Compare commits
9 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
c0f7f5fbd8 | |
|
|
da8ae49a72 | |
|
|
7814ed3951 | |
|
|
f650592676 | |
|
|
3b7fa17d06 | |
|
|
3b1a883787 | |
|
|
23376e8cbb | |
|
|
9ca343c214 | |
|
|
a7eec730d2 |
|
|
@ -4,3 +4,4 @@ dist/
|
||||||
config.json
|
config.json
|
||||||
package-lock.json
|
package-lock.json
|
||||||
*.log
|
*.log
|
||||||
|
.omo
|
||||||
471
agent.md
471
agent.md
|
|
@ -1,267 +1,270 @@
|
||||||
|
# Update: Restrained Team Card Styling
|
||||||
|
|
||||||
|
- Team score cards keep the existing label, elite/normal count, click behavior, and focused-team state.
|
||||||
|
- Team color is limited to a compact team marker and muted inner divider instead of a full-height side stripe or filled card background.
|
||||||
|
- Hover and focus states are quieter: no raised hover motion, reduced brightness, and a subtle inset focus treatment instead of an outer glow.
|
||||||
|
|
||||||
|
# Update: Battle Notice Rolling Text
|
||||||
|
|
||||||
|
- `battleDeathNotice.js` now renders notice text inside a message span, measures the rendered content width on the next animation frame, and switches to a rolling track only when the text exceeds the notice box content width.
|
||||||
|
- Overflowing battle notices duplicate the message in an `aria-hidden` track and use `aria-label` on the status node so assistive text is not repeated.
|
||||||
|
- Rolling speed, gap, and duration clamps are tuned by `UI.BATTLE_NOTICE_ROLL_*` constants; non-overflowing notices keep the normal centered display.
|
||||||
|
|
||||||
|
# Update: Special Projectile Trail
|
||||||
|
|
||||||
|
- Special projectile movement can leave short-lived visual afterimages controlled by `SPECIAL_EFFECT.PROJECTILE.TRAIL`.
|
||||||
|
- Trail sprites copy the projectile's current texture frame, scale, rotation, and flip state, then fade out without affecting hit detection or damage.
|
||||||
|
- Trail density and cost are bounded by `TRAIL.INTERVAL_MS` and `TRAIL.LIFETIME_MS`.
|
||||||
|
|
||||||
|
# Update: Split Special Projectile Visual Configs
|
||||||
|
|
||||||
|
- Special projectile visual asset settings are split by caster type: melee visuals live under `SPECIAL_EFFECT.MELEE`, and ranged visuals live under `SPECIAL_EFFECT.RANGE`.
|
||||||
|
- `SPECIAL_EFFECT.PROJECTILE` now owns shared movement, hit-detection, and trail tuning only, such as acceleration, travel duration, hold time, target area, arena clamp, hit radius, lifetime, and afterimages.
|
||||||
|
|
||||||
|
# Update: One-Shot Accelerating Special Projectile
|
||||||
|
|
||||||
|
- Melee special sprites now use `SPECIAL_EFFECT.MELEE.REPEAT = 0`, so the configured sprite sheet plays once instead of looping while the projectile travels.
|
||||||
|
- `special-melee-effect-1` has its `frameSequence` commented out for now, restoring the natural sprite-sheet order. A commented example remains next to the asset for quick tuning later.
|
||||||
|
- Special projectile movement now uses a tween instead of constant `physics.moveTo`. `SPECIAL_EFFECT.PROJECTILE.startHoldMs` controls the stationary pre-launch tell, `travelDurationMs` controls launch speed when set, and `movementEase` controls the acceleration curve; `speed` remains the fallback if `travelDurationMs` is unset.
|
||||||
|
|
||||||
|
# Update: Special Effect Frame Sequence Refresh
|
||||||
|
|
||||||
|
- Special effect animations now compare the existing Phaser animation against the current configured frames, repeat count, and frame rate. If the config changed, the old global animation key is removed and recreated so `frameSequence` edits take effect without stale animation data.
|
||||||
|
- `frameSequence` remains 1-based for sprite-sheet inspection, then converts through `generateFrameNumbers(..., { frames })`, preserving repeated frames when a special asset enables a custom sequence.
|
||||||
|
|
||||||
|
# Update: Special Effect Frame Rate And Render Budget
|
||||||
|
|
||||||
|
- Special effect sprite animations now multiply their configured `frameRate` by `SPECIAL_EFFECT.FRAME_RATE_MULTIPLIER`. This changes only visual frame playback; caster hold time, launch delay, projectile speed, travel distance, and timers keep their existing progress speed.
|
||||||
|
- The special focus blur snapshot is skipped when the living fighter count is above `SPECIAL_EFFECT.FOCUS_LAYER.BLUR_MAX_FIGHTERS`. The dim layer and raised caster focus still render, avoiding the expensive full-arena render-texture blur during larger battles.
|
||||||
|
- Special projectile hit checks now use the per-frame combat spatial index to inspect only fighters near the projectile segment when the index is available, while preserving the full-array fallback.
|
||||||
|
|
||||||
|
# Update: Elite Kill Splash
|
||||||
|
|
||||||
|
- Elite fighters now trigger a kill splash when they directly kill an enemy. The splash is centered on the killed fighter's body position.
|
||||||
|
- Splash damage is `COMBAT.ELITE_KILL_SPLASH_DAMAGE_PERCENT` of the killed fighter's max HP and applies to living enemy fighters inside `COMBAT.ELITE_KILL_SPLASH_RADIUS`.
|
||||||
|
- Splash kills still use the normal kill/death flow for logs, death statistics, despawn, split-on-death, scoreboard, and match-finish checks. `COMBAT.ELITE_KILL_SPLASH_CHAIN_ENABLED` is `false` by default, so splash kills do not recursively trigger more splashes unless explicitly enabled.
|
||||||
|
- A short team-colored pixel-dot burst is rendered for the splash when supplemental combat effects are enabled, avoiding smooth vector circles.
|
||||||
|
|
||||||
|
# Update: Team Card Focus Toggle
|
||||||
|
|
||||||
|
- Team cards in the left HUD still select a random living fighter from the clicked team and zoom the camera in.
|
||||||
|
- Clicking the same already-focused team card again clears the selected fighter, requests `CAMERA.MIN_ZOOM`, restores the match status summary, and removes the focused team-card state. If automatic spectator focus is active, that camera mode immediately reapplies its own zoom; this is intended.
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
- While the caster holds the Hurt frame, `SPECIAL_EFFECT.CASTER_SPARKLE` plays only frames 2, 3, and 4 from `public/assets/effects/special/effect.png` above the caster's eye area, then removes the sparkle before 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 can use explicit 1-based `frameSequence` arrays, but `special-melee-effect-1` currently leaves the sequence disabled to play the sheet once in natural order. 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 use `SPECIAL_EFFECT.RANGE`. Projectile movement uses a tweened Arcade Physics sprite so acceleration can be tuned while path hit checks still run per update. 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. 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.
|
||||||
|
- 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 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
|
||||||
|
|
||||||
|
- Fire and frost world effects now target the `WORLD_EFFECT.AREA_TILES` tile square containing the highest living-fighter density instead of a random fighter location.
|
||||||
|
- Each activation renders that large warning area, then drops `WORLD_EFFECT.IMPACT_COUNT_MIN` to `IMPACT_COUNT_MAX` smaller strikes within it. Only the smaller impact zones apply damage, frost, and lingering slow areas.
|
||||||
|
- `WORLD_EFFECT.WARNING_DURATION_MS` tunes how long the large targeting warning remains visible. `IMPACT_AREA_TILES`, `IMPACT_STAGGER_MS`, and `IMPACT_VISUAL_SCALE` tune the barrage footprint, rhythm, and sprite size, while `SIZE_SCALE_VARIANCE` randomizes individual impact scale.
|
||||||
|
- `WORLD_EFFECT.INTERVAL` delays the first barrage from match start; `WORLD_EFFECT.REPEAT_INTERVAL` controls later normal barrages, while sudden-death repetition continues to use `SUDDEN_DEATH.INTERVAL_MS`.
|
||||||
|
- Meteor screen shake scales from the same size multiplier, with base values in `WORLD_EFFECT.METEOR_SHAKE_DURATION_MS` and `WORLD_EFFECT.METEOR_SHAKE_INTENSITY`.
|
||||||
|
|
||||||
|
# Update: Direct Fighter Counts And Spawn Zones
|
||||||
|
|
||||||
|
- Live match entries interpret a suffix such as `Alice*250` as that team's assigned fighter count; entries without a suffix receive one assigned fighter.
|
||||||
|
- The former team-size inputs are removed. Presentation mode retains its fixed preview size through suffixed internal entries.
|
||||||
|
- `SPAWN.MAX_FIGHTER_COUNT` caps only fighters assigned through participant input. Slime `spawnMultiplier` and `splitOnDeath` additions are game traits and are not counted against that input cap.
|
||||||
|
- Match-start validation shows a styled fighter-cap warning card beneath the participant nickname input, emphasizes requested and allowed counts separately, and clears when names are edited or a valid match is submitted.
|
||||||
|
- For starting-zone placement, `SPAWN.FIGHTERS_PER_STARTING_ZONE` defines how many assigned fighters share each team zone.
|
||||||
|
|
||||||
|
# Update: Large Battle Performance
|
||||||
|
|
||||||
|
- Combat target acquisition now builds a per-frame spatial grid so every fighter that needs a fresh target can search nearby cells instead of scanning the full battlefield array.
|
||||||
|
- Large battle thresholds and related tuning live in `PERFORMANCE` inside `src/constants.js`, including target grid size, HUD pool size, minimap dot size, and large-battle corpse despawn delay.
|
||||||
|
- Fighter 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`.
|
||||||
|
|
||||||
|
# Update: Dead Fighter Despawn
|
||||||
|
|
||||||
|
- Dead fighters now keep their initial opacity at death, then fade out over `FIGHTER.DEAD_DESPAWN_DELAY_MS` before being removed.
|
||||||
|
- Adjust the corpse lifetime in `src/constants.js` by changing `FIGHTER.DEAD_DESPAWN_DELAY_MS`; adjust the final fade target with `FIGHTER.DEAD_DESPAWN_ALPHA`.
|
||||||
|
- Despawn uses the Phaser scene timer and a matching tween so pause/state cleanup follows the existing match lifecycle.
|
||||||
|
|
||||||
|
# Update: Team Shadow Rendering
|
||||||
|
|
||||||
|
- Team color is now represented by recoloring the built-in floor shadow pixels on each fighter spritesheet instead of rendering a duplicated `teamMarker` sprite.
|
||||||
|
- `fighterAssets.js` owns lazy team-shadow texture and animation generation for actual `skin + action + teamColor` combinations. Avoid pre-generating every team/skin/action combination because that can move the bottleneck into startup texture creation and memory use.
|
||||||
|
- `fighterFactory.js` should keep each fighter to one Phaser sprite. Name labels and health bars remain separate HUD objects, but there is no per-fighter team marker sprite to synchronize.
|
||||||
|
- `combat.js` must resolve action animations through `ensureFighterTeamAnimation()` so action changes keep the team-colored shadow.
|
||||||
|
- Frost stun uses body tint only. Do not use tint for persistent team identity.
|
||||||
|
|
||||||
# Agent: Arena Picker
|
# Agent: Arena Picker
|
||||||
|
|
||||||
## 0. 필수
|
## 0. 필수
|
||||||
|
|
||||||
- 작업이 완료되면 작업과 관련된 모든 문서를 함께 업데이트한다.
|
- 작업이 완료되면 작업에 관련된 모든 문서를 업데이트한다
|
||||||
- 대규모 전투, LOD, 모델/렌더 분리, 전투 워커, 서버 API를 수정할 때는 관련 `context/` 문서를 먼저 확인하고 변경 내용을 문서에 반영한다.
|
|
||||||
|
|
||||||
## 1. 프로젝트 정의
|
## 1. 프로젝트 정의
|
||||||
|
|
||||||
**Arena Picker**는 Phaser 3 게임 엔진과 Vite 번들러를 기반으로 구축된 **대규모 팀 전투 시뮬레이션 웹 애플리케이션**입니다. 사용자가 입력한 여러 참가자 닉네임을 각각 하나의 팀으로 설정하고, `닉네임*N` 형식으로 지정된 인원만큼 캐릭터를 생성해 자동 전투를 시뮬레이션합니다.
|
**Arena Picker**는 Phaser 3 게임 엔진과 Vite 번들러를 기반으로 구축된 **대규모 팀 전투 시뮬레이션 웹 애플리케이션**입니다. 사용자가 입력한 여러 명의 참가자(닉네임)를 바탕으로 각 참가자를 하나의 팀으로 설정하고, 지정된 인원만큼의 캐릭터를 생성하여 자동 전투를 시뮬레이션합니다.
|
||||||
|
|
||||||
전장은 3200px 월드 크기를 유지하되 Phaser 내부 렌더 캔버스는 1280px로 낮춰 픽셀 작업량을 줄입니다. 일반 전투는 개별 Phaser Sprite와 Arcade Physics를 사용하고, 3,000명 이상 대규모 전투에서는 `FighterModel` 중심 시뮬레이션, rolling-window LOD, Web Worker 기반 후보 선정/집계 전투, HUD/이펙트 풀링을 결합해 8,000명급 전투를 처리합니다.
|
서버 런타임은 Fastify를 사용하며, MongoDB 커넥션 풀을 유지해 유니크 방문자 수와 전투 사망 통계를 기록하는 간단한 통계 API를 제공합니다.
|
||||||
|
|
||||||
서버 런타임은 Fastify를 사용하며 MongoDB 커넥션 풀을 유지합니다. 방문자 수, 일일 운영 지표, 전투 사망 통계, About 콘텐츠를 API로 제공합니다.
|
## 2. 프로젝트 전체 구조 (Directory Tree)
|
||||||
|
|
||||||
## 2. 현재 아키텍처 핵심
|
|
||||||
|
|
||||||
### 2.1 FighterModel 기반 상태와 렌더 브리지
|
|
||||||
|
|
||||||
- `src/game/fighter/fighterModel.js`가 HP, 팀, 스킨, 타깃, 쿨다운, 사망/선택/성장/동결 상태, 모델 좌표를 보관하는 순수 JS 상태 객체를 만듭니다.
|
|
||||||
- `fighterFactory.js`는 실제 Phaser Sprite를 생성하고 `fighter.model` 브리지로 기존 `fighter.hp`, `fighter.team` 스타일 접근을 호환합니다.
|
|
||||||
- `fighterAdapter.js`는 위치, 거리, 방향, 이동, body enable/disable, 애니메이션, 동결 tint, arena clamp 등 Phaser Sprite 접근의 경계입니다. 전투/카메라/월드 이펙트 코드는 새로 직접 `body`, `setVelocity()`, 애니메이션 API를 만지기보다 adapter를 우선 사용합니다.
|
|
||||||
- `ArenaScene`은 `fighterModels`, `fighterByModelId`, `fighterModelById`를 함께 유지합니다. `fighterForModelId()`는 현재 attach된 렌더 Sprite만 반환할 수 있으므로, 전투 로직은 null 가능성을 항상 고려합니다.
|
|
||||||
|
|
||||||
### 2.2 대규모 전투 렌더 LOD
|
|
||||||
|
|
||||||
- 대규모 live match는 `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` 이상에서 render LOD를 활성화합니다.
|
|
||||||
- full-arena overview는 `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT`만큼 팀별 대표 Sprite를 유지하고, 나머지 생존자는 팀 색상 dot으로 표시합니다.
|
|
||||||
- zoomed, selected, spectator 시점은 rolling camera window 안의 모든 생존자를 detailed Sprite로 승격합니다. 이 경로는 더 이상 별도 zoom cap이나 buffer ratio에 묶이지 않습니다.
|
|
||||||
- `src/game/arena/fighterLodWorker.js`는 생존 fighter worker id, position, team key TypedArray를 받아 현재 match/job의 detailed id 목록만 반환합니다. Worker 실패 또는 오류 시 `resolveFighterLodDetailedSet()` 동기 경로로 fallback합니다.
|
|
||||||
- LOD 적용은 최초 활성화 때 full sync를 수행한 뒤, 이후에는 이전 detailed set과 다음 set의 차이만 attach/detach합니다.
|
|
||||||
- parked fighter는 display/update list에서 빠지고 Arcade World에서도 `world.disable()`로 제거됩니다. 재진입 시 `world.enable()` 후 모델 좌표로 body를 복구합니다.
|
|
||||||
- hidden-fighter dot redraw는 zoomed view에서 카메라 viewport와 padding 밖의 dot을 건너뛰어 `Graphics.fillRect()` 비용을 줄입니다.
|
|
||||||
|
|
||||||
### 2.3 대규모 전투 집계 시뮬레이션
|
|
||||||
|
|
||||||
- attached/detail fighter는 매 프레임 `updateFighterModel()`로 고정밀 개별 AI를 유지합니다.
|
|
||||||
- detached/offscreen fighter는 `team + cell + squad` 단위로 압축되어 coarse movement와 group DPS를 처리합니다. 기본 squad 크기는 `PERFORMANCE.LARGE_BATTLE_AGGREGATE_SQUAD_SIZE`가 제어합니다.
|
|
||||||
- `src/game/combat/aggregateCombatWorker.js`는 detached model id, position, HP, team key, 이동속도, DPS, frost flag를 Transferable TypedArray로 받아 집계 전투를 계산합니다.
|
|
||||||
- Phaser 상태 변경, death 처리, split-on-death, kill reward, scoreboard, match finish는 여전히 main thread가 소유합니다.
|
|
||||||
- Worker 결과는 match id가 일치하고 해당 model이 여전히 detached일 때만 적용합니다. 이미 attach된 fighter, 죽었거나 unregister된 stale id는 무시합니다.
|
|
||||||
- Worker 생성 실패 또는 오류 시 기존 동기 집계 전투 경로로 fallback합니다.
|
|
||||||
|
|
||||||
### 2.4 전투 및 이펙트 최적화
|
|
||||||
|
|
||||||
- target spatial index는 model 기반으로 구성하되, 대규모 전투에서는 attached/detail model 중심으로 갱신해 8,000명 전체 스캔을 줄입니다.
|
|
||||||
- stale `targetModelId`는 null-safe validation으로 정리합니다.
|
|
||||||
- instant-spell 공격 시각 효과는 texture별 sprite pool을 재사용합니다. `clearCombatObjects()`는 active pooled effect도 공통 cleanup 경로로 반환합니다.
|
|
||||||
- projectile hit detection은 projectile마다 Arcade overlap collider를 만들지 않고 line/rectangle path check와 scratch geometry를 재사용합니다.
|
|
||||||
- 대규모 전투에서 critical label, instant-spell sprite, kill-heal sprite, kill-growth tween 같은 보조 효과는 meteor camera focus 중일 때만 노출합니다. damage, heal, 성장 수치 자체는 유지됩니다.
|
|
||||||
- world effect는 랜덤 생존자 대신 생존자 밀집도가 가장 높은 tile square를 큰 경고 영역으로 잡고, 내부에 소형 화염/냉기 strike를 분산 투하합니다.
|
|
||||||
|
|
||||||
### 2.5 카메라, HUD, 서버 지표
|
|
||||||
|
|
||||||
- 대규모 live match 시작 시 full-arena 최저 줌 대신 평균 생존 위치에 가까운 fighter 주변으로 `CAMERA.LARGE_BATTLE_START_ZOOM`을 적용합니다.
|
|
||||||
- scoreboard 팀 버튼을 이미 선택된 팀에 다시 클릭하면 선택을 해제하고 full-arena view로 돌아갑니다.
|
|
||||||
- 수동 fighter/team focus와 full-arena return은 `transitionMainCameraTo()`의 Phaser `pan()`/`zoomTo()` tween을 사용합니다.
|
|
||||||
- HUD 체력바는 모든 fighter가 영구 소유하지 않고 pool에서 빌려 씁니다. selected fighter와 zoom-visible 후보만 slot을 보유하며, zoom HUD에는 fighter 이름을 표시하지 않습니다.
|
|
||||||
- live minimap은 별도 HUD camera와 `Graphics` dot overlay로 렌더링하며 `PERFORMANCE.MINIMAP_REFRESH_MS`로 redraw를 throttle합니다.
|
|
||||||
- 서버는 visitor, death stats, daily metrics, About 콘텐츠 API를 제공합니다.
|
|
||||||
|
|
||||||
## 3. 프로젝트 전체 구조 (Directory Tree)
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
├── index.html # 메인 HTML 진입점 및 UI 레이아웃
|
├── index.html # 메인 HTML 진입점 및 UI 레이아웃
|
||||||
├── package.json # Phaser, Vite, Fastify, MongoDB 의존성 및 npm scripts
|
├── package.json # 프로젝트 의존성 및 스크립트 정의 (Phaser, Vite, Fastify, MongoDB)
|
||||||
|
├── config.json # 로컬 서버/MongoDB 설정 (git ignore)
|
||||||
├── config.json.sample # 공유용 서버/MongoDB 설정 예시
|
├── config.json.sample # 공유용 서버/MongoDB 설정 예시
|
||||||
├── agent.md # 프로젝트 개요 및 에이전트 작업 가이드
|
├── agent.md # 프로젝트 개요 및 기능 정의 (본 문서)
|
||||||
|
├── CONTEXT.md # 상세 개발 가이드 및 로직 설명
|
||||||
├── todo.md # 작업 내역 및 잔여 이슈 관리
|
├── todo.md # 작업 내역 및 잔여 이슈 관리
|
||||||
├── build.sh # 배포/빌드 보조 스크립트
|
|
||||||
├── context/ # 상세 개발 가이드
|
|
||||||
│ ├── core.md # main.js, constants.js, 렌더/성능 상수, worker entrypoint
|
|
||||||
│ ├── arena.md # ArenaScene, camera, minimap, fighter render LOD
|
|
||||||
│ ├── combat.md # 전투 AI, model-only combat, aggregate combat, world effects
|
|
||||||
│ ├── fighter.md # FighterModel, adapter, factory, HUD pool, team-shadow texture
|
|
||||||
│ ├── match-ui.md # 매치 설정, spawn, HUD, kill log, victory UI
|
|
||||||
│ ├── server.md # Fastify, MongoDB, visitor/death/daily metrics/About API
|
|
||||||
│ ├── style.md # CSS 모듈, 디자인 변수, 반응형/애니메이션 규칙
|
|
||||||
│ └── refactor/
|
|
||||||
│ └── arena-scene-modularization-work-order.md
|
|
||||||
├── server/ # Fastify API 서버 및 MongoDB 연결 관리
|
├── server/ # Fastify API 서버 및 MongoDB 연결 관리
|
||||||
│ ├── index.js # Fastify 진입점, Vite dev middleware, 정적 배포 서빙
|
│ ├── index.js # Fastify 서버 진입점, Vite 개발 미들웨어, 정적 배포 서빙
|
||||||
│ ├── config.js # config.json 로드 및 MongoDB URI/컬렉션 설정
|
│ ├── config.js # config.json 로드 및 MongoDB URI 조립
|
||||||
│ ├── db.js # MongoClient 커넥션 풀 생성/재사용/종료
|
│ ├── db.js # MongoClient 커넥션 풀 생성/재사용/종료
|
||||||
│ ├── visitorCookie.js # 방문자 UUID 쿠키 읽기/쓰기/검증
|
│ ├── deathStats.js # 전투 종료 시 오늘 일자별 종족 사망 통계 누적 API
|
||||||
│ ├── visitors.js # 유니크 방문자 체크 및 통계 API
|
│ ├── about.js # About 개발자정보/개인정보처리방침 기본값 시드 및 조회 API
|
||||||
│ ├── dailyMetrics.js # 일일 방문/전투 시작/전투 종료/후원 클릭 지표 API
|
│ └── visitors.js # 유니크 방문자 체크 및 통계 API
|
||||||
│ ├── deathStats.js # 종족별 전투 사망 통계 API
|
├── public/ # 정적 리소스 (게임 에셋)
|
||||||
│ └── about.js # About 개발자정보/개인정보처리방침 seed 및 조회 API
|
|
||||||
├── public/ # 정적 리소스
|
|
||||||
│ └── assets/
|
│ └── assets/
|
||||||
│ ├── og-image.png # 공유 미리보기 이미지
|
│ ├── effects/ # 공통 전투/월드 이펙트 스프라이트시트
|
||||||
│ ├── effects/
|
|
||||||
│ │ ├── heal/ # 처치 회복 연출
|
│ │ ├── heal/ # 처치 회복 연출
|
||||||
│ │ ├── world_Effect.png
|
│ │ ├── world_Effect.png # 화염 메테오 7프레임 이미지
|
||||||
│ │ └── world_Effect_2.png
|
│ │ └── world_Effect_2.png # 냉기 메테오 7프레임 이미지
|
||||||
│ └── characters/ # 20종 이상 캐릭터 스킨/투사체/마법 이펙트 에셋
|
│ └── characters/ # 20종 이상의 캐릭터 스킨 및 투사체 에셋
|
||||||
│ ├── archer/
|
│ ├── archer/, armored-axeman/, armored-orc/, ... (중략)
|
||||||
│ ├── armored-axeman/
|
│ └── wizard/ # 각 폴더 내 애니메이션 시트 및 이펙트 포함
|
||||||
│ ├── armored-orc/
|
└── src/ # 소스 코드 root
|
||||||
│ ├── priest/
|
├── main.js # Phaser 게임 인스턴스 생성, 옵션 drawer/재시작/일시정지 UI 제어
|
||||||
│ ├── wizard/
|
├── constants.js # 전역 물리/UI 상수 통합 관리 (공격력, 체력, 줌, 카메라 속도 등)
|
||||||
│ └── ... # knight, orc, skeleton, slime, wolf, bear 계열 등
|
├── styles.css # UI 스타일링 (인트로, 옵션 drawer, 좌측 HUD 레일, 좌측 하단 킬로그, 상단 전투 안내바)
|
||||||
└── src/ # 프론트엔드 소스 root
|
├── game/ # 게임 로직 모듈 (역할별 하위 폴더 구성)
|
||||||
├── main.js # Phaser game config, 앱 상태, 옵션 drawer, 방문자 추적
|
│ ├── arena/ # 아레나 및 씬 관리
|
||||||
├── constants.js # 렌더/전장/전투/카메라/성능/월드 이펙트 상수
|
│ │ ├── ArenaScene.js # 메인 게임 씬 (Orchestrator, 생명주기 및 모듈 조율)
|
||||||
├── styles.css # CSS 모듈 통합 엔트리
|
│ │ ├── arenaRenderer.js# 경기장 바닥, 격자 및 팀 시작 영역 렌더링
|
||||||
├── styles/
|
│ │ └── arenaSpectatorCamera.js # 지능형 관전 카메라 및 줌 로직
|
||||||
│ ├── base.css # 전역 변수, reset, 기본 레이아웃
|
│ ├── combat/ # 전투 시스템
|
||||||
│ ├── intro.css # 대기 화면 및 프리뷰 스타일
|
│ │ ├── combat.js # 전투 AI, 투사체 및 피격 판정 핵심 엔진
|
||||||
│ ├── game-ui.css # scoreboard, kill log, battle notice, victory layer
|
│ │ ├── combatSettings.js # 전투 속도 및 이동 배율 관리
|
||||||
│ ├── overlay.css # option drawer, About dialog, form controls
|
│ │ ├── arenaFinalCombatEffects.js # 최종 교전 슬로우 모션 등 연출 효과
|
||||||
│ ├── animations.css # 공통 keyframes/animation utilities
|
│ │ └── worldEffects.js # 주기적 메테오/냉각지대 및 냉기 동결 효과
|
||||||
│ └── mobile.css # 960px 이하 반응형 override
|
│ ├── fighter/ # 캐릭터 및 에셋
|
||||||
├── game/
|
│ │ ├── fighterAssets.js # 스프라이트 로드 및 팀 실루엣 동적 생성
|
||||||
│ ├── arena/
|
│ │ ├── fighterFactory.js # 캐릭터 인스턴스화 및 HUD 동기화
|
||||||
│ │ ├── ArenaScene.js # 메인 Phaser Scene orchestrator
|
│ │ ├── fighterManifest.js # 20종 캐릭터 스탯/특성 상세 정의
|
||||||
│ │ ├── arenaRenderer.js # 전장 바닥, grid, starting zone 렌더링
|
│ │ ├── fighterStats.js # 근접/원거리/마법 프로필 판별 및 스탯 해석
|
||||||
│ │ ├── arenaSpectatorCamera.js # 자동/수동 카메라 포커싱
|
│ │ └── fighterSelection.js # 캐릭터 스킨 무작위 선택 로직
|
||||||
│ │ └── fighterLodWorker.js # 대규모 전투 detailed sprite 후보 worker
|
├── match/ # 매치 및 진행
|
||||||
│ ├── combat/
|
│ ├── matchSetup.js # 팀 구성(닉네임 배수 파싱 포함) 및 스폰 좌표 계산 (스타팅 영역/랜덤)
|
||||||
│ │ ├── combat.js # model 기반 전투 AI, 타깃, 피해, 처치 처리
|
│ └── arenaMatchRuntime.js # 매치 진행 중 헬퍼 (스폰 클러스터, 팀 크기 동기화)
|
||||||
│ │ ├── aggregateCombatWorker.js# detached/offscreen 집계 전투 worker
|
...
|
||||||
│ │ ├── combatSettings.js # 전투 속도 및 이동 배율 설정
|
## 7. 주요 기능 상세 (New)
|
||||||
│ │ ├── arenaFinalCombatEffects.js
|
|
||||||
│ │ └── worldEffects.js # 밀집 구역 메테오/냉기/감속/동결 효과
|
### 7.1 닉네임 배수 시스템 (Multi-Spawn)
|
||||||
│ ├── fighter/
|
- 사용자가 닉네임 뒤에 `*N` (예: `홍길동*2`)을 입력하면 해당 팀은 기본 팀 인원의 N배만큼 생성됩니다.
|
||||||
│ │ ├── fighterModel.js # 순수 JS fighter 상태 모델
|
- 스타팅 존 모드에서 배수만큼의 독립된 스폰 지점이 할당되어 전략적인 분산 배치가 이루어집니다.
|
||||||
│ │ ├── fighterAdapter.js # Phaser Sprite/Physics 접근 경계
|
- 닉네임 표시 시 `*N` 접미사는 자동으로 제거되어 깔끔한 UI를 유지합니다.
|
||||||
│ │ ├── fighterAssets.js # sprite load, team-shadow texture/animation 생성
|
|
||||||
│ │ ├── fighterFactory.js # Sprite 생성, model bridge, HUD pool, detail visibility
|
### 7.2 서든 데스 (Sudden Death) 시스템
|
||||||
│ │ ├── fighterManifest.js # 캐릭터 스탯/종족/특성 정의
|
- 매치 시작 후 일정 시간(기본 8초)이 경과하면 전장의 환경이 극도로 위험해지는 서든 데스 상태에 진입합니다.
|
||||||
│ │ ├── fighterStats.js # melee/ranged/magic 프로필 해석
|
- 메테오 생성 주기가 비약적으로 단축(기본 1초)되며, 빙결 효과를 가진 냉기 메테오가 집중 투하됩니다.
|
||||||
│ │ └── fighterSelection.js # 캐릭터 선택/셔플 로직
|
- `constants.js`를 통해 활성화 여부, 시작 시간, 주기 등을 간편하게 조정할 수 있습니다.
|
||||||
│ └── match/
|
|
||||||
│ ├── matchSetup.js # `닉네임*N` 파싱, 팀 구성, spawn 좌표 계산
|
### 7.3 밀집 구역 기반 월드 이펙트 포격
|
||||||
│ └── arenaMatchRuntime.js # match 진행 중 helper
|
- 월드 이펙트는 랜덤 생존자 대신 `WORLD_EFFECT.AREA_TILES` 크기 범위 중 현재 생존 캐릭터가 가장 많이 모인 위치를 표적으로 선택합니다.
|
||||||
└── ui/
|
- 선택 범위를 먼저 경고로 표시한 뒤, 그 내부에 작은 화염 또는 냉기 메테오를 3~4발 분산 투하합니다.
|
||||||
├── matchForm.js # 설정 폼 및 localStorage 유지
|
- 피해, 기절, 냉각 감속은 큰 경고 범위 전체가 아니라 각각의 작은 탄착 영역에만 적용됩니다.
|
||||||
├── aboutDialog.js # About dialog 및 Markdown 표시
|
|
||||||
├── visitorCounter.js # 방문자 API 호출/표시
|
└── ui/ # UI 컴포넌트 및 API 연동
|
||||||
├── dailyMetrics.js # 일일 지표 API 호출
|
├── arenaKillLog.js # [New] 독립된 킬로그 DOM 조작 모듈
|
||||||
├── deathStats.js # 사망 통계 API 호출
|
├── arenaScoreboard.js # [New] 팀 스코어 badge 업데이트 모듈
|
||||||
├── arenaScoreboard.js # 팀 badge 및 선택 상태
|
├── battleDeathNotice.js # [New] 상단 사망 공지 메시지 및 UI 관리
|
||||||
├── arenaKillLog.js # kill log DOM
|
├── victoryCelebration.js # [New] 승리 축하 연출 (DOM/Audio) 모듈
|
||||||
├── battleDeathNotice.js# 상단 사망/통계 안내
|
├── matchForm.js # 설정 폼 제어 및 localStorage 유지
|
||||||
└── victoryCelebration.js
|
├── aboutDialog.js # About 다이얼로그, 개발자정보/개인정보처리방침 표시
|
||||||
|
├── deathStats.js # 사망 통계 API 호출 래퍼
|
||||||
|
└── visitorCounter.js # 방문자 체크 API 호출 및 표시
|
||||||
```
|
```
|
||||||
|
|
||||||
로컬/생성 파일인 `config.json`, `node_modules/`, `dist/`, `.vite/`, `package-lock.json`, `*.log`는 `.gitignore` 대상입니다.
|
## 3. 상세 기술 가이드 (Context Routing)
|
||||||
|
|
||||||
## 4. 상세 기술 가이드 (Context Routing)
|
토큰 절약 및 효율적인 정보 조회를 위해 상세 로직은 기능별로 분리되어 보관됩니다. 특정 모듈 작업 시 아래의 관련 문서를 먼저 읽으십시오.
|
||||||
|
|
||||||
토큰 절약 및 효율적인 정보 조회를 위해 상세 로직은 기능별 문서로 분리되어 있습니다. 특정 모듈 작업 시 아래 문서를 먼저 읽으십시오.
|
- **[인프라 및 전역 설정] [context/core.md](./context/core.md)**: `main.js`, `constants.js`, 개발/유지보수 공통 규칙.
|
||||||
|
- **[서버 및 API] [context/server.md](./context/server.md)**: Fastify 서버, MongoDB 연동, 방문자 및 사망 통계 API 상세.
|
||||||
|
- **[아레나 및 카메라] [context/arena.md](./context/arena.md)**: `ArenaScene` 오케스트레이션, 지능형 카메라 추적, 미니맵 가이드라인.
|
||||||
|
- **[전투 엔진] [context/combat.md](./context/combat.md)**: 전투 AI, 엘리트 피해 판정, 비활성화된 처치 보너스 경로, 슬로우모션 및 월드 이펙트 연출.
|
||||||
|
- **[캐릭터 및 에셋] [context/fighter.md](./context/fighter.md)**: 캐릭터 공장, 동적 실루엣 생성, 종족 및 특성(Slime 등) 정의.
|
||||||
|
- **[매치 로직 및 UI] [context/match-ui.md](./context/match-ui.md)**: 팀 구성 및 스폰 알고리즘, HUD 레이아웃, 킬로그, 승리 연출 UI.
|
||||||
|
- **[스타일 및 디자인] [context/style.md](./context/style.md)**: CSS 모듈 구조, 디자인 변수, 반응형 및 애니메이션 가이드.
|
||||||
|
|
||||||
- **[인프라 및 전역 설정](./context/core.md)**: `main.js`, `constants.js`, 렌더 크기, `PERFORMANCE`, worker entrypoint, 공통 유지보수 규칙.
|
## 4. 기술 사양
|
||||||
- **[아레나 및 카메라](./context/arena.md)**: `ArenaScene`, rolling-window LOD, `fighterLodWorker.js`, minimap, spectator/manual camera.
|
|
||||||
- **[전투 엔진](./context/combat.md)**: `combat.js`, model-only combat fallback, target spatial index, `aggregateCombatWorker.js`, world effects.
|
|
||||||
- **[캐릭터 및 에셋](./context/fighter.md)**: `FighterModel`, `fighterAdapter.js`, sprite attach/detach, HUD pool, team-shadow texture.
|
|
||||||
- **[매치 로직 및 UI](./context/match-ui.md)**: `닉네임*N` 팀 인원, spawn zone, scoreboard, kill log, victory UI, 모바일 레이아웃.
|
|
||||||
- **[서버 및 API](./context/server.md)**: Fastify, MongoDB, visitor cookie, daily metrics, death stats, About 콘텐츠.
|
|
||||||
- **[스타일 및 디자인](./context/style.md)**: CSS 모듈 구조, 디자인 변수, 반응형 및 애니메이션 가이드.
|
|
||||||
|
|
||||||
## 5. 주요 기능 상세
|
|
||||||
|
|
||||||
### 5.1 매치 입력과 스폰
|
|
||||||
|
|
||||||
- live match 참가자는 `닉네임*N` 형식으로 팀별 배정 인원을 직접 지정합니다. 접미사가 없으면 1명입니다.
|
|
||||||
- `SPAWN.MAX_FIGHTER_COUNT`는 참가자 입력으로 배정되는 fighter 수의 상한입니다. Slime의 `spawnMultiplier`, `splitOnDeath` 같은 특성 기반 추가 생성은 이 입력 상한에 포함하지 않습니다.
|
|
||||||
- starting-zone placement는 `SPAWN.FIGHTERS_PER_STARTING_ZONE`마다 팀 영역을 추가로 배정해 대규모 팀이 한 점에 뭉치지 않도록 분산합니다.
|
|
||||||
- match-start validation은 요청 인원과 허용 인원을 분리해 사용자에게 경고 카드로 보여줍니다.
|
|
||||||
|
|
||||||
### 5.2 대규모 전투 흐름
|
|
||||||
|
|
||||||
- match 시작 시 `ArenaScene`은 live fighter 수가 threshold 이상인지 판단하고 large-battle 모드로 들어갑니다.
|
|
||||||
- 첫 화면은 full-arena overview가 아니라 living fighter 평균 위치에 가까운 fighter 주변으로 zoom합니다.
|
|
||||||
- 최초 LOD sync 후 `fighterLodWorker.js` 또는 동기 resolver가 현재 카메라 상태에 맞는 detailed set을 계산합니다.
|
|
||||||
- full overview는 대표 Sprite와 dot field를 유지합니다. focused view는 rolling window 안의 모든 생존자를 detail Sprite로 복구합니다.
|
|
||||||
- offscreen/detached model은 집계 squad combat으로 이동/피해/사망을 처리하고, 카메라에 다시 들어오면 model position에서 Sprite를 재attach합니다.
|
|
||||||
|
|
||||||
### 5.3 모델/렌더 생명주기
|
|
||||||
|
|
||||||
- `createFighter()`는 항상 실제 Phaser Sprite를 만들고 `FighterModel`을 붙입니다. 과거 lazy `SpriteProxy` 실험은 rollback되었습니다.
|
|
||||||
- `attachSprite: false`는 Sprite 생성을 건너뛰는 뜻이 아니라, 생성 직후 `setFighterDetailVisible(false)`로 parking한다는 뜻입니다.
|
|
||||||
- parking된 fighter는 render/update/physics traversal에서 빠지지만 model state는 계속 살아 있습니다.
|
|
||||||
- model-only death는 model을 inactive/unregister 처리하고 parked fighter entry를 제거합니다.
|
|
||||||
- animation helper는 실제 renderable fighter가 없으면 action key resolution/playback을 건너뜁니다.
|
|
||||||
|
|
||||||
### 5.4 전투, 효과, 월드 이벤트
|
|
||||||
|
|
||||||
- `updateFighterModel()`은 Sprite가 있으면 기존 Arcade/animation path를 사용하고, Sprite가 없으면 model 좌표/HP/쿨다운 기반으로 이동과 공격을 진행합니다.
|
|
||||||
- ranged/magic 공격은 양쪽 Sprite가 모두 있으면 visual projectile/spell path를 사용합니다. detached 참여자가 있으면 같은 windup/travel/hit delay를 model hit로 해석합니다.
|
|
||||||
- kill reward, split-on-death, death stats, scoreboard, match finish는 Sprite 유무와 무관하게 기존 authoritative path를 사용합니다.
|
|
||||||
- dense-area meteor barrage는 큰 경고 범위를 먼저 표시한 뒤 내부 소형 strike에만 피해/동결/감속을 적용합니다.
|
|
||||||
- sudden death는 설정 시간 이후 meteor 주기를 단축하고 필요 시 frost meteor를 강제해 장기전을 방지합니다.
|
|
||||||
|
|
||||||
### 5.5 카메라와 HUD
|
|
||||||
|
|
||||||
- `transitionMainCameraTo()`는 수동 focus 이동에 Phaser `pan()`/`zoomTo()`를 적용합니다.
|
|
||||||
- selected fighter auto-centering은 수동 tween 중에는 기다려 tween을 취소하지 않습니다.
|
|
||||||
- scoreboard에서 선택된 팀을 다시 클릭하면 selection/focus/meteor focus를 정리하고 full-arena view로 돌아갑니다.
|
|
||||||
- minimap은 field camera와 분리된 HUD camera로 고정 표시하며, main camera viewport rectangle과 team-colored dot을 그립니다.
|
|
||||||
- fighter HUD는 pool 기반입니다. selected/zoom-visible 후보만 health bar를 빌려 쓰고, hidden LOD fighter는 HUD slot과 pointer input을 해제합니다.
|
|
||||||
|
|
||||||
### 5.6 서버/API와 지표
|
|
||||||
|
|
||||||
- 방문자 체크는 `arena_visitor_id` HttpOnly 쿠키와 MongoDB `visitors` 컬렉션을 사용합니다.
|
|
||||||
- daily metrics는 앱 방문, 실제 전투 시작, 실제 전투 종료, 후원 클릭 예약 지표를 날짜별 합산 문서로 저장합니다.
|
|
||||||
- death stats는 프리뷰가 아닌 실제 전투 종료 시 종족별 사망 수를 오늘 일자 문서에 누적합니다.
|
|
||||||
- About 콘텐츠는 DB의 Markdown을 실시간 조회하며 서버 메모리 캐시를 두지 않습니다.
|
|
||||||
|
|
||||||
## 6. 기술 사양 및 튜닝 포인트
|
|
||||||
|
|
||||||
- **Framework**: Phaser 3.90.0 (Arcade Physics 기반)
|
- **Framework**: Phaser 3.90.0 (Arcade Physics 기반)
|
||||||
- **Build Tool**: Vite 7.1.12
|
- **Build Tool**: Vite 7.1.12
|
||||||
- **Server**: Fastify 5.x (`@fastify/static`, `@fastify/middie`)
|
- **Server**: Fastify 5.x (`@fastify/static`, `@fastify/middie`)
|
||||||
- **Database**: MongoDB 7.x Node Driver
|
- **Database**: MongoDB 7.x Node Driver
|
||||||
- **UI Logic**: Vanilla JS & CSS
|
- **UI Logic**: Vanilla JS & CSS (Flexbox/Grid 활용)
|
||||||
- **Render**: `RENDER.WIDTH/HEIGHT = 1280`, `ARENA.SIZE = 3200`, `CAMERA.MIN_ZOOM = RENDER_SIZE / ARENA_SIZE`
|
|
||||||
- **Large Battle**: `PERFORMANCE.LARGE_BATTLE_*` 상수에서 threshold, simulation buckets, aggregate refresh/cell/squad/death cap, target index refresh, HUD limit, Sprite budget, rolling window, dot redraw를 조정합니다.
|
|
||||||
- **World Effect**: `WORLD_EFFECT.*`에서 첫/반복 포격, 밀집 경고 범위, 소형 strike 범위/개수/간격/시각 배율, meteor shake, fire/frost damage, frost stun/slow를 조정합니다.
|
|
||||||
- **Camera**: `CAMERA.LARGE_BATTLE_START_ZOOM`, `CAMERA.MANUAL_FOCUS_TWEEN_MS`, `CAMERA.MANUAL_FOCUS_TWEEN_EASE`, meteor focus, spectator thresholds를 조정합니다.
|
|
||||||
- **Fighter**: `FIGHTER.DEAD_DESPAWN_DELAY_MS`, `FIGHTER.DEAD_DESPAWN_ALPHA`, `FIGHTER_TYPE_STATS`, kill growth 상수를 조정합니다.
|
|
||||||
- **Worker fallback**: LOD/aggregate worker는 성능 최적화 경로이며, 실패 시 main-thread 동기 path가 계속 동작해야 합니다.
|
|
||||||
|
|
||||||
## 7. 서버/API 설정
|
## 5. 서버/API 설정
|
||||||
|
|
||||||
- 개발 서버는 `npm run dev`, 운영 서버는 `npm start`, 정적 빌드는 `npm run build`로 실행합니다.
|
- 개발/운영 서버는 `npm run dev` 또는 `npm start`로 실행하며 기본 포트는 `config.json`의 `SERVER_PORT` 값인 `9736`입니다.
|
||||||
- 기본 포트는 `config.json`의 `SERVER_PORT` 값이며 샘플은 `9736`입니다.
|
|
||||||
- `config.json`은 로컬 설정 파일이므로 저장소에 커밋하지 않습니다. 새 환경에서는 `config.json.sample`을 복사해 사용합니다.
|
- `config.json`은 로컬 설정 파일이므로 저장소에 커밋하지 않습니다. 새 환경에서는 `config.json.sample`을 복사해 사용합니다.
|
||||||
|
- 기본 API:
|
||||||
|
- `GET /api/health`: 서버 및 MongoDB 설정 여부 확인.
|
||||||
|
- `POST /api/visitors/check`: 현재 브라우저 방문자를 체크하고 유니크 방문자 수를 반환.
|
||||||
|
- `GET /api/visitors/stats`: 전체 유니크 방문자 수 조회.
|
||||||
|
- `GET /api/about`: 데이터베이스에서 실시간으로 개발자정보와 개인정보처리방침 Markdown 조회 (캐시 없이 즉시 반영).
|
||||||
|
- `GET /api/death-stats/today`: 오늘의 종족별 전투 사망 통계 조회.
|
||||||
|
- `POST /api/death-stats/today`: 종료된 전투의 종족별 사망 수를 오늘 집계에 누적.
|
||||||
|
|
||||||
기본 API:
|
## 6. 관련 문서
|
||||||
|
|
||||||
- `GET /api/health`: 서버 및 MongoDB 설정 여부 확인.
|
- [CONTEXT.md](./CONTEXT.md): 상세 개발 가이드 및 핵심 로직 설명 (필독)
|
||||||
- `POST /api/visitors/check`: 방문자 UUID 쿠키 확인/발급 및 유니크 방문자 수 반환.
|
- [todo.md](./todo.md): 작업 내역 및 잔여 이슈 관리
|
||||||
- `GET /api/visitors/stats`: 전체 유니크 방문자 수 조회.
|
|
||||||
- `GET /api/daily-metrics/today`: 오늘의 운영 지표 조회.
|
|
||||||
- `POST /api/daily-metrics/match-started`: 실제 전투 시작 수 누적.
|
|
||||||
- `POST /api/daily-metrics/match-finished`: 실제 전투 종료 수 누적.
|
|
||||||
- `POST /api/daily-metrics/donation-clicked`: 후원 클릭 수 누적용 예약 API.
|
|
||||||
- `GET /api/death-stats/today`: 오늘의 종족별 전투 사망 통계 조회.
|
|
||||||
- `POST /api/death-stats/today`: 종료된 실제 전투의 종족별 사망 수 누적.
|
|
||||||
- `GET /api/about`: 개발자정보와 개인정보처리방침 Markdown 조회.
|
|
||||||
|
|
||||||
## 8. 유지보수 규칙
|
|
||||||
|
|
||||||
- **문서 동기화**: 구조, 상수, API, 대규모 전투 path가 바뀌면 `agent.md`와 관련 `context/*.md`를 함께 갱신합니다.
|
|
||||||
- **모듈 경계**: `ArenaScene`은 orchestration을 맡고, fighter 상태/렌더 세부는 `fighter/`, 전투 판정은 `combat/`, match input/spawn은 `match/`, DOM UI는 `ui/`로 분리합니다.
|
|
||||||
- **Fighter 접근**: 새 코드가 fighter body, animation, tint, velocity, position을 직접 다뤄야 한다면 먼저 `fighterAdapter.js`에 적절한 helper가 있는지 확인합니다.
|
|
||||||
- **Model-first 안전성**: `fighterForModelId()`는 null을 반환할 수 있습니다. model-only 전투, stale id, death/unregister 이후 상태를 항상 고려합니다.
|
|
||||||
- **대규모 전투 성능**: 8,000명급 경로에서는 전체 fighter 배열을 매 프레임 스캔하거나 DOM/HUD/Graphics를 전원 갱신하지 않습니다. throttle, pool, worker, spatial index, set diff를 우선 사용합니다.
|
|
||||||
- **Phaser lifecycle**: parked Sprite는 display/update list와 Arcade World에서 모두 빠져야 하며, reattach 시 model 좌표와 body를 동기화합니다.
|
|
||||||
- **이펙트 lifecycle**: pooled combat object는 `releaseToPool`/`disposeCombatObject()` 경로로 정리합니다. 새 이펙트도 match reset과 scene cleanup에서 누수되지 않아야 합니다.
|
|
||||||
- **API 변경**: `/api/*` 경로는 Fastify route가 담당합니다. 개발 모드에서 Vite SPA fallback이 API 요청을 가로채지 않게 유지합니다.
|
|
||||||
- **신규 캐릭터**: `public/assets/characters/`에 에셋을 배치하고 `fighterManifest.js`에 `species`와 combat/stat 정의를 추가합니다. 사망 통계 종족은 `human`, `orc`, `skeleton`, `slime`, `wolf`, `bear` 중 하나를 사용합니다.
|
|
||||||
- **스타일 변경**: `src/styles.css`는 모듈 import 엔트리입니다. 실제 수정은 `src/styles/*.css`의 해당 영역에서 진행합니다.
|
|
||||||
|
|
||||||
## 9. 관련 문서
|
|
||||||
|
|
||||||
- [context/core.md](./context/core.md): 전역 설정, 성능 상수, 렌더/worker 가이드.
|
|
||||||
- [context/arena.md](./context/arena.md): 아레나 씬, 카메라, minimap, render LOD.
|
|
||||||
- [context/combat.md](./context/combat.md): 전투 AI, 집계 전투, projectile/world effect.
|
|
||||||
- [context/fighter.md](./context/fighter.md): FighterModel, adapter, factory, assets.
|
|
||||||
- [context/match-ui.md](./context/match-ui.md): 매치 입력, spawn, HUD, 모바일 UI.
|
|
||||||
- [context/server.md](./context/server.md): Fastify/MongoDB API.
|
|
||||||
- [context/style.md](./context/style.md): CSS 모듈 및 디자인 규칙.
|
|
||||||
- [todo.md](./todo.md): 작업 내역 및 잔여 이슈.
|
|
||||||
|
|
|
||||||
141
context/arena.md
141
context/arena.md
|
|
@ -1,139 +1,24 @@
|
||||||
# Update: Fighter LOD Worker
|
# Update: Special Effect Camera Focus
|
||||||
|
|
||||||
- `ArenaScene` now starts a dedicated `fighterLodWorker.js` job for recurring large-battle LOD candidate selection after the initial forced LOD sync.
|
- `ArenaScene` preloads and creates special-effect animations beside the existing fighter and world-effect assets, then starts `startSpecialEffects()` for live matches only.
|
||||||
- The worker returns detailed fighter worker ids for either full-arena representatives or all living fighters inside the focused rolling window.
|
- 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.
|
||||||
- `ArenaScene` maps those ids back to current fighter sprites and then reuses `applyFighterLodDetailedSet()` for the actual Phaser attach/detach work.
|
- Special focus temporarily takes priority over selected-fighter, spectator, and meteor focus so the Hurt-frame pose and projectile launch are visible.
|
||||||
- Worker errors disable the async path and keep the synchronous LOD resolver as a fallback.
|
- 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: Full Rolling-Window Detail Sprites
|
# Update: Elite-Weighted Scene Counts
|
||||||
|
|
||||||
- Zoomed, selected, and spectator large-battle views now promote every living fighter inside the rolling camera window to a detailed Phaser sprite.
|
- `ArenaScene.recordDeath()` records an elite death as its represented `stackCount`, keeping persisted species death totals aligned with the displayed army size.
|
||||||
- Full-arena overview remains bounded by the representative sprite budget, so the all-map 8,000-fighter view still stays lightweight.
|
- `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.
|
||||||
- `addCameraFighterDetails()` no longer receives a detail cap; it adds exact viewport candidates first, then all remaining rolling-window candidates.
|
- Minimap dots remain physical fighter markers; an elite is visible as its single large battlefield representative while numeric population remains in the scoreboard.
|
||||||
- Fighters outside the rolling window stay detached and continue to render as LOD dots.
|
|
||||||
|
|
||||||
# Update: Async Aggregate Result Safety
|
|
||||||
|
|
||||||
- Worker aggregate results are applied only to models that remain detached from `fighterByModelId`; if the camera has promoted a fighter to detailed sprite while a worker job is in flight, that result is ignored for the promoted model.
|
|
||||||
- Match id checks discard stale worker results after match reset, keeping async aggregate ticks from mutating a new match.
|
|
||||||
|
|
||||||
# Update: LOD Diff And Dot Frustum Culling
|
|
||||||
|
|
||||||
- Rolling-window LOD now does a one-time full sprite visibility sync when large-battle LOD first activates, then applies only the delta between the previous and next detailed fighter sets on later refreshes.
|
|
||||||
- Destroyed fighters are removed from `fighterLodDetailedSet`, and stale no-scene references are skipped during the diff pass before Phaser input/body APIs are touched.
|
|
||||||
- Hidden-fighter dot redraws still use model positions, but skip fighters outside the main camera world view plus `PERFORMANCE.LARGE_BATTLE_SPRITE_VIEW_PADDING`, avoiding offscreen `Graphics.fillRect()` calls during zoomed views.
|
|
||||||
- Full-arena overview still draws the arena-wide dot field because the main camera view covers the full battlefield at `CAMERA.MIN_ZOOM`.
|
|
||||||
|
|
||||||
# Update: Squad Materialization Bridge
|
|
||||||
|
|
||||||
- The arena still keeps individual fighter models for rendering, selection, minimap dots, and deterministic re-entry, but offscreen movement/combat is now driven by squad centers.
|
|
||||||
- Aggregate ticks reslot squad members around their squad center, allowing rolling-window LOD to reattach sprites from plausible positions when the camera approaches.
|
|
||||||
- Visible attached fighters remain the high-fidelity path; offscreen detached fighters no longer consume individual AI buckets while squad aggregation is active.
|
|
||||||
|
|
||||||
# Update: Aggregate Detached Simulation Path
|
|
||||||
|
|
||||||
- During large live battles, `ArenaScene.updateFighterModels()` now calls `updateAggregateDetachedCombat()` before per-model updates.
|
|
||||||
- If aggregate combat is active, only attached/detail fighters continue through full `updateFighterModel()` every frame; detached fighters are skipped by the individual simulation buckets.
|
|
||||||
- This keeps the rolling-window camera area high-fidelity while offscreen fighters continue moving, taking damage, dying, splitting, and changing match outcome through model data.
|
|
||||||
- If aggregate combat finishes the match during a batch, `updateFighterModels()` exits immediately so no stale attached updates run after `finishMatch()`.
|
|
||||||
|
|
||||||
# Update: Large Battle Simulation Throttle
|
|
||||||
|
|
||||||
- `ArenaScene.updateFighterModels()` now keeps attached/detailed render models on every-frame updates, but distributes detached model-only fighters across simulation buckets during large live matches.
|
|
||||||
- `PERFORMANCE.LARGE_BATTLE_SIMULATION_BUCKETS` controls the bucket count and `LARGE_BATTLE_SIMULATION_MAX_DELTA_MS` caps the accumulated delta passed to a skipped detached model.
|
|
||||||
- The detailed sprite cap was reduced aggressively for 8,000-fighter battles, and rolling-window detail budgeting now accepts ratios below `1` so dense zoomed views do not promote hundreds-to-thousands of sprites at once.
|
|
||||||
- Large-battle fighter HUD health bars use `PERFORMANCE.LARGE_BATTLE_HUD_VISIBLE_LIMIT` instead of the normal HUD limit.
|
|
||||||
|
|
||||||
# Update: Fighter Sprite Render Recovery
|
|
||||||
|
|
||||||
- `startMatch()` still passes `attachSprite: false` for large live matches, but `createFighter()` now creates a real Phaser Sprite and immediately parks it instead of returning a pure proxy.
|
|
||||||
- This restores visible rendering for normal matches and keeps rolling-window LOD's display/update-list detach path for large battles.
|
|
||||||
- `spawnSplitFighters()` follows the same rule during active large-battle LOD: split children are registered with models and can be parked until LOD promotion.
|
|
||||||
- Input events now work directly with Phaser sprites again; `_fighterProxy` fallback remains harmless for any future proxy experiment.
|
|
||||||
|
|
||||||
# Update: Render Sprite Detach In Rolling LOD
|
|
||||||
|
|
||||||
- `applyFighterLodDetailedSet()` treats the detailed set as the list of fighter sprites that should be attached to Phaser for this camera window.
|
|
||||||
- Non-detailed living fighters call `setFighterDetailVisible(false)`, which parks the sprite outside the display/update lists and removes its `fighterByModelId` mapping while keeping the model registered.
|
|
||||||
- Detailed fighters are reattached with `ensureFighterSpriteAttached()` / `setFighterSpriteAttached()`, so team-button selection can force a detached sprite back before the camera transition and HUD sync.
|
|
||||||
- `removeDetachedFighterProxyForModel()` still removes parked fighter entries after model-only death so dead detached entries do not remain in large-battle scan arrays.
|
|
||||||
- LOD candidate collection and minimap dots intentionally scan `this.fighters` instead of `combatTargetIndex.livingFighters`, because the combat index's sprite list may contain only currently attached render sprites.
|
|
||||||
|
|
||||||
# Update: Fighter Model Indexes
|
|
||||||
|
|
||||||
- `ArenaScene` now keeps `fighterModels`, `fighterByModelId`, and `fighterModelById` in sync with the sprite list.
|
|
||||||
- New fighters are registered when a match starts or split-on-death children spawn; despawned or model-only dead fighters are unregistered and their models are marked inactive.
|
|
||||||
- `fighterModelForId()` covers all living models, while `fighterForModelId()` now returns only currently attached render sprites.
|
|
||||||
- `unregisterFighterModel()` supports model-only cleanup paths that do not have an attached Phaser sprite.
|
|
||||||
|
|
||||||
# Update: FighterModel Use In Arena LOD
|
|
||||||
|
|
||||||
- Split-on-death spawn origins now use `fighterModelPoint(source)` so dormant parents spawn children from their simulation position.
|
|
||||||
- Rolling-window LOD candidate collection, dot drawing, and minimap fighter dots now read model `x/y` through `fighterModelPoint()` instead of direct sprite coordinates.
|
|
||||||
- This keeps the large-battle camera/render UI aligned with the simulation model while offscreen render sprites are detached.
|
|
||||||
|
|
||||||
# Update: Fighter Adapter Use In Arena
|
|
||||||
|
|
||||||
- `ArenaScene.finishMatch()` stops fighters through `fighterAdapter.stopFighterMovement()` instead of directly touching Arcade bodies.
|
|
||||||
- `arenaSpectatorCamera.js` uses `fighterWorldPoint()` and `fighterDistanceSquared()` so spectator targets, observed combat centers, and closest-pair lookup remain correct when rolling-window LOD has disabled offscreen fighter bodies.
|
|
||||||
- Camera/focus code should continue using `fighterCameraPoint()` or adapter position helpers instead of reading `fighter.body.center` directly.
|
|
||||||
|
|
||||||
# Update: Rolling Window Fighter LOD
|
|
||||||
|
|
||||||
- `collectCameraFighterDetails()` now builds two candidate lists from a camera-centered rolling window: exact viewport candidates and rolling-window candidates.
|
|
||||||
- The rolling window is larger than the visible camera view, using `PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_SCALE` plus `LARGE_BATTLE_SPRITE_VIEW_PADDING` as a minimum expansion.
|
|
||||||
- Nearby soon-to-enter fighters remain detailed sprites instead of dots because the focused-camera path now consumes the full rolling-window candidate list.
|
|
||||||
- `addCameraFighterDetails()` still fills exact viewport candidates first, then rolling-window candidates, preserving visible fidelity during camera movement.
|
|
||||||
- Fighters outside the detailed set become dormant through `setFighterDetailVisible(false)`, reducing animation/body work while keeping combat simulation active.
|
|
||||||
|
|
||||||
# Update: Manual Camera Pan/Zoom Tween
|
|
||||||
|
|
||||||
- `ArenaScene.transitionMainCameraTo()` wraps Phaser camera `pan()` and `zoomTo()` for short manual focus transitions.
|
|
||||||
- `selectFighter()` uses the transition helper for scoreboard/team/fighter focus instead of an instant `setZoom()` plus `centerOn()`.
|
|
||||||
- `returnToFullArenaView()` uses the same helper to move back to arena center at `CAMERA.MIN_ZOOM`.
|
|
||||||
- `focusSelectedFighter()` skips immediate recentering while the camera pan/zoom effect is active, preventing the selected-fighter follow path from cancelling the transition.
|
|
||||||
|
|
||||||
# Update: Large Battle Start Camera
|
|
||||||
|
|
||||||
- `startMatch()` now calls `focusLargeBattleStartCamera()` after creating live fighters and before the initial LOD sync.
|
|
||||||
- Large live matches start at `CAMERA.LARGE_BATTLE_START_ZOOM` centered on the living fighter nearest to the living population average, so the first view is a readable local battle view instead of the full minimap-like arena.
|
|
||||||
- The start camera does not mark a fighter selected; scoreboard team toggle and manual team selection keep their existing behavior.
|
|
||||||
|
|
||||||
# Update: Team Button Toggle To Full Arena
|
|
||||||
|
|
||||||
- `selectRandomTeamFighter()` treats a scoreboard click on the already selected team as a toggle-off action instead of choosing another random fighter from that team.
|
|
||||||
- `returnToFullArenaView()` clears selection/focus state, sets `CAMERA.MIN_ZOOM`, centers on the arena, refreshes the minimap, and updates the scoreboard so the focused team style is removed.
|
|
||||||
|
|
||||||
# Update: Dynamic Zoomed Fighter LOD
|
|
||||||
|
|
||||||
- Zoomed large-battle LOD now separates exact camera-visible fighters from rolling-window fighters.
|
|
||||||
- Focused large-battle LOD promotes the selected fighter plus all living fighters inside the rolling window, reducing the awkward mix of detailed sprites and dots inside the player's current view.
|
|
||||||
- `addCameraFighterDetails()` always consumes exact viewport candidates before rolling-window candidates.
|
|
||||||
- Full-arena `CAMERA.MIN_ZOOM` overview keeps the lower representative budget so the expensive case remains protected.
|
|
||||||
|
|
||||||
# Update: Large Battle Fighter Render LOD
|
|
||||||
|
|
||||||
- `ArenaScene` owns the large-battle fighter render LOD pass through `syncFighterRenderLod()`, `resolveFighterLodDetailedSet()`, and `drawFighterLodDots()`.
|
|
||||||
- The LOD pass activates only for live matches above `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD`; presentation mode and finished matches restore normal fighter visibility.
|
|
||||||
- Full-arena overview keeps a bounded set of representative sprites from each team, while zoomed or selected-camera views keep the selected fighter plus camera-near fighters.
|
|
||||||
- Hidden living fighters are still present in `this.fighters` with model state, but their Phaser sprite is removed from the display/update lists and they are drawn as team-colored dots on a shared `Graphics` object.
|
|
||||||
- HUD candidate selection ignores hidden fighters, and match finish disables LOD before post-match handling.
|
|
||||||
|
|
||||||
# Update: Full-Arena Camera At Lower Render Resolution
|
|
||||||
|
|
||||||
- The Phaser canvas resolution is no longer tied to `ARENA.SIZE`; `CAMERA.MIN_ZOOM` is below `1` so the main camera can still frame the full 3200px arena inside the smaller render canvas.
|
|
||||||
- Existing team click, selected fighter, meteor focus, and final-combat camera zooms remain absolute zoom targets above that full-arena minimum.
|
|
||||||
|
|
||||||
# Update: Minimap Redraw Throttle
|
|
||||||
|
|
||||||
- `ArenaScene.updateMinimap()` accepts a forced refresh flag and otherwise redraws no more often than `PERFORMANCE.MINIMAP_REFRESH_MS`.
|
|
||||||
- Match setup and camera zoom changes force an immediate minimap refresh, while routine scene updates share the throttled path to reduce `Graphics` redraw work in large battles.
|
|
||||||
|
|
||||||
# Update: Graphics Minimap And HUD Candidates
|
# 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.
|
- 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.
|
- 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
|
# Context: Arena & Scene
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,94 +1,59 @@
|
||||||
# Update: Aggregate Combat Worker Path
|
# Update: Special Projectile Trail
|
||||||
|
|
||||||
- Large-battle detached aggregate combat now tries to run through `src/game/combat/aggregateCombatWorker.js` before falling back to the synchronous aggregate path.
|
- `SPECIAL_EFFECT.PROJECTILE.TRAIL` controls optional afterimages for the moving special projectile. Each trail copy uses the projectile's current texture frame, scale, rotation, and flip state.
|
||||||
- The main thread sends Transferable TypedArrays for detached model ids, position, HP, team key, movement speed, DPS, and frost state; Phaser objects and team/skin references stay on the main thread.
|
- Trail objects are visual-only combat objects: they fade out and self-dispose, but they do not participate in hit detection.
|
||||||
- Worker results are applied only when the match id still matches and the model is still detached, preventing stale async results from overwriting visible/detail fighters.
|
- `TRAIL.INTERVAL_MS` and `TRAIL.LIFETIME_MS` bound how many afterimages can exist at once.
|
||||||
- Stale worker ids whose models have already been unregistered are skipped before reading model fields.
|
|
||||||
- The main thread still performs `killFighterModel()` for worker-reported deaths so split-on-death, kill rewards, death stats, scoreboard updates, and match completion stay on the existing authoritative path.
|
|
||||||
|
|
||||||
# Update: Magic Attack Effect Pooling
|
# Update: Split Special Projectile Visual Configs
|
||||||
|
|
||||||
- `spawnSpellEffect()` now acquires instant-spell visual sprites from a small per-texture pool and returns them when their attack animation completes.
|
- Special projectile visual asset settings are separated by caster type. Melee visual sheets are configured under `SPECIAL_EFFECT.MELEE`; ranged visual sheets are configured under `SPECIAL_EFFECT.RANGE`.
|
||||||
- Pooled spell effects are reset on reuse for texture frame, position, scale, depth, alpha, rotation, flip, active/visible state, and animation-complete listeners.
|
- `SPECIAL_EFFECT.PROJECTILE` now carries shared projectile behavior only: hold time, acceleration/ease, fallback speed, target density area, travel clamp, hit radius, max lifetime, and optional trail visuals.
|
||||||
- `clearCombatObjects()` now disposes through `disposeCombatObject()`, allowing active pooled spell effects to be returned during match cleanup while non-pooled projectiles, labels, heal effects, and world effects keep their destroy path.
|
|
||||||
- Only magic/instant-spell visuals were pooled here; projectile hit objects and meteor/world-effect objects remain on their existing lifecycle.
|
|
||||||
|
|
||||||
# Update: Squad-Based Detached Combat
|
# Update: One-Shot Accelerating Special Projectile
|
||||||
|
|
||||||
- Large-battle detached models are grouped into transient squads by arena cell, team id, and `PERFORMANCE.LARGE_BATTLE_AGGREGATE_SQUAD_SIZE`.
|
- `SPECIAL_EFFECT.MELEE.REPEAT = 0` makes melee special sprites play once. `special-melee-effect-1` currently has `frameSequence` commented out, so it uses the sprite sheet's natural frame order.
|
||||||
- Squad AI does nearest-opposing-squad movement and group DPS resolution, then writes surviving members back into deterministic spiral slots around the squad center.
|
- Special projectile movement now uses a tween instead of constant `physics.moveTo`. `SPECIAL_EFFECT.PROJECTILE.startHoldMs` keeps the effect stationary for the pre-launch tell, `travelDurationMs` controls the launch duration when set, and `movementEase` controls the acceleration curve. If `travelDurationMs` is unset, movement falls back to `speed`.
|
||||||
- This removes per-frame movement/target AI for thousands of offscreen models; individual `updateFighterModel()` stays reserved for attached/detail fighters in the rolling camera window.
|
- Projectile hit checks still run from the scene UPDATE event while the tween moves the sprite, so instant-kill path detection remains active during acceleration.
|
||||||
- In large battles the combat target spatial index is built from attached/detail models, not the full model list, so visible individual AI no longer reintroduces an 8,000-model target scan.
|
|
||||||
|
|
||||||
# Update: Aggregate Detached Combat
|
# Update: Special Effect Frame Sequence Refresh
|
||||||
|
|
||||||
- `updateAggregateDetachedCombat()` handles large-battle detached model-only fighters as coarse cell groups instead of invoking full `updateFighterModel()` AI for each offscreen model.
|
- `createSpecialAnimation()` now rebuilds a special animation when the existing Phaser global animation no longer matches the configured frames, repeat count, or frame rate. This prevents stale animation keys from hiding `frameSequence` edits.
|
||||||
- Every-frame work for detached models is now simple movement toward the nearest enemy aggregate cell; target scanning, attack windup, projectile scheduling, and animation locks are reserved for attached/detail fighters.
|
- `frameSequence` values stay 1-based in config and are converted with `generateFrameNumbers(..., { frames })`, so repeated frames are preserved when a special asset enables a custom sequence.
|
||||||
- Aggregate damage is computed from group attack DPS on a throttled interval and applied to real `FighterModel` HP, so deaths, kill rewards, split-on-death, death stats, and winner checks remain tied to the existing combat state.
|
|
||||||
- Aggregate kills pass `silentLog: true` to the model death path to avoid large offscreen death batches flooding the DOM kill log.
|
|
||||||
|
|
||||||
# Update: Large Battle Combat Frame Throttles
|
# Update: Special Effect Frame Rate And Render Budget
|
||||||
|
|
||||||
- `prepareCombatFrame()` now syncs model positions from `fighterByModelId` only, so detached/offscreen sprite records are not scanned just to no-op position sync.
|
- `SPECIAL_EFFECT.FRAME_RATE_MULTIPLIER` multiplies only special-effect animation frame rates. Caster sparkle, melee special sprites, and ranged special projectile frames can play faster or slower without changing caster hold time, launch delay, projectile movement speed, travel distance, or cleanup timers.
|
||||||
- Large battles reuse the target spatial index for `PERFORMANCE.LARGE_BATTLE_TARGET_INDEX_REFRESH_MS` instead of rebuilding the full 8,000-model grid every frame.
|
- `SPECIAL_EFFECT.FOCUS_LAYER.BLUR_MAX_FIGHTERS` caps when `specialEffects.js` creates the full-arena blurred render-texture snapshot. Above that living-fighter count, the special focus keeps the dim layer and raised caster but skips the expensive blur pass.
|
||||||
- The defensive model-index audit now runs once per second instead of every frame.
|
- Special projectile hit checks prefer `scene.combatTargetIndex` and scan only spatial cells around the projectile segment, falling back to `scene.fighters` when no index exists.
|
||||||
|
|
||||||
# Update: Null-Safe Model Target Cache
|
# Update: Special Effect Projectile
|
||||||
|
|
||||||
- `resolveTargetEnemyModel()` now clears stale `targetModelId` values when the cached model can no longer be resolved or is no longer a living enemy.
|
- `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.
|
||||||
- `isValidEnemyTargetModel()` now null-checks both attacker and candidate models before reading team ids, preventing a removed/dead cached target from crashing the update loop.
|
- `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.
|
||||||
|
- The Hurt-frame preparation also spawns a caster sparkle overlay from `public/assets/effects/special/effect.png`. Its animation uses the 1-based frame sequence `[2, 3, 4]` only, positions near the caster's eyes through `SPECIAL_EFFECT.CASTER_SPARKLE`, and is disposed before the attack animation starts.
|
||||||
|
- 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 specific frames can be repeated for readability. 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 use `SPECIAL_EFFECT.RANGE`. Movement uses a tweened Arcade Physics sprite, matching normal ranged projectile path-update checks while allowing acceleration, 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: Combat With Detached Render Sprites
|
# Update: Elite Magic Attack Effect Scale
|
||||||
|
|
||||||
- `prepareCombatFrame()` now syncs model position only from attached sprites; detached sprites are skipped so model-only movement remains authoritative.
|
- `combat.js` resolves instant-spell attack effect scale through constants instead of hard-coding `FIGHTER.SCALE`.
|
||||||
- `fighterForModelId()` may now return `null` for living fighters outside the rolling-window detail set, which intentionally routes movement, attacks, damage, and death through the model-only fallback.
|
- Normal spell effects use `FIGHTER.SCALE * FIGHTER.ATTACK_EFFECT_SCALE_MULTIPLIER`.
|
||||||
- The target spatial index still builds from `scene.fighterModels`; its `livingFighters` compatibility list now represents attached render sprites only, while `livingModels` remains the full combat list.
|
- Elite magic spell effects additionally multiply by `FIGHTER.ELITE.ATTACK_EFFECT_SCALE_MULTIPLIER`, keeping caster body scale and effect scale separately tunable.
|
||||||
- Model-only death asks `ArenaScene.removeDetachedFighterProxyForModel()` to remove the parked fighter entry, and `livingFighterProxyCount()` prevents any remaining dead entries from being re-registered.
|
|
||||||
|
|
||||||
# Update: Model-Only Combat Fallback
|
# Update: Elite Target Damage And Density
|
||||||
|
|
||||||
- `updateFighterModel()` no longer requires an attached Phaser sprite to keep a living fighter model moving and fighting.
|
- `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.
|
||||||
- If a render sprite exists, movement, animation, projectiles, and death presentation keep using the existing Sprite/Arcade path.
|
- 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.
|
||||||
- If no render sprite exists, movement updates model `x/y` directly, attacks schedule delayed model hits, damage writes to model HP, and death unregisters the model from `ArenaScene` indexes immediately.
|
- Elite direct kills trigger a kill splash at the killed fighter's body position. The splash deals `COMBAT.ELITE_KILL_SPLASH_DAMAGE_PERCENT` of that killed fighter's max HP to living enemies inside `COMBAT.ELITE_KILL_SPLASH_RADIUS`; splash kills are recorded normally, recursive splash chaining is controlled by `ELITE_KILL_SPLASH_CHAIN_ENABLED`, and the optional visual uses square pixel dots rather than smooth circles.
|
||||||
- Projectile and instant-spell model-only attacks preserve windup/effect/travel timing, but skip visual projectile/spell objects.
|
- 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.
|
||||||
- Kill reward and split-on-death can now run from model state, so offscreen sprite detachment does not stop combat resolution.
|
- `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: Model-Based Targeting And Spatial Index
|
|
||||||
|
|
||||||
- `ArenaScene.update()` now iterates `scene.fighterModels` and calls `updateFighterModel()` instead of driving combat directly from the sprite array.
|
|
||||||
- `prepareCombatFrame()` still syncs active sprite positions into models, but the target spatial index is built from model records and stores model entries in each grid cell.
|
|
||||||
- Target caching moved to `model.targetModelId`; validation checks model liveness and team identity before resolving the render sprite through `scene.fighterForModelId()`.
|
|
||||||
- `combatTargetIndex` now exposes `livingModels` as the primary model list while keeping `livingFighters` as an attached-sprite compatibility list.
|
|
||||||
- Attack execution, animation, projectiles, and HUD-facing effects still use sprites when they exist; detached participants resolve through the model-only path.
|
|
||||||
|
|
||||||
# Update: FighterModel Position Sync In Combat
|
|
||||||
|
|
||||||
- `prepareCombatFrame()` now syncs each sprite's current render position into its `FighterModel` before building the target spatial index.
|
|
||||||
- Dormant/offscreen fighters keep advancing model `x/y` through `fighterAdapter.moveFighterToward()` while visible fighters continue to use Arcade movement and sync back into the model on the next combat frame.
|
|
||||||
- Target-grid cell placement and nearest-enemy lookup use model position helpers, keeping the combat path ready for a future model-first update loop.
|
|
||||||
- Attack execution still resolves a fighter sprite from `targetModelId` for movement, animation, and hit visuals. Removing that render dependency is a later migration step.
|
|
||||||
|
|
||||||
# Update: Fighter Adapter In Combat
|
|
||||||
|
|
||||||
- `combat.js` no longer owns fighter render/body helpers locally. It imports fighter position, distance, movement, detail visibility, animation, body-disable, and arena-clamp helpers from `fighterAdapter.js`.
|
|
||||||
- Visible fighters still move through Arcade physics, while dormant fighters are advanced by the adapter with JS `x/y` math and arena clamping.
|
|
||||||
- Target selection and camera/world-effect hit points now use adapter position helpers so disabled Arcade bodies do not leave stale centers behind.
|
|
||||||
- Ranged attacks still render projectiles only when both attacker and defender are detailed; dormant participation resolves through delayed data hits.
|
|
||||||
|
|
||||||
# Update: Dormant Fighter Combat Simulation
|
|
||||||
|
|
||||||
- `updateFighterModel()` accepts `delta` and manually advances dormant fighters with disabled Arcade bodies using JS position math.
|
|
||||||
- Visible fighters still use `scene.physics.moveToObject()` so nearby/on-screen motion keeps the existing Arcade movement behavior.
|
|
||||||
- Attack/hurt animation locks are applied only to detailed fighters. Dormant fighters rely on cooldowns and delayed hit timers instead of animation-complete events.
|
|
||||||
- Projectile attacks involving dormant fighters resolve as delayed data hits and skip Phaser projectile object creation.
|
|
||||||
- Hit-point, camera, and world-effect helpers treat disabled bodies as stale and use fighter `x/y` instead.
|
|
||||||
|
|
||||||
# Update: Projectile And Target Grid Optimization
|
|
||||||
|
|
||||||
- Projectile hit detection now relies on `projectilePathHitsDefender()` only; it no longer creates one Arcade overlap collider per projectile because the path check already covers fast projectile travel against the defender hit area.
|
|
||||||
- Projectile path/hit-area geometry is reused through module-level scratch objects to avoid repeated `Line`/`Rectangle` allocation during projectile updates.
|
|
||||||
- The per-frame target spatial index now stores cells in a numeric array, avoiding string cell keys and `Map` writes during every combat frame.
|
|
||||||
- `clearCombatObjects()` also clears `scene.combatTargetIndex` so match resets and LOD passes do not briefly reuse stale living-fighter lists.
|
|
||||||
|
|
||||||
# Update: Dense-Area Meteor Barrage
|
# Update: Dense-Area Meteor Barrage
|
||||||
|
|
||||||
|
|
@ -109,23 +74,22 @@
|
||||||
|
|
||||||
- Dead fighters keep their death animation/corpse state at initial opacity, then fade out until `combat.js` removes them from `scene.fighters` and destroys the sprite.
|
- Dead fighters keep their death animation/corpse state at initial opacity, then fade out until `combat.js` removes them from `scene.fighters` and destroys the sprite.
|
||||||
- Tune that fade/despawn lifetime with `FIGHTER.DEAD_DESPAWN_DELAY_MS` and the final alpha with `FIGHTER.DEAD_DESPAWN_ALPHA` in `src/constants.js`.
|
- Tune that fade/despawn lifetime with `FIGHTER.DEAD_DESPAWN_DELAY_MS` and the final alpha with `FIGHTER.DEAD_DESPAWN_ALPHA` in `src/constants.js`.
|
||||||
- Fighter action playback now goes through `fighterAdapter.playFighterAction()` / `playFighterActionIfNeeded()`, which resolve animation keys with `ensureFighterTeamAnimation()` so every action can use the team-shadow baked texture generated from the original spritesheet.
|
- `combat.js` resolves fighter animation keys through `ensureFighterTeamAnimation()` so every action can use the team-shadow baked texture generated from the original spritesheet.
|
||||||
- The adapter compares against the team-shadow animation key before replaying an action. This avoids switching back to the original non-team-colored spritesheet when fighters move, attack, take damage, or die.
|
- `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.
|
- 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 pooled 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
|
# 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.
|
- `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.
|
- World-effect meteor/frost visuals remain visible, and projectile objects remain enabled because projectiles currently participate in hit detection.
|
||||||
- Projectile objects should keep calling `projectilePathHitsDefender()` for collision checks instead of adding per-projectile Arcade overlap colliders.
|
|
||||||
|
|
||||||
# Context: Combat System
|
# Context: Combat System
|
||||||
|
|
||||||
## 1. 모듈별 상세 역할 (`src/game/combat/`)
|
## 1. 모듈별 상세 역할 (`src/game/combat/`)
|
||||||
|
|
||||||
- **`combat.js`**: 전투 AI, 피해 계산, 처치 보상 등 핵심 전투 로직을 담당합니다. `fighterStats.js`에서 해석한 역할별 수치로 이동, 공격, 투사체 발사 등을 처리합니다.
|
- **`combat.js`**: 전투 AI, 피해 계산, 처치 기록 및 비활성화된 보너스 경로를 담당합니다. `fighterStats.js`에서 해석한 역할별 수치로 이동, 공격, 투사체 발사 등을 처리합니다.
|
||||||
- **`combatSettings.js`**: 전투 속도 배율 등 런타임 전투 설정을 관리합니다.
|
- **`combatSettings.js`**: 전투 속도 배율 등 런타임 전투 설정을 관리합니다.
|
||||||
- **`arenaFinalCombatEffects.js`**: 최종 교전 시 슬로우 모션 등 연출 효과를 담당합니다. 수학적인 이징(easing) 함수와 물리 시간 배율 계산을 포함합니다.
|
- **`arenaFinalCombatEffects.js`**: 최종 교전 시 슬로우 모션 등 연출 효과를 담당합니다. 수학적인 이징(easing) 함수와 물리 시간 배율 계산을 포함합니다.
|
||||||
- **`worldEffects.js`**: 실제 전투에서 설정 주기마다 생존자 밀집 구역을 탐색하고 화염/냉기 소형 메테오 포격을 실행하며, 대각선 낙하 연출, 개별 탄착 판정, 냉기 동결과 감속 구역 수명주기를 처리합니다.
|
- **`worldEffects.js`**: 실제 전투에서 설정 주기마다 생존자 밀집 구역을 탐색하고 화염/냉기 소형 메테오 포격을 실행하며, 대각선 낙하 연출, 개별 탄착 판정, 냉기 동결과 감속 구역 수명주기를 처리합니다.
|
||||||
|
|
@ -133,14 +97,15 @@
|
||||||
## 2. 주요 로직 구현 세부 사항
|
## 2. 주요 로직 구현 세부 사항
|
||||||
|
|
||||||
### 전투 AI 및 유닛 동작
|
### 전투 AI 및 유닛 동작
|
||||||
- **`updateFighterModel()`**: 가장 가까운 적 모델을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
|
- **`updateFighter()`**: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
|
||||||
- **`applyHit()`**: 일반 공격 피해량은 공격자의 `melee`/`ranged`/`magic` 프로필 피해량 범위에서 계산하고, 치명타 적중은 `Critical!` 표기와 즉시 처치를 처리합니다.
|
- **`applyHit()`**: 일반 공격 피해량은 공격자의 `melee`/`ranged`/`magic` 프로필 피해량 범위에서 계산합니다. 치명타 적중은 `Critical!`을 표시하고, 일반 대상에는 일반 피해의 2배, elite 대상에는 최대 체력 비례 피해를 적용합니다.
|
||||||
- **역할별 기본값**: `src/constants.js`의 `FIGHTER_TYPE_STATS`에서 체력, 이동속도, 사거리, 공격 쿨다운, 피해량, 치명타 확률, 발동 지연을 독립적으로 조절합니다. 투사체 속도는 `ranged`, 효과 적중 지연은 `magic` 프로필에 포함됩니다.
|
- **역할별 기본값**: `src/constants.js`의 `FIGHTER_TYPE_STATS`에서 체력, 이동속도, 사거리, 공격 쿨다운, 피해량, 치명타 확률, 발동 지연을 독립적으로 조절합니다. 투사체 속도는 `ranged`, 효과 적중 지연은 `magic` 프로필에 포함됩니다.
|
||||||
- **`projectilePathHitsDefender()`**: 투사체가 대상을 스쳐 지나가지 않도록 궤적(Line)과 히트박스(Rectangle) 겹침 검사를 수행합니다.
|
- **`projectilePathHitsDefender()`**: 투사체가 대상을 스쳐 지나가지 않도록 궤적(Line)과 히트박스(Rectangle) 겹침 검사를 수행합니다.
|
||||||
|
|
||||||
### 처치 보상 및 성장
|
### 처치 보너스 정책
|
||||||
- **`applyKillReward()`**: 처치한 캐릭터의 체력 회복(현재 체력 30%), 크기 증가, 공격속도/이동속도 배율 증가를 처리합니다. 누적 배율은 `KILL_GROWTH_MAX_MULTIPLIER`로 제한합니다.
|
- elite 압축 전투에서는 `COMBAT.KILL_REWARD_ENABLED`가 `false`이므로 처치자 체력 회복, 크기 성장, 공격속도/이동속도 보너스와 회복 이펙트가 적용되지 않습니다.
|
||||||
- **`clampFighterInsideArena()`**: 처치 성장 중 커진 캐릭터가 전장 바깥으로 나가지 않도록 위치를 보정합니다.
|
- 킬로그, 사망 통계, 분열 판정, 승패 판정은 처치 보너스와 별개로 계속 처리됩니다.
|
||||||
|
- `applyKillReward()`와 관련 상수는 향후 별도의 비압축 모드에서 명시적으로 활성화할 수 있는 경로로만 보존합니다.
|
||||||
|
|
||||||
### 월드 이펙트
|
### 월드 이펙트
|
||||||
- **발동 규칙**: 프리뷰가 아닌 실제 전투에서 시작 후 첫 포격은 `WORLD_EFFECT.INTERVAL`이 지난 뒤 발생하고, 이후 일반 포격은 `WORLD_EFFECT.REPEAT_INTERVAL` 간격으로 발생합니다. 각 포격은 `AREA_TILES` 크기의 모든 후보 구역을 타일 누적합으로 평가해, 생존 캐릭터가 가장 많이 모인 범위를 선택합니다. 같은 밀도의 후보가 여러 개일 때만 그 후보 사이에서 무작위로 고릅니다.
|
- **발동 규칙**: 프리뷰가 아닌 실제 전투에서 시작 후 첫 포격은 `WORLD_EFFECT.INTERVAL`이 지난 뒤 발생하고, 이후 일반 포격은 `WORLD_EFFECT.REPEAT_INTERVAL` 간격으로 발생합니다. 각 포격은 `AREA_TILES` 크기의 모든 후보 구역을 타일 누적합으로 평가해, 생존 캐릭터가 가장 많이 모인 범위를 선택합니다. 같은 밀도의 후보가 여러 개일 때만 그 후보 사이에서 무작위로 고릅니다.
|
||||||
|
|
@ -160,10 +125,10 @@
|
||||||
- Arcade Physics는 timeScale 방향이 반대라 물리 이동에는 역수 배율을 적용합니다.
|
- Arcade Physics는 timeScale 방향이 반대라 물리 이동에는 역수 배율을 적용합니다.
|
||||||
|
|
||||||
## 3. 유지보수 규칙
|
## 3. 유지보수 규칙
|
||||||
- **처치 성장 상한**: `src/constants.js`의 `KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
|
- **처치 보너스**: elite 압축 규칙을 유지하는 동안 `src/constants.js`의 `COMBAT.KILL_REWARD_ENABLED`는 `false`로 유지합니다.
|
||||||
- **공격력 조정**: `src/constants.js`의 `FIGHTER_TYPE_STATS.<type>.damageMin/damageMax`를 수정합니다.
|
- **공격력 조정**: 일반 역할 피해량은 `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`(냉기 고정) 설정을 변경할 수 있습니다.
|
- `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`는 내부 소형 포격의 판정 범위, 발수, 간격, 시각 크기를 조정합니다.
|
- `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`로 동결 시간과 표시 색상을 조정합니다.
|
- `WORLD_EFFECT.FROST_STUN_DURATION`/`FROST_STUN_TINT`로 동결 시간과 표시 색상을 조정합니다.
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,34 @@
|
||||||
# Update: Worker Entrypoints
|
# Update: Special Effect Constants
|
||||||
|
|
||||||
- `src/game/arena/fighterLodWorker.js` is bundled as a Vite module worker for large-battle render LOD candidate selection.
|
- `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.
|
||||||
- It is separate from `src/game/combat/aggregateCombatWorker.js`: LOD worker chooses which sprites should be detailed, while aggregate combat worker advances detached/offscreen combat math.
|
- `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: Full Rolling-Window Detail Constants
|
# Update: Large-Battle Render Budget
|
||||||
|
|
||||||
- Focused large-battle rendering no longer uses a separate zoomed sprite cap or rolling-window buffer ratio.
|
- `PERFORMANCE.LARGE_BATTLE_RENDERED_FIGHTER_LIMIT` caps the number of physical fighter plans produced for large battles.
|
||||||
- `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT` is the bounded representative sprite count for full-arena overview.
|
- `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.
|
||||||
- `PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_SCALE` and `PERFORMANCE.LARGE_BATTLE_SPRITE_VIEW_PADDING` define the focused camera window whose living fighters are all promoted to detailed sprites.
|
|
||||||
|
|
||||||
# Update: Web Worker Aggregate Path
|
# Update: Large-Battle Elite Probability
|
||||||
|
|
||||||
- `aggregateCombatWorker.js` is bundled as a Vite module worker and is used only for detached/offscreen aggregate combat math.
|
- `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`.
|
||||||
- Main-thread combat keeps the authoritative Phaser/game-state mutations, while the worker exchanges Transferable TypedArrays for model position, HP, team, speed, DPS, and death results.
|
- `matchSetup.js` keeps the threshold under `PERFORMANCE` and the elite ratio under `FIGHTER.ELITE`, so performance detection and elite balancing remain separately tunable.
|
||||||
- Worker failure disables the worker path and leaves the synchronous aggregate fallback active.
|
|
||||||
|
|
||||||
# Update: LOD Traversal Reduction
|
# Update: Elite Magic Attack Effect Scale
|
||||||
|
|
||||||
- Large-battle LOD now removes parked fighter bodies from Arcade World's active body set and re-enables them only when a fighter becomes detailed again.
|
- `FIGHTER.ATTACK_EFFECT_SCALE_MULTIPLIER` controls normal instant-spell attack effect size.
|
||||||
- LOD refreshes still use `PERFORMANCE.LARGE_BATTLE_LOD_REFRESH_MS`, but detail visibility changes are now applied as set differences after initial activation.
|
- `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.
|
||||||
- `PERFORMANCE.LARGE_BATTLE_SPRITE_VIEW_PADDING` also pads the dot redraw culling view so zoomed camera movement does not require drawing every offscreen LOD dot.
|
- Elite skin selection uses the full `FIGHTER.ELITE.TYPE` list, currently `melee` and `magic`.
|
||||||
|
|
||||||
# Update: Aggregate Combat Constants
|
# Update: Elite Balance Constants
|
||||||
|
|
||||||
- `PERFORMANCE.LARGE_BATTLE_AGGREGATE_COMBAT_REFRESH_MS` controls the detached/offscreen aggregate combat tick interval.
|
- `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.
|
||||||
- `PERFORMANCE.LARGE_BATTLE_AGGREGATE_CELL_SIZE` controls the coarse combat grid size used for large-battle detached model groups.
|
- `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.
|
||||||
- `PERFORMANCE.LARGE_BATTLE_AGGREGATE_SQUAD_SIZE` controls how many detached fighters are represented by one squad in a cell/team group.
|
- `COMBAT.ELITE_KILL_SPLASH_ENABLED`, `ELITE_KILL_SPLASH_DAMAGE_PERCENT`, `ELITE_KILL_SPLASH_RADIUS`, and `ELITE_KILL_SPLASH_CHAIN_ENABLED` tune the elite-only on-kill area damage centered on the killed fighter.
|
||||||
- `PERFORMANCE.LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_CELL_TICK` and `LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_TICK` cap batched deaths to avoid a single aggregate tick creating a large DOM/game-state spike.
|
- `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.
|
||||||
- `PERFORMANCE.LARGE_BATTLE_AGGREGATE_MOVEMENT_RATIO` tunes the speed of detached models moving toward their nearest aggregate enemy cell.
|
- `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.
|
||||||
# Update: Large Battle Throttle Constants
|
|
||||||
|
|
||||||
- `PERFORMANCE.LARGE_BATTLE_SIMULATION_BUCKETS` spreads detached model-only combat updates across frames during large live matches.
|
|
||||||
- `PERFORMANCE.LARGE_BATTLE_SIMULATION_MAX_DELTA_MS` caps the accumulated delta used by throttled detached fighters.
|
|
||||||
- `PERFORMANCE.LARGE_BATTLE_TARGET_INDEX_REFRESH_MS` controls how often the full target spatial index is rebuilt in large battles.
|
|
||||||
- `PERFORMANCE.WORLD_EFFECT_MODIFIER_REFRESH_MS` throttles frost-zone speed modifier scans.
|
|
||||||
- `PERFORMANCE.LARGE_BATTLE_HUD_VISIBLE_LIMIT` caps pooled fighter HUD health bars separately from normal battles.
|
|
||||||
- The 8,000-fighter full-arena overview budget is intentionally tight through `LARGE_BATTLE_SPRITE_RENDER_LIMIT`; focused rolling-window views promote all fighters in the local window.
|
|
||||||
|
|
||||||
# Update: Fighter Render LOD Constants
|
|
||||||
|
|
||||||
- `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT` caps the number of representative detailed fighter sprites kept visible in the full-arena overview during large live battles.
|
|
||||||
- `PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_SCALE` makes the sprite-ready area larger than the exact camera view.
|
|
||||||
- `PERFORMANCE.LARGE_BATTLE_SPRITE_VIEW_PADDING` provides a minimum rolling-window expansion even at tighter zooms.
|
|
||||||
- `PERFORMANCE.LARGE_BATTLE_LOD_REFRESH_MS` throttles detailed-set recomputation, and `PERFORMANCE.LARGE_BATTLE_DOT_REFRESH_MS` throttles the shared dot overlay redraw.
|
|
||||||
- `PERFORMANCE.LARGE_BATTLE_DOT_SIZE` and `PERFORMANCE.LARGE_BATTLE_DOT_ALPHA` tune the hidden-fighter dot representation.
|
|
||||||
- `CAMERA.LARGE_BATTLE_START_ZOOM` controls the initial zoom used when a live match starts as a large battle.
|
|
||||||
- `CAMERA.MANUAL_FOCUS_TWEEN_MS` and `CAMERA.MANUAL_FOCUS_TWEEN_EASE` tune manual camera pan/zoom transitions used by fighter/team selection and full-arena return.
|
|
||||||
|
|
||||||
# Update: Phaser Render Tuning
|
|
||||||
|
|
||||||
- `src/constants.js` exports `RENDER` for the Phaser canvas resolution. The arena remains `ARENA.SIZE = 3200`, while the canvas now renders at `1280 x 1280`.
|
|
||||||
- `CAMERA.MIN_ZOOM` is derived from render size versus arena size so full-arena overview still works at the lower internal canvas resolution.
|
|
||||||
- `src/main.js` keeps `pixelArt: true` and now also sets `autoRound: true` plus `powerPreference: "high-performance"` for the Phaser game config.
|
|
||||||
- `PERFORMANCE.MINIMAP_REFRESH_MS` centralizes the live minimap redraw interval so large battles avoid redrawing thousands of dots on every scene update.
|
|
||||||
|
|
||||||
# Context: Core & Infrastructure
|
# Context: Core & Infrastructure
|
||||||
|
|
||||||
|
|
@ -75,7 +50,7 @@
|
||||||
|
|
||||||
# Update: Performance Constants
|
# Update: Performance Constants
|
||||||
|
|
||||||
- `src/constants.js` now exports `PERFORMANCE` for large-battle tuning: fighter threshold, target grid size, HUD pool/candidate limits, graphics minimap settings/redraw interval, and large-battle dead despawn delay.
|
- `src/constants.js` now exports `PERFORMANCE` for large-battle tuning: fighter threshold, target grid size, HUD pool/candidate limits, graphics minimap settings, and large-battle dead despawn delay.
|
||||||
- Keep large-battle behavior switches tied to `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` so high-count match tuning stays centralized.
|
- Keep large-battle behavior switches tied to `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` so high-count match tuning stays centralized.
|
||||||
|
|
||||||
# Update: Dead Fighter Despawn Constant
|
# Update: Dead Fighter Despawn Constant
|
||||||
|
|
@ -91,8 +66,8 @@
|
||||||
- **`src/constants.js`**: 게임 내 모든 튜닝 수치를 관리합니다.
|
- **`src/constants.js`**: 게임 내 모든 튜닝 수치를 관리합니다.
|
||||||
- `FIGHTER_TYPE_STATS`: `melee`, `ranged`, `magic`별 최대 체력, 이동속도, 사거리, 쿨다운, 피해량, 치명타 및 공격 발동 지연 기본값.
|
- `FIGHTER_TYPE_STATS`: `melee`, `ranged`, `magic`별 최대 체력, 이동속도, 사거리, 쿨다운, 피해량, 치명타 및 공격 발동 지연 기본값.
|
||||||
- `FIGHTER_HITBOX_*`: 100x100 캐릭터 프레임 안에서 실제 충돌 판정이 놓이는 위치와 크기.
|
- `FIGHTER_HITBOX_*`: 100x100 캐릭터 프레임 안에서 실제 충돌 판정이 놓이는 위치와 크기.
|
||||||
- `KILL_HEALTH_RECOVERY_RATIO`, `KILL_GROWTH_MULTIPLIER`, `KILL_GROWTH_MAX_MULTIPLIER`: 처치 후 회복량, 크기/공격속도/이동속도 성장 배율, 누적 보상 상한.
|
- `KILL_REWARD_ENABLED`, `KILL_HEALTH_RECOVERY_RATIO`, `KILL_GROWTH_MULTIPLIER`, `KILL_GROWTH_MAX_MULTIPLIER`: 기본적으로 비활성화된 처치 보너스 토글과, 명시적으로 재활성화할 때 사용하는 회복/성장 값.
|
||||||
- `WORLD_EFFECT.*`: 첫/반복 포격 간격, 밀집 경고 범위, 개별 탄착 범위/발수/시각 배율, 대각선 낙하 거리, 화염/냉기 메테오 피해량, 냉기 동결 시간/색상, 냉각지대 지속시간과 감속 배율.
|
- `WORLD_EFFECT.*`: 첫/반복 포격 간격, 밀집 경고 범위, 개별 탄착 범위/발수/시각 배율, 대각선 낙하 거리, normal 고정 화염/냉기 피해량, elite 최대 체력 비례 화염/냉기 피해량, 냉기 동결 시간/색상, 냉각지대 지속시간과 감속 배율.
|
||||||
- `SELECTED_FIGHTER_OUTLINE_GAP`, `SELECTED_FIGHTER_OUTLINE_WIDTH`, `SELECTED_FIGHTER_OUTLINE_ALPHA`: 팀 색상 실루엣 마커의 캐릭터 이격 거리, 두께, 투명도.
|
- `SELECTED_FIGHTER_OUTLINE_GAP`, `SELECTED_FIGHTER_OUTLINE_WIDTH`, `SELECTED_FIGHTER_OUTLINE_ALPHA`: 팀 색상 실루엣 마커의 캐릭터 이격 거리, 두께, 투명도.
|
||||||
- `TEAM_COLORS`, `getTeamColor()`: 8팀 이하에서는 기본 팔레트를 쓰고, 9팀 이상에서는 팀 수에 맞춰 중복 없는 색상을 동적으로 생성합니다.
|
- `TEAM_COLORS`, `getTeamColor()`: 8팀 이하에서는 기본 팔레트를 쓰고, 9팀 이상에서는 팀 수에 맞춰 중복 없는 색상을 동적으로 생성합니다.
|
||||||
- `CAMERA.SPECTATOR_LERP`: 카메라 추적의 부드러움 정도.
|
- `CAMERA.SPECTATOR_LERP`: 카메라 추적의 부드러움 정도.
|
||||||
|
|
@ -107,8 +82,8 @@
|
||||||
- **신규 캐릭터 추가**: `public/assets/characters/`에 에셋 배치 후 `fighterManifest.js`에 정의를 추가하면 즉시 게임에 반영됩니다.
|
- **신규 캐릭터 추가**: `public/assets/characters/`에 에셋 배치 후 `fighterManifest.js`에 정의를 추가하면 즉시 게임에 반영됩니다.
|
||||||
- **종족값 유지**: 신규 스킨을 추가할 때는 사망 통계가 누락되지 않도록 `species`를 `human`, `orc`, `skeleton`, `slime`, `wolf`, `bear` 중 하나로 지정해야 합니다.
|
- **종족값 유지**: 신규 스킨을 추가할 때는 사망 통계가 누락되지 않도록 `species`를 `human`, `orc`, `skeleton`, `slime`, `wolf`, `bear` 중 하나로 지정해야 합니다.
|
||||||
- **물리 수치 조정**: 역할별 기본 체력/속도/사거리/공격 수치는 `src/constants.js`의 `FIGHTER_TYPE_STATS`에서 변경하고, 특정 스킨만 다르게 할 때는 `fighterManifest.js`의 `stats` 또는 `combat` 설정을 사용하십시오.
|
- **물리 수치 조정**: 역할별 기본 체력/속도/사거리/공격 수치는 `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`의 `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에 접근합니다.
|
- **DOM 접근**: 성능을 위해 `ArenaScene`은 좌측 HUD badge 등 필요한 시점에만 최소한으로 DOM에 접근합니다.
|
||||||
- **패키지 락 파일**: 이 프로젝트는 `package-lock.json`을 저장소에서 제외합니다. 의존성 변경 시 `package.json`을 기준으로 관리합니다.
|
- **패키지 락 파일**: 이 프로젝트는 `package-lock.json`을 저장소에서 제외합니다. 의존성 변경 시 `package.json`을 기준으로 관리합니다.
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,20 @@
|
||||||
# Update: Parked Body Removal From Arcade World
|
# Update: Field HUD Text Removal
|
||||||
|
|
||||||
- `disableFighterBody()` now calls `scene.physics.world.disable(fighter)` for parked or dead fighter sprites, removing the body from Arcade World's active body set instead of only setting `body.enable = false`.
|
- `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.
|
||||||
- `enableFighterBody()` re-adds the sprite body with `world.enable(fighter)`, resets it to the model position, stops residual velocity, and syncs the model from the reattached sprite.
|
- HUD cleanup now owns only health-bar display objects.
|
||||||
- `setFighterDetailVisible()` uses these adapter helpers for LOD detach/reattach, so render parking also removes offscreen bodies from physics traversal.
|
|
||||||
- `isLivingFighterModel()` now returns false for missing/null models so stale async ids and removed models cannot pass living checks.
|
|
||||||
|
|
||||||
# Update: Parked Fighter Detail Early Return
|
# Update: Elite Representative Fighter
|
||||||
|
|
||||||
- `setFighterDetailVisible(false)` now returns immediately when a fighter is already parked, avoiding repeated body/input/HUD/display-list work during large-battle LOD refreshes.
|
- `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` and `magic`); normal plans still draw from the complete manifest.
|
||||||
# Update: Detached Fighter Animation Guard
|
- 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.
|
||||||
- `shouldRenderFighterDetail()` now requires an actual active fighter object before returning true, preventing model-only large-battle combat from trying to animate a `null` sprite.
|
- Elite representatives cannot use `splitOnDeath`. Elite attack-speed and movement-speed bonuses are configurable independently under `FIGHTER.ELITE`.
|
||||||
- `playFighterAction()` and `playFighterActionIfNeeded()` now skip playback if no animation key can be resolved.
|
|
||||||
|
|
||||||
# Update: Fighter Sprite Render Recovery
|
|
||||||
|
|
||||||
- `createFighter()` returns a real Phaser Sprite again, with combat-facing fields bridged to `fighter.model`.
|
|
||||||
- The lazy `SpriteProxy` pool was rolled back because the proxy handoff could leave the simulation data alive while no stable Phaser render object was visible.
|
|
||||||
- `attachSprite: false` is still accepted for large-battle startup, but it now creates the sprite and immediately parks it through `setFighterDetailVisible(false)` instead of skipping Sprite creation.
|
|
||||||
- Rolling-window LOD still removes non-detailed sprites from Phaser's display/update lists and restores the same sprite from model `x/y` when it becomes detailed again.
|
|
||||||
|
|
||||||
# Update: Fighter Render Sprite Detach
|
|
||||||
|
|
||||||
- `setFighterDetailVisible(false)` now parks a fighter sprite by disabling body/input/HUD, pausing animation, hiding it, and removing it from Phaser's display and update lists.
|
|
||||||
- `setFighterDetailVisible(true)` reattaches the same sprite, resets its body from model `x/y`, resumes animation, and restores pointer interaction for living fighters.
|
|
||||||
- `syncFighterModelFromSprite()` ignores detached sprites so offscreen model-only movement cannot be overwritten by a stale parked sprite position.
|
|
||||||
- Adapter helpers treat `_spriteDetached` as non-rendered/non-body state, so animation, body position, and projectile path logic naturally fall back to model data.
|
|
||||||
|
|
||||||
# Update: FighterModel Shell
|
|
||||||
|
|
||||||
- `fighterModel.js` now creates the pure JS state record for a fighter. The model owns combat-facing fields including HP, team/skin references, `targetModelId`/cooldown state, selection, lock/death flags, kill-growth state, frost state, detail visibility, facing, and model `x/y`.
|
|
||||||
- `attachFighterModel()` connects a Phaser sprite to its model and preserves the existing `fighter.hp`, `fighter.team`, `fighter.isDead`, etc. surface through getter/setter bridges. This keeps the current code stable while making `fighter.model` the state home.
|
|
||||||
- `isLivingFighterModel()` and `fighterModelDistanceSquared()` support model-first combat code without requiring a Sprite wrapper.
|
|
||||||
- `fighterFactory.js` creates a Phaser Sprite with an attached `fighter.model` bridge. HUD slots, timers, scale, and input hit areas remain render concerns.
|
|
||||||
- `fighterAdapter.js` updates model `x/y` when sprites are synced or when dormant fighters move manually, and now treats detached proxies as model-only for body/render checks.
|
|
||||||
|
|
||||||
# Update: Fighter Adapter Layer
|
|
||||||
|
|
||||||
- `fighterAdapter.js` centralizes fighter-facing Phaser operations: `fighterWorldPoint()`, `fighterDistanceSquared()`, `setFighterFacing()`, `moveFighterToward()`, `stopFighterMovement()`, `enableFighterBody()`, `disableFighterBody()`, `clampFighterInsideArena()`, animation playback, and frost tint helpers.
|
|
||||||
- `fighterFactory.js` owns sprite creation plus detail visibility; offscreen fighters remain model-backed while their sprite is parked outside Phaser render/update traversal.
|
|
||||||
- Treat the adapter as the boundary for the upcoming model/proxy split. Code outside `src/game/fighter/` should avoid new direct fighter `body`, `setFlipX()`, `setVelocity()`, or animation calls unless it is explicitly dealing with a non-fighter object.
|
|
||||||
|
|
||||||
# Update: Dormant Fighter Detail State
|
|
||||||
|
|
||||||
- `setFighterDetailVisible(false)` now makes a non-detailed fighter dormant and detached from Phaser render/update traversal.
|
|
||||||
- `setFighterDetailVisible(true)` re-enables the body at the model position, resumes animation, and restores pointer interaction for living fighters.
|
|
||||||
- Dormant fighters remain sprite/model records in `this.fighters` so existing match arrays, ownership, death stats, split-on-death, and team bookkeeping remain intact.
|
|
||||||
|
|
||||||
# Update: Fighter Detail Visibility For LOD
|
|
||||||
|
|
||||||
- `fighterFactory.js` exposes `setFighterDetailVisible()` so `ArenaScene` can hide or restore the detailed Phaser sprite for large-battle render LOD without removing the fighter from combat simulation.
|
|
||||||
- Hidden fighters release borrowed HUD slots and disable pointer interaction; visible living fighters keep their original hit-area based interaction.
|
|
||||||
- `syncFighterHud()` now treats invisible fighters as HUD-ineligible, preventing hidden LOD fighters from holding health-bar display objects.
|
|
||||||
- Detached LOD fighters now pause animation safely because combat locks and delayed hits can resolve through the model-only fallback while the sprite is parked.
|
|
||||||
|
|
||||||
# Update: HUD Pooling
|
# Update: HUD Pooling
|
||||||
|
|
||||||
- `fighterFactory.js` no longer creates permanent HUD objects for every fighter; zoom HUD now shows health bars without fighter name labels.
|
- `fighterFactory.js` no longer creates permanent health bars for every fighter, and battlefield name labels are not created at all.
|
||||||
- HUD health-bar display objects are pooled on the scene and assigned only to selected fighters or zoom-visible nearby fighters chosen by `ArenaScene`.
|
- 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.
|
- `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`.
|
- Tune pool size and visible candidate limits in `PERFORMANCE.FIGHTER_HUD_POOL_SIZE` and `PERFORMANCE.FIGHTER_HUD_VISIBLE_LIMIT`.
|
||||||
|
|
||||||
|
|
@ -75,7 +31,7 @@
|
||||||
## 1. 모듈별 상세 역할 (`src/game/fighter/`)
|
## 1. 모듈별 상세 역할 (`src/game/fighter/`)
|
||||||
|
|
||||||
- **`fighterAssets.js`**: 캐릭터 스프라이트 로드 및 애니메이션/실루엣 생성을 담당합니다. 원본 이미지로부터 팀 색상 마커용 실루엣을 동적으로 생성합니다.
|
- **`fighterAssets.js`**: 캐릭터 스프라이트 로드 및 애니메이션/실루엣 생성을 담당합니다. 원본 이미지로부터 팀 색상 마커용 실루엣을 동적으로 생성합니다.
|
||||||
- **`fighterFactory.js`**: 캐릭터 인스턴스화 및 HUD 체력바 관리를 담당합니다. Phaser Sprite와 DOM UI 사이의 가교 역할을 합니다.
|
- **`fighterFactory.js`**: 캐릭터 인스턴스화 및 HUD(이름표, 체력바) 관리를 담당합니다. Phaser Sprite와 DOM UI 사이의 가교 역할을 합니다.
|
||||||
- **`fighterManifest.js`**: 모든 캐릭터 종족 및 스탯 데이터를 정의합니다. 20여 종의 캐릭터 설정이 포함되어 있습니다.
|
- **`fighterManifest.js`**: 모든 캐릭터 종족 및 스탯 데이터를 정의합니다. 20여 종의 캐릭터 설정이 포함되어 있습니다.
|
||||||
- **`fighterStats.js`**: 공격 방식으로 `melee`, `ranged`, `magic` 역할을 판별하고 역할별 기본 스탯과 스킨별 오버라이드를 병합합니다.
|
- **`fighterStats.js`**: 공격 방식으로 `melee`, `ranged`, `magic` 역할을 판별하고 역할별 기본 스탯과 스킨별 오버라이드를 병합합니다.
|
||||||
- **`fighterSelection.js`**: 매치 참여 캐릭터를 무작위로 선택하거나 섞는 로직을 담당합니다.
|
- **`fighterSelection.js`**: 매치 참여 캐릭터를 무작위로 선택하거나 섞는 로직을 담당합니다.
|
||||||
|
|
@ -90,7 +46,7 @@
|
||||||
4. 캐릭터가 성장하여 커져도 같은 배율로 실루엣이 유지됩니다.
|
4. 캐릭터가 성장하여 커져도 같은 배율로 실루엣이 유지됩니다.
|
||||||
|
|
||||||
### 캐릭터 HUD 및 상태 동기화
|
### 캐릭터 HUD 및 상태 동기화
|
||||||
- **체력바 표시**: 줌 또는 선택 상태에서 후보 fighter만 pooled HUD slot을 빌려 체력바를 표시합니다. 이름표는 zoom HUD에 표시하지 않습니다.
|
- **이름표 고정**: 스프라이트 중심이 아닌 실제 히트박스 하단에 고정되어 시각적 일관성을 유지합니다.
|
||||||
- **사망자 처리**: 사망 시 HUD와 팀 마커를 숨겨 화면 가독성을 높입니다. 본체 sprite만 낮은 depth와 반투명 상태로 남깁니다.
|
- **사망자 처리**: 사망 시 HUD와 팀 마커를 숨겨 화면 가독성을 높입니다. 본체 sprite만 낮은 depth와 반투명 상태로 남깁니다.
|
||||||
- **월드 감속 상태**: 생성 시 `worldEffectSpeedMultiplier`를 `1`로 초기화하며, 냉각지대 안에서는 `worldEffects.js`가 해당 배율을 낮춰 공격속도와 이동속도 계산에 반영합니다.
|
- **월드 감속 상태**: 생성 시 `worldEffectSpeedMultiplier`를 `1`로 초기화하며, 냉각지대 안에서는 `worldEffects.js`가 해당 배율을 낮춰 공격속도와 이동속도 계산에 반영합니다.
|
||||||
- **냉기 동결 상태**: `isFrostStunned`와 동결 타이머를 캐릭터별로 관리합니다. 냉기 메테오 착탄에 생존하면 캐릭터 본체와 팀 실루엣 마커가 함께 얼음색으로 바뀌고, 동결 종료 시 본체 원본 색상과 저장된 팀 색상으로 복구됩니다.
|
- **냉기 동결 상태**: `isFrostStunned`와 동결 타이머를 캐릭터별로 관리합니다. 냉기 메테오 착탄에 생존하면 캐릭터 본체와 팀 실루엣 마커가 함께 얼음색으로 바뀌고, 동결 종료 시 본체 원본 색상과 저장된 팀 색상으로 복구됩니다.
|
||||||
|
|
@ -98,7 +54,7 @@
|
||||||
### 캐릭터별 특성 (예: Slime)
|
### 캐릭터별 특성 (예: Slime)
|
||||||
- **`spawnMultiplier`**: 배정된 슬롯 1개를 지정된 수만큼 확장하여 스폰합니다.
|
- **`spawnMultiplier`**: 배정된 슬롯 1개를 지정된 수만큼 확장하여 스폰합니다.
|
||||||
- **`splitOnDeath`**: 사망 시 확률적으로 지정된 수만큼 분열체를 생성합니다.
|
- **`splitOnDeath`**: 사망 시 확률적으로 지정된 수만큼 분열체를 생성합니다.
|
||||||
- **스탯 상한**: 처치 보상은 현재 체력을 회복시키지만 `maxHp`를 넘을 수 없습니다. (예: Slime은 항상 1 HP)
|
- **처치 보너스 비활성화**: elite 압축 전투에서는 처치 회복/성장 보너스가 적용되지 않습니다. 따라서 Slime을 포함한 모든 fighter는 처치로 HP 또는 크기/속도 배율을 얻지 않습니다.
|
||||||
|
|
||||||
### 역할별 전투 스탯
|
### 역할별 전투 스탯
|
||||||
- `combat.type`이 `projectile`이면 `ranged`, `instant-spell`이면 `magic`, 그 외에는 `melee` 기본 프로필을 사용합니다.
|
- `combat.type`이 `projectile`이면 `ranged`, `instant-spell`이면 `magic`, 그 외에는 `melee` 기본 프로필을 사용합니다.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,27 @@
|
||||||
|
# Update: Restrained Team Card Styling
|
||||||
|
|
||||||
|
- Team score cards preserve their existing content and selection behavior while using a neutral dark card surface.
|
||||||
|
- Per-team color now reads mainly through a compact marker and a small divider segment, making the HUD feel less saturated.
|
||||||
|
- Focus and hover feedback uses subtle inset emphasis without the previous raised motion or strong glow.
|
||||||
|
|
||||||
|
# Update: Battle Notice Rolling Text
|
||||||
|
|
||||||
|
- `battleDeathNotice.js` measures the rendered message against the visible notice content width and only enables rolling text when the message would overflow.
|
||||||
|
- Rolling notices render an internal duplicated track for continuous movement while exposing the single message through the status node's `aria-label`.
|
||||||
|
- `UI.BATTLE_NOTICE_ROLL_GAP_PX`, `BATTLE_NOTICE_ROLL_SPEED_PX_PER_SECOND`, and min/max duration constants tune the marquee behavior.
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
- 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` 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
|
# Context: Match & UI
|
||||||
|
|
||||||
# Update: Direct Fighter Count Entries And Zone Distribution
|
# Update: Direct Fighter Count Entries And Zone Distribution
|
||||||
|
|
@ -30,7 +54,7 @@
|
||||||
- **설정 유지**: 닉네임, 인원, 배치 모드는 `localStorage`에 저장되어 재접속 시 복원됩니다.
|
- **설정 유지**: 닉네임, 인원, 배치 모드는 `localStorage`에 저장되어 재접속 시 복원됩니다.
|
||||||
|
|
||||||
### 전투 화면 레이아웃 (HUD)
|
### 전투 화면 레이아웃 (HUD)
|
||||||
- **팀 Badge**: 좌측 HUD 레일에 배치되며, 클릭 시 해당 팀의 생존 유닛 중 무작위 1명으로 시점을 고정합니다.
|
- **팀 Badge**: 좌측 HUD 레일에 배치되며, 클릭 시 해당 팀의 생존 유닛 중 무작위 1명으로 시점을 고정합니다. 이미 고정된 동일 팀 Badge를 다시 클릭하면 선택을 해제하고 기본 줌을 요청합니다. 단, 자동 관전 줌 조건이 활성화되어 있으면 다음 카메라 갱신에서 자동 줌이 즉시 다시 적용됩니다.
|
||||||
- **팀 Badge 갱신 안정성**: 사망으로 생존 수가 바뀔 때 기존 badge 버튼 DOM을 유지한 채 숫자, 비활성 상태, 선택 강조만 갱신하여 사망 프레임에 겹친 클릭도 시점 고정으로 전달되도록 합니다.
|
- **팀 Badge 갱신 안정성**: 사망으로 생존 수가 바뀔 때 기존 badge 버튼 DOM을 유지한 채 숫자, 비활성 상태, 선택 강조만 갱신하여 사망 프레임에 겹친 클릭도 시점 고정으로 전달되도록 합니다.
|
||||||
- **킬로그**: 처치자와 피처치자를 좌우로 배치하고, 피처치자 아이콘에 빨간 X를 겹쳐 사망 관계를 명확히 표시합니다. 캐릭터 idle 시트의 `100x100` 프레임 내 투명 여백을 제외한 중앙 하단 영역을 확대 표시해 작은 아이콘 박스에서도 실루엣이 충분히 보이도록 합니다.
|
- **킬로그**: 처치자와 피처치자를 좌우로 배치하고, 피처치자 아이콘에 빨간 X를 겹쳐 사망 관계를 명확히 표시합니다. 캐릭터 idle 시트의 `100x100` 프레임 내 투명 여백을 제외한 중앙 하단 영역을 확대 표시해 작은 아이콘 박스에서도 실루엣이 충분히 보이도록 합니다.
|
||||||
- **하단 메타 정보**: 전투 화면 우측 하단(`arena-meta` 컨테이너)에 방문자 카운터와 About 버튼이 Pill(알약) 형태로 디자인이 통일되어 나란히 고정 배치됩니다. 드로어가 열려도 동일한 위치를 유지합니다.
|
- **하단 메타 정보**: 전투 화면 우측 하단(`arena-meta` 컨테이너)에 방문자 카운터와 About 버튼이 Pill(알약) 형태로 디자인이 통일되어 나란히 고정 배치됩니다. 드로어가 열려도 동일한 위치를 유지합니다.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
# 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 압축 여부를 판정한다.
|
||||||
|
- 사용자가 입력한 총 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", "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.1,
|
||||||
|
ATTACK_SPEED_BONUS_MULTIPLIER: 1,
|
||||||
|
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 계산 의도:
|
||||||
|
|
||||||
|
```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 상수를 관리한다.
|
||||||
|
- 일반 마법 공격 이펙트는 `FIGHTER.ATTACK_EFFECT_SCALE_MULTIPLIER`, elite 마법 공격 이펙트는
|
||||||
|
`FIGHTER.ELITE.ATTACK_EFFECT_SCALE_MULTIPLIER`로 조정한다.
|
||||||
|
- 카메라/렌더/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개체로 렌더링한다.
|
||||||
|
- 전체 입력 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은 대응하는 개별 스폰 지점을 사용한다.
|
||||||
|
|
||||||
|
### `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_*` 상수를 사용한다.
|
||||||
|
- instant-spell 공격 이펙트 크기는 상수 기반으로 계산하고, elite magic 스킨이면
|
||||||
|
`FIGHTER.ELITE.ATTACK_EFFECT_SCALE_MULTIPLIER`를 추가 적용한다.
|
||||||
|
- 처치 흐름은 로그와 사망 처리는 유지하면서 `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 : 32 | N : 800` 부근으로 표시되어
|
||||||
|
실제 렌더링되는 군세 구성을 바로 확인할 수 있다.
|
||||||
|
|
||||||
|
### `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 수가 평균 32개, normal 수가 평균 800개에 수렴하고,
|
||||||
|
모든 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 양쪽 흐름을 수동 확인한다.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
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 |
247
src/constants.js
247
src/constants.js
|
|
@ -2,7 +2,8 @@
|
||||||
const GRID_SIZE = 50;
|
const GRID_SIZE = 50;
|
||||||
const TILE_SIZE = 64;
|
const TILE_SIZE = 64;
|
||||||
const ARENA_SIZE = GRID_SIZE * TILE_SIZE;
|
const ARENA_SIZE = GRID_SIZE * TILE_SIZE;
|
||||||
const RENDER_SIZE = 1280;
|
const VIEWPORT_SIZE = 1600;
|
||||||
|
const CAMERA_ZOOM_SCALE = VIEWPORT_SIZE / ARENA_SIZE;
|
||||||
|
|
||||||
export const ARENA = {
|
export const ARENA = {
|
||||||
GRID_SIZE,
|
GRID_SIZE,
|
||||||
|
|
@ -11,13 +12,14 @@ export const ARENA = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RENDER = {
|
export const RENDER = {
|
||||||
HEIGHT: RENDER_SIZE,
|
VIEWPORT_SIZE,
|
||||||
WIDTH: RENDER_SIZE,
|
CAMERA_ZOOM_SCALE,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. FIGHTER 도메인
|
// 2. FIGHTER 도메인
|
||||||
export const FIGHTER = {
|
export const FIGHTER = {
|
||||||
SCALE: 3,
|
SCALE: 3,
|
||||||
|
ATTACK_EFFECT_SCALE_MULTIPLIER: 1,
|
||||||
DEPTH: 2,
|
DEPTH: 2,
|
||||||
DEAD_DEPTH: 1,
|
DEAD_DEPTH: 1,
|
||||||
DEAD_DESPAWN_ALPHA: 0,
|
DEAD_DESPAWN_ALPHA: 0,
|
||||||
|
|
@ -51,7 +53,7 @@ export const FIGHTER = {
|
||||||
attackCooldown: 840,
|
attackCooldown: 840,
|
||||||
damageMin: 14,
|
damageMin: 14,
|
||||||
damageMax: 24,
|
damageMax: 24,
|
||||||
criticalChance: 0.2,
|
criticalChance: 0.05,
|
||||||
windupDelay: 260,
|
windupDelay: 260,
|
||||||
},
|
},
|
||||||
ranged: {
|
ranged: {
|
||||||
|
|
@ -77,38 +79,39 @@ export const FIGHTER = {
|
||||||
effectHitDelay: 160,
|
effectHitDelay: 160,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ELITE: {
|
||||||
|
TYPE: ["melee", "magic"],
|
||||||
|
STACK_SIZE: 100,
|
||||||
|
VISUAL_SCALE_MULTIPLIER: 5,
|
||||||
|
ATTACK_EFFECT_SCALE_MULTIPLIER: 2,
|
||||||
|
HP_BONUS_RATIO: 2,
|
||||||
|
ATTACK_RANGE_MULTIPLIER: 1.5,
|
||||||
|
ATTACK_DAMAGE_BONUS_MULTIPLIER: 1.1,
|
||||||
|
ATTACK_DAMAGE_STACK_EXPONENT: 1,
|
||||||
|
ATTACK_SPEED_BONUS_MULTIPLIER: 1,
|
||||||
|
ATTACK_SPEED_STACK_EXPONENT: 0.4,
|
||||||
|
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 = {
|
export const PERFORMANCE = {
|
||||||
LARGE_BATTLE_FIGHTER_THRESHOLD: 2000,
|
LARGE_BATTLE_FIGHTER_THRESHOLD: 2000,
|
||||||
|
LARGE_BATTLE_RENDERED_FIGHTER_LIMIT: 2000,
|
||||||
LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS: 0,
|
LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS: 0,
|
||||||
LARGE_BATTLE_SIMULATION_BUCKETS: 16,
|
|
||||||
LARGE_BATTLE_SIMULATION_MAX_DELTA_MS: 260,
|
|
||||||
LARGE_BATTLE_TARGET_INDEX_REFRESH_MS: 160,
|
|
||||||
LARGE_BATTLE_AGGREGATE_COMBAT_REFRESH_MS: 260,
|
|
||||||
LARGE_BATTLE_AGGREGATE_CELL_SIZE: TILE_SIZE * 5,
|
|
||||||
LARGE_BATTLE_AGGREGATE_SQUAD_SIZE: 100,
|
|
||||||
LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_CELL_TICK: 4,
|
|
||||||
LARGE_BATTLE_AGGREGATE_MAX_DEATHS_PER_TICK: 80,
|
|
||||||
LARGE_BATTLE_AGGREGATE_MOVEMENT_RATIO: 0.72,
|
|
||||||
WORLD_EFFECT_MODIFIER_REFRESH_MS: 180,
|
|
||||||
TARGET_GRID_CELL_SIZE: TILE_SIZE * 4,
|
TARGET_GRID_CELL_SIZE: TILE_SIZE * 4,
|
||||||
FIGHTER_HUD_POOL_SIZE: 48,
|
FIGHTER_HUD_POOL_SIZE: 96,
|
||||||
FIGHTER_HUD_VISIBLE_LIMIT: 32,
|
FIGHTER_HUD_VISIBLE_LIMIT: 72,
|
||||||
LARGE_BATTLE_HUD_VISIBLE_LIMIT: 8,
|
|
||||||
FIGHTER_HUD_VIEW_PADDING: TILE_SIZE * 2,
|
FIGHTER_HUD_VIEW_PADDING: TILE_SIZE * 2,
|
||||||
FIGHTER_HUD_CANDIDATE_REFRESH_MS: 120,
|
FIGHTER_HUD_CANDIDATE_REFRESH_MS: 120,
|
||||||
MINIMAP_DOT_RADIUS: 3,
|
MINIMAP_DOT_RADIUS: 3,
|
||||||
MINIMAP_BACKGROUND_ALPHA: 0.62,
|
MINIMAP_BACKGROUND_ALPHA: 0.62,
|
||||||
MINIMAP_BORDER_ALPHA: 0.84,
|
MINIMAP_BORDER_ALPHA: 0.84,
|
||||||
MINIMAP_REFRESH_MS: 220,
|
|
||||||
LARGE_BATTLE_SPRITE_RENDER_LIMIT: 140,
|
|
||||||
LARGE_BATTLE_ROLLING_WINDOW_SCALE: 1.05,
|
|
||||||
LARGE_BATTLE_SPRITE_VIEW_PADDING: TILE_SIZE * 2,
|
|
||||||
LARGE_BATTLE_LOD_REFRESH_MS: 180,
|
|
||||||
LARGE_BATTLE_DOT_REFRESH_MS: 220,
|
|
||||||
LARGE_BATTLE_DOT_SIZE: 6,
|
|
||||||
LARGE_BATTLE_DOT_ALPHA: 0.86,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. SPAWN 도메인
|
// 3. SPAWN 도메인
|
||||||
|
|
@ -119,9 +122,9 @@ export const SPAWN = {
|
||||||
STARTING_ZONES: "starting-zones",
|
STARTING_ZONES: "starting-zones",
|
||||||
},
|
},
|
||||||
// Caps participant-assigned slots; traits such as slime spawning may add fighters.
|
// Caps participant-assigned slots; traits such as slime spawning may add fighters.
|
||||||
MAX_FIGHTER_COUNT: 8000,
|
MAX_FIGHTER_COUNT: 20000,
|
||||||
FIGHTERS_PER_STARTING_ZONE: 100,
|
FIGHTERS_PER_STARTING_ZONE: 500,
|
||||||
STARTING_ZONE_RADIUS: 2,
|
STARTING_ZONE_RADIUS: 3,
|
||||||
STARTING_ZONE_FILL_ALPHA: 0.07,
|
STARTING_ZONE_FILL_ALPHA: 0.07,
|
||||||
STARTING_ZONE_BORDER_ALPHA: 0.14,
|
STARTING_ZONE_BORDER_ALPHA: 0.14,
|
||||||
STARTING_ZONE_VISIBLE_DURATION_MS: 2000,
|
STARTING_ZONE_VISIBLE_DURATION_MS: 2000,
|
||||||
|
|
@ -131,18 +134,31 @@ export const SPAWN = {
|
||||||
|
|
||||||
// 4. COMBAT 도메인
|
// 4. COMBAT 도메인
|
||||||
export const COMBAT = {
|
export const COMBAT = {
|
||||||
|
KILL_REWARD_ENABLED: false,
|
||||||
KILL_HEALTH_RECOVERY_RATIO: 0.3,
|
KILL_HEALTH_RECOVERY_RATIO: 0.3,
|
||||||
KILL_HEAL_EFFECT_FRAMES: 4,
|
KILL_HEAL_EFFECT_FRAMES: 4,
|
||||||
KILL_HEAL_EFFECT_FRAME_RATE: 12,
|
KILL_HEAL_EFFECT_FRAME_RATE: 12,
|
||||||
KILL_GROWTH_MULTIPLIER: 1.25,
|
KILL_GROWTH_MULTIPLIER: 1.25,
|
||||||
KILL_GROWTH_MAX_MULTIPLIER: 5,
|
KILL_GROWTH_MAX_MULTIPLIER: 5,
|
||||||
KILL_GROWTH_TWEEN_DURATION: 180,
|
KILL_GROWTH_TWEEN_DURATION: 180,
|
||||||
|
CRITICAL_DAMAGE_PERCENT: 0.1,
|
||||||
|
NORMAL_CRITICAL_DAMAGE_MULTIPLIER: 2,
|
||||||
|
ELITE_KILL_SPLASH_ENABLED: true,
|
||||||
|
ELITE_KILL_SPLASH_DAMAGE_PERCENT: 0.1,
|
||||||
|
ELITE_KILL_SPLASH_RADIUS: TILE_SIZE * 2,
|
||||||
|
ELITE_KILL_SPLASH_CHAIN_ENABLED: false,
|
||||||
// 최종교전 슬로우모션 설정
|
// 최종교전 슬로우모션 설정
|
||||||
FINAL_SLOW_MOTION_ENABLED: false,
|
FINAL_SLOW_MOTION_ENABLED: false,
|
||||||
FINAL_SLOW_MOTION_ENTER_DURATION: 14000,
|
FINAL_SLOW_MOTION_ENTER_DURATION: 14000,
|
||||||
FINAL_SLOW_MOTION_HOLD_DURATION: 14000,
|
FINAL_SLOW_MOTION_HOLD_DURATION: 14000,
|
||||||
FINAL_SLOW_MOTION_EXIT_DURATION: 14000,
|
FINAL_SLOW_MOTION_EXIT_DURATION: 14000,
|
||||||
FINAL_SLOW_MOTION_SCALE: 0.28,
|
FINAL_SLOW_MOTION_SCALE: 0.28,
|
||||||
|
// 전투원 간 공간 분리 (spatial grid 기반 군중 밀착 방지)
|
||||||
|
FIGHTER_SEPARATION_ENABLED: true,
|
||||||
|
// 전투원 중심 간 최소 이격 거리(px). HITBOX_WIDTH=22, SCALE=3이므로 약 1.5배
|
||||||
|
FIGHTER_SEPARATION_DISTANCE: 42 * 3,
|
||||||
|
// 밀착 시 밀어내는 힘. moveSpeed(148~163)보다 낮아야 자연스러움
|
||||||
|
FIGHTER_SEPARATION_FORCE: 148 / 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 5. PROJECTILE 도메인
|
// 5. PROJECTILE 도메인
|
||||||
|
|
@ -155,20 +171,20 @@ export const PROJECTILE = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 6. WORLD_EFFECT 도메인
|
// 6. WORLD_EFFECT 도메인
|
||||||
export const WORLD_EFFECT = {
|
const WORLD_EFFECT_CONFIG = {
|
||||||
// Delay from match start until the first barrage.
|
// Delay from match start until the first barrage.
|
||||||
INTERVAL: 8000,
|
INTERVAL: 8000,
|
||||||
// Delay between barrages after the first one has fired.
|
// Delay between barrages after the first one has fired.
|
||||||
REPEAT_INTERVAL: 8000,
|
REPEAT_INTERVAL: 20000,
|
||||||
AREA_TILES: 40,
|
AREA_TILES: 40,
|
||||||
// How long the large dense-area warning marker remains visible.
|
// How long the large dense-area warning marker remains visible.
|
||||||
WARNING_DURATION_MS: 2000,
|
WARNING_DURATION_MS: 2000,
|
||||||
IMPACT_AREA_TILES: 10,
|
IMPACT_AREA_TILES: 10,
|
||||||
IMPACT_COUNT_MIN: 15,
|
IMPACT_COUNT_MIN: 5,
|
||||||
IMPACT_COUNT_MAX: 25,
|
IMPACT_COUNT_MAX: 10,
|
||||||
IMPACT_STAGGER_MS: 140,
|
IMPACT_STAGGER_MS: 140,
|
||||||
IMPACT_VISUAL_SCALE: 10,
|
IMPACT_VISUAL_SCALE: 15,
|
||||||
SIZE_SCALE_VARIANCE: 0,
|
SIZE_SCALE_VARIANCE: 1,
|
||||||
FRAMES: 7,
|
FRAMES: 7,
|
||||||
FRAME_RATE: 14,
|
FRAME_RATE: 14,
|
||||||
FALL_DURATION: 920,
|
FALL_DURATION: 920,
|
||||||
|
|
@ -176,7 +192,9 @@ export const WORLD_EFFECT = {
|
||||||
METEOR_SHAKE_DURATION_MS: 150,
|
METEOR_SHAKE_DURATION_MS: 150,
|
||||||
METEOR_SHAKE_INTENSITY: 0.004,
|
METEOR_SHAKE_INTENSITY: 0.004,
|
||||||
METEOR_DAMAGE: 90,
|
METEOR_DAMAGE: 90,
|
||||||
|
METEOR_DAMAGE_PERCENT: 0.4,
|
||||||
FROST_DAMAGE: 45,
|
FROST_DAMAGE: 45,
|
||||||
|
FROST_DAMAGE_PERCENT: 0.2,
|
||||||
FROST_STUN_DURATION: 2000,
|
FROST_STUN_DURATION: 2000,
|
||||||
FROST_STUN_TINT: 0x82e9ff,
|
FROST_STUN_TINT: 0x82e9ff,
|
||||||
FROST_DURATION: 2000,
|
FROST_DURATION: 2000,
|
||||||
|
|
@ -189,36 +207,161 @@ export const WORLD_EFFECT = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SPECIAL_EFFECT = {
|
||||||
|
ENABLED: true,
|
||||||
|
FRAME_RATE_MULTIPLIER: 1.5,
|
||||||
|
// 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: 11000,
|
||||||
|
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: false,
|
||||||
|
INVULNERABLE_MS: 4500,
|
||||||
|
},
|
||||||
|
CASTER_SPARKLE: {
|
||||||
|
ENABLED: true,
|
||||||
|
key: "special-caster-eye-sparkle",
|
||||||
|
path: "assets/effects/special/effect.png",
|
||||||
|
frames: 12,
|
||||||
|
frameWidth: 100,
|
||||||
|
frameHeight: 100,
|
||||||
|
frameRate: 12,
|
||||||
|
frameSequence: [2, 3, 4],
|
||||||
|
repeat: -1,
|
||||||
|
scaleMultiplier: 1,
|
||||||
|
anchorX: 50,
|
||||||
|
anchorY: 40,
|
||||||
|
effectAnchorX: 54,
|
||||||
|
effectAnchorY: 50,
|
||||||
|
depthOffset: 0.25,
|
||||||
|
alpha: 1,
|
||||||
|
},
|
||||||
|
CAMERA: {
|
||||||
|
ZOOM: 3 * CAMERA_ZOOM_SCALE,
|
||||||
|
CENTER_ON_CASTER_AT_START: true,
|
||||||
|
ZOOM_IN_MS: 720,
|
||||||
|
HOLD_MS: 1100,
|
||||||
|
ZOOM_OUT_MS: 1300,
|
||||||
|
LERP: 0.045,
|
||||||
|
PROJECTILE_VIEW_ZOOM: 1 * CAMERA_ZOOM_SCALE,
|
||||||
|
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,
|
||||||
|
BLUR_MAX_FIGHTERS: 800,
|
||||||
|
FADE_IN_MS: 160,
|
||||||
|
FADE_OUT_MS: 220,
|
||||||
|
},
|
||||||
|
MELEE: {
|
||||||
|
SCALE: 15,
|
||||||
|
FRAME_WIDTH: 100,
|
||||||
|
FRAME_HEIGHT: 100,
|
||||||
|
FRAME_RATE: 12,
|
||||||
|
REPEAT: -1,
|
||||||
|
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: [10, 9, 8],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
RANGE: {
|
||||||
|
key: "special-projectile-effect-1",
|
||||||
|
path: "assets/effects/special/projectile/projectile_Effect_1.png",
|
||||||
|
frames: 12,
|
||||||
|
frameWidth: 100,
|
||||||
|
frameHeight: 100,
|
||||||
|
frameRate: 20,
|
||||||
|
repeat: -1,
|
||||||
|
scale: 16,
|
||||||
|
depth: 7,
|
||||||
|
spawnDistance: TILE_SIZE * 2.8,
|
||||||
|
},
|
||||||
|
PROJECTILE: {
|
||||||
|
speed: 1150,
|
||||||
|
travelDurationMs: 620,
|
||||||
|
movementEase: "Cubic.In",
|
||||||
|
startHoldMs: 380,
|
||||||
|
targetAreaTiles: 8,
|
||||||
|
travelTiles: GRID_SIZE * 1.6,
|
||||||
|
arenaEdgePadding: TILE_SIZE * 2,
|
||||||
|
hitRadius: TILE_SIZE * 2.2,
|
||||||
|
maxLifetimeMs: 5200,
|
||||||
|
TRAIL: {
|
||||||
|
ENABLED: true,
|
||||||
|
INTERVAL_MS: 36,
|
||||||
|
LIFETIME_MS: 280,
|
||||||
|
ALPHA: 0.34,
|
||||||
|
SCALE_MULTIPLIER: 0.96,
|
||||||
|
DEPTH_OFFSET: -0.1,
|
||||||
|
FADE_EASE: "Cubic.Out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WORLD_EFFECT = {
|
||||||
|
...WORLD_EFFECT_CONFIG,
|
||||||
|
SPECIAL: SPECIAL_EFFECT,
|
||||||
|
};
|
||||||
|
|
||||||
// 7. CAMERA 도메인
|
// 7. CAMERA 도메인
|
||||||
export const CAMERA = {
|
export const CAMERA = {
|
||||||
MIN_ZOOM: RENDER_SIZE / ARENA_SIZE,
|
MIN_ZOOM: 1 * CAMERA_ZOOM_SCALE,
|
||||||
MAX_ZOOM: 3,
|
MAX_ZOOM: 3 * CAMERA_ZOOM_SCALE,
|
||||||
ZOOM_STEP: 0.1,
|
ZOOM_STEP: 0.1 * CAMERA_ZOOM_SCALE,
|
||||||
// 자동 관전 진입 전 화염/냉기 메테오 낙하 위치를 임시로 확대 추적합니다.
|
// 자동 관전 진입 전 화염/냉기 메테오 낙하 위치를 임시로 확대 추적합니다.
|
||||||
METEOR_FOCUS_ENABLED: false,
|
METEOR_FOCUS_ENABLED: false,
|
||||||
METEOR_FOCUS_ZOOM: 2,
|
METEOR_FOCUS_ZOOM: 2 * CAMERA_ZOOM_SCALE,
|
||||||
SPECTATOR_LERP: 0.05,
|
SPECTATOR_LERP: 0.01,
|
||||||
// 메테오 착탄 후 카메라를 해당 위치에 유지하는 시간(ms)입니다.
|
// 메테오 착탄 후 카메라를 해당 위치에 유지하는 시간(ms)입니다.
|
||||||
METEOR_FOCUS_HOLD_DURATION: 1200,
|
METEOR_FOCUS_HOLD_DURATION: 1200,
|
||||||
SPECTATOR_FINAL_FIGHTER_THRESHOLD: 5,
|
SPECTATOR_FINAL_FIGHTER_THRESHOLD: 5,
|
||||||
SPECTATOR_FINAL_FIGHT_ZOOM: 2,
|
SPECTATOR_FINAL_FIGHT_ZOOM: 3 * CAMERA_ZOOM_SCALE,
|
||||||
SPECTATOR_FINAL_TEAM_COUNT: 2,
|
SPECTATOR_FINAL_TEAM_COUNT: 2,
|
||||||
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD: 8,
|
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD: 8,
|
||||||
SPECTATOR_RANDOM_FOCUS_INTERVAL: 100000,
|
SPECTATOR_RANDOM_FOCUS_INTERVAL: 10000,
|
||||||
SPECTATOR_LATE_FIGHTER_THRESHOLD: 80,
|
SPECTATOR_LATE_FIGHTER_THRESHOLD: 500,
|
||||||
SPECTATOR_LATE_FIGHT_ZOOM: 1,
|
SPECTATOR_LATE_FIGHT_ZOOM: 2 * CAMERA_ZOOM_SCALE,
|
||||||
LARGE_BATTLE_START_ZOOM: 0.8,
|
SELECTED_FIGHTER_ZOOM: 2 * CAMERA_ZOOM_SCALE,
|
||||||
SELECTED_FIGHTER_ZOOM: 0.8,
|
|
||||||
MANUAL_FOCUS_TWEEN_MS: 220,
|
|
||||||
MANUAL_FOCUS_TWEEN_EASE: "Sine.easeInOut",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 8. UI 도메인
|
// 8. UI 도메인
|
||||||
export const UI = {
|
export const UI = {
|
||||||
MINIMAP_ALPHA: 0.8,
|
MINIMAP_ALPHA: 0.8,
|
||||||
MINIMAP_MARGIN: Math.round(RENDER_SIZE * 0.016),
|
MINIMAP_MARGIN: Math.round(VIEWPORT_SIZE * 0.016),
|
||||||
MINIMAP_VIEWPORT_SIZE: Math.round(RENDER_SIZE * 0.22),
|
MINIMAP_VIEWPORT_SIZE: Math.round(VIEWPORT_SIZE * 0.22),
|
||||||
MINIMAP_VIEW_FRAME_STROKE: Math.max(3, Math.round(RENDER_SIZE * 0.003125)),
|
MINIMAP_VIEW_FRAME_STROKE: Math.max(4, Math.round(VIEWPORT_SIZE * 0.003125)),
|
||||||
SELECTED_FIGHTER_OUTLINE_GAP: 1,
|
SELECTED_FIGHTER_OUTLINE_GAP: 1,
|
||||||
SELECTED_FIGHTER_OUTLINE_WIDTH: 1,
|
SELECTED_FIGHTER_OUTLINE_WIDTH: 1,
|
||||||
SELECTED_FIGHTER_OUTLINE_RED: 255,
|
SELECTED_FIGHTER_OUTLINE_RED: 255,
|
||||||
|
|
@ -229,6 +372,10 @@ export const UI = {
|
||||||
BATTLE_NOTICE_DELAY_MS: 5000,
|
BATTLE_NOTICE_DELAY_MS: 5000,
|
||||||
BATTLE_NOTICE_VISIBLE_MS: 2000,
|
BATTLE_NOTICE_VISIBLE_MS: 2000,
|
||||||
BATTLE_NOTICE_INTERVAL_MS: 10000,
|
BATTLE_NOTICE_INTERVAL_MS: 10000,
|
||||||
|
BATTLE_NOTICE_ROLL_GAP_PX: 48,
|
||||||
|
BATTLE_NOTICE_ROLL_SPEED_PX_PER_SECOND: 58,
|
||||||
|
BATTLE_NOTICE_ROLL_MIN_DURATION_MS: 7000,
|
||||||
|
BATTLE_NOTICE_ROLL_MAX_DURATION_MS: 18000,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 9. TEAM 도메인
|
// 9. TEAM 도메인
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,13 +1,13 @@
|
||||||
|
import Phaser from "phaser";
|
||||||
import {
|
import {
|
||||||
CAMERA,
|
CAMERA,
|
||||||
} from "../../constants.js";
|
} from "../../constants.js";
|
||||||
import {
|
|
||||||
fighterDistanceSquared,
|
|
||||||
fighterWorldPoint,
|
|
||||||
} from "../fighter/fighterAdapter.js";
|
|
||||||
|
|
||||||
export function getSpectatorState(livingFighters) {
|
export function getSpectatorState(livingFighters) {
|
||||||
const livingFighterCount = livingFighters.length;
|
const livingFighterCount = livingFighters.reduce(
|
||||||
|
(count, fighter) => count + representedFighterCount(fighter),
|
||||||
|
0,
|
||||||
|
);
|
||||||
const teamSummaries = getLivingTeamSummaries(livingFighters);
|
const teamSummaries = getLivingTeamSummaries(livingFighters);
|
||||||
|
|
||||||
if (livingFighterCount < CAMERA.SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
|
if (livingFighterCount < CAMERA.SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
|
||||||
|
|
@ -51,7 +51,7 @@ export function getLivingTeamSummaries(livingFighters) {
|
||||||
teamId,
|
teamId,
|
||||||
};
|
};
|
||||||
|
|
||||||
summary.count += 1;
|
summary.count += representedFighterCount(fighter);
|
||||||
summaries.set(teamId, summary);
|
summaries.set(teamId, summary);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -73,28 +73,39 @@ export function averageFighterPosition(fighters) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = fighters.reduce(
|
const weighted = fighters.reduce(
|
||||||
(position, fighter) => {
|
(position, fighter) => {
|
||||||
const point = fighterCameraPoint(fighter);
|
const point = fighterCameraPoint(fighter);
|
||||||
position.x += point.x;
|
const weight = representedFighterCount(fighter);
|
||||||
position.y += point.y;
|
position.count += weight;
|
||||||
|
position.x += point.x * weight;
|
||||||
|
position.y += point.y * weight;
|
||||||
return position;
|
return position;
|
||||||
},
|
},
|
||||||
{ x: 0, y: 0 },
|
{ count: 0, x: 0, y: 0 },
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: total.x / fighters.length,
|
x: weighted.x / weighted.count,
|
||||||
y: total.y / fighters.length,
|
y: weighted.y / weighted.count,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function representedFighterCount(fighter) {
|
||||||
|
return Math.max(1, Math.round(Number(fighter?.stackCount) || 1));
|
||||||
|
}
|
||||||
|
|
||||||
export function fighterCameraPoint(fighter) {
|
export function fighterCameraPoint(fighter) {
|
||||||
if (!fighter) {
|
const target = fighter?.body?.center ?? fighter;
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fighterWorldPoint(fighter);
|
return {
|
||||||
|
x: target.x,
|
||||||
|
y: target.y,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findClosestOpponentPair(fighters) {
|
export function findClosestOpponentPair(fighters) {
|
||||||
|
|
@ -113,7 +124,7 @@ export function findClosestOpponentPair(fighters) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const distance = fighterDistanceSquared(fighter, candidate);
|
const distance = Phaser.Math.Distance.Between(fighter.x, fighter.y, candidate.x, candidate.y);
|
||||||
|
|
||||||
if (distance < closestDistance) {
|
if (distance < closestDistance) {
|
||||||
closestDistance = distance;
|
closestDistance = distance;
|
||||||
|
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
self.onmessage = (event) => {
|
|
||||||
const job = event.data;
|
|
||||||
|
|
||||||
if (job?.type !== "fighter-lod-job") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const detailedIds = resolveDetailedIds(job);
|
|
||||||
|
|
||||||
self.postMessage(
|
|
||||||
{
|
|
||||||
detailedIds,
|
|
||||||
jobId: job.jobId,
|
|
||||||
matchId: job.matchId,
|
|
||||||
type: "fighter-lod-result",
|
|
||||||
},
|
|
||||||
[detailedIds.buffer],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function resolveDetailedIds(job) {
|
|
||||||
const modelIds = job.modelIds;
|
|
||||||
const count = modelIds?.length ?? 0;
|
|
||||||
const included = new Uint8Array(count);
|
|
||||||
const detailedIds = [];
|
|
||||||
const selectedIndex = indexOfModelId(modelIds, job.selectedModelId);
|
|
||||||
|
|
||||||
if (selectedIndex >= 0) {
|
|
||||||
addIndex(modelIds, included, detailedIds, selectedIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (job.fullArenaOverview) {
|
|
||||||
addRepresentativeDetails(job, included, detailedIds);
|
|
||||||
return Int32Array.from(detailedIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
addRollingWindowDetails(job, included, detailedIds);
|
|
||||||
|
|
||||||
if (detailedIds.length <= (selectedIndex >= 0 ? 1 : 0)) {
|
|
||||||
addRepresentativeDetails(job, included, detailedIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Int32Array.from(detailedIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRollingWindowDetails(job, included, detailedIds) {
|
|
||||||
const modelIds = job.modelIds;
|
|
||||||
const x = job.x;
|
|
||||||
const y = job.y;
|
|
||||||
|
|
||||||
for (let index = 0; index < modelIds.length; index += 1) {
|
|
||||||
if (
|
|
||||||
included[index]
|
|
||||||
|| x[index] < job.rollingLeft
|
|
||||||
|| x[index] > job.rollingRight
|
|
||||||
|| y[index] < job.rollingTop
|
|
||||||
|| y[index] > job.rollingBottom
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
addIndex(modelIds, included, detailedIds, index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRepresentativeDetails(job, included, detailedIds) {
|
|
||||||
const detailLimit = Math.max(1, Math.round(Number(job.baseDetailLimit) || 1));
|
|
||||||
const remainingLimit = detailLimit - detailedIds.length;
|
|
||||||
|
|
||||||
if (remainingLimit <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelIds = job.modelIds;
|
|
||||||
const teamKeys = job.teamKeys;
|
|
||||||
const groupsByTeam = new Map();
|
|
||||||
|
|
||||||
for (let index = 0; index < modelIds.length; index += 1) {
|
|
||||||
if (included[index]) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const teamKey = teamKeys[index] || 0;
|
|
||||||
let indexes = groupsByTeam.get(teamKey);
|
|
||||||
|
|
||||||
if (!indexes) {
|
|
||||||
indexes = [];
|
|
||||||
groupsByTeam.set(teamKey, indexes);
|
|
||||||
}
|
|
||||||
|
|
||||||
indexes.push(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
const teamCount = Math.max(1, groupsByTeam.size);
|
|
||||||
const quotaPerTeam = Math.max(1, Math.floor(remainingLimit / teamCount));
|
|
||||||
|
|
||||||
groupsByTeam.forEach((indexes) => {
|
|
||||||
const step = Math.max(1, Math.ceil(indexes.length / quotaPerTeam));
|
|
||||||
|
|
||||||
for (
|
|
||||||
let offset = 0;
|
|
||||||
offset < indexes.length && detailedIds.length < detailLimit;
|
|
||||||
offset += step
|
|
||||||
) {
|
|
||||||
addIndex(modelIds, included, detailedIds, indexes[offset]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let index = 0; index < modelIds.length && detailedIds.length < detailLimit; index += 1) {
|
|
||||||
addIndex(modelIds, included, detailedIds, index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function indexOfModelId(modelIds, modelId) {
|
|
||||||
if (!modelId) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = 0; index < modelIds.length; index += 1) {
|
|
||||||
if (modelIds[index] === modelId) {
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addIndex(modelIds, included, detailedIds, index) {
|
|
||||||
if (included[index]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
included[index] = 1;
|
|
||||||
detailedIds.push(modelIds[index]);
|
|
||||||
}
|
|
||||||
|
|
@ -1,496 +0,0 @@
|
||||||
globalThis.onmessage = (event) => {
|
|
||||||
const job = event.data;
|
|
||||||
|
|
||||||
if (!job?.modelIds || !job?.config) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = runAggregateJob(job);
|
|
||||||
|
|
||||||
globalThis.postMessage(result, [
|
|
||||||
result.modelIds.buffer,
|
|
||||||
result.x.buffer,
|
|
||||||
result.y.buffer,
|
|
||||||
result.hp.buffer,
|
|
||||||
result.deadDefenderIds.buffer,
|
|
||||||
result.deadAttackerIds.buffer,
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
function runAggregateJob(job) {
|
|
||||||
const state = {
|
|
||||||
alive: aliveArray(job.hp),
|
|
||||||
damageCursor: Math.max(0, Math.round(Number(job.damageCursor) || 0)),
|
|
||||||
deadAttackerIds: new Int32Array(resolveMaxDeathsPerTick(job.config)),
|
|
||||||
deadCount: 0,
|
|
||||||
deadDefenderIds: new Int32Array(resolveMaxDeathsPerTick(job.config)),
|
|
||||||
winnerCursor: Math.max(0, Math.round(Number(job.winnerCursor) || 0)),
|
|
||||||
};
|
|
||||||
const aggregateState = buildAggregateSquadState(job, state);
|
|
||||||
|
|
||||||
assignAggregateSquadTargets(aggregateState, job.config);
|
|
||||||
advanceAggregateSquads(aggregateState.squads, job.tickDelta, job.config);
|
|
||||||
resolveAggregateSquadCombatState(job, aggregateState.cells, state);
|
|
||||||
syncAggregateSquadMembers(job, aggregateState.squads, state);
|
|
||||||
|
|
||||||
return {
|
|
||||||
damageCursor: state.damageCursor,
|
|
||||||
deadAttackerIds: state.deadAttackerIds,
|
|
||||||
deadCount: state.deadCount,
|
|
||||||
deadDefenderIds: state.deadDefenderIds,
|
|
||||||
hp: job.hp,
|
|
||||||
jobId: job.jobId,
|
|
||||||
matchId: job.matchId,
|
|
||||||
modelIds: job.modelIds,
|
|
||||||
type: "aggregate-result",
|
|
||||||
winnerCursor: state.winnerCursor,
|
|
||||||
x: job.x,
|
|
||||||
y: job.y,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function aliveArray(hp) {
|
|
||||||
const alive = new Uint8Array(hp.length);
|
|
||||||
|
|
||||||
for (let index = 0; index < hp.length; index += 1) {
|
|
||||||
alive[index] = hp[index] > 0 ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return alive;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAggregateSquadState(job, state) {
|
|
||||||
const { config, teamKeys, x, y } = job;
|
|
||||||
const cellSize = Math.max(1, Number(config.cellSize) || 1);
|
|
||||||
const maxCellX = Math.floor((config.arenaSize - 1) / cellSize);
|
|
||||||
const maxCellY = Math.floor((config.arenaSize - 1) / cellSize);
|
|
||||||
const columns = maxCellX + 1;
|
|
||||||
const cellsByKey = new Map();
|
|
||||||
const cells = [];
|
|
||||||
|
|
||||||
for (let index = 0; index < job.modelIds.length; index += 1) {
|
|
||||||
if (!state.alive[index]) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cellX = clampCell(x[index], cellSize, maxCellX);
|
|
||||||
const cellY = clampCell(y[index], cellSize, maxCellY);
|
|
||||||
const key = targetCellIndex(cellX, cellY, columns);
|
|
||||||
let cell = cellsByKey.get(key);
|
|
||||||
|
|
||||||
if (!cell) {
|
|
||||||
cell = {
|
|
||||||
cellX,
|
|
||||||
cellY,
|
|
||||||
key,
|
|
||||||
squads: [],
|
|
||||||
teams: new Map(),
|
|
||||||
};
|
|
||||||
cellsByKey.set(key, cell);
|
|
||||||
cells.push(cell);
|
|
||||||
}
|
|
||||||
|
|
||||||
const teamKey = teamKeys[index] || 0;
|
|
||||||
let group = cell.teams.get(teamKey);
|
|
||||||
|
|
||||||
if (!group) {
|
|
||||||
group = {
|
|
||||||
indexes: [],
|
|
||||||
teamKey,
|
|
||||||
};
|
|
||||||
cell.teams.set(teamKey, group);
|
|
||||||
}
|
|
||||||
|
|
||||||
group.indexes.push(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
cells,
|
|
||||||
squads: createAggregateSquads(cells, job, state),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAggregateSquads(cells, job, state) {
|
|
||||||
const squads = [];
|
|
||||||
const squadSize = Math.max(1, Math.round(Number(job.config.squadSize) || 100));
|
|
||||||
|
|
||||||
cells.forEach((cell) => {
|
|
||||||
cell.teams.forEach((group) => {
|
|
||||||
for (let offset = 0; offset < group.indexes.length; offset += squadSize) {
|
|
||||||
const indexes = group.indexes.slice(offset, offset + squadSize);
|
|
||||||
|
|
||||||
if (indexes.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const squad = createAggregateSquad(cell, group.teamKey, indexes, offset / squadSize, job, state);
|
|
||||||
squads.push(squad);
|
|
||||||
cell.squads.push(squad);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return squads;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAggregateSquad(cell, teamKey, indexes, chunkIndex, job, state) {
|
|
||||||
const center = averageAggregatePosition(indexes, job);
|
|
||||||
const count = livingCount(indexes, state);
|
|
||||||
|
|
||||||
return {
|
|
||||||
averageMoveSpeed: averageAggregateMoveSpeed(indexes, job, state),
|
|
||||||
centerX: center.x,
|
|
||||||
centerY: center.y,
|
|
||||||
count,
|
|
||||||
dps: aggregateGroupDamage(indexes, job, state),
|
|
||||||
indexes,
|
|
||||||
radius: resolveAggregateSquadRadius(count, job.config),
|
|
||||||
seed: aggregateSquadSeed(cell.key, teamKey, chunkIndex),
|
|
||||||
teamKey,
|
|
||||||
targetX: null,
|
|
||||||
targetY: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function averageAggregatePosition(indexes, { x, y }) {
|
|
||||||
let totalX = 0;
|
|
||||||
let totalY = 0;
|
|
||||||
|
|
||||||
indexes.forEach((index) => {
|
|
||||||
totalX += x[index];
|
|
||||||
totalY += y[index];
|
|
||||||
});
|
|
||||||
|
|
||||||
const count = Math.max(1, indexes.length);
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: totalX / count,
|
|
||||||
y: totalY / count,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function averageAggregateMoveSpeed(indexes, job, state) {
|
|
||||||
let count = 0;
|
|
||||||
let totalSpeed = 0;
|
|
||||||
|
|
||||||
indexes.forEach((index) => {
|
|
||||||
if (!state.alive[index]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
count += 1;
|
|
||||||
totalSpeed += job.moveSpeed[index];
|
|
||||||
});
|
|
||||||
|
|
||||||
return count > 0 ? totalSpeed / count : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function aggregateGroupDamage(indexes, job, state) {
|
|
||||||
let damage = 0;
|
|
||||||
|
|
||||||
indexes.forEach((index) => {
|
|
||||||
if (!state.alive[index] || job.isFrostStunned[index]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
damage += job.damagePerSecond[index];
|
|
||||||
});
|
|
||||||
|
|
||||||
return damage;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAggregateSquadRadius(count, config) {
|
|
||||||
const spacing = Math.max(1, Number(config.squadSpacing) || 1);
|
|
||||||
|
|
||||||
return Math.max(
|
|
||||||
(Number(config.tileSize) || 64) * 0.42,
|
|
||||||
Math.sqrt(Math.max(1, count)) * spacing,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignAggregateSquadTargets({ squads }, config) {
|
|
||||||
squads.forEach((squad) => {
|
|
||||||
const targetSquad = findNearestAggregateEnemySquad(squads, squad);
|
|
||||||
|
|
||||||
if (!targetSquad) {
|
|
||||||
squad.targetX = null;
|
|
||||||
squad.targetY = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const spread = Math.max(1, Number(config.cellSize) || 1) * 0.18;
|
|
||||||
const offsetX = (((squad.seed % 997) / 997) - 0.5) * spread;
|
|
||||||
const offsetY = ((((squad.seed * 31) % 991) / 991) - 0.5) * spread;
|
|
||||||
|
|
||||||
squad.targetX = clamp(
|
|
||||||
targetSquad.centerX + offsetX,
|
|
||||||
config.halfWidth,
|
|
||||||
config.arenaSize - config.halfWidth,
|
|
||||||
);
|
|
||||||
squad.targetY = clamp(
|
|
||||||
targetSquad.centerY + offsetY,
|
|
||||||
config.halfHeight,
|
|
||||||
config.arenaSize - config.halfHeight,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function findNearestAggregateEnemySquad(squads, sourceSquad) {
|
|
||||||
let nearestSquad = null;
|
|
||||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
||||||
|
|
||||||
squads.forEach((candidateSquad) => {
|
|
||||||
if (
|
|
||||||
candidateSquad === sourceSquad
|
|
||||||
|| candidateSquad.teamKey === sourceSquad.teamKey
|
|
||||||
|| candidateSquad.count <= 0
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deltaX = candidateSquad.centerX - sourceSquad.centerX;
|
|
||||||
const deltaY = candidateSquad.centerY - sourceSquad.centerY;
|
|
||||||
const distance = deltaX * deltaX + deltaY * deltaY;
|
|
||||||
|
|
||||||
if (distance < nearestDistance) {
|
|
||||||
nearestDistance = distance;
|
|
||||||
nearestSquad = candidateSquad;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return nearestSquad;
|
|
||||||
}
|
|
||||||
|
|
||||||
function advanceAggregateSquads(squads, tickDelta, config) {
|
|
||||||
const seconds = Math.max(0, Number(tickDelta) || 0) / 1000;
|
|
||||||
|
|
||||||
if (seconds <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const movementRatio = clamp(Number(config.movementRatio) || 1, 0, 2);
|
|
||||||
|
|
||||||
squads.forEach((squad) => {
|
|
||||||
if (
|
|
||||||
squad.count <= 0
|
|
||||||
|| !Number.isFinite(squad.targetX)
|
|
||||||
|| !Number.isFinite(squad.targetY)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deltaX = squad.targetX - squad.centerX;
|
|
||||||
const deltaY = squad.targetY - squad.centerY;
|
|
||||||
const distance = Math.hypot(deltaX, deltaY);
|
|
||||||
|
|
||||||
if (distance <= 4) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const step = Math.min(distance, squad.averageMoveSpeed * movementRatio * seconds);
|
|
||||||
squad.centerX = clamp(
|
|
||||||
squad.centerX + (deltaX / distance) * step,
|
|
||||||
config.halfWidth,
|
|
||||||
config.arenaSize - config.halfWidth,
|
|
||||||
);
|
|
||||||
squad.centerY = clamp(
|
|
||||||
squad.centerY + (deltaY / distance) * step,
|
|
||||||
config.halfHeight,
|
|
||||||
config.arenaSize - config.halfHeight,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAggregateSquadCombatState(job, cells, state) {
|
|
||||||
const maxDeathsPerCell = Math.max(
|
|
||||||
1,
|
|
||||||
Math.round(Number(job.config.maxDeathsPerCellTick) || 1),
|
|
||||||
);
|
|
||||||
const maxDeathsPerTick = resolveMaxDeathsPerTick(job.config);
|
|
||||||
|
|
||||||
for (let index = 0; index < cells.length && state.deadCount < maxDeathsPerTick; index += 1) {
|
|
||||||
const groups = (cells[index].squads ?? [])
|
|
||||||
.filter((squad) => squad.count > 0 && squadHasLivingMembers(squad, state))
|
|
||||||
.sort((left, right) => right.count - left.count);
|
|
||||||
|
|
||||||
if (groups.length < 2) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const leftGroup = groups[0];
|
|
||||||
const rightGroup = groups.find((candidate) => candidate.teamKey !== leftGroup.teamKey);
|
|
||||||
|
|
||||||
if (!rightGroup) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cellDeathBudget = Math.min(
|
|
||||||
maxDeathsPerCell,
|
|
||||||
maxDeathsPerTick - state.deadCount,
|
|
||||||
);
|
|
||||||
let cellDeaths = 0;
|
|
||||||
|
|
||||||
cellDeaths += applyAggregateDamage(
|
|
||||||
job,
|
|
||||||
state,
|
|
||||||
rightGroup.indexes,
|
|
||||||
leftGroup.indexes,
|
|
||||||
(leftGroup.dps * Math.max(0, Number(job.tickDelta) || 0)) / 1000,
|
|
||||||
cellDeathBudget - cellDeaths,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (cellDeaths < cellDeathBudget) {
|
|
||||||
applyAggregateDamage(
|
|
||||||
job,
|
|
||||||
state,
|
|
||||||
leftGroup.indexes,
|
|
||||||
rightGroup.indexes,
|
|
||||||
(rightGroup.dps * Math.max(0, Number(job.tickDelta) || 0)) / 1000,
|
|
||||||
cellDeathBudget - cellDeaths,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyAggregateDamage(job, state, defenderIndexes, attackerIndexes, damage, maxDeaths) {
|
|
||||||
if (damage <= 0 || maxDeaths <= 0 || defenderIndexes.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startIndex = state.damageCursor % defenderIndexes.length;
|
|
||||||
let remainingDamage = damage;
|
|
||||||
let resolvedDeaths = 0;
|
|
||||||
|
|
||||||
state.damageCursor += 1;
|
|
||||||
|
|
||||||
for (
|
|
||||||
let checked = 0;
|
|
||||||
checked < defenderIndexes.length && remainingDamage > 0 && resolvedDeaths < maxDeaths;
|
|
||||||
checked += 1
|
|
||||||
) {
|
|
||||||
const modelIndex = defenderIndexes[(startIndex + checked) % defenderIndexes.length];
|
|
||||||
|
|
||||||
if (!state.alive[modelIndex]) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentHp = Math.max(0, Number(job.hp[modelIndex]) || 0);
|
|
||||||
|
|
||||||
if (remainingDamage >= currentHp) {
|
|
||||||
remainingDamage -= currentHp;
|
|
||||||
job.hp[modelIndex] = 0;
|
|
||||||
state.alive[modelIndex] = 0;
|
|
||||||
state.deadDefenderIds[state.deadCount] = job.modelIds[modelIndex];
|
|
||||||
state.deadAttackerIds[state.deadCount] = pickAggregateWinnerId(job, state, attackerIndexes);
|
|
||||||
state.deadCount += 1;
|
|
||||||
resolvedDeaths += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
job.hp[modelIndex] = Math.max(1, currentHp - remainingDamage);
|
|
||||||
remainingDamage = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolvedDeaths;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickAggregateWinnerId(job, state, indexes) {
|
|
||||||
if (indexes.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startIndex = state.winnerCursor % indexes.length;
|
|
||||||
|
|
||||||
state.winnerCursor += 1;
|
|
||||||
|
|
||||||
for (let index = 0; index < indexes.length; index += 1) {
|
|
||||||
const modelIndex = indexes[(startIndex + index) % indexes.length];
|
|
||||||
|
|
||||||
if (state.alive[modelIndex] && !job.isFrostStunned[modelIndex]) {
|
|
||||||
return job.modelIds[modelIndex];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncAggregateSquadMembers(job, squads, state) {
|
|
||||||
squads.forEach((squad) => {
|
|
||||||
const livingIndexes = squad.indexes.filter((index) => state.alive[index]);
|
|
||||||
const count = livingIndexes.length;
|
|
||||||
|
|
||||||
squad.count = count;
|
|
||||||
|
|
||||||
livingIndexes.forEach((modelIndex, index) => {
|
|
||||||
const slot = aggregateSquadSlot(squad, index, count);
|
|
||||||
job.x[modelIndex] = clamp(
|
|
||||||
squad.centerX + slot.x,
|
|
||||||
job.config.halfWidth,
|
|
||||||
job.config.arenaSize - job.config.halfWidth,
|
|
||||||
);
|
|
||||||
job.y[modelIndex] = clamp(
|
|
||||||
squad.centerY + slot.y,
|
|
||||||
job.config.halfHeight,
|
|
||||||
job.config.arenaSize - job.config.halfHeight,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function aggregateSquadSlot(squad, index, count) {
|
|
||||||
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
|
|
||||||
const progress = (index + 0.5) / Math.max(1, count);
|
|
||||||
const radius = Math.sqrt(progress) * squad.radius;
|
|
||||||
const angle = squad.seed * 0.017 + index * goldenAngle;
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: Math.cos(angle) * radius,
|
|
||||||
y: Math.sin(angle) * radius,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function livingCount(indexes, state) {
|
|
||||||
let count = 0;
|
|
||||||
|
|
||||||
indexes.forEach((index) => {
|
|
||||||
if (state.alive[index]) {
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
function squadHasLivingMembers(squad, state) {
|
|
||||||
return squad.indexes.some((index) => state.alive[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveMaxDeathsPerTick(config) {
|
|
||||||
return Math.max(
|
|
||||||
Math.max(1, Math.round(Number(config.maxDeathsPerCellTick) || 1)),
|
|
||||||
Math.max(1, Math.round(Number(config.maxDeathsPerTick) || 1)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function aggregateSquadSeed(cellKey, teamKey, chunkIndex) {
|
|
||||||
const id = `${cellKey}:${teamKey}:${chunkIndex}`;
|
|
||||||
let seed = 17;
|
|
||||||
|
|
||||||
for (let index = 0; index < id.length; index += 1) {
|
|
||||||
seed = (seed * 31 + id.charCodeAt(index)) % 104729;
|
|
||||||
}
|
|
||||||
|
|
||||||
return seed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampCell(value, cellSize, maxCell) {
|
|
||||||
return Math.min(maxCell, Math.max(0, Math.floor(value / cellSize)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function targetCellIndex(cellX, cellY, columns) {
|
|
||||||
return cellY * columns + cellX;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(value, min, max) {
|
|
||||||
return Math.min(max, Math.max(min, value));
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,20 +1,14 @@
|
||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import {
|
import {
|
||||||
ARENA,
|
ARENA,
|
||||||
PERFORMANCE,
|
|
||||||
WORLD_EFFECT,
|
WORLD_EFFECT,
|
||||||
} from "../../constants.js";
|
} from "../../constants.js";
|
||||||
import {
|
import {
|
||||||
applyWorldEffectDamage,
|
applyWorldEffectDamage,
|
||||||
disposeCombatObject,
|
disposeCombatObject,
|
||||||
|
isFighterSpecialInvulnerable,
|
||||||
trackCombatObject,
|
trackCombatObject,
|
||||||
} from "./combat.js";
|
} from "./combat.js";
|
||||||
import {
|
|
||||||
clearFighterTint,
|
|
||||||
fighterWorldPoint,
|
|
||||||
stopFighterMovement,
|
|
||||||
tintFighter,
|
|
||||||
} from "../fighter/fighterAdapter.js";
|
|
||||||
|
|
||||||
const METEOR_EFFECT_PATH = "assets/effects/world_Effect.png";
|
const METEOR_EFFECT_PATH = "assets/effects/world_Effect.png";
|
||||||
const METEOR_EFFECT_KEY = "world-meteor-effect";
|
const METEOR_EFFECT_KEY = "world-meteor-effect";
|
||||||
|
|
@ -98,8 +92,6 @@ export function clearWorldEffects(scene) {
|
||||||
scene.clearMeteorCameraFocus?.(null, { restoreCamera: false });
|
scene.clearMeteorCameraFocus?.(null, { restoreCamera: false });
|
||||||
scene.matchStartedAt = null;
|
scene.matchStartedAt = null;
|
||||||
scene.isSuddenDeath = false;
|
scene.isSuddenDeath = false;
|
||||||
scene.nextWorldEffectModifierRefreshAt = 0;
|
|
||||||
scene.worldEffectModifierActive = false;
|
|
||||||
|
|
||||||
scene.fighters?.forEach((fighter) => {
|
scene.fighters?.forEach((fighter) => {
|
||||||
fighter.worldEffectSpeedMultiplier = 1;
|
fighter.worldEffectSpeedMultiplier = 1;
|
||||||
|
|
@ -112,20 +104,6 @@ export function updateWorldEffectModifiers(scene) {
|
||||||
(zone) => zone.marker?.active,
|
(zone) => zone.marker?.active,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (frostZones.length === 0 && !scene.worldEffectModifierActive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = scene.time?.now ?? 0;
|
|
||||||
const refreshMs = Math.max(0, Number(PERFORMANCE.WORLD_EFFECT_MODIFIER_REFRESH_MS) || 0);
|
|
||||||
|
|
||||||
if (frostZones.length > 0 && refreshMs > 0 && now < (scene.nextWorldEffectModifierRefreshAt ?? 0)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scene.nextWorldEffectModifierRefreshAt = now + refreshMs;
|
|
||||||
scene.worldEffectModifierActive = frostZones.length > 0;
|
|
||||||
|
|
||||||
scene.fighters.forEach((fighter) => {
|
scene.fighters.forEach((fighter) => {
|
||||||
const isSlowed =
|
const isSlowed =
|
||||||
fighter.active
|
fighter.active
|
||||||
|
|
@ -174,13 +152,12 @@ export function findDensestWorldEffectZone(livingFighters) {
|
||||||
);
|
);
|
||||||
|
|
||||||
livingFighters.forEach((fighter) => {
|
livingFighters.forEach((fighter) => {
|
||||||
const point = fighterWorldPoint(fighter);
|
const x = fighter.body?.center.x ?? fighter.x;
|
||||||
const x = point.x;
|
const y = fighter.body?.center.y ?? fighter.y;
|
||||||
const y = point.y;
|
|
||||||
const column = Phaser.Math.Clamp(Math.floor(x / ARENA.TILE_SIZE), 0, ARENA.GRID_SIZE - 1);
|
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);
|
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.
|
// Summed-area lookup keeps dense-zone selection cheap even with thousands of fighters.
|
||||||
|
|
@ -219,7 +196,7 @@ function randomEntry(entries) {
|
||||||
function spawnMeteor(scene, zone) {
|
function spawnMeteor(scene, zone) {
|
||||||
spawnWorldEffectBarrage(scene, zone, {
|
spawnWorldEffectBarrage(scene, zone, {
|
||||||
color: METEOR_ZONE_COLOR,
|
color: METEOR_ZONE_COLOR,
|
||||||
damage: WORLD_EFFECT.METEOR_DAMAGE,
|
effectType: "meteor",
|
||||||
effectKey: METEOR_EFFECT_KEY,
|
effectKey: METEOR_EFFECT_KEY,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -227,7 +204,7 @@ function spawnMeteor(scene, zone) {
|
||||||
function spawnFrostZone(scene, zone) {
|
function spawnFrostZone(scene, zone) {
|
||||||
spawnWorldEffectBarrage(scene, zone, {
|
spawnWorldEffectBarrage(scene, zone, {
|
||||||
color: FROST_ZONE_COLOR,
|
color: FROST_ZONE_COLOR,
|
||||||
damage: WORLD_EFFECT.FROST_DAMAGE,
|
effectType: "frost",
|
||||||
effectKey: FROST_EFFECT_KEY,
|
effectKey: FROST_EFFECT_KEY,
|
||||||
isFrost: true,
|
isFrost: true,
|
||||||
});
|
});
|
||||||
|
|
@ -236,7 +213,7 @@ function spawnFrostZone(scene, zone) {
|
||||||
function spawnWorldEffectBarrage(
|
function spawnWorldEffectBarrage(
|
||||||
scene,
|
scene,
|
||||||
targetZone,
|
targetZone,
|
||||||
{ color, damage, effectKey, isFrost = false },
|
{ color, effectType, effectKey, isFrost = false },
|
||||||
) {
|
) {
|
||||||
const matchId = scene.matchId;
|
const matchId = scene.matchId;
|
||||||
const targetMarker = createZoneMarker(scene, targetZone, color);
|
const targetMarker = createZoneMarker(scene, targetZone, color);
|
||||||
|
|
@ -289,7 +266,7 @@ function spawnWorldEffectBarrage(
|
||||||
resolveImpactDamage(
|
resolveImpactDamage(
|
||||||
scene,
|
scene,
|
||||||
impactZone,
|
impactZone,
|
||||||
damage,
|
effectType,
|
||||||
isFrost ? (fighter) => applyFrostStun(scene, fighter) : undefined,
|
isFrost ? (fighter) => applyFrostStun(scene, fighter) : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -549,13 +526,18 @@ function createZoneMarker(scene, zone, color) {
|
||||||
return marker;
|
return marker;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveImpactDamage(scene, zone, damage, onSurvivor) {
|
function resolveImpactDamage(scene, zone, effectType, onSurvivor) {
|
||||||
let deathCount = 0;
|
let deathCount = 0;
|
||||||
|
|
||||||
scene.fighters
|
scene.fighters
|
||||||
.filter((fighter) => fighter.active && !fighter.isDead && containsFighter(zone, fighter))
|
.filter((fighter) =>
|
||||||
|
fighter.active
|
||||||
|
&& !fighter.isDead
|
||||||
|
&& !isFighterSpecialInvulnerable(fighter)
|
||||||
|
&& containsFighter(zone, fighter),
|
||||||
|
)
|
||||||
.forEach((fighter) => {
|
.forEach((fighter) => {
|
||||||
if (applyWorldEffectDamage(scene, fighter, damage)) {
|
if (applyWorldEffectDamage(scene, fighter, effectType)) {
|
||||||
deathCount += 1;
|
deathCount += 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -576,8 +558,8 @@ function applyFrostStun(scene, fighter) {
|
||||||
|
|
||||||
fighter.frostStunTimer?.remove(false);
|
fighter.frostStunTimer?.remove(false);
|
||||||
fighter.isFrostStunned = true;
|
fighter.isFrostStunned = true;
|
||||||
stopFighterMovement(fighter);
|
fighter.body?.setVelocity(0, 0);
|
||||||
tintFighter(fighter, WORLD_EFFECT.FROST_STUN_TINT);
|
fighter.setTint(WORLD_EFFECT.FROST_STUN_TINT);
|
||||||
fighter.frostStunTimer = scene.time.delayedCall(WORLD_EFFECT.FROST_STUN_DURATION, () => {
|
fighter.frostStunTimer = scene.time.delayedCall(WORLD_EFFECT.FROST_STUN_DURATION, () => {
|
||||||
clearFrostStun(fighter);
|
clearFrostStun(fighter);
|
||||||
});
|
});
|
||||||
|
|
@ -588,7 +570,9 @@ function clearFrostStun(fighter) {
|
||||||
fighter.frostStunTimer = null;
|
fighter.frostStunTimer = null;
|
||||||
fighter.isFrostStunned = false;
|
fighter.isFrostStunned = false;
|
||||||
|
|
||||||
clearFighterTint(fighter);
|
if (fighter.active) {
|
||||||
|
fighter.clearTint();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateFrostZone(scene, zone, marker) {
|
function activateFrostZone(scene, zone, marker) {
|
||||||
|
|
@ -622,13 +606,16 @@ function activateFrostZone(scene, zone, marker) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function containsFighter(zone, fighter) {
|
function containsFighter(zone, fighter) {
|
||||||
const point = fighterWorldPoint(fighter);
|
const x = fighter.body?.center.x ?? fighter.x;
|
||||||
const x = point.x;
|
const y = fighter.body?.center.y ?? fighter.y;
|
||||||
const y = point.y;
|
|
||||||
|
|
||||||
return Phaser.Geom.Rectangle.Contains(zone.bounds, x, y);
|
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) {
|
function isLiveMatch(scene, matchId = scene.matchId) {
|
||||||
return !scene.matchOver && !scene.presentationMode && scene.matchId === matchId;
|
return !scene.matchOver && !scene.presentationMode && scene.matchId === matchId;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
import {
|
|
||||||
ARENA,
|
|
||||||
FIGHTER,
|
|
||||||
} from "../../constants.js";
|
|
||||||
import { ensureFighterTeamAnimation } from "./fighterAssets.js";
|
|
||||||
import {
|
|
||||||
fighterModelPoint,
|
|
||||||
fighterModelDistanceSquared,
|
|
||||||
getFighterModel,
|
|
||||||
isLivingFighterModel,
|
|
||||||
setFighterModelPosition,
|
|
||||||
syncFighterModelFromSprite,
|
|
||||||
} from "./fighterModel.js";
|
|
||||||
|
|
||||||
export {
|
|
||||||
fighterModelDistanceSquared,
|
|
||||||
fighterModelPoint,
|
|
||||||
getFighterModel,
|
|
||||||
isLivingFighterModel,
|
|
||||||
syncFighterModelFromSprite,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function isFighterBodyEnabled(fighter) {
|
|
||||||
return Boolean(!fighter?._spriteDetached && fighter?.body && fighter.body.enable !== false);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldRenderFighterDetail(fighter) {
|
|
||||||
return Boolean(
|
|
||||||
fighter
|
|
||||||
&& fighter.active !== false
|
|
||||||
&& !fighter._spriteDetached
|
|
||||||
&& fighter._detailVisible !== false
|
|
||||||
&& fighter.visible !== false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fighterWorldPoint(fighter) {
|
|
||||||
if (isFighterBodyEnabled(fighter)) {
|
|
||||||
return {
|
|
||||||
x: fighter.body.center.x,
|
|
||||||
y: fighter.body.center.y,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return fighterModelPoint(fighter);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fighterDistanceSquared(left, right) {
|
|
||||||
const deltaX = fighterWorldX(left) - fighterWorldX(right);
|
|
||||||
const deltaY = fighterWorldY(left) - fighterWorldY(right);
|
|
||||||
|
|
||||||
return deltaX * deltaX + deltaY * deltaY;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setFighterFacing(fighter, faceLeft) {
|
|
||||||
if (!fighter) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fighter.facingLeft = Boolean(faceLeft);
|
|
||||||
|
|
||||||
const model = getFighterModel(fighter);
|
|
||||||
if (model) {
|
|
||||||
model.facingLeft = fighter.facingLeft;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof fighter.setFlipX === "function") {
|
|
||||||
fighter.setFlipX(fighter.facingLeft);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fighter.flipX = fighter.facingLeft;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopFighterMovement(fighter) {
|
|
||||||
if (isFighterBodyEnabled(fighter)) {
|
|
||||||
fighter.body.setVelocity(0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function disableFighterBody(fighter) {
|
|
||||||
syncFighterModelFromSprite(fighter);
|
|
||||||
stopFighterMovement(fighter);
|
|
||||||
fighter?.body?.stop?.();
|
|
||||||
|
|
||||||
if (fighter?.body) {
|
|
||||||
const world = fighter.scene?.physics?.world;
|
|
||||||
|
|
||||||
if (world) {
|
|
||||||
world.disable(fighter);
|
|
||||||
} else {
|
|
||||||
fighter.body.enable = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function enableFighterBody(fighter) {
|
|
||||||
if (!fighter?.body || fighter.isDead) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const point = fighterModelPoint(fighter);
|
|
||||||
const world = fighter.scene?.physics?.world;
|
|
||||||
|
|
||||||
if (world) {
|
|
||||||
world.enable(fighter);
|
|
||||||
} else {
|
|
||||||
fighter.body.enable = true;
|
|
||||||
}
|
|
||||||
fighter.body.reset?.(point.x, point.y);
|
|
||||||
stopFighterMovement(fighter);
|
|
||||||
fighter.body.updateFromGameObject?.();
|
|
||||||
syncFighterModelFromSprite(fighter);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setFighterWorldPosition(fighter, x, y) {
|
|
||||||
if (!fighter) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fighter._spriteDetached && typeof fighter.setPosition === "function") {
|
|
||||||
fighter.setPosition(x, y);
|
|
||||||
} else if (!fighter._spriteDetached) {
|
|
||||||
fighter.x = x;
|
|
||||||
fighter.y = y;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFighterModelPosition(fighter, x, y);
|
|
||||||
|
|
||||||
if (isFighterBodyEnabled(fighter)) {
|
|
||||||
fighter.body.updateFromGameObject?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moveFighterToward(scene, fighter, target, speed, delta) {
|
|
||||||
if (!fighter?.active || !target) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetPoint = fighterWorldPoint(target);
|
|
||||||
|
|
||||||
if (isFighterBodyEnabled(fighter)) {
|
|
||||||
scene.physics.moveTo(fighter, targetPoint.x, targetPoint.y, speed);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourcePoint = fighterModelPoint(fighter);
|
|
||||||
const deltaX = targetPoint.x - sourcePoint.x;
|
|
||||||
const deltaY = targetPoint.y - sourcePoint.y;
|
|
||||||
const distance = Math.hypot(deltaX, deltaY);
|
|
||||||
|
|
||||||
if (distance <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const step = Math.min(
|
|
||||||
distance,
|
|
||||||
speed * (Math.max(0, Number(delta) || 0) / 1000),
|
|
||||||
);
|
|
||||||
|
|
||||||
setFighterWorldPosition(
|
|
||||||
fighter,
|
|
||||||
sourcePoint.x + (deltaX / distance) * step,
|
|
||||||
sourcePoint.y + (deltaY / distance) * step,
|
|
||||||
);
|
|
||||||
clampFighterInsideArena(fighter);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clampFighterInsideArena(fighter) {
|
|
||||||
if (!fighter?.active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bodyWidth = fighter.body?.width ?? FIGHTER.HITBOX_WIDTH;
|
|
||||||
const bodyHeight = fighter.body?.height ?? FIGHTER.HITBOX_HEIGHT;
|
|
||||||
const halfWidth = Math.min(
|
|
||||||
ARENA.SIZE / 2,
|
|
||||||
Math.max(Math.abs(fighter.displayWidth ?? 0), bodyWidth) / 2,
|
|
||||||
);
|
|
||||||
const halfHeight = Math.min(
|
|
||||||
ARENA.SIZE / 2,
|
|
||||||
Math.max(Math.abs(fighter.displayHeight ?? 0), bodyHeight) / 2,
|
|
||||||
);
|
|
||||||
const point = fighterModelPoint(fighter);
|
|
||||||
const x = clamp(point.x, halfWidth, ARENA.SIZE - halfWidth);
|
|
||||||
const y = clamp(point.y, halfHeight, ARENA.SIZE - halfHeight);
|
|
||||||
|
|
||||||
setFighterWorldPosition(fighter, x, y);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function playFighterActionIfNeeded(fighter, action) {
|
|
||||||
if (!shouldRenderFighterDetail(fighter)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = resolveFighterAnimationKey(fighter, action);
|
|
||||||
|
|
||||||
if (key && fighter.anims?.currentAnim?.key !== key) {
|
|
||||||
playResolvedFighterAnimation(fighter, key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function playFighterAction(fighter, action, timeScale = 1) {
|
|
||||||
if (!shouldRenderFighterDetail(fighter)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = resolveFighterAnimationKey(fighter, action);
|
|
||||||
|
|
||||||
if (key) {
|
|
||||||
playResolvedFighterAnimation(fighter, key, timeScale);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tintFighter(fighter, tint) {
|
|
||||||
if (typeof fighter?.setTint === "function") {
|
|
||||||
fighter.setTint(tint);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearFighterTint(fighter) {
|
|
||||||
if (fighter?.active && typeof fighter.clearTint === "function") {
|
|
||||||
fighter.clearTint();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function playResolvedFighterAnimation(fighter, key, timeScale = 1) {
|
|
||||||
if (!fighter?.anims || typeof fighter.play !== "function") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fighter.anims.timeScale = timeScale;
|
|
||||||
fighter.play(key, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveFighterAnimationKey(fighter, action) {
|
|
||||||
if (!fighter?.scene || !fighter?.skin || !action) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ensureFighterTeamAnimation(
|
|
||||||
fighter.scene,
|
|
||||||
fighter.skin,
|
|
||||||
action,
|
|
||||||
fighter.team?.color,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(value, minimum, maximum) {
|
|
||||||
return Math.min(maximum, Math.max(minimum, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function fighterWorldX(fighter) {
|
|
||||||
if (isFighterBodyEnabled(fighter)) {
|
|
||||||
return fighter.body.center.x;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fighterModelPoint(fighter).x;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fighterWorldY(fighter) {
|
|
||||||
if (isFighterBodyEnabled(fighter)) {
|
|
||||||
return fighter.body.center.y;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fighterModelPoint(fighter).y;
|
|
||||||
}
|
|
||||||
|
|
@ -8,23 +8,26 @@ import {
|
||||||
ensureFighterTeamAnimations,
|
ensureFighterTeamAnimations,
|
||||||
fighterSheetKey,
|
fighterSheetKey,
|
||||||
} from "./fighterAssets.js";
|
} from "./fighterAssets.js";
|
||||||
import {
|
|
||||||
disableFighterBody,
|
|
||||||
enableFighterBody,
|
|
||||||
} from "./fighterAdapter.js";
|
|
||||||
import {
|
|
||||||
attachFighterModel,
|
|
||||||
createFighterModel,
|
|
||||||
fighterModelPoint,
|
|
||||||
} from "./fighterModel.js";
|
|
||||||
import { getFighterStats } from "./fighterStats.js";
|
import { getFighterStats } from "./fighterStats.js";
|
||||||
|
|
||||||
const HUD_DETAIL_SYNC_INTERVAL_MS = 100;
|
const HUD_DETAIL_SYNC_INTERVAL_MS = 100;
|
||||||
|
|
||||||
export function createFighter(
|
export function createFighter(
|
||||||
scene,
|
scene,
|
||||||
{ canSplitOnDeath = true, faceLeft, hp, maxHp, name, skin, team, teamIndex, x, y },
|
{
|
||||||
{ attachSprite = true } = {},
|
canSplitOnDeath = true,
|
||||||
|
faceLeft,
|
||||||
|
hp,
|
||||||
|
isElite = false,
|
||||||
|
maxHp,
|
||||||
|
name,
|
||||||
|
skin,
|
||||||
|
stackCount = 1,
|
||||||
|
team,
|
||||||
|
teamIndex,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
ensureFighterTeamAnimations(scene, skin, team.color, ["idle"]);
|
ensureFighterTeamAnimations(scene, skin, team.color, ["idle"]);
|
||||||
|
|
||||||
|
|
@ -32,41 +35,42 @@ export function createFighter(
|
||||||
const idleSheetKey = scene.textures.exists(teamIdleSheetKey)
|
const idleSheetKey = scene.textures.exists(teamIdleSheetKey)
|
||||||
? teamIdleSheetKey
|
? teamIdleSheetKey
|
||||||
: fighterSheetKey(skin, "idle");
|
: fighterSheetKey(skin, "idle");
|
||||||
|
const fighter = scene.physics.add.sprite(x, y, idleSheetKey, 0);
|
||||||
const displayName = name || team.label;
|
const displayName = name || team.label;
|
||||||
const combatStats = getFighterStats(skin);
|
const baseCombatStats = getFighterStats(skin);
|
||||||
const resolvedMaxHp = Math.max(1, Math.round(maxHp ?? combatStats.maxHp));
|
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(
|
const resolvedHp = Math.min(
|
||||||
resolvedMaxHp,
|
resolvedMaxHp,
|
||||||
Math.max(1, Math.round(hp ?? resolvedMaxHp)),
|
Math.max(1, Math.round(hp ?? resolvedMaxHp)),
|
||||||
);
|
);
|
||||||
const fighter = scene.physics.add.sprite(x, y, idleSheetKey, 0);
|
|
||||||
const inputHitArea = new Phaser.Geom.Rectangle(
|
|
||||||
FIGHTER.HITBOX_OFFSET_X,
|
|
||||||
FIGHTER.HITBOX_OFFSET_Y,
|
|
||||||
FIGHTER.HITBOX_WIDTH,
|
|
||||||
FIGHTER.HITBOX_HEIGHT,
|
|
||||||
);
|
|
||||||
|
|
||||||
fighter._spriteDetached = false;
|
fighter.setScale(visualScale);
|
||||||
fighter._detailVisible = true;
|
|
||||||
attachFighterModel(
|
|
||||||
fighter,
|
|
||||||
createFighterModel({
|
|
||||||
canSplitOnDeath,
|
|
||||||
combatStats,
|
|
||||||
facingLeft: faceLeft,
|
|
||||||
fighterName: displayName,
|
|
||||||
hp: resolvedHp,
|
|
||||||
maxHp: resolvedMaxHp,
|
|
||||||
skin,
|
|
||||||
team,
|
|
||||||
teamIndex,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
fighter.setScale(FIGHTER.SCALE);
|
|
||||||
fighter.setName(displayName);
|
fighter.setName(displayName);
|
||||||
fighter.setDepth(FIGHTER.DEPTH);
|
fighter.setDepth(FIGHTER.DEPTH);
|
||||||
fighter.setAlpha(1);
|
fighter.setAlpha(1);
|
||||||
|
|
@ -74,19 +78,43 @@ export function createFighter(
|
||||||
fighter.setFlipX(faceLeft);
|
fighter.setFlipX(faceLeft);
|
||||||
fighter.body.setSize(FIGHTER.HITBOX_WIDTH, FIGHTER.HITBOX_HEIGHT);
|
fighter.body.setSize(FIGHTER.HITBOX_WIDTH, FIGHTER.HITBOX_HEIGHT);
|
||||||
fighter.body.setOffset(FIGHTER.HITBOX_OFFSET_X, FIGHTER.HITBOX_OFFSET_Y);
|
fighter.body.setOffset(FIGHTER.HITBOX_OFFSET_X, FIGHTER.HITBOX_OFFSET_Y);
|
||||||
fighter.setInteractive(inputHitArea, Phaser.Geom.Rectangle.Contains);
|
fighter.setInteractive(
|
||||||
|
new Phaser.Geom.Rectangle(
|
||||||
|
FIGHTER.HITBOX_OFFSET_X,
|
||||||
|
FIGHTER.HITBOX_OFFSET_Y,
|
||||||
|
FIGHTER.HITBOX_WIDTH,
|
||||||
|
FIGHTER.HITBOX_HEIGHT,
|
||||||
|
),
|
||||||
|
Phaser.Geom.Rectangle.Contains,
|
||||||
|
);
|
||||||
fighter.input.cursor = "pointer";
|
fighter.input.cursor = "pointer";
|
||||||
|
|
||||||
fighter._inputHitArea = inputHitArea;
|
fighter.skin = skin;
|
||||||
fighter.baseScaleX = FIGHTER.SCALE;
|
fighter.combatStats = combatStats;
|
||||||
fighter.baseScaleY = FIGHTER.SCALE;
|
fighter.fighterName = displayName;
|
||||||
fighter.deadDespawnTimer = null;
|
fighter.isElite = resolvedIsElite;
|
||||||
fighter.deadDespawnTween = null;
|
fighter.stackCount = resolvedStackCount;
|
||||||
|
fighter.team = team;
|
||||||
|
fighter.teamIndex = teamIndex;
|
||||||
|
fighter.baseScaleX = visualScale;
|
||||||
|
fighter.baseScaleY = visualScale;
|
||||||
|
fighter.canSplitOnDeath = canSplitOnDeath && !resolvedIsElite;
|
||||||
|
fighter.isSelected = false;
|
||||||
|
fighter.killCount = 0;
|
||||||
|
fighter.killRewardMultiplier = 1;
|
||||||
|
fighter.worldEffectSpeedMultiplier = 1;
|
||||||
|
fighter.isFrostStunned = false;
|
||||||
fighter.frostStunTimer = null;
|
fighter.frostStunTimer = null;
|
||||||
|
fighter.maxHp = resolvedMaxHp;
|
||||||
|
fighter.hp = resolvedHp;
|
||||||
|
fighter.nextAttackAt = 0;
|
||||||
fighter.nextHudSyncAt = 0;
|
fighter.nextHudSyncAt = 0;
|
||||||
|
fighter.nextTargetScanAt = 0;
|
||||||
|
fighter.targetEnemy = null;
|
||||||
fighter._hudDetailsVisible = false;
|
fighter._hudDetailsVisible = false;
|
||||||
fighter._hudSlot = null;
|
fighter._hudSlot = null;
|
||||||
fighter.releaseHud = () => releaseFighterHud(fighter);
|
fighter.isLocked = false;
|
||||||
|
fighter.isDead = false;
|
||||||
fighter.play(ensureFighterTeamAnimation(scene, skin, "walk", team.color));
|
fighter.play(ensureFighterTeamAnimation(scene, skin, "walk", team.color));
|
||||||
|
|
||||||
fighter.on(Phaser.Animations.Events.ANIMATION_COMPLETE, (animation) => {
|
fighter.on(Phaser.Animations.Events.ANIMATION_COMPLETE, (animation) => {
|
||||||
|
|
@ -99,71 +127,22 @@ export function createFighter(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!attachSprite) {
|
fighter.releaseHud = () => releaseFighterHud(fighter);
|
||||||
setFighterDetailVisible(fighter, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
attachHudCleanup(fighter);
|
attachHudCleanup(fighter);
|
||||||
|
|
||||||
return fighter;
|
return fighter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setFighterDetailVisible(fighter, visible) {
|
function eliteBonusMultiplier(stackCount, bonusMultiplier, stackExponent) {
|
||||||
if (!fighter || !fighter.scene) {
|
const stackedMultiplier = Math.pow(stackCount, stackExponent);
|
||||||
return;
|
return 1 + bonusMultiplier * (stackedMultiplier - 1);
|
||||||
}
|
|
||||||
|
|
||||||
const shouldShow = Boolean(visible && !fighter.isDead);
|
|
||||||
|
|
||||||
if (shouldShow && fighter._detailVisible === true && !fighter._spriteDetached) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldShow && fighter._detailVisible === false && fighter._spriteDetached) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fighter._detailVisible = shouldShow;
|
|
||||||
|
|
||||||
if (!shouldShow) {
|
|
||||||
fighter.isLocked = false;
|
|
||||||
disableFighterBody(fighter);
|
|
||||||
fighter.anims?.pause();
|
|
||||||
fighter.disableInteractive?.();
|
|
||||||
releaseFighterHud(fighter);
|
|
||||||
fighter.setVisible(false);
|
|
||||||
fighter.removeFromDisplayList?.();
|
|
||||||
fighter.removeFromUpdateList?.();
|
|
||||||
fighter._spriteDetached = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const point = fighterModelPoint(fighter);
|
|
||||||
|
|
||||||
fighter.setActive(true);
|
|
||||||
fighter.setPosition(point.x, point.y);
|
|
||||||
fighter.addToDisplayList?.();
|
|
||||||
fighter.addToUpdateList?.();
|
|
||||||
fighter.setVisible(true);
|
|
||||||
fighter._spriteDetached = false;
|
|
||||||
enableFighterBody(fighter);
|
|
||||||
|
|
||||||
fighter.setInteractive?.(fighter._inputHitArea, Phaser.Geom.Rectangle.Contains);
|
|
||||||
|
|
||||||
if (fighter.input) {
|
|
||||||
fighter.input.cursor = "pointer";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fighter.isDead) {
|
|
||||||
fighter.anims?.resume();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncFighterHud(
|
export function syncFighterHud(
|
||||||
fighter,
|
fighter,
|
||||||
{ force = false, showDetails = true, time = fighter.scene?.time?.now ?? 0 } = {},
|
{ force = false, showDetails = true, time = fighter.scene?.time?.now ?? 0 } = {},
|
||||||
) {
|
) {
|
||||||
const isVisible = Boolean(fighter.active && fighter.visible && !fighter.isDead);
|
const isVisible = Boolean(fighter.active && !fighter.isDead);
|
||||||
const detailsVisible = isVisible && (showDetails || fighter.isSelected);
|
const detailsVisible = isVisible && (showDetails || fighter.isSelected);
|
||||||
|
|
||||||
if (!detailsVisible || !fighter.body) {
|
if (!detailsVisible || !fighter.body) {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export const fighterManifest = [
|
||||||
walk: animation("Knight-Walk.png", 8),
|
walk: animation("Knight-Walk.png", 8),
|
||||||
attack: animation("Knight-Attack01.png", 7),
|
attack: animation("Knight-Attack01.png", 7),
|
||||||
attack02: animation("Knight-Attack02.png", 10),
|
attack02: animation("Knight-Attack02.png", 10),
|
||||||
attack03: animation("Knight-Attack03.png", 11),
|
// attack03: animation("Knight-Attack03.png", 11),
|
||||||
block: animation("Knight-Block.png", 4),
|
block: animation("Knight-Block.png", 4),
|
||||||
hurt: animation("Knight-Hurt.png", 4),
|
hurt: animation("Knight-Hurt.png", 4),
|
||||||
death: animation("Knight-Death.png", 4),
|
death: animation("Knight-Death.png", 4),
|
||||||
|
|
@ -154,7 +154,7 @@ export const fighterManifest = [
|
||||||
walk02: animation("Lancer-Walk02.png", 8),
|
walk02: animation("Lancer-Walk02.png", 8),
|
||||||
attack: animation("Lancer-Attack01.png", 6),
|
attack: animation("Lancer-Attack01.png", 6),
|
||||||
attack02: animation("Lancer-Attack02.png", 9),
|
attack02: animation("Lancer-Attack02.png", 9),
|
||||||
attack03: animation("Lancer-Attack03.png", 8),
|
// attack03: animation("Lancer-Attack03.png", 8),
|
||||||
hurt: animation("Lancer-Hurt.png", 4),
|
hurt: animation("Lancer-Hurt.png", 4),
|
||||||
death: animation("Lancer-Death.png", 4),
|
death: animation("Lancer-Death.png", 4),
|
||||||
},
|
},
|
||||||
|
|
@ -190,7 +190,7 @@ export const fighterManifest = [
|
||||||
animations: {
|
animations: {
|
||||||
idle: animation("Priest-Idle.png", 6),
|
idle: animation("Priest-Idle.png", 6),
|
||||||
walk: animation("Priest-Walk.png", 8),
|
walk: animation("Priest-Walk.png", 8),
|
||||||
attack: animation("Priest-Attack.png", 9),
|
attack: animation("Priest-Heal.png", 6),
|
||||||
heal: animation("Priest-Heal.png", 6),
|
heal: animation("Priest-Heal.png", 6),
|
||||||
hurt: animation("Priest-Hurt.png", 4),
|
hurt: animation("Priest-Hurt.png", 4),
|
||||||
death: animation("Priest-Death.png", 4),
|
death: animation("Priest-Death.png", 4),
|
||||||
|
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
const FIGHTER_MODEL_PROPERTY_MAP = {
|
|
||||||
_detailVisible: "detailVisible",
|
|
||||||
canSplitOnDeath: "canSplitOnDeath",
|
|
||||||
combatStats: "combatStats",
|
|
||||||
facingLeft: "facingLeft",
|
|
||||||
fighterName: "fighterName",
|
|
||||||
hp: "hp",
|
|
||||||
isDead: "isDead",
|
|
||||||
isFrostStunned: "isFrostStunned",
|
|
||||||
isLocked: "isLocked",
|
|
||||||
isSelected: "isSelected",
|
|
||||||
killCount: "killCount",
|
|
||||||
killRewardMultiplier: "killRewardMultiplier",
|
|
||||||
maxHp: "maxHp",
|
|
||||||
nextAttackAt: "nextAttackAt",
|
|
||||||
nextTargetScanAt: "nextTargetScanAt",
|
|
||||||
skin: "skin",
|
|
||||||
targetModelId: "targetModelId",
|
|
||||||
team: "team",
|
|
||||||
teamIndex: "teamIndex",
|
|
||||||
worldEffectSpeedMultiplier: "worldEffectSpeedMultiplier",
|
|
||||||
};
|
|
||||||
|
|
||||||
let nextFighterModelId = 1;
|
|
||||||
|
|
||||||
export function createFighterModel({
|
|
||||||
canSplitOnDeath = true,
|
|
||||||
combatStats,
|
|
||||||
facingLeft = false,
|
|
||||||
fighterName,
|
|
||||||
hp,
|
|
||||||
maxHp,
|
|
||||||
skin,
|
|
||||||
team,
|
|
||||||
teamIndex,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
}) {
|
|
||||||
const id = `fighter-${nextFighterModelId}`;
|
|
||||||
nextFighterModelId += 1;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
active: true,
|
|
||||||
canSplitOnDeath,
|
|
||||||
combatStats,
|
|
||||||
detailVisible: true,
|
|
||||||
facingLeft: Boolean(facingLeft),
|
|
||||||
fighterName,
|
|
||||||
hp,
|
|
||||||
isDead: false,
|
|
||||||
isFrostStunned: false,
|
|
||||||
isLocked: false,
|
|
||||||
isSelected: false,
|
|
||||||
killCount: 0,
|
|
||||||
killRewardMultiplier: 1,
|
|
||||||
maxHp,
|
|
||||||
nextAttackAt: 0,
|
|
||||||
nextTargetScanAt: 0,
|
|
||||||
skin,
|
|
||||||
targetModelId: null,
|
|
||||||
team,
|
|
||||||
teamIndex,
|
|
||||||
worldEffectSpeedMultiplier: 1,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function attachFighterModel(fighter, model) {
|
|
||||||
if (!fighter || !model) {
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.defineProperty(fighter, "model", {
|
|
||||||
configurable: true,
|
|
||||||
value: model,
|
|
||||||
});
|
|
||||||
Object.defineProperty(fighter, "modelId", {
|
|
||||||
configurable: true,
|
|
||||||
get() {
|
|
||||||
return this.model?.id;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.entries(FIGHTER_MODEL_PROPERTY_MAP).forEach(([fighterKey, modelKey]) => {
|
|
||||||
Object.defineProperty(fighter, fighterKey, {
|
|
||||||
configurable: true,
|
|
||||||
get() {
|
|
||||||
return this.model?.[modelKey];
|
|
||||||
},
|
|
||||||
set(value) {
|
|
||||||
if (this.model) {
|
|
||||||
this.model[modelKey] = value;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
syncFighterModelFromSprite(fighter);
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFighterModel(fighter) {
|
|
||||||
return fighter?.model ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fighterModelPoint(fighter) {
|
|
||||||
const model = getFighterModel(fighter);
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: model?.x ?? fighter?.x ?? 0,
|
|
||||||
y: model?.y ?? fighter?.y ?? 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setFighterModelPosition(fighter, x, y) {
|
|
||||||
const model = getFighterModel(fighter);
|
|
||||||
|
|
||||||
if (!model) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
model.x = x;
|
|
||||||
model.y = y;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fighterModelDistanceSquared(left, right) {
|
|
||||||
const deltaX = (left?.x ?? 0) - (right?.x ?? 0);
|
|
||||||
const deltaY = (left?.y ?? 0) - (right?.y ?? 0);
|
|
||||||
|
|
||||||
return deltaX * deltaX + deltaY * deltaY;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLivingFighterModel(model) {
|
|
||||||
return Boolean(model && model.active !== false && !model.isDead);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function syncFighterModelFromSprite(fighter) {
|
|
||||||
const model = getFighterModel(fighter);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!model
|
|
||||||
|| fighter?._spriteDetached
|
|
||||||
|| !Number.isFinite(fighter?.x)
|
|
||||||
|| !Number.isFinite(fighter?.y)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
model.x = fighter.x;
|
|
||||||
model.y = fighter.y;
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
|
import { FIGHTER } from "../../constants.js";
|
||||||
|
import { getFighterType } from "./fighterStats.js";
|
||||||
|
|
||||||
export function pickUniqueFighters(fighters, count) {
|
export function pickUniqueFighters(fighters, count) {
|
||||||
if (count > fighters.length) {
|
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);
|
return shuffleFighters(fighters).slice(0, count);
|
||||||
|
|
@ -20,6 +25,34 @@ export function pickFighters(fighters, count) {
|
||||||
return picks;
|
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) =>
|
||||||
|
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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function shuffleFighters(fighters) {
|
||||||
const pool = [...fighters];
|
const pool = [...fighters];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
||||||
export function createFighterPlans(fighterSetups, skins, { expandSpawnMultipliers = true } = {}) {
|
export function createFighterPlans(fighterSetups, skins, { expandSpawnMultipliers = true } = {}) {
|
||||||
return fighterSetups.flatMap((fighterSetup, index) => {
|
return fighterSetups.flatMap((fighterSetup, index) => {
|
||||||
const skin = skins[index];
|
const skin = skins[index];
|
||||||
const spawnMultiplier = expandSpawnMultipliers
|
const spawnMultiplier = expandSpawnMultipliers && !fighterSetup.isElite
|
||||||
? Math.max(1, Math.round(skin.traits?.spawnMultiplier ?? 1))
|
? Math.max(1, Math.round(skin.traits?.spawnMultiplier ?? 1))
|
||||||
: 1;
|
: 1;
|
||||||
|
|
||||||
|
|
@ -49,6 +49,12 @@ export function clampInsideArena(value) {
|
||||||
|
|
||||||
export function syncTeamSizes(teams, fighterPlans) {
|
export function syncTeamSizes(teams, fighterPlans) {
|
||||||
teams.forEach((team) => {
|
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, PERFORMANCE, SPAWN, TEAM } from "../../constants.js";
|
||||||
|
|
||||||
const NAME_MULTIPLIER_REGEX = /\*(\d+)$/;
|
const NAME_MULTIPLIER_REGEX = /\*(\d+)$/;
|
||||||
|
|
||||||
|
|
@ -37,23 +37,229 @@ export function createMatchSetup(
|
||||||
startingZones,
|
startingZones,
|
||||||
);
|
);
|
||||||
|
|
||||||
const fighters = [];
|
const teamRosters = [];
|
||||||
|
let spawnOffset = 0;
|
||||||
|
|
||||||
teams.forEach((team) => {
|
teams.forEach((team) => {
|
||||||
for (let i = 0; i < team.size; i++) {
|
const teamRoster = usesRandomizedEliteCompression(team)
|
||||||
const globalIndex = fighters.length;
|
? createRandomizedEliteRoster(team, spawns, spawnOffset, totalFighters)
|
||||||
fighters.push({
|
: createFixedEliteRoster(team, spawns, spawnOffset);
|
||||||
...spawns[globalIndex],
|
|
||||||
name: team.label,
|
teamRosters.push(teamRoster);
|
||||||
team: team,
|
|
||||||
teamIndex: i,
|
spawnOffset += team.size;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
enforceRenderedFighterLimit(teamRosters, totalFighters);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fighters: teamRosters.flatMap(materializeEliteRoster),
|
||||||
|
startingZones,
|
||||||
|
teams,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return {
|
||||||
|
blocks: Array.from({ length: eliteCount }, (_, index) => ({
|
||||||
|
isElite: true,
|
||||||
|
startIndex: index * FIGHTER.ELITE.STACK_SIZE,
|
||||||
|
stackCount: FIGHTER.ELITE.STACK_SIZE,
|
||||||
|
})),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 resolveEliteBlockProbability(totalFighters) {
|
||||||
|
const {
|
||||||
|
ELITE_BLOCK_PROBABILITY,
|
||||||
|
LARGE_BATTLE_ELITE_BLOCK_PROBABILITY,
|
||||||
|
} = FIGHTER.ELITE.RANDOMIZED_COMPRESSION;
|
||||||
|
|
||||||
|
const baseProbability = clampProbability(ELITE_BLOCK_PROBABILITY);
|
||||||
|
|
||||||
|
if (totalFighters <= PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD) {
|
||||||
|
return baseProbability;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }) {
|
||||||
return {
|
return {
|
||||||
fighters,
|
...spawn,
|
||||||
startingZones,
|
isElite: true,
|
||||||
teams,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
11
src/main.js
11
src/main.js
|
|
@ -181,12 +181,15 @@ const arenaScene = new ArenaScene({
|
||||||
const game = new Phaser.Game({
|
const game = new Phaser.Game({
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
parent: "game",
|
parent: "game",
|
||||||
width: RENDER.WIDTH,
|
width: RENDER.VIEWPORT_SIZE,
|
||||||
height: RENDER.HEIGHT,
|
height: RENDER.VIEWPORT_SIZE,
|
||||||
autoRound: true,
|
|
||||||
pixelArt: true,
|
pixelArt: true,
|
||||||
powerPreference: "high-performance",
|
|
||||||
backgroundColor: "#282819",
|
backgroundColor: "#282819",
|
||||||
|
render: {
|
||||||
|
antialias: false,
|
||||||
|
pixelArt: true,
|
||||||
|
roundPixels: true,
|
||||||
|
},
|
||||||
physics: {
|
physics: {
|
||||||
default: "arcade",
|
default: "arcade",
|
||||||
arcade: {
|
arcade: {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,15 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes battle-notice-roll {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(calc(-1 * (var(--battle-notice-message-width) + var(--battle-notice-roll-gap))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes kill-log-entry {
|
@keyframes kill-log-entry {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgb(238 185 73 / 0.3) transparent;
|
scrollbar-color: rgb(238 185 73 / 0.3) transparent;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid rgb(238 185 73 / 0.18);
|
border: 1px solid rgb(238 185 73 / 0.12);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgb(4 6 4 / 0.5);
|
background: rgb(4 6 4 / 0.46);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translateY(-18px);
|
transform: translateY(-18px);
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
|
|
||||||
.score-side {
|
.score-side {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 114px);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
@ -56,33 +56,54 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-score {
|
.team-score {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 1fr 1px auto;
|
grid-template-rows: 1fr 1px auto;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
width: 114px;
|
width: 100%;
|
||||||
min-height: 72px;
|
min-height: 72px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border: 1px solid rgb(255 244 209 / 0.08);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px 9px;
|
padding: 8px 7px;
|
||||||
|
background: rgb(8 10 7 / 0.58);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
text-shadow: 1px 1px 2px #000;
|
text-shadow: 0 1px 1px rgb(0 0 0 / 0.78);
|
||||||
transition:
|
transition:
|
||||||
filter 160ms ease,
|
background-color 160ms ease,
|
||||||
transform 160ms ease;
|
border-color 160ms ease,
|
||||||
|
filter 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 9px;
|
||||||
|
left: 8px;
|
||||||
|
width: 10px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--team-color);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgb(0 0 0 / 0.72),
|
||||||
|
inset 0 -1px 0 rgb(0 0 0 / 0.28);
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: skewX(-14deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-score:hover {
|
.team-score:hover {
|
||||||
filter: brightness(1.16);
|
border-color: rgb(255 244 209 / 0.16);
|
||||||
transform: translateY(-1px);
|
background: rgb(12 14 10 / 0.68);
|
||||||
|
filter: brightness(1.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-score.is-focused {
|
.team-score.is-focused {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 0 0 2px rgb(255 244 209 / 0.92),
|
inset 0 0 0 1px rgb(255 244 209 / 0.72),
|
||||||
0 0 18px rgb(227 178 79 / 0.26);
|
inset 0 0 0 999px rgb(255 244 209 / 0.035);
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-score:disabled {
|
.team-score:disabled {
|
||||||
|
|
@ -91,7 +112,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-score:disabled:hover {
|
.team-score:disabled:hover {
|
||||||
transform: none;
|
background: rgb(8 10 7 / 0.58);
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-score-name {
|
.team-score-name {
|
||||||
|
|
@ -99,6 +120,7 @@
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding-left: 16px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
|
|
@ -106,14 +128,19 @@
|
||||||
|
|
||||||
.team-score-rule {
|
.team-score-rule {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--team-color);
|
background: linear-gradient(
|
||||||
opacity: 0.9;
|
90deg,
|
||||||
|
var(--team-color) 0 34%,
|
||||||
|
rgb(255 244 209 / 0.1) 34% 100%
|
||||||
|
);
|
||||||
|
opacity: 0.68;
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-score-count {
|
.team-score-count {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
color: #fff2c8;
|
color: #ead9b3;
|
||||||
font-size: 0.86rem;
|
font-size: 0.68rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.battle-notice {
|
.battle-notice {
|
||||||
|
|
@ -138,13 +165,51 @@
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-shadow: 1px 1px 2px #000;
|
text-shadow: 1px 1px 2px #000;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translate(-50%, -10px);
|
transform: translate(-50%, -10px);
|
||||||
transition:
|
transition:
|
||||||
opacity 260ms ease,
|
opacity 260ms ease,
|
||||||
transform 260ms ease;
|
transform 260ms ease;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(7px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-notice-message {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-notice.is-rolling {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-notice-track {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: var(--battle-notice-roll-gap, 48px);
|
||||||
|
width: max-content;
|
||||||
|
max-width: none;
|
||||||
|
animation: battle-notice-roll var(--battle-notice-roll-duration, 12s) linear infinite;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-notice.is-rolling .battle-notice-message {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: none;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.battle-notice-track {
|
||||||
|
animation: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#app.match-live .battle-notice.is-visible {
|
#app.match-live .battle-notice.is-visible {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
--mobile-kill-log-top: calc(var(--score-band-height) + var(--mobile-game-size) + 10px);
|
--mobile-kill-log-top: calc(var(--score-band-height) + var(--mobile-game-size) + 10px);
|
||||||
--mobile-options-button-width: 54px;
|
--mobile-options-button-width: 54px;
|
||||||
--mobile-options-gap: 8px;
|
--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));
|
--mobile-visitor-space: calc(104px + env(safe-area-inset-bottom));
|
||||||
--score-band-height: 132px;
|
--score-band-height: 132px;
|
||||||
--score-panel-left: 10px;
|
--score-panel-left: 10px;
|
||||||
|
|
@ -204,12 +204,25 @@
|
||||||
align-content: center;
|
align-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.team-score::before {
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
width: 8px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score-name {
|
||||||
|
padding-left: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.team-score-count {
|
.team-score-count {
|
||||||
font-size: 0.74rem;
|
font-size: 0.64rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-score.is-focused {
|
.team-score.is-focused {
|
||||||
box-shadow: inset 0 0 0 2px rgb(255 244 209 / 0.92);
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgb(255 244 209 / 0.72),
|
||||||
|
inset 0 0 0 999px rgb(255 244 209 / 0.035);
|
||||||
}
|
}
|
||||||
|
|
||||||
.battle-notice {
|
.battle-notice {
|
||||||
|
|
|
||||||
|
|
@ -24,22 +24,24 @@ export function updateScoreboard(
|
||||||
|
|
||||||
teams.forEach((team, index) => {
|
teams.forEach((team, index) => {
|
||||||
const teamEl = containerLeft.children[index];
|
const teamEl = containerLeft.children[index];
|
||||||
const aliveCount = fighters.filter(
|
const livingFighters = fighters.filter(
|
||||||
(fighter) => fighter.team.id === team.id && !fighter.isDead,
|
(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.setAttribute("aria-label", `${team.label} 생존 캐릭터 무작위 시점 고정`);
|
||||||
teamEl.style.setProperty("--team-color", team.color);
|
teamEl.style.setProperty("--team-color", team.color);
|
||||||
teamEl.style.backgroundColor = `${team.color}33`;
|
teamEl.style.removeProperty("background-color");
|
||||||
teamEl.style.borderLeft = `4px solid ${team.color}`;
|
teamEl.style.removeProperty("border-left");
|
||||||
teamEl.classList.toggle("is-focused", selectedFighterTeamId === team.id);
|
teamEl.classList.toggle("is-focused", selectedFighterTeamId === team.id);
|
||||||
|
|
||||||
const labelEl = teamEl.querySelector(".team-score-name");
|
const labelEl = teamEl.querySelector(".team-score-name");
|
||||||
labelEl.textContent = team.label;
|
labelEl.textContent = team.label;
|
||||||
|
|
||||||
const countEl = teamEl.querySelector(".team-score-count");
|
const countEl = teamEl.querySelector(".team-score-count");
|
||||||
countEl.textContent = `${aliveCount}명`;
|
countEl.textContent = `E : ${eliteCount} | N : ${normalCount}`;
|
||||||
|
|
||||||
teamEl.onclick = () => {
|
teamEl.onclick = () => {
|
||||||
onTeamClick(team.id);
|
onTeamClick(team.id);
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,13 @@ const DEATH_NOTICE_TEMPLATES = [
|
||||||
const SYSTEM_TIP_TEMPLATES = [
|
const SYSTEM_TIP_TEMPLATES = [
|
||||||
"경보: 화염 메테오는 낙하 지점 5x5 영역에 강력한 폭발 피해를 입힙니다!",
|
"경보: 화염 메테오는 낙하 지점 5x5 영역에 강력한 폭발 피해를 입힙니다!",
|
||||||
"주의: 냉기 메테오는 피해와 함께 2초간 동결 및 냉각을 유발합니다.",
|
"주의: 냉기 메테오는 피해와 함께 2초간 동결 및 냉각을 유발합니다.",
|
||||||
"팁: 근접 캐릭터는 20% 확률로 치명타를 터뜨려 적을 즉사시킵니다.",
|
"팁: 근접 치명타는 일반 대상에 2배 피해, 엘리트 대상에 최대 체력 비례 피해를 줍니다.",
|
||||||
"성장: 적 처치 시 체력을 30% 회복하며, 크기와 속도가 최대 5배까지 커집니다.",
|
"엘리트 전투: 처치 보너스는 비활성화되어 전투 중 체력 회복이나 성장 효과가 없습니다.",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const NOTICE_MESSAGE_CLASS = "battle-notice-message";
|
||||||
|
const NOTICE_TRACK_CLASS = "battle-notice-track";
|
||||||
|
|
||||||
export function createDeathCounts() {
|
export function createDeathCounts() {
|
||||||
return SPECIES_KEYS.reduce((counts, species) => {
|
return SPECIES_KEYS.reduce((counts, species) => {
|
||||||
counts[species] = 0;
|
counts[species] = 0;
|
||||||
|
|
@ -90,9 +93,21 @@ export function showBattleDeathNotice(noticeNode, message) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
noticeNode.textContent = message;
|
cancelBattleNoticeMeasure(noticeNode);
|
||||||
|
|
||||||
|
const text = String(message ?? "");
|
||||||
|
const messageNode = createBattleNoticeMessage(text);
|
||||||
|
|
||||||
|
noticeNode.classList.remove("is-rolling");
|
||||||
|
noticeNode.removeAttribute("aria-label");
|
||||||
|
clearBattleNoticeRollStyles(noticeNode);
|
||||||
|
noticeNode.replaceChildren(messageNode);
|
||||||
noticeNode.classList.add("is-visible");
|
noticeNode.classList.add("is-visible");
|
||||||
noticeNode.setAttribute("aria-hidden", "false");
|
noticeNode.setAttribute("aria-hidden", "false");
|
||||||
|
noticeNode.battleNoticeMeasureFrame = requestAnimationFrame(() => {
|
||||||
|
noticeNode.battleNoticeMeasureFrame = null;
|
||||||
|
applyBattleNoticeRollingIfNeeded(noticeNode, text, messageNode);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearBattleNotice(noticeNode) {
|
export function clearBattleNotice(noticeNode) {
|
||||||
|
|
@ -100,6 +115,94 @@ export function clearBattleNotice(noticeNode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelBattleNoticeMeasure(noticeNode);
|
||||||
noticeNode.classList.remove("is-visible");
|
noticeNode.classList.remove("is-visible");
|
||||||
noticeNode.setAttribute("aria-hidden", "true");
|
noticeNode.setAttribute("aria-hidden", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createBattleNoticeMessage(message) {
|
||||||
|
const messageNode = document.createElement("span");
|
||||||
|
|
||||||
|
messageNode.className = NOTICE_MESSAGE_CLASS;
|
||||||
|
messageNode.textContent = message;
|
||||||
|
|
||||||
|
return messageNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBattleNoticeRollingIfNeeded(noticeNode, message, messageNode) {
|
||||||
|
if (
|
||||||
|
!noticeNode.isConnected ||
|
||||||
|
!noticeNode.classList.contains("is-visible") ||
|
||||||
|
!messageNode.isConnected
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableWidth = resolveBattleNoticeContentWidth(noticeNode);
|
||||||
|
const messageWidth = Math.ceil(messageNode.scrollWidth);
|
||||||
|
|
||||||
|
if (availableWidth <= 0 || messageWidth <= availableWidth + 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = resolveBattleNoticeRollGap();
|
||||||
|
const durationMs = resolveBattleNoticeRollDuration(
|
||||||
|
messageWidth + gap + availableWidth,
|
||||||
|
);
|
||||||
|
const track = document.createElement("span");
|
||||||
|
|
||||||
|
track.className = NOTICE_TRACK_CLASS;
|
||||||
|
track.setAttribute("aria-hidden", "true");
|
||||||
|
track.append(createBattleNoticeMessage(message), createBattleNoticeMessage(message));
|
||||||
|
|
||||||
|
noticeNode.classList.add("is-rolling");
|
||||||
|
noticeNode.setAttribute("aria-label", message);
|
||||||
|
noticeNode.style.setProperty("--battle-notice-message-width", `${messageWidth}px`);
|
||||||
|
noticeNode.style.setProperty("--battle-notice-roll-gap", `${gap}px`);
|
||||||
|
noticeNode.style.setProperty("--battle-notice-roll-duration", `${durationMs}ms`);
|
||||||
|
noticeNode.replaceChildren(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBattleNoticeContentWidth(noticeNode) {
|
||||||
|
const style = getComputedStyle(noticeNode);
|
||||||
|
const paddingX =
|
||||||
|
(Number.parseFloat(style.paddingLeft) || 0) +
|
||||||
|
(Number.parseFloat(style.paddingRight) || 0);
|
||||||
|
|
||||||
|
return Math.max(0, noticeNode.clientWidth - paddingX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBattleNoticeRollGap() {
|
||||||
|
return Math.max(0, Math.round(Number(UI.BATTLE_NOTICE_ROLL_GAP_PX) || 48));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBattleNoticeRollDuration(distancePx) {
|
||||||
|
const speed = Math.max(
|
||||||
|
1,
|
||||||
|
Number(UI.BATTLE_NOTICE_ROLL_SPEED_PX_PER_SECOND) || 58,
|
||||||
|
);
|
||||||
|
const duration = Math.round((Math.max(1, distancePx) / speed) * 1000);
|
||||||
|
const minimum = Math.max(
|
||||||
|
1,
|
||||||
|
Number(UI.BATTLE_NOTICE_ROLL_MIN_DURATION_MS) || 7000,
|
||||||
|
);
|
||||||
|
const maximum = Math.max(
|
||||||
|
minimum,
|
||||||
|
Number(UI.BATTLE_NOTICE_ROLL_MAX_DURATION_MS) || 18000,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Math.min(maximum, Math.max(minimum, duration));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBattleNoticeRollStyles(noticeNode) {
|
||||||
|
noticeNode.style.removeProperty("--battle-notice-message-width");
|
||||||
|
noticeNode.style.removeProperty("--battle-notice-roll-gap");
|
||||||
|
noticeNode.style.removeProperty("--battle-notice-roll-duration");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelBattleNoticeMeasure(noticeNode) {
|
||||||
|
if (noticeNode.battleNoticeMeasureFrame) {
|
||||||
|
cancelAnimationFrame(noticeNode.battleNoticeMeasureFrame);
|
||||||
|
noticeNode.battleNoticeMeasureFrame = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
190
todo.md
190
todo.md
|
|
@ -311,154 +311,44 @@
|
||||||
52. Configurable barrage warning duration (completed)
|
52. Configurable barrage warning duration (completed)
|
||||||
- Added `WORLD_EFFECT.WARNING_DURATION_MS` to control the visible lifetime of the large dense-area warning marker.
|
- 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.
|
- Kept scheduled small impacts and meteor camera focus running after the warning marker hides.
|
||||||
53. Phaser 3 online optimization review and low-risk performance pass (완료)
|
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`).
|
||||||
- Phaser 공식 문서/뉴스에서 object allocation, Group pooling, Blitter, camera ignore, Arcade Collider, render config 관련 최적화 기법을 확인.
|
- Added centralized elite scale/HP/range and critical/meteor/frost percentage-damage constants; elite damage and attack speed now scale with `sqrt(stackCount)`.
|
||||||
- 투사체별 Arcade overlap collider 생성은 제거하고 기존 궤적 기반 판정만 유지.
|
- 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.
|
||||||
- 투사체 판정용 geometry와 전투 타깃 spatial grid의 프레임별 할당을 줄임.
|
- 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.
|
||||||
- 미니맵 redraw를 `PERFORMANCE.MINIMAP_REFRESH_MS`로 제한하고 Phaser 렌더 설정에 `autoRound`, `powerPreference`를 추가.
|
|
||||||
- Blitter 전환, Canvas 강제 전환, 광범위한 Group pooling은 현재 구조와 리스크 대비 보류.
|
|
||||||
54. Render resolution split for large-battle baseline performance (완료)
|
|
||||||
- **조치 사항**:
|
|
||||||
- Phaser 내부 canvas 해상도를 `ARENA.SIZE` 3200x3200에서 `RENDER` 1280x1280으로 분리.
|
|
||||||
- 전투 로직/월드 bounds는 기존 3200x3200 arena를 유지.
|
|
||||||
- `CAMERA.MIN_ZOOM`을 render/arena 비율로 낮춰 기본 전장 전체 시야를 유지.
|
|
||||||
- 미니맵 HUD 크기와 stroke도 render size 기준으로 조정.
|
|
||||||
55. Large-battle fighter render LOD (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Added a large-battle render LOD pass that caps detailed visible fighter sprites through `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT`.
|
|
||||||
- Rendered hidden living fighters as team-colored dots on a shared `Graphics` layer while keeping them active for combat simulation.
|
|
||||||
- Used per-team representatives at full-arena `CAMERA.MIN_ZOOM` and camera-near detail selection for zoomed/selected views.
|
|
||||||
- Released HUD slots and pointer interaction for hidden fighters, and invalidated LOD when split-on-death children spawn.
|
|
||||||
- Verified production build with `npm run build`.
|
|
||||||
56. Dynamic zoomed fighter render LOD (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Changed `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_LIMIT` into the full-arena/base budget instead of a universal fixed cap.
|
|
||||||
- Added `PERFORMANCE.LARGE_BATTLE_SPRITE_RENDER_MAX` as the dynamic safety cap.
|
|
||||||
- Counted camera-near living fighters during zoomed/selected views and raised the detailed sprite budget dynamically up to the safety cap.
|
|
||||||
- Prioritized exact viewport candidates before rolling-window candidates so dots do not appear inside the current view unless the rolling-window count exceeds the dynamic cap.
|
|
||||||
57. Scoreboard team button toggle to full arena (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Clicking the currently selected team button now clears the fighter selection instead of selecting another random same-team fighter.
|
|
||||||
- Added `ArenaScene.returnToFullArenaView()` to restore `CAMERA.MIN_ZOOM`, center the arena, clear combat focus state, refresh the minimap, and update the scoreboard.
|
|
||||||
58. Large-battle start camera avoids full-arena overview (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Added `CAMERA.LARGE_BATTLE_START_ZOOM`.
|
|
||||||
- Live matches at or above `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` now start zoomed in instead of staying on `CAMERA.MIN_ZOOM`.
|
|
||||||
- Centered the initial large-battle camera on the living fighter closest to the overall living-fighter average without selecting that fighter.
|
|
||||||
59. Manual camera pan/zoom transitions (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Added `CAMERA.MANUAL_FOCUS_TWEEN_MS` and `CAMERA.MANUAL_FOCUS_TWEEN_EASE`.
|
|
||||||
- Added `ArenaScene.transitionMainCameraTo()` for Phaser camera `pan()` and `zoomTo()` based manual focus changes.
|
|
||||||
- Updated fighter/team selection and selected-team full-arena return to tween instead of jumping instantly.
|
|
||||||
- Paused selected-fighter auto-centering while the manual camera transition is active so it does not cancel the tween.
|
|
||||||
60. Rolling-window fighter LOD for smooth camera movement (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Replaced exact-visible-count LOD budgeting with a camera-centered rolling window.
|
|
||||||
- Added `PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_SCALE` and `PERFORMANCE.LARGE_BATTLE_ROLLING_WINDOW_BUFFER_RATIO`.
|
|
||||||
- Kept exact viewport fighters as the first priority, then filled the detailed set with rolling-window fighters before they enter the visible screen.
|
|
||||||
- Shortened `PERFORMANCE.LARGE_BATTLE_LOD_REFRESH_MS` so the rolling window follows manual pan/zoom more responsively.
|
|
||||||
61. Dormant offscreen fighter simulation (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Rolling-window LOD outside fighters now pause animation, disable input, release HUD, and disable Arcade bodies instead of only setting invisible.
|
|
||||||
- Updated combat movement so disabled-body fighters keep moving through JS position math.
|
|
||||||
- Kept visible fighters on Arcade movement for on-screen fidelity.
|
|
||||||
- Resolved projectile attacks involving dormant fighters as delayed data hits without spawning projectile objects.
|
|
||||||
- Updated camera/world-effect helpers to use fighter `x/y` when a body is disabled.
|
|
||||||
62. Fighter adapter layer before model/proxy rewrite (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Added `src/game/fighter/fighterAdapter.js` as the first boundary between fighter simulation state and Phaser Sprite/Arcade APIs.
|
|
||||||
- Moved fighter world-point, distance, facing, movement, body enable/disable, arena clamping, animation, and tint helpers behind the adapter.
|
|
||||||
- Updated combat, world effects, spectator camera, match finish cleanup, and fighter detail visibility to use the adapter for fighter-specific render/body access.
|
|
||||||
- Left the full `FighterModel + SpriteProxy` rewrite as a later larger step; this pass reduces the direct coupling first.
|
|
||||||
63. FighterModel shell for state/render split (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Added `src/game/fighter/fighterModel.js` to hold combat/state fields in a pure JS model object.
|
|
||||||
- Attached each fighter sprite to `fighter.model` and bridged existing custom sprite fields with getter/setters so current code remains compatible.
|
|
||||||
- Synced live sprite positions into model `x/y` during combat-frame preparation and wrote dormant movement through model-aware adapter helpers.
|
|
||||||
- Updated combat target indexing, arena rolling-window LOD, minimap dots, and split-spawn origins to use model position helpers where safe.
|
|
||||||
- Retained Phaser sprites for every fighter at this stage; later work added rolling-window SpriteProxy detach and lazy sprite pooling.
|
|
||||||
64. Model-based combat targeting and spatial index (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Added `ArenaScene.fighterModels`, `fighterByModelId`, and `fighterModelById` indexes.
|
|
||||||
- Registered models on match start and split spawns, and unregistered despawned fighters so stale models are marked inactive.
|
|
||||||
- Changed combat update entry to `updateFighterModel()` and iterated `scene.fighterModels` from the scene update loop.
|
|
||||||
- Built the target spatial grid from models instead of sprites.
|
|
||||||
- Replaced cached `targetEnemy` sprite references with `model.targetModelId` and resolved sprites only when rendering/movement/attack execution needs them.
|
|
||||||
65. Model-only combat fallback before SpriteProxy pooling (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Allowed `updateFighterModel()` to keep simulating living models that do not currently have a render sprite.
|
|
||||||
- Added model-only movement that updates `model.x/y` directly with arena clamping.
|
|
||||||
- Added delayed model-hit resolution for melee, projectile, and instant-spell attacks when either combatant lacks a sprite.
|
|
||||||
- Added `killFighterModel()` so model-only deaths mark models inactive, unregister indexes, record deaths/kills, apply kill rewards, and process split-on-death.
|
|
||||||
- Kept the full visual Sprite path when both combatants still have render sprites.
|
|
||||||
66. Rolling-window SpriteProxy detach (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Changed large-battle LOD so non-detailed fighter proxies are removed from Phaser's display list and update list instead of only being hidden.
|
|
||||||
- Removed detached proxies from `fighterByModelId`, letting combat route through the model-only fallback until the rolling window reattaches the proxy.
|
|
||||||
- Reattached detailed proxies from model state, including position/body reset, facing, input, animation resume, and kill-growth scale.
|
|
||||||
- Removed parked detached proxies from `this.fighters` on model-only death so dead offscreen proxies do not keep participating in scan loops.
|
|
||||||
- Kept LOD candidate selection and minimap drawing on the full model-backed proxy list so detached fighters still appear as dots and can be selected by team buttons.
|
|
||||||
- Verified production build with `npm run build`.
|
|
||||||
67. Lazy SpriteProxy pool for large-battle startup (rolled back)
|
|
||||||
- **Changes**:
|
|
||||||
- Changed `createFighter()` to create a lightweight model-backed proxy first, with optional Phaser Sprite attachment.
|
|
||||||
- Large live matches now pass `attachSprite: false` at match start, so thousands of fighters begin as model-only proxies.
|
|
||||||
- Added `scene.fighterSpritePool`; LOD detail promotion acquires/reconfigures a pooled sprite and LOD demotion releases it back to the pool.
|
|
||||||
- Routed clicked Phaser sprites back to their owning proxy through `_fighterProxy`, keeping selection/team focus logic model-first.
|
|
||||||
- Split-on-death children in active large-battle LOD now spawn model-only and wait for LOD promotion before acquiring render sprites.
|
|
||||||
- Verified production build with `npm run build`.
|
|
||||||
- Rolled this back after a render regression where fighter models and team counts were alive but no stable Phaser Sprite appeared on the field.
|
|
||||||
68. Fighter sprite render recovery after lazy proxy regression (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Restored `createFighter()` to return a real Phaser Sprite with an attached `fighter.model` bridge.
|
|
||||||
- Kept the `attachSprite` option, but large-battle startup now parks the created sprite through `setFighterDetailVisible(false)` instead of skipping Sprite creation.
|
|
||||||
- Preserved rolling-window LOD's display/update-list detach and reattach behavior for non-detailed fighters.
|
|
||||||
- Updated arena/fighter/combat context docs to mark the lazy sprite pool as disabled.
|
|
||||||
- Verified production build with `npm run build`.
|
|
||||||
69. Null-safe model target cache after sprite recovery (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Fixed `isValidEnemyTargetModel()` so cached target validation safely handles `null` attacker or candidate models before reading team ids.
|
|
||||||
- Cleared stale `targetModelId` values in `resolveTargetEnemyModel()` before scanning for a replacement enemy.
|
|
||||||
- Verified production build with `npm run build`.
|
|
||||||
70. Detached fighter animation guard for large-battle model-only attacks (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Fixed `fighterAdapter.shouldRenderFighterDetail()` so `null` or detached fighters cannot be treated as renderable.
|
|
||||||
- Added animation-key guards in `playFighterAction()` and `playFighterActionIfNeeded()` so model-only attacks skip sprite animation safely.
|
|
||||||
- Verified production build with `npm run build`.
|
|
||||||
71. Large-battle simulation throttle and tighter render budget (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Added large-battle simulation buckets so attached/detailed fighters update every frame while detached model-only fighters are distributed across frames.
|
|
||||||
- Added capped accumulated delta for throttled detached model updates.
|
|
||||||
- Reduced large-battle detailed sprite and HUD label caps to avoid 8,000-fighter zoom views promoting thousands of animated sprites.
|
|
||||||
- Allowed rolling-window LOD buffer ratios below `1` for dense large-battle scenes.
|
|
||||||
- Added an early return when `setFighterDetailVisible(false)` is called on an already parked fighter, reducing LOD refresh spikes.
|
|
||||||
- Verified production build with `npm run build`.
|
|
||||||
72. Aggressive 8k battle throttles for remaining frame drops (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Lowered detailed sprite caps again for dense 8,000-fighter zoom views.
|
|
||||||
- Changed combat frame preparation to sync only attached sprites and rebuild the large-battle target spatial index on an interval instead of every frame.
|
|
||||||
- Throttled model-index audits to once per second.
|
|
||||||
- Skipped world-effect modifier scans when no frost zone is active, and throttled active frost-zone scans.
|
|
||||||
- Verified production build with `npm run build`.
|
|
||||||
73. Aggregate detached combat for large battles (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Added a large-battle aggregate combat path for detached/offscreen model-only fighters.
|
|
||||||
- Detached fighters now move toward coarse enemy cells and resolve batched HP/deaths at `PERFORMANCE.LARGE_BATTLE_AGGREGATE_COMBAT_REFRESH_MS` instead of running full target/attack AI.
|
|
||||||
- Kept attached/detail fighters on every-frame individual combat for visible camera fidelity.
|
|
||||||
- Suppressed per-kill DOM log entries for aggregate offscreen deaths while preserving death stats, kill rewards, split-on-death, scoreboard updates, and match finish checks.
|
|
||||||
- Verified production build with `npm run build`.
|
|
||||||
74. Squad-based detached combat compression (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Compressed detached/offscreen fighters into `team + cell + 100 fighters` squads during large live battles.
|
|
||||||
- Moved and resolved combat at the squad level, then reslotted individual models around squad centers only on aggregate ticks.
|
|
||||||
- Changed large-battle target spatial indexing to attached/detail models so visible individual AI does not scan all offscreen models.
|
|
||||||
- Added `PERFORMANCE.LARGE_BATTLE_AGGREGATE_SQUAD_SIZE` for tuning squad population.
|
|
||||||
- Verified production build with `npm run build`.
|
|
||||||
75. Magic attack effect sprite pooling (completed)
|
|
||||||
- **Changes**:
|
|
||||||
- Added per-texture pooling for instant-spell attack effect sprites in `combat.js`.
|
|
||||||
- Returned pooled spell effects on animation completion and during `clearCombatObjects()` cleanup instead of destroying them.
|
|
||||||
- Reset pooled spell sprites before reuse, including frame, position, scale, depth, alpha, rotation, flip, active/visible state, and animation-complete listener state.
|
|
||||||
- Left projectile and meteor/world-effect lifecycles unchanged.
|
|
||||||
- Verified production build with `npm run build`.
|
- 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; 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.
|
||||||
|
- 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.
|
||||||
|
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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue