Add final combat polish and saved settings

This commit is contained in:
Horoli 2026-05-22 18:00:23 +09:00
parent 0720efe7ba
commit a03dc02d01
8 changed files with 406 additions and 47 deletions

View File

@ -14,6 +14,7 @@
- `SELECTED_FIGHTER_OUTLINE_GAP`, `SELECTED_FIGHTER_OUTLINE_WIDTH`, `SELECTED_FIGHTER_OUTLINE_ALPHA`: 팀 색상 실루엣 마커의 캐릭터 이격 거리, 두께, 투명도. - `SELECTED_FIGHTER_OUTLINE_GAP`, `SELECTED_FIGHTER_OUTLINE_WIDTH`, `SELECTED_FIGHTER_OUTLINE_ALPHA`: 팀 색상 실루엣 마커의 캐릭터 이격 거리, 두께, 투명도.
- `TEAM_COLORS`, `getTeamColor()`: 8팀 이하에서는 기본 팔레트를 쓰고, 9팀 이상에서는 팀 수에 맞춰 중복 없는 색상을 동적으로 생성합니다. - `TEAM_COLORS`, `getTeamColor()`: 8팀 이하에서는 기본 팔레트를 쓰고, 9팀 이상에서는 팀 수에 맞춰 중복 없는 색상을 동적으로 생성합니다.
- `SPECTATOR_CAMERA_LERP`: 카메라 추적의 부드러움 정도. - `SPECTATOR_CAMERA_LERP`: 카메라 추적의 부드러움 정도.
- `SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD`, `SPECTATOR_RANDOM_FOCUS_INTERVAL`, `FINAL_COMBAT_SLOW_MOTION_*`: 최종교전 관전 조건, 랜덤 포커싱 간격, 슬로우모션 배율/지속시간.
- `MINIMAP_VIEWPORT_SIZE`: 미니맵의 고정 픽셀 크기. - `MINIMAP_VIEWPORT_SIZE`: 미니맵의 고정 픽셀 크기.
- `ARENA_SIZE`: 경기장 전체 크기 (GRID * TILE). - `ARENA_SIZE`: 경기장 전체 크기 (GRID * TILE).
@ -48,6 +49,8 @@
- `scheduleBattleNotice()`, `showBattleDeathNotice()`: 실제 전투가 5초 이상 지속되면 오늘 누적 사망 통계와 현재 전투 사망 수를 합산해 상단 안내바에 표시합니다. 안내는 2초 표시 후 10초 대기하는 주기로 반복됩니다. - `scheduleBattleNotice()`, `showBattleDeathNotice()`: 실제 전투가 5초 이상 지속되면 오늘 누적 사망 통계와 현재 전투 사망 수를 합산해 상단 안내바에 표시합니다. 안내는 2초 표시 후 10초 대기하는 주기로 반복됩니다.
- `setPaused()`, `togglePause()`: 실제 전투에서 물리, Phaser 타이머, tween, 스프라이트 애니메이션을 함께 정지/재개합니다. 프리뷰 전투(`presentationMode`)와 종료된 전투는 pause 대상에서 제외합니다. - `setPaused()`, `togglePause()`: 실제 전투에서 물리, Phaser 타이머, tween, 스프라이트 애니메이션을 함께 정지/재개합니다. 프리뷰 전투(`presentationMode`)와 종료된 전투는 pause 대상에서 제외합니다.
- `observeCombat()`: 캐릭터가 공격할 때 카메라가 주목할 "관전 대상"을 설정합니다. - `observeCombat()`: 캐릭터가 공격할 때 카메라가 주목할 "관전 대상"을 설정합니다.
- `getSpectatorState()`, `getSpectatorCameraTarget()`: 생존 4명 이하에서는 생존 캐릭터를 무작위로 포커싱하고, 잔여 2팀의 생존 합이 8명 이하이면 생존 수가 적은 팀을 포커싱합니다.
- `triggerFinalCombatSlowMotion()`: 최종교전 상태에서 공격 모션이 시작될 때만 짧게 전역 time scale을 낮춰 슬로우모션을 연출합니다.
- `selectFighter()`, `focusSelectedFighter()`: 캐릭터 클릭 시 해당 캐릭터의 히트박스 중심으로 카메라를 고정합니다. 팀 색상 마커는 선택 상태와 별개로 상시 표시됩니다. - `selectFighter()`, `focusSelectedFighter()`: 캐릭터 클릭 시 해당 캐릭터의 히트박스 중심으로 카메라를 고정합니다. 팀 색상 마커는 선택 상태와 별개로 상시 표시됩니다.
- `selectRandomTeamFighter()`: 좌측 팀 badge 클릭 시 해당 팀의 생존 전투원 중 무작위 1명을 골라 카메라를 고정합니다. - `selectRandomTeamFighter()`: 좌측 팀 badge 클릭 시 해당 팀의 생존 전투원 중 무작위 1명을 골라 카메라를 고정합니다.
- `updateMinimapViewportFrame()`: 주 카메라의 이동에 맞춰 미니맵 가이드 사각형을 렌더링합니다. - `updateMinimapViewportFrame()`: 주 카메라의 이동에 맞춰 미니맵 가이드 사각형을 렌더링합니다.
@ -56,6 +59,7 @@
- **`combat.js`**: - **`combat.js`**:
- `updateFighter()`: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다. - `updateFighter()`: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
- `applyHit()`: 일반 공격 피해량은 `ATTACK_DAMAGE_MIN/MAX` 범위에서 계산합니다. - `applyHit()`: 일반 공격 피해량은 `ATTACK_DAMAGE_MIN/MAX` 범위에서 계산합니다.
- `killFighter()`: 사망한 캐릭터를 반투명 처리하고 살아있는 캐릭터보다 낮은 depth로 내려 전투원을 가리지 않게 합니다.
- `applyKillReward()`: 처치한 캐릭터의 체력 회복, 크기 증가, 공격속도/이동속도 배율 증가를 처리합니다. 누적 배율은 `KILL_GROWTH_MAX_MULTIPLIER`로 제한합니다. - `applyKillReward()`: 처치한 캐릭터의 체력 회복, 크기 증가, 공격속도/이동속도 배율 증가를 처리합니다. 누적 배율은 `KILL_GROWTH_MAX_MULTIPLIER`로 제한합니다.
- `clampFighterInsideArena()`: 처치 성장 tween 중/완료 시 커진 캐릭터가 arena 밖으로 밀려 히트박스가 전장 바깥에 놓이지 않도록 위치를 보정합니다. - `clampFighterInsideArena()`: 처치 성장 tween 중/완료 시 커진 캐릭터가 arena 밖으로 밀려 히트박스가 전장 바깥에 놓이지 않도록 위치를 보정합니다.
- `maybeSplitFighter()`: 사망한 캐릭터의 `traits.splitOnDeath` 특성을 확인하고 분열 생성을 요청합니다. - `maybeSplitFighter()`: 사망한 캐릭터의 `traits.splitOnDeath` 특성을 확인하고 분열 생성을 요청합니다.
@ -63,9 +67,9 @@
### [Assets & UI] ### [Assets & UI]
- **`fighterAssets.js`**: 원본 캐릭터 스프라이트의 alpha 값을 읽어 흰색 실루엣 spritesheet를 런타임에 생성합니다. 원본 주변 1px은 비워두고 그 바깥 1px만 칠한 뒤, `fighterFactory.js`에서 팀 색상으로 tint 처리합니다. - **`fighterAssets.js`**: 원본 캐릭터 스프라이트의 alpha 값을 읽어 흰색 실루엣 spritesheet를 런타임에 생성합니다. 원본 주변 1px은 비워두고 그 바깥 1px만 칠한 뒤, `fighterFactory.js`에서 팀 색상으로 tint 처리합니다.
- **`fighterFactory.js`**: 캐릭터 히트박스, 이름표, 체력바, 팀 색상 마커 sprite를 생성하고 매 프레임 위치/스케일/방향을 동기화합니다. 이름표는 스프라이트 중심이 아니라 실제 히트박스 하단에 고정됩니다. 생성 옵션의 `hp`, `maxHp`, `canSplitOnDeath`를 통해 개별 전투원의 체력과 분열 가능 여부를 지정할 수 있습니다. - **`fighterFactory.js`**: 캐릭터 히트박스, 이름표, 체력바, 팀 색상 마커 sprite를 생성하고 매 프레임 위치/스케일/방향을 동기화합니다. 이름표는 스프라이트 중심이 아니라 실제 히트박스 하단에 고정됩니다. 사망한 캐릭터의 HUD와 팀 색상 마커는 숨겨 전투 화면을 덜 가리게 합니다. 생성 옵션의 `hp`, `maxHp`, `canSplitOnDeath`를 통해 개별 전투원의 체력과 분열 가능 여부를 지정할 수 있습니다.
- **`fighterManifest.js`**: 20여 종의 캐릭터 스킨 정보가 담긴 딕셔너리입니다. 각 스킨은 사망 통계용 `species`를 가지며, `combat.type` (melee/projectile/instant-spell)에 따라 전투 메커니즘이 결정됩니다. `stats``traits`로 캐릭터별 기본 체력과 특수 규칙을 정의합니다. - **`fighterManifest.js`**: 20여 종의 캐릭터 스킨 정보가 담긴 딕셔너리입니다. 각 스킨은 사망 통계용 `species`를 가지며, `combat.type` (melee/projectile/instant-spell)에 따라 전투 메커니즘이 결정됩니다. `stats``traits`로 캐릭터별 기본 체력과 특수 규칙을 정의합니다.
- **`matchForm.js`**: `index.html`의 입력을 읽어 `ArenaScene`에 매치 구성을 전달하고, 검증/결과 상태 메시지를 DOM에 반영할 수 있는 setter를 제공합니다. 실제 전투 중 하단 안내바는 숨기고, 처치 내역은 `ArenaScene`의 좌측 하단 킬로그가 담당합니다. - **`matchForm.js`**: `index.html`의 입력을 읽어 `ArenaScene`에 매치 구성을 전달하고, 검증/결과 상태 메시지를 DOM에 반영할 수 있는 setter를 제공합니다. 참가자 닉네임과 팀당 인원은 브라우저 `localStorage`에 저장/복원합니다. 실제 전투 중 하단 안내바는 숨기고, 처치 내역은 `ArenaScene`의 좌측 하단 킬로그가 담당합니다.
- **`deathStats.js`**: `GET /api/death-stats/today`, `POST /api/death-stats/today`를 호출하는 프론트엔드 API 래퍼입니다. - **`deathStats.js`**: `GET /api/death-stats/today`, `POST /api/death-stats/today`를 호출하는 프론트엔드 API 래퍼입니다.
- **`visitorCounter.js`**: `POST /api/visitors/check`를 호출하고, 응답의 `uniqueVisitors` 값을 전투 화면 우측 하단의 `#visitor-count` 배지에 표시합니다. - **`visitorCounter.js`**: `POST /api/visitors/check`를 호출하고, 응답의 `uniqueVisitors` 값을 전투 화면 우측 하단의 `#visitor-count` 배지에 표시합니다.
@ -79,6 +83,8 @@
this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP; 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`)을 그려줍니다. 미니맵은 전장 전체를 축소하여 보여주는 독립된 카메라입니다. 주 카메라가 비추는 영역을 계산하여 미니맵 위에 사각형(`graphics`)을 그려줍니다.
- `camera.displayWidth / zoom` 등을 이용하여 현재 월드에서 보이는 실제 영역 크기를 계산합니다. - `camera.displayWidth / zoom` 등을 이용하여 현재 월드에서 보이는 실제 영역 크기를 계산합니다.
@ -91,11 +97,12 @@ this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATO
3. 전투 중 drawer 우측 상단의 `옵션 접기/옵션 펼치기` 버튼으로 설정 패널을 접거나 다시 펼칩니다. 접힌 상태에서는 같은 우측 상단 위치에 토글 버튼만 남아 전투 화면을 가리지 않습니다. 3. 전투 중 drawer 우측 상단의 `옵션 접기/옵션 펼치기` 버튼으로 설정 패널을 접거나 다시 펼칩니다. 접힌 상태에서는 같은 우측 상단 위치에 토글 버튼만 남아 전투 화면을 가리지 않습니다.
4. drawer 안의 `재시작` 버튼은 현재 입력값으로 새 매치를 시작합니다. 4. drawer 안의 `재시작` 버튼은 현재 입력값으로 새 매치를 시작합니다.
5. drawer 안의 `일시정지` 버튼은 `ArenaScene.togglePause()`를 호출하고, 정지 중에는 버튼 문구를 `계속`으로 바꾸며 `#app.match-paused`를 설정합니다. 5. drawer 안의 `일시정지` 버튼은 `ArenaScene.togglePause()`를 호출하고, 정지 중에는 버튼 문구를 `계속`으로 바꾸며 `#app.match-paused`를 설정합니다.
6. 팀 badge는 경기장 캔버스 위가 아니라 좌측 HUD 레일에 배치합니다. 레일 폭은 CSS 변수 `--score-panel-width`, `--score-rail-width`로 계산되며 미니맵과 겹치지 않습니다. 6. 참가자 닉네임과 팀당 인원은 입력이 바뀔 때마다 `localStorage`에 저장하고 앱 로드 시 먼저 복원합니다.
7. badge는 팀명, 팀 색상 구분선, 생존 인원 순서로 표기하고, 클릭 시 해당 팀 생존 전투원 중 무작위 1명으로 관전 시점을 고정합니다. 7. 팀 badge는 경기장 캔버스 위가 아니라 좌측 HUD 레일에 배치합니다. 레일 폭은 CSS 변수 `--score-panel-width`, `--score-rail-width`로 계산되며 미니맵과 겹치지 않습니다.
8. 전투 중 하단 `match-status` 안내바는 숨깁니다. 처치가 발생하면 좌측 하단 `kill-log`가 처치자와 피처치자를 좌우로 나누고, 각 항목에 작은 idle 스프라이트 이미지, 팀명, `manifest.key`를 표시합니다. 중앙에는 칼 아이콘과 `처치 >` 방향 텍스트를 둬 왼쪽 캐릭터가 오른쪽 캐릭터를 처치했다는 관계를 명확히 보여줍니다. 8. badge는 팀명, 팀 색상 구분선, 생존 인원 순서로 표기하고, 클릭 시 해당 팀 생존 전투원 중 무작위 1명으로 관전 시점을 고정합니다.
9. 실제 전투가 5초 이상 지속되면 `#battle-notice` 상단 안내바가 표시됩니다. 문구는 오늘 서버 집계와 현재 전투의 사망 수를 합산한 뒤 가장 많이 사망한 종족을 기준으로 생성하며, 전투 화면보다 넓어 보이지 않는 작은 폭으로 2초 표시 후 10초 대기하는 주기로 다음 문구를 보여줍니다. 9. 전투 중 하단 `match-status` 안내바는 숨깁니다. 처치가 발생하면 좌측 하단 `kill-log`가 처치자와 피처치자를 좌우로 나누고, 각 항목에 작은 idle 스프라이트 이미지, 팀명, `manifest.key`를 표시합니다. 중앙에는 칼 아이콘과 `처치 >` 방향 텍스트를 둬 왼쪽 캐릭터가 오른쪽 캐릭터를 처치했다는 관계를 명확히 보여줍니다.
10. 방문자 수는 메인 화면에서는 숨기고, 실제 전투 중에만 우측 하단의 작은 `#visitor-count` 배지로 표시합니다. 10. 실제 전투가 5초 이상 지속되면 `#battle-notice` 상단 안내바가 표시됩니다. 문구는 오늘 서버 집계와 현재 전투의 사망 수를 합산한 뒤 가장 많이 사망한 종족을 기준으로 생성하며, 전투 화면보다 넓어 보이지 않는 작은 폭으로 2초 표시 후 10초 대기하는 주기로 다음 문구를 보여줍니다.
11. 방문자 수는 메인 화면에서는 숨기고, 실제 전투 중에만 우측 하단의 작은 `#visitor-count` 배지로 표시합니다.
### 상시 팀 색상 실루엣 ### 상시 팀 색상 실루엣
팀 색상 표시는 히트박스 사각형이 아니라 캐릭터 모양을 따라가는 별도 spritesheet입니다. 팀 색상 표시는 히트박스 사각형이 아니라 캐릭터 모양을 따라가는 별도 spritesheet입니다.
@ -105,6 +112,7 @@ this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATO
3. 그 바깥 `SELECTED_FIGHTER_OUTLINE_WIDTH` 범위에만 흰색 outline을 칠합니다. 3. 그 바깥 `SELECTED_FIGHTER_OUTLINE_WIDTH` 범위에만 흰색 outline을 칠합니다.
4. `fighterFactory.js`가 생존 캐릭터 뒤에 `teamMarker` sprite를 배치하고, 팀 색상으로 tint 처리합니다. 4. `fighterFactory.js`가 생존 캐릭터 뒤에 `teamMarker` sprite를 배치하고, 팀 색상으로 tint 처리합니다.
5. `syncFighterHud()`가 현재 texture frame, flip 방향, scale, 위치를 원본 캐릭터와 동기화합니다. 5. `syncFighterHud()`가 현재 texture frame, flip 방향, scale, 위치를 원본 캐릭터와 동기화합니다.
6. 사망 캐릭터는 팀 색상 마커와 HUD를 숨기며, 본체 sprite만 낮은 depth와 반투명 상태로 남깁니다.
이 방식은 별도 에셋 없이도 캐릭터 모양에 맞춘 팀 색상 마커를 만들 수 있으며, 캐릭터가 처치 보상으로 커져도 마커가 같은 배율로 따라갑니다. 캐릭터 클릭은 카메라 포커스 기능만 담당하고, 팀 색상 마커는 선택 여부와 관계없이 유지됩니다. 이 방식은 별도 에셋 없이도 캐릭터 모양에 맞춘 팀 색상 마커를 만들 수 있으며, 캐릭터가 처치 보상으로 커져도 마커가 같은 배율로 따라갑니다. 캐릭터 클릭은 카메라 포커스 기능만 담당하고, 팀 색상 마커는 선택 여부와 관계없이 유지됩니다.

