feat: implement elite fighter compression for large battles with randomized scaling and percentage-based damage

This commit is contained in:
Horoli 2026-05-28 10:17:07 +09:00
parent 30d7be41be
commit a7eec730d2
21 changed files with 660 additions and 77 deletions

View File

@ -1,7 +1,18 @@
# Update: Elite Stacking Compression
- Below `FIGHTER.ELITE.RANDOMIZED_COMPRESSION.MIN_TEAM_SIZE`, complete `FIGHTER.ELITE.STACK_SIZE = 100` blocks use fixed elite compression; with the current threshold of `100`, entries containing a complete block use randomized compression instead.
- At or above the randomized-compression threshold, each complete 100-member block becomes one elite with probability `ELITE_BLOCK_PROBABILITY = 0.6`; a non-elite block remains 100 rendered normal fighters. Therefore `*4000` targets an average of `24 elite` representing 2,400 fighters plus `1,600 normal` fighters.
- Elite fighters use nested `FIGHTER.ELITE` settings for their type, 5x visual scale, HP ratio, attack range, attack damage, and attack/movement speed formulas. A bonus multiplier of `0` disables that added elite bonus; `1` applies the configured stack exponent fully.
- Elite spawn plans select skins only from the configured `FIGHTER.ELITE.TYPE` (`melee`); normal plans retain the full skin pool.
- Critical hits and world effects distinguish elite targets: elites take max-HP based critical/meteor/frost damage (10%/40%/20%), while normal fighters take 2x critical hit damage and the existing fixed meteor/frost damage.
- `COMBAT.KILL_REWARD_ENABLED` is `false` for elite-compressed battles. Kills still update logs and death statistics, but no fighter heals, grows, or gains attack/movement speed from a kill.
- Team cards display living physical composition as `E : elite count | N : normal count`. Death statistics, spectator thresholds/centers, and dense-area world-effect targeting continue to use represented `stackCount` population.
- Elite representative fighters do not expand Slime `spawnMultiplier` or `splitOnDeath`; applying a randomly selected per-unit trait to an aggregated army would duplicate the represented population.
# 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.
- Large-battle suppression covers critical-hit labels, instant-spell attack sprites, kill-heal sprites, and kill-growth tweens; damage outcomes remain unchanged. Kill healing/growth is now disabled by the elite-compression policy.
- 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
@ -139,7 +150,7 @@
- **[인프라 및 전역 설정] [context/core.md](./context/core.md)**: `main.js`, `constants.js`, 개발/유지보수 공통 규칙.
- **[서버 및 API] [context/server.md](./context/server.md)**: Fastify 서버, MongoDB 연동, 방문자 및 사망 통계 API 상세.
- **[아레나 및 카메라] [context/arena.md](./context/arena.md)**: `ArenaScene` 오케스트레이션, 지능형 카메라 추적, 미니맵 가이드라인.
- **[전투 엔진] [context/combat.md](./context/combat.md)**: 전투 AI, 투사체 판정, 처치 보상 성장, 슬로우모션 및 월드 이펙트 연출.
- **[전투 엔진] [context/combat.md](./context/combat.md)**: 전투 AI, 엘리트 피해 판정, 비활성화된 처치 보너스 경로, 슬로우모션 및 월드 이펙트 연출.
- **[캐릭터 및 에셋] [context/fighter.md](./context/fighter.md)**: 캐릭터 공장, 동적 실루엣 생성, 종족 및 특성(Slime 등) 정의.
- **[매치 로직 및 UI] [context/match-ui.md](./context/match-ui.md)**: 팀 구성 및 스폰 알고리즘, HUD 레이아웃, 킬로그, 승리 연출 UI.
- **[스타일 및 디자인] [context/style.md](./context/style.md)**: CSS 모듈 구조, 디자인 변수, 반응형 및 애니메이션 가이드.

View File

@ -1,3 +1,9 @@
# Update: Elite-Weighted Scene Counts
- `ArenaScene.recordDeath()` records an elite death as its represented `stackCount`, keeping persisted species death totals aligned with the displayed army size.
- `arenaSpectatorCamera.js` uses summed `stackCount` for late/final thresholds, underdog comparison, and weighted team center positions. A match containing two large compressed armies therefore does not enter final-combat camera mode at match start.
- Minimap dots remain physical fighter markers; an elite is visible as its single large battlefield representative while numeric population remains in the scoreboard.
# 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.

View File

@ -1,3 +1,11 @@
# Update: Elite Target Damage And Density
- `combat.js` uses `fighter.isElite` to split damage rules. Elite critical hits deal the greater of the ordinary hit or `COMBAT.CRITICAL_DAMAGE_PERCENT` of max HP; normal critical hits deal `NORMAL_CRITICAL_DAMAGE_MULTIPLIER` times the ordinary hit.
- Elite attack and movement speed are calculated through `FIGHTER.ELITE.ATTACK_SPEED_*` and `MOVE_SPEED_*` constants. Each multiplier is additive: `0` removes its added stack bonus, while `1` applies its configured exponent.
- Kills still record the attacker/defender and drive match resolution, but `COMBAT.KILL_REWARD_ENABLED = false` prevents heal effects, scale growth, and kill-derived speed multipliers in compressed elite battles.
- `worldEffects.js` passes an effect type into `applyWorldEffectDamage()`: normal targets retain fixed fire/frost damage, while elite targets take `WORLD_EFFECT.METEOR_DAMAGE_PERCENT` or `FROST_DAMAGE_PERCENT` of max HP.
- Dense-area target scanning adds each fighter's represented `stackCount` into its tile, preventing compressed armies from disappearing from meteor/frost targeting pressure.
# 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.
@ -25,14 +33,14 @@
# 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.
- Outside that window, large battles skip critical labels, instant-spell sprites, kill-heal sprites, and kill-growth tweens while retaining underlying damage calculations. Kill rewards are globally disabled by the elite policy.
- World-effect meteor/frost visuals remain visible, and projectile objects remain enabled because projectiles currently participate in hit detection.
# Context: Combat System
## 1. 모듈별 상세 역할 (`src/game/combat/`)
- **`combat.js`**: 전투 AI, 피해 계산, 처치 보상 등 핵심 전투 로직을 담당합니다. `fighterStats.js`에서 해석한 역할별 수치로 이동, 공격, 투사체 발사 등을 처리합니다.
- **`combat.js`**: 전투 AI, 피해 계산, 처치 기록 및 비활성화된 보너스 경로를 담당합니다. `fighterStats.js`에서 해석한 역할별 수치로 이동, 공격, 투사체 발사 등을 처리합니다.
- **`combatSettings.js`**: 전투 속도 배율 등 런타임 전투 설정을 관리합니다.
- **`arenaFinalCombatEffects.js`**: 최종 교전 시 슬로우 모션 등 연출 효과를 담당합니다. 수학적인 이징(easing) 함수와 물리 시간 배율 계산을 포함합니다.
- **`worldEffects.js`**: 실제 전투에서 설정 주기마다 생존자 밀집 구역을 탐색하고 화염/냉기 소형 메테오 포격을 실행하며, 대각선 낙하 연출, 개별 탄착 판정, 냉기 동결과 감속 구역 수명주기를 처리합니다.
@ -41,13 +49,14 @@
### 전투 AI 및 유닛 동작
- **`updateFighter()`**: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
- **`applyHit()`**: 일반 공격 피해량은 공격자의 `melee`/`ranged`/`magic` 프로필 피해량 범위에서 계산하고, 치명타 적중은 `Critical!` 표기와 즉시 처치를 처리합니다.
- **`applyHit()`**: 일반 공격 피해량은 공격자의 `melee`/`ranged`/`magic` 프로필 피해량 범위에서 계산합니다. 치명타 적중은 `Critical!`을 표시하고, 일반 대상에는 일반 피해의 2배, elite 대상에는 최대 체력 비례 피해를 적용합니다.
- **역할별 기본값**: `src/constants.js``FIGHTER_TYPE_STATS`에서 체력, 이동속도, 사거리, 공격 쿨다운, 피해량, 치명타 확률, 발동 지연을 독립적으로 조절합니다. 투사체 속도는 `ranged`, 효과 적중 지연은 `magic` 프로필에 포함됩니다.
- **`projectilePathHitsDefender()`**: 투사체가 대상을 스쳐 지나가지 않도록 궤적(Line)과 히트박스(Rectangle) 겹침 검사를 수행합니다.
### 처치 보상 및 성장
- **`applyKillReward()`**: 처치한 캐릭터의 체력 회복(현재 체력 30%), 크기 증가, 공격속도/이동속도 배율 증가를 처리합니다. 누적 배율은 `KILL_GROWTH_MAX_MULTIPLIER`로 제한합니다.
- **`clampFighterInsideArena()`**: 처치 성장 중 커진 캐릭터가 전장 바깥으로 나가지 않도록 위치를 보정합니다.
### 처치 보너스 정책
- elite 압축 전투에서는 `COMBAT.KILL_REWARD_ENABLED``false`이므로 처치자 체력 회복, 크기 성장, 공격속도/이동속도 보너스와 회복 이펙트가 적용되지 않습니다.
- 킬로그, 사망 통계, 분열 판정, 승패 판정은 처치 보너스와 별개로 계속 처리됩니다.
- `applyKillReward()`와 관련 상수는 향후 별도의 비압축 모드에서 명시적으로 활성화할 수 있는 경로로만 보존합니다.
### 월드 이펙트
- **발동 규칙**: 프리뷰가 아닌 실제 전투에서 시작 후 첫 포격은 `WORLD_EFFECT.INTERVAL`이 지난 뒤 발생하고, 이후 일반 포격은 `WORLD_EFFECT.REPEAT_INTERVAL` 간격으로 발생합니다. 각 포격은 `AREA_TILES` 크기의 모든 후보 구역을 타일 누적합으로 평가해, 생존 캐릭터가 가장 많이 모인 범위를 선택합니다. 같은 밀도의 후보가 여러 개일 때만 그 후보 사이에서 무작위로 고릅니다.
@ -67,10 +76,10 @@
- Arcade Physics는 timeScale 방향이 반대라 물리 이동에는 역수 배율을 적용합니다.
## 3. 유지보수 규칙
- **처치 성장 상한**: `src/constants.js``KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
- **공격력 조정**: `src/constants.js``FIGHTER_TYPE_STATS.<type>.damageMin/damageMax`를 수정합니다.
- **처치 보너스**: elite 압축 규칙을 유지하는 동안 `src/constants.js``COMBAT.KILL_REWARD_ENABLED``false`로 유지합니다.
- **공격력 조정**: 일반 역할 피해량은 `src/constants.js``FIGHTER_TYPE_STATS.<type>.damageMin/damageMax`를 수정하고, elite 추가 공격력은 `FIGHTER.ELITE.ATTACK_DAMAGE_BONUS_MULTIPLIER``ATTACK_DAMAGE_STACK_EXPONENT`를 수정합니다.
- **월드 이펙트 및 서든 데스 조정**:
- `src/constants.js``WORLD_EFFECT.METEOR_DAMAGE``WORLD_EFFECT.FROST_DAMAGE`로 피해량을 조정합니다.
- `src/constants.js``WORLD_EFFECT.METEOR_DAMAGE``WORLD_EFFECT.FROST_DAMAGE`는 normal 고정 피해를, `METEOR_DAMAGE_PERCENT``FROST_DAMAGE_PERCENT`는 elite 최대 체력 비례 피해를 조정합니다.
- `SUDDEN_DEATH.ENABLED`로 서든 데스 활성화 여부를 결정하며, `TRIGGER_MS`(시작 시간), `INTERVAL_MS`(주기), `FORCE_FROST`(냉기 고정) 설정을 변경할 수 있습니다.
- `INTERVAL`은 첫 포격까지의 대기 시간, `REPEAT_INTERVAL`은 이후 일반 포격 주기입니다. `AREA_TILES`는 밀집도를 검색하고 경고로 표시할 큰 구역이며, `WARNING_DURATION_MS`는 그 경고 표시 시간입니다. `IMPACT_AREA_TILES`, `IMPACT_COUNT_MIN`/`IMPACT_COUNT_MAX`, `IMPACT_STAGGER_MS`, `IMPACT_VISUAL_SCALE`는 내부 소형 포격의 판정 범위, 발수, 간격, 시각 크기를 조정합니다.
- `WORLD_EFFECT.FROST_STUN_DURATION`/`FROST_STUN_TINT`로 동결 시간과 표시 색상을 조정합니다.

View File

@ -1,3 +1,11 @@
# Update: Elite Balance Constants
- `FIGHTER.ELITE` contains elite type, stack/appearance/HP/range tuning, attack-damage and speed tuning, and randomized large-team compression settings inside the fighter domain.
- `COMBAT.CRITICAL_DAMAGE_PERCENT` sets elite critical damage from target max HP, while `COMBAT.NORMAL_CRITICAL_DAMAGE_MULTIPLIER` replaces normal-fighter instant critical kills with multiplied attack damage.
- `COMBAT.KILL_REWARD_ENABLED` is `false` by default because one compressed kill is not equivalent to one represented casualty. The legacy heal/growth constants remain available only for an explicitly re-enabled mode.
- `WORLD_EFFECT.METEOR_DAMAGE_PERCENT` and `WORLD_EFFECT.FROST_DAMAGE_PERCENT` apply only to elite targets. Existing fixed `METEOR_DAMAGE` and `FROST_DAMAGE` remain the normal-target values.
- `ATTACK_DAMAGE_*`, `ATTACK_SPEED_*`, and `MOVE_SPEED_*` constants control elite stack bonuses. For each bonus, multiplier `0` removes the added bonus and multiplier `1` applies the configured stack exponent fully.
# Context: Core & Infrastructure
# Update: Dense-Area Meteor Barrage
@ -34,8 +42,8 @@
- **`src/constants.js`**: 게임 내 모든 튜닝 수치를 관리합니다.
- `FIGHTER_TYPE_STATS`: `melee`, `ranged`, `magic`별 최대 체력, 이동속도, 사거리, 쿨다운, 피해량, 치명타 및 공격 발동 지연 기본값.
- `FIGHTER_HITBOX_*`: 100x100 캐릭터 프레임 안에서 실제 충돌 판정이 놓이는 위치와 크기.
- `KILL_HEALTH_RECOVERY_RATIO`, `KILL_GROWTH_MULTIPLIER`, `KILL_GROWTH_MAX_MULTIPLIER`: 처치 후 회복량, 크기/공격속도/이동속도 성장 배율, 누적 보상 상한.
- `WORLD_EFFECT.*`: 첫/반복 포격 간격, 밀집 경고 범위, 개별 탄착 범위/발수/시각 배율, 대각선 낙하 거리, 화염/냉기 메테오 피해량, 냉기 동결 시간/색상, 냉각지대 지속시간과 감속 배율.
- `KILL_REWARD_ENABLED`, `KILL_HEALTH_RECOVERY_RATIO`, `KILL_GROWTH_MULTIPLIER`, `KILL_GROWTH_MAX_MULTIPLIER`: 기본적으로 비활성화된 처치 보너스 토글과, 명시적으로 재활성화할 때 사용하는 회복/성장 값.
- `WORLD_EFFECT.*`: 첫/반복 포격 간격, 밀집 경고 범위, 개별 탄착 범위/발수/시각 배율, 대각선 낙하 거리, normal 고정 화염/냉기 피해량, elite 최대 체력 비례 화염/냉기 피해량, 냉기 동결 시간/색상, 냉각지대 지속시간과 감속 배율.
- `SELECTED_FIGHTER_OUTLINE_GAP`, `SELECTED_FIGHTER_OUTLINE_WIDTH`, `SELECTED_FIGHTER_OUTLINE_ALPHA`: 팀 색상 실루엣 마커의 캐릭터 이격 거리, 두께, 투명도.
- `TEAM_COLORS`, `getTeamColor()`: 8팀 이하에서는 기본 팔레트를 쓰고, 9팀 이상에서는 팀 수에 맞춰 중복 없는 색상을 동적으로 생성합니다.
- `CAMERA.SPECTATOR_LERP`: 카메라 추적의 부드러움 정도.
@ -50,8 +58,8 @@
- **신규 캐릭터 추가**: `public/assets/characters/`에 에셋 배치 후 `fighterManifest.js`에 정의를 추가하면 즉시 게임에 반영됩니다.
- **종족값 유지**: 신규 스킨을 추가할 때는 사망 통계가 누락되지 않도록 `species``human`, `orc`, `skeleton`, `slime`, `wolf`, `bear` 중 하나로 지정해야 합니다.
- **물리 수치 조정**: 역할별 기본 체력/속도/사거리/공격 수치는 `src/constants.js``FIGHTER_TYPE_STATS`에서 변경하고, 특정 스킨만 다르게 할 때는 `fighterManifest.js``stats` 또는 `combat` 설정을 사용하십시오.
- **처치 성장 상한 조정**: 처치 보상으로 캐릭터가 커지는 최대치와 공격/이동 배율 상한은 `src/constants.js``KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
- **처치 보너스 정책**: elite 압축 전투에서는 `src/constants.js``COMBAT.KILL_REWARD_ENABLED``false`로 유지합니다. 별도 모드에서 재활성화할 때만 `KILL_GROWTH_MAX_MULTIPLIER` 등 보너스 수치를 조정합니다.
- **공격력 조정**: 역할별 기본 피해량은 `src/constants.js``FIGHTER_TYPE_STATS.<type>.damageMin/damageMax`를 수정합니다. 캐릭터별 특수 공격 방식은 `fighterManifest.js``combat` 설정을 우선 확인합니다.
- **월드 이펙트 조정**: `src/constants.js``WORLD_EFFECT.INTERVAL`, `WORLD_EFFECT.REPEAT_INTERVAL`, `WORLD_EFFECT.AREA_TILES`, `WORLD_EFFECT.WARNING_DURATION_MS`, `WORLD_EFFECT.IMPACT_AREA_TILES`, `WORLD_EFFECT.IMPACT_COUNT_MIN`, `WORLD_EFFECT.IMPACT_COUNT_MAX`, `WORLD_EFFECT.IMPACT_STAGGER_MS`, `WORLD_EFFECT.IMPACT_VISUAL_SCALE`, `WORLD_EFFECT.SIZE_SCALE_VARIANCE`, `WORLD_EFFECT.FALL_TRAVEL_TILES`, `WORLD_EFFECT.METEOR_SHAKE_DURATION_MS`, `WORLD_EFFECT.METEOR_SHAKE_INTENSITY`, `WORLD_EFFECT.METEOR_DAMAGE`, `WORLD_EFFECT.FROST_DAMAGE`, `WORLD_EFFECT.FROST_STUN_DURATION`, `WORLD_EFFECT.FROST_STUN_TINT`, `WORLD_EFFECT.FROST_DURATION`, `WORLD_EFFECT.FROST_SPEED_MULTIPLIER`를 수정합니다. `INTERVAL`은 첫 포격까지의 대기 시간, `REPEAT_INTERVAL`은 이후 일반 포격 주기, `AREA_TILES``WARNING_DURATION_MS`는 밀집 경고 구역과 표시 시간이며, `IMPACT_*` 값은 그 내부 실제 포격을 제어합니다. 임시 메테오 카메라는 `CAMERA.METEOR_FOCUS_ENABLED`로 끌 수 있습니다.
- **월드 이펙트 조정**: `src/constants.js``WORLD_EFFECT.INTERVAL`, `WORLD_EFFECT.REPEAT_INTERVAL`, `WORLD_EFFECT.AREA_TILES`, `WORLD_EFFECT.WARNING_DURATION_MS`, `WORLD_EFFECT.IMPACT_AREA_TILES`, `WORLD_EFFECT.IMPACT_COUNT_MIN`, `WORLD_EFFECT.IMPACT_COUNT_MAX`, `WORLD_EFFECT.IMPACT_STAGGER_MS`, `WORLD_EFFECT.IMPACT_VISUAL_SCALE`, `WORLD_EFFECT.SIZE_SCALE_VARIANCE`, `WORLD_EFFECT.FALL_TRAVEL_TILES`, `WORLD_EFFECT.METEOR_SHAKE_DURATION_MS`, `WORLD_EFFECT.METEOR_SHAKE_INTENSITY`, `WORLD_EFFECT.METEOR_DAMAGE`, `WORLD_EFFECT.FROST_DAMAGE`, `WORLD_EFFECT.METEOR_DAMAGE_PERCENT`, `WORLD_EFFECT.FROST_DAMAGE_PERCENT`, `WORLD_EFFECT.FROST_STUN_DURATION`, `WORLD_EFFECT.FROST_STUN_TINT`, `WORLD_EFFECT.FROST_DURATION`, `WORLD_EFFECT.FROST_SPEED_MULTIPLIER`를 수정합니다. `INTERVAL`은 첫 포격까지의 대기 시간, `REPEAT_INTERVAL`은 이후 일반 포격 주기, `AREA_TILES``WARNING_DURATION_MS`는 밀집 경고 구역과 표시 시간이며, `IMPACT_*` 값은 그 내부 실제 포격을 제어합니다. 임시 메테오 카메라는 `CAMERA.METEOR_FOCUS_ENABLED`로 끌 수 있습니다.
- **DOM 접근**: 성능을 위해 `ArenaScene`은 좌측 HUD badge 등 필요한 시점에만 최소한으로 DOM에 접근합니다.
- **패키지 락 파일**: 이 프로젝트는 `package-lock.json`을 저장소에서 제외합니다. 의존성 변경 시 `package.json`을 기준으로 관리합니다.

View File

@ -1,3 +1,10 @@
# Update: Elite Representative Fighter
- `fighterFactory.js` accepts `isElite` and `stackCount` on a spawn plan. Elite attack damage is tuned by `FIGHTER.ELITE.ATTACK_DAMAGE_BONUS_MULTIPLIER` and `ATTACK_DAMAGE_STACK_EXPONENT`; HP, scale, and range use the other nested elite settings.
- `fighterSelection.js` assigns elite plans only skins whose derived type matches `FIGHTER.ELITE.TYPE` (currently `melee`); normal plans still draw from the complete manifest.
- Elite scale becomes its `baseScaleX`/`baseScaleY`; kill-growth is currently disabled by `COMBAT.KILL_REWARD_ENABLED = false`, but this baseline remains correct if a separate mode enables it later.
- Elite representatives cannot use `splitOnDeath`. Elite attack-speed and movement-speed bonuses are configurable independently under `FIGHTER.ELITE`.
# Update: HUD Pooling
- `fighterFactory.js` no longer creates permanent name labels or health bars for every fighter.
@ -41,7 +48,7 @@
### 캐릭터별 특성 (예: Slime)
- **`spawnMultiplier`**: 배정된 슬롯 1개를 지정된 수만큼 확장하여 스폰합니다.
- **`splitOnDeath`**: 사망 시 확률적으로 지정된 수만큼 분열체를 생성합니다.
- **스탯 상한**: 처치 보상은 현재 체력을 회복시키지만 `maxHp`를 넘을 수 없습니다. (예: Slime은 항상 1 HP)
- **처치 보너스 비활성화**: elite 압축 전투에서는 처치 회복/성장 보너스가 적용되지 않습니다. 따라서 Slime을 포함한 모든 fighter는 처치로 HP 또는 크기/속도 배율을 얻지 않습니다.
### 역할별 전투 스탯
- `combat.type``projectile`이면 `ranged`, `instant-spell`이면 `magic`, 그 외에는 `melee` 기본 프로필을 사용합니다.

View File

@ -1,3 +1,13 @@
# Update: Elite Compression And Population Display
- Below `FIGHTER.ELITE.RANDOMIZED_COMPRESSION.MIN_TEAM_SIZE`, `matchSetup.js` converts each complete `FIGHTER.ELITE.STACK_SIZE = 100` block into one elite plan and keeps the remainder as individual normal plans. With the current threshold of `100`, complete blocks are randomized.
- For starting-zone matches, each elite consumes the assigned spawn point at the start of its represented 100-member block, while remainder normals retain their corresponding individual spawn points.
- At or above the randomized-compression threshold, each complete 100-member block becomes one elite at probability `0.6`, or expands into 100 normal plans otherwise. A `*4000` team therefore averages `24 elite` representing 2,400 fighters plus `1,600 normal` fighters.
- `arenaMatchRuntime.js` keeps the submitted population represented through `stackCount`, while `arenaScoreboard.js` intentionally shows living physical composition as `E : <elite sprites> | N : <normal sprites>`.
- Trait-generated extra spawning remains enabled for normal fighters only. Elite plans do not apply `spawnMultiplier`, because one aggregate fighter multiplying would multiply the entire represented army.
- `ArenaScene` uses setup-aware fighter selection so elite plans receive only skins matching `FIGHTER.ELITE.TYPE` (currently `melee`), while normal plans keep the existing full selection pool.
- The team card layout reserves enough horizontal space for values such as `E : 24 | N : 1600`, including the horizontally scrolling mobile scoreboard.
# Context: Match & UI
# Update: Direct Fighter Count Entries And Zone Distribution

237
elite.md Normal file
View File

@ -0,0 +1,237 @@
# Elite 캐릭터 구현 문서
## 현재 상태
이 문서는 `major` 브랜치에서 `30d7be41bef258685bf67219f2fcf77334c191f8`
기준으로 구현한 elite 압축 전투 규칙을 설명한다. 이전 WIP에서 누락됐던 월드 이펙트
비율 상수까지 포함해 구현했으며, `npm run build`로 빌드를 검증했다.
## 기능 의도
### 1. 인원 압축
- 참가자 입력은 기존의 `닉네임*N` 형식을 유지한다.
- `FIGHTER.ELITE.RANDOMIZED_COMPRESSION.MIN_TEAM_SIZE` 미만에서는 `STACK_SIZE = 100`명마다 elite fighter 1개체를 소환한다.
- 소규모 팀에서 100명으로 묶이지 않는 나머지는 normal fighter를 1명당 1개체씩 소환한다.
- 현재 `MIN_TEAM_SIZE = 100` 설정에서는 100명 블록이 있는 입력부터 랜덤 압축 대상이다.
- 대규모 팀은 100명 블록마다 `ELITE_BLOCK_PROBABILITY = 0.6`으로 elite 압축 여부를 판정한다.
예: `Alice*4000`은 40개 블록 중 장기 평균 24개가 elite 24개체로 압축되어 2,400명을
대표하고, 나머지 16개 블록은 normal 1,600개체로 실제 렌더링된다.
- 팀 카드는 대표 인원 합계 대신 생존 렌더 구성을 `E : <elite 수> | N : <normal 수>`로 표시한다.
- 사망 통계, 관전 판정, 밀집 구역 표적 선정은 계속 `stackCount` 합계를 사용한다.
### 2. Elite 스탯
구현된 상수:
```js
export const FIGHTER = {
// ...
ELITE: {
TYPE: "melee",
STACK_SIZE: 100,
VISUAL_SCALE_MULTIPLIER: 5,
HP_BONUS_RATIO: 1,
ATTACK_RANGE_MULTIPLIER: 1.5,
ATTACK_DAMAGE_BONUS_MULTIPLIER: 1,
ATTACK_DAMAGE_STACK_EXPONENT: 0.5,
ATTACK_SPEED_BONUS_MULTIPLIER: 1,
ATTACK_SPEED_STACK_EXPONENT: 0,
MOVE_SPEED_BONUS_MULTIPLIER: 1,
MOVE_SPEED_STACK_EXPONENT: 0,
RANDOMIZED_COMPRESSION: {
MIN_TEAM_SIZE: 100,
ELITE_BLOCK_PROBABILITY: 0.6,
},
},
};
```
elite 계산 의도:
```js
const attackDamageMultiplier = 1
+ FIGHTER.ELITE.ATTACK_DAMAGE_BONUS_MULTIPLIER
* (stackCount ** FIGHTER.ELITE.ATTACK_DAMAGE_STACK_EXPONENT - 1);
const visualScale = FIGHTER.SCALE * FIGHTER.ELITE.VISUAL_SCALE_MULTIPLIER;
const rangeBonus =
(visualScale - FIGHTER.SCALE) * (FIGHTER.HITBOX_WIDTH / 2);
damageMin = baseDamageMin * attackDamageMultiplier;
damageMax = baseDamageMax * attackDamageMultiplier;
maxHp = baseMaxHp * stackCount * FIGHTER.ELITE.HP_BONUS_RATIO;
attackRange =
baseAttackRange * FIGHTER.ELITE.ATTACK_RANGE_MULTIPLIER + rangeBonus;
attackSpeedMultiplier *= 1 + FIGHTER.ELITE.ATTACK_SPEED_BONUS_MULTIPLIER
* (stackCount ** FIGHTER.ELITE.ATTACK_SPEED_STACK_EXPONENT - 1);
moveSpeedMultiplier *= 1 + FIGHTER.ELITE.MOVE_SPEED_BONUS_MULTIPLIER
* (stackCount ** FIGHTER.ELITE.MOVE_SPEED_STACK_EXPONENT - 1);
```
- 피해량, 공격속도, 이동속도 보너스는 각각 `FIGHTER.ELITE.*_BONUS_MULTIPLIER`
`*_STACK_EXPONENT`로 조정한다. multiplier `0`은 추가 보너스 없음이고,
multiplier `1`은 설정한 stack 곡선을 그대로 적용한다.
- HP는 압축 인원수와 현재 `FIGHTER.ELITE.HP_BONUS_RATIO = 1` 설정에 따라 선형 비례한다.
- 이동속도에는 `stackCount` 보정을 적용하지 않는다. 공격 DPS와 생존력만 대표
인원에 맞춰 증가시키고, 거대 elite의 전장 이동은 일반 이동 규칙을 유지한다.
### 3. 처치 보너스 비활성화
- elite는 여러 명의 피해량과 체력을 하나의 객체로 대표하므로, 물리 객체 기준의
처치 1회에 회복/성장 보너스를 주면 실제 대표 인원 기준으로 보상이 과대 적용된다.
- `COMBAT.KILL_REWARD_ENABLED = false`를 기본 정책으로 두고, 모든 fighter의
처치 회복, 크기 성장, 공격속도/이동속도 보너스, 회복 이펙트를 비활성화한다.
- 킬로그, 사망 통계, 승패 판정은 계속 동작한다. 기존 보너스 구현은 별도 모드가
필요할 경우 명시적으로 재활성화할 수 있도록 코드에 남겨 둔다.
### 4. Elite 대상 피해 이원화
구현된 치명타 상수:
```js
COMBAT.CRITICAL_DAMAGE_PERCENT = 0.1;
COMBAT.NORMAL_CRITICAL_DAMAGE_MULTIPLIER = 2;
```
의도한 판정:
- normal 대상 치명타: 일반 랜덤 피해의 2배를 적용한다.
- elite 대상 치명타: `maxHp`의 10% 피해를 적용하되, 일반 타격 피해보다 낮아지지 않게 한다.
- normal 대상 메테오/냉기: 기존 고정 피해인 `WORLD_EFFECT.METEOR_DAMAGE`,
`WORLD_EFFECT.FROST_DAMAGE`를 유지한다.
- elite 대상 메테오: `maxHp`의 40% 피해를 적용한다.
- elite 대상 냉기: `maxHp`의 20% 피해를 적용한다.
이전 WIP에서 누락됐고 이번 구현에서 보완한 상수:
```js
WORLD_EFFECT.METEOR_DAMAGE_PERCENT = 0.4;
WORLD_EFFECT.FROST_DAMAGE_PERCENT = 0.2;
```
위 두 값은 `src/constants.js`에 정의되어 elite 월드 이펙트 피해 계산에 사용된다.
## 구현 변경 지점
### `src/constants.js`
- `COMBAT.CRITICAL_DAMAGE_PERCENT`, `COMBAT.NORMAL_CRITICAL_DAMAGE_MULTIPLIER`를 추가했다.
- `COMBAT.KILL_REWARD_ENABLED = false`를 추가해 처치 보너스를 비활성화했다.
- `WORLD_EFFECT.METEOR_DAMAGE_PERCENT`, `WORLD_EFFECT.FROST_DAMAGE_PERCENT`를 추가했다.
- fighter 도메인 아래 `FIGHTER.ELITE` 키로 elite 상수를 관리한다.
- 카메라/렌더/worker 성능 리팩터링 없이 elite 밸런스 상수만 추가했다.
### `src/game/match/matchSetup.js`
- 기존에는 `team.size`만큼 실제 fighter plan을 만들었다.
- 임계값 미만 팀은 완전한 100명 블록마다 `stackCount: 100`, `isElite: true`
plan을 하나 만들고, 나머지는 `stackCount: 1`, `isElite: false` plan으로 유지한다.
- 임계값 이상 팀은 완전한 100명 블록마다
`RANDOMIZED_COMPRESSION.ELITE_BLOCK_PROBABILITY`로 elite 여부를 판정한다.
성공 블록은 `stackCount: 100`인 elite 하나가 되고, 실패 블록은 normal 100개체로 렌더링한다.
- 스폰 좌표 배열은 요청 인원 기준으로 생성하며, 각 elite는 대표하는 100명 블록의
첫 스폰 지점을 사용하고 나머지 normal은 대응하는 개별 스폰 지점을 사용한다.
### `src/game/match/arenaMatchRuntime.js`
- 팀 크기 동기화는 물리 Sprite 수 대신 `stackCount` 합계를 사용한다.
- elite plan에는 `spawnMultiplier`를 적용하지 않아 대표 스택 전체가 한 번에
복제되지 않게 한다. normal plan의 기존 trait 동작은 유지한다.
### `src/game/fighter/fighterFactory.js`
- `stackCount`, `isElite`를 입력으로 받고 HP, 피해량, 사거리, 외형 크기를 계산한다.
- elite의 `baseScaleX`/`baseScaleY`도 큰 외형 기준으로 저장한다. 현재는 킬 보너스가
꺼져 있지만, 별도 모드에서 다시 활성화할 경우 elite 기준 크기를 보존한다.
- 기존 Sprite 기반 `createFighter()`에만 적용했으며, elite는 `splitOnDeath`
사용하지 않는다.
### `src/game/fighter/fighterSelection.js`
- `pickFightersForSetups()`는 elite plan에 `FIGHTER.ELITE.TYPE`과 일치하는 스킨만 배정한다.
- normal plan은 기존과 동일하게 전체 fighter manifest에서 스킨을 선택한다.
### `src/game/combat/combat.js`
- 치명타 판정을 normal 즉사에서 normal 2배 피해 / elite 최대 HP 비례 피해로 바꿨다.
- 월드 이펙트 함수 인자를 고정 `damage` 값에서 `"meteor"`/`"frost"` 타입으로 바꾸고,
대상이 elite인지에 따라 고정 피해와 비율 피해를 나눈다.
- 공격력 계산은 `FIGHTER.ELITE.ATTACK_DAMAGE_*`, 공격속도와 이동속도 계산은
`FIGHTER.ELITE.ATTACK_SPEED_*``FIGHTER.ELITE.MOVE_SPEED_*` 상수를 사용한다.
- 처치 흐름은 로그와 사망 처리는 유지하면서 `COMBAT.KILL_REWARD_ENABLED`
`false`일 때 `applyKillReward()`를 실행하지 않는다.
- 기준 커밋에 존재하는 Sprite 전투 경로인 `applyHit()`,
`applyWorldEffectDamage()`, `fighterAttackSpeedMultiplier()`만 수정했다.
### `src/game/combat/worldEffects.js`
- 메테오/냉기 낙하 처리에서 effect type을 `applyWorldEffectDamage()`로 전달한다.
- 밀집 구역 계산은 `stackCount`를 가중치로 사용해 elite가 대표하는 인원을
월드 이펙트 표적 선정에 반영한다.
### `src/game/arena/ArenaScene.js`
- 사망 통계 누적 값을 `+ 1` 대신 `+ (fighter.stackCount || 1)`로 바꿨다.
- elite가 죽으면 압축된 인원 전체가 오늘의 사망 통계에 기록되어야 한다.
### `src/game/arena/arenaSpectatorCamera.js`
- 관전 진입 임계값, 열세 팀 비교, 평균 포커스 좌표는 `stackCount`를 가중해
대규모 압축 팀이 시작 즉시 최종 교전으로 오판되지 않게 한다.
### `src/ui/arenaScoreboard.js`
- 살아 있는 elite 객체 수와 normal 객체 수를 각각 계산해
`E : <elite> | N : <normal>` 형식으로 표시한다.
- 예를 들어 `Alice*4000`의 평균 구성은 `E : 24 | N : 1600` 부근으로 표시되어
실제 렌더링되는 군세 구성을 바로 확인할 수 있다.
### `src/game/fighter/fighterModel.js`, `src/game/fighter/fighterAdapter.js`
- WIP 당시에는 `stackCount``isElite`를 모델 브리지에도 추가했다.
- 이 두 모듈은 `30d7be4` 이후 대규모 전투 최적화 커밋에서 추가된 구조이므로,
이번 롤백 기준에서는 elite 재구현의 선행 조건이 아니다.
## 의도적으로 포함하지 않은 변경
- `fighterLodWorker.js`, `aggregateCombatWorker.js` 제거 또는 대체
- 모델 전투/LOD/worker 경로 전면 단순화
- 렌더 캔버스 크기, 카메라 줌, minimap throttle, HUD 파일 분리
- `agent.md` 전체를 elite 전용 구조로 축약하는 변경
위 항목은 이전 WIP에 섞여 있었지만, elite 캐릭터 기능의 최소 구현과 독립적인
리팩터링이므로 포함하지 않았다.
## 구현 흐름
1. `src/constants.js``FIGHTER.ELITE`에 elite 타입, 스탯, 공격력/속도, 랜덤 압축 설정을 묶고, 치명타 비율/배수와 메테오/냉기 elite 비율 상수를 추가한다.
2. `matchSetup.js`에서 소규모 입력은 100명 단위로 압축하고, 대규모 입력은
각 100명 블록을 확률적으로 elite 한 개체 또는 normal 100개체로 생성한다.
3. `fighterFactory.js`의 기존 Sprite 생성 경로에서 elite 외형, HP, 공격력,
사거리를 계산한다.
4. `fighterSelection.js`에서 elite plan에는 근거리 스킨만 할당한다.
5. `combat.js``worldEffects.js`에서 normal/elite 피해 판정을 나눈다.
6. `COMBAT.KILL_REWARD_ENABLED = false`로 처치 회복/성장 보너스를 차단한다.
7. `ArenaScene.js`의 사망 통계는 `stackCount` 합산을 유지하고,
`arenaScoreboard.js` 팀 카드는 생존 elite/normal 객체 수를 분리 표시한다.
8. `agent.md`, `context/core.md`, `context/combat.md`, `context/fighter.md`,
`context/match-ui.md`, `context/arena.md`, `todo.md`에 구현 규칙을 기록한다.
## 검증 체크리스트
- `Alice*1`은 이전과 동일하게 normal 1개체만 생성된다.
- `Alice*99`는 normal 99개체로 생성된다.
- 현재 임계값 `100`에서는 `Alice*100` 이상의 완전한 블록이 `ELITE_BLOCK_PROBABILITY`에 따라 elite 또는 normal 100개체가 되는지 확인한다.
- `Alice*4000` 표본 반복에서 elite 수가 평균 24개, normal 수가 평균 1,600개에 수렴하고,
모든 plan의 `stackCount` 합계가 매번 4,000인지 확인한다.
- 팀 카드가 같은 구성에 대해 `E : <elite 수> | N : <normal 수>` 형식으로 표시되는지 확인한다.
- elite HP/공격력/공격속도/사거리/외형이 상수와 `stackCount` 계산식에 맞는다.
- normal 치명타가 기존 즉사가 아닌 설정한 고정 배수 피해로 동작하는지 의도와 다시 대조한다.
- elite 치명타, 메테오, 냉기 피해가 각각 최대 HP 10%, 40%, 20% 기준으로 계산된다.
- elite 사망 시 사망 통계가 `stackCount`만큼 증가한다.
- 어떤 fighter도 처치로 회복하거나 커지거나 공격/이동 속도 보너스를 얻지 않는다.
- elite에는 Slime의 `spawnMultiplier``splitOnDeath`가 적용되지 않고,
normal fighter에는 기존 trait 동작이 유지되는지 확인한다.
- elite plan에 선택된 스킨 타입은 항상 `FIGHTER.ELITE.TYPE`과 일치하고 normal plan은 기존 전체 스킨 풀을 사용하는지 확인한다.
- 밀집 구역 월드 이펙트 표적 산정은 elite를 `stackCount`만큼 가중한다.
- `npm run build`를 통과시키고 실제 전투에서 normal/elite 양쪽 흐름을 수동 확인한다.

View File

@ -71,6 +71,23 @@ export const FIGHTER = {
effectHitDelay: 160,
},
},
ELITE: {
TYPE: "melee",
STACK_SIZE: 100,
VISUAL_SCALE_MULTIPLIER: 5,
HP_BONUS_RATIO: 1,
ATTACK_RANGE_MULTIPLIER: 1.5,
ATTACK_DAMAGE_BONUS_MULTIPLIER: 1,
ATTACK_DAMAGE_STACK_EXPONENT: 0.5,
ATTACK_SPEED_BONUS_MULTIPLIER: 1,
ATTACK_SPEED_STACK_EXPONENT: 0,
MOVE_SPEED_BONUS_MULTIPLIER: 1,
MOVE_SPEED_STACK_EXPONENT: 0,
RANDOMIZED_COMPRESSION: {
MIN_TEAM_SIZE: 100,
ELITE_BLOCK_PROBABILITY: 0.6,
},
},
};
export const PERFORMANCE = {
@ -95,7 +112,7 @@ export const SPAWN = {
},
// Caps participant-assigned slots; traits such as slime spawning may add fighters.
MAX_FIGHTER_COUNT: 8000,
FIGHTERS_PER_STARTING_ZONE: 100,
FIGHTERS_PER_STARTING_ZONE: 200,
STARTING_ZONE_RADIUS: 2,
STARTING_ZONE_FILL_ALPHA: 0.07,
STARTING_ZONE_BORDER_ALPHA: 0.14,
@ -106,12 +123,15 @@ export const SPAWN = {
// 4. COMBAT 도메인
export const COMBAT = {
KILL_REWARD_ENABLED: false,
KILL_HEALTH_RECOVERY_RATIO: 0.3,
KILL_HEAL_EFFECT_FRAMES: 4,
KILL_HEAL_EFFECT_FRAME_RATE: 12,
KILL_GROWTH_MULTIPLIER: 1.25,
KILL_GROWTH_MAX_MULTIPLIER: 5,
KILL_GROWTH_TWEEN_DURATION: 180,
CRITICAL_DAMAGE_PERCENT: 0.1,
NORMAL_CRITICAL_DAMAGE_MULTIPLIER: 2,
// 최종교전 슬로우모션 설정
FINAL_SLOW_MOTION_ENABLED: false,
FINAL_SLOW_MOTION_ENTER_DURATION: 14000,
@ -151,7 +171,9 @@ export const WORLD_EFFECT = {
METEOR_SHAKE_DURATION_MS: 150,
METEOR_SHAKE_INTENSITY: 0.004,
METEOR_DAMAGE: 90,
METEOR_DAMAGE_PERCENT: 0.4,
FROST_DAMAGE: 45,
FROST_DAMAGE_PERCENT: 0.2,
FROST_STUN_DURATION: 2000,
FROST_STUN_TINT: 0x82e9ff,
FROST_DURATION: 2000,
@ -172,7 +194,7 @@ export const CAMERA = {
// 자동 관전 진입 전 화염/냉기 메테오 낙하 위치를 임시로 확대 추적합니다.
METEOR_FOCUS_ENABLED: false,
METEOR_FOCUS_ZOOM: 2,
SPECTATOR_LERP: 0.1,
SPECTATOR_LERP: 0.01,
// 메테오 착탄 후 카메라를 해당 위치에 유지하는 시간(ms)입니다.
METEOR_FOCUS_HOLD_DURATION: 1200,
SPECTATOR_FINAL_FIGHTER_THRESHOLD: 5,
@ -180,7 +202,7 @@ export const CAMERA = {
SPECTATOR_FINAL_TEAM_COUNT: 2,
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD: 8,
SPECTATOR_RANDOM_FOCUS_INTERVAL: 10000,
SPECTATOR_LATE_FIGHTER_THRESHOLD: 80,
SPECTATOR_LATE_FIGHTER_THRESHOLD: 500,
SPECTATOR_LATE_FIGHT_ZOOM: 2,
SELECTED_FIGHTER_ZOOM: 2,
};

View File

@ -23,7 +23,7 @@ import {
syncFighterHud,
} from "../fighter/fighterFactory.js";
import { fighterManifest } from "../fighter/fighterManifest.js";
import { pickFighters } from "../fighter/fighterSelection.js";
import { pickFightersForSetups } from "../fighter/fighterSelection.js";
import {
createMatchSetup,
FighterCountLimitError,
@ -202,7 +202,7 @@ export class ArenaScene extends Phaser.Scene {
throw error;
}
const matchSkins = pickFighters(fighterManifest, matchSetup.fighters.length);
const matchSkins = pickFightersForSetups(fighterManifest, matchSetup.fighters);
const fighterPlans = createFighterPlans(matchSetup.fighters, matchSkins, {
expandSpawnMultipliers: !silent,
});
@ -357,7 +357,8 @@ export class ArenaScene extends Phaser.Scene {
}
const species = normalizeSpecies(fighter?.skin?.species);
this.battleDeathCounts[species] = (this.battleDeathCounts[species] ?? 0) + 1;
const deathCount = Math.max(1, Math.round(Number(fighter?.stackCount) || 1));
this.battleDeathCounts[species] = (this.battleDeathCounts[species] ?? 0) + deathCount;
}
showBattleDeathNotice() {

View File

@ -4,7 +4,10 @@ import {
} from "../../constants.js";
export function getSpectatorState(livingFighters) {
const livingFighterCount = livingFighters.length;
const livingFighterCount = livingFighters.reduce(
(count, fighter) => count + representedFighterCount(fighter),
0,
);
const teamSummaries = getLivingTeamSummaries(livingFighters);
if (livingFighterCount < CAMERA.SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
@ -48,7 +51,7 @@ export function getLivingTeamSummaries(livingFighters) {
teamId,
};
summary.count += 1;
summary.count += representedFighterCount(fighter);
summaries.set(teamId, summary);
});
@ -70,22 +73,28 @@ export function averageFighterPosition(fighters) {
return null;
}
const total = fighters.reduce(
const weighted = fighters.reduce(
(position, fighter) => {
const point = fighterCameraPoint(fighter);
position.x += point.x;
position.y += point.y;
const weight = representedFighterCount(fighter);
position.count += weight;
position.x += point.x * weight;
position.y += point.y * weight;
return position;
},
{ x: 0, y: 0 },
{ count: 0, x: 0, y: 0 },
);
return {
x: total.x / fighters.length,
y: total.y / fighters.length,
x: weighted.x / weighted.count,
y: weighted.y / weighted.count,
};
}
function representedFighterCount(fighter) {
return Math.max(1, Math.round(Number(fighter?.stackCount) || 1));
}
export function fighterCameraPoint(fighter) {
const target = fighter?.body?.center ?? fighter;

View File

@ -5,6 +5,7 @@ import {
COMBAT,
PERFORMANCE,
PROJECTILE,
WORLD_EFFECT,
} from "../../constants.js";
import {
getAttackSpeedMultiplier,
@ -255,12 +256,12 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = f
}
const attackerStats = combatStatsFor(attacker);
defender.hp = isCritical
? 0
: Math.max(
0,
defender.hp - Phaser.Math.Between(attackerStats.damageMin, attackerStats.damageMax),
);
const normalDamage = Phaser.Math.Between(attackerStats.damageMin, attackerStats.damageMax);
const damage = isCritical
? criticalDamageFor(defender, normalDamage)
: normalDamage;
defender.hp = Math.max(0, defender.hp - damage);
defender.body.setVelocity(0, 0);
if (defender.hp === 0) {
@ -272,12 +273,12 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = f
playAnimation(defender, "hurt");
}
export function applyWorldEffectDamage(scene, defender, damage) {
export function applyWorldEffectDamage(scene, defender, effectType) {
if (scene.matchOver || !defender?.active || defender.isDead) {
return false;
}
const resolvedDamage = Math.max(0, Math.round(Number(damage) || 0));
const resolvedDamage = worldEffectDamageFor(defender, effectType);
if (resolvedDamage === 0) {
return false;
@ -296,6 +297,38 @@ export function applyWorldEffectDamage(scene, defender, damage) {
return false;
}
function criticalDamageFor(defender, normalDamage) {
if (!defender.isElite) {
return normalDamage * COMBAT.NORMAL_CRITICAL_DAMAGE_MULTIPLIER;
}
const percentageDamage = Math.ceil(
(defender.maxHp ?? 1) * COMBAT.CRITICAL_DAMAGE_PERCENT,
);
return Math.max(normalDamage, percentageDamage);
}
function worldEffectDamageFor(defender, effectType) {
const fixedDamage = effectType === "meteor"
? WORLD_EFFECT.METEOR_DAMAGE
: effectType === "frost"
? WORLD_EFFECT.FROST_DAMAGE
: 0;
if (!defender.isElite) {
return Math.max(0, Math.round(Number(fixedDamage) || 0));
}
const percentage = effectType === "meteor"
? WORLD_EFFECT.METEOR_DAMAGE_PERCENT
: effectType === "frost"
? WORLD_EFFECT.FROST_DAMAGE_PERCENT
: 0;
return Math.max(0, Math.ceil((defender.maxHp ?? 1) * percentage));
}
function spawnCriticalHitLabel(scene, defender) {
const scaleRatio = Math.max(1, Math.abs(defender.scaleY) / FIGHTER.SCALE);
const label = scene.add
@ -425,7 +458,10 @@ function killFighter(defender, winner, onWinner) {
winner.body.setVelocity(0, 0);
playAnimation(winner, "idle");
winner.scene.recordKill?.(winner, defender);
applyKillReward(winner);
if (COMBAT.KILL_REWARD_ENABLED) {
applyKillReward(winner);
}
} else {
defender.scene.recordDeath?.(defender);
}
@ -838,6 +874,11 @@ function fighterAttackSpeedMultiplier(fighter) {
getAttackSpeedMultiplier()
* (fighter.killRewardMultiplier ?? 1)
* (fighter.worldEffectSpeedMultiplier ?? 1)
* eliteSpeedMultiplier(
fighter,
FIGHTER.ELITE.ATTACK_SPEED_BONUS_MULTIPLIER,
FIGHTER.ELITE.ATTACK_SPEED_STACK_EXPONENT,
)
);
}
@ -846,9 +887,24 @@ function fighterMovementSpeedMultiplier(fighter) {
getMovementSpeedMultiplier()
* (fighter.killRewardMultiplier ?? 1)
* (fighter.worldEffectSpeedMultiplier ?? 1)
* eliteSpeedMultiplier(
fighter,
FIGHTER.ELITE.MOVE_SPEED_BONUS_MULTIPLIER,
FIGHTER.ELITE.MOVE_SPEED_STACK_EXPONENT,
)
);
}
function eliteSpeedMultiplier(fighter, bonusMultiplier, stackExponent) {
if (!fighter.isElite) {
return 1;
}
const stackCount = Math.max(1, Number(fighter.stackCount) || 1);
const stackedMultiplier = Math.pow(stackCount, stackExponent);
return 1 + bonusMultiplier * (stackedMultiplier - 1);
}
function combatStatsFor(fighter) {
return fighter.combatStats ?? getFighterStats(fighter.skin);
}

View File

@ -156,7 +156,7 @@ export function findDensestWorldEffectZone(livingFighters) {
const column = Phaser.Math.Clamp(Math.floor(x / ARENA.TILE_SIZE), 0, ARENA.GRID_SIZE - 1);
const row = Phaser.Math.Clamp(Math.floor(y / ARENA.TILE_SIZE), 0, ARENA.GRID_SIZE - 1);
tileCounts[row][column] += 1;
tileCounts[row][column] += representedFighterCount(fighter);
});
// Summed-area lookup keeps dense-zone selection cheap even with thousands of fighters.
@ -195,7 +195,7 @@ function randomEntry(entries) {
function spawnMeteor(scene, zone) {
spawnWorldEffectBarrage(scene, zone, {
color: METEOR_ZONE_COLOR,
damage: WORLD_EFFECT.METEOR_DAMAGE,
effectType: "meteor",
effectKey: METEOR_EFFECT_KEY,
});
}
@ -203,7 +203,7 @@ function spawnMeteor(scene, zone) {
function spawnFrostZone(scene, zone) {
spawnWorldEffectBarrage(scene, zone, {
color: FROST_ZONE_COLOR,
damage: WORLD_EFFECT.FROST_DAMAGE,
effectType: "frost",
effectKey: FROST_EFFECT_KEY,
isFrost: true,
});
@ -212,7 +212,7 @@ function spawnFrostZone(scene, zone) {
function spawnWorldEffectBarrage(
scene,
targetZone,
{ color, damage, effectKey, isFrost = false },
{ color, effectType, effectKey, isFrost = false },
) {
const matchId = scene.matchId;
const targetMarker = createZoneMarker(scene, targetZone, color);
@ -265,7 +265,7 @@ function spawnWorldEffectBarrage(
resolveImpactDamage(
scene,
impactZone,
damage,
effectType,
isFrost ? (fighter) => applyFrostStun(scene, fighter) : undefined,
);
@ -525,13 +525,13 @@ function createZoneMarker(scene, zone, color) {
return marker;
}
function resolveImpactDamage(scene, zone, damage, onSurvivor) {
function resolveImpactDamage(scene, zone, effectType, onSurvivor) {
let deathCount = 0;
scene.fighters
.filter((fighter) => fighter.active && !fighter.isDead && containsFighter(zone, fighter))
.forEach((fighter) => {
if (applyWorldEffectDamage(scene, fighter, damage)) {
if (applyWorldEffectDamage(scene, fighter, effectType)) {
deathCount += 1;
return;
}
@ -606,6 +606,10 @@ function containsFighter(zone, fighter) {
return Phaser.Geom.Rectangle.Contains(zone.bounds, x, y);
}
function representedFighterCount(fighter) {
return Math.max(1, Math.round(Number(fighter?.stackCount) || 1));
}
function isLiveMatch(scene, matchId = scene.matchId) {
return !scene.matchOver && !scene.presentationMode && scene.matchId === matchId;
}

View File

@ -15,7 +15,20 @@ const HUD_DETAIL_SYNC_INTERVAL_MS = 100;
export function createFighter(
scene,
{ canSplitOnDeath = true, faceLeft, hp, maxHp, name, skin, team, teamIndex, x, y },
{
canSplitOnDeath = true,
faceLeft,
hp,
isElite = false,
maxHp,
name,
skin,
stackCount = 1,
team,
teamIndex,
x,
y,
},
) {
ensureFighterTeamAnimations(scene, skin, team.color, ["idle"]);
@ -25,14 +38,40 @@ export function createFighter(
: 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));
const baseCombatStats = getFighterStats(skin);
const resolvedStackCount = Math.max(1, Math.round(Number(stackCount) || 1));
const resolvedIsElite = Boolean(isElite);
const attackDamageMultiplier = resolvedIsElite
? eliteBonusMultiplier(
resolvedStackCount,
FIGHTER.ELITE.ATTACK_DAMAGE_BONUS_MULTIPLIER,
FIGHTER.ELITE.ATTACK_DAMAGE_STACK_EXPONENT,
)
: 1;
const visualScale = resolvedIsElite
? FIGHTER.SCALE * FIGHTER.ELITE.VISUAL_SCALE_MULTIPLIER
: FIGHTER.SCALE;
const rangeBonus = resolvedIsElite
? (visualScale - FIGHTER.SCALE) * (FIGHTER.HITBOX_WIDTH / 2)
: 0;
const combatStats = {
...baseCombatStats,
attackRange: resolvedIsElite
? baseCombatStats.attackRange * FIGHTER.ELITE.ATTACK_RANGE_MULTIPLIER + rangeBonus
: baseCombatStats.attackRange,
damageMax: baseCombatStats.damageMax * attackDamageMultiplier,
damageMin: baseCombatStats.damageMin * attackDamageMultiplier,
};
const hpMultiplier = resolvedIsElite
? resolvedStackCount * FIGHTER.ELITE.HP_BONUS_RATIO
: resolvedStackCount;
const resolvedMaxHp = Math.max(1, Math.round((maxHp ?? baseCombatStats.maxHp) * hpMultiplier));
const resolvedHp = Math.min(
resolvedMaxHp,
Math.max(1, Math.round(hp ?? resolvedMaxHp)),
);
fighter.setScale(FIGHTER.SCALE);
fighter.setScale(visualScale);
fighter.setName(displayName);
fighter.setDepth(FIGHTER.DEPTH);
fighter.setAlpha(1);
@ -54,11 +93,13 @@ export function createFighter(
fighter.skin = skin;
fighter.combatStats = combatStats;
fighter.fighterName = displayName;
fighter.isElite = resolvedIsElite;
fighter.stackCount = resolvedStackCount;
fighter.team = team;
fighter.teamIndex = teamIndex;
fighter.baseScaleX = FIGHTER.SCALE;
fighter.baseScaleY = FIGHTER.SCALE;
fighter.canSplitOnDeath = canSplitOnDeath;
fighter.baseScaleX = visualScale;
fighter.baseScaleY = visualScale;
fighter.canSplitOnDeath = canSplitOnDeath && !resolvedIsElite;
fighter.isSelected = false;
fighter.killCount = 0;
fighter.killRewardMultiplier = 1;
@ -93,6 +134,11 @@ export function createFighter(
return fighter;
}
function eliteBonusMultiplier(stackCount, bonusMultiplier, stackExponent) {
const stackedMultiplier = Math.pow(stackCount, stackExponent);
return 1 + bonusMultiplier * (stackedMultiplier - 1);
}
export function syncFighterHud(
fighter,
{ force = false, showDetails = true, time = fighter.scene?.time?.now ?? 0 } = {},

View File

@ -1,3 +1,6 @@
import { FIGHTER } from "../../constants.js";
import { getFighterType } from "./fighterStats.js";
export function pickUniqueFighters(fighters, count) {
if (count > fighters.length) {
throw new Error(`Cannot pick ${count} fighters from ${fighters.length} entries.`);
@ -20,6 +23,29 @@ export function pickFighters(fighters, count) {
return picks;
}
export function pickFightersForSetups(fighters, fighterSetups) {
const eliteCount = fighterSetups.filter((fighterSetup) => fighterSetup.isElite).length;
const normalCount = fighterSetups.length - eliteCount;
const eligibleEliteFighters = fighters.filter(
(fighter) => getFighterType(fighter) === FIGHTER.ELITE.TYPE,
);
if (eliteCount > 0 && eligibleEliteFighters.length === 0) {
throw new Error(`Cannot create elite fighters without ${FIGHTER.ELITE.TYPE} fighter skins.`);
}
const elitePicks = pickFighters(eligibleEliteFighters, eliteCount);
const normalPicks = pickFighters(fighters, normalCount);
let eliteIndex = 0;
let normalIndex = 0;
return fighterSetups.map((fighterSetup) => (
fighterSetup.isElite
? elitePicks[eliteIndex++]
: normalPicks[normalIndex++]
));
}
function shuffleFighters(fighters) {
const pool = [...fighters];

View File

@ -8,7 +8,7 @@ const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
export function createFighterPlans(fighterSetups, skins, { expandSpawnMultipliers = true } = {}) {
return fighterSetups.flatMap((fighterSetup, index) => {
const skin = skins[index];
const spawnMultiplier = expandSpawnMultipliers
const spawnMultiplier = expandSpawnMultipliers && !fighterSetup.isElite
? Math.max(1, Math.round(skin.traits?.spawnMultiplier ?? 1))
: 1;
@ -49,6 +49,12 @@ export function clampInsideArena(value) {
export function syncTeamSizes(teams, fighterPlans) {
teams.forEach((team) => {
team.size = fighterPlans.filter((fighterPlan) => fighterPlan.team.id === team.id).length;
team.size = fighterPlans
.filter((fighterPlan) => fighterPlan.team.id === team.id)
.reduce((sum, fighterPlan) => sum + representedFighterCount(fighterPlan), 0);
});
}
function representedFighterCount(fighter) {
return Math.max(1, Math.round(Number(fighter?.stackCount) || 1));
}

View File

@ -1,4 +1,4 @@
import { ARENA, SPAWN, TEAM } from "../../constants.js";
import { ARENA, FIGHTER, SPAWN, TEAM } from "../../constants.js";
const NAME_MULTIPLIER_REGEX = /\*(\d+)$/;
@ -38,16 +38,16 @@ export function createMatchSetup(
);
const fighters = [];
let spawnOffset = 0;
teams.forEach((team) => {
for (let i = 0; i < team.size; i++) {
const globalIndex = fighters.length;
fighters.push({
...spawns[globalIndex],
name: team.label,
team: team,
teamIndex: i,
});
}
const teamFighters = usesRandomizedEliteCompression(team)
? createRandomizedEliteCompression(team, spawns, spawnOffset)
: createFixedEliteRoster(team, spawns, spawnOffset);
fighters.push(...teamFighters);
spawnOffset += team.size;
});
return {
@ -57,6 +57,102 @@ export function createMatchSetup(
};
}
function usesRandomizedEliteCompression(team) {
return team.size >= FIGHTER.ELITE.RANDOMIZED_COMPRESSION.MIN_TEAM_SIZE;
}
function createFixedEliteRoster(team, spawns, spawnOffset) {
const eliteCount = Math.floor(team.size / FIGHTER.ELITE.STACK_SIZE);
const normalCount = team.size % FIGHTER.ELITE.STACK_SIZE;
const fighters = [];
for (let index = 0; index < eliteCount; index += 1) {
const teamIndex = index * FIGHTER.ELITE.STACK_SIZE;
fighters.push(createElitePlan({
eliteCount,
eliteIndex: index,
spawn: spawns[spawnOffset + teamIndex],
stackCount: FIGHTER.ELITE.STACK_SIZE,
team,
teamIndex,
}));
}
for (let index = 0; index < normalCount; index += 1) {
const teamIndex = eliteCount * FIGHTER.ELITE.STACK_SIZE + index;
fighters.push(createNormalPlan(team, spawns[spawnOffset + teamIndex], teamIndex));
}
return fighters;
}
function createRandomizedEliteCompression(team, spawns, spawnOffset) {
const { ELITE_BLOCK_PROBABILITY } = FIGHTER.ELITE.RANDOMIZED_COMPRESSION;
const stackSize = FIGHTER.ELITE.STACK_SIZE;
const blockCount = Math.floor(team.size / stackSize);
const normalRemainderCount = team.size % stackSize;
const eliteBlocks = Array.from(
{ length: blockCount },
() => Math.random() < ELITE_BLOCK_PROBABILITY,
);
const eliteCount = eliteBlocks.filter(Boolean).length;
const fighters = [];
let eliteIndex = 0;
eliteBlocks.forEach((isElite, blockIndex) => {
const blockStartIndex = blockIndex * stackSize;
if (isElite) {
fighters.push(createElitePlan({
eliteCount,
eliteIndex,
spawn: spawns[spawnOffset + blockStartIndex],
stackCount: stackSize,
team,
teamIndex: blockStartIndex,
}));
eliteIndex += 1;
return;
}
for (let index = 0; index < stackSize; index += 1) {
const teamIndex = blockStartIndex + index;
fighters.push(createNormalPlan(team, spawns[spawnOffset + teamIndex], teamIndex));
}
});
for (let index = 0; index < normalRemainderCount; index += 1) {
const teamIndex = blockCount * stackSize + index;
fighters.push(createNormalPlan(team, spawns[spawnOffset + teamIndex], teamIndex));
}
return fighters;
}
function createElitePlan({ eliteCount, eliteIndex, spawn, stackCount, team, teamIndex }) {
return {
...spawn,
isElite: true,
name: eliteCount > 1
? `${team.label} (Elite ${eliteIndex + 1})`
: `${team.label} (Elite)`,
stackCount,
team,
teamIndex,
};
}
function createNormalPlan(team, spawn, teamIndex) {
return {
...spawn,
isElite: false,
name: team.label,
stackCount: 1,
team,
teamIndex,
};
}
export class FighterCountLimitError extends Error {
constructor(fighterCount) {
super(`Requested fighter count exceeds the ${SPAWN.MAX_FIGHTER_COUNT} limit.`);

View File

@ -46,7 +46,7 @@
.score-side {
display: grid;
grid-template-columns: repeat(2, 114px);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
width: 100%;
}
@ -59,11 +59,11 @@
display: grid;
grid-template-rows: 1fr 1px auto;
gap: 6px;
width: 114px;
width: 100%;
min-height: 72px;
overflow: hidden;
border-radius: 6px;
padding: 8px 9px;
padding: 8px 7px;
color: #fff;
font-size: 0.8rem;
font-weight: 900;
@ -113,7 +113,9 @@
.team-score-count {
justify-self: end;
color: #fff2c8;
font-size: 0.86rem;
font-size: 0.68rem;
letter-spacing: -0.02em;
white-space: nowrap;
}
.battle-notice {

View File

@ -9,7 +9,7 @@
--mobile-kill-log-top: calc(var(--score-band-height) + var(--mobile-game-size) + 10px);
--mobile-options-button-width: 54px;
--mobile-options-gap: 8px;
--mobile-team-card-width: clamp(56px, calc((100vw - 120px) / 4), 72px);
--mobile-team-card-width: clamp(108px, calc((100vw - 38px) / 3), 124px);
--mobile-visitor-space: calc(104px + env(safe-area-inset-bottom));
--score-band-height: 132px;
--score-panel-left: 10px;
@ -205,7 +205,7 @@
}
.team-score-count {
font-size: 0.74rem;
font-size: 0.64rem;
}
.team-score.is-focused {

View File

@ -24,11 +24,13 @@ export function updateScoreboard(
teams.forEach((team, index) => {
const teamEl = containerLeft.children[index];
const aliveCount = fighters.filter(
const livingFighters = fighters.filter(
(fighter) => fighter.team.id === team.id && !fighter.isDead,
).length;
);
const eliteCount = livingFighters.filter((fighter) => fighter.isElite).length;
const normalCount = livingFighters.length - eliteCount;
teamEl.disabled = aliveCount === 0;
teamEl.disabled = livingFighters.length === 0;
teamEl.setAttribute("aria-label", `${team.label} 생존 캐릭터 무작위 시점 고정`);
teamEl.style.setProperty("--team-color", team.color);
teamEl.style.backgroundColor = `${team.color}33`;
@ -39,7 +41,7 @@ export function updateScoreboard(
labelEl.textContent = team.label;
const countEl = teamEl.querySelector(".team-score-count");
countEl.textContent = `${aliveCount}`;
countEl.textContent = `E : ${eliteCount} | N : ${normalCount}`;
teamEl.onclick = () => {
onTeamClick(team.id);

View File

@ -27,8 +27,8 @@ const DEATH_NOTICE_TEMPLATES = [
const SYSTEM_TIP_TEMPLATES = [
"경보: 화염 메테오는 낙하 지점 5x5 영역에 강력한 폭발 피해를 입힙니다!",
"주의: 냉기 메테오는 피해와 함께 2초간 동결 및 냉각을 유발합니다.",
"팁: 근접 캐릭터는 20% 확률로 치명타를 터뜨려 적을 즉사시킵니다.",
"성장: 적 처치 시 체력을 30% 회복하며, 크기와 속도가 최대 5배까지 커집니다.",
"팁: 근접 치명타는 일반 대상에 2배 피해, 엘리트 대상에 최대 체력 비례 피해를 줍니다.",
"엘리트 전투: 처치 보너스는 비활성화되어 전투 중 체력 회복이나 성장 효과가 없습니다.",
];
export function createDeathCounts() {

25
todo.md
View File

@ -311,3 +311,28 @@
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. Elite stacked-fighter compression and damage model (completed)
- Compressed each complete 100-member block in a smaller `nickname*N` team into one elite (`stackCount = 100`) and preserved the remaining members as individual normal fighters (`*101 = 1 elite + 1 normal`, `*199 = 1 elite + 99 normal`, `*200 = 2 elite`).
- Added centralized elite scale/HP/range and critical/meteor/frost percentage-damage constants; elite damage and attack speed now scale with `sqrt(stackCount)`.
- Weighted spectator thresholds, focus centers, and dense-area world-effect targeting by `stackCount` so compressed armies continue to influence viewing and hazard selection by their represented size.
- Kept elite movement speed unchanged and disabled Slime spawn/split reproduction on elite representatives to avoid multiplying an entire compressed army from a per-unit trait.
- Verified production build with `npm run build`.
54. Disable kill rewards for elite-compressed combat (completed)
- Added `COMBAT.KILL_REWARD_ENABLED = false` and skipped the heal/growth reward path after kills while retaining kill logs, death statistics, and match resolution.
- Updated the battle-tip message and documentation so the UI no longer advertises inactive heal or growth behavior.
- Preserved the legacy reward function behind the explicit toggle for a future non-compressed mode.
55. Fighter-domain elite configuration and melee-only elite spawns (completed)
- Nested elite stack, appearance, HP, and range tuning under `FIGHTER.ELITE` so fighter-specific settings are keyed within the fighter domain.
- Added setup-aware skin selection so elite plans use only melee fighter profiles while normal plans continue to use the complete manifest.
- Preserved the current elite HP ratio setting while moving the configuration.
56. Configurable elite speed and randomized large-team compression (completed)
- Added `FIGHTER.ELITE.TYPE`, attack/movement speed constants, and randomized compression tuning under the fighter domain.
- Kept fixed compression below the configured threshold, while eligible teams roll each 100-member block against a configured 60% elite probability.
- Preserved represented population by leaving failed elite blocks as normal fighters; a `*4000` team targets an average of `24 elite` representing 2,400 fighters plus `1,600 normal` fighters.
57. Elite and normal composition in team cards (completed)
- Replaced the represented-population total in each team card with living physical composition in `E : <elite> | N : <normal>` format.
- Kept `stackCount` population weighting for combat-side statistics and targeting while exposing the actual rendered mix in the HUD.
- Adjusted desktop and mobile team card sizing so large normal counts remain readable.
58. Configurable elite attack-damage bonus (completed)
- Added elite attack-damage bonus multiplier and stack-exponent settings beside existing elite speed settings.
- Changed elite bonus multiplier semantics so `0` disables an added stack bonus and `1` applies the configured stack curve, consistently for damage and speeds.