feat: implement dense-area meteor barrage, large-battle optimization, and direct fighter count input
This commit is contained in:
parent
2c013247a9
commit
30d7be41be
33
agent.md
33
agent.md
|
|
@ -1,15 +1,24 @@
|
|||
# Update: Variable Meteor Scale
|
||||
# Update: Focused Combat Effects In Large Battles
|
||||
|
||||
- Fire and frost world-effect meteors now randomize their size on each drop.
|
||||
- `WORLD_EFFECT.SIZE_SCALE_VARIANCE` controls the per-drop random range around the base size.
|
||||
- The same random multiplier is applied to both the damage/frost zone bounds and the falling/impact sprite scale.
|
||||
- When the live fighter count reaches `PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD`, supplemental combat visuals are suppressed unless a meteor camera focus is active.
|
||||
- Large-battle suppression covers critical-hit labels, instant-spell attack sprites, kill-heal sprites, and kill-growth tweens; damage, critical kills, healing values, and growth multipliers remain unchanged.
|
||||
- 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: Team Size Constants
|
||||
# Update: Direct Fighter Counts And Spawn Zones
|
||||
|
||||
- The battle setup team-size limit is centralized in `SPAWN.MAX_TEAM_SIZE` inside `src/constants.js`.
|
||||
- `matchForm.js` applies `SPAWN.DEFAULT_TEAM_SIZE` and `SPAWN.MAX_TEAM_SIZE` to the number/range controls at runtime, so `index.html` should not hardcode the team-size max.
|
||||
- `matchSetup.js` clamps the requested base team size with `SPAWN.MAX_TEAM_SIZE` before applying any per-name `*N` multiplier.
|
||||
- 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
|
||||
|
||||
|
|
@ -107,10 +116,10 @@
|
|||
- 메테오 생성 주기가 비약적으로 단축(기본 1초)되며, 빙결 효과를 가진 냉기 메테오가 집중 투하됩니다.
|
||||
- `constants.js`를 통해 활성화 여부, 시작 시간, 주기 등을 간편하게 조정할 수 있습니다.
|
||||
|
||||
### 7.3 구매 배수 기반 월드 이펙트 표적 가중치
|
||||
- `닉네임*N`으로 구매한 추가 병력의 수치와 수량은 낮추지 않고 그대로 유지합니다.
|
||||
- 월드 이펙트는 생존 중인 팀의 구매 배수 지분보다 생존 지분이 높아진 경우에만 해당 팀의 표적 확률을 추가로 높여, 결제 이점은 보존하면서 독주만 랜덤 요소로 견제합니다.
|
||||
- `WORLD_EFFECT.DOMINANCE_TARGETING_MULTIPLIER`가 `0`이면 기존 생존 유닛 비례 표적 선택이며, 기본값 `1`은 초과 생존 지분에 따른 추가 가중치를 그대로 적용합니다.
|
||||
### 7.3 밀집 구역 기반 월드 이펙트 포격
|
||||
- 월드 이펙트는 랜덤 생존자 대신 `WORLD_EFFECT.AREA_TILES` 크기 범위 중 현재 생존 캐릭터가 가장 많이 모인 위치를 표적으로 선택합니다.
|
||||
- 선택 범위를 먼저 경고로 표시한 뒤, 그 내부에 작은 화염 또는 냉기 메테오를 3~4발 분산 투하합니다.
|
||||
- 피해, 기절, 냉각 감속은 큰 경고 범위 전체가 아니라 각각의 작은 탄착 영역에만 적용됩니다.
|
||||
|
||||
└── ui/ # UI 컴포넌트 및 API 연동
|
||||
├── arenaKillLog.js # [New] 독립된 킬로그 DOM 조작 모듈
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * CAMERA.S
|
|||
```
|
||||
|
||||
자동 관전은 월드 이펙트 임시 시점, 후반 진입과 최종교전 세부 포커싱으로 나뉩니다.
|
||||
- **메테오 임시 포커싱**: 자동 관전 진입 전 화염 또는 냉기 메테오가 시작되면 착탄 지점을 임시로 확대 추적하고, 착탄 후 `CAMERA.METEOR_FOCUS_HOLD_DURATION`만큼 유지한 뒤 이전 카메라로 복귀합니다. `CAMERA.METEOR_FOCUS_ENABLED`를 `false`로 설정하면 끌 수 있으며, 수동 선택 시점과 아래 자동 관전 시점이 우선합니다.
|
||||
- **메테오 임시 포커싱**: 자동 관전 진입 전 화염 또는 냉기 포격이 시작되면 가장 밀집한 큰 경고 구역의 중심을 임시로 확대 추적합니다. 큰 경고 표시 자체는 `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명 이하**: 더 적은 생존 수를 가진 팀의 중앙을 포커싱하며, 동률이면 기존 교전쌍 중심 포커싱으로 되돌아갑니다.
|
||||
|
|
@ -39,4 +39,4 @@ this.cameras.main.scrollX += (targetX - this.cameras.main.midPoint.x) * CAMERA.S
|
|||
### 씬 상태 관리
|
||||
- **프리뷰 모드 (`presentationMode`)**: 최초 로드 시 조용히 실행되는 배경 전투입니다. 로컬 저장 옵션과 무관하게 10팀 x 5명 고정 규모로 동작합니다.
|
||||
- **일시정지 (`setPaused`)**: 실제 전투에서 물리, Phaser 타이머, tween, 스프라이트 애니메이션을 함께 제어합니다. 프리뷰 및 종료된 전투는 제외됩니다.
|
||||
- **월드 이펙트 주기**: 실제 전투 생성 시 `startWorldEffects()`를 시작하고, 새 매치/종료 때 `clearWorldEffects()`로 주기 타이머, 잔여 냉각 구역, 메테오 임시 포커스, 캐릭터 감속 배율을 정리합니다. Phaser 타이머를 사용하므로 일시정지 시간은 4초 발동 간격과 냉각 지속시간에 포함되지 않습니다.
|
||||
- **월드 이펙트 주기**: 실제 전투 생성 시 `startWorldEffects()`를 시작하고, 첫 포격은 `WORLD_EFFECT.INTERVAL`, 이후 일반 포격은 `WORLD_EFFECT.REPEAT_INTERVAL`을 사용합니다. 새 매치/종료 때 `clearWorldEffects()`로 주기 타이머, 잔여 냉각 구역, 메테오 임시 포커스, 캐릭터 감속 배율을 정리합니다. Phaser 타이머를 사용하므로 일시정지 시간은 이 간격과 냉각 지속시간에 포함되지 않습니다.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
# Update: Variable Meteor Scale
|
||||
# Update: Dense-Area Meteor Barrage
|
||||
|
||||
- `worldEffects.js` resolves a fresh size multiplier for every fire/frost meteor drop.
|
||||
- Tune the base damage/frost zone with `WORLD_EFFECT.AREA_TILES`, the base sprite size with `WORLD_EFFECT.VISUAL_SCALE`, and the shared random spread with `WORLD_EFFECT.SIZE_SCALE_VARIANCE`.
|
||||
- The same multiplier changes both the damage/frost zone bounds and the falling/impact sprite scale.
|
||||
- `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
|
||||
|
|
@ -21,6 +22,12 @@
|
|||
- Frost stun remains a body tint effect in `worldEffects.js`. Since team identity is baked into the floor shadow pixels, there is no `teamMarker` tint state to update or restore.
|
||||
- The removed `teamMarker` display object means death handling no longer needs to hide or destroy a separate marker. HUD cleanup only owns the name label and health bar objects.
|
||||
|
||||
# Update: Focused Combat Effects In Large Battles
|
||||
|
||||
- `combat.js` exposes supplemental combat visuals only while a large battle is inside the temporary meteor camera-focus window.
|
||||
- Outside that window, large battles skip critical labels, instant-spell sprites, kill-heal sprites, and kill-growth tweens while retaining the underlying damage and reward calculations.
|
||||
- World-effect meteor/frost visuals remain visible, and projectile objects remain enabled because projectiles currently participate in hit detection.
|
||||
|
||||
# Context: Combat System
|
||||
|
||||
## 1. 모듈별 상세 역할 (`src/game/combat/`)
|
||||
|
|
@ -28,7 +35,7 @@
|
|||
- **`combat.js`**: 전투 AI, 피해 계산, 처치 보상 등 핵심 전투 로직을 담당합니다. `fighterStats.js`에서 해석한 역할별 수치로 이동, 공격, 투사체 발사 등을 처리합니다.
|
||||
- **`combatSettings.js`**: 전투 속도 배율 등 런타임 전투 설정을 관리합니다.
|
||||
- **`arenaFinalCombatEffects.js`**: 최종 교전 시 슬로우 모션 등 연출 효과를 담당합니다. 수학적인 이징(easing) 함수와 물리 시간 배율 계산을 포함합니다.
|
||||
- **`worldEffects.js`**: 실제 전투에서 4초마다 발동하는 화염/냉기 메테오 선택, 사분면별 대각선 낙하 연출, 5x5 영역 판정, 냉기 동결과 감속 구역 수명주기를 처리합니다.
|
||||
- **`worldEffects.js`**: 실제 전투에서 설정 주기마다 생존자 밀집 구역을 탐색하고 화염/냉기 소형 메테오 포격을 실행하며, 대각선 낙하 연출, 개별 탄착 판정, 냉기 동결과 감속 구역 수명주기를 처리합니다.
|
||||
|
||||
## 2. 주요 로직 구현 세부 사항
|
||||
|
||||
|
|
@ -43,15 +50,15 @@
|
|||
- **`clampFighterInsideArena()`**: 처치 성장 중 커진 캐릭터가 전장 바깥으로 나가지 않도록 위치를 보정합니다.
|
||||
|
||||
### 월드 이펙트
|
||||
- **발동 규칙**: 프리뷰가 아닌 실제 전투에서 전투 시간 4초마다 생존 캐릭터 하나를 표적으로 선택하고, 대상의 당시 위치를 중심으로 메테오 또는 냉각지대 중 하나를 무작위 발동합니다.
|
||||
- **독주 표적 가중치**: `닉네임*N`의 구매 배수는 해당 팀의 정당한 초기 생존 지분으로 취급합니다. 생존 팀 사이에서 현재 생존 지분이 구매 배수 지분을 넘어선 팀만 `WORLD_EFFECT.DOMINANCE_TARGETING_MULTIPLIER`에 따라 추가 표적 확률을 받습니다. 따라서 배수 팀은 시작 시 스탯이나 수량 패널티를 받지 않으며, 전투 중 독주가 생겼을 때만 랜덤 위험이 증가합니다.
|
||||
- **발동 규칙**: 프리뷰가 아닌 실제 전투에서 시작 후 첫 포격은 `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`의 7프레임 애니메이션이 낙하하면 크기에 따른 화면 흔들림을 적용하고, 5x5 타일 영역의 생존자에게 고정 피해를 줍니다. 자동 관전 진입 전에는 `CAMERA.METEOR_FOCUS_ENABLED`가 켜져 있을 때 착탄 위치를 임시 포커싱합니다. 환경 피해로 인한 사망은 킬 보상을 지급하지 않지만 사망 통계와 승패 판정에는 반영됩니다.
|
||||
- **냉기 메테오**: `world_Effect_2.png`의 7프레임 애니메이션으로 착탄을 표시하고 크기에 따른 화면 흔들림을 적용하며, 자동 관전 진입 전에는 같은 설정에 따라 착탄 위치를 임시 포커싱합니다. 착탄 시 별도 조정 가능한 피해를 주며, 생존한 피격 대상은 캐릭터 본체와 실루엣이 얼음색으로 바뀐 채 2초 동안 기절합니다. 이후 남은 5x5 냉각지대 안에서는 공격속도와 이동속도 감속 배율을 적용하며, 영역을 벗어나거나 지속시간이 끝나면 배율을 복구합니다.
|
||||
- **화염 메테오**: `world_Effect.png`의 소형 탄착 애니메이션 3~4개가 밀집 경고 구역 내부로 순차 낙하합니다. 각 탄착은 크기에 따른 화면 흔들림과 개별 영역 고정 피해를 적용합니다. 환경 피해로 인한 사망은 킬 보상을 지급하지 않지만 사망 통계와 승패 판정에는 반영됩니다.
|
||||
- **냉기 메테오**: `world_Effect_2.png`의 소형 탄착 애니메이션들이 같은 방식으로 낙하합니다. 개별 탄착 피해를 입고 생존한 대상은 얼음색으로 바뀐 채 설정 시간 동안 기절하며, 각 탄착점에 남는 냉각지대 안에서는 공격속도와 이동속도 감속 배율을 적용합니다.
|
||||
|
||||
### 최종교전 슬로우모션
|
||||
`COMBAT.FINAL_SLOW_MOTION_ENABLED`가 활성화된 경우:
|
||||
|
|
@ -65,7 +72,7 @@
|
|||
- **월드 이펙트 및 서든 데스 조정**:
|
||||
- `src/constants.js`의 `WORLD_EFFECT.METEOR_DAMAGE`와 `WORLD_EFFECT.FROST_DAMAGE`로 피해량을 조정합니다.
|
||||
- `SUDDEN_DEATH.ENABLED`로 서든 데스 활성화 여부를 결정하며, `TRIGGER_MS`(시작 시간), `INTERVAL_MS`(주기), `FORCE_FROST`(냉기 고정) 설정을 변경할 수 있습니다.
|
||||
- `DOMINANCE_TARGETING_MULTIPLIER`는 구매 지분을 초과한 생존 지분에 대한 표적 가중치입니다. `0`은 기존 생존 유닛 비례 추첨, 기본값 `1`은 계산된 초과 비율을 그대로 반영합니다.
|
||||
- `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` 설정을 확인합니다.
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
# Context: Core & Infrastructure
|
||||
|
||||
# Update: Variable Meteor Scale
|
||||
# Update: Dense-Area Meteor Barrage
|
||||
|
||||
- `WORLD_EFFECT.SIZE_SCALE_VARIANCE` randomizes each fire/frost meteor drop around the base size.
|
||||
- The randomized size applies to both the world-effect damage/frost zone bounds from `WORLD_EFFECT.AREA_TILES` and the sprite scale from `WORLD_EFFECT.VISUAL_SCALE`.
|
||||
- `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: Team Size Constants
|
||||
# Update: Direct Fighter Counts And Match Cap
|
||||
|
||||
- `SPAWN.MAX_TEAM_SIZE` is the single source of truth for the battle setup team-size maximum.
|
||||
- `src/ui/matchForm.js` applies `SPAWN.DEFAULT_TEAM_SIZE` and `SPAWN.MAX_TEAM_SIZE` to the team-size number/range inputs at runtime.
|
||||
- `src/game/match/matchSetup.js` clamps the requested base team size with `SPAWN.MAX_TEAM_SIZE`; per-name `*N` multipliers are applied after that clamp.
|
||||
- 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
|
||||
|
||||
|
|
@ -31,7 +35,7 @@
|
|||
- `FIGHTER_TYPE_STATS`: `melee`, `ranged`, `magic`별 최대 체력, 이동속도, 사거리, 쿨다운, 피해량, 치명타 및 공격 발동 지연 기본값.
|
||||
- `FIGHTER_HITBOX_*`: 100x100 캐릭터 프레임 안에서 실제 충돌 판정이 놓이는 위치와 크기.
|
||||
- `KILL_HEALTH_RECOVERY_RATIO`, `KILL_GROWTH_MULTIPLIER`, `KILL_GROWTH_MAX_MULTIPLIER`: 처치 후 회복량, 크기/공격속도/이동속도 성장 배율, 누적 보상 상한.
|
||||
- `WORLD_EFFECT.*`: 월드 이펙트 발동 간격, 범위, 대각선 낙하 거리/시각 배율, 구매 배수 대비 독주 팀 표적 가중치, 화염/냉기 메테오 피해량, 냉기 동결 시간/실루엣 색상, 냉각지대 지속시간과 감속 배율.
|
||||
- `WORLD_EFFECT.*`: 첫/반복 포격 간격, 밀집 경고 범위, 개별 탄착 범위/발수/시각 배율, 대각선 낙하 거리, 화염/냉기 메테오 피해량, 냉기 동결 시간/색상, 냉각지대 지속시간과 감속 배율.
|
||||
- `SELECTED_FIGHTER_OUTLINE_GAP`, `SELECTED_FIGHTER_OUTLINE_WIDTH`, `SELECTED_FIGHTER_OUTLINE_ALPHA`: 팀 색상 실루엣 마커의 캐릭터 이격 거리, 두께, 투명도.
|
||||
- `TEAM_COLORS`, `getTeamColor()`: 8팀 이하에서는 기본 팔레트를 쓰고, 9팀 이상에서는 팀 수에 맞춰 중복 없는 색상을 동적으로 생성합니다.
|
||||
- `CAMERA.SPECTATOR_LERP`: 카메라 추적의 부드러움 정도.
|
||||
|
|
@ -48,6 +52,6 @@
|
|||
- **물리 수치 조정**: 역할별 기본 체력/속도/사거리/공격 수치는 `src/constants.js`의 `FIGHTER_TYPE_STATS`에서 변경하고, 특정 스킨만 다르게 할 때는 `fighterManifest.js`의 `stats` 또는 `combat` 설정을 사용하십시오.
|
||||
- **처치 성장 상한 조정**: 처치 보상으로 캐릭터가 커지는 최대치와 공격/이동 배율 상한은 `src/constants.js`의 `KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
|
||||
- **공격력 조정**: 역할별 기본 피해량은 `src/constants.js`의 `FIGHTER_TYPE_STATS.<type>.damageMin/damageMax`를 수정합니다. 캐릭터별 특수 공격 방식은 `fighterManifest.js`의 `combat` 설정을 우선 확인합니다.
|
||||
- **월드 이펙트 조정**: `src/constants.js`의 `WORLD_EFFECT.INTERVAL`, `WORLD_EFFECT.AREA_TILES`, `WORLD_EFFECT.SIZE_SCALE_VARIANCE`, `WORLD_EFFECT.FALL_TRAVEL_TILES`, `WORLD_EFFECT.VISUAL_SCALE`, `WORLD_EFFECT.METEOR_SHAKE_DURATION_MS`, `WORLD_EFFECT.METEOR_SHAKE_INTENSITY`, `WORLD_EFFECT.DOMINANCE_TARGETING_MULTIPLIER`, `WORLD_EFFECT.METEOR_DAMAGE`, `WORLD_EFFECT.FROST_DAMAGE`, `WORLD_EFFECT.FROST_STUN_DURATION`, `WORLD_EFFECT.FROST_STUN_TINT`, `WORLD_EFFECT.FROST_DURATION`, `WORLD_EFFECT.FROST_SPEED_MULTIPLIER`를 수정합니다. 독주 표적 배율은 `0`이면 기존 생존 유닛 비례 추첨이며, `1`이면 구매 배수 지분보다 높은 생존 지분의 초과분을 표적 가중치로 반영합니다. 임시 메테오 카메라는 `CAMERA.METEOR_FOCUS_ENABLED`로 끌 수 있습니다.
|
||||
- **월드 이펙트 조정**: `src/constants.js`의 `WORLD_EFFECT.INTERVAL`, `WORLD_EFFECT.REPEAT_INTERVAL`, `WORLD_EFFECT.AREA_TILES`, `WORLD_EFFECT.WARNING_DURATION_MS`, `WORLD_EFFECT.IMPACT_AREA_TILES`, `WORLD_EFFECT.IMPACT_COUNT_MIN`, `WORLD_EFFECT.IMPACT_COUNT_MAX`, `WORLD_EFFECT.IMPACT_STAGGER_MS`, `WORLD_EFFECT.IMPACT_VISUAL_SCALE`, `WORLD_EFFECT.SIZE_SCALE_VARIANCE`, `WORLD_EFFECT.FALL_TRAVEL_TILES`, `WORLD_EFFECT.METEOR_SHAKE_DURATION_MS`, `WORLD_EFFECT.METEOR_SHAKE_INTENSITY`, `WORLD_EFFECT.METEOR_DAMAGE`, `WORLD_EFFECT.FROST_DAMAGE`, `WORLD_EFFECT.FROST_STUN_DURATION`, `WORLD_EFFECT.FROST_STUN_TINT`, `WORLD_EFFECT.FROST_DURATION`, `WORLD_EFFECT.FROST_SPEED_MULTIPLIER`를 수정합니다. `INTERVAL`은 첫 포격까지의 대기 시간, `REPEAT_INTERVAL`은 이후 일반 포격 주기, `AREA_TILES`와 `WARNING_DURATION_MS`는 밀집 경고 구역과 표시 시간이며, `IMPACT_*` 값은 그 내부 실제 포격을 제어합니다. 임시 메테오 카메라는 `CAMERA.METEOR_FOCUS_ENABLED`로 끌 수 있습니다.
|
||||
- **DOM 접근**: 성능을 위해 `ArenaScene`은 좌측 HUD badge 등 필요한 시점에만 최소한으로 DOM에 접근합니다.
|
||||
- **패키지 락 파일**: 이 프로젝트는 `package-lock.json`을 저장소에서 제외합니다. 의존성 변경 시 `package.json`을 기준으로 관리합니다.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
# 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. 모듈별 상세 역할
|
||||
|
||||
### 매치 로직 (`src/game/match/`)
|
||||
|
|
|
|||
55
index.html
55
index.html
|
|
@ -162,8 +162,13 @@
|
|||
<form id="fighter-form" autocomplete="off">
|
||||
<fieldset>
|
||||
<legend>Players</legend>
|
||||
<label for="player-names">참가자 닉네임</label>
|
||||
<textarea id="player-names" name="playerNames" rows="10">
|
||||
<label for="player-names">참가자 닉네임 (*숫자 = 출전 인원)</label>
|
||||
<textarea
|
||||
id="player-names"
|
||||
name="playerNames"
|
||||
rows="10"
|
||||
aria-describedby="player-names-warning"
|
||||
>
|
||||
Player 1
|
||||
Player 2
|
||||
Player 3
|
||||
|
|
@ -175,29 +180,35 @@ Player 8
|
|||
Player 9
|
||||
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>
|
||||
<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"
|
||||
step="1"
|
||||
value="5"
|
||||
inputmode="numeric"
|
||||
aria-label="팀당 인원 직접 입력"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
id="team-size"
|
||||
name="teamSize"
|
||||
type="range"
|
||||
min="1"
|
||||
value="5"
|
||||
/>
|
||||
<div class="spawn-placement-field">
|
||||
<span id="spawn-placement-label" class="spawn-placement-label"
|
||||
>리스폰 설정</span
|
||||
|
|
|
|||
|
|
@ -74,8 +74,8 @@ export const FIGHTER = {
|
|||
};
|
||||
|
||||
export const PERFORMANCE = {
|
||||
LARGE_BATTLE_FIGHTER_THRESHOLD: 500,
|
||||
LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS: 700,
|
||||
LARGE_BATTLE_FIGHTER_THRESHOLD: 3000,
|
||||
LARGE_BATTLE_DEAD_DESPAWN_DELAY_MS: 0,
|
||||
TARGET_GRID_CELL_SIZE: TILE_SIZE * 4,
|
||||
FIGHTER_HUD_POOL_SIZE: 96,
|
||||
FIGHTER_HUD_VISIBLE_LIMIT: 72,
|
||||
|
|
@ -88,19 +88,20 @@ export const PERFORMANCE = {
|
|||
|
||||
// 3. SPAWN 도메인
|
||||
export const SPAWN = {
|
||||
DEFAULT_TEAM_SIZE: 5,
|
||||
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: 8000,
|
||||
FIGHTERS_PER_STARTING_ZONE: 100,
|
||||
STARTING_ZONE_RADIUS: 2,
|
||||
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,
|
||||
MAX_TEAM_SIZE: 50,
|
||||
};
|
||||
|
||||
// 4. COMBAT 도메인
|
||||
|
|
@ -130,19 +131,25 @@ export const PROJECTILE = {
|
|||
|
||||
// 6. WORLD_EFFECT 도메인
|
||||
export const WORLD_EFFECT = {
|
||||
INTERVAL: 4000,
|
||||
AREA_TILES: 15,
|
||||
SIZE_SCALE_VARIANCE: 3,
|
||||
// Delay from match start until the first barrage.
|
||||
INTERVAL: 8000,
|
||||
// Delay between barrages after the first one has fired.
|
||||
REPEAT_INTERVAL: 8000,
|
||||
AREA_TILES: 40,
|
||||
// How long the large dense-area warning marker remains visible.
|
||||
WARNING_DURATION_MS: 2000,
|
||||
IMPACT_AREA_TILES: 10,
|
||||
IMPACT_COUNT_MIN: 15,
|
||||
IMPACT_COUNT_MAX: 25,
|
||||
IMPACT_STAGGER_MS: 140,
|
||||
IMPACT_VISUAL_SCALE: 10,
|
||||
SIZE_SCALE_VARIANCE: 0,
|
||||
FRAMES: 7,
|
||||
FRAME_RATE: 14,
|
||||
FALL_DURATION: 920,
|
||||
FALL_TRAVEL_TILES: 8,
|
||||
VISUAL_SCALE: 50,
|
||||
METEOR_SHAKE_DURATION_MS: 150,
|
||||
METEOR_SHAKE_INTENSITY: 0.004,
|
||||
// 0 keeps target selection proportional to living units.
|
||||
// 1 adds pressure when a team's living share exceeds its paid spawn share.
|
||||
DOMINANCE_TARGETING_MULTIPLIER: 0.5,
|
||||
METEOR_DAMAGE: 90,
|
||||
FROST_DAMAGE: 45,
|
||||
FROST_STUN_DURATION: 2000,
|
||||
|
|
@ -163,7 +170,7 @@ export const CAMERA = {
|
|||
MAX_ZOOM: 3,
|
||||
ZOOM_STEP: 0.1,
|
||||
// 자동 관전 진입 전 화염/냉기 메테오 낙하 위치를 임시로 확대 추적합니다.
|
||||
METEOR_FOCUS_ENABLED: true,
|
||||
METEOR_FOCUS_ENABLED: false,
|
||||
METEOR_FOCUS_ZOOM: 2,
|
||||
SPECTATOR_LERP: 0.1,
|
||||
// 메테오 착탄 후 카메라를 해당 위치에 유지하는 시간(ms)입니다.
|
||||
|
|
@ -173,7 +180,7 @@ export const CAMERA = {
|
|||
SPECTATOR_FINAL_TEAM_COUNT: 2,
|
||||
SPECTATOR_FINAL_TEAM_TOTAL_THRESHOLD: 8,
|
||||
SPECTATOR_RANDOM_FOCUS_INTERVAL: 10000,
|
||||
SPECTATOR_LATE_FIGHTER_THRESHOLD: 30,
|
||||
SPECTATOR_LATE_FIGHTER_THRESHOLD: 80,
|
||||
SPECTATOR_LATE_FIGHT_ZOOM: 2,
|
||||
SELECTED_FIGHTER_ZOOM: 2,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,7 +24,11 @@ import {
|
|||
} from "../fighter/fighterFactory.js";
|
||||
import { fighterManifest } from "../fighter/fighterManifest.js";
|
||||
import { pickFighters } from "../fighter/fighterSelection.js";
|
||||
import { createMatchSetup, matchStatusText } from "../match/matchSetup.js";
|
||||
import {
|
||||
createMatchSetup,
|
||||
FighterCountLimitError,
|
||||
matchStatusText,
|
||||
} from "../match/matchSetup.js";
|
||||
import { trackMatchFinish, trackMatchStart } from "../../ui/dailyMetrics.js";
|
||||
import { addTodayDeathStats, fetchTodayDeathStats } from "../../ui/deathStats.js";
|
||||
import { createFighterPlans, clusterSpawnPosition, syncTeamSizes } from "../match/arenaMatchRuntime.js";
|
||||
|
|
@ -61,7 +65,7 @@ import {
|
|||
} from "../../ui/battleDeathNotice.js";
|
||||
|
||||
export class ArenaScene extends Phaser.Scene {
|
||||
constructor({ getInitialMatchConfig, onMatchEnd, setStatus }) {
|
||||
constructor({ getInitialMatchConfig, onMatchEnd, setPlayerNamesWarning, setStatus }) {
|
||||
super("arena");
|
||||
this.fighters = [];
|
||||
this.getInitialMatchConfig = getInitialMatchConfig;
|
||||
|
|
@ -71,6 +75,8 @@ export class ArenaScene extends Phaser.Scene {
|
|||
this.onMatchEnd = typeof onMatchEnd === "function" ? onMatchEnd : () => {};
|
||||
this.presentationMode = true;
|
||||
this.ready = false;
|
||||
this.setPlayerNamesWarning =
|
||||
typeof setPlayerNamesWarning === "function" ? setPlayerNamesWarning : () => {};
|
||||
this.updateStatus = typeof setStatus === "function" ? setStatus : () => {};
|
||||
this.setStatus = (message) => {
|
||||
this.updateStatus(message);
|
||||
|
|
@ -165,25 +171,46 @@ export class ArenaScene extends Phaser.Scene {
|
|||
this.startMatch(this.getInitialMatchConfig(), { silent: true });
|
||||
}
|
||||
|
||||
startMatch({ names = [], spawnPlacement, teamSize } = {}, { silent = false } = {}) {
|
||||
startMatch({ names = [], spawnPlacement } = {}, { silent = false } = {}) {
|
||||
if (!this.ready) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
this.setPlayerNamesWarning();
|
||||
}
|
||||
|
||||
if (names.length < 2) {
|
||||
this.setStatus("참가자 닉네임을 2명 이상 입력하세요.");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
let matchSetup;
|
||||
|
||||
try {
|
||||
matchSetup = createMatchSetup(names, spawnPlacement);
|
||||
} catch (error) {
|
||||
if (error instanceof FighterCountLimitError) {
|
||||
this.setPlayerNamesWarning({
|
||||
title: "최대 출전 인원 초과",
|
||||
fighterCount: error.fighterCount,
|
||||
maxFighterCount: error.maxFighterCount,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const matchSkins = pickFighters(fighterManifest, matchSetup.fighters.length);
|
||||
const fighterPlans = createFighterPlans(matchSetup.fighters, matchSkins, {
|
||||
expandSpawnMultipliers: !silent,
|
||||
});
|
||||
|
||||
if (!silent) {
|
||||
primeVictoryFanfareAudio();
|
||||
}
|
||||
|
||||
const matchSetup = createMatchSetup(names, teamSize, spawnPlacement);
|
||||
const matchSkins = pickFighters(fighterManifest, matchSetup.fighters.length);
|
||||
const fighterPlans = createFighterPlans(matchSetup.fighters, matchSkins, {
|
||||
expandSpawnMultipliers: !silent,
|
||||
});
|
||||
syncTeamSizes(matchSetup.teams, fighterPlans);
|
||||
|
||||
this.matchId += 1;
|
||||
|
|
@ -215,6 +242,7 @@ export class ArenaScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
this.updateScoreboard();
|
||||
return true;
|
||||
}
|
||||
|
||||
showStartingZones(startingZones) {
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) {
|
|||
}
|
||||
|
||||
function spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack) {
|
||||
if (shouldRenderCombatEffects(scene)) {
|
||||
const effect = scene.add.sprite(defender.x, defender.y, fighterAttackEffectKey(attacker.skin));
|
||||
effect.setDepth(3);
|
||||
effect.setScale(FIGHTER.SCALE);
|
||||
|
|
@ -232,6 +233,7 @@ function spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack)
|
|||
effect.once(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
|
||||
disposeCombatObject(scene, effect);
|
||||
});
|
||||
}
|
||||
|
||||
scene.time.delayedCall(
|
||||
scaledAttackDelay(combatStatsFor(attacker).effectHitDelay, attacker),
|
||||
|
|
@ -248,7 +250,7 @@ function applyHit(scene, attacker, defender, onWinner, matchId, { isCritical = f
|
|||
return;
|
||||
}
|
||||
|
||||
if (isCritical) {
|
||||
if (isCritical && shouldRenderCombatEffects(scene)) {
|
||||
spawnCriticalHitLabel(scene, defender);
|
||||
}
|
||||
|
||||
|
|
@ -526,11 +528,18 @@ function applyKillReward(winner) {
|
|||
|
||||
const nextScaleX = (winner.baseScaleX ?? FIGHTER.SCALE) * rewardMultiplier;
|
||||
const nextScaleY = (winner.baseScaleY ?? FIGHTER.SCALE) * rewardMultiplier;
|
||||
const renderEffects = shouldRenderCombatEffects(winner.scene);
|
||||
|
||||
if (nextHp > previousHp) {
|
||||
if (nextHp > previousHp && renderEffects) {
|
||||
spawnKillHealEffect(winner, Math.max(Math.abs(nextScaleX), Math.abs(nextScaleY)));
|
||||
}
|
||||
|
||||
if (!renderEffects) {
|
||||
winner.setScale(nextScaleX, nextScaleY);
|
||||
clampFighterInsideArena(winner);
|
||||
return;
|
||||
}
|
||||
|
||||
winner.scene.tweens.add({
|
||||
targets: winner,
|
||||
scaleX: nextScaleX,
|
||||
|
|
@ -609,6 +618,14 @@ function resolveDeadDespawnDelay(scene) {
|
|||
return Math.max(0, Number(delay) || 0);
|
||||
}
|
||||
|
||||
function shouldRenderCombatEffects(scene) {
|
||||
const fighterCount = scene.fighters?.length ?? 0;
|
||||
const isLargeBattle =
|
||||
fighterCount >= Math.max(1, PERFORMANCE.LARGE_BATTLE_FIGHTER_THRESHOLD);
|
||||
|
||||
return !isLargeBattle || Boolean(scene.meteorFocusState);
|
||||
}
|
||||
|
||||
function createTargetSpatialIndex(fighters) {
|
||||
const cellSize = Math.max(1, Number(PERFORMANCE.TARGET_GRID_CELL_SIZE) || ARENA.TILE_SIZE);
|
||||
const maxCellX = Math.floor((ARENA.SIZE - 1) / cellSize);
|
||||
|
|
|
|||
|
|
@ -60,12 +60,16 @@ export function startWorldEffects(scene) {
|
|||
scene.matchStartedAt = scene.time.now;
|
||||
scene.isSuddenDeath = false;
|
||||
|
||||
const scheduleNext = () => {
|
||||
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 = isSuddenDeath ? WORLD_EFFECT.SUDDEN_DEATH.INTERVAL_MS : WORLD_EFFECT.INTERVAL;
|
||||
const delay = isInitialBarrage
|
||||
? WORLD_EFFECT.INTERVAL
|
||||
: isSuddenDeath
|
||||
? WORLD_EFFECT.SUDDEN_DEATH.INTERVAL_MS
|
||||
: WORLD_EFFECT.REPEAT_INTERVAL;
|
||||
|
||||
if (isSuddenDeath && !scene.isSuddenDeath) {
|
||||
scene.isSuddenDeath = true;
|
||||
|
|
@ -77,7 +81,7 @@ export function startWorldEffects(scene) {
|
|||
});
|
||||
};
|
||||
|
||||
scheduleNext();
|
||||
scheduleNext(true);
|
||||
}
|
||||
|
||||
export function clearWorldEffects(scene) {
|
||||
|
|
@ -124,8 +128,7 @@ function triggerWorldEffect(scene) {
|
|||
return;
|
||||
}
|
||||
|
||||
const target = chooseWorldEffectTarget(livingFighters);
|
||||
const zone = createEffectZone(target, resolveWorldEffectSizeScale());
|
||||
const zone = findDensestWorldEffectZone(livingFighters);
|
||||
|
||||
// Sudden Death 상태이고 냉기 고정 설정이 되어있으면 무조건 냉기 메테오
|
||||
if ((scene.isSuddenDeath && WORLD_EFFECT.SUDDEN_DEATH.FORCE_FROST) || Phaser.Math.Between(0, 1) === 0) {
|
||||
|
|
@ -136,69 +139,53 @@ function triggerWorldEffect(scene) {
|
|||
spawnMeteor(scene, zone);
|
||||
}
|
||||
|
||||
export function chooseWorldEffectTarget(livingFighters) {
|
||||
export function findDensestWorldEffectZone(livingFighters) {
|
||||
if (livingFighters.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dominanceMultiplier = Math.max(
|
||||
0,
|
||||
Number(WORLD_EFFECT.DOMINANCE_TARGETING_MULTIPLIER) || 0,
|
||||
const areaTiles = resolveTileCount(WORLD_EFFECT.AREA_TILES, ARENA.GRID_SIZE);
|
||||
const tileCounts = Array.from(
|
||||
{ length: ARENA.GRID_SIZE },
|
||||
() => Array(ARENA.GRID_SIZE).fill(0),
|
||||
);
|
||||
|
||||
if (dominanceMultiplier === 0) {
|
||||
return randomEntry(livingFighters);
|
||||
}
|
||||
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);
|
||||
|
||||
const teamPools = Array.from(
|
||||
livingFighters.reduce((pools, fighter) => {
|
||||
const teamId = fighter.team.id;
|
||||
const teamPool = pools.get(teamId) ?? {
|
||||
fighters: [],
|
||||
purchasedWeight: resolvePurchasedWeight(fighter.team),
|
||||
};
|
||||
|
||||
teamPool.fighters.push(fighter);
|
||||
pools.set(teamId, teamPool);
|
||||
return pools;
|
||||
}, new Map()).values(),
|
||||
);
|
||||
const totalLivingFighters = livingFighters.length;
|
||||
const totalPurchasedWeight = teamPools.reduce(
|
||||
(total, teamPool) => total + teamPool.purchasedWeight,
|
||||
0,
|
||||
);
|
||||
|
||||
teamPools.forEach((teamPool) => {
|
||||
const livingShare = teamPool.fighters.length / totalLivingFighters;
|
||||
const purchasedShare = teamPool.purchasedWeight / totalPurchasedWeight;
|
||||
const dominanceRatio = livingShare / purchasedShare;
|
||||
const excessDominance = Math.max(0, dominanceRatio - 1);
|
||||
|
||||
teamPool.targetWeight =
|
||||
teamPool.fighters.length * (1 + excessDominance * dominanceMultiplier);
|
||||
tileCounts[row][column] += 1;
|
||||
});
|
||||
|
||||
return randomEntry(weightedEntry(teamPools).fighters);
|
||||
// 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 });
|
||||
}
|
||||
|
||||
function resolvePurchasedWeight(team) {
|
||||
return Math.max(1, Number(team?.multiplier) || 1);
|
||||
}
|
||||
|
||||
function weightedEntry(entries) {
|
||||
const totalWeight = entries.reduce((total, entry) => total + entry.targetWeight, 0);
|
||||
let randomWeight = Math.random() * totalWeight;
|
||||
|
||||
for (const entry of entries) {
|
||||
randomWeight -= entry.targetWeight;
|
||||
|
||||
if (randomWeight <= 0) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return entries[entries.length - 1];
|
||||
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) {
|
||||
|
|
@ -206,49 +193,97 @@ function randomEntry(entries) {
|
|||
}
|
||||
|
||||
function spawnMeteor(scene, zone) {
|
||||
const marker = createZoneMarker(scene, zone, METEOR_ZONE_COLOR);
|
||||
scene.beginMeteorCameraFocus?.(zone);
|
||||
|
||||
dropWorldEffectSprite(scene, zone, {
|
||||
spawnWorldEffectBarrage(scene, zone, {
|
||||
color: METEOR_ZONE_COLOR,
|
||||
damage: WORLD_EFFECT.METEOR_DAMAGE,
|
||||
effectKey: METEOR_EFFECT_KEY,
|
||||
onCancel: () => {
|
||||
scene.clearMeteorCameraFocus?.(zone);
|
||||
disposeCombatObject(scene, marker);
|
||||
},
|
||||
onImpact: () => {
|
||||
scene.tweens.killTweensOf(marker);
|
||||
marker.setAlpha(1);
|
||||
applyMeteorImpactShake(scene, zone);
|
||||
resolveImpactDamage(scene, zone, WORLD_EFFECT.METEOR_DAMAGE);
|
||||
},
|
||||
onAnimationComplete: () => {
|
||||
scene.clearMeteorCameraFocus?.(zone);
|
||||
disposeCombatObject(scene, marker);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function spawnFrostZone(scene, zone) {
|
||||
const marker = createZoneMarker(scene, zone, FROST_ZONE_COLOR);
|
||||
scene.beginMeteorCameraFocus?.(zone);
|
||||
|
||||
dropWorldEffectSprite(scene, zone, {
|
||||
spawnWorldEffectBarrage(scene, zone, {
|
||||
color: FROST_ZONE_COLOR,
|
||||
damage: WORLD_EFFECT.FROST_DAMAGE,
|
||||
effectKey: FROST_EFFECT_KEY,
|
||||
isFrost: true,
|
||||
});
|
||||
}
|
||||
|
||||
function spawnWorldEffectBarrage(
|
||||
scene,
|
||||
targetZone,
|
||||
{ color, damage, 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: () => {
|
||||
scene.clearMeteorCameraFocus?.(zone);
|
||||
disposeCombatObject(scene, marker);
|
||||
disposeCombatObject(scene, impactMarker);
|
||||
finishImpact();
|
||||
},
|
||||
onImpact: () => {
|
||||
applyMeteorImpactShake(scene, zone);
|
||||
resolveImpactDamage(scene, zone, WORLD_EFFECT.FROST_DAMAGE, (fighter) => {
|
||||
applyFrostStun(scene, fighter);
|
||||
});
|
||||
scene.tweens.killTweensOf(impactMarker);
|
||||
impactMarker.setAlpha(1);
|
||||
applyMeteorImpactShake(scene, impactZone);
|
||||
resolveImpactDamage(
|
||||
scene,
|
||||
impactZone,
|
||||
damage,
|
||||
isFrost ? (fighter) => applyFrostStun(scene, fighter) : undefined,
|
||||
);
|
||||
|
||||
if (!scene.matchOver) {
|
||||
activateFrostZone(scene, zone, marker);
|
||||
if (isFrost && !scene.matchOver) {
|
||||
activateFrostZone(scene, impactZone, impactMarker);
|
||||
}
|
||||
},
|
||||
onAnimationComplete: () => scene.clearMeteorCameraFocus?.(zone),
|
||||
onAnimationComplete: () => {
|
||||
if (!isFrost) {
|
||||
disposeCombatObject(scene, impactMarker);
|
||||
}
|
||||
|
||||
finishImpact();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
pendingTimers.push(timer);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -315,7 +350,7 @@ function resolveWorldEffectSizeScale() {
|
|||
}
|
||||
|
||||
function resolveWorldEffectVisualScale(zone) {
|
||||
const baseScale = Math.max(0.01, Number(WORLD_EFFECT.VISUAL_SCALE) || 1);
|
||||
const baseScale = Math.max(0.01, Number(WORLD_EFFECT.IMPACT_VISUAL_SCALE) || 1);
|
||||
return baseScale * Math.max(0.1, Number(zone?.sizeScale) || 1);
|
||||
}
|
||||
|
||||
|
|
@ -348,14 +383,44 @@ function createFallTrajectory(zone) {
|
|||
};
|
||||
}
|
||||
|
||||
function createEffectZone(target, sizeScale = 1) {
|
||||
const areaTiles = Math.max(
|
||||
1,
|
||||
(Number(WORLD_EFFECT.AREA_TILES) || 1) * Math.max(0.1, Number(sizeScale) || 1),
|
||||
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;
|
||||
const centerX = target.body?.center.x ?? target.x;
|
||||
const centerY = target.body?.center.y ?? target.y;
|
||||
|
||||
return {
|
||||
areaTiles,
|
||||
|
|
@ -367,6 +432,66 @@ function createEffectZone(target, sizeScale = 1) {
|
|||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@ const NAME_MULTIPLIER_REGEX = /\*(\d+)$/;
|
|||
|
||||
export function createMatchSetup(
|
||||
names,
|
||||
requestedTeamSize = SPAWN.DEFAULT_TEAM_SIZE,
|
||||
requestedSpawnPlacement = SPAWN.DEFAULT_PLACEMENT,
|
||||
) {
|
||||
const baseTeamSize = resolveTeamSize(requestedTeamSize);
|
||||
const teams = names.map((rawName, index) => {
|
||||
const match = rawName.match(NAME_MULTIPLIER_REGEX);
|
||||
const multiplier = match ? Math.max(1, parseInt(match[1], 10)) : 1;
|
||||
|
|
@ -18,19 +16,23 @@ export function createMatchSetup(
|
|||
id: `team-${index + 1}`,
|
||||
label,
|
||||
multiplier,
|
||||
size: baseTeamSize * 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 totalFighters = teams.reduce((sum, team) => sum + team.size, 0);
|
||||
const spawns = createSpawnPoints(
|
||||
totalFighters,
|
||||
baseTeamSize,
|
||||
requestedSpawnPlacement,
|
||||
startingZones,
|
||||
);
|
||||
|
|
@ -55,29 +57,24 @@ export function createMatchSetup(
|
|||
};
|
||||
}
|
||||
|
||||
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 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}`;
|
||||
return `${teams.length}팀 | 총 ${totalFighters}명 출전 | ${labels}`;
|
||||
}
|
||||
|
||||
function createTeams(playerCount, teamSize) {
|
||||
const teamCount = Math.ceil(playerCount / teamSize);
|
||||
|
||||
return Array.from({ length: teamCount }, (_, index) => ({
|
||||
color: TEAM.getColor(index, teamCount),
|
||||
id: `team-${index + 1}`,
|
||||
label: `Team ${index + 1}`,
|
||||
size: Math.min(teamSize, playerCount - index * teamSize),
|
||||
}));
|
||||
}
|
||||
|
||||
function createSpawnPoints(totalCount, teamSize, requestedSpawnPlacement, startingZones) {
|
||||
function createSpawnPoints(totalCount, requestedSpawnPlacement, startingZones) {
|
||||
if (requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES) {
|
||||
return createStartingZoneSpawnPoints(startingZones, teamSize);
|
||||
return createStartingZoneSpawnPoints(startingZones);
|
||||
}
|
||||
|
||||
return createRandomSpawnPoints(totalCount);
|
||||
|
|
@ -87,30 +84,36 @@ function createRandomSpawnPoints(count) {
|
|||
return createSpawnPointsFromSlots(createSpawnSlots(), count);
|
||||
}
|
||||
|
||||
function createStartingZoneSpawnPoints(startingZones, teamSize) {
|
||||
function createStartingZoneSpawnPoints(startingZones) {
|
||||
const fallbackSlots = createSpawnSlots();
|
||||
|
||||
return startingZones.flatMap((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.multiplier || 1), 0);
|
||||
const totalZonesNeeded = teams.reduce((sum, team) => sum + team.startingZoneCount, 0);
|
||||
const layout = shuffle(createStartingZoneLayout(totalZonesNeeded));
|
||||
|
||||
let layoutIndex = 0;
|
||||
return teams.flatMap((team) => {
|
||||
const multiplier = team.multiplier || 1;
|
||||
let remainingFighters = team.size;
|
||||
const teamZones = [];
|
||||
|
||||
for (let i = 0; i < multiplier; i++) {
|
||||
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;
|
||||
|
|
@ -213,14 +216,6 @@ function startingZonesOverlap(left, right) {
|
|||
);
|
||||
}
|
||||
|
||||
function resolveTeamSize(requestedTeamSize) {
|
||||
return clamp(
|
||||
Math.round(Number(requestedTeamSize) || SPAWN.DEFAULT_TEAM_SIZE),
|
||||
1,
|
||||
SPAWN.MAX_TEAM_SIZE,
|
||||
);
|
||||
}
|
||||
|
||||
function spawnJitter() {
|
||||
return (Math.random() - 0.5) * ARENA.TILE_SIZE * 0.36;
|
||||
}
|
||||
|
|
|
|||
12
src/main.js
12
src/main.js
|
|
@ -56,6 +56,10 @@ function startConfiguredMatch(matchConfig) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!arenaScene.startMatch(matchConfig)) {
|
||||
return;
|
||||
}
|
||||
|
||||
appNode?.classList.remove("match-ended");
|
||||
appNode?.classList.add("match-live");
|
||||
|
||||
|
|
@ -65,14 +69,15 @@ function startConfiguredMatch(matchConfig) {
|
|||
openOptionsDrawer({ focus: false });
|
||||
}
|
||||
|
||||
arenaScene.startMatch(matchConfig);
|
||||
syncPauseButton();
|
||||
}
|
||||
|
||||
function getPresentationMatchConfig() {
|
||||
return {
|
||||
names: Array.from({ length: SPAWN.PRESENTATION_TEAM_COUNT }, (_, index) => `Player ${index + 1}`),
|
||||
teamSize: SPAWN.PRESENTATION_TEAM_SIZE,
|
||||
names: Array.from(
|
||||
{ length: SPAWN.PRESENTATION_TEAM_COUNT },
|
||||
(_, index) => `Player ${index + 1}*${SPAWN.PRESENTATION_TEAM_SIZE}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -169,6 +174,7 @@ window.addEventListener("keydown", (event) => {
|
|||
const arenaScene = new ArenaScene({
|
||||
getInitialMatchConfig: getPresentationMatchConfig,
|
||||
onMatchEnd: handleMatchEnd,
|
||||
setPlayerNamesWarning: matchForm.setPlayerNamesWarning,
|
||||
setStatus: matchForm.setStatus,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -100,11 +100,6 @@
|
|||
padding-block: 9px;
|
||||
}
|
||||
|
||||
#app.match-live .team-size-number {
|
||||
width: 64px;
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
#app.match-live .spawn-placement-option span {
|
||||
min-height: 36px;
|
||||
padding: 6px;
|
||||
|
|
|
|||
|
|
@ -462,21 +462,6 @@ legend {
|
|||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.team-size-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.team-size-number {
|
||||
width: 88px;
|
||||
min-width: 88px;
|
||||
padding-inline: 10px;
|
||||
text-align: center;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #ead8ad;
|
||||
font-size: 0.92rem;
|
||||
|
|
@ -500,6 +485,73 @@ textarea {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -3,52 +3,51 @@ import { FIGHTER, SPAWN } from "../constants.js";
|
|||
const STORAGE_KEYS = {
|
||||
names: "arena.match.playerNames",
|
||||
spawnPlacement: "arena.match.spawnPlacement",
|
||||
teamSize: "arena.match.teamSize",
|
||||
};
|
||||
|
||||
export function createMatchForm() {
|
||||
const form = getElement("#fighter-form");
|
||||
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 statusNode = document.querySelector("#match-status");
|
||||
const statusTextNodes = document.querySelectorAll("[data-status-text]");
|
||||
const spawnPlacementInputs = getElements('input[name="spawnPlacement"]');
|
||||
const teamSizeInput = getElement("#team-size");
|
||||
const teamSizeNumberInput = getElement("#team-size-value");
|
||||
const setPlayerNamesWarning = (warning = null) => {
|
||||
const hasWarning = Boolean(warning);
|
||||
|
||||
applyTeamSizeInputLimits(teamSizeInput, teamSizeNumberInput);
|
||||
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 = () => ({
|
||||
names: nicknameValues(namesInput.value),
|
||||
spawnPlacement: selectedSpawnPlacement(spawnPlacementInputs),
|
||||
teamSize: Number(teamSizeInput.value),
|
||||
});
|
||||
|
||||
restoreSavedMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput, teamSizeNumberInput);
|
||||
syncTeamSizeInputs(teamSizeInput, teamSizeNumberInput);
|
||||
restoreSavedMatchSettings(namesInput, spawnPlacementInputs);
|
||||
namesInput.addEventListener("input", () => {
|
||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
||||
});
|
||||
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);
|
||||
setPlayerNamesWarning();
|
||||
saveMatchSettings(namesInput, spawnPlacementInputs);
|
||||
});
|
||||
spawnPlacementInputs.forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput);
|
||||
saveMatchSettings(namesInput, spawnPlacementInputs);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -60,6 +59,7 @@ export function createMatchForm() {
|
|||
});
|
||||
},
|
||||
readMatchConfig,
|
||||
setPlayerNamesWarning,
|
||||
setStatus(message) {
|
||||
if (statusNode) {
|
||||
statusNode.setAttribute("aria-hidden", "false");
|
||||
|
|
@ -102,34 +102,7 @@ function nicknameValues(value) {
|
|||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function applyTeamSizeInputLimits(...inputs) {
|
||||
inputs.forEach((input) => {
|
||||
input.min = "1";
|
||||
input.max = String(SPAWN.MAX_TEAM_SIZE);
|
||||
input.step = "1";
|
||||
input.value = String(SPAWN.DEFAULT_TEAM_SIZE);
|
||||
});
|
||||
}
|
||||
|
||||
function syncTeamSizeInputs(rangeInput, numberInput, value = rangeInput.value) {
|
||||
const normalizedTeamSize = normalizeTeamSize(value, rangeInput);
|
||||
|
||||
if (!normalizedTeamSize) {
|
||||
return "";
|
||||
}
|
||||
|
||||
rangeInput.value = normalizedTeamSize;
|
||||
numberInput.value = normalizedTeamSize;
|
||||
|
||||
return normalizedTeamSize;
|
||||
}
|
||||
|
||||
function restoreSavedMatchSettings(
|
||||
namesInput,
|
||||
spawnPlacementInputs,
|
||||
teamSizeInput,
|
||||
teamSizeNumberInput,
|
||||
) {
|
||||
function restoreSavedMatchSettings(namesInput, spawnPlacementInputs) {
|
||||
const storage = getLocalStorage();
|
||||
|
||||
if (!storage) {
|
||||
|
|
@ -139,27 +112,18 @@ function restoreSavedMatchSettings(
|
|||
try {
|
||||
const savedNames = storage.getItem(STORAGE_KEYS.names);
|
||||
const savedSpawnPlacement = storage.getItem(STORAGE_KEYS.spawnPlacement);
|
||||
const savedTeamSize = storage.getItem(STORAGE_KEYS.teamSize);
|
||||
|
||||
if (savedNames !== null) {
|
||||
namesInput.value = savedNames;
|
||||
}
|
||||
|
||||
const normalizedTeamSize = normalizeTeamSize(savedTeamSize, teamSizeInput);
|
||||
|
||||
syncTeamSizeInputs(
|
||||
teamSizeInput,
|
||||
teamSizeNumberInput,
|
||||
normalizedTeamSize || teamSizeInput.value,
|
||||
);
|
||||
|
||||
setSpawnPlacement(spawnPlacementInputs, savedSpawnPlacement);
|
||||
} catch {
|
||||
// Storage may be unavailable in private or restricted browser contexts.
|
||||
}
|
||||
}
|
||||
|
||||
function saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput) {
|
||||
function saveMatchSettings(namesInput, spawnPlacementInputs) {
|
||||
const storage = getLocalStorage();
|
||||
|
||||
if (!storage) {
|
||||
|
|
@ -169,7 +133,6 @@ function saveMatchSettings(namesInput, spawnPlacementInputs, teamSizeInput) {
|
|||
try {
|
||||
storage.setItem(STORAGE_KEYS.names, namesInput.value);
|
||||
storage.setItem(STORAGE_KEYS.spawnPlacement, selectedSpawnPlacement(spawnPlacementInputs));
|
||||
storage.setItem(STORAGE_KEYS.teamSize, normalizeTeamSize(teamSizeInput.value, teamSizeInput));
|
||||
} catch {
|
||||
// Ignore storage failures so the match form remains usable.
|
||||
}
|
||||
|
|
@ -189,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() {
|
||||
try {
|
||||
return window.localStorage;
|
||||
|
|
|
|||
20
todo.md
20
todo.md
|
|
@ -291,3 +291,23 @@
|
|||
- 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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue