// ───────────────────────────────────────────────────────────────────────────── // WLGhostStrike.cs — 스킬 타격 지점마다 PC 공격 자세 실루엣(고스트)을 잇달아 띄운다 — 순간이동하며 마구 베는 연출 // // PD 원문(#943-⑦ · 2026-10-19): 「단검 궁극 스킬 시전 시 타격 이펙트가 위치한 지점에 캐릭터의 공격 장면이 실루엣처럼 등장시켜서 // 마치 순간이동하며 마구 공격하는 느낌으로 연출해줘.」 // // ■ 대상 — 단검(비수 1043 · Dagger 10202) 각성 「환영 난무」 Skill_DarkAmbush (rules[].skill = 원본 스킬 클래스 이름 · 다른 스킬은 표에 줄만 더한다) // ■ 실측(2026-10-19 · 프리팹 YAML · 코드) — 원본 시전 = PC 1.5 초 숨김(Set_Invisible · 애니메이터 정지) · 이펙트 FX_Continuous_Collision(배율 0.7 · 월드 축 고정)의 // 폭발 Exp_01 ~ Exp_11 이 시작 지연 0 ~ 1.0 초(0.1 초 간격)로 PC 둘레 약 3 × 7 m 에 지그재그로 터지고 · 판정 상자(약 6.3 × 10 m)가 0.1 초마다 10 번 켜진다. // 단검은 WLImpactFx skillHitFx 에 칸이 없어(10201 · 10202 없음) 적 몸에 뜨는 스킬 피격 이펙트가 없다 → 화면의 「타격 이펙트」 = 이 폭발들. // ■ 자리(anchor) — "fx" = 그 폭발이 터지는 시각 · 자리마다(이름 앞부분 fxPointPrefix · 파티클 startDelay) · "hit" = 이 스킬에 맞은 적 둘레 hitDistance m · // 타격마다 직전 방위에서 hitTurnMin ~ Max° 돌려 다른 방위 · 적을 바라봄 · "both" = 둘 다. // fx 방향 = faceRadius 안 가장 가까운 적 · 없으면 직전 자리 → 이번 자리(순간이동 방향). // ■ 자세 — poseSource "clips" = 숨은 꼭두각시(PC 복제 → 스크립트 · 충돌체 · 내비 · 소리 제거 → 남은 스크립트 0 확인 뒤에만 켬 · K-DEV-020: 러너 Update 에서만)에 // poses("상태@정규화 시각")를 Animator.Play + Update(0)(이벤트 끔 · 루트 모션 끔)로 걸어 BakeMesh → 자세마다 합친 메시 1 개를 굽고 보관 // (PC · 컨트롤러 · 메시 · poses 가 바뀔 때만 다시 굽는다) · 실패하거나 "live" 면 그 시전 순간 PC 자세 1 개(원본 숨김 중 정지 자세). // 부품 = 훅 순간(원본 숨김 전) 켜져 있던 SkinnedMeshRenderer + 무기(PCActor.tfs_weapon 아래 MeshFilter) — 경로로 꼭두각시에서 찾는다. // BakeMesh(useScale false) = 렌더러 위치 · 회전 기준 월드 크기(편집 모드 실측: 루트 배율 2 → 메시 경계 × 2) → 부품 배율 1 로 합친다. // ■ 그리기 — 공유 재질 1 장(URP Unlit 반투명 · 색 · 알파 = MaterialPropertyBlock) · 풀(maxAlive) · 그림자 · 프로브 끔 · hold 초 유지 뒤 fade 초 동안 알파 → 0. // ■ 값 = Resources/WL/WLGhostStrike.json (C45) · 끄기 = enabled 0 · 원본 훅 = Skill_DarkAmbush.Set 1줄(OnSet) · 원본 진행 · 피해 그대로 // 🔴 Debug.LogError 금지(원본 ErrorLogHook 팝업) — 실패는 LastLog · 타격 콜백(물리) · 애니메이션 이벤트 안에서는 기록 · 요청만 쌓는다(K-DEV-020) // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using WL.Combat.Core; namespace WL.Combat.Weapon { [System.Serializable] public class WLGhostStrikeRule { public string skill = ""; // 원본 스킬 클래스 이름(eEffect) — 원본 Set 훅이 이 이름으로 찾는다 public string anchor = "fx"; // fx = 원본 이펙트 폭발 자리 · hit = 맞은 적 둘레 · both public string fxPointPrefix = "Exp_"; // fx: 원본 스킬 프리팹 안 폭발 자식 이름 앞부분(시각 = 그 파티클 startDelay) // #1000 PD 「비수 각성 스킬 사용 시 스킬 판정 범위를 뒤덮을 만큼 "Slash Effects FREE" 에셋의 위 이펙트를 추가로 재생해줘. (기존 이펙트와 결합해 더 화려하게)」 — // areaFx = 원본 판정 상자(BoxCollider) 가운데에 areaFxTimes 초마다 띄우는 이펙트(Resources) · 크기 = 판정 상자 수평 대각선 × areaFxFit(프리팹 기준 지름 areaFxBase m) · 회전 = 무작위 방위 + 기울기 ±areaFxTilt° public string areaFx = ""; public float[] areaFxTimes = new float[0]; public float areaFxFit = 0.9f, areaFxBase = 6.6f, areaFxTilt = 25f, areaFxUp = 1.0f, areaFxLife = 1.0f; // #1001 PD 「새로 추가 된 이펙트가 빠르게 재생되어야할 것 같은데, 몇번씩 나눠서 재생되는 느낌이야. 중단 없이 한번에 재생되게 하고, 속도를 전체 공격이 발동되는 타이밍에 맞게 끝나게」 — areaFxSpan > 0 = areaFxTimes 첫 값에 1번만 · 재생 속도 = 프리팹 본래 길이(층 지연 + 수명 최댓값) / areaFxSpan → areaFxSpan 초 뒤에 끝남 public float areaFxSpan; // #1005 PD 「비수 각성 스킬에 추가 된 궤적 이펙트가 너무 느리게 재생되는 느낌이야. 좀 더 빠르게 재생시키고」 — areaFxSpeed > 0 = 그 재생 속도로 고정하고 // 숨김 끝(areaFxSpan 음수)까지 areaFxEvery 초마다 새 방향으로 이어 띄운다(앞 검기가 사라지기 전에 다음 검기 = 끊김 없이 · 끝나기 전 반 길이 안엔 새로 안 띄움) public float areaFxSpeed, areaFxEvery; public float fxLead = 0f; // fx: 폭발보다 이만큼 먼저(+) · 늦게(−) 띄운다(초) public float faceRadius = 4f; // 이 반경(m) 안 가장 가까운 적을 바라봄 · 없으면 직전 자리 → 이번 자리 방향 public float hitDistance = 0.8f; // hit: 적 위치에서 떨어진 거리(m) public int hitFromBody = 0; // hit: 1 = 거리를 적 몸 반지름 바깥부터 잰다(WLCombatMotionSettings.BodyRadius) public float hitTurnMin = 70f; // hit: 같은 적 직전 방위에서 돌리는 각 최소(°) · 좌우 무작위 public float hitTurnMax = 150f; // hit: 최대(°) public float hitMinInterval = 0.08f; // hit: 같은 적 최소 간격(초) public float castSeconds = 2f; // 시전 기록 유지(초) — 이 안의 타격만 hit 고스트 · fx 예약도 이 안에서만 // #951 PD 「시전 시 뛰어오르는 잔상 → 사라진 채 공격 → 잔상과 함께 등장 · 낮은 공중에서 떨어져 착지 포즈 · 0.5 m 미끄러지다 정지 · 흙먼지」 public int jumpGhosts = 0; // 시전 순간 위·앞으로 남기는 잔상 수(0 = 없음) public float jumpHeight = 1f, jumpForward = 0.4f, jumpSeconds = 0.25f; public string landState = ""; // 착지 애니메이터 상태 이름(비우면 착지 연출 없음) public float landHeight = 1.2f, landFallSeconds = 0.25f, slideDistance = 0.5f, slideSeconds = 0.3f, landTotalSeconds = 1f; public float landAtSeconds = 0f; // 공중 클립 종료를 못 잡았을 때 이 시각(시전 뒤 초)에 착지 시작(0 = 클립 감지만) public int invisibleMatchFx; // #965 1 = 원본 숨김 시간(Set_Invisible)을 이 시전 이펙트가 끝나는 시각(켜진 렌더러 입자계의 시작 지연 + 마지막 분출 + 수명 최댓값)으로 public float invisibleExtra; // #965 위 시각에 더할 초(0 = 이펙트 끝과 동시에 등장) 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)을 건너뛴다 — 카메라 각도 유지 // #959 PD 「각성 시전 후 공중에서 떨어지지 말고 옆에서 튀어나와 정지하는 것처럼」 — entrySide ≠ 0: 다시 보이는 순간 최종 자리에서 PC 오른쪽(+ · 왼쪽 −)으로 entrySide m 떨어진 곳에 워프한 뒤 // landFallSeconds 동안 감속하며 최종 자리로 미끄러져 들어온다(그동안 landPose 반투명 캐릭터 · landHeight 0 이면 수직 이동 없음) · jumpSide: 시전 순간 잔상이 옆으로도 빠진다(m · 같은 부호 = 같은 쪽) public float entrySide = 0f, jumpSide = 0f; public float fallForward = 0f; // #953 낙하하는 동안 앞으로 가는 거리(m · 대각선으로 미끄러지며 낙하) · 착지 뒤 미끄러짐(slideDistance)은 별도 // #954 PD 「0.5 m 정도 짧은 높이에서 반투명 상태로 갑자기 이펙트와 함께 튀어나오며 랜딩 · 랜딩 시간 짧게 · 랜딩 중 공격 불가」 public string landPose = ""; // 착지 자세("상태@정규화 시각") — 떨어지는 동안 반투명 캐릭터(잔상 재질)로 그리고 실제 모델은 숨긴다(landHideModel) public int landHideModel = 0; // 1 = 떨어지는 동안 실제 모델 숨김 → 착지 순간 보임(튀어나옴) public float[] landGhostColor; // 떨어지는 반투명 캐릭터 색(r,g,b,a) · 비우면 잔상 색 public float landGhostHold = 0.02f, landGhostFade = 0.08f; // 매 프레임 띄우는 반투명 캐릭터의 유지·소멸(초) public int popGhosts = 0; // 착지 순간 착지 자세로 터뜨리는 잔상 수(잔상 색 · 기본 유지·소멸) // #960 PD 「각성 마지막 랜딩 때 그림자는 옆에서 등장하는데 캐릭터는 공중에서 낙하 → 캐릭터를 양손 베기 모션 상태로 그림자처럼 옆에서 등장하며 랜딩」 — // landStateStart = 착지 상태를 이 정규화 시각부터(양손 베기 클립의 베는 구간이 옆 진입 동안 보이게) · landNoEvents 1 = 착지 동안 애니메이션 이벤트 끔(4타 클립의 순간이동 · 카메라 고정 · 타격이 착지에 끼지 않게) · // entryGhostInterval > 0 = 옆에서 들어오는 동안(실제 모델이 보일 때) 이 간격으로 지금 자세 잔상(landGhostColor · landGhostHold · landGhostFade) = 그림자처럼 지나온 길 public float landStateStart = 0f, entryGhostInterval = 0f; public float popGhostStep = 0.18f; // #961 끝 잔상 간격(m · 캐릭터 뒤로 이어짐) public string popGhostDir = "back"; // #961 끝 잔상 방향 — back = 캐릭터 등 쪽(정면 반대 · 이전 = 정면) · entry = 옆 진입으로 들어온 길 쪽 // #961 PD 「비수 각성 시전 중 피격 대상이 된 몬스터 주위에 기존 비수 주력 이펙트(붉은 빛의 회전하는 검기들)도 함께 재생」 — // hitFxPrefab = 이 스킬에 맞은 적마다 몸 중심에 띄우는 이펙트(Resources 경로) · hitFxScale 배율 · hitFxYOffset 높이(m) · // hitFxInterval = 같은 적에게 다시 띄우는 간격(초 · 맞는 동안 이어 보이게) · hitFxLife = 띄운 뒤 지우는 초 · hitFxFollow 1 = 밀려나는 적을 따라감(위치만) public string hitFxPrefab = ""; public float hitFxScale = 0.5f, hitFxYOffset = 0f, hitFxInterval = 0.45f, hitFxLife = 1.2f; public int hitFxFollow = 1; public string hitFxSkill = ""; // #992 비우지 않으면 hitFxPrefab 대신 WLImpactFx 스킬 피격 규칙(이 이름 · 직업)으로 — 기본 공격과 같은 이펙트 · 몸 높이 비례 · 무작위 회전(hitFxInterval 은 그대로) public int landNoEvents = 1; public string poseSource = "clips"; // clips = 숨은 꼭두각시에 poses 를 걸어 굽기 · live = 시전 순간 PC 자세 1 개 public string[] poses = new string[0]; // "애니메이터 상태 이름@정규화 시각" — 차례로 돌려 쓴다(시작은 무작위) } [System.Serializable] public class WLGhostStrikeJson { public int enabled = 0; public int maxAlive = 6; // 동시에 보이는 고스트 최대(= 풀 크기) public float hold = 0.05f; // 나타난 뒤 알파 유지(초) public float fade = 0.3f; // 그 뒤 알파 → 0(초) public float[] color = new float[0]; // 실루엣 색 RGBA(A = 시작 알파) public int includeWeapons = 1; // 1 = 무기(PCActor.tfs_weapon 아래 메시)도 실루엣에 public int zWrite = 1; // prepass 0 일 때만: 1 = 색 패스가 깊이를 씀 public int renderQueue = 3000; public int prepass = 1; // 1 = 깊이만 쓰는 앞 패스(색 그대로) → 색 패스는 앞면 한 겹만 칠한다(몸 · 팔 겹침이 진해지지 않는 고른 실루엣 · 편집 렌더 확인) public float prepassMinAlpha = 0.35f; // 알파가 이보다 낮아지면 앞 패스를 끈다(거의 투명한 고스트가 뒤 이펙트를 가리지 않게) public int prepassQueue = 2990; // 앞 패스 렌더 큐(색 패스보다 먼저) public string[] shaderNames = new string[0]; public string[] colorProperties = new string[0]; public WLGhostStrikeRule[] rules = new WLGhostStrikeRule[0]; } public static class WLGhostStrike { public const string ResourcesPath = "WL/WLGhostStrike"; // ── 진단(프로브가 읽는다) public static int Casts, FxSpawned, HitSpawned, SkippedCap, PuppetBuilt, PuppetFailed, PosesBaked, LiveBaked, Errors; public static float LastBakeMs; public static string LastLog = ""; public static int Spawned { get { return FxSpawned + HitSpawned; } } public static int ActiveCount { get { int n = 0; for (int i = 0; i < s_pool.Count; i++) if (s_pool[i].busy) n++; return n; } } public static int PoseCount { get { return s_poses.Count; } } struct FxPoint { public Transform t; public float delay; } struct HitReq { public Actor victim; public float time; } sealed class Cast { public WLGhostStrikeRule rule; public ProjectileBase skill; public Actor shooter; public float t0, groundY; public List fx; public int next, poseCursor; public Vector3 lastPos; public bool hasLast, posesReady; public string sig = ""; // #951 도약 잔상 · 착지 public int jumpDone; public float jumpNext = -1f; public bool wasHidden, sawAir, landing, landed, dustDone, hideDuringFall; public float landT0, modelBaseY, modelY = float.NaN; public int landGhostsDone, hideUntilFrame; public Vector3 landPos, landFwd, landRight; public float entryMoved; public Transform model; public UnityEngine.AI.NavMeshAgent agent; public object stopWas; // #959 landRight · entryMoved = 옆에서 들어오기 public bool eventsWas = true, eventsOff; public float nextEntryGhost; // #960 착지 동안 애니메이션 이벤트 끔 · 옆 진입 잔상 다음 시각 public readonly List smrs = new List(); public readonly List weapons = new List(); public readonly List smrPaths = new List(); public readonly List weaponPaths = new List(); } sealed class Pose { public Mesh body; public readonly List extraMesh = new List(); public readonly List extraM = new List(); } sealed class Ghost { public GameObject root; public MeshFilter bodyF; public MeshRenderer bodyR; public readonly List exF = new List(); public readonly List exR = new List(); public readonly List exSingle = new List(); public readonly MaterialPropertyBlock mpb = new MaterialPropertyBlock(); public float t0; public bool busy, pre; public float[] color; public float hold = -1f, fade = -1f; // #954 잔상별 색·수명 덮기(착지 중 반투명 캐릭터) } static WLGhostStrikeJson s_cfg; static readonly Dictionary s_rules = new Dictionary(); static readonly Dictionary> s_fxCache = new Dictionary>(); static Cast s_cast; static readonly List s_hitQ = new List(16), s_fxQ = new List(16); // #961 s_fxQ = 피격 대상 이펙트 요청 static readonly Dictionary s_bearing = new Dictionary(), s_lastHitAt = new Dictionary(), s_lastFxAt = new Dictionary(); public static int HitFxSpawned; public static string HitFxLog = ""; static readonly List s_poses = new List(); static string s_poseSig = ""; static readonly List s_pool = new List(); static Material s_mat, s_depthMat; static bool s_matTried; static Material[] s_matsPre, s_matsOne; static readonly List s_colorIds = new List(); static WLGhostStrikeRunner s_runner; static readonly List s_combine = new List(32); static readonly List s_tmp = new List(8); static WLGhostStrikeJson Cfg { get { if (s_cfg != null) return s_cfg; s_cfg = new WLGhostStrikeJson(); s_rules.Clear(); var ta = Resources.Load(ResourcesPath); if (ta == null) { LastLog = "설정 없음 " + ResourcesPath; return s_cfg; } try { JsonUtility.FromJsonOverwrite(ta.text, s_cfg); } catch (System.Exception ex) { LastLog = "설정 파싱 실패 — " + ex.Message; } if (s_cfg.rules != null) foreach (var r in s_cfg.rules) if (r != null && !string.IsNullOrEmpty(r.skill)) s_rules[r.skill] = r; return s_cfg; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { s_cfg = null; s_cast = null; s_hitQ.Clear(); s_bearing.Clear(); s_lastHitAt.Clear(); s_fxCache.Clear(); s_fxQ.Clear(); s_lastFxAt.Clear(); HitFxSpawned = 0; HitFxLog = ""; s_poses.Clear(); s_poseSig = ""; s_pool.Clear(); s_mat = null; s_depthMat = null; s_matsPre = s_matsOne = null; s_matTried = false; s_runner = null; Casts = FxSpawned = HitSpawned = SkippedCap = PuppetBuilt = PuppetFailed = PosesBaked = LiveBaked = Errors = 0; LastBakeMs = 0f; LastLog = ""; JumpSpawned = Landings = 0; LandLog = ""; CombatEvents.HitConfirmed.Remove(OnHit); if (Cfg.enabled == 0) return; foreach (var r in s_rules.Values) if (r.anchor != "fx" || !string.IsNullOrEmpty(r.hitFxPrefab)) { CombatEvents.HitConfirmed.Add(OnHit); break; } // #961 피격 대상 이펙트도 타격 확정을 본다 } public static string Describe() { return "casts " + Casts + " · fx " + FxSpawned + " · hit " + HitSpawned + " · active " + ActiveCount + " · cap-skip " + SkippedCap + " · poses " + s_poses.Count + " (puppet " + PuppetBuilt + " fail " + PuppetFailed + " baked " + PosesBaked + " live " + LiveBaked + " · " + LastBakeMs.ToString("F1") + " ms) · err " + Errors + " · " + LastLog; } // ───────────────────────────────────────── 원본 훅 /// 원본 스킬 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 string InvisibleLog = ""; /// #965 PD 「비수 각성 스킬 끝나고 캐릭터 등장까지 딜레이가 너무 길어 — 이펙트 종료와 동시에 등장」 — 원본 Skill_DarkAmbush.Co_Update 의 Set_Invisible(1.5 s) 인자 훅(1줄): /// 규칙 invisibleMatchFx 면 이 시전 인스턴스의 켜진 렌더러 입자계(반복 · 렌더러 꺼진 보조 입자계 제외)마다 (시작 지연 + 마지막 분출 + 수명 최댓값)의 최댓값 = 마지막 폭발이 사라지는 시각 + invisibleExtra. /// 다시 보이는 순간 = 착지 시작(LateTick 「보이는 순간」)이라 등장도 그 시각. public static float InvisibleSeconds(ProjectileBase skill, float orig) { try { var c = Cfg; if (skill == null || c.enabled == 0) return orig; WLGhostStrikeRule r; if (!s_rules.TryGetValue(skill.GetType().Name, out r) || r == null || r.invisibleMatchFx == 0) return orig; float v = 0f; foreach (var ps in skill.GetComponentsInChildren(true)) { var rd = ps.GetComponent(); if (rd == null || !rd.enabled) continue; var m = ps.main; if (m.loop) continue; float delay = m.startDelay.mode == ParticleSystemCurveMode.Constant ? m.startDelay.constant : m.startDelay.constantMax; float life = m.startLifetime.mode == ParticleSystemCurveMode.Constant ? m.startLifetime.constant : (m.startLifetime.mode == ParticleSystemCurveMode.TwoConstants ? m.startLifetime.constantMax : m.startLifetime.curveMultiplier); var em = ps.emission; float last = -1f; for (int i = 0; i < em.burstCount; i++) { var b = em.GetBurst(i); if (b.cycleCount <= 0) continue; last = Mathf.Max(last, b.time + (b.cycleCount - 1) * b.repeatInterval); } if (em.rateOverTime.constantMax > 0f) last = Mathf.Max(last, m.duration); if (last < 0f) continue; v = Mathf.Max(v, delay + last + life); } if (v < 0.2f) { InvisibleLog = "이펙트 시간 못 셈 → 원본 " + orig; return orig; } float sec = Mathf.Max(0.2f, v + r.invisibleExtra); InvisibleLog = "숨김 " + orig.ToString("F2") + " → " + sec.ToString("F2") + " s(이펙트 끝 " + v.ToString("F2") + ")"; return sec; } catch (System.Exception) { return orig; } } public static void OnSet(ProjectileBase skill) { try { var c = Cfg; if (skill == null || c.enabled == 0) return; WLGhostStrikeRule r; if (!s_rules.TryGetValue(skill.GetType().Name, out r)) return; var pd = skill.Get_ProjectTileData(); var shooter = pd != null ? pd.shooter : null; if (DSUtil.CheckNull(shooter)) return; var cast = new Cast { rule = r, skill = skill, shooter = shooter, t0 = Time.time, groundY = shooter.Get_position().y, poseCursor = Random.Range(0, 1024), jumpNext = Time.time }; if (r.anchor != "hit") cast.fx = FxPoints(skill, r); Capture(cast, c); s_cast = cast; s_hitQ.Clear(); s_bearing.Clear(); s_lastHitAt.Clear(); s_fxQ.Clear(); s_lastFxAt.Clear(); Casts++; EnsureRunner(); if (!string.IsNullOrEmpty(r.areaFx) && r.areaFxTimes != null && r.areaFxTimes.Length > 0) s_runner.StartCoroutine(AreaFx(skill, r)); // #1000 LastLog = "시전 " + r.skill + " · fx " + (cast.fx != null ? cast.fx.Count : 0) + " · 부품 " + cast.smrs.Count + "+" + cast.weapons.Count; } catch (System.Exception ex) { Errors++; LastLog = "OnSet 실패 — " + ex.Message; } } public static int AreaFxShown; public static string AreaLog = ""; /// #1000 판정 상자 가운데에 정해진 시각마다 검기(Slash Effects FREE Double Slash 사본)를 띄워 판정 범위를 덮는다 — 시전 자리 고정(원본 판정도 시전 자리). static System.Collections.IEnumerator AreaFx(ProjectileBase skill, WLGhostStrikeRule r) { var p = Resources.Load(r.areaFx); if (p == null) { AreaLog = "없음 " + r.areaFx; yield break; } var box = skill.GetComponentInChildren(true); float diag = 6f; if (box != null) { var s = Vector3.Scale(box.size, box.transform.lossyScale); diag = new Vector2(s.x, s.z).magnitude; } float scale = diag * r.areaFxFit / Mathf.Max(0.1f, r.areaFxBase), t0 = Time.time; foreach (var at in r.areaFxTimes) { while (Time.time - t0 < at) yield return null; if (skill == null || !skill.gameObject.activeInHierarchy) yield break; // 판정 상자 가운데 = 띄우는 순간에 잰다(시전 순간엔 판정 상자가 풀 대기 자리 100000 m 에 있음 · 0.25 s 에 켜지며 옮겨짐 · #1000 1차 실측) · 꺼져 있거나 20 m 넘게 떨어져 있으면 시전 자리 Vector3 c = skill.transform.position; if (box != null && box.gameObject.activeInHierarchy) { var bc = box.transform.TransformPoint(box.center); if (Vector3.Distance(new Vector3(bc.x, 0f, bc.z), new Vector3(c.x, 0f, c.z)) < 20f) c = bc; } c.y = skill.transform.position.y + r.areaFxUp; AreaLog = "판정 상자 대각 " + diag.ToString("0.0") + " m · 배율 " + scale.ToString("0.00") + " · " + r.areaFxTimes.Length + "번 · 가운데 " + c.ToString("F1"); var rot = Quaternion.Euler(Random.Range(-r.areaFxTilt, r.areaFxTilt), Random.Range(0f, 360f), Random.Range(-r.areaFxTilt, r.areaFxTilt)); var go = Object.Instantiate(p, c, rot); float natural = 0f; foreach (var ps in go.GetComponentsInChildren(true)) { var m0 = ps.main; natural = Mathf.Max(natural, m0.startDelay.constantMax + m0.startLifetime.constantMax); } float span = r.areaFxSpan < 0f ? Mathf.Max(0.2f, InvisibleSeconds(skill, 1.5f) - at) : r.areaFxSpan; // #1002 PD 「각성 스킬 시전 후 플레이어가 등장하기 전까지 베는 연출이 재생」 — 음수 = 숨김(Set_Invisible) 끝까지 if (r.areaFxSpeed > 0f && span > 0f && natural > 0f) // #1005 고정 속도 · 이어 띄우기 { Object.Destroy(go); float dur = natural / r.areaFxSpeed, every = r.areaFxEvery > 0f ? r.areaFxEvery : dur, t1 = Time.time; int k = 0; // #1008 PD 「재생 속도가 빨라져도 지금과 동일하게 스킬 연출이 끝나는 타이밍에 맞게 끝날 수 있게」 — 시작 시각을 0 ~ (숨김 끝 − 1번 길이)에 고르게(간격 ≤ every) = 마지막 검기가 숨김 끝에 정확히 끝남 int n = span > dur ? Mathf.CeilToInt((span - dur) / Mathf.Max(0.02f, every) - 1e-3f) + 1 : 1; float step = n > 1 ? (span - dur) / (n - 1) : 0f; for (int i = 0; i < n; i++) { float ts = i * step; while (Time.time - t1 < ts) yield return null; if (skill == null || !skill.gameObject.activeInHierarchy) yield break; var g = Object.Instantiate(p, c, k == 0 ? rot : Quaternion.Euler(Random.Range(-r.areaFxTilt, r.areaFxTilt), Random.Range(0f, 360f), Random.Range(-r.areaFxTilt, r.areaFxTilt))); foreach (var ps in g.GetComponentsInChildren(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; m.simulationSpeed *= r.areaFxSpeed; } g.transform.localScale = Vector3.one * scale; Object.Destroy(g, dur + 0.15f); AreaFxShown++; k++; } AreaLog += " · 본래 " + natural.ToString("0.00") + " s × 속도 " + r.areaFxSpeed.ToString("0.00") + " · " + k + "번(" + step.ToString("0.000") + " s 간격 · 마지막 끝 " + ((n - 1) * step + dur).ToString("0.00") + " s · 숨김 " + span.ToString("0.00") + " s)"; yield break; } float sp = span > 0f && natural > 0f ? natural / span : 1f; foreach (var ps in go.GetComponentsInChildren(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; m.simulationSpeed *= sp; } go.transform.localScale = Vector3.one * scale; Object.Destroy(go, span > 0f ? span + 0.15f : Mathf.Max(0.2f, r.areaFxLife)); AreaFxShown++; AreaLog += " · 본래 " + natural.ToString("0.00") + " s × 속도 " + sp.ToString("0.00"); if (span > 0f) yield break; // 1번만 } } /// 풀 정리(Tick · 유지·소멸 뒤 반환)를 돌리는 러너 — #959 실측: 각성(OnSet) 전에 4타 순간이동 잔상·난사 잔상만 쓰면 러너가 없어 잔상 6개가 영영 busy(원장 47 「첫 6개 뒤 0개」의 원인) → 잔상을 띄우는 모든 입구에서 보장. static void EnsureRunner() { if (s_runner != null) return; var go = new GameObject("[WL943] GhostStrike"); go.hideFlags = HideFlags.HideInHierarchy; Object.DontDestroyOnLoad(go); s_runner = go.AddComponent(); } /// 타격 확정(물리 콜백 안) — 이번 시전 스킬이 시전자로 맞힌 것만 요청으로 쌓는다(생성은 러너 Update). static void OnHit(in HitConfirmedEvent e) { try { var cast = s_cast; if (cast == null || DSUtil.CheckNull(e.victim) || !ReferenceEquals(e.attacker, cast.shooter)) return; bool ghost = cast.rule.anchor != "fx", fx = !string.IsNullOrEmpty(cast.rule.hitFxPrefab); // #961 피격 대상 이펙트 if (!ghost && !fx) return; var pd = e.projectile; var sk = pd != null ? pd.m_SkillListTableData : null; if (sk == null || sk.e_Skill.ToString() != cast.rule.skill) return; if (ghost && s_hitQ.Count < Mathf.Max(1, Cfg.maxAlive) * 4) s_hitQ.Add(new HitReq { victim = e.victim, time = Time.time }); if (fx && s_fxQ.Count < 32) s_fxQ.Add(new HitReq { victim = e.victim, time = Time.time }); } catch (System.Exception ex) { Errors++; LastLog = "hit 실패 — " + ex.Message; } } // ───────────────────────────────────────── 러너 틱 internal static void Tick() { try { var c = Cfg; float now = Time.time; var cast = s_cast; if (cast != null && DSUtil.CheckNull(cast.shooter)) { s_cast = cast = null; } if (cast != null) { if (!cast.posesReady) { cast.posesReady = true; PreparePoses(cast, c); } // #951 ① 도약 잔상(시전 직후 jumpSeconds 동안 위·앞으로) var rl = cast.rule; if (rl.jumpGhosts > 0 && cast.jumpDone < rl.jumpGhosts && now >= cast.jumpNext && cast.jumpNext >= 0f) { float k = (cast.jumpDone + 1f) / rl.jumpGhosts; var sp = cast.shooter.Get_position(); var fw = cast.shooter.transform.forward; fw.y = 0f; if (fw.sqrMagnitude < 1e-4f) fw = Vector3.forward; fw.Normalize(); var rt = Vector3.Cross(Vector3.up, fw); // #959 PC 오른쪽 if (Spawn(c, cast, sp + Vector3.up * (rl.jumpHeight * k) + fw * (rl.jumpForward * k) + rt * (rl.jumpSide * k), fw)) JumpSpawned++; cast.jumpDone++; cast.jumpNext = now + rl.jumpSeconds / rl.jumpGhosts; } // #951 ② 착지 연출 — 시작 판정은 LateTick(#952-② 다시 보이는 그 프레임에 즉시 · 서 있는 자세가 한 프레임도 안 보이게) · 진행은 여기 if (cast.landing) UpdateLanding(c, cast, now); if (cast.fx != null) while (cast.next < cast.fx.Count && now >= cast.t0 + cast.fx[cast.next].delay - cast.rule.fxLead) { var p = cast.fx[cast.next++]; if (p.t == null || !p.t.gameObject.activeInHierarchy) continue; // 원본 이펙트가 이미 꺼짐(Off) var pos = p.t.position; pos.y = cast.groundY; if (Spawn(c, cast, pos, FaceDir(cast, pos))) FxSpawned++; cast.lastPos = pos; cast.hasLast = true; } for (int i = 0; i < s_hitQ.Count; i++) HitSpawn(c, cast, s_hitQ[i]); for (int i = 0; i < s_fxQ.Count; i++) HitFxSpawn(cast, s_fxQ[i]); // #961 bool landPending = !string.IsNullOrEmpty(cast.rule.landState) && !cast.landed && now - cast.t0 < Mathf.Max(0.1f, cast.rule.castSeconds) + 4f; // #951 착지가 끝날 때까지(상한 +4 s) 시전 기록 유지 if (now - cast.t0 > Mathf.Max(0.1f, cast.rule.castSeconds) && !cast.landing && !landPending) s_cast = null; } s_hitQ.Clear(); s_fxQ.Clear(); for (int i = 0; i < s_pool.Count; i++) { var g = s_pool[i]; if (!g.busy || g.root == null) continue; float t = now - g.t0; float hold = g.hold >= 0f ? g.hold : c.hold, fade = g.fade >= 0f ? g.fade : c.fade; if (t >= hold + fade) { g.busy = false; g.root.SetActive(false); g.color = null; g.hold = g.fade = -1f; continue; } SetAlpha(g, t <= hold ? 1f : 1f - (t - hold) / Mathf.Max(0.01f, fade)); } } catch (System.Exception ex) { Errors++; LastLog = "틱 실패 — " + ex.Message; } } // ───────────────────────────────────────── #951 도약 잔상 · 착지 연출 public static int JumpSpawned, Landings, LandGhostFrames; public static string LandLog = ""; /// #955 비수 4타 순간이동 잔상 — 지금 자세(살아 있는 메시)를 한 번 굽고 from→to 사이에 n 개(잔상 색 · 기본 유지·소멸). public static int BlinkGhosts(Actor pc, Vector3 from, Vector3 to, int n) { try { var c = Cfg; if (c.enabled == 0 || pc == null || n <= 0) return 0; EnsureRunner(); // #959 풀 정리 보장(원장 47) var cast = new Cast { shooter = pc, rule = new WLGhostStrikeRule(), t0 = Time.time, groundY = pc.Get_position().y, poseCursor = 0 }; Capture(cast, c); var pose = BakePose(pc.transform, cast.smrs, cast.weapons); if (pose == null) return 0; var face = to - from; face.y = 0f; if (face.sqrMagnitude < 1e-4f) face = pc.transform.forward; int made = 0; for (int i = 0; i < n; i++) if (Spawn(c, cast, Vector3.Lerp(from, to, (i + 0.5f) / n), face, pose)) made++; if (pose.body != null) Object.Destroy(pose.body, c.hold + c.fade + 0.5f); foreach (var m in pose.extraMesh) if (m != null) Object.Destroy(m, c.hold + c.fade + 0.5f); BlinkGhostCount += made; return made; } catch (System.Exception ex) { Errors++; LastLog = "잔상 실패 — " + ex.Message; return 0; } } public static int BlinkGhostCount; /// #959 지금 자세 잔상 1개(살아 있는 메시를 굽는다) — 비수 주력 난사 중 「빠르게 공격하는」 잔상 · 색(RGBA · null = 기본) · 유지 · 소멸(−1 = 기본). public static int LiveGhosts; public static bool LiveGhost(Actor pc, float[] color, float hold, float fade) { try { var c = Cfg; if (c.enabled == 0 || pc == null) return false; EnsureRunner(); // #959 풀 정리 보장 var cast = new Cast { shooter = pc, rule = new WLGhostStrikeRule(), t0 = Time.time, groundY = pc.Get_position().y }; Capture(cast, c); var pose = BakePose(pc.transform, cast.smrs, cast.weapons); if (pose == null) return false; var face = pc.transform.forward; face.y = 0f; if (face.sqrMagnitude < 1e-4f) face = Vector3.forward; bool ok = Spawn(c, cast, pc.Get_position(), face, pose, color, hold, fade); float life = (hold >= 0f ? hold : c.hold) + (fade >= 0f ? fade : c.fade) + 0.5f; if (pose.body != null) Object.Destroy(pose.body, life); foreach (var m in pose.extraMesh) if (m != null) Object.Destroy(m, life); if (ok) LiveGhosts++; return ok; } catch (System.Exception ex) { Errors++; LastLog = "잔상 실패 — " + ex.Message; return false; } } /// #954 「랜딩 중 공격할 수 없어야」 — 이 시전자가 착지 연출 중인가(자동 전투는 IsStop 으로 이미 멈춤 · 수동 공격 키는 이걸로 막는다). public static bool IsLanding(Actor a) { var cast = s_cast; return cast != null && cast.landing && a != null && ReferenceEquals(cast.shooter, a); } static System.Reflection.PropertyInfo s_isStop; static string CurrentClip(Actor a) { var an = a != null ? a.m_animation : null; if (an == null) return ""; var ci = an.GetCurrentAnimatorClipInfo(0); return ci.Length > 0 && ci[0].clip != null ? ci[0].clip.name : ""; } /// 러너 LateUpdate — 애니메이터가 모델 위치를 쓴 뒤에 낙하 높이를 덮는다(Update 에서 쓰면 되돌아간다 · 실측). /// #952-② PD 「이펙트가 끝나는 시점에 잠시 캐릭터가 서 있는 상태가 보였다가 착지 → 끝나는 시점에 즉시 착지」 — 착지 시작 판정도 여기서: 원본 Co_Invisible 이 모델을 다시 켜는 순간(코루틴 = Update 뒤 · LateUpdate 앞)을 /// 같은 프레임에 잡아 착지 클립·높이를 적용하고 그 한 프레임은 모델을 숨긴다(이미 그려질 자세 = 서 있는 원본 자세) → 다음 프레임부터 공중 착지 자세. 공중 클립 끝·시각 조건은 예비. internal static void LateTick() { var cast = s_cast; if (cast == null || DSUtil.CheckNull(cast.shooter)) return; var rl = cast.rule; if (!string.IsNullOrEmpty(rl.landState) && !cast.landing && !cast.landed) { float now = Time.time; bool hidden = IsHidden(cast); string clip = CurrentClip(cast.shooter); if (hidden) cast.wasHidden = true; bool inAir = !string.IsNullOrEmpty(rl.airClip) && clip.Contains(rl.airClip); if (inAir) cast.sawAir = true; bool reappeared = cast.wasHidden && !hidden; bool airEnded = cast.sawAir && !inAir && !hidden && now - cast.t0 > 0.3f; if (reappeared || airEnded || (rl.landAtSeconds > 0f && now - cast.t0 >= rl.landAtSeconds)) { StartLanding(cast, now); cast.modelY = cast.modelBaseY + rl.landHeight; SetVisible(cast, false); cast.hideUntilFrame = Time.frameCount + 1; LandLog += reappeared ? " · 보이는 순간" : airEnded ? " · 공중 클립 끝" : " · 시각"; } } if (cast.landing && cast.hideUntilFrame > 0 && Time.frameCount >= cast.hideUntilFrame) { cast.hideUntilFrame = 0; if (!cast.hideDuringFall) SetVisible(cast, true); } // #954 landHideModel 이면 착지 순간까지 숨긴 채 if (!cast.landing || cast.model == null || float.IsNaN(cast.modelY)) return; var lp = cast.model.localPosition; cast.model.localPosition = new Vector3(lp.x, cast.modelY, lp.z); } public static int EntryGhosts; // #960 옆 진입 잔상 수(검증용) /// #960 착지 동안 끈 애니메이션 이벤트를 되돌린다(한 번만). static void RestoreEvents(Cast cast) { if (!cast.eventsOff) return; cast.eventsOff = false; try { if (!DSUtil.CheckNull(cast.shooter) && cast.shooter.m_animation != null) cast.shooter.m_animation.fireEvents = cast.eventsWas; } catch (System.Exception) { } } /// 시전자 모델(살갗 메시 + 무기 메시) 보이기·숨기기 — 원본 Model_OnOff 와 같은 렌더러를 쓰되 한 프레임용. static void SetVisible(Cast cast, bool on) { for (int i = 0; i < cast.smrs.Count; i++) { var r = cast.smrs[i]; if (r != null) r.enabled = on; } for (int i = 0; i < cast.weapons.Count; i++) { var w = cast.weapons[i]; var r = w != null ? w.GetComponent() : null; if (r != null) r.enabled = on; } } /// #952-④ 직업(무기) 전환 등으로 끊길 때 — 이 시전자의 착지 연출을 접고 멈춤 상태·모델 높이·보이기를 되돌린다. public static void CancelFor(Actor a) { var cast = s_cast; if (cast == null || a == null || !ReferenceEquals(cast.shooter, a)) return; if (cast.landing) { if (cast.model != null) { var lp = cast.model.localPosition; cast.model.localPosition = new Vector3(lp.x, cast.modelBaseY, lp.z); } SetVisible(cast, true); SetStop(a, cast.stopWas); RestoreEvents(cast); // #960 } cast.landing = false; cast.landed = true; cast.modelY = float.NaN; cast.hideUntilFrame = 0; cast.hideDuringFall = false; s_cast = null; LandLog += " · 취소(직업 전환)"; } static bool IsHidden(Cast cast) { for (int i = 0; i < cast.smrs.Count; i++) { var r = cast.smrs[i]; if (r == null) continue; return !r.enabled || !r.gameObject.activeInHierarchy; } return false; } static void SetStop(Actor a, object v) { try { if (s_isStop == null) s_isStop = typeof(Actor).GetProperty("IsStop", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); if (s_isStop != null && s_isStop.CanWrite && v != null) s_isStop.SetValue(a, v, null); } catch (System.Exception) { } } static void StartLanding(Cast cast, float now) { var pc = cast.shooter as PCActor; var r = cast.rule; cast.landing = true; cast.landT0 = now; cast.landGhostsDone = 0; cast.dustDone = false; cast.hideDuringFall = r.landHideModel != 0 && s_landPose != null; // #954 착지 자세를 구웠을 때만 실제 모델을 숨긴다 cast.landPos = cast.shooter.Get_position(); cast.landFwd = cast.shooter.transform.forward; cast.landFwd.y = 0f; if (cast.landFwd.sqrMagnitude < 1e-4f) cast.landFwd = Vector3.forward; cast.landFwd.Normalize(); cast.model = cast.shooter.m_animation != null ? cast.shooter.m_animation.transform : null; cast.modelBaseY = cast.model != null ? cast.model.localPosition.y : 0f; cast.agent = cast.shooter.GetComponent(); if (pc != null) { try { if (s_isStop == null) s_isStop = typeof(Actor).GetProperty("IsStop", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); cast.stopWas = s_isStop != null ? s_isStop.GetValue(pc, null) : null; } catch (System.Exception) { } SetStop(pc, true); pc.WL_StopNavMove(); } cast.landRight = Vector3.Cross(Vector3.up, cast.landFwd).normalized; cast.entryMoved = 0f; if (r.entrySide != 0f) // #959 옆에서 들어오기 — 최종 자리(landPos)에서 옆으로 entrySide 만큼 떨어진 곳으로 워프 → UpdateLanding 이 감속하며 되돌아온다 { var from = cast.landPos - cast.landRight * r.entrySide; try { if (pc != null) pc.Set_Warp(from); else cast.shooter.transform.position = from; } catch (System.Exception) { cast.shooter.transform.position = from; } } try { var an = cast.shooter.m_animation; if (an != null) { if (r.landNoEvents != 0) { cast.eventsWas = an.fireEvents; an.fireEvents = false; cast.eventsOff = true; } // #960 양손 베기(4타) 클립의 이벤트(순간이동 · 카메라 · 타격)가 착지에 끼지 않게 an.speed = 1f; an.CrossFade(r.landState, 0f, 0, Mathf.Clamp01(r.landStateStart)); } } catch (System.Exception ex) { LandLog = "착지 클립 실패 — " + ex.Message; } // #952-② 섞임 0 = 서 있는 자세에서 번지지 않게 cast.nextEntryGhost = now; // #960 옆 진입 잔상 첫 장 = 바로 if (cast.model != null) cast.model.localPosition = new Vector3(cast.model.localPosition.x, cast.modelBaseY + r.landHeight, cast.model.localPosition.z); Landings++; LandLog = "착지 시작 t+" + (now - cast.t0).ToString("F2") + " @" + cast.landPos.ToString("F2") + (cast.agent != null ? " agent" : " agent 없음"); } static void UpdateLanding(WLGhostStrikeJson c, Cast cast, float now) { var r = cast.rule; float t = now - cast.landT0; float fall = Mathf.Max(0.05f, r.landFallSeconds); if (DSUtil.CheckNull(cast.shooter)) { cast.landing = false; cast.landed = true; return; } if (t < fall) { 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 (cast.hideDuringFall && s_landPose != null) { if (Spawn(c, cast, cast.shooter.Get_position() + Vector3.up * y, cast.landFwd, s_landPose, r.landGhostColor, r.landGhostHold, r.landGhostFade)) LandGhostFrames++; } // #954 떨어지는 동안 = 반투명 캐릭터(매 프레임) 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; } if (r.entrySide != 0f) // #959 옆에서 튀어나오기 — 속도 2D/T → 0 감속(총 이동 = |entrySide|) · 방향 = 워프한 쪽의 반대 { float total = Mathf.Abs(r.entrySide), kk = Mathf.Clamp01(t / fall); float step = Mathf.Min(total * (2f / fall) * (1f - kk) * Time.deltaTime, Mathf.Max(0f, total - cast.entryMoved)); var dir = cast.landRight * Mathf.Sign(r.entrySide); cast.entryMoved += step; if (cast.agent != null && cast.agent.enabled && cast.agent.isOnNavMesh) cast.agent.Move(dir * step); else cast.shooter.transform.position += dir * step; if (r.entryGhostInterval > 0f && !cast.hideDuringFall && now >= cast.nextEntryGhost) // #960 실제 캐릭터가 들어오는 길에 그림자 잔상 { cast.nextEntryGhost = now + r.entryGhostInterval; if (LiveGhost(cast.shooter, r.landGhostColor, r.landGhostHold, r.landGhostFade)) EntryGhosts++; } } return; } cast.modelY = cast.modelBaseY; if (!cast.dustDone) { cast.dustDone = true; if (r.entrySide != 0f) // #959 들어오기 끝 = 최종 자리에 정확히 { var d = cast.landPos - cast.shooter.Get_position(); d.y = 0f; if (cast.agent != null && cast.agent.enabled && cast.agent.isOnNavMesh) cast.agent.Move(d); else cast.shooter.transform.position += d; LandLog += " · 옆 진입 " + cast.entryMoved.ToString("F2") + " m"; } if (cast.hideDuringFall) { cast.hideDuringFall = false; SetVisible(cast, true); } // #954 착지 순간 실제 모델이 튀어나온다 if (r.popGhosts > 0 && s_landPose != null) { // #961 PD 「각성 뒤 그림자가 캐릭터 앞으로 나온다 → 캐릭터 뒤로 이어지게」 — 이전 = 정면(landFwd) ±18° 로 0.12 m 씩 앞에 놓였다 · 이제 popGhostDir(기본 back = 캐릭터 등 쪽 · entry = 옆 진입으로 들어온 길 쪽)으로 popGhostStep 씩 Vector3 back = r.popGhostDir == "entry" && r.entrySide != 0f ? -cast.landRight * Mathf.Sign(r.entrySide) : -cast.landFwd; // Play 실측: 들어온 길 쪽은 PC 방향에 따라 카메라 쪽(캐릭터를 덮음)이 되어 기본 = 등 쪽 for (int i = 0; i < r.popGhosts; i++) { var face = Quaternion.Euler(0f, (i - (r.popGhosts - 1) * 0.5f) * 8f, 0f) * cast.landFwd; if (Spawn(c, cast, cast.shooter.Get_position() + back * r.popGhostStep * (i + 1), face, s_landPose)) JumpSpawned++; } } if (!string.IsNullOrEmpty(r.dustPrefab)) { var p = Resources.Load(r.dustPrefab); if (p != null) { var go = Object.Instantiate(p, cast.landPos, Quaternion.LookRotation(cast.landFwd, Vector3.up)); go.transform.localScale = p.transform.localScale * r.dustScale; foreach (var ps in go.GetComponentsInChildren(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; ps.Play(true); } Object.Destroy(go, 3f); } else LandLog = "먼지 프리팹 없음 " + r.dustPrefab; } } float slideT = t - fall; if (slideT < r.slideSeconds && r.slideDistance > 0f) { // #952-② PD 「떨어진 후 미끄러지듯 살짝 앞으로 전진하다 멈추게」 — 속도를 처음 2D/T 에서 0 으로 선형 감속(총 이동 = slideDistance · 등속 뒤 급정지였음) float T = Mathf.Max(0.05f, r.slideSeconds); float k = Mathf.Clamp01(slideT / T); float step = r.slideDistance * (2f / T) * (1f - k) * Time.deltaTime; if (cast.agent != null && cast.agent.enabled && cast.agent.isOnNavMesh) cast.agent.Move(cast.landFwd * step); else cast.shooter.transform.position += cast.landFwd * step; } if (t >= r.landTotalSeconds) { cast.landing = false; cast.landed = true; cast.modelY = float.NaN; if (cast.model != null) { var lp = cast.model.localPosition; cast.model.localPosition = new Vector3(lp.x, cast.modelBaseY, lp.z); } var pc = cast.shooter as PCActor; RestoreEvents(cast); // #960 SetStop(cast.shooter, cast.stopWas); try { if (pc != null) pc.Play_Idle(); } catch (System.Exception) { } LandLog += " · 끝 " + t.ToString("F2") + " s · 이동 " + Vector3.Distance(cast.landPos, cast.shooter.Get_position()).ToString("F2") + " m"; } } static List FxPoints(ProjectileBase skill, WLGhostStrikeRule r) { List list; int id = skill.GetInstanceID(); if (s_fxCache.TryGetValue(id, out list) && (list.Count == 0 || list[0].t != null)) return list; list = new List(); if (!string.IsNullOrEmpty(r.fxPointPrefix)) foreach (var t in skill.GetComponentsInChildren(true)) { if (!t.name.StartsWith(r.fxPointPrefix, System.StringComparison.Ordinal)) continue; var ps = t.GetComponent(); list.Add(new FxPoint { t = t, delay = ps != null ? ps.main.startDelay.constantMax : 0f }); } list.Sort((a, b) => a.delay.CompareTo(b.delay)); s_fxCache[id] = list; return list; } static Vector3 FaceDir(Cast cast, Vector3 pos) { var best = Vector3.zero; float bestD = float.MaxValue; var info = ActorInfo.Ins; if (info != null && cast.rule.faceRadius > 0f) { var list = info.Get_EnemyActors(cast.shooter.IsEnemy(), pos, cast.rule.faceRadius); try { for (int i = 0; i < list.Count; i++) { var a = list[i]; if (a == null) continue; var d = a.Get_position() - pos; d.y = 0f; float m = d.sqrMagnitude; if (m > 1e-4f && m < bestD) { bestD = m; best = d; } } } finally { info.Release_Actors(list); } } if (best.sqrMagnitude < 1e-4f && cast.hasLast) { best = pos - cast.lastPos; best.y = 0f; } if (best.sqrMagnitude < 1e-4f) { best = cast.shooter.transform.forward; best.y = 0f; } return best.sqrMagnitude < 1e-4f ? Vector3.forward : best.normalized; } static void HitSpawn(WLGhostStrikeJson c, Cast cast, HitReq q) { var r = cast.rule; var v = q.victim; if (DSUtil.CheckNull(v) || v.IsDead()) return; float last; if (s_lastHitAt.TryGetValue(v, out last) && q.time - last < r.hitMinInterval) return; s_lastHitAt[v] = q.time; float b; if (!s_bearing.TryGetValue(v, out b)) b = Random.Range(0f, 360f); else b += Random.Range(r.hitTurnMin, r.hitTurnMax) * (Random.value < 0.5f ? -1f : 1f); s_bearing[v] = b; var dir = Quaternion.Euler(0f, b, 0f) * Vector3.forward; var center = v.Get_position(); float dist = r.hitDistance + (r.hitFromBody != 0 ? WL.Combat.WLCombatMotionSettings.BodyRadius(v) : 0f); var pos = center + dir * dist; pos.y = center.y; if (Spawn(c, cast, pos, -dir)) HitSpawned++; } /// #961 이 스킬에 맞은 적의 몸 중심에 hitFxPrefab(원본 주력 붉은 회전 검기 사본)을 띄운다 — 같은 적은 hitFxInterval 마다 · 월드 축(원본 Projectile_StopAndColliderControl 과 같은 방향) · hitFxLife 뒤 파괴. static void HitFxSpawn(Cast cast, HitReq q) { var r = cast.rule; var v = q.victim; if (DSUtil.CheckNull(v) || v.IsDead()) return; float last; if (s_lastFxAt.TryGetValue(v, out last) && q.time - last < r.hitFxInterval) return; if (!string.IsNullOrEmpty(r.hitFxSkill)) // #992 · #993 간격 = 이 규칙 hitFxInterval 만(WLImpactFx 같은 대상 간격 우회 = 명중마다 보이게) { s_lastFxAt[v] = q.time; if (WL.Combat.Impact.WLImpactFx.PlaySkillHitByPC(v, r.hitFxSkill, true)) { HitFxSpawned++; HitFxLog = "피격 이펙트(스킬 규칙 " + r.hitFxSkill + ") " + HitFxSpawned + " · " + v.name + " · 시전 뒤 " + (q.time - cast.t0).ToString("F2") + " s"; } return; } var p = Resources.Load(r.hitFxPrefab); if (p == null) { HitFxLog = "이펙트 없음 " + r.hitFxPrefab; return; } s_lastFxAt[v] = q.time; var offset = v.Get_Center_position() - v.Get_position() + Vector3.up * r.hitFxYOffset; var go = Object.Instantiate(p, v.Get_position() + offset, Quaternion.identity); go.transform.localScale = p.transform.localScale * Mathf.Max(0.01f, r.hitFxScale); foreach (var ps in go.GetComponentsInChildren(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; } var root = go.GetComponent(); if (root != null) root.Play(true); if (r.hitFxFollow != 0) { var f = go.AddComponent(); f.target = v.transform; f.offset = offset; } // 적 자식으로 두지 않는다(적 렌더러 일괄 처리 · 풀 반환에 휩쓸리지 않게) Object.Destroy(go, Mathf.Max(0.3f, r.hitFxLife)); HitFxSpawned++; HitFxLog = "피격 이펙트 " + HitFxSpawned + " · " + v.name + " @" + go.transform.position.ToString("F2") + " ×" + r.hitFxScale.ToString("F2") + " · 시전 뒤 " + (q.time - cast.t0).ToString("F2") + " s"; } // ───────────────────────────────────────── 부품 · 자세 /// 훅 순간(PC 가 보일 때) 켜져 있는 부품의 경로 · 서명(PC · 컨트롤러 · 메시 · poses). static void Capture(Cast cast, WLGhostStrikeJson c) { var pc = cast.shooter; var root = pc.transform; var sig = new System.Text.StringBuilder(); sig.Append(pc.GetInstanceID()).Append('|').Append(pc.m_animation != null && pc.m_animation.runtimeAnimatorController != null ? pc.m_animation.runtimeAnimatorController.GetInstanceID() : 0); foreach (var r in pc.GetComponentsInChildren(true)) { if (r.sharedMesh == null || !r.enabled || !r.gameObject.activeInHierarchy) continue; var path = RelPath(root, r.transform); if (path == null) continue; cast.smrs.Add(r); cast.smrPaths.Add(path); sig.Append('|').Append(path).Append(':').Append(r.sharedMesh.GetInstanceID()); } var pca = pc as PCActor; if (c.includeWeapons != 0 && pca != null && pca.tfs_weapon != null) foreach (var sock in pca.tfs_weapon) { if (sock == null) continue; foreach (var mf in sock.GetComponentsInChildren(true)) { var mr = mf.GetComponent(); if (mf.sharedMesh == null || mr == null || !mr.enabled || !mf.gameObject.activeInHierarchy) continue; var path = RelPath(root, mf.transform); if (path == null) continue; cast.weapons.Add(mf); cast.weaponPaths.Add(path); sig.Append("|w").Append(path).Append(':').Append(mf.sharedMesh.GetInstanceID()); } } sig.Append('|').Append(cast.rule.poseSource).Append('|').Append(cast.rule.poses != null ? string.Join(",", cast.rule.poses) : ""); cast.sig = sig.ToString(); } static void PreparePoses(Cast cast, WLGhostStrikeJson c) { float t0 = Time.realtimeSinceStartup; var r = cast.rule; bool clips = r.poseSource == "clips" && r.poses != null && r.poses.Length > 0 && cast.shooter is PCActor; if (clips && cast.sig == s_poseSig && s_poses.Count > 0) return; // 보관한 자세 그대로 ClearPoses(); if (clips && BakeClips(cast)) s_poseSig = cast.sig; else { ClearPoses(); // 꼭두각시 도중 실패면 일부 구운 자세를 버리고 live 1 개로 var p = BakePose(cast.shooter.transform, cast.smrs, cast.weapons); // 원본 숨김 중 정지 자세(렌더러가 꺼져 있어도 BakeMesh 됨 · 편집 모드 실측) if (p != null) { s_poses.Add(p); LiveBaked++; } } LastBakeMs = (Time.realtimeSinceStartup - t0) * 1000f; } /// 숨은 꼭두각시(PC 복제)에 poses 를 걸어 굽는다 — 러너 Update 에서만(DestroyImmediate). 스크립트 · 충돌체가 남으면 켜지 않고 버린다. static bool BakeClips(Cast cast) { var pc = cast.shooter; GameObject holder = null; try { holder = new GameObject("WL_GhostPuppet"); holder.hideFlags = HideFlags.HideInHierarchy; holder.SetActive(false); var go = Object.Instantiate(pc.gameObject, holder.transform); // 비활성 부모 아래 = Awake · OnEnable 안 돈다 string err = null; for (int pass = 0; pass < 3; pass++) { var mbs = go.GetComponentsInChildren(true); for (int i = mbs.Length - 1; i >= 0; i--) { if (mbs[i] == null) continue; try { Object.DestroyImmediate(mbs[i]); } catch (System.Exception ex) { err = ex.Message; } } } Strip(go, ref err); Strip(go, ref err); // 강체에 붙은 관절이 남으면 강체를 못 뗀다 Strip(go, ref err); Strip(go, ref err); Strip(go, ref err); Strip(go, ref err); // 켜는 순간 두 번째 리스너 경고 방지 int leftMb = go.GetComponentsInChildren(true).Length, leftCol = go.GetComponentsInChildren(true).Length; if (leftMb > 0 || leftCol > 0) { PuppetFailed++; LastLog = "꼭두각시 실패 — 남은 스크립트 " + leftMb + " · 충돌체 " + leftCol + (err != null ? " · " + err : ""); return false; } var an = go.GetComponent(); if (an == null || an.runtimeAnimatorController == null) { PuppetFailed++; LastLog = "꼭두각시 애니메이터 없음"; return false; } an.fireEvents = false; an.applyRootMotion = false; an.cullingMode = AnimatorCullingMode.AlwaysAnimate; an.speed = 1f; var gt = go.transform; gt.localPosition = Vector3.zero; gt.localRotation = Quaternion.identity; gt.localScale = pc.transform.lossyScale; var smrs = new List(); var weapons = new List(); foreach (var p in cast.smrPaths) { var x = Find(gt, p); var s = x != null ? x.GetComponent() : null; if (s != null) smrs.Add(s); } foreach (var p in cast.weaponPaths) { var x = Find(gt, p); var m = x != null ? x.GetComponent() : null; if (m != null) weapons.Add(m); } if (smrs.Count == 0) { PuppetFailed++; LastLog = "꼭두각시 부품 없음"; return false; } holder.SetActive(true); // 남은 스크립트 0 확인 뒤에만 켠다 · 이 함수 끝에서 끄고 지운다(그 사이 그려지지 않음) an.Rebind(); an.Update(0f); foreach (var spec in cast.rule.poses) { string state; float nt; if (!ParsePose(spec, out state, out nt)) continue; int h = Animator.StringToHash(state); if (!an.HasState(0, h)) { LastLog = "상태 없음 " + state; continue; } an.Play(h, 0, nt); an.Update(0f); var pose = BakePose(gt, smrs, weapons); if (pose != null) { s_poses.Add(pose); PosesBaked++; } } // #954 착지 자세 1 개(순환 목록 밖) — 떨어지는 동안의 반투명 캐릭터 · 착지 순간 튀어나오는 잔상 if (s_landPose != null && s_landPose.body != null) Object.Destroy(s_landPose.body); s_landPose = null; if (!string.IsNullOrEmpty(cast.rule.landPose)) { string ls; float lnt; if (ParsePose(cast.rule.landPose, out ls, out lnt) && an.HasState(0, Animator.StringToHash(ls))) { an.Play(Animator.StringToHash(ls), 0, lnt); an.Update(0f); s_landPose = BakePose(gt, smrs, weapons); } else LastLog = "착지 자세 상태 없음 " + cast.rule.landPose; } PuppetBuilt++; return s_poses.Count > 0; } catch (System.Exception ex) { Errors++; PuppetFailed++; LastLog = "꼭두각시 예외 — " + ex.Message; return false; } finally { if (holder != null) { holder.SetActive(false); Object.Destroy(holder); } } } /// 부품을 root 회전 기준 · 월드 단위로 합친 메시 1 개(+ 읽을 수 없는 무기 메시는 따로 그릴 목록). static Pose BakePose(Transform root, List smrs, List weapons) { var rootPos = root.position; var inv = Quaternion.Inverse(root.rotation); s_combine.Clear(); s_tmp.Clear(); var pose = new Pose(); int verts = 0; for (int i = 0; i < smrs.Count; i++) { var r = smrs[i]; if (r == null || r.sharedMesh == null) continue; var m = new Mesh(); r.BakeMesh(m); s_tmp.Add(m); // useScale false = 렌더러 위치 · 회전 기준 월드 크기 var t = r.transform; var mx = Matrix4x4.TRS(inv * (t.position - rootPos), inv * t.rotation, Vector3.one); for (int s = 0; s < m.subMeshCount; s++) s_combine.Add(new CombineInstance { mesh = m, subMeshIndex = s, transform = mx }); verts += m.vertexCount; } for (int i = 0; i < weapons.Count; i++) { var mf = weapons[i]; if (mf == null || mf.sharedMesh == null) continue; var t = mf.transform; var mesh = mf.sharedMesh; var mx = Matrix4x4.TRS(inv * (t.position - rootPos), inv * t.rotation, t.lossyScale); if (mesh.isReadable) { for (int s = 0; s < mesh.subMeshCount; s++) s_combine.Add(new CombineInstance { mesh = mesh, subMeshIndex = s, transform = mx }); verts += mesh.vertexCount; } else { pose.extraMesh.Add(mesh); pose.extraM.Add(mx); } // 읽을 수 없는 메시(빌드에서 Read/Write 끔)는 합치지 않고 따로 그린다 } if (s_combine.Count > 0) { var body = new Mesh { name = "__WLGhostStrikePose" }; if (verts > 65000) body.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32; body.CombineMeshes(s_combine.ToArray(), true, true); body.UploadMeshData(true); // GPU 로 올리고 CPU 사본은 버린다(그리기만 한다) pose.body = body; } for (int i = 0; i < s_tmp.Count; i++) Object.Destroy(s_tmp[i]); s_tmp.Clear(); s_combine.Clear(); return pose.body != null || pose.extraMesh.Count > 0 ? pose : null; } static void ClearPoses() { for (int i = 0; i < s_poses.Count; i++) if (s_poses[i].body != null) Object.Destroy(s_poses[i].body); s_poses.Clear(); s_poseSig = ""; } static void Strip(GameObject go, ref string err) where T : Component { foreach (var x in go.GetComponentsInChildren(true)) { try { Object.DestroyImmediate(x); } catch (System.Exception ex) { err = ex.Message; } } } static bool ParsePose(string spec, out string state, out float nt) { state = ""; nt = 0f; if (string.IsNullOrEmpty(spec)) return false; int at = spec.LastIndexOf('@'); state = (at >= 0 ? spec.Substring(0, at) : spec).Trim(); if (at >= 0 && !float.TryParse(spec.Substring(at + 1).Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out nt)) return false; return state.Length > 0; } static string RelPath(Transform root, Transform t) { if (t == root) return ""; var names = new List(); for (var x = t; x != root; x = x.parent) { if (x == null) return null; names.Add(x.name); } names.Reverse(); return string.Join("/", names.ToArray()); } static Transform Find(Transform root, string path) { return string.IsNullOrEmpty(path) ? root : root.Find(path); } // ───────────────────────────────────────── 고스트 · 재질 static Pose s_landPose; // #954 착지 자세(규칙 landPose) — 반투명 캐릭터 · 튀어나오는 잔상용(순환 목록 밖) static bool Spawn(WLGhostStrikeJson c, Cast cast, Vector3 pos, Vector3 face, Pose poseOverride = null, float[] color = null, float hold = -1f, float fade = -1f) { if ((s_poses.Count == 0 && poseOverride == null) || !EnsureMaterial(c)) return false; var g = Rent(c); if (g == null) { SkippedCap++; return false; } var pose = poseOverride ?? s_poses[(cast.poseCursor++ & 0x7fffffff) % s_poses.Count]; g.color = color; g.hold = hold; g.fade = fade; g.pre = s_matsPre != null; g.bodyF.sharedMesh = pose.body; g.bodyR.sharedMaterials = g.pre ? s_matsPre : s_matsOne; while (g.exF.Count < pose.extraMesh.Count) { MeshFilter f; MeshRenderer mr; Part(g.root.transform, out f, out mr); g.exF.Add(f); g.exR.Add(mr); g.exSingle.Add(true); } for (int i = 0; i < g.exF.Count; i++) { bool on = i < pose.extraMesh.Count; g.exF[i].gameObject.SetActive(on); if (!on) continue; var mesh = pose.extraMesh[i]; var m = pose.extraM[i]; g.exF[i].sharedMesh = mesh; g.exSingle[i] = mesh.subMeshCount <= 1; if (g.exSingle[i]) g.exR[i].sharedMaterials = g.pre ? s_matsPre : s_matsOne; // 부분 메시 1 개 = 남는 재질이 같은 메시를 한 번 더 그린다(앞 패스 → 색) else { var mats = new Material[mesh.subMeshCount]; for (int k = 0; k < mats.Length; k++) mats[k] = s_mat; g.exR[i].sharedMaterials = mats; } var tr = g.exF[i].transform; tr.localPosition = m.GetColumn(3); tr.localRotation = m.rotation; tr.localScale = m.lossyScale; } g.root.transform.SetPositionAndRotation(pos, Quaternion.LookRotation(face, Vector3.up)); g.t0 = Time.time; g.busy = true; SetAlpha(g, 1f); g.root.SetActive(true); return true; } static Ghost Rent(WLGhostStrikeJson c) { for (int i = s_pool.Count - 1; i >= 0; i--) if (s_pool[i].root == null) s_pool.RemoveAt(i); // 씬 · Play 경계에서 파괴된 것 for (int i = 0; i < s_pool.Count; i++) if (!s_pool[i].busy) return s_pool[i]; if (s_pool.Count >= Mathf.Max(1, c.maxAlive)) return null; var g = new Ghost(); g.root = new GameObject("__WLGhostStrike" + s_pool.Count); Object.DontDestroyOnLoad(g.root); g.root.SetActive(false); Part(g.root.transform, out g.bodyF, out g.bodyR); s_pool.Add(g); return g; } static void Part(Transform parent, out MeshFilter f, out MeshRenderer r) { var go = new GameObject("part"); go.transform.SetParent(parent, false); f = go.AddComponent(); r = go.AddComponent(); r.sharedMaterials = s_matsPre ?? s_matsOne ?? new[] { s_mat }; r.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; r.receiveShadows = false; r.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; r.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off; } static void SetAlpha(Ghost g, float k) { var a = g.color != null && g.color.Length >= 4 ? g.color : Cfg.color; // #954 잔상별 색 덮기 var col = a != null && a.Length >= 4 ? new Color(a[0], a[1], a[2], a[3]) : Color.black; col.a *= Mathf.Clamp01(k); g.mpb.Clear(); for (int i = 0; i < s_colorIds.Count; i++) g.mpb.SetColor(s_colorIds[i], col); bool pre = s_matsPre != null && col.a >= Cfg.prepassMinAlpha; // 거의 투명해지면 앞 패스를 끈다(뒤 이펙트를 가리지 않게) if (pre != g.pre) { g.pre = pre; var arr = pre ? s_matsPre : s_matsOne; g.bodyR.sharedMaterials = arr; for (int i = 0; i < g.exR.Count; i++) if (g.exSingle[i] && g.exR[i].gameObject.activeSelf) g.exR[i].sharedMaterials = arr; } g.bodyR.SetPropertyBlock(g.mpb); for (int i = 0; i < g.exR.Count; i++) if (g.exR[i].gameObject.activeSelf) g.exR[i].SetPropertyBlock(g.mpb); } /// 런타임 재질 1 장(에셋 0) — URP Unlit 반투명 배관 값(DashAfterImage · WLPartSkillClone 과 같은 방식). static bool EnsureMaterial(WLGhostStrikeJson c) { if (s_mat != null) return true; if (s_matTried) return false; s_matTried = true; Shader sh = null; if (c.shaderNames != null) foreach (var n in c.shaderNames) { if (string.IsNullOrEmpty(n)) continue; sh = Shader.Find(n); if (sh != null) break; } if (sh == null) { LastLog = "셰이더 없음"; return false; } bool pre = c.prepass != 0; var m = NewMat(sh, "__WLGhostStrikeMat", UnityEngine.Rendering.BlendMode.SrcAlpha, UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha, !pre && c.zWrite != 0, c.renderQueue > 0 ? c.renderQueue : (int)UnityEngine.Rendering.RenderQueue.Transparent); s_colorIds.Clear(); if (c.colorProperties != null) foreach (var p in c.colorProperties) if (!string.IsNullOrEmpty(p) && m.HasProperty(p)) s_colorIds.Add(Shader.PropertyToID(p)); s_mat = m; s_matsOne = new[] { m }; if (pre) { // 깊이만 쓰는 앞 패스 = 색 그대로(Blend Zero One) · ZWrite On · 색 패스보다 앞 큐 → 색 패스(ZTest LEqual)는 가장 앞 면만 한 번 칠한다 s_depthMat = NewMat(sh, "__WLGhostStrikeDepth", UnityEngine.Rendering.BlendMode.Zero, UnityEngine.Rendering.BlendMode.One, true, c.prepassQueue); s_matsPre = new[] { s_depthMat, m }; } for (int i = 0; i < s_pool.Count; i++) if (s_pool[i].bodyR != null) s_pool[i].bodyR.sharedMaterials = s_matsPre ?? s_matsOne; return true; } static Material NewMat(Shader sh, string name, UnityEngine.Rendering.BlendMode src, UnityEngine.Rendering.BlendMode dst, bool zWrite, int queue) { var m = new Material(sh) { name = name }; if (m.HasProperty("_Surface")) m.SetFloat("_Surface", 1f); if (m.HasProperty("_Blend")) m.SetFloat("_Blend", 0f); if (m.HasProperty("_SrcBlend")) m.SetFloat("_SrcBlend", (float)src); if (m.HasProperty("_DstBlend")) m.SetFloat("_DstBlend", (float)dst); if (m.HasProperty("_SrcBlendAlpha")) m.SetFloat("_SrcBlendAlpha", (float)(src == UnityEngine.Rendering.BlendMode.Zero ? UnityEngine.Rendering.BlendMode.Zero : UnityEngine.Rendering.BlendMode.One)); if (m.HasProperty("_DstBlendAlpha")) m.SetFloat("_DstBlendAlpha", (float)dst); if (m.HasProperty("_ZWrite")) m.SetFloat("_ZWrite", zWrite ? 1f : 0f); if (m.HasProperty("_AlphaClip")) m.SetFloat("_AlphaClip", 0f); m.EnableKeyword("_SURFACE_TYPE_TRANSPARENT"); m.DisableKeyword("_ALPHATEST_ON"); m.SetOverrideTag("RenderType", "Transparent"); m.renderQueue = queue; return m; } } /// #943-⑦ 고스트 러너(숨김 GameObject 1 개) — 예약 · 요청 생성 · 페이드 · 꼭두각시 굽기는 여기(Update)에서만. public sealed class WLGhostStrikeRunner : MonoBehaviour { void Update() { WLGhostStrike.Tick(); } void LateUpdate() { WLGhostStrike.LateTick(); } } /// #961 피격 대상 이펙트가 밀려나는 적을 위치만 따라간다(적이 꺼지면 그 자리에 남음). public sealed class WLGhostHitFxFollow : MonoBehaviour { public Transform target; public Vector3 offset; void LateUpdate() { if (target != null && target.gameObject.activeInHierarchy) transform.position = target.position + offset; } } }