From a7eec730d253c6fdb45ffe19cbefb7a7e3935d97 Mon Sep 17 00:00:00 2001 From: Horoli Date: Thu, 28 May 2026 10:17:07 +0900 Subject: [PATCH] feat: implement elite fighter compression for large battles with randomized scaling and percentage-based damage --- agent.md | 15 +- context/arena.md | 6 + context/combat.md | 27 ++- context/core.md | 16 +- context/fighter.md | 9 +- context/match-ui.md | 10 ++ elite.md | 237 +++++++++++++++++++++++++ src/constants.js | 28 ++- src/game/arena/ArenaScene.js | 7 +- src/game/arena/arenaSpectatorCamera.js | 25 ++- src/game/combat/combat.js | 74 +++++++- src/game/combat/worldEffects.js | 18 +- src/game/fighter/fighterFactory.js | 60 ++++++- src/game/fighter/fighterSelection.js | 26 +++ src/game/match/arenaMatchRuntime.js | 10 +- src/game/match/matchSetup.js | 116 ++++++++++-- src/styles/game-ui.css | 10 +- src/styles/mobile.css | 4 +- src/ui/arenaScoreboard.js | 10 +- src/ui/battleDeathNotice.js | 4 +- todo.md | 25 +++ 21 files changed, 660 insertions(+), 77 deletions(-) create mode 100644 elite.md diff --git a/agent.md b/agent.md index 4ad843f..9686741 100644 --- a/agent.md +++ b/agent.md @@ -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 모듈 구조, 디자인 변수, 반응형 및 애니메이션 가이드. diff --git a/context/arena.md b/context/arena.md index 2dab86e..489076d 100644 --- a/context/arena.md +++ b/context/arena.md @@ -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. diff --git a/context/combat.md b/context/combat.md index 5718a5e..9d2227c 100644 --- a/context/combat.md +++ b/context/combat.md @@ -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..damageMin/damageMax`를 수정합니다. +- **처치 보너스**: elite 압축 규칙을 유지하는 동안 `src/constants.js`의 `COMBAT.KILL_REWARD_ENABLED`는 `false`로 유지합니다. +- **공격력 조정**: 일반 역할 피해량은 `src/constants.js`의 `FIGHTER_TYPE_STATS..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`로 동결 시간과 표시 색상을 조정합니다. diff --git a/context/core.md b/context/core.md index f65d351..b0632e9 100644 --- a/context/core.md +++ b/context/core.md @@ -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..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`을 기준으로 관리합니다. diff --git a/context/fighter.md b/context/fighter.md index f979169..cd8f14f 100644 --- a/context/fighter.md +++ b/context/fighter.md @@ -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` 기본 프로필을 사용합니다. diff --git a/context/match-ui.md b/context/match-ui.md index 308ffc3..0c2a697 100644 --- a/context/match-ui.md +++ b/context/match-ui.md @@ -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 : | N : `. +- 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 diff --git a/elite.md b/elite.md new file mode 100644 index 0000000..1a7566f --- /dev/null +++ b/elite.md @@ -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 : | N : `로 표시한다. +- 사망 통계, 관전 판정, 밀집 구역 표적 선정은 계속 `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 : | N : ` 형식으로 표시한다. +- 예를 들어 `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 : | N : ` 형식으로 표시되는지 확인한다. +- 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 양쪽 흐름을 수동 확인한다. diff --git a/src/constants.js b/src/constants.js index 069cc01..956d1a9 100644 --- a/src/constants.js +++ b/src/constants.js @@ -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, }; diff --git a/src/game/arena/ArenaScene.js b/src/game/arena/ArenaScene.js index 8d6f6a0..09bb31d 100644 --- a/src/game/arena/ArenaScene.js +++ b/src/game/arena/ArenaScene.js @@ -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() { diff --git a/src/game/arena/arenaSpectatorCamera.js b/src/game/arena/arenaSpectatorCamera.js index 012df53..e0f41c5 100644 --- a/src/game/arena/arenaSpectatorCamera.js +++ b/src/game/arena/arenaSpectatorCamera.js @@ -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; diff --git a/src/game/combat/combat.js b/src/game/combat/combat.js index faf1c27..dc56175 100644 --- a/src/game/combat/combat.js +++ b/src/game/combat/combat.js @@ -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); } diff --git a/src/game/combat/worldEffects.js b/src/game/combat/worldEffects.js index e938db6..c02f312 100644 --- a/src/game/combat/worldEffects.js +++ b/src/game/combat/worldEffects.js @@ -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; } diff --git a/src/game/fighter/fighterFactory.js b/src/game/fighter/fighterFactory.js index d619a87..2f39063 100644 --- a/src/game/fighter/fighterFactory.js +++ b/src/game/fighter/fighterFactory.js @@ -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 } = {}, diff --git a/src/game/fighter/fighterSelection.js b/src/game/fighter/fighterSelection.js index 36a0571..1cc1cf9 100644 --- a/src/game/fighter/fighterSelection.js +++ b/src/game/fighter/fighterSelection.js @@ -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]; diff --git a/src/game/match/arenaMatchRuntime.js b/src/game/match/arenaMatchRuntime.js index f883007..00423e2 100644 --- a/src/game/match/arenaMatchRuntime.js +++ b/src/game/match/arenaMatchRuntime.js @@ -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)); +} diff --git a/src/game/match/matchSetup.js b/src/game/match/matchSetup.js index 71278da..6c1579d 100644 --- a/src/game/match/matchSetup.js +++ b/src/game/match/matchSetup.js @@ -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.`); diff --git a/src/styles/game-ui.css b/src/styles/game-ui.css index bede3f1..7fc9da6 100644 --- a/src/styles/game-ui.css +++ b/src/styles/game-ui.css @@ -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 { diff --git a/src/styles/mobile.css b/src/styles/mobile.css index 7caea4c..d060e9d 100644 --- a/src/styles/mobile.css +++ b/src/styles/mobile.css @@ -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 { diff --git a/src/ui/arenaScoreboard.js b/src/ui/arenaScoreboard.js index 64b3a76..8c39a97 100644 --- a/src/ui/arenaScoreboard.js +++ b/src/ui/arenaScoreboard.js @@ -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); diff --git a/src/ui/battleDeathNotice.js b/src/ui/battleDeathNotice.js index e3c1e28..2d9da55 100644 --- a/src/ui/battleDeathNotice.js +++ b/src/ui/battleDeathNotice.js @@ -27,8 +27,8 @@ const DEATH_NOTICE_TEMPLATES = [ const SYSTEM_TIP_TEMPLATES = [ "경보: 화염 메테오는 낙하 지점 5x5 영역에 강력한 폭발 피해를 입힙니다!", "주의: 냉기 메테오는 피해와 함께 2초간 동결 및 냉각을 유발합니다.", - "팁: 근접 캐릭터는 20% 확률로 치명타를 터뜨려 적을 즉사시킵니다.", - "성장: 적 처치 시 체력을 30% 회복하며, 크기와 속도가 최대 5배까지 커집니다.", + "팁: 근접 치명타는 일반 대상에 2배 피해, 엘리트 대상에 최대 체력 비례 피해를 줍니다.", + "엘리트 전투: 처치 보너스는 비활성화되어 전투 중 체력 회복이나 성장 효과가 없습니다.", ]; export function createDeathCounts() { diff --git a/todo.md b/todo.md index 06dae5f..fb3628f 100644 --- a/todo.md +++ b/todo.md @@ -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 : | N : ` 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.