// ───────────────────────────────────────────────────────────────────────────── // WLBowCharge.cs — 활 3타 「기 모으기 → 큰 화살」 (#839 · 2026-10-11 · PD 「3발 발사 후 마지막 타 공격 시 살짝 임팩트 있게 모았다가 발사 · 활에 기가 모이는 임팩트 후 좀 더 큰 화살」) // // ■ 신호 — 3타 클립(FR_Bow3_{CID}.anim)에 매핑 도구가 굽는 애니메이션 이벤트 WLBowCharge(float = 지속 초). // Animator 는 PC 루트(Actor 와 같은 GameObject)에 있으므로 루트에 붙은 이 수신기가 받는다(원본 Actor.cs 0줄). // ■ 동작 — ① 활 소켓(이름에 Bow 가 든 무기 노드)에 Resources/WL/WL_BowChargeVisual 을 지속 시간만큼 띄운다 // ② 「강화 창」(ArmedUntil)을 연다 → 그 안에 스폰되는 화살(WLProjectileVisual 가 붙인 WLChargedMark)이 크기 × chargedScale + 글로우. // ■ 부착 — WLBowChargeBoot 가 0.5 s 마다 MyValue.MyPC 루트에 수신기를 보장한다(씬 전환·캐릭터 교체 대응). // ■ #919 PD 「마지막 타의 모으기 이펙트가 활 중심부에 모이도록 · 3타 모으기 시간을 절반으로 · 발사되는 투사체를 지금 기본 공격 투사체로」 // = 이펙트 프리팹(WL_BowChargeVisual)의 모으기 파티클 3 개를 루트(= 활 그림 중심 · 활 피벗이 그림 중심과 같음 실측 0.00 m)로 옮기고 재생 속도 ×2(Level_up 배치 그대로라 0.37~0.52 m 위에서 모였다 · 실측) // · 매핑(CombatAnimMapping · FR_Bow3_1040x.anim) WLBowCharge 0.6 → 0.3 초 · 당김 유지 속도 0.35 → 0.69(모으기 이벤트 → 발사 이벤트 클립 0.208 초를 0.3 초에 — 모으기 끝에 쏜다) // · 강화 화살 겉모습 = WLProjectileVisualRule.chargedReplaces(기본 겉모습을 끄고 chargedVisual 로 바꿈 · normal) // ■ #923 PD 「활 공격 중 구 버전의 화살 이펙트가 날아가는 경우가 간헐적으로」 — 3타 모으기 뒤 3타가 끊기면(구르기 등) 창(모으기 + 1.3 초)이 // 남아 다음 1타 화살이 3타 겉모습(1·2타였던 노란 탄 · #921 맞바꿈)으로 날았다(Play 재현: 3타 모으기 중 구르기 → 1.24 초 뒤 다음 1타 화살이 3타 겉모습) // → 창을 연 PC 의 애니메이터가 창을 연 상태(3타 클립)가 아닐 때 쏜 화살이면 강화하지 않고 창을 닫는다(Dropped) · 다른 쏜 이의 화살은 창을 쓰지 않는다 // ■ #1005 모으기 이펙트 = Resources/WL/WLBowCharge.json(대검 모으기 사본 WL_Fx_BowChargeWhite · 대검과 같은 그라데이션 · 숨김) · 끝 = 방출 멈춤 + 흐려짐(WLFxFade) // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; namespace WL.Combat.Weapon { // #1005 PD 「활 기본 공격 마지막타의 에너지 모이는 연출이 갑자기 끊기는 느낌이 들지 않도록 수정해줘. (대검 모으기 이펙트를 그대로 복사해서 바꾸고, 타이밍을 조절해줘)」 // prefab = Resources 경로 · scale = 월드 배율 · speed = 입자 재생 속도 배율 · fade = 모으기 끝 뒤 흐려지는 초 · gradient · hide = 대검 WLGreatWave.json chargeFxGradient · chargeFxHide 와 같은 형식 [System.Serializable] public class WLBowChargeJson { public string prefab = "WL/WL_BowChargeVisual"; public float scale = 1f, speed = 1f, fade = 0.25f; public float[] gradient = new float[0]; public string[] hide = new string[0]; public string note = ""; } public sealed class WLBowChargeBoot : MonoBehaviour { float _next; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { if (FindFirstObjectByType() != null) return; var go = new GameObject("WL_BowChargeBoot"); DontDestroyOnLoad(go); go.AddComponent(); } void Update() { if (Time.unscaledTime < _next) return; _next = Time.unscaledTime + 0.5f; var pc = MyValue.MyPC; if (pc == null) return; if (pc.GetComponent() == null) pc.gameObject.AddComponent(); WLBowPoseIK.Ensure(pc); // #967 활 쏘는 자세 두 손 벌리기(IK) } } public sealed class WLBowChargeReceiver : MonoBehaviour { public const string ResourcesPath = "WL/WLBowCharge"; // #1005 모으기 이펙트 값(이전 = 상수 WL/WL_BowChargeVisual) static WLBowChargeJson s_cfg; static bool s_missing; static WLBowChargeJson Cfg { get { if (s_cfg != null) return s_cfg; s_cfg = new WLBowChargeJson(); var ta = Resources.Load(ResourcesPath); if (ta != null) try { JsonUtility.FromJsonOverwrite(ta.text, s_cfg); } catch (System.Exception ex) { LastLog = "설정 파싱 실패 — " + ex.Message; } return s_cfg; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void BootCfg() { s_cfg = null; } /// 이 시각 전에 스폰되는 투사체는 강화된다(0 = 닫힘). public static float ArmedUntil; /// #923 창을 연 PC 와 그때의 애니메이터 상태(3타 클립) — 그 PC 가 다른 상태(다음 콤보 1타 · 구르기 등)에서 쏜 화살이면 창을 닫는다 /// (3타가 끊겨 남은 창이 다음 1타 화살을 3타 겉모습으로 바꾸던 것 · 활 콤보는 1~3타 모두 CurAnim = Attack 이라 동작 값으로는 못 가른다 · Play 실측). public static Actor ArmedShooter; public static Animator ArmedAnimator; public static int ArmedState; public static int Charges, Consumed, Dropped; public static string LastLog = ""; // 애니메이션 이벤트 진입점 — functionName "WLBowCharge" · floatParameter = 기 모으기(활시위 유지) 초 · stringParameter = 유지 중 애니메이터 속도(예 "0.35" · 비면 유지 없음) // #840 PD 「투사체 발사 전까지 활시위를 당기는 모션이 유지되도록」 — 이벤트 시점부터 floatParameter 초 동안 애니메이터를 느리게(당김 자세 유지) 돌리고 원래 속도로 되돌린다. // 발사(Projectile 이벤트 0.92)는 그 뒤 클립 진행에 따라 온다 → 당김이 끝나기 전에 쏘는 일이 없다. Coroutine _hold; public void WLBowCharge(AnimationEvent e) { float dur = e != null && e.floatParameter > 0f ? e.floatParameter : 0.45f; float holdSpeed = 0f; if (e != null && !string.IsNullOrEmpty(e.stringParameter)) float.TryParse(e.stringParameter, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out holdSpeed); if (holdSpeed > 0f) { var an = GetComponent(); if (an != null) { if (_hold != null) StopCoroutine(_hold); _hold = StartCoroutine(CoHold(an, holdSpeed, dur)); } } Transform anchor = FindBow(transform); var c = Cfg; if (!string.IsNullOrEmpty(c.prefab)) { // #1005 대검 모으기와 같게 — 월드 배율 scale · 대검 그라데이션 · 숨김 층 · 모으기가 끝나면 방출을 멈추고 fade 초 동안 흐려짐(이전 = dur + 0.25 s 에 통째로 지움 = 끊김) var at = anchor != null ? anchor.position : transform.position + Vector3.up * 0.9f; var vfx = WLGreatWave.Spawn(c.prefab, at, Quaternion.identity, c.scale, 0f); s_missing = vfx == null; if (vfx != null) { WLGreatWave.TintGradient(vfx, c.gradient); WLGreatWave.HideLayers(vfx, c.hide); // 숨김은 루트 이름(프리팹 이름)으로 찾으므로 이름 바꾸기 전에 vfx.name = "~WL_BowCharge"; if (anchor != null) vfx.transform.SetParent(anchor, true); foreach (var ps in vfx.GetComponentsInChildren(true)) { if (c.speed > 0f) { var m = ps.main; m.simulationSpeed *= c.speed; } ps.Play(true); } WLFxFade.Attach(vfx, dur, c.fade, true); } } ArmedUntil = Time.time + dur + 1.0f; ArmedShooter = GetComponent(); ArmedAnimator = GetComponent(); ArmedState = StateHash(ArmedAnimator, true); Charges++; LastLog = "charge " + dur.ToString("0.00") + "s @" + (anchor != null ? anchor.name : "pc") + " · " + c.prefab + " ×" + c.scale.ToString("0.00") + (s_missing ? " (VFX 프리팹 없음)" : ""); } /// 지금 상태(전이 중이면 preferNext 일 때 들어가는 상태)의 fullPathHash · 없으면 0. internal static int StateHash(Animator an, bool preferNext) { if (an == null || !an.isActiveAndEnabled) return 0; if (preferNext && an.IsInTransition(0)) return an.GetNextAnimatorStateInfo(0).fullPathHash; return an.GetCurrentAnimatorStateInfo(0).fullPathHash; } System.Collections.IEnumerator CoHold(Animator an, float holdSpeed, float dur) { float prev = an.speed; an.speed = Mathf.Clamp(holdSpeed, 0.05f, 1f); float t = 0f; while (t < dur && an != null && an.isActiveAndEnabled) { t += Time.deltaTime; yield return null; } if (an != null) an.speed = prev <= 0f ? 1f : prev; _hold = null; } static Transform FindBow(Transform root) { Transform best = null; foreach (var t in root.GetComponentsInChildren(false)) { if (!t.gameObject.activeInHierarchy) continue; if (t.name.IndexOf("Bow", System.StringComparison.OrdinalIgnoreCase) >= 0 && t.name.IndexOf("Elbow", System.StringComparison.OrdinalIgnoreCase) < 0) { best = t; break; } } return best; } } /// 풀 투사체 인스턴스에 붙어, 켜질 때 강화 창이 열려 있으면 크기·글로우를 적용하고 꺼질 때 되돌린다. public sealed class WLChargedMark : MonoBehaviour { public float scale = 1.6f; public GameObject visual; public GameObject normal; // #919 강화 동안 끌 기본 겉모습(WLProjectileVisualRule.chargedReplaces) — null 이면 덧붙이기 Vector3 _orig; bool _hasOrig; bool _charged; void Awake() { if (!_hasOrig) { _orig = transform.localScale; _hasOrig = true; } } void OnEnable() { ApplyNow(); } void OnDisable() { Restore(); } public void ApplyNow() { if (!_hasOrig) { _orig = transform.localScale; _hasOrig = true; } if (!_charged && WLBowChargeReceiver.ArmedUntil > 0f && Time.time < WLBowChargeReceiver.ArmedUntil) { // #923 3타 모으기 뒤 3타 화살이 안 나가고(구르기 등으로 끊김) 창이 남아 있으면 — 같은 PC 의 다른 동작 중 화살은 강화하지 않고 창을 닫는다 var armed = WLBowChargeReceiver.ArmedShooter; if (armed != null) { var pb = GetComponent(); var pd = pb != null ? pb.Get_ProjectTileData() : null; if (pd == null || !ReferenceEquals(pd.shooter, armed)) return; var an = WLBowChargeReceiver.ArmedAnimator; int want = WLBowChargeReceiver.ArmedState; if (an != null && want != 0 && WLBowChargeReceiver.StateHash(an, false) != want && WLBowChargeReceiver.StateHash(an, true) != want) { WLBowChargeReceiver.ArmedUntil = 0f; WLBowChargeReceiver.Dropped++; return; } } WLBowChargeReceiver.ArmedUntil = 0f; _charged = true; transform.localScale = _orig * Mathf.Max(scale, 0.01f); if (normal != null) normal.SetActive(false); if (visual != null) { visual.SetActive(true); foreach (var ps in visual.GetComponentsInChildren(true)) ps.Play(true); } WLBowChargeReceiver.Consumed++; } } void Restore() { if (!_charged) return; _charged = false; transform.localScale = _orig; if (visual != null) visual.SetActive(false); if (normal != null) normal.SetActive(true); } } }