Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0f7f5fbd8 | ||
|
|
da8ae49a72 | ||
|
|
7814ed3951 | ||
|
|
f650592676 | ||
|
|
3b7fa17d06 | ||
|
|
3b1a883787 | ||
|
|
23376e8cbb | ||
|
|
9ca343c214 | ||
|
|
a7eec730d2 | ||
|
|
30d7be41be | ||
|
|
2c013247a9 | ||
|
|
9df4f3dcde | ||
|
|
743b2a75f5 | ||
|
|
f9b548f9cd | ||
|
|
ddccd01eb6 | ||
|
|
1668e3c941 | ||
|
|
36fd25731a | ||
|
|
43383bb833 | ||
|
|
518767b21b |
@@ -4,3 +4,4 @@ dist/
|
|||||||
config.json
|
config.json
|
||||||
package-lock.json
|
package-lock.json
|
||||||
*.log
|
*.log
|
||||||
|
.omo
|
||||||
@@ -1,3 +1,147 @@
|
|||||||
|
# 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. 필수
|
||||||
@@ -29,6 +173,10 @@
|
|||||||
│ └── visitors.js # 유니크 방문자 체크 및 통계 API
|
│ └── visitors.js # 유니크 방문자 체크 및 통계 API
|
||||||
├── public/ # 정적 리소스 (게임 에셋)
|
├── public/ # 정적 리소스 (게임 에셋)
|
||||||
│ └── assets/
|
│ └── assets/
|
||||||
|
│ ├── effects/ # 공통 전투/월드 이펙트 스프라이트시트
|
||||||
|
│ │ ├── heal/ # 처치 회복 연출
|
||||||
|
│ │ ├── world_Effect.png # 화염 메테오 7프레임 이미지
|
||||||
|
│ │ └── world_Effect_2.png # 냉기 메테오 7프레임 이미지
|
||||||
│ └── characters/ # 20종 이상의 캐릭터 스킨 및 투사체 에셋
|
│ └── characters/ # 20종 이상의 캐릭터 스킨 및 투사체 에셋
|
||||||
│ ├── archer/, armored-axeman/, armored-orc/, ... (중략)
|
│ ├── archer/, armored-axeman/, armored-orc/, ... (중략)
|
||||||
│ └── wizard/ # 각 폴더 내 애니메이션 시트 및 이펙트 포함
|
│ └── wizard/ # 각 폴더 내 애니메이션 시트 및 이펙트 포함
|
||||||
@@ -39,20 +187,40 @@
|
|||||||
├── game/ # 게임 로직 모듈 (역할별 하위 폴더 구성)
|
├── game/ # 게임 로직 모듈 (역할별 하위 폴더 구성)
|
||||||
│ ├── arena/ # 아레나 및 씬 관리
|
│ ├── arena/ # 아레나 및 씬 관리
|
||||||
│ │ ├── ArenaScene.js # 메인 게임 씬 (Orchestrator, 생명주기 및 모듈 조율)
|
│ │ ├── ArenaScene.js # 메인 게임 씬 (Orchestrator, 생명주기 및 모듈 조율)
|
||||||
│ │ ├── arenaRenderer.js# 경기장 바닥 및 격자 렌더링
|
│ │ ├── arenaRenderer.js# 경기장 바닥, 격자 및 팀 시작 영역 렌더링
|
||||||
│ │ └── arenaSpectatorCamera.js # 지능형 관전 카메라 및 줌 로직
|
│ │ └── arenaSpectatorCamera.js # 지능형 관전 카메라 및 줌 로직
|
||||||
│ ├── combat/ # 전투 시스템
|
│ ├── combat/ # 전투 시스템
|
||||||
│ │ ├── combat.js # 전투 AI, 투사체 및 피격 판정 핵심 엔진
|
│ │ ├── combat.js # 전투 AI, 투사체 및 피격 판정 핵심 엔진
|
||||||
│ │ ├── combatSettings.js # 전투 속도 및 이동 배율 관리
|
│ │ ├── combatSettings.js # 전투 속도 및 이동 배율 관리
|
||||||
│ │ └── arenaFinalCombatEffects.js # 최종 교전 슬로우 모션 등 연출 효과
|
│ │ ├── arenaFinalCombatEffects.js # 최종 교전 슬로우 모션 등 연출 효과
|
||||||
|
│ │ └── worldEffects.js # 주기적 메테오/냉각지대 및 냉기 동결 효과
|
||||||
│ ├── fighter/ # 캐릭터 및 에셋
|
│ ├── fighter/ # 캐릭터 및 에셋
|
||||||
│ │ ├── fighterAssets.js # 스프라이트 로드 및 팀 실루엣 동적 생성
|
│ │ ├── fighterAssets.js # 스프라이트 로드 및 팀 실루엣 동적 생성
|
||||||
│ │ ├── fighterFactory.js # 캐릭터 인스턴스화 및 HUD 동기화
|
│ │ ├── fighterFactory.js # 캐릭터 인스턴스화 및 HUD 동기화
|
||||||
│ │ ├── fighterManifest.js # 20종 캐릭터 스탯/특성 상세 정의
|
│ │ ├── fighterManifest.js # 20종 캐릭터 스탯/특성 상세 정의
|
||||||
|
│ │ ├── fighterStats.js # 근접/원거리/마법 프로필 판별 및 스탯 해석
|
||||||
│ │ └── fighterSelection.js # 캐릭터 스킨 무작위 선택 로직
|
│ │ └── fighterSelection.js # 캐릭터 스킨 무작위 선택 로직
|
||||||
│ └── match/ # 매치 및 진행
|
├── match/ # 매치 및 진행
|
||||||
│ ├── matchSetup.js # 팀 구성 및 스폰 좌표 계산 (구역/랜덤)
|
│ ├── matchSetup.js # 팀 구성(닉네임 배수 파싱 포함) 및 스폰 좌표 계산 (스타팅 영역/랜덤)
|
||||||
│ └── arenaMatchRuntime.js # 매치 진행 중 헬퍼 (스폰 클러스터, 팀 크기 동기화)
|
│ └── arenaMatchRuntime.js # 매치 진행 중 헬퍼 (스폰 클러스터, 팀 크기 동기화)
|
||||||
|
...
|
||||||
|
## 7. 주요 기능 상세 (New)
|
||||||
|
|
||||||
|
### 7.1 닉네임 배수 시스템 (Multi-Spawn)
|
||||||
|
- 사용자가 닉네임 뒤에 `*N` (예: `홍길동*2`)을 입력하면 해당 팀은 기본 팀 인원의 N배만큼 생성됩니다.
|
||||||
|
- 스타팅 존 모드에서 배수만큼의 독립된 스폰 지점이 할당되어 전략적인 분산 배치가 이루어집니다.
|
||||||
|
- 닉네임 표시 시 `*N` 접미사는 자동으로 제거되어 깔끔한 UI를 유지합니다.
|
||||||
|
|
||||||
|
### 7.2 서든 데스 (Sudden Death) 시스템
|
||||||
|
- 매치 시작 후 일정 시간(기본 8초)이 경과하면 전장의 환경이 극도로 위험해지는 서든 데스 상태에 진입합니다.
|
||||||
|
- 메테오 생성 주기가 비약적으로 단축(기본 1초)되며, 빙결 효과를 가진 냉기 메테오가 집중 투하됩니다.
|
||||||
|
- `constants.js`를 통해 활성화 여부, 시작 시간, 주기 등을 간편하게 조정할 수 있습니다.
|
||||||
|
|
||||||
|
### 7.3 밀집 구역 기반 월드 이펙트 포격
|
||||||
|
- 월드 이펙트는 랜덤 생존자 대신 `WORLD_EFFECT.AREA_TILES` 크기 범위 중 현재 생존 캐릭터가 가장 많이 모인 위치를 표적으로 선택합니다.
|
||||||
|
- 선택 범위를 먼저 경고로 표시한 뒤, 그 내부에 작은 화염 또는 냉기 메테오를 3~4발 분산 투하합니다.
|
||||||
|
- 피해, 기절, 냉각 감속은 큰 경고 범위 전체가 아니라 각각의 작은 탄착 영역에만 적용됩니다.
|
||||||
|
|
||||||
└── ui/ # UI 컴포넌트 및 API 연동
|
└── ui/ # UI 컴포넌트 및 API 연동
|
||||||
├── arenaKillLog.js # [New] 독립된 킬로그 DOM 조작 모듈
|
├── arenaKillLog.js # [New] 독립된 킬로그 DOM 조작 모듈
|
||||||
├── arenaScoreboard.js # [New] 팀 스코어 badge 업데이트 모듈
|
├── arenaScoreboard.js # [New] 팀 스코어 badge 업데이트 모듈
|
||||||
@@ -71,9 +239,10 @@
|
|||||||
- **[인프라 및 전역 설정] [context/core.md](./context/core.md)**: `main.js`, `constants.js`, 개발/유지보수 공통 규칙.
|
- **[인프라 및 전역 설정] [context/core.md](./context/core.md)**: `main.js`, `constants.js`, 개발/유지보수 공통 규칙.
|
||||||
- **[서버 및 API] [context/server.md](./context/server.md)**: Fastify 서버, MongoDB 연동, 방문자 및 사망 통계 API 상세.
|
- **[서버 및 API] [context/server.md](./context/server.md)**: Fastify 서버, MongoDB 연동, 방문자 및 사망 통계 API 상세.
|
||||||
- **[아레나 및 카메라] [context/arena.md](./context/arena.md)**: `ArenaScene` 오케스트레이션, 지능형 카메라 추적, 미니맵 가이드라인.
|
- **[아레나 및 카메라] [context/arena.md](./context/arena.md)**: `ArenaScene` 오케스트레이션, 지능형 카메라 추적, 미니맵 가이드라인.
|
||||||
- **[전투 엔진] [context/combat.md](./context/combat.md)**: 전투 AI, 투사체 판정, 처치 보상 성장, 슬로우모션 연출.
|
- **[전투 엔진] [context/combat.md](./context/combat.md)**: 전투 AI, 엘리트 피해 판정, 비활성화된 처치 보너스 경로, 슬로우모션 및 월드 이펙트 연출.
|
||||||
- **[캐릭터 및 에셋] [context/fighter.md](./context/fighter.md)**: 캐릭터 공장, 동적 실루엣 생성, 종족 및 특성(Slime 등) 정의.
|
- **[캐릭터 및 에셋] [context/fighter.md](./context/fighter.md)**: 캐릭터 공장, 동적 실루엣 생성, 종족 및 특성(Slime 등) 정의.
|
||||||
- **[매치 로직 및 UI] [context/match-ui.md](./context/match-ui.md)**: 팀 구성 및 스폰 알고리즘, HUD 레이아웃, 킬로그, 승리 연출 UI.
|
- **[매치 로직 및 UI] [context/match-ui.md](./context/match-ui.md)**: 팀 구성 및 스폰 알고리즘, HUD 레이아웃, 킬로그, 승리 연출 UI.
|
||||||
|
- **[스타일 및 디자인] [context/style.md](./context/style.md)**: CSS 모듈 구조, 디자인 변수, 반응형 및 애니메이션 가이드.
|
||||||
|
|
||||||
## 4. 기술 사양
|
## 4. 기술 사양
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,31 @@
|
|||||||
|
# Update: Special Effect Camera Focus
|
||||||
|
|
||||||
|
- `ArenaScene` preloads and creates special-effect animations beside the existing fighter and world-effect assets, then starts `startSpecialEffects()` for live matches only.
|
||||||
|
- During a special cast, `beginSpecialEffectCameraFocus()` zooms toward the caster, `zoomOutSpecialEffectCameraFocus()` zooms back out in place when the projectile starts moving, and `clearSpecialEffectCameraFocus()` restores the previous camera after the configured hold/outro timing.
|
||||||
|
- Special focus temporarily takes priority over selected-fighter, spectator, and meteor focus so the Hurt-frame pose and projectile launch are visible.
|
||||||
|
- When `SPECIAL_EFFECT.CAMERA.CENTER_ON_CASTER_AT_START` is enabled, special focus centers the main camera on the caster's location immediately and keeps it snapped there until projectile handoff, then continues the slower follow motion.
|
||||||
|
- The Hurt-frame preparation window sets `scene.specialEffectPreparationPaused`, pauses the scene clock and Arcade Physics, freezes living fighter animations, pauses active combat-object tweens/animations/velocities, and places a blurred battlefield snapshot plus dim layer beneath the raised caster. Camera focus tweening stays active so the zoom-in can complete while combat and world-effect progression are stopped.
|
||||||
|
- The projectile is not used as a camera target. Special camera targets are still clamped against the current zoom viewport so the focus stack cannot drag the camera outside the arena bounds.
|
||||||
|
- Match restart and match finish both call `clearSpecialEffects()` so pending timers, projectile objects, caster locks, and special camera tweens do not leak into the next battle.
|
||||||
|
|
||||||
|
# Update: Elite-Weighted Scene Counts
|
||||||
|
|
||||||
|
- `ArenaScene.recordDeath()` records an elite death as its represented `stackCount`, keeping persisted species death totals aligned with the displayed army size.
|
||||||
|
- `arenaSpectatorCamera.js` uses summed `stackCount` for late/final thresholds, underdog comparison, and weighted team center positions. A match containing two large compressed armies therefore does not enter final-combat camera mode at match start.
|
||||||
|
- Minimap dots remain physical fighter markers; an elite is visible as its single large battlefield representative while numeric population remains in the scoreboard.
|
||||||
|
|
||||||
|
# Update: Graphics Minimap And HUD Candidates
|
||||||
|
|
||||||
|
- The minimap is drawn during live matches by `ArenaScene` as a lightweight `Graphics` overlay through a dedicated `minimap-hud` camera instead of reusing the field camera. Presentation/waiting mode hides it.
|
||||||
|
- 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 health-bar HUD pool slots. Battlefield name text is not shown.
|
||||||
|
|
||||||
# Context: Arena & Scene
|
# Context: Arena & Scene
|
||||||
|
|
||||||
## 1. 모듈별 상세 역할 (`src/game/arena/`)
|
## 1. 모듈별 상세 역할 (`src/game/arena/`)
|
||||||
|
|
||||||
- **`ArenaScene.js`**: Phaser 씬의 생명주기와 전반적인 오케스트레이션을 담당합니다. `update()` 매 프레임마다 전투원 상태를 체크하고, 카메라 이동 및 UI 모듈 호출을 조율합니다.
|
- **`ArenaScene.js`**: Phaser 씬의 생명주기와 전반적인 오케스트레이션을 담당합니다. `update()` 매 프레임마다 전투원 상태를 체크하고, 카메라 이동 및 UI 모듈 호출을 조율합니다.
|
||||||
- **`arenaRenderer.js`**: 아레나 배경 그래픽 및 타일 렌더링을 담당합니다.
|
- **`arenaRenderer.js`**: 아레나 배경 그래픽, 타일 및 팀별 스타팅 영역 오버레이 렌더링을 담당합니다.
|
||||||
- **`arenaSpectatorCamera.js`**: 관전 모드 시점 계산 및 카메라 포커싱 로직을 담당합니다. 생존 인원에 따른 지능형 카메라 추적 알고리즘이 구현되어 있습니다.
|
- **`arenaSpectatorCamera.js`**: 관전 모드 시점 계산 및 카메라 포커싱 로직을 담당합니다. 생존 인원에 따른 지능형 카메라 추적 알고리즘이 구현되어 있습니다.
|
||||||
|
|
||||||
## 2. 주요 로직 구현 세부 사항
|
## 2. 주요 로직 구현 세부 사항
|
||||||
@@ -13,11 +35,13 @@
|
|||||||
1. 목표 좌표(`targetX, targetY`)를 `Math.round()`로 정수화합니다.
|
1. 목표 좌표(`targetX, targetY`)를 `Math.round()`로 정수화합니다.
|
||||||
2. 현재 카메라 위치에서 목표 지점까지 매 프레임 `0.1`의 배율로 거리를 좁혀나가는 `Lerp` 연산을 수행합니다.
|
2. 현재 카메라 위치에서 목표 지점까지 매 프레임 `0.1`의 배율로 거리를 좁혀나가는 `Lerp` 연산을 수행합니다.
|
||||||
```javascript
|
```javascript
|
||||||
this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATOR_CAMERA_LERP;
|
this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * CAMERA.SPECTATOR_LERP;
|
||||||
```
|
```
|
||||||
|
|
||||||
최종교전 관전은 두 단계로 나뉩니다.
|
자동 관전은 월드 이펙트 임시 시점, 후반 진입과 최종교전 세부 포커싱으로 나뉩니다.
|
||||||
- **생존 4명 이하**: `SPECTATOR_RANDOM_FOCUS_INTERVAL`마다 생존 캐릭터 중 한 명을 무작위로 포커싱합니다.
|
- **메테오 임시 포커싱**: 자동 관전 진입 전 화염 또는 냉기 포격이 시작되면 가장 밀집한 큰 경고 구역의 중심을 임시로 확대 추적합니다. 큰 경고 표시 자체는 `WORLD_EFFECT.WARNING_DURATION_MS` 이후 사라지며, 카메라는 내부 소형 탄착들이 종료된 뒤 `CAMERA.METEOR_FOCUS_HOLD_DURATION`만큼 유지한 다음 이전 시점으로 복귀합니다. `CAMERA.METEOR_FOCUS_ENABLED`를 `false`로 설정하면 끌 수 있으며, 수동 선택 시점과 아래 자동 관전 시점이 우선합니다.
|
||||||
|
- **후반 자동 관전 진입**: 생존 캐릭터가 30명 미만(`CAMERA.SPECTATOR_LATE_FIGHTER_THRESHOLD`)이 되면 교전 중심(가장 가까운 적 대항쌍)을 포커싱하는 후반 줌을 적용합니다. (2팀만 남았더라도 인원이 많으면 어지러움을 방지하기 위해 자동 관전으로 바로 진입하지 않습니다.)
|
||||||
|
- **생존 4명 이하**: `CAMERA.SPECTATOR_RANDOM_FOCUS_INTERVAL`마다 생존 캐릭터 중 한 명을 무작위로 포커싱합니다.
|
||||||
- **2팀 잔여 & 합계 8명 이하**: 더 적은 생존 수를 가진 팀의 중앙을 포커싱하며, 동률이면 기존 교전쌍 중심 포커싱으로 되돌아갑니다.
|
- **2팀 잔여 & 합계 8명 이하**: 더 적은 생존 수를 가진 팀의 중앙을 포커싱하며, 동률이면 기존 교전쌍 중심 포커싱으로 되돌아갑니다.
|
||||||
|
|
||||||
### 미니맵 가이드라인
|
### 미니맵 가이드라인
|
||||||
@@ -25,6 +49,10 @@ this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * SPECTATO
|
|||||||
- `camera.displayWidth / zoom` 등을 이용하여 현재 월드에서 보이는 실제 영역 크기를 계산합니다.
|
- `camera.displayWidth / zoom` 등을 이용하여 현재 월드에서 보이는 실제 영역 크기를 계산합니다.
|
||||||
- 뷰포트 사각형 좌표는 미니맵 픽셀 격자에 맞춰 반올림하고, 외곽 stroke가 겹쳐 검게 깨지지 않도록 노란 내부 선을 채운 직사각형으로 렌더링합니다.
|
- 뷰포트 사각형 좌표는 미니맵 픽셀 격자에 맞춰 반올림하고, 외곽 stroke가 겹쳐 검게 깨지지 않도록 노란 내부 선을 채운 직사각형으로 렌더링합니다.
|
||||||
|
|
||||||
|
### 스타팅 영역 오버레이
|
||||||
|
`스타팅 지점 배치` 매치에서는 `matchSetup.js`가 전장 그리드에서 팀별 중심 셀을 무작위로 뽑아 만든 영역을 `ArenaScene`이 `arenaRenderer.js`에 전달합니다. 렌더러는 각 팀 색상을 낮은 투명도로 채우고 얇게 둘러 실제 스폰 후보 영역을 표시하며, 이 오버레이는 매치 시작 후 5초 동안만 보입니다. 숨김 예약은 Phaser 씬 타이머를 사용하므로 일시정지 시간은 표시 시간에 포함되지 않고, 새 매치가 시작되면 이전 예약을 취소합니다.
|
||||||
|
|
||||||
### 씬 상태 관리
|
### 씬 상태 관리
|
||||||
- **프리뷰 모드 (`presentationMode`)**: 최초 로드 시 조용히 실행되는 배경 전투입니다. 로컬 저장 옵션과 무관하게 10팀 x 5명 고정 규모로 동작합니다.
|
- **프리뷰 모드 (`presentationMode`)**: 최초 로드 시 조용히 실행되는 배경 전투입니다. 로컬 저장 옵션과 무관하게 10팀 x 5명 고정 규모로 동작합니다.
|
||||||
- **일시정지 (`setPaused`)**: 실제 전투에서 물리, Phaser 타이머, tween, 스프라이트 애니메이션을 함께 제어합니다. 프리뷰 및 종료된 전투는 제외됩니다.
|
- **일시정지 (`setPaused`)**: 실제 전투에서 물리, Phaser 타이머, tween, 스프라이트 애니메이션을 함께 제어합니다. 프리뷰 및 종료된 전투는 제외됩니다.
|
||||||
|
- **월드 이펙트 주기**: 실제 전투 생성 시 `startWorldEffects()`를 시작하고, 첫 포격은 `WORLD_EFFECT.INTERVAL`, 이후 일반 포격은 `WORLD_EFFECT.REPEAT_INTERVAL`을 사용합니다. 새 매치/종료 때 `clearWorldEffects()`로 주기 타이머, 잔여 냉각 구역, 메테오 임시 포커스, 캐릭터 감속 배율을 정리합니다. Phaser 타이머를 사용하므로 일시정지 시간은 이 간격과 냉각 지속시간에 포함되지 않습니다.
|
||||||
|
|||||||
@@ -1,29 +1,136 @@
|
|||||||
|
# Update: Special Projectile Trail
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
- Trail objects are visual-only combat objects: they fade out and self-dispose, but they do not participate in hit detection.
|
||||||
|
- `TRAIL.INTERVAL_MS` and `TRAIL.LIFETIME_MS` bound how many afterimages can exist at once.
|
||||||
|
|
||||||
|
# Update: Split Special Projectile Visual Configs
|
||||||
|
|
||||||
|
- 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`.
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
# Update: One-Shot Accelerating Special Projectile
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
- 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`.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
# Update: Special Effect Frame Sequence Refresh
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
# Update: Special Effect Frame Rate And Render Budget
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
- `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.
|
||||||
|
- 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: Special Effect Projectile
|
||||||
|
|
||||||
|
- `specialEffects.js` owns the one-shot special effect flow: asset preload/animation creation, random live-match scheduling, underdog caster selection, caster pose lock, launch visuals, projectile path checks, and cleanup.
|
||||||
|
- `SPECIAL_EFFECT` in `src/constants.js` tunes trigger timing, caster Hurt-frame hold, camera zoom, melee projectile assets, ranged projectile asset scale/speed/travel distance/hit radius, target density area, arena edge padding, and lifetime. `WORLD_EFFECT.SPECIAL` points to the same config object.
|
||||||
|
- Casters must be living, non-elite, non-magic fighters from teams that are not currently tied for first by represented living count. When `SPECIAL_EFFECT.CASTER.BALANCE_NON_MAGIC_TYPES` is enabled, caster selection first picks among available non-magic types and then picks a fighter from that type, preventing the larger melee roster from overwhelming ranged special casts. If no caster exists at the chosen time, the timer retries within the configured window instead of firing multiple times.
|
||||||
|
- Special casters receive realtime invulnerability for `SPECIAL_EFFECT.CASTER.INVULNERABLE_MS`. `combat.js` checks that window in normal attacks, world-effect damage, and special instant kills, while `worldEffects.js` also skips frost survivor effects for invulnerable fighters.
|
||||||
|
- While the caster holds the Hurt frame, `specialEffects.js` pauses fighter AI, Arcade Physics, the scene clock, existing combat-object physics velocity, combat-object animations, and combat-object tweens. Existing combat/world delayed calls and already-falling meteor/frost tweens stop advancing until the realtime preparation hold releases into the attack animation.
|
||||||
|
- 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: Elite Magic Attack Effect Scale
|
||||||
|
|
||||||
|
- `combat.js` resolves instant-spell attack effect scale through constants instead of hard-coding `FIGHTER.SCALE`.
|
||||||
|
- Normal spell effects use `FIGHTER.SCALE * FIGHTER.ATTACK_EFFECT_SCALE_MULTIPLIER`.
|
||||||
|
- Elite magic spell effects additionally multiply by `FIGHTER.ELITE.ATTACK_EFFECT_SCALE_MULTIPLIER`, keeping caster body scale and effect scale separately tunable.
|
||||||
|
|
||||||
|
# Update: Elite Target Damage And Density
|
||||||
|
|
||||||
|
- `combat.js` uses `fighter.isElite` to split damage rules. Elite critical hits deal the greater of the ordinary hit or `COMBAT.CRITICAL_DAMAGE_PERCENT` of max HP; normal critical hits deal `NORMAL_CRITICAL_DAMAGE_MULTIPLIER` times the ordinary hit.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
- Kills still record the attacker/defender and drive match resolution, but `COMBAT.KILL_REWARD_ENABLED = false` prevents heal effects, scale growth, and kill-derived speed multipliers in compressed elite battles.
|
||||||
|
- `worldEffects.js` passes an effect type into `applyWorldEffectDamage()`: normal targets retain fixed fire/frost damage, while elite targets take `WORLD_EFFECT.METEOR_DAMAGE_PERCENT` or `FROST_DAMAGE_PERCENT` of max HP.
|
||||||
|
- Dense-area target scanning adds each fighter's represented `stackCount` into its tile, preventing compressed armies from disappearing from meteor/frost targeting pressure.
|
||||||
|
|
||||||
|
# Update: Dense-Area Meteor Barrage
|
||||||
|
|
||||||
|
- `worldEffects.js` aggregates living fighters on the arena tile grid and uses a summed-area scan to select the `WORLD_EFFECT.AREA_TILES` square with the highest population.
|
||||||
|
- That selected square is a warning/focus area. Each fire or frost event schedules `WORLD_EFFECT.IMPACT_COUNT_MIN` to `IMPACT_COUNT_MAX` smaller strikes inside it.
|
||||||
|
- Tune the large warning visibility with `WORLD_EFFECT.WARNING_DURATION_MS`, actual damage/frost footprints with `WORLD_EFFECT.IMPACT_AREA_TILES`, sprite size with `WORLD_EFFECT.IMPACT_VISUAL_SCALE`, strike spacing with `WORLD_EFFECT.IMPACT_STAGGER_MS`, and per-strike variation with `WORLD_EFFECT.SIZE_SCALE_VARIANCE`.
|
||||||
|
- `WORLD_EFFECT.INTERVAL` sets the delay before the first barrage; subsequent normal barrages use `WORLD_EFFECT.REPEAT_INTERVAL`, with `SUDDEN_DEATH.INTERVAL_MS` taking over once sudden death is active.
|
||||||
|
- Meteor impact shake uses the same size multiplier, scaling from `WORLD_EFFECT.METEOR_SHAKE_DURATION_MS` and `WORLD_EFFECT.METEOR_SHAKE_INTENSITY`.
|
||||||
|
|
||||||
|
# Update: Large Battle Targeting
|
||||||
|
|
||||||
|
- `combat.js` now prepares a per-frame target spatial index through `prepareCombatFrame(scene)`.
|
||||||
|
- `resolveTargetEnemy()` keeps valid cached targets until their scan interval expires, then immediately looks up a fresh nearest enemy through the spatial grid.
|
||||||
|
- Nearest enemy lookup searches grid cells outward from the fighter's current cell, with full-array scanning kept only as a fallback when no frame index exists.
|
||||||
|
- Large-battle corpse cleanup uses `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD` and `PERFORMANCE.LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS` from `src/constants.js`.
|
||||||
|
|
||||||
|
# Update: Team Shadow Animations And Frost Tint
|
||||||
|
|
||||||
|
- 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`.
|
||||||
|
- `combat.js` resolves fighter animation keys through `ensureFighterTeamAnimation()` so every action can use the team-shadow baked texture generated from the original spritesheet.
|
||||||
|
- `playIfNeeded()` compares against the team-shadow animation key. This avoids switching back to the original non-team-colored spritesheet when fighters move, attack, take damage, or die.
|
||||||
|
- Frost stun remains a body tint effect in `worldEffects.js`. Since team identity is baked into the floor shadow pixels, there is no `teamMarker` tint state to update or restore.
|
||||||
|
- The removed `teamMarker` display object means death handling no longer needs to hide or destroy a separate marker. HUD cleanup only owns health-bar objects because battlefield name labels are no longer created.
|
||||||
|
|
||||||
|
# Update: Focused Combat Effects In Large Battles
|
||||||
|
|
||||||
|
- `combat.js` exposes supplemental combat visuals only while a large battle is inside the temporary meteor camera-focus window.
|
||||||
|
- Outside that window, large battles skip critical labels, instant-spell sprites, kill-heal sprites, and kill-growth tweens while retaining underlying damage calculations. Kill rewards are globally disabled by the elite policy.
|
||||||
|
- World-effect meteor/frost visuals remain visible, and projectile objects remain enabled because projectiles currently participate in hit detection.
|
||||||
|
|
||||||
# Context: Combat System
|
# Context: Combat System
|
||||||
|
|
||||||
## 1. 모듈별 상세 역할 (`src/game/combat/`)
|
## 1. 모듈별 상세 역할 (`src/game/combat/`)
|
||||||
|
|
||||||
- **`combat.js`**: 전투 AI, 피해 계산, 처치 보상 등 핵심 전투 로직을 담당합니다. 유닛의 이동, 공격, 투사체 발사 등을 처리합니다.
|
- **`combat.js`**: 전투 AI, 피해 계산, 처치 기록 및 비활성화된 보너스 경로를 담당합니다. `fighterStats.js`에서 해석한 역할별 수치로 이동, 공격, 투사체 발사 등을 처리합니다.
|
||||||
- **`combatSettings.js`**: 전투 속도 배율 등 런타임 전투 설정을 관리합니다.
|
- **`combatSettings.js`**: 전투 속도 배율 등 런타임 전투 설정을 관리합니다.
|
||||||
- **`arenaFinalCombatEffects.js`**: 최종 교전 시 슬로우 모션 등 연출 효과를 담당합니다. 수학적인 이징(easing) 함수와 물리 시간 배율 계산을 포함합니다.
|
- **`arenaFinalCombatEffects.js`**: 최종 교전 시 슬로우 모션 등 연출 효과를 담당합니다. 수학적인 이징(easing) 함수와 물리 시간 배율 계산을 포함합니다.
|
||||||
|
- **`worldEffects.js`**: 실제 전투에서 설정 주기마다 생존자 밀집 구역을 탐색하고 화염/냉기 소형 메테오 포격을 실행하며, 대각선 낙하 연출, 개별 탄착 판정, 냉기 동결과 감속 구역 수명주기를 처리합니다.
|
||||||
|
|
||||||
## 2. 주요 로직 구현 세부 사항
|
## 2. 주요 로직 구현 세부 사항
|
||||||
|
|
||||||
### 전투 AI 및 유닛 동작
|
### 전투 AI 및 유닛 동작
|
||||||
- **`updateFighter()`**: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
|
- **`updateFighter()`**: 가장 가까운 적을 찾아 이동하거나 공격하는 유닛 AI의 핵심입니다.
|
||||||
- **`applyHit()`**: 일반 공격 피해량은 `ATTACK_DAMAGE_MIN/MAX` 범위에서 계산하고, 치명타 적중은 `Critical!` 표기와 즉시 처치/카메라 흔들림을 처리합니다.
|
- **`applyHit()`**: 일반 공격 피해량은 공격자의 `melee`/`ranged`/`magic` 프로필 피해량 범위에서 계산합니다. 치명타 적중은 `Critical!`을 표시하고, 일반 대상에는 일반 피해의 2배, elite 대상에는 최대 체력 비례 피해를 적용합니다.
|
||||||
|
- **역할별 기본값**: `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` 크기의 모든 후보 구역을 타일 누적합으로 평가해, 생존 캐릭터가 가장 많이 모인 범위를 선택합니다. 같은 밀도의 후보가 여러 개일 때만 그 후보 사이에서 무작위로 고릅니다.
|
||||||
|
- **포격 판정**: 선택된 큰 범위는 경고 표시와 카메라 포커스 대상으로 사용되고, 내부에 투하되는 작은 탄착 영역만 피해, 기절, 냉기 감속을 처리합니다. 이 때문에 넓은 밀집지대를 위협하면서도 영역 전체를 즉시 동일 피해로 덮지 않습니다.
|
||||||
|
- **서든 데스 (Sudden Death)**:
|
||||||
|
- **조건**: 매치 시작 후 `WORLD_EFFECT.SUDDEN_DEATH.TRIGGER_MS` 시간이 경과하면 서든 데스 상태에 진입합니다 (활성화 시).
|
||||||
|
- **효과**: 메테오 투하 주기가 `SUDDEN_DEATH.INTERVAL_MS`로 단축되며, `FORCE_FROST` 설정 시 빙결 효과를 가진 냉기 메테오가 집중적으로 생성됩니다.
|
||||||
|
- **목적**: 장기전을 방지하고 전장에 무작위 변수를 극대화하여 물량 중심 팀에게 리스크를 부여합니다.
|
||||||
|
- **낙하 방향과 크기**: 대상이 전장 좌측 반면(2, 3사분면)이면 화살표가 좌상단에서 우하단으로, 우측 반면(1, 4사분면)이면 좌우 반전되어 우상단에서 좌하단으로 이동합니다. 스프라이트를 45도로 기울이고 전용 시각 배율을 사용해 전역 마법 규모로 표현합니다.
|
||||||
|
- **화염 메테오**: `world_Effect.png`의 소형 탄착 애니메이션 3~4개가 밀집 경고 구역 내부로 순차 낙하합니다. 각 탄착은 크기에 따른 화면 흔들림과 개별 영역 고정 피해를 적용합니다. 환경 피해로 인한 사망은 킬 보상을 지급하지 않지만 사망 통계와 승패 판정에는 반영됩니다.
|
||||||
|
- **냉기 메테오**: `world_Effect_2.png`의 소형 탄착 애니메이션들이 같은 방식으로 낙하합니다. 개별 탄착 피해를 입고 생존한 대상은 얼음색으로 바뀐 채 설정 시간 동안 기절하며, 각 탄착점에 남는 냉각지대 안에서는 공격속도와 이동속도 감속 배율을 적용합니다.
|
||||||
|
|
||||||
### 최종교전 슬로우모션
|
### 최종교전 슬로우모션
|
||||||
`FINAL_COMBAT_SLOW_MOTION_ENABLED`가 활성화된 경우:
|
`COMBAT.FINAL_SLOW_MOTION_ENABLED`가 활성화된 경우:
|
||||||
- 최종교전 상태에서 공격 모션이 시작될 때 전역 time scale을 낮춥니다.
|
- 최종교전 상태에서 공격 모션이 시작될 때 전역 time scale을 낮춥니다.
|
||||||
- 진입/유지/복귀 속도 램프(Ease)를 적용합니다.
|
- 진입/유지/복귀 속도 램프(Ease)를 적용합니다.
|
||||||
- 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`의 `ATTACK_DAMAGE_MIN/MAX`를 수정합니다.
|
- **공격력 조정**: 일반 역할 피해량은 `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`는 normal 고정 피해를, `METEOR_DAMAGE_PERCENT`와 `FROST_DAMAGE_PERCENT`는 elite 최대 체력 비례 피해를 조정합니다.
|
||||||
|
- `SUDDEN_DEATH.ENABLED`로 서든 데스 활성화 여부를 결정하며, `TRIGGER_MS`(시작 시간), `INTERVAL_MS`(주기), `FORCE_FROST`(냉기 고정) 설정을 변경할 수 있습니다.
|
||||||
|
- `INTERVAL`은 첫 포격까지의 대기 시간, `REPEAT_INTERVAL`은 이후 일반 포격 주기입니다. `AREA_TILES`는 밀집도를 검색하고 경고로 표시할 큰 구역이며, `WARNING_DURATION_MS`는 그 경고 표시 시간입니다. `IMPACT_AREA_TILES`, `IMPACT_COUNT_MIN`/`IMPACT_COUNT_MAX`, `IMPACT_STAGGER_MS`, `IMPACT_VISUAL_SCALE`는 내부 소형 포격의 판정 범위, 발수, 간격, 시각 크기를 조정합니다.
|
||||||
|
- `WORLD_EFFECT.FROST_STUN_DURATION`/`FROST_STUN_TINT`로 동결 시간과 표시 색상을 조정합니다.
|
||||||
|
- 나머지 `WORLD_EFFECT.*` 값으로 발동 주기, 범위, 냉각 지속시간과 감속 정도를 수정하며, 메테오 착탄 위치 포커싱은 `CAMERA.METEOR_FOCUS_ENABLED`에서 켜고 끕니다.
|
||||||
- **특수 규칙**: 캐릭터별 특수 공격 방식은 `fighterManifest.js`의 `combat` 설정을 확인합니다.
|
- **특수 규칙**: 캐릭터별 특수 공격 방식은 `fighterManifest.js`의 `combat` 설정을 확인합니다.
|
||||||
|
|||||||
@@ -1,5 +1,62 @@
|
|||||||
|
# Update: Special Effect Constants
|
||||||
|
|
||||||
|
- `src/constants.js` now exports `SPECIAL_EFFECT` for the special projectile system, while `WORLD_EFFECT.SPECIAL` references the same object for world-effect domain grouping.
|
||||||
|
- `SPECIAL_EFFECT.TRIGGER_DELAY_MIN_MS`, `TRIGGER_DELAY_MAX_MS`, and `RETRY_DELAY_MS` control the once-per-match activation window.
|
||||||
|
- `SPECIAL_EFFECT.CASTER`, `CAMERA`, `MELEE`, and `PROJECTILE` centralize the Hurt-frame pose, non-magic caster type balancing, caster invulnerability time, caster-start centering, zoom timing, projectile-view zoom-out timing, melee projectile frame sequences/looping assets, projectile start hold, size, speed, target density area, arena edge padding, hit radius, travel distance, and lifetime.
|
||||||
|
- `SPECIAL_EFFECT.FOCUS_LAYER` controls the temporary caster-emphasis stack: blurred battlefield snapshot depth/alpha, dim depth/alpha, caster depth, Blur FX quality/strength/steps, and fade timing.
|
||||||
|
|
||||||
|
# Update: Large-Battle Render Budget
|
||||||
|
|
||||||
|
- `PERFORMANCE.LARGE_BATTLE_RENDERED_FIGHTER_LIMIT` caps the number of physical fighter plans produced for large battles.
|
||||||
|
- `matchSetup.js` applies the budget after randomized elite compression by promoting normal 100-member blocks or normal remainder groups to elite groups as needed. It does not change represented `stackCount` population.
|
||||||
|
|
||||||
|
# Update: Large-Battle Elite Probability
|
||||||
|
|
||||||
|
- `FIGHTER.ELITE.RANDOMIZED_COMPRESSION.LARGE_BATTLE_ELITE_BLOCK_PROBABILITY` controls the randomized elite block ratio once the user-entered total fighter count exceeds `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD`.
|
||||||
|
- `matchSetup.js` keeps the threshold under `PERFORMANCE` and the elite ratio under `FIGHTER.ELITE`, so performance detection and elite balancing remain separately tunable.
|
||||||
|
|
||||||
|
# Update: Elite Magic Attack Effect Scale
|
||||||
|
|
||||||
|
- `FIGHTER.ATTACK_EFFECT_SCALE_MULTIPLIER` controls normal instant-spell attack effect size.
|
||||||
|
- `FIGHTER.ELITE.ATTACK_EFFECT_SCALE_MULTIPLIER` applies only to elite magic fighters, multiplying the normal spell-effect scale without changing body scale, HP, range, or damage formulas.
|
||||||
|
- Elite skin selection uses the full `FIGHTER.ELITE.TYPE` list, currently `melee` and `magic`.
|
||||||
|
|
||||||
|
# Update: Elite Balance Constants
|
||||||
|
|
||||||
|
- `FIGHTER.ELITE` contains elite type, stack/appearance/HP/range tuning, attack-damage and speed tuning, and randomized large-team compression settings inside the fighter domain.
|
||||||
|
- `COMBAT.CRITICAL_DAMAGE_PERCENT` sets elite critical damage from target max HP, while `COMBAT.NORMAL_CRITICAL_DAMAGE_MULTIPLIER` replaces normal-fighter instant critical kills with multiplied attack damage.
|
||||||
|
- `COMBAT.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.
|
||||||
|
- `COMBAT.KILL_REWARD_ENABLED` is `false` by default because one compressed kill is not equivalent to one represented casualty. The legacy heal/growth constants remain available only for an explicitly re-enabled mode.
|
||||||
|
- `WORLD_EFFECT.METEOR_DAMAGE_PERCENT` and `WORLD_EFFECT.FROST_DAMAGE_PERCENT` apply only to elite targets. Existing fixed `METEOR_DAMAGE` and `FROST_DAMAGE` remain the normal-target values.
|
||||||
|
- `ATTACK_DAMAGE_*`, `ATTACK_SPEED_*`, and `MOVE_SPEED_*` constants control elite stack bonuses. For each bonus, multiplier `0` removes the added bonus and multiplier `1` applies the configured stack exponent fully.
|
||||||
|
|
||||||
# Context: Core & Infrastructure
|
# Context: Core & Infrastructure
|
||||||
|
|
||||||
|
# Update: Dense-Area Meteor Barrage
|
||||||
|
|
||||||
|
- `WORLD_EFFECT.AREA_TILES` now defines the large warning/search square selected from the densest living-fighter region.
|
||||||
|
- `WORLD_EFFECT.WARNING_DURATION_MS` controls how long that large warning marker stays visible without changing the scheduled strikes.
|
||||||
|
- `WORLD_EFFECT.IMPACT_AREA_TILES`, `IMPACT_COUNT_MIN`, `IMPACT_COUNT_MAX`, `IMPACT_STAGGER_MS`, and `IMPACT_VISUAL_SCALE` configure the smaller strikes fired inside that warning square.
|
||||||
|
- `WORLD_EFFECT.SIZE_SCALE_VARIANCE` randomizes each fire/frost impact around the smaller strike size.
|
||||||
|
- `WORLD_EFFECT.INTERVAL` schedules the first barrage after match start, and `WORLD_EFFECT.REPEAT_INTERVAL` schedules later normal barrages.
|
||||||
|
- Meteor impact shake strength follows the same size multiplier, using `WORLD_EFFECT.METEOR_SHAKE_DURATION_MS` and `WORLD_EFFECT.METEOR_SHAKE_INTENSITY` as base values.
|
||||||
|
|
||||||
|
# Update: Direct Fighter Counts And Match Cap
|
||||||
|
|
||||||
|
- Live-match name entries use the `name*N` suffix as the assigned fighter count; a name without `*N` creates one assigned fighter.
|
||||||
|
- `SPAWN.MAX_FIGHTER_COUNT` is the maximum for participant-assigned fighter slots and is currently 8,000; Slime trait-generated fighters are excluded.
|
||||||
|
- Limit failures during live-match setup are surfaced beneath the participant nickname input.
|
||||||
|
- `SPAWN.FIGHTERS_PER_STARTING_ZONE` controls starting-zone distribution; each additional block of that many assigned fighters adds a team zone.
|
||||||
|
|
||||||
|
# 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, 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.
|
||||||
|
|
||||||
|
# Update: Dead Fighter Despawn Constant
|
||||||
|
|
||||||
|
- `FIGHTER.DEAD_DESPAWN_DELAY_MS` controls how long a dead fighter fades before disappearing; `FIGHTER.DEAD_DESPAWN_ALPHA` controls the fade target.
|
||||||
|
|
||||||
## 1. 모듈별 상세 역할
|
## 1. 모듈별 상세 역할
|
||||||
|
|
||||||
- **`src/main.js`**: Phaser 게임의 전역 설정(Physics, Scale, Canvas Parent)을 담당하며, `ArenaScene`을 인스턴스화합니다.
|
- **`src/main.js`**: Phaser 게임의 전역 설정(Physics, Scale, Canvas Parent)을 담당하며, `ArenaScene`을 인스턴스화합니다.
|
||||||
@@ -7,13 +64,16 @@
|
|||||||
- `Start` 버튼, 옵션 drawer, 전투 시작 submit 흐름을 제어하며 전투 시작 시 `#app`에 `match-live` 상태 클래스를 부여합니다.
|
- `Start` 버튼, 옵션 drawer, 전투 시작 submit 흐름을 제어하며 전투 시작 시 `#app`에 `match-live` 상태 클래스를 부여합니다.
|
||||||
- 전투 중 drawer 접기/펼치기(`drawer-collapsed`), 재시작 버튼, 일시정지 버튼 상태(`match-paused`)를 DOM 클래스와 `ArenaScene` 상태에 동기화합니다.
|
- 전투 중 drawer 접기/펼치기(`drawer-collapsed`), 재시작 버튼, 일시정지 버튼 상태(`match-paused`)를 DOM 클래스와 `ArenaScene` 상태에 동기화합니다.
|
||||||
- **`src/constants.js`**: 게임 내 모든 튜닝 수치를 관리합니다.
|
- **`src/constants.js`**: 게임 내 모든 튜닝 수치를 관리합니다.
|
||||||
- `ATTACK_DAMAGE_MIN`, `ATTACK_DAMAGE_MAX`: 일반 공격 1회 적중 시 적용되는 랜덤 피해량 범위.
|
- `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.*`: 첫/반복 포격 간격, 밀집 경고 범위, 개별 탄착 범위/발수/시각 배율, 대각선 낙하 거리, 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팀 이상에서는 팀 수에 맞춰 중복 없는 색상을 동적으로 생성합니다.
|
||||||
- `SPECTATOR_CAMERA_LERP`: 카메라 추적의 부드러움 정도.
|
- `CAMERA.SPECTATOR_LERP`: 카메라 추적의 부드러움 정도.
|
||||||
- `SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD`, `SPECTATOR_RANDOM_FOCUS_INTERVAL`, `FINAL_COMBAT_SLOW_MOTION_*`: 최종교전 관전 조건, 랜덤 포커싱 간격, 슬로우모션 on/off, 배율과 속도 램프 시간.
|
- `CAMERA.METEOR_FOCUS_ENABLED`, `CAMERA.METEOR_FOCUS_ZOOM`, `CAMERA.METEOR_FOCUS_HOLD_DURATION`: 자동 관전 진입 전 화염/냉기 메테오 착탄 위치의 임시 포커싱 on/off, 확대 배율 및 착탄 후 유지 시간.
|
||||||
|
- `CAMERA.SPECTATOR_LATE_FIGHTER_THRESHOLD`: 생존 인원 임계값에 따른 후반 자동 관전 진입 조건. (2팀만 남았더라도 이 수치보다 인원이 많으면 자동 관전을 유예합니다.)
|
||||||
|
- `CAMERA.SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD`, `CAMERA.SPECTATOR_RANDOM_FOCUS_INTERVAL`, `COMBAT.FINAL_SLOW_MOTION_*`: 최종교전 관전 조건, 랜덤 포커싱 간격, 슬로우모션 on/off, 배율과 속도 램프 시간.
|
||||||
- `MINIMAP_VIEWPORT_SIZE`: 미니맵의 고정 픽셀 크기.
|
- `MINIMAP_VIEWPORT_SIZE`: 미니맵의 고정 픽셀 크기.
|
||||||
- `ARENA_SIZE`: 경기장 전체 크기 (GRID * TILE).
|
- `ARENA_SIZE`: 경기장 전체 크기 (GRID * TILE).
|
||||||
|
|
||||||
@@ -21,8 +81,9 @@
|
|||||||
|
|
||||||
- **신규 캐릭터 추가**: `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` 또는 `fighterManifest.js` 내 개별 설정을 통해 변경하십시오.
|
- **물리 수치 조정**: 역할별 기본 체력/속도/사거리/공격 수치는 `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`의 `ATTACK_DAMAGE_MIN`, `ATTACK_DAMAGE_MAX`를 수정합니다. 캐릭터별 특수 공격 방식은 `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.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,3 +1,31 @@
|
|||||||
|
# Update: Field HUD Text Removal
|
||||||
|
|
||||||
|
- `fighterFactory.js` no longer creates pooled battlefield name labels. Zoom-visible and selected fighters can still show pooled health bars, while team identity comes from the team-colored sprite shadow.
|
||||||
|
- HUD cleanup now owns only health-bar display objects.
|
||||||
|
|
||||||
|
# Update: Elite Representative Fighter
|
||||||
|
|
||||||
|
- `fighterFactory.js` accepts `isElite` and `stackCount` on a spawn plan. Elite attack damage is tuned by `FIGHTER.ELITE.ATTACK_DAMAGE_BONUS_MULTIPLIER` and `ATTACK_DAMAGE_STACK_EXPONENT`; HP, scale, and range use the other nested elite settings.
|
||||||
|
- `fighterSelection.js` assigns elite plans only skins whose derived type matches `FIGHTER.ELITE.TYPE` (currently `melee` and `magic`); normal plans still draw from the complete manifest.
|
||||||
|
- Elite scale becomes its `baseScaleX`/`baseScaleY`; kill-growth is currently disabled by `COMBAT.KILL_REWARD_ENABLED = false`, but this baseline remains correct if a separate mode enables it later.
|
||||||
|
- Elite magic attack effects are scaled in `combat.js` through `FIGHTER.ELITE.ATTACK_EFFECT_SCALE_MULTIPLIER`, so spell visuals can be tuned independently from body scale.
|
||||||
|
- Elite representatives cannot use `splitOnDeath`. Elite attack-speed and movement-speed bonuses are configurable independently under `FIGHTER.ELITE`.
|
||||||
|
|
||||||
|
# Update: HUD Pooling
|
||||||
|
|
||||||
|
- `fighterFactory.js` no longer creates permanent health bars for every fighter, and battlefield name labels are not created at all.
|
||||||
|
- Health-bar HUD display objects are pooled on the scene and assigned only to selected fighters or zoom-visible nearby fighters chosen by `ArenaScene`.
|
||||||
|
- `syncFighterHud()` acquires a slot lazily and `releaseFighterHud()` returns it to the pool when the fighter leaves the HUD candidate set, dies, or is destroyed.
|
||||||
|
- Tune pool size and visible candidate limits in `PERFORMANCE.FIGHTER_HUD_POOL_SIZE` and `PERFORMANCE.FIGHTER_HUD_VISIBLE_LIMIT`.
|
||||||
|
|
||||||
|
# Update: Team Shadow Sprite Optimization
|
||||||
|
|
||||||
|
- Team identity is no longer rendered with a duplicated `teamMarker` sprite. `fighterFactory.js` now creates only the main Phaser sprite for each fighter.
|
||||||
|
- `fighterAssets.js` lazily creates team-colored spritesheets for the actual `skin + action + teamColor` combinations used in a match. The derived texture keeps the original character art and replaces only the floor shadow color (`#534545`) in the lower frame band (`y=55..59`) with the team color.
|
||||||
|
- Combat animation playback calls `ensureFighterTeamAnimation()` before switching actions, so idle, walk, attack, hurt, and death states keep the same team shadow treatment.
|
||||||
|
- This reduces display-list cost from `fighter sprite + teamMarker sprite` to a single fighter sprite. The tradeoff is extra texture memory for team-colored derivatives, so derived textures must stay lazy and should not be pre-generated for the whole manifest.
|
||||||
|
- Frost stun still uses the fighter body's `setTint(WORLD_EFFECT.FROST_STUN_TINT)`. Do not reuse tint for team identity; team color is baked into the shadow pixels instead.
|
||||||
|
|
||||||
# Context: Fighter & Assets
|
# Context: Fighter & Assets
|
||||||
|
|
||||||
## 1. 모듈별 상세 역할 (`src/game/fighter/`)
|
## 1. 모듈별 상세 역할 (`src/game/fighter/`)
|
||||||
@@ -5,6 +33,7 @@
|
|||||||
- **`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` 역할을 판별하고 역할별 기본 스탯과 스킨별 오버라이드를 병합합니다.
|
||||||
- **`fighterSelection.js`**: 매치 참여 캐릭터를 무작위로 선택하거나 섞는 로직을 담당합니다.
|
- **`fighterSelection.js`**: 매치 참여 캐릭터를 무작위로 선택하거나 섞는 로직을 담당합니다.
|
||||||
|
|
||||||
## 2. 주요 로직 구현 세부 사항
|
## 2. 주요 로직 구현 세부 사항
|
||||||
@@ -19,11 +48,18 @@
|
|||||||
### 캐릭터 HUD 및 상태 동기화
|
### 캐릭터 HUD 및 상태 동기화
|
||||||
- **이름표 고정**: 스프라이트 중심이 아닌 실제 히트박스 하단에 고정되어 시각적 일관성을 유지합니다.
|
- **이름표 고정**: 스프라이트 중심이 아닌 실제 히트박스 하단에 고정되어 시각적 일관성을 유지합니다.
|
||||||
- **사망자 처리**: 사망 시 HUD와 팀 마커를 숨겨 화면 가독성을 높입니다. 본체 sprite만 낮은 depth와 반투명 상태로 남깁니다.
|
- **사망자 처리**: 사망 시 HUD와 팀 마커를 숨겨 화면 가독성을 높입니다. 본체 sprite만 낮은 depth와 반투명 상태로 남깁니다.
|
||||||
|
- **월드 감속 상태**: 생성 시 `worldEffectSpeedMultiplier`를 `1`로 초기화하며, 냉각지대 안에서는 `worldEffects.js`가 해당 배율을 낮춰 공격속도와 이동속도 계산에 반영합니다.
|
||||||
|
- **냉기 동결 상태**: `isFrostStunned`와 동결 타이머를 캐릭터별로 관리합니다. 냉기 메테오 착탄에 생존하면 캐릭터 본체와 팀 실루엣 마커가 함께 얼음색으로 바뀌고, 동결 종료 시 본체 원본 색상과 저장된 팀 색상으로 복구됩니다.
|
||||||
|
|
||||||
### 캐릭터별 특성 (예: 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.fighterType`에 `melee`, `ranged`, `magic` 중 하나를 명시합니다.
|
||||||
|
- 개별 스킨의 기존 `stats.maxHp`, `combat.range`, `combat.cooldown`, `combat.criticalChance`, `combat.projectile.speed`, `combat.attackEffect.hitDelay` 설정은 역할별 기본값보다 우선합니다.
|
||||||
|
|
||||||
## 3. 유지보수 규칙
|
## 3. 유지보수 규칙
|
||||||
- **신규 캐릭터**: 에셋 배치 후 `fighterManifest.js`에 정의를 추가합니다.
|
- **신규 캐릭터**: 에셋 배치 후 `fighterManifest.js`에 정의를 추가합니다.
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
- Match setup no longer exposes a separate team-size control. Each live entry uses `nickname*N` for that team's assigned fighter count, with plain names defaulting to one fighter.
|
||||||
|
- A live match is rejected before replacing the current match only when the participant-assigned count exceeds `SPAWN.MAX_FIGHTER_COUNT`; Slime `spawnMultiplier` and `splitOnDeath` results may grow the actual live population past it.
|
||||||
|
- Fighter-count cap validation is reported as a visually distinct warning card below the participant nickname textarea, with requested and allowed counts emphasized separately; it clears when that input changes or a valid live match is submitted.
|
||||||
|
- Starting-zone placement assigns one zone per `SPAWN.FIGHTERS_PER_STARTING_ZONE` fighters in each team and puts any remainder in that team's final zone.
|
||||||
|
|
||||||
## 1. 모듈별 상세 역할
|
## 1. 모듈별 상세 역할
|
||||||
|
|
||||||
### 매치 로직 (`src/game/match/`)
|
### 매치 로직 (`src/game/match/`)
|
||||||
@@ -17,15 +48,17 @@
|
|||||||
## 2. 주요 로직 구현 세부 사항
|
## 2. 주요 로직 구현 세부 사항
|
||||||
|
|
||||||
### 매치 설정 및 스폰 배치
|
### 매치 설정 및 스폰 배치
|
||||||
- **완전 랜덤 배치**: 전장 전체 스폰 슬롯을 무작위로 섞어 배치합니다.
|
- **닉네임 배수 시스템**: `닉네임*배수` 형식(예: `Alice*2`)을 감지하여 팀 인원을 배수만큼 생성합니다.
|
||||||
- **스타팅 지점 배치**: 참가자 수에 맞춰 전장을 구역으로 나눈 뒤, 참가자별 구역 배정을 매치마다 섞고 구역 내 무작위 위치에 스폰합니다.
|
- **구매 배수 보존과 독주 견제**: 배수 팀의 생성 인원과 전투 수치는 결제 이점으로 유지합니다. 월드 이펙트 표적 선정에서는 `team.multiplier`를 구매 지분으로 사용하고, 그 지분을 초과해 생존 중인 팀에만 설정 가능한 추가 표적 가중치를 적용합니다.
|
||||||
|
- **스타팅 지점 배치 (멀티 스폰)**: 팀마다 전장 스폰 가능 그리드에서 중심 셀을 무작위로 고르고, 중심 주변 2칸(`5 x 5`)을 해당 팀의 스타팅 영역으로 사용합니다. 배수가 설정된 팀은 배수만큼의 독립적인 스타팅 영역을 할당받아 병력이 분산 배치됩니다. 겹치지 않는 후보가 남아 있는 동안에는 해당 후보를 우선 선택하며, 영역은 매치 시작 후 5초 동안만 팀 색상으로 매우 옅게 표시되고 팀 전투원은 이 안에서만 스폰합니다.
|
||||||
- **설정 유지**: 닉네임, 인원, 배치 모드는 `localStorage`에 저장되어 재접속 시 복원됩니다.
|
- **설정 유지**: 닉네임, 인원, 배치 모드는 `localStorage`에 저장되어 재접속 시 복원됩니다.
|
||||||
|
|
||||||
### 전투 화면 레이아웃 (HUD)
|
### 전투 화면 레이아웃 (HUD)
|
||||||
- **팀 Badge**: 좌측 HUD 레일에 배치되며, 클릭 시 해당 팀의 생존 유닛 중 무작위 1명으로 시점을 고정합니다.
|
- **팀 Badge**: 좌측 HUD 레일에 배치되며, 클릭 시 해당 팀의 생존 유닛 중 무작위 1명으로 시점을 고정합니다. 이미 고정된 동일 팀 Badge를 다시 클릭하면 선택을 해제하고 기본 줌을 요청합니다. 단, 자동 관전 줌 조건이 활성화되어 있으면 다음 카메라 갱신에서 자동 줌이 즉시 다시 적용됩니다.
|
||||||
- **킬로그**: 처치자와 피처치자를 좌우로 배치하고, 피처치자 아이콘에 빨간 X를 겹쳐 사망 관계를 명확히 표시합니다.
|
- **팀 Badge 갱신 안정성**: 사망으로 생존 수가 바뀔 때 기존 badge 버튼 DOM을 유지한 채 숫자, 비활성 상태, 선택 강조만 갱신하여 사망 프레임에 겹친 클릭도 시점 고정으로 전달되도록 합니다.
|
||||||
|
- **킬로그**: 처치자와 피처치자를 좌우로 배치하고, 피처치자 아이콘에 빨간 X를 겹쳐 사망 관계를 명확히 표시합니다. 캐릭터 idle 시트의 `100x100` 프레임 내 투명 여백을 제외한 중앙 하단 영역을 확대 표시해 작은 아이콘 박스에서도 실루엣이 충분히 보이도록 합니다.
|
||||||
- **하단 메타 정보**: 전투 화면 우측 하단(`arena-meta` 컨테이너)에 방문자 카운터와 About 버튼이 Pill(알약) 형태로 디자인이 통일되어 나란히 고정 배치됩니다. 드로어가 열려도 동일한 위치를 유지합니다.
|
- **하단 메타 정보**: 전투 화면 우측 하단(`arena-meta` 컨테이너)에 방문자 카운터와 About 버튼이 Pill(알약) 형태로 디자인이 통일되어 나란히 고정 배치됩니다. 드로어가 열려도 동일한 위치를 유지합니다.
|
||||||
- **모바일 레이아웃**: 실제 전투 시작 시 모바일에서는 옵션 drawer를 자동으로 접고, 상단 팀 HUD는 옵션 버튼 폭을 제외한 영역에 두 줄 4열로 맞춰 4개 이후 팀도 잘리지 않게 합니다. 모바일 팀 카드 선택 표시는 내부 테두리로 처리해 외곽선이 잘려 보이지 않게 합니다. 킬로그는 전투 캔버스 바로 아래에 배치하되 하단 메타 정보(방문자 카운터/About)와 겹치지 않게 안전 여백을 확보합니다.
|
- **모바일 레이아웃**: 실제 전투 시작 시 모바일에서는 옵션 drawer를 자동으로 접고, 상단 팀 HUD는 옵션 버튼 폭을 제외한 영역에 두 줄로 배치됩니다. 이때 데스크톱의 고정 가로폭 상속을 방지(`grid-template-columns: none`)하여 모든 팀 카드가 균일한 가로폭을 유지하도록 하며, 4개 이후 팀도 스크롤을 통해 확인할 수 있습니다. 모바일 팀 카드 선택 표시는 내부 테두리로 처리해 외곽선이 잘려 보이지 않게 합니다. 킬로그는 전투 캔버스 바로 아래에 배치하되 하단 메타 정보(방문자 카운터/About)와 겹치지 않게 안전 여백을 확보합니다.
|
||||||
- **모바일 옵션 drawer**: 전투 중 펼친 옵션 drawer는 닉네임 입력 높이와 컨트롤 간격을 줄여 전투 시작/재시작/일시정지 버튼이 작은 화면에서도 한 번에 보이도록 합니다.
|
- **모바일 옵션 drawer**: 전투 중 펼친 옵션 drawer는 닉네임 입력 높이와 컨트롤 간격을 줄여 전투 시작/재시작/일시정지 버튼이 작은 화면에서도 한 번에 보이도록 합니다.
|
||||||
- **승리 연출**: 승리 시 Web Audio 기반 팡파르와 CSS 애니메이션(광선, 컨페티)을 결합해 화려하게 연출합니다. 전투 종료 시 옵션 drawer를 접어 결과 배너가 설정 폼과 충돌하지 않게 하며, 결과 배너는 일정 시간 후 자동으로 사라지거나 클릭 시 즉시 닫힙니다. 무승부는 더 차분한 톤을 사용합니다.
|
- **승리 연출**: 승리 시 Web Audio 기반 팡파르와 CSS 애니메이션(광선, 컨페티)을 결합해 화려하게 연출합니다. 전투 종료 시 옵션 drawer를 접어 결과 배너가 설정 폼과 충돌하지 않게 하며, 결과 배너는 일정 시간 후 자동으로 사라지거나 클릭 시 즉시 닫힙니다. 무승부는 더 차분한 톤을 사용합니다.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Context: Style & Design
|
||||||
|
|
||||||
|
## 1. CSS 모듈 구조 (src/styles/)
|
||||||
|
|
||||||
|
이 프로젝트는 거대한 단일 CSS 파일을 지양하고, 기능별로 분리된 모듈형 CSS 구조를 채택하고 있습니다. `src/styles.css`는 각 모듈을 통합하는 엔트리 포인트 역할을 합니다.
|
||||||
|
|
||||||
|
- **`base.css`**: 전역 변수(`:root`), 리셋 스타일, 레이아웃의 뼈대(`#app`, `#game`, `.arena-shell`)를 정의합니다.
|
||||||
|
- **`intro.css`**: 대기 화면, 로고 애니메이션, 전투 프리뷰 연출 스타일을 담당합니다.
|
||||||
|
- **`game-ui.css`**: 스코어보드(팀 badge), 킬로그, 상단 전투 안내바, 승리/무승부 축하 레이어 등 실제 게임 진행 중 노출되는 모든 HUD 요소를 관리합니다.
|
||||||
|
- **`overlay.css`**: 설정 드로어(전투 옵션 폼), About 다이얼로그 및 공통 폼 컨트롤 스타일을 정의합니다.
|
||||||
|
- **`animations.css`**: 프로젝트 전역에서 재사용되는 `@keyframes`와 애니메이션 관련 유틸리티 클래스를 포함합니다.
|
||||||
|
- **`mobile.css`**: `960px` 이하 해상도를 위한 미디어 쿼리 오버라이드 스타일을 통합 관리합니다. 모바일 전용 레이아웃 조정 및 터치 최적화 스타일이 포함됩니다.
|
||||||
|
|
||||||
|
## 2. 디자인 시스템 및 변수
|
||||||
|
|
||||||
|
- **색상 체계**: 어두운 배경(`#080a07`)과 금색/주황색 계열의 포인트 컬러(`rgb(238 185 73)`)를 사용하여 판타지 아레나 분위기를 연출합니다.
|
||||||
|
- **반응형 대응**: `clamp()`, `min()`, `calc()` 등 현대적인 CSS 함수를 적극 활용하여 다양한 화면 크기에서도 유연하게 대응합니다.
|
||||||
|
- **가독성**: 텍스트 섀도우와 반투명 배경(`backdrop-filter`)을 활용해 복잡한 전투 화면 위에서도 UI 요소의 시인성을 확보합니다.
|
||||||
|
|
||||||
|
## 3. 스타일 수정 가이드
|
||||||
|
|
||||||
|
- **전역 상수 변경**: 색상이나 기본 여백 등은 `base.css`의 `:root` 변수를 먼저 확인하십시오.
|
||||||
|
- **컴포넌트 스타일 수정**: 수정하려는 UI 요소가 속한 카테고리에 맞는 파일을 열어 작업하십시오. (예: 킬로그 수정 -> `game-ui.css`)
|
||||||
|
- **모바일 레이아웃 수정**: 데스크톱 스타일을 수정한 후에는 `mobile.css`에서 해당 요소가 모바일에서 어떻게 보이는지 반드시 확인하고 필요한 경우 오버라이드하십시오.
|
||||||
|
- **애니메이션 추가**: 새로운 키프레임은 `animations.css`에 추가하여 중앙 집중식으로 관리합니다.
|
||||||
@@ -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 양쪽 흐름을 수동 확인한다.
|
||||||
@@ -4,6 +4,25 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Arena Picker</title>
|
<title>Arena Picker</title>
|
||||||
|
|
||||||
|
<!-- OpenGraph Meta Tags -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="Arena Picker" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="친구들의 닉네임을 입력하고 최후의 승자를 가려보세요! 화려한 픽셀 아트 기반 자동 전투 시뮬레이션."
|
||||||
|
/>
|
||||||
|
<meta property="og:image" content="/assets/og-image.png" />
|
||||||
|
|
||||||
|
<!-- Twitter Meta Tags -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Arena Picker" />
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content="친구들의 닉네임을 입력하고 최후의 승자를 가려보세요! 화려한 픽셀 아트 기반 자동 전투 시뮬레이션."
|
||||||
|
/>
|
||||||
|
<meta name="twitter:image" content="/assets/og-image.png" />
|
||||||
|
|
||||||
<link
|
<link
|
||||||
rel="icon"
|
rel="icon"
|
||||||
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>⚔️</text></svg>"
|
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>⚔️</text></svg>"
|
||||||
@@ -143,8 +162,13 @@
|
|||||||
<form id="fighter-form" autocomplete="off">
|
<form id="fighter-form" autocomplete="off">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Players</legend>
|
<legend>Players</legend>
|
||||||
<label for="player-names">참가자 닉네임</label>
|
<label for="player-names">참가자 닉네임 (*숫자 = 출전 인원)</label>
|
||||||
<textarea id="player-names" name="playerNames" rows="10">
|
<textarea
|
||||||
|
id="player-names"
|
||||||
|
name="playerNames"
|
||||||
|
rows="10"
|
||||||
|
aria-describedby="player-names-warning"
|
||||||
|
>
|
||||||
Player 1
|
Player 1
|
||||||
Player 2
|
Player 2
|
||||||
Player 3
|
Player 3
|
||||||
@@ -156,31 +180,35 @@ Player 8
|
|||||||
Player 9
|
Player 9
|
||||||
Player 10</textarea
|
Player 10</textarea
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
id="player-names-warning"
|
||||||
|
class="player-names-warning"
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<span class="player-names-warning__badge" aria-hidden="true">⚠</span>
|
||||||
|
<strong class="player-names-warning__title" data-player-names-warning-title
|
||||||
|
>최대 출전 인원 초과</strong
|
||||||
|
>
|
||||||
|
<p class="player-names-warning__detail">
|
||||||
|
출전 인원
|
||||||
|
<strong class="player-names-warning__count" data-player-names-warning-count
|
||||||
|
>0</strong
|
||||||
|
>명 / 최대
|
||||||
|
<strong class="player-names-warning__limit" data-player-names-warning-limit
|
||||||
|
>0</strong
|
||||||
|
>명
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="player-names-warning__reason"
|
||||||
|
data-player-names-warning-reason
|
||||||
|
hidden
|
||||||
|
></p>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Match</legend>
|
<legend>Match</legend>
|
||||||
<div class="team-size-row">
|
|
||||||
<label for="team-size">팀당 인원</label>
|
|
||||||
<input
|
|
||||||
id="team-size-value"
|
|
||||||
class="team-size-number"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="100"
|
|
||||||
step="1"
|
|
||||||
value="5"
|
|
||||||
inputmode="numeric"
|
|
||||||
aria-label="팀당 인원 직접 입력"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="team-size"
|
|
||||||
name="teamSize"
|
|
||||||
type="range"
|
|
||||||
min="1"
|
|
||||||
max="100"
|
|
||||||
value="5"
|
|
||||||
/>
|
|
||||||
<div class="spawn-placement-field">
|
<div class="spawn-placement-field">
|
||||||
<span id="spawn-placement-label" class="spawn-placement-label"
|
<span id="spawn-placement-label" class="spawn-placement-label"
|
||||||
>리스폰 설정</span
|
>리스폰 설정</span
|
||||||
@@ -196,7 +224,7 @@ Player 10</textarea
|
|||||||
name="spawnPlacement"
|
name="spawnPlacement"
|
||||||
value="starting-zones"
|
value="starting-zones"
|
||||||
/>
|
/>
|
||||||
<span>집결 배치</span>
|
<span>스타팅 지점 배치</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="spawn-placement-option">
|
<label class="spawn-placement-option">
|
||||||
<input
|
<input
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 481 KiB |
@@ -1,174 +1,385 @@
|
|||||||
// 경기장을 구성하는 격자 칸 수입니다. 값이 커질수록 전장이 넓어집니다.
|
// 1. ARENA 도메인
|
||||||
export const GRID_SIZE = 50;
|
const GRID_SIZE = 50;
|
||||||
// 격자 한 칸의 픽셀 크기입니다. 경기장 크기와 좌표 간격에 영향을 줍니다.
|
const TILE_SIZE = 64;
|
||||||
export const TILE_SIZE = 64;
|
const ARENA_SIZE = GRID_SIZE * TILE_SIZE;
|
||||||
// 실제 전장 전체 픽셀 크기입니다. GRID_SIZE와 TILE_SIZE를 기반으로 계산합니다.
|
const VIEWPORT_SIZE = 1600;
|
||||||
export const ARENA_SIZE = GRID_SIZE * TILE_SIZE;
|
const CAMERA_ZOOM_SCALE = VIEWPORT_SIZE / ARENA_SIZE;
|
||||||
|
|
||||||
// 근접 캐릭터가 공격을 시작할 수 있는 기본 거리입니다.
|
export const ARENA = {
|
||||||
export const ATTACK_RANGE = 84;
|
GRID_SIZE,
|
||||||
// 기본 공격 쿨다운(ms)입니다. 낮을수록 공격 빈도가 높아집니다.
|
TILE_SIZE,
|
||||||
export const ATTACK_COOLDOWN = 840;
|
SIZE: ARENA_SIZE,
|
||||||
// 공격이 한 번 적중했을 때 적용되는 최소 피해량입니다.
|
|
||||||
export const ATTACK_DAMAGE_MIN = 14;
|
|
||||||
// 공격이 한 번 적중했을 때 적용되는 최대 피해량입니다.
|
|
||||||
export const ATTACK_DAMAGE_MAX = 24;
|
|
||||||
// 새 매치가 시작될 때 기본 팀당 캐릭터 수입니다.
|
|
||||||
export const DEFAULT_TEAM_SIZE = 5;
|
|
||||||
// 전투 시작 시 전투원을 배치하는 기본 방식입니다.
|
|
||||||
export const DEFAULT_SPAWN_PLACEMENT = "random";
|
|
||||||
// 전투 설정 UI와 매치 생성 로직이 공유하는 스폰 배치 모드입니다.
|
|
||||||
export const SPAWN_PLACEMENTS = {
|
|
||||||
RANDOM: DEFAULT_SPAWN_PLACEMENT,
|
|
||||||
STARTING_ZONES: "starting-zones",
|
|
||||||
};
|
};
|
||||||
// 최초 접속 대기 전투에서 고정으로 보여줄 팀 수입니다.
|
|
||||||
export const PRESENTATION_TEAM_COUNT = 10;
|
|
||||||
// 최초 접속 대기 전투에서 팀마다 배치할 전투원 수입니다.
|
|
||||||
export const PRESENTATION_TEAM_SIZE = 5;
|
|
||||||
// 캐릭터 스프라이트의 기본 화면 배율입니다.
|
|
||||||
export const FIGHTER_SCALE = 3;
|
|
||||||
export const FIGHTER_DEPTH = 2;
|
|
||||||
export const DEAD_FIGHTER_DEPTH = 1;
|
|
||||||
export const DEAD_FIGHTER_ALPHA = 0.42;
|
|
||||||
// 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 너비입니다.
|
|
||||||
export const FIGHTER_FRAME_WIDTH = 100;
|
|
||||||
// 캐릭터 스프라이트시트에서 한 프레임이 차지하는 원본 높이입니다.
|
|
||||||
export const FIGHTER_FRAME_HEIGHT = 100;
|
|
||||||
// 캐릭터 히트박스의 원본 프레임 기준 너비입니다.
|
|
||||||
export const FIGHTER_HITBOX_WIDTH = 22;
|
|
||||||
// 캐릭터 히트박스의 원본 프레임 기준 높이입니다.
|
|
||||||
export const FIGHTER_HITBOX_HEIGHT = 20;
|
|
||||||
// 100x100 프레임 안에서 히트박스가 시작되는 X 좌표입니다.
|
|
||||||
export const FIGHTER_HITBOX_OFFSET_X = 39;
|
|
||||||
// 100x100 프레임 안에서 히트박스가 시작되는 Y 좌표입니다. 실제 캐릭터 픽셀 하단은 대체로 y=59입니다.
|
|
||||||
export const FIGHTER_HITBOX_OFFSET_Y = 40;
|
|
||||||
// 캐릭터의 기본 최대 체력입니다.
|
|
||||||
export const FIGHTER_MAX_HP = 100;
|
|
||||||
// 적 처치 시 현재 체력 기준으로 회복되는 비율입니다.
|
|
||||||
export const KILL_HEALTH_RECOVERY_RATIO = 0.3;
|
|
||||||
// 처치 회복 이펙트 스프라이트시트의 프레임 수입니다.
|
|
||||||
export const KILL_HEAL_EFFECT_FRAMES = 4;
|
|
||||||
// 처치 회복 이펙트 애니메이션의 초당 프레임 수입니다.
|
|
||||||
export const KILL_HEAL_EFFECT_FRAME_RATE = 12;
|
|
||||||
// 적 처치 시 크기, 공격속도, 이동속도에 누적 적용되는 배율입니다.
|
|
||||||
export const KILL_GROWTH_MULTIPLIER = 1.25;
|
|
||||||
// 처치 보상으로 누적 적용되는 최대 배율입니다. 기본 scale에 곱해지는 상한이기도 합니다.
|
|
||||||
export const KILL_GROWTH_MAX_MULTIPLIER = 5;
|
|
||||||
// 처치 성장 연출 tween 지속 시간(ms)입니다.
|
|
||||||
export const KILL_GROWTH_TWEEN_DURATION = 180;
|
|
||||||
// 입력 UI에서 허용하는 팀당 최대 캐릭터 수입니다.
|
|
||||||
export const MAX_TEAM_SIZE = 100;
|
|
||||||
// 근접 캐릭터의 기본 치명타 확률입니다. 치명타는 즉시 처치로 처리됩니다.
|
|
||||||
export const MELEE_CRITICAL_CHANCE = 0.05;
|
|
||||||
// 캐릭터 기본 이동 속도입니다. 처치 보상과 전역 이동 배율이 곱해집니다.
|
|
||||||
export const MOVE_SPEED = 148;
|
|
||||||
// 투사체가 자동으로 사라지기까지의 시간(ms)입니다.
|
|
||||||
export const PROJECTILE_LIFETIME = 1800;
|
|
||||||
// 투사체 기본 이동 속도입니다. 처치 보상과 전역 공격 배율이 곱해집니다.
|
|
||||||
export const PROJECTILE_SPEED = 420;
|
|
||||||
// 원거리 캐릭터의 기본 치명타 확률입니다.
|
|
||||||
export const RANGED_CRITICAL_CHANCE = 0;
|
|
||||||
// 원거리 캐릭터가 공격을 시작할 수 있는 기본 거리입니다.
|
|
||||||
export const RANGED_ATTACK_RANGE = TILE_SIZE * 5;
|
|
||||||
|
|
||||||
// 근접 공격 애니메이션 시작 후 실제 피해가 들어가기까지의 지연(ms)입니다.
|
export const RENDER = {
|
||||||
export const MELEE_HIT_DELAY = 260;
|
VIEWPORT_SIZE,
|
||||||
// 원거리 공격 애니메이션 시작 후 투사체가 발사되기까지의 지연(ms)입니다.
|
CAMERA_ZOOM_SCALE,
|
||||||
export const PROJECTILE_FIRE_DELAY = 360;
|
};
|
||||||
// 투사체 충돌 원형 바디가 이미지 안에서 시작되는 오프셋입니다.
|
|
||||||
export const PROJECTILE_BODY_OFFSET = 4;
|
|
||||||
// 투사체 궤적 충돌 검사 시 대상 히트박스에 더하는 여유 픽셀입니다.
|
|
||||||
export const PROJECTILE_HIT_PADDING = 20;
|
|
||||||
// 투사체 충돌 원형 바디의 반지름입니다.
|
|
||||||
export const PROJECTILE_HIT_RADIUS = 12;
|
|
||||||
// 투사체가 공격자 위치에서 얼마나 떨어져 생성되는지 정하는 거리입니다.
|
|
||||||
export const PROJECTILE_SPAWN_DISTANCE = 1;
|
|
||||||
// 즉발 마법 캐스팅 후 이펙트가 생성되기까지의 지연(ms)입니다.
|
|
||||||
export const SPELL_CAST_DELAY = 340;
|
|
||||||
// 마법 이펙트 생성 후 실제 피해가 들어가기까지의 지연(ms)입니다.
|
|
||||||
export const SPELL_HIT_DELAY = 160;
|
|
||||||
|
|
||||||
// 카메라 최소 줌입니다. 전장 전체를 보는 기본 배율입니다.
|
// 2. FIGHTER 도메인
|
||||||
export const CAMERA_MIN_ZOOM = 1;
|
export const FIGHTER = {
|
||||||
// 카메라 최대 줌입니다. 후반 관전 및 휠 확대의 상한입니다.
|
SCALE: 3,
|
||||||
export const CAMERA_MAX_ZOOM = 3;
|
ATTACK_EFFECT_SCALE_MULTIPLIER: 1,
|
||||||
// 마우스 휠 한 번당 카메라 줌 변화량입니다.
|
DEPTH: 2,
|
||||||
export const CAMERA_ZOOM_STEP = 0.1;
|
DEAD_DEPTH: 1,
|
||||||
// 미니맵 카메라가 보일 때의 투명도입니다.
|
DEAD_DESPAWN_ALPHA: 0,
|
||||||
export const MINIMAP_ALPHA = 0.8;
|
DEAD_DESPAWN_DELAY_MS: 5000,
|
||||||
// 미니맵이 화면 가장자리에서 떨어지는 거리입니다.
|
FRAME_WIDTH: 100,
|
||||||
export const MINIMAP_MARGIN = Math.round(ARENA_SIZE * 0.016);
|
FRAME_HEIGHT: 100,
|
||||||
// 미니맵의 고정 픽셀 크기입니다.
|
HITBOX_WIDTH: 22,
|
||||||
export const MINIMAP_VIEWPORT_SIZE = Math.round(ARENA_SIZE * 0.22);
|
HITBOX_HEIGHT: 20,
|
||||||
// 미니맵 현재 뷰포트 표시용 선 두께입니다.
|
HITBOX_OFFSET_X: 39,
|
||||||
export const MINIMAP_VIEW_FRAME_STROKE = 10;
|
HITBOX_OFFSET_Y: 40,
|
||||||
// 관전 카메라가 목표 전투 지점으로 따라가는 부드러움입니다.
|
NICKNAME_LENGTH: 24,
|
||||||
export const SPECTATOR_CAMERA_LERP = 0.1;
|
// 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수
|
||||||
// 생존자가 이 수보다 적으면 최종 전투 줌을 적용합니다.
|
ANIMATION_OPTIONS: {
|
||||||
export const SPECTATOR_FINAL_FIGHTER_THRESHOLD = 5;
|
|
||||||
// 최종 전투 구간에서 강제로 적용되는 카메라 줌입니다.
|
|
||||||
export const SPECTATOR_FINAL_FIGHT_ZOOM = 3;
|
|
||||||
export const SPECTATOR_FINAL_TEAM_COUNT = 2;
|
|
||||||
export const SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD = 8;
|
|
||||||
export const SPECTATOR_RANDOM_FOCUS_INTERVAL = 2400;
|
|
||||||
// 최종교전 슬로우모션 연출을 켜고 끕니다.
|
|
||||||
export const FINAL_COMBAT_SLOW_MOTION_ENABLED = false;
|
|
||||||
// 최종교전 공격 시작에서 슬로우 배율로 내려가는 속도 램프 시간(ms)입니다.
|
|
||||||
export const FINAL_COMBAT_SLOW_MOTION_ENTER_DURATION = 14000;
|
|
||||||
// 최종교전 공격을 슬로우 배율로 붙잡아 두는 시간(ms)입니다.
|
|
||||||
export const FINAL_COMBAT_SLOW_MOTION_HOLD_DURATION = 14000;
|
|
||||||
// 최종교전 슬로우에서 기본 속도로 복귀하는 속도 램프 시간(ms)입니다.
|
|
||||||
export const FINAL_COMBAT_SLOW_MOTION_EXIT_DURATION = 14000;
|
|
||||||
export const FINAL_COMBAT_SLOW_MOTION_SCALE = 0.28;
|
|
||||||
// 생존자가 이 수보다 적으면 후반 전투 줌을 적용합니다.
|
|
||||||
export const SPECTATOR_LATE_FIGHTER_THRESHOLD = 30;
|
|
||||||
// 후반 전투 구간에서 강제로 적용되는 카메라 줌입니다.
|
|
||||||
export const SPECTATOR_LATE_FIGHT_ZOOM = 2;
|
|
||||||
// 캐릭터를 선택했을 때 최소로 확보하는 카메라 줌입니다.
|
|
||||||
export const SELECTED_FIGHTER_CAMERA_ZOOM = 2;
|
|
||||||
// 선택 실루엣과 원본 캐릭터 사이에 비워두는 픽셀 간격입니다.
|
|
||||||
export const SELECTED_FIGHTER_OUTLINE_GAP = 1;
|
|
||||||
// 선택 실루엣 자체가 차지하는 픽셀 두께입니다.
|
|
||||||
export const SELECTED_FIGHTER_OUTLINE_WIDTH = 1;
|
|
||||||
// 선택 실루엣의 빨간색 채널 값입니다.
|
|
||||||
export const SELECTED_FIGHTER_OUTLINE_RED = 255;
|
|
||||||
// 선택 실루엣의 초록색 채널 값입니다.
|
|
||||||
export const SELECTED_FIGHTER_OUTLINE_GREEN = 228;
|
|
||||||
// 선택 실루엣의 파란색 채널 값입니다.
|
|
||||||
export const SELECTED_FIGHTER_OUTLINE_BLUE = 64;
|
|
||||||
// 선택 실루엣의 전체 투명도입니다. 0.65는 윤곽을 또렷하게 보이면서 원본 캐릭터를 덮지 않습니다.
|
|
||||||
export const SELECTED_FIGHTER_OUTLINE_ALPHA = 0.65;
|
|
||||||
|
|
||||||
// 참가자 닉네임을 잘라낼 최대 글자 수입니다.
|
|
||||||
export const NICKNAME_LENGTH = 18;
|
|
||||||
|
|
||||||
// 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수입니다.
|
|
||||||
export const FIGHTER_ANIMATION_OPTIONS = {
|
|
||||||
// 기본 공격 애니메이션 속도입니다.
|
|
||||||
attack: { frameRate: 15, repeat: 0 },
|
attack: { frameRate: 15, repeat: 0 },
|
||||||
// 보조 공격 애니메이션 속도입니다.
|
|
||||||
attack02: { frameRate: 15, repeat: 0 },
|
attack02: { frameRate: 15, repeat: 0 },
|
||||||
// 강공격/치명타용 공격 애니메이션 속도입니다.
|
|
||||||
attack03: { frameRate: 15, repeat: 0 },
|
attack03: { frameRate: 15, repeat: 0 },
|
||||||
// 방어 애니메이션 속도입니다.
|
|
||||||
block: { frameRate: 13, repeat: 0 },
|
block: { frameRate: 13, repeat: 0 },
|
||||||
// 사망 애니메이션 속도입니다.
|
|
||||||
death: { frameRate: 11, repeat: 0 },
|
death: { frameRate: 11, repeat: 0 },
|
||||||
// 회복 애니메이션 속도입니다.
|
|
||||||
heal: { frameRate: 13, repeat: 0 },
|
heal: { frameRate: 13, repeat: 0 },
|
||||||
// 피격 애니메이션 속도입니다.
|
|
||||||
hurt: { frameRate: 13, repeat: 0 },
|
hurt: { frameRate: 13, repeat: 0 },
|
||||||
// 대기 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
|
|
||||||
idle: { frameRate: 7, repeat: -1 },
|
idle: { frameRate: 7, repeat: -1 },
|
||||||
// 이동 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
|
|
||||||
walk: { frameRate: 10, repeat: -1 },
|
walk: { frameRate: 10, repeat: -1 },
|
||||||
// 대체 이동 애니메이션 속도입니다. repeat -1은 무한 반복입니다.
|
|
||||||
walk02: { frameRate: 10, repeat: -1 },
|
walk02: { frameRate: 10, repeat: -1 },
|
||||||
|
},
|
||||||
|
// 역할별 기본 스탯
|
||||||
|
TYPE_STATS: {
|
||||||
|
melee: {
|
||||||
|
maxHp: 100,
|
||||||
|
moveSpeed: 148 * 1.1,
|
||||||
|
attackRange: 84,
|
||||||
|
attackCooldown: 840,
|
||||||
|
damageMin: 14,
|
||||||
|
damageMax: 24,
|
||||||
|
criticalChance: 0.05,
|
||||||
|
windupDelay: 260,
|
||||||
|
},
|
||||||
|
ranged: {
|
||||||
|
maxHp: 80,
|
||||||
|
moveSpeed: 148,
|
||||||
|
attackRange: TILE_SIZE * 5,
|
||||||
|
attackCooldown: 840 * 1.1,
|
||||||
|
damageMin: 14 * 1.2,
|
||||||
|
damageMax: 24 * 1.2,
|
||||||
|
criticalChance: 0,
|
||||||
|
windupDelay: 360,
|
||||||
|
projectileSpeed: 420,
|
||||||
|
},
|
||||||
|
magic: {
|
||||||
|
maxHp: 80,
|
||||||
|
moveSpeed: 148,
|
||||||
|
attackRange: TILE_SIZE * 5,
|
||||||
|
attackCooldown: 840 * 1.1,
|
||||||
|
damageMin: 14 * 1.5,
|
||||||
|
damageMax: 24 * 1.5,
|
||||||
|
criticalChance: 0,
|
||||||
|
windupDelay: 340,
|
||||||
|
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 TEAM_COLORS = [
|
LARGE_BATTLE_FIGHTER_THRESHOLD: 2000,
|
||||||
|
LARGE_BATTLE_RENDERED_FIGHTER_LIMIT: 2000,
|
||||||
|
LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS: 0,
|
||||||
|
TARGET_GRID_CELL_SIZE: TILE_SIZE * 4,
|
||||||
|
FIGHTER_HUD_POOL_SIZE: 96,
|
||||||
|
FIGHTER_HUD_VISIBLE_LIMIT: 72,
|
||||||
|
FIGHTER_HUD_VIEW_PADDING: TILE_SIZE * 2,
|
||||||
|
FIGHTER_HUD_CANDIDATE_REFRESH_MS: 120,
|
||||||
|
MINIMAP_DOT_RADIUS: 3,
|
||||||
|
MINIMAP_BACKGROUND_ALPHA: 0.62,
|
||||||
|
MINIMAP_BORDER_ALPHA: 0.84,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. SPAWN 도메인
|
||||||
|
export const SPAWN = {
|
||||||
|
DEFAULT_PLACEMENT: "random",
|
||||||
|
PLACEMENTS: {
|
||||||
|
RANDOM: "random",
|
||||||
|
STARTING_ZONES: "starting-zones",
|
||||||
|
},
|
||||||
|
// Caps participant-assigned slots; traits such as slime spawning may add fighters.
|
||||||
|
MAX_FIGHTER_COUNT: 20000,
|
||||||
|
FIGHTERS_PER_STARTING_ZONE: 500,
|
||||||
|
STARTING_ZONE_RADIUS: 3,
|
||||||
|
STARTING_ZONE_FILL_ALPHA: 0.07,
|
||||||
|
STARTING_ZONE_BORDER_ALPHA: 0.14,
|
||||||
|
STARTING_ZONE_VISIBLE_DURATION_MS: 2000,
|
||||||
|
PRESENTATION_TEAM_COUNT: 10,
|
||||||
|
PRESENTATION_TEAM_SIZE: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. COMBAT 도메인
|
||||||
|
export const COMBAT = {
|
||||||
|
KILL_REWARD_ENABLED: false,
|
||||||
|
KILL_HEALTH_RECOVERY_RATIO: 0.3,
|
||||||
|
KILL_HEAL_EFFECT_FRAMES: 4,
|
||||||
|
KILL_HEAL_EFFECT_FRAME_RATE: 12,
|
||||||
|
KILL_GROWTH_MULTIPLIER: 1.25,
|
||||||
|
KILL_GROWTH_MAX_MULTIPLIER: 5,
|
||||||
|
KILL_GROWTH_TWEEN_DURATION: 180,
|
||||||
|
CRITICAL_DAMAGE_PERCENT: 0.1,
|
||||||
|
NORMAL_CRITICAL_DAMAGE_MULTIPLIER: 2,
|
||||||
|
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_ENTER_DURATION: 14000,
|
||||||
|
FINAL_SLOW_MOTION_HOLD_DURATION: 14000,
|
||||||
|
FINAL_SLOW_MOTION_EXIT_DURATION: 14000,
|
||||||
|
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 도메인
|
||||||
|
export const PROJECTILE = {
|
||||||
|
LIFETIME: 1800,
|
||||||
|
BODY_OFFSET: 4,
|
||||||
|
HIT_PADDING: 20,
|
||||||
|
HIT_RADIUS: 12,
|
||||||
|
SPAWN_DISTANCE: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 6. WORLD_EFFECT 도메인
|
||||||
|
const WORLD_EFFECT_CONFIG = {
|
||||||
|
// Delay from match start until the first barrage.
|
||||||
|
INTERVAL: 8000,
|
||||||
|
// Delay between barrages after the first one has fired.
|
||||||
|
REPEAT_INTERVAL: 20000,
|
||||||
|
AREA_TILES: 40,
|
||||||
|
// How long the large dense-area warning marker remains visible.
|
||||||
|
WARNING_DURATION_MS: 2000,
|
||||||
|
IMPACT_AREA_TILES: 10,
|
||||||
|
IMPACT_COUNT_MIN: 5,
|
||||||
|
IMPACT_COUNT_MAX: 10,
|
||||||
|
IMPACT_STAGGER_MS: 140,
|
||||||
|
IMPACT_VISUAL_SCALE: 15,
|
||||||
|
SIZE_SCALE_VARIANCE: 1,
|
||||||
|
FRAMES: 7,
|
||||||
|
FRAME_RATE: 14,
|
||||||
|
FALL_DURATION: 920,
|
||||||
|
FALL_TRAVEL_TILES: 8,
|
||||||
|
METEOR_SHAKE_DURATION_MS: 150,
|
||||||
|
METEOR_SHAKE_INTENSITY: 0.004,
|
||||||
|
METEOR_DAMAGE: 90,
|
||||||
|
METEOR_DAMAGE_PERCENT: 0.4,
|
||||||
|
FROST_DAMAGE: 45,
|
||||||
|
FROST_DAMAGE_PERCENT: 0.2,
|
||||||
|
FROST_STUN_DURATION: 2000,
|
||||||
|
FROST_STUN_TINT: 0x82e9ff,
|
||||||
|
FROST_DURATION: 2000,
|
||||||
|
FROST_SPEED_MULTIPLIER: 0.55,
|
||||||
|
SUDDEN_DEATH: {
|
||||||
|
ENABLED: false,
|
||||||
|
TRIGGER_MS: 10000,
|
||||||
|
INTERVAL_MS: 2000,
|
||||||
|
FORCE_FROST: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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 도메인
|
||||||
|
export const CAMERA = {
|
||||||
|
MIN_ZOOM: 1 * CAMERA_ZOOM_SCALE,
|
||||||
|
MAX_ZOOM: 3 * CAMERA_ZOOM_SCALE,
|
||||||
|
ZOOM_STEP: 0.1 * CAMERA_ZOOM_SCALE,
|
||||||
|
// 자동 관전 진입 전 화염/냉기 메테오 낙하 위치를 임시로 확대 추적합니다.
|
||||||
|
METEOR_FOCUS_ENABLED: false,
|
||||||
|
METEOR_FOCUS_ZOOM: 2 * CAMERA_ZOOM_SCALE,
|
||||||
|
SPECTATOR_LERP: 0.01,
|
||||||
|
// 메테오 착탄 후 카메라를 해당 위치에 유지하는 시간(ms)입니다.
|
||||||
|
METEOR_FOCUS_HOLD_DURATION: 1200,
|
||||||
|
SPECTATOR_FINAL_FIGHTER_THRESHOLD: 5,
|
||||||
|
SPECTATOR_FINAL_FIGHT_ZOOM: 3 * CAMERA_ZOOM_SCALE,
|
||||||
|
SPECTATOR_FINAL_TEAM_COUNT: 2,
|
||||||
|
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD: 8,
|
||||||
|
SPECTATOR_RANDOM_FOCUS_INTERVAL: 10000,
|
||||||
|
SPECTATOR_LATE_FIGHTER_THRESHOLD: 500,
|
||||||
|
SPECTATOR_LATE_FIGHT_ZOOM: 2 * CAMERA_ZOOM_SCALE,
|
||||||
|
SELECTED_FIGHTER_ZOOM: 2 * CAMERA_ZOOM_SCALE,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 8. UI 도메인
|
||||||
|
export const UI = {
|
||||||
|
MINIMAP_ALPHA: 0.8,
|
||||||
|
MINIMAP_MARGIN: Math.round(VIEWPORT_SIZE * 0.016),
|
||||||
|
MINIMAP_VIEWPORT_SIZE: Math.round(VIEWPORT_SIZE * 0.22),
|
||||||
|
MINIMAP_VIEW_FRAME_STROKE: Math.max(4, Math.round(VIEWPORT_SIZE * 0.003125)),
|
||||||
|
SELECTED_FIGHTER_OUTLINE_GAP: 1,
|
||||||
|
SELECTED_FIGHTER_OUTLINE_WIDTH: 1,
|
||||||
|
SELECTED_FIGHTER_OUTLINE_RED: 255,
|
||||||
|
SELECTED_FIGHTER_OUTLINE_GREEN: 228,
|
||||||
|
SELECTED_FIGHTER_OUTLINE_BLUE: 64,
|
||||||
|
SELECTED_FIGHTER_OUTLINE_ALPHA: 0.65,
|
||||||
|
// 상단 종족별 사망 통계 공지 설정
|
||||||
|
BATTLE_NOTICE_DELAY_MS: 5000,
|
||||||
|
BATTLE_NOTICE_VISIBLE_MS: 2000,
|
||||||
|
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 도메인
|
||||||
|
const TEAM_COLORS = [
|
||||||
"#da6a48",
|
"#da6a48",
|
||||||
"#5fb4d9",
|
"#5fb4d9",
|
||||||
"#9bd15a",
|
"#9bd15a",
|
||||||
@@ -184,7 +395,7 @@ const TEAM_COLOR_HUE_OFFSET = 12;
|
|||||||
const TEAM_COLOR_SATURATIONS = [72, 62, 78, 68];
|
const TEAM_COLOR_SATURATIONS = [72, 62, 78, 68];
|
||||||
const TEAM_COLOR_LIGHTNESSES = [57, 63, 51, 69];
|
const TEAM_COLOR_LIGHTNESSES = [57, 63, 51, 69];
|
||||||
|
|
||||||
export function getTeamColor(index, totalTeams = TEAM_COLORS.length) {
|
function getTeamColor(index, totalTeams = TEAM_COLORS.length) {
|
||||||
const safeIndex = Math.max(0, Math.floor(Number(index) || 0));
|
const safeIndex = Math.max(0, Math.floor(Number(index) || 0));
|
||||||
const safeTeamCount = Math.max(1, Math.floor(Number(totalTeams) || 1));
|
const safeTeamCount = Math.max(1, Math.floor(Number(totalTeams) || 1));
|
||||||
|
|
||||||
@@ -234,3 +445,8 @@ function hslToHex(hue, saturation, lightness) {
|
|||||||
)
|
)
|
||||||
.join("")}`;
|
.join("")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const TEAM = {
|
||||||
|
COLORS: TEAM_COLORS,
|
||||||
|
getColor: getTeamColor,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,29 +1,49 @@
|
|||||||
import { ARENA_SIZE, GRID_SIZE, TILE_SIZE } from "../../constants.js";
|
import {
|
||||||
|
ARENA,
|
||||||
|
SPAWN,
|
||||||
|
} from "../../constants.js";
|
||||||
|
|
||||||
export function drawArena(scene) {
|
export function drawArena(scene) {
|
||||||
const graphics = scene.add.graphics();
|
const graphics = scene.add.graphics();
|
||||||
graphics.fillStyle(0x34351f, 1);
|
graphics.fillStyle(0x34351f, 1);
|
||||||
graphics.fillRect(0, 0, ARENA_SIZE, ARENA_SIZE);
|
graphics.fillRect(0, 0, ARENA.SIZE, ARENA.SIZE);
|
||||||
graphics.fillStyle(0x556235, 0.12);
|
graphics.fillStyle(0x556235, 0.12);
|
||||||
|
|
||||||
for (let row = 0; row < GRID_SIZE; row += 1) {
|
for (let row = 0; row < ARENA.GRID_SIZE; row += 1) {
|
||||||
for (let column = 0; column < GRID_SIZE; column += 1) {
|
for (let column = 0; column < ARENA.GRID_SIZE; column += 1) {
|
||||||
if ((row + column) % 2 === 0) {
|
if ((row + column) % 2 === 0) {
|
||||||
graphics.fillRect(column * TILE_SIZE, row * TILE_SIZE, TILE_SIZE, TILE_SIZE);
|
graphics.fillRect(column * ARENA.TILE_SIZE, row * ARENA.TILE_SIZE, ARENA.TILE_SIZE, ARENA.TILE_SIZE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
graphics.lineStyle(1, 0xd3bd72, 0.11);
|
graphics.lineStyle(1, 0xd3bd72, 0.11);
|
||||||
|
|
||||||
for (let index = 0; index <= GRID_SIZE; index += 1) {
|
for (let index = 0; index <= ARENA.GRID_SIZE; index += 1) {
|
||||||
const offset = index * TILE_SIZE;
|
const offset = index * ARENA.TILE_SIZE;
|
||||||
graphics.lineBetween(offset, 0, offset, ARENA_SIZE);
|
graphics.lineBetween(offset, 0, offset, ARENA.SIZE);
|
||||||
graphics.lineBetween(0, offset, ARENA_SIZE, offset);
|
graphics.lineBetween(0, offset, ARENA.SIZE, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
graphics.lineStyle(12, 0x17180e, 1);
|
graphics.lineStyle(12, 0x17180e, 1);
|
||||||
graphics.strokeRect(0, 0, ARENA_SIZE, ARENA_SIZE);
|
graphics.strokeRect(0, 0, ARENA.SIZE, ARENA.SIZE);
|
||||||
graphics.lineStyle(2, 0xd3bd72, 0.35);
|
graphics.lineStyle(2, 0xd3bd72, 0.35);
|
||||||
graphics.strokeRect(12, 12, ARENA_SIZE - 24, ARENA_SIZE - 24);
|
graphics.strokeRect(12, 12, ARENA.SIZE - 24, ARENA.SIZE - 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawStartingZones(graphics, startingZones = []) {
|
||||||
|
graphics.clear();
|
||||||
|
|
||||||
|
startingZones.forEach((zone) => {
|
||||||
|
const color = Number.parseInt(zone.color.slice(1), 16);
|
||||||
|
const x = zone.columnStart * ARENA.TILE_SIZE;
|
||||||
|
const y = zone.rowStart * ARENA.TILE_SIZE;
|
||||||
|
const width = (zone.columnEnd - zone.columnStart) * ARENA.TILE_SIZE;
|
||||||
|
const height = (zone.rowEnd - zone.rowStart) * ARENA.TILE_SIZE;
|
||||||
|
|
||||||
|
graphics.fillStyle(color, SPAWN.STARTING_ZONE_FILL_ALPHA);
|
||||||
|
graphics.fillRect(x, y, width, height);
|
||||||
|
graphics.lineStyle(2, color, SPAWN.STARTING_ZONE_BORDER_ALPHA);
|
||||||
|
graphics.strokeRect(x, y, width, height);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,40 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import {
|
import {
|
||||||
SPECTATOR_FINAL_FIGHTER_THRESHOLD,
|
CAMERA,
|
||||||
SPECTATOR_FINAL_FIGHT_ZOOM,
|
|
||||||
SPECTATOR_FINAL_TEAM_COUNT,
|
|
||||||
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD,
|
|
||||||
SPECTATOR_LATE_FIGHTER_THRESHOLD,
|
|
||||||
SPECTATOR_LATE_FIGHT_ZOOM,
|
|
||||||
} from "../../constants.js";
|
} from "../../constants.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 < SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
|
if (livingFighterCount < CAMERA.SPECTATOR_FINAL_FIGHTER_THRESHOLD) {
|
||||||
return {
|
return {
|
||||||
isFinal: true,
|
isFinal: true,
|
||||||
mode: "final-random",
|
mode: "final-random",
|
||||||
zoom: SPECTATOR_FINAL_FIGHT_ZOOM,
|
zoom: CAMERA.SPECTATOR_FINAL_FIGHT_ZOOM,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
teamSummaries.length === SPECTATOR_FINAL_TEAM_COUNT &&
|
teamSummaries.length === CAMERA.SPECTATOR_FINAL_TEAM_COUNT &&
|
||||||
livingFighterCount <= SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD
|
livingFighterCount <= CAMERA.SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
isFinal: true,
|
isFinal: true,
|
||||||
mode: "final-underdog",
|
mode: "final-underdog",
|
||||||
teamId: getUnderdogTeamId(teamSummaries),
|
teamId: getUnderdogTeamId(teamSummaries),
|
||||||
zoom: SPECTATOR_FINAL_FIGHT_ZOOM,
|
zoom: CAMERA.SPECTATOR_FINAL_FIGHT_ZOOM,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (livingFighterCount < SPECTATOR_LATE_FIGHTER_THRESHOLD) {
|
if (livingFighterCount < CAMERA.SPECTATOR_LATE_FIGHTER_THRESHOLD) {
|
||||||
return {
|
return {
|
||||||
isFinal: false,
|
isFinal: false,
|
||||||
mode: "late",
|
mode: "late",
|
||||||
zoom: SPECTATOR_LATE_FIGHT_ZOOM,
|
zoom: CAMERA.SPECTATOR_LATE_FIGHT_ZOOM,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +51,7 @@ export function getLivingTeamSummaries(livingFighters) {
|
|||||||
teamId,
|
teamId,
|
||||||
};
|
};
|
||||||
|
|
||||||
summary.count += 1;
|
summary.count += representedFighterCount(fighter);
|
||||||
summaries.set(teamId, summary);
|
summaries.set(teamId, summary);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,22 +73,28 @@ 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) {
|
||||||
const target = fighter?.body?.center ?? fighter;
|
const target = fighter?.body?.center ?? fighter;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,621 @@
|
|||||||
|
import Phaser from "phaser";
|
||||||
|
import {
|
||||||
|
ARENA,
|
||||||
|
WORLD_EFFECT,
|
||||||
|
} from "../../constants.js";
|
||||||
|
import {
|
||||||
|
applyWorldEffectDamage,
|
||||||
|
disposeCombatObject,
|
||||||
|
isFighterSpecialInvulnerable,
|
||||||
|
trackCombatObject,
|
||||||
|
} from "./combat.js";
|
||||||
|
|
||||||
|
const METEOR_EFFECT_PATH = "assets/effects/world_Effect.png";
|
||||||
|
const METEOR_EFFECT_KEY = "world-meteor-effect";
|
||||||
|
const FROST_EFFECT_PATH = "assets/effects/world_Effect_2.png";
|
||||||
|
const FROST_EFFECT_KEY = "world-frost-effect";
|
||||||
|
const WORLD_EFFECT_SHEETS = [
|
||||||
|
{ key: METEOR_EFFECT_KEY, path: METEOR_EFFECT_PATH },
|
||||||
|
{ key: FROST_EFFECT_KEY, path: FROST_EFFECT_PATH },
|
||||||
|
];
|
||||||
|
const METEOR_ZONE_COLOR = 0xf16a38;
|
||||||
|
const FROST_ZONE_COLOR = 0x58cef4;
|
||||||
|
const FALL_ANGLE_DEGREES = 45;
|
||||||
|
|
||||||
|
export function preloadWorldEffectAssets(scene) {
|
||||||
|
WORLD_EFFECT_SHEETS.forEach(({ key, path }) => {
|
||||||
|
scene.load.spritesheet(key, path, {
|
||||||
|
frameWidth: 100,
|
||||||
|
frameHeight: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorldEffectAnimations(scene) {
|
||||||
|
WORLD_EFFECT_SHEETS.forEach(({ key }) => {
|
||||||
|
const animationKey = worldEffectAnimationKey(key);
|
||||||
|
|
||||||
|
if (scene.anims.exists(animationKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.anims.create({
|
||||||
|
key: animationKey,
|
||||||
|
frames: scene.anims.generateFrameNumbers(key, {
|
||||||
|
start: 0,
|
||||||
|
end: WORLD_EFFECT.FRAMES - 1,
|
||||||
|
}),
|
||||||
|
frameRate: WORLD_EFFECT.FRAME_RATE,
|
||||||
|
repeat: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startWorldEffects(scene) {
|
||||||
|
clearWorldEffects(scene);
|
||||||
|
|
||||||
|
if (scene.presentationMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.matchStartedAt = scene.time.now;
|
||||||
|
scene.isSuddenDeath = false;
|
||||||
|
|
||||||
|
const scheduleNext = (isInitialBarrage = false) => {
|
||||||
|
if (!isLiveMatch(scene)) return;
|
||||||
|
|
||||||
|
const elapsed = scene.time.now - (scene.matchStartedAt ?? scene.time.now);
|
||||||
|
const isSuddenDeath = WORLD_EFFECT.SUDDEN_DEATH.ENABLED && elapsed >= WORLD_EFFECT.SUDDEN_DEATH.TRIGGER_MS;
|
||||||
|
const delay = isInitialBarrage
|
||||||
|
? WORLD_EFFECT.INTERVAL
|
||||||
|
: isSuddenDeath
|
||||||
|
? WORLD_EFFECT.SUDDEN_DEATH.INTERVAL_MS
|
||||||
|
: WORLD_EFFECT.REPEAT_INTERVAL;
|
||||||
|
|
||||||
|
if (isSuddenDeath && !scene.isSuddenDeath) {
|
||||||
|
scene.isSuddenDeath = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.worldEffectTimer = scene.time.delayedCall(delay, () => {
|
||||||
|
triggerWorldEffect(scene);
|
||||||
|
scheduleNext();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
scheduleNext(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearWorldEffects(scene) {
|
||||||
|
scene.worldEffectTimer?.remove(false);
|
||||||
|
scene.worldEffectTimer = null;
|
||||||
|
scene.worldEffectZones?.clear();
|
||||||
|
scene.clearMeteorCameraFocus?.(null, { restoreCamera: false });
|
||||||
|
scene.matchStartedAt = null;
|
||||||
|
scene.isSuddenDeath = false;
|
||||||
|
|
||||||
|
scene.fighters?.forEach((fighter) => {
|
||||||
|
fighter.worldEffectSpeedMultiplier = 1;
|
||||||
|
clearFrostStun(fighter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateWorldEffectModifiers(scene) {
|
||||||
|
const frostZones = Array.from(scene.worldEffectZones ?? []).filter(
|
||||||
|
(zone) => zone.marker?.active,
|
||||||
|
);
|
||||||
|
|
||||||
|
scene.fighters.forEach((fighter) => {
|
||||||
|
const isSlowed =
|
||||||
|
fighter.active
|
||||||
|
&& !fighter.isDead
|
||||||
|
&& frostZones.some((zone) => containsFighter(zone, fighter));
|
||||||
|
|
||||||
|
fighter.worldEffectSpeedMultiplier = isSlowed
|
||||||
|
? WORLD_EFFECT.FROST_SPEED_MULTIPLIER
|
||||||
|
: 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerWorldEffect(scene) {
|
||||||
|
if (!isLiveMatch(scene)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const livingFighters = scene.fighters.filter(
|
||||||
|
(fighter) => fighter.active && !fighter.isDead,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (livingFighters.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zone = findDensestWorldEffectZone(livingFighters);
|
||||||
|
|
||||||
|
// Sudden Death 상태이고 냉기 고정 설정이 되어있으면 무조건 냉기 메테오
|
||||||
|
if ((scene.isSuddenDeath && WORLD_EFFECT.SUDDEN_DEATH.FORCE_FROST) || Phaser.Math.Between(0, 1) === 0) {
|
||||||
|
spawnFrostZone(scene, zone);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnMeteor(scene, zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findDensestWorldEffectZone(livingFighters) {
|
||||||
|
if (livingFighters.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const areaTiles = resolveTileCount(WORLD_EFFECT.AREA_TILES, ARENA.GRID_SIZE);
|
||||||
|
const tileCounts = Array.from(
|
||||||
|
{ length: ARENA.GRID_SIZE },
|
||||||
|
() => Array(ARENA.GRID_SIZE).fill(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
livingFighters.forEach((fighter) => {
|
||||||
|
const x = fighter.body?.center.x ?? fighter.x;
|
||||||
|
const y = fighter.body?.center.y ?? fighter.y;
|
||||||
|
const column = Phaser.Math.Clamp(Math.floor(x / ARENA.TILE_SIZE), 0, ARENA.GRID_SIZE - 1);
|
||||||
|
const row = Phaser.Math.Clamp(Math.floor(y / ARENA.TILE_SIZE), 0, ARENA.GRID_SIZE - 1);
|
||||||
|
|
||||||
|
tileCounts[row][column] += representedFighterCount(fighter);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Summed-area lookup keeps dense-zone selection cheap even with thousands of fighters.
|
||||||
|
const densitySums = createSummedAreaTable(tileCounts);
|
||||||
|
const maximumOrigin = ARENA.GRID_SIZE - areaTiles;
|
||||||
|
let highestCount = -1;
|
||||||
|
let densestOrigins = [];
|
||||||
|
|
||||||
|
for (let row = 0; row <= maximumOrigin; row += 1) {
|
||||||
|
for (let column = 0; column <= maximumOrigin; column += 1) {
|
||||||
|
const count = sumArea(densitySums, column, row, areaTiles);
|
||||||
|
|
||||||
|
if (count > highestCount) {
|
||||||
|
highestCount = count;
|
||||||
|
densestOrigins = [{ column, row }];
|
||||||
|
} else if (count === highestCount) {
|
||||||
|
densestOrigins.push({ column, row });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = randomEntry(densestOrigins);
|
||||||
|
const size = areaTiles * ARENA.TILE_SIZE;
|
||||||
|
|
||||||
|
return createEffectZone(
|
||||||
|
origin.column * ARENA.TILE_SIZE + size / 2,
|
||||||
|
origin.row * ARENA.TILE_SIZE + size / 2,
|
||||||
|
areaTiles,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomEntry(entries) {
|
||||||
|
return entries[Phaser.Math.Between(0, entries.length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnMeteor(scene, zone) {
|
||||||
|
spawnWorldEffectBarrage(scene, zone, {
|
||||||
|
color: METEOR_ZONE_COLOR,
|
||||||
|
effectType: "meteor",
|
||||||
|
effectKey: METEOR_EFFECT_KEY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnFrostZone(scene, zone) {
|
||||||
|
spawnWorldEffectBarrage(scene, zone, {
|
||||||
|
color: FROST_ZONE_COLOR,
|
||||||
|
effectType: "frost",
|
||||||
|
effectKey: FROST_EFFECT_KEY,
|
||||||
|
isFrost: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnWorldEffectBarrage(
|
||||||
|
scene,
|
||||||
|
targetZone,
|
||||||
|
{ color, effectType, effectKey, isFrost = false },
|
||||||
|
) {
|
||||||
|
const matchId = scene.matchId;
|
||||||
|
const targetMarker = createZoneMarker(scene, targetZone, color);
|
||||||
|
const impactZones = createBarrageImpactZones(targetZone);
|
||||||
|
const pendingTimers = [];
|
||||||
|
const warningHideTimer = scene.time.delayedCall(resolveWarningDurationMs(), () => {
|
||||||
|
hideZoneMarker(scene, targetMarker);
|
||||||
|
});
|
||||||
|
let unresolvedImpacts = impactZones.length;
|
||||||
|
|
||||||
|
scene.beginMeteorCameraFocus?.(targetZone);
|
||||||
|
|
||||||
|
const finishImpact = () => {
|
||||||
|
unresolvedImpacts -= 1;
|
||||||
|
|
||||||
|
if (unresolvedImpacts > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.clearMeteorCameraFocus?.(targetZone);
|
||||||
|
disposeCombatObject(scene, targetMarker);
|
||||||
|
};
|
||||||
|
|
||||||
|
const previousCleanup = targetMarker.cleanup;
|
||||||
|
targetMarker.cleanup = () => {
|
||||||
|
warningHideTimer.remove(false);
|
||||||
|
pendingTimers.forEach((timer) => timer.remove(false));
|
||||||
|
previousCleanup?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
impactZones.forEach((impactZone, index) => {
|
||||||
|
const timer = scene.time.delayedCall(resolveImpactStaggerMs() * index, () => {
|
||||||
|
if (!targetMarker.active || !isLiveMatch(scene, matchId)) {
|
||||||
|
finishImpact();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const impactMarker = createZoneMarker(scene, impactZone, color);
|
||||||
|
|
||||||
|
dropWorldEffectSprite(scene, impactZone, {
|
||||||
|
effectKey,
|
||||||
|
onCancel: () => {
|
||||||
|
disposeCombatObject(scene, impactMarker);
|
||||||
|
finishImpact();
|
||||||
|
},
|
||||||
|
onImpact: () => {
|
||||||
|
scene.tweens.killTweensOf(impactMarker);
|
||||||
|
impactMarker.setAlpha(1);
|
||||||
|
applyMeteorImpactShake(scene, impactZone);
|
||||||
|
resolveImpactDamage(
|
||||||
|
scene,
|
||||||
|
impactZone,
|
||||||
|
effectType,
|
||||||
|
isFrost ? (fighter) => applyFrostStun(scene, fighter) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isFrost && !scene.matchOver) {
|
||||||
|
activateFrostZone(scene, impactZone, impactMarker);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onAnimationComplete: () => {
|
||||||
|
if (!isFrost) {
|
||||||
|
disposeCombatObject(scene, impactMarker);
|
||||||
|
}
|
||||||
|
|
||||||
|
finishImpact();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingTimers.push(timer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropWorldEffectSprite(
|
||||||
|
scene,
|
||||||
|
zone,
|
||||||
|
{ effectKey = METEOR_EFFECT_KEY, onCancel, onImpact, onAnimationComplete } = {},
|
||||||
|
) {
|
||||||
|
const matchId = scene.matchId;
|
||||||
|
const trajectory = createFallTrajectory(zone);
|
||||||
|
const sprite = scene.add
|
||||||
|
.sprite(trajectory.startX, trajectory.startY, effectKey, 0)
|
||||||
|
.setDepth(3)
|
||||||
|
.setScale(resolveWorldEffectVisualScale(zone))
|
||||||
|
.setFlipX(trajectory.flipX)
|
||||||
|
.setAngle(trajectory.angle)
|
||||||
|
.setAlpha(0.9);
|
||||||
|
|
||||||
|
sprite.cleanup = () => {
|
||||||
|
scene.tweens.killTweensOf(sprite);
|
||||||
|
};
|
||||||
|
|
||||||
|
trackCombatObject(scene, sprite);
|
||||||
|
sprite.once(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
|
||||||
|
onAnimationComplete?.();
|
||||||
|
disposeCombatObject(scene, sprite);
|
||||||
|
});
|
||||||
|
|
||||||
|
scene.tweens.add({
|
||||||
|
targets: sprite,
|
||||||
|
x: zone.centerX,
|
||||||
|
y: zone.centerY,
|
||||||
|
alpha: 1,
|
||||||
|
duration: WORLD_EFFECT.FALL_DURATION,
|
||||||
|
ease: "Cubic.In",
|
||||||
|
onComplete: () => {
|
||||||
|
if (!sprite.active || !isLiveMatch(scene, matchId)) {
|
||||||
|
onCancel?.();
|
||||||
|
disposeCombatObject(scene, sprite);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sprite.play(worldEffectAnimationKey(effectKey));
|
||||||
|
onImpact?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function worldEffectAnimationKey(effectKey) {
|
||||||
|
return `${effectKey}-anim`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorldEffectSizeScale() {
|
||||||
|
const variance = Math.max(0, Number(WORLD_EFFECT.SIZE_SCALE_VARIANCE) || 0);
|
||||||
|
|
||||||
|
if (variance === 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minScale = Math.max(0.1, 1 - variance);
|
||||||
|
const maxScale = 1 + variance;
|
||||||
|
|
||||||
|
return Phaser.Math.FloatBetween(minScale, maxScale);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWorldEffectVisualScale(zone) {
|
||||||
|
const baseScale = Math.max(0.01, Number(WORLD_EFFECT.IMPACT_VISUAL_SCALE) || 1);
|
||||||
|
return baseScale * Math.max(0.1, Number(zone?.sizeScale) || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMeteorImpactShake(scene, zone) {
|
||||||
|
const sizeScale = Math.max(0.1, Number(zone?.sizeScale) || 1);
|
||||||
|
const duration = Math.round(
|
||||||
|
Math.max(0, Number(WORLD_EFFECT.METEOR_SHAKE_DURATION_MS) || 0)
|
||||||
|
* Math.sqrt(sizeScale),
|
||||||
|
);
|
||||||
|
const intensity =
|
||||||
|
Math.max(0, Number(WORLD_EFFECT.METEOR_SHAKE_INTENSITY) || 0) * sizeScale;
|
||||||
|
|
||||||
|
if (duration <= 0 || intensity <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.cameras.main.shake(duration, intensity);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFallTrajectory(zone) {
|
||||||
|
const distance = ARENA.TILE_SIZE * WORLD_EFFECT.FALL_TRAVEL_TILES;
|
||||||
|
const isLeftHalf = zone.centerX < ARENA.SIZE / 2;
|
||||||
|
const travelDirection = isLeftHalf ? 1 : -1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
angle: travelDirection * FALL_ANGLE_DEGREES,
|
||||||
|
flipX: travelDirection < 0,
|
||||||
|
startX: zone.centerX - travelDirection * distance,
|
||||||
|
startY: zone.centerY - distance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBarrageImpactZones(targetZone) {
|
||||||
|
const minimumCount = Math.max(1, Math.round(Number(WORLD_EFFECT.IMPACT_COUNT_MIN) || 1));
|
||||||
|
const maximumCount = Math.max(
|
||||||
|
minimumCount,
|
||||||
|
Math.round(Number(WORLD_EFFECT.IMPACT_COUNT_MAX) || minimumCount),
|
||||||
|
);
|
||||||
|
const count = Phaser.Math.Between(minimumCount, maximumCount);
|
||||||
|
|
||||||
|
return Array.from({ length: count }, () => createBarrageImpactZone(targetZone));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBarrageImpactZone(targetZone) {
|
||||||
|
const sizeScale = resolveWorldEffectSizeScale();
|
||||||
|
const maximumImpactTiles = Math.max(1, targetZone.areaTiles - 1);
|
||||||
|
const impactTiles = Math.min(
|
||||||
|
maximumImpactTiles,
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
Math.round((Number(WORLD_EFFECT.IMPACT_AREA_TILES) || 1) * sizeScale),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const impactSize = impactTiles * ARENA.TILE_SIZE;
|
||||||
|
const halfImpactSize = impactSize / 2;
|
||||||
|
const minimumX = targetZone.bounds.left + halfImpactSize;
|
||||||
|
const maximumX = targetZone.bounds.right - halfImpactSize;
|
||||||
|
const minimumY = targetZone.bounds.top + halfImpactSize;
|
||||||
|
const maximumY = targetZone.bounds.bottom - halfImpactSize;
|
||||||
|
|
||||||
|
return createEffectZone(
|
||||||
|
randomBetween(minimumX, maximumX),
|
||||||
|
randomBetween(minimumY, maximumY),
|
||||||
|
impactTiles,
|
||||||
|
sizeScale,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEffectZone(centerX, centerY, areaTiles, sizeScale = 1) {
|
||||||
|
const size = ARENA.TILE_SIZE * areaTiles;
|
||||||
|
|
||||||
|
return {
|
||||||
|
areaTiles,
|
||||||
|
bounds: new Phaser.Geom.Rectangle(centerX - size / 2, centerY - size / 2, size, size),
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
marker: null,
|
||||||
|
sizeScale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSummedAreaTable(tileCounts) {
|
||||||
|
const sums = Array.from(
|
||||||
|
{ length: ARENA.GRID_SIZE + 1 },
|
||||||
|
() => Array(ARENA.GRID_SIZE + 1).fill(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let row = 0; row < ARENA.GRID_SIZE; row += 1) {
|
||||||
|
for (let column = 0; column < ARENA.GRID_SIZE; column += 1) {
|
||||||
|
sums[row + 1][column + 1] =
|
||||||
|
tileCounts[row][column]
|
||||||
|
+ sums[row][column + 1]
|
||||||
|
+ sums[row + 1][column]
|
||||||
|
- sums[row][column];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sums;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumArea(sums, column, row, areaTiles) {
|
||||||
|
const bottom = row + areaTiles;
|
||||||
|
const right = column + areaTiles;
|
||||||
|
|
||||||
|
return (
|
||||||
|
sums[bottom][right]
|
||||||
|
- sums[row][right]
|
||||||
|
- sums[bottom][column]
|
||||||
|
+ sums[row][column]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTileCount(value, maximum) {
|
||||||
|
return Phaser.Math.Clamp(Math.round(Number(value) || 1), 1, maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveImpactStaggerMs() {
|
||||||
|
return Math.max(0, Math.round(Number(WORLD_EFFECT.IMPACT_STAGGER_MS) || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWarningDurationMs() {
|
||||||
|
return Math.max(0, Math.round(Number(WORLD_EFFECT.WARNING_DURATION_MS) || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomBetween(minimum, maximum) {
|
||||||
|
if (maximum <= minimum) {
|
||||||
|
return minimum;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Phaser.Math.FloatBetween(minimum, maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideZoneMarker(scene, marker) {
|
||||||
|
if (!marker?.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.tweens.killTweensOf(marker);
|
||||||
|
marker.setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createZoneMarker(scene, zone, color) {
|
||||||
|
const marker = scene.add.graphics().setDepth(1.5);
|
||||||
|
const { x, y, width, height } = zone.bounds;
|
||||||
|
|
||||||
|
marker.fillStyle(color, 0.13);
|
||||||
|
marker.fillRect(x, y, width, height);
|
||||||
|
marker.lineStyle(3, color, 0.82);
|
||||||
|
marker.strokeRect(x, y, width, height);
|
||||||
|
marker.lineStyle(1, color, 0.34);
|
||||||
|
|
||||||
|
for (let index = 1; index < zone.areaTiles; index += 1) {
|
||||||
|
const offset = index * ARENA.TILE_SIZE;
|
||||||
|
marker.lineBetween(x + offset, y, x + offset, y + height);
|
||||||
|
marker.lineBetween(x, y + offset, x + width, y + offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
marker.cleanup = () => {
|
||||||
|
scene.tweens.killTweensOf(marker);
|
||||||
|
};
|
||||||
|
|
||||||
|
trackCombatObject(scene, marker);
|
||||||
|
scene.tweens.add({
|
||||||
|
targets: marker,
|
||||||
|
alpha: { from: 0.46, to: 0.9 },
|
||||||
|
duration: 360,
|
||||||
|
ease: "Sine.InOut",
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveImpactDamage(scene, zone, effectType, onSurvivor) {
|
||||||
|
let deathCount = 0;
|
||||||
|
|
||||||
|
scene.fighters
|
||||||
|
.filter((fighter) =>
|
||||||
|
fighter.active
|
||||||
|
&& !fighter.isDead
|
||||||
|
&& !isFighterSpecialInvulnerable(fighter)
|
||||||
|
&& containsFighter(zone, fighter),
|
||||||
|
)
|
||||||
|
.forEach((fighter) => {
|
||||||
|
if (applyWorldEffectDamage(scene, fighter, effectType)) {
|
||||||
|
deathCount += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSurvivor?.(fighter);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deathCount > 0) {
|
||||||
|
scene.updateScoreboard?.();
|
||||||
|
scene.finishMatch?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFrostStun(scene, fighter) {
|
||||||
|
if (!fighter?.active || fighter.isDead) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fighter.frostStunTimer?.remove(false);
|
||||||
|
fighter.isFrostStunned = true;
|
||||||
|
fighter.body?.setVelocity(0, 0);
|
||||||
|
fighter.setTint(WORLD_EFFECT.FROST_STUN_TINT);
|
||||||
|
fighter.frostStunTimer = scene.time.delayedCall(WORLD_EFFECT.FROST_STUN_DURATION, () => {
|
||||||
|
clearFrostStun(fighter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFrostStun(fighter) {
|
||||||
|
fighter.frostStunTimer?.remove(false);
|
||||||
|
fighter.frostStunTimer = null;
|
||||||
|
fighter.isFrostStunned = false;
|
||||||
|
|
||||||
|
if (fighter.active) {
|
||||||
|
fighter.clearTint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateFrostZone(scene, zone, marker) {
|
||||||
|
if (!marker.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
zone.marker = marker;
|
||||||
|
scene.worldEffectZones ??= new Set();
|
||||||
|
scene.worldEffectZones.add(zone);
|
||||||
|
scene.tweens.killTweensOf(marker);
|
||||||
|
scene.tweens.add({
|
||||||
|
targets: marker,
|
||||||
|
alpha: { from: 0.42, to: 0.72 },
|
||||||
|
duration: 680,
|
||||||
|
ease: "Sine.InOut",
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const previousCleanup = marker.cleanup;
|
||||||
|
const expiryTimer = scene.time.delayedCall(WORLD_EFFECT.FROST_DURATION, () => {
|
||||||
|
disposeCombatObject(scene, marker);
|
||||||
|
});
|
||||||
|
|
||||||
|
marker.cleanup = () => {
|
||||||
|
expiryTimer.remove(false);
|
||||||
|
scene.worldEffectZones?.delete(zone);
|
||||||
|
previousCleanup?.();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsFighter(zone, fighter) {
|
||||||
|
const x = fighter.body?.center.x ?? fighter.x;
|
||||||
|
const y = fighter.body?.center.y ?? fighter.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) {
|
||||||
|
return !scene.matchOver && !scene.presentationMode && scene.matchId === matchId;
|
||||||
|
}
|
||||||
@@ -1,33 +1,29 @@
|
|||||||
import {
|
import {
|
||||||
FIGHTER_ANIMATION_OPTIONS,
|
FIGHTER,
|
||||||
FIGHTER_FRAME_HEIGHT,
|
COMBAT,
|
||||||
FIGHTER_FRAME_WIDTH,
|
|
||||||
KILL_HEAL_EFFECT_FRAME_RATE,
|
|
||||||
KILL_HEAL_EFFECT_FRAMES,
|
|
||||||
SELECTED_FIGHTER_OUTLINE_ALPHA,
|
|
||||||
SELECTED_FIGHTER_OUTLINE_GAP,
|
|
||||||
SELECTED_FIGHTER_OUTLINE_WIDTH,
|
|
||||||
} from "../../constants.js";
|
} from "../../constants.js";
|
||||||
|
|
||||||
const SOURCE_ALPHA_THRESHOLD = 8;
|
|
||||||
const HEAL_EFFECT_PATH = "assets/effects/heal/Heal_Effect.png";
|
const HEAL_EFFECT_PATH = "assets/effects/heal/Heal_Effect.png";
|
||||||
const HEAL_EFFECT_KEY = "kill-heal-effect";
|
const HEAL_EFFECT_KEY = "kill-heal-effect";
|
||||||
const HEAL_EFFECT_ANIMATION_KEY = `${HEAL_EFFECT_KEY}-anim`;
|
const HEAL_EFFECT_ANIMATION_KEY = `${HEAL_EFFECT_KEY}-anim`;
|
||||||
|
const TEAM_SHADOW_SOURCE_COLOR = {
|
||||||
|
red: 0x53,
|
||||||
|
green: 0x45,
|
||||||
|
blue: 0x45,
|
||||||
|
};
|
||||||
|
const TEAM_SHADOW_FRAME_Y_START = 55;
|
||||||
|
const TEAM_SHADOW_FRAME_Y_END = 60;
|
||||||
|
|
||||||
|
export function fighterSheetKey(skin, action, teamColor) {
|
||||||
|
if (teamColor) {
|
||||||
|
return `${skin.key}-${action}-team-shadow-${normalizeTeamColorKey(teamColor)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function fighterSheetKey(skin, action) {
|
|
||||||
return `${skin.key}-${action}`;
|
return `${skin.key}-${action}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fighterAnimationKey(skin, action) {
|
export function fighterAnimationKey(skin, action, teamColor) {
|
||||||
return `${fighterSheetKey(skin, action)}-anim`;
|
return `${fighterSheetKey(skin, action, teamColor)}-anim`;
|
||||||
}
|
|
||||||
|
|
||||||
export function fighterOutlineSheetKey(skin, action) {
|
|
||||||
return `${fighterSheetKey(skin, action)}-outline`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fighterOutlineSheetKeyFromSheetKey(sheetKey) {
|
|
||||||
return `${sheetKey}-outline`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fighterAttackEffectKey(skin) {
|
export function fighterAttackEffectKey(skin) {
|
||||||
@@ -52,8 +48,8 @@ export function healEffectAnimationKey() {
|
|||||||
|
|
||||||
export function preloadFighterSheets(scene, skins) {
|
export function preloadFighterSheets(scene, skins) {
|
||||||
scene.load.spritesheet(healEffectKey(), HEAL_EFFECT_PATH, {
|
scene.load.spritesheet(healEffectKey(), HEAL_EFFECT_PATH, {
|
||||||
frameWidth: FIGHTER_FRAME_WIDTH,
|
frameWidth: FIGHTER.FRAME_WIDTH,
|
||||||
frameHeight: FIGHTER_FRAME_HEIGHT,
|
frameHeight: FIGHTER.FRAME_HEIGHT,
|
||||||
});
|
});
|
||||||
|
|
||||||
skins.forEach((skin) => {
|
skins.forEach((skin) => {
|
||||||
@@ -61,7 +57,7 @@ export function preloadFighterSheets(scene, skins) {
|
|||||||
scene.load.spritesheet(
|
scene.load.spritesheet(
|
||||||
fighterSheetKey(skin, action),
|
fighterSheetKey(skin, action),
|
||||||
`${skin.assetRoot}/${animation.file}`,
|
`${skin.assetRoot}/${animation.file}`,
|
||||||
{ frameWidth: FIGHTER_FRAME_WIDTH, frameHeight: FIGHTER_FRAME_HEIGHT },
|
{ frameWidth: FIGHTER.FRAME_WIDTH, frameHeight: FIGHTER.FRAME_HEIGHT },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,7 +71,7 @@ export function createFighterAnimations(scene, skins) {
|
|||||||
const key = fighterAnimationKey(skin, action);
|
const key = fighterAnimationKey(skin, action);
|
||||||
|
|
||||||
if (!scene.anims.exists(key)) {
|
if (!scene.anims.exists(key)) {
|
||||||
const { frameRate, repeat } = FIGHTER_ANIMATION_OPTIONS[action];
|
const { frameRate, repeat } = FIGHTER.ANIMATION_OPTIONS[action];
|
||||||
|
|
||||||
scene.anims.create({
|
scene.anims.create({
|
||||||
key,
|
key,
|
||||||
@@ -87,8 +83,6 @@ export function createFighterAnimations(scene, skins) {
|
|||||||
repeat,
|
repeat,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createFighterOutlineSheet(scene, skin, action, animation.frames);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
createAttackEffectAnimation(scene, skin);
|
createAttackEffectAnimation(scene, skin);
|
||||||
@@ -97,6 +91,47 @@ export function createFighterAnimations(scene, skins) {
|
|||||||
createHealEffectAnimation(scene);
|
createHealEffectAnimation(scene);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ensureFighterTeamAnimation(scene, skin, action, teamColor) {
|
||||||
|
const animation = skin.animations[action];
|
||||||
|
|
||||||
|
if (!animation || !teamColor) {
|
||||||
|
return fighterAnimationKey(skin, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textureKey = fighterSheetKey(skin, action, teamColor);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!scene.textures.exists(textureKey)
|
||||||
|
&& !createFighterTeamShadowSheet(scene, skin, action, animation.frames, teamColor)
|
||||||
|
) {
|
||||||
|
return fighterAnimationKey(skin, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = fighterAnimationKey(skin, action, teamColor);
|
||||||
|
|
||||||
|
if (!scene.anims.exists(key)) {
|
||||||
|
const { frameRate, repeat } = FIGHTER.ANIMATION_OPTIONS[action];
|
||||||
|
|
||||||
|
scene.anims.create({
|
||||||
|
key,
|
||||||
|
frames: scene.anims.generateFrameNumbers(textureKey, {
|
||||||
|
start: 0,
|
||||||
|
end: animation.frames - 1,
|
||||||
|
}),
|
||||||
|
frameRate,
|
||||||
|
repeat,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureFighterTeamAnimations(scene, skin, teamColor, actions = []) {
|
||||||
|
actions.forEach((action) => {
|
||||||
|
ensureFighterTeamAnimation(scene, skin, action, teamColor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function preloadCombatAssets(scene, skin) {
|
function preloadCombatAssets(scene, skin) {
|
||||||
const projectile = skin.combat?.projectile;
|
const projectile = skin.combat?.projectile;
|
||||||
const attackEffect = skin.combat?.attackEffect;
|
const attackEffect = skin.combat?.attackEffect;
|
||||||
@@ -109,7 +144,7 @@ function preloadCombatAssets(scene, skin) {
|
|||||||
scene.load.spritesheet(
|
scene.load.spritesheet(
|
||||||
fighterAttackEffectKey(skin),
|
fighterAttackEffectKey(skin),
|
||||||
`${skin.assetRoot}/${attackEffect.file}`,
|
`${skin.assetRoot}/${attackEffect.file}`,
|
||||||
{ frameWidth: FIGHTER_FRAME_WIDTH, frameHeight: FIGHTER_FRAME_HEIGHT },
|
{ frameWidth: FIGHTER.FRAME_WIDTH, frameHeight: FIGHTER.FRAME_HEIGHT },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,29 +182,29 @@ function createHealEffectAnimation(scene) {
|
|||||||
key: healEffectAnimationKey(),
|
key: healEffectAnimationKey(),
|
||||||
frames: scene.anims.generateFrameNumbers(healEffectKey(), {
|
frames: scene.anims.generateFrameNumbers(healEffectKey(), {
|
||||||
start: 0,
|
start: 0,
|
||||||
end: KILL_HEAL_EFFECT_FRAMES - 1,
|
end: COMBAT.KILL_HEAL_EFFECT_FRAMES - 1,
|
||||||
}),
|
}),
|
||||||
frameRate: KILL_HEAL_EFFECT_FRAME_RATE,
|
frameRate: COMBAT.KILL_HEAL_EFFECT_FRAME_RATE,
|
||||||
repeat: 0,
|
repeat: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFighterOutlineSheet(scene, skin, action, frameCount) {
|
function createFighterTeamShadowSheet(scene, skin, action, frameCount, teamColor) {
|
||||||
const key = fighterOutlineSheetKey(skin, action);
|
const key = fighterSheetKey(skin, action, teamColor);
|
||||||
|
|
||||||
if (scene.textures.exists(key)) {
|
if (scene.textures.exists(key)) {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceTexture = scene.textures.get(fighterSheetKey(skin, action));
|
const sourceTexture = scene.textures.get(fighterSheetKey(skin, action));
|
||||||
const sourceImage = sourceTexture?.getSourceImage?.();
|
const sourceImage = sourceTexture?.getSourceImage?.();
|
||||||
|
|
||||||
if (!sourceImage) {
|
if (!sourceImage) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sheetWidth = FIGHTER_FRAME_WIDTH * frameCount;
|
const sheetWidth = FIGHTER.FRAME_WIDTH * frameCount;
|
||||||
const sheetHeight = FIGHTER_FRAME_HEIGHT;
|
const sheetHeight = FIGHTER.FRAME_HEIGHT;
|
||||||
const sourceCanvas = document.createElement("canvas");
|
const sourceCanvas = document.createElement("canvas");
|
||||||
sourceCanvas.width = sheetWidth;
|
sourceCanvas.width = sheetWidth;
|
||||||
sourceCanvas.height = sheetHeight;
|
sourceCanvas.height = sheetHeight;
|
||||||
@@ -177,90 +212,60 @@ function createFighterOutlineSheet(scene, skin, action, frameCount) {
|
|||||||
const sourceContext = sourceCanvas.getContext("2d", { willReadFrequently: true });
|
const sourceContext = sourceCanvas.getContext("2d", { willReadFrequently: true });
|
||||||
sourceContext.drawImage(sourceImage, 0, 0);
|
sourceContext.drawImage(sourceImage, 0, 0);
|
||||||
|
|
||||||
const sourceData = sourceContext.getImageData(0, 0, sheetWidth, sheetHeight).data;
|
const sourceImageData = sourceContext.getImageData(0, 0, sheetWidth, sheetHeight);
|
||||||
const outlineCanvas = document.createElement("canvas");
|
const sourceData = sourceImageData.data;
|
||||||
outlineCanvas.width = sheetWidth;
|
const shadowColor = parseHexColor(teamColor);
|
||||||
outlineCanvas.height = sheetHeight;
|
|
||||||
|
|
||||||
const outlineContext = outlineCanvas.getContext("2d");
|
|
||||||
const outlineImage = outlineContext.createImageData(sheetWidth, sheetHeight);
|
|
||||||
const outlineData = outlineImage.data;
|
|
||||||
const gapMask = new Uint8Array(sheetWidth * sheetHeight);
|
|
||||||
const outerMask = new Uint8Array(sheetWidth * sheetHeight);
|
|
||||||
const outlineAlpha = Math.round(SELECTED_FIGHTER_OUTLINE_ALPHA * 255);
|
|
||||||
|
|
||||||
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
||||||
const frameLeft = frameIndex * FIGHTER_FRAME_WIDTH;
|
const frameLeft = frameIndex * FIGHTER.FRAME_WIDTH;
|
||||||
|
|
||||||
for (let y = 0; y < FIGHTER_FRAME_HEIGHT; y += 1) {
|
for (let y = TEAM_SHADOW_FRAME_Y_START; y < TEAM_SHADOW_FRAME_Y_END; y += 1) {
|
||||||
for (let x = 0; x < FIGHTER_FRAME_WIDTH; x += 1) {
|
for (let x = 0; x < FIGHTER.FRAME_WIDTH; x += 1) {
|
||||||
const sourceIndex = ((y * sheetWidth) + frameLeft + x) * 4;
|
const sourceIndex = ((y * sheetWidth) + frameLeft + x) * 4;
|
||||||
|
|
||||||
if (sourceData[sourceIndex + 3] <= SOURCE_ALPHA_THRESHOLD) {
|
if (!isTeamShadowPixel(sourceData, sourceIndex)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, x, y);
|
sourceData[sourceIndex] = shadowColor.red;
|
||||||
|
sourceData[sourceIndex + 1] = shadowColor.green;
|
||||||
|
sourceData[sourceIndex + 2] = shadowColor.blue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha);
|
sourceContext.putImageData(sourceImageData, 0, 0);
|
||||||
outlineContext.putImageData(outlineImage, 0, 0);
|
scene.textures.addSpriteSheet(key, sourceCanvas, {
|
||||||
scene.textures.addSpriteSheet(key, outlineCanvas, {
|
frameWidth: FIGHTER.FRAME_WIDTH,
|
||||||
frameWidth: FIGHTER_FRAME_WIDTH,
|
frameHeight: FIGHTER.FRAME_HEIGHT,
|
||||||
frameHeight: FIGHTER_FRAME_HEIGHT,
|
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function markOutlineMasks(gapMask, outerMask, sheetWidth, frameLeft, sourceX, sourceY) {
|
function isTeamShadowPixel(data, index) {
|
||||||
const outerRadius = SELECTED_FIGHTER_OUTLINE_GAP + SELECTED_FIGHTER_OUTLINE_WIDTH;
|
return (
|
||||||
|
data[index + 3] > 0
|
||||||
for (
|
&& data[index] === TEAM_SHADOW_SOURCE_COLOR.red
|
||||||
let offsetY = -outerRadius;
|
&& data[index + 1] === TEAM_SHADOW_SOURCE_COLOR.green
|
||||||
offsetY <= outerRadius;
|
&& data[index + 2] === TEAM_SHADOW_SOURCE_COLOR.blue
|
||||||
offsetY += 1
|
);
|
||||||
) {
|
|
||||||
const targetY = sourceY + offsetY;
|
|
||||||
|
|
||||||
if (targetY < 0 || targetY >= FIGHTER_FRAME_HEIGHT) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (
|
|
||||||
let offsetX = -outerRadius;
|
|
||||||
offsetX <= outerRadius;
|
|
||||||
offsetX += 1
|
|
||||||
) {
|
|
||||||
const targetX = sourceX + offsetX;
|
|
||||||
|
|
||||||
if (targetX < 0 || targetX >= FIGHTER_FRAME_WIDTH) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maskIndex = (targetY * sheetWidth) + frameLeft + targetX;
|
|
||||||
const distance = Math.max(Math.abs(offsetX), Math.abs(offsetY));
|
|
||||||
|
|
||||||
outerMask[maskIndex] = 1;
|
|
||||||
|
|
||||||
if (distance <= SELECTED_FIGHTER_OUTLINE_GAP) {
|
|
||||||
gapMask[maskIndex] = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function paintOutlinePixels(outlineData, gapMask, outerMask, outlineAlpha) {
|
function normalizeTeamColorKey(teamColor) {
|
||||||
for (let maskIndex = 0; maskIndex < outerMask.length; maskIndex += 1) {
|
return parseHexColor(teamColor).hex;
|
||||||
if (!outerMask[maskIndex] || gapMask[maskIndex]) {
|
}
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const outlineIndex = maskIndex * 4;
|
function parseHexColor(teamColor) {
|
||||||
|
const fallback = "ffffff";
|
||||||
|
const hex = typeof teamColor === "string"
|
||||||
|
? teamColor.trim().replace(/^#/, "").toLowerCase()
|
||||||
|
: fallback;
|
||||||
|
const normalizedHex = /^[0-9a-f]{6}$/.test(hex) ? hex : fallback;
|
||||||
|
|
||||||
outlineData[outlineIndex] = 255;
|
return {
|
||||||
outlineData[outlineIndex + 1] = 255;
|
blue: parseInt(normalizedHex.slice(4, 6), 16),
|
||||||
outlineData[outlineIndex + 2] = 255;
|
green: parseInt(normalizedHex.slice(2, 4), 16),
|
||||||
outlineData[outlineIndex + 3] = outlineAlpha;
|
hex: normalizedHex,
|
||||||
}
|
red: parseInt(normalizedHex.slice(0, 2), 16),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,173 +1,292 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import {
|
import {
|
||||||
FIGHTER_FRAME_HEIGHT,
|
FIGHTER,
|
||||||
FIGHTER_FRAME_WIDTH,
|
PERFORMANCE,
|
||||||
FIGHTER_DEPTH,
|
|
||||||
FIGHTER_HITBOX_HEIGHT,
|
|
||||||
FIGHTER_HITBOX_OFFSET_X,
|
|
||||||
FIGHTER_HITBOX_OFFSET_Y,
|
|
||||||
FIGHTER_HITBOX_WIDTH,
|
|
||||||
FIGHTER_MAX_HP,
|
|
||||||
FIGHTER_SCALE,
|
|
||||||
} from "../../constants.js";
|
} from "../../constants.js";
|
||||||
import {
|
import {
|
||||||
fighterAnimationKey,
|
ensureFighterTeamAnimation,
|
||||||
fighterOutlineSheetKeyFromSheetKey,
|
ensureFighterTeamAnimations,
|
||||||
fighterSheetKey,
|
fighterSheetKey,
|
||||||
} from "./fighterAssets.js";
|
} from "./fighterAssets.js";
|
||||||
|
import { getFighterStats } from "./fighterStats.js";
|
||||||
|
|
||||||
const NAME_LABEL_BOTTOM_GAP = 14;
|
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 },
|
{
|
||||||
|
canSplitOnDeath = true,
|
||||||
|
faceLeft,
|
||||||
|
hp,
|
||||||
|
isElite = false,
|
||||||
|
maxHp,
|
||||||
|
name,
|
||||||
|
skin,
|
||||||
|
stackCount = 1,
|
||||||
|
team,
|
||||||
|
teamIndex,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const fighter = scene.physics.add.sprite(x, y, fighterSheetKey(skin, "idle"), 0);
|
ensureFighterTeamAnimations(scene, skin, team.color, ["idle"]);
|
||||||
const teamColor = Phaser.Display.Color.HexStringToColor(team.color).color;
|
|
||||||
|
const teamIdleSheetKey = fighterSheetKey(skin, "idle", team.color);
|
||||||
|
const idleSheetKey = scene.textures.exists(teamIdleSheetKey)
|
||||||
|
? teamIdleSheetKey
|
||||||
|
: fighterSheetKey(skin, "idle");
|
||||||
|
const fighter = scene.physics.add.sprite(x, y, idleSheetKey, 0);
|
||||||
const displayName = name || team.label;
|
const displayName = name || team.label;
|
||||||
const resolvedMaxHp = Math.max(1, Math.round(maxHp ?? skin.stats?.maxHp ?? FIGHTER_MAX_HP));
|
const baseCombatStats = getFighterStats(skin);
|
||||||
|
const resolvedStackCount = Math.max(1, Math.round(Number(stackCount) || 1));
|
||||||
|
const resolvedIsElite = Boolean(isElite);
|
||||||
|
const attackDamageMultiplier = resolvedIsElite
|
||||||
|
? eliteBonusMultiplier(
|
||||||
|
resolvedStackCount,
|
||||||
|
FIGHTER.ELITE.ATTACK_DAMAGE_BONUS_MULTIPLIER,
|
||||||
|
FIGHTER.ELITE.ATTACK_DAMAGE_STACK_EXPONENT,
|
||||||
|
)
|
||||||
|
: 1;
|
||||||
|
const visualScale = resolvedIsElite
|
||||||
|
? FIGHTER.SCALE * FIGHTER.ELITE.VISUAL_SCALE_MULTIPLIER
|
||||||
|
: FIGHTER.SCALE;
|
||||||
|
const rangeBonus = resolvedIsElite
|
||||||
|
? (visualScale - FIGHTER.SCALE) * (FIGHTER.HITBOX_WIDTH / 2)
|
||||||
|
: 0;
|
||||||
|
const combatStats = {
|
||||||
|
...baseCombatStats,
|
||||||
|
attackRange: resolvedIsElite
|
||||||
|
? baseCombatStats.attackRange * FIGHTER.ELITE.ATTACK_RANGE_MULTIPLIER + rangeBonus
|
||||||
|
: baseCombatStats.attackRange,
|
||||||
|
damageMax: baseCombatStats.damageMax * attackDamageMultiplier,
|
||||||
|
damageMin: baseCombatStats.damageMin * attackDamageMultiplier,
|
||||||
|
};
|
||||||
|
const hpMultiplier = resolvedIsElite
|
||||||
|
? resolvedStackCount * FIGHTER.ELITE.HP_BONUS_RATIO
|
||||||
|
: resolvedStackCount;
|
||||||
|
const resolvedMaxHp = Math.max(1, Math.round((maxHp ?? baseCombatStats.maxHp) * hpMultiplier));
|
||||||
const resolvedHp = Math.min(
|
const resolvedHp = Math.min(
|
||||||
resolvedMaxHp,
|
resolvedMaxHp,
|
||||||
Math.max(1, Math.round(hp ?? resolvedMaxHp)),
|
Math.max(1, Math.round(hp ?? resolvedMaxHp)),
|
||||||
);
|
);
|
||||||
|
|
||||||
fighter.setScale(FIGHTER_SCALE);
|
fighter.setScale(visualScale);
|
||||||
fighter.setName(displayName);
|
fighter.setName(displayName);
|
||||||
fighter.setDepth(FIGHTER_DEPTH);
|
fighter.setDepth(FIGHTER.DEPTH);
|
||||||
fighter.setAlpha(1);
|
fighter.setAlpha(1);
|
||||||
fighter.setCollideWorldBounds(true);
|
fighter.setCollideWorldBounds(true);
|
||||||
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(
|
fighter.setInteractive(
|
||||||
new Phaser.Geom.Rectangle(
|
new Phaser.Geom.Rectangle(
|
||||||
FIGHTER_HITBOX_OFFSET_X,
|
FIGHTER.HITBOX_OFFSET_X,
|
||||||
FIGHTER_HITBOX_OFFSET_Y,
|
FIGHTER.HITBOX_OFFSET_Y,
|
||||||
FIGHTER_HITBOX_WIDTH,
|
FIGHTER.HITBOX_WIDTH,
|
||||||
FIGHTER_HITBOX_HEIGHT,
|
FIGHTER.HITBOX_HEIGHT,
|
||||||
),
|
),
|
||||||
Phaser.Geom.Rectangle.Contains,
|
Phaser.Geom.Rectangle.Contains,
|
||||||
);
|
);
|
||||||
fighter.input.cursor = "pointer";
|
fighter.input.cursor = "pointer";
|
||||||
|
|
||||||
fighter.teamMarker = scene.add
|
|
||||||
.sprite(x, y, fighterOutlineSheetKeyFromSheetKey(fighterSheetKey(skin, "idle")), 0)
|
|
||||||
.setDisplaySize(FIGHTER_FRAME_WIDTH * FIGHTER_SCALE, FIGHTER_FRAME_HEIGHT * FIGHTER_SCALE)
|
|
||||||
.setTint(teamColor)
|
|
||||||
.setAlpha(0.8)
|
|
||||||
.setDepth(1.9)
|
|
||||||
.setVisible(true);
|
|
||||||
|
|
||||||
fighter.nameLabel = scene.add
|
|
||||||
.text(x, y, displayName, {
|
|
||||||
color: "#fff2c2",
|
|
||||||
fontFamily: "Inter, Pretendard, sans-serif",
|
|
||||||
fontSize: "18px",
|
|
||||||
fontStyle: "700",
|
|
||||||
stroke: team.color,
|
|
||||||
strokeThickness: 4,
|
|
||||||
})
|
|
||||||
.setOrigin(0.5, 0)
|
|
||||||
.setDepth(4);
|
|
||||||
fighter.healthBack = scene.add
|
|
||||||
.rectangle(x, y - 44, 72, 8, 0x17180e, 0.92)
|
|
||||||
.setDepth(4);
|
|
||||||
fighter.healthBar = scene.add
|
|
||||||
.rectangle(x - 34, y - 44, 68, 4, 0xd95f3f, 1)
|
|
||||||
.setOrigin(0, 0.5)
|
|
||||||
.setDepth(5);
|
|
||||||
|
|
||||||
fighter.skin = skin;
|
fighter.skin = skin;
|
||||||
|
fighter.combatStats = combatStats;
|
||||||
fighter.fighterName = displayName;
|
fighter.fighterName = displayName;
|
||||||
|
fighter.isElite = resolvedIsElite;
|
||||||
|
fighter.stackCount = resolvedStackCount;
|
||||||
fighter.team = team;
|
fighter.team = team;
|
||||||
fighter.teamIndex = teamIndex;
|
fighter.teamIndex = teamIndex;
|
||||||
fighter.baseScaleX = FIGHTER_SCALE;
|
fighter.baseScaleX = visualScale;
|
||||||
fighter.baseScaleY = FIGHTER_SCALE;
|
fighter.baseScaleY = visualScale;
|
||||||
fighter.canSplitOnDeath = canSplitOnDeath;
|
fighter.canSplitOnDeath = canSplitOnDeath && !resolvedIsElite;
|
||||||
fighter.isSelected = false;
|
fighter.isSelected = false;
|
||||||
fighter.killCount = 0;
|
fighter.killCount = 0;
|
||||||
fighter.killRewardMultiplier = 1;
|
fighter.killRewardMultiplier = 1;
|
||||||
|
fighter.worldEffectSpeedMultiplier = 1;
|
||||||
|
fighter.isFrostStunned = false;
|
||||||
|
fighter.frostStunTimer = null;
|
||||||
fighter.maxHp = resolvedMaxHp;
|
fighter.maxHp = resolvedMaxHp;
|
||||||
fighter.hp = resolvedHp;
|
fighter.hp = resolvedHp;
|
||||||
fighter.nextAttackAt = 0;
|
fighter.nextAttackAt = 0;
|
||||||
|
fighter.nextHudSyncAt = 0;
|
||||||
|
fighter.nextTargetScanAt = 0;
|
||||||
|
fighter.targetEnemy = null;
|
||||||
|
fighter._hudDetailsVisible = false;
|
||||||
|
fighter._hudSlot = null;
|
||||||
fighter.isLocked = false;
|
fighter.isLocked = false;
|
||||||
fighter.isDead = false;
|
fighter.isDead = false;
|
||||||
fighter.play(fighterAnimationKey(skin, "walk"));
|
fighter.play(ensureFighterTeamAnimation(scene, skin, "walk", team.color));
|
||||||
|
|
||||||
fighter.on(Phaser.Animations.Events.ANIMATION_COMPLETE, (animation) => {
|
fighter.on(Phaser.Animations.Events.ANIMATION_COMPLETE, (animation) => {
|
||||||
if (fighter.isDead) {
|
if (fighter.isDead) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (animation.key.includes("-attack") || animation.key.endsWith("-hurt-anim")) {
|
if (animation.key.includes("-attack") || animation.key.includes("-hurt")) {
|
||||||
fighter.isLocked = false;
|
fighter.isLocked = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fighter.releaseHud = () => releaseFighterHud(fighter);
|
||||||
attachHudCleanup(fighter);
|
attachHudCleanup(fighter);
|
||||||
syncFighterHud(fighter);
|
|
||||||
|
|
||||||
return fighter;
|
return fighter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncFighterHud(fighter) {
|
function eliteBonusMultiplier(stackCount, bonusMultiplier, stackExponent) {
|
||||||
const isVisible = Boolean(fighter.active && !fighter.isDead);
|
const stackedMultiplier = Math.pow(stackCount, stackExponent);
|
||||||
|
return 1 + bonusMultiplier * (stackedMultiplier - 1);
|
||||||
fighter.nameLabel.setVisible(isVisible);
|
|
||||||
fighter.healthBack.setVisible(isVisible);
|
|
||||||
fighter.healthBar.setVisible(isVisible);
|
|
||||||
syncTeamMarker(fighter);
|
|
||||||
|
|
||||||
if (!isVisible || !fighter.body) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scaleRatio = Math.max(1, Math.abs(fighter.scaleY) / FIGHTER_SCALE);
|
|
||||||
const healthOffset = 44 * scaleRatio;
|
|
||||||
const hitbox = fighter.body;
|
|
||||||
const nameX = hitbox.x + hitbox.width / 2;
|
|
||||||
const nameY = hitbox.y + hitbox.height + NAME_LABEL_BOTTOM_GAP;
|
|
||||||
|
|
||||||
fighter.nameLabel.setPosition(nameX, nameY);
|
|
||||||
fighter.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
|
|
||||||
fighter.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
|
|
||||||
fighter.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? FIGHTER_MAX_HP)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncTeamMarker(fighter) {
|
export function syncFighterHud(
|
||||||
const marker = fighter.teamMarker;
|
fighter,
|
||||||
|
{ force = false, showDetails = true, time = fighter.scene?.time?.now ?? 0 } = {},
|
||||||
if (!marker) {
|
) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isVisible = Boolean(fighter.active && !fighter.isDead);
|
const isVisible = Boolean(fighter.active && !fighter.isDead);
|
||||||
marker.setVisible(isVisible);
|
const detailsVisible = isVisible && (showDetails || fighter.isSelected);
|
||||||
|
|
||||||
if (!isVisible) {
|
if (!detailsVisible || !fighter.body) {
|
||||||
|
releaseFighterHud(fighter);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hudSlot = acquireFighterHudSlot(fighter);
|
||||||
|
|
||||||
|
if (!hudSlot) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Number.isFinite(time) ? time : fighter.scene?.time?.now ?? 0;
|
||||||
|
const shouldSyncDetails =
|
||||||
|
force
|
||||||
|
|| fighter.isSelected
|
||||||
|
|| !fighter._hudDetailsVisible
|
||||||
|
|| fighter._lastHudHp !== fighter.hp
|
||||||
|
|| now >= (fighter.nextHudSyncAt ?? 0);
|
||||||
|
|
||||||
|
if (!shouldSyncDetails) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fighter.nextHudSyncAt = now + HUD_DETAIL_SYNC_INTERVAL_MS;
|
||||||
|
fighter._lastHudHp = fighter.hp;
|
||||||
|
setHudDetailsVisible(fighter, true);
|
||||||
|
|
||||||
|
const scaleRatio = Math.max(1, Math.abs(fighter.scaleY) / FIGHTER.SCALE);
|
||||||
|
const healthOffset = 44 * scaleRatio;
|
||||||
|
|
||||||
|
hudSlot.healthBack.setPosition(fighter.x, fighter.y - healthOffset);
|
||||||
|
hudSlot.healthBar.setPosition(fighter.x - 34, fighter.y - healthOffset);
|
||||||
|
hudSlot.healthBar.width = Math.max(0, 68 * (fighter.hp / (fighter.maxHp ?? 1)));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseFighterHud(fighter) {
|
||||||
|
const hudSlot = fighter?._hudSlot;
|
||||||
|
|
||||||
|
if (!hudSlot) {
|
||||||
|
if (fighter) {
|
||||||
|
fighter._hudDetailsVisible = false;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const outlineTextureKey = fighterOutlineSheetKeyFromSheetKey(fighter.texture.key);
|
setHudSlotVisible(hudSlot, false);
|
||||||
|
hudSlot.fighter = null;
|
||||||
|
fighter._hudSlot = null;
|
||||||
|
fighter._hudDetailsVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (fighter.scene.textures.exists(outlineTextureKey)) {
|
export function releaseUnusedFighterHuds(scene, fightersWithHud = []) {
|
||||||
marker.setTexture(outlineTextureKey, fighter.frame.name);
|
const activeFighters = fightersWithHud instanceof Set
|
||||||
|
? fightersWithHud
|
||||||
|
: new Set(fightersWithHud);
|
||||||
|
|
||||||
|
scene.fighterHudPool?.forEach((hudSlot) => {
|
||||||
|
if (hudSlot.fighter && !activeFighters.has(hudSlot.fighter)) {
|
||||||
|
releaseFighterHud(hudSlot.fighter);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHudDetailsVisible(fighter, visible) {
|
||||||
|
const hudSlot = fighter._hudSlot;
|
||||||
|
|
||||||
|
if (!hudSlot) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
marker.setPosition(fighter.x, fighter.y);
|
fighter._hudDetailsVisible = visible;
|
||||||
marker.setScale(fighter.scaleX, fighter.scaleY);
|
setHudSlotVisible(hudSlot, visible);
|
||||||
marker.setFlipX(fighter.flipX);
|
}
|
||||||
marker.setDepth(fighter.depth - 0.1);
|
|
||||||
|
function setVisibleIfChanged(gameObject, visible) {
|
||||||
|
if (gameObject && gameObject.visible !== visible) {
|
||||||
|
gameObject.setVisible(visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHudSlotVisible(hudSlot, visible) {
|
||||||
|
setVisibleIfChanged(hudSlot.healthBack, visible);
|
||||||
|
setVisibleIfChanged(hudSlot.healthBar, visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquireFighterHudSlot(fighter) {
|
||||||
|
if (fighter._hudSlot) {
|
||||||
|
return fighter._hudSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hudPool = ensureFighterHudPool(fighter.scene);
|
||||||
|
const hudSlot = hudPool.find((candidate) => !candidate.fighter);
|
||||||
|
|
||||||
|
if (!hudSlot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
hudSlot.fighter = fighter;
|
||||||
|
fighter._hudSlot = hudSlot;
|
||||||
|
configureHudSlot(hudSlot);
|
||||||
|
return hudSlot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureFighterHudPool(scene) {
|
||||||
|
if (scene.fighterHudPool) {
|
||||||
|
return scene.fighterHudPool;
|
||||||
|
}
|
||||||
|
|
||||||
|
const poolSize = Math.max(0, Math.round(PERFORMANCE.FIGHTER_HUD_POOL_SIZE));
|
||||||
|
scene.fighterHudPool = Array.from({ length: poolSize }, () => createHudSlot(scene));
|
||||||
|
return scene.fighterHudPool;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHudSlot(scene) {
|
||||||
|
const healthBack = scene.add
|
||||||
|
.rectangle(0, 0, 72, 8, 0x17180e, 0.92)
|
||||||
|
.setDepth(4)
|
||||||
|
.setVisible(false);
|
||||||
|
const healthBar = scene.add
|
||||||
|
.rectangle(0, 0, 68, 4, 0xd95f3f, 1)
|
||||||
|
.setOrigin(0, 0.5)
|
||||||
|
.setDepth(5)
|
||||||
|
.setVisible(false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fighter: null,
|
||||||
|
healthBack,
|
||||||
|
healthBar,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureHudSlot(hudSlot) {
|
||||||
|
hudSlot.healthBack.setDepth(4);
|
||||||
|
hudSlot.healthBar.setDepth(5);
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachHudCleanup(fighter) {
|
function attachHudCleanup(fighter) {
|
||||||
const originalDestroy = fighter.destroy.bind(fighter);
|
const originalDestroy = fighter.destroy.bind(fighter);
|
||||||
|
|
||||||
fighter.destroy = (...args) => {
|
fighter.destroy = (...args) => {
|
||||||
fighter.teamMarker.destroy();
|
releaseFighterHud(fighter);
|
||||||
fighter.nameLabel.destroy();
|
|
||||||
fighter.healthBack.destroy();
|
|
||||||
fighter.healthBar.destroy();
|
|
||||||
originalDestroy(...args);
|
originalDestroy(...args);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
@@ -239,7 +239,7 @@ export const fighterManifest = [
|
|||||||
maxHp: 1,
|
maxHp: 1,
|
||||||
},
|
},
|
||||||
traits: {
|
traits: {
|
||||||
spawnMultiplier: 10,
|
spawnMultiplier: 3,
|
||||||
splitOnDeath: {
|
splitOnDeath: {
|
||||||
chance: 0.5,
|
chance: 0.5,
|
||||||
count: 2,
|
count: 2,
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { FIGHTER } from "../../constants.js";
|
||||||
|
|
||||||
|
export const FIGHTER_TYPES = {
|
||||||
|
MAGIC: "magic",
|
||||||
|
MELEE: "melee",
|
||||||
|
RANGED: "ranged",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getFighterType(skin) {
|
||||||
|
const configuredType = skin.combat?.fighterType;
|
||||||
|
|
||||||
|
if (configuredType && FIGHTER.TYPE_STATS[configuredType]) {
|
||||||
|
return configuredType;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (skin.combat?.type) {
|
||||||
|
case "projectile":
|
||||||
|
return FIGHTER_TYPES.RANGED;
|
||||||
|
case "instant-spell":
|
||||||
|
return FIGHTER_TYPES.MAGIC;
|
||||||
|
default:
|
||||||
|
return FIGHTER_TYPES.MELEE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFighterStats(skin) {
|
||||||
|
const defaults = FIGHTER.TYPE_STATS[getFighterType(skin)];
|
||||||
|
const stats = skin.stats ?? {};
|
||||||
|
const combat = skin.combat ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxHp: stats.maxHp ?? defaults.maxHp,
|
||||||
|
moveSpeed: stats.moveSpeed ?? defaults.moveSpeed,
|
||||||
|
attackRange: combat.range ?? stats.attackRange ?? defaults.attackRange,
|
||||||
|
attackCooldown: combat.cooldown ?? stats.attackCooldown ?? defaults.attackCooldown,
|
||||||
|
damageMin: combat.damageMin ?? stats.damageMin ?? defaults.damageMin,
|
||||||
|
damageMax: combat.damageMax ?? stats.damageMax ?? defaults.damageMax,
|
||||||
|
criticalChance:
|
||||||
|
combat.criticalChance ?? stats.criticalChance ?? defaults.criticalChance,
|
||||||
|
windupDelay: combat.windupDelay ?? stats.windupDelay ?? defaults.windupDelay,
|
||||||
|
projectileSpeed:
|
||||||
|
combat.projectile?.speed ??
|
||||||
|
stats.projectileSpeed ??
|
||||||
|
defaults.projectileSpeed ??
|
||||||
|
FIGHTER.TYPE_STATS.ranged.projectileSpeed,
|
||||||
|
effectHitDelay:
|
||||||
|
combat.attackEffect?.hitDelay ??
|
||||||
|
stats.effectHitDelay ??
|
||||||
|
defaults.effectHitDelay ??
|
||||||
|
FIGHTER.TYPE_STATS.magic.effectHitDelay,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { ARENA_SIZE } from "../../constants.js";
|
import { ARENA } from "../../constants.js";
|
||||||
|
|
||||||
const SPAWN_CLUSTER_MARGIN = 48;
|
const SPAWN_CLUSTER_MARGIN = 48;
|
||||||
const SPAWN_CLUSTER_STEP = 28;
|
const SPAWN_CLUSTER_STEP = 28;
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -44,11 +44,17 @@ export function clusterSpawnPosition(origin, index, count) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function clampInsideArena(value) {
|
export function clampInsideArena(value) {
|
||||||
return Phaser.Math.Clamp(value, SPAWN_CLUSTER_MARGIN, ARENA_SIZE - SPAWN_CLUSTER_MARGIN);
|
return Phaser.Math.Clamp(value, SPAWN_CLUSTER_MARGIN, ARENA.SIZE - SPAWN_CLUSTER_MARGIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
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,112 +1,382 @@
|
|||||||
import {
|
import { ARENA, FIGHTER, PERFORMANCE, SPAWN, TEAM } from "../../constants.js";
|
||||||
ARENA_SIZE,
|
|
||||||
DEFAULT_SPAWN_PLACEMENT,
|
const NAME_MULTIPLIER_REGEX = /\*(\d+)$/;
|
||||||
DEFAULT_TEAM_SIZE,
|
|
||||||
GRID_SIZE,
|
|
||||||
getTeamColor,
|
|
||||||
MAX_TEAM_SIZE,
|
|
||||||
SPAWN_PLACEMENTS,
|
|
||||||
TILE_SIZE,
|
|
||||||
} from "../../constants.js";
|
|
||||||
|
|
||||||
export function createMatchSetup(
|
export function createMatchSetup(
|
||||||
names,
|
names,
|
||||||
requestedTeamSize = DEFAULT_TEAM_SIZE,
|
requestedSpawnPlacement = SPAWN.DEFAULT_PLACEMENT,
|
||||||
requestedSpawnPlacement = DEFAULT_SPAWN_PLACEMENT,
|
|
||||||
) {
|
) {
|
||||||
const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE));
|
const teams = names.map((rawName, index) => {
|
||||||
const teams = names.map((name, index) => ({
|
const match = rawName.match(NAME_MULTIPLIER_REGEX);
|
||||||
color: getTeamColor(index, names.length),
|
const multiplier = match ? Math.max(1, parseInt(match[1], 10)) : 1;
|
||||||
id: `team-${index + 1}`,
|
const label = match ? rawName.replace(NAME_MULTIPLIER_REGEX, "") : rawName;
|
||||||
label: name,
|
|
||||||
size: teamSize,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const spawns = createSpawnPoints(names.length, teamSize, requestedSpawnPlacement);
|
|
||||||
|
|
||||||
const fighters = [];
|
|
||||||
names.forEach((name, teamIndex) => {
|
|
||||||
for (let i = 0; i < teamSize; i++) {
|
|
||||||
const globalIndex = teamIndex * teamSize + i;
|
|
||||||
fighters.push({
|
|
||||||
...spawns[globalIndex],
|
|
||||||
name: name,
|
|
||||||
team: teams[teamIndex],
|
|
||||||
teamIndex: i,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fighters,
|
color: TEAM.getColor(index, names.length),
|
||||||
|
id: `team-${index + 1}`,
|
||||||
|
label,
|
||||||
|
multiplier,
|
||||||
|
size: multiplier,
|
||||||
|
startingZoneCount: Math.ceil(multiplier / SPAWN.FIGHTERS_PER_STARTING_ZONE),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalFighters = teams.reduce((sum, team) => sum + team.size, 0);
|
||||||
|
|
||||||
|
if (totalFighters > SPAWN.MAX_FIGHTER_COUNT) {
|
||||||
|
throw new FighterCountLimitError(totalFighters);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startingZones =
|
||||||
|
requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES
|
||||||
|
? createStartingZones(teams)
|
||||||
|
: [];
|
||||||
|
const spawns = createSpawnPoints(
|
||||||
|
totalFighters,
|
||||||
|
requestedSpawnPlacement,
|
||||||
|
startingZones,
|
||||||
|
);
|
||||||
|
|
||||||
|
const teamRosters = [];
|
||||||
|
let spawnOffset = 0;
|
||||||
|
|
||||||
|
teams.forEach((team) => {
|
||||||
|
const teamRoster = usesRandomizedEliteCompression(team)
|
||||||
|
? createRandomizedEliteRoster(team, spawns, spawnOffset, totalFighters)
|
||||||
|
: createFixedEliteRoster(team, spawns, spawnOffset);
|
||||||
|
|
||||||
|
teamRosters.push(teamRoster);
|
||||||
|
|
||||||
|
spawnOffset += team.size;
|
||||||
|
});
|
||||||
|
|
||||||
|
enforceRenderedFighterLimit(teamRosters, totalFighters);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fighters: teamRosters.flatMap(materializeEliteRoster),
|
||||||
|
startingZones,
|
||||||
teams,
|
teams,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchStatusText(teams) {
|
function usesRandomizedEliteCompression(team) {
|
||||||
const totalFighters = teams.reduce((sum, team) => sum + team.size, 0);
|
return team.size >= FIGHTER.ELITE.RANDOMIZED_COMPRESSION.MIN_TEAM_SIZE;
|
||||||
const teamSizes = new Set(teams.map((team) => team.size));
|
|
||||||
const teamSizeText = teamSizes.size === 1 ? `팀당 ${teams[0]?.size ?? 0}명` : "팀별 가변 인원";
|
|
||||||
const labels = teams.map((team) => `${team.label} ${team.size}명`).join(" / ");
|
|
||||||
|
|
||||||
return `${teams.length}팀 | ${teamSizeText} | 총 ${totalFighters}명 출전 | ${labels}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTeams(playerCount, teamSize) {
|
function createFixedEliteRoster(team, spawns, spawnOffset) {
|
||||||
const teamCount = Math.ceil(playerCount / teamSize);
|
const eliteCount = Math.floor(team.size / FIGHTER.ELITE.STACK_SIZE);
|
||||||
|
|
||||||
return Array.from({ length: teamCount }, (_, index) => ({
|
return {
|
||||||
color: getTeamColor(index, teamCount),
|
blocks: Array.from({ length: eliteCount }, (_, index) => ({
|
||||||
id: `team-${index + 1}`,
|
isElite: true,
|
||||||
label: `Team ${index + 1}`,
|
startIndex: index * FIGHTER.ELITE.STACK_SIZE,
|
||||||
size: Math.min(teamSize, playerCount - index * teamSize),
|
stackCount: FIGHTER.ELITE.STACK_SIZE,
|
||||||
}));
|
})),
|
||||||
|
normalRemainderCount: team.size % FIGHTER.ELITE.STACK_SIZE,
|
||||||
|
remainderIsElite: false,
|
||||||
|
spawnOffset,
|
||||||
|
spawns,
|
||||||
|
team,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSpawnPoints(teamCount, teamSize, requestedSpawnPlacement) {
|
function createRandomizedEliteRoster(team, spawns, spawnOffset, totalFighters) {
|
||||||
if (requestedSpawnPlacement === SPAWN_PLACEMENTS.STARTING_ZONES) {
|
const eliteBlockProbability = resolveEliteBlockProbability(totalFighters);
|
||||||
return createStartingZoneSpawnPoints(teamCount, teamSize);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return createRandomSpawnPoints(teamCount * teamSize);
|
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 {
|
||||||
|
...spawn,
|
||||||
|
isElite: true,
|
||||||
|
name: eliteCount > 1
|
||||||
|
? `${team.label} (Elite ${eliteIndex + 1})`
|
||||||
|
: `${team.label} (Elite)`,
|
||||||
|
stackCount,
|
||||||
|
team,
|
||||||
|
teamIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNormalPlan(team, spawn, teamIndex) {
|
||||||
|
return {
|
||||||
|
...spawn,
|
||||||
|
isElite: false,
|
||||||
|
name: team.label,
|
||||||
|
stackCount: 1,
|
||||||
|
team,
|
||||||
|
teamIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FighterCountLimitError extends Error {
|
||||||
|
constructor(fighterCount) {
|
||||||
|
super(`Requested fighter count exceeds the ${SPAWN.MAX_FIGHTER_COUNT} limit.`);
|
||||||
|
this.fighterCount = fighterCount;
|
||||||
|
this.maxFighterCount = SPAWN.MAX_FIGHTER_COUNT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchStatusText(teams) {
|
||||||
|
const totalFighters = teams.reduce((sum, team) => sum + team.size, 0);
|
||||||
|
const labels = teams.map((team) => `${team.label} ${team.size}명`).join(" / ");
|
||||||
|
|
||||||
|
return `${teams.length}팀 | 총 ${totalFighters}명 출전 | ${labels}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSpawnPoints(totalCount, requestedSpawnPlacement, startingZones) {
|
||||||
|
if (requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES) {
|
||||||
|
return createStartingZoneSpawnPoints(startingZones);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createRandomSpawnPoints(totalCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRandomSpawnPoints(count) {
|
function createRandomSpawnPoints(count) {
|
||||||
return createSpawnPointsFromSlots(createSpawnSlots(), count);
|
return createSpawnPointsFromSlots(createSpawnSlots(), count);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStartingZoneSpawnPoints(teamCount, teamSize) {
|
function createStartingZoneSpawnPoints(startingZones) {
|
||||||
const fallbackSlots = createSpawnSlots();
|
const fallbackSlots = createSpawnSlots();
|
||||||
const layout = shuffle(createStartingZoneLayout(teamCount));
|
|
||||||
|
|
||||||
return layout.flatMap((zone) => {
|
return startingZones.flatMap((zone) => {
|
||||||
const zoneSlots = createSpawnSlots(zone);
|
const zoneSlots = createSpawnSlots(zone);
|
||||||
return createSpawnPointsFromSlots(zoneSlots.length > 0 ? zoneSlots : fallbackSlots, teamSize);
|
return createSpawnPointsFromSlots(
|
||||||
|
zoneSlots.length > 0 ? zoneSlots : fallbackSlots,
|
||||||
|
zone.spawnCount,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStartingZones(teams) {
|
||||||
|
const totalZonesNeeded = teams.reduce((sum, team) => sum + team.startingZoneCount, 0);
|
||||||
|
const layout = shuffle(createStartingZoneLayout(totalZonesNeeded));
|
||||||
|
|
||||||
|
let layoutIndex = 0;
|
||||||
|
return teams.flatMap((team) => {
|
||||||
|
let remainingFighters = team.size;
|
||||||
|
const teamZones = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < team.startingZoneCount; i++) {
|
||||||
|
const spawnCount = Math.min(SPAWN.FIGHTERS_PER_STARTING_ZONE, remainingFighters);
|
||||||
|
teamZones.push({
|
||||||
|
...layout[layoutIndex++],
|
||||||
|
color: team.color,
|
||||||
|
spawnCount,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
remainingFighters -= spawnCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return teamZones;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStartingZoneLayout(teamCount) {
|
function createStartingZoneLayout(teamCount) {
|
||||||
const columnCount = Math.max(1, Math.ceil(Math.sqrt(teamCount)));
|
const zones = [];
|
||||||
const rowCount = Math.max(1, Math.ceil(teamCount / columnCount));
|
let candidates = shuffle(createStartingZoneCandidates());
|
||||||
const availableRows = GRID_SIZE - 2;
|
|
||||||
|
|
||||||
return Array.from({ length: teamCount }, (_, index) => {
|
while (zones.length < teamCount) {
|
||||||
const column = index % columnCount;
|
if (candidates.length === 0) {
|
||||||
const row = Math.floor(index / columnCount);
|
candidates = shuffle(createStartingZoneCandidates());
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
const separateCandidateIndex = candidates.findIndex((candidate) =>
|
||||||
columnEnd: partitionEnd(GRID_SIZE, columnCount, column),
|
zones.every((zone) => !startingZonesOverlap(zone, candidate)),
|
||||||
columnStart: partitionStart(GRID_SIZE, columnCount, column),
|
);
|
||||||
rowEnd: 1 + partitionEnd(availableRows, rowCount, row),
|
const selectedIndex = separateCandidateIndex >= 0 ? separateCandidateIndex : 0;
|
||||||
rowStart: 1 + partitionStart(availableRows, rowCount, row),
|
|
||||||
};
|
zones.push(...candidates.splice(selectedIndex, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return zones;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStartingZoneCandidates() {
|
||||||
|
const zones = [];
|
||||||
|
|
||||||
|
for (
|
||||||
|
let anchorRow = 1 + SPAWN.STARTING_ZONE_RADIUS;
|
||||||
|
anchorRow < ARENA.GRID_SIZE - 1 - SPAWN.STARTING_ZONE_RADIUS;
|
||||||
|
anchorRow += 1
|
||||||
|
) {
|
||||||
|
for (
|
||||||
|
let anchorColumn = SPAWN.STARTING_ZONE_RADIUS;
|
||||||
|
anchorColumn < ARENA.GRID_SIZE - SPAWN.STARTING_ZONE_RADIUS;
|
||||||
|
anchorColumn += 1
|
||||||
|
) {
|
||||||
|
zones.push({
|
||||||
|
anchorColumn,
|
||||||
|
anchorRow,
|
||||||
|
columnEnd: anchorColumn + SPAWN.STARTING_ZONE_RADIUS + 1,
|
||||||
|
columnStart: anchorColumn - SPAWN.STARTING_ZONE_RADIUS,
|
||||||
|
rowEnd: anchorRow + SPAWN.STARTING_ZONE_RADIUS + 1,
|
||||||
|
rowStart: anchorRow - SPAWN.STARTING_ZONE_RADIUS,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return zones;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSpawnSlots({
|
function createSpawnSlots({
|
||||||
columnEnd = GRID_SIZE,
|
columnEnd = ARENA.GRID_SIZE,
|
||||||
columnStart = 0,
|
columnStart = 0,
|
||||||
rowEnd = GRID_SIZE - 1,
|
rowEnd = ARENA.GRID_SIZE - 1,
|
||||||
rowStart = 1,
|
rowStart = 1,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const spawnSlots = [];
|
const spawnSlots = [];
|
||||||
@@ -114,8 +384,8 @@ function createSpawnSlots({
|
|||||||
for (let row = rowStart; row < rowEnd; row += 1) {
|
for (let row = rowStart; row < rowEnd; row += 1) {
|
||||||
for (let column = columnStart; column < columnEnd; column += 1) {
|
for (let column = columnStart; column < columnEnd; column += 1) {
|
||||||
spawnSlots.push({
|
spawnSlots.push({
|
||||||
x: column * TILE_SIZE + TILE_SIZE / 2,
|
x: column * ARENA.TILE_SIZE + ARENA.TILE_SIZE / 2,
|
||||||
y: row * TILE_SIZE + TILE_SIZE / 2,
|
y: row * ARENA.TILE_SIZE + ARENA.TILE_SIZE / 2,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,8 +404,8 @@ function createSpawnPointsFromSlots(spawnSlots, count) {
|
|||||||
|
|
||||||
points.push({
|
points.push({
|
||||||
faceLeft: Math.random() >= 0.5,
|
faceLeft: Math.random() >= 0.5,
|
||||||
x: clampInsideArena(slot.x + spawnJitter(), TILE_SIZE / 2),
|
x: clampInsideArena(slot.x + spawnJitter(), ARENA.TILE_SIZE / 2),
|
||||||
y: clampInsideArena(slot.y + spawnJitter(), TILE_SIZE),
|
y: clampInsideArena(slot.y + spawnJitter(), ARENA.TILE_SIZE),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -143,34 +413,21 @@ function createSpawnPointsFromSlots(spawnSlots, count) {
|
|||||||
return points;
|
return points;
|
||||||
}
|
}
|
||||||
|
|
||||||
function partitionStart(size, partCount, partIndex) {
|
function startingZonesOverlap(left, right) {
|
||||||
return Math.floor((size * partIndex) / partCount);
|
return (
|
||||||
}
|
left.columnStart < right.columnEnd &&
|
||||||
|
left.columnEnd > right.columnStart &&
|
||||||
function partitionEnd(size, partCount, partIndex) {
|
left.rowStart < right.rowEnd &&
|
||||||
return partitionStart(size, partCount, partIndex + 1);
|
left.rowEnd > right.rowStart
|
||||||
}
|
|
||||||
|
|
||||||
function resolveTeamSize(playerCount, requestedTeamSize) {
|
|
||||||
const teamSize = clamp(
|
|
||||||
Math.round(Number(requestedTeamSize) || DEFAULT_TEAM_SIZE),
|
|
||||||
1,
|
|
||||||
MAX_TEAM_SIZE,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (playerCount <= teamSize) {
|
|
||||||
return Math.max(1, Math.ceil(playerCount / 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
return teamSize;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnJitter() {
|
function spawnJitter() {
|
||||||
return (Math.random() - 0.5) * TILE_SIZE * 0.36;
|
return (Math.random() - 0.5) * ARENA.TILE_SIZE * 0.36;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampInsideArena(value, margin) {
|
function clampInsideArena(value, margin) {
|
||||||
return clamp(value, margin, ARENA_SIZE - margin);
|
return clamp(value, margin, ARENA.SIZE - margin);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(value, minimum, maximum) {
|
function clamp(value, minimum, maximum) {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import Phaser from "phaser";
|
import Phaser from "phaser";
|
||||||
import { ArenaScene } from "./game/arena/ArenaScene.js";
|
import { ArenaScene } from "./game/arena/ArenaScene.js";
|
||||||
import {
|
import {
|
||||||
ARENA_SIZE,
|
RENDER,
|
||||||
PRESENTATION_TEAM_COUNT,
|
SPAWN,
|
||||||
PRESENTATION_TEAM_SIZE,
|
|
||||||
} from "./constants.js";
|
} from "./constants.js";
|
||||||
import { createMatchForm } from "./ui/matchForm.js";
|
import { createMatchForm } from "./ui/matchForm.js";
|
||||||
import { createAboutDialog } from "./ui/aboutDialog.js";
|
import { createAboutDialog } from "./ui/aboutDialog.js";
|
||||||
@@ -57,6 +56,10 @@ function startConfiguredMatch(matchConfig) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!arenaScene.startMatch(matchConfig)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
appNode?.classList.remove("match-ended");
|
appNode?.classList.remove("match-ended");
|
||||||
appNode?.classList.add("match-live");
|
appNode?.classList.add("match-live");
|
||||||
|
|
||||||
@@ -66,14 +69,15 @@ function startConfiguredMatch(matchConfig) {
|
|||||||
openOptionsDrawer({ focus: false });
|
openOptionsDrawer({ focus: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
arenaScene.startMatch(matchConfig);
|
|
||||||
syncPauseButton();
|
syncPauseButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPresentationMatchConfig() {
|
function getPresentationMatchConfig() {
|
||||||
return {
|
return {
|
||||||
names: Array.from({ length: PRESENTATION_TEAM_COUNT }, (_, index) => `Player ${index + 1}`),
|
names: Array.from(
|
||||||
teamSize: PRESENTATION_TEAM_SIZE,
|
{ length: SPAWN.PRESENTATION_TEAM_COUNT },
|
||||||
|
(_, index) => `Player ${index + 1}*${SPAWN.PRESENTATION_TEAM_SIZE}`,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,16 +174,22 @@ window.addEventListener("keydown", (event) => {
|
|||||||
const arenaScene = new ArenaScene({
|
const arenaScene = new ArenaScene({
|
||||||
getInitialMatchConfig: getPresentationMatchConfig,
|
getInitialMatchConfig: getPresentationMatchConfig,
|
||||||
onMatchEnd: handleMatchEnd,
|
onMatchEnd: handleMatchEnd,
|
||||||
|
setPlayerNamesWarning: matchForm.setPlayerNamesWarning,
|
||||||
setStatus: matchForm.setStatus,
|
setStatus: matchForm.setStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
const game = new Phaser.Game({
|
const game = new Phaser.Game({
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
parent: "game",
|
parent: "game",
|
||||||
width: ARENA_SIZE,
|
width: RENDER.VIEWPORT_SIZE,
|
||||||
height: ARENA_SIZE,
|
height: RENDER.VIEWPORT_SIZE,
|
||||||
pixelArt: true,
|
pixelArt: true,
|
||||||
backgroundColor: "#282819",
|
backgroundColor: "#282819",
|
||||||
|
render: {
|
||||||
|
antialias: false,
|
||||||
|
pixelArt: true,
|
||||||
|
roundPixels: true,
|
||||||
|
},
|
||||||
physics: {
|
physics: {
|
||||||
default: "arcade",
|
default: "arcade",
|
||||||
arcade: {
|
arcade: {
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
@keyframes intro-rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(22px) scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes preview-attack {
|
||||||
|
to {
|
||||||
|
background-position-x: var(--sprite-end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes preview-breathe {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
margin-top: -8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes preview-strike {
|
||||||
|
0%,
|
||||||
|
58%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
64%,
|
||||||
|
76% {
|
||||||
|
opacity: 0.86;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes status-marquee {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes banner-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px) scale(0.78);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes victory-banner-sheen {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(0) skewX(-18deg);
|
||||||
|
}
|
||||||
|
18% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(560%) skewX(-18deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes victory-confetti-burst {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -50%) rotate(var(--confetti-tilt)) scale(0.3);
|
||||||
|
}
|
||||||
|
12% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
74% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform:
|
||||||
|
translate(calc(-50% + var(--confetti-x)), calc(-50% + var(--confetti-y)))
|
||||||
|
rotate(calc(var(--confetti-tilt) + var(--confetti-spin)))
|
||||||
|
scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes victory-glow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.58);
|
||||||
|
}
|
||||||
|
35% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes victory-rays-in {
|
||||||
|
from {
|
||||||
|
transform: scale(0.56);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes victory-rays-turn {
|
||||||
|
to {
|
||||||
|
rotate: 360deg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes victory-message-pulse {
|
||||||
|
from {
|
||||||
|
opacity: 0.72;
|
||||||
|
transform: scale(0.88);
|
||||||
|
}
|
||||||
|
58% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.victory-banner,
|
||||||
|
.victory-banner::before,
|
||||||
|
.victory-banner-message,
|
||||||
|
.victory-celebration::before,
|
||||||
|
.victory-confetti-piece,
|
||||||
|
.victory-rays {
|
||||||
|
animation-duration: 1ms;
|
||||||
|
animation-iteration-count: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family:
|
||||||
|
Inter, Pretendard, "Noto Sans KR", system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
|
"Segoe UI", sans-serif;
|
||||||
|
background: #080a07;
|
||||||
|
color: #fff5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100%;
|
||||||
|
background: #080a07;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: 3px solid rgb(238 185 73 / 0.46);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
--arena-gap: 18px;
|
||||||
|
--score-band-height: 134px;
|
||||||
|
--score-panel-left: 14px;
|
||||||
|
--score-panel-width: 260px;
|
||||||
|
--score-rail-width: calc(var(--score-panel-left) + var(--score-panel-width));
|
||||||
|
--drawer-width: min(430px, 100vw);
|
||||||
|
--drawer-live-width: min(340px, calc(100vw - 48px));
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgb(8 10 7 / 0.18), rgb(3 5 4 / 0.84)),
|
||||||
|
#080a07;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live {
|
||||||
|
--drawer-width: var(--drawer-live-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-shell {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #090b08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-shell::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 50%, rgb(255 211 122 / 0.06), transparent 42%),
|
||||||
|
linear-gradient(90deg, rgb(3 5 4 / 0.48), rgb(3 5 4 / 0.08) 45%, rgb(3 5 4 / 0.48)),
|
||||||
|
linear-gradient(180deg, rgb(3 5 4 / 0.08), rgb(3 5 4 / 0.5));
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
background 520ms ease,
|
||||||
|
opacity 520ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .arena-shell::before {
|
||||||
|
opacity: 0.24;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .arena-shell {
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#game {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
width: max(100vw, 100vh);
|
||||||
|
height: max(100vw, 100vh);
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0.68;
|
||||||
|
filter: saturate(1) contrast(1.08) brightness(1.08);
|
||||||
|
transform: scale(1.04);
|
||||||
|
transform-origin: center;
|
||||||
|
transition:
|
||||||
|
width 620ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||||
|
height 620ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||||
|
opacity 520ms ease,
|
||||||
|
filter 520ms ease,
|
||||||
|
transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live #game {
|
||||||
|
width: min(100vw, 100vh);
|
||||||
|
height: min(100vw, 100vh);
|
||||||
|
margin-left: 0;
|
||||||
|
opacity: 1;
|
||||||
|
filter: none;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#game canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
@@ -0,0 +1,668 @@
|
|||||||
|
.scoreboard {
|
||||||
|
position: fixed;
|
||||||
|
top: clamp(14px, 3vw, 28px);
|
||||||
|
left: var(--score-panel-left);
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: var(--score-panel-width);
|
||||||
|
max-height: calc(100vh - 420px);
|
||||||
|
min-height: 64px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgb(238 185 73 / 0.3) transparent;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid rgb(238 185 73 / 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgb(4 6 4 / 0.46);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(-18px);
|
||||||
|
transition:
|
||||||
|
opacity 420ms ease,
|
||||||
|
transform 420ms ease;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgb(238 185 73 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .scoreboard {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-side {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-side.right {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr 1px auto;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 72px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgb(255 244 209 / 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 7px;
|
||||||
|
background: rgb(8 10 7 / 0.58);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 900;
|
||||||
|
text-align: left;
|
||||||
|
text-shadow: 0 1px 1px rgb(0 0 0 / 0.78);
|
||||||
|
transition:
|
||||||
|
background-color 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 {
|
||||||
|
border-color: rgb(255 244 209 / 0.16);
|
||||||
|
background: rgb(12 14 10 / 0.68);
|
||||||
|
filter: brightness(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score.is-focused {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgb(255 244 209 / 0.72),
|
||||||
|
inset 0 0 0 999px rgb(255 244 209 / 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score:disabled {
|
||||||
|
cursor: default;
|
||||||
|
filter: grayscale(0.6) brightness(0.68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score:disabled:hover {
|
||||||
|
background: rgb(8 10 7 / 0.58);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score-name {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-left: 16px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score-rule {
|
||||||
|
width: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--team-color) 0 34%,
|
||||||
|
rgb(255 244 209 / 0.1) 34% 100%
|
||||||
|
);
|
||||||
|
opacity: 0.68;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score-count {
|
||||||
|
justify-self: end;
|
||||||
|
color: #ead9b3;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-notice {
|
||||||
|
position: fixed;
|
||||||
|
top: clamp(12px, 2vw, 20px);
|
||||||
|
left: 50%;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: auto;
|
||||||
|
max-width: min(640px, calc(100vw - 40px));
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid rgb(238 185 73 / 0.26);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
background: rgb(8 10 7 / 0.68);
|
||||||
|
color: #ffe8b4;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 1px 1px 2px #000;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, -10px);
|
||||||
|
transition:
|
||||||
|
opacity 260ms ease,
|
||||||
|
transform 260ms ease;
|
||||||
|
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 {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 961px) {
|
||||||
|
#app.match-live .battle-notice {
|
||||||
|
right: auto;
|
||||||
|
left: 50%;
|
||||||
|
width: min(420px, 72vmin, calc(100vw - var(--drawer-width) - var(--score-rail-width) - 56px));
|
||||||
|
transform: translate(-50%, -10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .battle-notice.is-visible {
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live.drawer-collapsed .battle-notice {
|
||||||
|
right: auto;
|
||||||
|
width: min(420px, 72vmin, calc(100vw - 64px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log {
|
||||||
|
position: fixed;
|
||||||
|
bottom: clamp(14px, 3vw, 26px);
|
||||||
|
left: var(--score-panel-left);
|
||||||
|
z-index: 4;
|
||||||
|
width: min(370px, calc(100vw - 32px));
|
||||||
|
max-height: min(34vh, 292px);
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgb(238 185 73 / 0.3) transparent;
|
||||||
|
border: 1px solid rgb(238 185 73 / 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgb(4 6 4 / 0.58);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(16px);
|
||||||
|
transition:
|
||||||
|
opacity 260ms ease,
|
||||||
|
transform 260ms ease;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgb(238 185 73 / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .kill-log.has-entries {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 54px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 54px;
|
||||||
|
border: 1px solid rgb(255 244 209 / 0.12);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
background: rgb(8 10 7 / 0.74);
|
||||||
|
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.06);
|
||||||
|
animation: kill-log-entry 180ms ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-fighter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-fighter.killer {
|
||||||
|
border-left: 3px solid var(--killer-color);
|
||||||
|
padding-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-fighter.victim {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
border-right: 3px solid var(--victim-color);
|
||||||
|
padding-right: 6px;
|
||||||
|
justify-content: end;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-avatar {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 1px solid rgb(255 244 209 / 0.16);
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: rgb(255 246 216 / 0.08);
|
||||||
|
background-position: -24px -16px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: auto 86px;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
box-shadow: inset 0 -10px 18px rgb(0 0 0 / 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-fighter.victim .kill-log-avatar::before,
|
||||||
|
.kill-log-fighter.victim .kill-log-avatar::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 7px;
|
||||||
|
right: 1px;
|
||||||
|
width: 14px;
|
||||||
|
height: 2px;
|
||||||
|
border: 1px solid rgb(255 216 212 / 0.22);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f24a42;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgb(48 4 3 / 0.7),
|
||||||
|
0 0 5px rgb(227 54 46 / 0.6);
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-fighter.victim .kill-log-avatar::before {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-fighter.victim .kill-log-avatar::after {
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-team,
|
||||||
|
.kill-log-member {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-team {
|
||||||
|
min-width: 0;
|
||||||
|
color: #fff7df;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 900;
|
||||||
|
text-shadow: 1px 1px 2px #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-member {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #ead8ad;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-action {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-action-text {
|
||||||
|
color: #ffdc93;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-weapon {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
place-self: center;
|
||||||
|
border: 1px solid rgb(238 185 73 / 0.28);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(255 246 216 / 0.08);
|
||||||
|
box-shadow: 0 0 16px rgb(227 89 59 / 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-weapon::before,
|
||||||
|
.kill-log-weapon::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 18px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, #ffe8b4 0 70%, #b93c2f 70% 100%);
|
||||||
|
box-shadow: 0 0 8px rgb(255 226 166 / 0.3);
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-weapon::before {
|
||||||
|
transform: translate(-50%, -50%) rotate(42deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log-weapon::after {
|
||||||
|
transform: translate(-50%, -50%) rotate(-42deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.victory-celebration {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9;
|
||||||
|
display: grid;
|
||||||
|
overflow: hidden;
|
||||||
|
place-items: center;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(4 6 4 / 0.2);
|
||||||
|
isolation: isolate;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: scale(1);
|
||||||
|
transition:
|
||||||
|
opacity 220ms ease,
|
||||||
|
transform 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.victory-celebration.is-leaving {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.victory-celebration::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
width: min(122vmin, 1240px);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle, rgb(255 233 166 / 0.18) 0 18%, rgb(227 178 79 / 0.12) 31%, transparent 66%);
|
||||||
|
animation: victory-glow 1.8s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.victory-celebration.is-draw::before {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle, rgb(255 247 223 / 0.16) 0 18%, rgb(227 178 79 / 0.1) 31%, transparent 62%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.victory-rays {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
width: min(112vmin, 1120px);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: repeating-conic-gradient(
|
||||||
|
from -4deg,
|
||||||
|
rgb(255 233 166 / 0.18) 0 8deg,
|
||||||
|
transparent 8deg 18deg
|
||||||
|
);
|
||||||
|
opacity: 0.54;
|
||||||
|
mask-image: radial-gradient(circle, #000 0 18%, transparent 66%);
|
||||||
|
animation: victory-rays-in 1.1s ease-out both, victory-rays-turn 11s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.victory-celebration.is-draw .victory-rays {
|
||||||
|
opacity: 0.22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.victory-confetti {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.victory-confetti-piece {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
display: block;
|
||||||
|
width: clamp(6px, 0.8vw, 11px);
|
||||||
|
height: clamp(10px, 1.2vw, 18px);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--confetti-color);
|
||||||
|
box-shadow: 0 0 12px rgb(255 230 166 / 0.22);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -50%) rotate(var(--confetti-tilt)) scale(0.3);
|
||||||
|
animation: victory-confetti-burst var(--confetti-duration) cubic-bezier(0.15, 0.84, 0.35, 1) var(--confetti-delay) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.victory-confetti-piece:nth-child(3n) {
|
||||||
|
width: clamp(10px, 1vw, 15px);
|
||||||
|
height: clamp(6px, 0.72vw, 10px);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.victory-banner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
width: min(calc(100vw - 36px), 760px);
|
||||||
|
min-height: clamp(108px, 18vw, 170px);
|
||||||
|
overflow: hidden;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px solid #f1c45d;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: clamp(1.25rem, 3.8vw, 2rem) clamp(1.3rem, 5.4vw, 3.4rem);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgb(18 21 13 / 0.98), rgb(3 5 4 / 0.92)),
|
||||||
|
rgb(4 6 4 / 0.9);
|
||||||
|
color: #fff7df;
|
||||||
|
font-size: clamp(1.65rem, 5vw, 3rem);
|
||||||
|
font-weight: 950;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1.12;
|
||||||
|
text-align: center;
|
||||||
|
text-wrap: balance;
|
||||||
|
text-shadow:
|
||||||
|
0 2px 0 rgb(55 36 8 / 0.56),
|
||||||
|
0 0 24px rgb(255 226 153 / 0.28);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgb(255 237 187 / 0.2) inset,
|
||||||
|
0 0 42px rgb(227 178 79 / 0.44),
|
||||||
|
0 24px 90px rgb(0 0 0 / 0.58);
|
||||||
|
animation: banner-in 0.64s cubic-bezier(0.16, 0.9, 0.25, 1.2);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.victory-banner::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -40% auto -40% -36%;
|
||||||
|
width: 28%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgb(255 248 223 / 0.6), transparent);
|
||||||
|
transform: skewX(-18deg);
|
||||||
|
animation: victory-banner-sheen 1s 0.28s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.victory-banner::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 10px;
|
||||||
|
border: 1px solid rgb(255 225 151 / 0.24);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.victory-banner-message {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
animation: victory-message-pulse 720ms 80ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.victory-celebration.is-draw .victory-banner {
|
||||||
|
border-color: #d8c28d;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgb(255 237 187 / 0.14) inset,
|
||||||
|
0 0 28px rgb(227 178 79 / 0.24),
|
||||||
|
0 24px 90px rgb(0 0 0 / 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-paused .arena-shell::after {
|
||||||
|
content: "일시정지";
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 6;
|
||||||
|
border: 1px solid rgb(238 185 73 / 0.34);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 26px;
|
||||||
|
background: rgb(5 7 5 / 0.76);
|
||||||
|
color: #ffe8b4;
|
||||||
|
font-size: clamp(1.3rem, 4vw, 2rem);
|
||||||
|
font-weight: 950;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
box-shadow: 0 18px 60px rgb(0 0 0 / 0.46);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-status {
|
||||||
|
position: fixed;
|
||||||
|
bottom: clamp(14px, 3vw, 26px);
|
||||||
|
left: 50%;
|
||||||
|
z-index: 4;
|
||||||
|
width: min(980px, calc(100vw - 32px));
|
||||||
|
min-height: 48px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgb(238 185 73 / 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 13px 0;
|
||||||
|
background: rgb(8 10 7 / 0.74);
|
||||||
|
color: #ffe2a6;
|
||||||
|
font-weight: 900;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translate(-50%, calc(100% + 28px));
|
||||||
|
transition:
|
||||||
|
opacity 420ms ease,
|
||||||
|
transform 420ms ease;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.status-active:not(.match-live) .match-status {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .match-status {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 961px) {
|
||||||
|
#app.match-live .match-status {
|
||||||
|
left: calc((100vw - var(--drawer-width)) / 2);
|
||||||
|
width: min(760px, calc(100vw - var(--drawer-width) - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live.drawer-collapsed .match-status {
|
||||||
|
left: 50%;
|
||||||
|
width: min(980px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-track {
|
||||||
|
display: flex;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 200%;
|
||||||
|
gap: 64px;
|
||||||
|
animation: status-marquee 22s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-track span {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: calc(50vw - 32px);
|
||||||
|
padding-left: 28px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
.battle-preview {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0.84;
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
opacity 420ms ease,
|
||||||
|
transform 700ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .battle-preview {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-fighter {
|
||||||
|
position: absolute;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: auto 100px;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
transform-origin: center;
|
||||||
|
filter:
|
||||||
|
drop-shadow(0 18px 22px rgb(0 0 0 / 0.68))
|
||||||
|
saturate(1.14)
|
||||||
|
brightness(1.12);
|
||||||
|
animation:
|
||||||
|
preview-attack var(--sprite-speed) steps(var(--sprite-steps)) infinite,
|
||||||
|
preview-breathe 1800ms ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-knight {
|
||||||
|
--sprite-end: -600px;
|
||||||
|
--sprite-scale: 5.2;
|
||||||
|
--sprite-speed: 840ms;
|
||||||
|
--sprite-steps: 6;
|
||||||
|
left: 10vw;
|
||||||
|
top: 48vh;
|
||||||
|
background-image: url("/assets/characters/knight/Knight-Attack01.png");
|
||||||
|
transform: scale(var(--sprite-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-orc {
|
||||||
|
--sprite-end: -500px;
|
||||||
|
--sprite-scale: 5.35;
|
||||||
|
--sprite-speed: 760ms;
|
||||||
|
--sprite-steps: 5;
|
||||||
|
right: 9vw;
|
||||||
|
top: 46vh;
|
||||||
|
background-image: url("/assets/characters/orc/Orc-Attack01.png");
|
||||||
|
transform: scaleX(-1) scale(var(--sprite-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-wizard {
|
||||||
|
--sprite-end: -500px;
|
||||||
|
--sprite-scale: 4.35;
|
||||||
|
--sprite-speed: 980ms;
|
||||||
|
--sprite-steps: 5;
|
||||||
|
left: 56vw;
|
||||||
|
top: 24vh;
|
||||||
|
background-image: url("/assets/characters/wizard/Wizard-Attack01.png");
|
||||||
|
opacity: 0.58;
|
||||||
|
transform: scaleX(-1) scale(var(--sprite-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-strike {
|
||||||
|
position: absolute;
|
||||||
|
width: 160px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgb(255 229 156 / 0.86), transparent);
|
||||||
|
box-shadow: 0 0 24px rgb(227 89 59 / 0.5);
|
||||||
|
opacity: 0;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: preview-strike 980ms ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-strike-a {
|
||||||
|
left: 38vw;
|
||||||
|
top: 54vh;
|
||||||
|
transform: rotate(-18deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-strike-b {
|
||||||
|
right: 31vw;
|
||||||
|
top: 42vh;
|
||||||
|
transform: rotate(22deg);
|
||||||
|
animation-delay: 260ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-stage {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: clamp(24px, 5vw, 56px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
opacity 420ms ease,
|
||||||
|
transform 620ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .intro-stage {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .intro-content {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-content {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 22px;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: auto;
|
||||||
|
animation: intro-rise 760ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||||
|
transition:
|
||||||
|
transform 560ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||||
|
opacity 360ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.options-open:not(.match-live) .intro-content {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-logo {
|
||||||
|
margin: 0;
|
||||||
|
color: #fff4d1;
|
||||||
|
font-size: clamp(4rem, 16vw, 11rem);
|
||||||
|
font-weight: 950;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 0.9;
|
||||||
|
text-shadow:
|
||||||
|
0 2px 0 #ad4d37,
|
||||||
|
0 14px 42px rgb(0 0 0 / 0.72),
|
||||||
|
0 0 40px rgb(230 173 71 / 0.28);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-logo .small-text {
|
||||||
|
font-size: 0.7em;
|
||||||
|
margin-top: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-logo span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-meta {
|
||||||
|
position: fixed;
|
||||||
|
right: clamp(10px, 2vw, 18px);
|
||||||
|
bottom: clamp(10px, 2vw, 18px);
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visitor-count,
|
||||||
|
.about-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 28px;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid rgb(238 185 73 / 0.22);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
background: rgb(8 10 7 / 0.68);
|
||||||
|
color: #e7c879;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
text-decoration: none;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
pointer-events: auto;
|
||||||
|
transition:
|
||||||
|
background 180ms ease,
|
||||||
|
border-color 180ms ease,
|
||||||
|
transform 180ms ease,
|
||||||
|
opacity 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visitor-count {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .visitor-count {
|
||||||
|
opacity: 0.86;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-button {
|
||||||
|
min-width: 72px;
|
||||||
|
color: #ffe8b4;
|
||||||
|
font-weight: 900;
|
||||||
|
box-shadow: 0 4px 12px rgb(0 0 0 / 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-button:hover {
|
||||||
|
border-color: rgb(238 185 73 / 0.42);
|
||||||
|
background: rgb(255 246 216 / 0.14);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-button {
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 0 30px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.options-open:not(.match-live) .start-button {
|
||||||
|
pointer-events: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
@media (max-width: 960px) {
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
--arena-gap: 0px;
|
||||||
|
--mobile-game-size: min(100vw, calc(100svh - var(--score-band-height)));
|
||||||
|
--mobile-kill-log-top: calc(var(--score-band-height) + var(--mobile-game-size) + 10px);
|
||||||
|
--mobile-options-button-width: 54px;
|
||||||
|
--mobile-options-gap: 8px;
|
||||||
|
--mobile-team-card-width: clamp(108px, calc((100vw - 38px) / 3), 124px);
|
||||||
|
--mobile-visitor-space: calc(104px + env(safe-area-inset-bottom));
|
||||||
|
--score-band-height: 132px;
|
||||||
|
--score-panel-left: 10px;
|
||||||
|
--score-panel-width: calc(100vw - 20px);
|
||||||
|
--score-rail-width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .arena-shell {
|
||||||
|
place-items: start center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live #game {
|
||||||
|
width: var(--mobile-game-size);
|
||||||
|
height: var(--mobile-game-size);
|
||||||
|
margin-top: var(--score-band-height);
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-stage {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-logo {
|
||||||
|
font-size: clamp(3.8rem, 22vw, 7rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fighter-entry {
|
||||||
|
width: 100vw;
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .fighter-entry {
|
||||||
|
top: calc(10px + env(safe-area-inset-top));
|
||||||
|
right: 10px;
|
||||||
|
left: 10px;
|
||||||
|
width: auto;
|
||||||
|
max-height: calc(100svh - 20px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live.drawer-collapsed .fighter-entry {
|
||||||
|
top: calc(22px + env(safe-area-inset-top));
|
||||||
|
right: 10px;
|
||||||
|
left: auto;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .fighter-entry h2 {
|
||||||
|
font-size: clamp(1.45rem, 7vw, 1.8rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .fighter-entry textarea {
|
||||||
|
height: 112px;
|
||||||
|
min-height: 112px;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .fighter-entry fieldset {
|
||||||
|
gap: 7px;
|
||||||
|
padding: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .fighter-entry form {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .entry-copy {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .eyebrow {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live label,
|
||||||
|
#app.match-live .spawn-placement-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live input:not([type="range"]):not([type="radio"]),
|
||||||
|
#app.match-live textarea {
|
||||||
|
min-height: 40px;
|
||||||
|
padding-inline: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live textarea {
|
||||||
|
padding-block: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .spawn-placement-option span {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 6px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .match-actions {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .match-actions button {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .drawer-toggle {
|
||||||
|
min-width: 116px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live.drawer-collapsed .drawer-toggle {
|
||||||
|
width: var(--mobile-options-button-width);
|
||||||
|
min-width: var(--mobile-options-button-width);
|
||||||
|
padding-inline: 6px;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live.drawer-collapsed .drawer-toggle::before {
|
||||||
|
content: "옵션";
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.battle-preview {
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-knight {
|
||||||
|
left: -8vw;
|
||||||
|
top: 52vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-orc {
|
||||||
|
right: -9vw;
|
||||||
|
top: 49vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-wizard {
|
||||||
|
left: 48vw;
|
||||||
|
top: 21vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard {
|
||||||
|
align-items: flex-start;
|
||||||
|
top: 10px;
|
||||||
|
left: var(--score-panel-left);
|
||||||
|
width: var(--score-panel-width);
|
||||||
|
max-height: calc(var(--score-band-height) - 12px);
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
padding: 9px min(148px, 38vw) 9px 9px;
|
||||||
|
scrollbar-color: rgb(238 185 73 / 0.38) transparent;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
touch-action: pan-x;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live.drawer-collapsed .scoreboard {
|
||||||
|
width: calc(
|
||||||
|
100vw - 20px - var(--mobile-options-button-width) - var(--mobile-options-gap)
|
||||||
|
);
|
||||||
|
padding-right: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-side {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: none;
|
||||||
|
grid-auto-columns: var(--mobile-team-card-width);
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-template-rows: repeat(2, 48px);
|
||||||
|
gap: 5px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(238 185 73 / 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score {
|
||||||
|
width: auto;
|
||||||
|
min-height: 48px;
|
||||||
|
height: 48px;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 5px 6px;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
grid-template-rows: 1fr 1px auto;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score::before {
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
width: 8px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score-name {
|
||||||
|
padding-left: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score-count {
|
||||||
|
font-size: 0.64rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-score.is-focused {
|
||||||
|
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 {
|
||||||
|
top: calc(var(--score-band-height) + 8px);
|
||||||
|
right: 24px;
|
||||||
|
left: 24px;
|
||||||
|
width: auto;
|
||||||
|
padding-inline: 12px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .battle-notice.is-visible {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kill-log {
|
||||||
|
top: var(--mobile-kill-log-top);
|
||||||
|
bottom: auto;
|
||||||
|
left: 10px;
|
||||||
|
width: calc(100vw - 20px);
|
||||||
|
max-height: calc(100svh - var(--mobile-kill-log-top) - var(--mobile-visitor-space));
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .victory-celebration {
|
||||||
|
padding:
|
||||||
|
var(--score-band-height)
|
||||||
|
14px
|
||||||
|
min(30svh, 230px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.victory-banner {
|
||||||
|
width: min(calc(100vw - 48px), 520px);
|
||||||
|
min-height: 92px;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
font-size: clamp(1.35rem, 7vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-status {
|
||||||
|
bottom: 10px;
|
||||||
|
width: calc(100vw - 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arena-meta {
|
||||||
|
right: 10px;
|
||||||
|
bottom: calc(10px + env(safe-area-inset-bottom));
|
||||||
|
z-index: 10;
|
||||||
|
gap: 8px;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visitor-count {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-button {
|
||||||
|
min-width: 68px;
|
||||||
|
min-height: 26px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-backdrop {
|
||||||
|
align-items: end;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-dialog {
|
||||||
|
width: 100%;
|
||||||
|
max-height: calc(100svh - 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-header {
|
||||||
|
padding: 18px 18px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tabs {
|
||||||
|
padding: 0 18px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-panel {
|
||||||
|
padding: 16px 18px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-field-row {
|
||||||
|
grid-template-columns: 78px minmax(0, 1fr);
|
||||||
|
min-height: 48px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
.start-button,
|
||||||
|
form button[type="submit"],
|
||||||
|
.pause-button,
|
||||||
|
.restart-button {
|
||||||
|
min-height: 52px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(180deg, #e56443, #b93c2f);
|
||||||
|
color: #fff7df;
|
||||||
|
font-weight: 900;
|
||||||
|
box-shadow:
|
||||||
|
0 18px 44px rgb(0 0 0 / 0.36),
|
||||||
|
inset 0 1px 0 rgb(255 255 255 / 0.2);
|
||||||
|
transition:
|
||||||
|
background 180ms ease,
|
||||||
|
transform 180ms ease,
|
||||||
|
box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-button:hover,
|
||||||
|
form button[type="submit"]:hover,
|
||||||
|
.pause-button:hover,
|
||||||
|
.restart-button:hover {
|
||||||
|
background: linear-gradient(180deg, #f0754f, #c84636);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow:
|
||||||
|
0 22px 52px rgb(0 0 0 / 0.42),
|
||||||
|
inset 0 1px 0 rgb(255 255 255 / 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pause-button,
|
||||||
|
.restart-button {
|
||||||
|
display: none;
|
||||||
|
border: 1px solid rgb(238 185 73 / 0.3);
|
||||||
|
background: rgb(255 246 216 / 0.08);
|
||||||
|
color: #ffe8b4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pause-button:hover,
|
||||||
|
.restart-button:hover {
|
||||||
|
background: rgb(255 246 216 / 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .pause-button,
|
||||||
|
#app.match-live .restart-button {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .match-actions {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .match-actions button[type="submit"] {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-paused .pause-button {
|
||||||
|
background: linear-gradient(180deg, #e3b24f, #9a6c24);
|
||||||
|
color: #120f08;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-ended .pause-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-scrim {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 6;
|
||||||
|
background: rgb(4 5 4 / 0.42);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 320ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.options-open .drawer-scrim {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .drawer-scrim {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-toggle {
|
||||||
|
display: none;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid rgb(238 185 73 / 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: rgb(12 15 11 / 0.84);
|
||||||
|
color: #ffe8b4;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 900;
|
||||||
|
box-shadow: 0 16px 38px rgb(0 0 0 / 0.36);
|
||||||
|
transition:
|
||||||
|
background 180ms ease,
|
||||||
|
transform 180ms ease;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-toggle:hover {
|
||||||
|
background: rgb(255 246 216 / 0.14);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .drawer-toggle {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fighter-entry {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 7;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 24px;
|
||||||
|
width: var(--drawer-width);
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-left: 1px solid rgb(239 199 103 / 0.22);
|
||||||
|
padding: clamp(22px, 4vw, 34px);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgb(29 33 22 / 0.94), rgb(13 16 12 / 0.96)),
|
||||||
|
#11140f;
|
||||||
|
box-shadow: -28px 0 80px rgb(0 0 0 / 0.52);
|
||||||
|
transform: translateX(104%);
|
||||||
|
transition:
|
||||||
|
opacity 260ms ease,
|
||||||
|
transform 520ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.options-open .fighter-entry {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .fighter-entry {
|
||||||
|
top: 24px;
|
||||||
|
right: 24px;
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
gap: 16px;
|
||||||
|
border: 1px solid rgb(239 199 103 / 0.22);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live.drawer-collapsed .fighter-entry {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: visible;
|
||||||
|
border-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .drawer-close {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .fighter-entry h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .fighter-entry textarea {
|
||||||
|
min-height: 190px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live .fighter-entry fieldset {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live.drawer-collapsed .entry-copy,
|
||||||
|
#app.match-live.drawer-collapsed .fighter-entry form {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app.match-live.drawer-collapsed .drawer-header {
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-header-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
color: #e3b24f;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #fff3d2;
|
||||||
|
font-size: clamp(1.7rem, 4vw, 2.5rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-close {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid rgb(238 185 73 / 0.22);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgb(255 246 216 / 0.08);
|
||||||
|
color: #f8deb0;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-close:hover {
|
||||||
|
background: rgb(255 246 216 / 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: clamp(16px, 4vw, 34px);
|
||||||
|
background: rgb(3 5 4 / 0.66);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-backdrop[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-dialog {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
width: min(560px, calc(100vw - 32px));
|
||||||
|
max-height: min(760px, calc(100svh - 32px));
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgb(239 199 103 / 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgb(29 33 22 / 0.98), rgb(10 13 9 / 0.98)),
|
||||||
|
#11140f;
|
||||||
|
box-shadow:
|
||||||
|
0 24px 100px rgb(0 0 0 / 0.62),
|
||||||
|
inset 0 1px 0 rgb(255 255 255 / 0.06);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: clamp(20px, 4vw, 28px) clamp(20px, 4vw, 30px) 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-close {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid rgb(238 185 73 / 0.22);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgb(255 246 216 / 0.08);
|
||||||
|
color: #f8deb0;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-close:hover {
|
||||||
|
background: rgb(255 246 216 / 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 clamp(20px, 4vw, 30px) 14px;
|
||||||
|
border-bottom: 1px solid rgb(238 185 73 / 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tab {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 42px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgb(255 246 216 / 0.06);
|
||||||
|
color: #ead8ad;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tab[aria-selected="true"] {
|
||||||
|
border-color: rgb(238 185 73 / 0.36);
|
||||||
|
background: #323822;
|
||||||
|
color: #fff7df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-panel {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: clamp(18px, 4vw, 26px) clamp(20px, 4vw, 30px) clamp(22px, 5vw, 34px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 50px;
|
||||||
|
border-bottom: 1px solid rgb(238 185 73 / 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-field-row:first-child {
|
||||||
|
border-top: 1px solid rgb(238 185 73 / 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-field-row dt {
|
||||||
|
color: #e3b24f;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 950;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-field-row dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
color: #fff7df;
|
||||||
|
font-weight: 800;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-field-row a,
|
||||||
|
.about-markdown a {
|
||||||
|
color: #85dcc7;
|
||||||
|
text-decoration-color: rgb(133 220 199 / 0.42);
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-markdown {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
color: #ead8ad;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.56;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-markdown :is(h3, h4, h5, h6, p, ul, blockquote) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-markdown h3,
|
||||||
|
.about-markdown h4,
|
||||||
|
.about-markdown h5,
|
||||||
|
.about-markdown h6 {
|
||||||
|
color: #fff3d2;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-markdown blockquote {
|
||||||
|
border-left: 3px solid rgb(238 185 73 / 0.36);
|
||||||
|
padding: 4px 0 4px 16px;
|
||||||
|
color: #c4b693;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-markdown hr {
|
||||||
|
margin: 8px 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid rgb(238 185 73 / 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-markdown code {
|
||||||
|
border: 1px solid rgb(238 185 73 / 0.14);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
background: rgb(255 246 216 / 0.08);
|
||||||
|
color: #f1c761;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-markdown li {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-markdown li strong {
|
||||||
|
color: #fff3d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-markdown ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-empty {
|
||||||
|
color: #bfae83;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-actions {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid rgb(238 185 73 / 0.22);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
background: rgb(5 7 5 / 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
padding: 0 6px;
|
||||||
|
color: #e3b24f;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
color: #ead8ad;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:not([type="range"]):not([type="radio"]),
|
||||||
|
textarea {
|
||||||
|
min-height: 48px;
|
||||||
|
border: 1px solid rgb(238 185 73 / 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 14px;
|
||||||
|
background: #232719;
|
||||||
|
color: #fff7df;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 258px;
|
||||||
|
resize: vertical;
|
||||||
|
padding-block: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea[aria-invalid="true"] {
|
||||||
|
border-color: #d9a628;
|
||||||
|
box-shadow: 0 0 0 1px rgb(217 166 40 / 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-names-warning {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px 9px;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid rgb(219 168 45 / 0.72);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background: linear-gradient(135deg, rgb(88 67 17 / 0.7), rgb(43 37 19 / 0.92));
|
||||||
|
box-shadow: inset 3px 0 0 #dba82d;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-names-warning[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-names-warning__badge {
|
||||||
|
grid-row: span 2;
|
||||||
|
align-self: start;
|
||||||
|
color: #f4bd37;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-names-warning__title {
|
||||||
|
color: #ffd565;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-names-warning__detail {
|
||||||
|
grid-column: 2;
|
||||||
|
margin: 0;
|
||||||
|
color: #f1dfaa;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-names-warning__count {
|
||||||
|
color: #ffd04f;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-names-warning__limit {
|
||||||
|
color: #f7e0a0;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-names-warning__reason {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 5px 0 0;
|
||||||
|
border-top: 1px solid rgb(219 168 45 / 0.25);
|
||||||
|
padding-top: 7px;
|
||||||
|
color: #d7c489;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: #e3b24f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spawn-placement-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spawn-placement-label {
|
||||||
|
color: #ead8ad;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spawn-placement-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid rgb(238 185 73 / 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px;
|
||||||
|
background: #1d2116;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spawn-placement-option {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spawn-placement-option input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spawn-placement-option span {
|
||||||
|
display: grid;
|
||||||
|
min-height: 44px;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
color: #ead8ad;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.25;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spawn-placement-option input:checked + span {
|
||||||
|
border-color: rgb(238 185 73 / 0.36);
|
||||||
|
background: #323822;
|
||||||
|
color: #fff7df;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spawn-placement-option input:focus-visible + span {
|
||||||
|
outline: 2px solid #f1c761;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
@@ -9,41 +9,61 @@ export function updateScoreboard(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
containerLeft.innerHTML = "";
|
const currentTeamElements = [...containerLeft.children];
|
||||||
containerRight.innerHTML = "";
|
const teamsChanged =
|
||||||
|
currentTeamElements.length !== teams.length ||
|
||||||
|
teams.some((team, index) => currentTeamElements[index]?.dataset.teamId !== String(team.id));
|
||||||
|
|
||||||
teams.forEach((team) => {
|
if (teamsChanged) {
|
||||||
const aliveCount = fighters.filter((f) => f.team.id === team.id && !f.isDead).length;
|
containerLeft.replaceChildren(...teams.map((team) => createTeamElement(team.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containerRight.childElementCount > 0) {
|
||||||
|
containerRight.replaceChildren();
|
||||||
|
}
|
||||||
|
|
||||||
|
teams.forEach((team, index) => {
|
||||||
|
const teamEl = containerLeft.children[index];
|
||||||
|
const livingFighters = fighters.filter(
|
||||||
|
(fighter) => fighter.team.id === team.id && !fighter.isDead,
|
||||||
|
);
|
||||||
|
const eliteCount = livingFighters.filter((fighter) => fighter.isElite).length;
|
||||||
|
const normalCount = livingFighters.length - eliteCount;
|
||||||
|
|
||||||
|
teamEl.disabled = livingFighters.length === 0;
|
||||||
|
teamEl.setAttribute("aria-label", `${team.label} 생존 캐릭터 무작위 시점 고정`);
|
||||||
|
teamEl.style.setProperty("--team-color", team.color);
|
||||||
|
teamEl.style.removeProperty("background-color");
|
||||||
|
teamEl.style.removeProperty("border-left");
|
||||||
|
teamEl.classList.toggle("is-focused", selectedFighterTeamId === team.id);
|
||||||
|
|
||||||
|
const labelEl = teamEl.querySelector(".team-score-name");
|
||||||
|
labelEl.textContent = team.label;
|
||||||
|
|
||||||
|
const countEl = teamEl.querySelector(".team-score-count");
|
||||||
|
countEl.textContent = `E : ${eliteCount} | N : ${normalCount}`;
|
||||||
|
|
||||||
|
teamEl.onclick = () => {
|
||||||
|
onTeamClick(team.id);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTeamElement(teamId) {
|
||||||
const teamEl = document.createElement("button");
|
const teamEl = document.createElement("button");
|
||||||
teamEl.className = "team-score";
|
teamEl.className = "team-score";
|
||||||
teamEl.type = "button";
|
teamEl.type = "button";
|
||||||
teamEl.disabled = aliveCount === 0;
|
teamEl.dataset.teamId = String(teamId);
|
||||||
teamEl.setAttribute("aria-label", `${team.label} 생존 캐릭터 무작위 시점 고정`);
|
|
||||||
teamEl.style.setProperty("--team-color", team.color);
|
|
||||||
teamEl.style.backgroundColor = `${team.color}33`;
|
|
||||||
teamEl.style.borderLeft = `4px solid ${team.color}`;
|
|
||||||
|
|
||||||
if (selectedFighterTeamId === team.id) {
|
|
||||||
teamEl.classList.add("is-focused");
|
|
||||||
}
|
|
||||||
|
|
||||||
const labelEl = document.createElement("span");
|
const labelEl = document.createElement("span");
|
||||||
labelEl.className = "team-score-name";
|
labelEl.className = "team-score-name";
|
||||||
labelEl.textContent = team.label;
|
|
||||||
|
|
||||||
const ruleEl = document.createElement("span");
|
const ruleEl = document.createElement("span");
|
||||||
ruleEl.className = "team-score-rule";
|
ruleEl.className = "team-score-rule";
|
||||||
|
|
||||||
const countEl = document.createElement("span");
|
const countEl = document.createElement("span");
|
||||||
countEl.className = "team-score-count";
|
countEl.className = "team-score-count";
|
||||||
countEl.textContent = `${aliveCount}명`;
|
|
||||||
|
|
||||||
teamEl.addEventListener("click", () => {
|
|
||||||
onTeamClick(team.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
teamEl.append(labelEl, ruleEl, countEl);
|
teamEl.append(labelEl, ruleEl, countEl);
|
||||||
containerLeft.appendChild(teamEl);
|
return teamEl;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { UI } from "../constants.js";
|
||||||
|
|
||||||
const SPECIES_KEYS = ["human", "orc", "skeleton", "slime", "wolf", "bear"];
|
const SPECIES_KEYS = ["human", "orc", "skeleton", "slime", "wolf", "bear"];
|
||||||
const SPECIES_LABELS = {
|
const SPECIES_LABELS = {
|
||||||
bear: "곰",
|
bear: "곰",
|
||||||
@@ -22,9 +24,15 @@ const DEATH_NOTICE_TEMPLATES = [
|
|||||||
"{species}{particle} 전투 중 {count}명 쓰러졌습니다. 관중석은 침착한 척하는 중입니다.",
|
"{species}{particle} 전투 중 {count}명 쓰러졌습니다. 관중석은 침착한 척하는 중입니다.",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const BATTLE_NOTICE_DELAY_MS = 5000;
|
const SYSTEM_TIP_TEMPLATES = [
|
||||||
export const BATTLE_NOTICE_VISIBLE_MS = 2000;
|
"경보: 화염 메테오는 낙하 지점 5x5 영역에 강력한 폭발 피해를 입힙니다!",
|
||||||
export const BATTLE_NOTICE_INTERVAL_MS = 10000;
|
"주의: 냉기 메테오는 피해와 함께 2초간 동결 및 냉각을 유발합니다.",
|
||||||
|
"팁: 근접 치명타는 일반 대상에 2배 피해, 엘리트 대상에 최대 체력 비례 피해를 줍니다.",
|
||||||
|
"엘리트 전투: 처치 보너스는 비활성화되어 전투 중 체력 회복이나 성장 효과가 없습니다.",
|
||||||
|
];
|
||||||
|
|
||||||
|
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) => {
|
||||||
@@ -42,7 +50,8 @@ export function normalizeDeathCounts(value = {}) {
|
|||||||
|
|
||||||
export function addDeathCounts(baseCounts, matchCounts) {
|
export function addDeathCounts(baseCounts, matchCounts) {
|
||||||
return SPECIES_KEYS.reduce((counts, species) => {
|
return SPECIES_KEYS.reduce((counts, species) => {
|
||||||
counts[species] = (baseCounts?.[species] ?? 0) + (matchCounts?.[species] ?? 0);
|
counts[species] =
|
||||||
|
(baseCounts?.[species] ?? 0) + (matchCounts?.[species] ?? 0);
|
||||||
return counts;
|
return counts;
|
||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
@@ -52,15 +61,24 @@ export function normalizeSpecies(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createDeathNoticeMessage(deathsBySpecies, seed = 0) {
|
export function createDeathNoticeMessage(deathsBySpecies, seed = 0) {
|
||||||
const topSpecies = SPECIES_KEYS
|
// 3번에 한 번꼴로 시스템 팁 출력
|
||||||
.map((species) => ({ species, count: deathsBySpecies?.[species] ?? 0 }))
|
if (seed % 3 === 0) {
|
||||||
.sort((left, right) => right.count - left.count)[0];
|
return SYSTEM_TIP_TEMPLATES[
|
||||||
|
Math.floor(seed / 3) % SYSTEM_TIP_TEMPLATES.length
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const topSpecies = SPECIES_KEYS.map((species) => ({
|
||||||
|
species,
|
||||||
|
count: deathsBySpecies?.[species] ?? 0,
|
||||||
|
})).sort((left, right) => right.count - left.count)[0];
|
||||||
|
|
||||||
if (!topSpecies || topSpecies.count === 0) {
|
if (!topSpecies || topSpecies.count === 0) {
|
||||||
return "오늘 사망자 집계는 아직 0명입니다. 이 평화가 얼마나 버틸까요?";
|
return "오늘 사망자 집계는 아직 0명입니다. 이 평화가 얼마나 버틸까요?";
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = DEATH_NOTICE_TEMPLATES[
|
const template =
|
||||||
|
DEATH_NOTICE_TEMPLATES[
|
||||||
(topSpecies.count + seed) % DEATH_NOTICE_TEMPLATES.length
|
(topSpecies.count + seed) % DEATH_NOTICE_TEMPLATES.length
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -75,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) {
|
||||||
@@ -85,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,52 +1,53 @@
|
|||||||
import { DEFAULT_SPAWN_PLACEMENT, NICKNAME_LENGTH } from "../constants.js";
|
import { FIGHTER, SPAWN } from "../constants.js";
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
names: "arena.match.playerNames",
|
names: "arena.match.playerNames",
|
||||||
spawnPlacement: "arena.match.spawnPlacement",
|
spawnPlacement: "arena.match.spawnPlacement",
|
||||||
teamSize: "arena.match.teamSize",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createMatchForm() {
|
export function createMatchForm() {
|
||||||
const form = getElement("#fighter-form");
|
const form = getElement("#fighter-form");
|
||||||
const namesInput = getElement("#player-names");
|
const namesInput = getElement("#player-names");
|
||||||
|
const namesWarningNode = getElement("#player-names-warning");
|
||||||
|
const namesWarningTitleNode = getElement("[data-player-names-warning-title]");
|
||||||
|
const namesWarningCountNode = getElement("[data-player-names-warning-count]");
|
||||||
|
const namesWarningLimitNode = getElement("[data-player-names-warning-limit]");
|
||||||
|
const namesWarningReasonNode = getElement("[data-player-names-warning-reason]");
|
||||||
const appNode = document.querySelector("#app");
|
const appNode = document.querySelector("#app");
|
||||||
const statusNode = document.querySelector("#match-status");
|
const statusNode = document.querySelector("#match-status");
|
||||||
const statusTextNodes = document.querySelectorAll("[data-status-text]");
|
const statusTextNodes = document.querySelectorAll("[data-status-text]");
|
||||||
const spawnPlacementInputs = getElements('input[name="spawnPlacement"]');
|
const spawnPlacementInputs = getElements('input[name="spawnPlacement"]');
|
||||||
const teamSizeInput = getElement("#team-size");
|
const setPlayerNamesWarning = (warning = null) => {
|
||||||
const teamSizeNumberInput = getElement("#team-size-value");
|
const hasWarning = Boolean(warning);
|
||||||
|
|
||||||
|
if (!hasWarning) {
|
||||||
|
namesWarningNode.hidden = true;
|
||||||
|
namesInput.removeAttribute("aria-invalid");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
namesWarningTitleNode.textContent = warning.title;
|
||||||
|
namesWarningCountNode.textContent = warning.fighterCount.toLocaleString("ko-KR");
|
||||||
|
namesWarningLimitNode.textContent = warning.maxFighterCount.toLocaleString("ko-KR");
|
||||||
|
namesWarningReasonNode.textContent = warning.reason ?? "";
|
||||||
|
namesWarningReasonNode.hidden = !warning.reason;
|
||||||
|
namesWarningNode.hidden = false;
|
||||||
|
namesInput.setAttribute("aria-invalid", "true");
|
||||||
|
};
|
||||||
|
|
||||||
const readMatchConfig = () => ({
|
const readMatchConfig = () => ({
|
||||||
names: nicknameValues(namesInput.value),
|
names: nicknameValues(namesInput.value),
|
||||||
spawnPlacement: selectedSpawnPlacement(spawnPlacementInputs),
|
spawnPlacement: selectedSpawnPlacement(spawnPlacementInputs),
|
||||||
teamSize: Number(teamSizeInput.value),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
restoreSavedMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput, teamSizeNumberInput);
|
restoreSavedMatchSettings(namesInput, spawnPlacementInputs);
|
||||||
syncTeamSizeInputs(teamSizeInput, teamSizeNumberInput);
|
|
||||||
namesInput.addEventListener("input", () => {
|
namesInput.addEventListener("input", () => {
|
||||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
setPlayerNamesWarning();
|
||||||
});
|
saveMatchSettings(namesInput, spawnPlacementInputs);
|
||||||
teamSizeInput.addEventListener("input", () => {
|
|
||||||
syncTeamSizeInputs(teamSizeInput, teamSizeNumberInput);
|
|
||||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
|
||||||
});
|
|
||||||
teamSizeNumberInput.addEventListener("input", () => {
|
|
||||||
if (syncTeamSizeInputs(teamSizeInput, teamSizeNumberInput, teamSizeNumberInput.value)) {
|
|
||||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
teamSizeNumberInput.addEventListener("change", () => {
|
|
||||||
syncTeamSizeInputs(
|
|
||||||
teamSizeInput,
|
|
||||||
teamSizeNumberInput,
|
|
||||||
teamSizeNumberInput.value || teamSizeInput.value,
|
|
||||||
);
|
|
||||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
|
||||||
});
|
});
|
||||||
spawnPlacementInputs.forEach((input) => {
|
spawnPlacementInputs.forEach((input) => {
|
||||||
input.addEventListener("change", () => {
|
input.addEventListener("change", () => {
|
||||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
saveMatchSettings(namesInput, spawnPlacementInputs);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ export function createMatchForm() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
readMatchConfig,
|
readMatchConfig,
|
||||||
|
setPlayerNamesWarning,
|
||||||
setStatus(message) {
|
setStatus(message) {
|
||||||
if (statusNode) {
|
if (statusNode) {
|
||||||
statusNode.setAttribute("aria-hidden", "false");
|
statusNode.setAttribute("aria-hidden", "false");
|
||||||
@@ -96,29 +98,11 @@ function getElements(selector) {
|
|||||||
function nicknameValues(value) {
|
function nicknameValues(value) {
|
||||||
return value
|
return value
|
||||||
.split(/\r?\n|,/)
|
.split(/\r?\n|,/)
|
||||||
.map((name) => name.trim().slice(0, NICKNAME_LENGTH))
|
.map((name) => name.trim().slice(0, FIGHTER.NICKNAME_LENGTH))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncTeamSizeInputs(rangeInput, numberInput, value = rangeInput.value) {
|
function restoreSavedMatchSettings(namesInput, spawnPlacementInputs) {
|
||||||
const normalizedTeamSize = normalizeTeamSize(value, rangeInput);
|
|
||||||
|
|
||||||
if (!normalizedTeamSize) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
rangeInput.value = normalizedTeamSize;
|
|
||||||
numberInput.value = normalizedTeamSize;
|
|
||||||
|
|
||||||
return normalizedTeamSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreSavedMatchSettings(
|
|
||||||
namesInput,
|
|
||||||
spawnPlacementInputs,
|
|
||||||
teamSizeInput,
|
|
||||||
teamSizeNumberInput,
|
|
||||||
) {
|
|
||||||
const storage = getLocalStorage();
|
const storage = getLocalStorage();
|
||||||
|
|
||||||
if (!storage) {
|
if (!storage) {
|
||||||
@@ -128,27 +112,18 @@ function restoreSavedMatchSettings(
|
|||||||
try {
|
try {
|
||||||
const savedNames = storage.getItem(STORAGE_KEYS.names);
|
const savedNames = storage.getItem(STORAGE_KEYS.names);
|
||||||
const savedSpawnPlacement = storage.getItem(STORAGE_KEYS.spawnPlacement);
|
const savedSpawnPlacement = storage.getItem(STORAGE_KEYS.spawnPlacement);
|
||||||
const savedTeamSize = storage.getItem(STORAGE_KEYS.teamSize);
|
|
||||||
|
|
||||||
if (savedNames !== null) {
|
if (savedNames !== null) {
|
||||||
namesInput.value = savedNames;
|
namesInput.value = savedNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedTeamSize = normalizeTeamSize(savedTeamSize, teamSizeInput);
|
|
||||||
|
|
||||||
syncTeamSizeInputs(
|
|
||||||
teamSizeInput,
|
|
||||||
teamSizeNumberInput,
|
|
||||||
normalizedTeamSize || teamSizeInput.value,
|
|
||||||
);
|
|
||||||
|
|
||||||
setSpawnPlacement(spawnPlacementInputs, savedSpawnPlacement);
|
setSpawnPlacement(spawnPlacementInputs, savedSpawnPlacement);
|
||||||
} catch {
|
} catch {
|
||||||
// Storage may be unavailable in private or restricted browser contexts.
|
// Storage may be unavailable in private or restricted browser contexts.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput) {
|
function saveMatchSettings(namesInput, spawnPlacementInputs) {
|
||||||
const storage = getLocalStorage();
|
const storage = getLocalStorage();
|
||||||
|
|
||||||
if (!storage) {
|
if (!storage) {
|
||||||
@@ -158,19 +133,18 @@ function saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput) {
|
|||||||
try {
|
try {
|
||||||
storage.setItem(STORAGE_KEYS.names, namesInput.value);
|
storage.setItem(STORAGE_KEYS.names, namesInput.value);
|
||||||
storage.setItem(STORAGE_KEYS.spawnPlacement, selectedSpawnPlacement(spawnPlacementInputs));
|
storage.setItem(STORAGE_KEYS.spawnPlacement, selectedSpawnPlacement(spawnPlacementInputs));
|
||||||
storage.setItem(STORAGE_KEYS.teamSize, normalizeTeamSize(teamSizeInput.value, teamSizeInput));
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore storage failures so the match form remains usable.
|
// Ignore storage failures so the match form remains usable.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedSpawnPlacement(inputs) {
|
function selectedSpawnPlacement(inputs) {
|
||||||
return inputs.find((input) => input.checked)?.value ?? DEFAULT_SPAWN_PLACEMENT;
|
return inputs.find((input) => input.checked)?.value ?? SPAWN.DEFAULT_PLACEMENT;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSpawnPlacement(inputs, value) {
|
function setSpawnPlacement(inputs, value) {
|
||||||
const savedInput = inputs.find((input) => input.value === value);
|
const savedInput = inputs.find((input) => input.value === value);
|
||||||
const defaultInput = inputs.find((input) => input.value === DEFAULT_SPAWN_PLACEMENT);
|
const defaultInput = inputs.find((input) => input.value === SPAWN.DEFAULT_PLACEMENT);
|
||||||
const nextInput = savedInput ?? defaultInput;
|
const nextInput = savedInput ?? defaultInput;
|
||||||
|
|
||||||
if (nextInput) {
|
if (nextInput) {
|
||||||
@@ -178,18 +152,6 @@ function setSpawnPlacement(inputs, value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTeamSize(value, input) {
|
|
||||||
const min = Number(input.min) || 1;
|
|
||||||
const max = Number(input.max) || min;
|
|
||||||
const teamSize = Math.round(Number(value));
|
|
||||||
|
|
||||||
if (!Number.isFinite(teamSize)) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(Math.min(max, Math.max(min, teamSize)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocalStorage() {
|
function getLocalStorage() {
|
||||||
try {
|
try {
|
||||||
return window.localStorage;
|
return window.localStorage;
|
||||||
|
|||||||
@@ -109,12 +109,12 @@
|
|||||||
18. 치명타 적중 표기 추가 (완료)
|
18. 치명타 적중 표기 추가 (완료)
|
||||||
- **조치 사항**:
|
- **조치 사항**:
|
||||||
- 공격 프로필의 치명타 판정을 실제 적중 처리까지 전달해 전투 타입별 적중 연출이 같은 흐름을 사용하도록 정리.
|
- 공격 프로필의 치명타 판정을 실제 적중 처리까지 전달해 전투 타입별 적중 연출이 같은 흐름을 사용하도록 정리.
|
||||||
- 치명타 적중 시 대상 위에 `Critical!` 문구를 띄우고 즉시 처치와 카메라 흔들림이 함께 적용되도록 `applyHit()`를 보강.
|
- 치명타 적중 시 대상 위에 `Critical!` 문구를 띄우고 즉시 처치가 적용되도록 `applyHit()`를 보강. (카메라 흔들림은 이후 메테오 착탄 연출로 이전)
|
||||||
|
|
||||||
19. 리스폰 배치 설정 구분 추가 (완료)
|
19. 리스폰 배치 설정 구분 추가 (완료)
|
||||||
- **조치 사항**:
|
- **조치 사항**:
|
||||||
- 전투 설정 drawer에 `스타팅 지점 배치`와 기존 `완전 랜덤 배치`를 선택하는 리스폰 설정을 추가.
|
- 전투 설정 drawer에 `스타팅 지점 배치`와 기존 `완전 랜덤 배치`를 선택하는 리스폰 설정을 추가.
|
||||||
- `스타팅 지점 배치`에서는 참가자 수에 맞춰 전장 구역을 나누고 참가자별 시작 구역 배정과 구역 안 스폰 위치를 매치마다 무작위로 정하도록 구현.
|
- `스타팅 지점 배치`에서는 참가자별 스타팅 영역과 영역 안 스폰 위치를 매치마다 무작위로 정하도록 구현했으며, 이후 30번 작업에서 영역 선택을 랜덤 중심 셀 기반 `5 x 5` 방식으로 구체화.
|
||||||
- 선택한 리스폰 배치 모드를 `localStorage`에 저장해 새로고침과 재시작 이후에도 유지.
|
- 선택한 리스폰 배치 모드를 `localStorage`에 저장해 새로고침과 재시작 이후에도 유지.
|
||||||
|
|
||||||
20. 팀당 인원 직접 입력 동기화 (완료)
|
20. 팀당 인원 직접 입력 동기화 (완료)
|
||||||
@@ -178,4 +178,177 @@
|
|||||||
- 유저가 About 다이얼로그를 열 때마다 DB에서 최신 데이터를 가져오도록 서버 메모리 캐시 로직을 제거.
|
- 유저가 About 다이얼로그를 열 때마다 DB에서 최신 데이터를 가져오도록 서버 메모리 캐시 로직을 제거.
|
||||||
- 기본 개인정보처리방침 마크다운의 공고/시행 일자를 최신화.
|
- 기본 개인정보처리방침 마크다운의 공고/시행 일자를 최신화.
|
||||||
|
|
||||||
|
28. 전투 역할별 기본 스탯 프로필 분리 (완료)
|
||||||
|
- **조치 사항**:
|
||||||
|
- `src/constants.js`에 `FIGHTER_TYPE_STATS.melee/ranged/magic` 프로필을 추가해 최대 체력, 이동속도, 사거리, 쿨다운, 피해량, 치명타, 공격 발동 지연을 역할별로 조정할 수 있도록 변경.
|
||||||
|
- `src/game/fighter/fighterStats.js`를 추가해 투사체 캐릭터는 원거리, 즉발 주문 캐릭터는 마법, 나머지는 근접 프로필로 판별하고 개별 스킨 오버라이드를 병합.
|
||||||
|
- 캐릭터 생성과 전투 엔진이 해석된 프로필을 사용하도록 연결해 역할별 체력, 이동 및 공격 수치가 실제 전투에 적용되도록 변경.
|
||||||
|
|
||||||
|
29. 킬로그 캐릭터 아이콘 가시성 개선 (완료)
|
||||||
|
- **조치 사항**:
|
||||||
|
- `100x100` idle 프레임에 포함된 투명 여백까지 축소되던 킬로그 아이콘 배경 표시 방식을 보정.
|
||||||
|
- 아이콘 박스 크기와 행 레이아웃은 유지하면서 캐릭터 실루엣이 있는 중앙 하단 영역을 확대 표시하도록 배경 크기와 위치를 조정.
|
||||||
|
|
||||||
|
30. 팀별 스타팅 영역 앵커 및 전장 표시 추가 (완료)
|
||||||
|
- **조치 사항**:
|
||||||
|
- `스타팅 지점 배치`에서 전장 스폰 가능 그리드 중 팀별 중심 셀을 무작위로 선택하고, 중심 주변 2칸을 포함하는 `5 x 5` 영역을 팀별 스폰 구역으로 사용하도록 변경.
|
||||||
|
- 겹치지 않는 후보가 남아 있는 동안에는 선택된 스타팅 영역끼리 중첩되지 않는 랜덤 중심을 우선 사용해 전투 시작 즉시 팀이 섞이는 상황을 줄임.
|
||||||
|
- 팀별로 무작위 배정된 스타팅 영역 데이터를 실제 스폰 좌표와 공유해 표시 영역 밖에서 시작하지 않도록 구성.
|
||||||
|
- `arenaRenderer.js`에 팀 색상의 매우 옅은 채움 및 외곽선 오버레이를 추가하고, 랜덤 배치에서는 오버레이가 표시되지 않도록 연결.
|
||||||
|
|
||||||
|
31. 사망 시점 팀 badge 클릭 입력 유실 수정 (완료)
|
||||||
|
- **조치 사항**:
|
||||||
|
- 사망 발생 때마다 `arenaScoreboard.js`가 팀 badge 버튼 전체를 재생성해 클릭 중인 DOM이 제거되던 문제를 수정.
|
||||||
|
- 팀 구성이 바뀌지 않는 전투 중 갱신에서는 기존 버튼 DOM을 유지하고 생존 인원, 선택 강조, 비활성 상태만 업데이트하도록 변경.
|
||||||
|
- 사망 처리와 팀 badge 클릭이 같은 시점에 겹쳐도 생존 캐릭터 관전 시점 선택이 정상 전달되도록 보강.
|
||||||
|
|
||||||
|
32. 주기적 월드 이펙트 메테오 및 냉각지대 추가 (완료)
|
||||||
|
- **조치 사항**:
|
||||||
|
- `public/assets/effects/world_Effect.png`를 7프레임 공용 스프라이트시트로 로드하고, 실제 전투 시작 후 8초마다 무작위 생존자 위치에 메테오 또는 냉각지대를 무작위 발동하도록 `worldEffects.js`를 추가.
|
||||||
|
- 메테오는 낙하 경고 후 대상 위치 기준 `5 x 5` 영역에 환경 피해를 적용하고, 환경 사망이 처치 보상 없이 사망 통계와 승패 판정에 반영되도록 전투 피해 처리를 확장.
|
||||||
|
- 냉각지대는 냉기 착탄 연출과 지속 구역을 표시하며, 구역 안에 있는 캐릭터의 공격속도와 이동속도를 함께 감속하도록 연결.
|
||||||
|
- 발동 간격, 범위, 피해량, 냉각 지속시간과 감속 배율을 `src/constants.js`의 `WORLD_EFFECT_*` 상수로 분리하고, 새 경기/종료/일시정지 생명주기에 맞춰 정리되도록 구성.
|
||||||
|
|
||||||
|
33. 월드 메테오 대각선 낙하 및 냉기 전용 시트 적용 (완료)
|
||||||
|
- **조치 사항**:
|
||||||
|
- 대상 위치가 전장 좌측 반면(2, 3사분면)이면 좌상단에서 우하단, 우측 반면(1, 4사분면)이면 우상단에서 좌하단으로 낙하하도록 궤적, 좌우 반전, `45`도 회전을 적용.
|
||||||
|
- `WORLD_EFFECT_VISUAL_SCALE`과 `WORLD_EFFECT_FALL_TRAVEL_TILES`를 추가해 피해 판정 `5 x 5`는 유지하면서 스프라이트를 전역 마법처럼 크게 보이도록 확장.
|
||||||
|
- 화염 메테오는 `public/assets/effects/world_Effect.png`, 냉기 메테오는 새 `public/assets/effects/world_Effect_2.png`를 각각 독립된 7프레임 애니메이션으로 로드하도록 변경.
|
||||||
|
|
||||||
|
34. 냉기 메테오 착탄 피해 옵션 추가 (완료)
|
||||||
|
- **조치 사항**:
|
||||||
|
- `WORLD_EFFECT_FROST_DAMAGE`를 추가해 냉기 메테오 피해를 화염 메테오와 독립적으로 조절할 수 있도록 변경.
|
||||||
|
- 냉기 메테오 착탄 시 `5 x 5` 영역 피해를 먼저 처리하고, 전투가 종료되지 않은 경우 기존 냉각지대 감속 효과를 이어서 생성하도록 연결.
|
||||||
|
|
||||||
|
35. 스타팅 영역 표시 시간 제한 추가 (완료)
|
||||||
|
- **조치 사항**:
|
||||||
|
- 팀별 스타팅 영역 오버레이가 `스타팅 지점 배치` 매치 시작 후 5초 동안만 표시되고 이후 자동으로 사라지도록 연결.
|
||||||
|
- 숨김 예약을 Phaser 씬 타이머로 관리하여 일시정지 시간은 표시 지속 시간에 포함되지 않고, 새 매치 시작 시 이전 숨김 타이머가 남지 않도록 정리.
|
||||||
|
|
||||||
|
36. 최종 2팀 자동 관전 및 메테오 착탄 화면 흔들림 전환 (완료)
|
||||||
|
- **조치 사항**:
|
||||||
|
- 생존 캐릭터가 30명 미만이거나 최종 2팀만 남으면 후반 자동 줌과 교전 중심 포커싱이 시작되도록 관전 조건을 확장.
|
||||||
|
- 치명타의 `Critical!` 표기와 즉시 처치는 유지하면서 카메라 흔들림을 제거.
|
||||||
|
- 화염 메테오 착탄 화면 흔들림을 먼저 적용했으며, 이후 모든 메테오 착탄이 크기 기반 흔들림을 공유하도록 확장.
|
||||||
|
|
||||||
|
37. 자동 관전 이전 메테오 임시 포커싱 추가 (완료)
|
||||||
|
- **조치 사항**:
|
||||||
|
- 후반 자동 관전 조건이 성립하기 전 화염 또는 냉기 메테오가 낙하하면 착탄 위치를 확대 추적하고 착탄 연출 종료 후 기존 카메라 위치와 줌을 복원.
|
||||||
|
- 캐릭터 수동 선택과 후반/최종 자동 관전은 메테오 임시 시점보다 우선하도록 카메라 상태를 정리.
|
||||||
|
- `src/constants.js`의 `CAMERA.METEOR_FOCUS_ENABLED` 플래그로 메테오 임시 포커싱을 코드에서 켜고 끌 수 있도록 구성.
|
||||||
|
|
||||||
|
38. 냉기 메테오 동결 기절 및 실루엣 효과 추가 (완료)
|
||||||
|
- **조치 사항**:
|
||||||
|
- 냉기 메테오 착탄 피해에 생존한 전투원은 `2초` 동안 이동과 새 공격이 정지되는 `isFrostStunned` 상태가 되도록 연결.
|
||||||
|
- 동결 중 캐릭터 본체와 팀 실루엣 마커를 함께 얼음색으로 틴트하고, 시간이 끝나거나 매치가 정리되면 본체 원본 색상과 팀 색상으로 복원.
|
||||||
|
- `WORLD_EFFECT.FROST_STUN_DURATION`과 `WORLD_EFFECT.FROST_STUN_TINT`를 추가해 동결 지속시간과 표시 색상을 조절 가능하게 구성.
|
||||||
|
|
||||||
|
39. 모바일 세로모드 팀 카드 가로폭 불균형 수정 (완료)
|
||||||
|
- **조치 사항**:
|
||||||
|
- 모바일 미디어 쿼리에서 `.score-side`가 데스크톱의 `grid-template-columns: repeat(2, 114px)`를 상속받아 1~4번 팀 카드만 길게 표시되던 현상을 수정.
|
||||||
|
- `grid-template-columns: none`을 추가하여 모든 팀 카드가 `grid-auto-columns`에 설정된 일정한 가로폭을 가지도록 보정.
|
||||||
|
|
||||||
|
40. CSS 파일 기능별 모듈화 (완료)
|
||||||
|
- **조치 사항**:
|
||||||
|
- 거대했던 `src/styles.css`(약 2,000라인)를 기능별로 6개의 파일(`base`, `intro`, `game-ui`, `overlay`, `animations`, `mobile`)로 분리.
|
||||||
|
- `src/styles/` 폴더를 생성하여 모듈화된 CSS 파일들을 관리.
|
||||||
|
- `src/styles.css`는 이제 `@import`를 통해 각 모듈을 통합하는 엔트리 포인트 역할만 수행.
|
||||||
|
- 코드 가독성과 유지보수 편의성을 대폭 향상.
|
||||||
|
|
||||||
|
41. 스타일 관련 컨텍스트 문서 추가 및 라우팅 업데이트 (완료)
|
||||||
|
- **조치 사항**:
|
||||||
|
- 새로운 CSS 모듈 구조와 디자인 원칙을 설명하는 `context/style.md` 문서를 신규 생성.
|
||||||
|
- `agent.md`의 상세 기술 가이드(Context Routing) 섹션에 스타일 및 디자인 항목을 추가하여 문서 접근성 개선.
|
||||||
|
|
||||||
|
42. 상단 공지(Battle Notice) 콘텐츠 확장 (완료)
|
||||||
|
- **조치 사항**:
|
||||||
|
- 사망 통계만 보여주던 공지 UI에 게임 시스템 가이드(화염/냉기 메테오 특성, 밀리 치명타 확률 등) 팁을 추가.
|
||||||
|
- 사망 통계 공지 2회당 1회의 비율로 시스템 팁이 교차 출력되도록 로직 개선.
|
||||||
|
|
||||||
|
43. 구매 배수 기반 월드 이펙트 독주 표적 가중치 추가 (완료)
|
||||||
|
- **조치 사항**:
|
||||||
|
- `닉네임*N`으로 구매한 추가 병력은 수량과 전투 수치를 낮추지 않고 그대로 유지.
|
||||||
|
- 생존 팀별 구매 배수 지분과 현재 생존 지분을 비교해, 구매 지분을 초과해 살아남은 팀에만 월드 이펙트 표적 가중치를 추가.
|
||||||
|
- `WORLD_EFFECT.DOMINANCE_TARGETING_MULTIPLIER`를 추가하고 기본값을 `1`로 설정하여 초과 생존 지분 기반 압력을 활성화하며, `0`으로 설정하면 기존 생존 유닛 비례 표적 선택으로 복귀하도록 구성.
|
||||||
|
|
||||||
|
|
||||||
|
44. Team marker duplicated sprite removal and team shadow baking (completed)
|
||||||
|
- **Changes**:
|
||||||
|
- Removed the duplicated per-fighter `teamMarker` Phaser sprite and its frame/position/depth synchronization path.
|
||||||
|
- Added lazy team-colored spritesheet and animation generation in `fighterAssets.js` by recoloring floor shadow pixels (`#534545`) to the fighter team color.
|
||||||
|
- Updated fighter creation and combat animation playback to use team-shadow animation keys for idle, walk, attack, hurt, and death actions.
|
||||||
|
- Kept frost stun as a body `setTint(WORLD_EFFECT.FROST_STUN_TINT)` effect, with no team-marker tint state to restore.
|
||||||
|
- Verified production build with `npm run build`.
|
||||||
|
|
||||||
|
45. Dead fighter battlefield despawn (completed)
|
||||||
|
- **Changes**:
|
||||||
|
- Added `FIGHTER.DEAD_DESPAWN_DELAY_MS` in `src/constants.js` so corpse lifetime is easy to tune.
|
||||||
|
- Updated `combat.js` to keep a dead fighter at initial opacity, fade it toward `FIGHTER.DEAD_DESPAWN_ALPHA`, then remove it from `scene.fighters` and destroy the sprite after the configured delay.
|
||||||
|
- Kept death bookkeeping, kill rewards, split-on-death, and winner checks ahead of the despawn schedule.
|
||||||
|
|
||||||
|
46. Variable meteor impact and visual scale (completed)
|
||||||
|
- **Changes**:
|
||||||
|
- Added `WORLD_EFFECT.SIZE_SCALE_VARIANCE` so each fire/frost meteor drop can pick a different size multiplier.
|
||||||
|
- Updated `worldEffects.js` to apply the same per-drop multiplier to both the damage/frost zone bounds and the falling/impact sprite scale.
|
||||||
|
- Added `WORLD_EFFECT.METEOR_SHAKE_DURATION_MS` and `WORLD_EFFECT.METEOR_SHAKE_INTENSITY`, then scaled fire/frost meteor camera shake from the meteor size multiplier.
|
||||||
|
47. Large-battle combat effect focus gating (completed)
|
||||||
|
- Suppressed critical labels, instant-spell sprites, kill-heal sprites, and kill-growth tweens during large battles outside meteor camera focus.
|
||||||
|
- Kept combat outcomes, reward values, meteor/frost visuals, and projectile hit-detection objects unchanged.
|
||||||
|
48. Direct fighter count input and generated population cap (completed)
|
||||||
|
- Replaced the team-size control with `nickname*N` direct assigned-fighter input semantics and preserved the preview size with internal suffixed entries.
|
||||||
|
- Added `SPAWN.MAX_FIGHTER_COUNT = 8000` validation for participant-assigned fighter slots before match replacement; Slime trait-generated spawns and splits remain outside that input cap.
|
||||||
|
- Distributed starting-zone teams through `SPAWN.FIGHTERS_PER_STARTING_ZONE = 100`, assigning any remainder to the final zone.
|
||||||
|
49. Inline fighter-count limit warning (completed)
|
||||||
|
- Displayed assigned-count cap violations beneath the participant nickname textarea.
|
||||||
|
- Cleared the warning when participant input changes or a valid live match is submitted.
|
||||||
|
50. Dense-area multi-meteor barrage targeting (completed)
|
||||||
|
- Replaced random living-fighter world-effect targeting with a summed-area tile scan that selects the most populated `WORLD_EFFECT.AREA_TILES` warning region.
|
||||||
|
- Changed each fire/frost activation into a configurable number of smaller strikes inside the warning region, with damage, stun, and lingering frost applied only per impact zone.
|
||||||
|
- Added `WORLD_EFFECT.IMPACT_*` tuning values and retired purchase-share dominance weighting in favor of direct crowd-density pressure.
|
||||||
|
51. Initial and repeating barrage interval split (completed)
|
||||||
|
- Kept `WORLD_EFFECT.INTERVAL` as the delay from match start to the first barrage and added `WORLD_EFFECT.REPEAT_INTERVAL` for subsequent normal barrages.
|
||||||
|
- Preserved `WORLD_EFFECT.SUDDEN_DEATH.INTERVAL_MS` as the repeat delay once sudden death becomes active.
|
||||||
|
52. Configurable barrage warning duration (completed)
|
||||||
|
- Added `WORLD_EFFECT.WARNING_DURATION_MS` to control the visible lifetime of the large dense-area warning marker.
|
||||||
|
- Kept scheduled small impacts and meteor camera focus running after the warning marker hides.
|
||||||
|
53. Elite stacked-fighter compression and damage model (completed)
|
||||||
|
- Compressed each complete 100-member block in a smaller `nickname*N` team into one elite (`stackCount = 100`) and preserved the remaining members as individual normal fighters (`*101 = 1 elite + 1 normal`, `*199 = 1 elite + 99 normal`, `*200 = 2 elite`).
|
||||||
|
- Added centralized elite scale/HP/range and critical/meteor/frost percentage-damage constants; elite damage and attack speed now scale with `sqrt(stackCount)`.
|
||||||
|
- Weighted spectator thresholds, focus centers, and dense-area world-effect targeting by `stackCount` so compressed armies continue to influence viewing and hazard selection by their represented size.
|
||||||
|
- Kept elite movement speed unchanged and disabled Slime spawn/split reproduction on elite representatives to avoid multiplying an entire compressed army from a per-unit trait.
|
||||||
|
- Verified production build with `npm run build`.
|
||||||
|
54. Disable kill rewards for elite-compressed combat (completed)
|
||||||
|
- Added `COMBAT.KILL_REWARD_ENABLED = false` and skipped the heal/growth reward path after kills while retaining kill logs, death statistics, and match resolution.
|
||||||
|
- Updated the battle-tip message and documentation so the UI no longer advertises inactive heal or growth behavior.
|
||||||
|
- Preserved the legacy reward function behind the explicit toggle for a future non-compressed mode.
|
||||||
|
55. Fighter-domain elite configuration and melee-only elite spawns (completed)
|
||||||
|
- Nested elite stack, appearance, HP, and range tuning under `FIGHTER.ELITE` so fighter-specific settings are keyed within the fighter domain.
|
||||||
|
- Added setup-aware skin selection so elite plans use only melee fighter profiles while normal plans continue to use the complete manifest.
|
||||||
|
- Preserved the current elite HP ratio setting while moving the configuration.
|
||||||
|
56. Configurable elite speed and randomized large-team compression (completed)
|
||||||
|
- Added `FIGHTER.ELITE.TYPE`, attack/movement speed constants, and randomized compression tuning under the fighter domain.
|
||||||
|
- Kept fixed compression below the configured threshold, while eligible teams roll each 100-member block against a configured 60% elite probability.
|
||||||
|
- Preserved represented population by leaving failed elite blocks as normal fighters; 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.
|
||||||
|
|||||||