View File

@ -41,12 +41,12 @@
│ ├── combat.js # 전투 AI 및 피격 판정 로직 │ ├── combat.js # 전투 AI 및 피격 판정 로직
│ ├── combatSettings.js# 전투 속도 및 이동 배율 관리 │ ├── combatSettings.js# 전투 속도 및 이동 배율 관리
│ ├── fighterAssets.js# 스프라이트 시트 로드, 애니메이션 및 팀 색상 실루엣 마스크 생성 │ ├── fighterAssets.js# 스프라이트 시트 로드, 애니메이션 및 팀 색상 실루엣 마스크 생성
│ ├── fighterFactory.js# 캐릭터 객체, 히트박스, HUD, 개별 체력 및 팀 색상 마커 동기화 │ ├── fighterFactory.js# 캐릭터 객체, 히트박스, HUD, 개별 체력, 사망자 HUD 숨김 및 팀 색상 마커 동기화
│ ├── fighterManifest.js# 캐릭터 스킨/종족/전투/스탯/특성 데이터 정의 (20종 캐릭터 상세 설정) │ ├── fighterManifest.js# 캐릭터 스킨/종족/전투/스탯/특성 데이터 정의 (20종 캐릭터 상세 설정)
│ ├── fighterSelection.js# 무작위 캐릭터 스킨 선택 로직 │ ├── fighterSelection.js# 무작위 캐릭터 스킨 선택 로직
│ └── matchSetup.js # 닉네임 기반 팀 구성 및 스폰 좌표 계산 │ └── matchSetup.js # 닉네임 기반 팀 구성 및 스폰 좌표 계산
└── ui/ └── ui/
├── matchForm.js # 참가자 입력 폼 및 팀 설정 UI 제어 ├── matchForm.js # 참가자 입력 폼, 팀 설정 UI 제어 및 localStorage 설정 유지
├── deathStats.js # 전투 사망 통계 API 호출 ├── deathStats.js # 전투 사망 통계 API 호출
└── visitorCounter.js# 방문자 체크 API 호출 및 UI 갱신 └── visitorCounter.js# 방문자 체크 API 호출 및 UI 갱신
``` ```
@ -55,14 +55,17 @@
- **닉네임 기반 팀 스폰**: 입력된 각 닉네임을 독립된 팀으로 인식하고, 설정된 인원(1~100명)만큼 분신 캐릭터를 소환합니다. 캐릭터 특성에 따라 실제 출전 수가 달라질 수 있으며, Slime은 배정된 기본 스폰 슬롯마다 10마리로 확장됩니다. - **닉네임 기반 팀 스폰**: 입력된 각 닉네임을 독립된 팀으로 인식하고, 설정된 인원(1~100명)만큼 분신 캐릭터를 소환합니다. 캐릭터 특성에 따라 실제 출전 수가 달라질 수 있으며, Slime은 배정된 기본 스폰 슬롯마다 10마리로 확장됩니다.
- **전투 진입 및 제어 UI**: 최초 접속 화면은 투명 전투 프리뷰를 배경으로 `Arena` 로고와 `Start` 버튼을 표시합니다. `Start`를 누르면 우측 옵션 drawer가 열리고, 전투 시작 후에도 drawer는 compact 패널로 유지됩니다. 패널 우측 상단의 `옵션 접기/옵션 펼치기` 버튼으로 전장 시야를 확보할 수 있으며, 패널 안에서 `재시작``일시정지/계속`을 제어합니다. - **전투 진입 및 제어 UI**: 최초 접속 화면은 투명 전투 프리뷰를 배경으로 `Arena` 로고와 `Start` 버튼을 표시합니다. `Start`를 누르면 우측 옵션 drawer가 열리고, 전투 시작 후에도 drawer는 compact 패널로 유지됩니다. 패널 우측 상단의 `옵션 접기/옵션 펼치기` 버튼으로 전장 시야를 확보할 수 있으며, 패널 안에서 `재시작``일시정지/계속`을 제어합니다.
- **전투 설정 유지**: 참가자 닉네임과 팀당 인원은 브라우저 `localStorage`에 저장되어 새로고침하거나 다시 접속해도 입력값이 유지됩니다.
- **지능형 카메라 시스템**: - **지능형 카메라 시스템**:
- **자동 전투 관전**: 화면 확대 시 인접한 교전 중인 캐릭터 쌍을 찾아 부드럽게 추적(Lerp)합니다. - **자동 전투 관전**: 화면 확대 시 인접한 교전 중인 캐릭터 쌍을 찾아 부드럽게 추적(Lerp)합니다.
- **미니맵 연동**: 줌 인 상태에서 전장 전체 상황과 현재 뷰포트 위치를 미니맵에 가이드라인으로 표시합니다. - **미니맵 연동**: 줌 인 상태에서 전장 전체 상황과 현재 뷰포트 위치를 미니맵에 가이드라인으로 표시합니다.
- **최종교전 연출**: 생존 4명 이하에서는 카메라가 생존 캐릭터를 무작위로 전환 포커싱하고, 잔여 2팀의 생존 합이 8명 이하이면 생존 수가 더 적은 팀을 포커싱합니다. 이 최종교전 구간에서 공격 모션이 시작되면 짧은 슬로우모션을 적용합니다.
- **역동적인 전투 연출**: - **역동적인 전투 연출**:
- 캐릭터별 고유 공격 방식(근접, 투사체, 마법) 및 애니메이션. - 캐릭터별 고유 공격 방식(근접, 투사체, 마법) 및 애니메이션.
- `ATTACK_DAMAGE_MIN/MAX`로 기본 공격력 범위를 관리하고, 치명타(Critical) 발생 시 즉시 처치 및 화면 흔들림 효과를 적용합니다. - `ATTACK_DAMAGE_MIN/MAX`로 기본 공격력 범위를 관리하고, 치명타(Critical) 발생 시 즉시 처치 및 화면 흔들림 효과를 적용합니다.
- 상대를 처치한 캐릭터는 체력을 현재 체력 기준 30% 회복하고, 처치 누적 배율에 따라 크기, 공격속도, 이동속도가 함께 상승합니다. 누적 보상은 `KILL_GROWTH_MAX_MULTIPLIER` 상한으로 제한해 캐릭터가 필드/히트박스를 벗어나 전투가 끝나지 않는 상황을 방지합니다. - 상대를 처치한 캐릭터는 체력을 현재 체력 기준 30% 회복하고, 처치 누적 배율에 따라 크기, 공격속도, 이동속도가 함께 상승합니다. 누적 보상은 `KILL_GROWTH_MAX_MULTIPLIER` 상한으로 제한해 캐릭터가 필드/히트박스를 벗어나 전투가 끝나지 않는 상황을 방지합니다.
- 캐릭터별 종족(`human`, `orc`, `skeleton`, `slime`, `wolf`, `bear`)과 스탯/특성을 `fighterManifest.js`에서 정의할 수 있습니다. Slime은 최대 체력 1이며, 사망 시 50% 확률로 최대 체력 1인 Slime 2마리로 분열합니다. 분열체는 다시 분열하지 않습니다. - 캐릭터별 종족(`human`, `orc`, `skeleton`, `slime`, `wolf`, `bear`)과 스탯/특성을 `fighterManifest.js`에서 정의할 수 있습니다. Slime은 최대 체력 1이며, 사망 시 50% 확률로 최대 체력 1인 Slime 2마리로 분열합니다. 분열체는 다시 분열하지 않습니다.
- 사망한 캐릭터는 반투명하게 표시하고 살아있는 캐릭터보다 낮은 depth로 내려, 움직이는 전투원을 가리지 않도록 유지합니다.
- **상시 팀 색상 표시 및 선택 관전**: 생존 캐릭터는 항상 팀 색상 실루엣 마커를 표시합니다. 캐릭터를 클릭하면 해당 캐릭터에 카메라가 고정되며, 팀 색상 마커는 선택 여부와 관계없이 유지됩니다. 좌측 팀 badge를 클릭하면 해당 팀의 생존 캐릭터 중 무작위 1명으로 시점이 고정됩니다. - **상시 팀 색상 표시 및 선택 관전**: 생존 캐릭터는 항상 팀 색상 실루엣 마커를 표시합니다. 캐릭터를 클릭하면 해당 캐릭터에 카메라가 고정되며, 팀 색상 마커는 선택 여부와 관계없이 유지됩니다. 좌측 팀 badge를 클릭하면 해당 팀의 생존 캐릭터 중 무작위 1명으로 시점이 고정됩니다.
- **실시간 경기 중계 UI**: 팀 badge는 경기장 밖 좌측 HUD 레일에 고정되어 미니맵을 가리지 않습니다. 각 badge는 팀명, 구분선, 현재 생존 인원 순서로 표시되며, 클릭 가능한 관전 진입점으로 동작합니다. 전투 중 하단 안내바는 숨기고, 좌측 하단 킬로그에 처치자와 피처치자를 좌우로 나눠 작은 캐릭터 이미지, 팀명, `manifest.key`, 칼 아이콘, `처치 >` 방향 표시를 목록으로 보여줍니다. 실제 전투가 5초 이상 지속되면 작은 상단 안내바에 오늘의 종족별 사망 집계를 2초 표시하고 10초 쉬는 주기로 재치 있게 보여줍니다. 승리 시 대형 배너로 결과를 알립니다. - **실시간 경기 중계 UI**: 팀 badge는 경기장 밖 좌측 HUD 레일에 고정되어 미니맵을 가리지 않습니다. 각 badge는 팀명, 구분선, 현재 생존 인원 순서로 표시되며, 클릭 가능한 관전 진입점으로 동작합니다. 전투 중 하단 안내바는 숨기고, 좌측 하단 킬로그에 처치자와 피처치자를 좌우로 나눠 작은 캐릭터 이미지, 팀명, `manifest.key`, 칼 아이콘, `처치 >` 방향 표시를 목록으로 보여줍니다. 실제 전투가 5초 이상 지속되면 작은 상단 안내바에 오늘의 종족별 사망 집계를 2초 표시하고 10초 쉬는 주기로 재치 있게 보여줍니다. 승리 시 대형 배너로 결과를 알립니다.
- **유니크 방문자 체크**: 접속 시 `POST /api/visitors/check`를 호출하고, 서버가 `HttpOnly` 쿠키 기반 UUID로 MongoDB에 방문자 1명당 1개 문서를 유지합니다. 방문자 수는 메인 화면이 아니라 실제 전투 화면의 우측 하단에 작은 배지로 표시합니다. - **유니크 방문자 체크**: 접속 시 `POST /api/visitors/check`를 호출하고, 서버가 `HttpOnly` 쿠키 기반 UUID로 MongoDB에 방문자 1명당 1개 문서를 유지합니다. 방문자 수는 메인 화면이 아니라 실제 전투 화면의 우측 하단에 작은 배지로 표시합니다.

