Compare commits
2 Commits
43383bb833
...
1668e3c941
| Author | SHA1 | Date |
|---|---|---|
|
|
1668e3c941 | |
|
|
36fd25731a |
15
agent.md
15
agent.md
|
|
@ -29,6 +29,10 @@
|
|||
│ └── visitors.js # 유니크 방문자 체크 및 통계 API
|
||||
├── public/ # 정적 리소스 (게임 에셋)
|
||||
│ └── assets/
|
||||
│ ├── effects/ # 공통 전투/월드 이펙트 스프라이트시트
|
||||
│ │ ├── heal/ # 처치 회복 연출
|
||||
│ │ ├── world_Effect.png # 화염 메테오 7프레임 이미지
|
||||
│ │ └── world_Effect_2.png # 냉기 메테오 7프레임 이미지
|
||||
│ └── characters/ # 20종 이상의 캐릭터 스킨 및 투사체 에셋
|
||||
│ ├── archer/, armored-axeman/, armored-orc/, ... (중략)
|
||||
│ └── wizard/ # 각 폴더 내 애니메이션 시트 및 이펙트 포함
|
||||
|
|
@ -39,19 +43,21 @@
|
|||
├── game/ # 게임 로직 모듈 (역할별 하위 폴더 구성)
|
||||
│ ├── arena/ # 아레나 및 씬 관리
|
||||
│ │ ├── ArenaScene.js # 메인 게임 씬 (Orchestrator, 생명주기 및 모듈 조율)
|
||||
│ │ ├── arenaRenderer.js# 경기장 바닥 및 격자 렌더링
|
||||
│ │ ├── arenaRenderer.js# 경기장 바닥, 격자 및 팀 시작 영역 렌더링
|
||||
│ │ └── arenaSpectatorCamera.js # 지능형 관전 카메라 및 줌 로직
|
||||
│ ├── combat/ # 전투 시스템
|
||||
│ │ ├── combat.js # 전투 AI, 투사체 및 피격 판정 핵심 엔진
|
||||
│ │ ├── combatSettings.js # 전투 속도 및 이동 배율 관리
|
||||
│ │ └── arenaFinalCombatEffects.js # 최종 교전 슬로우 모션 등 연출 효과
|
||||
│ │ ├── arenaFinalCombatEffects.js # 최종 교전 슬로우 모션 등 연출 효과
|
||||
│ │ └── worldEffects.js # 주기적 메테오/냉각지대 및 냉기 동결 효과
|
||||
│ ├── fighter/ # 캐릭터 및 에셋
|
||||
│ │ ├── fighterAssets.js # 스프라이트 로드 및 팀 실루엣 동적 생성
|
||||
│ │ ├── fighterFactory.js # 캐릭터 인스턴스화 및 HUD 동기화
|
||||
│ │ ├── fighterManifest.js # 20종 캐릭터 스탯/특성 상세 정의
|
||||
│ │ ├── fighterStats.js # 근접/원거리/마법 프로필 판별 및 스탯 해석
|
||||
│ │ └── fighterSelection.js # 캐릭터 스킨 무작위 선택 로직
|
||||
│ └── match/ # 매치 및 진행
|
||||
│ ├── matchSetup.js # 팀 구성 및 스폰 좌표 계산 (구역/랜덤)
|
||||
│ ├── matchSetup.js # 팀 구성 및 스폰 좌표 계산 (스타팅 영역/랜덤)
|
||||
│ └── arenaMatchRuntime.js # 매치 진행 중 헬퍼 (스폰 클러스터, 팀 크기 동기화)
|
||||
└── ui/ # UI 컴포넌트 및 API 연동
|
||||
├── arenaKillLog.js # [New] 독립된 킬로그 DOM 조작 모듈
|
||||
|
|
@ -71,9 +77,10 @@
|
|||
- **[인프라 및 전역 설정] [context/core.md](./context/core.md)**: `main.js`, `constants.js`, 개발/유지보수 공통 규칙.
|
||||
- **[서버 및 API] [context/server.md](./context/server.md)**: Fastify 서버, MongoDB 연동, 방문자 및 사망 통계 API 상세.
|
||||
- **[아레나 및 카메라] [context/arena.md](./context/arena.md)**: `ArenaScene` 오케스트레이션, 지능형 카메라 추적, 미니맵 가이드라인.
|
||||
- **[전투 엔진] [context/combat.md](./context/combat.md)**: 전투 AI, 투사체 판정, 처치 보상 성장, 슬로우모션 연출.
|
||||
- **[전투 엔진] [context/combat.md](./context/combat.md)**: 전투 AI, 투사체 판정, 처치 보상 성장, 슬로우모션 및 월드 이펙트 연출.
|
||||
- **[캐릭터 및 에셋] [context/fighter.md](./context/fighter.md)**: 캐릭터 공장, 동적 실루엣 생성, 종족 및 특성(Slime 등) 정의.
|
||||
- **[매치 로직 및 UI] [context/match-ui.md](./context/match-ui.md)**: 팀 구성 및 스폰 알고리즘, HUD 레이아웃, 킬로그, 승리 연출 UI.
|
||||
- **[스타일 및 디자인] [context/style.md](./context/style.md)**: CSS 모듈 구조, 디자인 변수, 반응형 및 애니메이션 가이드.
|
||||
|
||||
## 4. 기술 사양
|
||||
|
||||
|
|
|
|||
|
|
@ -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개 종족 중 하나를 반드시 선택해야 합니다.
|
||||
|
|
|
|||
|
|
@ -18,14 +18,15 @@
|
|||
|
||||
### 매치 설정 및 스폰 배치
|
||||
- **완전 랜덤 배치**: 전장 전체 스폰 슬롯을 무작위로 섞어 배치합니다.
|
||||
- **스타팅 지점 배치**: 참가자 수에 맞춰 전장을 구역으로 나눈 뒤, 참가자별 구역 배정을 매치마다 섞고 구역 내 무작위 위치에 스폰합니다.
|
||||
- **스타팅 지점 배치**: 팀마다 전장 스폰 가능 그리드에서 중심 셀을 무작위로 고르고, 중심 주변 2칸(`5 x 5`)을 해당 팀의 스타팅 영역으로 사용합니다. 겹치지 않는 후보가 남아 있는 동안에는 해당 후보를 우선 선택하며, 영역은 매치 시작 후 5초 동안만 팀 색상으로 매우 옅게 표시되고 팀 전투원은 이 안에서만 스폰합니다.
|
||||
- **설정 유지**: 닉네임, 인원, 배치 모드는 `localStorage`에 저장되어 재접속 시 복원됩니다.
|
||||
|
||||
### 전투 화면 레이아웃 (HUD)
|
||||
- **팀 Badge**: 좌측 HUD 레일에 배치되며, 클릭 시 해당 팀의 생존 유닛 중 무작위 1명으로 시점을 고정합니다.
|
||||
- **킬로그**: 처치자와 피처치자를 좌우로 배치하고, 피처치자 아이콘에 빨간 X를 겹쳐 사망 관계를 명확히 표시합니다.
|
||||
- **팀 Badge 갱신 안정성**: 사망으로 생존 수가 바뀔 때 기존 badge 버튼 DOM을 유지한 채 숫자, 비활성 상태, 선택 강조만 갱신하여 사망 프레임에 겹친 클릭도 시점 고정으로 전달되도록 합니다.
|
||||
- **킬로그**: 처치자와 피처치자를 좌우로 배치하고, 피처치자 아이콘에 빨간 X를 겹쳐 사망 관계를 명확히 표시합니다. 캐릭터 idle 시트의 `100x100` 프레임 내 투명 여백을 제외한 중앙 하단 영역을 확대 표시해 작은 아이콘 박스에서도 실루엣이 충분히 보이도록 합니다.
|
||||
- **하단 메타 정보**: 전투 화면 우측 하단(`arena-meta` 컨테이너)에 방문자 카운터와 About 버튼이 Pill(알약) 형태로 디자인이 통일되어 나란히 고정 배치됩니다. 드로어가 열려도 동일한 위치를 유지합니다.
|
||||
- **모바일 레이아웃**: 실제 전투 시작 시 모바일에서는 옵션 drawer를 자동으로 접고, 상단 팀 HUD는 옵션 버튼 폭을 제외한 영역에 두 줄 4열로 맞춰 4개 이후 팀도 잘리지 않게 합니다. 모바일 팀 카드 선택 표시는 내부 테두리로 처리해 외곽선이 잘려 보이지 않게 합니다. 킬로그는 전투 캔버스 바로 아래에 배치하되 하단 메타 정보(방문자 카운터/About)와 겹치지 않게 안전 여백을 확보합니다.
|
||||
- **모바일 레이아웃**: 실제 전투 시작 시 모바일에서는 옵션 drawer를 자동으로 접고, 상단 팀 HUD는 옵션 버튼 폭을 제외한 영역에 두 줄로 배치됩니다. 이때 데스크톱의 고정 가로폭 상속을 방지(`grid-template-columns: none`)하여 모든 팀 카드가 균일한 가로폭을 유지하도록 하며, 4개 이후 팀도 스크롤을 통해 확인할 수 있습니다. 모바일 팀 카드 선택 표시는 내부 테두리로 처리해 외곽선이 잘려 보이지 않게 합니다. 킬로그는 전투 캔버스 바로 아래에 배치하되 하단 메타 정보(방문자 카운터/About)와 겹치지 않게 안전 여백을 확보합니다.
|
||||
- **모바일 옵션 drawer**: 전투 중 펼친 옵션 drawer는 닉네임 입력 높이와 컨트롤 간격을 줄여 전투 시작/재시작/일시정지 버튼이 작은 화면에서도 한 번에 보이도록 합니다.
|
||||
- **승리 연출**: 승리 시 Web Audio 기반 팡파르와 CSS 애니메이션(광선, 컨페티)을 결합해 화려하게 연출합니다. 전투 종료 시 옵션 drawer를 접어 결과 배너가 설정 폼과 충돌하지 않게 하며, 결과 배너는 일정 시간 후 자동으로 사라지거나 클릭 시 즉시 닫힙니다. 무승부는 더 차분한 톤을 사용합니다.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
# Context: Style & Design
|
||||
|
||||
## 1. CSS 모듈 구조 (src/styles/)
|
||||
|
||||
이 프로젝트는 거대한 단일 CSS 파일을 지양하고, 기능별로 분리된 모듈형 CSS 구조를 채택하고 있습니다. `src/styles.css`는 각 모듈을 통합하는 엔트리 포인트 역할을 합니다.
|
||||
|
||||
- **`base.css`**: 전역 변수(`:root`), 리셋 스타일, 레이아웃의 뼈대(`#app`, `#game`, `.arena-shell`)를 정의합니다.
|
||||
- **`intro.css`**: 대기 화면, 로고 애니메이션, 전투 프리뷰 연출 스타일을 담당합니다.
|
||||
- **`game-ui.css`**: 스코어보드(팀 badge), 킬로그, 상단 전투 안내바, 승리/무승부 축하 레이어 등 실제 게임 진행 중 노출되는 모든 HUD 요소를 관리합니다.
|
||||
- **`overlay.css`**: 설정 드로어(전투 옵션 폼), About 다이얼로그 및 공통 폼 컨트롤 스타일을 정의합니다.
|
||||
- **`animations.css`**: 프로젝트 전역에서 재사용되는 `@keyframes`와 애니메이션 관련 유틸리티 클래스를 포함합니다.
|
||||
- **`mobile.css`**: `960px` 이하 해상도를 위한 미디어 쿼리 오버라이드 스타일을 통합 관리합니다. 모바일 전용 레이아웃 조정 및 터치 최적화 스타일이 포함됩니다.
|
||||
|
||||
## 2. 디자인 시스템 및 변수
|
||||
|
||||
- **색상 체계**: 어두운 배경(`#080a07`)과 금색/주황색 계열의 포인트 컬러(`rgb(238 185 73)`)를 사용하여 판타지 아레나 분위기를 연출합니다.
|
||||
- **반응형 대응**: `clamp()`, `min()`, `calc()` 등 현대적인 CSS 함수를 적극 활용하여 다양한 화면 크기에서도 유연하게 대응합니다.
|
||||
- **가독성**: 텍스트 섀도우와 반투명 배경(`backdrop-filter`)을 활용해 복잡한 전투 화면 위에서도 UI 요소의 시인성을 확보합니다.
|
||||
|
||||
## 3. 스타일 수정 가이드
|
||||
|
||||
- **전역 상수 변경**: 색상이나 기본 여백 등은 `base.css`의 `:root` 변수를 먼저 확인하십시오.
|
||||
- **컴포넌트 스타일 수정**: 수정하려는 UI 요소가 속한 카테고리에 맞는 파일을 열어 작업하십시오. (예: 킬로그 수정 -> `game-ui.css`)
|
||||
- **모바일 레이아웃 수정**: 데스크톱 스타일을 수정한 후에는 `mobile.css`에서 해당 요소가 모바일에서 어떻게 보이는지 반드시 확인하고 필요한 경우 오버라이드하십시오.
|
||||
- **애니메이션 추가**: 새로운 키프레임은 `animations.css`에 추가하여 중앙 집중식으로 관리합니다.
|
||||
|
|
@ -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 |
342
src/constants.js
342
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 PRESENTATION_TEAM_COUNT = 10;
|
||||
// 최초 접속 대기 전투에서 팀마다 배치할 전투원 수입니다.
|
||||
export const PRESENTATION_TEAM_SIZE = 5;
|
||||
// 캐릭터 스프라이트의 기본 화면 배율입니다.
|
||||
export const FIGHTER_SCALE = 3;
|
||||
export const FIGHTER_DEPTH = 2;
|
||||
export const DEAD_FIGHTER_DEPTH = 1;
|
||||
export const DEAD_FIGHTER_ALPHA = 0.42;
|
||||
// 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 너비입니다.
|
||||
export const FIGHTER_FRAME_WIDTH = 100;
|
||||
// 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 높이입니다.
|
||||
export const FIGHTER_FRAME_HEIGHT = 100;
|
||||
// 캐릭터 히트박스의 원본 프레임 기준 너비입니다.
|
||||
export const FIGHTER_HITBOX_WIDTH = 22;
|
||||
// 캐릭터 히트박스의 원본 프레임 기준 높이입니다.
|
||||
export const FIGHTER_HITBOX_HEIGHT = 20;
|
||||
// 100x100 프레임 안에서 히트박스가 시작되는 X 좌표입니다.
|
||||
export const FIGHTER_HITBOX_OFFSET_X = 39;
|
||||
// 100x100 프레임 안에서 히트박스가 시작되는 Y 좌표입니다. 실제 캐릭터 픽셀 하단은 대체로 y=59입니다.
|
||||
export const FIGHTER_HITBOX_OFFSET_Y = 40;
|
||||
// 캐릭터의 기본 최대 체력입니다.
|
||||
export const FIGHTER_MAX_HP = 100;
|
||||
// 적 처치 시 현재 체력 기준으로 회복되는 비율입니다.
|
||||
export const KILL_HEALTH_RECOVERY_RATIO = 0.3;
|
||||
// 처치 회복 이펙트 스프라이트시트의 프레임 수입니다.
|
||||
export const KILL_HEAL_EFFECT_FRAMES = 4;
|
||||
// 처치 회복 이펙트 애니메이션의 초당 프레임 수입니다.
|
||||
export const KILL_HEAL_EFFECT_FRAME_RATE = 12;
|
||||
// 적 처치 시 크기, 공격속도, 이동속도에 누적 적용되는 배율입니다.
|
||||
export const KILL_GROWTH_MULTIPLIER = 1.25;
|
||||
// 처치 보상으로 누적 적용되는 최대 배율입니다. 기본 scale에 곱해지는 상한이기도 합니다.
|
||||
export const KILL_GROWTH_MAX_MULTIPLIER = 5;
|
||||
// 처치 성장 연출 tween 지속 시간(ms)입니다.
|
||||
export const KILL_GROWTH_TWEEN_DURATION = 180;
|
||||
// 입력 UI에서 허용하는 팀당 최대 캐릭터 수입니다.
|
||||
export const MAX_TEAM_SIZE = 100;
|
||||
// 근접 캐릭터의 기본 치명타 확률입니다. 치명타는 즉시 처치로 처리됩니다.
|
||||
export const MELEE_CRITICAL_CHANCE = 0.05;
|
||||
// 캐릭터 기본 이동 속도입니다. 처치 보상과 전역 이동 배율이 곱해집니다.
|
||||
export const MOVE_SPEED = 148;
|
||||
// 투사체가 자동으로 사라지기까지의 시간(ms)입니다.
|
||||
export const PROJECTILE_LIFETIME = 1800;
|
||||
// 투사체 기본 이동 속도입니다. 처치 보상과 전역 공격 배율이 곱해집니다.
|
||||
export const PROJECTILE_SPEED = 420;
|
||||
// 원거리 캐릭터의 기본 치명타 확률입니다.
|
||||
export const RANGED_CRITICAL_CHANCE = 0;
|
||||
// 원거리 캐릭터가 공격을 시작할 수 있는 기본 거리입니다.
|
||||
export const RANGED_ATTACK_RANGE = TILE_SIZE * 5;
|
||||
|
||||
// 근접 공격 애니메이션 시작 후 실제 피해가 들어가기까지의 지연(ms)입니다.
|
||||
export const MELEE_HIT_DELAY = 260;
|
||||
// 원거리 공격 애니메이션 시작 후 투사체가 발사되기까지의 지연(ms)입니다.
|
||||
export const PROJECTILE_FIRE_DELAY = 360;
|
||||
// 투사체 충돌 원형 바디가 이미지 안에서 시작되는 오프셋입니다.
|
||||
export const PROJECTILE_BODY_OFFSET = 4;
|
||||
// 투사체 궤적 충돌 검사 시 대상 히트박스에 더하는 여유 픽셀입니다.
|
||||
export const PROJECTILE_HIT_PADDING = 20;
|
||||
// 투사체 충돌 원형 바디의 반지름입니다.
|
||||
export const PROJECTILE_HIT_RADIUS = 12;
|
||||
// 투사체가 공격자 위치에서 얼마나 떨어져 생성되는지 정하는 거리입니다.
|
||||
export const PROJECTILE_SPAWN_DISTANCE = 1;
|
||||
// 즉발 마법 캐스팅 후 이펙트가 생성되기까지의 지연(ms)입니다.
|
||||
export const SPELL_CAST_DELAY = 340;
|
||||
// 마법 이펙트 생성 후 실제 피해가 들어가기까지의 지연(ms)입니다.
|
||||
export const SPELL_HIT_DELAY = 160;
|
||||
|
||||
// 카메라 최소 줌입니다. 전장 전체를 보는 기본 배율입니다.
|
||||
export const CAMERA_MIN_ZOOM = 1;
|
||||
// 카메라 최대 줌입니다. 후반 관전 및 휠 확대의 상한입니다.
|
||||
export const CAMERA_MAX_ZOOM = 3;
|
||||
// 마우스 휠 한 번당 카메라 줌 변화량입니다.
|
||||
export const CAMERA_ZOOM_STEP = 0.1;
|
||||
// 미니맵 카메라가 보일 때의 투명도입니다.
|
||||
export const MINIMAP_ALPHA = 0.8;
|
||||
// 미니맵이 화면 가장자리에서 떨어지는 거리입니다.
|
||||
export const MINIMAP_MARGIN = Math.round(ARENA_SIZE * 0.016);
|
||||
// 미니맵의 고정 픽셀 크기입니다.
|
||||
export const MINIMAP_VIEWPORT_SIZE = Math.round(ARENA_SIZE * 0.22);
|
||||
// 미니맵 현재 뷰포트 표시용 선 두께입니다.
|
||||
export const MINIMAP_VIEW_FRAME_STROKE = 10;
|
||||
// 관전 카메라가 목표 전투 지점으로 따라가는 부드러움입니다.
|
||||
export const SPECTATOR_CAMERA_LERP = 0.1;
|
||||
// 생존자가 이 수보다 적으면 최종 전투 줌을 적용합니다.
|
||||
export const SPECTATOR_FINAL_FIGHTER_THRESHOLD = 5;
|
||||
// 최종 전투 구간에서 강제로 적용되는 카메라 줌입니다.
|
||||
export const SPECTATOR_FINAL_FIGHT_ZOOM = 3;
|
||||
export const SPECTATOR_FINAL_TEAM_COUNT = 2;
|
||||
export const SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD = 8;
|
||||
export const SPECTATOR_RANDOM_FOCUS_INTERVAL = 2400;
|
||||
// 최종교전 슬로우모션 연출을 켜고 끕니다.
|
||||
export const FINAL_COMBAT_SLOW_MOTION_ENABLED = false;
|
||||
// 최종교전 공격 시작에서 슬로우 배율로 내려가는 속도 램프 시간(ms)입니다.
|
||||
export const FINAL_COMBAT_SLOW_MOTION_ENTER_DURATION = 14000;
|
||||
// 최종교전 공격을 슬로우 배율로 붙잡아 두는 시간(ms)입니다.
|
||||
export const FINAL_COMBAT_SLOW_MOTION_HOLD_DURATION = 14000;
|
||||
// 최종교전 슬로우에서 기본 속도로 복귀하는 속도 램프 시간(ms)입니다.
|
||||
export const FINAL_COMBAT_SLOW_MOTION_EXIT_DURATION = 14000;
|
||||
export const FINAL_COMBAT_SLOW_MOTION_SCALE = 0.28;
|
||||
// 생존자가 이 수보다 적으면 후반 전투 줌을 적용합니다.
|
||||
export const SPECTATOR_LATE_FIGHTER_THRESHOLD = 30;
|
||||
// 후반 전투 구간에서 강제로 적용되는 카메라 줌입니다.
|
||||
export const SPECTATOR_LATE_FIGHT_ZOOM = 2;
|
||||
// 캐릭터를 선택했을 때 최소로 확보하는 카메라 줌입니다.
|
||||
export const SELECTED_FIGHTER_CAMERA_ZOOM = 2;
|
||||
// 선택 실루엣과 원본 캐릭터 사이에 비워두는 픽셀 간격입니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_GAP = 1;
|
||||
// 선택 실루엣 자체가 차지하는 픽셀 두께입니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_WIDTH = 1;
|
||||
// 선택 실루엣의 빨간색 채널 값입니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_RED = 255;
|
||||
// 선택 실루엣의 초록색 채널 값입니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_GREEN = 228;
|
||||
// 선택 실루엣의 파란색 채널 값입니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_BLUE = 64;
|
||||
// 선택 실루엣의 전체 투명도입니다. 0.65는 윤곽을 또렷하게 보이면서 원본 캐릭터를 덮지 않습니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_ALPHA = 0.65;
|
||||
|
||||
// 참가자 닉네임을 잘라낼 최대 글자 수입니다.
|
||||
export const NICKNAME_LENGTH = 18;
|
||||
|
||||
// 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수입니다.
|
||||
export const FIGHTER_ANIMATION_OPTIONS = {
|
||||
// 기본 공격 애니메이션 속도입니다.
|
||||
attack: { frameRate: 15, repeat: 0 },
|
||||
// 보조 공격 애니메이션 속도입니다.
|
||||
attack02: { frameRate: 15, repeat: 0 },
|
||||
// 강공격/치명타용 공격 애니메이션 속도입니다.
|
||||
attack03: { frameRate: 15, repeat: 0 },
|
||||
// 방어 애니메이션 속도입니다.
|
||||
block: { frameRate: 13, repeat: 0 },
|
||||
// 사망 애니메이션 속도입니다.
|
||||
death: { frameRate: 11, repeat: 0 },
|
||||
// 회복 애니메이션 속도입니다.
|
||||
heal: { frameRate: 13, repeat: 0 },
|
||||
// 피격 애니메이션 속도입니다.
|
||||
hurt: { frameRate: 13, repeat: 0 },
|
||||
// 대기 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
|
||||
idle: { frameRate: 7, repeat: -1 },
|
||||
// 이동 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
|
||||
walk: { frameRate: 10, repeat: -1 },
|
||||
// 대체 이동 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
|
||||
walk02: { frameRate: 10, repeat: -1 },
|
||||
export const ARENA = {
|
||||
GRID_SIZE,
|
||||
TILE_SIZE,
|
||||
SIZE: ARENA_SIZE,
|
||||
};
|
||||
|
||||
// 팀 배정에 순서대로 사용되는 기본 색상 팔레트입니다.
|
||||
export const TEAM_COLORS = [
|
||||
// 2. FIGHTER 도메인
|
||||
export const FIGHTER = {
|
||||
SCALE: 3,
|
||||
DEPTH: 2,
|
||||
DEAD_DEPTH: 1,
|
||||
DEAD_ALPHA: 0.42,
|
||||
FRAME_WIDTH: 100,
|
||||
FRAME_HEIGHT: 100,
|
||||
HITBOX_WIDTH: 22,
|
||||
HITBOX_HEIGHT: 20,
|
||||
HITBOX_OFFSET_X: 39,
|
||||
HITBOX_OFFSET_Y: 40,
|
||||
NICKNAME_LENGTH: 18,
|
||||
// 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수
|
||||
ANIMATION_OPTIONS: {
|
||||
attack: { frameRate: 15, repeat: 0 },
|
||||
attack02: { frameRate: 15, repeat: 0 },
|
||||
attack03: { frameRate: 15, repeat: 0 },
|
||||
block: { frameRate: 13, repeat: 0 },
|
||||
death: { frameRate: 11, repeat: 0 },
|
||||
heal: { frameRate: 13, repeat: 0 },
|
||||
hurt: { frameRate: 13, repeat: 0 },
|
||||
idle: { frameRate: 7, repeat: -1 },
|
||||
walk: { frameRate: 10, repeat: -1 },
|
||||
walk02: { frameRate: 10, repeat: -1 },
|
||||
},
|
||||
// 역할별 기본 스탯
|
||||
TYPE_STATS: {
|
||||
melee: {
|
||||
maxHp: 100,
|
||||
moveSpeed: 148 * 1.1,
|
||||
attackRange: 84,
|
||||
attackCooldown: 840,
|
||||
damageMin: 14,
|
||||
damageMax: 24,
|
||||
criticalChance: 0.2,
|
||||
windupDelay: 260,
|
||||
},
|
||||
ranged: {
|
||||
maxHp: 80,
|
||||
moveSpeed: 148,
|
||||
attackRange: TILE_SIZE * 5,
|
||||
attackCooldown: 840 * 1.1,
|
||||
damageMin: 14 * 1.2,
|
||||
damageMax: 24 * 1.2,
|
||||
criticalChance: 0,
|
||||
windupDelay: 360,
|
||||
projectileSpeed: 420,
|
||||
},
|
||||
magic: {
|
||||
maxHp: 80,
|
||||
moveSpeed: 148,
|
||||
attackRange: TILE_SIZE * 5,
|
||||
attackCooldown: 840 * 1.1,
|
||||
damageMin: 14 * 1.5,
|
||||
damageMax: 24 * 1.5,
|
||||
criticalChance: 0,
|
||||
windupDelay: 340,
|
||||
effectHitDelay: 160,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 3. SPAWN 도메인
|
||||
export const SPAWN = {
|
||||
DEFAULT_TEAM_SIZE: 5,
|
||||
DEFAULT_PLACEMENT: "random",
|
||||
PLACEMENTS: {
|
||||
RANDOM: "random",
|
||||
STARTING_ZONES: "starting-zones",
|
||||
},
|
||||
STARTING_ZONE_RADIUS: 2,
|
||||
STARTING_ZONE_FILL_ALPHA: 0.07,
|
||||
STARTING_ZONE_BORDER_ALPHA: 0.14,
|
||||
STARTING_ZONE_VISIBLE_DURATION_MS: 5000,
|
||||
PRESENTATION_TEAM_COUNT: 10,
|
||||
PRESENTATION_TEAM_SIZE: 5,
|
||||
MAX_TEAM_SIZE: 100,
|
||||
};
|
||||
|
||||
// 4. COMBAT 도메인
|
||||
export const COMBAT = {
|
||||
KILL_HEALTH_RECOVERY_RATIO: 0.3,
|
||||
KILL_HEAL_EFFECT_FRAMES: 4,
|
||||
KILL_HEAL_EFFECT_FRAME_RATE: 12,
|
||||
KILL_GROWTH_MULTIPLIER: 1.25,
|
||||
KILL_GROWTH_MAX_MULTIPLIER: 5,
|
||||
KILL_GROWTH_TWEEN_DURATION: 180,
|
||||
// 최종교전 슬로우모션 설정
|
||||
FINAL_SLOW_MOTION_ENABLED: false,
|
||||
FINAL_SLOW_MOTION_ENTER_DURATION: 14000,
|
||||
FINAL_SLOW_MOTION_HOLD_DURATION: 14000,
|
||||
FINAL_SLOW_MOTION_EXIT_DURATION: 14000,
|
||||
FINAL_SLOW_MOTION_SCALE: 0.28,
|
||||
};
|
||||
|
||||
// 5. PROJECTILE 도메인
|
||||
export const PROJECTILE = {
|
||||
LIFETIME: 1800,
|
||||
BODY_OFFSET: 4,
|
||||
HIT_PADDING: 20,
|
||||
HIT_RADIUS: 12,
|
||||
SPAWN_DISTANCE: 1,
|
||||
};
|
||||
|
||||
// 6. WORLD_EFFECT 도메인
|
||||
export const WORLD_EFFECT = {
|
||||
INTERVAL: 4000,
|
||||
AREA_TILES: 5,
|
||||
FRAMES: 7,
|
||||
FRAME_RATE: 14,
|
||||
FALL_DURATION: 920,
|
||||
FALL_TRAVEL_TILES: 8,
|
||||
VISUAL_SCALE: 12,
|
||||
METEOR_DAMAGE: 80,
|
||||
FROST_DAMAGE: 40,
|
||||
FROST_STUN_DURATION: 2000,
|
||||
FROST_STUN_TINT: 0x82e9ff,
|
||||
FROST_DURATION: 20000,
|
||||
FROST_SPEED_MULTIPLIER: 0.55,
|
||||
};
|
||||
|
||||
// 7. CAMERA 도메인
|
||||
export const CAMERA = {
|
||||
MIN_ZOOM: 1,
|
||||
MAX_ZOOM: 3,
|
||||
ZOOM_STEP: 0.1,
|
||||
// 자동 관전 진입 전 화염/냉기 메테오 낙하 위치를 임시로 확대 추적합니다.
|
||||
METEOR_FOCUS_ENABLED: true,
|
||||
METEOR_FOCUS_ZOOM: 2,
|
||||
SPECTATOR_LERP: 0.1,
|
||||
// 메테오 착탄 후 카메라를 해당 위치에 유지하는 시간(ms)입니다.
|
||||
METEOR_FOCUS_HOLD_DURATION: 1200,
|
||||
SPECTATOR_FINAL_FIGHTER_THRESHOLD: 5,
|
||||
SPECTATOR_FINAL_FIGHT_ZOOM: 3,
|
||||
SPECTATOR_FINAL_TEAM_COUNT: 2,
|
||||
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD: 8,
|
||||
SPECTATOR_RANDOM_FOCUS_INTERVAL: 10000,
|
||||
SPECTATOR_LATE_FIGHTER_THRESHOLD: 30,
|
||||
SPECTATOR_LATE_FIGHT_ZOOM: 2,
|
||||
SELECTED_FIGHTER_ZOOM: 2,
|
||||
};
|
||||
|
||||
// 8. UI 도메인
|
||||
export const UI = {
|
||||
MINIMAP_ALPHA: 0.8,
|
||||
MINIMAP_MARGIN: Math.round(ARENA_SIZE * 0.016),
|
||||
MINIMAP_VIEWPORT_SIZE: Math.round(ARENA_SIZE * 0.22),
|
||||
MINIMAP_VIEW_FRAME_STROKE: 10,
|
||||
SELECTED_FIGHTER_OUTLINE_GAP: 1,
|
||||
SELECTED_FIGHTER_OUTLINE_WIDTH: 1,
|
||||
SELECTED_FIGHTER_OUTLINE_RED: 255,
|
||||
SELECTED_FIGHTER_OUTLINE_GREEN: 228,
|
||||
SELECTED_FIGHTER_OUTLINE_BLUE: 64,
|
||||
SELECTED_FIGHTER_OUTLINE_ALPHA: 0.65,
|
||||
};
|
||||
|
||||
// 9. TEAM 도메인
|
||||
const TEAM_COLORS = [
|
||||
"#da6a48",
|
||||
"#5fb4d9",
|
||||
"#9bd15a",
|
||||
|
|
@ -184,7 +183,7 @@ const TEAM_COLOR_HUE_OFFSET = 12;
|
|||
const TEAM_COLOR_SATURATIONS = [72, 62, 78, 68];
|
||||
const TEAM_COLOR_LIGHTNESSES = [57, 63, 51, 69];
|
||||
|
||||
export function getTeamColor(index, totalTeams = TEAM_COLORS.length) {
|
||||
function getTeamColor(index, totalTeams = TEAM_COLORS.length) {
|
||||
const safeIndex = Math.max(0, Math.floor(Number(index) || 0));
|
||||
const safeTeamCount = Math.max(1, Math.floor(Number(totalTeams) || 1));
|
||||
|
||||
|
|
@ -234,3 +233,8 @@ function hslToHex(hue, saturation, lightness) {
|
|||
)
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
export const TEAM = {
|
||||
COLORS: TEAM_COLORS,
|
||||
getColor: getTeamColor,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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), () => {
|
||||
applyHit(scene, attacker, defender, onWinner, matchId, {
|
||||
isCritical: attack.isCritical,
|
||||
});
|
||||
});
|
||||
scene.time.delayedCall(
|
||||
scaledAttackDelay(combatStatsFor(attacker).windupDelay, attacker),
|
||||
() => {
|
||||
applyHit(scene, attacker, defender, onWinner, matchId, {
|
||||
isCritical: attack.isCritical,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function queueProjectile(scene, attacker, defender, onWinner, attack) {
|
||||
const matchId = scene.matchId;
|
||||
|
||||
scene.time.delayedCall(scaledAttackDelay(PROJECTILE_FIRE_DELAY, attacker), () => {
|
||||
if (!isAttackValid(scene, attacker, defender, matchId)) {
|
||||
return;
|
||||
}
|
||||
scene.time.delayedCall(
|
||||
scaledAttackDelay(combatStatsFor(attacker).windupDelay, attacker),
|
||||
() => {
|
||||
if (!isAttackValid(scene, attacker, defender, matchId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
spawnProjectile(scene, attacker, defender, onWinner, matchId, attack);
|
||||
});
|
||||
spawnProjectile(scene, attacker, defender, onWinner, matchId, attack);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function queueInstantSpell(scene, attacker, defender, onWinner, attack) {
|
||||
const matchId = scene.matchId;
|
||||
|
||||
scene.time.delayedCall(scaledAttackDelay(SPELL_CAST_DELAY, attacker), () => {
|
||||
if (!isAttackValid(scene, attacker, defender, matchId)) {
|
||||
return;
|
||||
}
|
||||
scene.time.delayedCall(
|
||||
scaledAttackDelay(combatStatsFor(attacker).windupDelay, attacker),
|
||||
() => {
|
||||
if (!isAttackValid(scene, attacker, defender, matchId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack);
|
||||
});
|
||||
spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) {
|
||||
|
|
@ -142,9 +133,9 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) {
|
|||
projectile.setDepth(3);
|
||||
projectile.setScale(2);
|
||||
projectile.body.setCircle(
|
||||
PROJECTILE_HIT_RADIUS,
|
||||
PROJECTILE_BODY_OFFSET,
|
||||
PROJECTILE_BODY_OFFSET,
|
||||
PROJECTILE.HIT_RADIUS,
|
||||
PROJECTILE.BODY_OFFSET,
|
||||
PROJECTILE.BODY_OFFSET,
|
||||
);
|
||||
projectile.setRotation(
|
||||
Phaser.Math.Angle.Between(
|
||||
|
|
@ -158,7 +149,7 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) {
|
|||
projectile,
|
||||
defenderHitPoint.x,
|
||||
defenderHitPoint.y,
|
||||
(attacker.skin.combat?.projectile?.speed ?? PROJECTILE_SPEED) *
|
||||
combatStatsFor(attacker).projectileSpeed *
|
||||
fighterAttackSpeedMultiplier(attacker),
|
||||
);
|
||||
trackCombatObject(scene, projectile);
|
||||
|
|
@ -210,7 +201,7 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) {
|
|||
scene.events.off(Phaser.Scenes.Events.UPDATE, checkProjectilePath);
|
||||
};
|
||||
|
||||
scene.time.delayedCall(PROJECTILE_LIFETIME, () => {
|
||||
scene.time.delayedCall(PROJECTILE.LIFETIME, () => {
|
||||
disposeCombatObject(scene, projectile);
|
||||
});
|
||||
}
|
||||
|
|
@ -218,7 +209,7 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) {
|
|||
function spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack) {
|
||||
const effect = scene.add.sprite(defender.x, defender.y, fighterAttackEffectKey(attacker.skin));
|
||||
effect.setDepth(3);
|
||||
effect.setScale(FIGHTER_SCALE);
|
||||
effect.setScale(FIGHTER.SCALE);
|
||||
effect.play(fighterAttackEffectAnimationKey(attacker.skin));
|
||||
trackCombatObject(scene, effect);
|
||||
|
||||
|
|
@ -227,7 +218,7 @@ function spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack)
|
|||
});
|
||||
|
||||
scene.time.delayedCall(
|
||||
scaledAttackDelay(attacker.skin.combat?.attackEffect?.hitDelay ?? SPELL_HIT_DELAY, attacker),
|
||||
scaledAttackDelay(combatStatsFor(attacker).effectHitDelay, attacker),
|
||||
() => {
|
||||
applyHit(scene, attacker, defender, onWinner, matchId, {
|
||||
isCritical: attack.isCritical,
|
||||
|
|
@ -243,12 +234,15 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = f
|
|||
|
||||
if (isCritical) {
|
||||
spawnCriticalHitLabel(scene, defender);
|
||||
scene.cameras.main.shake(90, 0.002);
|
||||
}
|
||||
|
||||
const attackerStats = combatStatsFor(attacker);
|
||||
defender.hp = isCritical
|
||||
? 0
|
||||
: Math.max(0, defender.hp - Phaser.Math.Between(ATTACK_DAMAGE_MIN, ATTACK_DAMAGE_MAX));
|
||||
: Math.max(
|
||||
0,
|
||||
defender.hp - Phaser.Math.Between(attackerStats.damageMin, attackerStats.damageMax),
|
||||
);
|
||||
defender.body.setVelocity(0, 0);
|
||||
|
||||
if (defender.hp === 0) {
|
||||
|
|
@ -260,8 +254,32 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = f
|
|||
playAnimation(defender, "hurt");
|
||||
}
|
||||
|
||||
export function applyWorldEffectDamage(scene, defender, damage) {
|
||||
if (scene.matchOver || !defender?.active || defender.isDead) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedDamage = Math.max(0, Math.round(Number(damage) || 0));
|
||||
|
||||
if (resolvedDamage === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
defender.hp = Math.max(0, defender.hp - resolvedDamage);
|
||||
defender.body?.setVelocity(0, 0);
|
||||
|
||||
if (defender.hp === 0) {
|
||||
killFighter(defender);
|
||||
return true;
|
||||
}
|
||||
|
||||
defender.isLocked = true;
|
||||
playAnimation(defender, "hurt");
|
||||
return false;
|
||||
}
|
||||
|
||||
function spawnCriticalHitLabel(scene, defender) {
|
||||
const scaleRatio = Math.max(1, Math.abs(defender.scaleY) / FIGHTER_SCALE);
|
||||
const scaleRatio = Math.max(1, Math.abs(defender.scaleY) / FIGHTER.SCALE);
|
||||
const label = scene.add
|
||||
.text(defender.x, defender.y - 44 * scaleRatio - 24, "Critical!", {
|
||||
color: "#ffe45c",
|
||||
|
|
@ -292,11 +310,7 @@ function spawnCriticalHitLabel(scene, defender) {
|
|||
}
|
||||
|
||||
function getAttackRange(fighter) {
|
||||
if (getCombatType(fighter) === "melee") {
|
||||
return ATTACK_RANGE;
|
||||
}
|
||||
|
||||
return fighter.skin.combat?.range ?? RANGED_ATTACK_RANGE;
|
||||
return combatStatsFor(fighter).attackRange;
|
||||
}
|
||||
|
||||
function getCombatType(fighter) {
|
||||
|
|
@ -314,11 +328,7 @@ function createAttackProfile(attacker) {
|
|||
}
|
||||
|
||||
function getCriticalChance(fighter) {
|
||||
if (getCombatType(fighter) !== "melee") {
|
||||
return fighter.skin.combat?.criticalChance ?? RANGED_CRITICAL_CHANCE;
|
||||
}
|
||||
|
||||
return fighter.skin.combat?.criticalChance ?? MELEE_CRITICAL_CHANCE;
|
||||
return combatStatsFor(fighter).criticalChance;
|
||||
}
|
||||
|
||||
function isAttackValid(scene, attacker, defender, matchId) {
|
||||
|
|
@ -353,8 +363,8 @@ function projectileSpawnPoint(attacker, target) {
|
|||
direction.normalize();
|
||||
|
||||
return {
|
||||
x: attacker.x + direction.x * PROJECTILE_SPAWN_DISTANCE,
|
||||
y: attacker.y + direction.y * PROJECTILE_SPAWN_DISTANCE,
|
||||
x: attacker.x + direction.x * PROJECTILE.SPAWN_DISTANCE,
|
||||
y: attacker.y + direction.y * PROJECTILE.SPAWN_DISTANCE,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -370,10 +380,10 @@ function projectilePathHitsDefender(projectile, defender) {
|
|||
projectile.y,
|
||||
);
|
||||
const defenderHitArea = new Phaser.Geom.Rectangle(
|
||||
defender.body.x - PROJECTILE_HIT_PADDING,
|
||||
defender.body.y - PROJECTILE_HIT_PADDING,
|
||||
defender.body.width + PROJECTILE_HIT_PADDING * 2,
|
||||
defender.body.height + PROJECTILE_HIT_PADDING * 2,
|
||||
defender.body.x - PROJECTILE.HIT_PADDING,
|
||||
defender.body.y - PROJECTILE.HIT_PADDING,
|
||||
defender.body.width + PROJECTILE.HIT_PADDING * 2,
|
||||
defender.body.height + PROJECTILE.HIT_PADDING * 2,
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -388,21 +398,27 @@ function killFighter(defender, winner, onWinner) {
|
|||
defender.body.setVelocity(0, 0);
|
||||
defender.body.enable = false;
|
||||
defender.healthBar.width = 0;
|
||||
defender.setAlpha(DEAD_FIGHTER_ALPHA);
|
||||
defender.setDepth(DEAD_FIGHTER_DEPTH);
|
||||
defender.setAlpha(FIGHTER.DEAD_ALPHA);
|
||||
defender.setDepth(FIGHTER.DEAD_DEPTH);
|
||||
defender.disableInteractive();
|
||||
defender.teamMarker?.setVisible(false);
|
||||
defender.nameLabel?.setVisible(false);
|
||||
defender.healthBack?.setVisible(false);
|
||||
defender.healthBar?.setVisible(false);
|
||||
playAnimation(defender, "death");
|
||||
winner.isLocked = false;
|
||||
winner.body.setVelocity(0, 0);
|
||||
playAnimation(winner, "idle");
|
||||
winner.scene.recordKill?.(winner, defender);
|
||||
applyKillReward(winner);
|
||||
|
||||
if (winner) {
|
||||
winner.isLocked = false;
|
||||
winner.body.setVelocity(0, 0);
|
||||
playAnimation(winner, "idle");
|
||||
winner.scene.recordKill?.(winner, defender);
|
||||
applyKillReward(winner);
|
||||
} else {
|
||||
defender.scene.recordDeath?.(defender);
|
||||
}
|
||||
|
||||
maybeSplitFighter(defender);
|
||||
onWinner(winner);
|
||||
onWinner?.(winner);
|
||||
}
|
||||
|
||||
function maybeSplitFighter(fighter) {
|
||||
|
|
@ -427,16 +443,16 @@ function applyKillReward(winner) {
|
|||
winner.killCount = (winner.killCount ?? 0) + 1;
|
||||
|
||||
const rewardMultiplier = Math.min(
|
||||
KILL_GROWTH_MAX_MULTIPLIER,
|
||||
KILL_GROWTH_MULTIPLIER ** winner.killCount,
|
||||
COMBAT.KILL_GROWTH_MAX_MULTIPLIER,
|
||||
COMBAT.KILL_GROWTH_MULTIPLIER ** winner.killCount,
|
||||
);
|
||||
const previousHp = winner.hp;
|
||||
const nextHp = recoveredHealth(winner);
|
||||
winner.killRewardMultiplier = rewardMultiplier;
|
||||
winner.hp = nextHp;
|
||||
|
||||
const nextScaleX = (winner.baseScaleX ?? FIGHTER_SCALE) * rewardMultiplier;
|
||||
const nextScaleY = (winner.baseScaleY ?? FIGHTER_SCALE) * rewardMultiplier;
|
||||
const nextScaleX = (winner.baseScaleX ?? FIGHTER.SCALE) * rewardMultiplier;
|
||||
const nextScaleY = (winner.baseScaleY ?? FIGHTER.SCALE) * rewardMultiplier;
|
||||
|
||||
if (nextHp > previousHp) {
|
||||
spawnKillHealEffect(winner, Math.max(Math.abs(nextScaleX), Math.abs(nextScaleY)));
|
||||
|
|
@ -446,7 +462,7 @@ function applyKillReward(winner) {
|
|||
targets: winner,
|
||||
scaleX: nextScaleX,
|
||||
scaleY: nextScaleY,
|
||||
duration: KILL_GROWTH_TWEEN_DURATION,
|
||||
duration: COMBAT.KILL_GROWTH_TWEEN_DURATION,
|
||||
ease: "Back.Out",
|
||||
onUpdate: () => clampFighterInsideArena(winner),
|
||||
onComplete: () => clampFighterInsideArena(winner),
|
||||
|
|
@ -459,23 +475,23 @@ function clampFighterInsideArena(fighter) {
|
|||
}
|
||||
|
||||
const halfWidth = Math.min(
|
||||
ARENA_SIZE / 2,
|
||||
ARENA.SIZE / 2,
|
||||
Math.max(Math.abs(fighter.displayWidth), fighter.body.width) / 2,
|
||||
);
|
||||
const halfHeight = Math.min(
|
||||
ARENA_SIZE / 2,
|
||||
ARENA.SIZE / 2,
|
||||
Math.max(Math.abs(fighter.displayHeight), fighter.body.height) / 2,
|
||||
);
|
||||
const x = Phaser.Math.Clamp(fighter.x, halfWidth, ARENA_SIZE - halfWidth);
|
||||
const y = Phaser.Math.Clamp(fighter.y, halfHeight, ARENA_SIZE - halfHeight);
|
||||
const x = Phaser.Math.Clamp(fighter.x, halfWidth, ARENA.SIZE - halfWidth);
|
||||
const y = Phaser.Math.Clamp(fighter.y, halfHeight, ARENA.SIZE - halfHeight);
|
||||
|
||||
fighter.setPosition(x, y);
|
||||
fighter.body.updateFromGameObject?.();
|
||||
}
|
||||
|
||||
function recoveredHealth(fighter) {
|
||||
const maxHp = fighter.maxHp ?? FIGHTER_MAX_HP;
|
||||
const recovery = Math.ceil(fighter.hp * KILL_HEALTH_RECOVERY_RATIO);
|
||||
const maxHp = fighter.maxHp ?? combatStatsFor(fighter).maxHp;
|
||||
const recovery = Math.ceil(fighter.hp * COMBAT.KILL_HEALTH_RECOVERY_RATIO);
|
||||
|
||||
return Math.min(maxHp, fighter.hp + recovery);
|
||||
}
|
||||
|
|
@ -556,19 +572,31 @@ function scaledAttackDelay(duration, fighter) {
|
|||
}
|
||||
|
||||
function fighterAttackSpeedMultiplier(fighter) {
|
||||
return getAttackSpeedMultiplier() * (fighter.killRewardMultiplier ?? 1);
|
||||
return (
|
||||
getAttackSpeedMultiplier()
|
||||
* (fighter.killRewardMultiplier ?? 1)
|
||||
* (fighter.worldEffectSpeedMultiplier ?? 1)
|
||||
);
|
||||
}
|
||||
|
||||
function fighterMovementSpeedMultiplier(fighter) {
|
||||
return getMovementSpeedMultiplier() * (fighter.killRewardMultiplier ?? 1);
|
||||
return (
|
||||
getMovementSpeedMultiplier()
|
||||
* (fighter.killRewardMultiplier ?? 1)
|
||||
* (fighter.worldEffectSpeedMultiplier ?? 1)
|
||||
);
|
||||
}
|
||||
|
||||
function trackCombatObject(scene, object) {
|
||||
function combatStatsFor(fighter) {
|
||||
return fighter.combatStats ?? getFighterStats(fighter.skin);
|
||||
}
|
||||
|
||||
export function trackCombatObject(scene, object) {
|
||||
scene.combatObjects ??= new Set();
|
||||
scene.combatObjects.add(object);
|
||||
}
|
||||
|
||||
function disposeCombatObject(scene, object) {
|
||||
export function disposeCombatObject(scene, object) {
|
||||
if (!object?.active) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
1975
src/styles.css
1975
src/styles.css
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,158 @@
|
|||
@keyframes intro-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(22px) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes preview-attack {
|
||||
to {
|
||||
background-position-x: var(--sprite-end);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes preview-breathe {
|
||||
0%,
|
||||
100% {
|
||||
margin-top: 0;
|
||||
}
|
||||
50% {
|
||||
margin-top: -8px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes preview-strike {
|
||||
0%,
|
||||
58%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
64%,
|
||||
76% {
|
||||
opacity: 0.86;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes status-marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes kill-log-entry {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes banner-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px) scale(0.78);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes victory-banner-sheen {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(0) skewX(-18deg);
|
||||
}
|
||||
18% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(560%) skewX(-18deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes victory-confetti-burst {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) rotate(var(--confetti-tilt)) scale(0.3);
|
||||
}
|
||||
12% {
|
||||
opacity: 1;
|
||||
}
|
||||
74% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform:
|
||||
translate(calc(-50% + var(--confetti-x)), calc(-50% + var(--confetti-y)))
|
||||
rotate(calc(var(--confetti-tilt) + var(--confetti-spin)))
|
||||
scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes victory-glow {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.58);
|
||||
}
|
||||
35% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.8;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes victory-rays-in {
|
||||
from {
|
||||
transform: scale(0.56);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes victory-rays-turn {
|
||||
to {
|
||||
rotate: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes victory-message-pulse {
|
||||
from {
|
||||
opacity: 0.72;
|
||||
transform: scale(0.88);
|
||||
}
|
||||
58% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.victory-banner,
|
||||
.victory-banner::before,
|
||||
.victory-banner-message,
|
||||
.victory-celebration::before,
|
||||
.victory-confetti-piece,
|
||||
.victory-rays {
|
||||
animation-duration: 1ms;
|
||||
animation-iteration-count: 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
font-family:
|
||||
Inter, Pretendard, "Noto Sans KR", system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", sans-serif;
|
||||
background: #080a07;
|
||||
color: #fff5db;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
background: #080a07;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 3px solid rgb(238 185 73 / 0.46);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
#app {
|
||||
--arena-gap: 18px;
|
||||
--score-band-height: 134px;
|
||||
--score-panel-left: 14px;
|
||||
--score-panel-width: 260px;
|
||||
--score-rail-width: calc(var(--score-panel-left) + var(--score-panel-width));
|
||||
--drawer-width: min(430px, 100vw);
|
||||
--drawer-live-width: min(340px, calc(100vw - 48px));
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(8 10 7 / 0.18), rgb(3 5 4 / 0.84)),
|
||||
#080a07;
|
||||
}
|
||||
|
||||
#app.match-live {
|
||||
--drawer-width: var(--drawer-live-width);
|
||||
}
|
||||
|
||||
.arena-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: #090b08;
|
||||
}
|
||||
|
||||
.arena-shell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, rgb(255 211 122 / 0.06), transparent 42%),
|
||||
linear-gradient(90deg, rgb(3 5 4 / 0.48), rgb(3 5 4 / 0.08) 45%, rgb(3 5 4 / 0.48)),
|
||||
linear-gradient(180deg, rgb(3 5 4 / 0.08), rgb(3 5 4 / 0.5));
|
||||
pointer-events: none;
|
||||
transition:
|
||||
background 520ms ease,
|
||||
opacity 520ms ease;
|
||||
}
|
||||
|
||||
#app.match-live .arena-shell::before {
|
||||
opacity: 0.24;
|
||||
}
|
||||
|
||||
#app.match-live .arena-shell {
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#game {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
width: max(100vw, 100vh);
|
||||
height: max(100vw, 100vh);
|
||||
overflow: hidden;
|
||||
opacity: 0.68;
|
||||
filter: saturate(1) contrast(1.08) brightness(1.08);
|
||||
transform: scale(1.04);
|
||||
transform-origin: center;
|
||||
transition:
|
||||
width 620ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
height 620ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
opacity 520ms ease,
|
||||
filter 520ms ease,
|
||||
transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
#app.match-live #game {
|
||||
width: min(100vw, 100vh);
|
||||
height: min(100vw, 100vh);
|
||||
margin-left: 0;
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
#game canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
|
@ -0,0 +1,601 @@
|
|||
.scoreboard {
|
||||
position: fixed;
|
||||
top: clamp(14px, 3vw, 28px);
|
||||
left: var(--score-panel-left);
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
width: var(--score-panel-width);
|
||||
max-height: calc(100vh - 420px);
|
||||
min-height: 64px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(238 185 73 / 0.3) transparent;
|
||||
padding: 8px;
|
||||
border: 1px solid rgb(238 185 73 / 0.18);
|
||||
border-radius: 8px;
|
||||
background: rgb(4 6 4 / 0.5);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-18px);
|
||||
transition:
|
||||
opacity 420ms ease,
|
||||
transform 420ms ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.scoreboard::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.scoreboard::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scoreboard::-webkit-scrollbar-thumb {
|
||||
border-radius: 10px;
|
||||
background: rgb(238 185 73 / 0.3);
|
||||
}
|
||||
|
||||
#app.match-live .scoreboard {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.score-side {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 114px);
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.score-side.right {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.team-score {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 1px auto;
|
||||
gap: 6px;
|
||||
width: 114px;
|
||||
min-height: 72px;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
padding: 8px 9px;
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 900;
|
||||
text-align: left;
|
||||
text-shadow: 1px 1px 2px #000;
|
||||
transition:
|
||||
filter 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.team-score:hover {
|
||||
filter: brightness(1.16);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.team-score.is-focused {
|
||||
box-shadow:
|
||||
inset 0 0 0 2px rgb(255 244 209 / 0.92),
|
||||
0 0 18px rgb(227 178 79 / 0.26);
|
||||
}
|
||||
|
||||
.team-score:disabled {
|
||||
cursor: default;
|
||||
filter: grayscale(0.6) brightness(0.68);
|
||||
}
|
||||
|
||||
.team-score:disabled:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.team-score-name {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.team-score-rule {
|
||||
width: 100%;
|
||||
background: var(--team-color);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.team-score-count {
|
||||
justify-self: end;
|
||||
color: #fff2c8;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.battle-notice {
|
||||
position: fixed;
|
||||
top: clamp(12px, 2vw, 20px);
|
||||
left: 50%;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: min(420px, 72vmin, calc(100vw - 64px));
|
||||
min-height: 38px;
|
||||
border: 1px solid rgb(238 185 73 / 0.26);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
background: rgb(8 10 7 / 0.68);
|
||||
color: #ffe8b4;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
text-shadow: 1px 1px 2px #000;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -10px);
|
||||
transition:
|
||||
opacity 260ms ease,
|
||||
transform 260ms ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
#app.match-live .battle-notice.is-visible {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
@media (min-width: 961px) {
|
||||
#app.match-live .battle-notice {
|
||||
right: auto;
|
||||
left: 50%;
|
||||
width: min(420px, 72vmin, calc(100vw - var(--drawer-width) - var(--score-rail-width) - 56px));
|
||||
transform: translate(-50%, -10px);
|
||||
}
|
||||
|
||||
#app.match-live .battle-notice.is-visible {
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
#app.match-live.drawer-collapsed .battle-notice {
|
||||
right: auto;
|
||||
width: min(420px, 72vmin, calc(100vw - 64px));
|
||||
}
|
||||
}
|
||||
|
||||
.kill-log {
|
||||
position: fixed;
|
||||
bottom: clamp(14px, 3vw, 26px);
|
||||
left: var(--score-panel-left);
|
||||
z-index: 4;
|
||||
width: min(370px, calc(100vw - 32px));
|
||||
max-height: min(34vh, 292px);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(238 185 73 / 0.3) transparent;
|
||||
border: 1px solid rgb(238 185 73 / 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: rgb(4 6 4 / 0.58);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(16px);
|
||||
transition:
|
||||
opacity 260ms ease,
|
||||
transform 260ms ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.kill-log::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.kill-log::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.kill-log::-webkit-scrollbar-thumb {
|
||||
border-radius: 10px;
|
||||
background: rgb(238 185 73 / 0.3);
|
||||
}
|
||||
|
||||
#app.match-live .kill-log.has-entries {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.kill-log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.kill-log-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 54px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 54px;
|
||||
border: 1px solid rgb(255 244 209 / 0.12);
|
||||
border-radius: 6px;
|
||||
padding: 7px 9px;
|
||||
background: rgb(8 10 7 / 0.74);
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06);
|
||||
animation: kill-log-entry 180ms ease both;
|
||||
}
|
||||
|
||||
.kill-log-fighter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kill-log-fighter.killer {
|
||||
border-left: 3px solid var(--killer-color);
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.kill-log-fighter.victim {
|
||||
flex-direction: row-reverse;
|
||||
border-right: 3px solid var(--victim-color);
|
||||
padding-right: 6px;
|
||||
justify-content: end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.kill-log-avatar {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid rgb(255 244 209 / 0.16);
|
||||
border-radius: 6px;
|
||||
background-color: rgb(255 246 216 / 0.08);
|
||||
background-position: -24px -16px;
|
||||
background-repeat: no-repeat;
|
||||
background-size: auto 86px;
|
||||
image-rendering: pixelated;
|
||||
box-shadow: inset 0 -10px 18px rgb(0 0 0 / 0.22);
|
||||
}
|
||||
|
||||
.kill-log-fighter.victim .kill-log-avatar::before,
|
||||
.kill-log-fighter.victim .kill-log-avatar::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 1px;
|
||||
width: 14px;
|
||||
height: 2px;
|
||||
border: 1px solid rgb(255 216 212 / 0.22);
|
||||
border-radius: 999px;
|
||||
background: #f24a42;
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(48 4 3 / 0.7),
|
||||
0 0 5px rgb(227 54 46 / 0.6);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.kill-log-fighter.victim .kill-log-avatar::before {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.kill-log-fighter.victim .kill-log-avatar::after {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.kill-log-copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kill-log-team,
|
||||
.kill-log-member {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kill-log-team {
|
||||
min-width: 0;
|
||||
color: #fff7df;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 900;
|
||||
text-shadow: 1px 1px 2px #000;
|
||||
}
|
||||
|
||||
.kill-log-member {
|
||||
flex: 0 0 auto;
|
||||
color: #ead8ad;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.kill-log-action {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kill-log-action-text {
|
||||
color: #ffdc93;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 950;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.kill-log-weapon {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
place-self: center;
|
||||
border: 1px solid rgb(238 185 73 / 0.28);
|
||||
border-radius: 999px;
|
||||
background: rgb(255 246 216 / 0.08);
|
||||
box-shadow: 0 0 16px rgb(227 89 59 / 0.16);
|
||||
}
|
||||
|
||||
.kill-log-weapon::before,
|
||||
.kill-log-weapon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 18px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #ffe8b4 0 70%, #b93c2f 70% 100%);
|
||||
box-shadow: 0 0 8px rgb(255 226 166 / 0.3);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.kill-log-weapon::before {
|
||||
transform: translate(-50%, -50%) rotate(42deg);
|
||||
}
|
||||
|
||||
.kill-log-weapon::after {
|
||||
transform: translate(-50%, -50%) rotate(-42deg);
|
||||
}
|
||||
|
||||
.victory-celebration {
|
||||
position: fixed;
|
||||
z-index: 9;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
inset: 0;
|
||||
background: rgb(4 6 4 / 0.2);
|
||||
isolation: isolate;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
transition:
|
||||
opacity 220ms ease,
|
||||
transform 220ms ease;
|
||||
}
|
||||
|
||||
.victory-celebration.is-leaving {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.victory-celebration::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
width: min(122vmin, 1240px);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle, rgb(255 233 166 / 0.18) 0 18%, rgb(227 178 79 / 0.12) 31%, transparent 66%);
|
||||
animation: victory-glow 1.8s ease-out both;
|
||||
}
|
||||
|
||||
.victory-celebration.is-draw::before {
|
||||
background:
|
||||
radial-gradient(circle, rgb(255 247 223 / 0.16) 0 18%, rgb(227 178 79 / 0.1) 31%, transparent 62%);
|
||||
}
|
||||
|
||||
.victory-rays {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
width: min(112vmin, 1120px);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: repeating-conic-gradient(
|
||||
from -4deg,
|
||||
rgb(255 233 166 / 0.18) 0 8deg,
|
||||
transparent 8deg 18deg
|
||||
);
|
||||
opacity: 0.54;
|
||||
mask-image: radial-gradient(circle, #000 0 18%, transparent 66%);
|
||||
animation: victory-rays-in 1.1s ease-out both, victory-rays-turn 11s linear infinite;
|
||||
}
|
||||
|
||||
.victory-celebration.is-draw .victory-rays {
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
.victory-confetti {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.victory-confetti-piece {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: block;
|
||||
width: clamp(6px, 0.8vw, 11px);
|
||||
height: clamp(10px, 1.2vw, 18px);
|
||||
border-radius: 8px;
|
||||
background: var(--confetti-color);
|
||||
box-shadow: 0 0 12px rgb(255 230 166 / 0.22);
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) rotate(var(--confetti-tilt)) scale(0.3);
|
||||
animation: victory-confetti-burst var(--confetti-duration) cubic-bezier(0.15, 0.84, 0.35, 1) var(--confetti-delay) both;
|
||||
}
|
||||
|
||||
.victory-confetti-piece:nth-child(3n) {
|
||||
width: clamp(10px, 1vw, 15px);
|
||||
height: clamp(6px, 0.72vw, 10px);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.victory-banner {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
width: min(calc(100vw - 36px), 760px);
|
||||
min-height: clamp(108px, 18vw, 170px);
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
border: 2px solid #f1c45d;
|
||||
border-radius: 8px;
|
||||
padding: clamp(1.25rem, 3.8vw, 2rem) clamp(1.3rem, 5.4vw, 3.4rem);
|
||||
background:
|
||||
linear-gradient(135deg, rgb(18 21 13 / 0.98), rgb(3 5 4 / 0.92)),
|
||||
rgb(4 6 4 / 0.9);
|
||||
color: #fff7df;
|
||||
font-size: clamp(1.65rem, 5vw, 3rem);
|
||||
font-weight: 950;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.12;
|
||||
text-align: center;
|
||||
text-wrap: balance;
|
||||
text-shadow:
|
||||
0 2px 0 rgb(55 36 8 / 0.56),
|
||||
0 0 24px rgb(255 226 153 / 0.28);
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(255 237 187 / 0.2) inset,
|
||||
0 0 42px rgb(227 178 79 / 0.44),
|
||||
0 24px 90px rgb(0 0 0 / 0.58);
|
||||
animation: banner-in 0.64s cubic-bezier(0.16, 0.9, 0.25, 1.2);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.victory-banner::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -40% auto -40% -36%;
|
||||
width: 28%;
|
||||
background: linear-gradient(90deg, transparent, rgb(255 248 223 / 0.6), transparent);
|
||||
transform: skewX(-18deg);
|
||||
animation: victory-banner-sheen 1s 0.28s ease-out both;
|
||||
}
|
||||
|
||||
.victory-banner::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
border: 1px solid rgb(255 225 151 / 0.24);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.victory-banner-message {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
animation: victory-message-pulse 720ms 80ms ease-out both;
|
||||
}
|
||||
|
||||
.victory-celebration.is-draw .victory-banner {
|
||||
border-color: #d8c28d;
|
||||
box-shadow:
|
||||
0 0 0 1px rgb(255 237 187 / 0.14) inset,
|
||||
0 0 28px rgb(227 178 79 / 0.24),
|
||||
0 24px 90px rgb(0 0 0 / 0.52);
|
||||
}
|
||||
|
||||
#app.match-paused .arena-shell::after {
|
||||
content: "일시정지";
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 6;
|
||||
border: 1px solid rgb(238 185 73 / 0.34);
|
||||
border-radius: 8px;
|
||||
padding: 14px 26px;
|
||||
background: rgb(5 7 5 / 0.76);
|
||||
color: #ffe8b4;
|
||||
font-size: clamp(1.3rem, 4vw, 2rem);
|
||||
font-weight: 950;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 18px 60px rgb(0 0 0 / 0.46);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.match-status {
|
||||
position: fixed;
|
||||
bottom: clamp(14px, 3vw, 26px);
|
||||
left: 50%;
|
||||
z-index: 4;
|
||||
width: min(980px, calc(100vw - 32px));
|
||||
min-height: 48px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(238 185 73 / 0.28);
|
||||
border-radius: 8px;
|
||||
padding: 13px 0;
|
||||
background: rgb(8 10 7 / 0.74);
|
||||
color: #ffe2a6;
|
||||
font-weight: 900;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, calc(100% + 28px));
|
||||
transition:
|
||||
opacity 420ms ease,
|
||||
transform 420ms ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
#app.status-active:not(.match-live) .match-status {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
#app.match-live .match-status {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 961px) {
|
||||
#app.match-live .match-status {
|
||||
left: calc((100vw - var(--drawer-width)) / 2);
|
||||
width: min(760px, calc(100vw - var(--drawer-width) - 32px));
|
||||
}
|
||||
|
||||
#app.match-live.drawer-collapsed .match-status {
|
||||
left: 50%;
|
||||
width: min(980px, calc(100vw - 32px));
|
||||
}
|
||||
}
|
||||
|
||||
.status-track {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
min-width: 200%;
|
||||
gap: 64px;
|
||||
animation: status-marquee 22s linear infinite;
|
||||
}
|
||||
|
||||
.status-track span {
|
||||
flex: 0 0 auto;
|
||||
min-width: calc(50vw - 32px);
|
||||
padding-left: 28px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
.battle-preview {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
opacity: 0.84;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 420ms ease,
|
||||
transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
#app.match-live .battle-preview {
|
||||
opacity: 0;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.preview-fighter {
|
||||
position: absolute;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background-repeat: no-repeat;
|
||||
background-size: auto 100px;
|
||||
image-rendering: pixelated;
|
||||
transform-origin: center;
|
||||
filter:
|
||||
drop-shadow(0 18px 22px rgb(0 0 0 / 0.68))
|
||||
saturate(1.14)
|
||||
brightness(1.12);
|
||||
animation:
|
||||
preview-attack var(--sprite-speed) steps(var(--sprite-steps)) infinite,
|
||||
preview-breathe 1800ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
.preview-knight {
|
||||
--sprite-end: -600px;
|
||||
--sprite-scale: 5.2;
|
||||
--sprite-speed: 840ms;
|
||||
--sprite-steps: 6;
|
||||
left: 10vw;
|
||||
top: 48vh;
|
||||
background-image: url("/assets/characters/knight/Knight-Attack01.png");
|
||||
transform: scale(var(--sprite-scale));
|
||||
}
|
||||
|
||||
.preview-orc {
|
||||
--sprite-end: -500px;
|
||||
--sprite-scale: 5.35;
|
||||
--sprite-speed: 760ms;
|
||||
--sprite-steps: 5;
|
||||
right: 9vw;
|
||||
top: 46vh;
|
||||
background-image: url("/assets/characters/orc/Orc-Attack01.png");
|
||||
transform: scaleX(-1) scale(var(--sprite-scale));
|
||||
}
|
||||
|
||||
.preview-wizard {
|
||||
--sprite-end: -500px;
|
||||
--sprite-scale: 4.35;
|
||||
--sprite-speed: 980ms;
|
||||
--sprite-steps: 5;
|
||||
left: 56vw;
|
||||
top: 24vh;
|
||||
background-image: url("/assets/characters/wizard/Wizard-Attack01.png");
|
||||
opacity: 0.58;
|
||||
transform: scaleX(-1) scale(var(--sprite-scale));
|
||||
}
|
||||
|
||||
.preview-strike {
|
||||
position: absolute;
|
||||
width: 160px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, transparent, rgb(255 229 156 / 0.86), transparent);
|
||||
box-shadow: 0 0 24px rgb(227 89 59 / 0.5);
|
||||
opacity: 0;
|
||||
transform-origin: center;
|
||||
animation: preview-strike 980ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
.preview-strike-a {
|
||||
left: 38vw;
|
||||
top: 54vh;
|
||||
transform: rotate(-18deg);
|
||||
}
|
||||
|
||||
.preview-strike-b {
|
||||
right: 31vw;
|
||||
top: 42vh;
|
||||
transform: rotate(22deg);
|
||||
animation-delay: 260ms;
|
||||
}
|
||||
|
||||
.intro-stage {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: clamp(24px, 5vw, 56px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 420ms ease,
|
||||
transform 620ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
#app.match-live .intro-stage {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
#app.match-live .intro-content {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.intro-content {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 22px;
|
||||
text-align: center;
|
||||
pointer-events: auto;
|
||||
animation: intro-rise 760ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
transition:
|
||||
transform 560ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
opacity 360ms ease;
|
||||
}
|
||||
|
||||
#app.options-open:not(.match-live) .intro-content {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.arena-logo {
|
||||
margin: 0;
|
||||
color: #fff4d1;
|
||||
font-size: clamp(4rem, 16vw, 11rem);
|
||||
font-weight: 950;
|
||||
letter-spacing: 0;
|
||||
line-height: 0.9;
|
||||
text-shadow:
|
||||
0 2px 0 #ad4d37,
|
||||
0 14px 42px rgb(0 0 0 / 0.72),
|
||||
0 0 40px rgb(230 173 71 / 0.28);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.arena-logo .small-text {
|
||||
font-size: 0.7em;
|
||||
margin-top: 0.05em;
|
||||
}
|
||||
|
||||
.arena-logo span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.arena-meta {
|
||||
position: fixed;
|
||||
right: clamp(10px, 2vw, 18px);
|
||||
bottom: clamp(10px, 2vw, 18px);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
transition: opacity 220ms ease;
|
||||
}
|
||||
|
||||
.visitor-count,
|
||||
.about-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
margin: 0;
|
||||
border: 1px solid rgb(238 185 73 / 0.22);
|
||||
border-radius: 999px;
|
||||
padding: 5px 12px;
|
||||
background: rgb(8 10 7 / 0.68);
|
||||
color: #e7c879;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
backdrop-filter: blur(10px);
|
||||
pointer-events: auto;
|
||||
transition:
|
||||
background 180ms ease,
|
||||
border-color 180ms ease,
|
||||
transform 180ms ease,
|
||||
opacity 220ms ease;
|
||||
}
|
||||
|
||||
.visitor-count {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
#app.match-live .visitor-count {
|
||||
opacity: 0.86;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.about-button {
|
||||
min-width: 72px;
|
||||
color: #ffe8b4;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 0.22);
|
||||
}
|
||||
|
||||
.about-button:hover {
|
||||
border-color: rgb(238 185 73 / 0.42);
|
||||
background: rgb(255 246 216 / 0.14);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.start-button {
|
||||
min-width: 180px;
|
||||
padding: 0 30px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#app.options-open:not(.match-live) .start-button {
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
|
@ -0,0 +1,308 @@
|
|||
@media (max-width: 960px) {
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
--arena-gap: 0px;
|
||||
--mobile-game-size: min(100vw, calc(100svh - var(--score-band-height)));
|
||||
--mobile-kill-log-top: calc(var(--score-band-height) + var(--mobile-game-size) + 10px);
|
||||
--mobile-options-button-width: 54px;
|
||||
--mobile-options-gap: 8px;
|
||||
--mobile-team-card-width: clamp(56px, calc((100vw - 120px) / 4), 72px);
|
||||
--mobile-visitor-space: calc(104px + env(safe-area-inset-bottom));
|
||||
--score-band-height: 132px;
|
||||
--score-panel-left: 10px;
|
||||
--score-panel-width: calc(100vw - 20px);
|
||||
--score-rail-width: 0px;
|
||||
}
|
||||
|
||||
#app.match-live .arena-shell {
|
||||
place-items: start center;
|
||||
}
|
||||
|
||||
#app.match-live #game {
|
||||
width: var(--mobile-game-size);
|
||||
height: var(--mobile-game-size);
|
||||
margin-top: var(--score-band-height);
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.intro-stage {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.arena-logo {
|
||||
font-size: clamp(3.8rem, 22vw, 7rem);
|
||||
}
|
||||
|
||||
.fighter-entry {
|
||||
width: 100vw;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
#app.match-live .fighter-entry {
|
||||
top: calc(10px + env(safe-area-inset-top));
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
width: auto;
|
||||
max-height: calc(100svh - 20px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#app.match-live.drawer-collapsed .fighter-entry {
|
||||
top: calc(22px + env(safe-area-inset-top));
|
||||
right: 10px;
|
||||
left: auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
#app.match-live .fighter-entry h2 {
|
||||
font-size: clamp(1.45rem, 7vw, 1.8rem);
|
||||
}
|
||||
|
||||
#app.match-live .fighter-entry textarea {
|
||||
height: 112px;
|
||||
min-height: 112px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
#app.match-live .fighter-entry fieldset {
|
||||
gap: 7px;
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
#app.match-live .fighter-entry form {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#app.match-live .entry-copy {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#app.match-live .eyebrow {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
#app.match-live label,
|
||||
#app.match-live .spawn-placement-label {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
#app.match-live input:not([type="range"]):not([type="radio"]),
|
||||
#app.match-live textarea {
|
||||
min-height: 40px;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
#app.match-live textarea {
|
||||
padding-block: 9px;
|
||||
}
|
||||
|
||||
#app.match-live .team-size-number {
|
||||
width: 64px;
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
#app.match-live .spawn-placement-option span {
|
||||
min-height: 36px;
|
||||
padding: 6px;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
#app.match-live .match-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#app.match-live .match-actions button {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
#app.match-live .drawer-toggle {
|
||||
min-width: 116px;
|
||||
}
|
||||
|
||||
#app.match-live.drawer-collapsed .drawer-toggle {
|
||||
width: var(--mobile-options-button-width);
|
||||
min-width: var(--mobile-options-button-width);
|
||||
padding-inline: 6px;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
#app.match-live.drawer-collapsed .drawer-toggle::before {
|
||||
content: "옵션";
|
||||
font-size: 0.78rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.battle-preview {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.preview-knight {
|
||||
left: -8vw;
|
||||
top: 52vh;
|
||||
}
|
||||
|
||||
.preview-orc {
|
||||
right: -9vw;
|
||||
top: 49vh;
|
||||
}
|
||||
|
||||
.preview-wizard {
|
||||
left: 48vw;
|
||||
top: 21vh;
|
||||
}
|
||||
|
||||
.scoreboard {
|
||||
align-items: flex-start;
|
||||
top: 10px;
|
||||
left: var(--score-panel-left);
|
||||
width: var(--score-panel-width);
|
||||
max-height: calc(var(--score-band-height) - 12px);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 9px min(148px, 38vw) 9px 9px;
|
||||
scrollbar-color: rgb(238 185 73 / 0.38) transparent;
|
||||
scrollbar-width: thin;
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
#app.match-live.drawer-collapsed .scoreboard {
|
||||
width: calc(
|
||||
100vw - 20px - var(--mobile-options-button-width) - var(--mobile-options-gap)
|
||||
);
|
||||
padding-right: 9px;
|
||||
}
|
||||
|
||||
.score-side {
|
||||
display: grid;
|
||||
grid-template-columns: none;
|
||||
grid-auto-columns: var(--mobile-team-card-width);
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: repeat(2, 48px);
|
||||
gap: 5px 5px;
|
||||
}
|
||||
|
||||
.scoreboard::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.scoreboard::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scoreboard::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: rgb(238 185 73 / 0.38);
|
||||
}
|
||||
|
||||
.team-score {
|
||||
width: auto;
|
||||
min-height: 48px;
|
||||
height: 48px;
|
||||
gap: 3px;
|
||||
padding: 5px 6px;
|
||||
font-size: 0.66rem;
|
||||
grid-template-rows: 1fr 1px auto;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.team-score-count {
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.team-score.is-focused {
|
||||
box-shadow: inset 0 0 0 2px rgb(255 244 209 / 0.92);
|
||||
}
|
||||
|
||||
.battle-notice {
|
||||
top: calc(var(--score-band-height) + 8px);
|
||||
right: 24px;
|
||||
left: 24px;
|
||||
width: auto;
|
||||
padding-inline: 12px;
|
||||
font-size: 0.76rem;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
#app.match-live .battle-notice.is-visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.kill-log {
|
||||
top: var(--mobile-kill-log-top);
|
||||
bottom: auto;
|
||||
left: 10px;
|
||||
width: calc(100vw - 20px);
|
||||
max-height: calc(100svh - var(--mobile-kill-log-top) - var(--mobile-visitor-space));
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#app.match-live .victory-celebration {
|
||||
padding:
|
||||
var(--score-band-height)
|
||||
14px
|
||||
min(30svh, 230px);
|
||||
}
|
||||
|
||||
.victory-banner {
|
||||
width: min(calc(100vw - 48px), 520px);
|
||||
min-height: 92px;
|
||||
padding: 1rem 1.1rem;
|
||||
font-size: clamp(1.35rem, 7vw, 2rem);
|
||||
}
|
||||
|
||||
.match-status {
|
||||
bottom: 10px;
|
||||
width: calc(100vw - 20px);
|
||||
}
|
||||
|
||||
.arena-meta {
|
||||
right: 10px;
|
||||
bottom: calc(10px + env(safe-area-inset-bottom));
|
||||
z-index: 10;
|
||||
gap: 8px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.visitor-count {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.about-button {
|
||||
min-width: 68px;
|
||||
min-height: 26px;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.about-backdrop {
|
||||
align-items: end;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.about-dialog {
|
||||
width: 100%;
|
||||
max-height: calc(100svh - 24px);
|
||||
}
|
||||
|
||||
.about-header {
|
||||
padding: 18px 18px 12px;
|
||||
}
|
||||
|
||||
.about-tabs {
|
||||
padding: 0 18px 12px;
|
||||
}
|
||||
|
||||
.about-panel {
|
||||
padding: 16px 18px 22px;
|
||||
}
|
||||
|
||||
.about-field-row {
|
||||
grid-template-columns: 78px minmax(0, 1fr);
|
||||
min-height: 48px;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,566 @@
|
|||
.start-button,
|
||||
form button[type="submit"],
|
||||
.pause-button,
|
||||
.restart-button {
|
||||
min-height: 52px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #e56443, #b93c2f);
|
||||
color: #fff7df;
|
||||
font-weight: 900;
|
||||
box-shadow:
|
||||
0 18px 44px rgb(0 0 0 / 0.36),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.2);
|
||||
transition:
|
||||
background 180ms ease,
|
||||
transform 180ms ease,
|
||||
box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.start-button:hover,
|
||||
form button[type="submit"]:hover,
|
||||
.pause-button:hover,
|
||||
.restart-button:hover {
|
||||
background: linear-gradient(180deg, #f0754f, #c84636);
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 22px 52px rgb(0 0 0 / 0.42),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.24);
|
||||
}
|
||||
|
||||
.pause-button,
|
||||
.restart-button {
|
||||
display: none;
|
||||
border: 1px solid rgb(238 185 73 / 0.3);
|
||||
background: rgb(255 246 216 / 0.08);
|
||||
color: #ffe8b4;
|
||||
}
|
||||
|
||||
.pause-button:hover,
|
||||
.restart-button:hover {
|
||||
background: rgb(255 246 216 / 0.14);
|
||||
}
|
||||
|
||||
#app.match-live .pause-button,
|
||||
#app.match-live .restart-button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#app.match-live .match-actions {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
#app.match-live .match-actions button[type="submit"] {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
#app.match-paused .pause-button {
|
||||
background: linear-gradient(180deg, #e3b24f, #9a6c24);
|
||||
color: #120f08;
|
||||
}
|
||||
|
||||
#app.match-ended .pause-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drawer-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
background: rgb(4 5 4 / 0.42);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 320ms ease;
|
||||
}
|
||||
|
||||
#app.options-open .drawer-scrim {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#app.match-live .drawer-scrim {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drawer-toggle {
|
||||
display: none;
|
||||
min-height: 40px;
|
||||
border: 1px solid rgb(238 185 73 / 0.28);
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
background: rgb(12 15 11 / 0.84);
|
||||
color: #ffe8b4;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 16px 38px rgb(0 0 0 / 0.36);
|
||||
transition:
|
||||
background 180ms ease,
|
||||
transform 180ms ease;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.drawer-toggle:hover {
|
||||
background: rgb(255 246 216 / 0.14);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
#app.match-live .drawer-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fighter-entry {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 7;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 24px;
|
||||
width: var(--drawer-width);
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid rgb(239 199 103 / 0.22);
|
||||
padding: clamp(22px, 4vw, 34px);
|
||||
background:
|
||||
linear-gradient(180deg, rgb(29 33 22 / 0.94), rgb(13 16 12 / 0.96)),
|
||||
#11140f;
|
||||
box-shadow: -28px 0 80px rgb(0 0 0 / 0.52);
|
||||
transform: translateX(104%);
|
||||
transition:
|
||||
opacity 260ms ease,
|
||||
transform 520ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
#app.options-open .fighter-entry {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
#app.match-live .fighter-entry {
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
height: auto;
|
||||
max-height: calc(100vh - 48px);
|
||||
gap: 16px;
|
||||
border: 1px solid rgb(239 199 103 / 0.22);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
#app.match-live.drawer-collapsed .fighter-entry {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
border-color: transparent;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
#app.match-live .drawer-close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#app.match-live .fighter-entry h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
#app.match-live .fighter-entry textarea {
|
||||
min-height: 190px;
|
||||
}
|
||||
|
||||
#app.match-live .fighter-entry fieldset {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#app.match-live.drawer-collapsed .entry-copy,
|
||||
#app.match-live.drawer-collapsed .fighter-entry form {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#app.match-live.drawer-collapsed .drawer-header {
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.drawer-header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entry-copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: #e3b24f;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #fff3d2;
|
||||
font-size: clamp(1.7rem, 4vw, 2.5rem);
|
||||
line-height: 1.05;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.drawer-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgb(238 185 73 / 0.22);
|
||||
border-radius: 8px;
|
||||
background: rgb(255 246 216 / 0.08);
|
||||
color: #f8deb0;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.drawer-close:hover {
|
||||
background: rgb(255 246 216 / 0.14);
|
||||
}
|
||||
|
||||
.about-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: clamp(16px, 4vw, 34px);
|
||||
background: rgb(3 5 4 / 0.66);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.about-backdrop[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.about-dialog {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
width: min(560px, calc(100vw - 32px));
|
||||
max-height: min(760px, calc(100svh - 32px));
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(239 199 103 / 0.28);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(180deg, rgb(29 33 22 / 0.98), rgb(10 13 9 / 0.98)),
|
||||
#11140f;
|
||||
box-shadow:
|
||||
0 24px 100px rgb(0 0 0 / 0.62),
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.06);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.about-header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: clamp(20px, 4vw, 28px) clamp(20px, 4vw, 30px) 14px;
|
||||
}
|
||||
|
||||
.about-close {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid rgb(238 185 73 / 0.22);
|
||||
border-radius: 8px;
|
||||
background: rgb(255 246 216 / 0.08);
|
||||
color: #f8deb0;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.about-close:hover {
|
||||
background: rgb(255 246 216 / 0.14);
|
||||
}
|
||||
|
||||
.about-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
padding: 0 clamp(20px, 4vw, 30px) 14px;
|
||||
border-bottom: 1px solid rgb(238 185 73 / 0.16);
|
||||
}
|
||||
|
||||
.about-tab {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
background: rgb(255 246 216 / 0.06);
|
||||
color: #ead8ad;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.about-tab[aria-selected="true"] {
|
||||
border-color: rgb(238 185 73 / 0.36);
|
||||
background: #323822;
|
||||
color: #fff7df;
|
||||
}
|
||||
|
||||
.about-panel {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: clamp(18px, 4vw, 26px) clamp(20px, 4vw, 30px) clamp(22px, 5vw, 34px);
|
||||
}
|
||||
|
||||
.about-fields {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.about-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 50px;
|
||||
border-bottom: 1px solid rgb(238 185 73 / 0.14);
|
||||
}
|
||||
|
||||
.about-field-row:first-child {
|
||||
border-top: 1px solid rgb(238 185 73 / 0.14);
|
||||
}
|
||||
|
||||
.about-field-row dt {
|
||||
color: #e3b24f;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 950;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.about-field-row dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: #fff7df;
|
||||
font-weight: 800;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.about-field-row a,
|
||||
.about-markdown a {
|
||||
color: #85dcc7;
|
||||
text-decoration-color: rgb(133 220 199 / 0.42);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.about-markdown {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
color: #ead8ad;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.56;
|
||||
}
|
||||
|
||||
.about-markdown :is(h3, h4, h5, h6, p, ul, blockquote) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.about-markdown h3,
|
||||
.about-markdown h4,
|
||||
.about-markdown h5,
|
||||
.about-markdown h6 {
|
||||
color: #fff3d2;
|
||||
font-size: 1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.about-markdown blockquote {
|
||||
border-left: 3px solid rgb(238 185 73 / 0.36);
|
||||
padding: 4px 0 4px 16px;
|
||||
color: #c4b693;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.about-markdown hr {
|
||||
margin: 8px 0;
|
||||
border: 0;
|
||||
border-top: 1px solid rgb(238 185 73 / 0.16);
|
||||
}
|
||||
|
||||
.about-markdown code {
|
||||
border: 1px solid rgb(238 185 73 / 0.14);
|
||||
border-radius: 4px;
|
||||
padding: 2px 5px;
|
||||
background: rgb(255 246 216 / 0.08);
|
||||
color: #f1c761;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.about-markdown li {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.about-markdown li strong {
|
||||
color: #fff3d2;
|
||||
}
|
||||
|
||||
.about-markdown ul {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.about-empty {
|
||||
color: #bfae83;
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.match-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: 1px solid rgb(238 185 73 / 0.22);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: rgb(5 7 5 / 0.26);
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0 6px;
|
||||
color: #e3b24f;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.team-size-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.team-size-number {
|
||||
width: 88px;
|
||||
min-width: 88px;
|
||||
padding-inline: 10px;
|
||||
text-align: center;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #ead8ad;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
input:not([type="range"]):not([type="radio"]),
|
||||
textarea {
|
||||
min-height: 48px;
|
||||
border: 1px solid rgb(238 185 73 / 0.28);
|
||||
border-radius: 8px;
|
||||
padding: 0 14px;
|
||||
background: #232719;
|
||||
color: #fff7df;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 258px;
|
||||
resize: vertical;
|
||||
padding-block: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: #e3b24f;
|
||||
}
|
||||
|
||||
.spawn-placement-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spawn-placement-label {
|
||||
color: #ead8ad;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.spawn-placement-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
border: 1px solid rgb(238 185 73 / 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
background: #1d2116;
|
||||
}
|
||||
|
||||
.spawn-placement-option {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.spawn-placement-option input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.spawn-placement-option span {
|
||||
display: grid;
|
||||
min-height: 44px;
|
||||
place-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
color: #ead8ad;
|
||||
text-align: center;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 900;
|
||||
line-height: 1.25;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.spawn-placement-option input:checked + span {
|
||||
border-color: rgb(238 185 73 / 0.36);
|
||||
background: #323822;
|
||||
color: #fff7df;
|
||||
}
|
||||
|
||||
.spawn-placement-option input:focus-visible + span {
|
||||
outline: 2px solid #f1c761;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
|
@ -9,41 +9,59 @@ export function updateScoreboard(
|
|||
return;
|
||||
}
|
||||
|
||||
containerLeft.innerHTML = "";
|
||||
containerRight.innerHTML = "";
|
||||
const currentTeamElements = [...containerLeft.children];
|
||||
const teamsChanged =
|
||||
currentTeamElements.length !== teams.length ||
|
||||
teams.some((team, index) => currentTeamElements[index]?.dataset.teamId !== String(team.id));
|
||||
|
||||
teams.forEach((team) => {
|
||||
const aliveCount = fighters.filter((f) => f.team.id === team.id && !f.isDead).length;
|
||||
if (teamsChanged) {
|
||||
containerLeft.replaceChildren(...teams.map((team) => createTeamElement(team.id)));
|
||||
}
|
||||
|
||||
if (containerRight.childElementCount > 0) {
|
||||
containerRight.replaceChildren();
|
||||
}
|
||||
|
||||
teams.forEach((team, index) => {
|
||||
const teamEl = containerLeft.children[index];
|
||||
const aliveCount = fighters.filter(
|
||||
(fighter) => fighter.team.id === team.id && !fighter.isDead,
|
||||
).length;
|
||||
|
||||
const teamEl = document.createElement("button");
|
||||
teamEl.className = "team-score";
|
||||
teamEl.type = "button";
|
||||
teamEl.disabled = aliveCount === 0;
|
||||
teamEl.setAttribute("aria-label", `${team.label} 생존 캐릭터 무작위 시점 고정`);
|
||||
teamEl.style.setProperty("--team-color", team.color);
|
||||
teamEl.style.backgroundColor = `${team.color}33`;
|
||||
teamEl.style.borderLeft = `4px solid ${team.color}`;
|
||||
teamEl.classList.toggle("is-focused", selectedFighterTeamId === team.id);
|
||||
|
||||
if (selectedFighterTeamId === team.id) {
|
||||
teamEl.classList.add("is-focused");
|
||||
}
|
||||
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "team-score-name";
|
||||
const labelEl = teamEl.querySelector(".team-score-name");
|
||||
labelEl.textContent = team.label;
|
||||
|
||||
const ruleEl = document.createElement("span");
|
||||
ruleEl.className = "team-score-rule";
|
||||
|
||||
const countEl = document.createElement("span");
|
||||
countEl.className = "team-score-count";
|
||||
const countEl = teamEl.querySelector(".team-score-count");
|
||||
countEl.textContent = `${aliveCount}명`;
|
||||
|
||||
teamEl.addEventListener("click", () => {
|
||||
teamEl.onclick = () => {
|
||||
onTeamClick(team.id);
|
||||
});
|
||||
|
||||
teamEl.append(labelEl, ruleEl, countEl);
|
||||
containerLeft.appendChild(teamEl);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createTeamElement(teamId) {
|
||||
const teamEl = document.createElement("button");
|
||||
teamEl.className = "team-score";
|
||||
teamEl.type = "button";
|
||||
teamEl.dataset.teamId = String(teamId);
|
||||
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "team-score-name";
|
||||
|
||||
const ruleEl = document.createElement("span");
|
||||
ruleEl.className = "team-score-rule";
|
||||
|
||||
const countEl = document.createElement("span");
|
||||
countEl.className = "team-score-count";
|
||||
|
||||
teamEl.append(labelEl, ruleEl, countEl);
|
||||
return teamEl;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { DEFAULT_SPAWN_PLACEMENT, NICKNAME_LENGTH } from "../constants.js";
|
||||
import { FIGHTER, SPAWN } from "../constants.js";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
names: "arena.match.playerNames",
|
||||
|
|
@ -96,7 +96,7 @@ function getElements(selector) {
|
|||
function nicknameValues(value) {
|
||||
return value
|
||||
.split(/\r?\n|,/)
|
||||
.map((name) => name.trim().slice(0, NICKNAME_LENGTH))
|
||||
.map((name) => name.trim().slice(0, FIGHTER.NICKNAME_LENGTH))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
|
|
@ -165,12 +165,12 @@ function saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput) {
|
|||
}
|
||||
|
||||
function selectedSpawnPlacement(inputs) {
|
||||
return inputs.find((input) => input.checked)?.value ?? DEFAULT_SPAWN_PLACEMENT;
|
||||
return inputs.find((input) => input.checked)?.value ?? SPAWN.DEFAULT_PLACEMENT;
|
||||
}
|
||||
|
||||
function setSpawnPlacement(inputs, value) {
|
||||
const savedInput = inputs.find((input) => input.value === value);
|
||||
const defaultInput = inputs.find((input) => input.value === DEFAULT_SPAWN_PLACEMENT);
|
||||
const defaultInput = inputs.find((input) => input.value === SPAWN.DEFAULT_PLACEMENT);
|
||||
const nextInput = savedInput ?? defaultInput;
|
||||
|
||||
if (nextInput) {
|
||||
|
|
|
|||
86
todo.md
86
todo.md
|
|
@ -109,12 +109,12 @@
|
|||
18. 치명타 적중 표기 추가 (완료)
|
||||
- **조치 사항**:
|
||||
- 공격 프로필의 치명타 판정을 실제 적중 처리까지 전달해 전투 타입별 적중 연출이 같은 흐름을 사용하도록 정리.
|
||||
- 치명타 적중 시 대상 위에 `Critical!` 문구를 띄우고 즉시 처치와 카메라 흔들림이 함께 적용되도록 `applyHit()`를 보강.
|
||||
- 치명타 적중 시 대상 위에 `Critical!` 문구를 띄우고 즉시 처치가 적용되도록 `applyHit()`를 보강. (카메라 흔들림은 이후 메테오 착탄 연출로 이전)
|
||||
|
||||
19. 리스폰 배치 설정 구분 추가 (완료)
|
||||
- **조치 사항**:
|
||||
- 전투 설정 drawer에 `스타팅 지점 배치`와 기존 `완전 랜덤 배치`를 선택하는 리스폰 설정을 추가.
|
||||
- `스타팅 지점 배치`에서는 참가자 수에 맞춰 전장 구역을 나누고 참가자별 시작 구역 배정과 구역 안 스폰 위치를 매치마다 무작위로 정하도록 구현.
|
||||
- `스타팅 지점 배치`에서는 참가자별 스타팅 영역과 영역 안 스폰 위치를 매치마다 무작위로 정하도록 구현했으며, 이후 30번 작업에서 영역 선택을 랜덤 중심 셀 기반 `5 x 5` 방식으로 구체화.
|
||||
- 선택한 리스폰 배치 모드를 `localStorage`에 저장해 새로고침과 재시작 이후에도 유지.
|
||||
|
||||
20. 팀당 인원 직접 입력 동기화 (완료)
|
||||
|
|
@ -178,4 +178,86 @@
|
|||
- 유저가 About 다이얼로그를 열 때마다 DB에서 최신 데이터를 가져오도록 서버 메모리 캐시 로직을 제거.
|
||||
- 기본 개인정보처리방침 마크다운의 공고/시행 일자를 최신화.
|
||||
|
||||
28. 전투 역할별 기본 스탯 프로필 분리 (완료)
|
||||
- **조치 사항**:
|
||||
- `src/constants.js`에 `FIGHTER_TYPE_STATS.melee/ranged/magic` 프로필을 추가해 최대 체력, 이동속도, 사거리, 쿨다운, 피해량, 치명타, 공격 발동 지연을 역할별로 조정할 수 있도록 변경.
|
||||
- `src/game/fighter/fighterStats.js`를 추가해 투사체 캐릭터는 원거리, 즉발 주문 캐릭터는 마법, 나머지는 근접 프로필로 판별하고 개별 스킨 오버라이드를 병합.
|
||||
- 캐릭터 생성과 전투 엔진이 해석된 프로필을 사용하도록 연결해 역할별 체력, 이동 및 공격 수치가 실제 전투에 적용되도록 변경.
|
||||
|
||||
29. 킬로그 캐릭터 아이콘 가시성 개선 (완료)
|
||||
- **조치 사항**:
|
||||
- `100x100` idle 프레임에 포함된 투명 여백까지 축소되던 킬로그 아이콘 배경 표시 방식을 보정.
|
||||
- 아이콘 박스 크기와 행 레이아웃은 유지하면서 캐릭터 실루엣이 있는 중앙 하단 영역을 확대 표시하도록 배경 크기와 위치를 조정.
|
||||
|
||||
30. 팀별 스타팅 영역 앵커 및 전장 표시 추가 (완료)
|
||||
- **조치 사항**:
|
||||
- `스타팅 지점 배치`에서 전장 스폰 가능 그리드 중 팀별 중심 셀을 무작위로 선택하고, 중심 주변 2칸을 포함하는 `5 x 5` 영역을 팀별 스폰 구역으로 사용하도록 변경.
|
||||
- 겹치지 않는 후보가 남아 있는 동안에는 선택된 스타팅 영역끼리 중첩되지 않는 랜덤 중심을 우선 사용해 전투 시작 즉시 팀이 섞이는 상황을 줄임.
|
||||
- 팀별로 무작위 배정된 스타팅 영역 데이터를 실제 스폰 좌표와 공유해 표시 영역 밖에서 시작하지 않도록 구성.
|
||||
- `arenaRenderer.js`에 팀 색상의 매우 옅은 채움 및 외곽선 오버레이를 추가하고, 랜덤 배치에서는 오버레이가 표시되지 않도록 연결.
|
||||
|
||||
31. 사망 시점 팀 badge 클릭 입력 유실 수정 (완료)
|
||||
- **조치 사항**:
|
||||
- 사망 발생 때마다 `arenaScoreboard.js`가 팀 badge 버튼 전체를 재생성해 클릭 중인 DOM이 제거되던 문제를 수정.
|
||||
- 팀 구성이 바뀌지 않는 전투 중 갱신에서는 기존 버튼 DOM을 유지하고 생존 인원, 선택 강조, 비활성 상태만 업데이트하도록 변경.
|
||||
- 사망 처리와 팀 badge 클릭이 같은 시점에 겹쳐도 생존 캐릭터 관전 시점 선택이 정상 전달되도록 보강.
|
||||
|
||||
32. 주기적 월드 이펙트 메테오 및 냉각지대 추가 (완료)
|
||||
- **조치 사항**:
|
||||
- `public/assets/effects/world_Effect.png`를 7프레임 공용 스프라이트시트로 로드하고, 실제 전투 시작 후 8초마다 무작위 생존자 위치에 메테오 또는 냉각지대를 무작위 발동하도록 `worldEffects.js`를 추가.
|
||||
- 메테오는 낙하 경고 후 대상 위치 기준 `5 x 5` 영역에 환경 피해를 적용하고, 환경 사망이 처치 보상 없이 사망 통계와 승패 판정에 반영되도록 전투 피해 처리를 확장.
|
||||
- 냉각지대는 냉기 착탄 연출과 지속 구역을 표시하며, 구역 안에 있는 캐릭터의 공격속도와 이동속도를 함께 감속하도록 연결.
|
||||
- 발동 간격, 범위, 피해량, 냉각 지속시간과 감속 배율을 `src/constants.js`의 `WORLD_EFFECT_*` 상수로 분리하고, 새 경기/종료/일시정지 생명주기에 맞춰 정리되도록 구성.
|
||||
|
||||
33. 월드 메테오 대각선 낙하 및 냉기 전용 시트 적용 (완료)
|
||||
- **조치 사항**:
|
||||
- 대상 위치가 전장 좌측 반면(2, 3사분면)이면 좌상단에서 우하단, 우측 반면(1, 4사분면)이면 우상단에서 좌하단으로 낙하하도록 궤적, 좌우 반전, `45`도 회전을 적용.
|
||||
- `WORLD_EFFECT_VISUAL_SCALE`과 `WORLD_EFFECT_FALL_TRAVEL_TILES`를 추가해 피해 판정 `5 x 5`는 유지하면서 스프라이트를 전역 마법처럼 크게 보이도록 확장.
|
||||
- 화염 메테오는 `public/assets/effects/world_Effect.png`, 냉기 메테오는 새 `public/assets/effects/world_Effect_2.png`를 각각 독립된 7프레임 애니메이션으로 로드하도록 변경.
|
||||
|
||||
34. 냉기 메테오 착탄 피해 옵션 추가 (완료)
|
||||
- **조치 사항**:
|
||||
- `WORLD_EFFECT_FROST_DAMAGE`를 추가해 냉기 메테오 피해를 화염 메테오와 독립적으로 조절할 수 있도록 변경.
|
||||
- 냉기 메테오 착탄 시 `5 x 5` 영역 피해를 먼저 처리하고, 전투가 종료되지 않은 경우 기존 냉각지대 감속 효과를 이어서 생성하도록 연결.
|
||||
|
||||
35. 스타팅 영역 표시 시간 제한 추가 (완료)
|
||||
- **조치 사항**:
|
||||
- 팀별 스타팅 영역 오버레이가 `스타팅 지점 배치` 매치 시작 후 5초 동안만 표시되고 이후 자동으로 사라지도록 연결.
|
||||
- 숨김 예약을 Phaser 씬 타이머로 관리하여 일시정지 시간은 표시 지속 시간에 포함되지 않고, 새 매치 시작 시 이전 숨김 타이머가 남지 않도록 정리.
|
||||
|
||||
36. 최종 2팀 자동 관전 및 메테오 착탄 화면 흔들림 전환 (완료)
|
||||
- **조치 사항**:
|
||||
- 생존 캐릭터가 30명 미만이거나 최종 2팀만 남으면 후반 자동 줌과 교전 중심 포커싱이 시작되도록 관전 조건을 확장.
|
||||
- 치명타의 `Critical!` 표기와 즉시 처치는 유지하면서 카메라 흔들림을 제거.
|
||||
- 화염 메테오가 착탄할 때 화면 흔들림을 적용하고, 냉각지대 착탄과 피해 계산에서는 흔들림을 분리.
|
||||
|
||||
37. 자동 관전 이전 메테오 임시 포커싱 추가 (완료)
|
||||
- **조치 사항**:
|
||||
- 후반 자동 관전 조건이 성립하기 전 화염 또는 냉기 메테오가 낙하하면 착탄 위치를 확대 추적하고 착탄 연출 종료 후 기존 카메라 위치와 줌을 복원.
|
||||
- 캐릭터 수동 선택과 후반/최종 자동 관전은 메테오 임시 시점보다 우선하도록 카메라 상태를 정리.
|
||||
- `src/constants.js`의 `CAMERA.METEOR_FOCUS_ENABLED` 플래그로 메테오 임시 포커싱을 코드에서 켜고 끌 수 있도록 구성.
|
||||
|
||||
38. 냉기 메테오 동결 기절 및 실루엣 효과 추가 (완료)
|
||||
- **조치 사항**:
|
||||
- 냉기 메테오 착탄 피해에 생존한 전투원은 `2초` 동안 이동과 새 공격이 정지되는 `isFrostStunned` 상태가 되도록 연결.
|
||||
- 동결 중 캐릭터 본체와 팀 실루엣 마커를 함께 얼음색으로 틴트하고, 시간이 끝나거나 매치가 정리되면 본체 원본 색상과 팀 색상으로 복원.
|
||||
- `WORLD_EFFECT.FROST_STUN_DURATION`과 `WORLD_EFFECT.FROST_STUN_TINT`를 추가해 동결 지속시간과 표시 색상을 조절 가능하게 구성.
|
||||
|
||||
39. 모바일 세로모드 팀 카드 가로폭 불균형 수정 (완료)
|
||||
- **조치 사항**:
|
||||
- 모바일 미디어 쿼리에서 `.score-side`가 데스크톱의 `grid-template-columns: repeat(2, 114px)`를 상속받아 1~4번 팀 카드만 길게 표시되던 현상을 수정.
|
||||
- `grid-template-columns: none`을 추가하여 모든 팀 카드가 `grid-auto-columns`에 설정된 일정한 가로폭을 가지도록 보정.
|
||||
|
||||
40. CSS 파일 기능별 모듈화 (완료)
|
||||
- **조치 사항**:
|
||||
- 거대했던 `src/styles.css`(약 2,000라인)를 기능별로 6개의 파일(`base`, `intro`, `game-ui`, `overlay`, `animations`, `mobile`)로 분리.
|
||||
- `src/styles/` 폴더를 생성하여 모듈화된 CSS 파일들을 관리.
|
||||
- `src/styles.css`는 이제 `@import`를 통해 각 모듈을 통합하는 엔트리 포인트 역할만 수행.
|
||||
- 코드 가독성과 유지보수 편의성을 대폭 향상.
|
||||
|
||||
41. 스타일 관련 컨텍스트 문서 추가 및 라우팅 업데이트 (완료)
|
||||
- **조치 사항**:
|
||||
- 새로운 CSS 모듈 구조와 디자인 원칙을 설명하는 `context/style.md` 문서를 신규 생성.
|
||||
- `agent.md`의 상세 기술 가이드(Context Routing) 섹션에 스타일 및 디자인 항목을 추가하여 문서 접근성 개선.
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue