feat: implement world effects, role-based stats, and optimized starting zones with refactored constants

This commit is contained in:
Horoli 2026-05-24 19:16:51 +09:00
parent 36fd25731a
commit 1668e3c941
22 changed files with 1146 additions and 500 deletions

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

@ -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: {

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) {