From a03dc02d01a76bc748225556cc649a7c6c74b904 Mon Sep 17 00:00:00 2001 From: Horoli Date: Fri, 22 May 2026 18:00:23 +0900 Subject: [PATCH] Add final combat polish and saved settings --- CONTEXT.md | 22 +++- agent.md | 7 +- src/constants.js | 49 ++++--- src/game/ArenaScene.js | 263 ++++++++++++++++++++++++++++++++++--- src/game/combat.js | 10 ++ src/game/fighterFactory.js | 16 ++- src/ui/matchForm.js | 70 ++++++++++ todo.md | 16 +++ 8 files changed, 406 insertions(+), 47 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 95756a7..66a0a0c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,6 +14,7 @@ - `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). @@ -48,6 +49,8 @@ - `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()`: 주 카메라의 이동에 맞춰 미니맵 가이드 사각형을 렌더링합니다. @@ -56,6 +59,7 @@ - **`combat.js`**: - `updateFighter()`: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다. - `applyHit()`: 일반 공격 피해량은 `ATTACK_DAMAGE_MIN/MAX` 범위에서 계산합니다. + - `killFighter()`: 사망한 캐릭터를 반투명 처리하고 살아있는 캐릭터보다 낮은 depth로 내려 전투원을 가리지 않게 합니다. - `applyKillReward()`: 처치한 캐릭터의 체력 회복, 크기 증가, 공격속도/이동속도 배율 증가를 처리합니다. 누적 배율은 `KILL_GROWTH_MAX_MULTIPLIER`로 제한합니다. - `clampFighterInsideArena()`: 처치 성장 tween 중/완료 시 커진 캐릭터가 arena 밖으로 밀려 히트박스가 전장 바깥에 놓이지 않도록 위치를 보정합니다. - `maybeSplitFighter()`: 사망한 캐릭터의 `traits.splitOnDeath` 특성을 확인하고 분열 생성을 요청합니다. @@ -63,9 +67,9 @@ ### [Assets & UI] - **`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`로 캐릭터별 기본 체력과 특수 규칙을 정의합니다. -- **`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 래퍼입니다. - **`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; ``` +최종교전 관전은 두 단계로 나뉩니다. 생존 4명 이하에서는 `SPECTATOR_RANDOM_FOCUS_INTERVAL`마다 생존 캐릭터 중 한 명을 무작위로 포커싱합니다. 잔여 팀이 2팀이고 생존 합이 8명 이하인 구간에서는 더 적은 생존 수를 가진 팀의 중앙을 포커싱하며, 동률이면 기존 교전쌍 중심 포커싱으로 되돌아갑니다. 최종교전 상태에서 `combat.js`가 공격 모션을 시작할 때 `FINAL_COMBAT_SLOW_MOTION_SCALE`을 짧게 적용합니다. + ### 미니맵 가이드라인 미니맵은 전장 전체를 축소하여 보여주는 독립된 카메라입니다. 주 카메라가 비추는 영역을 계산하여 미니맵 위에 사각형(`graphics`)을 그려줍니다. - `camera.displayWidth / zoom` 등을 이용하여 현재 월드에서 보이는 실제 영역 크기를 계산합니다. @@ -91,11 +97,12 @@ this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATO 3. 전투 중 drawer 우측 상단의 `옵션 접기/옵션 펼치기` 버튼으로 설정 패널을 접거나 다시 펼칩니다. 접힌 상태에서는 같은 우측 상단 위치에 토글 버튼만 남아 전투 화면을 가리지 않습니다. 4. drawer 안의 `재시작` 버튼은 현재 입력값으로 새 매치를 시작합니다. 5. drawer 안의 `일시정지` 버튼은 `ArenaScene.togglePause()`를 호출하고, 정지 중에는 버튼 문구를 `계속`으로 바꾸며 `#app.match-paused`를 설정합니다. -6. 팀 badge는 경기장 캔버스 위가 아니라 좌측 HUD 레일에 배치합니다. 레일 폭은 CSS 변수 `--score-panel-width`, `--score-rail-width`로 계산되며 미니맵과 겹치지 않습니다. -7. badge는 팀명, 팀 색상 구분선, 생존 인원 순서로 표기하고, 클릭 시 해당 팀 생존 전투원 중 무작위 1명으로 관전 시점을 고정합니다. -8. 전투 중 하단 `match-status` 안내바는 숨깁니다. 처치가 발생하면 좌측 하단 `kill-log`가 처치자와 피처치자를 좌우로 나누고, 각 항목에 작은 idle 스프라이트 이미지, 팀명, `manifest.key`를 표시합니다. 중앙에는 칼 아이콘과 `처치 >` 방향 텍스트를 둬 왼쪽 캐릭터가 오른쪽 캐릭터를 처치했다는 관계를 명확히 보여줍니다. -9. 실제 전투가 5초 이상 지속되면 `#battle-notice` 상단 안내바가 표시됩니다. 문구는 오늘 서버 집계와 현재 전투의 사망 수를 합산한 뒤 가장 많이 사망한 종족을 기준으로 생성하며, 전투 화면보다 넓어 보이지 않는 작은 폭으로 2초 표시 후 10초 대기하는 주기로 다음 문구를 보여줍니다. -10. 방문자 수는 메인 화면에서는 숨기고, 실제 전투 중에만 우측 하단의 작은 `#visitor-count` 배지로 표시합니다. +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입니다. @@ -105,6 +112,7 @@ this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATO 3. 그 바깥 `SELECTED_FIGHTER_OUTLINE_WIDTH` 범위에만 흰색 outline을 칠합니다. 4. `fighterFactory.js`가 생존 캐릭터 뒤에 `teamMarker` sprite를 배치하고, 팀 색상으로 tint 처리합니다. 5. `syncFighterHud()`가 현재 texture frame, flip 방향, scale, 위치를 원본 캐릭터와 동기화합니다. +6. 사망 캐릭터는 팀 색상 마커와 HUD를 숨기며, 본체 sprite만 낮은 depth와 반투명 상태로 남깁니다. 이 방식은 별도 에셋 없이도 캐릭터 모양에 맞춘 팀 색상 마커를 만들 수 있으며, 캐릭터가 처치 보상으로 커져도 마커가 같은 배율로 따라갑니다. 캐릭터 클릭은 카메라 포커스 기능만 담당하고, 팀 색상 마커는 선택 여부와 관계없이 유지됩니다. diff --git a/agent.md b/agent.md index 1c572bf..73420e2 100644 --- a/agent.md +++ b/agent.md @@ -41,12 +41,12 @@ │ ├── combat.js # 전투 AI 및 피격 판정 로직 │ ├── combatSettings.js# 전투 속도 및 이동 배율 관리 │ ├── fighterAssets.js# 스프라이트 시트 로드, 애니메이션 및 팀 색상 실루엣 마스크 생성 - │ ├── fighterFactory.js# 캐릭터 객체, 히트박스, HUD, 개별 체력 및 팀 색상 마커 동기화 + │ ├── fighterFactory.js# 캐릭터 객체, 히트박스, HUD, 개별 체력, 사망자 HUD 숨김 및 팀 색상 마커 동기화 │ ├── fighterManifest.js# 캐릭터 스킨/종족/전투/스탯/특성 데이터 정의 (20종 캐릭터 상세 설정) │ ├── fighterSelection.js# 무작위 캐릭터 스킨 선택 로직 │ └── matchSetup.js # 닉네임 기반 팀 구성 및 스폰 좌표 계산 └── ui/ - ├── matchForm.js # 참가자 입력 폼 및 팀 설정 UI 제어 + ├── matchForm.js # 참가자 입력 폼, 팀 설정 UI 제어 및 localStorage 설정 유지 ├── deathStats.js # 전투 사망 통계 API 호출 └── visitorCounter.js# 방문자 체크 API 호출 및 UI 갱신 ``` @@ -55,14 +55,17 @@ - **닉네임 기반 팀 스폰**: 입력된 각 닉네임을 독립된 팀으로 인식하고, 설정된 인원(1~100명)만큼 분신 캐릭터를 소환합니다. 캐릭터 특성에 따라 실제 출전 수가 달라질 수 있으며, Slime은 배정된 기본 스폰 슬롯마다 10마리로 확장됩니다. - **전투 진입 및 제어 UI**: 최초 접속 화면은 투명 전투 프리뷰를 배경으로 `Arena` 로고와 `Start` 버튼을 표시합니다. `Start`를 누르면 우측 옵션 drawer가 열리고, 전투 시작 후에도 drawer는 compact 패널로 유지됩니다. 패널 우측 상단의 `옵션 접기/옵션 펼치기` 버튼으로 전장 시야를 확보할 수 있으며, 패널 안에서 `재시작`과 `일시정지/계속`을 제어합니다. +- **전투 설정 유지**: 참가자 닉네임과 팀당 인원은 브라우저 `localStorage`에 저장되어 새로고침하거나 다시 접속해도 입력값이 유지됩니다. - **지능형 카메라 시스템**: - **자동 전투 관전**: 화면 확대 시 인접한 교전 중인 캐릭터 쌍을 찾아 부드럽게 추적(Lerp)합니다. - **미니맵 연동**: 줌 인 상태에서 전장 전체 상황과 현재 뷰포트 위치를 미니맵에 가이드라인으로 표시합니다. + - **최종교전 연출**: 생존 4명 이하에서는 카메라가 생존 캐릭터를 무작위로 전환 포커싱하고, 잔여 2팀의 생존 합이 8명 이하이면 생존 수가 더 적은 팀을 포커싱합니다. 이 최종교전 구간에서 공격 모션이 시작되면 짧은 슬로우모션을 적용합니다. - **역동적인 전투 연출**: - 캐릭터별 고유 공격 방식(근접, 투사체, 마법) 및 애니메이션. - `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개 문서를 유지합니다. 방문자 수는 메인 화면이 아니라 실제 전투 화면의 우측 하단에 작은 배지로 표시합니다. diff --git a/src/constants.js b/src/constants.js index 63e5e4c..4247700 100644 --- a/src/constants.js +++ b/src/constants.js @@ -17,6 +17,9 @@ export const ATTACK_DAMAGE_MAX = 24; export const DEFAULT_TEAM_SIZE = 5; // 캐릭터 스프라이트의 기본 화면 배율입니다. 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; // 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 높이입니다. @@ -97,6 +100,11 @@ export const SPECTATOR_CAMERA_LERP = 0.1; export const SPECTATOR_FINAL_FIGHTER_THRESHOLD = 5; // 최종 전투 구간에서 강제로 적용되는 카메라 줌입니다. 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; // 후반 전투 구간에서 강제로 적용되는 카메라 줌입니다. @@ -168,11 +176,14 @@ export function getTeamColor(index, totalTeams = TEAM_COLORS.length) { return TEAM_COLORS[safeIndex % TEAM_COLORS.length]; } - const hue = (TEAM_COLOR_HUE_OFFSET + safeIndex * TEAM_COLOR_GOLDEN_ANGLE) % 360; - const saturation = TEAM_COLOR_SATURATIONS[safeIndex % TEAM_COLOR_SATURATIONS.length]; + const hue = + (TEAM_COLOR_HUE_OFFSET + safeIndex * TEAM_COLOR_GOLDEN_ANGLE) % 360; + const saturation = + TEAM_COLOR_SATURATIONS[safeIndex % TEAM_COLOR_SATURATIONS.length]; const lightness = TEAM_COLOR_LIGHTNESSES[ - Math.floor(safeIndex / TEAM_COLOR_SATURATIONS.length) % TEAM_COLOR_LIGHTNESSES.length + Math.floor(safeIndex / TEAM_COLOR_SATURATIONS.length) % + TEAM_COLOR_LIGHTNESSES.length ]; return hslToHex(hue, saturation, lightness); @@ -181,23 +192,29 @@ export function getTeamColor(index, totalTeams = TEAM_COLORS.length) { function hslToHex(hue, saturation, lightness) { const normalizedSaturation = saturation / 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 x = chroma * (1 - Math.abs((huePrime % 2) - 1)); const match = normalizedLightness - chroma / 2; - const [red, green, blue] = huePrime < 1 - ? [chroma, x, 0] - : huePrime < 2 - ? [x, chroma, 0] - : huePrime < 3 - ? [0, chroma, x] - : huePrime < 4 - ? [0, x, chroma] - : huePrime < 5 - ? [x, 0, chroma] - : [chroma, 0, x]; + const [red, green, blue] = + huePrime < 1 + ? [chroma, x, 0] + : huePrime < 2 + ? [x, chroma, 0] + : huePrime < 3 + ? [0, chroma, x] + : huePrime < 4 + ? [0, x, chroma] + : huePrime < 5 + ? [x, 0, chroma] + : [chroma, 0, x]; return `#${[red, green, blue] - .map((channel) => Math.round((channel + match) * 255).toString(16).padStart(2, "0")) + .map((channel) => + Math.round((channel + match) * 255) + .toString(16) + .padStart(2, "0"), + ) .join("")}`; } diff --git a/src/game/ArenaScene.js b/src/game/ArenaScene.js index 453198e..7aeef38 100644 --- a/src/game/ArenaScene.js +++ b/src/game/ArenaScene.js @@ -4,6 +4,8 @@ import { CAMERA_MAX_ZOOM, CAMERA_MIN_ZOOM, CAMERA_ZOOM_STEP, + FINAL_COMBAT_SLOW_MOTION_DURATION, + FINAL_COMBAT_SLOW_MOTION_SCALE, MINIMAP_ALPHA, MINIMAP_MARGIN, MINIMAP_VIEWPORT_SIZE, @@ -12,9 +14,12 @@ import { SELECTED_FIGHTER_CAMERA_ZOOM, SPECTATOR_CAMERA_LERP, SPECTATOR_FINAL_FIGHTER_THRESHOLD, + SPECTATOR_FINAL_TEAM_COUNT, + SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD, SPECTATOR_FINAL_FIGHT_ZOOM, SPECTATOR_LATE_FIGHTER_THRESHOLD, SPECTATOR_LATE_FIGHT_ZOOM, + SPECTATOR_RANDOM_FOCUS_INTERVAL, } from "../constants.js"; import { drawArena } from "./arenaRenderer.js"; import { clearCombatObjects, updateFighter } from "./combat.js"; @@ -61,6 +66,11 @@ export class ArenaScene extends Phaser.Scene { this.battleDeathCounts = createDeathCounts(); this.deathStatsBaseline = createDeathCounts(); this.deathStatsSaved = false; + this.finalFocusNextSwitchAt = 0; + this.finalFocusTarget = null; + this.spectatorMode = null; + this.slowMotionRestoreState = null; + this.slowMotionTimer = null; } preload() { @@ -133,6 +143,7 @@ export class ArenaScene extends Phaser.Scene { this.matchId += 1; this.matchOver = false; this.setPaused(false, { silent: true }); + this.clearFinalCombatEffects(); this.presentationMode = silent; this.resetMatchDeathStats({ silent }); this.observedCombat = []; @@ -405,23 +416,13 @@ update(time) { } // 확대 상태일 때 생존 캐릭터들의 중앙으로 카메라 이동 - const livingFighterCount = this.fighters.filter(isLivingFighter).length; - const forcedSpectatorZoom = getForcedSpectatorZoom(livingFighterCount); + const livingFighters = this.fighters.filter(isLivingFighter); + const spectatorState = getSpectatorState(livingFighters); + this.syncSpectatorMode(spectatorState?.mode ?? null); - if (forcedSpectatorZoom) { - this.setMainCameraZoom(forcedSpectatorZoom); - - const combatCenter = this.getObservedCombatCenter(); - if (combatCenter) { - - // 소수점 단위 변동으로 인한 지터링 방지를 위해 반올림 처리 및 부드러운 이동(Lerp) 적용 - const targetX = Math.round(combatCenter.x); - const targetY = Math.round(combatCenter.y); - - // Move from the current world-space camera center toward the target. - this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP; - this.cameras.main.scrollY += (targetY - this.cameras.main.midPoint.y) * SPECTATOR_CAMERA_LERP; - } + if (spectatorState) { + this.setMainCameraZoom(spectatorState.zoom); + this.moveCameraToward(this.getSpectatorCameraTarget(spectatorState, livingFighters, time)); } else if (this.cameras.main.zoom <= CAMERA_MIN_ZOOM) { // 줌이 1일 때는 경기장 중앙에 고정 this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2); @@ -430,6 +431,143 @@ update(time) { 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) { if (!isLivingFighter(fighter)) { return; @@ -624,7 +762,7 @@ update(time) { observeCombat(attacker, defender) { const canObserveCombat = Boolean( - getForcedSpectatorZoom(this.fighters.filter(isLivingFighter).length), + getSpectatorState(this.fighters.filter(isLivingFighter)), ); if (!canObserveCombat || !isLivingOpponentPair([attacker, defender])) { @@ -713,6 +851,7 @@ update(time) { } this.matchOver = true; + this.clearFinalCombatEffects(); clearCombatObjects(this); this.fighters.forEach((fighter) => { if (fighter.body) { @@ -935,18 +1074,102 @@ function findClosestOpponentPair(fighters) { return closestPair; } -function getForcedSpectatorZoom(livingFighterCount) { +function getSpectatorState(livingFighters) { + const livingFighterCount = livingFighters.length; + const teamSummaries = getLivingTeamSummaries(livingFighters); + 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) { - return SPECTATOR_LATE_FIGHT_ZOOM; + 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; diff --git a/src/game/combat.js b/src/game/combat.js index 849441a..33d5fce 100644 --- a/src/game/combat.js +++ b/src/game/combat.js @@ -4,6 +4,8 @@ import { ATTACK_COOLDOWN, ATTACK_DAMAGE_MAX, ATTACK_DAMAGE_MIN, + DEAD_FIGHTER_ALPHA, + DEAD_FIGHTER_DEPTH, ATTACK_RANGE, FIGHTER_MAX_HP, FIGHTER_SCALE, @@ -80,6 +82,7 @@ function beginAttack(scene, attacker, defender, time, onWinner) { time + scaledAttackDelay(attacker.skin.combat?.cooldown ?? ATTACK_COOLDOWN, attacker); attacker.isLocked = true; scene.observeCombat?.(attacker, defender); + scene.triggerFinalCombatSlowMotion?.(attacker, defender, attack.animation); playAnimation(attacker, attack.animation, fighterAttackSpeedMultiplier(attacker)); switch (getCombatType(attacker)) { @@ -349,6 +352,13 @@ function killFighter(defender, winner, onWinner) { defender.body.setVelocity(0, 0); defender.body.enable = false; 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"); winner.isLocked = false; winner.body.setVelocity(0, 0); diff --git a/src/game/fighterFactory.js b/src/game/fighterFactory.js index 2ba94c6..9be05f1 100644 --- a/src/game/fighterFactory.js +++ b/src/game/fighterFactory.js @@ -2,6 +2,7 @@ import Phaser from "phaser"; import { FIGHTER_FRAME_HEIGHT, FIGHTER_FRAME_WIDTH, + FIGHTER_DEPTH, FIGHTER_HITBOX_HEIGHT, FIGHTER_HITBOX_OFFSET_X, FIGHTER_HITBOX_OFFSET_Y, @@ -32,7 +33,8 @@ export function createFighter( fighter.setScale(FIGHTER_SCALE); fighter.setName(displayName); - fighter.setDepth(2); + fighter.setDepth(FIGHTER_DEPTH); + fighter.setAlpha(1); fighter.setCollideWorldBounds(true); fighter.setFlipX(faceLeft); fighter.body.setSize(FIGHTER_HITBOX_WIDTH, FIGHTER_HITBOX_HEIGHT); @@ -109,6 +111,17 @@ export function createFighter( } 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 healthOffset = 44 * scaleRatio; const hitbox = fighter.body; @@ -119,7 +132,6 @@ export function syncFighterHud(fighter) { fighter.healthBack.setPosition(fighter.x, fighter.y - healthOffset); fighter.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset); fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? FIGHTER_MAX_HP))); - syncTeamMarker(fighter); } function syncTeamMarker(fighter) { diff --git a/src/ui/matchForm.js b/src/ui/matchForm.js index 3d24307..7837421 100644 --- a/src/ui/matchForm.js +++ b/src/ui/matchForm.js @@ -1,5 +1,10 @@ import { NICKNAME_LENGTH } from "../constants.js"; +const STORAGE_KEYS = { + names: "arena.match.playerNames", + teamSize: "arena.match.teamSize", +}; + export function createMatchForm() { const form = getElement("#fighter-form"); const namesInput = getElement("#player-names"); @@ -14,9 +19,14 @@ export function createMatchForm() { teamSize: Number(teamSizeInput.value), }); + restoreSavedMatchSettings(namesInput, teamSizeInput); syncTeamSizeOutput(teamSizeInput, teamSizeOutput); + namesInput.addEventListener("input", () => { + saveMatchSettings(namesInput, teamSizeInput); + }); teamSizeInput.addEventListener("input", () => { syncTeamSizeOutput(teamSizeInput, teamSizeOutput); + saveMatchSettings(namesInput, teamSizeInput); }); return { @@ -62,3 +72,63 @@ function nicknameValues(value) { function syncTeamSizeOutput(input, output) { 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; + } +} diff --git a/todo.md b/todo.md index 510a400..1beeb34 100644 --- a/todo.md +++ b/todo.md @@ -82,3 +82,19 @@ - 전투가 5초 이상 지속되면 상단 `#battle-notice`에 오늘 종족별 사망 집계를 2초 표시/10초 대기 주기의 재치 있는 안내 문구로 표시. - 상단 안내바 폭을 전투 화면 안쪽에 어울리도록 줄이고, 방문자 수는 메인 화면 대신 전투 화면 우측 하단 작은 배지로 이동. - `config.json.sample`에 사망 통계 컬렉션명과 집계 타임존 설정 예시를 추가. + +14. 사망 캐릭터가 생존 캐릭터를 가리는 문제 개선 (완료) +- **조치 사항**: + - 사망한 캐릭터 sprite를 반투명 처리하고 생존 캐릭터보다 낮은 depth로 내려 전투원을 가리지 않도록 조정. + - 사망 캐릭터의 이름표, 체력바, 팀 색상 마커를 숨겨 전투 화면의 가독성을 유지. + +15. 전투 설정 입력값 localStorage 유지 (완료) +- **조치 사항**: + - 참가자 닉네임 textarea와 팀당 인원 range 값을 브라우저 `localStorage`에 저장. + - 앱 로드 시 저장된 참가자 닉네임과 팀당 인원을 먼저 복원해 새로고침/재접속 후에도 입력값이 유지되도록 구현. + +16. 최종교전 카메라 조건 및 슬로우모션 연출 추가 (완료) +- **조치 사항**: + - 생존 4명 이하에서는 카메라가 생존 캐릭터를 일정 간격으로 무작위 포커싱하도록 변경. + - 잔여 팀이 2팀이고 생존 캐릭터 합이 8명 이하이면 생존 수가 적은 팀의 중앙을 포커싱하도록 추가. + - 최종교전 상태에서 idle이 아닌 공격 모션이 시작될 때 짧은 전역 슬로우모션을 적용.