feat: implement world effects, role-based stats, and optimized starting zones with refactored constants
This commit is contained in:
parent
36fd25731a
commit
1668e3c941
|
|
@ -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초 발동 간격과 냉각 지속시간에 포함되지 않습니다.
|
||||
|
|
|
|||
|
|
@ -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` 설정을 확인합니다.
|
||||
|
|
|
|||
|
|
@ -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`을 기준으로 관리합니다.
|
||||
|
|
|
|||
|
|
@ -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개 종족 중 하나를 반드시 선택해야 합니다.
|
||||
|
|
|
|||
|
|
@ -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 |
318
src/constants.js
318
src/constants.js
|
|
@ -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 ARENA = {
|
||||
GRID_SIZE,
|
||||
TILE_SIZE,
|
||||
SIZE: ARENA_SIZE,
|
||||
};
|
||||
// 최초 접속 대기 전투에서 고정으로 보여줄 팀 수입니다.
|
||||
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 = {
|
||||
// 기본 공격 애니메이션 속도입니다.
|
||||
// 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 },
|
||||
// 대기 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
|
||||
idle: { frameRate: 7, repeat: -1 },
|
||||
// 이동 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
|
||||
walk: { frameRate: 10, repeat: -1 },
|
||||
// 대체 이동 애니메이션 속도입니다. 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 팀 배정에 순서대로 사용되는 기본 색상 팔레트입니다.
|
||||
export const TEAM_COLORS = [
|
||||
// 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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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), () => {
|
||||
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), () => {
|
||||
scene.time.delayedCall(
|
||||
scaledAttackDelay(combatStatsFor(attacker).windupDelay, attacker),
|
||||
() => {
|
||||
if (!isAttackValid(scene, attacker, defender, matchId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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), () => {
|
||||
scene.time.delayedCall(
|
||||
scaledAttackDelay(combatStatsFor(attacker).windupDelay, attacker),
|
||||
() => {
|
||||
if (!isAttackValid(scene, attacker, defender, matchId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ export const fighterManifest = [
|
|||
maxHp: 1,
|
||||
},
|
||||
traits: {
|
||||
spawnMultiplier: 10,
|
||||
spawnMultiplier: 3,
|
||||
splitOnDeath: {
|
||||
chance: 0.5,
|
||||
count: 2,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
13
src/main.js
13
src/main.js
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 = teamEl.querySelector(".team-score-name");
|
||||
labelEl.textContent = team.label;
|
||||
|
||||
const countEl = teamEl.querySelector(".team-score-count");
|
||||
countEl.textContent = `${aliveCount}명`;
|
||||
|
||||
teamEl.onclick = () => {
|
||||
onTeamClick(team.id);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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";
|
||||
labelEl.textContent = team.label;
|
||||
|
||||
const ruleEl = document.createElement("span");
|
||||
ruleEl.className = "team-score-rule";
|
||||
|
||||
const countEl = document.createElement("span");
|
||||
countEl.className = "team-score-count";
|
||||
countEl.textContent = `${aliveCount}명`;
|
||||
|
||||
teamEl.addEventListener("click", () => {
|
||||
onTeamClick(team.id);
|
||||
});
|
||||
|
||||
teamEl.append(labelEl, ruleEl, countEl);
|
||||
containerLeft.appendChild(teamEl);
|
||||
});
|
||||
return teamEl;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue