2026-10-18 15:09:05 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// WLProjectileVisual.cs — 보이지 않는 기본 공격 투사체에 시각 효과를 붙인다 (PD 2026-10-10)
/ /
// PD 원문: 「활 및 마법 투사체에 적합한 투사체를 배치해서 자연스러운 공격이 가능하도록 해줘」
/ /
// ■ 실측 — 지팡이 직업의 기본 공격 투사체 `Projectile_10301`·`Projectile_10302` 는 Projectile_Strait 에 렌더러·파티클이
// 하나도 없다(보이지 않는 직선 판정 상자 + 명중 이펙트 Effect_Hit_1030x 만). 활 `Projectile_10402` 는 화살 메시가 있다.
// ■ 방법 — 원본 프리팹·EffectList 표(수명)·판정은 그대로 두고, 투사체 인스턴스(ProjectileInfo 풀)에 **시각 자식**
// (Resources/WL/WL_MagicBoltVisual 등 · WL 프리팹)을 1회 붙인다. 풀 재사용 때도 자식이 남아 다시 보인다.
// 규칙 = Resources/WL/WLProjectileVisual.json (투사체 이름 → 시각 프리팹 · 배율).
// ■ 시점 — #923 원본 ProjectileBase.Set 끝 훅 1줄(OnSet) · 쏘는 그 순간(켜진 직후 · 첫 프레임 전) 인스턴스에 바로 붙인다.
// (#923 전 = Shoot_Projectile 첫 줄의 HitboxSpawned 에서 풀을 뒤졌다 — 투사체가 만들어지기 전이라, 풀에 이미 붙인 화살이 하나라도
// 있으면 「찾음」으로 끝나 새로 늘어난 풀 인스턴스는 안 붙은 채 원래 화살 메시로 날았다 · PD 「구 버전 화살 이펙트가 간헐적으로」)
// 🔴 Debug.LogError 금지.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic ;
using UnityEngine ;
namespace WL.Combat.Weapon
{
[System.Serializable]
public class WLProjectileVisualRule
{
public string projectile = "" ;
public string visual = "" ;
public float scale = 1f ;
// #839 활 3타 강화 화살 — WLBowChargeReceiver.ArmedUntil 창 안에 켜지는 인스턴스에 WLChargedMark 가 적용(크기 × chargedScale + 글로우 자식)
public string chargedVisual = "" ;
public float chargedScale = 1.6f ;
// #919 PD 「활 3타 … 발사되는 투사체를 지금 기본 공격 투사체로 변경」 · 「1, 2타는 Master Stylized Projectile 이펙트」 — true 면 강화 화살이 기본 겉모습(~WL_Visual)을 끄고 chargedVisual 만 보인다(덧붙이기 대신 바꾸기)
public bool chargedReplaces ;
// #917 PD 「화살 투사체 오브젝트 및 활 히트 이펙트를 교체(Casual RPG VFX)」 — 원래 화살 메시를 숨기고(hideMesh) · 이 투사체의 명중 이펙트(hitFx · 몸 중심 · 몸 크기 비례 hitScale)
public bool hideMesh ;
// #934 PD 「지팡이 기본 공격 투사체에 세로로 긴 이펙트가 숨겨져 있는 것 같아. 세로로 긴 화살형 이펙트는 보이지 않게 해줘.」 — 원본 투사체 안 자식(투사체 기준 경로)의 렌더러를 끈다(풀 재사용에도 꺼진 채)
public string [ ] hideNames = new string [ 0 ] ;
public string hitFx = "" ;
public float hitScale = 1f ;
public float hitLife = 1.5f ;
}
[System.Serializable]
public class WLProjectileVisualJson
{
public WLProjectileVisualRule [ ] rules = new WLProjectileVisualRule [ 0 ] ;
}
public sealed class WLProjectileVisual : MonoBehaviour
{
public const string ResourcesPath = "WL/WLProjectileVisual" ;
public static int Attached , Events ;
public static string LastLog = "" ;
static bool s_loaded ;
static readonly Dictionary < string , WLProjectileVisualRule > s_rules = new Dictionary < string , WLProjectileVisualRule > ( ) ;
static readonly Dictionary < string , WLProjectileVisualRule > s_byName = new Dictionary < string , WLProjectileVisualRule > ( ) ; // 인스턴스 이름(…(Clone)) → 규칙(없으면 null)
static readonly HashSet < int > s_done = new HashSet < int > ( ) ;
static readonly Dictionary < string , GameObject > s_prefabs = new Dictionary < string , GameObject > ( ) ;
static void Load ( )
{
s_loaded = true ; s_rules . Clear ( ) ; s_byName . Clear ( ) ;
var ta = Resources . Load < TextAsset > ( ResourcesPath ) ;
if ( ta = = null ) return ;
WLProjectileVisualJson j = null ;
try { j = JsonUtility . FromJson < WLProjectileVisualJson > ( ta . text ) ; } catch ( System . Exception ex ) { LastLog = "규칙 파싱 실패 — " + ex . Message ; }
if ( j = = null | | j . rules = = null ) return ;
for ( int i = 0 ; i < j . rules . Length ; i + + )
{
var r = j . rules [ i ] ;
if ( r = = null | | string . IsNullOrEmpty ( r . projectile ) | | ( string . IsNullOrEmpty ( r . visual ) & & string . IsNullOrEmpty ( r . chargedVisual ) & & string . IsNullOrEmpty ( r . hitFx ) ) ) continue ;
s_rules [ r . projectile ] = r ;
}
}
/// <summary>#923 원본 ProjectileBase.Set 끝 훅 — 켜진 직후(첫 프레임 그리기 전) 이 인스턴스에 규칙을 붙인다(인스턴스마다 1 회 · 풀 재사용 때는 자식이 남아 있다).</summary>
public static void OnSet ( ProjectileBase pb )
{
if ( pb = = null ) return ;
WLStaffEarth . OnProjectileSet ( pb ) ; // #935 지팡이 3타 투사체 표시(쏠 때마다 · 이미 겉모습을 붙인 풀 인스턴스도)
if ( s_done . Contains ( pb . GetInstanceID ( ) ) ) return ;
if ( ! s_loaded ) Load ( ) ;
WLProjectileVisualRule rule ;
var n = pb . name ;
if ( ! s_byName . TryGetValue ( n , out rule ) )
{
int k = n . IndexOf ( '(' ) ; var key = k > 0 ? n . Substring ( 0 , k ) . TrimEnd ( ) : n ;
s_rules . TryGetValue ( key , out rule ) ; s_byName [ n ] = rule ;
}
if ( rule = = null ) return ;
Events + + ;
if ( Attach ( pb , rule ) ) s_done . Add ( pb . GetInstanceID ( ) ) ;
}
/// <summary>#917 속사(WLRapidShot)가 쏜 직후 부른다 — Set 훅(OnSet)이 이미 붙였으면 아무것도 안 한다(1 회).</summary>
public static void ApplyTo ( ProjectileBase pb , string name )
{
if ( pb = = null | | string . IsNullOrEmpty ( name ) ) return ;
if ( ! s_loaded ) Load ( ) ;
WLProjectileVisualRule rule ;
if ( ! s_rules . TryGetValue ( name , out rule ) ) return ;
int id = pb . GetInstanceID ( ) ;
if ( s_done . Contains ( id ) ) return ;
if ( Attach ( pb , rule ) ) s_done . Add ( id ) ;
}
static bool Attach ( ProjectileBase pb , WLProjectileVisualRule rule )
{
int pss = 0 ; GameObject normal = null ;
// #917 원래 화살 메시 숨김(풀 재사용에도 꺼진 채) · 투사체별 명중 이펙트
if ( rule . hideMesh ) foreach ( var mr in pb . GetComponentsInChildren < MeshRenderer > ( true ) ) if ( mr . transform . parent = = pb . transform | | mr . transform = = pb . transform ) mr . enabled = false ;
int hid = 0 ;
if ( rule . hideNames ! = null ) foreach ( var hn in rule . hideNames ) { var ht = string . IsNullOrEmpty ( hn ) ? null : pb . transform . Find ( hn ) ; if ( ht ! = null ) foreach ( var hr in ht . GetComponents < Renderer > ( ) ) { hr . enabled = false ; hid + + ; } }
if ( ! string . IsNullOrEmpty ( rule . hitFx ) )
{
var hf = pb . GetComponent < WLProjectileHitFx > ( ) ; if ( hf = = null ) hf = pb . gameObject . AddComponent < WLProjectileHitFx > ( ) ;
hf . prefab = rule . hitFx ; hf . scale = rule . hitScale ; hf . life = rule . hitLife ;
}
if ( ! string . IsNullOrEmpty ( rule . visual ) )
{
var go = InstantiateVisual ( rule . visual , pb . transform , rule . scale , "~WL_Visual" ) ;
if ( go = = null ) { LastLog = "시각 프리팹 없음 " + rule . visual ; return false ; }
var arr = go . GetComponentsInChildren < ParticleSystem > ( true ) ;
for ( int i = 0 ; i < arr . Length ; i + + )
{
var main = arr [ i ] . main ;
main . playOnAwake = true ; // 풀 재사용(SetActive) 때 다시 재생
arr [ i ] . Play ( true ) ;
}
pss = arr . Length ; normal = go ;
}
// #839 — 강화 화살: 글로우 자식(꺼둠) + WLChargedMark(켜질 때 창 열려 있으면 적용)
if ( ! string . IsNullOrEmpty ( rule . chargedVisual ) )
{
var cv = InstantiateVisual ( rule . chargedVisual , pb . transform , 1f , "~WL_Charged" ) ;
if ( cv = = null ) LastLog = "강화 프리팹 없음 " + rule . chargedVisual ;
else cv . SetActive ( false ) ;
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 ;
mark . ApplyNow ( ) ;
}
Attached + + ;
LastLog = pb . name + " ← " + rule . visual + ( string . IsNullOrEmpty ( rule . chargedVisual ) ? "" : ( rule . chargedReplaces ? " / 3타 " : " +charged " ) + rule . chargedVisual ) + ( rule . hideMesh ? " · 메시 숨김" : "" ) + ( hid > 0 ? " · 숨김 " + hid : "" ) + ( string . IsNullOrEmpty ( rule . hitFx ) ? "" : " · 명중 " + rule . hitFx ) + " (파티클 " + pss + ")" ;
return true ;
}
internal static GameObject InstantiateVisual ( string path , Transform parent , float scale , string name )
{
GameObject prefab ;
if ( ! s_prefabs . TryGetValue ( path , out prefab ) | | prefab = = null )
{
prefab = Resources . Load < GameObject > ( path ) ;
s_prefabs [ path ] = prefab ;
}
if ( prefab = = null ) return null ;
var go = Instantiate ( prefab , parent ) ;
go . name = name ;
go . transform . localPosition = Vector3 . zero ;
go . transform . localRotation = Quaternion . identity ;
go . transform . localScale = Vector3 . one * Mathf . Max ( scale , 0.01f ) ;
return go ;
}
}
/// <summary>#917 투사체별 명중 이펙트 — WLImpactFx.TryHitEffect(원본 Show_HitEffect 훅)가 직업 규칙보다 먼저 묻는다. 몸 중심 · 몸 크기 비례(SizeFactor).</summary>
public sealed class WLProjectileHitFx : MonoBehaviour
{
public string prefab = "" ;
public float scale = 1f , life = 1.5f ;
public static int Shown ;
static readonly Dictionary < string , GameObject > s_prefabs = new Dictionary < string , GameObject > ( ) ;
public bool Play ( Actor target )
{
if ( target = = null | | string . IsNullOrEmpty ( prefab ) ) return false ;
GameObject p ;
if ( ! s_prefabs . TryGetValue ( prefab , out p ) | | p = = null ) { p = Resources . Load < GameObject > ( prefab ) ; s_prefabs [ prefab ] = p ; }
if ( p = = null ) return false ;
2026-10-18 17:27:34 +00:00
var go = Instantiate ( p , WL . Combat . Impact . WLImpactFx . BodyCenter ( target ) + WL . Combat . Impact . WLImpactFx . TowardCamera ( target ) , p . transform . rotation ) ; // #941 몸 앞(카메라 쪽)
2026-10-18 15:09:05 +00:00
go . transform . localScale = p . transform . localScale * ( scale * WL . Combat . Impact . WLImpactFx . SizeFactor ( target ) ) ;
Destroy ( go , Mathf . Max ( 0.2f , life ) ) ;
Shown + + ;
return true ;
}
}
}