feat: implement special effect projectiles, large-battle render budgeting, and UI optimizations

This commit is contained in:
Horoli 2026-05-28 16:15:52 +09:00
parent a7eec730d2
commit 9ca343c214
21 changed files with 1931 additions and 132 deletions

View File

@ -1,9 +1,45 @@
# Update: Special Effect Projectile
- Special battle effects are implemented separately from meteor/frost barrages in `src/game/combat/specialEffects.js`.
- Tuning lives under `SPECIAL_EFFECT` in `src/constants.js`; the same object is also exposed as `WORLD_EFFECT.SPECIAL` for world-effect domain access.
- Each live match schedules one special-effect attempt at a random time between `SPECIAL_EFFECT.TRIGGER_DELAY_MIN_MS` and `TRIGGER_DELAY_MAX_MS`. It retries briefly only when no eligible caster exists, and never fires more than once per match.
- Eligible casters are living, non-elite, non-magic fighters from teams that are not currently tied for first by represented living count. `SPECIAL_EFFECT.CASTER.BALANCE_NON_MAGIC_TYPES` first balances between available non-magic caster types, then picks a fighter inside that type, so ranged casters are not drowned out by the larger melee roster. The caster holds Hurt frame index `1` long enough for the zoom/focus layer to read, then the attack animation launches a giant projectile.
- The caster receives realtime special invulnerability for `SPECIAL_EFFECT.CASTER.INVULNERABLE_MS`. Normal attacks, world-effect damage/frost survivor effects, and special instant kills all skip fighters whose invulnerability window is still active.
- During that Hurt-frame preparation hold, combat is frozen by pausing fighter AI, Arcade Physics, and the scene clock, so combat/world timers do not advance. A realtime cinematic timer releases the pause when the attack motion begins.
- The caster is emphasized with a temporary focus stack: a blurred render-texture snapshot of the battlefield, a dim layer, then the caster and special launch/projectile effects above that layer. `SPECIAL_EFFECT.FOCUS_LAYER` controls depths, blur, alpha, and fades.
- `SPECIAL_EFFECT.CAMERA.CENTER_ON_CASTER_AT_START` makes the camera center on the caster location immediately when the special cast begins, before the slower zoom/focus motion continues.
- When the special projectile starts moving, the special camera stops zooming in and zooms out in place through `SPECIAL_EFFECT.CAMERA.PROJECTILE_VIEW_ZOOM` and `PROJECTILE_ZOOM_OUT_MS`; it does not follow the projectile.
- Special melee sprites use explicit 1-based `frameSequence` arrays, holding the largest frames longer than normal linear playback. Because melee sprites are moving projectiles, their animations loop while traveling. The projectile uses `startHoldMs` to remain visible near the caster before traveling.
- At target-selection time, the projectile locks onto the densest enemy tile area using represented `stackCount` population. `SPECIAL_EFFECT.PROJECTILE.targetAreaTiles` controls that scan footprint.
- The moving special projectile visual is selected by caster type: melee casters fire one random sprite from `SPECIAL_EFFECT.MELEE.ASSETS`, while ranged casters fire `public/assets/effects/special/projectile/projectile_Effect_1.png`. Projectile movement uses an Arcade Physics sprite and `physics.moveTo`, matching the normal ranged projectile position-update path. Projectile travel is clamped by `SPECIAL_EFFECT.PROJECTILE.arenaEdgePadding`, and any living fighter intersecting the projectile path is killed instantly, with kill/death records flowing through the normal combat cleanup path.
- Special preparation pauses existing combat objects as well as fighters: active battle tweens, world-effect fall tweens, combat-object animations, physics velocities, scene time, and Arcade Physics are restored only after the realtime Hurt-frame hold finishes.
# Update: Large-Battle Render Budget
- When the user-entered total fighter count is greater than `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD`, match setup enforces `PERFORMANCE.LARGE_BATTLE_RENDERED_FIGHTER_LIMIT` as a hard budget for physically rendered fighter plans.
- Randomized compression still rolls blocks first. If the resulting rendered count exceeds the budget, failed normal 100-member blocks and normal remainder groups are promoted to elite groups until the rendered count is within the limit or no promotable groups remain.
- `stackCount` is preserved, so team totals, death statistics, spectator weighting, and dense-area targeting continue to use the represented population.
# Update: Field HUD Text Removal
- Zoom-visible fighter HUD slots no longer create battlefield name text. Team identity is carried by the team-colored sprite shadow, so selected and zoom-visible fighters only borrow health-bar HUD objects.
# Update: Large-Battle Elite Probability
- When the user-entered total fighter count is greater than `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD`, randomized elite compression uses `FIGHTER.ELITE.RANDOMIZED_COMPRESSION.LARGE_BATTLE_ELITE_BLOCK_PROBABILITY` instead of the normal block probability.
- The large-battle elite probability is `0.8` by default and is clamped against the normal probability so large battles never use a lower elite block ratio than regular randomized compression.
# Update: Elite Magic Attack Effect Scale
- Instant-spell attack effects use `FIGHTER.ATTACK_EFFECT_SCALE_MULTIPLIER` for their normal visual scale.
- Elite magic fighters multiply that normal spell-effect scale by `FIGHTER.ELITE.ATTACK_EFFECT_SCALE_MULTIPLIER`, so giant elite casters can have attack effects sized independently from fighter body scale.
- Elite spawn plans select skins only from the configured `FIGHTER.ELITE.TYPE` list (`melee`, `magic`); normal plans retain the full skin pool.
# 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.
- 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. When the total requested fighter count is above the large-battle threshold, the probability increases to `LARGE_BATTLE_ELITE_BLOCK_PROBABILITY = 0.8`.
- Elite fighters use nested `FIGHTER.ELITE` settings for their type, 5x visual scale, magic attack-effect 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.
- 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.
@ -35,7 +71,7 @@
- Combat target acquisition now builds a per-frame spatial grid so every fighter that needs a fresh target can search nearby cells instead of scanning the full battlefield array.
- Large battle thresholds and related tuning live in `PERFORMANCE` inside `src/constants.js`, including target grid size, HUD pool size, minimap dot size, and large-battle corpse despawn delay.
- Fighter name/health HUD objects are pooled. Fighters no longer own permanent text/bar objects; selected and zoom-visible nearby fighters borrow HUD slots.
- Fighter health HUD objects are pooled. Fighters no longer own permanent HUD objects, and selected or zoom-visible nearby fighters borrow health-bar slots without battlefield name text.
- The minimap is separated from the field camera. During live matches, `ArenaScene` draws a lightweight graphics minimap through a dedicated `minimap-hud` camera while the main camera ignores the minimap object and the HUD camera ignores field objects. Presentation/waiting mode hides the minimap.
- Dead fighter despawn switches to the large-battle delay when the current fighter count reaches `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD`.

View File

