feat: implement nickname multiplier (*N) for variable team sizes and sudden death system with configurable world effects

This commit is contained in:
Horoli 2026-05-25 21:24:22 +09:00
parent f9b548f9cd
commit 743b2a75f5
8 changed files with 199 additions and 45 deletions

View File

@ -56,9 +56,27 @@
│ │ ├── fighterManifest.js # 20종 캐릭터 스탯/특성 상세 정의 │ │ ├── fighterManifest.js # 20종 캐릭터 스탯/특성 상세 정의
│ │ ├── fighterStats.js # 근접/원거리/마법 프로필 판별 및 스탯 해석 │ │ ├── fighterStats.js # 근접/원거리/마법 프로필 판별 및 스탯 해석
│ │ └── fighterSelection.js # 캐릭터 스킨 무작위 선택 로직 │ │ └── fighterSelection.js # 캐릭터 스킨 무작위 선택 로직
│ └── match/ # 매치 및 진행 ├── match/ # 매치 및 진행
│ ├── matchSetup.js # 팀 구성 및 스폰 좌표 계산 (스타팅 영역/랜덤) │ ├── matchSetup.js # 팀 구성(닉네임 배수 파싱 포함) 및 스폰 좌표 계산 (스타팅 영역/랜덤)
│ └── arenaMatchRuntime.js # 매치 진행 중 헬퍼 (스폰 클러스터, 팀 크기 동기화) │ └── arenaMatchRuntime.js # 매치 진행 중 헬퍼 (스폰 클러스터, 팀 크기 동기화)
...
## 7. 주요 기능 상세 (New)
### 7.1 닉네임 배수 시스템 (Multi-Spawn)
- 사용자가 닉네임 뒤에 `*N` (예: `홍길동*2`)을 입력하면 해당 팀은 기본 팀 인원의 N배만큼 생성됩니다.
- 스타팅 존 모드에서 배수만큼의 독립된 스폰 지점이 할당되어 전략적인 분산 배치가 이루어집니다.
- 닉네임 표시 시 `*N` 접미사는 자동으로 제거되어 깔끔한 UI를 유지합니다.
### 7.2 서든 데스 (Sudden Death) 시스템
- 매치 시작 후 일정 시간(기본 8초)이 경과하면 전장의 환경이 극도로 위험해지는 서든 데스 상태에 진입합니다.
- 메테오 생성 주기가 비약적으로 단축(기본 1초)되며, 빙결 효과를 가진 냉기 메테오가 집중 투하됩니다.
- `constants.js`를 통해 활성화 여부, 시작 시간, 주기 등을 간편하게 조정할 수 있습니다.
### 7.3 구매 배수 기반 월드 이펙트 표적 가중치
- `닉네임*N`으로 구매한 추가 병력의 수치와 수량은 낮추지 않고 그대로 유지합니다.
- 월드 이펙트는 생존 중인 팀의 구매 배수 지분보다 생존 지분이 높아진 경우에만 해당 팀의 표적 확률을 추가로 높여, 결제 이점은 보존하면서 독주만 랜덤 요소로 견제합니다.
- `WORLD_EFFECT.DOMINANCE_TARGETING_MULTIPLIER``0`이면 기존 생존 유닛 비례 표적 선택이며, 기본값 `1`은 초과 생존 지분에 따른 추가 가중치를 그대로 적용합니다.
└── ui/ # UI 컴포넌트 및 API 연동 └── ui/ # UI 컴포넌트 및 API 연동
├── arenaKillLog.js # [New] 독립된 킬로그 DOM 조작 모듈 ├── arenaKillLog.js # [New] 독립된 킬로그 DOM 조작 모듈
├── arenaScoreboard.js # [New] 팀 스코어 badge 업데이트 모듈 ├── arenaScoreboard.js # [New] 팀 스코어 badge 업데이트 모듈

View File

