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 ;
2026-10-18 18:48:42 +00:00
public float thickness = 1f ; // #943-② PD 「발사되는 화살이 좀 더 샤프하게 두께를 줄여줘」 — 시각(일반·강화)의 굵기만 배율(입자 크기 · 3D 크기 X/Y · 늘림 길이 보정 · 궤적 폭) · 길이는 그대로
2026-10-20 12:36:12 +00:00
public float length = 1f ; // #952-③ PD 「1, 2타 화살의 투사체 길이를 3타 투사체와 비슷하게 줄이고」 — 기본 겉모습(visual)의 길이만 배율(늘림 빌보드 lengthScale · 입자 궤적 수명 · TrailRenderer 시간 · Mesh 모드 입자의 진행 축 Z) · 굵기 그대로 · 강화(3타) 겉모습에는 안 씀
public float speedMul = 1f ; // #952-③ PD 「활 투사체 속도를 2배로」 — 쏘는 순간 ProjectileData.Speed 배율(원본 prefab 은 Speed 를 직렬화하지 않아 클래스 기본값 5 + 스탯 보정 · 매 발사 적용 · 1 = 그대로)
2026-10-18 15:09:05 +00:00
// #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 ;
2026-10-25 16:06:21 +00:00
// #985 PD 「활 투사체에 화살이 같이 보이는게 좋을 것 같아. (화살과 전혀 동떨어진 투사체로 보여서 어색해)」 — arrow = 날아가는 투사체에 시위 화살과 같은 화살 메시(WL_NockArrow · +Z = 촉)를 자식으로
// 길이 arrowLength(m · 월드) · 굵기 배율 arrowWidth · 촉 = 투사체 자리에서 앞으로 arrowTip(m) · 강화(3타) 겉모습으로 바뀌어도 그대로 보인다
public string arrow = "" ;
public float arrowLength = 0.7f , arrowWidth = 1f , arrowTip = 0f ;
2026-10-18 15:09:05 +00:00
// #934 PD 「지팡이 기본 공격 투사체에 세로로 긴 이펙트가 숨겨져 있는 것 같아. 세로로 긴 화살형 이펙트는 보이지 않게 해줘.」 — 원본 투사체 안 자식(투사체 기준 경로)의 렌더러를 끈다(풀 재사용에도 꺼진 채)
public string [ ] hideNames = new string [ 0 ] ;
public string hitFx = "" ;
public float hitScale = 1f ;
public float hitLife = 1.5f ;
2026-10-20 03:53:38 +00:00
// #948 PD 「마법검 3타 공격의 크기를 절반으로」 — 쏜 PC 의 콤보 단계별 겉모습(보이는 자식)·명중 이펙트 배율. 판정(루트 콜라이더)은 그대로 · 항목 없는 단계 = 1
public WLProjectileStageScale [ ] stageScales = new WLProjectileStageScale [ 0 ] ;
2026-10-22 05:14:07 +00:00
// #968 PD 「오브 각성 스킬 이펙트 크기를 50% 줄이고」 — 1 이 아니면 쏠 때마다(풀 재사용 포함) 투사체 루트 크기 = 처음 크기 × fxScale (원본 이펙트 전체 · 입자는 계층 배율일 때만 따라 줄어든다 · 판정 반경은 그대로)
public float fxScale = 1f ;
2026-10-23 13:08:59 +00:00
// #974 1 = 원본 코드가 쏠 때 띄우는 시작 이펙트(검기 분출 Effect_Hit_Skill_EnergySlashFlash)를 끈다 — 같은 섬광을 스킬 연출 impact(푸른 WL 사본)가 띄우므로 두 번 겹치던 것
public int hideStartFx ;
2026-10-18 15:09:05 +00:00
}
2026-10-20 03:53:38 +00:00
[System.Serializable]
2026-10-31 06:53:50 +00:00
public class WLProjectileStageScale
{
public int stage ; public float scale = 1f ; public float hitScale = 1f ;
// #990 PD 「지팡이 기본 공격 마지막타를 Unique Toon Projectiles Vol. 1 … 파이어볼로 바꿔줘(피격 이펙트 포함)」 — 이 단계에만 따로 겉모습 자식(~WL_Stage · 다른 단계 땐 꺼짐) · 명중 이펙트(WLProjectileHitFx 를 이 단계 동안만 바꿈)
public string visual = "" ;
public float visualScale = 1f ;
public string hitFx = "" ;
public float hitFxScale = 1f , hitFxLife = 1.5f ;
// #998 PD 「(지팡이 3타 불덩이 발사 자리를 빛나는) 오른손으로 옮겨」 — 1 = 쏘는 순간 자리를 무기 든 손 소켓(3타 모으기 빛 WLStaffEarth preChargeFx head 2 와 같은 점)으로 옮기고 대상 몸 중심을 겨눈다
// (원본 = 몸 앞 가운데 0.38 m · 높이 0.53 m · 왼손 0.31 · 오른손 0.50 m 실측)
public int fromSocket ; // #999 2 = 빈 손 뼈(PD 「빈 손에서 에너지가 모여야 해」 — 모으기 · 발사를 같은 빈 손으로)
}
2026-10-20 03:53:38 +00:00
2026-10-18 15:09:05 +00:00
[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 > ( ) ;
2026-10-20 07:32:41 +00:00
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics ( ) { s_loaded = false ; s_rules . Clear ( ) ; s_byName . Clear ( ) ; s_done . Clear ( ) ; s_prefabs . Clear ( ) ; s_baseScales . Clear ( ) ; } // #948-5 도메인 리로드 끔 대비 — JSON(hideNames 등)을 Play 마다 다시 읽는다(실측: 이전 값이 도메인에 남아 연기 층까지 숨겨짐)
2026-10-18 15:09:05 +00:00
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 ] ;
2026-10-23 13:08:59 +00:00
if ( r = = null | | string . IsNullOrEmpty ( r . projectile ) | | ( string . IsNullOrEmpty ( r . visual ) & & string . IsNullOrEmpty ( r . chargedVisual ) & & string . IsNullOrEmpty ( r . hitFx ) & & ( r . fxScale < = 0f | | Mathf . Approximately ( r . fxScale , 1f ) ) & & r . hideStartFx = = 0 ) ) continue ; // #968 fxScale 만 있는 규칙(블랙홀 0.5 배)도 · #974 hideStartFx 만 있는 규칙도 읽는다
2026-10-18 15:09:05 +00:00
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타 투사체 표시(쏠 때마다 · 이미 겉모습을 붙인 풀 인스턴스도)
2026-10-21 16:30:22 +00:00
try { OnSetVisual ( pb ) ; }
finally { WLStaffEarth . AfterProjectileSet ( pb ) ; } // #967 3타 메테오 = 겉모습 자식까지 붙은 뒤 넘겨받아 모두 끈다
}
2026-10-23 13:08:59 +00:00
static WLProjectileVisualRule RuleOf ( ProjectileBase pb )
2026-10-21 16:30:22 +00:00
{
2026-10-18 15:09:05 +00:00
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 ;
}
2026-10-23 13:08:59 +00:00
return rule ;
}
public static int StartFxHidden ;
/// <summary>#974 원본 스킬 투사체의 시작 이펙트 줄 훅 — true = 띄우지 않는다(규칙 hideStartFx).</summary>
public static bool HideStartFx ( ProjectileBase pb )
{
try { var r = pb ! = null ? RuleOf ( pb ) : null ; if ( r = = null | | r . hideStartFx = = 0 ) return false ; StartFxHidden + + ; return true ; }
catch ( System . Exception ) { return false ; }
}
static void OnSetVisual ( ProjectileBase pb )
{
var rule = RuleOf ( pb ) ;
2026-10-18 15:09:05 +00:00
if ( rule = = null ) return ;
2026-10-20 12:36:12 +00:00
if ( rule . speedMul > 0f & & ! Mathf . Approximately ( rule . speedMul , 1f ) ) { var sm = pb . GetComponent < WLProjectileSpeedMul > ( ) ; if ( sm = = null ) sm = pb . gameObject . AddComponent < WLProjectileSpeedMul > ( ) ; sm . mul = rule . speedMul ; sm . Arm ( pb ) ; } // #952-③ 매 발사 · 적용은 그 프레임 LateUpdate(Projectile_Strait.Set 이 base.Set 뒤에 m_Speed 로 덮어쓰므로 — 10402 prefab m_Speed 7 실측)
2026-10-20 03:53:38 +00:00
if ( ! s_done . Contains ( pb . GetInstanceID ( ) ) ) { Events + + ; if ( Attach ( pb , rule ) ) s_done . Add ( pb . GetInstanceID ( ) ) ; }
ApplyStageScale ( pb , rule ) ; // #948 매 발사(풀 재사용 포함) — 콤보 단계별 배율
2026-10-22 05:14:07 +00:00
ApplyFxScale ( pb , rule ) ; // #968 원본 이펙트 전체 배율
}
public static int FxScaled ;
static readonly Dictionary < int , Vector3 > s_baseRoot = new Dictionary < int , Vector3 > ( ) ; // #968 투사체 인스턴스 → 처음 루트 localScale
/// <summary>#968 rule.fxScale 이 1 이 아니면 투사체 루트 localScale = 처음 값 × fxScale(매 발사 · 누적 없음). 판정은 원본 코드 값(반경 · 범위)이라 바뀌지 않는다.</summary>
static void ApplyFxScale ( ProjectileBase pb , WLProjectileVisualRule rule )
{
if ( rule . fxScale < = 0f | | Mathf . Approximately ( rule . fxScale , 1f ) ) return ;
int id = pb . GetInstanceID ( ) ; Vector3 b ;
2026-10-23 13:08:59 +00:00
if ( ! s_baseRoot . TryGetValue ( id , out b ) )
{
b = pb . transform . localScale ; s_baseRoot [ id ] = b ;
// #974 입자가 Local 배율이면 루트 크기를 안 따라 그대로 보인다(검기 분출 FX_SlashLight) → 이 인스턴스 입자만 계층 배율로(원본 프리팹 무수정 · 처음 1회)
foreach ( var ps in pb . GetComponentsInChildren < ParticleSystem > ( true ) ) { var m = ps . main ; if ( m . scalingMode ! = ParticleSystemScalingMode . Hierarchy ) m . scalingMode = ParticleSystemScalingMode . Hierarchy ; }
}
2026-10-22 05:14:07 +00:00
pb . transform . localScale = b * rule . fxScale ; FxScaled + + ;
2026-10-20 03:53:38 +00:00
}
2026-10-31 06:53:50 +00:00
public static int StageScaled , StageVisuals ; public static string StageLog = "" ;
const string StageChild = "~WL_Stage" ;
2026-10-20 03:53:38 +00:00
static readonly Dictionary < int , Vector3 [ ] > s_baseScales = new Dictionary < int , Vector3 [ ] > ( ) ; // 투사체 인스턴스 → 직접 자식들의 기본 localScale
/// <summary>#948 쏜 PC 의 콤보 단계(ComboTracker.LastStage)에 맞는 stageScales 항목이 있으면 보이는 직접 자식(원본 FX 노드 · ~WL_Visual)의 localScale 과 명중 이펙트 배율을 곱한다. 콜라이더가 달린 자식(판정)은 건드리지 않는다. 항목이 없으면 기본값(풀 재사용 복원).</summary>
static void ApplyStageScale ( ProjectileBase pb , WLProjectileVisualRule rule )
{
if ( rule . stageScales = = null | | rule . stageScales . Length = = 0 ) return ;
var pd = pb . Get_ProjectTileData ( ) ; var shooter = pd ! = null ? pd . shooter as PCActor : null ;
var ct = shooter ! = null ? shooter . GetComponent < WL . Combat . Core . ComboTracker > ( ) : null ;
int stage = ct ! = null ? ct . LastStage : 0 ;
WLProjectileStageScale e = null ;
foreach ( var s in rule . stageScales ) if ( s ! = null & & s . stage = = stage ) { e = s ; break ; }
float mul = e ! = null ? e . scale : 1f , hitMul = e ! = null ? e . hitScale : 1f ;
int id = pb . GetInstanceID ( ) ; var t = pb . transform ; Vector3 [ ] bases ;
2026-10-31 06:53:50 +00:00
int real = t . childCount - ( t . Find ( StageChild ) ! = null ? 1 : 0 ) ; // #990 단계 겉모습 자식(맨 뒤에 붙음)은 세지 않는다 — 세면 그 단계(배율 0)로 줄어든 크기를 기본값으로 다시 읽어 1 · 2타까지 사라진다
if ( ! s_baseScales . TryGetValue ( id , out bases ) | | bases . Length < real )
2026-10-20 03:53:38 +00:00
{
bases = new Vector3 [ t . childCount ] ;
for ( int i = 0 ; i < t . childCount ; i + + ) bases [ i ] = t . GetChild ( i ) . localScale ;
s_baseScales [ id ] = bases ;
}
2026-10-31 06:53:50 +00:00
for ( int i = 0 ; i < t . childCount & & i < bases . Length ; i + + ) { var c = t . GetChild ( i ) ; if ( c . GetComponent < Collider > ( ) ! = null | | c . name = = StageChild ) continue ; c . localScale = bases [ i ] * mul ; }
var hf = pb . GetComponent < WLProjectileHitFx > ( ) ; if ( hf ! = null ) { hf . scale = rule . hitScale * hitMul ; hf . prefab = rule . hitFx ; hf . life = rule . hitLife ; }
// #990 단계 전용 겉모습 · 명중(풀 재사용: 다른 단계로 쏘면 끄고 원래 명중으로)
bool own = e ! = null & & ! string . IsNullOrEmpty ( e . visual ) ;
var sv = t . Find ( StageChild ) ;
if ( own & & ( sv = = null | | sv . GetComponent < WLStageVisualMark > ( ) = = null | | sv . GetComponent < WLStageVisualMark > ( ) . path ! = e . visual ) )
{
if ( sv ! = null ) Destroy ( sv . gameObject ) ;
var go = InstantiateVisual ( e . visual , t , e . visualScale , StageChild ) ;
if ( go ! = null ) { go . AddComponent < WLStageVisualMark > ( ) . path = e . visual ; foreach ( var ps in go . GetComponentsInChildren < ParticleSystem > ( true ) ) { var m = ps . main ; m . playOnAwake = true ; } sv = go . transform ; }
else { sv = null ; LastLog = "단계 겉모습 없음 " + e . visual ; }
}
if ( sv ! = null )
{
sv . gameObject . SetActive ( false ) ; // 쏠 때마다 처음부터(궤적 · 고리 입자 지움 → 다시 재생)
if ( own ) { sv . localScale = Vector3 . one * Mathf . Max ( 0.01f , e . visualScale ) ; sv . gameObject . SetActive ( true ) ; foreach ( var ps in sv . GetComponentsInChildren < ParticleSystem > ( true ) ) { ps . Clear ( true ) ; ps . Play ( true ) ; } StageVisuals + + ; }
}
if ( e ! = null & & ! string . IsNullOrEmpty ( e . hitFx ) )
{
if ( hf = = null ) hf = pb . gameObject . AddComponent < WLProjectileHitFx > ( ) ;
hf . prefab = e . hitFx ; hf . scale = e . hitFxScale ; hf . life = e . hitFxLife ;
}
2026-10-20 03:53:38 +00:00
if ( e ! = null ) { StageScaled + + ; StageLog = pb . name + " 단계 " + stage + " 겉모습 × " + mul + " · 명중 × " + hitMul ; }
2026-10-31 06:53:50 +00:00
if ( e ! = null & & e . fromSocket ! = 0 & & shooter ! = null ) FromSocket ( pb , pd , shooter , e . fromSocket ) ;
}
public static int SocketStarts ; public static string SocketLog = "" ;
/// <summary>#998 쏘는 순간 자리 = 무기 든 손 소켓 · 방향 = 대상 몸 중심(Projectile_Strait 는 transform.forward 로 곧게 난다 · 원본 Set 의 자리 · 조준 뒤라 여기서 덮는다).</summary>
static void FromSocket ( ProjectileBase pb , ProjectileData pd , PCActor shooter , int mode )
{
var sock = mode = = 2 ? WLStaffEarth . EmptyHand ( shooter ) : WLStaffEarth . Socket ( shooter , "" ) ; if ( sock = = null ) { SocketLog = "소켓 없음" ; return ; } // #999 2 = 빈 손 뼈(3타 모으기와 같은 손)
Vector3 from = pb . transform . position , to = ! DSUtil . CheckNull ( pd . target ) ? WL . Combat . Impact . WLImpactFx . BodyCenter ( pd . target ) : sock . position + shooter . transform . forward ;
pb . transform . position = sock . position ;
var d = to - sock . position ; if ( d . sqrMagnitude > 1e-4f ) pb . transform . rotation = Quaternion . LookRotation ( d . normalized ) ;
SocketStarts + + ; SocketLog = pb . name + " " + sock . name + " · 옮김 " + Vector3 . Distance ( from , sock . position ) . ToString ( "0.00" ) + " m" ;
2026-10-18 15:09:05 +00:00
}
/// <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 ; }
2026-10-18 18:48:42 +00:00
ApplyThickness ( go , rule . thickness ) ; // #943-②
2026-10-20 12:36:12 +00:00
ApplyLength ( go , rule . length ) ; // #952-③ 기본 겉모습만(강화 화살은 그대로)
2026-10-18 15:09:05 +00:00
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 ;
2026-10-20 04:42:05 +00:00
// #948 — 덧씌움 자식(~WL_Visual/…)도 hideNames 로 끌 수 있게 붙인 뒤 한 번 더(위 첫 패스는 덧씌움이 생기기 전이라 원본 자식만 잡는다)
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 . GetComponentsInChildren < Renderer > ( true ) ) { if ( hr . enabled ) { hr . enabled = false ; hid + + ; } } }
2026-10-18 15:09:05 +00:00
}
2026-10-25 16:06:21 +00:00
if ( ! string . IsNullOrEmpty ( rule . arrow ) ) AttachArrow ( pb , rule ) ; // #985 화살 메시
2026-10-18 15:09:05 +00:00
// #839 — 강화 화살: 글로우 자식(꺼둠) + WLChargedMark(켜질 때 창 열려 있으면 적용)
if ( ! string . IsNullOrEmpty ( rule . chargedVisual ) )
{
var cv = InstantiateVisual ( rule . chargedVisual , pb . transform , 1f , "~WL_Charged" ) ;
if ( cv = = null ) LastLog = "강화 프리팹 없음 " + rule . chargedVisual ;
2026-10-18 18:48:42 +00:00
else { ApplyThickness ( cv , rule . thickness ) ; cv . SetActive ( false ) ; } // #943-② 강화 화살도 같은 굵기
2026-10-18 15:09:05 +00:00
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 ;
}
2026-10-25 16:06:21 +00:00
public static int Arrows ; public static string LastArrow = "" ;
/// <summary>#985 화살 메시 자식 — 메시 경계로 길이(꼬리 → 촉)를 재서 월드 길이 arrowLength 가 되게(투사체 루트 배율 보정) · 촉을 투사체 자리 + arrowTip 에.</summary>
static void AttachArrow ( ProjectileBase pb , WLProjectileVisualRule rule )
{
var go = InstantiateVisual ( rule . arrow , pb . transform , 1f , "~WL_Arrow" ) ; if ( go = = null ) { LastArrow = "화살 프리팹 없음 " + rule . arrow ; return ; }
float zmin = float . MaxValue , zmax = float . MinValue ;
foreach ( var mf in go . GetComponentsInChildren < MeshFilter > ( true ) )
{
if ( mf . sharedMesh = = null ) continue ; var b = mf . sharedMesh . bounds ; var m = go . transform . worldToLocalMatrix * mf . transform . localToWorldMatrix ;
for ( int i = 0 ; i < 8 ; i + + ) { var v = m . MultiplyPoint3x4 ( new Vector3 ( ( i & 1 ) = = 0 ? b . min . x : b . max . x , ( i & 2 ) = = 0 ? b . min . y : b . max . y , ( i & 4 ) = = 0 ? b . min . z : b . max . z ) ) ; zmin = Mathf . Min ( zmin , v . z ) ; zmax = Mathf . Max ( zmax , v . z ) ; }
}
float len = zmin < zmax ? zmax - zmin : 1f , s = rule . arrowLength / Mathf . Max ( 1e-3f , len ) ; var ls = pb . transform . lossyScale ;
float lx = Mathf . Max ( 1e-4f , Mathf . Abs ( ls . x ) ) , ly = Mathf . Max ( 1e-4f , Mathf . Abs ( ls . y ) ) , lz = Mathf . Max ( 1e-4f , Mathf . Abs ( ls . z ) ) ;
go . transform . localScale = new Vector3 ( s * rule . arrowWidth / lx , s * rule . arrowWidth / ly , s / lz ) ;
go . transform . localPosition = new Vector3 ( 0f , 0f , ( rule . arrowTip - s * ( zmin < zmax ? zmax : 0f ) ) / lz ) ; // 촉(zmax)이 투사체 자리 + arrowTip
Arrows + + ; LastArrow = pb . name + " 화살 " + rule . arrowLength + " m (원본 " + len . ToString ( "F2" ) + " · 배율 " + s . ToString ( "F3" ) + ")" ;
}
2026-10-18 18:48:42 +00:00
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 + + ;
}
2026-10-20 12:36:12 +00:00
public static int Shortened , SpeedScaled ; public static string LastSpeed = "" ;
/// <summary>#952-③ 투사체 속도 배율 — OnSet(ProjectileBase.Set 안)에서 곱하면 파생 클래스(Projectile_Strait)가 base.Set 뒤 prefab m_Speed 로 덮어쓴다(바람쐐기 10402 = 7 · 실측) → 발사 프레임의 LateUpdate(이동 코루틴이 첫 이동을 하기 전)에 그 발사의 ProjectileData 에 1회 적용.</summary>
public sealed class WLProjectileSpeedMul : MonoBehaviour
{
public float mul = 1f ; ProjectileBase _pb ; bool _pending ; ProjectileData _last ;
public void Arm ( ProjectileBase pb ) { _pb = pb ; _pending = true ; }
void LateUpdate ( )
{
if ( ! _pending | | _pb = = null ) return ;
var pd = _pb . Get_ProjectTileData ( ) ; if ( pd = = null ) return ;
_last = pd ; _pending = false ; // 발사마다 Arm(OnSet) 이 다시 켠다 — 풀 인스턴스는 ProjectileData 객체를 재사용하므로(최초 로드 때 1회 new · Set 은 값 복사) 참조 비교로는 발사를 못 가른다(실측: 첫 발만 × 2)
if ( mul > 0f & & ! Mathf . Approximately ( mul , 1f ) ) { pd . Speed * = mul ; SpeedScaled + + ; LastSpeed = _pb . name + " 속도 " + pd . Speed . ToString ( "F1" ) ; }
}
}
/// <summary>#952-③ 길이만 L 배 — 늘림 빌보드 lengthScale·velocityScale(길이 = 크기 × lengthScale + 속도 × velocityScale) · 입자 궤적 수명 · TrailRenderer 시간(길이 = 시간 × 비행 속도 → 속도를 올리면 그만큼 길어지므로 함께 줄인다) · Mesh 모드 입자는 시스템 변환의 Z(진행 축 · 계층 배율). 굵기는 그대로.</summary>
static void ApplyLength ( GameObject go , float L )
{
if ( go = = null | | L < = 0f | | Mathf . Approximately ( L , 1f ) ) return ;
foreach ( var ps in go . GetComponentsInChildren < ParticleSystem > ( true ) )
{
var r = ps . GetComponent < ParticleSystemRenderer > ( ) ;
if ( r ! = null & & r . renderMode = = ParticleSystemRenderMode . Stretch ) { r . lengthScale * = L ; r . velocityScale * = L ; }
else if ( r ! = null & & r . renderMode = = ParticleSystemRenderMode . Mesh ) { var s = ps . transform . localScale ; ps . transform . localScale = new Vector3 ( s . x , s . y , s . z * L ) ; }
var m = ps . main ;
if ( m . simulationSpace = = ParticleSystemSimulationSpace . World ) m . startLifetime = Mul ( m . startLifetime , L ) ; // 월드 공간 발광(flares 0.6 s · lines 0.2 s)은 지나온 길에 남아 꼬리 = 수명 × 비행 속도(14 m/s 면 8 m · Play AABB 실측 4.3 m 가 length 와 무관하게 같았던 이유) → 수명도 같은 배율
var tr = ps . trails ; if ( tr . enabled ) tr . lifetime = Mul ( tr . lifetime , L ) ;
}
foreach ( var tr in go . GetComponentsInChildren < TrailRenderer > ( true ) ) tr . time * = L ;
Shortened + + ;
}
2026-10-18 18:48:42 +00:00
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 ;
}
}
2026-10-18 15:09:05 +00:00
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 ;
}
}
2026-10-31 06:53:50 +00:00
/// <summary>#990 단계 전용 겉모습 자식이 어느 프리팹으로 만들어졌는지(경로가 바뀌면 다시 만든다).</summary>
public sealed class WLStageVisualMark : MonoBehaviour { public string path = "" ; }
2026-10-18 15:09:05 +00:00
/// <summary>#917 투사체별 명중 이펙트 — WLImpactFx.TryHitEffect(원본 Show_HitEffect 훅)가 직업 규칙보다 먼저 묻는다. 몸 중심 · 몸 크기 비례(SizeFactor).</summary>
public sealed class WLProjectileHitFx : MonoBehaviour
{
public string prefab = "" ;
public float scale = 1f , life = 1.5f ;
2026-10-31 06:53:50 +00:00
public static int Shown ; public static string LastPrefab = "" ;
2026-10-18 15:09:05 +00:00
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 ) ) ;
2026-10-31 06:53:50 +00:00
Shown + + ; LastPrefab = prefab + " × " + go . transform . localScale . x . ToString ( "F2" ) ;
2026-10-18 15:09:05 +00:00
return true ;
}
}
}