Compare commits
17
Commits
f459585cec
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5795cc9741 | ||
|
|
30d7be41be | ||
|
|
2c013247a9 | ||
|
|
9df4f3dcde | ||
|
|
743b2a75f5 | ||
|
|
f9b548f9cd | ||
|
|
ddccd01eb6 | ||
|
|
1668e3c941 | ||
|
|
36fd25731a | ||
|
|
43383bb833 | ||
|
|
518767b21b | ||
|
|
8d0de2a945 | ||
|
|
7e38e4d434 | ||
|
|
93e407ae8e | ||
|
|
2dd7d2dceb | ||
|
|
353dc6738b | ||
|
|
55132ed662 |
@@ -2,100 +2,266 @@
|
||||
|
||||
## 0. 필수
|
||||
|
||||
- 작업이 완료되면 작업에 관련된 모든 문서를 업데이트한다
|
||||
- 작업이 완료되면 작업과 관련된 모든 문서를 함께 업데이트한다.
|
||||
- 대규모 전투, LOD, 모델/렌더 분리, 전투 워커, 서버 API를 수정할 때는 관련 `context/` 문서를 먼저 확인하고 변경 내용을 문서에 반영한다.
|
||||
|
||||
## 1. 프로젝트 정의
|
||||
|
||||
**Arena Picker**는 Phaser 3 게임 엔진과 Vite 번들러를 기반으로 구축된 **대규모 팀 전투 시뮬레이션 웹 애플리케이션**입니다. 사용자가 입력한 여러 명의 참가자(닉네임)를 바탕으로 각 참가자를 하나의 팀으로 설정하고, 지정된 인원만큼의 캐릭터를 생성하여 자동 전투를 시뮬레이션합니다.
|
||||
**Arena Picker**는 Phaser 3 게임 엔진과 Vite 번들러를 기반으로 구축된 **대규모 팀 전투 시뮬레이션 웹 애플리케이션**입니다. 사용자가 입력한 여러 참가자 닉네임을 각각 하나의 팀으로 설정하고, `닉네임*N` 형식으로 지정된 인원만큼 캐릭터를 생성해 자동 전투를 시뮬레이션합니다.
|
||||
|
||||
서버 런타임은 Fastify를 사용하며, MongoDB 커넥션 풀을 유지해 유니크 방문자 수와 전투 사망 통계를 기록하는 간단한 통계 API를 제공합니다.
|
||||
전장은 3200px 월드 크기를 유지하되 Phaser 내부 렌더 캔버스는 1280px로 낮춰 픽셀 작업량을 줄입니다. 일반 전투는 개별 Phaser Sprite와 Arcade Physics를 사용하고, 3,000명 이상 대규모 전투에서는 `FighterModel` 중심 시뮬레이션, rolling-window LOD, Web Worker 기반 후보 선정/집계 전투, HUD/이펙트 풀링을 결합해 8,000명급 전투를 처리합니다.
|
||||
|
||||
## 2. 프로젝트 전체 구조 (Directory Tree)
|
||||
서버 런타임은 Fastify를 사용하며 MongoDB 커넥션 풀을 유지합니다. 방문자 수, 일일 운영 지표, 전투 사망 통계, About 콘텐츠를 API로 제공합니다.
|
||||
|
||||
## 2. 현재 아키텍처 핵심
|
||||
|
||||
### 2.1 FighterModel 기반 상태와 렌더 브리지
|
||||
|
||||
- `src/game/fighter/fighterModel.js`가 HP, 팀, 스킨, 타깃, 쿨다운, 사망/선택/성장/동결 상태, 모델 좌표를 보관하는 순수 JS 상태 객체를 만듭니다.
|
||||
- `fighterFactory.js`는 실제 Phaser Sprite를 생성하고 `fighter.model` 브리지로 기존 `fighter.hp`, `fighter.team` 스타일 접근을 호환합니다.
|
||||
- `fighterAdapter.js`는 위치, 거리, 방향, 이동, body enable/disable, 애니메이션, 동결 tint, arena clamp 등 Phaser Sprite 접근의 경계입니다. 전투/카메라/월드 이펙트 코드는 새로 직접 `body`, `setVelocity()`, 애니메이션 API를 만지기보다 adapter를 우선 사용합니다.
|
||||
- `ArenaScene`은 `fighterModels`, `fighterByModelId`, `fighterModelById`를 함께 유지합니다. `fighterForModelId()`는 현재 attach된 렌더 Sprite만 반환할 수 있으므로, 전투 로직은 null 가능성을 항상 고려합니다.
|
||||
|
||||
### 2.2 대규모 전투 렌더 LOD
|
||||
|
||||
- 대규모 live match는 `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` 이상에서 render LOD를 활성화합니다.
|
||||
- full-arena overview는 `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT`만큼 팀별 대표 Sprite를 유지하고, 나머지 생존자는 팀 색상 dot으로 표시합니다.
|
||||
- zoomed, selected, spectator 시점은 rolling camera window 안의 모든 생존자를 detailed Sprite로 승격합니다. 이 경로는 더 이상 별도 zoom cap이나 buffer ratio에 묶이지 않습니다.
|
||||
- `src/game/arena/fighterLodWorker.js`는 생존 fighter worker id, position, team key TypedArray를 받아 현재 match/job의 detailed id 목록만 반환합니다. Worker 실패 또는 오류 시 `resolveFighterLodDetailedSet()` 동기 경로로 fallback합니다.
|
||||
- LOD 적용은 최초 활성화 때 full sync를 수행한 뒤, 이후에는 이전 detailed set과 다음 set의 차이만 attach/detach합니다.
|
||||
- parked fighter는 display/update list에서 빠지고 Arcade World에서도 `world.disable()`로 제거됩니다. 재진입 시 `world.enable()` 후 모델 좌표로 body를 복구합니다.
|
||||
- hidden-fighter dot redraw는 zoomed view에서 카메라 viewport와 padding 밖의 dot을 건너뛰어 `Graphics.fillRect()` 비용을 줄입니다.
|
||||
|
||||
### 2.3 대규모 전투 집계 시뮬레이션
|
||||
|
||||
- attached/detail fighter는 매 프레임 `updateFighterModel()`로 고정밀 개별 AI를 유지합니다.
|
||||
- detached/offscreen fighter는 `team + cell + squad` 단위로 압축되어 coarse movement와 group DPS를 처리합니다. 기본 squad 크기는 `PERFORMANCE.LARGE_BATTLE_AGGREGATE_SQUAD_SIZE`가 제어합니다.
|
||||
- `src/game/combat/aggregateCombatWorker.js`는 detached model id, position, HP, team key, 이동속도, DPS, frost flag를 Transferable TypedArray로 받아 집계 전투를 계산합니다.
|
||||
- Phaser 상태 변경, death 처리, split-on-death, kill reward, scoreboard, match finish는 여전히 main thread가 소유합니다.
|
||||
- Worker 결과는 match id가 일치하고 해당 model이 여전히 detached일 때만 적용합니다. 이미 attach된 fighter, 죽었거나 unregister된 stale id는 무시합니다.
|
||||
- Worker 생성 실패 또는 오류 시 기존 동기 집계 전투 경로로 fallback합니다.
|
||||
|
||||
### 2.4 전투 및 이펙트 최적화
|
||||
|
||||
- target spatial index는 model 기반으로 구성하되, 대규모 전투에서는 attached/detail model 중심으로 갱신해 8,000명 전체 스캔을 줄입니다.
|
||||
- stale `targetModelId`는 null-safe validation으로 정리합니다.
|
||||
- instant-spell 공격 시각 효과는 texture별 sprite pool을 재사용합니다. `clearCombatObjects()`는 active pooled effect도 공통 cleanup 경로로 반환합니다.
|
||||
- projectile hit detection은 projectile마다 Arcade overlap collider를 만들지 않고 line/rectangle path check와 scratch geometry를 재사용합니다.
|
||||
- 대규모 전투에서 critical label, instant-spell sprite, kill-heal sprite, kill-growth tween 같은 보조 효과는 meteor camera focus 중일 때만 노출합니다. damage, heal, 성장 수치 자체는 유지됩니다.
|
||||
- world effect는 랜덤 생존자 대신 생존자 밀집도가 가장 높은 tile square를 큰 경고 영역으로 잡고, 내부에 소형 화염/냉기 strike를 분산 투하합니다.
|
||||
|
||||
### 2.5 카메라, HUD, 서버 지표
|
||||
|
||||
- 대규모 live match 시작 시 full-arena 최저 줌 대신 평균 생존 위치에 가까운 fighter 주변으로 `CAMERA.LARGE_BATTLE_START_ZOOM`을 적용합니다.
|
||||
- scoreboard 팀 버튼을 이미 선택된 팀에 다시 클릭하면 선택을 해제하고 full-arena view로 돌아갑니다.
|
||||
- 수동 fighter/team focus와 full-arena return은 `transitionMainCameraTo()`의 Phaser `pan()`/`zoomTo()` tween을 사용합니다.
|
||||
- HUD 체력바는 모든 fighter가 영구 소유하지 않고 pool에서 빌려 씁니다. selected fighter와 zoom-visible 후보만 slot을 보유하며, zoom HUD에는 fighter 이름을 표시하지 않습니다.
|
||||
- live minimap은 별도 HUD camera와 `Graphics` dot overlay로 렌더링하며 `PERFORMANCE.MINIMAP_REFRESH_MS`로 redraw를 throttle합니다.
|
||||
- 서버는 visitor, death stats, daily metrics, About 콘텐츠 API를 제공합니다.
|
||||
|
||||
## 3. 프로젝트 전체 구조 (Directory Tree)
|
||||
|
||||
```text
|
||||
├── index.html # 메인 HTML 진입점 및 UI 레이아웃
|
||||
├── package.json # 프로젝트 의존성 및 스크립트 정의 (Phaser, Vite, Fastify, MongoDB)
|
||||
├── config.json # 로컬 서버/MongoDB 설정 (git ignore)
|
||||
├── package.json # Phaser, Vite, Fastify, MongoDB 의존성 및 npm scripts
|
||||
├── config.json.sample # 공유용 서버/MongoDB 설정 예시
|
||||
├── agent.md # 프로젝트 개요 및 기능 정의 (본 문서)
|
||||
├── CONTEXT.md # 상세 개발 가이드 및 로직 설명
|
||||
├── agent.md # 프로젝트 개요 및 에이전트 작업 가이드
|
||||
├── todo.md # 작업 내역 및 잔여 이슈 관리
|
||||
├── build.sh # 배포/빌드 보조 스크립트
|
||||
├── context/ # 상세 개발 가이드
|
||||
│ ├── core.md # main.js, constants.js, 렌더/성능 상수, worker entrypoint
|
||||
│ ├── arena.md # ArenaScene, camera, minimap, fighter render LOD
|
||||
│ ├── combat.md # 전투 AI, model-only combat, aggregate combat, world effects
|
||||
│ ├── fighter.md # FighterModel, adapter, factory, HUD pool, team-shadow texture
|
||||
│ ├── match-ui.md # 매치 설정, spawn, HUD, kill log, victory UI
|
||||
│ ├── server.md # Fastify, MongoDB, visitor/death/daily metrics/About API
|
||||
│ ├── style.md # CSS 모듈, 디자인 변수, 반응형/애니메이션 규칙
|
||||
│ └── refactor/
|
||||
│ └── arena-scene-modularization-work-order.md
|
||||
├── server/ # Fastify API 서버 및 MongoDB 연결 관리
|
||||
│ ├── index.js # Fastify 서버 진입점, Vite 개발 미들웨어, 정적 배포 서빙
|
||||
│ ├── config.js # config.json 로드 및 MongoDB URI 조립
|
||||
│ ├── index.js # Fastify 진입점, Vite dev middleware, 정적 배포 서빙
|
||||
│ ├── config.js # config.json 로드 및 MongoDB URI/컬렉션 설정
|
||||
│ ├── db.js # MongoClient 커넥션 풀 생성/재사용/종료
|
||||
│ ├── deathStats.js # 전투 종료 시 오늘 일자별 종족 사망 통계 누적 API
|
||||
│ ├── about.js # About 개발자정보/개인정보처리방침 기본값 시드 및 조회 API
|
||||
│ └── visitors.js # 유니크 방문자 체크 및 통계 API
|
||||
├── public/ # 정적 리소스 (게임 에셋)
|
||||
│ ├── visitorCookie.js # 방문자 UUID 쿠키 읽기/쓰기/검증
|
||||
│ ├── visitors.js # 유니크 방문자 체크 및 통계 API
|
||||
│ ├── dailyMetrics.js # 일일 방문/전투 시작/전투 종료/후원 클릭 지표 API
|
||||
│ ├── deathStats.js # 종족별 전투 사망 통계 API
|
||||
│ └── about.js # About 개발자정보/개인정보처리방침 seed 및 조회 API
|
||||
├── public/ # 정적 리소스
|
||||
│ └── assets/
|
||||
│ └── characters/ # 20종 이상의 캐릭터 스킨 및 투사체 에셋
|
||||
│ ├── archer/, armored-axeman/, armored-orc/, ... (중략)
|
||||
│ └── wizard/ # 각 폴더 내 애니메이션 시트 및 이펙트 포함
|
||||
└── src/ # 소스 코드 root
|
||||
├── main.js # Phaser 게임 인스턴스 생성, 옵션 drawer/재시작/일시정지 UI 제어
|
||||
├── constants.js # 전역 물리/UI 상수 통합 관리 (공격력, 체력, 줌, 카메라 속도 등)
|
||||
├── styles.css # UI 스타일링 (인트로, 옵션 drawer, 좌측 HUD 레일, 좌측 하단 킬로그, 상단 전투 안내바)
|
||||
├── game/ # 게임 로직 모듈 (역할별 하위 폴더 구성)
|
||||
│ ├── arena/ # 아레나 및 씬 관리
|
||||
│ │ ├── ArenaScene.js # 메인 게임 씬 (Orchestrator, 생명주기 및 모듈 조율)
|
||||
│ │ ├── arenaRenderer.js# 경기장 바닥 및 격자 렌더링
|
||||
│ │ └── arenaSpectatorCamera.js # 지능형 관전 카메라 및 줌 로직
|
||||
│ ├── combat/ # 전투 시스템
|
||||
│ │ ├── combat.js # 전투 AI, 투사체 및 피격 판정 핵심 엔진
|
||||
│ │ ├── combatSettings.js # 전투 속도 및 이동 배율 관리
|
||||
│ │ └── arenaFinalCombatEffects.js # 최종 교전 슬로우 모션 등 연출 효과
|
||||
│ ├── fighter/ # 캐릭터 및 에셋
|
||||
│ │ ├── fighterAssets.js # 스프라이트 로드 및 팀 실루엣 동적 생성
|
||||
│ │ ├── fighterFactory.js # 캐릭터 인스턴스화 및 HUD 동기화
|
||||
│ │ ├── fighterManifest.js # 20종 캐릭터 스탯/특성 상세 정의
|
||||
│ │ └── fighterSelection.js # 캐릭터 스킨 무작위 선택 로직
|
||||
│ └── match/ # 매치 및 진행
|
||||
│ ├── matchSetup.js # 팀 구성 및 스폰 좌표 계산 (구역/랜덤)
|
||||
│ └── arenaMatchRuntime.js # 매치 진행 중 헬퍼 (스폰 클러스터, 팀 크기 동기화)
|
||||
└── ui/ # UI 컴포넌트 및 API 연동
|
||||
├── arenaKillLog.js # [New] 독립된 킬로그 DOM 조작 모듈
|
||||
├── arenaScoreboard.js # [New] 팀 스코어 badge 업데이트 모듈
|
||||
├── battleDeathNotice.js # [New] 상단 사망 공지 메시지 및 UI 관리
|
||||
├── victoryCelebration.js # [New] 승리 축하 연출 (DOM/Audio) 모듈
|
||||
├── matchForm.js # 설정 폼 제어 및 localStorage 유지
|
||||
├── aboutDialog.js # About 다이얼로그, 개발자정보/개인정보처리방침 표시
|
||||
├── deathStats.js # 사망 통계 API 호출 래퍼
|
||||
└── visitorCounter.js # 방문자 체크 API 호출 및 표시
|
||||
│ ├── og-image.png # 공유 미리보기 이미지
|
||||
│ ├── effects/
|
||||
│ │ ├── heal/ # 처치 회복 연출
|
||||
│ │ ├── world_Effect.png
|
||||
│ │ └── world_Effect_2.png
|
||||
│ └── characters/ # 20종 이상 캐릭터 스킨/투사체/마법 이펙트 에셋
|
||||
│ ├── archer/
|
||||
│ ├── armored-axeman/
|
||||
│ ├── armored-orc/
|
||||
│ ├── priest/
|
||||
│ ├── wizard/
|
||||
│ └── ... # knight, orc, skeleton, slime, wolf, bear 계열 등
|
||||
└── src/ # 프론트엔드 소스 root
|
||||
├── main.js # Phaser game config, 앱 상태, 옵션 drawer, 방문자 추적
|
||||
├── constants.js # 렌더/전장/전투/카메라/성능/월드 이펙트 상수
|
||||
├── styles.css # CSS 모듈 통합 엔트리
|
||||
├── styles/
|
||||
│ ├── base.css # 전역 변수, reset, 기본 레이아웃
|
||||
│ ├── intro.css # 대기 화면 및 프리뷰 스타일
|
||||
│ ├── game-ui.css # scoreboard, kill log, battle notice, victory layer
|
||||
│ ├── overlay.css # option drawer, About dialog, form controls
|
||||
│ ├── animations.css # 공통 keyframes/animation utilities
|
||||
│ └── mobile.css # 960px 이하 반응형 override
|
||||
├── game/
|
||||
│ ├── arena/
|
||||
│ │ ├── ArenaScene.js # 메인 Phaser Scene orchestrator
|
||||
│ │ ├── arenaRenderer.js # 전장 바닥, grid, starting zone 렌더링
|
||||
│ │ ├── arenaSpectatorCamera.js # 자동/수동 카메라 포커싱
|
||||
│ │ └── fighterLodWorker.js # 대규모 전투 detailed sprite 후보 worker
|
||||
│ ├── combat/
|
||||
│ │ ├── combat.js # model 기반 전투 AI, 타깃, 피해, 처치 처리
|
||||
│ │ ├── aggregateCombatWorker.js# detached/offscreen 집계 전투 worker
|
||||
│ │ ├── combatSettings.js # 전투 속도 및 이동 배율 설정
|
||||
│ │ ├── arenaFinalCombatEffects.js
|
||||
│ │ └── worldEffects.js # 밀집 구역 메테오/냉기/감속/동결 효과
|
||||
│ ├── fighter/
|
||||
│ │ ├── fighterModel.js # 순수 JS fighter 상태 모델
|
||||
│ │ ├── fighterAdapter.js # Phaser Sprite/Physics 접근 경계
|
||||
│ │ ├── fighterAssets.js # sprite load, team-shadow texture/animation 생성
|
||||
│ │ ├── fighterFactory.js # Sprite 생성, model bridge, HUD pool, detail visibility
|
||||
│ │ ├── fighterManifest.js # 캐릭터 스탯/종족/특성 정의
|
||||
│ │ ├── fighterStats.js # melee/ranged/magic 프로필 해석
|
||||
│ │ └── fighterSelection.js # 캐릭터 선택/셔플 로직
|
||||
│ └── match/
|
||||
│ ├── matchSetup.js # `닉네임*N` 파싱, 팀 구성, spawn 좌표 계산
|
||||
│ └── arenaMatchRuntime.js # match 진행 중 helper
|
||||
└── ui/
|
||||
├── matchForm.js # 설정 폼 및 localStorage 유지
|
||||
├── aboutDialog.js # About dialog 및 Markdown 표시
|
||||
├── visitorCounter.js # 방문자 API 호출/표시
|
||||
├── dailyMetrics.js # 일일 지표 API 호출
|
||||
├── deathStats.js # 사망 통계 API 호출
|
||||
├── arenaScoreboard.js # 팀 badge 및 선택 상태
|
||||
├── arenaKillLog.js # kill log DOM
|
||||
├── battleDeathNotice.js# 상단 사망/통계 안내
|
||||
└── victoryCelebration.js
|
||||
```
|
||||
|
||||
## 3. 상세 기술 가이드 (Context Routing)
|
||||
로컬/생성 파일인 `config.json`, `node_modules/`, `dist/`, `.vite/`, `package-lock.json`, `*.log`는 `.gitignore` 대상입니다.
|
||||
|
||||
토큰 절약 및 효율적인 정보 조회를 위해 상세 로직은 기능별로 분리되어 보관됩니다. 특정 모듈 작업 시 아래의 관련 문서를 먼저 읽으십시오.
|
||||
## 4. 상세 기술 가이드 (Context Routing)
|
||||
|
||||
- **[인프라 및 전역 설정] [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/fighter.md](./context/fighter.md)**: 캐릭터 공장, 동적 실루엣 생성, 종족 및 특성(Slime 등) 정의.
|
||||
- **[매치 로직 및 UI] [context/match-ui.md](./context/match-ui.md)**: 팀 구성 및 스폰 알고리즘, HUD 레이아웃, 킬로그, 승리 연출 UI.
|
||||
토큰 절약 및 효율적인 정보 조회를 위해 상세 로직은 기능별 문서로 분리되어 있습니다. 특정 모듈 작업 시 아래 문서를 먼저 읽으십시오.
|
||||
|
||||
## 4. 기술 사양
|
||||
- **[인프라 및 전역 설정](./context/core.md)**: `main.js`, `constants.js`, 렌더 크기, `PERFORMANCE`, worker entrypoint, 공통 유지보수 규칙.
|
||||
- **[아레나 및 카메라](./context/arena.md)**: `ArenaScene`, rolling-window LOD, `fighterLodWorker.js`, minimap, spectator/manual camera.
|
||||
- **[전투 엔진](./context/combat.md)**: `combat.js`, model-only combat fallback, target spatial index, `aggregateCombatWorker.js`, world effects.
|
||||
- **[캐릭터 및 에셋](./context/fighter.md)**: `FighterModel`, `fighterAdapter.js`, sprite attach/detach, HUD pool, team-shadow texture.
|
||||
- **[매치 로직 및 UI](./context/match-ui.md)**: `닉네임*N` 팀 인원, spawn zone, scoreboard, kill log, victory UI, 모바일 레이아웃.
|
||||
- **[서버 및 API](./context/server.md)**: Fastify, MongoDB, visitor cookie, daily metrics, death stats, About 콘텐츠.
|
||||
- **[스타일 및 디자인](./context/style.md)**: CSS 모듈 구조, 디자인 변수, 반응형 및 애니메이션 가이드.
|
||||
|
||||
## 5. 주요 기능 상세
|
||||
|
||||
### 5.1 매치 입력과 스폰
|
||||
|
||||
- live match 참가자는 `닉네임*N` 형식으로 팀별 배정 인원을 직접 지정합니다. 접미사가 없으면 1명입니다.
|
||||
- `SPAWN.MAX_FIGHTER_COUNT`는 참가자 입력으로 배정되는 fighter 수의 상한입니다. Slime의 `spawnMultiplier`, `splitOnDeath` 같은 특성 기반 추가 생성은 이 입력 상한에 포함하지 않습니다.
|
||||
- starting-zone placement는 `SPAWN.FIGHTERS_PER_STARTING_ZONE`마다 팀 영역을 추가로 배정해 대규모 팀이 한 점에 뭉치지 않도록 분산합니다.
|
||||
- match-start validation은 요청 인원과 허용 인원을 분리해 사용자에게 경고 카드로 보여줍니다.
|
||||
|
||||
### 5.2 대규모 전투 흐름
|
||||
|
||||
- match 시작 시 `ArenaScene`은 live fighter 수가 threshold 이상인지 판단하고 large-battle 모드로 들어갑니다.
|
||||
- 첫 화면은 full-arena overview가 아니라 living fighter 평균 위치에 가까운 fighter 주변으로 zoom합니다.
|
||||
- 최초 LOD sync 후 `fighterLodWorker.js` 또는 동기 resolver가 현재 카메라 상태에 맞는 detailed set을 계산합니다.
|
||||
- full overview는 대표 Sprite와 dot field를 유지합니다. focused view는 rolling window 안의 모든 생존자를 detail Sprite로 복구합니다.
|
||||
- offscreen/detached model은 집계 squad combat으로 이동/피해/사망을 처리하고, 카메라에 다시 들어오면 model position에서 Sprite를 재attach합니다.
|
||||
|
||||
### 5.3 모델/렌더 생명주기
|
||||
|
||||
- `createFighter()`는 항상 실제 Phaser Sprite를 만들고 `FighterModel`을 붙입니다. 과거 lazy `SpriteProxy` 실험은 rollback되었습니다.
|
||||
- `attachSprite: false`는 Sprite 생성을 건너뛰는 뜻이 아니라, 생성 직후 `setFighterDetailVisible(false)`로 parking한다는 뜻입니다.
|
||||
- parking된 fighter는 render/update/physics traversal에서 빠지지만 model state는 계속 살아 있습니다.
|
||||
- model-only death는 model을 inactive/unregister 처리하고 parked fighter entry를 제거합니다.
|
||||
- animation helper는 실제 renderable fighter가 없으면 action key resolution/playback을 건너뜁니다.
|
||||
|
||||
### 5.4 전투, 효과, 월드 이벤트
|
||||
|
||||
- `updateFighterModel()`은 Sprite가 있으면 기존 Arcade/animation path를 사용하고, Sprite가 없으면 model 좌표/HP/쿨다운 기반으로 이동과 공격을 진행합니다.
|
||||
- ranged/magic 공격은 양쪽 Sprite가 모두 있으면 visual projectile/spell path를 사용합니다. detached 참여자가 있으면 같은 windup/travel/hit delay를 model hit로 해석합니다.
|
||||
- kill reward, split-on-death, death stats, scoreboard, match finish는 Sprite 유무와 무관하게 기존 authoritative path를 사용합니다.
|
||||
- dense-area meteor barrage는 큰 경고 범위를 먼저 표시한 뒤 내부 소형 strike에만 피해/동결/감속을 적용합니다.
|
||||
- sudden death는 설정 시간 이후 meteor 주기를 단축하고 필요 시 frost meteor를 강제해 장기전을 방지합니다.
|
||||
|
||||
### 5.5 카메라와 HUD
|
||||
|
||||
- `transitionMainCameraTo()`는 수동 focus 이동에 Phaser `pan()`/`zoomTo()`를 적용합니다.
|
||||
- selected fighter auto-centering은 수동 tween 중에는 기다려 tween을 취소하지 않습니다.
|
||||
- scoreboard에서 선택된 팀을 다시 클릭하면 selection/focus/meteor focus를 정리하고 full-arena view로 돌아갑니다.
|
||||
- minimap은 field camera와 분리된 HUD camera로 고정 표시하며, main camera viewport rectangle과 team-colored dot을 그립니다.
|
||||
- fighter HUD는 pool 기반입니다. selected/zoom-visible 후보만 health bar를 빌려 쓰고, hidden LOD fighter는 HUD slot과 pointer input을 해제합니다.
|
||||
|
||||
### 5.6 서버/API와 지표
|
||||
|
||||
- 방문자 체크는 `arena_visitor_id` HttpOnly 쿠키와 MongoDB `visitors` 컬렉션을 사용합니다.
|
||||
- daily metrics는 앱 방문, 실제 전투 시작, 실제 전투 종료, 후원 클릭 예약 지표를 날짜별 합산 문서로 저장합니다.
|
||||
- death stats는 프리뷰가 아닌 실제 전투 종료 시 종족별 사망 수를 오늘 일자 문서에 누적합니다.
|
||||
- About 콘텐츠는 DB의 Markdown을 실시간 조회하며 서버 메모리 캐시를 두지 않습니다.
|
||||
|
||||
## 6. 기술 사양 및 튜닝 포인트
|
||||
|
||||
- **Framework**: Phaser 3.90.0 (Arcade Physics 기반)
|
||||
- **Build Tool**: Vite 7.1.12
|
||||
- **Server**: Fastify 5.x (`@fastify/static`, `@fastify/middie`)
|
||||
- **Database**: MongoDB 7.x Node Driver
|
||||
- **UI Logic**: Vanilla JS & CSS (Flexbox/Grid 활용)
|
||||
- **UI Logic**: Vanilla JS & CSS
|
||||
- **Render**: `RENDER.WIDTH/HEIGHT = 1280`, `ARENA.SIZE = 3200`, `CAMERA.MIN_ZOOM = RENDER_SIZE / ARENA_SIZE`
|
||||
- **Large Battle**: `PERFORMANCE.LARGE_BATTLE_*` 상수에서 threshold, simulation buckets, aggregate refresh/cell/squad/death cap, target index refresh, HUD limit, Sprite budget, rolling window, dot redraw를 조정합니다.
|
||||
- **World Effect**: `WORLD_EFFECT.*`에서 첫/반복 포격, 밀집 경고 범위, 소형 strike 범위/개수/간격/시각 배율, meteor shake, fire/frost damage, frost stun/slow를 조정합니다.
|
||||
- **Camera**: `CAMERA.LARGE_BATTLE_START_ZOOM`, `CAMERA.MANUAL_FOCUS_TWEEN_MS`, `CAMERA.MANUAL_FOCUS_TWEEN_EASE`, meteor focus, spectator thresholds를 조정합니다.
|
||||
- **Fighter**: `FIGHTER.DEAD_DESPAWN_DELAY_MS`, `FIGHTER.DEAD_DESPAWN_ALPHA`, `FIGHTER_TYPE_STATS`, kill growth 상수를 조정합니다.
|
||||
- **Worker fallback**: LOD/aggregate worker는 성능 최적화 경로이며, 실패 시 main-thread 동기 path가 계속 동작해야 합니다.
|
||||
|
||||
## 5. 서버/API 설정
|
||||
## 7. 서버/API 설정
|
||||
|
||||
- 개발/운영 서버는 `npm run dev` 또는 `npm start`로 실행하며 기본 포트는 `config.json`의 `SERVER_PORT` 값인 `9736`입니다.
|
||||
- 개발 서버는 `npm run dev`, 운영 서버는 `npm start`, 정적 빌드는 `npm run build`로 실행합니다.
|
||||
- 기본 포트는 `config.json`의 `SERVER_PORT` 값이며 샘플은 `9736`입니다.
|
||||
- `config.json`은 로컬 설정 파일이므로 저장소에 커밋하지 않습니다. 새 환경에서는 `config.json.sample`을 복사해 사용합니다.
|
||||
- 기본 API:
|
||||
|
||||
기본 API:
|
||||
|
||||
- `GET /api/health`: 서버 및 MongoDB 설정 여부 확인.
|
||||
- `POST /api/visitors/check`: 현재 브라우저 방문자를 체크하고 유니크 방문자 수를 반환.
|
||||
- `POST /api/visitors/check`: 방문자 UUID 쿠키 확인/발급 및 유니크 방문자 수 반환.
|
||||
- `GET /api/visitors/stats`: 전체 유니크 방문자 수 조회.
|
||||
- `GET /api/about`: 데이터베이스에서 실시간으로 개발자정보와 개인정보처리방침 Markdown 조회 (캐시 없이 즉시 반영).
|
||||
- `GET /api/daily-metrics/today`: 오늘의 운영 지표 조회.
|
||||
- `POST /api/daily-metrics/match-started`: 실제 전투 시작 수 누적.
|
||||
- `POST /api/daily-metrics/match-finished`: 실제 전투 종료 수 누적.
|
||||
- `POST /api/daily-metrics/donation-clicked`: 후원 클릭 수 누적용 예약 API.
|
||||
- `GET /api/death-stats/today`: 오늘의 종족별 전투 사망 통계 조회.
|
||||
- `POST /api/death-stats/today`: 종료된 전투의 종족별 사망 수를 오늘 집계에 누적.
|
||||
- `POST /api/death-stats/today`: 종료된 실제 전투의 종족별 사망 수 누적.
|
||||
- `GET /api/about`: 개발자정보와 개인정보처리방침 Markdown 조회.
|
||||
|
||||
## 6. 관련 문서
|
||||
## 8. 유지보수 규칙
|
||||
|
||||
- [CONTEXT.md](./CONTEXT.md): 상세 개발 가이드 및 핵심 로직 설명 (필독)
|
||||
- [todo.md](./todo.md): 작업 내역 및 잔여 이슈 관리
|
||||
- **문서 동기화**: 구조, 상수, API, 대규모 전투 path가 바뀌면 `agent.md`와 관련 `context/*.md`를 함께 갱신합니다.
|
||||
- **모듈 경계**: `ArenaScene`은 orchestration을 맡고, fighter 상태/렌더 세부는 `fighter/`, 전투 판정은 `combat/`, match input/spawn은 `match/`, DOM UI는 `ui/`로 분리합니다.
|
||||
- **Fighter 접근**: 새 코드가 fighter body, animation, tint, velocity, position을 직접 다뤄야 한다면 먼저 `fighterAdapter.js`에 적절한 helper가 있는지 확인합니다.
|
||||
- **Model-first 안전성**: `fighterForModelId()`는 null을 반환할 수 있습니다. model-only 전투, stale id, death/unregister 이후 상태를 항상 고려합니다.
|
||||
- **대규모 전투 성능**: 8,000명급 경로에서는 전체 fighter 배열을 매 프레임 스캔하거나 DOM/HUD/Graphics를 전원 갱신하지 않습니다. throttle, pool, worker, spatial index, set diff를 우선 사용합니다.
|
||||
- **Phaser lifecycle**: parked Sprite는 display/update list와 Arcade World에서 모두 빠져야 하며, reattach 시 model 좌표와 body를 동기화합니다.
|
||||
- **이펙트 lifecycle**: pooled combat object는 `releaseToPool`/`disposeCombatObject()` 경로로 정리합니다. 새 이펙트도 match reset과 scene cleanup에서 누수되지 않아야 합니다.
|
||||
- **API 변경**: `/api/*` 경로는 Fastify route가 담당합니다. 개발 모드에서 Vite SPA fallback이 API 요청을 가로채지 않게 유지합니다.
|
||||
- **신규 캐릭터**: `public/assets/characters/`에 에셋을 배치하고 `fighterManifest.js`에 `species`와 combat/stat 정의를 추가합니다. 사망 통계 종족은 `human`, `orc`, `skeleton`, `slime`, `wolf`, `bear` 중 하나를 사용합니다.
|
||||
- **스타일 변경**: `src/styles.css`는 모듈 import 엔트리입니다. 실제 수정은 `src/styles/*.css`의 해당 영역에서 진행합니다.
|
||||
|
||||
## 9. 관련 문서
|
||||
|
||||
- [context/core.md](./context/core.md): 전역 설정, 성능 상수, 렌더/worker 가이드.
|
||||
- [context/arena.md](./context/arena.md): 아레나 씬, 카메라, minimap, render LOD.
|
||||
- [context/combat.md](./context/combat.md): 전투 AI, 집계 전투, projectile/world effect.
|
||||
- [context/fighter.md](./context/fighter.md): FighterModel, adapter, factory, assets.
|
||||
- [context/match-ui.md](./context/match-ui.md): 매치 입력, spawn, HUD, 모바일 UI.
|
||||
- [context/server.md](./context/server.md): Fastify/MongoDB API.
|
||||
- [context/style.md](./context/style.md): CSS 모듈 및 디자인 규칙.
|
||||
- [todo.md](./todo.md): 작업 내역 및 잔여 이슈.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
git pull origin master
|
||||
npm run build
|
||||
pm2 restart arena
|
||||
|
||||
+147
-4
@@ -1,9 +1,146 @@
|
||||
# Update: Fighter LOD Worker
|
||||
|
||||
- `ArenaScene` now starts a dedicated `fighterLodWorker.js` job for recurring large-battle LOD candidate selection after the initial forced LOD sync.
|
||||
- The worker returns detailed fighter worker ids for either full-arena representatives or all living fighters inside the focused rolling window.
|
||||
- `ArenaScene` maps those ids back to current fighter sprites and then reuses `applyFighterLodDetailedSet()` for the actual Phaser attach/detach work.
|
||||
- Worker errors disable the async path and keep the synchronous LOD resolver as a fallback.
|
||||
|
||||
# Update: Full Rolling-Window Detail Sprites
|
||||
|
||||
- Zoomed, selected, and spectator large-battle views now promote every living fighter inside the rolling camera window to a detailed Phaser sprite.
|
||||
- Full-arena overview remains bounded by the representative sprite budget, so the all-map 8,000-fighter view still stays lightweight.
|
||||
- `addCameraFighterDetails()` no longer receives a detail cap; it adds exact viewport candidates first, then all remaining rolling-window candidates.
|
||||
- Fighters outside the rolling window stay detached and continue to render as LOD dots.
|
||||
|
||||
# Update: Async Aggregate Result Safety
|
||||
|
||||
- Worker aggregate results are applied only to models that remain detached from `fighterByModelId`; if the camera has promoted a fighter to detailed sprite while a worker job is in flight, that result is ignored for the promoted model.
|
||||
- Match id checks discard stale worker results after match reset, keeping async aggregate ticks from mutating a new match.
|
||||
|
||||
# Update: LOD Diff And Dot Frustum Culling
|
||||
|
||||
- Rolling-window LOD now does a one-time full sprite visibility sync when large-battle LOD first activates, then applies only the delta between the previous and next detailed fighter sets on later refreshes.
|
||||
- Destroyed fighters are removed from `fighterLodDetailedSet`, and stale no-scene references are skipped during the diff pass before Phaser input/body APIs are touched.
|
||||
- Hidden-fighter dot redraws still use model positions, but skip fighters outside the main camera world view plus `PERFORMANCE.LARGE_BATTLE_SPRITE_VIEW_PADDING`, avoiding offscreen `Graphics.fillRect()` calls during zoomed views.
|
||||
- Full-arena overview still draws the arena-wide dot field because the main camera view covers the full battlefield at `CAMERA.MIN_ZOOM`.
|
||||
|
||||
# Update: Squad Materialization Bridge
|
||||
|
||||
- The arena still keeps individual fighter models for rendering, selection, minimap dots, and deterministic re-entry, but offscreen movement/combat is now driven by squad centers.
|
||||
- Aggregate ticks reslot squad members around their squad center, allowing rolling-window LOD to reattach sprites from plausible positions when the camera approaches.
|
||||
- Visible attached fighters remain the high-fidelity path; offscreen detached fighters no longer consume individual AI buckets while squad aggregation is active.
|
||||
|
||||
# Update: Aggregate Detached Simulation Path
|
||||
|
||||
- During large live battles, `ArenaScene.updateFighterModels()` now calls `updateAggregateDetachedCombat()` before per-model updates.
|
||||
- If aggregate combat is active, only attached/detail fighters continue through full `updateFighterModel()` every frame; detached fighters are skipped by the individual simulation buckets.
|
||||
- This keeps the rolling-window camera area high-fidelity while offscreen fighters continue moving, taking damage, dying, splitting, and changing match outcome through model data.
|
||||
- If aggregate combat finishes the match during a batch, `updateFighterModels()` exits immediately so no stale attached updates run after `finishMatch()`.
|
||||
|
||||
# Update: Large Battle Simulation Throttle
|
||||
|
||||
- `ArenaScene.updateFighterModels()` now keeps attached/detailed render models on every-frame updates, but distributes detached model-only fighters across simulation buckets during large live matches.
|
||||
- `PERFORMANCE.LARGE_BATTLE_SIMULATION_BUCKETS` controls the bucket count and `LARGE_BATTLE_SIMULATION_MAX_DELTA_MS` caps the accumulated delta passed to a skipped detached model.
|
||||
- The detailed sprite cap was reduced aggressively for 8,000-fighter battles, and rolling-window detail budgeting now accepts ratios below `1` so dense zoomed views do not promote hundreds-to-thousands of sprites at once.
|
||||
- Large-battle fighter HUD health bars use `PERFORMANCE.LARGE_BATTLE_HUD_VISIBLE_LIMIT` instead of the normal HUD limit.
|
||||
|
||||
# Update: Fighter Sprite Render Recovery
|
||||
|
||||
- `startMatch()` still passes `attachSprite: false` for large live matches, but `createFighter()` now creates a real Phaser Sprite and immediately parks it instead of returning a pure proxy.
|
||||
- This restores visible rendering for normal matches and keeps rolling-window LOD's display/update-list detach path for large battles.
|
||||
- `spawnSplitFighters()` follows the same rule during active large-battle LOD: split children are registered with models and can be parked until LOD promotion.
|
||||
- Input events now work directly with Phaser sprites again; `_fighterProxy` fallback remains harmless for any future proxy experiment.
|
||||
|
||||
# Update: Render Sprite Detach In Rolling LOD
|
||||
|
||||
- `applyFighterLodDetailedSet()` treats the detailed set as the list of fighter sprites that should be attached to Phaser for this camera window.
|
||||
- Non-detailed living fighters call `setFighterDetailVisible(false)`, which parks the sprite outside the display/update lists and removes its `fighterByModelId` mapping while keeping the model registered.
|
||||
- Detailed fighters are reattached with `ensureFighterSpriteAttached()` / `setFighterSpriteAttached()`, so team-button selection can force a detached sprite back before the camera transition and HUD sync.
|
||||
- `removeDetachedFighterProxyForModel()` still removes parked fighter entries after model-only death so dead detached entries do not remain in large-battle scan arrays.
|
||||
- LOD candidate collection and minimap dots intentionally scan `this.fighters` instead of `combatTargetIndex.livingFighters`, because the combat index's sprite list may contain only currently attached render sprites.
|
||||
|
||||
# Update: Fighter Model Indexes
|
||||
|
||||
- `ArenaScene` now keeps `fighterModels`, `fighterByModelId`, and `fighterModelById` in sync with the sprite list.
|
||||
- New fighters are registered when a match starts or split-on-death children spawn; despawned or model-only dead fighters are unregistered and their models are marked inactive.
|
||||
- `fighterModelForId()` covers all living models, while `fighterForModelId()` now returns only currently attached render sprites.
|
||||
- `unregisterFighterModel()` supports model-only cleanup paths that do not have an attached Phaser sprite.
|
||||
|
||||
# Update: FighterModel Use In Arena LOD
|
||||
|
||||
- Split-on-death spawn origins now use `fighterModelPoint(source)` so dormant parents spawn children from their simulation position.
|
||||
- Rolling-window LOD candidate collection, dot drawing, and minimap fighter dots now read model `x/y` through `fighterModelPoint()` instead of direct sprite coordinates.
|
||||
- This keeps the large-battle camera/render UI aligned with the simulation model while offscreen render sprites are detached.
|
||||
|
||||
# Update: Fighter Adapter Use In Arena
|
||||
|
||||
- `ArenaScene.finishMatch()` stops fighters through `fighterAdapter.stopFighterMovement()` instead of directly touching Arcade bodies.
|
||||
- `arenaSpectatorCamera.js` uses `fighterWorldPoint()` and `fighterDistanceSquared()` so spectator targets, observed combat centers, and closest-pair lookup remain correct when rolling-window LOD has disabled offscreen fighter bodies.
|
||||
- Camera/focus code should continue using `fighterCameraPoint()` or adapter position helpers instead of reading `fighter.body.center` directly.
|
||||
|
||||
# Update: Rolling Window Fighter LOD
|
||||
|
||||
- `collectCameraFighterDetails()` now builds two candidate lists from a camera-centered rolling window: exact viewport candidates and rolling-window candidates.
|
||||
- The rolling window is larger than the visible camera view, using `PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_SCALE` plus `LARGE_BATTLE_SPRITE_VIEW_PADDING` as a minimum expansion.
|
||||
- Nearby soon-to-enter fighters remain detailed sprites instead of dots because the focused-camera path now consumes the full rolling-window candidate list.
|
||||
- `addCameraFighterDetails()` still fills exact viewport candidates first, then rolling-window candidates, preserving visible fidelity during camera movement.
|
||||
- Fighters outside the detailed set become dormant through `setFighterDetailVisible(false)`, reducing animation/body work while keeping combat simulation active.
|
||||
|
||||
# Update: Manual Camera Pan/Zoom Tween
|
||||
|
||||
- `ArenaScene.transitionMainCameraTo()` wraps Phaser camera `pan()` and `zoomTo()` for short manual focus transitions.
|
||||
- `selectFighter()` uses the transition helper for scoreboard/team/fighter focus instead of an instant `setZoom()` plus `centerOn()`.
|
||||
- `returnToFullArenaView()` uses the same helper to move back to arena center at `CAMERA.MIN_ZOOM`.
|
||||
- `focusSelectedFighter()` skips immediate recentering while the camera pan/zoom effect is active, preventing the selected-fighter follow path from cancelling the transition.
|
||||
|
||||
# Update: Large Battle Start Camera
|
||||
|
||||
- `startMatch()` now calls `focusLargeBattleStartCamera()` after creating live fighters and before the initial LOD sync.
|
||||
- Large live matches start at `CAMERA.LARGE_BATTLE_START_ZOOM` centered on the living fighter nearest to the living population average, so the first view is a readable local battle view instead of the full minimap-like arena.
|
||||
- The start camera does not mark a fighter selected; scoreboard team toggle and manual team selection keep their existing behavior.
|
||||
|
||||
# Update: Team Button Toggle To Full Arena
|
||||
|
||||
- `selectRandomTeamFighter()` treats a scoreboard click on the already selected team as a toggle-off action instead of choosing another random fighter from that team.
|
||||
- `returnToFullArenaView()` clears selection/focus state, sets `CAMERA.MIN_ZOOM`, centers on the arena, refreshes the minimap, and updates the scoreboard so the focused team style is removed.
|
||||
|
||||
# Update: Dynamic Zoomed Fighter LOD
|
||||
|
||||
- Zoomed large-battle LOD now separates exact camera-visible fighters from rolling-window fighters.
|
||||
- Focused large-battle LOD promotes the selected fighter plus all living fighters inside the rolling window, reducing the awkward mix of detailed sprites and dots inside the player's current view.
|
||||
- `addCameraFighterDetails()` always consumes exact viewport candidates before rolling-window candidates.
|
||||
- Full-arena `CAMERA.MIN_ZOOM` overview keeps the lower representative budget so the expensive case remains protected.
|
||||
|
||||
# Update: Large Battle Fighter Render LOD
|
||||
|
||||
- `ArenaScene` owns the large-battle fighter render LOD pass through `syncFighterRenderLod()`, `resolveFighterLodDetailedSet()`, and `drawFighterLodDots()`.
|
||||
- The LOD pass activates only for live matches above `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD`; presentation mode and finished matches restore normal fighter visibility.
|
||||
- Full-arena overview keeps a bounded set of representative sprites from each team, while zoomed or selected-camera views keep the selected fighter plus camera-near fighters.
|
||||
- Hidden living fighters are still present in `this.fighters` with model state, but their Phaser sprite is removed from the display/update lists and they are drawn as team-colored dots on a shared `Graphics` object.
|
||||
- HUD candidate selection ignores hidden fighters, and match finish disables LOD before post-match handling.
|
||||
|
||||
# Update: Full-Arena Camera At Lower Render Resolution
|
||||
|
||||
- The Phaser canvas resolution is no longer tied to `ARENA.SIZE`; `CAMERA.MIN_ZOOM` is below `1` so the main camera can still frame the full 3200px arena inside the smaller render canvas.
|
||||
- Existing team click, selected fighter, meteor focus, and final-combat camera zooms remain absolute zoom targets above that full-arena minimum.
|
||||
|
||||
# Update: Minimap Redraw Throttle
|
||||
|
||||
- `ArenaScene.updateMinimap()` accepts a forced refresh flag and otherwise redraws no more often than `PERFORMANCE.MINIMAP_REFRESH_MS`.
|
||||
- Match setup and camera zoom changes force an immediate minimap refresh, while routine scene updates share the throttled path to reduce `Graphics` redraw work in large battles.
|
||||
|
||||
# Update: Graphics Minimap And HUD Candidates
|
||||
|
||||
- The minimap is drawn during live matches by `ArenaScene` as a lightweight `Graphics` overlay through a dedicated `minimap-hud` camera instead of reusing the field camera. Presentation/waiting mode hides it.
|
||||
- The main camera ignores the minimap graphics object, and the HUD camera ignores field objects as they are added to the scene. Minimap rendering uses team-colored living fighter dots and the main camera viewport rectangle, so the minimap frame stays fixed while the main camera follows combat or meteor focus.
|
||||
- `ArenaScene` refreshes HUD candidates on an interval, choosing selected fighters plus nearby visible fighters when zoomed in, then releases unused HUD pool slots.
|
||||
|
||||
# Context: Arena & Scene
|
||||
|
||||
## 1. 모듈별 상세 역할 (`src/game/arena/`)
|
||||
|
||||
- **`ArenaScene.js`**: Phaser 씬의 생명주기와 전반적인 오케스트레이션을 담당합니다. `update()` 매 프레임마다 전투원 상태를 체크하고, 카메라 이동 및 UI 모듈 호출을 조율합니다.
|
||||
- **`arenaRenderer.js`**: 아레나 배경 그래픽 및 타일 렌더링을 담당합니다.
|
||||
- **`arenaRenderer.js`**: 아레나 배경 그래픽, 타일 및 팀별 스타팅 영역 오버레이 렌더링을 담당합니다.
|
||||
- **`arenaSpectatorCamera.js`**: 관전 모드 시점 계산 및 카메라 포커싱 로직을 담당합니다. 생존 인원에 따른 지능형 카메라 추적 알고리즘이 구현되어 있습니다.
|
||||
|
||||
## 2. 주요 로직 구현 세부 사항
|
||||
@@ -13,11 +150,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`마다 생존 캐릭터 중 한 명을 무작위로 포커싱합니다.
|
||||
자동 관전은 월드 이펙트 임시 시점, 후반 진입과 최종교전 세부 포커싱으로 나뉩니다.
|
||||
- **메테오 임시 포커싱**: 자동 관전 진입 전 화염 또는 냉기 포격이 시작되면 가장 밀집한 큰 경고 구역의 중심을 임시로 확대 추적합니다. 큰 경고 표시 자체는 `WORLD_EFFECT.WARNING_DURATION_MS` 이후 사라지며, 카메라는 내부 소형 탄착들이 종료된 뒤 `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 +164,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()`를 시작하고, 첫 포격은 `WORLD_EFFECT.INTERVAL`, 이후 일반 포격은 `WORLD_EFFECT.REPEAT_INTERVAL`을 사용합니다. 새 매치/종료 때 `clearWorldEffects()`로 주기 타이머, 잔여 냉각 구역, 메테오 임시 포커스, 캐릭터 감속 배율을 정리합니다. Phaser 타이머를 사용하므로 일시정지 시간은 이 간격과 냉각 지속시간에 포함되지 않습니다.
|
||||
|
||||
+147
-5
@@ -1,29 +1,171 @@
|
||||
# Update: Aggregate Combat Worker Path
|
||||
|
||||
- Large-battle detached aggregate combat now tries to run through `src/game/combat/aggregateCombatWorker.js` before falling back to the synchronous aggregate path.
|
||||
- The main thread sends Transferable TypedArrays for detached model ids, position, HP, team key, movement speed, DPS, and frost state; Phaser objects and team/skin references stay on the main thread.
|
||||
- Worker results are applied only when the match id still matches and the model is still detached, preventing stale async results from overwriting visible/detail fighters.
|
||||
- Stale worker ids whose models have already been unregistered are skipped before reading model fields.
|
||||
- The main thread still performs `killFighterModel()` for worker-reported deaths so split-on-death, kill rewards, death stats, scoreboard updates, and match completion stay on the existing authoritative path.
|
||||
|
||||
# Update: Magic Attack Effect Pooling
|
||||
|
||||
- `spawnSpellEffect()` now acquires instant-spell visual sprites from a small per-texture pool and returns them when their attack animation completes.
|
||||
- Pooled spell effects are reset on reuse for texture frame, position, scale, depth, alpha, rotation, flip, active/visible state, and animation-complete listeners.
|
||||
- `clearCombatObjects()` now disposes through `disposeCombatObject()`, allowing active pooled spell effects to be returned during match cleanup while non-pooled projectiles, labels, heal effects, and world effects keep their destroy path.
|
||||
- Only magic/instant-spell visuals were pooled here; projectile hit objects and meteor/world-effect objects remain on their existing lifecycle.
|
||||
|
||||
# Update: Squad-Based Detached Combat
|
||||
|
||||
- Large-battle detached models are grouped into transient squads by arena cell, team id, and `PERFORMANCE.LARGE_BATTLE_AGGREGATE_SQUAD_SIZE`.
|
||||
- Squad AI does nearest-opposing-squad movement and group DPS resolution, then writes surviving members back into deterministic spiral slots around the squad center.
|
||||
- This removes per-frame movement/target AI for thousands of offscreen models; individual `updateFighterModel()` stays reserved for attached/detail fighters in the rolling camera window.
|
||||
- In large battles the combat target spatial index is built from attached/detail models, not the full model list, so visible individual AI no longer reintroduces an 8,000-model target scan.
|
||||
|
||||
# Update: Aggregate Detached Combat
|
||||
|
||||
- `updateAggregateDetachedCombat()` handles large-battle detached model-only fighters as coarse cell groups instead of invoking full `updateFighterModel()` AI for each offscreen model.
|
||||
- Every-frame work for detached models is now simple movement toward the nearest enemy aggregate cell; target scanning, attack windup, projectile scheduling, and animation locks are reserved for attached/detail fighters.
|
||||
- Aggregate damage is computed from group attack DPS on a throttled interval and applied to real `FighterModel` HP, so deaths, kill rewards, split-on-death, death stats, and winner checks remain tied to the existing combat state.
|
||||
- Aggregate kills pass `silentLog: true` to the model death path to avoid large offscreen death batches flooding the DOM kill log.
|
||||
|
||||
# Update: Large Battle Combat Frame Throttles
|
||||
|
||||
- `prepareCombatFrame()` now syncs model positions from `fighterByModelId` only, so detached/offscreen sprite records are not scanned just to no-op position sync.
|
||||
- Large battles reuse the target spatial index for `PERFORMANCE.LARGE_BATTLE_TARGET_INDEX_REFRESH_MS` instead of rebuilding the full 8,000-model grid every frame.
|
||||
- The defensive model-index audit now runs once per second instead of every frame.
|
||||
|
||||
# Update: Null-Safe Model Target Cache
|
||||
|
||||
- `resolveTargetEnemyModel()` now clears stale `targetModelId` values when the cached model can no longer be resolved or is no longer a living enemy.
|
||||
- `isValidEnemyTargetModel()` now null-checks both attacker and candidate models before reading team ids, preventing a removed/dead cached target from crashing the update loop.
|
||||
|
||||
# Update: Combat With Detached Render Sprites
|
||||
|
||||
- `prepareCombatFrame()` now syncs model position only from attached sprites; detached sprites are skipped so model-only movement remains authoritative.
|
||||
- `fighterForModelId()` may now return `null` for living fighters outside the rolling-window detail set, which intentionally routes movement, attacks, damage, and death through the model-only fallback.
|
||||
- The target spatial index still builds from `scene.fighterModels`; its `livingFighters` compatibility list now represents attached render sprites only, while `livingModels` remains the full combat list.
|
||||
- Model-only death asks `ArenaScene.removeDetachedFighterProxyForModel()` to remove the parked fighter entry, and `livingFighterProxyCount()` prevents any remaining dead entries from being re-registered.
|
||||
|
||||
# Update: Model-Only Combat Fallback
|
||||
|
||||
- `updateFighterModel()` no longer requires an attached Phaser sprite to keep a living fighter model moving and fighting.
|
||||
- If a render sprite exists, movement, animation, projectiles, and death presentation keep using the existing Sprite/Arcade path.
|
||||
- If no render sprite exists, movement updates model `x/y` directly, attacks schedule delayed model hits, damage writes to model HP, and death unregisters the model from `ArenaScene` indexes immediately.
|
||||
- Projectile and instant-spell model-only attacks preserve windup/effect/travel timing, but skip visual projectile/spell objects.
|
||||
- Kill reward and split-on-death can now run from model state, so offscreen sprite detachment does not stop combat resolution.
|
||||
|
||||
# Update: Model-Based Targeting And Spatial Index
|
||||
|
||||
- `ArenaScene.update()` now iterates `scene.fighterModels` and calls `updateFighterModel()` instead of driving combat directly from the sprite array.
|
||||
- `prepareCombatFrame()` still syncs active sprite positions into models, but the target spatial index is built from model records and stores model entries in each grid cell.
|
||||
- Target caching moved to `model.targetModelId`; validation checks model liveness and team identity before resolving the render sprite through `scene.fighterForModelId()`.
|
||||
- `combatTargetIndex` now exposes `livingModels` as the primary model list while keeping `livingFighters` as an attached-sprite compatibility list.
|
||||
- Attack execution, animation, projectiles, and HUD-facing effects still use sprites when they exist; detached participants resolve through the model-only path.
|
||||
|
||||
# Update: FighterModel Position Sync In Combat
|
||||
|
||||
- `prepareCombatFrame()` now syncs each sprite's current render position into its `FighterModel` before building the target spatial index.
|
||||
- Dormant/offscreen fighters keep advancing model `x/y` through `fighterAdapter.moveFighterToward()` while visible fighters continue to use Arcade movement and sync back into the model on the next combat frame.
|
||||
- Target-grid cell placement and nearest-enemy lookup use model position helpers, keeping the combat path ready for a future model-first update loop.
|
||||
- Attack execution still resolves a fighter sprite from `targetModelId` for movement, animation, and hit visuals. Removing that render dependency is a later migration step.
|
||||
|
||||
# Update: Fighter Adapter In Combat
|
||||
|
||||
- `combat.js` no longer owns fighter render/body helpers locally. It imports fighter position, distance, movement, detail visibility, animation, body-disable, and arena-clamp helpers from `fighterAdapter.js`.
|
||||
- Visible fighters still move through Arcade physics, while dormant fighters are advanced by the adapter with JS `x/y` math and arena clamping.
|
||||
- Target selection and camera/world-effect hit points now use adapter position helpers so disabled Arcade bodies do not leave stale centers behind.
|
||||
- Ranged attacks still render projectiles only when both attacker and defender are detailed; dormant participation resolves through delayed data hits.
|
||||
|
||||
# Update: Dormant Fighter Combat Simulation
|
||||
|
||||
- `updateFighterModel()` accepts `delta` and manually advances dormant fighters with disabled Arcade bodies using JS position math.
|
||||
- Visible fighters still use `scene.physics.moveToObject()` so nearby/on-screen motion keeps the existing Arcade movement behavior.
|
||||
- Attack/hurt animation locks are applied only to detailed fighters. Dormant fighters rely on cooldowns and delayed hit timers instead of animation-complete events.
|
||||
- Projectile attacks involving dormant fighters resolve as delayed data hits and skip Phaser projectile object creation.
|
||||
- Hit-point, camera, and world-effect helpers treat disabled bodies as stale and use fighter `x/y` instead.
|
||||
|
||||
# Update: Projectile And Target Grid Optimization
|
||||
|
||||
- Projectile hit detection now relies on `projectilePathHitsDefender()` only; it no longer creates one Arcade overlap collider per projectile because the path check already covers fast projectile travel against the defender hit area.
|
||||
- Projectile path/hit-area geometry is reused through module-level scratch objects to avoid repeated `Line`/`Rectangle` allocation during projectile updates.
|
||||
- The per-frame target spatial index now stores cells in a numeric array, avoiding string cell keys and `Map` writes during every combat frame.
|
||||
- `clearCombatObjects()` also clears `scene.combatTargetIndex` so match resets and LOD passes do not briefly reuse stale living-fighter lists.
|
||||
|
||||
# Update: Dense-Area Meteor Barrage
|
||||
|
||||
- `worldEffects.js` aggregates living fighters on the arena tile grid and uses a summed-area scan to select the `WORLD_EFFECT.AREA_TILES` square with the highest population.
|
||||
- That selected square is a warning/focus area. Each fire or frost event schedules `WORLD_EFFECT.IMPACT_COUNT_MIN` to `IMPACT_COUNT_MAX` smaller strikes inside it.
|
||||
- Tune the large warning visibility with `WORLD_EFFECT.WARNING_DURATION_MS`, actual damage/frost footprints with `WORLD_EFFECT.IMPACT_AREA_TILES`, sprite size with `WORLD_EFFECT.IMPACT_VISUAL_SCALE`, strike spacing with `WORLD_EFFECT.IMPACT_STAGGER_MS`, and per-strike variation with `WORLD_EFFECT.SIZE_SCALE_VARIANCE`.
|
||||
- `WORLD_EFFECT.INTERVAL` sets the delay before the first barrage; subsequent normal barrages use `WORLD_EFFECT.REPEAT_INTERVAL`, with `SUDDEN_DEATH.INTERVAL_MS` taking over once sudden death is active.
|
||||
- Meteor impact shake uses the same size multiplier, scaling from `WORLD_EFFECT.METEOR_SHAKE_DURATION_MS` and `WORLD_EFFECT.METEOR_SHAKE_INTENSITY`.
|
||||
|
||||
# Update: Large Battle Targeting
|
||||
|
||||
- `combat.js` now prepares a per-frame target spatial index through `prepareCombatFrame(scene)`.
|
||||
- `resolveTargetEnemy()` keeps valid cached targets until their scan interval expires, then immediately looks up a fresh nearest enemy through the spatial grid.
|
||||
- Nearest enemy lookup searches grid cells outward from the fighter's current cell, with full-array scanning kept only as a fallback when no frame index exists.
|
||||
- Large-battle corpse cleanup uses `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` and `PERFORMANCE.LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS` from `src/constants.js`.
|
||||
|
||||
# Update: Team Shadow Animations And Frost Tint
|
||||
|
||||
- Dead fighters keep their death animation/corpse state at initial opacity, then fade out until `combat.js` removes them from `scene.fighters` and destroys the sprite.
|
||||
- Tune that fade/despawn lifetime with `FIGHTER.DEAD_DESPAWN_DELAY_MS` and the final alpha with `FIGHTER.DEAD_DESPAWN_ALPHA` in `src/constants.js`.
|
||||
- Fighter action playback now goes through `fighterAdapter.playFighterAction()` / `playFighterActionIfNeeded()`, which resolve animation keys with `ensureFighterTeamAnimation()` so every action can use the team-shadow baked texture generated from the original spritesheet.
|
||||
- The adapter compares against the team-shadow animation key before replaying an action. This avoids switching back to the original non-team-colored spritesheet when fighters move, attack, take damage, or die.
|
||||
- Frost stun remains a body tint effect in `worldEffects.js`. Since team identity is baked into the floor shadow pixels, there is no `teamMarker` tint state to update or restore.
|
||||
- The removed `teamMarker` display object means death handling no longer needs to hide or destroy a separate marker. HUD cleanup only owns pooled health-bar objects.
|
||||
|
||||
# Update: Focused Combat Effects In Large Battles
|
||||
|
||||
- `combat.js` exposes supplemental combat visuals only while a large battle is inside the temporary meteor camera-focus window.
|
||||
- Outside that window, large battles skip critical labels, instant-spell sprites, kill-heal sprites, and kill-growth tweens while retaining the underlying damage and reward calculations.
|
||||
- World-effect meteor/frost visuals remain visible, and projectile objects remain enabled because projectiles currently participate in hit detection.
|
||||
- Projectile objects should keep calling `projectilePathHitsDefender()` for collision checks instead of adding per-projectile Arcade overlap colliders.
|
||||
|
||||
# Context: Combat System
|
||||
|
||||
## 1. 모듈별 상세 역할 (`src/game/combat/`)
|
||||
|
||||
- **`combat.js`**: 전투 AI, 피해 계산, 처치 보상 등 핵심 전투 로직을 담당합니다. 유닛의 이동, 공격, 투사체 발사 등을 처리합니다.
|
||||
- **`combat.js`**: 전투 AI, 피해 계산, 처치 보상 등 핵심 전투 로직을 담당합니다. `fighterStats.js`에서 해석한 역할별 수치로 이동, 공격, 투사체 발사 등을 처리합니다.
|
||||
- **`combatSettings.js`**: 전투 속도 배율 등 런타임 전투 설정을 관리합니다.
|
||||
- **`arenaFinalCombatEffects.js`**: 최종 교전 시 슬로우 모션 등 연출 효과를 담당합니다. 수학적인 이징(easing) 함수와 물리 시간 배율 계산을 포함합니다.
|
||||
- **`worldEffects.js`**: 실제 전투에서 설정 주기마다 생존자 밀집 구역을 탐색하고 화염/냉기 소형 메테오 포격을 실행하며, 대각선 낙하 연출, 개별 탄착 판정, 냉기 동결과 감속 구역 수명주기를 처리합니다.
|
||||
|
||||
## 2. 주요 로직 구현 세부 사항
|
||||
|
||||
### 전투 AI 및 유닛 동작
|
||||
- **`updateFighter()`**: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
|
||||
- **`applyHit()`**: 일반 공격 피해량은 `ATTACK_DAMAGE_MIN/MAX` 범위에서 계산하고, 치명타 적중은 `Critical!` 표기와 즉시 처치/카메라 흔들림을 처리합니다.
|
||||
- **`updateFighterModel()`**: 가장 가까운 적 모델을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
|
||||
- **`applyHit()`**: 일반 공격 피해량은 공격자의 `melee`/`ranged`/`magic` 프로필 피해량 범위에서 계산하고, 치명타 적중은 `Critical!` 표기와 즉시 처치를 처리합니다.
|
||||
- **역할별 기본값**: `src/constants.js`의 `FIGHTER_TYPE_STATS`에서 체력, 이동속도, 사거리, 공격 쿨다운, 피해량, 치명타 확률, 발동 지연을 독립적으로 조절합니다. 투사체 속도는 `ranged`, 효과 적중 지연은 `magic` 프로필에 포함됩니다.
|
||||
- **`projectilePathHitsDefender()`**: 투사체가 대상을 스쳐 지나가지 않도록 궤적(Line)과 히트박스(Rectangle) 겹침 검사를 수행합니다.
|
||||
|
||||
### 처치 보상 및 성장
|
||||
- **`applyKillReward()`**: 처치한 캐릭터의 체력 회복(현재 체력 30%), 크기 증가, 공격속도/이동속도 배율 증가를 처리합니다. 누적 배율은 `KILL_GROWTH_MAX_MULTIPLIER`로 제한합니다.
|
||||
- **`clampFighterInsideArena()`**: 처치 성장 중 커진 캐릭터가 전장 바깥으로 나가지 않도록 위치를 보정합니다.
|
||||
|
||||
### 월드 이펙트
|
||||
- **발동 규칙**: 프리뷰가 아닌 실제 전투에서 시작 후 첫 포격은 `WORLD_EFFECT.INTERVAL`이 지난 뒤 발생하고, 이후 일반 포격은 `WORLD_EFFECT.REPEAT_INTERVAL` 간격으로 발생합니다. 각 포격은 `AREA_TILES` 크기의 모든 후보 구역을 타일 누적합으로 평가해, 생존 캐릭터가 가장 많이 모인 범위를 선택합니다. 같은 밀도의 후보가 여러 개일 때만 그 후보 사이에서 무작위로 고릅니다.
|
||||
- **포격 판정**: 선택된 큰 범위는 경고 표시와 카메라 포커스 대상으로 사용되고, 내부에 투하되는 작은 탄착 영역만 피해, 기절, 냉기 감속을 처리합니다. 이 때문에 넓은 밀집지대를 위협하면서도 영역 전체를 즉시 동일 피해로 덮지 않습니다.
|
||||
- **서든 데스 (Sudden Death)**:
|
||||
- **조건**: 매치 시작 후 `WORLD_EFFECT.SUDDEN_DEATH.TRIGGER_MS` 시간이 경과하면 서든 데스 상태에 진입합니다 (활성화 시).
|
||||
- **효과**: 메테오 투하 주기가 `SUDDEN_DEATH.INTERVAL_MS`로 단축되며, `FORCE_FROST` 설정 시 빙결 효과를 가진 냉기 메테오가 집중적으로 생성됩니다.
|
||||
- **목적**: 장기전을 방지하고 전장에 무작위 변수를 극대화하여 물량 중심 팀에게 리스크를 부여합니다.
|
||||
- **낙하 방향과 크기**: 대상이 전장 좌측 반면(2, 3사분면)이면 화살표가 좌상단에서 우하단으로, 우측 반면(1, 4사분면)이면 좌우 반전되어 우상단에서 좌하단으로 이동합니다. 스프라이트를 45도로 기울이고 전용 시각 배율을 사용해 전역 마법 규모로 표현합니다.
|
||||
- **화염 메테오**: `world_Effect.png`의 소형 탄착 애니메이션 3~4개가 밀집 경고 구역 내부로 순차 낙하합니다. 각 탄착은 크기에 따른 화면 흔들림과 개별 영역 고정 피해를 적용합니다. 환경 피해로 인한 사망은 킬 보상을 지급하지 않지만 사망 통계와 승패 판정에는 반영됩니다.
|
||||
- **냉기 메테오**: `world_Effect_2.png`의 소형 탄착 애니메이션들이 같은 방식으로 낙하합니다. 개별 탄착 피해를 입고 생존한 대상은 얼음색으로 바뀐 채 설정 시간 동안 기절하며, 각 탄착점에 남는 냉각지대 안에서는 공격속도와 이동속도 감속 배율을 적용합니다.
|
||||
|
||||
### 최종교전 슬로우모션
|
||||
`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`로 피해량을 조정합니다.
|
||||
- `SUDDEN_DEATH.ENABLED`로 서든 데스 활성화 여부를 결정하며, `TRIGGER_MS`(시작 시간), `INTERVAL_MS`(주기), `FORCE_FROST`(냉기 고정) 설정을 변경할 수 있습니다.
|
||||
- `INTERVAL`은 첫 포격까지의 대기 시간, `REPEAT_INTERVAL`은 이후 일반 포격 주기입니다. `AREA_TILES`는 밀집도를 검색하고 경고로 표시할 큰 구역이며, `WARNING_DURATION_MS`는 그 경고 표시 시간입니다. `IMPACT_AREA_TILES`, `IMPACT_COUNT_MIN`/`IMPACT_COUNT_MAX`, `IMPACT_STAGGER_MS`, `IMPACT_VISUAL_SCALE`는 내부 소형 포격의 판정 범위, 발수, 간격, 시각 크기를 조정합니다.
|
||||
- `WORLD_EFFECT.FROST_STUN_DURATION`/`FROST_STUN_TINT`로 동결 시간과 표시 색상을 조정합니다.
|
||||
- 나머지 `WORLD_EFFECT.*` 값으로 발동 주기, 범위, 냉각 지속시간과 감속 정도를 수정하며, 메테오 착탄 위치 포커싱은 `CAMERA.METEOR_FOCUS_ENABLED`에서 켜고 끕니다.
|
||||
- **특수 규칙**: 캐릭터별 특수 공격 방식은 `fighterManifest.js`의 `combat` 설정을 확인합니다.
|
||||
|
||||
+91
-5
@@ -1,5 +1,87 @@
|
||||
# Update: Worker Entrypoints
|
||||
|
||||
- `src/game/arena/fighterLodWorker.js` is bundled as a Vite module worker for large-battle render LOD candidate selection.
|
||||
- It is separate from `src/game/combat/aggregateCombatWorker.js`: LOD worker chooses which sprites should be detailed, while aggregate combat worker advances detached/offscreen combat math.
|
||||
|
||||
# Update: Full Rolling-Window Detail Constants
|
||||
|
||||
- Focused large-battle rendering no longer uses a separate zoomed sprite cap or rolling-window buffer ratio.
|
||||
- `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT` is the bounded representative sprite count for full-arena overview.
|
||||
- `PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_SCALE` and `PERFORMANCE.LARGE_BATTLE_SPRITE_VIEW_PADDING` define the focused camera window whose living fighters are all promoted to detailed sprites.
|
||||
|
||||
# Update: Web Worker Aggregate Path
|
||||
|
||||
- `aggregateCombatWorker.js` is bundled as a Vite module worker and is used only for detached/offscreen aggregate combat math.
|
||||
- Main-thread combat keeps the authoritative Phaser/game-state mutations, while the worker exchanges Transferable TypedArrays for model position, HP, team, speed, DPS, and death results.
|
||||
- Worker failure disables the worker path and leaves the synchronous aggregate fallback active.
|
||||
|
||||
# Update: LOD Traversal Reduction
|
||||
|
||||
- Large-battle LOD now removes parked fighter bodies from Arcade World's active body set and re-enables them only when a fighter becomes detailed again.
|
||||
- LOD refreshes still use `PERFORMANCE.LARGE_BATTLE_LOD_REFRESH_MS`, but detail visibility changes are now applied as set differences after initial activation.
|
||||
- `PERFORMANCE.LARGE_BATTLE_SPRITE_VIEW_PADDING` also pads the dot redraw culling view so zoomed camera movement does not require drawing every offscreen LOD dot.
|
||||
|
||||
# Update: Aggregate Combat Constants
|
||||
|
||||
- `PERFORMANCE.LARGE_BATTLE_AGGREGATE_COMBAT_REFRESH_MS` controls the detached/offscreen aggregate combat tick interval.
|
||||
- `PERFORMANCE.LARGE_BATTLE_AGGREGATE_CELL_SIZE` controls the coarse combat grid size used for large-battle detached model groups.
|
||||
- `PERFORMANCE.LARGE_BATTLE_AGGREGATE_SQUAD_SIZE` controls how many detached fighters are represented by one squad in a cell/team group.
|
||||
- `PERFORMANCE.LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_CELL_TICK` and `LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_TICK` cap batched deaths to avoid a single aggregate tick creating a large DOM/game-state spike.
|
||||
- `PERFORMANCE.LARGE_BATTLE_AGGREGATE_MOVEMENT_RATIO` tunes the speed of detached models moving toward their nearest aggregate enemy cell.
|
||||
|
||||
# Update: Large Battle Throttle Constants
|
||||
|
||||
- `PERFORMANCE.LARGE_BATTLE_SIMULATION_BUCKETS` spreads detached model-only combat updates across frames during large live matches.
|
||||
- `PERFORMANCE.LARGE_BATTLE_SIMULATION_MAX_DELTA_MS` caps the accumulated delta used by throttled detached fighters.
|
||||
- `PERFORMANCE.LARGE_BATTLE_TARGET_INDEX_REFRESH_MS` controls how often the full target spatial index is rebuilt in large battles.
|
||||
- `PERFORMANCE.WORLD_EFFECT_MODIFIER_REFRESH_MS` throttles frost-zone speed modifier scans.
|
||||
- `PERFORMANCE.LARGE_BATTLE_HUD_VISIBLE_LIMIT` caps pooled fighter HUD health bars separately from normal battles.
|
||||
- The 8,000-fighter full-arena overview budget is intentionally tight through `LARGE_BATTLE_SPRITE_RENDER_LIMIT`; focused rolling-window views promote all fighters in the local window.
|
||||
|
||||
# Update: Fighter Render LOD Constants
|
||||
|
||||
- `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT` caps the number of representative detailed fighter sprites kept visible in the full-arena overview during large live battles.
|
||||
- `PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_SCALE` makes the sprite-ready area larger than the exact camera view.
|
||||
- `PERFORMANCE.LARGE_BATTLE_SPRITE_VIEW_PADDING` provides a minimum rolling-window expansion even at tighter zooms.
|
||||
- `PERFORMANCE.LARGE_BATTLE_LOD_REFRESH_MS` throttles detailed-set recomputation, and `PERFORMANCE.LARGE_BATTLE_DOT_REFRESH_MS` throttles the shared dot overlay redraw.
|
||||
- `PERFORMANCE.LARGE_BATTLE_DOT_SIZE` and `PERFORMANCE.LARGE_BATTLE_DOT_ALPHA` tune the hidden-fighter dot representation.
|
||||
- `CAMERA.LARGE_BATTLE_START_ZOOM` controls the initial zoom used when a live match starts as a large battle.
|
||||
- `CAMERA.MANUAL_FOCUS_TWEEN_MS` and `CAMERA.MANUAL_FOCUS_TWEEN_EASE` tune manual camera pan/zoom transitions used by fighter/team selection and full-arena return.
|
||||
|
||||
# Update: Phaser Render Tuning
|
||||
|
||||
- `src/constants.js` exports `RENDER` for the Phaser canvas resolution. The arena remains `ARENA.SIZE = 3200`, while the canvas now renders at `1280 x 1280`.
|
||||
- `CAMERA.MIN_ZOOM` is derived from render size versus arena size so full-arena overview still works at the lower internal canvas resolution.
|
||||
- `src/main.js` keeps `pixelArt: true` and now also sets `autoRound: true` plus `powerPreference: "high-performance"` for the Phaser game config.
|
||||
- `PERFORMANCE.MINIMAP_REFRESH_MS` centralizes the live minimap redraw interval so large battles avoid redrawing thousands of dots on every scene update.
|
||||
|
||||
# Context: Core & Infrastructure
|
||||
|
||||
# Update: Dense-Area Meteor Barrage
|
||||
|
||||
- `WORLD_EFFECT.AREA_TILES` now defines the large warning/search square selected from the densest living-fighter region.
|
||||
- `WORLD_EFFECT.WARNING_DURATION_MS` controls how long that large warning marker stays visible without changing the scheduled strikes.
|
||||
- `WORLD_EFFECT.IMPACT_AREA_TILES`, `IMPACT_COUNT_MIN`, `IMPACT_COUNT_MAX`, `IMPACT_STAGGER_MS`, and `IMPACT_VISUAL_SCALE` configure the smaller strikes fired inside that warning square.
|
||||
- `WORLD_EFFECT.SIZE_SCALE_VARIANCE` randomizes each fire/frost impact around the smaller strike size.
|
||||
- `WORLD_EFFECT.INTERVAL` schedules the first barrage after match start, and `WORLD_EFFECT.REPEAT_INTERVAL` schedules later normal barrages.
|
||||
- Meteor impact shake strength follows the same size multiplier, using `WORLD_EFFECT.METEOR_SHAKE_DURATION_MS` and `WORLD_EFFECT.METEOR_SHAKE_INTENSITY` as base values.
|
||||
|
||||
# Update: Direct Fighter Counts And Match Cap
|
||||
|
||||
- Live-match name entries use the `name*N` suffix as the assigned fighter count; a name without `*N` creates one assigned fighter.
|
||||
- `SPAWN.MAX_FIGHTER_COUNT` is the maximum for participant-assigned fighter slots and is currently 8,000; Slime trait-generated fighters are excluded.
|
||||
- Limit failures during live-match setup are surfaced beneath the participant nickname input.
|
||||
- `SPAWN.FIGHTERS_PER_STARTING_ZONE` controls starting-zone distribution; each additional block of that many assigned fighters adds a team zone.
|
||||
|
||||
# Update: Performance Constants
|
||||
|
||||
- `src/constants.js` now exports `PERFORMANCE` for large-battle tuning: fighter threshold, target grid size, HUD pool/candidate limits, graphics minimap settings/redraw interval, and large-battle dead despawn delay.
|
||||
- Keep large-battle behavior switches tied to `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` so high-count match tuning stays centralized.
|
||||
|
||||
# Update: Dead Fighter Despawn Constant
|
||||
|
||||
- `FIGHTER.DEAD_DESPAWN_DELAY_MS` controls how long a dead fighter fades before disappearing; `FIGHTER.DEAD_DESPAWN_ALPHA` controls the fade target.
|
||||
|
||||
## 1. 모듈별 상세 역할
|
||||
|
||||
- **`src/main.js`**: Phaser 게임의 전역 설정(Physics, Scale, Canvas Parent)을 담당하며, `ArenaScene`을 인스턴스화합니다.
|
||||
@@ -7,13 +89,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.*`: 첫/반복 포격 간격, 밀집 경고 범위, 개별 탄착 범위/발수/시각 배율, 대각선 낙하 거리, 화염/냉기 메테오 피해량, 냉기 동결 시간/색상, 냉각지대 지속시간과 감속 배율.
|
||||
- `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 +106,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.REPEAT_INTERVAL`, `WORLD_EFFECT.AREA_TILES`, `WORLD_EFFECT.WARNING_DURATION_MS`, `WORLD_EFFECT.IMPACT_AREA_TILES`, `WORLD_EFFECT.IMPACT_COUNT_MIN`, `WORLD_EFFECT.IMPACT_COUNT_MAX`, `WORLD_EFFECT.IMPACT_STAGGER_MS`, `WORLD_EFFECT.IMPACT_VISUAL_SCALE`, `WORLD_EFFECT.SIZE_SCALE_VARIANCE`, `WORLD_EFFECT.FALL_TRAVEL_TILES`, `WORLD_EFFECT.METEOR_SHAKE_DURATION_MS`, `WORLD_EFFECT.METEOR_SHAKE_INTENSITY`, `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`를 수정합니다. `INTERVAL`은 첫 포격까지의 대기 시간, `REPEAT_INTERVAL`은 이후 일반 포격 주기, `AREA_TILES`와 `WARNING_DURATION_MS`는 밀집 경고 구역과 표시 시간이며, `IMPACT_*` 값은 그 내부 실제 포격을 제어합니다. 임시 메테오 카메라는 `CAMERA.METEOR_FOCUS_ENABLED`로 끌 수 있습니다.
|
||||
- **DOM 접근**: 성능을 위해 `ArenaScene`은 좌측 HUD badge 등 필요한 시점에만 최소한으로 DOM에 접근합니다.
|
||||
- **패키지 락 파일**: 이 프로젝트는 `package-lock.json`을 저장소에서 제외합니다. 의존성 변경 시 `package.json`을 기준으로 관리합니다.
|
||||
|
||||
+82
-2
@@ -1,10 +1,83 @@
|
||||
# Update: Parked Body Removal From Arcade World
|
||||
|
||||
- `disableFighterBody()` now calls `scene.physics.world.disable(fighter)` for parked or dead fighter sprites, removing the body from Arcade World's active body set instead of only setting `body.enable = false`.
|
||||
- `enableFighterBody()` re-adds the sprite body with `world.enable(fighter)`, resets it to the model position, stops residual velocity, and syncs the model from the reattached sprite.
|
||||
- `setFighterDetailVisible()` uses these adapter helpers for LOD detach/reattach, so render parking also removes offscreen bodies from physics traversal.
|
||||
- `isLivingFighterModel()` now returns false for missing/null models so stale async ids and removed models cannot pass living checks.
|
||||
|
||||
# Update: Parked Fighter Detail Early Return
|
||||
|
||||
- `setFighterDetailVisible(false)` now returns immediately when a fighter is already parked, avoiding repeated body/input/HUD/display-list work during large-battle LOD refreshes.
|
||||
|
||||
# Update: Detached Fighter Animation Guard
|
||||
|
||||
- `shouldRenderFighterDetail()` now requires an actual active fighter object before returning true, preventing model-only large-battle combat from trying to animate a `null` sprite.
|
||||
- `playFighterAction()` and `playFighterActionIfNeeded()` now skip playback if no animation key can be resolved.
|
||||
|
||||
# Update: Fighter Sprite Render Recovery
|
||||
|
||||
- `createFighter()` returns a real Phaser Sprite again, with combat-facing fields bridged to `fighter.model`.
|
||||
- The lazy `SpriteProxy` pool was rolled back because the proxy handoff could leave the simulation data alive while no stable Phaser render object was visible.
|
||||
- `attachSprite: false` is still accepted for large-battle startup, but it now creates the sprite and immediately parks it through `setFighterDetailVisible(false)` instead of skipping Sprite creation.
|
||||
- Rolling-window LOD still removes non-detailed sprites from Phaser's display/update lists and restores the same sprite from model `x/y` when it becomes detailed again.
|
||||
|
||||
# Update: Fighter Render Sprite Detach
|
||||
|
||||
- `setFighterDetailVisible(false)` now parks a fighter sprite by disabling body/input/HUD, pausing animation, hiding it, and removing it from Phaser's display and update lists.
|
||||
- `setFighterDetailVisible(true)` reattaches the same sprite, resets its body from model `x/y`, resumes animation, and restores pointer interaction for living fighters.
|
||||
- `syncFighterModelFromSprite()` ignores detached sprites so offscreen model-only movement cannot be overwritten by a stale parked sprite position.
|
||||
- Adapter helpers treat `_spriteDetached` as non-rendered/non-body state, so animation, body position, and projectile path logic naturally fall back to model data.
|
||||
|
||||
# Update: FighterModel Shell
|
||||
|
||||
- `fighterModel.js` now creates the pure JS state record for a fighter. The model owns combat-facing fields including HP, team/skin references, `targetModelId`/cooldown state, selection, lock/death flags, kill-growth state, frost state, detail visibility, facing, and model `x/y`.
|
||||
- `attachFighterModel()` connects a Phaser sprite to its model and preserves the existing `fighter.hp`, `fighter.team`, `fighter.isDead`, etc. surface through getter/setter bridges. This keeps the current code stable while making `fighter.model` the state home.
|
||||
- `isLivingFighterModel()` and `fighterModelDistanceSquared()` support model-first combat code without requiring a Sprite wrapper.
|
||||
- `fighterFactory.js` creates a Phaser Sprite with an attached `fighter.model` bridge. HUD slots, timers, scale, and input hit areas remain render concerns.
|
||||
- `fighterAdapter.js` updates model `x/y` when sprites are synced or when dormant fighters move manually, and now treats detached proxies as model-only for body/render checks.
|
||||
|
||||
# Update: Fighter Adapter Layer
|
||||
|
||||
- `fighterAdapter.js` centralizes fighter-facing Phaser operations: `fighterWorldPoint()`, `fighterDistanceSquared()`, `setFighterFacing()`, `moveFighterToward()`, `stopFighterMovement()`, `enableFighterBody()`, `disableFighterBody()`, `clampFighterInsideArena()`, animation playback, and frost tint helpers.
|
||||
- `fighterFactory.js` owns sprite creation plus detail visibility; offscreen fighters remain model-backed while their sprite is parked outside Phaser render/update traversal.
|
||||
- Treat the adapter as the boundary for the upcoming model/proxy split. Code outside `src/game/fighter/` should avoid new direct fighter `body`, `setFlipX()`, `setVelocity()`, or animation calls unless it is explicitly dealing with a non-fighter object.
|
||||
|
||||
# Update: Dormant Fighter Detail State
|
||||
|
||||
- `setFighterDetailVisible(false)` now makes a non-detailed fighter dormant and detached from Phaser render/update traversal.
|
||||
- `setFighterDetailVisible(true)` re-enables the body at the model position, resumes animation, and restores pointer interaction for living fighters.
|
||||
- Dormant fighters remain sprite/model records in `this.fighters` so existing match arrays, ownership, death stats, split-on-death, and team bookkeeping remain intact.
|
||||
|
||||
# Update: Fighter Detail Visibility For LOD
|
||||
|
||||
- `fighterFactory.js` exposes `setFighterDetailVisible()` so `ArenaScene` can hide or restore the detailed Phaser sprite for large-battle render LOD without removing the fighter from combat simulation.
|
||||
- Hidden fighters release borrowed HUD slots and disable pointer interaction; visible living fighters keep their original hit-area based interaction.
|
||||
- `syncFighterHud()` now treats invisible fighters as HUD-ineligible, preventing hidden LOD fighters from holding health-bar display objects.
|
||||
- Detached LOD fighters now pause animation safely because combat locks and delayed hits can resolve through the model-only fallback while the sprite is parked.
|
||||
|
||||
# Update: HUD Pooling
|
||||
|
||||
- `fighterFactory.js` no longer creates permanent HUD objects for every fighter; zoom HUD now shows health bars without fighter name labels.
|
||||
- HUD health-bar display objects are pooled on the scene and assigned only to selected fighters or zoom-visible nearby fighters chosen by `ArenaScene`.
|
||||
- `syncFighterHud()` acquires a slot lazily and `releaseFighterHud()` returns it to the pool when the fighter leaves the HUD candidate set, dies, or is destroyed.
|
||||
- Tune pool size and visible candidate limits in `PERFORMANCE.FIGHTER_HUD_POOL_SIZE` and `PERFORMANCE.FIGHTER_HUD_VISIBLE_LIMIT`.
|
||||
|
||||
# Update: Team Shadow Sprite Optimization
|
||||
|
||||
- Team identity is no longer rendered with a duplicated `teamMarker` sprite. `fighterFactory.js` now creates only the main Phaser sprite for each fighter.
|
||||
- `fighterAssets.js` lazily creates team-colored spritesheets for the actual `skin + action + teamColor` combinations used in a match. The derived texture keeps the original character art and replaces only the floor shadow color (`#534545`) in the lower frame band (`y=55..59`) with the team color.
|
||||
- Combat animation playback calls `ensureFighterTeamAnimation()` before switching actions, so idle, walk, attack, hurt, and death states keep the same team shadow treatment.
|
||||
- This reduces display-list cost from `fighter sprite + teamMarker sprite` to a single fighter sprite. The tradeoff is extra texture memory for team-colored derivatives, so derived textures must stay lazy and should not be pre-generated for the whole manifest.
|
||||
- Frost stun still uses the fighter body's `setTint(WORLD_EFFECT.FROST_STUN_TINT)`. Do not reuse tint for team identity; team color is baked into the shadow pixels instead.
|
||||
|
||||
# Context: Fighter & Assets
|
||||
|
||||
## 1. 모듈별 상세 역할 (`src/game/fighter/`)
|
||||
|
||||
- **`fighterAssets.js`**: 캐릭터 스프라이트 로드 및 애니메이션/실루엣 생성을 담당합니다. 원본 이미지로부터 팀 색상 마커용 실루엣을 동적으로 생성합니다.
|
||||
- **`fighterFactory.js`**: 캐릭터 인스턴스화 및 HUD(이름표, 체력바) 관리를 담당합니다. Phaser Sprite와 DOM UI 사이의 가교 역할을 합니다.
|
||||
- **`fighterFactory.js`**: 캐릭터 인스턴스화 및 HUD 체력바 관리를 담당합니다. Phaser Sprite와 DOM UI 사이의 가교 역할을 합니다.
|
||||
- **`fighterManifest.js`**: 모든 캐릭터 종족 및 스탯 데이터를 정의합니다. 20여 종의 캐릭터 설정이 포함되어 있습니다.
|
||||
- **`fighterStats.js`**: 공격 방식으로 `melee`, `ranged`, `magic` 역할을 판별하고 역할별 기본 스탯과 스킨별 오버라이드를 병합합니다.
|
||||
- **`fighterSelection.js`**: 매치 참여 캐릭터를 무작위로 선택하거나 섞는 로직을 담당합니다.
|
||||
|
||||
## 2. 주요 로직 구현 세부 사항
|
||||
@@ -17,14 +90,21 @@
|
||||
4. 캐릭터가 성장하여 커져도 같은 배율로 실루엣이 유지됩니다.
|
||||
|
||||
### 캐릭터 HUD 및 상태 동기화
|
||||
- **이름표 고정**: 스프라이트 중심이 아닌 실제 히트박스 하단에 고정되어 시각적 일관성을 유지합니다.
|
||||
- **체력바 표시**: 줌 또는 선택 상태에서 후보 fighter만 pooled HUD slot을 빌려 체력바를 표시합니다. 이름표는 zoom 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개 종족 중 하나를 반드시 선택해야 합니다.
|
||||
|
||||
+13
-4
@@ -1,5 +1,12 @@
|
||||
# Context: Match & UI
|
||||
|
||||
# Update: Direct Fighter Count Entries And Zone Distribution
|
||||
|
||||
- Match setup no longer exposes a separate team-size control. Each live entry uses `nickname*N` for that team's assigned fighter count, with plain names defaulting to one fighter.
|
||||
- A live match is rejected before replacing the current match only when the participant-assigned count exceeds `SPAWN.MAX_FIGHTER_COUNT`; Slime `spawnMultiplier` and `splitOnDeath` results may grow the actual live population past it.
|
||||
- Fighter-count cap validation is reported as a visually distinct warning card below the participant nickname textarea, with requested and allowed counts emphasized separately; it clears when that input changes or a valid live match is submitted.
|
||||
- Starting-zone placement assigns one zone per `SPAWN.FIGHTERS_PER_STARTING_ZONE` fighters in each team and puts any remainder in that team's final zone.
|
||||
|
||||
## 1. 모듈별 상세 역할
|
||||
|
||||
### 매치 로직 (`src/game/match/`)
|
||||
@@ -17,15 +24,17 @@
|
||||
## 2. 주요 로직 구현 세부 사항
|
||||
|
||||
### 매치 설정 및 스폰 배치
|
||||
- **완전 랜덤 배치**: 전장 전체 스폰 슬롯을 무작위로 섞어 배치합니다.
|
||||
- **스타팅 지점 배치**: 참가자 수에 맞춰 전장을 구역으로 나눈 뒤, 참가자별 구역 배정을 매치마다 섞고 구역 내 무작위 위치에 스폰합니다.
|
||||
- **닉네임 배수 시스템**: `닉네임*배수` 형식(예: `Alice*2`)을 감지하여 팀 인원을 배수만큼 생성합니다.
|
||||
- **구매 배수 보존과 독주 견제**: 배수 팀의 생성 인원과 전투 수치는 결제 이점으로 유지합니다. 월드 이펙트 표적 선정에서는 `team.multiplier`를 구매 지분으로 사용하고, 그 지분을 초과해 생존 중인 팀에만 설정 가능한 추가 표적 가중치를 적용합니다.
|
||||
- **스타팅 지점 배치 (멀티 스폰)**: 팀마다 전장 스폰 가능 그리드에서 중심 셀을 무작위로 고르고, 중심 주변 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`에 추가하여 중앙 집중식으로 관리합니다.
|
||||
+67
-29
@@ -4,7 +4,29 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Arena Picker</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>⚔️</text></svg>">
|
||||
|
||||
<!-- OpenGraph Meta Tags -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Arena Picker" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="친구들의 닉네임을 입력하고 최후의 승자를 가려보세요! 화려한 픽셀 아트 기반 자동 전투 시뮬레이션."
|
||||
/>
|
||||
<meta property="og:image" content="/assets/og-image.png" />
|
||||
|
||||
<!-- Twitter Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Arena Picker" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="친구들의 닉네임을 입력하고 최후의 승자를 가려보세요! 화려한 픽셀 아트 기반 자동 전투 시뮬레이션."
|
||||
/>
|
||||
<meta name="twitter:image" content="/assets/og-image.png" />
|
||||
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>⚔️</text></svg>"
|
||||
/>
|
||||
<style>
|
||||
html.app-booting,
|
||||
html.app-booting body {
|
||||
@@ -81,9 +103,12 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="intro-stage" aria-label="Arena 시작 화면">
|
||||
<section class="intro-stage" aria-label="Arena Picker 시작 화면">
|
||||
<div class="intro-content">
|
||||
<h1 class="arena-logo">Arena</h1>
|
||||
<h1 class="arena-logo" aria-label="Arena Picker">
|
||||
<span>ARENA</span>
|
||||
<span class="small-text">PICKER</span>
|
||||
</h1>
|
||||
<button
|
||||
id="start-button"
|
||||
class="start-button"
|
||||
@@ -137,8 +162,13 @@
|
||||
<form id="fighter-form" autocomplete="off">
|
||||
<fieldset>
|
||||
<legend>Players</legend>
|
||||
<label for="player-names">참가자 닉네임</label>
|
||||
<textarea id="player-names" name="playerNames" rows="10">
|
||||
<label for="player-names">참가자 닉네임 (*숫자 = 출전 인원)</label>
|
||||
<textarea
|
||||
id="player-names"
|
||||
name="playerNames"
|
||||
rows="10"
|
||||
aria-describedby="player-names-warning"
|
||||
>
|
||||
Player 1
|
||||
Player 2
|
||||
Player 3
|
||||
@@ -150,31 +180,35 @@ Player 8
|
||||
Player 9
|
||||
Player 10</textarea
|
||||
>
|
||||
<div
|
||||
id="player-names-warning"
|
||||
class="player-names-warning"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
hidden
|
||||
>
|
||||
<span class="player-names-warning__badge" aria-hidden="true">⚠</span>
|
||||
<strong class="player-names-warning__title" data-player-names-warning-title
|
||||
>최대 출전 인원 초과</strong
|
||||
>
|
||||
<p class="player-names-warning__detail">
|
||||
출전 인원
|
||||
<strong class="player-names-warning__count" data-player-names-warning-count
|
||||
>0</strong
|
||||
>명 / 최대
|
||||
<strong class="player-names-warning__limit" data-player-names-warning-limit
|
||||
>0</strong
|
||||
>명
|
||||
</p>
|
||||
<p
|
||||
class="player-names-warning__reason"
|
||||
data-player-names-warning-reason
|
||||
hidden
|
||||
></p>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Match</legend>
|
||||
<div class="team-size-row">
|
||||
<label for="team-size">팀당 인원</label>
|
||||
<input
|
||||
id="team-size-value"
|
||||
class="team-size-number"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
step="1"
|
||||
value="5"
|
||||
inputmode="numeric"
|
||||
aria-label="팀당 인원 직접 입력"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
id="team-size"
|
||||
name="teamSize"
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
value="5"
|
||||
/>
|
||||
<div class="spawn-placement-field">
|
||||
<span id="spawn-placement-label" class="spawn-placement-label"
|
||||
>리스폰 설정</span
|
||||
@@ -190,7 +224,7 @@ Player 10</textarea
|
||||
name="spawnPlacement"
|
||||
value="starting-zones"
|
||||
/>
|
||||
<span>집결 배치</span>
|
||||
<span>스타팅 지점 배치</span>
|
||||
</label>
|
||||
<label class="spawn-placement-option">
|
||||
<input
|
||||
@@ -290,7 +324,11 @@ Player 10</textarea
|
||||
<div class="about-field-row">
|
||||
<dt>github</dt>
|
||||
<dd data-about-field="github">
|
||||
<a href="https://github.com/Horoli" target="_blank" rel="noreferrer">
|
||||
<a
|
||||
href="https://github.com/Horoli"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
https://github.com/Horoli
|
||||
</a>
|
||||
</dd>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 481 KiB |
+225
-156
@@ -1,174 +1,238 @@
|
||||
// 경기장을 구성하는 격자 칸 수입니다. 값이 커질수록 전장이 넓어집니다.
|
||||
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;
|
||||
const RENDER_SIZE = 1280;
|
||||
|
||||
// 근접 캐릭터가 공격을 시작할 수 있는 기본 거리입니다.
|
||||
export const ATTACK_RANGE = 84;
|
||||
// 기본 공격 쿨다운(ms)입니다. 낮을수록 공격 빈도가 높아집니다.
|
||||
export const ATTACK_COOLDOWN = 840;
|
||||
// 공격이 한 번 적중했을 때 적용되는 최소 피해량입니다.
|
||||
export const ATTACK_DAMAGE_MIN = 14;
|
||||
// 공격이 한 번 적중했을 때 적용되는 최대 피해량입니다.
|
||||
export const ATTACK_DAMAGE_MAX = 24;
|
||||
// 새 매치가 시작될 때 기본 팀당 캐릭터 수입니다.
|
||||
export const DEFAULT_TEAM_SIZE = 5;
|
||||
// 전투 시작 시 전투원을 배치하는 기본 방식입니다.
|
||||
export const DEFAULT_SPAWN_PLACEMENT = "random";
|
||||
// 전투 설정 UI와 매치 생성 로직이 공유하는 스폰 배치 모드입니다.
|
||||
export const SPAWN_PLACEMENTS = {
|
||||
RANDOM: DEFAULT_SPAWN_PLACEMENT,
|
||||
STARTING_ZONES: "starting-zones",
|
||||
export const ARENA = {
|
||||
GRID_SIZE,
|
||||
TILE_SIZE,
|
||||
SIZE: ARENA_SIZE,
|
||||
};
|
||||
// 최초 접속 대기 전투에서 고정으로 보여줄 팀 수입니다.
|
||||
export const PRESENTATION_TEAM_COUNT = 10;
|
||||
// 최초 접속 대기 전투에서 팀마다 배치할 전투원 수입니다.
|
||||
export const PRESENTATION_TEAM_SIZE = 5;
|
||||
// 캐릭터 스프라이트의 기본 화면 배율입니다.
|
||||
export const FIGHTER_SCALE = 3;
|
||||
export const FIGHTER_DEPTH = 2;
|
||||
export const DEAD_FIGHTER_DEPTH = 1;
|
||||
export const DEAD_FIGHTER_ALPHA = 0.42;
|
||||
// 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 너비입니다.
|
||||
export const FIGHTER_FRAME_WIDTH = 100;
|
||||
// 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 높이입니다.
|
||||
export const FIGHTER_FRAME_HEIGHT = 100;
|
||||
// 캐릭터 히트박스의 원본 프레임 기준 너비입니다.
|
||||
export const FIGHTER_HITBOX_WIDTH = 22;
|
||||
// 캐릭터 히트박스의 원본 프레임 기준 높이입니다.
|
||||
export const FIGHTER_HITBOX_HEIGHT = 20;
|
||||
// 100x100 프레임 안에서 히트박스가 시작되는 X 좌표입니다.
|
||||
export const FIGHTER_HITBOX_OFFSET_X = 39;
|
||||
// 100x100 프레임 안에서 히트박스가 시작되는 Y 좌표입니다. 실제 캐릭터 픽셀 하단은 대체로 y=59입니다.
|
||||
export const FIGHTER_HITBOX_OFFSET_Y = 40;
|
||||
// 캐릭터의 기본 최대 체력입니다.
|
||||
export const FIGHTER_MAX_HP = 100;
|
||||
// 적 처치 시 현재 체력 기준으로 회복되는 비율입니다.
|
||||
export const KILL_HEALTH_RECOVERY_RATIO = 0.3;
|
||||
// 처치 회복 이펙트 스프라이트시트의 프레임 수입니다.
|
||||
export const KILL_HEAL_EFFECT_FRAMES = 4;
|
||||
// 처치 회복 이펙트 애니메이션의 초당 프레임 수입니다.
|
||||
export const KILL_HEAL_EFFECT_FRAME_RATE = 12;
|
||||
// 적 처치 시 크기, 공격속도, 이동속도에 누적 적용되는 배율입니다.
|
||||
export const KILL_GROWTH_MULTIPLIER = 1.25;
|
||||
// 처치 보상으로 누적 적용되는 최대 배율입니다. 기본 scale에 곱해지는 상한이기도 합니다.
|
||||
export const KILL_GROWTH_MAX_MULTIPLIER = 5;
|
||||
// 처치 성장 연출 tween 지속 시간(ms)입니다.
|
||||
export const KILL_GROWTH_TWEEN_DURATION = 180;
|
||||
// 입력 UI에서 허용하는 팀당 최대 캐릭터 수입니다.
|
||||
export const MAX_TEAM_SIZE = 100;
|
||||
// 근접 캐릭터의 기본 치명타 확률입니다. 치명타는 즉시 처치로 처리됩니다.
|
||||
export const MELEE_CRITICAL_CHANCE = 0.05;
|
||||
// 캐릭터 기본 이동 속도입니다. 처치 보상과 전역 이동 배율이 곱해집니다.
|
||||
export const MOVE_SPEED = 148;
|
||||
// 투사체가 자동으로 사라지기까지의 시간(ms)입니다.
|
||||
export const PROJECTILE_LIFETIME = 1800;
|
||||
// 투사체 기본 이동 속도입니다. 처치 보상과 전역 공격 배율이 곱해집니다.
|
||||
export const PROJECTILE_SPEED = 420;
|
||||
// 원거리 캐릭터의 기본 치명타 확률입니다.
|
||||
export const RANGED_CRITICAL_CHANCE = 0;
|
||||
// 원거리 캐릭터가 공격을 시작할 수 있는 기본 거리입니다.
|
||||
export const RANGED_ATTACK_RANGE = TILE_SIZE * 5;
|
||||
|
||||
// 근접 공격 애니메이션 시작 후 실제 피해가 들어가기까지의 지연(ms)입니다.
|
||||
export const MELEE_HIT_DELAY = 260;
|
||||
// 원거리 공격 애니메이션 시작 후 투사체가 발사되기까지의 지연(ms)입니다.
|
||||
export const PROJECTILE_FIRE_DELAY = 360;
|
||||
// 투사체 충돌 원형 바디가 이미지 안에서 시작되는 오프셋입니다.
|
||||
export const PROJECTILE_BODY_OFFSET = 4;
|
||||
// 투사체 궤적 충돌 검사 시 대상 히트박스에 더하는 여유 픽셀입니다.
|
||||
export const PROJECTILE_HIT_PADDING = 20;
|
||||
// 투사체 충돌 원형 바디의 반지름입니다.
|
||||
export const PROJECTILE_HIT_RADIUS = 12;
|
||||
// 투사체가 공격자 위치에서 얼마나 떨어져 생성되는지 정하는 거리입니다.
|
||||
export const PROJECTILE_SPAWN_DISTANCE = 1;
|
||||
// 즉발 마법 캐스팅 후 이펙트가 생성되기까지의 지연(ms)입니다.
|
||||
export const SPELL_CAST_DELAY = 340;
|
||||
// 마법 이펙트 생성 후 실제 피해가 들어가기까지의 지연(ms)입니다.
|
||||
export const SPELL_HIT_DELAY = 160;
|
||||
export const RENDER = {
|
||||
HEIGHT: RENDER_SIZE,
|
||||
WIDTH: RENDER_SIZE,
|
||||
};
|
||||
|
||||
// 카메라 최소 줌입니다. 전장 전체를 보는 기본 배율입니다.
|
||||
export const CAMERA_MIN_ZOOM = 1;
|
||||
// 카메라 최대 줌입니다. 후반 관전 및 휠 확대의 상한입니다.
|
||||
export const CAMERA_MAX_ZOOM = 3;
|
||||
// 마우스 휠 한 번당 카메라 줌 변화량입니다.
|
||||
export const CAMERA_ZOOM_STEP = 0.1;
|
||||
// 미니맵 카메라가 보일 때의 투명도입니다.
|
||||
export const MINIMAP_ALPHA = 0.8;
|
||||
// 미니맵이 화면 가장자리에서 떨어지는 거리입니다.
|
||||
export const MINIMAP_MARGIN = Math.round(ARENA_SIZE * 0.016);
|
||||
// 미니맵의 고정 픽셀 크기입니다.
|
||||
export const MINIMAP_VIEWPORT_SIZE = Math.round(ARENA_SIZE * 0.22);
|
||||
// 미니맵 현재 뷰포트 표시용 선 두께입니다.
|
||||
export const MINIMAP_VIEW_FRAME_STROKE = 10;
|
||||
// 관전 카메라가 목표 전투 지점으로 따라가는 부드러움입니다.
|
||||
export const SPECTATOR_CAMERA_LERP = 0.1;
|
||||
// 생존자가 이 수보다 적으면 최종 전투 줌을 적용합니다.
|
||||
export const SPECTATOR_FINAL_FIGHTER_THRESHOLD = 5;
|
||||
// 최종 전투 구간에서 강제로 적용되는 카메라 줌입니다.
|
||||
export const SPECTATOR_FINAL_FIGHT_ZOOM = 3;
|
||||
export const SPECTATOR_FINAL_TEAM_COUNT = 2;
|
||||
export const SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD = 8;
|
||||
export const SPECTATOR_RANDOM_FOCUS_INTERVAL = 2400;
|
||||
// 최종교전 슬로우모션 연출을 켜고 끕니다.
|
||||
export const FINAL_COMBAT_SLOW_MOTION_ENABLED = false;
|
||||
// 최종교전 공격 시작에서 슬로우 배율로 내려가는 속도 램프 시간(ms)입니다.
|
||||
export const FINAL_COMBAT_SLOW_MOTION_ENTER_DURATION = 14000;
|
||||
// 최종교전 공격을 슬로우 배율로 붙잡아 두는 시간(ms)입니다.
|
||||
export const FINAL_COMBAT_SLOW_MOTION_HOLD_DURATION = 14000;
|
||||
// 최종교전 슬로우에서 기본 속도로 복귀하는 속도 램프 시간(ms)입니다.
|
||||
export const FINAL_COMBAT_SLOW_MOTION_EXIT_DURATION = 14000;
|
||||
export const FINAL_COMBAT_SLOW_MOTION_SCALE = 0.28;
|
||||
// 생존자가 이 수보다 적으면 후반 전투 줌을 적용합니다.
|
||||
export const SPECTATOR_LATE_FIGHTER_THRESHOLD = 30;
|
||||
// 후반 전투 구간에서 강제로 적용되는 카메라 줌입니다.
|
||||
export const SPECTATOR_LATE_FIGHT_ZOOM = 2;
|
||||
// 캐릭터를 선택했을 때 최소로 확보하는 카메라 줌입니다.
|
||||
export const SELECTED_FIGHTER_CAMERA_ZOOM = 2;
|
||||
// 선택 실루엣과 원본 캐릭터 사이에 비워두는 픽셀 간격입니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_GAP = 1;
|
||||
// 선택 실루엣 자체가 차지하는 픽셀 두께입니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_WIDTH = 1;
|
||||
// 선택 실루엣의 빨간색 채널 값입니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_RED = 255;
|
||||
// 선택 실루엣의 초록색 채널 값입니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_GREEN = 228;
|
||||
// 선택 실루엣의 파란색 채널 값입니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_BLUE = 64;
|
||||
// 선택 실루엣의 전체 투명도입니다. 0.65는 윤곽을 또렷하게 보이면서 원본 캐릭터를 덮지 않습니다.
|
||||
export const SELECTED_FIGHTER_OUTLINE_ALPHA = 0.65;
|
||||
|
||||
// 참가자 닉네임을 잘라낼 최대 글자 수입니다.
|
||||
export const NICKNAME_LENGTH = 18;
|
||||
|
||||
// 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수입니다.
|
||||
export const FIGHTER_ANIMATION_OPTIONS = {
|
||||
// 기본 공격 애니메이션 속도입니다.
|
||||
// 2. FIGHTER 도메인
|
||||
export const FIGHTER = {
|
||||
SCALE: 3,
|
||||
DEPTH: 2,
|
||||
DEAD_DEPTH: 1,
|
||||
DEAD_DESPAWN_ALPHA: 0,
|
||||
DEAD_DESPAWN_DELAY_MS: 5000,
|
||||
FRAME_WIDTH: 100,
|
||||
FRAME_HEIGHT: 100,
|
||||
HITBOX_WIDTH: 22,
|
||||
HITBOX_HEIGHT: 20,
|
||||
HITBOX_OFFSET_X: 39,
|
||||
HITBOX_OFFSET_Y: 40,
|
||||
NICKNAME_LENGTH: 24,
|
||||
// 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수
|
||||
ANIMATION_OPTIONS: {
|
||||
attack: { frameRate: 15, repeat: 0 },
|
||||
// 보조 공격 애니메이션 속도입니다.
|
||||
attack02: { frameRate: 15, repeat: 0 },
|
||||
// 강공격/치명타용 공격 애니메이션 속도입니다.
|
||||
attack03: { frameRate: 15, repeat: 0 },
|
||||
// 방어 애니메이션 속도입니다.
|
||||
block: { frameRate: 13, repeat: 0 },
|
||||
// 사망 애니메이션 속도입니다.
|
||||
death: { frameRate: 11, repeat: 0 },
|
||||
// 회복 애니메이션 속도입니다.
|
||||
heal: { frameRate: 13, repeat: 0 },
|
||||
// 피격 애니메이션 속도입니다.
|
||||
hurt: { frameRate: 13, repeat: 0 },
|
||||
// 대기 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
|
||||
idle: { frameRate: 7, repeat: -1 },
|
||||
// 이동 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
|
||||
walk: { frameRate: 10, repeat: -1 },
|
||||
// 대체 이동 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
|
||||
walk02: { frameRate: 10, repeat: -1 },
|
||||
},
|
||||
// 역할별 기본 스탯
|
||||
TYPE_STATS: {
|
||||
melee: {
|
||||
maxHp: 100,
|
||||
moveSpeed: 148 * 1.1,
|
||||
attackRange: 84,
|
||||
attackCooldown: 840,
|
||||
damageMin: 14,
|
||||
damageMax: 24,
|
||||
criticalChance: 0.2,
|
||||
windupDelay: 260,
|
||||
},
|
||||
ranged: {
|
||||
maxHp: 80,
|
||||
moveSpeed: 148,
|
||||
attackRange: TILE_SIZE * 5,
|
||||
attackCooldown: 840 * 1.1,
|
||||
damageMin: 14 * 1.2,
|
||||
damageMax: 24 * 1.2,
|
||||
criticalChance: 0,
|
||||
windupDelay: 360,
|
||||
projectileSpeed: 420,
|
||||
},
|
||||
magic: {
|
||||
maxHp: 80,
|
||||
moveSpeed: 148,
|
||||
attackRange: TILE_SIZE * 5,
|
||||
attackCooldown: 840 * 1.1,
|
||||
damageMin: 14 * 1.5,
|
||||
damageMax: 24 * 1.5,
|
||||
criticalChance: 0,
|
||||
windupDelay: 340,
|
||||
effectHitDelay: 160,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 팀 배정에 순서대로 사용되는 기본 색상 팔레트입니다.
|
||||
export const TEAM_COLORS = [
|
||||
export const PERFORMANCE = {
|
||||
LARGE_BATTLE_FIGHTER_THRESHOLD: 2000,
|
||||
LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS: 0,
|
||||
LARGE_BATTLE_SIMULATION_BUCKETS: 16,
|
||||
LARGE_BATTLE_SIMULATION_MAX_DELTA_MS: 260,
|
||||
LARGE_BATTLE_TARGET_INDEX_REFRESH_MS: 160,
|
||||
LARGE_BATTLE_AGGREGATE_COMBAT_REFRESH_MS: 260,
|
||||
LARGE_BATTLE_AGGREGATE_CELL_SIZE: TILE_SIZE * 5,
|
||||
LARGE_BATTLE_AGGREGATE_SQUAD_SIZE: 100,
|
||||
LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_CELL_TICK: 4,
|
||||
LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_TICK: 80,
|
||||
LARGE_BATTLE_AGGREGATE_MOVEMENT_RATIO: 0.72,
|
||||
WORLD_EFFECT_MODIFIER_REFRESH_MS: 180,
|
||||
TARGET_GRID_CELL_SIZE: TILE_SIZE * 4,
|
||||
FIGHTER_HUD_POOL_SIZE: 48,
|
||||
FIGHTER_HUD_VISIBLE_LIMIT: 32,
|
||||
LARGE_BATTLE_HUD_VISIBLE_LIMIT: 8,
|
||||
FIGHTER_HUD_VIEW_PADDING: TILE_SIZE * 2,
|
||||
FIGHTER_HUD_CANDIDATE_REFRESH_MS: 120,
|
||||
MINIMAP_DOT_RADIUS: 3,
|
||||
MINIMAP_BACKGROUND_ALPHA: 0.62,
|
||||
MINIMAP_BORDER_ALPHA: 0.84,
|
||||
MINIMAP_REFRESH_MS: 220,
|
||||
LARGE_BATTLE_SPRITE_RENDER_LIMIT: 140,
|
||||
LARGE_BATTLE_ROLLING_WINDOW_SCALE: 1.05,
|
||||
LARGE_BATTLE_SPRITE_VIEW_PADDING: TILE_SIZE * 2,
|
||||
LARGE_BATTLE_LOD_REFRESH_MS: 180,
|
||||
LARGE_BATTLE_DOT_REFRESH_MS: 220,
|
||||
LARGE_BATTLE_DOT_SIZE: 6,
|
||||
LARGE_BATTLE_DOT_ALPHA: 0.86,
|
||||
};
|
||||
|
||||
// 3. SPAWN 도메인
|
||||
export const SPAWN = {
|
||||
DEFAULT_PLACEMENT: "random",
|
||||
PLACEMENTS: {
|
||||
RANDOM: "random",
|
||||
STARTING_ZONES: "starting-zones",
|
||||
},
|
||||
// Caps participant-assigned slots; traits such as slime spawning may add fighters.
|
||||
MAX_FIGHTER_COUNT: 8000,
|
||||
FIGHTERS_PER_STARTING_ZONE: 100,
|
||||
STARTING_ZONE_RADIUS: 2,
|
||||
STARTING_ZONE_FILL_ALPHA: 0.07,
|
||||
STARTING_ZONE_BORDER_ALPHA: 0.14,
|
||||
STARTING_ZONE_VISIBLE_DURATION_MS: 2000,
|
||||
PRESENTATION_TEAM_COUNT: 10,
|
||||
PRESENTATION_TEAM_SIZE: 5,
|
||||
};
|
||||
|
||||
// 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 = {
|
||||
// Delay from match start until the first barrage.
|
||||
INTERVAL: 8000,
|
||||
// Delay between barrages after the first one has fired.
|
||||
REPEAT_INTERVAL: 8000,
|
||||
AREA_TILES: 40,
|
||||
// How long the large dense-area warning marker remains visible.
|
||||
WARNING_DURATION_MS: 2000,
|
||||
IMPACT_AREA_TILES: 10,
|
||||
IMPACT_COUNT_MIN: 15,
|
||||
IMPACT_COUNT_MAX: 25,
|
||||
IMPACT_STAGGER_MS: 140,
|
||||
IMPACT_VISUAL_SCALE: 10,
|
||||
SIZE_SCALE_VARIANCE: 0,
|
||||
FRAMES: 7,
|
||||
FRAME_RATE: 14,
|
||||
FALL_DURATION: 920,
|
||||
FALL_TRAVEL_TILES: 8,
|
||||
METEOR_SHAKE_DURATION_MS: 150,
|
||||
METEOR_SHAKE_INTENSITY: 0.004,
|
||||
METEOR_DAMAGE: 90,
|
||||
FROST_DAMAGE: 45,
|
||||
FROST_STUN_DURATION: 2000,
|
||||
FROST_STUN_TINT: 0x82e9ff,
|
||||
FROST_DURATION: 2000,
|
||||
FROST_SPEED_MULTIPLIER: 0.55,
|
||||
SUDDEN_DEATH: {
|
||||
ENABLED: false,
|
||||
TRIGGER_MS: 10000,
|
||||
INTERVAL_MS: 2000,
|
||||
FORCE_FROST: false,
|
||||
},
|
||||
};
|
||||
|
||||
// 7. CAMERA 도메인
|
||||
export const CAMERA = {
|
||||
MIN_ZOOM: RENDER_SIZE / ARENA_SIZE,
|
||||
MAX_ZOOM: 3,
|
||||
ZOOM_STEP: 0.1,
|
||||
// 자동 관전 진입 전 화염/냉기 메테오 낙하 위치를 임시로 확대 추적합니다.
|
||||
METEOR_FOCUS_ENABLED: false,
|
||||
METEOR_FOCUS_ZOOM: 2,
|
||||
SPECTATOR_LERP: 0.05,
|
||||
// 메테오 착탄 후 카메라를 해당 위치에 유지하는 시간(ms)입니다.
|
||||
METEOR_FOCUS_HOLD_DURATION: 1200,
|
||||
SPECTATOR_FINAL_FIGHTER_THRESHOLD: 5,
|
||||
SPECTATOR_FINAL_FIGHT_ZOOM: 2,
|
||||
SPECTATOR_FINAL_TEAM_COUNT: 2,
|
||||
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD: 8,
|
||||
SPECTATOR_RANDOM_FOCUS_INTERVAL: 100000,
|
||||
SPECTATOR_LATE_FIGHTER_THRESHOLD: 80,
|
||||
SPECTATOR_LATE_FIGHT_ZOOM: 1,
|
||||
LARGE_BATTLE_START_ZOOM: 0.8,
|
||||
SELECTED_FIGHTER_ZOOM: 0.8,
|
||||
MANUAL_FOCUS_TWEEN_MS: 220,
|
||||
MANUAL_FOCUS_TWEEN_EASE: "Sine.easeInOut",
|
||||
};
|
||||
|
||||
// 8. UI 도메인
|
||||
export const UI = {
|
||||
MINIMAP_ALPHA: 0.8,
|
||||
MINIMAP_MARGIN: Math.round(RENDER_SIZE * 0.016),
|
||||
MINIMAP_VIEWPORT_SIZE: Math.round(RENDER_SIZE * 0.22),
|
||||
MINIMAP_VIEW_FRAME_STROKE: Math.max(3, Math.round(RENDER_SIZE * 0.003125)),
|
||||
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,
|
||||
// 상단 종족별 사망 통계 공지 설정
|
||||
BATTLE_NOTICE_DELAY_MS: 5000,
|
||||
BATTLE_NOTICE_VISIBLE_MS: 2000,
|
||||
BATTLE_NOTICE_INTERVAL_MS: 10000,
|
||||
};
|
||||
|
||||
// 9. TEAM 도메인
|
||||
const TEAM_COLORS = [
|
||||
"#da6a48",
|
||||
"#5fb4d9",
|
||||
"#9bd15a",
|
||||
@@ -184,7 +248,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 +298,8 @@ function hslToHex(hue, saturation, lightness) {
|
||||
)
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
export const TEAM = {
|
||||
COLORS: TEAM_COLORS,
|
||||
getColor: getTeamColor,
|
||||
};
|
||||
|
||||
+1427
-152
File diff suppressed because it is too large
Load Diff
@@ -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,40 @@
|
||||
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";
|
||||
import {
|
||||
fighterDistanceSquared,
|
||||
fighterWorldPoint,
|
||||
} from "../fighter/fighterAdapter.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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,16 +90,11 @@ export function averageFighterPosition(fighters) {
|
||||
}
|
||||
|
||||
export function fighterCameraPoint(fighter) {
|
||||
const target = fighter?.body?.center ?? fighter;
|
||||
|
||||
if (!target) {
|
||||
if (!fighter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
};
|
||||
return fighterWorldPoint(fighter);
|
||||
}
|
||||
|
||||
export function findClosestOpponentPair(fighters) {
|
||||
@@ -120,7 +113,7 @@ export function findClosestOpponentPair(fighters) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = Phaser.Math.Distance.Between(fighter.x, fighter.y, candidate.x, candidate.y);
|
||||
const distance = fighterDistanceSquared(fighter, candidate);
|
||||
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
self.onmessage = (event) => {
|
||||
const job = event.data;
|
||||
|
||||
if (job?.type !== "fighter-lod-job") {
|
||||
return;
|
||||
}
|
||||
|
||||
const detailedIds = resolveDetailedIds(job);
|
||||
|
||||
self.postMessage(
|
||||
{
|
||||
detailedIds,
|
||||
jobId: job.jobId,
|
||||
matchId: job.matchId,
|
||||
type: "fighter-lod-result",
|
||||
},
|
||||
[detailedIds.buffer],
|
||||
);
|
||||
};
|
||||
|
||||
function resolveDetailedIds(job) {
|
||||
const modelIds = job.modelIds;
|
||||
const count = modelIds?.length ?? 0;
|
||||
const included = new Uint8Array(count);
|
||||
const detailedIds = [];
|
||||
const selectedIndex = indexOfModelId(modelIds, job.selectedModelId);
|
||||
|
||||
if (selectedIndex >= 0) {
|
||||
addIndex(modelIds, included, detailedIds, selectedIndex);
|
||||
}
|
||||
|
||||
if (job.fullArenaOverview) {
|
||||
addRepresentativeDetails(job, included, detailedIds);
|
||||
return Int32Array.from(detailedIds);
|
||||
}
|
||||
|
||||
addRollingWindowDetails(job, included, detailedIds);
|
||||
|
||||
if (detailedIds.length <= (selectedIndex >= 0 ? 1 : 0)) {
|
||||
addRepresentativeDetails(job, included, detailedIds);
|
||||
}
|
||||
|
||||
return Int32Array.from(detailedIds);
|
||||
}
|
||||
|
||||
function addRollingWindowDetails(job, included, detailedIds) {
|
||||
const modelIds = job.modelIds;
|
||||
const x = job.x;
|
||||
const y = job.y;
|
||||
|
||||
for (let index = 0; index < modelIds.length; index += 1) {
|
||||
if (
|
||||
included[index]
|
||||
|| x[index] < job.rollingLeft
|
||||
|| x[index] > job.rollingRight
|
||||
|| y[index] < job.rollingTop
|
||||
|| y[index] > job.rollingBottom
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
addIndex(modelIds, included, detailedIds, index);
|
||||
}
|
||||
}
|
||||
|
||||
function addRepresentativeDetails(job, included, detailedIds) {
|
||||
const detailLimit = Math.max(1, Math.round(Number(job.baseDetailLimit) || 1));
|
||||
const remainingLimit = detailLimit - detailedIds.length;
|
||||
|
||||
if (remainingLimit <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modelIds = job.modelIds;
|
||||
const teamKeys = job.teamKeys;
|
||||
const groupsByTeam = new Map();
|
||||
|
||||
for (let index = 0; index < modelIds.length; index += 1) {
|
||||
if (included[index]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const teamKey = teamKeys[index] || 0;
|
||||
let indexes = groupsByTeam.get(teamKey);
|
||||
|
||||
if (!indexes) {
|
||||
indexes = [];
|
||||
groupsByTeam.set(teamKey, indexes);
|
||||
}
|
||||
|
||||
indexes.push(index);
|
||||
}
|
||||
|
||||
const teamCount = Math.max(1, groupsByTeam.size);
|
||||
const quotaPerTeam = Math.max(1, Math.floor(remainingLimit / teamCount));
|
||||
|
||||
groupsByTeam.forEach((indexes) => {
|
||||
const step = Math.max(1, Math.ceil(indexes.length / quotaPerTeam));
|
||||
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < indexes.length && detailedIds.length < detailLimit;
|
||||
offset += step
|
||||
) {
|
||||
addIndex(modelIds, included, detailedIds, indexes[offset]);
|
||||
}
|
||||
});
|
||||
|
||||
for (let index = 0; index < modelIds.length && detailedIds.length < detailLimit; index += 1) {
|
||||
addIndex(modelIds, included, detailedIds, index);
|
||||
}
|
||||
}
|
||||
|
||||
function indexOfModelId(modelIds, modelId) {
|
||||
if (!modelId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (let index = 0; index < modelIds.length; index += 1) {
|
||||
if (modelIds[index] === modelId) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function addIndex(modelIds, included, detailedIds, index) {
|
||||
if (included[index]) {
|
||||
return;
|
||||
}
|
||||
|
||||
included[index] = 1;
|
||||
detailedIds.push(modelIds[index]);
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
globalThis.onmessage = (event) => {
|
||||
const job = event.data;
|
||||
|
||||
if (!job?.modelIds || !job?.config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = runAggregateJob(job);
|
||||
|
||||
globalThis.postMessage(result, [
|
||||
result.modelIds.buffer,
|
||||
result.x.buffer,
|
||||
result.y.buffer,
|
||||
result.hp.buffer,
|
||||
result.deadDefenderIds.buffer,
|
||||
result.deadAttackerIds.buffer,
|
||||
]);
|
||||
};
|
||||
|
||||
function runAggregateJob(job) {
|
||||
const state = {
|
||||
alive: aliveArray(job.hp),
|
||||
damageCursor: Math.max(0, Math.round(Number(job.damageCursor) || 0)),
|
||||
deadAttackerIds: new Int32Array(resolveMaxDeathsPerTick(job.config)),
|
||||
deadCount: 0,
|
||||
deadDefenderIds: new Int32Array(resolveMaxDeathsPerTick(job.config)),
|
||||
winnerCursor: Math.max(0, Math.round(Number(job.winnerCursor) || 0)),
|
||||
};
|
||||
const aggregateState = buildAggregateSquadState(job, state);
|
||||
|
||||
assignAggregateSquadTargets(aggregateState, job.config);
|
||||
advanceAggregateSquads(aggregateState.squads, job.tickDelta, job.config);
|
||||
resolveAggregateSquadCombatState(job, aggregateState.cells, state);
|
||||
syncAggregateSquadMembers(job, aggregateState.squads, state);
|
||||
|
||||
return {
|
||||
damageCursor: state.damageCursor,
|
||||
deadAttackerIds: state.deadAttackerIds,
|
||||
deadCount: state.deadCount,
|
||||
deadDefenderIds: state.deadDefenderIds,
|
||||
hp: job.hp,
|
||||
jobId: job.jobId,
|
||||
matchId: job.matchId,
|
||||
modelIds: job.modelIds,
|
||||
type: "aggregate-result",
|
||||
winnerCursor: state.winnerCursor,
|
||||
x: job.x,
|
||||
y: job.y,
|
||||
};
|
||||
}
|
||||
|
||||
function aliveArray(hp) {
|
||||
const alive = new Uint8Array(hp.length);
|
||||
|
||||
for (let index = 0; index < hp.length; index += 1) {
|
||||
alive[index] = hp[index] > 0 ? 1 : 0;
|
||||
}
|
||||
|
||||
return alive;
|
||||
}
|
||||
|
||||
function buildAggregateSquadState(job, state) {
|
||||
const { config, teamKeys, x, y } = job;
|
||||
const cellSize = Math.max(1, Number(config.cellSize) || 1);
|
||||
const maxCellX = Math.floor((config.arenaSize - 1) / cellSize);
|
||||
const maxCellY = Math.floor((config.arenaSize - 1) / cellSize);
|
||||
const columns = maxCellX + 1;
|
||||
const cellsByKey = new Map();
|
||||
const cells = [];
|
||||
|
||||
for (let index = 0; index < job.modelIds.length; index += 1) {
|
||||
if (!state.alive[index]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cellX = clampCell(x[index], cellSize, maxCellX);
|
||||
const cellY = clampCell(y[index], cellSize, maxCellY);
|
||||
const key = targetCellIndex(cellX, cellY, columns);
|
||||
let cell = cellsByKey.get(key);
|
||||
|
||||
if (!cell) {
|
||||
cell = {
|
||||
cellX,
|
||||
cellY,
|
||||
key,
|
||||
squads: [],
|
||||
teams: new Map(),
|
||||
};
|
||||
cellsByKey.set(key, cell);
|
||||
cells.push(cell);
|
||||
}
|
||||
|
||||
const teamKey = teamKeys[index] || 0;
|
||||
let group = cell.teams.get(teamKey);
|
||||
|
||||
if (!group) {
|
||||
group = {
|
||||
indexes: [],
|
||||
teamKey,
|
||||
};
|
||||
cell.teams.set(teamKey, group);
|
||||
}
|
||||
|
||||
group.indexes.push(index);
|
||||
}
|
||||
|
||||
return {
|
||||
cells,
|
||||
squads: createAggregateSquads(cells, job, state),
|
||||
};
|
||||
}
|
||||
|
||||
function createAggregateSquads(cells, job, state) {
|
||||
const squads = [];
|
||||
const squadSize = Math.max(1, Math.round(Number(job.config.squadSize) || 100));
|
||||
|
||||
cells.forEach((cell) => {
|
||||
cell.teams.forEach((group) => {
|
||||
for (let offset = 0; offset < group.indexes.length; offset += squadSize) {
|
||||
const indexes = group.indexes.slice(offset, offset + squadSize);
|
||||
|
||||
if (indexes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const squad = createAggregateSquad(cell, group.teamKey, indexes, offset / squadSize, job, state);
|
||||
squads.push(squad);
|
||||
cell.squads.push(squad);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return squads;
|
||||
}
|
||||
|
||||
function createAggregateSquad(cell, teamKey, indexes, chunkIndex, job, state) {
|
||||
const center = averageAggregatePosition(indexes, job);
|
||||
const count = livingCount(indexes, state);
|
||||
|
||||
return {
|
||||
averageMoveSpeed: averageAggregateMoveSpeed(indexes, job, state),
|
||||
centerX: center.x,
|
||||
centerY: center.y,
|
||||
count,
|
||||
dps: aggregateGroupDamage(indexes, job, state),
|
||||
indexes,
|
||||
radius: resolveAggregateSquadRadius(count, job.config),
|
||||
seed: aggregateSquadSeed(cell.key, teamKey, chunkIndex),
|
||||
teamKey,
|
||||
targetX: null,
|
||||
targetY: null,
|
||||
};
|
||||
}
|
||||
|
||||
function averageAggregatePosition(indexes, { x, y }) {
|
||||
let totalX = 0;
|
||||
let totalY = 0;
|
||||
|
||||
indexes.forEach((index) => {
|
||||
totalX += x[index];
|
||||
totalY += y[index];
|
||||
});
|
||||
|
||||
const count = Math.max(1, indexes.length);
|
||||
|
||||
return {
|
||||
x: totalX / count,
|
||||
y: totalY / count,
|
||||
};
|
||||
}
|
||||
|
||||
function averageAggregateMoveSpeed(indexes, job, state) {
|
||||
let count = 0;
|
||||
let totalSpeed = 0;
|
||||
|
||||
indexes.forEach((index) => {
|
||||
if (!state.alive[index]) {
|
||||
return;
|
||||
}
|
||||
|
||||
count += 1;
|
||||
totalSpeed += job.moveSpeed[index];
|
||||
});
|
||||
|
||||
return count > 0 ? totalSpeed / count : 0;
|
||||
}
|
||||
|
||||
function aggregateGroupDamage(indexes, job, state) {
|
||||
let damage = 0;
|
||||
|
||||
indexes.forEach((index) => {
|
||||
if (!state.alive[index] || job.isFrostStunned[index]) {
|
||||
return;
|
||||
}
|
||||
|
||||
damage += job.damagePerSecond[index];
|
||||
});
|
||||
|
||||
return damage;
|
||||
}
|
||||
|
||||
function resolveAggregateSquadRadius(count, config) {
|
||||
const spacing = Math.max(1, Number(config.squadSpacing) || 1);
|
||||
|
||||
return Math.max(
|
||||
(Number(config.tileSize) || 64) * 0.42,
|
||||
Math.sqrt(Math.max(1, count)) * spacing,
|
||||
);
|
||||
}
|
||||
|
||||
function assignAggregateSquadTargets({ squads }, config) {
|
||||
squads.forEach((squad) => {
|
||||
const targetSquad = findNearestAggregateEnemySquad(squads, squad);
|
||||
|
||||
if (!targetSquad) {
|
||||
squad.targetX = null;
|
||||
squad.targetY = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const spread = Math.max(1, Number(config.cellSize) || 1) * 0.18;
|
||||
const offsetX = (((squad.seed % 997) / 997) - 0.5) * spread;
|
||||
const offsetY = ((((squad.seed * 31) % 991) / 991) - 0.5) * spread;
|
||||
|
||||
squad.targetX = clamp(
|
||||
targetSquad.centerX + offsetX,
|
||||
config.halfWidth,
|
||||
config.arenaSize - config.halfWidth,
|
||||
);
|
||||
squad.targetY = clamp(
|
||||
targetSquad.centerY + offsetY,
|
||||
config.halfHeight,
|
||||
config.arenaSize - config.halfHeight,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function findNearestAggregateEnemySquad(squads, sourceSquad) {
|
||||
let nearestSquad = null;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
squads.forEach((candidateSquad) => {
|
||||
if (
|
||||
candidateSquad === sourceSquad
|
||||
|| candidateSquad.teamKey === sourceSquad.teamKey
|
||||
|| candidateSquad.count <= 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = candidateSquad.centerX - sourceSquad.centerX;
|
||||
const deltaY = candidateSquad.centerY - sourceSquad.centerY;
|
||||
const distance = deltaX * deltaX + deltaY * deltaY;
|
||||
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestSquad = candidateSquad;
|
||||
}
|
||||
});
|
||||
|
||||
return nearestSquad;
|
||||
}
|
||||
|
||||
function advanceAggregateSquads(squads, tickDelta, config) {
|
||||
const seconds = Math.max(0, Number(tickDelta) || 0) / 1000;
|
||||
|
||||
if (seconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const movementRatio = clamp(Number(config.movementRatio) || 1, 0, 2);
|
||||
|
||||
squads.forEach((squad) => {
|
||||
if (
|
||||
squad.count <= 0
|
||||
|| !Number.isFinite(squad.targetX)
|
||||
|| !Number.isFinite(squad.targetY)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = squad.targetX - squad.centerX;
|
||||
const deltaY = squad.targetY - squad.centerY;
|
||||
const distance = Math.hypot(deltaX, deltaY);
|
||||
|
||||
if (distance <= 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
const step = Math.min(distance, squad.averageMoveSpeed * movementRatio * seconds);
|
||||
squad.centerX = clamp(
|
||||
squad.centerX + (deltaX / distance) * step,
|
||||
config.halfWidth,
|
||||
config.arenaSize - config.halfWidth,
|
||||
);
|
||||
squad.centerY = clamp(
|
||||
squad.centerY + (deltaY / distance) * step,
|
||||
config.halfHeight,
|
||||
config.arenaSize - config.halfHeight,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAggregateSquadCombatState(job, cells, state) {
|
||||
const maxDeathsPerCell = Math.max(
|
||||
1,
|
||||
Math.round(Number(job.config.maxDeathsPerCellTick) || 1),
|
||||
);
|
||||
const maxDeathsPerTick = resolveMaxDeathsPerTick(job.config);
|
||||
|
||||
for (let index = 0; index < cells.length && state.deadCount < maxDeathsPerTick; index += 1) {
|
||||
const groups = (cells[index].squads ?? [])
|
||||
.filter((squad) => squad.count > 0 && squadHasLivingMembers(squad, state))
|
||||
.sort((left, right) => right.count - left.count);
|
||||
|
||||
if (groups.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const leftGroup = groups[0];
|
||||
const rightGroup = groups.find((candidate) => candidate.teamKey !== leftGroup.teamKey);
|
||||
|
||||
if (!rightGroup) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cellDeathBudget = Math.min(
|
||||
maxDeathsPerCell,
|
||||
maxDeathsPerTick - state.deadCount,
|
||||
);
|
||||
let cellDeaths = 0;
|
||||
|
||||
cellDeaths += applyAggregateDamage(
|
||||
job,
|
||||
state,
|
||||
rightGroup.indexes,
|
||||
leftGroup.indexes,
|
||||
(leftGroup.dps * Math.max(0, Number(job.tickDelta) || 0)) / 1000,
|
||||
cellDeathBudget - cellDeaths,
|
||||
);
|
||||
|
||||
if (cellDeaths < cellDeathBudget) {
|
||||
applyAggregateDamage(
|
||||
job,
|
||||
state,
|
||||
leftGroup.indexes,
|
||||
rightGroup.indexes,
|
||||
(rightGroup.dps * Math.max(0, Number(job.tickDelta) || 0)) / 1000,
|
||||
cellDeathBudget - cellDeaths,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyAggregateDamage(job, state, defenderIndexes, attackerIndexes, damage, maxDeaths) {
|
||||
if (damage <= 0 || maxDeaths <= 0 || defenderIndexes.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const startIndex = state.damageCursor % defenderIndexes.length;
|
||||
let remainingDamage = damage;
|
||||
let resolvedDeaths = 0;
|
||||
|
||||
state.damageCursor += 1;
|
||||
|
||||
for (
|
||||
let checked = 0;
|
||||
checked < defenderIndexes.length && remainingDamage > 0 && resolvedDeaths < maxDeaths;
|
||||
checked += 1
|
||||
) {
|
||||
const modelIndex = defenderIndexes[(startIndex + checked) % defenderIndexes.length];
|
||||
|
||||
if (!state.alive[modelIndex]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentHp = Math.max(0, Number(job.hp[modelIndex]) || 0);
|
||||
|
||||
if (remainingDamage >= currentHp) {
|
||||
remainingDamage -= currentHp;
|
||||
job.hp[modelIndex] = 0;
|
||||
state.alive[modelIndex] = 0;
|
||||
state.deadDefenderIds[state.deadCount] = job.modelIds[modelIndex];
|
||||
state.deadAttackerIds[state.deadCount] = pickAggregateWinnerId(job, state, attackerIndexes);
|
||||
state.deadCount += 1;
|
||||
resolvedDeaths += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
job.hp[modelIndex] = Math.max(1, currentHp - remainingDamage);
|
||||
remainingDamage = 0;
|
||||
}
|
||||
|
||||
return resolvedDeaths;
|
||||
}
|
||||
|
||||
function pickAggregateWinnerId(job, state, indexes) {
|
||||
if (indexes.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const startIndex = state.winnerCursor % indexes.length;
|
||||
|
||||
state.winnerCursor += 1;
|
||||
|
||||
for (let index = 0; index < indexes.length; index += 1) {
|
||||
const modelIndex = indexes[(startIndex + index) % indexes.length];
|
||||
|
||||
if (state.alive[modelIndex] && !job.isFrostStunned[modelIndex]) {
|
||||
return job.modelIds[modelIndex];
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function syncAggregateSquadMembers(job, squads, state) {
|
||||
squads.forEach((squad) => {
|
||||
const livingIndexes = squad.indexes.filter((index) => state.alive[index]);
|
||||
const count = livingIndexes.length;
|
||||
|
||||
squad.count = count;
|
||||
|
||||
livingIndexes.forEach((modelIndex, index) => {
|
||||
const slot = aggregateSquadSlot(squad, index, count);
|
||||
job.x[modelIndex] = clamp(
|
||||
squad.centerX + slot.x,
|
||||
job.config.halfWidth,
|
||||
job.config.arenaSize - job.config.halfWidth,
|
||||
);
|
||||
job.y[modelIndex] = clamp(
|
||||
squad.centerY + slot.y,
|
||||
job.config.halfHeight,
|
||||
job.config.arenaSize - job.config.halfHeight,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function aggregateSquadSlot(squad, index, count) {
|
||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
||||
const progress = (index + 0.5) / Math.max(1, count);
|
||||
const radius = Math.sqrt(progress) * squad.radius;
|
||||
const angle = squad.seed * 0.017 + index * goldenAngle;
|
||||
|
||||
return {
|
||||
x: Math.cos(angle) * radius,
|
||||
y: Math.sin(angle) * radius,
|
||||
};
|
||||
}
|
||||
|
||||
function livingCount(indexes, state) {
|
||||
let count = 0;
|
||||
|
||||
indexes.forEach((index) => {
|
||||
if (state.alive[index]) {
|
||||
count += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
function squadHasLivingMembers(squad, state) {
|
||||
return squad.indexes.some((index) => state.alive[index]);
|
||||
}
|
||||
|
||||
function resolveMaxDeathsPerTick(config) {
|
||||
return Math.max(
|
||||
Math.max(1, Math.round(Number(config.maxDeathsPerCellTick) || 1)),
|
||||
Math.max(1, Math.round(Number(config.maxDeathsPerTick) || 1)),
|
||||
);
|
||||
}
|
||||
|
||||
function aggregateSquadSeed(cellKey, teamKey, chunkIndex) {
|
||||
const id = `${cellKey}:${teamKey}:${chunkIndex}`;
|
||||
let seed = 17;
|
||||
|
||||
for (let index = 0; index < id.length; index += 1) {
|
||||
seed = (seed * 31 + id.charCodeAt(index)) % 104729;
|
||||
}
|
||||
|
||||
return seed;
|
||||
}
|
||||
|
||||
function clampCell(value, cellSize, maxCell) {
|
||||
return Math.min(maxCell, Math.max(0, Math.floor(value / cellSize)));
|
||||
}
|
||||
|
||||
function targetCellIndex(cellX, cellY, columns) {
|
||||
return cellY * columns + cellX;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
+1718
-200
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,634 @@
|
||||
import Phaser from "phaser";
|
||||
import {
|
||||
ARENA,
|
||||
PERFORMANCE,
|
||||
WORLD_EFFECT,
|
||||
} from "../../constants.js";
|
||||
import {
|
||||
applyWorldEffectDamage,
|
||||
disposeCombatObject,
|
||||
trackCombatObject,
|
||||
} from "./combat.js";
|
||||
import {
|
||||
clearFighterTint,
|
||||
fighterWorldPoint,
|
||||
stopFighterMovement,
|
||||
tintFighter,
|
||||
} from "../fighter/fighterAdapter.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.matchStartedAt = scene.time.now;
|
||||
scene.isSuddenDeath = false;
|
||||
|
||||
const scheduleNext = (isInitialBarrage = false) => {
|
||||
if (!isLiveMatch(scene)) return;
|
||||
|
||||
const elapsed = scene.time.now - (scene.matchStartedAt ?? scene.time.now);
|
||||
const isSuddenDeath = WORLD_EFFECT.SUDDEN_DEATH.ENABLED && elapsed >= WORLD_EFFECT.SUDDEN_DEATH.TRIGGER_MS;
|
||||
const delay = isInitialBarrage
|
||||
? WORLD_EFFECT.INTERVAL
|
||||
: isSuddenDeath
|
||||
? WORLD_EFFECT.SUDDEN_DEATH.INTERVAL_MS
|
||||
: WORLD_EFFECT.REPEAT_INTERVAL;
|
||||
|
||||
if (isSuddenDeath && !scene.isSuddenDeath) {
|
||||
scene.isSuddenDeath = true;
|
||||
}
|
||||
|
||||
scene.worldEffectTimer = scene.time.delayedCall(delay, () => {
|
||||
triggerWorldEffect(scene);
|
||||
scheduleNext();
|
||||
});
|
||||
};
|
||||
|
||||
scheduleNext(true);
|
||||
}
|
||||
|
||||
export function clearWorldEffects(scene) {
|
||||
scene.worldEffectTimer?.remove(false);
|
||||
scene.worldEffectTimer = null;
|
||||
scene.worldEffectZones?.clear();
|
||||
scene.clearMeteorCameraFocus?.(null, { restoreCamera: false });
|
||||
scene.matchStartedAt = null;
|
||||
scene.isSuddenDeath = false;
|
||||
scene.nextWorldEffectModifierRefreshAt = 0;
|
||||
scene.worldEffectModifierActive = 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,
|
||||
);
|
||||
|
||||
if (frostZones.length === 0 && !scene.worldEffectModifierActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = scene.time?.now ?? 0;
|
||||
const refreshMs = Math.max(0, Number(PERFORMANCE.WORLD_EFFECT_MODIFIER_REFRESH_MS) || 0);
|
||||
|
||||
if (frostZones.length > 0 && refreshMs > 0 && now < (scene.nextWorldEffectModifierRefreshAt ?? 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
scene.nextWorldEffectModifierRefreshAt = now + refreshMs;
|
||||
scene.worldEffectModifierActive = frostZones.length > 0;
|
||||
|
||||
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 zone = findDensestWorldEffectZone(livingFighters);
|
||||
|
||||
// Sudden Death 상태이고 냉기 고정 설정이 되어있으면 무조건 냉기 메테오
|
||||
if ((scene.isSuddenDeath && WORLD_EFFECT.SUDDEN_DEATH.FORCE_FROST) || Phaser.Math.Between(0, 1) === 0) {
|
||||
spawnFrostZone(scene, zone);
|
||||
return;
|
||||
}
|
||||
|
||||
spawnMeteor(scene, zone);
|
||||
}
|
||||
|
||||
export function findDensestWorldEffectZone(livingFighters) {
|
||||
if (livingFighters.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const areaTiles = resolveTileCount(WORLD_EFFECT.AREA_TILES, ARENA.GRID_SIZE);
|
||||
const tileCounts = Array.from(
|
||||
{ length: ARENA.GRID_SIZE },
|
||||
() => Array(ARENA.GRID_SIZE).fill(0),
|
||||
);
|
||||
|
||||
livingFighters.forEach((fighter) => {
|
||||
const point = fighterWorldPoint(fighter);
|
||||
const x = point.x;
|
||||
const y = point.y;
|
||||
const column = Phaser.Math.Clamp(Math.floor(x / ARENA.TILE_SIZE), 0, ARENA.GRID_SIZE - 1);
|
||||
const row = Phaser.Math.Clamp(Math.floor(y / ARENA.TILE_SIZE), 0, ARENA.GRID_SIZE - 1);
|
||||
|
||||
tileCounts[row][column] += 1;
|
||||
});
|
||||
|
||||
// Summed-area lookup keeps dense-zone selection cheap even with thousands of fighters.
|
||||
const densitySums = createSummedAreaTable(tileCounts);
|
||||
const maximumOrigin = ARENA.GRID_SIZE - areaTiles;
|
||||
let highestCount = -1;
|
||||
let densestOrigins = [];
|
||||
|
||||
for (let row = 0; row <= maximumOrigin; row += 1) {
|
||||
for (let column = 0; column <= maximumOrigin; column += 1) {
|
||||
const count = sumArea(densitySums, column, row, areaTiles);
|
||||
|
||||
if (count > highestCount) {
|
||||
highestCount = count;
|
||||
densestOrigins = [{ column, row }];
|
||||
} else if (count === highestCount) {
|
||||
densestOrigins.push({ column, row });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const origin = randomEntry(densestOrigins);
|
||||
const size = areaTiles * ARENA.TILE_SIZE;
|
||||
|
||||
return createEffectZone(
|
||||
origin.column * ARENA.TILE_SIZE + size / 2,
|
||||
origin.row * ARENA.TILE_SIZE + size / 2,
|
||||
areaTiles,
|
||||
);
|
||||
}
|
||||
|
||||
function randomEntry(entries) {
|
||||
return entries[Phaser.Math.Between(0, entries.length - 1)];
|
||||
}
|
||||
|
||||
function spawnMeteor(scene, zone) {
|
||||
spawnWorldEffectBarrage(scene, zone, {
|
||||
color: METEOR_ZONE_COLOR,
|
||||
damage: WORLD_EFFECT.METEOR_DAMAGE,
|
||||
effectKey: METEOR_EFFECT_KEY,
|
||||
});
|
||||
}
|
||||
|
||||
function spawnFrostZone(scene, zone) {
|
||||
spawnWorldEffectBarrage(scene, zone, {
|
||||
color: FROST_ZONE_COLOR,
|
||||
damage: WORLD_EFFECT.FROST_DAMAGE,
|
||||
effectKey: FROST_EFFECT_KEY,
|
||||
isFrost: true,
|
||||
});
|
||||
}
|
||||
|
||||
function spawnWorldEffectBarrage(
|
||||
scene,
|
||||
targetZone,
|
||||
{ color, damage, effectKey, isFrost = false },
|
||||
) {
|
||||
const matchId = scene.matchId;
|
||||
const targetMarker = createZoneMarker(scene, targetZone, color);
|
||||
const impactZones = createBarrageImpactZones(targetZone);
|
||||
const pendingTimers = [];
|
||||
const warningHideTimer = scene.time.delayedCall(resolveWarningDurationMs(), () => {
|
||||
hideZoneMarker(scene, targetMarker);
|
||||
});
|
||||
let unresolvedImpacts = impactZones.length;
|
||||
|
||||
scene.beginMeteorCameraFocus?.(targetZone);
|
||||
|
||||
const finishImpact = () => {
|
||||
unresolvedImpacts -= 1;
|
||||
|
||||
if (unresolvedImpacts > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
scene.clearMeteorCameraFocus?.(targetZone);
|
||||
disposeCombatObject(scene, targetMarker);
|
||||
};
|
||||
|
||||
const previousCleanup = targetMarker.cleanup;
|
||||
targetMarker.cleanup = () => {
|
||||
warningHideTimer.remove(false);
|
||||
pendingTimers.forEach((timer) => timer.remove(false));
|
||||
previousCleanup?.();
|
||||
};
|
||||
|
||||
impactZones.forEach((impactZone, index) => {
|
||||
const timer = scene.time.delayedCall(resolveImpactStaggerMs() * index, () => {
|
||||
if (!targetMarker.active || !isLiveMatch(scene, matchId)) {
|
||||
finishImpact();
|
||||
return;
|
||||
}
|
||||
|
||||
const impactMarker = createZoneMarker(scene, impactZone, color);
|
||||
|
||||
dropWorldEffectSprite(scene, impactZone, {
|
||||
effectKey,
|
||||
onCancel: () => {
|
||||
disposeCombatObject(scene, impactMarker);
|
||||
finishImpact();
|
||||
},
|
||||
onImpact: () => {
|
||||
scene.tweens.killTweensOf(impactMarker);
|
||||
impactMarker.setAlpha(1);
|
||||
applyMeteorImpactShake(scene, impactZone);
|
||||
resolveImpactDamage(
|
||||
scene,
|
||||
impactZone,
|
||||
damage,
|
||||
isFrost ? (fighter) => applyFrostStun(scene, fighter) : undefined,
|
||||
);
|
||||
|
||||
if (isFrost && !scene.matchOver) {
|
||||
activateFrostZone(scene, impactZone, impactMarker);
|
||||
}
|
||||
},
|
||||
onAnimationComplete: () => {
|
||||
if (!isFrost) {
|
||||
disposeCombatObject(scene, impactMarker);
|
||||
}
|
||||
|
||||
finishImpact();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
pendingTimers.push(timer);
|
||||
});
|
||||
}
|
||||
|
||||
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(resolveWorldEffectVisualScale(zone))
|
||||
.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 resolveWorldEffectSizeScale() {
|
||||
const variance = Math.max(0, Number(WORLD_EFFECT.SIZE_SCALE_VARIANCE) || 0);
|
||||
|
||||
if (variance === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const minScale = Math.max(0.1, 1 - variance);
|
||||
const maxScale = 1 + variance;
|
||||
|
||||
return Phaser.Math.FloatBetween(minScale, maxScale);
|
||||
}
|
||||
|
||||
function resolveWorldEffectVisualScale(zone) {
|
||||
const baseScale = Math.max(0.01, Number(WORLD_EFFECT.IMPACT_VISUAL_SCALE) || 1);
|
||||
return baseScale * Math.max(0.1, Number(zone?.sizeScale) || 1);
|
||||
}
|
||||
|
||||
function applyMeteorImpactShake(scene, zone) {
|
||||
const sizeScale = Math.max(0.1, Number(zone?.sizeScale) || 1);
|
||||
const duration = Math.round(
|
||||
Math.max(0, Number(WORLD_EFFECT.METEOR_SHAKE_DURATION_MS) || 0)
|
||||
* Math.sqrt(sizeScale),
|
||||
);
|
||||
const intensity =
|
||||
Math.max(0, Number(WORLD_EFFECT.METEOR_SHAKE_INTENSITY) || 0) * sizeScale;
|
||||
|
||||
if (duration <= 0 || intensity <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
scene.cameras.main.shake(duration, intensity);
|
||||
}
|
||||
|
||||
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 createBarrageImpactZones(targetZone) {
|
||||
const minimumCount = Math.max(1, Math.round(Number(WORLD_EFFECT.IMPACT_COUNT_MIN) || 1));
|
||||
const maximumCount = Math.max(
|
||||
minimumCount,
|
||||
Math.round(Number(WORLD_EFFECT.IMPACT_COUNT_MAX) || minimumCount),
|
||||
);
|
||||
const count = Phaser.Math.Between(minimumCount, maximumCount);
|
||||
|
||||
return Array.from({ length: count }, () => createBarrageImpactZone(targetZone));
|
||||
}
|
||||
|
||||
function createBarrageImpactZone(targetZone) {
|
||||
const sizeScale = resolveWorldEffectSizeScale();
|
||||
const maximumImpactTiles = Math.max(1, targetZone.areaTiles - 1);
|
||||
const impactTiles = Math.min(
|
||||
maximumImpactTiles,
|
||||
Math.max(
|
||||
1,
|
||||
Math.round((Number(WORLD_EFFECT.IMPACT_AREA_TILES) || 1) * sizeScale),
|
||||
),
|
||||
);
|
||||
const impactSize = impactTiles * ARENA.TILE_SIZE;
|
||||
const halfImpactSize = impactSize / 2;
|
||||
const minimumX = targetZone.bounds.left + halfImpactSize;
|
||||
const maximumX = targetZone.bounds.right - halfImpactSize;
|
||||
const minimumY = targetZone.bounds.top + halfImpactSize;
|
||||
const maximumY = targetZone.bounds.bottom - halfImpactSize;
|
||||
|
||||
return createEffectZone(
|
||||
randomBetween(minimumX, maximumX),
|
||||
randomBetween(minimumY, maximumY),
|
||||
impactTiles,
|
||||
sizeScale,
|
||||
);
|
||||
}
|
||||
|
||||
function createEffectZone(centerX, centerY, areaTiles, sizeScale = 1) {
|
||||
const size = ARENA.TILE_SIZE * areaTiles;
|
||||
|
||||
return {
|
||||
areaTiles,
|
||||
bounds: new Phaser.Geom.Rectangle(centerX - size / 2, centerY - size / 2, size, size),
|
||||
centerX,
|
||||
centerY,
|
||||
marker: null,
|
||||
sizeScale,
|
||||
};
|
||||
}
|
||||
|
||||
function createSummedAreaTable(tileCounts) {
|
||||
const sums = Array.from(
|
||||
{ length: ARENA.GRID_SIZE + 1 },
|
||||
() => Array(ARENA.GRID_SIZE + 1).fill(0),
|
||||
);
|
||||
|
||||
for (let row = 0; row < ARENA.GRID_SIZE; row += 1) {
|
||||
for (let column = 0; column < ARENA.GRID_SIZE; column += 1) {
|
||||
sums[row + 1][column + 1] =
|
||||
tileCounts[row][column]
|
||||
+ sums[row][column + 1]
|
||||
+ sums[row + 1][column]
|
||||
- sums[row][column];
|
||||
}
|
||||
}
|
||||
|
||||
return sums;
|
||||
}
|
||||
|
||||
function sumArea(sums, column, row, areaTiles) {
|
||||
const bottom = row + areaTiles;
|
||||
const right = column + areaTiles;
|
||||
|
||||
return (
|
||||
sums[bottom][right]
|
||||
- sums[row][right]
|
||||
- sums[bottom][column]
|
||||
+ sums[row][column]
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTileCount(value, maximum) {
|
||||
return Phaser.Math.Clamp(Math.round(Number(value) || 1), 1, maximum);
|
||||
}
|
||||
|
||||
function resolveImpactStaggerMs() {
|
||||
return Math.max(0, Math.round(Number(WORLD_EFFECT.IMPACT_STAGGER_MS) || 0));
|
||||
}
|
||||
|
||||
function resolveWarningDurationMs() {
|
||||
return Math.max(0, Math.round(Number(WORLD_EFFECT.WARNING_DURATION_MS) || 0));
|
||||
}
|
||||
|
||||
function randomBetween(minimum, maximum) {
|
||||
if (maximum <= minimum) {
|
||||
return minimum;
|
||||
}
|
||||
|
||||
return Phaser.Math.FloatBetween(minimum, maximum);
|
||||
}
|
||||
|
||||
function hideZoneMarker(scene, marker) {
|
||||
if (!marker?.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
scene.tweens.killTweensOf(marker);
|
||||
marker.setVisible(false);
|
||||
}
|
||||
|
||||
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 < zone.areaTiles; 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;
|
||||
stopFighterMovement(fighter);
|
||||
tintFighter(fighter, WORLD_EFFECT.FROST_STUN_TINT);
|
||||
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;
|
||||
|
||||
clearFighterTint(fighter);
|
||||
}
|
||||
|
||||
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 point = fighterWorldPoint(fighter);
|
||||
const x = point.x;
|
||||
const y = point.y;
|
||||
|
||||
return Phaser.Geom.Rectangle.Contains(zone.bounds, x, y);
|
||||
}
|
||||
|
||||
function isLiveMatch(scene, matchId = scene.matchId) {
|
||||
return !scene.matchOver && !scene.presentationMode && scene.matchId === matchId;
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
ARENA,
|
||||
FIGHTER,
|
||||
} from "../../constants.js";
|
||||
import { ensureFighterTeamAnimation } from "./fighterAssets.js";
|
||||
import {
|
||||
fighterModelPoint,
|
||||
fighterModelDistanceSquared,
|
||||
getFighterModel,
|
||||
isLivingFighterModel,
|
||||
setFighterModelPosition,
|
||||
syncFighterModelFromSprite,
|
||||
} from "./fighterModel.js";
|
||||
|
||||
export {
|
||||
fighterModelDistanceSquared,
|
||||
fighterModelPoint,
|
||||
getFighterModel,
|
||||
isLivingFighterModel,
|
||||
syncFighterModelFromSprite,
|
||||
};
|
||||
|
||||
export function isFighterBodyEnabled(fighter) {
|
||||
return Boolean(!fighter?._spriteDetached && fighter?.body && fighter.body.enable !== false);
|
||||
}
|
||||
|
||||
export function shouldRenderFighterDetail(fighter) {
|
||||
return Boolean(
|
||||
fighter
|
||||
&& fighter.active !== false
|
||||
&& !fighter._spriteDetached
|
||||
&& fighter._detailVisible !== false
|
||||
&& fighter.visible !== false,
|
||||
);
|
||||
}
|
||||
|
||||
export function fighterWorldPoint(fighter) {
|
||||
if (isFighterBodyEnabled(fighter)) {
|
||||
return {
|
||||
x: fighter.body.center.x,
|
||||
y: fighter.body.center.y,
|
||||
};
|
||||
}
|
||||
|
||||
return fighterModelPoint(fighter);
|
||||
}
|
||||
|
||||
export function fighterDistanceSquared(left, right) {
|
||||
const deltaX = fighterWorldX(left) - fighterWorldX(right);
|
||||
const deltaY = fighterWorldY(left) - fighterWorldY(right);
|
||||
|
||||
return deltaX * deltaX + deltaY * deltaY;
|
||||
}
|
||||
|
||||
export function setFighterFacing(fighter, faceLeft) {
|
||||
if (!fighter) {
|
||||
return;
|
||||
}
|
||||
|
||||
fighter.facingLeft = Boolean(faceLeft);
|
||||
|
||||
const model = getFighterModel(fighter);
|
||||
if (model) {
|
||||
model.facingLeft = fighter.facingLeft;
|
||||
}
|
||||
|
||||
if (typeof fighter.setFlipX === "function") {
|
||||
fighter.setFlipX(fighter.facingLeft);
|
||||
return;
|
||||
}
|
||||
|
||||
fighter.flipX = fighter.facingLeft;
|
||||
}
|
||||
|
||||
export function stopFighterMovement(fighter) {
|
||||
if (isFighterBodyEnabled(fighter)) {
|
||||
fighter.body.setVelocity(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
export function disableFighterBody(fighter) {
|
||||
syncFighterModelFromSprite(fighter);
|
||||
stopFighterMovement(fighter);
|
||||
fighter?.body?.stop?.();
|
||||
|
||||
if (fighter?.body) {
|
||||
const world = fighter.scene?.physics?.world;
|
||||
|
||||
if (world) {
|
||||
world.disable(fighter);
|
||||
} else {
|
||||
fighter.body.enable = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function enableFighterBody(fighter) {
|
||||
if (!fighter?.body || fighter.isDead) {
|
||||
return;
|
||||
}
|
||||
|
||||
const point = fighterModelPoint(fighter);
|
||||
const world = fighter.scene?.physics?.world;
|
||||
|
||||
if (world) {
|
||||
world.enable(fighter);
|
||||
} else {
|
||||
fighter.body.enable = true;
|
||||
}
|
||||
fighter.body.reset?.(point.x, point.y);
|
||||
stopFighterMovement(fighter);
|
||||
fighter.body.updateFromGameObject?.();
|
||||
syncFighterModelFromSprite(fighter);
|
||||
}
|
||||
|
||||
export function setFighterWorldPosition(fighter, x, y) {
|
||||
if (!fighter) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fighter._spriteDetached && typeof fighter.setPosition === "function") {
|
||||
fighter.setPosition(x, y);
|
||||
} else if (!fighter._spriteDetached) {
|
||||
fighter.x = x;
|
||||
fighter.y = y;
|
||||
}
|
||||
|
||||
setFighterModelPosition(fighter, x, y);
|
||||
|
||||
if (isFighterBodyEnabled(fighter)) {
|
||||
fighter.body.updateFromGameObject?.();
|
||||
}
|
||||
}
|
||||
|
||||
export function moveFighterToward(scene, fighter, target, speed, delta) {
|
||||
if (!fighter?.active || !target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPoint = fighterWorldPoint(target);
|
||||
|
||||
if (isFighterBodyEnabled(fighter)) {
|
||||
scene.physics.moveTo(fighter, targetPoint.x, targetPoint.y, speed);
|
||||
return;
|
||||
}
|
||||
|
||||
const sourcePoint = fighterModelPoint(fighter);
|
||||
const deltaX = targetPoint.x - sourcePoint.x;
|
||||
const deltaY = targetPoint.y - sourcePoint.y;
|
||||
const distance = Math.hypot(deltaX, deltaY);
|
||||
|
||||
if (distance <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const step = Math.min(
|
||||
distance,
|
||||
speed * (Math.max(0, Number(delta) || 0) / 1000),
|
||||
);
|
||||
|
||||
setFighterWorldPosition(
|
||||
fighter,
|
||||
sourcePoint.x + (deltaX / distance) * step,
|
||||
sourcePoint.y + (deltaY / distance) * step,
|
||||
);
|
||||
clampFighterInsideArena(fighter);
|
||||
}
|
||||
|
||||
export function clampFighterInsideArena(fighter) {
|
||||
if (!fighter?.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bodyWidth = fighter.body?.width ?? FIGHTER.HITBOX_WIDTH;
|
||||
const bodyHeight = fighter.body?.height ?? FIGHTER.HITBOX_HEIGHT;
|
||||
const halfWidth = Math.min(
|
||||
ARENA.SIZE / 2,
|
||||
Math.max(Math.abs(fighter.displayWidth ?? 0), bodyWidth) / 2,
|
||||
);
|
||||
const halfHeight = Math.min(
|
||||
ARENA.SIZE / 2,
|
||||
Math.max(Math.abs(fighter.displayHeight ?? 0), bodyHeight) / 2,
|
||||
);
|
||||
const point = fighterModelPoint(fighter);
|
||||
const x = clamp(point.x, halfWidth, ARENA.SIZE - halfWidth);
|
||||
const y = clamp(point.y, halfHeight, ARENA.SIZE - halfHeight);
|
||||
|
||||
setFighterWorldPosition(fighter, x, y);
|
||||
}
|
||||
|
||||
export function playFighterActionIfNeeded(fighter, action) {
|
||||
if (!shouldRenderFighterDetail(fighter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = resolveFighterAnimationKey(fighter, action);
|
||||
|
||||
if (key && fighter.anims?.currentAnim?.key !== key) {
|
||||
playResolvedFighterAnimation(fighter, key);
|
||||
}
|
||||
}
|
||||
|
||||
export function playFighterAction(fighter, action, timeScale = 1) {
|
||||
if (!shouldRenderFighterDetail(fighter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = resolveFighterAnimationKey(fighter, action);
|
||||
|
||||
if (key) {
|
||||
playResolvedFighterAnimation(fighter, key, timeScale);
|
||||
}
|
||||
}
|
||||
|
||||
export function tintFighter(fighter, tint) {
|
||||
if (typeof fighter?.setTint === "function") {
|
||||
fighter.setTint(tint);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearFighterTint(fighter) {
|
||||
if (fighter?.active && typeof fighter.clearTint === "function") {
|
||||
fighter.clearTint();
|
||||
}
|
||||
}
|
||||
|
||||
function playResolvedFighterAnimation(fighter, key, timeScale = 1) {
|
||||
if (!fighter?.anims || typeof fighter.play !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
fighter.anims.timeScale = timeScale;
|
||||
fighter.play(key, true);
|
||||
}
|
||||
|
||||
function resolveFighterAnimationKey(fighter, action) {
|
||||
if (!fighter?.scene || !fighter?.skin || !action) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ensureFighterTeamAnimation(
|
||||
fighter.scene,
|
||||
fighter.skin,
|
||||
action,
|
||||
fighter.team?.color,
|
||||
);
|
||||
}
|
||||
|
||||
function clamp(value, minimum, maximum) {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
function fighterWorldX(fighter) {
|
||||
if (isFighterBodyEnabled(fighter)) {
|
||||
return fighter.body.center.x;
|
||||
}
|
||||
|
||||
return fighterModelPoint(fighter).x;
|
||||
}
|
||||
|
||||
function fighterWorldY(fighter) {
|
||||
if (isFighterBodyEnabled(fighter)) {
|
||||
return fighter.body.center.y;
|
||||
}
|
||||
|
||||
return fighterModelPoint(fighter).y;
|
||||
}
|
||||
+106
-101
@@ -1,33 +1,29 @@
|
||||
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,
|
||||
} from "../../constants.js";
|
||||
|
||||
const SOURCE_ALPHA_THRESHOLD = 8;
|
||||
const HEAL_EFFECT_PATH = "assets/effects/heal/Heal_Effect.png";
|
||||
const HEAL_EFFECT_KEY = "kill-heal-effect";
|
||||
const HEAL_EFFECT_ANIMATION_KEY = `${HEAL_EFFECT_KEY}-anim`;
|
||||
const TEAM_SHADOW_SOURCE_COLOR = {
|
||||
red: 0x53,
|
||||
green: 0x45,
|
||||
blue: 0x45,
|
||||
};
|
||||
const TEAM_SHADOW_FRAME_Y_START = 55;
|
||||
const TEAM_SHADOW_FRAME_Y_END = 60;
|
||||
|
||||
export function fighterSheetKey(skin, action, teamColor) {
|
||||
if (teamColor) {
|
||||
return `${skin.key}-${action}-team-shadow-${normalizeTeamColorKey(teamColor)}`;
|
||||
}
|
||||
|
||||
export function fighterSheetKey(skin, action) {
|
||||
return `${skin.key}-${action}`;
|
||||
}
|
||||
|
||||
export function fighterAnimationKey(skin, action) {
|
||||
return `${fighterSheetKey(skin, action)}-anim`;
|
||||
}
|
||||
|
||||
export function fighterOutlineSheetKey(skin, action) {
|
||||
return `${fighterSheetKey(skin, action)}-outline`;
|
||||
}
|
||||
|
||||
export function fighterOutlineSheetKeyFromSheetKey(sheetKey) {
|
||||
return `${sheetKey}-outline`;
|
||||
export function fighterAnimationKey(skin, action, teamColor) {
|
||||
return `${fighterSheetKey(skin, action, teamColor)}-anim`;
|
||||
}
|
||||
|
||||
export function fighterAttackEffectKey(skin) {
|
||||
@@ -52,8 +48,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 +57,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 +71,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,
|
||||
@@ -87,8 +83,6 @@ export function createFighterAnimations(scene, skins) {
|
||||
repeat,
|
||||
});
|
||||
}
|
||||
|
||||
createFighterOutlineSheet(scene, skin, action, animation.frames);
|
||||
});
|
||||
|
||||
createAttackEffectAnimation(scene, skin);
|
||||
@@ -97,6 +91,47 @@ export function createFighterAnimations(scene, skins) {
|
||||
createHealEffectAnimation(scene);
|
||||
}
|
||||
|
||||
export function ensureFighterTeamAnimation(scene, skin, action, teamColor) {
|
||||
const animation = skin.animations[action];
|
||||
|
||||
if (!animation || !teamColor) {
|
||||
return fighterAnimationKey(skin, action);
|
||||
}
|
||||
|
||||
const textureKey = fighterSheetKey(skin, action, teamColor);
|
||||
|
||||
if (
|
||||
!scene.textures.exists(textureKey)
|
||||
&& !createFighterTeamShadowSheet(scene, skin, action, animation.frames, teamColor)
|
||||
) {
|
||||
return fighterAnimationKey(skin, action);
|
||||
}
|
||||
|
||||
const key = fighterAnimationKey(skin, action, teamColor);
|
||||
|
||||
if (!scene.anims.exists(key)) {
|
||||
const { frameRate, repeat } = FIGHTER.ANIMATION_OPTIONS[action];
|
||||
|
||||
scene.anims.create({
|
||||
key,
|
||||
frames: scene.anims.generateFrameNumbers(textureKey, {
|
||||
start: 0,
|
||||
end: animation.frames - 1,
|
||||
}),
|
||||
frameRate,
|
||||
repeat,
|
||||
});
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
export function ensureFighterTeamAnimations(scene, skin, teamColor, actions = []) {
|
||||
actions.forEach((action) => {
|
||||
ensureFighterTeamAnimation(scene, skin, action, teamColor);
|
||||
});
|
||||
}
|
||||
|
||||
function preloadCombatAssets(scene, skin) {
|
||||
const projectile = skin.combat?.projectile;
|
||||
const attackEffect = skin.combat?.attackEffect;
|
||||
@@ -109,7 +144,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,29 +182,29 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
function createFighterOutlineSheet(scene, skin, action, frameCount) {
|
||||
const key = fighterOutlineSheetKey(skin, action);
|
||||
function createFighterTeamShadowSheet(scene, skin, action, frameCount, teamColor) {
|
||||
const key = fighterSheetKey(skin, action, teamColor);
|
||||
|
||||
if (scene.textures.exists(key)) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const sourceTexture = scene.textures.get(fighterSheetKey(skin, action));
|
||||
const sourceImage = sourceTexture?.getSourceImage?.();
|
||||
|
||||
if (!sourceImage) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -177,90 +212,60 @@ function createFighterOutlineSheet(scene, skin, action, frameCount) {
|
||||
const sourceContext = sourceCanvas.getContext("2d", { willReadFrequently: true });
|
||||
sourceContext.drawImage(sourceImage, 0, 0);
|
||||
|
||||
const sourceData = sourceContext.getImageData(0, 0, sheetWidth, sheetHeight).data;
|
||||
const outlineCanvas = document.createElement("canvas");
|
||||
outlineCanvas.width = sheetWidth;
|
||||
outlineCanvas.height = sheetHeight;
|
||||
|
||||
const outlineContext = outlineCanvas.getContext("2d");
|
||||
const outlineImage = outlineContext.createImageData(sheetWidth, sheetHeight);
|
||||
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 sourceImageData = sourceContext.getImageData(0, 0, sheetWidth, sheetHeight);
|
||||
const sourceData = sourceImageData.data;
|
||||
const shadowColor = parseHexColor(teamColor);
|
||||
|
||||
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 = TEAM_SHADOW_FRAME_Y_START; y < TEAM_SHADOW_FRAME_Y_END; 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) {
|
||||
if (!isTeamShadowPixel(sourceData, sourceIndex)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, x, y);
|
||||
sourceData[sourceIndex] = shadowColor.red;
|
||||
sourceData[sourceIndex + 1] = shadowColor.green;
|
||||
sourceData[sourceIndex + 2] = shadowColor.blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha);
|
||||
outlineContext.putImageData(outlineImage, 0, 0);
|
||||
scene.textures.addSpriteSheet(key, outlineCanvas, {
|
||||
frameWidth: FIGHTER_FRAME_WIDTH,
|
||||
frameHeight: FIGHTER_FRAME_HEIGHT,
|
||||
sourceContext.putImageData(sourceImageData, 0, 0);
|
||||
scene.textures.addSpriteSheet(key, sourceCanvas, {
|
||||
frameWidth: FIGHTER.FRAME_WIDTH,
|
||||
frameHeight: FIGHTER.FRAME_HEIGHT,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, sourceX, sourceY) {
|
||||
const outerRadius = SELECTED_FIGHTER_OUTLINE_GAP + SELECTED_FIGHTER_OUTLINE_WIDTH;
|
||||
|
||||
for (
|
||||
let offsetY = -outerRadius;
|
||||
offsetY <= outerRadius;
|
||||
offsetY += 1
|
||||
) {
|
||||
const targetY = sourceY + offsetY;
|
||||
|
||||
if (targetY < 0 || targetY >= FIGHTER_FRAME_HEIGHT) {
|
||||
continue;
|
||||
function isTeamShadowPixel(data, index) {
|
||||
return (
|
||||
data[index + 3] > 0
|
||||
&& data[index] === TEAM_SHADOW_SOURCE_COLOR.red
|
||||
&& data[index + 1] === TEAM_SHADOW_SOURCE_COLOR.green
|
||||
&& data[index + 2] === TEAM_SHADOW_SOURCE_COLOR.blue
|
||||
);
|
||||
}
|
||||
|
||||
for (
|
||||
let offsetX = -outerRadius;
|
||||
offsetX <= outerRadius;
|
||||
offsetX += 1
|
||||
) {
|
||||
const targetX = sourceX + offsetX;
|
||||
|
||||
if (targetX < 0 || targetX >= FIGHTER_FRAME_WIDTH) {
|
||||
continue;
|
||||
function normalizeTeamColorKey(teamColor) {
|
||||
return parseHexColor(teamColor).hex;
|
||||
}
|
||||
|
||||
const maskIndex = (targetY * sheetWidth) + frameLeft + targetX;
|
||||
const distance = Math.max(Math.abs(offsetX), Math.abs(offsetY));
|
||||
function parseHexColor(teamColor) {
|
||||
const fallback = "ffffff";
|
||||
const hex = typeof teamColor === "string"
|
||||
? teamColor.trim().replace(/^#/, "").toLowerCase()
|
||||
: fallback;
|
||||
const normalizedHex = /^[0-9a-f]{6}$/.test(hex) ? hex : fallback;
|
||||
|
||||
outerMask[maskIndex] = 1;
|
||||
|
||||
if (distance <= SELECTED_FIGHTER_OUTLINE_GAP) {
|
||||
gapMask[maskIndex] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha) {
|
||||
for (let maskIndex = 0; maskIndex < outerMask.length; maskIndex += 1) {
|
||||
if (!outerMask[maskIndex] || gapMask[maskIndex]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const outlineIndex = maskIndex * 4;
|
||||
|
||||
outlineData[outlineIndex] = 255;
|
||||
outlineData[outlineIndex + 1] = 255;
|
||||
outlineData[outlineIndex + 2] = 255;
|
||||
outlineData[outlineIndex + 3] = outlineAlpha;
|
||||
}
|
||||
return {
|
||||
blue: parseInt(normalizedHex.slice(4, 6), 16),
|
||||
green: parseInt(normalizedHex.slice(2, 4), 16),
|
||||
hex: normalizedHex,
|
||||
red: parseInt(normalizedHex.slice(0, 2), 16),
|
||||
};
|
||||
}
|
||||
|
||||
+248
-108
@@ -1,173 +1,313 @@
|
||||
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,
|
||||
PERFORMANCE,
|
||||
} from "../../constants.js";
|
||||
import {
|
||||
fighterAnimationKey,
|
||||
fighterOutlineSheetKeyFromSheetKey,
|
||||
ensureFighterTeamAnimation,
|
||||
ensureFighterTeamAnimations,
|
||||
fighterSheetKey,
|
||||
} from "./fighterAssets.js";
|
||||
import {
|
||||
disableFighterBody,
|
||||
enableFighterBody,
|
||||
} from "./fighterAdapter.js";
|
||||
import {
|
||||
attachFighterModel,
|
||||
createFighterModel,
|
||||
fighterModelPoint,
|
||||
} from "./fighterModel.js";
|
||||
import { getFighterStats } from "./fighterStats.js";
|
||||
|
||||
const NAME_LABEL_BOTTOM_GAP = 14;
|
||||
const HUD_DETAIL_SYNC_INTERVAL_MS = 100;
|
||||
|
||||
export function createFighter(
|
||||
scene,
|
||||
{ canSplitOnDeath = true, faceLeft, hp, maxHp, name, skin, team, teamIndex, x, y },
|
||||
{ attachSprite = true } = {},
|
||||
) {
|
||||
const fighter = scene.physics.add.sprite(x, y, fighterSheetKey(skin, "idle"), 0);
|
||||
const teamColor = Phaser.Display.Color.HexStringToColor(team.color).color;
|
||||
ensureFighterTeamAnimations(scene, skin, team.color, ["idle"]);
|
||||
|
||||
const teamIdleSheetKey = fighterSheetKey(skin, "idle", team.color);
|
||||
const idleSheetKey = scene.textures.exists(teamIdleSheetKey)
|
||||
? teamIdleSheetKey
|
||||
: fighterSheetKey(skin, "idle");
|
||||
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)),
|
||||
);
|
||||
const fighter = scene.physics.add.sprite(x, y, idleSheetKey, 0);
|
||||
const inputHitArea = new Phaser.Geom.Rectangle(
|
||||
FIGHTER.HITBOX_OFFSET_X,
|
||||
FIGHTER.HITBOX_OFFSET_Y,
|
||||
FIGHTER.HITBOX_WIDTH,
|
||||
FIGHTER.HITBOX_HEIGHT,
|
||||
);
|
||||
|
||||
fighter.setScale(FIGHTER_SCALE);
|
||||
fighter._spriteDetached = false;
|
||||
fighter._detailVisible = true;
|
||||
attachFighterModel(
|
||||
fighter,
|
||||
createFighterModel({
|
||||
canSplitOnDeath,
|
||||
combatStats,
|
||||
facingLeft: faceLeft,
|
||||
fighterName: displayName,
|
||||
hp: resolvedHp,
|
||||
maxHp: resolvedMaxHp,
|
||||
skin,
|
||||
team,
|
||||
teamIndex,
|
||||
x,
|
||||
y,
|
||||
}),
|
||||
);
|
||||
|
||||
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.setInteractive(
|
||||
new Phaser.Geom.Rectangle(
|
||||
FIGHTER_HITBOX_OFFSET_X,
|
||||
FIGHTER_HITBOX_OFFSET_Y,
|
||||
FIGHTER_HITBOX_WIDTH,
|
||||
FIGHTER_HITBOX_HEIGHT,
|
||||
),
|
||||
Phaser.Geom.Rectangle.Contains,
|
||||
);
|
||||
fighter.body.setSize(FIGHTER.HITBOX_WIDTH, FIGHTER.HITBOX_HEIGHT);
|
||||
fighter.body.setOffset(FIGHTER.HITBOX_OFFSET_X, FIGHTER.HITBOX_OFFSET_Y);
|
||||
fighter.setInteractive(inputHitArea, Phaser.Geom.Rectangle.Contains);
|
||||
fighter.input.cursor = "pointer";
|
||||
|
||||
fighter.teamMarker = scene.add
|
||||
.sprite(x, y, fighterOutlineSheetKeyFromSheetKey(fighterSheetKey(skin, "idle")), 0)
|
||||
.setDisplaySize(FIGHTER_FRAME_WIDTH * FIGHTER_SCALE, FIGHTER_FRAME_HEIGHT * FIGHTER_SCALE)
|
||||
.setTint(teamColor)
|
||||
.setAlpha(0.8)
|
||||
.setDepth(1.9)
|
||||
.setVisible(true);
|
||||
|
||||
fighter.nameLabel = scene.add
|
||||
.text(x, y, displayName, {
|
||||
color: "#fff2c2",
|
||||
fontFamily: "Inter, Pretendard, sans-serif",
|
||||
fontSize: "18px",
|
||||
fontStyle: "700",
|
||||
stroke: team.color,
|
||||
strokeThickness: 4,
|
||||
})
|
||||
.setOrigin(0.5, 0)
|
||||
.setDepth(4);
|
||||
fighter.healthBack = scene.add
|
||||
.rectangle(x, y - 44, 72, 8, 0x17180e, 0.92)
|
||||
.setDepth(4);
|
||||
fighter.healthBar = scene.add
|
||||
.rectangle(x - 34, y - 44, 68, 4, 0xd95f3f, 1)
|
||||
.setOrigin(0, 0.5)
|
||||
.setDepth(5);
|
||||
|
||||
fighter.skin = skin;
|
||||
fighter.fighterName = displayName;
|
||||
fighter.team = team;
|
||||
fighter.teamIndex = teamIndex;
|
||||
fighter.baseScaleX = FIGHTER_SCALE;
|
||||
fighter.baseScaleY = FIGHTER_SCALE;
|
||||
fighter.canSplitOnDeath = canSplitOnDeath;
|
||||
fighter.isSelected = false;
|
||||
fighter.killCount = 0;
|
||||
fighter.killRewardMultiplier = 1;
|
||||
fighter.maxHp = resolvedMaxHp;
|
||||
fighter.hp = resolvedHp;
|
||||
fighter.nextAttackAt = 0;
|
||||
fighter.isLocked = false;
|
||||
fighter.isDead = false;
|
||||
fighter.play(fighterAnimationKey(skin, "walk"));
|
||||
fighter._inputHitArea = inputHitArea;
|
||||
fighter.baseScaleX = FIGHTER.SCALE;
|
||||
fighter.baseScaleY = FIGHTER.SCALE;
|
||||
fighter.deadDespawnTimer = null;
|
||||
fighter.deadDespawnTween = null;
|
||||
fighter.frostStunTimer = null;
|
||||
fighter.nextHudSyncAt = 0;
|
||||
fighter._hudDetailsVisible = false;
|
||||
fighter._hudSlot = null;
|
||||
fighter.releaseHud = () => releaseFighterHud(fighter);
|
||||
fighter.play(ensureFighterTeamAnimation(scene, skin, "walk", team.color));
|
||||
|
||||
fighter.on(Phaser.Animations.Events.ANIMATION_COMPLETE, (animation) => {
|
||||
if (fighter.isDead) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (animation.key.includes("-attack") || animation.key.endsWith("-hurt-anim")) {
|
||||
if (animation.key.includes("-attack") || animation.key.includes("-hurt")) {
|
||||
fighter.isLocked = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!attachSprite) {
|
||||
setFighterDetailVisible(fighter, false);
|
||||
}
|
||||
|
||||
attachHudCleanup(fighter);
|
||||
syncFighterHud(fighter);
|
||||
|
||||
return fighter;
|
||||
}
|
||||
|
||||
export function syncFighterHud(fighter) {
|
||||
const isVisible = Boolean(fighter.active && !fighter.isDead);
|
||||
|
||||
fighter.nameLabel.setVisible(isVisible);
|
||||
fighter.healthBack.setVisible(isVisible);
|
||||
fighter.healthBar.setVisible(isVisible);
|
||||
syncTeamMarker(fighter);
|
||||
|
||||
if (!isVisible || !fighter.body) {
|
||||
export function setFighterDetailVisible(fighter, visible) {
|
||||
if (!fighter || !fighter.scene) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scaleRatio = Math.max(1, Math.abs(fighter.scaleY) / FIGHTER_SCALE);
|
||||
const shouldShow = Boolean(visible && !fighter.isDead);
|
||||
|
||||
if (shouldShow && fighter._detailVisible === true && !fighter._spriteDetached) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldShow && fighter._detailVisible === false && fighter._spriteDetached) {
|
||||
return;
|
||||
}
|
||||
|
||||
fighter._detailVisible = shouldShow;
|
||||
|
||||
if (!shouldShow) {
|
||||
fighter.isLocked = false;
|
||||
disableFighterBody(fighter);
|
||||
fighter.anims?.pause();
|
||||
fighter.disableInteractive?.();
|
||||
releaseFighterHud(fighter);
|
||||
fighter.setVisible(false);
|
||||
fighter.removeFromDisplayList?.();
|
||||
fighter.removeFromUpdateList?.();
|
||||
fighter._spriteDetached = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const point = fighterModelPoint(fighter);
|
||||
|
||||
fighter.setActive(true);
|
||||
fighter.setPosition(point.x, point.y);
|
||||
fighter.addToDisplayList?.();
|
||||
fighter.addToUpdateList?.();
|
||||
fighter.setVisible(true);
|
||||
fighter._spriteDetached = false;
|
||||
enableFighterBody(fighter);
|
||||
|
||||
fighter.setInteractive?.(fighter._inputHitArea, Phaser.Geom.Rectangle.Contains);
|
||||
|
||||
if (fighter.input) {
|
||||
fighter.input.cursor = "pointer";
|
||||
}
|
||||
|
||||
if (!fighter.isDead) {
|
||||
fighter.anims?.resume();
|
||||
}
|
||||
}
|
||||
|
||||
export function syncFighterHud(
|
||||
fighter,
|
||||
{ force = false, showDetails = true, time = fighter.scene?.time?.now ?? 0 } = {},
|
||||
) {
|
||||
const isVisible = Boolean(fighter.active && fighter.visible && !fighter.isDead);
|
||||
const detailsVisible = isVisible && (showDetails || fighter.isSelected);
|
||||
|
||||
if (!detailsVisible || !fighter.body) {
|
||||
releaseFighterHud(fighter);
|
||||
return false;
|
||||
}
|
||||
|
||||
const hudSlot = acquireFighterHudSlot(fighter);
|
||||
|
||||
if (!hudSlot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Number.isFinite(time) ? time : fighter.scene?.time?.now ?? 0;
|
||||
const shouldSyncDetails =
|
||||
force
|
||||
|| fighter.isSelected
|
||||
|| !fighter._hudDetailsVisible
|
||||
|| fighter._lastHudHp !== fighter.hp
|
||||
|| now >= (fighter.nextHudSyncAt ?? 0);
|
||||
|
||||
if (!shouldSyncDetails) {
|
||||
return true;
|
||||
}
|
||||
|
||||
fighter.nextHudSyncAt = now + HUD_DETAIL_SYNC_INTERVAL_MS;
|
||||
fighter._lastHudHp = fighter.hp;
|
||||
setHudDetailsVisible(fighter, true);
|
||||
|
||||
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;
|
||||
const nameY = hitbox.y + hitbox.height + NAME_LABEL_BOTTOM_GAP;
|
||||
|
||||
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)));
|
||||
hudSlot.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
|
||||
hudSlot.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
|
||||
hudSlot.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? 1)));
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncTeamMarker(fighter) {
|
||||
const marker = fighter.teamMarker;
|
||||
export function releaseFighterHud(fighter) {
|
||||
const hudSlot = fighter?._hudSlot;
|
||||
|
||||
if (!marker) {
|
||||
if (!hudSlot) {
|
||||
if (fighter) {
|
||||
fighter._hudDetailsVisible = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isVisible = Boolean(fighter.active && !fighter.isDead);
|
||||
marker.setVisible(isVisible);
|
||||
setHudSlotVisible(hudSlot, false);
|
||||
hudSlot.fighter = null;
|
||||
fighter._hudSlot = null;
|
||||
fighter._hudDetailsVisible = false;
|
||||
}
|
||||
|
||||
if (!isVisible) {
|
||||
export function releaseUnusedFighterHuds(scene, fightersWithHud = []) {
|
||||
const activeFighters = fightersWithHud instanceof Set
|
||||
? fightersWithHud
|
||||
: new Set(fightersWithHud);
|
||||
|
||||
scene.fighterHudPool?.forEach((hudSlot) => {
|
||||
if (hudSlot.fighter && !activeFighters.has(hudSlot.fighter)) {
|
||||
releaseFighterHud(hudSlot.fighter);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setHudDetailsVisible(fighter, visible) {
|
||||
const hudSlot = fighter._hudSlot;
|
||||
|
||||
if (!hudSlot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const outlineTextureKey = fighterOutlineSheetKeyFromSheetKey(fighter.texture.key);
|
||||
|
||||
if (fighter.scene.textures.exists(outlineTextureKey)) {
|
||||
marker.setTexture(outlineTextureKey, fighter.frame.name);
|
||||
fighter._hudDetailsVisible = visible;
|
||||
setHudSlotVisible(hudSlot, visible);
|
||||
}
|
||||
|
||||
marker.setPosition(fighter.x, fighter.y);
|
||||
marker.setScale(fighter.scaleX, fighter.scaleY);
|
||||
marker.setFlipX(fighter.flipX);
|
||||
marker.setDepth(fighter.depth - 0.1);
|
||||
function setVisibleIfChanged(gameObject, visible) {
|
||||
if (gameObject && gameObject.visible !== visible) {
|
||||
gameObject.setVisible(visible);
|
||||
}
|
||||
}
|
||||
|
||||
function setHudSlotVisible(hudSlot, visible) {
|
||||
setVisibleIfChanged(hudSlot.healthBack, visible);
|
||||
setVisibleIfChanged(hudSlot.healthBar, visible);
|
||||
}
|
||||
|
||||
function acquireFighterHudSlot(fighter) {
|
||||
if (fighter._hudSlot) {
|
||||
return fighter._hudSlot;
|
||||
}
|
||||
|
||||
const hudPool = ensureFighterHudPool(fighter.scene);
|
||||
const hudSlot = hudPool.find((candidate) => !candidate.fighter);
|
||||
|
||||
if (!hudSlot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
hudSlot.fighter = fighter;
|
||||
fighter._hudSlot = hudSlot;
|
||||
configureHudSlot(hudSlot);
|
||||
return hudSlot;
|
||||
}
|
||||
|
||||
function ensureFighterHudPool(scene) {
|
||||
if (scene.fighterHudPool) {
|
||||
return scene.fighterHudPool;
|
||||
}
|
||||
|
||||
const poolSize = Math.max(0, Math.round(PERFORMANCE.FIGHTER_HUD_POOL_SIZE));
|
||||
scene.fighterHudPool = Array.from({ length: poolSize }, () => createHudSlot(scene));
|
||||
return scene.fighterHudPool;
|
||||
}
|
||||
|
||||
function createHudSlot(scene) {
|
||||
const healthBack = scene.add
|
||||
.rectangle(0, 0, 72, 8, 0x17180e, 0.92)
|
||||
.setDepth(4)
|
||||
.setVisible(false);
|
||||
const healthBar = scene.add
|
||||
.rectangle(0, 0, 68, 4, 0xd95f3f, 1)
|
||||
.setOrigin(0, 0.5)
|
||||
.setDepth(5)
|
||||
.setVisible(false);
|
||||
|
||||
return {
|
||||
fighter: null,
|
||||
healthBack,
|
||||
healthBar,
|
||||
};
|
||||
}
|
||||
|
||||
function configureHudSlot(hudSlot) {
|
||||
hudSlot.healthBack.setDepth(4);
|
||||
hudSlot.healthBar.setDepth(5);
|
||||
}
|
||||
|
||||
function attachHudCleanup(fighter) {
|
||||
const originalDestroy = fighter.destroy.bind(fighter);
|
||||
|
||||
fighter.destroy = (...args) => {
|
||||
fighter.teamMarker.destroy();
|
||||
fighter.nameLabel.destroy();
|
||||
fighter.healthBack.destroy();
|
||||
fighter.healthBar.destroy();
|
||||
releaseFighterHud(fighter);
|
||||
originalDestroy(...args);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ export const fighterManifest = [
|
||||
maxHp: 1,
|
||||
},
|
||||
traits: {
|
||||
spawnMultiplier: 10,
|
||||
spawnMultiplier: 3,
|
||||
splitOnDeath: {
|
||||
chance: 0.5,
|
||||
count: 2,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
const FIGHTER_MODEL_PROPERTY_MAP = {
|
||||
_detailVisible: "detailVisible",
|
||||
canSplitOnDeath: "canSplitOnDeath",
|
||||
combatStats: "combatStats",
|
||||
facingLeft: "facingLeft",
|
||||
fighterName: "fighterName",
|
||||
hp: "hp",
|
||||
isDead: "isDead",
|
||||
isFrostStunned: "isFrostStunned",
|
||||
isLocked: "isLocked",
|
||||
isSelected: "isSelected",
|
||||
killCount: "killCount",
|
||||
killRewardMultiplier: "killRewardMultiplier",
|
||||
maxHp: "maxHp",
|
||||
nextAttackAt: "nextAttackAt",
|
||||
nextTargetScanAt: "nextTargetScanAt",
|
||||
skin: "skin",
|
||||
targetModelId: "targetModelId",
|
||||
team: "team",
|
||||
teamIndex: "teamIndex",
|
||||
worldEffectSpeedMultiplier: "worldEffectSpeedMultiplier",
|
||||
};
|
||||
|
||||
let nextFighterModelId = 1;
|
||||
|
||||
export function createFighterModel({
|
||||
canSplitOnDeath = true,
|
||||
combatStats,
|
||||
facingLeft = false,
|
||||
fighterName,
|
||||
hp,
|
||||
maxHp,
|
||||
skin,
|
||||
team,
|
||||
teamIndex,
|
||||
x,
|
||||
y,
|
||||
}) {
|
||||
const id = `fighter-${nextFighterModelId}`;
|
||||
nextFighterModelId += 1;
|
||||
|
||||
return {
|
||||
id,
|
||||
active: true,
|
||||
canSplitOnDeath,
|
||||
combatStats,
|
||||
detailVisible: true,
|
||||
facingLeft: Boolean(facingLeft),
|
||||
fighterName,
|
||||
hp,
|
||||
isDead: false,
|
||||
isFrostStunned: false,
|
||||
isLocked: false,
|
||||
isSelected: false,
|
||||
killCount: 0,
|
||||
killRewardMultiplier: 1,
|
||||
maxHp,
|
||||
nextAttackAt: 0,
|
||||
nextTargetScanAt: 0,
|
||||
skin,
|
||||
targetModelId: null,
|
||||
team,
|
||||
teamIndex,
|
||||
worldEffectSpeedMultiplier: 1,
|
||||
x,
|
||||
y,
|
||||
};
|
||||
}
|
||||
|
||||
export function attachFighterModel(fighter, model) {
|
||||
if (!fighter || !model) {
|
||||
return model;
|
||||
}
|
||||
|
||||
Object.defineProperty(fighter, "model", {
|
||||
configurable: true,
|
||||
value: model,
|
||||
});
|
||||
Object.defineProperty(fighter, "modelId", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this.model?.id;
|
||||
},
|
||||
});
|
||||
|
||||
Object.entries(FIGHTER_MODEL_PROPERTY_MAP).forEach(([fighterKey, modelKey]) => {
|
||||
Object.defineProperty(fighter, fighterKey, {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this.model?.[modelKey];
|
||||
},
|
||||
set(value) {
|
||||
if (this.model) {
|
||||
this.model[modelKey] = value;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
syncFighterModelFromSprite(fighter);
|
||||
return model;
|
||||
}
|
||||
|
||||
export function getFighterModel(fighter) {
|
||||
return fighter?.model ?? null;
|
||||
}
|
||||
|
||||
export function fighterModelPoint(fighter) {
|
||||
const model = getFighterModel(fighter);
|
||||
|
||||
return {
|
||||
x: model?.x ?? fighter?.x ?? 0,
|
||||
y: model?.y ?? fighter?.y ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function setFighterModelPosition(fighter, x, y) {
|
||||
const model = getFighterModel(fighter);
|
||||
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.x = x;
|
||||
model.y = y;
|
||||
}
|
||||
|
||||
export function fighterModelDistanceSquared(left, right) {
|
||||
const deltaX = (left?.x ?? 0) - (right?.x ?? 0);
|
||||
const deltaY = (left?.y ?? 0) - (right?.y ?? 0);
|
||||
|
||||
return deltaX * deltaX + deltaY * deltaY;
|
||||
}
|
||||
|
||||
export function isLivingFighterModel(model) {
|
||||
return Boolean(model && model.active !== false && !model.isDead);
|
||||
}
|
||||
|
||||
export function syncFighterModelFromSprite(fighter) {
|
||||
const model = getFighterModel(fighter);
|
||||
|
||||
if (
|
||||
!model
|
||||
|| fighter?._spriteDetached
|
||||
|| !Number.isFinite(fighter?.x)
|
||||
|| !Number.isFinite(fighter?.y)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.x = fighter.x;
|
||||
model.y = fighter.y;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+137
-86
@@ -1,37 +1,50 @@
|
||||
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";
|
||||
|
||||
const NAME_MULTIPLIER_REGEX = /\*(\d+)$/;
|
||||
|
||||
export function createMatchSetup(
|
||||
names,
|
||||
requestedTeamSize = DEFAULT_TEAM_SIZE,
|
||||
requestedSpawnPlacement = DEFAULT_SPAWN_PLACEMENT,
|
||||
requestedSpawnPlacement = SPAWN.DEFAULT_PLACEMENT,
|
||||
) {
|
||||
const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE));
|
||||
const teams = names.map((name, index) => ({
|
||||
color: getTeamColor(index, names.length),
|
||||
id: `team-${index + 1}`,
|
||||
label: name,
|
||||
size: teamSize,
|
||||
}));
|
||||
const teams = names.map((rawName, index) => {
|
||||
const match = rawName.match(NAME_MULTIPLIER_REGEX);
|
||||
const multiplier = match ? Math.max(1, parseInt(match[1], 10)) : 1;
|
||||
const label = match ? rawName.replace(NAME_MULTIPLIER_REGEX, "") : rawName;
|
||||
|
||||
const spawns = createSpawnPoints(names.length, teamSize, requestedSpawnPlacement);
|
||||
return {
|
||||
color: TEAM.getColor(index, names.length),
|
||||
id: `team-${index + 1}`,
|
||||
label,
|
||||
multiplier,
|
||||
size: multiplier,
|
||||
startingZoneCount: Math.ceil(multiplier / SPAWN.FIGHTERS_PER_STARTING_ZONE),
|
||||
};
|
||||
});
|
||||
|
||||
const totalFighters = teams.reduce((sum, team) => sum + team.size, 0);
|
||||
|
||||
if (totalFighters > SPAWN.MAX_FIGHTER_COUNT) {
|
||||
throw new FighterCountLimitError(totalFighters);
|
||||
}
|
||||
|
||||
const startingZones =
|
||||
requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES
|
||||
? createStartingZones(teams)
|
||||
: [];
|
||||
const spawns = createSpawnPoints(
|
||||
totalFighters,
|
||||
requestedSpawnPlacement,
|
||||
startingZones,
|
||||
);
|
||||
|
||||
const fighters = [];
|
||||
names.forEach((name, teamIndex) => {
|
||||
for (let i = 0; i < teamSize; i++) {
|
||||
const globalIndex = teamIndex * teamSize + i;
|
||||
teams.forEach((team) => {
|
||||
for (let i = 0; i < team.size; i++) {
|
||||
const globalIndex = fighters.length;
|
||||
fighters.push({
|
||||
...spawns[globalIndex],
|
||||
name: name,
|
||||
team: teams[teamIndex],
|
||||
name: team.label,
|
||||
team: team,
|
||||
teamIndex: i,
|
||||
});
|
||||
}
|
||||
@@ -39,74 +52,125 @@ export function createMatchSetup(
|
||||
|
||||
return {
|
||||
fighters,
|
||||
startingZones,
|
||||
teams,
|
||||
};
|
||||
}
|
||||
|
||||
export class FighterCountLimitError extends Error {
|
||||
constructor(fighterCount) {
|
||||
super(`Requested fighter count exceeds the ${SPAWN.MAX_FIGHTER_COUNT} limit.`);
|
||||
this.fighterCount = fighterCount;
|
||||
this.maxFighterCount = SPAWN.MAX_FIGHTER_COUNT;
|
||||
}
|
||||
}
|
||||
|
||||
export function matchStatusText(teams) {
|
||||
const totalFighters = teams.reduce((sum, team) => sum + team.size, 0);
|
||||
const teamSizes = new Set(teams.map((team) => team.size));
|
||||
const teamSizeText = teamSizes.size === 1 ? `팀당 ${teams[0]?.size ?? 0}명` : "팀별 가변 인원";
|
||||
const labels = teams.map((team) => `${team.label} ${team.size}명`).join(" / ");
|
||||
|
||||
return `${teams.length}팀 | ${teamSizeText} | 총 ${totalFighters}명 출전 | ${labels}`;
|
||||
return `${teams.length}팀 | 총 ${totalFighters}명 출전 | ${labels}`;
|
||||
}
|
||||
|
||||
function createTeams(playerCount, teamSize) {
|
||||
const teamCount = Math.ceil(playerCount / teamSize);
|
||||
|
||||
return Array.from({ length: teamCount }, (_, index) => ({
|
||||
color: getTeamColor(index, teamCount),
|
||||
id: `team-${index + 1}`,
|
||||
label: `Team ${index + 1}`,
|
||||
size: Math.min(teamSize, playerCount - index * teamSize),
|
||||
}));
|
||||
function createSpawnPoints(totalCount, requestedSpawnPlacement, startingZones) {
|
||||
if (requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES) {
|
||||
return createStartingZoneSpawnPoints(startingZones);
|
||||
}
|
||||
|
||||
function createSpawnPoints(teamCount, teamSize, requestedSpawnPlacement) {
|
||||
if (requestedSpawnPlacement === SPAWN_PLACEMENTS.STARTING_ZONES) {
|
||||
return createStartingZoneSpawnPoints(teamCount, teamSize);
|
||||
}
|
||||
|
||||
return createRandomSpawnPoints(teamCount * teamSize);
|
||||
return createRandomSpawnPoints(totalCount);
|
||||
}
|
||||
|
||||
function createRandomSpawnPoints(count) {
|
||||
return createSpawnPointsFromSlots(createSpawnSlots(), count);
|
||||
}
|
||||
|
||||
function createStartingZoneSpawnPoints(teamCount, teamSize) {
|
||||
function createStartingZoneSpawnPoints(startingZones) {
|
||||
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);
|
||||
return createSpawnPointsFromSlots(
|
||||
zoneSlots.length > 0 ? zoneSlots : fallbackSlots,
|
||||
zone.spawnCount,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function createStartingZones(teams) {
|
||||
const totalZonesNeeded = teams.reduce((sum, team) => sum + team.startingZoneCount, 0);
|
||||
const layout = shuffle(createStartingZoneLayout(totalZonesNeeded));
|
||||
|
||||
let layoutIndex = 0;
|
||||
return teams.flatMap((team) => {
|
||||
let remainingFighters = team.size;
|
||||
const teamZones = [];
|
||||
|
||||
for (let i = 0; i < team.startingZoneCount; i++) {
|
||||
const spawnCount = Math.min(SPAWN.FIGHTERS_PER_STARTING_ZONE, remainingFighters);
|
||||
teamZones.push({
|
||||
...layout[layoutIndex++],
|
||||
color: team.color,
|
||||
spawnCount,
|
||||
teamId: team.id,
|
||||
});
|
||||
remainingFighters -= spawnCount;
|
||||
}
|
||||
|
||||
return teamZones;
|
||||
});
|
||||
}
|
||||
|
||||
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 +178,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 +198,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,34 +207,21 @@ 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 resolveTeamSize(playerCount, requestedTeamSize) {
|
||||
const teamSize = clamp(
|
||||
Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE),
|
||||
1,
|
||||
MAX_TEAM_SIZE,
|
||||
function startingZonesOverlap(left, right) {
|
||||
return (
|
||||
left.columnStart < right.columnEnd &&
|
||||
left.columnEnd > right.columnStart &&
|
||||
left.rowStart < right.rowEnd &&
|
||||
left.rowEnd > right.rowStart
|
||||
);
|
||||
|
||||
if (playerCount <= teamSize) {
|
||||
return Math.max(1, Math.ceil(playerCount / 2));
|
||||
}
|
||||
|
||||
return teamSize;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
+15
-8
@@ -1,9 +1,8 @@
|
||||
import Phaser from "phaser";
|
||||
import { ArenaScene } from "./game/arena/ArenaScene.js";
|
||||
import {
|
||||
ARENA_SIZE,
|
||||
PRESENTATION_TEAM_COUNT,
|
||||
PRESENTATION_TEAM_SIZE,
|
||||
RENDER,
|
||||
SPAWN,
|
||||
} from "./constants.js";
|
||||
import { createMatchForm } from "./ui/matchForm.js";
|
||||
import { createAboutDialog } from "./ui/aboutDialog.js";
|
||||
@@ -57,6 +56,10 @@ function startConfiguredMatch(matchConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!arenaScene.startMatch(matchConfig)) {
|
||||
return;
|
||||
}
|
||||
|
||||
appNode?.classList.remove("match-ended");
|
||||
appNode?.classList.add("match-live");
|
||||
|
||||
@@ -66,14 +69,15 @@ function startConfiguredMatch(matchConfig) {
|
||||
openOptionsDrawer({ focus: false });
|
||||
}
|
||||
|
||||
arenaScene.startMatch(matchConfig);
|
||||
syncPauseButton();
|
||||
}
|
||||
|
||||
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}*${SPAWN.PRESENTATION_TEAM_SIZE}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,15 +174,18 @@ window.addEventListener("keydown", (event) => {
|
||||
const arenaScene = new ArenaScene({
|
||||
getInitialMatchConfig: getPresentationMatchConfig,
|
||||
onMatchEnd: handleMatchEnd,
|
||||
setPlayerNamesWarning: matchForm.setPlayerNamesWarning,
|
||||
setStatus: matchForm.setStatus,
|
||||
});
|
||||
|
||||
const game = new Phaser.Game({
|
||||
type: Phaser.AUTO,
|
||||
parent: "game",
|
||||
width: ARENA_SIZE,
|
||||
height: ARENA_SIZE,
|
||||
width: RENDER.WIDTH,
|
||||
height: RENDER.HEIGHT,
|
||||
autoRound: true,
|
||||
pixelArt: true,
|
||||
powerPreference: "high-performance",
|
||||
backgroundColor: "#282819",
|
||||
physics: {
|
||||
default: "arcade",
|
||||
|
||||
+6
-1945
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,603 @@
|
||||
.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: auto;
|
||||
max-width: min(640px, calc(100vw - 40px));
|
||||
min-height: 38px;
|
||||
border: 1px solid rgb(238 185 73 / 0.26);
|
||||
border-radius: 8px;
|
||||
padding: 8px 18px;
|
||||
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;
|
||||
white-space: nowrap;
|
||||
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,303 @@
|
||||
@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 .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,618 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
textarea[aria-invalid="true"] {
|
||||
border-color: #d9a628;
|
||||
box-shadow: 0 0 0 1px rgb(217 166 40 / 0.34);
|
||||
}
|
||||
|
||||
.player-names-warning {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 4px 9px;
|
||||
margin: 0;
|
||||
border: 1px solid rgb(219 168 45 / 0.72);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: linear-gradient(135deg, rgb(88 67 17 / 0.7), rgb(43 37 19 / 0.92));
|
||||
box-shadow: inset 3px 0 0 #dba82d;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.player-names-warning[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.player-names-warning__badge {
|
||||
grid-row: span 2;
|
||||
align-self: start;
|
||||
color: #f4bd37;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.player-names-warning__title {
|
||||
color: #ffd565;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.player-names-warning__detail {
|
||||
grid-column: 2;
|
||||
margin: 0;
|
||||
color: #f1dfaa;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.player-names-warning__count {
|
||||
color: #ffd04f;
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.player-names-warning__limit {
|
||||
color: #f7e0a0;
|
||||
font-size: 0.94rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.player-names-warning__reason {
|
||||
grid-column: 1 / -1;
|
||||
margin: 5px 0 0;
|
||||
border-top: 1px solid rgb(219 168 45 / 0.25);
|
||||
padding-top: 7px;
|
||||
color: #d7c489;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
const KILL_LOG_LIMIT = 8;
|
||||
const KILL_LOG_LIMIT = 30;
|
||||
|
||||
export function resetKillLog(nodes) {
|
||||
const { logNode, listNode } = nodes;
|
||||
@@ -45,10 +45,10 @@ export function appendKillLog(nodes, winner, defender) {
|
||||
action,
|
||||
createKillLogFighterNode(victim, "victim"),
|
||||
);
|
||||
listNode.append(item);
|
||||
listNode.prepend(item);
|
||||
|
||||
while (listNode.children.length > KILL_LOG_LIMIT) {
|
||||
listNode.firstElementChild?.remove();
|
||||
listNode.lastElementChild?.remove();
|
||||
}
|
||||
|
||||
logNode.classList.add("has-entries");
|
||||
|
||||
+35
-17
@@ -9,41 +9,59 @@ export function updateScoreboard(
|
||||
return;
|
||||
}
|
||||
|
||||
containerLeft.innerHTML = "";
|
||||
containerRight.innerHTML = "";
|
||||
const currentTeamElements = [...containerLeft.children];
|
||||
const teamsChanged =
|
||||
currentTeamElements.length !== teams.length ||
|
||||
teams.some((team, index) => currentTeamElements[index]?.dataset.teamId !== String(team.id));
|
||||
|
||||
teams.forEach((team) => {
|
||||
const aliveCount = fighters.filter((f) => f.team.id === team.id && !f.isDead).length;
|
||||
if (teamsChanged) {
|
||||
containerLeft.replaceChildren(...teams.map((team) => createTeamElement(team.id)));
|
||||
}
|
||||
|
||||
if (containerRight.childElementCount > 0) {
|
||||
containerRight.replaceChildren();
|
||||
}
|
||||
|
||||
teams.forEach((team, index) => {
|
||||
const teamEl = containerLeft.children[index];
|
||||
const aliveCount = fighters.filter(
|
||||
(fighter) => fighter.team.id === team.id && !fighter.isDead,
|
||||
).length;
|
||||
|
||||
const teamEl = document.createElement("button");
|
||||
teamEl.className = "team-score";
|
||||
teamEl.type = "button";
|
||||
teamEl.disabled = aliveCount === 0;
|
||||
teamEl.setAttribute("aria-label", `${team.label} 생존 캐릭터 무작위 시점 고정`);
|
||||
teamEl.style.setProperty("--team-color", team.color);
|
||||
teamEl.style.backgroundColor = `${team.color}33`;
|
||||
teamEl.style.borderLeft = `4px solid ${team.color}`;
|
||||
teamEl.classList.toggle("is-focused", selectedFighterTeamId === team.id);
|
||||
|
||||
if (selectedFighterTeamId === team.id) {
|
||||
teamEl.classList.add("is-focused");
|
||||
const labelEl = teamEl.querySelector(".team-score-name");
|
||||
labelEl.textContent = team.label;
|
||||
|
||||
const countEl = teamEl.querySelector(".team-score-count");
|
||||
countEl.textContent = `${aliveCount}명`;
|
||||
|
||||
teamEl.onclick = () => {
|
||||
onTeamClick(team.id);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createTeamElement(teamId) {
|
||||
const teamEl = document.createElement("button");
|
||||
teamEl.className = "team-score";
|
||||
teamEl.type = "button";
|
||||
teamEl.dataset.teamId = String(teamId);
|
||||
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.className = "team-score-name";
|
||||
labelEl.textContent = team.label;
|
||||
|
||||
const ruleEl = document.createElement("span");
|
||||
ruleEl.className = "team-score-rule";
|
||||
|
||||
const countEl = document.createElement("span");
|
||||
countEl.className = "team-score-count";
|
||||
countEl.textContent = `${aliveCount}명`;
|
||||
|
||||
teamEl.addEventListener("click", () => {
|
||||
onTeamClick(team.id);
|
||||
});
|
||||
|
||||
teamEl.append(labelEl, ruleEl, countEl);
|
||||
containerLeft.appendChild(teamEl);
|
||||
});
|
||||
return teamEl;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { UI } from "../constants.js";
|
||||
|
||||
const SPECIES_KEYS = ["human", "orc", "skeleton", "slime", "wolf", "bear"];
|
||||
const SPECIES_LABELS = {
|
||||
bear: "곰",
|
||||
@@ -22,9 +24,12 @@ const DEATH_NOTICE_TEMPLATES = [
|
||||
"{species}{particle} 전투 중 {count}명 쓰러졌습니다. 관중석은 침착한 척하는 중입니다.",
|
||||
];
|
||||
|
||||
export const BATTLE_NOTICE_DELAY_MS = 5000;
|
||||
export const BATTLE_NOTICE_VISIBLE_MS = 2000;
|
||||
export const BATTLE_NOTICE_INTERVAL_MS = 10000;
|
||||
const SYSTEM_TIP_TEMPLATES = [
|
||||
"경보: 화염 메테오는 낙하 지점 5x5 영역에 강력한 폭발 피해를 입힙니다!",
|
||||
"주의: 냉기 메테오는 피해와 함께 2초간 동결 및 냉각을 유발합니다.",
|
||||
"팁: 근접 캐릭터는 20% 확률로 치명타를 터뜨려 적을 즉사시킵니다.",
|
||||
"성장: 적 처치 시 체력을 30% 회복하며, 크기와 속도가 최대 5배까지 커집니다.",
|
||||
];
|
||||
|
||||
export function createDeathCounts() {
|
||||
return SPECIES_KEYS.reduce((counts, species) => {
|
||||
@@ -42,7 +47,8 @@ export function normalizeDeathCounts(value = {}) {
|
||||
|
||||
export function addDeathCounts(baseCounts, matchCounts) {
|
||||
return SPECIES_KEYS.reduce((counts, species) => {
|
||||
counts[species] = (baseCounts?.[species] ?? 0) + (matchCounts?.[species] ?? 0);
|
||||
counts[species] =
|
||||
(baseCounts?.[species] ?? 0) + (matchCounts?.[species] ?? 0);
|
||||
return counts;
|
||||
}, {});
|
||||
}
|
||||
@@ -52,15 +58,24 @@ export function normalizeSpecies(value) {
|
||||
}
|
||||
|
||||
export function createDeathNoticeMessage(deathsBySpecies, seed = 0) {
|
||||
const topSpecies = SPECIES_KEYS
|
||||
.map((species) => ({ species, count: deathsBySpecies?.[species] ?? 0 }))
|
||||
.sort((left, right) => right.count - left.count)[0];
|
||||
// 3번에 한 번꼴로 시스템 팁 출력
|
||||
if (seed % 3 === 0) {
|
||||
return SYSTEM_TIP_TEMPLATES[
|
||||
Math.floor(seed / 3) % SYSTEM_TIP_TEMPLATES.length
|
||||
];
|
||||
}
|
||||
|
||||
const topSpecies = SPECIES_KEYS.map((species) => ({
|
||||
species,
|
||||
count: deathsBySpecies?.[species] ?? 0,
|
||||
})).sort((left, right) => right.count - left.count)[0];
|
||||
|
||||
if (!topSpecies || topSpecies.count === 0) {
|
||||
return "오늘 사망자 집계는 아직 0명입니다. 이 평화가 얼마나 버틸까요?";
|
||||
}
|
||||
|
||||
const template = DEATH_NOTICE_TEMPLATES[
|
||||
const template =
|
||||
DEATH_NOTICE_TEMPLATES[
|
||||
(topSpecies.count + seed) % DEATH_NOTICE_TEMPLATES.length
|
||||
];
|
||||
|
||||
|
||||
+33
-71
@@ -1,52 +1,53 @@
|
||||
import { DEFAULT_SPAWN_PLACEMENT, NICKNAME_LENGTH } from "../constants.js";
|
||||
import { FIGHTER, SPAWN } from "../constants.js";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
names: "arena.match.playerNames",
|
||||
spawnPlacement: "arena.match.spawnPlacement",
|
||||
teamSize: "arena.match.teamSize",
|
||||
};
|
||||
|
||||
export function createMatchForm() {
|
||||
const form = getElement("#fighter-form");
|
||||
const namesInput = getElement("#player-names");
|
||||
const namesWarningNode = getElement("#player-names-warning");
|
||||
const namesWarningTitleNode = getElement("[data-player-names-warning-title]");
|
||||
const namesWarningCountNode = getElement("[data-player-names-warning-count]");
|
||||
const namesWarningLimitNode = getElement("[data-player-names-warning-limit]");
|
||||
const namesWarningReasonNode = getElement("[data-player-names-warning-reason]");
|
||||
const appNode = document.querySelector("#app");
|
||||
const statusNode = document.querySelector("#match-status");
|
||||
const statusTextNodes = document.querySelectorAll("[data-status-text]");
|
||||
const spawnPlacementInputs = getElements('input[name="spawnPlacement"]');
|
||||
const teamSizeInput = getElement("#team-size");
|
||||
const teamSizeNumberInput = getElement("#team-size-value");
|
||||
const setPlayerNamesWarning = (warning = null) => {
|
||||
const hasWarning = Boolean(warning);
|
||||
|
||||
if (!hasWarning) {
|
||||
namesWarningNode.hidden = true;
|
||||
namesInput.removeAttribute("aria-invalid");
|
||||
return;
|
||||
}
|
||||
|
||||
namesWarningTitleNode.textContent = warning.title;
|
||||
namesWarningCountNode.textContent = warning.fighterCount.toLocaleString("ko-KR");
|
||||
namesWarningLimitNode.textContent = warning.maxFighterCount.toLocaleString("ko-KR");
|
||||
namesWarningReasonNode.textContent = warning.reason ?? "";
|
||||
namesWarningReasonNode.hidden = !warning.reason;
|
||||
namesWarningNode.hidden = false;
|
||||
namesInput.setAttribute("aria-invalid", "true");
|
||||
};
|
||||
|
||||
const readMatchConfig = () => ({
|
||||
names: nicknameValues(namesInput.value),
|
||||
spawnPlacement: selectedSpawnPlacement(spawnPlacementInputs),
|
||||
teamSize: Number(teamSizeInput.value),
|
||||
});
|
||||
|
||||
restoreSavedMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput, teamSizeNumberInput);
|
||||
syncTeamSizeInputs(teamSizeInput, teamSizeNumberInput);
|
||||
restoreSavedMatchSettings(namesInput, spawnPlacementInputs);
|
||||
namesInput.addEventListener("input", () => {
|
||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
||||
});
|
||||
teamSizeInput.addEventListener("input", () => {
|
||||
syncTeamSizeInputs(teamSizeInput, teamSizeNumberInput);
|
||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
||||
});
|
||||
teamSizeNumberInput.addEventListener("input", () => {
|
||||
if (syncTeamSizeInputs(teamSizeInput, teamSizeNumberInput, teamSizeNumberInput.value)) {
|
||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
||||
}
|
||||
});
|
||||
teamSizeNumberInput.addEventListener("change", () => {
|
||||
syncTeamSizeInputs(
|
||||
teamSizeInput,
|
||||
teamSizeNumberInput,
|
||||
teamSizeNumberInput.value || teamSizeInput.value,
|
||||
);
|
||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
||||
setPlayerNamesWarning();
|
||||
saveMatchSettings(namesInput, spawnPlacementInputs);
|
||||
});
|
||||
spawnPlacementInputs.forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
||||
saveMatchSettings(namesInput, spawnPlacementInputs);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +59,7 @@ export function createMatchForm() {
|
||||
});
|
||||
},
|
||||
readMatchConfig,
|
||||
setPlayerNamesWarning,
|
||||
setStatus(message) {
|
||||
if (statusNode) {
|
||||
statusNode.setAttribute("aria-hidden", "false");
|
||||
@@ -96,29 +98,11 @@ 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);
|
||||
}
|
||||
|
||||
function syncTeamSizeInputs(rangeInput, numberInput, value = rangeInput.value) {
|
||||
const normalizedTeamSize = normalizeTeamSize(value, rangeInput);
|
||||
|
||||
if (!normalizedTeamSize) {
|
||||
return "";
|
||||
}
|
||||
|
||||
rangeInput.value = normalizedTeamSize;
|
||||
numberInput.value = normalizedTeamSize;
|
||||
|
||||
return normalizedTeamSize;
|
||||
}
|
||||
|
||||
function restoreSavedMatchSettings(
|
||||
namesInput,
|
||||
spawnPlacementInputs,
|
||||
teamSizeInput,
|
||||
teamSizeNumberInput,
|
||||
) {
|
||||
function restoreSavedMatchSettings(namesInput, spawnPlacementInputs) {
|
||||
const storage = getLocalStorage();
|
||||
|
||||
if (!storage) {
|
||||
@@ -128,27 +112,18 @@ function restoreSavedMatchSettings(
|
||||
try {
|
||||
const savedNames = storage.getItem(STORAGE_KEYS.names);
|
||||
const savedSpawnPlacement = storage.getItem(STORAGE_KEYS.spawnPlacement);
|
||||
const savedTeamSize = storage.getItem(STORAGE_KEYS.teamSize);
|
||||
|
||||
if (savedNames !== null) {
|
||||
namesInput.value = savedNames;
|
||||
}
|
||||
|
||||
const normalizedTeamSize = normalizeTeamSize(savedTeamSize, teamSizeInput);
|
||||
|
||||
syncTeamSizeInputs(
|
||||
teamSizeInput,
|
||||
teamSizeNumberInput,
|
||||
normalizedTeamSize || teamSizeInput.value,
|
||||
);
|
||||
|
||||
setSpawnPlacement(spawnPlacementInputs, savedSpawnPlacement);
|
||||
} catch {
|
||||
// Storage may be unavailable in private or restricted browser contexts.
|
||||
}
|
||||
}
|
||||
|
||||
function saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput) {
|
||||
function saveMatchSettings(namesInput, spawnPlacementInputs) {
|
||||
const storage = getLocalStorage();
|
||||
|
||||
if (!storage) {
|
||||
@@ -158,19 +133,18 @@ function saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput) {
|
||||
try {
|
||||
storage.setItem(STORAGE_KEYS.names, namesInput.value);
|
||||
storage.setItem(STORAGE_KEYS.spawnPlacement, selectedSpawnPlacement(spawnPlacementInputs));
|
||||
storage.setItem(STORAGE_KEYS.teamSize, normalizeTeamSize(teamSizeInput.value, teamSizeInput));
|
||||
} catch {
|
||||
// Ignore storage failures so the match form remains usable.
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -178,18 +152,6 @@ function setSpawnPlacement(inputs, value) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTeamSize(value, input) {
|
||||
const min = Number(input.min) || 1;
|
||||
const max = Number(input.max) || min;
|
||||
const teamSize = Math.round(Number(value));
|
||||
|
||||
if (!Number.isFinite(teamSize)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return String(Math.min(max, Math.max(min, teamSize)));
|
||||
}
|
||||
|
||||
function getLocalStorage() {
|
||||
try {
|
||||
return window.localStorage;
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
|
||||
9. 전투 진입 UI, 좌측 HUD badge, 좌측 하단 킬로그 개선 (완료)
|
||||
- **조치 사항**:
|
||||
- 최초 접속 화면에 투명 전투 프리뷰, `Arena` 로고, `Start` 버튼을 배치.
|
||||
- `Start` 클릭 시 우측 옵션 drawer가 열리고 홈 drawer 상태에서는 `Arena` 로고 위치를 유지한 채 `Start` 버튼을 숨기며, 전투 시작 시 실제 경기 화면으로 전환.
|
||||
- 최초 접속 화면에 투명 전투 프리뷰, `ARENA` / `PICKER` 2단 로고, `Start` 버튼을 배치.
|
||||
- `Start` 클릭 시 우측 옵션 drawer가 열리고 홈 drawer 상태에서는 `ARENA` / `PICKER` 로고 위치를 유지한 채 `Start` 버튼을 숨기며, 전투 시작 시 실제 경기 화면으로 전환.
|
||||
- 팀 badge를 상단 좌/우 분할에서 경기장 밖 좌측 HUD 레일로 이동.
|
||||
- badge를 팀명, 팀 색상 구분선, 생존 인원 형식으로 표기.
|
||||
- 좌측 HUD 레일 폭과 경기장 시작 위치를 분리 계산해 badge가 미니맵과 경기장 캔버스를 가리지 않도록 조정.
|
||||
@@ -109,12 +109,12 @@
|
||||
18. 치명타 적중 표기 추가 (완료)
|
||||
- **조치 사항**:
|
||||
- 공격 프로필의 치명타 판정을 실제 적중 처리까지 전달해 전투 타입별 적중 연출이 같은 흐름을 사용하도록 정리.
|
||||
- 치명타 적중 시 대상 위에 `Critical!` 문구를 띄우고 즉시 처치와 카메라 흔들림이 함께 적용되도록 `applyHit()`를 보강.
|
||||
- 치명타 적중 시 대상 위에 `Critical!` 문구를 띄우고 즉시 처치가 적용되도록 `applyHit()`를 보강. (카메라 흔들림은 이후 메테오 착탄 연출로 이전)
|
||||
|
||||
19. 리스폰 배치 설정 구분 추가 (완료)
|
||||
- **조치 사항**:
|
||||
- 전투 설정 drawer에 `스타팅 지점 배치`와 기존 `완전 랜덤 배치`를 선택하는 리스폰 설정을 추가.
|
||||
- `스타팅 지점 배치`에서는 참가자 수에 맞춰 전장 구역을 나누고 참가자별 시작 구역 배정과 구역 안 스폰 위치를 매치마다 무작위로 정하도록 구현.
|
||||
- `스타팅 지점 배치`에서는 참가자별 스타팅 영역과 영역 안 스폰 위치를 매치마다 무작위로 정하도록 구현했으며, 이후 30번 작업에서 영역 선택을 랜덤 중심 셀 기반 `5 x 5` 방식으로 구체화.
|
||||
- 선택한 리스폰 배치 모드를 `localStorage`에 저장해 새로고침과 재시작 이후에도 유지.
|
||||
|
||||
20. 팀당 인원 직접 입력 동기화 (완료)
|
||||
@@ -178,4 +178,287 @@
|
||||
- 유저가 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) 섹션에 스타일 및 디자인 항목을 추가하여 문서 접근성 개선.
|
||||
|
||||
42. 상단 공지(Battle Notice) 콘텐츠 확장 (완료)
|
||||
- **조치 사항**:
|
||||
- 사망 통계만 보여주던 공지 UI에 게임 시스템 가이드(화염/냉기 메테오 특성, 밀리 치명타 확률 등) 팁을 추가.
|
||||
- 사망 통계 공지 2회당 1회의 비율로 시스템 팁이 교차 출력되도록 로직 개선.
|
||||
|
||||
43. 구매 배수 기반 월드 이펙트 독주 표적 가중치 추가 (완료)
|
||||
- **조치 사항**:
|
||||
- `닉네임*N`으로 구매한 추가 병력은 수량과 전투 수치를 낮추지 않고 그대로 유지.
|
||||
- 생존 팀별 구매 배수 지분과 현재 생존 지분을 비교해, 구매 지분을 초과해 살아남은 팀에만 월드 이펙트 표적 가중치를 추가.
|
||||
- `WORLD_EFFECT.DOMINANCE_TARGETING_MULTIPLIER`를 추가하고 기본값을 `1`로 설정하여 초과 생존 지분 기반 압력을 활성화하며, `0`으로 설정하면 기존 생존 유닛 비례 표적 선택으로 복귀하도록 구성.
|
||||
|
||||
|
||||
44. Team marker duplicated sprite removal and team shadow baking (completed)
|
||||
- **Changes**:
|
||||
- Removed the duplicated per-fighter `teamMarker` Phaser sprite and its frame/position/depth synchronization path.
|
||||
- Added lazy team-colored spritesheet and animation generation in `fighterAssets.js` by recoloring floor shadow pixels (`#534545`) to the fighter team color.
|
||||
- Updated fighter creation and combat animation playback to use team-shadow animation keys for idle, walk, attack, hurt, and death actions.
|
||||
- Kept frost stun as a body `setTint(WORLD_EFFECT.FROST_STUN_TINT)` effect, with no team-marker tint state to restore.
|
||||
- Verified production build with `npm run build`.
|
||||
|
||||
45. Dead fighter battlefield despawn (completed)
|
||||
- **Changes**:
|
||||
- Added `FIGHTER.DEAD_DESPAWN_DELAY_MS` in `src/constants.js` so corpse lifetime is easy to tune.
|
||||
- Updated `combat.js` to keep a dead fighter at initial opacity, fade it toward `FIGHTER.DEAD_DESPAWN_ALPHA`, then remove it from `scene.fighters` and destroy the sprite after the configured delay.
|
||||
- Kept death bookkeeping, kill rewards, split-on-death, and winner checks ahead of the despawn schedule.
|
||||
|
||||
46. Variable meteor impact and visual scale (completed)
|
||||
- **Changes**:
|
||||
- Added `WORLD_EFFECT.SIZE_SCALE_VARIANCE` so each fire/frost meteor drop can pick a different size multiplier.
|
||||
- Updated `worldEffects.js` to apply the same per-drop multiplier to both the damage/frost zone bounds and the falling/impact sprite scale.
|
||||
- Added `WORLD_EFFECT.METEOR_SHAKE_DURATION_MS` and `WORLD_EFFECT.METEOR_SHAKE_INTENSITY`, then scaled fire/frost meteor camera shake from the meteor size multiplier.
|
||||
47. Large-battle combat effect focus gating (completed)
|
||||
- Suppressed critical labels, instant-spell sprites, kill-heal sprites, and kill-growth tweens during large battles outside meteor camera focus.
|
||||
- Kept combat outcomes, reward values, meteor/frost visuals, and projectile hit-detection objects unchanged.
|
||||
48. Direct fighter count input and generated population cap (completed)
|
||||
- Replaced the team-size control with `nickname*N` direct assigned-fighter input semantics and preserved the preview size with internal suffixed entries.
|
||||
- Added `SPAWN.MAX_FIGHTER_COUNT = 8000` validation for participant-assigned fighter slots before match replacement; Slime trait-generated spawns and splits remain outside that input cap.
|
||||
- Distributed starting-zone teams through `SPAWN.FIGHTERS_PER_STARTING_ZONE = 100`, assigning any remainder to the final zone.
|
||||
49. Inline fighter-count limit warning (completed)
|
||||
- Displayed assigned-count cap violations beneath the participant nickname textarea.
|
||||
- Cleared the warning when participant input changes or a valid live match is submitted.
|
||||
50. Dense-area multi-meteor barrage targeting (completed)
|
||||
- Replaced random living-fighter world-effect targeting with a summed-area tile scan that selects the most populated `WORLD_EFFECT.AREA_TILES` warning region.
|
||||
- Changed each fire/frost activation into a configurable number of smaller strikes inside the warning region, with damage, stun, and lingering frost applied only per impact zone.
|
||||
- Added `WORLD_EFFECT.IMPACT_*` tuning values and retired purchase-share dominance weighting in favor of direct crowd-density pressure.
|
||||
51. Initial and repeating barrage interval split (completed)
|
||||
- Kept `WORLD_EFFECT.INTERVAL` as the delay from match start to the first barrage and added `WORLD_EFFECT.REPEAT_INTERVAL` for subsequent normal barrages.
|
||||
- Preserved `WORLD_EFFECT.SUDDEN_DEATH.INTERVAL_MS` as the repeat delay once sudden death becomes active.
|
||||
52. Configurable barrage warning duration (completed)
|
||||
- Added `WORLD_EFFECT.WARNING_DURATION_MS` to control the visible lifetime of the large dense-area warning marker.
|
||||
- Kept scheduled small impacts and meteor camera focus running after the warning marker hides.
|
||||
53. Phaser 3 online optimization review and low-risk performance pass (완료)
|
||||
- **조치 사항**:
|
||||
- Phaser 공식 문서/뉴스에서 object allocation, Group pooling, Blitter, camera ignore, Arcade Collider, render config 관련 최적화 기법을 확인.
|
||||
- 투사체별 Arcade overlap collider 생성은 제거하고 기존 궤적 기반 판정만 유지.
|
||||
- 투사체 판정용 geometry와 전투 타깃 spatial grid의 프레임별 할당을 줄임.
|
||||
- 미니맵 redraw를 `PERFORMANCE.MINIMAP_REFRESH_MS`로 제한하고 Phaser 렌더 설정에 `autoRound`, `powerPreference`를 추가.
|
||||
- Blitter 전환, Canvas 강제 전환, 광범위한 Group pooling은 현재 구조와 리스크 대비 보류.
|
||||
54. Render resolution split for large-battle baseline performance (완료)
|
||||
- **조치 사항**:
|
||||
- Phaser 내부 canvas 해상도를 `ARENA.SIZE` 3200x3200에서 `RENDER` 1280x1280으로 분리.
|
||||
- 전투 로직/월드 bounds는 기존 3200x3200 arena를 유지.
|
||||
- `CAMERA.MIN_ZOOM`을 render/arena 비율로 낮춰 기본 전장 전체 시야를 유지.
|
||||
- 미니맵 HUD 크기와 stroke도 render size 기준으로 조정.
|
||||
55. Large-battle fighter render LOD (completed)
|
||||
- **Changes**:
|
||||
- Added a large-battle render LOD pass that caps detailed visible fighter sprites through `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT`.
|
||||
- Rendered hidden living fighters as team-colored dots on a shared `Graphics` layer while keeping them active for combat simulation.
|
||||
- Used per-team representatives at full-arena `CAMERA.MIN_ZOOM` and camera-near detail selection for zoomed/selected views.
|
||||
- Released HUD slots and pointer interaction for hidden fighters, and invalidated LOD when split-on-death children spawn.
|
||||
- Verified production build with `npm run build`.
|
||||
56. Dynamic zoomed fighter render LOD (completed)
|
||||
- **Changes**:
|
||||
- Changed `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT` into the full-arena/base budget instead of a universal fixed cap.
|
||||
- Added `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_MAX` as the dynamic safety cap.
|
||||
- Counted camera-near living fighters during zoomed/selected views and raised the detailed sprite budget dynamically up to the safety cap.
|
||||
- Prioritized exact viewport candidates before rolling-window candidates so dots do not appear inside the current view unless the rolling-window count exceeds the dynamic cap.
|
||||
57. Scoreboard team button toggle to full arena (completed)
|
||||
- **Changes**:
|
||||
- Clicking the currently selected team button now clears the fighter selection instead of selecting another random same-team fighter.
|
||||
- Added `ArenaScene.returnToFullArenaView()` to restore `CAMERA.MIN_ZOOM`, center the arena, clear combat focus state, refresh the minimap, and update the scoreboard.
|
||||
58. Large-battle start camera avoids full-arena overview (completed)
|
||||
- **Changes**:
|
||||
- Added `CAMERA.LARGE_BATTLE_START_ZOOM`.
|
||||
- Live matches at or above `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` now start zoomed in instead of staying on `CAMERA.MIN_ZOOM`.
|
||||
- Centered the initial large-battle camera on the living fighter closest to the overall living-fighter average without selecting that fighter.
|
||||
59. Manual camera pan/zoom transitions (completed)
|
||||
- **Changes**:
|
||||
- Added `CAMERA.MANUAL_FOCUS_TWEEN_MS` and `CAMERA.MANUAL_FOCUS_TWEEN_EASE`.
|
||||
- Added `ArenaScene.transitionMainCameraTo()` for Phaser camera `pan()` and `zoomTo()` based manual focus changes.
|
||||
- Updated fighter/team selection and selected-team full-arena return to tween instead of jumping instantly.
|
||||
- Paused selected-fighter auto-centering while the manual camera transition is active so it does not cancel the tween.
|
||||
60. Rolling-window fighter LOD for smooth camera movement (completed)
|
||||
- **Changes**:
|
||||
- Replaced exact-visible-count LOD budgeting with a camera-centered rolling window.
|
||||
- Added `PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_SCALE` and `PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_BUFFER_RATIO`.
|
||||
- Kept exact viewport fighters as the first priority, then filled the detailed set with rolling-window fighters before they enter the visible screen.
|
||||
- Shortened `PERFORMANCE.LARGE_BATTLE_LOD_REFRESH_MS` so the rolling window follows manual pan/zoom more responsively.
|
||||
61. Dormant offscreen fighter simulation (completed)
|
||||
- **Changes**:
|
||||
- Rolling-window LOD outside fighters now pause animation, disable input, release HUD, and disable Arcade bodies instead of only setting invisible.
|
||||
- Updated combat movement so disabled-body fighters keep moving through JS position math.
|
||||
- Kept visible fighters on Arcade movement for on-screen fidelity.
|
||||
- Resolved projectile attacks involving dormant fighters as delayed data hits without spawning projectile objects.
|
||||
- Updated camera/world-effect helpers to use fighter `x/y` when a body is disabled.
|
||||
62. Fighter adapter layer before model/proxy rewrite (completed)
|
||||
- **Changes**:
|
||||
- Added `src/game/fighter/fighterAdapter.js` as the first boundary between fighter simulation state and Phaser Sprite/Arcade APIs.
|
||||
- Moved fighter world-point, distance, facing, movement, body enable/disable, arena clamping, animation, and tint helpers behind the adapter.
|
||||
- Updated combat, world effects, spectator camera, match finish cleanup, and fighter detail visibility to use the adapter for fighter-specific render/body access.
|
||||
- Left the full `FighterModel + SpriteProxy` rewrite as a later larger step; this pass reduces the direct coupling first.
|
||||
63. FighterModel shell for state/render split (completed)
|
||||
- **Changes**:
|
||||
- Added `src/game/fighter/fighterModel.js` to hold combat/state fields in a pure JS model object.
|
||||
- Attached each fighter sprite to `fighter.model` and bridged existing custom sprite fields with getter/setters so current code remains compatible.
|
||||
- Synced live sprite positions into model `x/y` during combat-frame preparation and wrote dormant movement through model-aware adapter helpers.
|
||||
- Updated combat target indexing, arena rolling-window LOD, minimap dots, and split-spawn origins to use model position helpers where safe.
|
||||
- Retained Phaser sprites for every fighter at this stage; later work added rolling-window SpriteProxy detach and lazy sprite pooling.
|
||||
64. Model-based combat targeting and spatial index (completed)
|
||||
- **Changes**:
|
||||
- Added `ArenaScene.fighterModels`, `fighterByModelId`, and `fighterModelById` indexes.
|
||||
- Registered models on match start and split spawns, and unregistered despawned fighters so stale models are marked inactive.
|
||||
- Changed combat update entry to `updateFighterModel()` and iterated `scene.fighterModels` from the scene update loop.
|
||||
- Built the target spatial grid from models instead of sprites.
|
||||
- Replaced cached `targetEnemy` sprite references with `model.targetModelId` and resolved sprites only when rendering/movement/attack execution needs them.
|
||||
65. Model-only combat fallback before SpriteProxy pooling (completed)
|
||||
- **Changes**:
|
||||
- Allowed `updateFighterModel()` to keep simulating living models that do not currently have a render sprite.
|
||||
- Added model-only movement that updates `model.x/y` directly with arena clamping.
|
||||
- Added delayed model-hit resolution for melee, projectile, and instant-spell attacks when either combatant lacks a sprite.
|
||||
- Added `killFighterModel()` so model-only deaths mark models inactive, unregister indexes, record deaths/kills, apply kill rewards, and process split-on-death.
|
||||
- Kept the full visual Sprite path when both combatants still have render sprites.
|
||||
66. Rolling-window SpriteProxy detach (completed)
|
||||
- **Changes**:
|
||||
- Changed large-battle LOD so non-detailed fighter proxies are removed from Phaser's display list and update list instead of only being hidden.
|
||||
- Removed detached proxies from `fighterByModelId`, letting combat route through the model-only fallback until the rolling window reattaches the proxy.
|
||||
- Reattached detailed proxies from model state, including position/body reset, facing, input, animation resume, and kill-growth scale.
|
||||
- Removed parked detached proxies from `this.fighters` on model-only death so dead offscreen proxies do not keep participating in scan loops.
|
||||
- Kept LOD candidate selection and minimap drawing on the full model-backed proxy list so detached fighters still appear as dots and can be selected by team buttons.
|
||||
- Verified production build with `npm run build`.
|
||||
67. Lazy SpriteProxy pool for large-battle startup (rolled back)
|
||||
- **Changes**:
|
||||
- Changed `createFighter()` to create a lightweight model-backed proxy first, with optional Phaser Sprite attachment.
|
||||
- Large live matches now pass `attachSprite: false` at match start, so thousands of fighters begin as model-only proxies.
|
||||
- Added `scene.fighterSpritePool`; LOD detail promotion acquires/reconfigures a pooled sprite and LOD demotion releases it back to the pool.
|
||||
- Routed clicked Phaser sprites back to their owning proxy through `_fighterProxy`, keeping selection/team focus logic model-first.
|
||||
- Split-on-death children in active large-battle LOD now spawn model-only and wait for LOD promotion before acquiring render sprites.
|
||||
- Verified production build with `npm run build`.
|
||||
- Rolled this back after a render regression where fighter models and team counts were alive but no stable Phaser Sprite appeared on the field.
|
||||
68. Fighter sprite render recovery after lazy proxy regression (completed)
|
||||
- **Changes**:
|
||||
- Restored `createFighter()` to return a real Phaser Sprite with an attached `fighter.model` bridge.
|
||||
- Kept the `attachSprite` option, but large-battle startup now parks the created sprite through `setFighterDetailVisible(false)` instead of skipping Sprite creation.
|
||||
- Preserved rolling-window LOD's display/update-list detach and reattach behavior for non-detailed fighters.
|
||||
- Updated arena/fighter/combat context docs to mark the lazy sprite pool as disabled.
|
||||
- Verified production build with `npm run build`.
|
||||
69. Null-safe model target cache after sprite recovery (completed)
|
||||
- **Changes**:
|
||||
- Fixed `isValidEnemyTargetModel()` so cached target validation safely handles `null` attacker or candidate models before reading team ids.
|
||||
- Cleared stale `targetModelId` values in `resolveTargetEnemyModel()` before scanning for a replacement enemy.
|
||||
- Verified production build with `npm run build`.
|
||||
70. Detached fighter animation guard for large-battle model-only attacks (completed)
|
||||
- **Changes**:
|
||||
- Fixed `fighterAdapter.shouldRenderFighterDetail()` so `null` or detached fighters cannot be treated as renderable.
|
||||
- Added animation-key guards in `playFighterAction()` and `playFighterActionIfNeeded()` so model-only attacks skip sprite animation safely.
|
||||
- Verified production build with `npm run build`.
|
||||
71. Large-battle simulation throttle and tighter render budget (completed)
|
||||
- **Changes**:
|
||||
- Added large-battle simulation buckets so attached/detailed fighters update every frame while detached model-only fighters are distributed across frames.
|
||||
- Added capped accumulated delta for throttled detached model updates.
|
||||
- Reduced large-battle detailed sprite and HUD label caps to avoid 8,000-fighter zoom views promoting thousands of animated sprites.
|
||||
- Allowed rolling-window LOD buffer ratios below `1` for dense large-battle scenes.
|
||||
- Added an early return when `setFighterDetailVisible(false)` is called on an already parked fighter, reducing LOD refresh spikes.
|
||||
- Verified production build with `npm run build`.
|
||||
72. Aggressive 8k battle throttles for remaining frame drops (completed)
|
||||
- **Changes**:
|
||||
- Lowered detailed sprite caps again for dense 8,000-fighter zoom views.
|
||||
- Changed combat frame preparation to sync only attached sprites and rebuild the large-battle target spatial index on an interval instead of every frame.
|
||||
- Throttled model-index audits to once per second.
|
||||
- Skipped world-effect modifier scans when no frost zone is active, and throttled active frost-zone scans.
|
||||
- Verified production build with `npm run build`.
|
||||
73. Aggregate detached combat for large battles (completed)
|
||||
- **Changes**:
|
||||
- Added a large-battle aggregate combat path for detached/offscreen model-only fighters.
|
||||
- Detached fighters now move toward coarse enemy cells and resolve batched HP/deaths at `PERFORMANCE.LARGE_BATTLE_AGGREGATE_COMBAT_REFRESH_MS` instead of running full target/attack AI.
|
||||
- Kept attached/detail fighters on every-frame individual combat for visible camera fidelity.
|
||||
- Suppressed per-kill DOM log entries for aggregate offscreen deaths while preserving death stats, kill rewards, split-on-death, scoreboard updates, and match finish checks.
|
||||
- Verified production build with `npm run build`.
|
||||
74. Squad-based detached combat compression (completed)
|
||||
- **Changes**:
|
||||
- Compressed detached/offscreen fighters into `team + cell + 100 fighters` squads during large live battles.
|
||||
- Moved and resolved combat at the squad level, then reslotted individual models around squad centers only on aggregate ticks.
|
||||
- Changed large-battle target spatial indexing to attached/detail models so visible individual AI does not scan all offscreen models.
|
||||
- Added `PERFORMANCE.LARGE_BATTLE_AGGREGATE_SQUAD_SIZE` for tuning squad population.
|
||||
- Verified production build with `npm run build`.
|
||||
75. Magic attack effect sprite pooling (completed)
|
||||
- **Changes**:
|
||||
- Added per-texture pooling for instant-spell attack effect sprites in `combat.js`.
|
||||
- Returned pooled spell effects on animation completion and during `clearCombatObjects()` cleanup instead of destroying them.
|
||||
- Reset pooled spell sprites before reuse, including frame, position, scale, depth, alpha, rotation, flip, active/visible state, and animation-complete listener state.
|
||||
- Left projectile and meteor/world-effect lifecycles unchanged.
|
||||
- Verified production build with `npm run build`.
|
||||
|
||||
Reference in New Issue
Block a user