From 5795cc9741f97eb32a6aebc47d27ec423909f013 Mon Sep 17 00:00:00 2001 From: Horoli Date: Wed, 27 May 2026 18:18:20 +0900 Subject: [PATCH] Optimize large battle rendering and combat --- agent.md | 383 +++-- context/arena.md | 131 ++ context/combat.md | 101 +- context/core.md | 59 +- context/fighter.md | 65 +- src/constants.js | 52 +- src/game/arena/ArenaScene.js | 1172 ++++++++++++++-- src/game/arena/arenaSpectatorCamera.js | 16 +- src/game/arena/fighterLodWorker.js | 135 ++ src/game/combat/aggregateCombatWorker.js | 496 +++++++ src/game/combat/combat.js | 1622 +++++++++++++++++++--- src/game/combat/worldEffects.js | 41 +- src/game/fighter/fighterAdapter.js | 267 ++++ src/game/fighter/fighterFactory.js | 153 +- src/game/fighter/fighterModel.js | 153 ++ src/main.js | 8 +- todo.md | 151 ++ 17 files changed, 4484 insertions(+), 521 deletions(-) create mode 100644 src/game/arena/fighterLodWorker.js create mode 100644 src/game/combat/aggregateCombatWorker.js create mode 100644 src/game/fighter/fighterAdapter.js create mode 100644 src/game/fighter/fighterModel.js diff --git a/agent.md b/agent.md index 4ad843f..4841a60 100644 --- a/agent.md +++ b/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 ## 0. 필수 -- 작업이 완료되면 작업에 관련된 모든 문서를 업데이트한다 +- 작업이 완료되면 작업과 관련된 모든 문서를 함께 업데이트한다. +- 대규모 전투, LOD, 모델/렌더 분리, 전투 워커, 서버 API를 수정할 때는 관련 `context/` 문서를 먼저 확인하고 변경 내용을 문서에 반영한다. ## 1. 프로젝트 정의 -**Arena Picker**는 Phaser 3 게임 엔진과 Vite 번들러를 기반으로 구축된 **대규모 팀 전투 시뮬레이션 웹 애플리케이션**입니다. 사용자가 입력한 여러 명의 참가자(닉네임)를 바탕으로 각 참가자를 하나의 팀으로 설정하고, 지정된 인원만큼의 캐릭터를 생성하여 자동 전투를 시뮬레이션합니다. +**Arena Picker**는 Phaser 3 게임 엔진과 Vite 번들러를 기반으로 구축된 **대규모 팀 전투 시뮬레이션 웹 애플리케이션**입니다. 사용자가 입력한 여러 참가자 닉네임을 각각 하나의 팀으로 설정하고, `닉네임*N` 형식으로 지정된 인원만큼 캐릭터를 생성해 자동 전투를 시뮬레이션합니다. -서버 런타임은 Fastify를 사용하며, MongoDB 커넥션 풀을 유지해 유니크 방문자 수와 전투 사망 통계를 기록하는 간단한 통계 API를 제공합니다. +전장은 3200px 월드 크기를 유지하되 Phaser 내부 렌더 캔버스는 1280px로 낮춰 픽셀 작업량을 줄입니다. 일반 전투는 개별 Phaser Sprite와 Arcade Physics를 사용하고, 3,000명 이상 대규모 전투에서는 `FighterModel` 중심 시뮬레이션, rolling-window LOD, Web Worker 기반 후보 선정/집계 전투, HUD/이펙트 풀링을 결합해 8,000명급 전투를 처리합니다. -## 2. 프로젝트 전체 구조 (Directory Tree) +서버 런타임은 Fastify를 사용하며 MongoDB 커넥션 풀을 유지합니다. 방문자 수, 일일 운영 지표, 전투 사망 통계, About 콘텐츠를 API로 제공합니다. + +## 2. 현재 아키텍처 핵심 + +### 2.1 FighterModel 기반 상태와 렌더 브리지 + +- `src/game/fighter/fighterModel.js`가 HP, 팀, 스킨, 타깃, 쿨다운, 사망/선택/성장/동결 상태, 모델 좌표를 보관하는 순수 JS 상태 객체를 만듭니다. +- `fighterFactory.js`는 실제 Phaser Sprite를 생성하고 `fighter.model` 브리지로 기존 `fighter.hp`, `fighter.team` 스타일 접근을 호환합니다. +- `fighterAdapter.js`는 위치, 거리, 방향, 이동, body enable/disable, 애니메이션, 동결 tint, arena clamp 등 Phaser Sprite 접근의 경계입니다. 전투/카메라/월드 이펙트 코드는 새로 직접 `body`, `setVelocity()`, 애니메이션 API를 만지기보다 adapter를 우선 사용합니다. +- `ArenaScene`은 `fighterModels`, `fighterByModelId`, `fighterModelById`를 함께 유지합니다. `fighterForModelId()`는 현재 attach된 렌더 Sprite만 반환할 수 있으므로, 전투 로직은 null 가능성을 항상 고려합니다. + +### 2.2 대규모 전투 렌더 LOD + +- 대규모 live match는 `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` 이상에서 render LOD를 활성화합니다. +- full-arena overview는 `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT`만큼 팀별 대표 Sprite를 유지하고, 나머지 생존자는 팀 색상 dot으로 표시합니다. +- zoomed, selected, spectator 시점은 rolling camera window 안의 모든 생존자를 detailed Sprite로 승격합니다. 이 경로는 더 이상 별도 zoom cap이나 buffer ratio에 묶이지 않습니다. +- `src/game/arena/fighterLodWorker.js`는 생존 fighter worker id, position, team key TypedArray를 받아 현재 match/job의 detailed id 목록만 반환합니다. Worker 실패 또는 오류 시 `resolveFighterLodDetailedSet()` 동기 경로로 fallback합니다. +- LOD 적용은 최초 활성화 때 full sync를 수행한 뒤, 이후에는 이전 detailed set과 다음 set의 차이만 attach/detach합니다. +- parked fighter는 display/update list에서 빠지고 Arcade World에서도 `world.disable()`로 제거됩니다. 재진입 시 `world.enable()` 후 모델 좌표로 body를 복구합니다. +- hidden-fighter dot redraw는 zoomed view에서 카메라 viewport와 padding 밖의 dot을 건너뛰어 `Graphics.fillRect()` 비용을 줄입니다. + +### 2.3 대규모 전투 집계 시뮬레이션 + +- attached/detail fighter는 매 프레임 `updateFighterModel()`로 고정밀 개별 AI를 유지합니다. +- detached/offscreen fighter는 `team + cell + squad` 단위로 압축되어 coarse movement와 group DPS를 처리합니다. 기본 squad 크기는 `PERFORMANCE.LARGE_BATTLE_AGGREGATE_SQUAD_SIZE`가 제어합니다. +- `src/game/combat/aggregateCombatWorker.js`는 detached model id, position, HP, team key, 이동속도, DPS, frost flag를 Transferable TypedArray로 받아 집계 전투를 계산합니다. +- Phaser 상태 변경, death 처리, split-on-death, kill reward, scoreboard, match finish는 여전히 main thread가 소유합니다. +- Worker 결과는 match id가 일치하고 해당 model이 여전히 detached일 때만 적용합니다. 이미 attach된 fighter, 죽었거나 unregister된 stale id는 무시합니다. +- Worker 생성 실패 또는 오류 시 기존 동기 집계 전투 경로로 fallback합니다. + +### 2.4 전투 및 이펙트 최적화 + +- target spatial index는 model 기반으로 구성하되, 대규모 전투에서는 attached/detail model 중심으로 갱신해 8,000명 전체 스캔을 줄입니다. +- stale `targetModelId`는 null-safe validation으로 정리합니다. +- instant-spell 공격 시각 효과는 texture별 sprite pool을 재사용합니다. `clearCombatObjects()`는 active pooled effect도 공통 cleanup 경로로 반환합니다. +- projectile hit detection은 projectile마다 Arcade overlap collider를 만들지 않고 line/rectangle path check와 scratch geometry를 재사용합니다. +- 대규모 전투에서 critical label, instant-spell sprite, kill-heal sprite, kill-growth tween 같은 보조 효과는 meteor camera focus 중일 때만 노출합니다. damage, heal, 성장 수치 자체는 유지됩니다. +- world effect는 랜덤 생존자 대신 생존자 밀집도가 가장 높은 tile square를 큰 경고 영역으로 잡고, 내부에 소형 화염/냉기 strike를 분산 투하합니다. + +### 2.5 카메라, HUD, 서버 지표 + +- 대규모 live match 시작 시 full-arena 최저 줌 대신 평균 생존 위치에 가까운 fighter 주변으로 `CAMERA.LARGE_BATTLE_START_ZOOM`을 적용합니다. +- scoreboard 팀 버튼을 이미 선택된 팀에 다시 클릭하면 선택을 해제하고 full-arena view로 돌아갑니다. +- 수동 fighter/team focus와 full-arena return은 `transitionMainCameraTo()`의 Phaser `pan()`/`zoomTo()` tween을 사용합니다. +- HUD 체력바는 모든 fighter가 영구 소유하지 않고 pool에서 빌려 씁니다. selected fighter와 zoom-visible 후보만 slot을 보유하며, zoom HUD에는 fighter 이름을 표시하지 않습니다. +- live minimap은 별도 HUD camera와 `Graphics` dot overlay로 렌더링하며 `PERFORMANCE.MINIMAP_REFRESH_MS`로 redraw를 throttle합니다. +- 서버는 visitor, death stats, daily metrics, About 콘텐츠 API를 제공합니다. + +## 3. 프로젝트 전체 구조 (Directory Tree) ```text -├── index.html # 메인 HTML 진입점 및 UI 레이아웃 -├── package.json # 프로젝트 의존성 및 스크립트 정의 (Phaser, Vite, Fastify, MongoDB) -├── config.json # 로컬 서버/MongoDB 설정 (git ignore) -├── config.json.sample # 공유용 서버/MongoDB 설정 예시 -├── agent.md # 프로젝트 개요 및 기능 정의 (본 문서) -├── CONTEXT.md # 상세 개발 가이드 및 로직 설명 -├── todo.md # 작업 내역 및 잔여 이슈 관리 -├── server/ # Fastify API 서버 및 MongoDB 연결 관리 -│ ├── index.js # Fastify 서버 진입점, Vite 개발 미들웨어, 정적 배포 서빙 -│ ├── config.js # config.json 로드 및 MongoDB URI 조립 -│ ├── db.js # MongoClient 커넥션 풀 생성/재사용/종료 -│ ├── deathStats.js # 전투 종료 시 오늘 일자별 종족 사망 통계 누적 API -│ ├── about.js # About 개발자정보/개인정보처리방침 기본값 시드 및 조회 API -│ └── visitors.js # 유니크 방문자 체크 및 통계 API -├── public/ # 정적 리소스 (게임 에셋) +├── index.html # 메인 HTML 진입점 및 UI 레이아웃 +├── package.json # Phaser, Vite, Fastify, MongoDB 의존성 및 npm scripts +├── config.json.sample # 공유용 서버/MongoDB 설정 예시 +├── agent.md # 프로젝트 개요 및 에이전트 작업 가이드 +├── todo.md # 작업 내역 및 잔여 이슈 관리 +├── build.sh # 배포/빌드 보조 스크립트 +├── context/ # 상세 개발 가이드 +│ ├── core.md # main.js, constants.js, 렌더/성능 상수, worker entrypoint +│ ├── arena.md # ArenaScene, camera, minimap, fighter render LOD +│ ├── combat.md # 전투 AI, model-only combat, aggregate combat, world effects +│ ├── fighter.md # FighterModel, adapter, factory, HUD pool, team-shadow texture +│ ├── match-ui.md # 매치 설정, spawn, HUD, kill log, victory UI +│ ├── server.md # Fastify, MongoDB, visitor/death/daily metrics/About API +│ ├── style.md # CSS 모듈, 디자인 변수, 반응형/애니메이션 규칙 +│ └── refactor/ +│ └── arena-scene-modularization-work-order.md +├── server/ # Fastify API 서버 및 MongoDB 연결 관리 +│ ├── index.js # Fastify 진입점, Vite dev middleware, 정적 배포 서빙 +│ ├── config.js # config.json 로드 및 MongoDB URI/컬렉션 설정 +│ ├── db.js # MongoClient 커넥션 풀 생성/재사용/종료 +│ ├── visitorCookie.js # 방문자 UUID 쿠키 읽기/쓰기/검증 +│ ├── visitors.js # 유니크 방문자 체크 및 통계 API +│ ├── dailyMetrics.js # 일일 방문/전투 시작/전투 종료/후원 클릭 지표 API +│ ├── deathStats.js # 종족별 전투 사망 통계 API +│ └── about.js # About 개발자정보/개인정보처리방침 seed 및 조회 API +├── public/ # 정적 리소스 │ └── assets/ -│ ├── effects/ # 공통 전투/월드 이펙트 스프라이트시트 -│ │ ├── heal/ # 처치 회복 연출 -│ │ ├── world_Effect.png # 화염 메테오 7프레임 이미지 -│ │ └── world_Effect_2.png # 냉기 메테오 7프레임 이미지 -│ └── characters/ # 20종 이상의 캐릭터 스킨 및 투사체 에셋 -│ ├── archer/, armored-axeman/, armored-orc/, ... (중략) -│ └── wizard/ # 각 폴더 내 애니메이션 시트 및 이펙트 포함 -└── src/ # 소스 코드 root - ├── main.js # Phaser 게임 인스턴스 생성, 옵션 drawer/재시작/일시정지 UI 제어 - ├── constants.js # 전역 물리/UI 상수 통합 관리 (공격력, 체력, 줌, 카메라 속도 등) - ├── styles.css # UI 스타일링 (인트로, 옵션 drawer, 좌측 HUD 레일, 좌측 하단 킬로그, 상단 전투 안내바) - ├── game/ # 게임 로직 모듈 (역할별 하위 폴더 구성) - │ ├── arena/ # 아레나 및 씬 관리 - │ │ ├── ArenaScene.js # 메인 게임 씬 (Orchestrator, 생명주기 및 모듈 조율) - │ │ ├── arenaRenderer.js# 경기장 바닥, 격자 및 팀 시작 영역 렌더링 - │ │ └── arenaSpectatorCamera.js # 지능형 관전 카메라 및 줌 로직 - │ ├── combat/ # 전투 시스템 - │ │ ├── combat.js # 전투 AI, 투사체 및 피격 판정 핵심 엔진 - │ │ ├── combatSettings.js # 전투 속도 및 이동 배율 관리 - │ │ ├── arenaFinalCombatEffects.js # 최종 교전 슬로우 모션 등 연출 효과 - │ │ └── worldEffects.js # 주기적 메테오/냉각지대 및 냉기 동결 효과 - │ ├── fighter/ # 캐릭터 및 에셋 - │ │ ├── fighterAssets.js # 스프라이트 로드 및 팀 실루엣 동적 생성 - │ │ ├── fighterFactory.js # 캐릭터 인스턴스화 및 HUD 동기화 - │ │ ├── fighterManifest.js # 20종 캐릭터 스탯/특성 상세 정의 - │ │ ├── fighterStats.js # 근접/원거리/마법 프로필 판별 및 스탯 해석 - │ │ └── fighterSelection.js # 캐릭터 스킨 무작위 선택 로직 - ├── match/ # 매치 및 진행 - │ ├── matchSetup.js # 팀 구성(닉네임 배수 파싱 포함) 및 스폰 좌표 계산 (스타팅 영역/랜덤) - │ └── arenaMatchRuntime.js # 매치 진행 중 헬퍼 (스폰 클러스터, 팀 크기 동기화) - ... - ## 7. 주요 기능 상세 (New) - - ### 7.1 닉네임 배수 시스템 (Multi-Spawn) - - 사용자가 닉네임 뒤에 `*N` (예: `홍길동*2`)을 입력하면 해당 팀은 기본 팀 인원의 N배만큼 생성됩니다. - - 스타팅 존 모드에서 배수만큼의 독립된 스폰 지점이 할당되어 전략적인 분산 배치가 이루어집니다. - - 닉네임 표시 시 `*N` 접미사는 자동으로 제거되어 깔끔한 UI를 유지합니다. - - ### 7.2 서든 데스 (Sudden Death) 시스템 - - 매치 시작 후 일정 시간(기본 8초)이 경과하면 전장의 환경이 극도로 위험해지는 서든 데스 상태에 진입합니다. - - 메테오 생성 주기가 비약적으로 단축(기본 1초)되며, 빙결 효과를 가진 냉기 메테오가 집중 투하됩니다. - - `constants.js`를 통해 활성화 여부, 시작 시간, 주기 등을 간편하게 조정할 수 있습니다. - - ### 7.3 밀집 구역 기반 월드 이펙트 포격 - - 월드 이펙트는 랜덤 생존자 대신 `WORLD_EFFECT.AREA_TILES` 크기 범위 중 현재 생존 캐릭터가 가장 많이 모인 위치를 표적으로 선택합니다. - - 선택 범위를 먼저 경고로 표시한 뒤, 그 내부에 작은 화염 또는 냉기 메테오를 3~4발 분산 투하합니다. - - 피해, 기절, 냉각 감속은 큰 경고 범위 전체가 아니라 각각의 작은 탄착 영역에만 적용됩니다. - - └── ui/ # UI 컴포넌트 및 API 연동 - ├── arenaKillLog.js # [New] 독립된 킬로그 DOM 조작 모듈 - ├── arenaScoreboard.js # [New] 팀 스코어 badge 업데이트 모듈 - ├── battleDeathNotice.js # [New] 상단 사망 공지 메시지 및 UI 관리 - ├── victoryCelebration.js # [New] 승리 축하 연출 (DOM/Audio) 모듈 - ├── matchForm.js # 설정 폼 제어 및 localStorage 유지 - ├── aboutDialog.js # About 다이얼로그, 개발자정보/개인정보처리방침 표시 - ├── deathStats.js # 사망 통계 API 호출 래퍼 - └── visitorCounter.js # 방문자 체크 API 호출 및 표시 +│ ├── og-image.png # 공유 미리보기 이미지 +│ ├── effects/ +│ │ ├── heal/ # 처치 회복 연출 +│ │ ├── world_Effect.png +│ │ └── world_Effect_2.png +│ └── characters/ # 20종 이상 캐릭터 스킨/투사체/마법 이펙트 에셋 +│ ├── archer/ +│ ├── armored-axeman/ +│ ├── armored-orc/ +│ ├── priest/ +│ ├── wizard/ +│ └── ... # knight, orc, skeleton, slime, wolf, bear 계열 등 +└── src/ # 프론트엔드 소스 root + ├── main.js # Phaser game config, 앱 상태, 옵션 drawer, 방문자 추적 + ├── constants.js # 렌더/전장/전투/카메라/성능/월드 이펙트 상수 + ├── styles.css # CSS 모듈 통합 엔트리 + ├── styles/ + │ ├── base.css # 전역 변수, reset, 기본 레이아웃 + │ ├── intro.css # 대기 화면 및 프리뷰 스타일 + │ ├── game-ui.css # scoreboard, kill log, battle notice, victory layer + │ ├── overlay.css # option drawer, About dialog, form controls + │ ├── animations.css # 공통 keyframes/animation utilities + │ └── mobile.css # 960px 이하 반응형 override + ├── game/ + │ ├── arena/ + │ │ ├── ArenaScene.js # 메인 Phaser Scene orchestrator + │ │ ├── arenaRenderer.js # 전장 바닥, grid, starting zone 렌더링 + │ │ ├── arenaSpectatorCamera.js # 자동/수동 카메라 포커싱 + │ │ └── fighterLodWorker.js # 대규모 전투 detailed sprite 후보 worker + │ ├── combat/ + │ │ ├── combat.js # model 기반 전투 AI, 타깃, 피해, 처치 처리 + │ │ ├── aggregateCombatWorker.js# detached/offscreen 집계 전투 worker + │ │ ├── combatSettings.js # 전투 속도 및 이동 배율 설정 + │ │ ├── arenaFinalCombatEffects.js + │ │ └── worldEffects.js # 밀집 구역 메테오/냉기/감속/동결 효과 + │ ├── fighter/ + │ │ ├── fighterModel.js # 순수 JS fighter 상태 모델 + │ │ ├── fighterAdapter.js # Phaser Sprite/Physics 접근 경계 + │ │ ├── fighterAssets.js # sprite load, team-shadow texture/animation 생성 + │ │ ├── fighterFactory.js # Sprite 생성, model bridge, HUD pool, detail visibility + │ │ ├── fighterManifest.js # 캐릭터 스탯/종족/특성 정의 + │ │ ├── fighterStats.js # melee/ranged/magic 프로필 해석 + │ │ └── fighterSelection.js # 캐릭터 선택/셔플 로직 + │ └── match/ + │ ├── matchSetup.js # `닉네임*N` 파싱, 팀 구성, spawn 좌표 계산 + │ └── arenaMatchRuntime.js # match 진행 중 helper + └── ui/ + ├── matchForm.js # 설정 폼 및 localStorage 유지 + ├── aboutDialog.js # About dialog 및 Markdown 표시 + ├── visitorCounter.js # 방문자 API 호출/표시 + ├── dailyMetrics.js # 일일 지표 API 호출 + ├── deathStats.js # 사망 통계 API 호출 + ├── arenaScoreboard.js # 팀 badge 및 선택 상태 + ├── arenaKillLog.js # kill log DOM + ├── battleDeathNotice.js# 상단 사망/통계 안내 + └── victoryCelebration.js ``` -## 3. 상세 기술 가이드 (Context Routing) +로컬/생성 파일인 `config.json`, `node_modules/`, `dist/`, `.vite/`, `package-lock.json`, `*.log`는 `.gitignore` 대상입니다. -토큰 절약 및 효율적인 정보 조회를 위해 상세 로직은 기능별로 분리되어 보관됩니다. 특정 모듈 작업 시 아래의 관련 문서를 먼저 읽으십시오. +## 4. 상세 기술 가이드 (Context Routing) -- **[인프라 및 전역 설정] [context/core.md](./context/core.md)**: `main.js`, `constants.js`, 개발/유지보수 공통 규칙. -- **[서버 및 API] [context/server.md](./context/server.md)**: Fastify 서버, MongoDB 연동, 방문자 및 사망 통계 API 상세. -- **[아레나 및 카메라] [context/arena.md](./context/arena.md)**: `ArenaScene` 오케스트레이션, 지능형 카메라 추적, 미니맵 가이드라인. -- **[전투 엔진] [context/combat.md](./context/combat.md)**: 전투 AI, 투사체 판정, 처치 보상 성장, 슬로우모션 및 월드 이펙트 연출. -- **[캐릭터 및 에셋] [context/fighter.md](./context/fighter.md)**: 캐릭터 공장, 동적 실루엣 생성, 종족 및 특성(Slime 등) 정의. -- **[매치 로직 및 UI] [context/match-ui.md](./context/match-ui.md)**: 팀 구성 및 스폰 알고리즘, HUD 레이아웃, 킬로그, 승리 연출 UI. -- **[스타일 및 디자인] [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 기반) - **Build Tool**: Vite 7.1.12 - **Server**: Fastify 5.x (`@fastify/static`, `@fastify/middie`) - **Database**: MongoDB 7.x Node Driver -- **UI Logic**: Vanilla JS & CSS (Flexbox/Grid 활용) +- **UI Logic**: Vanilla JS & CSS +- **Render**: `RENDER.WIDTH/HEIGHT = 1280`, `ARENA.SIZE = 3200`, `CAMERA.MIN_ZOOM = RENDER_SIZE / ARENA_SIZE` +- **Large Battle**: `PERFORMANCE.LARGE_BATTLE_*` 상수에서 threshold, simulation buckets, aggregate refresh/cell/squad/death cap, target index refresh, HUD limit, Sprite budget, rolling window, dot redraw를 조정합니다. +- **World Effect**: `WORLD_EFFECT.*`에서 첫/반복 포격, 밀집 경고 범위, 소형 strike 범위/개수/간격/시각 배율, meteor shake, fire/frost damage, frost stun/slow를 조정합니다. +- **Camera**: `CAMERA.LARGE_BATTLE_START_ZOOM`, `CAMERA.MANUAL_FOCUS_TWEEN_MS`, `CAMERA.MANUAL_FOCUS_TWEEN_EASE`, meteor focus, spectator thresholds를 조정합니다. +- **Fighter**: `FIGHTER.DEAD_DESPAWN_DELAY_MS`, `FIGHTER.DEAD_DESPAWN_ALPHA`, `FIGHTER_TYPE_STATS`, kill growth 상수를 조정합니다. +- **Worker fallback**: LOD/aggregate worker는 성능 최적화 경로이며, 실패 시 main-thread 동기 path가 계속 동작해야 합니다. -## 5. 서버/API 설정 +## 7. 서버/API 설정 -- 개발/운영 서버는 `npm run dev` 또는 `npm start`로 실행하며 기본 포트는 `config.json`의 `SERVER_PORT` 값인 `9736`입니다. +- 개발 서버는 `npm run dev`, 운영 서버는 `npm start`, 정적 빌드는 `npm run build`로 실행합니다. +- 기본 포트는 `config.json`의 `SERVER_PORT` 값이며 샘플은 `9736`입니다. - `config.json`은 로컬 설정 파일이므로 저장소에 커밋하지 않습니다. 새 환경에서는 `config.json.sample`을 복사해 사용합니다. -- 기본 API: - - `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): 상세 개발 가이드 및 핵심 로직 설명 (필독) -- [todo.md](./todo.md): 작업 내역 및 잔여 이슈 관리 +- `GET /api/health`: 서버 및 MongoDB 설정 여부 확인. +- `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): 작업 내역 및 잔여 이슈. diff --git a/context/arena.md b/context/arena.md index 2dab86e..b8425a1 100644 --- a/context/arena.md +++ b/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 - 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. diff --git a/context/combat.md b/context/combat.md index 5718a5e..b5e5d3a 100644 --- a/context/combat.md +++ b/context/combat.md @@ -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 - `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. - 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. -- `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. +- Fighter action playback now goes through `fighterAdapter.playFighterAction()` / `playFighterActionIfNeeded()`, which resolve animation keys with `ensureFighterTeamAnimation()` so every action can use the team-shadow baked texture generated from the original spritesheet. +- The adapter compares against the team-shadow animation key before replaying an action. This avoids switching back to the original non-team-colored spritesheet when fighters move, attack, take damage, or die. - Frost stun remains a body tint effect in `worldEffects.js`. Since team identity is baked into the floor shadow pixels, there is no `teamMarker` tint state to update or restore. -- The removed `teamMarker` display object means death handling no longer needs to hide or destroy a separate marker. HUD cleanup only owns 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 - `combat.js` exposes supplemental combat visuals only while a large battle is inside the temporary meteor camera-focus window. - Outside that window, large battles skip critical labels, instant-spell sprites, kill-heal sprites, and kill-growth tweens while retaining the underlying damage and reward calculations. - World-effect meteor/frost visuals remain visible, and projectile objects remain enabled because projectiles currently participate in hit detection. +- Projectile objects should keep calling `projectilePathHitsDefender()` for collision checks instead of adding per-projectile Arcade overlap colliders. # Context: Combat System @@ -40,7 +133,7 @@ ## 2. 주요 로직 구현 세부 사항 ### 전투 AI 및 유닛 동작 -- **`updateFighter()`**: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다. +- **`updateFighterModel()`**: 가장 가까운 적 모델을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다. - **`applyHit()`**: 일반 공격 피해량은 공격자의 `melee`/`ranged`/`magic` 프로필 피해량 범위에서 계산하고, 치명타 적중은 `Critical!` 표기와 즉시 처치를 처리합니다. - **역할별 기본값**: `src/constants.js`의 `FIGHTER_TYPE_STATS`에서 체력, 이동속도, 사거리, 공격 쿨다운, 피해량, 치명타 확률, 발동 지연을 독립적으로 조절합니다. 투사체 속도는 `ranged`, 효과 적중 지연은 `magic` 프로필에 포함됩니다. - **`projectilePathHitsDefender()`**: 투사체가 대상을 스쳐 지나가지 않도록 궤적(Line)과 히트박스(Rectangle) 겹침 검사를 수행합니다. diff --git a/context/core.md b/context/core.md index f65d351..a419dbf 100644 --- a/context/core.md +++ b/context/core.md @@ -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 # Update: Dense-Area Meteor Barrage @@ -18,7 +75,7 @@ # 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. # Update: Dead Fighter Despawn Constant diff --git a/context/fighter.md b/context/fighter.md index f979169..cbc6684 100644 --- a/context/fighter.md +++ b/context/fighter.md @@ -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 -- `fighterFactory.js` no longer creates permanent name labels or health bars for every fighter. -- HUD display objects are pooled on the scene and assigned only to selected fighters or zoom-visible nearby fighters chosen by `ArenaScene`. +- `fighterFactory.js` no longer creates permanent HUD objects for every fighter; zoom HUD now shows health bars without fighter name labels. +- HUD health-bar display objects are pooled on the scene and assigned only to selected fighters or zoom-visible nearby fighters chosen by `ArenaScene`. - `syncFighterHud()` acquires a slot lazily and `releaseFighterHud()` returns it to the pool when the fighter leaves the HUD candidate set, dies, or is destroyed. - Tune pool size and visible candidate limits in `PERFORMANCE.FIGHTER_HUD_POOL_SIZE` and `PERFORMANCE.FIGHTER_HUD_VISIBLE_LIMIT`. @@ -18,7 +75,7 @@ ## 1. 모듈별 상세 역할 (`src/game/fighter/`) - **`fighterAssets.js`**: 캐릭터 스프라이트 로드 및 애니메이션/실루엣 생성을 담당합니다. 원본 이미지로부터 팀 색상 마커용 실루엣을 동적으로 생성합니다. -- **`fighterFactory.js`**: 캐릭터 인스턴스화 및 HUD(이름표, 체력바) 관리를 담당합니다. Phaser Sprite와 DOM UI 사이의 가교 역할을 합니다. +- **`fighterFactory.js`**: 캐릭터 인스턴스화 및 HUD 체력바 관리를 담당합니다. Phaser Sprite와 DOM UI 사이의 가교 역할을 합니다. - **`fighterManifest.js`**: 모든 캐릭터 종족 및 스탯 데이터를 정의합니다. 20여 종의 캐릭터 설정이 포함되어 있습니다. - **`fighterStats.js`**: 공격 방식으로 `melee`, `ranged`, `magic` 역할을 판별하고 역할별 기본 스탯과 스킨별 오버라이드를 병합합니다. - **`fighterSelection.js`**: 매치 참여 캐릭터를 무작위로 선택하거나 섞는 로직을 담당합니다. @@ -33,7 +90,7 @@ 4. 캐릭터가 성장하여 커져도 같은 배율로 실루엣이 유지됩니다. ### 캐릭터 HUD 및 상태 동기화 -- **이름표 고정**: 스프라이트 중심이 아닌 실제 히트박스 하단에 고정되어 시각적 일관성을 유지합니다. +- **체력바 표시**: 줌 또는 선택 상태에서 후보 fighter만 pooled HUD slot을 빌려 체력바를 표시합니다. 이름표는 zoom HUD에 표시하지 않습니다. - **사망자 처리**: 사망 시 HUD와 팀 마커를 숨겨 화면 가독성을 높입니다. 본체 sprite만 낮은 depth와 반투명 상태로 남깁니다. - **월드 감속 상태**: 생성 시 `worldEffectSpeedMultiplier`를 `1`로 초기화하며, 냉각지대 안에서는 `worldEffects.js`가 해당 배율을 낮춰 공격속도와 이동속도 계산에 반영합니다. - **냉기 동결 상태**: `isFrostStunned`와 동결 타이머를 캐릭터별로 관리합니다. 냉기 메테오 착탄에 생존하면 캐릭터 본체와 팀 실루엣 마커가 함께 얼음색으로 바뀌고, 동결 종료 시 본체 원본 색상과 저장된 팀 색상으로 복구됩니다. diff --git a/src/constants.js b/src/constants.js index 069cc01..b19f570 100644 --- a/src/constants.js +++ b/src/constants.js @@ -2,6 +2,7 @@ const GRID_SIZE = 50; const TILE_SIZE = 64; const ARENA_SIZE = GRID_SIZE * TILE_SIZE; +const RENDER_SIZE = 1280; export const ARENA = { GRID_SIZE, @@ -9,6 +10,11 @@ export const ARENA = { SIZE: ARENA_SIZE, }; +export const RENDER = { + HEIGHT: RENDER_SIZE, + WIDTH: RENDER_SIZE, +}; + // 2. FIGHTER 도메인 export const FIGHTER = { SCALE: 3, @@ -74,16 +80,35 @@ export const FIGHTER = { }; export const PERFORMANCE = { - LARGE_BATTLE_FIGHTER_THRESHOLD: 3000, + LARGE_BATTLE_FIGHTER_THRESHOLD: 2000, LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS: 0, + LARGE_BATTLE_SIMULATION_BUCKETS: 16, + LARGE_BATTLE_SIMULATION_MAX_DELTA_MS: 260, + LARGE_BATTLE_TARGET_INDEX_REFRESH_MS: 160, + LARGE_BATTLE_AGGREGATE_COMBAT_REFRESH_MS: 260, + LARGE_BATTLE_AGGREGATE_CELL_SIZE: TILE_SIZE * 5, + LARGE_BATTLE_AGGREGATE_SQUAD_SIZE: 100, + LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_CELL_TICK: 4, + LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_TICK: 80, + LARGE_BATTLE_AGGREGATE_MOVEMENT_RATIO: 0.72, + WORLD_EFFECT_MODIFIER_REFRESH_MS: 180, TARGET_GRID_CELL_SIZE: TILE_SIZE * 4, - FIGHTER_HUD_POOL_SIZE: 96, - FIGHTER_HUD_VISIBLE_LIMIT: 72, + FIGHTER_HUD_POOL_SIZE: 48, + FIGHTER_HUD_VISIBLE_LIMIT: 32, + LARGE_BATTLE_HUD_VISIBLE_LIMIT: 8, FIGHTER_HUD_VIEW_PADDING: TILE_SIZE * 2, FIGHTER_HUD_CANDIDATE_REFRESH_MS: 120, MINIMAP_DOT_RADIUS: 3, MINIMAP_BACKGROUND_ALPHA: 0.62, MINIMAP_BORDER_ALPHA: 0.84, + MINIMAP_REFRESH_MS: 220, + LARGE_BATTLE_SPRITE_RENDER_LIMIT: 140, + LARGE_BATTLE_ROLLING_WINDOW_SCALE: 1.05, + LARGE_BATTLE_SPRITE_VIEW_PADDING: TILE_SIZE * 2, + LARGE_BATTLE_LOD_REFRESH_MS: 180, + LARGE_BATTLE_DOT_REFRESH_MS: 220, + LARGE_BATTLE_DOT_SIZE: 6, + LARGE_BATTLE_DOT_ALPHA: 0.86, }; // 3. SPAWN 도메인 @@ -166,31 +191,34 @@ export const WORLD_EFFECT = { // 7. CAMERA 도메인 export const CAMERA = { - MIN_ZOOM: 1, + MIN_ZOOM: RENDER_SIZE / ARENA_SIZE, MAX_ZOOM: 3, ZOOM_STEP: 0.1, // 자동 관전 진입 전 화염/냉기 메테오 낙하 위치를 임시로 확대 추적합니다. METEOR_FOCUS_ENABLED: false, METEOR_FOCUS_ZOOM: 2, - SPECTATOR_LERP: 0.1, + SPECTATOR_LERP: 0.05, // 메테오 착탄 후 카메라를 해당 위치에 유지하는 시간(ms)입니다. METEOR_FOCUS_HOLD_DURATION: 1200, SPECTATOR_FINAL_FIGHTER_THRESHOLD: 5, - SPECTATOR_FINAL_FIGHT_ZOOM: 3, + SPECTATOR_FINAL_FIGHT_ZOOM: 2, SPECTATOR_FINAL_TEAM_COUNT: 2, SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD: 8, - SPECTATOR_RANDOM_FOCUS_INTERVAL: 10000, + SPECTATOR_RANDOM_FOCUS_INTERVAL: 100000, SPECTATOR_LATE_FIGHTER_THRESHOLD: 80, - SPECTATOR_LATE_FIGHT_ZOOM: 2, - SELECTED_FIGHTER_ZOOM: 2, + SPECTATOR_LATE_FIGHT_ZOOM: 1, + LARGE_BATTLE_START_ZOOM: 0.8, + SELECTED_FIGHTER_ZOOM: 0.8, + MANUAL_FOCUS_TWEEN_MS: 220, + MANUAL_FOCUS_TWEEN_EASE: "Sine.easeInOut", }; // 8. UI 도메인 export const UI = { MINIMAP_ALPHA: 0.8, - MINIMAP_MARGIN: Math.round(ARENA_SIZE * 0.016), - MINIMAP_VIEWPORT_SIZE: Math.round(ARENA_SIZE * 0.22), - MINIMAP_VIEW_FRAME_STROKE: 10, + MINIMAP_MARGIN: Math.round(RENDER_SIZE * 0.016), + MINIMAP_VIEWPORT_SIZE: Math.round(RENDER_SIZE * 0.22), + MINIMAP_VIEW_FRAME_STROKE: Math.max(3, Math.round(RENDER_SIZE * 0.003125)), SELECTED_FIGHTER_OUTLINE_GAP: 1, SELECTED_FIGHTER_OUTLINE_WIDTH: 1, SELECTED_FIGHTER_OUTLINE_RED: 255, diff --git a/src/game/arena/ArenaScene.js b/src/game/arena/ArenaScene.js index 8d6f6a0..c1930e1 100644 --- a/src/game/arena/ArenaScene.js +++ b/src/game/arena/ArenaScene.js @@ -3,12 +3,18 @@ import { ARENA, CAMERA, COMBAT, + FIGHTER, PERFORMANCE, SPAWN, UI, } from "../../constants.js"; import { drawArena, drawStartingZones } from "./arenaRenderer.js"; -import { clearCombatObjects, prepareCombatFrame, updateFighter } from "../combat/combat.js"; +import { + clearCombatObjects, + prepareCombatFrame, + updateAggregateDetachedCombat, + updateFighterModel, +} from "../combat/combat.js"; import { clearWorldEffects, createWorldEffectAnimations, @@ -20,8 +26,13 @@ import { createFighterAnimations, preloadFighterSheets } from "../fighter/fighte import { createFighter, releaseUnusedFighterHuds, + setFighterDetailVisible, syncFighterHud, } from "../fighter/fighterFactory.js"; +import { + fighterModelPoint, + stopFighterMovement, +} from "../fighter/fighterAdapter.js"; import { fighterManifest } from "../fighter/fighterManifest.js"; import { pickFighters } from "../fighter/fighterSelection.js"; import { @@ -68,6 +79,9 @@ export class ArenaScene extends Phaser.Scene { constructor({ getInitialMatchConfig, onMatchEnd, setPlayerNamesWarning, setStatus }) { super("arena"); this.fighters = []; + this.fighterByModelId = new Map(); + this.fighterModelById = new Map(); + this.fighterModels = []; this.getInitialMatchConfig = getInitialMatchConfig; this.matchId = 0; this.matchOver = false; @@ -101,6 +115,7 @@ export class ArenaScene extends Phaser.Scene { this.deathStatsSaved = false; this.finalFocusNextSwitchAt = 0; this.finalFocusTarget = null; + this.cameraTransitionUntil = 0; this.fighterHudCandidates = []; this.nextFighterHudCandidateRefreshAt = 0; this.spectatorMode = null; @@ -112,6 +127,31 @@ export class ArenaScene extends Phaser.Scene { this.startingZoneHideTimer = null; this.minimapGraphics = null; this.minimapHudCamera = null; + this.nextMinimapUpdateAt = 0; + this.fighterLodGraphics = null; + this.fighterLodDetailedSet = new Set(); + this.fighterLodActive = false; + this.nextFighterLodRefreshAt = 0; + this.nextFighterLodDrawAt = 0; + this.fighterLodWorker = null; + this.fighterLodWorkerDisabled = false; + this.fighterLodWorkerJobId = 0; + this.fighterLodWorkerJobPending = false; + this.fighterLodWorkerModelById = new Map(); + this.fighterLodWorkerResults = []; + this.nextFighterLodWorkerModelId = 0; + this.fighterSimulationBucketCursor = 0; + this.nextAggregateCombatAt = 0; + this.lastAggregateCombatAt = 0; + this.aggregateCombatDamageCursor = 0; + this.aggregateCombatWinnerCursor = 0; + this.aggregateWorker = null; + this.aggregateWorkerDisabled = false; + this.aggregateWorkerJobId = 0; + this.aggregateWorkerJobPending = false; + this.aggregateWorkerModelById = new Map(); + this.aggregateWorkerResults = []; + this.nextAggregateWorkerModelId = 0; this.worldEffectTimer = null; this.worldEffectZones = new Set(); } @@ -127,6 +167,7 @@ export class ArenaScene extends Phaser.Scene { this.cameras.main.setBackgroundColor("#282819"); drawArena(this); this.startingZoneGraphics = this.add.graphics().setDepth(0.5); + this.fighterLodGraphics = this.add.graphics().setDepth(FIGHTER.DEPTH); createFighterAnimations(this, fighterManifest); createWorldEffectAnimations(this); @@ -140,7 +181,7 @@ export class ArenaScene extends Phaser.Scene { this.cameras.main.ignore(this.minimapGraphics); this.syncMinimapHudCameraIgnores(); this.events.on("addedtoscene", this.handleGameObjectAddedToScene, this); - this.updateMinimap(); + this.updateMinimap({ force: true }); // 마우스 휠로 줌 조절 this.input.on('wheel', (pointer, gameObjects, deltaX, deltaY, deltaZ) => { const newZoom = Phaser.Math.Clamp( @@ -154,12 +195,14 @@ export class ArenaScene extends Phaser.Scene { // 확대 시 미니맵 표시 }); this.input.on("gameobjectdown", (pointer, gameObject) => { - if (this.fighters.includes(gameObject)) { - this.selectFighter(gameObject); + const fighter = gameObject?._fighterProxy ?? gameObject; + + if (this.fighters.includes(fighter)) { + this.selectFighter(fighter); } }); this.input.on("pointerdown", (pointer, gameObjects = []) => { - if (!gameObjects.some((gameObject) => this.fighters.includes(gameObject))) { + if (!gameObjects.some((gameObject) => this.fighters.includes(gameObject?._fighterProxy ?? gameObject))) { this.clearSelectedFighter(); } }); @@ -223,16 +266,44 @@ export class ArenaScene extends Phaser.Scene { this.observedCombat = []; this.fighterHudCandidates = []; this.nextFighterHudCandidateRefreshAt = 0; + this.nextMinimapUpdateAt = 0; + this.fighterSimulationBucketCursor = 0; + this.fighterLodWorkerJobId = 0; + this.fighterLodWorkerJobPending = false; + this.fighterLodWorkerModelById.clear(); + this.fighterLodWorkerResults = []; + this.nextFighterLodWorkerModelId = 0; + this.aggregateWorkerJobId = 0; + this.aggregateWorkerJobPending = false; + this.aggregateWorkerModelById.clear(); + this.aggregateWorkerResults = []; + this.nextAggregateWorkerModelId = 0; + this.resetFighterRenderLod(); this.clearSelectedFighter(); this.setMainCameraZoom(CAMERA.MIN_ZOOM); this.cameras.main.centerOn(ARENA.SIZE / 2, ARENA.SIZE / 2); clearCombatObjects(this); - this.fighters.forEach((fighter) => fighter.destroy()); + this.fighters.forEach((fighter) => { + if (fighter.model) { + fighter.model.active = false; + } + + fighter.destroy(); + }); + this.fighterByModelId.clear(); + this.fighterModelById.clear(); + this.fighterModels = []; this.resetKillLog(); this.teams = matchSetup.teams; this.showStartingZones(matchSetup.startingZones); - this.fighters = fighterPlans.map((fighterPlan) => createFighter(this, fighterPlan)); + const shouldDeferFighterSprites = this.shouldDeferInitialFighterSprites(fighterPlans, silent); + this.fighters = fighterPlans.map((fighterPlan) => createFighter(this, fighterPlan, { + attachSprite: !shouldDeferFighterSprites, + })); + this.syncFighterModelIndex(); + this.focusLargeBattleStartCamera(); startWorldEffects(this); + this.syncFighterRenderLod(this.time.now, { force: true }); if (!silent) { trackMatchStart(); @@ -245,6 +316,69 @@ export class ArenaScene extends Phaser.Scene { return true; } + focusLargeBattleStartCamera() { + if ( + this.presentationMode + || this.matchOver + || this.fighters.length < Math.max(1, PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD) + ) { + return; + } + + const livingFighters = this.fighters.filter(isLivingFighter); + const target = this.getLargeBattleStartCameraTarget(livingFighters); + + this.observedCombat = []; + this.setMainCameraZoom(CAMERA.LARGE_BATTLE_START_ZOOM); + + if (target) { + this.cameras.main.centerOn(Math.round(target.x), Math.round(target.y)); + } + + this.invalidateFighterRenderLod(); + this.updateMinimap({ force: true }); + } + + shouldDeferInitialFighterSprites(fighterPlans, silent) { + return Boolean( + !silent + && fighterPlans.length >= Math.max(1, PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD), + ); + } + + getLargeBattleStartCameraTarget(livingFighters) { + const average = averageFighterPosition(livingFighters); + + if (!average) { + return { + x: ARENA.SIZE / 2, + y: ARENA.SIZE / 2, + }; + } + + let nearestPoint = average; + let nearestDistance = Number.POSITIVE_INFINITY; + + livingFighters.forEach((fighter) => { + const point = fighterCameraPoint(fighter); + + if (!point) { + return; + } + + const deltaX = point.x - average.x; + const deltaY = point.y - average.y; + const distance = deltaX * deltaX + deltaY * deltaY; + + if (distance < nearestDistance) { + nearestDistance = distance; + nearestPoint = point; + } + }); + + return nearestPoint; + } + showStartingZones(startingZones) { this.startingZoneHideTimer?.remove(false); this.startingZoneHideTimer = null; @@ -272,23 +406,31 @@ export class ArenaScene extends Phaser.Scene { } const children = Array.from({ length: count }, (_, index) => { - const position = clusterSpawnPosition(source, index, count); + const position = clusterSpawnPosition(fighterModelPoint(source), index, count); - return createFighter(this, { - canSplitOnDeath: Boolean(splitOnDeath.childCanSplit), - faceLeft: source.flipX, - hp: childMaxHp, - maxHp: childMaxHp, - name: source.name, - skin: source.skin, - team: source.team, - teamIndex: source.teamIndex, - x: position.x, - y: position.y, - }); + return createFighter( + this, + { + canSplitOnDeath: Boolean(splitOnDeath.childCanSplit), + faceLeft: source.flipX ?? source.facingLeft, + hp: childMaxHp, + maxHp: childMaxHp, + name: source.name ?? source.fighterName, + skin: source.skin, + team: source.team, + teamIndex: source.teamIndex, + x: position.x, + y: position.y, + }, + { + attachSprite: !this.shouldUseFighterRenderLod(), + }, + ); }); this.fighters.push(...children); + this.registerFighters(children); + this.invalidateFighterRenderLod(); const team = this.teams.find((candidate) => candidate.id === source.team.id); if (team) { @@ -302,6 +444,114 @@ export class ArenaScene extends Phaser.Scene { resetKillLog(this.getKillLogNodes()); } + registerFighters(fighters) { + fighters.forEach((fighter) => { + const model = fighter?.model; + + if (!model?.id || model.isDead) { + return; + } + + model.active = true; + this.fighterModelById.set(model.id, model); + + if (!fighter._spriteDetached) { + this.fighterByModelId.set(model.id, fighter); + } + + if (!this.fighterModels.includes(model)) { + this.fighterModels.push(model); + } + }); + } + + unregisterFighter(fighter) { + if (!fighter?.model?.id) { + return; + } + + this.fighterLodDetailedSet?.delete(fighter); + this.unregisterFighterModel(fighter.model); + } + + unregisterFighterModel(model) { + if (!model?.id) { + return; + } + + model.active = false; + this.fighterByModelId.delete(model.id); + this.fighterModelById.delete(model.id); + if (model.aggregateWorkerModelId) { + this.aggregateWorkerModelById.delete(model.aggregateWorkerModelId); + } + if (model.fighterLodWorkerModelId) { + this.fighterLodWorkerModelById.delete(model.fighterLodWorkerModelId); + } + this.fighterModels = this.fighterModels.filter((candidate) => candidate !== model); + } + + removeDetachedFighterProxyForModel(model) { + if (!model?.id) { + return; + } + + const fighter = this.fighters.find((candidate) => candidate?.model === model); + + if (!fighter || !fighter._spriteDetached) { + return; + } + + if (this.selectedFighter === fighter) { + this.selectedFighter = null; + } + + this.fighterLodDetailedSet?.delete(fighter); + fighter.destroy(); + this.fighters = this.fighters.filter((candidate) => candidate !== fighter); + } + + syncFighterModelIndex() { + this.fighterByModelId.clear(); + this.fighterModelById.clear(); + this.fighterModels = []; + this.registerFighters(this.fighters); + } + + fighterForModelId(modelId) { + return this.fighterByModelId.get(modelId) ?? null; + } + + fighterModelForId(modelId) { + return this.fighterModelById.get(modelId) ?? null; + } + + setFighterSpriteAttached(fighter, attached) { + const model = fighter?.model; + + if (!model?.id) { + return; + } + + if (attached && isLivingFighter(fighter) && !fighter._spriteDetached) { + this.fighterByModelId.set(model.id, fighter); + return; + } + + this.fighterByModelId.delete(model.id); + } + + ensureFighterSpriteAttached(fighter) { + if (!isLivingFighter(fighter)) { + return false; + } + + setFighterDetailVisible(fighter, true); + this.setFighterSpriteAttached(fighter, true); + this.fighterLodDetailedSet.add(fighter); + return true; + } + resetMatchDeathStats({ silent = false } = {}) { this.clearBattleNotice(); this.battleDeathCounts = createDeathCounts(); @@ -426,61 +676,123 @@ export class ArenaScene extends Phaser.Scene { listNode: this.killLogListNode, }; } -update(time) { - this.syncFighterHuds(time); - if (this.matchPaused) { + update(time, delta) { + this.syncFighterRenderLod(time); + this.syncFighterHuds(time); + + if (this.matchPaused) { + this.updateMinimap(); + return; + } + + if (!this.matchOver) { + prepareCombatFrame(this, time); + updateWorldEffectModifiers(this); + this.updateFighterModels(time, delta); + } + + if (this.presentationMode) { + this.followPresentationCombat(); + this.updateMinimap(); + return; + } + + if (this.focusSelectedFighter()) { + this.updateMinimap(); + return; + } + + if (this.matchOver) { + this.updateMinimap(); + return; + } + + // 확대 상태일 때 생존 캐릭터들의 중앙으로 카메라 이동 + const livingFighters = this.fighters.filter(isLivingFighter); + const spectatorState = getSpectatorState(livingFighters); + this.syncSpectatorMode(spectatorState?.mode ?? null); + + if (spectatorState) { + this.clearMeteorCameraFocus(null, { restoreCamera: false }); + this.setMainCameraZoom(spectatorState.zoom); + this.moveCameraToward(this.getSpectatorCameraTarget(spectatorState, livingFighters, time)); + } else if (this.followMeteorCameraFocus()) { + // 월드 이펙트 착탄 지점의 임시 시점은 자동 관전 진입 전까지만 사용합니다. + } else if (this.cameras.main.zoom <= CAMERA.MIN_ZOOM) { + // 줌이 1일 때는 경기장 중앙에 고정 + this.cameras.main.centerOn(ARENA.SIZE / 2, ARENA.SIZE / 2); + } + this.updateMinimap(); - return; } - if (!this.matchOver) { - prepareCombatFrame(this); - updateWorldEffectModifiers(this); + updateFighterModels(time, delta) { + const onWinner = () => { + this.updateScoreboard(); + this.finishMatch(); + }; + const models = this.fighterModels ?? []; - this.fighters.forEach((fighter) => { - updateFighter(this, fighter, time, () => { - this.updateScoreboard(); - this.finishMatch(); + if (!this.shouldThrottleLargeBattleSimulation()) { + models.forEach((fighterModel) => { + updateFighterModel(this, fighterModel, time, onWinner, delta); }); + return; + } + + const bucketCount = Math.max( + 1, + Math.round(Number(PERFORMANCE.LARGE_BATTLE_SIMULATION_BUCKETS) || 1), + ); + const bucketIndex = this.fighterSimulationBucketCursor % bucketCount; + const frameDelta = Math.max(0, Number(delta) || 16.67); + const bucketDelta = Math.min( + frameDelta * bucketCount, + Math.max(frameDelta, Number(PERFORMANCE.LARGE_BATTLE_SIMULATION_MAX_DELTA_MS) || 180), + ); + const aggregateHandlesDetached = updateAggregateDetachedCombat( + this, + time, + onWinner, + frameDelta, + ); + + if (this.matchOver) { + return; + } + + this.fighterSimulationBucketCursor = (this.fighterSimulationBucketCursor + 1) % bucketCount; + + models.forEach((fighterModel, index) => { + const hasAttachedSprite = this.fighterByModelId.has(fighterModel?.id); + + if (aggregateHandlesDetached && !hasAttachedSprite) { + return; + } + + if (!hasAttachedSprite && index % bucketCount !== bucketIndex) { + return; + } + + updateFighterModel( + this, + fighterModel, + time, + onWinner, + hasAttachedSprite ? frameDelta : bucketDelta, + ); }); } - if (this.presentationMode) { - this.followPresentationCombat(); - this.updateMinimap(); - return; + shouldThrottleLargeBattleSimulation() { + return Boolean( + !this.presentationMode + && !this.matchOver + && (this.fighterModels?.length ?? 0) >= Math.max(1, PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD), + ); } - if (this.focusSelectedFighter()) { - this.updateMinimap(); - return; - } - - if (this.matchOver) { - this.updateMinimap(); - return; - } - - // 확대 상태일 때 생존 캐릭터들의 중앙으로 카메라 이동 - const livingFighters = this.fighters.filter(isLivingFighter); - const spectatorState = getSpectatorState(livingFighters); - this.syncSpectatorMode(spectatorState?.mode ?? null); - - if (spectatorState) { - this.clearMeteorCameraFocus(null, { restoreCamera: false }); - this.setMainCameraZoom(spectatorState.zoom); - this.moveCameraToward(this.getSpectatorCameraTarget(spectatorState, livingFighters, time)); - } else if (this.followMeteorCameraFocus()) { - // 월드 이펙트 착탄 지점의 임시 시점은 자동 관전 진입 전까지만 사용합니다. - } else if (this.cameras.main.zoom <= CAMERA.MIN_ZOOM) { - // 줌이 1일 때는 경기장 중앙에 고정 - this.cameras.main.centerOn(ARENA.SIZE / 2, ARENA.SIZE / 2); - } - - this.updateMinimap(); -} - syncSpectatorMode(mode) { if (this.spectatorMode === mode) { return; @@ -795,11 +1107,15 @@ update(time) { this.clearMeteorCameraFocus(null, { restoreCamera: false }); this.clearSelectedFighter(); + this.ensureFighterSpriteAttached(fighter); this.selectedFighter = fighter; fighter.isSelected = true; this.observedCombat = []; - this.setMainCameraZoom(Math.max(this.cameras.main.zoom, CAMERA.SELECTED_FIGHTER_ZOOM)); - this.centerCameraOnFighter(fighter); + this.transitionMainCameraTo({ + target: fighterCameraPoint(fighter), + zoom: Math.max(this.cameras.main.zoom, CAMERA.SELECTED_FIGHTER_ZOOM), + }); + this.invalidateFighterRenderLod(); syncFighterHud(fighter, { force: true, showDetails: true, @@ -818,6 +1134,7 @@ update(time) { } this.selectedFighter = null; + this.invalidateFighterRenderLod(); } focusSelectedFighter() { @@ -830,12 +1147,16 @@ update(time) { return false; } + if (this.isMainCameraTransitionActive()) { + return true; + } + this.centerCameraOnFighter(this.selectedFighter); return true; } centerCameraOnFighter(fighter) { - const target = fighter.body?.center ?? fighter; + const target = fighterCameraPoint(fighter); this.cameras.main.centerOn(Math.round(target.x), Math.round(target.y)); } @@ -899,6 +1220,11 @@ update(time) { return; } + if (isLivingFighter(this.selectedFighter) && this.selectedFighter.team.id === teamId) { + this.returnToFullArenaView(); + return; + } + const candidates = this.fighters.filter( (fighter) => isLivingFighter(fighter) && fighter.team.id === teamId, ); @@ -913,6 +1239,20 @@ update(time) { this.updateScoreboard(); } + returnToFullArenaView() { + this.clearMeteorCameraFocus(null, { immediate: true, restoreCamera: false }); + this.clearSelectedFighter(); + this.observedCombat = []; + this.transitionMainCameraTo({ + target: { + x: ARENA.SIZE / 2, + y: ARENA.SIZE / 2, + }, + zoom: CAMERA.MIN_ZOOM, + }); + this.updateScoreboard(); + } + focusPresentationCombat() { this.cameras.main.setZoom(CAMERA.SPECTATOR_LATE_FIGHT_ZOOM); this.observedCombat = findClosestOpponentPair(this.fighters) ?? []; @@ -925,6 +1265,52 @@ update(time) { this.updateMinimap(); } + transitionMainCameraTo({ duration = CAMERA.MANUAL_FOCUS_TWEEN_MS, target, zoom }) { + if (!target) { + return; + } + + const camera = this.cameras.main; + const targetX = Math.round(target.x); + const targetY = Math.round(target.y); + const targetZoom = Phaser.Math.Clamp(zoom ?? camera.zoom, CAMERA.MIN_ZOOM, CAMERA.MAX_ZOOM); + const durationMs = Math.max(0, Math.round(Number(duration) || 0)); + + this.stopMainCameraTransition(); + + if (durationMs === 0) { + this.setMainCameraZoom(targetZoom); + camera.centerOn(targetX, targetY); + this.invalidateFighterRenderLod(); + this.updateMinimap({ force: true }); + return; + } + + if (targetZoom === CAMERA.MIN_ZOOM) { + this.observedCombat = []; + } + + camera.pan(targetX, targetY, durationMs, CAMERA.MANUAL_FOCUS_TWEEN_EASE, true); + camera.zoomTo(targetZoom, durationMs, CAMERA.MANUAL_FOCUS_TWEEN_EASE, true); + this.cameraTransitionUntil = (this.time?.now ?? 0) + durationMs; + this.invalidateFighterRenderLod(); + this.updateMinimap({ force: true }); + } + + stopMainCameraTransition() { + this.cameras.main.panEffect?.reset(); + this.cameras.main.zoomEffect?.reset(); + this.cameraTransitionUntil = 0; + } + + isMainCameraTransitionActive() { + return Boolean( + this.cameras.main.panEffect?.isRunning + || this.cameras.main.zoomEffect?.isRunning + || (this.time?.now ?? 0) < this.cameraTransitionUntil, + ); + } + followPresentationCombat() { if (this.cameras.main.zoom !== CAMERA.SPECTATOR_LATE_FIGHT_ZOOM) { this.cameras.main.setZoom(CAMERA.SPECTATOR_LATE_FIGHT_ZOOM); @@ -943,14 +1329,610 @@ update(time) { setMainCameraZoom(zoom) { const newZoom = Phaser.Math.Clamp(zoom, CAMERA.MIN_ZOOM, CAMERA.MAX_ZOOM); + const zoomChanged = Math.abs(this.cameras.main.zoom - newZoom) > 0.0001; - this.cameras.main.setZoom(newZoom); + this.stopMainCameraTransition(); + + if (zoomChanged) { + this.cameras.main.setZoom(newZoom); + this.invalidateFighterRenderLod(); + } if (newZoom === CAMERA.MIN_ZOOM) { this.observedCombat = []; } - this.updateMinimap(); + if (zoomChanged) { + this.updateMinimap({ force: true }); + } + } + + resetFighterRenderLod() { + this.fighterLodGraphics?.clear(); + this.fighterLodDetailedSet?.clear(); + this.fighterLodActive = false; + this.nextFighterLodRefreshAt = 0; + this.nextFighterLodDrawAt = 0; + this.fighterLodWorkerJobPending = false; + this.fighterLodWorkerResults = []; + } + + invalidateFighterRenderLod() { + this.nextFighterLodRefreshAt = 0; + this.nextFighterLodDrawAt = 0; + } + + syncFighterRenderLod(time, { force = false } = {}) { + const now = Number.isFinite(time) ? time : this.time?.now ?? 0; + + if (!this.shouldUseFighterRenderLod()) { + this.disableFighterRenderLod({ + restoreSprites: !this.matchOver, + }); + return; + } + + this.applyFighterLodWorkerResults(); + + const refreshMs = Math.max(0, Number(PERFORMANCE.LARGE_BATTLE_LOD_REFRESH_MS) || 0); + const dotRefreshMs = Math.max(0, Number(PERFORMANCE.LARGE_BATTLE_DOT_REFRESH_MS) || 0); + + if (force || now >= this.nextFighterLodRefreshAt) { + let refreshed = false; + + if (force || !this.fighterLodActive || this.fighterLodWorkerDisabled) { + this.applyFighterLodDetailedSet(this.resolveFighterLodDetailedSet()); + refreshed = true; + } else if (!this.fighterLodWorkerJobPending) { + const workerStarted = this.startFighterLodWorkerJob(); + + if (!workerStarted) { + this.applyFighterLodDetailedSet(this.resolveFighterLodDetailedSet()); + } + + refreshed = true; + } + + if (refreshed) { + this.nextFighterLodRefreshAt = now + refreshMs; + } + } + + if (force || now >= this.nextFighterLodDrawAt) { + this.drawFighterLodDots(); + this.nextFighterLodDrawAt = now + dotRefreshMs; + } + } + + shouldUseFighterRenderLod() { + return Boolean( + !this.presentationMode + && !this.matchOver + && (this.fighters?.length ?? 0) >= Math.max(1, PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD), + ); + } + + startFighterLodWorkerJob() { + const worker = this.getFighterLodWorker(); + + if (!worker) { + return false; + } + + const job = this.createFighterLodWorkerJob(); + + if (!job) { + return false; + } + + try { + this.fighterLodWorkerJobPending = true; + this.fighterLodWorkerJobId = job.jobId; + worker.postMessage(job.message, job.transfer); + return true; + } catch (error) { + console.warn(error); + this.fighterLodWorkerJobPending = false; + this.fighterLodWorkerDisabled = true; + worker.terminate(); + this.fighterLodWorker = null; + return false; + } + } + + getFighterLodWorker() { + if (this.fighterLodWorkerDisabled) { + return null; + } + + if (this.fighterLodWorker) { + return this.fighterLodWorker; + } + + if (typeof globalThis.Worker !== "function") { + this.fighterLodWorkerDisabled = true; + return null; + } + + try { + const worker = new Worker( + new URL("./fighterLodWorker.js", import.meta.url), + { type: "module" }, + ); + + worker.onmessage = (event) => { + const result = event.data; + + if (result?.type !== "fighter-lod-result") { + return; + } + + if ( + result.matchId === this.matchId + && result.jobId === this.fighterLodWorkerJobId + ) { + this.fighterLodWorkerJobPending = false; + } + + this.fighterLodWorkerResults ??= []; + this.fighterLodWorkerResults.push(result); + }; + worker.onerror = (error) => { + console.warn(error); + this.fighterLodWorkerJobPending = false; + this.fighterLodWorkerDisabled = true; + worker.terminate(); + this.fighterLodWorker = null; + }; + this.fighterLodWorker = worker; + return worker; + } catch (error) { + console.warn(error); + this.fighterLodWorkerDisabled = true; + return null; + } + } + + createFighterLodWorkerJob() { + const livingFighters = this.fighters.filter(isLivingFighter); + + if (livingFighters.length === 0) { + return null; + } + + const count = livingFighters.length; + const modelIds = new Int32Array(count); + const teamKeys = new Int32Array(count); + const x = new Float32Array(count); + const y = new Float32Array(count); + const teamKeyById = new Map(); + + this.fighterLodWorkerModelById ??= new Map(); + + livingFighters.forEach((fighter, index) => { + const point = fighterModelPoint(fighter); + const teamId = fighter.team?.id ?? "unknown"; + + if (!teamKeyById.has(teamId)) { + teamKeyById.set(teamId, teamKeyById.size + 1); + } + + modelIds[index] = this.ensureFighterLodWorkerModelId(fighter); + teamKeys[index] = teamKeyById.get(teamId) ?? 0; + x[index] = point.x; + y[index] = point.y; + }); + + const selectedFighter = isLivingFighter(this.selectedFighter) ? this.selectedFighter : null; + const jobId = (this.fighterLodWorkerJobId ?? 0) + 1; + + return { + jobId, + message: { + ...this.resolveFighterLodWorkerCameraState(selectedFighter), + baseDetailLimit: this.resolveFighterLodBaseDetailLimit(), + jobId, + matchId: this.matchId, + modelIds, + selectedModelId: selectedFighter + ? this.ensureFighterLodWorkerModelId(selectedFighter) + : 0, + teamKeys, + type: "fighter-lod-job", + x, + y, + }, + transfer: [ + modelIds.buffer, + teamKeys.buffer, + x.buffer, + y.buffer, + ], + }; + } + + ensureFighterLodWorkerModelId(fighter) { + const model = fighter?.model; + + if (!model?.id) { + return 0; + } + + if (!model.fighterLodWorkerModelId) { + this.nextFighterLodWorkerModelId = (this.nextFighterLodWorkerModelId ?? 0) + 1; + model.fighterLodWorkerModelId = this.nextFighterLodWorkerModelId; + } + + this.fighterLodWorkerModelById.set(model.fighterLodWorkerModelId, fighter); + return model.fighterLodWorkerModelId; + } + + resolveFighterLodWorkerCameraState(selectedFighter) { + const camera = this.cameras.main; + const view = camera.worldView; + const minPadding = Math.max(0, Number(PERFORMANCE.LARGE_BATTLE_SPRITE_VIEW_PADDING) || 0); + const rollingWindowScale = Math.max( + 1, + Number(PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_SCALE) || 1, + ); + const rollingWidth = Math.max(view.width + minPadding * 2, view.width * rollingWindowScale); + const rollingHeight = Math.max(view.height + minPadding * 2, view.height * rollingWindowScale); + const cameraCenter = camera.midPoint; + + return { + fullArenaOverview: + !selectedFighter + && !this.meteorFocusState + && camera.zoom <= CAMERA.MIN_ZOOM + 0.001, + rollingBottom: cameraCenter.y + rollingHeight / 2, + rollingLeft: cameraCenter.x - rollingWidth / 2, + rollingRight: cameraCenter.x + rollingWidth / 2, + rollingTop: cameraCenter.y - rollingHeight / 2, + }; + } + + applyFighterLodWorkerResults() { + const results = this.fighterLodWorkerResults; + + if (!Array.isArray(results) || results.length === 0) { + return false; + } + + let latestResult = null; + + this.fighterLodWorkerResults = []; + + results.forEach((result) => { + if ( + result?.type !== "fighter-lod-result" + || result.matchId !== this.matchId + ) { + return; + } + + if (!latestResult || result.jobId > latestResult.jobId) { + latestResult = result; + } + }); + + if (!latestResult) { + return false; + } + + this.applyFighterLodDetailedSet( + this.resolveFighterLodDetailedSetFromWorkerResult(latestResult), + ); + return true; + } + + resolveFighterLodDetailedSetFromWorkerResult(result) { + const detailedSet = new Set(); + const detailedIds = result.detailedIds; + + if (!detailedIds) { + return this.resolveFighterLodDetailedSet(); + } + + for (let index = 0; index < detailedIds.length; index += 1) { + const fighter = this.fighterLodWorkerModelById?.get(detailedIds[index]); + + if (fighter?.scene && isLivingFighter(fighter)) { + detailedSet.add(fighter); + } + } + + if (detailedSet.size === 0 && this.fighters.some(isLivingFighter)) { + return this.resolveFighterLodDetailedSet(); + } + + return detailedSet; + } + + disableFighterRenderLod({ restoreSprites = true } = {}) { + if (!this.fighterLodActive && this.fighterLodDetailedSet.size === 0) { + this.fighterLodGraphics?.clear(); + return; + } + + this.fighterLodGraphics?.clear(); + this.fighterLodDetailedSet.clear(); + this.fighterLodActive = false; + this.fighterLodWorkerJobPending = false; + this.fighterLodWorkerResults = []; + + if (!restoreSprites) { + return; + } + + this.fighters.forEach((fighter) => { + setFighterDetailVisible(fighter, true); + this.setFighterSpriteAttached(fighter, true); + }); + } + + resolveFighterLodDetailedSet() { + const livingFighters = this.fighters.filter(isLivingFighter); + const baseDetailLimit = this.resolveFighterLodBaseDetailLimit(); + const detailedSet = new Set(); + const selectedFighter = isLivingFighter(this.selectedFighter) ? this.selectedFighter : null; + + if (selectedFighter) { + detailedSet.add(selectedFighter); + } + + const isFullArenaOverview = + !selectedFighter + && !this.meteorFocusState + && this.cameras.main.zoom <= CAMERA.MIN_ZOOM + 0.001; + + if (isFullArenaOverview) { + this.addRepresentativeFighterDetails(livingFighters, detailedSet, baseDetailLimit); + return detailedSet; + } + + const cameraDetails = this.collectCameraFighterDetails(livingFighters, detailedSet); + this.addCameraFighterDetails(cameraDetails, detailedSet); + + if (detailedSet.size <= (selectedFighter ? 1 : 0)) { + this.addRepresentativeFighterDetails(livingFighters, detailedSet, baseDetailLimit); + } + + return detailedSet; + } + + resolveFighterLodBaseDetailLimit() { + return Math.max( + 1, + Math.round(Number(PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT) || 1), + ); + } + + addRepresentativeFighterDetails(livingFighters, detailedSet, detailLimit) { + const remainingLimit = detailLimit - detailedSet.size; + + if (remainingLimit <= 0) { + return; + } + + const fightersByTeam = new Map(); + + livingFighters.forEach((fighter) => { + if (detailedSet.has(fighter)) { + return; + } + + const teamId = fighter.team?.id ?? "unknown"; + const teamFighters = fightersByTeam.get(teamId) ?? []; + teamFighters.push(fighter); + fightersByTeam.set(teamId, teamFighters); + }); + + const teamCount = Math.max(1, fightersByTeam.size); + const quotaPerTeam = Math.max(1, Math.floor(remainingLimit / teamCount)); + + fightersByTeam.forEach((teamFighters) => { + const step = Math.max(1, Math.ceil(teamFighters.length / quotaPerTeam)); + + for ( + let index = 0; + index < teamFighters.length && detailedSet.size < detailLimit; + index += step + ) { + detailedSet.add(teamFighters[index]); + } + }); + + for (let index = 0; index < livingFighters.length && detailedSet.size < detailLimit; index += 1) { + detailedSet.add(livingFighters[index]); + } + } + + collectCameraFighterDetails(livingFighters, detailedSet) { + const camera = this.cameras.main; + const view = camera.worldView; + const minPadding = Math.max(0, Number(PERFORMANCE.LARGE_BATTLE_SPRITE_VIEW_PADDING) || 0); + const rollingWindowScale = Math.max( + 1, + Number(PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_SCALE) || 1, + ); + const rollingWidth = Math.max(view.width + minPadding * 2, view.width * rollingWindowScale); + const rollingHeight = Math.max(view.height + minPadding * 2, view.height * rollingWindowScale); + const visibleLeft = view.x; + const visibleRight = view.right; + const visibleTop = view.y; + const visibleBottom = view.bottom; + const cameraCenter = camera.midPoint; + const rollingLeft = cameraCenter.x - rollingWidth / 2; + const rollingRight = cameraCenter.x + rollingWidth / 2; + const rollingTop = cameraCenter.y - rollingHeight / 2; + const rollingBottom = cameraCenter.y + rollingHeight / 2; + const visibleCandidates = []; + const rollingCandidates = []; + + livingFighters.forEach((fighter) => { + const point = fighterModelPoint(fighter); + + if ( + detailedSet.has(fighter) + || point.x < rollingLeft + || point.x > rollingRight + || point.y < rollingTop + || point.y > rollingBottom + ) { + return; + } + + const deltaX = point.x - cameraCenter.x; + const deltaY = point.y - cameraCenter.y; + const targetCandidates = + point.x >= visibleLeft + && point.x <= visibleRight + && point.y >= visibleTop + && point.y <= visibleBottom + ? visibleCandidates + : rollingCandidates; + + targetCandidates.push({ + distance: deltaX * deltaX + deltaY * deltaY, + fighter, + }); + }); + + const sortByDistance = (leftCandidate, rightCandidate) => + leftCandidate.distance - rightCandidate.distance; + + visibleCandidates.sort(sortByDistance); + rollingCandidates.sort(sortByDistance); + + return { + rollingCandidates, + visibleCandidates, + }; + } + + addCameraFighterDetails({ rollingCandidates, visibleCandidates }, detailedSet) { + const candidates = [...visibleCandidates, ...rollingCandidates]; + + for (let index = 0; index < candidates.length; index += 1) { + detailedSet.add(candidates[index].fighter); + } + } + + applyFighterLodDetailedSet(detailedSet) { + const needsFullSync = !this.fighterLodActive; + const previousDetailedSet = this.fighterLodDetailedSet ?? new Set(); + + this.fighterLodActive = true; + this.fighterLodDetailedSet = detailedSet; + + if (needsFullSync) { + this.fighters.forEach((fighter) => { + const shouldAttachSprite = isLivingFighter(fighter) && detailedSet.has(fighter); + + setFighterDetailVisible(fighter, shouldAttachSprite); + this.setFighterSpriteAttached(fighter, shouldAttachSprite); + }); + return; + } + + previousDetailedSet.forEach((fighter) => { + if (!fighter?.scene || fighter.active === false) { + this.setFighterSpriteAttached(fighter, false); + return; + } + + if (detailedSet.has(fighter) && isLivingFighter(fighter)) { + return; + } + + setFighterDetailVisible(fighter, false); + this.setFighterSpriteAttached(fighter, false); + }); + + detailedSet.forEach((fighter) => { + if (!fighter?.scene || !isLivingFighter(fighter)) { + return; + } + + setFighterDetailVisible(fighter, true); + this.setFighterSpriteAttached(fighter, true); + }); + } + + drawFighterLodDots() { + const graphics = this.fighterLodGraphics; + + if (!graphics) { + return; + } + + graphics.clear(); + + if (!this.fighterLodActive) { + return; + } + + const dotSize = Math.max(1, Math.round(Number(PERFORMANCE.LARGE_BATTLE_DOT_SIZE) || 1)); + const dotOffset = dotSize / 2; + const dotAlpha = Phaser.Math.Clamp( + Number(PERFORMANCE.LARGE_BATTLE_DOT_ALPHA) || 1, + 0, + 1, + ); + let currentColor = null; + const dotView = this.resolveFighterLodDotView(dotOffset); + + this.fighters.forEach((fighter) => { + if (!isLivingFighter(fighter) || this.fighterLodDetailedSet.has(fighter)) { + return; + } + + const point = fighterModelPoint(fighter); + + if (!this.isPointInFighterLodDotView(point, dotView)) { + return; + } + + const color = this.minimapTeamColor(fighter.team); + + if (color !== currentColor) { + graphics.fillStyle(color, dotAlpha); + currentColor = color; + } + + graphics.fillRect( + Math.round(point.x - dotOffset), + Math.round(point.y - dotOffset), + dotSize, + dotSize, + ); + }); + } + + resolveFighterLodDotView(dotOffset = 0) { + const view = this.cameras.main.worldView; + const padding = Math.max( + dotOffset, + Number(PERFORMANCE.LARGE_BATTLE_SPRITE_VIEW_PADDING) || 0, + ); + + return { + bottom: view.bottom + padding, + left: view.x - padding, + right: view.right + padding, + top: view.y - padding, + }; + } + + isPointInFighterLodDotView(point, view) { + return Boolean( + point + && view + && point.x >= view.left + && point.x <= view.right + && point.y >= view.top + && point.y <= view.bottom + ); } handleGameObjectAddedToScene(gameObject) { @@ -976,12 +1958,20 @@ update(time) { this.minimapHudCamera.ignore(gameObject); } - updateMinimap() { + updateMinimap({ force = false } = {}) { if (!this.minimapGraphics) { return; } - const camera = this.cameras.main; + const now = this.time?.now ?? 0; + const refreshMs = Math.max(0, Number(PERFORMANCE.MINIMAP_REFRESH_MS) || 0); + + if (!force && refreshMs > 0 && now < this.nextMinimapUpdateAt) { + return; + } + + this.nextMinimapUpdateAt = now + refreshMs; + const graphics = this.minimapGraphics; graphics.clear(); @@ -1008,12 +1998,12 @@ update(time) { } drawMinimapFighterDots(graphics, x, y, size, dotRadius) { - const livingFighters = this.combatTargetIndex?.livingFighters - ?? this.fighters.filter(isLivingFighter); + const livingFighters = this.fighters.filter(isLivingFighter); livingFighters.forEach((fighter) => { - const dotX = x + Phaser.Math.Clamp(fighter.x / ARENA.SIZE, 0, 1) * size; - const dotY = y + Phaser.Math.Clamp(fighter.y / ARENA.SIZE, 0, 1) * size; + const point = fighterModelPoint(fighter); + const dotX = x + Phaser.Math.Clamp(point.x / ARENA.SIZE, 0, 1) * size; + const dotY = y + Phaser.Math.Clamp(point.y / ARENA.SIZE, 0, 1) * size; graphics.fillStyle(this.minimapTeamColor(fighter.team), 0.9); graphics.fillCircle(dotX, dotY, dotRadius); @@ -1091,7 +2081,11 @@ update(time) { const candidates = []; this.fighters.forEach((fighter) => { - if (!isLivingFighter(fighter) || !viewBounds.contains(fighter.x, fighter.y)) { + if ( + !isLivingFighter(fighter) + || !fighter.visible + || !viewBounds.contains(fighter.x, fighter.y) + ) { return; } @@ -1106,7 +2100,7 @@ update(time) { candidates.sort((left, right) => left.distance - right.distance); - const limit = Math.max(1, Math.round(PERFORMANCE.FIGHTER_HUD_VISIBLE_LIMIT)); + const limit = this.resolveFighterHudVisibleLimit(); this.fighterHudCandidates = this.ensureSelectedHudCandidate( candidates.slice(0, limit).map((candidate) => candidate.fighter), selectedFighter, @@ -1117,17 +2111,26 @@ update(time) { ensureSelectedHudCandidate(candidates, selectedFighter) { const livingCandidates = candidates.filter(isLivingFighter); + const limit = this.resolveFighterHudVisibleLimit(); if (selectedFighter && !livingCandidates.includes(selectedFighter)) { return [selectedFighter, ...livingCandidates].slice( 0, - Math.max(1, Math.round(PERFORMANCE.FIGHTER_HUD_VISIBLE_LIMIT)), + limit, ); } return livingCandidates; } + resolveFighterHudVisibleLimit() { + const value = this.shouldUseFighterRenderLod() + ? PERFORMANCE.LARGE_BATTLE_HUD_VISIBLE_LIMIT + : PERFORMANCE.FIGHTER_HUD_VISIBLE_LIMIT; + + return Math.max(1, Math.round(Number(value) || 1)); + } + shouldShowFighterHudDetails() { return this.cameras.main.zoom > CAMERA.MIN_ZOOM || Boolean(this.selectedFighter); } @@ -1160,10 +2163,12 @@ update(time) { } const [fighterA, fighterB] = this.observedCombat; + const pointA = fighterCameraPoint(fighterA); + const pointB = fighterCameraPoint(fighterB); return { - x: (fighterA.x + fighterB.x) / 2, - y: (fighterA.y + fighterB.y) / 2, + x: (pointA.x + pointB.x) / 2, + y: (pointA.y + pointB.y) / 2, }; } @@ -1196,11 +2201,8 @@ update(time) { this.clearFinalCombatEffects(); clearWorldEffects(this); clearCombatObjects(this); - this.fighters.forEach((fighter) => { - if (fighter.body) { - fighter.body.setVelocity(0, 0); - } - }); + this.disableFighterRenderLod({ restoreSprites: false }); + this.fighters.forEach((fighter) => stopFighterMovement(fighter)); if (this.presentationMode) { const finishedMatchId = this.matchId; diff --git a/src/game/arena/arenaSpectatorCamera.js b/src/game/arena/arenaSpectatorCamera.js index 012df53..990c275 100644 --- a/src/game/arena/arenaSpectatorCamera.js +++ b/src/game/arena/arenaSpectatorCamera.js @@ -1,7 +1,10 @@ -import Phaser from "phaser"; import { CAMERA, } from "../../constants.js"; +import { + fighterDistanceSquared, + fighterWorldPoint, +} from "../fighter/fighterAdapter.js"; export function getSpectatorState(livingFighters) { const livingFighterCount = livingFighters.length; @@ -87,16 +90,11 @@ export function averageFighterPosition(fighters) { } export function fighterCameraPoint(fighter) { - const target = fighter?.body?.center ?? fighter; - - if (!target) { + if (!fighter) { return null; } - return { - x: target.x, - y: target.y, - }; + return fighterWorldPoint(fighter); } export function findClosestOpponentPair(fighters) { @@ -115,7 +113,7 @@ export function findClosestOpponentPair(fighters) { continue; } - const distance = Phaser.Math.Distance.Between(fighter.x, fighter.y, candidate.x, candidate.y); + const distance = fighterDistanceSquared(fighter, candidate); if (distance < closestDistance) { closestDistance = distance; diff --git a/src/game/arena/fighterLodWorker.js b/src/game/arena/fighterLodWorker.js new file mode 100644 index 0000000..511853e --- /dev/null +++ b/src/game/arena/fighterLodWorker.js @@ -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]); +} diff --git a/src/game/combat/aggregateCombatWorker.js b/src/game/combat/aggregateCombatWorker.js new file mode 100644 index 0000000..7d5aab9 --- /dev/null +++ b/src/game/combat/aggregateCombatWorker.js @@ -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)); +} diff --git a/src/game/combat/combat.js b/src/game/combat/combat.js index faf1c27..e66d3b4 100644 --- a/src/game/combat/combat.js +++ b/src/game/combat/combat.js @@ -11,77 +11,1115 @@ import { getMovementSpeedMultiplier, } from "./combatSettings.js"; import { - ensureFighterTeamAnimation, fighterAttackEffectAnimationKey, fighterAttackEffectKey, fighterProjectileKey, healEffectAnimationKey, healEffectKey, } from "../fighter/fighterAssets.js"; +import { + clampFighterInsideArena, + disableFighterBody, + fighterModelDistanceSquared, + fighterWorldPoint, + getFighterModel, + isFighterBodyEnabled, + isLivingFighterModel, + moveFighterToward, + playFighterAction, + playFighterActionIfNeeded, + setFighterFacing, + shouldRenderFighterDetail, + stopFighterMovement, + syncFighterModelFromSprite, +} from "../fighter/fighterAdapter.js"; import { getFighterStats } from "../fighter/fighterStats.js"; const TARGET_SCAN_INTERVAL_MS = 180; const TARGET_SCAN_JITTER_MS = 90; +const SPELL_EFFECT_POOL_LIMIT_PER_KEY = 48; +const PROJECTILE_PATH = new Phaser.Geom.Line(); +const PROJECTILE_HIT_AREA = new Phaser.Geom.Rectangle(); -export function prepareCombatFrame(scene) { - scene.combatTargetIndex = createTargetSpatialIndex(scene.fighters ?? []); +export function prepareCombatFrame(scene, time) { + syncAttachedFighterModels(scene); + + if (shouldAuditFighterModelIndex(scene, time)) { + scene.syncFighterModelIndex?.(); + } + + if (shouldRefreshCombatTargetIndex(scene, time)) { + const targetModels = resolveCombatTargetModels(scene); + scene.combatTargetIndex = createTargetSpatialIndex(targetModels, scene); + scene.combatTargetIndexModelCount = targetModels.length; + scene.nextCombatTargetIndexRefreshAt = + combatFrameTime(scene, time) + resolveCombatTargetIndexRefreshMs(scene); + } } -export function updateFighter(scene, fighter, time, onWinner) { - if (!fighter.active || fighter.isDead || fighter.isFrostStunned || fighter.isLocked) { - fighter.body?.setVelocity(0, 0); +export function updateFighter(scene, fighter, time, onWinner, delta = 16.67) { + return updateFighterModel(scene, getFighterModel(fighter), time, onWinner, delta); +} + +export function updateAggregateDetachedCombat(scene, time, onWinner, delta = 16.67) { + if (!shouldAggregateDetachedCombat(scene)) { + return false; + } + + const workerDeaths = applyAggregateWorkerResults(scene); + + if (workerDeaths > 0) { + onWinner?.(); + } + + const now = combatFrameTime(scene, time); + const refreshMs = resolveAggregateCombatRefreshMs(); + let resolvedDeaths = 0; + + if (now >= (scene.nextAggregateCombatAt ?? 0) && !scene.aggregateWorkerJobPending) { + const tickDelta = resolveAggregateCombatDelta(scene, now, refreshMs); + + if (!startAggregateWorkerJob(scene, tickDelta)) { + resolvedDeaths = updateAggregateDetachedCombatSync(scene, tickDelta); + } + + scene.lastAggregateCombatAt = now; + scene.nextAggregateCombatAt = now + refreshMs; + } + + if (resolvedDeaths > 0) { + onWinner?.(); + } + + return true; +} + +function updateAggregateDetachedCombatSync(scene, tickDelta) { + const aggregateState = buildAggregateSquadState(scene); + + assignAggregateSquadTargets(aggregateState); + advanceAggregateSquads(aggregateState.squads, tickDelta); + const resolvedDeaths = resolveAggregateSquadCombatState(scene, aggregateState, tickDelta); + syncAggregateSquadMembers(aggregateState.squads); + scene.aggregateCombatState = aggregateState; + + return resolvedDeaths; +} + +function startAggregateWorkerJob(scene, tickDelta) { + const worker = getAggregateWorker(scene); + + if (!worker) { + return false; + } + + const job = createAggregateWorkerJob(scene, tickDelta); + + if (!job) { + return false; + } + + scene.aggregateWorkerJobPending = true; + scene.aggregateWorkerJobId = job.jobId; + worker.postMessage(job.message, job.transfer); + return true; +} + +function getAggregateWorker(scene) { + if (scene.aggregateWorkerDisabled) { + return null; + } + + if (scene.aggregateWorker) { + return scene.aggregateWorker; + } + + if (typeof globalThis.Worker !== "function") { + scene.aggregateWorkerDisabled = true; + return null; + } + + try { + const worker = new Worker( + new URL("./aggregateCombatWorker.js", import.meta.url), + { type: "module" }, + ); + + worker.onmessage = (event) => { + const result = event.data; + + if (result?.type !== "aggregate-result") { + return; + } + + scene.aggregateWorkerJobPending = false; + scene.aggregateWorkerResults ??= []; + scene.aggregateWorkerResults.push(result); + }; + worker.onerror = (error) => { + console.warn(error); + scene.aggregateWorkerJobPending = false; + scene.aggregateWorkerDisabled = true; + worker.terminate(); + scene.aggregateWorker = null; + }; + scene.aggregateWorker = worker; + return worker; + } catch (error) { + console.warn(error); + scene.aggregateWorkerDisabled = true; + return null; + } +} + +function createAggregateWorkerJob(scene, tickDelta) { + const detachedModels = []; + const teamKeyById = new Map(); + + (scene.fighterModels ?? []).forEach((model) => { + if (!isDetachedAggregateModel(scene, model)) { + return; + } + + detachedModels.push(model); + const teamId = model.team?.id ?? "unknown"; + + if (!teamKeyById.has(teamId)) { + teamKeyById.set(teamId, teamKeyById.size + 1); + } + }); + + if (detachedModels.length === 0 || teamKeyById.size < 2) { + return null; + } + + const count = detachedModels.length; + const modelIds = new Int32Array(count); + const teamKeys = new Int32Array(count); + const x = new Float32Array(count); + const y = new Float32Array(count); + const hp = new Float32Array(count); + const moveSpeed = new Float32Array(count); + const damagePerSecond = new Float32Array(count); + const isFrostStunned = new Uint8Array(count); + + scene.aggregateWorkerModelById ??= new Map(); + + detachedModels.forEach((model, index) => { + const workerModelId = ensureAggregateWorkerModelId(scene, model); + const stats = combatStatsFor(model); + const averageDamage = (Number(stats.damageMin) + Number(stats.damageMax)) / 2; + const attackDelaySeconds = Math.max(0.1, scaledAttackDelay(stats.attackCooldown, model) / 1000); + const criticalPressure = 1 + getCriticalChance(model) * 0.5; + + modelIds[index] = workerModelId; + teamKeys[index] = teamKeyById.get(model.team?.id ?? "unknown") ?? 0; + x[index] = Number(model.x) || 0; + y[index] = Number(model.y) || 0; + hp[index] = Math.max(0, Number(model.hp) || 0); + moveSpeed[index] = combatStatsFor(model).moveSpeed * fighterMovementSpeedMultiplier(model); + damagePerSecond[index] = isLivingFighterModel(model) && !model.isFrostStunned + ? (averageDamage * criticalPressure) / attackDelaySeconds + : 0; + isFrostStunned[index] = model.isFrostStunned ? 1 : 0; + model.targetModelId = null; + }); + + const jobId = (scene.aggregateWorkerJobId ?? 0) + 1; + + return { + jobId, + message: { + config: aggregateWorkerConfig(), + damageCursor: scene.aggregateCombatDamageCursor ?? 0, + damagePerSecond, + hp, + isFrostStunned, + jobId, + matchId: scene.matchId, + modelIds, + moveSpeed, + teamKeys, + tickDelta: Math.max(0, Number(tickDelta) || 0), + winnerCursor: scene.aggregateCombatWinnerCursor ?? 0, + x, + y, + }, + transfer: [ + modelIds.buffer, + teamKeys.buffer, + x.buffer, + y.buffer, + hp.buffer, + moveSpeed.buffer, + damagePerSecond.buffer, + isFrostStunned.buffer, + ], + }; +} + +function ensureAggregateWorkerModelId(scene, model) { + if (!model.aggregateWorkerModelId) { + scene.nextAggregateWorkerModelId = (scene.nextAggregateWorkerModelId ?? 0) + 1; + model.aggregateWorkerModelId = scene.nextAggregateWorkerModelId; + } + + scene.aggregateWorkerModelById.set(model.aggregateWorkerModelId, model); + return model.aggregateWorkerModelId; +} + +function aggregateWorkerConfig() { + return { + arenaSize: ARENA.SIZE, + cellSize: Math.max( + 1, + Number(PERFORMANCE.LARGE_BATTLE_AGGREGATE_CELL_SIZE) || PERFORMANCE.TARGET_GRID_CELL_SIZE, + ), + halfHeight: FIGHTER.HITBOX_HEIGHT / 2, + halfWidth: FIGHTER.HITBOX_WIDTH / 2, + maxDeathsPerCellTick: Math.max( + 1, + Math.round(Number(PERFORMANCE.LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_CELL_TICK) || 1), + ), + maxDeathsPerTick: Math.max( + 1, + Math.round(Number(PERFORMANCE.LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_TICK) || 1), + ), + movementRatio: Math.min( + 2, + Math.max(0, Number(PERFORMANCE.LARGE_BATTLE_AGGREGATE_MOVEMENT_RATIO) || 1), + ), + squadSize: Math.max( + 1, + Math.round(Number(PERFORMANCE.LARGE_BATTLE_AGGREGATE_SQUAD_SIZE) || 100), + ), + squadSpacing: Math.max(FIGHTER.HITBOX_WIDTH, FIGHTER.HITBOX_HEIGHT) * 1.55, + tileSize: ARENA.TILE_SIZE, + }; +} + +function applyAggregateWorkerResults(scene) { + const results = scene.aggregateWorkerResults; + + if (!Array.isArray(results) || results.length === 0) { + return 0; + } + + let resolvedDeaths = 0; + + scene.aggregateWorkerResults = []; + + results.forEach((result) => { + if (result.matchId !== scene.matchId) { + return; + } + + scene.aggregateCombatDamageCursor = result.damageCursor ?? scene.aggregateCombatDamageCursor; + scene.aggregateCombatWinnerCursor = result.winnerCursor ?? scene.aggregateCombatWinnerCursor; + resolvedDeaths += applyAggregateWorkerResult(scene, result); + }); + + return resolvedDeaths; +} + +function applyAggregateWorkerResult(scene, result) { + const modelIds = result.modelIds; + const x = result.x; + const y = result.y; + const hp = result.hp; + + if (!modelIds || !x || !y || !hp) { + return 0; + } + + for (let index = 0; index < modelIds.length; index += 1) { + const model = scene.aggregateWorkerModelById?.get(modelIds[index]); + + if ( + !model + || !isLivingFighterModel(model) + || scene.fighterByModelId?.has(model.id) + ) { + continue; + } + + model.x = x[index]; + model.y = y[index]; + model.hp = Math.max(0, hp[index]); + model.isLocked = false; + model.targetModelId = null; + } + + return applyAggregateWorkerDeaths(scene, result); +} + +function applyAggregateWorkerDeaths(scene, result) { + const defenderIds = result.deadDefenderIds; + const attackerIds = result.deadAttackerIds; + const deadCount = Math.max(0, Math.round(Number(result.deadCount) || 0)); + let resolvedDeaths = 0; + + if (!defenderIds || !attackerIds || deadCount === 0) { + return 0; + } + + for (let index = 0; index < deadCount; index += 1) { + const defenderModel = scene.aggregateWorkerModelById?.get(defenderIds[index]); + const winnerModel = scene.aggregateWorkerModelById?.get(attackerIds[index]) ?? null; + + if ( + !defenderModel + || !isLivingFighterModel(defenderModel) + || scene.fighterByModelId?.has(defenderModel.id) + ) { + continue; + } + + defenderModel.hp = 0; + killFighterModel(scene, defenderModel, winnerModel, null, { silentLog: true }); + resolvedDeaths += 1; + } + + return resolvedDeaths; +} + +export function updateFighterModel(scene, fighterModel, time, onWinner, delta = 16.67) { + const fighter = scene.fighterForModelId?.(fighterModel?.id); + + if ( + !isLivingFighterModel(fighterModel) + || fighterModel.isFrostStunned + || fighterModel.isLocked + ) { + stopFighterMovement(fighter); return; } - const enemy = resolveTargetEnemy(scene, fighter, time); + const enemyModel = resolveTargetEnemyModel(scene, fighterModel, time); - if (!enemy) { - fighter.body?.setVelocity(0, 0); + if (!enemyModel) { + stopFighterMovement(fighter); return; } - const deltaX = fighter.x - enemy.x; - const deltaY = fighter.y - enemy.y; - const distance = deltaX * deltaX + deltaY * deltaY; - const attackRange = getAttackRange(fighter); - fighter.setFlipX(enemy.x < fighter.x); + const enemy = scene.fighterForModelId?.(enemyModel.id); + const distance = fighterModelDistanceSquared(fighterModel, enemyModel); + const attackRange = getAttackRange(fighterModel); + setModelFacing(fighterModel, enemyModel.x < fighterModel.x); + setFighterFacing(fighter, fighterModel.facingLeft); if (distance > attackRange * attackRange) { - scene.physics.moveToObject( + moveCombatantToward( + scene, + fighterModel, + enemyModel, fighter, enemy, - combatStatsFor(fighter).moveSpeed * fighterMovementSpeedMultiplier(fighter), + combatStatsFor(fighterModel).moveSpeed * fighterMovementSpeedMultiplier(fighterModel), + delta, ); - playIfNeeded(fighter, "walk"); + playFighterActionIfNeeded(fighter, "walk"); return; } - fighter.body.setVelocity(0, 0); + stopFighterMovement(fighter); - if (time >= fighter.nextAttackAt) { - beginAttack(scene, fighter, enemy, time, onWinner); + if (time >= fighterModel.nextAttackAt) { + beginModelAttack(scene, fighterModel, enemyModel, time, onWinner); return; } - playIfNeeded(fighter, "idle"); + playFighterActionIfNeeded(fighter, "idle"); } export function clearCombatObjects(scene) { - scene.combatObjects?.forEach((object) => { - object.cleanup?.(); - object.destroy(); + Array.from(scene.combatObjects ?? []).forEach((object) => { + disposeCombatObject(scene, object); }); scene.combatObjects?.clear(); + scene.combatTargetIndex = null; + scene.combatTargetIndexModelCount = 0; + scene.nextCombatTargetIndexRefreshAt = 0; + scene.nextFighterModelIndexAuditAt = 0; + scene.nextAggregateCombatAt = 0; + scene.lastAggregateCombatAt = 0; + scene.aggregateCombatState = null; + scene.aggregateCombatDamageCursor = 0; + scene.aggregateCombatWinnerCursor = 0; + scene.aggregateWorkerJobPending = false; + scene.aggregateWorkerJobId = 0; + scene.aggregateWorkerResults = []; +} + +function syncAttachedFighterModels(scene) { + const attachedFighters = scene.fighterByModelId?.values?.(); + + if (attachedFighters) { + Array.from(attachedFighters).forEach(syncFighterModelFromSprite); + return; + } + + scene.fighters?.forEach(syncFighterModelFromSprite); +} + +function shouldAuditFighterModelIndex(scene, time) { + const now = combatFrameTime(scene, time); + + if (now < (scene.nextFighterModelIndexAuditAt ?? 0)) { + return false; + } + + scene.nextFighterModelIndexAuditAt = now + 1000; + return (scene.fighterModels?.length ?? 0) !== livingFighterProxyCount(scene); +} + +function shouldRefreshCombatTargetIndex(scene, time) { + if (!scene.combatTargetIndex) { + return true; + } + + if (resolveCombatTargetModelCount(scene) !== (scene.combatTargetIndexModelCount ?? 0)) { + return true; + } + + if (!isLargeBattleCombatScene(scene)) { + return true; + } + + return combatFrameTime(scene, time) >= (scene.nextCombatTargetIndexRefreshAt ?? 0); +} + +function resolveCombatTargetIndexRefreshMs(scene) { + if (!isLargeBattleCombatScene(scene)) { + return 0; + } + + return Math.max(0, Number(PERFORMANCE.LARGE_BATTLE_TARGET_INDEX_REFRESH_MS) || 0); +} + +function resolveCombatTargetModels(scene) { + if (!isLargeBattleCombatScene(scene)) { + return scene.fighterModels ?? []; + } + + const attachedModels = Array.from(scene.fighterByModelId?.values?.() ?? []) + .map(getFighterModel) + .filter(isLivingFighterModel); + + return attachedModels.length > 0 ? attachedModels : scene.fighterModels ?? []; +} + +function resolveCombatTargetModelCount(scene) { + if (!isLargeBattleCombatScene(scene)) { + return scene.fighterModels?.length ?? 0; + } + + const attachedCount = scene.fighterByModelId?.size ?? 0; + + return attachedCount > 0 ? attachedCount : scene.fighterModels?.length ?? 0; +} + +function isLargeBattleCombatScene(scene) { + return Boolean( + !scene.presentationMode + && !scene.matchOver + && (scene.fighterModels?.length ?? 0) >= Math.max(1, PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD), + ); +} + +function livingFighterProxyCount(scene) { + return scene.fighters?.reduce((count, fighter) => ( + isLivingFighterModel(getFighterModel(fighter)) ? count + 1 : count + ), 0) ?? 0; +} + +function combatFrameTime(scene, time) { + return Number.isFinite(time) ? time : scene.time?.now ?? 0; +} + +function moveCombatantToward(scene, fighterModel, targetModel, fighter, target, speed, delta) { + if (fighter) { + moveFighterToward(scene, fighter, target ?? targetModel, speed, delta); + return; + } + + const deltaX = targetModel.x - fighterModel.x; + const deltaY = targetModel.y - fighterModel.y; + const distance = Math.hypot(deltaX, deltaY); + + if (distance <= 0) { + return; + } + + const step = Math.min( + distance, + speed * (Math.max(0, Number(delta) || 0) / 1000), + ); + + fighterModel.x += (deltaX / distance) * step; + fighterModel.y += (deltaY / distance) * step; + clampModelInsideArena(fighterModel); +} + +function clampModelInsideArena(model) { + if (!model) { + return; + } + + const halfWidth = FIGHTER.HITBOX_WIDTH / 2; + const halfHeight = FIGHTER.HITBOX_HEIGHT / 2; + + model.x = Phaser.Math.Clamp(model.x, halfWidth, ARENA.SIZE - halfWidth); + model.y = Phaser.Math.Clamp(model.y, halfHeight, ARENA.SIZE - halfHeight); +} + +function setModelFacing(model, faceLeft) { + if (model) { + model.facingLeft = Boolean(faceLeft); + } +} + +function shouldAggregateDetachedCombat(scene) { + return Boolean( + isLargeBattleCombatScene(scene) + && resolveAggregateCombatRefreshMs() > 0 + ); +} + +function resolveAggregateCombatRefreshMs() { + return Math.max( + 0, + Number(PERFORMANCE.LARGE_BATTLE_AGGREGATE_COMBAT_REFRESH_MS) || 0, + ); +} + +function resolveAggregateCombatDelta(scene, now, refreshMs) { + const previousTime = Number(scene.lastAggregateCombatAt) || 0; + + if (previousTime <= 0) { + return refreshMs; + } + + return Phaser.Math.Clamp( + now - previousTime, + refreshMs * 0.5, + Math.max(refreshMs, Number(PERFORMANCE.LARGE_BATTLE_SIMULATION_MAX_DELTA_MS) || refreshMs), + ); +} + +function buildAggregateSquadState(scene) { + const cellSize = Math.max( + 1, + Number(PERFORMANCE.LARGE_BATTLE_AGGREGATE_CELL_SIZE) || PERFORMANCE.TARGET_GRID_CELL_SIZE, + ); + const squadSize = Math.max( + 1, + Math.round(Number(PERFORMANCE.LARGE_BATTLE_AGGREGATE_SQUAD_SIZE) || 100), + ); + const maxCellX = Math.floor((ARENA.SIZE - 1) / cellSize); + const maxCellY = Math.floor((ARENA.SIZE - 1) / cellSize); + const columns = maxCellX + 1; + const cellsByKey = new Map(); + const cells = []; + + (scene.fighterModels ?? []).forEach((model) => { + if (!isDetachedAggregateModel(scene, model)) { + return; + } + + const cellX = clampCell(model.x, cellSize, maxCellX); + const cellY = clampCell(model.y, cellSize, maxCellY); + const key = targetCellIndex(cellX, cellY, columns); + let cell = cellsByKey.get(key); + + if (!cell) { + cell = { + centerX: (cellX + 0.5) * cellSize, + centerY: (cellY + 0.5) * cellSize, + cellX, + cellY, + key, + teams: new Map(), + }; + cellsByKey.set(key, cell); + cells.push(cell); + } + + const teamId = model.team?.id ?? "unknown"; + let group = cell.teams.get(teamId); + + if (!group) { + group = { + models: [], + teamId, + }; + cell.teams.set(teamId, group); + } + + group.models.push(model); + }); + + return { + cellSize, + cells, + squads: createAggregateSquads(cells, squadSize), + }; +} + +function createAggregateSquads(cells, squadSize) { + const squads = []; + + cells.forEach((cell) => { + cell.squads = []; + + cell.teams.forEach((group) => { + for (let offset = 0; offset < group.models.length; offset += squadSize) { + const models = group.models.slice(offset, offset + squadSize); + + if (models.length === 0) { + continue; + } + + const squad = createAggregateSquad(cell, group.teamId, models, offset / squadSize); + squads.push(squad); + cell.squads.push(squad); + } + }); + }); + + return squads; +} + +function createAggregateSquad(cell, teamId, models, chunkIndex) { + const center = averageAggregateModelPosition(models); + const seed = aggregateSquadSeed(cell.key, teamId, chunkIndex); + const count = models.length; + + return { + averageMoveSpeed: averageAggregateMoveSpeed(models), + cell, + centerX: center.x, + centerY: center.y, + count, + dps: aggregateGroupDamage(models, 1000), + id: `${cell.key}:${teamId}:${chunkIndex}`, + models, + radius: resolveAggregateSquadRadius(count), + seed, + teamId, + targetX: null, + targetY: null, + }; +} + +function averageAggregateModelPosition(models) { + const total = models.reduce( + (position, model) => { + position.x += model.x; + position.y += model.y; + return position; + }, + { x: 0, y: 0 }, + ); + const count = Math.max(1, models.length); + + return { + x: total.x / count, + y: total.y / count, + }; +} + +function averageAggregateMoveSpeed(models) { + if (models.length === 0) { + return 0; + } + + const totalSpeed = models.reduce((speed, model) => ( + speed + + combatStatsFor(model).moveSpeed + * fighterMovementSpeedMultiplier(model) + ), 0); + + return totalSpeed / models.length; +} + +function resolveAggregateSquadRadius(count) { + const spacing = Math.max(FIGHTER.HITBOX_WIDTH, FIGHTER.HITBOX_HEIGHT) * 1.55; + + return Math.max( + ARENA.TILE_SIZE * 0.42, + Math.sqrt(Math.max(1, count)) * spacing, + ); +} + +function isDetachedAggregateModel(scene, model) { + return Boolean( + isLivingFighterModel(model) + && !scene.fighterByModelId?.has(model.id) + ); +} + +function assignAggregateSquadTargets({ cellSize, cells, squads }) { + squads.forEach((squad) => { + const targetSquad = findNearestAggregateEnemySquad(squads, squad); + + if (!targetSquad) { + squad.targetX = null; + squad.targetY = null; + return; + } + + const spread = cellSize * 0.18; + const offsetX = (((squad.seed % 997) / 997) - 0.5) * spread; + const offsetY = ((((squad.seed * 31) % 991) / 991) - 0.5) * spread; + + squad.targetX = Phaser.Math.Clamp( + targetSquad.centerX + offsetX, + FIGHTER.HITBOX_WIDTH / 2, + ARENA.SIZE - FIGHTER.HITBOX_WIDTH / 2, + ); + squad.targetY = Phaser.Math.Clamp( + targetSquad.centerY + offsetY, + FIGHTER.HITBOX_HEIGHT / 2, + ARENA.SIZE - FIGHTER.HITBOX_HEIGHT / 2, + ); + + squad.models.forEach((model) => { + model.targetModelId = null; + }); + }); +} + +function findNearestAggregateEnemySquad(squads, sourceSquad) { + let nearestSquad = null; + let nearestDistance = Number.POSITIVE_INFINITY; + + squads.forEach((candidateSquad) => { + if ( + candidateSquad === sourceSquad + || candidateSquad.teamId === sourceSquad.teamId + || 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) { + const seconds = Math.max(0, Number(tickDelta) || 0) / 1000; + + if (seconds <= 0) { + return; + } + + const movementRatio = Phaser.Math.Clamp( + Number(PERFORMANCE.LARGE_BATTLE_AGGREGATE_MOVEMENT_RATIO) || 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 = Phaser.Math.Clamp( + squad.centerX + (deltaX / distance) * step, + FIGHTER.HITBOX_WIDTH / 2, + ARENA.SIZE - FIGHTER.HITBOX_WIDTH / 2, + ); + squad.centerY = Phaser.Math.Clamp( + squad.centerY + (deltaY / distance) * step, + FIGHTER.HITBOX_HEIGHT / 2, + ARENA.SIZE - FIGHTER.HITBOX_HEIGHT / 2, + ); + }); +} + +function resolveAggregateSquadCombatState(scene, { cells }, tickDelta) { + const maxDeathsPerCell = Math.max( + 1, + Math.round(Number(PERFORMANCE.LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_CELL_TICK) || 1), + ); + const maxDeathsPerTick = Math.max( + maxDeathsPerCell, + Math.round(Number(PERFORMANCE.LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_TICK) || maxDeathsPerCell), + ); + let resolvedDeaths = 0; + + for (let index = 0; index < cells.length && resolvedDeaths < maxDeathsPerTick; index += 1) { + const groups = (cells[index].squads ?? []) + .filter((squad) => squad.count > 0 && squad.models.some(isLivingFighterModel)) + .sort((left, right) => right.count - left.count); + + if (groups.length < 2) { + continue; + } + + const leftGroup = groups[0]; + const rightGroup = groups.find((candidate) => candidate.teamId !== leftGroup.teamId); + + if (!rightGroup) { + continue; + } + + const cellDeathBudget = Math.min( + maxDeathsPerCell, + maxDeathsPerTick - resolvedDeaths, + ); + let cellDeaths = 0; + + cellDeaths += applyAggregateDamage( + scene, + rightGroup.models, + (leftGroup.dps * Math.max(0, Number(tickDelta) || 0)) / 1000, + leftGroup.models, + cellDeathBudget - cellDeaths, + ); + + if (cellDeaths < cellDeathBudget) { + cellDeaths += applyAggregateDamage( + scene, + leftGroup.models, + (rightGroup.dps * Math.max(0, Number(tickDelta) || 0)) / 1000, + rightGroup.models, + cellDeathBudget - cellDeaths, + ); + } + + resolvedDeaths += cellDeaths; + } + + return resolvedDeaths; +} + +function syncAggregateSquadMembers(squads) { + squads.forEach((squad) => { + const livingModels = squad.models.filter(isLivingFighterModel); + squad.count = livingModels.length; + + livingModels.forEach((model, index) => { + const slot = aggregateSquadSlot(squad, index, livingModels.length); + model.x = Phaser.Math.Clamp( + squad.centerX + slot.x, + FIGHTER.HITBOX_WIDTH / 2, + ARENA.SIZE - FIGHTER.HITBOX_WIDTH / 2, + ); + model.y = Phaser.Math.Clamp( + squad.centerY + slot.y, + FIGHTER.HITBOX_HEIGHT / 2, + ARENA.SIZE - FIGHTER.HITBOX_HEIGHT / 2, + ); + model.aggregateSquadId = squad.id; + model.isLocked = false; + }); + }); +} + +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 aggregateGroupDamage(models, tickDelta) { + const seconds = Math.max(0, Number(tickDelta) || 0) / 1000; + + if (seconds <= 0) { + return 0; + } + + return models.reduce((damage, model) => { + if (!isLivingFighterModel(model) || model.isFrostStunned) { + return damage; + } + + const stats = combatStatsFor(model); + const averageDamage = (Number(stats.damageMin) + Number(stats.damageMax)) / 2; + const attackDelaySeconds = Math.max(0.1, scaledAttackDelay(stats.attackCooldown, model) / 1000); + const criticalPressure = 1 + getCriticalChance(model) * 0.5; + + return damage + (averageDamage * criticalPressure * seconds) / attackDelaySeconds; + }, 0); +} + +function applyAggregateDamage(scene, defenderModels, damage, attackerModels, maxDeaths) { + if (damage <= 0 || maxDeaths <= 0 || defenderModels.length === 0) { + return 0; + } + + const startIndex = (scene.aggregateCombatDamageCursor ?? 0) % defenderModels.length; + let remainingDamage = damage; + let resolvedDeaths = 0; + + scene.aggregateCombatDamageCursor = (scene.aggregateCombatDamageCursor ?? 0) + 1; + + for ( + let checked = 0; + checked < defenderModels.length && remainingDamage > 0 && resolvedDeaths < maxDeaths; + checked += 1 + ) { + const defenderModel = defenderModels[(startIndex + checked) % defenderModels.length]; + + if (!isLivingFighterModel(defenderModel)) { + continue; + } + + const hp = Math.max(0, Number(defenderModel.hp) || 0); + + if (remainingDamage >= hp) { + remainingDamage -= hp; + killFighterModel( + scene, + defenderModel, + pickAggregateWinnerModel(scene, attackerModels), + null, + { silentLog: true }, + ); + resolvedDeaths += 1; + continue; + } + + defenderModel.hp = Math.max(1, hp - remainingDamage); + remainingDamage = 0; + } + + return resolvedDeaths; +} + +function pickAggregateWinnerModel(scene, models) { + if (models.length === 0) { + return null; + } + + const startIndex = (scene.aggregateCombatWinnerCursor ?? 0) % models.length; + + scene.aggregateCombatWinnerCursor = (scene.aggregateCombatWinnerCursor ?? 0) + 1; + + for (let index = 0; index < models.length; index += 1) { + const model = models[(startIndex + index) % models.length]; + + if (isLivingFighterModel(model) && !model.isFrostStunned) { + return model; + } + } + + return null; +} + +function aggregateSquadSeed(cellKey, teamId, chunkIndex) { + const id = `${cellKey}:${teamId}:${chunkIndex}`; + let seed = 17; + + for (let index = 0; index < id.length; index += 1) { + seed = (seed * 31 + id.charCodeAt(index)) % 104729; + } + + return seed; +} + +function beginModelAttack(scene, attackerModel, defenderModel, time, onWinner) { + const attacker = scene.fighterForModelId?.(attackerModel.id); + const defender = scene.fighterForModelId?.(defenderModel.id); + + if (attacker && defender) { + beginAttack(scene, attacker, defender, time, onWinner); + return; + } + + const attack = createAttackProfile(attackerModel); + const attackerStats = combatStatsFor(attackerModel); + const matchId = scene.matchId; + attackerModel.nextAttackAt = + time + scaledAttackDelay(attackerStats.attackCooldown, attackerModel); + attackerModel.isLocked = attacker ? shouldRenderFighterDetail(attacker) : false; + playFighterAction(attacker, attack.animation, fighterAttackSpeedMultiplier(attackerModel)); + + const windupDelay = scaledAttackDelay(attackerStats.windupDelay, attackerModel); + + switch (getCombatType(attackerModel)) { + case "projectile": { + const projectileSpeed = attackerStats.projectileSpeed * fighterAttackSpeedMultiplier(attackerModel); + const travelDistance = Phaser.Math.Distance.Between( + attackerModel.x, + attackerModel.y, + defenderModel.x, + defenderModel.y, + ); + const travelDelay = Math.max(0, (travelDistance / Math.max(1, projectileSpeed)) * 1000); + queueModelHit(scene, attackerModel, defenderModel, onWinner, matchId, { + isCritical: attack.isCritical, + delay: windupDelay + travelDelay, + }); + return; + } + case "instant-spell": + queueModelHit(scene, attackerModel, defenderModel, onWinner, matchId, { + isCritical: attack.isCritical, + delay: windupDelay + scaledAttackDelay(attackerStats.effectHitDelay, attackerModel), + }); + return; + default: + queueModelHit(scene, attackerModel, defenderModel, onWinner, matchId, { + isCritical: attack.isCritical, + delay: windupDelay, + }); + } +} + +function queueModelHit( + scene, + attackerModel, + defenderModel, + onWinner, + matchId, + { delay = 0, isCritical = false } = {}, +) { + scene.time.delayedCall(Math.max(0, delay), () => { + applyModelHit(scene, attackerModel, defenderModel, onWinner, matchId, { + isCritical, + }); + }); } function beginAttack(scene, attacker, defender, time, onWinner) { const attack = createAttackProfile(attacker); attacker.nextAttackAt = time + scaledAttackDelay(combatStatsFor(attacker).attackCooldown, attacker); - attacker.isLocked = true; + attacker.isLocked = shouldRenderFighterDetail(attacker); scene.observeCombat?.(attacker, defender); scene.triggerFinalCombatSlowMotion?.(attacker, defender, attack.animation); - playAnimation(attacker, attack.animation, fighterAttackSpeedMultiplier(attacker)); + playFighterAction(attacker, attack.animation, fighterAttackSpeedMultiplier(attacker)); switch (getCombatType(attacker)) { case "projectile": @@ -139,8 +1177,29 @@ function queueInstantSpell(scene, attacker, defender, onWinner, attack) { } function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) { - const defenderHitPoint = fighterHitPoint(defender); + const defenderHitPoint = fighterWorldPoint(defender); const projectileOrigin = projectileSpawnPoint(attacker, defenderHitPoint); + const projectileSpeed = + combatStatsFor(attacker).projectileSpeed * + fighterAttackSpeedMultiplier(attacker); + + if (shouldResolveProjectileWithoutRender(scene, attacker, defender)) { + const travelDistance = Phaser.Math.Distance.Between( + projectileOrigin.x, + projectileOrigin.y, + defenderHitPoint.x, + defenderHitPoint.y, + ); + const travelDelay = Math.max(0, (travelDistance / Math.max(1, projectileSpeed)) * 1000); + + scene.time.delayedCall(travelDelay, () => { + applyHit(scene, attacker, defender, onWinner, matchId, { + isCritical: attack.isCritical, + }); + }); + return; + } + const projectile = scene.physics.add.image( projectileOrigin.x, projectileOrigin.y, @@ -165,8 +1224,7 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) { projectile, defenderHitPoint.x, defenderHitPoint.y, - combatStatsFor(attacker).projectileSpeed * - fighterAttackSpeedMultiplier(attacker), + projectileSpeed, ); trackCombatObject(scene, projectile); @@ -190,7 +1248,6 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) { }); }; - const overlap = scene.physics.add.overlap(projectile, defender, hitDefender); const checkProjectilePath = () => { if (!projectile.active || projectile.hasHit) { return; @@ -213,7 +1270,6 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) { scene.events.on(Phaser.Scenes.Events.UPDATE, checkProjectilePath); projectile.cleanup = () => { - overlap.destroy(); scene.events.off(Phaser.Scenes.Events.UPDATE, checkProjectilePath); }; @@ -223,16 +1279,28 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) { } function spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack) { - if (shouldRenderCombatEffects(scene)) { - const effect = scene.add.sprite(defender.x, defender.y, fighterAttackEffectKey(attacker.skin)); + if (shouldRenderCombatEffects(scene) && shouldRenderFighterDetail(defender)) { + const defenderPoint = fighterWorldPoint(defender); + const effectKey = fighterAttackEffectKey(attacker.skin); + const effect = acquireSpellEffect( + scene, + effectKey, + defenderPoint.x, + defenderPoint.y, + ); effect.setDepth(3); effect.setScale(FIGHTER.SCALE); effect.play(fighterAttackEffectAnimationKey(attacker.skin)); trackCombatObject(scene, effect); - effect.once(Phaser.Animations.Events.ANIMATION_COMPLETE, () => { + const completeHandler = () => { disposeCombatObject(scene, effect); - }); + }; + effect._spellEffectCompleteHandler = completeHandler; + effect.cleanup = () => { + removeSpellEffectCompleteHandler(effect); + }; + effect.once(Phaser.Animations.Events.ANIMATION_COMPLETE, completeHandler); } scene.time.delayedCall( @@ -250,7 +1318,7 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = f return; } - if (isCritical && shouldRenderCombatEffects(scene)) { + if (isCritical && shouldRenderCombatEffects(scene) && shouldRenderFighterDetail(defender)) { spawnCriticalHitLabel(scene, defender); } @@ -261,15 +1329,44 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = f 0, defender.hp - Phaser.Math.Between(attackerStats.damageMin, attackerStats.damageMax), ); - defender.body.setVelocity(0, 0); + stopFighterMovement(defender); if (defender.hp === 0) { killFighter(defender, attacker, onWinner); return; } - defender.isLocked = true; - playAnimation(defender, "hurt"); + defender.isLocked = shouldRenderFighterDetail(defender); + playFighterAction(defender, "hurt"); +} + +function applyModelHit(scene, attackerModel, defenderModel, onWinner, matchId, { isCritical = false } = {}) { + if (!isModelAttackValid(scene, attackerModel, defenderModel, matchId)) { + return; + } + + const defender = scene.fighterForModelId?.(defenderModel.id); + + if (isCritical && defender && shouldRenderCombatEffects(scene) && shouldRenderFighterDetail(defender)) { + spawnCriticalHitLabel(scene, defender); + } + + const attackerStats = combatStatsFor(attackerModel); + defenderModel.hp = isCritical + ? 0 + : Math.max( + 0, + defenderModel.hp - Phaser.Math.Between(attackerStats.damageMin, attackerStats.damageMax), + ); + stopFighterMovement(defender); + + if (defenderModel.hp === 0) { + killFighterModel(scene, defenderModel, attackerModel, onWinner); + return; + } + + defenderModel.isLocked = defender ? shouldRenderFighterDetail(defender) : false; + playFighterAction(defender, "hurt"); } export function applyWorldEffectDamage(scene, defender, damage) { @@ -284,22 +1381,23 @@ export function applyWorldEffectDamage(scene, defender, damage) { } defender.hp = Math.max(0, defender.hp - resolvedDamage); - defender.body?.setVelocity(0, 0); + stopFighterMovement(defender); if (defender.hp === 0) { killFighter(defender); return true; } - defender.isLocked = true; - playAnimation(defender, "hurt"); + defender.isLocked = shouldRenderFighterDetail(defender); + playFighterAction(defender, "hurt"); return false; } function spawnCriticalHitLabel(scene, defender) { const scaleRatio = Math.max(1, Math.abs(defender.scaleY) / FIGHTER.SCALE); + const defenderPoint = fighterWorldPoint(defender); const label = scene.add - .text(defender.x, defender.y - 44 * scaleRatio - 24, "Critical!", { + .text(defenderPoint.x, defenderPoint.y - 44 * scaleRatio - 24, "Critical!", { color: "#ffe45c", fontFamily: "Inter, Pretendard, sans-serif", fontSize: "24px", @@ -360,77 +1458,110 @@ function isAttackValid(scene, attacker, defender, matchId) { ); } -function fighterHitPoint(fighter) { - if (!fighter.body) { - return { x: fighter.x, y: fighter.y }; - } - - return { - x: fighter.body.center.x, - y: fighter.body.center.y, - }; -} - function projectileSpawnPoint(attacker, target) { - const direction = new Phaser.Math.Vector2(target.x - attacker.x, target.y - attacker.y); + const origin = fighterWorldPoint(attacker); + const deltaX = target.x - origin.x; + const deltaY = target.y - origin.y; + const distance = Math.hypot(deltaX, deltaY); - if (direction.lengthSq() === 0) { - return { x: attacker.x, y: attacker.y }; + if (distance === 0) { + return origin; } - direction.normalize(); - return { - x: attacker.x + direction.x * PROJECTILE.SPAWN_DISTANCE, - y: attacker.y + direction.y * PROJECTILE.SPAWN_DISTANCE, + x: origin.x + (deltaX / distance) * PROJECTILE.SPAWN_DISTANCE, + y: origin.y + (deltaY / distance) * PROJECTILE.SPAWN_DISTANCE, }; } function projectilePathHitsDefender(projectile, defender) { - if (!defender.body) { + if (!isFighterBodyEnabled(defender)) { return false; } - const projectilePath = new Phaser.Geom.Line( - projectile.lastHitCheckX, - projectile.lastHitCheckY, - projectile.x, - projectile.y, - ); - const defenderHitArea = new Phaser.Geom.Rectangle( - defender.body.x - PROJECTILE.HIT_PADDING, - defender.body.y - PROJECTILE.HIT_PADDING, - defender.body.width + PROJECTILE.HIT_PADDING * 2, - defender.body.height + PROJECTILE.HIT_PADDING * 2, - ); + PROJECTILE_PATH.x1 = projectile.lastHitCheckX; + PROJECTILE_PATH.y1 = projectile.lastHitCheckY; + PROJECTILE_PATH.x2 = projectile.x; + PROJECTILE_PATH.y2 = projectile.y; + PROJECTILE_HIT_AREA.x = defender.body.x - PROJECTILE.HIT_PADDING; + PROJECTILE_HIT_AREA.y = defender.body.y - PROJECTILE.HIT_PADDING; + PROJECTILE_HIT_AREA.width = defender.body.width + PROJECTILE.HIT_PADDING * 2; + PROJECTILE_HIT_AREA.height = defender.body.height + PROJECTILE.HIT_PADDING * 2; return ( - Phaser.Geom.Rectangle.Contains(defenderHitArea, projectile.x, projectile.y) || - Phaser.Geom.Intersects.LineToRectangle(projectilePath, defenderHitArea) + Phaser.Geom.Rectangle.Contains(PROJECTILE_HIT_AREA, projectile.x, projectile.y) || + Phaser.Geom.Intersects.LineToRectangle(PROJECTILE_PATH, PROJECTILE_HIT_AREA) ); } function killFighter(defender, winner, onWinner) { - defender.isDead = true; - defender.isLocked = true; - defender.body.setVelocity(0, 0); - defender.body.enable = false; + killFighterModel( + defender.scene, + getFighterModel(defender), + getFighterModel(winner), + onWinner, + ); +} + +function killFighterModel( + scene, + defenderModel, + winnerModel, + onWinner, + { silentLog = false } = {}, +) { + if (!isLivingFighterModel(defenderModel)) { + return; + } + + const defender = scene.fighterForModelId?.(defenderModel.id); + const winner = winnerModel ? scene.fighterForModelId?.(winnerModel.id) : null; + + defenderModel.isDead = true; + defenderModel.isLocked = true; + + if (!defender) { + scene.unregisterFighterModel?.(defenderModel); + scene.removeDetachedFighterProxyForModel?.(defenderModel); + + if (winnerModel) { + winnerModel.isLocked = false; + if (silentLog) { + scene.recordDeath?.(defenderModel); + } else { + scene.recordKill?.(winner ?? winnerModel, defenderModel); + } + applyKillRewardModel(scene, winnerModel); + } else { + scene.recordDeath?.(defenderModel); + } + + maybeSplitFighterModel(scene, defenderModel); + onWinner?.(winner ?? null); + return; + } + + disableFighterBody(defender); defender.setDepth(FIGHTER.DEAD_DEPTH); defender.disableInteractive(); defender.releaseHud?.(); - playAnimation(defender, "death"); + playFighterAction(defender, "death"); - if (winner) { - winner.isLocked = false; - winner.body.setVelocity(0, 0); - playAnimation(winner, "idle"); - winner.scene.recordKill?.(winner, defender); - applyKillReward(winner); + if (winnerModel) { + winnerModel.isLocked = false; + stopFighterMovement(winner); + playFighterAction(winner, "idle"); + if (silentLog) { + scene.recordDeath?.(defender); + } else { + scene.recordKill?.(winner ?? winnerModel, defender); + } + applyKillRewardModel(scene, winnerModel); } else { - defender.scene.recordDeath?.(defender); + scene.recordDeath?.(defender); } - maybeSplitFighter(defender); + maybeSplitFighterModel(scene, defenderModel); onWinner?.(winner); scheduleDeadFighterDespawn(defender); } @@ -493,15 +1624,16 @@ function removeFighterFromBattlefield(scene, fighter) { return; } + scene.unregisterFighter?.(fighter); scene.fighters = scene.fighters.filter((candidate) => candidate !== fighter); } -function maybeSplitFighter(fighter) { - const splitOnDeath = fighter.canSplitOnDeath === false +function maybeSplitFighterModel(scene, fighterModel) { + const splitOnDeath = fighterModel.canSplitOnDeath === false ? null - : fighter.skin.traits?.splitOnDeath; + : fighterModel.skin.traits?.splitOnDeath; - if (!splitOnDeath || typeof fighter.scene.spawnSplitFighters !== "function") { + if (!splitOnDeath || typeof scene.spawnSplitFighters !== "function") { return; } @@ -511,7 +1643,23 @@ function maybeSplitFighter(fighter) { return; } - fighter.scene.spawnSplitFighters(fighter, splitOnDeath); + scene.spawnSplitFighters(fighterModel, splitOnDeath); +} + +function applyKillRewardModel(scene, winnerModel) { + const winner = scene.fighterForModelId?.(winnerModel.id); + + if (winner) { + applyKillReward(winner); + return; + } + + winnerModel.killCount = (winnerModel.killCount ?? 0) + 1; + winnerModel.killRewardMultiplier = Math.min( + COMBAT.KILL_GROWTH_MAX_MULTIPLIER, + COMBAT.KILL_GROWTH_MULTIPLIER ** winnerModel.killCount, + ); + winnerModel.hp = recoveredHealth(winnerModel); } function applyKillReward(winner) { @@ -551,26 +1699,6 @@ function applyKillReward(winner) { }); } -function clampFighterInsideArena(fighter) { - if (!fighter?.active || !fighter.body) { - return; - } - - const halfWidth = Math.min( - ARENA.SIZE / 2, - Math.max(Math.abs(fighter.displayWidth), fighter.body.width) / 2, - ); - const halfHeight = Math.min( - ARENA.SIZE / 2, - Math.max(Math.abs(fighter.displayHeight), fighter.body.height) / 2, - ); - const x = Phaser.Math.Clamp(fighter.x, halfWidth, ARENA.SIZE - halfWidth); - const y = Phaser.Math.Clamp(fighter.y, halfHeight, ARENA.SIZE - halfHeight); - - fighter.setPosition(x, y); - fighter.body.updateFromGameObject?.(); -} - function recoveredHealth(fighter) { const maxHp = fighter.maxHp ?? combatStatsFor(fighter).maxHp; const recovery = Math.ceil(fighter.hp * COMBAT.KILL_HEALTH_RECOVERY_RATIO); @@ -580,8 +1708,9 @@ function recoveredHealth(fighter) { function spawnKillHealEffect(fighter, effectScale) { const scene = fighter.scene; + const initialPoint = fighterWorldPoint(fighter); const effect = scene.add - .sprite(fighter.x, fighter.y, healEffectKey()) + .sprite(initialPoint.x, initialPoint.y, healEffectKey()) .setDepth(fighter.depth + 1) .setScale(effectScale); @@ -590,7 +1719,8 @@ function spawnKillHealEffect(fighter, effectScale) { return; } - effect.setPosition(fighter.x, fighter.y); + const point = fighterWorldPoint(fighter); + effect.setPosition(point.x, point.y); effect.setDepth(fighter.depth + 1); }; @@ -626,95 +1756,109 @@ function shouldRenderCombatEffects(scene) { return !isLargeBattle || Boolean(scene.meteorFocusState); } -function createTargetSpatialIndex(fighters) { +function shouldResolveProjectileWithoutRender(scene, attacker, defender) { + return ( + scene.fighters?.length >= Math.max(1, PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD) + && (!shouldRenderFighterDetail(attacker) || !shouldRenderFighterDetail(defender)) + ); +} + +function createTargetSpatialIndex(models, scene) { const cellSize = Math.max(1, Number(PERFORMANCE.TARGET_GRID_CELL_SIZE) || ARENA.TILE_SIZE); const maxCellX = Math.floor((ARENA.SIZE - 1) / cellSize); const maxCellY = Math.floor((ARENA.SIZE - 1) / cellSize); - const cells = new Map(); - const livingFighters = []; + const columns = maxCellX + 1; + const cells = Array.from( + { length: columns * (maxCellY + 1) }, + () => [], + ); + const livingModels = []; - fighters.forEach((fighter) => { - if (!fighter?.active || fighter.isDead) { + models.forEach((model) => { + if (!isLivingFighterModel(model)) { return; } - const cellX = clampCell(fighter.x, cellSize, maxCellX); - const cellY = clampCell(fighter.y, cellSize, maxCellY); - const key = targetCellKey(cellX, cellY); - const cell = cells.get(key) ?? []; + const cellX = clampCell(model.x, cellSize, maxCellX); + const cellY = clampCell(model.y, cellSize, maxCellY); - cell.push(fighter); - cells.set(key, cell); - livingFighters.push(fighter); + cells[targetCellIndex(cellX, cellY, columns)].push(model); + livingModels.push(model); }); return { cellSize, cells, - livingCount: livingFighters.length, - livingFighters, + columns, + livingCount: livingModels.length, + livingFighters: livingModels + .map((model) => scene?.fighterForModelId?.(model.id)) + .filter(Boolean), + livingModels, maxCellX, maxCellY, maxSearchRing: Math.max(maxCellX, maxCellY) + 1, }; } -function findNearestEnemy(scene, fighter) { +function findNearestEnemyModel(scene, fighterModel) { const targetIndex = scene.combatTargetIndex; if (!targetIndex) { - return findNearestEnemyByFullScan(scene.fighters ?? [], fighter); + return findNearestEnemyModelByFullScan(scene.fighterModels ?? [], fighterModel); } - return findNearestEnemyBySpatialIndex(targetIndex, fighter); + return findNearestEnemyModelBySpatialIndex(targetIndex, fighterModel); } -function findNearestEnemyByFullScan(fighters, fighter) { - let nearestEnemy; +function findNearestEnemyModelByFullScan(models, fighterModel) { + let nearestEnemyModel; let nearestDistance = Number.POSITIVE_INFINITY; - fighters.forEach((candidate) => { - if (candidate === fighter || !isValidEnemyTarget(fighter, candidate)) { + models.forEach((candidateModel) => { + if ( + candidateModel === fighterModel + || !isValidEnemyTargetModel(fighterModel, candidateModel) + ) { return; } - const deltaX = fighter.x - candidate.x; - const deltaY = fighter.y - candidate.y; - const distance = deltaX * deltaX + deltaY * deltaY; + const distance = fighterModelDistanceSquared(fighterModel, candidateModel); if (distance < nearestDistance) { nearestDistance = distance; - nearestEnemy = candidate; + nearestEnemyModel = candidateModel; } }); - return nearestEnemy; + return nearestEnemyModel; } -function findNearestEnemyBySpatialIndex(targetIndex, fighter) { - const cellX = clampCell(fighter.x, targetIndex.cellSize, targetIndex.maxCellX); - const cellY = clampCell(fighter.y, targetIndex.cellSize, targetIndex.maxCellY); - let nearestEnemy; +function findNearestEnemyModelBySpatialIndex(targetIndex, fighterModel) { + const cellX = clampCell(fighterModel.x, targetIndex.cellSize, targetIndex.maxCellX); + const cellY = clampCell(fighterModel.y, targetIndex.cellSize, targetIndex.maxCellY); + let nearestEnemyModel; let nearestDistance = Number.POSITIVE_INFINITY; for (let ring = 0; ring <= targetIndex.maxSearchRing; ring += 1) { - forEachTargetCellInRing(targetIndex, cellX, cellY, ring, (candidate) => { - if (candidate === fighter || !isValidEnemyTarget(fighter, candidate)) { + forEachTargetCellInRing(targetIndex, cellX, cellY, ring, (candidateModel) => { + if ( + candidateModel === fighterModel + || !isValidEnemyTargetModel(fighterModel, candidateModel) + ) { return; } - const deltaX = fighter.x - candidate.x; - const deltaY = fighter.y - candidate.y; - const distance = deltaX * deltaX + deltaY * deltaY; + const distance = fighterModelDistanceSquared(fighterModel, candidateModel); if (distance < nearestDistance) { nearestDistance = distance; - nearestEnemy = candidate; + nearestEnemyModel = candidateModel; } }); if ( - nearestEnemy + nearestEnemyModel && ring > 0 && nearestDistance <= (ring * targetIndex.cellSize) ** 2 ) { @@ -722,7 +1866,7 @@ function findNearestEnemyBySpatialIndex(targetIndex, fighter) { } } - return nearestEnemy; + return nearestEnemyModel; } function forEachTargetCellInRing(targetIndex, cellX, cellY, ring, callback) { @@ -752,81 +1896,81 @@ function forEachTargetCell(targetIndex, cellX, cellY, callback) { return; } - const cell = targetIndex.cells.get(targetCellKey(cellX, cellY)); + const cell = targetIndex.cells[targetCellIndex(cellX, cellY, targetIndex.columns)]; - if (!cell) { + if (!cell?.length) { return; } cell.forEach(callback); } -function targetCellKey(cellX, cellY) { - return `${cellX}:${cellY}`; +function targetCellIndex(cellX, cellY, columns) { + return cellY * columns + cellX; } function clampCell(value, cellSize, maxCell) { return Math.min(maxCell, Math.max(0, Math.floor(value / cellSize))); } -function resolveTargetEnemy(scene, fighter, time) { +function resolveTargetEnemyModel(scene, fighterModel, time) { const now = Number.isFinite(time) ? time : scene.time?.now ?? 0; + const cachedTargetModel = findTargetModel(scene, fighterModel.targetModelId); + const hasValidCachedTarget = isValidEnemyTargetModel(fighterModel, cachedTargetModel); if ( - isValidEnemyTarget(fighter, fighter.targetEnemy) - && now < (fighter.nextTargetScanAt ?? 0) + hasValidCachedTarget + && now < (fighterModel.nextTargetScanAt ?? 0) ) { - return fighter.targetEnemy; + return cachedTargetModel; } - const enemy = findNearestEnemy(scene, fighter) ?? null; - fighter.targetEnemy = enemy; - scheduleNextTargetScan(fighter, now); + if (!hasValidCachedTarget) { + fighterModel.targetModelId = null; + } - return enemy; + const enemyModel = findNearestEnemyModel(scene, fighterModel) ?? null; + fighterModel.targetModelId = enemyModel?.id ?? null; + scheduleNextTargetScan(fighterModel, now); + + return enemyModel; } -function scheduleNextTargetScan(fighter, now) { - fighter.nextTargetScanAt = +function scheduleNextTargetScan(fighterModel, now) { + fighterModel.nextTargetScanAt = now + TARGET_SCAN_INTERVAL_MS + Phaser.Math.Between(0, TARGET_SCAN_JITTER_MS); } -function isValidEnemyTarget(fighter, candidate) { - return Boolean( - candidate?.active - && !candidate.isDead - && candidate.team?.id !== fighter.team?.id, - ); -} - -function playIfNeeded(fighter, action) { - const key = resolveFighterAnimationKey(fighter, action); - - if (fighter.anims.currentAnim?.key !== key) { - playResolvedAnimation(fighter, key); +function findTargetModel(scene, targetModelId) { + if (!targetModelId) { + return null; } + + return scene.fighterModelForId?.(targetModelId) ?? null; } -function playAnimation(fighter, action, timeScale = 1) { - playResolvedAnimation(fighter, resolveFighterAnimationKey(fighter, action), timeScale); -} +function isValidEnemyTargetModel(fighterModel, candidateModel) { + const fighterTeamId = fighterModel?.team?.id; + const candidateTeamId = candidateModel?.team?.id; -function playResolvedAnimation(fighter, key, timeScale = 1) { - fighter.anims.timeScale = timeScale; - fighter.play(key, true); -} - -function resolveFighterAnimationKey(fighter, action) { - const key = ensureFighterTeamAnimation( - fighter.scene, - fighter.skin, - action, - fighter.team?.color, + return Boolean( + isLivingFighterModel(fighterModel) + && isLivingFighterModel(candidateModel) + && fighterTeamId + && candidateTeamId + && candidateTeamId !== fighterTeamId, ); +} - return key; +function isModelAttackValid(scene, attackerModel, defenderModel, matchId) { + return Boolean( + !scene.matchOver + && matchId === scene.matchId + && isLivingFighterModel(attackerModel) + && isLivingFighterModel(defenderModel), + ); } function scaledAttackDelay(duration, fighter) { @@ -859,11 +2003,95 @@ export function trackCombatObject(scene, object) { } export function disposeCombatObject(scene, object) { - if (!object?.active) { + if (!object) { + return; + } + + const isTracked = scene.combatObjects?.has(object); + + if (!object.active && !isTracked) { return; } object.cleanup?.(); scene.combatObjects?.delete(object); + + if (typeof object.releaseToPool === "function") { + object.releaseToPool(); + return; + } + object.destroy(); } + +function acquireSpellEffect(scene, effectKey, x, y) { + const pool = spellEffectPoolFor(scene, effectKey); + const effect = pool.pop() ?? scene.add.sprite(0, 0, effectKey, 0); + + effect._spellEffectKey = effectKey; + effect.releaseToPool = () => releaseSpellEffect(scene, effect); + effect.cleanup = null; + removeSpellEffectCompleteHandler(effect); + resetSpellEffectForUse(effect, effectKey, x, y); + + return effect; +} + +function releaseSpellEffect(scene, effect) { + const effectKey = effect._spellEffectKey; + + removeSpellEffectCompleteHandler(effect); + effect.cleanup = null; + effect.anims?.stop(); + effect.setActive(false); + effect.setVisible(false); + effect.setAlpha(1); + effect.setPosition(0, 0); + + const pool = spellEffectPoolFor(scene, effectKey); + + if (pool.length >= SPELL_EFFECT_POOL_LIMIT_PER_KEY) { + effect.releaseToPool = null; + effect.destroy(); + return; + } + + pool.push(effect); +} + +function resetSpellEffectForUse(effect, effectKey, x, y) { + effect.anims?.stop(); + effect + .setTexture(effectKey) + .setFrame(0) + .setPosition(x, y) + .setDepth(3) + .setScale(FIGHTER.SCALE) + .setAlpha(1) + .setRotation(0) + .setFlip(false, false) + .setActive(true) + .setVisible(true); +} + +function removeSpellEffectCompleteHandler(effect) { + if (!effect?._spellEffectCompleteHandler) { + return; + } + + effect.off( + Phaser.Animations.Events.ANIMATION_COMPLETE, + effect._spellEffectCompleteHandler, + ); + effect._spellEffectCompleteHandler = null; +} + +function spellEffectPoolFor(scene, effectKey) { + scene.spellEffectPools ??= new Map(); + + if (!scene.spellEffectPools.has(effectKey)) { + scene.spellEffectPools.set(effectKey, []); + } + + return scene.spellEffectPools.get(effectKey); +} diff --git a/src/game/combat/worldEffects.js b/src/game/combat/worldEffects.js index e938db6..f0a5d63 100644 --- a/src/game/combat/worldEffects.js +++ b/src/game/combat/worldEffects.js @@ -1,6 +1,7 @@ import Phaser from "phaser"; import { ARENA, + PERFORMANCE, WORLD_EFFECT, } from "../../constants.js"; import { @@ -8,6 +9,12 @@ import { disposeCombatObject, trackCombatObject, } from "./combat.js"; +import { + clearFighterTint, + fighterWorldPoint, + stopFighterMovement, + tintFighter, +} from "../fighter/fighterAdapter.js"; const METEOR_EFFECT_PATH = "assets/effects/world_Effect.png"; const METEOR_EFFECT_KEY = "world-meteor-effect"; @@ -91,6 +98,8 @@ export function clearWorldEffects(scene) { scene.clearMeteorCameraFocus?.(null, { restoreCamera: false }); scene.matchStartedAt = null; scene.isSuddenDeath = false; + scene.nextWorldEffectModifierRefreshAt = 0; + scene.worldEffectModifierActive = false; scene.fighters?.forEach((fighter) => { fighter.worldEffectSpeedMultiplier = 1; @@ -103,6 +112,20 @@ export function updateWorldEffectModifiers(scene) { (zone) => zone.marker?.active, ); + if (frostZones.length === 0 && !scene.worldEffectModifierActive) { + return; + } + + const now = scene.time?.now ?? 0; + const refreshMs = Math.max(0, Number(PERFORMANCE.WORLD_EFFECT_MODIFIER_REFRESH_MS) || 0); + + if (frostZones.length > 0 && refreshMs > 0 && now < (scene.nextWorldEffectModifierRefreshAt ?? 0)) { + return; + } + + scene.nextWorldEffectModifierRefreshAt = now + refreshMs; + scene.worldEffectModifierActive = frostZones.length > 0; + scene.fighters.forEach((fighter) => { const isSlowed = fighter.active @@ -151,8 +174,9 @@ export function findDensestWorldEffectZone(livingFighters) { ); livingFighters.forEach((fighter) => { - const x = fighter.body?.center.x ?? fighter.x; - const y = fighter.body?.center.y ?? fighter.y; + const point = fighterWorldPoint(fighter); + const x = point.x; + const y = point.y; const column = Phaser.Math.Clamp(Math.floor(x / ARENA.TILE_SIZE), 0, ARENA.GRID_SIZE - 1); const row = Phaser.Math.Clamp(Math.floor(y / ARENA.TILE_SIZE), 0, ARENA.GRID_SIZE - 1); @@ -552,8 +576,8 @@ function applyFrostStun(scene, fighter) { fighter.frostStunTimer?.remove(false); fighter.isFrostStunned = true; - fighter.body?.setVelocity(0, 0); - fighter.setTint(WORLD_EFFECT.FROST_STUN_TINT); + stopFighterMovement(fighter); + tintFighter(fighter, WORLD_EFFECT.FROST_STUN_TINT); fighter.frostStunTimer = scene.time.delayedCall(WORLD_EFFECT.FROST_STUN_DURATION, () => { clearFrostStun(fighter); }); @@ -564,9 +588,7 @@ function clearFrostStun(fighter) { fighter.frostStunTimer = null; fighter.isFrostStunned = false; - if (fighter.active) { - fighter.clearTint(); - } + clearFighterTint(fighter); } function activateFrostZone(scene, zone, marker) { @@ -600,8 +622,9 @@ function activateFrostZone(scene, zone, marker) { } function containsFighter(zone, fighter) { - const x = fighter.body?.center.x ?? fighter.x; - const y = fighter.body?.center.y ?? fighter.y; + const point = fighterWorldPoint(fighter); + const x = point.x; + const y = point.y; return Phaser.Geom.Rectangle.Contains(zone.bounds, x, y); } diff --git a/src/game/fighter/fighterAdapter.js b/src/game/fighter/fighterAdapter.js new file mode 100644 index 0000000..9d2e6c9 --- /dev/null +++ b/src/game/fighter/fighterAdapter.js @@ -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; +} diff --git a/src/game/fighter/fighterFactory.js b/src/game/fighter/fighterFactory.js index d619a87..719a257 100644 --- a/src/game/fighter/fighterFactory.js +++ b/src/game/fighter/fighterFactory.js @@ -8,14 +8,23 @@ import { ensureFighterTeamAnimations, fighterSheetKey, } from "./fighterAssets.js"; +import { + disableFighterBody, + enableFighterBody, +} from "./fighterAdapter.js"; +import { + attachFighterModel, + createFighterModel, + fighterModelPoint, +} from "./fighterModel.js"; import { getFighterStats } from "./fighterStats.js"; -const NAME_LABEL_BOTTOM_GAP = 14; const HUD_DETAIL_SYNC_INTERVAL_MS = 100; export function createFighter( scene, { canSplitOnDeath = true, faceLeft, hp, maxHp, name, skin, team, teamIndex, x, y }, + { attachSprite = true } = {}, ) { ensureFighterTeamAnimations(scene, skin, team.color, ["idle"]); @@ -23,7 +32,6 @@ export function createFighter( const idleSheetKey = scene.textures.exists(teamIdleSheetKey) ? teamIdleSheetKey : fighterSheetKey(skin, "idle"); - const fighter = scene.physics.add.sprite(x, y, idleSheetKey, 0); const displayName = name || team.label; const combatStats = getFighterStats(skin); const resolvedMaxHp = Math.max(1, Math.round(maxHp ?? combatStats.maxHp)); @@ -31,6 +39,32 @@ export function createFighter( 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.setName(displayName); @@ -40,41 +74,19 @@ export function createFighter( fighter.setFlipX(faceLeft); fighter.body.setSize(FIGHTER.HITBOX_WIDTH, FIGHTER.HITBOX_HEIGHT); fighter.body.setOffset(FIGHTER.HITBOX_OFFSET_X, FIGHTER.HITBOX_OFFSET_Y); - fighter.setInteractive( - new Phaser.Geom.Rectangle( - FIGHTER.HITBOX_OFFSET_X, - FIGHTER.HITBOX_OFFSET_Y, - FIGHTER.HITBOX_WIDTH, - FIGHTER.HITBOX_HEIGHT, - ), - Phaser.Geom.Rectangle.Contains, - ); + fighter.setInteractive(inputHitArea, Phaser.Geom.Rectangle.Contains); fighter.input.cursor = "pointer"; - fighter.skin = skin; - fighter.combatStats = combatStats; - fighter.fighterName = displayName; - fighter.team = team; - fighter.teamIndex = teamIndex; + fighter._inputHitArea = inputHitArea; fighter.baseScaleX = FIGHTER.SCALE; fighter.baseScaleY = FIGHTER.SCALE; - fighter.canSplitOnDeath = canSplitOnDeath; - fighter.isSelected = false; - fighter.killCount = 0; - fighter.killRewardMultiplier = 1; - fighter.worldEffectSpeedMultiplier = 1; - fighter.isFrostStunned = false; + fighter.deadDespawnTimer = null; + fighter.deadDespawnTween = null; fighter.frostStunTimer = null; - fighter.maxHp = resolvedMaxHp; - fighter.hp = resolvedHp; - fighter.nextAttackAt = 0; fighter.nextHudSyncAt = 0; - fighter.nextTargetScanAt = 0; - fighter.targetEnemy = null; fighter._hudDetailsVisible = false; fighter._hudSlot = null; - fighter.isLocked = false; - fighter.isDead = false; + fighter.releaseHud = () => releaseFighterHud(fighter); fighter.play(ensureFighterTeamAnimation(scene, skin, "walk", team.color)); 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); 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( fighter, { 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); if (!detailsVisible || !fighter.body) { @@ -129,11 +195,7 @@ export function syncFighterHud( const scaleRatio = Math.max(1, Math.abs(fighter.scaleY) / FIGHTER.SCALE); const healthOffset = 44 * scaleRatio; - const hitbox = fighter.body; - const nameX = hitbox.x + hitbox.width / 2; - const nameY = hitbox.y + hitbox.height + NAME_LABEL_BOTTOM_GAP; - hudSlot.nameLabel.setPosition(nameX, nameY); hudSlot.healthBack.setPosition(fighter.x, fighter.y - healthOffset); hudSlot.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset); hudSlot.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? 1))); @@ -186,7 +248,6 @@ function setVisibleIfChanged(gameObject, visible) { } function setHudSlotVisible(hudSlot, visible) { - setVisibleIfChanged(hudSlot.nameLabel, visible); setVisibleIfChanged(hudSlot.healthBack, visible); setVisibleIfChanged(hudSlot.healthBar, visible); } @@ -205,7 +266,7 @@ function acquireFighterHudSlot(fighter) { hudSlot.fighter = fighter; fighter._hudSlot = hudSlot; - configureHudSlot(hudSlot, fighter); + configureHudSlot(hudSlot); return hudSlot; } @@ -220,18 +281,6 @@ function ensureFighterHudPool(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 .rectangle(0, 0, 72, 8, 0x17180e, 0.92) .setDepth(4) @@ -246,14 +295,10 @@ function createHudSlot(scene) { fighter: null, healthBack, healthBar, - nameLabel, }; } -function configureHudSlot(hudSlot, fighter) { - hudSlot.nameLabel.setText(fighter.fighterName ?? fighter.name ?? ""); - hudSlot.nameLabel.setStroke(fighter.team?.color ?? "#17180e", 4); - hudSlot.nameLabel.setDepth(4); +function configureHudSlot(hudSlot) { hudSlot.healthBack.setDepth(4); hudSlot.healthBar.setDepth(5); } diff --git a/src/game/fighter/fighterModel.js b/src/game/fighter/fighterModel.js new file mode 100644 index 0000000..d4c9a7f --- /dev/null +++ b/src/game/fighter/fighterModel.js @@ -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; +} diff --git a/src/main.js b/src/main.js index df2fc8c..d12e352 100644 --- a/src/main.js +++ b/src/main.js @@ -1,7 +1,7 @@ import Phaser from "phaser"; import { ArenaScene } from "./game/arena/ArenaScene.js"; import { - ARENA, + RENDER, SPAWN, } from "./constants.js"; import { createMatchForm } from "./ui/matchForm.js"; @@ -181,9 +181,11 @@ const arenaScene = new ArenaScene({ const game = new Phaser.Game({ type: Phaser.AUTO, parent: "game", - width: ARENA.SIZE, - height: ARENA.SIZE, + width: RENDER.WIDTH, + height: RENDER.HEIGHT, + autoRound: true, pixelArt: true, + powerPreference: "high-performance", backgroundColor: "#282819", physics: { default: "arcade", diff --git a/todo.md b/todo.md index 06dae5f..0c7b854 100644 --- a/todo.md +++ b/todo.md @@ -311,3 +311,154 @@ 52. Configurable barrage warning duration (completed) - Added `WORLD_EFFECT.WARNING_DURATION_MS` to control the visible lifetime of the large dense-area warning marker. - Kept scheduled small impacts and meteor camera focus running after the warning marker hides. +53. Phaser 3 online optimization review and low-risk performance pass (완료) +- **조치 사항**: + - Phaser 공식 문서/뉴스에서 object allocation, Group pooling, Blitter, camera ignore, Arcade Collider, render config 관련 최적화 기법을 확인. + - 투사체별 Arcade overlap collider 생성은 제거하고 기존 궤적 기반 판정만 유지. + - 투사체 판정용 geometry와 전투 타깃 spatial grid의 프레임별 할당을 줄임. + - 미니맵 redraw를 `PERFORMANCE.MINIMAP_REFRESH_MS`로 제한하고 Phaser 렌더 설정에 `autoRound`, `powerPreference`를 추가. + - Blitter 전환, Canvas 강제 전환, 광범위한 Group pooling은 현재 구조와 리스크 대비 보류. +54. Render resolution split for large-battle baseline performance (완료) +- **조치 사항**: + - Phaser 내부 canvas 해상도를 `ARENA.SIZE` 3200x3200에서 `RENDER` 1280x1280으로 분리. + - 전투 로직/월드 bounds는 기존 3200x3200 arena를 유지. + - `CAMERA.MIN_ZOOM`을 render/arena 비율로 낮춰 기본 전장 전체 시야를 유지. + - 미니맵 HUD 크기와 stroke도 render size 기준으로 조정. +55. Large-battle fighter render LOD (completed) +- **Changes**: + - Added a large-battle render LOD pass that caps detailed visible fighter sprites through `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT`. + - Rendered hidden living fighters as team-colored dots on a shared `Graphics` layer while keeping them active for combat simulation. + - Used per-team representatives at full-arena `CAMERA.MIN_ZOOM` and camera-near detail selection for zoomed/selected views. + - Released HUD slots and pointer interaction for hidden fighters, and invalidated LOD when split-on-death children spawn. + - Verified production build with `npm run build`. +56. Dynamic zoomed fighter render LOD (completed) +- **Changes**: + - Changed `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT` into the full-arena/base budget instead of a universal fixed cap. + - Added `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_MAX` as the dynamic safety cap. + - Counted camera-near living fighters during zoomed/selected views and raised the detailed sprite budget dynamically up to the safety cap. + - Prioritized exact viewport candidates before rolling-window candidates so dots do not appear inside the current view unless the rolling-window count exceeds the dynamic cap. +57. Scoreboard team button toggle to full arena (completed) +- **Changes**: + - Clicking the currently selected team button now clears the fighter selection instead of selecting another random same-team fighter. + - Added `ArenaScene.returnToFullArenaView()` to restore `CAMERA.MIN_ZOOM`, center the arena, clear combat focus state, refresh the minimap, and update the scoreboard. +58. Large-battle start camera avoids full-arena overview (completed) +- **Changes**: + - Added `CAMERA.LARGE_BATTLE_START_ZOOM`. + - Live matches at or above `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` now start zoomed in instead of staying on `CAMERA.MIN_ZOOM`. + - Centered the initial large-battle camera on the living fighter closest to the overall living-fighter average without selecting that fighter. +59. Manual camera pan/zoom transitions (completed) +- **Changes**: + - Added `CAMERA.MANUAL_FOCUS_TWEEN_MS` and `CAMERA.MANUAL_FOCUS_TWEEN_EASE`. + - Added `ArenaScene.transitionMainCameraTo()` for Phaser camera `pan()` and `zoomTo()` based manual focus changes. + - Updated fighter/team selection and selected-team full-arena return to tween instead of jumping instantly. + - Paused selected-fighter auto-centering while the manual camera transition is active so it does not cancel the tween. +60. Rolling-window fighter LOD for smooth camera movement (completed) +- **Changes**: + - Replaced exact-visible-count LOD budgeting with a camera-centered rolling window. + - Added `PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_SCALE` and `PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_BUFFER_RATIO`. + - Kept exact viewport fighters as the first priority, then filled the detailed set with rolling-window fighters before they enter the visible screen. + - Shortened `PERFORMANCE.LARGE_BATTLE_LOD_REFRESH_MS` so the rolling window follows manual pan/zoom more responsively. +61. Dormant offscreen fighter simulation (completed) +- **Changes**: + - Rolling-window LOD outside fighters now pause animation, disable input, release HUD, and disable Arcade bodies instead of only setting invisible. + - Updated combat movement so disabled-body fighters keep moving through JS position math. + - Kept visible fighters on Arcade movement for on-screen fidelity. + - Resolved projectile attacks involving dormant fighters as delayed data hits without spawning projectile objects. + - Updated camera/world-effect helpers to use fighter `x/y` when a body is disabled. +62. Fighter adapter layer before model/proxy rewrite (completed) +- **Changes**: + - Added `src/game/fighter/fighterAdapter.js` as the first boundary between fighter simulation state and Phaser Sprite/Arcade APIs. + - Moved fighter world-point, distance, facing, movement, body enable/disable, arena clamping, animation, and tint helpers behind the adapter. + - Updated combat, world effects, spectator camera, match finish cleanup, and fighter detail visibility to use the adapter for fighter-specific render/body access. + - Left the full `FighterModel + SpriteProxy` rewrite as a later larger step; this pass reduces the direct coupling first. +63. FighterModel shell for state/render split (completed) +- **Changes**: + - Added `src/game/fighter/fighterModel.js` to hold combat/state fields in a pure JS model object. + - Attached each fighter sprite to `fighter.model` and bridged existing custom sprite fields with getter/setters so current code remains compatible. + - Synced live sprite positions into model `x/y` during combat-frame preparation and wrote dormant movement through model-aware adapter helpers. + - Updated combat target indexing, arena rolling-window LOD, minimap dots, and split-spawn origins to use model position helpers where safe. + - Retained Phaser sprites for every fighter at this stage; later work added rolling-window SpriteProxy detach and lazy sprite pooling. +64. Model-based combat targeting and spatial index (completed) +- **Changes**: + - Added `ArenaScene.fighterModels`, `fighterByModelId`, and `fighterModelById` indexes. + - Registered models on match start and split spawns, and unregistered despawned fighters so stale models are marked inactive. + - Changed combat update entry to `updateFighterModel()` and iterated `scene.fighterModels` from the scene update loop. + - Built the target spatial grid from models instead of sprites. + - Replaced cached `targetEnemy` sprite references with `model.targetModelId` and resolved sprites only when rendering/movement/attack execution needs them. +65. Model-only combat fallback before SpriteProxy pooling (completed) +- **Changes**: + - Allowed `updateFighterModel()` to keep simulating living models that do not currently have a render sprite. + - Added model-only movement that updates `model.x/y` directly with arena clamping. + - Added delayed model-hit resolution for melee, projectile, and instant-spell attacks when either combatant lacks a sprite. + - Added `killFighterModel()` so model-only deaths mark models inactive, unregister indexes, record deaths/kills, apply kill rewards, and process split-on-death. + - Kept the full visual Sprite path when both combatants still have render sprites. +66. Rolling-window SpriteProxy detach (completed) +- **Changes**: + - Changed large-battle LOD so non-detailed fighter proxies are removed from Phaser's display list and update list instead of only being hidden. + - Removed detached proxies from `fighterByModelId`, letting combat route through the model-only fallback until the rolling window reattaches the proxy. + - Reattached detailed proxies from model state, including position/body reset, facing, input, animation resume, and kill-growth scale. + - Removed parked detached proxies from `this.fighters` on model-only death so dead offscreen proxies do not keep participating in scan loops. + - Kept LOD candidate selection and minimap drawing on the full model-backed proxy list so detached fighters still appear as dots and can be selected by team buttons. + - Verified production build with `npm run build`. +67. Lazy SpriteProxy pool for large-battle startup (rolled back) +- **Changes**: + - Changed `createFighter()` to create a lightweight model-backed proxy first, with optional Phaser Sprite attachment. + - Large live matches now pass `attachSprite: false` at match start, so thousands of fighters begin as model-only proxies. + - Added `scene.fighterSpritePool`; LOD detail promotion acquires/reconfigures a pooled sprite and LOD demotion releases it back to the pool. + - Routed clicked Phaser sprites back to their owning proxy through `_fighterProxy`, keeping selection/team focus logic model-first. + - Split-on-death children in active large-battle LOD now spawn model-only and wait for LOD promotion before acquiring render sprites. + - Verified production build with `npm run build`. + - Rolled this back after a render regression where fighter models and team counts were alive but no stable Phaser Sprite appeared on the field. +68. Fighter sprite render recovery after lazy proxy regression (completed) +- **Changes**: + - Restored `createFighter()` to return a real Phaser Sprite with an attached `fighter.model` bridge. + - Kept the `attachSprite` option, but large-battle startup now parks the created sprite through `setFighterDetailVisible(false)` instead of skipping Sprite creation. + - Preserved rolling-window LOD's display/update-list detach and reattach behavior for non-detailed fighters. + - Updated arena/fighter/combat context docs to mark the lazy sprite pool as disabled. + - Verified production build with `npm run build`. +69. Null-safe model target cache after sprite recovery (completed) +- **Changes**: + - Fixed `isValidEnemyTargetModel()` so cached target validation safely handles `null` attacker or candidate models before reading team ids. + - Cleared stale `targetModelId` values in `resolveTargetEnemyModel()` before scanning for a replacement enemy. + - Verified production build with `npm run build`. +70. Detached fighter animation guard for large-battle model-only attacks (completed) +- **Changes**: + - Fixed `fighterAdapter.shouldRenderFighterDetail()` so `null` or detached fighters cannot be treated as renderable. + - Added animation-key guards in `playFighterAction()` and `playFighterActionIfNeeded()` so model-only attacks skip sprite animation safely. + - Verified production build with `npm run build`. +71. Large-battle simulation throttle and tighter render budget (completed) +- **Changes**: + - Added large-battle simulation buckets so attached/detailed fighters update every frame while detached model-only fighters are distributed across frames. + - Added capped accumulated delta for throttled detached model updates. + - Reduced large-battle detailed sprite and HUD label caps to avoid 8,000-fighter zoom views promoting thousands of animated sprites. + - Allowed rolling-window LOD buffer ratios below `1` for dense large-battle scenes. + - Added an early return when `setFighterDetailVisible(false)` is called on an already parked fighter, reducing LOD refresh spikes. + - Verified production build with `npm run build`. +72. Aggressive 8k battle throttles for remaining frame drops (completed) +- **Changes**: + - Lowered detailed sprite caps again for dense 8,000-fighter zoom views. + - Changed combat frame preparation to sync only attached sprites and rebuild the large-battle target spatial index on an interval instead of every frame. + - Throttled model-index audits to once per second. + - Skipped world-effect modifier scans when no frost zone is active, and throttled active frost-zone scans. + - Verified production build with `npm run build`. +73. Aggregate detached combat for large battles (completed) +- **Changes**: + - Added a large-battle aggregate combat path for detached/offscreen model-only fighters. + - Detached fighters now move toward coarse enemy cells and resolve batched HP/deaths at `PERFORMANCE.LARGE_BATTLE_AGGREGATE_COMBAT_REFRESH_MS` instead of running full target/attack AI. + - Kept attached/detail fighters on every-frame individual combat for visible camera fidelity. + - Suppressed per-kill DOM log entries for aggregate offscreen deaths while preserving death stats, kill rewards, split-on-death, scoreboard updates, and match finish checks. + - Verified production build with `npm run build`. +74. Squad-based detached combat compression (completed) +- **Changes**: + - Compressed detached/offscreen fighters into `team + cell + 100 fighters` squads during large live battles. + - Moved and resolved combat at the squad level, then reslotted individual models around squad centers only on aggregate ticks. + - Changed large-battle target spatial indexing to attached/detail models so visible individual AI does not scan all offscreen models. + - Added `PERFORMANCE.LARGE_BATTLE_AGGREGATE_SQUAD_SIZE` for tuning squad population. + - Verified production build with `npm run build`. +75. Magic attack effect sprite pooling (completed) +- **Changes**: + - Added per-texture pooling for instant-spell attack effect sprites in `combat.js`. + - Returned pooled spell effects on animation completion and during `clearCombatObjects()` cleanup instead of destroying them. + - Reset pooled spell sprites before reuse, including frame, position, scale, depth, alpha, rotation, flip, active/visible state, and animation-complete listener state. + - Left projectile and meteor/world-effect lifecycles unchanged. + - Verified production build with `npm run build`.