Merge branch 'wl/systems/WL-815b-stage-director'
This commit is contained in:
commit
669501a447
|
|
@ -0,0 +1,44 @@
|
|||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 8d815b01a1b2c3d4e5f60708090a0b13, type: 3}
|
||||
m_Name: WLStageSettings
|
||||
m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Stage.WLStageSettings
|
||||
enabled: 1
|
||||
verboseLog: 0
|
||||
pollSeconds: 0.1
|
||||
tickIntervalSeconds: 1
|
||||
pauseTimerWhileWorldHeld: 1
|
||||
autoEnterOnMapEnter: 0
|
||||
warpPlayerToAnchor: 1
|
||||
useBarrierHook: 1
|
||||
clearBarrierOnExit: 1
|
||||
stopRespawnCoroutines: 1
|
||||
respawnStopRequiresLoadComplete: 1
|
||||
warpSpawnsToAnchor: 1
|
||||
spawnScatterFactor: 0.7
|
||||
resolveSpawnOnNavMesh: 1
|
||||
recallExcess: 1
|
||||
maxSpawnersPerStage: 8
|
||||
failOnPlayerDeath: 1
|
||||
retryRecallsMonsters: 1
|
||||
retryResetsPotions: 1
|
||||
retryHealsPlayer: 1
|
||||
bindReviveOkToRetry: 1
|
||||
clearHoldScale: 0
|
||||
mirrorRunEnded: 1
|
||||
mirrorRunHudEvents: 1
|
||||
cardOnStageClear: 1
|
||||
wrapToFirstStage: 0
|
||||
suppressRunDirector: 1
|
||||
disableRunGuide: 1
|
||||
resetRunDirectorOnEnter: 1
|
||||
toLobbyCallsReturnToLobby: 0
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8d815b01a1b2c3d4e5f60708090a0b18
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 8d815b01a1b2c3d4e5f60708090a0b12, type: 3}
|
||||
m_Name: WLStageTable
|
||||
m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Stage.WLStageTable
|
||||
stageTimeLimitSeconds: 60
|
||||
retryHealRate: 1
|
||||
countdownSeconds: 1.5
|
||||
clearHoldSeconds: 1
|
||||
wavesPerStage: 1
|
||||
waveGapSeconds: 1.5
|
||||
stages:
|
||||
- id: 1
|
||||
mapId: -1
|
||||
anchor: {x: 115, y: 0.4, z: -22}
|
||||
startRadius: 2
|
||||
barrierRadius: 14
|
||||
barrierShape: 0
|
||||
waves:
|
||||
- spawnerId: 813001
|
||||
monsterIds:
|
||||
- 101
|
||||
- 102
|
||||
- 103
|
||||
- 104
|
||||
count: 8
|
||||
elite: 0
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
isBoss: 0
|
||||
bossMonsterId: 0
|
||||
clearRewardCards: 1
|
||||
goldMultiplier: 1
|
||||
recommendPower: 0
|
||||
telegraphScale: 1.2
|
||||
timeLimitOverrideSeconds: 0
|
||||
- id: 2
|
||||
mapId: -1
|
||||
anchor: {x: 71, y: -0.3, z: 18}
|
||||
startRadius: 2
|
||||
barrierRadius: 14
|
||||
barrierShape: 0
|
||||
waves:
|
||||
- spawnerId: 813003
|
||||
monsterIds:
|
||||
- 173
|
||||
- 174
|
||||
- 175
|
||||
- 146
|
||||
- 147
|
||||
- 151
|
||||
count: 10
|
||||
elite: 0
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
isBoss: 0
|
||||
bossMonsterId: 0
|
||||
clearRewardCards: 1
|
||||
goldMultiplier: 1
|
||||
recommendPower: 0
|
||||
telegraphScale: 1.1
|
||||
timeLimitOverrideSeconds: 0
|
||||
- id: 3
|
||||
mapId: -1
|
||||
anchor: {x: 147, y: 2.6, z: 30}
|
||||
startRadius: 2
|
||||
barrierRadius: 14
|
||||
barrierShape: 0
|
||||
waves:
|
||||
- spawnerId: 813002
|
||||
monsterIds:
|
||||
- 168
|
||||
- 169
|
||||
- 170
|
||||
- 171
|
||||
- 172
|
||||
count: 12
|
||||
elite: 0
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
isBoss: 0
|
||||
bossMonsterId: 0
|
||||
clearRewardCards: 1
|
||||
goldMultiplier: 1
|
||||
recommendPower: 0
|
||||
telegraphScale: 1
|
||||
timeLimitOverrideSeconds: 0
|
||||
- id: 4
|
||||
mapId: -1
|
||||
anchor: {x: 159, y: 0.9, z: -22}
|
||||
startRadius: 2
|
||||
barrierRadius: 14
|
||||
barrierShape: 0
|
||||
waves:
|
||||
- spawnerId: 813004
|
||||
monsterIds:
|
||||
- 118
|
||||
- 119
|
||||
- 120
|
||||
count: 8
|
||||
elite: 0
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
- spawnerId: 813001
|
||||
monsterIds:
|
||||
- 101
|
||||
- 102
|
||||
- 103
|
||||
- 104
|
||||
count: 6
|
||||
elite: 0
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
- spawnerId: 813021
|
||||
monsterIds:
|
||||
- 1023
|
||||
count: 1
|
||||
elite: 1
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
isBoss: 0
|
||||
bossMonsterId: 0
|
||||
clearRewardCards: 1
|
||||
goldMultiplier: 1
|
||||
recommendPower: 0
|
||||
telegraphScale: 1
|
||||
timeLimitOverrideSeconds: 0
|
||||
- id: 5
|
||||
mapId: -1
|
||||
anchor: {x: 139, y: 0.8, z: -20}
|
||||
startRadius: 2
|
||||
barrierRadius: 20
|
||||
barrierShape: 0
|
||||
waves:
|
||||
- spawnerId: 813001
|
||||
monsterIds:
|
||||
- 101
|
||||
- 102
|
||||
- 103
|
||||
- 104
|
||||
count: 6
|
||||
elite: 0
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
- spawnerId: 813010
|
||||
monsterIds:
|
||||
- 10006
|
||||
count: 1
|
||||
elite: 0
|
||||
boss: 1
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
isBoss: 1
|
||||
bossMonsterId: 10006
|
||||
clearRewardCards: 1
|
||||
goldMultiplier: 1
|
||||
recommendPower: 0
|
||||
telegraphScale: 1
|
||||
timeLimitOverrideSeconds: 0
|
||||
- id: 6
|
||||
mapId: -1
|
||||
anchor: {x: 55, y: 0.2, z: -38}
|
||||
startRadius: 2
|
||||
barrierRadius: 16
|
||||
barrierShape: 0
|
||||
waves:
|
||||
- spawnerId: 813003
|
||||
monsterIds:
|
||||
- 173
|
||||
- 174
|
||||
- 175
|
||||
- 146
|
||||
- 147
|
||||
- 151
|
||||
count: 14
|
||||
elite: 0
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
- spawnerId: 813001
|
||||
monsterIds:
|
||||
- 101
|
||||
- 102
|
||||
- 103
|
||||
- 104
|
||||
count: 4
|
||||
elite: 0
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
- spawnerId: 813023
|
||||
monsterIds:
|
||||
- 1009
|
||||
count: 1
|
||||
elite: 1
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
isBoss: 0
|
||||
bossMonsterId: 0
|
||||
clearRewardCards: 1
|
||||
goldMultiplier: 1
|
||||
recommendPower: 0
|
||||
telegraphScale: 0.95
|
||||
timeLimitOverrideSeconds: 0
|
||||
- id: 7
|
||||
mapId: -1
|
||||
anchor: {x: 19, y: 0.2, z: 10}
|
||||
startRadius: 2
|
||||
barrierRadius: 16
|
||||
barrierShape: 0
|
||||
waves:
|
||||
- spawnerId: 813002
|
||||
monsterIds:
|
||||
- 168
|
||||
- 169
|
||||
- 170
|
||||
- 171
|
||||
- 172
|
||||
count: 15
|
||||
elite: 0
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
- spawnerId: 813003
|
||||
monsterIds:
|
||||
- 173
|
||||
- 174
|
||||
- 175
|
||||
- 146
|
||||
- 147
|
||||
- 151
|
||||
count: 5
|
||||
elite: 0
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
- spawnerId: 813022
|
||||
monsterIds:
|
||||
- 1028
|
||||
count: 1
|
||||
elite: 1
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
isBoss: 0
|
||||
bossMonsterId: 0
|
||||
clearRewardCards: 1
|
||||
goldMultiplier: 1
|
||||
recommendPower: 0
|
||||
telegraphScale: 0.95
|
||||
timeLimitOverrideSeconds: 0
|
||||
- id: 8
|
||||
mapId: -1
|
||||
anchor: {x: 143, y: 0.4, z: 110}
|
||||
startRadius: 2
|
||||
barrierRadius: 18
|
||||
barrierShape: 0
|
||||
waves:
|
||||
- spawnerId: 813004
|
||||
monsterIds:
|
||||
- 118
|
||||
- 119
|
||||
- 120
|
||||
count: 8
|
||||
elite: 0
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
- spawnerId: 813001
|
||||
monsterIds:
|
||||
- 101
|
||||
- 102
|
||||
- 103
|
||||
- 104
|
||||
count: 12
|
||||
elite: 0
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
- spawnerId: 813002
|
||||
monsterIds:
|
||||
- 168
|
||||
- 169
|
||||
- 170
|
||||
- 171
|
||||
- 172
|
||||
count: 4
|
||||
elite: 0
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
- spawnerId: 813005
|
||||
monsterIds:
|
||||
- 1014
|
||||
- 1015
|
||||
- 1016
|
||||
count: 2
|
||||
elite: 1
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
isBoss: 0
|
||||
bossMonsterId: 0
|
||||
clearRewardCards: 1
|
||||
goldMultiplier: 1
|
||||
recommendPower: 0
|
||||
telegraphScale: 0.9
|
||||
timeLimitOverrideSeconds: 0
|
||||
- id: 9
|
||||
mapId: -1
|
||||
anchor: {x: 67, y: 0.1, z: 110}
|
||||
startRadius: 2
|
||||
barrierRadius: 18
|
||||
barrierShape: 0
|
||||
waves:
|
||||
- spawnerId: 813002
|
||||
monsterIds:
|
||||
- 168
|
||||
- 169
|
||||
- 170
|
||||
- 171
|
||||
- 172
|
||||
count: 15
|
||||
elite: 0
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
- spawnerId: 813003
|
||||
monsterIds:
|
||||
- 173
|
||||
- 174
|
||||
- 175
|
||||
- 146
|
||||
- 147
|
||||
- 151
|
||||
count: 12
|
||||
elite: 0
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
- spawnerId: 813024
|
||||
monsterIds:
|
||||
- 1024
|
||||
count: 1
|
||||
elite: 1
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
isBoss: 0
|
||||
bossMonsterId: 0
|
||||
clearRewardCards: 1
|
||||
goldMultiplier: 1
|
||||
recommendPower: 0
|
||||
telegraphScale: 0.9
|
||||
timeLimitOverrideSeconds: 0
|
||||
- id: 10
|
||||
mapId: -1
|
||||
anchor: {x: 139, y: 0.8, z: -20}
|
||||
startRadius: 2
|
||||
barrierRadius: 20
|
||||
barrierShape: 0
|
||||
waves:
|
||||
- spawnerId: 813001
|
||||
monsterIds:
|
||||
- 101
|
||||
- 102
|
||||
- 103
|
||||
- 104
|
||||
count: 6
|
||||
elite: 0
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
- spawnerId: 813004
|
||||
monsterIds:
|
||||
- 118
|
||||
- 119
|
||||
- 120
|
||||
count: 6
|
||||
elite: 0
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
- spawnerId: 813005
|
||||
monsterIds:
|
||||
- 1014
|
||||
- 1015
|
||||
- 1016
|
||||
count: 1
|
||||
elite: 1
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
- spawnerId: 813024
|
||||
monsterIds:
|
||||
- 1024
|
||||
count: 1
|
||||
elite: 1
|
||||
boss: 0
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
- spawnerId: 813010
|
||||
monsterIds:
|
||||
- 10006
|
||||
count: 1
|
||||
elite: 0
|
||||
boss: 1
|
||||
mode: 0
|
||||
delaySec: 0
|
||||
isBoss: 1
|
||||
bossMonsterId: 10006
|
||||
clearRewardCards: 1
|
||||
goldMultiplier: 1
|
||||
recommendPower: 0
|
||||
telegraphScale: 0.85
|
||||
timeLimitOverrideSeconds: 0
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8d815b01a1b2c3d4e5f60708090a0b17
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -47,7 +47,7 @@ MonoBehaviour:
|
|||
reviveInvincibleSeconds: 1.5
|
||||
reviveHealRate: 1
|
||||
reviveCompanions: 1
|
||||
reviveWatchdogSeconds: 8
|
||||
reviveWatchdogSeconds: 0
|
||||
reviveWatchdogIgnoresDisable: 1
|
||||
reviveWatchdogLog: 1
|
||||
reviveTopmostEnabled: 1
|
||||
|
|
|
|||
|
|
@ -0,0 +1,279 @@
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// StageContracts.cs — 스테이지 축의 **공개 계약**(이벤트 · 상태 · 장벽 훅)
|
||||
//
|
||||
// PD 지시 #815 · 발주서 WL-815b §1 (2026-09-11)
|
||||
// SOT = 기준서 `스테이지_전환_기준서_v1.md` §B-2(상태기계) · §C(스키마) · §E(실패·재도전)
|
||||
//
|
||||
// ■ 이 파일이 계약의 전부다 — 815c(장벽) · 815d(실패/결과 UI) · 815f(텔레그래프)는
|
||||
// `StageEvents` · `StageDirector` 의 정적 게터 · `StageBarrierHook` **만** 본다.
|
||||
// (813p `RunEvents` 와 같은 규약: 정적 클래스 · struct 페이로드를 `in` 으로 · 코어의
|
||||
// `CombatEventList<T>` 재사용 · 구독자 예외는 Dispatch 안에서 격리 · 새 Manager/Singleton 0)
|
||||
//
|
||||
// ■ 장벽(815c)과의 이음매 — `StageBarrierHook`
|
||||
// 이 태스크는 **인터페이스만** 둔다. 815c 가 `StageBarrierHook.Set` / `.Clear` 에 자기 구현을
|
||||
// 대입하면 그 순간부터 장벽이 선다. 아무도 대입하지 않으면 **no-op**(예외 0 · 동작 변경 0).
|
||||
//
|
||||
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 플레이어 팝업을 띄운다.
|
||||
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(Actor/PCActor 가 Assembly-CSharp 에 있다).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
using UnityEngine;
|
||||
using WL.Combat.Core;
|
||||
|
||||
namespace WL.Combat.Stage
|
||||
{
|
||||
/// <summary>스테이지 진행 단계(기준서 §B-2 · 로비는 이 축 밖 = Idle).</summary>
|
||||
public enum StageState
|
||||
{
|
||||
/// <summary>스테이지 밖(로비 · 타이틀 · 종료 후 대기).</summary>
|
||||
Idle = 0,
|
||||
/// <summary>입장 — PC 워프 · 장벽 · 몬스터 고정 수 배치.</summary>
|
||||
Entering = 1,
|
||||
/// <summary>카운트다운(countdownSeconds).</summary>
|
||||
Countdown = 2,
|
||||
/// <summary>전투 — 고정 수 전멸이 승리 · 제한 시간이 흐른다.</summary>
|
||||
Combat = 3,
|
||||
/// <summary>클리어 직후 정지(clearHoldSeconds).</summary>
|
||||
Cleared = 4,
|
||||
/// <summary>결과 — 「다음 스테이지 / 로비로」 대기.</summary>
|
||||
Result = 5,
|
||||
/// <summary>실패 — 「다시 도전 / 로비로」 대기.</summary>
|
||||
Failed = 6,
|
||||
}
|
||||
|
||||
/// <summary>실패 사유(기준서 §E).</summary>
|
||||
public enum StageFailReason
|
||||
{
|
||||
None = 0,
|
||||
/// <summary>PC 사망(`DeathFlow.Died`).</summary>
|
||||
PlayerDied = 1,
|
||||
/// <summary>제한 시간 초과(모든 스테이지 60 s · PD 추가 규칙).</summary>
|
||||
TimeLimit = 2,
|
||||
/// <summary>바깥 사정으로 중단(맵 이탈 · 로비 · 축 off) — 재도전 대상이 아니다.</summary>
|
||||
Aborted = 3,
|
||||
}
|
||||
|
||||
/// <summary>장벽 모양(1차는 Circle 만 · 815c 소관).</summary>
|
||||
public enum StageBarrierShape { Circle = 0, Box = 1 }
|
||||
|
||||
// ───────────────────────────────────────────── 페이로드(struct · 발생 시점 스냅샷 · GC 0)
|
||||
|
||||
/// <summary>스테이지 입장(배치 직후 · 카운트다운 시작 전).</summary>
|
||||
public struct StageEnteredEvent
|
||||
{
|
||||
/// <summary>표 인덱스(0부터) · 표의 `id`(1부터).</summary>
|
||||
public int index, stageId;
|
||||
/// <summary>이 스테이지의 정의(🔴 SO 의 인스턴스 참조 — 읽기만 할 것).</summary>
|
||||
public StageDef def;
|
||||
/// <summary>이번에 실제로 배치한 마리 수(= 승리에 필요한 처치 수).</summary>
|
||||
public int totalEnemies;
|
||||
/// <summary>이 스테이지의 제한 시간(초).</summary>
|
||||
public float timeLimit;
|
||||
/// <summary>고정 영역 중심 · 반경(815c 장벽이 그대로 쓰는 값).</summary>
|
||||
public Vector3 anchor;
|
||||
public float barrierRadius;
|
||||
/// <summary>카운트다운 길이(초).</summary>
|
||||
public float countdownSeconds;
|
||||
/// <summary>같은 스테이지를 몇 번째 도전하는가(0 = 첫 도전).</summary>
|
||||
public int retries;
|
||||
public float time;
|
||||
public int frame;
|
||||
}
|
||||
|
||||
/// <summary>카운트다운 갱신(SO tickIntervalSeconds 주기 + 시작·종료 시점).</summary>
|
||||
public struct StageCountdownEvent
|
||||
{
|
||||
public int index;
|
||||
/// <summary>남은 초 · 전체 초.</summary>
|
||||
public float remaining, total;
|
||||
public float time;
|
||||
public int frame;
|
||||
}
|
||||
|
||||
/// <summary>전투 시작(카운트다운 종료 · 타이머 시작).</summary>
|
||||
public struct StageStartedEvent
|
||||
{
|
||||
public int index, stageId;
|
||||
public int totalEnemies;
|
||||
public float timeLimit;
|
||||
public int retries;
|
||||
public float time;
|
||||
public int frame;
|
||||
}
|
||||
|
||||
/// <summary>남은 적 수가 바뀌었다(처치 1건마다 · 배치 직후 1회).</summary>
|
||||
public struct StageEnemyCountEvent
|
||||
{
|
||||
public int index;
|
||||
/// <summary>남은 적 · 이 스테이지의 고정 수.</summary>
|
||||
public int remaining, total;
|
||||
public float time;
|
||||
public int frame;
|
||||
}
|
||||
|
||||
/// <summary>남은 시간이 바뀌었다(SO tickIntervalSeconds 주기 · 전투 중에만).</summary>
|
||||
public struct StageTimeEvent
|
||||
{
|
||||
public int index;
|
||||
/// <summary>남은 초 · 제한 시간(초).</summary>
|
||||
public float remaining, limit;
|
||||
public float time;
|
||||
public int frame;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 스테이지 결과 — 클리어 화면(815d)이 이 하나로 전부를 그린다.
|
||||
/// 실패에도 같은 구조를 쓴다(`win=false` · `failReason` 참조).
|
||||
/// 🔴 이름이 발주서의 `StageResult` 가 아닌 이유 = **실측 충돌**. 원본 `Assets/Script/UI/Stage/StageResult.cs:7`
|
||||
/// 에 전역 네임스페이스 `public class StageResult`(BackKeyAdd)가 이미 있어, `using WL.Combat.Stage;` 를 쓴
|
||||
/// 전역 파일에서 `StageResult` 가 원본 클래스로 잡힌다(컴파일 에러 CS0453 실측). 그래서 `StageClearResult`.
|
||||
/// </summary>
|
||||
public struct StageClearResult
|
||||
{
|
||||
/// <summary>표 인덱스 · 표의 id.</summary>
|
||||
public int index, stageId;
|
||||
/// <summary>클리어했는가.</summary>
|
||||
public bool win;
|
||||
/// <summary>실패 사유(win 이면 None).</summary>
|
||||
public StageFailReason failReason;
|
||||
/// <summary>전투 시작 후 경과 초(세계 정지 시간 제외) · 제한 시간.</summary>
|
||||
public float elapsedSec, timeLimit;
|
||||
/// <summary>처치 수 / 고정 수 · 남은 적.</summary>
|
||||
public int killed, total, remaining;
|
||||
/// <summary>같은 스테이지를 몇 번 재도전했는가(0 = 첫 도전에 성공).</summary>
|
||||
public int retries;
|
||||
/// <summary>이 스테이지가 마지막인가(다음 스테이지가 없다).</summary>
|
||||
public bool isLastStage;
|
||||
/// <summary>보스 스테이지인가.</summary>
|
||||
public bool isBoss;
|
||||
public float time;
|
||||
public int frame;
|
||||
}
|
||||
|
||||
/// <summary>실패(사망 · 시간 초과) — 815d 의 「다시 도전 / 로비」 팝업 트리거.</summary>
|
||||
public struct StageFailedEvent
|
||||
{
|
||||
public int index, stageId;
|
||||
public StageFailReason reason;
|
||||
/// <summary>남은 적 / 고정 수 · 경과 초.</summary>
|
||||
public int remaining, total;
|
||||
public float elapsedSec;
|
||||
/// <summary>지금까지의 재도전 횟수(무제한 · PD 3항).</summary>
|
||||
public int retries;
|
||||
public float time;
|
||||
public int frame;
|
||||
}
|
||||
|
||||
/// <summary>스테이지 축에서 빠져나갔다(로비로 · 맵 이탈 · 축 off).</summary>
|
||||
public struct StageExitedEvent
|
||||
{
|
||||
public int index;
|
||||
/// <summary>로비로 나가는 길인가(false = 중단).</summary>
|
||||
public bool toLobby;
|
||||
/// <summary>사유 문자열(진단 · 로그용).</summary>
|
||||
public string reason;
|
||||
public float time;
|
||||
public int frame;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────── 이벤트 허브
|
||||
|
||||
/// <summary>
|
||||
/// 스테이지 이벤트 허브. 정적 · GC 0 · 구독은 각 목록의 Add / Remove.
|
||||
/// 🔴 815c/815d/815f 는 이 계약과 <see cref="StageDirector"/> 의 정적 게터만 본다.
|
||||
/// </summary>
|
||||
public static class StageEvents
|
||||
{
|
||||
/// <summary>스테이지 입장(배치 직후).</summary>
|
||||
public static readonly CombatEventList<StageEnteredEvent> StageEntered = new CombatEventList<StageEnteredEvent>(4);
|
||||
/// <summary>카운트다운 갱신.</summary>
|
||||
public static readonly CombatEventList<StageCountdownEvent> CountdownTick = new CombatEventList<StageCountdownEvent>(4);
|
||||
/// <summary>전투 시작.</summary>
|
||||
public static readonly CombatEventList<StageStartedEvent> StageStarted = new CombatEventList<StageStartedEvent>(4);
|
||||
/// <summary>남은 적 수 변경.</summary>
|
||||
public static readonly CombatEventList<StageEnemyCountEvent> EnemyCountChanged = new CombatEventList<StageEnemyCountEvent>(4);
|
||||
/// <summary>남은 시간 변경.</summary>
|
||||
public static readonly CombatEventList<StageTimeEvent> TimeChanged = new CombatEventList<StageTimeEvent>(4);
|
||||
/// <summary>클리어(고정 수 전멸).</summary>
|
||||
public static readonly CombatEventList<StageClearResult> StageCleared = new CombatEventList<StageClearResult>(4);
|
||||
/// <summary>실패(사망 · 시간 초과).</summary>
|
||||
public static readonly CombatEventList<StageFailedEvent> StageFailed = new CombatEventList<StageFailedEvent>(4);
|
||||
/// <summary>스테이지 축 이탈.</summary>
|
||||
public static readonly CombatEventList<StageExitedEvent> StageExited = new CombatEventList<StageExitedEvent>(4);
|
||||
|
||||
/// <summary>스테이지 축이 살아 있는가(= <see cref="WLStageSettings.Enabled"/>).</summary>
|
||||
public static bool Enabled { get { return WLStageSettings.Enabled; } }
|
||||
|
||||
// ── 진단(프로브가 읽는다)
|
||||
public static int TotalRaised;
|
||||
public static string LastEvent = "";
|
||||
public static float LastEventTime;
|
||||
|
||||
internal static void Stamp(string name)
|
||||
{
|
||||
TotalRaised++;
|
||||
LastEvent = name;
|
||||
LastEventTime = Time.unscaledTime;
|
||||
}
|
||||
|
||||
/// <summary>프로브용: 카운터 초기화(구독은 유지).</summary>
|
||||
public static void ResetDiagnostics()
|
||||
{
|
||||
TotalRaised = 0; LastEvent = ""; LastEventTime = 0f;
|
||||
StageEntered.Raised = StageEntered.Dispatched = 0;
|
||||
CountdownTick.Raised = CountdownTick.Dispatched = 0;
|
||||
StageStarted.Raised = StageStarted.Dispatched = 0;
|
||||
EnemyCountChanged.Raised = EnemyCountChanged.Dispatched = 0;
|
||||
TimeChanged.Raised = TimeChanged.Dispatched = 0;
|
||||
StageCleared.Raised = StageCleared.Dispatched = 0;
|
||||
StageFailed.Raised = StageFailed.Dispatched = 0;
|
||||
StageExited.Raised = StageExited.Dispatched = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────── 장벽 훅 (815c 계약)
|
||||
|
||||
/// <summary>
|
||||
/// 🔴 815c(장벽)와의 유일한 이음매. 이 태스크는 **인터페이스만** 둔다.
|
||||
/// · 815c 가 <see cref="Set"/> / <see cref="Clear"/> 에 자기 구현을 대입한다.
|
||||
/// · 아무도 대입하지 않으면 **no-op** — 예외 0 · 동작 변경 0(장벽 없이 스테이지가 그대로 돈다).
|
||||
/// · 구독자 예외는 여기서 삼킨다(장벽 하나 때문에 스테이지 흐름이 끊기지 않게).
|
||||
/// </summary>
|
||||
public static class StageBarrierHook
|
||||
{
|
||||
/// <summary>장벽을 세운다 — (중심, 반경).</summary>
|
||||
public static System.Action<Vector3, float> Set;
|
||||
/// <summary>장벽을 없앤다.</summary>
|
||||
public static System.Action Clear;
|
||||
|
||||
/// <summary>진단 — 호출 횟수 · 마지막 값 · 구현이 붙어 있는가.</summary>
|
||||
public static int SetCalls, ClearCalls, Faults;
|
||||
public static Vector3 LastCenter;
|
||||
public static float LastRadius;
|
||||
public static bool HasSet { get { return Set != null; } }
|
||||
public static bool HasClear { get { return Clear != null; } }
|
||||
|
||||
/// <summary>장벽 요청(구현이 없으면 값만 기록하고 아무 일도 하지 않는다).</summary>
|
||||
public static void RaiseSet(Vector3 center, float radius)
|
||||
{
|
||||
SetCalls++; LastCenter = center; LastRadius = radius;
|
||||
var h = Set;
|
||||
if (h == null) return;
|
||||
try { h(center, radius); }
|
||||
catch (System.Exception) { Faults++; }
|
||||
}
|
||||
|
||||
/// <summary>장벽 해제(구현이 없으면 아무 일도 하지 않는다).</summary>
|
||||
public static void RaiseClear()
|
||||
{
|
||||
ClearCalls++;
|
||||
var h = Clear;
|
||||
if (h == null) return;
|
||||
try { h(); }
|
||||
catch (System.Exception) { Faults++; }
|
||||
}
|
||||
|
||||
/// <summary>프로브용: 카운터만 비운다(대입한 구현은 유지).</summary>
|
||||
public static void ResetDiagnostics() { SetCalls = ClearCalls = Faults = 0; LastCenter = Vector3.zero; LastRadius = 0f; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8d815b01a1b2c3d4e5f60708090a0b11
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,801 @@
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// StageDirector.cs — 스테이지 진행기(상태기계 · 고정 수 전멸 승리 · 60 s 제한 · 재도전)
|
||||
//
|
||||
// PD 지시 #815 · 발주서 WL-815b §1-2 (2026-09-11)
|
||||
// SOT = 기준서 §B-2(상태기계 표) · §A(재사용/개조) · §A-1(리스폰 0) · §E(실패·재도전) · §C(표)
|
||||
//
|
||||
// ■ 813p `RunDirector` 에서 무엇을 가져오고 무엇을 바꿨나 (기준서 §A 1행)
|
||||
// 가져온 것 : 상태기계 골격 · 폴링 틱(unscaled) · 세계 정지 중 시계 멈춤 · 이벤트 허브 규약 ·
|
||||
// `Restart()` 의 복구 순서(DeathFlow.Abort → On_Regen → PotionUse.ResetRun) ·
|
||||
// 진입 경계 원천(`AutoCombatOnEnter.ReadBoundarySources`) · C8 게이트 구조
|
||||
// 바꾼 것 : 🔴 **승리 축** — 누적 킬(`zoneClearKills`) → **고정 수 전멸**(`RemainingEnemies == 0`)
|
||||
// 🔴 **타이머 의미** — 기록용 → **제한 시간(60 s · 초과 = 실패)**
|
||||
// 🔴 **실패 축 신설** — 사망 / 시간 초과 → `StageFailed` → 재도전 무제한
|
||||
// 🔴 **리스폰 0** — 스테이지 스포너의 리젠 예약을 끊는다(기준서 §A-1 ⓑ · 원본 0줄)
|
||||
// 🔴 `RunDirector.cs` 는 **1줄도 고치지 않는다**. 스테이지 중에는 `WLRunSettings.RuntimeDisabled`
|
||||
// 로 재우고(SO), 이탈하면 자동 복원한다 = C8 이 값 하나가 아니라 **구조로** 성립한다.
|
||||
//
|
||||
// ■ 상태 전이 (기준서 §B-2 · 로비 제외)
|
||||
// Idle ──Enter()──▶ Entering ──배치 완료──▶ Countdown ──countdownSeconds──▶ Combat
|
||||
// Combat ──남은 적 0(마지막 묶음)──▶ Cleared ──clearHoldSeconds──▶ Result ──Next()──▶ Entering
|
||||
// Combat ──제한 시간 0──▶ Failed ──Retry()──▶ Entering
|
||||
// Combat ──PC 사망──▶ Failed ──Retry()──▶ Entering
|
||||
// Result/Failed ──ToLobby()──▶ Idle 어느 상태든 ──Abort()──▶ Idle
|
||||
//
|
||||
// ■ 815c / 815d / 815f 가 보는 것 = `StageEvents` + 아래 정적 게터 + `StageBarrierHook` 뿐이다.
|
||||
//
|
||||
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 플레이어 팝업을 띄운다.
|
||||
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
using UnityEngine;
|
||||
using WL.Combat.Auto;
|
||||
using WL.Combat.Core;
|
||||
using WL.Combat.Run;
|
||||
using WL.Combat.Survival;
|
||||
|
||||
namespace WL.Combat.Stage
|
||||
{
|
||||
/// <summary>스테이지 상태기계. 정적 · 새 Manager/Singleton 0(러너는 숨김 GameObject 1개).</summary>
|
||||
public static class StageDirector
|
||||
{
|
||||
// ───────────────────────────────────────── 상태
|
||||
static StageState s_state = StageState.Idle;
|
||||
static StageDef s_def;
|
||||
static int s_index = -1;
|
||||
static int s_total, s_killed, s_retries, s_batch, s_batchCount = 1;
|
||||
static float s_limit;
|
||||
static float s_startTime, s_pausedTotal, s_pauseStart = -1f;
|
||||
static float s_stateEnter; // 현재 상태에 들어온 시각(unscaled)
|
||||
static float s_nextPoll, s_nextTick;
|
||||
static float s_nextBatchAt = -1f;
|
||||
static bool s_subscribed, s_faulted;
|
||||
static bool s_respawnStopped;
|
||||
static StageFailReason s_fail = StageFailReason.None;
|
||||
static StageClearResult s_lastResult;
|
||||
|
||||
// ── 기존 축 억제(스테이지 중에만 · 이탈 시 자동 복원)
|
||||
static bool s_runSuppressed, s_guideSuppressed, s_reviveBound;
|
||||
static bool s_runPrev, s_guidePrev;
|
||||
static System.Action<PCActor, float> s_revivePrev;
|
||||
|
||||
// ── 진입 경계(813p 와 같은 원천 · 판정은 여기)
|
||||
static PCActor s_lastPc;
|
||||
static int s_lastMapId = int.MinValue;
|
||||
|
||||
// ───────────────────────────────────────── 공개 계약 (815c/815d/815f 가 읽는다)
|
||||
|
||||
/// <summary>현재 스테이지 정의(없으면 null).</summary>
|
||||
public static StageDef Current { get { return s_def; } }
|
||||
/// <summary>현재 스테이지 표 인덱스(0부터 · 없으면 −1).</summary>
|
||||
public static int Index { get { return s_index; } }
|
||||
/// <summary>남은 적 수(고정 수 − 처치 수 · 0 미만 없음).</summary>
|
||||
public static int RemainingEnemies { get { int r = s_total - s_killed; return r > 0 ? r : 0; } }
|
||||
/// <summary>이 스테이지의 고정 수.</summary>
|
||||
public static int TotalEnemies { get { return s_total; } }
|
||||
/// <summary>남은 초(전투 중에만 줄어든다 · 0 미만 없음).</summary>
|
||||
public static float RemainingSeconds
|
||||
{
|
||||
get
|
||||
{
|
||||
if (s_state != StageState.Combat) return s_limit;
|
||||
float r = s_limit - Elapsed;
|
||||
return r > 0f ? r : 0f;
|
||||
}
|
||||
}
|
||||
/// <summary>스테이지가 진행 중인가(입장 ~ 전투 · 결과·실패 대기는 false).</summary>
|
||||
public static bool IsActive
|
||||
{
|
||||
get { return s_state == StageState.Entering || s_state == StageState.Countdown || s_state == StageState.Combat; }
|
||||
}
|
||||
/// <summary>스테이지 축이 어떤 형태로든 살아 있는가(결과·실패 대기 포함).</summary>
|
||||
public static bool InStage { get { return s_state != StageState.Idle; } }
|
||||
|
||||
/// <summary>현재 상태.</summary>
|
||||
public static StageState State { get { return s_state; } }
|
||||
/// <summary>이 스테이지를 몇 번 재도전했는가(무제한 · PD 3항).</summary>
|
||||
public static int Retries { get { return s_retries; } }
|
||||
/// <summary>이번 스테이지의 처치 수.</summary>
|
||||
public static int Killed { get { return s_killed; } }
|
||||
/// <summary>이 스테이지의 제한 시간(초).</summary>
|
||||
public static float TimeLimit { get { return s_limit; } }
|
||||
/// <summary>마지막 실패 사유.</summary>
|
||||
public static StageFailReason LastFailReason { get { return s_fail; } }
|
||||
/// <summary>마지막 결과(클리어·실패 공통 · 결과 화면이 늦게 떠도 읽을 수 있게 남긴다).</summary>
|
||||
public static StageClearResult LastResult { get { return s_lastResult; } }
|
||||
/// <summary>현재 묶음(웨이브) 번호 · 전체 묶음 수.</summary>
|
||||
public static int Batch { get { return s_batch; } }
|
||||
public static int BatchCount { get { return s_batchCount; } }
|
||||
/// <summary>카운트다운 남은 초(카운트다운 중에만 · 그 밖에는 0).</summary>
|
||||
public static float CountdownRemaining
|
||||
{
|
||||
get
|
||||
{
|
||||
if (s_state != StageState.Countdown) return 0f;
|
||||
var t = Table;
|
||||
float total = t != null ? t.countdownSeconds : 0f;
|
||||
float r = total - (Now - s_stateEnter);
|
||||
return r > 0f ? r : 0f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>전투 시작 후 경과 초(세계 정지 시간 제외).</summary>
|
||||
public static float Elapsed
|
||||
{
|
||||
get
|
||||
{
|
||||
if (s_state != StageState.Combat && s_startTime <= 0f) return 0f;
|
||||
float now = Now;
|
||||
float paused = s_pausedTotal + (s_pauseStart >= 0f ? now - s_pauseStart : 0f);
|
||||
float e = now - s_startTime - paused;
|
||||
return e > 0f ? e : 0f;
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────── 진단(프로브·QA)
|
||||
public static bool Subscribed { get { return s_subscribed; } }
|
||||
public static int EnterCount, StartCount, ClearCount, FailCount, RetryCount, NextCount, LobbyCount, TickCount, BoundaryCount;
|
||||
public static string LastLog = "";
|
||||
/// <summary>기존 축을 재우고 있는가(진단 · C8 증거).</summary>
|
||||
public static bool RunDirectorSuppressed { get { return s_runSuppressed; } }
|
||||
public static bool RunGuideSuppressed { get { return s_guideSuppressed; } }
|
||||
public static bool ReviveBound { get { return s_reviveBound; } }
|
||||
|
||||
static WLStageSettings Cfg { get { return WLStageSettings.Instance; } }
|
||||
static WLStageTable Table { get { return WLStageTable.Instance; } }
|
||||
static bool Verbose { get { var c = Cfg; return c != null && c.verboseLog; } }
|
||||
static float Now { get { return Time.unscaledTime; } }
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 구독
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>집계 구독(중복 구독은 CombatEventList.Add 가 막는다).</summary>
|
||||
public static void EnsureSubscribed()
|
||||
{
|
||||
if (s_subscribed) return;
|
||||
CombatEvents.Killed.Add(OnKilled);
|
||||
DeathFlow.Died.Add(OnDied);
|
||||
s_subscribed = true;
|
||||
}
|
||||
|
||||
/// <summary>구독 해제(프로브·롤백용).</summary>
|
||||
public static void Unsubscribe()
|
||||
{
|
||||
if (!s_subscribed) return;
|
||||
CombatEvents.Killed.Remove(OnKilled);
|
||||
DeathFlow.Died.Remove(OnDied);
|
||||
s_subscribed = false;
|
||||
}
|
||||
|
||||
static void OnKilled(in KilledEvent e)
|
||||
{
|
||||
if (s_state != StageState.Combat || s_def == null) return;
|
||||
int sid = DSUtil.CheckNull(e.victim) ? 0 : e.victim.Get_Group();
|
||||
if (!s_def.HasSpawner(sid)) return; // 스테이지 밖 처치는 세지 않는다
|
||||
NoteKill(sid);
|
||||
}
|
||||
|
||||
static void OnDied(in DeathEvent e)
|
||||
{
|
||||
var c = Cfg;
|
||||
if (c == null || !c.failOnPlayerDeath) return;
|
||||
if (s_state != StageState.Combat && s_state != StageState.Countdown) return;
|
||||
Fail(StageFailReason.PlayerDied);
|
||||
}
|
||||
|
||||
/// <summary>813z ReviveDialog 의 [확인] 이 여기로 온다(DeathFlow.ReviveOverride · 815d 전 임시 배선).</summary>
|
||||
static void OnReviveOk(PCActor pc, float healRate)
|
||||
{
|
||||
if (s_state == StageState.Failed || s_state == StageState.Combat) { Retry(); return; }
|
||||
if (!DSUtil.CheckNull(pc)) pc.On_Regen(false, healRate); // 스테이지 밖이면 813i 기본 동작 그대로
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 집계 이음매 (프로브가 가짜 처치를 넣을 때도 이 경로를 쓴다 — 813d/813p 선례)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>처치 1건 반영 + 전멸 재판정.</summary>
|
||||
public static void NoteKill(int spawnerId)
|
||||
{
|
||||
if (s_state != StageState.Combat) return;
|
||||
s_killed++;
|
||||
RaiseEnemyCount();
|
||||
if (RemainingEnemies > 0) return;
|
||||
|
||||
// 묶음(웨이브)이 더 남았으면 다음 묶음을 예약한다(기준서 §B-2 · 「차단 시점」만 바꾸면 2웨이브).
|
||||
if (s_batch + 1 < s_batchCount)
|
||||
{
|
||||
var t = Table;
|
||||
s_nextBatchAt = Now + (t != null && t.waveGapSeconds > 0f ? t.waveGapSeconds : 0f);
|
||||
return;
|
||||
}
|
||||
Clear();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 공개 API — 815d 팝업 · 815h 로비가 부르는 전부
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>스테이지 <paramref name="index"/>(0부터) 로 입장한다. 진행 중이면 먼저 정리한다.</summary>
|
||||
public static bool Enter(int index, string reason)
|
||||
{
|
||||
var c = Cfg; var t = Table;
|
||||
if (c == null || t == null || !WLStageSettings.Enabled) return false;
|
||||
var def = t.Get(index);
|
||||
if (def == null) { Log("입장 실패 — 표 인덱스 " + index + " 없음"); return false; }
|
||||
|
||||
bool sameStage = s_index == index;
|
||||
if (InStage && !sameStage) ReleaseBarrier();
|
||||
ApplyStageOverrides();
|
||||
|
||||
s_index = index;
|
||||
s_def = def;
|
||||
if (!sameStage) s_retries = 0;
|
||||
s_limit = t.TimeLimitOf(def);
|
||||
s_batchCount = t.WaveBatches;
|
||||
s_batch = 0;
|
||||
s_killed = 0;
|
||||
s_total = def.TotalCount;
|
||||
s_fail = StageFailReason.None;
|
||||
s_nextBatchAt = -1f;
|
||||
s_respawnStopped = false;
|
||||
s_pausedTotal = 0f; s_pauseStart = -1f; s_startTime = 0f;
|
||||
SetState(StageState.Entering);
|
||||
EnterCount++;
|
||||
EnsureSubscribed();
|
||||
EnsureRunner();
|
||||
|
||||
// ① PC 를 스테이지 앵커로 — 813w3 이 쓴 것과 같은 원본 공개 API(Actor.Set_Warp · Actor.cs:2111)
|
||||
if (c.warpPlayerToAnchor) WarpPlayerToAnchor(def);
|
||||
|
||||
// ② 장벽 = 815c 계약 호출(구현이 없으면 no-op)
|
||||
if (c.useBarrierHook) StageBarrierHook.RaiseSet(def.anchor, def.barrierRadius);
|
||||
|
||||
// ③ 🔴 리스폰 0(기준서 §A-1 ⓑ) — 배치보다 **먼저** 예약을 끊는다.
|
||||
bool allStopped;
|
||||
StageSpawner.StopRespawns(def, out allStopped);
|
||||
s_respawnStopped = allStopped;
|
||||
|
||||
// ④ 고정 수 배치(초과분은 비활성 유지)
|
||||
int placed = StageSpawner.Deploy(def, 0, "스테이지 " + def.id + " 입장");
|
||||
|
||||
var ev = new StageEnteredEvent
|
||||
{
|
||||
index = index, stageId = def.id, def = def,
|
||||
totalEnemies = s_total, timeLimit = s_limit,
|
||||
anchor = def.anchor, barrierRadius = def.barrierRadius,
|
||||
countdownSeconds = t.countdownSeconds, retries = s_retries,
|
||||
time = Now, frame = Time.frameCount
|
||||
};
|
||||
StageEvents.Stamp("StageEntered");
|
||||
StageEvents.StageEntered.Dispatch(in ev);
|
||||
RaiseEnemyCount();
|
||||
|
||||
if (Verbose) Log("스테이지 " + def.id + " 입장(" + reason + ") — 고정 수 " + s_total +
|
||||
" · 배치 " + placed + " · 제한 " + s_limit.ToString("F0") + "s · 리젠차단=" + allStopped);
|
||||
|
||||
// 🔴 카운트다운은 **다음 틱**에 들어간다 — 로딩이 안 끝난 스포너의 리젠 차단을 한 번 더 시도할 기회를 남긴다
|
||||
// (BossArena.cs:1077 `Get_LoadComplete()` 가드와 같은 이유). 상태 Entering 은 관측 가능한 실제 상태다.
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>같은 스테이지를 다시 도전한다(HP 전회복 · 물약 초기화 · 몬스터 재배치 · 타이머 리셋 · 무제한).</summary>
|
||||
public static bool Retry()
|
||||
{
|
||||
var c = Cfg; var t = Table;
|
||||
if (c == null || t == null || !WLStageSettings.Enabled || s_def == null) return false;
|
||||
RetryCount++;
|
||||
s_retries++;
|
||||
|
||||
try
|
||||
{
|
||||
DeathFlow.Abort("stage retry"); // 부활 팝업이 떠 있으면 세계 정지부터 푼다
|
||||
var pc = MyValue.MyPC;
|
||||
if (c.retryHealsPlayer && !DSUtil.CheckNull(pc))
|
||||
pc.On_Regen(false, t.retryHealRate); // MyActor.On_Regen = 전회복(813z 와 같은 경로)
|
||||
if (c.retryResetsPotions) PotionUse.ResetRun("stage retry");
|
||||
if (c.retryRecallsMonsters) StageSpawner.Recall(s_def, "재도전");
|
||||
}
|
||||
catch (System.Exception ex) { Log("재도전 일부 실패 — " + ex.Message); }
|
||||
|
||||
int idx = s_index;
|
||||
int keep = s_retries;
|
||||
bool ok = Enter(idx, "retry");
|
||||
s_retries = keep; // Enter 가 같은 스테이지라 0 으로 되돌리지 않지만 방어
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// <summary>다음 스테이지로(index + 1). 마지막이면 SO wrapToFirstStage 에 따라 0 으로 돌거나 실패한다.</summary>
|
||||
public static bool Next()
|
||||
{
|
||||
var c = Cfg; var t = Table;
|
||||
if (c == null || t == null || !WLStageSettings.Enabled) return false;
|
||||
NextCount++;
|
||||
int next = s_index + 1;
|
||||
if (next >= t.Count)
|
||||
{
|
||||
if (!c.wrapToFirstStage) { Log("다음 스테이지 없음 — 마지막(" + s_index + ")"); return false; }
|
||||
next = 0;
|
||||
}
|
||||
s_retries = 0;
|
||||
return Enter(next, "next");
|
||||
}
|
||||
|
||||
/// <summary>로비로 나간다(스테이지 축 종료 · 억제한 기존 축 복원).</summary>
|
||||
public static void ToLobby()
|
||||
{
|
||||
LobbyCount++;
|
||||
var c = Cfg;
|
||||
Exit(true, "lobby");
|
||||
if (c != null && c.toLobbyCallsReturnToLobby && Application.isPlaying)
|
||||
{
|
||||
try { var ig = InGameInfo.Ins; if (!DSUtil.CheckNull(ig)) ig.Return_To_Lobby(); }
|
||||
catch (System.Exception ex) { Log("로비 복귀 실패 — " + ex.Message); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>중단(맵 이탈 · PC 소실 · 축 off). 실패로 세지 않는다.</summary>
|
||||
public static void Abort(string reason) { Exit(false, reason); }
|
||||
|
||||
static void Exit(bool toLobby, string reason)
|
||||
{
|
||||
if (s_state == StageState.Idle) { ClearStageOverrides(); return; }
|
||||
int idx = s_index;
|
||||
ReleaseBarrier();
|
||||
SetState(StageState.Idle);
|
||||
s_startTime = 0f; s_pauseStart = -1f; s_nextBatchAt = -1f;
|
||||
ClearStageOverrides();
|
||||
|
||||
var ev = new StageExitedEvent { index = idx, toLobby = toLobby, reason = reason, time = Now, frame = Time.frameCount };
|
||||
StageEvents.Stamp("StageExited");
|
||||
StageEvents.StageExited.Dispatch(in ev);
|
||||
if (Verbose) Log("스테이지 이탈(" + reason + ") — 로비=" + toLobby);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 클리어 · 실패
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
static void Clear()
|
||||
{
|
||||
var c = Cfg; var t = Table;
|
||||
ClearCount++;
|
||||
float el = Elapsed;
|
||||
PausePump(false);
|
||||
SetState(StageState.Cleared);
|
||||
|
||||
if (c != null && c.clearBarrierOnExit) ReleaseBarrier();
|
||||
|
||||
// 전멸 직후 정지 — 코어 중재자(TimeScaleArbiter)를 쓴다. 직접 timeScale 대입 0(813z 교훈).
|
||||
if (c != null && t != null && t.clearHoldSeconds > 0f && c.clearHoldScale < 1f && Application.isPlaying)
|
||||
TimeScaleArbiter.Request(TimeLayer.Cinematic, c.clearHoldScale, t.clearHoldSeconds);
|
||||
|
||||
s_lastResult = BuildResult(true, StageFailReason.None, el);
|
||||
StageEvents.Stamp("StageCleared");
|
||||
StageEvents.StageCleared.Dispatch(in s_lastResult);
|
||||
|
||||
// 813o 카드 = 「스테이지 클리어 시 1회」(기준서 §A · 트리거만 교체)
|
||||
if (c != null && c.cardOnStageClear) MirrorZoneCleared(el);
|
||||
if (c != null && c.mirrorRunEnded) MirrorRunEnded(RunOutcome.Win, el);
|
||||
|
||||
if (Verbose) Log("스테이지 " + (s_def != null ? s_def.id : 0) + " 클리어 — " + el.ToString("F1") +
|
||||
"s · " + s_killed + "/" + s_total + " · 재도전 " + s_retries);
|
||||
}
|
||||
|
||||
static void Fail(StageFailReason reason)
|
||||
{
|
||||
var c = Cfg;
|
||||
FailCount++;
|
||||
float el = Elapsed;
|
||||
PausePump(false);
|
||||
s_fail = reason;
|
||||
SetState(StageState.Failed);
|
||||
|
||||
s_lastResult = BuildResult(false, reason, el);
|
||||
var ev = new StageFailedEvent
|
||||
{
|
||||
index = s_index, stageId = s_def != null ? s_def.id : 0, reason = reason,
|
||||
remaining = RemainingEnemies, total = s_total, elapsedSec = el, retries = s_retries,
|
||||
time = Now, frame = Time.frameCount
|
||||
};
|
||||
StageEvents.Stamp("StageFailed");
|
||||
StageEvents.StageFailed.Dispatch(in ev);
|
||||
|
||||
if (c != null && c.mirrorRunEnded)
|
||||
MirrorRunEnded(reason == StageFailReason.TimeLimit ? RunOutcome.Timeout : RunOutcome.Abandon, el);
|
||||
|
||||
if (Verbose) Log("스테이지 " + (s_def != null ? s_def.id : 0) + " 실패(" + reason + ") — " +
|
||||
el.ToString("F1") + "s · 남은 적 " + RemainingEnemies + "/" + s_total);
|
||||
}
|
||||
|
||||
static StageClearResult BuildResult(bool win, StageFailReason reason, float el)
|
||||
{
|
||||
var t = Table;
|
||||
return new StageClearResult
|
||||
{
|
||||
index = s_index, stageId = s_def != null ? s_def.id : 0,
|
||||
win = win, failReason = reason,
|
||||
elapsedSec = el, timeLimit = s_limit,
|
||||
killed = s_killed, total = s_total, remaining = RemainingEnemies,
|
||||
retries = s_retries,
|
||||
isLastStage = t != null && s_index >= t.Count - 1,
|
||||
isBoss = s_def != null && s_def.isBoss,
|
||||
time = Now, frame = Time.frameCount
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 틱
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>러너가 매 프레임 부른다(에디트 모드에서는 프로브가 ForceTick 으로 부른다).</summary>
|
||||
internal static void Tick()
|
||||
{
|
||||
if (s_faulted) return;
|
||||
var c = Cfg;
|
||||
if (c == null || !WLStageSettings.Enabled) { if (InStage) Abort("disabled"); return; }
|
||||
|
||||
float now = Now;
|
||||
if (now < s_nextPoll) return;
|
||||
s_nextPoll = now + Mathf.Max(0.02f, c.pollSeconds);
|
||||
|
||||
// 🔴 Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 플레이어 팝업으로 띄운다.
|
||||
try
|
||||
{
|
||||
PollBoundary(c);
|
||||
if (!InStage) return;
|
||||
|
||||
switch (s_state)
|
||||
{
|
||||
case StageState.Entering:
|
||||
RetryRespawnStop();
|
||||
SetState(StageState.Countdown);
|
||||
RaiseCountdown();
|
||||
break;
|
||||
|
||||
case StageState.Countdown:
|
||||
{
|
||||
RetryRespawnStop();
|
||||
var t = Table;
|
||||
float cd = t != null ? t.countdownSeconds : 0f;
|
||||
if (now >= s_nextTick) { s_nextTick = now + TickStep(c); RaiseCountdown(); }
|
||||
if (now - s_stateEnter >= cd) BeginCombat();
|
||||
break;
|
||||
}
|
||||
|
||||
case StageState.Combat:
|
||||
{
|
||||
PausePump(c.pauseTimerWhileWorldHeld && DeathFlow.WorldHeld);
|
||||
|
||||
if (s_nextBatchAt >= 0f && now >= s_nextBatchAt) DeployNextBatch();
|
||||
|
||||
float el = Elapsed;
|
||||
if (s_limit > 0f && el >= s_limit) { Fail(StageFailReason.TimeLimit); break; }
|
||||
if (now >= s_nextTick) { s_nextTick = now + TickStep(c); RaiseTime(); }
|
||||
break;
|
||||
}
|
||||
|
||||
case StageState.Cleared:
|
||||
{
|
||||
var t = Table;
|
||||
float hold = t != null ? t.clearHoldSeconds : 0f;
|
||||
if (now - s_stateEnter >= hold) SetState(StageState.Result);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex) { s_faulted = true; Log("중단(예외) — " + ex.Message); }
|
||||
}
|
||||
|
||||
static float TickStep(WLStageSettings c) { return c.tickIntervalSeconds > 0f ? c.tickIntervalSeconds : 0f; }
|
||||
|
||||
/// <summary>로딩이 안 끝나 미뤘던 리젠 차단을 다시 시도한다(BossArena.cs:1077 가드와 같은 이유).</summary>
|
||||
static void RetryRespawnStop()
|
||||
{
|
||||
if (s_respawnStopped || s_def == null) return;
|
||||
bool all;
|
||||
StageSpawner.StopRespawns(s_def, out all);
|
||||
s_respawnStopped = all;
|
||||
}
|
||||
|
||||
static void BeginCombat()
|
||||
{
|
||||
var c = Cfg;
|
||||
SetState(StageState.Combat);
|
||||
StartCount++;
|
||||
s_startTime = Now;
|
||||
s_pausedTotal = 0f; s_pauseStart = -1f;
|
||||
s_nextTick = s_startTime;
|
||||
|
||||
var ev = new StageStartedEvent
|
||||
{
|
||||
index = s_index, stageId = s_def != null ? s_def.id : 0,
|
||||
totalEnemies = s_total, timeLimit = s_limit, retries = s_retries,
|
||||
time = Now, frame = Time.frameCount
|
||||
};
|
||||
StageEvents.Stamp("StageStarted");
|
||||
StageEvents.StageStarted.Dispatch(in ev);
|
||||
|
||||
if (c != null && c.mirrorRunHudEvents) MirrorRunStarted();
|
||||
RaiseTime();
|
||||
RaiseEnemyCount();
|
||||
}
|
||||
|
||||
static void DeployNextBatch()
|
||||
{
|
||||
s_nextBatchAt = -1f;
|
||||
s_batch++;
|
||||
if (s_def == null) return;
|
||||
int placed = StageSpawner.Deploy(s_def, s_batch, "묶음 " + (s_batch + 1));
|
||||
if (Verbose) Log("다음 묶음 배치 — " + placed + "마리(묶음 " + (s_batch + 1) + "/" + s_batchCount + ")");
|
||||
RaiseEnemyCount();
|
||||
}
|
||||
|
||||
static void PausePump(bool held)
|
||||
{
|
||||
float now = Now;
|
||||
if (held && s_pauseStart < 0f) s_pauseStart = now;
|
||||
else if (!held && s_pauseStart >= 0f) { s_pausedTotal += now - s_pauseStart; s_pauseStart = -1f; }
|
||||
}
|
||||
|
||||
/// <summary>진입 경계 — 813x AutoCombatOnEnter 와 같은 원천(그 파일의 동작 변경 0 · 판정은 여기).</summary>
|
||||
static void PollBoundary(WLStageSettings c)
|
||||
{
|
||||
PCActor pc; int map; bool lobby;
|
||||
if (!AutoCombatOnEnter.ReadBoundarySources(out pc, out map, out lobby)) return;
|
||||
|
||||
bool pcChanged = !ReferenceEquals(pc, s_lastPc);
|
||||
bool mapChanged = map != s_lastMapId;
|
||||
if (!pcChanged && !mapChanged) return;
|
||||
|
||||
s_lastPc = pc; s_lastMapId = map;
|
||||
BoundaryCount++;
|
||||
|
||||
if (pc == null || lobby) { if (InStage) Abort(pc == null ? "pc 없음" : "로비"); return; }
|
||||
if (InStage) { Abort("맵 이동"); return; }
|
||||
if (c.autoEnterOnMapEnter) Enter(0, pcChanged ? "pc" : ("map " + map));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 이벤트 발행
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
static void SetState(StageState s) { s_state = s; s_stateEnter = Now; }
|
||||
|
||||
static void RaiseCountdown()
|
||||
{
|
||||
var t = Table;
|
||||
float total = t != null ? t.countdownSeconds : 0f;
|
||||
var e = new StageCountdownEvent
|
||||
{
|
||||
index = s_index, remaining = CountdownRemaining, total = total,
|
||||
time = Now, frame = Time.frameCount
|
||||
};
|
||||
StageEvents.Stamp("CountdownTick");
|
||||
StageEvents.CountdownTick.Dispatch(in e);
|
||||
}
|
||||
|
||||
static void RaiseEnemyCount()
|
||||
{
|
||||
var e = new StageEnemyCountEvent
|
||||
{
|
||||
index = s_index, remaining = RemainingEnemies, total = s_total,
|
||||
time = Now, frame = Time.frameCount
|
||||
};
|
||||
StageEvents.Stamp("EnemyCountChanged");
|
||||
StageEvents.EnemyCountChanged.Dispatch(in e);
|
||||
}
|
||||
|
||||
static void RaiseTime()
|
||||
{
|
||||
TickCount++;
|
||||
var e = new StageTimeEvent
|
||||
{
|
||||
index = s_index, remaining = RemainingSeconds, limit = s_limit,
|
||||
time = Now, frame = Time.frameCount
|
||||
};
|
||||
StageEvents.Stamp("TimeChanged");
|
||||
StageEvents.TimeChanged.Dispatch(in e);
|
||||
|
||||
var c = Cfg;
|
||||
if (c != null && c.mirrorRunHudEvents) MirrorRunTick();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 813p RunEvents 병행 발행 (기존 구독자 호환 · SO 로 끈다)
|
||||
// 🔴 RunDirector 는 재워 둔 상태다 — 이 발행은 **스테이지가 낸 것**이고 RunDirector 상태와 무관하다.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
static void MirrorRunStarted()
|
||||
{
|
||||
var e = new RunStartedEvent
|
||||
{
|
||||
runIndex = s_index + 1, startTime = s_startTime, runSeconds = s_limit,
|
||||
zoneCount = 1, reason = "stage",
|
||||
time = Now, frame = Time.frameCount
|
||||
};
|
||||
RunEvents.Stamp("RunStarted");
|
||||
RunEvents.RunStarted.Dispatch(in e);
|
||||
}
|
||||
|
||||
static void MirrorRunTick()
|
||||
{
|
||||
var e = new RunTickEvent
|
||||
{
|
||||
elapsedSec = Elapsed, remainingSec = RemainingSeconds,
|
||||
zoneIndex = 0, zoneKills = s_killed, zoneRequired = s_total,
|
||||
zonesCleared = 0, zoneCount = 1,
|
||||
phase = RunPhase.Zones, runIndex = s_index + 1,
|
||||
time = Now, frame = Time.frameCount
|
||||
};
|
||||
RunEvents.Stamp("RunTick");
|
||||
RunEvents.RunTick.Dispatch(in e);
|
||||
}
|
||||
|
||||
static void MirrorZoneCleared(float el)
|
||||
{
|
||||
var e = new ZoneClearedEvent
|
||||
{
|
||||
zoneIndex = 0, spawnerId = FirstSpawnerId(), elapsedSec = el,
|
||||
kills = s_killed, required = s_total, allZonesCleared = true,
|
||||
time = Now, frame = Time.frameCount
|
||||
};
|
||||
RunEvents.Stamp("ZoneCleared");
|
||||
RunEvents.ZoneCleared.Dispatch(in e);
|
||||
}
|
||||
|
||||
static void MirrorRunEnded(RunOutcome outcome, float el)
|
||||
{
|
||||
var e = new RunResult
|
||||
{
|
||||
outcome = outcome, runIndex = s_index + 1,
|
||||
totalSec = el, runSeconds = s_limit,
|
||||
zoneClearSec = null, zoneKills = null, zoneCount = 1,
|
||||
zonesCleared = outcome == RunOutcome.Win ? 1 : 0,
|
||||
bossGateSec = -1f, bossStartSec = -1f, bossClearSec = -1f, bossHpRatio = -1f,
|
||||
kills = s_killed, bossKills = 0,
|
||||
lootByGrade = null,
|
||||
time = Now, frame = Time.frameCount
|
||||
};
|
||||
RunEvents.Stamp("RunEnded");
|
||||
RunEvents.RunEnded.Dispatch(in e);
|
||||
}
|
||||
|
||||
static int FirstSpawnerId()
|
||||
{
|
||||
if (s_def == null || s_def.waves == null) return 0;
|
||||
for (int i = 0; i < s_def.waves.Length; i++) if (s_def.waves[i] != null) return s_def.waves[i].spawnerId;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 기존 축 억제 · 복원 (🔴 C8 이 값이 아니라 구조로 성립하는 곳)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
static void ApplyStageOverrides()
|
||||
{
|
||||
var c = Cfg;
|
||||
if (c == null) return;
|
||||
|
||||
if (c.suppressRunDirector && !s_runSuppressed)
|
||||
{
|
||||
if (c.resetRunDirectorOnEnter) RunDirector.ResetAll(); // 🔴 EndRun 이 아니라 ResetAll — RunEnded 를 내지 않는다
|
||||
s_runPrev = WLRunSettings.RuntimeDisabled;
|
||||
WLRunSettings.RuntimeDisabled = true;
|
||||
s_runSuppressed = true;
|
||||
}
|
||||
if (c.disableRunGuide && !s_guideSuppressed)
|
||||
{
|
||||
s_guidePrev = WLRunGuideSettings.RuntimeDisabled;
|
||||
WLRunGuideSettings.RuntimeDisabled = true;
|
||||
s_guideSuppressed = true;
|
||||
}
|
||||
if (c.bindReviveOkToRetry && !s_reviveBound)
|
||||
{
|
||||
s_revivePrev = DeathFlow.ReviveOverride;
|
||||
DeathFlow.ReviveOverride = OnReviveOk;
|
||||
s_reviveBound = true;
|
||||
}
|
||||
}
|
||||
|
||||
static void ClearStageOverrides()
|
||||
{
|
||||
if (s_runSuppressed) { WLRunSettings.RuntimeDisabled = s_runPrev; s_runSuppressed = false; }
|
||||
if (s_guideSuppressed) { WLRunGuideSettings.RuntimeDisabled = s_guidePrev; s_guideSuppressed = false; }
|
||||
if (s_reviveBound) { DeathFlow.ReviveOverride = s_revivePrev; s_revivePrev = null; s_reviveBound = false; }
|
||||
}
|
||||
|
||||
static void ReleaseBarrier()
|
||||
{
|
||||
var c = Cfg;
|
||||
if (c != null && c.clearBarrierOnExit) StageBarrierHook.RaiseClear();
|
||||
}
|
||||
|
||||
static void WarpPlayerToAnchor(StageDef def)
|
||||
{
|
||||
if (!Application.isPlaying) return;
|
||||
try
|
||||
{
|
||||
var pc = MyValue.MyPC;
|
||||
if (DSUtil.CheckNull(pc)) return;
|
||||
Vector3 want = def.anchor;
|
||||
if (def.startRadius > 0.01f) want += new Vector3(0f, 0f, -def.startRadius); // 앵커 뒤쪽에서 시작(난수 0)
|
||||
Vector3 target;
|
||||
var tg = WL.Combat.Nav.WLTerrainGuardSettings.Instance;
|
||||
if (tg != null &&
|
||||
WL.Combat.Nav.SpawnPositionGuard.ResolveSpawnPoint(tg, want, 0, out target) != WL.Combat.Nav.SpawnPositionGuard.ResultFailed)
|
||||
pc.Set_Warp(target);
|
||||
else
|
||||
pc.Set_Warp(want);
|
||||
}
|
||||
catch (System.Exception ex) { Log("PC 워프 실패 — " + ex.Message); }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 진단 · 프로브 이음매
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
static void Log(string msg)
|
||||
{
|
||||
LastLog = msg;
|
||||
var c = Cfg;
|
||||
if (c != null && c.verboseLog) Debug.Log("[StageDirector] " + msg);
|
||||
}
|
||||
|
||||
/// <summary>프로브용: 상태·카운터를 전부 비운다(구독은 Unsubscribe 로 따로 끊는다 · 억제는 복원한다).</summary>
|
||||
public static void ResetAll()
|
||||
{
|
||||
ClearStageOverrides();
|
||||
s_state = StageState.Idle;
|
||||
s_def = null; s_index = -1;
|
||||
s_total = s_killed = s_retries = s_batch = 0; s_batchCount = 1;
|
||||
s_limit = 0f;
|
||||
s_startTime = s_pausedTotal = 0f; s_pauseStart = -1f;
|
||||
s_stateEnter = s_nextPoll = s_nextTick = 0f;
|
||||
s_nextBatchAt = -1f;
|
||||
s_faulted = s_respawnStopped = false;
|
||||
s_fail = StageFailReason.None;
|
||||
s_lastResult = default(StageClearResult);
|
||||
s_lastPc = null; s_lastMapId = int.MinValue;
|
||||
EnterCount = StartCount = ClearCount = FailCount = RetryCount = NextCount = LobbyCount = TickCount = BoundaryCount = 0;
|
||||
LastLog = "";
|
||||
}
|
||||
|
||||
/// <summary>프로브용: 러너 없이 1회 틱(폴링 간격 무시).</summary>
|
||||
public static void ForceTick() { s_nextPoll = 0f; Tick(); }
|
||||
|
||||
/// <summary>
|
||||
/// 프로브용: 스테이지 시계를 강제로 되감는다(초 단위로 「경과했다」를 만든다).
|
||||
/// 카운트다운·정지 대기까지 같은 축으로 움직이도록 상태 진입 시각·틱 예약도 함께 당긴다.
|
||||
/// </summary>
|
||||
public static void ShiftClockForProbe(float seconds)
|
||||
{
|
||||
s_startTime -= seconds;
|
||||
s_stateEnter -= seconds;
|
||||
s_nextTick -= seconds;
|
||||
s_nextPoll -= seconds;
|
||||
if (s_nextBatchAt >= 0f) s_nextBatchAt -= seconds;
|
||||
}
|
||||
|
||||
/// <summary>프로브용: PC 사망 실패 경로를 이벤트 없이 직접 태운다.</summary>
|
||||
public static void ForceFailForProbe(StageFailReason reason) { if (s_state == StageState.Combat || s_state == StageState.Countdown) Fail(reason); }
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 러너
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
internal static void EnsureRunner()
|
||||
{
|
||||
if (Application.isPlaying) StageDirectorRunner.Ensure();
|
||||
}
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Boot()
|
||||
{
|
||||
if (!WLStageSettings.Enabled) return; // 꺼져 있으면 오브젝트 0 · 구독 0(C8)
|
||||
EnsureSubscribed();
|
||||
EnsureRunner();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8d815b01a1b2c3d4e5f60708090a0b15
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// StageDirectorRunner.cs — StageDirector 의 시간 축(숨김 GameObject 1개 · 코루틴 0)
|
||||
//
|
||||
// PD 지시 #815 · 발주서 WL-815b §1-2 (2026-09-11)
|
||||
//
|
||||
// 새 Manager/Singleton 을 만들지 않는다 — 813p `RunDirectorRunner` · `BossArenaRunner` ·
|
||||
// `SurvivalRunner` 와 같은 방식이다. HideAndDontSave 라 씬 전환에도 살아남고 씬·프리팹에 저장되지 않는다.
|
||||
// `WLStageSettings.Enabled` 가 false 면 `StageDirector.Boot` 이 이 오브젝트를 아예 만들지 않는다(C8).
|
||||
//
|
||||
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
namespace WL.Combat.Stage
|
||||
{
|
||||
/// <summary>StageDirector 를 매 프레임 틱한다(폴링 간격은 SO pollSeconds 가 결정).</summary>
|
||||
internal sealed class StageDirectorRunner : MonoBehaviour
|
||||
{
|
||||
static StageDirectorRunner s_runner;
|
||||
|
||||
/// <summary>런너 1개 보장(플레이 모드 전용 · 에디트 모드에서는 만들지 않는다).</summary>
|
||||
internal static void Ensure()
|
||||
{
|
||||
if (!Application.isPlaying || s_runner != null) return;
|
||||
var go = new GameObject("[WL815b] StageDirectorRunner");
|
||||
go.hideFlags = HideFlags.HideAndDontSave;
|
||||
s_runner = go.AddComponent<StageDirectorRunner>();
|
||||
}
|
||||
|
||||
/// <summary>런너가 살아 있는가(프로브가 읽는다).</summary>
|
||||
internal static bool Exists { get { return s_runner != null; } }
|
||||
|
||||
void Update() { StageDirector.Tick(); }
|
||||
|
||||
void OnDestroy() { if (ReferenceEquals(s_runner, this)) s_runner = null; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8d815b01a1b2c3d4e5f60708090a0b16
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,357 @@
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// StageSpawner.cs — 「스테이지마다 정해진 수」 배치 · 회수 · **리스폰 0** (원본 코드 0줄)
|
||||
//
|
||||
// PD 지시 #815 · 발주서 WL-815b §1-2 (2026-09-11)
|
||||
// SOT = 기준서 §A-1 (리스폰 0 최소 방법 · 실측 결론)
|
||||
//
|
||||
// ■ 🔴 리스폰 0 = 기준서 §A-1 ⓑ — 원본 수정 0줄
|
||||
// 원본 스포너 타입 4종(`Util/MyEnum.cs:8` Random/Order/Phase/PhaseAllKill)에 「리젠 없음」이 없다.
|
||||
// `MobControlMgr.actDie` 가 `Set_Coroutine(…, f_SpawnerDelay)` 로 **예약**만 하므로,
|
||||
// 그 예약을 `MobControlMgr.StopAllCoroutines()` 로 끊으면 리젠이 영영 오지 않는다.
|
||||
// `BossArena.HoldNearbyZoneSpawns`(BossArena.cs:1080)가 **이미 하고 있는 그대로**이고,
|
||||
// 813w7 이 D-45 로 「그 스포너가 맵 세션 내내 영구 소진」됨을 실측했다 —
|
||||
// **그 부작용이 곧 우리가 원하는 동작**이다.
|
||||
// 🔴 가드: `Get_LoadComplete()` 가 false 인 스포너를 끊으면 **최초 스폰 코루틴**까지 죽는다
|
||||
// (BossArena.cs:1077 과 같은 가드를 그대로 따른다 · SO `respawnStopRequiresLoadComplete`).
|
||||
//
|
||||
// ■ 🔴 고정 수 배치 — 원본이 스스로 쓰는 공개 경로만 쓴다
|
||||
// `MobControlMgr` 의 공개 멤버는 `SpawnerID` · `MobGenRange` · `text_lv` · `Get_LoadComplete()` 뿐이라
|
||||
// 스포너에 「N 마리만 내라」고 시킬 방법이 없다(813w7 실측). 그래서 813w7 `BossArena.RearmZone` 과
|
||||
// **같은 경로**를 쓴다 — `ActorInfo.Get_Actors(eRole.Mob, spawnerId, true)` 의 **비활성** 몹에
|
||||
// `MobActor.On_Regen()`(= `MobControlMgr.cs:221` 과 같은 호출 · 좌표·스폰 이펙트·`RaiseSpawned` 원본 그대로).
|
||||
// 다른 점은 **마리 수를 표가 정한다**는 것 하나다 — 초과분은 깨우지 않으므로 비활성으로 남는다.
|
||||
//
|
||||
// ■ 회수(`Recall`) = `Actor.Off()` — 원본이 스폰 직후 쓰는 그 호출(`MobControlMgr.cs:163 mob.Off()`).
|
||||
//
|
||||
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 플레이어 팝업을 띄운다.
|
||||
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.AI;
|
||||
using WL.Combat.Nav;
|
||||
|
||||
namespace WL.Combat.Stage
|
||||
{
|
||||
/// <summary>스테이지 몬스터의 배치 · 회수 · 리젠 차단. 정적 · 새 Manager/Singleton 0.</summary>
|
||||
public static class StageSpawner
|
||||
{
|
||||
// ── 스포너 캐시(노드는 움직이지 않는다 · 맵이 바뀌면 다시 훑는다)
|
||||
static MobControlMgr[] s_mgrs;
|
||||
static readonly Dictionary<int, MobControlMgr> s_byId = new Dictionary<int, MobControlMgr>(16);
|
||||
|
||||
// ── 진단(프로브가 읽는다)
|
||||
/// <summary>리젠 예약을 끊은 횟수 · 로딩 미완으로 미룬 횟수 · 스포너를 못 찾은 횟수.</summary>
|
||||
public static int RespawnStopCalls, RespawnStopDeferred, SpawnerMissing;
|
||||
/// <summary>배치한 마리 수 · 회수한 마리 수 · 앵커로 워프한 마리 수 · NavMesh 보정 실패 수.</summary>
|
||||
public static int Deployed, Recalled, WarpedToAnchor, WarpFailed;
|
||||
/// <summary>스포너 목록을 훑은 횟수.</summary>
|
||||
public static int Scans;
|
||||
public static string LastLog = "";
|
||||
|
||||
/// <summary>
|
||||
/// 프로브 전용 대체 배치 — null 이 아니면 `On_Regen()` 대신 이것을 부르고 「배치했다」로 센다.
|
||||
/// 에디트 모드에는 살아 있는 `MobActor`(스탯·NavMeshAgent·애니메이터)가 없어 원본 호출을 태울 수 없다.
|
||||
/// 🔴 실기에서는 항상 null(누구도 대입하지 않는다) · Play 종료 시 도메인 리로드로 자동 null.
|
||||
/// </summary>
|
||||
public static System.Func<StageDef, StageWave, int, int> DeployOverride;
|
||||
|
||||
/// <summary>프로브 전용 대체 회수 · 대체 리젠 차단(같은 이유).</summary>
|
||||
public static System.Func<StageDef, int, int> RecallOverride;
|
||||
public static System.Func<StageDef, int, bool> StopRespawnOverride;
|
||||
|
||||
static WLStageSettings Cfg { get { return WLStageSettings.Instance; } }
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 스포너 조회
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>스포너 목록을 다시 훑는다(맵 로드 뒤 1회면 충분하다).</summary>
|
||||
public static void Rescan()
|
||||
{
|
||||
Scans++;
|
||||
s_byId.Clear();
|
||||
s_mgrs = Object.FindObjectsByType<MobControlMgr>(FindObjectsInactive.Include, FindObjectsSortMode.None);
|
||||
for (int i = 0; s_mgrs != null && i < s_mgrs.Length; i++)
|
||||
{
|
||||
var m = s_mgrs[i];
|
||||
if (DSUtil.CheckNull(m)) continue;
|
||||
s_byId[m.SpawnerID] = m;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>스포너 1개를 찾는다(캐시 · 없으면 1회 다시 훑는다).</summary>
|
||||
public static MobControlMgr Find(int spawnerId)
|
||||
{
|
||||
MobControlMgr m;
|
||||
if (s_byId.TryGetValue(spawnerId, out m) && !DSUtil.CheckNull(m)) return m;
|
||||
Rescan();
|
||||
if (s_byId.TryGetValue(spawnerId, out m) && !DSUtil.CheckNull(m)) return m;
|
||||
SpawnerMissing++;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>이 스포너에서 지금 살아 있는(활성 · 미사망) 몹 수.</summary>
|
||||
public static int AliveOf(int spawnerId)
|
||||
{
|
||||
var info = ActorInfo.Ins;
|
||||
if (DSUtil.CheckNull(info)) return 0;
|
||||
List<Actor> actors = null;
|
||||
int n = 0;
|
||||
try
|
||||
{
|
||||
actors = info.Get_Actors(eRole.Mob, spawnerId, true);
|
||||
if (actors == null) return 0;
|
||||
for (int i = 0; i < actors.Count; i++)
|
||||
{
|
||||
var a = actors[i];
|
||||
if (!DSUtil.CheckNull(a) && a.isActiveAndEnabled && !a.IsDead()) n++;
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex) { Log("생존 수 조회 실패 " + spawnerId + " — " + ex.Message); }
|
||||
finally { if (actors != null) info.Release_Actors(actors); }
|
||||
return n;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 🔴 리스폰 0 (기준서 §A-1 ⓑ · 원본 0줄)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 이 스테이지가 쓰는 모든 스포너의 리젠 예약을 끊는다.
|
||||
/// 반환 = 실제로 끊은 스포너 수. 로딩이 안 끝난 스포너는 세지 않고 <paramref name="allStopped"/> 가 false 가 된다
|
||||
/// (호출측이 다음 폴링에 다시 부르면 된다).
|
||||
/// </summary>
|
||||
public static int StopRespawns(StageDef def, out bool allStopped)
|
||||
{
|
||||
allStopped = true;
|
||||
int done = 0;
|
||||
var c = Cfg;
|
||||
if (def == null || def.waves == null || c == null || !c.stopRespawnCoroutines) return 0;
|
||||
|
||||
for (int i = 0; i < def.waves.Length; i++)
|
||||
{
|
||||
var w = def.waves[i];
|
||||
if (w == null || w.spawnerId == 0) continue;
|
||||
if (AlreadyRequested(def, w.spawnerId, i)) continue; // 같은 스포너를 두 번 끊지 않는다
|
||||
|
||||
var ov = StopRespawnOverride;
|
||||
if (ov != null) { if (ov(def, w.spawnerId)) { done++; RespawnStopCalls++; } else { allStopped = false; RespawnStopDeferred++; } continue; }
|
||||
|
||||
var mgr = Find(w.spawnerId);
|
||||
if (mgr == null) { allStopped = false; continue; }
|
||||
|
||||
// 🔴 BossArena.cs:1077 과 같은 가드 — Get_LoadComplete() 전에 끊으면 최초 스폰 코루틴이 죽는다.
|
||||
if (c.respawnStopRequiresLoadComplete && !mgr.Get_LoadComplete()) { allStopped = false; RespawnStopDeferred++; continue; }
|
||||
|
||||
mgr.StopAllCoroutines(); // ← 기준서 §A-1 ⓑ · 원본 수정 0줄
|
||||
RespawnStopCalls++;
|
||||
done++;
|
||||
}
|
||||
if (done > 0) Log("리젠 예약 차단 — 스포너 " + done + "기(전부=" + allStopped + ")");
|
||||
return done;
|
||||
}
|
||||
|
||||
/// <summary>앞선 웨이브가 이미 같은 스포너를 다뤘는가(중복 호출 방지 · 할당 0).</summary>
|
||||
static bool AlreadyRequested(StageDef def, int spawnerId, int upTo)
|
||||
{
|
||||
for (int j = 0; j < upTo; j++)
|
||||
{
|
||||
var p = def.waves[j];
|
||||
if (p != null && p.spawnerId == spawnerId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 고정 수 배치
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 묶음 <paramref name="batch"/> 를 배치한다. 반환 = 이번에 실제로 깨운 마리 수.
|
||||
/// 🔴 「고정 수」= 표의 count. 스포너에 등록된 수가 모자라면 모자란 만큼만 나온다(반환값으로 알 수 있다).
|
||||
/// </summary>
|
||||
public static int Deploy(StageDef def, int batch, string reason)
|
||||
{
|
||||
var c = Cfg;
|
||||
var tbl = WLStageTable.Instance;
|
||||
if (def == null || def.waves == null || c == null || tbl == null) return 0;
|
||||
|
||||
int total = 0;
|
||||
for (int i = 0; i < def.waves.Length; i++)
|
||||
{
|
||||
var w = def.waves[i];
|
||||
if (w == null || w.spawnerId == 0) continue;
|
||||
int want = tbl.CountInBatch(w, batch);
|
||||
if (want <= 0) continue;
|
||||
total += DeployWave(def, w, want, reason);
|
||||
}
|
||||
if (total > 0) Log("배치(" + reason + ") — " + total + "마리 · 묶음 " + batch);
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>웨이브 1개를 <paramref name="want"/> 마리만 깨운다.</summary>
|
||||
public static int DeployWave(StageDef def, StageWave w, int want, string reason)
|
||||
{
|
||||
var ov = DeployOverride;
|
||||
if (ov != null) { int n = ov(def, w, want); Deployed += n; return n; }
|
||||
|
||||
if (!Application.isPlaying || def == null || w == null || want <= 0) return 0;
|
||||
var c = Cfg;
|
||||
if (c == null) return 0;
|
||||
var info = ActorInfo.Ins;
|
||||
if (DSUtil.CheckNull(info)) return 0;
|
||||
|
||||
List<Actor> actors = null;
|
||||
int done = 0, alive = 0;
|
||||
try
|
||||
{
|
||||
// 🔴 `ActorInfo.Get_Actors` 는 dic_actor[role][spawnerId] 를 직접 인덱싱한다 —
|
||||
// 등록이 없는 스포너면 KeyNotFoundException 이 난다(813w7 실측). 배치 실패가 스테이지를 죽이면 안 된다.
|
||||
actors = info.Get_Actors(eRole.Mob, w.spawnerId, true);
|
||||
if (actors == null) return 0;
|
||||
|
||||
// ① 이미 살아 있는 개체를 고정 수에 포함한다(중복 배치 0).
|
||||
for (int i = 0; i < actors.Count; i++)
|
||||
{
|
||||
var a = actors[i];
|
||||
if (!DSUtil.CheckNull(a) && a.isActiveAndEnabled && !a.IsDead()) alive++;
|
||||
}
|
||||
|
||||
// ② 초과분 회수 — 「스테이지마다 정해진 수」를 넘겨 서 있으면 원본 호출로 재운다.
|
||||
if (c.recallExcess && alive > want)
|
||||
{
|
||||
for (int i = 0; i < actors.Count && alive > want; i++)
|
||||
{
|
||||
var a = actors[i];
|
||||
if (DSUtil.CheckNull(a) || !a.isActiveAndEnabled || a.IsDead()) continue;
|
||||
a.Off();
|
||||
Recalled++; alive--;
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 모자란 만큼만 깨운다 — 초과분은 비활성으로 남는다.
|
||||
int need = want - alive;
|
||||
for (int i = 0; i < actors.Count && done < need; i++)
|
||||
{
|
||||
var mob = actors[i] as MobActor;
|
||||
if (DSUtil.CheckNull(mob) || mob.isActiveAndEnabled) continue;
|
||||
mob.On_Regen(); // 원본 웨이브와 같은 호출(MobControlMgr.cs:221)
|
||||
if (c.warpSpawnsToAnchor) WarpIntoStage(def, mob, alive + done);
|
||||
Deployed++;
|
||||
done++;
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex) { Log("웨이브 배치 실패 " + w.spawnerId + " — " + ex.Message); }
|
||||
finally { if (actors != null) info.Release_Actors(actors); }
|
||||
return done;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 배치한 몹을 스테이지 앵커 주변으로 옮긴다(황금각 나선 산포 = 겹침 없이 고르게 · 난수 0 = 재현 가능).
|
||||
/// 🔴 `MobActor.On_Regen()` 은 원본대로 자기 스포너 자리(`OriginalPos`)로 워프하므로,
|
||||
/// 스테이지 앵커가 스포너 자리와 다르면 이 보정이 없으면 장벽 밖에 선다.
|
||||
/// </summary>
|
||||
static void WarpIntoStage(StageDef def, MobActor mob, int slot)
|
||||
{
|
||||
var c = Cfg;
|
||||
float rMax = def.barrierRadius * (c.spawnScatterFactor > 0f ? c.spawnScatterFactor : 0.7f);
|
||||
if (rMax <= 0.01f) return;
|
||||
|
||||
float ang = slot * 2.399963f; // 황금각(rad)
|
||||
float rad = rMax * Mathf.Sqrt((slot + 0.5f) / Mathf.Max(1, def.TotalCount));
|
||||
Vector3 want = def.anchor + new Vector3(Mathf.Cos(ang) * rad, 0f, Mathf.Sin(ang) * rad);
|
||||
|
||||
Vector3 target = want;
|
||||
if (c.resolveSpawnOnNavMesh)
|
||||
{
|
||||
var tg = WLTerrainGuardSettings.Instance;
|
||||
if (tg != null)
|
||||
{
|
||||
Vector3 fixedPos;
|
||||
int how = SpawnPositionGuard.ResolveSpawnPoint(tg, want, mob.Get_Group(), out fixedPos);
|
||||
if (how == SpawnPositionGuard.ResultFailed) { WarpFailed++; return; }
|
||||
target = fixedPos;
|
||||
}
|
||||
else
|
||||
{
|
||||
NavMeshHit hit;
|
||||
if (!NavMesh.SamplePosition(want, out hit, 4f, NavMesh.AllAreas)) { WarpFailed++; return; }
|
||||
target = hit.position;
|
||||
}
|
||||
}
|
||||
mob.Set_Warp(target);
|
||||
WarpedToAnchor++;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 회수
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>이 스테이지 스포너의 살아 있는 몹을 전부 재운다(재도전 전처리). 반환 = 재운 마리 수.</summary>
|
||||
public static int Recall(StageDef def, string reason)
|
||||
{
|
||||
if (def == null || def.waves == null) return 0;
|
||||
int total = 0;
|
||||
for (int i = 0; i < def.waves.Length; i++)
|
||||
{
|
||||
var w = def.waves[i];
|
||||
if (w == null || w.spawnerId == 0) continue;
|
||||
if (AlreadyRequested(def, w.spawnerId, i)) continue;
|
||||
total += RecallSpawner(def, w.spawnerId);
|
||||
}
|
||||
if (total > 0) Log("회수(" + reason + ") — " + total + "마리");
|
||||
return total;
|
||||
}
|
||||
|
||||
/// <summary>스포너 1개의 살아 있는 몹을 전부 재운다.</summary>
|
||||
public static int RecallSpawner(StageDef def, int spawnerId)
|
||||
{
|
||||
var ov = RecallOverride;
|
||||
if (ov != null) { int n = ov(def, spawnerId); Recalled += n; return n; }
|
||||
|
||||
if (!Application.isPlaying) return 0;
|
||||
var info = ActorInfo.Ins;
|
||||
if (DSUtil.CheckNull(info)) return 0;
|
||||
|
||||
List<Actor> actors = null;
|
||||
int done = 0;
|
||||
try
|
||||
{
|
||||
actors = info.Get_Actors(eRole.Mob, spawnerId, true);
|
||||
if (actors == null) return 0;
|
||||
for (int i = 0; i < actors.Count; i++)
|
||||
{
|
||||
var a = actors[i];
|
||||
if (DSUtil.CheckNull(a) || !a.isActiveAndEnabled) continue;
|
||||
a.Off(); // 원본이 스폰 직후 쓰는 호출(MobControlMgr.cs:163)
|
||||
Recalled++; done++;
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex) { Log("회수 실패 " + spawnerId + " — " + ex.Message); }
|
||||
finally { if (actors != null) info.Release_Actors(actors); }
|
||||
return done;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 진단
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
static void Log(string msg)
|
||||
{
|
||||
LastLog = msg;
|
||||
var c = Cfg;
|
||||
if (c != null && c.verboseLog) Debug.Log("[StageSpawner] " + msg);
|
||||
}
|
||||
|
||||
/// <summary>프로브용: 카운터·캐시 초기화(대체 호출은 유지).</summary>
|
||||
public static void ResetDiagnostics()
|
||||
{
|
||||
RespawnStopCalls = RespawnStopDeferred = SpawnerMissing = 0;
|
||||
Deployed = Recalled = WarpedToAnchor = WarpFailed = Scans = 0;
|
||||
LastLog = "";
|
||||
s_byId.Clear(); s_mgrs = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8d815b01a1b2c3d4e5f60708090a0b14
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// WLStageSettings.cs — 스테이지 진행기 값의 단일 출처(SOT · C45)
|
||||
//
|
||||
// PD 지시 #815 · 발주서 WL-815b §1-2 (2026-09-11)
|
||||
//
|
||||
// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLStageSettings.asset
|
||||
//
|
||||
// ■ C8 롤백 (하나라도 false 면 StageDirector 는 아무 일도 하지 않는다 = 813p 런 루프 100%)
|
||||
// ① 에셋이 없다 → Enabled false
|
||||
// ② enabled = false → Enabled false
|
||||
// ③ RuntimeDisabled = true → Enabled false (진단·A/B 전용 · 도메인 리로드로 자동 해제)
|
||||
// ④ WLCombatCoreSettings.Enabled = false → Enabled false
|
||||
// 🔴 ④ 가 필요한 이유: 승리 판정(고정 수 전멸)이 전적으로 `CombatEvents.Killed` 구독이다.
|
||||
// 코어가 꺼진 채 스테이지를 걸면 처치가 0 으로 보여 「무조건 시간 초과 실패」가 된다.
|
||||
//
|
||||
// ■ 🔴 축을 켜도 저절로 시작하지 않는다 — `autoEnterOnMapEnter` 기본 **0**.
|
||||
// 로비·스테이지 선택 UI 가 아직 없다(기준서 §B-1 실측 · 815h 소관). 그래서 켜 두어도
|
||||
// `StageDirector.Enter(index)` 를 부르기 전까지 기존 흐름(813p 런)이 그대로 돈다.
|
||||
//
|
||||
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
using UnityEngine;
|
||||
using WL.Combat.Core;
|
||||
|
||||
namespace WL.Combat.Stage
|
||||
{
|
||||
[CreateAssetMenu(fileName = "WLStageSettings", menuName = "WL/Stage Settings", order = 331)]
|
||||
public sealed class WLStageSettings : ScriptableObject
|
||||
{
|
||||
public const string ResourcesPath = "WL/WLStageSettings";
|
||||
|
||||
static WLStageSettings s_cached;
|
||||
static bool s_lookupDone;
|
||||
|
||||
/// <summary>설정 에셋(1회 로드 후 캐시). 없으면 null.</summary>
|
||||
public static WLStageSettings Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!s_lookupDone)
|
||||
{
|
||||
s_cached = Resources.Load<WLStageSettings>(ResourcesPath);
|
||||
s_lookupDone = true;
|
||||
}
|
||||
return s_cached;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>진단·A/B 전용 런타임 스위치(도메인 리로드로 자동 false).</summary>
|
||||
public static bool RuntimeDisabled;
|
||||
|
||||
/// <summary>스테이지 축이 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off · 코어 on.</summary>
|
||||
public static bool Enabled
|
||||
{
|
||||
get
|
||||
{
|
||||
var i = Instance;
|
||||
return i != null && i.enabled && !RuntimeDisabled && WLCombatCoreSettings.Enabled;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>테스트용: 캐시를 비운다.</summary>
|
||||
public static void ClearCache() { s_cached = null; s_lookupDone = false; }
|
||||
|
||||
// ─────────────────────────────────────────── 전체 스위치
|
||||
[Header("전체 스위치 (C8 롤백 1순위)")]
|
||||
[Tooltip("끄면 스테이지 상태기계·배치·이벤트가 전부 비활성 — 813p 런 루프가 원래대로 돈다.")]
|
||||
public bool enabled = true;
|
||||
|
||||
[Tooltip("입장·클리어·실패·재도전을 Console 에 남긴다(검증용 · 기본 off).")]
|
||||
public bool verboseLog = false;
|
||||
|
||||
// ─────────────────────────────────────────── 시간 축
|
||||
[Header("시간 축")]
|
||||
[Tooltip("상태 폴링 주기(초 · unscaled). 813p RunDirector 와 같은 방식이다.")]
|
||||
public float pollSeconds = 0.1f;
|
||||
|
||||
[Tooltip("TimeChanged / CountdownTick 발행 주기(초 · unscaled). 1 = 1 Hz(HUD 타이머).")]
|
||||
public float tickIntervalSeconds = 1f;
|
||||
|
||||
[Tooltip("813i DeathFlow 가 세계를 멈춘 동안(부활 팝업)은 스테이지 시계를 세운다.")]
|
||||
public bool pauseTimerWhileWorldHeld = true;
|
||||
|
||||
// ─────────────────────────────────────────── 입장
|
||||
[Header("입장 (기준서 §B-2 1)")]
|
||||
[Tooltip("🔴 맵 진입 경계에서 스테이지 0 번을 자동으로 연다. 기본 0 — 로비·선택 UI 가 아직 없다(815h).")]
|
||||
public bool autoEnterOnMapEnter = false;
|
||||
|
||||
[Tooltip("입장 시 PC 를 스테이지 앵커로 워프한다(Actor.Set_Warp · 813w3 API 재사용).")]
|
||||
public bool warpPlayerToAnchor = true;
|
||||
|
||||
[Tooltip("입장·재도전 시 815c 장벽 훅(StageBarrierHook.Set)을 부른다. 구현이 없으면 no-op.")]
|
||||
public bool useBarrierHook = true;
|
||||
|
||||
[Tooltip("클리어·이탈 시 장벽 훅(StageBarrierHook.Clear)을 부른다.")]
|
||||
public bool clearBarrierOnExit = true;
|
||||
|
||||
// ─────────────────────────────────────────── 배치 · 리스폰 0
|
||||
[Header("몬스터 고정 수 배치 · 리스폰 0 (기준서 §A-1 ⓑ)")]
|
||||
[Tooltip("🔴 리스폰 0 — 스테이지 스포너의 리젠 예약 코루틴을 끊는다(MobControlMgr.StopAllCoroutines · 원본 0줄).")]
|
||||
public bool stopRespawnCoroutines = true;
|
||||
|
||||
[Tooltip("🔴 Get_LoadComplete() 가 true 인 스포너만 끊는다(BossArena.cs:1077 가드 그대로). 끄면 최초 스폰이 죽을 수 있다 — 켜 둘 것.")]
|
||||
public bool respawnStopRequiresLoadComplete = true;
|
||||
|
||||
[Tooltip("배치한 몹을 스테이지 앵커 주변으로 워프한다. 끄면 각자 원래 스포너 자리(MobActor.On_Regen 의 OriginalPos)에 선다.")]
|
||||
public bool warpSpawnsToAnchor = true;
|
||||
|
||||
[Tooltip("몹 산포 반경 = barrierRadius × 이 값. 장벽 안에 들어오도록 1 보다 작게 둔다.")]
|
||||
public float spawnScatterFactor = 0.7f;
|
||||
|
||||
[Tooltip("산포 좌표를 NavMesh 로 보정한다(813t2 SpawnPositionGuard.ResolveSpawnPoint 재사용 · 실패하면 워프하지 않는다).")]
|
||||
public bool resolveSpawnOnNavMesh = true;
|
||||
|
||||
[Tooltip("고정 수를 넘겨 살아 있는 몹은 회수한다(Actor.Off · 원본이 스폰 직후 쓰는 그 호출).")]
|
||||
public bool recallExcess = true;
|
||||
|
||||
[Tooltip("한 스테이지에서 다룰 스포너 수 상한(버퍼 크기 · GC 0).")]
|
||||
public int maxSpawnersPerStage = 8;
|
||||
|
||||
// ─────────────────────────────────────────── 재도전 (기준서 §E)
|
||||
[Header("재도전 · 실패 (기준서 §E)")]
|
||||
[Tooltip("PC 사망을 스테이지 실패로 본다(DeathFlow.Died 구독).")]
|
||||
public bool failOnPlayerDeath = true;
|
||||
|
||||
[Tooltip("재도전 때 몬스터를 전부 회수한 뒤 다시 배치한다(끄면 남은 몹 위에 채우기만 한다).")]
|
||||
public bool retryRecallsMonsters = true;
|
||||
|
||||
[Tooltip("재도전 때 813i 물약 잔량을 되돌린다(PotionUse.ResetRun).")]
|
||||
public bool retryResetsPotions = true;
|
||||
|
||||
[Tooltip("재도전 때 PC 를 전회복시킨다(MyActor.On_Regen(false, 표의 retryHealRate) · 813z 와 같은 경로).")]
|
||||
public bool retryHealsPlayer = true;
|
||||
|
||||
[Tooltip("🔴 임시 배선 — 813z ReviveDialog 의 [확인] 을 Retry() 로 잇는다(DeathFlow.ReviveOverride). 815d 가 2버튼 팝업을 붙이면 끈다.")]
|
||||
public bool bindReviveOkToRetry = true;
|
||||
|
||||
// ─────────────────────────────────────────── 클리어
|
||||
[Header("클리어 · 결과")]
|
||||
[Tooltip("전멸 직후 세계를 늦춘다(TimeScaleArbiter.Cinematic · 표의 clearHoldSeconds 동안). 0 = 완전 정지 · 1 = 늦추지 않음.")]
|
||||
public float clearHoldScale = 0f;
|
||||
|
||||
[Tooltip("클리어·실패 시 813p RunEvents.RunEnded 를 **병행 발행**한다(813q 결과창·813l 등 기존 구독자 호환).")]
|
||||
public bool mirrorRunEnded = true;
|
||||
|
||||
[Tooltip("입장·틱에 RunEvents.RunStarted / RunTick 도 병행 발행한다(기존 RunHud 가 그대로 돈다).")]
|
||||
public bool mirrorRunHudEvents = true;
|
||||
|
||||
[Tooltip("클리어 때 RunEvents.ZoneCleared 를 **1회** 발행한다 = 813o ZonePickup 카드 트리거(기준서 §A 「트리거만 교체」).")]
|
||||
public bool cardOnStageClear = true;
|
||||
|
||||
[Tooltip("[다음 스테이지] 가 없을 때(마지막 스테이지 클리어) 처음으로 돌아간다. 끄면 결과에서 멈춘다.")]
|
||||
public bool wrapToFirstStage = false;
|
||||
|
||||
// ─────────────────────────────────────────── 기존 런 요소 처리
|
||||
[Header("기존 런 요소 (스테이지 중에만 끈다 · 자동 복원 = C8)")]
|
||||
[Tooltip("🔴 스테이지 중에는 813p RunDirector 를 재운다(WLRunSettings.RuntimeDisabled). 이탈하면 자동 복원.")]
|
||||
public bool suppressRunDirector = true;
|
||||
|
||||
[Tooltip("🔴 스테이지 중에는 RunGuide 존 이동 유도를 끈다(WLRunGuideSettings.RuntimeDisabled). 장벽 안에서는 유도가 필요 없다.")]
|
||||
public bool disableRunGuide = true;
|
||||
|
||||
[Tooltip("스테이지에 들어갈 때 RunDirector 상태를 Idle 로 비운다(RunDirector.ResetAll · 런 밖 사망 자동부활 오작동 방지).")]
|
||||
public bool resetRunDirectorOnEnter = true;
|
||||
|
||||
[Tooltip("로비로 나갈 때 원본 InGameInfo.Return_To_Lobby() 를 부른다. 🔴 기본 0 — 로비 맵 행이 아직 없다(기준서 §B-1 · 815h).")]
|
||||
public bool toLobbyCallsReturnToLobby = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8d815b01a1b2c3d4e5f60708090a0b13
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// WLStageTable.cs — 스테이지 데이터의 단일 출처(SO · WL 규약 C45 · 기준서 §C)
|
||||
//
|
||||
// PD 지시 #815 · 발주서 WL-815b §1-1 (2026-09-11)
|
||||
//
|
||||
// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLStageTable.asset
|
||||
// → Resources.Load<WLStageTable>("WL/WLStageTable") 로 1회 로드 후 캐시
|
||||
// (WLRunSettings · WLBossArenaSettings 등 기존 설정 SO 관례 그대로).
|
||||
//
|
||||
// ■ 왜 SO 인가 (기준서 §C-1 실측)
|
||||
// `MonsterAppear.json` 은 **한 줄 JSON** 이라 텍스트 병합이 안 되고, PD 가 `butler.xlsm` 을
|
||||
// 재익스포트하면 WL 행이 통째로 사라진다. 그래서 스테이지 표는 WL 소유 SO 로 둔다.
|
||||
// 🔴 몬스터 자체의 값(HP/ATK/드랍)은 여전히 `MonsterAppear.json` 이 SOT 다 — 이 표는
|
||||
// 「어느 스포너의 몹을 몇 마리, 어디에」만 정한다(원본 테이블 0줄).
|
||||
//
|
||||
// ■ 🔴 스포너 용량 실측 (`Assets/ResWork/Table/Export/MonsterAppear.json` · 2026-09-11)
|
||||
// 813001 PhaseAllKill 12마리 101|102|103|104 813002 PhaseAllKill 15마리 168|169|170|171|172
|
||||
// 813003 PhaseAllKill 14마리 173|174|175|146|147|151 813004 PhaseAllKill 8마리 118|119|120
|
||||
// 813005 Order 5마리 1014|1015|1016 813010 Random 1마리 10006(Anubis)
|
||||
// 813021 Random 1마리 1023 813022 Random 1마리 1028 813023 Random 1마리 1009 813024 Random 1마리 1024
|
||||
// → 한 스포너에서 등록 수보다 많이 꺼낼 수 없다. 그래서 기준서 §C-3 의 「수」는 유지하되
|
||||
// **여러 스포너로 나눠 담았다**(아래 표 주석에 스테이지별 분배가 있다).
|
||||
//
|
||||
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
namespace WL.Combat.Stage
|
||||
{
|
||||
/// <summary>웨이브 배치 방식(기준서 §C-2 `mode`).</summary>
|
||||
public enum StageWaveMode
|
||||
{
|
||||
/// <summary>일괄 — 스테이지 시작에 전부 배치.</summary>
|
||||
All = 0,
|
||||
/// <summary>웨이브 — 앞 묶음이 전멸하면 배치(WLStageTable.wavesPerStage 가 묶음 수).</summary>
|
||||
Wave = 1,
|
||||
/// <summary>시간차 — delaySec 뒤 배치.</summary>
|
||||
Staggered = 2,
|
||||
}
|
||||
|
||||
/// <summary>스테이지 한 묶음의 몬스터(기준서 §C-2 `waves[]` 행).</summary>
|
||||
[System.Serializable]
|
||||
public sealed class StageWave
|
||||
{
|
||||
[Tooltip("원본 MonsterAppear.n_SpawnerID. 이 스포너에 등록된 몹에서 꺼낸다(등록 수가 상한).")]
|
||||
public int spawnerId;
|
||||
|
||||
[Tooltip("이 스포너가 실제로 내보내는 몹 ID(MonsterAppear.s_AppearMonster 실측 · 표시·검증용 · 배치는 원본이 고른다).")]
|
||||
public int[] monsterIds;
|
||||
|
||||
[Tooltip("이 스테이지에서 배치할 마리 수(고정 수). 등록 수를 넘으면 등록 수까지만 나온다.")]
|
||||
public int count = 1;
|
||||
|
||||
[Tooltip("엘리트 묶음인가(813s EliteMarker · 815d 표시 · 815f 예고 배율 대상).")]
|
||||
public bool elite;
|
||||
|
||||
[Tooltip("보스 묶음인가(스테이지 5·10). 보스는 BossArena 게이트/아레나 로직의 대상이다.")]
|
||||
public bool boss;
|
||||
|
||||
[Tooltip("배치 방식 — 일괄 / 웨이브 / 시간차.")]
|
||||
public StageWaveMode mode = StageWaveMode.All;
|
||||
|
||||
[Tooltip("Staggered 일 때 배치까지의 지연(초). 다른 모드에서는 쓰이지 않는다.")]
|
||||
public float delaySec;
|
||||
}
|
||||
|
||||
/// <summary>스테이지 1개(기준서 §C-2 스키마).</summary>
|
||||
[System.Serializable]
|
||||
public sealed class StageDef
|
||||
{
|
||||
[Tooltip("스테이지 번호(1부터).")]
|
||||
public int id = 1;
|
||||
|
||||
[Tooltip("BattleMapConfig.n_MapID (−1 = WL_Nature). 현재 값은 전부 −1 = 한 맵 안에서 영역만 옮긴다.")]
|
||||
public int mapId = -1;
|
||||
|
||||
[Tooltip("시작 영역 중심(월드 좌표 · 기준서 §D-1 실측 앵커).")]
|
||||
public Vector3 anchor;
|
||||
|
||||
[Tooltip("PC 시작 텔레포트 산포 반경(m).")]
|
||||
public float startRadius = 2f;
|
||||
|
||||
[Tooltip("장벽 반경(m) — 815c StageBarrierHook.Set 에 그대로 넘어간다.")]
|
||||
public float barrierRadius = 14f;
|
||||
|
||||
[Tooltip("장벽 모양(1차는 Circle 만).")]
|
||||
public StageBarrierShape barrierShape = StageBarrierShape.Circle;
|
||||
|
||||
[Tooltip("이 스테이지의 몬스터 묶음. 합계가 「고정 수」이고 그 전멸이 승리 조건이다.")]
|
||||
public StageWave[] waves;
|
||||
|
||||
[Tooltip("보스 스테이지인가(기준서 §C-3 5·10).")]
|
||||
public bool isBoss;
|
||||
|
||||
[Tooltip("보스 MonsterList.n_MonsterID(실측 10006 = Anubis). isBoss 가 아니면 0.")]
|
||||
public int bossMonsterId;
|
||||
|
||||
[Tooltip("클리어 보상 — 카드 3택 장수(0 = 카드 없음).")]
|
||||
public int clearRewardCards = 1;
|
||||
|
||||
[Tooltip("클리어 보상 — 골드 배율(1 = 기본).")]
|
||||
public float goldMultiplier = 1f;
|
||||
|
||||
[Tooltip("권장 전투력(813l 산식) · 0 = 미설정(🔴 아직 실측값 없음).")]
|
||||
public int recommendPower;
|
||||
|
||||
[Tooltip("§G 예고(텔레그래프) 시간 배율 — 815f 가 읽는다. 클수록 예고가 길다(쉽다).")]
|
||||
public float telegraphScale = 1f;
|
||||
|
||||
[Tooltip("이 스테이지만의 제한 시간(초). 0 이하면 표 전역 stageTimeLimitSeconds 를 쓴다.")]
|
||||
public float timeLimitOverrideSeconds;
|
||||
|
||||
/// <summary>이 스테이지의 고정 수(모든 묶음 합) — 승리에 필요한 처치 수.</summary>
|
||||
public int TotalCount
|
||||
{
|
||||
get
|
||||
{
|
||||
int n = 0;
|
||||
if (waves != null)
|
||||
for (int i = 0; i < waves.Length; i++)
|
||||
if (waves[i] != null && waves[i].count > 0) n += waves[i].count;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>이 스포너가 이 스테이지에 속하는가(처치 집계 · 리스폰 차단 대상 판정).</summary>
|
||||
public bool HasSpawner(int spawnerId)
|
||||
{
|
||||
if (waves == null) return false;
|
||||
for (int i = 0; i < waves.Length; i++)
|
||||
if (waves[i] != null && waves[i].spawnerId == spawnerId) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[CreateAssetMenu(fileName = "WLStageTable", menuName = "WL/Stage Table", order = 330)]
|
||||
public sealed class WLStageTable : ScriptableObject
|
||||
{
|
||||
public const string ResourcesPath = "WL/WLStageTable";
|
||||
|
||||
static WLStageTable s_cached;
|
||||
static bool s_lookupDone;
|
||||
|
||||
/// <summary>표 에셋(1회 로드 후 캐시). 없으면 null.</summary>
|
||||
public static WLStageTable Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!s_lookupDone)
|
||||
{
|
||||
s_cached = Resources.Load<WLStageTable>(ResourcesPath);
|
||||
s_lookupDone = true;
|
||||
}
|
||||
return s_cached;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다.</summary>
|
||||
public static void ClearCache() { s_cached = null; s_lookupDone = false; }
|
||||
|
||||
// ─────────────────────────────────────────── 전역 값 (기준서 §C-3 · PD 추가 규칙)
|
||||
[Header("전역 값 (PD 지시 #815 00:2x)")]
|
||||
[Tooltip("🔴 모든 스테이지 제한 시간(초). 초과 = 실패 → 재도전. 스테이지별 override 가 0 보다 크면 그쪽이 이긴다.")]
|
||||
public float stageTimeLimitSeconds = 60f;
|
||||
|
||||
[Tooltip("재도전 시 HP 회복 비율(1 = 전회복 · 기준서 §E `restartHealRate`).")]
|
||||
public float retryHealRate = 1f;
|
||||
|
||||
[Tooltip("입장 후 전투 시작까지의 카운트다운(초 · 기준서 §B-2 1.5 「제안」).")]
|
||||
public float countdownSeconds = 1.5f;
|
||||
|
||||
[Tooltip("전멸 직후 정지(초 · 기준서 §B-2 3 「정지 1.0 s」).")]
|
||||
public float clearHoldSeconds = 1f;
|
||||
|
||||
[Tooltip("스테이지당 웨이브 묶음 수(1 = 초안 · 2 = 전멸 → 다음 묶음). 각 묶음의 마리 수는 표의 count 를 균등 분할한다.")]
|
||||
public int wavesPerStage = 1;
|
||||
|
||||
[Tooltip("웨이브 묶음 사이의 간격(초 · wavesPerStage ≥ 2 일 때만).")]
|
||||
public float waveGapSeconds = 1.5f;
|
||||
|
||||
// ─────────────────────────────────────────── 표
|
||||
[Header("스테이지 표 (기준서 §C-3 10스테이지 초안 · 전부 「제안」)")]
|
||||
[Tooltip("스테이지 목록(순서 = 진행 순서). 인덱스 0 = 스테이지 1.")]
|
||||
public StageDef[] stages;
|
||||
|
||||
/// <summary>스테이지 수.</summary>
|
||||
public int Count { get { return stages != null ? stages.Length : 0; } }
|
||||
|
||||
/// <summary>인덱스로 스테이지를 얻는다(범위 밖이면 null).</summary>
|
||||
public StageDef Get(int index) { return stages != null && index >= 0 && index < stages.Length ? stages[index] : null; }
|
||||
|
||||
/// <summary>표의 `id` 로 인덱스를 찾는다(없으면 −1).</summary>
|
||||
public int IndexOfId(int stageId)
|
||||
{
|
||||
if (stages == null) return -1;
|
||||
for (int i = 0; i < stages.Length; i++) if (stages[i] != null && stages[i].id == stageId) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>이 스테이지의 제한 시간(초) — override 가 0 보다 크면 그 값, 아니면 전역값.</summary>
|
||||
public float TimeLimitOf(StageDef def)
|
||||
{
|
||||
if (def != null && def.timeLimitOverrideSeconds > 0f) return def.timeLimitOverrideSeconds;
|
||||
return stageTimeLimitSeconds;
|
||||
}
|
||||
|
||||
/// <summary>웨이브 묶음 수(1 이상으로 보정).</summary>
|
||||
public int WaveBatches { get { return wavesPerStage > 1 ? wavesPerStage : 1; } }
|
||||
|
||||
/// <summary>
|
||||
/// 묶음 b(0부터) 에서 이 웨이브가 배치할 마리 수 — count 를 WaveBatches 로 균등 분할하고 나머지는 앞 묶음에 준다.
|
||||
/// wavesPerStage = 1 이면 b=0 에 전부(초안 동작).
|
||||
/// </summary>
|
||||
public int CountInBatch(StageWave w, int batch)
|
||||
{
|
||||
if (w == null || w.count <= 0) return 0;
|
||||
int n = WaveBatches;
|
||||
if (n <= 1) return batch == 0 ? w.count : 0;
|
||||
if (batch < 0 || batch >= n) return 0;
|
||||
int baseN = w.count / n, rem = w.count % n;
|
||||
return baseN + (batch < rem ? 1 : 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8d815b01a1b2c3d4e5f60708090a0b12
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Loading…
Reference in New Issue