@ -20,7 +20,12 @@
- **`clampFighterInsideArena()`**: 처치 성장 중 커진 캐릭터가 전장 바깥으로 나가지 않도록 위치를 보정합니다. - **`clampFighterInsideArena()`**: 처치 성장 중 커진 캐릭터가 전장 바깥으로 나가지 않도록 위치를 보정합니다.
### 월드 이펙트 ### 월드 이펙트
- **발동 규칙**: 프리뷰가 아닌 실제 전투에서 전투 시간 4초마다 생존 캐릭터 하나를 무작위로 선택하고, 대상의 당시 위치를 중심으로 메테오 또는 냉각지대 중 하나를 무작위 발동합니다. - **발동 규칙**: 프리뷰가 아닌 실제 전투에서 전투 시간 4초마다 생존 캐릭터 하나를 표적으로 선택하고, 대상의 당시 위치를 중심으로 메테오 또는 냉각지대 중 하나를 무작위 발동합니다.
- **독주 표적 가중치**: `닉네임*N`의 구매 배수는 해당 팀의 정당한 초기 생존 지분으로 취급합니다. 생존 팀 사이에서 현재 생존 지분이 구매 배수 지분을 넘어선 팀만 `WORLD_EFFECT.DOMINANCE_TARGETING_MULTIPLIER`에 따라 추가 표적 확률을 받습니다. 따라서 배수 팀은 시작 시 스탯이나 수량 패널티를 받지 않으며, 전투 중 독주가 생겼을 때만 랜덤 위험이 증가합니다.
- **서든 데스 (Sudden Death)**:
- **조건**: 매치 시작 후 `WORLD_EFFECT.SUDDEN_DEATH.TRIGGER_MS` 시간이 경과하면 서든 데스 상태에 진입합니다 (활성화 시).
- **효과**: 메테오 투하 주기가 `SUDDEN_DEATH.INTERVAL_MS`로 단축되며, `FORCE_FROST` 설정 시 빙결 효과를 가진 냉기 메테오가 집중적으로 생성됩니다.
- **목적**: 장기전을 방지하고 전장에 무작위 변수를 극대화하여 물량 중심 팀에게 리스크를 부여합니다.
- **낙하 방향과 크기**: 대상이 전장 좌측 반면(2, 3사분면)이면 화살표가 좌상단에서 우하단으로, 우측 반면(1, 4사분면)이면 좌우 반전되어 우상단에서 좌하단으로 이동합니다. 스프라이트를 45도로 기울이고 전용 시각 배율을 사용해 전역 마법 규모로 표현합니다. - **낙하 방향과 크기**: 대상이 전장 좌측 반면(2, 3사분면)이면 화살표가 좌상단에서 우하단으로, 우측 반면(1, 4사분면)이면 좌우 반전되어 우상단에서 좌하단으로 이동합니다. 스프라이트를 45도로 기울이고 전용 시각 배율을 사용해 전역 마법 규모로 표현합니다.
- **화염 메테오**: `world_Effect.png`의 7프레임 애니메이션이 낙하하면 화면 흔들림을 적용하고, 5x5 타일 영역의 생존자에게 고정 피해를 줍니다. 자동 관전 진입 전에는 `CAMERA.METEOR_FOCUS_ENABLED`가 켜져 있을 때 착탄 위치를 임시 포커싱합니다. 환경 피해로 인한 사망은 킬 보상을 지급하지 않지만 사망 통계와 승패 판정에는 반영됩니다. - **화염 메테오**: `world_Effect.png`의 7프레임 애니메이션이 낙하하면 화면 흔들림을 적용하고, 5x5 타일 영역의 생존자에게 고정 피해를 줍니다. 자동 관전 진입 전에는 `CAMERA.METEOR_FOCUS_ENABLED`가 켜져 있을 때 착탄 위치를 임시 포커싱합니다. 환경 피해로 인한 사망은 킬 보상을 지급하지 않지만 사망 통계와 승패 판정에는 반영됩니다.
- **냉기 메테오**: `world_Effect_2.png`의 7프레임 애니메이션으로 착탄을 표시하고, 자동 관전 진입 전에는 같은 설정에 따라 착탄 위치를 임시 포커싱합니다. 착탄 시 별도 조정 가능한 피해를 주며, 생존한 피격 대상은 캐릭터 본체와 실루엣이 얼음색으로 바뀐 채 2초 동안 기절합니다. 이후 남은 5x5 냉각지대 안에서는 공격속도와 이동속도 감속 배율을 적용하며, 영역을 벗어나거나 지속시간이 끝나면 배율을 복구합니다. - **냉기 메테오**: `world_Effect_2.png`의 7프레임 애니메이션으로 착탄을 표시하고, 자동 관전 진입 전에는 같은 설정에 따라 착탄 위치를 임시 포커싱합니다. 착탄 시 별도 조정 가능한 피해를 주며, 생존한 피격 대상은 캐릭터 본체와 실루엣이 얼음색으로 바뀐 채 2초 동안 기절합니다. 이후 남은 5x5 냉각지대 안에서는 공격속도와 이동속도 감속 배율을 적용하며, 영역을 벗어나거나 지속시간이 끝나면 배율을 복구합니다.
@ -34,5 +39,10 @@
## 3. 유지보수 규칙 ## 3. 유지보수 규칙
- **처치 성장 상한**: `src/constants.js``KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다. - **처치 성장 상한**: `src/constants.js``KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
- **공격력 조정**: `src/constants.js``FIGHTER_TYPE_STATS.<type>.damageMin/damageMax`를 수정합니다. - **공격력 조정**: `src/constants.js``FIGHTER_TYPE_STATS.<type>.damageMin/damageMax`를 수정합니다.
- **월드 이펙트 조정**: `src/constants.js``WORLD_EFFECT.METEOR_DAMAGE``WORLD_EFFECT.FROST_DAMAGE`로 두 메테오 피해를 각각 조정하고, `WORLD_EFFECT.FROST_STUN_DURATION`/`FROST_STUN_TINT`로 동결 시간과 표시 색상을 조정합니다. 나머지 `WORLD_EFFECT.*` 값으로 발동 주기, 범위, 냉각 지속시간과 감속 정도를 수정하며, 메테오 착탄 위치 포커싱은 `CAMERA.METEOR_FOCUS_ENABLED`에서 켜고 끕니다. - **월드 이펙트 및 서든 데스 조정**:
- `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`은 계산된 초과 비율을 그대로 반영합니다.
- `WORLD_EFFECT.FROST_STUN_DURATION`/`FROST_STUN_TINT`로 동결 시간과 표시 색상을 조정합니다.
- 나머지 `WORLD_EFFECT.*` 값으로 발동 주기, 범위, 냉각 지속시간과 감속 정도를 수정하며, 메테오 착탄 위치 포커싱은 `CAMERA.METEOR_FOCUS_ENABLED`에서 켜고 끕니다.
- **특수 규칙**: 캐릭터별 특수 공격 방식은 `fighterManifest.js``combat` 설정을 확인합니다. - **특수 규칙**: 캐릭터별 특수 공격 방식은 `fighterManifest.js``combat` 설정을 확인합니다.

