Project_WL/Assets/WL/Combat/Weapon/WLFloatingBook.cs

216 lines
13 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// WLFloatingBook.cs — 마법책(새 무기 9009 · 직업 10303) 장착 중 PC 옆에 떠다니는 책 (#944 · 2026-10-19)
//
// PD 원문: 「신규 마법책 장비도 인벤토리에 추가해줘」(새 무기 9009 · 직업 10303) · 「마법책은 오브 모션과 같도록 수정하고 캐릭터 주위에 책 오브젝트가 떠다니게 해줘. (보유 에셋중 Low Poly Casual Magic Book를 활용하고,
// 부유 위치나 컨셉은 스크린샷을 참고할 것)」 · 「아트 컨셉은 무시해도 되며 위치 및 동작 형태만 참고할 것」
// ■ 원리 — 원본 코드 훅 0줄. WLWeaponClassApplier 와 같이 씬 로드 뒤 상주 오브젝트 1개를 만들고, WLInventory.Changed(장착 변경) + pollSec 폴링으로
// 「내 PC 가 있고 · 살아 있고(활성) · 장착 무기 GroupNo 가 weaponIds 에 든다(또는 그 직업이 classIds 에 든다)」를 판정한다.
// 참이면 책 프리팹(Resources · WL 사본)을 PC 와 같은 씬에 count 권 만들고, LateUpdate(애니메이터 뒤)마다 PC 기준 (side · height · forward) 자리로
// 부드럽게 따라가게 둔다(상하 흔들림 · 좌우 흔들림 · 선택 공전). 거짓이 되면(해제 · 사망 · PC 교체) 바로 없앤다. 씬이 바뀌면 책은 씬과 함께 사라지고
// 다음 판정에서 새 PC 에 다시 만든다.
// ■ 애니 — 책 클립(7.46 초 · 0~1 초 펼침 · 넘김 · 4.5 초부터 닫힘)을 처음부터 재생해 펼친 뒤 [animLoopStartSec, animLoopEndSec) 을 반복한다(넘김).
// ■ 값 = Resources/WL/WLFloatingBook.json (C45 · 코드 기본값은 0/빈 값 — 파일이 없으면 꺼짐) · 🔴 Debug.LogError 금지(원본 ErrorLogHook 팝업)
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
using WL.Equip;
namespace WL.Combat.Weapon
{
[System.Serializable]
public class WLFloatingBookJson
{
public int enabled;
public int[] weaponIds = new int[0]; // 장착 무기 GroupNo 가 여기 들면 띄운다(#944 마법책 9009)
public int[] classIds = new int[0]; // (선택) 장착 무기의 직업(공격표 class_id)이 여기 들어도 띄운다 — 같은 직업을 쓰는 다른 무기에도 뜨므로 기본은 비움
public string prefab = ""; // Resources 경로(확장자 없음)
public int count = 1; // 권 수(여러 권 = PC 둘레에 고르게)
public float scale = 1f; // 프리팹 배율
public float side, height, forward; // PC 기준 오른쪽 · 발밑 위 · 정면 m
public float[] euler = new float[3]; // PC(또는 카메라) 방향 기준 책 회전(도)
public int faceCamera; // 1 = 책 방향(yaw)을 카메라 기준으로(펼친 면이 늘 화면 쪽) · 0 = PC 정면 기준
public float bobAmp, bobHz; // 상하 흔들림 진폭 m · 초당 횟수
public float swayYawDeg, swayRollDeg, swayHz;// 좌우 돌기 · 기울기 흔들림(도) · 초당 횟수
public float orbitDegPerSec; // 0 = 옆에 고정 · 그 외 = PC 둘레 공전
public float followLerp, turnLerp; // 따라가기 · 돌기 부드러움(초당 · 0 = 즉시)
public float animLoopStartSec, animLoopEndSec;// 책 클립 반복 구간(초 · 끝 ≤ 시작 = 반복 안 함)
public int matchPcLayer; // 1 = 책 레이어를 PC 와 같게(같은 카메라 경로로 그려지게)
public float pollSec = 0.25f;
public string note = "";
}
public static class WLFloatingBookSettings
{
public const string ResourcesPath = "WL/WLFloatingBook";
static WLFloatingBookJson s_cfg; static bool s_loaded;
public static WLFloatingBookJson Cfg { get { if (!s_loaded) Load(); return s_cfg; } }
public static void Reload() { s_loaded = false; }
static void Load()
{
s_loaded = true;
try { var ta = Resources.Load<TextAsset>(ResourcesPath); s_cfg = ta != null ? JsonUtility.FromJson<WLFloatingBookJson>(ta.text) : null; }
catch (System.Exception ex) { WLFloatingBook.LastLog = "설정 읽기 실패 — " + ex.Message; s_cfg = null; }
if (s_cfg == null) s_cfg = new WLFloatingBookJson { enabled = 0 };
}
}
/// <summary>상주 드라이버(씬 로드 뒤 1개 · DontDestroyOnLoad) — 책 만들기 · 없애기 · 매 프레임 배치.</summary>
public sealed class WLFloatingBook : MonoBehaviour
{
public static WLFloatingBook Instance { get; private set; }
public static int Spawned, Cleared, Frames, LoopJumps;
public static string LastLog = "";
readonly List<Transform> _books = new List<Transform>();
readonly List<Animator> _anims = new List<Animator>();
PCActor _pc; float _t, _t0, _orbit; bool _dirty = true;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
if (Instance != null) return;
var c = WLFloatingBookSettings.Cfg; if (c == null || c.enabled == 0) return;
var go = new GameObject("WL_FloatingBook");
DontDestroyOnLoad(go);
Instance = go.AddComponent<WLFloatingBook>();
}
void OnEnable() { WLInventory.Changed += OnChanged; }
void OnDisable() { WLInventory.Changed -= OnChanged; Clear("비활성"); }
void OnDestroy() { if (Instance == this) Instance = null; }
void OnChanged() { _dirty = true; }
void Update()
{
var c = WLFloatingBookSettings.Cfg; if (c == null) return;
_t += Time.unscaledDeltaTime;
if (!_dirty && _t < Mathf.Max(0.05f, c.pollSec)) return;
_t = 0f; _dirty = false;
try { Tick(c); }
catch (System.Exception ex) { LastLog = "예외 — " + ex.Message; }
}
void Tick(WLFloatingBookJson c)
{
PCActor pc = MyValue.bMyPC ? MyValue.MyPC : null;
bool want = c.enabled != 0 && pc != null && !pc.IsDead() && WantsBook(c);
bool missing = false;
for (int i = 0; i < _books.Count; i++) if (_books[i] == null) { missing = true; break; }
if (_books.Count > 0 && (!want || missing || !ReferenceEquals(pc, _pc))) Clear(!want ? "조건 해제" : missing ? "씬 정리됨" : "PC 교체");
if (want && _books.Count == 0) Spawn(c, pc);
}
/// <summary>장착 무기(FR GroupNo)가 weaponIds 에 들거나, 그 무기의 공격표 class_id 가 classIds 에 드는가.</summary>
static bool WantsBook(WLFloatingBookJson c)
{
bool byWeapon = c.weaponIds != null && c.weaponIds.Length > 0, byClass = c.classIds != null && c.classIds.Length > 0;
if (!byWeapon && !byClass || !WLInventory.EnsureLoaded()) return false;
var w = WLInventory.EquippedInSlot(eFREquipmentType.Weapon);
int gid = w != null && w.itemJson != null ? w.itemJson.GroupNo : 0;
if (gid <= 0) return false;
if (byWeapon && System.Array.IndexOf(c.weaponIds, gid) >= 0) return true;
int cls = byClass ? WLWeaponAttackTable.ClassIdFor(gid) : 0;
return cls > 0 && System.Array.IndexOf(c.classIds, cls) >= 0;
}
void Spawn(WLFloatingBookJson c, PCActor pc)
{
var prefab = string.IsNullOrEmpty(c.prefab) ? null : Resources.Load<GameObject>(c.prefab);
if (prefab == null) { LastLog = "책 프리팹 없음 — " + c.prefab; return; }
int n = Mathf.Max(1, c.count);
for (int i = 0; i < n; i++)
{
var go = Instantiate(prefab);
_books.Add(go.transform); _anims.Add(go.GetComponentInChildren<Animator>()); // 먼저 등록(예외가 나도 Clear 가 치운다) · 기본 상태가 처음(닫힘)부터 재생 → 펼친 뒤 반복 구간
go.name = prefab.name + "_" + i;
var ps = pc.gameObject.scene; // PC 와 같은 씬 → 씬이 바뀌면 함께 사라진다
if (ps.IsValid() && go.scene != ps) { if (ps.name == "DontDestroyOnLoad") DontDestroyOnLoad(go); else UnityEngine.SceneManagement.SceneManager.MoveGameObjectToScene(go, ps); }
go.transform.localScale = prefab.transform.localScale * c.scale;
if (c.matchPcLayer != 0) SetLayer(go.transform, pc.gameObject.layer);
}
_pc = pc; _t0 = Time.time; _orbit = 0f;
Place(c, true);
Spawned++;
LastLog = "책 " + n + "권 생성 — " + prefab.name + " · PC " + pc.name + " · 배율 " + c.scale + " · 옆 " + c.side + " 위 " + c.height + " 앞 " + c.forward;
}
void Clear(string why)
{
if (_books.Count == 0) return;
for (int i = 0; i < _books.Count; i++) if (_books[i] != null) Destroy(_books[i].gameObject);
_books.Clear(); _anims.Clear(); _pc = null;
Cleared++; LastLog = "책 정리 — " + why;
}
static void SetLayer(Transform t, int layer)
{
t.gameObject.layer = layer;
for (int i = 0; i < t.childCount; i++) SetLayer(t.GetChild(i), layer);
}
void LateUpdate()
{
if (_books.Count == 0 || _pc == null) return;
var c = WLFloatingBookSettings.Cfg; if (c == null) return;
Place(c, false);
LoopAnim(c);
Frames++;
}
void Place(WLFloatingBookJson c, bool snap)
{
float t = Time.time - _t0;
_orbit += c.orbitDegPerSec * Time.deltaTime;
var pt = _pc.transform;
var pcYaw = Quaternion.Euler(0f, pt.eulerAngles.y, 0f);
Quaternion faceYaw = pcYaw;
if (c.faceCamera != 0) { var cam = Camera.main; if (cam != null) { var f = cam.transform.forward; f.y = 0f; if (f.sqrMagnitude > 1e-4f) faceYaw = Quaternion.LookRotation(f.normalized, Vector3.up); } }
var e = c.euler != null && c.euler.Length >= 3 ? Quaternion.Euler(c.euler[0], c.euler[1], c.euler[2]) : Quaternion.identity;
int n = _books.Count;
float kp = snap || c.followLerp <= 0f ? 1f : 1f - Mathf.Exp(-c.followLerp * Time.deltaTime);
float kr = snap || c.turnLerp <= 0f ? 1f : 1f - Mathf.Exp(-c.turnLerp * Time.deltaTime);
for (int i = 0; i < n; i++)
{
var b = _books[i]; if (b == null) continue;
float share = (float)i / n; // 여러 권 = 둘레 · 흔들림 위상 고르게
var ring = Quaternion.Euler(0f, _orbit + 360f * share, 0f);
float bob = Mathf.Sin((t * c.bobHz + share) * Mathf.PI * 2f) * c.bobAmp;
float sw = Mathf.Sin((t * c.swayHz + share) * Mathf.PI * 2f);
var pos = pt.position + pcYaw * (ring * new Vector3(c.side, 0f, c.forward)) + Vector3.up * (c.height + bob);
var rot = faceYaw * ring * e * Quaternion.Euler(0f, sw * c.swayYawDeg, sw * c.swayRollDeg);
if (kp >= 1f && kr >= 1f) b.SetPositionAndRotation(pos, rot);
else b.SetPositionAndRotation(Vector3.Lerp(b.position, pos, kp), Quaternion.Slerp(b.rotation, rot, kr));
}
}
/// <summary>펼친 뒤 넘김 구간 반복 — 상태 시간이 끝 초를 넘으면 시작 초로 되돌린다.</summary>
void LoopAnim(WLFloatingBookJson c)
{
if (c.animLoopEndSec <= c.animLoopStartSec) return;
for (int i = 0; i < _anims.Count; i++)
{
var an = _anims[i]; if (an == null || !an.isActiveAndEnabled) continue;
var st = an.GetCurrentAnimatorStateInfo(0);
float len = st.length; if (len <= 0.0001f) continue;
if (st.normalizedTime * len >= c.animLoopEndSec) { an.Play(st.fullPathHash, 0, c.animLoopStartSec / len); LoopJumps++; }
}
}
/// <summary>프로브용 한 줄 — 책 수 · 셈 · PC 와의 거리/높이.</summary>
public static string Describe()
{
var s = "spawned=" + Spawned + " cleared=" + Cleared + " frames=" + Frames + " loops=" + LoopJumps;
var me = Instance;
if (me != null && me._pc != null)
for (int i = 0; i < me._books.Count; i++)
{
var b = me._books[i]; if (b == null) continue;
var d = b.position - me._pc.transform.position;
var local = Quaternion.Inverse(Quaternion.Euler(0f, me._pc.transform.eulerAngles.y, 0f)) * d;
s += " | " + b.name + " local(" + local.x.ToString("F2") + "," + local.y.ToString("F2") + "," + local.z.ToString("F2") + ") scale " + b.localScale.x.ToString("F2");
}
return s + " · " + LastLog;
}
}
}