View File

@ -17,6 +17,9 @@ export const ATTACK_DAMAGE_MAX = 24;
export const DEFAULT_TEAM_SIZE = 5; export const DEFAULT_TEAM_SIZE = 5;
// 캐릭터 스프라이트의 기본 화면 배율입니다. // 캐릭터 스프라이트의 기본 화면 배율입니다.
export const FIGHTER_SCALE = 3; export const FIGHTER_SCALE = 3;
export const FIGHTER_DEPTH = 2;
export const DEAD_FIGHTER_DEPTH = 1;
export const DEAD_FIGHTER_ALPHA = 0.42;
// 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 너비입니다. // 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 너비입니다.
export const FIGHTER_FRAME_WIDTH = 100; export const FIGHTER_FRAME_WIDTH = 100;
// 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 높이입니다. // 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 높이입니다.
@ -97,6 +100,11 @@ export const SPECTATOR_CAMERA_LERP = 0.1;
export const SPECTATOR_FINAL_FIGHTER_THRESHOLD = 5; export const SPECTATOR_FINAL_FIGHTER_THRESHOLD = 5;
// 최종 전투 구간에서 강제로 적용되는 카메라 줌입니다. // 최종 전투 구간에서 강제로 적용되는 카메라 줌입니다.
export const SPECTATOR_FINAL_FIGHT_ZOOM = 3; export const SPECTATOR_FINAL_FIGHT_ZOOM = 3;
export const SPECTATOR_FINAL_TEAM_COUNT = 2;
export const SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD = 8;
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 SPECTATOR_LATE_FIGHTER_THRESHOLD = 30; export const SPECTATOR_LATE_FIGHTER_THRESHOLD = 30;
// 후반 전투 구간에서 강제로 적용되는 카메라 줌입니다. // 후반 전투 구간에서 강제로 적용되는 카메라 줌입니다.
@ -168,11 +176,14 @@ export function getTeamColor(index, totalTeams = TEAM_COLORS.length) {
return TEAM_COLORS[safeIndex % TEAM_COLORS.length]; return TEAM_COLORS[safeIndex % TEAM_COLORS.length];
} }
const hue = (TEAM_COLOR_HUE_OFFSET + safeIndex * TEAM_COLOR_GOLDEN_ANGLE) % 360; const hue =
const saturation = TEAM_COLOR_SATURATIONS[safeIndex % TEAM_COLOR_SATURATIONS.length]; (TEAM_COLOR_HUE_OFFSET + safeIndex * TEAM_COLOR_GOLDEN_ANGLE) % 360;
const saturation =
TEAM_COLOR_SATURATIONS[safeIndex % TEAM_COLOR_SATURATIONS.length];
const lightness = const lightness =
TEAM_COLOR_LIGHTNESSES[ TEAM_COLOR_LIGHTNESSES[
Math.floor(safeIndex / TEAM_COLOR_SATURATIONS.length) % TEAM_COLOR_LIGHTNESSES.length Math.floor(safeIndex / TEAM_COLOR_SATURATIONS.length) %
TEAM_COLOR_LIGHTNESSES.length
]; ];
return hslToHex(hue, saturation, lightness); return hslToHex(hue, saturation, lightness);
@ -181,23 +192,29 @@ export function getTeamColor(index, totalTeams = TEAM_COLORS.length) {
function hslToHex(hue, saturation, lightness) { function hslToHex(hue, saturation, lightness) {
const normalizedSaturation = saturation / 100; const normalizedSaturation = saturation / 100;
const normalizedLightness = lightness / 100; const normalizedLightness = lightness / 100;
const chroma = (1 - Math.abs(2 * normalizedLightness - 1)) * normalizedSaturation; const chroma =
(1 - Math.abs(2 * normalizedLightness - 1)) * normalizedSaturation;
const huePrime = hue / 60; const huePrime = hue / 60;
const x = chroma * (1 - Math.abs((huePrime % 2) - 1)); const x = chroma * (1 - Math.abs((huePrime % 2) - 1));
const match = normalizedLightness - chroma / 2; const match = normalizedLightness - chroma / 2;
const [red, green, blue] = huePrime < 1 const [red, green, blue] =
? [chroma, x, 0] huePrime < 1
: huePrime < 2 ? [chroma, x, 0]
? [x, chroma, 0] : huePrime < 2
: huePrime < 3 ? [x, chroma, 0]
? [0, chroma, x] : huePrime < 3
: huePrime < 4 ? [0, chroma, x]
? [0, x, chroma] : huePrime < 4
: huePrime < 5 ? [0, x, chroma]
? [x, 0, chroma] : huePrime < 5
: [chroma, 0, x]; ? [x, 0, chroma]
: [chroma, 0, x];
return `#${[red, green, blue] return `#${[red, green, blue]
.map((channel) => Math.round((channel + match) * 255).toString(16).padStart(2, "0")) .map((channel) =>
Math.round((channel + match) * 255)
.toString(16)
.padStart(2, "0"),
)
.join("")}`; .join("")}`;
} }

