Compare commits

...
31 Commits
Author SHA1 Message Date
horoli 5795cc9741 Optimize large battle rendering and combat 2026-05-27 18:18:20 +09:00
horoli 30d7be41be feat: implement dense-area meteor barrage, large-battle optimization, and direct fighter count input 2026-05-27 01:20:31 +09:00
horoli 2c013247a9 Scale meteor impacts by size 2026-05-26 18:26:34 +09:00
horoli 9df4f3dcde Refine battle performance and team limits 2026-05-26 17:54:41 +09:00
horoli 743b2a75f5 feat: implement nickname multiplier (*N) for variable team sizes and sudden death system with configurable world effects 2026-05-25 21:24:22 +09:00
horoli f9b548f9cd modified : spawn radius 2026-05-24 22:39:59 +09:00
horoli ddccd01eb6 feat: improve battle notice system with expanded content, centralized constants, and fixed text wrapping 2026-05-24 22:32:48 +09:00
horoli 1668e3c941 feat: implement world effects, role-based stats, and optimized starting zones with refactored constants 2026-05-24 19:16:51 +09:00
horoli 36fd25731a fix: mobile team card width and modularize CSS with documentation 2026-05-24 19:15:31 +09:00
horoli 43383bb833 update image 2026-05-24 12:36:55 +09:00
horoli 518767b21b feat: add OpenGraph and Twitter meta tags for social sharing 2026-05-24 12:32:03 +09:00
horoli 8d0de2a945 modified build scripts(2) 2026-05-24 07:17:16 +09:00
horoli 7e38e4d434 modified build scripts 2026-05-24 07:17:01 +09:00
horoli 93e407ae8e add : build script 2026-05-24 07:14:37 +09:00
horoli 2dd7d2dceb ui: refine Arena logo layout and update todo documentation 2026-05-24 06:56:45 +09:00
horoli 353dc6738b ui: optimize kill log with 30-entry limit and slim scrollbar 2026-05-23 13:50:36 +09:00
horoli 55132ed662 ui: reverse kill log order and enable scrolling 2026-05-23 13:48:36 +09:00
horoli f459585cec docs: update documentation for real-time privacy policy and remove server cache 2026-05-23 13:43:14 +09:00
horoli 41d3f4db7c fix: prioritize database content for privacy policy and update default markdown 2026-05-23 13:28:49 +09:00
horoli 62c5d94269 feat: optimize About button layout and enhance static file caching strategy 2026-05-23 12:51:33 +09:00
horoli 3e5c079e68 feat: About 다이얼로그 추가, UI 최적화 및 서버 캐싱 강화
- About 다이얼로그 추가 (개발자 정보 및 개인정보처리방침)
- Markdown 렌더러 구현 (Bold, Italic, Code, Blockquote 지원)
- 전투 화면 하단 'About' 및 방문자 카운팅 UI 재배치 및 디자인 통일
- 프로덕션 환경에서 정적 파일 강력 캐싱 설정 (7일 유지)
- 파비콘 404 오류 해결을 위한 이모지 데이터 URI 추가
- 모바일 전투 화면 레이아웃 최적화 및 승리 연출 개선
- 일일 운영 지표(Daily Metrics) 수집 API 및 로직 추가
2026-05-23 11:32:01 +09:00
horoli e4f542d487 Modularize documentation: split CONTEXT.md into functional modules and update agent.md router 2026-05-23 02:02:15 +09:00
horoli 4793680831 Update agent.md with new modular directory structure and UI components 2026-05-23 01:55:11 +09:00
horoli c9ce1a8afd Organize src/game into sub-folders: arena, combat, fighter, and match 2026-05-23 01:39:59 +09:00
horoli bb08d5cee1 Refactor ArenaScene.js: modularize match runtime, spectator camera, and UI components 2026-05-23 01:32:11 +09:00
horoli 25137cf26e Add critical hits, victory celebrations, and enhanced match settings 2026-05-23 01:20:04 +09:00
horoli a03dc02d01 Add final combat polish and saved settings 2026-05-22 18:00:23 +09:00
horoli 0720efe7ba update : gitignore 2026-05-22 17:07:56 +09:00
horoli a41e9b6f26 Add battle death stats and HUD updates 2026-05-22 17:01:01 +09:00
horoli be80cb17b0 Add kill heal effect animation 2026-05-22 13:27:19 +09:00
horoli 1509b0c5dd Add visitor tracking and combat selection features 2026-05-22 13:22:26 +09:00
71 changed files with 13920 additions and 2702 deletions
+3 -1
View File
@@ -1,4 +1,6 @@
node_modules/ node_modules/
dist/ dist/
.vite/ .vite/
config.json
package-lock.json
*.log
-44
View File
@@ -1,44 +0,0 @@
# Context: Arena Picker 개발 가이드
## 1. 모듈별 상세 역할
### [Core Engine]
- **`src/main.js`**: Phaser 게임의 전역 설정(Physics, Scale, Canvas Parent)을 담당하며, `ArenaScene`을 인스턴스화합니다.
- **`src/constants.js`**: 게임 내 모든 튜닝 수치를 관리합니다.
- `SPECTATOR_CAMERA_LERP`: 카메라 추적의 부드러움 정도.
- `MINIMAP_VIEWPORT_SIZE`: 미니맵의 고정 픽셀 크기.
- `ARENA_SIZE`: 경기장 전체 크기 (GRID * TILE).
### [Game Logic - src/game/]
- **`ArenaScene.js`**:
- `update()`: 매 프레임 생존 팀을 체크하고 스코어보드를 갱신합니다.
- `observeCombat()`: 캐릭터가 공격할 때 카메라가 주목할 "관전 대상"을 설정합니다.
- `updateMinimapViewportFrame()`: 주 카메라의 이동에 맞춰 미니맵 가이드 사각형을 렌더링합니다.
- **`matchSetup.js`**:
- 입력된 닉네임을 순회하여 `team` 객체를 생성하고, 요청된 인원만큼 캐릭터 데이터를 복제 배치합니다.
- **`combat.js`**:
- `updateFighter()`: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
- `projectilePathHitsDefender()`: 투사체가 대상을 스쳐 지나가지 않도록 궤적 검사를 수행합니다.
### [Assets & UI]
- **`fighterManifest.js`**: 20여 종의 캐릭터 스킨 정보가 담긴 딕셔너리입니다. `type` (melee/projectile/instant-spell)에 따라 전투 메커니즘이 결정됩니다.
- **`matchForm.js`**: `index.html`의 입력을 읽어 `ArenaScene`에 매치 구성을 전달합니다.
## 2. 주요 로직 구현 세부 사항
### 지능형 카메라 추적 (Lerp & Jittering 방지)
카메라가 소수점 단위의 평균 좌표를 즉시 따라가면 화면이 떨려 보일 수 있습니다. 이를 방지하기 위해:
1. 목표 좌표(`targetX, targetY`)를 `Math.round()`로 정수화합니다.
2. 현재 카메라 위치에서 목표 지점까지 매 프레임 `0.1`의 배율로 거리를 좁혀나가는 `Lerp` 연산을 수행합니다.
```javascript
this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP;
```
### 미니맵 가이드라인
미니맵은 전장 전체를 축소하여 보여주는 독립된 카메라입니다. 주 카메라가 비추는 영역을 계산하여 미니맵 위에 사각형(`graphics`)을 그려줍니다.
- `camera.displayWidth / zoom` 등을 이용하여 현재 월드에서 보이는 실제 영역 크기를 계산합니다.
## 3. 개발 및 유지보수 규칙
- **신규 캐릭터 추가**: `public/assets/characters/`에 에셋 배치 후 `fighterManifest.js`에 정의를 추가하면 즉시 게임에 반영됩니다.
- **물리 수치 조정**: 캐릭터의 속도나 사거리 등은 `src/constants.js` 또는 `fighterManifest.js` 내 개별 설정을 통해 변경하십시오.
- **DOM 접근**: 성능을 위해 `ArenaScene`은 상단 스코어보드 등 필요한 시점에만 최소한으로 DOM에 접근합니다.
+249 -40
View File
@@ -1,58 +1,267 @@
# Agent: Arena Picker # Agent: Arena Picker
## 0. 필수
- 작업이 완료되면 작업과 관련된 모든 문서를 함께 업데이트한다.
- 대규모 전투, LOD, 모델/렌더 분리, 전투 워커, 서버 API를 수정할 때는 관련 `context/` 문서를 먼저 확인하고 변경 내용을 문서에 반영한다.
## 1. 프로젝트 정의 ## 1. 프로젝트 정의
**Arena Picker**는 Phaser 3 게임 엔진과 Vite 번들러를 기반으로 구축된 **대규모 팀 전투 시뮬레이션 웹 애플리케이션**입니다. 사용자가 입력한 여러 명의 참가자(닉네임)를 바탕으로 각 참가자를 하나의 팀으로 설정하고, 지정된 인원만큼의 캐릭터를 생성하여 자동 전투를 시뮬레이션합니다. **Arena Picker**는 Phaser 3 게임 엔진과 Vite 번들러를 기반으로 구축된 **대규모 팀 전투 시뮬레이션 웹 애플리케이션**입니다. 사용자가 입력한 여러 참가자 닉네임을 각각 하나의 팀으로 설정하고, `닉네임*N` 형식으로 지정된 인원만큼 캐릭터를 생성해 자동 전투를 시뮬레이션합니다.
## 2. 프로젝트 전체 구조 (Directory Tree) 전장은 3200px 월드 크기를 유지하되 Phaser 내부 렌더 캔버스는 1280px로 낮춰 픽셀 작업량을 줄입니다. 일반 전투는 개별 Phaser Sprite와 Arcade Physics를 사용하고, 3,000명 이상 대규모 전투에서는 `FighterModel` 중심 시뮬레이션, rolling-window LOD, Web Worker 기반 후보 선정/집계 전투, HUD/이펙트 풀링을 결합해 8,000명급 전투를 처리합니다.
서버 런타임은 Fastify를 사용하며 MongoDB 커넥션 풀을 유지합니다. 방문자 수, 일일 운영 지표, 전투 사망 통계, About 콘텐츠를 API로 제공합니다.
## 2. 현재 아키텍처 핵심
### 2.1 FighterModel 기반 상태와 렌더 브리지
- `src/game/fighter/fighterModel.js`가 HP, 팀, 스킨, 타깃, 쿨다운, 사망/선택/성장/동결 상태, 모델 좌표를 보관하는 순수 JS 상태 객체를 만듭니다.
- `fighterFactory.js`는 실제 Phaser Sprite를 생성하고 `fighter.model` 브리지로 기존 `fighter.hp`, `fighter.team` 스타일 접근을 호환합니다.
- `fighterAdapter.js`는 위치, 거리, 방향, 이동, body enable/disable, 애니메이션, 동결 tint, arena clamp 등 Phaser Sprite 접근의 경계입니다. 전투/카메라/월드 이펙트 코드는 새로 직접 `body`, `setVelocity()`, 애니메이션 API를 만지기보다 adapter를 우선 사용합니다.
- `ArenaScene`은 `fighterModels`, `fighterByModelId`, `fighterModelById`를 함께 유지합니다. `fighterForModelId()`는 현재 attach된 렌더 Sprite만 반환할 수 있으므로, 전투 로직은 null 가능성을 항상 고려합니다.
### 2.2 대규모 전투 렌더 LOD
- 대규모 live match는 `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` 이상에서 render LOD를 활성화합니다.
- full-arena overview는 `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT`만큼 팀별 대표 Sprite를 유지하고, 나머지 생존자는 팀 색상 dot으로 표시합니다.
- zoomed, selected, spectator 시점은 rolling camera window 안의 모든 생존자를 detailed Sprite로 승격합니다. 이 경로는 더 이상 별도 zoom cap이나 buffer ratio에 묶이지 않습니다.
- `src/game/arena/fighterLodWorker.js`는 생존 fighter worker id, position, team key TypedArray를 받아 현재 match/job의 detailed id 목록만 반환합니다. Worker 실패 또는 오류 시 `resolveFighterLodDetailedSet()` 동기 경로로 fallback합니다.
- LOD 적용은 최초 활성화 때 full sync를 수행한 뒤, 이후에는 이전 detailed set과 다음 set의 차이만 attach/detach합니다.
- parked fighter는 display/update list에서 빠지고 Arcade World에서도 `world.disable()`로 제거됩니다. 재진입 시 `world.enable()` 후 모델 좌표로 body를 복구합니다.
- hidden-fighter dot redraw는 zoomed view에서 카메라 viewport와 padding 밖의 dot을 건너뛰어 `Graphics.fillRect()` 비용을 줄입니다.
### 2.3 대규모 전투 집계 시뮬레이션
- attached/detail fighter는 매 프레임 `updateFighterModel()`로 고정밀 개별 AI를 유지합니다.
- detached/offscreen fighter는 `team + cell + squad` 단위로 압축되어 coarse movement와 group DPS를 처리합니다. 기본 squad 크기는 `PERFORMANCE.LARGE_BATTLE_AGGREGATE_SQUAD_SIZE`가 제어합니다.
- `src/game/combat/aggregateCombatWorker.js`는 detached model id, position, HP, team key, 이동속도, DPS, frost flag를 Transferable TypedArray로 받아 집계 전투를 계산합니다.
- Phaser 상태 변경, death 처리, split-on-death, kill reward, scoreboard, match finish는 여전히 main thread가 소유합니다.
- Worker 결과는 match id가 일치하고 해당 model이 여전히 detached일 때만 적용합니다. 이미 attach된 fighter, 죽었거나 unregister된 stale id는 무시합니다.
- Worker 생성 실패 또는 오류 시 기존 동기 집계 전투 경로로 fallback합니다.
### 2.4 전투 및 이펙트 최적화
- target spatial index는 model 기반으로 구성하되, 대규모 전투에서는 attached/detail model 중심으로 갱신해 8,000명 전체 스캔을 줄입니다.
- stale `targetModelId`는 null-safe validation으로 정리합니다.
- instant-spell 공격 시각 효과는 texture별 sprite pool을 재사용합니다. `clearCombatObjects()`는 active pooled effect도 공통 cleanup 경로로 반환합니다.
- projectile hit detection은 projectile마다 Arcade overlap collider를 만들지 않고 line/rectangle path check와 scratch geometry를 재사용합니다.
- 대규모 전투에서 critical label, instant-spell sprite, kill-heal sprite, kill-growth tween 같은 보조 효과는 meteor camera focus 중일 때만 노출합니다. damage, heal, 성장 수치 자체는 유지됩니다.
- world effect는 랜덤 생존자 대신 생존자 밀집도가 가장 높은 tile square를 큰 경고 영역으로 잡고, 내부에 소형 화염/냉기 strike를 분산 투하합니다.
### 2.5 카메라, HUD, 서버 지표
- 대규모 live match 시작 시 full-arena 최저 줌 대신 평균 생존 위치에 가까운 fighter 주변으로 `CAMERA.LARGE_BATTLE_START_ZOOM`을 적용합니다.
- scoreboard 팀 버튼을 이미 선택된 팀에 다시 클릭하면 선택을 해제하고 full-arena view로 돌아갑니다.
- 수동 fighter/team focus와 full-arena return은 `transitionMainCameraTo()`의 Phaser `pan()`/`zoomTo()` tween을 사용합니다.
- HUD 체력바는 모든 fighter가 영구 소유하지 않고 pool에서 빌려 씁니다. selected fighter와 zoom-visible 후보만 slot을 보유하며, zoom HUD에는 fighter 이름을 표시하지 않습니다.
- live minimap은 별도 HUD camera와 `Graphics` dot overlay로 렌더링하며 `PERFORMANCE.MINIMAP_REFRESH_MS`로 redraw를 throttle합니다.
- 서버는 visitor, death stats, daily metrics, About 콘텐츠 API를 제공합니다.
## 3. 프로젝트 전체 구조 (Directory Tree)
```text ```text
├── index.html # 메인 HTML 진입점 및 UI 레이아웃 ├── index.html # 메인 HTML 진입점 및 UI 레이아웃
├── package.json # 프로젝트 의존성 및 스크립트 정의 (Phaser, Vite) ├── package.json # Phaser, Vite, Fastify, MongoDB 의존성 및 npm scripts
├── agent.md # 프로젝트 개요 및 기능 정의 (본 문서) ├── config.json.sample # 공유용 서버/MongoDB 설정 예시
├── CONTEXT.md # 상세 개발 가이드 및 로직 설명 ├── agent.md # 프로젝트 개요 및 에이전트 작업 가이드
├── todo.md # 작업 내역 및 잔여 이슈 관리 ├── todo.md # 작업 내역 및 잔여 이슈 관리
├── public/ # 정적 리소스 (게임 에셋) ├── build.sh # 배포/빌드 보조 스크립트
├── context/ # 상세 개발 가이드
│ ├── core.md # main.js, constants.js, 렌더/성능 상수, worker entrypoint
│ ├── arena.md # ArenaScene, camera, minimap, fighter render LOD
│ ├── combat.md # 전투 AI, model-only combat, aggregate combat, world effects
│ ├── fighter.md # FighterModel, adapter, factory, HUD pool, team-shadow texture
│ ├── match-ui.md # 매치 설정, spawn, HUD, kill log, victory UI
│ ├── server.md # Fastify, MongoDB, visitor/death/daily metrics/About API
│ ├── style.md # CSS 모듈, 디자인 변수, 반응형/애니메이션 규칙
│ └── refactor/
│ └── arena-scene-modularization-work-order.md
├── server/ # Fastify API 서버 및 MongoDB 연결 관리
│ ├── index.js # Fastify 진입점, Vite dev middleware, 정적 배포 서빙
│ ├── config.js # config.json 로드 및 MongoDB URI/컬렉션 설정
│ ├── db.js # MongoClient 커넥션 풀 생성/재사용/종료
│ ├── visitorCookie.js # 방문자 UUID 쿠키 읽기/쓰기/검증
│ ├── visitors.js # 유니크 방문자 체크 및 통계 API
│ ├── dailyMetrics.js # 일일 방문/전투 시작/전투 종료/후원 클릭 지표 API
│ ├── deathStats.js # 종족별 전투 사망 통계 API
│ └── about.js # About 개발자정보/개인정보처리방침 seed 및 조회 API
├── public/ # 정적 리소스
│ └── assets/ │ └── assets/
│ └── characters/ # 20종 이상의 캐릭터 스킨 및 투사체 에셋 │ ├── og-image.png # 공유 미리보기 이미지
│ ├── archer/, armored-axeman/, armored-orc/, ... (중략) │ ├── effects/
│ └── wizard/ # 각 폴더 내 애니메이션 시트 및 이펙트 포함 │ │ ├── heal/ # 처치 회복 연출
└── src/ # 소스 코드 root │ │ ├── world_Effect.png
├── main.js # Phaser 게임 인스턴스 생성 및 초기화 │ │ └── world_Effect_2.png
├── constants.js # 전역 물리/UI 상수 통합 관리 (줌, 카메라 속도 등) │ └── characters/ # 20종 이상 캐릭터 스킨/투사체/마법 이펙트 에셋
├── styles.css # UI 스타일링 (스코어보드, 승리 배너 애니메이션) │ ├── archer/
├── game/ # 게임 로직 모듈 │ ├── armored-axeman/
│ ├── ArenaScene.js # 핵심 게임 씬 (카메라 추적, 승리 판정, 스코어보드 제어) │ ├── armored-orc/
│ ├── arenaRenderer.js# 경기장 바닥 및 격자 렌더링 │ ├── priest/
│ ├── combat.js # 전투 AI 및 피격 판정 로직 │ ├── wizard/
│ ├── combatSettings.js# 전투 속도 및 이동 배율 관리 │ └── ... # knight, orc, skeleton, slime, wolf, bear 계열 등
│ ├── fighterAssets.js# 스프라이트 시트 로드 및 애니메이션 생성 └── src/ # 프론트엔드 소스 root
│ ├── fighterFactory.js# 캐릭터 객체 및 HUD 생성 ├── main.js # Phaser game config, 앱 상태, 옵션 drawer, 방문자 추적
│ ├── fighterManifest.js# 캐릭터 스킨 데이터 정의 (20종 캐릭터 상세 설정) ├── constants.js # 렌더/전장/전투/카메라/성능/월드 이펙트 상수
│ ├── fighterSelection.js# 무작위 캐릭터 스킨 선택 로직 ├── styles.css # CSS 모듈 통합 엔트리
│ └── matchSetup.js # 닉네임 기반 팀 구성 및 스폰 좌표 계산 ├── styles/
│ ├── base.css # 전역 변수, reset, 기본 레이아웃
│ ├── intro.css # 대기 화면 및 프리뷰 스타일
│ ├── game-ui.css # scoreboard, kill log, battle notice, victory layer
│ ├── overlay.css # option drawer, About dialog, form controls
│ ├── animations.css # 공통 keyframes/animation utilities
│ └── mobile.css # 960px 이하 반응형 override
├── game/
│ ├── arena/
│ │ ├── ArenaScene.js # 메인 Phaser Scene orchestrator
│ │ ├── arenaRenderer.js # 전장 바닥, grid, starting zone 렌더링
│ │ ├── arenaSpectatorCamera.js # 자동/수동 카메라 포커싱
│ │ └── fighterLodWorker.js # 대규모 전투 detailed sprite 후보 worker
│ ├── combat/
│ │ ├── combat.js # model 기반 전투 AI, 타깃, 피해, 처치 처리
│ │ ├── aggregateCombatWorker.js# detached/offscreen 집계 전투 worker
│ │ ├── combatSettings.js # 전투 속도 및 이동 배율 설정
│ │ ├── arenaFinalCombatEffects.js
│ │ └── worldEffects.js # 밀집 구역 메테오/냉기/감속/동결 효과
│ ├── fighter/
│ │ ├── fighterModel.js # 순수 JS fighter 상태 모델
│ │ ├── fighterAdapter.js # Phaser Sprite/Physics 접근 경계
│ │ ├── fighterAssets.js # sprite load, team-shadow texture/animation 생성
│ │ ├── fighterFactory.js # Sprite 생성, model bridge, HUD pool, detail visibility
│ │ ├── fighterManifest.js # 캐릭터 스탯/종족/특성 정의
│ │ ├── fighterStats.js # melee/ranged/magic 프로필 해석
│ │ └── fighterSelection.js # 캐릭터 선택/셔플 로직
│ └── match/
│ ├── matchSetup.js # `닉네임*N` 파싱, 팀 구성, spawn 좌표 계산
│ └── arenaMatchRuntime.js # match 진행 중 helper
└── ui/ └── ui/
└── matchForm.js # 참가자 입력 폼 및 팀 설정 UI 제어 ├── matchForm.js # 설정 폼 및 localStorage 유지
├── aboutDialog.js # About dialog 및 Markdown 표시
├── visitorCounter.js # 방문자 API 호출/표시
├── dailyMetrics.js # 일일 지표 API 호출
├── deathStats.js # 사망 통계 API 호출
├── arenaScoreboard.js # 팀 badge 및 선택 상태
├── arenaKillLog.js # kill log DOM
├── battleDeathNotice.js# 상단 사망/통계 안내
└── victoryCelebration.js
``` ```
## 3. 핵심 기능 로컬/생성 파일인 `config.json`, `node_modules/`, `dist/`, `.vite/`, `package-lock.json`, `*.log`는 `.gitignore` 대상입니다.
- **닉네임 기반 팀 스폰**: 입력된 각 닉네임을 독립된 팀으로 인식하고, 설정된 인원(1~100명)만큼 분신 캐릭터를 소환합니다. ## 4. 상세 기술 가이드 (Context Routing)
- **지능형 카메라 시스템**:
- **자동 전투 관전**: 화면 확대 시 인접한 교전 중인 캐릭터 쌍을 찾아 부드럽게 추적(Lerp)합니다.
- **미니맵 연동**: 줌 인 상태에서 전장 전체 상황과 현재 뷰포트 위치를 미니맵에 가이드라인으로 표시합니다.
- **역동적인 전투 연출**:
- 캐릭터별 고유 공격 방식(근접, 투사체, 마법) 및 애니메이션.
- 치명타(Critical) 발생 시 화면 흔들림 효과 및 대미지 가중치 적용.
- **실시간 경기 중계 UI**: 상단 좌/우 영역에 팀별 현재 생존 인원을 실시간으로 표시하며, 승리 시 대형 배너로 결과를 알립니다.
## 4. 기술 사양 토큰 절약 및 효율적인 정보 조회를 위해 상세 로직은 기능별 문서로 분리되어 있습니다. 특정 모듈 작업 시 아래 문서를 먼저 읽으십시오.
- **[인프라 및 전역 설정](./context/core.md)**: `main.js`, `constants.js`, 렌더 크기, `PERFORMANCE`, worker entrypoint, 공통 유지보수 규칙.
- **[아레나 및 카메라](./context/arena.md)**: `ArenaScene`, rolling-window LOD, `fighterLodWorker.js`, minimap, spectator/manual camera.
- **[전투 엔진](./context/combat.md)**: `combat.js`, model-only combat fallback, target spatial index, `aggregateCombatWorker.js`, world effects.
- **[캐릭터 및 에셋](./context/fighter.md)**: `FighterModel`, `fighterAdapter.js`, sprite attach/detach, HUD pool, team-shadow texture.
- **[매치 로직 및 UI](./context/match-ui.md)**: `닉네임*N` 팀 인원, spawn zone, scoreboard, kill log, victory UI, 모바일 레이아웃.
- **[서버 및 API](./context/server.md)**: Fastify, MongoDB, visitor cookie, daily metrics, death stats, About 콘텐츠.
- **[스타일 및 디자인](./context/style.md)**: CSS 모듈 구조, 디자인 변수, 반응형 및 애니메이션 가이드.
## 5. 주요 기능 상세
### 5.1 매치 입력과 스폰
- live match 참가자는 `닉네임*N` 형식으로 팀별 배정 인원을 직접 지정합니다. 접미사가 없으면 1명입니다.
- `SPAWN.MAX_FIGHTER_COUNT`는 참가자 입력으로 배정되는 fighter 수의 상한입니다. Slime의 `spawnMultiplier`, `splitOnDeath` 같은 특성 기반 추가 생성은 이 입력 상한에 포함하지 않습니다.
- starting-zone placement는 `SPAWN.FIGHTERS_PER_STARTING_ZONE`마다 팀 영역을 추가로 배정해 대규모 팀이 한 점에 뭉치지 않도록 분산합니다.
- match-start validation은 요청 인원과 허용 인원을 분리해 사용자에게 경고 카드로 보여줍니다.
### 5.2 대규모 전투 흐름
- match 시작 시 `ArenaScene`은 live fighter 수가 threshold 이상인지 판단하고 large-battle 모드로 들어갑니다.
- 첫 화면은 full-arena overview가 아니라 living fighter 평균 위치에 가까운 fighter 주변으로 zoom합니다.
- 최초 LOD sync 후 `fighterLodWorker.js` 또는 동기 resolver가 현재 카메라 상태에 맞는 detailed set을 계산합니다.
- full overview는 대표 Sprite와 dot field를 유지합니다. focused view는 rolling window 안의 모든 생존자를 detail Sprite로 복구합니다.
- offscreen/detached model은 집계 squad combat으로 이동/피해/사망을 처리하고, 카메라에 다시 들어오면 model position에서 Sprite를 재attach합니다.
### 5.3 모델/렌더 생명주기
- `createFighter()`는 항상 실제 Phaser Sprite를 만들고 `FighterModel`을 붙입니다. 과거 lazy `SpriteProxy` 실험은 rollback되었습니다.
- `attachSprite: false`는 Sprite 생성을 건너뛰는 뜻이 아니라, 생성 직후 `setFighterDetailVisible(false)`로 parking한다는 뜻입니다.
- parking된 fighter는 render/update/physics traversal에서 빠지지만 model state는 계속 살아 있습니다.
- model-only death는 model을 inactive/unregister 처리하고 parked fighter entry를 제거합니다.
- animation helper는 실제 renderable fighter가 없으면 action key resolution/playback을 건너뜁니다.
### 5.4 전투, 효과, 월드 이벤트
- `updateFighterModel()`은 Sprite가 있으면 기존 Arcade/animation path를 사용하고, Sprite가 없으면 model 좌표/HP/쿨다운 기반으로 이동과 공격을 진행합니다.
- ranged/magic 공격은 양쪽 Sprite가 모두 있으면 visual projectile/spell path를 사용합니다. detached 참여자가 있으면 같은 windup/travel/hit delay를 model hit로 해석합니다.
- kill reward, split-on-death, death stats, scoreboard, match finish는 Sprite 유무와 무관하게 기존 authoritative path를 사용합니다.
- dense-area meteor barrage는 큰 경고 범위를 먼저 표시한 뒤 내부 소형 strike에만 피해/동결/감속을 적용합니다.
- sudden death는 설정 시간 이후 meteor 주기를 단축하고 필요 시 frost meteor를 강제해 장기전을 방지합니다.
### 5.5 카메라와 HUD
- `transitionMainCameraTo()`는 수동 focus 이동에 Phaser `pan()`/`zoomTo()`를 적용합니다.
- selected fighter auto-centering은 수동 tween 중에는 기다려 tween을 취소하지 않습니다.
- scoreboard에서 선택된 팀을 다시 클릭하면 selection/focus/meteor focus를 정리하고 full-arena view로 돌아갑니다.
- minimap은 field camera와 분리된 HUD camera로 고정 표시하며, main camera viewport rectangle과 team-colored dot을 그립니다.
- fighter HUD는 pool 기반입니다. selected/zoom-visible 후보만 health bar를 빌려 쓰고, hidden LOD fighter는 HUD slot과 pointer input을 해제합니다.
### 5.6 서버/API와 지표
- 방문자 체크는 `arena_visitor_id` HttpOnly 쿠키와 MongoDB `visitors` 컬렉션을 사용합니다.
- daily metrics는 앱 방문, 실제 전투 시작, 실제 전투 종료, 후원 클릭 예약 지표를 날짜별 합산 문서로 저장합니다.
- death stats는 프리뷰가 아닌 실제 전투 종료 시 종족별 사망 수를 오늘 일자 문서에 누적합니다.
- About 콘텐츠는 DB의 Markdown을 실시간 조회하며 서버 메모리 캐시를 두지 않습니다.
## 6. 기술 사양 및 튜닝 포인트
- **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
- **UI Logic**: Vanilla JS & CSS (Flexbox/Grid 활용) - **Server**: Fastify 5.x (`@fastify/static`, `@fastify/middie`)
- **Database**: MongoDB 7.x Node Driver
- **UI Logic**: Vanilla JS & CSS
- **Render**: `RENDER.WIDTH/HEIGHT = 1280`, `ARENA.SIZE = 3200`, `CAMERA.MIN_ZOOM = RENDER_SIZE / ARENA_SIZE`
- **Large Battle**: `PERFORMANCE.LARGE_BATTLE_*` 상수에서 threshold, simulation buckets, aggregate refresh/cell/squad/death cap, target index refresh, HUD limit, Sprite budget, rolling window, dot redraw를 조정합니다.
- **World Effect**: `WORLD_EFFECT.*`에서 첫/반복 포격, 밀집 경고 범위, 소형 strike 범위/개수/간격/시각 배율, meteor shake, fire/frost damage, frost stun/slow를 조정합니다.
- **Camera**: `CAMERA.LARGE_BATTLE_START_ZOOM`, `CAMERA.MANUAL_FOCUS_TWEEN_MS`, `CAMERA.MANUAL_FOCUS_TWEEN_EASE`, meteor focus, spectator thresholds를 조정합니다.
- **Fighter**: `FIGHTER.DEAD_DESPAWN_DELAY_MS`, `FIGHTER.DEAD_DESPAWN_ALPHA`, `FIGHTER_TYPE_STATS`, kill growth 상수를 조정합니다.
- **Worker fallback**: LOD/aggregate worker는 성능 최적화 경로이며, 실패 시 main-thread 동기 path가 계속 동작해야 합니다.
## 5. 관련 문서 ## 7. 서버/API 설정
- [CONTEXT.md](./CONTEXT.md): 상세 개발 가이드 및 핵심 로직 설명 (필독) - 개발 서버는 `npm run dev`, 운영 서버는 `npm start`, 정적 빌드는 `npm run build`로 실행합니다.
- [todo.md](./todo.md): 작업 내역 및 잔여 이슈 관리 - 기본 포트는 `config.json`의 `SERVER_PORT` 값이며 샘플은 `9736`입니다.
- `config.json`은 로컬 설정 파일이므로 저장소에 커밋하지 않습니다. 새 환경에서는 `config.json.sample`을 복사해 사용합니다.
기본 API:
- `GET /api/health`: 서버 및 MongoDB 설정 여부 확인.
- `POST /api/visitors/check`: 방문자 UUID 쿠키 확인/발급 및 유니크 방문자 수 반환.
- `GET /api/visitors/stats`: 전체 유니크 방문자 수 조회.
- `GET /api/daily-metrics/today`: 오늘의 운영 지표 조회.
- `POST /api/daily-metrics/match-started`: 실제 전투 시작 수 누적.
- `POST /api/daily-metrics/match-finished`: 실제 전투 종료 수 누적.
- `POST /api/daily-metrics/donation-clicked`: 후원 클릭 수 누적용 예약 API.
- `GET /api/death-stats/today`: 오늘의 종족별 전투 사망 통계 조회.
- `POST /api/death-stats/today`: 종료된 실제 전투의 종족별 사망 수 누적.
- `GET /api/about`: 개발자정보와 개인정보처리방침 Markdown 조회.
## 8. 유지보수 규칙
- **문서 동기화**: 구조, 상수, API, 대규모 전투 path가 바뀌면 `agent.md`와 관련 `context/*.md`를 함께 갱신합니다.
- **모듈 경계**: `ArenaScene`은 orchestration을 맡고, fighter 상태/렌더 세부는 `fighter/`, 전투 판정은 `combat/`, match input/spawn은 `match/`, DOM UI는 `ui/`로 분리합니다.
- **Fighter 접근**: 새 코드가 fighter body, animation, tint, velocity, position을 직접 다뤄야 한다면 먼저 `fighterAdapter.js`에 적절한 helper가 있는지 확인합니다.
- **Model-first 안전성**: `fighterForModelId()`는 null을 반환할 수 있습니다. model-only 전투, stale id, death/unregister 이후 상태를 항상 고려합니다.
- **대규모 전투 성능**: 8,000명급 경로에서는 전체 fighter 배열을 매 프레임 스캔하거나 DOM/HUD/Graphics를 전원 갱신하지 않습니다. throttle, pool, worker, spatial index, set diff를 우선 사용합니다.
- **Phaser lifecycle**: parked Sprite는 display/update list와 Arcade World에서 모두 빠져야 하며, reattach 시 model 좌표와 body를 동기화합니다.
- **이펙트 lifecycle**: pooled combat object는 `releaseToPool`/`disposeCombatObject()` 경로로 정리합니다. 새 이펙트도 match reset과 scene cleanup에서 누수되지 않아야 합니다.
- **API 변경**: `/api/*` 경로는 Fastify route가 담당합니다. 개발 모드에서 Vite SPA fallback이 API 요청을 가로채지 않게 유지합니다.
- **신규 캐릭터**: `public/assets/characters/`에 에셋을 배치하고 `fighterManifest.js`에 `species`와 combat/stat 정의를 추가합니다. 사망 통계 종족은 `human`, `orc`, `skeleton`, `slime`, `wolf`, `bear` 중 하나를 사용합니다.
- **스타일 변경**: `src/styles.css`는 모듈 import 엔트리입니다. 실제 수정은 `src/styles/*.css`의 해당 영역에서 진행합니다.
## 9. 관련 문서
- [context/core.md](./context/core.md): 전역 설정, 성능 상수, 렌더/worker 가이드.
- [context/arena.md](./context/arena.md): 아레나 씬, 카메라, minimap, render LOD.
- [context/combat.md](./context/combat.md): 전투 AI, 집계 전투, projectile/world effect.
- [context/fighter.md](./context/fighter.md): FighterModel, adapter, factory, assets.
- [context/match-ui.md](./context/match-ui.md): 매치 입력, spawn, HUD, 모바일 UI.
- [context/server.md](./context/server.md): Fastify/MongoDB API.
- [context/style.md](./context/style.md): CSS 모듈 및 디자인 규칙.
- [todo.md](./todo.md): 작업 내역 및 잔여 이슈.
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
git pull origin master
npm run build
pm2 restart arena
+20
View File
@@ -0,0 +1,20 @@
{
"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_ABOUT_COLLECTION": "about_content",
"MONGODB_DAILY_DEATH_COLLECTION": "daily_death_stats",
"MONGODB_DAILY_METRICS_COLLECTION": "daily_metrics",
"MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION": "daily_visitor_activity",
"MONGODB_MAX_POOL_SIZE": 10,
"MONGODB_SERVER_SELECTION_TIMEOUT_MS": 5000,
"DEATH_STATS_TIME_ZONE": "Asia/Seoul",
"ANALYTICS_TIME_ZONE": "Asia/Seoul",
"DAILY_ACTIVITY_RETENTION_DAYS": 60,
"COOKIE_SECURE": false
}
+173
View File
@@ -0,0 +1,173 @@
# Update: Fighter LOD Worker
- `ArenaScene` now starts a dedicated `fighterLodWorker.js` job for recurring large-battle LOD candidate selection after the initial forced LOD sync.
- The worker returns detailed fighter worker ids for either full-arena representatives or all living fighters inside the focused rolling window.
- `ArenaScene` maps those ids back to current fighter sprites and then reuses `applyFighterLodDetailedSet()` for the actual Phaser attach/detach work.
- Worker errors disable the async path and keep the synchronous LOD resolver as a fallback.
# Update: Full Rolling-Window Detail Sprites
- Zoomed, selected, and spectator large-battle views now promote every living fighter inside the rolling camera window to a detailed Phaser sprite.
- Full-arena overview remains bounded by the representative sprite budget, so the all-map 8,000-fighter view still stays lightweight.
- `addCameraFighterDetails()` no longer receives a detail cap; it adds exact viewport candidates first, then all remaining rolling-window candidates.
- Fighters outside the rolling window stay detached and continue to render as LOD dots.
# Update: Async Aggregate Result Safety
- Worker aggregate results are applied only to models that remain detached from `fighterByModelId`; if the camera has promoted a fighter to detailed sprite while a worker job is in flight, that result is ignored for the promoted model.
- Match id checks discard stale worker results after match reset, keeping async aggregate ticks from mutating a new match.
# Update: LOD Diff And Dot Frustum Culling
- Rolling-window LOD now does a one-time full sprite visibility sync when large-battle LOD first activates, then applies only the delta between the previous and next detailed fighter sets on later refreshes.
- Destroyed fighters are removed from `fighterLodDetailedSet`, and stale no-scene references are skipped during the diff pass before Phaser input/body APIs are touched.
- Hidden-fighter dot redraws still use model positions, but skip fighters outside the main camera world view plus `PERFORMANCE.LARGE_BATTLE_SPRITE_VIEW_PADDING`, avoiding offscreen `Graphics.fillRect()` calls during zoomed views.
- Full-arena overview still draws the arena-wide dot field because the main camera view covers the full battlefield at `CAMERA.MIN_ZOOM`.
# Update: Squad Materialization Bridge
- The arena still keeps individual fighter models for rendering, selection, minimap dots, and deterministic re-entry, but offscreen movement/combat is now driven by squad centers.
- Aggregate ticks reslot squad members around their squad center, allowing rolling-window LOD to reattach sprites from plausible positions when the camera approaches.
- Visible attached fighters remain the high-fidelity path; offscreen detached fighters no longer consume individual AI buckets while squad aggregation is active.
# Update: Aggregate Detached Simulation Path
- During large live battles, `ArenaScene.updateFighterModels()` now calls `updateAggregateDetachedCombat()` before per-model updates.
- If aggregate combat is active, only attached/detail fighters continue through full `updateFighterModel()` every frame; detached fighters are skipped by the individual simulation buckets.
- This keeps the rolling-window camera area high-fidelity while offscreen fighters continue moving, taking damage, dying, splitting, and changing match outcome through model data.
- If aggregate combat finishes the match during a batch, `updateFighterModels()` exits immediately so no stale attached updates run after `finishMatch()`.
# Update: Large Battle Simulation Throttle
- `ArenaScene.updateFighterModels()` now keeps attached/detailed render models on every-frame updates, but distributes detached model-only fighters across simulation buckets during large live matches.
- `PERFORMANCE.LARGE_BATTLE_SIMULATION_BUCKETS` controls the bucket count and `LARGE_BATTLE_SIMULATION_MAX_DELTA_MS` caps the accumulated delta passed to a skipped detached model.
- The detailed sprite cap was reduced aggressively for 8,000-fighter battles, and rolling-window detail budgeting now accepts ratios below `1` so dense zoomed views do not promote hundreds-to-thousands of sprites at once.
- Large-battle fighter HUD health bars use `PERFORMANCE.LARGE_BATTLE_HUD_VISIBLE_LIMIT` instead of the normal HUD limit.
# Update: Fighter Sprite Render Recovery
- `startMatch()` still passes `attachSprite: false` for large live matches, but `createFighter()` now creates a real Phaser Sprite and immediately parks it instead of returning a pure proxy.
- This restores visible rendering for normal matches and keeps rolling-window LOD's display/update-list detach path for large battles.
- `spawnSplitFighters()` follows the same rule during active large-battle LOD: split children are registered with models and can be parked until LOD promotion.
- Input events now work directly with Phaser sprites again; `_fighterProxy` fallback remains harmless for any future proxy experiment.
# Update: Render Sprite Detach In Rolling LOD
- `applyFighterLodDetailedSet()` treats the detailed set as the list of fighter sprites that should be attached to Phaser for this camera window.
- Non-detailed living fighters call `setFighterDetailVisible(false)`, which parks the sprite outside the display/update lists and removes its `fighterByModelId` mapping while keeping the model registered.
- Detailed fighters are reattached with `ensureFighterSpriteAttached()` / `setFighterSpriteAttached()`, so team-button selection can force a detached sprite back before the camera transition and HUD sync.
- `removeDetachedFighterProxyForModel()` still removes parked fighter entries after model-only death so dead detached entries do not remain in large-battle scan arrays.
- LOD candidate collection and minimap dots intentionally scan `this.fighters` instead of `combatTargetIndex.livingFighters`, because the combat index's sprite list may contain only currently attached render sprites.
# Update: Fighter Model Indexes
- `ArenaScene` now keeps `fighterModels`, `fighterByModelId`, and `fighterModelById` in sync with the sprite list.
- New fighters are registered when a match starts or split-on-death children spawn; despawned or model-only dead fighters are unregistered and their models are marked inactive.
- `fighterModelForId()` covers all living models, while `fighterForModelId()` now returns only currently attached render sprites.
- `unregisterFighterModel()` supports model-only cleanup paths that do not have an attached Phaser sprite.
# Update: FighterModel Use In Arena LOD
- Split-on-death spawn origins now use `fighterModelPoint(source)` so dormant parents spawn children from their simulation position.
- Rolling-window LOD candidate collection, dot drawing, and minimap fighter dots now read model `x/y` through `fighterModelPoint()` instead of direct sprite coordinates.
- This keeps the large-battle camera/render UI aligned with the simulation model while offscreen render sprites are detached.
# Update: Fighter Adapter Use In Arena
- `ArenaScene.finishMatch()` stops fighters through `fighterAdapter.stopFighterMovement()` instead of directly touching Arcade bodies.
- `arenaSpectatorCamera.js` uses `fighterWorldPoint()` and `fighterDistanceSquared()` so spectator targets, observed combat centers, and closest-pair lookup remain correct when rolling-window LOD has disabled offscreen fighter bodies.
- Camera/focus code should continue using `fighterCameraPoint()` or adapter position helpers instead of reading `fighter.body.center` directly.
# Update: Rolling Window Fighter LOD
- `collectCameraFighterDetails()` now builds two candidate lists from a camera-centered rolling window: exact viewport candidates and rolling-window candidates.
- The rolling window is larger than the visible camera view, using `PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_SCALE` plus `LARGE_BATTLE_SPRITE_VIEW_PADDING` as a minimum expansion.
- Nearby soon-to-enter fighters remain detailed sprites instead of dots because the focused-camera path now consumes the full rolling-window candidate list.
- `addCameraFighterDetails()` still fills exact viewport candidates first, then rolling-window candidates, preserving visible fidelity during camera movement.
- Fighters outside the detailed set become dormant through `setFighterDetailVisible(false)`, reducing animation/body work while keeping combat simulation active.
# Update: Manual Camera Pan/Zoom Tween
- `ArenaScene.transitionMainCameraTo()` wraps Phaser camera `pan()` and `zoomTo()` for short manual focus transitions.
- `selectFighter()` uses the transition helper for scoreboard/team/fighter focus instead of an instant `setZoom()` plus `centerOn()`.
- `returnToFullArenaView()` uses the same helper to move back to arena center at `CAMERA.MIN_ZOOM`.
- `focusSelectedFighter()` skips immediate recentering while the camera pan/zoom effect is active, preventing the selected-fighter follow path from cancelling the transition.
# Update: Large Battle Start Camera
- `startMatch()` now calls `focusLargeBattleStartCamera()` after creating live fighters and before the initial LOD sync.
- Large live matches start at `CAMERA.LARGE_BATTLE_START_ZOOM` centered on the living fighter nearest to the living population average, so the first view is a readable local battle view instead of the full minimap-like arena.
- The start camera does not mark a fighter selected; scoreboard team toggle and manual team selection keep their existing behavior.
# Update: Team Button Toggle To Full Arena
- `selectRandomTeamFighter()` treats a scoreboard click on the already selected team as a toggle-off action instead of choosing another random fighter from that team.
- `returnToFullArenaView()` clears selection/focus state, sets `CAMERA.MIN_ZOOM`, centers on the arena, refreshes the minimap, and updates the scoreboard so the focused team style is removed.
# Update: Dynamic Zoomed Fighter LOD
- Zoomed large-battle LOD now separates exact camera-visible fighters from rolling-window fighters.
- Focused large-battle LOD promotes the selected fighter plus all living fighters inside the rolling window, reducing the awkward mix of detailed sprites and dots inside the player's current view.
- `addCameraFighterDetails()` always consumes exact viewport candidates before rolling-window candidates.
- Full-arena `CAMERA.MIN_ZOOM` overview keeps the lower representative budget so the expensive case remains protected.
# Update: Large Battle Fighter Render LOD
- `ArenaScene` owns the large-battle fighter render LOD pass through `syncFighterRenderLod()`, `resolveFighterLodDetailedSet()`, and `drawFighterLodDots()`.
- The LOD pass activates only for live matches above `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD`; presentation mode and finished matches restore normal fighter visibility.
- Full-arena overview keeps a bounded set of representative sprites from each team, while zoomed or selected-camera views keep the selected fighter plus camera-near fighters.
- Hidden living fighters are still present in `this.fighters` with model state, but their Phaser sprite is removed from the display/update lists and they are drawn as team-colored dots on a shared `Graphics` object.
- HUD candidate selection ignores hidden fighters, and match finish disables LOD before post-match handling.
# Update: Full-Arena Camera At Lower Render Resolution
- The Phaser canvas resolution is no longer tied to `ARENA.SIZE`; `CAMERA.MIN_ZOOM` is below `1` so the main camera can still frame the full 3200px arena inside the smaller render canvas.
- Existing team click, selected fighter, meteor focus, and final-combat camera zooms remain absolute zoom targets above that full-arena minimum.
# Update: Minimap Redraw Throttle
- `ArenaScene.updateMinimap()` accepts a forced refresh flag and otherwise redraws no more often than `PERFORMANCE.MINIMAP_REFRESH_MS`.
- Match setup and camera zoom changes force an immediate minimap refresh, while routine scene updates share the throttled path to reduce `Graphics` redraw work in large battles.
# Update: Graphics Minimap And HUD Candidates
- The minimap is drawn during live matches by `ArenaScene` as a lightweight `Graphics` overlay through a dedicated `minimap-hud` camera instead of reusing the field camera. Presentation/waiting mode hides it.
- The main camera ignores the minimap graphics object, and the HUD camera ignores field objects as they are added to the scene. Minimap rendering uses team-colored living fighter dots and the main camera viewport rectangle, so the minimap frame stays fixed while the main camera follows combat or meteor focus.
- `ArenaScene` refreshes HUD candidates on an interval, choosing selected fighters plus nearby visible fighters when zoomed in, then releases unused HUD pool slots.
# Context: Arena & Scene
## 1. 모듈별 상세 역할 (`src/game/arena/`)
- **`ArenaScene.js`**: Phaser 씬의 생명주기와 전반적인 오케스트레이션을 담당합니다. `update()` 매 프레임마다 전투원 상태를 체크하고, 카메라 이동 및 UI 모듈 호출을 조율합니다.
- **`arenaRenderer.js`**: 아레나 배경 그래픽, 타일 및 팀별 스타팅 영역 오버레이 렌더링을 담당합니다.
- **`arenaSpectatorCamera.js`**: 관전 모드 시점 계산 및 카메라 포커싱 로직을 담당합니다. 생존 인원에 따른 지능형 카메라 추적 알고리즘이 구현되어 있습니다.
## 2. 주요 로직 구현 세부 사항
### 지능형 카메라 추적 (Lerp & Jittering 방지)
카메라가 소수점 단위의 평균 좌표를 즉시 따라가면 화면이 떨려 보일 수 있습니다. 이를 방지하기 위해:
1. 목표 좌표(`targetX, targetY`)를 `Math.round()`로 정수화합니다.
2. 현재 카메라 위치에서 목표 지점까지 매 프레임 `0.1`의 배율로 거리를 좁혀나가는 `Lerp` 연산을 수행합니다.
```javascript
this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * CAMERA.SPECTATOR_LERP;
```
자동 관전은 월드 이펙트 임시 시점, 후반 진입과 최종교전 세부 포커싱으로 나뉩니다.
- **메테오 임시 포커싱**: 자동 관전 진입 전 화염 또는 냉기 포격이 시작되면 가장 밀집한 큰 경고 구역의 중심을 임시로 확대 추적합니다. 큰 경고 표시 자체는 `WORLD_EFFECT.WARNING_DURATION_MS` 이후 사라지며, 카메라는 내부 소형 탄착들이 종료된 뒤 `CAMERA.METEOR_FOCUS_HOLD_DURATION`만큼 유지한 다음 이전 시점으로 복귀합니다. `CAMERA.METEOR_FOCUS_ENABLED`를 `false`로 설정하면 끌 수 있으며, 수동 선택 시점과 아래 자동 관전 시점이 우선합니다.
- **후반 자동 관전 진입**: 생존 캐릭터가 30명 미만(`CAMERA.SPECTATOR_LATE_FIGHTER_THRESHOLD`)이 되면 교전 중심(가장 가까운 적 대항쌍)을 포커싱하는 후반 줌을 적용합니다. (2팀만 남았더라도 인원이 많으면 어지러움을 방지하기 위해 자동 관전으로 바로 진입하지 않습니다.)
- **생존 4명 이하**: `CAMERA.SPECTATOR_RANDOM_FOCUS_INTERVAL`마다 생존 캐릭터 중 한 명을 무작위로 포커싱합니다.
- **2팀 잔여 & 합계 8명 이하**: 더 적은 생존 수를 가진 팀의 중앙을 포커싱하며, 동률이면 기존 교전쌍 중심 포커싱으로 되돌아갑니다.
### 미니맵 가이드라인
미니맵은 전장 전체를 축소하여 보여주는 독립된 카메라입니다. 주 카메라가 비추는 영역을 계산하여 미니맵 위에 사각형(`graphics`)을 그려줍니다.
- `camera.displayWidth / zoom` 등을 이용하여 현재 월드에서 보이는 실제 영역 크기를 계산합니다.
- 뷰포트 사각형 좌표는 미니맵 픽셀 격자에 맞춰 반올림하고, 외곽 stroke가 겹쳐 검게 깨지지 않도록 노란 내부 선을 채운 직사각형으로 렌더링합니다.
### 스타팅 영역 오버레이
`스타팅 지점 배치` 매치에서는 `matchSetup.js`가 전장 그리드에서 팀별 중심 셀을 무작위로 뽑아 만든 영역을 `ArenaScene`이 `arenaRenderer.js`에 전달합니다. 렌더러는 각 팀 색상을 낮은 투명도로 채우고 얇게 둘러 실제 스폰 후보 영역을 표시하며, 이 오버레이는 매치 시작 후 5초 동안만 보입니다. 숨김 예약은 Phaser 씬 타이머를 사용하므로 일시정지 시간은 표시 시간에 포함되지 않고, 새 매치가 시작되면 이전 예약을 취소합니다.
### 씬 상태 관리
- **프리뷰 모드 (`presentationMode`)**: 최초 로드 시 조용히 실행되는 배경 전투입니다. 로컬 저장 옵션과 무관하게 10팀 x 5명 고정 규모로 동작합니다.
- **일시정지 (`setPaused`)**: 실제 전투에서 물리, Phaser 타이머, tween, 스프라이트 애니메이션을 함께 제어합니다. 프리뷰 및 종료된 전투는 제외됩니다.
- **월드 이펙트 주기**: 실제 전투 생성 시 `startWorldEffects()`를 시작하고, 첫 포격은 `WORLD_EFFECT.INTERVAL`, 이후 일반 포격은 `WORLD_EFFECT.REPEAT_INTERVAL`을 사용합니다. 새 매치/종료 때 `clearWorldEffects()`로 주기 타이머, 잔여 냉각 구역, 메테오 임시 포커스, 캐릭터 감속 배율을 정리합니다. Phaser 타이머를 사용하므로 일시정지 시간은 이 간격과 냉각 지속시간에 포함되지 않습니다.
+171
View File
@@ -0,0 +1,171 @@
# Update: Aggregate Combat Worker Path
- Large-battle detached aggregate combat now tries to run through `src/game/combat/aggregateCombatWorker.js` before falling back to the synchronous aggregate path.
- The main thread sends Transferable TypedArrays for detached model ids, position, HP, team key, movement speed, DPS, and frost state; Phaser objects and team/skin references stay on the main thread.
- Worker results are applied only when the match id still matches and the model is still detached, preventing stale async results from overwriting visible/detail fighters.
- Stale worker ids whose models have already been unregistered are skipped before reading model fields.
- The main thread still performs `killFighterModel()` for worker-reported deaths so split-on-death, kill rewards, death stats, scoreboard updates, and match completion stay on the existing authoritative path.
# Update: Magic Attack Effect Pooling
- `spawnSpellEffect()` now acquires instant-spell visual sprites from a small per-texture pool and returns them when their attack animation completes.
- Pooled spell effects are reset on reuse for texture frame, position, scale, depth, alpha, rotation, flip, active/visible state, and animation-complete listeners.
- `clearCombatObjects()` now disposes through `disposeCombatObject()`, allowing active pooled spell effects to be returned during match cleanup while non-pooled projectiles, labels, heal effects, and world effects keep their destroy path.
- Only magic/instant-spell visuals were pooled here; projectile hit objects and meteor/world-effect objects remain on their existing lifecycle.
# Update: Squad-Based Detached Combat
- Large-battle detached models are grouped into transient squads by arena cell, team id, and `PERFORMANCE.LARGE_BATTLE_AGGREGATE_SQUAD_SIZE`.
- Squad AI does nearest-opposing-squad movement and group DPS resolution, then writes surviving members back into deterministic spiral slots around the squad center.
- This removes per-frame movement/target AI for thousands of offscreen models; individual `updateFighterModel()` stays reserved for attached/detail fighters in the rolling camera window.
- In large battles the combat target spatial index is built from attached/detail models, not the full model list, so visible individual AI no longer reintroduces an 8,000-model target scan.
# Update: Aggregate Detached Combat
- `updateAggregateDetachedCombat()` handles large-battle detached model-only fighters as coarse cell groups instead of invoking full `updateFighterModel()` AI for each offscreen model.
- Every-frame work for detached models is now simple movement toward the nearest enemy aggregate cell; target scanning, attack windup, projectile scheduling, and animation locks are reserved for attached/detail fighters.
- Aggregate damage is computed from group attack DPS on a throttled interval and applied to real `FighterModel` HP, so deaths, kill rewards, split-on-death, death stats, and winner checks remain tied to the existing combat state.
- Aggregate kills pass `silentLog: true` to the model death path to avoid large offscreen death batches flooding the DOM kill log.
# Update: Large Battle Combat Frame Throttles
- `prepareCombatFrame()` now syncs model positions from `fighterByModelId` only, so detached/offscreen sprite records are not scanned just to no-op position sync.
- Large battles reuse the target spatial index for `PERFORMANCE.LARGE_BATTLE_TARGET_INDEX_REFRESH_MS` instead of rebuilding the full 8,000-model grid every frame.
- The defensive model-index audit now runs once per second instead of every frame.
# Update: Null-Safe Model Target Cache
- `resolveTargetEnemyModel()` now clears stale `targetModelId` values when the cached model can no longer be resolved or is no longer a living enemy.
- `isValidEnemyTargetModel()` now null-checks both attacker and candidate models before reading team ids, preventing a removed/dead cached target from crashing the update loop.
# Update: Combat With Detached Render Sprites
- `prepareCombatFrame()` now syncs model position only from attached sprites; detached sprites are skipped so model-only movement remains authoritative.
- `fighterForModelId()` may now return `null` for living fighters outside the rolling-window detail set, which intentionally routes movement, attacks, damage, and death through the model-only fallback.
- The target spatial index still builds from `scene.fighterModels`; its `livingFighters` compatibility list now represents attached render sprites only, while `livingModels` remains the full combat list.
- Model-only death asks `ArenaScene.removeDetachedFighterProxyForModel()` to remove the parked fighter entry, and `livingFighterProxyCount()` prevents any remaining dead entries from being re-registered.
# Update: Model-Only Combat Fallback
- `updateFighterModel()` no longer requires an attached Phaser sprite to keep a living fighter model moving and fighting.
- If a render sprite exists, movement, animation, projectiles, and death presentation keep using the existing Sprite/Arcade path.
- If no render sprite exists, movement updates model `x/y` directly, attacks schedule delayed model hits, damage writes to model HP, and death unregisters the model from `ArenaScene` indexes immediately.
- Projectile and instant-spell model-only attacks preserve windup/effect/travel timing, but skip visual projectile/spell objects.
- Kill reward and split-on-death can now run from model state, so offscreen sprite detachment does not stop combat resolution.
# Update: Model-Based Targeting And Spatial Index
- `ArenaScene.update()` now iterates `scene.fighterModels` and calls `updateFighterModel()` instead of driving combat directly from the sprite array.
- `prepareCombatFrame()` still syncs active sprite positions into models, but the target spatial index is built from model records and stores model entries in each grid cell.
- Target caching moved to `model.targetModelId`; validation checks model liveness and team identity before resolving the render sprite through `scene.fighterForModelId()`.
- `combatTargetIndex` now exposes `livingModels` as the primary model list while keeping `livingFighters` as an attached-sprite compatibility list.
- Attack execution, animation, projectiles, and HUD-facing effects still use sprites when they exist; detached participants resolve through the model-only path.
# Update: FighterModel Position Sync In Combat
- `prepareCombatFrame()` now syncs each sprite's current render position into its `FighterModel` before building the target spatial index.
- Dormant/offscreen fighters keep advancing model `x/y` through `fighterAdapter.moveFighterToward()` while visible fighters continue to use Arcade movement and sync back into the model on the next combat frame.
- Target-grid cell placement and nearest-enemy lookup use model position helpers, keeping the combat path ready for a future model-first update loop.
- Attack execution still resolves a fighter sprite from `targetModelId` for movement, animation, and hit visuals. Removing that render dependency is a later migration step.
# Update: Fighter Adapter In Combat
- `combat.js` no longer owns fighter render/body helpers locally. It imports fighter position, distance, movement, detail visibility, animation, body-disable, and arena-clamp helpers from `fighterAdapter.js`.
- Visible fighters still move through Arcade physics, while dormant fighters are advanced by the adapter with JS `x/y` math and arena clamping.
- Target selection and camera/world-effect hit points now use adapter position helpers so disabled Arcade bodies do not leave stale centers behind.
- Ranged attacks still render projectiles only when both attacker and defender are detailed; dormant participation resolves through delayed data hits.
# Update: Dormant Fighter Combat Simulation
- `updateFighterModel()` accepts `delta` and manually advances dormant fighters with disabled Arcade bodies using JS position math.
- Visible fighters still use `scene.physics.moveToObject()` so nearby/on-screen motion keeps the existing Arcade movement behavior.
- Attack/hurt animation locks are applied only to detailed fighters. Dormant fighters rely on cooldowns and delayed hit timers instead of animation-complete events.
- Projectile attacks involving dormant fighters resolve as delayed data hits and skip Phaser projectile object creation.
- Hit-point, camera, and world-effect helpers treat disabled bodies as stale and use fighter `x/y` instead.
# Update: Projectile And Target Grid Optimization
- Projectile hit detection now relies on `projectilePathHitsDefender()` only; it no longer creates one Arcade overlap collider per projectile because the path check already covers fast projectile travel against the defender hit area.
- Projectile path/hit-area geometry is reused through module-level scratch objects to avoid repeated `Line`/`Rectangle` allocation during projectile updates.
- The per-frame target spatial index now stores cells in a numeric array, avoiding string cell keys and `Map` writes during every combat frame.
- `clearCombatObjects()` also clears `scene.combatTargetIndex` so match resets and LOD passes do not briefly reuse stale living-fighter lists.
# Update: Dense-Area Meteor Barrage
- `worldEffects.js` aggregates living fighters on the arena tile grid and uses a summed-area scan to select the `WORLD_EFFECT.AREA_TILES` square with the highest population.
- That selected square is a warning/focus area. Each fire or frost event schedules `WORLD_EFFECT.IMPACT_COUNT_MIN` to `IMPACT_COUNT_MAX` smaller strikes inside it.
- Tune the large warning visibility with `WORLD_EFFECT.WARNING_DURATION_MS`, actual damage/frost footprints with `WORLD_EFFECT.IMPACT_AREA_TILES`, sprite size with `WORLD_EFFECT.IMPACT_VISUAL_SCALE`, strike spacing with `WORLD_EFFECT.IMPACT_STAGGER_MS`, and per-strike variation with `WORLD_EFFECT.SIZE_SCALE_VARIANCE`.
- `WORLD_EFFECT.INTERVAL` sets the delay before the first barrage; subsequent normal barrages use `WORLD_EFFECT.REPEAT_INTERVAL`, with `SUDDEN_DEATH.INTERVAL_MS` taking over once sudden death is active.
- Meteor impact shake uses the same size multiplier, scaling from `WORLD_EFFECT.METEOR_SHAKE_DURATION_MS` and `WORLD_EFFECT.METEOR_SHAKE_INTENSITY`.
# Update: Large Battle Targeting
- `combat.js` now prepares a per-frame target spatial index through `prepareCombatFrame(scene)`.
- `resolveTargetEnemy()` keeps valid cached targets until their scan interval expires, then immediately looks up a fresh nearest enemy through the spatial grid.
- Nearest enemy lookup searches grid cells outward from the fighter's current cell, with full-array scanning kept only as a fallback when no frame index exists.
- Large-battle corpse cleanup uses `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` and `PERFORMANCE.LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS` from `src/constants.js`.
# Update: Team Shadow Animations And Frost Tint
- Dead fighters keep their death animation/corpse state at initial opacity, then fade out until `combat.js` removes them from `scene.fighters` and destroys the sprite.
- Tune that fade/despawn lifetime with `FIGHTER.DEAD_DESPAWN_DELAY_MS` and the final alpha with `FIGHTER.DEAD_DESPAWN_ALPHA` in `src/constants.js`.
- Fighter action playback now goes through `fighterAdapter.playFighterAction()` / `playFighterActionIfNeeded()`, which resolve animation keys with `ensureFighterTeamAnimation()` so every action can use the team-shadow baked texture generated from the original spritesheet.
- The adapter compares against the team-shadow animation key before replaying an action. This avoids switching back to the original non-team-colored spritesheet when fighters move, attack, take damage, or die.
- Frost stun remains a body tint effect in `worldEffects.js`. Since team identity is baked into the floor shadow pixels, there is no `teamMarker` tint state to update or restore.
- The removed `teamMarker` display object means death handling no longer needs to hide or destroy a separate marker. HUD cleanup only owns pooled health-bar objects.
# Update: Focused Combat Effects In Large Battles
- `combat.js` exposes supplemental combat visuals only while a large battle is inside the temporary meteor camera-focus window.
- Outside that window, large battles skip critical labels, instant-spell sprites, kill-heal sprites, and kill-growth tweens while retaining the underlying damage and reward calculations.
- World-effect meteor/frost visuals remain visible, and projectile objects remain enabled because projectiles currently participate in hit detection.
- Projectile objects should keep calling `projectilePathHitsDefender()` for collision checks instead of adding per-projectile Arcade overlap colliders.
# Context: Combat System
## 1. 모듈별 상세 역할 (`src/game/combat/`)
- **`combat.js`**: 전투 AI, 피해 계산, 처치 보상 등 핵심 전투 로직을 담당합니다. `fighterStats.js`에서 해석한 역할별 수치로 이동, 공격, 투사체 발사 등을 처리합니다.
- **`combatSettings.js`**: 전투 속도 배율 등 런타임 전투 설정을 관리합니다.
- **`arenaFinalCombatEffects.js`**: 최종 교전 시 슬로우 모션 등 연출 효과를 담당합니다. 수학적인 이징(easing) 함수와 물리 시간 배율 계산을 포함합니다.
- **`worldEffects.js`**: 실제 전투에서 설정 주기마다 생존자 밀집 구역을 탐색하고 화염/냉기 소형 메테오 포격을 실행하며, 대각선 낙하 연출, 개별 탄착 판정, 냉기 동결과 감속 구역 수명주기를 처리합니다.
## 2. 주요 로직 구현 세부 사항
### 전투 AI 및 유닛 동작
- **`updateFighterModel()`**: 가장 가까운 적 모델을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
- **`applyHit()`**: 일반 공격 피해량은 공격자의 `melee`/`ranged`/`magic` 프로필 피해량 범위에서 계산하고, 치명타 적중은 `Critical!` 표기와 즉시 처치를 처리합니다.
- **역할별 기본값**: `src/constants.js`의 `FIGHTER_TYPE_STATS`에서 체력, 이동속도, 사거리, 공격 쿨다운, 피해량, 치명타 확률, 발동 지연을 독립적으로 조절합니다. 투사체 속도는 `ranged`, 효과 적중 지연은 `magic` 프로필에 포함됩니다.
- **`projectilePathHitsDefender()`**: 투사체가 대상을 스쳐 지나가지 않도록 궤적(Line)과 히트박스(Rectangle) 겹침 검사를 수행합니다.
### 처치 보상 및 성장
- **`applyKillReward()`**: 처치한 캐릭터의 체력 회복(현재 체력 30%), 크기 증가, 공격속도/이동속도 배율 증가를 처리합니다. 누적 배율은 `KILL_GROWTH_MAX_MULTIPLIER`로 제한합니다.
- **`clampFighterInsideArena()`**: 처치 성장 중 커진 캐릭터가 전장 바깥으로 나가지 않도록 위치를 보정합니다.
### 월드 이펙트
- **발동 규칙**: 프리뷰가 아닌 실제 전투에서 시작 후 첫 포격은 `WORLD_EFFECT.INTERVAL`이 지난 뒤 발생하고, 이후 일반 포격은 `WORLD_EFFECT.REPEAT_INTERVAL` 간격으로 발생합니다. 각 포격은 `AREA_TILES` 크기의 모든 후보 구역을 타일 누적합으로 평가해, 생존 캐릭터가 가장 많이 모인 범위를 선택합니다. 같은 밀도의 후보가 여러 개일 때만 그 후보 사이에서 무작위로 고릅니다.
- **포격 판정**: 선택된 큰 범위는 경고 표시와 카메라 포커스 대상으로 사용되고, 내부에 투하되는 작은 탄착 영역만 피해, 기절, 냉기 감속을 처리합니다. 이 때문에 넓은 밀집지대를 위협하면서도 영역 전체를 즉시 동일 피해로 덮지 않습니다.
- **서든 데스 (Sudden Death)**:
- **조건**: 매치 시작 후 `WORLD_EFFECT.SUDDEN_DEATH.TRIGGER_MS` 시간이 경과하면 서든 데스 상태에 진입합니다 (활성화 시).
- **효과**: 메테오 투하 주기가 `SUDDEN_DEATH.INTERVAL_MS`로 단축되며, `FORCE_FROST` 설정 시 빙결 효과를 가진 냉기 메테오가 집중적으로 생성됩니다.
- **목적**: 장기전을 방지하고 전장에 무작위 변수를 극대화하여 물량 중심 팀에게 리스크를 부여합니다.
- **낙하 방향과 크기**: 대상이 전장 좌측 반면(2, 3사분면)이면 화살표가 좌상단에서 우하단으로, 우측 반면(1, 4사분면)이면 좌우 반전되어 우상단에서 좌하단으로 이동합니다. 스프라이트를 45도로 기울이고 전용 시각 배율을 사용해 전역 마법 규모로 표현합니다.
- **화염 메테오**: `world_Effect.png`의 소형 탄착 애니메이션 3~4개가 밀집 경고 구역 내부로 순차 낙하합니다. 각 탄착은 크기에 따른 화면 흔들림과 개별 영역 고정 피해를 적용합니다. 환경 피해로 인한 사망은 킬 보상을 지급하지 않지만 사망 통계와 승패 판정에는 반영됩니다.
- **냉기 메테오**: `world_Effect_2.png`의 소형 탄착 애니메이션들이 같은 방식으로 낙하합니다. 개별 탄착 피해를 입고 생존한 대상은 얼음색으로 바뀐 채 설정 시간 동안 기절하며, 각 탄착점에 남는 냉각지대 안에서는 공격속도와 이동속도 감속 배율을 적용합니다.
### 최종교전 슬로우모션
`COMBAT.FINAL_SLOW_MOTION_ENABLED`가 활성화된 경우:
- 최종교전 상태에서 공격 모션이 시작될 때 전역 time scale을 낮춥니다.
- 진입/유지/복귀 속도 램프(Ease)를 적용합니다.
- Arcade Physics는 timeScale 방향이 반대라 물리 이동에는 역수 배율을 적용합니다.
## 3. 유지보수 규칙
- **처치 성장 상한**: `src/constants.js`의 `KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
- **공격력 조정**: `src/constants.js`의 `FIGHTER_TYPE_STATS.<type>.damageMin/damageMax`를 수정합니다.
- **월드 이펙트 및 서든 데스 조정**:
- `src/constants.js`의 `WORLD_EFFECT.METEOR_DAMAGE`와 `WORLD_EFFECT.FROST_DAMAGE`로 피해량을 조정합니다.
- `SUDDEN_DEATH.ENABLED`로 서든 데스 활성화 여부를 결정하며, `TRIGGER_MS`(시작 시간), `INTERVAL_MS`(주기), `FORCE_FROST`(냉기 고정) 설정을 변경할 수 있습니다.
- `INTERVAL`은 첫 포격까지의 대기 시간, `REPEAT_INTERVAL`은 이후 일반 포격 주기입니다. `AREA_TILES`는 밀집도를 검색하고 경고로 표시할 큰 구역이며, `WARNING_DURATION_MS`는 그 경고 표시 시간입니다. `IMPACT_AREA_TILES`, `IMPACT_COUNT_MIN`/`IMPACT_COUNT_MAX`, `IMPACT_STAGGER_MS`, `IMPACT_VISUAL_SCALE`는 내부 소형 포격의 판정 범위, 발수, 간격, 시각 크기를 조정합니다.
- `WORLD_EFFECT.FROST_STUN_DURATION`/`FROST_STUN_TINT`로 동결 시간과 표시 색상을 조정합니다.
- 나머지 `WORLD_EFFECT.*` 값으로 발동 주기, 범위, 냉각 지속시간과 감속 정도를 수정하며, 메테오 착탄 위치 포커싱은 `CAMERA.METEOR_FOCUS_ENABLED`에서 켜고 끕니다.
- **특수 규칙**: 캐릭터별 특수 공격 방식은 `fighterManifest.js`의 `combat` 설정을 확인합니다.
+114
View File
@@ -0,0 +1,114 @@
# Update: Worker Entrypoints
- `src/game/arena/fighterLodWorker.js` is bundled as a Vite module worker for large-battle render LOD candidate selection.
- It is separate from `src/game/combat/aggregateCombatWorker.js`: LOD worker chooses which sprites should be detailed, while aggregate combat worker advances detached/offscreen combat math.
# Update: Full Rolling-Window Detail Constants
- Focused large-battle rendering no longer uses a separate zoomed sprite cap or rolling-window buffer ratio.
- `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT` is the bounded representative sprite count for full-arena overview.
- `PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_SCALE` and `PERFORMANCE.LARGE_BATTLE_SPRITE_VIEW_PADDING` define the focused camera window whose living fighters are all promoted to detailed sprites.
# Update: Web Worker Aggregate Path
- `aggregateCombatWorker.js` is bundled as a Vite module worker and is used only for detached/offscreen aggregate combat math.
- Main-thread combat keeps the authoritative Phaser/game-state mutations, while the worker exchanges Transferable TypedArrays for model position, HP, team, speed, DPS, and death results.
- Worker failure disables the worker path and leaves the synchronous aggregate fallback active.
# Update: LOD Traversal Reduction
- Large-battle LOD now removes parked fighter bodies from Arcade World's active body set and re-enables them only when a fighter becomes detailed again.
- LOD refreshes still use `PERFORMANCE.LARGE_BATTLE_LOD_REFRESH_MS`, but detail visibility changes are now applied as set differences after initial activation.
- `PERFORMANCE.LARGE_BATTLE_SPRITE_VIEW_PADDING` also pads the dot redraw culling view so zoomed camera movement does not require drawing every offscreen LOD dot.
# Update: Aggregate Combat Constants
- `PERFORMANCE.LARGE_BATTLE_AGGREGATE_COMBAT_REFRESH_MS` controls the detached/offscreen aggregate combat tick interval.
- `PERFORMANCE.LARGE_BATTLE_AGGREGATE_CELL_SIZE` controls the coarse combat grid size used for large-battle detached model groups.
- `PERFORMANCE.LARGE_BATTLE_AGGREGATE_SQUAD_SIZE` controls how many detached fighters are represented by one squad in a cell/team group.
- `PERFORMANCE.LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_CELL_TICK` and `LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_TICK` cap batched deaths to avoid a single aggregate tick creating a large DOM/game-state spike.
- `PERFORMANCE.LARGE_BATTLE_AGGREGATE_MOVEMENT_RATIO` tunes the speed of detached models moving toward their nearest aggregate enemy cell.
# Update: Large Battle Throttle Constants
- `PERFORMANCE.LARGE_BATTLE_SIMULATION_BUCKETS` spreads detached model-only combat updates across frames during large live matches.
- `PERFORMANCE.LARGE_BATTLE_SIMULATION_MAX_DELTA_MS` caps the accumulated delta used by throttled detached fighters.
- `PERFORMANCE.LARGE_BATTLE_TARGET_INDEX_REFRESH_MS` controls how often the full target spatial index is rebuilt in large battles.
- `PERFORMANCE.WORLD_EFFECT_MODIFIER_REFRESH_MS` throttles frost-zone speed modifier scans.
- `PERFORMANCE.LARGE_BATTLE_HUD_VISIBLE_LIMIT` caps pooled fighter HUD health bars separately from normal battles.
- The 8,000-fighter full-arena overview budget is intentionally tight through `LARGE_BATTLE_SPRITE_RENDER_LIMIT`; focused rolling-window views promote all fighters in the local window.
# Update: Fighter Render LOD Constants
- `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT` caps the number of representative detailed fighter sprites kept visible in the full-arena overview during large live battles.
- `PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_SCALE` makes the sprite-ready area larger than the exact camera view.
- `PERFORMANCE.LARGE_BATTLE_SPRITE_VIEW_PADDING` provides a minimum rolling-window expansion even at tighter zooms.
- `PERFORMANCE.LARGE_BATTLE_LOD_REFRESH_MS` throttles detailed-set recomputation, and `PERFORMANCE.LARGE_BATTLE_DOT_REFRESH_MS` throttles the shared dot overlay redraw.
- `PERFORMANCE.LARGE_BATTLE_DOT_SIZE` and `PERFORMANCE.LARGE_BATTLE_DOT_ALPHA` tune the hidden-fighter dot representation.
- `CAMERA.LARGE_BATTLE_START_ZOOM` controls the initial zoom used when a live match starts as a large battle.
- `CAMERA.MANUAL_FOCUS_TWEEN_MS` and `CAMERA.MANUAL_FOCUS_TWEEN_EASE` tune manual camera pan/zoom transitions used by fighter/team selection and full-arena return.
# Update: Phaser Render Tuning
- `src/constants.js` exports `RENDER` for the Phaser canvas resolution. The arena remains `ARENA.SIZE = 3200`, while the canvas now renders at `1280 x 1280`.
- `CAMERA.MIN_ZOOM` is derived from render size versus arena size so full-arena overview still works at the lower internal canvas resolution.
- `src/main.js` keeps `pixelArt: true` and now also sets `autoRound: true` plus `powerPreference: "high-performance"` for the Phaser game config.
- `PERFORMANCE.MINIMAP_REFRESH_MS` centralizes the live minimap redraw interval so large battles avoid redrawing thousands of dots on every scene update.
# Context: Core & Infrastructure
# Update: Dense-Area Meteor Barrage
- `WORLD_EFFECT.AREA_TILES` now defines the large warning/search square selected from the densest living-fighter region.
- `WORLD_EFFECT.WARNING_DURATION_MS` controls how long that large warning marker stays visible without changing the scheduled strikes.
- `WORLD_EFFECT.IMPACT_AREA_TILES`, `IMPACT_COUNT_MIN`, `IMPACT_COUNT_MAX`, `IMPACT_STAGGER_MS`, and `IMPACT_VISUAL_SCALE` configure the smaller strikes fired inside that warning square.
- `WORLD_EFFECT.SIZE_SCALE_VARIANCE` randomizes each fire/frost impact around the smaller strike size.
- `WORLD_EFFECT.INTERVAL` schedules the first barrage after match start, and `WORLD_EFFECT.REPEAT_INTERVAL` schedules later normal barrages.
- Meteor impact shake strength follows the same size multiplier, using `WORLD_EFFECT.METEOR_SHAKE_DURATION_MS` and `WORLD_EFFECT.METEOR_SHAKE_INTENSITY` as base values.
# Update: Direct Fighter Counts And Match Cap
- Live-match name entries use the `name*N` suffix as the assigned fighter count; a name without `*N` creates one assigned fighter.
- `SPAWN.MAX_FIGHTER_COUNT` is the maximum for participant-assigned fighter slots and is currently 8,000; Slime trait-generated fighters are excluded.
- Limit failures during live-match setup are surfaced beneath the participant nickname input.
- `SPAWN.FIGHTERS_PER_STARTING_ZONE` controls starting-zone distribution; each additional block of that many assigned fighters adds a team zone.
# Update: Performance Constants
- `src/constants.js` now exports `PERFORMANCE` for large-battle tuning: fighter threshold, target grid size, HUD pool/candidate limits, graphics minimap settings/redraw interval, and large-battle dead despawn delay.
- Keep large-battle behavior switches tied to `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` so high-count match tuning stays centralized.
# Update: Dead Fighter Despawn Constant
- `FIGHTER.DEAD_DESPAWN_DELAY_MS` controls how long a dead fighter fades before disappearing; `FIGHTER.DEAD_DESPAWN_ALPHA` controls the fade target.
## 1. 모듈별 상세 역할
- **`src/main.js`**: Phaser 게임의 전역 설정(Physics, Scale, Canvas Parent)을 담당하며, `ArenaScene`을 인스턴스화합니다.
- 앱 로드 시 `trackVisitor()`를 호출해 방문자 체크 API와 연동합니다.
- `Start` 버튼, 옵션 drawer, 전투 시작 submit 흐름을 제어하며 전투 시작 시 `#app`에 `match-live` 상태 클래스를 부여합니다.
- 전투 중 drawer 접기/펼치기(`drawer-collapsed`), 재시작 버튼, 일시정지 버튼 상태(`match-paused`)를 DOM 클래스와 `ArenaScene` 상태에 동기화합니다.
- **`src/constants.js`**: 게임 내 모든 튜닝 수치를 관리합니다.
- `FIGHTER_TYPE_STATS`: `melee`, `ranged`, `magic`별 최대 체력, 이동속도, 사거리, 쿨다운, 피해량, 치명타 및 공격 발동 지연 기본값.
- `FIGHTER_HITBOX_*`: 100x100 캐릭터 프레임 안에서 실제 충돌 판정이 놓이는 위치와 크기.
- `KILL_HEALTH_RECOVERY_RATIO`, `KILL_GROWTH_MULTIPLIER`, `KILL_GROWTH_MAX_MULTIPLIER`: 처치 후 회복량, 크기/공격속도/이동속도 성장 배율, 누적 보상 상한.
- `WORLD_EFFECT.*`: 첫/반복 포격 간격, 밀집 경고 범위, 개별 탄착 범위/발수/시각 배율, 대각선 낙하 거리, 화염/냉기 메테오 피해량, 냉기 동결 시간/색상, 냉각지대 지속시간과 감속 배율.
- `SELECTED_FIGHTER_OUTLINE_GAP`, `SELECTED_FIGHTER_OUTLINE_WIDTH`, `SELECTED_FIGHTER_OUTLINE_ALPHA`: 팀 색상 실루엣 마커의 캐릭터 이격 거리, 두께, 투명도.
- `TEAM_COLORS`, `getTeamColor()`: 8팀 이하에서는 기본 팔레트를 쓰고, 9팀 이상에서는 팀 수에 맞춰 중복 없는 색상을 동적으로 생성합니다.
- `CAMERA.SPECTATOR_LERP`: 카메라 추적의 부드러움 정도.
- `CAMERA.METEOR_FOCUS_ENABLED`, `CAMERA.METEOR_FOCUS_ZOOM`, `CAMERA.METEOR_FOCUS_HOLD_DURATION`: 자동 관전 진입 전 화염/냉기 메테오 착탄 위치의 임시 포커싱 on/off, 확대 배율 및 착탄 후 유지 시간.
- `CAMERA.SPECTATOR_LATE_FIGHTER_THRESHOLD`: 생존 인원 임계값에 따른 후반 자동 관전 진입 조건. (2팀만 남았더라도 이 수치보다 인원이 많으면 자동 관전을 유예합니다.)
- `CAMERA.SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD`, `CAMERA.SPECTATOR_RANDOM_FOCUS_INTERVAL`, `COMBAT.FINAL_SLOW_MOTION_*`: 최종교전 관전 조건, 랜덤 포커싱 간격, 슬로우모션 on/off, 배율과 속도 램프 시간.
- `MINIMAP_VIEWPORT_SIZE`: 미니맵의 고정 픽셀 크기.
- `ARENA_SIZE`: 경기장 전체 크기 (GRID * TILE).
## 2. 개발 및 유지보수 규칙
- **신규 캐릭터 추가**: `public/assets/characters/`에 에셋 배치 후 `fighterManifest.js`에 정의를 추가하면 즉시 게임에 반영됩니다.
- **종족값 유지**: 신규 스킨을 추가할 때는 사망 통계가 누락되지 않도록 `species`를 `human`, `orc`, `skeleton`, `slime`, `wolf`, `bear` 중 하나로 지정해야 합니다.
- **물리 수치 조정**: 역할별 기본 체력/속도/사거리/공격 수치는 `src/constants.js`의 `FIGHTER_TYPE_STATS`에서 변경하고, 특정 스킨만 다르게 할 때는 `fighterManifest.js`의 `stats` 또는 `combat` 설정을 사용하십시오.
- **처치 성장 상한 조정**: 처치 보상으로 캐릭터가 커지는 최대치와 공격/이동 배율 상한은 `src/constants.js`의 `KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
- **공격력 조정**: 역할별 기본 피해량은 `src/constants.js`의 `FIGHTER_TYPE_STATS.<type>.damageMin/damageMax`를 수정합니다. 캐릭터별 특수 공격 방식은 `fighterManifest.js`의 `combat` 설정을 우선 확인합니다.
- **월드 이펙트 조정**: `src/constants.js`의 `WORLD_EFFECT.INTERVAL`, `WORLD_EFFECT.REPEAT_INTERVAL`, `WORLD_EFFECT.AREA_TILES`, `WORLD_EFFECT.WARNING_DURATION_MS`, `WORLD_EFFECT.IMPACT_AREA_TILES`, `WORLD_EFFECT.IMPACT_COUNT_MIN`, `WORLD_EFFECT.IMPACT_COUNT_MAX`, `WORLD_EFFECT.IMPACT_STAGGER_MS`, `WORLD_EFFECT.IMPACT_VISUAL_SCALE`, `WORLD_EFFECT.SIZE_SCALE_VARIANCE`, `WORLD_EFFECT.FALL_TRAVEL_TILES`, `WORLD_EFFECT.METEOR_SHAKE_DURATION_MS`, `WORLD_EFFECT.METEOR_SHAKE_INTENSITY`, `WORLD_EFFECT.METEOR_DAMAGE`, `WORLD_EFFECT.FROST_DAMAGE`, `WORLD_EFFECT.FROST_STUN_DURATION`, `WORLD_EFFECT.FROST_STUN_TINT`, `WORLD_EFFECT.FROST_DURATION`, `WORLD_EFFECT.FROST_SPEED_MULTIPLIER`를 수정합니다. `INTERVAL`은 첫 포격까지의 대기 시간, `REPEAT_INTERVAL`은 이후 일반 포격 주기, `AREA_TILES`와 `WARNING_DURATION_MS`는 밀집 경고 구역과 표시 시간이며, `IMPACT_*` 값은 그 내부 실제 포격을 제어합니다. 임시 메테오 카메라는 `CAMERA.METEOR_FOCUS_ENABLED`로 끌 수 있습니다.
- **DOM 접근**: 성능을 위해 `ArenaScene`은 좌측 HUD badge 등 필요한 시점에만 최소한으로 DOM에 접근합니다.
- **패키지 락 파일**: 이 프로젝트는 `package-lock.json`을 저장소에서 제외합니다. 의존성 변경 시 `package.json`을 기준으로 관리합니다.
+111
View File
@@ -0,0 +1,111 @@
# Update: Parked Body Removal From Arcade World
- `disableFighterBody()` now calls `scene.physics.world.disable(fighter)` for parked or dead fighter sprites, removing the body from Arcade World's active body set instead of only setting `body.enable = false`.
- `enableFighterBody()` re-adds the sprite body with `world.enable(fighter)`, resets it to the model position, stops residual velocity, and syncs the model from the reattached sprite.
- `setFighterDetailVisible()` uses these adapter helpers for LOD detach/reattach, so render parking also removes offscreen bodies from physics traversal.
- `isLivingFighterModel()` now returns false for missing/null models so stale async ids and removed models cannot pass living checks.
# Update: Parked Fighter Detail Early Return
- `setFighterDetailVisible(false)` now returns immediately when a fighter is already parked, avoiding repeated body/input/HUD/display-list work during large-battle LOD refreshes.
# Update: Detached Fighter Animation Guard
- `shouldRenderFighterDetail()` now requires an actual active fighter object before returning true, preventing model-only large-battle combat from trying to animate a `null` sprite.
- `playFighterAction()` and `playFighterActionIfNeeded()` now skip playback if no animation key can be resolved.
# Update: Fighter Sprite Render Recovery
- `createFighter()` returns a real Phaser Sprite again, with combat-facing fields bridged to `fighter.model`.
- The lazy `SpriteProxy` pool was rolled back because the proxy handoff could leave the simulation data alive while no stable Phaser render object was visible.
- `attachSprite: false` is still accepted for large-battle startup, but it now creates the sprite and immediately parks it through `setFighterDetailVisible(false)` instead of skipping Sprite creation.
- Rolling-window LOD still removes non-detailed sprites from Phaser's display/update lists and restores the same sprite from model `x/y` when it becomes detailed again.
# Update: Fighter Render Sprite Detach
- `setFighterDetailVisible(false)` now parks a fighter sprite by disabling body/input/HUD, pausing animation, hiding it, and removing it from Phaser's display and update lists.
- `setFighterDetailVisible(true)` reattaches the same sprite, resets its body from model `x/y`, resumes animation, and restores pointer interaction for living fighters.
- `syncFighterModelFromSprite()` ignores detached sprites so offscreen model-only movement cannot be overwritten by a stale parked sprite position.
- Adapter helpers treat `_spriteDetached` as non-rendered/non-body state, so animation, body position, and projectile path logic naturally fall back to model data.
# Update: FighterModel Shell
- `fighterModel.js` now creates the pure JS state record for a fighter. The model owns combat-facing fields including HP, team/skin references, `targetModelId`/cooldown state, selection, lock/death flags, kill-growth state, frost state, detail visibility, facing, and model `x/y`.
- `attachFighterModel()` connects a Phaser sprite to its model and preserves the existing `fighter.hp`, `fighter.team`, `fighter.isDead`, etc. surface through getter/setter bridges. This keeps the current code stable while making `fighter.model` the state home.
- `isLivingFighterModel()` and `fighterModelDistanceSquared()` support model-first combat code without requiring a Sprite wrapper.
- `fighterFactory.js` creates a Phaser Sprite with an attached `fighter.model` bridge. HUD slots, timers, scale, and input hit areas remain render concerns.
- `fighterAdapter.js` updates model `x/y` when sprites are synced or when dormant fighters move manually, and now treats detached proxies as model-only for body/render checks.
# Update: Fighter Adapter Layer
- `fighterAdapter.js` centralizes fighter-facing Phaser operations: `fighterWorldPoint()`, `fighterDistanceSquared()`, `setFighterFacing()`, `moveFighterToward()`, `stopFighterMovement()`, `enableFighterBody()`, `disableFighterBody()`, `clampFighterInsideArena()`, animation playback, and frost tint helpers.
- `fighterFactory.js` owns sprite creation plus detail visibility; offscreen fighters remain model-backed while their sprite is parked outside Phaser render/update traversal.
- Treat the adapter as the boundary for the upcoming model/proxy split. Code outside `src/game/fighter/` should avoid new direct fighter `body`, `setFlipX()`, `setVelocity()`, or animation calls unless it is explicitly dealing with a non-fighter object.
# Update: Dormant Fighter Detail State
- `setFighterDetailVisible(false)` now makes a non-detailed fighter dormant and detached from Phaser render/update traversal.
- `setFighterDetailVisible(true)` re-enables the body at the model position, resumes animation, and restores pointer interaction for living fighters.
- Dormant fighters remain sprite/model records in `this.fighters` so existing match arrays, ownership, death stats, split-on-death, and team bookkeeping remain intact.
# Update: Fighter Detail Visibility For LOD
- `fighterFactory.js` exposes `setFighterDetailVisible()` so `ArenaScene` can hide or restore the detailed Phaser sprite for large-battle render LOD without removing the fighter from combat simulation.
- Hidden fighters release borrowed HUD slots and disable pointer interaction; visible living fighters keep their original hit-area based interaction.
- `syncFighterHud()` now treats invisible fighters as HUD-ineligible, preventing hidden LOD fighters from holding health-bar display objects.
- Detached LOD fighters now pause animation safely because combat locks and delayed hits can resolve through the model-only fallback while the sprite is parked.
# Update: HUD Pooling
- `fighterFactory.js` no longer creates permanent HUD objects for every fighter; zoom HUD now shows health bars without fighter name labels.
- HUD health-bar display objects are pooled on the scene and assigned only to selected fighters or zoom-visible nearby fighters chosen by `ArenaScene`.
- `syncFighterHud()` acquires a slot lazily and `releaseFighterHud()` returns it to the pool when the fighter leaves the HUD candidate set, dies, or is destroyed.
- Tune pool size and visible candidate limits in `PERFORMANCE.FIGHTER_HUD_POOL_SIZE` and `PERFORMANCE.FIGHTER_HUD_VISIBLE_LIMIT`.
# Update: Team Shadow Sprite Optimization
- Team identity is no longer rendered with a duplicated `teamMarker` sprite. `fighterFactory.js` now creates only the main Phaser sprite for each fighter.
- `fighterAssets.js` lazily creates team-colored spritesheets for the actual `skin + action + teamColor` combinations used in a match. The derived texture keeps the original character art and replaces only the floor shadow color (`#534545`) in the lower frame band (`y=55..59`) with the team color.
- Combat animation playback calls `ensureFighterTeamAnimation()` before switching actions, so idle, walk, attack, hurt, and death states keep the same team shadow treatment.
- This reduces display-list cost from `fighter sprite + teamMarker sprite` to a single fighter sprite. The tradeoff is extra texture memory for team-colored derivatives, so derived textures must stay lazy and should not be pre-generated for the whole manifest.
- Frost stun still uses the fighter body's `setTint(WORLD_EFFECT.FROST_STUN_TINT)`. Do not reuse tint for team identity; team color is baked into the shadow pixels instead.
# Context: Fighter & Assets
## 1. 모듈별 상세 역할 (`src/game/fighter/`)
- **`fighterAssets.js`**: 캐릭터 스프라이트 로드 및 애니메이션/실루엣 생성을 담당합니다. 원본 이미지로부터 팀 색상 마커용 실루엣을 동적으로 생성합니다.
- **`fighterFactory.js`**: 캐릭터 인스턴스화 및 HUD 체력바 관리를 담당합니다. Phaser Sprite와 DOM UI 사이의 가교 역할을 합니다.
- **`fighterManifest.js`**: 모든 캐릭터 종족 및 스탯 데이터를 정의합니다. 20여 종의 캐릭터 설정이 포함되어 있습니다.
- **`fighterStats.js`**: 공격 방식으로 `melee`, `ranged`, `magic` 역할을 판별하고 역할별 기본 스탯과 스킨별 오버라이드를 병합합니다.
- **`fighterSelection.js`**: 매치 참여 캐릭터를 무작위로 선택하거나 섞는 로직을 담당합니다.
## 2. 주요 로직 구현 세부 사항
### 동적 팀 실루엣 생성
팀 색상 표시는 캐릭터 모양을 정교하게 따라가는 별도 spritesheet입니다.
1. `fighterAssets.js`가 로드된 원본 스프라이트의 alpha 데이터를 캔버스에서 읽습니다.
2. 원본 alpha 픽셀 주변 `SELECTED_FIGHTER_OUTLINE_GAP`은 비우고, 그 바깥 `SELECTED_FIGHTER_OUTLINE_WIDTH`에만 흰색 outline을 칠합니다.
3. `fighterFactory.js`에서 생성된 캐릭터 뒤에 배치하고 팀 색상으로 tint 처리합니다.
4. 캐릭터가 성장하여 커져도 같은 배율로 실루엣이 유지됩니다.
### 캐릭터 HUD 및 상태 동기화
- **체력바 표시**: 줌 또는 선택 상태에서 후보 fighter만 pooled HUD slot을 빌려 체력바를 표시합니다. 이름표는 zoom HUD에 표시하지 않습니다.
- **사망자 처리**: 사망 시 HUD와 팀 마커를 숨겨 화면 가독성을 높입니다. 본체 sprite만 낮은 depth와 반투명 상태로 남깁니다.
- **월드 감속 상태**: 생성 시 `worldEffectSpeedMultiplier`를 `1`로 초기화하며, 냉각지대 안에서는 `worldEffects.js`가 해당 배율을 낮춰 공격속도와 이동속도 계산에 반영합니다.
- **냉기 동결 상태**: `isFrostStunned`와 동결 타이머를 캐릭터별로 관리합니다. 냉기 메테오 착탄에 생존하면 캐릭터 본체와 팀 실루엣 마커가 함께 얼음색으로 바뀌고, 동결 종료 시 본체 원본 색상과 저장된 팀 색상으로 복구됩니다.
### 캐릭터별 특성 (예: Slime)
- **`spawnMultiplier`**: 배정된 슬롯 1개를 지정된 수만큼 확장하여 스폰합니다.
- **`splitOnDeath`**: 사망 시 확률적으로 지정된 수만큼 분열체를 생성합니다.
- **스탯 상한**: 처치 보상은 현재 체력을 회복시키지만 `maxHp`를 넘을 수 없습니다. (예: Slime은 항상 1 HP)
### 역할별 전투 스탯
- `combat.type`이 `projectile`이면 `ranged`, `instant-spell`이면 `magic`, 그 외에는 `melee` 기본 프로필을 사용합니다.
- 새로운 공격 구현이 기본 판별과 다른 역할을 사용해야 할 때는 `combat.fighterType`에 `melee`, `ranged`, `magic` 중 하나를 명시합니다.
- 개별 스킨의 기존 `stats.maxHp`, `combat.range`, `combat.cooldown`, `combat.criticalChance`, `combat.projectile.speed`, `combat.attackEffect.hitDelay` 설정은 역할별 기본값보다 우선합니다.
## 3. 유지보수 규칙
- **신규 캐릭터**: 에셋 배치 후 `fighterManifest.js`에 정의를 추가합니다.
- **종족값**: 사망 통계를 위해 지정된 6개 종족 중 하나를 반드시 선택해야 합니다.
- **캐릭터 특성**: 전투 중 새 유닛을 생성하는 특성은 `ArenaScene.js`의 생성 헬퍼를 통해 `this.fighters`와 `team.size`를 함께 갱신해야 합니다.
+51
View File
@@ -0,0 +1,51 @@
# Context: Match & UI
# Update: Direct Fighter Count Entries And Zone Distribution
- Match setup no longer exposes a separate team-size control. Each live entry uses `nickname*N` for that team's assigned fighter count, with plain names defaulting to one fighter.
- A live match is rejected before replacing the current match only when the participant-assigned count exceeds `SPAWN.MAX_FIGHTER_COUNT`; Slime `spawnMultiplier` and `splitOnDeath` results may grow the actual live population past it.
- Fighter-count cap validation is reported as a visually distinct warning card below the participant nickname textarea, with requested and allowed counts emphasized separately; it clears when that input changes or a valid live match is submitted.
- Starting-zone placement assigns one zone per `SPAWN.FIGHTERS_PER_STARTING_ZONE` fighters in each team and puts any remainder in that team's final zone.
## 1. 모듈별 상세 역할
### 매치 로직 (`src/game/match/`)
- **`matchSetup.js`**: 매치 초기화, 팀 구성, 스폰 위치 계산을 담당합니다.
- **`arenaMatchRuntime.js`**: 스폰 클러스터 계산 및 팀 크기 동기화 등 매치 진행 중 헬퍼 기능을 제공합니다.
### UI 컴포넌트 (`src/ui/`)
- **`matchForm.js`**: 설정 폼 제어 및 `localStorage` 설정 유지.
- **`aboutDialog.js`**: About 버튼/다이얼로그, 개발자정보, 개인정보처리방침 Markdown 표시.
- **`arenaScoreboard.js`**: 좌측 HUD 레일의 팀 badge 업데이트 및 관전 시점 전환.
- **`arenaKillLog.js`**: 좌측 하단 킬로그 표시 및 관리.
- **`battleDeathNotice.js`**: 상단 사망 통계 공지 UI.
- **`victoryCelebration.js`**: 승리/무승부 축하 연출 (광선, 컨페티, 오디오).
## 2. 주요 로직 구현 세부 사항
### 매치 설정 및 스폰 배치
- **닉네임 배수 시스템**: `닉네임*배수` 형식(예: `Alice*2`)을 감지하여 팀 인원을 배수만큼 생성합니다.
- **구매 배수 보존과 독주 견제**: 배수 팀의 생성 인원과 전투 수치는 결제 이점으로 유지합니다. 월드 이펙트 표적 선정에서는 `team.multiplier`를 구매 지분으로 사용하고, 그 지분을 초과해 생존 중인 팀에만 설정 가능한 추가 표적 가중치를 적용합니다.
- **스타팅 지점 배치 (멀티 스폰)**: 팀마다 전장 스폰 가능 그리드에서 중심 셀을 무작위로 고르고, 중심 주변 2칸(`5 x 5`)을 해당 팀의 스타팅 영역으로 사용합니다. 배수가 설정된 팀은 배수만큼의 독립적인 스타팅 영역을 할당받아 병력이 분산 배치됩니다. 겹치지 않는 후보가 남아 있는 동안에는 해당 후보를 우선 선택하며, 영역은 매치 시작 후 5초 동안만 팀 색상으로 매우 옅게 표시되고 팀 전투원은 이 안에서만 스폰합니다.
- **설정 유지**: 닉네임, 인원, 배치 모드는 `localStorage`에 저장되어 재접속 시 복원됩니다.
### 전투 화면 레이아웃 (HUD)
- **팀 Badge**: 좌측 HUD 레일에 배치되며, 클릭 시 해당 팀의 생존 유닛 중 무작위 1명으로 시점을 고정합니다.
- **팀 Badge 갱신 안정성**: 사망으로 생존 수가 바뀔 때 기존 badge 버튼 DOM을 유지한 채 숫자, 비활성 상태, 선택 강조만 갱신하여 사망 프레임에 겹친 클릭도 시점 고정으로 전달되도록 합니다.
- **킬로그**: 처치자와 피처치자를 좌우로 배치하고, 피처치자 아이콘에 빨간 X를 겹쳐 사망 관계를 명확히 표시합니다. 캐릭터 idle 시트의 `100x100` 프레임 내 투명 여백을 제외한 중앙 하단 영역을 확대 표시해 작은 아이콘 박스에서도 실루엣이 충분히 보이도록 합니다.
- **하단 메타 정보**: 전투 화면 우측 하단(`arena-meta` 컨테이너)에 방문자 카운터와 About 버튼이 Pill(알약) 형태로 디자인이 통일되어 나란히 고정 배치됩니다. 드로어가 열려도 동일한 위치를 유지합니다.
- **모바일 레이아웃**: 실제 전투 시작 시 모바일에서는 옵션 drawer를 자동으로 접고, 상단 팀 HUD는 옵션 버튼 폭을 제외한 영역에 두 줄로 배치됩니다. 이때 데스크톱의 고정 가로폭 상속을 방지(`grid-template-columns: none`)하여 모든 팀 카드가 균일한 가로폭을 유지하도록 하며, 4개 이후 팀도 스크롤을 통해 확인할 수 있습니다. 모바일 팀 카드 선택 표시는 내부 테두리로 처리해 외곽선이 잘려 보이지 않게 합니다. 킬로그는 전투 캔버스 바로 아래에 배치하되 하단 메타 정보(방문자 카운터/About)와 겹치지 않게 안전 여백을 확보합니다.
- **모바일 옵션 drawer**: 전투 중 펼친 옵션 drawer는 닉네임 입력 높이와 컨트롤 간격을 줄여 전투 시작/재시작/일시정지 버튼이 작은 화면에서도 한 번에 보이도록 합니다.
- **승리 연출**: 승리 시 Web Audio 기반 팡파르와 CSS 애니메이션(광선, 컨페티)을 결합해 화려하게 연출합니다. 전투 종료 시 옵션 drawer를 접어 결과 배너가 설정 폼과 충돌하지 않게 하며, 결과 배너는 일정 시간 후 자동으로 사라지거나 클릭 시 즉시 닫힙니다. 무승부는 더 차분한 톤을 사용합니다.
## 3. UI 개발 규칙
- **DOM 접근 최소화**: 성능 최적화를 위해 필요한 시점에만 최소한으로 DOM을 업데이트합니다.
- **반응형 상태**: `#app`의 클래스(`match-live`, `options-open`, `drawer-collapsed`, `match-paused`, `match-ended`)를 통해 전반적인 UI 상태를 제어합니다.
## 4. About 다이얼로그
- **`src/ui/aboutDialog.js`**:
- 대기 화면과 전투 화면 우측 하단에 상시 노출되는 `#about-button`과 `#visitor-count`를 관리합니다.
- 다이얼로그는 개발자정보와 개인정보처리방침 탭으로 구성됩니다.
- **자체 Markdown 렌더러**: 개인정보처리방침 탭은 자체 구현된 렌더러를 통해 출력되며, 굵게(`**`), 기울임(`*`), 인라인 코드(`` ` ``), 인용구(`>`), 구분선(`---`), 헤딩(`#`) 및 리스트(`-`) 문법을 안전하게(HTML 이스케이프) 지원합니다.
- 브라우저는 `GET /api/about` API로 서버에 캐시된 콘텐츠를 가져오며, 실패 시 폴백 값을 표시합니다.
@@ -0,0 +1,183 @@
# ArenaScene 모듈화 작업지시서
작성일: 2026-05-23
## 목적
`src/game/ArenaScene.js`가 매치 생명주기, Phaser scene hook, HUD DOM, 관전 카메라, 미니맵, 최종교전 슬로우모션, 승리 연출, 사망 통계까지 함께 들고 있다. 이번 작업은 기능별 모듈로 책임을 나누고 `ArenaScene`은 scene orchestration과 외부 공개 메서드의 얇은 진입점으로 줄이는 리팩터링이다.
이번 작업은 **동작 보존 리팩터링**으로 진행한다. 게임 규칙, UI 문구, 튜닝 상수, 에셋, API 스펙은 바꾸지 않는다.
## 현재 코드 관찰
- 대상 파일은 `src/game/ArenaScene.js`다. `src/main.js`는 대소문자 포함 `./game/ArenaScene.js`를 import하므로 파일명/import 경로는 유지한다.
- 현재 파일 크기는 약 42KB이고 역할이 아래 구간에 섞여 있다.
| 구간 | 현재 책임 |
| --- | --- |
| `ArenaScene` constructor, `preload()`, `create()`, `startMatch()` | scene 상태 초기화, 입력 바인딩, 매치 시작 |
| `spawnSplitFighters()` | 전투 중 분열 전투원 생성 |
| `resetMatchDeathStats()` ~ `persistDailyDeathStats()` | 실제 매치 사망 집계, 상단 battle notice 스케줄링, API 연동 |
| `resetKillLog()` ~ `getKillLogNodes()` | 킬로그 DOM |
| `update()` ~ `getObservedCombatCenter()` | 전투 update loop, 관전 카메라, 선택 관전, presentation 관전 |
| `triggerFinalCombatSlowMotion()` ~ `restoreSceneTimeScale()` | 최종교전 슬로우모션 |
| `setPaused()` | 실제 매치 pause/resume |
| `setMainCameraZoom()` ~ `snapMinimapFrameValue()` | 줌과 미니맵 viewport frame |
| `updateScoreboard()` | 팀 badge HUD DOM |
| `finishMatch()` | 종료 판정, presentation 재시작, 사망 통계 저장, 승리 상태 |
| 파일 하단 helper들 | 승리 overlay/audio, death notice 문구, kill log node 생성, 스폰 확장, spectator 순수 계산 |
## 유지해야 할 외부 계약
리팩터링 중 아래 표면은 깨지지 않게 유지한다. 구현을 다른 모듈로 옮기더라도 필요하면 `ArenaScene`에 같은 이름의 delegate 메서드를 남긴다.
### 앱 진입점 계약
`src/main.js`는 다음을 사용한다.
- `new ArenaScene({ getInitialMatchConfig, setStatus })`
- `arenaScene.startMatch(matchConfig)`
- `arenaScene.isMatchPaused()`
- `arenaScene.togglePause()`
### combat 계약
`src/game/combat.js`는 scene에 아래 optional callback 메서드가 있다고 보고 호출한다.
- `scene.observeCombat(attacker, defender)`
- `scene.triggerFinalCombatSlowMotion(attacker, defender, attackAnimation)`
- `winner.scene.recordKill(winner, defender)`
- `fighter.scene.spawnSplitFighters(fighter, splitOnDeath)`
이 계약은 우선 유지한다. `combat.js`까지 구조를 바꾸는 작업은 이번 리팩터링의 필수 범위가 아니다.
### Phaser hook 계약
- `preload()`
- `create()`
- `update(time)`
hook은 `ArenaScene`에 남기고 기능 모듈로 위임한다.
## 권장 분리 경계
아래 파일명은 권장안이다. 기존 코드 패턴과 충돌하면 더 나은 이름을 써도 되지만, 책임 경계는 유지한다.
| 권장 모듈 | 옮길 책임 | 비고 |
| --- | --- | --- |
| `src/game/arenaMatchRuntime.js` | fighter plan 확장, spawn cluster 계산, team size sync, 분열 스폰 보조 로직 | 순수 helper를 먼저 빼고 scene mutation은 작은 함수로 감싼다. |
| `src/game/arenaSpectatorCamera.js` | spectator state 계산, 관전 대상 선택, presentation follow, 선택 fighter camera focus | camera 관련 계산과 scene 동작을 한 경계로 묶는다. |
| `src/game/arenaMinimap.js` | minimap camera 생성, zoom 연동, viewport frame 렌더링 | spectator 모듈이 너무 커지면 분리한다. |
| `src/game/arenaFinalCombatEffects.js` | final combat slow motion 진입/유지/복귀, easing, timeScale 적용/복원 | Arcade Physics timeScale 역수 적용을 그대로 보존한다. |
| `src/ui/arenaScoreboard.js` | 팀 badge DOM 갱신과 team click 핸들링 | DOM 생성 책임을 scene에서 뺀다. |
| `src/ui/arenaKillLog.js` | kill log reset/append/node factory/avatar URL helper | `document` 접근과 node cache 경계를 명확히 한다. |
| `src/ui/battleDeathNotice.js` | 종족별 death count helper, notice 문구 생성, notice DOM/timer, 오늘 사망 통계 fetch/persist 흐름 | presentation match 제외 조건을 유지한다. |
| `src/ui/victoryCelebration.js` | victory overlay DOM, confetti, fanfare audio priming/playback | `setStatus()` wrapper가 이 모듈만 호출하게 만든다. |
`ArenaScene`에 남길 책임은 다음 정도로 제한한다.
- scene 상태의 루트 소유권
- Phaser hook과 입력 이벤트 등록
- 매치 시작/종료 orchestration
- `main.js`와 `combat.js`가 부르는 공개 delegate 메서드
- 기능 모듈을 호출하는 update 흐름
## 구현 원칙
1. 첫 패스에서는 상태 저장소를 새로 만들지 않는다. 이미 scene에 있는 상태를 유지하고, 기능 모듈은 필요한 scene 또는 명시적 인자를 받게 한다.
2. 먼저 파일 하단의 순수 helper를 추출한다. 예를 들면 spectator 계산, spawn plan 계산, death count/message 계산은 scene method 이동보다 리스크가 낮다.
3. DOM 전용 로직은 `src/ui/`로 옮긴다. Phaser object 조작과 DOM node 생성이 한 함수에 섞이지 않게 한다.
4. 외부 호출 메서드는 한 번에 제거하지 않는다. 예를 들어 `recordKill()`은 `ArenaScene`에 남겨 `appendKillLog()`와 death count 갱신 함수에 위임해도 된다.
5. 공통 manager class를 크게 만들지 않는다. 기능별 함수 또는 상태가 분명한 작은 controller가 우선이다.
6. 기존 상수는 가능한 한 현재 위치를 유지한다. 기능 전용 상수가 새 모듈과 함께 이동할 때만 옮긴다.
7. 문서 업데이트가 필요한 구조 변경이면 `CONTEXT.md`, `agent.md`, `todo.md`의 설명도 실제 변경에 맞게 갱신한다.
## 권장 작업 순서
### 1. 기준선 확인
- `npm run build`로 현재 build 기준선을 확인한다.
- 자동 테스트 스크립트는 `package.json`에 없다. 빌드와 수동 smoke test를 기본 검증으로 잡는다.
### 2. pure helper부터 추출
- fighter plan/spawn cluster helper
- death count/message helper
- spectator state/position helper
- victory confetti/audio helper 중 DOM 밖 계산
이 단계에서는 `ArenaScene` 동작 순서와 method signature를 바꾸지 않는다.
### 3. DOM UI를 분리
- scoreboard
- kill log
- battle notice/death stats notice
- victory celebration
DOM module은 생성한 node, cached node, timer 정리 책임을 문서화된 함수 경계로 드러낸다.
### 4. camera/effect를 분리
- spectator camera와 selection focus
- minimap 생성/viewport frame
- final combat slow motion
카메라가 같은 프레임에서 여러 feature에게 조작되므로 `update()`의 우선순위는 그대로 유지한다.
현재 우선순위는 대략 다음과 같다.
1. fighter HUD sync
2. pause면 minimap frame만 갱신하고 return
3. 매치 진행 중 fighter update
4. presentation이면 presentation follow 후 return
5. 선택 fighter focus가 있으면 return
6. match over면 return
7. spectator state 기반 camera follow
8. minimap viewport frame 갱신
### 5. scene를 얇게 정리
- hook과 orchestration만 남았는지 확인한다.
- 외부 delegate 메서드가 계속 같은 이름으로 동작하는지 확인한다.
- import graph가 순환하지 않는지 확인한다. 특히 `combat.js`와 scene feature module이 서로 import하지 않게 한다.
## 리팩터링 중 주의점
- `presentationMode` 전투는 실제 매치와 다르다. 사망 통계 fetch/persist, battle notice, pause, 승리 연출 오디오 priming의 조건을 바꾸지 않는다.
- `matchId`는 비동기 death stats fetch와 전투 delayed action의 stale result를 막는 기준이다. 초기화 순서를 흐트러뜨리지 않는다.
- 새 매치 시작 시 battle notice timer, slow-motion timer/animation frame, combat object, selected fighter 상태가 정리되어야 한다.
- 최종교전 슬로우모션은 Phaser timer, tween, animation, Arcade Physics를 함께 건드린다. `arcadePhysicsTimeScale()`의 역수 규칙을 잃지 않는다.
- 미니맵 viewport frame은 main camera에서 ignore되고 zoom 상태에 따라 alpha/visibility가 바뀐다.
- `setStatus()`는 단순 상태 전달만 하지 않는다. 기존 승리/무승부 message에 맞춰 celebration overlay를 제거/생성한다.
- scoreboard badge 클릭은 `selectRandomTeamFighter()`로 이어지고 선택 fighter는 spectator 관전보다 우선한다.
- Slime `spawnMultiplier`와 `splitOnDeath`는 team size, scoreboard, finish 판정에 영향을 준다.
## 완료 조건
- `ArenaScene.js`가 기능별 모듈을 호출하는 orchestration 파일로 줄어든다.
- `main.js`와 `combat.js`의 기존 호출 계약이 깨지지 않는다.
- 실제 매치와 presentation 매치 흐름이 기존처럼 구분된다.
- 신규 모듈의 위치가 게임 로직(`src/game`)과 DOM UI(`src/ui`) 책임을 반영한다.
- `npm run build`가 통과한다.
- 관련 문서가 실제 구조와 맞게 갱신된다.
## 수동 smoke test
최소한 아래 흐름을 확인한다.
1. 앱 최초 로드 시 presentation 전투가 시작되고 실제 매치 입력 UI가 정상 동작한다.
2. 실제 매치를 시작하면 팀 badge와 전투원이 표시되고 restart가 새 매치를 시작한다.
3. pause/continue가 physics, timer, tween, sprite animation을 같이 멈추고 재개한다.
4. 마우스 wheel zoom, spectator follow, fighter 클릭 focus, team badge 클릭 focus, minimap viewport frame이 동작한다.
5. 처치 발생 시 kill log와 scoreboard 생존 수가 갱신된다.
6. Slime이 배정된 매치에서 spawn multiplier와 split-on-death가 깨지지 않는다.
7. 실제 매치가 끝나면 승리/무승부 상태와 victory celebration 흐름이 정상이다.
8. 실제 매치가 충분히 지속되면 battle notice 흐름이 동작한다. death stats API가 실패하더라도 scene이 멈추지 않고 warning 수준으로 끝난다.
## 산출물 기대치
- 기능별 신규 모듈
- 얇아진 `src/game/ArenaScene.js`
- 필요 시 갱신된 `CONTEXT.md`, `agent.md`, `todo.md`
- 검증 결과 요약
+84
View File
@@ -0,0 +1,84 @@
# Context: Server & API
## 1. 모듈별 상세 역할
- **`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를 조립합니다.
- 전투 사망 통계 컬렉션(`MONGODB_DAILY_DEATH_COLLECTION`)과 집계 기준 타임존(`DEATH_STATS_TIME_ZONE`) 기본값을 제공합니다.
- About 콘텐츠 컬렉션은 `MONGODB_ABOUT_COLLECTION`으로 조정하며 기본값은 `about_content`입니다.
- **`server/visitorCookie.js`**:
- `arena_visitor_id` 쿠키 읽기/쓰기와 UUID 형식 검증을 담당합니다.
- 방문자 쿠키 처리를 방문자 API와 일일 지표 API가 공유하도록 분리합니다.
- **`server/db.js`**:
- `MongoClient`를 한 번 생성한 뒤 재사용하여 MongoDB 커넥션 풀을 유지합니다.
- 종료 시 `closeMongoConnection()`으로 커넥션을 닫습니다.
- **`server/about.js`**:
- About 개발자정보와 개인정보처리방침 Markdown을 DB 기본 문서로 시드합니다.
- 실시간 반영을 위해 메모리 캐시를 사용하지 않고, 요청 시마다 DB에서 최신 데이터를 조회합니다.
- **`server/dailyMetrics.js`**:
- `GET /api/daily-metrics/today`: `ANALYTICS_TIME_ZONE` 기준 오늘의 운영 지표를 반환합니다.
- `POST /api/daily-metrics/match-started`: 사용자가 실제 전투를 시작했을 때 `totalMatchStarts`를 누적합니다.
- `POST /api/daily-metrics/match-finished`: 실제 전투가 끝났을 때 `totalMatchFinishes`를 누적합니다.
- `POST /api/daily-metrics/donation-clicked`: 후원 버튼 클릭 수를 누적하기 위한 예약 API입니다.
- 날짜별 합산 컬렉션(`daily_metrics`)과 날짜+방문자 해시 기준 임시 카운터 컬렉션(`daily_visitor_activity`)을 사용합니다.
- 임시 카운터는 `expireAt` TTL 인덱스로 기본 60일 뒤 자동 삭제됩니다.
- **`server/deathStats.js`**:
- `GET /api/death-stats/today`: `DEATH_STATS_TIME_ZONE` 기준 오늘 일자의 종족별 사망 집계와 총 사망 수를 반환합니다.
- `POST /api/death-stats/today`: 전투 종료 시 전달된 `deathsBySpecies`를 오늘 일자별 누적 문서의 `deathsBySpecies`, `totalDeaths`, `battles`에 바로 더합니다.
- 집계 대상 종족은 `human`, `orc`, `skeleton`, `slime`, `wolf`, `bear`로 제한합니다.
- **`server/visitors.js`**:
- `POST /api/visitors/check`: `arena_visitor_id` `HttpOnly` 쿠키를 확인하고 없으면 UUID를 발급합니다.
- MongoDB `visitors` 컬렉션에 `_id = visitorId`로 upsert해 방문자 1명당 1개 문서를 유지합니다.
- `GET /api/visitors/stats`: 전체 유니크 방문자 수를 반환합니다.
## 2. 주요 로직 구현 세부 사항
### 정적 파일 캐싱 전략
운영 모드(`npm start`)에서 `@fastify/static`을 통해 `dist/` 폴더의 자산을 서빙할 때 효율적인 캐싱을 수행합니다.
1. **`index.html`**: 항상 최신 상태를 유지해야 하므로 `no-cache, no-store, must-revalidate`를 설정해 브라우저가 매번 서버에 변경 사항을 확인하도록 합니다.
2. **기타 자산(JS, CSS, 이미지 등)**: 빌드 시 고유 해시가 포함되거나 변경 빈도가 낮은 정적 파일은 `public, max-age=604800, immutable` (7일) 설정을 통해 브라우저 캐시를 적극적으로 활용합니다.
### 유니크 방문자 체크
브라우저가 직접 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()`로 전체 유니크 방문자 수를 계산해 반환합니다.
6. 같은 요청에서 일일 지표의 `totalVisits`를 1 증가시키고, 해당 날짜에 처음 확인된 방문자면 `uniqueVisitors`도 1 증가시킵니다.
### 일일 운영 지표
수익화 판단에 필요한 최소 지표만 저장하며, 입력 닉네임이나 매치 상세 로그는 저장하지 않습니다.
1. 앱 로드 시 기존 `POST /api/visitors/check` 흐름에서 `daily_metrics.totalVisits`와 `daily_metrics.uniqueVisitors`를 갱신합니다.
2. 사용자가 직접 시작한 전투만 `POST /api/daily-metrics/match-started`로 `totalMatchStarts`에 누적합니다.
3. 프리뷰 전투는 제외하고, 실제 전투가 승리/무승부로 끝난 경우만 `POST /api/daily-metrics/match-finished`로 `totalMatchFinishes`에 누적합니다.
4. `daily_visitor_activity`는 날짜와 방문자 UUID를 함께 해시한 `_id`를 사용해 당일 방문자별 `visits`, `matchStarts`, `matchFinishes`, `donationClicks`만 임시 저장합니다.
5. 방문자의 당일 `matchStarts`가 1에서 2로 넘어가는 순간에만 `daily_metrics.visitorsWithTwoOrMoreMatches`를 1 증가시킵니다.
6. `daily_visitor_activity`는 `DAILY_ACTIVITY_RETENTION_DAYS` 설정값에 따라 TTL로 자동 삭제하고, 장기 보관 대상은 날짜별 합산 문서인 `daily_metrics`입니다.
### 전투 사망 통계
프리뷰 전투는 통계에서 제외하고, 사용자가 시작한 실제 전투만 저장합니다.
1. `fighterManifest.js`의 모든 스킨은 `species`를 가집니다.
2. `combat.js`의 처치 흐름이 `ArenaScene.recordKill()`을 호출하면, `ArenaScene`은 피처치자의 `skin.species`를 현재 전투의 `battleDeathCounts`에 누적합니다.
3. 전투가 5초 이상 이어지면 `GET /api/death-stats/today`로 가져온 오늘 집계와 현재 전투의 사망 수를 합산해 `#battle-notice`에 표시합니다.
4. 전투 종료 시 `POST /api/death-stats/today`로 `deathsBySpecies`만 보냅니다.
5. 서버는 `daily_death_stats` 컬렉션에서 오늘 일자의 `battles`, `totalDeaths`, `deathsBySpecies.*` 값을 `$inc`로 갱신합니다.
## 3. 설정 규칙
- **서버 설정**: `.env` 대신 `config.json`을 사용합니다. 로컬 전용 파일이며, 저장소에는 `config.json.sample`만 공유합니다.
- **MongoDB 연결**: 접속 정보는 `config.json`의 `MONGODB_HOST`, `MONGODB_PORT`, `MONGODB_DB` 등으로 관리합니다.
- **일일 지표 설정**: `MONGODB_DAILY_METRICS_COLLECTION`, `MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION`, `ANALYTICS_TIME_ZONE`, `DAILY_ACTIVITY_RETENTION_DAYS`로 집계 컬렉션과 임시 카운터 보관 기간을 조정합니다.
- **API 변경**: `/api/*` 경로는 Fastify 라우트가 담당하며, 개발 모드에서 Vite 미들웨어보다 우선순위를 가집니다.
## 4. About 콘텐츠
- **`server/about.js`**:
- 서버 시작 시 `MONGODB_ABOUT_COLLECTION` 컬렉션(기본값 `about_content`)에 `developer-info`, `privacy-policy` 기본 문서를 upsert합니다. 이때 기존 데이터가 있으면 덮어씌우지 않고 유지합니다.
- 개발자 정보 기본값은 `alias: horoli`, `email: sunha321@gmail.com`, `github: https://github.com/Horoli`입니다.
- 개인정보처리방침은 `privacy-policy.markdown` 문자열 필드에 Markdown 원문으로 저장합니다. 운영자가 DB에서 직접 작성/수정하면 유저가 About 다이얼로그를 열 때 실시간으로 반영됩니다.
- 실시간 조회를 위해 서버 메모리에 캐시하지 않으며, `GET /api/about` 읽기 전용 API를 통해 항상 최신 데이터를 제공합니다.
+25
View File
@@ -0,0 +1,25 @@
# Context: Style & Design
## 1. CSS 모듈 구조 (src/styles/)
이 프로젝트는 거대한 단일 CSS 파일을 지양하고, 기능별로 분리된 모듈형 CSS 구조를 채택하고 있습니다. `src/styles.css`는 각 모듈을 통합하는 엔트리 포인트 역할을 합니다.
- **`base.css`**: 전역 변수(`:root`), 리셋 스타일, 레이아웃의 뼈대(`#app`, `#game`, `.arena-shell`)를 정의합니다.
- **`intro.css`**: 대기 화면, 로고 애니메이션, 전투 프리뷰 연출 스타일을 담당합니다.
- **`game-ui.css`**: 스코어보드(팀 badge), 킬로그, 상단 전투 안내바, 승리/무승부 축하 레이어 등 실제 게임 진행 중 노출되는 모든 HUD 요소를 관리합니다.
- **`overlay.css`**: 설정 드로어(전투 옵션 폼), About 다이얼로그 및 공통 폼 컨트롤 스타일을 정의합니다.
- **`animations.css`**: 프로젝트 전역에서 재사용되는 `@keyframes`와 애니메이션 관련 유틸리티 클래스를 포함합니다.
- **`mobile.css`**: `960px` 이하 해상도를 위한 미디어 쿼리 오버라이드 스타일을 통합 관리합니다. 모바일 전용 레이아웃 조정 및 터치 최적화 스타일이 포함됩니다.
## 2. 디자인 시스템 및 변수
- **색상 체계**: 어두운 배경(`#080a07`)과 금색/주황색 계열의 포인트 컬러(`rgb(238 185 73)`)를 사용하여 판타지 아레나 분위기를 연출합니다.
- **반응형 대응**: `clamp()`, `min()`, `calc()` 등 현대적인 CSS 함수를 적극 활용하여 다양한 화면 크기에서도 유연하게 대응합니다.
- **가독성**: 텍스트 섀도우와 반투명 배경(`backdrop-filter`)을 활용해 복잡한 전투 화면 위에서도 UI 요소의 시인성을 확보합니다.
## 3. 스타일 수정 가이드
- **전역 상수 변경**: 색상이나 기본 여백 등은 `base.css`의 `:root` 변수를 먼저 확인하십시오.
- **컴포넌트 스타일 수정**: 수정하려는 UI 요소가 속한 카테고리에 맞는 파일을 열어 작업하십시오. (예: 킬로그 수정 -> `game-ui.css`)
- **모바일 레이아웃 수정**: 데스크톱 스타일을 수정한 후에는 `mobile.css`에서 해당 요소가 모바일에서 어떻게 보이는지 반드시 확인하고 필요한 경우 오버라이드하십시오.
- **애니메이션 추가**: 새로운 키프레임은 `animations.css`에 추가하여 중앙 집중식으로 관리합니다.
+325 -22
View File
@@ -1,22 +1,175 @@
<!doctype html> <!doctype html>
<html lang="ko"> <html lang="ko" class="app-booting">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Arena Picker</title> <title>Arena Picker</title>
<!-- OpenGraph Meta Tags -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Arena Picker" />
<meta
property="og:description"
content="친구들의 닉네임을 입력하고 최후의 승자를 가려보세요! 화려한 픽셀 아트 기반 자동 전투 시뮬레이션."
/>
<meta property="og:image" content="/assets/og-image.png" />
<!-- Twitter Meta Tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Arena Picker" />
<meta
name="twitter:description"
content="친구들의 닉네임을 입력하고 최후의 승자를 가려보세요! 화려한 픽셀 아트 기반 자동 전투 시뮬레이션."
/>
<meta name="twitter:image" content="/assets/og-image.png" />
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>⚔️</text></svg>"
/>
<style>
html.app-booting,
html.app-booting body {
margin: 0;
min-width: 320px;
min-height: 100%;
background: #080a07;
}
html.app-booting #app {
visibility: hidden;
}
</style>
<link rel="stylesheet" href="/src/styles.css" data-app-styles />
</head> </head>
<body> <body>
<main id="app"> <main id="app" class="intro-mode">
<section class="fighter-entry" aria-label="대전 참가자 입력"> <section class="arena-shell" aria-label="전투 경기장">
<div class="entry-copy"> <div id="scoreboard" class="scoreboard">
<p class="eyebrow">Arena Picker</p> <div id="score-left" class="score-side left"></div>
<h1>팀 전투 뽑기</h1> <div id="score-right" class="score-side right"></div>
</div>
<div
id="battle-notice"
class="battle-notice"
role="status"
aria-live="polite"
aria-hidden="true"
></div>
<div id="game"></div>
<div class="battle-preview" aria-hidden="true">
<span class="preview-fighter preview-knight"></span>
<span class="preview-fighter preview-orc"></span>
<span class="preview-fighter preview-wizard"></span>
<span class="preview-strike preview-strike-a"></span>
<span class="preview-strike preview-strike-b"></span>
</div>
<section
id="kill-log"
class="kill-log"
aria-label="킬로그"
aria-live="polite"
aria-relevant="additions"
aria-hidden="true"
>
<ol id="kill-log-list" class="kill-log-list"></ol>
</section>
<div
id="match-status"
class="match-status"
role="status"
aria-live="polite"
aria-hidden="true"
>
<div class="status-track">
<span data-status-text>옵션 대기 중</span>
<span data-status-text>옵션 대기 중</span>
</div>
</div>
<div class="arena-meta">
<p id="visitor-count" class="visitor-count" aria-live="polite">
방문자 확인 중
</p>
<button
id="about-button"
class="about-button"
type="button"
aria-haspopup="dialog"
aria-controls="about-dialog"
aria-expanded="false"
>
About
</button>
</div>
</section>
<section class="intro-stage" aria-label="Arena Picker 시작 화면">
<div class="intro-content">
<h1 class="arena-logo" aria-label="Arena Picker">
<span>ARENA</span>
<span class="small-text">PICKER</span>
</h1>
<button
id="start-button"
class="start-button"
type="button"
aria-controls="fighter-entry"
aria-expanded="false"
>
Start
</button>
</div>
</section>
<button
id="drawer-scrim"
class="drawer-scrim"
type="button"
aria-label="옵션 닫기"
></button>
<aside
id="fighter-entry"
class="fighter-entry"
aria-label="전투 옵션 입력"
aria-hidden="true"
>
<div class="drawer-header">
<div class="entry-copy">
<p class="eyebrow">Match Options</p>
<h2>전투 설정</h2>
</div>
<div class="drawer-header-controls">
<button
id="drawer-toggle"
class="drawer-toggle"
type="button"
aria-controls="fighter-entry"
aria-expanded="true"
>
옵션 접기
</button>
<button
id="drawer-close"
class="drawer-close"
type="button"
aria-label="옵션 닫기"
>
X
</button>
</div>
</div> </div>
<form id="fighter-form" autocomplete="off"> <form id="fighter-form" autocomplete="off">
<fieldset> <fieldset>
<legend>Players</legend> <legend>Players</legend>
<label for="player-names">참가자 닉네임</label> <label for="player-names">참가자 닉네임 (*숫자 = 출전 인원)</label>
<textarea id="player-names" name="playerNames" rows="10">Player 1 <textarea
id="player-names"
name="playerNames"
rows="10"
aria-describedby="player-names-warning"
>
Player 1
Player 2 Player 2
Player 3 Player 3
Player 4 Player 4
@@ -25,26 +178,176 @@ Player 6
Player 7 Player 7
Player 8 Player 8
Player 9 Player 9
Player 10</textarea> Player 10</textarea
>
<div
id="player-names-warning"
class="player-names-warning"
role="alert"
aria-live="polite"
hidden
>
<span class="player-names-warning__badge" aria-hidden="true">⚠</span>
<strong class="player-names-warning__title" data-player-names-warning-title
>최대 출전 인원 초과</strong
>
<p class="player-names-warning__detail">
출전 인원
<strong class="player-names-warning__count" data-player-names-warning-count
>0</strong
>명 / 최대
<strong class="player-names-warning__limit" data-player-names-warning-limit
>0</strong
>명
</p>
<p
class="player-names-warning__reason"
data-player-names-warning-reason
hidden
></p>
</div>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>Match</legend> <legend>Match</legend>
<div class="team-size-row"> <div class="spawn-placement-field">
<label for="team-size">팀당 인원</label> <span id="spawn-placement-label" class="spawn-placement-label"
<output id="team-size-value" for="team-size">5</output> >리스폰 설정</span
>
<div
class="spawn-placement-options"
role="radiogroup"
aria-labelledby="spawn-placement-label"
>
<label class="spawn-placement-option">
<input
type="radio"
name="spawnPlacement"
value="starting-zones"
/>
<span>스타팅 지점 배치</span>
</label>
<label class="spawn-placement-option">
<input
type="radio"
name="spawnPlacement"
value="random"
checked
/>
<span>완전 랜덤 배치</span>
</label>
</div>
</div> </div>
<input id="team-size" name="teamSize" type="range" min="1" max="100" value="5" />
</fieldset> </fieldset>
<button type="submit">전투 시작</button> <div class="match-actions">
<button type="submit">전투 시작</button>
<button id="restart-button" class="restart-button" type="button">
재시작
</button>
<button
id="pause-button"
class="pause-button"
type="button"
aria-pressed="false"
>
일시정지
</button>
</div>
</form> </form>
</section> </aside>
<section class="arena-shell" aria-label="전투 경기장">
<div id="scoreboard" class="scoreboard"> <div id="about-dialog" class="about-backdrop" hidden>
<div id="score-left" class="score-side left"></div> <section
<div id="score-right" class="score-side right"></div> class="about-dialog"
</div> data-about-dialog
<div id="game"></div> role="dialog"
</section> aria-modal="true"
aria-labelledby="about-dialog-title"
tabindex="-1"
>
<header class="about-header">
<div>
<p class="eyebrow">About</p>
<h2 id="about-dialog-title">개발자정보</h2>
</div>
<button
class="about-close"
data-about-close
type="button"
aria-label="About 닫기"
>
X
</button>
</header>
<div class="about-tabs" role="tablist" aria-label="About 메뉴">
<button
id="about-tab-developer"
class="about-tab"
data-about-tab="developer"
type="button"
role="tab"
aria-selected="true"
aria-controls="about-panel-developer"
>
개발자정보
</button>
<button
id="about-tab-privacy"
class="about-tab"
data-about-tab="privacy"
type="button"
role="tab"
aria-selected="false"
aria-controls="about-panel-privacy"
tabindex="-1"
>
개인정보처리방침
</button>
</div>
<section
id="about-panel-developer"
class="about-panel"
data-about-panel="developer"
role="tabpanel"
aria-labelledby="about-tab-developer"
>
<dl class="about-fields">
<div class="about-field-row">
<dt>alias</dt>
<dd data-about-field="alias">horoli</dd>
</div>
<div class="about-field-row">
<dt>email</dt>
<dd data-about-field="email">sunha321@gmail.com</dd>
</div>
<div class="about-field-row">
<dt>github</dt>
<dd data-about-field="github">
<a
href="https://github.com/Horoli"
target="_blank"
rel="noreferrer"
>
https://github.com/Horoli
</a>
</dd>
</div>
</dl>
</section>
<section
id="about-panel-privacy"
class="about-panel"
data-about-panel="privacy"
role="tabpanel"
aria-labelledby="about-tab-privacy"
hidden
>
<div id="privacy-policy-content" class="about-markdown"></div>
</section>
</section>
</div>
</main> </main>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
-1161
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -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"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

+177
View File
@@ -0,0 +1,177 @@
import { getConfig } from "./config.js";
import { getDb, hasMongoConfig } from "./db.js";
const DEFAULT_ABOUT_COLLECTION_NAME = "about_content";
const DEVELOPER_INFO_ID = "developer-info";
const PRIVACY_POLICY_ID = "privacy-policy";
const DEFAULT_DEVELOPER_INFO = {
alias: "horoli",
email: "sunha321@gmail.com",
github: "https://github.com/Horoli",
};
const DEFAULT_PRIVACY_POLICY_MARKDOWN = `
### 개인정보처리방침
**Arena Picker**는 이용자의 개인정보를 최소한으로 수집하며, 투명하게 관리하기 위해 노력합니다.
#### 1. 수집하는 개인정보 항목 및 방법
본 서비스는 별도의 회원가입 없이 이용 가능하며, 서비스 운영 지표 측정을 위해 아래와 같은 정보를 수집합니다.
- **수집 항목**: 방문자 식별값 (브라우저 쿠키를 기반으로 생성된 암호화된 UUID 해시), 방문 일시, 서비스 이용 기록 (전투 시작/종료, 버튼 클릭 등)
- **수집 방법**: 서비스 접속 시 자동으로 생성 및 서버로 전송
#### 2. 개인정보의 수집 및 이용 목적
수집된 정보는 오직 서비스 품질 개선 및 통계 분석을 위해서만 활용됩니다.
- 중복되지 않는 일일 방문자 수 측정
- 서비스 이용 통계 (전투 횟수, 선호 캐릭터 등) 분석
- 서비스 안정성 확인 및 버그 진단
#### 3. 개인정보의 보유 및 이용 기간
- 수집된 활동 로그 및 통계 데이터는 수집일로부터 **60일**간 보관 후 복구 불가능한 방법으로 파기됩니다.
#### 4. 개인정보의 제3자 제공
본 서비스는 이용자의 개인정보를 외부에 제공하거나 공유하지 않습니다.
#### 5. 이용자의 권리
이용자는 브라우저의 쿠키를 삭제함으로써 언제든지 식별 정보를 초기화할 수 있습니다.
**공고일자**: 2026년 5월 23일
**시행일자**: 2026년 5월 23일
`;
let aboutIndexesReady;
let aboutWarmupPromise;
export async function aboutRoutes(fastify) {
fastify.get("/about", async () => getAboutContent());
fastify.get("/about/", async () => getAboutContent());
// 관리용: 초기 데이터 보장 및 인덱스 생성을 위한 명시적 호출용 (필요시)
fastify.post("/about/warmup", async (request, reply) => {
await warmAboutContent();
return { success: true };
});
}
export async function warmAboutContent() {
if (!hasMongoConfig()) {
return formatAboutContent();
}
if (!aboutWarmupPromise) {
aboutWarmupPromise = loadAboutContent()
.finally(() => {
aboutWarmupPromise = undefined;
});
}
return aboutWarmupPromise;
}
async function getAboutContent() {
// 실시간 반영을 위해 캐시를 사용하지 않고 매번 DB에서 로드
return loadAboutContent();
}
async function loadAboutContent() {
const collection = await getAboutCollection();
// 서버 시작 시나 첫 호출 시 기본값 보장 (이미 있으면 유지됨)
await ensureAboutDefaults(collection);
const [developerInfo, privacyPolicy] = await Promise.all([
collection.findOne({ _id: DEVELOPER_INFO_ID }),
collection.findOne({ _id: PRIVACY_POLICY_ID }),
]);
return formatAboutContent(developerInfo, privacyPolicy);
}
async function getAboutCollection() {
const db = await getDb();
const collection = db.collection(
getConfig().MONGODB_ABOUT_COLLECTION || DEFAULT_ABOUT_COLLECTION_NAME,
);
await ensureAboutIndexes(collection);
return collection;
}
async function ensureAboutIndexes(collection) {
if (!aboutIndexesReady) {
aboutIndexesReady = collection.createIndex({ type: 1 });
}
return aboutIndexesReady;
}
async function ensureAboutDefaults(collection) {
const now = new Date();
await collection.bulkWrite(
[
{
updateOne: {
filter: { _id: DEVELOPER_INFO_ID },
update: {
$setOnInsert: {
_id: DEVELOPER_INFO_ID,
type: "developerInfo",
...DEFAULT_DEVELOPER_INFO,
createdAt: now,
},
},
upsert: true,
},
},
{
updateOne: {
filter: { _id: PRIVACY_POLICY_ID },
update: {
$setOnInsert: {
_id: PRIVACY_POLICY_ID,
type: "privacyPolicy",
markdown: DEFAULT_PRIVACY_POLICY_MARKDOWN,
createdAt: now,
updatedAt: now,
},
},
upsert: true,
},
},
],
{ ordered: false },
);
}
function formatAboutContent(developerInfo = {}, privacyPolicy = {}) {
return {
developer: normalizeDeveloperInfo(developerInfo),
privacyPolicy: {
markdown: stringValue(
privacyPolicy?.markdown,
DEFAULT_PRIVACY_POLICY_MARKDOWN,
),
updatedAt: dateString(
privacyPolicy?.updatedAt || privacyPolicy?.createdAt,
),
},
};
}
function normalizeDeveloperInfo(document = {}) {
return {
alias: stringValue(document?.alias, DEFAULT_DEVELOPER_INFO.alias),
email: stringValue(document?.email, DEFAULT_DEVELOPER_INFO.email),
github: stringValue(document?.github, DEFAULT_DEVELOPER_INFO.github),
};
}
function stringValue(...values) {
const value = values.find((candidate) => typeof candidate === "string");
return value?.trim() ?? "";
}
function dateString(value) {
return value?.toISOString?.() ?? null;
}
+173
View File
@@ -0,0 +1,173 @@
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_ABOUT_COLLECTION: "about_content",
MONGODB_DAILY_DEATH_COLLECTION: "daily_death_stats",
MONGODB_DAILY_METRICS_COLLECTION: "daily_metrics",
MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION: "daily_visitor_activity",
MONGODB_MAX_POOL_SIZE: 10,
MONGODB_SERVER_SELECTION_TIMEOUT_MS: 5000,
DEATH_STATS_TIME_ZONE: "Asia/Seoul",
ANALYTICS_TIME_ZONE: "Asia/Seoul",
DAILY_ACTIVITY_RETENTION_DAYS: 60,
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 arena 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_ABOUT_COLLECTION: stringValue(
rawConfig.MONGODB_ABOUT_COLLECTION,
mongodb.aboutCollection,
DEFAULT_CONFIG.MONGODB_ABOUT_COLLECTION,
),
MONGODB_DAILY_DEATH_COLLECTION: stringValue(
rawConfig.MONGODB_DAILY_DEATH_COLLECTION,
mongodb.dailyDeathCollection,
DEFAULT_CONFIG.MONGODB_DAILY_DEATH_COLLECTION,
),
MONGODB_DAILY_METRICS_COLLECTION: stringValue(
rawConfig.MONGODB_DAILY_METRICS_COLLECTION,
mongodb.dailyMetricsCollection,
DEFAULT_CONFIG.MONGODB_DAILY_METRICS_COLLECTION,
),
MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION: stringValue(
rawConfig.MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION,
mongodb.dailyVisitorActivityCollection,
DEFAULT_CONFIG.MONGODB_DAILY_VISITOR_ACTIVITY_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,
),
DEATH_STATS_TIME_ZONE: stringValue(
rawConfig.DEATH_STATS_TIME_ZONE,
rawConfig.TIME_ZONE,
mongodb.deathStatsTimeZone,
DEFAULT_CONFIG.DEATH_STATS_TIME_ZONE,
),
ANALYTICS_TIME_ZONE: stringValue(
rawConfig.ANALYTICS_TIME_ZONE,
rawConfig.TIME_ZONE,
mongodb.analyticsTimeZone,
DEFAULT_CONFIG.ANALYTICS_TIME_ZONE,
),
DAILY_ACTIVITY_RETENTION_DAYS: numberValue(
rawConfig.DAILY_ACTIVITY_RETENTION_DAYS,
mongodb.dailyActivityRetentionDays,
DEFAULT_CONFIG.DAILY_ACTIVITY_RETENTION_DAYS,
),
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);
}
+357
View File
@@ -0,0 +1,357 @@
import { createHash } from "node:crypto";
import { getConfig } from "./config.js";
import { getDb } from "./db.js";
import { readVisitorCookie, isValidVisitorId } from "./visitorCookie.js";
const DEFAULT_DAILY_METRICS_COLLECTION_NAME = "daily_metrics";
const DEFAULT_DAILY_VISITOR_ACTIVITY_COLLECTION_NAME = "daily_visitor_activity";
const DEFAULT_ACTIVITY_RETENTION_DAYS = 60;
const METRIC_FIELDS = [
"uniqueVisitors",
"totalVisits",
"totalMatchStarts",
"totalMatchFinishes",
"visitorsWithTwoOrMoreMatches",
"donationClicks",
];
const EVENT_CONFIG = {
"match-started": {
metricField: "totalMatchStarts",
activityField: "matchStarts",
},
"match-finished": {
metricField: "totalMatchFinishes",
activityField: "matchFinishes",
},
"donation-clicked": {
metricField: "donationClicks",
activityField: "donationClicks",
},
};
let metricsIndexesReady;
let activityIndexesReady;
export async function dailyMetricsRoutes(fastify) {
fastify.get("/today", async () => {
return getTodayDailyMetrics();
});
fastify.post("/match-started", async (request) => {
return recordDailyMetricEvent("match-started", {
visitorId: readVisitorCookie(request),
});
});
fastify.post("/match-finished", async (request) => {
return recordDailyMetricEvent("match-finished", {
visitorId: readVisitorCookie(request),
});
});
fastify.post("/donation-clicked", async (request) => {
return recordDailyMetricEvent("donation-clicked", {
visitorId: readVisitorCookie(request),
});
});
}
export async function recordDailyVisit(visitorId, { now = new Date() } = {}) {
const date = dayKey(now);
let uniqueVisitors = 0;
if (isValidVisitorId(visitorId)) {
const activityCollection = await getDailyVisitorActivityCollection();
const activityId = await ensureDailyVisitorActivity(
activityCollection,
date,
visitorId,
now,
);
const uniqueResult = await activityCollection.updateOne(
{
_id: activityId,
dailyUniqueCounted: { $ne: true },
},
{
$set: {
dailyUniqueCounted: true,
lastSeenAt: now,
},
$inc: {
visits: 1,
},
},
);
if (uniqueResult.modifiedCount > 0) {
uniqueVisitors = 1;
} else {
await activityCollection.updateOne(
{ _id: activityId },
{
$set: {
lastSeenAt: now,
},
$inc: {
visits: 1,
},
},
);
}
}
return updateDailyMetrics(date, now, {
totalVisits: 1,
uniqueVisitors,
});
}
export async function recordDailyMetricEvent(eventType, { visitorId, now = new Date() } = {}) {
const eventConfig = EVENT_CONFIG[eventType];
if (!eventConfig) {
throw new Error(`Unknown daily metric event: ${eventType}`);
}
const date = dayKey(now);
const increments = {
[eventConfig.metricField]: 1,
};
if (isValidVisitorId(visitorId)) {
const countedSecondMatch = await recordDailyVisitorEvent(
date,
visitorId,
eventConfig.activityField,
now,
);
if (countedSecondMatch) {
increments.visitorsWithTwoOrMoreMatches = 1;
}
}
return updateDailyMetrics(date, now, increments);
}
async function recordDailyVisitorEvent(date, visitorId, activityField, now) {
const activityCollection = await getDailyVisitorActivityCollection();
const activityId = await ensureDailyVisitorActivity(
activityCollection,
date,
visitorId,
now,
);
if (activityField !== "matchStarts") {
await activityCollection.updateOne(
{ _id: activityId },
{
$set: {
lastSeenAt: now,
},
$inc: {
[activityField]: 1,
},
},
);
return false;
}
const secondMatchResult = await activityCollection.updateOne(
{
_id: activityId,
matchStarts: 1,
},
{
$set: {
lastSeenAt: now,
},
$inc: {
matchStarts: 1,
},
},
);
if (secondMatchResult.modifiedCount > 0) {
return true;
}
await activityCollection.updateOne(
{ _id: activityId },
{
$set: {
lastSeenAt: now,
},
$inc: {
matchStarts: 1,
},
},
);
return false;
}
async function ensureDailyVisitorActivity(collection, date, visitorId, now) {
const visitorHash = dailyVisitorHash(date, visitorId);
const activityId = `${date}:${visitorHash}`;
await collection.updateOne(
{ _id: activityId },
{
$setOnInsert: {
_id: activityId,
date,
visitorHash,
dailyUniqueCounted: false,
visits: 0,
matchStarts: 0,
matchFinishes: 0,
donationClicks: 0,
firstSeenAt: now,
expireAt: retentionDate(now),
},
},
{ upsert: true },
);
return activityId;
}
async function updateDailyMetrics(date, now, increments) {
const collection = await getDailyMetricsCollection();
const normalizedIncrements = normalizeIncrements(increments);
await collection.updateOne(
{ _id: date },
{
$setOnInsert: {
_id: date,
date,
createdAt: now,
},
$set: {
updatedAt: now,
},
$inc: normalizedIncrements,
},
{ upsert: true },
);
const today = await collection.findOne({ _id: date });
return formatDailyMetrics(today, date);
}
async function getTodayDailyMetrics(now = new Date()) {
const date = dayKey(now);
const collection = await getDailyMetricsCollection();
const today = await collection.findOne({ _id: date });
return formatDailyMetrics(today, date);
}
async function getDailyMetricsCollection() {
const db = await getDb();
const collection = db.collection(
getConfig().MONGODB_DAILY_METRICS_COLLECTION || DEFAULT_DAILY_METRICS_COLLECTION_NAME,
);
await ensureDailyMetricsIndexes(collection);
return collection;
}
async function getDailyVisitorActivityCollection() {
const db = await getDb();
const collection = db.collection(
getConfig().MONGODB_DAILY_VISITOR_ACTIVITY_COLLECTION
|| DEFAULT_DAILY_VISITOR_ACTIVITY_COLLECTION_NAME,
);
await ensureDailyVisitorActivityIndexes(collection);
return collection;
}
async function ensureDailyMetricsIndexes(collection) {
if (!metricsIndexesReady) {
metricsIndexesReady = collection.createIndex({ updatedAt: -1 });
}
return metricsIndexesReady;
}
async function ensureDailyVisitorActivityIndexes(collection) {
if (!activityIndexesReady) {
activityIndexesReady = Promise.all([
collection.createIndex({ date: 1 }),
collection.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 }),
]);
}
return activityIndexesReady;
}
function normalizeIncrements(increments) {
return Object.entries(increments).reduce((result, [field, value]) => {
const numericValue = Math.max(0, Math.round(Number(value) || 0));
if (METRIC_FIELDS.includes(field) && numericValue > 0) {
result[field] = numericValue;
}
return result;
}, {});
}
function formatDailyMetrics(document, date) {
return {
date,
uniqueVisitors: metricNumber(document?.uniqueVisitors),
totalVisits: metricNumber(document?.totalVisits),
totalMatchStarts: metricNumber(document?.totalMatchStarts),
totalMatchFinishes: metricNumber(document?.totalMatchFinishes),
visitorsWithTwoOrMoreMatches: metricNumber(document?.visitorsWithTwoOrMoreMatches),
donationClicks: metricNumber(document?.donationClicks),
updatedAt: document?.updatedAt?.toISOString?.() ?? null,
};
}
function metricNumber(value) {
return Math.max(0, Math.round(Number(value) || 0));
}
function dailyVisitorHash(date, visitorId) {
return createHash("sha256")
.update(`${date}:${visitorId}`)
.digest("hex")
.slice(0, 32);
}
function retentionDate(now) {
const retentionDays = getConfig().DAILY_ACTIVITY_RETENTION_DAYS || DEFAULT_ACTIVITY_RETENTION_DAYS;
return new Date(now.getTime() + retentionDays * 24 * 60 * 60 * 1000);
}
function dayKey(date) {
const appConfig = getConfig();
const timeZone = appConfig.ANALYTICS_TIME_ZONE || appConfig.DEATH_STATS_TIME_ZONE || "Asia/Seoul";
try {
const parts = new Intl.DateTimeFormat("en-US", {
day: "2-digit",
month: "2-digit",
timeZone,
year: "numeric",
})
.formatToParts(date)
.reduce((result, part) => {
result[part.type] = part.value;
return result;
}, {});
return `${parts.year}-${parts.month}-${parts.day}`;
} catch {
return date.toISOString().slice(0, 10);
}
}
+52
View File
@@ -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();
}
+156
View File
@@ -0,0 +1,156 @@
import { getConfig } from "./config.js";
import { getDb } from "./db.js";
const DEFAULT_DAILY_COLLECTION_NAME = "daily_death_stats";
const SPECIES_KEYS = ["human", "orc", "skeleton", "slime", "wolf", "bear"];
let dailyIndexesReady;
export async function deathStatsRoutes(fastify) {
fastify.get("/today", async () => {
return getTodayDeathStats();
});
fastify.post("/today", async (request) => {
const payload = parseJsonBody(request.body);
const deathsBySpecies = normalizeDeathCounts(payload.deathsBySpecies);
const totalDeaths = totalCount(deathsBySpecies);
const now = new Date();
const date = dayKey(now);
const collection = await getDailyCollection();
await ensureDailyDeathStatsIndex(collection);
if (totalDeaths === 0) {
const today = await collection.findOne({ _id: date });
return {
saved: false,
today: formatDailyStats(today, date),
};
}
await collection.updateOne(
{ _id: date },
{
$setOnInsert: {
_id: date,
date,
createdAt: now,
},
$set: {
updatedAt: now,
},
$inc: {
battles: 1,
totalDeaths,
...speciesIncrements(deathsBySpecies),
},
},
{ upsert: true },
);
const today = await collection.findOne({ _id: date });
return {
saved: true,
today: formatDailyStats(today, date),
};
});
}
async function getTodayDeathStats() {
const collection = await getDailyCollection();
await ensureDailyDeathStatsIndex(collection);
const date = dayKey(new Date());
const today = await collection.findOne({ _id: date });
return formatDailyStats(today, date);
}
async function getDailyCollection() {
const db = await getDb();
return db.collection(
getConfig().MONGODB_DAILY_DEATH_COLLECTION || DEFAULT_DAILY_COLLECTION_NAME,
);
}
async function ensureDailyDeathStatsIndex(collection) {
if (!dailyIndexesReady) {
dailyIndexesReady = collection.createIndex({ updatedAt: -1 });
}
return dailyIndexesReady;
}
function parseJsonBody(body) {
if (!body) {
return {};
}
if (typeof body === "object") {
return body;
}
try {
return JSON.parse(body);
} catch {
return {};
}
}
function normalizeDeathCounts(value = {}) {
return SPECIES_KEYS.reduce((counts, species) => {
counts[species] = Math.max(0, Math.round(Number(value?.[species]) || 0));
return counts;
}, {});
}
function speciesIncrements(deathsBySpecies) {
return SPECIES_KEYS.reduce((increments, species) => {
increments[`deathsBySpecies.${species}`] = deathsBySpecies[species] ?? 0;
return increments;
}, {});
}
function formatDailyStats(document, date) {
const deathsBySpecies = normalizeDeathCounts(document?.deathsBySpecies);
return {
date,
battles: Math.max(0, Math.round(Number(document?.battles) || 0)),
deathsBySpecies,
totalDeaths: Math.max(
totalCount(deathsBySpecies),
Math.round(Number(document?.totalDeaths) || 0),
),
updatedAt: document?.updatedAt?.toISOString?.() ?? null,
};
}
function totalCount(deathsBySpecies) {
return SPECIES_KEYS.reduce((sum, species) => sum + (deathsBySpecies[species] ?? 0), 0);
}
function dayKey(date) {
const timeZone = getConfig().DEATH_STATS_TIME_ZONE || "Asia/Seoul";
try {
const parts = new Intl.DateTimeFormat("en-US", {
day: "2-digit",
month: "2-digit",
timeZone,
year: "numeric",
})
.formatToParts(date)
.reduce((result, part) => {
result[part.type] = part.value;
return result;
}, {});
return `${parts.year}-${parts.month}-${parts.day}`;
} catch {
return date.toISOString().slice(0, 10);
}
}
+156
View File
@@ -0,0 +1,156 @@
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 { aboutRoutes, warmAboutContent } from "./about.js";
import { dailyMetricsRoutes } from "./dailyMetrics.js";
import { deathStatsRoutes } from "./deathStats.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;
let viteDevServer;
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(),
};
});
if (!isProduction) {
const { createServer: createViteServer } = await import("vite");
viteDevServer = await createViteServer({
root,
server: {
middlewareMode: true,
hmr: {
server: app.server,
},
},
appType: "spa",
});
}
await app.register(visitorRoutes, { prefix: "/api/visitors" });
await app.register(aboutRoutes, { prefix: "/api" });
await app.register(deathStatsRoutes, { prefix: "/api/death-stats" });
await app.register(dailyMetricsRoutes, { prefix: "/api/daily-metrics" });
if (isProduction) {
await app.register(fastifyStatic, {
root: distPath,
prefix: "/",
cacheControl: true,
maxAge: 3600000 * 24 * 7,
immutable: true,
lastModified: true,
etag: true,
setHeaders: (res, path) => {
// index.html은 캐싱하지 않음 (업데이트 반영을 위해)
if (path.endsWith("index.html")) {
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
return;
}
// 나머지 정적 자산(이미지, js, css 등)은 강력 캐싱
res.setHeader("Cache-Control", "public, max-age=604800, immutable");
},
});
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 {
app.setNotFoundHandler((request, reply) => {
if (request.url.startsWith("/api/")) {
reply.code(404).send({ error: "not_found" });
return;
}
reply.hijack();
viteDevServer.middlewares(request.raw, reply.raw, (error) => {
if (error) {
viteDevServer.ssrFixStacktrace(error);
console.error(error);
if (!reply.raw.headersSent) {
reply.raw.statusCode = 500;
reply.raw.end(error.stack);
}
return;
}
if (!reply.raw.headersSent && !reply.raw.writableEnded) {
reply.raw.statusCode = 404;
reply.raw.end("Not found");
}
});
});
}
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 ? "Arena tracking is unavailable." : error.message,
});
});
await app.listen({ port, host });
console.log(`Arena Picker listening on http://localhost:${port}`);
if (hasMongoConfig()) {
getMongoClient()
.then(async () => {
console.log("MongoDB connection pool is ready.");
try {
await warmAboutContent();
console.log("About content cache is ready.");
} catch (error) {
console.error("About content cache warmup failed. API route will retry on request.", error);
}
})
.catch((error) => {
console.error("MongoDB connection failed. API routes 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);
});
}
+42
View File
@@ -0,0 +1,42 @@
export const VISITOR_COOKIE_NAME = "arena_visitor_id";
export const VISITOR_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 * 2;
export function readVisitorCookie(request) {
return readCookie(request, VISITOR_COOKIE_NAME);
}
export function writeVisitorCookie(reply, visitorId, { secure = false } = {}) {
const secureFlag = secure ? "; Secure" : "";
reply.header(
"Set-Cookie",
`${VISITOR_COOKIE_NAME}=${encodeURIComponent(visitorId)}; Path=/; Max-Age=${VISITOR_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax; HttpOnly${secureFlag}`,
);
}
export 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,
);
}
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 "";
}
}
+88
View File
@@ -0,0 +1,88 @@
import { randomUUID } from "node:crypto";
import { getConfig } from "./config.js";
import { getDb } from "./db.js";
import { recordDailyVisit } from "./dailyMetrics.js";
import { isValidVisitorId, readVisitorCookie, writeVisitorCookie } from "./visitorCookie.js";
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 = readVisitorCookie(request);
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, { secure: getConfig().COOKIE_SECURE });
}
try {
await recordDailyVisit(visitorId, { now });
} catch (error) {
request.log.warn({ err: error }, "Daily visit metrics update failed.");
}
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;
}
+293 -52
View File
@@ -1,58 +1,238 @@
export const GRID_SIZE = 50; // 1. ARENA 도메인
export const TILE_SIZE = 64; const GRID_SIZE = 50;
export const ARENA_SIZE = GRID_SIZE * TILE_SIZE; const TILE_SIZE = 64;
const ARENA_SIZE = GRID_SIZE * TILE_SIZE;
const RENDER_SIZE = 1280;
export const ATTACK_RANGE = 84; export const ARENA = {
export const ATTACK_COOLDOWN = 840; GRID_SIZE,
export const DEFAULT_TEAM_SIZE = 5; TILE_SIZE,
export const FIGHTER_SCALE = 3; SIZE: ARENA_SIZE,
export const MAX_TEAM_SIZE = 100;
export const MELEE_CRITICAL_CHANCE = 0.05;
export const MOVE_SPEED = 148;
export const PROJECTILE_LIFETIME = 1800;
export const PROJECTILE_SPEED = 420;
export const RANGED_CRITICAL_CHANCE = 0;
export const RANGED_ATTACK_RANGE = TILE_SIZE * 5;
export const MELEE_HIT_DELAY = 260;
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;
export const SPELL_CAST_DELAY = 340;
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_FIGHT_ZOOM = 2;
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 },
idle: { frameRate: 7, repeat: -1 },
walk: { frameRate: 10, repeat: -1 },
walk02: { frameRate: 10, repeat: -1 },
}; };
export const TEAM_COLORS = [ export const RENDER = {
HEIGHT: RENDER_SIZE,
WIDTH: RENDER_SIZE,
};
// 2. FIGHTER 도메인
export const FIGHTER = {
SCALE: 3,
DEPTH: 2,
DEAD_DEPTH: 1,
DEAD_DESPAWN_ALPHA: 0,
DEAD_DESPAWN_DELAY_MS: 5000,
FRAME_WIDTH: 100,
FRAME_HEIGHT: 100,
HITBOX_WIDTH: 22,
HITBOX_HEIGHT: 20,
HITBOX_OFFSET_X: 39,
HITBOX_OFFSET_Y: 40,
NICKNAME_LENGTH: 24,
// 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수
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 },
idle: { frameRate: 7, repeat: -1 },
walk: { frameRate: 10, repeat: -1 },
walk02: { frameRate: 10, repeat: -1 },
},
// 역할별 기본 스탯
TYPE_STATS: {
melee: {
maxHp: 100,
moveSpeed: 148 * 1.1,
attackRange: 84,
attackCooldown: 840,
damageMin: 14,
damageMax: 24,
criticalChance: 0.2,
windupDelay: 260,
},
ranged: {
maxHp: 80,
moveSpeed: 148,
attackRange: TILE_SIZE * 5,
attackCooldown: 840 * 1.1,
damageMin: 14 * 1.2,
damageMax: 24 * 1.2,
criticalChance: 0,
windupDelay: 360,
projectileSpeed: 420,
},
magic: {
maxHp: 80,
moveSpeed: 148,
attackRange: TILE_SIZE * 5,
attackCooldown: 840 * 1.1,
damageMin: 14 * 1.5,
damageMax: 24 * 1.5,
criticalChance: 0,
windupDelay: 340,
effectHitDelay: 160,
},
},
};
export const PERFORMANCE = {
LARGE_BATTLE_FIGHTER_THRESHOLD: 2000,
LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS: 0,
LARGE_BATTLE_SIMULATION_BUCKETS: 16,
LARGE_BATTLE_SIMULATION_MAX_DELTA_MS: 260,
LARGE_BATTLE_TARGET_INDEX_REFRESH_MS: 160,
LARGE_BATTLE_AGGREGATE_COMBAT_REFRESH_MS: 260,
LARGE_BATTLE_AGGREGATE_CELL_SIZE: TILE_SIZE * 5,
LARGE_BATTLE_AGGREGATE_SQUAD_SIZE: 100,
LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_CELL_TICK: 4,
LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_TICK: 80,
LARGE_BATTLE_AGGREGATE_MOVEMENT_RATIO: 0.72,
WORLD_EFFECT_MODIFIER_REFRESH_MS: 180,
TARGET_GRID_CELL_SIZE: TILE_SIZE * 4,
FIGHTER_HUD_POOL_SIZE: 48,
FIGHTER_HUD_VISIBLE_LIMIT: 32,
LARGE_BATTLE_HUD_VISIBLE_LIMIT: 8,
FIGHTER_HUD_VIEW_PADDING: TILE_SIZE * 2,
FIGHTER_HUD_CANDIDATE_REFRESH_MS: 120,
MINIMAP_DOT_RADIUS: 3,
MINIMAP_BACKGROUND_ALPHA: 0.62,
MINIMAP_BORDER_ALPHA: 0.84,
MINIMAP_REFRESH_MS: 220,
LARGE_BATTLE_SPRITE_RENDER_LIMIT: 140,
LARGE_BATTLE_ROLLING_WINDOW_SCALE: 1.05,
LARGE_BATTLE_SPRITE_VIEW_PADDING: TILE_SIZE * 2,
LARGE_BATTLE_LOD_REFRESH_MS: 180,
LARGE_BATTLE_DOT_REFRESH_MS: 220,
LARGE_BATTLE_DOT_SIZE: 6,
LARGE_BATTLE_DOT_ALPHA: 0.86,
};
// 3. SPAWN 도메인
export const SPAWN = {
DEFAULT_PLACEMENT: "random",
PLACEMENTS: {
RANDOM: "random",
STARTING_ZONES: "starting-zones",
},
// Caps participant-assigned slots; traits such as slime spawning may add fighters.
MAX_FIGHTER_COUNT: 8000,
FIGHTERS_PER_STARTING_ZONE: 100,
STARTING_ZONE_RADIUS: 2,
STARTING_ZONE_FILL_ALPHA: 0.07,
STARTING_ZONE_BORDER_ALPHA: 0.14,
STARTING_ZONE_VISIBLE_DURATION_MS: 2000,
PRESENTATION_TEAM_COUNT: 10,
PRESENTATION_TEAM_SIZE: 5,
};
// 4. COMBAT 도메인
export const COMBAT = {
KILL_HEALTH_RECOVERY_RATIO: 0.3,
KILL_HEAL_EFFECT_FRAMES: 4,
KILL_HEAL_EFFECT_FRAME_RATE: 12,
KILL_GROWTH_MULTIPLIER: 1.25,
KILL_GROWTH_MAX_MULTIPLIER: 5,
KILL_GROWTH_TWEEN_DURATION: 180,
// 최종교전 슬로우모션 설정
FINAL_SLOW_MOTION_ENABLED: false,
FINAL_SLOW_MOTION_ENTER_DURATION: 14000,
FINAL_SLOW_MOTION_HOLD_DURATION: 14000,
FINAL_SLOW_MOTION_EXIT_DURATION: 14000,
FINAL_SLOW_MOTION_SCALE: 0.28,
};
// 5. PROJECTILE 도메인
export const PROJECTILE = {
LIFETIME: 1800,
BODY_OFFSET: 4,
HIT_PADDING: 20,
HIT_RADIUS: 12,
SPAWN_DISTANCE: 1,
};
// 6. WORLD_EFFECT 도메인
export const WORLD_EFFECT = {
// Delay from match start until the first barrage.
INTERVAL: 8000,
// Delay between barrages after the first one has fired.
REPEAT_INTERVAL: 8000,
AREA_TILES: 40,
// How long the large dense-area warning marker remains visible.
WARNING_DURATION_MS: 2000,
IMPACT_AREA_TILES: 10,
IMPACT_COUNT_MIN: 15,
IMPACT_COUNT_MAX: 25,
IMPACT_STAGGER_MS: 140,
IMPACT_VISUAL_SCALE: 10,
SIZE_SCALE_VARIANCE: 0,
FRAMES: 7,
FRAME_RATE: 14,
FALL_DURATION: 920,
FALL_TRAVEL_TILES: 8,
METEOR_SHAKE_DURATION_MS: 150,
METEOR_SHAKE_INTENSITY: 0.004,
METEOR_DAMAGE: 90,
FROST_DAMAGE: 45,
FROST_STUN_DURATION: 2000,
FROST_STUN_TINT: 0x82e9ff,
FROST_DURATION: 2000,
FROST_SPEED_MULTIPLIER: 0.55,
SUDDEN_DEATH: {
ENABLED: false,
TRIGGER_MS: 10000,
INTERVAL_MS: 2000,
FORCE_FROST: false,
},
};
// 7. CAMERA 도메인
export const CAMERA = {
MIN_ZOOM: RENDER_SIZE / ARENA_SIZE,
MAX_ZOOM: 3,
ZOOM_STEP: 0.1,
// 자동 관전 진입 전 화염/냉기 메테오 낙하 위치를 임시로 확대 추적합니다.
METEOR_FOCUS_ENABLED: false,
METEOR_FOCUS_ZOOM: 2,
SPECTATOR_LERP: 0.05,
// 메테오 착탄 후 카메라를 해당 위치에 유지하는 시간(ms)입니다.
METEOR_FOCUS_HOLD_DURATION: 1200,
SPECTATOR_FINAL_FIGHTER_THRESHOLD: 5,
SPECTATOR_FINAL_FIGHT_ZOOM: 2,
SPECTATOR_FINAL_TEAM_COUNT: 2,
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD: 8,
SPECTATOR_RANDOM_FOCUS_INTERVAL: 100000,
SPECTATOR_LATE_FIGHTER_THRESHOLD: 80,
SPECTATOR_LATE_FIGHT_ZOOM: 1,
LARGE_BATTLE_START_ZOOM: 0.8,
SELECTED_FIGHTER_ZOOM: 0.8,
MANUAL_FOCUS_TWEEN_MS: 220,
MANUAL_FOCUS_TWEEN_EASE: "Sine.easeInOut",
};
// 8. UI 도메인
export const UI = {
MINIMAP_ALPHA: 0.8,
MINIMAP_MARGIN: Math.round(RENDER_SIZE * 0.016),
MINIMAP_VIEWPORT_SIZE: Math.round(RENDER_SIZE * 0.22),
MINIMAP_VIEW_FRAME_STROKE: Math.max(3, Math.round(RENDER_SIZE * 0.003125)),
SELECTED_FIGHTER_OUTLINE_GAP: 1,
SELECTED_FIGHTER_OUTLINE_WIDTH: 1,
SELECTED_FIGHTER_OUTLINE_RED: 255,
SELECTED_FIGHTER_OUTLINE_GREEN: 228,
SELECTED_FIGHTER_OUTLINE_BLUE: 64,
SELECTED_FIGHTER_OUTLINE_ALPHA: 0.65,
// 상단 종족별 사망 통계 공지 설정
BATTLE_NOTICE_DELAY_MS: 5000,
BATTLE_NOTICE_VISIBLE_MS: 2000,
BATTLE_NOTICE_INTERVAL_MS: 10000,
};
// 9. TEAM 도메인
const TEAM_COLORS = [
"#da6a48", "#da6a48",
"#5fb4d9", "#5fb4d9",
"#9bd15a", "#9bd15a",
@@ -62,3 +242,64 @@ export const TEAM_COLORS = [
"#63c5a6", "#63c5a6",
"#d98755", "#d98755",
]; ];
const TEAM_COLOR_GOLDEN_ANGLE = 137.508;
const TEAM_COLOR_HUE_OFFSET = 12;
const TEAM_COLOR_SATURATIONS = [72, 62, 78, 68];
const TEAM_COLOR_LIGHTNESSES = [57, 63, 51, 69];
function getTeamColor(index, totalTeams = TEAM_COLORS.length) {
const safeIndex = Math.max(0, Math.floor(Number(index) || 0));
const safeTeamCount = Math.max(1, Math.floor(Number(totalTeams) || 1));
if (safeTeamCount <= TEAM_COLORS.length) {
return TEAM_COLORS[safeIndex % TEAM_COLORS.length];
}
const hue =
(TEAM_COLOR_HUE_OFFSET + safeIndex * TEAM_COLOR_GOLDEN_ANGLE) % 360;
const saturation =
TEAM_COLOR_SATURATIONS[safeIndex % TEAM_COLOR_SATURATIONS.length];
const lightness =
TEAM_COLOR_LIGHTNESSES[
Math.floor(safeIndex / TEAM_COLOR_SATURATIONS.length) %
TEAM_COLOR_LIGHTNESSES.length
];
return hslToHex(hue, saturation, lightness);
}
function hslToHex(hue, saturation, lightness) {
const normalizedSaturation = saturation / 100;
const normalizedLightness = lightness / 100;
const chroma =
(1 - Math.abs(2 * normalizedLightness - 1)) * normalizedSaturation;
const huePrime = hue / 60;
const x = chroma * (1 - Math.abs((huePrime % 2) - 1));
const match = normalizedLightness - chroma / 2;
const [red, green, blue] =
huePrime < 1
? [chroma, x, 0]
: huePrime < 2
? [x, chroma, 0]
: huePrime < 3
? [0, chroma, x]
: huePrime < 4
? [0, x, chroma]
: huePrime < 5
? [x, 0, chroma]
: [chroma, 0, x];
return `#${[red, green, blue]
.map((channel) =>
Math.round((channel + match) * 255)
.toString(16)
.padStart(2, "0"),
)
.join("")}`;
}
export const TEAM = {
COLORS: TEAM_COLORS,
getColor: getTeamColor,
};
-350
View File
@@ -1,350 +0,0 @@
import Phaser from "phaser";
import {
ARENA_SIZE,
CAMERA_MAX_ZOOM,
CAMERA_MIN_ZOOM,
CAMERA_ZOOM_STEP,
MINIMAP_ALPHA,
MINIMAP_MARGIN,
MINIMAP_VIEWPORT_SIZE,
MINIMAP_VIEW_FRAME_OUTLINE,
MINIMAP_VIEW_FRAME_STROKE,
SPECTATOR_CAMERA_LERP,
SPECTATOR_FINAL_FIGHTER_THRESHOLD,
SPECTATOR_FINAL_FIGHT_ZOOM,
SPECTATOR_LATE_FIGHTER_THRESHOLD,
SPECTATOR_LATE_FIGHT_ZOOM,
} from "../constants.js";
import { drawArena } from "./arenaRenderer.js";
import { clearCombatObjects, updateFighter } from "./combat.js";
import { createFighterAnimations, preloadFighterSheets } from "./fighterAssets.js";
import { createFighter, syncFighterHud } from "./fighterFactory.js";
import { fighterManifest } from "./fighterManifest.js";
import { pickFighters } from "./fighterSelection.js";
import { createMatchSetup, matchStatusText } from "./matchSetup.js";
export class ArenaScene extends Phaser.Scene {
constructor({ getInitialMatchConfig, setStatus }) {
super("arena");
this.fighters = [];
this.getInitialMatchConfig = getInitialMatchConfig;
this.matchId = 0;
this.matchOver = false;
this.ready = false;
this.setStatus = (message) => {
// 기존 배너 제거
const oldBanner = document.querySelector(".victory-banner");
if (oldBanner) oldBanner.remove();
// 승리 또는 무승부 메시지인 경우 전용 배너 생성
if (message.includes("승리") || message.includes("무승부")) {
const banner = document.createElement("div");
banner.className = "victory-banner";
banner.textContent = message;
document.querySelector(".arena-shell").appendChild(banner);
}
};
this.observedCombat = [];
this.teams = [];
}
preload() {
preloadFighterSheets(this, fighterManifest);
}
create() {
this.physics.world.setBounds(0, 0, ARENA_SIZE, ARENA_SIZE);
this.cameras.main.setBounds(0, 0, ARENA_SIZE, ARENA_SIZE);
this.cameras.main.setBackgroundColor("#282819");
drawArena(this);
createFighterAnimations(this, fighterManifest);
// 미니맵 카메라 설정
this.minimapCamera = this.cameras
.add(MINIMAP_MARGIN, MINIMAP_MARGIN, MINIMAP_VIEWPORT_SIZE, MINIMAP_VIEWPORT_SIZE)
.setZoom(MINIMAP_VIEWPORT_SIZE / ARENA_SIZE)
.setName("minimap");
this.minimapCamera.setBackgroundColor(0x000000);
this.minimapCamera.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
this.minimapViewportFrame = this.add.graphics().setDepth(10);
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(
this.cameras.main.zoom + (deltaY > 0 ? -CAMERA_ZOOM_STEP : CAMERA_ZOOM_STEP),
CAMERA_MIN_ZOOM,
CAMERA_MAX_ZOOM,
);
this.setMainCameraZoom(newZoom);
// 확대 시 미니맵 표시
});
this.ready = true;
this.startMatch(this.getInitialMatchConfig());
}
startMatch({ names = [], teamSize } = {}) {
if (!this.ready) {
return;
}
if (names.length < 2) {
this.setStatus("참가자 닉네임을 2명 이상 입력하세요.");
return;
}
const matchSetup = createMatchSetup(names, teamSize);
const matchSkins = pickFighters(fighterManifest, matchSetup.fighters.length);
this.matchId += 1;
this.matchOver = false;
this.observedCombat = [];
this.setMainCameraZoom(CAMERA_MIN_ZOOM);
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
clearCombatObjects(this);
this.fighters.forEach((fighter) => fighter.destroy());
this.teams = matchSetup.teams;
this.fighters = matchSetup.fighters.map((fighterSetup, index) =>
createFighter(this, {
...fighterSetup,
skin: matchSkins[index],
}),
);
this.setStatus(matchStatusText(this.teams));
this.updateScoreboard();
}
update(time) {
this.fighters.forEach(syncFighterHud);
if (this.matchOver) {
this.updateMinimapViewportFrame();
return;
}
// 확대 상태일 때 생존 캐릭터들의 중앙으로 카메라 이동
const livingFighterCount = this.fighters.filter(isLivingFighter).length;
const forcedSpectatorZoom = getForcedSpectatorZoom(livingFighterCount);
if (forcedSpectatorZoom) {
this.setMainCameraZoom(forcedSpectatorZoom);
const combatCenter = this.getObservedCombatCenter();
if (combatCenter) {
// 소수점 단위 변동으로 인한 지터링 방지를 위해 반올림 처리 및 부드러운 이동(Lerp) 적용
const targetX = Math.round(combatCenter.x);
const targetY = Math.round(combatCenter.y);
// Move from the current world-space camera center toward the target.
this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP;
this.cameras.main.scrollY += (targetY - this.cameras.main.midPoint.y) * SPECTATOR_CAMERA_LERP;
}
} else if (this.cameras.main.zoom <= CAMERA_MIN_ZOOM) {
// 줌이 1일 때는 경기장 중앙에 고정
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
}
this.fighters.forEach((fighter) => {
updateFighter(this, fighter, time, () => {
this.updateScoreboard();
this.finishMatch();
});
});
this.updateMinimapViewportFrame();
}
setMainCameraZoom(zoom) {
const newZoom = Phaser.Math.Clamp(zoom, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM);
this.cameras.main.setZoom(newZoom);
if (newZoom === CAMERA_MIN_ZOOM) {
this.observedCombat = [];
}
this.minimapCamera.setAlpha(newZoom > CAMERA_MIN_ZOOM ? MINIMAP_ALPHA : 0);
this.updateMinimapViewportFrame();
}
updateMinimapViewportFrame() {
if (!this.minimapViewportFrame) {
return;
}
const camera = this.cameras.main;
this.minimapViewportFrame.clear();
this.minimapViewportFrame.setVisible(camera.zoom > CAMERA_MIN_ZOOM);
if (camera.zoom <= CAMERA_MIN_ZOOM) {
return;
}
const frameWidth = Math.min(camera.displayWidth, ARENA_SIZE);
const frameHeight = Math.min(camera.displayHeight, ARENA_SIZE);
const scrollX = camera.useBounds ? camera.clampX(camera.scrollX) : camera.scrollX;
const scrollY = camera.useBounds ? camera.clampY(camera.scrollY) : camera.scrollY;
const cameraMidX = scrollX + camera.width / 2;
const cameraMidY = scrollY + camera.height / 2;
const frameX = Phaser.Math.Clamp(cameraMidX - frameWidth / 2, 0, ARENA_SIZE - frameWidth);
const frameY = Phaser.Math.Clamp(cameraMidY - frameHeight / 2, 0, ARENA_SIZE - frameHeight);
this.minimapViewportFrame.lineStyle(MINIMAP_VIEW_FRAME_OUTLINE, 0x080a05, 0.95);
this.minimapViewportFrame.strokeRect(frameX, frameY, frameWidth, frameHeight);
this.minimapViewportFrame.lineStyle(MINIMAP_VIEW_FRAME_STROKE, 0xffe4a8, 1);
this.minimapViewportFrame.strokeRect(frameX, frameY, frameWidth, frameHeight);
}
observeCombat(attacker, defender) {
const canObserveCombat = Boolean(
getForcedSpectatorZoom(this.fighters.filter(isLivingFighter).length),
);
if (!canObserveCombat || !isLivingOpponentPair([attacker, defender])) {
return;
}
if (
!isLivingOpponentPair(this.observedCombat) ||
this.observedCombat.includes(attacker) ||
this.observedCombat.includes(defender)
) {
this.observedCombat = [attacker, defender];
}
}
getObservedCombatCenter() {
if (!isLivingOpponentPair(this.observedCombat)) {
this.observedCombat = findClosestOpponentPair(this.fighters) ?? [];
}
if (!isLivingOpponentPair(this.observedCombat)) {
return null;
}
const [fighterA, fighterB] = this.observedCombat;
return {
x: (fighterA.x + fighterB.x) / 2,
y: (fighterA.y + fighterB.y) / 2,
};
}
updateScoreboard() {
const scoreLeft = document.getElementById("score-left");
const scoreRight = document.getElementById("score-right");
if (!scoreLeft || !scoreRight) return;
scoreLeft.innerHTML = "";
scoreRight.innerHTML = "";
this.teams.forEach((team, index) => {
const aliveCount = this.fighters.filter(
(f) => f.team.id === team.id && !f.isDead
).length;
const teamEl = document.createElement("div");
teamEl.className = "team-score";
teamEl.style.backgroundColor = `${team.color}44`; // 44 is alpha for 26%
teamEl.style.borderLeft = `4px solid ${team.color}`;
teamEl.innerHTML = `<span>${team.label}</span> <span>${aliveCount}</span>`;
if (index % 2 === 0) {
scoreLeft.appendChild(teamEl);
} else {
scoreRight.appendChild(teamEl);
}
});
}
finishMatch() {
const livingFighters = this.fighters.filter((fighter) => !fighter.isDead);
const livingTeams = new Set(livingFighters.map((fighter) => fighter.team.id));
if (livingTeams.size > 1) {
return;
}
this.matchOver = true;
clearCombatObjects(this);
this.fighters.forEach((fighter) => {
if (fighter.body) {
fighter.body.setVelocity(0, 0);
}
});
if (livingTeams.size === 1) {
const winningTeamId = Array.from(livingTeams)[0];
const winningTeam = this.teams.find((team) => team.id === winningTeamId);
this.setStatus(`${winningTeam?.label ?? "Unknown"} 승리!`);
} else {
this.setStatus("무승부!");
}
}
}
function findClosestOpponentPair(fighters) {
let closestPair;
let closestDistance = Number.POSITIVE_INFINITY;
fighters.forEach((fighter, index) => {
if (!isLivingFighter(fighter)) {
return;
}
for (let candidateIndex = index + 1; candidateIndex < fighters.length; candidateIndex += 1) {
const candidate = fighters[candidateIndex];
if (!isLivingOpponentPair([fighter, candidate])) {
continue;
}
const distance = Phaser.Math.Distance.Between(fighter.x, fighter.y, candidate.x, candidate.y);
if (distance < closestDistance) {
closestDistance = distance;
closestPair = [fighter, candidate];
}
}
});
return closestPair;
}
function getForcedSpectatorZoom(livingFighterCount) {
if (livingFighterCount < SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
return SPECTATOR_FINAL_FIGHT_ZOOM;
}
if (livingFighterCount < SPECTATOR_LATE_FIGHTER_THRESHOLD) {
return SPECTATOR_LATE_FIGHT_ZOOM;
}
return null;
}
function isLivingOpponentPair(pair) {
if (pair.length !== 2) {
return false;
}
const [fighterA, fighterB] = pair;
return (
isLivingFighter(fighterA) &&
isLivingFighter(fighterB) &&
fighterA.team.id !== fighterB.team.id
);
}
function isLivingFighter(fighter) {
return fighter?.active && !fighter.isDead;
}
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
import {
ARENA,
SPAWN,
} from "../../constants.js";
export function drawArena(scene) {
const graphics = scene.add.graphics();
graphics.fillStyle(0x34351f, 1);
graphics.fillRect(0, 0, ARENA.SIZE, ARENA.SIZE);
graphics.fillStyle(0x556235, 0.12);
for (let row = 0; row < ARENA.GRID_SIZE; row += 1) {
for (let column = 0; column < ARENA.GRID_SIZE; column += 1) {
if ((row + column) % 2 === 0) {
graphics.fillRect(column * ARENA.TILE_SIZE, row * ARENA.TILE_SIZE, ARENA.TILE_SIZE, ARENA.TILE_SIZE);
}
}
}
graphics.lineStyle(1, 0xd3bd72, 0.11);
for (let index = 0; index <= ARENA.GRID_SIZE; index += 1) {
const offset = index * ARENA.TILE_SIZE;
graphics.lineBetween(offset, 0, offset, ARENA.SIZE);
graphics.lineBetween(0, offset, ARENA.SIZE, offset);
}
graphics.lineStyle(12, 0x17180e, 1);
graphics.strokeRect(0, 0, ARENA.SIZE, ARENA.SIZE);
graphics.lineStyle(2, 0xd3bd72, 0.35);
graphics.strokeRect(12, 12, ARENA.SIZE - 24, ARENA.SIZE - 24);
}
export function drawStartingZones(graphics, startingZones = []) {
graphics.clear();
startingZones.forEach((zone) => {
const color = Number.parseInt(zone.color.slice(1), 16);
const x = zone.columnStart * ARENA.TILE_SIZE;
const y = zone.rowStart * ARENA.TILE_SIZE;
const width = (zone.columnEnd - zone.columnStart) * ARENA.TILE_SIZE;
const height = (zone.rowEnd - zone.rowStart) * ARENA.TILE_SIZE;
graphics.fillStyle(color, SPAWN.STARTING_ZONE_FILL_ALPHA);
graphics.fillRect(x, y, width, height);
graphics.lineStyle(2, color, SPAWN.STARTING_ZONE_BORDER_ALPHA);
graphics.strokeRect(x, y, width, height);
});
}
+144
View File
@@ -0,0 +1,144 @@
import {
CAMERA,
} from "../../constants.js";
import {
fighterDistanceSquared,
fighterWorldPoint,
} from "../fighter/fighterAdapter.js";
export function getSpectatorState(livingFighters) {
const livingFighterCount = livingFighters.length;
const teamSummaries = getLivingTeamSummaries(livingFighters);
if (livingFighterCount < CAMERA.SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
return {
isFinal: true,
mode: "final-random",
zoom: CAMERA.SPECTATOR_FINAL_FIGHT_ZOOM,
};
}
if (
teamSummaries.length === CAMERA.SPECTATOR_FINAL_TEAM_COUNT &&
livingFighterCount <= CAMERA.SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD
) {
return {
isFinal: true,
mode: "final-underdog",
teamId: getUnderdogTeamId(teamSummaries),
zoom: CAMERA.SPECTATOR_FINAL_FIGHT_ZOOM,
};
}
if (livingFighterCount < CAMERA.SPECTATOR_LATE_FIGHTER_THRESHOLD) {
return {
isFinal: false,
mode: "late",
zoom: CAMERA.SPECTATOR_LATE_FIGHT_ZOOM,
};
}
return null;
}
export function getLivingTeamSummaries(livingFighters) {
const summaries = new Map();
livingFighters.forEach((fighter) => {
const teamId = fighter.team.id;
const summary = summaries.get(teamId) ?? {
count: 0,
teamId,
};
summary.count += 1;
summaries.set(teamId, summary);
});
return Array.from(summaries.values());
}
export function getUnderdogTeamId(teamSummaries) {
const sortedTeams = [...teamSummaries].sort((left, right) => left.count - right.count);
if (sortedTeams.length < 2 || sortedTeams[0].count === sortedTeams[1].count) {
return null;
}
return sortedTeams[0].teamId;
}
export function averageFighterPosition(fighters) {
if (fighters.length === 0) {
return null;
}
const total = fighters.reduce(
(position, fighter) => {
const point = fighterCameraPoint(fighter);
position.x += point.x;
position.y += point.y;
return position;
},
{ x: 0, y: 0 },
);
return {
x: total.x / fighters.length,
y: total.y / fighters.length,
};
}
export function fighterCameraPoint(fighter) {
if (!fighter) {
return null;
}
return fighterWorldPoint(fighter);
}
export function findClosestOpponentPair(fighters) {
let closestPair;
let closestDistance = Number.POSITIVE_INFINITY;
fighters.forEach((fighter, index) => {
if (!isLivingFighter(fighter)) {
return;
}
for (let candidateIndex = index + 1; candidateIndex < fighters.length; candidateIndex += 1) {
const candidate = fighters[candidateIndex];
if (!isLivingOpponentPair([fighter, candidate])) {
continue;
}
const distance = fighterDistanceSquared(fighter, candidate);
if (distance < closestDistance) {
closestDistance = distance;
closestPair = [fighter, candidate];
}
}
});
return closestPair;
}
export function isLivingOpponentPair(pair) {
if (pair.length !== 2) {
return false;
}
const [fighterA, fighterB] = pair;
return (
isLivingFighter(fighterA) &&
isLivingFighter(fighterB) &&
fighterA.team.id !== fighterB.team.id
);
}
export function isLivingFighter(fighter) {
return fighter?.active && !fighter.isDead;
}
+135
View File
@@ -0,0 +1,135 @@
self.onmessage = (event) => {
const job = event.data;
if (job?.type !== "fighter-lod-job") {
return;
}
const detailedIds = resolveDetailedIds(job);
self.postMessage(
{
detailedIds,
jobId: job.jobId,
matchId: job.matchId,
type: "fighter-lod-result",
},
[detailedIds.buffer],
);
};
function resolveDetailedIds(job) {
const modelIds = job.modelIds;
const count = modelIds?.length ?? 0;
const included = new Uint8Array(count);
const detailedIds = [];
const selectedIndex = indexOfModelId(modelIds, job.selectedModelId);
if (selectedIndex >= 0) {
addIndex(modelIds, included, detailedIds, selectedIndex);
}
if (job.fullArenaOverview) {
addRepresentativeDetails(job, included, detailedIds);
return Int32Array.from(detailedIds);
}
addRollingWindowDetails(job, included, detailedIds);
if (detailedIds.length <= (selectedIndex >= 0 ? 1 : 0)) {
addRepresentativeDetails(job, included, detailedIds);
}
return Int32Array.from(detailedIds);
}
function addRollingWindowDetails(job, included, detailedIds) {
const modelIds = job.modelIds;
const x = job.x;
const y = job.y;
for (let index = 0; index < modelIds.length; index += 1) {
if (
included[index]
|| x[index] < job.rollingLeft
|| x[index] > job.rollingRight
|| y[index] < job.rollingTop
|| y[index] > job.rollingBottom
) {
continue;
}
addIndex(modelIds, included, detailedIds, index);
}
}
function addRepresentativeDetails(job, included, detailedIds) {
const detailLimit = Math.max(1, Math.round(Number(job.baseDetailLimit) || 1));
const remainingLimit = detailLimit - detailedIds.length;
if (remainingLimit <= 0) {
return;
}
const modelIds = job.modelIds;
const teamKeys = job.teamKeys;
const groupsByTeam = new Map();
for (let index = 0; index < modelIds.length; index += 1) {
if (included[index]) {
continue;
}
const teamKey = teamKeys[index] || 0;
let indexes = groupsByTeam.get(teamKey);
if (!indexes) {
indexes = [];
groupsByTeam.set(teamKey, indexes);
}
indexes.push(index);
}
const teamCount = Math.max(1, groupsByTeam.size);
const quotaPerTeam = Math.max(1, Math.floor(remainingLimit / teamCount));
groupsByTeam.forEach((indexes) => {
const step = Math.max(1, Math.ceil(indexes.length / quotaPerTeam));
for (
let offset = 0;
offset < indexes.length && detailedIds.length < detailLimit;
offset += step
) {
addIndex(modelIds, included, detailedIds, indexes[offset]);
}
});
for (let index = 0; index < modelIds.length && detailedIds.length < detailLimit; index += 1) {
addIndex(modelIds, included, detailedIds, index);
}
}
function indexOfModelId(modelIds, modelId) {
if (!modelId) {
return -1;
}
for (let index = 0; index < modelIds.length; index += 1) {
if (modelIds[index] === modelId) {
return index;
}
}
return -1;
}
function addIndex(modelIds, included, detailedIds, index) {
if (included[index]) {
return;
}
included[index] = 1;
detailedIds.push(modelIds[index]);
}
-29
View File
@@ -1,29 +0,0 @@
import { ARENA_SIZE, GRID_SIZE, TILE_SIZE } from "../constants.js";
export function drawArena(scene) {
const graphics = scene.add.graphics();
graphics.fillStyle(0x34351f, 1);
graphics.fillRect(0, 0, ARENA_SIZE, ARENA_SIZE);
graphics.fillStyle(0x556235, 0.12);
for (let row = 0; row < GRID_SIZE; row += 1) {
for (let column = 0; column < GRID_SIZE; column += 1) {
if ((row + column) % 2 === 0) {
graphics.fillRect(column * TILE_SIZE, row * TILE_SIZE, TILE_SIZE, TILE_SIZE);
}
}
}
graphics.lineStyle(1, 0xd3bd72, 0.11);
for (let index = 0; index <= GRID_SIZE; index += 1) {
const offset = index * TILE_SIZE;
graphics.lineBetween(offset, 0, offset, ARENA_SIZE);
graphics.lineBetween(0, offset, ARENA_SIZE, offset);
}
graphics.lineStyle(12, 0x17180e, 1);
graphics.strokeRect(0, 0, ARENA_SIZE, ARENA_SIZE);
graphics.lineStyle(2, 0xd3bd72, 0.35);
graphics.strokeRect(12, 12, ARENA_SIZE - 24, ARENA_SIZE - 24);
}
-401
View File
@@ -1,401 +0,0 @@
import Phaser from "phaser";
import {
ATTACK_COOLDOWN,
ATTACK_RANGE,
FIGHTER_SCALE,
MELEE_HIT_DELAY,
MELEE_CRITICAL_CHANCE,
MOVE_SPEED,
PROJECTILE_BODY_OFFSET,
PROJECTILE_FIRE_DELAY,
PROJECTILE_HIT_PADDING,
PROJECTILE_HIT_RADIUS,
PROJECTILE_LIFETIME,
PROJECTILE_SPAWN_DISTANCE,
PROJECTILE_SPEED,
SPELL_CAST_DELAY,
SPELL_HIT_DELAY,
RANGED_CRITICAL_CHANCE,
RANGED_ATTACK_RANGE,
} from "../constants.js";
import {
getAttackSpeedMultiplier,
getMovementSpeedMultiplier,
} from "./combatSettings.js";
import {
fighterAnimationKey,
fighterAttackEffectAnimationKey,
fighterAttackEffectKey,
fighterProjectileKey,
} from "./fighterAssets.js";
export function updateFighter(scene, fighter, time, onWinner) {
const enemy = findNearestEnemy(scene.fighters, fighter);
if (!enemy || fighter.isDead || enemy.isDead || fighter.isLocked) {
fighter.body.setVelocity(0, 0);
return;
}
const distance = Phaser.Math.Distance.Between(fighter.x, fighter.y, enemy.x, enemy.y);
fighter.setFlipX(enemy.x < fighter.x);
if (distance > getAttackRange(fighter)) {
scene.physics.moveToObject(fighter, enemy, MOVE_SPEED * getMovementSpeedMultiplier());
playIfNeeded(fighter, "walk");
return;
}
fighter.body.setVelocity(0, 0);
if (time >= fighter.nextAttackAt) {
beginAttack(scene, fighter, enemy, time, onWinner);
return;
}
playIfNeeded(fighter, "idle");
}
export function clearCombatObjects(scene) {
scene.combatObjects?.forEach((object) => {
object.cleanup?.();
object.destroy();
});
scene.combatObjects?.clear();
}
function beginAttack(scene, attacker, defender, time, onWinner) {
const attack = createAttackProfile(attacker);
attacker.nextAttackAt = time + scaledAttackDelay(attacker.skin.combat?.cooldown ?? ATTACK_COOLDOWN);
attacker.isLocked = true;
scene.observeCombat?.(attacker, defender);
playAnimation(attacker, attack.animation, getAttackSpeedMultiplier());
switch (getCombatType(attacker)) {
case "projectile":
queueProjectile(scene, attacker, defender, onWinner);
return;
case "instant-spell":
queueInstantSpell(scene, attacker, defender, onWinner);
return;
default:
queueMeleeHit(scene, attacker, defender, onWinner, attack);
}
}
function queueMeleeHit(scene, attacker, defender, onWinner, attack) {
const matchId = scene.matchId;
scene.time.delayedCall(scaledAttackDelay(MELEE_HIT_DELAY), () => {
applyHit(scene, attacker, defender, onWinner, matchId, {
instantKill: attack.isCritical,
});
});
}
function queueProjectile(scene, attacker, defender, onWinner) {
const matchId = scene.matchId;
scene.time.delayedCall(scaledAttackDelay(PROJECTILE_FIRE_DELAY), () => {
if (!isAttackValid(scene, attacker, defender, matchId)) {
return;
}
spawnProjectile(scene, attacker, defender, onWinner, matchId);
});
}
function queueInstantSpell(scene, attacker, defender, onWinner) {
const matchId = scene.matchId;
scene.time.delayedCall(scaledAttackDelay(SPELL_CAST_DELAY), () => {
if (!isAttackValid(scene, attacker, defender, matchId)) {
return;
}
spawnSpellEffect(scene, attacker, defender, onWinner, matchId);
});
}
function spawnProjectile(scene, attacker, defender, onWinner, matchId) {
const defenderHitPoint = fighterHitPoint(defender);
const projectileOrigin = projectileSpawnPoint(attacker, defenderHitPoint);
const projectile = scene.physics.add.image(
projectileOrigin.x,
projectileOrigin.y,
fighterProjectileKey(attacker.skin),
);
projectile.setDepth(3);
projectile.setScale(2);
projectile.body.setCircle(
PROJECTILE_HIT_RADIUS,
PROJECTILE_BODY_OFFSET,
PROJECTILE_BODY_OFFSET,
);
projectile.setRotation(
Phaser.Math.Angle.Between(
projectile.x,
projectile.y,
defenderHitPoint.x,
defenderHitPoint.y,
),
);
scene.physics.moveTo(
projectile,
defenderHitPoint.x,
defenderHitPoint.y,
(attacker.skin.combat?.projectile?.speed ?? PROJECTILE_SPEED) * getAttackSpeedMultiplier(),
);
trackCombatObject(scene, projectile);
projectile.lastHitCheckX = projectile.x;
projectile.lastHitCheckY = projectile.y;
const hitDefender = () => {
if (projectile.hasHit) {
return;
}
if (!isAttackValid(scene, attacker, defender, matchId)) {
disposeCombatObject(scene, projectile);
return;
}
projectile.hasHit = true;
disposeCombatObject(scene, projectile);
applyHit(scene, attacker, defender, onWinner, matchId);
};
const overlap = scene.physics.add.overlap(projectile, defender, hitDefender);
const checkProjectilePath = () => {
if (!projectile.active || projectile.hasHit) {
return;
}
if (!isAttackValid(scene, attacker, defender, matchId)) {
disposeCombatObject(scene, projectile);
return;
}
if (projectilePathHitsDefender(projectile, defender)) {
hitDefender();
return;
}
projectile.lastHitCheckX = projectile.x;
projectile.lastHitCheckY = projectile.y;
};
scene.events.on(Phaser.Scenes.Events.UPDATE, checkProjectilePath);
projectile.cleanup = () => {
overlap.destroy();
scene.events.off(Phaser.Scenes.Events.UPDATE, checkProjectilePath);
};
scene.time.delayedCall(PROJECTILE_LIFETIME, () => {
disposeCombatObject(scene, projectile);
});
}
function spawnSpellEffect(scene, attacker, defender, onWinner, matchId) {
const effect = scene.add.sprite(defender.x, defender.y, fighterAttackEffectKey(attacker.skin));
effect.setDepth(3);
effect.setScale(FIGHTER_SCALE);
effect.play(fighterAttackEffectAnimationKey(attacker.skin));
trackCombatObject(scene, effect);
effect.once(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
disposeCombatObject(scene, effect);
});
scene.time.delayedCall(scaledAttackDelay(attacker.skin.combat?.attackEffect?.hitDelay ?? SPELL_HIT_DELAY), () => {
applyHit(scene, attacker, defender, onWinner, matchId);
});
}
function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill = false } = {}) {
if (!isAttackValid(scene, attacker, defender, matchId)) {
return;
}
defender.hp = instantKill ? 0 : Math.max(0, defender.hp - Phaser.Math.Between(14, 24));
defender.body.setVelocity(0, 0);
if (defender.hp === 0) {
killFighter(defender, attacker, onWinner);
return;
}
defender.isLocked = true;
playAnimation(defender, "hurt");
if (instantKill) {
scene.cameras.main.shake(90, 0.002);
}
}
function getAttackRange(fighter) {
if (getCombatType(fighter) === "melee") {
return ATTACK_RANGE;
}
return fighter.skin.combat?.range ?? RANGED_ATTACK_RANGE;
}
function getCombatType(fighter) {
return fighter.skin.combat?.type ?? "melee";
}
function createAttackProfile(attacker) {
const isCritical = Math.random() < getCriticalChance(attacker);
return {
animation:
isCritical && attacker.skin.animations.attack03 ? "attack03" : "attack",
isCritical,
};
}
function getCriticalChance(fighter) {
if (getCombatType(fighter) !== "melee") {
return fighter.skin.combat?.criticalChance ?? RANGED_CRITICAL_CHANCE;
}
return fighter.skin.combat?.criticalChance ?? MELEE_CRITICAL_CHANCE;
}
function isAttackValid(scene, attacker, defender, matchId) {
return (
!scene.matchOver &&
matchId === scene.matchId &&
attacker.active &&
defender.active &&
!attacker.isDead &&
!defender.isDead
);
}
function fighterHitPoint(fighter) {
if (!fighter.body) {
return { x: fighter.x, y: fighter.y };
}
return {
x: fighter.body.center.x,
y: fighter.body.center.y,
};
}
function projectileSpawnPoint(attacker, target) {
const direction = new Phaser.Math.Vector2(target.x - attacker.x, target.y - attacker.y);
if (direction.lengthSq() === 0) {
return { x: attacker.x, y: attacker.y };
}
direction.normalize();
return {
x: attacker.x + direction.x * PROJECTILE_SPAWN_DISTANCE,
y: attacker.y + direction.y * PROJECTILE_SPAWN_DISTANCE,
};
}
function projectilePathHitsDefender(projectile, defender) {
if (!defender.body) {
return false;
}
const projectilePath = new Phaser.Geom.Line(
projectile.lastHitCheckX,
projectile.lastHitCheckY,
projectile.x,
projectile.y,
);
const defenderHitArea = new Phaser.Geom.Rectangle(
defender.body.x - PROJECTILE_HIT_PADDING,
defender.body.y - PROJECTILE_HIT_PADDING,
defender.body.width + PROJECTILE_HIT_PADDING * 2,
defender.body.height + PROJECTILE_HIT_PADDING * 2,
);
return (
Phaser.Geom.Rectangle.Contains(defenderHitArea, projectile.x, projectile.y) ||
Phaser.Geom.Intersects.LineToRectangle(projectilePath, defenderHitArea)
);
}
function killFighter(defender, winner, onWinner) {
defender.isDead = true;
defender.isLocked = true;
defender.body.setVelocity(0, 0);
defender.body.enable = false;
defender.healthBar.width = 0;
playAnimation(defender, "death");
winner.isLocked = false;
winner.body.setVelocity(0, 0);
playAnimation(winner, "idle");
onWinner(winner);
}
function findNearestEnemy(fighters, fighter) {
let nearestEnemy;
let nearestDistance = Number.POSITIVE_INFINITY;
fighters.forEach((candidate) => {
if (
candidate === fighter ||
candidate.isDead ||
candidate.team.id === fighter.team.id
) {
return;
}
const distance = Phaser.Math.Distance.Between(
fighter.x,
fighter.y,
candidate.x,
candidate.y,
);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestEnemy = candidate;
}
});
return nearestEnemy;
}
function playIfNeeded(fighter, action) {
const key = fighterAnimationKey(fighter.skin, action);
if (fighter.anims.currentAnim?.key !== key) {
playAnimation(fighter, action);
}
}
function playAnimation(fighter, action, timeScale = 1) {
fighter.anims.timeScale = timeScale;
fighter.play(fighterAnimationKey(fighter.skin, action), true);
}
function scaledAttackDelay(duration) {
return duration / getAttackSpeedMultiplier();
}
function trackCombatObject(scene, object) {
scene.combatObjects ??= new Set();
scene.combatObjects.add(object);
}
function disposeCombatObject(scene, object) {
if (!object?.active) {
return;
}
object.cleanup?.();
scene.combatObjects?.delete(object);
object.destroy();
}
+496
View File
@@ -0,0 +1,496 @@
globalThis.onmessage = (event) => {
const job = event.data;
if (!job?.modelIds || !job?.config) {
return;
}
const result = runAggregateJob(job);
globalThis.postMessage(result, [
result.modelIds.buffer,
result.x.buffer,
result.y.buffer,
result.hp.buffer,
result.deadDefenderIds.buffer,
result.deadAttackerIds.buffer,
]);
};
function runAggregateJob(job) {
const state = {
alive: aliveArray(job.hp),
damageCursor: Math.max(0, Math.round(Number(job.damageCursor) || 0)),
deadAttackerIds: new Int32Array(resolveMaxDeathsPerTick(job.config)),
deadCount: 0,
deadDefenderIds: new Int32Array(resolveMaxDeathsPerTick(job.config)),
winnerCursor: Math.max(0, Math.round(Number(job.winnerCursor) || 0)),
};
const aggregateState = buildAggregateSquadState(job, state);
assignAggregateSquadTargets(aggregateState, job.config);
advanceAggregateSquads(aggregateState.squads, job.tickDelta, job.config);
resolveAggregateSquadCombatState(job, aggregateState.cells, state);
syncAggregateSquadMembers(job, aggregateState.squads, state);
return {
damageCursor: state.damageCursor,
deadAttackerIds: state.deadAttackerIds,
deadCount: state.deadCount,
deadDefenderIds: state.deadDefenderIds,
hp: job.hp,
jobId: job.jobId,
matchId: job.matchId,
modelIds: job.modelIds,
type: "aggregate-result",
winnerCursor: state.winnerCursor,
x: job.x,
y: job.y,
};
}
function aliveArray(hp) {
const alive = new Uint8Array(hp.length);
for (let index = 0; index < hp.length; index += 1) {
alive[index] = hp[index] > 0 ? 1 : 0;
}
return alive;
}
function buildAggregateSquadState(job, state) {
const { config, teamKeys, x, y } = job;
const cellSize = Math.max(1, Number(config.cellSize) || 1);
const maxCellX = Math.floor((config.arenaSize - 1) / cellSize);
const maxCellY = Math.floor((config.arenaSize - 1) / cellSize);
const columns = maxCellX + 1;
const cellsByKey = new Map();
const cells = [];
for (let index = 0; index < job.modelIds.length; index += 1) {
if (!state.alive[index]) {
continue;
}
const cellX = clampCell(x[index], cellSize, maxCellX);
const cellY = clampCell(y[index], cellSize, maxCellY);
const key = targetCellIndex(cellX, cellY, columns);
let cell = cellsByKey.get(key);
if (!cell) {
cell = {
cellX,
cellY,
key,
squads: [],
teams: new Map(),
};
cellsByKey.set(key, cell);
cells.push(cell);
}
const teamKey = teamKeys[index] || 0;
let group = cell.teams.get(teamKey);
if (!group) {
group = {
indexes: [],
teamKey,
};
cell.teams.set(teamKey, group);
}
group.indexes.push(index);
}
return {
cells,
squads: createAggregateSquads(cells, job, state),
};
}
function createAggregateSquads(cells, job, state) {
const squads = [];
const squadSize = Math.max(1, Math.round(Number(job.config.squadSize) || 100));
cells.forEach((cell) => {
cell.teams.forEach((group) => {
for (let offset = 0; offset < group.indexes.length; offset += squadSize) {
const indexes = group.indexes.slice(offset, offset + squadSize);
if (indexes.length === 0) {
continue;
}
const squad = createAggregateSquad(cell, group.teamKey, indexes, offset / squadSize, job, state);
squads.push(squad);
cell.squads.push(squad);
}
});
});
return squads;
}
function createAggregateSquad(cell, teamKey, indexes, chunkIndex, job, state) {
const center = averageAggregatePosition(indexes, job);
const count = livingCount(indexes, state);
return {
averageMoveSpeed: averageAggregateMoveSpeed(indexes, job, state),
centerX: center.x,
centerY: center.y,
count,
dps: aggregateGroupDamage(indexes, job, state),
indexes,
radius: resolveAggregateSquadRadius(count, job.config),
seed: aggregateSquadSeed(cell.key, teamKey, chunkIndex),
teamKey,
targetX: null,
targetY: null,
};
}
function averageAggregatePosition(indexes, { x, y }) {
let totalX = 0;
let totalY = 0;
indexes.forEach((index) => {
totalX += x[index];
totalY += y[index];
});
const count = Math.max(1, indexes.length);
return {
x: totalX / count,
y: totalY / count,
};
}
function averageAggregateMoveSpeed(indexes, job, state) {
let count = 0;
let totalSpeed = 0;
indexes.forEach((index) => {
if (!state.alive[index]) {
return;
}
count += 1;
totalSpeed += job.moveSpeed[index];
});
return count > 0 ? totalSpeed / count : 0;
}
function aggregateGroupDamage(indexes, job, state) {
let damage = 0;
indexes.forEach((index) => {
if (!state.alive[index] || job.isFrostStunned[index]) {
return;
}
damage += job.damagePerSecond[index];
});
return damage;
}
function resolveAggregateSquadRadius(count, config) {
const spacing = Math.max(1, Number(config.squadSpacing) || 1);
return Math.max(
(Number(config.tileSize) || 64) * 0.42,
Math.sqrt(Math.max(1, count)) * spacing,
);
}
function assignAggregateSquadTargets({ squads }, config) {
squads.forEach((squad) => {
const targetSquad = findNearestAggregateEnemySquad(squads, squad);
if (!targetSquad) {
squad.targetX = null;
squad.targetY = null;
return;
}
const spread = Math.max(1, Number(config.cellSize) || 1) * 0.18;
const offsetX = (((squad.seed % 997) / 997) - 0.5) * spread;
const offsetY = ((((squad.seed * 31) % 991) / 991) - 0.5) * spread;
squad.targetX = clamp(
targetSquad.centerX + offsetX,
config.halfWidth,
config.arenaSize - config.halfWidth,
);
squad.targetY = clamp(
targetSquad.centerY + offsetY,
config.halfHeight,
config.arenaSize - config.halfHeight,
);
});
}
function findNearestAggregateEnemySquad(squads, sourceSquad) {
let nearestSquad = null;
let nearestDistance = Number.POSITIVE_INFINITY;
squads.forEach((candidateSquad) => {
if (
candidateSquad === sourceSquad
|| candidateSquad.teamKey === sourceSquad.teamKey
|| candidateSquad.count <= 0
) {
return;
}
const deltaX = candidateSquad.centerX - sourceSquad.centerX;
const deltaY = candidateSquad.centerY - sourceSquad.centerY;
const distance = deltaX * deltaX + deltaY * deltaY;
if (distance < nearestDistance) {
nearestDistance = distance;
nearestSquad = candidateSquad;
}
});
return nearestSquad;
}
function advanceAggregateSquads(squads, tickDelta, config) {
const seconds = Math.max(0, Number(tickDelta) || 0) / 1000;
if (seconds <= 0) {
return;
}
const movementRatio = clamp(Number(config.movementRatio) || 1, 0, 2);
squads.forEach((squad) => {
if (
squad.count <= 0
|| !Number.isFinite(squad.targetX)
|| !Number.isFinite(squad.targetY)
) {
return;
}
const deltaX = squad.targetX - squad.centerX;
const deltaY = squad.targetY - squad.centerY;
const distance = Math.hypot(deltaX, deltaY);
if (distance <= 4) {
return;
}
const step = Math.min(distance, squad.averageMoveSpeed * movementRatio * seconds);
squad.centerX = clamp(
squad.centerX + (deltaX / distance) * step,
config.halfWidth,
config.arenaSize - config.halfWidth,
);
squad.centerY = clamp(
squad.centerY + (deltaY / distance) * step,
config.halfHeight,
config.arenaSize - config.halfHeight,
);
});
}
function resolveAggregateSquadCombatState(job, cells, state) {
const maxDeathsPerCell = Math.max(
1,
Math.round(Number(job.config.maxDeathsPerCellTick) || 1),
);
const maxDeathsPerTick = resolveMaxDeathsPerTick(job.config);
for (let index = 0; index < cells.length && state.deadCount < maxDeathsPerTick; index += 1) {
const groups = (cells[index].squads ?? [])
.filter((squad) => squad.count > 0 && squadHasLivingMembers(squad, state))
.sort((left, right) => right.count - left.count);
if (groups.length < 2) {
continue;
}
const leftGroup = groups[0];
const rightGroup = groups.find((candidate) => candidate.teamKey !== leftGroup.teamKey);
if (!rightGroup) {
continue;
}
const cellDeathBudget = Math.min(
maxDeathsPerCell,
maxDeathsPerTick - state.deadCount,
);
let cellDeaths = 0;
cellDeaths += applyAggregateDamage(
job,
state,
rightGroup.indexes,
leftGroup.indexes,
(leftGroup.dps * Math.max(0, Number(job.tickDelta) || 0)) / 1000,
cellDeathBudget - cellDeaths,
);
if (cellDeaths < cellDeathBudget) {
applyAggregateDamage(
job,
state,
leftGroup.indexes,
rightGroup.indexes,
(rightGroup.dps * Math.max(0, Number(job.tickDelta) || 0)) / 1000,
cellDeathBudget - cellDeaths,
);
}
}
}
function applyAggregateDamage(job, state, defenderIndexes, attackerIndexes, damage, maxDeaths) {
if (damage <= 0 || maxDeaths <= 0 || defenderIndexes.length === 0) {
return 0;
}
const startIndex = state.damageCursor % defenderIndexes.length;
let remainingDamage = damage;
let resolvedDeaths = 0;
state.damageCursor += 1;
for (
let checked = 0;
checked < defenderIndexes.length && remainingDamage > 0 && resolvedDeaths < maxDeaths;
checked += 1
) {
const modelIndex = defenderIndexes[(startIndex + checked) % defenderIndexes.length];
if (!state.alive[modelIndex]) {
continue;
}
const currentHp = Math.max(0, Number(job.hp[modelIndex]) || 0);
if (remainingDamage >= currentHp) {
remainingDamage -= currentHp;
job.hp[modelIndex] = 0;
state.alive[modelIndex] = 0;
state.deadDefenderIds[state.deadCount] = job.modelIds[modelIndex];
state.deadAttackerIds[state.deadCount] = pickAggregateWinnerId(job, state, attackerIndexes);
state.deadCount += 1;
resolvedDeaths += 1;
continue;
}
job.hp[modelIndex] = Math.max(1, currentHp - remainingDamage);
remainingDamage = 0;
}
return resolvedDeaths;
}
function pickAggregateWinnerId(job, state, indexes) {
if (indexes.length === 0) {
return 0;
}
const startIndex = state.winnerCursor % indexes.length;
state.winnerCursor += 1;
for (let index = 0; index < indexes.length; index += 1) {
const modelIndex = indexes[(startIndex + index) % indexes.length];
if (state.alive[modelIndex] && !job.isFrostStunned[modelIndex]) {
return job.modelIds[modelIndex];
}
}
return 0;
}
function syncAggregateSquadMembers(job, squads, state) {
squads.forEach((squad) => {
const livingIndexes = squad.indexes.filter((index) => state.alive[index]);
const count = livingIndexes.length;
squad.count = count;
livingIndexes.forEach((modelIndex, index) => {
const slot = aggregateSquadSlot(squad, index, count);
job.x[modelIndex] = clamp(
squad.centerX + slot.x,
job.config.halfWidth,
job.config.arenaSize - job.config.halfWidth,
);
job.y[modelIndex] = clamp(
squad.centerY + slot.y,
job.config.halfHeight,
job.config.arenaSize - job.config.halfHeight,
);
});
});
}
function aggregateSquadSlot(squad, index, count) {
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
const progress = (index + 0.5) / Math.max(1, count);
const radius = Math.sqrt(progress) * squad.radius;
const angle = squad.seed * 0.017 + index * goldenAngle;
return {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
};
}
function livingCount(indexes, state) {
let count = 0;
indexes.forEach((index) => {
if (state.alive[index]) {
count += 1;
}
});
return count;
}
function squadHasLivingMembers(squad, state) {
return squad.indexes.some((index) => state.alive[index]);
}
function resolveMaxDeathsPerTick(config) {
return Math.max(
Math.max(1, Math.round(Number(config.maxDeathsPerCellTick) || 1)),
Math.max(1, Math.round(Number(config.maxDeathsPerTick) || 1)),
);
}
function aggregateSquadSeed(cellKey, teamKey, chunkIndex) {
const id = `${cellKey}:${teamKey}:${chunkIndex}`;
let seed = 17;
for (let index = 0; index < id.length; index += 1) {
seed = (seed * 31 + id.charCodeAt(index)) % 104729;
}
return seed;
}
function clampCell(value, cellSize, maxCell) {
return Math.min(maxCell, Math.max(0, Math.floor(value / cellSize)));
}
function targetCellIndex(cellX, cellY, columns) {
return cellY * columns + cellX;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
@@ -0,0 +1,15 @@
export function easeOutCubic(progress) {
return 1 - (1 - progress) ** 3;
}
export function easeInOutCubic(progress) {
if (progress < 0.5) {
return 4 * progress ** 3;
}
return 1 - ((-2 * progress + 2) ** 3) / 2;
}
export function arcadePhysicsTimeScale(sceneTimeScale) {
return 1 / Math.max(sceneTimeScale, Number.EPSILON);
}
File diff suppressed because it is too large Load Diff
+634
View File
@@ -0,0 +1,634 @@
import Phaser from "phaser";
import {
ARENA,
PERFORMANCE,
WORLD_EFFECT,
} from "../../constants.js";
import {
applyWorldEffectDamage,
disposeCombatObject,
trackCombatObject,
} from "./combat.js";
import {
clearFighterTint,
fighterWorldPoint,
stopFighterMovement,
tintFighter,
} from "../fighter/fighterAdapter.js";
const METEOR_EFFECT_PATH = "assets/effects/world_Effect.png";
const METEOR_EFFECT_KEY = "world-meteor-effect";
const FROST_EFFECT_PATH = "assets/effects/world_Effect_2.png";
const FROST_EFFECT_KEY = "world-frost-effect";
const WORLD_EFFECT_SHEETS = [
{ key: METEOR_EFFECT_KEY, path: METEOR_EFFECT_PATH },
{ key: FROST_EFFECT_KEY, path: FROST_EFFECT_PATH },
];
const METEOR_ZONE_COLOR = 0xf16a38;
const FROST_ZONE_COLOR = 0x58cef4;
const FALL_ANGLE_DEGREES = 45;
export function preloadWorldEffectAssets(scene) {
WORLD_EFFECT_SHEETS.forEach(({ key, path }) => {
scene.load.spritesheet(key, path, {
frameWidth: 100,
frameHeight: 100,
});
});
}
export function createWorldEffectAnimations(scene) {
WORLD_EFFECT_SHEETS.forEach(({ key }) => {
const animationKey = worldEffectAnimationKey(key);
if (scene.anims.exists(animationKey)) {
return;
}
scene.anims.create({
key: animationKey,
frames: scene.anims.generateFrameNumbers(key, {
start: 0,
end: WORLD_EFFECT.FRAMES - 1,
}),
frameRate: WORLD_EFFECT.FRAME_RATE,
repeat: 0,
});
});
}
export function startWorldEffects(scene) {
clearWorldEffects(scene);
if (scene.presentationMode) {
return;
}
scene.matchStartedAt = scene.time.now;
scene.isSuddenDeath = false;
const scheduleNext = (isInitialBarrage = false) => {
if (!isLiveMatch(scene)) return;
const elapsed = scene.time.now - (scene.matchStartedAt ?? scene.time.now);
const isSuddenDeath = WORLD_EFFECT.SUDDEN_DEATH.ENABLED && elapsed >= WORLD_EFFECT.SUDDEN_DEATH.TRIGGER_MS;
const delay = isInitialBarrage
? WORLD_EFFECT.INTERVAL
: isSuddenDeath
? WORLD_EFFECT.SUDDEN_DEATH.INTERVAL_MS
: WORLD_EFFECT.REPEAT_INTERVAL;
if (isSuddenDeath && !scene.isSuddenDeath) {
scene.isSuddenDeath = true;
}
scene.worldEffectTimer = scene.time.delayedCall(delay, () => {
triggerWorldEffect(scene);
scheduleNext();
});
};
scheduleNext(true);
}
export function clearWorldEffects(scene) {
scene.worldEffectTimer?.remove(false);
scene.worldEffectTimer = null;
scene.worldEffectZones?.clear();
scene.clearMeteorCameraFocus?.(null, { restoreCamera: false });
scene.matchStartedAt = null;
scene.isSuddenDeath = false;
scene.nextWorldEffectModifierRefreshAt = 0;
scene.worldEffectModifierActive = false;
scene.fighters?.forEach((fighter) => {
fighter.worldEffectSpeedMultiplier = 1;
clearFrostStun(fighter);
});
}
export function updateWorldEffectModifiers(scene) {
const frostZones = Array.from(scene.worldEffectZones ?? []).filter(
(zone) => zone.marker?.active,
);
if (frostZones.length === 0 && !scene.worldEffectModifierActive) {
return;
}
const now = scene.time?.now ?? 0;
const refreshMs = Math.max(0, Number(PERFORMANCE.WORLD_EFFECT_MODIFIER_REFRESH_MS) || 0);
if (frostZones.length > 0 && refreshMs > 0 && now < (scene.nextWorldEffectModifierRefreshAt ?? 0)) {
return;
}
scene.nextWorldEffectModifierRefreshAt = now + refreshMs;
scene.worldEffectModifierActive = frostZones.length > 0;
scene.fighters.forEach((fighter) => {
const isSlowed =
fighter.active
&& !fighter.isDead
&& frostZones.some((zone) => containsFighter(zone, fighter));
fighter.worldEffectSpeedMultiplier = isSlowed
? WORLD_EFFECT.FROST_SPEED_MULTIPLIER
: 1;
});
}
function triggerWorldEffect(scene) {
if (!isLiveMatch(scene)) {
return;
}
const livingFighters = scene.fighters.filter(
(fighter) => fighter.active && !fighter.isDead,
);
if (livingFighters.length === 0) {
return;
}
const zone = findDensestWorldEffectZone(livingFighters);
// Sudden Death 상태이고 냉기 고정 설정이 되어있으면 무조건 냉기 메테오
if ((scene.isSuddenDeath && WORLD_EFFECT.SUDDEN_DEATH.FORCE_FROST) || Phaser.Math.Between(0, 1) === 0) {
spawnFrostZone(scene, zone);
return;
}
spawnMeteor(scene, zone);
}
export function findDensestWorldEffectZone(livingFighters) {
if (livingFighters.length === 0) {
return null;
}
const areaTiles = resolveTileCount(WORLD_EFFECT.AREA_TILES, ARENA.GRID_SIZE);
const tileCounts = Array.from(
{ length: ARENA.GRID_SIZE },
() => Array(ARENA.GRID_SIZE).fill(0),
);
livingFighters.forEach((fighter) => {
const point = fighterWorldPoint(fighter);
const x = point.x;
const y = point.y;
const column = Phaser.Math.Clamp(Math.floor(x / ARENA.TILE_SIZE), 0, ARENA.GRID_SIZE - 1);
const row = Phaser.Math.Clamp(Math.floor(y / ARENA.TILE_SIZE), 0, ARENA.GRID_SIZE - 1);
tileCounts[row][column] += 1;
});
// Summed-area lookup keeps dense-zone selection cheap even with thousands of fighters.
const densitySums = createSummedAreaTable(tileCounts);
const maximumOrigin = ARENA.GRID_SIZE - areaTiles;
let highestCount = -1;
let densestOrigins = [];
for (let row = 0; row <= maximumOrigin; row += 1) {
for (let column = 0; column <= maximumOrigin; column += 1) {
const count = sumArea(densitySums, column, row, areaTiles);
if (count > highestCount) {
highestCount = count;
densestOrigins = [{ column, row }];
} else if (count === highestCount) {
densestOrigins.push({ column, row });
}
}
}
const origin = randomEntry(densestOrigins);
const size = areaTiles * ARENA.TILE_SIZE;
return createEffectZone(
origin.column * ARENA.TILE_SIZE + size / 2,
origin.row * ARENA.TILE_SIZE + size / 2,
areaTiles,
);
}
function randomEntry(entries) {
return entries[Phaser.Math.Between(0, entries.length - 1)];
}
function spawnMeteor(scene, zone) {
spawnWorldEffectBarrage(scene, zone, {
color: METEOR_ZONE_COLOR,
damage: WORLD_EFFECT.METEOR_DAMAGE,
effectKey: METEOR_EFFECT_KEY,
});
}
function spawnFrostZone(scene, zone) {
spawnWorldEffectBarrage(scene, zone, {
color: FROST_ZONE_COLOR,
damage: WORLD_EFFECT.FROST_DAMAGE,
effectKey: FROST_EFFECT_KEY,
isFrost: true,
});
}
function spawnWorldEffectBarrage(
scene,
targetZone,
{ color, damage, effectKey, isFrost = false },
) {
const matchId = scene.matchId;
const targetMarker = createZoneMarker(scene, targetZone, color);
const impactZones = createBarrageImpactZones(targetZone);
const pendingTimers = [];
const warningHideTimer = scene.time.delayedCall(resolveWarningDurationMs(), () => {
hideZoneMarker(scene, targetMarker);
});
let unresolvedImpacts = impactZones.length;
scene.beginMeteorCameraFocus?.(targetZone);
const finishImpact = () => {
unresolvedImpacts -= 1;
if (unresolvedImpacts > 0) {
return;
}
scene.clearMeteorCameraFocus?.(targetZone);
disposeCombatObject(scene, targetMarker);
};
const previousCleanup = targetMarker.cleanup;
targetMarker.cleanup = () => {
warningHideTimer.remove(false);
pendingTimers.forEach((timer) => timer.remove(false));
previousCleanup?.();
};
impactZones.forEach((impactZone, index) => {
const timer = scene.time.delayedCall(resolveImpactStaggerMs() * index, () => {
if (!targetMarker.active || !isLiveMatch(scene, matchId)) {
finishImpact();
return;
}
const impactMarker = createZoneMarker(scene, impactZone, color);
dropWorldEffectSprite(scene, impactZone, {
effectKey,
onCancel: () => {
disposeCombatObject(scene, impactMarker);
finishImpact();
},
onImpact: () => {
scene.tweens.killTweensOf(impactMarker);
impactMarker.setAlpha(1);
applyMeteorImpactShake(scene, impactZone);
resolveImpactDamage(
scene,
impactZone,
damage,
isFrost ? (fighter) => applyFrostStun(scene, fighter) : undefined,
);
if (isFrost && !scene.matchOver) {
activateFrostZone(scene, impactZone, impactMarker);
}
},
onAnimationComplete: () => {
if (!isFrost) {
disposeCombatObject(scene, impactMarker);
}
finishImpact();
},
});
});
pendingTimers.push(timer);
});
}
function dropWorldEffectSprite(
scene,
zone,
{ effectKey = METEOR_EFFECT_KEY, onCancel, onImpact, onAnimationComplete } = {},
) {
const matchId = scene.matchId;
const trajectory = createFallTrajectory(zone);
const sprite = scene.add
.sprite(trajectory.startX, trajectory.startY, effectKey, 0)
.setDepth(3)
.setScale(resolveWorldEffectVisualScale(zone))
.setFlipX(trajectory.flipX)
.setAngle(trajectory.angle)
.setAlpha(0.9);
sprite.cleanup = () => {
scene.tweens.killTweensOf(sprite);
};
trackCombatObject(scene, sprite);
sprite.once(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
onAnimationComplete?.();
disposeCombatObject(scene, sprite);
});
scene.tweens.add({
targets: sprite,
x: zone.centerX,
y: zone.centerY,
alpha: 1,
duration: WORLD_EFFECT.FALL_DURATION,
ease: "Cubic.In",
onComplete: () => {
if (!sprite.active || !isLiveMatch(scene, matchId)) {
onCancel?.();
disposeCombatObject(scene, sprite);
return;
}
sprite.play(worldEffectAnimationKey(effectKey));
onImpact?.();
},
});
}
function worldEffectAnimationKey(effectKey) {
return `${effectKey}-anim`;
}
function resolveWorldEffectSizeScale() {
const variance = Math.max(0, Number(WORLD_EFFECT.SIZE_SCALE_VARIANCE) || 0);
if (variance === 0) {
return 1;
}
const minScale = Math.max(0.1, 1 - variance);
const maxScale = 1 + variance;
return Phaser.Math.FloatBetween(minScale, maxScale);
}
function resolveWorldEffectVisualScale(zone) {
const baseScale = Math.max(0.01, Number(WORLD_EFFECT.IMPACT_VISUAL_SCALE) || 1);
return baseScale * Math.max(0.1, Number(zone?.sizeScale) || 1);
}
function applyMeteorImpactShake(scene, zone) {
const sizeScale = Math.max(0.1, Number(zone?.sizeScale) || 1);
const duration = Math.round(
Math.max(0, Number(WORLD_EFFECT.METEOR_SHAKE_DURATION_MS) || 0)
* Math.sqrt(sizeScale),
);
const intensity =
Math.max(0, Number(WORLD_EFFECT.METEOR_SHAKE_INTENSITY) || 0) * sizeScale;
if (duration <= 0 || intensity <= 0) {
return;
}
scene.cameras.main.shake(duration, intensity);
}
function createFallTrajectory(zone) {
const distance = ARENA.TILE_SIZE * WORLD_EFFECT.FALL_TRAVEL_TILES;
const isLeftHalf = zone.centerX < ARENA.SIZE / 2;
const travelDirection = isLeftHalf ? 1 : -1;
return {
angle: travelDirection * FALL_ANGLE_DEGREES,
flipX: travelDirection < 0,
startX: zone.centerX - travelDirection * distance,
startY: zone.centerY - distance,
};
}
function createBarrageImpactZones(targetZone) {
const minimumCount = Math.max(1, Math.round(Number(WORLD_EFFECT.IMPACT_COUNT_MIN) || 1));
const maximumCount = Math.max(
minimumCount,
Math.round(Number(WORLD_EFFECT.IMPACT_COUNT_MAX) || minimumCount),
);
const count = Phaser.Math.Between(minimumCount, maximumCount);
return Array.from({ length: count }, () => createBarrageImpactZone(targetZone));
}
function createBarrageImpactZone(targetZone) {
const sizeScale = resolveWorldEffectSizeScale();
const maximumImpactTiles = Math.max(1, targetZone.areaTiles - 1);
const impactTiles = Math.min(
maximumImpactTiles,
Math.max(
1,
Math.round((Number(WORLD_EFFECT.IMPACT_AREA_TILES) || 1) * sizeScale),
),
);
const impactSize = impactTiles * ARENA.TILE_SIZE;
const halfImpactSize = impactSize / 2;
const minimumX = targetZone.bounds.left + halfImpactSize;
const maximumX = targetZone.bounds.right - halfImpactSize;
const minimumY = targetZone.bounds.top + halfImpactSize;
const maximumY = targetZone.bounds.bottom - halfImpactSize;
return createEffectZone(
randomBetween(minimumX, maximumX),
randomBetween(minimumY, maximumY),
impactTiles,
sizeScale,
);
}
function createEffectZone(centerX, centerY, areaTiles, sizeScale = 1) {
const size = ARENA.TILE_SIZE * areaTiles;
return {
areaTiles,
bounds: new Phaser.Geom.Rectangle(centerX - size / 2, centerY - size / 2, size, size),
centerX,
centerY,
marker: null,
sizeScale,
};
}
function createSummedAreaTable(tileCounts) {
const sums = Array.from(
{ length: ARENA.GRID_SIZE + 1 },
() => Array(ARENA.GRID_SIZE + 1).fill(0),
);
for (let row = 0; row < ARENA.GRID_SIZE; row += 1) {
for (let column = 0; column < ARENA.GRID_SIZE; column += 1) {
sums[row + 1][column + 1] =
tileCounts[row][column]
+ sums[row][column + 1]
+ sums[row + 1][column]
- sums[row][column];
}
}
return sums;
}
function sumArea(sums, column, row, areaTiles) {
const bottom = row + areaTiles;
const right = column + areaTiles;
return (
sums[bottom][right]
- sums[row][right]
- sums[bottom][column]
+ sums[row][column]
);
}
function resolveTileCount(value, maximum) {
return Phaser.Math.Clamp(Math.round(Number(value) || 1), 1, maximum);
}
function resolveImpactStaggerMs() {
return Math.max(0, Math.round(Number(WORLD_EFFECT.IMPACT_STAGGER_MS) || 0));
}
function resolveWarningDurationMs() {
return Math.max(0, Math.round(Number(WORLD_EFFECT.WARNING_DURATION_MS) || 0));
}
function randomBetween(minimum, maximum) {
if (maximum <= minimum) {
return minimum;
}
return Phaser.Math.FloatBetween(minimum, maximum);
}
function hideZoneMarker(scene, marker) {
if (!marker?.active) {
return;
}
scene.tweens.killTweensOf(marker);
marker.setVisible(false);
}
function createZoneMarker(scene, zone, color) {
const marker = scene.add.graphics().setDepth(1.5);
const { x, y, width, height } = zone.bounds;
marker.fillStyle(color, 0.13);
marker.fillRect(x, y, width, height);
marker.lineStyle(3, color, 0.82);
marker.strokeRect(x, y, width, height);
marker.lineStyle(1, color, 0.34);
for (let index = 1; index < zone.areaTiles; index += 1) {
const offset = index * ARENA.TILE_SIZE;
marker.lineBetween(x + offset, y, x + offset, y + height);
marker.lineBetween(x, y + offset, x + width, y + offset);
}
marker.cleanup = () => {
scene.tweens.killTweensOf(marker);
};
trackCombatObject(scene, marker);
scene.tweens.add({
targets: marker,
alpha: { from: 0.46, to: 0.9 },
duration: 360,
ease: "Sine.InOut",
yoyo: true,
repeat: -1,
});
return marker;
}
function resolveImpactDamage(scene, zone, damage, onSurvivor) {
let deathCount = 0;
scene.fighters
.filter((fighter) => fighter.active && !fighter.isDead && containsFighter(zone, fighter))
.forEach((fighter) => {
if (applyWorldEffectDamage(scene, fighter, damage)) {
deathCount += 1;
return;
}
onSurvivor?.(fighter);
});
if (deathCount > 0) {
scene.updateScoreboard?.();
scene.finishMatch?.();
}
}
function applyFrostStun(scene, fighter) {
if (!fighter?.active || fighter.isDead) {
return;
}
fighter.frostStunTimer?.remove(false);
fighter.isFrostStunned = true;
stopFighterMovement(fighter);
tintFighter(fighter, WORLD_EFFECT.FROST_STUN_TINT);
fighter.frostStunTimer = scene.time.delayedCall(WORLD_EFFECT.FROST_STUN_DURATION, () => {
clearFrostStun(fighter);
});
}
function clearFrostStun(fighter) {
fighter.frostStunTimer?.remove(false);
fighter.frostStunTimer = null;
fighter.isFrostStunned = false;
clearFighterTint(fighter);
}
function activateFrostZone(scene, zone, marker) {
if (!marker.active) {
return;
}
zone.marker = marker;
scene.worldEffectZones ??= new Set();
scene.worldEffectZones.add(zone);
scene.tweens.killTweensOf(marker);
scene.tweens.add({
targets: marker,
alpha: { from: 0.42, to: 0.72 },
duration: 680,
ease: "Sine.InOut",
yoyo: true,
repeat: -1,
});
const previousCleanup = marker.cleanup;
const expiryTimer = scene.time.delayedCall(WORLD_EFFECT.FROST_DURATION, () => {
disposeCombatObject(scene, marker);
});
marker.cleanup = () => {
expiryTimer.remove(false);
scene.worldEffectZones?.delete(zone);
previousCleanup?.();
};
}
function containsFighter(zone, fighter) {
const point = fighterWorldPoint(fighter);
const x = point.x;
const y = point.y;
return Phaser.Geom.Rectangle.Contains(zone.bounds, x, y);
}
function isLiveMatch(scene, matchId = scene.matchId) {
return !scene.matchOver && !scene.presentationMode && scene.matchId === matchId;
}
+267
View File
@@ -0,0 +1,267 @@
import {
ARENA,
FIGHTER,
} from "../../constants.js";
import { ensureFighterTeamAnimation } from "./fighterAssets.js";
import {
fighterModelPoint,
fighterModelDistanceSquared,
getFighterModel,
isLivingFighterModel,
setFighterModelPosition,
syncFighterModelFromSprite,
} from "./fighterModel.js";
export {
fighterModelDistanceSquared,
fighterModelPoint,
getFighterModel,
isLivingFighterModel,
syncFighterModelFromSprite,
};
export function isFighterBodyEnabled(fighter) {
return Boolean(!fighter?._spriteDetached && fighter?.body && fighter.body.enable !== false);
}
export function shouldRenderFighterDetail(fighter) {
return Boolean(
fighter
&& fighter.active !== false
&& !fighter._spriteDetached
&& fighter._detailVisible !== false
&& fighter.visible !== false,
);
}
export function fighterWorldPoint(fighter) {
if (isFighterBodyEnabled(fighter)) {
return {
x: fighter.body.center.x,
y: fighter.body.center.y,
};
}
return fighterModelPoint(fighter);
}
export function fighterDistanceSquared(left, right) {
const deltaX = fighterWorldX(left) - fighterWorldX(right);
const deltaY = fighterWorldY(left) - fighterWorldY(right);
return deltaX * deltaX + deltaY * deltaY;
}
export function setFighterFacing(fighter, faceLeft) {
if (!fighter) {
return;
}
fighter.facingLeft = Boolean(faceLeft);
const model = getFighterModel(fighter);
if (model) {
model.facingLeft = fighter.facingLeft;
}
if (typeof fighter.setFlipX === "function") {
fighter.setFlipX(fighter.facingLeft);
return;
}
fighter.flipX = fighter.facingLeft;
}
export function stopFighterMovement(fighter) {
if (isFighterBodyEnabled(fighter)) {
fighter.body.setVelocity(0, 0);
}
}
export function disableFighterBody(fighter) {
syncFighterModelFromSprite(fighter);
stopFighterMovement(fighter);
fighter?.body?.stop?.();
if (fighter?.body) {
const world = fighter.scene?.physics?.world;
if (world) {
world.disable(fighter);
} else {
fighter.body.enable = false;
}
}
}
export function enableFighterBody(fighter) {
if (!fighter?.body || fighter.isDead) {
return;
}
const point = fighterModelPoint(fighter);
const world = fighter.scene?.physics?.world;
if (world) {
world.enable(fighter);
} else {
fighter.body.enable = true;
}
fighter.body.reset?.(point.x, point.y);
stopFighterMovement(fighter);
fighter.body.updateFromGameObject?.();
syncFighterModelFromSprite(fighter);
}
export function setFighterWorldPosition(fighter, x, y) {
if (!fighter) {
return;
}
if (!fighter._spriteDetached && typeof fighter.setPosition === "function") {
fighter.setPosition(x, y);
} else if (!fighter._spriteDetached) {
fighter.x = x;
fighter.y = y;
}
setFighterModelPosition(fighter, x, y);
if (isFighterBodyEnabled(fighter)) {
fighter.body.updateFromGameObject?.();
}
}
export function moveFighterToward(scene, fighter, target, speed, delta) {
if (!fighter?.active || !target) {
return;
}
const targetPoint = fighterWorldPoint(target);
if (isFighterBodyEnabled(fighter)) {
scene.physics.moveTo(fighter, targetPoint.x, targetPoint.y, speed);
return;
}
const sourcePoint = fighterModelPoint(fighter);
const deltaX = targetPoint.x - sourcePoint.x;
const deltaY = targetPoint.y - sourcePoint.y;
const distance = Math.hypot(deltaX, deltaY);
if (distance <= 0) {
return;
}
const step = Math.min(
distance,
speed * (Math.max(0, Number(delta) || 0) / 1000),
);
setFighterWorldPosition(
fighter,
sourcePoint.x + (deltaX / distance) * step,
sourcePoint.y + (deltaY / distance) * step,
);
clampFighterInsideArena(fighter);
}
export function clampFighterInsideArena(fighter) {
if (!fighter?.active) {
return;
}
const bodyWidth = fighter.body?.width ?? FIGHTER.HITBOX_WIDTH;
const bodyHeight = fighter.body?.height ?? FIGHTER.HITBOX_HEIGHT;
const halfWidth = Math.min(
ARENA.SIZE / 2,
Math.max(Math.abs(fighter.displayWidth ?? 0), bodyWidth) / 2,
);
const halfHeight = Math.min(
ARENA.SIZE / 2,
Math.max(Math.abs(fighter.displayHeight ?? 0), bodyHeight) / 2,
);
const point = fighterModelPoint(fighter);
const x = clamp(point.x, halfWidth, ARENA.SIZE - halfWidth);
const y = clamp(point.y, halfHeight, ARENA.SIZE - halfHeight);
setFighterWorldPosition(fighter, x, y);
}
export function playFighterActionIfNeeded(fighter, action) {
if (!shouldRenderFighterDetail(fighter)) {
return;
}
const key = resolveFighterAnimationKey(fighter, action);
if (key && fighter.anims?.currentAnim?.key !== key) {
playResolvedFighterAnimation(fighter, key);
}
}
export function playFighterAction(fighter, action, timeScale = 1) {
if (!shouldRenderFighterDetail(fighter)) {
return;
}
const key = resolveFighterAnimationKey(fighter, action);
if (key) {
playResolvedFighterAnimation(fighter, key, timeScale);
}
}
export function tintFighter(fighter, tint) {
if (typeof fighter?.setTint === "function") {
fighter.setTint(tint);
}
}
export function clearFighterTint(fighter) {
if (fighter?.active && typeof fighter.clearTint === "function") {
fighter.clearTint();
}
}
function playResolvedFighterAnimation(fighter, key, timeScale = 1) {
if (!fighter?.anims || typeof fighter.play !== "function") {
return;
}
fighter.anims.timeScale = timeScale;
fighter.play(key, true);
}
function resolveFighterAnimationKey(fighter, action) {
if (!fighter?.scene || !fighter?.skin || !action) {
return null;
}
return ensureFighterTeamAnimation(
fighter.scene,
fighter.skin,
action,
fighter.team?.color,
);
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
function fighterWorldX(fighter) {
if (isFighterBodyEnabled(fighter)) {
return fighter.body.center.x;
}
return fighterModelPoint(fighter).x;
}
function fighterWorldY(fighter) {
if (isFighterBodyEnabled(fighter)) {
return fighter.body.center.y;
}
return fighterModelPoint(fighter).y;
}
+271
View File
@@ -0,0 +1,271 @@
import {
FIGHTER,
COMBAT,
} from "../../constants.js";
const HEAL_EFFECT_PATH = "assets/effects/heal/Heal_Effect.png";
const HEAL_EFFECT_KEY = "kill-heal-effect";
const HEAL_EFFECT_ANIMATION_KEY = `${HEAL_EFFECT_KEY}-anim`;
const TEAM_SHADOW_SOURCE_COLOR = {
red: 0x53,
green: 0x45,
blue: 0x45,
};
const TEAM_SHADOW_FRAME_Y_START = 55;
const TEAM_SHADOW_FRAME_Y_END = 60;
export function fighterSheetKey(skin, action, teamColor) {
if (teamColor) {
return `${skin.key}-${action}-team-shadow-${normalizeTeamColorKey(teamColor)}`;
}
return `${skin.key}-${action}`;
}
export function fighterAnimationKey(skin, action, teamColor) {
return `${fighterSheetKey(skin, action, teamColor)}-anim`;
}
export function fighterAttackEffectKey(skin) {
return `${skin.key}-attack-effect`;
}
export function fighterAttackEffectAnimationKey(skin) {
return `${fighterAttackEffectKey(skin)}-anim`;
}
export function fighterProjectileKey(skin) {
return `${skin.key}-projectile`;
}
export function healEffectKey() {
return HEAL_EFFECT_KEY;
}
export function healEffectAnimationKey() {
return HEAL_EFFECT_ANIMATION_KEY;
}
export function preloadFighterSheets(scene, skins) {
scene.load.spritesheet(healEffectKey(), HEAL_EFFECT_PATH, {
frameWidth: FIGHTER.FRAME_WIDTH,
frameHeight: FIGHTER.FRAME_HEIGHT,
});
skins.forEach((skin) => {
Object.entries(skin.animations).forEach(([action, animation]) => {
scene.load.spritesheet(
fighterSheetKey(skin, action),
`${skin.assetRoot}/${animation.file}`,
{ frameWidth: FIGHTER.FRAME_WIDTH, frameHeight: FIGHTER.FRAME_HEIGHT },
);
});
preloadCombatAssets(scene, skin);
});
}
export function createFighterAnimations(scene, skins) {
skins.forEach((skin) => {
Object.entries(skin.animations).forEach(([action, animation]) => {
const key = fighterAnimationKey(skin, action);
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,
});
}
});
createAttackEffectAnimation(scene, skin);
});
createHealEffectAnimation(scene);
}
export function ensureFighterTeamAnimation(scene, skin, action, teamColor) {
const animation = skin.animations[action];
if (!animation || !teamColor) {
return fighterAnimationKey(skin, action);
}
const textureKey = fighterSheetKey(skin, action, teamColor);
if (
!scene.textures.exists(textureKey)
&& !createFighterTeamShadowSheet(scene, skin, action, animation.frames, teamColor)
) {
return fighterAnimationKey(skin, action);
}
const key = fighterAnimationKey(skin, action, teamColor);
if (!scene.anims.exists(key)) {
const { frameRate, repeat } = FIGHTER.ANIMATION_OPTIONS[action];
scene.anims.create({
key,
frames: scene.anims.generateFrameNumbers(textureKey, {
start: 0,
end: animation.frames - 1,
}),
frameRate,
repeat,
});
}
return key;
}
export function ensureFighterTeamAnimations(scene, skin, teamColor, actions = []) {
actions.forEach((action) => {
ensureFighterTeamAnimation(scene, skin, action, teamColor);
});
}
function preloadCombatAssets(scene, skin) {
const projectile = skin.combat?.projectile;
const attackEffect = skin.combat?.attackEffect;
if (projectile) {
scene.load.image(fighterProjectileKey(skin), `${skin.assetRoot}/${projectile.file}`);
}
if (attackEffect) {
scene.load.spritesheet(
fighterAttackEffectKey(skin),
`${skin.assetRoot}/${attackEffect.file}`,
{ frameWidth: FIGHTER.FRAME_WIDTH, frameHeight: FIGHTER.FRAME_HEIGHT },
);
}
}
function createAttackEffectAnimation(scene, skin) {
const attackEffect = skin.combat?.attackEffect;
if (!attackEffect) {
return;
}
const key = fighterAttackEffectAnimationKey(skin);
if (scene.anims.exists(key)) {
return;
}
scene.anims.create({
key,
frames: scene.anims.generateFrameNumbers(fighterAttackEffectKey(skin), {
start: 0,
end: attackEffect.frames - 1,
}),
frameRate: attackEffect.frameRate ?? 14,
repeat: 0,
});
}
function createHealEffectAnimation(scene) {
if (scene.anims.exists(healEffectAnimationKey())) {
return;
}
scene.anims.create({
key: healEffectAnimationKey(),
frames: scene.anims.generateFrameNumbers(healEffectKey(), {
start: 0,
end: COMBAT.KILL_HEAL_EFFECT_FRAMES - 1,
}),
frameRate: COMBAT.KILL_HEAL_EFFECT_FRAME_RATE,
repeat: 0,
});
}
function createFighterTeamShadowSheet(scene, skin, action, frameCount, teamColor) {
const key = fighterSheetKey(skin, action, teamColor);
if (scene.textures.exists(key)) {
return true;
}
const sourceTexture = scene.textures.get(fighterSheetKey(skin, action));
const sourceImage = sourceTexture?.getSourceImage?.();
if (!sourceImage) {
return false;
}
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 sourceImageData = sourceContext.getImageData(0, 0, sheetWidth, sheetHeight);
const sourceData = sourceImageData.data;
const shadowColor = parseHexColor(teamColor);
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
const frameLeft = frameIndex * FIGHTER.FRAME_WIDTH;
for (let y = TEAM_SHADOW_FRAME_Y_START; y < TEAM_SHADOW_FRAME_Y_END; y += 1) {
for (let x = 0; x < FIGHTER.FRAME_WIDTH; x += 1) {
const sourceIndex = ((y * sheetWidth) + frameLeft + x) * 4;
if (!isTeamShadowPixel(sourceData, sourceIndex)) {
continue;
}
sourceData[sourceIndex] = shadowColor.red;
sourceData[sourceIndex + 1] = shadowColor.green;
sourceData[sourceIndex + 2] = shadowColor.blue;
}
}
}
sourceContext.putImageData(sourceImageData, 0, 0);
scene.textures.addSpriteSheet(key, sourceCanvas, {
frameWidth: FIGHTER.FRAME_WIDTH,
frameHeight: FIGHTER.FRAME_HEIGHT,
});
return true;
}
function isTeamShadowPixel(data, index) {
return (
data[index + 3] > 0
&& data[index] === TEAM_SHADOW_SOURCE_COLOR.red
&& data[index + 1] === TEAM_SHADOW_SOURCE_COLOR.green
&& data[index + 2] === TEAM_SHADOW_SOURCE_COLOR.blue
);
}
function normalizeTeamColorKey(teamColor) {
return parseHexColor(teamColor).hex;
}
function parseHexColor(teamColor) {
const fallback = "ffffff";
const hex = typeof teamColor === "string"
? teamColor.trim().replace(/^#/, "").toLowerCase()
: fallback;
const normalizedHex = /^[0-9a-f]{6}$/.test(hex) ? hex : fallback;
return {
blue: parseInt(normalizedHex.slice(4, 6), 16),
green: parseInt(normalizedHex.slice(2, 4), 16),
hex: normalizedHex,
red: parseInt(normalizedHex.slice(0, 2), 16),
};
}
+313
View File
@@ -0,0 +1,313 @@
import Phaser from "phaser";
import {
FIGHTER,
PERFORMANCE,
} from "../../constants.js";
import {
ensureFighterTeamAnimation,
ensureFighterTeamAnimations,
fighterSheetKey,
} from "./fighterAssets.js";
import {
disableFighterBody,
enableFighterBody,
} from "./fighterAdapter.js";
import {
attachFighterModel,
createFighterModel,
fighterModelPoint,
} from "./fighterModel.js";
import { getFighterStats } from "./fighterStats.js";
const HUD_DETAIL_SYNC_INTERVAL_MS = 100;
export function createFighter(
scene,
{ canSplitOnDeath = true, faceLeft, hp, maxHp, name, skin, team, teamIndex, x, y },
{ attachSprite = true } = {},
) {
ensureFighterTeamAnimations(scene, skin, team.color, ["idle"]);
const teamIdleSheetKey = fighterSheetKey(skin, "idle", team.color);
const idleSheetKey = scene.textures.exists(teamIdleSheetKey)
? teamIdleSheetKey
: fighterSheetKey(skin, "idle");
const displayName = name || team.label;
const combatStats = getFighterStats(skin);
const resolvedMaxHp = Math.max(1, Math.round(maxHp ?? combatStats.maxHp));
const resolvedHp = Math.min(
resolvedMaxHp,
Math.max(1, Math.round(hp ?? resolvedMaxHp)),
);
const fighter = scene.physics.add.sprite(x, y, idleSheetKey, 0);
const inputHitArea = new Phaser.Geom.Rectangle(
FIGHTER.HITBOX_OFFSET_X,
FIGHTER.HITBOX_OFFSET_Y,
FIGHTER.HITBOX_WIDTH,
FIGHTER.HITBOX_HEIGHT,
);
fighter._spriteDetached = false;
fighter._detailVisible = true;
attachFighterModel(
fighter,
createFighterModel({
canSplitOnDeath,
combatStats,
facingLeft: faceLeft,
fighterName: displayName,
hp: resolvedHp,
maxHp: resolvedMaxHp,
skin,
team,
teamIndex,
x,
y,
}),
);
fighter.setScale(FIGHTER.SCALE);
fighter.setName(displayName);
fighter.setDepth(FIGHTER.DEPTH);
fighter.setAlpha(1);
fighter.setCollideWorldBounds(true);
fighter.setFlipX(faceLeft);
fighter.body.setSize(FIGHTER.HITBOX_WIDTH, FIGHTER.HITBOX_HEIGHT);
fighter.body.setOffset(FIGHTER.HITBOX_OFFSET_X, FIGHTER.HITBOX_OFFSET_Y);
fighter.setInteractive(inputHitArea, Phaser.Geom.Rectangle.Contains);
fighter.input.cursor = "pointer";
fighter._inputHitArea = inputHitArea;
fighter.baseScaleX = FIGHTER.SCALE;
fighter.baseScaleY = FIGHTER.SCALE;
fighter.deadDespawnTimer = null;
fighter.deadDespawnTween = null;
fighter.frostStunTimer = null;
fighter.nextHudSyncAt = 0;
fighter._hudDetailsVisible = false;
fighter._hudSlot = null;
fighter.releaseHud = () => releaseFighterHud(fighter);
fighter.play(ensureFighterTeamAnimation(scene, skin, "walk", team.color));
fighter.on(Phaser.Animations.Events.ANIMATION_COMPLETE, (animation) => {
if (fighter.isDead) {
return;
}
if (animation.key.includes("-attack") || animation.key.includes("-hurt")) {
fighter.isLocked = false;
}
});
if (!attachSprite) {
setFighterDetailVisible(fighter, false);
}
attachHudCleanup(fighter);
return fighter;
}
export function setFighterDetailVisible(fighter, visible) {
if (!fighter || !fighter.scene) {
return;
}
const shouldShow = Boolean(visible && !fighter.isDead);
if (shouldShow && fighter._detailVisible === true && !fighter._spriteDetached) {
return;
}
if (!shouldShow && fighter._detailVisible === false && fighter._spriteDetached) {
return;
}
fighter._detailVisible = shouldShow;
if (!shouldShow) {
fighter.isLocked = false;
disableFighterBody(fighter);
fighter.anims?.pause();
fighter.disableInteractive?.();
releaseFighterHud(fighter);
fighter.setVisible(false);
fighter.removeFromDisplayList?.();
fighter.removeFromUpdateList?.();
fighter._spriteDetached = true;
return;
}
const point = fighterModelPoint(fighter);
fighter.setActive(true);
fighter.setPosition(point.x, point.y);
fighter.addToDisplayList?.();
fighter.addToUpdateList?.();
fighter.setVisible(true);
fighter._spriteDetached = false;
enableFighterBody(fighter);
fighter.setInteractive?.(fighter._inputHitArea, Phaser.Geom.Rectangle.Contains);
if (fighter.input) {
fighter.input.cursor = "pointer";
}
if (!fighter.isDead) {
fighter.anims?.resume();
}
}
export function syncFighterHud(
fighter,
{ force = false, showDetails = true, time = fighter.scene?.time?.now ?? 0 } = {},
) {
const isVisible = Boolean(fighter.active && fighter.visible && !fighter.isDead);
const detailsVisible = isVisible && (showDetails || fighter.isSelected);
if (!detailsVisible || !fighter.body) {
releaseFighterHud(fighter);
return false;
}
const hudSlot = acquireFighterHudSlot(fighter);
if (!hudSlot) {
return false;
}
const now = Number.isFinite(time) ? time : fighter.scene?.time?.now ?? 0;
const shouldSyncDetails =
force
|| fighter.isSelected
|| !fighter._hudDetailsVisible
|| fighter._lastHudHp !== fighter.hp
|| now >= (fighter.nextHudSyncAt ?? 0);
if (!shouldSyncDetails) {
return true;
}
fighter.nextHudSyncAt = now + HUD_DETAIL_SYNC_INTERVAL_MS;
fighter._lastHudHp = fighter.hp;
setHudDetailsVisible(fighter, true);
const scaleRatio = Math.max(1, Math.abs(fighter.scaleY) / FIGHTER.SCALE);
const healthOffset = 44 * scaleRatio;
hudSlot.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
hudSlot.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
hudSlot.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? 1)));
return true;
}
export function releaseFighterHud(fighter) {
const hudSlot = fighter?._hudSlot;
if (!hudSlot) {
if (fighter) {
fighter._hudDetailsVisible = false;
}
return;
}
setHudSlotVisible(hudSlot, false);
hudSlot.fighter = null;
fighter._hudSlot = null;
fighter._hudDetailsVisible = false;
}
export function releaseUnusedFighterHuds(scene, fightersWithHud = []) {
const activeFighters = fightersWithHud instanceof Set
? fightersWithHud
: new Set(fightersWithHud);
scene.fighterHudPool?.forEach((hudSlot) => {
if (hudSlot.fighter && !activeFighters.has(hudSlot.fighter)) {
releaseFighterHud(hudSlot.fighter);
}
});
}
function setHudDetailsVisible(fighter, visible) {
const hudSlot = fighter._hudSlot;
if (!hudSlot) {
return;
}
fighter._hudDetailsVisible = visible;
setHudSlotVisible(hudSlot, visible);
}
function setVisibleIfChanged(gameObject, visible) {
if (gameObject && gameObject.visible !== visible) {
gameObject.setVisible(visible);
}
}
function setHudSlotVisible(hudSlot, visible) {
setVisibleIfChanged(hudSlot.healthBack, visible);
setVisibleIfChanged(hudSlot.healthBar, visible);
}
function acquireFighterHudSlot(fighter) {
if (fighter._hudSlot) {
return fighter._hudSlot;
}
const hudPool = ensureFighterHudPool(fighter.scene);
const hudSlot = hudPool.find((candidate) => !candidate.fighter);
if (!hudSlot) {
return null;
}
hudSlot.fighter = fighter;
fighter._hudSlot = hudSlot;
configureHudSlot(hudSlot);
return hudSlot;
}
function ensureFighterHudPool(scene) {
if (scene.fighterHudPool) {
return scene.fighterHudPool;
}
const poolSize = Math.max(0, Math.round(PERFORMANCE.FIGHTER_HUD_POOL_SIZE));
scene.fighterHudPool = Array.from({ length: poolSize }, () => createHudSlot(scene));
return scene.fighterHudPool;
}
function createHudSlot(scene) {
const healthBack = scene.add
.rectangle(0, 0, 72, 8, 0x17180e, 0.92)
.setDepth(4)
.setVisible(false);
const healthBar = scene.add
.rectangle(0, 0, 68, 4, 0xd95f3f, 1)
.setOrigin(0, 0.5)
.setDepth(5)
.setVisible(false);
return {
fighter: null,
healthBack,
healthBar,
};
}
function configureHudSlot(hudSlot) {
hudSlot.healthBack.setDepth(4);
hudSlot.healthBar.setDepth(5);
}
function attachHudCleanup(fighter) {
const originalDestroy = fighter.destroy.bind(fighter);
fighter.destroy = (...args) => {
releaseFighterHud(fighter);
originalDestroy(...args);
};
}
@@ -4,6 +4,7 @@ export const fighterManifest = [
{ {
key: "knight", key: "knight",
label: "Knight", label: "Knight",
species: "human",
assetRoot: "assets/characters/knight", assetRoot: "assets/characters/knight",
animations: { animations: {
idle: animation("Knight-Idle.png", 6), idle: animation("Knight-Idle.png", 6),
@@ -19,6 +20,7 @@ export const fighterManifest = [
{ {
key: "orc", key: "orc",
label: "Orc", label: "Orc",
species: "orc",
assetRoot: "assets/characters/orc", assetRoot: "assets/characters/orc",
animations: { animations: {
idle: animation("Orc-Idle.png", 6), idle: animation("Orc-Idle.png", 6),
@@ -32,6 +34,7 @@ export const fighterManifest = [
{ {
key: "archer", key: "archer",
label: "Archer", label: "Archer",
species: "human",
assetRoot: "assets/characters/archer", assetRoot: "assets/characters/archer",
combat: { combat: {
projectile: { projectile: {
@@ -51,6 +54,7 @@ export const fighterManifest = [
{ {
key: "armored-axeman", key: "armored-axeman",
label: "Armored Axeman", label: "Armored Axeman",
species: "human",
assetRoot: "assets/characters/armored-axeman", assetRoot: "assets/characters/armored-axeman",
animations: { animations: {
idle: animation("Armored Axeman-Idle.png", 6), idle: animation("Armored Axeman-Idle.png", 6),
@@ -65,6 +69,7 @@ export const fighterManifest = [
{ {
key: "armored-orc", key: "armored-orc",
label: "Armored Orc", label: "Armored Orc",
species: "orc",
assetRoot: "assets/characters/armored-orc", assetRoot: "assets/characters/armored-orc",
animations: { animations: {
idle: animation("Armored Orc-Idle.png", 6), idle: animation("Armored Orc-Idle.png", 6),
@@ -80,6 +85,7 @@ export const fighterManifest = [
{ {
key: "armored-skeleton", key: "armored-skeleton",
label: "Armored Skeleton", label: "Armored Skeleton",
species: "skeleton",
assetRoot: "assets/characters/armored-skeleton", assetRoot: "assets/characters/armored-skeleton",
animations: { animations: {
idle: animation("Armored Skeleton-Idle.png", 6), idle: animation("Armored Skeleton-Idle.png", 6),
@@ -93,6 +99,7 @@ export const fighterManifest = [
{ {
key: "elite-orc", key: "elite-orc",
label: "Elite Orc", label: "Elite Orc",
species: "orc",
assetRoot: "assets/characters/elite-orc", assetRoot: "assets/characters/elite-orc",
animations: { animations: {
idle: animation("Elite Orc-Idle.png", 6), idle: animation("Elite Orc-Idle.png", 6),
@@ -107,6 +114,7 @@ export const fighterManifest = [
{ {
key: "greatsword-skeleton", key: "greatsword-skeleton",
label: "Greatsword Skeleton", label: "Greatsword Skeleton",
species: "skeleton",
assetRoot: "assets/characters/greatsword-skeleton", assetRoot: "assets/characters/greatsword-skeleton",
animations: { animations: {
idle: animation("Greatsword Skeleton-Idle.png", 6), idle: animation("Greatsword Skeleton-Idle.png", 6),
@@ -121,6 +129,7 @@ export const fighterManifest = [
{ {
key: "knight-templar", key: "knight-templar",
label: "Knight Templar", label: "Knight Templar",
species: "human",
assetRoot: "assets/characters/knight-templar", assetRoot: "assets/characters/knight-templar",
animations: { animations: {
idle: animation("Knight Templar-Idle.png", 6), idle: animation("Knight Templar-Idle.png", 6),
@@ -137,6 +146,7 @@ export const fighterManifest = [
{ {
key: "lancer", key: "lancer",
label: "Lancer", label: "Lancer",
species: "human",
assetRoot: "assets/characters/lancer", assetRoot: "assets/characters/lancer",
animations: { animations: {
idle: animation("Lancer-Idle.png", 6), idle: animation("Lancer-Idle.png", 6),
@@ -152,6 +162,7 @@ export const fighterManifest = [
{ {
key: "orc-rider", key: "orc-rider",
label: "Orc rider", label: "Orc rider",
species: "orc",
assetRoot: "assets/characters/orc-rider", assetRoot: "assets/characters/orc-rider",
animations: { animations: {
idle: animation("Orc rider-Idle.png", 6), idle: animation("Orc rider-Idle.png", 6),
@@ -167,6 +178,7 @@ export const fighterManifest = [
{ {
key: "priest", key: "priest",
label: "Priest", label: "Priest",
species: "human",
assetRoot: "assets/characters/priest", assetRoot: "assets/characters/priest",
combat: { combat: {
attackEffect: { attackEffect: {
@@ -187,6 +199,7 @@ export const fighterManifest = [
{ {
key: "skeleton", key: "skeleton",
label: "Skeleton", label: "Skeleton",
species: "skeleton",
assetRoot: "assets/characters/skeleton", assetRoot: "assets/characters/skeleton",
animations: { animations: {
idle: animation("Skeleton-Idle.png", 6), idle: animation("Skeleton-Idle.png", 6),
@@ -201,6 +214,7 @@ export const fighterManifest = [
{ {
key: "skeleton-archer", key: "skeleton-archer",
label: "Skeleton Archer", label: "Skeleton Archer",
species: "skeleton",
assetRoot: "assets/characters/skeleton-archer", assetRoot: "assets/characters/skeleton-archer",
combat: { combat: {
projectile: { projectile: {
@@ -219,7 +233,20 @@ export const fighterManifest = [
{ {
key: "slime", key: "slime",
label: "Slime", label: "Slime",
species: "slime",
assetRoot: "assets/characters/slime", assetRoot: "assets/characters/slime",
stats: {
maxHp: 1,
},
traits: {
spawnMultiplier: 3,
splitOnDeath: {
chance: 0.5,
count: 2,
childMaxHp: 1,
childCanSplit: false,
},
},
animations: { animations: {
idle: animation("Slime-Idle.png", 6), idle: animation("Slime-Idle.png", 6),
walk: animation("Slime-Walk.png", 6), walk: animation("Slime-Walk.png", 6),
@@ -232,6 +259,7 @@ export const fighterManifest = [
{ {
key: "soldier-close", key: "soldier-close",
label: "Soldier Close", label: "Soldier Close",
species: "human",
assetRoot: "assets/characters/soldier", assetRoot: "assets/characters/soldier",
combat: { combat: {
type: "melee", type: "melee",
@@ -248,6 +276,7 @@ export const fighterManifest = [
{ {
key: "soldier-range", key: "soldier-range",
label: "Soldier Range", label: "Soldier Range",
species: "human",
assetRoot: "assets/characters/soldier", assetRoot: "assets/characters/soldier",
combat: { combat: {
projectile: { projectile: {
@@ -266,6 +295,7 @@ export const fighterManifest = [
{ {
key: "swordsman", key: "swordsman",
label: "Swordsman", label: "Swordsman",
species: "human",
assetRoot: "assets/characters/swordsman", assetRoot: "assets/characters/swordsman",
animations: { animations: {
idle: animation("Swordsman-Idle.png", 6), idle: animation("Swordsman-Idle.png", 6),
@@ -280,6 +310,7 @@ export const fighterManifest = [
{ {
key: "werebear", key: "werebear",
label: "Werebear", label: "Werebear",
species: "bear",
assetRoot: "assets/characters/werebear", assetRoot: "assets/characters/werebear",
animations: { animations: {
idle: animation("Werebear-Idle.png", 6), idle: animation("Werebear-Idle.png", 6),
@@ -294,6 +325,7 @@ export const fighterManifest = [
{ {
key: "werewolf", key: "werewolf",
label: "Werewolf", label: "Werewolf",
species: "wolf",
assetRoot: "assets/characters/werewolf", assetRoot: "assets/characters/werewolf",
animations: { animations: {
idle: animation("Werewolf-Idle.png", 6), idle: animation("Werewolf-Idle.png", 6),
@@ -307,6 +339,7 @@ export const fighterManifest = [
{ {
key: "wizard", key: "wizard",
label: "Wizard", label: "Wizard",
species: "human",
assetRoot: "assets/characters/wizard", assetRoot: "assets/characters/wizard",
combat: { combat: {
attackEffect: { attackEffect: {
+153
View File
@@ -0,0 +1,153 @@
const FIGHTER_MODEL_PROPERTY_MAP = {
_detailVisible: "detailVisible",
canSplitOnDeath: "canSplitOnDeath",
combatStats: "combatStats",
facingLeft: "facingLeft",
fighterName: "fighterName",
hp: "hp",
isDead: "isDead",
isFrostStunned: "isFrostStunned",
isLocked: "isLocked",
isSelected: "isSelected",
killCount: "killCount",
killRewardMultiplier: "killRewardMultiplier",
maxHp: "maxHp",
nextAttackAt: "nextAttackAt",
nextTargetScanAt: "nextTargetScanAt",
skin: "skin",
targetModelId: "targetModelId",
team: "team",
teamIndex: "teamIndex",
worldEffectSpeedMultiplier: "worldEffectSpeedMultiplier",
};
let nextFighterModelId = 1;
export function createFighterModel({
canSplitOnDeath = true,
combatStats,
facingLeft = false,
fighterName,
hp,
maxHp,
skin,
team,
teamIndex,
x,
y,
}) {
const id = `fighter-${nextFighterModelId}`;
nextFighterModelId += 1;
return {
id,
active: true,
canSplitOnDeath,
combatStats,
detailVisible: true,
facingLeft: Boolean(facingLeft),
fighterName,
hp,
isDead: false,
isFrostStunned: false,
isLocked: false,
isSelected: false,
killCount: 0,
killRewardMultiplier: 1,
maxHp,
nextAttackAt: 0,
nextTargetScanAt: 0,
skin,
targetModelId: null,
team,
teamIndex,
worldEffectSpeedMultiplier: 1,
x,
y,
};
}
export function attachFighterModel(fighter, model) {
if (!fighter || !model) {
return model;
}
Object.defineProperty(fighter, "model", {
configurable: true,
value: model,
});
Object.defineProperty(fighter, "modelId", {
configurable: true,
get() {
return this.model?.id;
},
});
Object.entries(FIGHTER_MODEL_PROPERTY_MAP).forEach(([fighterKey, modelKey]) => {
Object.defineProperty(fighter, fighterKey, {
configurable: true,
get() {
return this.model?.[modelKey];
},
set(value) {
if (this.model) {
this.model[modelKey] = value;
}
},
});
});
syncFighterModelFromSprite(fighter);
return model;
}
export function getFighterModel(fighter) {
return fighter?.model ?? null;
}
export function fighterModelPoint(fighter) {
const model = getFighterModel(fighter);
return {
x: model?.x ?? fighter?.x ?? 0,
y: model?.y ?? fighter?.y ?? 0,
};
}
export function setFighterModelPosition(fighter, x, y) {
const model = getFighterModel(fighter);
if (!model) {
return;
}
model.x = x;
model.y = y;
}
export function fighterModelDistanceSquared(left, right) {
const deltaX = (left?.x ?? 0) - (right?.x ?? 0);
const deltaY = (left?.y ?? 0) - (right?.y ?? 0);
return deltaX * deltaX + deltaY * deltaY;
}
export function isLivingFighterModel(model) {
return Boolean(model && model.active !== false && !model.isDead);
}
export function syncFighterModelFromSprite(fighter) {
const model = getFighterModel(fighter);
if (
!model
|| fighter?._spriteDetached
|| !Number.isFinite(fighter?.x)
|| !Number.isFinite(fighter?.y)
) {
return;
}
model.x = fighter.x;
model.y = fighter.y;
}
+52
View File
@@ -0,0 +1,52 @@
import { FIGHTER } from "../../constants.js";
export const FIGHTER_TYPES = {
MAGIC: "magic",
MELEE: "melee",
RANGED: "ranged",
};
export function getFighterType(skin) {
const configuredType = skin.combat?.fighterType;
if (configuredType && FIGHTER.TYPE_STATS[configuredType]) {
return configuredType;
}
switch (skin.combat?.type) {
case "projectile":
return FIGHTER_TYPES.RANGED;
case "instant-spell":
return FIGHTER_TYPES.MAGIC;
default:
return FIGHTER_TYPES.MELEE;
}
}
export function getFighterStats(skin) {
const defaults = FIGHTER.TYPE_STATS[getFighterType(skin)];
const stats = skin.stats ?? {};
const combat = skin.combat ?? {};
return {
maxHp: stats.maxHp ?? defaults.maxHp,
moveSpeed: stats.moveSpeed ?? defaults.moveSpeed,
attackRange: combat.range ?? stats.attackRange ?? defaults.attackRange,
attackCooldown: combat.cooldown ?? stats.attackCooldown ?? defaults.attackCooldown,
damageMin: combat.damageMin ?? stats.damageMin ?? defaults.damageMin,
damageMax: combat.damageMax ?? stats.damageMax ?? defaults.damageMax,
criticalChance:
combat.criticalChance ?? stats.criticalChance ?? defaults.criticalChance,
windupDelay: combat.windupDelay ?? stats.windupDelay ?? defaults.windupDelay,
projectileSpeed:
combat.projectile?.speed ??
stats.projectileSpeed ??
defaults.projectileSpeed ??
FIGHTER.TYPE_STATS.ranged.projectileSpeed,
effectHitDelay:
combat.attackEffect?.hitDelay ??
stats.effectHitDelay ??
defaults.effectHitDelay ??
FIGHTER.TYPE_STATS.magic.effectHitDelay,
};
}
-102
View File
@@ -1,102 +0,0 @@
import { FIGHTER_ANIMATION_OPTIONS } from "../constants.js";
export function fighterSheetKey(skin, action) {
return `${skin.key}-${action}`;
}
export function fighterAnimationKey(skin, action) {
return `${fighterSheetKey(skin, action)}-anim`;
}
export function fighterAttackEffectKey(skin) {
return `${skin.key}-attack-effect`;
}
export function fighterAttackEffectAnimationKey(skin) {
return `${fighterAttackEffectKey(skin)}-anim`;
}
export function fighterProjectileKey(skin) {
return `${skin.key}-projectile`;
}
export function preloadFighterSheets(scene, skins) {
skins.forEach((skin) => {
Object.entries(skin.animations).forEach(([action, animation]) => {
scene.load.spritesheet(
fighterSheetKey(skin, action),
`${skin.assetRoot}/${animation.file}`,
{ frameWidth: 100, frameHeight: 100 },
);
});
preloadCombatAssets(scene, skin);
});
}
export function createFighterAnimations(scene, skins) {
skins.forEach((skin) => {
Object.entries(skin.animations).forEach(([action, animation]) => {
const key = fighterAnimationKey(skin, action);
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,
});
});
createAttackEffectAnimation(scene, skin);
});
}
function preloadCombatAssets(scene, skin) {
const projectile = skin.combat?.projectile;
const attackEffect = skin.combat?.attackEffect;
if (projectile) {
scene.load.image(fighterProjectileKey(skin), `${skin.assetRoot}/${projectile.file}`);
}
if (attackEffect) {
scene.load.spritesheet(
fighterAttackEffectKey(skin),
`${skin.assetRoot}/${attackEffect.file}`,
{ frameWidth: 100, frameHeight: 100 },
);
}
}
function createAttackEffectAnimation(scene, skin) {
const attackEffect = skin.combat?.attackEffect;
if (!attackEffect) {
return;
}
const key = fighterAttackEffectAnimationKey(skin);
if (scene.anims.exists(key)) {
return;
}
scene.anims.create({
key,
frames: scene.anims.generateFrameNumbers(fighterAttackEffectKey(skin), {
start: 0,
end: attackEffect.frames - 1,
}),
frameRate: attackEffect.frameRate ?? 14,
repeat: 0,
});
}
-73
View File
@@ -1,73 +0,0 @@
import Phaser from "phaser";
import { FIGHTER_SCALE } from "../constants.js";
import { fighterAnimationKey, fighterSheetKey } from "./fighterAssets.js";
export function createFighter(scene, { faceLeft, name, skin, team, teamIndex, x, y }) {
const fighter = scene.physics.add.sprite(x, y, fighterSheetKey(skin, "idle"), 0);
fighter.setScale(FIGHTER_SCALE);
fighter.setDepth(2);
fighter.setCollideWorldBounds(true);
fighter.setFlipX(faceLeft);
fighter.body.setSize(22, 20);
fighter.body.setOffset(39, 60);
fighter.nameLabel = scene.add
.text(x, y - 68, name, {
color: "#fff2c2",
fontFamily: "Inter, Pretendard, sans-serif",
fontSize: "18px",
fontStyle: "700",
stroke: team.color,
strokeThickness: 4,
})
.setOrigin(0.5)
.setDepth(4);
fighter.healthBack = scene.add
.rectangle(x, y - 44, 72, 8, 0x17180e, 0.92)
.setDepth(4);
fighter.healthBar = scene.add
.rectangle(x - 34, y - 44, 68, 4, 0xd95f3f, 1)
.setOrigin(0, 0.5)
.setDepth(5);
fighter.skin = skin;
fighter.team = team;
fighter.teamIndex = teamIndex;
fighter.hp = 100;
fighter.nextAttackAt = 0;
fighter.isLocked = false;
fighter.isDead = false;
fighter.play(fighterAnimationKey(skin, "walk"));
fighter.on(Phaser.Animations.Events.ANIMATION_COMPLETE, (animation) => {
if (fighter.isDead) {
return;
}
if (animation.key.includes("-attack") || animation.key.endsWith("-hurt-anim")) {
fighter.isLocked = false;
}
});
attachHudCleanup(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));
}
function attachHudCleanup(fighter) {
const originalDestroy = fighter.destroy.bind(fighter);
fighter.destroy = (...args) => {
fighter.nameLabel.destroy();
fighter.healthBack.destroy();
fighter.healthBar.destroy();
originalDestroy(...args);
};
}
+54
View File
@@ -0,0 +1,54 @@
import Phaser from "phaser";
import { ARENA } from "../../constants.js";
const SPAWN_CLUSTER_MARGIN = 48;
const SPAWN_CLUSTER_STEP = 28;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
export function createFighterPlans(fighterSetups, skins, { expandSpawnMultipliers = true } = {}) {
return fighterSetups.flatMap((fighterSetup, index) => {
const skin = skins[index];
const spawnMultiplier = expandSpawnMultipliers
? Math.max(1, Math.round(skin.traits?.spawnMultiplier ?? 1))
: 1;
return Array.from({ length: spawnMultiplier }, (_, spawnIndex) => {
const position = clusterSpawnPosition(fighterSetup, spawnIndex, spawnMultiplier);
return {
...fighterSetup,
skin,
x: position.x,
y: position.y,
};
});
});
}
export function clusterSpawnPosition(origin, index, count) {
if (count <= 1 || index === 0) {
return {
x: origin.x,
y: origin.y,
};
}
const ring = Math.ceil(index / 6);
const radius = SPAWN_CLUSTER_STEP * ring;
const angle = index * GOLDEN_ANGLE;
return {
x: clampInsideArena(origin.x + Math.cos(angle) * radius),
y: clampInsideArena(origin.y + Math.sin(angle) * radius),
};
}
export function clampInsideArena(value) {
return Phaser.Math.Clamp(value, SPAWN_CLUSTER_MARGIN, ARENA.SIZE - SPAWN_CLUSTER_MARGIN);
}
export function syncTeamSizes(teams, fighterPlans) {
teams.forEach((team) => {
team.size = fighterPlans.filter((fighterPlan) => fighterPlan.team.id === team.id).length;
});
}
+238
View File
@@ -0,0 +1,238 @@
import { ARENA, SPAWN, TEAM } from "../../constants.js";
const NAME_MULTIPLIER_REGEX = /\*(\d+)$/;
export function createMatchSetup(
names,
requestedSpawnPlacement = SPAWN.DEFAULT_PLACEMENT,
) {
const teams = names.map((rawName, index) => {
const match = rawName.match(NAME_MULTIPLIER_REGEX);
const multiplier = match ? Math.max(1, parseInt(match[1], 10)) : 1;
const label = match ? rawName.replace(NAME_MULTIPLIER_REGEX, "") : rawName;
return {
color: TEAM.getColor(index, names.length),
id: `team-${index + 1}`,
label,
multiplier,
size: multiplier,
startingZoneCount: Math.ceil(multiplier / SPAWN.FIGHTERS_PER_STARTING_ZONE),
};
});
const totalFighters = teams.reduce((sum, team) => sum + team.size, 0);
if (totalFighters > SPAWN.MAX_FIGHTER_COUNT) {
throw new FighterCountLimitError(totalFighters);
}
const startingZones =
requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES
? createStartingZones(teams)
: [];
const spawns = createSpawnPoints(
totalFighters,
requestedSpawnPlacement,
startingZones,
);
const fighters = [];
teams.forEach((team) => {
for (let i = 0; i < team.size; i++) {
const globalIndex = fighters.length;
fighters.push({
...spawns[globalIndex],
name: team.label,
team: team,
teamIndex: i,
});
}
});
return {
fighters,
startingZones,
teams,
};
}
export class FighterCountLimitError extends Error {
constructor(fighterCount) {
super(`Requested fighter count exceeds the ${SPAWN.MAX_FIGHTER_COUNT} limit.`);
this.fighterCount = fighterCount;
this.maxFighterCount = SPAWN.MAX_FIGHTER_COUNT;
}
}
export function matchStatusText(teams) {
const totalFighters = teams.reduce((sum, team) => sum + team.size, 0);
const labels = teams.map((team) => `${team.label} ${team.size}명`).join(" / ");
return `${teams.length}팀 | 총 ${totalFighters}명 출전 | ${labels}`;
}
function createSpawnPoints(totalCount, requestedSpawnPlacement, startingZones) {
if (requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES) {
return createStartingZoneSpawnPoints(startingZones);
}
return createRandomSpawnPoints(totalCount);
}
function createRandomSpawnPoints(count) {
return createSpawnPointsFromSlots(createSpawnSlots(), count);
}
function createStartingZoneSpawnPoints(startingZones) {
const fallbackSlots = createSpawnSlots();
return startingZones.flatMap((zone) => {
const zoneSlots = createSpawnSlots(zone);
return createSpawnPointsFromSlots(
zoneSlots.length > 0 ? zoneSlots : fallbackSlots,
zone.spawnCount,
);
});
}
function createStartingZones(teams) {
const totalZonesNeeded = teams.reduce((sum, team) => sum + team.startingZoneCount, 0);
const layout = shuffle(createStartingZoneLayout(totalZonesNeeded));
let layoutIndex = 0;
return teams.flatMap((team) => {
let remainingFighters = team.size;
const teamZones = [];
for (let i = 0; i < team.startingZoneCount; i++) {
const spawnCount = Math.min(SPAWN.FIGHTERS_PER_STARTING_ZONE, remainingFighters);
teamZones.push({
...layout[layoutIndex++],
color: team.color,
spawnCount,
teamId: team.id,
});
remainingFighters -= spawnCount;
}
return teamZones;
});
}
function createStartingZoneLayout(teamCount) {
const zones = [];
let candidates = shuffle(createStartingZoneCandidates());
while (zones.length < teamCount) {
if (candidates.length === 0) {
candidates = shuffle(createStartingZoneCandidates());
}
const separateCandidateIndex = candidates.findIndex((candidate) =>
zones.every((zone) => !startingZonesOverlap(zone, candidate)),
);
const selectedIndex = separateCandidateIndex >= 0 ? separateCandidateIndex : 0;
zones.push(...candidates.splice(selectedIndex, 1));
}
return zones;
}
function createStartingZoneCandidates() {
const zones = [];
for (
let anchorRow = 1 + SPAWN.STARTING_ZONE_RADIUS;
anchorRow < ARENA.GRID_SIZE - 1 - SPAWN.STARTING_ZONE_RADIUS;
anchorRow += 1
) {
for (
let anchorColumn = SPAWN.STARTING_ZONE_RADIUS;
anchorColumn < ARENA.GRID_SIZE - SPAWN.STARTING_ZONE_RADIUS;
anchorColumn += 1
) {
zones.push({
anchorColumn,
anchorRow,
columnEnd: anchorColumn + SPAWN.STARTING_ZONE_RADIUS + 1,
columnStart: anchorColumn - SPAWN.STARTING_ZONE_RADIUS,
rowEnd: anchorRow + SPAWN.STARTING_ZONE_RADIUS + 1,
rowStart: anchorRow - SPAWN.STARTING_ZONE_RADIUS,
});
}
}
return zones;
}
function createSpawnSlots({
columnEnd = ARENA.GRID_SIZE,
columnStart = 0,
rowEnd = ARENA.GRID_SIZE - 1,
rowStart = 1,
} = {}) {
const spawnSlots = [];
for (let row = rowStart; row < rowEnd; row += 1) {
for (let column = columnStart; column < columnEnd; column += 1) {
spawnSlots.push({
x: column * ARENA.TILE_SIZE + ARENA.TILE_SIZE / 2,
y: row * ARENA.TILE_SIZE + ARENA.TILE_SIZE / 2,
});
}
}
return spawnSlots;
}
function createSpawnPointsFromSlots(spawnSlots, count) {
const points = [];
while (points.length < count) {
shuffle([...spawnSlots]).forEach((slot) => {
if (points.length >= count) {
return;
}
points.push({
faceLeft: Math.random() >= 0.5,
x: clampInsideArena(slot.x + spawnJitter(), ARENA.TILE_SIZE / 2),
y: clampInsideArena(slot.y + spawnJitter(), ARENA.TILE_SIZE),
});
});
}
return points;
}
function startingZonesOverlap(left, right) {
return (
left.columnStart < right.columnEnd &&
left.columnEnd > right.columnStart &&
left.rowStart < right.rowEnd &&
left.rowEnd > right.rowStart
);
}
function spawnJitter() {
return (Math.random() - 0.5) * ARENA.TILE_SIZE * 0.36;
}
function clampInsideArena(value, margin) {
return clamp(value, margin, ARENA.SIZE - margin);
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
function shuffle(items) {
for (let index = items.length - 1; index > 0; index -= 1) {
const randomIndex = Math.floor(Math.random() * (index + 1));
[items[index], items[randomIndex]] = [items[randomIndex], items[index]];
}
return items;
}
-119
View File
@@ -1,119 +0,0 @@
import {
ARENA_SIZE,
DEFAULT_TEAM_SIZE,
GRID_SIZE,
MAX_TEAM_SIZE,
TEAM_COLORS,
TILE_SIZE,
} from "../constants.js";
export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) {
const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE));
const teams = names.map((name, index) => ({
color: TEAM_COLORS[index % TEAM_COLORS.length],
id: `team-${index + 1}`,
label: name,
size: teamSize,
}));
const totalFighters = names.length * teamSize;
const spawns = createRandomSpawnPoints(totalFighters);
const fighters = [];
names.forEach((name, teamIndex) => {
for (let i = 0; i < teamSize; i++) {
const globalIndex = teamIndex * teamSize + i;
fighters.push({
...spawns[globalIndex],
name: name,
team: teams[teamIndex],
teamIndex: i,
});
}
});
return {
fighters,
teams,
};
}
export function matchStatusText(teams) {
const teamSizes = teams.map((team) => team.size).join(", ");
return `${teams.length}팀 전투: ${teamSizes}`;
}
function createTeams(playerCount, teamSize) {
return Array.from({ length: Math.ceil(playerCount / teamSize) }, (_, index) => ({
color: TEAM_COLORS[index % TEAM_COLORS.length],
id: `team-${index + 1}`,
label: `Team ${index + 1}`,
size: Math.min(teamSize, playerCount - index * teamSize),
}));
}
function createRandomSpawnPoints(count) {
const spawnSlots = [];
for (let row = 1; row < GRID_SIZE - 1; row += 1) {
for (let column = 0; column < GRID_SIZE; column += 1) {
spawnSlots.push({
x: column * TILE_SIZE + TILE_SIZE / 2,
y: row * TILE_SIZE + TILE_SIZE / 2,
});
}
}
const points = [];
while (points.length < count) {
shuffle([...spawnSlots]).forEach((slot) => {
if (points.length >= count) {
return;
}
points.push({
faceLeft: Math.random() >= 0.5,
x: clampInsideArena(slot.x + spawnJitter(), TILE_SIZE / 2),
y: clampInsideArena(slot.y + spawnJitter(), TILE_SIZE),
});
});
}
return points;
}
function resolveTeamSize(playerCount, requestedTeamSize) {
const teamSize = clamp(
Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE),
1,
MAX_TEAM_SIZE,
);
if (playerCount <= teamSize) {
return Math.max(1, Math.ceil(playerCount / 2));
}
return teamSize;
}
function spawnJitter() {
return (Math.random() - 0.5) * TILE_SIZE * 0.36;
}
function clampInsideArena(value, margin) {
return clamp(value, margin, ARENA_SIZE - margin);
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
function shuffle(items) {
for (let index = items.length - 1; index > 0; index -= 1) {
const randomIndex = Math.floor(Math.random() * (index + 1));
[items[index], items[randomIndex]] = [items[randomIndex], items[index]];
}
return items;
}
+199 -7
View File
@@ -1,21 +1,191 @@
import Phaser from "phaser"; import Phaser from "phaser";
import { ArenaScene } from "./game/ArenaScene.js"; import { ArenaScene } from "./game/arena/ArenaScene.js";
import { ARENA_SIZE } from "./constants.js"; import {
RENDER,
SPAWN,
} from "./constants.js";
import { createMatchForm } from "./ui/matchForm.js"; import { createMatchForm } from "./ui/matchForm.js";
import "./styles.css"; import { createAboutDialog } from "./ui/aboutDialog.js";
import { trackVisitor } from "./ui/visitorCounter.js";
const matchForm = createMatchForm(); const matchForm = createMatchForm();
const aboutDialog = createAboutDialog();
const appNode = document.querySelector("#app");
const startButton = document.querySelector("#start-button");
const drawer = document.querySelector("#fighter-entry");
const drawerCloseButton = document.querySelector("#drawer-close");
const drawerScrim = document.querySelector("#drawer-scrim");
const drawerToggleButton = document.querySelector("#drawer-toggle");
const playerNamesInput = document.querySelector("#player-names");
const pauseButton = document.querySelector("#pause-button");
const restartButton = document.querySelector("#restart-button");
const MOBILE_MATCH_MEDIA_QUERY = "(max-width: 960px)";
function isMatchLive() {
return appNode?.classList.contains("match-live") ?? false;
}
function openOptionsDrawer({ focus = true } = {}) {
appNode?.classList.add("options-open");
setDrawerCollapsed(false);
resetDrawerScroll();
drawer?.setAttribute("aria-hidden", "false");
startButton?.setAttribute("aria-expanded", "true");
if (focus) {
window.setTimeout(() => playerNamesInput?.focus(), 220);
}
}
function closeOptionsDrawer() {
if (isMatchLive()) {
setDrawerCollapsed(true);
return;
}
appNode?.classList.remove("options-open");
appNode?.classList.remove("drawer-collapsed");
drawer?.setAttribute("aria-hidden", "true");
startButton?.setAttribute("aria-expanded", "false");
syncDrawerToggleButton();
}
function startConfiguredMatch(matchConfig) {
if (matchConfig.names.length < 2) {
matchForm.setStatus("참가자 닉네임을 2명 이상 입력하세요");
return;
}
if (!arenaScene.startMatch(matchConfig)) {
return;
}
appNode?.classList.remove("match-ended");
appNode?.classList.add("match-live");
if (shouldCompactOptionsDrawer()) {
setDrawerCollapsed(true);
} else {
openOptionsDrawer({ focus: false });
}
syncPauseButton();
}
function getPresentationMatchConfig() {
return {
names: Array.from(
{ length: SPAWN.PRESENTATION_TEAM_COUNT },
(_, index) => `Player ${index + 1}*${SPAWN.PRESENTATION_TEAM_SIZE}`,
),
};
}
function setDrawerCollapsed(collapsed) {
const nextCollapsed = Boolean(collapsed) && isMatchLive();
appNode?.classList.toggle("drawer-collapsed", nextCollapsed);
drawer?.setAttribute("aria-hidden", "false");
if (!nextCollapsed) {
resetDrawerScroll();
}
syncDrawerToggleButton();
}
function syncDrawerToggleButton() {
if (!drawerToggleButton) {
return;
}
const isCollapsed = appNode?.classList.contains("drawer-collapsed") ?? false;
drawerToggleButton.textContent = isCollapsed ? "옵션 펼치기" : "옵션 접기";
drawerToggleButton.setAttribute("aria-expanded", String(!isCollapsed));
}
function syncPauseButton() {
if (!pauseButton) {
return;
}
const isPaused = arenaScene.isMatchPaused();
appNode?.classList.toggle("match-paused", isPaused);
pauseButton.textContent = isPaused ? "계속" : "일시정지";
pauseButton.setAttribute("aria-pressed", String(isPaused));
}
function shouldCompactOptionsDrawer() {
return window.matchMedia?.(MOBILE_MATCH_MEDIA_QUERY).matches ?? window.innerWidth <= 960;
}
function resetDrawerScroll() {
if (drawer) {
drawer.scrollTop = 0;
}
}
function handleMatchEnd() {
appNode?.classList.add("match-ended");
setDrawerCollapsed(true);
syncPauseButton();
}
function revealAppWhenStylesAreReady() {
const stylesheet = document.querySelector('link[data-app-styles], link[rel="stylesheet"]');
const reveal = () => {
window.requestAnimationFrame(() => {
document.documentElement.classList.remove("app-booting");
});
};
if (!stylesheet || stylesheet.sheet) {
reveal();
return;
}
stylesheet.addEventListener("load", reveal, { once: true });
}
startButton?.addEventListener("click", openOptionsDrawer);
drawerCloseButton?.addEventListener("click", closeOptionsDrawer);
drawerScrim?.addEventListener("click", closeOptionsDrawer);
drawerToggleButton?.addEventListener("click", () => {
const isCollapsed = appNode?.classList.contains("drawer-collapsed") ?? false;
setDrawerCollapsed(!isCollapsed);
});
pauseButton?.addEventListener("click", () => {
arenaScene.togglePause();
syncPauseButton();
});
restartButton?.addEventListener("click", () => {
startConfiguredMatch(matchForm.readMatchConfig());
});
window.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
if (aboutDialog?.isOpen()) {
return;
}
closeOptionsDrawer();
}
});
const arenaScene = new ArenaScene({ const arenaScene = new ArenaScene({
getInitialMatchConfig: matchForm.readMatchConfig, getInitialMatchConfig: getPresentationMatchConfig,
onMatchEnd: handleMatchEnd,
setPlayerNamesWarning: matchForm.setPlayerNamesWarning,
setStatus: matchForm.setStatus, setStatus: matchForm.setStatus,
}); });
const game = new Phaser.Game({ const game = new Phaser.Game({
type: Phaser.AUTO, type: Phaser.AUTO,
parent: "game", parent: "game",
width: ARENA_SIZE, width: RENDER.WIDTH,
height: ARENA_SIZE, height: RENDER.HEIGHT,
autoRound: true,
pixelArt: true, pixelArt: true,
powerPreference: "high-performance",
backgroundColor: "#282819", backgroundColor: "#282819",
physics: { physics: {
default: "arcade", default: "arcade",
@@ -30,6 +200,28 @@ const game = new Phaser.Game({
scene: arenaScene, scene: arenaScene,
}); });
matchForm.onSubmit((matchConfig) => arenaScene.startMatch(matchConfig)); revealAppWhenStylesAreReady();
matchForm.onSubmit((matchConfig) => {
startConfiguredMatch(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;
+6 -287
View File
@@ -1,287 +1,6 @@
:root { @import "./styles/base.css";
color-scheme: dark; @import "./styles/intro.css";
font-family: @import "./styles/game-ui.css";
Inter, Pretendard, "Noto Sans KR", system-ui, -apple-system, BlinkMacSystemFont, @import "./styles/overlay.css";
"Segoe UI", sans-serif; @import "./styles/animations.css";
background: #141612; @import "./styles/mobile.css";
color: #f6f1dd;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
input,
textarea {
font: inherit;
}
button {
border: 0;
cursor: pointer;
}
#app {
display: grid;
min-height: 100vh;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
background:
linear-gradient(135deg, rgb(112 53 29 / 0.16), transparent 30%),
linear-gradient(180deg, #171912, #0d0f0c);
}
.fighter-entry {
display: grid;
align-content: center;
gap: 28px;
padding: clamp(24px, 5vw, 48px);
border-right: 1px solid rgb(230 207 134 / 0.14);
}
.entry-copy {
display: grid;
gap: 10px;
}
.eyebrow {
margin: 0;
color: #d6a94a;
font-size: 0.82rem;
font-weight: 800;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(1.8rem, 4vw, 3rem);
line-height: 1.05;
letter-spacing: 0;
}
form {
display: grid;
gap: 16px;
}
fieldset {
display: grid;
gap: 10px;
min-width: 0;
margin: 0;
border: 1px solid rgb(230 207 134 / 0.18);
border-radius: 8px;
padding: 12px;
}
legend {
padding: 0 6px;
color: #d6a94a;
font-size: 0.82rem;
font-weight: 800;
text-transform: uppercase;
}
.team-size-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
output {
min-width: 88px;
border: 1px solid rgb(230 207 134 / 0.18);
border-radius: 6px;
padding: 8px 10px;
background: #1d2017;
color: #fff7df;
text-align: center;
font-weight: 800;
}
label {
color: #e6d7ac;
font-size: 0.92rem;
}
input:not([type="range"]),
textarea {
min-height: 48px;
border: 1px solid rgb(230 207 134 / 0.24);
border-radius: 6px;
padding: 0 14px;
background: #26291d;
color: #fff7df;
outline: none;
}
textarea {
min-height: 232px;
resize: vertical;
padding-block: 12px;
line-height: 1.45;
}
input[type="range"] {
width: 100%;
accent-color: #d6a94a;
}
input:focus,
textarea:focus {
border-color: #d6a94a;
box-shadow: 0 0 0 3px rgb(214 169 74 / 0.18);
}
button {
min-height: 50px;
border-radius: 6px;
background: #c84f34;
color: #fff1da;
font-weight: 800;
}
button:hover {
background: #dd6245;
}
.arena-shell {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
min-height: 100vh;
overflow: hidden;
padding: clamp(16px, 3vw, 36px);
}
.scoreboard {
width: 100%;
display: flex;
justify-content: space-between;
padding: 0.5rem 1rem;
background: rgba(0, 0, 0, 0.5);
border-radius: 8px;
min-height: 40px;
pointer-events: none;
z-index: 10;
}
.score-side {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
width: 48%;
}
.score-side.right {
justify-content: flex-end;
}
.team-score {
padding: 4px 10px;
border-radius: 4px;
font-size: 0.85rem;
font-weight: bold;
color: #fff;
text-shadow: 1px 1px 2px #000;
display: flex;
gap: 6px;
background: rgba(255, 255, 255, 0.1);
}
.victory-banner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.85);
padding: 1.5rem 3rem;
border: 2px solid #d6a94a;
border-radius: 12px;
color: #fff7df;
font-size: 2rem;
font-weight: 900;
text-align: center;
z-index: 100;
box-shadow: 0 0 30px rgba(214, 169, 74, 0.4);
backdrop-filter: blur(4px);
animation: banner-in 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes banner-in {
from { transform: translate(-50%, -60%) scale(0.8); opacity: 0; }
to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
}
#game {
width: min(100%, calc(100vh - 72px), 1080px);
aspect-ratio: 1;
overflow: hidden;
border: 1px solid rgb(245 219 136 / 0.22);
border-radius: 8px;
background: #242617;
box-shadow:
0 24px 80px rgb(0 0 0 / 0.45),
inset 0 0 0 1px rgb(255 244 205 / 0.06);
}
#game canvas {
display: block;
width: 100%;
height: 100%;
image-rendering: pixelated;
}
.match-status {
position: absolute;
top: clamp(24px, 4vw, 48px);
left: 50%;
min-width: min(78vw, 340px);
transform: translateX(-50%);
border: 1px solid rgb(252 224 147 / 0.22);
border-radius: 8px;
padding: 12px 16px;
background: rgb(18 19 13 / 0.82);
color: #f8e8b5;
text-align: center;
font-weight: 800;
backdrop-filter: blur(8px);
pointer-events: none;
}
@media (max-width: 820px) {
#app {
grid-template-columns: 1fr;
}
.fighter-entry {
align-content: start;
gap: 18px;
padding-bottom: 18px;
border-right: 0;
border-bottom: 1px solid rgb(230 207 134 / 0.14);
}
.arena-shell {
align-content: start;
min-height: auto;
}
#game {
width: min(100%, calc(100svh - 360px));
min-width: min(100%, 320px);
}
.match-status {
top: 28px;
}
}
+158
View File
@@ -0,0 +1,158 @@
@keyframes intro-rise {
from {
opacity: 0;
transform: translateY(22px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes preview-attack {
to {
background-position-x: var(--sprite-end);
}
}
@keyframes preview-breathe {
0%,
100% {
margin-top: 0;
}
50% {
margin-top: -8px;
}
}
@keyframes preview-strike {
0%,
58%,
100% {
opacity: 0;
}
64%,
76% {
opacity: 0.86;
}
}
@keyframes status-marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@keyframes kill-log-entry {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes banner-in {
from {
opacity: 0;
transform: translateY(18px) scale(0.78);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes victory-banner-sheen {
from {
opacity: 0;
transform: translateX(0) skewX(-18deg);
}
18% {
opacity: 1;
}
to {
opacity: 0;
transform: translateX(560%) skewX(-18deg);
}
}
@keyframes victory-confetti-burst {
0% {
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--confetti-tilt)) scale(0.3);
}
12% {
opacity: 1;
}
74% {
opacity: 1;
}
100% {
opacity: 0;
transform:
translate(calc(-50% + var(--confetti-x)), calc(-50% + var(--confetti-y)))
rotate(calc(var(--confetti-tilt) + var(--confetti-spin)))
scale(1);
}
}
@keyframes victory-glow {
from {
opacity: 0;
transform: scale(0.58);
}
35% {
opacity: 1;
}
to {
opacity: 0.8;
transform: scale(1);
}
}
@keyframes victory-rays-in {
from {
transform: scale(0.56);
}
to {
transform: scale(1);
}
}
@keyframes victory-rays-turn {
to {
rotate: 360deg;
}
}
@keyframes victory-message-pulse {
from {
opacity: 0.72;
transform: scale(0.88);
}
58% {
transform: scale(1.05);
}
to {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.victory-banner,
.victory-banner::before,
.victory-banner-message,
.victory-celebration::before,
.victory-confetti-piece,
.victory-rays {
animation-duration: 1ms;
animation-iteration-count: 1;
}
}
+129
View File
@@ -0,0 +1,129 @@
:root {
color-scheme: dark;
font-family:
Inter, Pretendard, "Noto Sans KR", system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
background: #080a07;
color: #fff5db;
}
* {
box-sizing: border-box;
}
html {
min-width: 320px;
min-height: 100%;
background: #080a07;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
overflow: hidden;
}
button,
input,
textarea {
font: inherit;
}
button {
border: 0;
cursor: pointer;
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 3px solid rgb(238 185 73 / 0.46);
outline-offset: 3px;
}
#app {
--arena-gap: 18px;
--score-band-height: 134px;
--score-panel-left: 14px;
--score-panel-width: 260px;
--score-rail-width: calc(var(--score-panel-left) + var(--score-panel-width));
--drawer-width: min(430px, 100vw);
--drawer-live-width: min(340px, calc(100vw - 48px));
position: relative;
min-height: 100vh;
overflow: hidden;
background:
linear-gradient(180deg, rgb(8 10 7 / 0.18), rgb(3 5 4 / 0.84)),
#080a07;
}
#app.match-live {
--drawer-width: var(--drawer-live-width);
}
.arena-shell {
position: fixed;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
background: #090b08;
}
.arena-shell::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
background:
radial-gradient(circle at 50% 50%, rgb(255 211 122 / 0.06), transparent 42%),
linear-gradient(90deg, rgb(3 5 4 / 0.48), rgb(3 5 4 / 0.08) 45%, rgb(3 5 4 / 0.48)),
linear-gradient(180deg, rgb(3 5 4 / 0.08), rgb(3 5 4 / 0.5));
pointer-events: none;
transition:
background 520ms ease,
opacity 520ms ease;
}
#app.match-live .arena-shell::before {
opacity: 0.24;
}
#app.match-live .arena-shell {
place-items: center;
}
#game {
position: relative;
z-index: 0;
width: max(100vw, 100vh);
height: max(100vw, 100vh);
overflow: hidden;
opacity: 0.68;
filter: saturate(1) contrast(1.08) brightness(1.08);
transform: scale(1.04);
transform-origin: center;
transition:
width 620ms cubic-bezier(0.2, 0.8, 0.2, 1),
height 620ms cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 520ms ease,
filter 520ms ease,
transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#app.match-live #game {
width: min(100vw, 100vh);
height: min(100vw, 100vh);
margin-left: 0;
opacity: 1;
filter: none;
transform: scale(1);
}
#game canvas {
display: block;
width: 100% !important;
height: 100% !important;
image-rendering: pixelated;
}
+603
View File
@@ -0,0 +1,603 @@
.scoreboard {
position: fixed;
top: clamp(14px, 3vw, 28px);
left: var(--score-panel-left);
z-index: 3;
display: flex;
justify-content: flex-start;
width: var(--score-panel-width);
max-height: calc(100vh - 420px);
min-height: 64px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgb(238 185 73 / 0.3) transparent;
padding: 8px;
border: 1px solid rgb(238 185 73 / 0.18);
border-radius: 8px;
background: rgb(4 6 4 / 0.5);
opacity: 0;
pointer-events: none;
transform: translateY(-18px);
transition:
opacity 420ms ease,
transform 420ms ease;
backdrop-filter: blur(10px);
}
.scoreboard::-webkit-scrollbar {
width: 4px;
}
.scoreboard::-webkit-scrollbar-track {
background: transparent;
}
.scoreboard::-webkit-scrollbar-thumb {
border-radius: 10px;
background: rgb(238 185 73 / 0.3);
}
#app.match-live .scoreboard {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.score-side {
display: grid;
grid-template-columns: repeat(2, 114px);
gap: 6px;
width: 100%;
}
.score-side.right {
display: none;
}
.team-score {
display: grid;
grid-template-rows: 1fr 1px auto;
gap: 6px;
width: 114px;
min-height: 72px;
overflow: hidden;
border-radius: 6px;
padding: 8px 9px;
color: #fff;
font-size: 0.8rem;
font-weight: 900;
text-align: left;
text-shadow: 1px 1px 2px #000;
transition:
filter 160ms ease,
transform 160ms ease;
}
.team-score:hover {
filter: brightness(1.16);
transform: translateY(-1px);
}
.team-score.is-focused {
box-shadow:
inset 0 0 0 2px rgb(255 244 209 / 0.92),
0 0 18px rgb(227 178 79 / 0.26);
}
.team-score:disabled {
cursor: default;
filter: grayscale(0.6) brightness(0.68);
}
.team-score:disabled:hover {
transform: none;
}
.team-score-name {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
line-height: 1.15;
}
.team-score-rule {
width: 100%;
background: var(--team-color);
opacity: 0.9;
}
.team-score-count {
justify-self: end;
color: #fff2c8;
font-size: 0.86rem;
}
.battle-notice {
position: fixed;
top: clamp(12px, 2vw, 20px);
left: 50%;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
width: auto;
max-width: min(640px, calc(100vw - 40px));
min-height: 38px;
border: 1px solid rgb(238 185 73 / 0.26);
border-radius: 8px;
padding: 8px 18px;
background: rgb(8 10 7 / 0.68);
color: #ffe8b4;
font-size: 0.8rem;
font-weight: 900;
line-height: 1.35;
text-align: center;
text-shadow: 1px 1px 2px #000;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -10px);
transition:
opacity 260ms ease,
transform 260ms ease;
backdrop-filter: blur(10px);
}
#app.match-live .battle-notice.is-visible {
opacity: 1;
transform: translate(-50%, 0);
}
@media (min-width: 961px) {
#app.match-live .battle-notice {
right: auto;
left: 50%;
width: min(420px, 72vmin, calc(100vw - var(--drawer-width) - var(--score-rail-width) - 56px));
transform: translate(-50%, -10px);
}
#app.match-live .battle-notice.is-visible {
transform: translate(-50%, 0);
}
#app.match-live.drawer-collapsed .battle-notice {
right: auto;
width: min(420px, 72vmin, calc(100vw - 64px));
}
}
.kill-log {
position: fixed;
bottom: clamp(14px, 3vw, 26px);
left: var(--score-panel-left);
z-index: 4;
width: min(370px, calc(100vw - 32px));
max-height: min(34vh, 292px);
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgb(238 185 73 / 0.3) transparent;
border: 1px solid rgb(238 185 73 / 0.2);
border-radius: 8px;
padding: 10px;
background: rgb(4 6 4 / 0.58);
opacity: 0;
pointer-events: none;
transform: translateY(16px);
transition:
opacity 260ms ease,
transform 260ms ease;
backdrop-filter: blur(10px);
}
.kill-log::-webkit-scrollbar {
width: 4px;
}
.kill-log::-webkit-scrollbar-track {
background: transparent;
}
.kill-log::-webkit-scrollbar-thumb {
border-radius: 10px;
background: rgb(238 185 73 / 0.3);
}
#app.match-live .kill-log.has-entries {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.kill-log-list {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 0;
margin: 0;
padding: 0;
list-style: none;
}
.kill-log-item {
display: grid;
grid-template-columns: minmax(0, 1fr) 54px minmax(0, 1fr);
align-items: center;
gap: 8px;
min-height: 54px;
border: 1px solid rgb(255 244 209 / 0.12);
border-radius: 6px;
padding: 7px 9px;
background: rgb(8 10 7 / 0.74);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06);
animation: kill-log-entry 180ms ease both;
}
.kill-log-fighter {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.kill-log-fighter.killer {
border-left: 3px solid var(--killer-color);
padding-left: 6px;
}
.kill-log-fighter.victim {
flex-direction: row-reverse;
border-right: 3px solid var(--victim-color);
padding-right: 6px;
justify-content: end;
text-align: right;
}
.kill-log-avatar {
position: relative;
flex: 0 0 auto;
width: 36px;
height: 36px;
border: 1px solid rgb(255 244 209 / 0.16);
border-radius: 6px;
background-color: rgb(255 246 216 / 0.08);
background-position: -24px -16px;
background-repeat: no-repeat;
background-size: auto 86px;
image-rendering: pixelated;
box-shadow: inset 0 -10px 18px rgb(0 0 0 / 0.22);
}
.kill-log-fighter.victim .kill-log-avatar::before,
.kill-log-fighter.victim .kill-log-avatar::after {
content: "";
position: absolute;
top: 7px;
right: 1px;
width: 14px;
height: 2px;
border: 1px solid rgb(255 216 212 / 0.22);
border-radius: 999px;
background: #f24a42;
box-shadow:
0 0 0 1px rgb(48 4 3 / 0.7),
0 0 5px rgb(227 54 46 / 0.6);
transform-origin: center;
}
.kill-log-fighter.victim .kill-log-avatar::before {
transform: rotate(45deg);
}
.kill-log-fighter.victim .kill-log-avatar::after {
transform: rotate(-45deg);
}
.kill-log-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.kill-log-team,
.kill-log-member {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kill-log-team {
min-width: 0;
color: #fff7df;
font-size: 0.78rem;
font-weight: 900;
text-shadow: 1px 1px 2px #000;
}
.kill-log-member {
flex: 0 0 auto;
color: #ead8ad;
font-size: 0.72rem;
font-weight: 800;
}
.kill-log-action {
display: grid;
justify-items: center;
gap: 2px;
min-width: 0;
}
.kill-log-action-text {
color: #ffdc93;
font-size: 0.68rem;
font-weight: 950;
line-height: 1;
}
.kill-log-weapon {
position: relative;
display: block;
width: 28px;
height: 28px;
place-self: center;
border: 1px solid rgb(238 185 73 / 0.28);
border-radius: 999px;
background: rgb(255 246 216 / 0.08);
box-shadow: 0 0 16px rgb(227 89 59 / 0.16);
}
.kill-log-weapon::before,
.kill-log-weapon::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 18px;
height: 3px;
border-radius: 999px;
background: linear-gradient(90deg, #ffe8b4 0 70%, #b93c2f 70% 100%);
box-shadow: 0 0 8px rgb(255 226 166 / 0.3);
transform-origin: center;
}
.kill-log-weapon::before {
transform: translate(-50%, -50%) rotate(42deg);
}
.kill-log-weapon::after {
transform: translate(-50%, -50%) rotate(-42deg);
}
.victory-celebration {
position: fixed;
z-index: 9;
display: grid;
overflow: hidden;
place-items: center;
inset: 0;
background: rgb(4 6 4 / 0.2);
isolation: isolate;
opacity: 1;
pointer-events: auto;
transform: scale(1);
transition:
opacity 220ms ease,
transform 220ms ease;
}
.victory-celebration.is-leaving {
opacity: 0;
transform: scale(0.98);
}
.victory-celebration::before {
content: "";
position: absolute;
z-index: -1;
width: min(122vmin, 1240px);
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(circle, rgb(255 233 166 / 0.18) 0 18%, rgb(227 178 79 / 0.12) 31%, transparent 66%);
animation: victory-glow 1.8s ease-out both;
}
.victory-celebration.is-draw::before {
background:
radial-gradient(circle, rgb(255 247 223 / 0.16) 0 18%, rgb(227 178 79 / 0.1) 31%, transparent 62%);
}
.victory-rays {
position: absolute;
z-index: 0;
width: min(112vmin, 1120px);
aspect-ratio: 1;
border-radius: 50%;
background: repeating-conic-gradient(
from -4deg,
rgb(255 233 166 / 0.18) 0 8deg,
transparent 8deg 18deg
);
opacity: 0.54;
mask-image: radial-gradient(circle, #000 0 18%, transparent 66%);
animation: victory-rays-in 1.1s ease-out both, victory-rays-turn 11s linear infinite;
}
.victory-celebration.is-draw .victory-rays {
opacity: 0.22;
}
.victory-confetti {
position: absolute;
z-index: 1;
inset: 0;
}
.victory-confetti-piece {
position: absolute;
top: 50%;
left: 50%;
display: block;
width: clamp(6px, 0.8vw, 11px);
height: clamp(10px, 1.2vw, 18px);
border-radius: 8px;
background: var(--confetti-color);
box-shadow: 0 0 12px rgb(255 230 166 / 0.22);
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--confetti-tilt)) scale(0.3);
animation: victory-confetti-burst var(--confetti-duration) cubic-bezier(0.15, 0.84, 0.35, 1) var(--confetti-delay) both;
}
.victory-confetti-piece:nth-child(3n) {
width: clamp(10px, 1vw, 15px);
height: clamp(6px, 0.72vw, 10px);
border-radius: 2px;
}
.victory-banner {
position: relative;
z-index: 2;
display: grid;
width: min(calc(100vw - 36px), 760px);
min-height: clamp(108px, 18vw, 170px);
overflow: hidden;
place-items: center;
border: 2px solid #f1c45d;
border-radius: 8px;
padding: clamp(1.25rem, 3.8vw, 2rem) clamp(1.3rem, 5.4vw, 3.4rem);
background:
linear-gradient(135deg, rgb(18 21 13 / 0.98), rgb(3 5 4 / 0.92)),
rgb(4 6 4 / 0.9);
color: #fff7df;
font-size: clamp(1.65rem, 5vw, 3rem);
font-weight: 950;
letter-spacing: 0;
line-height: 1.12;
text-align: center;
text-wrap: balance;
text-shadow:
0 2px 0 rgb(55 36 8 / 0.56),
0 0 24px rgb(255 226 153 / 0.28);
box-shadow:
0 0 0 1px rgb(255 237 187 / 0.2) inset,
0 0 42px rgb(227 178 79 / 0.44),
0 24px 90px rgb(0 0 0 / 0.58);
animation: banner-in 0.64s cubic-bezier(0.16, 0.9, 0.25, 1.2);
backdrop-filter: blur(6px);
}
.victory-banner::before {
content: "";
position: absolute;
inset: -40% auto -40% -36%;
width: 28%;
background: linear-gradient(90deg, transparent, rgb(255 248 223 / 0.6), transparent);
transform: skewX(-18deg);
animation: victory-banner-sheen 1s 0.28s ease-out both;
}
.victory-banner::after {
content: "";
position: absolute;
inset: 10px;
border: 1px solid rgb(255 225 151 / 0.24);
border-radius: 5px;
}
.victory-banner-message {
position: relative;
z-index: 1;
display: block;
max-width: 100%;
overflow-wrap: anywhere;
animation: victory-message-pulse 720ms 80ms ease-out both;
}
.victory-celebration.is-draw .victory-banner {
border-color: #d8c28d;
box-shadow:
0 0 0 1px rgb(255 237 187 / 0.14) inset,
0 0 28px rgb(227 178 79 / 0.24),
0 24px 90px rgb(0 0 0 / 0.52);
}
#app.match-paused .arena-shell::after {
content: "일시정지";
position: fixed;
top: 50%;
left: 50%;
z-index: 6;
border: 1px solid rgb(238 185 73 / 0.34);
border-radius: 8px;
padding: 14px 26px;
background: rgb(5 7 5 / 0.76);
color: #ffe8b4;
font-size: clamp(1.3rem, 4vw, 2rem);
font-weight: 950;
transform: translate(-50%, -50%);
box-shadow: 0 18px 60px rgb(0 0 0 / 0.46);
backdrop-filter: blur(8px);
}
.match-status {
position: fixed;
bottom: clamp(14px, 3vw, 26px);
left: 50%;
z-index: 4;
width: min(980px, calc(100vw - 32px));
min-height: 48px;
overflow: hidden;
border: 1px solid rgb(238 185 73 / 0.28);
border-radius: 8px;
padding: 13px 0;
background: rgb(8 10 7 / 0.74);
color: #ffe2a6;
font-weight: 900;
opacity: 0;
pointer-events: none;
transform: translate(-50%, calc(100% + 28px));
transition:
opacity 420ms ease,
transform 420ms ease;
backdrop-filter: blur(10px);
}
#app.status-active:not(.match-live) .match-status {
opacity: 1;
transform: translate(-50%, 0);
}
#app.match-live .match-status {
display: none;
}
@media (min-width: 961px) {
#app.match-live .match-status {
left: calc((100vw - var(--drawer-width)) / 2);
width: min(760px, calc(100vw - var(--drawer-width) - 32px));
}
#app.match-live.drawer-collapsed .match-status {
left: 50%;
width: min(980px, calc(100vw - 32px));
}
}
.status-track {
display: flex;
width: max-content;
min-width: 200%;
gap: 64px;
animation: status-marquee 22s linear infinite;
}
.status-track span {
flex: 0 0 auto;
min-width: calc(50vw - 32px);
padding-left: 28px;
white-space: nowrap;
}
+225
View File
@@ -0,0 +1,225 @@
.battle-preview {
position: fixed;
inset: 0;
z-index: 2;
overflow: hidden;
opacity: 0.84;
pointer-events: none;
transition:
opacity 420ms ease,
transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#app.match-live .battle-preview {
opacity: 0;
transform: scale(1.08);
}
.preview-fighter {
position: absolute;
width: 100px;
height: 100px;
background-repeat: no-repeat;
background-size: auto 100px;
image-rendering: pixelated;
transform-origin: center;
filter:
drop-shadow(0 18px 22px rgb(0 0 0 / 0.68))
saturate(1.14)
brightness(1.12);
animation:
preview-attack var(--sprite-speed) steps(var(--sprite-steps)) infinite,
preview-breathe 1800ms ease-in-out infinite;
}
.preview-knight {
--sprite-end: -600px;
--sprite-scale: 5.2;
--sprite-speed: 840ms;
--sprite-steps: 6;
left: 10vw;
top: 48vh;
background-image: url("/assets/characters/knight/Knight-Attack01.png");
transform: scale(var(--sprite-scale));
}
.preview-orc {
--sprite-end: -500px;
--sprite-scale: 5.35;
--sprite-speed: 760ms;
--sprite-steps: 5;
right: 9vw;
top: 46vh;
background-image: url("/assets/characters/orc/Orc-Attack01.png");
transform: scaleX(-1) scale(var(--sprite-scale));
}
.preview-wizard {
--sprite-end: -500px;
--sprite-scale: 4.35;
--sprite-speed: 980ms;
--sprite-steps: 5;
left: 56vw;
top: 24vh;
background-image: url("/assets/characters/wizard/Wizard-Attack01.png");
opacity: 0.58;
transform: scaleX(-1) scale(var(--sprite-scale));
}
.preview-strike {
position: absolute;
width: 160px;
height: 5px;
border-radius: 999px;
background: linear-gradient(90deg, transparent, rgb(255 229 156 / 0.86), transparent);
box-shadow: 0 0 24px rgb(227 89 59 / 0.5);
opacity: 0;
transform-origin: center;
animation: preview-strike 980ms ease-in-out infinite;
}
.preview-strike-a {
left: 38vw;
top: 54vh;
transform: rotate(-18deg);
}
.preview-strike-b {
right: 31vw;
top: 42vh;
transform: rotate(22deg);
animation-delay: 260ms;
}
.intro-stage {
position: fixed;
inset: 0;
z-index: 5;
display: grid;
place-items: center;
padding: clamp(24px, 5vw, 56px);
pointer-events: none;
transition:
opacity 420ms ease,
transform 620ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#app.match-live .intro-stage {
opacity: 0;
transform: scale(0.96);
}
#app.match-live .intro-content {
pointer-events: none;
}
.intro-content {
display: grid;
justify-items: center;
gap: 22px;
text-align: center;
pointer-events: auto;
animation: intro-rise 760ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
transition:
transform 560ms cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 360ms ease;
}
#app.options-open:not(.match-live) .intro-content {
opacity: 0.72;
}
.arena-logo {
margin: 0;
color: #fff4d1;
font-size: clamp(4rem, 16vw, 11rem);
font-weight: 950;
letter-spacing: 0;
line-height: 0.9;
text-shadow:
0 2px 0 #ad4d37,
0 14px 42px rgb(0 0 0 / 0.72),
0 0 40px rgb(230 173 71 / 0.28);
text-transform: uppercase;
}
.arena-logo .small-text {
font-size: 0.7em;
margin-top: 0.05em;
}
.arena-logo span {
display: block;
}
.arena-meta {
position: fixed;
right: clamp(10px, 2vw, 18px);
bottom: clamp(10px, 2vw, 18px);
z-index: 10;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
pointer-events: none;
transition: opacity 220ms ease;
}
.visitor-count,
.about-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
margin: 0;
border: 1px solid rgb(238 185 73 / 0.22);
border-radius: 999px;
padding: 5px 12px;
background: rgb(8 10 7 / 0.68);
color: #e7c879;
font-size: 0.72rem;
font-weight: 800;
line-height: 1;
text-decoration: none;
backdrop-filter: blur(10px);
pointer-events: auto;
transition:
background 180ms ease,
border-color 180ms ease,
transform 180ms ease,
opacity 220ms ease;
}
.visitor-count {
opacity: 0;
transform: translateY(8px);
}
#app.match-live .visitor-count {
opacity: 0.86;
transform: translateY(0);
}
.about-button {
min-width: 72px;
color: #ffe8b4;
font-weight: 900;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.22);
}
.about-button:hover {
border-color: rgb(238 185 73 / 0.42);
background: rgb(255 246 216 / 0.14);
transform: translateY(-1px);
}
.start-button {
min-width: 180px;
padding: 0 30px;
text-transform: uppercase;
}
#app.options-open:not(.match-live) .start-button {
pointer-events: none;
visibility: hidden;
}
+303
View File
@@ -0,0 +1,303 @@
@media (max-width: 960px) {
body {
overflow: hidden;
}
#app {
--arena-gap: 0px;
--mobile-game-size: min(100vw, calc(100svh - var(--score-band-height)));
--mobile-kill-log-top: calc(var(--score-band-height) + var(--mobile-game-size) + 10px);
--mobile-options-button-width: 54px;
--mobile-options-gap: 8px;
--mobile-team-card-width: clamp(56px, calc((100vw - 120px) / 4), 72px);
--mobile-visitor-space: calc(104px + env(safe-area-inset-bottom));
--score-band-height: 132px;
--score-panel-left: 10px;
--score-panel-width: calc(100vw - 20px);
--score-rail-width: 0px;
}
#app.match-live .arena-shell {
place-items: start center;
}
#app.match-live #game {
width: var(--mobile-game-size);
height: var(--mobile-game-size);
margin-top: var(--score-band-height);
margin-left: 0;
}
.intro-stage {
padding: 20px;
}
.arena-logo {
font-size: clamp(3.8rem, 22vw, 7rem);
}
.fighter-entry {
width: 100vw;
padding: 22px;
}
#app.match-live .fighter-entry {
top: calc(10px + env(safe-area-inset-top));
right: 10px;
left: 10px;
width: auto;
max-height: calc(100svh - 20px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
gap: 10px;
padding: 12px;
}
#app.match-live.drawer-collapsed .fighter-entry {
top: calc(22px + env(safe-area-inset-top));
right: 10px;
left: auto;
width: auto;
}
#app.match-live .fighter-entry h2 {
font-size: clamp(1.45rem, 7vw, 1.8rem);
}
#app.match-live .fighter-entry textarea {
height: 112px;
min-height: 112px;
resize: none;
}
#app.match-live .fighter-entry fieldset {
gap: 7px;
padding: 9px;
}
#app.match-live .fighter-entry form {
gap: 10px;
}
#app.match-live .entry-copy {
gap: 4px;
}
#app.match-live .eyebrow {
font-size: 0.68rem;
}
#app.match-live label,
#app.match-live .spawn-placement-label {
font-size: 0.82rem;
}
#app.match-live input:not([type="range"]):not([type="radio"]),
#app.match-live textarea {
min-height: 40px;
padding-inline: 10px;
}
#app.match-live textarea {
padding-block: 9px;
}
#app.match-live .spawn-placement-option span {
min-height: 36px;
padding: 6px;
font-size: 0.76rem;
}
#app.match-live .match-actions {
gap: 8px;
}
#app.match-live .match-actions button {
min-height: 44px;
}
#app.match-live .drawer-toggle {
min-width: 116px;
}
#app.match-live.drawer-collapsed .drawer-toggle {
width: var(--mobile-options-button-width);
min-width: var(--mobile-options-button-width);
padding-inline: 6px;
font-size: 0;
}
#app.match-live.drawer-collapsed .drawer-toggle::before {
content: "옵션";
font-size: 0.78rem;
line-height: 1;
}
.battle-preview {
opacity: 0.62;
}
.preview-knight {
left: -8vw;
top: 52vh;
}
.preview-orc {
right: -9vw;
top: 49vh;
}
.preview-wizard {
left: 48vw;
top: 21vh;
}
.scoreboard {
align-items: flex-start;
top: 10px;
left: var(--score-panel-left);
width: var(--score-panel-width);
max-height: calc(var(--score-band-height) - 12px);
overflow-x: auto;
overflow-y: hidden;
padding: 9px min(148px, 38vw) 9px 9px;
scrollbar-color: rgb(238 185 73 / 0.38) transparent;
scrollbar-width: thin;
touch-action: pan-x;
}
#app.match-live.drawer-collapsed .scoreboard {
width: calc(
100vw - 20px - var(--mobile-options-button-width) - var(--mobile-options-gap)
);
padding-right: 9px;
}
.score-side {
display: grid;
grid-template-columns: none;
grid-auto-columns: var(--mobile-team-card-width);
grid-auto-flow: column;
grid-template-rows: repeat(2, 48px);
gap: 5px 5px;
}
.scoreboard::-webkit-scrollbar {
height: 4px;
}
.scoreboard::-webkit-scrollbar-track {
background: transparent;
}
.scoreboard::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgb(238 185 73 / 0.38);
}
.team-score {
width: auto;
min-height: 48px;
height: 48px;
gap: 3px;
padding: 5px 6px;
font-size: 0.66rem;
grid-template-rows: 1fr 1px auto;
align-content: center;
}
.team-score-count {
font-size: 0.74rem;
}
.team-score.is-focused {
box-shadow: inset 0 0 0 2px rgb(255 244 209 / 0.92);
}
.battle-notice {
top: calc(var(--score-band-height) + 8px);
right: 24px;
left: 24px;
width: auto;
padding-inline: 12px;
font-size: 0.76rem;
transform: translateY(-10px);
}
#app.match-live .battle-notice.is-visible {
transform: translateY(0);
}
.kill-log {
top: var(--mobile-kill-log-top);
bottom: auto;
left: 10px;
width: calc(100vw - 20px);
max-height: calc(100svh - var(--mobile-kill-log-top) - var(--mobile-visitor-space));
padding: 8px;
}
#app.match-live .victory-celebration {
padding:
var(--score-band-height)
14px
min(30svh, 230px);
}
.victory-banner {
width: min(calc(100vw - 48px), 520px);
min-height: 92px;
padding: 1rem 1.1rem;
font-size: clamp(1.35rem, 7vw, 2rem);
}
.match-status {
bottom: 10px;
width: calc(100vw - 20px);
}
.arena-meta {
right: 10px;
bottom: calc(10px + env(safe-area-inset-bottom));
z-index: 10;
gap: 8px;
opacity: 1;
pointer-events: auto;
}
.visitor-count {
font-size: 0.68rem;
}
.about-button {
min-width: 68px;
min-height: 26px;
font-size: 0.68rem;
}
.about-backdrop {
align-items: end;
padding: 12px;
}
.about-dialog {
width: 100%;
max-height: calc(100svh - 24px);
}
.about-header {
padding: 18px 18px 12px;
}
.about-tabs {
padding: 0 18px 12px;
}
.about-panel {
padding: 16px 18px 22px;
}
.about-field-row {
grid-template-columns: 78px minmax(0, 1fr);
min-height: 48px;
gap: 10px;
}
}
+618
View File
@@ -0,0 +1,618 @@
.start-button,
form button[type="submit"],
.pause-button,
.restart-button {
min-height: 52px;
border-radius: 8px;
background: linear-gradient(180deg, #e56443, #b93c2f);
color: #fff7df;
font-weight: 900;
box-shadow:
0 18px 44px rgb(0 0 0 / 0.36),
inset 0 1px 0 rgb(255 255 255 / 0.2);
transition:
background 180ms ease,
transform 180ms ease,
box-shadow 180ms ease;
}
.start-button:hover,
form button[type="submit"]:hover,
.pause-button:hover,
.restart-button:hover {
background: linear-gradient(180deg, #f0754f, #c84636);
transform: translateY(-1px);
box-shadow:
0 22px 52px rgb(0 0 0 / 0.42),
inset 0 1px 0 rgb(255 255 255 / 0.24);
}
.pause-button,
.restart-button {
display: none;
border: 1px solid rgb(238 185 73 / 0.3);
background: rgb(255 246 216 / 0.08);
color: #ffe8b4;
}
.pause-button:hover,
.restart-button:hover {
background: rgb(255 246 216 / 0.14);
}
#app.match-live .pause-button,
#app.match-live .restart-button {
display: block;
}
#app.match-live .match-actions {
grid-template-columns: 1fr 1fr;
}
#app.match-live .match-actions button[type="submit"] {
grid-column: 1 / -1;
}
#app.match-paused .pause-button {
background: linear-gradient(180deg, #e3b24f, #9a6c24);
color: #120f08;
}
#app.match-ended .pause-button {
display: none;
}
.drawer-scrim {
position: fixed;
inset: 0;
z-index: 6;
background: rgb(4 5 4 / 0.42);
opacity: 0;
pointer-events: none;
transition: opacity 320ms ease;
}
#app.options-open .drawer-scrim {
opacity: 1;
pointer-events: auto;
}
#app.match-live .drawer-scrim {
opacity: 0;
pointer-events: none;
}
.drawer-toggle {
display: none;
min-height: 40px;
border: 1px solid rgb(238 185 73 / 0.28);
border-radius: 8px;
padding: 0 12px;
background: rgb(12 15 11 / 0.84);
color: #ffe8b4;
font-size: 0.82rem;
font-weight: 900;
box-shadow: 0 16px 38px rgb(0 0 0 / 0.36);
transition:
background 180ms ease,
transform 180ms ease;
backdrop-filter: blur(10px);
}
.drawer-toggle:hover {
background: rgb(255 246 216 / 0.14);
transform: translateY(-1px);
}
#app.match-live .drawer-toggle {
display: block;
}
.fighter-entry {
position: fixed;
top: 0;
right: 0;
z-index: 7;
display: grid;
align-content: start;
gap: 24px;
width: var(--drawer-width);
height: 100vh;
overflow-y: auto;
border-left: 1px solid rgb(239 199 103 / 0.22);
padding: clamp(22px, 4vw, 34px);
background:
linear-gradient(180deg, rgb(29 33 22 / 0.94), rgb(13 16 12 / 0.96)),
#11140f;
box-shadow: -28px 0 80px rgb(0 0 0 / 0.52);
transform: translateX(104%);
transition:
opacity 260ms ease,
transform 520ms cubic-bezier(0.2, 0.8, 0.2, 1);
backdrop-filter: blur(16px);
}
#app.options-open .fighter-entry {
transform: translateX(0);
}
#app.match-live .fighter-entry {
top: 24px;
right: 24px;
height: auto;
max-height: calc(100vh - 48px);
gap: 16px;
border: 1px solid rgb(239 199 103 / 0.22);
border-radius: 8px;
padding: 20px;
transform: translateX(0);
}
#app.match-live.drawer-collapsed .fighter-entry {
width: auto;
min-width: 0;
overflow: visible;
border-color: transparent;
padding: 0;
background: transparent;
box-shadow: none;
opacity: 1;
pointer-events: auto;
transform: translateX(0);
}
#app.match-live .drawer-close {
display: none;
}
#app.match-live .fighter-entry h2 {
font-size: 2rem;
}
#app.match-live .fighter-entry textarea {
min-height: 190px;
}
#app.match-live .fighter-entry fieldset {
padding: 12px;
}
#app.match-live.drawer-collapsed .entry-copy,
#app.match-live.drawer-collapsed .fighter-entry form {
display: none;
}
#app.match-live.drawer-collapsed .drawer-header {
justify-content: end;
}
.drawer-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
}
.drawer-header-controls {
display: flex;
align-items: center;
gap: 8px;
}
.entry-copy {
display: grid;
gap: 8px;
}
.eyebrow {
margin: 0;
color: #e3b24f;
font-size: 0.78rem;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
h2 {
margin: 0;
color: #fff3d2;
font-size: clamp(1.7rem, 4vw, 2.5rem);
line-height: 1.05;
letter-spacing: 0;
}
.drawer-close {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: 0 0 auto;
border: 1px solid rgb(238 185 73 / 0.22);
border-radius: 8px;
background: rgb(255 246 216 / 0.08);
color: #f8deb0;
font-weight: 900;
}
.drawer-close:hover {
background: rgb(255 246 216 / 0.14);
}
.about-backdrop {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: clamp(16px, 4vw, 34px);
background: rgb(3 5 4 / 0.66);
backdrop-filter: blur(8px);
}
.about-backdrop[hidden] {
display: none;
}
.about-dialog {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
width: min(560px, calc(100vw - 32px));
max-height: min(760px, calc(100svh - 32px));
overflow: hidden;
border: 1px solid rgb(239 199 103 / 0.28);
border-radius: 8px;
background:
linear-gradient(180deg, rgb(29 33 22 / 0.98), rgb(10 13 9 / 0.98)),
#11140f;
box-shadow:
0 24px 100px rgb(0 0 0 / 0.62),
inset 0 1px 0 rgb(255 255 255 / 0.06);
outline: none;
}
.about-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
padding: clamp(20px, 4vw, 28px) clamp(20px, 4vw, 30px) 14px;
}
.about-close {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: 0 0 auto;
border: 1px solid rgb(238 185 73 / 0.22);
border-radius: 8px;
background: rgb(255 246 216 / 0.08);
color: #f8deb0;
font-weight: 900;
}
.about-close:hover {
background: rgb(255 246 216 / 0.14);
}
.about-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
padding: 0 clamp(20px, 4vw, 30px) 14px;
border-bottom: 1px solid rgb(238 185 73 / 0.16);
}
.about-tab {
min-width: 0;
min-height: 42px;
border: 1px solid transparent;
border-radius: 6px;
padding: 8px 10px;
background: rgb(255 246 216 / 0.06);
color: #ead8ad;
font-size: 0.86rem;
font-weight: 900;
line-height: 1.2;
}
.about-tab[aria-selected="true"] {
border-color: rgb(238 185 73 / 0.36);
background: #323822;
color: #fff7df;
}
.about-panel {
min-height: 0;
overflow: auto;
padding: clamp(18px, 4vw, 26px) clamp(20px, 4vw, 30px) clamp(22px, 5vw, 34px);
}
.about-fields {
display: grid;
gap: 0;
margin: 0;
}
.about-field-row {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
align-items: center;
gap: 14px;
min-height: 50px;
border-bottom: 1px solid rgb(238 185 73 / 0.14);
}
.about-field-row:first-child {
border-top: 1px solid rgb(238 185 73 / 0.14);
}
.about-field-row dt {
color: #e3b24f;
font-size: 0.78rem;
font-weight: 950;
text-transform: uppercase;
}
.about-field-row dd {
min-width: 0;
margin: 0;
color: #fff7df;
font-weight: 800;
overflow-wrap: anywhere;
}
.about-field-row a,
.about-markdown a {
color: #85dcc7;
text-decoration-color: rgb(133 220 199 / 0.42);
text-underline-offset: 3px;
}
.about-markdown {
display: grid;
gap: 12px;
color: #ead8ad;
font-size: 0.92rem;
font-weight: 600;
line-height: 1.56;
}
.about-markdown :is(h3, h4, h5, h6, p, ul, blockquote) {
margin: 0;
}
.about-markdown h3,
.about-markdown h4,
.about-markdown h5,
.about-markdown h6 {
color: #fff3d2;
font-size: 1rem;
line-height: 1.3;
}
.about-markdown blockquote {
border-left: 3px solid rgb(238 185 73 / 0.36);
padding: 4px 0 4px 16px;
color: #c4b693;
font-style: italic;
}
.about-markdown hr {
margin: 8px 0;
border: 0;
border-top: 1px solid rgb(238 185 73 / 0.16);
}
.about-markdown code {
border: 1px solid rgb(238 185 73 / 0.14);
border-radius: 4px;
padding: 2px 5px;
background: rgb(255 246 216 / 0.08);
color: #f1c761;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.88em;
}
.about-markdown li {
line-height: 1.5;
}
.about-markdown li strong {
color: #fff3d2;
}
.about-markdown ul {
display: grid;
gap: 8px;
padding-left: 1.2rem;
}
.about-empty {
color: #bfae83;
}
form {
display: grid;
gap: 16px;
}
.match-actions {
display: grid;
gap: 10px;
}
fieldset {
display: grid;
gap: 10px;
min-width: 0;
margin: 0;
border: 1px solid rgb(238 185 73 / 0.22);
border-radius: 8px;
padding: 14px;
background: rgb(5 7 5 / 0.26);
}
legend {
padding: 0 6px;
color: #e3b24f;
font-size: 0.78rem;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
label {
color: #ead8ad;
font-size: 0.92rem;
}
input:not([type="range"]):not([type="radio"]),
textarea {
min-height: 48px;
border: 1px solid rgb(238 185 73 / 0.28);
border-radius: 8px;
padding: 0 14px;
background: #232719;
color: #fff7df;
outline: none;
}
textarea {
min-height: 258px;
resize: vertical;
padding-block: 12px;
line-height: 1.45;
}
textarea[aria-invalid="true"] {
border-color: #d9a628;
box-shadow: 0 0 0 1px rgb(217 166 40 / 0.34);
}
.player-names-warning {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 4px 9px;
margin: 0;
border: 1px solid rgb(219 168 45 / 0.72);
border-radius: 8px;
padding: 10px;
background: linear-gradient(135deg, rgb(88 67 17 / 0.7), rgb(43 37 19 / 0.92));
box-shadow: inset 3px 0 0 #dba82d;
line-height: 1.35;
}
.player-names-warning[hidden] {
display: none;
}
.player-names-warning__badge {
grid-row: span 2;
align-self: start;
color: #f4bd37;
font-size: 1.2rem;
font-weight: 900;
line-height: 1;
}
.player-names-warning__title {
color: #ffd565;
font-size: 0.92rem;
font-weight: 900;
}
.player-names-warning__detail {
grid-column: 2;
margin: 0;
color: #f1dfaa;
font-size: 0.82rem;
font-weight: 500;
}
.player-names-warning__count {
color: #ffd04f;
font-size: 1rem;
font-weight: 900;
}
.player-names-warning__limit {
color: #f7e0a0;
font-size: 0.94rem;
font-weight: 900;
}
.player-names-warning__reason {
grid-column: 1 / -1;
margin: 5px 0 0;
border-top: 1px solid rgb(219 168 45 / 0.25);
padding-top: 7px;
color: #d7c489;
font-size: 0.76rem;
}
input[type="range"] {
width: 100%;
accent-color: #e3b24f;
}
.spawn-placement-field {
display: grid;
gap: 8px;
}
.spawn-placement-label {
color: #ead8ad;
font-size: 0.92rem;
}
.spawn-placement-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
min-width: 0;
border: 1px solid rgb(238 185 73 / 0.2);
border-radius: 8px;
padding: 4px;
background: #1d2116;
}
.spawn-placement-option {
position: relative;
min-width: 0;
}
.spawn-placement-option input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.spawn-placement-option span {
display: grid;
min-height: 44px;
place-items: center;
border: 1px solid transparent;
border-radius: 6px;
padding: 8px;
color: #ead8ad;
text-align: center;
font-size: 0.86rem;
font-weight: 900;
line-height: 1.25;
cursor: pointer;
}
.spawn-placement-option input:checked + span {
border-color: rgb(238 185 73 / 0.36);
background: #323822;
color: #fff7df;
}
.spawn-placement-option input:focus-visible + span {
outline: 2px solid #f1c761;
outline-offset: -2px;
}
+379
View File
@@ -0,0 +1,379 @@
const ABOUT_ENDPOINT = "/api/about";
const DEFAULT_ABOUT_CONTENT = {
developer: {
alias: "horoli",
email: "sunha321@gmail.com",
github: "https://github.com/Horoli",
},
privacyPolicy: {
markdown: "",
},
};
export function createAboutDialog() {
const openButton = document.querySelector("#about-button");
const backdrop = document.querySelector("#about-dialog");
const dialog = backdrop?.querySelector("[data-about-dialog]");
const closeButton = backdrop?.querySelector("[data-about-close]");
const tabs = [...(backdrop?.querySelectorAll("[data-about-tab]") ?? [])];
const panels = [...(backdrop?.querySelectorAll("[data-about-panel]") ?? [])];
const privacyContent = backdrop?.querySelector("#privacy-policy-content");
if (!openButton || !backdrop || !dialog || !closeButton || tabs.length === 0) {
return null;
}
let lastFocusedElement = null;
let loadPromise = null;
let loaded = false;
renderAboutContent(DEFAULT_ABOUT_CONTENT);
openButton.addEventListener("click", () => {
openDialog();
});
closeButton.addEventListener("click", () => {
closeDialog();
});
backdrop.addEventListener("click", (event) => {
if (event.target === backdrop) {
closeDialog();
}
});
dialog.addEventListener("keydown", trapFocus);
window.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape" || !isOpen()) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
closeDialog();
},
true,
);
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
selectTab(tab.dataset.aboutTab, { focus: true });
});
});
function openDialog() {
lastFocusedElement = document.activeElement;
backdrop.hidden = false;
document.body.classList.add("about-dialog-open");
openButton.setAttribute("aria-expanded", "true");
selectTab("developer");
window.requestAnimationFrame(() => {
dialog.focus();
});
loadAboutContent();
}
function closeDialog() {
backdrop.hidden = true;
document.body.classList.remove("about-dialog-open");
openButton.setAttribute("aria-expanded", "false");
if (lastFocusedElement instanceof HTMLElement) {
lastFocusedElement.focus();
}
}
function isOpen() {
return !backdrop.hidden;
}
function selectTab(tabName, { focus = false } = {}) {
const nextTabName = tabName || "developer";
tabs.forEach((tab) => {
const selected = tab.dataset.aboutTab === nextTabName;
tab.setAttribute("aria-selected", String(selected));
tab.tabIndex = selected ? 0 : -1;
if (selected && focus) {
tab.focus();
}
});
panels.forEach((panel) => {
panel.hidden = panel.dataset.aboutPanel !== nextTabName;
});
}
async function loadAboutContent() {
if (loaded) {
return;
}
if (!loadPromise) {
loadPromise = fetchAboutContent()
.then((content) => {
renderAboutContent(content);
loaded = true;
})
.catch((error) => {
console.warn(error);
renderMarkdown(
privacyContent,
"",
"개인정보처리방침을 불러오지 못했습니다.",
);
})
.finally(() => {
loadPromise = null;
});
}
await loadPromise;
}
function renderAboutContent(content) {
const normalizedContent = normalizeAboutContent(content);
setField("alias", normalizedContent.developer.alias);
setField("email", normalizedContent.developer.email);
setLinkField("github", normalizedContent.developer.github);
renderMarkdown(privacyContent, normalizedContent.privacyPolicy.markdown);
}
return {
close: closeDialog,
isOpen,
open: openDialog,
};
}
async function fetchAboutContent() {
const response = await fetch(ABOUT_ENDPOINT, {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`About content fetch failed: ${response.status}`);
}
return response.json();
}
function setField(fieldName, value) {
const field = document.querySelector(`[data-about-field="${fieldName}"]`);
if (field) {
field.textContent = value || "-";
}
}
function setLinkField(fieldName, value) {
const field = document.querySelector(`[data-about-field="${fieldName}"]`);
if (!field) {
return;
}
field.textContent = "";
if (!value) {
field.textContent = "-";
return;
}
const link = document.createElement("a");
link.href = value;
link.rel = "noreferrer";
link.target = "_blank";
link.textContent = value;
field.appendChild(link);
}
function normalizeAboutContent(content = {}) {
return {
developer: {
alias: stringValue(content?.developer?.alias, DEFAULT_ABOUT_CONTENT.developer.alias),
email: stringValue(content?.developer?.email, DEFAULT_ABOUT_CONTENT.developer.email),
github: stringValue(content?.developer?.github, DEFAULT_ABOUT_CONTENT.developer.github),
},
privacyPolicy: {
markdown: stringValue(
content?.privacyPolicy?.markdown,
DEFAULT_ABOUT_CONTENT.privacyPolicy.markdown,
),
},
};
}
function renderMarkdown(container, markdown, emptyMessage = "개인정보처리방침이 아직 작성되지 않았습니다.") {
if (!container) {
return;
}
container.textContent = "";
const text = String(markdown || "").trim();
if (!text) {
const message = document.createElement("p");
message.className = "about-empty";
message.textContent = emptyMessage;
container.appendChild(message);
return;
}
const lines = text.replace(/\r\n?/g, "\n").split("\n");
let paragraphLines = [];
let list = null;
let blockquote = null;
const flushParagraph = () => {
if (paragraphLines.length === 0) {
return;
}
const paragraph = document.createElement("p");
appendInlineMarkdown(paragraph, paragraphLines.join(" "));
(blockquote || container).appendChild(paragraph);
paragraphLines = [];
};
const closeList = () => {
list = null;
};
const closeBlockquote = () => {
blockquote = null;
};
lines.forEach((line) => {
const trimmed = line.trim();
// Horizontal Rule
if (/^(?:---|[*]{3}|_{3})$/.test(trimmed)) {
flushParagraph();
closeList();
closeBlockquote();
container.appendChild(document.createElement("hr"));
return;
}
// Headings
const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
if (heading) {
flushParagraph();
closeList();
closeBlockquote();
const level = Math.min(heading[1].length + 2, 6);
const headingNode = document.createElement(`h${level}`);
appendInlineMarkdown(headingNode, heading[2]);
container.appendChild(headingNode);
return;
}
// Blockquote
const bqMatch = /^>\s?(.*)$/.exec(line);
if (bqMatch) {
flushParagraph();
closeList();
if (!blockquote) {
blockquote = document.createElement("blockquote");
container.appendChild(blockquote);
}
const content = bqMatch[1].trim();
if (content) {
const p = document.createElement("p");
appendInlineMarkdown(p, content);
blockquote.appendChild(p);
}
return;
}
// List Item
const listItem = /^[-*]\s+(.+)$/.exec(trimmed);
if (listItem) {
flushParagraph();
closeBlockquote();
if (!list) {
list = document.createElement("ul");
container.appendChild(list);
}
const item = document.createElement("li");
appendInlineMarkdown(item, listItem[1]);
list.appendChild(item);
return;
}
if (!trimmed) {
flushParagraph();
closeList();
closeBlockquote();
} else {
paragraphLines.push(trimmed);
}
});
flushParagraph();
}
function appendInlineMarkdown(parent, text) {
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const html = escaped
.replace(/\*\*\*([^*]+)\*\*\*/g, "<strong><em>$1</em></strong>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/_([^_]+)_/g, "<em>$1</em>")
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(
/\[([^\]]+)\]\((https?:\/\/[^)\s]+|mailto:[^)]+)\)/g,
'<a href="$2" rel="noreferrer" target="_blank">$1</a>',
);
parent.innerHTML = html;
}
function trapFocus(event) {
if (event.key !== "Tab") {
return;
}
const focusableElements = [
...event.currentTarget.querySelectorAll(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
].filter((element) => element.offsetParent !== null);
if (focusableElements.length === 0) {
event.preventDefault();
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
function stringValue(...values) {
const value = values.find((candidate) => typeof candidate === "string");
return value?.trim() ?? "";
}
+99
View File
@@ -0,0 +1,99 @@
const KILL_LOG_LIMIT = 30;
export function resetKillLog(nodes) {
const { logNode, listNode } = nodes;
if (listNode) {
listNode.replaceChildren();
}
logNode?.classList.remove("has-entries");
logNode?.setAttribute("aria-hidden", "true");
}
export function appendKillLog(nodes, winner, defender) {
const { logNode, listNode } = nodes;
if (!logNode || !listNode) {
return;
}
const item = document.createElement("li");
const killer = killLogFighterParts(winner);
const victim = killLogFighterParts(defender);
const action = document.createElement("span");
const weapon = document.createElement("span");
const actionText = document.createElement("span");
item.className = "kill-log-item";
item.style.setProperty("--killer-color", winner.team?.color ?? "#e3b24f");
item.style.setProperty("--victim-color", defender.team?.color ?? "#e3b24f");
item.setAttribute(
"aria-label",
`${killer.teamLabel} ${killer.memberLabel} 처치 ${victim.teamLabel} ${victim.memberLabel}`,
);
action.className = "kill-log-action";
weapon.className = "kill-log-weapon";
weapon.setAttribute("aria-hidden", "true");
actionText.className = "kill-log-action-text";
actionText.textContent = "처치";
action.append(weapon, actionText);
item.append(
createKillLogFighterNode(killer, "killer"),
action,
createKillLogFighterNode(victim, "victim"),
);
listNode.prepend(item);
while (listNode.children.length > KILL_LOG_LIMIT) {
listNode.lastElementChild?.remove();
}
logNode.classList.add("has-entries");
logNode.setAttribute("aria-hidden", "false");
}
function createKillLogFighterNode(fighterParts, role) {
const container = document.createElement("span");
const avatar = document.createElement("span");
const copy = document.createElement("span");
const team = document.createElement("span");
const member = document.createElement("span");
container.className = `kill-log-fighter ${role}`;
avatar.className = "kill-log-avatar";
if (fighterParts.avatarUrl) {
avatar.style.backgroundImage = `url("${fighterParts.avatarUrl}")`;
}
avatar.setAttribute("aria-hidden", "true");
copy.className = "kill-log-copy";
team.className = "kill-log-team";
team.textContent = fighterParts.teamLabel;
member.className = "kill-log-member";
member.textContent = fighterParts.memberLabel;
copy.append(team, member);
container.append(avatar, copy);
return container;
}
function killLogFighterParts(fighter) {
return {
teamLabel: fighter?.team?.label ?? "Unknown",
memberLabel: fighter?.skin?.key ?? fighter?.skin?.label ?? fighter?.fighterName ?? "fighter",
avatarUrl: fighterSkinIdleUrl(fighter?.skin),
};
}
function fighterSkinIdleUrl(skin) {
const idleFile = skin?.animations?.idle?.file;
if (!skin?.assetRoot || !idleFile) {
return "";
}
return `${skin.assetRoot}/${idleFile}`;
}
+67
View File
@@ -0,0 +1,67 @@
export function updateScoreboard(
containerLeft,
containerRight,
teams,
fighters,
{ selectedFighterTeamId = null, onTeamClick = () => {} } = {},
) {
if (!containerLeft || !containerRight) {
return;
}
const currentTeamElements = [...containerLeft.children];
const teamsChanged =
currentTeamElements.length !== teams.length ||
teams.some((team, index) => currentTeamElements[index]?.dataset.teamId !== String(team.id));
if (teamsChanged) {
containerLeft.replaceChildren(...teams.map((team) => createTeamElement(team.id)));
}
if (containerRight.childElementCount > 0) {
containerRight.replaceChildren();
}
teams.forEach((team, index) => {
const teamEl = containerLeft.children[index];
const aliveCount = fighters.filter(
(fighter) => fighter.team.id === team.id && !fighter.isDead,
).length;
teamEl.disabled = aliveCount === 0;
teamEl.setAttribute("aria-label", `${team.label} 생존 캐릭터 무작위 시점 고정`);
teamEl.style.setProperty("--team-color", team.color);
teamEl.style.backgroundColor = `${team.color}33`;
teamEl.style.borderLeft = `4px solid ${team.color}`;
teamEl.classList.toggle("is-focused", selectedFighterTeamId === team.id);
const labelEl = teamEl.querySelector(".team-score-name");
labelEl.textContent = team.label;
const countEl = teamEl.querySelector(".team-score-count");
countEl.textContent = `${aliveCount}명`;
teamEl.onclick = () => {
onTeamClick(team.id);
};
});
}
function createTeamElement(teamId) {
const teamEl = document.createElement("button");
teamEl.className = "team-score";
teamEl.type = "button";
teamEl.dataset.teamId = String(teamId);
const labelEl = document.createElement("span");
labelEl.className = "team-score-name";
const ruleEl = document.createElement("span");
ruleEl.className = "team-score-rule";
const countEl = document.createElement("span");
countEl.className = "team-score-count";
teamEl.append(labelEl, ruleEl, countEl);
return teamEl;
}
+105
View File
@@ -0,0 +1,105 @@
import { UI } from "../constants.js";
const SPECIES_KEYS = ["human", "orc", "skeleton", "slime", "wolf", "bear"];
const SPECIES_LABELS = {
bear: "곰",
human: "인간",
orc: "오크",
skeleton: "해골",
slime: "슬라임",
wolf: "늑대",
};
const SPECIES_SUBJECT_PARTICLES = {
bear: "이",
human: "이",
orc: "가",
skeleton: "이",
slime: "이",
wolf: "가",
};
const DEATH_NOTICE_TEMPLATES = [
"오늘만 해도 {species}{particle} 전투 중에 {count}명 사망했습니다.",
"{species}{particle} 오늘 {count}명째 경기장 바닥과 친해졌습니다.",
"오늘의 부고: {species} {count}명. 경기장은 너무 성실합니다.",
"{species}{particle} 전투 중 {count}명 쓰러졌습니다. 관중석은 침착한 척하는 중입니다.",
];
const SYSTEM_TIP_TEMPLATES = [
"경보: 화염 메테오는 낙하 지점 5x5 영역에 강력한 폭발 피해를 입힙니다!",
"주의: 냉기 메테오는 피해와 함께 2초간 동결 및 냉각을 유발합니다.",
"팁: 근접 캐릭터는 20% 확률로 치명타를 터뜨려 적을 즉사시킵니다.",
"성장: 적 처치 시 체력을 30% 회복하며, 크기와 속도가 최대 5배까지 커집니다.",
];
export function createDeathCounts() {
return SPECIES_KEYS.reduce((counts, species) => {
counts[species] = 0;
return counts;
}, {});
}
export function normalizeDeathCounts(value = {}) {
return SPECIES_KEYS.reduce((counts, species) => {
counts[species] = Math.max(0, Math.round(Number(value?.[species]) || 0));
return counts;
}, {});
}
export function addDeathCounts(baseCounts, matchCounts) {
return SPECIES_KEYS.reduce((counts, species) => {
counts[species] =
(baseCounts?.[species] ?? 0) + (matchCounts?.[species] ?? 0);
return counts;
}, {});
}
export function normalizeSpecies(value) {
return SPECIES_KEYS.includes(value) ? value : "human";
}
export function createDeathNoticeMessage(deathsBySpecies, seed = 0) {
// 3번에 한 번꼴로 시스템 팁 출력
if (seed % 3 === 0) {
return SYSTEM_TIP_TEMPLATES[
Math.floor(seed / 3) % SYSTEM_TIP_TEMPLATES.length
];
}
const topSpecies = SPECIES_KEYS.map((species) => ({
species,
count: deathsBySpecies?.[species] ?? 0,
})).sort((left, right) => right.count - left.count)[0];
if (!topSpecies || topSpecies.count === 0) {
return "오늘 사망자 집계는 아직 0명입니다. 이 평화가 얼마나 버틸까요?";
}
const template =
DEATH_NOTICE_TEMPLATES[
(topSpecies.count + seed) % DEATH_NOTICE_TEMPLATES.length
];
return template
.replace("{species}", SPECIES_LABELS[topSpecies.species])
.replace("{particle}", SPECIES_SUBJECT_PARTICLES[topSpecies.species])
.replace("{count}", topSpecies.count.toLocaleString("ko-KR"));
}
export function showBattleDeathNotice(noticeNode, message) {
if (!noticeNode) {
return;
}
noticeNode.textContent = message;
noticeNode.classList.add("is-visible");
noticeNode.setAttribute("aria-hidden", "false");
}
export function clearBattleNotice(noticeNode) {
if (!noticeNode) {
return;
}
noticeNode.classList.remove("is-visible");
noticeNode.setAttribute("aria-hidden", "true");
}
+45
View File
@@ -0,0 +1,45 @@
const DAILY_METRIC_ENDPOINTS = {
donationClicked: "/api/daily-metrics/donation-clicked",
matchFinished: "/api/daily-metrics/match-finished",
matchStarted: "/api/daily-metrics/match-started",
};
export function trackMatchStart() {
return postDailyMetric("matchStarted");
}
export function trackMatchFinish() {
return postDailyMetric("matchFinished");
}
export function trackDonationClick() {
return postDailyMetric("donationClicked");
}
async function postDailyMetric(type) {
const endpoint = DAILY_METRIC_ENDPOINTS[type];
if (!endpoint) {
return null;
}
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: "{}",
});
if (!response.ok) {
throw new Error(`Daily metric update failed: ${response.status}`);
}
return response.json();
} catch (error) {
console.warn(error);
return null;
}
}
+30
View File
@@ -0,0 +1,30 @@
export async function fetchTodayDeathStats() {
const response = await fetch("/api/death-stats/today", {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`Death stats fetch failed: ${response.status}`);
}
return response.json();
}
export async function addTodayDeathStats(deathStats) {
const response = await fetch("/api/death-stats/today", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(deathStats),
});
if (!response.ok) {
throw new Error(`Death stats update failed: ${response.status}`);
}
return response.json();
}
+117 -11
View File
@@ -1,20 +1,54 @@
import { NICKNAME_LENGTH } from "../constants.js"; import { FIGHTER, SPAWN } from "../constants.js";
const STORAGE_KEYS = {
names: "arena.match.playerNames",
spawnPlacement: "arena.match.spawnPlacement",
};
export function createMatchForm() { export function createMatchForm() {
const form = getElement("#fighter-form"); const form = getElement("#fighter-form");
const namesInput = getElement("#player-names"); const namesInput = getElement("#player-names");
const namesWarningNode = getElement("#player-names-warning");
const namesWarningTitleNode = getElement("[data-player-names-warning-title]");
const namesWarningCountNode = getElement("[data-player-names-warning-count]");
const namesWarningLimitNode = getElement("[data-player-names-warning-limit]");
const namesWarningReasonNode = getElement("[data-player-names-warning-reason]");
const appNode = document.querySelector("#app");
const statusNode = document.querySelector("#match-status"); const statusNode = document.querySelector("#match-status");
const teamSizeInput = getElement("#team-size"); const statusTextNodes = document.querySelectorAll("[data-status-text]");
const teamSizeOutput = getElement("#team-size-value"); const spawnPlacementInputs = getElements('input[name="spawnPlacement"]');
const setPlayerNamesWarning = (warning = null) => {
const hasWarning = Boolean(warning);
if (!hasWarning) {
namesWarningNode.hidden = true;
namesInput.removeAttribute("aria-invalid");
return;
}
namesWarningTitleNode.textContent = warning.title;
namesWarningCountNode.textContent = warning.fighterCount.toLocaleString("ko-KR");
namesWarningLimitNode.textContent = warning.maxFighterCount.toLocaleString("ko-KR");
namesWarningReasonNode.textContent = warning.reason ?? "";
namesWarningReasonNode.hidden = !warning.reason;
namesWarningNode.hidden = false;
namesInput.setAttribute("aria-invalid", "true");
};
const readMatchConfig = () => ({ const readMatchConfig = () => ({
names: nicknameValues(namesInput.value), names: nicknameValues(namesInput.value),
teamSize: Number(teamSizeInput.value), spawnPlacement: selectedSpawnPlacement(spawnPlacementInputs),
}); });
syncTeamSizeOutput(teamSizeInput, teamSizeOutput); restoreSavedMatchSettings(namesInput, spawnPlacementInputs);
teamSizeInput.addEventListener("input", () => { namesInput.addEventListener("input", () => {
syncTeamSizeOutput(teamSizeInput, teamSizeOutput); setPlayerNamesWarning();
saveMatchSettings(namesInput, spawnPlacementInputs);
});
spawnPlacementInputs.forEach((input) => {
input.addEventListener("change", () => {
saveMatchSettings(namesInput, spawnPlacementInputs);
});
}); });
return { return {
@@ -25,10 +59,18 @@ export function createMatchForm() {
}); });
}, },
readMatchConfig, readMatchConfig,
setPlayerNamesWarning,
setStatus(message) { setStatus(message) {
if (statusNode) { if (statusNode) {
statusNode.textContent = message; statusNode.setAttribute("aria-hidden", "false");
statusNode.title = message;
} }
statusTextNodes.forEach((node) => {
node.textContent = message;
});
appNode?.classList.add("status-active");
}, },
}; };
} }
@@ -43,13 +85,77 @@ function getElement(selector) {
return element; return element;
} }
function getElements(selector) {
const elements = [...document.querySelectorAll(selector)];
if (elements.length === 0) {
throw new Error(`Missing required elements: ${selector}`);
}
return elements;
}
function nicknameValues(value) { function nicknameValues(value) {
return value return value
.split(/\r?\n|,/) .split(/\r?\n|,/)
.map((name) => name.trim().slice(0, NICKNAME_LENGTH)) .map((name) => name.trim().slice(0, FIGHTER.NICKNAME_LENGTH))
.filter(Boolean); .filter(Boolean);
} }
function syncTeamSizeOutput(input, output) { function restoreSavedMatchSettings(namesInput, spawnPlacementInputs) {
output.textContent = input.value; const storage = getLocalStorage();
if (!storage) {
return;
}
try {
const savedNames = storage.getItem(STORAGE_KEYS.names);
const savedSpawnPlacement = storage.getItem(STORAGE_KEYS.spawnPlacement);
if (savedNames !== null) {
namesInput.value = savedNames;
}
setSpawnPlacement(spawnPlacementInputs, savedSpawnPlacement);
} catch {
// Storage may be unavailable in private or restricted browser contexts.
}
}
function saveMatchSettings(namesInput, spawnPlacementInputs) {
const storage = getLocalStorage();
if (!storage) {
return;
}
try {
storage.setItem(STORAGE_KEYS.names, namesInput.value);
storage.setItem(STORAGE_KEYS.spawnPlacement, selectedSpawnPlacement(spawnPlacementInputs));
} catch {
// Ignore storage failures so the match form remains usable.
}
}
function selectedSpawnPlacement(inputs) {
return inputs.find((input) => input.checked)?.value ?? SPAWN.DEFAULT_PLACEMENT;
}
function setSpawnPlacement(inputs, value) {
const savedInput = inputs.find((input) => input.value === value);
const defaultInput = inputs.find((input) => input.value === SPAWN.DEFAULT_PLACEMENT);
const nextInput = savedInput ?? defaultInput;
if (nextInput) {
nextInput.checked = true;
}
}
function getLocalStorage() {
try {
return window.localStorage;
} catch {
return null;
}
} }
+168
View File
@@ -0,0 +1,168 @@
const VICTORY_CONFETTI_COLORS = ["#ffe8a8", "#f7b842", "#f36f45", "#85dcc7", "#f7f2df"];
const VICTORY_CONFETTI_COUNT = 40;
const VICTORY_CELEBRATION_EXIT_MS = 260;
const VICTORY_CELEBRATION_VISIBLE_MS = 5200;
const VICTORY_FANFARE_NOTES = [
{ duration: 0.16, frequency: 392, offset: 0, volume: 0.065 },
{ duration: 0.16, frequency: 523.25, offset: 0, volume: 0.052 },
{ duration: 0.18, frequency: 493.88, offset: 0.13, volume: 0.064 },
{ duration: 0.18, frequency: 659.25, offset: 0.13, volume: 0.05 },
{ duration: 0.2, frequency: 587.33, offset: 0.28, volume: 0.062 },
{ duration: 0.2, frequency: 783.99, offset: 0.28, volume: 0.048 },
{ duration: 0.5, frequency: 523.25, offset: 0.46, volume: 0.064 },
{ duration: 0.5, frequency: 659.25, offset: 0.46, volume: 0.052 },
{ duration: 0.5, frequency: 783.99, offset: 0.46, volume: 0.043 },
];
export function createVictoryConfettiPiece(index) {
const piece = document.createElement("i");
const angle = (Math.PI * 2 * index) / VICTORY_CONFETTI_COUNT + (index % 4) * 0.11;
const distance = 170 + (index % 8) * 26;
const x = Math.round(Math.cos(angle) * distance);
const y = Math.round(Math.sin(angle) * distance * 0.78);
piece.className = "victory-confetti-piece";
piece.style.setProperty("--confetti-color", VICTORY_CONFETTI_COLORS[index % VICTORY_CONFETTI_COLORS.length]);
piece.style.setProperty("--confetti-delay", `${(index % 10) * 18}ms`);
piece.style.setProperty("--confetti-duration", `${880 + (index % 6) * 90}ms`);
piece.style.setProperty("--confetti-spin", `${180 + (index % 9) * 58}deg`);
piece.style.setProperty("--confetti-x", `${x}px`);
piece.style.setProperty("--confetti-y", `${y}px`);
piece.style.setProperty("--confetti-tilt", `${(index % 7) * 19 - 54}deg`);
return piece;
}
export { VICTORY_CONFETTI_COUNT, VICTORY_FANFARE_NOTES };
let victoryAudioContext = null;
let victoryDismissTimer = null;
let victoryRemoveTimer = null;
export function removeVictoryCelebration() {
clearVictoryCelebrationTimers();
document.querySelector(".victory-celebration")?.remove();
}
export function createVictoryCelebration(message) {
const celebrationHost = document.querySelector("#app") ?? document.querySelector(".arena-shell");
if (!celebrationHost) {
return;
}
const isVictory = message.includes("승리");
const celebration = document.createElement("div");
celebration.className = `victory-celebration ${isVictory ? "is-victory" : "is-draw"}`;
celebration.setAttribute("aria-hidden", "true");
const rays = document.createElement("span");
rays.className = "victory-rays";
const confetti = document.createElement("span");
confetti.className = "victory-confetti";
if (isVictory) {
Array.from({ length: VICTORY_CONFETTI_COUNT }, (_, index) => {
confetti.appendChild(createVictoryConfettiPiece(index));
});
}
const banner = document.createElement("div");
banner.className = "victory-banner";
const messageNode = document.createElement("span");
messageNode.className = "victory-banner-message";
messageNode.textContent = message;
banner.appendChild(messageNode);
celebration.append(rays, confetti, banner);
celebration.addEventListener("click", () => {
dismissVictoryCelebration(celebration);
});
celebrationHost.appendChild(celebration);
scheduleVictoryCelebrationDismiss(celebration);
if (isVictory) {
playVictoryFanfare();
}
}
function clearVictoryCelebrationTimers() {
window.clearTimeout(victoryDismissTimer);
window.clearTimeout(victoryRemoveTimer);
victoryDismissTimer = null;
victoryRemoveTimer = null;
}
function scheduleVictoryCelebrationDismiss(celebration) {
clearVictoryCelebrationTimers();
victoryDismissTimer = window.setTimeout(() => {
dismissVictoryCelebration(celebration);
}, VICTORY_CELEBRATION_VISIBLE_MS);
}
function dismissVictoryCelebration(celebration) {
if (!celebration?.isConnected || celebration.classList.contains("is-leaving")) {
return;
}
clearVictoryCelebrationTimers();
celebration.classList.add("is-leaving");
victoryRemoveTimer = window.setTimeout(() => {
celebration.remove();
victoryRemoveTimer = null;
}, VICTORY_CELEBRATION_EXIT_MS);
}
export function primeVictoryFanfareAudio() {
const AudioContextClass = window.AudioContext ?? window.webkitAudioContext;
if (!AudioContextClass) {
return null;
}
if (!victoryAudioContext) {
victoryAudioContext = new AudioContextClass();
}
if (victoryAudioContext.state === "suspended") {
victoryAudioContext.resume().catch(() => {});
}
return victoryAudioContext;
}
export function playVictoryFanfare() {
const audioContext = primeVictoryFanfareAudio();
if (!audioContext || audioContext.state !== "running") {
return;
}
const startAt = audioContext.currentTime + 0.03;
VICTORY_FANFARE_NOTES.forEach((note) => {
playVictoryFanfareNote(audioContext, startAt + note.offset, note);
});
}
function playVictoryFanfareNote(audioContext, startAt, { duration, frequency, volume }) {
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
const releaseAt = startAt + duration;
oscillator.type = "triangle";
oscillator.frequency.setValueAtTime(frequency, startAt);
oscillator.frequency.exponentialRampToValueAtTime(frequency * 1.01, releaseAt);
gain.gain.setValueAtTime(0.0001, startAt);
gain.gain.exponentialRampToValueAtTime(volume, startAt + 0.025);
gain.gain.exponentialRampToValueAtTime(0.0001, releaseAt);
oscillator.connect(gain);
gain.connect(audioContext.destination);
oscillator.start(startAt);
oscillator.stop(releaseAt + 0.02);
}
+23
View File
@@ -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;
}
}
+449
View File
@@ -13,3 +13,452 @@
- **조치 사항**: - **조치 사항**:
- `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 마스크 기반 실루엣으로 생성.
- 이후 선택 전용 실루엣은 상시 팀 색상 마커로 전환되어, 생존 캐릭터마다 팀 색상을 계속 보여줌.
7. 패키지 락 파일 제외 (완료)
- **조치 사항**:
- `package-lock.json`을 git 추적에서 제외하고 `.gitignore`에 추가.
8. Slime 캐릭터 특성 추가 (완료)
- **조치 사항**:
- `src/game/fighterManifest.js`에 Slime 전용 `stats.maxHp: 1`과 `traits`를 추가.
- Slime으로 배정된 기본 스폰 슬롯 1개가 실제 Slime 10마리로 확장되도록 `spawnMultiplier` 처리 추가.
- Slime 사망 시 50% 확률로 최대 체력 1인 Slime 2마리를 생성하도록 분열 특성 추가.
- 분열체는 `childCanSplit: false`로 다시 분열하지 않도록 제한.
- 처치 보상은 최대 체력을 증가시키지 않으므로 Slime이 적을 처치해도 최대 체력 1이 유지됨.
9. 전투 진입 UI, 좌측 HUD badge, 좌측 하단 킬로그 개선 (완료)
- **조치 사항**:
- 최초 접속 화면에 투명 전투 프리뷰, `ARENA` / `PICKER` 2단 로고, `Start` 버튼을 배치.
- `Start` 클릭 시 우측 옵션 drawer가 열리고 홈 drawer 상태에서는 `ARENA` / `PICKER` 로고 위치를 유지한 채 `Start` 버튼을 숨기며, 전투 시작 시 실제 경기 화면으로 전환.
- 팀 badge를 상단 좌/우 분할에서 경기장 밖 좌측 HUD 레일로 이동.
- badge를 팀명, 팀 색상 구분선, 생존 인원 형식으로 표기.
- 좌측 HUD 레일 폭과 경기장 시작 위치를 분리 계산해 badge가 미니맵과 경기장 캔버스를 가리지 않도록 조정.
- 전투 시작 후 하단 안내바는 숨기고, 좌측 하단에 처치자/피처치자 이미지와 `manifest.key`를 포함한 목록형 킬로그를 표시. 중앙 텍스트는 `처치`로 유지하고 피처치자 아이콘에는 빨간 X를 겹쳐 구분.
10. 전투 중 옵션 drawer 유지, 접기/펼치기, 재시작, 일시정지 추가 (완료)
- **조치 사항**:
- 전투 시작 후 우측 drawer를 compact 패널로 유지하고, 전투 화면 중앙 정렬을 유지.
- `전투 설정` 헤더 우측 상단에 `옵션 접기/옵션 펼치기` 버튼을 배치.
- 접힌 상태에서는 같은 우측 상단 위치에 토글 버튼만 남기고 패널 내용은 숨겨 전투 화면을 가리지 않도록 조정.
- `재시작` 버튼으로 현재 입력값 기준 새 전투를 즉시 시작하도록 연결.
- `일시정지/계속` 버튼으로 Phaser 물리, 타이머, tween, 스프라이트 애니메이션을 함께 정지/재개하도록 구현.
11. 팀 badge 기반 무작위 시점 고정 (완료)
- **조치 사항**:
- 좌측 팀 badge를 클릭 가능한 버튼으로 변경.
- 클릭한 팀의 생존 캐릭터 중 무작위 1명을 선택해 기존 캐릭터 선택 카메라 고정 로직에 연결.
- 선택된 팀 badge에 강조 스타일을 적용하고, 전투 중 하단 안내바가 노출되지 않도록 상태 표시를 정리.
12. 처치 성장 scale 상한 및 arena 내부 보정 (완료)
- **조치 사항**:
- `KILL_GROWTH_MAX_MULTIPLIER`를 추가해 처치 누적 보상으로 인한 크기/공격속도/이동속도 증가에 상한을 적용.
- 처치 성장 tween 중/완료 시 캐릭터 위치를 arena 안쪽으로 보정해 히트박스가 전장 밖으로 나가는 문제를 방지.
13. 종족별 사망 통계 저장 및 상단 안내바 추가 (완료)
- **조치 사항**:
- `src/game/fighterManifest.js`의 모든 캐릭터에 `human`, `orc`, `skeleton`, `slime`, `wolf`, `bear` 중 하나의 `species` 값을 부여.
- 실제 전투에서 사망한 캐릭터를 `skin.species` 기준으로 집계하고, 프리뷰 전투는 통계에서 제외.
- `server/deathStats.js`와 `/api/death-stats/today` API를 추가해 별도 매치별 문서 없이 오늘 일자별 집계에 사망 카운트를 누적.
- 전투가 5초 이상 지속되면 상단 `#battle-notice`에 오늘 종족별 사망 집계를 2초 표시/10초 대기 주기의 재치 있는 안내 문구로 표시.
- 상단 안내바 폭을 전투 화면 안쪽에 어울리도록 줄이고, 방문자 수는 메인 화면 대신 전투 화면 우측 하단 작은 배지로 이동.
- `config.json.sample`에 사망 통계 컬렉션명과 집계 타임존 설정 예시를 추가.
14. 사망 캐릭터가 생존 캐릭터를 가리는 문제 개선 (완료)
- **조치 사항**:
- 사망한 캐릭터 sprite를 반투명 처리하고 생존 캐릭터보다 낮은 depth로 내려 전투원을 가리지 않도록 조정.
- 사망 캐릭터의 이름표, 체력바, 팀 색상 마커를 숨겨 전투 화면의 가독성을 유지.
15. 전투 설정 입력값 localStorage 유지 (완료)
- **조치 사항**:
- 참가자 닉네임 textarea와 팀당 인원 숫자 입력/range 값을 브라우저 `localStorage`에 저장.
- 앱 로드 시 저장된 참가자 닉네임과 팀당 인원을 먼저 복원해 새로고침/재접속 후에도 입력값이 유지되도록 구현.
- 저장된 입력값이 최초 대기 전투 프리뷰 규모를 키우지 않도록 프리뷰는 10팀 x 팀당 5명 설정으로 분리.
16. 최종교전 카메라 조건 및 슬로우모션 연출 추가 (완료)
- **조치 사항**:
- 생존 4명 이하에서는 카메라가 생존 캐릭터를 일정 간격으로 무작위 포커싱하도록 변경.
- 잔여 팀이 2팀이고 생존 캐릭터 합이 8명 이하이면 생존 수가 적은 팀의 중앙을 포커싱하도록 추가.
- `FINAL_COMBAT_SLOW_MOTION_ENABLED`로 최종교전 슬로우모션을 켜고 끌 수 있게 하고 기본값은 `false`로 둠.
- 활성화 시 최종교전 상태에서 idle이 아닌 공격 모션이 시작될 때 진입/유지/복귀 완급이 있는 전역 슬로우모션을 적용하고, Arcade Physics 이동에는 역수 timeScale을 적용.
17. 미니맵 뷰포트 박스 검은 깨짐 수정 (완료)
- **조치 사항**:
- 현재 뷰포트 사각형 좌표를 미니맵 픽셀 격자에 맞춰 이동 중 가장자리 흔들림을 완화.
- 검은 외곽 stroke 위에 노란 stroke를 겹치던 뷰포트 박스를 노란 내부 채움 선으로 바꿔 이동 중 일부 선이 검게 보이는 현상을 제거.
18. 치명타 적중 표기 추가 (완료)
- **조치 사항**:
- 공격 프로필의 치명타 판정을 실제 적중 처리까지 전달해 전투 타입별 적중 연출이 같은 흐름을 사용하도록 정리.
- 치명타 적중 시 대상 위에 `Critical!` 문구를 띄우고 즉시 처치가 적용되도록 `applyHit()`를 보강. (카메라 흔들림은 이후 메테오 착탄 연출로 이전)
19. 리스폰 배치 설정 구분 추가 (완료)
- **조치 사항**:
- 전투 설정 drawer에 `스타팅 지점 배치`와 기존 `완전 랜덤 배치`를 선택하는 리스폰 설정을 추가.
- `스타팅 지점 배치`에서는 참가자별 스타팅 영역과 영역 안 스폰 위치를 매치마다 무작위로 정하도록 구현했으며, 이후 30번 작업에서 영역 선택을 랜덤 중심 셀 기반 `5 x 5` 방식으로 구체화.
- 선택한 리스폰 배치 모드를 `localStorage`에 저장해 새로고침과 재시작 이후에도 유지.
20. 팀당 인원 직접 입력 동기화 (완료)
- **조치 사항**:
- 팀당 인원 표시 필드를 `number` 입력으로 바꿔 값을 직접 입력할 수 있도록 변경.
- 숫자 입력과 range 슬라이더가 같은 `1~100` 범위를 사용하며 양방향으로 즉시 동기화되도록 연결.
21. 승리 화면 축하 연출 추가 (완료)
- **조치 사항**:
- 기존 중앙 승리 배너를 금빛 광선과 컨페티가 함께 터지는 `.victory-celebration` 레이어로 확장.
- 실제 전투 시작에서 Web Audio 컨텍스트를 준비하고 승리 시 짧은 팡파르를 합성해 재생하도록 추가.
- 무승부는 팡파르와 컨페티를 제외한 절제된 결과 배너를 유지하고, 축하 애니메이션은 축소 모션 설정을 따르도록 보강.
22. ArenaScene.js 모듈화 및 src/game 폴더 구조 정리 (완료)
- **조치 사항**:
- `ArenaScene.js`의 방대한 기능을 7개의 전문 모듈(`arenaKillLog`, `arenaScoreboard`, `battleDeathNotice`, `victoryCelebration`, `arenaMatchRuntime`, `arenaSpectatorCamera`, `arenaFinalCombatEffects`)로 분리.
- `src/game` 폴더 내의 파일들을 역할별 하위 폴더(`arena/`, `combat/`, `fighter/`, `match/`)로 분류하여 재배치.
- 모든 `import` 경로를 새로운 계층 구조에 맞춰 업데이트하고 빌드 안정성을 확보.
- `ArenaScene.js`는 이제 각 모듈을 조율하는 오케스트레이션 역할에 집중하도록 경량화됨.
23. 일일 운영 지표 집계 추가 (완료)
- **조치 사항**:
- `server/dailyMetrics.js`와 `/api/daily-metrics/today`, `/match-started`, `/match-finished`, `/donation-clicked` API를 추가.
- 날짜별 합산 문서에는 `uniqueVisitors`, `totalVisits`, `totalMatchStarts`, `totalMatchFinishes`, `visitorsWithTwoOrMoreMatches`, `donationClicks`만 저장.
- 날짜+방문자 UUID 해시 기준의 `daily_visitor_activity` 임시 카운터로 당일 2회 이상 매치 시작 방문자 수를 계산.
- 임시 카운터에는 TTL 인덱스를 적용하고, 기본 보관 기간을 `DAILY_ACTIVITY_RETENTION_DAYS` 60일로 설정.
- 프리뷰 전투는 제외하고 사용자가 시작한 실제 전투만 매치 시작/종료 지표에 반영.
24. 모바일 전투 화면 구성 및 종료 팝업 대응 (완료)
- **조치 사항**:
- 모바일에서 실제 전투 시작 시 옵션 drawer를 자동으로 접어 상단 HUD와 전투 화면을 먼저 보여주도록 변경.
- 전투 중 옵션을 다시 펼쳐도 패널이 좌우 화면 밖으로 밀리지 않도록 모바일 live drawer 위치와 크기 규칙을 보정.
- 모바일 킬 로그를 정사각형 전투 캔버스 바로 아래에 배치해 큰 빈 구간이 생기지 않도록 조정.
- 전투 종료 시 옵션 drawer를 접고 `match-ended` 상태를 부여해 승리/무승부 연출이 설정 폼 위에 겹치지 않게 처리.
- 승리 연출은 읽을 수 있는 시간 동안 표시한 뒤 자동으로 사라지며, 결과 텍스트가 더 빠르게 선명하게 보이도록 애니메이션을 조정.
- 모바일 접힘 상태의 옵션 버튼을 더 작게 표시하고 상단 팀 HUD를 두 줄 4열 레이아웃으로 바꿔 4개 이후 팀도 잘리지 않게 조정.
- 모바일 킬로그 최대 높이 계산에 방문자 카운터 안전 여백을 포함해 하단 방문자 카운터와 겹치지 않도록 보정.
- 모바일 팀 카드의 선택 표시를 내부 테두리로 바꿔 카드 외곽선이 부모 영역에서 잘려 보이지 않게 수정.
- 모바일 전투 중 옵션 drawer를 압축하고 닉네임 입력 높이를 고정해 전투 시작/재시작/일시정지 버튼이 한 화면에 보이도록 조정.
- 승리/무승부 연출 레이어를 클릭하면 즉시 닫히도록 처리.
25. About 다이얼로그 및 개인정보처리방침 보관 컬렉션 추가 (완료)
- **조치 사항**:
- `server/about.js`를 추가해 `about_content` 컬렉션에 `developer-info`, `privacy-policy` 기본 문서를 서버 시작 시 upsert하고 메모리에 캐시.
- 개발자정보 기본값을 `alias: horoli`, `email: sunha321@gmail.com`, `github: https://github.com/Horoli`로 설정.
- 개인정보처리방침은 DB의 `privacy-policy.markdown` 문자열 필드에 Markdown 원문으로 저장하고, 클라이언트에서는 안전한 DOM 노드로 렌더링.
- 대기 화면과 전투 화면에 공통 About 버튼을 추가하고 개발자정보/개인정보처리방침 탭 다이얼로그를 연결.
- 수정 API는 만들지 않고, 브라우저 표시용 `GET /api/about` 읽기 전용 API만 추가.
26. 전투 화면 'About' 버튼 위치 최적화 및 정적 자산 캐싱 강화 (완료)
- **조치 사항**:
- `index.html`에서 `about-button`과 `visitor-count`를 `arena-meta` 컨테이너로 통합하여 `arena-shell` 내부로 배치.
- `styles.css`에서 `arena-meta`에 flex 레이아웃을 적용하여 전투 중 방문자 수 표시와 About 버튼이 나란히 배치되도록 수정.
- `server/index.js`에서 프로덕션 환경(`--production`) 시 정적 자산에 대해 강력한 캐싱(7일 유지, immutable) 및 명시적 Cache-Control 헤더 주입 로직 추가.
- `index.html`에 이모지 데이터 URI를 활용한 파비콘 설정을 추가하여 404 오류 해결.
- `src/ui/aboutDialog.js`의 Markdown 렌더러를 고도화하여 Bold, Italic, Code, Blockquote 등 핵심 문법 지원.
27. 개인정보처리방침 DB 연동 로직 개선 및 실시간 조회 적용 (완료)
- **조치 사항**:
- `server/about.js`의 `ensureAboutDefaults` 로직을 수정하여, DB에 이미 데이터가 있는 경우 기본값으로 덮어씌우지 않고 기존 데이터를 보존하도록 `$setOnInsert` 적용.
- 유저가 About 다이얼로그를 열 때마다 DB에서 최신 데이터를 가져오도록 서버 메모리 캐시 로직을 제거.
- 기본 개인정보처리방침 마크다운의 공고/시행 일자를 최신화.
28. 전투 역할별 기본 스탯 프로필 분리 (완료)
- **조치 사항**:
- `src/constants.js`에 `FIGHTER_TYPE_STATS.melee/ranged/magic` 프로필을 추가해 최대 체력, 이동속도, 사거리, 쿨다운, 피해량, 치명타, 공격 발동 지연을 역할별로 조정할 수 있도록 변경.
- `src/game/fighter/fighterStats.js`를 추가해 투사체 캐릭터는 원거리, 즉발 주문 캐릭터는 마법, 나머지는 근접 프로필로 판별하고 개별 스킨 오버라이드를 병합.
- 캐릭터 생성과 전투 엔진이 해석된 프로필을 사용하도록 연결해 역할별 체력, 이동 및 공격 수치가 실제 전투에 적용되도록 변경.
29. 킬로그 캐릭터 아이콘 가시성 개선 (완료)
- **조치 사항**:
- `100x100` idle 프레임에 포함된 투명 여백까지 축소되던 킬로그 아이콘 배경 표시 방식을 보정.
- 아이콘 박스 크기와 행 레이아웃은 유지하면서 캐릭터 실루엣이 있는 중앙 하단 영역을 확대 표시하도록 배경 크기와 위치를 조정.
30. 팀별 스타팅 영역 앵커 및 전장 표시 추가 (완료)
- **조치 사항**:
- `스타팅 지점 배치`에서 전장 스폰 가능 그리드 중 팀별 중심 셀을 무작위로 선택하고, 중심 주변 2칸을 포함하는 `5 x 5` 영역을 팀별 스폰 구역으로 사용하도록 변경.
- 겹치지 않는 후보가 남아 있는 동안에는 선택된 스타팅 영역끼리 중첩되지 않는 랜덤 중심을 우선 사용해 전투 시작 즉시 팀이 섞이는 상황을 줄임.
- 팀별로 무작위 배정된 스타팅 영역 데이터를 실제 스폰 좌표와 공유해 표시 영역 밖에서 시작하지 않도록 구성.
- `arenaRenderer.js`에 팀 색상의 매우 옅은 채움 및 외곽선 오버레이를 추가하고, 랜덤 배치에서는 오버레이가 표시되지 않도록 연결.
31. 사망 시점 팀 badge 클릭 입력 유실 수정 (완료)
- **조치 사항**:
- 사망 발생 때마다 `arenaScoreboard.js`가 팀 badge 버튼 전체를 재생성해 클릭 중인 DOM이 제거되던 문제를 수정.
- 팀 구성이 바뀌지 않는 전투 중 갱신에서는 기존 버튼 DOM을 유지하고 생존 인원, 선택 강조, 비활성 상태만 업데이트하도록 변경.
- 사망 처리와 팀 badge 클릭이 같은 시점에 겹쳐도 생존 캐릭터 관전 시점 선택이 정상 전달되도록 보강.
32. 주기적 월드 이펙트 메테오 및 냉각지대 추가 (완료)
- **조치 사항**:
- `public/assets/effects/world_Effect.png`를 7프레임 공용 스프라이트시트로 로드하고, 실제 전투 시작 후 8초마다 무작위 생존자 위치에 메테오 또는 냉각지대를 무작위 발동하도록 `worldEffects.js`를 추가.
- 메테오는 낙하 경고 후 대상 위치 기준 `5 x 5` 영역에 환경 피해를 적용하고, 환경 사망이 처치 보상 없이 사망 통계와 승패 판정에 반영되도록 전투 피해 처리를 확장.
- 냉각지대는 냉기 착탄 연출과 지속 구역을 표시하며, 구역 안에 있는 캐릭터의 공격속도와 이동속도를 함께 감속하도록 연결.
- 발동 간격, 범위, 피해량, 냉각 지속시간과 감속 배율을 `src/constants.js`의 `WORLD_EFFECT_*` 상수로 분리하고, 새 경기/종료/일시정지 생명주기에 맞춰 정리되도록 구성.
33. 월드 메테오 대각선 낙하 및 냉기 전용 시트 적용 (완료)
- **조치 사항**:
- 대상 위치가 전장 좌측 반면(2, 3사분면)이면 좌상단에서 우하단, 우측 반면(1, 4사분면)이면 우상단에서 좌하단으로 낙하하도록 궤적, 좌우 반전, `45`도 회전을 적용.
- `WORLD_EFFECT_VISUAL_SCALE`과 `WORLD_EFFECT_FALL_TRAVEL_TILES`를 추가해 피해 판정 `5 x 5`는 유지하면서 스프라이트를 전역 마법처럼 크게 보이도록 확장.
- 화염 메테오는 `public/assets/effects/world_Effect.png`, 냉기 메테오는 새 `public/assets/effects/world_Effect_2.png`를 각각 독립된 7프레임 애니메이션으로 로드하도록 변경.
34. 냉기 메테오 착탄 피해 옵션 추가 (완료)
- **조치 사항**:
- `WORLD_EFFECT_FROST_DAMAGE`를 추가해 냉기 메테오 피해를 화염 메테오와 독립적으로 조절할 수 있도록 변경.
- 냉기 메테오 착탄 시 `5 x 5` 영역 피해를 먼저 처리하고, 전투가 종료되지 않은 경우 기존 냉각지대 감속 효과를 이어서 생성하도록 연결.
35. 스타팅 영역 표시 시간 제한 추가 (완료)
- **조치 사항**:
- 팀별 스타팅 영역 오버레이가 `스타팅 지점 배치` 매치 시작 후 5초 동안만 표시되고 이후 자동으로 사라지도록 연결.
- 숨김 예약을 Phaser 씬 타이머로 관리하여 일시정지 시간은 표시 지속 시간에 포함되지 않고, 새 매치 시작 시 이전 숨김 타이머가 남지 않도록 정리.
36. 최종 2팀 자동 관전 및 메테오 착탄 화면 흔들림 전환 (완료)
- **조치 사항**:
- 생존 캐릭터가 30명 미만이거나 최종 2팀만 남으면 후반 자동 줌과 교전 중심 포커싱이 시작되도록 관전 조건을 확장.
- 치명타의 `Critical!` 표기와 즉시 처치는 유지하면서 카메라 흔들림을 제거.
- 화염 메테오 착탄 화면 흔들림을 먼저 적용했으며, 이후 모든 메테오 착탄이 크기 기반 흔들림을 공유하도록 확장.
37. 자동 관전 이전 메테오 임시 포커싱 추가 (완료)
- **조치 사항**:
- 후반 자동 관전 조건이 성립하기 전 화염 또는 냉기 메테오가 낙하하면 착탄 위치를 확대 추적하고 착탄 연출 종료 후 기존 카메라 위치와 줌을 복원.
- 캐릭터 수동 선택과 후반/최종 자동 관전은 메테오 임시 시점보다 우선하도록 카메라 상태를 정리.
- `src/constants.js`의 `CAMERA.METEOR_FOCUS_ENABLED` 플래그로 메테오 임시 포커싱을 코드에서 켜고 끌 수 있도록 구성.
38. 냉기 메테오 동결 기절 및 실루엣 효과 추가 (완료)
- **조치 사항**:
- 냉기 메테오 착탄 피해에 생존한 전투원은 `2초` 동안 이동과 새 공격이 정지되는 `isFrostStunned` 상태가 되도록 연결.
- 동결 중 캐릭터 본체와 팀 실루엣 마커를 함께 얼음색으로 틴트하고, 시간이 끝나거나 매치가 정리되면 본체 원본 색상과 팀 색상으로 복원.
- `WORLD_EFFECT.FROST_STUN_DURATION`과 `WORLD_EFFECT.FROST_STUN_TINT`를 추가해 동결 지속시간과 표시 색상을 조절 가능하게 구성.
39. 모바일 세로모드 팀 카드 가로폭 불균형 수정 (완료)
- **조치 사항**:
- 모바일 미디어 쿼리에서 `.score-side`가 데스크톱의 `grid-template-columns: repeat(2, 114px)`를 상속받아 1~4번 팀 카드만 길게 표시되던 현상을 수정.
- `grid-template-columns: none`을 추가하여 모든 팀 카드가 `grid-auto-columns`에 설정된 일정한 가로폭을 가지도록 보정.
40. CSS 파일 기능별 모듈화 (완료)
- **조치 사항**:
- 거대했던 `src/styles.css`(약 2,000라인)를 기능별로 6개의 파일(`base`, `intro`, `game-ui`, `overlay`, `animations`, `mobile`)로 분리.
- `src/styles/` 폴더를 생성하여 모듈화된 CSS 파일들을 관리.
- `src/styles.css`는 이제 `@import`를 통해 각 모듈을 통합하는 엔트리 포인트 역할만 수행.
- 코드 가독성과 유지보수 편의성을 대폭 향상.
41. 스타일 관련 컨텍스트 문서 추가 및 라우팅 업데이트 (완료)
- **조치 사항**:
- 새로운 CSS 모듈 구조와 디자인 원칙을 설명하는 `context/style.md` 문서를 신규 생성.
- `agent.md`의 상세 기술 가이드(Context Routing) 섹션에 스타일 및 디자인 항목을 추가하여 문서 접근성 개선.
42. 상단 공지(Battle Notice) 콘텐츠 확장 (완료)
- **조치 사항**:
- 사망 통계만 보여주던 공지 UI에 게임 시스템 가이드(화염/냉기 메테오 특성, 밀리 치명타 확률 등) 팁을 추가.
- 사망 통계 공지 2회당 1회의 비율로 시스템 팁이 교차 출력되도록 로직 개선.
43. 구매 배수 기반 월드 이펙트 독주 표적 가중치 추가 (완료)
- **조치 사항**:
- `닉네임*N`으로 구매한 추가 병력은 수량과 전투 수치를 낮추지 않고 그대로 유지.
- 생존 팀별 구매 배수 지분과 현재 생존 지분을 비교해, 구매 지분을 초과해 살아남은 팀에만 월드 이펙트 표적 가중치를 추가.
- `WORLD_EFFECT.DOMINANCE_TARGETING_MULTIPLIER`를 추가하고 기본값을 `1`로 설정하여 초과 생존 지분 기반 압력을 활성화하며, `0`으로 설정하면 기존 생존 유닛 비례 표적 선택으로 복귀하도록 구성.
44. Team marker duplicated sprite removal and team shadow baking (completed)
- **Changes**:
- Removed the duplicated per-fighter `teamMarker` Phaser sprite and its frame/position/depth synchronization path.
- Added lazy team-colored spritesheet and animation generation in `fighterAssets.js` by recoloring floor shadow pixels (`#534545`) to the fighter team color.
- Updated fighter creation and combat animation playback to use team-shadow animation keys for idle, walk, attack, hurt, and death actions.
- Kept frost stun as a body `setTint(WORLD_EFFECT.FROST_STUN_TINT)` effect, with no team-marker tint state to restore.
- Verified production build with `npm run build`.
45. Dead fighter battlefield despawn (completed)
- **Changes**:
- Added `FIGHTER.DEAD_DESPAWN_DELAY_MS` in `src/constants.js` so corpse lifetime is easy to tune.
- Updated `combat.js` to keep a dead fighter at initial opacity, fade it toward `FIGHTER.DEAD_DESPAWN_ALPHA`, then remove it from `scene.fighters` and destroy the sprite after the configured delay.
- Kept death bookkeeping, kill rewards, split-on-death, and winner checks ahead of the despawn schedule.
46. Variable meteor impact and visual scale (completed)
- **Changes**:
- Added `WORLD_EFFECT.SIZE_SCALE_VARIANCE` so each fire/frost meteor drop can pick a different size multiplier.
- Updated `worldEffects.js` to apply the same per-drop multiplier to both the damage/frost zone bounds and the falling/impact sprite scale.
- Added `WORLD_EFFECT.METEOR_SHAKE_DURATION_MS` and `WORLD_EFFECT.METEOR_SHAKE_INTENSITY`, then scaled fire/frost meteor camera shake from the meteor size multiplier.
47. Large-battle combat effect focus gating (completed)
- Suppressed critical labels, instant-spell sprites, kill-heal sprites, and kill-growth tweens during large battles outside meteor camera focus.
- Kept combat outcomes, reward values, meteor/frost visuals, and projectile hit-detection objects unchanged.
48. Direct fighter count input and generated population cap (completed)
- Replaced the team-size control with `nickname*N` direct assigned-fighter input semantics and preserved the preview size with internal suffixed entries.
- Added `SPAWN.MAX_FIGHTER_COUNT = 8000` validation for participant-assigned fighter slots before match replacement; Slime trait-generated spawns and splits remain outside that input cap.
- Distributed starting-zone teams through `SPAWN.FIGHTERS_PER_STARTING_ZONE = 100`, assigning any remainder to the final zone.
49. Inline fighter-count limit warning (completed)
- Displayed assigned-count cap violations beneath the participant nickname textarea.
- Cleared the warning when participant input changes or a valid live match is submitted.
50. Dense-area multi-meteor barrage targeting (completed)
- Replaced random living-fighter world-effect targeting with a summed-area tile scan that selects the most populated `WORLD_EFFECT.AREA_TILES` warning region.
- Changed each fire/frost activation into a configurable number of smaller strikes inside the warning region, with damage, stun, and lingering frost applied only per impact zone.
- Added `WORLD_EFFECT.IMPACT_*` tuning values and retired purchase-share dominance weighting in favor of direct crowd-density pressure.
51. Initial and repeating barrage interval split (completed)
- Kept `WORLD_EFFECT.INTERVAL` as the delay from match start to the first barrage and added `WORLD_EFFECT.REPEAT_INTERVAL` for subsequent normal barrages.
- Preserved `WORLD_EFFECT.SUDDEN_DEATH.INTERVAL_MS` as the repeat delay once sudden death becomes active.
52. Configurable barrage warning duration (completed)
- Added `WORLD_EFFECT.WARNING_DURATION_MS` to control the visible lifetime of the large dense-area warning marker.
- Kept scheduled small impacts and meteor camera focus running after the warning marker hides.
53. Phaser 3 online optimization review and low-risk performance pass (완료)
- **조치 사항**:
- Phaser 공식 문서/뉴스에서 object allocation, Group pooling, Blitter, camera ignore, Arcade Collider, render config 관련 최적화 기법을 확인.
- 투사체별 Arcade overlap collider 생성은 제거하고 기존 궤적 기반 판정만 유지.
- 투사체 판정용 geometry와 전투 타깃 spatial grid의 프레임별 할당을 줄임.
- 미니맵 redraw를 `PERFORMANCE.MINIMAP_REFRESH_MS`로 제한하고 Phaser 렌더 설정에 `autoRound`, `powerPreference`를 추가.
- Blitter 전환, Canvas 강제 전환, 광범위한 Group pooling은 현재 구조와 리스크 대비 보류.
54. Render resolution split for large-battle baseline performance (완료)
- **조치 사항**:
- Phaser 내부 canvas 해상도를 `ARENA.SIZE` 3200x3200에서 `RENDER` 1280x1280으로 분리.
- 전투 로직/월드 bounds는 기존 3200x3200 arena를 유지.
- `CAMERA.MIN_ZOOM`을 render/arena 비율로 낮춰 기본 전장 전체 시야를 유지.
- 미니맵 HUD 크기와 stroke도 render size 기준으로 조정.
55. Large-battle fighter render LOD (completed)
- **Changes**:
- Added a large-battle render LOD pass that caps detailed visible fighter sprites through `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT`.
- Rendered hidden living fighters as team-colored dots on a shared `Graphics` layer while keeping them active for combat simulation.
- Used per-team representatives at full-arena `CAMERA.MIN_ZOOM` and camera-near detail selection for zoomed/selected views.
- Released HUD slots and pointer interaction for hidden fighters, and invalidated LOD when split-on-death children spawn.
- Verified production build with `npm run build`.
56. Dynamic zoomed fighter render LOD (completed)
- **Changes**:
- Changed `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT` into the full-arena/base budget instead of a universal fixed cap.
- Added `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_MAX` as the dynamic safety cap.
- Counted camera-near living fighters during zoomed/selected views and raised the detailed sprite budget dynamically up to the safety cap.
- Prioritized exact viewport candidates before rolling-window candidates so dots do not appear inside the current view unless the rolling-window count exceeds the dynamic cap.
57. Scoreboard team button toggle to full arena (completed)
- **Changes**:
- Clicking the currently selected team button now clears the fighter selection instead of selecting another random same-team fighter.
- Added `ArenaScene.returnToFullArenaView()` to restore `CAMERA.MIN_ZOOM`, center the arena, clear combat focus state, refresh the minimap, and update the scoreboard.
58. Large-battle start camera avoids full-arena overview (completed)
- **Changes**:
- Added `CAMERA.LARGE_BATTLE_START_ZOOM`.
- Live matches at or above `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` now start zoomed in instead of staying on `CAMERA.MIN_ZOOM`.
- Centered the initial large-battle camera on the living fighter closest to the overall living-fighter average without selecting that fighter.
59. Manual camera pan/zoom transitions (completed)
- **Changes**:
- Added `CAMERA.MANUAL_FOCUS_TWEEN_MS` and `CAMERA.MANUAL_FOCUS_TWEEN_EASE`.
- Added `ArenaScene.transitionMainCameraTo()` for Phaser camera `pan()` and `zoomTo()` based manual focus changes.
- Updated fighter/team selection and selected-team full-arena return to tween instead of jumping instantly.
- Paused selected-fighter auto-centering while the manual camera transition is active so it does not cancel the tween.
60. Rolling-window fighter LOD for smooth camera movement (completed)
- **Changes**:
- Replaced exact-visible-count LOD budgeting with a camera-centered rolling window.
- Added `PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_SCALE` and `PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_BUFFER_RATIO`.
- Kept exact viewport fighters as the first priority, then filled the detailed set with rolling-window fighters before they enter the visible screen.
- Shortened `PERFORMANCE.LARGE_BATTLE_LOD_REFRESH_MS` so the rolling window follows manual pan/zoom more responsively.
61. Dormant offscreen fighter simulation (completed)
- **Changes**:
- Rolling-window LOD outside fighters now pause animation, disable input, release HUD, and disable Arcade bodies instead of only setting invisible.
- Updated combat movement so disabled-body fighters keep moving through JS position math.
- Kept visible fighters on Arcade movement for on-screen fidelity.
- Resolved projectile attacks involving dormant fighters as delayed data hits without spawning projectile objects.
- Updated camera/world-effect helpers to use fighter `x/y` when a body is disabled.
62. Fighter adapter layer before model/proxy rewrite (completed)
- **Changes**:
- Added `src/game/fighter/fighterAdapter.js` as the first boundary between fighter simulation state and Phaser Sprite/Arcade APIs.
- Moved fighter world-point, distance, facing, movement, body enable/disable, arena clamping, animation, and tint helpers behind the adapter.
- Updated combat, world effects, spectator camera, match finish cleanup, and fighter detail visibility to use the adapter for fighter-specific render/body access.
- Left the full `FighterModel + SpriteProxy` rewrite as a later larger step; this pass reduces the direct coupling first.
63. FighterModel shell for state/render split (completed)
- **Changes**:
- Added `src/game/fighter/fighterModel.js` to hold combat/state fields in a pure JS model object.
- Attached each fighter sprite to `fighter.model` and bridged existing custom sprite fields with getter/setters so current code remains compatible.
- Synced live sprite positions into model `x/y` during combat-frame preparation and wrote dormant movement through model-aware adapter helpers.
- Updated combat target indexing, arena rolling-window LOD, minimap dots, and split-spawn origins to use model position helpers where safe.
- Retained Phaser sprites for every fighter at this stage; later work added rolling-window SpriteProxy detach and lazy sprite pooling.
64. Model-based combat targeting and spatial index (completed)
- **Changes**:
- Added `ArenaScene.fighterModels`, `fighterByModelId`, and `fighterModelById` indexes.
- Registered models on match start and split spawns, and unregistered despawned fighters so stale models are marked inactive.
- Changed combat update entry to `updateFighterModel()` and iterated `scene.fighterModels` from the scene update loop.
- Built the target spatial grid from models instead of sprites.
- Replaced cached `targetEnemy` sprite references with `model.targetModelId` and resolved sprites only when rendering/movement/attack execution needs them.
65. Model-only combat fallback before SpriteProxy pooling (completed)
- **Changes**:
- Allowed `updateFighterModel()` to keep simulating living models that do not currently have a render sprite.
- Added model-only movement that updates `model.x/y` directly with arena clamping.
- Added delayed model-hit resolution for melee, projectile, and instant-spell attacks when either combatant lacks a sprite.
- Added `killFighterModel()` so model-only deaths mark models inactive, unregister indexes, record deaths/kills, apply kill rewards, and process split-on-death.
- Kept the full visual Sprite path when both combatants still have render sprites.
66. Rolling-window SpriteProxy detach (completed)
- **Changes**:
- Changed large-battle LOD so non-detailed fighter proxies are removed from Phaser's display list and update list instead of only being hidden.
- Removed detached proxies from `fighterByModelId`, letting combat route through the model-only fallback until the rolling window reattaches the proxy.
- Reattached detailed proxies from model state, including position/body reset, facing, input, animation resume, and kill-growth scale.
- Removed parked detached proxies from `this.fighters` on model-only death so dead offscreen proxies do not keep participating in scan loops.
- Kept LOD candidate selection and minimap drawing on the full model-backed proxy list so detached fighters still appear as dots and can be selected by team buttons.
- Verified production build with `npm run build`.
67. Lazy SpriteProxy pool for large-battle startup (rolled back)
- **Changes**:
- Changed `createFighter()` to create a lightweight model-backed proxy first, with optional Phaser Sprite attachment.
- Large live matches now pass `attachSprite: false` at match start, so thousands of fighters begin as model-only proxies.
- Added `scene.fighterSpritePool`; LOD detail promotion acquires/reconfigures a pooled sprite and LOD demotion releases it back to the pool.
- Routed clicked Phaser sprites back to their owning proxy through `_fighterProxy`, keeping selection/team focus logic model-first.
- Split-on-death children in active large-battle LOD now spawn model-only and wait for LOD promotion before acquiring render sprites.
- Verified production build with `npm run build`.
- Rolled this back after a render regression where fighter models and team counts were alive but no stable Phaser Sprite appeared on the field.
68. Fighter sprite render recovery after lazy proxy regression (completed)
- **Changes**:
- Restored `createFighter()` to return a real Phaser Sprite with an attached `fighter.model` bridge.
- Kept the `attachSprite` option, but large-battle startup now parks the created sprite through `setFighterDetailVisible(false)` instead of skipping Sprite creation.
- Preserved rolling-window LOD's display/update-list detach and reattach behavior for non-detailed fighters.
- Updated arena/fighter/combat context docs to mark the lazy sprite pool as disabled.
- Verified production build with `npm run build`.
69. Null-safe model target cache after sprite recovery (completed)
- **Changes**:
- Fixed `isValidEnemyTargetModel()` so cached target validation safely handles `null` attacker or candidate models before reading team ids.
- Cleared stale `targetModelId` values in `resolveTargetEnemyModel()` before scanning for a replacement enemy.
- Verified production build with `npm run build`.
70. Detached fighter animation guard for large-battle model-only attacks (completed)
- **Changes**:
- Fixed `fighterAdapter.shouldRenderFighterDetail()` so `null` or detached fighters cannot be treated as renderable.
- Added animation-key guards in `playFighterAction()` and `playFighterActionIfNeeded()` so model-only attacks skip sprite animation safely.
- Verified production build with `npm run build`.
71. Large-battle simulation throttle and tighter render budget (completed)
- **Changes**:
- Added large-battle simulation buckets so attached/detailed fighters update every frame while detached model-only fighters are distributed across frames.
- Added capped accumulated delta for throttled detached model updates.
- Reduced large-battle detailed sprite and HUD label caps to avoid 8,000-fighter zoom views promoting thousands of animated sprites.
- Allowed rolling-window LOD buffer ratios below `1` for dense large-battle scenes.
- Added an early return when `setFighterDetailVisible(false)` is called on an already parked fighter, reducing LOD refresh spikes.
- Verified production build with `npm run build`.
72. Aggressive 8k battle throttles for remaining frame drops (completed)
- **Changes**:
- Lowered detailed sprite caps again for dense 8,000-fighter zoom views.
- Changed combat frame preparation to sync only attached sprites and rebuild the large-battle target spatial index on an interval instead of every frame.
- Throttled model-index audits to once per second.
- Skipped world-effect modifier scans when no frost zone is active, and throttled active frost-zone scans.
- Verified production build with `npm run build`.
73. Aggregate detached combat for large battles (completed)
- **Changes**:
- Added a large-battle aggregate combat path for detached/offscreen model-only fighters.
- Detached fighters now move toward coarse enemy cells and resolve batched HP/deaths at `PERFORMANCE.LARGE_BATTLE_AGGREGATE_COMBAT_REFRESH_MS` instead of running full target/attack AI.
- Kept attached/detail fighters on every-frame individual combat for visible camera fidelity.
- Suppressed per-kill DOM log entries for aggregate offscreen deaths while preserving death stats, kill rewards, split-on-death, scoreboard updates, and match finish checks.
- Verified production build with `npm run build`.
74. Squad-based detached combat compression (completed)
- **Changes**:
- Compressed detached/offscreen fighters into `team + cell + 100 fighters` squads during large live battles.
- Moved and resolved combat at the squad level, then reslotted individual models around squad centers only on aggregate ticks.
- Changed large-battle target spatial indexing to attached/detail models so visible individual AI does not scan all offscreen models.
- Added `PERFORMANCE.LARGE_BATTLE_AGGREGATE_SQUAD_SIZE` for tuning squad population.
- Verified production build with `npm run build`.
75. Magic attack effect sprite pooling (completed)
- **Changes**:
- Added per-texture pooling for instant-spell attack effect sprites in `combat.js`.
- Returned pooled spell effects on animation completion and during `clearCombatObjects()` cleanup instead of destroying them.
- Reset pooled spell sprites before reuse, including frame, position, scale, depth, alpha, rotation, flip, active/visible state, and animation-complete listener state.
- Left projectile and meteor/world-effect lifecycles unchanged.
- Verified production build with `npm run build`.