View File

@ -10,7 +10,7 @@
- `FIGHTER_TYPE_STATS`: `melee`, `ranged`, `magic`별 최대 체력, 이동속도, 사거리, 쿨다운, 피해량, 치명타 및 공격 발동 지연 기본값. - `FIGHTER_TYPE_STATS`: `melee`, `ranged`, `magic`별 최대 체력, 이동속도, 사거리, 쿨다운, 피해량, 치명타 및 공격 발동 지연 기본값.
- `FIGHTER_HITBOX_*`: 100x100 캐릭터 프레임 안에서 실제 충돌 판정이 놓이는 위치와 크기. - `FIGHTER_HITBOX_*`: 100x100 캐릭터 프레임 안에서 실제 충돌 판정이 놓이는 위치와 크기.
- `KILL_HEALTH_RECOVERY_RATIO`, `KILL_GROWTH_MULTIPLIER`, `KILL_GROWTH_MAX_MULTIPLIER`: 처치 후 회복량, 크기/공격속도/이동속도 성장 배율, 누적 보상 상한. - `KILL_HEALTH_RECOVERY_RATIO`, `KILL_GROWTH_MULTIPLIER`, `KILL_GROWTH_MAX_MULTIPLIER`: 처치 후 회복량, 크기/공격속도/이동속도 성장 배율, 누적 보상 상한.
- `WORLD_EFFECT.*`: 월드 이펙트 발동 간격, 5x5 범위, 대각선 낙하 거리/시각 배율, 화염/냉기 메테오 피해량, 냉기 동결 시간/실루엣 색상, 냉각지대 지속시간과 감속 배율. - `WORLD_EFFECT.*`: 월드 이펙트 발동 간격, 범위, 대각선 낙하 거리/시각 배율, 구매 배수 대비 독주 팀 표적 가중치, 화염/냉기 메테오 피해량, 냉기 동결 시간/실루엣 색상, 냉각지대 지속시간과 감속 배율.
- `SELECTED_FIGHTER_OUTLINE_GAP`, `SELECTED_FIGHTER_OUTLINE_WIDTH`, `SELECTED_FIGHTER_OUTLINE_ALPHA`: 팀 색상 실루엣 마커의 캐릭터 이격 거리, 두께, 투명도. - `SELECTED_FIGHTER_OUTLINE_GAP`, `SELECTED_FIGHTER_OUTLINE_WIDTH`, `SELECTED_FIGHTER_OUTLINE_ALPHA`: 팀 색상 실루엣 마커의 캐릭터 이격 거리, 두께, 투명도.
- `TEAM_COLORS`, `getTeamColor()`: 8팀 이하에서는 기본 팔레트를 쓰고, 9팀 이상에서는 팀 수에 맞춰 중복 없는 색상을 동적으로 생성합니다. - `TEAM_COLORS`, `getTeamColor()`: 8팀 이하에서는 기본 팔레트를 쓰고, 9팀 이상에서는 팀 수에 맞춰 중복 없는 색상을 동적으로 생성합니다.
- `CAMERA.SPECTATOR_LERP`: 카메라 추적의 부드러움 정도. - `CAMERA.SPECTATOR_LERP`: 카메라 추적의 부드러움 정도.
@ -27,6 +27,6 @@
- **물리 수치 조정**: 역할별 기본 체력/속도/사거리/공격 수치는 `src/constants.js``FIGHTER_TYPE_STATS`에서 변경하고, 특정 스킨만 다르게 할 때는 `fighterManifest.js``stats` 또는 `combat` 설정을 사용하십시오. - **물리 수치 조정**: 역할별 기본 체력/속도/사거리/공격 수치는 `src/constants.js``FIGHTER_TYPE_STATS`에서 변경하고, 특정 스킨만 다르게 할 때는 `fighterManifest.js``stats` 또는 `combat` 설정을 사용하십시오.
- **처치 성장 상한 조정**: 처치 보상으로 캐릭터가 커지는 최대치와 공격/이동 배율 상한은 `src/constants.js``KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다. - **처치 성장 상한 조정**: 처치 보상으로 캐릭터가 커지는 최대치와 공격/이동 배율 상한은 `src/constants.js``KILL_GROWTH_MAX_MULTIPLIER`를 수정합니다.
- **공격력 조정**: 역할별 기본 피해량은 `src/constants.js``FIGHTER_TYPE_STATS.<type>.damageMin/damageMax`를 수정합니다. 캐릭터별 특수 공격 방식은 `fighterManifest.js``combat` 설정을 우선 확인합니다. - **공격력 조정**: 역할별 기본 피해량은 `src/constants.js``FIGHTER_TYPE_STATS.<type>.damageMin/damageMax`를 수정합니다. 캐릭터별 특수 공격 방식은 `fighterManifest.js``combat` 설정을 우선 확인합니다.
- **월드 이펙트 조정**: `src/constants.js``WORLD_EFFECT.INTERVAL`, `WORLD_EFFECT.FALL_TRAVEL_TILES`, `WORLD_EFFECT.VISUAL_SCALE`, `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`를 수정합니다. 임시 메테오 카메라는 `CAMERA.METEOR_FOCUS_ENABLED`로 끌 수 있습니다. - **월드 이펙트 조정**: `src/constants.js``WORLD_EFFECT.INTERVAL`, `WORLD_EFFECT.FALL_TRAVEL_TILES`, `WORLD_EFFECT.VISUAL_SCALE`, `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`로 끌 수 있습니다.
- **DOM 접근**: 성능을 위해 `ArenaScene`은 좌측 HUD badge 등 필요한 시점에만 최소한으로 DOM에 접근합니다. - **DOM 접근**: 성능을 위해 `ArenaScene`은 좌측 HUD badge 등 필요한 시점에만 최소한으로 DOM에 접근합니다.
- **패키지 락 파일**: 이 프로젝트는 `package-lock.json`을 저장소에서 제외합니다. 의존성 변경 시 `package.json`을 기준으로 관리합니다. - **패키지 락 파일**: 이 프로젝트는 `package-lock.json`을 저장소에서 제외합니다. 의존성 변경 시 `package.json`을 기준으로 관리합니다.

View File

@ -17,8 +17,9 @@
## 2. 주요 로직 구현 세부 사항 ## 2. 주요 로직 구현 세부 사항
### 매치 설정 및 스폰 배치 ### 매치 설정 및 스폰 배치
- **완전 랜덤 배치**: 전장 전체 스폰 슬롯을 무작위로 섞어 배치합니다. - **닉네임 배수 시스템**: `닉네임*배수` 형식(예: `Alice*2`)을 감지하여 팀 인원을 배수만큼 생성합니다.
- **스타팅 지점 배치**: 팀마다 전장 스폰 가능 그리드에서 중심 셀을 무작위로 고르고, 중심 주변 2칸(`5 x 5`)을 해당 팀의 스타팅 영역으로 사용합니다. 겹치지 않는 후보가 남아 있는 동안에는 해당 후보를 우선 선택하며, 영역은 매치 시작 후 5초 동안만 팀 색상으로 매우 옅게 표시되고 팀 전투원은 이 안에서만 스폰합니다. - **구매 배수 보존과 독주 견제**: 배수 팀의 생성 인원과 전투 수치는 결제 이점으로 유지합니다. 월드 이펙트 표적 선정에서는 `team.multiplier`를 구매 지분으로 사용하고, 그 지분을 초과해 생존 중인 팀에만 설정 가능한 추가 표적 가중치를 적용합니다.
- **스타팅 지점 배치 (멀티 스폰)**: 팀마다 전장 스폰 가능 그리드에서 중심 셀을 무작위로 고르고, 중심 주변 2칸(`5 x 5`)을 해당 팀의 스타팅 영역으로 사용합니다. 배수가 설정된 팀은 배수만큼의 독립적인 스타팅 영역을 할당받아 병력이 분산 배치됩니다. 겹치지 않는 후보가 남아 있는 동안에는 해당 후보를 우선 선택하며, 영역은 매치 시작 후 5초 동안만 팀 색상으로 매우 옅게 표시되고 팀 전투원은 이 안에서만 스폰합니다.
- **설정 유지**: 닉네임, 인원, 배치 모드는 `localStorage`에 저장되어 재접속 시 복원됩니다. - **설정 유지**: 닉네임, 인원, 배치 모드는 `localStorage`에 저장되어 재접속 시 복원됩니다.
### 전투 화면 레이아웃 (HUD) ### 전투 화면 레이아웃 (HUD)

View File

@ -21,7 +21,7 @@ export const FIGHTER = {
HITBOX_HEIGHT: 20, HITBOX_HEIGHT: 20,
HITBOX_OFFSET_X: 39, HITBOX_OFFSET_X: 39,
HITBOX_OFFSET_Y: 40, HITBOX_OFFSET_Y: 40,
NICKNAME_LENGTH: 18, NICKNAME_LENGTH: 24,
// 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수 // 캐릭터 액션별 애니메이션 프레임 속도와 반복 횟수
ANIMATION_OPTIONS: { ANIMATION_OPTIONS: {
attack: { frameRate: 15, repeat: 0 }, attack: { frameRate: 15, repeat: 0 },
@ -117,18 +117,27 @@ export const PROJECTILE = {
// 6. WORLD_EFFECT 도메인 // 6. WORLD_EFFECT 도메인
export const WORLD_EFFECT = { export const WORLD_EFFECT = {
INTERVAL: 4000, INTERVAL: 4000,
AREA_TILES: 5, AREA_TILES: 15,
FRAMES: 7, FRAMES: 7,
FRAME_RATE: 14, FRAME_RATE: 14,
FALL_DURATION: 920, FALL_DURATION: 920,
FALL_TRAVEL_TILES: 8, FALL_TRAVEL_TILES: 8,
VISUAL_SCALE: 12, VISUAL_SCALE: 50,
METEOR_DAMAGE: 80, // 0 keeps target selection proportional to living units.
FROST_DAMAGE: 40, // 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, FROST_STUN_DURATION: 2000,
FROST_STUN_TINT: 0x82e9ff, FROST_STUN_TINT: 0x82e9ff,
FROST_DURATION: 20000, FROST_DURATION: 20000,
FROST_SPEED_MULTIPLIER: 0.55, FROST_SPEED_MULTIPLIER: 0.55,
SUDDEN_DEATH: {
ENABLED: false,
TRIGGER_MS: 10000,
INTERVAL_MS: 2000,
FORCE_FROST: false,
},
}; };
// 7. CAMERA 도메인 // 7. CAMERA 도메인

View File

@ -57,11 +57,27 @@ export function startWorldEffects(scene) {
return; return;
} }
scene.worldEffectTimer = scene.time.addEvent({ scene.matchStartedAt = scene.time.now;
callback: () => triggerWorldEffect(scene), scene.isSuddenDeath = false;
delay: WORLD_EFFECT.INTERVAL,
loop: true, const scheduleNext = () => {
}); 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;
if (isSuddenDeath && !scene.isSuddenDeath) {
scene.isSuddenDeath = true;
}
scene.worldEffectTimer = scene.time.delayedCall(delay, () => {
triggerWorldEffect(scene);
scheduleNext();
});
};
scheduleNext();
} }
export function clearWorldEffects(scene) { export function clearWorldEffects(scene) {
@ -69,6 +85,8 @@ export function clearWorldEffects(scene) {
scene.worldEffectTimer = null; scene.worldEffectTimer = null;
scene.worldEffectZones?.clear(); scene.worldEffectZones?.clear();
scene.clearMeteorCameraFocus?.(null, { restoreCamera: false }); scene.clearMeteorCameraFocus?.(null, { restoreCamera: false });
scene.matchStartedAt = null;
scene.isSuddenDeath = false;
scene.fighters?.forEach((fighter) => { scene.fighters?.forEach((fighter) => {
fighter.worldEffectSpeedMultiplier = 1; fighter.worldEffectSpeedMultiplier = 1;
@ -106,15 +124,85 @@ function triggerWorldEffect(scene) {
return; return;
} }
const target = livingFighters[Phaser.Math.Between(0, livingFighters.length - 1)]; const target = chooseWorldEffectTarget(livingFighters);
const zone = createEffectZone(target); const zone = createEffectZone(target);
if (Phaser.Math.Between(0, 1) === 0) { // Sudden Death 상태이고 냉기 고정 설정이 되어있으면 무조건 냉기 메테오
spawnMeteor(scene, zone); if ((scene.isSuddenDeath && WORLD_EFFECT.SUDDEN_DEATH.FORCE_FROST) || Phaser.Math.Between(0, 1) === 0) {
spawnFrostZone(scene, zone);
return; return;
} }
spawnFrostZone(scene, zone); spawnMeteor(scene, zone);
}
export function chooseWorldEffectTarget(livingFighters) {
if (livingFighters.length === 0) {
return null;
}
const dominanceMultiplier = Math.max(
0,
Number(WORLD_EFFECT.DOMINANCE_TARGETING_MULTIPLIER) || 0,
);
if (dominanceMultiplier === 0) {
return randomEntry(livingFighters);
}
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);
});
return randomEntry(weightedEntry(teamPools).fighters);
}
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];
}
function randomEntry(entries) {
return entries[Phaser.Math.Between(0, entries.length - 1)];
} }
function spawnMeteor(scene, zone) { function spawnMeteor(scene, zone) {

View File

@ -1,37 +1,48 @@
import { ARENA, SPAWN, TEAM } from "../../constants.js"; import { ARENA, SPAWN, TEAM } from "../../constants.js";
const NAME_MULTIPLIER_REGEX = /\*(\d+)$/;
export function createMatchSetup( export function createMatchSetup(
names, names,
requestedTeamSize = SPAWN.DEFAULT_TEAM_SIZE, requestedTeamSize = SPAWN.DEFAULT_TEAM_SIZE,
requestedSpawnPlacement = SPAWN.DEFAULT_PLACEMENT, requestedSpawnPlacement = SPAWN.DEFAULT_PLACEMENT,
) { ) {
const teamSize = Math.max(1, Math.round(Number(requestedTeamSize) || SPAWN.DEFAULT_TEAM_SIZE)); const baseTeamSize = Math.max(1, Math.round(Number(requestedTeamSize) || SPAWN.DEFAULT_TEAM_SIZE));
const teams = names.map((name, index) => ({ const teams = names.map((rawName, index) => {
color: TEAM.getColor(index, names.length), const match = rawName.match(NAME_MULTIPLIER_REGEX);
id: `team-${index + 1}`, const multiplier = match ? Math.max(1, parseInt(match[1], 10)) : 1;
label: name, const label = match ? rawName.replace(NAME_MULTIPLIER_REGEX, "") : rawName;
size: teamSize,
})); return {
color: TEAM.getColor(index, names.length),
id: `team-${index + 1}`,
label,
multiplier,
size: baseTeamSize * multiplier,
};
});
const startingZones = const startingZones =
requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES
? createStartingZones(teams) ? createStartingZones(teams)
: []; : [];
const totalFighters = teams.reduce((sum, team) => sum + team.size, 0);
const spawns = createSpawnPoints( const spawns = createSpawnPoints(
names.length, totalFighters,
teamSize, baseTeamSize,
requestedSpawnPlacement, requestedSpawnPlacement,
startingZones, startingZones,
); );
const fighters = []; const fighters = [];
names.forEach((name, teamIndex) => { teams.forEach((team) => {
for (let i = 0; i < teamSize; i++) { for (let i = 0; i < team.size; i++) {
const globalIndex = teamIndex * teamSize + i; const globalIndex = fighters.length;
fighters.push({ fighters.push({
...spawns[globalIndex], ...spawns[globalIndex],
name: name, name: team.label,
team: teams[teamIndex], team: team,
teamIndex: i, teamIndex: i,
}); });
} }
@ -64,12 +75,12 @@ function createTeams(playerCount, teamSize) {
})); }));
} }
function createSpawnPoints(teamCount, teamSize, requestedSpawnPlacement, startingZones) { function createSpawnPoints(totalCount, teamSize, requestedSpawnPlacement, startingZones) {
if (requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES) { if (requestedSpawnPlacement === SPAWN.PLACEMENTS.STARTING_ZONES) {
return createStartingZoneSpawnPoints(startingZones, teamSize); return createStartingZoneSpawnPoints(startingZones, teamSize);
} }
return createRandomSpawnPoints(teamCount * teamSize); return createRandomSpawnPoints(totalCount);
} }
function createRandomSpawnPoints(count) { function createRandomSpawnPoints(count) {
@ -86,13 +97,24 @@ function createStartingZoneSpawnPoints(startingZones, teamSize) {
} }
function createStartingZones(teams) { function createStartingZones(teams) {
const layout = shuffle(createStartingZoneLayout(teams.length)); const totalZonesNeeded = teams.reduce((sum, team) => sum + (team.multiplier || 1), 0);
const layout = shuffle(createStartingZoneLayout(totalZonesNeeded));
return teams.map((team, index) => ({ let layoutIndex = 0;
...layout[index], return teams.flatMap((team) => {
color: team.color, const multiplier = team.multiplier || 1;
teamId: team.id, const teamZones = [];
}));
for (let i = 0; i < multiplier; i++) {
teamZones.push({
...layout[layoutIndex++],
color: team.color,
teamId: team.id,
});
}
return teamZones;
});
} }
function createStartingZoneLayout(teamCount) { function createStartingZoneLayout(teamCount) {

View File

@ -265,4 +265,10 @@
- 사망 통계만 보여주던 공지 UI에 게임 시스템 가이드(화염/냉기 메테오 특성, 밀리 치명타 확률 등) 팁을 추가. - 사망 통계만 보여주던 공지 UI에 게임 시스템 가이드(화염/냉기 메테오 특성, 밀리 치명타 확률 등) 팁을 추가.
- 사망 통계 공지 2회당 1회의 비율로 시스템 팁이 교차 출력되도록 로직 개선. - 사망 통계 공지 2회당 1회의 비율로 시스템 팁이 교차 출력되도록 로직 개선.
43. 구매 배수 기반 월드 이펙트 독주 표적 가중치 추가 (완료)
- **조치 사항**:
- `닉네임*N`으로 구매한 추가 병력은 수량과 전투 수치를 낮추지 않고 그대로 유지.
- 생존 팀별 구매 배수 지분과 현재 생존 지분을 비교해, 구매 지분을 초과해 살아남은 팀에만 월드 이펙트 표적 가중치를 추가.
- `WORLD_EFFECT.DOMINANCE_TARGETING_MULTIPLIER`를 추가하고 기본값을 `1`로 설정하여 초과 생존 지분 기반 압력을 활성화하며, `0`으로 설정하면 기존 생존 유닛 비례 표적 선택으로 복귀하도록 구성.