diff --git a/Client/Assets/Script/Editor/HumanoidAnimationFbxImportSettings.cs b/Client/Assets/Script/Editor/HumanoidAnimationFbxImportSettings.cs new file mode 100644 index 00000000..3b930b00 --- /dev/null +++ b/Client/Assets/Script/Editor/HumanoidAnimationFbxImportSettings.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEngine; + +public static class HumanoidAnimationFbxImportSettings +{ + public const string ActorRootPath = "Assets/Game/Art/Actor/"; + public const string CityboyAvatarPath = "Assets/Game/Art/Actor/cityboy/cityboy_sk.fbx"; + public const string CityboyAvatarName = "Cityboy_sk Avatar"; + public const string CityboyHipBoneName = "mixamorig:Hips"; + + public static bool ShouldProcessAsset(string assetPath) + { + string normalizedPath = NormalizeAssetPath(assetPath); + + return normalizedPath.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase) + && normalizedPath.StartsWith(ActorRootPath, StringComparison.OrdinalIgnoreCase) + && !string.Equals(normalizedPath, CityboyAvatarPath, StringComparison.OrdinalIgnoreCase); + } + + public static ModelImporterClipAnimation ApplyClipSettings(ModelImporterClipAnimation clip) + { + clip.lockRootRotation = true; + clip.keepOriginalOrientation = true; + clip.lockRootHeightY = true; + return clip; + } + + public static ModelImporterClipAnimation[] ApplyClipSettings(ModelImporterClipAnimation[] clips) + { + for (int i = 0; i < clips.Length; i++) + { + clips[i] = ApplyClipSettings(clips[i]); + } + + return clips; + } + + public static bool ShouldUseCityboyAvatar(string assetPath) + { + string filePath = ResolveAssetFilePath(assetPath); + if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) + { + return false; + } + + return ShouldUseCityboyAvatar(File.ReadAllBytes(filePath)); + } + + public static bool ShouldUseCityboyAvatar(byte[] assetBytes) + { + if (assetBytes == null || assetBytes.Length == 0) + { + return false; + } + + byte[] marker = System.Text.Encoding.ASCII.GetBytes(CityboyHipBoneName); + for (int i = 0; i <= assetBytes.Length - marker.Length; i++) + { + bool matched = true; + for (int markerIndex = 0; markerIndex < marker.Length; markerIndex++) + { + if (assetBytes[i + markerIndex] != marker[markerIndex]) + { + matched = false; + break; + } + } + + if (matched) + { + return true; + } + } + + return false; + } + + public static ModelImporterClipAnimation CreateClipAnimation(TakeInfo takeInfo) + { + string clipName = string.IsNullOrEmpty(takeInfo.defaultClipName) ? takeInfo.name : takeInfo.defaultClipName; + + return ApplyClipSettings(new ModelImporterClipAnimation + { + name = clipName, + takeName = takeInfo.name, + firstFrame = takeInfo.startTime * takeInfo.sampleRate, + lastFrame = takeInfo.stopTime * takeInfo.sampleRate + }); + } + + public static ModelImporterClipAnimation CreateClipAnimation(AnimationClip animationClip) + { + float frameRate = animationClip.frameRate > 0f ? animationClip.frameRate : 30f; + + return ApplyClipSettings(new ModelImporterClipAnimation + { + name = animationClip.name, + takeName = animationClip.name, + firstFrame = 0f, + lastFrame = animationClip.length * frameRate + }); + } + + private static string NormalizeAssetPath(string assetPath) + { + return string.IsNullOrEmpty(assetPath) ? string.Empty : assetPath.Replace('\\', '/'); + } + + private static string ResolveAssetFilePath(string assetPath) + { + if (string.IsNullOrEmpty(assetPath)) + { + return string.Empty; + } + + if (Path.IsPathRooted(assetPath) || File.Exists(assetPath)) + { + return assetPath; + } + + DirectoryInfo projectRoot = Directory.GetParent(Application.dataPath); + return projectRoot == null ? assetPath : Path.Combine(projectRoot.FullName, NormalizeAssetPath(assetPath)); + } +} + +public sealed class HumanoidAnimationFbxPostprocessor : AssetPostprocessor +{ + private static readonly HashSet ReimportingAssets = new HashSet(StringComparer.OrdinalIgnoreCase); + + private void OnPreprocessModel() + { + if (!(assetImporter is ModelImporter modelImporter)) + { + return; + } + + if (!HumanoidAnimationFbxImportSettings.ShouldProcessAsset(assetPath)) + { + return; + } + + modelImporter.importAnimation = true; + modelImporter.animationType = ModelImporterAnimationType.Human; + if (HumanoidAnimationFbxImportSettings.ShouldUseCityboyAvatar(assetPath)) + { + Avatar cityboyAvatar = LoadCityboyAvatar(); + if (cityboyAvatar == null) + { + Debug.LogWarning( + $"[HumanoidAnimationFbxPostprocessor] Cannot find Cityboy avatar at {HumanoidAnimationFbxImportSettings.CityboyAvatarPath}. Skip import settings for {assetPath}."); + return; + } + + modelImporter.avatarSetup = ModelImporterAvatarSetup.CopyFromOther; + modelImporter.sourceAvatar = cityboyAvatar; + } + else + { + modelImporter.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel; + modelImporter.sourceAvatar = null; + } + + ModelImporterClipAnimation[] clips = GetAnimationClips(modelImporter); + if (clips.Length == 0) + { + return; + } + + modelImporter.clipAnimations = HumanoidAnimationFbxImportSettings.ApplyClipSettings(clips); + } + + private static void OnPostprocessAllAssets( + string[] importedAssets, + string[] deletedAssets, + string[] movedAssets, + string[] movedFromAssetPaths) + { + foreach (string importedAsset in importedAssets) + { + ConfigureImportedAnimationClips(importedAsset); + } + } + + private static void ConfigureImportedAnimationClips(string assetPath) + { + if (!HumanoidAnimationFbxImportSettings.ShouldProcessAsset(assetPath)) + { + return; + } + + if (ReimportingAssets.Remove(assetPath)) + { + return; + } + + if (!(AssetImporter.GetAtPath(assetPath) is ModelImporter modelImporter)) + { + return; + } + + ModelImporterClipAnimation[] existingClips = modelImporter.clipAnimations; + if (existingClips != null && existingClips.Length > 0) + { + return; + } + + ModelImporterClipAnimation[] clips = CreateClipAnimationsFromImportedAssets(assetPath); + if (clips.Length == 0) + { + return; + } + + modelImporter.clipAnimations = HumanoidAnimationFbxImportSettings.ApplyClipSettings(clips); + ReimportingAssets.Add(assetPath); + modelImporter.SaveAndReimport(); + } + + private static ModelImporterClipAnimation[] GetAnimationClips(ModelImporter modelImporter) + { + ModelImporterClipAnimation[] clips = modelImporter.clipAnimations; + if (clips != null && clips.Length > 0) + { + return clips; + } + + clips = modelImporter.defaultClipAnimations; + if (clips != null && clips.Length > 0) + { + return clips; + } + + TakeInfo[] takes = modelImporter.importedTakeInfos; + if (takes == null || takes.Length == 0) + { + return Array.Empty(); + } + + clips = new ModelImporterClipAnimation[takes.Length]; + for (int i = 0; i < takes.Length; i++) + { + clips[i] = HumanoidAnimationFbxImportSettings.CreateClipAnimation(takes[i]); + } + + return clips; + } + + private static Avatar LoadCityboyAvatar() + { + Avatar fallbackAvatar = null; + UnityEngine.Object[] assets = AssetDatabase.LoadAllAssetsAtPath(HumanoidAnimationFbxImportSettings.CityboyAvatarPath); + + foreach (UnityEngine.Object asset in assets) + { + if (!(asset is Avatar avatar)) + { + continue; + } + + if (fallbackAvatar == null) + { + fallbackAvatar = avatar; + } + + if (string.Equals(avatar.name, HumanoidAnimationFbxImportSettings.CityboyAvatarName, StringComparison.OrdinalIgnoreCase)) + { + return avatar; + } + } + + return fallbackAvatar; + } + + private static ModelImporterClipAnimation[] CreateClipAnimationsFromImportedAssets(string assetPath) + { + UnityEngine.Object[] assets = AssetDatabase.LoadAllAssetRepresentationsAtPath(assetPath); + var clips = new List(); + + foreach (UnityEngine.Object asset in assets) + { + if (asset is AnimationClip animationClip) + { + clips.Add(HumanoidAnimationFbxImportSettings.CreateClipAnimation(animationClip)); + } + } + + return clips.ToArray(); + } +} diff --git a/Client/Assets/Script/Editor/HumanoidAnimationFbxImportSettings.cs.meta b/Client/Assets/Script/Editor/HumanoidAnimationFbxImportSettings.cs.meta new file mode 100644 index 00000000..b1793435 --- /dev/null +++ b/Client/Assets/Script/Editor/HumanoidAnimationFbxImportSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eca73c54c7164f2cb8873812efa969fa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Script/Tests/EditMode/HumanoidAnimationFbxImportSettingsTests.cs b/Client/Assets/Script/Tests/EditMode/HumanoidAnimationFbxImportSettingsTests.cs new file mode 100644 index 00000000..e625f62d --- /dev/null +++ b/Client/Assets/Script/Tests/EditMode/HumanoidAnimationFbxImportSettingsTests.cs @@ -0,0 +1,145 @@ +using NUnit.Framework; +using UnityEditor; +using UnityEngine; + +public sealed class HumanoidAnimationFbxImportSettingsTests +{ + [Test] + public void ShouldProcessActorAnimationFbx() + { + Assert.That( + HumanoidAnimationFbxImportSettings.ShouldProcessAsset( + "Assets/Game/Art/Actor/Controller/Class/Caster/Standing Walk Forward.fbx"), + Is.True); + } + + [Test] + public void ShouldNotProcessCityboySourceAvatar() + { + Assert.That( + HumanoidAnimationFbxImportSettings.ShouldProcessAsset( + HumanoidAnimationFbxImportSettings.CityboyAvatarPath), + Is.False); + } + + [Test] + public void ShouldProcessActorFbxEvenWhenCustomClipAnimationsAreEmpty() + { + Assert.That( + HumanoidAnimationFbxImportSettings.ShouldProcessAsset( + "Assets/Game/Art/Actor/Controller/Class/Caster/Standing 2H Magic Area Attack 02.fbx"), + Is.True); + } + + [Test] + public void ShouldNotProcessFbxOutsideActorFolder() + { + Assert.That( + HumanoidAnimationFbxImportSettings.ShouldProcessAsset( + "Assets/Res/cityboy_uv2/cityboy_uv2.fbx"), + Is.False); + } + + [Test] + public void ShouldNotUseCityboyAvatarWhenFbxDoesNotContainCityboyHipBone() + { + Assert.That( + HumanoidAnimationFbxImportSettings.ShouldUseCityboyAvatar( + new byte[] { 72, 105, 112, 115 }), + Is.False); + } + + [Test] + public void ShouldUseCityboyAvatarWhenFbxContainsCityboyHipBone() + { + Assert.That( + HumanoidAnimationFbxImportSettings.ShouldUseCityboyAvatar( + System.Text.Encoding.ASCII.GetBytes("Model::mixamorig:Hips")), + Is.True); + } + + [Test] + public void ApplyClipSettingsUsesOriginalRotationAndBakesRotationAndY() + { + var clip = new ModelImporterClipAnimation + { + lockRootRotation = false, + keepOriginalOrientation = false, + lockRootHeightY = false + }; + + ModelImporterClipAnimation updated = HumanoidAnimationFbxImportSettings.ApplyClipSettings(clip); + + Assert.That(updated.lockRootRotation, Is.True); + Assert.That(updated.keepOriginalOrientation, Is.True); + Assert.That(updated.lockRootHeightY, Is.True); + } + + [Test] + public void ApplyClipSettingsUpdatesEveryClipBeforeWriteback() + { + var clips = new[] + { + new ModelImporterClipAnimation { name = "Attack01" }, + new ModelImporterClipAnimation { name = "Attack02" } + }; + + ModelImporterClipAnimation[] updated = HumanoidAnimationFbxImportSettings.ApplyClipSettings(clips); + + Assert.That(updated, Has.Length.EqualTo(2)); + Assert.That(updated[0].lockRootRotation, Is.True); + Assert.That(updated[0].keepOriginalOrientation, Is.True); + Assert.That(updated[0].lockRootHeightY, Is.True); + Assert.That(updated[1].lockRootRotation, Is.True); + Assert.That(updated[1].keepOriginalOrientation, Is.True); + Assert.That(updated[1].lockRootHeightY, Is.True); + } + + [Test] + public void CreateClipAnimationFromTakeInfoUsesTakeFramesAndAppliesRootSettings() + { + var takeInfo = new TakeInfo + { + name = "mixamo.com", + defaultClipName = "Standing 2H Magic Area Attack 02", + startTime = 0f, + stopTime = 4.5f, + sampleRate = 30f + }; + + ModelImporterClipAnimation clip = HumanoidAnimationFbxImportSettings.CreateClipAnimation(takeInfo); + + Assert.That(clip.name, Is.EqualTo("Standing 2H Magic Area Attack 02")); + Assert.That(clip.takeName, Is.EqualTo("mixamo.com")); + Assert.That(clip.firstFrame, Is.EqualTo(0f)); + Assert.That(clip.lastFrame, Is.EqualTo(135f)); + Assert.That(clip.lockRootRotation, Is.True); + Assert.That(clip.keepOriginalOrientation, Is.True); + Assert.That(clip.lockRootHeightY, Is.True); + } + + [Test] + public void CreateClipAnimationFromImportedClipUsesClipNameAndLength() + { + var importedClip = new AnimationClip + { + name = "Standing 2H Magic Area Attack 02", + frameRate = 30f + }; + importedClip.SetCurve( + string.Empty, + typeof(Transform), + "m_LocalPosition.x", + new AnimationCurve(new Keyframe(0f, 0f), new Keyframe(4.5f, 0f))); + + ModelImporterClipAnimation clip = HumanoidAnimationFbxImportSettings.CreateClipAnimation(importedClip); + + Assert.That(clip.name, Is.EqualTo("Standing 2H Magic Area Attack 02")); + Assert.That(clip.takeName, Is.EqualTo("Standing 2H Magic Area Attack 02")); + Assert.That(clip.firstFrame, Is.EqualTo(0f)); + Assert.That(clip.lastFrame, Is.EqualTo(135f)); + Assert.That(clip.lockRootRotation, Is.True); + Assert.That(clip.keepOriginalOrientation, Is.True); + Assert.That(clip.lockRootHeightY, Is.True); + } +} diff --git a/Client/Assets/Script/Tests/EditMode/HumanoidAnimationFbxImportSettingsTests.cs.meta b/Client/Assets/Script/Tests/EditMode/HumanoidAnimationFbxImportSettingsTests.cs.meta new file mode 100644 index 00000000..9b3a5d2c --- /dev/null +++ b/Client/Assets/Script/Tests/EditMode/HumanoidAnimationFbxImportSettingsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 60252bd67a3943b38f65d188a2128557 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Client/Assets/Script/xmain/Client/LobbyWorldController.cs b/Client/Assets/Script/xmain/Client/LobbyWorldController.cs index 5a1849ee..9ffb1015 100644 --- a/Client/Assets/Script/xmain/Client/LobbyWorldController.cs +++ b/Client/Assets/Script/xmain/Client/LobbyWorldController.cs @@ -43,9 +43,9 @@ namespace XGame private const float JoystickPadding = 100.0f; private const float JoystickDeadZone = 0.18f; private const float JoystickAcquireRadiusMultiplier = 1.6f; - private const float CameraMinDistance = 3.5f; - private const float CameraMaxDistance = 14f; - private const float CameraDefaultDistance = 9.5f; + private const float CameraMinDistance = 2.0f; + private const float CameraMaxDistance = 8f; + private const float CameraDefaultDistance = 4.5f; private const float CameraMinPitch = 20f; private const float CameraMaxPitch = 75f; private const float CameraMouseOrbitSensitivity = 0.2f; diff --git a/Client/Assets/Script/xmain/Game.cs b/Client/Assets/Script/xmain/Game.cs index 533af38a..5c9d9890 100644 --- a/Client/Assets/Script/xmain/Game.cs +++ b/Client/Assets/Script/xmain/Game.cs @@ -285,7 +285,7 @@ namespace XGame sun.type = LightType.Directional; sun.color = RuntimeSunColor; sun.intensity = Mathf.Max(sun.intensity, RuntimeSunIntensity); - sun.shadows = LightShadows.None; + //sun.shadows = LightShadows.None; sun.transform.rotation = Quaternion.Euler(50f, -30f, 0f); RenderSettings.sun = sun; return sun;