2026-10-18 15:09:05 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// WLStaffEarth.cs — 지팡이(현자의 지팡이 · 클래스 10302) 주력 · 각성 · 3타를 대지(Stylized Earth Skills) WL 방식으로 (#935 · 2026-10-18)
/ /
// PD 원문(#935):
// ③ 「지팡이 주력 스킬 시전 시 캐릭터의 주문 시전 모션에 모으기 이펙트를 재생시키고, Casual RPG VFX 에셋의 첨부한 스크린샷과 같은 이펙트를 시전한 캐릭터몸에 맞게 발생시켜줘.」
// ④ 「지팡이 주력 스킬 시전 시 타겟이 된 대상에게 Stylized Earth Skills에셋의 "FX_EarthSkill_Summon_04" 이펙트를 재생시켜 공격하고
// 타겟이 된 대상 및 주변의 적에게도 광역 피해 및 스턴 효과를 1.5초간 부여해줘」
// ⑤ 「지팡이 각성 스킬 시전 시간도 0.5초로 줄이고, 주력 스킬과 동일하게 모으기 이펙트 및 캐릭터 주변 시전 이펙트를 적용해. 그리고 시전한 스킬을
// FX_Earthskill_Path_03_1을 캐릭터를 중심으로 시전시키켜 범위 내 명중 된 적을 2m 띄운 후 떨어질 때 2초간 기절 시키게 해줘.」
// ⑥ 「지팡이 스킬 3타에는 타겟이 된 대상에게 FX_EarthSkill_Popup_04 이펙트를 재생시켜줘. 이 공격에 맞은 적은 피해를 받을 때 1.5m 띄워졌다가 0.5초 기절해야 해.」
/ /
// ■ 시전 — CombatEvents.SkillCast(시전 시작)에 castSkills 의 스킬이면 무기 끝에 모으기(chargeFx) · 발밑에 몸 기둥(auraFx · 몸 크기 비례) — 둘 다 따라가고
// 시전 시간 + life 뒤 fade 초 동안 흐려진다
// ■ 주력 · 각성 — 원본 Set() 의 훅 1줄(TakeOver)이 원본 진행(소용돌이 15 초 · 운석 3.75 초)을 건너뛴다: center(대상 · 시전자) 발밑에 fx →
// hitDelay(+ 중심에서 거리 / spreadSpeed) 뒤 radius 안의 적에게 원본 투사체 ProcessCollider(스킬 피해 · 피격 반응) + WLLaunch(띄우기 · 기절) →
// endAfter 뒤 원본 투사체 끄기(풀). 진행 중엔 원본 겉모습 · 판정 상자 · Update 를 끈다(끈 뒤 되돌림)
// ■ 3타 — 투사체 Set 끝 훅(WLProjectileVisual.OnSet → OnProjectileSet)이 쏘는 순간 애니메이터 현재 상태가 popup.stateName(attack3)이면 그 투사체를 표시 →
// 그 투사체의 피해 확정(HitConfirmed)에 맞은 적 발밑에 popup fx + WLLaunch(띄우기 · 내려온 뒤 기절)
// ■ 값 = Resources/WL/WLStaffEarth.json (C45) · enabled 0 = 구독 0 · 훅은 false(원본 그대로)
// 🔴 Debug.LogError 금지(원본 ErrorLogHook 이 팝업을 띄운다) — 예외는 LastLog 로만
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections ;
using System.Collections.Generic ;
using System.Reflection ;
using UnityEngine ;
using WL.Combat.Core ;
namespace WL.Combat.Weapon
{
[System.Serializable]
public class WLStaffEarthFx
{
public string prefab = "" ;
public float scale = 1f ;
public int bodyScale ; // 1 = scale × 시전자 몸 크기 비례(WLImpactFx.SizeFactor)
public float [ ] offset = new float [ 3 ] ; // 시전자 정면 기준 (오른쪽, 위, 앞) m
public float [ ] euler = new float [ 3 ] ;
public float life = 2f ; // 남는 시간(초) — 시전 이펙트는 시전 시간 + life
public float fade = 0.4f ; // 0 보다 크면 마지막 fade 초 동안 흐려지며 사라진다(WLFxFade)
public int follow ; // 1 = 무기 끝 · 시전자를 따라간다
public string socket = "" ; // 모으기 무기 소켓 이름(빈 값 = 무기가 달린 켜진 첫 소켓)
2026-10-31 06:53:50 +00:00
public int head ; // #963 1 = 무기 메시 머리(로컬 경계 긴 축 먼 쪽 끝 − headPull · 끝 빛과 같은 점) · 0 = 이전(소켓 아래 렌더러 월드 상자 꼭대기 · 기울면 머리에서 벗어남) · 2 = 무기 든 손 소켓 · 3 = 빈 손 뼈(#999 · 3타 모으기만)
2026-10-21 09:37:16 +00:00
public float headPull = 0.08f ;
2026-10-31 06:53:50 +00:00
// #998 PD 「창 주력 스킬 이펙트 위치와 창의 위치가 맞지 않는 것 같아. 창을 내지른 방향에 맞게 이펙트위치를 수정해줘」 — alignWeapon 1 = 매 프레임 무기 축(자루 중심선 · 손 → 창끝)에 맞춰
// 돌리고 손에서 창 길이 × alignFraction 간 자리에 둔다(창 4타 찌르기와 같은 WLWeaponFollowFx · 음수 = 손 뒤 몸 쪽) · alignPitch 1 = 위아래 기울기도 따름 · toCamera = 카메라 쪽 m
public int alignWeapon ;
public float alignFraction ;
public int alignPitch = 1 ;
public float toCamera ; // #999 무기 축 추종이 아니어도 카메라 쪽 m(플레이어보다 앞 = 가림 · WLFxToCamera)
// #999 PD 「창 주력 스킬 시전시 이펙트가 반복 재생되는 느낌인데 이펙트가 꺼지는 느낌이 들지 않도록 중간 프레임 부분을 돌진 지속시간 동안 반복재생되다가 돌진 끝나는 시점에 맞춰서 완전히 이펙트가 종료되도록」
// sustain 1 = (돌진 감싸기 · 대쉬) 한 번만 띄우고 시간당 방출 층(버스트 없는 층)은 반복 = 중간 구간 유지 · 끝 − sustainTail 초에 방출 멈춤 · 끝에 남은 입자 지움 · speed = 입자 시뮬레이션 속도(버스트형 짧은 층을 돌진 동안 늘림)
public int sustain ;
public float sustainTail = 0.35f ;
public float speed = 1f ;
public float lightMul = 1f ; // #1006 이펙트에 붙은 점광원 세기 배율(손이 낮게 있는 지팡이 마지막 타 = 땅에 밝은 웅덩이 → 낮춤)
2026-10-18 15:09:05 +00:00
}
[System.Serializable]
public class WLStaffEarthSkill
{
public string skill = "" ; // 원본 스킬 클래스 이름(Skill_Hurricane · Skill_MeteoStorm)
2026-10-23 15:51:48 +00:00
public int classId ; // #975 0 = 모든 직업 · > 0 = 시전자 직업이 이 값일 때만(같은 스킬 클래스를 직업별로 다르게 — 해머 기본 = 운석 소환 복사본 → 대지 줄기) · 직업이 맞는 행이 0 행보다 먼저
2026-10-18 15:09:05 +00:00
public string center = "target" ; // target = 대상 발밑 · caster = 시전자 발밑
public WLStaffEarthFx fx = new WLStaffEarthFx ( ) ;
public float hitDelay = 0.4f ; // fx 뒤 판정까지(초)
public float spreadSpeed ; // 0 보다 크면 중심에서 거리 / 이 속도(m/s)만큼 판정이 늦어진다(퍼져 나가는 바위 줄기)
public float radius = 2.5f ; // 판정 반경(m · 적 몸 반경을 더함)
public float launchHeight ; // 띄우기 높이(m · 0 = 띄우지 않음)
public float launchSeconds = 0.6f ; // 떠 있는 시간(초)
public float stunSeconds ; // 내려온 뒤 기절(초)
public float endAfter = 1f ; // 마지막 판정 뒤 원본 투사체를 끄기까지(초)
2026-10-31 06:53:50 +00:00
public int noExtra ; // #991 1 = 원본 스킬 부가 효과(화상 등) 없이 피해만
public int [ ] weaponIds = new int [ 0 ] ; // #1000 비어 있지 않으면 장착 무기(그룹 번호)가 이 안일 때만(같은 직업 10303 = 정령의 셉터 9005 · 마법책 9009)
// #1000 PD 「마법책 각성 스킬 시전 시 AOE Explosions Pack 1 에셋의 첨부한 이펙트를 손바닥에서 전방으로 발사하는 형태로 구현해줘 (에셋은 하늘에서 떨어지는 형태이나, 손바닥에서 전방으로 날아가는 에너지 볼트 난사 컨셉) · 피격 이펙트는 일단 에셋에 있는 피격 이펙트로」
// boltCount 발을 boltInterval 초마다 빈 손(손바닥)에서 대상(없으면 정면 boltRange m) 쪽 ±boltSpread° 로 쏜다 · boltPrefab = 볼트 1발 사본(에셋 볼트 + 꼬리 · 수명 끝 = 착탄 하위 이미터)
// · 날아가는 시간 = 거리 / boltSpeed · 그 시각 도착 자리 boltRadius m 안 적에게 원본 투사체 피해(발마다 따로 셈)
public int boltCount ;
public float boltInterval = 0.08f , boltSpeed = 15f , boltSpread = 10f , boltRange = 8f , boltScale = 0.35f , boltRadius = 1.0f ;
public string boltPrefab = "" ;
// #1001 boltCurve = 휘는 정도(조절점 좌우 · 위 m) · boltHitFx = 도착 피격 이펙트 · boltMuzzleFx = 손바닥 발사 섬광(배율 따로)
public float boltCurve = 1.5f , boltHitScale = 0.15f , boltMuzzleScale = 0.15f ;
public float boltTrailWidth , boltTrailTime , boltOwnTrail ; // boltOwnTrail > 0 = 공 재질 리본 꼬리 너비(m) // #1001 꼬리 너비 배율(0 = boltScale) · 꼬리 시간(초 · 0 = 프리팹 값)
public string boltHitFx = "" , boltMuzzleFx = "" ;
// #1001 PD 「창 각성 스킬에 명중 된 적은 1m 정도 회오리가 전진하는 방향으로 밀려나며 공중에 뜨도록」 — pushDistance > 0 이면 띄움(launchHeight · launchSeconds · stunSeconds)을 그 방향 밀기와 함께(WLLaunch.FlingDir)
public float pushDistance ;
// #1001 PD 「대검 기본 스킬 회전하는 동안 검기 범위에 있는 적에게 최대 5회 연속 피격(피격 된 적이 조금씩 밀려야하고, 공중 회전 시 적이 밀리는 만큼 플레이어도 전진하며 회전) → 마지막 내려치기 공격 시 적이 1m정도 밀리며 0.5m 공중에 뜨게」
// spinHits 번 = 시전(SkillEvent = 점프 시작) 뒤 spinHitStart 초부터 spinHitInterval 초마다 PC 둘레 spinRadius m 적에게 원본 피해 × spinDamage + PC 정면으로 spinPush m · slamAt 초 = 착지 내려치기 정면 slamForward m 자리 radius m 적에게 원본 피해 + 정면 slamPush m · slamLaunch m 띄움(slamSeconds)
public int spinHits ;
public float spinHitStart = 0.05f , spinHitInterval = 0.09f , spinRadius = 1.8f , spinPush = 0.25f , spinDamage = 0.15f , slamAt , slamForward = 1.0f , slamPush , slamLaunch , slamSeconds = 0.35f ;
2026-10-21 09:37:16 +00:00
// #963 PD 「지팡이 각성 = Hyper Casual FX Pack Vol.2 메테오 — 타겟 대상 주위 5 m 에 운석 10개를 랜덤 낙하 · 낙하 자리의 적에게 피해 · 맞은 적 0.5 초 스턴」 —
// meteors > 0 = 메테오 방식: meteorInterval 마다 center 둘레 meteorArea(m) 원 안 무작위 자리에 fx(운석 1개 = 낙하 + 충돌)를 띄우고 meteorImpact 초 뒤(사본이 땅에 닿는 시각) 그 자리 radius 안 적에게 원본 투사체 피해 + 기절(stunSeconds) ·
// meteorYaw = 운석이 날아오는 쪽(카메라 수평 정면을 이 각만큼 돌린 방향 · 사본 로컬 +Z 위쪽 앞에서 떨어진다)
public int meteors ;
public float meteorArea = 5f , meteorInterval = 0.12f , meteorImpact = 0.67f , meteorYaw = 30f ;
2026-10-25 04:53:08 +00:00
// #983 PD 「오브 기본 스킬 시전 시 플레이어의 전방(공격 대상이 있는 방향)으로 번개 3발이 순차적으로 낙하하며 번개 피해가 전진하며 피해를 입히는 형태 (쾅, 쾅, 쾅! 0.3초 간격으로 전진 된 위치에 순차 낙하하는 번개)」 —
// meteorPattern "line" = 무작위 대신 시전 순간 시전자 자리에서 대상 쪽(없으면 정면) 직선 위 meteorStart + meteorStep × i(m) 자리에 차례로(이펙트 앞 = 그 방향)
public string meteorPattern = "" ;
public float meteorStart = 1.5f , meteorStep = 1.8f ;
2026-10-31 06:53:50 +00:00
// #1006 PD 「마법책 기본 스킬과 대검 기본 스킬 사용 후 지면에 충격 흔적이 남도록 해줘. (해머 공격에 사용 된 지면 충격 효과를 참고할 것)」 — traceFx = 판정 순간(메테오 = 땅에 닿는 시각 · 그 밖 = hitDelay 뒤) 그 자리 지면에 남기는 흔적
public WLStaffEarthFx traceFx = new WLStaffEarthFx ( ) ;
// #996 PD 「창 주력 = 찌르기 자세로 전방 돌진 · 돌진 이펙트로 몸을 감싼 채 4 m 전진 · 경로상 적을 밀치며 공격 · 돌진 중 방향 전환」 —
// rushDistance > 0 = 돌진 방식: rushHoldState 를 rushHoldNormalized 에 멈춘 자세로 rushSeconds 동안 rushDistance m(내비 안) · 가상패드로 rushTurnDegPerSec 회전 ·
// 몸 둘레 radius m 적 = rushHitInterval 마다 원본 투사체 피해 + 앞쪽 적은 같은 거리만큼 밀림(rushPush 배) · rushFx = 몸을 감싸는 이펙트(따라감)
public float rushDistance , rushSeconds = 0.6f , rushHitInterval = 0.2f , rushPush = 1f , rushTurnDegPerSec = 360f , rushHoldNormalized = 0.3f , rushSteerDeadzone = 0.2f ;
public string rushHoldState = "" ;
public WLStaffEarthFx rushFx = new WLStaffEarthFx ( ) ;
public WLStaffEarthFx rushFx2 = new WLStaffEarthFx ( ) ; // #997 몸 · 창 전체를 감싸는 이펙트(따라감) — rushFx2Every 초마다 다시 띄워 돌진 내내 보이게
public float rushFx2Every = 0.3f ;
public WLStaffEarthFx rushFx3 = new WLStaffEarthFx ( ) ; // #1000 PD 「돌진하는 캐릭터가 흙먼지를 일으키며 돌진 · Hyper Casual FX Pack Vol.1 의 첨부한 돌진 이펙트」 — 발밑에 rushFx3Every 초마다(따라가지 않음 = 지나온 길에 남음)
public float rushFx3Every = 0.2f ;
public float fanShrinkFrom = 1f ; // #997 비행 진행 비율이 이 값을 넘으면 끝까지 크기를 0 으로 줄이며 사라짐(1 = 줄이지 않음)
// #996 PD 「창 각성 = 회오리를 전방 부채꼴 방향으로 3 m 가량 날아가다 소멸 · 명중 적 2 m 띄움 → 낙하 → 2 초 기절」 —
// fanCount > 0 = 부채꼴 방식: 시전자 정면(대상 쪽) ±fanAngle/2 에 fanCount 개 fx 를 fanStart m 에서 fanDistance m 까지 fanSeconds 동안 날리고 끝에서 지움 ·
// 지나는 자리 radius m 적 = 한 번씩 피해 + launchHeight · launchSeconds · stunSeconds(기존 칸)
public int fanCount ;
public float fanAngle = 60f , fanStart = 0.6f , fanDistance = 3f , fanSeconds = 0.6f , fanFade = 0.25f ;
// #1002 PD 「마법책 주력 스킬 사용 시 Stylized Earth Skills 이펙트가 발동 된 후 주위의 적들이 해당 위치로 몰리게 하고(중력에 이끌리듯) 해당 이펙트가 소멸하는 시점에
// 지면에 FX_EarthSkill_Energy_01로 주변에 피해를 주며 적을 1초간 기절시키며 골렘이 생성되게 · 골렘은 용암 컨셉 · 크기 2배」 —
// pullSeconds > 0 = 중력 방식: center 에 pullFx(pullSeconds 동안) · pullRadius m 안 적을 중심으로 끌어당김(속도 = pullAccel × 지난 초 · 최대 pullMaxSpeed · 중심에서 pullStop m 에서 멈춤) →
// 끝에 fx(지면) + hitDelay 뒤 radius 안 적 피해 + stunSeconds 기절 → 원본 Set 을 다시 불러 소환(summonPrefab = 원본 prefab 칸 대신 · 빈 값 = 원본) ·
// 소환체가 켜지면 center 로 옮기고 원본 배율 × summonFit(모델 키 맞춤) × summonScale
public float pullSeconds , pullRadius = 5f , pullAccel = 12f , pullMaxSpeed = 8f , pullStop = 0.6f ;
public WLStaffEarthFx pullFx = new WLStaffEarthFx ( ) ;
public string summonPrefab = "" ;
public float summonFit = 1f , summonScale = 1f ;
// #1003 PD 「골렘의 공격 사거리를 근접처럼 줄이고, 몸 주변을 불타는 것처럼 · 2초에 한번 정도 공격 · 주위 0.5m이내 근접한 적에게 골렘 공격력의 1/10의 지속적인 피해」 — WLSummonMelee
public float summonMeleeGap = 0.3f , summonAttackInterval , summonAuraRadius = 0.5f , summonAuraRate , summonAuraTick = 0.5f ;
public WLStaffEarthFx summonBurn = new WLStaffEarthFx ( ) ; // 몸 불꽃(골렘 발밑 · 따라감 · 소환 시간 + life)
// #1004 PD 「다가가 멈추는 거리를 줄이라는 지시가 아니라 공격 판정 범위를 줄이라고 한 지시 (팔이 늘어나 공격하는 거리가 길기 때문에 길게 늘어지지 않게)」 · 「골렘 주위 불꽃 이펙트를 파티클 느낌의 다른 이펙트로」 —
// summonMeleeGap < 0 = 다가가 멈추는 거리 원본 그대로 · summonHitReach > 0 = 골렘 공격 판정 상자 앞 끝(몸 중심에서 m · WLHitboxFit) · summonBurnBones = 불꽃을 붙일 뼈 경로(비면 발밑 1개)
public float summonHitReach ;
public string [ ] summonBurnBones = new string [ 0 ] ;
}
/// <summary>#997 PD 「창 기본 · 각성 · 주력 스킬을 사용할 때 에너지가 모일 때 창을 빛나게」 — 이 스킬 · 직업 시전 시작부터 seconds 동안 무기 발광(rise 초 동안 켜짐 · 끝에서 fade 초 동안 꺼짐).</summary>
[System.Serializable]
public class WLSkillWeaponGlow
{
public string skill = "" ; public int classId ;
public float seconds = 0.6f , rise = 0.15f , fade = 0.25f , max = 1f , fill = 0.35f , rimPower = 2.5f ;
public float [ ] color = { 1.6f , 0.75f , 0.08f } ;
2026-10-18 15:09:05 +00:00
}
2026-10-21 15:12:29 +00:00
/// <summary>#966 PD 「비수 각성 스킬에 시전 모션 및 시전 이펙트 — Hyper Casual FX Pack Vol.2 스크린샷 이펙트를 캐릭터에게 한번 재생시킨 후 발동」 — 이 원본 스킬(e_Skill)의 시전(SkillCast) 순간 시전자에게 fx(시전 시간 + life · follow 1 = 따라감)</summary>
[System.Serializable]
public class WLSkillCastFx
{
public string skill = "" ;
2026-10-31 06:53:50 +00:00
public int classId ; // #996 0 = 모든 직업 · > 0 = 이 직업일 때만(같은 원본 스킬을 다른 직업 그릇으로 쓸 때)
2026-10-21 15:12:29 +00:00
public WLStaffEarthFx fx = new WLStaffEarthFx ( ) ;
}
2026-10-18 15:09:05 +00:00
[System.Serializable]
public class WLStaffEarthPopup
{
public int classId = 10302 ;
public string stateName = "attack3" ; // 쏘는 순간 애니메이터 현재 상태 이름(지팡이 콤보는 애니메이터 전환으로 이어져 CurAnim · ComboTracker 단계가 1 에 머문다)
public WLStaffEarthFx fx = new WLStaffEarthFx ( ) ;
public float launchHeight = 1.5f , launchSeconds = 0.6f , stunSeconds = 0.5f ;
2026-10-21 16:30:22 +00:00
// #967 PD 「지팡이 공격 마지막 타 — 기본 공격 투사체 대신 메테오(각성 스킬에 사용중인 이펙트) 한 발이 대상에게 떨어지도록」 — 1 = 3타 투사체를 쏘는 순간(겉모습을 붙인 뒤) 넘겨받아
// 숨기고 meteorRule(메테오 방식 · 운석 1개 · 대상 자리)로 떨어뜨려 땅에 닿는 순간 그 투사체 피해 · 이 경우 바위 솟음(fx)은 띄우지 않는다 · 0 = 이전(투사체 + 맞은 자리 바위)
public int meteor ;
public WLStaffEarthSkill meteorRule = new WLStaffEarthSkill ( ) ;
2026-10-25 04:53:08 +00:00
// #983 PD 「오브 "기본 공격" 마지막 타(3타)에서는 공격 직후 다시 모션을 캔슬하는 모습인데, 공격 상태의 손을 내지른 상태를 잠시 유지한 채 투사체를 날리지 말고 타겟중인 대상에게 번개가 1발을 낙하」 —
// holdSeconds > 0 = 넘겨받는 순간(발사 시점) holdDelay 뒤 애니메이터를 holdSeconds 동안 멈춤(자세 유지 · 다른 상태로 바뀌거나 다른 코드가 속도를 바꾸면 바로 손 뗌)
public float holdDelay , holdSeconds ;
2026-10-31 06:53:50 +00:00
// #994 PD 「지팡이 마지막타 공격 전 짧게 에너지를 모으는 대기 시간과 연출을 부여」 — preChargeSeconds > 0 = 그 상태(stateName)로 넘어가는 첫 프레임(발사 이벤트 전 ·
// 그 상태 시각 preChargeMaxIn 초 안)에 애니메이터를 preChargeSeconds 동안 멈추고 무기 머리에 preChargeFx(시전 모으기와 같은 형식) — 다른 코드가 속도를 바꾸면 손 뗌
public float preChargeSeconds , preChargeMaxIn = 0.05f ;
public WLStaffEarthFx preChargeFx = new WLStaffEarthFx ( ) ;
public int [ ] weaponIds = new int [ 0 ] ; // #1002 비어 있지 않으면 장착 무기(그룹 번호)가 이 안일 때만(같은 직업 10303 = 정령의 셉터 9005 · 마법책 9009 — PD 「마법책은 4타 공격 후 마지막 타에 FX_Splash_energy_01_floor」)
2026-10-18 15:09:05 +00:00
}
[System.Serializable]
public class WLStaffEarthJson
{
public int enabled = 0 ;
public string [ ] castSkills = new string [ 0 ] ;
2026-10-31 06:53:50 +00:00
public int [ ] castSkipClasses = new int [ 0 ] ; // #1007 castSkills 이름이 같아도 이 직업은 chargeFx · auraFx 를 띄우지 않음(창 주력 = Skill_Hurricane → 창 끝에 지팡이 모으기 빛이 뭉침)
2026-10-18 15:09:05 +00:00
public WLStaffEarthFx chargeFx = new WLStaffEarthFx ( ) ;
public WLStaffEarthFx auraFx = new WLStaffEarthFx ( ) ;
public WLStaffEarthSkill [ ] skills = new WLStaffEarthSkill [ 0 ] ;
public WLStaffEarthPopup popup = new WLStaffEarthPopup ( ) ;
2026-10-25 04:53:08 +00:00
public WLStaffEarthPopup [ ] popups = new WLStaffEarthPopup [ 0 ] ; // #983 popup 과 같은 규칙을 다른 직업에(오브 10301 3타 = 번개 1발 · 자세 유지) — 직업 · 상태가 맞는 첫 규칙(popup 먼저)
2026-10-21 09:37:16 +00:00
// #963 PD 「플레이어가 마법을 시전할 때는 Hyper Casual FX Pack Vol.1 의 마법진을 플레이어가 있는 바닥에 재생되었다가 시전(주황색 계열)」 — castCircleClasses 직업의 스킬 시전(SkillCast)마다 발밑에 castCircle(시전 시간 + life · 따라감)
public int [ ] castCircleClasses = new int [ 0 ] ;
public WLStaffEarthFx castCircle = new WLStaffEarthFx ( ) ;
2026-10-21 15:12:29 +00:00
public WLSkillCastFx [ ] skillCastFx = new WLSkillCastFx [ 0 ] ; // #966 스킬별 시전 이펙트(시전자)
2026-10-31 06:53:50 +00:00
public WLSkillWeaponGlow [ ] weaponGlow = new WLSkillWeaponGlow [ 0 ] ; // #997 스킬 시전(모으기) 동안 무기 발광(WLWeaponGlowFx · 대검 · 비수와 같은 재질)
public string weaponGlowMaterial = "WL/Fx/WL_Mat_WeaponGlow" ;
2026-10-18 15:09:05 +00:00
}
public static class WLStaffEarth
{
public const string ResourcesPath = "WL/WLStaffEarth" ;
2026-10-31 06:53:50 +00:00
public static int Casts , TakeOvers , Hits , Launches , Marked , Popups , Errors , Circles , Meteors , SkillCastFxShown , Attack3Meteors , NoExtra ; // #967 Attack3Meteors = 3타 투사체를 메테오로 넘겨받은 수
2026-10-21 16:30:22 +00:00
static ProjectileBase s_pendingMeteor ; // #967 OnProjectileSet 이 고른 3타 투사체 — 겉모습 훅이 끝난 뒤(AfterProjectileSet) 넘겨받는다
2026-10-25 04:53:08 +00:00
static WLStaffEarthPopup s_pendingPopup ; // #983 그 투사체에 맞은 3타 규칙(popup · popups)
public static int Holds ; public static string HoldLog = "" ; // #983 3타 자세 유지 횟수 · 마지막 기록
2026-10-21 13:43:29 +00:00
public static string MeteorSrc = "" ; public static Vector3 MeteorCenter ; public static readonly List < Vector3 > MeteorPts = new List < Vector3 > ( ) , MeteorCenters = new List < Vector3 > ( ) ; // #964 검증용(메테오 중심 출처 · 낙하 자리)
2026-10-18 15:09:05 +00:00
public static string LastLog = "" ;
static WLStaffEarthJson s_cfg ;
static readonly Dictionary < string , GameObject > s_prefabs = new Dictionary < string , GameObject > ( ) ;
2026-10-25 04:53:08 +00:00
static readonly Dictionary < int , WLStaffEarthPopup > s_marked = new Dictionary < int , WLStaffEarthPopup > ( ) ;
2026-10-18 15:09:05 +00:00
static WLStaffEarthRunner s_runner ;
static MethodInfo s_off ;
static WLStaffEarthJson Cfg
{
get
{
if ( s_cfg ! = null ) return s_cfg ;
s_cfg = new WLStaffEarthJson ( ) ;
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 ( )
{
2026-10-31 06:53:50 +00:00
s_cfg = null ; s_prefabs . Clear ( ) ; s_marked . Clear ( ) ; s_pendingMeteor = null ; s_pendingPopup = null ; s_preAn = null ; s_preArmed = true ;
2026-10-18 15:09:05 +00:00
CombatEvents . SkillCast . Remove ( OnSkillCast ) ; CombatEvents . HitConfirmed . Remove ( OnHitConfirmed ) ;
if ( Cfg . enabled = = 0 ) return ;
CombatEvents . SkillCast . Add ( OnSkillCast ) ; CombatEvents . HitConfirmed . Add ( OnHitConfirmed ) ;
}
// ── 시전: 모으기(무기 끝) · 몸 기둥(발밑) ──────────────────────────────────────────
static void OnSkillCast ( in SkillCastEvent e )
{
try
{
var c = Cfg ; var pc = e . actor as PCActor ;
2026-10-21 09:37:16 +00:00
if ( pc = = null | | e . skill = = null ) return ;
2026-10-18 15:09:05 +00:00
float cast = Mathf . Max ( 0f , e . skill . f_CastingTime ) ;
2026-10-21 09:37:16 +00:00
if ( pc . IsMainPC ( ) & & c . castCircleClasses ! = null & & System . Array . IndexOf ( c . castCircleClasses , pc . Get_ID ( ) ) > = 0 & & Spawn ( c . castCircle , pc , pc . transform , pc . Get_position ( ) , cast ) ! = null ) Circles + + ; // #963 마법 시전 마법진
2026-10-21 15:12:29 +00:00
if ( pc . IsMainPC ( ) & & c . skillCastFx ! = null ) // #966 스킬별 시전 이펙트(비수 각성 = HCFX Energy_01 사본)
2026-10-31 06:53:50 +00:00
foreach ( var sf in c . skillCastFx ) if ( sf ! = null & & sf . skill = = e . skill . e_Skill . ToString ( ) & & ( sf . classId = = 0 | | sf . classId = = pc . Get_ID ( ) ) & & Spawn ( sf . fx , pc , pc . transform , pc . Get_position ( ) , cast ) ! = null ) SkillCastFxShown + + ;
if ( pc . IsMainPC ( ) & & c . weaponGlow ! = null ) // #997 무기 발광
foreach ( var g in c . weaponGlow ) if ( g ! = null & & g . skill = = e . skill . e_Skill . ToString ( ) & & ( g . classId = = 0 | | g . classId = = pc . Get_ID ( ) ) ) { Runner ( ) . StartCoroutine ( GlowRun ( pc , g , c . weaponGlowMaterial ) ) ; Glows + + ; }
2026-10-21 09:37:16 +00:00
if ( c . castSkills = = null | | System . Array . IndexOf ( c . castSkills , e . skill . e_Skill . ToString ( ) ) < 0 ) return ;
2026-10-31 06:53:50 +00:00
if ( c . castSkipClasses ! = null & & System . Array . IndexOf ( c . castSkipClasses , pc . Get_ID ( ) ) > = 0 ) return ; // #1007
2026-10-18 15:09:05 +00:00
var sock = Socket ( pc , c . chargeFx . socket ) ;
2026-10-21 09:37:16 +00:00
if ( Spawn ( c . chargeFx , pc , sock , sock ! = null ? Tip ( sock , c . chargeFx ) : pc . Get_Center_position ( ) , cast ) ! = null ) Casts + + ;
2026-10-18 15:09:05 +00:00
Spawn ( c . auraFx , pc , pc . transform , pc . Get_position ( ) , cast ) ;
}
catch ( System . Exception ex ) { Errors + + ; LastLog = "시전 이펙트 실패 — " + ex . Message ; }
}
2026-10-31 06:53:50 +00:00
public static int Glows ;
static WLStaffEarthRunner Runner ( )
{
if ( s_runner = = null ) { var go = new GameObject ( "[WL935] StaffEarth" ) ; go . hideFlags = HideFlags . HideInHierarchy ; Object . DontDestroyOnLoad ( go ) ; s_runner = go . AddComponent < WLStaffEarthRunner > ( ) ; }
return s_runner ;
}
static IEnumerator GlowRun ( PCActor pc , WLSkillWeaponGlow g , string mat )
{
var sock = Socket ( pc , "" ) ; if ( sock = = null ) yield break ;
var fx = new WLWeaponGlowFx ( ) ; if ( fx . Begin ( sock , mat ) < = 0 ) yield break ;
var col = g . color ! = null & & g . color . Length > = 3 ? new Color ( g . color [ 0 ] , g . color [ 1 ] , g . color [ 2 ] , 1f ) : Color . white ;
float t = 0f , total = Mathf . Max ( 0.05f , g . seconds ) ;
try
{
while ( t < total & & pc ! = null & & ! pc . IsDead ( ) )
{
float k = Mathf . Min ( 1f , t / Mathf . Max ( 0.01f , g . rise ) ) * Mathf . Min ( 1f , ( total - t ) / Mathf . Max ( 0.01f , g . fade ) ) ;
fx . Set ( g . max * k , col , g . fill , g . rimPower ) ;
yield return null ; t + = Time . deltaTime ;
}
}
finally { fx . End ( ) ; }
}
2026-10-18 15:09:05 +00:00
/// <summary>무기 소켓 — 이름이 맞는 켜진 소켓, 없으면 무기(자식)가 달린 켜진 첫 소켓.</summary>
2026-10-31 06:53:50 +00:00
/// <summary>#999 PD 「여전히 지팡이 마지막타가 지팡이를 든 손이 빛나고 있어. 빈 손에서 에너지가 모여야 해」 — 빈 손 = Humanoid 왼손 · 오른손 뼈 중 무기 소켓에서 먼 쪽(이 PC 지팡이 = w_righthand → 왼손).</summary>
internal static Transform EmptyHand ( PCActor pc )
{
var an = pc ! = null ? pc . m_animation : null ; if ( an = = null | | ! an . isHuman ) return null ;
Transform l = an . GetBoneTransform ( HumanBodyBones . LeftHand ) , r = an . GetBoneTransform ( HumanBodyBones . RightHand ) , s = Socket ( pc , "" ) ;
if ( l = = null | | r = = null ) return l ? ? r ;
if ( s = = null ) return l ;
return Vector3 . Distance ( s . position , l . position ) > = Vector3 . Distance ( s . position , r . position ) ? l : r ;
}
internal static Transform Socket ( PCActor pc , string name ) // #998 WLProjectileVisual fromSocket 도 쓴다(3타 모으기 빛과 같은 손)
2026-10-18 15:09:05 +00:00
{
if ( pc = = null | | pc . tfs_weapon = = null ) return null ;
Transform first = null ;
foreach ( var s in pc . tfs_weapon )
{
if ( s = = null | | ! s . gameObject . activeInHierarchy ) continue ;
if ( ! string . IsNullOrEmpty ( name ) & & s . name = = name ) return s ;
if ( first = = null & & s . childCount > 0 ) first = s ;
}
return first ;
}
/// <summary>무기 끝(소켓 아래 렌더러 경계의 가장 높은 곳) — 렌더러가 없으면 소켓 자리.</summary>
2026-10-21 09:37:16 +00:00
static Vector3 Tip ( Transform sock , WLStaffEarthFx fx )
2026-10-18 15:09:05 +00:00
{
2026-10-31 06:53:50 +00:00
if ( fx ! = null & & ( fx . head = = 2 | | fx . head = = 3 ) ) return sock . position ; // #997 2 = 소켓(무기 든 손) 자리 — PD 「지팡이가 아니라 손이 빛나게」 · #999 3 = 빈 손 뼈(부르는 쪽이 EmptyHand 를 넘김)
2026-10-21 09:37:16 +00:00
if ( fx ! = null & & fx . head ! = 0 ) // #963 무기 메시 머리(끝 빛과 같은 점) — 이전 방식은 기운 지팡이에서 월드 상자 꼭대기 · 가운데라 머리에서 떨어졌다(PD 「모으는 연출이 둥근 머리에 안 모인다」)
for ( int i = 0 ; i < sock . childCount ; i + + )
{
var w = sock . GetChild ( i ) ; if ( ! w . gameObject . activeInHierarchy ) continue ;
Vector3 hl ; if ( WLWeaponTipGlow . HeadLocal ( w , sock , fx . headPull , out hl ) ) return w . TransformPoint ( hl ) ;
}
2026-10-18 15:09:05 +00:00
bool any = false ; Bounds b = new Bounds ( sock . position , Vector3 . zero ) ;
foreach ( var r in sock . GetComponentsInChildren < Renderer > ( false ) )
{
if ( r is ParticleSystemRenderer | | ! r . enabled ) continue ;
if ( ! any ) { b = r . bounds ; any = true ; } else b . Encapsulate ( r . bounds ) ;
}
return any ? new Vector3 ( b . center . x , b . max . y , b . center . z ) : sock . position ;
}
static GameObject Spawn ( WLStaffEarthFx fx , Actor actor , Transform parent , Vector3 pos , float extra )
{
var fwd = actor ! = null ? actor . transform . forward : Vector3 . forward ;
var go = SpawnAt ( fx , actor , pos , fwd , extra ) ;
if ( go ! = null & & fx . follow ! = 0 & & parent ! = null ) go . transform . SetParent ( parent , true ) ; // 월드 크기 · 자리 유지한 채 따라감
return go ;
}
internal static GameObject SpawnAt ( WLStaffEarthFx fx , Actor actor , Vector3 pos , Vector3 fwd , float extra )
{
if ( fx = = null | | string . IsNullOrEmpty ( fx . prefab ) ) return null ;
var p = Prefab ( fx . prefab ) ; if ( p = = null ) return null ;
fwd . y = 0f ; if ( fwd . sqrMagnitude < 1e-4f ) fwd = Vector3 . forward ;
var basis = Quaternion . LookRotation ( fwd . normalized ) ;
var go = Object . Instantiate ( p , pos + basis * V ( fx . offset ) , basis * Quaternion . Euler ( V ( fx . euler ) ) ) ;
float s = fx . scale * ( fx . bodyScale ! = 0 & & actor ! = null ? WL . Combat . Impact . WLImpactFx . SizeFactor ( actor ) : 1f ) ;
foreach ( var ps in go . GetComponentsInChildren < ParticleSystem > ( true ) ) { var m = ps . main ; m . scalingMode = ParticleSystemScalingMode . Hierarchy ; }
go . transform . localScale = Vector3 . one * s ;
float total = Mathf . Max ( 0f , extra ) + Mathf . Max ( 0.1f , fx . life ) ;
if ( fx . fade > 0f ) WLFxFade . Attach ( go , Mathf . Max ( 0f , total - fx . fade ) , fx . fade ) ;
else Object . Destroy ( go , total ) ;
LastLog = "생성 " + fx . prefab + " @" + pos . ToString ( "F1" ) + " × " + s . ToString ( "F2" ) ;
2026-10-31 06:53:50 +00:00
if ( fx . alignWeapon ! = 0 & & actor is PCActor apc ) AlignToWeapon ( go , apc , fx , s ) ;
else if ( fx . toCamera > 0f ) go . AddComponent < WLFxToCamera > ( ) . dist = fx . toCamera ;
if ( ! Mathf . Approximately ( fx . speed , 1f ) & & fx . speed > 0f ) foreach ( var ps in go . GetComponentsInChildren < ParticleSystem > ( true ) ) { var m = ps . main ; m . simulationSpeed * = fx . speed ; }
if ( ! Mathf . Approximately ( fx . lightMul , 1f ) ) foreach ( var l in go . GetComponentsInChildren < Light > ( true ) ) l . intensity * = Mathf . Max ( 0f , fx . lightMul ) ; // #1006 (WLFxFade 는 흐려지기 시작할 때의 세기를 기준으로 낮춤)
2026-10-18 15:09:05 +00:00
return go ;
}
2026-10-31 06:53:50 +00:00
public static int Sustained , SustainLoops , Traces ;
/// <summary>#999 시간당 방출 층(버스트 없는 층)만 반복으로 — 돌진 내내 중간 구간이 이어진다(버스트 층은 처음 1번 · 반복하면 번쩍임이 되풀이).</summary>
internal static void Sustain ( GameObject go )
{
if ( go = = null ) return ; Sustained + + ;
foreach ( var ps in go . GetComponentsInChildren < ParticleSystem > ( true ) )
{
var em = ps . emission ; if ( ! em . enabled | | em . rateOverTime . constantMax < 5f ) continue ;
bool burst = false ; for ( int i = 0 ; i < em . burstCount ; i + + ) if ( em . GetBurst ( i ) . count . constantMax > 0f ) burst = true ;
if ( burst ) continue ;
var m = ps . main ; m . loop = true ; SustainLoops + + ;
}
}
/// <summary>#999 방출만 멈춤(살아 있는 입자는 수명대로 사라짐).</summary>
internal static void StopEmit ( GameObject go ) { if ( go = = null ) return ; foreach ( var ps in go . GetComponentsInChildren < ParticleSystem > ( true ) ) ps . Stop ( false , ParticleSystemStopBehavior . StopEmitting ) ; }
/// <summary>#999 남은 입자까지 지우고 없앤다(돌진 끝 = 이펙트 끝).</summary>
internal static void ClearEnd ( GameObject go ) { if ( go = = null ) return ; foreach ( var ps in go . GetComponentsInChildren < ParticleSystem > ( true ) ) ps . Stop ( false , ParticleSystemStopBehavior . StopEmittingAndClear ) ; Object . Destroy ( go ) ; }
public static int Aligned , AlignFailed , Dusts ;
public static int BoltsShot ;
static bool WeaponOk ( WLStaffEarthSkill x , int wid ) { return x . weaponIds = = null | | x . weaponIds . Length = = 0 | | System . Array . IndexOf ( x . weaponIds , wid ) > = 0 ; }
static int EquippedWeaponId ( ) { var wi = WL . Equip . WLInventory . EquippedInSlot ( WL . Equip . eFREquipmentType . Weapon ) ; return wi ! = null & & wi . itemJson ! = null ? wi . itemJson . GroupNo : 0 ; }
internal static bool SummonPass ; // #1002 true 인 동안 TakeOver 는 원본에 넘긴다(중력 끝 소환)
public static int Pulled , PullFrames , Summons ; public static string SummonLog = "" ;
static Actor s_sumOwner ; static Vector3 s_sumAt ; static Quaternion s_sumRot ; static float s_sumMul = 1f , s_sumUntil ; static WLStaffEarthSkill s_sumRule ; static ProjectileData s_sumPd ;
internal static SummonActor LastSummon ;
internal static void ReserveSummon ( Actor owner , Vector3 at , Quaternion rot , float mul , WLStaffEarthSkill rule , ProjectileData pd ) { s_sumOwner = owner ; s_sumAt = at ; s_sumRot = rot ; s_sumMul = mul ; s_sumRule = rule ; s_sumPd = pd ; s_sumUntil = Time . time + 3f ; LastSummon = null ; }
/// <summary>#1002 원본 소환 콜백 훅(Skill_Golem 의 summon.Set 바로 뒤 1줄) — 중력 진행이 예약한 소환(주인 · 3 s 안)이면 보이기 전 같은 프레임에 자리 · 방향 · 배율을 바꾼다. 예약이 없으면 아무것도 안 함(원본 그대로).</summary>
public static void AfterSummon ( SummonActor s )
{
if ( s = = null | | s_sumOwner = = null | | Time . time > s_sumUntil | | s . Get_Owner ( ) ! = s_sumOwner ) return ;
s . Set_Warp ( s_sumAt ) ; s . transform . rotation = s_sumRot ; s . transform . localScale * = s_sumMul ;
WLSummonMelee . Arm ( s , s_sumRule , s_sumPd ! = null ? s_sumPd . LifeTime : 30f ) ; // #1003 근접 · 공격 간격 · 몸 불꽃 · 둘레 지속 피해
s_sumOwner = null ; LastSummon = s ;
}
/// <summary>#998 이펙트를 무기 축에 붙인다(WLWeaponFollowFx = 창 4타 찌르기 규칙과 같은 계산 · 손 → 창끝 · 자루 중심선).</summary>
static void AlignToWeapon ( GameObject go , PCActor pc , WLStaffEarthFx fx , float scale )
{
var rule = new WL . Combat . Impact . WLSwingFxRule { alignWeapon = true , alignAxis = true , alignPitch = fx . alignPitch ! = 0 , alignFraction = fx . alignFraction , toCamera = fx . toCamera , euler = fx . euler , life = fx . life , massLocal = new float [ 3 ] } ;
var fw = go . AddComponent < WL . Combat . Impact . WLWeaponFollowFx > ( ) ;
if ( fw . Arm ( pc , rule , scale ) ) Aligned + + ; else { AlignFailed + + ; Object . Destroy ( fw ) ; }
}
2026-10-18 15:09:05 +00:00
// ── 주력 · 각성: 원본 Set() 훅 ─────────────────────────────────────────────────────
/// <summary>원본 Set() 훅(1줄) — 표에 있는 스킬이면 원본 진행을 건너뛰고 WL 진행을 시작한다(true). 꺼져 있거나 표에 없으면 false(원본 그대로).</summary>
public static bool TakeOver ( ProjectileBase pb )
{
try
{
var c = Cfg ; if ( c . enabled = = 0 | | pb = = null ) return false ;
var pd = pb . Get_ProjectTileData ( ) ; if ( pd = = null | | DSUtil . CheckNull ( pd . shooter ) ) return false ;
2026-10-23 15:51:48 +00:00
WLStaffEarthSkill r = null ; int cls = pd . shooter . Get_ID ( ) ; // #975 직업이 맞는 행 먼저 · 없으면 classId 0 행
2026-10-31 06:53:50 +00:00
if ( SummonPass ) return false ; // #1002 중력 끝에 원본 소환을 다시 부르는 중
int wid = EquippedWeaponId ( ) ; // #1000 같은 직업 다른 무기 구분
if ( c . skills ! = null ) foreach ( var x in c . skills ) if ( x ! = null & & x . skill = = pb . GetType ( ) . Name & & x . classId = = cls & & WeaponOk ( x , wid ) ) { r = x ; break ; }
if ( r = = null & & c . skills ! = null ) foreach ( var x in c . skills ) if ( x ! = null & & x . skill = = pb . GetType ( ) . Name & & x . classId = = 0 & & WeaponOk ( x , wid ) ) { r = x ; break ; }
2026-10-23 15:51:48 +00:00
if ( r = = null ) return false ;
2026-10-31 06:53:50 +00:00
if ( r . noExtra ! = 0 ) { pd . e_SkillExtraType = eSkillExtraType . None ; pd . m_skillTypeConfigTable = null ; pd . SetSkillExtraType = false ; NoExtra + + ; } // #991 PD 「오브 기본 스킬 시전 시 몬스터에게 불타는 이펙트가 발생하지 않도록(화상 효과 X)」 — 이 발사의 원본 부가 효과(운석 소환 = Burn)를 지움 · Actor.Check_Extra 가 None 이면 바로 돌아간다(#977 활 멀티샷과 같은 방식)
2026-10-18 15:09:05 +00:00
if ( s_runner = = null ) { var go = new GameObject ( "[WL935] StaffEarth" ) ; go . hideFlags = HideFlags . HideInHierarchy ; Object . DontDestroyOnLoad ( go ) ; s_runner = go . AddComponent < WLStaffEarthRunner > ( ) ; }
s_runner . Begin ( pb , pd , r ) ;
TakeOvers + + ;
return true ;
}
catch ( System . Exception ex ) { Errors + + ; LastLog = "넘겨받기 실패 — " + ex . Message ; return false ; }
}
/// <summary>center 에서 reach(≤ radius) 안의 적(아직 안 맞은)에게 원본 투사체 피해 + 띄우기 · 기절.</summary>
2026-10-31 06:53:50 +00:00
internal static void HitAll ( ProjectileBase pb , Actor shooter , Vector3 center , WLStaffEarthSkill r , float reach , HashSet < Actor > done , Vector3 pushDir = default ( Vector3 ) )
2026-10-18 15:09:05 +00:00
{
var info = ActorInfo . Ins ; if ( info = = null | | pb = = null | | DSUtil . CheckNull ( shooter ) ) return ;
var list = info . Get_EnemyActors ( shooter . IsEnemy ( ) , center , r . radius + 3f ) ; if ( list = = null ) return ;
try
{
for ( int k = 0 ; k < list . Count ; k + + )
{
var a = list [ k ] ; if ( a = = null | | a . IsDead ( ) | | done . Contains ( a ) ) continue ;
var v = a . Get_position ( ) - center ; v . y = 0f ;
if ( v . magnitude > Mathf . Min ( reach , r . radius ) + WL . Combat . WLCombatMotionSettings . BodyRadius ( a ) ) continue ;
var col = a . GetComponent < Collider > ( ) ; if ( col = = null | | ! col . enabled ) continue ;
done . Add ( a ) ;
if ( ! pb . ProcessCollider ( col , false ) ) continue ;
Hits + + ;
2026-10-31 06:53:50 +00:00
if ( r . pushDistance > 0f & & pushDir . sqrMagnitude > 1e-4f ) { WLLaunch . FlingDir ( a , pushDir , r . pushDistance , Mathf . Max ( 0.1f , r . launchSeconds ) , r . launchHeight , r . stunSeconds , false ) ; Launches + + ; } // #1001 진행 방향으로 밀며 띄움
else if ( ( r . launchHeight > 0f | | r . stunSeconds > 0f ) & & WLLaunch . Apply ( a , r . launchHeight , r . launchSeconds , r . stunSeconds ) ) Launches + + ;
2026-10-18 15:09:05 +00:00
}
}
finally { info . Release_Actors ( list ) ; }
}
/// <summary>원본 투사체 끄기(풀 반납) — ProjectileBase.Off(bool, bool) 는 protected 라 리플렉션.</summary>
internal static void OffOriginal ( ProjectileBase pb )
{
if ( pb = = null | | ! pb . gameObject . activeInHierarchy ) return ;
if ( s_off = = null ) s_off = typeof ( ProjectileBase ) . GetMethod ( "Off" , BindingFlags . Instance | BindingFlags . NonPublic | BindingFlags . Public , null , new [ ] { typeof ( bool ) , typeof ( bool ) } , null ) ;
try { if ( s_off ! = null ) s_off . Invoke ( pb , new object [ ] { true , false } ) ; else pb . gameObject . SetActive ( false ) ; }
catch ( System . Exception ex ) { Errors + + ; LastLog = "Off 실패 — " + ex . Message ; pb . gameObject . SetActive ( false ) ; }
}
// ── 3타: 쏘는 순간 표시 → 피해 확정에 바위 솟음 · 띄우기 · 기절 ─────────────────────
/// <summary>투사체 Set 끝 훅(WLProjectileVisual.OnSet 첫 줄) — 쏘는 순간 애니메이터 상태가 popup.stateName 이면 그 투사체(인스턴스)를 표시 · 아니면 지운다(풀 재사용).</summary>
public static void OnProjectileSet ( ProjectileBase pb )
{
try
{
var c = Cfg ; if ( c . enabled = = 0 | | pb = = null ) return ;
var pd = pb . Get_ProjectTileData ( ) ; var sh = pd ! = null ? pd . shooter as PCActor : null ;
var an = sh ! = null ? sh . m_animation : null ;
2026-10-31 06:53:50 +00:00
WLStaffEarthPopup hit = null ; int wid = EquippedWeaponId ( ) ;
2026-10-25 04:53:08 +00:00
if ( sh ! = null & & ! pd . IsSkill ( ) & & an ! = null )
for ( int i = - 1 ; i < ( c . popups ! = null ? c . popups . Length : 0 ) & & hit = = null ; i + + ) // #983 popup 먼저 · 이어서 popups(직업별)
{
var p = i < 0 ? c . popup : c . popups [ i ] ; if ( p = = null | | sh . Get_ID ( ) ! = p . classId ) continue ;
2026-10-31 06:53:50 +00:00
if ( p . weaponIds ! = null & & p . weaponIds . Length > 0 & & System . Array . IndexOf ( p . weaponIds , wid ) < 0 ) continue ; // #1002 같은 직업 다른 무기
2026-10-25 04:53:08 +00:00
// #967 3타 발사 이벤트(attack3 0.08 s)가 attack2 → attack3 전환 중에 나와 현재 상태가 아직 attack2 였다(Play 실측 3타 표시 0) → 전환 중이면 다음 상태도 본다
if ( an . GetCurrentAnimatorStateInfo ( 0 ) . IsName ( p . stateName ) | | ( an . IsInTransition ( 0 ) & & an . GetNextAnimatorStateInfo ( 0 ) . IsName ( p . stateName ) ) ) hit = p ;
}
bool meteor = hit ! = null & & hit . meteor ! = 0 & & hit . meteorRule ! = null & & hit . meteorRule . meteors > 0 ;
if ( meteor ) { s_pendingMeteor = pb ; s_pendingPopup = hit ; } // #967 3타 = 메테오(바위 솟음 표시 안 함)
if ( hit ! = null & & ! meteor ) { s_marked [ pb . GetInstanceID ( ) ] = hit ; Marked + + ; } else s_marked . Remove ( pb . GetInstanceID ( ) ) ;
2026-10-18 15:09:05 +00:00
}
catch ( System . Exception ex ) { Errors + + ; LastLog = "3타 표시 실패 — " + ex . Message ; }
}
2026-10-21 16:30:22 +00:00
/// <summary>#967 투사체 Set 끝 훅(WLProjectileVisual.OnSet 마지막 · 겉모습 자식까지 붙은 뒤) — OnProjectileSet 이 고른 3타 투사체를 메테오 러너로 넘겨받는다(겉모습 · 판정 상자 · 이동을 끄고 대상 자리에 운석 1개).</summary>
public static void AfterProjectileSet ( ProjectileBase pb )
{
if ( pb = = null | | ! ReferenceEquals ( s_pendingMeteor , pb ) ) return ;
2026-10-25 04:53:08 +00:00
var rule = s_pendingPopup ; s_pendingMeteor = null ; s_pendingPopup = null ;
2026-10-21 16:30:22 +00:00
try
{
var c = Cfg ; var pd = pb . Get_ProjectTileData ( ) ; if ( pd = = null | | DSUtil . CheckNull ( pd . shooter ) ) return ;
2026-10-25 04:53:08 +00:00
if ( rule = = null ) rule = c . popup ;
2026-10-21 16:30:22 +00:00
if ( s_runner = = null ) { var go = new GameObject ( "[WL935] StaffEarth" ) ; go . hideFlags = HideFlags . HideInHierarchy ; Object . DontDestroyOnLoad ( go ) ; s_runner = go . AddComponent < WLStaffEarthRunner > ( ) ; }
2026-10-25 04:53:08 +00:00
s_runner . Begin ( pb , pd , rule . meteorRule ) ;
if ( rule . holdSeconds > 0f ) s_runner . Hold ( pd . shooter as PCActor , rule ) ; // #983 손 내지른 자세 유지
2026-10-21 16:30:22 +00:00
Attack3Meteors + + ;
}
catch ( System . Exception ex ) { Errors + + ; LastLog = "3타 메테오 실패 — " + ex . Message ; }
}
2026-10-18 15:09:05 +00:00
static void OnHitConfirmed ( in HitConfirmedEvent e )
{
try
{
2026-10-25 04:53:08 +00:00
var pb = e . projectile ! = null ? e . projectile . m_ProjectileBase : null ; WLStaffEarthPopup p ;
if ( pb = = null | | DSUtil . CheckNull ( e . victim ) | | ! s_marked . TryGetValue ( pb . GetInstanceID ( ) , out p ) | | p = = null ) return ;
2026-10-18 15:09:05 +00:00
var fwd = ! DSUtil . CheckNull ( e . attacker ) ? e . victim . Get_position ( ) - e . attacker . Get_position ( ) : e . victim . transform . forward ;
SpawnAt ( p . fx , e . attacker , e . victim . Get_position ( ) , fwd , 0f ) ;
Popups + + ;
if ( ( p . launchHeight > 0f | | p . stunSeconds > 0f ) & & WLLaunch . Apply ( e . victim , p . launchHeight , p . launchSeconds , p . stunSeconds ) ) Launches + + ;
}
catch ( System . Exception ex ) { Errors + + ; LastLog = "3타 명중 실패 — " + ex . Message ; }
}
2026-10-31 06:53:50 +00:00
// ── #994 3타 발사 전 모으기(WLStaffPreChargeTick 이 매 프레임 부른다) ─────────────────────
static Animator s_preAn ; static float s_preUntil , s_preSpeed ; static bool s_preArmed = true ; static string s_preState = "" ;
public static int PreCharges ; public static string PreChargeLog = "" ;
internal static void TickPreCharge ( )
{
try
{
var c = Cfg ; if ( c . enabled = = 0 ) return ;
var pc = MyValue . MyPC as PCActor ; var an = pc ! = null ? pc . m_animation : null ; if ( an = = null ) return ;
if ( s_preAn ! = null ) // 멈춰 둔 동안
{
if ( s_preAn ! = an | | an . speed ! = 0f ) { s_preAn = null ; PreChargeLog + = " · 손 뗌(다른 코드)" ; return ; }
var sn = an . IsInTransition ( 0 ) ? an . GetNextAnimatorStateInfo ( 0 ) : an . GetCurrentAnimatorStateInfo ( 0 ) ;
bool moved = ! sn . IsName ( s_preState ) ; // 구르기 등 다른 코드가 상태를 바꿈 = 바로 풀기
if ( moved | | Time . time > = s_preUntil ) { an . speed = s_preSpeed > 0f ? s_preSpeed : 1f ; s_preAn = null ; PreChargeLog + = moved ? " · 상태 바뀜 풀림" : " · 풀림" ; }
return ;
}
if ( pc . Get_CurAnim ( ) > = eAnim . Skill ) { s_preArmed = false ; return ; } // 스킬(화염 광선이 attack3 자세를 씀) 중엔 안 함
WLStaffEarthPopup p = null ;
int wid = EquippedWeaponId ( ) ; // #1003 같은 직업 다른 무기(마법책 9009 만 · 셉터 9005 는 없음)
for ( int i = - 1 ; i < ( c . popups ! = null ? c . popups . Length : 0 ) & & p = = null ; i + + ) { var x = i < 0 ? c . popup : c . popups [ i ] ; if ( x ! = null & & x . preChargeSeconds > 0f & & x . classId = = pc . Get_ID ( ) & & ( x . weaponIds = = null | | x . weaponIds . Length = = 0 | | System . Array . IndexOf ( x . weaponIds , wid ) > = 0 ) ) p = x ; }
if ( p = = null ) return ;
bool tr = an . IsInTransition ( 0 ) ; var st = tr ? an . GetNextAnimatorStateInfo ( 0 ) : an . GetCurrentAnimatorStateInfo ( 0 ) ;
if ( ! st . IsName ( p . stateName ) ) { s_preArmed = true ; return ; } // 그 상태 밖 = 다음 진입에 다시
if ( ! s_preArmed ) return ;
s_preArmed = false ;
float into = st . normalizedTime * st . length ;
if ( into > p . preChargeMaxIn ) { PreChargeLog = "늦음 " + into . ToString ( "F2" ) + " s" ; return ; } // 이미 발사 이벤트 근처 = 이번엔 안 멈춤
s_preSpeed = an . speed ; an . speed = 0f ; s_preAn = an ; s_preUntil = Time . time + p . preChargeSeconds ; s_preState = p . stateName ;
var sock = Socket ( pc , p . preChargeFx . socket ) ;
if ( p . preChargeFx . head = = 3 ) sock = EmptyHand ( pc ) ? ? sock ; // #999 빈 손 뼈에 붙여 따라감(자리 = 손 뼈)
Spawn ( p . preChargeFx , pc , sock , sock ! = null ? Tip ( sock , p . preChargeFx ) : pc . Get_Center_position ( ) , p . preChargeSeconds ) ;
PreCharges + + ; PreChargeLog = "모으기 " + p . stateName + ( tr ? " 전환 중" : "" ) + " " + into . ToString ( "F2" ) + " s · " + p . preChargeSeconds + " s" + ( sock ! = null ? " · " + sock . name : "" ) ;
}
catch ( System . Exception ex ) { Errors + + ; LastLog = "3타 모으기 실패 — " + ex . Message ; }
}
2026-10-18 15:09:05 +00:00
static GameObject Prefab ( string path )
{
GameObject p ;
if ( s_prefabs . TryGetValue ( path , out p ) & & p ! = null ) return p ;
p = Resources . Load < GameObject > ( path ) ;
if ( p = = null ) LastLog = "이펙트 없음 " + path ;
s_prefabs [ path ] = p ;
return p ;
}
static Vector3 V ( float [ ] a ) { return a ! = null & & a . Length > = 3 ? new Vector3 ( a [ 0 ] , a [ 1 ] , a [ 2 ] ) : Vector3 . zero ; }
}
2026-10-31 06:53:50 +00:00
/// <summary>#994 3타 발사 전 모으기 — 숨김 객체 1 개가 매 프레임 WLStaffEarth.TickPreCharge 를 부른다(애니메이터 갱신 전 Update).</summary>
public sealed class WLStaffPreChargeTick : MonoBehaviour
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot ( )
{
if ( FindFirstObjectByType < WLStaffPreChargeTick > ( ) ! = null ) return ;
var go = new GameObject ( "[WL994] StaffPreCharge" ) ; go . hideFlags = HideFlags . HideInHierarchy ; DontDestroyOnLoad ( go ) ; go . AddComponent < WLStaffPreChargeTick > ( ) ;
}
void Update ( ) { WLStaffEarth . TickPreCharge ( ) ; }
}
2026-10-18 15:09:05 +00:00
/// <summary>#935 넘겨받은 원본 스킬 투사체 1 개의 진행(이펙트 → 판정 → 끄기). 숨김 러너 1 개가 코루틴을 돌린다(원본 투사체가 먼저 꺼져도 판정은 이어감).</summary>
public sealed class WLStaffEarthRunner : MonoBehaviour
{
public void Begin ( ProjectileBase pb , ProjectileData pd , WLStaffEarthSkill r ) { StartCoroutine ( Run ( pb , pd , r ) ) ; }
2026-10-25 04:53:08 +00:00
/// <summary>#983 3타 자세 유지 — holdDelay 뒤 그 상태(stateName)면 애니메이터를 holdSeconds 동안 멈춘다. 그 사이 상태가 바뀌거나 전환이 시작되거나 다른 코드가 속도를 바꾸면 바로 손 뗀다(내가 멈춘 그대로일 때만 되돌림).</summary>
public void Hold ( PCActor pc , WLStaffEarthPopup p ) { if ( pc ! = null & & pc . m_animation ! = null & & p ! = null ) StartCoroutine ( HoldRun ( pc , p ) ) ; }
IEnumerator HoldRun ( PCActor pc , WLStaffEarthPopup p )
{
if ( p . holdDelay > 0f ) yield return new WaitForSeconds ( p . holdDelay ) ;
var an = pc ! = null ? pc . m_animation : null ; if ( an = = null ) yield break ;
var st = an . GetCurrentAnimatorStateInfo ( 0 ) ;
if ( ! st . IsName ( p . stateName ) | | an . IsInTransition ( 0 ) ) { WLStaffEarth . HoldLog = "안 멈춤(상태 " + ( st . IsName ( p . stateName ) ? "전환 중" : "다름" ) + ")" ; yield break ; }
float sp = an . speed , n0 = st . normalizedTime , t = 0f ; string why = "끝" ;
an . speed = 0f ; WLStaffEarth . Holds + + ;
while ( t < p . holdSeconds )
{
yield return null ; t + = Time . deltaTime ;
if ( pc = = null | | an = = null ) { why = "PC 없음" ; break ; }
if ( an . speed ! = 0f ) { why = "다른 코드가 속도 바꿈" ; break ; }
if ( an . IsInTransition ( 0 ) | | ! an . GetCurrentAnimatorStateInfo ( 0 ) . IsName ( p . stateName ) ) { why = "상태 바뀜" ; break ; }
}
if ( an ! = null & & an . speed = = 0f ) an . speed = sp > 0f ? sp : 1f ;
WLStaffEarth . HoldLog = "멈춤 n" + n0 . ToString ( "F2" ) + " · " + t . ToString ( "F2" ) + " s · " + why ;
}
2026-10-21 09:37:16 +00:00
/// <summary>#963 운석 1개 — 땅에 닿는 시각(meteorImpact)에 그 자리 radius 안 적(운석마다 따로 셈)에게 원본 투사체 피해 + 기절.</summary>
IEnumerator MeteorHit ( ProjectileBase pb , Actor shooter , Vector3 pt , WLStaffEarthSkill r )
{
if ( r . meteorImpact > 0f ) yield return new WaitForSeconds ( r . meteorImpact ) ;
if ( pb = = null | | DSUtil . CheckNull ( shooter ) ) yield break ;
2026-10-31 06:53:50 +00:00
if ( WLStaffEarth . SpawnAt ( r . traceFx , shooter , pt , shooter . transform . forward , 0f ) ! = null ) WLStaffEarth . Traces + + ; // #1006 지면 흔적
2026-10-21 09:37:16 +00:00
WLStaffEarth . HitAll ( pb , shooter , pt , r , r . radius , new HashSet < Actor > ( ) ) ;
}
2026-10-18 15:09:05 +00:00
IEnumerator Run ( ProjectileBase pb , ProjectileData pd , WLStaffEarthSkill r )
{
// 원본 겉모습 · 판정 상자 · Update(대상 따라가기 · 대상 없으면 끄기) 끔 — 끈 뒤 되돌린다(풀 재사용)
var ren = new List < Renderer > ( ) ; foreach ( var x in pb . GetComponentsInChildren < Renderer > ( true ) ) if ( x . enabled ) { x . enabled = false ; ren . Add ( x ) ; }
var col = new List < Collider > ( ) ; foreach ( var x in pb . GetComponentsInChildren < Collider > ( true ) ) if ( x . enabled ) { x . enabled = false ; col . Add ( x ) ; }
foreach ( var ps in pb . GetComponentsInChildren < ParticleSystem > ( true ) ) ps . Stop ( true , ParticleSystemStopBehavior . StopEmittingAndClear ) ;
bool wasEnabled = pb . enabled ; pb . enabled = false ;
var shooter = pd . shooter ; var target = pd . target ;
2026-10-31 06:53:50 +00:00
if ( r . pullSeconds > 0f ) { yield return Gravity ( pb , pd , shooter , target , r , ren , col , wasEnabled ) ; yield break ; } // #1002 마법책 주력(골렘 소환) — 끝에 원본 Set 을 다시 불러 소환 · 되돌림도 그 안에서
if ( r . rushDistance > 0f | | r . fanCount > 0 | | r . boltCount > 0 | | r . spinHits > 0 | | r . slamAt > 0f ) // #996 창 주력(돌진) · 각성(부채꼴 회오리) · #1000 마법책 각성(볼트 난사) · #1001 대검 기본(회전 연타 · 내려치기)
{
if ( r . rushDistance > 0f ) yield return Rush ( pb , shooter as PCActor , r ) ; else if ( r . boltCount > 0 ) yield return Bolts ( pb , shooter , target , r ) ; else if ( r . spinHits > 0 | | r . slamAt > 0f ) yield return JumpSpin ( pb , shooter as PCActor , r ) ; else yield return Fan ( pb , shooter , target , r ) ;
if ( r . endAfter > 0f ) yield return new WaitForSeconds ( r . endAfter ) ;
if ( pb ! = null ) { pb . enabled = wasEnabled ; WLStaffEarth . OffOriginal ( pb ) ; }
foreach ( var x in ren ) if ( x ! = null ) x . enabled = true ;
foreach ( var x in col ) if ( x ! = null ) x . enabled = true ;
yield break ;
}
2026-10-21 09:37:16 +00:00
if ( r . meteors > 0 ) // #963 메테오 — 운석마다 무작위 자리 · 땅에 닿는 시각에 그 자리 판정 + 기절
{
2026-10-21 13:43:29 +00:00
// #964 PD 「지팡이 각성 스킬이 플레이어 주위가 아니라 타겟 된 적 중심으로 3 m 범위 내 랜덤」 — 투사체 대상이 비거나 죽었으면 PC 가 겨눈 대상(Get_Target)으로 · 그것도 없으면 이전처럼 시전자 앞 3 m
WLStaffEarth . MeteorSrc = ! DSUtil . CheckNull ( target ) & & ! target . IsDead ( ) ? "투사체 대상" : "투사체 대상 없음" ;
if ( ( DSUtil . CheckNull ( target ) | | target . IsDead ( ) ) & & shooter ! = null ) { var t2 = shooter . Get_Target ( ) ; if ( ! DSUtil . CheckNull ( t2 ) & & ! t2 . IsDead ( ) ) { target = t2 ; WLStaffEarth . MeteorSrc + = " → PC 대상" ; } else WLStaffEarth . MeteorSrc + = " → 시전자 앞" ; }
WLStaffEarth . MeteorPts . Clear ( ) ; WLStaffEarth . MeteorCenters . Clear ( ) ;
2026-10-21 09:37:16 +00:00
Vector3 last = DSUtil . CheckNull ( target ) ? shooter . Get_position ( ) + shooter . transform . forward * 3f : target . Get_position ( ) ;
var cam = Camera . main ; Vector3 cf = cam ! = null ? cam . transform . forward : shooter . transform . forward ; cf . y = 0f ; if ( cf . sqrMagnitude < 1e-4f ) cf = Vector3 . forward ;
Vector3 from = Quaternion . Euler ( 0f , r . meteorYaw , 0f ) * cf . normalized ;
2026-10-25 04:53:08 +00:00
// #983 직선(오브 기본) — 시전 순간 시전자 자리 · 대상 쪽 방향을 고정하고 그 위로 전진(대상 없으면 시전자 정면)
bool line = r . meteorPattern = = "line" ; Vector3 lineO = shooter . Get_position ( ) , lineD = ! DSUtil . CheckNull ( target ) & & ! target . IsDead ( ) ? target . Get_position ( ) - lineO : shooter . transform . forward ;
lineD . y = 0f ; if ( lineD . sqrMagnitude < 1e-4f ) { lineD = shooter . transform . forward ; lineD . y = 0f ; } if ( lineD . sqrMagnitude < 1e-4f ) lineD = Vector3 . forward ; lineD . Normalize ( ) ;
if ( line ) from = lineD ;
2026-10-21 09:37:16 +00:00
for ( int i = 0 ; i < r . meteors ; i + + )
{
if ( r . center ! = "caster" & & ! DSUtil . CheckNull ( target ) & & ! target . IsDead ( ) ) last = target . Get_position ( ) ;
2026-10-25 04:53:08 +00:00
Vector3 c0 = line ? lineO : r . center = = "caster" ? shooter . Get_position ( ) : last ;
Vector2 d = line ? Vector2 . zero : Random . insideUnitCircle * Mathf . Max ( 0f , r . meteorArea ) ;
Vector3 pt = line ? lineO + lineD * ( r . meteorStart + r . meteorStep * i ) : new Vector3 ( c0 . x + d . x , c0 . y , c0 . z + d . y ) ;
2026-10-21 13:43:29 +00:00
WLStaffEarth . MeteorPts . Add ( pt ) ; WLStaffEarth . MeteorCenters . Add ( c0 ) ; WLStaffEarth . MeteorCenter = c0 ; // #964 검증용(낙하 자리 · 그때 중심)
2026-10-21 09:37:16 +00:00
WLStaffEarth . SpawnAt ( r . fx , shooter , pt , from , 0f ) ; WLStaffEarth . Meteors + + ;
StartCoroutine ( MeteorHit ( pb , shooter , pt , r ) ) ;
if ( r . meteorInterval > 0f & & i < r . meteors - 1 ) yield return new WaitForSeconds ( r . meteorInterval ) ;
}
2026-10-25 04:53:08 +00:00
WLStaffEarth . LastLog = r . skill + " 메테오 " + r . meteors + "개 · " + ( line ? "직선 " + r . meteorStart + " + " + r . meteorStep + " m 씩" : "반경 " + r . meteorArea + " m" ) ;
2026-10-21 09:37:16 +00:00
yield return new WaitForSeconds ( Mathf . Max ( 0f , r . meteorImpact ) + Mathf . Max ( 0f , r . endAfter ) ) ;
if ( pb ! = null ) { pb . enabled = wasEnabled ; WLStaffEarth . OffOriginal ( pb ) ; }
foreach ( var x in ren ) if ( x ! = null ) x . enabled = true ;
foreach ( var x in col ) if ( x ! = null ) x . enabled = true ;
yield break ;
}
2026-10-18 15:09:05 +00:00
Vector3 center = r . center = = "caster" | | DSUtil . CheckNull ( target ) ? shooter . Get_position ( ) : target . Get_position ( ) ;
Vector3 fwd = center - shooter . Get_position ( ) ; if ( r . center = = "caster" | | fwd . sqrMagnitude < 0.01f ) fwd = shooter . transform . forward ;
WLStaffEarth . SpawnAt ( r . fx , shooter , center , fwd , 0f ) ;
WLStaffEarth . LastLog = r . skill + " → " + r . fx . prefab + " @" + center . ToString ( "F1" ) ;
if ( r . hitDelay > 0f ) yield return new WaitForSeconds ( r . hitDelay ) ;
2026-10-31 06:53:50 +00:00
if ( ! DSUtil . CheckNull ( shooter ) & & WLStaffEarth . SpawnAt ( r . traceFx , shooter , center , fwd , 0f ) ! = null ) WLStaffEarth . Traces + + ; // #1006 지면 흔적
2026-10-18 15:09:05 +00:00
var done = new HashSet < Actor > ( ) ;
if ( r . spreadSpeed > 0f )
{
float t0 = Time . time , last = r . radius / r . spreadSpeed + 0.05f ;
while ( true )
{
float t = Time . time - t0 ;
WLStaffEarth . HitAll ( pb , shooter , center , r , t * r . spreadSpeed , done ) ;
if ( t > = last ) break ;
yield return null ;
}
}
else WLStaffEarth . HitAll ( pb , shooter , center , r , r . radius , done ) ;
if ( r . endAfter > 0f ) yield return new WaitForSeconds ( r . endAfter ) ;
if ( pb ! = null ) { pb . enabled = wasEnabled ; WLStaffEarth . OffOriginal ( pb ) ; }
foreach ( var x in ren ) if ( x ! = null ) x . enabled = true ;
foreach ( var x in col ) if ( x ! = null ) x . enabled = true ;
}
2026-10-31 06:53:50 +00:00
/// <summary>#1002 중력 → 지면 폭발 → 원본 소환. center 에 pullFx 를 띄우고 pullSeconds 동안 pullRadius 안 적을 중심으로 끌어당긴다(NavMeshAgent.Move · 속도는 시간에 비례해 빨라짐 · 경직으로 붙잡음) →
/// 끝에 지면 fx + hitDelay 뒤 radius 안 적 피해 · 기절 → SummonPass 로 원본 Set 을 다시 불러(prefab 칸만 summonPrefab 으로 잠시 바꿈) 소환 → 새로 켜진 소환체를 center 로 옮기고 배율을 곱한다.</summary>
IEnumerator Gravity ( ProjectileBase pb , ProjectileData pd , Actor shooter , Actor target , WLStaffEarthSkill r , List < Renderer > ren , List < Collider > col , bool wasEnabled )
{
if ( DSUtil . CheckNull ( shooter ) ) yield break ;
// 소환 스킬 투사체의 대상은 시전자 자신이었다(1차 Play 실측 · 중심 = PC 옆 0.47 m) → 적이 아니면 PC 가 겨눈 대상 · 그것도 없으면 시전자 앞 3 m
if ( DSUtil . CheckNull ( target ) | | target . IsDead ( ) | | target = = shooter | | target . IsEnemy ( ) = = shooter . IsEnemy ( ) ) { var t2 = shooter . Get_Target ( ) ; target = ! DSUtil . CheckNull ( t2 ) & & ! t2 . IsDead ( ) & & t2 . IsEnemy ( ) ! = shooter . IsEnemy ( ) ? t2 : null ; }
Vector3 center = r . center = = "caster" ? shooter . Get_position ( ) : ! DSUtil . CheckNull ( target ) & & ! target . IsDead ( ) ? target . Get_position ( ) : shooter . Get_position ( ) + shooter . transform . forward * 3f ;
Vector3 fwd = center - shooter . Get_position ( ) ; fwd . y = 0f ; if ( fwd . sqrMagnitude < 0.01f ) fwd = shooter . transform . forward ;
WLStaffEarth . MeteorCenter = center ;
WLStaffEarth . SpawnAt ( r . pullFx , shooter , center , fwd , 0f ) ;
var info = ActorInfo . Ins ; var pulled = new HashSet < Actor > ( ) ; float t = 0f ;
while ( t < r . pullSeconds & & info ! = null & & ! DSUtil . CheckNull ( shooter ) )
{
float step = Mathf . Min ( r . pullMaxSpeed , r . pullAccel * t ) * Time . deltaTime ;
var list = info . Get_EnemyActors ( shooter . IsEnemy ( ) , center , r . pullRadius + 3f ) ;
if ( list ! = null )
try
{
for ( int k = 0 ; k < list . Count ; k + + )
{
var a = list [ k ] ; if ( a = = null | | a . IsDead ( ) | | a . IsSubRole ( eSubRol . Boss ) ) continue ;
var v = center - a . Get_position ( ) ; v . y = 0f ; float d = v . magnitude ;
if ( d > r . pullRadius + WL . Combat . WLCombatMotionSettings . BodyRadius ( a ) | | d < = r . pullStop ) continue ;
var ag = a . Get_Agent ( ) ; if ( ag = = null | | ! ag . enabled | | ! ag . isOnNavMesh ) continue ;
ag . Move ( v / d * Mathf . Min ( step , d - r . pullStop ) ) ;
WL . Combat . Reaction . MobStagger . Extend ( a , 0.1f ) ;
if ( pulled . Add ( a ) ) WLStaffEarth . Pulled + + ;
}
}
finally { info . Release_Actors ( list ) ; }
WLStaffEarth . PullFrames + + ;
yield return null ; t + = Time . deltaTime ;
}
WLStaffEarth . SpawnAt ( r . fx , shooter , center , fwd , 0f ) ;
if ( r . hitDelay > 0f ) yield return new WaitForSeconds ( r . hitDelay ) ;
if ( pb ! = null & & ! DSUtil . CheckNull ( shooter ) ) WLStaffEarth . HitAll ( pb , shooter , center , r , r . radius , new HashSet < Actor > ( ) ) ;
// 원본 소환 — 이 투사체를 되돌린 뒤 원본 Set 을 한 번 더(소환 콜백이 투사체를 끈다)
var fxBefore = new HashSet < int > ( ) ; foreach ( var x in Object . FindObjectsByType < Transform > ( FindObjectsSortMode . None ) ) if ( x . name . StartsWith ( "Effect_Summoning_Golem" ) ) fxBefore . Add ( x . GetInstanceID ( ) ) ;
// 자리 · 방향 · 배율은 원본 소환 콜백 안(AfterSummon 훅 · summon.Set 바로 뒤)에서 같은 프레임에 — 3차 Play 실측: 다음 프레임에 옮기면 원래 크기 골렘이 시전자 앞에 약 0.05 s 보였다
WLStaffEarth . ReserveSummon ( shooter , center , Quaternion . LookRotation ( fwd . normalized ) , Mathf . Max ( 0.01f , r . summonFit * r . summonScale ) , r , pd ) ;
if ( pb ! = null & & ! DSUtil . CheckNull ( shooter ) )
{
pb . enabled = wasEnabled ; foreach ( var x in col ) if ( x ! = null ) x . enabled = true ;
var sg = pb as Skill_Golem ; string keep = sg ! = null ? sg . prefab : null ;
WLStaffEarth . SummonPass = true ;
try { if ( sg ! = null & & ! string . IsNullOrEmpty ( r . summonPrefab ) ) sg . prefab = r . summonPrefab ; pb . Set ( pd ) ; }
catch ( System . Exception ex ) { WLStaffEarth . Errors + + ; WLStaffEarth . LastLog = "소환 실패 — " + ex . Message ; }
finally { WLStaffEarth . SummonPass = false ; if ( sg ! = null ) sg . prefab = keep ; }
}
float w = 0f ;
while ( WLStaffEarth . LastSummon = = null & & w < 3f ) { yield return null ; w + = Time . deltaTime ; }
foreach ( var x in ren ) if ( x ! = null ) x . enabled = true ;
var got = WLStaffEarth . LastSummon ;
if ( got = = null ) { WLStaffEarth . SummonLog = "소환체 못 찾음 " + w . ToString ( "F1" ) + " s" ; yield break ; }
WLStaffEarth . Summons + + ;
WLStaffEarth . SummonLog = got . name + " @" + center . ToString ( "F1" ) + " × " + got . transform . localScale . x . ToString ( "F2" ) + " · 끌림 " + pulled . Count + " · 소환 " + w . ToString ( "F2" ) + " s" ;
// 원본 소환 섬광(SummonActor.Set = 시전자 앞 1 m · 주소 로드라 몇 프레임 늦게 켜진다 — 2차 Play 실측 PC 자리에 남음)도 골렘 자리로
int moved = 0 ; float mv = 0f ;
while ( mv < 1f & & got ! = null )
{
foreach ( var x in Object . FindObjectsByType < Transform > ( FindObjectsSortMode . None ) )
if ( x . name . StartsWith ( "Effect_Summoning_Golem" ) & & fxBefore . Add ( x . GetInstanceID ( ) ) ) { x . position = got . Get_position ( ) ; moved + + ; }
yield return null ; mv + = Time . deltaTime ;
}
WLStaffEarth . SummonLog + = " · 소환 섬광 옮김 " + moved ;
}
/// <summary>#1000 · #1001 볼트 난사 — 빈 손(손바닥)에서 1발씩 · 대상 쪽에서 좌우 · 위로 틀어 낸 조절점을 지나 대상의 지금 몸 중심으로 휘어 날아가(2차 베지어 · 끝점은 매 프레임 대상 = 유도) 도착하면 짝 피격 이펙트 + 그 자리 판정.
/// #1001 PD 「마법책 각성 스킬이 왜 에셋과 다르게 색상이 에너지 볼 같은 느낌이 나지 않는거지? … 불규칙한 궤적이지만 타겟을 향해 날아가는 에너지 볼트를 난사하는 스킬을 원했어」 · 「보유한 리소스 중 마법책 각성 스킬에 적합한 에셋이 없는지 체크해보고 적합한 투사체 이펙트로 교체해줘」</summary>
IEnumerator Bolts ( ProjectileBase pb , Actor shooter , Actor target , WLStaffEarthSkill r )
{
var pc = shooter as PCActor ; if ( pc = = null ) yield break ;
GameObject orb = LoadFx ( r . boltPrefab ) , hit = LoadFx ( r . boltHitFx ) , muz = LoadFx ( r . boltMuzzleFx ) ;
if ( ( DSUtil . CheckNull ( target ) | | target . IsDead ( ) ) & & shooter ! = null ) { var t2 = shooter . Get_Target ( ) ; if ( ! DSUtil . CheckNull ( t2 ) & & ! t2 . IsDead ( ) ) target = t2 ; }
int hits0 = WLStaffEarth . Hits ; var flying = new int [ 1 ] ; float t0 = Time . time ;
for ( int i = 0 ; i < r . boltCount ; i + + )
{
if ( pc = = null | | pc . IsDead ( ) ) break ;
var hand = WLStaffEarth . EmptyHand ( pc ) ; Vector3 from = hand ! = null ? hand . position : pc . Get_Center_position ( ) ;
Actor tg = ! DSUtil . CheckNull ( target ) & & ! target . IsDead ( ) ? target : null ;
Vector3 aim = tg ! = null ? WL . Combat . Impact . WLImpactFx . BodyCenter ( tg ) : from + pc . transform . forward * r . boltRange ;
var to = aim - from ; float dist = Mathf . Clamp ( to . magnitude , 0.3f , r . boltRange ) ; var fwd = to . sqrMagnitude > 1e-4f ? to . normalized : pc . transform . forward ;
var side = Vector3 . Cross ( Vector3 . up , fwd ) ; if ( side . sqrMagnitude < 1e-4f ) side = pc . transform . right ; side . Normalize ( ) ;
float lat = Random . Range ( - 1f , 1f ) ; lat = Mathf . Sign ( lat ) * Mathf . Lerp ( 0.35f , 1f , Mathf . Abs ( lat ) ) ; // 가운데로 몰리지 않게
Vector3 ctrl = from + fwd * dist * 0.4f + side * lat * r . boltCurve + Vector3 . up * Random . Range ( - 0.15f , 0.6f ) * r . boltCurve ;
float fly = dist * 1.2f / Mathf . Max ( 0.5f , r . boltSpeed ) ;
if ( muz ! = null ) { var m = Object . Instantiate ( muz , from , Quaternion . LookRotation ( ( ctrl - from ) . normalized ) ) ; m . transform . localScale = Vector3 . one * r . boltMuzzleScale ; Object . Destroy ( m , 1.5f ) ; }
flying [ 0 ] + + ; StartCoroutine ( Fly ( pb , shooter , r , orb , hit , from , ctrl , tg , aim , fly , flying ) ) ;
float next = t0 + ( i + 1 ) * Mathf . Max ( 0.01f , r . boltInterval ) ;
while ( Time . time < next ) yield return null ;
}
float guard = 0f ; while ( flying [ 0 ] > 0 & & guard < 5f ) { guard + = Time . deltaTime ; yield return null ; }
WLStaffEarth . LastLog = r . skill + " 볼트 " + r . boltCount + "발 · " + r . boltInterval + " s · 명중 " + ( WLStaffEarth . Hits - hits0 ) + ( DSUtil . CheckNull ( target ) ? " · 대상 없음(정면)" : "" ) ;
}
static GameObject LoadFx ( string path ) { return string . IsNullOrEmpty ( path ) ? null : Resources . Load < GameObject > ( path ) ; }
IEnumerator Fly ( ProjectileBase pb , Actor shooter , WLStaffEarthSkill r , GameObject orbP , GameObject hitP , Vector3 from , Vector3 ctrl , Actor tg , Vector3 end , float fly , int [ ] flying )
{
GameObject go = null ;
if ( orbP ! = null )
{
go = Object . Instantiate ( orbP , from , Quaternion . LookRotation ( ( ctrl - from ) . normalized ) ) ; go . transform . localScale = Vector3 . one * r . boltScale ;
foreach ( var tr in go . GetComponentsInChildren < TrailRenderer > ( true ) ) { tr . widthMultiplier * = r . boltTrailWidth > 0f ? r . boltTrailWidth : r . boltScale ; if ( r . boltTrailTime > 0f ) tr . time = r . boltTrailTime ; tr . Clear ( ) ; } // 꼬리 너비는 배율이 안 먹는다 · #1001 꼬리 = 궤적이 보이게 따로
if ( r . boltOwnTrail > 0f ) // #1001 궤적 리본 — 팩 꼬리(스크롤 셰이더 · 어두운 남색)가 밝은 바닥에서 안 보여(3회 캡처) 공에 보이는 보라 빛 재질로 끝이 가늘어지는 꼬리를 하나 더
{
var pr = go . GetComponent < ParticleSystemRenderer > ( ) ; var own = go . AddComponent < TrailRenderer > ( ) ;
own . sharedMaterial = pr ! = null ? pr . sharedMaterial : null ; own . time = r . boltTrailTime > 0f ? r . boltTrailTime : 0.35f ; own . widthMultiplier = r . boltOwnTrail ;
own . widthCurve = AnimationCurve . Linear ( 0f , 1f , 1f , 0f ) ; own . minVertexDistance = 0.04f ; own . alignment = LineAlignment . View ; own . textureMode = LineTextureMode . Stretch ; own . autodestruct = false ;
var g = new Gradient ( ) ; g . SetKeys ( new [ ] { new GradientColorKey ( new Color ( 1f , 0.7f , 1f ) , 0f ) , new GradientColorKey ( new Color ( 0.6f , 0.25f , 1f ) , 1f ) } , new [ ] { new GradientAlphaKey ( 1f , 0f ) , new GradientAlphaKey ( 0f , 1f ) } ) ; own . colorGradient = g ;
own . Clear ( ) ;
}
WLStaffEarth . BoltsShot + + ;
}
float t = 0f ; Vector3 prev = from ;
while ( t < fly )
{
yield return null ; t + = Time . deltaTime ; float k = Mathf . Clamp01 ( t / fly ) , u = 1f - k ;
if ( tg ! = null & & ! tg . IsDead ( ) ) end = WL . Combat . Impact . WLImpactFx . BodyCenter ( tg ) ; // 유도 — 끝점 = 대상의 지금 몸 중심
var p = u * u * from + 2f * u * k * ctrl + k * k * end ;
if ( go ! = null ) { go . transform . position = p ; var d = p - prev ; if ( d . sqrMagnitude > 1e-6f ) go . transform . rotation = Quaternion . LookRotation ( d . normalized ) ; }
prev = p ;
}
if ( hitP ! = null ) { var h = Object . Instantiate ( hitP , end , Quaternion . identity ) ; h . transform . localScale = Vector3 . one * r . boltHitScale ; Object . Destroy ( h , 2f ) ; }
if ( pb ! = null & & ! DSUtil . CheckNull ( shooter ) )
{
WL . Combat . Impact . WLImpactFx . SuppressSkillHit = true ; // 피격 이펙트 = 볼트 짝 명중 이펙트(공통 지팡이 스킬 피격 WL_Hit_StaffSkill 겹침 끔)
try { WLStaffEarth . HitAll ( pb , shooter , end , r , r . boltRadius , new HashSet < Actor > ( ) ) ; }
finally { WL . Combat . Impact . WLImpactFx . SuppressSkillHit = false ; }
}
if ( go ! = null )
{
foreach ( var ps in go . GetComponentsInChildren < ParticleSystem > ( true ) ) ps . Stop ( false , ParticleSystemStopBehavior . StopEmittingAndClear ) ; // 공은 명중과 함께 사라지고 꼬리만 흐려짐
float trail = 0f ; foreach ( var tr in go . GetComponentsInChildren < TrailRenderer > ( true ) ) { tr . emitting = false ; trail = Mathf . Max ( trail , tr . time ) ; }
Object . Destroy ( go , Mathf . Clamp ( trail , 0.1f , 1.5f ) ) ;
}
flying [ 0 ] - - ;
}
/// <summary>#1001 대검 기본 — 점프(WLJumpSlam 제자리 점프 · 시전 = 점프 시작) 동안 회전 연타 spinHits 번(피해 × spinDamage · PC 정면으로 spinPush m) → slamAt 초 착지 내려치기(원본 피해 · 정면 slamPush m · slamLaunch m 띄움).</summary>
IEnumerator JumpSpin ( ProjectileBase pb , PCActor pc , WLStaffEarthSkill r )
{
if ( pc = = null ) yield break ; float t0 = Time . time ; int hits0 = WLStaffEarth . Hits , spun = 0 , landed = 0 ;
for ( int n = 0 ; n < r . spinHits ; n + + )
{
while ( Time . time - t0 < r . spinHitStart + n * r . spinHitInterval ) yield return null ;
if ( pc = = null | | pc . IsDead ( ) ) yield break ;
spun + = Strike ( pb , pc , pc . Get_position ( ) , r . spinRadius , r . spinDamage , r . spinPush , 0.08f , 0f ) ;
}
if ( r . slamAt > 0f )
{
while ( Time . time - t0 < r . slamAt ) yield return null ;
if ( pc ! = null & & ! pc . IsDead ( ) ) { var f = pc . transform . forward ; f . y = 0f ; landed = Strike ( pb , pc , pc . Get_position ( ) + f . normalized * r . slamForward , r . radius , - 1f , r . slamPush , r . slamSeconds , r . slamLaunch ) ; }
}
WLStaffEarth . LastLog = r . skill + " 회전 연타 " + r . spinHits + "번 · 맞음 " + spun + " · 내려치기 맞음 " + landed + " · 명중 " + ( WLStaffEarth . Hits - hits0 ) ;
}
/// <summary>#1001 center 둘레 reach m 적에게 원본 투사체 판정(scale ≥ 0 = 피해 배율 · WLStatus.DamageScale) + PC 정면으로 push m 밀기(height m 띄움).</summary>
static int Strike ( ProjectileBase pb , PCActor pc , Vector3 center , float reach , float scale , float push , float seconds , float height )
{
var info = ActorInfo . Ins ; if ( info = = null | | pb = = null ) return 0 ;
var list = info . Get_EnemyActors ( pc . IsEnemy ( ) , center , reach + 3f ) ; if ( list = = null ) return 0 ; int n = 0 ;
var f = pc . transform . forward ; f . y = 0f ; if ( f . sqrMagnitude < 1e-4f ) f = Vector3 . forward ; f . Normalize ( ) ;
try
{
for ( int k = 0 ; k < list . Count ; k + + )
{
var a = list [ k ] ; if ( a = = null | | a . IsDead ( ) ) continue ;
var v = a . Get_position ( ) - center ; v . y = 0f ; if ( v . magnitude > reach + WL . Combat . WLCombatMotionSettings . BodyRadius ( a ) ) continue ;
var col = a . GetComponent < Collider > ( ) ; if ( col = = null | | ! col . enabled ) continue ;
WL . Combat . Status . WLStatus . DamageScale = scale ;
bool ok ; try { ok = pb . ProcessCollider ( col , false ) ; } finally { WL . Combat . Status . WLStatus . DamageScale = - 1f ; }
if ( ! ok ) continue ;
WLStaffEarth . Hits + + ; n + + ;
if ( push > 0f ) WLLaunch . FlingDir ( a , f , push , Mathf . Max ( 0.05f , seconds ) , height , 0f , false ) ;
}
}
finally { info . Release_Actors ( list ) ; }
return n ;
}
/// <summary>#996 돌진 — 찌르기 자세 고정 · 정면으로 rushDistance m · 가상패드 회전 · 몸 둘레 적 다단 피해 + 앞으로 밀기 · 몸 감싸는 이펙트.</summary>
IEnumerator Rush ( ProjectileBase pb , PCActor pc , WLStaffEarthSkill r )
{
if ( pc = = null ) yield break ;
var tg = pc . Get_Target ( ) ; // 돌진 시작 = 대상 쪽(대상 없으면 지금 정면) · 이후는 가상패드로 회전
if ( ! DSUtil . CheckNull ( tg ) & & ! tg . IsDead ( ) ) { var to = tg . Get_position ( ) - pc . Get_position ( ) ; to . y = 0f ; if ( to . sqrMagnitude > 1e-4f ) pc . transform . rotation = Quaternion . LookRotation ( to . normalized ) ; }
var an = pc . m_animation ; float sp0 = an ! = null & & an . speed > 0f ? an . speed : 1f ;
if ( OptionInfo . Ins ! = null ) OptionInfo . Ins . Set_PCMove ( pc , false ) ;
pc . Set_CurAnim ( eAnim . Skill ) ;
if ( an ! = null & & ! string . IsNullOrEmpty ( r . rushHoldState ) ) { an . speed = 0f ; an . Play ( r . rushHoldState , 0 , Mathf . Clamp01 ( r . rushHoldNormalized ) ) ; an . Update ( 0f ) ; }
var fx = WLStaffEarth . SpawnAt ( r . rushFx , pc , pc . Get_position ( ) , pc . transform . forward , r . rushSeconds ) ;
if ( fx ! = null ) { fx . transform . SetParent ( pc . transform , true ) ; if ( r . rushFx . sustain ! = 0 ) WLStaffEarth . Sustain ( fx ) ; }
GameObject wrap = null ; bool fxStopped = false , wrapStopped = false ; // #999 감싸기 1개를 돌진 내내(sustain)
float nextFx3 = 0f ;
var agent = pc . GetComponent < UnityEngine . AI . NavMeshAgent > ( ) ;
float speed = r . rushDistance / Mathf . Max ( 0.05f , r . rushSeconds ) , t = 0f , next = 0f , moved = 0f , nextFx2 = 0f ; int hits0 = WLStaffEarth . Hits , pushes = 0 , turns = 0 ; bool canceled = false ;
while ( t < r . rushSeconds )
{
if ( pc = = null | | pc . IsDead ( ) ) { canceled = true ; break ; }
if ( pc . Get_CurAnim ( ) = = eAnim . Dodge ) { canceled = true ; break ; } // 구르기 = 취소(화염 광선과 같은 규칙)
// 방향 전환 — 가상패드 축(카메라 정면 기준) 쪽으로 몸을 돌린다
var a = WLStaffLaserRunner . PadAxis ( ) ;
if ( a . sqrMagnitude > = r . rushSteerDeadzone * r . rushSteerDeadzone )
{
var cam = Camera . main ; var cf = cam ! = null ? cam . transform . forward : Vector3 . forward ; cf . y = 0f ; if ( cf . sqrMagnitude < 1e-4f ) cf = Vector3 . forward ; cf . Normalize ( ) ;
var want = cf * a . y + Vector3 . Cross ( Vector3 . up , cf ) * a . x ;
if ( want . sqrMagnitude > 1e-4f ) { pc . transform . rotation = Quaternion . RotateTowards ( pc . transform . rotation , Quaternion . LookRotation ( want . normalized ) , r . rushTurnDegPerSec * Time . deltaTime ) ; turns + + ; }
}
if ( an ! = null & & an . speed ! = 0f ) an . speed = 0f ;
var f = pc . transform . forward ; f . y = 0f ; if ( f . sqrMagnitude < 1e-4f ) f = Vector3 . forward ; f . Normalize ( ) ;
float step = speed * Time . deltaTime ; var p0 = pc . Get_position ( ) ;
if ( agent ! = null & & agent . enabled & & agent . isOnNavMesh ) agent . Move ( f * step ) ; else pc . transform . position + = f * step ;
moved + = Vector3 . Distance ( p0 , pc . Get_position ( ) ) ;
Carry ( pc , f , step * r . rushPush , r . radius , ref pushes ) ;
if ( ! string . IsNullOrEmpty ( r . rushFx2 . prefab ) & & t > = nextFx2 )
{
if ( r . rushFx2 . sustain ! = 0 ) { nextFx2 = float . MaxValue ; wrap = WLStaffEarth . SpawnAt ( r . rushFx2 , pc , pc . Get_position ( ) , f , r . rushSeconds ) ; if ( wrap ! = null ) { wrap . transform . SetParent ( pc . transform , true ) ; WLStaffEarth . Sustain ( wrap ) ; } }
else { nextFx2 + = Mathf . Max ( 0.05f , r . rushFx2Every ) ; var w = WLStaffEarth . SpawnAt ( r . rushFx2 , pc , pc . Get_position ( ) , f , 0f ) ; if ( w ! = null ) w . transform . SetParent ( pc . transform , true ) ; }
}
if ( ! string . IsNullOrEmpty ( r . rushFx3 . prefab ) & & t > = nextFx3 ) { nextFx3 + = Mathf . Max ( 0.05f , r . rushFx3Every ) ; if ( WLStaffEarth . SpawnAt ( r . rushFx3 , pc , pc . Get_position ( ) , f , 0f ) ! = null ) WLStaffEarth . Dusts + + ; } // #1000 흙먼지(지나온 길에 남김)
if ( wrap ! = null & & ! wrapStopped & & t > = r . rushSeconds - r . rushFx2 . sustainTail ) { wrapStopped = true ; WLStaffEarth . StopEmit ( wrap ) ; } // 끝에 맞춰 사라지게
if ( fx ! = null & & r . rushFx . sustain ! = 0 & & ! fxStopped & & t > = r . rushSeconds - r . rushFx . sustainTail ) { fxStopped = true ; WLStaffEarth . StopEmit ( fx ) ; }
if ( t > = next ) { next + = Mathf . Max ( 0.05f , r . rushHitInterval ) ; WLStaffEarth . HitAll ( pb , pc , pc . Get_position ( ) + f * ( r . radius * 0.5f ) , r , r . radius , new HashSet < Actor > ( ) ) ; }
yield return null ; t + = Time . deltaTime ;
}
if ( fx ! = null & & r . rushFx . sustain ! = 0 ) WLStaffEarth . ClearEnd ( fx ) ; // #999 돌진 끝 = 이펙트 끝
else if ( fx ! = null ) { foreach ( var ps in fx . GetComponentsInChildren < ParticleSystem > ( true ) ) ps . Stop ( true , ParticleSystemStopBehavior . StopEmitting ) ; fx . transform . SetParent ( null , true ) ; Destroy ( fx , 0.4f ) ; }
if ( wrap ! = null ) WLStaffEarth . ClearEnd ( wrap ) ;
if ( an ! = null & & an . speed = = 0f ) an . speed = sp0 ;
if ( OptionInfo . Ins ! = null & & pc ! = null ) OptionInfo . Ins . Set_PCMove ( pc , true ) ;
if ( ! canceled & & pc ! = null & & ! pc . IsDead ( ) ) pc . Play_Idle ( ) ;
WLStaffEarth . LastLog = r . skill + " 돌진 " + ( canceled ? "취소 " : "끝 " ) + t . ToString ( "F2" ) + " s · 이동 " + moved . ToString ( "F2" ) + " m · 명중 " + ( WLStaffEarth . Hits - hits0 ) + " · 밀침 " + pushes + " · 회전 프레임 " + turns ;
}
/// <summary>#996 돌진 앞쪽(몸 반지름 + reach 안 · 정면 반원) 적을 같은 방향으로 함께 민다.</summary>
static void Carry ( PCActor pc , Vector3 f , float d , float reach , ref int pushes )
{
var info = ActorInfo . Ins ; if ( info = = null | | d < = 0f ) return ;
var o = pc . Get_position ( ) ; var list = info . Get_EnemyActors ( pc . IsEnemy ( ) , o , reach + 3f ) ; if ( list = = null ) return ;
try
{
for ( int k = 0 ; k < list . Count ; k + + )
{
var a = list [ k ] ; if ( a = = null | | a . IsDead ( ) ) continue ;
var v = a . Get_position ( ) - o ; v . y = 0f ;
if ( v . magnitude > reach + WL . Combat . WLCombatMotionSettings . BodyRadius ( a ) | | Vector3 . Dot ( v , f ) < 0f ) continue ;
var ag = a . GetComponent < UnityEngine . AI . NavMeshAgent > ( ) ;
if ( ag ! = null & & ag . enabled & & ag . isOnNavMesh ) ag . Move ( f * d ) ; else a . transform . position + = f * d ;
pushes + + ;
}
}
finally { info . Release_Actors ( list ) ; }
}
/// <summary>#996 부채꼴 — 정면(대상 쪽) ±fanAngle/2 에 fanCount 개 fx 를 fanStart → fanDistance m 로 fanSeconds 동안 날리며 지나는 자리 radius 안 적을 한 번씩 피해 + 띄움 · 기절(HitAll).</summary>
IEnumerator Fan ( ProjectileBase pb , Actor shooter , Actor target , WLStaffEarthSkill r )
{
if ( DSUtil . CheckNull ( shooter ) ) yield break ;
var o = shooter . Get_position ( ) ; var fwd = ! DSUtil . CheckNull ( target ) & & ! target . IsDead ( ) ? target . Get_position ( ) - o : shooter . transform . forward ; fwd . y = 0f ;
if ( fwd . sqrMagnitude < 1e-4f ) fwd = shooter . transform . forward ; fwd . y = 0f ; if ( fwd . sqrMagnitude < 1e-4f ) fwd = Vector3 . forward ; fwd . Normalize ( ) ;
int n = Mathf . Max ( 1 , r . fanCount ) ; var dirs = new Vector3 [ n ] ; var fxs = new GameObject [ n ] ;
for ( int i = 0 ; i < n ; i + + )
{
float ang = n = = 1 ? 0f : - r . fanAngle * 0.5f + r . fanAngle * i / ( n - 1 ) ;
dirs [ i ] = Quaternion . Euler ( 0f , ang , 0f ) * fwd ;
fxs [ i ] = WLStaffEarth . SpawnAt ( r . fx , shooter , o + dirs [ i ] * r . fanStart , dirs [ i ] , r . fanSeconds ) ;
}
var done = new HashSet < Actor > ( ) ; float t = 0f ; int hits0 = WLStaffEarth . Hits ;
var s0 = new Vector3 [ n ] ; for ( int i = 0 ; i < n ; i + + ) s0 [ i ] = fxs [ i ] ! = null ? fxs [ i ] . transform . localScale : Vector3 . one ;
while ( t < = r . fanSeconds )
{
float u = Mathf . Clamp01 ( t / Mathf . Max ( 0.05f , r . fanSeconds ) ) , dist = Mathf . Lerp ( r . fanStart , r . fanDistance , u ) ;
float k = r . fanShrinkFrom < 1f & & u > r . fanShrinkFrom ? 1f - Mathf . SmoothStep ( 0f , 1f , ( u - r . fanShrinkFrom ) / ( 1f - r . fanShrinkFrom ) ) : 1f ; // #997 끝에서 점점 작아짐
for ( int i = 0 ; i < n ; i + + )
{
var p = o + dirs [ i ] * dist ;
if ( fxs [ i ] ! = null ) { fxs [ i ] . transform . position = new Vector3 ( p . x , fxs [ i ] . transform . position . y , p . z ) ; fxs [ i ] . transform . localScale = s0 [ i ] * Mathf . Max ( 0.01f , k ) ; }
WLStaffEarth . HitAll ( pb , shooter , p , r , r . radius , done , dirs [ i ] ) ; // #1001 회오리 진행 방향으로 밀며 띄움(pushDistance)
}
yield return null ; t + = Time . deltaTime ;
}
foreach ( var g in fxs ) if ( g ! = null ) { foreach ( var ps in g . GetComponentsInChildren < ParticleSystem > ( true ) ) ps . Stop ( true , ParticleSystemStopBehavior . StopEmitting ) ; Destroy ( g , Mathf . Max ( 0.05f , r . fanFade ) ) ; }
WLStaffEarth . LastLog = r . skill + " 부채꼴 " + n + "개 · " + r . fanAngle + "° · " + r . fanStart + " → " + r . fanDistance + " m · 명중 " + ( WLStaffEarth . Hits - hits0 ) + " · 띄움 " + WLStaffEarth . Launches ;
}
}
/// <summary>#999 PD 「이펙트가 플레이어보다 상위에 보이게해서 플레이어를 가려줘」 — 부모(PC) 기준 원래 자리에서 매 프레임 카메라 쪽으로 dist m(팩 셰이더에 깊이 검사 속성이 없어 위치로 · 창 4타 toCamera 와 같은 방식).</summary>
public sealed class WLFxToCamera : MonoBehaviour
{
public float dist ; Vector3 _local ; Transform _p ; bool _got ;
void LateUpdate ( )
{
var cam = Camera . main ; if ( cam = = null ) return ;
if ( ! _got | | _p ! = transform . parent ) { _p = transform . parent ; _local = _p ! = null ? _p . InverseTransformPoint ( transform . position ) : transform . position ; _got = true ; }
Vector3 b = _p ! = null ? _p . TransformPoint ( _local ) : _local ;
transform . position = b + ( cam . transform . position - b ) . normalized * dist ;
}
2026-10-18 15:09:05 +00:00
}
}