Compare commits
7
Commits
1668e3c941
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5795cc9741 | ||
|
|
30d7be41be | ||
|
|
2c013247a9 | ||
|
|
9df4f3dcde | ||
|
|
743b2a75f5 | ||
|
|
f9b548f9cd | ||
|
|
ddccd01eb6 |
@@ -2,107 +2,266 @@
|
|||||||
|
|
||||||
## 0. 필수
|
## 0. 필수
|
||||||
|
|
||||||
- 작업이 완료되면 작업에 관련된 모든 문서를 업데이트한다
|
- 작업이 완료되면 작업과 관련된 모든 문서를 함께 업데이트한다.
|
||||||
|
- 대규모 전투, LOD, 모델/렌더 분리, 전투 워커, 서버 API를 수정할 때는 관련 `context/` 문서를 먼저 확인하고 변경 내용을 문서에 반영한다.
|
||||||
|
|
||||||
## 1. 프로젝트 정의
|
## 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
|
```text
|
||||||
├── index.html # 메인 HTML 진입점 및 UI 레이아웃
|
├── index.html # 메인 HTML 진입점 및 UI 레이아웃
|
||||||
├── package.json # 프로젝트 의존성 및 스크립트 정의 (Phaser, Vite, Fastify, MongoDB)
|
├── package.json # Phaser, Vite, Fastify, MongoDB 의존성 및 npm scripts
|
||||||
├── config.json # 로컬 서버/MongoDB 설정 (git ignore)
|
|
||||||
├── config.json.sample # 공유용 서버/MongoDB 설정 예시
|
├── config.json.sample # 공유용 서버/MongoDB 설정 예시
|
||||||
├── agent.md # 프로젝트 개요 및 기능 정의 (본 문서)
|
├── agent.md # 프로젝트 개요 및 에이전트 작업 가이드
|
||||||
├── CONTEXT.md # 상세 개발 가이드 및 로직 설명
|
|
||||||
├── todo.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 연결 관리
|
├── server/ # Fastify API 서버 및 MongoDB 연결 관리
|
||||||
│ ├── index.js # Fastify 서버 진입점, Vite 개발 미들웨어, 정적 배포 서빙
|
│ ├── index.js # Fastify 진입점, Vite dev middleware, 정적 배포 서빙
|
||||||
│ ├── config.js # config.json 로드 및 MongoDB URI 조립
|
│ ├── config.js # config.json 로드 및 MongoDB URI/컬렉션 설정
|
||||||
│ ├── db.js # MongoClient 커넥션 풀 생성/재사용/종료
|
│ ├── db.js # MongoClient 커넥션 풀 생성/재사용/종료
|
||||||
│ ├── deathStats.js # 전투 종료 시 오늘 일자별 종족 사망 통계 누적 API
|
│ ├── visitorCookie.js # 방문자 UUID 쿠키 읽기/쓰기/검증
|
||||||
│ ├── about.js # About 개발자정보/개인정보처리방침 기본값 시드 및 조회 API
|
│ ├── visitors.js # 유니크 방문자 체크 및 통계 API
|
||||||
│ └── visitors.js # 유니크 방문자 체크 및 통계 API
|
│ ├── dailyMetrics.js # 일일 방문/전투 시작/전투 종료/후원 클릭 지표 API
|
||||||
├── public/ # 정적 리소스 (게임 에셋)
|
│ ├── deathStats.js # 종족별 전투 사망 통계 API
|
||||||
|
│ └── about.js # About 개발자정보/개인정보처리방침 seed 및 조회 API
|
||||||
|
├── public/ # 정적 리소스
|
||||||
│ └── assets/
|
│ └── assets/
|
||||||
│ ├── effects/ # 공통 전투/월드 이펙트 스프라이트시트
|
│ ├── og-image.png # 공유 미리보기 이미지
|
||||||
|
│ ├── effects/
|
||||||
│ │ ├── heal/ # 처치 회복 연출
|
│ │ ├── heal/ # 처치 회복 연출
|
||||||
│ │ ├── world_Effect.png # 화염 메테오 7프레임 이미지
|
│ │ ├── world_Effect.png
|
||||||
│ │ └── world_Effect_2.png # 냉기 메테오 7프레임 이미지
|
│ │ └── world_Effect_2.png
|
||||||
│ └── characters/ # 20종 이상의 캐릭터 스킨 및 투사체 에셋
|
│ └── characters/ # 20종 이상 캐릭터 스킨/투사체/마법 이펙트 에셋
|
||||||
│ ├── archer/, armored-axeman/, armored-orc/, ... (중략)
|
│ ├── archer/
|
||||||
│ └── wizard/ # 각 폴더 내 애니메이션 시트 및 이펙트 포함
|
│ ├── armored-axeman/
|
||||||
└── src/ # 소스 코드 root
|
│ ├── armored-orc/
|
||||||
├── main.js # Phaser 게임 인스턴스 생성, 옵션 drawer/재시작/일시정지 UI 제어
|
│ ├── priest/
|
||||||
├── constants.js # 전역 물리/UI 상수 통합 관리 (공격력, 체력, 줌, 카메라 속도 등)
|
│ ├── wizard/
|
||||||
├── styles.css # UI 스타일링 (인트로, 옵션 drawer, 좌측 HUD 레일, 좌측 하단 킬로그, 상단 전투 안내바)
|
│ └── ... # knight, orc, skeleton, slime, wolf, bear 계열 등
|
||||||
├── game/ # 게임 로직 모듈 (역할별 하위 폴더 구성)
|
└── src/ # 프론트엔드 소스 root
|
||||||
│ ├── arena/ # 아레나 및 씬 관리
|
├── main.js # Phaser game config, 앱 상태, 옵션 drawer, 방문자 추적
|
||||||
│ │ ├── ArenaScene.js # 메인 게임 씬 (Orchestrator, 생명주기 및 모듈 조율)
|
├── constants.js # 렌더/전장/전투/카메라/성능/월드 이펙트 상수
|
||||||
│ │ ├── arenaRenderer.js# 경기장 바닥, 격자 및 팀 시작 영역 렌더링
|
├── styles.css # CSS 모듈 통합 엔트리
|
||||||
│ │ └── arenaSpectatorCamera.js # 지능형 관전 카메라 및 줌 로직
|
├── styles/
|
||||||
│ ├── combat/ # 전투 시스템
|
│ ├── base.css # 전역 변수, reset, 기본 레이아웃
|
||||||
│ │ ├── combat.js # 전투 AI, 투사체 및 피격 판정 핵심 엔진
|
│ ├── intro.css # 대기 화면 및 프리뷰 스타일
|
||||||
│ │ ├── combatSettings.js # 전투 속도 및 이동 배율 관리
|
│ ├── game-ui.css # scoreboard, kill log, battle notice, victory layer
|
||||||
│ │ ├── arenaFinalCombatEffects.js # 최종 교전 슬로우 모션 등 연출 효과
|
│ ├── overlay.css # option drawer, About dialog, form controls
|
||||||
│ │ └── worldEffects.js # 주기적 메테오/냉각지대 및 냉기 동결 효과
|
│ ├── animations.css # 공통 keyframes/animation utilities
|
||||||
│ ├── fighter/ # 캐릭터 및 에셋
|
│ └── mobile.css # 960px 이하 반응형 override
|
||||||
│ │ ├── fighterAssets.js # 스프라이트 로드 및 팀 실루엣 동적 생성
|
├── game/
|
||||||
│ │ ├── fighterFactory.js # 캐릭터 인스턴스화 및 HUD 동기화
|
│ ├── arena/
|
||||||
│ │ ├── fighterManifest.js # 20종 캐릭터 스탯/특성 상세 정의
|
│ │ ├── ArenaScene.js # 메인 Phaser Scene orchestrator
|
||||||
│ │ ├── fighterStats.js # 근접/원거리/마법 프로필 판별 및 스탯 해석
|
│ │ ├── arenaRenderer.js # 전장 바닥, grid, starting zone 렌더링
|
||||||
│ │ └── fighterSelection.js # 캐릭터 스킨 무작위 선택 로직
|
│ │ ├── arenaSpectatorCamera.js # 자동/수동 카메라 포커싱
|
||||||
│ └── match/ # 매치 및 진행
|
│ │ └── fighterLodWorker.js # 대규모 전투 detailed sprite 후보 worker
|
||||||
│ ├── matchSetup.js # 팀 구성 및 스폰 좌표 계산 (스타팅 영역/랜덤)
|
│ ├── combat/
|
||||||
│ └── arenaMatchRuntime.js # 매치 진행 중 헬퍼 (스폰 클러스터, 팀 크기 동기화)
|
│ │ ├── combat.js # model 기반 전투 AI, 타깃, 피해, 처치 처리
|
||||||
└── ui/ # UI 컴포넌트 및 API 연동
|
│ │ ├── aggregateCombatWorker.js# detached/offscreen 집계 전투 worker
|
||||||
├── arenaKillLog.js # [New] 독립된 킬로그 DOM 조작 모듈
|
│ │ ├── combatSettings.js # 전투 속도 및 이동 배율 설정
|
||||||
├── arenaScoreboard.js # [New] 팀 스코어 badge 업데이트 모듈
|
│ │ ├── arenaFinalCombatEffects.js
|
||||||
├── battleDeathNotice.js # [New] 상단 사망 공지 메시지 및 UI 관리
|
│ │ └── worldEffects.js # 밀집 구역 메테오/냉기/감속/동결 효과
|
||||||
├── victoryCelebration.js # [New] 승리 축하 연출 (DOM/Audio) 모듈
|
│ ├── fighter/
|
||||||
├── matchForm.js # 설정 폼 제어 및 localStorage 유지
|
│ │ ├── fighterModel.js # 순수 JS fighter 상태 모델
|
||||||
├── aboutDialog.js # About 다이얼로그, 개발자정보/개인정보처리방침 표시
|
│ │ ├── fighterAdapter.js # Phaser Sprite/Physics 접근 경계
|
||||||
├── deathStats.js # 사망 통계 API 호출 래퍼
|
│ │ ├── fighterAssets.js # sprite load, team-shadow texture/animation 생성
|
||||||
└── visitorCounter.js # 방문자 체크 API 호출 및 표시
|
│ │ ├── 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.
|
|
||||||
- **[스타일 및 디자인] [context/style.md](./context/style.md)**: CSS 모듈 구조, 디자인 변수, 반응형 및 애니메이션 가이드.
|
|
||||||
|
|
||||||
## 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 기반)
|
- **Framework**: Phaser 3.90.0 (Arcade Physics 기반)
|
||||||
- **Build Tool**: Vite 7.1.12
|
- **Build Tool**: Vite 7.1.12
|
||||||
- **Server**: Fastify 5.x (`@fastify/static`, `@fastify/middie`)
|
- **Server**: Fastify 5.x (`@fastify/static`, `@fastify/middie`)
|
||||||
- **Database**: MongoDB 7.x Node Driver
|
- **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`을 복사해 사용합니다.
|
- `config.json`은 로컬 설정 파일이므로 저장소에 커밋하지 않습니다. 새 환경에서는 `config.json.sample`을 복사해 사용합니다.
|
||||||
- 기본 API:
|
|
||||||
|
기본 API:
|
||||||
|
|
||||||
- `GET /api/health`: 서버 및 MongoDB 설정 여부 확인.
|
- `GET /api/health`: 서버 및 MongoDB 설정 여부 확인.
|
||||||
- `POST /api/visitors/check`: 현재 브라우저 방문자를 체크하고 유니크 방문자 수를 반환.
|
- `POST /api/visitors/check`: 방문자 UUID 쿠키 확인/발급 및 유니크 방문자 수 반환.
|
||||||
- `GET /api/visitors/stats`: 전체 유니크 방문자 수 조회.
|
- `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`: 오늘의 종족별 전투 사망 통계 조회.
|
- `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): 상세 개발 가이드 및 핵심 로직 설명 (필독)
|
- **문서 동기화**: 구조, 상수, API, 대규모 전투 path가 바뀌면 `agent.md`와 관련 `context/*.md`를 함께 갱신합니다.
|
||||||
- [todo.md](./todo.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): 작업 내역 및 잔여 이슈.
|
||||||
|
|||||||
+139
-2
@@ -1,3 +1,140 @@
|
|||||||
|
# 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
|
# Context: Arena & Scene
|
||||||
|
|
||||||
## 1. 모듈별 상세 역할 (`src/game/arena/`)
|
## 1. 모듈별 상세 역할 (`src/game/arena/`)
|
||||||
@@ -17,7 +154,7 @@ this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * CAMERA.S
|
|||||||
```
|
```
|
||||||
|
|
||||||
자동 관전은 월드 이펙트 임시 시점, 후반 진입과 최종교전 세부 포커싱으로 나뉩니다.
|
자동 관전은 월드 이펙트 임시 시점, 후반 진입과 최종교전 세부 포커싱으로 나뉩니다.
|
||||||
- **메테오 임시 포커싱**: 자동 관전 진입 전 화염 또는 냉기 메테오가 시작되면 착탄 지점을 임시로 확대 추적하고, 착탄 후 `CAMERA.METEOR_FOCUS_HOLD_DURATION`만큼 유지한 뒤 이전 카메라로 복귀합니다. `CAMERA.METEOR_FOCUS_ENABLED`를 `false`로 설정하면 끌 수 있으며, 수동 선택 시점과 아래 자동 관전 시점이 우선합니다.
|
- **메테오 임시 포커싱**: 자동 관전 진입 전 화염 또는 냉기 포격이 시작되면 가장 밀집한 큰 경고 구역의 중심을 임시로 확대 추적합니다. 큰 경고 표시 자체는 `WORLD_EFFECT.WARNING_DURATION_MS` 이후 사라지며, 카메라는 내부 소형 탄착들이 종료된 뒤 `CAMERA.METEOR_FOCUS_HOLD_DURATION`만큼 유지한 다음 이전 시점으로 복귀합니다. `CAMERA.METEOR_FOCUS_ENABLED`를 `false`로 설정하면 끌 수 있으며, 수동 선택 시점과 아래 자동 관전 시점이 우선합니다.
|
||||||
- **후반 자동 관전 진입**: 생존 캐릭터가 30명 미만(`CAMERA.SPECTATOR_LATE_FIGHTER_THRESHOLD`)이 되면 교전 중심(가장 가까운 적 대항쌍)을 포커싱하는 후반 줌을 적용합니다. (2팀만 남았더라도 인원이 많으면 어지러움을 방지하기 위해 자동 관전으로 바로 진입하지 않습니다.)
|
- **후반 자동 관전 진입**: 생존 캐릭터가 30명 미만(`CAMERA.SPECTATOR_LATE_FIGHTER_THRESHOLD`)이 되면 교전 중심(가장 가까운 적 대항쌍)을 포커싱하는 후반 줌을 적용합니다. (2팀만 남았더라도 인원이 많으면 어지러움을 방지하기 위해 자동 관전으로 바로 진입하지 않습니다.)
|
||||||
- **생존 4명 이하**: `CAMERA.SPECTATOR_RANDOM_FOCUS_INTERVAL`마다 생존 캐릭터 중 한 명을 무작위로 포커싱합니다.
|
- **생존 4명 이하**: `CAMERA.SPECTATOR_RANDOM_FOCUS_INTERVAL`마다 생존 캐릭터 중 한 명을 무작위로 포커싱합니다.
|
||||||
- **2팀 잔여 & 합계 8명 이하**: 더 적은 생존 수를 가진 팀의 중앙을 포커싱하며, 동률이면 기존 교전쌍 중심 포커싱으로 되돌아갑니다.
|
- **2팀 잔여 & 합계 8명 이하**: 더 적은 생존 수를 가진 팀의 중앙을 포커싱하며, 동률이면 기존 교전쌍 중심 포커싱으로 되돌아갑니다.
|
||||||
@@ -33,4 +170,4 @@ this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * CAMERA.S
|
|||||||
### 씬 상태 관리
|
### 씬 상태 관리
|
||||||
- **프리뷰 모드 (`presentationMode`)**: 최초 로드 시 조용히 실행되는 배경 전투입니다. 로컬 저장 옵션과 무관하게 10팀 x 5명 고정 규모로 동작합니다.
|
- **프리뷰 모드 (`presentationMode`)**: 최초 로드 시 조용히 실행되는 배경 전투입니다. 로컬 저장 옵션과 무관하게 10팀 x 5명 고정 규모로 동작합니다.
|
||||||
- **일시정지 (`setPaused`)**: 실제 전투에서 물리, Phaser 타이머, tween, 스프라이트 애니메이션을 함께 제어합니다. 프리뷰 및 종료된 전투는 제외됩니다.
|
- **일시정지 (`setPaused`)**: 실제 전투에서 물리, Phaser 타이머, tween, 스프라이트 애니메이션을 함께 제어합니다. 프리뷰 및 종료된 전투는 제외됩니다.
|
||||||
- **월드 이펙트 주기**: 실제 전투 생성 시 `startWorldEffects()`를 시작하고, 새 매치/종료 때 `clearWorldEffects()`로 주기 타이머, 잔여 냉각 구역, 메테오 임시 포커스, 캐릭터 감속 배율을 정리합니다. Phaser 타이머를 사용하므로 일시정지 시간은 4초 발동 간격과 냉각 지속시간에 포함되지 않습니다.
|
- **월드 이펙트 주기**: 실제 전투 생성 시 `startWorldEffects()`를 시작하고, 첫 포격은 `WORLD_EFFECT.INTERVAL`, 이후 일반 포격은 `WORLD_EFFECT.REPEAT_INTERVAL`을 사용합니다. 새 매치/종료 때 `clearWorldEffects()`로 주기 타이머, 잔여 냉각 구역, 메테오 임시 포커스, 캐릭터 감속 배율을 정리합니다. Phaser 타이머를 사용하므로 일시정지 시간은 이 간격과 냉각 지속시간에 포함되지 않습니다.
|
||||||
|
|||||||
+139
-6
@@ -1,3 +1,126 @@
|
|||||||
|
# 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
|
# Context: Combat System
|
||||||
|
|
||||||
## 1. 모듈별 상세 역할 (`src/game/combat/`)
|
## 1. 모듈별 상세 역할 (`src/game/combat/`)
|
||||||
@@ -5,12 +128,12 @@
|
|||||||
- **`combat.js`**: 전투 AI, 피해 계산, 처치 보상 등 핵심 전투 로직을 담당합니다. `fighterStats.js`에서 해석한 역할별 수치로 이동, 공격, 투사체 발사 등을 처리합니다.
|
- **`combat.js`**: 전투 AI, 피해 계산, 처치 보상 등 핵심 전투 로직을 담당합니다. `fighterStats.js`에서 해석한 역할별 수치로 이동, 공격, 투사체 발사 등을 처리합니다.
|
||||||
- **`combatSettings.js`**: 전투 속도 배율 등 런타임 전투 설정을 관리합니다.
|
- **`combatSettings.js`**: 전투 속도 배율 등 런타임 전투 설정을 관리합니다.
|
||||||
- **`arenaFinalCombatEffects.js`**: 최종 교전 시 슬로우 모션 등 연출 효과를 담당합니다. 수학적인 이징(easing) 함수와 물리 시간 배율 계산을 포함합니다.
|
- **`arenaFinalCombatEffects.js`**: 최종 교전 시 슬로우 모션 등 연출 효과를 담당합니다. 수학적인 이징(easing) 함수와 물리 시간 배율 계산을 포함합니다.
|
||||||
- **`worldEffects.js`**: 실제 전투에서 4초마다 발동하는 화염/냉기 메테오 선택, 사분면별 대각선 낙하 연출, 5x5 영역 판정, 냉기 동결과 감속 구역 수명주기를 처리합니다.
|
- **`worldEffects.js`**: 실제 전투에서 설정 주기마다 생존자 밀집 구역을 탐색하고 화염/냉기 소형 메테오 포격을 실행하며, 대각선 낙하 연출, 개별 탄착 판정, 냉기 동결과 감속 구역 수명주기를 처리합니다.
|
||||||
|
|
||||||
## 2. 주요 로직 구현 세부 사항
|
## 2. 주요 로직 구현 세부 사항
|
||||||
|
|
||||||
### 전투 AI 및 유닛 동작
|
### 전투 AI 및 유닛 동작
|
||||||
- **`updateFighter()`**: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
|
- **`updateFighterModel()`**: 가장 가까운 적 모델을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
|
||||||
- **`applyHit()`**: 일반 공격 피해량은 공격자의 `melee`/`ranged`/`magic` 프로필 피해량 범위에서 계산하고, 치명타 적중은 `Critical!` 표기와 즉시 처치를 처리합니다.
|
- **`applyHit()`**: 일반 공격 피해량은 공격자의 `melee`/`ranged`/`magic` 프로필 피해량 범위에서 계산하고, 치명타 적중은 `Critical!` 표기와 즉시 처치를 처리합니다.
|
||||||
- **역할별 기본값**: `src/constants.js`의 `FIGHTER_TYPE_STATS`에서 체력, 이동속도, 사거리, 공격 쿨다운, 피해량, 치명타 확률, 발동 지연을 독립적으로 조절합니다. 투사체 속도는 `ranged`, 효과 적중 지연은 `magic` 프로필에 포함됩니다.
|
- **역할별 기본값**: `src/constants.js`의 `FIGHTER_TYPE_STATS`에서 체력, 이동속도, 사거리, 공격 쿨다운, 피해량, 치명타 확률, 발동 지연을 독립적으로 조절합니다. 투사체 속도는 `ranged`, 효과 적중 지연은 `magic` 프로필에 포함됩니다.
|
||||||
- **`projectilePathHitsDefender()`**: 투사체가 대상을 스쳐 지나가지 않도록 궤적(Line)과 히트박스(Rectangle) 겹침 검사를 수행합니다.
|
- **`projectilePathHitsDefender()`**: 투사체가 대상을 스쳐 지나가지 않도록 궤적(Line)과 히트박스(Rectangle) 겹침 검사를 수행합니다.
|
||||||
@@ -20,10 +143,15 @@
|
|||||||
- **`clampFighterInsideArena()`**: 처치 성장 중 커진 캐릭터가 전장 바깥으로 나가지 않도록 위치를 보정합니다.
|
- **`clampFighterInsideArena()`**: 처치 성장 중 커진 캐릭터가 전장 바깥으로 나가지 않도록 위치를 보정합니다.
|
||||||
|
|
||||||
### 월드 이펙트
|
### 월드 이펙트
|
||||||
- **발동 규칙**: 프리뷰가 아닌 실제 전투에서 전투 시간 4초마다 생존 캐릭터 하나를 무작위로 선택하고, 대상의 당시 위치를 중심으로 메테오 또는 냉각지대 중 하나를 무작위 발동합니다.
|
- **발동 규칙**: 프리뷰가 아닌 실제 전투에서 시작 후 첫 포격은 `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도로 기울이고 전용 시각 배율을 사용해 전역 마법 규모로 표현합니다.
|
- **낙하 방향과 크기**: 대상이 전장 좌측 반면(2, 3사분면)이면 화살표가 좌상단에서 우하단으로, 우측 반면(1, 4사분면)이면 좌우 반전되어 우상단에서 좌하단으로 이동합니다. 스프라이트를 45도로 기울이고 전용 시각 배율을 사용해 전역 마법 규모로 표현합니다.
|
||||||
- **화염 메테오**: `world_Effect.png`의 7프레임 애니메이션이 낙하하면 화면 흔들림을 적용하고, 5x5 타일 영역의 생존자에게 고정 피해를 줍니다. 자동 관전 진입 전에는 `CAMERA.METEOR_FOCUS_ENABLED`가 켜져 있을 때 착탄 위치를 임시 포커싱합니다. 환경 피해로 인한 사망은 킬 보상을 지급하지 않지만 사망 통계와 승패 판정에는 반영됩니다.
|
- **화염 메테오**: `world_Effect.png`의 소형 탄착 애니메이션 3~4개가 밀집 경고 구역 내부로 순차 낙하합니다. 각 탄착은 크기에 따른 화면 흔들림과 개별 영역 고정 피해를 적용합니다. 환경 피해로 인한 사망은 킬 보상을 지급하지 않지만 사망 통계와 승패 판정에는 반영됩니다.
|
||||||
- **냉기 메테오**: `world_Effect_2.png`의 7프레임 애니메이션으로 착탄을 표시하고, 자동 관전 진입 전에는 같은 설정에 따라 착탄 위치를 임시 포커싱합니다. 착탄 시 별도 조정 가능한 피해를 주며, 생존한 피격 대상은 캐릭터 본체와 실루엣이 얼음색으로 바뀐 채 2초 동안 기절합니다. 이후 남은 5x5 냉각지대 안에서는 공격속도와 이동속도 감속 배율을 적용하며, 영역을 벗어나거나 지속시간이 끝나면 배율을 복구합니다.
|
- **냉기 메테오**: `world_Effect_2.png`의 소형 탄착 애니메이션들이 같은 방식으로 낙하합니다. 개별 탄착 피해를 입고 생존한 대상은 얼음색으로 바뀐 채 설정 시간 동안 기절하며, 각 탄착점에 남는 냉각지대 안에서는 공격속도와 이동속도 감속 배율을 적용합니다.
|
||||||
|
|
||||||
### 최종교전 슬로우모션
|
### 최종교전 슬로우모션
|
||||||
`COMBAT.FINAL_SLOW_MOTION_ENABLED`가 활성화된 경우:
|
`COMBAT.FINAL_SLOW_MOTION_ENABLED`가 활성화된 경우:
|
||||||
@@ -34,5 +162,10 @@
|
|||||||
## 3. 유지보수 규칙
|
## 3. 유지보수 규칙
|
||||||
- **처치 성장 상한**: `src/constants.js`의 `KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
|
- **처치 성장 상한**: `src/constants.js`의 `KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
|
||||||
- **공격력 조정**: `src/constants.js`의 `FIGHTER_TYPE_STATS.<type>.damageMin/damageMax`를 수정합니다.
|
- **공격력 조정**: `src/constants.js`의 `FIGHTER_TYPE_STATS.<type>.damageMin/damageMax`를 수정합니다.
|
||||||
- **월드 이펙트 조정**: `src/constants.js`의 `WORLD_EFFECT.METEOR_DAMAGE`와 `WORLD_EFFECT.FROST_DAMAGE`로 두 메테오 피해를 각각 조정하고, `WORLD_EFFECT.FROST_STUN_DURATION`/`FROST_STUN_TINT`로 동결 시간과 표시 색상을 조정합니다. 나머지 `WORLD_EFFECT.*` 값으로 발동 주기, 범위, 냉각 지속시간과 감속 정도를 수정하며, 메테오 착탄 위치 포커싱은 `CAMERA.METEOR_FOCUS_ENABLED`에서 켜고 끕니다.
|
- **월드 이펙트 및 서든 데스 조정**:
|
||||||
|
- `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` 설정을 확인합니다.
|
- **특수 규칙**: 캐릭터별 특수 공격 방식은 `fighterManifest.js`의 `combat` 설정을 확인합니다.
|
||||||
|
|||||||
+84
-2
@@ -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
|
# 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. 모듈별 상세 역할
|
## 1. 모듈별 상세 역할
|
||||||
|
|
||||||
- **`src/main.js`**: Phaser 게임의 전역 설정(Physics, Scale, Canvas Parent)을 담당하며, `ArenaScene`을 인스턴스화합니다.
|
- **`src/main.js`**: Phaser 게임의 전역 설정(Physics, Scale, Canvas Parent)을 담당하며, `ArenaScene`을 인스턴스화합니다.
|
||||||
@@ -10,7 +92,7 @@
|
|||||||
- `FIGHTER_TYPE_STATS`: `melee`, `ranged`, `magic`별 최대 체력, 이동속도, 사거리, 쿨다운, 피해량, 치명타 및 공격 발동 지연 기본값.
|
- `FIGHTER_TYPE_STATS`: `melee`, `ranged`, `magic`별 최대 체력, 이동속도, 사거리, 쿨다운, 피해량, 치명타 및 공격 발동 지연 기본값.
|
||||||
- `FIGHTER_HITBOX_*`: 100x100 캐릭터 프레임 안에서 실제 충돌 판정이 놓이는 위치와 크기.
|
- `FIGHTER_HITBOX_*`: 100x100 캐릭터 프레임 안에서 실제 충돌 판정이 놓이는 위치와 크기.
|
||||||
- `KILL_HEALTH_RECOVERY_RATIO`, `KILL_GROWTH_MULTIPLIER`, `KILL_GROWTH_MAX_MULTIPLIER`: 처치 후 회복량, 크기/공격속도/이동속도 성장 배율, 누적 보상 상한.
|
- `KILL_HEALTH_RECOVERY_RATIO`, `KILL_GROWTH_MULTIPLIER`, `KILL_GROWTH_MAX_MULTIPLIER`: 처치 후 회복량, 크기/공격속도/이동속도 성장 배율, 누적 보상 상한.
|
||||||
- `WORLD_EFFECT.*`: 월드 이펙트 발동 간격, 5x5 범위, 대각선 낙하 거리/시각 배율, 화염/냉기 메테오 피해량, 냉기 동결 시간/실루엣 색상, 냉각지대 지속시간과 감속 배율.
|
- `WORLD_EFFECT.*`: 첫/반복 포격 간격, 밀집 경고 범위, 개별 탄착 범위/발수/시각 배율, 대각선 낙하 거리, 화염/냉기 메테오 피해량, 냉기 동결 시간/색상, 냉각지대 지속시간과 감속 배율.
|
||||||
- `SELECTED_FIGHTER_OUTLINE_GAP`, `SELECTED_FIGHTER_OUTLINE_WIDTH`, `SELECTED_FIGHTER_OUTLINE_ALPHA`: 팀 색상 실루엣 마커의 캐릭터 이격 거리, 두께, 투명도.
|
- `SELECTED_FIGHTER_OUTLINE_GAP`, `SELECTED_FIGHTER_OUTLINE_WIDTH`, `SELECTED_FIGHTER_OUTLINE_ALPHA`: 팀 색상 실루엣 마커의 캐릭터 이격 거리, 두께, 투명도.
|
||||||
- `TEAM_COLORS`, `getTeamColor()`: 8팀 이하에서는 기본 팔레트를 쓰고, 9팀 이상에서는 팀 수에 맞춰 중복 없는 색상을 동적으로 생성합니다.
|
- `TEAM_COLORS`, `getTeamColor()`: 8팀 이하에서는 기본 팔레트를 쓰고, 9팀 이상에서는 팀 수에 맞춰 중복 없는 색상을 동적으로 생성합니다.
|
||||||
- `CAMERA.SPECTATOR_LERP`: 카메라 추적의 부드러움 정도.
|
- `CAMERA.SPECTATOR_LERP`: 카메라 추적의 부드러움 정도.
|
||||||
@@ -27,6 +109,6 @@
|
|||||||
- **물리 수치 조정**: 역할별 기본 체력/속도/사거리/공격 수치는 `src/constants.js`의 `FIGHTER_TYPE_STATS`에서 변경하고, 특정 스킨만 다르게 할 때는 `fighterManifest.js`의 `stats` 또는 `combat` 설정을 사용하십시오.
|
- **물리 수치 조정**: 역할별 기본 체력/속도/사거리/공격 수치는 `src/constants.js`의 `FIGHTER_TYPE_STATS`에서 변경하고, 특정 스킨만 다르게 할 때는 `fighterManifest.js`의 `stats` 또는 `combat` 설정을 사용하십시오.
|
||||||
- **처치 성장 상한 조정**: 처치 보상으로 캐릭터가 커지는 최대치와 공격/이동 배율 상한은 `src/constants.js`의 `KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
|
- **처치 성장 상한 조정**: 처치 보상으로 캐릭터가 커지는 최대치와 공격/이동 배율 상한은 `src/constants.js`의 `KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
|
||||||
- **공격력 조정**: 역할별 기본 피해량은 `src/constants.js`의 `FIGHTER_TYPE_STATS.<type>.damageMin/damageMax`를 수정합니다. 캐릭터별 특수 공격 방식은 `fighterManifest.js`의 `combat` 설정을 우선 확인합니다.
|
- **공격력 조정**: 역할별 기본 피해량은 `src/constants.js`의 `FIGHTER_TYPE_STATS.<type>.damageMin/damageMax`를 수정합니다. 캐릭터별 특수 공격 방식은 `fighterManifest.js`의 `combat` 설정을 우선 확인합니다.
|
||||||
- **월드 이펙트 조정**: `src/constants.js`의 `WORLD_EFFECT.INTERVAL`, `WORLD_EFFECT.FALL_TRAVEL_TILES`, `WORLD_EFFECT.VISUAL_SCALE`, `WORLD_EFFECT.METEOR_DAMAGE`, `WORLD_EFFECT.FROST_DAMAGE`, `WORLD_EFFECT.FROST_STUN_DURATION`, `WORLD_EFFECT.FROST_STUN_TINT`, `WORLD_EFFECT.FROST_DURATION`, `WORLD_EFFECT.FROST_SPEED_MULTIPLIER`를 수정합니다. 임시 메테오 카메라는 `CAMERA.METEOR_FOCUS_ENABLED`로 끌 수 있습니다.
|
- **월드 이펙트 조정**: `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에 접근합니다.
|
- **DOM 접근**: 성능을 위해 `ArenaScene`은 좌측 HUD badge 등 필요한 시점에만 최소한으로 DOM에 접근합니다.
|
||||||
- **패키지 락 파일**: 이 프로젝트는 `package-lock.json`을 저장소에서 제외합니다. 의존성 변경 시 `package.json`을 기준으로 관리합니다.
|
- **패키지 락 파일**: 이 프로젝트는 `package-lock.json`을 저장소에서 제외합니다. 의존성 변경 시 `package.json`을 기준으로 관리합니다.
|
||||||
|
|||||||
+74
-2
@@ -1,9 +1,81 @@
|
|||||||
|
# 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
|
# Context: Fighter & Assets
|
||||||
|
|
||||||
## 1. 모듈별 상세 역할 (`src/game/fighter/`)
|
## 1. 모듈별 상세 역할 (`src/game/fighter/`)
|
||||||
|
|
||||||
- **`fighterAssets.js`**: 캐릭터 스프라이트 로드 및 애니메이션/실루엣 생성을 담당합니다. 원본 이미지로부터 팀 색상 마커용 실루엣을 동적으로 생성합니다.
|
- **`fighterAssets.js`**: 캐릭터 스프라이트 로드 및 애니메이션/실루엣 생성을 담당합니다. 원본 이미지로부터 팀 색상 마커용 실루엣을 동적으로 생성합니다.
|
||||||
- **`fighterFactory.js`**: 캐릭터 인스턴스화 및 HUD(이름표, 체력바) 관리를 담당합니다. Phaser Sprite와 DOM UI 사이의 가교 역할을 합니다.
|
- **`fighterFactory.js`**: 캐릭터 인스턴스화 및 HUD 체력바 관리를 담당합니다. Phaser Sprite와 DOM UI 사이의 가교 역할을 합니다.
|
||||||
- **`fighterManifest.js`**: 모든 캐릭터 종족 및 스탯 데이터를 정의합니다. 20여 종의 캐릭터 설정이 포함되어 있습니다.
|
- **`fighterManifest.js`**: 모든 캐릭터 종족 및 스탯 데이터를 정의합니다. 20여 종의 캐릭터 설정이 포함되어 있습니다.
|
||||||
- **`fighterStats.js`**: 공격 방식으로 `melee`, `ranged`, `magic` 역할을 판별하고 역할별 기본 스탯과 스킨별 오버라이드를 병합합니다.
|
- **`fighterStats.js`**: 공격 방식으로 `melee`, `ranged`, `magic` 역할을 판별하고 역할별 기본 스탯과 스킨별 오버라이드를 병합합니다.
|
||||||
- **`fighterSelection.js`**: 매치 참여 캐릭터를 무작위로 선택하거나 섞는 로직을 담당합니다.
|
- **`fighterSelection.js`**: 매치 참여 캐릭터를 무작위로 선택하거나 섞는 로직을 담당합니다.
|
||||||
@@ -18,7 +90,7 @@
|
|||||||
4. 캐릭터가 성장하여 커져도 같은 배율로 실루엣이 유지됩니다.
|
4. 캐릭터가 성장하여 커져도 같은 배율로 실루엣이 유지됩니다.
|
||||||
|
|
||||||
### 캐릭터 HUD 및 상태 동기화
|
### 캐릭터 HUD 및 상태 동기화
|
||||||
- **이름표 고정**: 스프라이트 중심이 아닌 실제 히트박스 하단에 고정되어 시각적 일관성을 유지합니다.
|
- **체력바 표시**: 줌 또는 선택 상태에서 후보 fighter만 pooled HUD slot을 빌려 체력바를 표시합니다. 이름표는 zoom HUD에 표시하지 않습니다.
|
||||||
- **사망자 처리**: 사망 시 HUD와 팀 마커를 숨겨 화면 가독성을 높입니다. 본체 sprite만 낮은 depth와 반투명 상태로 남깁니다.
|
- **사망자 처리**: 사망 시 HUD와 팀 마커를 숨겨 화면 가독성을 높입니다. 본체 sprite만 낮은 depth와 반투명 상태로 남깁니다.
|
||||||
- **월드 감속 상태**: 생성 시 `worldEffectSpeedMultiplier`를 `1`로 초기화하며, 냉각지대 안에서는 `worldEffects.js`가 해당 배율을 낮춰 공격속도와 이동속도 계산에 반영합니다.
|
- **월드 감속 상태**: 생성 시 `worldEffectSpeedMultiplier`를 `1`로 초기화하며, 냉각지대 안에서는 `worldEffects.js`가 해당 배율을 낮춰 공격속도와 이동속도 계산에 반영합니다.
|
||||||
- **냉기 동결 상태**: `isFrostStunned`와 동결 타이머를 캐릭터별로 관리합니다. 냉기 메테오 착탄에 생존하면 캐릭터 본체와 팀 실루엣 마커가 함께 얼음색으로 바뀌고, 동결 종료 시 본체 원본 색상과 저장된 팀 색상으로 복구됩니다.
|
- **냉기 동결 상태**: `isFrostStunned`와 동결 타이머를 캐릭터별로 관리합니다. 냉기 메테오 착탄에 생존하면 캐릭터 본체와 팀 실루엣 마커가 함께 얼음색으로 바뀌고, 동결 종료 시 본체 원본 색상과 저장된 팀 색상으로 복구됩니다.
|
||||||
|
|||||||
+10
-2
@@ -1,5 +1,12 @@
|
|||||||
# Context: Match & UI
|
# 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. 모듈별 상세 역할
|
## 1. 모듈별 상세 역할
|
||||||
|
|
||||||
### 매치 로직 (`src/game/match/`)
|
### 매치 로직 (`src/game/match/`)
|
||||||
@@ -17,8 +24,9 @@
|
|||||||
## 2. 주요 로직 구현 세부 사항
|
## 2. 주요 로직 구현 세부 사항
|
||||||
|
|
||||||
### 매치 설정 및 스폰 배치
|
### 매치 설정 및 스폰 배치
|
||||||
- **완전 랜덤 배치**: 전장 전체 스폰 슬롯을 무작위로 섞어 배치합니다.
|
- **닉네임 배수 시스템**: `닉네임*배수` 형식(예: `Alice*2`)을 감지하여 팀 인원을 배수만큼 생성합니다.
|
||||||
- **스타팅 지점 배치**: 팀마다 전장 스폰 가능 그리드에서 중심 셀을 무작위로 고르고, 중심 주변 2칸(`5 x 5`)을 해당 팀의 스타팅 영역으로 사용합니다. 겹치지 않는 후보가 남아 있는 동안에는 해당 후보를 우선 선택하며, 영역은 매치 시작 후 5초 동안만 팀 색상으로 매우 옅게 표시되고 팀 전투원은 이 안에서만 스폰합니다.
|
- **구매 배수 보존과 독주 견제**: 배수 팀의 생성 인원과 전투 수치는 결제 이점으로 유지합니다. 월드 이펙트 표적 선정에서는 `team.multiplier`를 구매 지분으로 사용하고, 그 지분을 초과해 생존 중인 팀에만 설정 가능한 추가 표적 가중치를 적용합니다.
|
||||||
|
- **스타팅 지점 배치 (멀티 스폰)**: 팀마다 전장 스폰 가능 그리드에서 중심 셀을 무작위로 고르고, 중심 주변 2칸(`5 x 5`)을 해당 팀의 스타팅 영역으로 사용합니다. 배수가 설정된 팀은 배수만큼의 독립적인 스타팅 영역을 할당받아 병력이 분산 배치됩니다. 겹치지 않는 후보가 남아 있는 동안에는 해당 후보를 우선 선택하며, 영역은 매치 시작 후 5초 동안만 팀 색상으로 매우 옅게 표시되고 팀 전투원은 이 안에서만 스폰합니다.
|
||||||
- **설정 유지**: 닉네임, 인원, 배치 모드는 `localStorage`에 저장되어 재접속 시 복원됩니다.
|
- **설정 유지**: 닉네임, 인원, 배치 모드는 `localStorage`에 저장되어 재접속 시 복원됩니다.
|
||||||
|
|
||||||
### 전투 화면 레이아웃 (HUD)
|
### 전투 화면 레이아웃 (HUD)
|
||||||
|
|||||||
+33
-24
@@ -162,8 +162,13 @@
|
|||||||
<form id="fighter-form" autocomplete="off">
|
<form id="fighter-form" autocomplete="off">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Players</legend>
|
<legend>Players</legend>
|
||||||
<label for="player-names">참가자 닉네임</label>
|
<label for="player-names">참가자 닉네임 (*숫자 = 출전 인원)</label>
|
||||||
<textarea id="player-names" name="playerNames" rows="10">
|
<textarea
|
||||||
|
id="player-names"
|
||||||
|
name="playerNames"
|
||||||
|
rows="10"
|
||||||
|
aria-describedby="player-names-warning"
|
||||||
|
>
|
||||||
Player 1
|
Player 1
|
||||||
Player 2
|
Player 2
|
||||||
Player 3
|
Player 3
|
||||||
@@ -175,31 +180,35 @@ Player 8
|
|||||||
Player 9
|
Player 9
|
||||||
Player 10</textarea
|
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>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Match</legend>
|
<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">
|
<div class="spawn-placement-field">
|
||||||
<span id="spawn-placement-label" class="spawn-placement-label"
|
<span id="spawn-placement-label" class="spawn-placement-label"
|
||||||
>리스폰 설정</span
|
>리스폰 설정</span
|
||||||
|
|||||||
+87
-22
@@ -2,6 +2,7 @@
|
|||||||
const GRID_SIZE = 50;
|
const GRID_SIZE = 50;
|
||||||
const TILE_SIZE = 64;
|
const TILE_SIZE = 64;
|
||||||
const ARENA_SIZE = GRID_SIZE * TILE_SIZE;
|
const ARENA_SIZE = GRID_SIZE * TILE_SIZE;
|
||||||
|
const RENDER_SIZE = 1280;
|
||||||
|
|
||||||
export const ARENA = {
|
export const ARENA = {
|
||||||
GRID_SIZE,
|
GRID_SIZE,
|
||||||
@@ -9,19 +10,25 @@ export const ARENA = {
|
|||||||
SIZE: ARENA_SIZE,
|
SIZE: ARENA_SIZE,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const RENDER = {
|
||||||
|
HEIGHT: RENDER_SIZE,
|
||||||
|
WIDTH: RENDER_SIZE,
|
||||||
|
};
|
||||||
|
|
||||||
// 2. FIGHTER 도메인
|
// 2. FIGHTER 도메인
|
||||||
export const FIGHTER = {
|
export const FIGHTER = {
|
||||||
SCALE: 3,
|
SCALE: 3,
|
||||||
DEPTH: 2,
|
DEPTH: 2,
|
||||||
DEAD_DEPTH: 1,
|
DEAD_DEPTH: 1,
|
||||||
DEAD_ALPHA: 0.42,
|
DEAD_DESPAWN_ALPHA: 0,
|
||||||
|
DEAD_DESPAWN_DELAY_MS: 5000,
|
||||||
FRAME_WIDTH: 100,
|
FRAME_WIDTH: 100,
|
||||||
FRAME_HEIGHT: 100,
|
FRAME_HEIGHT: 100,
|
||||||
HITBOX_WIDTH: 22,
|
HITBOX_WIDTH: 22,
|
||||||
HITBOX_HEIGHT: 20,
|
HITBOX_HEIGHT: 20,
|
||||||
HITBOX_OFFSET_X: 39,
|
HITBOX_OFFSET_X: 39,
|
||||||
HITBOX_OFFSET_Y: 40,
|
HITBOX_OFFSET_Y: 40,
|
||||||
NICKNAME_LENGTH: 18,
|
NICKNAME_LENGTH: 24,
|
||||||
// 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수
|
// 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수
|
||||||
ANIMATION_OPTIONS: {
|
ANIMATION_OPTIONS: {
|
||||||
attack: { frameRate: 15, repeat: 0 },
|
attack: { frameRate: 15, repeat: 0 },
|
||||||
@@ -72,21 +79,54 @@ export const FIGHTER = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 도메인
|
// 3. SPAWN 도메인
|
||||||
export const SPAWN = {
|
export const SPAWN = {
|
||||||
DEFAULT_TEAM_SIZE: 5,
|
|
||||||
DEFAULT_PLACEMENT: "random",
|
DEFAULT_PLACEMENT: "random",
|
||||||
PLACEMENTS: {
|
PLACEMENTS: {
|
||||||
RANDOM: "random",
|
RANDOM: "random",
|
||||||
STARTING_ZONES: "starting-zones",
|
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_RADIUS: 2,
|
||||||
STARTING_ZONE_FILL_ALPHA: 0.07,
|
STARTING_ZONE_FILL_ALPHA: 0.07,
|
||||||
STARTING_ZONE_BORDER_ALPHA: 0.14,
|
STARTING_ZONE_BORDER_ALPHA: 0.14,
|
||||||
STARTING_ZONE_VISIBLE_DURATION_MS: 5000,
|
STARTING_ZONE_VISIBLE_DURATION_MS: 2000,
|
||||||
PRESENTATION_TEAM_COUNT: 10,
|
PRESENTATION_TEAM_COUNT: 10,
|
||||||
PRESENTATION_TEAM_SIZE: 5,
|
PRESENTATION_TEAM_SIZE: 5,
|
||||||
MAX_TEAM_SIZE: 100,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. COMBAT 도메인
|
// 4. COMBAT 도메인
|
||||||
@@ -116,54 +156,79 @@ export const PROJECTILE = {
|
|||||||
|
|
||||||
// 6. WORLD_EFFECT 도메인
|
// 6. WORLD_EFFECT 도메인
|
||||||
export const WORLD_EFFECT = {
|
export const WORLD_EFFECT = {
|
||||||
INTERVAL: 4000,
|
// Delay from match start until the first barrage.
|
||||||
AREA_TILES: 5,
|
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,
|
FRAMES: 7,
|
||||||
FRAME_RATE: 14,
|
FRAME_RATE: 14,
|
||||||
FALL_DURATION: 920,
|
FALL_DURATION: 920,
|
||||||
FALL_TRAVEL_TILES: 8,
|
FALL_TRAVEL_TILES: 8,
|
||||||
VISUAL_SCALE: 12,
|
METEOR_SHAKE_DURATION_MS: 150,
|
||||||
METEOR_DAMAGE: 80,
|
METEOR_SHAKE_INTENSITY: 0.004,
|
||||||
FROST_DAMAGE: 40,
|
METEOR_DAMAGE: 90,
|
||||||
|
FROST_DAMAGE: 45,
|
||||||
FROST_STUN_DURATION: 2000,
|
FROST_STUN_DURATION: 2000,
|
||||||
FROST_STUN_TINT: 0x82e9ff,
|
FROST_STUN_TINT: 0x82e9ff,
|
||||||
FROST_DURATION: 20000,
|
FROST_DURATION: 2000,
|
||||||
FROST_SPEED_MULTIPLIER: 0.55,
|
FROST_SPEED_MULTIPLIER: 0.55,
|
||||||
|
SUDDEN_DEATH: {
|
||||||
|
ENABLED: false,
|
||||||
|
TRIGGER_MS: 10000,
|
||||||
|
INTERVAL_MS: 2000,
|
||||||
|
FORCE_FROST: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 7. CAMERA 도메인
|
// 7. CAMERA 도메인
|
||||||
export const CAMERA = {
|
export const CAMERA = {
|
||||||
MIN_ZOOM: 1,
|
MIN_ZOOM: RENDER_SIZE / ARENA_SIZE,
|
||||||
MAX_ZOOM: 3,
|
MAX_ZOOM: 3,
|
||||||
ZOOM_STEP: 0.1,
|
ZOOM_STEP: 0.1,
|
||||||
// 자동 관전 진입 전 화염/냉기 메테오 낙하 위치를 임시로 확대 추적합니다.
|
// 자동 관전 진입 전 화염/냉기 메테오 낙하 위치를 임시로 확대 추적합니다.
|
||||||
METEOR_FOCUS_ENABLED: true,
|
METEOR_FOCUS_ENABLED: false,
|
||||||
METEOR_FOCUS_ZOOM: 2,
|
METEOR_FOCUS_ZOOM: 2,
|
||||||
SPECTATOR_LERP: 0.1,
|
SPECTATOR_LERP: 0.05,
|
||||||
// 메테오 착탄 후 카메라를 해당 위치에 유지하는 시간(ms)입니다.
|
// 메테오 착탄 후 카메라를 해당 위치에 유지하는 시간(ms)입니다.
|
||||||
METEOR_FOCUS_HOLD_DURATION: 1200,
|
METEOR_FOCUS_HOLD_DURATION: 1200,
|
||||||
SPECTATOR_FINAL_FIGHTER_THRESHOLD: 5,
|
SPECTATOR_FINAL_FIGHTER_THRESHOLD: 5,
|
||||||
SPECTATOR_FINAL_FIGHT_ZOOM: 3,
|
SPECTATOR_FINAL_FIGHT_ZOOM: 2,
|
||||||
SPECTATOR_FINAL_TEAM_COUNT: 2,
|
SPECTATOR_FINAL_TEAM_COUNT: 2,
|
||||||
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD: 8,
|
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD: 8,
|
||||||
SPECTATOR_RANDOM_FOCUS_INTERVAL: 10000,
|
SPECTATOR_RANDOM_FOCUS_INTERVAL: 100000,
|
||||||
SPECTATOR_LATE_FIGHTER_THRESHOLD: 30,
|
SPECTATOR_LATE_FIGHTER_THRESHOLD: 80,
|
||||||
SPECTATOR_LATE_FIGHT_ZOOM: 2,
|
SPECTATOR_LATE_FIGHT_ZOOM: 1,
|
||||||
SELECTED_FIGHTER_ZOOM: 2,
|
LARGE_BATTLE_START_ZOOM: 0.8,
|
||||||
|
SELECTED_FIGHTER_ZOOM: 0.8,
|
||||||
|
MANUAL_FOCUS_TWEEN_MS: 220,
|
||||||
|
MANUAL_FOCUS_TWEEN_EASE: "Sine.easeInOut",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 8. UI 도메인
|
// 8. UI 도메인
|
||||||
export const UI = {
|
export const UI = {
|
||||||
MINIMAP_ALPHA: 0.8,
|
MINIMAP_ALPHA: 0.8,
|
||||||
MINIMAP_MARGIN: Math.round(ARENA_SIZE * 0.016),
|
MINIMAP_MARGIN: Math.round(RENDER_SIZE * 0.016),
|
||||||
MINIMAP_VIEWPORT_SIZE: Math.round(ARENA_SIZE * 0.22),
|
MINIMAP_VIEWPORT_SIZE: Math.round(RENDER_SIZE * 0.22),
|
||||||
MINIMAP_VIEW_FRAME_STROKE: 10,
|
MINIMAP_VIEW_FRAME_STROKE: Math.max(3, Math.round(RENDER_SIZE * 0.003125)),
|
||||||
SELECTED_FIGHTER_OUTLINE_GAP: 1,
|
SELECTED_FIGHTER_OUTLINE_GAP: 1,
|
||||||
SELECTED_FIGHTER_OUTLINE_WIDTH: 1,
|
SELECTED_FIGHTER_OUTLINE_WIDTH: 1,
|
||||||
SELECTED_FIGHTER_OUTLINE_RED: 255,
|
SELECTED_FIGHTER_OUTLINE_RED: 255,
|
||||||
SELECTED_FIGHTER_OUTLINE_GREEN: 228,
|
SELECTED_FIGHTER_OUTLINE_GREEN: 228,
|
||||||
SELECTED_FIGHTER_OUTLINE_BLUE: 64,
|
SELECTED_FIGHTER_OUTLINE_BLUE: 64,
|
||||||
SELECTED_FIGHTER_OUTLINE_ALPHA: 0.65,
|
SELECTED_FIGHTER_OUTLINE_ALPHA: 0.65,
|
||||||
|
// 상단 종족별 사망 통계 공지 설정
|
||||||
|
BATTLE_NOTICE_DELAY_MS: 5000,
|
||||||
|
BATTLE_NOTICE_VISIBLE_MS: 2000,
|
||||||
|
BATTLE_NOTICE_INTERVAL_MS: 10000,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 9. TEAM 도메인
|
// 9. TEAM 도메인
|
||||||
|
|||||||
+1273
-105
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,10 @@
|
|||||||
import Phaser from "phaser";
|
|
||||||
import {
|
import {
|
||||||
CAMERA,
|
CAMERA,
|
||||||
} from "../../constants.js";
|
} from "../../constants.js";
|
||||||
|
import {
|
||||||
|
fighterDistanceSquared,
|
||||||
|
fighterWorldPoint,
|
||||||
|
} from "../fighter/fighterAdapter.js";
|
||||||
|
|
||||||
export function getSpectatorState(livingFighters) {
|
export function getSpectatorState(livingFighters) {
|
||||||
const livingFighterCount = livingFighters.length;
|
const livingFighterCount = livingFighters.length;
|
||||||
@@ -87,16 +90,11 @@ export function averageFighterPosition(fighters) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function fighterCameraPoint(fighter) {
|
export function fighterCameraPoint(fighter) {
|
||||||
const target = fighter?.body?.center ?? fighter;
|
if (!fighter) {
|
||||||
|
|
||||||
if (!target) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return fighterWorldPoint(fighter);
|
||||||
x: target.x,
|
|
||||||
y: target.y,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findClosestOpponentPair(fighters) {
|
export function findClosestOpponentPair(fighters) {
|
||||||
@@ -115,7 +113,7 @@ export function findClosestOpponentPair(fighters) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const distance = Phaser.Math.Distance.Between(fighter.x, fighter.y, candidate.x, candidate.y);
|
const distance = fighterDistanceSquared(fighter, candidate);
|
||||||
|
|
||||||
if (distance < closestDistance) {
|
if (distance < closestDistance) {
|
||||||
closestDistance = distance;
|
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));
|
||||||
|
}
|
||||||
+1629
-139
File diff suppressed because it is too large
Load Diff
+329
-57
@@ -1,6 +1,7 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import {
|
import {
|
||||||
ARENA,
|
ARENA,
|
||||||
|
PERFORMANCE,
|
||||||
WORLD_EFFECT,
|
WORLD_EFFECT,
|
||||||
} from "../../constants.js";
|
} from "../../constants.js";
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +9,12 @@ import {
|
|||||||
disposeCombatObject,
|
disposeCombatObject,
|
||||||
trackCombatObject,
|
trackCombatObject,
|
||||||
} from "./combat.js";
|
} from "./combat.js";
|
||||||
|
import {
|
||||||
|
clearFighterTint,
|
||||||
|
fighterWorldPoint,
|
||||||
|
stopFighterMovement,
|
||||||
|
tintFighter,
|
||||||
|
} from "../fighter/fighterAdapter.js";
|
||||||
|
|
||||||
const METEOR_EFFECT_PATH = "assets/effects/world_Effect.png";
|
const METEOR_EFFECT_PATH = "assets/effects/world_Effect.png";
|
||||||
const METEOR_EFFECT_KEY = "world-meteor-effect";
|
const METEOR_EFFECT_KEY = "world-meteor-effect";
|
||||||
@@ -57,11 +64,31 @@ export function startWorldEffects(scene) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
scene.worldEffectTimer = scene.time.addEvent({
|
scene.matchStartedAt = scene.time.now;
|
||||||
callback: () => triggerWorldEffect(scene),
|
scene.isSuddenDeath = false;
|
||||||
delay: WORLD_EFFECT.INTERVAL,
|
|
||||||
loop: true,
|
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) {
|
export function clearWorldEffects(scene) {
|
||||||
@@ -69,6 +96,10 @@ export function clearWorldEffects(scene) {
|
|||||||
scene.worldEffectTimer = null;
|
scene.worldEffectTimer = null;
|
||||||
scene.worldEffectZones?.clear();
|
scene.worldEffectZones?.clear();
|
||||||
scene.clearMeteorCameraFocus?.(null, { restoreCamera: false });
|
scene.clearMeteorCameraFocus?.(null, { restoreCamera: false });
|
||||||
|
scene.matchStartedAt = null;
|
||||||
|
scene.isSuddenDeath = false;
|
||||||
|
scene.nextWorldEffectModifierRefreshAt = 0;
|
||||||
|
scene.worldEffectModifierActive = false;
|
||||||
|
|
||||||
scene.fighters?.forEach((fighter) => {
|
scene.fighters?.forEach((fighter) => {
|
||||||
fighter.worldEffectSpeedMultiplier = 1;
|
fighter.worldEffectSpeedMultiplier = 1;
|
||||||
@@ -81,6 +112,20 @@ export function updateWorldEffectModifiers(scene) {
|
|||||||
(zone) => zone.marker?.active,
|
(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) => {
|
scene.fighters.forEach((fighter) => {
|
||||||
const isSlowed =
|
const isSlowed =
|
||||||
fighter.active
|
fighter.active
|
||||||
@@ -106,60 +151,163 @@ function triggerWorldEffect(scene) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = livingFighters[Phaser.Math.Between(0, livingFighters.length - 1)];
|
const zone = findDensestWorldEffectZone(livingFighters);
|
||||||
const zone = createEffectZone(target);
|
|
||||||
|
|
||||||
if (Phaser.Math.Between(0, 1) === 0) {
|
// Sudden Death 상태이고 냉기 고정 설정이 되어있으면 무조건 냉기 메테오
|
||||||
spawnMeteor(scene, zone);
|
if ((scene.isSuddenDeath && WORLD_EFFECT.SUDDEN_DEATH.FORCE_FROST) || Phaser.Math.Between(0, 1) === 0) {
|
||||||
|
spawnFrostZone(scene, zone);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
spawnFrostZone(scene, zone);
|
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) {
|
function spawnMeteor(scene, zone) {
|
||||||
const marker = createZoneMarker(scene, zone, METEOR_ZONE_COLOR);
|
spawnWorldEffectBarrage(scene, zone, {
|
||||||
scene.beginMeteorCameraFocus?.(zone);
|
color: METEOR_ZONE_COLOR,
|
||||||
|
damage: WORLD_EFFECT.METEOR_DAMAGE,
|
||||||
dropWorldEffectSprite(scene, zone, {
|
|
||||||
effectKey: METEOR_EFFECT_KEY,
|
effectKey: METEOR_EFFECT_KEY,
|
||||||
onCancel: () => {
|
|
||||||
scene.clearMeteorCameraFocus?.(zone);
|
|
||||||
disposeCombatObject(scene, marker);
|
|
||||||
},
|
|
||||||
onImpact: () => {
|
|
||||||
scene.tweens.killTweensOf(marker);
|
|
||||||
marker.setAlpha(1);
|
|
||||||
scene.cameras.main.shake(150, 0.004);
|
|
||||||
resolveImpactDamage(scene, zone, WORLD_EFFECT.METEOR_DAMAGE);
|
|
||||||
},
|
|
||||||
onAnimationComplete: () => {
|
|
||||||
scene.clearMeteorCameraFocus?.(zone);
|
|
||||||
disposeCombatObject(scene, marker);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnFrostZone(scene, zone) {
|
function spawnFrostZone(scene, zone) {
|
||||||
const marker = createZoneMarker(scene, zone, FROST_ZONE_COLOR);
|
spawnWorldEffectBarrage(scene, zone, {
|
||||||
scene.beginMeteorCameraFocus?.(zone);
|
color: FROST_ZONE_COLOR,
|
||||||
|
damage: WORLD_EFFECT.FROST_DAMAGE,
|
||||||
dropWorldEffectSprite(scene, zone, {
|
|
||||||
effectKey: FROST_EFFECT_KEY,
|
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: () => {
|
onCancel: () => {
|
||||||
scene.clearMeteorCameraFocus?.(zone);
|
disposeCombatObject(scene, impactMarker);
|
||||||
disposeCombatObject(scene, marker);
|
finishImpact();
|
||||||
},
|
},
|
||||||
onImpact: () => {
|
onImpact: () => {
|
||||||
resolveImpactDamage(scene, zone, WORLD_EFFECT.FROST_DAMAGE, (fighter) => {
|
scene.tweens.killTweensOf(impactMarker);
|
||||||
applyFrostStun(scene, fighter);
|
impactMarker.setAlpha(1);
|
||||||
});
|
applyMeteorImpactShake(scene, impactZone);
|
||||||
|
resolveImpactDamage(
|
||||||
|
scene,
|
||||||
|
impactZone,
|
||||||
|
damage,
|
||||||
|
isFrost ? (fighter) => applyFrostStun(scene, fighter) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
if (!scene.matchOver) {
|
if (isFrost && !scene.matchOver) {
|
||||||
activateFrostZone(scene, zone, marker);
|
activateFrostZone(scene, impactZone, impactMarker);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAnimationComplete: () => scene.clearMeteorCameraFocus?.(zone),
|
onAnimationComplete: () => {
|
||||||
|
if (!isFrost) {
|
||||||
|
disposeCombatObject(scene, impactMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
finishImpact();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingTimers.push(timer);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +321,7 @@ function dropWorldEffectSprite(
|
|||||||
const sprite = scene.add
|
const sprite = scene.add
|
||||||
.sprite(trajectory.startX, trajectory.startY, effectKey, 0)
|
.sprite(trajectory.startX, trajectory.startY, effectKey, 0)
|
||||||
.setDepth(3)
|
.setDepth(3)
|
||||||
.setScale(WORLD_EFFECT.VISUAL_SCALE)
|
.setScale(resolveWorldEffectVisualScale(zone))
|
||||||
.setFlipX(trajectory.flipX)
|
.setFlipX(trajectory.flipX)
|
||||||
.setAngle(trajectory.angle)
|
.setAngle(trajectory.angle)
|
||||||
.setAlpha(0.9);
|
.setAlpha(0.9);
|
||||||
@@ -212,6 +360,40 @@ function worldEffectAnimationKey(effectKey) {
|
|||||||
return `${effectKey}-anim`;
|
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) {
|
function createFallTrajectory(zone) {
|
||||||
const distance = ARENA.TILE_SIZE * WORLD_EFFECT.FALL_TRAVEL_TILES;
|
const distance = ARENA.TILE_SIZE * WORLD_EFFECT.FALL_TRAVEL_TILES;
|
||||||
const isLeftHalf = zone.centerX < ARENA.SIZE / 2;
|
const isLeftHalf = zone.centerX < ARENA.SIZE / 2;
|
||||||
@@ -225,19 +407,115 @@ function createFallTrajectory(zone) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEffectZone(target) {
|
function createBarrageImpactZones(targetZone) {
|
||||||
const size = ARENA.TILE_SIZE * WORLD_EFFECT.AREA_TILES;
|
const minimumCount = Math.max(1, Math.round(Number(WORLD_EFFECT.IMPACT_COUNT_MIN) || 1));
|
||||||
const centerX = target.body?.center.x ?? target.x;
|
const maximumCount = Math.max(
|
||||||
const centerY = target.body?.center.y ?? target.y;
|
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 {
|
return {
|
||||||
|
areaTiles,
|
||||||
bounds: new Phaser.Geom.Rectangle(centerX - size / 2, centerY - size / 2, size, size),
|
bounds: new Phaser.Geom.Rectangle(centerX - size / 2, centerY - size / 2, size, size),
|
||||||
centerX,
|
centerX,
|
||||||
centerY,
|
centerY,
|
||||||
marker: null,
|
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) {
|
function createZoneMarker(scene, zone, color) {
|
||||||
const marker = scene.add.graphics().setDepth(1.5);
|
const marker = scene.add.graphics().setDepth(1.5);
|
||||||
const { x, y, width, height } = zone.bounds;
|
const { x, y, width, height } = zone.bounds;
|
||||||
@@ -248,7 +526,7 @@ function createZoneMarker(scene, zone, color) {
|
|||||||
marker.strokeRect(x, y, width, height);
|
marker.strokeRect(x, y, width, height);
|
||||||
marker.lineStyle(1, color, 0.34);
|
marker.lineStyle(1, color, 0.34);
|
||||||
|
|
||||||
for (let index = 1; index < WORLD_EFFECT.AREA_TILES; index += 1) {
|
for (let index = 1; index < zone.areaTiles; index += 1) {
|
||||||
const offset = index * ARENA.TILE_SIZE;
|
const offset = index * ARENA.TILE_SIZE;
|
||||||
marker.lineBetween(x + offset, y, x + offset, y + height);
|
marker.lineBetween(x + offset, y, x + offset, y + height);
|
||||||
marker.lineBetween(x, y + offset, x + width, y + offset);
|
marker.lineBetween(x, y + offset, x + width, y + offset);
|
||||||
@@ -298,9 +576,8 @@ function applyFrostStun(scene, fighter) {
|
|||||||
|
|
||||||
fighter.frostStunTimer?.remove(false);
|
fighter.frostStunTimer?.remove(false);
|
||||||
fighter.isFrostStunned = true;
|
fighter.isFrostStunned = true;
|
||||||
fighter.body?.setVelocity(0, 0);
|
stopFighterMovement(fighter);
|
||||||
fighter.setTint(WORLD_EFFECT.FROST_STUN_TINT);
|
tintFighter(fighter, WORLD_EFFECT.FROST_STUN_TINT);
|
||||||
fighter.teamMarker?.setTint(WORLD_EFFECT.FROST_STUN_TINT).setAlpha(1);
|
|
||||||
fighter.frostStunTimer = scene.time.delayedCall(WORLD_EFFECT.FROST_STUN_DURATION, () => {
|
fighter.frostStunTimer = scene.time.delayedCall(WORLD_EFFECT.FROST_STUN_DURATION, () => {
|
||||||
clearFrostStun(fighter);
|
clearFrostStun(fighter);
|
||||||
});
|
});
|
||||||
@@ -311,13 +588,7 @@ function clearFrostStun(fighter) {
|
|||||||
fighter.frostStunTimer = null;
|
fighter.frostStunTimer = null;
|
||||||
fighter.isFrostStunned = false;
|
fighter.isFrostStunned = false;
|
||||||
|
|
||||||
if (fighter.active) {
|
clearFighterTint(fighter);
|
||||||
fighter.clearTint();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fighter.teamMarker?.active) {
|
|
||||||
fighter.teamMarker.setTint(fighter.teamColor).setAlpha(0.8);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateFrostZone(scene, zone, marker) {
|
function activateFrostZone(scene, zone, marker) {
|
||||||
@@ -351,8 +622,9 @@ function activateFrostZone(scene, zone, marker) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function containsFighter(zone, fighter) {
|
function containsFighter(zone, fighter) {
|
||||||
const x = fighter.body?.center.x ?? fighter.x;
|
const point = fighterWorldPoint(fighter);
|
||||||
const y = fighter.body?.center.y ?? fighter.y;
|
const x = point.x;
|
||||||
|
const y = point.y;
|
||||||
|
|
||||||
return Phaser.Geom.Rectangle.Contains(zone.bounds, x, y);
|
return Phaser.Geom.Rectangle.Contains(zone.bounds, x, y);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,28 +1,29 @@
|
|||||||
import {
|
import {
|
||||||
FIGHTER,
|
FIGHTER,
|
||||||
COMBAT,
|
COMBAT,
|
||||||
UI,
|
|
||||||
} from "../../constants.js";
|
} from "../../constants.js";
|
||||||
|
|
||||||
const SOURCE_ALPHA_THRESHOLD = 8;
|
|
||||||
const HEAL_EFFECT_PATH = "assets/effects/heal/Heal_Effect.png";
|
const HEAL_EFFECT_PATH = "assets/effects/heal/Heal_Effect.png";
|
||||||
const HEAL_EFFECT_KEY = "kill-heal-effect";
|
const HEAL_EFFECT_KEY = "kill-heal-effect";
|
||||||
const HEAL_EFFECT_ANIMATION_KEY = `${HEAL_EFFECT_KEY}-anim`;
|
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}`;
|
return `${skin.key}-${action}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fighterAnimationKey(skin, action) {
|
export function fighterAnimationKey(skin, action, teamColor) {
|
||||||
return `${fighterSheetKey(skin, action)}-anim`;
|
return `${fighterSheetKey(skin, action, teamColor)}-anim`;
|
||||||
}
|
|
||||||
|
|
||||||
export function fighterOutlineSheetKey(skin, action) {
|
|
||||||
return `${fighterSheetKey(skin, action)}-outline`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fighterOutlineSheetKeyFromSheetKey(sheetKey) {
|
|
||||||
return `${sheetKey}-outline`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fighterAttackEffectKey(skin) {
|
export function fighterAttackEffectKey(skin) {
|
||||||
@@ -82,8 +83,6 @@ export function createFighterAnimations(scene, skins) {
|
|||||||
repeat,
|
repeat,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createFighterOutlineSheet(scene, skin, action, animation.frames);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
createAttackEffectAnimation(scene, skin);
|
createAttackEffectAnimation(scene, skin);
|
||||||
@@ -92,6 +91,47 @@ export function createFighterAnimations(scene, skins) {
|
|||||||
createHealEffectAnimation(scene);
|
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) {
|
function preloadCombatAssets(scene, skin) {
|
||||||
const projectile = skin.combat?.projectile;
|
const projectile = skin.combat?.projectile;
|
||||||
const attackEffect = skin.combat?.attackEffect;
|
const attackEffect = skin.combat?.attackEffect;
|
||||||
@@ -149,18 +189,18 @@ function createHealEffectAnimation(scene) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFighterOutlineSheet(scene, skin, action, frameCount) {
|
function createFighterTeamShadowSheet(scene, skin, action, frameCount, teamColor) {
|
||||||
const key = fighterOutlineSheetKey(skin, action);
|
const key = fighterSheetKey(skin, action, teamColor);
|
||||||
|
|
||||||
if (scene.textures.exists(key)) {
|
if (scene.textures.exists(key)) {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceTexture = scene.textures.get(fighterSheetKey(skin, action));
|
const sourceTexture = scene.textures.get(fighterSheetKey(skin, action));
|
||||||
const sourceImage = sourceTexture?.getSourceImage?.();
|
const sourceImage = sourceTexture?.getSourceImage?.();
|
||||||
|
|
||||||
if (!sourceImage) {
|
if (!sourceImage) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sheetWidth = FIGHTER.FRAME_WIDTH * frameCount;
|
const sheetWidth = FIGHTER.FRAME_WIDTH * frameCount;
|
||||||
@@ -172,90 +212,60 @@ function createFighterOutlineSheet(scene, skin, action, frameCount) {
|
|||||||
const sourceContext = sourceCanvas.getContext("2d", { willReadFrequently: true });
|
const sourceContext = sourceCanvas.getContext("2d", { willReadFrequently: true });
|
||||||
sourceContext.drawImage(sourceImage, 0, 0);
|
sourceContext.drawImage(sourceImage, 0, 0);
|
||||||
|
|
||||||
const sourceData = sourceContext.getImageData(0, 0, sheetWidth, sheetHeight).data;
|
const sourceImageData = sourceContext.getImageData(0, 0, sheetWidth, sheetHeight);
|
||||||
const outlineCanvas = document.createElement("canvas");
|
const sourceData = sourceImageData.data;
|
||||||
outlineCanvas.width = sheetWidth;
|
const shadowColor = parseHexColor(teamColor);
|
||||||
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(UI.SELECTED_FIGHTER_OUTLINE_ALPHA * 255);
|
|
||||||
|
|
||||||
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
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 y = TEAM_SHADOW_FRAME_Y_START; y < TEAM_SHADOW_FRAME_Y_END; y += 1) {
|
||||||
for (let x = 0; x < FIGHTER.FRAME_WIDTH; x += 1) {
|
for (let x = 0; x < FIGHTER.FRAME_WIDTH; x += 1) {
|
||||||
const sourceIndex = ((y * sheetWidth) + frameLeft + x) * 4;
|
const sourceIndex = ((y * sheetWidth) + frameLeft + x) * 4;
|
||||||
|
|
||||||
if (sourceData[sourceIndex + 3] <= SOURCE_ALPHA_THRESHOLD) {
|
if (!isTeamShadowPixel(sourceData, sourceIndex)) {
|
||||||
continue;
|
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);
|
sourceContext.putImageData(sourceImageData, 0, 0);
|
||||||
outlineContext.putImageData(outlineImage, 0, 0);
|
scene.textures.addSpriteSheet(key, sourceCanvas, {
|
||||||
scene.textures.addSpriteSheet(key, outlineCanvas, {
|
|
||||||
frameWidth: FIGHTER.FRAME_WIDTH,
|
frameWidth: FIGHTER.FRAME_WIDTH,
|
||||||
frameHeight: FIGHTER.FRAME_HEIGHT,
|
frameHeight: FIGHTER.FRAME_HEIGHT,
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, sourceX, sourceY) {
|
function isTeamShadowPixel(data, index) {
|
||||||
const outerRadius = UI.SELECTED_FIGHTER_OUTLINE_GAP + UI.SELECTED_FIGHTER_OUTLINE_WIDTH;
|
return (
|
||||||
|
data[index + 3] > 0
|
||||||
for (
|
&& data[index] === TEAM_SHADOW_SOURCE_COLOR.red
|
||||||
let offsetY = -outerRadius;
|
&& data[index + 1] === TEAM_SHADOW_SOURCE_COLOR.green
|
||||||
offsetY <= outerRadius;
|
&& data[index + 2] === TEAM_SHADOW_SOURCE_COLOR.blue
|
||||||
offsetY += 1
|
);
|
||||||
) {
|
|
||||||
const targetY = sourceY + offsetY;
|
|
||||||
|
|
||||||
if (targetY < 0 || targetY >= FIGHTER.FRAME_HEIGHT) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (
|
function normalizeTeamColorKey(teamColor) {
|
||||||
let offsetX = -outerRadius;
|
return parseHexColor(teamColor).hex;
|
||||||
offsetX <= outerRadius;
|
|
||||||
offsetX += 1
|
|
||||||
) {
|
|
||||||
const targetX = sourceX + offsetX;
|
|
||||||
|
|
||||||
if (targetX < 0 || targetX >= FIGHTER.FRAME_WIDTH) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const maskIndex = (targetY * sheetWidth) + frameLeft + targetX;
|
function parseHexColor(teamColor) {
|
||||||
const distance = Math.max(Math.abs(offsetX), Math.abs(offsetY));
|
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;
|
return {
|
||||||
|
blue: parseInt(normalizedHex.slice(4, 6), 16),
|
||||||
if (distance <= UI.SELECTED_FIGHTER_OUTLINE_GAP) {
|
green: parseInt(normalizedHex.slice(2, 4), 16),
|
||||||
gapMask[maskIndex] = 1;
|
hex: normalizedHex,
|
||||||
}
|
red: parseInt(normalizedHex.slice(0, 2), 16),
|
||||||
}
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import {
|
import {
|
||||||
FIGHTER,
|
FIGHTER,
|
||||||
|
PERFORMANCE,
|
||||||
} from "../../constants.js";
|
} from "../../constants.js";
|
||||||
import {
|
import {
|
||||||
fighterAnimationKey,
|
ensureFighterTeamAnimation,
|
||||||
fighterOutlineSheetKeyFromSheetKey,
|
ensureFighterTeamAnimations,
|
||||||
fighterSheetKey,
|
fighterSheetKey,
|
||||||
} from "./fighterAssets.js";
|
} from "./fighterAssets.js";
|
||||||
|
import {
|
||||||
|
disableFighterBody,
|
||||||
|
enableFighterBody,
|
||||||
|
} from "./fighterAdapter.js";
|
||||||
|
import {
|
||||||
|
attachFighterModel,
|
||||||
|
createFighterModel,
|
||||||
|
fighterModelPoint,
|
||||||
|
} from "./fighterModel.js";
|
||||||
import { getFighterStats } from "./fighterStats.js";
|
import { getFighterStats } from "./fighterStats.js";
|
||||||
|
|
||||||
const NAME_LABEL_BOTTOM_GAP = 14;
|
const HUD_DETAIL_SYNC_INTERVAL_MS = 100;
|
||||||
|
|
||||||
export function createFighter(
|
export function createFighter(
|
||||||
scene,
|
scene,
|
||||||
{ canSplitOnDeath = true, faceLeft, hp, maxHp, name, skin, team, teamIndex, x, y },
|
{ 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);
|
ensureFighterTeamAnimations(scene, skin, team.color, ["idle"]);
|
||||||
const teamColor = Phaser.Display.Color.HexStringToColor(team.color).color;
|
|
||||||
|
const teamIdleSheetKey = fighterSheetKey(skin, "idle", team.color);
|
||||||
|
const idleSheetKey = scene.textures.exists(teamIdleSheetKey)
|
||||||
|
? teamIdleSheetKey
|
||||||
|
: fighterSheetKey(skin, "idle");
|
||||||
const displayName = name || team.label;
|
const displayName = name || team.label;
|
||||||
const combatStats = getFighterStats(skin);
|
const combatStats = getFighterStats(skin);
|
||||||
const resolvedMaxHp = Math.max(1, Math.round(maxHp ?? combatStats.maxHp));
|
const resolvedMaxHp = Math.max(1, Math.round(maxHp ?? combatStats.maxHp));
|
||||||
@@ -24,6 +39,32 @@ export function createFighter(
|
|||||||
resolvedMaxHp,
|
resolvedMaxHp,
|
||||||
Math.max(1, Math.round(hp ?? 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._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.setScale(FIGHTER.SCALE);
|
||||||
fighter.setName(displayName);
|
fighter.setName(displayName);
|
||||||
@@ -33,140 +74,240 @@ export function createFighter(
|
|||||||
fighter.setFlipX(faceLeft);
|
fighter.setFlipX(faceLeft);
|
||||||
fighter.body.setSize(FIGHTER.HITBOX_WIDTH, FIGHTER.HITBOX_HEIGHT);
|
fighter.body.setSize(FIGHTER.HITBOX_WIDTH, FIGHTER.HITBOX_HEIGHT);
|
||||||
fighter.body.setOffset(FIGHTER.HITBOX_OFFSET_X, FIGHTER.HITBOX_OFFSET_Y);
|
fighter.body.setOffset(FIGHTER.HITBOX_OFFSET_X, FIGHTER.HITBOX_OFFSET_Y);
|
||||||
fighter.setInteractive(
|
fighter.setInteractive(inputHitArea, Phaser.Geom.Rectangle.Contains);
|
||||||
new Phaser.Geom.Rectangle(
|
|
||||||
FIGHTER.HITBOX_OFFSET_X,
|
|
||||||
FIGHTER.HITBOX_OFFSET_Y,
|
|
||||||
FIGHTER.HITBOX_WIDTH,
|
|
||||||
FIGHTER.HITBOX_HEIGHT,
|
|
||||||
),
|
|
||||||
Phaser.Geom.Rectangle.Contains,
|
|
||||||
);
|
|
||||||
fighter.input.cursor = "pointer";
|
fighter.input.cursor = "pointer";
|
||||||
|
|
||||||
fighter.teamMarker = scene.add
|
fighter._inputHitArea = inputHitArea;
|
||||||
.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.combatStats = combatStats;
|
|
||||||
fighter.fighterName = displayName;
|
|
||||||
fighter.team = team;
|
|
||||||
fighter.teamColor = teamColor;
|
|
||||||
fighter.teamIndex = teamIndex;
|
|
||||||
fighter.baseScaleX = FIGHTER.SCALE;
|
fighter.baseScaleX = FIGHTER.SCALE;
|
||||||
fighter.baseScaleY = FIGHTER.SCALE;
|
fighter.baseScaleY = FIGHTER.SCALE;
|
||||||
fighter.canSplitOnDeath = canSplitOnDeath;
|
fighter.deadDespawnTimer = null;
|
||||||
fighter.isSelected = false;
|
fighter.deadDespawnTween = null;
|
||||||
fighter.killCount = 0;
|
|
||||||
fighter.killRewardMultiplier = 1;
|
|
||||||
fighter.worldEffectSpeedMultiplier = 1;
|
|
||||||
fighter.isFrostStunned = false;
|
|
||||||
fighter.frostStunTimer = null;
|
fighter.frostStunTimer = null;
|
||||||
fighter.maxHp = resolvedMaxHp;
|
fighter.nextHudSyncAt = 0;
|
||||||
fighter.hp = resolvedHp;
|
fighter._hudDetailsVisible = false;
|
||||||
fighter.nextAttackAt = 0;
|
fighter._hudSlot = null;
|
||||||
fighter.isLocked = false;
|
fighter.releaseHud = () => releaseFighterHud(fighter);
|
||||||
fighter.isDead = false;
|
fighter.play(ensureFighterTeamAnimation(scene, skin, "walk", team.color));
|
||||||
fighter.play(fighterAnimationKey(skin, "walk"));
|
|
||||||
|
|
||||||
fighter.on(Phaser.Animations.Events.ANIMATION_COMPLETE, (animation) => {
|
fighter.on(Phaser.Animations.Events.ANIMATION_COMPLETE, (animation) => {
|
||||||
if (fighter.isDead) {
|
if (fighter.isDead) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (animation.key.includes("-attack") || animation.key.endsWith("-hurt-anim")) {
|
if (animation.key.includes("-attack") || animation.key.includes("-hurt")) {
|
||||||
fighter.isLocked = false;
|
fighter.isLocked = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!attachSprite) {
|
||||||
|
setFighterDetailVisible(fighter, false);
|
||||||
|
}
|
||||||
|
|
||||||
attachHudCleanup(fighter);
|
attachHudCleanup(fighter);
|
||||||
syncFighterHud(fighter);
|
|
||||||
|
|
||||||
return fighter;
|
return fighter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncFighterHud(fighter) {
|
export function setFighterDetailVisible(fighter, visible) {
|
||||||
const isVisible = Boolean(fighter.active && !fighter.isDead);
|
if (!fighter || !fighter.scene) {
|
||||||
|
|
||||||
fighter.nameLabel.setVisible(isVisible);
|
|
||||||
fighter.healthBack.setVisible(isVisible);
|
|
||||||
fighter.healthBar.setVisible(isVisible);
|
|
||||||
syncTeamMarker(fighter);
|
|
||||||
|
|
||||||
if (!isVisible || !fighter.body) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 scaleRatio = Math.max(1, Math.abs(fighter.scaleY) / FIGHTER.SCALE);
|
||||||
const healthOffset = 44 * scaleRatio;
|
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);
|
hudSlot.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
|
||||||
fighter.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
|
hudSlot.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
|
||||||
fighter.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
|
hudSlot.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? 1)));
|
||||||
fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? 1)));
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncTeamMarker(fighter) {
|
export function releaseFighterHud(fighter) {
|
||||||
const marker = fighter.teamMarker;
|
const hudSlot = fighter?._hudSlot;
|
||||||
|
|
||||||
if (!marker) {
|
if (!hudSlot) {
|
||||||
|
if (fighter) {
|
||||||
|
fighter._hudDetailsVisible = false;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isVisible = Boolean(fighter.active && !fighter.isDead);
|
setHudSlotVisible(hudSlot, false);
|
||||||
marker.setVisible(isVisible);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const outlineTextureKey = fighterOutlineSheetKeyFromSheetKey(fighter.texture.key);
|
fighter._hudDetailsVisible = visible;
|
||||||
|
setHudSlotVisible(hudSlot, visible);
|
||||||
if (fighter.scene.textures.exists(outlineTextureKey)) {
|
|
||||||
marker.setTexture(outlineTextureKey, fighter.frame.name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
marker.setPosition(fighter.x, fighter.y);
|
function setVisibleIfChanged(gameObject, visible) {
|
||||||
marker.setScale(fighter.scaleX, fighter.scaleY);
|
if (gameObject && gameObject.visible !== visible) {
|
||||||
marker.setFlipX(fighter.flipX);
|
gameObject.setVisible(visible);
|
||||||
marker.setDepth(fighter.depth - 0.1);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function attachHudCleanup(fighter) {
|
||||||
const originalDestroy = fighter.destroy.bind(fighter);
|
const originalDestroy = fighter.destroy.bind(fighter);
|
||||||
|
|
||||||
fighter.destroy = (...args) => {
|
fighter.destroy = (...args) => {
|
||||||
fighter.teamMarker.destroy();
|
releaseFighterHud(fighter);
|
||||||
fighter.nameLabel.destroy();
|
|
||||||
fighter.healthBack.destroy();
|
|
||||||
fighter.healthBar.destroy();
|
|
||||||
originalDestroy(...args);
|
originalDestroy(...args);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,37 +1,50 @@
|
|||||||
import { ARENA, SPAWN, TEAM } from "../../constants.js";
|
import { ARENA, SPAWN, TEAM } from "../../constants.js";
|
||||||
|
|
||||||
|
const NAME_MULTIPLIER_REGEX = /\*(\d+)$/;
|
||||||
|
|
||||||
export function createMatchSetup(
|
export function createMatchSetup(
|
||||||
names,
|
names,
|
||||||
requestedTeamSize = SPAWN.DEFAULT_TEAM_SIZE,
|
|
||||||
requestedSpawnPlacement = SPAWN.DEFAULT_PLACEMENT,
|
requestedSpawnPlacement = SPAWN.DEFAULT_PLACEMENT,
|
||||||
) {
|
) {
|
||||||
const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || SPAWN.DEFAULT_TEAM_SIZE));
|
const teams = names.map((rawName, index) => {
|
||||||
const teams = names.map((name, 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;
|
||||||
|
|
||||||
|
return {
|
||||||
color: TEAM.getColor(index, names.length),
|
color: TEAM.getColor(index, names.length),
|
||||||
id: `team-${index + 1}`,
|
id: `team-${index + 1}`,
|
||||||
label: name,
|
label,
|
||||||
size: teamSize,
|
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 =
|
const startingZones =
|
||||||
requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES
|
requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES
|
||||||
? createStartingZones(teams)
|
? createStartingZones(teams)
|
||||||
: [];
|
: [];
|
||||||
const spawns = createSpawnPoints(
|
const spawns = createSpawnPoints(
|
||||||
names.length,
|
totalFighters,
|
||||||
teamSize,
|
|
||||||
requestedSpawnPlacement,
|
requestedSpawnPlacement,
|
||||||
startingZones,
|
startingZones,
|
||||||
);
|
);
|
||||||
|
|
||||||
const fighters = [];
|
const fighters = [];
|
||||||
names.forEach((name, teamIndex) => {
|
teams.forEach((team) => {
|
||||||
for (let i = 0; i < teamSize; i++) {
|
for (let i = 0; i < team.size; i++) {
|
||||||
const globalIndex = teamIndex * teamSize + i;
|
const globalIndex = fighters.length;
|
||||||
fighters.push({
|
fighters.push({
|
||||||
...spawns[globalIndex],
|
...spawns[globalIndex],
|
||||||
name: name,
|
name: team.label,
|
||||||
team: teams[teamIndex],
|
team: team,
|
||||||
teamIndex: i,
|
teamIndex: i,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -44,55 +57,67 @@ export function createMatchSetup(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
export function matchStatusText(teams) {
|
||||||
const totalFighters = teams.reduce((sum, team) => sum + team.size, 0);
|
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(" / ");
|
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) {
|
function createSpawnPoints(totalCount, requestedSpawnPlacement, startingZones) {
|
||||||
const teamCount = Math.ceil(playerCount / teamSize);
|
|
||||||
|
|
||||||
return Array.from({ length: teamCount }, (_, index) => ({
|
|
||||||
color: TEAM.getColor(index, teamCount),
|
|
||||||
id: `team-${index + 1}`,
|
|
||||||
label: `Team ${index + 1}`,
|
|
||||||
size: Math.min(teamSize, playerCount - index * teamSize),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSpawnPoints(teamCount, teamSize, requestedSpawnPlacement, startingZones) {
|
|
||||||
if (requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES) {
|
if (requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES) {
|
||||||
return createStartingZoneSpawnPoints(startingZones, teamSize);
|
return createStartingZoneSpawnPoints(startingZones);
|
||||||
}
|
}
|
||||||
|
|
||||||
return createRandomSpawnPoints(teamCount * teamSize);
|
return createRandomSpawnPoints(totalCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRandomSpawnPoints(count) {
|
function createRandomSpawnPoints(count) {
|
||||||
return createSpawnPointsFromSlots(createSpawnSlots(), count);
|
return createSpawnPointsFromSlots(createSpawnSlots(), count);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStartingZoneSpawnPoints(startingZones, teamSize) {
|
function createStartingZoneSpawnPoints(startingZones) {
|
||||||
const fallbackSlots = createSpawnSlots();
|
const fallbackSlots = createSpawnSlots();
|
||||||
|
|
||||||
return startingZones.flatMap((zone) => {
|
return startingZones.flatMap((zone) => {
|
||||||
const zoneSlots = createSpawnSlots(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) {
|
function createStartingZones(teams) {
|
||||||
const layout = shuffle(createStartingZoneLayout(teams.length));
|
const totalZonesNeeded = teams.reduce((sum, team) => sum + team.startingZoneCount, 0);
|
||||||
|
const layout = shuffle(createStartingZoneLayout(totalZonesNeeded));
|
||||||
|
|
||||||
return teams.map((team, index) => ({
|
let layoutIndex = 0;
|
||||||
...layout[index],
|
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,
|
color: team.color,
|
||||||
|
spawnCount,
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
}));
|
});
|
||||||
|
remainingFighters -= spawnCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return teamZones;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStartingZoneLayout(teamCount) {
|
function createStartingZoneLayout(teamCount) {
|
||||||
@@ -191,20 +216,6 @@ function startingZonesOverlap(left, right) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTeamSize(playerCount, requestedTeamSize) {
|
|
||||||
const teamSize = clamp(
|
|
||||||
Math.round(Number(requestedTeamSize) || SPAWN.DEFAULT_TEAM_SIZE),
|
|
||||||
1,
|
|
||||||
SPAWN.MAX_TEAM_SIZE,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (playerCount <= teamSize) {
|
|
||||||
return Math.max(1, Math.ceil(playerCount / 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
return teamSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
function spawnJitter() {
|
function spawnJitter() {
|
||||||
return (Math.random() - 0.5) * ARENA.TILE_SIZE * 0.36;
|
return (Math.random() - 0.5) * ARENA.TILE_SIZE * 0.36;
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-6
@@ -1,7 +1,7 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { ArenaScene } from "./game/arena/ArenaScene.js";
|
import { ArenaScene } from "./game/arena/ArenaScene.js";
|
||||||
import {
|
import {
|
||||||
ARENA,
|
RENDER,
|
||||||
SPAWN,
|
SPAWN,
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
import { createMatchForm } from "./ui/matchForm.js";
|
import { createMatchForm } from "./ui/matchForm.js";
|
||||||
@@ -56,6 +56,10 @@ function startConfiguredMatch(matchConfig) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!arenaScene.startMatch(matchConfig)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
appNode?.classList.remove("match-ended");
|
appNode?.classList.remove("match-ended");
|
||||||
appNode?.classList.add("match-live");
|
appNode?.classList.add("match-live");
|
||||||
|
|
||||||
@@ -65,14 +69,15 @@ function startConfiguredMatch(matchConfig) {
|
|||||||
openOptionsDrawer({ focus: false });
|
openOptionsDrawer({ focus: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
arenaScene.startMatch(matchConfig);
|
|
||||||
syncPauseButton();
|
syncPauseButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPresentationMatchConfig() {
|
function getPresentationMatchConfig() {
|
||||||
return {
|
return {
|
||||||
names: Array.from({ length: SPAWN.PRESENTATION_TEAM_COUNT }, (_, index) => `Player ${index + 1}`),
|
names: Array.from(
|
||||||
teamSize: SPAWN.PRESENTATION_TEAM_SIZE,
|
{ length: SPAWN.PRESENTATION_TEAM_COUNT },
|
||||||
|
(_, index) => `Player ${index + 1}*${SPAWN.PRESENTATION_TEAM_SIZE}`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,15 +174,18 @@ window.addEventListener("keydown", (event) => {
|
|||||||
const arenaScene = new ArenaScene({
|
const arenaScene = new ArenaScene({
|
||||||
getInitialMatchConfig: getPresentationMatchConfig,
|
getInitialMatchConfig: getPresentationMatchConfig,
|
||||||
onMatchEnd: handleMatchEnd,
|
onMatchEnd: handleMatchEnd,
|
||||||
|
setPlayerNamesWarning: matchForm.setPlayerNamesWarning,
|
||||||
setStatus: matchForm.setStatus,
|
setStatus: matchForm.setStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
const game = new Phaser.Game({
|
const game = new Phaser.Game({
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
parent: "game",
|
parent: "game",
|
||||||
width: ARENA.SIZE,
|
width: RENDER.WIDTH,
|
||||||
height: ARENA.SIZE,
|
height: RENDER.HEIGHT,
|
||||||
|
autoRound: true,
|
||||||
pixelArt: true,
|
pixelArt: true,
|
||||||
|
powerPreference: "high-performance",
|
||||||
backgroundColor: "#282819",
|
backgroundColor: "#282819",
|
||||||
physics: {
|
physics: {
|
||||||
default: "arcade",
|
default: "arcade",
|
||||||
|
|||||||
@@ -124,11 +124,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: min(420px, 72vmin, calc(100vw - 64px));
|
width: auto;
|
||||||
|
max-width: min(640px, calc(100vw - 40px));
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
border: 1px solid rgb(238 185 73 / 0.26);
|
border: 1px solid rgb(238 185 73 / 0.26);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px 14px;
|
padding: 8px 18px;
|
||||||
background: rgb(8 10 7 / 0.68);
|
background: rgb(8 10 7 / 0.68);
|
||||||
color: #ffe8b4;
|
color: #ffe8b4;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -136,6 +137,7 @@
|
|||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-shadow: 1px 1px 2px #000;
|
text-shadow: 1px 1px 2px #000;
|
||||||
|
white-space: nowrap;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translate(-50%, -10px);
|
transform: translate(-50%, -10px);
|
||||||
|
|||||||
@@ -100,11 +100,6 @@
|
|||||||
padding-block: 9px;
|
padding-block: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app.match-live .team-size-number {
|
|
||||||
width: 64px;
|
|
||||||
min-width: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app.match-live .spawn-placement-option span {
|
#app.match-live .spawn-placement-option span {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
|||||||
+67
-15
@@ -462,21 +462,6 @@ legend {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-size-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-size-number {
|
|
||||||
width: 88px;
|
|
||||||
min-width: 88px;
|
|
||||||
padding-inline: 10px;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
label {
|
||||||
color: #ead8ad;
|
color: #ead8ad;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
@@ -500,6 +485,73 @@ textarea {
|
|||||||
line-height: 1.45;
|
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"] {
|
input[type="range"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
accent-color: #e3b24f;
|
accent-color: #e3b24f;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { UI } from "../constants.js";
|
||||||
|
|
||||||
const SPECIES_KEYS = ["human", "orc", "skeleton", "slime", "wolf", "bear"];
|
const SPECIES_KEYS = ["human", "orc", "skeleton", "slime", "wolf", "bear"];
|
||||||
const SPECIES_LABELS = {
|
const SPECIES_LABELS = {
|
||||||
bear: "곰",
|
bear: "곰",
|
||||||
@@ -22,9 +24,12 @@ const DEATH_NOTICE_TEMPLATES = [
|
|||||||
"{species}{particle} 전투 중 {count}명 쓰러졌습니다. 관중석은 침착한 척하는 중입니다.",
|
"{species}{particle} 전투 중 {count}명 쓰러졌습니다. 관중석은 침착한 척하는 중입니다.",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const BATTLE_NOTICE_DELAY_MS = 5000;
|
const SYSTEM_TIP_TEMPLATES = [
|
||||||
export const BATTLE_NOTICE_VISIBLE_MS = 2000;
|
"경보: 화염 메테오는 낙하 지점 5x5 영역에 강력한 폭발 피해를 입힙니다!",
|
||||||
export const BATTLE_NOTICE_INTERVAL_MS = 10000;
|
"주의: 냉기 메테오는 피해와 함께 2초간 동결 및 냉각을 유발합니다.",
|
||||||
|
"팁: 근접 캐릭터는 20% 확률로 치명타를 터뜨려 적을 즉사시킵니다.",
|
||||||
|
"성장: 적 처치 시 체력을 30% 회복하며, 크기와 속도가 최대 5배까지 커집니다.",
|
||||||
|
];
|
||||||
|
|
||||||
export function createDeathCounts() {
|
export function createDeathCounts() {
|
||||||
return SPECIES_KEYS.reduce((counts, species) => {
|
return SPECIES_KEYS.reduce((counts, species) => {
|
||||||
@@ -42,7 +47,8 @@ export function normalizeDeathCounts(value = {}) {
|
|||||||
|
|
||||||
export function addDeathCounts(baseCounts, matchCounts) {
|
export function addDeathCounts(baseCounts, matchCounts) {
|
||||||
return SPECIES_KEYS.reduce((counts, species) => {
|
return SPECIES_KEYS.reduce((counts, species) => {
|
||||||
counts[species] = (baseCounts?.[species] ?? 0) + (matchCounts?.[species] ?? 0);
|
counts[species] =
|
||||||
|
(baseCounts?.[species] ?? 0) + (matchCounts?.[species] ?? 0);
|
||||||
return counts;
|
return counts;
|
||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
@@ -52,15 +58,24 @@ export function normalizeSpecies(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createDeathNoticeMessage(deathsBySpecies, seed = 0) {
|
export function createDeathNoticeMessage(deathsBySpecies, seed = 0) {
|
||||||
const topSpecies = SPECIES_KEYS
|
// 3번에 한 번꼴로 시스템 팁 출력
|
||||||
.map((species) => ({ species, count: deathsBySpecies?.[species] ?? 0 }))
|
if (seed % 3 === 0) {
|
||||||
.sort((left, right) => right.count - left.count)[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) {
|
if (!topSpecies || topSpecies.count === 0) {
|
||||||
return "오늘 사망자 집계는 아직 0명입니다. 이 평화가 얼마나 버틸까요?";
|
return "오늘 사망자 집계는 아직 0명입니다. 이 평화가 얼마나 버틸까요?";
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = DEATH_NOTICE_TEMPLATES[
|
const template =
|
||||||
|
DEATH_NOTICE_TEMPLATES[
|
||||||
(topSpecies.count + seed) % DEATH_NOTICE_TEMPLATES.length
|
(topSpecies.count + seed) % DEATH_NOTICE_TEMPLATES.length
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+29
-67
@@ -3,50 +3,51 @@ import { FIGHTER, SPAWN } from "../constants.js";
|
|||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
names: "arena.match.playerNames",
|
names: "arena.match.playerNames",
|
||||||
spawnPlacement: "arena.match.spawnPlacement",
|
spawnPlacement: "arena.match.spawnPlacement",
|
||||||
teamSize: "arena.match.teamSize",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createMatchForm() {
|
export function createMatchForm() {
|
||||||
const form = getElement("#fighter-form");
|
const form = getElement("#fighter-form");
|
||||||
const namesInput = getElement("#player-names");
|
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 appNode = document.querySelector("#app");
|
||||||
const statusNode = document.querySelector("#match-status");
|
const statusNode = document.querySelector("#match-status");
|
||||||
const statusTextNodes = document.querySelectorAll("[data-status-text]");
|
const statusTextNodes = document.querySelectorAll("[data-status-text]");
|
||||||
const spawnPlacementInputs = getElements('input[name="spawnPlacement"]');
|
const spawnPlacementInputs = getElements('input[name="spawnPlacement"]');
|
||||||
const teamSizeInput = getElement("#team-size");
|
const setPlayerNamesWarning = (warning = null) => {
|
||||||
const teamSizeNumberInput = getElement("#team-size-value");
|
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 = () => ({
|
const readMatchConfig = () => ({
|
||||||
names: nicknameValues(namesInput.value),
|
names: nicknameValues(namesInput.value),
|
||||||
spawnPlacement: selectedSpawnPlacement(spawnPlacementInputs),
|
spawnPlacement: selectedSpawnPlacement(spawnPlacementInputs),
|
||||||
teamSize: Number(teamSizeInput.value),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
restoreSavedMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput, teamSizeNumberInput);
|
restoreSavedMatchSettings(namesInput, spawnPlacementInputs);
|
||||||
syncTeamSizeInputs(teamSizeInput, teamSizeNumberInput);
|
|
||||||
namesInput.addEventListener("input", () => {
|
namesInput.addEventListener("input", () => {
|
||||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
setPlayerNamesWarning();
|
||||||
});
|
saveMatchSettings(namesInput, spawnPlacementInputs);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
spawnPlacementInputs.forEach((input) => {
|
spawnPlacementInputs.forEach((input) => {
|
||||||
input.addEventListener("change", () => {
|
input.addEventListener("change", () => {
|
||||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
saveMatchSettings(namesInput, spawnPlacementInputs);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ export function createMatchForm() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
readMatchConfig,
|
readMatchConfig,
|
||||||
|
setPlayerNamesWarning,
|
||||||
setStatus(message) {
|
setStatus(message) {
|
||||||
if (statusNode) {
|
if (statusNode) {
|
||||||
statusNode.setAttribute("aria-hidden", "false");
|
statusNode.setAttribute("aria-hidden", "false");
|
||||||
@@ -100,25 +102,7 @@ function nicknameValues(value) {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncTeamSizeInputs(rangeInput, numberInput, value = rangeInput.value) {
|
function restoreSavedMatchSettings(namesInput, spawnPlacementInputs) {
|
||||||
const normalizedTeamSize = normalizeTeamSize(value, rangeInput);
|
|
||||||
|
|
||||||
if (!normalizedTeamSize) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
rangeInput.value = normalizedTeamSize;
|
|
||||||
numberInput.value = normalizedTeamSize;
|
|
||||||
|
|
||||||
return normalizedTeamSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreSavedMatchSettings(
|
|
||||||
namesInput,
|
|
||||||
spawnPlacementInputs,
|
|
||||||
teamSizeInput,
|
|
||||||
teamSizeNumberInput,
|
|
||||||
) {
|
|
||||||
const storage = getLocalStorage();
|
const storage = getLocalStorage();
|
||||||
|
|
||||||
if (!storage) {
|
if (!storage) {
|
||||||
@@ -128,27 +112,18 @@ function restoreSavedMatchSettings(
|
|||||||
try {
|
try {
|
||||||
const savedNames = storage.getItem(STORAGE_KEYS.names);
|
const savedNames = storage.getItem(STORAGE_KEYS.names);
|
||||||
const savedSpawnPlacement = storage.getItem(STORAGE_KEYS.spawnPlacement);
|
const savedSpawnPlacement = storage.getItem(STORAGE_KEYS.spawnPlacement);
|
||||||
const savedTeamSize = storage.getItem(STORAGE_KEYS.teamSize);
|
|
||||||
|
|
||||||
if (savedNames !== null) {
|
if (savedNames !== null) {
|
||||||
namesInput.value = savedNames;
|
namesInput.value = savedNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedTeamSize = normalizeTeamSize(savedTeamSize, teamSizeInput);
|
|
||||||
|
|
||||||
syncTeamSizeInputs(
|
|
||||||
teamSizeInput,
|
|
||||||
teamSizeNumberInput,
|
|
||||||
normalizedTeamSize || teamSizeInput.value,
|
|
||||||
);
|
|
||||||
|
|
||||||
setSpawnPlacement(spawnPlacementInputs, savedSpawnPlacement);
|
setSpawnPlacement(spawnPlacementInputs, savedSpawnPlacement);
|
||||||
} catch {
|
} catch {
|
||||||
// Storage may be unavailable in private or restricted browser contexts.
|
// Storage may be unavailable in private or restricted browser contexts.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput) {
|
function saveMatchSettings(namesInput, spawnPlacementInputs) {
|
||||||
const storage = getLocalStorage();
|
const storage = getLocalStorage();
|
||||||
|
|
||||||
if (!storage) {
|
if (!storage) {
|
||||||
@@ -158,7 +133,6 @@ function saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput) {
|
|||||||
try {
|
try {
|
||||||
storage.setItem(STORAGE_KEYS.names, namesInput.value);
|
storage.setItem(STORAGE_KEYS.names, namesInput.value);
|
||||||
storage.setItem(STORAGE_KEYS.spawnPlacement, selectedSpawnPlacement(spawnPlacementInputs));
|
storage.setItem(STORAGE_KEYS.spawnPlacement, selectedSpawnPlacement(spawnPlacementInputs));
|
||||||
storage.setItem(STORAGE_KEYS.teamSize, normalizeTeamSize(teamSizeInput.value, teamSizeInput));
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore storage failures so the match form remains usable.
|
// Ignore storage failures so the match form remains usable.
|
||||||
}
|
}
|
||||||
@@ -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() {
|
function getLocalStorage() {
|
||||||
try {
|
try {
|
||||||
return window.localStorage;
|
return window.localStorage;
|
||||||
|
|||||||
@@ -229,7 +229,7 @@
|
|||||||
- **조치 사항**:
|
- **조치 사항**:
|
||||||
- 생존 캐릭터가 30명 미만이거나 최종 2팀만 남으면 후반 자동 줌과 교전 중심 포커싱이 시작되도록 관전 조건을 확장.
|
- 생존 캐릭터가 30명 미만이거나 최종 2팀만 남으면 후반 자동 줌과 교전 중심 포커싱이 시작되도록 관전 조건을 확장.
|
||||||
- 치명타의 `Critical!` 표기와 즉시 처치는 유지하면서 카메라 흔들림을 제거.
|
- 치명타의 `Critical!` 표기와 즉시 처치는 유지하면서 카메라 흔들림을 제거.
|
||||||
- 화염 메테오가 착탄할 때 화면 흔들림을 적용하고, 냉각지대 착탄과 피해 계산에서는 흔들림을 분리.
|
- 화염 메테오 착탄 화면 흔들림을 먼저 적용했으며, 이후 모든 메테오 착탄이 크기 기반 흔들림을 공유하도록 확장.
|
||||||
|
|
||||||
37. 자동 관전 이전 메테오 임시 포커싱 추가 (완료)
|
37. 자동 관전 이전 메테오 임시 포커싱 추가 (완료)
|
||||||
- **조치 사항**:
|
- **조치 사항**:
|
||||||
@@ -260,4 +260,205 @@
|
|||||||
- 새로운 CSS 모듈 구조와 디자인 원칙을 설명하는 `context/style.md` 문서를 신규 생성.
|
- 새로운 CSS 모듈 구조와 디자인 원칙을 설명하는 `context/style.md` 문서를 신규 생성.
|
||||||
- `agent.md`의 상세 기술 가이드(Context Routing) 섹션에 스타일 및 디자인 항목을 추가하여 문서 접근성 개선.
|
- `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