diff --git a/agent.md b/agent.md index 0f6b638..4ad843f 100644 --- a/agent.md +++ b/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 조작 모듈 diff --git a/context/arena.md b/context/arena.md index 9274034..2dab86e 100644 --- a/context/arena.md +++ b/context/arena.md @@ -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 타이머를 사용하므로 일시정지 시간은 이 간격과 냉각 지속시간에 포함되지 않습니다. diff --git a/context/combat.md b/context/combat.md index 94ce159..5718a5e 100644 --- a/context/combat.md +++ b/context/combat.md @@ -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` 설정을 확인합니다. diff --git a/context/core.md b/context/core.md index aa3aa87..f65d351 100644 --- a/context/core.md +++ b/context/core.md @@ -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..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`을 기준으로 관리합니다. diff --git a/context/match-ui.md b/context/match-ui.md index 5ff5fd7..308ffc3 100644 --- a/context/match-ui.md +++ b/context/match-ui.md @@ -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/`) diff --git a/index.html b/index.html index 5ad0251..f38b5b6 100644 --- a/index.html +++ b/index.html @@ -162,8 +162,13 @@
Players - - +
Match -
- - -
-
리스폰 설정 {}; 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) { diff --git a/src/game/combat/combat.js b/src/game/combat/combat.js index 86ba52f..faf1c27 100644 --- a/src/game/combat/combat.js +++ b/src/game/combat/combat.js @@ -223,15 +223,17 @@ function spawnProjectile(scene, attacker, defender, onWinner, matchId, attack) { } function spawnSpellEffect(scene, attacker, defender, onWinner, matchId, attack) { - const effect = scene.add.sprite(defender.x, defender.y, fighterAttackEffectKey(attacker.skin)); - effect.setDepth(3); - effect.setScale(FIGHTER.SCALE); - effect.play(fighterAttackEffectAnimationKey(attacker.skin)); - trackCombatObject(scene, effect); + if (shouldRenderCombatEffects(scene)) { + const effect = scene.add.sprite(defender.x, defender.y, fighterAttackEffectKey(attacker.skin)); + effect.setDepth(3); + effect.setScale(FIGHTER.SCALE); + effect.play(fighterAttackEffectAnimationKey(attacker.skin)); + trackCombatObject(scene, effect); - effect.once(Phaser.Animations.Events.ANIMATION_COMPLETE, () => { - disposeCombatObject(scene, effect); - }); + 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); diff --git a/src/game/combat/worldEffects.js b/src/game/combat/worldEffects.js index b87e5b9..e938db6 100644 --- a/src/game/combat/worldEffects.js +++ b/src/game/combat/worldEffects.js @@ -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 = []; -function resolvePurchasedWeight(team) { - return Math.max(1, Number(team?.multiplier) || 1); -} + for (let row = 0; row <= maximumOrigin; row += 1) { + for (let column = 0; column <= maximumOrigin; column += 1) { + const count = sumArea(densitySums, column, row, areaTiles); -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; + if (count > highestCount) { + highestCount = count; + densestOrigins = [{ column, row }]; + } else if (count === highestCount) { + densestOrigins.push({ column, row }); + } } } - 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, - onCancel: () => { - scene.clearMeteorCameraFocus?.(zone); - disposeCombatObject(scene, marker); - }, - onImpact: () => { - applyMeteorImpactShake(scene, zone); - resolveImpactDamage(scene, zone, WORLD_EFFECT.FROST_DAMAGE, (fighter) => { - applyFrostStun(scene, fighter); - }); + isFrost: true, + }); +} - if (!scene.matchOver) { - activateFrostZone(scene, zone, marker); +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; } - }, - onAnimationComplete: () => scene.clearMeteorCameraFocus?.(zone), + + 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, + damage, + isFrost ? (fighter) => applyFrostStun(scene, fighter) : undefined, + ); + + if (isFrost && !scene.matchOver) { + activateFrostZone(scene, impactZone, impactMarker); + } + }, + 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; diff --git a/src/game/match/matchSetup.js b/src/game/match/matchSetup.js index 4858279..71278da 100644 --- a/src/game/match/matchSetup.js +++ b/src/game/match/matchSetup.js @@ -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; } diff --git a/src/main.js b/src/main.js index cf83ac2..df2fc8c 100644 --- a/src/main.js +++ b/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, }); diff --git a/src/styles/mobile.css b/src/styles/mobile.css index 5adf976..7caea4c 100644 --- a/src/styles/mobile.css +++ b/src/styles/mobile.css @@ -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; diff --git a/src/styles/overlay.css b/src/styles/overlay.css index bdbd4b3..bbf8387 100644 --- a/src/styles/overlay.css +++ b/src/styles/overlay.css @@ -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; diff --git a/src/ui/matchForm.js b/src/ui/matchForm.js index c42d215..23c42af 100644 --- a/src/ui/matchForm.js +++ b/src/ui/matchForm.js @@ -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; diff --git a/todo.md b/todo.md index f3486fd..06dae5f 100644 --- a/todo.md +++ b/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.