feat: implement elite fighter compression for large battles with randomized scaling and percentage-based damage
This commit is contained in:
parent
30d7be41be
commit
a7eec730d2
15
agent.md
15
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 모듈 구조, 디자인 변수, 반응형 및 애니메이션 가이드.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`로 동결 시간과 표시 색상을 조정합니다.
|
||||
|
|
|
|||
|
|
@ -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`을 기준으로 관리합니다.
|
||||
|
|
|
|||
|
|
@ -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` 기본 프로필을 사용합니다.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 양쪽 흐름을 수동 확인한다.
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 } = {},
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ const DEATH_NOTICE_TEMPLATES = [
|
|||
const SYSTEM_TIP_TEMPLATES = [
|
||||
"경보: 화염 메테오는 낙하 지점 5x5 영역에 강력한 폭발 피해를 입힙니다!",
|
||||
"주의: 냉기 메테오는 피해와 함께 2초간 동결 및 냉각을 유발합니다.",
|
||||
"팁: 근접 캐릭터는 20% 확률로 치명타를 터뜨려 적을 즉사시킵니다.",
|
||||
"성장: 적 처치 시 체력을 30% 회복하며, 크기와 속도가 최대 5배까지 커집니다.",
|
||||
"팁: 근접 치명타는 일반 대상에 2배 피해, 엘리트 대상에 최대 체력 비례 피해를 줍니다.",
|
||||
"엘리트 전투: 처치 보너스는 비활성화되어 전투 중 체력 회복이나 성장 효과가 없습니다.",
|
||||
];
|
||||
|
||||
export function createDeathCounts() {
|
||||
|
|
|
|||
25
todo.md
25
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 : <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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue