diff --git a/.cursor/rules/aigame-basics.mdc b/.cursor/rules/aigame-basics.mdc index 05391c3..bf07053 100644 --- a/.cursor/rules/aigame-basics.mdc +++ b/.cursor/rules/aigame-basics.mdc @@ -15,7 +15,7 @@ alwaysApply: true - sound → `Doc/ResDesc_Sound.md` - effect → `Doc/ResDesc_Effect.md` -2. **3D 游戏网格**:导出 FBX 为 Y-up、原点在底面中点、**局部 -Z 为正面**。摆场景时让 -Z 朝向目标,不要用 Unity 默认 +Z forward。详见 `GameResProductProc.md` 附录 B。 +2. **3D 游戏网格**:导出 FBX 为 Y-up、原点在底面中点、**局部 +Z 为正面**。摆场景时让 +Z 朝向目标,使用 Unity `transform.forward` / `Quaternion.LookRotation(direction)`。旧 -Z 资产按明确的旧资源配置兼容,不自动翻转。配置见 `Config/Art/ModelConvention.json`,详见 `GameResProductProc.md` 附录 B。 3. **UI 自动生成(必须)**:业务面板使用 JSON→Prefab 管线,不要用运行时临时代码拼业务 UI 替代 Prefab。 - 总览:`Doc/UI_AutoGen_Overview.md` @@ -28,3 +28,7 @@ alwaysApply: true - 标准控件底图必须使用九宫格:Sprite 设置有效的非零 `Border`、`Full Rect`,JSON 使用 `imageType: Sliced`;按钮各状态图同样设置。图标、插画等整图按原比例使用。 - 设计分辨率:竖屏 **`[1080, 1920]`**(不要用参考项目的 2048×1024) - Unity 菜单:`Tools/UI/...`;转换器:`AIClient/Assets/Editor/UI/` + +4. **人形角色蒙皮默认方案**:固定 Mixamo 骨架 + TokenRig 只预测蒙皮权重。先按角色体型拟合骨架,推理时固定骨架;生成权重后恢复并校验原骨名、层级、骨轴和休息矩阵,检查跨部位错误影响并做必要清理。具体流程见 `GameResProductProc.md`「二.2.A 人物角色」。 + +5. **新场景组件模型默认流程**:所有新组件(含独立地表)采用「分件图 → 混元 50k 高模 → Studio 低模 → Studio UV → Blender 同源对齐烘焙 → 实际面数严格超过目标 130% 时保留 UV 减面」。不得用 Blender 自动 UV 替代 Studio UV;未超过阈值须登记跳过。统一入口 `Tools/scene_component_pipeline.py`,细则见 `Doc/Design/Scene/SceneModelProduction.md`。已有 Prefab 可在外观验收后复用;既有模型回执保留真实历史。TownSquare 测试内容已删除,不再生成或注册为加载/打包入口。 diff --git a/AIClient/AgentScripts/AuditCharacterVisual.cs b/AIClient/AgentScripts/AuditCharacterVisual.cs index b914ca4..25ce9d4 100644 --- a/AIClient/AgentScripts/AuditCharacterVisual.cs +++ b/AIClient/AgentScripts/AuditCharacterVisual.cs @@ -18,7 +18,7 @@ public static class AuditCharacterVisual "Tools/output/campguide_review/idle", 1.55f, "Assets/Bundles/Characters/Human/CampGuide/Clips/Idle.anim", - 0.45f); + 0.45f, -1f); // Existing CampGuide asset uses legacy -Z. } public static string CaptureCampGuideWalk() @@ -28,11 +28,12 @@ public static class AuditCharacterVisual "Tools/output/campguide_review/walk", 1.55f, "Assets/Bundles/Characters/Human/CampGuide/Clips/Walk.anim", - 0.3f); + 0.3f, -1f); } - public static string Capture(string prefabPath,string outputDirectory,float height=.55f,string clipPath="",float phase=0) + public static string Capture(string prefabPath,string outputDirectory,float height=.55f,string clipPath="",float phase=0,float forwardZ=1f) { + var directions=ViewDirections(forwardZ); string dir=Path.GetFullPath(Path.Combine(Application.dataPath,"../..",outputDirectory));Directory.CreateDirectory(dir); var scene=EditorSceneManager.NewPreviewScene(); try @@ -50,7 +51,7 @@ public static class AuditCharacterVisual int triangles=filters.Sum(m=>m.sharedMesh.triangles.Length/3); if(Mathf.Abs(minY)>.002f || Mathf.Abs(actualHeight-height)>.002f)throw new Exception("Static height/bottom invalid"); if(unit.GetComponentsInChildren().Any(r=>r.sharedMaterial==null || r.sharedMaterial.shader.name!="Test/XPbr"))throw new Exception("Static XPbr material missing"); - File.WriteAllText(Path.Combine(dir,"static_verification.txt"),$"height={actualHeight:F6} bottom={minY:F6} triangles={triangles} XPbr=True; facing verified in front/side/back views"); + File.WriteAllText(Path.Combine(dir,"static_verification.txt"),$"height={actualHeight:F6} bottom={minY:F6} triangles={triangles} XPbr=True; frontAxis={(forwardZ>0?"+Z":"-Z")}; facing requires visual review of front/side/back views"); } if(!string.IsNullOrEmpty(clipPath)) { @@ -77,7 +78,6 @@ public static class AuditCharacterVisual var rt=new RenderTexture(640,640,24);var tex=new Texture2D(640,640,TextureFormat.RGB24,false);var previous=RenderTexture.active; try { - var directions=new[]{new Vector3(0,.22f,-1),new Vector3(1,.22f,0),new Vector3(0,.22f,1),new Vector3(.7f,.3f,-1)}; string[] names={"front","side","back","perspective"}; for(int i=0;i Directory.GetParent(Application.dataPath).Parent.FullName; + static string Output => Path.Combine(Repo, "Tools/output/bot_animation_lab"); + static void Folder(string path) + { + if (AssetDatabase.IsValidFolder(path)) return; + Folder(Path.GetDirectoryName(path).Replace('\\', '/')); + AssetDatabase.CreateFolder(Path.GetDirectoryName(path).Replace('\\', '/'), Path.GetFileName(path)); + } + static void Report(string name, object report) + { + Directory.CreateDirectory(Output); + File.WriteAllText(Path.Combine(Output, name + ".json"), JsonConvert.SerializeObject(report, Formatting.Indented)); + } + public static string Configure() + { + if (EditorApplication.isPlayingOrWillChangePlaymode) throw new Exception("Edit Mode required"); + Folder(Root); Folder(Common + "/Prefabs"); + var reports = new List(); + float referenceHeight = 0; + foreach (string name in Names) + { + string path = Common + "/" + name + ".fbx"; + AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceSynchronousImport); + var importer = (ModelImporter)AssetImporter.GetAtPath(path); + if (importer.animationType != ModelImporterAnimationType.Human || importer.avatarSetup != ModelImporterAvatarSetup.CreateFromThisModel) + { + importer.animationType = ModelImporterAnimationType.Human; + importer.avatarSetup = ModelImporterAvatarSetup.CreateFromThisModel; + importer.SaveAndReimport(); + } + var avatar = AssetDatabase.LoadAllAssetsAtPath(path).OfType().Single(); + if (!avatar.isValid || !avatar.isHuman) throw new Exception(name + " invalid Humanoid Avatar"); + var model = AssetDatabase.LoadAssetAtPath(path); + var bones = model.GetComponentsInChildren(true).Where(t => t.name.StartsWith("mixamorig:")).Select(t => t.name).ToArray(); + var mapped = importer.humanDescription.human; + string controllerPath = Common + "/" + (name == "YBot" ? "YBot" : "XBot") + ".controller"; + var controller = AssetDatabase.LoadAssetAtPath(controllerPath); + if (!controller) throw new Exception("Missing controller " + controllerPath); + var preview = EditorSceneManager.NewPreviewScene(); + try + { + var go = (GameObject)PrefabUtility.InstantiatePrefab(model, preview); + go.name = name; + float sourceHeight = BoundsOf(go).size.y; + if (name == "XBot") referenceHeight = sourceHeight; + if (name == "XBot25" || name == "XBot41") go.transform.localScale *= referenceHeight / sourceHeight; + var animator = go.GetComponent(); + if (!animator) animator = go.AddComponent(); + animator.avatar = avatar; animator.runtimeAnimatorController = controller; + animator.applyRootMotion = false; animator.cullingMode = AnimatorCullingMode.AlwaysAnimate; + foreach (var skin in go.GetComponentsInChildren()) skin.updateWhenOffscreen = true; + string prefab = Common + "/Prefabs/" + name + ".prefab"; + PrefabUtility.SaveAsPrefabAsset(go, prefab); + reports.Add(new { name, path, bones = bones.Length, boneNames = bones, mapped = mapped.Select(b => new { b.humanName, b.boneName }), + avatar = avatar.name, avatarValid = avatar.isValid, avatarHuman = avatar.isHuman, controller = controllerPath, prefab, + sourceHeight, prefabHeight = BoundsOf(go).size.y, prefabScale = new[] { go.transform.localScale.x, go.transform.localScale.y, go.transform.localScale.z }, + renderers = go.GetComponentsInChildren().Select(r => new { r.name, skinBones = r.bones.Length, material = r.sharedMaterial ? r.sharedMaterial.name : null }).ToArray() }); + } + finally { EditorSceneManager.ClosePreviewScene(preview); } + } + Report("avatar_configuration", reports); + return JsonConvert.SerializeObject(reports); + } + static Material Material(string name, Color color) + { + Folder(Root + "/Generated"); + string path = Root + "/Generated/" + name + ".mat"; + var material = AssetDatabase.LoadAssetAtPath(path); + if (!material) + { + var shader = Shader.Find("Universal Render Pipeline/Lit") ?? Shader.Find("Standard"); + material = new Material(shader) { name = name }; + AssetDatabase.CreateAsset(material, path); + } + material.color = color; + if (material.HasProperty("_Smoothness")) material.SetFloat("_Smoothness", .35f); + EditorUtility.SetDirty(material); AssetDatabase.SaveAssetIfDirty(material); + return material; + } + static Bounds BoundsOf(GameObject go) + { + var renderers = go.GetComponentsInChildren(); + var bounds = renderers[0].bounds; + foreach (var r in renderers.Skip(1)) bounds.Encapsulate(r.bounds); + return bounds; + } + public static string Build() + { + if (EditorApplication.isPlayingOrWillChangePlaymode) throw new Exception("Edit Mode required"); + for (int i = 0; i < SceneManager.sceneCount; i++) + if (SceneManager.GetSceneAt(i).isDirty) throw new Exception("Unsaved scene: " + SceneManager.GetSceneAt(i).path); + Configure(); + string uiPath = JsonUIPrefabBuilder.BuildFromJsonFile(Path.Combine(Repo, "Doc/UIPrefabCreater/UI_BotAnimationLab.json")); + if (string.IsNullOrEmpty(uiPath)) throw new Exception("UI JSON generation failed"); + var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + var root = new GameObject("BotAnimationLab"); + var viewer = root.AddComponent(); + viewer.actors = new BotAnimationLab.Actor[4]; + viewer.clayMaterial = Material("Clay", new Color(.65f, .69f, .74f)); + Color[] colors = { new Color(.20f,.60f,.90f), new Color(.96f,.55f,.18f), new Color(.28f,.72f,.43f), new Color(.66f,.42f,.85f) }; + for (int i = 0; i < Names.Length; i++) + { + string name = Names[i]; + var stage = new GameObject("Stage_" + name); stage.transform.SetParent(root.transform, false); + stage.transform.localPosition = new Vector3((1.5f - i) * 20f, 0, 0); + var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath(Common + "/Prefabs/" + name + ".prefab"), stage.transform); + var bounds = BoundsOf(go); + go.transform.localScale *= 1.6f / bounds.size.y; + bounds = BoundsOf(go); + go.transform.position -= new Vector3(bounds.center.x - stage.transform.position.x, bounds.min.y, bounds.center.z); + var a = go.GetComponent(); + var skins = go.GetComponentsInChildren(); + var color = Material(name + "Color", colors[i]); + var joints = Material(name + "Joints", colors[i] * .6f); + foreach (var skin in skins) skin.sharedMaterial = skin.name.Contains("Joint") ? joints : color; + var controller = (AnimatorController)a.runtimeAnimatorController; + var states = controller.layers[0].stateMachine.states.Select(s => s.state).ToArray(); + viewer.actors[i] = new BotAnimationLab.Actor { label = name, animator = a, stage = stage.transform, skins = skins, + originalMaterials = skins.Select(s => s.sharedMaterial).ToArray(), + clips = BotAnimationLab.StateNames.Select(n => (AnimationClip)states.Single(s => s.name == n).motion).ToArray() }; + var pedestal = GameObject.CreatePrimitive(PrimitiveType.Cylinder); pedestal.name = "Pedestal_" + name; + pedestal.transform.SetParent(root.transform, false); pedestal.transform.position = stage.transform.position + new Vector3(0,-.035f,0); + pedestal.transform.localScale = new Vector3(1.25f,.035f,1.25f); pedestal.GetComponent().sharedMaterial = color; + UnityEngine.Object.DestroyImmediate(pedestal.GetComponent()); + } + var floor = GameObject.CreatePrimitive(PrimitiveType.Plane); floor.name = "ReviewFloor"; floor.transform.SetParent(root.transform, false); + floor.transform.position = new Vector3(0,-.08f,0); floor.transform.localScale = new Vector3(10,1,1); + floor.GetComponent().sharedMaterial = Material("Floor", new Color(.79f,.84f,.87f)); + UnityEngine.Object.DestroyImmediate(floor.GetComponent()); + var background = new GameObject("BackgroundCamera").AddComponent(); background.transform.SetParent(root.transform, false); + background.clearFlags = CameraClearFlags.SolidColor; background.backgroundColor = new Color(.82f,.89f,.94f); background.depth = -10; background.cullingMask = 0; + var camera = new GameObject("ReviewCamera").AddComponent(); camera.transform.SetParent(root.transform, false); + camera.tag = "MainCamera"; camera.rect = new Rect(0,.335f,1,.54f); camera.orthographic = true; + camera.clearFlags = CameraClearFlags.SolidColor; camera.backgroundColor = background.backgroundColor; + camera.nearClipPlane = .05f; camera.farClipPlane = 40; camera.allowHDR = false; camera.gameObject.AddComponent(); + viewer.reviewCamera = camera; + viewer.comparisonCameras = new Camera[4]; + for (int i = 0; i < 4; i++) + { + var cell = new GameObject("CompareCamera_" + Names[i]).AddComponent(); cell.transform.SetParent(root.transform, false); + cell.rect = new Rect((i % 2) * .5f, i < 2 ? .605f : .335f, .5f, .27f); + cell.orthographic = true; cell.clearFlags = CameraClearFlags.SolidColor; cell.backgroundColor = background.backgroundColor; + cell.nearClipPlane = .05f; cell.farClipPlane = 40; cell.allowHDR = false; + viewer.comparisonCameras[i] = cell; + } + foreach (var config in new[] { ("KeyLight", new Vector3(35, -40, 0), 1.3f), ("FillLight", new Vector3(30, 140, 0), .6f) }) + { + var light = new GameObject(config.Item1).AddComponent(); light.transform.SetParent(root.transform, false); + light.type = LightType.Directional; light.transform.rotation = Quaternion.Euler(config.Item2); light.intensity = config.Item3; + } + RenderSettings.ambientMode = AmbientMode.Flat; RenderSettings.ambientLight = new Color(.65f,.68f,.73f); RenderSettings.fog = false; + var ui = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath(uiPath), root.transform); + viewer.ui = ui.transform; ui.GetComponentInChildren(true).viewer = viewer; + var events = new GameObject("EventSystem", typeof(EventSystem), typeof(StandaloneInputModule)); events.transform.SetParent(root.transform, false); + viewer.RefreshView(); + EditorSceneManager.MarkSceneDirty(scene); EditorSceneManager.SaveScene(scene, ScenePath); + EditorGUIUtility.PingObject(AssetDatabase.LoadAssetAtPath(ScenePath)); + return Verify(); + } + static void Advance(Animator a, float seconds) + { + a.speed = 1; + for (int i = 0; i < Mathf.CeilToInt(seconds * 60); i++) a.Update(1f / 60); + a.speed = 0; + } + static void Expect(Animator a, string name) + { + if (!a.GetCurrentAnimatorStateInfo(0).IsName("Base Layer." + name) || a.IsInTransition(0)) throw new Exception("Expected " + name); + } + public static string Verify() + { + var reports = new List(); + foreach (string name in Names) + { + var scene = EditorSceneManager.NewPreviewScene(); + var mesh = new Mesh(); + try + { + var go = (GameObject)PrefabUtility.InstantiatePrefab(AssetDatabase.LoadAssetAtPath(Common + "/Prefabs/" + name + ".prefab"), scene); + var a = go.GetComponent(); + if (!a.avatar || !a.avatar.isHuman || !a.avatar.isValid) throw new Exception(name + " invalid Avatar"); + var controller = (AnimatorController)a.runtimeAnimatorController; + string expected = Common + "/" + (name == "YBot" ? "YBot" : "XBot") + ".controller"; + if (AssetDatabase.GetAssetPath(controller) != expected) throw new Exception(name + " wrong controller"); + a.Rebind(); a.speed = 0; a.Update(0); + var states = new List(); + foreach (var child in controller.layers[0].stateMachine.states) + { + var state = child.state; + float minGap = float.MaxValue, maxGap = float.MinValue, motion = 0; + Vector3 first = Vector3.zero; + for (int i = 0; i < 25; i++) + { + a.Play("Base Layer." + state.name, 0, i / 25f); a.Update(0); + Vector3 hip = a.GetBoneTransform(HumanBodyBones.Hips).position; + Vector3 hand = a.GetBoneTransform(HumanBodyBones.LeftHand).position - hip; + Vector3 foot = a.GetBoneTransform(HumanBodyBones.LeftFoot).position - hip; + if (i == 0) first = hand + foot; + motion = Mathf.Max(motion, Vector3.Distance(first, hand + foot)); + float gap = go.transform.InverseTransformPoint(a.GetBoneTransform(HumanBodyBones.RightFoot).position).x - go.transform.InverseTransformPoint(a.GetBoneTransform(HumanBodyBones.LeftFoot).position).x; + minGap = Mathf.Min(minGap, gap); maxGap = Mathf.Max(maxGap, gap); + foreach (var skin in go.GetComponentsInChildren()) + { + skin.BakeMesh(mesh); + if (mesh.vertices.Any(v => float.IsNaN(v.sqrMagnitude) || float.IsInfinity(v.sqrMagnitude))) throw new Exception(name + " invalid skin"); + } + } + if (motion < .00001f) throw new Exception(name + " has no motion in " + state.name); + states.Add(new { state = state.name, motion, minAnkleGap = minGap, maxAnkleGap = maxGap, samples = 25, source = AssetDatabase.GetAssetPath(state.motion) }); + } + var checks = new List(); + a.SetInteger("Motion", 0); a.Play("Base Layer.Idle", 0, 0); a.Update(0); + a.SetInteger("Motion", 1); Advance(a,.4f); Expect(a,"Walk"); checks.Add("Idle -> Walk"); + a.SetInteger("Motion", 0); Advance(a,.4f); Expect(a,"Idle"); checks.Add("Walk -> Idle"); + foreach (int locomotion in new[] { 0, 1 }) foreach (string attack in new[] { "Attack01", "Attack02" }) + { + string rest = locomotion == 0 ? "Idle" : "Walk"; + a.SetInteger("Motion", locomotion); a.Play("Base Layer." + rest, 0, 0); a.Update(0); + a.SetTrigger(attack); Advance(a,.2f); Expect(a,attack); + float length = ((AnimationClip)controller.layers[0].stateMachine.states.Single(s=>s.state.name==attack).state.motion).length; + Advance(a,length+.4f); Expect(a,rest); checks.Add(rest + " -> " + attack + " -> " + rest); + } + reports.Add(new { actor = name, controller = expected, avatar = a.avatar.name, validHumanoid = a.isHuman, states, checks }); + } + finally { UnityEngine.Object.DestroyImmediate(mesh); EditorSceneManager.ClosePreviewScene(scene); } + } + var result = new { passed = true, scene = ScenePath, samples = 400, transitionPaths = 24, actors = reports, verification = "Isolated Editor Animator and BakeMesh" }; + Report("playback_validation", result); + return JsonConvert.SerializeObject(result); + } + public static string Controls() + { + if (!Application.isPlaying) throw new Exception("Play Mode required"); + var v = UnityEngine.Object.FindObjectOfType(); + if (!v || v.actors.Length != 4) throw new Exception("Bot scene not active"); + var buttons = v.ui.GetComponentsInChildren