[WL] #943 8건 — 화살 굵기 절반(thickness) · 대검 도끼 층 끔 + 모으기·검 흰 발광 · 창 2타 고정 방향 + PC 회전 잠금(fixedDir) · 창 사거리 창끝 실측(1.29/1.29/1.55) + 궤적 창끝 반경 · 단검 궁극 실루엣(WLGhostStrike) · 단검 주력 캐릭터 보임 + 이펙트 0.5(WLShadowSlash)
- WLImpactFx: swingFx fixedDir/lockFacing/alignForward/alignSide · WLWeaponFollowFx 고정 방향 모드 - WLProjectileVisual: thickness(입자 크기·3D X/Y·늘림 보정·궤적 폭) · 10401/10402 = 0.5 - WLGreatWave.json glowColor [2,2,2] glowMax 1.0 glowFill 0.35 chargeFx WL_Fx_GreatChargeWhite · WL_Hit_GreatSkill 도끼 렌더러 끔 - WLHitboxFit · WLWeaponClasses · WLWeaponAttackTable · SlashTrailSettings 10701 창 길이 1.846 m 실측 반영 - WLGhostStrike.cs/.json · WLShadowSlash.cs/.json + Skill_DarkAmbush · Skill_ShadowSlash 훅 1줄 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
9202f9c8fe
commit
294620a433
|
|
@ -11,6 +11,7 @@ public class Skill_DarkAmbush : ProjectileBase
|
|||
base.Set(_data);
|
||||
transform.eulerAngles = Vector3.zero;
|
||||
|
||||
WL.Combat.Weapon.WLGhostStrike.OnSet(this); // WL #943-⑦ 훅(1줄) — 폭발(Exp_*) 자리마다 PC 공격 자세 실루엣(WLGhostStrike.json) · 원본 진행 · 피해 그대로
|
||||
StartCoroutine(Co_Update());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ public class Skill_ShadowSlash : ProjectileBase
|
|||
|
||||
Show_InvisibleEffect();
|
||||
});
|
||||
WL.Combat.Weapon.WLShadowSlash.OnSet(this); // WL #943-⑧ 훅(1줄) — 숨김 직후 렌더러 · 무기 다시 켬 + 베기 · 연기 크기 배율(WLShadowSlash.json) · 무적 · 2.1 초 뒤 순간이동은 원본 그대로
|
||||
|
||||
StartCoroutine(Co_Update());
|
||||
StartCoroutine(Co_Off(4f));
|
||||
|
|
|
|||
|
|
@ -10238,7 +10238,7 @@ ParticleSystemRenderer:
|
|||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 7845746115968255171}
|
||||
m_Enabled: 1
|
||||
m_Enabled: 0
|
||||
m_CastShadows: 0
|
||||
m_ReceiveShadows: 0
|
||||
m_DynamicOccludee: 1
|
||||
|
|
|
|||
|
|
@ -263,7 +263,7 @@
|
|||
90,
|
||||
0
|
||||
],
|
||||
"yOffset": -0.03,
|
||||
"yOffset": -0.15,
|
||||
"calibrated": false,
|
||||
"follow": true,
|
||||
"forward": 0.885,
|
||||
|
|
@ -271,13 +271,18 @@
|
|||
"life": 1.0,
|
||||
"minInterval": 0.8,
|
||||
"_note_941": "#941 PD 「창 공격 시 이펙트와 캐릭터 방향이 일치하게」 — alignWeapon: 매 프레임 창 소켓(손잡이 → 창끝 · 수평) 방향으로 돌리고 손잡이에서 길이 × alignFraction 자리에 둔다(forward/side 는 이때 안 씀 · massLocal 로 섬광 묶음 중심 보정)",
|
||||
"alignWeapon": true,
|
||||
"alignWeapon": false,
|
||||
"alignFraction": 0.55,
|
||||
"massLocal": [
|
||||
1.06,
|
||||
1.62,
|
||||
-0.33
|
||||
]
|
||||
],
|
||||
"_note_943": "#943-⑤ PD 「타격하려는 방향을 고정한 채 … 이펙트가 캐릭터 방향에 따라 마구 바뀌면 안 됨」 — alignWeapon(창 방향 추종) 끄고 fixedDir: 띄우는 순간의 표적 방향으로 이펙트 방향 고정 + 공격 상태 동안 PC 회전 잠금(lockFacing · 타격 상자 Projectile_10101 도 같은 방향) · 자리 = PC 중심 + 정면 0.6 + 오른쪽 0.3 + 위 −0.15 − R × massLocal × 배율",
|
||||
"fixedDir": true,
|
||||
"lockFacing": true,
|
||||
"alignForward": 0.6,
|
||||
"alignSide": 0.3
|
||||
}
|
||||
],
|
||||
"groundFx": [
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ namespace WL.Combat.Impact
|
|||
// #940 PD 「창 2타 연타 때 무기 부분을 FX_Machine_Gun 으로 가려」 — calibrated 0 전용: follow = PC 에 붙여 따라감 · forward = PC 정면 거리(m) · life = 켜 둘 초(0 = 프리팹 길이) · minInterval = 같은 PC·단계의 ShowEffect 가 이 초 안에 또 오면 새로 띄우지 않고 궤적만 막는다(6연타 이벤트 → 이펙트 1개)
|
||||
public bool follow; public float forward; public float side; public float life; public float minInterval; // side = PC 오른쪽 거리(m)
|
||||
// #941 PD 「창 공격 시 이펙트와 캐릭터 방향이 일치하게」 — alignWeapon = 매 프레임 무기 소켓(손잡이 → 무기 끝) 방향에 맞춰 돌리고 손잡이에서 alignFraction 만큼 간 자리에 둔다(forward/side 는 안 씀) · massLocal = 프리팹 로컬 단위의 보이는 묶음 중심(원점 보정 · Play 실측)
|
||||
public bool alignWeapon; public float alignFraction = 0.5f; public float[] massLocal; }
|
||||
public bool alignWeapon; public float alignFraction = 0.5f; public float[] massLocal;
|
||||
// #943-⑤ PD 「타격하려는 방향을 고정한 채 이펙트가 재생되는 동안 타격 판정 … 이펙트가 캐릭터 방향에 따라 마구 바뀌면 안 됨」 — fixedDir = 띄우는 순간 PC 정면(LookAtTarget 뒤 = 표적 방향)을 잡아 두고 life 동안 이펙트 방향 고정 + PC 회전도 그 방향으로 잠금(공격 상태인 동안) · 자리 = PC 중심 + 정면 alignForward + 오른쪽 alignSide + 위 yOffset − R × massLocal × 배율(PC 위치는 따라감)
|
||||
public bool fixedDir; public float alignForward = 0.5f; public float alignSide; public bool lockFacing = true; }
|
||||
/// <summary>지면 충격파. tipContact = 칼끝이 지면(contactHeight 이내)에 닿는 프레임·자리에서 재생(단계 안에서 minDrop 이상 올라갔다 내려올 때) · 못 잡으면 timeout 뒤 최저점(fallbackHeight 이내)에.</summary>
|
||||
/// <summary>#900 frontOnly = PC 정면 쪽 접점만 인정(해머 1타 내려치기 — 뒤로 휘두를 때 등 뒤 지면 접점을 건너뛴다 · 2026-10-17 실측 −0.8 m).</summary>
|
||||
public sealed class WLGroundFxRule { public int[] classIds; public int stage; public string prefab; public float scale = 1f; public float forward; public bool tipContact = true; public float contactHeight = 0.15f; public float minDrop = 0.35f; public float timeout = 1.5f; public float fallbackHeight = 0.5f; public bool frontOnly; }
|
||||
|
|
@ -326,8 +328,8 @@ namespace WL.Combat.Impact
|
|||
var inst = Play(r.prefab, pos, rot, scale); if (inst == null) return false;
|
||||
if (r.follow) inst.transform.SetParent(tf, true); // #940 PC 를 따라간다(풀 반환 때 WLImpactOneShot 이 원래 부모로 되돌린다)
|
||||
if (r.life > 0f) { var os = inst.GetComponent<WLImpactOneShot>(); if (os != null) os.OverrideLife(r.life); }
|
||||
var fw = inst.GetComponent<WLWeaponFollowFx>(); // #941 무기 방향 추종
|
||||
if (r.alignWeapon && !cal_used) { if (fw == null) fw = inst.AddComponent<WLWeaponFollowFx>(); if (fw.Arm(pc, r, scale)) how += " alignWeapon"; else how += " alignWeapon(no-socket)"; }
|
||||
var fw = inst.GetComponent<WLWeaponFollowFx>(); // #941 무기 방향 추종 · #943-⑤ 고정 방향
|
||||
if ((r.alignWeapon || r.fixedDir) && !cal_used) { if (fw == null) fw = inst.AddComponent<WLWeaponFollowFx>(); if (fw.Arm(pc, r, scale)) how += r.fixedDir ? " fixedDir" : " alignWeapon"; else how += " alignWeapon(no-socket)"; }
|
||||
else if (fw != null) fw.Disarm();
|
||||
SwingShown++; LastSwing = "swing " + r.prefab + " stage " + stage + " " + how + " scale=" + scale.ToString("F3"); LastLog = LastSwing;
|
||||
return true;
|
||||
|
|
@ -647,20 +649,38 @@ namespace WL.Combat.Impact
|
|||
/// <summary>#941 궤적 교체 이펙트를 매 프레임 무기 소켓 방향(손잡이 → 무기 끝 · 수평)에 맞춘다 — 창 2타 기계총 가림처럼 PC 정면이 아니라 무기가 향한 쪽으로 쏘게. 자리 = 손잡이 + 방향 × 길이 × alignFraction + 위 yOffset − R × massLocal × 배율.</summary>
|
||||
public sealed class WLWeaponFollowFx : MonoBehaviour
|
||||
{
|
||||
Transform _socket; Vector3 _tipLocal, _mass; Quaternion _base; float _scale, _frac, _yOff; PCActor _pc; bool _armed;
|
||||
public static int Frames; public static string LastLog = "";
|
||||
Transform _socket; Vector3 _tipLocal, _mass, _dir; Quaternion _base; float _scale, _frac, _yOff, _fwd, _side, _until; PCActor _pc; bool _armed, _fixed, _lock;
|
||||
public static int Frames, Locked; public static string LastLog = "";
|
||||
public bool Arm(PCActor pc, WLSwingFxRule r, float scale)
|
||||
{
|
||||
_pc = pc; _scale = scale; _frac = r.alignFraction; _yOff = r.yOffset;
|
||||
_pc = pc; _scale = scale; _frac = r.alignFraction; _yOff = r.yOffset; _fwd = r.alignForward; _side = r.alignSide; _fixed = r.fixedDir; _lock = r.lockFacing;
|
||||
_until = Time.time + (r.life > 0f ? r.life : 1f);
|
||||
_base = r.euler != null && r.euler.Length >= 3 ? Quaternion.Euler(r.euler[0], r.euler[1], r.euler[2]) : Quaternion.identity;
|
||||
_mass = r.massLocal != null && r.massLocal.Length >= 3 ? new Vector3(r.massLocal[0], r.massLocal[1], r.massLocal[2]) : Vector3.zero;
|
||||
if (_fixed)
|
||||
{
|
||||
_dir = pc.transform.forward; _dir.y = 0f; if (_dir.sqrMagnitude < 1e-4f) _dir = Vector3.forward; _dir.Normalize(); // #943-⑤ 띄우는 순간의 표적 방향으로 고정
|
||||
_armed = true; ApplyFixed(); return true;
|
||||
}
|
||||
_armed = WLImpactRunner.SocketTip(pc, out _socket, out _tipLocal);
|
||||
if (_armed) Apply();
|
||||
return _armed;
|
||||
}
|
||||
public void Disarm() { _armed = false; }
|
||||
void OnDisable() { _armed = false; }
|
||||
void LateUpdate() { if (_armed) Apply(); }
|
||||
void LateUpdate() { if (!_armed) return; if (_fixed) ApplyFixed(); else Apply(); }
|
||||
/// <summary>#943-⑤ 고정 방향 — 이펙트는 PC 위치만 따라가고 방향은 띄운 순간 그대로 · 공격 상태인 동안 PC 회전도 그 방향으로 잠근다(원본 LookAtTarget · 애니메이션 회전이 바꿔 놓은 것을 LateUpdate 에서 되돌림).</summary>
|
||||
void ApplyFixed()
|
||||
{
|
||||
if (_pc == null) { _armed = false; return; }
|
||||
var tf = _pc.transform;
|
||||
if (_lock && Time.time < _until && _pc.Get_CurAnim() == eAnim.Attack) { tf.rotation = Quaternion.LookRotation(_dir, Vector3.up); Locked++; }
|
||||
var rot = Quaternion.LookRotation(_dir, Vector3.up) * _base;
|
||||
Vector3 right = Vector3.Cross(Vector3.up, _dir);
|
||||
Vector3 pos = _pc.Get_Center_position() + _dir * _fwd + right * _side + Vector3.up * _yOff - rot * (_mass * _scale);
|
||||
transform.SetPositionAndRotation(pos, rot); Frames++;
|
||||
LastLog = "fixed dir=" + _dir.ToString("F2") + " pos=" + pos.ToString("F2") + " locked=" + Locked;
|
||||
}
|
||||
void Apply()
|
||||
{
|
||||
if (_socket == null || _pc == null) { _armed = false; return; }
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 12fa50d861e4bb84a8e04a210ca2fac8
|
||||
PrefabImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"enabled": 1,
|
||||
"maxAlive": 6,
|
||||
"hold": 0.05,
|
||||
"fade": 0.3,
|
||||
"color": [
|
||||
0.07,
|
||||
0.03,
|
||||
0.12,
|
||||
0.85
|
||||
],
|
||||
"includeWeapons": 1,
|
||||
"zWrite": 1,
|
||||
"renderQueue": 3000,
|
||||
"prepass": 1,
|
||||
"prepassMinAlpha": 0.35,
|
||||
"prepassQueue": 2990,
|
||||
"shaderNames": [
|
||||
"Universal Render Pipeline/Unlit",
|
||||
"Unlit/Color",
|
||||
"Sprites/Default"
|
||||
],
|
||||
"colorProperties": [
|
||||
"_BaseColor",
|
||||
"_Color"
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"skill": "Skill_DarkAmbush",
|
||||
"anchor": "fx",
|
||||
"fxPointPrefix": "Exp_",
|
||||
"fxLead": 0.0,
|
||||
"faceRadius": 4.0,
|
||||
"hitDistance": 0.8,
|
||||
"hitFromBody": 0,
|
||||
"hitTurnMin": 70,
|
||||
"hitTurnMax": 150,
|
||||
"hitMinInterval": 0.08,
|
||||
"castSeconds": 2.0,
|
||||
"poseSource": "clips",
|
||||
"poses": [
|
||||
"DualBlades_Attack2@0.35",
|
||||
"DualBlades_JumpAttack3@0.6",
|
||||
"DualBlades_Attack3@0.25",
|
||||
"attack@0.2",
|
||||
"DualBlades_DodgeAttack@0.4",
|
||||
"DualBlades_Attack3@0.47"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: cd05da07d15db6b48a83e2645e74e083
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -30,15 +30,15 @@
|
|||
"groundPad": 0.0,
|
||||
"glowMaterial": "WL/Fx/WL_Mat_WeaponGlow",
|
||||
"glowColor": [
|
||||
2.2,
|
||||
0.8,
|
||||
0.6
|
||||
2.0,
|
||||
2.0,
|
||||
2.0
|
||||
],
|
||||
"glowMax": 0.8,
|
||||
"glowFill": 0.25,
|
||||
"glowMax": 1.0,
|
||||
"glowFill": 0.35,
|
||||
"glowRimPower": 2.5,
|
||||
"glowFadeSeconds": 0.25,
|
||||
"chargeFx": "WL/Fx/WL_Fx_GreatCharge",
|
||||
"chargeFx": "WL/Fx/WL_Fx_GreatChargeWhite",
|
||||
"chargeFxScale": 0.12,
|
||||
"chargeFxAlong": 0.4,
|
||||
"shineFx": "",
|
||||
|
|
|
|||
|
|
@ -87,21 +87,22 @@
|
|||
10701
|
||||
],
|
||||
"stage": 1,
|
||||
"frontReach": 2.66
|
||||
"frontReach": 1.29,
|
||||
"_note_943": "#943-⑥ PD 「창 사거리를 실제 창 길이에 맞게」 — 앞 끝 = 창끝 정면 도달 실측(편집 모드 · 게임과 같은 PC LH_M05 × 1.0527 · 창 WL_W_Spear_T05_003 1.846 m · 60 Hz 포즈 · AgentScripts/WL828_TrailBake.ProbeDump): 1·2타(수평 회전 베기) 최대 1.29 · 3타(내려찍기) 1.55 m. 이전 2.66~2.78 = 사거리 2.5 크기 궤적 기준"
|
||||
},
|
||||
{
|
||||
"classIds": [
|
||||
10701
|
||||
],
|
||||
"stage": 2,
|
||||
"frontReach": 2.75
|
||||
"frontReach": 1.29
|
||||
},
|
||||
{
|
||||
"classIds": [
|
||||
10701
|
||||
],
|
||||
"stage": 3,
|
||||
"frontReach": 2.78
|
||||
"frontReach": 1.55
|
||||
},
|
||||
{
|
||||
"classIds": [
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
"projectile": "Projectile_10402",
|
||||
"visual": "WL/Fx/WL_Fx_ArrowLightShot",
|
||||
"scale": 1.0,
|
||||
"thickness": 0.5,
|
||||
"chargedVisual": "WL/Fx/WL_Fx_ArrowYellow",
|
||||
"chargedScale": 1.0,
|
||||
"chargedReplaces": true,
|
||||
|
|
@ -36,6 +37,8 @@
|
|||
"projectile": "Projectile_10401",
|
||||
"visual": "WL/Fx/WL_Fx_ArrowLightShot",
|
||||
"scale": 1.0,
|
||||
"thickness": 0.5,
|
||||
"_note_943": "#943-② PD 「발사되는 화살이 좀 더 샤프하게 두께를 줄여줘」 — thickness 0.5 = 굵기만 절반(길이 그대로 · 강화 화살 WL_Fx_ArrowYellow 도 같은 값)",
|
||||
"chargedVisual": "WL/Fx/WL_Fx_ArrowYellow",
|
||||
"chargedScale": 1.0,
|
||||
"chargedReplaces": true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"enabled": 1,
|
||||
"keepVisible": 1,
|
||||
"freezeAnim": 0,
|
||||
"slashScale": 0.5,
|
||||
"smokeScale": 0.5,
|
||||
"keepHitBox": 1
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
fileFormatVersion: 2
|
||||
guid: b68692c7a8143624592231e28290b46a
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -27,12 +27,12 @@
|
|||
9003,무희의 쌍검,Dagger,10201,pcanim_bladedancer,3,CRIT_RATE,1.0,2,DualBlades_Attack2,MultiHit,2,0.45,1,120,0,0,0,0,Projectile_10201,Effect_Slash_10201_2,Effect_Hit_10201,703202|703203,M:class_id;anim_controller;anim_state;range_m;hitbox_prefab;slash_vfx;hit_vfx;ref_skill_ids
|
||||
9003,무희의 쌍검,Dagger,10201,pcanim_bladedancer,3,CRIT_RATE,1.0,3,DualBlades_Attack3,Area,4,0.3,1,0,1.5,0,0,0,Projectile_10201,Effect_Slash_10201_3,Effect_Hit_10201,703202|703203,M:class_id;anim_controller;anim_state;range_m;hitbox_prefab;slash_vfx;hit_vfx;ref_skill_ids
|
||||
9005,정령의 셉터,Staff,10303,pcanim_summoner,4,INT,1.0,1,attack,Projectile,1,1.2,5,0,0,12,0,0,Projectile_10303,,Effect_Hit_10303,703307|703309,M:class_id;anim_controller;anim_state;range_m;hitbox_prefab;hit_vfx;ref_skill_ids
|
||||
9006,철창(가칭),Spear,10701,pcanim_spear,2,STR,1.0,1,attack,MeleeArc,1,1.1,2.5,60,0,0,0,0.3,Projectile_10101,,Effect_Hit_10101,,M:class_id;anim_controller;anim_state;hitbox_prefab;hit_vfx
|
||||
9006,철창(가칭),Spear,10701,pcanim_spear,2,STR,1.0,2,Attack2,MeleeArc,1,1.1,2.5,60,0,0,0,0.2,Projectile_10101,,Effect_Hit_10101,,M:class_id;anim_controller;anim_state;hitbox_prefab;hit_vfx
|
||||
9006,철창(가칭),Spear,10701,pcanim_spear,2,STR,1.0,3,Attack3,MeleeArc,1,1.7,3.0,40,0,0,1,0.5,Projectile_10101,,Effect_Hit_10101,,M:class_id;anim_controller;anim_state;hitbox_prefab;hit_vfx
|
||||
9007,은창(가칭),Spear,10701,pcanim_spear,3,STR,1.0,1,attack,MeleeArc,1,1.1,2.5,60,0,0,0,0.3,Projectile_10101,,Effect_Hit_10101,,M:class_id;anim_controller;anim_state;hitbox_prefab;hit_vfx
|
||||
9007,은창(가칭),Spear,10701,pcanim_spear,3,STR,1.0,2,Attack2,MeleeArc,1,1.1,2.5,60,0,0,0,0.2,Projectile_10101,,Effect_Hit_10101,,M:class_id;anim_controller;anim_state;hitbox_prefab;hit_vfx
|
||||
9007,은창(가칭),Spear,10701,pcanim_spear,3,STR,1.0,3,Attack3,MeleeArc,1,1.7,3.0,40,0,0,1,0.5,Projectile_10101,,Effect_Hit_10101,,M:class_id;anim_controller;anim_state;hitbox_prefab;hit_vfx
|
||||
9006,철창(가칭),Spear,10701,pcanim_spear,2,STR,1.0,1,attack,MeleeArc,1,1.1,1.29,60,0,0,0,0.3,Projectile_10101,,Effect_Hit_10101,,M:class_id;anim_controller;anim_state;range_m;hitbox_prefab;hit_vfx
|
||||
9006,철창(가칭),Spear,10701,pcanim_spear,2,STR,1.0,2,Attack2,MeleeArc,1,1.1,1.29,60,0,0,0,0.2,Projectile_10101,,Effect_Hit_10101,,M:class_id;anim_controller;anim_state;range_m;hitbox_prefab;hit_vfx
|
||||
9006,철창(가칭),Spear,10701,pcanim_spear,2,STR,1.0,3,Attack3,MeleeArc,1,1.7,1.55,40,0,0,1,0.5,Projectile_10101,,Effect_Hit_10101,,M:class_id;anim_controller;anim_state;range_m;hitbox_prefab;hit_vfx
|
||||
9007,은창(가칭),Spear,10701,pcanim_spear,3,STR,1.0,1,attack,MeleeArc,1,1.1,1.29,60,0,0,0,0.3,Projectile_10101,,Effect_Hit_10101,,M:class_id;anim_controller;anim_state;range_m;hitbox_prefab;hit_vfx
|
||||
9007,은창(가칭),Spear,10701,pcanim_spear,3,STR,1.0,2,Attack2,MeleeArc,1,1.1,1.29,60,0,0,0,0.2,Projectile_10101,,Effect_Hit_10101,,M:class_id;anim_controller;anim_state;range_m;hitbox_prefab;hit_vfx
|
||||
9007,은창(가칭),Spear,10701,pcanim_spear,3,STR,1.0,3,Attack3,MeleeArc,1,1.7,1.55,40,0,0,1,0.5,Projectile_10101,,Effect_Hit_10101,,M:class_id;anim_controller;anim_state;range_m;hitbox_prefab;hit_vfx
|
||||
9008,대검(가칭),Greatsword,10801,pcanim_greatsword,2,STR,1.0,1,attack,MeleeArc,1,1.2,1.8,150,0,0,0,0.3,Projectile_10101,,Effect_Hit_10101,703102|703603,M:class_id;anim_controller;anim_state;hitbox_prefab;hit_vfx
|
||||
9008,대검(가칭),Greatsword,10801,pcanim_greatsword,2,STR,1.0,2,Attack2,MeleeArc,1,1.2,1.8,150,0,0,0,0.15,Projectile_10101,,Effect_Hit_10101,703102|703603,M:class_id;anim_controller;anim_state;hitbox_prefab;hit_vfx
|
||||
9008,대검(가칭),Greatsword,10801,pcanim_greatsword,2,STR,1.0,3,Attack3,MeleeArc,1,1.9,2.0,180,0,0,0,0.31,Projectile_10101,,Effect_Hit_10101,703102|703603,M:class_id;anim_controller;anim_state;hitbox_prefab;hit_vfx
|
||||
|
|
|
|||
|
|
|
@ -53,7 +53,8 @@
|
|||
"weaponRHIndex": 1,
|
||||
"weaponLHPrefab": "",
|
||||
"weaponLHIndex": -1,
|
||||
"attackRange": 2.5,
|
||||
"attackRange": 1.09,
|
||||
"_note_943": "#943-⑥ PD 「창 사거리를 실제 창 길이에 맞게」 — 공격 시작 거리 = 1타 창끝 정면 도달 1.29 m(편집 모드 포즈 실측 = WLHitboxFit.json 1타 앞 끝) − 즉시 공격 여유 0.2(WLTargetingSettings) → 흰 원 1.29 = 1타 판정 앞 끝(검 10101 의 1.18 + 0.2 = 1타 1.38 과 같은 규칙) · 이전 2.5",
|
||||
"attackSpd": -1,
|
||||
"attackClipTokens": [],
|
||||
"_note_839": "#839 창 모션 = Spearman 팩(서브클립 Attack1_S_10701~3 · 이벤트는 매핑 도구가 굽는다) → 런타임 토큰 비움. 아래 projectileTimes/effectTimes 는 Shinabro 시절 값(미사용 · 기록용).",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,581 @@
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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)
|
||||
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 예약도 이 안에서만
|
||||
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<FxPoint> fx; public int next, poseCursor;
|
||||
public Vector3 lastPos; public bool hasLast, posesReady; public string sig = "";
|
||||
public readonly List<SkinnedMeshRenderer> smrs = new List<SkinnedMeshRenderer>();
|
||||
public readonly List<MeshFilter> weapons = new List<MeshFilter>();
|
||||
public readonly List<string> smrPaths = new List<string>();
|
||||
public readonly List<string> weaponPaths = new List<string>();
|
||||
}
|
||||
|
||||
sealed class Pose { public Mesh body; public readonly List<Mesh> extraMesh = new List<Mesh>(); public readonly List<Matrix4x4> extraM = new List<Matrix4x4>(); }
|
||||
|
||||
sealed class Ghost
|
||||
{
|
||||
public GameObject root; public MeshFilter bodyF; public MeshRenderer bodyR;
|
||||
public readonly List<MeshFilter> exF = new List<MeshFilter>(); public readonly List<MeshRenderer> exR = new List<MeshRenderer>();
|
||||
public readonly List<bool> exSingle = new List<bool>();
|
||||
public readonly MaterialPropertyBlock mpb = new MaterialPropertyBlock(); public float t0; public bool busy, pre;
|
||||
}
|
||||
|
||||
static WLGhostStrikeJson s_cfg;
|
||||
static readonly Dictionary<string, WLGhostStrikeRule> s_rules = new Dictionary<string, WLGhostStrikeRule>();
|
||||
static readonly Dictionary<int, List<FxPoint>> s_fxCache = new Dictionary<int, List<FxPoint>>();
|
||||
static Cast s_cast;
|
||||
static readonly List<HitReq> s_hitQ = new List<HitReq>(16);
|
||||
static readonly Dictionary<Actor, float> s_bearing = new Dictionary<Actor, float>(), s_lastHitAt = new Dictionary<Actor, float>();
|
||||
static readonly List<Pose> s_poses = new List<Pose>();
|
||||
static string s_poseSig = "";
|
||||
static readonly List<Ghost> s_pool = new List<Ghost>();
|
||||
static Material s_mat, s_depthMat; static bool s_matTried;
|
||||
static Material[] s_matsPre, s_matsOne;
|
||||
static readonly List<int> s_colorIds = new List<int>();
|
||||
static WLGhostStrikeRunner s_runner;
|
||||
static readonly List<CombineInstance> s_combine = new List<CombineInstance>(32);
|
||||
static readonly List<Mesh> s_tmp = new List<Mesh>(8);
|
||||
|
||||
static WLGhostStrikeJson Cfg
|
||||
{
|
||||
get
|
||||
{
|
||||
if (s_cfg != null) return s_cfg;
|
||||
s_cfg = new WLGhostStrikeJson();
|
||||
s_rules.Clear();
|
||||
var ta = Resources.Load<TextAsset>(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_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 = "";
|
||||
CombatEvents.HitConfirmed.Remove(OnHit);
|
||||
if (Cfg.enabled == 0) return;
|
||||
foreach (var r in s_rules.Values) if (r.anchor != "fx") { CombatEvents.HitConfirmed.Add(OnHit); break; }
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────── 원본 훅
|
||||
/// <summary>원본 스킬 Set 훅(1줄) — 표에 있는 스킬이면 시전을 기록하고 부품 경로를 잡는다(이 순간 PC 는 아직 보인다 · 원본 숨김은 훅 뒤 Co_Update).</summary>
|
||||
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) };
|
||||
if (r.anchor != "hit") cast.fx = FxPoints(skill, r);
|
||||
Capture(cast, c);
|
||||
s_cast = cast;
|
||||
s_hitQ.Clear(); s_bearing.Clear(); s_lastHitAt.Clear();
|
||||
Casts++;
|
||||
if (s_runner == null)
|
||||
{
|
||||
var go = new GameObject("[WL943] GhostStrike"); go.hideFlags = HideFlags.HideInHierarchy; Object.DontDestroyOnLoad(go);
|
||||
s_runner = go.AddComponent<WLGhostStrikeRunner>();
|
||||
}
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>타격 확정(물리 콜백 안) — 이번 시전 스킬이 시전자로 맞힌 것만 요청으로 쌓는다(생성은 러너 Update).</summary>
|
||||
static void OnHit(in HitConfirmedEvent e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cast = s_cast;
|
||||
if (cast == null || cast.rule.anchor == "fx" || DSUtil.CheckNull(e.victim) || !ReferenceEquals(e.attacker, cast.shooter)) 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 (s_hitQ.Count < Mathf.Max(1, Cfg.maxAlive) * 4) s_hitQ.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); }
|
||||
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]);
|
||||
if (now - cast.t0 > Mathf.Max(0.1f, cast.rule.castSeconds)) s_cast = null;
|
||||
}
|
||||
s_hitQ.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;
|
||||
if (t >= c.hold + c.fade) { g.busy = false; g.root.SetActive(false); continue; }
|
||||
SetAlpha(g, t <= c.hold ? 1f : 1f - (t - c.hold) / Mathf.Max(0.01f, c.fade));
|
||||
}
|
||||
}
|
||||
catch (System.Exception ex) { Errors++; LastLog = "틱 실패 — " + ex.Message; }
|
||||
}
|
||||
|
||||
static List<FxPoint> FxPoints(ProjectileBase skill, WLGhostStrikeRule r)
|
||||
{
|
||||
List<FxPoint> list;
|
||||
int id = skill.GetInstanceID();
|
||||
if (s_fxCache.TryGetValue(id, out list) && (list.Count == 0 || list[0].t != null)) return list;
|
||||
list = new List<FxPoint>();
|
||||
if (!string.IsNullOrEmpty(r.fxPointPrefix))
|
||||
foreach (var t in skill.GetComponentsInChildren<Transform>(true))
|
||||
{
|
||||
if (!t.name.StartsWith(r.fxPointPrefix, System.StringComparison.Ordinal)) continue;
|
||||
var ps = t.GetComponent<ParticleSystem>();
|
||||
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++;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────── 부품 · 자세
|
||||
/// <summary>훅 순간(PC 가 보일 때) 켜져 있는 부품의 경로 · 서명(PC · 컨트롤러 · 메시 · poses).</summary>
|
||||
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<SkinnedMeshRenderer>(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<MeshFilter>(true))
|
||||
{
|
||||
var mr = mf.GetComponent<MeshRenderer>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>숨은 꼭두각시(PC 복제)에 poses 를 걸어 굽는다 — 러너 Update 에서만(DestroyImmediate). 스크립트 · 충돌체가 남으면 켜지 않고 버린다.</summary>
|
||||
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<MonoBehaviour>(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<UnityEngine.AI.NavMeshAgent>(go, ref err);
|
||||
Strip<Joint>(go, ref err); // 강체에 붙은 관절이 남으면 강체를 못 뗀다
|
||||
Strip<Rigidbody>(go, ref err);
|
||||
Strip<Collider>(go, ref err);
|
||||
Strip<AudioSource>(go, ref err);
|
||||
Strip<AudioListener>(go, ref err); // 켜는 순간 두 번째 리스너 경고 방지
|
||||
int leftMb = go.GetComponentsInChildren<MonoBehaviour>(true).Length, leftCol = go.GetComponentsInChildren<Collider>(true).Length;
|
||||
if (leftMb > 0 || leftCol > 0) { PuppetFailed++; LastLog = "꼭두각시 실패 — 남은 스크립트 " + leftMb + " · 충돌체 " + leftCol + (err != null ? " · " + err : ""); return false; }
|
||||
var an = go.GetComponent<Animator>();
|
||||
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<SkinnedMeshRenderer>(); var weapons = new List<MeshFilter>();
|
||||
foreach (var p in cast.smrPaths) { var x = Find(gt, p); var s = x != null ? x.GetComponent<SkinnedMeshRenderer>() : null; if (s != null) smrs.Add(s); }
|
||||
foreach (var p in cast.weaponPaths) { var x = Find(gt, p); var m = x != null ? x.GetComponent<MeshFilter>() : 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++; }
|
||||
}
|
||||
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); } }
|
||||
}
|
||||
|
||||
/// <summary>부품을 root 회전 기준 · 월드 단위로 합친 메시 1 개(+ 읽을 수 없는 무기 메시는 따로 그릴 목록).</summary>
|
||||
static Pose BakePose(Transform root, List<SkinnedMeshRenderer> smrs, List<MeshFilter> 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<T>(GameObject go, ref string err) where T : Component
|
||||
{
|
||||
foreach (var x in go.GetComponentsInChildren<T>(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<string>();
|
||||
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 bool Spawn(WLGhostStrikeJson c, Cast cast, Vector3 pos, Vector3 face)
|
||||
{
|
||||
if (s_poses.Count == 0 || !EnsureMaterial(c)) return false;
|
||||
var g = Rent(c);
|
||||
if (g == null) { SkippedCap++; return false; }
|
||||
var pose = s_poses[(cast.poseCursor++ & 0x7fffffff) % s_poses.Count];
|
||||
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<MeshFilter>();
|
||||
r = go.AddComponent<MeshRenderer>();
|
||||
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 = Cfg.color;
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>런타임 재질 1 장(에셋 0) — URP Unlit 반투명 배관 값(DashAfterImage · WLPartSkillClone 과 같은 방식).</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>#943-⑦ 고스트 러너(숨김 GameObject 1 개) — 예약 · 요청 생성 · 페이드 · 꼭두각시 굽기는 여기(Update)에서만.</summary>
|
||||
public sealed class WLGhostStrikeRunner : MonoBehaviour { void Update() { WLGhostStrike.Tick(); } }
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: bbfb898eeb1e35243aa271fbbb9b8c71
|
||||
|
|
@ -24,6 +24,7 @@ namespace WL.Combat.Weapon
|
|||
public string projectile = "";
|
||||
public string visual = "";
|
||||
public float scale = 1f;
|
||||
public float thickness = 1f; // #943-② PD 「발사되는 화살이 좀 더 샤프하게 두께를 줄여줘」 — 시각(일반·강화)의 굵기만 배율(입자 크기 · 3D 크기 X/Y · 늘림 길이 보정 · 궤적 폭) · 길이는 그대로
|
||||
// #839 활 3타 강화 화살 — WLBowChargeReceiver.ArmedUntil 창 안에 켜지는 인스턴스에 WLChargedMark 가 적용(크기 × chargedScale + 글로우 자식)
|
||||
public string chargedVisual = "";
|
||||
public float chargedScale = 1.6f;
|
||||
|
|
@ -120,6 +121,7 @@ namespace WL.Combat.Weapon
|
|||
{
|
||||
var go = InstantiateVisual(rule.visual, pb.transform, rule.scale, "~WL_Visual");
|
||||
if (go == null) { LastLog = "시각 프리팹 없음 " + rule.visual; return false; }
|
||||
ApplyThickness(go, rule.thickness); // #943-②
|
||||
var arr = go.GetComponentsInChildren<ParticleSystem>(true);
|
||||
for (int i = 0; i < arr.Length; i++)
|
||||
{
|
||||
|
|
@ -134,7 +136,7 @@ namespace WL.Combat.Weapon
|
|||
{
|
||||
var cv = InstantiateVisual(rule.chargedVisual, pb.transform, 1f, "~WL_Charged");
|
||||
if (cv == null) LastLog = "강화 프리팹 없음 " + rule.chargedVisual;
|
||||
else cv.SetActive(false);
|
||||
else { ApplyThickness(cv, rule.thickness); cv.SetActive(false); } // #943-② 강화 화살도 같은 굵기
|
||||
var mark = pb.GetComponent<WLChargedMark>();
|
||||
if (mark == null) mark = pb.gameObject.AddComponent<WLChargedMark>();
|
||||
mark.scale = rule.chargedScale; mark.visual = cv; mark.normal = rule.chargedReplaces ? normal : null;
|
||||
|
|
@ -145,6 +147,34 @@ namespace WL.Combat.Weapon
|
|||
return true;
|
||||
}
|
||||
|
||||
public static int Thinned;
|
||||
/// <summary>#943-② 굵기만 t 배 — 입자 시작 크기(3D 면 X·Y 만) · 늘림 빌보드는 길이 보정(lengthScale ÷ t) · 입자 궤적 폭 · TrailRenderer/LineRenderer 폭. 길이(진행 방향)는 그대로.</summary>
|
||||
static void ApplyThickness(GameObject go, float t)
|
||||
{
|
||||
if (go == null || t <= 0f || Mathf.Approximately(t, 1f)) return;
|
||||
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true))
|
||||
{
|
||||
var m = ps.main;
|
||||
if (m.startSize3D) { m.startSizeX = Mul(m.startSizeX, t); m.startSizeY = Mul(m.startSizeY, t); }
|
||||
else m.startSize = Mul(m.startSize, t);
|
||||
var r = ps.GetComponent<ParticleSystemRenderer>();
|
||||
if (r != null && r.renderMode == ParticleSystemRenderMode.Stretch && !m.startSize3D) r.lengthScale = r.lengthScale / t; // 길이 = 크기 × lengthScale → 길이 유지
|
||||
var tr = ps.trails; if (tr.enabled) tr.widthOverTrailMultiplier = tr.widthOverTrailMultiplier * t;
|
||||
}
|
||||
foreach (var tr in go.GetComponentsInChildren<TrailRenderer>(true)) tr.widthMultiplier *= t;
|
||||
foreach (var lr in go.GetComponentsInChildren<LineRenderer>(true)) lr.widthMultiplier *= t;
|
||||
Thinned++;
|
||||
}
|
||||
static ParticleSystem.MinMaxCurve Mul(ParticleSystem.MinMaxCurve c, float t)
|
||||
{
|
||||
switch (c.mode)
|
||||
{
|
||||
case ParticleSystemCurveMode.Constant: return new ParticleSystem.MinMaxCurve(c.constant * t);
|
||||
case ParticleSystemCurveMode.TwoConstants: return new ParticleSystem.MinMaxCurve(c.constantMin * t, c.constantMax * t);
|
||||
default: c.curveMultiplier *= t; return c;
|
||||
}
|
||||
}
|
||||
|
||||
internal static GameObject InstantiateVisual(string path, Transform parent, float scale, string name)
|
||||
{
|
||||
GameObject prefab;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// WLShadowSlash.cs — 단검(비수 1043 · Dagger 10202) 주력 「그림자 베기」(Skill_ShadowSlash) — 시전 중 캐릭터 보이기 · 이펙트 크기 배율
|
||||
//
|
||||
// PD 원문(#943-⑧ · 2026-10-19): 「단검 주력 스킬 시전 시 캐릭터의 모습이 보이도록 수정하고, 이펙트의 크기를 절반으로 줄여줘.」
|
||||
//
|
||||
// ■ 안 보이던 원인(코드 실측) — 원본 Skill_ShadowSlash.Set 이 shooter.Set_Invisible(2.1 초)를 부른다 → Actor.Co_Invisible 이
|
||||
// 애니메이터 배속 0 · 무적 · Model_OnOff(false)(파티클 외 렌더러 전부 끔) · Weapon_OnOff(false) · 이동 금지 → 2.1 초 뒤 되돌린 다음 대상 뒤 1.5 m 로 순간이동.
|
||||
// 이펙트가 덮거나 카메라 문제가 아니라 원본 스킬이 모델을 끈다.
|
||||
// ■ 처리 — 원본 Set 의 Set_Invisible 뒤 훅 1줄(OnSet): Co_Invisible 이 끈 렌더러 · 무기를 바로 다시 켠다(원본이 2.1 초 뒤 부르는 같은 함수).
|
||||
// freezeAnim 0 이면 애니메이터 배속도 1 로 되돌려 원본 상태 사슬 DualBlades_Jump_Start → JumpAttack1·2·3 → Jump_End → idle 이 보인다
|
||||
// (JumpAttack1~3 클립 이벤트 0 개 · Jump_Start 의 SkillEvent 는 시각 0 하나 · 종료 전이 0.7 이라 다시 안 돈다 — 2026-10-19 컨트롤러 · FBX 메타 실측).
|
||||
// 무적 · 이동 금지 · 2.1 초 뒤 대상 뒤 순간이동 · 판정은 원본 그대로.
|
||||
// ■ 이펙트 — 베기(go_effect = Slash · 파티클 6 층 · 크기 2.6 ~ 7.7)와 연기(go_invisibleEffect = FX_DarkSkill_SmokeExplosion)의 Transform 을
|
||||
// 「처음 크기 × 배율」로(풀 재사용 때 누적 없음 · 처음 크기는 WLShadowSlashBase 에 기억) · 파티클 크기 모드 Hierarchy
|
||||
// (베기 팩 원본이 Local 이라 Transform 배율이 안 먹는다). 베기 판정 상자(BoxCollider 5.25 × 9 × 5.9)는 keepHitBox 1 이면 배율의 역수로 키워 월드 크기 그대로.
|
||||
// ■ 값 = Resources/WL/WLShadowSlash.json (C45) · 끄기 = enabled 0(원본 그대로)
|
||||
// 🔴 Debug.LogError 금지(원본 ErrorLogHook 팝업) — 실패는 LastLog
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
using System.Reflection;
|
||||
using UnityEngine;
|
||||
|
||||
namespace WL.Combat.Weapon
|
||||
{
|
||||
[System.Serializable]
|
||||
public class WLShadowSlashJson
|
||||
{
|
||||
public int enabled = 0;
|
||||
public int keepVisible = 1; // 1 = 시전 중 PC 렌더러 · 무기를 켠다(원본 = 2.1 초 숨김)
|
||||
public int freezeAnim = 0; // 1 = 원본처럼 애니메이터 배속 0 유지(보이면 2.1 초 정지 자세) · 0 = 원본 상태 사슬 재생
|
||||
public float slashScale = 1f; // 베기(go_effect) 보이는 크기 배율
|
||||
public float smokeScale = 1f; // 연기(go_invisibleEffect) 크기 배율
|
||||
public int keepHitBox = 1; // 1 = 베기 판정 상자 월드 크기 유지(보이는 크기만 바꿈)
|
||||
}
|
||||
|
||||
public static class WLShadowSlash
|
||||
{
|
||||
public const string ResourcesPath = "WL/WLShadowSlash";
|
||||
public static int Casts, Shown, Scaled, Errors;
|
||||
public static string LastLog = "";
|
||||
|
||||
static WLShadowSlashJson s_cfg;
|
||||
static MethodInfo s_model, s_weapon;
|
||||
|
||||
static WLShadowSlashJson Cfg
|
||||
{
|
||||
get
|
||||
{
|
||||
if (s_cfg != null) return s_cfg;
|
||||
s_cfg = new WLShadowSlashJson();
|
||||
var ta = Resources.Load<TextAsset>(ResourcesPath);
|
||||
if (ta == null) { LastLog = "설정 없음 " + ResourcesPath; return s_cfg; }
|
||||
try { JsonUtility.FromJsonOverwrite(ta.text, s_cfg); } catch (System.Exception ex) { LastLog = "설정 파싱 실패 — " + ex.Message; }
|
||||
return s_cfg;
|
||||
}
|
||||
}
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Boot() { s_cfg = null; Casts = Shown = Scaled = Errors = 0; LastLog = ""; }
|
||||
|
||||
public static string Describe() { return "casts " + Casts + " · shown " + Shown + " · scaled " + Scaled + " · err " + Errors + " · " + LastLog; }
|
||||
|
||||
/// <summary>원본 Skill_ShadowSlash.Set 훅(Set_Invisible 바로 뒤 1줄) — 캐릭터 보이기 · 베기 · 연기 크기 배율.</summary>
|
||||
public static void OnSet(Skill_ShadowSlash skill)
|
||||
{
|
||||
try
|
||||
{
|
||||
var c = Cfg;
|
||||
if (skill == null || c.enabled == 0) return;
|
||||
Casts++;
|
||||
var pd = skill.Get_ProjectTileData();
|
||||
var shooter = pd != null ? pd.shooter : null;
|
||||
if (c.keepVisible != 0 && !DSUtil.CheckNull(shooter))
|
||||
{
|
||||
bool model = Call(ref s_model, "Model_OnOff", shooter);
|
||||
bool weapon = Call(ref s_weapon, "Weapon_OnOff", shooter);
|
||||
if (model && weapon) Shown++;
|
||||
if (c.freezeAnim == 0 && shooter.m_animation != null) shooter.m_animation.speed = 1f;
|
||||
}
|
||||
if (skill.go_effect != null) Scale(skill.go_effect.transform, c.slashScale, c.keepHitBox != 0);
|
||||
if (skill.go_invisibleEffect != null) Scale(skill.go_invisibleEffect.transform, c.smokeScale, false);
|
||||
LastLog = "cast " + Casts + " · shown " + Shown + " · scaled " + Scaled;
|
||||
}
|
||||
catch (System.Exception ex) { Errors++; LastLog = "실패 — " + ex.Message; }
|
||||
}
|
||||
|
||||
/// <summary>Actor 의 protected 켜기 함수(Model_OnOff · Weapon_OnOff(bool))를 true 로 부른다 — 원본 Co_Invisible 끝과 같은 호출(가상 함수는 PCActor 쪽이 불린다).</summary>
|
||||
static bool Call(ref MethodInfo m, string name, Actor a)
|
||||
{
|
||||
if (m == null) m = typeof(Actor).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, null, new[] { typeof(bool) }, null);
|
||||
if (m == null) { LastLog = "함수 없음 " + name; return false; }
|
||||
m.Invoke(a, new object[] { true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Transform 을 처음 크기 × k 로 · 파티클 크기 모드 Hierarchy · 판정 상자는 keepBox 면 1/k 로(월드 크기 유지).</summary>
|
||||
static void Scale(Transform t, float k, bool keepBox)
|
||||
{
|
||||
if (t == null || k <= 0f) return;
|
||||
var b = t.GetComponent<WLShadowSlashBase>();
|
||||
if (b == null)
|
||||
{
|
||||
if (Mathf.Approximately(k, 1f)) return; // 손댄 적 없고 배율 1 = 원본 그대로
|
||||
b = t.gameObject.AddComponent<WLShadowSlashBase>();
|
||||
b.scale = t.localScale;
|
||||
var bc0 = t.GetComponent<BoxCollider>();
|
||||
if (bc0 != null) { b.hasBox = true; b.size = bc0.size; b.center = bc0.center; }
|
||||
foreach (var ps in t.GetComponentsInChildren<ParticleSystem>(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; }
|
||||
}
|
||||
t.localScale = b.scale * k;
|
||||
if (b.hasBox)
|
||||
{
|
||||
var bc = t.GetComponent<BoxCollider>();
|
||||
if (bc != null) { float inv = keepBox ? 1f / k : 1f; bc.size = b.size * inv; bc.center = b.center * inv; }
|
||||
}
|
||||
Scaled++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>#943-⑧ 이 Transform(원본 스킬 풀 인스턴스)의 처음 크기 · 판정 상자 — 배율 누적 방지.</summary>
|
||||
public sealed class WLShadowSlashBase : MonoBehaviour { public Vector3 scale, size, center; public bool hasBox; }
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 746b6f737ed79bf48ab1d8ec5fcf1383
|
||||
|
|
@ -169,6 +169,16 @@ MonoBehaviour:
|
|||
weaponSlotIndices: 01000000
|
||||
arcFitBlade: 0
|
||||
arcSizeMul: 1
|
||||
- classId: 10701
|
||||
mode: 0
|
||||
overrideLook: 0
|
||||
colorAdditive: {r: 0.086, g: 1.553, b: 2.024, a: 1}
|
||||
colorAlpha: {r: 0, g: 0.762, b: 1.582, a: 1}
|
||||
widthScale: 1
|
||||
overrideWeaponSlots: 0
|
||||
weaponSlotIndices:
|
||||
arcFitBlade: 1
|
||||
arcSizeMul: 1
|
||||
useTableWeaponIndices: 1
|
||||
burstMode: 0
|
||||
burstTiming: 1
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
값 파일 = `Assets/WL/Combat/Weapon/Resources/WL/WLGreatWave.json` · 이 파일은 사람·세션이 읽는 이력이며 게임은 읽지 않는다.
|
||||
|
||||
## _note_943
|
||||
#943 PD 「대검 주력 스킬 사용 중 등장 원인을 알 수 없는 도끼 이펙트를 제거해줘.」 · 「대검 주력 스킬 중 모으기 이펙트 및 검을 흰색으로 빛나게 해줘.」 — ③ 도끼 = 피격 hitFx WL_Hit_GreatSkill(#936 FX_Shoot_Axe_hit 사본)의 맨 위 층이 도끼 모델 메시(Fbx_fx_Shoot&HitVol2_Axe · 재질 Mat_fx_Shoot&HitVol2_Modeling)를 그리던 것(편집 렌더로 확인) → 프리팹에서 그 층 렌더러만 끔(입자는 그대로 돌아 WLImpactFx 크기 맞춤 불변 · 같은 프리팹을 쓰는 WLImpactFx skillHitFx 10801 · WLBladeStorm hitFx 에서도 함께 사라짐) · ④ chargeFx WL_Fx_GreatCharge → WL_Fx_GreatChargeWhite(켜진 층 시작색 · 수명색 RGB → 흰색 · 알파 유지 · 원본 사본은 그대로) · 검 빛 glowColor (2.2, 0.8, 0.6) → (2, 2, 2)(WLWeaponGlow 셰이더 기본 흰 빛) · glowMax 0.8 → 1 · glowFill 0.25 → 0.35(해머 각성 흰 빛과 같은 세기)
|
||||
|
||||
## _note_936
|
||||
#936 PD 「피격 된 대상에게 플레이어의 무기에 적합한 피격 이펙트 — 보유 에셋에서 추천 후 적용」 — 검기 피격 hitFx = WL_Hit_GreatSkill(VFX_Klaus Stylized Shoot & Hit Vol.2 FX_Shoot_Axe_hit 사본 · 도끼 타격의 큰 평면 폭발을 대검 붉은 계열로 색 보정 · 편집 모드 후보 133종 대조표에서 고름 · 이전 WL_Fx_GreatHitRed 는 가는 가로 선이라 약했다)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue