Compare commits

...

2 Commits

33 changed files with 3263 additions and 2478 deletions

View File

@ -29,6 +29,10 @@
│ └── visitors.js # 유니크 방문자 체크 및 통계 API
├── public/ # 정적 리소스 (게임 에셋)
│ └── assets/
│ ├── effects/ # 공통 전투/월드 이펙트 스프라이트시트
│ │ ├── heal/ # 처치 회복 연출
│ │ ├── world_Effect.png # 화염 메테오 7프레임 이미지
│ │ └── world_Effect_2.png # 냉기 메테오 7프레임 이미지
│ └── characters/ # 20종 이상의 캐릭터 스킨 및 투사체 에셋
│ ├── archer/, armored-axeman/, armored-orc/, ... (중략)
│ └── wizard/ # 각 폴더 내 애니메이션 시트 및 이펙트 포함
@ -39,19 +43,21 @@
├── game/ # 게임 로직 모듈 (역할별 하위 폴더 구성)
│ ├── arena/ # 아레나 및 씬 관리
│ │ ├── ArenaScene.js # 메인 게임 씬 (Orchestrator, 생명주기 및 모듈 조율)
│ │ ├── arenaRenderer.js# 경기장 바닥 및 격자 렌더링
│ │ ├── arenaRenderer.js# 경기장 바닥, 격자 및 팀 시작 영역 렌더링
│ │ └── arenaSpectatorCamera.js # 지능형 관전 카메라 및 줌 로직
│ ├── combat/ # 전투 시스템
│ │ ├── combat.js # 전투 AI, 투사체 및 피격 판정 핵심 엔진
│ │ ├── combatSettings.js # 전투 속도 및 이동 배율 관리
│ │ └── arenaFinalCombatEffects.js # 최종 교전 슬로우 모션 등 연출 효과
│ │ ├── arenaFinalCombatEffects.js # 최종 교전 슬로우 모션 등 연출 효과
│ │ └── worldEffects.js # 주기적 메테오/냉각지대 및 냉기 동결 효과
│ ├── fighter/ # 캐릭터 및 에셋
│ │ ├── fighterAssets.js # 스프라이트 로드 및 팀 실루엣 동적 생성
│ │ ├── fighterFactory.js # 캐릭터 인스턴스화 및 HUD 동기화
│ │ ├── fighterManifest.js # 20종 캐릭터 스탯/특성 상세 정의
│ │ ├── fighterStats.js # 근접/원거리/마법 프로필 판별 및 스탯 해석
│ │ └── fighterSelection.js # 캐릭터 스킨 무작위 선택 로직
│ └── match/ # 매치 및 진행
│ ├── matchSetup.js # 팀 구성 및 스폰 좌표 계산 (역/랜덤)
│ ├── matchSetup.js # 팀 구성 및 스폰 좌표 계산 (스타팅 영역/랜덤)
│ └── arenaMatchRuntime.js # 매치 진행 중 헬퍼 (스폰 클러스터, 팀 크기 동기화)
└── ui/ # UI 컴포넌트 및 API 연동
├── arenaKillLog.js # [New] 독립된 킬로그 DOM 조작 모듈
@ -71,9 +77,10 @@
- **[인프라 및 전역 설정] [context/core.md](./context/core.md)**: `main.js`, `constants.js`, 개발/유지보수 공통 규칙.
- **[서버 및 API] [context/server.md](./context/server.md)**: Fastify 서버, MongoDB 연동, 방문자 및 사망 통계 API 상세.
- **[아레나 및 카메라] [context/arena.md](./context/arena.md)**: `ArenaScene` 오케스트레이션, 지능형 카메라 추적, 미니맵 가이드라인.
- **[전투 엔진] [context/combat.md](./context/combat.md)**: 전투 AI, 투사체 판정, 처치 보상 성장, 슬로우모션 연출.
- **[전투 엔진] [context/combat.md](./context/combat.md)**: 전투 AI, 투사체 판정, 처치 보상 성장, 슬로우모션 및 월드 이펙트 연출.
- **[캐릭터 및 에셋] [context/fighter.md](./context/fighter.md)**: 캐릭터 공장, 동적 실루엣 생성, 종족 및 특성(Slime 등) 정의.
- **[매치 로직 및 UI] [context/match-ui.md](./context/match-ui.md)**: 팀 구성 및 스폰 알고리즘, HUD 레이아웃, 킬로그, 승리 연출 UI.
- **[스타일 및 디자인] [context/style.md](./context/style.md)**: CSS 모듈 구조, 디자인 변수, 반응형 및 애니메이션 가이드.
## 4. 기술 사양

View File

@ -3,7 +3,7 @@
## 1. 모듈별 상세 역할 (`src/game/arena/`)
- **`ArenaScene.js`**: Phaser 씬의 생명주기와 전반적인 오케스트레이션을 담당합니다. `update()` 매 프레임마다 전투원 상태를 체크하고, 카메라 이동 및 UI 모듈 호출을 조율합니다.
- **`arenaRenderer.js`**: 아레나 배경 그래픽 및 타일 렌더링을 담당합니다.
- **`arenaRenderer.js`**: 아레나 배경 그래픽, 타일 및 팀별 스타팅 영역 오버레이 렌더링을 담당합니다.
- **`arenaSpectatorCamera.js`**: 관전 모드 시점 계산 및 카메라 포커싱 로직을 담당합니다. 생존 인원에 따른 지능형 카메라 추적 알고리즘이 구현되어 있습니다.
## 2. 주요 로직 구현 세부 사항
@ -13,11 +13,13 @@
1. 목표 좌표(`targetX, targetY`)를 `Math.round()`로 정수화합니다.
2. 현재 카메라 위치에서 목표 지점까지 매 프레임 `0.1`의 배율로 거리를 좁혀나가는 `Lerp` 연산을 수행합니다.
```javascript
this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP;
this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * CAMERA.SPECTATOR_LERP;
```
최종교전 관전은 두 단계로 나뉩니다.
- **생존 4명 이하**: `SPECTATOR_RANDOM_FOCUS_INTERVAL`마다 생존 캐릭터 중 한 명을 무작위로 포커싱합니다.
자동 관전은 월드 이펙트 임시 시점, 후반 진입과 최종교전 세부 포커싱으로 나뉩니다.
- **메테오 임시 포커싱**: 자동 관전 진입 전 화염 또는 냉기 메테오가 시작되면 착탄 지점을 임시로 확대 추적하고, 착탄 후 `CAMERA.METEOR_FOCUS_HOLD_DURATION`만큼 유지한 뒤 이전 카메라로 복귀합니다. `CAMERA.METEOR_FOCUS_ENABLED``false`로 설정하면 끌 수 있으며, 수동 선택 시점과 아래 자동 관전 시점이 우선합니다.
- **후반 자동 관전 진입**: 생존 캐릭터가 30명 미만(`CAMERA.SPECTATOR_LATE_FIGHTER_THRESHOLD`)이 되면 교전 중심(가장 가까운 적 대항쌍)을 포커싱하는 후반 줌을 적용합니다. (2팀만 남았더라도 인원이 많으면 어지러움을 방지하기 위해 자동 관전으로 바로 진입하지 않습니다.)
- **생존 4명 이하**: `CAMERA.SPECTATOR_RANDOM_FOCUS_INTERVAL`마다 생존 캐릭터 중 한 명을 무작위로 포커싱합니다.
- **2팀 잔여 & 합계 8명 이하**: 더 적은 생존 수를 가진 팀의 중앙을 포커싱하며, 동률이면 기존 교전쌍 중심 포커싱으로 되돌아갑니다.
### 미니맵 가이드라인
@ -25,6 +27,10 @@ this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATO
- `camera.displayWidth / zoom` 등을 이용하여 현재 월드에서 보이는 실제 영역 크기를 계산합니다.
- 뷰포트 사각형 좌표는 미니맵 픽셀 격자에 맞춰 반올림하고, 외곽 stroke가 겹쳐 검게 깨지지 않도록 노란 내부 선을 채운 직사각형으로 렌더링합니다.
### 스타팅 영역 오버레이
`스타팅 지점 배치` 매치에서는 `matchSetup.js`가 전장 그리드에서 팀별 중심 셀을 무작위로 뽑아 만든 영역을 `ArenaScene``arenaRenderer.js`에 전달합니다. 렌더러는 각 팀 색상을 낮은 투명도로 채우고 얇게 둘러 실제 스폰 후보 영역을 표시하며, 이 오버레이는 매치 시작 후 5초 동안만 보입니다. 숨김 예약은 Phaser 씬 타이머를 사용하므로 일시정지 시간은 표시 시간에 포함되지 않고, 새 매치가 시작되면 이전 예약을 취소합니다.
### 씬 상태 관리
- **프리뷰 모드 (`presentationMode`)**: 최초 로드 시 조용히 실행되는 배경 전투입니다. 로컬 저장 옵션과 무관하게 10팀 x 5명 고정 규모로 동작합니다.
- **일시정지 (`setPaused`)**: 실제 전투에서 물리, Phaser 타이머, tween, 스프라이트 애니메이션을 함께 제어합니다. 프리뷰 및 종료된 전투는 제외됩니다.
- **월드 이펙트 주기**: 실제 전투 생성 시 `startWorldEffects()`를 시작하고, 새 매치/종료 때 `clearWorldEffects()`로 주기 타이머, 잔여 냉각 구역, 메테오 임시 포커스, 캐릭터 감속 배율을 정리합니다. Phaser 타이머를 사용하므로 일시정지 시간은 4초 발동 간격과 냉각 지속시간에 포함되지 않습니다.

View File

@ -2,28 +2,37 @@
## 1. 모듈별 상세 역할 (`src/game/combat/`)
- **`combat.js`**: 전투 AI, 피해 계산, 처치 보상 등 핵심 전투 로직을 담당합니다. 유닛의 이동, 공격, 투사체 발사 등을 처리합니다.
- **`combat.js`**: 전투 AI, 피해 계산, 처치 보상 등 핵심 전투 로직을 담당합니다. `fighterStats.js`에서 해석한 역할별 수치로 이동, 공격, 투사체 발사 등을 처리합니다.
- **`combatSettings.js`**: 전투 속도 배율 등 런타임 전투 설정을 관리합니다.
- **`arenaFinalCombatEffects.js`**: 최종 교전 시 슬로우 모션 등 연출 효과를 담당합니다. 수학적인 이징(easing) 함수와 물리 시간 배율 계산을 포함합니다.
- **`worldEffects.js`**: 실제 전투에서 4초마다 발동하는 화염/냉기 메테오 선택, 사분면별 대각선 낙하 연출, 5x5 영역 판정, 냉기 동결과 감속 구역 수명주기를 처리합니다.
## 2. 주요 로직 구현 세부 사항
### 전투 AI 및 유닛 동작
- **`updateFighter()`**: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
- **`applyHit()`**: 일반 공격 피해량은 `ATTACK_DAMAGE_MIN/MAX` 범위에서 계산하고, 치명타 적중은 `Critical!` 표기와 즉시 처치/카메라 흔들림을 처리합니다.
- **`applyHit()`**: 일반 공격 피해량은 공격자의 `melee`/`ranged`/`magic` 프로필 피해량 범위에서 계산하고, 치명타 적중은 `Critical!` 표기와 즉시 처치를 처리합니다.
- **역할별 기본값**: `src/constants.js``FIGHTER_TYPE_STATS`에서 체력, 이동속도, 사거리, 공격 쿨다운, 피해량, 치명타 확률, 발동 지연을 독립적으로 조절합니다. 투사체 속도는 `ranged`, 효과 적중 지연은 `magic` 프로필에 포함됩니다.
- **`projectilePathHitsDefender()`**: 투사체가 대상을 스쳐 지나가지 않도록 궤적(Line)과 히트박스(Rectangle) 겹침 검사를 수행합니다.
### 처치 보상 및 성장
- **`applyKillReward()`**: 처치한 캐릭터의 체력 회복(현재 체력 30%), 크기 증가, 공격속도/이동속도 배율 증가를 처리합니다. 누적 배율은 `KILL_GROWTH_MAX_MULTIPLIER`로 제한합니다.
- **`clampFighterInsideArena()`**: 처치 성장 중 커진 캐릭터가 전장 바깥으로 나가지 않도록 위치를 보정합니다.
### 월드 이펙트
- **발동 규칙**: 프리뷰가 아닌 실제 전투에서 전투 시간 4초마다 생존 캐릭터 하나를 무작위로 선택하고, 대상의 당시 위치를 중심으로 메테오 또는 냉각지대 중 하나를 무작위 발동합니다.
- **낙하 방향과 크기**: 대상이 전장 좌측 반면(2, 3사분면)이면 화살표가 좌상단에서 우하단으로, 우측 반면(1, 4사분면)이면 좌우 반전되어 우상단에서 좌하단으로 이동합니다. 스프라이트를 45도로 기울이고 전용 시각 배율을 사용해 전역 마법 규모로 표현합니다.
- **화염 메테오**: `world_Effect.png`의 7프레임 애니메이션이 낙하하면 화면 흔들림을 적용하고, 5x5 타일 영역의 생존자에게 고정 피해를 줍니다. 자동 관전 진입 전에는 `CAMERA.METEOR_FOCUS_ENABLED`가 켜져 있을 때 착탄 위치를 임시 포커싱합니다. 환경 피해로 인한 사망은 킬 보상을 지급하지 않지만 사망 통계와 승패 판정에는 반영됩니다.
- **냉기 메테오**: `world_Effect_2.png`의 7프레임 애니메이션으로 착탄을 표시하고, 자동 관전 진입 전에는 같은 설정에 따라 착탄 위치를 임시 포커싱합니다. 착탄 시 별도 조정 가능한 피해를 주며, 생존한 피격 대상은 캐릭터 본체와 실루엣이 얼음색으로 바뀐 채 2초 동안 기절합니다. 이후 남은 5x5 냉각지대 안에서는 공격속도와 이동속도 감속 배율을 적용하며, 영역을 벗어나거나 지속시간이 끝나면 배율을 복구합니다.
### 최종교전 슬로우모션
`FINAL_COMBAT_SLOW_MOTION_ENABLED`가 활성화된 경우:
`COMBAT.FINAL_SLOW_MOTION_ENABLED`가 활성화된 경우:
- 최종교전 상태에서 공격 모션이 시작될 때 전역 time scale을 낮춥니다.
- 진입/유지/복귀 속도 램프(Ease)를 적용합니다.
- Arcade Physics는 timeScale 방향이 반대라 물리 이동에는 역수 배율을 적용합니다.
## 3. 유지보수 규칙
- **처치 성장 상한**: `src/constants.js``KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
- **공격력 조정**: `src/constants.js``ATTACK_DAMAGE_MIN/MAX`를 수정합니다.
- **공격력 조정**: `src/constants.js``FIGHTER_TYPE_STATS.<type>.damageMin/damageMax`를 수정합니다.
- **월드 이펙트 조정**: `src/constants.js``WORLD_EFFECT.METEOR_DAMAGE``WORLD_EFFECT.FROST_DAMAGE`로 두 메테오 피해를 각각 조정하고, `WORLD_EFFECT.FROST_STUN_DURATION`/`FROST_STUN_TINT`로 동결 시간과 표시 색상을 조정합니다. 나머지 `WORLD_EFFECT.*` 값으로 발동 주기, 범위, 냉각 지속시간과 감속 정도를 수정하며, 메테오 착탄 위치 포커싱은 `CAMERA.METEOR_FOCUS_ENABLED`에서 켜고 끕니다.
- **특수 규칙**: 캐릭터별 특수 공격 방식은 `fighterManifest.js``combat` 설정을 확인합니다.

View File

@ -7,13 +7,16 @@
- `Start` 버튼, 옵션 drawer, 전투 시작 submit 흐름을 제어하며 전투 시작 시 `#app``match-live` 상태 클래스를 부여합니다.
- 전투 중 drawer 접기/펼치기(`drawer-collapsed`), 재시작 버튼, 일시정지 버튼 상태(`match-paused`)를 DOM 클래스와 `ArenaScene` 상태에 동기화합니다.
- **`src/constants.js`**: 게임 내 모든 튜닝 수치를 관리합니다.
- `ATTACK_DAMAGE_MIN`, `ATTACK_DAMAGE_MAX`: 일반 공격 1회 적중 시 적용되는 랜덤 피해량 범위.
- `FIGHTER_TYPE_STATS`: `melee`, `ranged`, `magic`별 최대 체력, 이동속도, 사거리, 쿨다운, 피해량, 치명타 및 공격 발동 지연 기본값.
- `FIGHTER_HITBOX_*`: 100x100 캐릭터 프레임 안에서 실제 충돌 판정이 놓이는 위치와 크기.
- `KILL_HEALTH_RECOVERY_RATIO`, `KILL_GROWTH_MULTIPLIER`, `KILL_GROWTH_MAX_MULTIPLIER`: 처치 후 회복량, 크기/공격속도/이동속도 성장 배율, 누적 보상 상한.
- `WORLD_EFFECT.*`: 월드 이펙트 발동 간격, 5x5 범위, 대각선 낙하 거리/시각 배율, 화염/냉기 메테오 피해량, 냉기 동결 시간/실루엣 색상, 냉각지대 지속시간과 감속 배율.
- `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, 배율과 속도 램프 시간.
- `CAMERA.SPECTATOR_LERP`: 카메라 추적의 부드러움 정도.
- `CAMERA.METEOR_FOCUS_ENABLED`, `CAMERA.METEOR_FOCUS_ZOOM`, `CAMERA.METEOR_FOCUS_HOLD_DURATION`: 자동 관전 진입 전 화염/냉기 메테오 착탄 위치의 임시 포커싱 on/off, 확대 배율 및 착탄 후 유지 시간.
- `CAMERA.SPECTATOR_LATE_FIGHTER_THRESHOLD`: 생존 인원 임계값에 따른 후반 자동 관전 진입 조건. (2팀만 남았더라도 이 수치보다 인원이 많으면 자동 관전을 유예합니다.)
- `CAMERA.SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD`, `CAMERA.SPECTATOR_RANDOM_FOCUS_INTERVAL`, `COMBAT.FINAL_SLOW_MOTION_*`: 최종교전 관전 조건, 랜덤 포커싱 간격, 슬로우모션 on/off, 배율과 속도 램프 시간.
- `MINIMAP_VIEWPORT_SIZE`: 미니맵의 고정 픽셀 크기.
- `ARENA_SIZE`: 경기장 전체 크기 (GRID * TILE).
@ -21,8 +24,9 @@
- **신규 캐릭터 추가**: `public/assets/characters/`에 에셋 배치 후 `fighterManifest.js`에 정의를 추가하면 즉시 게임에 반영됩니다.
- **종족값 유지**: 신규 스킨을 추가할 때는 사망 통계가 누락되지 않도록 `species``human`, `orc`, `skeleton`, `slime`, `wolf`, `bear` 중 하나로 지정해야 합니다.
- **물리 수치 조정**: 캐릭터의 속도나 사거리 등은 `src/constants.js` 또는 `fighterManifest.js` 내 개별 설정을 통해 변경하십시오.
- **물리 수치 조정**: 역할별 기본 체력/속도/사거리/공격 수치는 `src/constants.js``FIGHTER_TYPE_STATS`에서 변경하고, 특정 스킨만 다르게 할 때는 `fighterManifest.js``stats` 또는 `combat` 설정을 사용하십시오.
- **처치 성장 상한 조정**: 처치 보상으로 캐릭터가 커지는 최대치와 공격/이동 배율 상한은 `src/constants.js``KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
- **공격력 조정**: 기본 피해량은 `src/constants.js``ATTACK_DAMAGE_MIN`, `ATTACK_DAMAGE_MAX`를 수정합니다. 캐릭터별 특수 공격 방식은 `fighterManifest.js``combat` 설정을 우선 확인합니다.
- **공격력 조정**: 역할별 기본 피해량은 `src/constants.js``FIGHTER_TYPE_STATS.<type>.damageMin/damageMax`를 수정합니다. 캐릭터별 특수 공격 방식은 `fighterManifest.js``combat` 설정을 우선 확인합니다.
- **월드 이펙트 조정**: `src/constants.js``WORLD_EFFECT.INTERVAL`, `WORLD_EFFECT.FALL_TRAVEL_TILES`, `WORLD_EFFECT.VISUAL_SCALE`, `WORLD_EFFECT.METEOR_DAMAGE`, `WORLD_EFFECT.FROST_DAMAGE`, `WORLD_EFFECT.FROST_STUN_DURATION`, `WORLD_EFFECT.FROST_STUN_TINT`, `WORLD_EFFECT.FROST_DURATION`, `WORLD_EFFECT.FROST_SPEED_MULTIPLIER`를 수정합니다. 임시 메테오 카메라는 `CAMERA.METEOR_FOCUS_ENABLED`로 끌 수 있습니다.
- **DOM 접근**: 성능을 위해 `ArenaScene`은 좌측 HUD badge 등 필요한 시점에만 최소한으로 DOM에 접근합니다.
- **패키지 락 파일**: 이 프로젝트는 `package-lock.json`을 저장소에서 제외합니다. 의존성 변경 시 `package.json`을 기준으로 관리합니다.

View File

@ -5,6 +5,7 @@
- **`fighterAssets.js`**: 캐릭터 스프라이트 로드 및 애니메이션/실루엣 생성을 담당합니다. 원본 이미지로부터 팀 색상 마커용 실루엣을 동적으로 생성합니다.
- **`fighterFactory.js`**: 캐릭터 인스턴스화 및 HUD(이름표, 체력바) 관리를 담당합니다. Phaser Sprite와 DOM UI 사이의 가교 역할을 합니다.
- **`fighterManifest.js`**: 모든 캐릭터 종족 및 스탯 데이터를 정의합니다. 20여 종의 캐릭터 설정이 포함되어 있습니다.
- **`fighterStats.js`**: 공격 방식으로 `melee`, `ranged`, `magic` 역할을 판별하고 역할별 기본 스탯과 스킨별 오버라이드를 병합합니다.
- **`fighterSelection.js`**: 매치 참여 캐릭터를 무작위로 선택하거나 섞는 로직을 담당합니다.
## 2. 주요 로직 구현 세부 사항
@ -19,12 +20,19 @@
### 캐릭터 HUD 및 상태 동기화
- **이름표 고정**: 스프라이트 중심이 아닌 실제 히트박스 하단에 고정되어 시각적 일관성을 유지합니다.
- **사망자 처리**: 사망 시 HUD와 팀 마커를 숨겨 화면 가독성을 높입니다. 본체 sprite만 낮은 depth와 반투명 상태로 남깁니다.
- **월드 감속 상태**: 생성 시 `worldEffectSpeedMultiplier``1`로 초기화하며, 냉각지대 안에서는 `worldEffects.js`가 해당 배율을 낮춰 공격속도와 이동속도 계산에 반영합니다.
- **냉기 동결 상태**: `isFrostStunned`와 동결 타이머를 캐릭터별로 관리합니다. 냉기 메테오 착탄에 생존하면 캐릭터 본체와 팀 실루엣 마커가 함께 얼음색으로 바뀌고, 동결 종료 시 본체 원본 색상과 저장된 팀 색상으로 복구됩니다.
### 캐릭터별 특성 (예: Slime)
- **`spawnMultiplier`**: 배정된 슬롯 1개를 지정된 수만큼 확장하여 스폰합니다.
- **`splitOnDeath`**: 사망 시 확률적으로 지정된 수만큼 분열체를 생성합니다.
- **스탯 상한**: 처치 보상은 현재 체력을 회복시키지만 `maxHp`를 넘을 수 없습니다. (예: Slime은 항상 1 HP)
### 역할별 전투 스탯
- `combat.type``projectile`이면 `ranged`, `instant-spell`이면 `magic`, 그 외에는 `melee` 기본 프로필을 사용합니다.
- 새로운 공격 구현이 기본 판별과 다른 역할을 사용해야 할 때는 `combat.fighterType``melee`, `ranged`, `magic` 중 하나를 명시합니다.
- 개별 스킨의 기존 `stats.maxHp`, `combat.range`, `combat.cooldown`, `combat.criticalChance`, `combat.projectile.speed`, `combat.attackEffect.hitDelay` 설정은 역할별 기본값보다 우선합니다.
## 3. 유지보수 규칙
- **신규 캐릭터**: 에셋 배치 후 `fighterManifest.js`에 정의를 추가합니다.
- **종족값**: 사망 통계를 위해 지정된 6개 종족 중 하나를 반드시 선택해야 합니다.

View File

@ -18,14 +18,15 @@
### 매치 설정 및 스폰 배치
- **완전 랜덤 배치**: 전장 전체 스폰 슬롯을 무작위로 섞어 배치합니다.
- **스타팅 지점 배치**: 참가자 수에 맞춰 전장을 구역으로 나눈 뒤, 참가자별 구역 배정을 매치마다 섞고 구역 내 무작위 위치에 스폰합니다.
- **스타팅 지점 배치**: 팀마다 전장 스폰 가능 그리드에서 중심 셀을 무작위로 고르고, 중심 주변 2칸(`5 x 5`)을 해당 팀의 스타팅 영역으로 사용합니다. 겹치지 않는 후보가 남아 있는 동안에는 해당 후보를 우선 선택하며, 영역은 매치 시작 후 5초 동안만 팀 색상으로 매우 옅게 표시되고 팀 전투원은 이 안에서만 스폰합니다.
- **설정 유지**: 닉네임, 인원, 배치 모드는 `localStorage`에 저장되어 재접속 시 복원됩니다.
### 전투 화면 레이아웃 (HUD)
- **팀 Badge**: 좌측 HUD 레일에 배치되며, 클릭 시 해당 팀의 생존 유닛 중 무작위 1명으로 시점을 고정합니다.
- **킬로그**: 처치자와 피처치자를 좌우로 배치하고, 피처치자 아이콘에 빨간 X를 겹쳐 사망 관계를 명확히 표시합니다.
- **팀 Badge 갱신 안정성**: 사망으로 생존 수가 바뀔 때 기존 badge 버튼 DOM을 유지한 채 숫자, 비활성 상태, 선택 강조만 갱신하여 사망 프레임에 겹친 클릭도 시점 고정으로 전달되도록 합니다.
- **킬로그**: 처치자와 피처치자를 좌우로 배치하고, 피처치자 아이콘에 빨간 X를 겹쳐 사망 관계를 명확히 표시합니다. 캐릭터 idle 시트의 `100x100` 프레임 내 투명 여백을 제외한 중앙 하단 영역을 확대 표시해 작은 아이콘 박스에서도 실루엣이 충분히 보이도록 합니다.
- **하단 메타 정보**: 전투 화면 우측 하단(`arena-meta` 컨테이너)에 방문자 카운터와 About 버튼이 Pill(알약) 형태로 디자인이 통일되어 나란히 고정 배치됩니다. 드로어가 열려도 동일한 위치를 유지합니다.
- **모바일 레이아웃**: 실제 전투 시작 시 모바일에서는 옵션 drawer를 자동으로 접고, 상단 팀 HUD는 옵션 버튼 폭을 제외한 영역에 두 줄 4열로 맞춰 4개 이후 팀도 잘리지 않게 합니다. 모바일 팀 카드 선택 표시는 내부 테두리로 처리해 외곽선이 잘려 보이지 않게 합니다. 킬로그는 전투 캔버스 바로 아래에 배치하되 하단 메타 정보(방문자 카운터/About)와 겹치지 않게 안전 여백을 확보합니다.
- **모바일 레이아웃**: 실제 전투 시작 시 모바일에서는 옵션 drawer를 자동으로 접고, 상단 팀 HUD는 옵션 버튼 폭을 제외한 영역에 두 줄로 배치됩니다. 이때 데스크톱의 고정 가로폭 상속을 방지(`grid-template-columns: none`)하여 모든 팀 카드가 균일한 가로폭을 유지하도록 하며, 4개 이후 팀도 스크롤을 통해 확인할 수 있습니다. 모바일 팀 카드 선택 표시는 내부 테두리로 처리해 외곽선이 잘려 보이지 않게 합니다. 킬로그는 전투 캔버스 바로 아래에 배치하되 하단 메타 정보(방문자 카운터/About)와 겹치지 않게 안전 여백을 확보합니다.
- **모바일 옵션 drawer**: 전투 중 펼친 옵션 drawer는 닉네임 입력 높이와 컨트롤 간격을 줄여 전투 시작/재시작/일시정지 버튼이 작은 화면에서도 한 번에 보이도록 합니다.
- **승리 연출**: 승리 시 Web Audio 기반 팡파르와 CSS 애니메이션(광선, 컨페티)을 결합해 화려하게 연출합니다. 전투 종료 시 옵션 drawer를 접어 결과 배너가 설정 폼과 충돌하지 않게 하며, 결과 배너는 일정 시간 후 자동으로 사라지거나 클릭 시 즉시 닫힙니다. 무승부는 더 차분한 톤을 사용합니다.

25
context/style.md Normal file
View File

@ -0,0 +1,25 @@
# Context: Style & Design
## 1. CSS 모듈 구조 (src/styles/)
이 프로젝트는 거대한 단일 CSS 파일을 지양하고, 기능별로 분리된 모듈형 CSS 구조를 채택하고 있습니다. `src/styles.css`는 각 모듈을 통합하는 엔트리 포인트 역할을 합니다.
- **`base.css`**: 전역 변수(`:root`), 리셋 스타일, 레이아웃의 뼈대(`#app`, `#game`, `.arena-shell`)를 정의합니다.
- **`intro.css`**: 대기 화면, 로고 애니메이션, 전투 프리뷰 연출 스타일을 담당합니다.
- **`game-ui.css`**: 스코어보드(팀 badge), 킬로그, 상단 전투 안내바, 승리/무승부 축하 레이어 등 실제 게임 진행 중 노출되는 모든 HUD 요소를 관리합니다.
- **`overlay.css`**: 설정 드로어(전투 옵션 폼), About 다이얼로그 및 공통 폼 컨트롤 스타일을 정의합니다.
- **`animations.css`**: 프로젝트 전역에서 재사용되는 `@keyframes`와 애니메이션 관련 유틸리티 클래스를 포함합니다.
- **`mobile.css`**: `960px` 이하 해상도를 위한 미디어 쿼리 오버라이드 스타일을 통합 관리합니다. 모바일 전용 레이아웃 조정 및 터치 최적화 스타일이 포함됩니다.
## 2. 디자인 시스템 및 변수
- **색상 체계**: 어두운 배경(`#080a07`)과 금색/주황색 계열의 포인트 컬러(`rgb(238 185 73)`)를 사용하여 판타지 아레나 분위기를 연출합니다.
- **반응형 대응**: `clamp()`, `min()`, `calc()` 등 현대적인 CSS 함수를 적극 활용하여 다양한 화면 크기에서도 유연하게 대응합니다.
- **가독성**: 텍스트 섀도우와 반투명 배경(`backdrop-filter`)을 활용해 복잡한 전투 화면 위에서도 UI 요소의 시인성을 확보합니다.
## 3. 스타일 수정 가이드
- **전역 상수 변경**: 색상이나 기본 여백 등은 `base.css``:root` 변수를 먼저 확인하십시오.
- **컴포넌트 스타일 수정**: 수정하려는 UI 요소가 속한 카테고리에 맞는 파일을 열어 작업하십시오. (예: 킬로그 수정 -> `game-ui.css`)
- **모바일 레이아웃 수정**: 데스크톱 스타일을 수정한 후에는 `mobile.css`에서 해당 요소가 모바일에서 어떻게 보이는지 반드시 확인하고 필요한 경우 오버라이드하십시오.
- **애니메이션 추가**: 새로운 키프레임은 `animations.css`에 추가하여 중앙 집중식으로 관리합니다.

View File

