// ───────────────────────────────────────────────────────────────────────────── // RunEvents.cs — WL 3분 런 통지 허브(정적) · 813q(결과 화면 UI)의 유일한 구독 지점 // // PD 지시 #813 · 발주서 WL-813p §1 (2026-09-09) // // ■ 왜 별도 파일인가 // 코어 허브 `Assets/WL/Combat/Core/CombatEvents.cs` 는 811d~j 가 동시에 물고 있어 수정 금지다. // 「런」은 811 12종·813h BossEvents·813k GrowthEvents 에 없던 새 축이므로 같은 규약으로 따로 둔다. // · 정적 클래스(새 Manager/Singleton 0) · 구조체 페이로드를 `in` 으로 전달(복사·박싱 0) // · 구독자 = 사전 할당 배열(코어의 CombatEventList 재사용 — Assembly-CSharp 단일 어셈블리) // · 구독자 예외는 try/catch 로 격리(CombatEventList.Dispatch 안) → 런 흐름을 끊지 않는다 // · 발행은 전부 RunDirector 안(이 파일은 계약 + 구독자 목록만 들고 있다) // // ■ 813q(UI)가 구독할 것 — 발주서 §4 계약표와 1:1 // RunStarted 런 시작(HUD 타이머 리셋 · 존 게이지 리셋) // RunTick 1 Hz(SO tickIntervalSeconds) — 남은 시간 · 현재 존 진행률 // ZoneCleared 존 1개 클리어(존 게이지 완료 연출) // BossGateOpened 보스 게이트 개방(배너 · "보스 등장" 안내) // BossStarted 보스가 실제로 월드에 등장(보스 HP 바 · 보스 타이머 시작) // RunEnded 런 종료 — RunResult 1개로 결과 화면 전부를 그린다 // // 🔴 RunResult 안의 배열(zoneClearSec · zoneKills · lootByGrade)은 RunDirector 가 **재사용**한다. // 구독자는 값을 즉시 읽어 자기 버퍼에 복사할 것 — 참조를 보관하면 다음 런에서 덮어써진다(GC 0 대가). // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; using WL.Combat.Core; namespace WL.Combat.Run { /// 런 종료 사유. public enum RunOutcome { /// 아직 끝나지 않았다(진행 중 스냅샷). None = 0, /// 보스(Anubis) 처치 — 승리. Win = 1, /// runSeconds 초과 — 보스 미처치. Timeout = 2, /// 런 미완 이탈(로비 복귀 · 맵 이동 · PC 소실). Abandon = 3, } /// 런 진행 단계. public enum RunPhase { /// 런 없음(타이틀 · 로비 · 종료 후 대기). Idle = 0, /// 존 진행 중(게이트 미개방). Zones = 1, /// 게이트는 열렸으나 보스가 아직 월드에 없다. BossGate = 2, /// 보스전 진행 중. Boss = 3, /// 종료(RunEnded 발행 완료 · 다음 런 대기). Ended = 4, } /// 보스 게이트를 연 이유. public enum RunGateReason { None = 0, /// 존을 전부 클리어했다(openGateOnZonesCleared). ZonesCleared = 1, /// openGateAtSec 경과(존이 남았어도 연다). TimeLimit = 2, /// 외부에서 RunDirector.OpenBossGate() 를 직접 불렀다(QA·치트·813q 버튼). External = 3, /// 보스가 게이트보다 먼저 월드에 있었다(BossArena off · 813e 상시 배치). AlreadySpawned = 4, } // ───────────────────────────────────────────── 페이로드 (struct · 발생 시점 스냅샷 · GC 0) /// 런 시작. public struct RunStartedEvent { /// 이 세션에서 몇 번째 런인가(1부터). Restart 마다 +1. public int runIndex; /// 런이 시작된 시각(Time.unscaledTime). public float startTime; /// 제한 시간(초 · SO runSeconds) — UI 타이머 최대값. public float runSeconds; /// 이 런의 존 수(SO zoneSpawnerIds 길이) — UI 존 게이지 개수. public int zoneCount; /// 시작 사유("map 1" · "pc" · "restart" · "manual"). public string reason; public float time; public int frame; } /// 존 1개 클리어(누적 처치가 임계에 도달한 순간 · 존당 1회). public struct ZoneClearedEvent { /// zoneSpawnerIds 안의 인덱스(0부터). public int zoneIndex; /// 그 존의 SpawnerID(813001~). public int spawnerId; /// 런 시작 후 경과 초. public float elapsedSec; /// 런 시작 시점 대비 그 존의 처치 수 · 임계값. public int kills, required; /// 이번 클리어로 모든 존이 끝났는가. public bool allZonesCleared; public float time; public int frame; } /// 보스 게이트 개방(런당 1회). public struct BossGateOpenedEvent { /// 런 시작 후 경과 초. public float elapsedSec; /// 왜 열렸는가. public RunGateReason reason; /// 보스 스포너 ID(SO bossSpawnerId). public int bossSpawnerId; /// BossArena.ForceOpenGate() 가 실제로 먹혔는가(게이트가 무장돼 있었는가). false 면 통지만 났다. public bool arenaGateArmed; public float time; public int frame; } /// 보스가 월드에 등장(런당 1회). public struct BossStartedEvent { /// 런 시작 후 경과 초. public float elapsedSec; /// 등장한 보스의 SpawnerID. public int spawnerId; /// 등장한 보스(BossArena.Boss · 폴백 경로로 잡았으면 null 일 수 있다 — 반드시 null 검사). public Actor boss; public float time; public int frame; } /// 런 타이머 틱(기본 1 Hz · UI 타이머·존 게이지 갱신용). public struct RunTickEvent { /// 런 시작 후 경과 초 · 남은 초(0 미만으로 내려가지 않는다). public float elapsedSec, remainingSec; /// 현재 존 인덱스 = 아직 클리어하지 않은 첫 존(전부 클리어면 zoneCount). public int zoneIndex; /// 현재 존의 처치 수 · 임계값(전부 클리어면 0/0). public int zoneKills, zoneRequired; /// 지금까지 클리어한 존 수 · 전체 존 수. public int zonesCleared, zoneCount; /// 현재 단계. public RunPhase phase; /// 이 세션에서 몇 번째 런인가. public int runIndex; public float time; public int frame; } /// /// 런 결과 — 813q 결과 화면이 이 하나로 전부를 그린다. /// 🔴 배열 3종은 RunDirector 가 재사용한다. 값을 즉시 읽어 복사할 것(참조 보관 금지). /// public struct RunResult { /// 종료 사유. public RunOutcome outcome; /// 이 세션에서 몇 번째 런인가(1부터). public int runIndex; /// 런 전체 경과 초(pauseTimerWhileWorldHeld 면 부활 팝업 시간은 뺀다). public float totalSec; /// 제한 시간(초 · SO runSeconds). public float runSeconds; /// 존별 클리어 시각(런 시작 후 초). 미클리어는 −1. 길이 = zoneCount. public float[] zoneClearSec; /// 존별 처치 수(런 시작 시점 대비). 길이 = zoneCount. public int[] zoneKills; /// 존 수 · 클리어한 존 수. public int zoneCount, zonesCleared; /// 보스 게이트 개방 시각 · 보스 등장 시각 · 보스 처치 시각(런 시작 후 초 · 미발생 −1). public float bossGateSec, bossStartSec, bossClearSec; /// /// 🔴 813w6(D-44) — 런 종료 순간의 보스 HP 비율(0~1). 보스가 없거나 못 읽으면 **−1**. /// **BossArena.ParkBoss(만피 복구) 이전** 값이다(순서 = 스냅샷 → RunEnded 발행 → Park). /// 결과창 「보스 처치 : 미처치 (HP 남음 N %)」는 이 값을 쓰면 된다(813q2 는 UI 자체 실측이라 만피를 읽었다). /// public float bossHpRatio; /// 런 종료 순간의 보스 HP · 최대 HP(못 읽으면 0 · 813w6). public double bossHp, bossHpMax; /// 런 중 처치 수(보스 포함) · 그중 보스 처치 수. public int kills, bossKills; /// 런 중 최대 연쇄 처치 수(813e KillChain). public int maxChain; /// 단일 타격 최대 피해 · 합산(DamageMergeEvent.total) 최대 피해. public double maxDamage, maxDamageBurst; /// 런 시작·종료 시점 레벨(813k GrowthEvents · 플레이 모드가 아니면 0). public int levelFrom, levelTo; /// 등급별 획득 수 — **인덱스 = 등급(1~WLLootSettings.GradeSlots)** · [0] 미사용. 길이 = GradeSlots+1. public int[] lootByGrade; /// 획득 총 개수(골드 제외) · 획득 골드 총량. public int lootTotal; public long gold; /// 사망 수(813i DeathFlow · 런을 끝내지 않는다) · 물약 사용 수 · 스킬 시전 수(메인 PC). public int deaths, potionsUsed, skillCasts; public float time; public int frame; } /// 런 이벤트 허브. 정적 · GC 0 · 구독은 각 목록의 Add / Remove. public static class RunEvents { /// 런 시작. public static readonly CombatEventList RunStarted = new CombatEventList(4); /// 존 클리어(존당 1회). public static readonly CombatEventList ZoneCleared = new CombatEventList(4); /// 보스 게이트 개방(런당 1회). public static readonly CombatEventList BossGateOpened = new CombatEventList(4); /// 보스 등장(런당 1회). public static readonly CombatEventList BossStarted = new CombatEventList(4); /// 런 종료 — 페이로드가 곧 RunResult. public static readonly CombatEventList RunEnded = new CombatEventList(4); /// 런 타이머 틱(기본 1 Hz). public static readonly CombatEventList RunTick = new CombatEventList(4); /// 런 축이 살아 있는가 = 에셋 · enabled · 런타임 스위치 · 코어(C8). public static bool Enabled { get { return WLRunSettings.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; } /// 프로브용: 카운터 초기화(구독은 유지). public static void ResetDiagnostics() { TotalRaised = 0; LastEvent = ""; LastEventTime = 0f; RunStarted.Raised = RunStarted.Dispatched = 0; ZoneCleared.Raised = ZoneCleared.Dispatched = 0; BossGateOpened.Raised = BossGateOpened.Dispatched = 0; BossStarted.Raised = BossStarted.Dispatched = 0; RunEnded.Raised = RunEnded.Dispatched = 0; RunTick.Raised = RunTick.Dispatched = 0; } } }