@ -1,3 +1,13 @@
# Update: Special Effect Camera Focus
- `ArenaScene` preloads and creates special-effect animations beside the existing fighter and world-effect assets, then starts `startSpecialEffects()` for live matches only.
- During a special cast, `beginSpecialEffectCameraFocus()` zooms toward the caster, `zoomOutSpecialEffectCameraFocus()` zooms back out in place when the projectile starts moving, and `clearSpecialEffectCameraFocus()` restores the previous camera after the configured hold/outro timing.
- Special focus temporarily takes priority over selected-fighter, spectator, and meteor focus so the Hurt-frame pose and projectile launch are visible.
- When `SPECIAL_EFFECT.CAMERA.CENTER_ON_CASTER_AT_START` is enabled, special focus centers the main camera on the caster's location immediately and keeps it snapped there until projectile handoff, then continues the slower follow motion.
- The Hurt-frame preparation window sets `scene.specialEffectPreparationPaused`, pauses the scene clock and Arcade Physics, freezes living fighter animations, pauses active combat-object tweens/animations/velocities, and places a blurred battlefield snapshot plus dim layer beneath the raised caster. Camera focus tweening stays active so the zoom-in can complete while combat and world-effect progression are stopped.
- The projectile is not used as a camera target. Special camera targets are still clamped against the current zoom viewport so the focus stack cannot drag the camera outside the arena bounds.
- Match restart and match finish both call `clearSpecialEffects()` so pending timers, projectile objects, caster locks, and special camera tweens do not leak into the next battle.
# 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.
@ -8,7 +18,7 @@
- The minimap is drawn during live matches by `ArenaScene` as a lightweight `Graphics` overlay through a dedicated `minimap-hud` camera instead of reusing the field camera. Presentation/waiting mode hides it.
- The main camera ignores the minimap graphics object, and the HUD camera ignores field objects as they are added to the scene. Minimap rendering uses team-colored living fighter dots and the main camera viewport rectangle, so the minimap frame stays fixed while the main camera follows combat or meteor focus.
- `ArenaScene` refreshes HUD candidates on an interval, choosing selected fighters plus nearby visible fighters when zoomed in, then releases unused HUD pool slots.
- `ArenaScene` refreshes HUD candidates on an interval, choosing selected fighters plus nearby visible fighters when zoomed in, then releases unused health-bar HUD pool slots. Battlefield name text is not shown.
# Context: Arena & Scene

View File

@ -1,3 +1,22 @@
# Update: Special Effect Projectile
- `specialEffects.js` owns the one-shot special effect flow: asset preload/animation creation, random live-match scheduling, underdog caster selection, caster pose lock, launch visuals, projectile path checks, and cleanup.
- `SPECIAL_EFFECT` in `src/constants.js` tunes trigger timing, caster Hurt-frame hold, camera zoom, melee projectile assets, ranged projectile asset scale/speed/travel distance/hit radius, target density area, arena edge padding, and lifetime. `WORLD_EFFECT.SPECIAL` points to the same config object.
- Casters must be living, non-elite, non-magic fighters from teams that are not currently tied for first by represented living count. When `SPECIAL_EFFECT.CASTER.BALANCE_NON_MAGIC_TYPES` is enabled, caster selection first picks among available non-magic types and then picks a fighter from that type, preventing the larger melee roster from overwhelming ranged special casts. If no caster exists at the chosen time, the timer retries within the configured window instead of firing multiple times.
- Special casters receive realtime invulnerability for `SPECIAL_EFFECT.CASTER.INVULNERABLE_MS`. `combat.js` checks that window in normal attacks, world-effect damage, and special instant kills, while `worldEffects.js` also skips frost survivor effects for invulnerable fighters.
- While the caster holds the Hurt frame, `specialEffects.js` pauses fighter AI, Arcade Physics, the scene clock, existing combat-object physics velocity, combat-object animations, and combat-object tweens. Existing combat/world delayed calls and already-falling meteor/frost tweens stop advancing until the realtime preparation hold releases into the attack animation.
- Caster emphasis uses `SPECIAL_EFFECT.FOCUS_LAYER`: `specialEffects.js` snapshots the current battlefield into a render texture, applies Phaser Blur FX when available, adds a dim layer, and raises the caster above both layers until cleanup.
- The special camera does not follow the projectile. When projectile movement begins, `zoomOutSpecialEffectCameraFocus()` zooms out in place using `SPECIAL_EFFECT.CAMERA.PROJECTILE_VIEW_ZOOM` and `PROJECTILE_ZOOM_OUT_MS` so the projectile remains readable without dragging the camera off the arena.
- Melee special projectile effects can define 1-based `frameSequence` arrays. `specialEffects.js` converts them to Phaser frames so the largest frames can be repeated for readability, and melee projectile animations loop while traveling. The projectile's `startHoldMs` keeps it visible at the caster before travel begins.
- At target-selection time, the special projectile scans living enemies with a summed-area table and locks onto the `SPECIAL_EFFECT.PROJECTILE.targetAreaTiles` square containing the highest represented `stackCount` population. The moving projectile visual is type-based: melee casters fire one random `SPECIAL_EFFECT.MELEE.ASSETS` sprite, while ranged casters fire the configured projectile sprite. Movement uses an Arcade Physics sprite and `physics.moveTo`, matching normal ranged projectile position updates, and projectile travel is cut to the arena bounds using `arenaEdgePadding`.
- The special projectile calls `applySpecialEffectInstantKill()` from `combat.js`, so instant kills still use the normal death animation, death-stat recording, kill log attribution when there is a surviving caster, split-on-death behavior, scoreboard refresh, and match-finish checks.
# Update: Elite Magic Attack Effect Scale
- `combat.js` resolves instant-spell attack effect scale through constants instead of hard-coding `FIGHTER.SCALE`.
- Normal spell effects use `FIGHTER.SCALE * FIGHTER.ATTACK_EFFECT_SCALE_MULTIPLIER`.
- Elite magic spell effects additionally multiply by `FIGHTER.ELITE.ATTACK_EFFECT_SCALE_MULTIPLIER`, keeping caster body scale and effect scale separately tunable.
# 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.
@ -28,7 +47,7 @@
- `combat.js` resolves fighter animation keys through `ensureFighterTeamAnimation()` so every action can use the team-shadow baked texture generated from the original spritesheet.
- `playIfNeeded()` compares against the team-shadow animation key. This avoids switching back to the original non-team-colored spritesheet when fighters move, attack, take damage, or die.
- Frost stun remains a body tint effect in `worldEffects.js`. Since team identity is baked into the floor shadow pixels, there is no `teamMarker` tint state to update or restore.
- The removed `teamMarker` display object means death handling no longer needs to hide or destroy a separate marker. HUD cleanup only owns the name label and health bar objects.
- The removed `teamMarker` display object means death handling no longer needs to hide or destroy a separate marker. HUD cleanup only owns health-bar objects because battlefield name labels are no longer created.
# Update: Focused Combat Effects In Large Battles

View File

@ -1,3 +1,26 @@
# Update: Special Effect Constants
- `src/constants.js` now exports `SPECIAL_EFFECT` for the special projectile system, while `WORLD_EFFECT.SPECIAL` references the same object for world-effect domain grouping.
- `SPECIAL_EFFECT.TRIGGER_DELAY_MIN_MS`, `TRIGGER_DELAY_MAX_MS`, and `RETRY_DELAY_MS` control the once-per-match activation window.
- `SPECIAL_EFFECT.CASTER`, `CAMERA`, `MELEE`, and `PROJECTILE` centralize the Hurt-frame pose, non-magic caster type balancing, caster invulnerability time, caster-start centering, zoom timing, projectile-view zoom-out timing, melee projectile frame sequences/looping assets, projectile start hold, size, speed, target density area, arena edge padding, hit radius, travel distance, and lifetime.
- `SPECIAL_EFFECT.FOCUS_LAYER` controls the temporary caster-emphasis stack: blurred battlefield snapshot depth/alpha, dim depth/alpha, caster depth, Blur FX quality/strength/steps, and fade timing.
# Update: Large-Battle Render Budget
- `PERFORMANCE.LARGE_BATTLE_RENDERED_FIGHTER_LIMIT` caps the number of physical fighter plans produced for large battles.
- `matchSetup.js` applies the budget after randomized elite compression by promoting normal 100-member blocks or normal remainder groups to elite groups as needed. It does not change represented `stackCount` population.
# Update: Large-Battle Elite Probability
- `FIGHTER.ELITE.RANDOMIZED_COMPRESSION.LARGE_BATTLE_ELITE_BLOCK_PROBABILITY` controls the randomized elite block ratio once the user-entered total fighter count exceeds `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD`.
- `matchSetup.js` keeps the threshold under `PERFORMANCE` and the elite ratio under `FIGHTER.ELITE`, so performance detection and elite balancing remain separately tunable.
# Update: Elite Magic Attack Effect Scale
- `FIGHTER.ATTACK_EFFECT_SCALE_MULTIPLIER` controls normal instant-spell attack effect size.
- `FIGHTER.ELITE.ATTACK_EFFECT_SCALE_MULTIPLIER` applies only to elite magic fighters, multiplying the normal spell-effect scale without changing body scale, HP, range, or damage formulas.
- Elite skin selection uses the full `FIGHTER.ELITE.TYPE` list, currently `melee` and `magic`.
# 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.

View File

@ -1,14 +1,20 @@
# Update: Field HUD Text Removal
- `fighterFactory.js` no longer creates pooled battlefield name labels. Zoom-visible and selected fighters can still show pooled health bars, while team identity comes from the team-colored sprite shadow.
- HUD cleanup now owns only health-bar display objects.
# 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.
- `fighterSelection.js` assigns elite plans only skins whose derived type matches `FIGHTER.ELITE.TYPE` (currently `melee` and `magic`); 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 magic attack effects are scaled in `combat.js` through `FIGHTER.ELITE.ATTACK_EFFECT_SCALE_MULTIPLIER`, so spell visuals can be tuned independently from body scale.
- 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.
- HUD display objects are pooled on the scene and assigned only to selected fighters or zoom-visible nearby fighters chosen by `ArenaScene`.
- `fighterFactory.js` no longer creates permanent health bars for every fighter, and battlefield name labels are not created at all.
- Health-bar HUD display objects are pooled on the scene and assigned only to selected fighters or zoom-visible nearby fighters chosen by `ArenaScene`.
- `syncFighterHud()` acquires a slot lazily and `releaseFighterHud()` returns it to the pool when the fighter leaves the HUD candidate set, dies, or is destroyed.
- Tune pool size and visible candidate limits in `PERFORMANCE.FIGHTER_HUD_POOL_SIZE` and `PERFORMANCE.FIGHTER_HUD_VISIBLE_LIMIT`.

View File