View File

@ -4,6 +4,8 @@ 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_SCALE,
MINIMAP_ALPHA, MINIMAP_ALPHA,
MINIMAP_MARGIN, MINIMAP_MARGIN,
MINIMAP_VIEWPORT_SIZE, MINIMAP_VIEWPORT_SIZE,
@ -12,9 +14,12 @@ import {
SELECTED_FIGHTER_CAMERA_ZOOM, SELECTED_FIGHTER_CAMERA_ZOOM,
SPECTATOR_CAMERA_LERP, SPECTATOR_CAMERA_LERP,
SPECTATOR_FINAL_FIGHTER_THRESHOLD, SPECTATOR_FINAL_FIGHTER_THRESHOLD,
SPECTATOR_FINAL_TEAM_COUNT,
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD,
SPECTATOR_FINAL_FIGHT_ZOOM, SPECTATOR_FINAL_FIGHT_ZOOM,
SPECTATOR_LATE_FIGHTER_THRESHOLD, SPECTATOR_LATE_FIGHTER_THRESHOLD,
SPECTATOR_LATE_FIGHT_ZOOM, SPECTATOR_LATE_FIGHT_ZOOM,
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.js";
@ -61,6 +66,11 @@ export class ArenaScene extends Phaser.Scene {
this.battleDeathCounts = createDeathCounts(); this.battleDeathCounts = createDeathCounts();
this.deathStatsBaseline = createDeathCounts(); this.deathStatsBaseline = createDeathCounts();
this.deathStatsSaved = false; this.deathStatsSaved = false;
this.finalFocusNextSwitchAt = 0;
this.finalFocusTarget = null;
this.spectatorMode = null;
this.slowMotionRestoreState = null;
this.slowMotionTimer = null;
} }
preload() { preload() {
@ -133,6 +143,7 @@ export class ArenaScene extends Phaser.Scene {
this.matchId += 1; this.matchId += 1;
this.matchOver = false; this.matchOver = false;
this.setPaused(false, { silent: true }); this.setPaused(false, { silent: true });
this.clearFinalCombatEffects();
this.presentationMode = silent; this.presentationMode = silent;
this.resetMatchDeathStats({ silent }); this.resetMatchDeathStats({ silent });
this.observedCombat = []; this.observedCombat = [];
@ -405,23 +416,13 @@ update(time) {
} }
// 확대 상태일 때 생존 캐릭터들의 중앙으로 카메라 이동 // 확대 상태일 때 생존 캐릭터들의 중앙으로 카메라 이동
const livingFighterCount = this.fighters.filter(isLivingFighter).length; const livingFighters = this.fighters.filter(isLivingFighter);
const forcedSpectatorZoom = getForcedSpectatorZoom(livingFighterCount); const spectatorState = getSpectatorState(livingFighters);
this.syncSpectatorMode(spectatorState?.mode ?? null);
if (forcedSpectatorZoom) { if (spectatorState) {
this.setMainCameraZoom(forcedSpectatorZoom); this.setMainCameraZoom(spectatorState.zoom);
this.moveCameraToward(this.getSpectatorCameraTarget(spectatorState, livingFighters, time));
const combatCenter = this.getObservedCombatCenter();
if (combatCenter) {
// 소수점 단위 변동으로 인한 지터링 방지를 위해 반올림 처리 및 부드러운 이동(Lerp) 적용
const targetX = Math.round(combatCenter.x);
const targetY = Math.round(combatCenter.y);
// Move from the current world-space camera center toward the target.
this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP;
this.cameras.main.scrollY += (targetY - this.cameras.main.midPoint.y) * SPECTATOR_CAMERA_LERP;
}
} else if (this.cameras.main.zoom <= CAMERA_MIN_ZOOM) { } else if (this.cameras.main.zoom <= CAMERA_MIN_ZOOM) {
// 줌이 1일 때는 경기장 중앙에 고정 // 줌이 1일 때는 경기장 중앙에 고정
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2); this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
@ -430,6 +431,143 @@ update(time) {
this.updateMinimapViewportFrame(); this.updateMinimapViewportFrame();
} }
syncSpectatorMode(mode) {
if (this.spectatorMode === mode) {
return;
}
this.spectatorMode = mode;
this.finalFocusNextSwitchAt = 0;
this.finalFocusTarget = null;
if (mode !== "final-random") {
this.observedCombat = [];
}
}
getSpectatorCameraTarget(spectatorState, livingFighters, time) {
if (spectatorState.mode === "final-random") {
return this.getRandomFinalFocusTarget(livingFighters, time);
}
if (spectatorState.mode === "final-underdog" && spectatorState.teamId) {
const underdogFighters = livingFighters.filter(
(fighter) => fighter.team.id === spectatorState.teamId,
);
return averageFighterPosition(underdogFighters) ?? this.getObservedCombatCenter();
}
return this.getObservedCombatCenter();
}
getRandomFinalFocusTarget(livingFighters, time) {
const candidates = livingFighters.filter(isLivingFighter);
if (candidates.length === 0) {
this.finalFocusTarget = null;
return null;
}
const shouldPickNext =
!isLivingFighter(this.finalFocusTarget) || time >= this.finalFocusNextSwitchAt;
if (shouldPickNext) {
const nextCandidates = candidates.length > 1
? candidates.filter((fighter) => fighter !== this.finalFocusTarget)
: candidates;
this.finalFocusTarget = nextCandidates[Phaser.Math.Between(0, nextCandidates.length - 1)];
this.finalFocusNextSwitchAt = time + SPECTATOR_RANDOM_FOCUS_INTERVAL;
}
return fighterCameraPoint(this.finalFocusTarget);
}
moveCameraToward(target) {
if (!target) {
return;
}
const targetX = Math.round(target.x);
const targetY = Math.round(target.y);
this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP;
this.cameras.main.scrollY += (targetY - this.cameras.main.midPoint.y) * SPECTATOR_CAMERA_LERP;
}
isFinalCombatActive() {
return Boolean(getSpectatorState(this.fighters.filter(isLivingFighter))?.isFinal);
}
triggerFinalCombatSlowMotion() {
if (this.presentationMode || this.matchOver || this.matchPaused || !this.isFinalCombatActive()) {
return;
}
if (!this.slowMotionRestoreState) {
this.slowMotionRestoreState = {
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) {
globalThis.clearTimeout(this.slowMotionTimer);
}
this.slowMotionTimer = globalThis.setTimeout(() => {
this.clearFinalCombatEffects();
}, FINAL_COMBAT_SLOW_MOTION_DURATION);
}
applySceneTimeScale(scale) {
if (this.time) {
this.time.timeScale = scale;
}
if (this.physics?.world) {
this.physics.world.timeScale = scale;
}
if (this.tweens) {
this.tweens.timeScale = scale;
}
if (this.anims) {
this.anims.globalTimeScale = scale;
}
}
clearFinalCombatEffects() {
if (this.slowMotionTimer) {
globalThis.clearTimeout(this.slowMotionTimer);
this.slowMotionTimer = null;
}
if (this.slowMotionRestoreState) {
const restore = this.slowMotionRestoreState;
this.slowMotionRestoreState = null;
if (this.time) {
this.time.timeScale = restore.clock;
}
if (this.physics?.world) {
this.physics.world.timeScale = restore.physics;
}
if (this.tweens) {
this.tweens.timeScale = restore.tweens;
}
if (this.anims) {
this.anims.globalTimeScale = restore.animations;
}
}
}
selectFighter(fighter) { selectFighter(fighter) {
if (!isLivingFighter(fighter)) { if (!isLivingFighter(fighter)) {
return; return;
@ -624,7 +762,7 @@ update(time) {
observeCombat(attacker, defender) { observeCombat(attacker, defender) {
const canObserveCombat = Boolean( const canObserveCombat = Boolean(
getForcedSpectatorZoom(this.fighters.filter(isLivingFighter).length), getSpectatorState(this.fighters.filter(isLivingFighter)),
); );
if (!canObserveCombat || !isLivingOpponentPair([attacker, defender])) { if (!canObserveCombat || !isLivingOpponentPair([attacker, defender])) {
@ -713,6 +851,7 @@ update(time) {
} }
this.matchOver = true; this.matchOver = true;
this.clearFinalCombatEffects();
clearCombatObjects(this); clearCombatObjects(this);
this.fighters.forEach((fighter) => { this.fighters.forEach((fighter) => {
if (fighter.body) { if (fighter.body) {
@ -935,18 +1074,102 @@ function findClosestOpponentPair(fighters) {
return closestPair; return closestPair;
} }
function getForcedSpectatorZoom(livingFighterCount) { function getSpectatorState(livingFighters) {
const livingFighterCount = livingFighters.length;
const teamSummaries = getLivingTeamSummaries(livingFighters);
if (livingFighterCount < SPECTATOR_FINAL_FIGHTER_THRESHOLD) { if (livingFighterCount < SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
return SPECTATOR_FINAL_FIGHT_ZOOM; 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) { if (livingFighterCount < SPECTATOR_LATE_FIGHTER_THRESHOLD) {
return SPECTATOR_LATE_FIGHT_ZOOM; return {
isFinal: false,
mode: "late",
zoom: SPECTATOR_LATE_FIGHT_ZOOM,
};
} }
return null; 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) { function isLivingOpponentPair(pair) {
if (pair.length !== 2) { if (pair.length !== 2) {
return false; return false;

View File

@ -4,6 +4,8 @@ import {
ATTACK_COOLDOWN, ATTACK_COOLDOWN,
ATTACK_DAMAGE_MAX, ATTACK_DAMAGE_MAX,
ATTACK_DAMAGE_MIN, ATTACK_DAMAGE_MIN,
DEAD_FIGHTER_ALPHA,
DEAD_FIGHTER_DEPTH,
ATTACK_RANGE, ATTACK_RANGE,
FIGHTER_MAX_HP, FIGHTER_MAX_HP,
FIGHTER_SCALE, FIGHTER_SCALE,
@ -80,6 +82,7 @@ function beginAttack(scene, attacker, defender, time, onWinner) {
time + scaledAttackDelay(attacker.skin.combat?.cooldown ?? ATTACK_COOLDOWN, attacker); time + scaledAttackDelay(attacker.skin.combat?.cooldown ?? ATTACK_COOLDOWN, attacker);
attacker.isLocked = true; attacker.isLocked = true;
scene.observeCombat?.(attacker, defender); scene.observeCombat?.(attacker, defender);
scene.triggerFinalCombatSlowMotion?.(attacker, defender, attack.animation);
playAnimation(attacker, attack.animation, fighterAttackSpeedMultiplier(attacker)); playAnimation(attacker, attack.animation, fighterAttackSpeedMultiplier(attacker));
switch (getCombatType(attacker)) { switch (getCombatType(attacker)) {
@ -349,6 +352,13 @@ function killFighter(defender, winner, onWinner) {
defender.body.setVelocity(0, 0); defender.body.setVelocity(0, 0);
defender.body.enable = false; defender.body.enable = false;
defender.healthBar.width = 0; defender.healthBar.width = 0;
defender.setAlpha(DEAD_FIGHTER_ALPHA);
defender.setDepth(DEAD_FIGHTER_DEPTH);
defender.disableInteractive();
defender.teamMarker?.setVisible(false);
defender.nameLabel?.setVisible(false);
defender.healthBack?.setVisible(false);
defender.healthBar?.setVisible(false);
playAnimation(defender, "death"); playAnimation(defender, "death");
winner.isLocked = false; winner.isLocked = false;
winner.body.setVelocity(0, 0); winner.body.setVelocity(0, 0);

View File

@ -2,6 +2,7 @@ import Phaser from "phaser";
import { import {
FIGHTER_FRAME_HEIGHT, FIGHTER_FRAME_HEIGHT,
FIGHTER_FRAME_WIDTH, FIGHTER_FRAME_WIDTH,
FIGHTER_DEPTH,
FIGHTER_HITBOX_HEIGHT, FIGHTER_HITBOX_HEIGHT,
FIGHTER_HITBOX_OFFSET_X, FIGHTER_HITBOX_OFFSET_X,
FIGHTER_HITBOX_OFFSET_Y, FIGHTER_HITBOX_OFFSET_Y,
@ -32,7 +33,8 @@ export function createFighter(
fighter.setScale(FIGHTER_SCALE); fighter.setScale(FIGHTER_SCALE);
fighter.setName(displayName); fighter.setName(displayName);
fighter.setDepth(2); fighter.setDepth(FIGHTER_DEPTH);
fighter.setAlpha(1);
fighter.setCollideWorldBounds(true); fighter.setCollideWorldBounds(true);
fighter.setFlipX(faceLeft); fighter.setFlipX(faceLeft);
fighter.body.setSize(FIGHTER_HITBOX_WIDTH, FIGHTER_HITBOX_HEIGHT); fighter.body.setSize(FIGHTER_HITBOX_WIDTH, FIGHTER_HITBOX_HEIGHT);
@ -109,6 +111,17 @@ export function createFighter(
} }
export function syncFighterHud(fighter) { export function syncFighterHud(fighter) {
const isVisible = Boolean(fighter.active && !fighter.isDead);
fighter.nameLabel.setVisible(isVisible);
fighter.healthBack.setVisible(isVisible);
fighter.healthBar.setVisible(isVisible);
syncTeamMarker(fighter);
if (!isVisible || !fighter.body) {
return;
}
const scaleRatio = Math.max(1, Math.abs(fighter.scaleY) / FIGHTER_SCALE); const scaleRatio = Math.max(1, Math.abs(fighter.scaleY) / FIGHTER_SCALE);
const healthOffset = 44 * scaleRatio; const healthOffset = 44 * scaleRatio;
const hitbox = fighter.body; const hitbox = fighter.body;
@ -119,7 +132,6 @@ export function syncFighterHud(fighter) {
fighter.healthBack.setPosition(fighter.x, fighter.y - healthOffset); fighter.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
fighter.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset); fighter.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? FIGHTER_MAX_HP))); fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? FIGHTER_MAX_HP)));
syncTeamMarker(fighter);
} }
function syncTeamMarker(fighter) { function syncTeamMarker(fighter) {

View File

@ -1,5 +1,10 @@
import { NICKNAME_LENGTH } from "../constants.js"; import { NICKNAME_LENGTH } from "../constants.js";
const STORAGE_KEYS = {
names: "arena.match.playerNames",
teamSize: "arena.match.teamSize",
};
export function createMatchForm() { export function createMatchForm() {
const form = getElement("#fighter-form"); const form = getElement("#fighter-form");
const namesInput = getElement("#player-names"); const namesInput = getElement("#player-names");
@ -14,9 +19,14 @@ export function createMatchForm() {
teamSize: Number(teamSizeInput.value), teamSize: Number(teamSizeInput.value),
}); });
restoreSavedMatchSettings(namesInput, teamSizeInput);
syncTeamSizeOutput(teamSizeInput, teamSizeOutput); syncTeamSizeOutput(teamSizeInput, teamSizeOutput);
namesInput.addEventListener("input", () => {
saveMatchSettings(namesInput, teamSizeInput);
});
teamSizeInput.addEventListener("input", () => { teamSizeInput.addEventListener("input", () => {
syncTeamSizeOutput(teamSizeInput, teamSizeOutput); syncTeamSizeOutput(teamSizeInput, teamSizeOutput);
saveMatchSettings(namesInput, teamSizeInput);
}); });
return { return {
@ -62,3 +72,63 @@ function nicknameValues(value) {
function syncTeamSizeOutput(input, output) { function syncTeamSizeOutput(input, output) {
output.textContent = input.value; output.textContent = input.value;
} }
function restoreSavedMatchSettings(namesInput, teamSizeInput) {
const storage = getLocalStorage();
if (!storage) {
return;
}
try {
const savedNames = storage.getItem(STORAGE_KEYS.names);
const savedTeamSize = storage.getItem(STORAGE_KEYS.teamSize);
if (savedNames !== null) {
namesInput.value = savedNames;
}
const normalizedTeamSize = normalizeTeamSize(savedTeamSize, teamSizeInput);
if (normalizedTeamSize) {
teamSizeInput.value = normalizedTeamSize;
}
} catch {
// Storage may be unavailable in private or restricted browser contexts.
}
}
function saveMatchSettings(namesInput, teamSizeInput) {
const storage = getLocalStorage();
if (!storage) {
return;
}
try {
storage.setItem(STORAGE_KEYS.names, namesInput.value);
storage.setItem(STORAGE_KEYS.teamSize, normalizeTeamSize(teamSizeInput.value, teamSizeInput));
} catch {
// Ignore storage failures so the match form remains usable.
}
}
function normalizeTeamSize(value, input) {
const min = Number(input.min) || 1;
const max = Number(input.max) || min;
const teamSize = Math.round(Number(value));
if (!Number.isFinite(teamSize)) {
return "";
}
return String(Math.min(max, Math.max(min, teamSize)));
}
function getLocalStorage() {
try {
return window.localStorage;
} catch {
return null;
}
}

16
todo.md
View File

@ -82,3 +82,19 @@
- 전투가 5초 이상 지속되면 상단 `#battle-notice`에 오늘 종족별 사망 집계를 2초 표시/10초 대기 주기의 재치 있는 안내 문구로 표시. - 전투가 5초 이상 지속되면 상단 `#battle-notice`에 오늘 종족별 사망 집계를 2초 표시/10초 대기 주기의 재치 있는 안내 문구로 표시.
- 상단 안내바 폭을 전투 화면 안쪽에 어울리도록 줄이고, 방문자 수는 메인 화면 대신 전투 화면 우측 하단 작은 배지로 이동. - 상단 안내바 폭을 전투 화면 안쪽에 어울리도록 줄이고, 방문자 수는 메인 화면 대신 전투 화면 우측 하단 작은 배지로 이동.
- `config.json.sample`에 사망 통계 컬렉션명과 집계 타임존 설정 예시를 추가. - `config.json.sample`에 사망 통계 컬렉션명과 집계 타임존 설정 예시를 추가.
14. 사망 캐릭터가 생존 캐릭터를 가리는 문제 개선 (완료)
- **조치 사항**:
- 사망한 캐릭터 sprite를 반투명 처리하고 생존 캐릭터보다 낮은 depth로 내려 전투원을 가리지 않도록 조정.
- 사망 캐릭터의 이름표, 체력바, 팀 색상 마커를 숨겨 전투 화면의 가독성을 유지.
15. 전투 설정 입력값 localStorage 유지 (완료)
- **조치 사항**:
- 참가자 닉네임 textarea와 팀당 인원 range 값을 브라우저 `localStorage`에 저장.
- 앱 로드 시 저장된 참가자 닉네임과 팀당 인원을 먼저 복원해 새로고침/재접속 후에도 입력값이 유지되도록 구현.
16. 최종교전 카메라 조건 및 슬로우모션 연출 추가 (완료)
- **조치 사항**:
- 생존 4명 이하에서는 카메라가 생존 캐릭터를 일정 간격으로 무작위 포커싱하도록 변경.
- 잔여 팀이 2팀이고 생존 캐릭터 합이 8명 이하이면 생존 수가 적은 팀의 중앙을 포커싱하도록 추가.
- 최종교전 상태에서 idle이 아닌 공격 모션이 시작될 때 짧은 전역 슬로우모션을 적용.