@ -215,7 +215,7 @@ Player 10</textarea
name="spawnPlacement"
value="starting-zones"
/>
<span>집결 배치</span>
<span>스타팅 지점 배치</span>
</label>
<label class="spawn-placement-option">
<input

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@ -1,174 +1,173 @@
// 경기장을 구성하는 격자 칸 수입니다. 값이 커질수록 전장이 넓어집니다.
export const GRID_SIZE = 50;
// 격자 한 칸의 픽셀 크기입니다. 경기장 크기와 좌표 간격에 영향을 줍니다.
export const TILE_SIZE = 64;
// 실제 전장 전체 픽셀 크기입니다. GRID_SIZE와 TILE_SIZE를 기반으로 계산합니다.
export const ARENA_SIZE = GRID_SIZE * TILE_SIZE;
// 1. ARENA 도메인
const GRID_SIZE = 50;
const TILE_SIZE = 64;
const ARENA_SIZE = GRID_SIZE * TILE_SIZE;
// 근접 캐릭터가 공격을 시작할 수 있는 기본 거리입니다.
export const ATTACK_RANGE = 84;
// 기본 공격 쿨다운(ms)입니다. 낮을수록 공격 빈도가 높아집니다.
export const ATTACK_COOLDOWN = 840;
// 공격이 한 번 적중했을 때 적용되는 최소 피해량입니다.
export const ATTACK_DAMAGE_MIN = 14;
// 공격이 한 번 적중했을 때 적용되는 최대 피해량입니다.
export const ATTACK_DAMAGE_MAX = 24;
// 새 매치가 시작될 때 기본 팀당 캐릭터 수입니다.
export const DEFAULT_TEAM_SIZE = 5;
// 전투 시작 시 전투원을 배치하는 기본 방식입니다.
export const DEFAULT_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_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_HEIGHT = 100;
// 캐릭터 히트박스의 원본 프레임 기준 너비입니다.
export const FIGHTER_HITBOX_WIDTH = 22;
// 캐릭터 히트박스의 원본 프레임 기준 높이입니다.
export const FIGHTER_HITBOX_HEIGHT = 20;
// 100x100 프레임 안에서 히트박스가 시작되는 X 좌표입니다.
export const FIGHTER_HITBOX_OFFSET_X = 39;
// 100x100 프레임 안에서 히트박스가 시작되는 Y 좌표입니다. 실제 캐릭터 픽셀 하단은 대체로 y=59입니다.
export const FIGHTER_HITBOX_OFFSET_Y = 40;
// 캐릭터의 기본 최대 체력입니다.
export const FIGHTER_MAX_HP = 100;
// 적 처치 시 현재 체력 기준으로 회복되는 비율입니다.
export const KILL_HEALTH_RECOVERY_RATIO = 0.3;
// 처치 회복 이펙트 스프라이트시트의 프레임 수입니다.
export const KILL_HEAL_EFFECT_FRAMES = 4;
// 처치 회복 이펙트 애니메이션의 초당 프레임 수입니다.
export const KILL_HEAL_EFFECT_FRAME_RATE = 12;
// 적 처치 시 크기, 공격속도, 이동속도에 누적 적용되는 배율입니다.
export const KILL_GROWTH_MULTIPLIER = 1.25;
// 처치 보상으로 누적 적용되는 최대 배율입니다. 기본 scale에 곱해지는 상한이기도 합니다.
export const KILL_GROWTH_MAX_MULTIPLIER = 5;
// 처치 성장 연출 tween 지속 시간(ms)입니다.
export const KILL_GROWTH_TWEEN_DURATION = 180;
// 입력 UI에서 허용하는 팀당 최대 캐릭터 수입니다.
export const MAX_TEAM_SIZE = 100;
// 근접 캐릭터의 기본 치명타 확률입니다. 치명타는 즉시 처치로 처리됩니다.
export const MELEE_CRITICAL_CHANCE = 0.05;
// 캐릭터 기본 이동 속도입니다. 처치 보상과 전역 이동 배율이 곱해집니다.
export const MOVE_SPEED = 148;
// 투사체가 자동으로 사라지기까지의 시간(ms)입니다.
export const PROJECTILE_LIFETIME = 1800;
// 투사체 기본 이동 속도입니다. 처치 보상과 전역 공격 배율이 곱해집니다.
export const PROJECTILE_SPEED = 420;
// 원거리 캐릭터의 기본 치명타 확률입니다.
export const RANGED_CRITICAL_CHANCE = 0;
// 원거리 캐릭터가 공격을 시작할 수 있는 기본 거리입니다.
export const RANGED_ATTACK_RANGE = TILE_SIZE * 5;
// 근접 공격 애니메이션 시작 후 실제 피해가 들어가기까지의 지연(ms)입니다.
export const MELEE_HIT_DELAY = 260;
// 원거리 공격 애니메이션 시작 후 투사체가 발사되기까지의 지연(ms)입니다.
export const PROJECTILE_FIRE_DELAY = 360;
// 투사체 충돌 원형 바디가 이미지 안에서 시작되는 오프셋입니다.
export const PROJECTILE_BODY_OFFSET = 4;
// 투사체 궤적 충돌 검사 시 대상 히트박스에 더하는 여유 픽셀입니다.
export const PROJECTILE_HIT_PADDING = 20;
// 투사체 충돌 원형 바디의 반지름입니다.
export const PROJECTILE_HIT_RADIUS = 12;
// 투사체가 공격자 위치에서 얼마나 떨어져 생성되는지 정하는 거리입니다.
export const PROJECTILE_SPAWN_DISTANCE = 1;
// 즉발 마법 캐스팅 후 이펙트가 생성되기까지의 지연(ms)입니다.
export const SPELL_CAST_DELAY = 340;
// 마법 이펙트 생성 후 실제 피해가 들어가기까지의 지연(ms)입니다.
export const SPELL_HIT_DELAY = 160;
// 카메라 최소 줌입니다. 전장 전체를 보는 기본 배율입니다.
export const CAMERA_MIN_ZOOM = 1;
// 카메라 최대 줌입니다. 후반 관전 및 휠 확대의 상한입니다.
export const CAMERA_MAX_ZOOM = 3;
// 마우스 휠 한 번당 카메라 줌 변화량입니다.
export const CAMERA_ZOOM_STEP = 0.1;
// 미니맵 카메라가 보일 때의 투명도입니다.
export const MINIMAP_ALPHA = 0.8;
// 미니맵이 화면 가장자리에서 떨어지는 거리입니다.
export const MINIMAP_MARGIN = Math.round(ARENA_SIZE * 0.016);
// 미니맵의 고정 픽셀 크기입니다.
export const MINIMAP_VIEWPORT_SIZE = Math.round(ARENA_SIZE * 0.22);
// 미니맵 현재 뷰포트 표시용 선 두께입니다.
export const MINIMAP_VIEW_FRAME_STROKE = 10;
// 관전 카메라가 목표 전투 지점으로 따라가는 부드러움입니다.
export const SPECTATOR_CAMERA_LERP = 0.1;
// 생존자가 이 수보다 적으면 최종 전투 줌을 적용합니다.
export const SPECTATOR_FINAL_FIGHTER_THRESHOLD = 5;
// 최종 전투 구간에서 강제로 적용되는 카메라 줌입니다.
export const SPECTATOR_FINAL_FIGHT_ZOOM = 3;
export const SPECTATOR_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_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_FIGHT_ZOOM = 2;
// 캐릭터를 선택했을 때 최소로 확보하는 카메라 줌입니다.
export const SELECTED_FIGHTER_CAMERA_ZOOM = 2;
// 선택 실루엣과 원본 캐릭터 사이에 비워두는 픽셀 간격입니다.
export const SELECTED_FIGHTER_OUTLINE_GAP = 1;
// 선택 실루엣 자체가 차지하는 픽셀 두께입니다.
export const SELECTED_FIGHTER_OUTLINE_WIDTH = 1;
// 선택 실루엣의 빨간색 채널 값입니다.
export const SELECTED_FIGHTER_OUTLINE_RED = 255;
// 선택 실루엣의 초록색 채널 값입니다.
export const SELECTED_FIGHTER_OUTLINE_GREEN = 228;
// 선택 실루엣의 파란색 채널 값입니다.
export const SELECTED_FIGHTER_OUTLINE_BLUE = 64;
// 선택 실루엣의 전체 투명도입니다. 0.65는 윤곽을 또렷하게 보이면서 원본 캐릭터를 덮지 않습니다.
export const SELECTED_FIGHTER_OUTLINE_ALPHA = 0.65;
// 참가자 닉네임을 잘라낼 최대 글자 수입니다.
export const NICKNAME_LENGTH = 18;
// 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수입니다.
export const FIGHTER_ANIMATION_OPTIONS = {
// 기본 공격 애니메이션 속도입니다.
attack: { frameRate: 15, repeat: 0 },
// 보조 공격 애니메이션 속도입니다.
attack02: { frameRate: 15, repeat: 0 },
// 강공격/치명타용 공격 애니메이션 속도입니다.
attack03: { frameRate: 15, repeat: 0 },
// 방어 애니메이션 속도입니다.
block: { frameRate: 13, repeat: 0 },
// 사망 애니메이션 속도입니다.
death: { frameRate: 11, repeat: 0 },
// 회복 애니메이션 속도입니다.
heal: { frameRate: 13, repeat: 0 },
// 피격 애니메이션 속도입니다.
hurt: { frameRate: 13, repeat: 0 },
// 대기 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
idle: { frameRate: 7, repeat: -1 },
// 이동 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
walk: { frameRate: 10, repeat: -1 },
// 대체 이동 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
walk02: { frameRate: 10, repeat: -1 },
export const ARENA = {
GRID_SIZE,
TILE_SIZE,
SIZE: ARENA_SIZE,
};
// 팀 배정에 순서대로 사용되는 기본 색상 팔레트입니다.
export const TEAM_COLORS = [
// 2. FIGHTER 도메인
export const FIGHTER = {
SCALE: 3,
DEPTH: 2,
DEAD_DEPTH: 1,
DEAD_ALPHA: 0.42,
FRAME_WIDTH: 100,
FRAME_HEIGHT: 100,
HITBOX_WIDTH: 22,
HITBOX_HEIGHT: 20,
HITBOX_OFFSET_X: 39,
HITBOX_OFFSET_Y: 40,
NICKNAME_LENGTH: 18,
// 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수
ANIMATION_OPTIONS: {
attack: { frameRate: 15, repeat: 0 },
attack02: { frameRate: 15, repeat: 0 },
attack03: { frameRate: 15, repeat: 0 },
block: { frameRate: 13, repeat: 0 },
death: { frameRate: 11, repeat: 0 },
heal: { frameRate: 13, repeat: 0 },
hurt: { frameRate: 13, repeat: 0 },
idle: { frameRate: 7, repeat: -1 },
walk: { frameRate: 10, repeat: -1 },
walk02: { frameRate: 10, repeat: -1 },
},
// 역할별 기본 스탯
TYPE_STATS: {
melee: {
maxHp: 100,
moveSpeed: 148 * 1.1,
attackRange: 84,
attackCooldown: 840,
damageMin: 14,
damageMax: 24,
criticalChance: 0.2,
windupDelay: 260,
},
ranged: {
maxHp: 80,
moveSpeed: 148,
attackRange: TILE_SIZE * 5,
attackCooldown: 840 * 1.1,
damageMin: 14 * 1.2,
damageMax: 24 * 1.2,
criticalChance: 0,
windupDelay: 360,
projectileSpeed: 420,
},
magic: {
maxHp: 80,
moveSpeed: 148,
attackRange: TILE_SIZE * 5,
attackCooldown: 840 * 1.1,
damageMin: 14 * 1.5,
damageMax: 24 * 1.5,
criticalChance: 0,
windupDelay: 340,
effectHitDelay: 160,
},
},
};
// 3. SPAWN 도메인
export const SPAWN = {
DEFAULT_TEAM_SIZE: 5,
DEFAULT_PLACEMENT: "random",
PLACEMENTS: {
RANDOM: "random",
STARTING_ZONES: "starting-zones",
},
STARTING_ZONE_RADIUS: 2,
STARTING_ZONE_FILL_ALPHA: 0.07,
STARTING_ZONE_BORDER_ALPHA: 0.14,
STARTING_ZONE_VISIBLE_DURATION_MS: 5000,
PRESENTATION_TEAM_COUNT: 10,
PRESENTATION_TEAM_SIZE: 5,
MAX_TEAM_SIZE: 100,
};
// 4. COMBAT 도메인
export const COMBAT = {
KILL_HEALTH_RECOVERY_RATIO: 0.3,
KILL_HEAL_EFFECT_FRAMES: 4,
KILL_HEAL_EFFECT_FRAME_RATE: 12,
KILL_GROWTH_MULTIPLIER: 1.25,
KILL_GROWTH_MAX_MULTIPLIER: 5,
KILL_GROWTH_TWEEN_DURATION: 180,
// 최종교전 슬로우모션 설정
FINAL_SLOW_MOTION_ENABLED: false,
FINAL_SLOW_MOTION_ENTER_DURATION: 14000,
FINAL_SLOW_MOTION_HOLD_DURATION: 14000,
FINAL_SLOW_MOTION_EXIT_DURATION: 14000,
FINAL_SLOW_MOTION_SCALE: 0.28,
};
// 5. PROJECTILE 도메인
export const PROJECTILE = {
LIFETIME: 1800,
BODY_OFFSET: 4,
HIT_PADDING: 20,
HIT_RADIUS: 12,
SPAWN_DISTANCE: 1,
};
// 6. WORLD_EFFECT 도메인
export const WORLD_EFFECT = {
INTERVAL: 4000,
AREA_TILES: 5,
FRAMES: 7,
FRAME_RATE: 14,
FALL_DURATION: 920,
FALL_TRAVEL_TILES: 8,
VISUAL_SCALE: 12,
METEOR_DAMAGE: 80,
FROST_DAMAGE: 40,
FROST_STUN_DURATION: 2000,
FROST_STUN_TINT: 0x82e9ff,
FROST_DURATION: 20000,
FROST_SPEED_MULTIPLIER: 0.55,
};
// 7. CAMERA 도메인
export const CAMERA = {
MIN_ZOOM: 1,
MAX_ZOOM: 3,
ZOOM_STEP: 0.1,
// 자동 관전 진입 전 화염/냉기 메테오 낙하 위치를 임시로 확대 추적합니다.
METEOR_FOCUS_ENABLED: true,
METEOR_FOCUS_ZOOM: 2,
SPECTATOR_LERP: 0.1,
// 메테오 착탄 후 카메라를 해당 위치에 유지하는 시간(ms)입니다.
METEOR_FOCUS_HOLD_DURATION: 1200,
SPECTATOR_FINAL_FIGHTER_THRESHOLD: 5,
SPECTATOR_FINAL_FIGHT_ZOOM: 3,
SPECTATOR_FINAL_TEAM_COUNT: 2,
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD: 8,
SPECTATOR_RANDOM_FOCUS_INTERVAL: 10000,
SPECTATOR_LATE_FIGHTER_THRESHOLD: 30,
SPECTATOR_LATE_FIGHT_ZOOM: 2,
SELECTED_FIGHTER_ZOOM: 2,
};
// 8. UI 도메인
export const UI = {
MINIMAP_ALPHA: 0.8,
MINIMAP_MARGIN: Math.round(ARENA_SIZE * 0.016),
MINIMAP_VIEWPORT_SIZE: Math.round(ARENA_SIZE * 0.22),
MINIMAP_VIEW_FRAME_STROKE: 10,
SELECTED_FIGHTER_OUTLINE_GAP: 1,
SELECTED_FIGHTER_OUTLINE_WIDTH: 1,
SELECTED_FIGHTER_OUTLINE_RED: 255,
SELECTED_FIGHTER_OUTLINE_GREEN: 228,
SELECTED_FIGHTER_OUTLINE_BLUE: 64,
SELECTED_FIGHTER_OUTLINE_ALPHA: 0.65,
};
// 9. TEAM 도메인
const TEAM_COLORS = [
"#da6a48",
"#5fb4d9",
"#9bd15a",
@ -184,7 +183,7 @@ const TEAM_COLOR_HUE_OFFSET = 12;
const TEAM_COLOR_SATURATIONS = [72, 62, 78, 68];
const TEAM_COLOR_LIGHTNESSES = [57, 63, 51, 69];
export function getTeamColor(index, totalTeams = TEAM_COLORS.length) {
function getTeamColor(index, totalTeams = TEAM_COLORS.length) {
const safeIndex = Math.max(0, Math.floor(Number(index) || 0));
const safeTeamCount = Math.max(1, Math.floor(Number(totalTeams) || 1));
@ -234,3 +233,8 @@ function hslToHex(hue, saturation, lightness) {
)
.join("")}`;
}
export const TEAM = {
COLORS: TEAM_COLORS,
getColor: getTeamColor,
};

View File

@ -1,30 +1,20 @@
import Phaser from "phaser";
import {
ARENA_SIZE,
CAMERA_MAX_ZOOM,
CAMERA_MIN_ZOOM,
CAMERA_ZOOM_STEP,
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,
MINIMAP_ALPHA,
MINIMAP_MARGIN,
MINIMAP_VIEWPORT_SIZE,
MINIMAP_VIEW_FRAME_STROKE,
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,
ARENA,
CAMERA,
COMBAT,
SPAWN,
UI,
} from "../../constants.js";
import { drawArena } from "./arenaRenderer.js";
import { drawArena, drawStartingZones } from "./arenaRenderer.js";
import { clearCombatObjects, updateFighter } from "../combat/combat.js";
import {
clearWorldEffects,
createWorldEffectAnimations,
preloadWorldEffectAssets,
startWorldEffects,
updateWorldEffectModifiers,
} from "../combat/worldEffects.js";
import { createFighterAnimations, preloadFighterSheets } from "../fighter/fighterAssets.js";
import { createFighter, syncFighterHud } from "../fighter/fighterFactory.js";
import { fighterManifest } from "../fighter/fighterManifest.js";
@ -104,29 +94,37 @@ export class ArenaScene extends Phaser.Scene {
this.finalFocusNextSwitchAt = 0;
this.finalFocusTarget = null;
this.spectatorMode = null;
this.meteorFocusState = null;
this.slowMotionRestoreState = null;
this.slowMotionTimer = null;
this.slowMotionTransitionFrame = null;
this.startingZoneGraphics = null;
this.startingZoneHideTimer = null;
this.worldEffectTimer = null;
this.worldEffectZones = new Set();
}
preload() {
preloadFighterSheets(this, fighterManifest);
preloadWorldEffectAssets(this);
}
create() {
this.physics.world.setBounds(0, 0, ARENA_SIZE, ARENA_SIZE);
this.cameras.main.setBounds(0, 0, ARENA_SIZE, ARENA_SIZE);
this.physics.world.setBounds(0, 0, ARENA.SIZE, ARENA.SIZE);
this.cameras.main.setBounds(0, 0, ARENA.SIZE, ARENA.SIZE);
this.cameras.main.setBackgroundColor("#282819");
drawArena(this);
this.startingZoneGraphics = this.add.graphics().setDepth(0.5);
createFighterAnimations(this, fighterManifest);
createWorldEffectAnimations(this);
// 미니맵 카메라 설정
this.minimapCamera = this.cameras
.add(MINIMAP_MARGIN, MINIMAP_MARGIN, MINIMAP_VIEWPORT_SIZE, MINIMAP_VIEWPORT_SIZE)
.setZoom(MINIMAP_VIEWPORT_SIZE / ARENA_SIZE)
.add(UI.MINIMAP_MARGIN, UI.MINIMAP_MARGIN, UI.MINIMAP_VIEWPORT_SIZE, UI.MINIMAP_VIEWPORT_SIZE)
.setZoom(UI.MINIMAP_VIEWPORT_SIZE / ARENA.SIZE)
.setName("minimap");
this.minimapCamera.setBackgroundColor(0x000000);
this.minimapCamera.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
this.minimapCamera.centerOn(ARENA.SIZE / 2, ARENA.SIZE / 2);
this.minimapViewportFrame = this.add.graphics().setDepth(10);
this.cameras.main.ignore(this.minimapViewportFrame);
this.updateMinimapViewportFrame();
@ -134,9 +132,9 @@ export class ArenaScene extends Phaser.Scene {
// 마우스 휠로 줌 조절
this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY, deltaZ) => {
const newZoom = Phaser.Math.Clamp(
this.cameras.main.zoom + (deltaY > 0 ? -CAMERA_ZOOM_STEP : CAMERA_ZOOM_STEP),
CAMERA_MIN_ZOOM,
CAMERA_MAX_ZOOM,
this.cameras.main.zoom + (deltaY > 0 ? -CAMERA.ZOOM_STEP : CAMERA.ZOOM_STEP),
CAMERA.MIN_ZOOM,
CAMERA.MAX_ZOOM,
);
this.setMainCameraZoom(newZoom);
@ -186,17 +184,20 @@ export class ArenaScene extends Phaser.Scene {
this.matchOver = false;
this.setPaused(false, { silent: true });
this.clearFinalCombatEffects();
clearWorldEffects(this);
this.presentationMode = silent;
this.resetMatchDeathStats({ silent });
this.observedCombat = [];
this.clearSelectedFighter();
this.setMainCameraZoom(CAMERA_MIN_ZOOM);
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
this.setMainCameraZoom(CAMERA.MIN_ZOOM);
this.cameras.main.centerOn(ARENA.SIZE / 2, ARENA.SIZE / 2);
clearCombatObjects(this);
this.fighters.forEach((fighter) => fighter.destroy());
this.resetKillLog();
this.teams = matchSetup.teams;
this.showStartingZones(matchSetup.startingZones);
this.fighters = fighterPlans.map((fighterPlan) => createFighter(this, fighterPlan));
startWorldEffects(this);
if (!silent) {
trackMatchStart();
@ -208,6 +209,24 @@ export class ArenaScene extends Phaser.Scene {
this.updateScoreboard();
}
showStartingZones(startingZones) {
this.startingZoneHideTimer?.remove(false);
this.startingZoneHideTimer = null;
drawStartingZones(this.startingZoneGraphics, startingZones);
if (startingZones.length === 0) {
return;
}
this.startingZoneHideTimer = this.time.delayedCall(
SPAWN.STARTING_ZONE_VISIBLE_DURATION_MS,
() => {
this.startingZoneHideTimer = null;
this.startingZoneGraphics.clear();
},
);
}
spawnSplitFighters(source, splitOnDeath) {
const count = Math.max(0, Math.round(splitOnDeath.count ?? 0));
const childMaxHp = Math.max(1, Math.round(splitOnDeath.childMaxHp ?? 1));
@ -380,6 +399,8 @@ update(time) {
}
if (!this.matchOver) {
updateWorldEffectModifiers(this);
this.fighters.forEach((fighter) => {
updateFighter(this, fighter, time, () => {
this.updateScoreboard();
@ -411,11 +432,14 @@ update(time) {
this.syncSpectatorMode(spectatorState?.mode ?? null);
if (spectatorState) {
this.clearMeteorCameraFocus(null, { restoreCamera: false });
this.setMainCameraZoom(spectatorState.zoom);
this.moveCameraToward(this.getSpectatorCameraTarget(spectatorState, livingFighters, time));
} else if (this.cameras.main.zoom <= CAMERA_MIN_ZOOM) {
} else if (this.followMeteorCameraFocus()) {
// 월드 이펙트 착탄 지점의 임시 시점은 자동 관전 진입 전까지만 사용합니다.
} else if (this.cameras.main.zoom <= CAMERA.MIN_ZOOM) {
// 줌이 1일 때는 경기장 중앙에 고정
this.cameras.main.centerOn(ARENA_SIZE / 2, ARENA_SIZE / 2);
this.cameras.main.centerOn(ARENA.SIZE / 2, ARENA.SIZE / 2);
}
this.updateMinimapViewportFrame();
@ -466,7 +490,7 @@ update(time) {
? candidates.filter((fighter) => fighter !== this.finalFocusTarget)
: candidates;
this.finalFocusTarget = nextCandidates[Phaser.Math.Between(0, nextCandidates.length - 1)];
this.finalFocusNextSwitchAt = time + SPECTATOR_RANDOM_FOCUS_INTERVAL;
this.finalFocusNextSwitchAt = time + CAMERA.SPECTATOR_RANDOM_FOCUS_INTERVAL;
}
return fighterCameraPoint(this.finalFocusTarget);
@ -480,8 +504,89 @@ update(time) {
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;
this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * CAMERA.SPECTATOR_LERP;
this.cameras.main.scrollY += (targetY - this.cameras.main.midPoint.y) * CAMERA.SPECTATOR_LERP;
}
beginMeteorCameraFocus(zone) {
if (
!CAMERA.METEOR_FOCUS_ENABLED
|| this.presentationMode
|| this.matchOver
|| this.selectedFighter
|| getSpectatorState(this.fighters.filter(isLivingFighter))
) {
return;
}
const previousFocus = this.meteorFocusState;
this.meteorFocusState = {
restoreCenter: previousFocus?.restoreCenter ?? {
x: this.cameras.main.midPoint.x,
y: this.cameras.main.midPoint.y,
},
restoreZoom: previousFocus?.restoreZoom ?? this.cameras.main.zoom,
target: {
x: zone.centerX,
y: zone.centerY,
},
zone,
};
this.meteorFocusClearTimer?.remove(false);
this.meteorFocusClearTimer = null;
}
followMeteorCameraFocus() {
if (!this.meteorFocusState) {
return false;
}
this.setMainCameraZoom(CAMERA.METEOR_FOCUS_ZOOM);
this.moveCameraToward(this.meteorFocusState.target);
return true;
}
clearMeteorCameraFocus(zone, { restoreCamera = true, immediate = false } = {}) {
if (!this.meteorFocusState || (zone && this.meteorFocusState.zone !== zone)) {
return;
}
if (immediate || !restoreCamera) {
this.performClearMeteorCameraFocus(restoreCamera);
return;
}
this.meteorFocusClearTimer?.remove(false);
this.meteorFocusClearTimer = this.time.delayedCall(
CAMERA.METEOR_FOCUS_HOLD_DURATION,
() => this.performClearMeteorCameraFocus(restoreCamera),
);
}
performClearMeteorCameraFocus(restoreCamera) {
this.meteorFocusClearTimer?.remove(false);
this.meteorFocusClearTimer = null;
if (!this.meteorFocusState) {
return;
}
const { restoreCenter, restoreZoom } = this.meteorFocusState;
this.meteorFocusState = null;
if (
!restoreCamera
|| this.presentationMode
|| this.matchOver
|| this.selectedFighter
|| getSpectatorState(this.fighters.filter(isLivingFighter))
) {
return;
}
this.setMainCameraZoom(restoreZoom);
this.cameras.main.centerOn(Math.round(restoreCenter.x), Math.round(restoreCenter.y));
}
isFinalCombatActive() {
@ -490,7 +595,7 @@ update(time) {
triggerFinalCombatSlowMotion() {
if (
!FINAL_COMBAT_SLOW_MOTION_ENABLED
!COMBAT.FINAL_SLOW_MOTION_ENABLED
|| this.presentationMode
|| this.matchOver
|| this.matchPaused
@ -511,8 +616,8 @@ update(time) {
};
this.transitionSceneTimeScale(
FINAL_COMBAT_SLOW_MOTION_SCALE,
FINAL_COMBAT_SLOW_MOTION_ENTER_DURATION,
COMBAT.FINAL_SLOW_MOTION_SCALE,
COMBAT.FINAL_SLOW_MOTION_ENTER_DURATION,
easeOutCubic,
() => this.holdFinalCombatSlowMotion(),
);
@ -526,7 +631,7 @@ update(time) {
this.slowMotionTimer = globalThis.setTimeout(() => {
this.slowMotionTimer = null;
this.releaseFinalCombatSlowMotion();
}, FINAL_COMBAT_SLOW_MOTION_HOLD_DURATION);
}, COMBAT.FINAL_SLOW_MOTION_HOLD_DURATION);
}
releaseFinalCombatSlowMotion() {
@ -538,7 +643,7 @@ update(time) {
this.transitionSceneTimeScale(
restore.clock,
FINAL_COMBAT_SLOW_MOTION_EXIT_DURATION,
COMBAT.FINAL_SLOW_MOTION_EXIT_DURATION,
easeInOutCubic,
() => {
if (this.slowMotionRestoreState !== restore) {
@ -652,11 +757,12 @@ update(time) {
return;
}
this.clearMeteorCameraFocus(null, { restoreCamera: false });
this.clearSelectedFighter();
this.selectedFighter = fighter;
fighter.isSelected = true;
this.observedCombat = [];
this.setMainCameraZoom(Math.max(this.cameras.main.zoom, SELECTED_FIGHTER_CAMERA_ZOOM));
this.setMainCameraZoom(Math.max(this.cameras.main.zoom, CAMERA.SELECTED_FIGHTER_ZOOM));
this.centerCameraOnFighter(fighter);
syncFighterHud(fighter);
}
@ -764,7 +870,7 @@ update(time) {
}
focusPresentationCombat() {
this.cameras.main.setZoom(SPECTATOR_LATE_FIGHT_ZOOM);
this.cameras.main.setZoom(CAMERA.SPECTATOR_LATE_FIGHT_ZOOM);
this.observedCombat = findClosestOpponentPair(this.fighters) ?? [];
const combatCenter = this.getObservedCombatCenter();
@ -777,8 +883,8 @@ update(time) {
}
followPresentationCombat() {
if (this.cameras.main.zoom !== SPECTATOR_LATE_FIGHT_ZOOM) {
this.cameras.main.setZoom(SPECTATOR_LATE_FIGHT_ZOOM);
if (this.cameras.main.zoom !== CAMERA.SPECTATOR_LATE_FIGHT_ZOOM) {
this.cameras.main.setZoom(CAMERA.SPECTATOR_LATE_FIGHT_ZOOM);
}
const combatCenter = this.getObservedCombatCenter();
@ -787,21 +893,21 @@ update(time) {
}
this.cameras.main.scrollX +=
(Math.round(combatCenter.x) - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP;
(Math.round(combatCenter.x) - this.cameras.main.midPoint.x) * CAMERA.SPECTATOR_LERP;
this.cameras.main.scrollY +=
(Math.round(combatCenter.y) - this.cameras.main.midPoint.y) * SPECTATOR_CAMERA_LERP;
(Math.round(combatCenter.y) - this.cameras.main.midPoint.y) * CAMERA.SPECTATOR_LERP;
}
setMainCameraZoom(zoom) {
const newZoom = Phaser.Math.Clamp(zoom, CAMERA_MIN_ZOOM, CAMERA_MAX_ZOOM);
const newZoom = Phaser.Math.Clamp(zoom, CAMERA.MIN_ZOOM, CAMERA.MAX_ZOOM);
this.cameras.main.setZoom(newZoom);
if (newZoom === CAMERA_MIN_ZOOM) {
if (newZoom === CAMERA.MIN_ZOOM) {
this.observedCombat = [];
}
this.minimapCamera.setAlpha(newZoom > CAMERA_MIN_ZOOM ? MINIMAP_ALPHA : 0);
this.minimapCamera.setAlpha(newZoom > CAMERA.MIN_ZOOM ? UI.MINIMAP_ALPHA : 0);
this.updateMinimapViewportFrame();
}
@ -813,14 +919,14 @@ update(time) {
const camera = this.cameras.main;
this.minimapViewportFrame.clear();
this.minimapViewportFrame.setVisible(camera.zoom > CAMERA_MIN_ZOOM);
this.minimapViewportFrame.setVisible(camera.zoom > CAMERA.MIN_ZOOM);
if (camera.zoom <= CAMERA_MIN_ZOOM) {
if (camera.zoom <= CAMERA.MIN_ZOOM) {
return;
}
const frameWidth = this.snapMinimapFrameValue(Math.min(camera.displayWidth, ARENA_SIZE));
const frameHeight = this.snapMinimapFrameValue(Math.min(camera.displayHeight, ARENA_SIZE));
const frameWidth = this.snapMinimapFrameValue(Math.min(camera.displayWidth, ARENA.SIZE));
const frameHeight = this.snapMinimapFrameValue(Math.min(camera.displayHeight, ARENA.SIZE));
const scrollX = camera.useBounds ? camera.clampX(camera.scrollX) : camera.scrollX;
const scrollY = camera.useBounds ? camera.clampY(camera.scrollY) : camera.scrollY;
const cameraMidX = scrollX + camera.width / 2;
@ -828,19 +934,19 @@ update(time) {
const frameX = Phaser.Math.Clamp(
this.snapMinimapFrameValue(cameraMidX - frameWidth / 2),
0,
ARENA_SIZE - frameWidth,
ARENA.SIZE - frameWidth,
);
const frameY = Phaser.Math.Clamp(
this.snapMinimapFrameValue(cameraMidY - frameHeight / 2),
0,
ARENA_SIZE - frameHeight,
ARENA.SIZE - frameHeight,
);
this.drawMinimapViewportFrame(frameX, frameY, frameWidth, frameHeight);
}
drawMinimapViewportFrame(frameX, frameY, frameWidth, frameHeight) {
const stroke = Math.min(MINIMAP_VIEW_FRAME_STROKE, frameWidth, frameHeight);
const stroke = Math.min(UI.MINIMAP_VIEW_FRAME_STROKE, frameWidth, frameHeight);
const sideHeight = Math.max(0, frameHeight - stroke * 2);
this.minimapViewportFrame.fillStyle(0xffe4a8, 1);
@ -922,6 +1028,7 @@ update(time) {
this.matchOver = true;
this.clearFinalCombatEffects();
clearWorldEffects(this);
clearCombatObjects(this);
this.fighters.forEach((fighter) => {
if (fighter.body) {

View File

@ -1,29 +1,49 @@
import { ARENA_SIZE, GRID_SIZE, TILE_SIZE } from "../../constants.js";
import {
ARENA,
SPAWN,
} from "../../constants.js";
export function drawArena(scene) {
const graphics = scene.add.graphics();
graphics.fillStyle(0x34351f, 1);
graphics.fillRect(0, 0, ARENA_SIZE, ARENA_SIZE);
graphics.fillRect(0, 0, ARENA.SIZE, ARENA.SIZE);
graphics.fillStyle(0x556235, 0.12);
for (let row = 0; row < GRID_SIZE; row += 1) {
for (let column = 0; column < GRID_SIZE; column += 1) {
for (let row = 0; row < ARENA.GRID_SIZE; row += 1) {
for (let column = 0; column < ARENA.GRID_SIZE; column += 1) {
if ((row + column) % 2 === 0) {
graphics.fillRect(column * TILE_SIZE, row * TILE_SIZE, TILE_SIZE, TILE_SIZE);
graphics.fillRect(column * ARENA.TILE_SIZE, row * ARENA.TILE_SIZE, ARENA.TILE_SIZE, ARENA.TILE_SIZE);
}
}
}
graphics.lineStyle(1, 0xd3bd72, 0.11);
for (let index = 0; index <= GRID_SIZE; index += 1) {
const offset = index * TILE_SIZE;
graphics.lineBetween(offset, 0, offset, ARENA_SIZE);
graphics.lineBetween(0, offset, ARENA_SIZE, offset);
for (let index = 0; index <= ARENA.GRID_SIZE; index += 1) {
const offset = index * ARENA.TILE_SIZE;
graphics.lineBetween(offset, 0, offset, ARENA.SIZE);
graphics.lineBetween(0, offset, ARENA.SIZE, offset);
}
graphics.lineStyle(12, 0x17180e, 1);
graphics.strokeRect(0, 0, ARENA_SIZE, ARENA_SIZE);
graphics.strokeRect(0, 0, ARENA.SIZE, ARENA.SIZE);
graphics.lineStyle(2, 0xd3bd72, 0.35);
graphics.strokeRect(12, 12, ARENA_SIZE - 24, ARENA_SIZE - 24);
graphics.strokeRect(12, 12, ARENA.SIZE - 24, ARENA.SIZE - 24);
}
export function drawStartingZones(graphics, startingZones = []) {
graphics.clear();
startingZones.forEach((zone) => {
const color = Number.parseInt(zone.color.slice(1), 16);
const x = zone.columnStart * ARENA.TILE_SIZE;
const y = zone.rowStart * ARENA.TILE_SIZE;
const width = (zone.columnEnd - zone.columnStart) * ARENA.TILE_SIZE;
const height = (zone.rowEnd - zone.rowStart) * ARENA.TILE_SIZE;
graphics.fillStyle(color, SPAWN.STARTING_ZONE_FILL_ALPHA);
graphics.fillRect(x, y, width, height);
graphics.lineStyle(2, color, SPAWN.STARTING_ZONE_BORDER_ALPHA);
graphics.strokeRect(x, y, width, height);
});
}

View File

@ -1,42 +1,37 @@
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,
CAMERA,
} from "../../constants.js";
export function getSpectatorState(livingFighters) {
const livingFighterCount = livingFighters.length;
const teamSummaries = getLivingTeamSummaries(livingFighters);
if (livingFighterCount < SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
if (livingFighterCount < CAMERA.SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
return {
isFinal: true,
mode: "final-random",
zoom: SPECTATOR_FINAL_FIGHT_ZOOM,
zoom: CAMERA.SPECTATOR_FINAL_FIGHT_ZOOM,
};
}
if (
teamSummaries.length === SPECTATOR_FINAL_TEAM_COUNT &&
livingFighterCount <= SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD
teamSummaries.length === CAMERA.SPECTATOR_FINAL_TEAM_COUNT &&
livingFighterCount <= CAMERA.SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD
) {
return {
isFinal: true,
mode: "final-underdog",
teamId: getUnderdogTeamId(teamSummaries),
zoom: SPECTATOR_FINAL_FIGHT_ZOOM,
zoom: CAMERA.SPECTATOR_FINAL_FIGHT_ZOOM,
};
}
if (livingFighterCount < SPECTATOR_LATE_FIGHTER_THRESHOLD) {
if (livingFighterCount < CAMERA.SPECTATOR_LATE_FIGHTER_THRESHOLD) {
return {
isFinal: false,
mode: "late",
zoom: SPECTATOR_LATE_FIGHT_ZOOM,
zoom: CAMERA.SPECTATOR_LATE_FIGHT_ZOOM,
};
}

View File

@ -1,32 +1,9 @@
import Phaser from "phaser";
import {
ARENA_SIZE,
ATTACK_COOLDOWN,
ATTACK_DAMAGE_MAX,
ATTACK_DAMAGE_MIN,
DEAD_FIGHTER_ALPHA,
DEAD_FIGHTER_DEPTH,
ATTACK_RANGE,
FIGHTER_MAX_HP,
FIGHTER_SCALE,
KILL_HEALTH_RECOVERY_RATIO,
KILL_GROWTH_MAX_MULTIPLIER,
KILL_GROWTH_MULTIPLIER,
KILL_GROWTH_TWEEN_DURATION,
MELEE_HIT_DELAY,
MELEE_CRITICAL_CHANCE,
MOVE_SPEED,
PROJECTILE_BODY_OFFSET,
PROJECTILE_FIRE_DELAY,
PROJECTILE_HIT_PADDING,
PROJECTILE_HIT_RADIUS,
PROJECTILE_LIFETIME,
PROJECTILE_SPAWN_DISTANCE,
PROJECTILE_SPEED,
SPELL_CAST_DELAY,
SPELL_HIT_DELAY,
RANGED_CRITICAL_CHANCE,
RANGED_ATTACK_RANGE,
ARENA,
FIGHTER,
COMBAT,
PROJECTILE,
} from "../../constants.js";
import {
getAttackSpeedMultiplier,
@ -40,11 +17,12 @@ import {
healEffectAnimationKey,
healEffectKey,
} from "../fighter/fighterAssets.js";
import { getFighterStats } from "../fighter/fighterStats.js";
export function updateFighter(scene, fighter, time, onWinner) {
const enemy = findNearestEnemy(scene.fighters, fighter);
if (!enemy || fighter.isDead || enemy.isDead || fighter.isLocked) {
if (!enemy || fighter.isDead || enemy.isDead || fighter.isFrostStunned || fighter.isLocked) {
fighter.body.setVelocity(0, 0);
return;
}
@ -53,7 +31,11 @@ export function updateFighter(scene, fighter, time, onWinner) {
fighter.setFlipX(enemy.x < fighter.x);
if (distance > getAttackRange(fighter)) {
scene.physics.moveToObject(fighter, enemy, MOVE_SPEED * fighterMovementSpeedMultiplier(fighter));
scene.physics.moveToObject(
fighter,
enemy,
combatStatsFor(fighter).moveSpeed * fighterMovementSpeedMultiplier(fighter),
);
playIfNeeded(fighter, "walk");
return;
}
@ -79,7 +61,7 @@ export function clearCombatObjects(scene) {
function beginAttack(scene, attacker, defender, time, onWinner) {
const attack = createAttackProfile(attacker);
attacker.nextAttackAt =
time + scaledAttackDelay(attacker.skin.combat?.cooldown ?? ATTACK_COOLDOWN, attacker);
time + scaledAttackDelay(combatStatsFor(attacker).attackCooldown, attacker);
attacker.isLocked = true;
scene.observeCombat?.(attacker, defender);
scene.triggerFinalCombatSlowMotion?.(attacker, defender, attack.animation);
@ -100,35 +82,44 @@ function beginAttack(scene, attacker, defender, time, onWinner) {
function queueMeleeHit(scene, attacker, defender, onWinner, attack) {
const matchId = scene.matchId;
scene.time.delayedCall(scaledAttackDelay(MELEE_HIT_DELAY, attacker), () => {
applyHit(scene, attacker, defender, onWinner, matchId, {
isCritical: attack.isCritical,
});
});
scene.time.delayedCall(
scaledAttackDelay(combatStatsFor(attacker).windupDelay, attacker),
() => {
applyHit(scene, attacker, defender, onWinner, matchId, {
isCritical: attack.isCritical,
});
},
);
}
function queueProjectile(scene, attacker, defender, onWinner, attack) {
const matchId = scene.matchId;
scene.time.delayedCall(scaledAttackDelay(PROJECTILE_FIRE_DELAY, attacker), () => {
if (!isAttackValid(scene, attacker, defender, matchId)) {
return;
}
scene.time.delayedCall(
scaledAttackDelay(combatStatsFor(attacker).windupDelay, attacker),
() => {
if (!isAttackValid(scene, attacker, defender, matchId)) {
return;
}
spawnProjectile(scene, attacker, defender, onWinner, matchId, attack);
});
spawnProjectile(scene, attacker, defender, onWinner, matchId, attack);
},
);
}
function queueInstantSpell(scene, attacker, defender, onWinner, attack) {
const matchId = scene.matchId;
scene.time.delayedCall(scaledAttackDelay(SPELL_CAST_DELAY, attacker), () => {
if (!isAttackValid(scene, attacker, defender, matchId)) {
return;
}
scene.time.delayedCall(
scaledAttackDelay(combatStatsFor(attacker).windupDelay, attacker),
() => {
if (!isAttackValid(scene, attacker, defender, matchId)) {
return;
}
spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack);
});
spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack);
},
);
}
function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) {
@ -142,9 +133,9 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) {
projectile.setDepth(3);
projectile.setScale(2);
projectile.body.setCircle(
PROJECTILE_HIT_RADIUS,
PROJECTILE_BODY_OFFSET,
PROJECTILE_BODY_OFFSET,
PROJECTILE.HIT_RADIUS,
PROJECTILE.BODY_OFFSET,
PROJECTILE.BODY_OFFSET,
);
projectile.setRotation(
Phaser.Math.Angle.Between(
@ -158,7 +149,7 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) {
projectile,
defenderHitPoint.x,
defenderHitPoint.y,
(attacker.skin.combat?.projectile?.speed ?? PROJECTILE_SPEED) *
combatStatsFor(attacker).projectileSpeed *
fighterAttackSpeedMultiplier(attacker),
);
trackCombatObject(scene, projectile);
@ -210,7 +201,7 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) {
scene.events.off(Phaser.Scenes.Events.UPDATE, checkProjectilePath);
};
scene.time.delayedCall(PROJECTILE_LIFETIME, () => {
scene.time.delayedCall(PROJECTILE.LIFETIME, () => {
disposeCombatObject(scene, projectile);
});
}
@ -218,7 +209,7 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) {
function spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack) {
const effect = scene.add.sprite(defender.x, defender.y, fighterAttackEffectKey(attacker.skin));
effect.setDepth(3);
effect.setScale(FIGHTER_SCALE);
effect.setScale(FIGHTER.SCALE);
effect.play(fighterAttackEffectAnimationKey(attacker.skin));
trackCombatObject(scene, effect);
@ -227,7 +218,7 @@ function spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack)
});
scene.time.delayedCall(
scaledAttackDelay(attacker.skin.combat?.attackEffect?.hitDelay ?? SPELL_HIT_DELAY, attacker),
scaledAttackDelay(combatStatsFor(attacker).effectHitDelay, attacker),
() => {
applyHit(scene, attacker, defender, onWinner, matchId, {
isCritical: attack.isCritical,
@ -243,12 +234,15 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = f
if (isCritical) {
spawnCriticalHitLabel(scene, defender);
scene.cameras.main.shake(90, 0.002);
}
const attackerStats = combatStatsFor(attacker);
defender.hp = isCritical
? 0
: Math.max(0, defender.hp - Phaser.Math.Between(ATTACK_DAMAGE_MIN, ATTACK_DAMAGE_MAX));
: Math.max(
0,
defender.hp - Phaser.Math.Between(attackerStats.damageMin, attackerStats.damageMax),
);
defender.body.setVelocity(0, 0);
if (defender.hp === 0) {
@ -260,8 +254,32 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = f
playAnimation(defender, "hurt");
}
export function applyWorldEffectDamage(scene, defender, damage) {
if (scene.matchOver || !defender?.active || defender.isDead) {
return false;
}
const resolvedDamage = Math.max(0, Math.round(Number(damage) || 0));
if (resolvedDamage === 0) {
return false;
}
defender.hp = Math.max(0, defender.hp - resolvedDamage);
defender.body?.setVelocity(0, 0);
if (defender.hp === 0) {
killFighter(defender);
return true;
}
defender.isLocked = true;
playAnimation(defender, "hurt");
return false;
}
function spawnCriticalHitLabel(scene, defender) {
const scaleRatio = Math.max(1, Math.abs(defender.scaleY) / FIGHTER_SCALE);
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",
@ -292,11 +310,7 @@ function spawnCriticalHitLabel(scene, defender) {
}
function getAttackRange(fighter) {
if (getCombatType(fighter) === "melee") {
return ATTACK_RANGE;
}
return fighter.skin.combat?.range ?? RANGED_ATTACK_RANGE;
return combatStatsFor(fighter).attackRange;
}
function getCombatType(fighter) {
@ -314,11 +328,7 @@ function createAttackProfile(attacker) {
}
function getCriticalChance(fighter) {
if (getCombatType(fighter) !== "melee") {
return fighter.skin.combat?.criticalChance ?? RANGED_CRITICAL_CHANCE;
}
return fighter.skin.combat?.criticalChance ?? MELEE_CRITICAL_CHANCE;
return combatStatsFor(fighter).criticalChance;
}
function isAttackValid(scene, attacker, defender, matchId) {
@ -353,8 +363,8 @@ function projectileSpawnPoint(attacker, target) {
direction.normalize();
return {
x: attacker.x + direction.x * PROJECTILE_SPAWN_DISTANCE,
y: attacker.y + direction.y * PROJECTILE_SPAWN_DISTANCE,
x: attacker.x + direction.x * PROJECTILE.SPAWN_DISTANCE,
y: attacker.y + direction.y * PROJECTILE.SPAWN_DISTANCE,
};
}
@ -370,10 +380,10 @@ function projectilePathHitsDefender(projectile, defender) {
projectile.y,
);
const defenderHitArea = new Phaser.Geom.Rectangle(
defender.body.x - PROJECTILE_HIT_PADDING,
defender.body.y - PROJECTILE_HIT_PADDING,
defender.body.width + PROJECTILE_HIT_PADDING * 2,
defender.body.height + PROJECTILE_HIT_PADDING * 2,
defender.body.x - PROJECTILE.HIT_PADDING,
defender.body.y - PROJECTILE.HIT_PADDING,
defender.body.width + PROJECTILE.HIT_PADDING * 2,
defender.body.height + PROJECTILE.HIT_PADDING * 2,
);
return (
@ -388,21 +398,27 @@ 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.setAlpha(FIGHTER.DEAD_ALPHA);
defender.setDepth(FIGHTER.DEAD_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);
playAnimation(winner, "idle");
winner.scene.recordKill?.(winner, defender);
applyKillReward(winner);
if (winner) {
winner.isLocked = false;
winner.body.setVelocity(0, 0);
playAnimation(winner, "idle");
winner.scene.recordKill?.(winner, defender);
applyKillReward(winner);
} else {
defender.scene.recordDeath?.(defender);
}
maybeSplitFighter(defender);
onWinner(winner);
onWinner?.(winner);
}
function maybeSplitFighter(fighter) {
@ -427,16 +443,16 @@ function applyKillReward(winner) {
winner.killCount = (winner.killCount ?? 0) + 1;
const rewardMultiplier = Math.min(
KILL_GROWTH_MAX_MULTIPLIER,
KILL_GROWTH_MULTIPLIER ** winner.killCount,
COMBAT.KILL_GROWTH_MAX_MULTIPLIER,
COMBAT.KILL_GROWTH_MULTIPLIER ** winner.killCount,
);
const previousHp = winner.hp;
const nextHp = recoveredHealth(winner);
winner.killRewardMultiplier = rewardMultiplier;
winner.hp = nextHp;
const nextScaleX = (winner.baseScaleX ?? FIGHTER_SCALE) * rewardMultiplier;
const nextScaleY = (winner.baseScaleY ?? FIGHTER_SCALE) * rewardMultiplier;
const nextScaleX = (winner.baseScaleX ?? FIGHTER.SCALE) * rewardMultiplier;
const nextScaleY = (winner.baseScaleY ?? FIGHTER.SCALE) * rewardMultiplier;
if (nextHp > previousHp) {
spawnKillHealEffect(winner, Math.max(Math.abs(nextScaleX), Math.abs(nextScaleY)));
@ -446,7 +462,7 @@ function applyKillReward(winner) {
targets: winner,
scaleX: nextScaleX,
scaleY: nextScaleY,
duration: KILL_GROWTH_TWEEN_DURATION,
duration: COMBAT.KILL_GROWTH_TWEEN_DURATION,
ease: "Back.Out",
onUpdate: () => clampFighterInsideArena(winner),
onComplete: () => clampFighterInsideArena(winner),
@ -459,23 +475,23 @@ function clampFighterInsideArena(fighter) {
}
const halfWidth = Math.min(
ARENA_SIZE / 2,
ARENA.SIZE / 2,
Math.max(Math.abs(fighter.displayWidth), fighter.body.width) / 2,
);
const halfHeight = Math.min(
ARENA_SIZE / 2,
ARENA.SIZE / 2,
Math.max(Math.abs(fighter.displayHeight), fighter.body.height) / 2,
);
const x = Phaser.Math.Clamp(fighter.x, halfWidth, ARENA_SIZE - halfWidth);
const y = Phaser.Math.Clamp(fighter.y, halfHeight, ARENA_SIZE - halfHeight);
const x = Phaser.Math.Clamp(fighter.x, halfWidth, ARENA.SIZE - halfWidth);
const y = Phaser.Math.Clamp(fighter.y, halfHeight, ARENA.SIZE - halfHeight);
fighter.setPosition(x, y);
fighter.body.updateFromGameObject?.();
}
function recoveredHealth(fighter) {
const maxHp = fighter.maxHp ?? FIGHTER_MAX_HP;
const recovery = Math.ceil(fighter.hp * KILL_HEALTH_RECOVERY_RATIO);
const maxHp = fighter.maxHp ?? combatStatsFor(fighter).maxHp;
const recovery = Math.ceil(fighter.hp * COMBAT.KILL_HEALTH_RECOVERY_RATIO);
return Math.min(maxHp, fighter.hp + recovery);
}
@ -556,19 +572,31 @@ function scaledAttackDelay(duration, fighter) {
}
function fighterAttackSpeedMultiplier(fighter) {
return getAttackSpeedMultiplier() * (fighter.killRewardMultiplier ?? 1);
return (
getAttackSpeedMultiplier()
* (fighter.killRewardMultiplier ?? 1)
* (fighter.worldEffectSpeedMultiplier ?? 1)
);
}
function fighterMovementSpeedMultiplier(fighter) {
return getMovementSpeedMultiplier() * (fighter.killRewardMultiplier ?? 1);
return (
getMovementSpeedMultiplier()
* (fighter.killRewardMultiplier ?? 1)
* (fighter.worldEffectSpeedMultiplier ?? 1)
);
}
function trackCombatObject(scene, object) {
function combatStatsFor(fighter) {
return fighter.combatStats ?? getFighterStats(fighter.skin);
}
export function trackCombatObject(scene, object) {
scene.combatObjects ??= new Set();
scene.combatObjects.add(object);
}
function disposeCombatObject(scene, object) {
export function disposeCombatObject(scene, object) {
if (!object?.active) {
return;
}

View File

@ -0,0 +1,362 @@
import Phaser from "phaser";
import {
ARENA,
WORLD_EFFECT,
} from "../../constants.js";
import {
applyWorldEffectDamage,
disposeCombatObject,
trackCombatObject,
} from "./combat.js";
const METEOR_EFFECT_PATH = "assets/effects/world_Effect.png";
const METEOR_EFFECT_KEY = "world-meteor-effect";
const FROST_EFFECT_PATH = "assets/effects/world_Effect_2.png";
const FROST_EFFECT_KEY = "world-frost-effect";
const WORLD_EFFECT_SHEETS = [
{ key: METEOR_EFFECT_KEY, path: METEOR_EFFECT_PATH },
{ key: FROST_EFFECT_KEY, path: FROST_EFFECT_PATH },
];
const METEOR_ZONE_COLOR = 0xf16a38;
const FROST_ZONE_COLOR = 0x58cef4;
const FALL_ANGLE_DEGREES = 45;
export function preloadWorldEffectAssets(scene) {
WORLD_EFFECT_SHEETS.forEach(({ key, path }) => {
scene.load.spritesheet(key, path, {
frameWidth: 100,
frameHeight: 100,
});
});
}
export function createWorldEffectAnimations(scene) {
WORLD_EFFECT_SHEETS.forEach(({ key }) => {
const animationKey = worldEffectAnimationKey(key);
if (scene.anims.exists(animationKey)) {
return;
}
scene.anims.create({
key: animationKey,
frames: scene.anims.generateFrameNumbers(key, {
start: 0,
end: WORLD_EFFECT.FRAMES - 1,
}),
frameRate: WORLD_EFFECT.FRAME_RATE,
repeat: 0,
});
});
}
export function startWorldEffects(scene) {
clearWorldEffects(scene);
if (scene.presentationMode) {
return;
}
scene.worldEffectTimer = scene.time.addEvent({
callback: () => triggerWorldEffect(scene),
delay: WORLD_EFFECT.INTERVAL,
loop: true,
});
}
export function clearWorldEffects(scene) {
scene.worldEffectTimer?.remove(false);
scene.worldEffectTimer = null;
scene.worldEffectZones?.clear();
scene.clearMeteorCameraFocus?.(null, { restoreCamera: false });
scene.fighters?.forEach((fighter) => {
fighter.worldEffectSpeedMultiplier = 1;
clearFrostStun(fighter);
});
}
export function updateWorldEffectModifiers(scene) {
const frostZones = Array.from(scene.worldEffectZones ?? []).filter(
(zone) => zone.marker?.active,
);
scene.fighters.forEach((fighter) => {
const isSlowed =
fighter.active
&& !fighter.isDead
&& frostZones.some((zone) => containsFighter(zone, fighter));
fighter.worldEffectSpeedMultiplier = isSlowed
? WORLD_EFFECT.FROST_SPEED_MULTIPLIER
: 1;
});
}
function triggerWorldEffect(scene) {
if (!isLiveMatch(scene)) {
return;
}
const livingFighters = scene.fighters.filter(
(fighter) => fighter.active && !fighter.isDead,
);
if (livingFighters.length === 0) {
return;
}
const target = livingFighters[Phaser.Math.Between(0, livingFighters.length - 1)];
const zone = createEffectZone(target);
if (Phaser.Math.Between(0, 1) === 0) {
spawnMeteor(scene, zone);
return;
}
spawnFrostZone(scene, zone);
}
function spawnMeteor(scene, zone) {
const marker = createZoneMarker(scene, zone, METEOR_ZONE_COLOR);
scene.beginMeteorCameraFocus?.(zone);
dropWorldEffectSprite(scene, zone, {
effectKey: METEOR_EFFECT_KEY,
onCancel: () => {
scene.clearMeteorCameraFocus?.(zone);
disposeCombatObject(scene, marker);
},
onImpact: () => {
scene.tweens.killTweensOf(marker);
marker.setAlpha(1);
scene.cameras.main.shake(150, 0.004);
resolveImpactDamage(scene, zone, WORLD_EFFECT.METEOR_DAMAGE);
},
onAnimationComplete: () => {
scene.clearMeteorCameraFocus?.(zone);
disposeCombatObject(scene, marker);
},
});
}
function spawnFrostZone(scene, zone) {
const marker = createZoneMarker(scene, zone, FROST_ZONE_COLOR);
scene.beginMeteorCameraFocus?.(zone);
dropWorldEffectSprite(scene, zone, {
effectKey: FROST_EFFECT_KEY,
onCancel: () => {
scene.clearMeteorCameraFocus?.(zone);
disposeCombatObject(scene, marker);
},
onImpact: () => {
resolveImpactDamage(scene, zone, WORLD_EFFECT.FROST_DAMAGE, (fighter) => {
applyFrostStun(scene, fighter);
});
if (!scene.matchOver) {
activateFrostZone(scene, zone, marker);
}
},
onAnimationComplete: () => scene.clearMeteorCameraFocus?.(zone),
});
}
function dropWorldEffectSprite(
scene,
zone,
{ effectKey = METEOR_EFFECT_KEY, onCancel, onImpact, onAnimationComplete } = {},
) {
const matchId = scene.matchId;
const trajectory = createFallTrajectory(zone);
const sprite = scene.add
.sprite(trajectory.startX, trajectory.startY, effectKey, 0)
.setDepth(3)
.setScale(WORLD_EFFECT.VISUAL_SCALE)
.setFlipX(trajectory.flipX)
.setAngle(trajectory.angle)
.setAlpha(0.9);
sprite.cleanup = () => {
scene.tweens.killTweensOf(sprite);
};
trackCombatObject(scene, sprite);
sprite.once(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
onAnimationComplete?.();
disposeCombatObject(scene, sprite);
});
scene.tweens.add({
targets: sprite,
x: zone.centerX,
y: zone.centerY,
alpha: 1,
duration: WORLD_EFFECT.FALL_DURATION,
ease: "Cubic.In",
onComplete: () => {
if (!sprite.active || !isLiveMatch(scene, matchId)) {
onCancel?.();
disposeCombatObject(scene, sprite);
return;
}
sprite.play(worldEffectAnimationKey(effectKey));
onImpact?.();
},
});
}
function worldEffectAnimationKey(effectKey) {
return `${effectKey}-anim`;
}
function createFallTrajectory(zone) {
const distance = ARENA.TILE_SIZE * WORLD_EFFECT.FALL_TRAVEL_TILES;
const isLeftHalf = zone.centerX < ARENA.SIZE / 2;
const travelDirection = isLeftHalf ? 1 : -1;
return {
angle: travelDirection * FALL_ANGLE_DEGREES,
flipX: travelDirection < 0,
startX: zone.centerX - travelDirection * distance,
startY: zone.centerY - distance,
};
}
function createEffectZone(target) {
const size = ARENA.TILE_SIZE * WORLD_EFFECT.AREA_TILES;
const centerX = target.body?.center.x ?? target.x;
const centerY = target.body?.center.y ?? target.y;
return {
bounds: new Phaser.Geom.Rectangle(centerX - size / 2, centerY - size / 2, size, size),
centerX,
centerY,
marker: null,
};
}
function createZoneMarker(scene, zone, color) {
const marker = scene.add.graphics().setDepth(1.5);
const { x, y, width, height } = zone.bounds;
marker.fillStyle(color, 0.13);
marker.fillRect(x, y, width, height);
marker.lineStyle(3, color, 0.82);
marker.strokeRect(x, y, width, height);
marker.lineStyle(1, color, 0.34);
for (let index = 1; index < WORLD_EFFECT.AREA_TILES; index += 1) {
const offset = index * ARENA.TILE_SIZE;
marker.lineBetween(x + offset, y, x + offset, y + height);
marker.lineBetween(x, y + offset, x + width, y + offset);
}
marker.cleanup = () => {
scene.tweens.killTweensOf(marker);
};
trackCombatObject(scene, marker);
scene.tweens.add({
targets: marker,
alpha: { from: 0.46, to: 0.9 },
duration: 360,
ease: "Sine.InOut",
yoyo: true,
repeat: -1,
});
return marker;
}
function resolveImpactDamage(scene, zone, damage, onSurvivor) {
let deathCount = 0;
scene.fighters
.filter((fighter) => fighter.active && !fighter.isDead && containsFighter(zone, fighter))
.forEach((fighter) => {
if (applyWorldEffectDamage(scene, fighter, damage)) {
deathCount += 1;
return;
}
onSurvivor?.(fighter);
});
if (deathCount > 0) {
scene.updateScoreboard?.();
scene.finishMatch?.();
}
}
function applyFrostStun(scene, fighter) {
if (!fighter?.active || fighter.isDead) {
return;
}
fighter.frostStunTimer?.remove(false);
fighter.isFrostStunned = true;
fighter.body?.setVelocity(0, 0);
fighter.setTint(WORLD_EFFECT.FROST_STUN_TINT);
fighter.teamMarker?.setTint(WORLD_EFFECT.FROST_STUN_TINT).setAlpha(1);
fighter.frostStunTimer = scene.time.delayedCall(WORLD_EFFECT.FROST_STUN_DURATION, () => {
clearFrostStun(fighter);
});
}
function clearFrostStun(fighter) {
fighter.frostStunTimer?.remove(false);
fighter.frostStunTimer = null;
fighter.isFrostStunned = false;
if (fighter.active) {
fighter.clearTint();
}
if (fighter.teamMarker?.active) {
fighter.teamMarker.setTint(fighter.teamColor).setAlpha(0.8);
}
}
function activateFrostZone(scene, zone, marker) {
if (!marker.active) {
return;
}
zone.marker = marker;
scene.worldEffectZones ??= new Set();
scene.worldEffectZones.add(zone);
scene.tweens.killTweensOf(marker);
scene.tweens.add({
targets: marker,
alpha: { from: 0.42, to: 0.72 },
duration: 680,
ease: "Sine.InOut",
yoyo: true,
repeat: -1,
});
const previousCleanup = marker.cleanup;
const expiryTimer = scene.time.delayedCall(WORLD_EFFECT.FROST_DURATION, () => {
disposeCombatObject(scene, marker);
});
marker.cleanup = () => {
expiryTimer.remove(false);
scene.worldEffectZones?.delete(zone);
previousCleanup?.();
};
}
function containsFighter(zone, fighter) {
const x = fighter.body?.center.x ?? fighter.x;
const y = fighter.body?.center.y ?? fighter.y;
return Phaser.Geom.Rectangle.Contains(zone.bounds, x, y);
}
function isLiveMatch(scene, matchId = scene.matchId) {
return !scene.matchOver && !scene.presentationMode && scene.matchId === matchId;
}

View File

@ -1,12 +1,7 @@
import {
FIGHTER_ANIMATION_OPTIONS,
FIGHTER_FRAME_HEIGHT,
FIGHTER_FRAME_WIDTH,
KILL_HEAL_EFFECT_FRAME_RATE,
KILL_HEAL_EFFECT_FRAMES,
SELECTED_FIGHTER_OUTLINE_ALPHA,
SELECTED_FIGHTER_OUTLINE_GAP,
SELECTED_FIGHTER_OUTLINE_WIDTH,
FIGHTER,
COMBAT,
UI,
} from "../../constants.js";
const SOURCE_ALPHA_THRESHOLD = 8;
@ -52,8 +47,8 @@ export function healEffectAnimationKey() {
export function preloadFighterSheets(scene, skins) {
scene.load.spritesheet(healEffectKey(), HEAL_EFFECT_PATH, {
frameWidth: FIGHTER_FRAME_WIDTH,
frameHeight: FIGHTER_FRAME_HEIGHT,
frameWidth: FIGHTER.FRAME_WIDTH,
frameHeight: FIGHTER.FRAME_HEIGHT,
});
skins.forEach((skin) => {
@ -61,7 +56,7 @@ export function preloadFighterSheets(scene, skins) {
scene.load.spritesheet(
fighterSheetKey(skin, action),
`${skin.assetRoot}/${animation.file}`,
{ frameWidth: FIGHTER_FRAME_WIDTH, frameHeight: FIGHTER_FRAME_HEIGHT },
{ frameWidth: FIGHTER.FRAME_WIDTH, frameHeight: FIGHTER.FRAME_HEIGHT },
);
});
@ -75,7 +70,7 @@ export function createFighterAnimations(scene, skins) {
const key = fighterAnimationKey(skin, action);
if (!scene.anims.exists(key)) {
const { frameRate, repeat } = FIGHTER_ANIMATION_OPTIONS[action];
const { frameRate, repeat } = FIGHTER.ANIMATION_OPTIONS[action];
scene.anims.create({
key,
@ -109,7 +104,7 @@ function preloadCombatAssets(scene, skin) {
scene.load.spritesheet(
fighterAttackEffectKey(skin),
`${skin.assetRoot}/${attackEffect.file}`,
{ frameWidth: FIGHTER_FRAME_WIDTH, frameHeight: FIGHTER_FRAME_HEIGHT },
{ frameWidth: FIGHTER.FRAME_WIDTH, frameHeight: FIGHTER.FRAME_HEIGHT },
);
}
}
@ -147,9 +142,9 @@ function createHealEffectAnimation(scene) {
key: healEffectAnimationKey(),
frames: scene.anims.generateFrameNumbers(healEffectKey(), {
start: 0,
end: KILL_HEAL_EFFECT_FRAMES - 1,
end: COMBAT.KILL_HEAL_EFFECT_FRAMES - 1,
}),
frameRate: KILL_HEAL_EFFECT_FRAME_RATE,
frameRate: COMBAT.KILL_HEAL_EFFECT_FRAME_RATE,
repeat: 0,
});
}
@ -168,8 +163,8 @@ function createFighterOutlineSheet(scene, skin, action, frameCount) {
return;
}
const sheetWidth = FIGHTER_FRAME_WIDTH * frameCount;
const sheetHeight = FIGHTER_FRAME_HEIGHT;
const sheetWidth = FIGHTER.FRAME_WIDTH * frameCount;
const sheetHeight = FIGHTER.FRAME_HEIGHT;
const sourceCanvas = document.createElement("canvas");
sourceCanvas.width = sheetWidth;
sourceCanvas.height = sheetHeight;
@ -187,13 +182,13 @@ function createFighterOutlineSheet(scene, skin, action, frameCount) {
const outlineData = outlineImage.data;
const gapMask = new Uint8Array(sheetWidth * sheetHeight);
const outerMask = new Uint8Array(sheetWidth * sheetHeight);
const outlineAlpha = Math.round(SELECTED_FIGHTER_OUTLINE_ALPHA * 255);
const outlineAlpha = Math.round(UI.SELECTED_FIGHTER_OUTLINE_ALPHA * 255);
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
const frameLeft = frameIndex * FIGHTER_FRAME_WIDTH;
const frameLeft = frameIndex * FIGHTER.FRAME_WIDTH;
for (let y = 0; y < FIGHTER_FRAME_HEIGHT; y += 1) {
for (let x = 0; x < FIGHTER_FRAME_WIDTH; x += 1) {
for (let y = 0; y < FIGHTER.FRAME_HEIGHT; y += 1) {
for (let x = 0; x < FIGHTER.FRAME_WIDTH; x += 1) {
const sourceIndex = ((y * sheetWidth) + frameLeft + x) * 4;
if (sourceData[sourceIndex + 3] <= SOURCE_ALPHA_THRESHOLD) {
@ -208,13 +203,13 @@ function createFighterOutlineSheet(scene, skin, action, frameCount) {
paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha);
outlineContext.putImageData(outlineImage, 0, 0);
scene.textures.addSpriteSheet(key, outlineCanvas, {
frameWidth: FIGHTER_FRAME_WIDTH,
frameHeight: FIGHTER_FRAME_HEIGHT,
frameWidth: FIGHTER.FRAME_WIDTH,
frameHeight: FIGHTER.FRAME_HEIGHT,
});
}
function markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, sourceX, sourceY) {
const outerRadius = SELECTED_FIGHTER_OUTLINE_GAP + SELECTED_FIGHTER_OUTLINE_WIDTH;
const outerRadius = UI.SELECTED_FIGHTER_OUTLINE_GAP + UI.SELECTED_FIGHTER_OUTLINE_WIDTH;
for (
let offsetY = -outerRadius;
@ -223,7 +218,7 @@ function markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, sourceX, so
) {
const targetY = sourceY + offsetY;
if (targetY < 0 || targetY >= FIGHTER_FRAME_HEIGHT) {
if (targetY < 0 || targetY >= FIGHTER.FRAME_HEIGHT) {
continue;
}
@ -234,7 +229,7 @@ function markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, sourceX, so
) {
const targetX = sourceX + offsetX;
if (targetX < 0 || targetX >= FIGHTER_FRAME_WIDTH) {
if (targetX < 0 || targetX >= FIGHTER.FRAME_WIDTH) {
continue;
}
@ -243,7 +238,7 @@ function markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, sourceX, so
outerMask[maskIndex] = 1;
if (distance <= SELECTED_FIGHTER_OUTLINE_GAP) {
if (distance <= UI.SELECTED_FIGHTER_OUTLINE_GAP) {
gapMask[maskIndex] = 1;
}
}

View File

@ -1,20 +1,13 @@
import Phaser from "phaser";
import {
FIGHTER_FRAME_HEIGHT,
FIGHTER_FRAME_WIDTH,
FIGHTER_DEPTH,
FIGHTER_HITBOX_HEIGHT,
FIGHTER_HITBOX_OFFSET_X,
FIGHTER_HITBOX_OFFSET_Y,
FIGHTER_HITBOX_WIDTH,
FIGHTER_MAX_HP,
FIGHTER_SCALE,
FIGHTER,
} from "../../constants.js";
import {
fighterAnimationKey,
fighterOutlineSheetKeyFromSheetKey,
fighterSheetKey,
} from "./fighterAssets.js";
import { getFighterStats } from "./fighterStats.js";
const NAME_LABEL_BOTTOM_GAP = 14;
@ -25,26 +18,27 @@ export function createFighter(
const fighter = scene.physics.add.sprite(x, y, fighterSheetKey(skin, "idle"), 0);
const teamColor = Phaser.Display.Color.HexStringToColor(team.color).color;
const displayName = name || team.label;
const resolvedMaxHp = Math.max(1, Math.round(maxHp ?? skin.stats?.maxHp ?? FIGHTER_MAX_HP));
const combatStats = getFighterStats(skin);
const resolvedMaxHp = Math.max(1, Math.round(maxHp ?? combatStats.maxHp));
const resolvedHp = Math.min(
resolvedMaxHp,
Math.max(1, Math.round(hp ?? resolvedMaxHp)),
);
fighter.setScale(FIGHTER_SCALE);
fighter.setScale(FIGHTER.SCALE);
fighter.setName(displayName);
fighter.setDepth(FIGHTER_DEPTH);
fighter.setDepth(FIGHTER.DEPTH);
fighter.setAlpha(1);
fighter.setCollideWorldBounds(true);
fighter.setFlipX(faceLeft);
fighter.body.setSize(FIGHTER_HITBOX_WIDTH, FIGHTER_HITBOX_HEIGHT);
fighter.body.setOffset(FIGHTER_HITBOX_OFFSET_X, FIGHTER_HITBOX_OFFSET_Y);
fighter.body.setSize(FIGHTER.HITBOX_WIDTH, FIGHTER.HITBOX_HEIGHT);
fighter.body.setOffset(FIGHTER.HITBOX_OFFSET_X, FIGHTER.HITBOX_OFFSET_Y);
fighter.setInteractive(
new Phaser.Geom.Rectangle(
FIGHTER_HITBOX_OFFSET_X,
FIGHTER_HITBOX_OFFSET_Y,
FIGHTER_HITBOX_WIDTH,
FIGHTER_HITBOX_HEIGHT,
FIGHTER.HITBOX_OFFSET_X,
FIGHTER.HITBOX_OFFSET_Y,
FIGHTER.HITBOX_WIDTH,
FIGHTER.HITBOX_HEIGHT,
),
Phaser.Geom.Rectangle.Contains,
);
@ -52,7 +46,7 @@ export function createFighter(
fighter.teamMarker = scene.add
.sprite(x, y, fighterOutlineSheetKeyFromSheetKey(fighterSheetKey(skin, "idle")), 0)
.setDisplaySize(FIGHTER_FRAME_WIDTH * FIGHTER_SCALE, FIGHTER_FRAME_HEIGHT * FIGHTER_SCALE)
.setDisplaySize(FIGHTER.FRAME_WIDTH * FIGHTER.SCALE, FIGHTER.FRAME_HEIGHT * FIGHTER.SCALE)
.setTint(teamColor)
.setAlpha(0.8)
.setDepth(1.9)
@ -78,15 +72,20 @@ export function createFighter(
.setDepth(5);
fighter.skin = skin;
fighter.combatStats = combatStats;
fighter.fighterName = displayName;
fighter.team = team;
fighter.teamColor = teamColor;
fighter.teamIndex = teamIndex;
fighter.baseScaleX = FIGHTER_SCALE;
fighter.baseScaleY = FIGHTER_SCALE;
fighter.baseScaleX = FIGHTER.SCALE;
fighter.baseScaleY = FIGHTER.SCALE;
fighter.canSplitOnDeath = canSplitOnDeath;
fighter.isSelected = false;
fighter.killCount = 0;
fighter.killRewardMultiplier = 1;
fighter.worldEffectSpeedMultiplier = 1;
fighter.isFrostStunned = false;
fighter.frostStunTimer = null;
fighter.maxHp = resolvedMaxHp;
fighter.hp = resolvedHp;
fighter.nextAttackAt = 0;
@ -122,7 +121,7 @@ export function syncFighterHud(fighter) {
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 hitbox = fighter.body;
const nameX = hitbox.x + hitbox.width / 2;
@ -131,7 +130,7 @@ export function syncFighterHud(fighter) {
fighter.nameLabel.setPosition(nameX, nameY);
fighter.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
fighter.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? FIGHTER_MAX_HP)));
fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? 1)));
}
function syncTeamMarker(fighter) {

View File

@ -239,7 +239,7 @@ export const fighterManifest = [
maxHp: 1,
},
traits: {
spawnMultiplier: 10,
spawnMultiplier: 3,
splitOnDeath: {
chance: 0.5,
count: 2,

View File

@ -0,0 +1,52 @@
import { FIGHTER } from "../../constants.js";
export const FIGHTER_TYPES = {
MAGIC: "magic",
MELEE: "melee",
RANGED: "ranged",
};
export function getFighterType(skin) {
const configuredType = skin.combat?.fighterType;
if (configuredType && FIGHTER.TYPE_STATS[configuredType]) {
return configuredType;
}
switch (skin.combat?.type) {
case "projectile":
return FIGHTER_TYPES.RANGED;
case "instant-spell":
return FIGHTER_TYPES.MAGIC;
default:
return FIGHTER_TYPES.MELEE;
}
}
export function getFighterStats(skin) {
const defaults = FIGHTER.TYPE_STATS[getFighterType(skin)];
const stats = skin.stats ?? {};
const combat = skin.combat ?? {};
return {
maxHp: stats.maxHp ?? defaults.maxHp,
moveSpeed: stats.moveSpeed ?? defaults.moveSpeed,
attackRange: combat.range ?? stats.attackRange ?? defaults.attackRange,
attackCooldown: combat.cooldown ?? stats.attackCooldown ?? defaults.attackCooldown,
damageMin: combat.damageMin ?? stats.damageMin ?? defaults.damageMin,
damageMax: combat.damageMax ?? stats.damageMax ?? defaults.damageMax,
criticalChance:
combat.criticalChance ?? stats.criticalChance ?? defaults.criticalChance,
windupDelay: combat.windupDelay ?? stats.windupDelay ?? defaults.windupDelay,
projectileSpeed:
combat.projectile?.speed ??
stats.projectileSpeed ??
defaults.projectileSpeed ??
FIGHTER.TYPE_STATS.ranged.projectileSpeed,
effectHitDelay:
combat.attackEffect?.hitDelay ??
stats.effectHitDelay ??
defaults.effectHitDelay ??
FIGHTER.TYPE_STATS.magic.effectHitDelay,
};
}

View File

@ -1,5 +1,5 @@
import Phaser from "phaser";
import { ARENA_SIZE } from "../../constants.js";
import { ARENA } from "../../constants.js";
const SPAWN_CLUSTER_MARGIN = 48;
const SPAWN_CLUSTER_STEP = 28;
@ -44,7 +44,7 @@ export function clusterSpawnPosition(origin, index, count) {
}
export function clampInsideArena(value) {
return Phaser.Math.Clamp(value, SPAWN_CLUSTER_MARGIN, ARENA_SIZE - SPAWN_CLUSTER_MARGIN);
return Phaser.Math.Clamp(value, SPAWN_CLUSTER_MARGIN, ARENA.SIZE - SPAWN_CLUSTER_MARGIN);
}
export function syncTeamSizes(teams, fighterPlans) {

View File

@ -1,28 +1,28 @@
import {
ARENA_SIZE,
DEFAULT_SPAWN_PLACEMENT,
DEFAULT_TEAM_SIZE,
GRID_SIZE,
getTeamColor,
MAX_TEAM_SIZE,
SPAWN_PLACEMENTS,
TILE_SIZE,
} from "../../constants.js";
import { ARENA, SPAWN, TEAM } from "../../constants.js";
export function createMatchSetup(
names,
requestedTeamSize = DEFAULT_TEAM_SIZE,
requestedSpawnPlacement = DEFAULT_SPAWN_PLACEMENT,
requestedTeamSize = SPAWN.DEFAULT_TEAM_SIZE,
requestedSpawnPlacement = SPAWN.DEFAULT_PLACEMENT,
) {
const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE));
const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || SPAWN.DEFAULT_TEAM_SIZE));
const teams = names.map((name, index) => ({
color: getTeamColor(index, names.length),
color: TEAM.getColor(index, names.length),
id: `team-${index + 1}`,
label: name,
size: teamSize,
}));
const spawns = createSpawnPoints(names.length, teamSize, requestedSpawnPlacement);
const startingZones =
requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES
? createStartingZones(teams)
: [];
const spawns = createSpawnPoints(
names.length,
teamSize,
requestedSpawnPlacement,
startingZones,
);
const fighters = [];
names.forEach((name, teamIndex) => {
@ -39,6 +39,7 @@ export function createMatchSetup(
return {
fighters,
startingZones,
teams,
};
}
@ -56,16 +57,16 @@ function createTeams(playerCount, teamSize) {
const teamCount = Math.ceil(playerCount / teamSize);
return Array.from({ length: teamCount }, (_, index) => ({
color: getTeamColor(index, teamCount),
color: TEAM.getColor(index, teamCount),
id: `team-${index + 1}`,
label: `Team ${index + 1}`,
size: Math.min(teamSize, playerCount - index * teamSize),
}));
}
function createSpawnPoints(teamCount, teamSize, requestedSpawnPlacement) {
if (requestedSpawnPlacement === SPAWN_PLACEMENTS.STARTING_ZONES) {
return createStartingZoneSpawnPoints(teamCount, teamSize);
function createSpawnPoints(teamCount, teamSize, requestedSpawnPlacement, startingZones) {
if (requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES) {
return createStartingZoneSpawnPoints(startingZones, teamSize);
}
return createRandomSpawnPoints(teamCount * teamSize);
@ -75,38 +76,76 @@ function createRandomSpawnPoints(count) {
return createSpawnPointsFromSlots(createSpawnSlots(), count);
}
function createStartingZoneSpawnPoints(teamCount, teamSize) {
function createStartingZoneSpawnPoints(startingZones, teamSize) {
const fallbackSlots = createSpawnSlots();
const layout = shuffle(createStartingZoneLayout(teamCount));
return layout.flatMap((zone) => {
return startingZones.flatMap((zone) => {
const zoneSlots = createSpawnSlots(zone);
return createSpawnPointsFromSlots(zoneSlots.length > 0 ? zoneSlots : fallbackSlots, teamSize);
});
}
function createStartingZones(teams) {
const layout = shuffle(createStartingZoneLayout(teams.length));
return teams.map((team, index) => ({
...layout[index],
color: team.color,
teamId: team.id,
}));
}
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;
const zones = [];
let candidates = shuffle(createStartingZoneCandidates());
return Array.from({ length: teamCount }, (_, index) => {
const column = index % columnCount;
const row = Math.floor(index / columnCount);
while (zones.length < teamCount) {
if (candidates.length === 0) {
candidates = shuffle(createStartingZoneCandidates());
}
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),
};
});
const separateCandidateIndex = candidates.findIndex((candidate) =>
zones.every((zone) => !startingZonesOverlap(zone, candidate)),
);
const selectedIndex = separateCandidateIndex >= 0 ? separateCandidateIndex : 0;
zones.push(...candidates.splice(selectedIndex, 1));
}
return zones;
}
function createStartingZoneCandidates() {
const zones = [];
for (
let anchorRow = 1 + SPAWN.STARTING_ZONE_RADIUS;
anchorRow < ARENA.GRID_SIZE - 1 - SPAWN.STARTING_ZONE_RADIUS;
anchorRow += 1
) {
for (
let anchorColumn = SPAWN.STARTING_ZONE_RADIUS;
anchorColumn < ARENA.GRID_SIZE - SPAWN.STARTING_ZONE_RADIUS;
anchorColumn += 1
) {
zones.push({
anchorColumn,
anchorRow,
columnEnd: anchorColumn + SPAWN.STARTING_ZONE_RADIUS + 1,
columnStart: anchorColumn - SPAWN.STARTING_ZONE_RADIUS,
rowEnd: anchorRow + SPAWN.STARTING_ZONE_RADIUS + 1,
rowStart: anchorRow - SPAWN.STARTING_ZONE_RADIUS,
});
}
}
return zones;
}
function createSpawnSlots({
columnEnd = GRID_SIZE,
columnEnd = ARENA.GRID_SIZE,
columnStart = 0,
rowEnd = GRID_SIZE - 1,
rowEnd = ARENA.GRID_SIZE - 1,
rowStart = 1,
} = {}) {
const spawnSlots = [];
@ -114,8 +153,8 @@ function createSpawnSlots({
for (let row = rowStart; row < rowEnd; row += 1) {
for (let column = columnStart; column < columnEnd; column += 1) {
spawnSlots.push({
x: column * TILE_SIZE + TILE_SIZE / 2,
y: row * TILE_SIZE + TILE_SIZE / 2,
x: column * ARENA.TILE_SIZE + ARENA.TILE_SIZE / 2,
y: row * ARENA.TILE_SIZE + ARENA.TILE_SIZE / 2,
});
}
}
@ -134,8 +173,8 @@ function createSpawnPointsFromSlots(spawnSlots, count) {
points.push({
faceLeft: Math.random() >= 0.5,
x: clampInsideArena(slot.x + spawnJitter(), TILE_SIZE / 2),
y: clampInsideArena(slot.y + spawnJitter(), TILE_SIZE),
x: clampInsideArena(slot.x + spawnJitter(), ARENA.TILE_SIZE / 2),
y: clampInsideArena(slot.y + spawnJitter(), ARENA.TILE_SIZE),
});
});
}
@ -143,19 +182,20 @@ function createSpawnPointsFromSlots(spawnSlots, count) {
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 startingZonesOverlap(left, right) {
return (
left.columnStart < right.columnEnd &&
left.columnEnd > right.columnStart &&
left.rowStart < right.rowEnd &&
left.rowEnd > right.rowStart
);
}
function resolveTeamSize(playerCount, requestedTeamSize) {
const teamSize = clamp(
Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE),
Math.round(Number(requestedTeamSize) || SPAWN.DEFAULT_TEAM_SIZE),
1,
MAX_TEAM_SIZE,
SPAWN.MAX_TEAM_SIZE,
);
if (playerCount <= teamSize) {
@ -166,11 +206,11 @@ function resolveTeamSize(playerCount, requestedTeamSize) {
}
function spawnJitter() {
return (Math.random() - 0.5) * TILE_SIZE * 0.36;
return (Math.random() - 0.5) * ARENA.TILE_SIZE * 0.36;
}
function clampInsideArena(value, margin) {
return clamp(value, margin, ARENA_SIZE - margin);
return clamp(value, margin, ARENA.SIZE - margin);
}
function clamp(value, minimum, maximum) {

View File

@ -1,9 +1,8 @@
import Phaser from "phaser";
import { ArenaScene } from "./game/arena/ArenaScene.js";
import {
ARENA_SIZE,
PRESENTATION_TEAM_COUNT,
PRESENTATION_TEAM_SIZE,
ARENA,
SPAWN,
} from "./constants.js";
import { createMatchForm } from "./ui/matchForm.js";
import { createAboutDialog } from "./ui/aboutDialog.js";
@ -72,8 +71,8 @@ function startConfiguredMatch(matchConfig) {
function getPresentationMatchConfig() {
return {
names: Array.from({ length: PRESENTATION_TEAM_COUNT }, (_, index) => `Player ${index + 1}`),
teamSize: PRESENTATION_TEAM_SIZE,
names: Array.from({ length: SPAWN.PRESENTATION_TEAM_COUNT }, (_, index) => `Player ${index + 1}`),
teamSize: SPAWN.PRESENTATION_TEAM_SIZE,
};
}
@ -176,8 +175,8 @@ const arenaScene = new ArenaScene({
const game = new Phaser.Game({
type: Phaser.AUTO,
parent: "game",
width: ARENA_SIZE,
height: ARENA_SIZE,
width: ARENA.SIZE,
height: ARENA.SIZE,
pixelArt: true,
backgroundColor: "#282819",
physics: {

File diff suppressed because it is too large Load Diff

158
src/styles/animations.css Normal file
View File

@ -0,0 +1,158 @@
@keyframes intro-rise {
from {
opacity: 0;
transform: translateY(22px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes preview-attack {
to {
background-position-x: var(--sprite-end);
}
}
@keyframes preview-breathe {
0%,
100% {
margin-top: 0;
}
50% {
margin-top: -8px;
}
}
@keyframes preview-strike {
0%,
58%,
100% {
opacity: 0;
}
64%,
76% {
opacity: 0.86;
}
}
@keyframes status-marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@keyframes kill-log-entry {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes banner-in {
from {
opacity: 0;
transform: translateY(18px) scale(0.78);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes victory-banner-sheen {
from {
opacity: 0;
transform: translateX(0) skewX(-18deg);
}
18% {
opacity: 1;
}
to {
opacity: 0;
transform: translateX(560%) skewX(-18deg);
}
}
@keyframes victory-confetti-burst {
0% {
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--confetti-tilt)) scale(0.3);
}
12% {
opacity: 1;
}
74% {
opacity: 1;
}
100% {
opacity: 0;
transform:
translate(calc(-50% + var(--confetti-x)), calc(-50% + var(--confetti-y)))
rotate(calc(var(--confetti-tilt) + var(--confetti-spin)))
scale(1);
}
}
@keyframes victory-glow {
from {
opacity: 0;
transform: scale(0.58);
}
35% {
opacity: 1;
}
to {
opacity: 0.8;
transform: scale(1);
}
}
@keyframes victory-rays-in {
from {
transform: scale(0.56);
}
to {
transform: scale(1);
}
}
@keyframes victory-rays-turn {
to {
rotate: 360deg;
}
}
@keyframes victory-message-pulse {
from {
opacity: 0.72;
transform: scale(0.88);
}
58% {
transform: scale(1.05);
}
to {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.victory-banner,
.victory-banner::before,
.victory-banner-message,
.victory-celebration::before,
.victory-confetti-piece,
.victory-rays {
animation-duration: 1ms;
animation-iteration-count: 1;
}
}

129
src/styles/base.css Normal file
View File

@ -0,0 +1,129 @@
:root {
color-scheme: dark;
font-family:
Inter, Pretendard, "Noto Sans KR", system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
background: #080a07;
color: #fff5db;
}
* {
box-sizing: border-box;
}
html {
min-width: 320px;
min-height: 100%;
background: #080a07;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
overflow: hidden;
}
button,
input,
textarea {
font: inherit;
}
button {
border: 0;
cursor: pointer;
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 3px solid rgb(238 185 73 / 0.46);
outline-offset: 3px;
}
#app {
--arena-gap: 18px;
--score-band-height: 134px;
--score-panel-left: 14px;
--score-panel-width: 260px;
--score-rail-width: calc(var(--score-panel-left) + var(--score-panel-width));
--drawer-width: min(430px, 100vw);
--drawer-live-width: min(340px, calc(100vw - 48px));
position: relative;
min-height: 100vh;
overflow: hidden;
background:
linear-gradient(180deg, rgb(8 10 7 / 0.18), rgb(3 5 4 / 0.84)),
#080a07;
}
#app.match-live {
--drawer-width: var(--drawer-live-width);
}
.arena-shell {
position: fixed;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
background: #090b08;
}
.arena-shell::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
background:
radial-gradient(circle at 50% 50%, rgb(255 211 122 / 0.06), transparent 42%),
linear-gradient(90deg, rgb(3 5 4 / 0.48), rgb(3 5 4 / 0.08) 45%, rgb(3 5 4 / 0.48)),
linear-gradient(180deg, rgb(3 5 4 / 0.08), rgb(3 5 4 / 0.5));
pointer-events: none;
transition:
background 520ms ease,
opacity 520ms ease;
}
#app.match-live .arena-shell::before {
opacity: 0.24;
}
#app.match-live .arena-shell {
place-items: center;
}
#game {
position: relative;
z-index: 0;
width: max(100vw, 100vh);
height: max(100vw, 100vh);
overflow: hidden;
opacity: 0.68;
filter: saturate(1) contrast(1.08) brightness(1.08);
transform: scale(1.04);
transform-origin: center;
transition:
width 620ms cubic-bezier(0.2, 0.8, 0.2, 1),
height 620ms cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 520ms ease,
filter 520ms ease,
transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#app.match-live #game {
width: min(100vw, 100vh);
height: min(100vw, 100vh);
margin-left: 0;
opacity: 1;
filter: none;
transform: scale(1);
}
#game canvas {
display: block;
width: 100% !important;
height: 100% !important;
image-rendering: pixelated;
}

601
src/styles/game-ui.css Normal file
View File

@ -0,0 +1,601 @@
.scoreboard {
position: fixed;
top: clamp(14px, 3vw, 28px);
left: var(--score-panel-left);
z-index: 3;
display: flex;
justify-content: flex-start;
width: var(--score-panel-width);
max-height: calc(100vh - 420px);
min-height: 64px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgb(238 185 73 / 0.3) transparent;
padding: 8px;
border: 1px solid rgb(238 185 73 / 0.18);
border-radius: 8px;
background: rgb(4 6 4 / 0.5);
opacity: 0;
pointer-events: none;
transform: translateY(-18px);
transition:
opacity 420ms ease,
transform 420ms ease;
backdrop-filter: blur(10px);
}
.scoreboard::-webkit-scrollbar {
width: 4px;
}
.scoreboard::-webkit-scrollbar-track {
background: transparent;
}
.scoreboard::-webkit-scrollbar-thumb {
border-radius: 10px;
background: rgb(238 185 73 / 0.3);
}
#app.match-live .scoreboard {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.score-side {
display: grid;
grid-template-columns: repeat(2, 114px);
gap: 6px;
width: 100%;
}
.score-side.right {
display: none;
}
.team-score {
display: grid;
grid-template-rows: 1fr 1px auto;
gap: 6px;
width: 114px;
min-height: 72px;
overflow: hidden;
border-radius: 6px;
padding: 8px 9px;
color: #fff;
font-size: 0.8rem;
font-weight: 900;
text-align: left;
text-shadow: 1px 1px 2px #000;
transition:
filter 160ms ease,
transform 160ms ease;
}
.team-score:hover {
filter: brightness(1.16);
transform: translateY(-1px);
}
.team-score.is-focused {
box-shadow:
inset 0 0 0 2px rgb(255 244 209 / 0.92),
0 0 18px rgb(227 178 79 / 0.26);
}
.team-score:disabled {
cursor: default;
filter: grayscale(0.6) brightness(0.68);
}
.team-score:disabled:hover {
transform: none;
}
.team-score-name {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
line-height: 1.15;
}
.team-score-rule {
width: 100%;
background: var(--team-color);
opacity: 0.9;
}
.team-score-count {
justify-self: end;
color: #fff2c8;
font-size: 0.86rem;
}
.battle-notice {
position: fixed;
top: clamp(12px, 2vw, 20px);
left: 50%;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
width: min(420px, 72vmin, calc(100vw - 64px));
min-height: 38px;
border: 1px solid rgb(238 185 73 / 0.26);
border-radius: 8px;
padding: 8px 14px;
background: rgb(8 10 7 / 0.68);
color: #ffe8b4;
font-size: 0.8rem;
font-weight: 900;
line-height: 1.35;
text-align: center;
text-shadow: 1px 1px 2px #000;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -10px);
transition:
opacity 260ms ease,
transform 260ms ease;
backdrop-filter: blur(10px);
}
#app.match-live .battle-notice.is-visible {
opacity: 1;
transform: translate(-50%, 0);
}
@media (min-width: 961px) {
#app.match-live .battle-notice {
right: auto;
left: 50%;
width: min(420px, 72vmin, calc(100vw - var(--drawer-width) - var(--score-rail-width) - 56px));
transform: translate(-50%, -10px);
}
#app.match-live .battle-notice.is-visible {
transform: translate(-50%, 0);
}
#app.match-live.drawer-collapsed .battle-notice {
right: auto;
width: min(420px, 72vmin, calc(100vw - 64px));
}
}
.kill-log {
position: fixed;
bottom: clamp(14px, 3vw, 26px);
left: var(--score-panel-left);
z-index: 4;
width: min(370px, calc(100vw - 32px));
max-height: min(34vh, 292px);
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgb(238 185 73 / 0.3) transparent;
border: 1px solid rgb(238 185 73 / 0.2);
border-radius: 8px;
padding: 10px;
background: rgb(4 6 4 / 0.58);
opacity: 0;
pointer-events: none;
transform: translateY(16px);
transition:
opacity 260ms ease,
transform 260ms ease;
backdrop-filter: blur(10px);
}
.kill-log::-webkit-scrollbar {
width: 4px;
}
.kill-log::-webkit-scrollbar-track {
background: transparent;
}
.kill-log::-webkit-scrollbar-thumb {
border-radius: 10px;
background: rgb(238 185 73 / 0.3);
}
#app.match-live .kill-log.has-entries {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.kill-log-list {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 0;
margin: 0;
padding: 0;
list-style: none;
}
.kill-log-item {
display: grid;
grid-template-columns: minmax(0, 1fr) 54px minmax(0, 1fr);
align-items: center;
gap: 8px;
min-height: 54px;
border: 1px solid rgb(255 244 209 / 0.12);
border-radius: 6px;
padding: 7px 9px;
background: rgb(8 10 7 / 0.74);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06);
animation: kill-log-entry 180ms ease both;
}
.kill-log-fighter {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.kill-log-fighter.killer {
border-left: 3px solid var(--killer-color);
padding-left: 6px;
}
.kill-log-fighter.victim {
flex-direction: row-reverse;
border-right: 3px solid var(--victim-color);
padding-right: 6px;
justify-content: end;
text-align: right;
}
.kill-log-avatar {
position: relative;
flex: 0 0 auto;
width: 36px;
height: 36px;
border: 1px solid rgb(255 244 209 / 0.16);
border-radius: 6px;
background-color: rgb(255 246 216 / 0.08);
background-position: -24px -16px;
background-repeat: no-repeat;
background-size: auto 86px;
image-rendering: pixelated;
box-shadow: inset 0 -10px 18px rgb(0 0 0 / 0.22);
}
.kill-log-fighter.victim .kill-log-avatar::before,
.kill-log-fighter.victim .kill-log-avatar::after {
content: "";
position: absolute;
top: 7px;
right: 1px;
width: 14px;
height: 2px;
border: 1px solid rgb(255 216 212 / 0.22);
border-radius: 999px;
background: #f24a42;
box-shadow:
0 0 0 1px rgb(48 4 3 / 0.7),
0 0 5px rgb(227 54 46 / 0.6);
transform-origin: center;
}
.kill-log-fighter.victim .kill-log-avatar::before {
transform: rotate(45deg);
}
.kill-log-fighter.victim .kill-log-avatar::after {
transform: rotate(-45deg);
}
.kill-log-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.kill-log-team,
.kill-log-member {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kill-log-team {
min-width: 0;
color: #fff7df;
font-size: 0.78rem;
font-weight: 900;
text-shadow: 1px 1px 2px #000;
}
.kill-log-member {
flex: 0 0 auto;
color: #ead8ad;
font-size: 0.72rem;
font-weight: 800;
}
.kill-log-action {
display: grid;
justify-items: center;
gap: 2px;
min-width: 0;
}
.kill-log-action-text {
color: #ffdc93;
font-size: 0.68rem;
font-weight: 950;
line-height: 1;
}
.kill-log-weapon {
position: relative;
display: block;
width: 28px;
height: 28px;
place-self: center;
border: 1px solid rgb(238 185 73 / 0.28);
border-radius: 999px;
background: rgb(255 246 216 / 0.08);
box-shadow: 0 0 16px rgb(227 89 59 / 0.16);
}
.kill-log-weapon::before,
.kill-log-weapon::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 18px;
height: 3px;
border-radius: 999px;
background: linear-gradient(90deg, #ffe8b4 0 70%, #b93c2f 70% 100%);
box-shadow: 0 0 8px rgb(255 226 166 / 0.3);
transform-origin: center;
}
.kill-log-weapon::before {
transform: translate(-50%, -50%) rotate(42deg);
}
.kill-log-weapon::after {
transform: translate(-50%, -50%) rotate(-42deg);
}
.victory-celebration {
position: fixed;
z-index: 9;
display: grid;
overflow: hidden;
place-items: center;
inset: 0;
background: rgb(4 6 4 / 0.2);
isolation: isolate;
opacity: 1;
pointer-events: auto;
transform: scale(1);
transition:
opacity 220ms ease,
transform 220ms ease;
}
.victory-celebration.is-leaving {
opacity: 0;
transform: scale(0.98);
}
.victory-celebration::before {
content: "";
position: absolute;
z-index: -1;
width: min(122vmin, 1240px);
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(circle, rgb(255 233 166 / 0.18) 0 18%, rgb(227 178 79 / 0.12) 31%, transparent 66%);
animation: victory-glow 1.8s ease-out both;
}
.victory-celebration.is-draw::before {
background:
radial-gradient(circle, rgb(255 247 223 / 0.16) 0 18%, rgb(227 178 79 / 0.1) 31%, transparent 62%);
}
.victory-rays {
position: absolute;
z-index: 0;
width: min(112vmin, 1120px);
aspect-ratio: 1;
border-radius: 50%;
background: repeating-conic-gradient(
from -4deg,
rgb(255 233 166 / 0.18) 0 8deg,
transparent 8deg 18deg
);
opacity: 0.54;
mask-image: radial-gradient(circle, #000 0 18%, transparent 66%);
animation: victory-rays-in 1.1s ease-out both, victory-rays-turn 11s linear infinite;
}
.victory-celebration.is-draw .victory-rays {
opacity: 0.22;
}
.victory-confetti {
position: absolute;
z-index: 1;
inset: 0;
}
.victory-confetti-piece {
position: absolute;
top: 50%;
left: 50%;
display: block;
width: clamp(6px, 0.8vw, 11px);
height: clamp(10px, 1.2vw, 18px);
border-radius: 8px;
background: var(--confetti-color);
box-shadow: 0 0 12px rgb(255 230 166 / 0.22);
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--confetti-tilt)) scale(0.3);
animation: victory-confetti-burst var(--confetti-duration) cubic-bezier(0.15, 0.84, 0.35, 1) var(--confetti-delay) both;
}
.victory-confetti-piece:nth-child(3n) {
width: clamp(10px, 1vw, 15px);
height: clamp(6px, 0.72vw, 10px);
border-radius: 2px;
}
.victory-banner {
position: relative;
z-index: 2;
display: grid;
width: min(calc(100vw - 36px), 760px);
min-height: clamp(108px, 18vw, 170px);
overflow: hidden;
place-items: center;
border: 2px solid #f1c45d;
border-radius: 8px;
padding: clamp(1.25rem, 3.8vw, 2rem) clamp(1.3rem, 5.4vw, 3.4rem);
background:
linear-gradient(135deg, rgb(18 21 13 / 0.98), rgb(3 5 4 / 0.92)),
rgb(4 6 4 / 0.9);
color: #fff7df;
font-size: clamp(1.65rem, 5vw, 3rem);
font-weight: 950;
letter-spacing: 0;
line-height: 1.12;
text-align: center;
text-wrap: balance;
text-shadow:
0 2px 0 rgb(55 36 8 / 0.56),
0 0 24px rgb(255 226 153 / 0.28);
box-shadow:
0 0 0 1px rgb(255 237 187 / 0.2) inset,
0 0 42px rgb(227 178 79 / 0.44),
0 24px 90px rgb(0 0 0 / 0.58);
animation: banner-in 0.64s cubic-bezier(0.16, 0.9, 0.25, 1.2);
backdrop-filter: blur(6px);
}
.victory-banner::before {
content: "";
position: absolute;
inset: -40% auto -40% -36%;
width: 28%;
background: linear-gradient(90deg, transparent, rgb(255 248 223 / 0.6), transparent);
transform: skewX(-18deg);
animation: victory-banner-sheen 1s 0.28s ease-out both;
}
.victory-banner::after {
content: "";
position: absolute;
inset: 10px;
border: 1px solid rgb(255 225 151 / 0.24);
border-radius: 5px;
}
.victory-banner-message {
position: relative;
z-index: 1;
display: block;
max-width: 100%;
overflow-wrap: anywhere;
animation: victory-message-pulse 720ms 80ms ease-out both;
}
.victory-celebration.is-draw .victory-banner {
border-color: #d8c28d;
box-shadow:
0 0 0 1px rgb(255 237 187 / 0.14) inset,
0 0 28px rgb(227 178 79 / 0.24),
0 24px 90px rgb(0 0 0 / 0.52);
}
#app.match-paused .arena-shell::after {
content: "일시정지";
position: fixed;
top: 50%;
left: 50%;
z-index: 6;
border: 1px solid rgb(238 185 73 / 0.34);
border-radius: 8px;
padding: 14px 26px;
background: rgb(5 7 5 / 0.76);
color: #ffe8b4;
font-size: clamp(1.3rem, 4vw, 2rem);
font-weight: 950;
transform: translate(-50%, -50%);
box-shadow: 0 18px 60px rgb(0 0 0 / 0.46);
backdrop-filter: blur(8px);
}
.match-status {
position: fixed;
bottom: clamp(14px, 3vw, 26px);
left: 50%;
z-index: 4;
width: min(980px, calc(100vw - 32px));
min-height: 48px;
overflow: hidden;
border: 1px solid rgb(238 185 73 / 0.28);
border-radius: 8px;
padding: 13px 0;
background: rgb(8 10 7 / 0.74);
color: #ffe2a6;
font-weight: 900;
opacity: 0;
pointer-events: none;
transform: translate(-50%, calc(100% + 28px));
transition:
opacity 420ms ease,
transform 420ms ease;
backdrop-filter: blur(10px);
}
#app.status-active:not(.match-live) .match-status {
opacity: 1;
transform: translate(-50%, 0);
}
#app.match-live .match-status {
display: none;
}
@media (min-width: 961px) {
#app.match-live .match-status {
left: calc((100vw - var(--drawer-width)) / 2);
width: min(760px, calc(100vw - var(--drawer-width) - 32px));
}
#app.match-live.drawer-collapsed .match-status {
left: 50%;
width: min(980px, calc(100vw - 32px));
}
}
.status-track {
display: flex;
width: max-content;
min-width: 200%;
gap: 64px;
animation: status-marquee 22s linear infinite;
}
.status-track span {
flex: 0 0 auto;
min-width: calc(50vw - 32px);
padding-left: 28px;
white-space: nowrap;
}

225
src/styles/intro.css Normal file
View File

@ -0,0 +1,225 @@
.battle-preview {
position: fixed;
inset: 0;
z-index: 2;
overflow: hidden;
opacity: 0.84;
pointer-events: none;
transition:
opacity 420ms ease,
transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#app.match-live .battle-preview {
opacity: 0;
transform: scale(1.08);
}
.preview-fighter {
position: absolute;
width: 100px;
height: 100px;
background-repeat: no-repeat;
background-size: auto 100px;
image-rendering: pixelated;
transform-origin: center;
filter:
drop-shadow(0 18px 22px rgb(0 0 0 / 0.68))
saturate(1.14)
brightness(1.12);
animation:
preview-attack var(--sprite-speed) steps(var(--sprite-steps)) infinite,
preview-breathe 1800ms ease-in-out infinite;
}
.preview-knight {
--sprite-end: -600px;
--sprite-scale: 5.2;
--sprite-speed: 840ms;
--sprite-steps: 6;
left: 10vw;
top: 48vh;
background-image: url("/assets/characters/knight/Knight-Attack01.png");
transform: scale(var(--sprite-scale));
}
.preview-orc {
--sprite-end: -500px;
--sprite-scale: 5.35;
--sprite-speed: 760ms;
--sprite-steps: 5;
right: 9vw;
top: 46vh;
background-image: url("/assets/characters/orc/Orc-Attack01.png");
transform: scaleX(-1) scale(var(--sprite-scale));
}
.preview-wizard {
--sprite-end: -500px;
--sprite-scale: 4.35;
--sprite-speed: 980ms;
--sprite-steps: 5;
left: 56vw;
top: 24vh;
background-image: url("/assets/characters/wizard/Wizard-Attack01.png");
opacity: 0.58;
transform: scaleX(-1) scale(var(--sprite-scale));
}
.preview-strike {
position: absolute;
width: 160px;
height: 5px;
border-radius: 999px;
background: linear-gradient(90deg, transparent, rgb(255 229 156 / 0.86), transparent);
box-shadow: 0 0 24px rgb(227 89 59 / 0.5);
opacity: 0;
transform-origin: center;
animation: preview-strike 980ms ease-in-out infinite;
}
.preview-strike-a {
left: 38vw;
top: 54vh;
transform: rotate(-18deg);
}
.preview-strike-b {
right: 31vw;
top: 42vh;
transform: rotate(22deg);
animation-delay: 260ms;
}
.intro-stage {
position: fixed;
inset: 0;
z-index: 5;
display: grid;
place-items: center;
padding: clamp(24px, 5vw, 56px);
pointer-events: none;
transition:
opacity 420ms ease,
transform 620ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
#app.match-live .intro-stage {
opacity: 0;
transform: scale(0.96);
}
#app.match-live .intro-content {
pointer-events: none;
}
.intro-content {
display: grid;
justify-items: center;
gap: 22px;
text-align: center;
pointer-events: auto;
animation: intro-rise 760ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
transition:
transform 560ms cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 360ms ease;
}
#app.options-open:not(.match-live) .intro-content {
opacity: 0.72;
}
.arena-logo {
margin: 0;
color: #fff4d1;
font-size: clamp(4rem, 16vw, 11rem);
font-weight: 950;
letter-spacing: 0;
line-height: 0.9;
text-shadow:
0 2px 0 #ad4d37,
0 14px 42px rgb(0 0 0 / 0.72),
0 0 40px rgb(230 173 71 / 0.28);
text-transform: uppercase;
}
.arena-logo .small-text {
font-size: 0.7em;
margin-top: 0.05em;
}
.arena-logo span {
display: block;
}
.arena-meta {
position: fixed;
right: clamp(10px, 2vw, 18px);
bottom: clamp(10px, 2vw, 18px);
z-index: 10;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
pointer-events: none;
transition: opacity 220ms ease;
}
.visitor-count,
.about-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
margin: 0;
border: 1px solid rgb(238 185 73 / 0.22);
border-radius: 999px;
padding: 5px 12px;
background: rgb(8 10 7 / 0.68);
color: #e7c879;
font-size: 0.72rem;
font-weight: 800;
line-height: 1;
text-decoration: none;
backdrop-filter: blur(10px);
pointer-events: auto;
transition:
background 180ms ease,
border-color 180ms ease,
transform 180ms ease,
opacity 220ms ease;
}
.visitor-count {
opacity: 0;
transform: translateY(8px);
}
#app.match-live .visitor-count {
opacity: 0.86;
transform: translateY(0);
}
.about-button {
min-width: 72px;
color: #ffe8b4;
font-weight: 900;
box-shadow: 0 4px 12px rgb(0 0 0 / 0.22);
}
.about-button:hover {
border-color: rgb(238 185 73 / 0.42);
background: rgb(255 246 216 / 0.14);
transform: translateY(-1px);
}
.start-button {
min-width: 180px;
padding: 0 30px;
text-transform: uppercase;
}
#app.options-open:not(.match-live) .start-button {
pointer-events: none;
visibility: hidden;
}

308
src/styles/mobile.css Normal file
View File

@ -0,0 +1,308 @@
@media (max-width: 960px) {
body {
overflow: hidden;
}
#app {
--arena-gap: 0px;
--mobile-game-size: min(100vw, calc(100svh - var(--score-band-height)));
--mobile-kill-log-top: calc(var(--score-band-height) + var(--mobile-game-size) + 10px);
--mobile-options-button-width: 54px;
--mobile-options-gap: 8px;
--mobile-team-card-width: clamp(56px, calc((100vw - 120px) / 4), 72px);
--mobile-visitor-space: calc(104px + env(safe-area-inset-bottom));
--score-band-height: 132px;
--score-panel-left: 10px;
--score-panel-width: calc(100vw - 20px);
--score-rail-width: 0px;
}
#app.match-live .arena-shell {
place-items: start center;
}
#app.match-live #game {
width: var(--mobile-game-size);
height: var(--mobile-game-size);
margin-top: var(--score-band-height);
margin-left: 0;
}
.intro-stage {
padding: 20px;
}
.arena-logo {
font-size: clamp(3.8rem, 22vw, 7rem);
}
.fighter-entry {
width: 100vw;
padding: 22px;
}
#app.match-live .fighter-entry {
top: calc(10px + env(safe-area-inset-top));
right: 10px;
left: 10px;
width: auto;
max-height: calc(100svh - 20px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
gap: 10px;
padding: 12px;
}
#app.match-live.drawer-collapsed .fighter-entry {
top: calc(22px + env(safe-area-inset-top));
right: 10px;
left: auto;
width: auto;
}
#app.match-live .fighter-entry h2 {
font-size: clamp(1.45rem, 7vw, 1.8rem);
}
#app.match-live .fighter-entry textarea {
height: 112px;
min-height: 112px;
resize: none;
}
#app.match-live .fighter-entry fieldset {
gap: 7px;
padding: 9px;
}
#app.match-live .fighter-entry form {
gap: 10px;
}
#app.match-live .entry-copy {
gap: 4px;
}
#app.match-live .eyebrow {
font-size: 0.68rem;
}
#app.match-live label,
#app.match-live .spawn-placement-label {
font-size: 0.82rem;
}
#app.match-live input:not([type="range"]):not([type="radio"]),
#app.match-live textarea {
min-height: 40px;
padding-inline: 10px;
}
#app.match-live textarea {
padding-block: 9px;
}
#app.match-live .team-size-number {
width: 64px;
min-width: 64px;
}
#app.match-live .spawn-placement-option span {
min-height: 36px;
padding: 6px;
font-size: 0.76rem;
}
#app.match-live .match-actions {
gap: 8px;
}
#app.match-live .match-actions button {
min-height: 44px;
}
#app.match-live .drawer-toggle {
min-width: 116px;
}
#app.match-live.drawer-collapsed .drawer-toggle {
width: var(--mobile-options-button-width);
min-width: var(--mobile-options-button-width);
padding-inline: 6px;
font-size: 0;
}
#app.match-live.drawer-collapsed .drawer-toggle::before {
content: "옵션";
font-size: 0.78rem;
line-height: 1;
}
.battle-preview {
opacity: 0.62;
}
.preview-knight {
left: -8vw;
top: 52vh;
}
.preview-orc {
right: -9vw;
top: 49vh;
}
.preview-wizard {
left: 48vw;
top: 21vh;
}
.scoreboard {
align-items: flex-start;
top: 10px;
left: var(--score-panel-left);
width: var(--score-panel-width);
max-height: calc(var(--score-band-height) - 12px);
overflow-x: auto;
overflow-y: hidden;
padding: 9px min(148px, 38vw) 9px 9px;
scrollbar-color: rgb(238 185 73 / 0.38) transparent;
scrollbar-width: thin;
touch-action: pan-x;
}
#app.match-live.drawer-collapsed .scoreboard {
width: calc(
100vw - 20px - var(--mobile-options-button-width) - var(--mobile-options-gap)
);
padding-right: 9px;
}
.score-side {
display: grid;
grid-template-columns: none;
grid-auto-columns: var(--mobile-team-card-width);
grid-auto-flow: column;
grid-template-rows: repeat(2, 48px);
gap: 5px 5px;
}
.scoreboard::-webkit-scrollbar {
height: 4px;
}
.scoreboard::-webkit-scrollbar-track {
background: transparent;
}
.scoreboard::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgb(238 185 73 / 0.38);
}
.team-score {
width: auto;
min-height: 48px;
height: 48px;
gap: 3px;
padding: 5px 6px;
font-size: 0.66rem;
grid-template-rows: 1fr 1px auto;
align-content: center;
}
.team-score-count {
font-size: 0.74rem;
}
.team-score.is-focused {
box-shadow: inset 0 0 0 2px rgb(255 244 209 / 0.92);
}
.battle-notice {
top: calc(var(--score-band-height) + 8px);
right: 24px;
left: 24px;
width: auto;
padding-inline: 12px;
font-size: 0.76rem;
transform: translateY(-10px);
}
#app.match-live .battle-notice.is-visible {
transform: translateY(0);
}
.kill-log {
top: var(--mobile-kill-log-top);
bottom: auto;
left: 10px;
width: calc(100vw - 20px);
max-height: calc(100svh - var(--mobile-kill-log-top) - var(--mobile-visitor-space));
padding: 8px;
}
#app.match-live .victory-celebration {
padding:
var(--score-band-height)
14px
min(30svh, 230px);
}
.victory-banner {
width: min(calc(100vw - 48px), 520px);
min-height: 92px;
padding: 1rem 1.1rem;
font-size: clamp(1.35rem, 7vw, 2rem);
}
.match-status {
bottom: 10px;
width: calc(100vw - 20px);
}
.arena-meta {
right: 10px;
bottom: calc(10px + env(safe-area-inset-bottom));
z-index: 10;
gap: 8px;
opacity: 1;
pointer-events: auto;
}
.visitor-count {
font-size: 0.68rem;
}
.about-button {
min-width: 68px;
min-height: 26px;
font-size: 0.68rem;
}
.about-backdrop {
align-items: end;
padding: 12px;
}
.about-dialog {
width: 100%;
max-height: calc(100svh - 24px);
}
.about-header {
padding: 18px 18px 12px;
}
.about-tabs {
padding: 0 18px 12px;
}
.about-panel {
padding: 16px 18px 22px;
}
.about-field-row {
grid-template-columns: 78px minmax(0, 1fr);
min-height: 48px;
gap: 10px;
}
}

566
src/styles/overlay.css Normal file
View File

@ -0,0 +1,566 @@
.start-button,
form button[type="submit"],
.pause-button,
.restart-button {
min-height: 52px;
border-radius: 8px;
background: linear-gradient(180deg, #e56443, #b93c2f);
color: #fff7df;
font-weight: 900;
box-shadow:
0 18px 44px rgb(0 0 0 / 0.36),
inset 0 1px 0 rgb(255 255 255 / 0.2);
transition:
background 180ms ease,
transform 180ms ease,
box-shadow 180ms ease;
}
.start-button:hover,
form button[type="submit"]:hover,
.pause-button:hover,
.restart-button:hover {
background: linear-gradient(180deg, #f0754f, #c84636);
transform: translateY(-1px);
box-shadow:
0 22px 52px rgb(0 0 0 / 0.42),
inset 0 1px 0 rgb(255 255 255 / 0.24);
}
.pause-button,
.restart-button {
display: none;
border: 1px solid rgb(238 185 73 / 0.3);
background: rgb(255 246 216 / 0.08);
color: #ffe8b4;
}
.pause-button:hover,
.restart-button:hover {
background: rgb(255 246 216 / 0.14);
}
#app.match-live .pause-button,
#app.match-live .restart-button {
display: block;
}
#app.match-live .match-actions {
grid-template-columns: 1fr 1fr;
}
#app.match-live .match-actions button[type="submit"] {
grid-column: 1 / -1;
}
#app.match-paused .pause-button {
background: linear-gradient(180deg, #e3b24f, #9a6c24);
color: #120f08;
}
#app.match-ended .pause-button {
display: none;
}
.drawer-scrim {
position: fixed;
inset: 0;
z-index: 6;
background: rgb(4 5 4 / 0.42);
opacity: 0;
pointer-events: none;
transition: opacity 320ms ease;
}
#app.options-open .drawer-scrim {
opacity: 1;
pointer-events: auto;
}
#app.match-live .drawer-scrim {
opacity: 0;
pointer-events: none;
}
.drawer-toggle {
display: none;
min-height: 40px;
border: 1px solid rgb(238 185 73 / 0.28);
border-radius: 8px;
padding: 0 12px;
background: rgb(12 15 11 / 0.84);
color: #ffe8b4;
font-size: 0.82rem;
font-weight: 900;
box-shadow: 0 16px 38px rgb(0 0 0 / 0.36);
transition:
background 180ms ease,
transform 180ms ease;
backdrop-filter: blur(10px);
}
.drawer-toggle:hover {
background: rgb(255 246 216 / 0.14);
transform: translateY(-1px);
}
#app.match-live .drawer-toggle {
display: block;
}
.fighter-entry {
position: fixed;
top: 0;
right: 0;
z-index: 7;
display: grid;
align-content: start;
gap: 24px;
width: var(--drawer-width);
height: 100vh;
overflow-y: auto;
border-left: 1px solid rgb(239 199 103 / 0.22);
padding: clamp(22px, 4vw, 34px);
background:
linear-gradient(180deg, rgb(29 33 22 / 0.94), rgb(13 16 12 / 0.96)),
#11140f;
box-shadow: -28px 0 80px rgb(0 0 0 / 0.52);
transform: translateX(104%);
transition:
opacity 260ms ease,
transform 520ms cubic-bezier(0.2, 0.8, 0.2, 1);
backdrop-filter: blur(16px);
}
#app.options-open .fighter-entry {
transform: translateX(0);
}
#app.match-live .fighter-entry {
top: 24px;
right: 24px;
height: auto;
max-height: calc(100vh - 48px);
gap: 16px;
border: 1px solid rgb(239 199 103 / 0.22);
border-radius: 8px;
padding: 20px;
transform: translateX(0);
}
#app.match-live.drawer-collapsed .fighter-entry {
width: auto;
min-width: 0;
overflow: visible;
border-color: transparent;
padding: 0;
background: transparent;
box-shadow: none;
opacity: 1;
pointer-events: auto;
transform: translateX(0);
}
#app.match-live .drawer-close {
display: none;
}
#app.match-live .fighter-entry h2 {
font-size: 2rem;
}
#app.match-live .fighter-entry textarea {
min-height: 190px;
}
#app.match-live .fighter-entry fieldset {
padding: 12px;
}
#app.match-live.drawer-collapsed .entry-copy,
#app.match-live.drawer-collapsed .fighter-entry form {
display: none;
}
#app.match-live.drawer-collapsed .drawer-header {
justify-content: end;
}
.drawer-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
}
.drawer-header-controls {
display: flex;
align-items: center;
gap: 8px;
}
.entry-copy {
display: grid;
gap: 8px;
}
.eyebrow {
margin: 0;
color: #e3b24f;
font-size: 0.78rem;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
h2 {
margin: 0;
color: #fff3d2;
font-size: clamp(1.7rem, 4vw, 2.5rem);
line-height: 1.05;
letter-spacing: 0;
}
.drawer-close {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: 0 0 auto;
border: 1px solid rgb(238 185 73 / 0.22);
border-radius: 8px;
background: rgb(255 246 216 / 0.08);
color: #f8deb0;
font-weight: 900;
}
.drawer-close:hover {
background: rgb(255 246 216 / 0.14);
}
.about-backdrop {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: clamp(16px, 4vw, 34px);
background: rgb(3 5 4 / 0.66);
backdrop-filter: blur(8px);
}
.about-backdrop[hidden] {
display: none;
}
.about-dialog {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
width: min(560px, calc(100vw - 32px));
max-height: min(760px, calc(100svh - 32px));
overflow: hidden;
border: 1px solid rgb(239 199 103 / 0.28);
border-radius: 8px;
background:
linear-gradient(180deg, rgb(29 33 22 / 0.98), rgb(10 13 9 / 0.98)),
#11140f;
box-shadow:
0 24px 100px rgb(0 0 0 / 0.62),
inset 0 1px 0 rgb(255 255 255 / 0.06);
outline: none;
}
.about-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
padding: clamp(20px, 4vw, 28px) clamp(20px, 4vw, 30px) 14px;
}
.about-close {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: 0 0 auto;
border: 1px solid rgb(238 185 73 / 0.22);
border-radius: 8px;
background: rgb(255 246 216 / 0.08);
color: #f8deb0;
font-weight: 900;
}
.about-close:hover {
background: rgb(255 246 216 / 0.14);
}
.about-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
padding: 0 clamp(20px, 4vw, 30px) 14px;
border-bottom: 1px solid rgb(238 185 73 / 0.16);
}
.about-tab {
min-width: 0;
min-height: 42px;
border: 1px solid transparent;
border-radius: 6px;
padding: 8px 10px;
background: rgb(255 246 216 / 0.06);
color: #ead8ad;
font-size: 0.86rem;
font-weight: 900;
line-height: 1.2;
}
.about-tab[aria-selected="true"] {
border-color: rgb(238 185 73 / 0.36);
background: #323822;
color: #fff7df;
}
.about-panel {
min-height: 0;
overflow: auto;
padding: clamp(18px, 4vw, 26px) clamp(20px, 4vw, 30px) clamp(22px, 5vw, 34px);
}
.about-fields {
display: grid;
gap: 0;
margin: 0;
}
.about-field-row {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
align-items: center;
gap: 14px;
min-height: 50px;
border-bottom: 1px solid rgb(238 185 73 / 0.14);
}
.about-field-row:first-child {
border-top: 1px solid rgb(238 185 73 / 0.14);
}
.about-field-row dt {
color: #e3b24f;
font-size: 0.78rem;
font-weight: 950;
text-transform: uppercase;
}
.about-field-row dd {
min-width: 0;
margin: 0;
color: #fff7df;
font-weight: 800;
overflow-wrap: anywhere;
}
.about-field-row a,
.about-markdown a {
color: #85dcc7;
text-decoration-color: rgb(133 220 199 / 0.42);
text-underline-offset: 3px;
}
.about-markdown {
display: grid;
gap: 12px;
color: #ead8ad;
font-size: 0.92rem;
font-weight: 600;
line-height: 1.56;
}
.about-markdown :is(h3, h4, h5, h6, p, ul, blockquote) {
margin: 0;
}
.about-markdown h3,
.about-markdown h4,
.about-markdown h5,
.about-markdown h6 {
color: #fff3d2;
font-size: 1rem;
line-height: 1.3;
}
.about-markdown blockquote {
border-left: 3px solid rgb(238 185 73 / 0.36);
padding: 4px 0 4px 16px;
color: #c4b693;
font-style: italic;
}
.about-markdown hr {
margin: 8px 0;
border: 0;
border-top: 1px solid rgb(238 185 73 / 0.16);
}
.about-markdown code {
border: 1px solid rgb(238 185 73 / 0.14);
border-radius: 4px;
padding: 2px 5px;
background: rgb(255 246 216 / 0.08);
color: #f1c761;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.88em;
}
.about-markdown li {
line-height: 1.5;
}
.about-markdown li strong {
color: #fff3d2;
}
.about-markdown ul {
display: grid;
gap: 8px;
padding-left: 1.2rem;
}
.about-empty {
color: #bfae83;
}
form {
display: grid;
gap: 16px;
}
.match-actions {
display: grid;
gap: 10px;
}
fieldset {
display: grid;
gap: 10px;
min-width: 0;
margin: 0;
border: 1px solid rgb(238 185 73 / 0.22);
border-radius: 8px;
padding: 14px;
background: rgb(5 7 5 / 0.26);
}
legend {
padding: 0 6px;
color: #e3b24f;
font-size: 0.78rem;
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
.team-size-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.team-size-number {
width: 88px;
min-width: 88px;
padding-inline: 10px;
text-align: center;
font-weight: 900;
}
label {
color: #ead8ad;
font-size: 0.92rem;
}
input:not([type="range"]):not([type="radio"]),
textarea {
min-height: 48px;
border: 1px solid rgb(238 185 73 / 0.28);
border-radius: 8px;
padding: 0 14px;
background: #232719;
color: #fff7df;
outline: none;
}
textarea {
min-height: 258px;
resize: vertical;
padding-block: 12px;
line-height: 1.45;
}
input[type="range"] {
width: 100%;
accent-color: #e3b24f;
}
.spawn-placement-field {
display: grid;
gap: 8px;
}
.spawn-placement-label {
color: #ead8ad;
font-size: 0.92rem;
}
.spawn-placement-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
min-width: 0;
border: 1px solid rgb(238 185 73 / 0.2);
border-radius: 8px;
padding: 4px;
background: #1d2116;
}
.spawn-placement-option {
position: relative;
min-width: 0;
}
.spawn-placement-option input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.spawn-placement-option span {
display: grid;
min-height: 44px;
place-items: center;
border: 1px solid transparent;
border-radius: 6px;
padding: 8px;
color: #ead8ad;
text-align: center;
font-size: 0.86rem;
font-weight: 900;
line-height: 1.25;
cursor: pointer;
}
.spawn-placement-option input:checked + span {
border-color: rgb(238 185 73 / 0.36);
background: #323822;
color: #fff7df;
}
.spawn-placement-option input:focus-visible + span {
outline: 2px solid #f1c761;
outline-offset: -2px;
}

View File

@ -9,41 +9,59 @@ export function updateScoreboard(
return;
}
containerLeft.innerHTML = "";
containerRight.innerHTML = "";
const currentTeamElements = [...containerLeft.children];
const teamsChanged =
currentTeamElements.length !== teams.length ||
teams.some((team, index) => currentTeamElements[index]?.dataset.teamId !== String(team.id));
teams.forEach((team) => {
const aliveCount = fighters.filter((f) => f.team.id === team.id && !f.isDead).length;
if (teamsChanged) {
containerLeft.replaceChildren(...teams.map((team) => createTeamElement(team.id)));
}
if (containerRight.childElementCount > 0) {
containerRight.replaceChildren();
}
teams.forEach((team, index) => {
const teamEl = containerLeft.children[index];
const aliveCount = fighters.filter(
(fighter) => fighter.team.id === team.id && !fighter.isDead,
).length;
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}`;
teamEl.classList.toggle("is-focused", selectedFighterTeamId === team.id);
if (selectedFighterTeamId === team.id) {
teamEl.classList.add("is-focused");
}
const labelEl = document.createElement("span");
labelEl.className = "team-score-name";
const labelEl = teamEl.querySelector(".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";
const countEl = teamEl.querySelector(".team-score-count");
countEl.textContent = `${aliveCount}`;
teamEl.addEventListener("click", () => {
teamEl.onclick = () => {
onTeamClick(team.id);
});
teamEl.append(labelEl, ruleEl, countEl);
containerLeft.appendChild(teamEl);
};
});
}
function createTeamElement(teamId) {
const teamEl = document.createElement("button");
teamEl.className = "team-score";
teamEl.type = "button";
teamEl.dataset.teamId = String(teamId);
const labelEl = document.createElement("span");
labelEl.className = "team-score-name";
const ruleEl = document.createElement("span");
ruleEl.className = "team-score-rule";
const countEl = document.createElement("span");
countEl.className = "team-score-count";
teamEl.append(labelEl, ruleEl, countEl);
return teamEl;
}

View File

@ -1,4 +1,4 @@
import { DEFAULT_SPAWN_PLACEMENT, NICKNAME_LENGTH } from "../constants.js";
import { FIGHTER, SPAWN } from "../constants.js";
const STORAGE_KEYS = {
names: "arena.match.playerNames",
@ -96,7 +96,7 @@ function getElements(selector) {
function nicknameValues(value) {
return value
.split(/\r?\n|,/)
.map((name) => name.trim().slice(0, NICKNAME_LENGTH))
.map((name) => name.trim().slice(0, FIGHTER.NICKNAME_LENGTH))
.filter(Boolean);
}
@ -165,12 +165,12 @@ function saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput) {
}
function selectedSpawnPlacement(inputs) {
return inputs.find((input) => input.checked)?.value ?? DEFAULT_SPAWN_PLACEMENT;
return inputs.find((input) => input.checked)?.value ?? SPAWN.DEFAULT_PLACEMENT;
}
function setSpawnPlacement(inputs, value) {
const savedInput = inputs.find((input) => input.value === value);
const defaultInput = inputs.find((input) => input.value === DEFAULT_SPAWN_PLACEMENT);
const defaultInput = inputs.find((input) => input.value === SPAWN.DEFAULT_PLACEMENT);
const nextInput = savedInput ?? defaultInput;
if (nextInput) {

86
todo.md
View File

@ -109,12 +109,12 @@
18. 치명타 적중 표기 추가 (완료)
- **조치 사항**:
- 공격 프로필의 치명타 판정을 실제 적중 처리까지 전달해 전투 타입별 적중 연출이 같은 흐름을 사용하도록 정리.
- 치명타 적중 시 대상 위에 `Critical!` 문구를 띄우고 즉시 처치와 카메라 흔들림이 함께 적용되도록 `applyHit()`를 보강.
- 치명타 적중 시 대상 위에 `Critical!` 문구를 띄우고 즉시 처치가 적용되도록 `applyHit()`를 보강. (카메라 흔들림은 이후 메테오 착탄 연출로 이전)
19. 리스폰 배치 설정 구분 추가 (완료)
- **조치 사항**:
- 전투 설정 drawer에 `스타팅 지점 배치`와 기존 `완전 랜덤 배치`를 선택하는 리스폰 설정을 추가.
- `스타팅 지점 배치`에서는 참가자 수에 맞춰 전장 구역을 나누고 참가자별 시작 구역 배정과 구역 안 스폰 위치를 매치마다 무작위로 정하도록 구현.
- `스타팅 지점 배치`에서는 참가자별 스타팅 영역과 영역 안 스폰 위치를 매치마다 무작위로 정하도록 구현했으며, 이후 30번 작업에서 영역 선택을 랜덤 중심 셀 기반 `5 x 5` 방식으로 구체화.
- 선택한 리스폰 배치 모드를 `localStorage`에 저장해 새로고침과 재시작 이후에도 유지.
20. 팀당 인원 직접 입력 동기화 (완료)
@ -178,4 +178,86 @@
- 유저가 About 다이얼로그를 열 때마다 DB에서 최신 데이터를 가져오도록 서버 메모리 캐시 로직을 제거.
- 기본 개인정보처리방침 마크다운의 공고/시행 일자를 최신화.
28. 전투 역할별 기본 스탯 프로필 분리 (완료)
- **조치 사항**:
- `src/constants.js``FIGHTER_TYPE_STATS.melee/ranged/magic` 프로필을 추가해 최대 체력, 이동속도, 사거리, 쿨다운, 피해량, 치명타, 공격 발동 지연을 역할별로 조정할 수 있도록 변경.
- `src/game/fighter/fighterStats.js`를 추가해 투사체 캐릭터는 원거리, 즉발 주문 캐릭터는 마법, 나머지는 근접 프로필로 판별하고 개별 스킨 오버라이드를 병합.
- 캐릭터 생성과 전투 엔진이 해석된 프로필을 사용하도록 연결해 역할별 체력, 이동 및 공격 수치가 실제 전투에 적용되도록 변경.
29. 킬로그 캐릭터 아이콘 가시성 개선 (완료)
- **조치 사항**:
- `100x100` idle 프레임에 포함된 투명 여백까지 축소되던 킬로그 아이콘 배경 표시 방식을 보정.
- 아이콘 박스 크기와 행 레이아웃은 유지하면서 캐릭터 실루엣이 있는 중앙 하단 영역을 확대 표시하도록 배경 크기와 위치를 조정.
30. 팀별 스타팅 영역 앵커 및 전장 표시 추가 (완료)
- **조치 사항**:
- `스타팅 지점 배치`에서 전장 스폰 가능 그리드 중 팀별 중심 셀을 무작위로 선택하고, 중심 주변 2칸을 포함하는 `5 x 5` 영역을 팀별 스폰 구역으로 사용하도록 변경.
- 겹치지 않는 후보가 남아 있는 동안에는 선택된 스타팅 영역끼리 중첩되지 않는 랜덤 중심을 우선 사용해 전투 시작 즉시 팀이 섞이는 상황을 줄임.
- 팀별로 무작위 배정된 스타팅 영역 데이터를 실제 스폰 좌표와 공유해 표시 영역 밖에서 시작하지 않도록 구성.
- `arenaRenderer.js`에 팀 색상의 매우 옅은 채움 및 외곽선 오버레이를 추가하고, 랜덤 배치에서는 오버레이가 표시되지 않도록 연결.
31. 사망 시점 팀 badge 클릭 입력 유실 수정 (완료)
- **조치 사항**:
- 사망 발생 때마다 `arenaScoreboard.js`가 팀 badge 버튼 전체를 재생성해 클릭 중인 DOM이 제거되던 문제를 수정.
- 팀 구성이 바뀌지 않는 전투 중 갱신에서는 기존 버튼 DOM을 유지하고 생존 인원, 선택 강조, 비활성 상태만 업데이트하도록 변경.
- 사망 처리와 팀 badge 클릭이 같은 시점에 겹쳐도 생존 캐릭터 관전 시점 선택이 정상 전달되도록 보강.
32. 주기적 월드 이펙트 메테오 및 냉각지대 추가 (완료)
- **조치 사항**:
- `public/assets/effects/world_Effect.png`를 7프레임 공용 스프라이트시트로 로드하고, 실제 전투 시작 후 8초마다 무작위 생존자 위치에 메테오 또는 냉각지대를 무작위 발동하도록 `worldEffects.js`를 추가.
- 메테오는 낙하 경고 후 대상 위치 기준 `5 x 5` 영역에 환경 피해를 적용하고, 환경 사망이 처치 보상 없이 사망 통계와 승패 판정에 반영되도록 전투 피해 처리를 확장.
- 냉각지대는 냉기 착탄 연출과 지속 구역을 표시하며, 구역 안에 있는 캐릭터의 공격속도와 이동속도를 함께 감속하도록 연결.
- 발동 간격, 범위, 피해량, 냉각 지속시간과 감속 배율을 `src/constants.js``WORLD_EFFECT_*` 상수로 분리하고, 새 경기/종료/일시정지 생명주기에 맞춰 정리되도록 구성.
33. 월드 메테오 대각선 낙하 및 냉기 전용 시트 적용 (완료)
- **조치 사항**:
- 대상 위치가 전장 좌측 반면(2, 3사분면)이면 좌상단에서 우하단, 우측 반면(1, 4사분면)이면 우상단에서 좌하단으로 낙하하도록 궤적, 좌우 반전, `45`도 회전을 적용.
- `WORLD_EFFECT_VISUAL_SCALE``WORLD_EFFECT_FALL_TRAVEL_TILES`를 추가해 피해 판정 `5 x 5`는 유지하면서 스프라이트를 전역 마법처럼 크게 보이도록 확장.
- 화염 메테오는 `public/assets/effects/world_Effect.png`, 냉기 메테오는 새 `public/assets/effects/world_Effect_2.png`를 각각 독립된 7프레임 애니메이션으로 로드하도록 변경.
34. 냉기 메테오 착탄 피해 옵션 추가 (완료)
- **조치 사항**:
- `WORLD_EFFECT_FROST_DAMAGE`를 추가해 냉기 메테오 피해를 화염 메테오와 독립적으로 조절할 수 있도록 변경.
- 냉기 메테오 착탄 시 `5 x 5` 영역 피해를 먼저 처리하고, 전투가 종료되지 않은 경우 기존 냉각지대 감속 효과를 이어서 생성하도록 연결.
35. 스타팅 영역 표시 시간 제한 추가 (완료)
- **조치 사항**:
- 팀별 스타팅 영역 오버레이가 `스타팅 지점 배치` 매치 시작 후 5초 동안만 표시되고 이후 자동으로 사라지도록 연결.
- 숨김 예약을 Phaser 씬 타이머로 관리하여 일시정지 시간은 표시 지속 시간에 포함되지 않고, 새 매치 시작 시 이전 숨김 타이머가 남지 않도록 정리.
36. 최종 2팀 자동 관전 및 메테오 착탄 화면 흔들림 전환 (완료)
- **조치 사항**:
- 생존 캐릭터가 30명 미만이거나 최종 2팀만 남으면 후반 자동 줌과 교전 중심 포커싱이 시작되도록 관전 조건을 확장.
- 치명타의 `Critical!` 표기와 즉시 처치는 유지하면서 카메라 흔들림을 제거.
- 화염 메테오가 착탄할 때 화면 흔들림을 적용하고, 냉각지대 착탄과 피해 계산에서는 흔들림을 분리.
37. 자동 관전 이전 메테오 임시 포커싱 추가 (완료)
- **조치 사항**:
- 후반 자동 관전 조건이 성립하기 전 화염 또는 냉기 메테오가 낙하하면 착탄 위치를 확대 추적하고 착탄 연출 종료 후 기존 카메라 위치와 줌을 복원.
- 캐릭터 수동 선택과 후반/최종 자동 관전은 메테오 임시 시점보다 우선하도록 카메라 상태를 정리.
- `src/constants.js``CAMERA.METEOR_FOCUS_ENABLED` 플래그로 메테오 임시 포커싱을 코드에서 켜고 끌 수 있도록 구성.
38. 냉기 메테오 동결 기절 및 실루엣 효과 추가 (완료)
- **조치 사항**:
- 냉기 메테오 착탄 피해에 생존한 전투원은 `2초` 동안 이동과 새 공격이 정지되는 `isFrostStunned` 상태가 되도록 연결.
- 동결 중 캐릭터 본체와 팀 실루엣 마커를 함께 얼음색으로 틴트하고, 시간이 끝나거나 매치가 정리되면 본체 원본 색상과 팀 색상으로 복원.
- `WORLD_EFFECT.FROST_STUN_DURATION``WORLD_EFFECT.FROST_STUN_TINT`를 추가해 동결 지속시간과 표시 색상을 조절 가능하게 구성.
39. 모바일 세로모드 팀 카드 가로폭 불균형 수정 (완료)
- **조치 사항**:
- 모바일 미디어 쿼리에서 `.score-side`가 데스크톱의 `grid-template-columns: repeat(2, 114px)`를 상속받아 1~4번 팀 카드만 길게 표시되던 현상을 수정.
- `grid-template-columns: none`을 추가하여 모든 팀 카드가 `grid-auto-columns`에 설정된 일정한 가로폭을 가지도록 보정.
40. CSS 파일 기능별 모듈화 (완료)
- **조치 사항**:
- 거대했던 `src/styles.css`(약 2,000라인)를 기능별로 6개의 파일(`base`, `intro`, `game-ui`, `overlay`, `animations`, `mobile`)로 분리.
- `src/styles/` 폴더를 생성하여 모듈화된 CSS 파일들을 관리.
- `src/styles.css`는 이제 `@import`를 통해 각 모듈을 통합하는 엔트리 포인트 역할만 수행.
- 코드 가독성과 유지보수 편의성을 대폭 향상.
41. 스타일 관련 컨텍스트 문서 추가 및 라우팅 업데이트 (완료)
- **조치 사항**:
- 새로운 CSS 모듈 구조와 디자인 원칙을 설명하는 `context/style.md` 문서를 신규 생성.
- `agent.md`의 상세 기술 가이드(Context Routing) 섹션에 스타일 및 디자인 항목을 추가하여 문서 접근성 개선.