@ -2,11 +2,13 @@
- 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.
- 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.
- If the user-entered total fighter count exceeds `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD`, randomized compression uses `FIGHTER.ELITE.RANDOMIZED_COMPRESSION.LARGE_BATTLE_ELITE_BLOCK_PROBABILITY` (`0.8` by default) for every eligible team block.
- Large battles also enforce `PERFORMANCE.LARGE_BATTLE_RENDERED_FIGHTER_LIMIT` by promoting failed normal blocks or normal remainder groups to elite groups after the random roll. This keeps physical sprites bounded while preserving represented `stackCount`.
- `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.
- `ArenaScene` uses setup-aware fighter selection so elite plans receive only skins matching `FIGHTER.ELITE.TYPE` (currently `melee` and `magic`), while normal plans keep the existing full selection pool.
- The team card layout reserves enough horizontal space for values such as `E : 32 | N : 800`, including the horizontally scrolling mobile scoreboard.
# Context: Match & UI

View File

@ -14,37 +14,54 @@
- `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개체로 실제 렌더링된다.
- 일반 랜덤 압축은 100명 블록마다 `ELITE_BLOCK_PROBABILITY = 0.6`으로 elite 압축 여부를 판정한다.
- 사용자가 입력한 총 fighter 수가 `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD`보다 크면
`LARGE_BATTLE_ELITE_BLOCK_PROBABILITY = 0.8`을 사용해 elite 압축 비율을 높인다.
예: `Alice*4000`은 40개 블록 중 장기 평균 32개가 elite 32개체로 압축되어 3,200명을
대표하고, 나머지 8개 블록은 normal 800개체로 실제 렌더링된다.
- 대규모 전투에서는 `PERFORMANCE.LARGE_BATTLE_RENDERED_FIGHTER_LIMIT`도 적용한다.
랜덤 판정 후 실제 렌더링 수가 제한을 넘으면 normal 100명 블록이나 remainder 묶음을 elite 그룹으로 추가 승격한다.
- 팀 카드는 대표 인원 합계 대신 생존 렌더 구성을 `E : <elite 수> | N : <normal 수>`로 표시한다.
- 사망 통계, 관전 판정, 밀집 구역 표적 선정은 계속 `stackCount` 합계를 사용한다.
### 2. Elite 스탯
Update: elite magic attack effects now use `FIGHTER.ELITE.ATTACK_EFFECT_SCALE_MULTIPLIER`
on top of the normal `FIGHTER.ATTACK_EFFECT_SCALE_MULTIPLIER`, so spell visuals can be tuned
separately from the elite fighter body scale.
구현된 상수:
```js
export const FIGHTER = {
// ...
ATTACK_EFFECT_SCALE_MULTIPLIER: 1,
ELITE: {
TYPE: "melee",
TYPE: ["melee", "magic"],
STACK_SIZE: 100,
VISUAL_SCALE_MULTIPLIER: 5,
ATTACK_EFFECT_SCALE_MULTIPLIER: 5,
HP_BONUS_RATIO: 1,
ATTACK_RANGE_MULTIPLIER: 1.5,
ATTACK_DAMAGE_BONUS_MULTIPLIER: 1,
ATTACK_DAMAGE_STACK_EXPONENT: 0.5,
ATTACK_DAMAGE_STACK_EXPONENT: 0.1,
ATTACK_SPEED_BONUS_MULTIPLIER: 1,
ATTACK_SPEED_STACK_EXPONENT: 0,
ATTACK_SPEED_STACK_EXPONENT: 0.1,
MOVE_SPEED_BONUS_MULTIPLIER: 1,
MOVE_SPEED_STACK_EXPONENT: 0,
RANDOMIZED_COMPRESSION: {
MIN_TEAM_SIZE: 100,
ELITE_BLOCK_PROBABILITY: 0.6,
LARGE_BATTLE_ELITE_BLOCK_PROBABILITY: 0.8,
},
},
};
export const PERFORMANCE = {
LARGE_BATTLE_FIGHTER_THRESHOLD: 3000,
LARGE_BATTLE_RENDERED_FIGHTER_LIMIT: 1200,
// ...
};
```
elite 계산 의도:
@ -119,6 +136,8 @@ WORLD_EFFECT.FROST_DAMAGE_PERCENT = 0.2;
- `COMBAT.KILL_REWARD_ENABLED = false`를 추가해 처치 보너스를 비활성화했다.
- `WORLD_EFFECT.METEOR_DAMAGE_PERCENT`, `WORLD_EFFECT.FROST_DAMAGE_PERCENT`를 추가했다.
- fighter 도메인 아래 `FIGHTER.ELITE` 키로 elite 상수를 관리한다.
- 일반 마법 공격 이펙트는 `FIGHTER.ATTACK_EFFECT_SCALE_MULTIPLIER`, elite 마법 공격 이펙트는
`FIGHTER.ELITE.ATTACK_EFFECT_SCALE_MULTIPLIER`로 조정한다.
- 카메라/렌더/worker 성능 리팩터링 없이 elite 밸런스 상수만 추가했다.
### `src/game/match/matchSetup.js`
@ -129,6 +148,10 @@ WORLD_EFFECT.FROST_DAMAGE_PERCENT = 0.2;
- 임계값 이상 팀은 완전한 100명 블록마다
`RANDOMIZED_COMPRESSION.ELITE_BLOCK_PROBABILITY`로 elite 여부를 판정한다.
성공 블록은 `stackCount: 100`인 elite 하나가 되고, 실패 블록은 normal 100개체로 렌더링한다.
- 전체 입력 fighter 수가 `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD`보다 크면
`RANDOMIZED_COMPRESSION.LARGE_BATTLE_ELITE_BLOCK_PROBABILITY`를 사용한다.
- 랜덤 압축 결과가 `PERFORMANCE.LARGE_BATTLE_RENDERED_FIGHTER_LIMIT`를 넘으면
실패한 normal 100명 블록이나 normal remainder 묶음을 elite 그룹으로 승격해 실제 렌더링 개체 수를 제한한다.
- 스폰 좌표 배열은 요청 인원 기준으로 생성하며, 각 elite는 대표하는 100명 블록의
첫 스폰 지점을 사용하고 나머지 normal은 대응하는 개별 스폰 지점을 사용한다.
@ -158,6 +181,8 @@ WORLD_EFFECT.FROST_DAMAGE_PERCENT = 0.2;
대상이 elite인지에 따라 고정 피해와 비율 피해를 나눈다.
- 공격력 계산은 `FIGHTER.ELITE.ATTACK_DAMAGE_*`, 공격속도와 이동속도 계산은
`FIGHTER.ELITE.ATTACK_SPEED_*``FIGHTER.ELITE.MOVE_SPEED_*` 상수를 사용한다.
- instant-spell 공격 이펙트 크기는 상수 기반으로 계산하고, elite magic 스킨이면
`FIGHTER.ELITE.ATTACK_EFFECT_SCALE_MULTIPLIER`를 추가 적용한다.
- 처치 흐름은 로그와 사망 처리는 유지하면서 `COMBAT.KILL_REWARD_ENABLED`
`false`일 때 `applyKillReward()`를 실행하지 않는다.
- 기준 커밋에 존재하는 Sprite 전투 경로인 `applyHit()`,
@ -183,7 +208,7 @@ WORLD_EFFECT.FROST_DAMAGE_PERCENT = 0.2;
- 살아 있는 elite 객체 수와 normal 객체 수를 각각 계산해
`E : <elite> | N : <normal>` 형식으로 표시한다.
- 예를 들어 `Alice*4000`의 평균 구성은 `E : 24 | N : 1600` 부근으로 표시되어
- 예를 들어 `Alice*4000`의 평균 구성은 `E : 32 | N : 800` 부근으로 표시되어
실제 렌더링되는 군세 구성을 바로 확인할 수 있다.
### `src/game/fighter/fighterModel.js`, `src/game/fighter/fighterAdapter.js`
@ -222,7 +247,7 @@ WORLD_EFFECT.FROST_DAMAGE_PERCENT = 0.2;
- `Alice*1`은 이전과 동일하게 normal 1개체만 생성된다.
- `Alice*99`는 normal 99개체로 생성된다.
- 현재 임계값 `100`에서는 `Alice*100` 이상의 완전한 블록이 `ELITE_BLOCK_PROBABILITY`에 따라 elite 또는 normal 100개체가 되는지 확인한다.
- `Alice*4000` 표본 반복에서 elite 수가 평균 24개, normal 수가 평균 1,600개에 수렴하고,
- `Alice*4000` 표본 반복에서 elite 수가 평균 32개, normal 수가 평균 800개에 수렴하고,
모든 plan의 `stackCount` 합계가 매번 4,000인지 확인한다.
- 팀 카드가 같은 구성에 대해 `E : <elite 수> | N : <normal 수>` 형식으로 표시되는지 확인한다.
- elite HP/공격력/공격속도/사거리/외형이 상수와 `stackCount` 계산식에 맞는다.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -12,6 +12,7 @@ export const ARENA = {
// 2. FIGHTER 도메인
export const FIGHTER = {
SCALE: 3,
ATTACK_EFFECT_SCALE_MULTIPLIER: 1,
DEPTH: 2,
DEAD_DEPTH: 1,
DEAD_DESPAWN_ALPHA: 0,
@ -72,26 +73,29 @@ export const FIGHTER = {
},
},
ELITE: {
TYPE: "melee",
TYPE: ["melee", "magic"],
STACK_SIZE: 100,
VISUAL_SCALE_MULTIPLIER: 5,
ATTACK_EFFECT_SCALE_MULTIPLIER: 5,
HP_BONUS_RATIO: 1,
ATTACK_RANGE_MULTIPLIER: 1.5,
ATTACK_DAMAGE_BONUS_MULTIPLIER: 1,
ATTACK_DAMAGE_STACK_EXPONENT: 0.5,
ATTACK_DAMAGE_STACK_EXPONENT: 0.1,
ATTACK_SPEED_BONUS_MULTIPLIER: 1,
ATTACK_SPEED_STACK_EXPONENT: 0,
ATTACK_SPEED_STACK_EXPONENT: 0.1,
MOVE_SPEED_BONUS_MULTIPLIER: 1,
MOVE_SPEED_STACK_EXPONENT: 0,
RANDOMIZED_COMPRESSION: {
MIN_TEAM_SIZE: 100,
ELITE_BLOCK_PROBABILITY: 0.6,
LARGE_BATTLE_ELITE_BLOCK_PROBABILITY: 0.8,
},
},
};
export const PERFORMANCE = {
LARGE_BATTLE_FIGHTER_THRESHOLD: 3000,
LARGE_BATTLE_FIGHTER_THRESHOLD: 2000,
LARGE_BATTLE_RENDERED_FIGHTER_LIMIT: 2000,
LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS: 0,
TARGET_GRID_CELL_SIZE: TILE_SIZE * 4,
FIGHTER_HUD_POOL_SIZE: 96,
@ -111,8 +115,8 @@ export const SPAWN = {
STARTING_ZONES: "starting-zones",
},
// Caps participant-assigned slots; traits such as slime spawning may add fighters.
MAX_FIGHTER_COUNT: 8000,
FIGHTERS_PER_STARTING_ZONE: 200,
MAX_FIGHTER_COUNT: 20000,
FIGHTERS_PER_STARTING_ZONE: 1000,
STARTING_ZONE_RADIUS: 2,
STARTING_ZONE_FILL_ALPHA: 0.07,
STARTING_ZONE_BORDER_ALPHA: 0.14,
@ -150,20 +154,20 @@ export const PROJECTILE = {
};
// 6. WORLD_EFFECT 도메인
export const WORLD_EFFECT = {
const WORLD_EFFECT_CONFIG = {
// Delay from match start until the first barrage.
INTERVAL: 8000,
// Delay between barrages after the first one has fired.
REPEAT_INTERVAL: 8000,
REPEAT_INTERVAL: 12000,
AREA_TILES: 40,
// How long the large dense-area warning marker remains visible.
WARNING_DURATION_MS: 2000,
IMPACT_AREA_TILES: 10,
IMPACT_COUNT_MIN: 15,
IMPACT_COUNT_MAX: 25,
IMPACT_COUNT_MIN: 10,
IMPACT_COUNT_MAX: 15,
IMPACT_STAGGER_MS: 140,
IMPACT_VISUAL_SCALE: 10,
SIZE_SCALE_VARIANCE: 0,
IMPACT_VISUAL_SCALE: 15,
SIZE_SCALE_VARIANCE: 1,
FRAMES: 7,
FRAME_RATE: 14,
FALL_DURATION: 920,
@ -186,6 +190,99 @@ export const WORLD_EFFECT = {
},
};
export const SPECIAL_EFFECT = {
ENABLED: true,
// A special effect is picked once per battle, no earlier than the first world-effect delay.
TRIGGER_DELAY_MIN_MS: 10000,
TRIGGER_DELAY_MAX_MS: 50000,
RETRY_DELAY_MS: 2000,
CASTER: {
HURT_FRAME_INDEX: 1,
HURT_HOLD_MS: 1800,
ATTACK_LAUNCH_DELAY_MS: 360,
ATTACK_TIME_SCALE: 0.9,
POST_CAST_COOLDOWN_MS: 1200,
BALANCE_NON_MAGIC_TYPES: true,
INVULNERABLE_MS: 7000,
},
CAMERA: {
ZOOM: 3,
CENTER_ON_CASTER_AT_START: true,
ZOOM_IN_MS: 720,
HOLD_MS: 1100,
ZOOM_OUT_MS: 1300,
LERP: 0.045,
PROJECTILE_VIEW_ZOOM: 1,
PROJECTILE_ZOOM_OUT_MS: 300,
},
FOCUS_LAYER: {
ENABLED: true,
BLUR_DEPTH: 5.2,
DIM_DEPTH: 5.3,
CASTER_DEPTH: 8,
BLUR_ALPHA: 0.78,
DIM_ALPHA: 0.34,
BLUR_QUALITY: 1,
BLUR_OFFSET_X: 3,
BLUR_OFFSET_Y: 3,
BLUR_STRENGTH: 1.35,
BLUR_STEPS: 6,
FADE_IN_MS: 160,
FADE_OUT_MS: 220,
},
MELEE: {
SCALE: 15,
FRAME_WIDTH: 100,
FRAME_HEIGHT: 100,
FRAME_RATE: 12,
DEPTH: 6,
SPAWN_DISTANCE: TILE_SIZE * 1.2,
ASSETS: [
{
key: "special-melee-effect-1",
path: "assets/effects/special/melee/melee_Effect_1.png",
frames: 11,
frameSequence: [4, 5, 6, 7, 7, 7, 7, 7, 8, 9],
},
{
key: "special-melee-effect-2",
path: "assets/effects/special/melee/melee_Effect_2.png",
frames: 8,
frameSequence: [2, 3, 4, 5, 5, 5, 5, 5, 5, 5, 5],
},
{
key: "special-melee-effect-3",
path: "assets/effects/special/melee/melee_Effect_3.png",
frames: 11,
frameSequence: [4, 5, 6, 7, 8, 8, 8, 8, 8, 9, 10],
},
],
},
PROJECTILE: {
key: "special-projectile-effect-1",
path: "assets/effects/special/projectile/projectile_Effect_1.png",
frames: 12,
frameWidth: 100,
frameHeight: 100,
frameRate: 20,
scale: 16,
speed: 1150,
depth: 7,
spawnDistance: TILE_SIZE * 2.8,
startHoldMs: 360,
targetAreaTiles: 8,
travelTiles: GRID_SIZE * 1.6,
arenaEdgePadding: TILE_SIZE * 2,
hitRadius: TILE_SIZE * 2.2,
maxLifetimeMs: 5200,
},
};
export const WORLD_EFFECT = {
...WORLD_EFFECT_CONFIG,
SPECIAL: SPECIAL_EFFECT,
};
// 7. CAMERA 도메인
export const CAMERA = {
MIN_ZOOM: 1,

View File

@ -4,6 +4,7 @@ import {
CAMERA,
COMBAT,
PERFORMANCE,
SPECIAL_EFFECT,
SPAWN,
UI,
} from "../../constants.js";
@ -16,6 +17,12 @@ import {
startWorldEffects,
updateWorldEffectModifiers,
} from "../combat/worldEffects.js";
import {
clearSpecialEffects,
createSpecialEffectAnimations,
preloadSpecialEffectAssets,
startSpecialEffects,
} from "../combat/specialEffects.js";
import { createFighterAnimations, preloadFighterSheets } from "../fighter/fighterAssets.js";
import {
createFighter,
@ -114,11 +121,19 @@ export class ArenaScene extends Phaser.Scene {
this.minimapHudCamera = null;
this.worldEffectTimer = null;
this.worldEffectZones = new Set();
this.specialEffectTimer = null;
this.specialEffectStartedAt = null;
this.specialEffectUsed = false;
this.specialEffectCastState = null;
this.specialEffectFocusState = null;
this.specialEffectFocusTween = null;
this.specialEffectFocusClearTimer = null;
}
preload() {
preloadFighterSheets(this, fighterManifest);
preloadWorldEffectAssets(this);
preloadSpecialEffectAssets(this);
}
create() {
@ -129,6 +144,7 @@ export class ArenaScene extends Phaser.Scene {
this.startingZoneGraphics = this.add.graphics().setDepth(0.5);
createFighterAnimations(this, fighterManifest);
createWorldEffectAnimations(this);
createSpecialEffectAnimations(this);
// 미니맵 카메라 설정
this.minimapGraphics = this.add.graphics().setDepth(10);
@ -218,6 +234,7 @@ export class ArenaScene extends Phaser.Scene {
this.setPaused(false, { silent: true });
this.clearFinalCombatEffects();
clearWorldEffects(this);
clearSpecialEffects(this);
this.presentationMode = silent;
this.resetMatchDeathStats({ silent });
this.observedCombat = [];
@ -233,6 +250,7 @@ export class ArenaScene extends Phaser.Scene {
this.showStartingZones(matchSetup.startingZones);
this.fighters = fighterPlans.map((fighterPlan) => createFighter(this, fighterPlan));
startWorldEffects(this);
startSpecialEffects(this);
if (!silent) {
trackMatchStart();
@ -453,6 +471,11 @@ update(time) {
return;
}
if (this.followSpecialEffectCameraFocus()) {
this.updateMinimap();
return;
}
if (this.focusSelectedFighter()) {
this.updateMinimap();
return;
@ -533,7 +556,7 @@ update(time) {
return fighterCameraPoint(this.finalFocusTarget);
}
moveCameraToward(target) {
moveCameraToward(target, lerp = CAMERA.SPECTATOR_LERP) {
if (!target) {
return;
}
@ -541,8 +564,197 @@ update(time) {
const targetX = Math.round(target.x);
const targetY = Math.round(target.y);
this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * CAMERA.SPECTATOR_LERP;
this.cameras.main.scrollY += (targetY - this.cameras.main.midPoint.y) * CAMERA.SPECTATOR_LERP;
this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * lerp;
this.cameras.main.scrollY += (targetY - this.cameras.main.midPoint.y) * lerp;
}
beginSpecialEffectCameraFocus(target) {
if (this.presentationMode || this.matchOver) {
return;
}
this.clearMeteorCameraFocus(null, { restoreCamera: false, immediate: true });
this.specialEffectFocusClearTimer?.remove(false);
this.specialEffectFocusClearTimer = null;
this.specialEffectFocusTween?.remove();
this.specialEffectFocusTween = null;
const previousFocus = this.specialEffectFocusState;
const focusState = {
restoreCenter: previousFocus?.restoreCenter ?? {
x: this.cameras.main.midPoint.x,
y: this.cameras.main.midPoint.y,
},
restoreZoom: previousFocus?.restoreZoom ?? this.cameras.main.zoom,
returning: false,
snapToTarget: Boolean(SPECIAL_EFFECT.CAMERA.CENTER_ON_CASTER_AT_START),
target,
zoom: this.cameras.main.zoom,
};
this.specialEffectFocusState = focusState;
if (SPECIAL_EFFECT.CAMERA.CENTER_ON_CASTER_AT_START) {
const focusTarget = this.resolveSpecialEffectCameraTarget(focusState);
if (focusTarget) {
this.cameras.main.centerOn(
Math.round(focusTarget.x),
Math.round(focusTarget.y),
);
}
}
this.specialEffectFocusTween = this.tweens.add({
targets: focusState,
zoom: SPECIAL_EFFECT.CAMERA.ZOOM,
duration: SPECIAL_EFFECT.CAMERA.ZOOM_IN_MS,
ease: "Cubic.Out",
onComplete: () => {
if (this.specialEffectFocusState === focusState) {
this.specialEffectFocusTween = null;
}
},
});
}
zoomOutSpecialEffectCameraFocus() {
if (!this.specialEffectFocusState || this.specialEffectFocusState.returning) {
return;
}
const focusState = this.specialEffectFocusState;
this.specialEffectFocusClearTimer?.remove(false);
this.specialEffectFocusClearTimer = null;
this.specialEffectFocusTween?.remove();
this.specialEffectFocusTween = null;
focusState.target = {
x: this.cameras.main.midPoint.x,
y: this.cameras.main.midPoint.y,
};
this.specialEffectFocusState.snapToTarget = false;
this.specialEffectFocusTween = this.tweens.add({
targets: focusState,
zoom: SPECIAL_EFFECT.CAMERA.PROJECTILE_VIEW_ZOOM,
duration: SPECIAL_EFFECT.CAMERA.PROJECTILE_ZOOM_OUT_MS,
ease: "Cubic.Out",
onComplete: () => {
if (this.specialEffectFocusState === focusState) {
this.specialEffectFocusTween = null;
}
},
});
}
followSpecialEffectCameraFocus() {
const focusState = this.specialEffectFocusState;
if (!focusState) {
return false;
}
this.setMainCameraZoom(focusState.zoom ?? SPECIAL_EFFECT.CAMERA.ZOOM);
const target = this.resolveSpecialEffectCameraTarget(focusState);
if (focusState.snapToTarget) {
this.cameras.main.centerOn(Math.round(target.x), Math.round(target.y));
} else {
this.moveCameraToward(target, SPECIAL_EFFECT.CAMERA.LERP);
}
return true;
}
resolveSpecialEffectCameraTarget(focusState) {
const target = focusState.target;
let focusTarget = focusState.restoreCenter;
if (target?.body?.center) {
focusTarget = target.body.center;
} else if (Number.isFinite(target?.x) && Number.isFinite(target?.y)) {
focusTarget = target;
}
return this.clampSpecialEffectCameraTarget(focusTarget);
}
clampSpecialEffectCameraTarget(target) {
if (!target) {
return null;
}
const camera = this.cameras.main;
const zoom = Math.max(CAMERA.MIN_ZOOM, Number(camera.zoom) || CAMERA.MIN_ZOOM);
const viewWidth = (Number(camera.width) || ARENA.SIZE) / zoom;
const viewHeight = (Number(camera.height) || ARENA.SIZE) / zoom;
const halfWidth = Math.min(ARENA.SIZE / 2, viewWidth / 2);
const halfHeight = Math.min(ARENA.SIZE / 2, viewHeight / 2);
return {
x: Phaser.Math.Clamp(target.x, halfWidth, ARENA.SIZE - halfWidth),
y: Phaser.Math.Clamp(target.y, halfHeight, ARENA.SIZE - halfHeight),
};
}
clearSpecialEffectCameraFocus({ restoreCamera = true, immediate = false } = {}) {
if (!this.specialEffectFocusState) {
return;
}
this.specialEffectFocusClearTimer?.remove(false);
this.specialEffectFocusClearTimer = null;
if (immediate) {
this.performClearSpecialEffectCameraFocus(restoreCamera, true);
return;
}
this.specialEffectFocusClearTimer = this.time.delayedCall(
SPECIAL_EFFECT.CAMERA.HOLD_MS,
() => this.performClearSpecialEffectCameraFocus(restoreCamera),
);
}
performClearSpecialEffectCameraFocus(restoreCamera, immediate = false) {
const focusState = this.specialEffectFocusState;
this.specialEffectFocusClearTimer?.remove(false);
this.specialEffectFocusClearTimer = null;
this.specialEffectFocusTween?.remove();
this.specialEffectFocusTween = null;
if (!focusState) {
return;
}
if (!restoreCamera || immediate || this.presentationMode || this.matchOver) {
this.specialEffectFocusState = null;
return;
}
focusState.returning = true;
focusState.target = focusState.restoreCenter;
this.specialEffectFocusTween = this.tweens.add({
targets: focusState,
zoom: focusState.restoreZoom,
duration: SPECIAL_EFFECT.CAMERA.ZOOM_OUT_MS,
ease: "Cubic.InOut",
onComplete: () => {
if (this.specialEffectFocusState !== focusState) {
return;
}
this.specialEffectFocusState = null;
this.specialEffectFocusTween = null;
this.setMainCameraZoom(focusState.restoreZoom);
this.cameras.main.centerOn(
Math.round(focusState.restoreCenter.x),
Math.round(focusState.restoreCenter.y),
);
},
});
}
beginMeteorCameraFocus(zone) {
@ -1196,6 +1408,7 @@ update(time) {
this.matchOver = true;
this.clearFinalCombatEffects();
clearWorldEffects(this);
clearSpecialEffects(this);
clearCombatObjects(this);
this.fighters.forEach((fighter) => {
if (fighter.body) {

View File

@ -19,7 +19,11 @@ import {
healEffectAnimationKey,
healEffectKey,
} from "../fighter/fighterAssets.js";
import { getFighterStats } from "../fighter/fighterStats.js";
import {
FIGHTER_TYPES,
getFighterStats,
getFighterType,
} from "../fighter/fighterStats.js";
const TARGET_SCAN_INTERVAL_MS = 180;
const TARGET_SCAN_JITTER_MS = 90;
@ -29,7 +33,13 @@ export function prepareCombatFrame(scene) {
}
export function updateFighter(scene, fighter, time, onWinner) {
if (!fighter.active || fighter.isDead || fighter.isFrostStunned || fighter.isLocked) {
if (
scene.specialEffectPreparationPaused
|| !fighter.active
|| fighter.isDead
|| fighter.isFrostStunned
|| fighter.isLocked
) {
fighter.body?.setVelocity(0, 0);
return;
}
@ -227,7 +237,7 @@ function spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack)
if (shouldRenderCombatEffects(scene)) {
const effect = scene.add.sprite(defender.x, defender.y, fighterAttackEffectKey(attacker.skin));
effect.setDepth(3);
effect.setScale(FIGHTER.SCALE);
effect.setScale(attackEffectScaleFor(attacker));
effect.play(fighterAttackEffectAnimationKey(attacker.skin));
trackCombatObject(scene, effect);
@ -251,6 +261,10 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = f
return;
}
if (isFighterSpecialInvulnerable(defender)) {
return;
}
if (isCritical && shouldRenderCombatEffects(scene)) {
spawnCriticalHitLabel(scene, defender);
}
@ -274,7 +288,12 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = f
}
export function applyWorldEffectDamage(scene, defender, effectType) {
if (scene.matchOver || !defender?.active || defender.isDead) {
if (
scene.matchOver
|| !defender?.active
|| defender.isDead
|| isFighterSpecialInvulnerable(defender)
) {
return false;
}
@ -297,6 +316,28 @@ export function applyWorldEffectDamage(scene, defender, effectType) {
return false;
}
export function applySpecialEffectInstantKill(scene, defender, attacker) {
if (
scene.matchOver
|| !defender?.active
|| defender.isDead
|| isFighterSpecialInvulnerable(defender)
) {
return false;
}
defender.hp = 0;
defender.body?.setVelocity(0, 0);
const winner =
attacker?.active && !attacker.isDead && attacker !== defender
? attacker
: undefined;
killFighter(defender, winner);
return true;
}
function criticalDamageFor(defender, normalDamage) {
if (!defender.isElite) {
return normalDamage * COMBAT.NORMAL_CRITICAL_DAMAGE_MULTIPLIER;
@ -329,6 +370,32 @@ function worldEffectDamageFor(defender, effectType) {
return Math.max(0, Math.ceil((defender.maxHp ?? 1) * percentage));
}
export function isFighterSpecialInvulnerable(fighter) {
if (!fighter?.isSpecialInvulnerable) {
return false;
}
const invulnerableUntil = Number(fighter.specialInvulnerableUntil) || 0;
if (invulnerableUntil > resolveRealtimeNow()) {
return true;
}
fighter.isSpecialInvulnerable = false;
fighter.specialInvulnerableUntil = 0;
if (fighter.specialInvulnerabilityTimer) {
globalThis.clearTimeout(fighter.specialInvulnerabilityTimer);
fighter.specialInvulnerabilityTimer = null;
}
return false;
}
function resolveRealtimeNow() {
return globalThis.performance?.now?.() ?? Date.now();
}
function spawnCriticalHitLabel(scene, defender) {
const scaleRatio = Math.max(1, Math.abs(defender.scaleY) / FIGHTER.SCALE);
const label = scene.add
@ -368,6 +435,20 @@ function getCombatType(fighter) {
return fighter.skin.combat?.type ?? "melee";
}
function attackEffectScaleFor(attacker) {
const baseScale =
FIGHTER.SCALE * FIGHTER.ATTACK_EFFECT_SCALE_MULTIPLIER;
if (
!attacker.isElite ||
getFighterType(attacker.skin) !== FIGHTER_TYPES.MAGIC
) {
return baseScale;
}
return baseScale * FIGHTER.ELITE.ATTACK_EFFECT_SCALE_MULTIPLIER;
}
function createAttackProfile(attacker) {
const isCritical = Math.random() < getCriticalChance(attacker);

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ import {
import {
applyWorldEffectDamage,
disposeCombatObject,
isFighterSpecialInvulnerable,
trackCombatObject,
} from "./combat.js";
@ -529,7 +530,12 @@ function resolveImpactDamage(scene, zone, effectType, onSurvivor) {
let deathCount = 0;
scene.fighters
.filter((fighter) => fighter.active && !fighter.isDead && containsFighter(zone, fighter))
.filter((fighter) =>
fighter.active
&& !fighter.isDead
&& !isFighterSpecialInvulnerable(fighter)
&& containsFighter(zone, fighter),
)
.forEach((fighter) => {
if (applyWorldEffectDamage(scene, fighter, effectType)) {
deathCount += 1;

View File

@ -10,7 +10,6 @@ import {
} from "./fighterAssets.js";
import { getFighterStats } from "./fighterStats.js";
const NAME_LABEL_BOTTOM_GAP = 14;
const HUD_DETAIL_SYNC_INTERVAL_MS = 100;
export function createFighter(
@ -175,11 +174,7 @@ export function syncFighterHud(
const scaleRatio = Math.max(1, Math.abs(fighter.scaleY) / FIGHTER.SCALE);
const healthOffset = 44 * scaleRatio;
const hitbox = fighter.body;
const nameX = hitbox.x + hitbox.width / 2;
const nameY = hitbox.y + hitbox.height + NAME_LABEL_BOTTOM_GAP;
hudSlot.nameLabel.setPosition(nameX, nameY);
hudSlot.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
hudSlot.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
hudSlot.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? 1)));
@ -232,7 +227,6 @@ function setVisibleIfChanged(gameObject, visible) {
}
function setHudSlotVisible(hudSlot, visible) {
setVisibleIfChanged(hudSlot.nameLabel, visible);
setVisibleIfChanged(hudSlot.healthBack, visible);
setVisibleIfChanged(hudSlot.healthBar, visible);
}
@ -251,7 +245,7 @@ function acquireFighterHudSlot(fighter) {
hudSlot.fighter = fighter;
fighter._hudSlot = hudSlot;
configureHudSlot(hudSlot, fighter);
configureHudSlot(hudSlot);
return hudSlot;
}
@ -266,18 +260,6 @@ function ensureFighterHudPool(scene) {
}
function createHudSlot(scene) {
const nameLabel = scene.add
.text(0, 0, "", {
color: "#fff2c2",
fontFamily: "Inter, Pretendard, sans-serif",
fontSize: "18px",
fontStyle: "700",
stroke: "#17180e",
strokeThickness: 4,
})
.setOrigin(0.5, 0)
.setDepth(4)
.setVisible(false);
const healthBack = scene.add
.rectangle(0, 0, 72, 8, 0x17180e, 0.92)
.setDepth(4)
@ -292,14 +274,10 @@ function createHudSlot(scene) {
fighter: null,
healthBack,
healthBar,
nameLabel,
};
}
function configureHudSlot(hudSlot, fighter) {
hudSlot.nameLabel.setText(fighter.fighterName ?? fighter.name ?? "");
hudSlot.nameLabel.setStroke(fighter.team?.color ?? "#17180e", 4);
hudSlot.nameLabel.setDepth(4);
function configureHudSlot(hudSlot) {
hudSlot.healthBack.setDepth(4);
hudSlot.healthBar.setDepth(5);
}

View File

@ -11,7 +11,7 @@ export const fighterManifest = [
walk: animation("Knight-Walk.png", 8),
attack: animation("Knight-Attack01.png", 7),
attack02: animation("Knight-Attack02.png", 10),
attack03: animation("Knight-Attack03.png", 11),
// attack03: animation("Knight-Attack03.png", 11),
block: animation("Knight-Block.png", 4),
hurt: animation("Knight-Hurt.png", 4),
death: animation("Knight-Death.png", 4),
@ -154,7 +154,7 @@ export const fighterManifest = [
walk02: animation("Lancer-Walk02.png", 8),
attack: animation("Lancer-Attack01.png", 6),
attack02: animation("Lancer-Attack02.png", 9),
attack03: animation("Lancer-Attack03.png", 8),
// attack03: animation("Lancer-Attack03.png", 8),
hurt: animation("Lancer-Hurt.png", 4),
death: animation("Lancer-Death.png", 4),
},
@ -190,7 +190,7 @@ export const fighterManifest = [
animations: {
idle: animation("Priest-Idle.png", 6),
walk: animation("Priest-Walk.png", 8),
attack: animation("Priest-Attack.png", 9),
attack: animation("Priest-Heal.png", 6),
heal: animation("Priest-Heal.png", 6),
hurt: animation("Priest-Hurt.png", 4),
death: animation("Priest-Death.png", 4),

View File

@ -3,7 +3,9 @@ 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.`);
throw new Error(
`Cannot pick ${count} fighters from ${fighters.length} entries.`,
);
}
return shuffleFighters(fighters).slice(0, count);
@ -24,14 +26,19 @@ export function pickFighters(fighters, count) {
}
export function pickFightersForSetups(fighters, fighterSetups) {
const eliteCount = fighterSetups.filter((fighterSetup) => fighterSetup.isElite).length;
const eliteCount = fighterSetups.filter(
(fighterSetup) => fighterSetup.isElite,
).length;
const normalCount = fighterSetups.length - eliteCount;
const eligibleEliteFighters = fighters.filter(
(fighter) => getFighterType(fighter) === FIGHTER.ELITE.TYPE,
const eligibleEliteFighters = fighters.filter((fighter) =>
FIGHTER.ELITE.TYPE.includes(getFighterType(fighter)),
);
if (eliteCount > 0 && eligibleEliteFighters.length === 0) {
throw new Error(`Cannot create elite fighters without ${FIGHTER.ELITE.TYPE} fighter skins.`);
throw new Error(
`Cannot create elite fighters without ${FIGHTER.ELITE.TYPE} fighter skins.`,
);
}
const elitePicks = pickFighters(eligibleEliteFighters, eliteCount);
@ -39,11 +46,11 @@ export function pickFightersForSetups(fighters, fighterSetups) {
let eliteIndex = 0;
let normalIndex = 0;
return fighterSetups.map((fighterSetup) => (
return fighterSetups.map((fighterSetup) =>
fighterSetup.isElite
? elitePicks[eliteIndex++]
: normalPicks[normalIndex++]
));
: normalPicks[normalIndex++],
);
}
function shuffleFighters(fighters) {

View File

@ -1,4 +1,4 @@
import { ARENA, FIGHTER, SPAWN, TEAM } from "../../constants.js";
import { ARENA, FIGHTER, PERFORMANCE, SPAWN, TEAM } from "../../constants.js";
const NAME_MULTIPLIER_REGEX = /\*(\d+)$/;
@ -37,21 +37,23 @@ export function createMatchSetup(
startingZones,
);
const fighters = [];
const teamRosters = [];
let spawnOffset = 0;
teams.forEach((team) => {
const teamFighters = usesRandomizedEliteCompression(team)
? createRandomizedEliteCompression(team, spawns, spawnOffset)
const teamRoster = usesRandomizedEliteCompression(team)
? createRandomizedEliteRoster(team, spawns, spawnOffset, totalFighters)
: createFixedEliteRoster(team, spawns, spawnOffset);
fighters.push(...teamFighters);
teamRosters.push(teamRoster);
spawnOffset += team.size;
});
enforceRenderedFighterLimit(teamRosters, totalFighters);
return {
fighters,
fighters: teamRosters.flatMap(materializeEliteRoster),
startingZones,
teams,
};
@ -63,70 +65,178 @@ function usesRandomizedEliteCompression(team) {
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],
return {
blocks: Array.from({ length: eliteCount }, (_, index) => ({
isElite: true,
startIndex: index * FIGHTER.ELITE.STACK_SIZE,
stackCount: FIGHTER.ELITE.STACK_SIZE,
team,
teamIndex,
}));
})),
normalRemainderCount: team.size % FIGHTER.ELITE.STACK_SIZE,
remainderIsElite: false,
spawnOffset,
spawns,
team,
};
}
function createRandomizedEliteRoster(team, spawns, spawnOffset, totalFighters) {
const eliteBlockProbability = resolveEliteBlockProbability(totalFighters);
const stackSize = FIGHTER.ELITE.STACK_SIZE;
const blockCount = Math.floor(team.size / stackSize);
return {
blocks: Array.from({ length: blockCount }, (_, blockIndex) => ({
canPromote: true,
isElite: Math.random() < eliteBlockProbability,
startIndex: blockIndex * stackSize,
stackCount: stackSize,
})),
normalRemainderCount: team.size % stackSize,
remainderIsElite: false,
spawnOffset,
spawns,
team,
};
}
function enforceRenderedFighterLimit(rosters, totalFighters) {
if (totalFighters <= PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD) {
return;
}
for (let index = 0; index < normalCount; index += 1) {
const teamIndex = eliteCount * FIGHTER.ELITE.STACK_SIZE + index;
const renderLimit = Math.max(
1,
Math.round(Number(PERFORMANCE.LARGE_BATTLE_RENDERED_FIGHTER_LIMIT) || 1),
);
let overflow = renderedFighterCount(rosters) - renderLimit;
if (overflow <= 0) {
return;
}
const promotableGroups = shuffle(
rosters.flatMap((roster) =>
[
...roster.blocks
.filter((block) => block.canPromote && !block.isElite)
.map((block) => ({
promote: () => {
block.isElite = true;
},
stackCount: block.stackCount,
})),
...(roster.normalRemainderCount > 1 && !roster.remainderIsElite
? [{
promote: () => {
roster.remainderIsElite = true;
},
stackCount: roster.normalRemainderCount,
}]
: []),
],
),
);
for (const group of promotableGroups) {
if (overflow <= 0) {
break;
}
group.promote();
overflow -= group.stackCount - 1;
}
}
function renderedFighterCount(rosters) {
return rosters.reduce(
(sum, roster) =>
sum
+ (roster.remainderIsElite ? 1 : roster.normalRemainderCount)
+ roster.blocks.reduce(
(blockSum, block) => blockSum + (block.isElite ? 1 : block.stackCount),
0,
),
0,
);
}
function materializeEliteRoster(roster) {
const {
blocks,
normalRemainderCount,
remainderIsElite,
spawnOffset,
spawns,
team,
} = roster;
const eliteCount =
blocks.filter((block) => block.isElite).length + (remainderIsElite ? 1 : 0);
const fighters = [];
let eliteIndex = 0;
blocks.forEach((block) => {
if (block.isElite) {
fighters.push(createElitePlan({
eliteCount,
eliteIndex,
spawn: spawns[spawnOffset + block.startIndex],
stackCount: block.stackCount,
team,
teamIndex: block.startIndex,
}));
eliteIndex += 1;
return;
}
for (let index = 0; index < block.stackCount; index += 1) {
const teamIndex = block.startIndex + index;
fighters.push(createNormalPlan(team, spawns[spawnOffset + teamIndex], teamIndex));
}
});
const remainderStartIndex = blocks.length * FIGHTER.ELITE.STACK_SIZE;
if (remainderIsElite) {
fighters.push(createElitePlan({
eliteCount,
eliteIndex,
spawn: spawns[spawnOffset + remainderStartIndex],
stackCount: normalRemainderCount,
team,
teamIndex: remainderStartIndex,
}));
return fighters;
}
for (let index = 0; index < normalRemainderCount; index += 1) {
const teamIndex = remainderStartIndex + 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;
function resolveEliteBlockProbability(totalFighters) {
const {
ELITE_BLOCK_PROBABILITY,
LARGE_BATTLE_ELITE_BLOCK_PROBABILITY,
} = FIGHTER.ELITE.RANDOMIZED_COMPRESSION;
eliteBlocks.forEach((isElite, blockIndex) => {
const blockStartIndex = blockIndex * stackSize;
const baseProbability = clampProbability(ELITE_BLOCK_PROBABILITY);
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));
if (totalFighters <= PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD) {
return baseProbability;
}
return fighters;
return Math.max(
baseProbability,
clampProbability(LARGE_BATTLE_ELITE_BLOCK_PROBABILITY),
);
}
function clampProbability(value) {
return Math.min(1, Math.max(0, Number(value) || 0));
}
function createElitePlan({ eliteCount, eliteIndex, spawn, stackCount, team, teamIndex }) {

18
todo.md
View File

@ -328,7 +328,7 @@
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.
- Preserved represented population by leaving failed elite blocks as normal fighters; before large-battle probability tuning, a `*4000` team targeted 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.
@ -336,3 +336,19 @@
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.
59. Configurable elite magic attack-effect scale (completed)
- Added `FIGHTER.ATTACK_EFFECT_SCALE_MULTIPLIER` and `FIGHTER.ELITE.ATTACK_EFFECT_SCALE_MULTIPLIER`.
- Updated instant-spell attack effects so normal magic effects use the base multiplier and elite magic effects apply the elite multiplier on top.
- Updated elite/combat/context documentation for magic-enabled elite skin selection and effect-scale tuning.
60. Large-battle elite block probability (completed)
- Added `FIGHTER.ELITE.RANDOMIZED_COMPRESSION.LARGE_BATTLE_ELITE_BLOCK_PROBABILITY = 0.8`.
- Updated match setup so randomized elite compression uses the large-battle probability when the user-entered total fighter count is greater than `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD`.
- Kept the probability under `FIGHTER.ELITE` while reusing the existing performance threshold constant.
61. Remove zoom-visible battlefield name labels (completed)
- Removed pooled Phaser text labels from fighter HUD slots.
- Kept pooled health bars for selected and zoom-visible fighters while relying on team-colored shadows for identity.
- Updated HUD documentation to describe health-bar-only field HUDs.
62. Large-battle rendered fighter budget (completed)
- Added `PERFORMANCE.LARGE_BATTLE_RENDERED_FIGHTER_LIMIT = 1200`.
- Changed match setup to build randomized elite rosters first, then promote failed normal 100-member blocks or normal remainder groups until physical fighter plans fit within the large-battle render budget.
- Preserved represented `stackCount` totals so statistics, spectator weighting, and world-effect density still reflect the full requested population.