diff --git a/Assets/Script/Character/Projectile/Skill/Skill_DarkAmbush.cs b/Assets/Script/Character/Projectile/Skill/Skill_DarkAmbush.cs
index 180eb8a4f..70555c234 100644
--- a/Assets/Script/Character/Projectile/Skill/Skill_DarkAmbush.cs
+++ b/Assets/Script/Character/Projectile/Skill/Skill_DarkAmbush.cs
@@ -20,11 +20,12 @@ public class Skill_DarkAmbush : ProjectileBase
colliderControl.gameObject.SetActive(false);
colliderControl.Set(m_ProjecTileData);
m_ProjecTileData.shooter.Set_Invisible(1.5f, null);
- if (MyValue.m_RealCamera != null) MyValue.m_RealCamera.Set_TopView(m_ProjecTileData.shooter); // WL #881 섬 RealCamera 없음 가드
+ bool keepCam = WL.Combat.Weapon.WLGhostStrike.KeepCamera(this); // WL #953 PD 「카메라 각도를 바꾸지 말고 현재 상태로 착지」 — 규칙(WLGhostStrike.json keepCamera)이면 탑뷰 전환 생략
+ if (MyValue.m_RealCamera != null && !keepCam) MyValue.m_RealCamera.Set_TopView(m_ProjecTileData.shooter); // WL #881 섬 RealCamera 없음 가드
yield return new WaitForSeconds(0.25f);
colliderControl.gameObject.SetActive(true);
yield return new WaitForSeconds(1.75f);
- if (MyValue.m_RealCamera != null) MyValue.m_RealCamera.Set_TopView(null); // WL #881 섬 RealCamera 없음 가드
+ if (MyValue.m_RealCamera != null && !keepCam) MyValue.m_RealCamera.Set_TopView(null); // WL #881 섬 RealCamera 없음 가드
Off(true);
}
diff --git a/Assets/WL/Combat/Weapon/Resources/WL/WLGhostStrike.json b/Assets/WL/Combat/Weapon/Resources/WL/WLGhostStrike.json
index 07ed15f28..a3a8d8d7a 100644
--- a/Assets/WL/Combat/Weapon/Resources/WL/WLGhostStrike.json
+++ b/Assets/WL/Combat/Weapon/Resources/WL/WLGhostStrike.json
@@ -52,6 +52,9 @@
"landGhosts": 2,
"dustPrefab": "WL/Fx/WL_Fx_LandDust",
"dustScale": 0.15,
+ "keepCamera": 1,
+ "fallForward": 0.35,
+ "_note_953": "#953 PD 「비수 공격 시 카메라 각도가 달라짐 — 바꾸지 말고 현재 상태 유지한 채 착지(탑뷰가 아니라 대각선에서 미끄러지며 낙하하듯)」 — 원인 = 원본 Skill_DarkAmbush 가 시전 때 RealCamera.Set_TopView(PC) · 2.0 s 뒤 해제 → keepCamera 1 로 둘 다 건너뜀(원본 훅 2줄) · fallForward 0.35 m = 낙하 0.25 s 동안 앞으로(대각선) → 착지 뒤 slide 0.4 m 감속 · 값은 PM 추정(원장 40)",
"_note_952": "#952 PD 「이펙트가 끝나는 시점에 즉시 착지(서 있는 모습 없이) · 떨어진 후 미끄러지듯 살짝 전진하다 멈춤 · 흙먼지는 캐릭터 등신에 맞게」 — 착지 시작 = 원본이 모델을 다시 켜는 그 프레임(WLGhostStrike.LateTick · 그 프레임은 숨김 · 섞임 0) · 미끄러짐 = 감속(2D/T → 0 · 0.4 m / 0.35 s) · 먼지 = HCFX Dust_Dash 사본의 긴 줄기(3D 크기 1.7 × 5.0 × lengthScale 1 = 프리팹 단위 5 m) × 0.15 ≈ 0.75 m(PC 키 1.13 m 실측 · Bounds952.Model)",
"_note_951": "#951 PD 「각성 시전 시 뛰어오르는 잔상 → 사라진 채 공격 → 잔상과 함께 등장 · 낮은 공중에서 떨어져 착지 포즈 · 0.5 m 미끄러지다 정지 · 흙먼지」 — 시전 순간 위·앞으로 잔상 jumpGhosts 개(jumpSeconds 동안) · PC 가 다시 보이는 순간(또는 landAtSeconds) 모델을 landHeight 에서 landFallSeconds 에 내리며 잔상 landGhosts · 착지 클립 landState(어새신 컨트롤러 ghost_land = Platform Sub_HeroLanding 0~36) · slideSeconds 동안 NavMeshAgent.Move 로 slideDistance 앞으로 · 착지 때 dustPrefab(Hyper Casual Dust_Dash 사본) · landTotalSeconds 뒤 idle",
"poseSource": "clips",
diff --git a/Assets/WL/Combat/Weapon/Resources/WL/WLWeaponClasses.json b/Assets/WL/Combat/Weapon/Resources/WL/WLWeaponClasses.json
index da471edb5..cff53060f 100644
--- a/Assets/WL/Combat/Weapon/Resources/WL/WLWeaponClasses.json
+++ b/Assets/WL/Combat/Weapon/Resources/WL/WLWeaponClasses.json
@@ -96,6 +96,19 @@
"_note": "#838 (2026-10-11) PD 「대검 아이템 + 2Handed Warrior 모션 공격 타입」 — 모션·이벤트(타격 Projectile_10801 · 베기 Effect_Slash_10101_N 재사용)는 매핑 도구 WL826/WL_CombatAnimSetup 이 2Handed 팩 서브클립에 굽는다(런타임 projectileTimes/effectTimes 불필요) · 모델 = PurePoly 양손검 · 소켓 2 = 한손검 자리(포즈 띠 실측) · 사거리 1.8 = 제안값"
}
],
+ "extraClipEvents": [
+ {
+ "classId": 10402,
+ "controller": "pcanim_archer",
+ "clipToken": "Bow_Skill4_Push",
+ "functionName": "Projectile",
+ "time": 0.30,
+ "stringParameter": "Projectile_10402",
+ "floatParameter": 1.0,
+ "intParameter": 0,
+ "_note": "#953 PD 「활 3타 때 투사체가 연속으로 3발 나가도록(지금 3타 째 2발 · 중간에 화살 한 발을 더)」 — 3타 = FR_Bow3(화살 @0.74 s) → Bow_Skill4_Push(화살 @0.77 s · 길이 1.60 s) 연결이라 2발이었음(컨트롤러 실측) · 중간 = push 길이 비율 0.30(= 0.48 s · 실시간 간격 약 0.27 s 씩 3발) · 10401(들꽃 단궁)은 3타가 Bow_Attack_Loop 라 해당 없음"
+ }
+ ],
"weaponOrient": [
{
"prefab": "PP_Theme_05_Bow_003",
diff --git a/Assets/WL/Combat/Weapon/WLGhostStrike.cs b/Assets/WL/Combat/Weapon/WLGhostStrike.cs
index 7e9154e24..3a1f70a5d 100644
--- a/Assets/WL/Combat/Weapon/WLGhostStrike.cs
+++ b/Assets/WL/Combat/Weapon/WLGhostStrike.cs
@@ -49,6 +49,8 @@ namespace WL.Combat.Weapon
public string airClip = ""; // 원본 스킬의 공중 클립 이름 조각(예 Skill8_Air) — 이 클립이 보였다가 다른 클립으로 바뀌는 순간 = 공격 끝 → 착지 시작
public int landGhosts = 2; // 떨어지는 동안 잔상 수
public string dustPrefab = ""; public float dustScale = 1f;
+ public int keepCamera = 0; // #953 1 = 원본 Skill_DarkAmbush 의 탑뷰 전환(RealCamera.Set_TopView)을 건너뛴다 — 카메라 각도 유지
+ public float fallForward = 0f; // #953 낙하하는 동안 앞으로 가는 거리(m · 대각선으로 미끄러지며 낙하) · 착지 뒤 미끄러짐(slideDistance)은 별도
public string poseSource = "clips"; // clips = 숨은 꼭두각시에 poses 를 걸어 굽기 · live = 시전 순간 PC 자세 1 개
public string[] poses = new string[0]; // "애니메이터 상태 이름@정규화 시각" — 차례로 돌려 쓴다(시작은 무작위)
}
@@ -162,6 +164,13 @@ namespace WL.Combat.Weapon
// ───────────────────────────────────────── 원본 훅
/// 원본 스킬 Set 훅(1줄) — 표에 있는 스킬이면 시전을 기록하고 부품 경로를 잡는다(이 순간 PC 는 아직 보인다 · 원본 숨김은 훅 뒤 Co_Update).
+ /// #953 PD 「비수 공격 시 왜 카메라 각도가 달라지는가 — 각도를 바꾸지 말고 현재 상태로 착지」 — 원본 Skill_DarkAmbush 가 Set_TopView 전에 묻는다(규칙 keepCamera 1 = 탑뷰 전환 건너뜀 · 원본 훅 2줄).
+ public static bool KeepCamera(ProjectileBase skill)
+ {
+ try { var c = Cfg; if (skill == null || c.enabled == 0) return false; WLGhostStrikeRule r; return s_rules.TryGetValue(skill.GetType().Name, out r) && r != null && r.keepCamera != 0; }
+ catch (System.Exception) { return false; }
+ }
+
public static void OnSet(ProjectileBase skill)
{
try
@@ -348,6 +357,11 @@ namespace WL.Combat.Weapon
float k = 1f - t / fall; float y = r.landHeight * k * k; // 가속 낙하(실제 적용은 LateTick)
cast.modelY = cast.modelBaseY + y;
if (cast.landGhostsDone < r.landGhosts && t >= fall * (cast.landGhostsDone + 1f) / (r.landGhosts + 1f)) { cast.landGhostsDone++; if (Spawn(c, cast, cast.landPos + Vector3.up * y, cast.landFwd)) JumpSpawned++; }
+ if (r.fallForward > 0f) // #953 대각선 낙하 — 떨어지는 동안 앞으로 fallForward(등속) · 착지 뒤엔 slide 감속
+ {
+ float step = r.fallForward * Time.deltaTime / fall;
+ if (cast.agent != null && cast.agent.enabled && cast.agent.isOnNavMesh) cast.agent.Move(cast.landFwd * step); else cast.shooter.transform.position += cast.landFwd * step;
+ }
return;
}
cast.modelY = cast.modelBaseY;
diff --git a/Assets/WL/Combat/Weapon/WLWeaponClasses.cs b/Assets/WL/Combat/Weapon/WLWeaponClasses.cs
index f4fba142a..451e18531 100644
--- a/Assets/WL/Combat/Weapon/WLWeaponClasses.cs
+++ b/Assets/WL/Combat/Weapon/WLWeaponClasses.cs
@@ -32,7 +32,9 @@ namespace WL.Combat.Weapon
}
/// #834 무기 인스턴스 로컬 회전 보정(소켓 방향 규약이 팩 손 자세와 다를 때 · 원본 프리팹 0줄).
public class WLWeaponOrient { public string prefab = ""; public float[] euler; }
- class WLWeaponClassesJson { public List classes; public List weaponOrient; }
+ /// #953 기존 직업의 클립에 이벤트를 하나 더 붙인다(예 활 3타 push 클립에 화살 1발 추가) — 클립 이름 조각 · 컨트롤러 이름 조각 · 시점(길이 비율) · 함수 · 프리팹 · 배율. 같은 함수가 같은 시점(±0.02 s)에 이미 있으면 안 붙인다(멱등 · 세션 동안 유지).
+ public class WLExtraClipEvent { public int classId; public string controller = ""; public string clipToken = ""; public string functionName = "Projectile"; public float time = 0.5f; public string stringParameter = ""; public float floatParameter = 1f; public int intParameter; public string _note = ""; }
+ class WLWeaponClassesJson { public List classes; public List weaponOrient; public List extraClipEvents; }
/// 보정을 이미 적용한 무기 인스턴스 표식(중복 회전 방지).
public sealed class WLWeaponOrientMark : MonoBehaviour { public string rule = ""; }
@@ -57,7 +59,7 @@ namespace WL.Combat.Weapon
/// #947 Enter Play Mode 「도메인 리로드 끔」 대비 — Play 마다 표(table_classconfig)·PC 는 새 인스턴스인데 정적 「끝남」 표시가 남으면 수정 행·모델 재부착을 건너뛴다. 표 데이터(s_defs)는 에셋이라 그대로 둔다.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
- static void ResetStatics() { s_overridden.Clear(); s_modelRefreshed.Clear(); Injected = EventsAdded = Overridden = 0; }
+ static void ResetStatics() { s_overridden.Clear(); s_modelRefreshed.Clear(); Injected = EventsAdded = Overridden = 0; s_extraDone.Clear(); ExtraEventsAdded = 0; ExtraLog = ""; } // #953 런타임 AddEvent 는 Play 를 나가면 클립에서 사라진다(실측) → Play 마다 다시 붙인다
static readonly List s_orients = new List(); // #834 무기 방향 보정 규칙
public static int OrientApplied;
@@ -135,6 +137,7 @@ namespace WL.Combat.Weapon
if (j != null && j.classes != null) foreach (var d in j.classes) if (d != null && d.classId > 0) s_defs[d.classId] = d;
s_orients.Clear();
if (j != null && j.weaponOrient != null) s_orients.AddRange(j.weaponOrient);
+ s_extra.Clear(); if (j != null && j.extraClipEvents != null) s_extra.AddRange(j.extraClipEvents); // #953
Loaded = true;
LastLog = "추가 직업 표 로드 — " + s_defs.Count + " · 방향 보정 " + s_orients.Count;
}
@@ -192,6 +195,7 @@ namespace WL.Combat.Weapon
/// 그 직업의 공격 클립에 타격 이벤트(Projectile · 프리팹 이름 · 피해 배율)를 런타임으로 붙인다. 컨트롤러가 아직 안 올라왔으면 false.
public static bool EnsureClipEvents(PCActor pc, int classId)
{
+ ApplyExtraEvents(pc, classId); // #953 기존 직업 클립에 추가 이벤트(활 3타 3발)
var d = Def(classId);
if (d == null || pc == null || d.attackClipTokens == null || d.attackClipTokens.Count == 0) return true;
var an = pc.GetComponentInChildren();
@@ -241,5 +245,33 @@ namespace WL.Combat.Weapon
}
return matched > 0;
}
+
+ static readonly List s_extra = new List();
+ static readonly HashSet s_extraDone = new HashSet(); // (클립 인스턴스 ID · 규칙 index)
+ public static int ExtraEventsAdded; public static string ExtraLog = "";
+ /// #953 PD 「활 3타 때 투사체가 연속으로 3발 나가도록(지금 2발 · 중간에 한 발 더)」 — 표 extraClipEvents 의 그 직업 규칙을 지금 컨트롤러의 클립(이름 조각)에 붙인다. 컨트롤러가 아직 안 바뀌었으면(비동기) 이름 조각이 안 맞아 건너뛴다. 클립은 에셋이라 한 번 붙이면 세션 동안 남는다(같은 시점 중복 금지).
+ static void ApplyExtraEvents(PCActor pc, int classId)
+ {
+ EnsureLoaded();
+ if (pc == null || s_extra.Count == 0) return;
+ var an = pc.GetComponentInChildren(); var rac = an != null ? an.runtimeAnimatorController : null; if (rac == null) return;
+ int added = 0;
+ for (int i = 0; i < s_extra.Count; i++)
+ {
+ var x = s_extra[i]; if (x == null || x.classId != classId || string.IsNullOrEmpty(x.clipToken) || string.IsNullOrEmpty(x.functionName)) continue;
+ if (!string.IsNullOrEmpty(x.controller) && !rac.name.Contains(x.controller)) continue;
+ foreach (var c in rac.animationClips)
+ {
+ if (c == null || !c.name.Contains(x.clipToken)) continue;
+ long key = ((long)c.GetInstanceID() << 8) | (long)(i & 255); if (s_extraDone.Contains(key)) continue;
+ if (pc.Get_CurAnim() == eAnim.Skill) return; // 붙인 뒤 Rebind 가 동작을 끊으므로 스킬 중엔 미룸(다음 폴링 · 기본 공격은 한 번 끊겨도 됨 — 자동 전투 중엔 대기 프레임이 거의 없음)
+ float at = Mathf.Clamp(c.length * x.time, 0f, Mathf.Max(c.length - 0.0001f, 0f)); bool has = false;
+ var evs = c.events; if (evs != null) foreach (var e in evs) if (e.functionName == x.functionName && Mathf.Abs(e.time - at) < 0.02f) { has = true; break; }
+ if (!has) { c.AddEvent(new AnimationEvent { functionName = x.functionName, time = at, stringParameter = x.stringParameter, floatParameter = x.floatParameter, intParameter = x.intParameter, messageOptions = SendMessageOptions.DontRequireReceiver }); ExtraEventsAdded++; added++; }
+ s_extraDone.Add(key); ExtraLog = c.name + " +" + x.functionName + "@" + at.ToString("F2") + (has ? "(이미 있음)" : "") + " " + x.stringParameter;
+ }
+ }
+ if (added > 0) { an.Rebind(); ExtraLog += " · Rebind"; } // 런타임 AddEvent 는 Rebind 전엔 애니메이터가 안 읽는다(Hit1LookAtTarget 선례 · #953 실측: Rebind 없이는 3발째가 안 나갔다)
+ }
}
}