Project_WL/Assets/WL/Combat/Auto/AutoCombatOnEnter.cs

223 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// AutoCombatOnEnter.cs — 맵 진입 시 자동전투 기본 ON (Q3 D-2)
//
// PD 지시 #813 · 발주서 WL-813x §1-2 (2026-09-09) · 근거 = qa/W2-3_바퀴2.md D-2
//
// ■ 관측된 결함 (QA 실측)
// 신규 계정 기본값이 `eCommon.Auto = 0` 이라 인게임에 들어가도 자동전투가 꺼져 있다.
// 존1 60 s 방치 = **0킬 · skillCast 0**. `Actor.Set_Auto()` 1회 뒤 같은 조건 60 s = **23킬**.
// (W2-1 의 「스킬 0발」 미확인 항목이 여기서 해소됐다.) 이 값이 0인 채로 PD 가 Play 하면
// "스킬도 안 나가고 적도 안 죽는 게임"을 보게 된다.
//
// ■ 원인 (코드 실측)
// PCActor.cs:185 `m_Auto = m_ServerData.Get_Common(eCommon.Auto) == 1;` — ReSet() 마다 서버 값에서 다시 읽는다.
// PCActor.cs:93 의 자동 스킬 루프가 `m_Auto` 로 게이트된다 → false 면 스킬을 한 발도 쏘지 않는다.
// Actor.cs:2260 `Set_Auto()` 는 m_Auto 를 토글하고 같은 값을 eCommon.Auto 에 넣는다(둘은 항상 동기).
//
// ■ 이 파일이 하는 것 — 원본 훅 0 · 폴링만(Survival.PollRunBoundary 선례)
// 런(맵) 진입 경계를 관측해서 그 시점에 자동전투가 꺼져 있으면 `MyValue.MyPC.Set_Auto()` 를 **1회** 부른다.
// · 경계 = 메인 PC 교체(PCActor.ReSet 이 MyValue.MyPC 를 갈아 끼운다) 또는 맵 ID 변경.
// · 진입 뒤 플레이어가 직접 끈 것은 다시 켜지 않는다(경계당 1회이므로).
// · 로비는 기본 제외(적이 없어 자동 타겟팅이 의미가 없다) — autoCombatInLobby 로 켤 수 있다.
// · Set_Auto() 는 m_Auto 와 eCommon.Auto 를 함께 세우므로, 뒤늦게 도는 PCActor.ReSet() 이
// 다시 읽어도 ON 이 유지된다.
//
// ■ 계정 영구 변경 아님
// ServerClass.Set_Common(ServerClass.cs:1469) 은 로컬 캐시 배열만 쓴다(서버 저장 호출 없음).
// QA 도 "슬롯 2에서 켠 값이 슬롯 3 로그인에 안 남았다 = 세션 간 비영속"을 실측했다.
// → 이 핫픽스는 매 진입마다 다시 켜 주는 방식이지 계정 기본값을 바꾸는 것이 아니다.
// 계정 기본값(신규 계정 Auto=1)은 서버/테이블 소관 = S 트랙 · PD 결정.
//
// ■ 전투 패드 아이콘
// BattleUI.Set_Auto()(private)가 `Get_Common(eCommon.Auto)` 로 스프라이트를 고른다.
// UI 가 먼저 떴다면 아이콘이 OFF 로 굳으므로, 적용 뒤 잠시 동안 public 필드
// `BattleUI.images[0]` 의 스프라이트만 맞춘다(UI 파일 수정 0 · 813c 패드 배치와 무관).
//
// ■ 값(C45) 전부 WLCombatCoreSettings. autoCombatOnEnter = 0 이면 러너 자체가 뜨지 않는다(C8 롤백).
// 🔴 코어 스위치(WLCombatCoreSettings.Enabled)에는 의존하지 않는다 — 코어 A/B 가 킬 수를 0으로 만들면 안 된다.
//
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(Actor/PCActor 가 Assembly-CSharp 에 있다).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using UnityEngine.UI;
using WL.Combat.Core;
namespace WL.Combat.Auto
{
/// <summary>맵 진입 시 자동전투 1회 ON. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개).</summary>
public static class AutoCombatOnEnter
{
static WLCombatCoreSettings Cfg { get { return WLCombatCoreSettings.Instance; } }
/// <summary>기능이 살아 있는가. 에셋이 없거나 플래그가 꺼져 있으면 false = 원본 동작 100%.</summary>
public static bool Active
{
get { var c = Cfg; return c != null && c.autoCombatOnEnter && !RuntimeDisabled; }
}
/// <summary>진단·A/B 전용 런타임 스위치. Play 종료 시 도메인 리로드로 자동 false.</summary>
public static bool RuntimeDisabled;
// ───────────────────────────────────────── 진단 카운터 (프로브가 읽는다)
public static int Boundaries, Applied, AlreadyOn, SkippedLobby, IconFixed;
public static string LastReason = "", LastLog = "";
/// <summary>현재 자동전투 값(서버 데이터 캐시). 프로브가 「진입 후 true」를 확인하는 지점.</summary>
public static bool IsAutoOn
{
get
{
// 🔴 ServerInfo.Ins 는 없으면 GameObject 를 만든다(ServerInfo.cs:19) — 에디트 모드를 오염시키지 않는다.
if (!Application.isPlaying) return false;
var si = ServerInfo.Ins;
if (DSUtil.CheckNull(si) || si.m_ServerData == null) return false;
return si.m_ServerData.Get_Common(eCommon.Auto) == 1;
}
}
// ───────────────────────────────────────── 내부 상태
static PCActor s_lastPc;
static int s_lastMapId = int.MinValue;
static float s_nextPoll, s_iconDeadline;
static bool s_faulted;
// ─────────────────────────────────────────────────────────────────────
// 틱
// ─────────────────────────────────────────────────────────────────────
/// <summary>런너가 매 프레임 부른다(에디트 모드에서는 프로브가 직접 부를 수 있다).</summary>
internal static void Tick()
{
if (s_faulted || !Active) return;
var cfg = Cfg;
float now = Time.unscaledTime;
if (now < s_nextPoll) return;
s_nextPoll = now + Mathf.Max(0.02f, cfg.autoCombatPollSeconds);
// 🔴 Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 플레이어 팝업으로 띄운다(같은 커밋 §D-1).
try
{
PollBoundary(cfg);
if (now < s_iconDeadline) SyncPadIcon();
}
catch (System.Exception ex) { s_faulted = true; Log("중단(예외) — " + ex.Message); }
}
static void PollBoundary(WLCombatCoreSettings cfg)
{
var pc = MyValue.MyPC; // 파괴된 참조는 Unity != 로 걸러진다
if (DSUtil.CheckNull(pc)) { s_lastPc = null; return; } // PC 없음(타이틀·로딩) — 다음 등장을 경계로 본다
// InGameInfo 가 아직 없으면 경계를 「소비」하지 않는다 — 다음 폴링에서 같은 경계를 다시 본다.
var info = InGameInfo.Ins;
if (DSUtil.CheckNull(info)) return;
int map = MyValue.MyChoiceMapData != null ? MyValue.MyChoiceMapData.n_MapID : int.MinValue;
bool pcChanged = !ReferenceEquals(pc, s_lastPc);
bool mapChanged = map != s_lastMapId;
if (!pcChanged && !mapChanged) return;
s_lastPc = pc; s_lastMapId = map;
Boundaries++;
LastReason = pcChanged ? "pc" : ("map " + map);
// WL 인게임 맵(WL_Nature)은 BattleMapConfig n_MapID 1 · e_MapType = Stage 로 실측 — 로비가 아니다.
if (!cfg.autoCombatInLobby && info.Get_GameMode() == eGameMode.Lobby)
{
SkippedLobby++;
Log("로비 — 건너뜀(" + LastReason + ")");
return;
}
if (IsAutoOn) { AlreadyOn++; Log("이미 ON — 무동작(" + LastReason + ")"); ArmIcon(cfg); return; }
pc.Set_Auto(); // m_Auto = true + eCommon.Auto = 1 (Actor.cs:2260)
Applied++;
Log("자동전투 ON 적용 #" + Applied + " (" + LastReason + ") → auto=" + (IsAutoOn ? "1" : "0"));
ArmIcon(cfg);
}
internal static bool ReadBoundarySources(out PCActor pc, out int mapId, out bool inLobby) { // 813p 공용 — 원천 값만 읽는다(판정·소비 0 · 동작 변경 0)
pc = DSUtil.CheckNull(MyValue.MyPC) ? null : MyValue.MyPC; var info = InGameInfo.Ins;
mapId = MyValue.MyChoiceMapData != null ? MyValue.MyChoiceMapData.n_MapID : int.MinValue;
inLobby = !DSUtil.CheckNull(info) && info.Get_GameMode() == eGameMode.Lobby; return !DSUtil.CheckNull(info); }
static void ArmIcon(WLCombatCoreSettings cfg)
{
if (cfg.autoCombatIconRetrySeconds > 0f)
s_iconDeadline = Time.unscaledTime + cfg.autoCombatIconRetrySeconds;
}
/// <summary>전투 패드 자동 버튼 아이콘을 현재 값에 맞춘다. UI 파일 수정 0 — public 필드만 읽고 쓴다.</summary>
static void SyncPadIcon()
{
var atlas = UIAtlasMgr.Ins;
if (DSUtil.CheckNull(atlas)) return;
var want = atlas.Get_Sprite(IsAutoOn ? "autotarget_on" : "autotarget_off");
if (want == null) return;
var uis = Object.FindObjectsByType<BattleUI>(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < uis.Length; i++)
{
var imgs = uis[i].images;
if (imgs == null || imgs.Length == 0) continue;
Image img = imgs[0];
if (img == null || img.sprite == want) continue;
img.sprite = want;
IconFixed++;
s_iconDeadline = 0f; // 한 번 맞췄으면 폴링을 멈춘다
}
}
static void Log(string msg)
{
LastLog = msg;
var c = Cfg;
if (c != null && c.verboseLog) Debug.Log("[AutoCombatOnEnter] " + msg);
}
// ─────────────────────────────────────────────────────────────────────
// 진단 · 초기화
// ─────────────────────────────────────────────────────────────────────
/// <summary>프로브용: 카운터·경계 상태 초기화.</summary>
public static void ResetDiagnostics()
{
Boundaries = Applied = AlreadyOn = SkippedLobby = IconFixed = 0;
LastReason = LastLog = "";
s_lastPc = null; s_lastMapId = int.MinValue;
s_nextPoll = s_iconDeadline = 0f; s_faulted = false;
}
/// <summary>프로브용: 러너 없이 1회 폴링(강제 틱).</summary>
public static void ForceTick() { s_nextPoll = 0f; Tick(); }
// ─────────────────────────────────────────────────────────────────────
// 런너 (숨김 GameObject 1개 · 플레이 모드 전용)
// ─────────────────────────────────────────────────────────────────────
static AutoCombatOnEnterRunner s_runner;
internal static void EnsureRunner()
{
if (!Application.isPlaying || !DSUtil.CheckNull(s_runner)) return;
var go = new GameObject("[WL813x] AutoCombatOnEnterRunner");
go.hideFlags = HideFlags.HideAndDontSave;
s_runner = go.AddComponent<AutoCombatOnEnterRunner>();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
if (Active) EnsureRunner(); // 꺼져 있으면 오브젝트 0(C8)
}
}
/// <summary>AutoCombatOnEnter 의 시간 축. 숨김 GameObject 1개 · 코루틴 0.</summary>
internal sealed class AutoCombatOnEnterRunner : MonoBehaviour
{
void Update() { AutoCombatOnEnter.Tick(); }
}
}