Optimize large battle rendering and combat
This commit is contained in:
parent
30d7be41be
commit
5795cc9741
371
agent.md
371
agent.md
|
|
@ -1,170 +1,267 @@
|
||||||
# Update: Focused Combat Effects In Large Battles
|
|
||||||
|
|
||||||
- When the live fighter count reaches `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD`, supplemental combat visuals are suppressed unless a meteor camera focus is active.
|
|
||||||
- Large-battle suppression covers critical-hit labels, instant-spell attack sprites, kill-heal sprites, and kill-growth tweens; damage, critical kills, healing values, and growth multipliers remain unchanged.
|
|
||||||
- Meteor/frost world-effect visuals remain visible because they establish the temporary camera focus. Projectile visuals remain active because their current objects also perform hit detection.
|
|
||||||
|
|
||||||
# Update: Dense-Area Meteor Barrage
|
|
||||||
|
|
||||||
- Fire and frost world effects now target the `WORLD_EFFECT.AREA_TILES` tile square containing the highest living-fighter density instead of a random fighter location.
|
|
||||||
- Each activation renders that large warning area, then drops `WORLD_EFFECT.IMPACT_COUNT_MIN` to `IMPACT_COUNT_MAX` smaller strikes within it. Only the smaller impact zones apply damage, frost, and lingering slow areas.
|
|
||||||
- `WORLD_EFFECT.WARNING_DURATION_MS` tunes how long the large targeting warning remains visible. `IMPACT_AREA_TILES`, `IMPACT_STAGGER_MS`, and `IMPACT_VISUAL_SCALE` tune the barrage footprint, rhythm, and sprite size, while `SIZE_SCALE_VARIANCE` randomizes individual impact scale.
|
|
||||||
- `WORLD_EFFECT.INTERVAL` delays the first barrage from match start; `WORLD_EFFECT.REPEAT_INTERVAL` controls later normal barrages, while sudden-death repetition continues to use `SUDDEN_DEATH.INTERVAL_MS`.
|
|
||||||
- Meteor screen shake scales from the same size multiplier, with base values in `WORLD_EFFECT.METEOR_SHAKE_DURATION_MS` and `WORLD_EFFECT.METEOR_SHAKE_INTENSITY`.
|
|
||||||
|
|
||||||
# Update: Direct Fighter Counts And Spawn Zones
|
|
||||||
|
|
||||||
- Live match entries interpret a suffix such as `Alice*250` as that team's assigned fighter count; entries without a suffix receive one assigned fighter.
|
|
||||||
- The former team-size inputs are removed. Presentation mode retains its fixed preview size through suffixed internal entries.
|
|
||||||
- `SPAWN.MAX_FIGHTER_COUNT` caps only fighters assigned through participant input. Slime `spawnMultiplier` and `splitOnDeath` additions are game traits and are not counted against that input cap.
|
|
||||||
- Match-start validation shows a styled fighter-cap warning card beneath the participant nickname input, emphasizes requested and allowed counts separately, and clears when names are edited or a valid match is submitted.
|
|
||||||
- For starting-zone placement, `SPAWN.FIGHTERS_PER_STARTING_ZONE` defines how many assigned fighters share each team zone.
|
|
||||||
|
|
||||||
# Update: Large Battle Performance
|
|
||||||
|
|
||||||
- Combat target acquisition now builds a per-frame spatial grid so every fighter that needs a fresh target can search nearby cells instead of scanning the full battlefield array.
|
|
||||||
- Large battle thresholds and related tuning live in `PERFORMANCE` inside `src/constants.js`, including target grid size, HUD pool size, minimap dot size, and large-battle corpse despawn delay.
|
|
||||||
- Fighter name/health HUD objects are pooled. Fighters no longer own permanent text/bar objects; selected and zoom-visible nearby fighters borrow HUD slots.
|
|
||||||
- The minimap is separated from the field camera. During live matches, `ArenaScene` draws a lightweight graphics minimap through a dedicated `minimap-hud` camera while the main camera ignores the minimap object and the HUD camera ignores field objects. Presentation/waiting mode hides the minimap.
|
|
||||||
- Dead fighter despawn switches to the large-battle delay when the current fighter count reaches `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD`.
|
|
||||||
|
|
||||||
# Update: Dead Fighter Despawn
|
|
||||||
|
|
||||||
- Dead fighters now keep their initial opacity at death, then fade out over `FIGHTER.DEAD_DESPAWN_DELAY_MS` before being removed.
|
|
||||||
- Adjust the corpse lifetime in `src/constants.js` by changing `FIGHTER.DEAD_DESPAWN_DELAY_MS`; adjust the final fade target with `FIGHTER.DEAD_DESPAWN_ALPHA`.
|
|
||||||
- Despawn uses the Phaser scene timer and a matching tween so pause/state cleanup follows the existing match lifecycle.
|
|
||||||
|
|
||||||
# Update: Team Shadow Rendering
|
|
||||||
|
|
||||||
- Team color is now represented by recoloring the built-in floor shadow pixels on each fighter spritesheet instead of rendering a duplicated `teamMarker` sprite.
|
|
||||||
- `fighterAssets.js` owns lazy team-shadow texture and animation generation for actual `skin + action + teamColor` combinations. Avoid pre-generating every team/skin/action combination because that can move the bottleneck into startup texture creation and memory use.
|
|
||||||
- `fighterFactory.js` should keep each fighter to one Phaser sprite. Name labels and health bars remain separate HUD objects, but there is no per-fighter team marker sprite to synchronize.
|
|
||||||
- `combat.js` must resolve action animations through `ensureFighterTeamAnimation()` so action changes keep the team-colored shadow.
|
|
||||||
- Frost stun uses body tint only. Do not use tint for persistent team identity.
|
|
||||||
|
|
||||||
# Agent: Arena Picker
|
# Agent: Arena Picker
|
||||||
|
|
||||||
## 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, 타깃, 피해, 처치 처리
|
||||||
...
|
│ │ ├── aggregateCombatWorker.js# detached/offscreen 집계 전투 worker
|
||||||
## 7. 주요 기능 상세 (New)
|
│ │ ├── combatSettings.js # 전투 속도 및 이동 배율 설정
|
||||||
|
│ │ ├── arenaFinalCombatEffects.js
|
||||||
### 7.1 닉네임 배수 시스템 (Multi-Spawn)
|
│ │ └── worldEffects.js # 밀집 구역 메테오/냉기/감속/동결 효과
|
||||||
- 사용자가 닉네임 뒤에 `*N` (예: `홍길동*2`)을 입력하면 해당 팀은 기본 팀 인원의 N배만큼 생성됩니다.
|
│ ├── fighter/
|
||||||
- 스타팅 존 모드에서 배수만큼의 독립된 스폰 지점이 할당되어 전략적인 분산 배치가 이루어집니다.
|
│ │ ├── fighterModel.js # 순수 JS fighter 상태 모델
|
||||||
- 닉네임 표시 시 `*N` 접미사는 자동으로 제거되어 깔끔한 UI를 유지합니다.
|
│ │ ├── fighterAdapter.js # Phaser Sprite/Physics 접근 경계
|
||||||
|
│ │ ├── fighterAssets.js # sprite load, team-shadow texture/animation 생성
|
||||||
### 7.2 서든 데스 (Sudden Death) 시스템
|
│ │ ├── fighterFactory.js # Sprite 생성, model bridge, HUD pool, detail visibility
|
||||||
- 매치 시작 후 일정 시간(기본 8초)이 경과하면 전장의 환경이 극도로 위험해지는 서든 데스 상태에 진입합니다.
|
│ │ ├── fighterManifest.js # 캐릭터 스탯/종족/특성 정의
|
||||||
- 메테오 생성 주기가 비약적으로 단축(기본 1초)되며, 빙결 효과를 가진 냉기 메테오가 집중 투하됩니다.
|
│ │ ├── fighterStats.js # melee/ranged/magic 프로필 해석
|
||||||
- `constants.js`를 통해 활성화 여부, 시작 시간, 주기 등을 간편하게 조정할 수 있습니다.
|
│ │ └── fighterSelection.js # 캐릭터 선택/셔플 로직
|
||||||
|
│ └── match/
|
||||||
### 7.3 밀집 구역 기반 월드 이펙트 포격
|
│ ├── matchSetup.js # `닉네임*N` 파싱, 팀 구성, spawn 좌표 계산
|
||||||
- 월드 이펙트는 랜덤 생존자 대신 `WORLD_EFFECT.AREA_TILES` 크기 범위 중 현재 생존 캐릭터가 가장 많이 모인 위치를 표적으로 선택합니다.
|
│ └── arenaMatchRuntime.js # match 진행 중 helper
|
||||||
- 선택 범위를 먼저 경고로 표시한 뒤, 그 내부에 작은 화염 또는 냉기 메테오를 3~4발 분산 투하합니다.
|
└── ui/
|
||||||
- 피해, 기절, 냉각 감속은 큰 경고 범위 전체가 아니라 각각의 작은 탄착 영역에만 적용됩니다.
|
├── matchForm.js # 설정 폼 및 localStorage 유지
|
||||||
|
├── aboutDialog.js # About dialog 및 Markdown 표시
|
||||||
└── ui/ # UI 컴포넌트 및 API 연동
|
├── visitorCounter.js # 방문자 API 호출/표시
|
||||||
├── arenaKillLog.js # [New] 독립된 킬로그 DOM 조작 모듈
|
├── dailyMetrics.js # 일일 지표 API 호출
|
||||||
├── arenaScoreboard.js # [New] 팀 스코어 badge 업데이트 모듈
|
├── deathStats.js # 사망 통계 API 호출
|
||||||
├── battleDeathNotice.js # [New] 상단 사망 공지 메시지 및 UI 관리
|
├── arenaScoreboard.js # 팀 badge 및 선택 상태
|
||||||
├── victoryCelebration.js # [New] 승리 축하 연출 (DOM/Audio) 모듈
|
├── arenaKillLog.js # kill log DOM
|
||||||
├── matchForm.js # 설정 폼 제어 및 localStorage 유지
|
├── battleDeathNotice.js# 상단 사망/통계 안내
|
||||||
├── aboutDialog.js # About 다이얼로그, 개발자정보/개인정보처리방침 표시
|
└── victoryCelebration.js
|
||||||
├── deathStats.js # 사망 통계 API 호출 래퍼
|
|
||||||
└── visitorCounter.js # 방문자 체크 API 호출 및 표시
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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:
|
|
||||||
- `GET /api/health`: 서버 및 MongoDB 설정 여부 확인.
|
|
||||||
- `POST /api/visitors/check`: 현재 브라우저 방문자를 체크하고 유니크 방문자 수를 반환.
|
|
||||||
- `GET /api/visitors/stats`: 전체 유니크 방문자 수 조회.
|
|
||||||
- `GET /api/about`: 데이터베이스에서 실시간으로 개발자정보와 개인정보처리방침 Markdown 조회 (캐시 없이 즉시 반영).
|
|
||||||
- `GET /api/death-stats/today`: 오늘의 종족별 전투 사망 통계 조회.
|
|
||||||
- `POST /api/death-stats/today`: 종료된 전투의 종족별 사망 수를 오늘 집계에 누적.
|
|
||||||
|
|
||||||
## 6. 관련 문서
|
기본 API:
|
||||||
|
|
||||||
- [CONTEXT.md](./CONTEXT.md): 상세 개발 가이드 및 핵심 로직 설명 (필독)
|
- `GET /api/health`: 서버 및 MongoDB 설정 여부 확인.
|
||||||
- [todo.md](./todo.md): 작업 내역 및 잔여 이슈 관리
|
- `POST /api/visitors/check`: 방문자 UUID 쿠키 확인/발급 및 유니크 방문자 수 반환.
|
||||||
|
- `GET /api/visitors/stats`: 전체 유니크 방문자 수 조회.
|
||||||
|
- `GET /api/daily-metrics/today`: 오늘의 운영 지표 조회.
|
||||||
|
- `POST /api/daily-metrics/match-started`: 실제 전투 시작 수 누적.
|
||||||
|
- `POST /api/daily-metrics/match-finished`: 실제 전투 종료 수 누적.
|
||||||
|
- `POST /api/daily-metrics/donation-clicked`: 후원 클릭 수 누적용 예약 API.
|
||||||
|
- `GET /api/death-stats/today`: 오늘의 종족별 전투 사망 통계 조회.
|
||||||
|
- `POST /api/death-stats/today`: 종료된 실제 전투의 종족별 사망 수 누적.
|
||||||
|
- `GET /api/about`: 개발자정보와 개인정보처리방침 Markdown 조회.
|
||||||
|
|
||||||
|
## 8. 유지보수 규칙
|
||||||
|
|
||||||
|
- **문서 동기화**: 구조, 상수, API, 대규모 전투 path가 바뀌면 `agent.md`와 관련 `context/*.md`를 함께 갱신합니다.
|
||||||
|
- **모듈 경계**: `ArenaScene`은 orchestration을 맡고, fighter 상태/렌더 세부는 `fighter/`, 전투 판정은 `combat/`, match input/spawn은 `match/`, DOM UI는 `ui/`로 분리합니다.
|
||||||
|
- **Fighter 접근**: 새 코드가 fighter body, animation, tint, velocity, position을 직접 다뤄야 한다면 먼저 `fighterAdapter.js`에 적절한 helper가 있는지 확인합니다.
|
||||||
|
- **Model-first 안전성**: `fighterForModelId()`는 null을 반환할 수 있습니다. model-only 전투, stale id, death/unregister 이후 상태를 항상 고려합니다.
|
||||||
|
- **대규모 전투 성능**: 8,000명급 경로에서는 전체 fighter 배열을 매 프레임 스캔하거나 DOM/HUD/Graphics를 전원 갱신하지 않습니다. throttle, pool, worker, spatial index, set diff를 우선 사용합니다.
|
||||||
|
- **Phaser lifecycle**: parked Sprite는 display/update list와 Arcade World에서 모두 빠져야 하며, reattach 시 model 좌표와 body를 동기화합니다.
|
||||||
|
- **이펙트 lifecycle**: pooled combat object는 `releaseToPool`/`disposeCombatObject()` 경로로 정리합니다. 새 이펙트도 match reset과 scene cleanup에서 누수되지 않아야 합니다.
|
||||||
|
- **API 변경**: `/api/*` 경로는 Fastify route가 담당합니다. 개발 모드에서 Vite SPA fallback이 API 요청을 가로채지 않게 유지합니다.
|
||||||
|
- **신규 캐릭터**: `public/assets/characters/`에 에셋을 배치하고 `fighterManifest.js`에 `species`와 combat/stat 정의를 추가합니다. 사망 통계 종족은 `human`, `orc`, `skeleton`, `slime`, `wolf`, `bear` 중 하나를 사용합니다.
|
||||||
|
- **스타일 변경**: `src/styles.css`는 모듈 import 엔트리입니다. 실제 수정은 `src/styles/*.css`의 해당 영역에서 진행합니다.
|
||||||
|
|
||||||
|
## 9. 관련 문서
|
||||||
|
|
||||||
|
- [context/core.md](./context/core.md): 전역 설정, 성능 상수, 렌더/worker 가이드.
|
||||||
|
- [context/arena.md](./context/arena.md): 아레나 씬, 카메라, minimap, render LOD.
|
||||||
|
- [context/combat.md](./context/combat.md): 전투 AI, 집계 전투, projectile/world effect.
|
||||||
|
- [context/fighter.md](./context/fighter.md): FighterModel, adapter, factory, assets.
|
||||||
|
- [context/match-ui.md](./context/match-ui.md): 매치 입력, spawn, HUD, 모바일 UI.
|
||||||
|
- [context/server.md](./context/server.md): Fastify/MongoDB API.
|
||||||
|
- [context/style.md](./context/style.md): CSS 모듈 및 디자인 규칙.
|
||||||
|
- [todo.md](./todo.md): 작업 내역 및 잔여 이슈.
|
||||||
|
|
|
||||||
131
context/arena.md
131
context/arena.md
|
|
@ -1,3 +1,134 @@
|
||||||
|
# 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
|
# 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 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.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,95 @@
|
||||||
|
# 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
|
# 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.
|
- `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.
|
||||||
|
|
@ -17,16 +109,17 @@
|
||||||
|
|
||||||
- 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.
|
- 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`.
|
- Tune that fade/despawn lifetime with `FIGHTER.DEAD_DESPAWN_DELAY_MS` and the final alpha with `FIGHTER.DEAD_DESPAWN_ALPHA` in `src/constants.js`.
|
||||||
- `combat.js` resolves fighter animation keys through `ensureFighterTeamAnimation()` so every action can use the team-shadow baked texture generated from the original spritesheet.
|
- 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.
|
||||||
- `playIfNeeded()` compares against the team-shadow animation key. This avoids switching back to the original non-team-colored spritesheet when fighters move, attack, take damage, or die.
|
- 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.
|
- 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 the name label and health bar objects.
|
- 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
|
# 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.
|
- `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.
|
- 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.
|
- 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
|
||||||
|
|
||||||
|
|
@ -40,7 +133,7 @@
|
||||||
## 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) 겹침 검사를 수행합니다.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,60 @@
|
||||||
|
# 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
|
# Update: Dense-Area Meteor Barrage
|
||||||
|
|
@ -18,7 +75,7 @@
|
||||||
|
|
||||||
# Update: Performance Constants
|
# 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, and large-battle dead despawn delay.
|
- `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.
|
- Keep large-battle behavior switches tied to `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` so high-count match tuning stays centralized.
|
||||||
|
|
||||||
# Update: Dead Fighter Despawn Constant
|
# Update: Dead Fighter Despawn Constant
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,64 @@
|
||||||
|
# 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
|
# Update: HUD Pooling
|
||||||
|
|
||||||
- `fighterFactory.js` no longer creates permanent name labels or health bars for every fighter.
|
- `fighterFactory.js` no longer creates permanent HUD objects for every fighter; zoom HUD now shows health bars without fighter name labels.
|
||||||
- HUD display objects are pooled on the scene and assigned only to selected fighters or zoom-visible nearby fighters chosen by `ArenaScene`.
|
- 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.
|
- `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`.
|
- Tune pool size and visible candidate limits in `PERFORMANCE.FIGHTER_HUD_POOL_SIZE` and `PERFORMANCE.FIGHTER_HUD_VISIBLE_LIMIT`.
|
||||||
|
|
||||||
|
|
@ -18,7 +75,7 @@
|
||||||
## 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`**: 매치 참여 캐릭터를 무작위로 선택하거나 섞는 로직을 담당합니다.
|
||||||
|
|
@ -33,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`와 동결 타이머를 캐릭터별로 관리합니다. 냉기 메테오 착탄에 생존하면 캐릭터 본체와 팀 실루엣 마커가 함께 얼음색으로 바뀌고, 동결 종료 시 본체 원본 색상과 저장된 팀 색상으로 복구됩니다.
|
||||||
|
|
|
||||||
|
|
@ -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,6 +10,11 @@ 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,
|
||||||
|
|
@ -74,16 +80,35 @@ export const FIGHTER = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PERFORMANCE = {
|
export const PERFORMANCE = {
|
||||||
LARGE_BATTLE_FIGHTER_THRESHOLD: 3000,
|
LARGE_BATTLE_FIGHTER_THRESHOLD: 2000,
|
||||||
LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS: 0,
|
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,
|
TARGET_GRID_CELL_SIZE: TILE_SIZE * 4,
|
||||||
FIGHTER_HUD_POOL_SIZE: 96,
|
FIGHTER_HUD_POOL_SIZE: 48,
|
||||||
FIGHTER_HUD_VISIBLE_LIMIT: 72,
|
FIGHTER_HUD_VISIBLE_LIMIT: 32,
|
||||||
|
LARGE_BATTLE_HUD_VISIBLE_LIMIT: 8,
|
||||||
FIGHTER_HUD_VIEW_PADDING: TILE_SIZE * 2,
|
FIGHTER_HUD_VIEW_PADDING: TILE_SIZE * 2,
|
||||||
FIGHTER_HUD_CANDIDATE_REFRESH_MS: 120,
|
FIGHTER_HUD_CANDIDATE_REFRESH_MS: 120,
|
||||||
MINIMAP_DOT_RADIUS: 3,
|
MINIMAP_DOT_RADIUS: 3,
|
||||||
MINIMAP_BACKGROUND_ALPHA: 0.62,
|
MINIMAP_BACKGROUND_ALPHA: 0.62,
|
||||||
MINIMAP_BORDER_ALPHA: 0.84,
|
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 도메인
|
||||||
|
|
@ -166,31 +191,34 @@ export const WORLD_EFFECT = {
|
||||||
|
|
||||||
// 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: false,
|
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: 80,
|
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,
|
||||||
|
|
|
||||||
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));
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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";
|
||||||
|
|
@ -91,6 +98,8 @@ export function clearWorldEffects(scene) {
|
||||||
scene.clearMeteorCameraFocus?.(null, { restoreCamera: false });
|
scene.clearMeteorCameraFocus?.(null, { restoreCamera: false });
|
||||||
scene.matchStartedAt = null;
|
scene.matchStartedAt = null;
|
||||||
scene.isSuddenDeath = false;
|
scene.isSuddenDeath = false;
|
||||||
|
scene.nextWorldEffectModifierRefreshAt = 0;
|
||||||
|
scene.worldEffectModifierActive = false;
|
||||||
|
|
||||||
scene.fighters?.forEach((fighter) => {
|
scene.fighters?.forEach((fighter) => {
|
||||||
fighter.worldEffectSpeedMultiplier = 1;
|
fighter.worldEffectSpeedMultiplier = 1;
|
||||||
|
|
@ -103,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
|
||||||
|
|
@ -151,8 +174,9 @@ export function findDensestWorldEffectZone(livingFighters) {
|
||||||
);
|
);
|
||||||
|
|
||||||
livingFighters.forEach((fighter) => {
|
livingFighters.forEach((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;
|
||||||
const column = Phaser.Math.Clamp(Math.floor(x / ARENA.TILE_SIZE), 0, ARENA.GRID_SIZE - 1);
|
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);
|
const row = Phaser.Math.Clamp(Math.floor(y / ARENA.TILE_SIZE), 0, ARENA.GRID_SIZE - 1);
|
||||||
|
|
||||||
|
|
@ -552,8 +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.frostStunTimer = scene.time.delayedCall(WORLD_EFFECT.FROST_STUN_DURATION, () => {
|
fighter.frostStunTimer = scene.time.delayedCall(WORLD_EFFECT.FROST_STUN_DURATION, () => {
|
||||||
clearFrostStun(fighter);
|
clearFrostStun(fighter);
|
||||||
});
|
});
|
||||||
|
|
@ -564,9 +588,7 @@ function clearFrostStun(fighter) {
|
||||||
fighter.frostStunTimer = null;
|
fighter.frostStunTimer = null;
|
||||||
fighter.isFrostStunned = false;
|
fighter.isFrostStunned = false;
|
||||||
|
|
||||||
if (fighter.active) {
|
clearFighterTint(fighter);
|
||||||
fighter.clearTint();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateFrostZone(scene, zone, marker) {
|
function activateFrostZone(scene, zone, marker) {
|
||||||
|
|
@ -600,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;
|
||||||
|
}
|
||||||
|
|
@ -8,14 +8,23 @@ import {
|
||||||
ensureFighterTeamAnimations,
|
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;
|
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 } = {},
|
||||||
) {
|
) {
|
||||||
ensureFighterTeamAnimations(scene, skin, team.color, ["idle"]);
|
ensureFighterTeamAnimations(scene, skin, team.color, ["idle"]);
|
||||||
|
|
||||||
|
|
@ -23,7 +32,6 @@ export function createFighter(
|
||||||
const idleSheetKey = scene.textures.exists(teamIdleSheetKey)
|
const idleSheetKey = scene.textures.exists(teamIdleSheetKey)
|
||||||
? teamIdleSheetKey
|
? teamIdleSheetKey
|
||||||
: fighterSheetKey(skin, "idle");
|
: fighterSheetKey(skin, "idle");
|
||||||
const fighter = scene.physics.add.sprite(x, y, idleSheetKey, 0);
|
|
||||||
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));
|
||||||
|
|
@ -31,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);
|
||||||
|
|
@ -40,41 +74,19 @@ 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.skin = skin;
|
fighter._inputHitArea = inputHitArea;
|
||||||
fighter.combatStats = combatStats;
|
|
||||||
fighter.fighterName = displayName;
|
|
||||||
fighter.team = team;
|
|
||||||
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.hp = resolvedHp;
|
|
||||||
fighter.nextAttackAt = 0;
|
|
||||||
fighter.nextHudSyncAt = 0;
|
fighter.nextHudSyncAt = 0;
|
||||||
fighter.nextTargetScanAt = 0;
|
|
||||||
fighter.targetEnemy = null;
|
|
||||||
fighter._hudDetailsVisible = false;
|
fighter._hudDetailsVisible = false;
|
||||||
fighter._hudSlot = null;
|
fighter._hudSlot = null;
|
||||||
fighter.isLocked = false;
|
fighter.releaseHud = () => releaseFighterHud(fighter);
|
||||||
fighter.isDead = false;
|
|
||||||
fighter.play(ensureFighterTeamAnimation(scene, skin, "walk", team.color));
|
fighter.play(ensureFighterTeamAnimation(scene, skin, "walk", team.color));
|
||||||
|
|
||||||
fighter.on(Phaser.Animations.Events.ANIMATION_COMPLETE, (animation) => {
|
fighter.on(Phaser.Animations.Events.ANIMATION_COMPLETE, (animation) => {
|
||||||
|
|
@ -87,17 +99,71 @@ export function createFighter(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fighter.releaseHud = () => releaseFighterHud(fighter);
|
if (!attachSprite) {
|
||||||
|
setFighterDetailVisible(fighter, false);
|
||||||
|
}
|
||||||
|
|
||||||
attachHudCleanup(fighter);
|
attachHudCleanup(fighter);
|
||||||
|
|
||||||
return fighter;
|
return fighter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setFighterDetailVisible(fighter, visible) {
|
||||||
|
if (!fighter || !fighter.scene) {
|
||||||
|
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(
|
export function syncFighterHud(
|
||||||
fighter,
|
fighter,
|
||||||
{ force = false, showDetails = true, time = fighter.scene?.time?.now ?? 0 } = {},
|
{ force = false, showDetails = true, time = fighter.scene?.time?.now ?? 0 } = {},
|
||||||
) {
|
) {
|
||||||
const isVisible = Boolean(fighter.active && !fighter.isDead);
|
const isVisible = Boolean(fighter.active && fighter.visible && !fighter.isDead);
|
||||||
const detailsVisible = isVisible && (showDetails || fighter.isSelected);
|
const detailsVisible = isVisible && (showDetails || fighter.isSelected);
|
||||||
|
|
||||||
if (!detailsVisible || !fighter.body) {
|
if (!detailsVisible || !fighter.body) {
|
||||||
|
|
@ -129,11 +195,7 @@ export function syncFighterHud(
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
hudSlot.nameLabel.setPosition(nameX, nameY);
|
|
||||||
hudSlot.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
|
hudSlot.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
|
||||||
hudSlot.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
|
hudSlot.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
|
||||||
hudSlot.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? 1)));
|
hudSlot.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? 1)));
|
||||||
|
|
@ -186,7 +248,6 @@ function setVisibleIfChanged(gameObject, visible) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setHudSlotVisible(hudSlot, visible) {
|
function setHudSlotVisible(hudSlot, visible) {
|
||||||
setVisibleIfChanged(hudSlot.nameLabel, visible);
|
|
||||||
setVisibleIfChanged(hudSlot.healthBack, visible);
|
setVisibleIfChanged(hudSlot.healthBack, visible);
|
||||||
setVisibleIfChanged(hudSlot.healthBar, visible);
|
setVisibleIfChanged(hudSlot.healthBar, visible);
|
||||||
}
|
}
|
||||||
|
|
@ -205,7 +266,7 @@ function acquireFighterHudSlot(fighter) {
|
||||||
|
|
||||||
hudSlot.fighter = fighter;
|
hudSlot.fighter = fighter;
|
||||||
fighter._hudSlot = hudSlot;
|
fighter._hudSlot = hudSlot;
|
||||||
configureHudSlot(hudSlot, fighter);
|
configureHudSlot(hudSlot);
|
||||||
return hudSlot;
|
return hudSlot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,18 +281,6 @@ function ensureFighterHudPool(scene) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHudSlot(scene) {
|
function createHudSlot(scene) {
|
||||||
const nameLabel = scene.add
|
|
||||||
.text(0, 0, "", {
|
|
||||||
color: "#fff2c2",
|
|
||||||
fontFamily: "Inter, Pretendard, sans-serif",
|
|
||||||
fontSize: "18px",
|
|
||||||
fontStyle: "700",
|
|
||||||
stroke: "#17180e",
|
|
||||||
strokeThickness: 4,
|
|
||||||
})
|
|
||||||
.setOrigin(0.5, 0)
|
|
||||||
.setDepth(4)
|
|
||||||
.setVisible(false);
|
|
||||||
const healthBack = scene.add
|
const healthBack = scene.add
|
||||||
.rectangle(0, 0, 72, 8, 0x17180e, 0.92)
|
.rectangle(0, 0, 72, 8, 0x17180e, 0.92)
|
||||||
.setDepth(4)
|
.setDepth(4)
|
||||||
|
|
@ -246,14 +295,10 @@ function createHudSlot(scene) {
|
||||||
fighter: null,
|
fighter: null,
|
||||||
healthBack,
|
healthBack,
|
||||||
healthBar,
|
healthBar,
|
||||||
nameLabel,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function configureHudSlot(hudSlot, fighter) {
|
function configureHudSlot(hudSlot) {
|
||||||
hudSlot.nameLabel.setText(fighter.fighterName ?? fighter.name ?? "");
|
|
||||||
hudSlot.nameLabel.setStroke(fighter.team?.color ?? "#17180e", 4);
|
|
||||||
hudSlot.nameLabel.setDepth(4);
|
|
||||||
hudSlot.healthBack.setDepth(4);
|
hudSlot.healthBack.setDepth(4);
|
||||||
hudSlot.healthBar.setDepth(5);
|
hudSlot.healthBar.setDepth(5);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,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";
|
||||||
|
|
@ -181,9 +181,11 @@ const arenaScene = new ArenaScene({
|
||||||
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",
|
||||||
|
|
|
||||||
151
todo.md
151
todo.md
|
|
@ -311,3 +311,154 @@
|
||||||
52. Configurable barrage warning duration (completed)
|
52. Configurable barrage warning duration (completed)
|
||||||
- Added `WORLD_EFFECT.WARNING_DURATION_MS` to control the visible lifetime of the large dense-area warning marker.
|
- 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.
|
- 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`.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue