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