// ───────────────────────────────────────────────────────────────────── // WLWeaponTipGlow.cs — 무기 끝 은은한 빛 (#948 · 2026-10-20 · 2차: 쿼드 → 팩 이펙트 · 끝 = 축 위 끝점) // // PD 원문: 「마법검의 지팡이 끝은 은은하게 빛나도록 해줘. (마법 무기라는 느낌이 들도록)」 // 「지팡이 끝에 알 수 없는 흰색 선이 생겨났고 광원의 위치가 지팡이의 둥근 면이 아니라 엉뚱한 위치」 // ■ 1차 실패: 무기 자식 쿼드가 무기의 비균등 스케일에 눌려 흰 선이 됐고, 끝 = 경계 상자 모서리라 축에서 벗어났다. // ■ 2차: 끝 = 소켓 → 렌더러 경계 중심 방향(= 무기 축) 위에서 가장 먼 모서리의 투영점(둥근 끝면 중심) · 빛 = 씬 루트의 홀더가 매 프레임 그 점을 따라감 · // 겉모습 = 기존 팩 이펙트 프리팹(기본 WL_Fx_BowCharge · 시전 모으기와 같은 것)을 루프로 켜 둔다 + 약한 점광원 · 사인 맥동(홀더 스케일) // ■ 붙는 자리 = WLWeaponFitter 손 맞춤 뒤 훅 1줄(WLOrbFloat 과 같은 자리) · 값 = Resources/WL/WLWeaponTipGlow.json (C45) · enabled 0 = 아무것도 안 함 // 🔴 Debug.LogError 금지(원본 ErrorLogHook 팝업) — 예외는 LastLog 로만 // ───────────────────────────────────────────────────────────────────── using UnityEngine; namespace WL.Combat.Weapon { [System.Serializable] public class WLWeaponTipGlowRule { public int[] classIds = new int[0]; // 이 직업의 무기에 붙인다(PCActor.Get_ID) public string[] weaponNames = new string[0]; // 또는 무기 인스턴스 이름 앞부분 public string prefab = "WL/Fx/WL_Fx_BowCharge"; // Resources 경로 · 파티클을 전부 루프로 public float scale = 0.06f; public float pulse = 0.2f; // 맥동 진폭(크기 ±비율) public float pulseSeconds = 1.6f; public int light = 1; // 1 = 점광원도 public float[] lightColor = { 1f, 0.8f, 0.45f, 1f }; public float lightRange = 0.8f; public float lightIntensity = 0.3f; public float tipPull = 0f; // 끝에서 손 쪽으로 당길 거리(m · 둥근 면 안쪽에 두고 싶을 때) public int tipFromMesh = 0; // #963 1 = 끝 = 무기 메시 로컬 경계의 긴 축에서 손잡이(소켓)와 먼 쪽 면 중심 − tipPull(자세 · 월드 축 정렬 상자 부풀림과 무관 · 이전 = 붙는 순간의 월드 상자 모서리 투영이라 머리 위로 0.1 m 벗어남 실측) public float cameraPull = 0f; // #948-5 끝점에서 카메라 쪽으로 당길 거리(m) — 둥근 머리가 카메라와 구슬 사이에 올 때(휘두르는 동작) 구슬이 가려져 주변 빛만 보이는 것을 막는다 } [System.Serializable] public class WLWeaponTipGlowJson { public int enabled = 1; public WLWeaponTipGlowRule[] rules = new WLWeaponTipGlowRule[0]; public string note = ""; } public sealed class WLWeaponTipGlow : MonoBehaviour { public const string ResourcesPath = "WL/WLWeaponTipGlow"; public static int Attached; public static string LastLog = ""; static WLWeaponTipGlowJson s_cfg; static bool s_loaded; static WLWeaponTipGlowJson Cfg { get { if (!s_loaded) { s_loaded = true; var ta = Resources.Load(ResourcesPath); try { s_cfg = ta != null ? JsonUtility.FromJson(ta.text) : null; } catch (System.Exception ex) { LastLog = "설정 파싱 실패 — " + ex.Message; } } return s_cfg; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] static void ResetStatics() { s_loaded = false; s_cfg = null; } // 도메인 리로드 끔 대비(JSON 재읽기) Transform _weapon, _socket, _holder; Light _light; WLWeaponTipGlowRule _r; float _t0; Vector3 _localTip; public static bool TryAttach(Transform weapon) { try { var c = Cfg; if (c == null || c.enabled == 0 || weapon == null || c.rules == null) return false; if (weapon.GetComponent() != null) return true; var pc = weapon.GetComponentInParent(); int cls = pc != null ? pc.Get_ID() : 0; WLWeaponTipGlowRule rule = null; foreach (var r in c.rules) { if (r == null) continue; bool byClass = r.classIds != null && System.Array.IndexOf(r.classIds, cls) >= 0; bool byName = false; if (r.weaponNames != null) foreach (var n in r.weaponNames) if (!string.IsNullOrEmpty(n) && weapon.name.StartsWith(n)) { byName = true; break; } if (byClass || byName) { rule = r; break; } } if (rule == null) return false; if (!HasMesh(weapon)) return false; // #963 메시 없는 물체(공격 중 손 소켓에 붙는 WL_Fx_BowCharge 같은 이펙트)엔 붙이지 않는다 — 그 원점에 빛이 하나 더 떴다(Play 실측) var g = weapon.gameObject.AddComponent(); g._weapon = weapon; g._socket = weapon.parent != null ? weapon.parent : weapon; g._r = rule; g._t0 = Time.time; g.Build(); Attached++; LastLog = "무기 끝 빛 " + weapon.name + " 직업 " + cls + " 끝(로컬) " + g._localTip.ToString("F2") + (g._holder != null ? " · " + rule.prefab : " · 프리팹 없음"); return true; } catch (System.Exception ex) { LastLog = "무기 끝 빛 실패 — " + ex.Message; return false; } } static bool HasMesh(Transform w) { foreach (var mf in w.GetComponentsInChildren(true)) if (mf.sharedMesh != null && mf.GetComponent() != null) return true; foreach (var sm in w.GetComponentsInChildren(true)) if (sm.sharedMesh != null) return true; return false; } /// #963 무기 머리(로컬) — 무기 로컬 기준 메시 경계(MeshFilter · SkinnedMeshRenderer)의 긴 축에서 손잡이(socket)와 먼 쪽 면 중심 − pull(m). 자세 · 월드 축 정렬 상자 부풀림과 무관. WLStaffEarth 모으기도 같은 점을 쓴다. public static bool HeadLocal(Transform weapon, Transform socket, float pull, out Vector3 local) { local = Vector3.zero; if (weapon == null) return false; bool any = false; Vector3 mn = Vector3.zero, mx = Vector3.zero; System.Action add = (b, tr) => { for (int i = 0; i < 8; i++) { var corner = new Vector3((i & 1) == 0 ? b.min.x : b.max.x, (i & 2) == 0 ? b.min.y : b.max.y, (i & 4) == 0 ? b.min.z : b.max.z); var l = weapon.InverseTransformPoint(tr.TransformPoint(corner)); if (!any) { mn = mx = l; any = true; } else { mn = Vector3.Min(mn, l); mx = Vector3.Max(mx, l); } } }; foreach (var mf in weapon.GetComponentsInChildren(false)) if (mf.sharedMesh != null && mf.GetComponent() != null) add(mf.sharedMesh.bounds, mf.transform); foreach (var sm in weapon.GetComponentsInChildren(false)) if (sm.sharedMesh != null) add(sm.localBounds, sm.transform); if (!any) return false; var size = mx - mn; int ax = size.x >= size.y && size.x >= size.z ? 0 : (size.y >= size.z ? 1 : 2); Vector3 c = (mn + mx) * 0.5f, hi = c, lo = c; hi[ax] = mx[ax]; lo[ax] = mn[ax]; Vector3 grip = socket != null ? weapon.InverseTransformPoint(socket.position) : Vector3.zero; bool hiFar = Mathf.Abs(hi[ax] - grip[ax]) >= Mathf.Abs(lo[ax] - grip[ax]); Vector3 tip = hiFar ? hi : lo; float s = Mathf.Max(1e-4f, Mathf.Abs(weapon.lossyScale[ax])); tip[ax] += (hiFar ? -1f : 1f) * pull / s; local = tip; return true; } bool MeshTip(out Vector3 local) { return HeadLocal(_weapon, _socket, _r.tipPull, out local); } /// 무기 끝 = 소켓에서 렌더러 경계 중심을 향한 축 위, 가장 멀리 투영되는 모서리의 투영점(둥근 끝면 중심 · 상자 모서리가 아님). void Build() { var r = _r; bool any = false; Bounds b = new Bounds(_weapon.position, Vector3.zero); foreach (var rd in _weapon.GetComponentsInChildren(false)) { if (rd is ParticleSystemRenderer || !rd.enabled) continue; if (!any) { b = rd.bounds; any = true; } else b.Encapsulate(rd.bounds); } Vector3 tip; Vector3 ml; if (r.tipFromMesh != 0 && MeshTip(out ml)) tip = _weapon.TransformPoint(ml); // #963 else if (!any) tip = _weapon.position; else { Vector3 o = _socket.position; Vector3 axis = b.center - o; if (axis.sqrMagnitude < 1e-6f) axis = _weapon.up; axis.Normalize(); float far = 0f; for (int i = 0; i < 8; i++) { var corner = new Vector3((i & 1) == 0 ? b.min.x : b.max.x, (i & 2) == 0 ? b.min.y : b.max.y, (i & 4) == 0 ? b.min.z : b.max.z); float d = Vector3.Dot(corner - o, axis); if (d > far) far = d; } tip = o + axis * Mathf.Max(0f, far - r.tipPull); } _localTip = _weapon.InverseTransformPoint(tip); var holder = new GameObject("~WL_TipGlow"); _holder = holder.transform; // 씬 루트(무기 스케일 영향 0) _holder.position = tip; var p = Resources.Load(r.prefab); if (p != null) { var fx = Instantiate(p, _holder); fx.transform.localPosition = Vector3.zero; fx.transform.localRotation = Quaternion.identity; fx.transform.localScale = Vector3.one * r.scale; foreach (var ps in fx.GetComponentsInChildren(true)) { var m = ps.main; m.loop = true; m.playOnAwake = true; ps.Play(true); } var fade = fx.GetComponent(); // 사본 프리팹에 수명 스크립트(TurnOff_GO · WLFxFade)가 있으면 끈다 — 계속 켜 둬야 한다 foreach (var mb in fx.GetComponents()) if (mb != null && !(mb is ParticleSystem)) mb.enabled = false; } if (r.light != 0) { var lg = new GameObject("~WL_TipLight"); lg.transform.SetParent(_holder, false); _light = lg.AddComponent(); _light.type = LightType.Point; _light.range = r.lightRange; _light.intensity = r.lightIntensity; _light.shadows = LightShadows.None; _light.color = r.lightColor != null && r.lightColor.Length >= 3 ? new Color(r.lightColor[0], r.lightColor[1], r.lightColor[2]) : new Color(1f, 0.8f, 0.45f); } } void LateUpdate() { if (_holder == null || _weapon == null) return; float k = 1f + (_r.pulse > 0f && _r.pulseSeconds > 0f ? _r.pulse * Mathf.Sin((Time.time - _t0) / _r.pulseSeconds * Mathf.PI * 2f) : 0f); var tipW = _weapon.TransformPoint(_localTip); if (_r.cameraPull > 0f) { var cam = Camera.main; if (cam != null) { var toCam = cam.transform.position - tipW; if (toCam.sqrMagnitude > 1e-4f) tipW += toCam.normalized * _r.cameraPull; } // 머리 메시 앞으로 (가림 방지) } _holder.position = tipW; _holder.localScale = Vector3.one * k; if (_light != null) _light.intensity = _r.lightIntensity * k; } void OnDisable() { if (_holder != null) _holder.gameObject.SetActive(false); } void OnEnable() { if (_holder != null) _holder.gameObject.SetActive(true); } void OnDestroy() { if (_holder != null) Destroy(_holder.gameObject); } } }