Compare commits

..

5 Commits

31 changed files with 1921 additions and 751 deletions

View File

@ -1,163 +0,0 @@
# Context: Arena Picker 개발 가이드
## 1. 모듈별 상세 역할
### [Core Engine]
- **`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`**: 게임 내 모든 튜닝 수치를 관리합니다.
- `ATTACK_DAMAGE_MIN`, `ATTACK_DAMAGE_MAX`: 일반 공격 1회 적중 시 적용되는 랜덤 피해량 범위.
- `FIGHTER_HITBOX_*`: 100x100 캐릭터 프레임 안에서 실제 충돌 판정이 놓이는 위치와 크기.
- `KILL_HEALTH_RECOVERY_RATIO`, `KILL_GROWTH_MULTIPLIER`, `KILL_GROWTH_MAX_MULTIPLIER`: 처치 후 회복량, 크기/공격속도/이동속도 성장 배율, 누적 보상 상한.
- `SELECTED_FIGHTER_OUTLINE_GAP`, `SELECTED_FIGHTER_OUTLINE_WIDTH`, `SELECTED_FIGHTER_OUTLINE_ALPHA`: 팀 색상 실루엣 마커의 캐릭터 이격 거리, 두께, 투명도.
- `TEAM_COLORS`, `getTeamColor()`: 8팀 이하에서는 기본 팔레트를 쓰고, 9팀 이상에서는 팀 수에 맞춰 중복 없는 색상을 동적으로 생성합니다.
- `SPECTATOR_CAMERA_LERP`: 카메라 추적의 부드러움 정도.
- `SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD`, `SPECTATOR_RANDOM_FOCUS_INTERVAL`, `FINAL_COMBAT_SLOW_MOTION_*`: 최종교전 관전 조건, 랜덤 포커싱 간격, 슬로우모션 배율/지속시간.
- `MINIMAP_VIEWPORT_SIZE`: 미니맵의 고정 픽셀 크기.
- `ARENA_SIZE`: 경기장 전체 크기 (GRID * TILE).
### [Server/API - server/]
- **`server/index.js`**:
- Fastify 서버 진입점입니다.
- 개발 모드에서는 `@fastify/middie`로 Vite 미들웨어를 붙이고, `/api/*` 요청은 Vite SPA fallback이 가로채지 않도록 Fastify 라우트로 통과시킵니다.
- 운영 모드(`npm start`)에서는 `@fastify/static`으로 `dist/`를 서빙하고, HTML 요청은 `index.html`로 fallback합니다.
- **`server/config.js`**:
- `config.json`을 읽어 서버 포트, MongoDB host/port/db/user/pass, 쿠키 보안 옵션을 정규화합니다.
- `MONGODB_URI`가 직접 있으면 우선 사용하고, 없으면 `MONGODB_HOST`/`MONGODB_PORT` 기반으로 URI를 조립합니다.
- 전투 사망 통계 컬렉션(`MONGODB_DAILY_DEATH_COLLECTION`)과 집계 기준 타임존(`DEATH_STATS_TIME_ZONE`) 기본값을 제공합니다.
- **`server/db.js`**:
- `MongoClient`를 한 번 생성한 뒤 재사용하여 MongoDB 커넥션 풀을 유지합니다.
- 종료 시 `closeMongoConnection()`으로 커넥션을 닫습니다.
- **`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`: 전체 유니크 방문자 수를 반환합니다.
### [Game Logic - src/game/]
- **`ArenaScene.js`**:
- `update()`: 매 프레임 생존 팀을 체크하고 좌측 HUD 레일의 팀 badge를 갱신합니다.
- 최초 로드 시 `presentationMode`로 프리뷰 전투를 조용히 실행하고, 실제 전투 시작 전까지 가까운 교전 지점을 배경처럼 보여줍니다.
- `createFighterPlans()`: 매치 시작 시 배정된 스킨의 `traits.spawnMultiplier`를 반영해 실제 생성할 전투원 목록을 확장합니다.
- `spawnSplitFighters()`: Slime 사망 분열처럼 전투 중 새 전투원을 생성해야 하는 특성을 처리합니다.
- `recordDeath()`, `persistDailyDeathStats()`: 실제 전투에서만 사망 캐릭터의 `skin.species`를 종족별로 집계하고, 전투 종료 시 오늘 일자별 서버 사망 통계에 더합니다.
- `scheduleBattleNotice()`, `showBattleDeathNotice()`: 실제 전투가 5초 이상 지속되면 오늘 누적 사망 통계와 현재 전투 사망 수를 합산해 상단 안내바에 표시합니다. 안내는 2초 표시 후 10초 대기하는 주기로 반복됩니다.
- `setPaused()`, `togglePause()`: 실제 전투에서 물리, Phaser 타이머, tween, 스프라이트 애니메이션을 함께 정지/재개합니다. 프리뷰 전투(`presentationMode`)와 종료된 전투는 pause 대상에서 제외합니다.
- `observeCombat()`: 캐릭터가 공격할 때 카메라가 주목할 "관전 대상"을 설정합니다.
- `getSpectatorState()`, `getSpectatorCameraTarget()`: 생존 4명 이하에서는 생존 캐릭터를 무작위로 포커싱하고, 잔여 2팀의 생존 합이 8명 이하이면 생존 수가 적은 팀을 포커싱합니다.
- `triggerFinalCombatSlowMotion()`: 최종교전 상태에서 공격 모션이 시작될 때만 짧게 전역 time scale을 낮춰 슬로우모션을 연출합니다.
- `selectFighter()`, `focusSelectedFighter()`: 캐릭터 클릭 시 해당 캐릭터의 히트박스 중심으로 카메라를 고정합니다. 팀 색상 마커는 선택 상태와 별개로 상시 표시됩니다.
- `selectRandomTeamFighter()`: 좌측 팀 badge 클릭 시 해당 팀의 생존 전투원 중 무작위 1명을 골라 카메라를 고정합니다.
- `updateMinimapViewportFrame()`: 주 카메라의 이동에 맞춰 미니맵 가이드 사각형을 렌더링합니다.
- **`matchSetup.js`**:
- 입력된 닉네임을 순회하여 `team` 객체를 생성하고, 요청된 인원만큼 캐릭터 데이터를 복제 배치합니다.
- **`combat.js`**:
- `updateFighter()`: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
- `applyHit()`: 일반 공격 피해량은 `ATTACK_DAMAGE_MIN/MAX` 범위에서 계산합니다.
- `killFighter()`: 사망한 캐릭터를 반투명 처리하고 살아있는 캐릭터보다 낮은 depth로 내려 전투원을 가리지 않게 합니다.
- `applyKillReward()`: 처치한 캐릭터의 체력 회복, 크기 증가, 공격속도/이동속도 배율 증가를 처리합니다. 누적 배율은 `KILL_GROWTH_MAX_MULTIPLIER`로 제한합니다.
- `clampFighterInsideArena()`: 처치 성장 tween 중/완료 시 커진 캐릭터가 arena 밖으로 밀려 히트박스가 전장 바깥에 놓이지 않도록 위치를 보정합니다.
- `maybeSplitFighter()`: 사망한 캐릭터의 `traits.splitOnDeath` 특성을 확인하고 분열 생성을 요청합니다.
- `projectilePathHitsDefender()`: 투사체가 대상을 스쳐 지나가지 않도록 궤적 검사를 수행합니다.
### [Assets & UI]
- **`fighterAssets.js`**: 원본 캐릭터 스프라이트의 alpha 값을 읽어 흰색 실루엣 spritesheet를 런타임에 생성합니다. 원본 주변 1px은 비워두고 그 바깥 1px만 칠한 뒤, `fighterFactory.js`에서 팀 색상으로 tint 처리합니다.
- **`fighterFactory.js`**: 캐릭터 히트박스, 이름표, 체력바, 팀 색상 마커 sprite를 생성하고 매 프레임 위치/스케일/방향을 동기화합니다. 이름표는 스프라이트 중심이 아니라 실제 히트박스 하단에 고정됩니다. 사망한 캐릭터의 HUD와 팀 색상 마커는 숨겨 전투 화면을 덜 가리게 합니다. 생성 옵션의 `hp`, `maxHp`, `canSplitOnDeath`를 통해 개별 전투원의 체력과 분열 가능 여부를 지정할 수 있습니다.
- **`fighterManifest.js`**: 20여 종의 캐릭터 스킨 정보가 담긴 딕셔너리입니다. 각 스킨은 사망 통계용 `species`를 가지며, `combat.type` (melee/projectile/instant-spell)에 따라 전투 메커니즘이 결정됩니다. `stats``traits`로 캐릭터별 기본 체력과 특수 규칙을 정의합니다.
- **`matchForm.js`**: `index.html`의 입력을 읽어 `ArenaScene`에 매치 구성을 전달하고, 검증/결과 상태 메시지를 DOM에 반영할 수 있는 setter를 제공합니다. 참가자 닉네임과 팀당 인원은 브라우저 `localStorage`에 저장/복원합니다. 실제 전투 중 하단 안내바는 숨기고, 처치 내역은 `ArenaScene`의 좌측 하단 킬로그가 담당합니다.
- **`deathStats.js`**: `GET /api/death-stats/today`, `POST /api/death-stats/today`를 호출하는 프론트엔드 API 래퍼입니다.
- **`visitorCounter.js`**: `POST /api/visitors/check`를 호출하고, 응답의 `uniqueVisitors` 값을 전투 화면 우측 하단의 `#visitor-count` 배지에 표시합니다.
## 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;
```
최종교전 관전은 두 단계로 나뉩니다. 생존 4명 이하에서는 `SPECTATOR_RANDOM_FOCUS_INTERVAL`마다 생존 캐릭터 중 한 명을 무작위로 포커싱합니다. 잔여 팀이 2팀이고 생존 합이 8명 이하인 구간에서는 더 적은 생존 수를 가진 팀의 중앙을 포커싱하며, 동률이면 기존 교전쌍 중심 포커싱으로 되돌아갑니다. 최종교전 상태에서 `combat.js`가 공격 모션을 시작할 때 `FINAL_COMBAT_SLOW_MOTION_SCALE`을 짧게 적용합니다.
### 미니맵 가이드라인
미니맵은 전장 전체를 축소하여 보여주는 독립된 카메라입니다. 주 카메라가 비추는 영역을 계산하여 미니맵 위에 사각형(`graphics`)을 그려줍니다.
- `camera.displayWidth / zoom` 등을 이용하여 현재 월드에서 보이는 실제 영역 크기를 계산합니다.
### 전투 진입 UI와 HUD 레이아웃
초기 화면은 Phaser 프리뷰 전투와 CSS 스프라이트 프리뷰를 낮은 투명도의 배경으로 깔고, 중앙에 `Arena` 로고와 `Start` 버튼을 둡니다.
1. `Start` 클릭 시 `#app.options-open`이 설정되고 우측 옵션 drawer가 열립니다.
2. `전투 시작` 제출 시 `#app.match-live`가 설정되며 실제 전투가 시작됩니다. drawer는 닫히지 않고 우측 compact 패널로 유지됩니다.
3. 전투 중 drawer 우측 상단의 `옵션 접기/옵션 펼치기` 버튼으로 설정 패널을 접거나 다시 펼칩니다. 접힌 상태에서는 같은 우측 상단 위치에 토글 버튼만 남아 전투 화면을 가리지 않습니다.
4. drawer 안의 `재시작` 버튼은 현재 입력값으로 새 매치를 시작합니다.
5. drawer 안의 `일시정지` 버튼은 `ArenaScene.togglePause()`를 호출하고, 정지 중에는 버튼 문구를 `계속`으로 바꾸며 `#app.match-paused`를 설정합니다.
6. 참가자 닉네임과 팀당 인원은 입력이 바뀔 때마다 `localStorage`에 저장하고 앱 로드 시 먼저 복원합니다.
7. 팀 badge는 경기장 캔버스 위가 아니라 좌측 HUD 레일에 배치합니다. 레일 폭은 CSS 변수 `--score-panel-width`, `--score-rail-width`로 계산되며 미니맵과 겹치지 않습니다.
8. badge는 팀명, 팀 색상 구분선, 생존 인원 순서로 표기하고, 클릭 시 해당 팀 생존 전투원 중 무작위 1명으로 관전 시점을 고정합니다.
9. 전투 중 하단 `match-status` 안내바는 숨깁니다. 처치가 발생하면 좌측 하단 `kill-log`가 처치자와 피처치자를 좌우로 나누고, 각 항목에 작은 idle 스프라이트 이미지, 팀명, `manifest.key`를 표시합니다. 중앙에는 칼 아이콘과 `처치 >` 방향 텍스트를 둬 왼쪽 캐릭터가 오른쪽 캐릭터를 처치했다는 관계를 명확히 보여줍니다.
10. 실제 전투가 5초 이상 지속되면 `#battle-notice` 상단 안내바가 표시됩니다. 문구는 오늘 서버 집계와 현재 전투의 사망 수를 합산한 뒤 가장 많이 사망한 종족을 기준으로 생성하며, 전투 화면보다 넓어 보이지 않는 작은 폭으로 2초 표시 후 10초 대기하는 주기로 다음 문구를 보여줍니다.
11. 방문자 수는 메인 화면에서는 숨기고, 실제 전투 중에만 우측 하단의 작은 `#visitor-count` 배지로 표시합니다.
### 상시 팀 색상 실루엣
팀 색상 표시는 히트박스 사각형이 아니라 캐릭터 모양을 따라가는 별도 spritesheet입니다.
1. `fighterAssets.js`가 로드된 원본 스프라이트시트의 alpha 데이터를 캔버스에서 읽습니다.
2. 원본 alpha 픽셀 주변 `SELECTED_FIGHTER_OUTLINE_GAP` 범위는 공백 마스크로 남깁니다.
3. 그 바깥 `SELECTED_FIGHTER_OUTLINE_WIDTH` 범위에만 흰색 outline을 칠합니다.
4. `fighterFactory.js`가 생존 캐릭터 뒤에 `teamMarker` sprite를 배치하고, 팀 색상으로 tint 처리합니다.
5. `syncFighterHud()`가 현재 texture frame, flip 방향, scale, 위치를 원본 캐릭터와 동기화합니다.
6. 사망 캐릭터는 팀 색상 마커와 HUD를 숨기며, 본체 sprite만 낮은 depth와 반투명 상태로 남깁니다.
이 방식은 별도 에셋 없이도 캐릭터 모양에 맞춘 팀 색상 마커를 만들 수 있으며, 캐릭터가 처치 보상으로 커져도 마커가 같은 배율로 따라갑니다. 캐릭터 클릭은 카메라 포커스 기능만 담당하고, 팀 색상 마커는 선택 여부와 관계없이 유지됩니다.
### 캐릭터별 특성: Slime
Slime은 `fighterManifest.js``stats``traits`로 특수 규칙을 선언합니다.
- `stats.maxHp: 1`: 처음 배정되어 스폰되는 Slime과 분열로 생성되는 Slime 모두 최대 체력이 1입니다.
- `traits.spawnMultiplier: 10`: Slime 스킨이 배정된 기본 스폰 슬롯 1개를 실제 Slime 10마리로 확장합니다.
- `traits.splitOnDeath.chance: 0.5`: Slime이 사망할 때 50% 확률로 분열합니다.
- `traits.splitOnDeath.count: 2`: 분열 성공 시 같은 팀의 Slime 2마리를 사망 위치 근처에 생성합니다.
- `traits.splitOnDeath.childMaxHp: 1`: 분열체도 최대 체력 1로 생성됩니다.
- `traits.splitOnDeath.childCanSplit: false`: 분열체는 다시 분열하지 않습니다.
처치 보상은 최대 체력을 증가시키지 않습니다. `applyKillReward()`는 현재 체력을 `maxHp` 상한 안에서 회복하고, 처치 누적 배율로 크기/공격속도/이동속도만 증가시킵니다. 배율은 `KILL_GROWTH_MAX_MULTIPLIER`로 제한되므로 Slime이 적을 처치해도 최대 체력은 1로 유지되고, 장기전에서 캐릭터 scale/속도가 무한정 커지지 않습니다.
### 유니크 방문자 체크
브라우저가 직접 MongoDB에 연결하지 않고, Fastify API가 MongoDB 커넥션 풀을 유지합니다.
1. 프론트엔드가 앱 로드 시 `POST /api/visitors/check`를 호출합니다.
2. 서버가 `arena_visitor_id` 쿠키를 검사합니다.
3. 쿠키가 없거나 유효하지 않으면 `crypto.randomUUID()`로 새 방문자 ID를 만들고 `HttpOnly` 쿠키로 내려줍니다.
4. MongoDB에는 `_id`, `firstSeenAt`, `lastSeenAt`, `visits`, `firstUserAgent`, `lastUserAgent`를 저장합니다.
5. `countDocuments()`로 전체 유니크 방문자 수를 계산해 반환합니다.
방문자 체크는 인증 기능이 아니며, 브라우저/쿠키 단위의 단순 유니크 카운트입니다.
### 전투 사망 통계
프리뷰 전투는 통계에서 제외하고, 사용자가 시작한 실제 전투만 저장합니다.
1. `fighterManifest.js`의 모든 스킨은 `species`를 가집니다. 사용 가능한 값은 `human`, `orc`, `skeleton`, `slime`, `wolf`, `bear`입니다.
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. 개발 및 유지보수 규칙
- **신규 캐릭터 추가**: `public/assets/characters/`에 에셋 배치 후 `fighterManifest.js`에 정의를 추가하면 즉시 게임에 반영됩니다.
- **종족값 유지**: 신규 스킨을 추가할 때는 사망 통계가 누락되지 않도록 `species``human`, `orc`, `skeleton`, `slime`, `wolf`, `bear` 중 하나로 지정해야 합니다.
- **물리 수치 조정**: 캐릭터의 속도나 사거리 등은 `src/constants.js` 또는 `fighterManifest.js` 내 개별 설정을 통해 변경하십시오.
- **처치 성장 상한 조정**: 처치 보상으로 캐릭터가 커지는 최대치와 공격/이동 배율 상한은 `src/constants.js``KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
- **공격력 조정**: 기본 피해량은 `src/constants.js``ATTACK_DAMAGE_MIN`, `ATTACK_DAMAGE_MAX`를 수정합니다. 캐릭터별 특수 공격 방식은 `fighterManifest.js``combat` 설정을 우선 확인합니다.
- **캐릭터별 스탯/특성 추가**: 기본 체력은 `fighterManifest.js``stats.maxHp`에 정의하고, 스폰 배율이나 사망 분열 같은 특성은 `traits`에 선언합니다. 전투 중 새 전투원을 만들 필요가 있는 특성은 `ArenaScene.js`의 생성 헬퍼를 통해 `this.fighters``team.size`를 함께 갱신해야 합니다.
- **DOM 접근**: 성능을 위해 `ArenaScene`은 좌측 HUD badge 등 필요한 시점에만 최소한으로 DOM에 접근합니다.
- **서버 설정**: `.env` 대신 `config.json`을 사용합니다. `config.json`은 로컬 전용 파일이며, 저장소에는 `config.json.sample`만 공유합니다.
- **패키지 락 파일**: 이 프로젝트는 `package-lock.json`을 저장소에서 제외합니다. 의존성 변경 시 `package.json`을 기준으로 관리합니다.
- **기본 포트**: `SERVER_PORT` 기본값은 `9736`입니다.
- **MongoDB 연결**: DB 접속 정보는 `config.json``MONGODB_HOST`, `MONGODB_PORT`, `MONGODB_DB`, 선택적 `MONGODB_USER`, `MONGODB_PASS`로 관리합니다.
- **API 변경**: `/api/*` 경로는 Fastify 라우트가 담당합니다. 개발 모드에서 Vite 미들웨어가 API 요청을 SPA HTML로 처리하지 않도록 서버 라우팅 순서를 유지해야 합니다. 전투 사망 통계 API는 MongoDB 설정이 없으면 방문자 통계와 마찬가지로 503 응답을 반환합니다.

View File

@ -35,41 +35,43 @@
├── main.js # Phaser 게임 인스턴스 생성, 옵션 drawer/재시작/일시정지 UI 제어 ├── main.js # Phaser 게임 인스턴스 생성, 옵션 drawer/재시작/일시정지 UI 제어
├── constants.js # 전역 물리/UI 상수 통합 관리 (공격력, 체력, 줌, 카메라 속도 등) ├── constants.js # 전역 물리/UI 상수 통합 관리 (공격력, 체력, 줌, 카메라 속도 등)
├── styles.css # UI 스타일링 (인트로, 옵션 drawer, 좌측 HUD 레일, 좌측 하단 킬로그, 상단 전투 안내바) ├── styles.css # UI 스타일링 (인트로, 옵션 drawer, 좌측 HUD 레일, 좌측 하단 킬로그, 상단 전투 안내바)
├── game/ # 게임 로직 모듈 ├── game/ # 게임 로직 모듈 (역할별 하위 폴더 구성)
│ ├── ArenaScene.js # 핵심 게임 씬 (카메라 추적, 승리 판정, pause, 사망 통계, 좌측 HUD badge/킬로그 제어) │ ├── arena/ # 아레나 및 씬 관리
│ ├── arenaRenderer.js# 경기장 바닥 및 격자 렌더링 │ │ ├── ArenaScene.js # 메인 게임 씬 (Orchestrator, 생명주기 및 모듈 조율)
│ ├── combat.js # 전투 AI 및 피격 판정 로직 │ │ ├── arenaRenderer.js# 경기장 바닥 및 격자 렌더링
│ ├── combatSettings.js# 전투 속도 및 이동 배율 관리 │ │ └── arenaSpectatorCamera.js # 지능형 관전 카메라 및 줌 로직
│ ├── fighterAssets.js# 스프라이트 시트 로드, 애니메이션 및 팀 색상 실루엣 마스크 생성 │ ├── combat/ # 전투 시스템
│ ├── fighterFactory.js# 캐릭터 객체, 히트박스, HUD, 개별 체력, 사망자 HUD 숨김 및 팀 색상 마커 동기화 │ │ ├── combat.js # 전투 AI, 투사체 및 피격 판정 핵심 엔진
│ ├── fighterManifest.js# 캐릭터 스킨/종족/전투/스탯/특성 데이터 정의 (20종 캐릭터 상세 설정) │ │ ├── combatSettings.js # 전투 속도 및 이동 배율 관리
│ ├── fighterSelection.js# 무작위 캐릭터 스킨 선택 로직 │ │ └── arenaFinalCombatEffects.js # 최종 교전 슬로우 모션 등 연출 효과
│ └── matchSetup.js # 닉네임 기반 팀 구성 및 스폰 좌표 계산 │ ├── fighter/ # 캐릭터 및 에셋
└── ui/ │ │ ├── fighterAssets.js # 스프라이트 로드 및 팀 실루엣 동적 생성
├── matchForm.js # 참가자 입력 폼, 팀 설정 UI 제어 및 localStorage 설정 유지 │ │ ├── fighterFactory.js # 캐릭터 인스턴스화 및 HUD 동기화
├── deathStats.js # 전투 사망 통계 API 호출 │ │ ├── fighterManifest.js # 20종 캐릭터 스탯/특성 상세 정의
└── visitorCounter.js# 방문자 체크 API 호출 및 UI 갱신 │ │ └── fighterSelection.js # 캐릭터 스킨 무작위 선택 로직
│ └── match/ # 매치 및 진행
│ ├── matchSetup.js # 팀 구성 및 스폰 좌표 계산 (구역/랜덤)
│ └── arenaMatchRuntime.js # 매치 진행 중 헬퍼 (스폰 클러스터, 팀 크기 동기화)
└── ui/ # UI 컴포넌트 및 API 연동
├── arenaKillLog.js # [New] 독립된 킬로그 DOM 조작 모듈
├── arenaScoreboard.js # [New] 팀 스코어 badge 업데이트 모듈
├── battleDeathNotice.js # [New] 상단 사망 공지 메시지 및 UI 관리
├── victoryCelebration.js # [New] 승리 축하 연출 (DOM/Audio) 모듈
├── matchForm.js # 설정 폼 제어 및 localStorage 유지
├── deathStats.js # 사망 통계 API 호출 래퍼
└── visitorCounter.js # 방문자 체크 API 호출 및 표시
``` ```
## 3. 핵심 기능 ## 3. 상세 기술 가이드 (Context Routing)
- **닉네임 기반 팀 스폰**: 입력된 각 닉네임을 독립된 팀으로 인식하고, 설정된 인원(1~100명)만큼 분신 캐릭터를 소환합니다. 캐릭터 특성에 따라 실제 출전 수가 달라질 수 있으며, Slime은 배정된 기본 스폰 슬롯마다 10마리로 확장됩니다. 토큰 절약 및 효율적인 정보 조회를 위해 상세 로직은 기능별로 분리되어 보관됩니다. 특정 모듈 작업 시 아래의 관련 문서를 먼저 읽으십시오.
- **전투 진입 및 제어 UI**: 최초 접속 화면은 투명 전투 프리뷰를 배경으로 `Arena` 로고와 `Start` 버튼을 표시합니다. `Start`를 누르면 우측 옵션 drawer가 열리고, 전투 시작 후에도 drawer는 compact 패널로 유지됩니다. 패널 우측 상단의 `옵션 접기/옵션 펼치기` 버튼으로 전장 시야를 확보할 수 있으며, 패널 안에서 `재시작``일시정지/계속`을 제어합니다.
- **전투 설정 유지**: 참가자 닉네임과 팀당 인원은 브라우저 `localStorage`에 저장되어 새로고침하거나 다시 접속해도 입력값이 유지됩니다. - **[인프라 및 전역 설정] [context/core.md](./context/core.md)**: `main.js`, `constants.js`, 개발/유지보수 공통 규칙.
- **지능형 카메라 시스템**: - **[서버 및 API] [context/server.md](./context/server.md)**: Fastify 서버, MongoDB 연동, 방문자 및 사망 통계 API 상세.
- **자동 전투 관전**: 화면 확대 시 인접한 교전 중인 캐릭터 쌍을 찾아 부드럽게 추적(Lerp)합니다. - **[아레나 및 카메라] [context/arena.md](./context/arena.md)**: `ArenaScene` 오케스트레이션, 지능형 카메라 추적, 미니맵 가이드라인.
- **미니맵 연동**: 줌 인 상태에서 전장 전체 상황과 현재 뷰포트 위치를 미니맵에 가이드라인으로 표시합니다. - **[전투 엔진] [context/combat.md](./context/combat.md)**: 전투 AI, 투사체 판정, 처치 보상 성장, 슬로우모션 연출.
- **최종교전 연출**: 생존 4명 이하에서는 카메라가 생존 캐릭터를 무작위로 전환 포커싱하고, 잔여 2팀의 생존 합이 8명 이하이면 생존 수가 더 적은 팀을 포커싱합니다. 이 최종교전 구간에서 공격 모션이 시작되면 짧은 슬로우모션을 적용합니다. - **[캐릭터 및 에셋] [context/fighter.md](./context/fighter.md)**: 캐릭터 공장, 동적 실루엣 생성, 종족 및 특성(Slime 등) 정의.
- **역동적인 전투 연출**: - **[매치 로직 및 UI] [context/match-ui.md](./context/match-ui.md)**: 팀 구성 및 스폰 알고리즘, HUD 레이아웃, 킬로그, 승리 연출 UI.
- 캐릭터별 고유 공격 방식(근접, 투사체, 마법) 및 애니메이션.
- `ATTACK_DAMAGE_MIN/MAX`로 기본 공격력 범위를 관리하고, 치명타(Critical) 발생 시 즉시 처치 및 화면 흔들림 효과를 적용합니다.
- 상대를 처치한 캐릭터는 체력을 현재 체력 기준 30% 회복하고, 처치 누적 배율에 따라 크기, 공격속도, 이동속도가 함께 상승합니다. 누적 보상은 `KILL_GROWTH_MAX_MULTIPLIER` 상한으로 제한해 캐릭터가 필드/히트박스를 벗어나 전투가 끝나지 않는 상황을 방지합니다.
- 캐릭터별 종족(`human`, `orc`, `skeleton`, `slime`, `wolf`, `bear`)과 스탯/특성을 `fighterManifest.js`에서 정의할 수 있습니다. Slime은 최대 체력 1이며, 사망 시 50% 확률로 최대 체력 1인 Slime 2마리로 분열합니다. 분열체는 다시 분열하지 않습니다.
- 사망한 캐릭터는 반투명하게 표시하고 살아있는 캐릭터보다 낮은 depth로 내려, 움직이는 전투원을 가리지 않도록 유지합니다.
- **상시 팀 색상 표시 및 선택 관전**: 생존 캐릭터는 항상 팀 색상 실루엣 마커를 표시합니다. 캐릭터를 클릭하면 해당 캐릭터에 카메라가 고정되며, 팀 색상 마커는 선택 여부와 관계없이 유지됩니다. 좌측 팀 badge를 클릭하면 해당 팀의 생존 캐릭터 중 무작위 1명으로 시점이 고정됩니다.
- **실시간 경기 중계 UI**: 팀 badge는 경기장 밖 좌측 HUD 레일에 고정되어 미니맵을 가리지 않습니다. 각 badge는 팀명, 구분선, 현재 생존 인원 순서로 표시되며, 클릭 가능한 관전 진입점으로 동작합니다. 전투 중 하단 안내바는 숨기고, 좌측 하단 킬로그에 처치자와 피처치자를 좌우로 나눠 작은 캐릭터 이미지, 팀명, `manifest.key`, 칼 아이콘, `처치 >` 방향 표시를 목록으로 보여줍니다. 실제 전투가 5초 이상 지속되면 작은 상단 안내바에 오늘의 종족별 사망 집계를 2초 표시하고 10초 쉬는 주기로 재치 있게 보여줍니다. 승리 시 대형 배너로 결과를 알립니다.
- **유니크 방문자 체크**: 접속 시 `POST /api/visitors/check`를 호출하고, 서버가 `HttpOnly` 쿠키 기반 UUID로 MongoDB에 방문자 1명당 1개 문서를 유지합니다. 방문자 수는 메인 화면이 아니라 실제 전투 화면의 우측 하단에 작은 배지로 표시합니다.
- **전투 사망 통계**: 실제 전투에서 사망한 캐릭터를 종족별로 누적하고, 전투 종료 시 `POST /api/death-stats/today`로 오늘 일자별 집계 문서에 카운트를 더합니다. 별도의 매치별 사망 통계 문서는 저장하지 않습니다.
## 4. 기술 사양 ## 4. 기술 사양

30
context/arena.md Normal file
View File

@ -0,0 +1,30 @@
# 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) * SPECTATOR_CAMERA_LERP;
```
최종교전 관전은 두 단계로 나뉩니다.
- **생존 4명 이하**: `SPECTATOR_RANDOM_FOCUS_INTERVAL`마다 생존 캐릭터 중 한 명을 무작위로 포커싱합니다.
- **2팀 잔여 & 합계 8명 이하**: 더 적은 생존 수를 가진 팀의 중앙을 포커싱하며, 동률이면 기존 교전쌍 중심 포커싱으로 되돌아갑니다.
### 미니맵 가이드라인
미니맵은 전장 전체를 축소하여 보여주는 독립된 카메라입니다. 주 카메라가 비추는 영역을 계산하여 미니맵 위에 사각형(`graphics`)을 그려줍니다.
- `camera.displayWidth / zoom` 등을 이용하여 현재 월드에서 보이는 실제 영역 크기를 계산합니다.
- 뷰포트 사각형 좌표는 미니맵 픽셀 격자에 맞춰 반올림하고, 외곽 stroke가 겹쳐 검게 깨지지 않도록 노란 내부 선을 채운 직사각형으로 렌더링합니다.
### 씬 상태 관리
- **프리뷰 모드 (`presentationMode`)**: 최초 로드 시 조용히 실행되는 배경 전투입니다. 로컬 저장 옵션과 무관하게 10팀 x 5명 고정 규모로 동작합니다.
- **일시정지 (`setPaused`)**: 실제 전투에서 물리, Phaser 타이머, tween, 스프라이트 애니메이션을 함께 제어합니다. 프리뷰 및 종료된 전투는 제외됩니다.

29
context/combat.md Normal file
View File

@ -0,0 +1,29 @@
# Context: Combat System
## 1. 모듈별 상세 역할 (`src/game/combat/`)
- **`combat.js`**: 전투 AI, 피해 계산, 처치 보상 등 핵심 전투 로직을 담당합니다. 유닛의 이동, 공격, 투사체 발사 등을 처리합니다.
- **`combatSettings.js`**: 전투 속도 배율 등 런타임 전투 설정을 관리합니다.
- **`arenaFinalCombatEffects.js`**: 최종 교전 시 슬로우 모션 등 연출 효과를 담당합니다. 수학적인 이징(easing) 함수와 물리 시간 배율 계산을 포함합니다.
## 2. 주요 로직 구현 세부 사항
### 전투 AI 및 유닛 동작
- **`updateFighter()`**: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
- **`applyHit()`**: 일반 공격 피해량은 `ATTACK_DAMAGE_MIN/MAX` 범위에서 계산하고, 치명타 적중은 `Critical!` 표기와 즉시 처치/카메라 흔들림을 처리합니다.
- **`projectilePathHitsDefender()`**: 투사체가 대상을 스쳐 지나가지 않도록 궤적(Line)과 히트박스(Rectangle) 겹침 검사를 수행합니다.
### 처치 보상 및 성장
- **`applyKillReward()`**: 처치한 캐릭터의 체력 회복(현재 체력 30%), 크기 증가, 공격속도/이동속도 배율 증가를 처리합니다. 누적 배율은 `KILL_GROWTH_MAX_MULTIPLIER`로 제한합니다.
- **`clampFighterInsideArena()`**: 처치 성장 중 커진 캐릭터가 전장 바깥으로 나가지 않도록 위치를 보정합니다.
### 최종교전 슬로우모션
`FINAL_COMBAT_SLOW_MOTION_ENABLED`가 활성화된 경우:
- 최종교전 상태에서 공격 모션이 시작될 때 전역 time scale을 낮춥니다.
- 진입/유지/복귀 속도 램프(Ease)를 적용합니다.
- Arcade Physics는 timeScale 방향이 반대라 물리 이동에는 역수 배율을 적용합니다.
## 3. 유지보수 규칙
- **처치 성장 상한**: `src/constants.js``KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
- **공격력 조정**: `src/constants.js``ATTACK_DAMAGE_MIN/MAX`를 수정합니다.
- **특수 규칙**: 캐릭터별 특수 공격 방식은 `fighterManifest.js``combat` 설정을 확인합니다.

28
context/core.md Normal file
View File

@ -0,0 +1,28 @@
# Context: Core & Infrastructure
## 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`**: 게임 내 모든 튜닝 수치를 관리합니다.
- `ATTACK_DAMAGE_MIN`, `ATTACK_DAMAGE_MAX`: 일반 공격 1회 적중 시 적용되는 랜덤 피해량 범위.
- `FIGHTER_HITBOX_*`: 100x100 캐릭터 프레임 안에서 실제 충돌 판정이 놓이는 위치와 크기.
- `KILL_HEALTH_RECOVERY_RATIO`, `KILL_GROWTH_MULTIPLIER`, `KILL_GROWTH_MAX_MULTIPLIER`: 처치 후 회복량, 크기/공격속도/이동속도 성장 배율, 누적 보상 상한.
- `SELECTED_FIGHTER_OUTLINE_GAP`, `SELECTED_FIGHTER_OUTLINE_WIDTH`, `SELECTED_FIGHTER_OUTLINE_ALPHA`: 팀 색상 실루엣 마커의 캐릭터 이격 거리, 두께, 투명도.
- `TEAM_COLORS`, `getTeamColor()`: 8팀 이하에서는 기본 팔레트를 쓰고, 9팀 이상에서는 팀 수에 맞춰 중복 없는 색상을 동적으로 생성합니다.
- `SPECTATOR_CAMERA_LERP`: 카메라 추적의 부드러움 정도.
- `SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD`, `SPECTATOR_RANDOM_FOCUS_INTERVAL`, `FINAL_COMBAT_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` 또는 `fighterManifest.js` 내 개별 설정을 통해 변경하십시오.
- **처치 성장 상한 조정**: 처치 보상으로 캐릭터가 커지는 최대치와 공격/이동 배율 상한은 `src/constants.js``KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
- **공격력 조정**: 기본 피해량은 `src/constants.js``ATTACK_DAMAGE_MIN`, `ATTACK_DAMAGE_MAX`를 수정합니다. 캐릭터별 특수 공격 방식은 `fighterManifest.js``combat` 설정을 우선 확인합니다.
- **DOM 접근**: 성능을 위해 `ArenaScene`은 좌측 HUD badge 등 필요한 시점에만 최소한으로 DOM에 접근합니다.
- **패키지 락 파일**: 이 프로젝트는 `package-lock.json`을 저장소에서 제외합니다. 의존성 변경 시 `package.json`을 기준으로 관리합니다.

31
context/fighter.md Normal file
View File

@ -0,0 +1,31 @@
# Context: Fighter & Assets
## 1. 모듈별 상세 역할 (`src/game/fighter/`)
- **`fighterAssets.js`**: 캐릭터 스프라이트 로드 및 애니메이션/실루엣 생성을 담당합니다. 원본 이미지로부터 팀 색상 마커용 실루엣을 동적으로 생성합니다.
- **`fighterFactory.js`**: 캐릭터 인스턴스화 및 HUD(이름표, 체력바) 관리를 담당합니다. Phaser Sprite와 DOM UI 사이의 가교 역할을 합니다.
- **`fighterManifest.js`**: 모든 캐릭터 종족 및 스탯 데이터를 정의합니다. 20여 종의 캐릭터 설정이 포함되어 있습니다.
- **`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 및 상태 동기화
- **이름표 고정**: 스프라이트 중심이 아닌 실제 히트박스 하단에 고정되어 시각적 일관성을 유지합니다.
- **사망자 처리**: 사망 시 HUD와 팀 마커를 숨겨 화면 가독성을 높입니다. 본체 sprite만 낮은 depth와 반투명 상태로 남깁니다.
### 캐릭터별 특성 (예: Slime)
- **`spawnMultiplier`**: 배정된 슬롯 1개를 지정된 수만큼 확장하여 스폰합니다.
- **`splitOnDeath`**: 사망 시 확률적으로 지정된 수만큼 분열체를 생성합니다.
- **스탯 상한**: 처치 보상은 현재 체력을 회복시키지만 `maxHp`를 넘을 수 없습니다. (예: Slime은 항상 1 HP)
## 3. 유지보수 규칙
- **신규 캐릭터**: 에셋 배치 후 `fighterManifest.js`에 정의를 추가합니다.
- **종족값**: 사망 통계를 위해 지정된 6개 종족 중 하나를 반드시 선택해야 합니다.
- **캐릭터 특성**: 전투 중 새 유닛을 생성하는 특성은 `ArenaScene.js`의 생성 헬퍼를 통해 `this.fighters``team.size`를 함께 갱신해야 합니다.

30
context/match-ui.md Normal file
View File

@ -0,0 +1,30 @@
# Context: Match & UI
## 1. 모듈별 상세 역할
### 매치 로직 (`src/game/match/`)
- **`matchSetup.js`**: 매치 초기화, 팀 구성, 스폰 위치 계산을 담당합니다.
- **`arenaMatchRuntime.js`**: 스폰 클러스터 계산 및 팀 크기 동기화 등 매치 진행 중 헬퍼 기능을 제공합니다.
### UI 컴포넌트 (`src/ui/`)
- **`matchForm.js`**: 설정 폼 제어 및 `localStorage` 설정 유지.
- **`arenaScoreboard.js`**: 좌측 HUD 레일의 팀 badge 업데이트 및 관전 시점 전환.
- **`arenaKillLog.js`**: 좌측 하단 킬로그 표시 및 관리.
- **`battleDeathNotice.js`**: 상단 사망 통계 공지 UI.
- **`victoryCelebration.js`**: 승리/무승부 축하 연출 (광선, 컨페티, 오디오).
## 2. 주요 로직 구현 세부 사항
### 매치 설정 및 스폰 배치
- **완전 랜덤 배치**: 전장 전체 스폰 슬롯을 무작위로 섞어 배치합니다.
- **스타팅 지점 배치**: 참가자 수에 맞춰 전장을 구역으로 나눈 뒤, 참가자별 구역 배정을 매치마다 섞고 구역 내 무작위 위치에 스폰합니다.
- **설정 유지**: 닉네임, 인원, 배치 모드는 `localStorage`에 저장되어 재접속 시 복원됩니다.
### 전투 화면 레이아웃 (HUD)
- **팀 Badge**: 좌측 HUD 레일에 배치되며, 클릭 시 해당 팀의 생존 유닛 중 무작위 1명으로 시점을 고정합니다.
- **킬로그**: 처치자와 피처치자를 좌우로 배치하고, 피처치자 아이콘에 빨간 X를 겹쳐 사망 관계를 명확히 표시합니다.
- **승리 연출**: 승리 시 Web Audio 기반 팡파르와 CSS 애니메이션(광선, 컨페티)을 결합해 화려하게 연출합니다. 무승부는 더 차분한 톤을 사용합니다.
## 3. UI 개발 규칙
- **DOM 접근 최소화**: 성능 최적화를 위해 필요한 시점에만 최소한으로 DOM을 업데이트합니다.
- **반응형 상태**: `#app`의 클래스(`match-live`, `options-open`, `drawer-collapsed`, `match-paused`)를 통해 전반적인 UI 상태를 제어합니다.

View File

@ -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`
- 검증 결과 요약

46
context/server.md Normal file
View File

@ -0,0 +1,46 @@
# 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`) 기본값을 제공합니다.
- **`server/db.js`**:
- `MongoClient`를 한 번 생성한 뒤 재사용하여 MongoDB 커넥션 풀을 유지합니다.
- 종료 시 `closeMongoConnection()`으로 커넥션을 닫습니다.
- **`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. 주요 로직 구현 세부 사항
### 유니크 방문자 체크
브라우저가 직접 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()`로 전체 유니크 방문자 수를 계산해 반환합니다.
### 전투 사망 통계
프리뷰 전투는 통계에서 제외하고, 사용자가 시작한 실제 전투만 저장합니다.
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` 등으로 관리합니다.
- **API 변경**: `/api/*` 경로는 Fastify 라우트가 담당하며, 개발 모드에서 Vite 미들웨어보다 우선순위를 가집니다.

View File

@ -26,7 +26,13 @@
<div id="score-left" class="score-side left"></div> <div id="score-left" class="score-side left"></div>
<div id="score-right" class="score-side right"></div> <div id="score-right" class="score-side right"></div>
</div> </div>
<div id="battle-notice" class="battle-notice" role="status" aria-live="polite" aria-hidden="true"></div> <div
id="battle-notice"
class="battle-notice"
role="status"
aria-live="polite"
aria-hidden="true"
></div>
<div id="game"></div> <div id="game"></div>
<div class="battle-preview" aria-hidden="true"> <div class="battle-preview" aria-hidden="true">
<span class="preview-fighter preview-knight"></span> <span class="preview-fighter preview-knight"></span>
@ -45,13 +51,21 @@
> >
<ol id="kill-log-list" class="kill-log-list"></ol> <ol id="kill-log-list" class="kill-log-list"></ol>
</section> </section>
<div id="match-status" class="match-status" role="status" aria-live="polite" aria-hidden="true"> <div
id="match-status"
class="match-status"
role="status"
aria-live="polite"
aria-hidden="true"
>
<div class="status-track"> <div class="status-track">
<span data-status-text>옵션 대기 중</span> <span data-status-text>옵션 대기 중</span>
<span data-status-text>옵션 대기 중</span> <span data-status-text>옵션 대기 중</span>
</div> </div>
</div> </div>
<p id="visitor-count" class="visitor-count" aria-live="polite">방문자 확인 중</p> <p id="visitor-count" class="visitor-count" aria-live="polite">
방문자 확인 중
</p>
</section> </section>
<section class="intro-stage" aria-label="Arena 시작 화면"> <section class="intro-stage" aria-label="Arena 시작 화면">
@ -69,9 +83,19 @@
</div> </div>
</section> </section>
<button id="drawer-scrim" class="drawer-scrim" type="button" aria-label="옵션 닫기"></button> <button
id="drawer-scrim"
class="drawer-scrim"
type="button"
aria-label="옵션 닫기"
></button>
<aside id="fighter-entry" class="fighter-entry" aria-label="전투 옵션 입력" aria-hidden="true"> <aside
id="fighter-entry"
class="fighter-entry"
aria-label="전투 옵션 입력"
aria-hidden="true"
>
<div class="drawer-header"> <div class="drawer-header">
<div class="entry-copy"> <div class="entry-copy">
<p class="eyebrow">Match Options</p> <p class="eyebrow">Match Options</p>
@ -87,14 +111,22 @@
> >
옵션 접기 옵션 접기
</button> </button>
<button id="drawer-close" class="drawer-close" type="button" aria-label="옵션 닫기">X</button> <button
id="drawer-close"
class="drawer-close"
type="button"
aria-label="옵션 닫기"
>
X
</button>
</div> </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">
Player 1
Player 2 Player 2
Player 3 Player 3
Player 4 Player 4
@ -103,20 +135,75 @@ Player 6
Player 7 Player 7
Player 8 Player 8
Player 9 Player 9
Player 10</textarea> Player 10</textarea
>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>Match</legend> <legend>Match</legend>
<div class="team-size-row"> <div class="team-size-row">
<label for="team-size">팀당 인원</label> <label for="team-size">팀당 인원</label>
<output id="team-size-value" for="team-size">5</output> <input
id="team-size-value"
class="team-size-number"
type="number"
min="1"
max="100"
step="1"
value="5"
inputmode="numeric"
aria-label="팀당 인원 직접 입력"
/>
</div>
<input
id="team-size"
name="teamSize"
type="range"
min="1"
max="100"
value="5"
/>
<div class="spawn-placement-field">
<span id="spawn-placement-label" class="spawn-placement-label"
>리스폰 설정</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>
<div class="match-actions"> <div class="match-actions">
<button type="submit">전투 시작</button> <button type="submit">전투 시작</button>
<button id="restart-button" class="restart-button" type="button">재시작</button> <button id="restart-button" class="restart-button" type="button">
<button id="pause-button" class="pause-button" type="button" aria-pressed="false">일시정지</button> 재시작
</button>
<button
id="pause-button"
class="pause-button"
type="button"
aria-pressed="false"
>
일시정지
</button>
</div> </div>
</form> </form>
</aside> </aside>

View File

@ -15,6 +15,17 @@ export const ATTACK_DAMAGE_MIN = 14;
export const ATTACK_DAMAGE_MAX = 24; export const ATTACK_DAMAGE_MAX = 24;
// 새 매치가 시작될 때 기본 팀당 캐릭터 수입니다. // 새 매치가 시작될 때 기본 팀당 캐릭터 수입니다.
export const DEFAULT_TEAM_SIZE = 5; export const DEFAULT_TEAM_SIZE = 5;
// 전투 시작 시 전투원을 배치하는 기본 방식입니다.
export const DEFAULT_SPAWN_PLACEMENT = "random";
// 전투 설정 UI와 매치 생성 로직이 공유하는 스폰 배치 모드입니다.
export const SPAWN_PLACEMENTS = {
RANDOM: DEFAULT_SPAWN_PLACEMENT,
STARTING_ZONES: "starting-zones",
};
// 최초 접속 대기 전투에서 고정으로 보여줄 팀 수입니다.
export const PRESENTATION_TEAM_COUNT = 10;
// 최초 접속 대기 전투에서 팀마다 배치할 전투원 수입니다.
export const PRESENTATION_TEAM_SIZE = 5;
// 캐릭터 스프라이트의 기본 화면 배율입니다. // 캐릭터 스프라이트의 기본 화면 배율입니다.
export const FIGHTER_SCALE = 3; export const FIGHTER_SCALE = 3;
export const FIGHTER_DEPTH = 2; export const FIGHTER_DEPTH = 2;
@ -90,9 +101,7 @@ export const MINIMAP_ALPHA = 0.8;
export const MINIMAP_MARGIN = Math.round(ARENA_SIZE * 0.016); export const MINIMAP_MARGIN = Math.round(ARENA_SIZE * 0.016);
// 미니맵의 고정 픽셀 크기입니다. // 미니맵의 고정 픽셀 크기입니다.
export const MINIMAP_VIEWPORT_SIZE = Math.round(ARENA_SIZE * 0.22); export const MINIMAP_VIEWPORT_SIZE = Math.round(ARENA_SIZE * 0.22);
// 미니맵 현재 뷰포트 표시용 바깥 윤곽선 두께입니다. // 미니맵 현재 뷰포트 표시용 선 두께입니다.
export const MINIMAP_VIEW_FRAME_OUTLINE = 18;
// 미니맵 현재 뷰포트 표시용 안쪽 선 두께입니다.
export const MINIMAP_VIEW_FRAME_STROKE = 10; export const MINIMAP_VIEW_FRAME_STROKE = 10;
// 관전 카메라가 목표 전투 지점으로 따라가는 부드러움입니다. // 관전 카메라가 목표 전투 지점으로 따라가는 부드러움입니다.
export const SPECTATOR_CAMERA_LERP = 0.1; export const SPECTATOR_CAMERA_LERP = 0.1;
@ -103,8 +112,15 @@ export const SPECTATOR_FINAL_FIGHT_ZOOM = 3;
export const SPECTATOR_FINAL_TEAM_COUNT = 2; export const SPECTATOR_FINAL_TEAM_COUNT = 2;
export const SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD = 8; export const SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD = 8;
export const SPECTATOR_RANDOM_FOCUS_INTERVAL = 2400; export const SPECTATOR_RANDOM_FOCUS_INTERVAL = 2400;
export const FINAL_COMBAT_SLOW_MOTION_DURATION = 520; // 최종교전 슬로우모션 연출을 켜고 끕니다.
export const FINAL_COMBAT_SLOW_MOTION_SCALE = 0.35; export const FINAL_COMBAT_SLOW_MOTION_ENABLED = false;
// 최종교전 공격 시작에서 슬로우 배율로 내려가는 속도 램프 시간(ms)입니다.
export const FINAL_COMBAT_SLOW_MOTION_ENTER_DURATION = 14000;
// 최종교전 공격을 슬로우 배율로 붙잡아 두는 시간(ms)입니다.
export const FINAL_COMBAT_SLOW_MOTION_HOLD_DURATION = 14000;
// 최종교전 슬로우에서 기본 속도로 복귀하는 속도 램프 시간(ms)입니다.
export const FINAL_COMBAT_SLOW_MOTION_EXIT_DURATION = 14000;
export const FINAL_COMBAT_SLOW_MOTION_SCALE = 0.28;
// 생존자가 이 수보다 적으면 후반 전투 줌을 적용합니다. // 생존자가 이 수보다 적으면 후반 전투 줌을 적용합니다.
export const SPECTATOR_LATE_FIGHTER_THRESHOLD = 30; export const SPECTATOR_LATE_FIGHTER_THRESHOLD = 30;
// 후반 전투 구간에서 강제로 적용되는 카메라 줌입니다. // 후반 전투 구간에서 강제로 적용되는 카메라 줌입니다.

View File

@ -4,12 +4,14 @@ import {
CAMERA_MAX_ZOOM, CAMERA_MAX_ZOOM,
CAMERA_MIN_ZOOM, CAMERA_MIN_ZOOM,
CAMERA_ZOOM_STEP, CAMERA_ZOOM_STEP,
FINAL_COMBAT_SLOW_MOTION_DURATION, FINAL_COMBAT_SLOW_MOTION_ENABLED,
FINAL_COMBAT_SLOW_MOTION_ENTER_DURATION,
FINAL_COMBAT_SLOW_MOTION_EXIT_DURATION,
FINAL_COMBAT_SLOW_MOTION_HOLD_DURATION,
FINAL_COMBAT_SLOW_MOTION_SCALE, FINAL_COMBAT_SLOW_MOTION_SCALE,
MINIMAP_ALPHA, MINIMAP_ALPHA,
MINIMAP_MARGIN, MINIMAP_MARGIN,
MINIMAP_VIEWPORT_SIZE, MINIMAP_VIEWPORT_SIZE,
MINIMAP_VIEW_FRAME_OUTLINE,
MINIMAP_VIEW_FRAME_STROKE, MINIMAP_VIEW_FRAME_STROKE,
SELECTED_FIGHTER_CAMERA_ZOOM, SELECTED_FIGHTER_CAMERA_ZOOM,
SPECTATOR_CAMERA_LERP, SPECTATOR_CAMERA_LERP,
@ -20,15 +22,50 @@ import {
SPECTATOR_LATE_FIGHTER_THRESHOLD, SPECTATOR_LATE_FIGHTER_THRESHOLD,
SPECTATOR_LATE_FIGHT_ZOOM, SPECTATOR_LATE_FIGHT_ZOOM,
SPECTATOR_RANDOM_FOCUS_INTERVAL, SPECTATOR_RANDOM_FOCUS_INTERVAL,
} from "../constants.js"; } from "../../constants.js";
import { drawArena } from "./arenaRenderer.js"; import { drawArena } from "./arenaRenderer.js";
import { clearCombatObjects, updateFighter } from "./combat.js"; import { clearCombatObjects, updateFighter } from "../combat/combat.js";
import { createFighterAnimations, preloadFighterSheets } from "./fighterAssets.js"; import { createFighterAnimations, preloadFighterSheets } from "../fighter/fighterAssets.js";
import { createFighter, syncFighterHud } from "./fighterFactory.js"; import { createFighter, syncFighterHud } from "../fighter/fighterFactory.js";
import { fighterManifest } from "./fighterManifest.js"; import { fighterManifest } from "../fighter/fighterManifest.js";
import { pickFighters } from "./fighterSelection.js"; import { pickFighters } from "../fighter/fighterSelection.js";
import { createMatchSetup, matchStatusText } from "./matchSetup.js"; import { createMatchSetup, matchStatusText } from "../match/matchSetup.js";
import { addTodayDeathStats, fetchTodayDeathStats } from "../ui/deathStats.js"; import { addTodayDeathStats, fetchTodayDeathStats } from "../../ui/deathStats.js";
import { createFighterPlans, clusterSpawnPosition, syncTeamSizes } from "../match/arenaMatchRuntime.js";
import {
createDeathCounts,
normalizeDeathCounts,
addDeathCounts,
normalizeSpecies,
createDeathNoticeMessage,
} from "../../ui/battleDeathNotice.js";
import {
getSpectatorState,
averageFighterPosition,
fighterCameraPoint,
findClosestOpponentPair,
isLivingOpponentPair,
isLivingFighter,
} from "./arenaSpectatorCamera.js";
import {
easeOutCubic,
easeInOutCubic,
arcadePhysicsTimeScale,
} from "../combat/arenaFinalCombatEffects.js";
import {
createVictoryCelebration,
primeVictoryFanfareAudio,
removeVictoryCelebration,
} from "../../ui/victoryCelebration.js";
import { updateScoreboard } from "../../ui/arenaScoreboard.js";
import { appendKillLog, resetKillLog } from "../../ui/arenaKillLog.js";
import {
BATTLE_NOTICE_DELAY_MS,
BATTLE_NOTICE_INTERVAL_MS,
BATTLE_NOTICE_VISIBLE_MS,
clearBattleNotice,
showBattleDeathNotice,
} from "../../ui/battleDeathNotice.js";
export class ArenaScene extends Phaser.Scene { export class ArenaScene extends Phaser.Scene {
constructor({ getInitialMatchConfig, setStatus }) { constructor({ getInitialMatchConfig, setStatus }) {
@ -44,14 +81,10 @@ export class ArenaScene extends Phaser.Scene {
this.setStatus = (message) => { this.setStatus = (message) => {
this.updateStatus(message); this.updateStatus(message);
const oldBanner = document.querySelector(".victory-banner"); removeVictoryCelebration();
if (oldBanner) oldBanner.remove();
if (message.includes("승리") || message.includes("무승부")) { if (message.includes("승리") || message.includes("무승부")) {
const banner = document.createElement("div"); createVictoryCelebration(message);
banner.className = "victory-banner";
banner.textContent = message;
document.querySelector(".arena-shell")?.appendChild(banner);
} }
}; };
this.observedCombat = []; this.observedCombat = [];
@ -71,6 +104,7 @@ export class ArenaScene extends Phaser.Scene {
this.spectatorMode = null; this.spectatorMode = null;
this.slowMotionRestoreState = null; this.slowMotionRestoreState = null;
this.slowMotionTimer = null; this.slowMotionTimer = null;
this.slowMotionTransitionFrame = null;
} }
preload() { preload() {
@ -125,7 +159,7 @@ export class ArenaScene extends Phaser.Scene {
this.startMatch(this.getInitialMatchConfig(), { silent: true }); this.startMatch(this.getInitialMatchConfig(), { silent: true });
} }
startMatch({ names = [], teamSize } = {}, { silent = false } = {}) { startMatch({ names = [], spawnPlacement, teamSize } = {}, { silent = false } = {}) {
if (!this.ready) { if (!this.ready) {
return; return;
} }
@ -135,9 +169,15 @@ export class ArenaScene extends Phaser.Scene {
return; return;
} }
const matchSetup = createMatchSetup(names, teamSize); if (!silent) {
primeVictoryFanfareAudio();
}
const matchSetup = createMatchSetup(names, teamSize, spawnPlacement);
const matchSkins = pickFighters(fighterManifest, matchSetup.fighters.length); const matchSkins = pickFighters(fighterManifest, matchSetup.fighters.length);
const fighterPlans = createFighterPlans(matchSetup.fighters, matchSkins); const fighterPlans = createFighterPlans(matchSetup.fighters, matchSkins, {
expandSpawnMultipliers: !silent,
});
syncTeamSizes(matchSetup.teams, fighterPlans); syncTeamSizes(matchSetup.teams, fighterPlans);
this.matchId += 1; this.matchId += 1;
@ -201,14 +241,7 @@ export class ArenaScene extends Phaser.Scene {
} }
resetKillLog() { resetKillLog() {
const { logNode, listNode } = this.getKillLogNodes(); resetKillLog(this.getKillLogNodes());
if (listNode) {
listNode.replaceChildren();
}
logNode?.classList.remove("has-entries");
logNode?.setAttribute("aria-hidden", "true");
} }
resetMatchDeathStats({ silent = false } = {}) { resetMatchDeathStats({ silent = false } = {}) {
@ -257,10 +290,7 @@ export class ArenaScene extends Phaser.Scene {
this.battleNoticeTimer = null; this.battleNoticeTimer = null;
this.battleNoticeHideTimer = null; this.battleNoticeHideTimer = null;
const noticeNode = this.getBattleNoticeNode(); clearBattleNotice(this.getBattleNoticeNode());
noticeNode?.classList.remove("is-visible");
noticeNode?.setAttribute("aria-hidden", "true");
} }
recordDeath(fighter) { recordDeath(fighter) {
@ -279,19 +309,17 @@ export class ArenaScene extends Phaser.Scene {
return; return;
} }
noticeNode.textContent = createDeathNoticeMessage( const message = createDeathNoticeMessage(
addDeathCounts(this.deathStatsBaseline, this.battleDeathCounts), addDeathCounts(this.deathStatsBaseline, this.battleDeathCounts),
this.matchId + this.battleNoticeSequence, this.matchId + this.battleNoticeSequence,
); );
this.battleNoticeSequence += 1; this.battleNoticeSequence += 1;
noticeNode.classList.add("is-visible"); showBattleDeathNotice(noticeNode, message);
noticeNode.setAttribute("aria-hidden", "false");
this.battleNoticeHideTimer?.remove(false); this.battleNoticeHideTimer?.remove(false);
this.battleNoticeHideTimer = this.time.delayedCall(BATTLE_NOTICE_VISIBLE_MS, () => { this.battleNoticeHideTimer = this.time.delayedCall(BATTLE_NOTICE_VISIBLE_MS, () => {
this.battleNoticeHideTimer = null; this.battleNoticeHideTimer = null;
noticeNode.classList.remove("is-visible"); clearBattleNotice(noticeNode);
noticeNode.setAttribute("aria-hidden", "true");
if (!this.matchOver && !this.presentationMode) { if (!this.matchOver && !this.presentationMode) {
this.scheduleBattleNotice(BATTLE_NOTICE_INTERVAL_MS); this.scheduleBattleNotice(BATTLE_NOTICE_INTERVAL_MS);
@ -328,48 +356,7 @@ export class ArenaScene extends Phaser.Scene {
} }
this.recordDeath(defender); this.recordDeath(defender);
appendKillLog(this.getKillLogNodes(), winner, defender);
const { logNode, listNode } = this.getKillLogNodes();
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.append(item);
while (listNode.children.length > KILL_LOG_LIMIT) {
listNode.firstElementChild?.remove();
}
logNode.classList.add("has-entries");
logNode.setAttribute("aria-hidden", "false");
} }
getKillLogNodes() { getKillLogNodes() {
@ -499,27 +486,95 @@ update(time) {
} }
triggerFinalCombatSlowMotion() { triggerFinalCombatSlowMotion() {
if (this.presentationMode || this.matchOver || this.matchPaused || !this.isFinalCombatActive()) { if (
!FINAL_COMBAT_SLOW_MOTION_ENABLED
|| this.presentationMode
|| this.matchOver
|| this.matchPaused
|| !this.isFinalCombatActive()
) {
return; return;
} }
if (!this.slowMotionRestoreState) { if (this.slowMotionRestoreState) {
this.slowMotionRestoreState = { return;
animations: this.anims?.globalTimeScale ?? 1,
clock: this.time?.timeScale ?? 1,
physics: this.physics?.world?.timeScale ?? 1,
tweens: this.tweens?.timeScale ?? 1,
};
this.applySceneTimeScale(FINAL_COMBAT_SLOW_MOTION_SCALE);
} }
if (this.slowMotionTimer) { this.slowMotionRestoreState = {
globalThis.clearTimeout(this.slowMotionTimer); animations: this.anims?.globalTimeScale ?? 1,
clock: this.time?.timeScale ?? 1,
physics: this.physics?.world?.timeScale ?? 1,
tweens: this.tweens?.timeScale ?? 1,
};
this.transitionSceneTimeScale(
FINAL_COMBAT_SLOW_MOTION_SCALE,
FINAL_COMBAT_SLOW_MOTION_ENTER_DURATION,
easeOutCubic,
() => this.holdFinalCombatSlowMotion(),
);
}
holdFinalCombatSlowMotion() {
if (!this.slowMotionRestoreState) {
return;
} }
this.slowMotionTimer = globalThis.setTimeout(() => { this.slowMotionTimer = globalThis.setTimeout(() => {
this.clearFinalCombatEffects(); this.slowMotionTimer = null;
}, FINAL_COMBAT_SLOW_MOTION_DURATION); this.releaseFinalCombatSlowMotion();
}, FINAL_COMBAT_SLOW_MOTION_HOLD_DURATION);
}
releaseFinalCombatSlowMotion() {
const restore = this.slowMotionRestoreState;
if (!restore) {
return;
}
this.transitionSceneTimeScale(
restore.clock,
FINAL_COMBAT_SLOW_MOTION_EXIT_DURATION,
easeInOutCubic,
() => {
if (this.slowMotionRestoreState !== restore) {
return;
}
this.slowMotionRestoreState = null;
this.restoreSceneTimeScale(restore);
},
);
}
transitionSceneTimeScale(targetScale, duration, ease, onComplete) {
this.cancelSlowMotionTransition();
const startScale = this.time?.timeScale ?? 1;
if (duration <= 0 || Math.abs(startScale - targetScale) < 0.001) {
this.applySceneTimeScale(targetScale);
onComplete?.();
return;
}
const startedAt = globalThis.performance.now();
const updateScale = (now) => {
const progress = Math.min(1, Math.max(0, (now - startedAt) / duration));
this.applySceneTimeScale(startScale + (targetScale - startScale) * ease(progress));
if (progress >= 1) {
this.slowMotionTransitionFrame = null;
onComplete?.();
return;
}
this.slowMotionTransitionFrame = globalThis.requestAnimationFrame(updateScale);
};
this.slowMotionTransitionFrame = globalThis.requestAnimationFrame(updateScale);
} }
applySceneTimeScale(scale) { applySceneTimeScale(scale) {
@ -528,7 +583,8 @@ update(time) {
} }
if (this.physics?.world) { if (this.physics?.world) {
this.physics.world.timeScale = scale; // Arcade Physics uses larger timeScale values for slower world steps.
this.physics.world.timeScale = arcadePhysicsTimeScale(scale);
} }
if (this.tweens) { if (this.tweens) {
@ -541,30 +597,45 @@ update(time) {
} }
clearFinalCombatEffects() { clearFinalCombatEffects() {
if (this.slowMotionTimer) { this.clearSlowMotionTimer();
globalThis.clearTimeout(this.slowMotionTimer); this.cancelSlowMotionTransition();
this.slowMotionTimer = null;
}
if (this.slowMotionRestoreState) { if (this.slowMotionRestoreState) {
const restore = this.slowMotionRestoreState; const restore = this.slowMotionRestoreState;
this.slowMotionRestoreState = null; this.slowMotionRestoreState = null;
this.restoreSceneTimeScale(restore);
}
}
if (this.time) { clearSlowMotionTimer() {
this.time.timeScale = restore.clock; if (this.slowMotionTimer) {
} globalThis.clearTimeout(this.slowMotionTimer);
this.slowMotionTimer = null;
}
}
if (this.physics?.world) { cancelSlowMotionTransition() {
this.physics.world.timeScale = restore.physics; if (this.slowMotionTransitionFrame !== null) {
} globalThis.cancelAnimationFrame(this.slowMotionTransitionFrame);
this.slowMotionTransitionFrame = null;
}
}
if (this.tweens) { restoreSceneTimeScale(restore) {
this.tweens.timeScale = restore.tweens; if (this.time) {
} this.time.timeScale = restore.clock;
}
if (this.anims) { if (this.physics?.world) {
this.anims.globalTimeScale = restore.animations; this.physics.world.timeScale = restore.physics;
} }
if (this.tweens) {
this.tweens.timeScale = restore.tweens;
}
if (this.anims) {
this.anims.globalTimeScale = restore.animations;
} }
} }
@ -745,19 +816,45 @@ update(time) {
return; return;
} }
const frameWidth = Math.min(camera.displayWidth, ARENA_SIZE); const frameWidth = this.snapMinimapFrameValue(Math.min(camera.displayWidth, ARENA_SIZE));
const frameHeight = Math.min(camera.displayHeight, ARENA_SIZE); const frameHeight = this.snapMinimapFrameValue(Math.min(camera.displayHeight, ARENA_SIZE));
const scrollX = camera.useBounds ? camera.clampX(camera.scrollX) : camera.scrollX; const scrollX = camera.useBounds ? camera.clampX(camera.scrollX) : camera.scrollX;
const scrollY = camera.useBounds ? camera.clampY(camera.scrollY) : camera.scrollY; const scrollY = camera.useBounds ? camera.clampY(camera.scrollY) : camera.scrollY;
const cameraMidX = scrollX + camera.width / 2; const cameraMidX = scrollX + camera.width / 2;
const cameraMidY = scrollY + camera.height / 2; const cameraMidY = scrollY + camera.height / 2;
const frameX = Phaser.Math.Clamp(cameraMidX - frameWidth / 2, 0, ARENA_SIZE - frameWidth); const frameX = Phaser.Math.Clamp(
const frameY = Phaser.Math.Clamp(cameraMidY - frameHeight / 2, 0, ARENA_SIZE - frameHeight); this.snapMinimapFrameValue(cameraMidX - frameWidth / 2),
0,
ARENA_SIZE - frameWidth,
);
const frameY = Phaser.Math.Clamp(
this.snapMinimapFrameValue(cameraMidY - frameHeight / 2),
0,
ARENA_SIZE - frameHeight,
);
this.minimapViewportFrame.lineStyle(MINIMAP_VIEW_FRAME_OUTLINE, 0x080a05, 0.95); this.drawMinimapViewportFrame(frameX, frameY, frameWidth, frameHeight);
this.minimapViewportFrame.strokeRect(frameX, frameY, frameWidth, frameHeight); }
this.minimapViewportFrame.lineStyle(MINIMAP_VIEW_FRAME_STROKE, 0xffe4a8, 1);
this.minimapViewportFrame.strokeRect(frameX, frameY, frameWidth, frameHeight); drawMinimapViewportFrame(frameX, frameY, frameWidth, frameHeight) {
const stroke = Math.min(MINIMAP_VIEW_FRAME_STROKE, frameWidth, frameHeight);
const sideHeight = Math.max(0, frameHeight - stroke * 2);
this.minimapViewportFrame.fillStyle(0xffe4a8, 1);
this.minimapViewportFrame.fillRect(frameX, frameY, frameWidth, stroke);
this.minimapViewportFrame.fillRect(frameX, frameY + frameHeight - stroke, frameWidth, stroke);
this.minimapViewportFrame.fillRect(frameX, frameY + stroke, stroke, sideHeight);
this.minimapViewportFrame.fillRect(
frameX + frameWidth - stroke,
frameY + stroke,
stroke,
sideHeight,
);
}
snapMinimapFrameValue(value) {
const minimapZoom = this.minimapCamera?.zoom ?? 1;
return Math.round(value * minimapZoom) / minimapZoom;
} }
observeCombat(attacker, defender) { observeCombat(attacker, defender) {
@ -796,50 +893,16 @@ update(time) {
} }
updateScoreboard() { updateScoreboard() {
const scoreLeft = document.getElementById("score-left"); updateScoreboard(
const scoreRight = document.getElementById("score-right"); document.getElementById("score-left"),
document.getElementById("score-right"),
if (!scoreLeft || !scoreRight) return; this.teams,
this.fighters,
scoreLeft.innerHTML = ""; {
scoreRight.innerHTML = ""; selectedFighterTeamId: this.selectedFighter?.team.id,
onTeamClick: (teamId) => this.selectRandomTeamFighter(teamId),
this.teams.forEach((team) => { },
const aliveCount = this.fighters.filter( );
(f) => f.team.id === team.id && !f.isDead
).length;
const teamEl = document.createElement("button");
teamEl.className = "team-score";
teamEl.type = "button";
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}`;
if (this.selectedFighter?.team.id === team.id) {
teamEl.classList.add("is-focused");
}
const labelEl = document.createElement("span");
labelEl.className = "team-score-name";
labelEl.textContent = team.label;
const ruleEl = document.createElement("span");
ruleEl.className = "team-score-rule";
const countEl = document.createElement("span");
countEl.className = "team-score-count";
countEl.textContent = `${aliveCount}`;
teamEl.addEventListener("click", () => {
this.selectRandomTeamFighter(team.id);
});
teamEl.append(labelEl, ruleEl, countEl);
scoreLeft.appendChild(teamEl);
});
} }
finishMatch() { finishMatch() {
@ -881,309 +944,3 @@ update(time) {
} }
} }
} }
const KILL_LOG_LIMIT = 8;
const BATTLE_NOTICE_DELAY_MS = 5000;
const BATTLE_NOTICE_VISIBLE_MS = 2000;
const BATTLE_NOTICE_INTERVAL_MS = 10000;
const SPAWN_CLUSTER_MARGIN = 48;
const SPAWN_CLUSTER_STEP = 28;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
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}명 쓰러졌습니다. 관중석은 침착한 척하는 중입니다.",
];
function createDeathCounts() {
return SPECIES_KEYS.reduce((counts, species) => {
counts[species] = 0;
return counts;
}, {});
}
function normalizeDeathCounts(value = {}) {
return SPECIES_KEYS.reduce((counts, species) => {
counts[species] = Math.max(0, Math.round(Number(value?.[species]) || 0));
return counts;
}, {});
}
function addDeathCounts(baseCounts, matchCounts) {
return SPECIES_KEYS.reduce((counts, species) => {
counts[species] = (baseCounts?.[species] ?? 0) + (matchCounts?.[species] ?? 0);
return counts;
}, {});
}
function normalizeSpecies(value) {
return SPECIES_KEYS.includes(value) ? value : "human";
}
function createDeathNoticeMessage(deathsBySpecies, seed = 0) {
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"));
}
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}`;
}
function createFighterPlans(fighterSetups, skins) {
return fighterSetups.flatMap((fighterSetup, index) => {
const skin = skins[index];
const spawnMultiplier = Math.max(1, Math.round(skin.traits?.spawnMultiplier ?? 1));
return Array.from({ length: spawnMultiplier }, (_, spawnIndex) => {
const position = clusterSpawnPosition(fighterSetup, spawnIndex, spawnMultiplier);
return {
...fighterSetup,
skin,
x: position.x,
y: position.y,
};
});
});
}
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),
};
}
function clampInsideArena(value) {
return Phaser.Math.Clamp(value, SPAWN_CLUSTER_MARGIN, ARENA_SIZE - SPAWN_CLUSTER_MARGIN);
}
function syncTeamSizes(teams, fighterPlans) {
teams.forEach((team) => {
team.size = fighterPlans.filter((fighterPlan) => fighterPlan.team.id === team.id).length;
});
}
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 getSpectatorState(livingFighters) {
const livingFighterCount = livingFighters.length;
const teamSummaries = getLivingTeamSummaries(livingFighters);
if (livingFighterCount < SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
return {
isFinal: true,
mode: "final-random",
zoom: SPECTATOR_FINAL_FIGHT_ZOOM,
};
}
if (
teamSummaries.length === SPECTATOR_FINAL_TEAM_COUNT &&
livingFighterCount <= SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD
) {
return {
isFinal: true,
mode: "final-underdog",
teamId: getUnderdogTeamId(teamSummaries),
zoom: SPECTATOR_FINAL_FIGHT_ZOOM,
};
}
if (livingFighterCount < SPECTATOR_LATE_FIGHTER_THRESHOLD) {
return {
isFinal: false,
mode: "late",
zoom: SPECTATOR_LATE_FIGHT_ZOOM,
};
}
return null;
}
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());
}
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;
}
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,
};
}
function fighterCameraPoint(fighter) {
const target = fighter?.body?.center ?? fighter;
if (!target) {
return null;
}
return {
x: target.x,
y: target.y,
};
}
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;
}

View File

@ -1,4 +1,4 @@
import { ARENA_SIZE, GRID_SIZE, TILE_SIZE } from "../constants.js"; import { ARENA_SIZE, GRID_SIZE, TILE_SIZE } from "../../constants.js";
export function drawArena(scene) { export function drawArena(scene) {
const graphics = scene.add.graphics(); const graphics = scene.add.graphics();

View File

@ -0,0 +1,151 @@
import Phaser from "phaser";
import {
SPECTATOR_FINAL_FIGHTER_THRESHOLD,
SPECTATOR_FINAL_FIGHT_ZOOM,
SPECTATOR_FINAL_TEAM_COUNT,
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD,
SPECTATOR_LATE_FIGHTER_THRESHOLD,
SPECTATOR_LATE_FIGHT_ZOOM,
} from "../../constants.js";
export function getSpectatorState(livingFighters) {
const livingFighterCount = livingFighters.length;
const teamSummaries = getLivingTeamSummaries(livingFighters);
if (livingFighterCount < SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
return {
isFinal: true,
mode: "final-random",
zoom: SPECTATOR_FINAL_FIGHT_ZOOM,
};
}
if (
teamSummaries.length === SPECTATOR_FINAL_TEAM_COUNT &&
livingFighterCount <= SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD
) {
return {
isFinal: true,
mode: "final-underdog",
teamId: getUnderdogTeamId(teamSummaries),
zoom: SPECTATOR_FINAL_FIGHT_ZOOM,
};
}
if (livingFighterCount < SPECTATOR_LATE_FIGHTER_THRESHOLD) {
return {
isFinal: false,
mode: "late",
zoom: 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) {
const target = fighter?.body?.center ?? fighter;
if (!target) {
return null;
}
return {
x: target.x,
y: target.y,
};
}
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 = Phaser.Math.Distance.Between(fighter.x, fighter.y, candidate.x, candidate.y);
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;
}

View File

@ -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);
}

View File

@ -27,7 +27,7 @@ import {
SPELL_HIT_DELAY, SPELL_HIT_DELAY,
RANGED_CRITICAL_CHANCE, RANGED_CRITICAL_CHANCE,
RANGED_ATTACK_RANGE, RANGED_ATTACK_RANGE,
} from "../constants.js"; } from "../../constants.js";
import { import {
getAttackSpeedMultiplier, getAttackSpeedMultiplier,
getMovementSpeedMultiplier, getMovementSpeedMultiplier,
@ -39,7 +39,7 @@ import {
fighterProjectileKey, fighterProjectileKey,
healEffectAnimationKey, healEffectAnimationKey,
healEffectKey, healEffectKey,
} from "./fighterAssets.js"; } from "../fighter/fighterAssets.js";
export function updateFighter(scene, fighter, time, onWinner) { export function updateFighter(scene, fighter, time, onWinner) {
const enemy = findNearestEnemy(scene.fighters, fighter); const enemy = findNearestEnemy(scene.fighters, fighter);
@ -87,10 +87,10 @@ function beginAttack(scene, attacker, defender, time, onWinner) {
switch (getCombatType(attacker)) { switch (getCombatType(attacker)) {
case "projectile": case "projectile":
queueProjectile(scene, attacker, defender, onWinner); queueProjectile(scene, attacker, defender, onWinner, attack);
return; return;
case "instant-spell": case "instant-spell":
queueInstantSpell(scene, attacker, defender, onWinner); queueInstantSpell(scene, attacker, defender, onWinner, attack);
return; return;
default: default:
queueMeleeHit(scene, attacker, defender, onWinner, attack); queueMeleeHit(scene, attacker, defender, onWinner, attack);
@ -102,12 +102,12 @@ function queueMeleeHit(scene, attacker, defender, onWinner, attack) {
scene.time.delayedCall(scaledAttackDelay(MELEE_HIT_DELAY, attacker), () => { scene.time.delayedCall(scaledAttackDelay(MELEE_HIT_DELAY, attacker), () => {
applyHit(scene, attacker, defender, onWinner, matchId, { applyHit(scene, attacker, defender, onWinner, matchId, {
instantKill: attack.isCritical, isCritical: attack.isCritical,
}); });
}); });
} }
function queueProjectile(scene, attacker, defender, onWinner) { function queueProjectile(scene, attacker, defender, onWinner, attack) {
const matchId = scene.matchId; const matchId = scene.matchId;
scene.time.delayedCall(scaledAttackDelay(PROJECTILE_FIRE_DELAY, attacker), () => { scene.time.delayedCall(scaledAttackDelay(PROJECTILE_FIRE_DELAY, attacker), () => {
@ -115,11 +115,11 @@ function queueProjectile(scene, attacker, defender, onWinner) {
return; return;
} }
spawnProjectile(scene, attacker, defender, onWinner, matchId); spawnProjectile(scene, attacker, defender, onWinner, matchId, attack);
}); });
} }
function queueInstantSpell(scene, attacker, defender, onWinner) { function queueInstantSpell(scene, attacker, defender, onWinner, attack) {
const matchId = scene.matchId; const matchId = scene.matchId;
scene.time.delayedCall(scaledAttackDelay(SPELL_CAST_DELAY, attacker), () => { scene.time.delayedCall(scaledAttackDelay(SPELL_CAST_DELAY, attacker), () => {
@ -127,11 +127,11 @@ function queueInstantSpell(scene, attacker, defender, onWinner) {
return; return;
} }
spawnSpellEffect(scene, attacker, defender, onWinner, matchId); spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack);
}); });
} }
function spawnProjectile(scene, attacker, defender, onWinner, matchId) { function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) {
const defenderHitPoint = fighterHitPoint(defender); const defenderHitPoint = fighterHitPoint(defender);
const projectileOrigin = projectileSpawnPoint(attacker, defenderHitPoint); const projectileOrigin = projectileSpawnPoint(attacker, defenderHitPoint);
const projectile = scene.physics.add.image( const projectile = scene.physics.add.image(
@ -178,7 +178,9 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId) {
projectile.hasHit = true; projectile.hasHit = true;
disposeCombatObject(scene, projectile); disposeCombatObject(scene, projectile);
applyHit(scene, attacker, defender, onWinner, matchId); applyHit(scene, attacker, defender, onWinner, matchId, {
isCritical: attack.isCritical,
});
}; };
const overlap = scene.physics.add.overlap(projectile, defender, hitDefender); const overlap = scene.physics.add.overlap(projectile, defender, hitDefender);
@ -213,7 +215,7 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId) {
}); });
} }
function spawnSpellEffect(scene, attacker, defender, onWinner, matchId) { function spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack) {
const effect = scene.add.sprite(defender.x, defender.y, fighterAttackEffectKey(attacker.skin)); const effect = scene.add.sprite(defender.x, defender.y, fighterAttackEffectKey(attacker.skin));
effect.setDepth(3); effect.setDepth(3);
effect.setScale(FIGHTER_SCALE); effect.setScale(FIGHTER_SCALE);
@ -227,17 +229,24 @@ function spawnSpellEffect(scene, attacker, defender, onWinner, matchId) {
scene.time.delayedCall( scene.time.delayedCall(
scaledAttackDelay(attacker.skin.combat?.attackEffect?.hitDelay ?? SPELL_HIT_DELAY, attacker), scaledAttackDelay(attacker.skin.combat?.attackEffect?.hitDelay ?? SPELL_HIT_DELAY, attacker),
() => { () => {
applyHit(scene, attacker, defender, onWinner, matchId); applyHit(scene, attacker, defender, onWinner, matchId, {
isCritical: attack.isCritical,
});
}, },
); );
} }
function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill = false } = {}) { function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = false } = {}) {
if (!isAttackValid(scene, attacker, defender, matchId)) { if (!isAttackValid(scene, attacker, defender, matchId)) {
return; return;
} }
defender.hp = instantKill if (isCritical) {
spawnCriticalHitLabel(scene, defender);
scene.cameras.main.shake(90, 0.002);
}
defender.hp = isCritical
? 0 ? 0
: Math.max(0, defender.hp - Phaser.Math.Between(ATTACK_DAMAGE_MIN, ATTACK_DAMAGE_MAX)); : Math.max(0, defender.hp - Phaser.Math.Between(ATTACK_DAMAGE_MIN, ATTACK_DAMAGE_MAX));
defender.body.setVelocity(0, 0); defender.body.setVelocity(0, 0);
@ -249,10 +258,37 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { instantKill =
defender.isLocked = true; defender.isLocked = true;
playAnimation(defender, "hurt"); playAnimation(defender, "hurt");
}
if (instantKill) {
scene.cameras.main.shake(90, 0.002); function spawnCriticalHitLabel(scene, defender) {
} const scaleRatio = Math.max(1, Math.abs(defender.scaleY) / FIGHTER_SCALE);
const label = scene.add
.text(defender.x, defender.y - 44 * scaleRatio - 24, "Critical!", {
color: "#ffe45c",
fontFamily: "Inter, Pretendard, sans-serif",
fontSize: "24px",
fontStyle: "900",
stroke: "#7b1b11",
strokeThickness: 5,
})
.setOrigin(0.5)
.setDepth(6);
label.cleanup = () => {
scene.tweens.killTweensOf(label);
};
trackCombatObject(scene, label);
scene.tweens.add({
targets: label,
y: label.y - 32,
alpha: 0,
scaleX: 1.12,
scaleY: 1.12,
duration: 520,
ease: "Cubic.Out",
onComplete: () => disposeCombatObject(scene, label),
});
} }
function getAttackRange(fighter) { function getAttackRange(fighter) {

View File

@ -7,7 +7,7 @@ import {
SELECTED_FIGHTER_OUTLINE_ALPHA, SELECTED_FIGHTER_OUTLINE_ALPHA,
SELECTED_FIGHTER_OUTLINE_GAP, SELECTED_FIGHTER_OUTLINE_GAP,
SELECTED_FIGHTER_OUTLINE_WIDTH, SELECTED_FIGHTER_OUTLINE_WIDTH,
} from "../constants.js"; } from "../../constants.js";
const SOURCE_ALPHA_THRESHOLD = 8; const SOURCE_ALPHA_THRESHOLD = 8;
const HEAL_EFFECT_PATH = "assets/effects/heal/Heal_Effect.png"; const HEAL_EFFECT_PATH = "assets/effects/heal/Heal_Effect.png";

View File

@ -9,7 +9,7 @@ import {
FIGHTER_HITBOX_WIDTH, FIGHTER_HITBOX_WIDTH,
FIGHTER_MAX_HP, FIGHTER_MAX_HP,
FIGHTER_SCALE, FIGHTER_SCALE,
} from "../constants.js"; } from "../../constants.js";
import { import {
fighterAnimationKey, fighterAnimationKey,
fighterOutlineSheetKeyFromSheetKey, fighterOutlineSheetKeyFromSheetKey,

View File

@ -0,0 +1,54 @@
import Phaser from "phaser";
import { ARENA_SIZE } 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;
});
}

View File

@ -1,13 +1,19 @@
import { import {
ARENA_SIZE, ARENA_SIZE,
DEFAULT_SPAWN_PLACEMENT,
DEFAULT_TEAM_SIZE, DEFAULT_TEAM_SIZE,
GRID_SIZE, GRID_SIZE,
getTeamColor, getTeamColor,
MAX_TEAM_SIZE, MAX_TEAM_SIZE,
SPAWN_PLACEMENTS,
TILE_SIZE, TILE_SIZE,
} from "../constants.js"; } from "../../constants.js";
export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) { export function createMatchSetup(
names,
requestedTeamSize = DEFAULT_TEAM_SIZE,
requestedSpawnPlacement = DEFAULT_SPAWN_PLACEMENT,
) {
const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE)); const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE));
const teams = names.map((name, index) => ({ const teams = names.map((name, index) => ({
color: getTeamColor(index, names.length), color: getTeamColor(index, names.length),
@ -16,8 +22,7 @@ export function createMatchSetup(names, requestedTeamSize = DEFAULT_TEAM_SIZE) {
size: teamSize, size: teamSize,
})); }));
const totalFighters = names.length * teamSize; const spawns = createSpawnPoints(names.length, teamSize, requestedSpawnPlacement);
const spawns = createRandomSpawnPoints(totalFighters);
const fighters = []; const fighters = [];
names.forEach((name, teamIndex) => { names.forEach((name, teamIndex) => {
@ -58,11 +63,56 @@ function createTeams(playerCount, teamSize) {
})); }));
} }
function createSpawnPoints(teamCount, teamSize, requestedSpawnPlacement) {
if (requestedSpawnPlacement === SPAWN_PLACEMENTS.STARTING_ZONES) {
return createStartingZoneSpawnPoints(teamCount, teamSize);
}
return createRandomSpawnPoints(teamCount * teamSize);
}
function createRandomSpawnPoints(count) { function createRandomSpawnPoints(count) {
return createSpawnPointsFromSlots(createSpawnSlots(), count);
}
function createStartingZoneSpawnPoints(teamCount, teamSize) {
const fallbackSlots = createSpawnSlots();
const layout = shuffle(createStartingZoneLayout(teamCount));
return layout.flatMap((zone) => {
const zoneSlots = createSpawnSlots(zone);
return createSpawnPointsFromSlots(zoneSlots.length > 0 ? zoneSlots : fallbackSlots, teamSize);
});
}
function createStartingZoneLayout(teamCount) {
const columnCount = Math.max(1, Math.ceil(Math.sqrt(teamCount)));
const rowCount = Math.max(1, Math.ceil(teamCount / columnCount));
const availableRows = GRID_SIZE - 2;
return Array.from({ length: teamCount }, (_, index) => {
const column = index % columnCount;
const row = Math.floor(index / columnCount);
return {
columnEnd: partitionEnd(GRID_SIZE, columnCount, column),
columnStart: partitionStart(GRID_SIZE, columnCount, column),
rowEnd: 1 + partitionEnd(availableRows, rowCount, row),
rowStart: 1 + partitionStart(availableRows, rowCount, row),
};
});
}
function createSpawnSlots({
columnEnd = GRID_SIZE,
columnStart = 0,
rowEnd = GRID_SIZE - 1,
rowStart = 1,
} = {}) {
const spawnSlots = []; const spawnSlots = [];
for (let row = 1; row < GRID_SIZE - 1; row += 1) { for (let row = rowStart; row < rowEnd; row += 1) {
for (let column = 0; column < GRID_SIZE; column += 1) { for (let column = columnStart; column < columnEnd; column += 1) {
spawnSlots.push({ spawnSlots.push({
x: column * TILE_SIZE + TILE_SIZE / 2, x: column * TILE_SIZE + TILE_SIZE / 2,
y: row * TILE_SIZE + TILE_SIZE / 2, y: row * TILE_SIZE + TILE_SIZE / 2,
@ -70,6 +120,10 @@ function createRandomSpawnPoints(count) {
} }
} }
return spawnSlots;
}
function createSpawnPointsFromSlots(spawnSlots, count) {
const points = []; const points = [];
while (points.length < count) { while (points.length < count) {
@ -89,6 +143,14 @@ function createRandomSpawnPoints(count) {
return points; return points;
} }
function partitionStart(size, partCount, partIndex) {
return Math.floor((size * partIndex) / partCount);
}
function partitionEnd(size, partCount, partIndex) {
return partitionStart(size, partCount, partIndex + 1);
}
function resolveTeamSize(playerCount, requestedTeamSize) { function resolveTeamSize(playerCount, requestedTeamSize) {
const teamSize = clamp( const teamSize = clamp(
Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE), Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE),

View File

@ -1,6 +1,10 @@
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 {
ARENA_SIZE,
PRESENTATION_TEAM_COUNT,
PRESENTATION_TEAM_SIZE,
} from "./constants.js";
import { createMatchForm } from "./ui/matchForm.js"; import { createMatchForm } from "./ui/matchForm.js";
import { trackVisitor } from "./ui/visitorCounter.js"; import { trackVisitor } from "./ui/visitorCounter.js";
@ -55,6 +59,13 @@ function startConfiguredMatch(matchConfig) {
syncPauseButton(); syncPauseButton();
} }
function getPresentationMatchConfig() {
return {
names: Array.from({ length: PRESENTATION_TEAM_COUNT }, (_, index) => `Player ${index + 1}`),
teamSize: PRESENTATION_TEAM_SIZE,
};
}
function setDrawerCollapsed(collapsed) { function setDrawerCollapsed(collapsed) {
const nextCollapsed = Boolean(collapsed) && isMatchLive(); const nextCollapsed = Boolean(collapsed) && isMatchLive();
@ -121,7 +132,7 @@ window.addEventListener("keydown", (event) => {
}); });
const arenaScene = new ArenaScene({ const arenaScene = new ArenaScene({
getInitialMatchConfig: matchForm.readMatchConfig, getInitialMatchConfig: getPresentationMatchConfig,
setStatus: matchForm.setStatus, setStatus: matchForm.setStatus,
}); });

View File

@ -256,7 +256,6 @@ textarea:focus-visible {
} }
#app.options-open:not(.match-live) .intro-content { #app.options-open:not(.match-live) .intro-content {
transform: translateX(-14vw) scale(0.92);
opacity: 0.72; opacity: 0.72;
} }
@ -327,6 +326,11 @@ form button[type="submit"],
text-transform: uppercase; text-transform: uppercase;
} }
#app.options-open:not(.match-live) .start-button {
pointer-events: none;
visibility: hidden;
}
.start-button:hover, .start-button:hover,
form button[type="submit"]:hover, form button[type="submit"]:hover,
.pause-button:hover, .pause-button:hover,
@ -582,13 +586,10 @@ legend {
gap: 12px; gap: 12px;
} }
output { .team-size-number {
width: 88px;
min-width: 88px; min-width: 88px;
border: 1px solid rgb(238 185 73 / 0.2); padding-inline: 10px;
border-radius: 8px;
padding: 8px 10px;
background: #1d2116;
color: #fff7df;
text-align: center; text-align: center;
font-weight: 900; font-weight: 900;
} }
@ -598,7 +599,7 @@ label {
font-size: 0.92rem; font-size: 0.92rem;
} }
input:not([type="range"]), input:not([type="range"]):not([type="radio"]),
textarea { textarea {
min-height: 48px; min-height: 48px;
border: 1px solid rgb(238 185 73 / 0.28); border: 1px solid rgb(238 185 73 / 0.28);
@ -621,6 +622,66 @@ input[type="range"] {
accent-color: #e3b24f; 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;
}
.scoreboard { .scoreboard {
position: fixed; position: fixed;
top: clamp(14px, 3vw, 28px); top: clamp(14px, 3vw, 28px);
@ -843,6 +904,7 @@ input[type="range"] {
} }
.kill-log-avatar { .kill-log-avatar {
position: relative;
flex: 0 0 auto; flex: 0 0 auto;
width: 36px; width: 36px;
height: 36px; height: 36px;
@ -856,6 +918,31 @@ input[type="range"] {
box-shadow: inset 0 -10px 18px rgb(0 0 0 / 0.22); 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 { .kill-log-copy {
display: grid; display: grid;
gap: 2px; gap: 2px;
@ -932,26 +1019,149 @@ input[type="range"] {
transform: translate(-50%, -50%) rotate(-42deg); transform: translate(-50%, -50%) rotate(-42deg);
} }
.victory-banner { .victory-celebration {
position: fixed; position: fixed;
z-index: 9;
display: grid;
overflow: hidden;
place-items: center;
inset: 0;
background: rgb(4 6 4 / 0.2);
isolation: isolate;
pointer-events: none;
}
.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%; top: 50%;
left: 50%; left: 50%;
z-index: 9; display: block;
max-width: min(92vw, 720px); width: clamp(6px, 0.8vw, 11px);
border: 2px solid #e3b24f; height: clamp(10px, 1.2vw, 18px);
border-radius: 8px; border-radius: 8px;
padding: 1.3rem 2.4rem; background: var(--confetti-color);
background: rgb(4 6 4 / 0.88); 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; color: #fff7df;
font-size: clamp(1.45rem, 5vw, 2.4rem); font-size: clamp(1.65rem, 5vw, 3rem);
font-weight: 950; font-weight: 950;
letter-spacing: 0;
line-height: 1.12;
text-align: center; text-align: center;
box-shadow: 0 0 34px rgb(227 178 79 / 0.34); text-wrap: balance;
transform: translate(-50%, -50%); text-shadow:
animation: banner-in 0.5s cubic-bezier(0.2, 0.8, 0.2, 1); 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); 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 1.1s 0.2s 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 { #app.match-paused .arena-shell::after {
content: "일시정지"; content: "일시정지";
position: fixed; position: fixed;
@ -1091,11 +1301,100 @@ input[type="range"] {
@keyframes banner-in { @keyframes banner-in {
from { from {
opacity: 0; opacity: 0;
transform: translate(-50%, -56%) scale(0.86); transform: translateY(18px) scale(0.78);
} }
to { to {
opacity: 1; opacity: 1;
transform: translate(-50%, -50%) scale(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;
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;
} }
} }
@ -1126,10 +1425,6 @@ input[type="range"] {
padding: 20px; padding: 20px;
} }
#app.options-open:not(.match-live) .intro-content {
transform: translateY(-16vh) scale(0.86);
}
.arena-logo { .arena-logo {
font-size: clamp(3.8rem, 22vw, 7rem); font-size: clamp(3.8rem, 22vw, 7rem);
} }

99
src/ui/arenaKillLog.js Normal file
View File

@ -0,0 +1,99 @@
const KILL_LOG_LIMIT = 8;
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.append(item);
while (listNode.children.length > KILL_LOG_LIMIT) {
listNode.firstElementChild?.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}`;
}

49
src/ui/arenaScoreboard.js Normal file
View File

@ -0,0 +1,49 @@
export function updateScoreboard(
containerLeft,
containerRight,
teams,
fighters,
{ selectedFighterTeamId = null, onTeamClick = () => {} } = {},
) {
if (!containerLeft || !containerRight) {
return;
}
containerLeft.innerHTML = "";
containerRight.innerHTML = "";
teams.forEach((team) => {
const aliveCount = fighters.filter((f) => f.team.id === team.id && !f.isDead).length;
const teamEl = document.createElement("button");
teamEl.className = "team-score";
teamEl.type = "button";
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}`;
if (selectedFighterTeamId === team.id) {
teamEl.classList.add("is-focused");
}
const labelEl = document.createElement("span");
labelEl.className = "team-score-name";
labelEl.textContent = team.label;
const ruleEl = document.createElement("span");
ruleEl.className = "team-score-rule";
const countEl = document.createElement("span");
countEl.className = "team-score-count";
countEl.textContent = `${aliveCount}`;
teamEl.addEventListener("click", () => {
onTeamClick(team.id);
});
teamEl.append(labelEl, ruleEl, countEl);
containerLeft.appendChild(teamEl);
});
}

View File

@ -0,0 +1,90 @@
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}명 쓰러졌습니다. 관중석은 침착한 척하는 중입니다.",
];
export const BATTLE_NOTICE_DELAY_MS = 5000;
export const BATTLE_NOTICE_VISIBLE_MS = 2000;
export const BATTLE_NOTICE_INTERVAL_MS = 10000;
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) {
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");
}

View File

@ -1,7 +1,8 @@
import { NICKNAME_LENGTH } from "../constants.js"; import { DEFAULT_SPAWN_PLACEMENT, NICKNAME_LENGTH } from "../constants.js";
const STORAGE_KEYS = { const STORAGE_KEYS = {
names: "arena.match.playerNames", names: "arena.match.playerNames",
spawnPlacement: "arena.match.spawnPlacement",
teamSize: "arena.match.teamSize", teamSize: "arena.match.teamSize",
}; };
@ -11,22 +12,42 @@ export function createMatchForm() {
const appNode = document.querySelector("#app"); const appNode = document.querySelector("#app");
const statusNode = document.querySelector("#match-status"); const statusNode = document.querySelector("#match-status");
const statusTextNodes = document.querySelectorAll("[data-status-text]"); const statusTextNodes = document.querySelectorAll("[data-status-text]");
const spawnPlacementInputs = getElements('input[name="spawnPlacement"]');
const teamSizeInput = getElement("#team-size"); const teamSizeInput = getElement("#team-size");
const teamSizeOutput = getElement("#team-size-value"); const teamSizeNumberInput = getElement("#team-size-value");
const readMatchConfig = () => ({ const readMatchConfig = () => ({
names: nicknameValues(namesInput.value), names: nicknameValues(namesInput.value),
spawnPlacement: selectedSpawnPlacement(spawnPlacementInputs),
teamSize: Number(teamSizeInput.value), teamSize: Number(teamSizeInput.value),
}); });
restoreSavedMatchSettings(namesInput, teamSizeInput); restoreSavedMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput, teamSizeNumberInput);
syncTeamSizeOutput(teamSizeInput, teamSizeOutput); syncTeamSizeInputs(teamSizeInput, teamSizeNumberInput);
namesInput.addEventListener("input", () => { namesInput.addEventListener("input", () => {
saveMatchSettings(namesInput, teamSizeInput); saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
}); });
teamSizeInput.addEventListener("input", () => { teamSizeInput.addEventListener("input", () => {
syncTeamSizeOutput(teamSizeInput, teamSizeOutput); syncTeamSizeInputs(teamSizeInput, teamSizeNumberInput);
saveMatchSettings(namesInput, teamSizeInput); saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
});
teamSizeNumberInput.addEventListener("input", () => {
if (syncTeamSizeInputs(teamSizeInput, teamSizeNumberInput, teamSizeNumberInput.value)) {
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
}
});
teamSizeNumberInput.addEventListener("change", () => {
syncTeamSizeInputs(
teamSizeInput,
teamSizeNumberInput,
teamSizeNumberInput.value || teamSizeInput.value,
);
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
});
spawnPlacementInputs.forEach((input) => {
input.addEventListener("change", () => {
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
});
}); });
return { return {
@ -62,6 +83,16 @@ 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|,/)
@ -69,11 +100,25 @@ function nicknameValues(value) {
.filter(Boolean); .filter(Boolean);
} }
function syncTeamSizeOutput(input, output) { function syncTeamSizeInputs(rangeInput, numberInput, value = rangeInput.value) {
output.textContent = input.value; const normalizedTeamSize = normalizeTeamSize(value, rangeInput);
if (!normalizedTeamSize) {
return "";
}
rangeInput.value = normalizedTeamSize;
numberInput.value = normalizedTeamSize;
return normalizedTeamSize;
} }
function restoreSavedMatchSettings(namesInput, teamSizeInput) { function restoreSavedMatchSettings(
namesInput,
spawnPlacementInputs,
teamSizeInput,
teamSizeNumberInput,
) {
const storage = getLocalStorage(); const storage = getLocalStorage();
if (!storage) { if (!storage) {
@ -82,6 +127,7 @@ function restoreSavedMatchSettings(namesInput, teamSizeInput) {
try { try {
const savedNames = storage.getItem(STORAGE_KEYS.names); const savedNames = storage.getItem(STORAGE_KEYS.names);
const savedSpawnPlacement = storage.getItem(STORAGE_KEYS.spawnPlacement);
const savedTeamSize = storage.getItem(STORAGE_KEYS.teamSize); const savedTeamSize = storage.getItem(STORAGE_KEYS.teamSize);
if (savedNames !== null) { if (savedNames !== null) {
@ -90,15 +136,19 @@ function restoreSavedMatchSettings(namesInput, teamSizeInput) {
const normalizedTeamSize = normalizeTeamSize(savedTeamSize, teamSizeInput); const normalizedTeamSize = normalizeTeamSize(savedTeamSize, teamSizeInput);
if (normalizedTeamSize) { syncTeamSizeInputs(
teamSizeInput.value = normalizedTeamSize; teamSizeInput,
} teamSizeNumberInput,
normalizedTeamSize || teamSizeInput.value,
);
setSpawnPlacement(spawnPlacementInputs, savedSpawnPlacement);
} catch { } catch {
// Storage may be unavailable in private or restricted browser contexts. // Storage may be unavailable in private or restricted browser contexts.
} }
} }
function saveMatchSettings(namesInput, teamSizeInput) { function saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput) {
const storage = getLocalStorage(); const storage = getLocalStorage();
if (!storage) { if (!storage) {
@ -107,12 +157,27 @@ function saveMatchSettings(namesInput, teamSizeInput) {
try { try {
storage.setItem(STORAGE_KEYS.names, namesInput.value); storage.setItem(STORAGE_KEYS.names, namesInput.value);
storage.setItem(STORAGE_KEYS.spawnPlacement, selectedSpawnPlacement(spawnPlacementInputs));
storage.setItem(STORAGE_KEYS.teamSize, normalizeTeamSize(teamSizeInput.value, teamSizeInput)); storage.setItem(STORAGE_KEYS.teamSize, normalizeTeamSize(teamSizeInput.value, teamSizeInput));
} catch { } catch {
// Ignore storage failures so the match form remains usable. // Ignore storage failures so the match form remains usable.
} }
} }
function selectedSpawnPlacement(inputs) {
return inputs.find((input) => input.checked)?.value ?? DEFAULT_SPAWN_PLACEMENT;
}
function setSpawnPlacement(inputs, value) {
const savedInput = inputs.find((input) => input.value === value);
const defaultInput = inputs.find((input) => input.value === DEFAULT_SPAWN_PLACEMENT);
const nextInput = savedInput ?? defaultInput;
if (nextInput) {
nextInput.checked = true;
}
}
function normalizeTeamSize(value, input) { function normalizeTeamSize(value, input) {
const min = Number(input.min) || 1; const min = Number(input.min) || 1;
const max = Number(input.max) || min; const max = Number(input.max) || min;

View File

@ -0,0 +1,131 @@
const VICTORY_CONFETTI_COLORS = ["#ffe8a8", "#f7b842", "#f36f45", "#85dcc7", "#f7f2df"];
const VICTORY_CONFETTI_COUNT = 40;
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;
export function removeVictoryCelebration() {
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);
celebrationHost.appendChild(celebration);
if (isVictory) {
playVictoryFanfare();
}
}
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);
}

44
todo.md
View File

@ -49,11 +49,11 @@
9. 전투 진입 UI, 좌측 HUD badge, 좌측 하단 킬로그 개선 (완료) 9. 전투 진입 UI, 좌측 HUD badge, 좌측 하단 킬로그 개선 (완료)
- **조치 사항**: - **조치 사항**:
- 최초 접속 화면에 투명 전투 프리뷰, `Arena` 로고, `Start` 버튼을 배치. - 최초 접속 화면에 투명 전투 프리뷰, `Arena` 로고, `Start` 버튼을 배치.
- `Start` 클릭 시 우측 옵션 drawer가 열리고, 전투 시작 시 실제 경기 화면으로 전환. - `Start` 클릭 시 우측 옵션 drawer가 열리고 홈 drawer 상태에서는 `Arena` 로고 위치를 유지한 채 `Start` 버튼을 숨기며, 전투 시작 시 실제 경기 화면으로 전환.
- 팀 badge를 상단 좌/우 분할에서 경기장 밖 좌측 HUD 레일로 이동. - 팀 badge를 상단 좌/우 분할에서 경기장 밖 좌측 HUD 레일로 이동.
- badge를 팀명, 팀 색상 구분선, 생존 인원 형식으로 표기. - badge를 팀명, 팀 색상 구분선, 생존 인원 형식으로 표기.
- 좌측 HUD 레일 폭과 경기장 시작 위치를 분리 계산해 badge가 미니맵과 경기장 캔버스를 가리지 않도록 조정. - 좌측 HUD 레일 폭과 경기장 시작 위치를 분리 계산해 badge가 미니맵과 경기장 캔버스를 가리지 않도록 조정.
- 전투 시작 후 하단 안내바는 숨기고, 좌측 하단에 처치자/피처치자 이미지와 `manifest.key`를 포함한 목록형 킬로그를 표시. - 전투 시작 후 하단 안내바는 숨기고, 좌측 하단에 처치자/피처치자 이미지와 `manifest.key`를 포함한 목록형 킬로그를 표시. 중앙 텍스트는 `처치`로 유지하고 피처치자 아이콘에는 빨간 X를 겹쳐 구분.
10. 전투 중 옵션 drawer 유지, 접기/펼치기, 재시작, 일시정지 추가 (완료) 10. 전투 중 옵션 drawer 유지, 접기/펼치기, 재시작, 일시정지 추가 (완료)
- **조치 사항**: - **조치 사항**:
@ -90,11 +90,47 @@
15. 전투 설정 입력값 localStorage 유지 (완료) 15. 전투 설정 입력값 localStorage 유지 (완료)
- **조치 사항**: - **조치 사항**:
- 참가자 닉네임 textarea와 팀당 인원 range 값을 브라우저 `localStorage`에 저장. - 참가자 닉네임 textarea와 팀당 인원 숫자 입력/range 값을 브라우저 `localStorage`에 저장.
- 앱 로드 시 저장된 참가자 닉네임과 팀당 인원을 먼저 복원해 새로고침/재접속 후에도 입력값이 유지되도록 구현. - 앱 로드 시 저장된 참가자 닉네임과 팀당 인원을 먼저 복원해 새로고침/재접속 후에도 입력값이 유지되도록 구현.
- 저장된 입력값이 최초 대기 전투 프리뷰 규모를 키우지 않도록 프리뷰는 10팀 x 팀당 5명 설정으로 분리.
16. 최종교전 카메라 조건 및 슬로우모션 연출 추가 (완료) 16. 최종교전 카메라 조건 및 슬로우모션 연출 추가 (완료)
- **조치 사항**: - **조치 사항**:
- 생존 4명 이하에서는 카메라가 생존 캐릭터를 일정 간격으로 무작위 포커싱하도록 변경. - 생존 4명 이하에서는 카메라가 생존 캐릭터를 일정 간격으로 무작위 포커싱하도록 변경.
- 잔여 팀이 2팀이고 생존 캐릭터 합이 8명 이하이면 생존 수가 적은 팀의 중앙을 포커싱하도록 추가. - 잔여 팀이 2팀이고 생존 캐릭터 합이 8명 이하이면 생존 수가 적은 팀의 중앙을 포커싱하도록 추가.
- 최종교전 상태에서 idle이 아닌 공격 모션이 시작될 때 짧은 전역 슬로우모션을 적용. - `FINAL_COMBAT_SLOW_MOTION_ENABLED`로 최종교전 슬로우모션을 켜고 끌 수 있게 하고 기본값은 `false`로 둠.
- 활성화 시 최종교전 상태에서 idle이 아닌 공격 모션이 시작될 때 진입/유지/복귀 완급이 있는 전역 슬로우모션을 적용하고, Arcade Physics 이동에는 역수 timeScale을 적용.
17. 미니맵 뷰포트 박스 검은 깨짐 수정 (완료)
- **조치 사항**:
- 현재 뷰포트 사각형 좌표를 미니맵 픽셀 격자에 맞춰 이동 중 가장자리 흔들림을 완화.
- 검은 외곽 stroke 위에 노란 stroke를 겹치던 뷰포트 박스를 노란 내부 채움 선으로 바꿔 이동 중 일부 선이 검게 보이는 현상을 제거.
18. 치명타 적중 표기 추가 (완료)
- **조치 사항**:
- 공격 프로필의 치명타 판정을 실제 적중 처리까지 전달해 전투 타입별 적중 연출이 같은 흐름을 사용하도록 정리.
- 치명타 적중 시 대상 위에 `Critical!` 문구를 띄우고 즉시 처치와 카메라 흔들림이 함께 적용되도록 `applyHit()`를 보강.
19. 리스폰 배치 설정 구분 추가 (완료)
- **조치 사항**:
- 전투 설정 drawer에 `스타팅 지점 배치`와 기존 `완전 랜덤 배치`를 선택하는 리스폰 설정을 추가.
- `스타팅 지점 배치`에서는 참가자 수에 맞춰 전장 구역을 나누고 참가자별 시작 구역 배정과 구역 안 스폰 위치를 매치마다 무작위로 정하도록 구현.
- 선택한 리스폰 배치 모드를 `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`는 이제 각 모듈을 조율하는 오케스트레이션 역할에 집중하도록 경량화됨.