Project_WL/Assets/WL/Combat/Weapon/WLStaffEarth.cs

973 lines
86 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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 = ""; // 모으기 무기 소켓 이름(빈 값 = 무기가 달린 켜진 첫 소켓)
public int head; // #963 1 = 무기 메시 머리(로컬 경계 긴 축 먼 쪽 끝 − headPull · 끝 빛과 같은 점) · 0 = 이전(소켓 아래 렌더러 월드 상자 꼭대기 · 기울면 머리에서 벗어남) · 2 = 무기 든 손 소켓 · 3 = 빈 손 뼈(#999 · 3타 모으기만)
public float headPull = 0.08f;
// #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 이펙트에 붙은 점광원 세기 배율(손이 낮게 있는 지팡이 마지막 타 = 땅에 밝은 웅덩이 → 낮춤)
}
[System.Serializable]
public class WLStaffEarthSkill
{
public string skill = ""; // 원본 스킬 클래스 이름(Skill_Hurricane · Skill_MeteoStorm)
public int classId; // #975 0 = 모든 직업 · > 0 = 시전자 직업이 이 값일 때만(같은 스킬 클래스를 직업별로 다르게 — 해머 기본 = 운석 소환 복사본 → 대지 줄기) · 직업이 맞는 행이 0 행보다 먼저
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; // 마지막 판정 뒤 원본 투사체를 끄기까지(초)
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;
// #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;
// #983 PD 「오브 기본 스킬 시전 시 플레이어의 전방(공격 대상이 있는 방향)으로 번개 3발이 순차적으로 낙하하며 번개 피해가 전진하며 피해를 입히는 형태 (쾅, 쾅, 쾅! 0.3초 간격으로 전진 된 위치에 순차 낙하하는 번개)」 —
// meteorPattern "line" = 무작위 대신 시전 순간 시전자 자리에서 대상 쪽(없으면 정면) 직선 위 meteorStart + meteorStep × i(m) 자리에 차례로(이펙트 앞 = 그 방향)
public string meteorPattern = "";
public float meteorStart = 1.5f, meteorStep = 1.8f;
// #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 };
}
/// <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 = "";
public int classId; // #996 0 = 모든 직업 · > 0 = 이 직업일 때만(같은 원본 스킬을 다른 직업 그릇으로 쓸 때)
public WLStaffEarthFx fx = new WLStaffEarthFx();
}
[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;
// #967 PD 「지팡이 공격 마지막 타 — 기본 공격 투사체 대신 메테오(각성 스킬에 사용중인 이펙트) 한 발이 대상에게 떨어지도록」 — 1 = 3타 투사체를 쏘는 순간(겉모습을 붙인 뒤) 넘겨받아
// 숨기고 meteorRule(메테오 방식 · 운석 1개 · 대상 자리)로 떨어뜨려 땅에 닿는 순간 그 투사체 피해 · 이 경우 바위 솟음(fx)은 띄우지 않는다 · 0 = 이전(투사체 + 맞은 자리 바위)
public int meteor;
public WLStaffEarthSkill meteorRule = new WLStaffEarthSkill();
// #983 PD 「오브 "기본 공격" 마지막 타(3타)에서는 공격 직후 다시 모션을 캔슬하는 모습인데, 공격 상태의 손을 내지른 상태를 잠시 유지한 채 투사체를 날리지 말고 타겟중인 대상에게 번개가 1발을 낙하」 —
// holdSeconds > 0 = 넘겨받는 순간(발사 시점) holdDelay 뒤 애니메이터를 holdSeconds 동안 멈춤(자세 유지 · 다른 상태로 바뀌거나 다른 코드가 속도를 바꾸면 바로 손 뗌)
public float holdDelay, holdSeconds;
// #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」)
}
[System.Serializable]
public class WLStaffEarthJson
{
public int enabled = 0;
public string[] castSkills = new string[0];
public int[] castSkipClasses = new int[0]; // #1007 castSkills 이름이 같아도 이 직업은 chargeFx · auraFx 를 띄우지 않음(창 주력 = Skill_Hurricane → 창 끝에 지팡이 모으기 빛이 뭉침)
public WLStaffEarthFx chargeFx = new WLStaffEarthFx();
public WLStaffEarthFx auraFx = new WLStaffEarthFx();
public WLStaffEarthSkill[] skills = new WLStaffEarthSkill[0];
public WLStaffEarthPopup popup = new WLStaffEarthPopup();
public WLStaffEarthPopup[] popups = new WLStaffEarthPopup[0]; // #983 popup 과 같은 규칙을 다른 직업에(오브 10301 3타 = 번개 1발 · 자세 유지) — 직업 · 상태가 맞는 첫 규칙(popup 먼저)
// #963 PD 「플레이어가 마법을 시전할 때는 Hyper Casual FX Pack Vol.1 의 마법진을 플레이어가 있는 바닥에 재생되었다가 시전(주황색 계열)」 — castCircleClasses 직업의 스킬 시전(SkillCast)마다 발밑에 castCircle(시전 시간 + life · 따라감)
public int[] castCircleClasses = new int[0];
public WLStaffEarthFx castCircle = new WLStaffEarthFx();
public WLSkillCastFx[] skillCastFx = new WLSkillCastFx[0]; // #966 스킬별 시전 이펙트(시전자)
public WLSkillWeaponGlow[] weaponGlow = new WLSkillWeaponGlow[0]; // #997 스킬 시전(모으기) 동안 무기 발광(WLWeaponGlowFx · 대검 · 비수와 같은 재질)
public string weaponGlowMaterial = "WL/Fx/WL_Mat_WeaponGlow";
}
public static class WLStaffEarth
{
public const string ResourcesPath = "WL/WLStaffEarth";
public static int Casts, TakeOvers, Hits, Launches, Marked, Popups, Errors, Circles, Meteors, SkillCastFxShown, Attack3Meteors, NoExtra; // #967 Attack3Meteors = 3타 투사체를 메테오로 넘겨받은 수
static ProjectileBase s_pendingMeteor; // #967 OnProjectileSet 이 고른 3타 투사체 — 겉모습 훅이 끝난 뒤(AfterProjectileSet) 넘겨받는다
static WLStaffEarthPopup s_pendingPopup; // #983 그 투사체에 맞은 3타 규칙(popup · popups)
public static int Holds; public static string HoldLog = ""; // #983 3타 자세 유지 횟수 · 마지막 기록
public static string MeteorSrc = ""; public static Vector3 MeteorCenter; public static readonly List<Vector3> MeteorPts = new List<Vector3>(), MeteorCenters = new List<Vector3>(); // #964 검증용(메테오 중심 출처 · 낙하 자리)
public static string LastLog = "";
static WLStaffEarthJson s_cfg;
static readonly Dictionary<string, GameObject> s_prefabs = new Dictionary<string, GameObject>();
static readonly Dictionary<int, WLStaffEarthPopup> s_marked = new Dictionary<int, WLStaffEarthPopup>();
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()
{
s_cfg = null; s_prefabs.Clear(); s_marked.Clear(); s_pendingMeteor = null; s_pendingPopup = null; s_preAn = null; s_preArmed = true;
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;
if (pc == null || e.skill == null) return;
float cast = Mathf.Max(0f, e.skill.f_CastingTime);
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 마법 시전 마법진
if (pc.IsMainPC() && c.skillCastFx != null) // #966 스킬별 시전 이펙트(비수 각성 = HCFX Energy_01 사본)
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++; }
if (c.castSkills == null || System.Array.IndexOf(c.castSkills, e.skill.e_Skill.ToString()) < 0) return;
if (c.castSkipClasses != null && System.Array.IndexOf(c.castSkipClasses, pc.Get_ID()) >= 0) return; // #1007
var sock = Socket(pc, c.chargeFx.socket);
if (Spawn(c.chargeFx, pc, sock, sock != null ? Tip(sock, c.chargeFx) : pc.Get_Center_position(), cast) != null) Casts++;
Spawn(c.auraFx, pc, pc.transform, pc.Get_position(), cast);
}
catch (System.Exception ex) { Errors++; LastLog = "시전 이펙트 실패 — " + ex.Message; }
}
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(); }
}
/// <summary>무기 소켓 — 이름이 맞는 켜진 소켓, 없으면 무기(자식)가 달린 켜진 첫 소켓.</summary>
/// <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타 모으기 빛과 같은 손)
{
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>
static Vector3 Tip(Transform sock, WLStaffEarthFx fx)
{
if (fx != null && (fx.head == 2 || fx.head == 3)) return sock.position; // #997 2 = 소켓(무기 든 손) 자리 — PD 「지팡이가 아니라 손이 빛나게」 · #999 3 = 빈 손 뼈(부르는 쪽이 EmptyHand 를 넘김)
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);
}
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");
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 는 흐려지기 시작할 때의 세기를 기준으로 낮춤)
return go;
}
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); }
}
// ── 주력 · 각성: 원본 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;
WLStaffEarthSkill r = null; int cls = pd.shooter.Get_ID(); // #975 직업이 맞는 행 먼저 · 없으면 classId 0 행
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; }
if (r == null) return false;
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 활 멀티샷과 같은 방식)
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>
internal static void HitAll(ProjectileBase pb, Actor shooter, Vector3 center, WLStaffEarthSkill r, float reach, HashSet<Actor> done, Vector3 pushDir = default(Vector3))
{
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++;
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++;
}
}
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;
WLStaffEarthPopup hit = null; int wid = EquippedWeaponId();
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;
if (p.weaponIds != null && p.weaponIds.Length > 0 && System.Array.IndexOf(p.weaponIds, wid) < 0) continue; // #1002 같은 직업 다른 무기
// #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());
}
catch (System.Exception ex) { Errors++; LastLog = "3타 표시 실패 — " + ex.Message; }
}
/// <summary>#967 투사체 Set 끝 훅(WLProjectileVisual.OnSet 마지막 · 겉모습 자식까지 붙은 뒤) — OnProjectileSet 이 고른 3타 투사체를 메테오 러너로 넘겨받는다(겉모습 · 판정 상자 · 이동을 끄고 대상 자리에 운석 1개).</summary>
public static void AfterProjectileSet(ProjectileBase pb)
{
if (pb == null || !ReferenceEquals(s_pendingMeteor, pb)) return;
var rule = s_pendingPopup; s_pendingMeteor = null; s_pendingPopup = null;
try
{
var c = Cfg; var pd = pb.Get_ProjectTileData(); if (pd == null || DSUtil.CheckNull(pd.shooter)) return;
if (rule == null) rule = c.popup;
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, rule.meteorRule);
if (rule.holdSeconds > 0f) s_runner.Hold(pd.shooter as PCActor, rule); // #983 손 내지른 자세 유지
Attack3Meteors++;
}
catch (System.Exception ex) { Errors++; LastLog = "3타 메테오 실패 — " + ex.Message; }
}
static void OnHitConfirmed(in HitConfirmedEvent e)
{
try
{
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;
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; }
}
// ── #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; }
}
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; }
}
/// <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(); }
}
/// <summary>#935 넘겨받은 원본 스킬 투사체 1 개의 진행(이펙트 → 판정 → 끄기). 숨김 러너 1 개가 코루틴을 돌린다(원본 투사체가 먼저 꺼져도 판정은 이어감).</summary>
public sealed class WLStaffEarthRunner : MonoBehaviour
{
public void Begin(ProjectileBase pb, ProjectileData pd, WLStaffEarthSkill r) { StartCoroutine(Run(pb, pd, r)); }
/// <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;
}
/// <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;
if (WLStaffEarth.SpawnAt(r.traceFx, shooter, pt, shooter.transform.forward, 0f) != null) WLStaffEarth.Traces++; // #1006 지면 흔적
WLStaffEarth.HitAll(pb, shooter, pt, r, r.radius, new HashSet<Actor>());
}
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;
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;
}
if (r.meteors > 0) // #963 메테오 — 운석마다 무작위 자리 · 땅에 닿는 시각에 그 자리 판정 + 기절
{
// #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();
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;
// #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;
for (int i = 0; i < r.meteors; i++)
{
if (r.center != "caster" && !DSUtil.CheckNull(target) && !target.IsDead()) last = target.Get_position();
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);
WLStaffEarth.MeteorPts.Add(pt); WLStaffEarth.MeteorCenters.Add(c0); WLStaffEarth.MeteorCenter = c0; // #964 검증용(낙하 자리 · 그때 중심)
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);
}
WLStaffEarth.LastLog = r.skill + " 메테오 " + r.meteors + "개 · " + (line ? "직선 " + r.meteorStart + " + " + r.meteorStep + " m 씩" : "반경 " + r.meteorArea + " m");
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;
}
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);
if (!DSUtil.CheckNull(shooter) && WLStaffEarth.SpawnAt(r.traceFx, shooter, center, fwd, 0f) != null) WLStaffEarth.Traces++; // #1006 지면 흔적
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;
}
/// <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;
}
}
}