From b19aca15bd6bcef962e9f275dccb1d758ac0f3f4 Mon Sep 17 00:00:00 2001 From: swrring Date: Tue, 20 Oct 2026 12:53:38 +0900 Subject: [PATCH] =?UTF-8?q?WL=20#948=20=EB=A7=88=EB=B2=95=EA=B2=80(1071=20?= =?UTF-8?q?=C2=B7=20=EC=A7=80=ED=8C=A1=EC=9D=B4=2010302)=204=EA=B1=B4=20?= =?UTF-8?q?=E2=80=94=20=E2=91=A0=20=ED=88=AC=EC=82=AC=EC=B2=B4=20=EA=BC=AC?= =?UTF-8?q?=EB=A6=AC(=EC=9B=90=EB=B3=B8=20FX=5FShoot=5F06=20particle=20?= =?UTF-8?q?=EB=8A=98=EB=A6=BC=20=EB=B9=8C=EB=B3=B4=EB=93=9C)=20hideNames?= =?UTF-8?q?=20=E2=91=A1=201=C2=B72=ED=83=80=20=EB=AA=85=EC=A4=91=20=3D=200?= =?UTF-8?q?.5=20m=20=EB=B0=80=EB=A6=BC=20+=20=EA=B2=BD=EC=A7=81=200.3=20s(?= =?UTF-8?q?WLStageHitReaction=2010302=20=ED=96=89)=20=E2=91=A2=203?= =?UTF-8?q?=ED=83=80=20=3D=20=EA=B2=89=EB=AA=A8=EC=8A=B5=C2=B7=EB=AA=85?= =?UTF-8?q?=EC=A4=91=20=EC=9D=B4=ED=8E=99=ED=8A=B8=20=C3=970.5(WLProjectil?= =?UTF-8?q?eVisual=20stageScales)=20+=200.2=20s=20=EB=92=A4=20=EB=9D=84?= =?UTF-8?q?=EC=9B=80=200.7=20m=20=C2=B7=20=EB=B0=98=EB=8C=80=EC=AA=BD=201.?= =?UTF-8?q?5=20m=20=EB=B0=80=EB=A6=BC(delaySeconds=20=EC=8B=A0=EC=84=A4)?= =?UTF-8?q?=20=E2=91=A3=203=ED=83=80=20=EB=8F=99=EC=9E=91=20=3D=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=ED=81=B4=EB=A6=BD=EC=97=90=20=EC=83=81?= =?UTF-8?q?=EC=B2=B4=20=EC=95=84=EB=B0=94=ED=83=80=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=81=AC(=EB=A3=A8=ED=8A=B8=C2=B7=EB=8B=A4=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8=20=C2=B7=20=EB=A7=A4=ED=95=91=20maskPath=20?= =?UTF-8?q?=EC=8B=A0=EC=84=A4=20=C2=B7=20WL=5FUpperBody.mask=20=C2=B7=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC=20CopyFromOther)=20=C2=B7=20ComboTracker=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=97=86=EB=8A=94=20WL=20=EC=84=9C?= =?UTF-8?q?=EB=B8=8C=ED=81=B4=EB=A6=BD(=5FS=5F=20=EC=95=9E=20=EC=88=AB?= =?UTF-8?q?=EC=9E=90)=20=EB=8B=A8=EA=B3=84=20=EC=9D=B8=EC=8B=9D=20=C2=B7?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=95=98=EB=84=A4=EC=8A=A4=20?= =?UTF-8?q?=3D=20=EC=84=AC=EC=B2=98=EB=9F=BC=20Set=5FObj=20=EB=8F=99?= =?UTF-8?q?=EC=95=88=EB=A7=8C=20Stage(=EB=A1=9C=EB=B9=84=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EB=B0=A9=EC=A7=80)=20=C2=B7=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=20=EC=9E=AC=EA=B2=A8=EB=88=94=20=C2=B7=20?= =?UTF-8?q?=EC=82=AC=EA=B1=B0=EB=A6=AC=20=EB=B0=96=20=EB=8D=94=EB=AF=B8=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20R?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실측(테스트 씬 Play): 1·2·3타 반응 로그 각 적용 · 3타 겉모습 ×0.5 11회 · 비행 중 꼬리 입자 숨김 · 3타 자세 제자리(캡처) · 콘솔 게임 오류 0 · 매핑 재굽기 백업 비교 = 변경 1(CastspellC 메타 마스크) Co-Authored-By: Claude Fable 5.1 --- AgentScripts/WL_CombatAnimSetup.cs | 14 ++++ .../Anim@CastspellC_wing.fbx.meta | 56 ++++++++++++++-- Assets/WL/Combat/CombatAnimMapping.asset | 1 + Assets/WL/Combat/CombatAnimMapping.cs | 3 + Assets/WL/Combat/Core/ComboTracker.cs | 9 ++- Assets/WL/Combat/Core/WLCombatCoreSettings.cs | 3 + Assets/WL/Combat/Masks.meta | 8 +++ Assets/WL/Combat/Masks/WL_UpperBody.mask | 11 ++++ Assets/WL/Combat/Masks/WL_UpperBody.mask.meta | 8 +++ .../Resources/WL/WLProjectileVisual.json | 7 +- .../Resources/WL/WLStageHitReaction.json | 40 ++++++++++++ Assets/WL/Combat/Weapon/WLProjectileVisual.cs | 35 +++++++++- Assets/WL/Combat/Weapon/WLStageHitReaction.cs | 27 ++++++-- .../SkillTest/Resources/WL/WLSkillTest.json | 3 + Assets/WL/SkillTest/WLSkillTestHarness.cs | 64 ++++++++++++++++++- 15 files changed, 271 insertions(+), 18 deletions(-) create mode 100644 Assets/WL/Combat/Masks.meta create mode 100644 Assets/WL/Combat/Masks/WL_UpperBody.mask create mode 100644 Assets/WL/Combat/Masks/WL_UpperBody.mask.meta diff --git a/AgentScripts/WL_CombatAnimSetup.cs b/AgentScripts/WL_CombatAnimSetup.cs index 65a1eabf0..efea5b87e 100644 --- a/AgentScripts/WL_CombatAnimSetup.cs +++ b/AgentScripts/WL_CombatAnimSetup.cs @@ -169,6 +169,18 @@ public static class WL_CombatAnimSetup return sb.ToString(); } + // #948 — 매핑 행의 maskPath(AvatarMask 에셋)를 임포터 서브클립에 씌운다(CopyFromOther). 비우면 원본 설정 그대로. + // 상체 마스크(루트·다리 제외) = 지팡이 3타를 점프 없이 제자리에서 상체만(PD 「하체는 서 있는 상태에서 상체만」). + private static void ApplyMask(UnityEditor.ModelImporterClipAnimation c, WL.Combat.StateClipSpec st, StringBuilder sb) + { + if (c == null || st == null || string.IsNullOrEmpty(st.maskPath)) return; + var mask = UnityEditor.AssetDatabase.LoadAssetAtPath(st.maskPath); + if (mask == null) { sb.AppendLine(" [WARN] 마스크 에셋 없음: " + st.maskPath); return; } + c.maskType = UnityEditor.ClipAnimationMaskType.CopyFromOther; + c.maskSource = mask; + sb.AppendLine(" 마스크 " + mask.name + " → " + c.name); + } + // ── 서브클립 보장 ──────────────────────────────────────────────────────── // FBX 임포터의 clipAnimations 에 이 없으면 원본 클립 구간을 복제해 추가하고, // events 를 매핑대로 다시 쓴다. 이미 있으면 events 만 갱신한다. @@ -232,6 +244,7 @@ public static class WL_CombatAnimSetup maskType = src.maskType, events = evs, }; + ApplyMask(copy, st, sb); // #948 list.Add(copy); sb.AppendLine(" + 서브클립 생성 " + clipName + " (" + src.name + " 복제 · " + src.firstFrame + "~" + src.lastFrame + "f · " + clipLength.ToString("0.000") + "s)"); @@ -241,6 +254,7 @@ public static class WL_CombatAnimSetup var c = list[idx]; c.events = evs; if (rangeOverride) { c.firstFrame = useFirst; c.lastFrame = useLast; } + ApplyMask(c, st, sb); // #948 list[idx] = c; sb.AppendLine(" ~ 서브클립 이벤트 갱신 " + clipName + (rangeOverride ? " (범위 " + useFirst + "~" + useLast + "f)" : "")); } diff --git a/Assets/ResWork/Suriyun/Animations/AnimationsWing/Anim@CastspellC_wing.fbx.meta b/Assets/ResWork/Suriyun/Animations/AnimationsWing/Anim@CastspellC_wing.fbx.meta index 1b9f1d553..837ef3727 100644 --- a/Assets/ResWork/Suriyun/Animations/AnimationsWing/Anim@CastspellC_wing.fbx.meta +++ b/Assets/ResWork/Suriyun/Animations/AnimationsWing/Anim@CastspellC_wing.fbx.meta @@ -1,7 +1,7 @@ fileFormatVersion: 2 guid: a9b647a73aef2fb4a83c157be5e4867c ModelImporter: - serializedVersion: 22200 + serializedVersion: 24300 internalIDToNameTable: - first: 1: 100000 @@ -1326,6 +1326,7 @@ ModelImporter: materialName: 0 materialSearch: 1 materialLocation: 0 + searchTexturesGlobally: 1 animations: legacyGenerateAnimations: 4 bakeSimulation: 0 @@ -1333,8 +1334,6 @@ ModelImporter: optimizeGameObjects: 0 removeConstantScaleCurves: 0 motionNodeName: - rigImportErrors: - rigImportWarnings: animationImportErrors: animationImportWarnings: animationRetargetingWarnings: @@ -1352,7 +1351,7 @@ ModelImporter: - serializedVersion: 16 name: CastspellC_wing takeName: Anim@CastspellC - internalID: 0 + internalID: 7400000 firstFrame: 431 lastFrame: 495 wrapMode: 0 @@ -1801,6 +1800,49 @@ ModelImporter: maskType: 1 maskSource: {fileID: 101100000, guid: 61b0253f83d533f49bbc43d584500982, type: 2} additiveReferencePoseFrame: 430.80002 + - serializedVersion: 16 + name: StaffJumpCast3_S_10302 + takeName: Anim@CastspellC + internalID: -4485281783395832467 + firstFrame: 431 + lastFrame: 487 + wrapMode: 0 + orientationOffsetY: 0 + level: 0 + cycleOffset: 0 + loop: 0 + hasAdditiveReferencePose: 0 + loopTime: 0 + loopBlend: 0 + loopBlendOrientation: 1 + loopBlendPositionY: 1 + loopBlendPositionXZ: 1 + keepOriginalOrientation: 1 + keepOriginalPositionY: 1 + keepOriginalPositionXZ: 1 + heightFromFeet: 0 + mirror: 0 + bodyMask: 01000000010000000100000001000000010000000100000001000000010000000100000001000000010000000100000001000000 + curves: [] + events: + - time: 0 + functionName: LookAtTarget + data: + objectReferenceParameter: {instanceID: 0} + floatParameter: 0 + intParameter: 0 + messageOptions: 0 + - time: 0.43 + functionName: Projectile + data: Projectile_10302 + objectReferenceParameter: {instanceID: 0} + floatParameter: 0 + intParameter: 0 + messageOptions: 0 + transformMask: [] + maskType: 1 + maskSource: {fileID: 31900000, guid: 8568a603ff2c2854190b73771710444a, type: 2} + additiveReferencePoseFrame: 0 isReadable: 1 meshes: lODScreenPercentages: [] @@ -1827,6 +1869,9 @@ ModelImporter: maxBonesPerVertex: 4 minBoneWeight: 0.001 optimizeBones: 1 + generateMeshLods: 0 + meshLodGenerationFlags: 0 + maximumMeshLod: -1 meshOptimizationFlags: -1 indexFormat: 1 secondaryUVAngleDistortion: 8 @@ -3320,7 +3365,8 @@ ModelImporter: hasTranslationDoF: 0 hasExtraRoot: 1 skeletonHasParents: 1 - lastHumanDescriptionAvatarSource: {fileID: 9000000, guid: 91ee43d975a10d245a0eeb62290d5072, type: 3} + lastHumanDescriptionAvatarSource: {fileID: 9000000, guid: 91ee43d975a10d245a0eeb62290d5072, + type: 3} autoGenerateAvatarMappingIfUnspecified: 1 animationType: 3 humanoidOversampling: 1 diff --git a/Assets/WL/Combat/CombatAnimMapping.asset b/Assets/WL/Combat/CombatAnimMapping.asset index 57322f003..b1ad24391 100644 --- a/Assets/WL/Combat/CombatAnimMapping.asset +++ b/Assets/WL/Combat/CombatAnimMapping.asset @@ -992,6 +992,7 @@ MonoBehaviour: firstFrame: 431 lastFrame: 487 speed: 0 + maskPath: Assets/WL/Combat/Masks/WL_UpperBody.mask events: - functionName: LookAtTarget time: 0 diff --git a/Assets/WL/Combat/CombatAnimMapping.cs b/Assets/WL/Combat/CombatAnimMapping.cs index 20bed9d7d..d819516dc 100644 --- a/Assets/WL/Combat/CombatAnimMapping.cs +++ b/Assets/WL/Combat/CombatAnimMapping.cs @@ -87,6 +87,9 @@ namespace WL.Combat [Tooltip("#834 상태 재생 속도 배율(AnimatorState.speed). 0 = 건드리지 않음 · 1 = 원속 · 1.3 = 30 % 빠르게. 이벤트는 정규화 시각이라 함께 당겨진다.")] public float speed; + [Tooltip("#948 서브클립에 씌울 아바타 마스크 에셋 경로(Assets/… .mask). 비우면 원본 클립 설정 그대로. 상체 마스크(루트·다리 제외) = 점프·이동 없이 제자리에서 상체만 움직인다(지팡이 3타)")] + public string maskPath = ""; + public List events = new List(); } diff --git a/Assets/WL/Combat/Core/ComboTracker.cs b/Assets/WL/Combat/Core/ComboTracker.cs index ece55bd20..049d662ec 100644 --- a/Assets/WL/Combat/Core/ComboTracker.cs +++ b/Assets/WL/Combat/Core/ComboTracker.cs @@ -86,7 +86,14 @@ namespace WL.Combat.Core { if (string.IsNullOrEmpty(clip) || string.IsNullOrEmpty(token)) return 0; int idx = clip.IndexOf(token, System.StringComparison.OrdinalIgnoreCase); - if (idx < 0) return 0; + if (idx < 0) + { + // #948 지팡이처럼 토큰이 없는 WL 서브클립(CastFire1_S_10302 · Cast2_S_10302 · StaffJumpCast3_S_10302) — 접미사(_S_) 바로 앞 숫자가 단계. 실측: 2·3타가 0 단계로 읽혀 단계별 피격 반응이 1타에만 걸렸다 + var cs = WLCombatCoreSettings.Instance; string suf = cs != null ? cs.comboClipDigitSuffix : ""; + int s = string.IsNullOrEmpty(suf) ? -1 : clip.IndexOf(suf, System.StringComparison.OrdinalIgnoreCase); + if (s > 0 && clip[s - 1] >= '0' && clip[s - 1] <= '9') return clip[s - 1] - '0'; + return 0; + } for (int i = idx + token.Length; i < clip.Length; i++) { char c = clip[i]; diff --git a/Assets/WL/Combat/Core/WLCombatCoreSettings.cs b/Assets/WL/Combat/Core/WLCombatCoreSettings.cs index 71b15e7da..40975fbf9 100644 --- a/Assets/WL/Combat/Core/WLCombatCoreSettings.cs +++ b/Assets/WL/Combat/Core/WLCombatCoreSettings.cs @@ -71,6 +71,9 @@ namespace WL.Combat.Core [Tooltip("클립 이름에서 콤보 단계를 읽는 토큰. 토큰 바로 뒤의 첫 숫자가 단계(없으면 1). 예: Attack1_S_10501 → 1 · attack → 1 · Bow_Attack2 → 2.")] public string comboClipToken = "Attack"; + [Tooltip("#948 토큰이 없는 WL 서브클립(<이름><단계>_S_<직업> · CastFire1_S_10302 · Cast2_S_10302 · StaffJumpCast3_S_10302)은 이 접미사 바로 앞 숫자를 단계로 읽는다. 비우면 그런 클립은 0 단계.")] + public string comboClipDigitSuffix = "_S_"; + // ─────────────────────────────────────────── 감속 중재자 [Header("감속 중재자 (TimeScaleArbiter · 우선순위 Pause > Cinematic > HitStop · D-1(a))")] [Tooltip("끄면 WLHitFeelRunner 가 기존 코루틴 경로로 히트스톱을 건다(중재 없음).")] diff --git a/Assets/WL/Combat/Masks.meta b/Assets/WL/Combat/Masks.meta new file mode 100644 index 000000000..543283915 --- /dev/null +++ b/Assets/WL/Combat/Masks.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3093e91426560754fa2ffc1697fc2562 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Masks/WL_UpperBody.mask b/Assets/WL/Combat/Masks/WL_UpperBody.mask new file mode 100644 index 000000000..c42231d42 --- /dev/null +++ b/Assets/WL/Combat/Masks/WL_UpperBody.mask @@ -0,0 +1,11 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!319 &31900000 +AvatarMask: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: WL_UpperBody + m_Mask: 00000000010000000100000000000000000000000100000001000000010000000100000000000000000000000100000001000000 + m_Elements: [] diff --git a/Assets/WL/Combat/Masks/WL_UpperBody.mask.meta b/Assets/WL/Combat/Masks/WL_UpperBody.mask.meta new file mode 100644 index 000000000..c50b86a96 --- /dev/null +++ b/Assets/WL/Combat/Masks/WL_UpperBody.mask.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8568a603ff2c2854190b73771710444a +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 31900000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Weapon/Resources/WL/WLProjectileVisual.json b/Assets/WL/Combat/Weapon/Resources/WL/WLProjectileVisual.json index ee73285e1..c60bdc0f6 100644 --- a/Assets/WL/Combat/Weapon/Resources/WL/WLProjectileVisual.json +++ b/Assets/WL/Combat/Weapon/Resources/WL/WLProjectileVisual.json @@ -12,9 +12,14 @@ "projectile": "Projectile_10302", "visual": "WL/WL_FireBoltVisual", "scale": 1.0, + "_note_948": "#948 PD 마법검(1071): ① 「거리가 멀어질수록 꼬리처럼 길게 늘어지는 이펙트」 = 원본 FX_Shoot_06_projectile/particle(늘림 빌보드 · 비행 중 뒤로 남는 입자 · 실측 경계 2.5~3.2 m) → hideNames 추가 ② 「3타 공격의 크기를 절반」 = stageScales 3단계 겉모습·명중 이펙트 × 0.5(판정 그대로)", "hideNames": [ "FX_Shoot_06_projectile/trail", - "FX_Shoot_06_projectile/back_fire" + "FX_Shoot_06_projectile/back_fire", + "FX_Shoot_06_projectile/particle" + ], + "stageScales": [ + { "stage": 3, "scale": 0.5, "hitScale": 0.5 } ], "hitFx": "WL/Impact/WL_Hit_Staff", "hitScale": 0.37, diff --git a/Assets/WL/Combat/Weapon/Resources/WL/WLStageHitReaction.json b/Assets/WL/Combat/Weapon/Resources/WL/WLStageHitReaction.json index f85016394..aa392e8d6 100644 --- a/Assets/WL/Combat/Weapon/Resources/WL/WLStageHitReaction.json +++ b/Assets/WL/Combat/Weapon/Resources/WL/WLStageHitReaction.json @@ -1,6 +1,46 @@ { "enabled": 1, + "_note_948": "#948 PD 마법검(1071 · 직업 10302): 1·2타 투사체 명중 = 0.5 m 밀림 + 짧은 경직 · 3타 = 이펙트가 먼저 보이고(delaySeconds) 공중으로 튀어오르며 공격자 반대쪽으로 밀림(Fling · launchHeight)", "rules": [ + { + "classIds": [ + 10302 + ], + "stage": 1, + "knockbackSeconds": 0.15, + "knockbackSpeed": 0.0, + "stunSeconds": 0.0, + "launchHeight": 0.0, + "staggerSeconds": 0.3, + "knockbackDistance": 0.5, + "delaySeconds": 0.0 + }, + { + "classIds": [ + 10302 + ], + "stage": 2, + "knockbackSeconds": 0.15, + "knockbackSpeed": 0.0, + "stunSeconds": 0.0, + "launchHeight": 0.0, + "staggerSeconds": 0.3, + "knockbackDistance": 0.5, + "delaySeconds": 0.0 + }, + { + "classIds": [ + 10302 + ], + "stage": 3, + "knockbackSeconds": 0.4, + "knockbackSpeed": 0.0, + "stunSeconds": 0.3, + "launchHeight": 0.7, + "staggerSeconds": 0.0, + "knockbackDistance": 1.5, + "delaySeconds": 0.2 + }, { "classIds": [ 10501 diff --git a/Assets/WL/Combat/Weapon/WLProjectileVisual.cs b/Assets/WL/Combat/Weapon/WLProjectileVisual.cs index 3f0024a50..121fde87f 100644 --- a/Assets/WL/Combat/Weapon/WLProjectileVisual.cs +++ b/Assets/WL/Combat/Weapon/WLProjectileVisual.cs @@ -37,8 +37,13 @@ namespace WL.Combat.Weapon public string hitFx = ""; public float hitScale = 1f; public float hitLife = 1.5f; + // #948 PD 「마법검 3타 공격의 크기를 절반으로」 — 쏜 PC 의 콤보 단계별 겉모습(보이는 자식)·명중 이펙트 배율. 판정(루트 콜라이더)은 그대로 · 항목 없는 단계 = 1 + public WLProjectileStageScale[] stageScales = new WLProjectileStageScale[0]; } + [System.Serializable] + public class WLProjectileStageScale { public int stage; public float scale = 1f; public float hitScale = 1f; } + [System.Serializable] public class WLProjectileVisualJson { @@ -79,7 +84,6 @@ namespace WL.Combat.Weapon { if (pb == null) return; WLStaffEarth.OnProjectileSet(pb); // #935 지팡이 3타 투사체 표시(쏠 때마다 · 이미 겉모습을 붙인 풀 인스턴스도) - if (s_done.Contains(pb.GetInstanceID())) return; if (!s_loaded) Load(); WLProjectileVisualRule rule; var n = pb.name; @@ -89,8 +93,33 @@ namespace WL.Combat.Weapon s_rules.TryGetValue(key, out rule); s_byName[n] = rule; } if (rule == null) return; - Events++; - if (Attach(pb, rule)) s_done.Add(pb.GetInstanceID()); + if (!s_done.Contains(pb.GetInstanceID())) { Events++; if (Attach(pb, rule)) s_done.Add(pb.GetInstanceID()); } + ApplyStageScale(pb, rule); // #948 매 발사(풀 재사용 포함) — 콤보 단계별 배율 + } + + public static int StageScaled; public static string StageLog = ""; + static readonly Dictionary s_baseScales = new Dictionary(); // 투사체 인스턴스 → 직접 자식들의 기본 localScale + + /// #948 쏜 PC 의 콤보 단계(ComboTracker.LastStage)에 맞는 stageScales 항목이 있으면 보이는 직접 자식(원본 FX 노드 · ~WL_Visual)의 localScale 과 명중 이펙트 배율을 곱한다. 콜라이더가 달린 자식(판정)은 건드리지 않는다. 항목이 없으면 기본값(풀 재사용 복원). + static void ApplyStageScale(ProjectileBase pb, WLProjectileVisualRule rule) + { + if (rule.stageScales == null || rule.stageScales.Length == 0) return; + var pd = pb.Get_ProjectTileData(); var shooter = pd != null ? pd.shooter as PCActor : null; + var ct = shooter != null ? shooter.GetComponent() : null; + int stage = ct != null ? ct.LastStage : 0; + WLProjectileStageScale e = null; + foreach (var s in rule.stageScales) if (s != null && s.stage == stage) { e = s; break; } + float mul = e != null ? e.scale : 1f, hitMul = e != null ? e.hitScale : 1f; + int id = pb.GetInstanceID(); var t = pb.transform; Vector3[] bases; + if (!s_baseScales.TryGetValue(id, out bases) || bases.Length != t.childCount) + { + bases = new Vector3[t.childCount]; + for (int i = 0; i < t.childCount; i++) bases[i] = t.GetChild(i).localScale; + s_baseScales[id] = bases; + } + for (int i = 0; i < t.childCount; i++) { var c = t.GetChild(i); if (c.GetComponent() != null) continue; c.localScale = bases[i] * mul; } + var hf = pb.GetComponent(); if (hf != null) hf.scale = rule.hitScale * hitMul; + if (e != null) { StageScaled++; StageLog = pb.name + " 단계 " + stage + " 겉모습 × " + mul + " · 명중 × " + hitMul; } } /// #917 속사(WLRapidShot)가 쏜 직후 부른다 — Set 훅(OnSet)이 이미 붙였으면 아무것도 안 한다(1 회). diff --git a/Assets/WL/Combat/Weapon/WLStageHitReaction.cs b/Assets/WL/Combat/Weapon/WLStageHitReaction.cs index 8820b5e52..ae1b29aef 100644 --- a/Assets/WL/Combat/Weapon/WLStageHitReaction.cs +++ b/Assets/WL/Combat/Weapon/WLStageHitReaction.cs @@ -24,6 +24,7 @@ namespace WL.Combat.Weapon public float knockbackDistance; // #906 0 보다 크면 원본 KnockBack 대신 WLLaunch.Fling 으로 정확히 이만큼(m) knockbackSeconds 초에 밀고 띄운다 public float staggerSeconds; // #906 피격 경직(피격 자세 굳힘 + 이동 · 공격 잠금)을 이 시간(초)까지 늘림 — 다음 타가 자연스럽게 맞게 public int wallBounce; // #908 1 = knockbackDistance 로 날아가다 벽(이동 불가 경계)에 닿으면 튕겨 남은 거리를 계속 + public float delaySeconds; // #948 타격 확정 뒤 이만큼(초) 기다렸다 넉백·띄우기·기절을 건다 — PD 「이펙트 등장 후 타이밍에 맞게 몬스터가 튀어오르며 밀려나게(지금은 이펙트 생성과 동시에)」 · 경직은 즉시 } [System.Serializable] @@ -69,12 +70,8 @@ namespace WL.Combat.Weapon foreach (var r in rules) { if (r == null || r.stage != stage || r.classIds == null || System.Array.IndexOf(r.classIds, cls) < 0) continue; - if (r.knockbackDistance > 0f) WLLaunch.Fling(e.victim, pc, r.knockbackDistance, Mathf.Max(0.05f, r.knockbackSeconds), r.launchHeight, r.stunSeconds, r.wallBounce != 0); - else - { - if (r.knockbackSeconds > 0f || r.stunSeconds > 0f) WLLaunch.KnockbackStun(e.victim, pc, r.knockbackSeconds, r.knockbackSpeed, r.stunSeconds); - if (r.launchHeight > 0f && r.knockbackSeconds > 0f) WLLaunch.LiftOnly(e.victim, r.launchHeight, WLLaunch.EffectiveKnockbackSeconds(r.knockbackSeconds)); - } + if (r.delaySeconds > 0f) e.victim.StartCoroutine(CoDelayed(r, e.victim, pc)); // #948 이펙트가 먼저 보이고 몸이 뒤따르게 + else Push(r, e.victim, pc); if (r.staggerSeconds > 0f) WL.Combat.Reaction.MobStagger.Extend(e.victim, r.staggerSeconds); Applied++; LastLog = "stage " + stage + " → " + e.victim.name + " 넉백 " + (r.knockbackDistance > 0f ? r.knockbackDistance : WLLaunch.EffectiveKnockbackSeconds(r.knockbackSeconds) * r.knockbackSpeed).ToString("0.##") + " m · 띄움 " + r.launchHeight + " m · 기절 " + r.stunSeconds + " s · 경직 " + r.staggerSeconds + " s"; return; @@ -82,5 +79,23 @@ namespace WL.Combat.Weapon } catch (System.Exception ex) { Errors++; LastLog = "실패 — " + ex.Message; } } + + static void Push(WLStageHitRule r, Actor victim, PCActor pc) + { + if (DSUtil.CheckNull(victim) || victim.IsDead()) return; + if (r.knockbackDistance > 0f) WLLaunch.Fling(victim, pc, r.knockbackDistance, Mathf.Max(0.05f, r.knockbackSeconds), r.launchHeight, r.stunSeconds, r.wallBounce != 0); + else + { + if (r.knockbackSeconds > 0f || r.stunSeconds > 0f) WLLaunch.KnockbackStun(victim, pc, r.knockbackSeconds, r.knockbackSpeed, r.stunSeconds); + if (r.launchHeight > 0f && r.knockbackSeconds > 0f) WLLaunch.LiftOnly(victim, r.launchHeight, WLLaunch.EffectiveKnockbackSeconds(r.knockbackSeconds)); + } + } + + /// #948 지연 뒤 밀기·띄우기 — 피해자(Actor) 코루틴이라 그 사이 죽거나 꺼지면 자연히 멈춘다. + static System.Collections.IEnumerator CoDelayed(WLStageHitRule r, Actor victim, PCActor pc) + { + yield return new WaitForSeconds(r.delaySeconds); + try { Push(r, victim, pc); } catch (System.Exception ex) { Errors++; LastLog = "지연 밀기 실패 — " + ex.Message; } + } } } diff --git a/Assets/WL/SkillTest/Resources/WL/WLSkillTest.json b/Assets/WL/SkillTest/Resources/WL/WLSkillTest.json index c1967c07d..d9fb2b04e 100644 --- a/Assets/WL/SkillTest/Resources/WL/WLSkillTest.json +++ b/Assets/WL/SkillTest/Resources/WL/WLSkillTest.json @@ -8,6 +8,9 @@ "dummyOffset": [0, 0, 0], "dummyStill": 1, "preloadEffects": 0, + "gameMode": "Lobby", + "combatFormCheckSeconds": 0.5, + "autoResetDummySeconds": 2.0, "moveKeys": 1, "attackKey": "Space", "weaponPrevKey": "O", diff --git a/Assets/WL/SkillTest/WLSkillTestHarness.cs b/Assets/WL/SkillTest/WLSkillTestHarness.cs index 6abadb0d6..750fe74e9 100644 --- a/Assets/WL/SkillTest/WLSkillTestHarness.cs +++ b/Assets/WL/SkillTest/WLSkillTestHarness.cs @@ -48,6 +48,9 @@ namespace WL.SkillTest public float[] dummyOffset = { 0f, 0f, 0f }; // 전부 0 = WLIslandMobSettings.hitTestDummyOffset public int dummyStill = 1; // 1 = 더미 제자리 public int preloadEffects = 0; // 1 = 원본 InGameInfo 이펙트·스킬 예열(514 인스턴스 · 약 18 s) · 0 = 생략(첫 사용 때 로드) + public string gameMode = "Lobby"; // InGameInfo 게임 모드 — 섬과 같은 Lobby(자동 전투·대상 찾기가 로비 규칙). Stage 로 두면 PC 가 대상 없이 달린다(#948 실측). 로비 컨트롤러(pcanim_lobby) 문제는 Set_Obj 동안만 Stage 로 바꾸는 전투 형태 적용(섬 WLIslandAvatar.ApplyCombatForm 과 같은 꼴)으로 푼다 + public float combatFormCheckSeconds = 0.5f; // 이 간격으로 PC 가 로비 컨트롤러면 전투 형태 다시 적용 · 대상이 없으면 더미를 다시 겨눔 + public float autoResetDummySeconds = 2.0f; // 더미가 공격 사거리 밖(넉백 누적)에 이만큼(초) 머물면 PC 앞 기본 자리로(R 키와 같음) → 콤보가 끊기지 않고 반복된다(#948 · 0 = 끔) public int moveKeys = 1; // 1 = WASD/방향키도 이동(가상패드와 함께 · PD #947 「가상패드 + 키 입력」) public string attackKey = "Space"; public string weaponPrevKey = "O"; // PD #947: O = 무기 왼쪽 순환 @@ -81,7 +84,7 @@ namespace WL.SkillTest static WLSkillTestJson s_cfg; static System.Reflection.FieldInfo s_fEffects, s_fMagic, s_fMagicArr; - float _lastRt, _simH, _simV; + float _lastRt, _simH, _simV, _formTimer, _outOfRange; bool _moving, _slow; KeyCode _kAttack = KeyCode.Space, _kPrev = KeyCode.O, _kNext = KeyCode.P, _kReset = KeyCode.R, _kSlow = KeyCode.T, _kPause = KeyCode.Backslash; string _msg = ""; @@ -132,6 +135,7 @@ namespace WL.SkillTest // 예열을 켠 경우만: InGameInfo.Start 의 이펙트 예열 루프(테이블 0.1 s 폴링 뒤 1프레임)가 끝난 다음에 PC 를 만든다(먼저 만들면 PC 피격 이펙트 키가 먼저 등록돼 그 루프의 Add 가 중복 · #947 2차 Play 실측 · 원본에 가드도 넣음) if (cfg.preloadEffects != 0) for (float t = 0f; t < 3f && !EffectsPreloaded(); t += Time.unscaledDeltaTime) yield return null; + if (!Try("gamemode", () => SetGameMode(cfg.gameMode))) yield break; Lap("ingameinfo"); if (pcActor == null || dummyActor == null) { Fail("refs-missing", "pcActor/dummyActor 참조 없음 — Build Scene 다시"); yield break; } @@ -146,7 +150,7 @@ namespace WL.SkillTest cls = ResolveClass(cfg); pcActor.transform.position = NavPos(V3(cfg.spawnPos)); pcActor.gameObject.SetActive(true); // 빌더가 꺼 둔 것을 Set 직전에 켠다(원본 Set 도 켜지만 그 전에 콜라이더 bounds 를 읽는다) - pcActor.Set(false, null, sdata, cls, true); + WithStageMode(() => pcActor.Set(false, null, sdata, cls, true)); // Set 안의 Set_Obj 가 Lobby 면 로비 컨트롤러를 입힌다(#948 실측: 지팡이가 pcanim_lobby 로 서서 투사체 0) → 섬처럼 그 동안만 Stage FillMagicSlots(pcActor, sdata); ActorInfo.Ins.Add_Actor(pcActor, eRole.PC, 0, pcActor.name); })) yield break; @@ -202,6 +206,60 @@ namespace WL.SkillTest void Fail(string stage, string msg) { Stage = stage; LastLog = msg; WriteStatus(); Debug.LogWarning("[WLSkillTest] " + stage + " — " + msg); } // LogError 금지(원본 ErrorLogHook 팝업) + static System.Reflection.FieldInfo s_fMode; + public static int CombatForms; + + static System.Reflection.FieldInfo ModeField() + { + if (s_fMode == null) s_fMode = typeof(InGameInfo).GetField("m_GameMode", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); + return s_fMode; + } + + /// InGameInfo 의 비공개 m_GameMode 를 JSON 값(기본 Lobby = 섬과 같음)으로. + static void SetGameMode(string mode) + { + if (string.IsNullOrEmpty(mode)) return; + eGameMode m; if (!System.Enum.TryParse(mode, true, out m)) { LastLog = "게임 모드 이름 오류 " + mode; return; } + var ig = InGameInfo.Ins; var f = ModeField(); + if (ig == null || f == null) { LastLog = "InGameInfo/m_GameMode 없음"; return; } + f.SetValue(ig, m); + } + + /// act 를 Stage 모드로 돌린다(Lobby 였으면 끝나고 되돌림) — 원본 PCActor.Set_Obj 가 Lobby 면 pcanim_lobby 를 입히는 것을 피한다(섬 WLIslandAvatar.ApplyCombatForm · WLWeaponClasses.RefreshModelIfNeeded 와 같은 꼴). + static void WithStageMode(System.Action act) + { + var ig = InGameInfo.Ins; var f = ModeField(); + object prev = ig != null && f != null ? f.GetValue(ig) : null; bool flipped = false; + try + { + if (f != null && prev is eGameMode && (eGameMode)prev == eGameMode.Lobby) { f.SetValue(ig, eGameMode.Stage); flipped = true; } + act(); + } + finally { if (flipped) f.SetValue(ig, prev); } + } + + static bool PcInLobbyForm(PCActor pc) + { + var an = pc != null ? pc.GetComponentInChildren() : null; + var c = an != null ? an.runtimeAnimatorController : null; + return c != null && c.name.IndexOf("lobby", System.StringComparison.OrdinalIgnoreCase) >= 0; + } + + /// 무기 교체(O/P · 숫자 키 등급) 뒤 원본 Set_Obj 가 로비 컨트롤러를 다시 입혔으면 전투 형태로 되돌리고, 대상을 잃었으면 더미를 다시 겨눈다(0.5 s 마다 · JSON combatFormCheckSeconds). + void CheckCombatForm(PCActor pc) + { + if (PcInLobbyForm(pc)) { WithStageMode(() => pc.Set_Obj()); CombatForms++; _msg = "전투 형태 다시 적용 " + CombatForms; } + if (Dummy != null && pc.Get_Target() == null && !_moving) pc.Change_Target(false, Dummy); + // 더미가 사거리 밖에 머물면(넉백 누적) 자동 R — 콤보 반복 관찰용 + float auto = Cfg.autoResetDummySeconds; + if (auto > 0f && Dummy != null) + { + var d = Dummy.Get_position() - pc.Get_position(); d.y = 0f; + if (d.magnitude > pc.WL_AttackRange + 0.3f) { _outOfRange += Mathf.Max(0.1f, Cfg.combatFormCheckSeconds); if (_outOfRange >= auto) { _outOfRange = 0f; _msg = "더미 사거리 밖 " + auto + " s → " + ResetDummy(); } } + else _outOfRange = 0f; + } + } + /// InGameInfo.Start 의 예열 루프가 돌았는가 — 비공개 dic_str_Effect 의 키 수 ≥ 이펙트 표(목록 1) 행 수. static bool EffectsPreloaded() { @@ -374,6 +432,8 @@ namespace WL.SkillTest var pc = MyValue.MyPC as PCActor; if (pc == null) return; var cfg = Cfg; + _formTimer += Time.unscaledDeltaTime; + if (_formTimer >= Mathf.Max(0.1f, cfg.combatFormCheckSeconds)) { _formTimer = 0f; CheckCombatForm(pc); } // 이동 — 섬 조이스틱(WLIslandAvatar.DrivePc · 원본 FloatingJoystick)과 같은 호출 순서. 누름 = Set_AutoBraking(false) → JoystickStatus 1 · 매 프레임 Run_byJoystick(카메라 기준) · 뗌 = JoystickStatus 0 → Set_AutoBraking(true)(Play_Idle · 자동 전투 재개) float h = 0f, v = 0f;