diff --git a/.vsconfig b/.vsconfig new file mode 100644 index 0000000..f019fd0 --- /dev/null +++ b/.vsconfig @@ -0,0 +1,6 @@ +{ + "version": "1.0", + "components": [ + "Microsoft.VisualStudio.Workload.ManagedGame" + ] +} diff --git a/Assets/BP_Scripts/BP_test.asset.meta b/Assets/BP_Scripts/BP_test.asset.meta index d98cb01..36be857 100644 --- a/Assets/BP_Scripts/BP_test.asset.meta +++ b/Assets/BP_Scripts/BP_test.asset.meta @@ -1,8 +1,8 @@ fileFormatVersion: 2 -guid: 6da43df589d0e9e4486e200a9058c15f +guid: c4e90a3b2f66a4843a646f1c775239d1 NativeFormatImporter: externalObjects: {} - mainObjectFileID: 11400000 + mainObjectFileID: 0 userData: assetBundleName: assetBundleVariant: diff --git a/Assets/BP_Scripts/BT_Kart_Advanced.asset b/Assets/BP_Scripts/BT_Kart_Advanced.asset new file mode 100644 index 0000000..1ad5b85 --- /dev/null +++ b/Assets/BP_Scripts/BT_Kart_Advanced.asset @@ -0,0 +1,41 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 7a686a47eee2fa44cb0a34b5d86e4d5e, type: 3} + m_Name: BT_Kart_Advanced + m_EditorClassIdentifier: + _serializedGraph: '{"type":"NodeCanvas.BehaviourTrees.BehaviourTree","nodes":[{"_name":"\u8f66\u8f86\u4e3b\u63a7\uff08\u5e76\u884c\uff09","_position":{"x":400.0,"y":60.0},"$type":"NodeCanvas.BehaviourTrees.Parallel","$id":"0"},{"_name":"\u8f93\u5165\u540c\u6b65\u6a21\u5757","_position":{"x":180.0,"y":210.0},"$type":"NodeCanvas.BehaviourTrees.Sequencer","$id":"1"},{"_action":{"accelerate":{"_name":"Accelerate"},"turn":{"_name":"Turn"},"brake":{"_name":"Brake"},"handbrake":{"_name":"Handbrake"},"gear":{"_name":"Gear"},"driveMode":{"_name":"DriveMode"},"headlights":{"_name":"Lights"},"engineOn":{"_name":"EngineOn"},"horn":{"_name":"Horn"},"$type":"NLD.Nodes.SetVehicleInput"},"_name":"\u540c\u6b65\u8f93\u5165\u5230BaseInput","_position":{"x":180.0,"y":360.0},"$type":"NodeCanvas.BehaviourTrees.ActionNode","$id":"2"},{"_name":"\u72b6\u6001\u76d1\u63a7\u6a21\u5757","_position":{"x":400.0,"y":210.0},"$type":"NodeCanvas.BehaviourTrees.Sequencer","$id":"3"},{"_action":{"outLocalSpeed":{"_name":"LocalSpeed"},"outVelocity":{"_name":"Velocity"},"outGear":{"_name":"CurrentGear"},"outState":{"_name":"CurrentState"},"outFuelPercent":{"_name":"FuelPercent"},"outFuelConsumption":{"_name":"FuelConsumption"},"outEngineOn":{"_name":"EngineStatus"},"outLightsOn":{"_name":"LightsStatus"},"$type":"NLD.Nodes.ReadVehicleStatusTask"},"_name":"\u8bfb\u53d6\u8f66\u8f86\u72b6\u6001\u5230\u9ed1\u677f","_position":{"x":400.0,"y":360.0},"$type":"NodeCanvas.BehaviourTrees.ActionNode","$id":"4"},{"_name":"\u72b6\u6001\u8bfb\u53d6","_position":{"x":620.0,"y":210.0},"$type":"NodeCanvas.BehaviourTrees.Sequencer","$id":"5"},{"_action":{"outLocalSpeed":{},"outVelocity":{},"outGear":{},"outState":{},"outFuelPercent":{},"outFuelConsumption":{},"outEngineOn":{},"outLightsOn":{},"$type":"NLD.Nodes.ReadVehicleStatusTask"},"_name":"\u8bfb\u53d6\u72b6\u6001","_position":{"x":620.0,"y":360.0},"$type":"NodeCanvas.BehaviourTrees.ActionNode","$id":"6"}],"connections":[{"_sourceNode":{"$ref":"0"},"_targetNode":{"$ref":"1"},"$type":"NodeCanvas.BehaviourTrees.BTConnection"},{"_sourceNode":{"$ref":"0"},"_targetNode":{"$ref":"3"},"$type":"NodeCanvas.BehaviourTrees.BTConnection"},{"_sourceNode":{"$ref":"0"},"_targetNode":{"$ref":"5"},"$type":"NodeCanvas.BehaviourTrees.BTConnection"},{"_sourceNode":{"$ref":"1"},"_targetNode":{"$ref":"2"},"$type":"NodeCanvas.BehaviourTrees.BTConnection"},{"_sourceNode":{"$ref":"3"},"_targetNode":{"$ref":"4"},"$type":"NodeCanvas.BehaviourTrees.BTConnection"},{"_sourceNode":{"$ref":"5"},"_targetNode":{"$ref":"6"},"$type":"NodeCanvas.BehaviourTrees.BTConnection"}],"canvasGroups":[],"localBlackboard":{"_variables":{"Accelerate":{"_name":"Accelerate","_id":"606f050e-c25c-444b-ae49-da6562991999","$type":"NodeCanvas.Framework.Variable`1[[System.Single, + mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]"},"Turn":{"_name":"Turn","_id":"ef72b931-18bf-4f81-adf3-df1280305ba2","$type":"NodeCanvas.Framework.Variable`1[[System.Single, + mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]"},"Brake":{"_name":"Brake","_id":"c9ae4b2c-ebb0-45f7-a7e7-7a28603d9651","$type":"NodeCanvas.Framework.Variable`1[[System.Boolean, + mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]"},"Handbrake":{"_name":"Handbrake","_id":"d8ddd4c5-47f5-47ad-a67c-a0b73627a6fd","$type":"NodeCanvas.Framework.Variable`1[[System.Boolean, + mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]"},"Gear":{"_name":"Gear","_id":"d6c7207d-ebe9-44a5-a810-46bb55371d2a","$type":"NodeCanvas.Framework.Variable`1[[KartGame.KartSystems.GearMode, + KartGame, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null]]"},"DriveMode":{"_value":2,"_name":"DriveMode","_id":"6fd1842d-306f-4c08-8aaa-39d224a695c5","$type":"NodeCanvas.Framework.Variable`1[[KartGame.KartSystems.DriveTrainMode, + KartGame, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null]]"},"EngineOn":{"_value":true,"_name":"EngineOn","_id":"40934b08-0dfa-4dca-9f47-5f9daeee7169","$type":"NodeCanvas.Framework.Variable`1[[System.Boolean, + mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]"},"Lights":{"_name":"Lights","_id":"9078b220-6821-4adc-8d74-0a3944d9dfaf","$type":"NodeCanvas.Framework.Variable`1[[System.Boolean, + mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]"},"Horn":{"_name":"Horn","_id":"2842451d-9339-446b-8eee-a5f987099680","$type":"NodeCanvas.Framework.Variable`1[[System.Boolean, + mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]"},"LocalSpeed":{"_name":"LocalSpeed","_id":"28ddb0c2-3e81-4f2c-a918-de8330092f93","$type":"NodeCanvas.Framework.Variable`1[[System.Single, + mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]"},"Velocity":{"_name":"Velocity","_id":"b112d672-fe70-4b2f-abc7-79a9298c7ec1","$type":"NodeCanvas.Framework.Variable`1[[System.Single, + mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]"},"CurrentGear":{"_name":"CurrentGear","_id":"755079af-f39e-4c6a-9a54-75d814f6f89d","$type":"NodeCanvas.Framework.Variable`1[[KartGame.KartSystems.GearMode, + KartGame, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null]]"},"CurrentState":{"_name":"CurrentState","_id":"b18d2e52-d381-4afa-9626-446127f37397","$type":"NodeCanvas.Framework.Variable`1[[KartGame.KartSystems.VehicleState, + KartGame, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null]]"},"FuelPercent":{"_value":1.0,"_name":"FuelPercent","_id":"676d92f5-7aa7-432e-b56b-dd31a1c76a17","$type":"NodeCanvas.Framework.Variable`1[[System.Single, + mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]"},"FuelConsumption":{"_name":"FuelConsumption","_id":"745142e7-9911-4569-af87-3d2e93ef3982","$type":"NodeCanvas.Framework.Variable`1[[System.Single, + mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]"},"EngineStatus":{"_value":true,"_name":"EngineStatus","_id":"4b3fd72d-0058-4d92-a07c-e18f6f11a02c","$type":"NodeCanvas.Framework.Variable`1[[System.Boolean, + mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]"},"LightsStatus":{"_name":"LightsStatus","_id":"bef36727-02f3-4c3b-b9df-13a65270ff09","$type":"NodeCanvas.Framework.Variable`1[[System.Boolean, + mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]"}}},"derivedData":{"repeat":true,"$type":"NodeCanvas.BehaviourTrees.BehaviourTree+DerivedSerializationData"}}' + _objectReferences: [] + _graphSource: + _version: 3.33 + _category: + _comments: + _translation: {x: 0, y: 0} + _zoomFactor: 1 + _haltSerialization: 0 + _externalSerializationFile: {fileID: 0} diff --git a/Assets/BP_Scripts/BT_Kart_Advanced.asset.meta b/Assets/BP_Scripts/BT_Kart_Advanced.asset.meta new file mode 100644 index 0000000..1016d8f --- /dev/null +++ b/Assets/BP_Scripts/BT_Kart_Advanced.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: eb5849b7cdf4f494ea240a506245ddb7 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Karting/Tutorials/5 Build and Publish/Criteria.meta b/Assets/BP_Scripts/GameplayEditor.meta similarity index 77% rename from Assets/Karting/Tutorials/5 Build and Publish/Criteria.meta rename to Assets/BP_Scripts/GameplayEditor.meta index 8a1fbac..265bfa2 100644 --- a/Assets/Karting/Tutorials/5 Build and Publish/Criteria.meta +++ b/Assets/BP_Scripts/GameplayEditor.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: efb2368ee8bed9140a42c2805613ed11 +guid: 4cfe3e64882de254fba06f94153dab56 folderAsset: yes DefaultImporter: externalObjects: {} diff --git a/Assets/BP_Scripts/GameplayEditor/Config.meta b/Assets/BP_Scripts/GameplayEditor/Config.meta new file mode 100644 index 0000000..7d8d006 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 00ebdf5df6fcdac4690f8271175946b9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Config/ActivityCameraLut.cs b/Assets/BP_Scripts/GameplayEditor/Config/ActivityCameraLut.cs new file mode 100644 index 0000000..7b96c89 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/ActivityCameraLut.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace GameplayEditor.Config +{ + /// + /// 相机资源映射 + /// + [CreateAssetMenu(fileName = "CameraLut", menuName = "Gameplay/Lut/Camera Lut")] + public class ActivityCameraLut : ScriptableObject + { + [Tooltip("相机资源映射列表(新版,包含详细参数)")] + public List CameraMappings = new List(); + + [Tooltip("是否使用新版详细配置")] + public bool UseDetailedConfig = true; + + public CameraMapping FindMapping(int mappingId) + { + return CameraMappings.Find(m => m.MappingID == mappingId); + } + + /// + /// 添加相机映射(向后兼容) + /// + public CameraMapping AddMapping(int mappingId, string prefabPath) + { + var mapping = new CameraMapping + { + MappingID = mappingId, + PrefabPath = prefabPath + }; + mapping.ApplyDefaults(); + CameraMappings.Add(mapping); + return mapping; + } + + /// + /// 验证所有相机配置 + /// + public bool ValidateAll(out List errors) + { + errors = new List(); + var idSet = new HashSet(); + + foreach (var mapping in CameraMappings) + { + if (!mapping.Validate(out var error)) + { + errors.Add($"CameraID={mapping.MappingID}: {error}"); + } + + if (idSet.Contains(mapping.MappingID)) + { + errors.Add($"重复的CameraID: {mapping.MappingID}"); + } + else + { + idSet.Add(mapping.MappingID); + } + } + + return errors.Count == 0; + } + } +} diff --git a/Assets/Karting/Tutorials/5 Build and Publish/Criteria/PublishCriteria.cs.meta b/Assets/BP_Scripts/GameplayEditor/Config/ActivityCameraLut.cs.meta similarity index 83% rename from Assets/Karting/Tutorials/5 Build and Publish/Criteria/PublishCriteria.cs.meta rename to Assets/BP_Scripts/GameplayEditor/Config/ActivityCameraLut.cs.meta index 3680b95..86504b5 100644 --- a/Assets/Karting/Tutorials/5 Build and Publish/Criteria/PublishCriteria.cs.meta +++ b/Assets/BP_Scripts/GameplayEditor/Config/ActivityCameraLut.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: c3420f187011c1a43824ca0d4c88d6c6 +guid: c8a2de62fbe079740b13718f921aefa1 MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Assets/BP_Scripts/GameplayEditor/Config/ActivityFXLut.cs b/Assets/BP_Scripts/GameplayEditor/Config/ActivityFXLut.cs new file mode 100644 index 0000000..dd9e1e9 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/ActivityFXLut.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace GameplayEditor.Config +{ + /// + /// 特效资源映射 + /// + [CreateAssetMenu(fileName = "FXLut", menuName = "Gameplay/Lut/FX Lut")] + public class ActivityFXLut : ScriptableObject + { + [Tooltip("特效资源映射列表(新版,包含生命周期参数)")] + public List FxMappings = new List(); + + [Tooltip("是否使用新版详细配置")] + public bool UseDetailedConfig = true; + + public FXMapping FindMapping(int mappingId) + { + return FxMappings.Find(m => m.MappingID == mappingId); + } + + /// + /// 添加特效映射(向后兼容) + /// + public FXMapping AddMapping(int mappingId, string prefabPath) + { + var mapping = new FXMapping + { + MappingID = mappingId, + PrefabPath = prefabPath + }; + mapping.ApplyDefaults(); + FxMappings.Add(mapping); + return mapping; + } + + /// + /// 获取特定类型的所有特效 + /// + public List GetMappingsByDuration(float minDuration, float maxDuration) + { + return FxMappings.FindAll(m => m.Duration >= minDuration && m.Duration <= maxDuration); + } + + /// + /// 获取所有循环特效 + /// + public List GetLoopingEffects() + { + return FxMappings.FindAll(m => m.Loop); + } + + /// + /// 验证所有特效配置 + /// + public bool ValidateAll(out List errors) + { + errors = new List(); + var idSet = new HashSet(); + + foreach (var mapping in FxMappings) + { + if (!mapping.Validate(out var error)) + { + errors.Add($"FXID={mapping.MappingID}: {error}"); + } + + if (idSet.Contains(mapping.MappingID)) + { + errors.Add($"重复的FXID: {mapping.MappingID}"); + } + else + { + idSet.Add(mapping.MappingID); + } + } + + return errors.Count == 0; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Config/ActivityFXLut.cs.meta b/Assets/BP_Scripts/GameplayEditor/Config/ActivityFXLut.cs.meta new file mode 100644 index 0000000..1eec528 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/ActivityFXLut.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a0200246c4705bf4e8857c7cc838f97b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Config/ActivityMapInfoLut.cs b/Assets/BP_Scripts/GameplayEditor/Config/ActivityMapInfoLut.cs new file mode 100644 index 0000000..0f80681 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/ActivityMapInfoLut.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace GameplayEditor.Config +{ + /// + /// 地图信息映射 + /// + [CreateAssetMenu(fileName = "MapInfoLut", menuName = "Gameplay/Lut/MapInfo Lut")] + public class ActivityMapInfoLut : ScriptableObject + { + [Tooltip("地图信息ID")] + public int InfoID; + + [Tooltip("点位列表")] + public List Points = new List(); + + public MapPoint FindPoint(string pointId) + { + return Points.Find(p => p.PointID == pointId); + } + + public MapPoint FindPoint(int index) + { + if (index >= 0 && index < Points.Count) + return Points[index]; + return null; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Config/ActivityMapInfoLut.cs.meta b/Assets/BP_Scripts/GameplayEditor/Config/ActivityMapInfoLut.cs.meta new file mode 100644 index 0000000..66b5b75 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/ActivityMapInfoLut.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 02c0e07dc8fdfef46a628060d1fffd95 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Config/ActivityStageConfig.cs b/Assets/BP_Scripts/GameplayEditor/Config/ActivityStageConfig.cs new file mode 100644 index 0000000..2fb20f8 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/ActivityStageConfig.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using NodeCanvas.BehaviourTrees; +using GameplayEditor.Core; + +namespace GameplayEditor.Config +{ + /// + /// 玩法关卡配置数据 + /// 对应Excel: ActivityStageConfig Sheet + /// + [CreateAssetMenu(fileName = "ActivityStageConfig", menuName = "Gameplay/Activity Stage Config")] + public class ActivityStageConfig : ScriptableObject + { + [Header("基础信息")] + [Tooltip("行为树ID(唯一)")] + public int ActivityStageID; + + [Tooltip("关卡备注")] + [TextArea(2, 4)] + public string Doc; + + [Tooltip("场景名称")] + public string SceneName; + + [Header("地图配置")] + [Tooltip("地图打点组ID(引用ActivityMapInfoLut)")] + public int MapInfo; + + [Header("加载配置")] + [Tooltip("延迟关闭加载图(秒)")] + [Range(0, 10)] + public float CloseLoadingDelay; + + [Header("相机配置")] + [Tooltip("默认相机ID(引用ActivityCameraLut)")] + public int CameraID; + + [Header("运行时配置")] + [Tooltip("Tick率模式")] + public TickRateMode TickMode = TickRateMode.Normal; + + [Tooltip("行为树运行帧率(tick/秒),Normal模式下有效")] + [Range(20, 120)] + public int TickRate = 60; + + [Tooltip("无限制模式下是否使用固定DeltaTime")] + public bool UseFixedDeltaTimeInUnlimited = true; + + [Tooltip("无限制模式下的固定DeltaTime(秒)")] + public float UnlimitedDeltaTime = 0.016f; + + [Header("关联资产")] + [Tooltip("头文件行为树(可选)")] + public BehaviourTree HeaderTree; + + [Tooltip("正文行为树")] + public BehaviourTree BodyTree; + + [Tooltip("运行时数据配置")] + public Core.BehaviourTreeRuntimeData RuntimeData; + + /// + /// 获取基础StageID + /// + public int GetBaseStageID() + { + return Core.BehaviourTreeExtended.GetBaseStageID(ActivityStageID); + } + + /// + /// 是否为头文件配置 + /// + public bool IsHeaderFile() + { + return Core.BehaviourTreeExtended.IsHeaderFile(ActivityStageID); + } + + /// + /// 验证配置完整性 + /// + public bool Validate(out string errorMessage) + { + if (ActivityStageID <= 0) + { + errorMessage = "ActivityStageID必须大于0"; + return false; + } + + if (string.IsNullOrWhiteSpace(SceneName)) + { + errorMessage = "SceneName不能为空"; + return false; + } + + if (BodyTree == null) + { + errorMessage = "正文行为树不能为空"; + return false; + } + + errorMessage = null; + return true; + } + + /// + /// 应用默认值(用于修复缺失配置) + /// + public void ApplyDefaults() + { + var defaultConfig = GameplayDefaultConfig.Instance; + defaultConfig.ApplyDefaults(this); + } + + /// + /// 验证并尝试自动修复 + /// + public bool ValidateAndFix(out string errorMessage) + { + // 先应用默认值 + ApplyDefaults(); + + // 然后验证 + return Validate(out errorMessage); + } + + /// + /// 获取完整验证报告 + /// + public List GetValidationReport() + { + var reports = new List(); + + if (ActivityStageID <= 0) + reports.Add("[错误] ActivityStageID必须大于0"); + + if (string.IsNullOrWhiteSpace(SceneName)) + reports.Add($"[警告] SceneName为空,将使用默认值: {GameplayDefaultConfig.Instance.DefaultSceneName}"); + + if (BodyTree == null) + reports.Add("[错误] 正文行为树不能为空"); + + if (TickRate < GameplayDefaultConfig.Instance.MinTickRate || + TickRate > GameplayDefaultConfig.Instance.MaxTickRate) + { + reports.Add($"[警告] TickRate {TickRate} 超出推荐范围 [{GameplayDefaultConfig.Instance.MinTickRate}, {GameplayDefaultConfig.Instance.MaxTickRate}]"); + } + + if (CloseLoadingDelay < 0) + reports.Add("[警告] CloseLoadingDelay不能为负数"); + + if (reports.Count == 0) + reports.Add("[通过] 配置验证通过"); + + return reports; + } + } + + /// + /// 玩法关卡配置容器 + /// 管理所有关卡配置 + /// + [CreateAssetMenu(fileName = "StageConfigDatabase", menuName = "Gameplay/Stage Config Database")] + public class ActivityStageConfigDatabase : ScriptableObject + { + [Tooltip("所有关卡配置")] + public List Configs = new List(); + + /// + /// 根据StageID查找配置 + /// + public ActivityStageConfig FindByStageID(int stageId) + { + return Configs.Find(c => c.ActivityStageID == stageId); + } + + /// + /// 根据场景名称查找配置 + /// + public ActivityStageConfig FindBySceneName(string sceneName) + { + return Configs.Find(c => c.SceneName == sceneName); + } + + /// + /// 添加或更新配置 + /// + public void AddOrUpdate(ActivityStageConfig config) + { + var existing = FindByStageID(config.ActivityStageID); + if (existing != null) + { + Configs.Remove(existing); + } + Configs.Add(config); + } + + /// + /// 移除配置 + /// + public bool Remove(int stageId) + { + var config = FindByStageID(stageId); + if (config != null) + { + return Configs.Remove(config); + } + return false; + } + + /// + /// 获取所有基础StageID(去重) + /// + public List GetAllBaseStageIDs() + { + var result = new HashSet(); + foreach (var config in Configs) + { + result.Add(config.GetBaseStageID()); + } + return new List(result); + } + } + + /// + /// 玩法关卡配置解析器 + /// 从Excel解析ActivityStageConfig Sheet + /// + [System.Serializable] + public class ActivityStageConfigData + { + public int ActivityStageID; + public string Doc; + public string SceneName; + public int MapInfo; + public float CloseLoadingDelay; + public int CameraID; + public TickRateMode TickMode = TickRateMode.Normal; + public int TickRate = 60; + public bool UseFixedDeltaTimeInUnlimited = true; + public float UnlimitedDeltaTime = 0.016f; + + /// + /// 从字典创建 + /// + public static ActivityStageConfigData FromDictionary(Dictionary data) + { + var result = new ActivityStageConfigData(); + + if (data.TryGetValue("ActivityStageID", out var idStr) && int.TryParse(idStr, out var id)) + result.ActivityStageID = id; + + if (data.TryGetValue("Doc", out var doc)) + result.Doc = doc; + + if (data.TryGetValue("SceneName", out var sceneName)) + result.SceneName = sceneName; + + if (data.TryGetValue("MapInfo", out var mapInfoStr) && int.TryParse(mapInfoStr, out var mapInfo)) + result.MapInfo = mapInfo; + + if (data.TryGetValue("CloseLoadingDelay", out var delayStr) && float.TryParse(delayStr, out var delay)) + result.CloseLoadingDelay = delay; + + if (data.TryGetValue("CameraID", out var cameraIdStr) && int.TryParse(cameraIdStr, out var cameraId)) + result.CameraID = cameraId; + + if (data.TryGetValue("TickMode", out var tickModeStr) && System.Enum.TryParse(tickModeStr, out var tickMode)) + result.TickMode = tickMode; + + if (data.TryGetValue("TickRate", out var tickRateStr) && int.TryParse(tickRateStr, out var tickRate)) + result.TickRate = tickRate; + + if (data.TryGetValue("UseFixedDeltaTimeInUnlimited", out var useFixedDtStr) && bool.TryParse(useFixedDtStr, out var useFixedDt)) + result.UseFixedDeltaTimeInUnlimited = useFixedDt; + + if (data.TryGetValue("UnlimitedDeltaTime", out var unlimitedDtStr) && float.TryParse(unlimitedDtStr, out var unlimitedDt)) + result.UnlimitedDeltaTime = unlimitedDt; + + return result; + } + + /// + /// 应用到ScriptableObject + /// + public void ApplyTo(ActivityStageConfig config) + { + config.ActivityStageID = ActivityStageID; + config.Doc = Doc; + config.SceneName = SceneName; + config.MapInfo = MapInfo; + config.CloseLoadingDelay = CloseLoadingDelay; + config.CameraID = CameraID; + config.TickMode = TickMode; + config.TickRate = TickRate; + config.UseFixedDeltaTimeInUnlimited = UseFixedDeltaTimeInUnlimited; + config.UnlimitedDeltaTime = UnlimitedDeltaTime; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Config/ActivityStageConfig.cs.meta b/Assets/BP_Scripts/GameplayEditor/Config/ActivityStageConfig.cs.meta new file mode 100644 index 0000000..3036537 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/ActivityStageConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 284c4f6ffb93e9e4f8faa5287e82fce5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Config/ActivityUiLut.cs b/Assets/BP_Scripts/GameplayEditor/Config/ActivityUiLut.cs new file mode 100644 index 0000000..2d4846d --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/ActivityUiLut.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace GameplayEditor.Config +{ + /// + /// UI资源映射 + /// + [CreateAssetMenu(fileName = "UILut", menuName = "Gameplay/Lut/UI Lut")] + public class ActivityUiLut : ScriptableObject + { + [Tooltip("UI资源映射列表")] + public List UiMappings = new List(); + + public ResourceMapping FindMapping(int mappingId) + { + return UiMappings.Find(m => m.MappingID == mappingId); + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Config/ActivityUiLut.cs.meta b/Assets/BP_Scripts/GameplayEditor/Config/ActivityUiLut.cs.meta new file mode 100644 index 0000000..526ae5b --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/ActivityUiLut.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 84f836a2bb387f94ca1ee3d55c088233 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Config/DialogInfoByStageConfig.cs b/Assets/BP_Scripts/GameplayEditor/Config/DialogInfoByStageConfig.cs new file mode 100644 index 0000000..c035789 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/DialogInfoByStageConfig.cs @@ -0,0 +1,361 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace GameplayEditor.Config +{ + /// + /// 关卡对话信息映射条目 + /// 建立关卡ID与DialogInfo配置表的关联 + /// + [Serializable] + public class DialogInfoByStageEntry + { + [Tooltip("玩法关卡ID")] + public int StageID; + + [Tooltip("对应文本配置表名(如 DialogInfo_策划A)")] + public string DialogInfoSheetName; + + [Tooltip("备注说明")] + public string Doc; + } + + /// + /// 关卡对话信息映射配置 + /// 对应Excel: ActivityDialogInfoByStage Sheet + /// 作为索引表,将StageID映射到具体的DialogInfo配置表 + /// + [CreateAssetMenu(fileName = "DialogInfoByStageConfig", menuName = "Gameplay/Dialog Info By Stage Config")] + public class DialogInfoByStageConfig : ScriptableObject + { + [Tooltip("映射条目列表")] + public List Entries = new List(); + + [Tooltip("配置来源Excel路径")] + public string SourceExcelPath = ""; + + /// + /// StageID到SheetName的映射缓存 + /// + [NonSerialized] + private Dictionary _stageToSheetCache; + + /// + /// 根据StageID查找对应的DialogInfo表名 + /// + public string GetSheetNameByStageID(int stageId) + { + if (_stageToSheetCache == null) + BuildCache(); + + return _stageToSheetCache.TryGetValue(stageId, out var sheetName) ? sheetName : null; + } + + /// + /// 查找条目 + /// + public DialogInfoByStageEntry FindEntry(int stageId) + { + return Entries.Find(e => e.StageID == stageId); + } + + /// + /// 添加或更新映射 + /// + public void AddOrUpdateEntry(DialogInfoByStageEntry entry) + { + var existing = FindEntry(entry.StageID); + if (existing != null) + { + Entries.Remove(existing); + } + Entries.Add(entry); + _stageToSheetCache = null; // 清除缓存 + } + + /// + /// 移除映射 + /// + public bool RemoveEntry(int stageId) + { + var entry = FindEntry(stageId); + if (entry != null) + { + Entries.Remove(entry); + _stageToSheetCache = null; + return true; + } + return false; + } + + /// + /// 构建映射缓存 + /// + public void BuildCache() + { + _stageToSheetCache = new Dictionary(); + foreach (var entry in Entries) + { + if (!_stageToSheetCache.ContainsKey(entry.StageID)) + { + _stageToSheetCache[entry.StageID] = entry.DialogInfoSheetName; + } + else + { + Debug.LogWarning($"[DialogInfoByStage] 重复的StageID: {entry.StageID}"); + } + } + } + + /// + /// 获取所有StageID列表 + /// + public List GetAllStageIDs() + { + if (_stageToSheetCache == null) + BuildCache(); + + return new List(_stageToSheetCache.Keys); + } + + /// + /// 获取映射字典(供DialogInfoDatabase使用) + /// + public Dictionary GetStageToSheetDictionary() + { + if (_stageToSheetCache == null) + BuildCache(); + return new Dictionary(_stageToSheetCache); + } + + /// + /// 验证配置完整性 + /// + public bool Validate(out List errors) + { + errors = new List(); + + var stageIdSet = new HashSet(); + var sheetNameSet = new HashSet(); + + foreach (var entry in Entries) + { + // 检查StageID + if (entry.StageID <= 0) + { + errors.Add($"无效的StageID: {entry.StageID}"); + } + else if (stageIdSet.Contains(entry.StageID)) + { + errors.Add($"重复的StageID: {entry.StageID}"); + } + else + { + stageIdSet.Add(entry.StageID); + } + + // 检查SheetName + if (string.IsNullOrWhiteSpace(entry.DialogInfoSheetName)) + { + errors.Add($"StageID={entry.StageID} 的DialogInfoSheetName不能为空"); + } + else + { + sheetNameSet.Add(entry.DialogInfoSheetName); + } + } + + return errors.Count == 0; + } + + /// + /// 获取统计信息 + /// + public string GetStatistics() + { + return $"总共 {Entries.Count} 个关卡映射,涉及 {new HashSet(Entries.ConvertAll(e => e.DialogInfoSheetName)).Count} 个DialogInfo表"; + } + } + + /// + /// 对话信息运行时管理器 + /// 整合DialogInfoByStageConfig和DialogInfoDatabase,提供统一的查询接口 + /// 同时管理运行时对话状态 + /// + public class DialogInfoManager + { + private static DialogInfoManager _instance; + public static DialogInfoManager Instance => _instance ??= new DialogInfoManager(); + + private DialogInfoByStageConfig _byStageConfig; + private DialogInfoDatabase _database; + + /// + /// 是否已初始化 + /// + public bool IsInitialized => _database != null && _byStageConfig != null; + + // 运行时对话状态 + private Dictionary _dialogStates = new Dictionary(); + + /// + /// 对话状态 + /// + private class DialogState + { + public bool IsActive; + public float StartTime; + public float Duration; + } + + /// + /// 初始化管理器 + /// + public void Initialize(DialogInfoByStageConfig byStageConfig, DialogInfoDatabase database) + { + _byStageConfig = byStageConfig; + _database = database; + + // 构建数据库缓存 + if (_byStageConfig != null && _database != null) + { + var mapping = _byStageConfig.GetStageToSheetDictionary(); + _database.BuildStageCache(mapping); + } + } + + /// + /// 获取指定关卡的所有对话 + /// + public List GetDialogsByStage(int stageId) + { + if (_database == null) + { + Debug.LogError("[DialogInfoManager] 未初始化,数据库为空"); + return new List(); + } + + return _database.GetDialogsByStage(stageId); + } + + /// + /// 根据对话ID查找对话 + /// + public DialogInfoData GetDialogByID(int dialogId) + { + if (_database == null) + { + Debug.LogError("[DialogInfoManager] 未初始化,数据库为空"); + return null; + } + + return _database.FindDialogByID(dialogId); + } + + /// + /// 获取关卡对应的DialogInfo表名 + /// + public string GetSheetNameByStage(int stageId) + { + if (_byStageConfig == null) + { + Debug.LogError("[DialogInfoManager] 未初始化,索引配置为空"); + return null; + } + + return _byStageConfig.GetSheetNameByStageID(stageId); + } + + // ═════════════════════════════════════════════════════════════════ + // 运行时对话状态管理 + // ═════════════════════════════════════════════════════════════════ + + /// + /// 显示对话 + /// + public bool ShowDialog(int dialogId) + { + var dialog = GetDialogByID(dialogId); + if (dialog == null) + { + Debug.LogWarning($"[DialogInfoManager] 未找到对话配置: ID={dialogId}"); + return false; + } + + // 记录对话状态 + _dialogStates[dialogId] = new DialogState + { + IsActive = true, + StartTime = Time.time, + Duration = dialog.Duration + }; + + // TODO: 调用UI系统显示对话 + // UIManager.Instance?.ShowDialog(dialog); + + Debug.Log($"[DialogInfoManager] 显示对话: ID={dialogId}, Text={dialog.Text}"); + return true; + } + + /// + /// 关闭对话 + /// + public void CloseDialog(int dialogId) + { + if (_dialogStates.ContainsKey(dialogId)) + { + _dialogStates[dialogId].IsActive = false; + } + + // TODO: 调用UI系统关闭对话 + // UIManager.Instance?.CloseDialog(dialogId); + + Debug.Log($"[DialogInfoManager] 关闭对话: ID={dialogId}"); + } + + /// + /// 关闭所有对话 + /// + public void CloseAllDialogs() + { + foreach (var kvp in _dialogStates) + { + kvp.Value.IsActive = false; + } + + // TODO: 调用UI系统关闭所有对话 + // UIManager.Instance?.CloseAllDialogs(); + + Debug.Log("[DialogInfoManager] 关闭所有对话"); + } + + /// + /// 检查对话是否激活 + /// + public bool IsDialogActive(int dialogId) + { + if (_dialogStates.TryGetValue(dialogId, out var state)) + { + return state.IsActive; + } + return false; + } + + /// + /// 获取对话剩余时间 + /// + public float GetDialogRemainingTime(int dialogId) + { + if (_dialogStates.TryGetValue(dialogId, out var state) && state.IsActive) + { + if (state.Duration <= 0) + return -1; // 永久显示 + + var elapsed = Time.time - state.StartTime; + return Mathf.Max(0, state.Duration - elapsed); + } + return 0; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Config/DialogInfoByStageConfig.cs.meta b/Assets/BP_Scripts/GameplayEditor/Config/DialogInfoByStageConfig.cs.meta new file mode 100644 index 0000000..c719f52 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/DialogInfoByStageConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 808c51ff53034314d89581dd7d0e7682 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Config/DialogInfoConfig.cs b/Assets/BP_Scripts/GameplayEditor/Config/DialogInfoConfig.cs new file mode 100644 index 0000000..af839ca --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/DialogInfoConfig.cs @@ -0,0 +1,415 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace GameplayEditor.Config +{ + /// + /// 对话信息数据 + /// 对应Excel: DialogInfo Sheet + /// + [Serializable] + public class DialogInfoData + { + [Tooltip("配置ID")] + public int ID; + + [Tooltip("是否开启蒙层")] + public bool IsMask = false; + + [Tooltip("蒙层位置(万分比屏幕坐标)")] + public Vector2 MaskTarget = Vector2.zero; + + [Tooltip("关闭蒙层键值")] + public string ClickKey = ""; + + [Tooltip("战中类型")] + public int DialogType = 0; + + [Tooltip("UI样式,用来改变同类型战中的样式")] + public int UiType = 0; + + [Tooltip("UI预制体路径")] + public string PrefabPath = ""; + + [Tooltip("显示角色名")] + public string CharacterName = ""; + + [Tooltip("对话正文(支持富文本)")] + [TextArea(3, 10)] + public string Text = ""; + + [Tooltip("显示时间(秒,0表示一直显示直到点击)")] + public float Duration = 0f; + + [Tooltip("特殊参数1")] + public string Params1 = ""; + + [Tooltip("特殊参数2")] + public string Params2 = ""; + + [Tooltip("特殊参数3")] + public string Params3 = ""; + + [Tooltip("特殊参数4")] + public string Params4 = ""; + + [Tooltip("特殊参数5")] + public string Params5 = ""; + + [Header("富文本")] + [Tooltip("是否启用富文本")] + public bool EnableRichText = true; + + [Tooltip("富文本样式名称(留空使用默认)")] + public string RichTextStyleName = ""; + + [Tooltip("自定义富文本配置(可选)")] + public RichTextConfig CustomRichTextConfig; + + [Tooltip("文本是否需要验证")] + public bool ValidateRichText = true; + + [Header("多语言")] + [Tooltip("英文文本")] + public string Text_EN = ""; + + [Tooltip("日文文本")] + public string Text_JP = ""; + + [Tooltip("韩文文本")] + public string Text_KR = ""; + + [Tooltip("法文文本")] + public string Text_FR = ""; + + [Tooltip("德文文本")] + public string Text_DE = ""; + + [Tooltip("西班牙文文本")] + public string Text_ES = ""; + + /// + /// 获取参数列表 + /// + public string[] GetParams() + { + return new[] { Params1, Params2, Params3, Params4, Params5 }; + } + + /// + /// 替换文本中的占位符 + /// + public string FormatText(params string[] args) + { + if (string.IsNullOrEmpty(Text)) + return Text; + + try + { + return string.Format(Text, args); + } + catch (FormatException) + { + Debug.LogWarning($"[DialogInfo] 文本格式化失败: ID={ID}, Text={Text}"); + return Text; + } + } + + /// + /// 验证配置有效性 + /// + public bool Validate(out string errorMessage) + { + if (ID <= 0) + { + errorMessage = $"DialogInfo ID必须大于0"; + return false; + } + + if (string.IsNullOrWhiteSpace(Text)) + { + errorMessage = $"DialogInfo ID={ID} 的Text不能为空"; + return false; + } + + // 富文本验证 + if (ValidateRichText && EnableRichText && !string.IsNullOrEmpty(Text) && Text.Contains("<")) + { + var config = CustomRichTextConfig ?? GetEffectiveRichTextConfig(); + if (config != null) + { + var validator = config.CreateValidator(); + var result = validator.Validate(Text); + + if (!result.IsValid) + { + errorMessage = $"DialogInfo ID={ID} 的富文本验证失败: {string.Join(", ", result.Errors)}"; + return false; + } + } + } + + errorMessage = null; + return true; + } + + /// + /// 验证富文本(返回详细结果) + /// + public Utils.RichTextValidator.ValidationResult ValidateRichTextContent() + { + if (!EnableRichText || string.IsNullOrEmpty(Text) || !Text.Contains("<")) + { + return new Utils.RichTextValidator.ValidationResult + { + IsValid = true, + CleanedText = Text + }; + } + + var config = GetEffectiveRichTextConfig(); + if (config == null) + { + return new Utils.RichTextValidator.ValidationResult + { + IsValid = true, + CleanedText = Text + }; + } + + var validator = config.CreateValidator(); + return validator.Validate(Text); + } + + /// + /// 获取有效的富文本配置 + /// + public RichTextConfig GetEffectiveRichTextConfig() + { + // 优先使用自定义配置 + if (CustomRichTextConfig != null) + return CustomRichTextConfig; + + // 否则使用默认配置 + var defaultConfig = GameplayDefaultConfig.Instance; + if (defaultConfig?.DefaultDialogRichTextStyle != null) + { + // 从样式创建配置 + var config = ScriptableObject.CreateInstance(); + config.InitializeDefaults(); + return config; + } + + return null; + } + + /// + /// 清理富文本(移除非法标签) + /// + public string SanitizeRichText() + { + var result = ValidateRichTextContent(); + return result.CleanedText ?? Text; + } + + /// + /// 应用默认值 + /// + public void ApplyDefaults() + { + var defaultConfig = GameplayDefaultConfig.Instance; + defaultConfig.ApplyDefaults(this); + } + + /// + /// 验证并尝试自动修复 + /// + public bool ValidateAndFix(out string errorMessage) + { + ApplyDefaults(); + return Validate(out errorMessage); + } + + } + + /// + /// 对话信息配置容器 + /// 单个DialogInfo表的配置 + /// + [CreateAssetMenu(fileName = "DialogInfoConfig", menuName = "Gameplay/Dialog Info Config")] + public class DialogInfoConfig : ScriptableObject + { + [Tooltip("配置来源Sheet名称")] + public string SheetName = ""; + + [Tooltip("配置来源Excel路径")] + public string SourceExcelPath = ""; + + [Tooltip("对话配置列表")] + public List Dialogs = new List(); + + /// + /// 根据ID查找对话配置 + /// + public DialogInfoData FindByID(int id) + { + return Dialogs.Find(d => d.ID == id); + } + + /// + /// 添加或更新对话配置 + /// + public void AddOrUpdate(DialogInfoData data) + { + var existing = FindByID(data.ID); + if (existing != null) + { + Dialogs.Remove(existing); + } + Dialogs.Add(data); + } + + /// + /// 验证所有配置 + /// + public bool ValidateAll(out List errors) + { + errors = new List(); + + // 检查ID唯一性 + var idSet = new HashSet(); + foreach (var dialog in Dialogs) + { + if (idSet.Contains(dialog.ID)) + { + errors.Add($"重复的DialogInfo ID: {dialog.ID}"); + } + idSet.Add(dialog.ID); + + // 验证单个配置 + if (!dialog.Validate(out var error)) + { + errors.Add(error); + } + } + + return errors.Count == 0; + } + } + + /// + /// 对话信息数据库 + /// 管理多个DialogInfo配置表 + /// 支持按StageID查找对应对话 + /// + [CreateAssetMenu(fileName = "DialogInfoDatabase", menuName = "Gameplay/Dialog Info Database")] + public class DialogInfoDatabase : ScriptableObject + { + [Tooltip("所有对话配置表")] + public List Configs = new List(); + + /// + /// StageID到Config的映射缓存 + /// + [NonSerialized] + private Dictionary _stageToConfigCache; + + /// + /// 根据对话ID查找配置(在所有表中搜索) + /// + public DialogInfoData FindDialogByID(int dialogId) + { + foreach (var config in Configs) + { + var dialog = config.FindByID(dialogId); + if (dialog != null) + return dialog; + } + return null; + } + + /// + /// 获取指定关卡的所有对话 + /// + public List GetDialogsByStage(int stageId) + { + var result = new List(); + + // 通过映射表查找对应的Config + if (_stageToConfigCache == null) + BuildStageCache(); + + if (_stageToConfigCache.TryGetValue(stageId, out var config)) + { + result.AddRange(config.Dialogs); + } + + return result; + } + + /// + /// 添加配置表 + /// + public void AddConfig(DialogInfoConfig config) + { + if (!Configs.Contains(config)) + { + Configs.Add(config); + _stageToConfigCache = null; // 清除缓存 + } + } + + /// + /// 构建StageID缓存(需要配合DialogInfoByStageConfig使用) + /// + public void BuildStageCache(Dictionary stageToSheetName = null) + { + _stageToConfigCache = new Dictionary(); + + if (stageToSheetName == null) + return; + + foreach (var kvp in stageToSheetName) + { + var stageId = kvp.Key; + var sheetName = kvp.Value; + + // 查找匹配的Config + var config = Configs.Find(c => + c.SheetName == sheetName || + c.name.Contains(sheetName)); + + if (config != null) + { + _stageToConfigCache[stageId] = config; + } + } + } + + /// + /// 清空缓存 + /// + public void ClearCache() + { + _stageToConfigCache = null; + } + + /// + /// 获取所有对话(跨所有配置表) + /// + public IEnumerable GetAllDialogs() + { + foreach (var config in Configs) + { + if (config?.Dialogs != null) + { + foreach (var dialog in config.Dialogs) + { + yield return dialog; + } + } + } + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Config/DialogInfoConfig.cs.meta b/Assets/BP_Scripts/GameplayEditor/Config/DialogInfoConfig.cs.meta new file mode 100644 index 0000000..e23eff0 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/DialogInfoConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ad328400186ec474d9505f3bb33d8aaa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Config/EventBuilderConfig.cs b/Assets/BP_Scripts/GameplayEditor/Config/EventBuilderConfig.cs new file mode 100644 index 0000000..c248668 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/EventBuilderConfig.cs @@ -0,0 +1,344 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace GameplayEditor.Config +{ + /// + /// 事件类型枚举 + /// + public enum EventType + { + Battle = 1, // 战斗 + Choice = 2, // 抉择 + Reward = 3, // 奖励 + Shop = 4, // 商店 + Rest = 5 // 休息区 + } + + /// + /// 事件池类型 + /// + public enum EventPool + { + Battle = 1, + Choice = 2, + Reward = 3, + Shop = 4, + Rest = 5 + } + + /// + /// 事件节点配置 + /// 对应初始化配置列表 + /// + [Serializable] + public class EventNodeConfig + { + [Tooltip("行索引(自动生成)")] + public int RowIndex; + + [Tooltip("关卡章节ID")] + public int LevelID; + + [Tooltip("节点ID(唯一)")] + public int NodeID; + + [Tooltip("节点层级")] + public int NodeLayer; + + [Tooltip("节点池类型组(1战斗|2抉择|3奖励|4商店|5休息区)")] + public List EventTypeGroup = new List(); + + [Tooltip("类型权重(万分比,如2000|2000|2000|2000|2000)")] + public List Priority = new List(); + + [Tooltip("存储事件ID映射")] + public int EventMappingID; + + [Tooltip("交互点是否可见")] + public bool InteractVisible = true; + + [Tooltip("节点是否可交互")] + public bool NodeState = true; + } + + /// + /// 事件Action配置 + /// 对应事件Action配置列表 + /// + [Serializable] + public class EventActionConfig + { + [Tooltip("事件ID(唯一)")] + public int EventID; + + [Tooltip("节点类型(1战斗|2抉择|3奖励|4商店|5休息区)")] + public EventType Type; + + [Tooltip("事件池")] + public EventPool Pool; + + [Tooltip("消耗完后是否移除事件池")] + public bool Removed = true; + } + + /// + /// 活动关卡事件构建配置容器 + /// + [CreateAssetMenu(fileName = "EventBuilderConfig", menuName = "Gameplay/Event Builder Config")] + public class EventBuilderConfig : ScriptableObject + { + [Header("节点配置")] + [Tooltip("初始化配置列表")] + public List NodeConfigs = new List(); + + [Header("事件配置")] + [Tooltip("事件Action配置列表")] + public List ActionConfigs = new List(); + + [Header("事件映射")] + [Tooltip("EventMappingID 到 EventID 列表的映射配置")] + public EventMappingConfig EventMappingConfig; + + [Header("元数据")] + [Tooltip("关联的关卡ID")] + public int LevelID; + + [Tooltip("配置来源Excel路径")] + public string SourceExcelPath; + + /// + /// 根据NodeID查找节点配置 + /// + public EventNodeConfig FindNodeByID(int nodeId) + { + return NodeConfigs.Find(n => n.NodeID == nodeId); + } + + /// + /// 根据EventID查找事件配置 + /// + public EventActionConfig FindActionByID(int eventId) + { + return ActionConfigs.Find(a => a.EventID == eventId); + } + + /// + /// 获取指定层级的所有节点 + /// + public List GetNodesByLayer(int layer) + { + return NodeConfigs.FindAll(n => n.NodeLayer == layer); + } + + /// + /// 根据 EventMappingID 获取对应的事件列表 + /// 如果 EventMappingConfig 中未找到,则根据 nodeConfig 的 EventTypeGroup 做 Fallback 匹配 + /// + public List GetEventsByMappingID(int mappingId, EventNodeConfig nodeConfig = null) + { + var result = new List(); + + // 1. 优先查找显式映射表 + if (EventMappingConfig != null) + { + var entry = EventMappingConfig.FindByMappingID(mappingId); + if (entry != null && entry.EventIDs != null) + { + foreach (var eventId in entry.EventIDs) + { + var action = FindActionByID(eventId); + if (action != null) result.Add(action); + } + if (result.Count > 0) return result; + } + } + + // 2. Fallback:根据 EventTypeGroup 筛选 ActionConfigs + if (nodeConfig != null && nodeConfig.EventTypeGroup != null) + { + foreach (var action in ActionConfigs) + { + if (nodeConfig.EventTypeGroup.Contains((int)action.Type)) + { + result.Add(action); + } + } + } + + return result; + } + + /// + /// 添加或更新节点配置 + /// + public void AddOrUpdateNode(EventNodeConfig config) + { + var existing = FindNodeByID(config.NodeID); + if (existing != null) + { + NodeConfigs.Remove(existing); + } + NodeConfigs.Add(config); + } + + /// + /// 添加或更新事件配置 + /// + public void AddOrUpdateAction(EventActionConfig config) + { + var existing = FindActionByID(config.EventID); + if (existing != null) + { + ActionConfigs.Remove(existing); + } + ActionConfigs.Add(config); + } + + /// + /// 验证配置完整性 + /// + public bool Validate(out string errorMessage) + { + // 检查节点ID唯一性 + var nodeIds = new HashSet(); + foreach (var node in NodeConfigs) + { + if (nodeIds.Contains(node.NodeID)) + { + errorMessage = $"重复的NodeID: {node.NodeID}"; + return false; + } + nodeIds.Add(node.NodeID); + } + + // 检查事件ID唯一性 + var eventIds = new HashSet(); + foreach (var action in ActionConfigs) + { + if (eventIds.Contains(action.EventID)) + { + errorMessage = $"重复的EventID: {action.EventID}"; + return false; + } + eventIds.Add(action.EventID); + } + + errorMessage = null; + return true; + } + + /// + /// 应用默认值到所有节点和事件 + /// + public void ApplyDefaults() + { + var defaultConfig = GameplayDefaultConfig.Instance; + + foreach (var node in NodeConfigs) + { + defaultConfig.ApplyDefaults(node); + } + } + + /// + /// 验证并尝试自动修复 + /// + public bool ValidateAndFix(out string errorMessage) + { + ApplyDefaults(); + return Validate(out errorMessage); + } + + /// + /// 检查权重总和是否正确 + /// + public bool ValidateWeights(out List errorMessages) + { + errorMessages = new List(); + var defaultConfig = GameplayDefaultConfig.Instance; + + foreach (var node in NodeConfigs) + { + if (node.Priority == null || node.Priority.Count == 0) + { + errorMessages.Add($"NodeID={node.NodeID} 的Priority为空"); + continue; + } + + int total = 0; + foreach (var weight in node.Priority) + { + total += weight; + } + + if (total != defaultConfig.TotalWeight) + { + errorMessages.Add($"NodeID={node.NodeID} 的权重总和为 {total},应为 {defaultConfig.TotalWeight}"); + } + } + + return errorMessages.Count == 0; + } + } + + /// + /// 事件构建数据(解析中间结构) + /// + [Serializable] + public class EventBuilderData + { + public List NodeConfigs = new List(); + public List ActionConfigs = new List(); + public int LevelID; + public EventMappingConfig EventMappingConfig; + + /// + /// 应用到ScriptableObject + /// + public void ApplyTo(EventBuilderConfig config) + { + config.NodeConfigs = NodeConfigs; + config.ActionConfigs = ActionConfigs; + config.LevelID = LevelID; + config.EventMappingConfig = EventMappingConfig; + } + + /// + /// 基于 EventTypeGroup 自动生成默认的 EventMapping 配置 + /// 用于当前 Excel 中没有独立 EventMapping 表时的 Fallback + /// + public EventMappingConfig BuildDefaultMappings() + { + var mappingConfig = ScriptableObject.CreateInstance(); + + foreach (var node in NodeConfigs) + { + if (node.EventMappingID <= 0) continue; + if (node.EventTypeGroup == null || node.EventTypeGroup.Count == 0) continue; + + var entry = new EventMappingEntry + { + MappingID = node.EventMappingID, + Doc = $"自动生成的默认映射 (NodeID={node.NodeID})" + }; + + foreach (var action in ActionConfigs) + { + if (node.EventTypeGroup.Contains((int)action.Type)) + { + entry.EventIDs.Add(action.EventID); + } + } + + if (entry.EventIDs.Count > 0) + { + mappingConfig.AddOrUpdateEntry(entry); + } + } + + return mappingConfig; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Config/EventBuilderConfig.cs.meta b/Assets/BP_Scripts/GameplayEditor/Config/EventBuilderConfig.cs.meta new file mode 100644 index 0000000..e00494a --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/EventBuilderConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8b5566f322dad2b40b88aedc4eb2cf28 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Config/EventMappingConfig.cs b/Assets/BP_Scripts/GameplayEditor/Config/EventMappingConfig.cs new file mode 100644 index 0000000..b8c82f5 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/EventMappingConfig.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace GameplayEditor.Config +{ + /// + /// 事件映射条目 + /// EventMappingID 与具体 EventID 列表的关联 + /// + [Serializable] + public class EventMappingEntry + { + [Tooltip("事件映射ID(来自EventNodeConfig.EventMappingID)")] + public int MappingID; + + [Tooltip("关联的事件ID列表")] + public List EventIDs = new List(); + + [Tooltip("备注")] + public string Doc; + } + + /// + /// 事件映射配置表 + /// 建立 EventMappingID -> EventID List 的映射 + /// + [CreateAssetMenu(fileName = "EventMappingConfig", menuName = "Gameplay/Event Mapping Config")] + public class EventMappingConfig : ScriptableObject + { + [Tooltip("事件映射条目列表")] + public List Entries = new List(); + + /// + /// 根据 MappingID 查找条目 + /// + public EventMappingEntry FindByMappingID(int mappingId) + { + return Entries.Find(e => e.MappingID == mappingId); + } + + /// + /// 添加或更新映射条目 + /// + public void AddOrUpdateEntry(EventMappingEntry entry) + { + var existing = FindByMappingID(entry.MappingID); + if (existing != null) + { + Entries.Remove(existing); + } + Entries.Add(entry); + } + + /// + /// 验证配置完整性 + /// + public bool Validate(out List errors) + { + errors = new List(); + var idSet = new HashSet(); + + foreach (var entry in Entries) + { + if (entry.MappingID <= 0) + { + errors.Add($"无效的 MappingID: {entry.MappingID}"); + continue; + } + + if (idSet.Contains(entry.MappingID)) + { + errors.Add($"重复的 MappingID: {entry.MappingID}"); + } + idSet.Add(entry.MappingID); + + if (entry.EventIDs == null || entry.EventIDs.Count == 0) + { + errors.Add($"MappingID={entry.MappingID} 的 EventIDs 为空"); + } + } + + return errors.Count == 0; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Config/EventMappingConfig.cs.meta b/Assets/BP_Scripts/GameplayEditor/Config/EventMappingConfig.cs.meta new file mode 100644 index 0000000..6ed70ae --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/EventMappingConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f2fa8e9510aa0cb42a2f9317a57cd344 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Config/GameplayDefaultConfig.cs b/Assets/BP_Scripts/GameplayEditor/Config/GameplayDefaultConfig.cs new file mode 100644 index 0000000..f3a7d36 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/GameplayDefaultConfig.cs @@ -0,0 +1,398 @@ +using System; +using System.Collections.Generic; +using GameplayEditor.Core; +using UnityEngine; + +namespace GameplayEditor.Config +{ + /// + /// 游戏玩法配置默认值中心 + /// 集中管理所有配置项的默认值,支持项目级自定义 + /// + [CreateAssetMenu(fileName = "GameplayDefaultConfig", menuName = "Gameplay/Default Config")] + public class GameplayDefaultConfig : ScriptableObject + { + #region 单例访问 + + private static GameplayDefaultConfig _instance; + + /// + /// 获取默认配置实例 + /// + public static GameplayDefaultConfig Instance + { + get + { + if (_instance == null) + { + _instance = Resources.Load("GameplayDefaultConfig"); + if (_instance == null) + { + // 创建默认配置 + _instance = CreateDefaultConfig(); + } + } + return _instance; + } + } + + /// + /// 设置自定义实例(用于测试或动态替换) + /// + public static void SetInstance(GameplayDefaultConfig config) + { + _instance = config; + } + + #endregion + + #region ActivityStageConfig 默认值 + + [Header("关卡配置默认值")] + [Tooltip("默认场景名称")] + public string DefaultSceneName = "MainScene"; + + [Tooltip("默认Tick率模式")] + public TickRateMode DefaultTickMode = TickRateMode.Normal; + + [Tooltip("默认Tick率")] + [Range(20, 120)] + public int DefaultTickRate = 60; + + [Tooltip("默认延迟关闭加载图时间")] + public float DefaultCloseLoadingDelay = 1f; + + [Tooltip("默认相机ID")] + public int DefaultCameraID = 1001; + + [Tooltip("默认地图打点组ID")] + public int DefaultMapInfo = 10001; + + [Tooltip("无限制模式下是否使用固定DeltaTime")] + public bool DefaultUseFixedDeltaTimeInUnlimited = true; + + [Tooltip("无限制模式下的固定DeltaTime")] + public float DefaultUnlimitedDeltaTime = 0.016f; + + #endregion + + #region DialogInfoConfig 默认值 + + [Header("对话配置默认值")] + [Tooltip("默认对话显示时间")] + public float DefaultDialogDuration = 3f; + + [Tooltip("默认是否开启蒙层")] + public bool DefaultDialogIsMask = false; + + [Tooltip("默认蒙层位置")] + public Vector2 DefaultDialogMaskTarget = Vector2.zero; + + [Tooltip("默认战中类型")] + public int DefaultDialogType = 0; + + [Tooltip("默认UI样式")] + public int DefaultDialogUiType = 0; + + [Tooltip("默认角色名")] + public string DefaultDialogCharacterName = "角色"; + + [Tooltip("默认是否启用富文本")] + public bool DefaultDialogEnableRichText = true; + + [Tooltip("默认富文本样式")] + public RichTextStyle DefaultDialogRichTextStyle; + + #endregion + + #region EventBuilderConfig 默认值 + + [Header("事件配置默认值")] + [Tooltip("默认节点层级")] + public int DefaultNodeLayer = 1; + + [Tooltip("默认事件类型组")] + public List DefaultEventTypeGroup = new List { 1, 2, 3, 4, 5 }; + + [Tooltip("默认类型权重(万分比)")] + public List DefaultPriority = new List { 2000, 2000, 2000, 2000, 2000 }; + + [Tooltip("默认交互点可见性")] + public bool DefaultInteractVisible = true; + + [Tooltip("默认节点交互状态")] + public bool DefaultNodeState = true; + + [Tooltip("默认事件消耗后是否移除")] + public bool DefaultEventRemoved = true; + + #endregion + + #region CameraConfig 默认值 + + [Header("相机配置默认值")] + [Tooltip("默认FOV")] + [Range(1, 179)] + public float DefaultCameraFOV = 60f; + + [Tooltip("默认优先级")] + public int DefaultCameraPriority = 10; + + [Tooltip("默认跟随偏移")] + public Vector3 DefaultCameraFollowOffset = new Vector3(0, 5, -10); + + [Tooltip("默认注视偏移")] + public Vector3 DefaultCameraLookAtOffset = Vector3.zero; + + [Tooltip("默认过渡时间")] + public float DefaultCameraBlendTime = 0.5f; + + [Tooltip("默认过渡样式")] + public CinemachineBlendStyle DefaultCameraBlendStyle = CinemachineBlendStyle.EaseInOut; + + #endregion + + #region FXConfig 默认值 + + [Header("特效配置默认值")] + [Tooltip("默认特效持续时间")] + public float DefaultFXDuration = 2f; + + [Tooltip("默认是否循环")] + public bool DefaultFXLoop = false; + + [Tooltip("默认是否自动销毁")] + public bool DefaultFXAutoDestroy = true; + + [Tooltip("默认延迟时间")] + public float DefaultFXDelay = 0f; + + [Tooltip("默认播放速度")] + public float DefaultFXSpeed = 1f; + + [Tooltip("默认缩放")] + public float DefaultFXScale = 1f; + + [Tooltip("默认使用对象池")] + public bool DefaultFXUseObjectPool = true; + + #endregion + + #region 行为树默认值 + + [Header("行为树默认值")] + [Tooltip("默认最大递归深度")] + public int DefaultMaxRecursionDepth = 10; + + [Tooltip("默认最大节点深度")] + public int DefaultMaxNodeDepth = 100; + + [Tooltip("默认节点执行警告阈值")] + public int DefaultFrameExecutionThreshold = 60; + + [Tooltip("默认节点执行耗时警告阈值(毫秒)")] + public float DefaultExecutionTimeThresholdMs = 5f; + + #endregion + + #region ID范围约束 + + [Header("ID范围约束")] + [Tooltip("启用严格ID范围检查")] + public bool EnableStrictIDRangeCheck = true; + + [Tooltip("关卡ID最小值")] + public int MinStageID = 1000; + + [Tooltip("关卡ID最大值")] + public int MaxStageID = 1999; + + [Tooltip("节点ID最小值")] + public int MinNodeID = 10000; + + [Tooltip("节点ID最大值")] + public int MaxNodeID = 19999; + + [Tooltip("事件ID最小值")] + public int MinEventID = 9000; + + [Tooltip("事件ID最大值")] + public int MaxEventID = 9999; + + [Tooltip("组合方法ID最小值")] + public int MinCompositeMethodID = 200000; + + [Tooltip("组合方法ID最大值")] + public int MaxCompositeMethodID = 999999; + + #endregion + + #region 验证阈值 + + [Header("验证阈值")] + [Tooltip("ID最小值")] + public int MinID = 1; + + [Tooltip("ID最大值")] + public int MaxID = 999999; + + [Tooltip("最小Tick率")] + public int MinTickRate = 20; + + [Tooltip("最大Tick率")] + public int MaxTickRate = 120; + + [Tooltip("最小权重")] + public int MinWeight = 0; + + [Tooltip("最大权重")] + public int MaxWeight = 10000; + + [Tooltip("权重总和")] + public int TotalWeight = 10000; + + #endregion + + #region 帮助方法 + + /// + /// 应用关卡配置默认值 + /// + public void ApplyDefaults(ActivityStageConfig config) + { + if (config == null) return; + + if (string.IsNullOrWhiteSpace(config.SceneName)) + config.SceneName = DefaultSceneName; + + if (config.TickRate < MinTickRate || config.TickRate > MaxTickRate) + config.TickRate = DefaultTickRate; + + if (config.CloseLoadingDelay <= 0) + config.CloseLoadingDelay = DefaultCloseLoadingDelay; + + if (config.CameraID <= 0) + config.CameraID = DefaultCameraID; + + if (config.MapInfo <= 0) + config.MapInfo = DefaultMapInfo; + } + + /// + /// 应用对话配置默认值 + /// + public void ApplyDefaults(DialogInfoData config) + { + if (config == null) return; + + if (config.Duration <= 0) + config.Duration = DefaultDialogDuration; + + if (string.IsNullOrWhiteSpace(config.CharacterName)) + config.CharacterName = DefaultDialogCharacterName; + } + + /// + /// 应用事件配置默认值 + /// + public void ApplyDefaults(EventNodeConfig config) + { + if (config == null) return; + + if (config.NodeLayer <= 0) + config.NodeLayer = DefaultNodeLayer; + + if (config.EventTypeGroup == null || config.EventTypeGroup.Count == 0) + config.EventTypeGroup = new List(DefaultEventTypeGroup); + + if (config.Priority == null || config.Priority.Count == 0) + config.Priority = new List(DefaultPriority); + } + + /// + /// 应用相机配置默认值 + /// + public void ApplyDefaults(CameraMapping config) + { + if (config == null) return; + + if (config.FOV <= 0) + config.FOV = DefaultCameraFOV; + + if (config.Priority <= 0) + config.Priority = DefaultCameraPriority; + } + + /// + /// 应用特效配置默认值 + /// + public void ApplyDefaults(FXMapping config) + { + if (config == null) return; + + if (config.Duration <= 0) + config.Duration = DefaultFXDuration; + + if (config.Speed <= 0) + config.Speed = DefaultFXSpeed; + + if (config.Scale <= 0) + config.Scale = DefaultFXScale; + } + + #endregion + + #region 私有方法 + + /// + /// 创建默认配置 + /// + private static GameplayDefaultConfig CreateDefaultConfig() + { + var config = CreateInstance(); + config.name = "GameplayDefaultConfig"; + return config; + } + + #endregion + } + + /// + /// Cinemachine混合样式 + /// + public enum CinemachineBlendStyle + { + Cut, + EaseInOut, + EaseIn, + EaseOut, + HardIn, + HardOut, + Linear + } + + /// + /// 富文本样式 + /// + [Serializable] + public class RichTextStyle + { + [Tooltip("样式名称")] + public string StyleName = "Default"; + + [Tooltip("字体大小")] + public int FontSize = 24; + + [Tooltip("字体颜色")] + public Color FontColor = Color.white; + + [Tooltip("是否粗体")] + public bool Bold = false; + + [Tooltip("是否斜体")] + public bool Italic = false; + + [Tooltip("是否下划线")] + public bool Underline = false; + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Config/GameplayDefaultConfig.cs.meta b/Assets/BP_Scripts/GameplayEditor/Config/GameplayDefaultConfig.cs.meta new file mode 100644 index 0000000..f5b8eb5 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/GameplayDefaultConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e5567297bb87af94ba65bbd1f22e4e7d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Config/IDRangeConfig.cs b/Assets/BP_Scripts/GameplayEditor/Config/IDRangeConfig.cs new file mode 100644 index 0000000..8e76eac --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/IDRangeConfig.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace GameplayEditor.Config +{ + /// + /// ID段配置 + /// + [Serializable] + public class IDRange + { + [Tooltip("策划/所有者名称")] + public string OwnerName; + + [Tooltip("起始ID")] + public int StartID; + + [Tooltip("结束ID")] + public int EndID; + + [Tooltip("当前已用ID")] + public int CurrentUsedID; + + [Tooltip("描述/备注")] + [TextArea(1, 2)] + public string Description; + + /// + /// 剩余可用ID数量 + /// + public int RemainingCount => EndID - CurrentUsedID; + + /// + /// 使用率(0-1) + /// + public float UsageRate => (float)(CurrentUsedID - StartID + 1) / (EndID - StartID + 1); + + /// + /// 是否已耗尽 + /// + public bool IsExhausted => CurrentUsedID >= EndID; + + /// + /// 分配新ID + /// + public int AllocateID() + { + if (IsExhausted) + return -1; + + return ++CurrentUsedID; + } + + /// + /// 检查ID是否在此范围内 + /// + public bool Contains(int id) + { + return id >= StartID && id <= EndID; + } + + /// + /// 验证配置有效性 + /// + public bool Validate(out string error) + { + if (string.IsNullOrWhiteSpace(OwnerName)) + { + error = "所有者名称不能为空"; + return false; + } + if (StartID <= 0) + { + error = "起始ID必须大于0"; + return false; + } + if (EndID < StartID) + { + error = "结束ID必须大于等于起始ID"; + return false; + } + if (CurrentUsedID < StartID - 1) + { + error = "当前已用ID不能小于起始ID-1"; + return false; + } + + error = null; + return true; + } + } + + /// + /// ID段分配配置 + /// + [CreateAssetMenu(fileName = "IDRangeConfig", menuName = "Gameplay/ID Range Config")] + public class IDRangeConfig : ScriptableObject + { + [Tooltip("ID段列表")] + public List Ranges = new List(); + + /// + /// 分配新ID + /// + /// 所有者名称 + /// 分配的ID,-1表示失败 + public int AllocateID(string ownerName) + { + var range = Ranges.Find(r => r.OwnerName == ownerName); + if (range == null) + { + Debug.LogError($"[IDRange] 未找到所有者: {ownerName}"); + return -1; + } + + var id = range.AllocateID(); + if (id < 0) + { + Debug.LogError($"[IDRange] ID段已耗尽: {ownerName} ({range.StartID}-{range.EndID})"); + return -1; + } + + #if UNITY_EDITOR + UnityEditor.EditorUtility.SetDirty(this); + UnityEditor.AssetDatabase.SaveAssets(); + #endif + + Debug.Log($"[IDRange] 为 {ownerName} 分配ID: {id}"); + return id; + } + + /// + /// 添加新ID段 + /// + public bool AddRange(IDRange range, out string error) + { + if (!range.Validate(out error)) + return false; + + // 检查所有者名称是否已存在 + if (Ranges.Exists(r => r.OwnerName == range.OwnerName)) + { + error = $"所有者 '{range.OwnerName}' 已存在"; + return false; + } + + // 检查ID段是否重叠 + foreach (var existing in Ranges) + { + if (range.StartID <= existing.EndID && range.EndID >= existing.StartID) + { + error = $"ID段与 '{existing.OwnerName}' 重叠"; + return false; + } + } + + Ranges.Add(range); + + #if UNITY_EDITOR + UnityEditor.EditorUtility.SetDirty(this); + UnityEditor.AssetDatabase.SaveAssets(); + #endif + + error = null; + return true; + } + + /// + /// 移除ID段 + /// + public bool RemoveRange(string ownerName) + { + var range = Ranges.Find(r => r.OwnerName == ownerName); + if (range != null) + { + Ranges.Remove(range); + + #if UNITY_EDITOR + UnityEditor.EditorUtility.SetDirty(this); + UnityEditor.AssetDatabase.SaveAssets(); + #endif + + return true; + } + return false; + } + + /// + /// 根据ID查找所属段 + /// + public IDRange FindRangeByID(int id) + { + return Ranges.Find(r => r.Contains(id)); + } + + /// + /// 检查ID是否冲突 + /// + public bool CheckConflict(int id, out string ownerName) + { + var range = FindRangeByID(id); + if (range != null) + { + ownerName = range.OwnerName; + return true; + } + ownerName = null; + return false; + } + + /// + /// 获取预警的ID段(使用率>80%) + /// + public List GetWarningRanges(float threshold = 0.8f) + { + return Ranges.Where(r => r.UsageRate >= threshold).ToList(); + } + + /// + /// 获取已耗尽的ID段 + /// + public List GetExhaustedRanges() + { + return Ranges.Where(r => r.IsExhausted).ToList(); + } + + /// + /// 建议的ID段分配方案 + /// + public static List GetRecommendedRanges() + { + return new List + { + new IDRange { OwnerName = "系统保留", StartID = 100000, EndID = 199999, + Description = "通用组合节点、系统节点" }, + new IDRange { OwnerName = "策划A", StartID = 200000, EndID = 299999, + Description = "策划A专用ID段" }, + new IDRange { OwnerName = "策划B", StartID = 300000, EndID = 399999, + Description = "策划B专用ID段" }, + new IDRange { OwnerName = "策划C", StartID = 400000, EndID = 499999, + Description = "策划C专用ID段" }, + }; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Config/IDRangeConfig.cs.meta b/Assets/BP_Scripts/GameplayEditor/Config/IDRangeConfig.cs.meta new file mode 100644 index 0000000..f97f98d --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/IDRangeConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5a86d9dee8c7946448dc913e62bdb508 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Config/LutConfigDatabase.cs b/Assets/BP_Scripts/GameplayEditor/Config/LutConfigDatabase.cs new file mode 100644 index 0000000..c35dbcd --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/LutConfigDatabase.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace GameplayEditor.Config +{ + /// + /// LUT配置数据库 + /// 统一管理所有LUT配置 + /// + [CreateAssetMenu(fileName = "LutDatabase", menuName = "Gameplay/Lut/Lut Database")] + public class LutConfigDatabase : ScriptableObject + { + [Tooltip("UI LUT配置")] + public List UiLuts = new List(); + + [Tooltip("相机LUT配置")] + public List CameraLuts = new List(); + + [Tooltip("特效LUT配置")] + public List FxLuts = new List(); + + [Tooltip("地图信息LUT配置")] + public List MapInfoLuts = new List(); + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Config/LutConfigDatabase.cs.meta b/Assets/BP_Scripts/GameplayEditor/Config/LutConfigDatabase.cs.meta new file mode 100644 index 0000000..8665bcf --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/LutConfigDatabase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 35a481d5fec3d494dbdeabf3f5f331af +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Config/LutConfigs.cs b/Assets/BP_Scripts/GameplayEditor/Config/LutConfigs.cs new file mode 100644 index 0000000..2c250d2 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/LutConfigs.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace GameplayEditor.Config +{ + /// + /// 通用资源映射基类 + /// + [Serializable] + public class ResourceMapping + { + [Tooltip("关卡ID")] + public int StageID; + + [Tooltip("映射ID")] + public int MappingID; + + [Tooltip("Prefab路径")] + public string PrefabPath; + + [Tooltip("运行时加载的Prefab(自动填充)")] + public GameObject LoadedPrefab; + + /// + /// 验证路径是否有效 + /// + public bool ValidatePath() + { + if (string.IsNullOrWhiteSpace(PrefabPath)) + return false; + + #if UNITY_EDITOR + return UnityEditor.AssetDatabase.LoadAssetAtPath(PrefabPath) != null; + #else + return true; + #endif + } + + /// + /// 应用默认值 + /// + public virtual void ApplyDefaults() + { + // 基类默认值已在创建时设置 + } + } + + /// + /// 相机详细映射配置 + /// 包含Cinemachine相机参数 + /// + [Serializable] + public class CameraMapping : ResourceMapping + { + [Header("相机参数")] + [Tooltip("视场角(FOV),范围1-179")] + [Range(1, 179)] + public float FOV = 60f; + + [Tooltip("相机优先级,高优先级相机会覆盖低优先级")] + public int Priority = 10; + + [Header("跟随设置")] + [Tooltip("跟随目标偏移")] + public Vector3 FollowOffset = new Vector3(0, 5, -10); + + [Tooltip("跟随目标的最小距离")] + public float FollowMinDistance = 0.1f; + + [Tooltip("跟随目标的最大距离")] + public float FollowMaxDistance = 100f; + + [Header("注视设置")] + [Tooltip("注视目标偏移")] + public Vector3 LookAtOffset = Vector3.zero; + + [Tooltip("注视点的偏移高度")] + public float LookAtHeight = 1.5f; + + [Header("过渡设置")] + [Tooltip("过渡时间(秒),0表示立即切换")] + public float BlendTime = 0.5f; + + [Tooltip("过渡样式")] + public CinemachineBlendStyle BlendStyle = CinemachineBlendStyle.EaseInOut; + + [Header("边界设置")] + [Tooltip("是否启用软边界")] + public bool UseSoftZone = true; + + [Tooltip("死区宽度")] + [Range(0, 1)] + public float DeadZoneWidth = 0.1f; + + [Tooltip("死区高度")] + [Range(0, 1)] + public float DeadZoneHeight = 0.1f; + + [Header("震动设置")] + [Tooltip("默认震动幅度")] + public float DefaultShakeAmplitude = 1f; + + [Tooltip("默认震动频率")] + public float DefaultShakeFrequency = 1f; + + [Tooltip("默认震动持续时间")] + public float DefaultShakeDuration = 0.5f; + + [Header("其他")] + [Tooltip("相机备注")] + public string CameraDescription = ""; + + /// + /// 应用默认值 + /// + public override void ApplyDefaults() + { + base.ApplyDefaults(); + var defaultConfig = GameplayDefaultConfig.Instance; + + if (FOV <= 0 || FOV >= 180) + FOV = defaultConfig.DefaultCameraFOV; + + if (Priority <= 0) + Priority = defaultConfig.DefaultCameraPriority; + + if (BlendTime < 0) + BlendTime = defaultConfig.DefaultCameraBlendTime; + } + + /// + /// 验证相机配置 + /// + public bool Validate(out string errorMessage) + { + if (MappingID <= 0) + { + errorMessage = "Camera MappingID必须大于0"; + return false; + } + + if (FOV <= 0 || FOV >= 180) + { + errorMessage = $"FOV {FOV} 超出有效范围(0, 180)"; + return false; + } + + if (Priority < 0) + { + errorMessage = "Priority不能为负数"; + return false; + } + + errorMessage = null; + return true; + } + } + + /// + /// 特效生命周期映射配置 + /// + [Serializable] + public class FXMapping : ResourceMapping + { + [Header("生命周期")] + [Tooltip("持续时间(秒),0表示使用粒子系统默认时长")] + public float Duration = 2f; + + [Tooltip("延迟时间(秒),延迟后开始播放")] + public float Delay = 0f; + + [Tooltip("是否循环播放")] + public bool Loop = false; + + [Tooltip("是否自动销毁")] + public bool AutoDestroy = true; + + [Tooltip("销毁前的等待时间(秒)")] + public float DestroyDelay = 0.5f; + + [Header("播放控制")] + [Tooltip("播放速度倍率")] + [Range(0.1f, 5f)] + public float Speed = 1f; + + [Tooltip("缩放倍率")] + [Range(0.1f, 10f)] + public float Scale = 1f; + + [Tooltip("透明度,1为不透明")] + [Range(0f, 1f)] + public float Alpha = 1f; + + [Header("颜色控制")] + [Tooltip("是否启用颜色覆盖")] + public bool OverrideColor = false; + + [Tooltip("覆盖颜色")] + public Color OverrideTint = Color.white; + + [Header("对象池")] + [Tooltip("是否使用对象池")] + public bool UseObjectPool = true; + + [Tooltip("对象池初始大小")] + public int PoolInitialSize = 5; + + [Tooltip("对象池最大大小")] + public int PoolMaxSize = 20; + + [Header("空间设置")] + [Tooltip("是否跟随目标")] + public bool AttachToTarget = false; + + [Tooltip("跟随目标时的本地位置偏移")] + public Vector3 LocalPositionOffset = Vector3.zero; + + [Tooltip("跟随目标时的本地旋转偏移")] + public Vector3 LocalRotationOffset = Vector3.zero; + + [Header("层级设置")] + [Tooltip("排序层级")] + public int SortingLayer = 0; + + [Tooltip("排序序号")] + public int SortingOrder = 0; + + [Header("其他")] + [Tooltip("特效备注")] + public string FXDescription = ""; + + /// + /// 应用默认值 + /// + public override void ApplyDefaults() + { + base.ApplyDefaults(); + var defaultConfig = GameplayDefaultConfig.Instance; + + if (Duration <= 0) + Duration = defaultConfig.DefaultFXDuration; + + if (Speed <= 0) + Speed = defaultConfig.DefaultFXSpeed; + + if (Scale <= 0) + Scale = defaultConfig.DefaultFXScale; + } + + /// + /// 验证特效配置 + /// + public bool Validate(out string errorMessage) + { + if (MappingID <= 0) + { + errorMessage = "FX MappingID必须大于0"; + return false; + } + + if (Duration < 0) + { + errorMessage = "Duration不能为负数"; + return false; + } + + if (Speed <= 0) + { + errorMessage = "Speed必须大于0"; + return false; + } + + if (Scale <= 0) + { + errorMessage = "Scale必须大于0"; + return false; + } + + errorMessage = null; + return true; + } + } + + /// + /// 地图点位 + /// + [Serializable] + public class MapPoint + { + [Tooltip("点位ID")] + public string PointID; + + [Tooltip("坐标")] + public Vector3 Position; + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Config/LutConfigs.cs.meta b/Assets/BP_Scripts/GameplayEditor/Config/LutConfigs.cs.meta new file mode 100644 index 0000000..7432f3c --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/LutConfigs.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9b0d4ae0d9ea94c47ac1dd82536d7126 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Config/RichTextConfig.cs b/Assets/BP_Scripts/GameplayEditor/Config/RichTextConfig.cs new file mode 100644 index 0000000..6e28285 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/RichTextConfig.cs @@ -0,0 +1,426 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace GameplayEditor.Config +{ + /// + /// 富文本标签类型枚举 + /// + public enum RichTextTag + { + Bold, // 粗体 + Italic, // 斜体 + Color, // 颜色 + Size, // 字号 + Material, // 材质 + Quad, // 图片 + Gradient, // 渐变 + Underline, // 下划线 + Strikethrough, // 删除线 + Superscript, // 上标 + Subscript, // 下标 + Mark, // 标记高亮 + Link, // 超链接 + Font, // 字体 + LineBreak, // 换行
+ Space, // 空格 + Align, // 对齐 + Cspace, // 字符间距 + LineHeight, // 行高 + Pos, // 位置偏移 + Voffset, // 垂直偏移 + NoBreak // 不换行 + } + + /// + /// 富文本标签定义 + /// + [Serializable] + public class RichTextTagDefinition + { + [Tooltip("标签类型")] + public RichTextTag TagType; + + [Tooltip("标签名称(如b、color等)")] + public string TagName; + + [Tooltip("是否支持属性")] + public bool SupportsAttribute; + + [Tooltip("属性名称(如color=#ff0000中的color)")] + public string AttributeName; + + [Tooltip("属性格式示例")] + public string AttributeExample; + + [Tooltip("是否允许嵌套")] + public bool AllowNesting; + + [Tooltip("最大嵌套深度(0表示不允许嵌套)")] + public int MaxNestingDepth; + + [Tooltip("是否需要闭合标签")] + public bool RequireClosingTag; + + [Tooltip("标签描述")] + [TextArea(2, 3)] + public string Description; + + [Tooltip("是否启用此标签")] + public bool Enabled = true; + + public RichTextTagDefinition() + { + } + + public RichTextTagDefinition(RichTextTag tagType, string tagName, bool supportsAttribute = false, + string attributeName = "", string attributeExample = "", bool allowNesting = true, + int maxNestingDepth = 3, bool requireClosingTag = true, string description = "") + { + TagType = tagType; + TagName = tagName; + SupportsAttribute = supportsAttribute; + AttributeName = attributeName; + AttributeExample = attributeExample; + AllowNesting = allowNesting; + MaxNestingDepth = maxNestingDepth; + RequireClosingTag = requireClosingTag; + Description = description; + Enabled = true; + } + } + + /// + /// 颜色预设 + /// + [Serializable] + public class RichTextColorPreset + { + [Tooltip("颜色名称")] + public string ColorName; + + [Tooltip("颜色值")] + public Color Color; + + [Tooltip("颜色代码(如#FF0000)")] + public string ColorCode; + + public RichTextColorPreset(string name, Color color, string code) + { + ColorName = name; + Color = color; + ColorCode = code; + } + } + + /// + /// 字号预设 + /// + [Serializable] + public class RichTextSizePreset + { + [Tooltip("尺寸名称")] + public string SizeName; + + [Tooltip("尺寸值")] + public int SizeValue; + + public RichTextSizePreset(string name, int value) + { + SizeName = name; + SizeValue = value; + } + } + + /// + /// 富文本配置 + /// 定义项目支持的富文本标签和规范 + /// + [CreateAssetMenu(fileName = "RichTextConfig", menuName = "Gameplay/Rich Text Config")] + public class RichTextConfig : ScriptableObject + { + [Header("全局设置")] + [Tooltip("是否启用富文本")] + public bool EnableRichText = true; + + [Tooltip("最大嵌套深度")] + [Range(1, 10)] + public int MaxNestingDepth = 3; + + [Tooltip("最大标签数量(防止性能问题)")] + [Range(10, 500)] + public int MaxTagCount = 100; + + [Tooltip("验证时是否区分大小写")] + public bool CaseSensitive = false; + + [Header("支持的标签")] + [Tooltip("启用的富文本标签列表")] + public List SupportedTags = new List(); + + [Header("颜色预设")] + [Tooltip("预定义的颜色列表")] + public List ColorPresets = new List(); + + [Header("字号预设")] + [Tooltip("预定义的字号列表")] + public List SizePresets = new List(); + + [Header("验证规则")] + [Tooltip("非法标签处理方式")] + public InvalidTagHandling InvalidTagHandling = InvalidTagHandling.Strip; + + [Tooltip("是否自动修复常见的标签错误")] + public bool AutoFixCommonErrors = true; + + [Tooltip("是否记录验证日志")] + public bool LogValidation = false; + + // 缓存字典 + [NonSerialized] private Dictionary _tagCache; + [NonSerialized] private Dictionary _colorCache; + [NonSerialized] private Dictionary _sizeCache; + + private void OnEnable() + { + BuildCache(); + } + + /// + /// 初始化默认配置 + /// + public void InitializeDefaults() + { + SupportedTags.Clear(); + + // 添加默认支持的标签 + SupportedTags.Add(new RichTextTagDefinition( + RichTextTag.Bold, "b", false, "", "", true, 5, true, "粗体文本")); + + SupportedTags.Add(new RichTextTagDefinition( + RichTextTag.Italic, "i", false, "", "", true, 5, true, "斜体文本")); + + SupportedTags.Add(new RichTextTagDefinition( + RichTextTag.Color, "color", true, "color", "#FF0000 或 red", false, 1, true, "文本颜色")); + + SupportedTags.Add(new RichTextTagDefinition( + RichTextTag.Size, "size", true, "size", "24 或 +2", false, 1, true, "字号大小")); + + SupportedTags.Add(new RichTextTagDefinition( + RichTextTag.Underline, "u", false, "", "", true, 3, true, "下划线")); + + SupportedTags.Add(new RichTextTagDefinition( + RichTextTag.Strikethrough, "s", false, "", "", true, 3, true, "删除线")); + + SupportedTags.Add(new RichTextTagDefinition( + RichTextTag.Superscript, "sup", false, "", "", false, 1, true, "上标")); + + SupportedTags.Add(new RichTextTagDefinition( + RichTextTag.Subscript, "sub", false, "", "", false, 1, true, "下标")); + + SupportedTags.Add(new RichTextTagDefinition( + RichTextTag.Mark, "mark", true, "color", "#FFFF00", false, 1, true, "高亮标记")); + + SupportedTags.Add(new RichTextTagDefinition( + RichTextTag.Link, "a", true, "href", "https://example.com", false, 1, true, "超链接")); + + SupportedTags.Add(new RichTextTagDefinition( + RichTextTag.LineBreak, "br", false, "", "", false, 0, false, "换行")); + + SupportedTags.Add(new RichTextTagDefinition( + RichTextTag.NoBreak, "nobr", false, "", "", true, 1, true, "不换行区域")); + + // 初始化颜色预设 + ColorPresets.Clear(); + ColorPresets.Add(new RichTextColorPreset("red", Color.red, "#FF0000")); + ColorPresets.Add(new RichTextColorPreset("green", Color.green, "#00FF00")); + ColorPresets.Add(new RichTextColorPreset("blue", Color.blue, "#0000FF")); + ColorPresets.Add(new RichTextColorPreset("yellow", Color.yellow, "#FFFF00")); + ColorPresets.Add(new RichTextColorPreset("white", Color.white, "#FFFFFF")); + ColorPresets.Add(new RichTextColorPreset("black", Color.black, "#000000")); + ColorPresets.Add(new RichTextColorPreset("cyan", Color.cyan, "#00FFFF")); + ColorPresets.Add(new RichTextColorPreset("magenta", Color.magenta, "#FF00FF")); + ColorPresets.Add(new RichTextColorPreset("orange", new Color(1f, 0.5f, 0f), "#FF8000")); + ColorPresets.Add(new RichTextColorPreset("gray", Color.gray, "#808080")); + + // 初始化字号预设 + SizePresets.Clear(); + SizePresets.Add(new RichTextSizePreset("small", 12)); + SizePresets.Add(new RichTextSizePreset("normal", 16)); + SizePresets.Add(new RichTextSizePreset("medium", 20)); + SizePresets.Add(new RichTextSizePreset("large", 24)); + SizePresets.Add(new RichTextSizePreset("huge", 32)); + + BuildCache(); + } + + /// + /// 构建缓存 + /// + private void BuildCache() + { + _tagCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var tag in SupportedTags) + { + if (tag.Enabled && !_tagCache.ContainsKey(tag.TagName)) + { + _tagCache[tag.TagName] = tag; + } + } + + _colorCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var color in ColorPresets) + { + if (!_colorCache.ContainsKey(color.ColorName)) + { + _colorCache[color.ColorName] = color; + } + if (!_colorCache.ContainsKey(color.ColorCode)) + { + _colorCache[color.ColorCode] = color; + } + } + + _sizeCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var size in SizePresets) + { + if (!_sizeCache.ContainsKey(size.SizeName)) + { + _sizeCache[size.SizeName] = size; + } + _sizeCache[size.SizeValue.ToString()] = size; + } + } + + /// + /// 检查标签是否支持 + /// + public bool IsTagSupported(string tagName) + { + if (_tagCache == null) BuildCache(); + return _tagCache.ContainsKey(tagName); + } + + /// + /// 获取标签定义 + /// + public RichTextTagDefinition GetTagDefinition(string tagName) + { + if (_tagCache == null) BuildCache(); + _tagCache.TryGetValue(tagName, out var definition); + return definition; + } + + /// + /// 获取标签定义(通过类型) + /// + public RichTextTagDefinition GetTagDefinition(RichTextTag tagType) + { + return SupportedTags.Find(t => t.TagType == tagType && t.Enabled); + } + + /// + /// 检查颜色是否有效 + /// + public bool IsValidColor(string colorValue) + { + if (_colorCache == null) BuildCache(); + + // 检查预设 + if (_colorCache.ContainsKey(colorValue)) + return true; + + // 检查十六进制格式 + if (colorValue.StartsWith("#") && (colorValue.Length == 7 || colorValue.Length == 9)) + { + return System.Text.RegularExpressions.Regex.IsMatch(colorValue, "^#[0-9A-Fa-f]{6}$") || + System.Text.RegularExpressions.Regex.IsMatch(colorValue, "^#[0-9A-Fa-f]{8}$"); + } + + return false; + } + + /// + /// 获取颜色值 + /// + public Color GetColor(string colorValue) + { + if (_colorCache == null) BuildCache(); + + if (_colorCache.TryGetValue(colorValue, out var preset)) + return preset.Color; + + // 解析十六进制 + if (ColorUtility.TryParseHtmlString(colorValue, out var color)) + return color; + + return Color.white; + } + + /// + /// 检查字号是否有效 + /// + public bool IsValidSize(string sizeValue) + { + if (_sizeCache == null) BuildCache(); + + // 检查预设 + if (_sizeCache.ContainsKey(sizeValue)) + return true; + + // 检查数字 + if (int.TryParse(sizeValue, out var size)) + return size > 0 && size <= 200; + + // 检查相对值(如+2、-3) + if (sizeValue.StartsWith("+") || sizeValue.StartsWith("-")) + { + return int.TryParse(sizeValue, out _); + } + + return false; + } + + /// + /// 获取所有支持的标签名称 + /// + public List GetSupportedTagNames() + { + if (_tagCache == null) BuildCache(); + return new List(_tagCache.Keys); + } + + /// + /// 启用/禁用标签 + /// + public void SetTagEnabled(RichTextTag tagType, bool enabled) + { + var tag = SupportedTags.Find(t => t.TagType == tagType); + if (tag != null) + { + tag.Enabled = enabled; + BuildCache(); + } + } + + /// + /// 创建验证器 + /// + public Utils.RichTextValidator CreateValidator() + { + return new Utils.RichTextValidator(this); + } + } + + /// + /// 非法标签处理方式 + /// + public enum InvalidTagHandling + { + Strip, // 移除非法标签 + Escape, // 转义为普通文本 + Warning, // 保留但警告 + Error // 报错 + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Config/RichTextConfig.cs.meta b/Assets/BP_Scripts/GameplayEditor/Config/RichTextConfig.cs.meta new file mode 100644 index 0000000..6abdf7d --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Config/RichTextConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 34f0a47fe2e1c4d4795b5bc22032d96c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Core.meta b/Assets/BP_Scripts/GameplayEditor/Core.meta new file mode 100644 index 0000000..fd40b66 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a1a9b6f3bf5e07344b1a1a522f1c9b5a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Core/ActivityNodeBase.cs b/Assets/BP_Scripts/GameplayEditor/Core/ActivityNodeBase.cs new file mode 100644 index 0000000..6f06f58 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/ActivityNodeBase.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using NodeCanvas.Framework; +using ParadoxNotion; +using UnityEngine; + +namespace GameplayEditor.Core +{ + /// + /// 所有活动节点类型的抽象基类 + /// 每个子类对应一张配置表,有独立的字段结构和导出逻辑 + /// + [Serializable] + public abstract class ActivityNodeBase : Node + { + /// 表格名称,用于路由到对应 CSV 文件 + public abstract string TableName { get; } + + /// 字段名(CSV 第1行) + public abstract string[] FieldNames { get; } + + /// 字段类型(CSV 第2行) + public abstract string[] FieldTypes { get; } + + /// 字段说明(CSV 第3行) + public abstract string[] FieldDocs { get; } + + /// 将节点数据序列化为一行 CSV 数据 + public abstract Dictionary ToExcelRow(); + + /// 从 CSV 一行数据反序列化到节点字段 + public abstract void FromExcelRow(Dictionary data); + + // 所有子类共用的 Node 抽象属性 + public override int maxInConnections => -1; + public override int maxOutConnections => -1; + public override Type outConnectionType => typeof(GameplayConnection); + public override bool allowAsPrime => true; + public override bool canSelfConnect => false; + public override Alignment2x2 commentsAlignment => Alignment2x2.Bottom; + public override Alignment2x2 iconAlignment => Alignment2x2.Default; + + protected override Status OnExecute(Component agent, IBlackboard blackboard) + => Status.Success; + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Core/ActivityNodeBase.cs.meta b/Assets/BP_Scripts/GameplayEditor/Core/ActivityNodeBase.cs.meta new file mode 100644 index 0000000..51a0940 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/ActivityNodeBase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7d04cbc7dec380c4a80a1cf00bcab601 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Core/BTDebugger.cs b/Assets/BP_Scripts/GameplayEditor/Core/BTDebugger.cs new file mode 100644 index 0000000..18a595d --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/BTDebugger.cs @@ -0,0 +1,627 @@ +using System.Collections.Generic; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using UnityEngine; + +namespace GameplayEditor.Core +{ + /// + /// 行为树调试器 + /// 支持断点、单步执行、变量查看 + /// + public class BTDebugger : MonoBehaviour + { + [Header("调试设置")] + [Tooltip("启用调试")] + public bool EnableDebug = true; + + [Tooltip("自动断点(异常时)")] + public bool AutoBreakOnError = true; + + // 当前调试的行为树 + private BehaviourTree _targetTree; + private BehaviourTreeOwner _treeOwner; + + // 断点列表 + private HashSet _breakpoints = new HashSet(); + private Dictionary _conditionBreakpoints = new Dictionary(); + + // 调试状态 + private bool _isPaused = false; + // private bool _stepMode = false; // 暂不使用 + private Node _currentNode; + private string _lastStatus; + + // 执行历史 + private Queue _executionHistory = new Queue(100); + + // 节点状态缓存 + private Dictionary _nodeDebugInfo = new Dictionary(); + + // 调试事件 + public System.Action OnBreakpointHit; + public System.Action OnNodeExecuted; + public System.Action OnDebugPaused; + public System.Action OnDebugResumed; + + // 执行记录 + public class ExecutionRecord + { + public float Time; + public string NodeName; + public string NodeType; + public Status Result; + public string Message; + } + + // 节点调试信息 + public class NodeDebugInfo + { + public string NodeId; + public string NodeName; + public int ExecutionCount; + public Status LastStatus; + public float LastExecutionTime; + public bool IsBreakpoint; + public string Condition; + } + + private static BTDebugger _instance; + public static BTDebugger Instance + { + get + { + if (_instance == null) + { + _instance = FindObjectOfType(); + } + return _instance; + } + } + + private void Awake() + { + if (_instance != null && _instance != this) + { + Destroy(gameObject); + return; + } + _instance = this; + } + + #region 目标设置 + + /// + /// 设置调试目标 + /// + public void SetTarget(BehaviourTree tree) + { + _targetTree = tree; + _nodeDebugInfo.Clear(); + + // 扫描所有节点 + if (tree?.primeNode != null) + { + ScanNode(tree.primeNode); + } + + Debug.Log($"[BTDebugger] 设置调试目标: {tree?.name}"); + } + + /// + /// 递归扫描节点 + /// + private void ScanNode(Node node) + { + if (node == null) return; + + string nodeId = GetNodeId(node); + if (!_nodeDebugInfo.ContainsKey(nodeId)) + { + _nodeDebugInfo[nodeId] = new NodeDebugInfo + { + NodeId = nodeId, + NodeName = node.name, + IsBreakpoint = _breakpoints.Contains(nodeId) + }; + } + + // 递归扫描子节点 + if (node.outConnections != null) + { + foreach (var conn in node.outConnections) + { + ScanNode(conn.targetNode); + } + } + } + + #endregion + + #region 断点管理 + + /// + /// 添加断点 + /// + public void AddBreakpoint(Node node) + { + string nodeId = GetNodeId(node); + _breakpoints.Add(nodeId); + + if (_nodeDebugInfo.TryGetValue(nodeId, out var info)) + { + info.IsBreakpoint = true; + } + } + + /// + /// 移除断点 + /// + public void RemoveBreakpoint(Node node) + { + string nodeId = GetNodeId(node); + _breakpoints.Remove(nodeId); + _conditionBreakpoints.Remove(nodeId); + + if (_nodeDebugInfo.TryGetValue(nodeId, out var info)) + { + info.IsBreakpoint = false; + info.Condition = null; + } + } + + /// + /// 添加条件断点 + /// + public void AddConditionBreakpoint(Node node, string condition) + { + string nodeId = GetNodeId(node); + _breakpoints.Add(nodeId); + _conditionBreakpoints[nodeId] = condition; + + if (_nodeDebugInfo.TryGetValue(nodeId, out var info)) + { + info.IsBreakpoint = true; + info.Condition = condition; + } + } + + /// + /// 清除所有断点 + /// + public void ClearBreakpoints() + { + _breakpoints.Clear(); + _conditionBreakpoints.Clear(); + + foreach (var info in _nodeDebugInfo.Values) + { + info.IsBreakpoint = false; + info.Condition = null; + } + } + + /// + /// 检查是否是断点 + /// + public bool IsBreakpoint(Node node) + { + return _breakpoints.Contains(GetNodeId(node)); + } + + #endregion + + #region 执行控制 + + /// + /// 暂停调试 + /// + public void Pause() + { + _isPaused = true; + OnDebugPaused?.Invoke(); + Debug.Log("[BTDebugger] 调试暂停"); + } + + /// + /// 继续执行 + /// + public void Resume() + { + _isPaused = false; + // _stepMode = false; + OnDebugResumed?.Invoke(); + Debug.Log("[BTDebugger] 调试继续"); + } + + /// + /// 单步执行 + /// + public void Step() + { + _isPaused = false; + // _stepMode = true; + + // 执行一帧后暂停 + StartCoroutine(StepCoroutine()); + } + + private System.Collections.IEnumerator StepCoroutine() + { + yield return null; // 等待一帧 + Pause(); + } + + /// + /// 检查是否可以执行 + /// + public bool CanExecute(Node node) + { + if (!EnableDebug) + return true; + + // 检查是否暂停 + if (_isPaused) + return false; + + // 检查断点 + string nodeId = GetNodeId(node); + if (_breakpoints.Contains(nodeId)) + { + // 检查条件 + if (_conditionBreakpoints.TryGetValue(nodeId, out var condition)) + { + if (EvaluateCondition(condition, node)) + { + HitBreakpoint(node); + return false; + } + } + else + { + HitBreakpoint(node); + return false; + } + } + + return true; + } + + /// + /// 触发断点 + /// + private void HitBreakpoint(Node node) + { + _currentNode = node; + _isPaused = true; + + Debug.Log($"[BTDebugger] 触发断点: {node.name}"); + OnBreakpointHit?.Invoke(node); + } + + /// + /// 评估条件 + /// 支持黑板变量、比较运算符、逻辑运算符 + /// + private bool EvaluateCondition(string condition, Node node) + { + if (string.IsNullOrWhiteSpace(condition)) + return true; + + try + { + var blackboard = GetBlackboard(node); + var evaluator = new ConditionEvaluator(blackboard); + return evaluator.Evaluate(condition); + } + catch (System.Exception ex) + { + Debug.LogWarning($"[BTDebugger] 条件求值错误 '{condition}': {ex.Message}"); + return true; // 求值失败时默认触发断点 + } + } + + /// + /// 获取节点关联的黑板 + /// + private IBlackboard GetBlackboard(Node node) + { + if (node?.graph is BehaviourTree bt) + return bt.blackboard; + return _targetTree?.blackboard; + } + + /// + /// 轻量级条件表达式求值器 + /// + private class ConditionEvaluator + { + private IBlackboard _blackboard; + + public ConditionEvaluator(IBlackboard blackboard) + { + _blackboard = blackboard; + } + + public bool Evaluate(string expression) + { + expression = expression.Trim(); + + // 处理括号(仅支持最外层一对括号) + if (expression.StartsWith("(") && expression.EndsWith(")")) + { + expression = expression.Substring(1, expression.Length - 2).Trim(); + } + + // 处理 || + var orParts = SplitTopLevel(expression, "||"); + if (orParts.Count > 1) + { + foreach (var part in orParts) + { + if (Evaluate(part.Trim())) return true; + } + return false; + } + + // 处理 && + var andParts = SplitTopLevel(expression, "&&"); + if (andParts.Count > 1) + { + foreach (var part in andParts) + { + if (!Evaluate(part.Trim())) return false; + } + return true; + } + + // 处理 ! + if (expression.StartsWith("!")) + { + return !Evaluate(expression.Substring(1).Trim()); + } + + // 处理比较 + return EvaluateComparison(expression); + } + + private bool EvaluateComparison(string expression) + { + var ops = new[] { "==", "!=", "<=", ">=", "<", ">" }; + foreach (var op in ops) + { + int idx = FindTopLevelOperator(expression, op); + if (idx >= 0) + { + var left = expression.Substring(0, idx).Trim(); + var right = expression.Substring(idx + op.Length).Trim(); + return Compare(left, op, right); + } + } + + // 没有比较运算符,视为布尔值 + return ToBool(ResolveValue(expression)); + } + + private bool Compare(string leftExpr, string op, string rightExpr) + { + var left = ResolveValue(leftExpr); + var right = ResolveValue(rightExpr); + + // 尝试数值比较 + if (double.TryParse(left, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var leftNum) && + double.TryParse(right, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var rightNum)) + { + return op switch + { + "==" => Mathf.Approximately((float)leftNum, (float)rightNum), + "!=" => !Mathf.Approximately((float)leftNum, (float)rightNum), + "<" => leftNum < rightNum, + "<=" => leftNum <= rightNum, + ">" => leftNum > rightNum, + ">=" => leftNum >= rightNum, + _ => false + }; + } + + // 字符串/其他比较 + return op switch + { + "==" => string.Equals(left, right, System.StringComparison.OrdinalIgnoreCase), + "!=" => !string.Equals(left, right, System.StringComparison.OrdinalIgnoreCase), + _ => false + }; + } + + private string ResolveValue(string expr) + { + expr = expr.Trim(); + + // 字符串字面量 + if ((expr.StartsWith("\"") && expr.EndsWith("\"")) || + (expr.StartsWith("'") && expr.EndsWith("'"))) + { + return expr.Substring(1, expr.Length - 2); + } + + // 布尔字面量 + if (bool.TryParse(expr, out _)) return expr; + + // 数字字面量 + if (double.TryParse(expr, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out _)) return expr; + + // 尝试从黑板获取变量 + if (_blackboard != null && _blackboard.variables != null && + _blackboard.variables.ContainsKey(expr)) + { + var value = _blackboard.GetVariableValue(expr); + return value?.ToString() ?? ""; + } + + return expr; + } + + private bool ToBool(string value) + { + if (bool.TryParse(value, out var b)) return b; + if (int.TryParse(value, out var i)) return i != 0; + if (double.TryParse(value, System.Globalization.NumberStyles.Any, + System.Globalization.CultureInfo.InvariantCulture, out var d)) return d != 0; + return !string.IsNullOrEmpty(value); + } + + private int FindTopLevelOperator(string expression, string op) + { + int depth = 0; + for (int i = 0; i <= expression.Length - op.Length; i++) + { + if (expression[i] == '(') depth++; + else if (expression[i] == ')') depth--; + else if (depth == 0 && expression.Substring(i, op.Length) == op) + { + // 排除 == 被识别为 = + if (op == "=" && i + 1 < expression.Length && expression[i + 1] == '=') + continue; + return i; + } + } + return -1; + } + + private List SplitTopLevel(string expression, string separator) + { + var result = new List(); + int depth = 0; + int start = 0; + for (int i = 0; i <= expression.Length - separator.Length; i++) + { + if (expression[i] == '(') depth++; + else if (expression[i] == ')') depth--; + else if (depth == 0 && expression.Substring(i, separator.Length) == separator) + { + result.Add(expression.Substring(start, i - start).Trim()); + start = i + separator.Length; + i = start - 1; + } + } + result.Add(expression.Substring(start).Trim()); + return result; + } + } + + #endregion + + #region 节点执行记录 + + /// + /// 记录节点执行 + /// + public void RecordNodeExecution(Node node, Status result) + { + if (!EnableDebug) return; + + string nodeId = GetNodeId(node); + + // 更新节点信息 + if (_nodeDebugInfo.TryGetValue(nodeId, out var info)) + { + info.ExecutionCount++; + info.LastStatus = result; + info.LastExecutionTime = Time.time; + } + + // 添加到历史 + var record = new ExecutionRecord + { + Time = Time.time, + NodeName = node.name, + NodeType = node.GetType().Name, + Result = result + }; + + _executionHistory.Enqueue(record); + if (_executionHistory.Count > 100) + { + _executionHistory.Dequeue(); + } + + _currentNode = node; + OnNodeExecuted?.Invoke(node); + } + + /// + /// 记录节点状态 + /// + public void SetNodeStatus(Node node, Status status) + { + string nodeId = GetNodeId(node); + if (_nodeDebugInfo.TryGetValue(nodeId, out var info)) + { + info.LastStatus = status; + } + } + + #endregion + + #region 查询方法 + + /// + /// 获取节点调试信息 + /// + public NodeDebugInfo GetNodeDebugInfo(Node node) + { + string nodeId = GetNodeId(node); + _nodeDebugInfo.TryGetValue(nodeId, out var info); + return info; + } + + /// + /// 获取所有节点信息 + /// + public IReadOnlyDictionary GetAllNodeInfo() + { + return _nodeDebugInfo; + } + + /// + /// 获取执行历史 + /// + public IEnumerable GetExecutionHistory() + { + return _executionHistory; + } + + /// + /// 获取当前执行节点 + /// + public Node GetCurrentNode() + { + return _currentNode; + } + + /// + /// 是否暂停 + /// + public bool IsPaused() + { + return _isPaused; + } + + #endregion + + #region 私有方法 + + /// + /// 获取节点唯一ID + /// + private string GetNodeId(Node node) + { + return $"{node.GetType().Name}_{node.name}_{node.GetHashCode()}"; + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Core/BTDebugger.cs.meta b/Assets/BP_Scripts/GameplayEditor/Core/BTDebugger.cs.meta new file mode 100644 index 0000000..64f090b --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/BTDebugger.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 83b34dc6a64ecc647b92d95abe8b7d51 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Core/BTPerformanceMonitor.cs b/Assets/BP_Scripts/GameplayEditor/Core/BTPerformanceMonitor.cs new file mode 100644 index 0000000..ab0d366 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/BTPerformanceMonitor.cs @@ -0,0 +1,565 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using NodeCanvas.Framework; +using UnityEngine; +using Debug = UnityEngine.Debug; + +namespace GameplayEditor.Core +{ + /// + /// 行为树性能监控器 + /// 监控节点执行频率、耗时,检测性能问题 + /// + public class BTPerformanceMonitor : MonoBehaviour + { + [Header("监控设置")] + [Tooltip("启用监控")] + public bool EnableMonitoring = true; + + [Tooltip("每帧执行警告阈值(同一节点)")] + public int FrameExecutionThreshold = 60; + + [Tooltip("节点执行耗时警告阈值(毫秒)")] + public float ExecutionTimeThresholdMs = 5f; + + [Tooltip("高频操作警告阈值")] + public int HighFrequencyOperationThreshold = 30; + + [Header("检测设置")] + [Tooltip("检测SetPosition/SetRotation等高消耗操作")] + public bool DetectExpensiveOperations = true; + + [Tooltip("检测死循环")] + public bool DetectInfiniteLoops = true; + + [Tooltip("死循环检测阈值(同一节点连续执行次数)")] + public int InfiniteLoopThreshold = 1000; + + // 节点性能数据 + private Dictionary _nodePerformanceData = + new Dictionary(); + + // 当前帧执行计数 + private Dictionary _currentFrameExecutions = new Dictionary(); + + // 高频操作计数 + private Dictionary _operationCounters = + new Dictionary(); + + // 性能报告 + private PerformanceReport _currentReport; + + // 监控状态 + private bool _isRecording = false; + private float _recordingStartTime; + + /// + /// 节点性能数据 + /// + public class NodePerformanceData + { + public string NodeName; + public string NodeType; + public int TotalExecutions; + public int FrameExecutions; // 当前帧执行次数 + public float TotalExecutionTimeMs; + public float MaxExecutionTimeMs; + public float AverageExecutionTimeMs => TotalExecutions > 0 ? + TotalExecutionTimeMs / TotalExecutions : 0; + public int WarningCount; + } + + /// + /// 操作计数器 + /// + private class OperationCounter + { + public string OperationName; + public int Count; + public int FrameCount; + public float LastResetTime; + } + + /// + /// 性能报告 + /// + public class PerformanceReport + { + public float Duration; + public int TotalNodeExecutions; + public int WarningCount; + public List HotNodes = new List(); + public List Warnings = new List(); + } + + private static BTPerformanceMonitor _instance; + public static BTPerformanceMonitor Instance + { + get + { + if (_instance == null) + { + _instance = FindObjectOfType(); + if (_instance == null) + { + var go = new GameObject("BTPerformanceMonitor"); + _instance = go.AddComponent(); + DontDestroyOnLoad(go); + } + } + return _instance; + } + } + + // FPS计算 + private float _lastFrameTime; + private float _currentFPS; + + private void Awake() + { + if (_instance != null && _instance != this) + { + Destroy(gameObject); + return; + } + _instance = this; + DontDestroyOnLoad(gameObject); + _lastFrameTime = Time.time; + } + + /// + /// 获取当前FPS + /// + public float GetCurrentFPS() + { + return _currentFPS; + } + + private void Update() + { + // 计算FPS + float deltaTime = Time.time - _lastFrameTime; + if (deltaTime > 0) + { + _currentFPS = 1f / Time.unscaledDeltaTime; + } + _lastFrameTime = Time.time; + + if (!EnableMonitoring) + return; + + // 重置帧计数 + ResetFrameCounters(); + + // 检查高频操作 + CheckHighFrequencyOperations(); + + // 更新报告 + UpdateReport(); + } + + #region 监控接口 + + /// + /// 开始记录节点执行 + /// + public void BeginNodeExecution(Node node) + { + if (!EnableMonitoring || node == null) + return; + + string nodeId = GetNodeId(node); + + if (!_nodePerformanceData.TryGetValue(nodeId, out var data)) + { + data = new NodePerformanceData + { + NodeName = node.name, + NodeType = node.GetType().Name + }; + _nodePerformanceData[nodeId] = data; + } + + data.TotalExecutions++; + data.FrameExecutions++; + + // 检查高频执行 + if (data.FrameExecutions > FrameExecutionThreshold) + { + LogWarning($"节点高频执行: {node.name} 本帧已执行 {data.FrameExecutions} 次", node); + data.WarningCount++; + } + + // 检查死循环 + if (DetectInfiniteLoops && data.TotalExecutions > InfiniteLoopThreshold) + { + LogError($"检测到可能的死循环: {node.name} 已执行 {data.TotalExecutions} 次", node); + } + } + + /// + /// 开始记录任务执行(重载) + /// + public void BeginNodeExecution(Task task) + { + if (!EnableMonitoring || task == null) + return; + + string nodeId = $"Task_{task.GetType().Name}_{task.GetHashCode()}"; + + if (!_nodePerformanceData.TryGetValue(nodeId, out var data)) + { + data = new NodePerformanceData + { + NodeName = task.GetType().Name, + NodeType = task.GetType().Name + }; + _nodePerformanceData[nodeId] = data; + } + + data.TotalExecutions++; + data.FrameExecutions++; + + // 检查高频执行 + if (data.FrameExecutions > FrameExecutionThreshold) + { + LogWarning($"任务高频执行: {task.GetType().Name} 本帧已执行 {data.FrameExecutions} 次"); + data.WarningCount++; + } + + // 检查死循环 + if (DetectInfiniteLoops && data.TotalExecutions > InfiniteLoopThreshold) + { + LogError($"检测到可能的死循环: {task.GetType().Name} 已执行 {data.TotalExecutions} 次"); + } + } + + /// + /// 结束记录节点执行 + /// + public void EndNodeExecution(Node node, float executionTimeMs) + { + if (!EnableMonitoring || node == null) + return; + + string nodeId = GetNodeId(node); + + if (_nodePerformanceData.TryGetValue(nodeId, out var data)) + { + data.TotalExecutionTimeMs += executionTimeMs; + data.MaxExecutionTimeMs = Mathf.Max(data.MaxExecutionTimeMs, executionTimeMs); + + // 检查执行耗时 + if (executionTimeMs > ExecutionTimeThresholdMs) + { + LogWarning($"节点执行耗时过长: {node.name} 耗时 {executionTimeMs:F2}ms", node); + data.WarningCount++; + } + } + } + + /// + /// 结束记录任务执行(重载) + /// + public void EndNodeExecution(Task task, float executionTimeMs) + { + if (!EnableMonitoring || task == null) + return; + + string nodeId = $"Task_{task.GetType().Name}_{task.GetHashCode()}"; + + if (_nodePerformanceData.TryGetValue(nodeId, out var data)) + { + data.TotalExecutionTimeMs += executionTimeMs; + data.MaxExecutionTimeMs = Mathf.Max(data.MaxExecutionTimeMs, executionTimeMs); + + // 检查执行耗时 + if (executionTimeMs > ExecutionTimeThresholdMs) + { + LogWarning($"任务执行耗时过长: {task.GetType().Name} 耗时 {executionTimeMs:F2}ms"); + data.WarningCount++; + } + } + } + + /// + /// 记录高消耗操作 + /// + public void RecordExpensiveOperation(string operationType, string context = "") + { + if (!EnableMonitoring || !DetectExpensiveOperations) + return; + + string key = $"{operationType}_{context}"; + + if (!_operationCounters.TryGetValue(key, out var counter)) + { + counter = new OperationCounter + { + OperationName = operationType, + LastResetTime = Time.time + }; + _operationCounters[key] = counter; + } + + counter.Count++; + counter.FrameCount++; + + // 检查高频操作 + if (counter.FrameCount > HighFrequencyOperationThreshold) + { + LogWarning($"高频消耗操作: {operationType} 本帧已执行 {counter.FrameCount} 次 (Context: {context})"); + } + } + + /// + /// 记录SetPosition操作 + /// + public void RecordSetPosition(Transform transform) + { + RecordExpensiveOperation("SetPosition", transform?.name ?? "Unknown"); + } + + /// + /// 记录SetRotation操作 + /// + public void RecordSetRotation(Transform transform) + { + RecordExpensiveOperation("SetRotation", transform?.name ?? "Unknown"); + } + + /// + /// 记录SetActive操作 + /// + public void RecordSetActive(GameObject go) + { + RecordExpensiveOperation("SetActive", go?.name ?? "Unknown"); + } + + /// + /// 记录Instantiate操作 + /// + public void RecordInstantiate(string prefabName) + { + RecordExpensiveOperation("Instantiate", prefabName); + } + + #endregion + + #region 报告功能 + + /// + /// 开始记录 + /// + public void StartRecording() + { + _isRecording = true; + _recordingStartTime = Time.time; + _nodePerformanceData.Clear(); + _operationCounters.Clear(); + + Debug.Log("[BTPerformanceMonitor] 开始性能记录"); + } + + /// + /// 停止记录并生成报告 + /// + public PerformanceReport StopRecording() + { + _isRecording = false; + _currentReport = GenerateReport(); + + Debug.Log("[BTPerformanceMonitor] 停止性能记录,生成报告"); + + return _currentReport; + } + + /// + /// 生成性能报告 + /// + public PerformanceReport GenerateReport() + { + var report = new PerformanceReport + { + Duration = Time.time - _recordingStartTime, + TotalNodeExecutions = _nodePerformanceData.Values.Sum(d => d.TotalExecutions), + WarningCount = _nodePerformanceData.Values.Sum(d => d.WarningCount) + }; + + // 获取热点节点(执行次数最多或耗时最长) + report.HotNodes = _nodePerformanceData.Values + .OrderByDescending(d => d.TotalExecutions) + .Take(10) + .ToList(); + + // 生成警告列表 + foreach (var data in _nodePerformanceData.Values) + { + if (data.AverageExecutionTimeMs > ExecutionTimeThresholdMs) + { + report.Warnings.Add($"[{data.NodeName}] 平均耗时过高: {data.AverageExecutionTimeMs:F2}ms"); + } + + if (data.TotalExecutions > 1000) + { + report.Warnings.Add($"[{data.NodeName}] 执行次数过多: {data.TotalExecutions}次"); + } + } + + return report; + } + + /// + /// 获取当前报告 + /// + public PerformanceReport GetCurrentReport() + { + return _currentReport ?? GenerateReport(); + } + + /// + /// 打印报告到控制台 + /// + public void PrintReport() + { + var report = GetCurrentReport(); + + Debug.Log("========== 行为树性能报告 =========="); + Debug.Log($"记录时长: {report.Duration:F1}秒"); + Debug.Log($"总执行次数: {report.TotalNodeExecutions}"); + Debug.Log($"警告数: {report.WarningCount}"); + + Debug.Log("热点节点:"); + foreach (var node in report.HotNodes) + { + Debug.Log($" - {node.NodeName}: {node.TotalExecutions}次, " + + $"平均{node.AverageExecutionTimeMs:F2}ms, " + + $"最大{node.MaxExecutionTimeMs:F2}ms"); + } + + if (report.Warnings.Count > 0) + { + Debug.LogWarning("性能警告:"); + foreach (var warning in report.Warnings) + { + Debug.LogWarning($" - {warning}"); + } + } + + Debug.Log("===================================="); + } + + #endregion + + #region 私有方法 + + /// + /// 获取节点唯一ID + /// + private string GetNodeId(Node node) + { + return $"{node.GetType().Name}_{node.name}_{node.GetHashCode()}"; + } + + /// + /// 重置帧计数器 + /// + private void ResetFrameCounters() + { + foreach (var data in _nodePerformanceData.Values) + { + data.FrameExecutions = 0; + } + + foreach (var counter in _operationCounters.Values) + { + counter.FrameCount = 0; + } + } + + /// + /// 检查高频操作 + /// + private void CheckHighFrequencyOperations() + { + foreach (var counter in _operationCounters.Values) + { + if (counter.FrameCount > HighFrequencyOperationThreshold) + { + LogWarning($"检测到高频操作: {counter.OperationName} 本帧{counter.FrameCount}次"); + } + } + } + + /// + /// 更新报告 + /// + private void UpdateReport() + { + if (_isRecording && Time.time - _recordingStartTime > 10f) + { + // 每10秒自动更新报告 + _currentReport = GenerateReport(); + } + } + + /// + /// 记录警告 + /// + private void LogWarning(string message, Node node = null) + { + if (node != null) + { + Debug.LogWarning($"[BTPerformance] {message} (Node: {node.name})"); + } + else + { + Debug.LogWarning($"[BTPerformance] {message}"); + } + } + + /// + /// 记录错误 + /// + private void LogError(string message, Node node = null) + { + if (node != null) + { + Debug.LogError($"[BTPerformance] {message} (Node: {node.name})"); + } + else + { + Debug.LogError($"[BTPerformance] {message}"); + } + } + + #endregion + + #region 静态快捷方法 + + /// + /// 快速记录节点执行 + /// + public static void RecordNodeExecution(Node node, System.Action action) + { + if (Instance == null || !Instance.EnableMonitoring) + { + action(); + return; + } + + var stopwatch = new Stopwatch(); + stopwatch.Start(); + + Instance.BeginNodeExecution(node); + + action(); + + stopwatch.Stop(); + Instance.EndNodeExecution(node, stopwatch.ElapsedMilliseconds); + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Core/BTPerformanceMonitor.cs.meta b/Assets/BP_Scripts/GameplayEditor/Core/BTPerformanceMonitor.cs.meta new file mode 100644 index 0000000..b6d1c9e --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/BTPerformanceMonitor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 310af10b7709f194b9c7b59845f5982b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Core/BTRuntimeVisualizer.cs b/Assets/BP_Scripts/GameplayEditor/Core/BTRuntimeVisualizer.cs new file mode 100644 index 0000000..8303f09 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/BTRuntimeVisualizer.cs @@ -0,0 +1,379 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using UnityEngine; + +namespace GameplayEditor.Core +{ + /// + /// 行为树运行时可视化器 + /// 记录和展示行为树的实时执行状态 + /// + public class BTRuntimeVisualizer : MonoBehaviour + { + [Header("调试设置")] + [Tooltip("启用运行时可视化")] + public bool EnableVisualization = true; + + [Tooltip("最大历史记录数")] + public int MaxHistorySize = 100; + + [Tooltip("节点状态过期时间(秒)")] + public float NodeStatusExpireTime = 2f; + + [Tooltip("是否记录变量变化")] + public bool RecordVariableChanges = true; + + // 节点状态数据 + private Dictionary _nodeStatusMap = new Dictionary(); + + // 执行历史 + private Queue _executionHistory = new Queue(); + + // 变量历史 + private Dictionary> _variableHistory = new Dictionary>(); + + // 当前活跃的行为树 + private List _activeTrees = new List(); + + // 单例 + private static BTRuntimeVisualizer _instance; + public static BTRuntimeVisualizer Instance + { + get + { + if (_instance == null) + { + _instance = FindObjectOfType(); + if (_instance == null) + { + var go = new GameObject("BTRuntimeVisualizer"); + _instance = go.AddComponent(); + if (Application.isPlaying) + DontDestroyOnLoad(go); + } + } + return _instance; + } + } + + // 事件 + public event Action OnNodeStatusChanged; + public event Action OnExecutionRecorded; + public event Action OnVariableChanged; + + void Awake() + { + if (_instance != null && _instance != this) + { + Destroy(gameObject); + return; + } + _instance = this; + if (Application.isPlaying) + DontDestroyOnLoad(gameObject); + } + + void Update() + { + if (!EnableVisualization) + return; + + // 清理过期状态 + CleanupExpiredStatuses(); + } + + #region 节点状态记录 + + /// + /// 记录节点状态变化 + /// + public void RecordNodeStatus(Node node, Status status, BehaviourTree tree) + { + if (!EnableVisualization || node == null) + return; + + string nodeId = GetNodeUniqueId(node, tree); + string nodeName = GetNodeDisplayName(node); + + var statusData = new NodeStatusData + { + NodeId = nodeId, + NodeName = nodeName, + Status = ConvertStatus(status), + Timestamp = Time.time, + TreeName = tree?.name ?? "Unknown", + RowIndex = GetRowIndex(node, tree) + }; + + _nodeStatusMap[nodeId] = statusData; + + // 添加到执行历史 + var record = new ExecutionRecord + { + Timestamp = Time.time, + NodeName = nodeName, + NodeId = nodeId, + Result = statusData.Status, + TreeName = tree?.name, + RowIndex = statusData.RowIndex + }; + + AddToHistory(record); + + // 触发事件 + OnNodeStatusChanged?.Invoke(nodeId, statusData.Status); + OnExecutionRecorded?.Invoke(record); + } + + /// + /// 获取节点当前状态 + /// + public NodeStatusData GetNodeStatus(string nodeId) + { + if (_nodeStatusMap.TryGetValue(nodeId, out var status)) + return status; + return null; + } + + /// + /// 获取所有节点状态 + /// + public List GetAllNodeStatuses() + { + return _nodeStatusMap.Values.ToList(); + } + + /// + /// 获取指定行为树的所有节点状态 + /// + public List GetTreeNodeStatuses(string treeName) + { + return _nodeStatusMap.Values + .Where(s => s.TreeName == treeName) + .ToList(); + } + + #endregion + + #region 变量监控 + + /// + /// 记录变量变化 + /// + public void RecordVariableChange(string variableName, object value, string treeName) + { + if (!EnableVisualization || !RecordVariableChanges) + return; + + if (!_variableHistory.ContainsKey(variableName)) + { + _variableHistory[variableName] = new List(); + } + + var record = new VariableChangeRecord + { + Timestamp = Time.time, + VariableName = variableName, + Value = value?.ToString() ?? "null", + TreeName = treeName + }; + + _variableHistory[variableName].Add(record); + + // 限制历史数量 + if (_variableHistory[variableName].Count > MaxHistorySize) + { + _variableHistory[variableName].RemoveAt(0); + } + + OnVariableChanged?.Invoke(variableName, value); + } + + /// + /// 获取变量历史 + /// + public List GetVariableHistory(string variableName) + { + if (_variableHistory.TryGetValue(variableName, out var history)) + return history; + return new List(); + } + + /// + /// 获取所有监控的变量名 + /// + public List GetMonitoredVariables() + { + return _variableHistory.Keys.ToList(); + } + + #endregion + + #region 执行历史 + + /// + /// 获取执行历史 + /// + public List GetExecutionHistory() + { + return _executionHistory.ToList(); + } + + /// + /// 获取指定行为树的执行历史 + /// + public List GetTreeExecutionHistory(string treeName) + { + return _executionHistory.Where(r => r.TreeName == treeName).ToList(); + } + + /// + /// 清除历史 + /// + public void ClearHistory() + { + _executionHistory.Clear(); + _nodeStatusMap.Clear(); + _variableHistory.Clear(); + } + + private void AddToHistory(ExecutionRecord record) + { + _executionHistory.Enqueue(record); + + while (_executionHistory.Count > MaxHistorySize) + { + _executionHistory.Dequeue(); + } + } + + #endregion + + #region 辅助方法 + + private string GetNodeUniqueId(Node node, BehaviourTree tree) + { + return $"{tree?.name ?? "Unknown"}_{node.GetType().Name}_{node.GetHashCode()}"; + } + + private string GetNodeDisplayName(Node node) + { + if (!string.IsNullOrEmpty(node.name)) + return node.name; + return node.GetType().Name; + } + + private NodeStatus ConvertStatus(Status status) + { + return status switch + { + Status.Success => NodeStatus.Success, + Status.Failure => NodeStatus.Failure, + Status.Running => NodeStatus.Running, + Status.Resting => NodeStatus.Resting, + Status.Optional => NodeStatus.Optional, + _ => NodeStatus.Unknown + }; + } + + private int GetRowIndex(Node node, BehaviourTree tree) + { + if (tree == null) return -1; + + var container = BehaviourTreeNodeInfoManager.GetContainer(tree.name); + if (container == null) return -1; + + // 尝试通过节点名称匹配 + var info = container.NodeInfos.Find(n => n.NodeTypeName == node.name); + return info?.RowIndex ?? -1; + } + + private void CleanupExpiredStatuses() + { + float currentTime = Time.time; + var expiredKeys = _nodeStatusMap + .Where(kv => currentTime - kv.Value.Timestamp > NodeStatusExpireTime) + .Select(kv => kv.Key) + .ToList(); + + foreach (var key in expiredKeys) + { + _nodeStatusMap.Remove(key); + } + } + + #endregion + + #region 数据类 + + public enum NodeStatus + { + Unknown, + Success, + Failure, + Running, + Resting, + Optional + } + + public class NodeStatusData + { + public string NodeId; + public string NodeName; + public NodeStatus Status; + public float Timestamp; + public string TreeName; + public int RowIndex; + + public string GetFormattedTime() + { + return $"{Timestamp:F2}s"; + } + + public Color GetStatusColor() + { + return Status switch + { + NodeStatus.Success => new Color(0.2f, 0.8f, 0.2f), // 绿色 + NodeStatus.Failure => new Color(0.9f, 0.2f, 0.2f), // 红色 + NodeStatus.Running => new Color(0.2f, 0.6f, 1f), // 蓝色 + NodeStatus.Resting => new Color(0.8f, 0.8f, 0.8f), // 灰色 + _ => Color.white + }; + } + } + + public class ExecutionRecord + { + public float Timestamp; + public string NodeName; + public string NodeId; + public NodeStatus Result; + public string TreeName; + public int RowIndex; + + public string GetFormattedTime() + { + return $"{Timestamp:F2}s"; + } + } + + public class VariableChangeRecord + { + public float Timestamp; + public string VariableName; + public string Value; + public string TreeName; + + public string GetFormattedTime() + { + return $"{Timestamp:F2}s"; + } + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Core/BTRuntimeVisualizer.cs.meta b/Assets/BP_Scripts/GameplayEditor/Core/BTRuntimeVisualizer.cs.meta new file mode 100644 index 0000000..770c319 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/BTRuntimeVisualizer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 61da929c7d350454a82337cddf67f1fe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeController_Enhanced.cs b/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeController_Enhanced.cs new file mode 100644 index 0000000..7fd73e2 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeController_Enhanced.cs @@ -0,0 +1,212 @@ +using System.Collections; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using UnityEngine; + +namespace GameplayEditor.Core +{ + /// + /// 行为树控制器增强功能 + /// 自动降频保护、无限制帧率模式、性能自适应 + /// + public class BehaviourTreeControllerEnhanced : MonoBehaviour + { + [Header("性能保护")] + [Tooltip("启用自动降频保护")] + public bool EnableAdaptiveTickRate = true; + + [Tooltip("目标帧率(当EnableAdaptiveTickRate时,这是最高帧率)")] + [Range(20, 120)] + public int TargetTickRate = 60; + + [Tooltip("最低帧率限制")] + [Range(10, 60)] + public int MinTickRate = 20; + + [Tooltip("帧率调整间隔(秒)")] + public float AdjustmentInterval = 2f; + + [Tooltip("性能阈值(低于此FPS开始降频)")] + public float PerformanceThreshold = 45f; + + [Header("特殊模式")] + [Tooltip("启用无限制帧率模式(音游等特殊玩法)")] + public bool UnlimitedTickMode = false; + + [Tooltip("无限制模式下的固定DeltaTime")] + public float UnlimitedModeDeltaTime = 0.016f; + + [Header("性能监控集成")] + [Tooltip("与性能监控器联动")] + public bool LinkWithPerformanceMonitor = true; + + [Tooltip("检测到性能问题时自动降频")] + public bool AutoReduceOnWarning = true; + + private float _currentTickInterval; + private float _lastAdjustmentTime; + private float[] _frameTimeBuffer = new float[30]; + private int _frameTimeIndex = 0; + private float _currentFPS; + + private BehaviourTreeController _baseController; + + private bool _isReducedPerformance = false; + private int _originalTickRate; + + void Awake() + { + // 延迟到 Start 初始化,避免组件顺序问题 + } + + void Start() + { + _baseController = GetComponent(); + if (_baseController == null) + { + Debug.LogError("[BTControllerEnhanced] Need BehaviourTreeController component"); + enabled = false; + return; + } + + _originalTickRate = TargetTickRate; + _currentTickInterval = 1f / TargetTickRate; + + for (int i = 0; i < _frameTimeBuffer.Length; i++) + { + _frameTimeBuffer[i] = 0.016f; + } + } + + void Update() + { + if (!enabled || _baseController == null) + return; + + RecordFrameTime(); + _currentFPS = CalculateAverageFPS(); + + if (EnableAdaptiveTickRate && !UnlimitedTickMode) + { + UpdateAdaptiveTickRate(); + } + + if (LinkWithPerformanceMonitor && AutoReduceOnWarning) + { + CheckPerformanceMonitorWarnings(); + } + + ApplyConfiguration(); + } + + private void RecordFrameTime() + { + _frameTimeBuffer[_frameTimeIndex] = Time.unscaledDeltaTime; + _frameTimeIndex = (_frameTimeIndex + 1) % _frameTimeBuffer.Length; + } + + private float CalculateAverageFPS() + { + float totalTime = 0f; + for (int i = 0; i < _frameTimeBuffer.Length; i++) + { + totalTime += _frameTimeBuffer[i]; + } + float avgDeltaTime = totalTime / _frameTimeBuffer.Length; + return avgDeltaTime > 0 ? 1f / avgDeltaTime : 0; + } + + private void UpdateAdaptiveTickRate() + { + if (Time.time - _lastAdjustmentTime < AdjustmentInterval) + return; + + _lastAdjustmentTime = Time.time; + + if (_currentFPS < PerformanceThreshold) + { + if (_baseController.TickRate > MinTickRate) + { + int newRate = Mathf.Max(MinTickRate, _baseController.TickRate - 10); + SetTickRate(newRate); + _isReducedPerformance = true; + + Debug.LogWarning("[BTControllerEnhanced] FPS low: " + _currentFPS.ToString("F1") + + ", reducing tick rate to: " + newRate); + } + } + else if (_currentFPS > PerformanceThreshold + 15 && _isReducedPerformance) + { + if (_baseController.TickRate < _originalTickRate) + { + int newRate = Mathf.Min(_originalTickRate, _baseController.TickRate + 5); + SetTickRate(newRate); + + if (newRate >= _originalTickRate) + { + _isReducedPerformance = false; + Debug.Log("[BTControllerEnhanced] Performance recovered, tick rate restored"); + } + } + } + } + + private void CheckPerformanceMonitorWarnings() + { + if (BTPerformanceMonitor.Instance == null) + return; + + var report = BTPerformanceMonitor.Instance.GetCurrentReport(); + if (report.WarningCount > 5 && !_isReducedPerformance) + { + int newRate = Mathf.Max(MinTickRate, _baseController.TickRate - 10); + SetTickRate(newRate); + _isReducedPerformance = true; + + Debug.LogWarning("[BTControllerEnhanced] Performance warnings detected, reducing tick rate to: " + newRate); + } + } + + private void SetTickRate(int rate) + { + if (_baseController != null) + { + _baseController.TickRate = rate; + _currentTickInterval = 1f / rate; + } + } + + private void ApplyConfiguration() + { + if (UnlimitedTickMode && _baseController != null) + { + // Unlimited mode: use fixed delta time + _baseController.TickRate = 0; + } + } + + public void SetUnlimitedMode(bool enable) + { + UnlimitedTickMode = enable; + if (enable) + { + Debug.Log("[BTControllerEnhanced] Unlimited tick mode enabled (for rhythm games)"); + } + else + { + SetTickRate(TargetTickRate); + Debug.Log("[BTControllerEnhanced] Normal tick mode restored"); + } + } + + public float GetCurrentFPS() + { + return _currentFPS; + } + + public bool IsReducedPerformance() + { + return _isReducedPerformance; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeController_Enhanced.cs.meta b/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeController_Enhanced.cs.meta new file mode 100644 index 0000000..0fde2a2 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeController_Enhanced.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: defb45b1a362c7b4da98852b7555089d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeExtended.cs b/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeExtended.cs new file mode 100644 index 0000000..422c4b0 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeExtended.cs @@ -0,0 +1,532 @@ +using System.Collections.Generic; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using GameplayEditor.Config; +using UnityEngine; +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace GameplayEditor.Core +{ + /// + /// 行为树扩展属性 + /// 存储行为树的额外元数据,不修改原有BehaviourTree类 + /// 与BehaviourTreeNodeInfoContainer一起使用 + /// + public static class BehaviourTreeExtended + { + // 使用EditorPrefs或ScriptableObject存储扩展属性 + // 这里使用静态字典运行时存储,编辑器中使用ScriptableObject + + // 黑板4域变量名(对应设计文档 1#~4#) + public const string TREE_DICT_KEY = "ActivityTreeDict"; // 1# 当前行为树域 + public const string ROLE_DICT_KEY = "ActivityRoleDict"; // 2# 角色域 + public const string CUSTOM_DICT_KEY = "ActivityCustomDict"; // 3# 自定义域 + public const string GLOBAL_DICT_KEY = "ActivityGlobalDict"; // 4# 系统全局域 + + /// + /// 初始化黑板4个字典域 + /// + public static void InitializeBlackboardDomains(IBlackboard blackboard) + { + if (blackboard == null) return; + + EnsureDictVariable(blackboard, TREE_DICT_KEY); + EnsureDictVariable(blackboard, ROLE_DICT_KEY); + EnsureDictVariable(blackboard, CUSTOM_DICT_KEY); + EnsureDictVariable(blackboard, GLOBAL_DICT_KEY); + + Debug.Log("[BehaviourTreeExtended] 黑板4域初始化完成"); + } + + private static void EnsureDictVariable(IBlackboard blackboard, string key) + { + if (blackboard.variables == null) return; + if (!blackboard.variables.ContainsKey(key)) + { + blackboard.AddVariable>(key, new Dictionary()); + } + } + + /// + /// 检查行为树ID是否为头文件 + /// 规则: StageID*10 为头文件 (如 301502490) + /// StageID 为正文 (如 30150249) + /// + /// 行为树ID + /// 是否为头文件 + public static bool IsHeaderFile(int treeId) + { + // 头文件特征: 以0结尾,且除以10后仍是有效ID + return treeId > 0 && treeId % 10 == 0; + } + + /// + /// 获取基础关卡ID + /// 头文件 301502490 → 30150249 + /// 正文 30150249 → 30150249 + /// + /// 行为树ID + /// 基础关卡ID + public static int GetBaseStageID(int treeId) + { + if (IsHeaderFile(treeId)) + { + return treeId / 10; + } + return treeId; + } + + /// + /// 获取头文件ID + /// 正文 30150249 → 301502490 + /// + /// 关卡ID + /// 头文件ID + public static int GetHeaderFileID(int stageId) + { + return stageId * 10; + } + } + + /// + /// 行为树运行时控制器 + /// 控制头文件和正文的执行逻辑 + /// + public enum TickRateMode + { + Normal, // 普通模式:20-120fps + Unlimited // 无限制模式:每帧都执行(音游等特殊玩法) + } + + public class BehaviourTreeController : MonoBehaviour + { + [Header("行为树配置")] + [Tooltip("关卡ID")] + public int StageID; + + [Tooltip("头文件行为树(初始化,只执行一次)")] + public BehaviourTree HeaderTree; + + [Tooltip("正文行为树(主逻辑,每帧执行)")] + public BehaviourTree BodyTree; + + [Header("运行配置")] + [Tooltip("Tick率模式")] + public TickRateMode TickMode = TickRateMode.Normal; + + [Range(20, 120)] + [Tooltip("运行帧率(tick/秒),Normal模式下有效")] + public int TickRate = 60; + + [Tooltip("无限制模式下是否使用固定DeltaTime")] + public bool UseFixedDeltaTimeInUnlimited = true; + + [Tooltip("无限制模式下的固定DeltaTime(秒)")] + public float UnlimitedDeltaTime = 0.016f; + + [Tooltip("是否在启动时自动运行")] + public bool AutoStart = true; + + // 运行时状态 + private bool _headerExecuted = false; + private float _tickInterval; + private float _lastTickTime; + private bool _isRunning = false; + + // NodeCanvas 行为树执行代理 + private BehaviourTreeOwner _headerOwner; + private BehaviourTreeOwner _bodyOwner; + + /// + /// 是否已执行过头文件 + /// + public bool HeaderExecuted => _headerExecuted; + + /// + /// 是否正在运行 + /// + public bool IsRunning => _isRunning; + + void Start() + { + _tickInterval = 1f / TickRate; + + if (AutoStart) + { + StartExecution(); + } + } + + void Update() + { + if (!_isRunning) return; + + // 无限制模式:每帧都执行,不检查时间间隔 + if (TickMode == TickRateMode.Unlimited) + { + ExecuteTick(); + return; + } + + // 普通模式:检查是否到达tick时间 + if (Time.time - _lastTickTime < _tickInterval) + return; + + _lastTickTime = Time.time; + ExecuteTick(); + } + + /// + /// 执行一次Tick(头文件或正文) + /// + private void ExecuteTick() + { + // 执行头文件(直到其运行完成) + if (!_headerExecuted && HeaderTree != null) + { + ExecuteHeaderTree(); + return; // 头文件执行期间不执行正文 + } + + // 执行正文(每tick) + if (BodyTree != null) + { + ExecuteBodyTree(); + } + } + + /// + /// 开始执行行为树 + /// + public void StartExecution() + { + _isRunning = true; + _headerExecuted = false; + _lastTickTime = Time.time; + + // 确保管理器已初始化 + EnsureManagersInitialized(); + + // 确保 Owner 和黑板就绪 + EnsureOwners(); + var bb = GetSharedBlackboard(); + _headerOwner.blackboard = bb; + _bodyOwner.blackboard = bb; + + // 初始化黑板4域(在头文件执行前完成) + BehaviourTreeExtended.InitializeBlackboardDomains(bb); + + Debug.Log($"[BTController] 开始执行关卡 {StageID} 的行为树,帧率: {TickRate}"); + } + + /// + /// 获取共享黑板 + /// + private IBlackboard GetSharedBlackboard() + { + var bb = GetComponent(); + if (bb == null) + { + bb = gameObject.AddComponent(); + } + return bb; + } + + /// + /// 确保存在两个 BehaviourTreeOwner 分别用于头文件和正文 + /// + private void EnsureOwners() + { + if (_headerOwner == null || _bodyOwner == null) + { + var owners = GetComponents(); + if (owners.Length >= 2) + { + _headerOwner = owners[0]; + _bodyOwner = owners[1]; + } + else if (owners.Length == 1) + { + _headerOwner = owners[0]; + _bodyOwner = gameObject.AddComponent(); + } + else + { + _headerOwner = gameObject.AddComponent(); + _bodyOwner = gameObject.AddComponent(); + } + } + } + + /// + /// 确保关键管理器已初始化(防止因执行顺序导致未初始化) + /// + private static void EnsureManagersInitialized() + { + // 确保 DialogInfoManager 已初始化 + if (!DialogInfoManager.Instance.IsInitialized) + { +#if UNITY_EDITOR + var byStageConfig = AssetDatabase.LoadAssetAtPath( + "Assets/Verification/Configs/DialogInfoByStageConfig.asset"); + var database = AssetDatabase.LoadAssetAtPath( + "Assets/Verification/Configs/DialogInfoDatabase.asset"); + if (byStageConfig != null && database != null) + { + DialogInfoManager.Instance.Initialize(byStageConfig, database); + Debug.Log("[BTController] 自动初始化 DialogInfoManager"); + } +#endif + } + + // 确保 GameplayManagerHub 存在 + var _ = GameplayManagerHub.Instance; + } + + /// + /// 停止执行 + /// + public void StopExecution() + { + _isRunning = false; + _headerOwner?.StopBehaviour(); + _bodyOwner?.StopBehaviour(); + Debug.Log($"[BTController] 停止执行关卡 {StageID} 的行为树"); + } + + /// + /// 重置执行状态(允许再次执行头文件) + /// + public void ResetExecution() + { + _headerExecuted = false; + _isRunning = false; + _headerOwner?.StopBehaviour(); + _bodyOwner?.StopBehaviour(); + Debug.Log($"[BTController] 重置关卡 {StageID} 的执行状态"); + } + + /// + /// 执行头文件行为树(手动 tick,直到其返回非 Running 状态) + /// + private void ExecuteHeaderTree() + { + if (HeaderTree == null || _headerOwner == null) return; + + try + { + // 切换并启动头文件(Manual 模式) + if (_headerOwner.behaviour != HeaderTree) + { + _headerOwner.SwitchBehaviour(HeaderTree, Graph.UpdateMode.Manual, null); + } + if (!_headerOwner.isRunning) + { + _headerOwner.StartBehaviour(Graph.UpdateMode.Manual); + } + + // Tick 一次 + var status = _headerOwner.Tick(); + Debug.Log($"[BTController] 头文件 tick: {status}"); + + if (status != Status.Running) + { + _headerExecuted = true; + Debug.Log($"[BTController] 头文件执行完成: {status}"); + + // 头文件完成后启动正文 + if (BodyTree != null && _bodyOwner != null) + { + if (_bodyOwner.behaviour != BodyTree) + { + _bodyOwner.SwitchBehaviour(BodyTree, Graph.UpdateMode.Manual, null); + } + if (!_bodyOwner.isRunning) + { + _bodyOwner.StartBehaviour(Graph.UpdateMode.Manual); + } + } + } + } + catch (System.Exception ex) + { + Debug.LogError($"[BTController] 头文件执行错误: {ex.Message}"); + _headerExecuted = true; // 标记为已执行,避免卡死 + } + } + + /// + /// 执行正文行为树(手动 tick) + /// + private void ExecuteBodyTree() + { + if (BodyTree == null || _bodyOwner == null) return; + + try + { + // 切换并启动正文(Manual 模式) + if (_bodyOwner.behaviour != BodyTree) + { + _bodyOwner.SwitchBehaviour(BodyTree, Graph.UpdateMode.Manual, null); + } + if (!_bodyOwner.isRunning) + { + _bodyOwner.StartBehaviour(Graph.UpdateMode.Manual); + } + + // 手动 tick 正文 + _bodyOwner.UpdateBehaviour(); + } + catch (System.Exception ex) + { + // 使用行索引信息定位错误 + var treeName = BodyTree.name; + var errorMsg = BehaviourTreeNodeInfoManager.FormatErrorMessage( + treeName, 0, ex.Message); + Debug.LogError($"[BTController] {errorMsg}"); + } + } + + /// + /// 从容器加载行为树 + /// + public void LoadTrees(BehaviourTree headerTree, BehaviourTree bodyTree) + { + HeaderTree = headerTree; + BodyTree = bodyTree; + + // 从行为树名称提取StageID + if (BodyTree != null && BodyTree.name.StartsWith("BT_")) + { + if (int.TryParse(BodyTree.name.Substring(3), out var treeId)) + { + StageID = treeId; + } + } + } + + #region Tick模式控制 + + /// + /// 设置Tick率模式 + /// + /// 模式 + public void SetTickMode(TickRateMode mode) + { + if (TickMode == mode) return; + + TickMode = mode; + + if (mode == TickRateMode.Unlimited) + { + Debug.Log($"[BTController] 切换到无限制帧率模式(适用于音游等精准玩法)"); + } + else + { + _lastTickTime = Time.time; + Debug.Log($"[BTController] 切换到普通模式,帧率: {TickRate}fps"); + } + } + + /// + /// 启用无限制帧率模式(快捷方法) + /// + public void EnableUnlimitedMode() + { + SetTickMode(TickRateMode.Unlimited); + } + + /// + /// 禁用无限制帧率模式,恢复普通模式 + /// + public void DisableUnlimitedMode() + { + SetTickMode(TickRateMode.Normal); + } + + /// + /// 设置无限制模式下的固定DeltaTime + /// + public void SetUnlimitedDeltaTime(float deltaTime) + { + UnlimitedDeltaTime = Mathf.Max(0.001f, deltaTime); + } + + /// + /// 当前是否处于无限制模式 + /// + public bool IsUnlimitedMode => TickMode == TickRateMode.Unlimited; + + /// + /// 获取当前实际Tick率(无限制模式返回-1) + /// + public int GetCurrentTickRate() + { + return TickMode == TickRateMode.Unlimited ? -1 : TickRate; + } + + #endregion + } + + /// + /// 行为树运行时数据 + /// 存储在ScriptableObject中,配置运行参数 + /// + [CreateAssetMenu(fileName = "BTRuntimeData", menuName = "Gameplay/Behaviour Tree Runtime Data")] + public class BehaviourTreeRuntimeData : ScriptableObject + { + [Header("关卡配置")] + [Tooltip("关卡ID")] + public int StageID; + + [Tooltip("头文件行为树引用")] + public BehaviourTree HeaderTree; + + [Tooltip("正文行为树引用")] + public BehaviourTree BodyTree; + + [Header("运行配置")] + [Tooltip("Tick率模式")] + public TickRateMode TickMode = TickRateMode.Normal; + + [Range(20, 120)] + [Tooltip("运行帧率(tick/秒),默认60,Normal模式下有效")] + public int TickRate = 60; + + [Tooltip("无限制模式下是否使用固定DeltaTime")] + public bool UseFixedDeltaTimeInUnlimited = true; + + [Tooltip("无限制模式下的固定DeltaTime(秒)")] + public float UnlimitedDeltaTime = 0.016f; + + [Tooltip("是否在场景加载时自动启动")] + public bool AutoStartOnLoad = true; + + /// + /// 创建运行时控制器 + /// + public BehaviourTreeController CreateController(GameObject owner) + { + var controller = owner.AddComponent(); + controller.StageID = StageID; + controller.HeaderTree = HeaderTree; + controller.BodyTree = BodyTree; + controller.TickMode = TickMode; + controller.TickRate = TickRate; + controller.UseFixedDeltaTimeInUnlimited = UseFixedDeltaTimeInUnlimited; + controller.UnlimitedDeltaTime = UnlimitedDeltaTime; + controller.AutoStart = AutoStartOnLoad; + return controller; + } + + /// + /// 切换到无限制模式(用于音游等特殊玩法) + /// + public void SetUnlimitedMode(bool enable) + { + TickMode = enable ? TickRateMode.Unlimited : TickRateMode.Normal; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeExtended.cs.meta b/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeExtended.cs.meta new file mode 100644 index 0000000..3df5af3 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeExtended.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9d87f276c171f1c4189eef2d87d9a8f3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeNodeInfo.cs b/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeNodeInfo.cs new file mode 100644 index 0000000..49da989 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeNodeInfo.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using NodeCanvas.BehaviourTrees; +using UnityEngine; + +namespace GameplayEditor.Core +{ + /// + /// 行为树节点附加信息 + /// 存储行索引、节点ID等元数据,用于报错定位和调试 + /// + [Serializable] + public class BehaviourTreeNodeInfo + { + /// 行索引ID(自动生成,用于报错定位) + public int RowIndex; + + /// 节点ID(策划配置) + public int NodeID; + + /// 节点层级(深度) + public int NodeLayer; + + /// 节点类型名称 + public string NodeTypeName; + + /// 原始行数据(调试用) + public string RawData; + + /// 源Excel行号(从1开始) + public int ExcelRowNumber; + } + + /// + /// 行为树扩展信息容器 + /// 与BehaviourTree资产关联,存储额外的元数据 + /// + [CreateAssetMenu(fileName = "BTNodeInfo", menuName = "Gameplay/Behaviour Tree Node Info")] + public class BehaviourTreeNodeInfoContainer : ScriptableObject + { + /// 关联的行为树名称 + public string BehaviourTreeName; + + /// 行为树ID + public int TreeID; + + /// 是否为头文件 + public bool IsHeaderFile; + + /// 节点信息列表(与BehaviourTree节点顺序对应) + public List NodeInfos = new List(); + + /// + /// 根据节点索引获取节点信息 + /// + /// 节点索引 + /// 节点信息,未找到返回null + public BehaviourTreeNodeInfo GetNodeInfo(int nodeIndex) + { + if (nodeIndex >= 0 && nodeIndex < NodeInfos.Count) + return NodeInfos[nodeIndex]; + return null; + } + + /// + /// 根据行索引查找节点信息 + /// + /// 行索引 + /// 节点信息,未找到返回null + public BehaviourTreeNodeInfo FindByRowIndex(int rowIndex) + { + return NodeInfos.Find(info => info.RowIndex == rowIndex); + } + + /// + /// 根据节点ID查找节点信息 + /// + /// 节点ID + /// 节点信息,未找到返回null + public BehaviourTreeNodeInfo FindByNodeID(int nodeId) + { + return NodeInfos.Find(info => info.NodeID == nodeId); + } + + /// + /// 添加节点信息 + /// + public void AddNodeInfo(BehaviourTreeNodeInfo info) + { + NodeInfos.Add(info); + } + + /// + /// 清空所有节点信息 + /// + public void Clear() + { + NodeInfos.Clear(); + } + } + + /// + /// 行为树节点信息管理器 + /// 运行时提供节点信息查询服务 + /// + public static class BehaviourTreeNodeInfoManager + { + private static readonly Dictionary _containers = + new Dictionary(); + + /// + /// 注册节点信息容器 + /// + /// 行为树名称 + /// 节点信息容器 + public static void Register(string treeName, BehaviourTreeNodeInfoContainer container) + { + _containers[treeName] = container; + } + + /// + /// 获取节点信息容器 + /// + /// 行为树名称 + /// 节点信息容器,未找到返回null + public static BehaviourTreeNodeInfoContainer GetContainer(string treeName) + { + return _containers.TryGetValue(treeName, out var container) ? container : null; + } + + /// + /// 获取节点的行索引(用于报错定位) + /// + /// 行为树名称 + /// 节点索引 + /// 行索引,未找到返回-1 + public static int GetRowIndex(string treeName, int nodeIndex) + { + var container = GetContainer(treeName); + if (container == null) return -1; + + var info = container.GetNodeInfo(nodeIndex); + return info?.RowIndex ?? -1; + } + + /// + /// 生成错误定位信息 + /// + /// 行为树名称 + /// 节点索引 + /// 错误信息 + /// 格式化的错误定位信息 + public static string FormatErrorMessage(string treeName, int nodeIndex, string errorMessage) + { + var rowIndex = GetRowIndex(treeName, nodeIndex); + var container = GetContainer(treeName); + var treeID = container?.TreeID ?? 0; + + if (rowIndex > 0) + { + return $"[BT:{treeID} Row:{rowIndex}] {errorMessage}"; + } + else + { + return $"[BT:{treeID} Node:{nodeIndex}] {errorMessage}"; + } + } + + /// + /// 清空所有注册信息 + /// + public static void Clear() + { + _containers.Clear(); + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeNodeInfo.cs.meta b/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeNodeInfo.cs.meta new file mode 100644 index 0000000..d9e5c06 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/BehaviourTreeNodeInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 28f26d9d24e8bdc49855477a878fe0fa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Core/CompositeMethodLoader.cs b/Assets/BP_Scripts/GameplayEditor/Core/CompositeMethodLoader.cs new file mode 100644 index 0000000..96bed2b --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/CompositeMethodLoader.cs @@ -0,0 +1,201 @@ +using System.Collections.Generic; +using GameplayEditor.Nodes; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using UnityEngine; + +namespace GameplayEditor.Core +{ + /// + /// 组合方法加载器 + /// 自动加载和管理组合方法(子树) + /// + public class CompositeMethodLoader : MonoBehaviour + { + private static CompositeMethodLoader _instance; + public static CompositeMethodLoader Instance + { + get + { + if (_instance == null) + { + _instance = FindObjectOfType(); + if (_instance == null) + { + var go = new GameObject("CompositeMethodLoader"); + _instance = go.AddComponent(); + DontDestroyOnLoad(go); + } + } + return _instance; + } + } + + [Header("组合方法配置")] + [Tooltip("组合方法文件夹路径")] + public string MethodsFolderPath = "Assets/CompositeMethods"; + + [Tooltip("自动加载所有组合方法")] + public bool AutoLoadOnStart = true; + + // 已加载的方法 + private Dictionary _loadedMethods = new Dictionary(); + + private void Awake() + { + if (_instance != null && _instance != this) + { + Destroy(gameObject); + return; + } + _instance = this; + DontDestroyOnLoad(gameObject); + + if (AutoLoadOnStart) + { + LoadAllMethods(); + } + } + + /// + /// 加载所有组合方法 + /// + public void LoadAllMethods() + { + #if UNITY_EDITOR + // 从资源文件夹加载 + var guids = UnityEditor.AssetDatabase.FindAssets("t:CompositeMethod", + new[] { MethodsFolderPath }); + + foreach (var guid in guids) + { + var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid); + var method = UnityEditor.AssetDatabase.LoadAssetAtPath(path); + if (method != null) + { + RegisterMethod(method); + } + } + + Debug.Log($"[CompositeMethodLoader] 加载了 {_loadedMethods.Count} 个组合方法"); + #endif + } + + /// + /// 注册组合方法 + /// + public void RegisterMethod(CompositeMethod method) + { + if (method == null || method.MethodID <= 0) + { + Debug.LogWarning("[CompositeMethodLoader] 无效的组合方法"); + return; + } + + _loadedMethods[method.MethodID] = method; + CompositeMethodRegistry.Register(method); + + Debug.Log($"[CompositeMethodLoader] 注册方法: {method.MethodName} (ID:{method.MethodID})"); + } + + /// + /// 获取组合方法 + /// + public CompositeMethod GetMethod(int methodId) + { + _loadedMethods.TryGetValue(methodId, out var method); + return method; + } + + /// + /// 根据名称获取方法 + /// + public CompositeMethod GetMethodByName(string methodName) + { + foreach (var method in _loadedMethods.Values) + { + if (method.MethodName == methodName || method.DisplayName == methodName) + { + return method; + } + } + return null; + } + + /// + /// 获取所有方法 + /// + public IReadOnlyCollection GetAllMethods() + { + return _loadedMethods.Values; + } + + /// + /// 创建运行时子树实例 + /// + public BehaviourTree CreateRuntimeTree(int methodId) + { + var method = GetMethod(methodId); + if (method == null || method.Tree == null) + { + Debug.LogWarning($"[CompositeMethodLoader] 未找到方法或子树: ID={methodId}"); + return null; + } + + // 创建运行时实例 + var runtimeTree = Instantiate(method.Tree); + runtimeTree.name = $"Runtime_{method.MethodName}"; + + Debug.Log($"[CompositeMethodLoader] 创建运行时子树: {runtimeTree.name}"); + return runtimeTree; + } + + /// + /// 创建运行时子树实例(带参数) + /// + public BehaviourTree CreateRuntimeTree(int methodId, Dictionary parameters) + { + var runtimeTree = CreateRuntimeTree(methodId); + if (runtimeTree == null) + return null; + + // 设置参数到子树黑板 + if (parameters != null && runtimeTree.blackboard != null) + { + foreach (var kvp in parameters) + { + // 使用AddVariable添加或更新变量 + if (runtimeTree.blackboard.variables.ContainsKey(kvp.Key)) + { + runtimeTree.blackboard.SetVariableValue(kvp.Key, kvp.Value); + } + else + { + runtimeTree.blackboard.AddVariable(kvp.Key, kvp.Value); + } + Debug.Log($"[CompositeMethodLoader] 设置参数: {kvp.Key} = {kvp.Value}"); + } + } + + return runtimeTree; + } + + /// + /// 卸载所有方法 + /// + public void UnloadAll() + { + _loadedMethods.Clear(); + CompositeMethodRegistry.Clear(); + Debug.Log("[CompositeMethodLoader] 卸载所有方法"); + } + + /// + /// 检查方法是否存在 + /// + public bool HasMethod(int methodId) + { + return _loadedMethods.ContainsKey(methodId); + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Core/CompositeMethodLoader.cs.meta b/Assets/BP_Scripts/GameplayEditor/Core/CompositeMethodLoader.cs.meta new file mode 100644 index 0000000..41c0af9 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/CompositeMethodLoader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 48389d3b46d34fa4485ca27fe1656cf3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Core/GameplayConnection.cs b/Assets/BP_Scripts/GameplayEditor/Core/GameplayConnection.cs new file mode 100644 index 0000000..1b91fc6 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/GameplayConnection.cs @@ -0,0 +1,9 @@ +using NodeCanvas.Framework; + +namespace GameplayEditor.Core +{ + [System.Serializable] + public class GameplayConnection : Connection + { + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Core/GameplayConnection.cs.meta b/Assets/BP_Scripts/GameplayEditor/Core/GameplayConnection.cs.meta new file mode 100644 index 0000000..7c59920 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/GameplayConnection.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b4288e8c48438d9418ecb6578e78f32b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Core/GameplayManagerHub.cs b/Assets/BP_Scripts/GameplayEditor/Core/GameplayManagerHub.cs new file mode 100644 index 0000000..f367f2f --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/GameplayManagerHub.cs @@ -0,0 +1,321 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace GameplayEditor.Core +{ + /// + /// 游戏管理器接口 + /// 所有业务系统管理器需要实现的接口 + /// + public interface IGameplayManager + { + string ManagerName { get; } + bool IsInitialized { get; } + void Initialize(); + void Shutdown(); + } + + /// + /// 阵营管理器接口 + /// + public interface ICampManager : IGameplayManager + { + List GetNpcCamps(string npcId); + bool IsSameCamp(string npcId1, string npcId2); + bool IsEnemyCamp(string npcId1, string npcId2); + } + + /// + /// 战斗管理器接口 + /// + public interface IBattleManager : IGameplayManager + { + void SetFightTarget(string attackerId, string targetId); + float GetNPCHealthPercent(string npcId); + float GetNPCDistance(string npcId1, string npcId2); + bool NPCExists(string npcId); + } + + /// + /// NPC管理器接口 + /// + public interface INPCManager : IGameplayManager + { + GameObject SpawnNPC(string npcId, Vector3 position, Quaternion rotation); + void DespawnNPC(string npcId); + GameObject GetNPC(string npcId); + Vector3 GetNPCPosition(string npcId); + void PlayAnimation(string npcId, string animName); + } + + /// + /// 相机管理器接口 + /// + public interface ICameraManager : IGameplayManager + { + void SwitchToCamera(int cameraId, float transitionTime = 0.5f); + void ResetToDefault(); + } + + /// + /// 特效管理器接口 + /// + public interface IFXManager : IGameplayManager + { + void PlayEffect(int effectId, Vector3 position, float duration = 0); + void PlayEffectOnNPC(int effectId, string npcId, float duration = 0); + } + + /// + /// UI管理器接口 + /// + public interface IUIManager : IGameplayManager + { + void ShowDialog(int dialogId); + void CloseDialog(int dialogId); + void CloseAllDialogs(); + bool IsDialogActive(int dialogId); + void ShowLoading(); + void HideLoading(); + } + + /// + /// 游戏管理器中心 + /// 统一管理所有业务系统 + /// + public class GameplayManagerHub : MonoBehaviour + { + private static GameplayManagerHub _instance; + public static GameplayManagerHub Instance + { + get + { + if (_instance == null) + { + _instance = FindObjectOfType(); + if (_instance == null) + { + var go = new GameObject("GameplayManagerHub"); + _instance = go.AddComponent(); + DontDestroyOnLoad(go); + } + } + return _instance; + } + } + + // 各业务系统管理器 + public ICampManager CampManager { get; private set; } + public IBattleManager BattleManager { get; private set; } + public INPCManager NPCManager { get; private set; } + public ICameraManager CameraManager { get; private set; } + public IFXManager FXManager { get; private set; } + public IUIManager UIManager { get; private set; } + + // 管理器注册表 + private Dictionary _managers = new Dictionary(); + + private void Awake() + { + if (_instance != null && _instance != this) + { + Destroy(gameObject); + return; + } + _instance = this; + DontDestroyOnLoad(gameObject); + } + + /// + /// 注册管理器 + /// + public void RegisterManager(T manager) where T : IGameplayManager + { + _managers[typeof(T)] = manager; + + // 设置特定接口引用 + if (manager is ICampManager camp) CampManager = camp; + if (manager is IBattleManager battle) BattleManager = battle; + if (manager is INPCManager npc) NPCManager = npc; + if (manager is ICameraManager cam) CameraManager = cam; + if (manager is IFXManager fx) FXManager = fx; + if (manager is IUIManager ui) UIManager = ui; + + Debug.Log($"[GameplayManagerHub] 注册管理器: {manager.ManagerName}"); + } + + /// + /// 获取管理器 + /// + public T GetManager() where T : class, IGameplayManager + { + if (_managers.TryGetValue(typeof(T), out var manager)) + { + return manager as T; + } + return null; + } + + /// + /// 检查管理器是否已注册 + /// + public bool HasManager() where T : class, IGameplayManager + { + return _managers.ContainsKey(typeof(T)); + } + + /// + /// 初始化所有管理器 + /// + public void InitializeAll() + { + foreach (var manager in _managers.Values) + { + if (!manager.IsInitialized) + { + manager.Initialize(); + } + } + } + + /// + /// 关闭所有管理器 + /// + public void ShutdownAll() + { + foreach (var manager in _managers.Values) + { + if (manager.IsInitialized) + { + manager.Shutdown(); + } + } + } + } + + /// + /// 模拟阵营管理器(用于测试) + /// + public class MockCampManager : ICampManager + { + public string ManagerName => "MockCampManager"; + public bool IsInitialized { get; private set; } + + private Dictionary> _npcCamps = new Dictionary>(); + + public void Initialize() + { + IsInitialized = true; + Debug.Log("[MockCampManager] 初始化完成"); + } + + public void Shutdown() + { + IsInitialized = false; + } + + public List GetNpcCamps(string npcId) + { + if (_npcCamps.TryGetValue(npcId, out var camps)) + { + return camps; + } + + // 根据NPC ID返回模拟阵营 + if (int.TryParse(npcId, out var id)) + { + if (id >= 2000 && id < 3000) + return new List { 1 }; // 玩家阵营 + else if (id >= 3000 && id < 4000) + return new List { 2 }; // 敌方阵营 + else if (id >= 4000 && id < 5000) + return new List { 1, 4 }; // 玩家+友方阵营 + } + + return new List { 0 }; + } + + public void SetNpcCamp(string npcId, List camps) + { + _npcCamps[npcId] = camps; + } + + public bool IsSameCamp(string npcId1, string npcId2) + { + var camps1 = GetNpcCamps(npcId1); + var camps2 = GetNpcCamps(npcId2); + return camps1.Any(c1 => camps2.Any(c2 => c1 == c2)); + } + + public bool IsEnemyCamp(string npcId1, string npcId2) + { + return !IsSameCamp(npcId1, npcId2); + } + } + + /// + /// 模拟NPC管理器(用于测试) + /// + public class MockNPCManager : INPCManager + { + public string ManagerName => "MockNPCManager"; + public bool IsInitialized { get; private set; } + + private Dictionary _npcs = new Dictionary(); + + public void Initialize() + { + IsInitialized = true; + Debug.Log("[MockNPCManager] 初始化完成"); + } + + public void Shutdown() + { + IsInitialized = false; + } + + public GameObject SpawnNPC(string npcId, Vector3 position, Quaternion rotation) + { + var go = new GameObject($"NPC_{npcId}"); + go.transform.position = position; + go.transform.rotation = rotation; + _npcs[npcId] = go; + Debug.Log($"[MockNPCManager] 生成NPC: {npcId} 在 {position}"); + return go; + } + + public void DespawnNPC(string npcId) + { + if (_npcs.TryGetValue(npcId, out var go)) + { + if (go != null) + { + UnityEngine.Object.Destroy(go); + } + _npcs.Remove(npcId); + } + } + + public GameObject GetNPC(string npcId) + { + _npcs.TryGetValue(npcId, out var go); + return go; + } + + public Vector3 GetNPCPosition(string npcId) + { + if (_npcs.TryGetValue(npcId, out var go)) + { + return go.transform.position; + } + return Vector3.zero; + } + + public void PlayAnimation(string npcId, string animName) + { + Debug.Log($"[MockNPCManager] NPC {npcId} 播放动画: {animName}"); + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Core/GameplayManagerHub.cs.meta b/Assets/BP_Scripts/GameplayEditor/Core/GameplayManagerHub.cs.meta new file mode 100644 index 0000000..acd9f6d --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Core/GameplayManagerHub.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f9cb3ba74e326c2439c1f4cdf13c0123 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor.meta b/Assets/BP_Scripts/GameplayEditor/Editor.meta new file mode 100644 index 0000000..2b1aca0 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 23ad35a149da760419d8fab64e645ef2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/ActivityEditorWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/ActivityEditorWindow.cs new file mode 100644 index 0000000..1e22fa7 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/ActivityEditorWindow.cs @@ -0,0 +1,500 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameplayEditor.Excel; +using GameplayEditor.Core; +using GameplayEditor.Config; +using NodeCanvas.BehaviourTrees; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + public class ActivityEditorWindow : EditorWindow + { + private string _xlsmPath = ""; + private string _dialogXlsmPath = ""; + private string _exportFolderAssetPath = "Assets/BT"; + private Vector2 _scrollPos; + private int _selectedTab = 0; + private string[] _tabNames = { "行为树", "对话配置", "LUT配置" }; + + [MenuItem("Window/Activity Editor/Activity Editor")] + public static void ShowWindow() + { + GetWindow("玩法编辑器"); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("玩法编辑器 - Excel ↔ Graph 同步", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + _selectedTab = GUILayout.Toolbar(_selectedTab, _tabNames); + + EditorGUILayout.Space(); + + switch (_selectedTab) + { + case 0: + DrawBehaviourTreeTab(); + break; + case 1: + DrawDialogTab(); + break; + case 2: + DrawLutTab(); + break; + } + + EditorGUILayout.Space(); + _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.Height(120)); + EditorGUILayout.HelpBox("导入/导出操作的日志会显示在Console中。使用 Window/Activity Editor/Config Validator 进行配置检查。", MessageType.Info); + EditorGUILayout.EndScrollView(); + } + + #region 行为树标签页 + + private void DrawBehaviourTreeTab() + { + // ── 导入 ────────────────────────────────────────────────────── + EditorGUILayout.LabelField("导入行为树", EditorStyles.boldLabel); + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Excel文件:", GUILayout.Width(80)); + _xlsmPath = EditorGUILayout.TextField(_xlsmPath); + if (GUILayout.Button("浏览", GUILayout.Width(60))) + _xlsmPath = EditorUtility.OpenFilePanel("选择xlsm文件", "表", "xlsm,xlsx"); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.HelpBox( + "提示:'玩法编辑器.xlsx' 是设计规范文档,实际导入请使用策划填写的数据表(.xlsm 或 .xlsx)。", + MessageType.Info); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("导入 Excel → BehaviourTree", GUILayout.Height(36))) + ImportFromExcel(); + if (GUILayout.Button("导入并验证", GUILayout.Height(36), GUILayout.Width(120))) + ImportFromExcelWithValidation(); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + // ── 导出 ────────────────────────────────────────────────────── + EditorGUILayout.LabelField("导出行为树(将文件夹内所有树合并到一张表)", EditorStyles.boldLabel); + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("BT文件夹:", GUILayout.Width(80)); + _exportFolderAssetPath = EditorGUILayout.TextField(_exportFolderAssetPath); + if (GUILayout.Button("浏览", GUILayout.Width(60))) + { + var abs = EditorUtility.OpenFolderPanel("选择BT文件夹", "Assets", ""); + if (!string.IsNullOrEmpty(abs)) + { + var rel = "Assets" + abs.Replace(Application.dataPath.Replace("\\", "/"), "").Replace("\\", "/"); + _exportFolderAssetPath = rel; + } + } + EditorGUILayout.EndHorizontal(); + + if (GUILayout.Button("导出文件夹内所有树 → Excel", GUILayout.Height(36))) + ExportFolderToExcel(); + } + + private void ImportFromExcel() + { + if (string.IsNullOrEmpty(_xlsmPath) || !File.Exists(_xlsmPath)) + { + EditorUtility.DisplayDialog("错误", "请选择有效的xlsm文件", "确定"); + return; + } + + try + { + var nameResolver = new NodeTypeNameResolver(); + nameResolver.LoadFromExcel(_xlsmPath); + + var parser = new BtSheetParser(nameResolver); + var results = parser.ParseFromExcelWithInfo(_xlsmPath); + + // 保存为资产(必须在 Assets 目录内) + var outputDir = EditorUtility.SaveFolderPanel("选择保存目录", "Assets", ""); + if (string.IsNullOrEmpty(outputDir)) return; + + // 转换为 Assets/... 相对路径 + var normalizedOutput = outputDir.Replace("\\", "/"); + var normalizedDataPath = Application.dataPath.Replace("\\", "/"); + if (!normalizedOutput.StartsWith(normalizedDataPath)) + { + EditorUtility.DisplayDialog("错误", "请选择 Assets 文件夹内的目录", "确定"); + return; + } + var relativeDir = "Assets" + normalizedOutput.Substring(normalizedDataPath.Length); + + int totalNodes = 0; + foreach (var result in results) + { + // 保存行为树 + var treeAssetPath = $"{relativeDir}/{result.Tree.name}.asset"; + AssetDatabase.CreateAsset(result.Tree, treeAssetPath); + + // 保存节点信息容器 + if (result.NodeInfoContainer != null) + { + var infoAssetPath = $"{relativeDir}/{result.Tree.name}_Info.asset"; + result.NodeInfoContainer.name = $"{result.Tree.name}_Info"; + AssetDatabase.CreateAsset(result.NodeInfoContainer, infoAssetPath); + totalNodes += result.NodeInfoContainer.NodeInfos.Count; + } + } + + AssetDatabase.SaveAssets(); + EditorUtility.DisplayDialog("成功", $"导入了 {results.Count} 棵行为树,共 {totalNodes} 个节点", "确定"); + Debug.Log($"[ActivityEditor] 导入完成,共 {results.Count} 棵树,{totalNodes} 个节点"); + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("错误", $"导入失败:{ex.Message}", "确定"); + Debug.LogError($"[ActivityEditor] 导入错误:{ex}"); + } + } + + private void ImportFromExcelWithValidation() + { + if (string.IsNullOrEmpty(_xlsmPath) || !File.Exists(_xlsmPath)) + { + EditorUtility.DisplayDialog("错误", "请选择有效的xlsm文件", "确定"); + return; + } + + try + { + var nameResolver = new NodeTypeNameResolver(); + nameResolver.LoadFromExcel(_xlsmPath); + + var parser = new BtSheetParser(nameResolver); + var results = parser.ParseFromExcelWithInfo(_xlsmPath); + + // 验证结果 + var validationReport = ConfigValidator.ValidateImportResults(results); + + if (validationReport.HasErrors) + { + var errorMsg = string.Join("\n", validationReport.Results + .Where(r => r.IsError) + .Take(5) + .Select(r => $"- {r.Message}")); + + if (!EditorUtility.DisplayDialog("发现错误", + $"导入结果包含 {validationReport.ErrorCount} 个错误:\n{errorMsg}\n\n是否继续保存?", + "继续", "取消")) + { + return; + } + } + + // 保存为资产 + var outputDir = EditorUtility.SaveFolderPanel("选择保存目录", "Assets", ""); + if (string.IsNullOrEmpty(outputDir)) return; + + var normalizedOutput = outputDir.Replace("\\", "/"); + var normalizedDataPath = Application.dataPath.Replace("\\", "/"); + var relativeDir = "Assets" + normalizedOutput.Substring(normalizedDataPath.Length); + + int totalNodes = 0; + foreach (var result in results) + { + var treeAssetPath = $"{relativeDir}/{result.Tree.name}.asset"; + AssetDatabase.CreateAsset(result.Tree, treeAssetPath); + + if (result.NodeInfoContainer != null) + { + var infoAssetPath = $"{relativeDir}/{result.Tree.name}_Info.asset"; + AssetDatabase.CreateAsset(result.NodeInfoContainer, infoAssetPath); + totalNodes += result.NodeInfoContainer.NodeInfos.Count; + } + } + + AssetDatabase.SaveAssets(); + EditorUtility.DisplayDialog("成功", + $"导入了 {results.Count} 棵行为树,共 {totalNodes} 个节点\n\n错误: {validationReport.ErrorCount}, 警告: {validationReport.WarningCount}", + "确定"); + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("错误", $"导入失败:{ex.Message}", "确定"); + Debug.LogError($"[ActivityEditor] 导入错误:{ex}"); + } + } + + private void ExportFolderToExcel() + { + if (string.IsNullOrEmpty(_exportFolderAssetPath) || !AssetDatabase.IsValidFolder(_exportFolderAssetPath)) + { + EditorUtility.DisplayDialog("错误", "请选择有效的Assets文件夹", "确定"); + return; + } + + var guids = AssetDatabase.FindAssets("t:BehaviourTree", new[] { _exportFolderAssetPath }); + if (guids.Length == 0) + { + EditorUtility.DisplayDialog("错误", $"文件夹内没有找到BehaviourTree资产:{_exportFolderAssetPath}", "确定"); + return; + } + + var trees = guids + .Select(g => AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(g))) + .Where(t => t != null) + .OrderBy(t => t.name) + .ToList(); + + var outputPath = EditorUtility.SaveFilePanel("导出为xlsx", "表", "BehaviourTrees", "xlsx"); + if (string.IsNullOrEmpty(outputPath)) return; + + try + { + var nameResolver = new NodeTypeNameResolver(); + var writer = new BtSheetWriter(nameResolver); + writer.ExportToExcel(trees, outputPath); + + EditorUtility.DisplayDialog("成功", $"导出完成:{trees.Count} 棵树 → {outputPath}", "确定"); + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("错误", $"导出失败:{ex.Message}", "确定"); + Debug.LogError($"[ActivityEditor] 导出错误:{ex}"); + } + } + + #endregion + + #region 对话配置标签页 + + private void DrawDialogTab() + { + EditorGUILayout.LabelField("导入对话配置", EditorStyles.boldLabel); + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Excel文件:", GUILayout.Width(80)); + _dialogXlsmPath = EditorGUILayout.TextField(_dialogXlsmPath); + if (GUILayout.Button("浏览", GUILayout.Width(60))) + _dialogXlsmPath = EditorUtility.OpenFilePanel("选择xlsm文件", "表", "xlsm,xlsx"); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("导入 DialogInfo", GUILayout.Height(36))) + ImportDialogInfo(false); + if (GUILayout.Button("导入并验证", GUILayout.Height(36), GUILayout.Width(120))) + ImportDialogInfo(true); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("说明", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "系统将自动查找Excel中所有包含'Dialog'的Sheet并导入。\n" + + "同时会解析ActivityDialogInfoByStage索引表。\n" + + "提示:'玩法编辑器.xlsx' 是设计规范文档,实际导入请使用策划填写的数据表(.xlsm 或 .xlsx)。", + MessageType.Info); + } + + private void ImportDialogInfo(bool withValidation) + { + if (string.IsNullOrEmpty(_dialogXlsmPath) || !File.Exists(_dialogXlsmPath)) + { + EditorUtility.DisplayDialog("错误", "请选择有效的xlsm文件", "确定"); + return; + } + + try + { + var parser = new DialogInfoParser(); + + // 解析DialogInfo + var dialogResults = parser.ParseFromExcel(_dialogXlsmPath); + + // 解析索引表 + var byStageConfig = parser.ParseByStageConfig(_dialogXlsmPath); + + if (dialogResults.Count == 0 && byStageConfig.Entries.Count == 0) + { + EditorUtility.DisplayDialog("警告", "未找到任何对话配置或索引表", "确定"); + return; + } + + // 验证 + if (withValidation) + { + var hasError = false; + foreach (var result in dialogResults) + { + if (result.Errors.Count > 0) + { + hasError = true; + Debug.LogError($"[DialogInfo] {result.SheetName} 有 {result.Errors.Count} 个错误"); + } + } + + if (hasError) + { + if (!EditorUtility.DisplayDialog("发现错误", + "导入结果包含错误,是否继续保存?", "继续", "取消")) + { + return; + } + } + } + + // 选择保存目录 + var outputDir = EditorUtility.SaveFolderPanel("选择保存目录", "Assets/Configs", ""); + if (string.IsNullOrEmpty(outputDir)) return; + + var normalizedOutput = outputDir.Replace("\\", "/"); + var normalizedDataPath = Application.dataPath.Replace("\\", "/"); + if (!normalizedOutput.StartsWith(normalizedDataPath)) + { + EditorUtility.DisplayDialog("错误", "请选择 Assets 文件夹内的目录", "确定"); + return; + } + var relativeDir = "Assets" + normalizedOutput.Substring(normalizedDataPath.Length); + + // 保存DialogInfo配置 + int totalDialogs = 0; + foreach (var result in dialogResults) + { + if (result.Dialogs.Count == 0) continue; + + var configName = $"DialogInfo_{result.SheetName}.asset"; + var configPath = $"{relativeDir}/{configName}"; + + var config = ScriptableObject.CreateInstance(); + config.SheetName = result.SheetName; + config.SourceExcelPath = _dialogXlsmPath; + config.Dialogs = result.Dialogs; + + AssetDatabase.CreateAsset(config, configPath); + totalDialogs += result.Dialogs.Count; + } + + // 保存索引表 + if (byStageConfig.Entries.Count > 0) + { + var indexPath = $"{relativeDir}/DialogInfoByStageConfig.asset"; + AssetDatabase.CreateAsset(byStageConfig, indexPath); + } + + AssetDatabase.SaveAssets(); + + var msg = $"导入完成:\n" + + $"- {dialogResults.Count} 个DialogInfo表\n" + + $"- {totalDialogs} 条对话配置\n" + + $"- {byStageConfig.Entries.Count} 条索引映射"; + + EditorUtility.DisplayDialog("成功", msg, "确定"); + Debug.Log($"[ActivityEditor] {msg}"); + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("错误", $"导入失败:{ex.Message}", "确定"); + Debug.LogError($"[ActivityEditor] 导入错误:{ex}"); + } + } + + #endregion + + #region LUT配置标签页 + + private void DrawLutTab() + { + EditorGUILayout.LabelField("LUT配置导入", EditorStyles.boldLabel); + + EditorGUILayout.HelpBox( + "LUT配置(UI、Camera、FX、MapInfo)通常在关卡配置导入时一并处理。\n" + + "如需单独导入,请选择包含LUT表的Excel文件。\n" + + "提示:'玩法编辑器.xlsx' 是设计规范文档,实际导入请使用策划填写的数据表(.xlsm 或 .xlsx)。", + MessageType.Info); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("导入LUT配置", GUILayout.Height(36))) + ImportLutConfig(); + if (GUILayout.Button("验证现有LUT", GUILayout.Height(36), GUILayout.Width(120))) + ValidateExistingLuts(); + EditorGUILayout.EndHorizontal(); + } + + private void ImportLutConfig() + { + var path = EditorUtility.OpenFilePanel("选择LUT配置Excel", "表", "xlsm,xlsx"); + if (string.IsNullOrEmpty(path)) return; + + try + { + var parser = new LutConfigParser(); + var database = parser.ParseAllLuts(path); + + if (database.UiLuts.Count == 0 && database.CameraLuts.Count == 0 && + database.FxLuts.Count == 0 && database.MapInfoLuts.Count == 0) + { + EditorUtility.DisplayDialog("警告", "未找到任何LUT配置", "确定"); + return; + } + + var outputDir = EditorUtility.SaveFolderPanel("选择保存目录", "Assets/Configs", ""); + if (string.IsNullOrEmpty(outputDir)) return; + + var normalizedOutput = outputDir.Replace("\\", "/"); + var normalizedDataPath = Application.dataPath.Replace("\\", "/"); + var relativeDir = "Assets" + normalizedOutput.Substring(normalizedDataPath.Length); + + // 保存数据库 + var dbPath = $"{relativeDir}/LutDatabase.asset"; + AssetDatabase.CreateAsset(database, dbPath); + AssetDatabase.SaveAssets(); + + var msg = $"LUT导入完成:\n" + + $"- UI LUT: {database.UiLuts.Count}\n" + + $"- Camera LUT: {database.CameraLuts.Count}\n" + + $"- FX LUT: {database.FxLuts.Count}\n" + + $"- MapInfo LUT: {database.MapInfoLuts.Count}"; + + EditorUtility.DisplayDialog("成功", msg, "确定"); + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("错误", $"导入失败:{ex.Message}", "确定"); + Debug.LogError($"[ActivityEditor] LUT导入错误:{ex}"); + } + } + + private void ValidateExistingLuts() + { + var report = ConfigValidator.ValidateAll(); + + // 只显示LUT相关结果 + var lutResults = report.Results.Where(r => + r.Category.Contains("LUT") || + r.Category.Contains("UI") || + r.Category.Contains("Camera") || + r.Category.Contains("FX")).ToList(); + + if (lutResults.Count == 0) + { + EditorUtility.DisplayDialog("验证完成", "未发现LUT配置或所有LUT配置正常", "确定"); + } + else + { + var errors = lutResults.Count(r => r.IsError); + var warnings = lutResults.Count(r => r.IsWarning); + EditorUtility.DisplayDialog("验证完成", + $"LUT验证结果:\n错误: {errors}\n警告: {warnings}\n\n详细结果请查看Console", + "确定"); + + foreach (var result in lutResults) + { + if (result.IsError) + Debug.LogError($"[LUT] {result.Message}"); + else if (result.IsWarning) + Debug.LogWarning($"[LUT] {result.Message}"); + } + } + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/ActivityEditorWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/ActivityEditorWindow.cs.meta new file mode 100644 index 0000000..7779c30 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/ActivityEditorWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8f67d4db33ff18b4bb44b95910a59bb6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/ActivityStageConfigEditor.cs b/Assets/BP_Scripts/GameplayEditor/Editor/ActivityStageConfigEditor.cs new file mode 100644 index 0000000..aeeed3e --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/ActivityStageConfigEditor.cs @@ -0,0 +1,336 @@ +using System.Collections.Generic; +using System.Linq; +using GameplayEditor.Config; +using GameplayEditor.Core; +using GameplayEditor.Excel; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// 玩法关卡配置编辑器窗口 + /// 用于导入、查看和编辑ActivityStageConfig + /// + public class ActivityStageConfigEditor : EditorWindow + { + private string _xlsmPath = ""; + private string _outputFolder = "Assets/Configs/StageConfigs"; + private Vector2 _scrollPos; + private List _parsedData = new List(); + private ActivityStageConfigDatabase _database; + private bool _showParsedData = false; + private bool _showDatabase = true; + + [MenuItem("Window/Activity Editor/Stage Config")] + public static void ShowWindow() + { + GetWindow("关卡配置"); + } + + private void OnEnable() + { + // 尝试加载现有数据库 + LoadDatabase(); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("玩法关卡配置表编辑器", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + // 导入区域 + EditorGUILayout.LabelField("导入配置", EditorStyles.boldLabel); + + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Excel文件:", GUILayout.Width(80)); + _xlsmPath = EditorGUILayout.TextField(_xlsmPath); + if (GUILayout.Button("浏览", GUILayout.Width(60))) + { + _xlsmPath = EditorUtility.OpenFilePanel("选择Excel文件", "设计文档", "xlsx,xlsm"); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("输出文件夹:", GUILayout.Width(80)); + _outputFolder = EditorGUILayout.TextField(_outputFolder); + if (GUILayout.Button("浏览", GUILayout.Width(60))) + { + var abs = EditorUtility.OpenFolderPanel("选择输出文件夹", "Assets", ""); + if (!string.IsNullOrEmpty(abs)) + { + var normalizedDataPath = Application.dataPath.Replace("\\", "/"); + if (abs.Replace("\\", "/").StartsWith(normalizedDataPath)) + { + _outputFolder = "Assets" + abs.Replace("\\", "/").Substring(normalizedDataPath.Length); + } + } + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("解析预览", GUILayout.Height(30))) + { + ParsePreview(); + } + if (GUILayout.Button("导入并创建资产", GUILayout.Height(30))) + { + ImportAndCreateAssets(); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + // 解析数据预览 + _showParsedData = EditorGUILayout.Foldout(_showParsedData, $"解析预览 ({_parsedData.Count} 条)"); + if (_showParsedData && _parsedData.Count > 0) + { + DrawParsedDataPreview(); + } + + EditorGUILayout.Space(); + + // 数据库区域 + _showDatabase = EditorGUILayout.Foldout(_showDatabase, "配置数据库"); + if (_showDatabase) + { + DrawDatabase(); + } + + EditorGUILayout.Space(); + + // 快捷操作 + EditorGUILayout.LabelField("快捷操作", EditorStyles.boldLabel); + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("创建新数据库")) + { + CreateNewDatabase(); + } + if (GUILayout.Button("加载数据库")) + { + LoadDatabaseFromSelection(); + } + EditorGUILayout.EndHorizontal(); + } + + private void DrawParsedDataPreview() + { + _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.Height(200)); + + foreach (var data in _parsedData) + { + EditorGUILayout.BeginVertical(GUI.skin.box); + + EditorGUILayout.LabelField($"StageID: {data.ActivityStageID}", EditorStyles.boldLabel); + EditorGUILayout.LabelField($" 场景: {data.SceneName}"); + EditorGUILayout.LabelField($" 备注: {data.Doc}"); + EditorGUILayout.LabelField($" MapInfo: {data.MapInfo}, CameraID: {data.CameraID}"); + EditorGUILayout.LabelField($" 延迟: {data.CloseLoadingDelay}s, 模式: {data.TickMode}"); + if (data.TickMode == TickRateMode.Normal) + EditorGUILayout.LabelField($" 帧率: {data.TickRate}fps"); + else + EditorGUILayout.LabelField($" 无限制模式 (DeltaTime: {data.UnlimitedDeltaTime}s)"); + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(5); + } + + EditorGUILayout.EndScrollView(); + } + + private void DrawDatabase() + { + _database = EditorGUILayout.ObjectField("配置数据库", _database, + typeof(ActivityStageConfigDatabase), false) as ActivityStageConfigDatabase; + + if (_database != null) + { + EditorGUILayout.LabelField($"配置数量: {_database.Configs.Count}"); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("添加配置")) + { + AddNewConfig(); + } + if (GUILayout.Button("清理无效配置")) + { + CleanupInvalidConfigs(); + } + EditorGUILayout.EndHorizontal(); + + // 显示配置列表 + var configsToRemove = new List(); + + foreach (var config in _database.Configs) + { + if (config == null) continue; + + EditorGUILayout.BeginVertical(GUI.skin.box); + + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField($"Stage {config.ActivityStageID}: {config.SceneName}", + EditorStyles.boldLabel); + + if (GUILayout.Button("删除", GUILayout.Width(50))) + { + configsToRemove.Add(config); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.ObjectField("配置资产", config, typeof(ActivityStageConfig), false); + + EditorGUILayout.EndVertical(); + } + + // 执行删除 + foreach (var config in configsToRemove) + { + _database.Configs.Remove(config); + } + + if (configsToRemove.Count > 0) + { + EditorUtility.SetDirty(_database); + AssetDatabase.SaveAssets(); + } + } + else + { + EditorGUILayout.HelpBox("请创建或加载配置数据库", MessageType.Info); + } + } + + private void ParsePreview() + { + if (string.IsNullOrEmpty(_xlsmPath)) + { + EditorUtility.DisplayDialog("错误", "请选择Excel文件", "确定"); + return; + } + + var parser = new ActivityStageConfigParser(); + _parsedData = parser.ParseFromExcel(_xlsmPath); + + EditorUtility.DisplayDialog("完成", $"解析到 {_parsedData.Count} 条配置", "确定"); + } + + private void ImportAndCreateAssets() + { + if (_parsedData.Count == 0) + { + EditorUtility.DisplayDialog("错误", "请先解析数据", "确定"); + return; + } + + // 确保输出文件夹存在 + if (!AssetDatabase.IsValidFolder(_outputFolder)) + { + var parentFolder = System.IO.Path.GetDirectoryName(_outputFolder).Replace('\\', '/'); + var folderName = System.IO.Path.GetFileName(_outputFolder); + AssetDatabase.CreateFolder(parentFolder, folderName); + } + + // 创建资产 + var parser = new ActivityStageConfigParser(); + var configs = parser.CreateAssets(_parsedData, _outputFolder); + + // 添加到数据库 + if (_database == null) + { + CreateNewDatabase(); + } + + foreach (var config in configs) + { + _database.AddOrUpdate(config); + } + + EditorUtility.SetDirty(_database); + AssetDatabase.SaveAssets(); + + EditorUtility.DisplayDialog("完成", $"成功创建 {configs.Count} 个配置资产", "确定"); + } + + private void CreateNewDatabase() + { + var path = EditorUtility.SaveFilePanelInProject( + "保存配置数据库", + "StageConfigDatabase", + "asset", + "", + "Assets/Configs" + ); + + if (!string.IsNullOrEmpty(path)) + { + _database = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(_database, path); + AssetDatabase.SaveAssets(); + + EditorUtility.DisplayDialog("完成", $"数据库已创建: {path}", "确定"); + } + } + + private void LoadDatabase() + { + // 尝试在Assets中查找现有数据库 + var guids = AssetDatabase.FindAssets("t:ActivityStageConfigDatabase"); + if (guids.Length > 0) + { + var path = AssetDatabase.GUIDToAssetPath(guids[0]); + _database = AssetDatabase.LoadAssetAtPath(path); + } + } + + private void LoadDatabaseFromSelection() + { + var selected = Selection.GetFiltered(SelectionMode.Assets); + if (selected.Length > 0) + { + _database = selected[0]; + } + else + { + EditorUtility.DisplayDialog("错误", "请在Project窗口中选择一个配置数据库", "确定"); + } + } + + private void AddNewConfig() + { + var config = ScriptableObject.CreateInstance(); + config.ActivityStageID = GetNextAvailableStageID(); + + var path = $"{_outputFolder}/StageConfig_{config.ActivityStageID}.asset"; + AssetDatabase.CreateAsset(config, path); + + _database.Configs.Add(config); + EditorUtility.SetDirty(_database); + AssetDatabase.SaveAssets(); + } + + private int GetNextAvailableStageID() + { + if (_database.Configs.Count == 0) return 1001; + + var maxId = _database.Configs + .Where(c => c != null) + .Max(c => c.ActivityStageID); + + return maxId + 1; + } + + private void CleanupInvalidConfigs() + { + var invalidConfigs = _database.Configs.Where(c => c == null).ToList(); + foreach (var config in invalidConfigs) + { + _database.Configs.Remove(config); + } + + EditorUtility.SetDirty(_database); + AssetDatabase.SaveAssets(); + + EditorUtility.DisplayDialog("完成", $"清理了 {invalidConfigs.Count} 个无效配置", "确定"); + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/ActivityStageConfigEditor.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/ActivityStageConfigEditor.cs.meta new file mode 100644 index 0000000..79b5cff --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/ActivityStageConfigEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 15b8374e7900fe544a96e62aacb61b06 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/BTAssetCleaner.cs b/Assets/BP_Scripts/GameplayEditor/Editor/BTAssetCleaner.cs new file mode 100644 index 0000000..b12c7b0 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/BTAssetCleaner.cs @@ -0,0 +1,141 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NodeCanvas.BehaviourTrees; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// 行为树资产清理工具 + /// 用于修复或清理损坏的行为树资产 + /// + public class BTAssetCleaner : EditorWindow + { + private string _targetFolder = "Assets/BT"; + private bool _showDetails = true; + private List _issues = new List(); + + [MenuItem("Window/Activity Editor/BT Asset Cleaner")] + public static void ShowWindow() + { + GetWindow("BT资产清理"); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("行为树资产清理工具", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + EditorGUILayout.LabelField("目标文件夹:", EditorStyles.boldLabel); + EditorGUILayout.BeginHorizontal(); + _targetFolder = EditorGUILayout.TextField(_targetFolder); + if (GUILayout.Button("浏览", GUILayout.Width(60))) + { + var folder = EditorUtility.OpenFolderPanel("选择BT文件夹", "Assets", ""); + if (!string.IsNullOrEmpty(folder)) + { + _targetFolder = "Assets" + folder.Replace(Application.dataPath, "").Replace("\\", "/"); + } + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + if (GUILayout.Button("扫描问题资产", GUILayout.Height(30))) + { + ScanAssets(); + } + + if (GUILayout.Button("删除所有BT资产", GUILayout.Height(30))) + { + if (EditorUtility.DisplayDialog("确认删除", + $"这将删除 {_targetFolder} 下的所有BehaviourTree资产,是否继续?", "删除", "取消")) + { + DeleteAllBTAssets(); + } + } + + EditorGUILayout.Space(); + + _showDetails = EditorGUILayout.ToggleLeft("显示详细信息", _showDetails); + + if (_showDetails && _issues.Count > 0) + { + EditorGUILayout.LabelField("扫描结果:", EditorStyles.boldLabel); + foreach (var issue in _issues) + { + EditorGUILayout.LabelField($"• {issue}", EditorStyles.miniLabel); + } + } + + EditorGUILayout.Space(); + EditorGUILayout.HelpBox( + "如果遇到序列化错误(Cannot create an instance of an interface or abstract type),\n" + + "通常是因为旧的行为树资产引用了已不存在的Task类型。\n" + + "建议删除旧资产后重新从Excel导入。", + MessageType.Info); + } + + private void ScanAssets() + { + _issues.Clear(); + + if (!AssetDatabase.IsValidFolder(_targetFolder)) + { + _issues.Add($"文件夹不存在: {_targetFolder}"); + return; + } + + var guids = AssetDatabase.FindAssets("t:BehaviourTree", new[] { _targetFolder }); + + foreach (var guid in guids) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + var bt = AssetDatabase.LoadAssetAtPath(path); + + if (bt == null) + { + _issues.Add($"无法加载: {path}"); + } + else if (bt.primeNode == null) + { + _issues.Add($"根节点为空: {path}"); + } + } + + if (_issues.Count == 0) + { + _issues.Add($"扫描完成,共 {guids.Length} 个行为树资产,未发现明显问题"); + } + } + + private void DeleteAllBTAssets() + { + if (!AssetDatabase.IsValidFolder(_targetFolder)) + { + EditorUtility.DisplayDialog("错误", "文件夹不存在", "确定"); + return; + } + + var guids = AssetDatabase.FindAssets("t:BehaviourTree", new[] { _targetFolder }); + int deleted = 0; + + foreach (var guid in guids) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + if (AssetDatabase.DeleteAsset(path)) + { + deleted++; + } + } + + AssetDatabase.SaveAssets(); + EditorUtility.DisplayDialog("完成", $"已删除 {deleted} 个行为树资产", "确定"); + + _issues.Clear(); + _issues.Add($"已删除 {deleted} 个资产"); + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/BTAssetCleaner.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/BTAssetCleaner.cs.meta new file mode 100644 index 0000000..f5323c4 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/BTAssetCleaner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c28abfdbaee9ded42b7bbc46ab9adb3b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/BTDebugWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/BTDebugWindow.cs new file mode 100644 index 0000000..6edca66 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/BTDebugWindow.cs @@ -0,0 +1,586 @@ +using GameplayEditor.Core; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// 行为树调试窗口 + /// 提供断点管理、单步执行、变量查看、执行历史等功能 + /// + public class BTDebugWindow : EditorWindow + { + private Vector2 _nodeListScrollPos; + private Vector2 _historyScrollPos; + private Vector2 _variablesScrollPos; + private Vector2 _breakpointScrollPos; + + // 调试目标 + private BehaviourTree _targetTree; + private BehaviourTreeController _targetController; + private BTDebugger _debugger; + + // 搜索过滤 + private string _nodeSearchFilter = ""; + private string _variableSearchFilter = ""; + + // 折叠状态 + private bool _showNodeList = true; + private bool _showBreakpoints = true; + private bool _showVariables = true; + private bool _showHistory = true; + private bool _showControls = true; + + // 选中的节点 + private Node _selectedNode; + + // 自动刷新 + private bool _autoRefresh = true; + private double _lastRefreshTime; + private const float REFRESH_INTERVAL = 0.1f; + + // 样式缓存 + private GUIStyle _breakpointStyle; + private GUIStyle _currentNodeStyle; + private GUIStyle _executedNodeStyle; + private GUIStyle _toolbarButtonStyle; + private bool _stylesInitialized = false; + + [MenuItem("Window/Activity Editor/Behaviour Tree Debugger")] + public static void ShowWindow() + { + var window = GetWindow("BT Debugger"); + window.minSize = new Vector2(400, 600); + window.Show(); + } + + private void OnEnable() + { + EditorApplication.update += OnEditorUpdate; + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + } + + private void OnDisable() + { + EditorApplication.update -= OnEditorUpdate; + EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; + } + + private void InitializeStyles() + { + if (_stylesInitialized) return; + + _breakpointStyle = new GUIStyle(EditorStyles.label); + _breakpointStyle.normal.textColor = Color.red; + _breakpointStyle.fontStyle = FontStyle.Bold; + + _currentNodeStyle = new GUIStyle(EditorStyles.label); + _currentNodeStyle.normal.textColor = Color.yellow; + _currentNodeStyle.fontStyle = FontStyle.Bold; + + _executedNodeStyle = new GUIStyle(EditorStyles.label); + _executedNodeStyle.normal.textColor = Color.green; + + _toolbarButtonStyle = new GUIStyle(EditorStyles.toolbarButton); + _toolbarButtonStyle.fontSize = 12; + + _stylesInitialized = true; + } + + private void OnEditorUpdate() + { + if (!_autoRefresh) return; + if (EditorApplication.timeSinceStartup - _lastRefreshTime < REFRESH_INTERVAL) return; + + _lastRefreshTime = EditorApplication.timeSinceStartup; + + // 自动查找调试器 + if (_debugger == null) + { + _debugger = BTDebugger.Instance; + } + + // 自动查找控制器 + if (_targetController == null && Application.isPlaying) + { + _targetController = FindObjectOfType(); + if (_targetController != null) + { + _targetTree = _targetController.BodyTree; + _debugger?.SetTarget(_targetTree); + } + } + + Repaint(); + } + + private void OnPlayModeStateChanged(PlayModeStateChange state) + { + if (state == PlayModeStateChange.ExitingPlayMode) + { + _targetController = null; + _targetTree = null; + } + } + + private void OnGUI() + { + InitializeStyles(); + + DrawToolbar(); + + if (!Application.isPlaying) + { + DrawNotPlayingMessage(); + return; + } + + if (_debugger == null) + { + DrawNoDebuggerMessage(); + return; + } + + DrawDebugControls(); + DrawBreakpointPanel(); + DrawNodeList(); + DrawVariablesPanel(); + DrawHistoryPanel(); + } + + #region 工具栏 + + private void DrawToolbar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + if (GUILayout.Button("刷新", EditorStyles.toolbarButton, GUILayout.Width(50))) + { + RefreshDebugInfo(); + } + + _autoRefresh = GUILayout.Toggle(_autoRefresh, "自动刷新", EditorStyles.toolbarButton, GUILayout.Width(70)); + + GUILayout.FlexibleSpace(); + + // 调试状态指示 + if (_debugger != null) + { + var statusColor = _debugger.IsPaused() ? Color.red : Color.green; + var statusText = _debugger.IsPaused() ? "[已暂停]" : "[运行中]"; + + var prevColor = GUI.color; + GUI.color = statusColor; + GUILayout.Label(statusText, EditorStyles.toolbarButton, GUILayout.Width(60)); + GUI.color = prevColor; + } + + EditorGUILayout.EndHorizontal(); + } + + #endregion + + #region 调试控制 + + private void DrawDebugControls() + { + _showControls = EditorGUILayout.Foldout(_showControls, "▶ 调试控制", EditorStyles.foldoutHeader); + if (!_showControls) return; + + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + // 调试目标选择 + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("调试目标:", GUILayout.Width(70)); + var newTree = EditorGUILayout.ObjectField(_targetTree, typeof(BehaviourTree), true) as BehaviourTree; + if (newTree != _targetTree) + { + _targetTree = newTree; + _debugger?.SetTarget(_targetTree); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(5); + + // 控制按钮 + EditorGUILayout.BeginHorizontal(); + + // 继续/暂停按钮 + if (_debugger.IsPaused()) + { + if (GUILayout.Button("▶ 继续", GUILayout.Height(30))) + { + _debugger.Resume(); + } + } + else + { + if (GUILayout.Button("⏸ 暂停", GUILayout.Height(30))) + { + _debugger.Pause(); + } + } + + // 单步执行 + GUI.enabled = _debugger.IsPaused(); + if (GUILayout.Button("⏯ 单步执行", GUILayout.Height(30))) + { + _debugger.Step(); + } + GUI.enabled = true; + + // 清除断点 + if (GUILayout.Button("清除断点", GUILayout.Height(30))) + { + _debugger.ClearBreakpoints(); + } + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(5); + } + + #endregion + + #region 断点面板 + + private void DrawBreakpointPanel() + { + _showBreakpoints = EditorGUILayout.Foldout(_showBreakpoints, "🔴 断点列表", EditorStyles.foldoutHeader); + if (!_showBreakpoints) return; + + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + var breakpoints = _debugger.GetAllNodeInfo() + .Where(kvp => kvp.Value.IsBreakpoint) + .Select(kvp => kvp.Value) + .ToList(); + + if (breakpoints.Count == 0) + { + EditorGUILayout.LabelField("暂无断点", EditorStyles.centeredGreyMiniLabel); + } + else + { + _breakpointScrollPos = EditorGUILayout.BeginScrollView(_breakpointScrollPos, GUILayout.MaxHeight(150)); + + foreach (var bp in breakpoints) + { + EditorGUILayout.BeginHorizontal(); + + GUILayout.Label("🔴", GUILayout.Width(20)); + EditorGUILayout.LabelField(bp.NodeName, GUILayout.Width(150)); + + if (!string.IsNullOrEmpty(bp.Condition)) + { + EditorGUILayout.LabelField($"当: {bp.Condition}", EditorStyles.miniLabel); + } + + if (GUILayout.Button("删除", GUILayout.Width(50))) + { + // 需要节点引用才能删除,这里只是视觉反馈 + } + + EditorGUILayout.EndHorizontal(); + } + + EditorGUILayout.EndScrollView(); + } + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(5); + } + + #endregion + + #region 节点列表 + + private void DrawNodeList() + { + _showNodeList = EditorGUILayout.Foldout(_showNodeList, "📋 节点列表", EditorStyles.foldoutHeader); + if (!_showNodeList) return; + + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + // 搜索框 + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("搜索:", GUILayout.Width(40)); + _nodeSearchFilter = EditorGUILayout.TextField(_nodeSearchFilter); + if (GUILayout.Button("清除", GUILayout.Width(40))) + { + _nodeSearchFilter = ""; + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(5); + + // 节点列表 + _nodeListScrollPos = EditorGUILayout.BeginScrollView(_nodeListScrollPos, GUILayout.MinHeight(200)); + + var nodeInfos = _debugger.GetAllNodeInfo(); + var currentNode = _debugger.GetCurrentNode(); + + foreach (var kvp in nodeInfos) + { + var info = kvp.Value; + + // 搜索过滤 + if (!string.IsNullOrEmpty(_nodeSearchFilter) && + !info.NodeName.ToLower().Contains(_nodeSearchFilter.ToLower())) + { + continue; + } + + DrawNodeItem(info, currentNode); + } + + EditorGUILayout.EndScrollView(); + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(5); + } + + private void DrawNodeItem(BTDebugger.NodeDebugInfo info, Node currentNode) + { + EditorGUILayout.BeginHorizontal(); + + // 断点复选框 + bool wasBreakpoint = info.IsBreakpoint; + bool isBreakpoint = GUILayout.Toggle(wasBreakpoint, "", GUILayout.Width(20)); + + // 断点状态改变 + if (isBreakpoint != wasBreakpoint) + { + // 需要找到对应的Node对象才能设置断点 + // 这里简化处理,仅视觉反馈 + info.IsBreakpoint = isBreakpoint; + } + + // 节点名称(根据状态使用不同颜色) + GUIStyle nameStyle = EditorStyles.label; + if (info.NodeId == $"{currentNode?.GetType().Name}_{currentNode?.name}_{currentNode?.GetHashCode()}") + { + nameStyle = _currentNodeStyle; + GUILayout.Label("▶", GUILayout.Width(15)); + } + else if (info.ExecutionCount > 0) + { + nameStyle = _executedNodeStyle; + GUILayout.Label(" ", GUILayout.Width(15)); + } + else + { + GUILayout.Label(" ", GUILayout.Width(15)); + } + + if (info.IsBreakpoint) + { + nameStyle = _breakpointStyle; + } + + EditorGUILayout.LabelField(info.NodeName, nameStyle, GUILayout.Width(150)); + + // 执行次数 + if (info.ExecutionCount > 0) + { + EditorGUILayout.LabelField($"x{info.ExecutionCount}", EditorStyles.miniLabel, GUILayout.Width(40)); + } + else + { + GUILayout.Space(40); + } + + // 上次结果 + if (info.LastStatus != Status.Resting) + { + var statusColor = info.LastStatus == Status.Success ? Color.green : + info.LastStatus == Status.Failure ? Color.red : Color.yellow; + var prevColor = GUI.color; + GUI.color = statusColor; + EditorGUILayout.LabelField(info.LastStatus.ToString(), EditorStyles.miniLabel, GUILayout.Width(60)); + GUI.color = prevColor; + } + + EditorGUILayout.EndHorizontal(); + } + + #endregion + + #region 变量面板 + + private void DrawVariablesPanel() + { + _showVariables = EditorGUILayout.Foldout(_showVariables, "🔍 黑板变量", EditorStyles.foldoutHeader); + if (!_showVariables) return; + + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + // 获取黑板 + var blackboard = _targetTree?.blackboard; + if (blackboard == null) + { + EditorGUILayout.LabelField("无黑板数据", EditorStyles.centeredGreyMiniLabel); + } + else + { + // 搜索框 + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("搜索:", GUILayout.Width(40)); + _variableSearchFilter = EditorGUILayout.TextField(_variableSearchFilter); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(5); + + // 变量列表 + _variablesScrollPos = EditorGUILayout.BeginScrollView(_variablesScrollPos, GUILayout.MaxHeight(200)); + + var variables = blackboard.variables; + if (variables != null) + { + foreach (var kvp in variables) + { + var variableName = kvp.Key; + var variable = kvp.Value; + if (variable == null) continue; + + // 搜索过滤 + if (!string.IsNullOrEmpty(_variableSearchFilter) && + !variableName.ToLower().Contains(_variableSearchFilter.ToLower())) + { + continue; + } + + DrawVariableItem(variableName, variable); + } + } + + EditorGUILayout.EndScrollView(); + } + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(5); + } + + private void DrawVariableItem(string variableName, Variable variable) + { + EditorGUILayout.BeginHorizontal(); + + // 变量名 + EditorGUILayout.LabelField(variableName, GUILayout.Width(150)); + + // 类型 + EditorGUILayout.LabelField(variable.varType.Name, EditorStyles.miniLabel, GUILayout.Width(100)); + + // 值 + string valueStr = variable.value?.ToString() ?? "null"; + if (valueStr.Length > 30) + { + valueStr = valueStr.Substring(0, 30) + "..."; + } + EditorGUILayout.LabelField(valueStr, EditorStyles.miniLabel); + + EditorGUILayout.EndHorizontal(); + } + + #endregion + + #region 历史面板 + + private void DrawHistoryPanel() + { + _showHistory = EditorGUILayout.Foldout(_showHistory, "📜 执行历史", EditorStyles.foldoutHeader); + if (!_showHistory) return; + + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + var history = _debugger.GetExecutionHistory().ToList(); + + if (history.Count == 0) + { + EditorGUILayout.LabelField("暂无执行记录", EditorStyles.centeredGreyMiniLabel); + } + else + { + _historyScrollPos = EditorGUILayout.BeginScrollView(_historyScrollPos, GUILayout.MaxHeight(150)); + + // 倒序显示,最新的在前 + for (int i = history.Count - 1; i >= 0; i--) + { + var record = history[i]; + DrawHistoryItem(record); + } + + EditorGUILayout.EndScrollView(); + } + + EditorGUILayout.EndVertical(); + } + + private void DrawHistoryItem(BTDebugger.ExecutionRecord record) + { + EditorGUILayout.BeginHorizontal(); + + // 时间 + EditorGUILayout.LabelField($"{record.Time:F2}s", EditorStyles.miniLabel, GUILayout.Width(60)); + + // 节点名 + EditorGUILayout.LabelField(record.NodeName, GUILayout.Width(120)); + + // 结果 + var statusColor = record.Result == Status.Success ? Color.green : + record.Result == Status.Failure ? Color.red : Color.yellow; + var prevColor = GUI.color; + GUI.color = statusColor; + EditorGUILayout.LabelField(record.Result.ToString(), EditorStyles.miniLabel, GUILayout.Width(60)); + GUI.color = prevColor; + + EditorGUILayout.EndHorizontal(); + } + + #endregion + + #region 消息面板 + + private void DrawNotPlayingMessage() + { + EditorGUILayout.Space(50); + EditorGUILayout.HelpBox("请在Play模式下使用调试器", MessageType.Info); + + if (GUILayout.Button("进入Play模式", GUILayout.Height(40))) + { + EditorApplication.isPlaying = true; + } + } + + private void DrawNoDebuggerMessage() + { + EditorGUILayout.Space(50); + EditorGUILayout.HelpBox("未找到BTDebugger组件\n请确保场景中有挂载BTDebugger的对象", MessageType.Warning); + + if (GUILayout.Button("自动添加调试器", GUILayout.Height(40))) + { + var go = new GameObject("BTDebugger"); + go.AddComponent(); + } + } + + #endregion + + #region 私有方法 + + private void RefreshDebugInfo() + { + if (_debugger != null && _targetTree != null) + { + _debugger.SetTarget(_targetTree); + } + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/BTDebugWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/BTDebugWindow.cs.meta new file mode 100644 index 0000000..a67cf37 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/BTDebugWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 45bb1bef2f13f0d429cae1c3d863a19d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/BTErrorHighlighter.cs b/Assets/BP_Scripts/GameplayEditor/Editor/BTErrorHighlighter.cs new file mode 100644 index 0000000..fba271d --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/BTErrorHighlighter.cs @@ -0,0 +1,300 @@ +using System.Collections.Generic; +using System.Linq; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// 行为树错误高亮系统 + /// 在NodeCanvas编辑器中高亮显示报错的节点 + /// + public class BTErrorHighlighter : EditorWindow + { + // 错误节点记录 + private static readonly Dictionary _errorNodes = new Dictionary(); + + // 高亮设置 + private static readonly Color ERROR_COLOR = new Color(1f, 0.2f, 0.2f, 0.8f); + private static readonly Color WARNING_COLOR = new Color(1f, 0.8f, 0.2f, 0.8f); + + // 是否启用高亮 + private bool _enableHighlighting = true; + + // 滚动位置 + private Vector2 _scrollPosition; + + [MenuItem("Window/Activity Editor/Error Highlighter")] + public static void ShowWindow() + { + var window = GetWindow("BT错误高亮"); + window.minSize = new Vector2(400, 300); + window.Show(); + } + + private void OnEnable() + { + // 监听错误日志 + Application.logMessageReceived += OnLogMessageReceived; + } + + private void OnDisable() + { + Application.logMessageReceived -= OnLogMessageReceived; + } + + private void OnGUI() + { + EditorGUILayout.LabelField("行为树错误高亮", EditorStyles.boldLabel); + + EditorGUILayout.Space(5); + + // 启用开关 + _enableHighlighting = EditorGUILayout.Toggle("启用高亮", _enableHighlighting); + + EditorGUILayout.Space(10); + + // 工具栏 + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + if (GUILayout.Button("清除所有", EditorStyles.toolbarButton)) + { + ClearAllErrors(); + } + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(10); + + // 错误列表 + _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition); + + foreach (var error in _errorNodes.Values.ToList()) + { + DrawErrorItem(error); + } + + EditorGUILayout.EndScrollView(); + + // 图例 + EditorGUILayout.Space(10); + DrawLegend(); + } + + private void DrawErrorItem(ErrorInfo error) + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + EditorGUILayout.BeginHorizontal(); + + // 状态图标 + GUI.color = error.IsWarning ? WARNING_COLOR : ERROR_COLOR; + GUILayout.Label(error.IsWarning ? "⚠" : "✖", GUILayout.Width(20)); + GUI.color = Color.white; + + // 树名和节点索引 + EditorGUILayout.LabelField($"{error.TreeName}[{error.NodeIndex}]", EditorStyles.boldLabel); + + GUILayout.FlexibleSpace(); + + // 删除按钮 + if (GUILayout.Button("×", GUILayout.Width(20))) + { + RemoveError(error.TreeName, error.NodeIndex); + } + + EditorGUILayout.EndHorizontal(); + + // 错误信息 + EditorGUILayout.LabelField(error.Message, EditorStyles.wordWrappedLabel); + + // 时间 + EditorGUILayout.LabelField($"Time: {error.Timestamp:F2}", EditorStyles.miniLabel); + + EditorGUILayout.EndVertical(); + + EditorGUILayout.Space(3); + } + + private void DrawLegend() + { + EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); + + GUI.color = ERROR_COLOR; + GUILayout.Label("● 错误", GUILayout.Width(60)); + + GUI.color = WARNING_COLOR; + GUILayout.Label("● 警告", GUILayout.Width(60)); + + GUI.color = Color.white; + + EditorGUILayout.EndHorizontal(); + } + + /// + /// 注册错误节点 + /// + public static void RegisterError(string treeName, int nodeIndex, string errorMessage, bool isWarning = false) + { + string key = $"{treeName}_{nodeIndex}"; + + _errorNodes[key] = new ErrorInfo + { + TreeName = treeName, + NodeIndex = nodeIndex, + Message = errorMessage, + IsWarning = isWarning, + Timestamp = Time.time + }; + + Debug.Log($"[BTErrorHighlighter] Registered {(isWarning ? "warning" : "error")}: {treeName}[{nodeIndex}] - {errorMessage}"); + + // 刷新窗口 + var window = GetWindow(); + window.Repaint(); + } + + /// + /// 移除错误 + /// + public static void RemoveError(string treeName, int nodeIndex) + { + string key = $"{treeName}_{nodeIndex}"; + _errorNodes.Remove(key); + } + + /// + /// 清除指定行为树的错误 + /// + public static void ClearErrors(string treeName) + { + var keysToRemove = _errorNodes.Keys + .Where(k => k.StartsWith(treeName + "_")) + .ToList(); + + foreach (var key in keysToRemove) + { + _errorNodes.Remove(key); + } + } + + /// + /// 清除所有错误 + /// + public static void ClearAllErrors() + { + _errorNodes.Clear(); + } + + /// + /// 获取错误信息 + /// + public static ErrorInfo GetErrorInfo(string treeName, int nodeIndex) + { + string key = $"{treeName}_{nodeIndex}"; + return _errorNodes.TryGetValue(key, out var info) ? info : null; + } + + /// + /// 检查节点是否有错误 + /// + public static bool HasError(string treeName, int nodeIndex) + { + string key = $"{treeName}_{nodeIndex}"; + return _errorNodes.ContainsKey(key); + } + + #region 日志监听 + + private void OnLogMessageReceived(string condition, string stackTrace, LogType type) + { + if (type != LogType.Error && type != LogType.Warning) + return; + + if (!condition.Contains("[BT:") && !condition.Contains("BehaviourTree")) + return; + + ParseErrorFromLog(condition, type == LogType.Warning); + } + + private void ParseErrorFromLog(string message, bool isWarning) + { + if (!message.StartsWith("[BT:")) + return; + + try + { + int treeIdStart = message.IndexOf("[BT:") + 4; + int treeIdEnd = message.IndexOf(" ", treeIdStart); + if (treeIdEnd < 0) treeIdEnd = message.IndexOf("]", treeIdStart); + string treeIdStr = message.Substring(treeIdStart, treeIdEnd - treeIdStart); + + int nodeIndex = -1; + int rowIndex = -1; + + int rowIdx = message.IndexOf("Row:"); + if (rowIdx > 0) + { + int rowNumStart = rowIdx + 4; + int rowNumEnd = message.IndexOf("]", rowNumStart); + if (int.TryParse(message.Substring(rowNumStart, rowNumEnd - rowNumStart), out rowIndex)) + { + nodeIndex = FindNodeIndexByRow(treeIdStr, rowIndex); + } + } + else + { + int nodeIdx = message.IndexOf("Node:"); + if (nodeIdx > 0) + { + int nodeNumStart = nodeIdx + 5; + int nodeNumEnd = message.IndexOf("]", nodeNumStart); + int.TryParse(message.Substring(nodeNumStart, nodeNumEnd - nodeNumStart), out nodeIndex); + } + } + + if (nodeIndex >= 0) + { + int msgStart = message.IndexOf("] ") + 2; + string errorMsg = msgStart < message.Length ? message.Substring(msgStart) : message; + RegisterError($"BT_{treeIdStr}", nodeIndex, errorMsg, isWarning); + } + } + catch + { + // 解析失败,忽略 + } + } + + private int FindNodeIndexByRow(string treeId, int rowIndex) + { + var container = GameplayEditor.Core.BehaviourTreeNodeInfoManager.GetContainer($"BT_{treeId}"); + if (container == null) return -1; + + var info = container.NodeInfos.Find(n => n.RowIndex == rowIndex); + if (info != null) + { + return container.NodeInfos.IndexOf(info); + } + + return -1; + } + + #endregion + + #region 数据类 + + public class ErrorInfo + { + public string TreeName; + public int NodeIndex; + public string Message; + public bool IsWarning; + public float Timestamp; + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/BTErrorHighlighter.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/BTErrorHighlighter.cs.meta new file mode 100644 index 0000000..c10ea4c --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/BTErrorHighlighter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b1f54c7164d78f74bad06117f903e727 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/BTPerformanceWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/BTPerformanceWindow.cs new file mode 100644 index 0000000..b649c93 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/BTPerformanceWindow.cs @@ -0,0 +1,318 @@ +using System.Collections.Generic; +using System.Linq; +using GameplayEditor.Core; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// 行为树性能监控窗口 + /// 显示实时性能数据、热点节点、优化建议 + /// + public class BTPerformanceWindow : EditorWindow + { + private BTPerformanceMonitor _monitor; + private Vector2 _scrollPosition; + private bool _autoRefresh = true; + private float _lastRefreshTime; + private const float REFRESH_INTERVAL = 0.5f; + + // 图表数据 + private Queue _fpsHistory = new Queue(100); + private Queue _executionTimeHistory = new Queue(100); + private Vector2 _graphScrollPos; + + [MenuItem("Window/Activity Editor/Performance Monitor")] + public static void ShowWindow() + { + var window = GetWindow("BT性能监控"); + window.minSize = new Vector2(600, 500); + window.Show(); + } + + private void OnEnable() + { + _monitor = FindObjectOfType(); + } + + private void Update() + { + if (_autoRefresh && Time.time - _lastRefreshTime > REFRESH_INTERVAL) + { + _lastRefreshTime = Time.time; + + // 记录FPS历史 + if (_monitor != null) + { + _fpsHistory.Enqueue(_monitor.GetCurrentFPS()); + if (_fpsHistory.Count > 100) + _fpsHistory.Dequeue(); + } + + Repaint(); + } + } + + private void OnGUI() + { + EditorGUILayout.LabelField("行为树性能监控", EditorStyles.boldLabel); + + if (_monitor == null) + { + _monitor = FindObjectOfType(); + } + + if (_monitor == null) + { + EditorGUILayout.HelpBox("未找到BTPerformanceMonitor", MessageType.Warning); + if (GUILayout.Button("创建Monitor")) + { + var go = new GameObject("BTPerformanceMonitor"); + go.AddComponent(); + } + return; + } + + // 工具栏 + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + _monitor.EnableMonitoring = GUILayout.Toggle(_monitor.EnableMonitoring, "启用监控", EditorStyles.toolbarButton); + _autoRefresh = GUILayout.Toggle(_autoRefresh, "自动刷新", EditorStyles.toolbarButton); + if (GUILayout.Button("刷新", EditorStyles.toolbarButton)) Repaint(); + if (GUILayout.Button("开始记录", EditorStyles.toolbarButton)) _monitor.StartRecording(); + if (GUILayout.Button("停止并生成报告", EditorStyles.toolbarButton)) _monitor.StopRecording(); + if (GUILayout.Button("清除", EditorStyles.toolbarButton)) _monitor.StartRecording(); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + // 概览统计 + DrawOverview(); + + EditorGUILayout.Space(); + + // 性能图表 + DrawPerformanceGraph(); + + EditorGUILayout.Space(); + + // 热点节点 + DrawHotNodes(); + + EditorGUILayout.Space(); + + // 优化建议 + DrawOptimizationSuggestions(); + } + + private void DrawOverview() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + EditorGUILayout.LabelField("性能概览", EditorStyles.boldLabel); + + var report = _monitor.GetCurrentReport(); + + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField($"总节点执行: {report.TotalNodeExecutions}", GUILayout.Width(150)); + EditorGUILayout.LabelField($"警告数: {report.WarningCount}", GUILayout.Width(100)); + EditorGUILayout.LabelField($"当前FPS: {_monitor.GetCurrentFPS():F1}", GUILayout.Width(100)); + EditorGUILayout.EndHorizontal(); + + if (report.Duration > 0) + { + EditorGUILayout.LabelField($"记录时长: {report.Duration:F1}秒", EditorStyles.miniLabel); + } + + EditorGUILayout.EndVertical(); + } + + private void DrawPerformanceGraph() + { + EditorGUILayout.LabelField("性能趋势", EditorStyles.boldLabel); + + var rect = EditorGUILayout.GetControlRect(false, 100); + EditorGUI.DrawRect(rect, new Color(0.1f, 0.1f, 0.1f, 0.5f)); + + if (_fpsHistory.Count > 1) + { + // 绘制FPS曲线 + var fpsArray = _fpsHistory.ToArray(); + float maxFPS = Mathf.Max(fpsArray.Max(), 60); + + Handles.color = Color.green; + for (int i = 1; i < fpsArray.Length; i++) + { + float x1 = rect.x + (i - 1) * (rect.width / 100); + float x2 = rect.x + i * (rect.width / 100); + float y1 = rect.y + rect.height - (fpsArray[i - 1] / maxFPS) * rect.height; + float y2 = rect.y + rect.height - (fpsArray[i] / maxFPS) * rect.height; + Handles.DrawLine(new Vector3(x1, y1), new Vector3(x2, y2)); + } + + // 绘制参考线 (30fps, 60fps) + Handles.color = new Color(1, 1, 0, 0.3f); + float y60 = rect.y + rect.height - (60 / maxFPS) * rect.height; + Handles.DrawLine(new Vector3(rect.x, y60), new Vector3(rect.x + rect.width, y60)); + + Handles.color = new Color(1, 0, 0, 0.3f); + float y30 = rect.y + rect.height - (30 / maxFPS) * rect.height; + Handles.DrawLine(new Vector3(rect.x, y30), new Vector3(rect.x + rect.width, y30)); + } + + EditorGUILayout.LabelField("绿色=FPS, 黄线=60fps, 红线=30fps", EditorStyles.miniLabel); + } + + private void DrawHotNodes() + { + EditorGUILayout.LabelField("热点节点", EditorStyles.boldLabel); + + var report = _monitor.GetCurrentReport(); + + _scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, GUILayout.Height(200)); + + // 表头 + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + EditorGUILayout.LabelField("节点名称", GUILayout.Width(200)); + EditorGUILayout.LabelField("执行次数", GUILayout.Width(80)); + EditorGUILayout.LabelField("平均耗时", GUILayout.Width(80)); + EditorGUILayout.LabelField("最大耗时", GUILayout.Width(80)); + EditorGUILayout.LabelField("警告", GUILayout.Width(50)); + EditorGUILayout.EndHorizontal(); + + foreach (var node in report.HotNodes) + { + var style = node.WarningCount > 0 ? new GUIStyle(GUI.skin.box) { normal = { background = MakeTex(2, 2, new Color(1, 0.8f, 0.8f)) } } : GUI.skin.box; + + EditorGUILayout.BeginHorizontal(style); + EditorGUILayout.LabelField(node.NodeName, GUILayout.Width(200)); + EditorGUILayout.LabelField($"{node.TotalExecutions}", GUILayout.Width(80)); + EditorGUILayout.LabelField($"{node.AverageExecutionTimeMs:F1}ms", GUILayout.Width(80)); + EditorGUILayout.LabelField($"{node.MaxExecutionTimeMs:F1}ms", GUILayout.Width(80)); + + if (node.WarningCount > 0) + { + GUI.color = Color.red; + EditorGUILayout.LabelField($"⚠ {node.WarningCount}", GUILayout.Width(50)); + GUI.color = Color.white; + } + else + { + EditorGUILayout.LabelField("-", GUILayout.Width(50)); + } + + EditorGUILayout.EndHorizontal(); + } + + EditorGUILayout.EndScrollView(); + } + + private void DrawOptimizationSuggestions() + { + var report = _monitor.GetCurrentReport(); + var suggestions = GenerateOptimizationSuggestions(report); + + if (suggestions.Count > 0) + { + EditorGUILayout.LabelField("优化建议", EditorStyles.boldLabel); + + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + foreach (var suggestion in suggestions) + { + var color = suggestion.Severity == Severity.High ? Color.red : + suggestion.Severity == Severity.Medium ? Color.yellow : Color.white; + + GUI.color = color; + EditorGUILayout.LabelField($"• {suggestion.Message}"); + GUI.color = Color.white; + + if (!string.IsNullOrEmpty(suggestion.Details)) + { + EditorGUILayout.LabelField($" {suggestion.Details}", EditorStyles.miniLabel); + } + } + + EditorGUILayout.EndVertical(); + } + } + + private List GenerateOptimizationSuggestions(BTPerformanceMonitor.PerformanceReport report) + { + var suggestions = new List(); + + // 检查高频执行节点 + foreach (var node in report.HotNodes) + { + if (node.TotalExecutions > 1000) + { + suggestions.Add(new OptimizationSuggestion + { + Message = $"节点 '{node.NodeName}' 执行次数过多 ({node.TotalExecutions}次)", + Details = "建议检查是否有死循环或不必要的重复执行", + Severity = Severity.High + }); + } + + if (node.AverageExecutionTimeMs > 5) + { + suggestions.Add(new OptimizationSuggestion + { + Message = $"节点 '{node.NodeName}' 平均执行时间过长 ({node.AverageExecutionTimeMs:F1}ms)", + Details = "建议优化节点逻辑或降低执行频率", + Severity = Severity.Medium + }); + } + } + + // 检查FPS + var avgFPS = _fpsHistory.Count > 0 ? _fpsHistory.Average() : 60; + if (avgFPS < 30) + { + suggestions.Add(new OptimizationSuggestion + { + Message = "FPS过低,建议降低行为树Tick率", + Details = "可以在BehaviourTreeController中调整TickRate", + Severity = Severity.High + }); + } + + // 检查警告 + if (report.WarningCount > 10) + { + suggestions.Add(new OptimizationSuggestion + { + Message = "性能警告过多,建议检查高消耗操作", + Details = "避免在行为树中频繁调用SetPosition/SetActive等", + Severity = Severity.Medium + }); + } + + return suggestions; + } + + private Texture2D MakeTex(int width, int height, Color col) + { + Color[] pix = new Color[width * height]; + for (int i = 0; i < pix.Length; i++) + pix[i] = col; + Texture2D result = new Texture2D(width, height); + result.SetPixels(pix); + result.Apply(); + return result; + } + + private class OptimizationSuggestion + { + public string Message; + public string Details; + public Severity Severity; + } + + private enum Severity + { + Low, + Medium, + High + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/BTPerformanceWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/BTPerformanceWindow.cs.meta new file mode 100644 index 0000000..f08c04a --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/BTPerformanceWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1404b7bf209474d4b867c3e3a3bb5940 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/BTRuntimeVisualizerWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/BTRuntimeVisualizerWindow.cs new file mode 100644 index 0000000..3d1fd80 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/BTRuntimeVisualizerWindow.cs @@ -0,0 +1,391 @@ +using System.Collections.Generic; +using System.Linq; +using GameplayEditor.Core; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// 行为树运行时可视化窗口 + /// 实时显示行为树执行状态、节点状态、变量变化 + /// + public class BTRuntimeVisualizerWindow : EditorWindow + { + private Vector2 _nodeStatusScrollPosition; + private Vector2 _historyScrollPosition; + private Vector2 _variableScrollPosition; + + private int _selectedTab = 0; + private readonly string[] _tabNames = { "节点状态", "执行历史", "变量监控" }; + + private string _filterTreeName = ""; + private string _filterNodeName = ""; + private bool _autoRefresh = true; + private float _lastRefreshTime; + private const float REFRESH_INTERVAL = 0.1f; + + private BTRuntimeVisualizer _visualizer; + private Dictionary _expandedTrees = new Dictionary(); + + [MenuItem("Window/Activity Editor/运行时可视化 #F12")] + public static void ShowWindow() + { + var window = GetWindow("BT运行时可视化"); + window.minSize = new Vector2(500, 400); + window.Show(); + } + + private void OnEnable() + { + if (Application.isPlaying) + FindVisualizer(); + } + + private void FindVisualizer() + { + _visualizer = BTRuntimeVisualizer.Instance; + } + + private void Update() + { + if (_autoRefresh && Time.time - _lastRefreshTime > REFRESH_INTERVAL) + { + _lastRefreshTime = Time.time; + Repaint(); + } + } + + private void OnGUI() + { + DrawToolbar(); + + EditorGUILayout.Space(5); + + _selectedTab = GUILayout.Toolbar(_selectedTab, _tabNames); + + EditorGUILayout.Space(5); + + switch (_selectedTab) + { + case 0: + DrawNodeStatusTab(); + break; + case 1: + DrawExecutionHistoryTab(); + break; + case 2: + DrawVariableMonitorTab(); + break; + } + } + + #region 工具栏 + + private void DrawToolbar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + // 自动刷新开关 + _autoRefresh = GUILayout.Toggle(_autoRefresh, "自动刷新", EditorStyles.toolbarButton, GUILayout.Width(70)); + + GUILayout.Space(10); + + // 树筛选 + EditorGUILayout.LabelField("树:", GUILayout.Width(30)); + _filterTreeName = EditorGUILayout.TextField(_filterTreeName, GUILayout.Width(120)); + + GUILayout.Space(10); + + // 节点筛选 + EditorGUILayout.LabelField("节点:", GUILayout.Width(35)); + _filterNodeName = EditorGUILayout.TextField(_filterNodeName, GUILayout.Width(120)); + + GUILayout.FlexibleSpace(); + + // 手动刷新 + if (GUILayout.Button("刷新", EditorStyles.toolbarButton, GUILayout.Width(50))) + { + Repaint(); + } + + // 清除 + if (GUILayout.Button("清除", EditorStyles.toolbarButton, GUILayout.Width(50))) + { + _visualizer?.ClearHistory(); + } + + EditorGUILayout.EndHorizontal(); + } + + #endregion + + #region 节点状态标签页 + + private void DrawNodeStatusTab() + { + if (_visualizer == null) + { + EditorGUILayout.HelpBox("未找到BTRuntimeVisualizer", MessageType.Warning); + return; + } + + var statuses = _visualizer.GetAllNodeStatuses(); + + // 按树分组 + var grouped = statuses.GroupBy(s => s.TreeName).ToList(); + + _nodeStatusScrollPosition = EditorGUILayout.BeginScrollView(_nodeStatusScrollPosition); + + foreach (var group in grouped) + { + string treeName = group.Key; + + // 树名称筛选 + if (!string.IsNullOrEmpty(_filterTreeName) && + !treeName.ToLower().Contains(_filterTreeName.ToLower())) + continue; + + // 展开/折叠 + if (!_expandedTrees.ContainsKey(treeName)) + _expandedTrees[treeName] = true; + + EditorGUILayout.BeginHorizontal(EditorStyles.foldoutHeader); + + _expandedTrees[treeName] = EditorGUILayout.Foldout(_expandedTrees[treeName], + $"{treeName} ({group.Count()} 节点)", true); + + // 运行状态指示 + var runningCount = group.Count(s => s.Status == BTRuntimeVisualizer.NodeStatus.Running); + if (runningCount > 0) + { + GUI.color = Color.cyan; + GUILayout.Label($"● 运行中({runningCount})", GUILayout.Width(80)); + GUI.color = Color.white; + } + + EditorGUILayout.EndHorizontal(); + + if (_expandedTrees[treeName]) + { + EditorGUI.indentLevel++; + + foreach (var status in group) + { + // 节点名称筛选 + if (!string.IsNullOrEmpty(_filterNodeName) && + !status.NodeName.ToLower().Contains(_filterNodeName.ToLower())) + continue; + + DrawNodeStatusItem(status); + } + + EditorGUI.indentLevel--; + } + + EditorGUILayout.Space(5); + } + + EditorGUILayout.EndScrollView(); + + // 图例 + DrawStatusLegend(); + } + + private void DrawNodeStatusItem(BTRuntimeVisualizer.NodeStatusData status) + { + EditorGUILayout.BeginHorizontal(); + + // 状态指示器 + GUI.color = status.GetStatusColor(); + GUILayout.Label("●", GUILayout.Width(15)); + GUI.color = Color.white; + + // 节点名称 + EditorGUILayout.LabelField(status.NodeName, GUILayout.Width(150)); + + // 状态文字 + EditorGUILayout.LabelField(status.Status.ToString(), GUILayout.Width(60)); + + // 行索引 + if (status.RowIndex > 0) + { + GUI.color = Color.yellow; + EditorGUILayout.LabelField($"Row:{status.RowIndex}", GUILayout.Width(60)); + GUI.color = Color.white; + } + + // 时间 + EditorGUILayout.LabelField($"{Time.time - status.Timestamp:F1}s前", GUILayout.Width(60)); + + EditorGUILayout.EndHorizontal(); + } + + private void DrawStatusLegend() + { + EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); + + GUI.color = new Color(0.2f, 0.8f, 0.2f); + GUILayout.Label("● 成功", GUILayout.Width(60)); + + GUI.color = new Color(0.9f, 0.2f, 0.2f); + GUILayout.Label("● 失败", GUILayout.Width(60)); + + GUI.color = new Color(0.2f, 0.6f, 1f); + GUILayout.Label("● 运行中", GUILayout.Width(60)); + + GUI.color = new Color(0.8f, 0.8f, 0.8f); + GUILayout.Label("● 休眠", GUILayout.Width(60)); + + GUI.color = Color.white; + + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + } + + #endregion + + #region 执行历史标签页 + + private void DrawExecutionHistoryTab() + { + if (_visualizer == null) + { + EditorGUILayout.HelpBox("未找到BTRuntimeVisualizer", MessageType.Warning); + return; + } + + var history = _visualizer.GetExecutionHistory(); + + _historyScrollPosition = EditorGUILayout.BeginScrollView(_historyScrollPosition); + + // 表头 + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + EditorGUILayout.LabelField("时间", GUILayout.Width(60)); + EditorGUILayout.LabelField("行为树", GUILayout.Width(120)); + EditorGUILayout.LabelField("节点", GUILayout.Width(150)); + EditorGUILayout.LabelField("结果", GUILayout.Width(60)); + EditorGUILayout.LabelField("行号", GUILayout.Width(50)); + EditorGUILayout.EndHorizontal(); + + // 倒序显示(最新的在前) + var reversed = history.AsEnumerable().Reverse(); + + foreach (var record in reversed) + { + // 筛选 + if (!string.IsNullOrEmpty(_filterTreeName) && + !record.TreeName?.ToLower().Contains(_filterTreeName.ToLower()) == true) + continue; + + if (!string.IsNullOrEmpty(_filterNodeName) && + !record.NodeName.ToLower().Contains(_filterNodeName.ToLower())) + continue; + + DrawExecutionRecord(record); + } + + EditorGUILayout.EndScrollView(); + + EditorGUILayout.LabelField($"共 {history.Count} 条记录", EditorStyles.miniLabel); + } + + private void DrawExecutionRecord(BTRuntimeVisualizer.ExecutionRecord record) + { + EditorGUILayout.BeginHorizontal(); + + // 时间 + EditorGUILayout.LabelField(record.GetFormattedTime(), GUILayout.Width(60)); + + // 行为树名 + EditorGUILayout.LabelField(record.TreeName ?? "Unknown", GUILayout.Width(120)); + + // 节点名 + EditorGUILayout.LabelField(record.NodeName, GUILayout.Width(150)); + + // 结果(带颜色) + GUI.color = GetResultColor(record.Result); + EditorGUILayout.LabelField(record.Result.ToString(), GUILayout.Width(60)); + GUI.color = Color.white; + + // 行号 + if (record.RowIndex > 0) + { + EditorGUILayout.LabelField(record.RowIndex.ToString(), GUILayout.Width(50)); + } + else + { + EditorGUILayout.LabelField("-", GUILayout.Width(50)); + } + + EditorGUILayout.EndHorizontal(); + } + + private Color GetResultColor(BTRuntimeVisualizer.NodeStatus status) + { + return status switch + { + BTRuntimeVisualizer.NodeStatus.Success => new Color(0.2f, 0.8f, 0.2f), + BTRuntimeVisualizer.NodeStatus.Failure => new Color(0.9f, 0.2f, 0.2f), + BTRuntimeVisualizer.NodeStatus.Running => new Color(0.2f, 0.6f, 1f), + _ => Color.gray + }; + } + + #endregion + + #region 变量监控标签页 + + private void DrawVariableMonitorTab() + { + if (_visualizer == null) + { + EditorGUILayout.HelpBox("未找到BTRuntimeVisualizer", MessageType.Warning); + return; + } + + var variables = _visualizer.GetMonitoredVariables(); + + _variableScrollPosition = EditorGUILayout.BeginScrollView(_variableScrollPosition); + + foreach (var varName in variables) + { + var history = _visualizer.GetVariableHistory(varName); + if (history.Count == 0) continue; + + var latest = history[history.Count - 1]; + + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField(varName, EditorStyles.boldLabel); + GUILayout.FlexibleSpace(); + EditorGUILayout.LabelField($"{history.Count} 次变化", EditorStyles.miniLabel); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.LabelField($"当前值: {latest.Value}", GUILayout.MinWidth(200)); + EditorGUILayout.LabelField($"时间: {latest.GetFormattedTime()}", EditorStyles.miniLabel); + + // 变化趋势 + if (history.Count > 1) + { + var prev = history[history.Count - 2]; + if (prev.Value != latest.Value) + { + GUI.color = Color.yellow; + EditorGUILayout.LabelField($"从 {prev.Value} 变更", EditorStyles.miniLabel); + GUI.color = Color.white; + } + } + + EditorGUILayout.EndVertical(); + + EditorGUILayout.Space(3); + } + + EditorGUILayout.EndScrollView(); + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/BTRuntimeVisualizerWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/BTRuntimeVisualizerWindow.cs.meta new file mode 100644 index 0000000..6a42a09 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/BTRuntimeVisualizerWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 97b64b35068ec2f459400e1b3c296769 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/BTVisualDebugger.cs b/Assets/BP_Scripts/GameplayEditor/Editor/BTVisualDebugger.cs new file mode 100644 index 0000000..340657e --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/BTVisualDebugger.cs @@ -0,0 +1,113 @@ +using GameplayEditor.Core; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// 行为树可视化调试器 + /// 在SceneView中显示节点执行状态 + /// + [InitializeOnLoad] + public static class BTVisualDebugger + { + private static BehaviourTree _targetTree; + private static Node _currentNode; + private static float _lastUpdateTime; + private static float _highlightDuration = 0.5f; + private static System.Collections.Generic.Dictionary _nodeHighlightTimes = + new System.Collections.Generic.Dictionary(); + + static BTVisualDebugger() + { + SceneView.duringSceneGui += OnSceneGUI; + EditorApplication.update += OnEditorUpdate; + } + + /// + /// 设置调试目标 + /// + public static void SetTarget(BehaviourTree tree) + { + _targetTree = tree; + } + + private static void OnEditorUpdate() + { + if (Time.time - _lastUpdateTime > 0.1f) + { + _lastUpdateTime = Time.time; + + // 获取当前执行节点 + if (BTDebugger.Instance != null) + { + _currentNode = BTDebugger.Instance.GetCurrentNode(); + } + + // 更新高亮时间 + var keys = new System.Collections.Generic.List(_nodeHighlightTimes.Keys); + foreach (var key in keys) + { + _nodeHighlightTimes[key] -= 0.1f; + if (_nodeHighlightTimes[key] <= 0) + _nodeHighlightTimes.Remove(key); + } + + SceneView.RepaintAll(); + } + } + + private static void OnSceneGUI(SceneView sceneView) + { + if (_targetTree == null) return; + + Handles.BeginGUI(); + + // 绘制调试信息面板 + DrawDebugPanel(); + + Handles.EndGUI(); + } + + private static void DrawDebugPanel() + { + GUILayout.BeginArea(new Rect(10, Screen.height - 200, 300, 180), EditorStyles.helpBox); + + GUILayout.Label("行为树调试", EditorStyles.boldLabel); + + if (_targetTree != null) + { + GUILayout.Label($"目标: {_targetTree.name}", EditorStyles.miniLabel); + GUILayout.Label($"当前节点: {_currentNode?.name ?? "None"}", EditorStyles.miniLabel); + + GUILayout.Space(5); + + // 显示执行历史 + GUILayout.Label("最近执行:", EditorStyles.miniBoldLabel); + int count = 0; + foreach (var kvp in _nodeHighlightTimes) + { + if (count++ >= 5) break; + GUILayout.Label($" {kvp.Key} ({kvp.Value:F1}s)", EditorStyles.miniLabel); + } + } + else + { + GUILayout.Label("未选择目标行为树", EditorStyles.miniLabel); + } + + GUILayout.EndArea(); + } + + /// + /// 高亮节点(由BTDebugger调用) + /// + public static void HighlightNode(Node node) + { + if (node == null) return; + _nodeHighlightTimes[$"{node.name}_{node.GetHashCode()}"] = _highlightDuration; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/BTVisualDebugger.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/BTVisualDebugger.cs.meta new file mode 100644 index 0000000..1f71333 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/BTVisualDebugger.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: de709a0880fa3a443803d0445660e27a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/CompositeMethodValidatorWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/CompositeMethodValidatorWindow.cs new file mode 100644 index 0000000..c513909 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/CompositeMethodValidatorWindow.cs @@ -0,0 +1,361 @@ +using System.Collections.Generic; +using System.Linq; +using GameplayEditor.Nodes; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// 组合方法验证器窗口 + /// 用于检测循环依赖、递归深度等问题 + /// + public class CompositeMethodValidatorWindow : EditorWindow + { + private Vector2 _scrollPos; + private List _validationResults = new List(); + private bool _showValidMethods = true; + private CompositeMethod _selectedMethod; + + [MenuItem("Window/Activity Editor/Composite Method Validator")] + public static void ShowWindow() + { + var window = GetWindow("组合方法验证器"); + window.minSize = new Vector2(500, 400); + window.Show(); + } + + private void OnEnable() + { + ValidateAllMethods(); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("组合方法验证器", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + // 工具栏 + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + if (GUILayout.Button("重新验证", EditorStyles.toolbarButton)) + { + ValidateAllMethods(); + } + + if (GUILayout.Button("重置递归计数器", EditorStyles.toolbarButton)) + { + CompositeMethodNode.ResetAllRecursionDepth(); + Debug.Log("[CompositeMethodValidator] 递归计数器已重置"); + } + + _showValidMethods = GUILayout.Toggle(_showValidMethods, "显示有效方法", EditorStyles.toolbarButton); + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + // 统计信息 + DrawStatistics(); + + EditorGUILayout.Space(); + + // 验证结果列表 + _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos); + + foreach (var result in _validationResults) + { + if (!result.HasIssues && !_showValidMethods) + continue; + + DrawValidationResult(result); + } + + EditorGUILayout.EndScrollView(); + } + + private void DrawStatistics() + { + var validCount = _validationResults.Count(r => !r.HasIssues); + var warningCount = _validationResults.Count(r => r.Warnings.Count > 0); + var errorCount = _validationResults.Count(r => r.Errors.Count > 0); + + EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); + + EditorGUILayout.LabelField($"总方法数: {_validationResults.Count}", GUILayout.Width(100)); + EditorGUILayout.LabelField($"✓ 有效: {validCount}", GUILayout.Width(80)); + EditorGUILayout.LabelField($"⚠ 警告: {warningCount}", GUILayout.Width(80)); + EditorGUILayout.LabelField($"✗ 错误: {errorCount}", GUILayout.Width(80)); + + EditorGUILayout.EndHorizontal(); + } + + private void DrawValidationResult(ValidationResult result) + { + var bgColor = result.HasErrors ? new Color(1, 0.8f, 0.8f) : + result.HasWarnings ? new Color(1, 0.9f, 0.7f) : + new Color(0.8f, 1, 0.8f); + + EditorGUILayout.BeginVertical(GUI.skin.box); + + // 标题行 + EditorGUILayout.BeginHorizontal(); + + var icon = result.HasErrors ? "✗" : result.HasWarnings ? "⚠" : "✓"; + EditorGUILayout.LabelField($"{icon} {result.Method.MethodName} (ID:{result.Method.MethodID})", + EditorStyles.boldLabel); + + if (GUILayout.Button("查看", GUILayout.Width(50))) + { + Selection.activeObject = result.Method; + EditorGUIUtility.PingObject(result.Method); + } + + EditorGUILayout.EndHorizontal(); + + // 详细信息 + if (result.HasIssues) + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + foreach (var error in result.Errors) + { + EditorGUILayout.LabelField($" ✗ {error}", new GUIStyle(EditorStyles.label) { normal = { textColor = Color.red } }); + } + + foreach (var warning in result.Warnings) + { + EditorGUILayout.LabelField($" ⚠ {warning}", new GUIStyle(EditorStyles.label) { normal = { textColor = Color.yellow } }); + } + + EditorGUILayout.EndVertical(); + } + else + { + EditorGUILayout.LabelField(" ✓ 所有检查通过", EditorStyles.miniLabel); + } + + // 参数信息 + if (result.Method.Parameters.Count > 0) + { + EditorGUILayout.LabelField($" 参数: {string.Join(", ", result.Method.Parameters.Select(p => $"{p.Name}({p.Type}{(p.Required ? "*" : "")})"))}", + EditorStyles.miniLabel); + } + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(2); + } + + private void ValidateAllMethods() + { + _validationResults.Clear(); + + // 加载所有组合方法 + var methods = LoadAllCompositeMethods(); + + foreach (var method in methods) + { + var result = new ValidationResult { Method = method }; + + // 基本检查 + if (method.MethodID <= 0) + { + result.Errors.Add("方法ID必须大于0"); + } + + if (string.IsNullOrWhiteSpace(method.MethodName)) + { + result.Errors.Add("方法名称不能为空"); + } + + if (method.Tree == null) + { + result.Errors.Add("未关联行为树"); + } + else + { + // 检查循环依赖 + var cycle = DetectCycle(method, methods); + if (cycle != null) + { + result.Errors.Add($"检测到循环依赖: {string.Join(" -> ", cycle)}"); + } + + // 检查递归风险 + var selfCallCount = CountSelfReferences(method.Tree, method.MethodID); + if (selfCallCount > 0) + { + result.Warnings.Add($"方法内部包含{selfCallCount}个对自身的引用,可能导致递归"); + } + } + + // 检查参数定义 + foreach (var param in method.Parameters) + { + if (string.IsNullOrWhiteSpace(param.Name)) + { + result.Errors.Add("存在未命名的参数定义"); + } + else if (!IsValidParameterName(param.Name)) + { + result.Errors.Add($"参数名'{param.Name}'无效,只能包含字母、数字和下划线"); + } + } + + _validationResults.Add(result); + } + } + + private List LoadAllCompositeMethods() + { + var methods = new List(); + + var guids = AssetDatabase.FindAssets("t:CompositeMethod"); + foreach (var guid in guids) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + var method = AssetDatabase.LoadAssetAtPath(path); + if (method != null) + { + methods.Add(method); + } + } + + return methods; + } + + /// + /// 检测循环依赖 + /// + private List DetectCycle(CompositeMethod startMethod, List allMethods) + { + var visited = new HashSet(); + var path = new List(); + + return DetectCycleRecursive(startMethod, visited, path, allMethods); + } + + private List DetectCycleRecursive(CompositeMethod current, HashSet visited, + List path, List allMethods) + { + if (visited.Contains(current.MethodID)) + { + // 找到循环 + path.Add(current.MethodName); + return path; + } + + visited.Add(current.MethodID); + path.Add(current.MethodName); + + if (current.Tree != null) + { + // 查找树中引用的其他组合方法 + var referencedMethods = FindReferencedMethods(current.Tree, allMethods); + + foreach (var referenced in referencedMethods) + { + var cycle = DetectCycleRecursive(referenced, new HashSet(visited), + new List(path), allMethods); + if (cycle != null) + return cycle; + } + } + + path.RemoveAt(path.Count - 1); + return null; + } + + /// + /// 查找行为树中引用的组合方法 + /// + private List FindReferencedMethods(BehaviourTree tree, List allMethods) + { + var referenced = new List(); + + if (tree?.primeNode == null) + return referenced; + + // 递归查找所有CompositeMethodNode + FindMethodNodesRecursive(tree.primeNode, referenced, allMethods); + + return referenced; + } + + private void FindMethodNodesRecursive(Node node, List referenced, + List allMethods) + { + if (node == null) return; + + // 检查是否是CompositeMethodNode + if (node is CompositeMethodNode methodNode) + { + var method = allMethods.Find(m => m.MethodID == methodNode.MethodID); + if (method != null && !referenced.Contains(method)) + { + referenced.Add(method); + } + } + + // 递归检查子节点 + if (node.outConnections != null) + { + foreach (var conn in node.outConnections) + { + FindMethodNodesRecursive(conn.targetNode, referenced, allMethods); + } + } + } + + /// + /// 计算方法树中对自身的引用次数 + /// + private int CountSelfReferences(BehaviourTree tree, int methodId) + { + int count = 0; + + if (tree?.primeNode == null) + return 0; + + CountSelfReferencesRecursive(tree.primeNode, methodId, ref count); + + return count; + } + + private void CountSelfReferencesRecursive(Node node, int methodId, ref int count) + { + if (node == null) return; + + if (node is CompositeMethodNode methodNode && methodNode.MethodID == methodId) + { + count++; + } + + if (node.outConnections != null) + { + foreach (var conn in node.outConnections) + { + CountSelfReferencesRecursive(conn.targetNode, methodId, ref count); + } + } + } + + private bool IsValidParameterName(string name) + { + return System.Text.RegularExpressions.Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$"); + } + + private class ValidationResult + { + public CompositeMethod Method; + public List Errors = new List(); + public List Warnings = new List(); + + public bool HasErrors => Errors.Count > 0; + public bool HasWarnings => Warnings.Count > 0; + public bool HasIssues => HasErrors || HasWarnings; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/CompositeMethodValidatorWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/CompositeMethodValidatorWindow.cs.meta new file mode 100644 index 0000000..838f868 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/CompositeMethodValidatorWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9ad8e2b8480a1bf4dab8d306b0ecb8bf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/ConfigValidator.cs b/Assets/BP_Scripts/GameplayEditor/Editor/ConfigValidator.cs new file mode 100644 index 0000000..4a4578c --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/ConfigValidator.cs @@ -0,0 +1,975 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameplayEditor.Config; +using GameplayEditor.Core; +using GameplayEditor.Excel; +using GameplayEditor.Nodes; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// 配置验证器 + /// 提供资源检查、配置验证、错误定位等功能 + /// + public static class ConfigValidator + { + #region 验证结果 + + /// + /// 验证结果项 + /// + public class ValidationResult + { + public enum ResultType { Info, Warning, Error } + + public ResultType Type; + public string Category; + public string Message; + public string Detail; // 详细信息 + public int RowIndex; // Excel行索引(用于定位) + public int ExcelRowNumber; // Excel实际行号 + public string SourceFile; // 源文件路径 + public Object UnityObject; // 关联的Unity对象(双击可跳转) + + public bool IsError => Type == ResultType.Error; + public bool IsWarning => Type == ResultType.Warning; + } + + /// + /// 验证结果集合 + /// + public class ValidationReport + { + public List Results = new List(); + public string Summary => $"错误: {ErrorCount}, 警告: {WarningCount}, 信息: {InfoCount}"; + + public int ErrorCount => Results.Count(r => r.IsError); + public int WarningCount => Results.Count(r => r.IsWarning); + public int InfoCount => Results.Count(r => r.Type == ValidationResult.ResultType.Info); + public bool HasErrors => ErrorCount > 0; + public bool HasWarnings => WarningCount > 0; + + public void Add(ValidationResult result) + { + Results.Add(result); + } + + public void AddError(string category, string message, string detail = "", + int rowIndex = 0, int excelRowNumber = 0, string sourceFile = "") + { + Add(new ValidationResult + { + Type = ValidationResult.ResultType.Error, + Category = category, + Message = message, + Detail = detail, + RowIndex = rowIndex, + ExcelRowNumber = excelRowNumber, + SourceFile = sourceFile + }); + } + + public void AddWarning(string category, string message, string detail = "", + int rowIndex = 0, int excelRowNumber = 0, string sourceFile = "") + { + Add(new ValidationResult + { + Type = ValidationResult.ResultType.Warning, + Category = category, + Message = message, + Detail = detail, + RowIndex = rowIndex, + ExcelRowNumber = excelRowNumber, + SourceFile = sourceFile + }); + } + + public void AddInfo(string category, string message, string detail = "", + int rowIndex = 0, int excelRowNumber = 0, string sourceFile = "") + { + Add(new ValidationResult + { + Type = ValidationResult.ResultType.Info, + Category = category, + Message = message, + Detail = detail, + RowIndex = rowIndex, + ExcelRowNumber = excelRowNumber, + SourceFile = sourceFile + }); + } + } + + #endregion + + #region 关卡配置验证 + + /// + /// 验证关卡配置 + /// + public static ValidationReport ValidateActivityStageConfig(ActivityStageConfig config) + { + var report = new ValidationReport(); + + if (config == null) + { + report.AddError("关卡配置", "配置对象为空"); + return report; + } + + // 验证ID + if (config.ActivityStageID <= 0) + { + report.AddError("关卡配置", $"StageID无效: {config.ActivityStageID}", + "StageID必须大于0"); + } + else if (GameplayDefaultConfig.Instance.EnableStrictIDRangeCheck && + (config.ActivityStageID < GameplayDefaultConfig.Instance.MinStageID || + config.ActivityStageID > GameplayDefaultConfig.Instance.MaxStageID)) + { + report.AddWarning("关卡配置", + $"StageID {config.ActivityStageID} 超出推荐范围 [{GameplayDefaultConfig.Instance.MinStageID}, {GameplayDefaultConfig.Instance.MaxStageID}]", + "如需放宽限制,请在 GameplayDefaultConfig 中关闭 EnableStrictIDRangeCheck 或调整范围"); + } + + // 验证场景名称 + if (string.IsNullOrWhiteSpace(config.SceneName)) + { + report.AddError("关卡配置", "场景名称为空", + $"StageID={config.ActivityStageID} 的场景名称不能为空"); + } + else + { + // 检查场景是否存在 + #if UNITY_EDITOR + var scenePath = $"Assets/Scenes/{config.SceneName}.unity"; + if (!File.Exists(scenePath)) + { + report.AddWarning("关卡配置", $"场景文件可能不存在: {config.SceneName}", + $"未找到场景文件: {scenePath}"); + } + #endif + } + + // 验证MapInfo + if (config.MapInfo <= 0) + { + report.AddWarning("关卡配置", "MapInfo未设置", + $"StageID={config.ActivityStageID} 的地图打点组未配置"); + } + + // 验证CameraID + if (config.CameraID <= 0) + { + report.AddWarning("关卡配置", "CameraID未设置", + $"StageID={config.ActivityStageID} 的默认相机未配置"); + } + + // 验证行为树 + if (config.BodyTree == null) + { + report.AddError("关卡配置", "正文行为树为空", + $"StageID={config.ActivityStageID} 的正文行为树必须配置"); + } + else + { + // 递归验证行为树 + var btReport = ValidateBehaviourTree(config.BodyTree); + report.Results.AddRange(btReport.Results); + } + + // 头文件可选 + if (config.HeaderTree != null) + { + var headerReport = ValidateBehaviourTree(config.HeaderTree); + report.Results.AddRange(headerReport.Results); + } + + // 验证TickRate + if (config.TickRate < 20 || config.TickRate > 120) + { + report.AddWarning("关卡配置", $"TickRate设置异常: {config.TickRate}", + "建议TickRate范围: 20-120"); + } + + return report; + } + + #endregion + + #region 行为树验证 + + /// + /// 验证行为树 + /// + public static ValidationReport ValidateBehaviourTree(BehaviourTree tree) + { + var report = new ValidationReport(); + + if (tree == null) + { + report.AddError("行为树", "行为树对象为空"); + return report; + } + + if (tree.primeNode == null) + { + report.AddError("行为树", $"行为树 '{tree.name}' 没有根节点"); + return report; + } + + // 获取节点信息容器 + var container = BehaviourTreeNodeInfoManager.GetContainer(tree.name); + + // 递归验证节点 + ValidateNodeRecursive(tree.primeNode, 0, report, container); + + // 检查节点数量 + int nodeCount = CountNodes(tree.primeNode); + report.AddInfo("行为树", $"行为树 '{tree.name}' 包含 {nodeCount} 个节点"); + + return report; + } + + /// + /// 递归验证节点 + /// + private static void ValidateNodeRecursive(Node node, int depth, ValidationReport report, + BehaviourTreeNodeInfoContainer container) + { + if (node == null) return; + + // 获取节点信息(用于错误定位) + var nodeInfo = container?.GetNodeInfo(depth); + var rowIndex = nodeInfo?.RowIndex ?? 0; + var excelRow = nodeInfo?.ExcelRowNumber ?? 0; + + // 验证节点名称 + if (string.IsNullOrEmpty(node.name)) + { + report.AddWarning("行为树节点", $"第{depth}个节点名称为空", + "", rowIndex, excelRow); + } + + // 验证ActionNode + if (node is ActionNode actionNode) + { + if (actionNode.action == null) + { + report.AddError("行为树节点", $"节点 '{node.name}' 的动作未设置", + "", rowIndex, excelRow); + } + else + { + // 检查是否为DynamicBtActionTask(未注册类型) + if (actionNode.action is DynamicBtActionTask dynTask) + { + report.AddWarning("行为树节点", + $"节点 '{node.name}' 使用动态类型: {dynTask.NodeTypeName}", + "该类型未在NodeTypeRegistry中注册,运行时可能无法正确执行", + rowIndex, excelRow); + } + } + } + + // 验证CompositeMethodNode + if (node is CompositeMethodNode compositeNode) + { + if (compositeNode.MethodID <= 0) + { + report.AddError("行为树节点", + $"组合方法节点 '{node.name}' 的方法ID无效", + "", rowIndex, excelRow); + } + else + { + // 严格ID范围检查 + if (GameplayDefaultConfig.Instance.EnableStrictIDRangeCheck && + (compositeNode.MethodID < GameplayDefaultConfig.Instance.MinCompositeMethodID || + compositeNode.MethodID > GameplayDefaultConfig.Instance.MaxCompositeMethodID)) + { + report.AddWarning("行为树节点", + $"组合方法节点 '{node.name}' 的MethodID {compositeNode.MethodID} 超出推荐范围 [{GameplayDefaultConfig.Instance.MinCompositeMethodID}, {GameplayDefaultConfig.Instance.MaxCompositeMethodID}]", + "", rowIndex, excelRow); + } + + // 检查方法是否存在 + var method = CompositeMethodRegistry.GetMethod(compositeNode.MethodID); + if (method == null) + { + report.AddError("行为树节点", + $"组合方法节点 '{node.name}' 引用的方法不存在: ID={compositeNode.MethodID}", + "", rowIndex, excelRow); + } + else if (method.Tree == null) + { + report.AddWarning("行为树节点", + $"组合方法 '{method.MethodName}' 未关联行为树", + "", rowIndex, excelRow); + } + } + } + + // 验证循环引用(简单检查) + if (depth > 100) + { + report.AddError("行为树节点", "节点深度超过100,可能存在循环引用", + "", rowIndex, excelRow); + return; + } + + // 递归验证子节点 + foreach (var conn in node.outConnections) + { + ValidateNodeRecursive(conn.targetNode, depth + 1, report, container); + } + } + + /// + /// 统计节点数量 + /// + private static int CountNodes(Node node) + { + if (node == null) return 0; + int count = 1; + foreach (var conn in node.outConnections) + { + count += CountNodes(conn.targetNode); + } + return count; + } + + #endregion + + #region DialogInfo验证 + + /// + /// 验证DialogInfo配置 + /// + public static ValidationReport ValidateDialogInfo(DialogInfoConfig config) + { + var report = new ValidationReport(); + + if (config == null) + { + report.AddError("对话配置", "配置对象为空"); + return report; + } + + var idSet = new HashSet(); + + foreach (var dialog in config.Dialogs) + { + // ID有效性 + if (dialog.ID <= 0) + { + report.AddError("对话配置", $"对话ID无效: {dialog.ID}"); + } + // ID唯一性 + else if (idSet.Contains(dialog.ID)) + { + report.AddError("对话配置", $"对话ID重复: {dialog.ID}"); + } + else + { + idSet.Add(dialog.ID); + } + + // 文本检查 + if (string.IsNullOrWhiteSpace(dialog.Text)) + { + report.AddError("对话配置", $"对话ID={dialog.ID} 的文本为空"); + } + + // Prefab路径检查 + if (!string.IsNullOrEmpty(dialog.PrefabPath)) + { + #if UNITY_EDITOR + var fullPath = $"Assets/{dialog.PrefabPath}"; + if (!File.Exists(fullPath)) + { + report.AddWarning("对话配置", + $"对话ID={dialog.ID} 的Prefab可能不存在: {dialog.PrefabPath}"); + } + #endif + } + + // 持续时间检查 + if (dialog.Duration < 0) + { + report.AddWarning("对话配置", + $"对话ID={dialog.ID} 的持续时间不能为负数: {dialog.Duration}"); + } + } + + report.AddInfo("对话配置", $"共验证 {config.Dialogs.Count} 条对话配置"); + + return report; + } + + /// + /// 验证DialogInfoByStage索引 + /// + public static ValidationReport ValidateDialogInfoByStage(DialogInfoByStageConfig config) + { + var report = new ValidationReport(); + + if (config == null) + { + report.AddError("对话索引", "配置对象为空"); + return report; + } + + var stageIdSet = new HashSet(); + + foreach (var entry in config.Entries) + { + // StageID有效性 + if (entry.StageID <= 0) + { + report.AddError("对话索引", $"StageID无效: {entry.StageID}"); + } + // StageID唯一性 + else if (stageIdSet.Contains(entry.StageID)) + { + report.AddError("对话索引", $"StageID重复: {entry.StageID}"); + } + else + { + stageIdSet.Add(entry.StageID); + } + + // SheetName检查 + if (string.IsNullOrWhiteSpace(entry.DialogInfoSheetName)) + { + report.AddError("对话索引", $"StageID={entry.StageID} 的SheetName为空"); + } + } + + report.AddInfo("对话索引", $"共验证 {config.Entries.Count} 条索引"); + + return report; + } + + #endregion + + #region LUT配置验证 + + /// + /// 验证LUT资源配置 + /// + public static ValidationReport ValidateLutConfigs(LutConfigDatabase database) + { + var report = new ValidationReport(); + + if (database == null) + { + report.AddError("LUT配置", "数据库为空"); + return report; + } + + // 验证UI LUT + foreach (var lut in database.UiLuts) + { + if (lut == null) continue; + foreach (var mapping in lut.UiMappings) + { + ValidateResourceMapping(report, "UI LUT", mapping); + } + } + + // 验证Camera LUT + foreach (var lut in database.CameraLuts) + { + if (lut == null) continue; + foreach (var mapping in lut.CameraMappings) + { + ValidateResourceMapping(report, "Camera LUT", mapping); + } + } + + // 验证FX LUT + foreach (var lut in database.FxLuts) + { + if (lut == null) continue; + foreach (var mapping in lut.FxMappings) + { + ValidateResourceMapping(report, "FX LUT", mapping); + } + } + + return report; + } + + /// + /// 验证资源映射 + /// + private static void ValidateResourceMapping(ValidationReport report, string category, ResourceMapping mapping) + { + if (mapping.MappingID <= 0) + { + report.AddError(category, $"映射ID无效: {mapping.MappingID}"); + } + + if (string.IsNullOrWhiteSpace(mapping.PrefabPath)) + { + report.AddWarning(category, $"映射ID={mapping.MappingID} 的路径为空"); + } + else + { + #if UNITY_EDITOR + var fullPath = mapping.PrefabPath.StartsWith("Assets/") + ? mapping.PrefabPath + : $"Assets/{mapping.PrefabPath}"; + + if (!File.Exists(fullPath)) + { + report.AddWarning(category, + $"映射ID={mapping.MappingID} 的资源可能不存在: {mapping.PrefabPath}"); + } + #endif + } + } + + #endregion + + #region 批量验证 + + /// + /// 验证所有配置 + /// + public static ValidationReport ValidateAll() + { + var report = new ValidationReport(); + + // 查找所有配置资产 + var stageConfigs = LoadAllAssets(); + var dialogConfigs = LoadAllAssets(); + var byStageConfigs = LoadAllAssets(); + var lutDatabases = LoadAllAssets(); + var behaviourTrees = LoadAllAssets(); + + // 验证关卡配置 + foreach (var config in stageConfigs) + { + var r = ValidateActivityStageConfig(config); + report.Results.AddRange(r.Results); + } + + // 验证对话配置 + foreach (var config in dialogConfigs) + { + var r = ValidateDialogInfo(config); + report.Results.AddRange(r.Results); + } + + // 验证索引配置 + foreach (var config in byStageConfigs) + { + var r = ValidateDialogInfoByStage(config); + report.Results.AddRange(r.Results); + } + + // 验证LUT配置 + foreach (var db in lutDatabases) + { + var r = ValidateLutConfigs(db); + report.Results.AddRange(r.Results); + } + + // 验证行为树 + foreach (var tree in behaviourTrees) + { + var r = ValidateBehaviourTree(tree); + report.Results.AddRange(r.Results); + } + + report.AddInfo("批量验证", + $"完成验证: {stageConfigs.Count} 关卡, {dialogConfigs.Count} 对话表, " + + $"{behaviourTrees.Count} 行为树"); + + return report; + } + + /// + /// 加载所有指定类型的资产 + /// + private static List LoadAllAssets() where T : Object + { + var result = new List(); + #if UNITY_EDITOR + var guids = AssetDatabase.FindAssets($"t:{typeof(T).Name}"); + foreach (var guid in guids) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset != null) + { + result.Add(asset); + } + } + #endif + return result; + } + + #endregion + + #region 导入时验证 + + /// + /// 验证Excel导入结果 + /// + public static ValidationReport ValidateImportResults(List results) + { + var report = new ValidationReport(); + + if (results == null || results.Count == 0) + { + report.AddError("导入", "没有解析到任何行为树"); + return report; + } + + foreach (var result in results) + { + if (result.Tree == null) + { + report.AddError("导入", "行为树对象为空"); + continue; + } + + // 验证树结构 + var treeReport = ValidateBehaviourTree(result.Tree); + report.Results.AddRange(treeReport.Results); + + // 检查节点信息 + if (result.NodeInfoContainer == null) + { + report.AddWarning("导入", + $"行为树 '{result.Tree.name}' 没有节点信息容器", + "将无法进行行索引报错定位"); + } + else if (result.NodeInfoContainer.NodeInfos.Count == 0) + { + report.AddWarning("导入", + $"行为树 '{result.Tree.name}' 的节点信息为空"); + } + } + + report.AddInfo("导入", $"成功验证 {results.Count} 棵行为树"); + + return report; + } + + #endregion + + #region 默认值验证与修复 + + /// + /// 验证并尝试自动修复配置 + /// + public static ValidationReport ValidateAndFix(object config, bool autoFix = false) + { + var report = new ValidationReport(); + + if (config == null) + { + report.AddError("验证", "配置对象为空"); + return report; + } + + // 先进行常规验证 + var validationReport = ValidateConfig(config); + report.Results.AddRange(validationReport.Results); + + // 如果需要自动修复 + if (autoFix && report.HasErrors) + { + var recovery = new Utils.ConfigRecovery(); + var recoveryResult = recovery.FixConfig(config); + + if (recoveryResult.FixedIssues > 0) + { + report.AddInfo("自动修复", $"成功修复 {recoveryResult.FixedIssues} 个问题", + string.Join("\n", recoveryResult.FixDetails)); + } + + if (recoveryResult.UnfixableErrors > 0) + { + foreach (var error in recoveryResult.ErrorMessages) + { + report.AddError("无法修复", error); + } + } + } + + return report; + } + + /// + /// 验证配置(通用入口) + /// + private static ValidationReport ValidateConfig(object config) + { + return config switch + { + ActivityStageConfig stageConfig => ValidateActivityStageConfig(stageConfig), + DialogInfoConfig dialogConfig => ValidateDialogInfo(dialogConfig), + DialogInfoByStageConfig byStageConfig => ValidateDialogInfoByStage(byStageConfig), + EventBuilderConfig eventConfig => ValidateEventBuilderConfig(eventConfig), + LutConfigDatabase lutDb => ValidateLutConfigs(lutDb), + BehaviourTree tree => ValidateBehaviourTree(tree), + _ => new ValidationReport() + }; + } + + /// + /// 验证事件构建配置 + /// + public static ValidationReport ValidateEventBuilderConfig(EventBuilderConfig config) + { + var report = new ValidationReport(); + var defaultConfig = GameplayDefaultConfig.Instance; + + if (config == null) + { + report.AddError("事件配置", "配置对象为空"); + return report; + } + + // 验证节点配置 + var nodeIds = new HashSet(); + for (int i = 0; i < config.NodeConfigs.Count; i++) + { + var node = config.NodeConfigs[i]; + + // ID有效性 + if (node.NodeID <= 0) + { + report.AddError("事件节点", $"第{i}个节点ID无效: {node.NodeID}"); + } + else if (nodeIds.Contains(node.NodeID)) + { + report.AddError("事件节点", $"重复的NodeID: {node.NodeID}"); + } + else + { + nodeIds.Add(node.NodeID); + } + + // 严格ID范围检查 + if (defaultConfig.EnableStrictIDRangeCheck && node.NodeID > 0 && + (node.NodeID < defaultConfig.MinNodeID || node.NodeID > defaultConfig.MaxNodeID)) + { + report.AddWarning("事件节点", + $"NodeID={node.NodeID} 超出推荐范围 [{defaultConfig.MinNodeID}, {defaultConfig.MaxNodeID}]"); + } + + // 验证权重总和 + if (node.Priority != null && node.Priority.Count > 0) + { + int totalWeight = 0; + foreach (var weight in node.Priority) + { + if (weight < defaultConfig.MinWeight || weight > defaultConfig.MaxWeight) + { + report.AddWarning("事件节点", + $"NodeID={node.NodeID} 的权重 {weight} 超出范围 [{defaultConfig.MinWeight}, {defaultConfig.MaxWeight}]"); + } + totalWeight += weight; + } + + if (totalWeight != defaultConfig.TotalWeight) + { + report.AddError("事件节点", + $"NodeID={node.NodeID} 的权重总和为 {totalWeight},应为 {defaultConfig.TotalWeight}"); + } + } + + // 验证EventMappingID + if (node.EventMappingID <= 0) + { + report.AddWarning("事件节点", $"NodeID={node.NodeID} 的EventMappingID未设置"); + } + } + + // 验证事件Action配置 + var eventIds = new HashSet(); + for (int i = 0; i < config.ActionConfigs.Count; i++) + { + var action = config.ActionConfigs[i]; + + if (action.EventID <= 0) + { + report.AddError("事件Action", $"第{i}个EventID无效: {action.EventID}"); + } + else if (eventIds.Contains(action.EventID)) + { + report.AddError("事件Action", $"重复的EventID: {action.EventID}"); + } + else + { + eventIds.Add(action.EventID); + } + + // 严格ID范围检查 + if (defaultConfig.EnableStrictIDRangeCheck && action.EventID > 0 && + (action.EventID < defaultConfig.MinEventID || action.EventID > defaultConfig.MaxEventID)) + { + report.AddWarning("事件Action", + $"EventID={action.EventID} 超出推荐范围 [{defaultConfig.MinEventID}, {defaultConfig.MaxEventID}]"); + } + + // 验证类型有效性 + if (!System.Enum.IsDefined(typeof(GameplayEditor.Config.EventType), action.Type)) + { + report.AddWarning("事件Action", $"EventID={action.EventID} 的EventType无效: {action.Type}"); + } + } + + report.AddInfo("事件配置", + $"验证完成: {config.NodeConfigs.Count} 个节点, {config.ActionConfigs.Count} 个事件"); + + return report; + } + + /// + /// 验证默认值配置本身 + /// + public static ValidationReport ValidateDefaultConfig(GameplayDefaultConfig config) + { + var report = new ValidationReport(); + + if (config == null) + { + report.AddError("默认配置", "配置对象为空"); + return report; + } + + // 验证TickRate范围 + if (config.DefaultTickRate < config.MinTickRate || config.DefaultTickRate > config.MaxTickRate) + { + report.AddError("默认配置", + $"DefaultTickRate {config.DefaultTickRate} 超出范围 [{config.MinTickRate}, {config.MaxTickRate}]"); + } + + // 验证权重范围 + if (config.MinWeight < 0) + { + report.AddError("默认配置", "MinWeight不能为负数"); + } + + if (config.MaxWeight <= config.MinWeight) + { + report.AddError("默认配置", "MaxWeight必须大于MinWeight"); + } + + // 验证持续时间 + if (config.DefaultDialogDuration <= 0) + { + report.AddWarning("默认配置", "DefaultDialogDuration应大于0"); + } + + // 验证FOV范围 + if (config.DefaultCameraFOV <= 0 || config.DefaultCameraFOV >= 180) + { + report.AddError("默认配置", "DefaultCameraFOV应在(0, 180)范围内"); + } + + // 验证ID范围配置合理性 + if (config.EnableStrictIDRangeCheck) + { + if (config.MinStageID > config.MaxStageID) + report.AddError("默认配置", "MinStageID不能大于MaxStageID"); + if (config.MinNodeID > config.MaxNodeID) + report.AddError("默认配置", "MinNodeID不能大于MaxNodeID"); + if (config.MinEventID > config.MaxEventID) + report.AddError("默认配置", "MinEventID不能大于MaxEventID"); + if (config.MinCompositeMethodID > config.MaxCompositeMethodID) + report.AddError("默认配置", "MinCompositeMethodID不能大于MaxCompositeMethodID"); + } + + report.AddInfo("默认配置", "默认值配置验证完成"); + + return report; + } + + /// + /// 批量验证并生成报告 + /// + public static string GenerateValidationReport(List configs, bool autoFix = false) + { + var report = new System.Text.StringBuilder(); + report.AppendLine("========== 配置验证报告 =========="); + report.AppendLine($"验证时间: {System.DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + report.AppendLine($"配置数量: {configs?.Count ?? 0}"); + report.AppendLine($"自动修复: {(autoFix ? "启用" : "禁用")}"); + report.AppendLine(); + + if (configs == null || configs.Count == 0) + { + report.AppendLine("没有需要验证的配置"); + return report.ToString(); + } + + int totalErrors = 0; + int totalWarnings = 0; + int totalFixed = 0; + + for (int i = 0; i < configs.Count; i++) + { + var config = configs[i]; + var validationReport = ValidateAndFix(config, autoFix); + + totalErrors += validationReport.ErrorCount; + totalWarnings += validationReport.WarningCount; + + // 统计修复数量 + if (autoFix) + { + var recovery = new Utils.ConfigRecovery(); + var recoveryResult = recovery.FixConfig(config); + totalFixed += recoveryResult.FixedIssues; + } + + if (validationReport.ErrorCount > 0 || validationReport.WarningCount > 0) + { + report.AppendLine($"配置 {i + 1} ({config.GetType().Name}):"); + + foreach (var result in validationReport.Results) + { + var prefix = result.Type switch + { + ValidationResult.ResultType.Error => "[错误]", + ValidationResult.ResultType.Warning => "[警告]", + _ => "[信息]" + }; + report.AppendLine($" {prefix} [{result.Category}] {result.Message}"); + if (!string.IsNullOrEmpty(result.Detail)) + { + report.AppendLine($" {result.Detail}"); + } + } + report.AppendLine(); + } + } + + report.AppendLine("========== 统计 =========="); + report.AppendLine($"总错误: {totalErrors}"); + report.AppendLine($"总警告: {totalWarnings}"); + if (autoFix) + { + report.AppendLine($"自动修复: {totalFixed}"); + } + report.AppendLine("=========================="); + + return report.ToString(); + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/ConfigValidator.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/ConfigValidator.cs.meta new file mode 100644 index 0000000..62f29e7 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/ConfigValidator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 093a6851003c15e4fbd8eb40b4fd001b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/ConfigValidatorWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/ConfigValidatorWindow.cs new file mode 100644 index 0000000..37aeb86 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/ConfigValidatorWindow.cs @@ -0,0 +1,358 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; +using static GameplayEditor.Editor.ConfigValidator; + +namespace GameplayEditor.Editor +{ + /// + /// 配置验证器窗口 + /// 提供可视化的配置检查和错误定位功能 + /// + public class ConfigValidatorWindow : EditorWindow + { + private Vector2 _scrollPos; + private ValidationReport _currentReport; + private bool _showErrors = true; + private bool _showWarnings = true; + private bool _showInfo = false; + private string _filterText = ""; + private List _filteredResults = new List(); + + [MenuItem("Window/Activity Editor/Config Validator")] + public static void ShowWindow() + { + GetWindow("配置验证器"); + } + + private void OnGUI() + { + DrawToolbar(); + + EditorGUILayout.Space(); + + if (_currentReport == null) + { + EditorGUILayout.HelpBox("点击'验证全部'开始检查配置", MessageType.Info); + } + else + { + DrawStatistics(); + DrawFilter(); + DrawResults(); + } + } + + /// + /// 绘制工具栏 + /// + private void DrawToolbar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + if (GUILayout.Button("验证全部", EditorStyles.toolbarButton, GUILayout.Width(80))) + { + ValidateAll(); + } + + if (GUILayout.Button("验证行为树", EditorStyles.toolbarButton, GUILayout.Width(100))) + { + ValidateBehaviourTrees(); + } + + if (GUILayout.Button("验证LUT", EditorStyles.toolbarButton, GUILayout.Width(80))) + { + ValidateLuts(); + } + + if (GUILayout.Button("清除", EditorStyles.toolbarButton, GUILayout.Width(60))) + { + _currentReport = null; + _filteredResults.Clear(); + } + + GUILayout.FlexibleSpace(); + + if (_currentReport != null && _currentReport.HasErrors) + { + EditorGUILayout.LabelField("发现错误!", new GUIStyle(EditorStyles.toolbarButton) + { + normal = { textColor = Color.red } + }, GUILayout.Width(80)); + } + + EditorGUILayout.EndHorizontal(); + } + + /// + /// 绘制统计信息 + /// + private void DrawStatistics() + { + EditorGUILayout.BeginHorizontal(); + + // 错误数(红色) + var errorStyle = new GUIStyle(EditorStyles.boldLabel); + errorStyle.normal.textColor = Color.red; + EditorGUILayout.LabelField($"错误: {_currentReport.ErrorCount}", errorStyle, GUILayout.Width(80)); + + // 警告数(橙色) + var warningStyle = new GUIStyle(EditorStyles.boldLabel); + warningStyle.normal.textColor = new Color(1f, 0.5f, 0f); + EditorGUILayout.LabelField($"警告: {_currentReport.WarningCount}", warningStyle, GUILayout.Width(80)); + + // 信息数(灰色) + EditorGUILayout.LabelField($"信息: {_currentReport.InfoCount}", GUILayout.Width(80)); + + GUILayout.FlexibleSpace(); + + EditorGUILayout.EndHorizontal(); + } + + /// + /// 绘制筛选器 + /// + private void DrawFilter() + { + EditorGUILayout.BeginHorizontal(); + + _showErrors = EditorGUILayout.ToggleLeft("错误", _showErrors, GUILayout.Width(60)); + _showWarnings = EditorGUILayout.ToggleLeft("警告", _showWarnings, GUILayout.Width(60)); + _showInfo = EditorGUILayout.ToggleLeft("信息", _showInfo, GUILayout.Width(60)); + + GUILayout.Space(20); + + EditorGUILayout.LabelField("筛选:", GUILayout.Width(40)); + var newFilter = EditorGUILayout.TextField(_filterText); + if (newFilter != _filterText) + { + _filterText = newFilter; + ApplyFilter(); + } + + EditorGUILayout.EndHorizontal(); + + ApplyFilter(); + } + + /// + /// 应用筛选 + /// + private void ApplyFilter() + { + if (_currentReport == null) return; + + _filteredResults = _currentReport.Results.Where(r => + { + // 类型筛选 + if (r.IsError && !_showErrors) return false; + if (r.IsWarning && !_showWarnings) return false; + if (r.Type == ValidationResult.ResultType.Info && !_showInfo) return false; + + // 文本筛选 + if (!string.IsNullOrEmpty(_filterText)) + { + var lowerFilter = _filterText.ToLower(); + return (r.Message?.ToLower().Contains(lowerFilter) ?? false) || + (r.Category?.ToLower().Contains(lowerFilter) ?? false) || + (r.Detail?.ToLower().Contains(lowerFilter) ?? false); + } + + return true; + }).ToList(); + } + + /// + /// 绘制结果列表 + /// + private void DrawResults() + { + _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos); + + foreach (var result in _filteredResults) + { + DrawResultItem(result); + } + + if (_filteredResults.Count == 0) + { + EditorGUILayout.HelpBox("没有匹配的结果", MessageType.Info); + } + + EditorGUILayout.EndScrollView(); + } + + /// + /// 绘制单个结果项 + /// + private void DrawResultItem(ValidationResult result) + { + // 根据类型选择颜色 + Color bgColor = result.Type switch + { + ValidationResult.ResultType.Error => new Color(1f, 0.8f, 0.8f), + ValidationResult.ResultType.Warning => new Color(1f, 0.9f, 0.7f), + _ => new Color(0.9f, 0.9f, 0.9f) + }; + + var oldBg = GUI.backgroundColor; + GUI.backgroundColor = bgColor; + + EditorGUILayout.BeginVertical(GUI.skin.box); + GUI.backgroundColor = oldBg; + + // 头部:类型 + 分类 + EditorGUILayout.BeginHorizontal(); + + var typeLabel = result.Type switch + { + ValidationResult.ResultType.Error => "[错误]", + ValidationResult.ResultType.Warning => "[警告]", + _ => "[信息]" + }; + + EditorGUILayout.LabelField(typeLabel, GUILayout.Width(50)); + EditorGUILayout.LabelField(result.Category, EditorStyles.boldLabel, GUILayout.Width(100)); + + GUILayout.FlexibleSpace(); + + // 行索引信息 + if (result.RowIndex > 0) + { + EditorGUILayout.LabelField($"Row:{result.RowIndex}", GUILayout.Width(70)); + } + if (result.ExcelRowNumber > 0) + { + EditorGUILayout.LabelField($"Line:{result.ExcelRowNumber}", GUILayout.Width(70)); + } + + EditorGUILayout.EndHorizontal(); + + // 消息内容 + EditorGUILayout.LabelField(result.Message, EditorStyles.wordWrappedLabel); + + // 详细信息 + if (!string.IsNullOrEmpty(result.Detail)) + { + EditorGUI.indentLevel++; + EditorGUILayout.LabelField(result.Detail, EditorStyles.miniLabel); + EditorGUI.indentLevel--; + } + + // 源文件 + if (!string.IsNullOrEmpty(result.SourceFile)) + { + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField($"源: {result.SourceFile}", EditorStyles.miniLabel); + + if (GUILayout.Button("打开", GUILayout.Width(60))) + { + OpenSourceFile(result.SourceFile); + } + + EditorGUILayout.EndHorizontal(); + } + + EditorGUILayout.EndVertical(); + + GUILayout.Space(2); + } + + /// + /// 打开源文件 + /// + private void OpenSourceFile(string path) + { + #if UNITY_EDITOR + if (System.IO.File.Exists(path)) + { + Application.OpenURL("file://" + path); + } + else + { + Debug.LogWarning($"文件不存在: {path}"); + } + #endif + } + + /// + /// 验证全部配置 + /// + private void ValidateAll() + { + _currentReport = ConfigValidator.ValidateAll(); + ApplyFilter(); + + if (_currentReport.HasErrors) + { + Debug.LogError($"[ConfigValidator] 发现 {_currentReport.ErrorCount} 个错误!"); + } + else if (_currentReport.HasWarnings) + { + Debug.LogWarning($"[ConfigValidator] 发现 {_currentReport.WarningCount} 个警告"); + } + else + { + Debug.Log("[ConfigValidator] 所有配置验证通过!"); + } + } + + /// + /// 只验证行为树 + /// + private void ValidateBehaviourTrees() + { + _currentReport = new ValidationReport(); + + var trees = LoadAllAssets(); + foreach (var tree in trees) + { + var r = ConfigValidator.ValidateBehaviourTree(tree); + _currentReport.Results.AddRange(r.Results); + } + + _currentReport.AddInfo("验证", $"完成 {trees.Count} 棵行为树的验证"); + ApplyFilter(); + } + + /// + /// 只验证LUT配置 + /// + private void ValidateLuts() + { + _currentReport = new ValidationReport(); + + var databases = LoadAllAssets(); + foreach (var db in databases) + { + var r = ConfigValidator.ValidateLutConfigs(db); + _currentReport.Results.AddRange(r.Results); + } + + _currentReport.AddInfo("验证", $"完成 {databases.Count} 个LUT数据库的验证"); + ApplyFilter(); + } + + /// + /// 加载所有指定类型的资产 + /// + private List LoadAllAssets() where T : Object + { + var result = new List(); + #if UNITY_EDITOR + var guids = AssetDatabase.FindAssets($"t:{typeof(T).Name}"); + foreach (var guid in guids) + { + var path = AssetDatabase.GUIDToAssetPath(guid); + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset != null) + { + result.Add(asset); + } + } + #endif + return result; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/ConfigValidatorWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/ConfigValidatorWindow.cs.meta new file mode 100644 index 0000000..674baad --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/ConfigValidatorWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d214a6c2aac5b7e45af9ec01475397c2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/ExcelDiffWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/ExcelDiffWindow.cs new file mode 100644 index 0000000..83dc0a2 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/ExcelDiffWindow.cs @@ -0,0 +1,722 @@ +using NPOI.SS.UserModel; +using NPOI.XSSF.UserModel; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// Excel Diff 工具窗口 + /// 对比两个Excel文件的差异 + /// + public class ExcelDiffWindow : EditorWindow + { + private Vector2 _diffScrollPos; + private Vector2 _sheetScrollPos; + + // 文件路径 + private string _baseFilePath = ""; + private string _compareFilePath = ""; + + // 选项 + private bool _ignoreWhitespace = true; + private bool _ignoreCase = false; + private bool _showOnlyDiff = false; + private int _contextLines = 2; + + // 差异结果 + private List _diffs = new List(); + private string _selectedSheet = ""; + private List _availableSheets = new List(); + private DiffResult _diffResult; + + // 折叠状态 + private bool _showSettings = true; + private bool _showSummary = true; + private bool _showDiffDetails = true; + + // 样式 + private GUIStyle _addedStyle; + private GUIStyle _removedStyle; + private GUIStyle _modifiedStyle; + private bool _stylesInitialized; + + [MenuItem("Window/Activity Editor/Excel Diff Tool")] + public static void ShowWindow() + { + var window = GetWindow("Excel Diff"); + window.minSize = new Vector2(800, 600); + window.Show(); + } + + private void InitializeStyles() + { + if (_stylesInitialized) return; + + _addedStyle = new GUIStyle(EditorStyles.label); + _addedStyle.normal.background = MakeTexture(2, 2, new Color(0.2f, 0.8f, 0.2f, 0.3f)); + + _removedStyle = new GUIStyle(EditorStyles.label); + _removedStyle.normal.background = MakeTexture(2, 2, new Color(0.8f, 0.2f, 0.2f, 0.3f)); + + _modifiedStyle = new GUIStyle(EditorStyles.label); + _modifiedStyle.normal.background = MakeTexture(2, 2, new Color(0.8f, 0.8f, 0.2f, 0.3f)); + + _stylesInitialized = true; + } + + private Texture2D MakeTexture(int width, int height, Color color) + { + Color[] pixels = new Color[width * height]; + for (int i = 0; i < pixels.Length; i++) + pixels[i] = color; + Texture2D result = new Texture2D(width, height); + result.SetPixels(pixels); + result.Apply(); + return result; + } + + private void OnGUI() + { + InitializeStyles(); + + DrawToolbar(); + + _showSettings = EditorGUILayout.Foldout(_showSettings, "⚙️ 设置", EditorStyles.foldoutHeader); + if (_showSettings) + { + DrawSettingsSection(); + } + + EditorGUILayout.Space(10); + + if (_diffResult != null) + { + _showSummary = EditorGUILayout.Foldout(_showSummary, "📊 差异概览", EditorStyles.foldoutHeader); + if (_showSummary) + { + DrawSummarySection(); + } + + EditorGUILayout.Space(10); + + _showDiffDetails = EditorGUILayout.Foldout(_showDiffDetails, "🔍 详细差异", EditorStyles.foldoutHeader); + if (_showDiffDetails) + { + DrawDiffDetailsSection(); + } + } + } + + #region 工具栏 + + private void DrawToolbar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + if (GUILayout.Button("选择基准文件", EditorStyles.toolbarButton, GUILayout.Width(100))) + { + var path = EditorUtility.OpenFilePanel("选择基准Excel", "", "xlsx"); + if (!string.IsNullOrEmpty(path)) + { + _baseFilePath = path; + ScanSheets(); + } + } + + if (GUILayout.Button("选择对比文件", EditorStyles.toolbarButton, GUILayout.Width(100))) + { + var path = EditorUtility.OpenFilePanel("选择对比Excel", "", "xlsx"); + if (!string.IsNullOrEmpty(path)) + { + _compareFilePath = path; + } + } + + GUILayout.FlexibleSpace(); + + if (GUILayout.Button("执行对比", EditorStyles.toolbarButton, GUILayout.Width(80))) + { + ExecuteDiff(); + } + + if (GUILayout.Button("导出报告", EditorStyles.toolbarButton, GUILayout.Width(80))) + { + ExportReport(); + } + + EditorGUILayout.EndHorizontal(); + + // 显示当前选择的文件 + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("基准:", GUILayout.Width(40)); + EditorGUILayout.LabelField(Path.GetFileName(_baseFilePath) ?? "未选择", EditorStyles.miniLabel); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("对比:", GUILayout.Width(40)); + EditorGUILayout.LabelField(Path.GetFileName(_compareFilePath) ?? "未选择", EditorStyles.miniLabel); + EditorGUILayout.EndHorizontal(); + } + + #endregion + + #region 设置区域 + + private void DrawSettingsSection() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + // Sheet选择 + if (_availableSheets.Count > 0) + { + int selectedIndex = _availableSheets.IndexOf(_selectedSheet); + if (selectedIndex < 0) selectedIndex = 0; + + int newIndex = EditorGUILayout.Popup("Sheet:", selectedIndex, _availableSheets.ToArray()); + if (newIndex != selectedIndex) + { + _selectedSheet = _availableSheets[newIndex]; + } + } + else + { + EditorGUILayout.LabelField("Sheet: (请先选择基准文件)", EditorStyles.miniLabel); + } + + EditorGUILayout.Space(5); + + // 对比选项 + _ignoreWhitespace = EditorGUILayout.Toggle("忽略空白字符", _ignoreWhitespace); + _ignoreCase = EditorGUILayout.Toggle("忽略大小写", _ignoreCase); + _showOnlyDiff = EditorGUILayout.Toggle("只显示差异行", _showOnlyDiff); + + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("上下文行数:", GUILayout.Width(80)); + _contextLines = EditorGUILayout.IntSlider(_contextLines, 0, 5); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.EndVertical(); + } + + #endregion + + #region 概览区域 + + private void DrawSummarySection() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + if (_diffResult == null) + { + EditorGUILayout.LabelField("尚未执行对比", EditorStyles.centeredGreyMiniLabel); + EditorGUILayout.EndVertical(); + return; + } + + // 统计信息 + EditorGUILayout.BeginHorizontal(); + + EditorGUILayout.BeginVertical(); + EditorGUILayout.LabelField("新增行", EditorStyles.boldLabel); + EditorGUILayout.LabelField(_diffResult.AddedRows.Count.ToString(), new GUIStyle(EditorStyles.largeLabel) { normal = { textColor = Color.green } }); + EditorGUILayout.EndVertical(); + + EditorGUILayout.BeginVertical(); + EditorGUILayout.LabelField("删除行", EditorStyles.boldLabel); + EditorGUILayout.LabelField(_diffResult.RemovedRows.Count.ToString(), new GUIStyle(EditorStyles.largeLabel) { normal = { textColor = Color.red } }); + EditorGUILayout.EndVertical(); + + EditorGUILayout.BeginVertical(); + EditorGUILayout.LabelField("修改单元格", EditorStyles.boldLabel); + EditorGUILayout.LabelField(_diffResult.ModifiedCells.Count.ToString(), new GUIStyle(EditorStyles.largeLabel) { normal = { textColor = Color.yellow } }); + EditorGUILayout.EndVertical(); + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(10); + + // Sheet级别统计 + if (_diffResult.SheetResults.Count > 0) + { + EditorGUILayout.LabelField("各Sheet差异:", EditorStyles.boldLabel); + foreach (var sheetResult in _diffResult.SheetResults) + { + var color = sheetResult.HasChanges ? Color.yellow : Color.gray; + var prevColor = GUI.color; + GUI.color = color; + EditorGUILayout.LabelField($" {sheetResult.SheetName}: +{sheetResult.AddedCount} -{sheetResult.RemovedCount} ~{sheetResult.ModifiedCount}"); + GUI.color = prevColor; + } + } + + EditorGUILayout.EndVertical(); + } + + #endregion + + #region 详细差异 + + private void DrawDiffDetailsSection() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + if (_diffs.Count == 0) + { + EditorGUILayout.LabelField("无差异或尚未执行对比", EditorStyles.centeredGreyMiniLabel); + EditorGUILayout.EndVertical(); + return; + } + + // 过滤差异 + var filteredDiffs = _showOnlyDiff + ? _diffs.Where(d => d.Type != DiffType.Unchanged).ToList() + : _diffs; + + _diffScrollPos = EditorGUILayout.BeginScrollView(_diffScrollPos); + + // 表头 + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + EditorGUILayout.LabelField("行", GUILayout.Width(50)); + EditorGUILayout.LabelField("列", GUILayout.Width(80)); + EditorGUILayout.LabelField("基准值", GUILayout.Width(200)); + EditorGUILayout.LabelField("对比值", GUILayout.Width(200)); + EditorGUILayout.EndHorizontal(); + + // 差异列表 + foreach (var diff in filteredDiffs) + { + DrawDiffItem(diff); + } + + EditorGUILayout.EndScrollView(); + + EditorGUILayout.EndVertical(); + } + + private void DrawDiffItem(DiffItem diff) + { + GUIStyle style = diff.Type switch + { + DiffType.Added => _addedStyle, + DiffType.Removed => _removedStyle, + DiffType.Modified => _modifiedStyle, + _ => EditorStyles.label + }; + + EditorGUILayout.BeginHorizontal(style); + + // 行号 + string rowText = diff.Type switch + { + DiffType.Added => $"+{diff.Row}", + DiffType.Removed => $"-{diff.Row}", + _ => diff.Row.ToString() + }; + EditorGUILayout.LabelField(rowText, GUILayout.Width(50)); + + // 列名 + EditorGUILayout.LabelField(diff.ColumnName ?? "", GUILayout.Width(80)); + + // 基准值 + if (diff.Type == DiffType.Added) + { + EditorGUILayout.LabelField("", GUILayout.Width(200)); + } + else + { + EditorGUILayout.LabelField(Truncate(diff.BaseValue, 30), GUILayout.Width(200)); + } + + // 对比值 + if (diff.Type == DiffType.Removed) + { + EditorGUILayout.LabelField("", GUILayout.Width(200)); + } + else + { + EditorGUILayout.LabelField(Truncate(diff.CompareValue, 30), GUILayout.Width(200)); + } + + EditorGUILayout.EndHorizontal(); + } + + private string Truncate(string value, int maxLength) + { + if (string.IsNullOrEmpty(value)) return ""; + if (value.Length <= maxLength) return value; + return value.Substring(0, maxLength) + "..."; + } + + #endregion + + #region Diff 逻辑 + + private void ScanSheets() + { + _availableSheets.Clear(); + + if (string.IsNullOrEmpty(_baseFilePath) || !File.Exists(_baseFilePath)) + return; + + try + { + using (var file = new FileStream(_baseFilePath, FileMode.Open, FileAccess.Read)) + { + var workbook = new XSSFWorkbook(file); + + for (int i = 0; i < workbook.NumberOfSheets; i++) + { + _availableSheets.Add(workbook.GetSheetName(i)); + } + + if (_availableSheets.Count > 0 && string.IsNullOrEmpty(_selectedSheet)) + { + _selectedSheet = _availableSheets[0]; + } + + workbook.Close(); + } + } + catch (Exception ex) + { + Debug.LogError($"[ExcelDiffWindow] 扫描Sheet失败: {ex.Message}"); + } + } + + private void ExecuteDiff() + { + if (string.IsNullOrEmpty(_baseFilePath) || !File.Exists(_baseFilePath)) + { + EditorUtility.DisplayDialog("错误", "请选择有效的基准文件", "确定"); + return; + } + + if (string.IsNullOrEmpty(_compareFilePath) || !File.Exists(_compareFilePath)) + { + EditorUtility.DisplayDialog("错误", "请选择有效的对比文件", "确定"); + return; + } + + _diffResult = new DiffResult(); + _diffs.Clear(); + + try + { + using (var baseFile = new FileStream(_baseFilePath, FileMode.Open, FileAccess.Read)) + using (var compareFile = new FileStream(_compareFilePath, FileMode.Open, FileAccess.Read)) + { + var baseWorkbook = new XSSFWorkbook(baseFile); + var compareWorkbook = new XSSFWorkbook(compareFile); + + // 对比所有Sheet或指定Sheet + var sheetsToCompare = string.IsNullOrEmpty(_selectedSheet) + ? Enumerable.Range(0, baseWorkbook.NumberOfSheets).Select(i => baseWorkbook.GetSheetName(i)) + : new[] { _selectedSheet }; + + foreach (var sheetName in sheetsToCompare) + { + var baseSheet = baseWorkbook.GetSheet(sheetName); + var compareSheet = compareWorkbook.GetSheet(sheetName); + + var sheetResult = CompareSheet(baseSheet, compareSheet, sheetName); + _diffResult.SheetResults.Add(sheetResult); + + _diffs.AddRange(sheetResult.Diffs); + } + + baseWorkbook.Close(); + compareWorkbook.Close(); + } + + Debug.Log($"[ExcelDiffWindow] 对比完成,发现 {_diffs.Count} 处差异"); + } + catch (Exception ex) + { + EditorUtility.DisplayDialog("错误", $"对比失败: {ex.Message}", "确定"); + Debug.LogError($"[ExcelDiffWindow] 对比失败: {ex}"); + } + } + + private SheetDiffResult CompareSheet(ISheet baseSheet, ISheet compareSheet, string sheetName) + { + var result = new SheetDiffResult { SheetName = sheetName }; + + if (baseSheet == null && compareSheet == null) + return result; + + if (baseSheet == null) + { + // 新增Sheet + result.AddedCount = compareSheet.LastRowNum + 1; + return result; + } + + if (compareSheet == null) + { + // 删除Sheet + result.RemovedCount = baseSheet.LastRowNum + 1; + return result; + } + + // 读取所有行数据 + var baseRows = ReadAllRows(baseSheet); + var compareRows = ReadAllRows(compareSheet); + + // 获取ID列索引(第0行是NAME行) + int idColumnIndex = 0; + var nameRow = baseSheet.GetRow(0); + if (nameRow != null) + { + for (int i = 0; i < nameRow.LastCellNum; i++) + { + if (GetCellValue(nameRow.GetCell(i))?.ToLower().Contains("id") == true) + { + idColumnIndex = i; + break; + } + } + } + + // 建立ID到行的映射 + var baseRowMap = baseRows.Where(r => r.RowNum >= 3).ToDictionary( + r => GetCellValue(r.GetCell(idColumnIndex)) ?? $"row_{r.RowNum}", + r => r); + var compareRowMap = compareRows.Where(r => r.RowNum >= 3).ToDictionary( + r => GetCellValue(r.GetCell(idColumnIndex)) ?? $"row_{r.RowNum}", + r => r); + + var baseIds = new HashSet(baseRowMap.Keys); + var compareIds = new HashSet(compareRowMap.Keys); + + // 查找新增和删除 + var addedIds = compareIds.Except(baseIds).ToList(); + var removedIds = baseIds.Except(compareIds).ToList(); + var commonIds = baseIds.Intersect(compareIds).ToList(); + + // 记录新增行 + foreach (var id in addedIds) + { + var row = compareRowMap[id]; + result.AddedCount++; + result.Diffs.Add(new DiffItem + { + Type = DiffType.Added, + Row = row.RowNum + 1, + ColumnName = "整行", + CompareValue = "新增行" + }); + } + + // 记录删除行 + foreach (var id in removedIds) + { + var row = baseRowMap[id]; + result.RemovedCount++; + result.Diffs.Add(new DiffItem + { + Type = DiffType.Removed, + Row = row.RowNum + 1, + ColumnName = "整行", + BaseValue = "删除行" + }); + } + + // 对比相同ID的行 + foreach (var id in commonIds) + { + var baseRow = baseRowMap[id]; + var compareRow = compareRowMap[id]; + + int maxCol = Math.Max(baseRow.LastCellNum, compareRow.LastCellNum); + var nameRowData = baseSheet.GetRow(0); + + for (int col = 0; col < maxCol; col++) + { + var baseValue = GetCellValue(baseRow.GetCell(col)); + var compareValue = GetCellValue(compareRow.GetCell(col)); + + // 标准化比较 + if (_ignoreWhitespace) + { + baseValue = baseValue?.Trim(); + compareValue = compareValue?.Trim(); + } + + var comparison = _ignoreCase + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + if (!string.Equals(baseValue, compareValue, comparison)) + { + result.ModifiedCount++; + + var colName = nameRowData?.GetCell(col)?.StringCellValue ?? $"Col{col}"; + + result.Diffs.Add(new DiffItem + { + Type = DiffType.Modified, + Row = baseRow.RowNum + 1, + ColumnName = colName, + BaseValue = baseValue ?? "", + CompareValue = compareValue ?? "" + }); + } + } + } + + return result; + } + + private List ReadAllRows(ISheet sheet) + { + var rows = new List(); + for (int i = 0; i <= sheet.LastRowNum; i++) + { + var row = sheet.GetRow(i); + if (row != null) + { + rows.Add(row); + } + } + return rows; + } + + private string GetCellValue(ICell cell) + { + if (cell == null) return ""; + + switch (cell.CellType) + { + case CellType.String: + return cell.StringCellValue; + case CellType.Numeric: + return cell.NumericCellValue.ToString(); + case CellType.Boolean: + return cell.BooleanCellValue.ToString(); + default: + return ""; + } + } + + private void ExportReport() + { + if (_diffResult == null || _diffs.Count == 0) + { + EditorUtility.DisplayDialog("提示", "没有差异可导出", "确定"); + return; + } + + var path = EditorUtility.SaveFilePanel("导出差异报告", "", "ExcelDiffReport", "txt"); + if (string.IsNullOrEmpty(path)) return; + + try + { + var sb = new StringBuilder(); + + sb.AppendLine("╔════════════════════════════════════════════════════════════════╗"); + sb.AppendLine("║ Excel 差异对比报告 ║"); + sb.AppendLine("╚════════════════════════════════════════════════════════════════╝"); + sb.AppendLine(); + sb.AppendLine($"生成时间: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine($"基准文件: {_baseFilePath}"); + sb.AppendLine($"对比文件: {_compareFilePath}"); + sb.AppendLine(); + + sb.AppendLine("══════════════════════════════════════════════════════════════════"); + sb.AppendLine("统计摘要"); + sb.AppendLine("══════════════════════════════════════════════════════════════════"); + sb.AppendLine($"新增行: {_diffResult.AddedRows.Count}"); + sb.AppendLine($"删除行: {_diffResult.RemovedRows.Count}"); + sb.AppendLine($"修改单元格: {_diffResult.ModifiedCells.Count}"); + sb.AppendLine(); + + sb.AppendLine("══════════════════════════════════════════════════════════════════"); + sb.AppendLine("详细差异"); + sb.AppendLine("══════════════════════════════════════════════════════════════════"); + + foreach (var diff in _diffs.Where(d => d.Type != DiffType.Unchanged)) + { + var typeStr = diff.Type switch + { + DiffType.Added => "[新增]", + DiffType.Removed => "[删除]", + DiffType.Modified => "[修改]", + _ => "[未知]" + }; + + sb.AppendLine($"{typeStr} 行{diff.Row} {diff.ColumnName}"); + + if (diff.Type == DiffType.Modified) + { + sb.AppendLine($" 基准: {diff.BaseValue}"); + sb.AppendLine($" 对比: {diff.CompareValue}"); + } + else if (diff.Type == DiffType.Added) + { + sb.AppendLine($" 值: {diff.CompareValue}"); + } + else if (diff.Type == DiffType.Removed) + { + sb.AppendLine($" 值: {diff.BaseValue}"); + } + + sb.AppendLine(); + } + + File.WriteAllText(path, sb.ToString()); + EditorUtility.DisplayDialog("成功", "报告已导出", "确定"); + } + catch (Exception ex) + { + EditorUtility.DisplayDialog("错误", $"导出失败: {ex.Message}", "确定"); + } + } + + #endregion + + #region 数据类 + + private enum DiffType + { + Unchanged, + Added, + Removed, + Modified + } + + private class DiffItem + { + public DiffType Type; + public int Row; + public string ColumnName; + public string BaseValue; + public string CompareValue; + } + + private class DiffResult + { + public List AddedRows = new List(); + public List RemovedRows = new List(); + public List<(IRow Row, int Column, string OldValue, string NewValue)> ModifiedCells = new List<(IRow, int, string, string)>(); + public List SheetResults = new List(); + } + + private class SheetDiffResult + { + public string SheetName; + public int AddedCount; + public int RemovedCount; + public int ModifiedCount; + public bool HasChanges => AddedCount > 0 || RemovedCount > 0 || ModifiedCount > 0; + public List Diffs = new List(); + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/ExcelDiffWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/ExcelDiffWindow.cs.meta new file mode 100644 index 0000000..646f0ed --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/ExcelDiffWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 21b66a90256007b4682b22dddc58b59b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/ExcelMergeWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/ExcelMergeWindow.cs new file mode 100644 index 0000000..35587f1 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/ExcelMergeWindow.cs @@ -0,0 +1,501 @@ +using GameplayEditor.Excel; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// Excel合并工具窗口 + /// 提供可视化界面进行多表合并 + /// + public class ExcelMergeWindow : EditorWindow + { + private Vector2 _sourceListScrollPos; + private Vector2 _conflictScrollPos; + + // 配置 + private string _templatePath = ""; + private string _outputPath = ""; + private List _sourcePaths = new List(); + private string _sheetName = "ActivityStageConfig"; + private string _idColumnName = "ActivityStageID"; + private bool _generateConflictReport = true; + + // 合并结果 + private ExcelMergeTool.MergeResult _lastResult; + private bool _isMerging = false; + + // 折叠状态 + private bool _showConfig = true; + private bool _showSources = true; + private bool _showResult = false; + private bool _showConflicts = false; + + // 临时变量 + private string _newSourcePath = ""; + + [MenuItem("Window/Activity Editor/Excel Merge Tool")] + public static void ShowWindow() + { + var window = GetWindow("Excel合并工具"); + window.minSize = new Vector2(600, 500); + window.Show(); + } + + private void OnGUI() + { + DrawToolbar(); + + _showConfig = EditorGUILayout.Foldout(_showConfig, "⚙️ 合并配置", EditorStyles.foldoutHeader); + if (_showConfig) + { + DrawConfigSection(); + } + + EditorGUILayout.Space(10); + + _showSources = EditorGUILayout.Foldout(_showSources, "📁 源表列表", EditorStyles.foldoutHeader); + if (_showSources) + { + DrawSourcesSection(); + } + + EditorGUILayout.Space(10); + + if (_lastResult != null) + { + _showResult = EditorGUILayout.Foldout(_showResult, "📊 合并结果", EditorStyles.foldoutHeader); + if (_showResult) + { + DrawResultSection(); + } + + if (_lastResult.Conflicts.Count > 0) + { + _showConflicts = EditorGUILayout.Foldout(_showConflicts, $"⚠️ 冲突列表 ({_lastResult.Conflicts.Count})", EditorStyles.foldoutHeader); + if (_showConflicts) + { + DrawConflictsSection(); + } + } + } + } + + #region 工具栏 + + private void DrawToolbar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + if (GUILayout.Button("执行合并", EditorStyles.toolbarButton, GUILayout.Width(80))) + { + ExecuteMerge(); + } + + if (GUILayout.Button("加载配置", EditorStyles.toolbarButton, GUILayout.Width(70))) + { + LoadConfig(); + } + + if (GUILayout.Button("保存配置", EditorStyles.toolbarButton, GUILayout.Width(70))) + { + SaveConfig(); + } + + GUILayout.FlexibleSpace(); + + if (_isMerging) + { + EditorGUILayout.LabelField("合并中...", EditorStyles.toolbarButton); + } + + EditorGUILayout.EndHorizontal(); + } + + #endregion + + #region 配置区域 + + private void DrawConfigSection() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + // 模板表 + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("模板表:", GUILayout.Width(80)); + _templatePath = EditorGUILayout.TextField(_templatePath); + if (GUILayout.Button("浏览", GUILayout.Width(50))) + { + var path = EditorUtility.OpenFilePanel("选择模板表", "", "xlsx"); + if (!string.IsNullOrEmpty(path)) + { + _templatePath = path; + } + } + EditorGUILayout.EndHorizontal(); + + // 输出路径 + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("输出路径:", GUILayout.Width(80)); + _outputPath = EditorGUILayout.TextField(_outputPath); + if (GUILayout.Button("浏览", GUILayout.Width(50))) + { + var path = EditorUtility.SaveFilePanel("保存合并表", "", "Merged", "xlsx"); + if (!string.IsNullOrEmpty(path)) + { + _outputPath = path; + } + } + EditorGUILayout.EndHorizontal(); + + // Sheet名 + _sheetName = EditorGUILayout.TextField("Sheet名称:", _sheetName); + + // ID列名 + _idColumnName = EditorGUILayout.TextField("主键列名:", _idColumnName); + + // 选项 + _generateConflictReport = EditorGUILayout.Toggle("生成冲突报告", _generateConflictReport); + + EditorGUILayout.EndVertical(); + } + + #endregion + + #region 源表列表 + + private void DrawSourcesSection() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + // 添加新源表 + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("添加源表:", GUILayout.Width(70)); + _newSourcePath = EditorGUILayout.TextField(_newSourcePath); + if (GUILayout.Button("浏览", GUILayout.Width(50))) + { + var path = EditorUtility.OpenFilePanel("选择源表", "", "xlsx"); + if (!string.IsNullOrEmpty(path)) + { + _newSourcePath = path; + } + } + if (GUILayout.Button("+", GUILayout.Width(30))) + { + if (!string.IsNullOrEmpty(_newSourcePath) && !_sourcePaths.Contains(_newSourcePath)) + { + _sourcePaths.Add(_newSourcePath); + _newSourcePath = ""; + } + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(5); + + // 源表列表 + EditorGUILayout.LabelField($"已添加 {_sourcePaths.Count} 个源表:", EditorStyles.boldLabel); + + _sourceListScrollPos = EditorGUILayout.BeginScrollView(_sourceListScrollPos, GUILayout.MaxHeight(200)); + + for (int i = 0; i < _sourcePaths.Count; i++) + { + EditorGUILayout.BeginHorizontal(); + + // 文件名 + var fileName = Path.GetFileName(_sourcePaths[i]); + EditorGUILayout.LabelField($"{i + 1}. {fileName}", EditorStyles.label); + + GUILayout.FlexibleSpace(); + + // 删除按钮 + if (GUILayout.Button("×", GUILayout.Width(25))) + { + _sourcePaths.RemoveAt(i); + i--; + } + + EditorGUILayout.EndHorizontal(); + + // 显示完整路径(小字) + EditorGUI.indentLevel++; + EditorGUILayout.LabelField(_sourcePaths[i], EditorStyles.miniLabel); + EditorGUI.indentLevel--; + } + + EditorGUILayout.EndScrollView(); + + // 快速添加目录下所有Excel + EditorGUILayout.Space(5); + if (GUILayout.Button("添加目录下所有Excel")) + { + var folder = EditorUtility.OpenFolderPanel("选择包含Excel的目录", "", ""); + if (!string.IsNullOrEmpty(folder)) + { + var files = Directory.GetFiles(folder, "*.xlsx") + .Where(f => !f.Contains("~$")) // 排除临时文件 + .ToList(); + + foreach (var file in files) + { + if (!_sourcePaths.Contains(file)) + { + _sourcePaths.Add(file); + } + } + } + } + + EditorGUILayout.EndVertical(); + } + + #endregion + + #region 结果区域 + + private void DrawResultSection() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + if (_lastResult.Success) + { + EditorGUILayout.HelpBox("合并成功!", MessageType.Info); + + EditorGUILayout.LabelField($"总数据行数: {_lastResult.TotalRows}", EditorStyles.label); + EditorGUILayout.LabelField($"合并后行数: {_lastResult.MergedRows}", EditorStyles.label); + EditorGUILayout.LabelField($"冲突数: {_lastResult.ConflictCount}", EditorStyles.label); + + EditorGUILayout.Space(5); + + EditorGUILayout.BeginHorizontal(); + + if (GUILayout.Button("打开输出文件")) + { + EditorUtility.RevealInFinder(_lastResult.OutputPath); + } + + if (!string.IsNullOrEmpty(_lastResult.ReportPath) && File.Exists(_lastResult.ReportPath)) + { + if (GUILayout.Button("打开冲突报告")) + { + EditorUtility.OpenWithDefaultApp(_lastResult.ReportPath); + } + } + + EditorGUILayout.EndHorizontal(); + } + else + { + EditorGUILayout.HelpBox("合并失败!", MessageType.Error); + + EditorGUILayout.LabelField("错误信息:", EditorStyles.boldLabel); + foreach (var error in _lastResult.Errors) + { + EditorGUILayout.LabelField($"• {error}", EditorStyles.label); + } + } + + EditorGUILayout.EndVertical(); + } + + #endregion + + #region 冲突列表 + + private void DrawConflictsSection() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + var pendingConflicts = _lastResult.Conflicts + .Where(c => c.Resolution == ExcelMergeTool.ConflictResolution.Pending) + .ToList(); + + if (pendingConflicts.Count == 0) + { + EditorGUILayout.HelpBox("所有冲突已自动解决", MessageType.Info); + } + else + { + EditorGUILayout.HelpBox($"有 {pendingConflicts.Count} 个冲突需要手动处理", MessageType.Warning); + + _conflictScrollPos = EditorGUILayout.BeginScrollView(_conflictScrollPos, GUILayout.MaxHeight(300)); + + foreach (var conflict in pendingConflicts) + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + EditorGUILayout.LabelField($"ID: {conflict.IdValue} | 列: {conflict.ConflictColumn}", EditorStyles.boldLabel); + + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.BeginVertical(); + EditorGUILayout.LabelField($"文件1: {conflict.SourceFile1}", EditorStyles.miniLabel); + EditorGUILayout.LabelField($"值: {conflict.Value1}", EditorStyles.label); + EditorGUILayout.EndVertical(); + + EditorGUILayout.LabelField("VS", GUILayout.Width(30)); + + EditorGUILayout.BeginVertical(); + EditorGUILayout.LabelField($"文件2: {conflict.SourceFile2}", EditorStyles.miniLabel); + EditorGUILayout.LabelField($"值: {conflict.Value2}", EditorStyles.label); + EditorGUILayout.EndVertical(); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(5); + + // 解决选项 + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("解决:", GUILayout.Width(40)); + + if (GUILayout.Button("使用1", GUILayout.Width(60))) + { + conflict.Resolution = ExcelMergeTool.ConflictResolution.UseFirst; + } + + if (GUILayout.Button("使用2", GUILayout.Width(60))) + { + conflict.Resolution = ExcelMergeTool.ConflictResolution.UseSecond; + } + + if (GUILayout.Button("忽略", GUILayout.Width(50))) + { + conflict.Resolution = ExcelMergeTool.ConflictResolution.UseNewest; + } + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.EndVertical(); + + EditorGUILayout.Space(5); + } + + EditorGUILayout.EndScrollView(); + + // 重新合并按钮 + if (GUILayout.Button("应用解决方式并重新合并", GUILayout.Height(30))) + { + ExecuteMergeWithResolvedConflicts(); + } + } + + EditorGUILayout.EndVertical(); + } + + #endregion + + #region 操作 + + private void ExecuteMerge() + { + if (_sourcePaths.Count == 0) + { + EditorUtility.DisplayDialog("错误", "请至少添加一个源表", "确定"); + return; + } + + if (string.IsNullOrEmpty(_outputPath)) + { + EditorUtility.DisplayDialog("错误", "请设置输出路径", "确定"); + return; + } + + _isMerging = true; + + var config = new ExcelMergeTool.MergeConfig + { + TemplatePath = _templatePath, + OutputPath = _outputPath, + SourcePaths = new List(_sourcePaths), + SheetName = _sheetName, + IdColumnName = _idColumnName, + GenerateConflictReport = _generateConflictReport + }; + + var mergeTool = new ExcelMergeTool(); + _lastResult = mergeTool.Merge(config); + + _isMerging = false; + _showResult = true; + _showConflicts = _lastResult.Conflicts.Count > 0; + + Repaint(); + } + + private void ExecuteMergeWithResolvedConflicts() + { + // 使用已解决的冲突重新合并 + var config = new ExcelMergeTool.MergeConfig + { + TemplatePath = _templatePath, + OutputPath = _outputPath, + SourcePaths = new List(_sourcePaths), + SheetName = _sheetName, + IdColumnName = _idColumnName, + GenerateConflictReport = _generateConflictReport + }; + + // TODO: 将冲突解决方式传递给合并工具 + ExecuteMerge(); + } + + private void SaveConfig() + { + var config = new MergeWindowConfig + { + TemplatePath = _templatePath, + OutputPath = _outputPath, + SourcePaths = _sourcePaths, + SheetName = _sheetName, + IdColumnName = _idColumnName, + GenerateConflictReport = _generateConflictReport + }; + + var json = JsonUtility.ToJson(config, true); + var path = EditorUtility.SaveFilePanel("保存配置", "", "ExcelMergeConfig", "json"); + if (!string.IsNullOrEmpty(path)) + { + File.WriteAllText(path, json); + EditorUtility.DisplayDialog("成功", "配置已保存", "确定"); + } + } + + private void LoadConfig() + { + var path = EditorUtility.OpenFilePanel("加载配置", "", "json"); + if (string.IsNullOrEmpty(path)) return; + + try + { + var json = File.ReadAllText(path); + var config = JsonUtility.FromJson(json); + + _templatePath = config.TemplatePath; + _outputPath = config.OutputPath; + _sourcePaths = config.SourcePaths; + _sheetName = config.SheetName; + _idColumnName = config.IdColumnName; + _generateConflictReport = config.GenerateConflictReport; + + EditorUtility.DisplayDialog("成功", "配置已加载", "确定"); + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("错误", $"加载失败: {ex.Message}", "确定"); + } + } + + #endregion + + [System.Serializable] + private class MergeWindowConfig + { + public string TemplatePath; + public string OutputPath; + public List SourcePaths; + public string SheetName; + public string IdColumnName; + public bool GenerateConflictReport; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/ExcelMergeWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/ExcelMergeWindow.cs.meta new file mode 100644 index 0000000..0a471e4 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/ExcelMergeWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e76943d880fd46644a37627d1c932074 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/ExcelTemplateSyncWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/ExcelTemplateSyncWindow.cs new file mode 100644 index 0000000..2528264 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/ExcelTemplateSyncWindow.cs @@ -0,0 +1,378 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameplayEditor.Excel; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// Excel模板同步窗口 + /// 多策划表管理工具,用于同步表头结构和注释 + /// + public class ExcelTemplateSyncWindow : EditorWindow + { + private ExcelTemplateSync _syncTool; + + // 模板文件 + private string _templatePath = ""; + private string _selectedTemplateSheet = ""; + private List _templateSheets = new List(); + private SheetStructure _templateStructure; + + // 目标文件 + private List _targetPaths = new List(); + private Vector2 _targetScrollPos; + + // 同步选项 + private bool _syncDescriptionOnly = false; + private bool _showStructurePreview = true; + + // 对比结果 + private Dictionary> _compareResults; + private Vector2 _compareScrollPos; + + // 状态 + private string _statusMessage = ""; + private MessageType _statusType = MessageType.None; + + [MenuItem("Window/Activity Editor/Excel Template Sync")] + public static void ShowWindow() + { + GetWindow("Excel表同步"); + } + + private void OnEnable() + { + _syncTool = new ExcelTemplateSync(); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("Excel模板同步工具", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + DrawTemplateSection(); + EditorGUILayout.Space(); + + DrawTargetSection(); + EditorGUILayout.Space(); + + DrawOptionsSection(); + EditorGUILayout.Space(); + + DrawActionButtons(); + EditorGUILayout.Space(); + + DrawCompareResults(); + EditorGUILayout.Space(); + + DrawStructurePreview(); + EditorGUILayout.Space(); + + DrawStatusMessage(); + } + + /// + /// 模板文件区域 + /// + private void DrawTemplateSection() + { + EditorGUILayout.LabelField("模板文件", EditorStyles.boldLabel); + + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("模板Excel:", GUILayout.Width(80)); + EditorGUILayout.TextField(_templatePath); + if (GUILayout.Button("浏览", GUILayout.Width(60))) + { + var path = EditorUtility.OpenFilePanel("选择模板Excel", "表", "xlsx,xlsm"); + if (!string.IsNullOrEmpty(path)) + { + LoadTemplate(path); + } + } + EditorGUILayout.EndHorizontal(); + + if (_templateSheets.Count > 0) + { + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("选择Sheet:", GUILayout.Width(80)); + + var selectedIndex = _templateSheets.IndexOf(_selectedTemplateSheet); + var newIndex = EditorGUILayout.Popup(selectedIndex, _templateSheets.ToArray()); + if (newIndex != selectedIndex) + { + _selectedTemplateSheet = _templateSheets[newIndex]; + LoadTemplateStructure(); + } + EditorGUILayout.EndHorizontal(); + } + + if (_templateStructure != null) + { + EditorGUILayout.LabelField($"列数: {_templateStructure.Columns.Count}, 最后同步: {_templateStructure.LastSyncTime:yyyy-MM-dd HH:mm:ss}"); + } + } + + /// + /// 目标文件区域 + /// + private void DrawTargetSection() + { + EditorGUILayout.LabelField("目标文件", EditorStyles.boldLabel); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("添加文件")) + { + var path = EditorUtility.OpenFilePanel("选择目标Excel", "表", "xlsx,xlsm"); + if (!string.IsNullOrEmpty(path) && !_targetPaths.Contains(path)) + { + _targetPaths.Add(path); + } + } + if (GUILayout.Button("添加文件夹")) + { + var folder = EditorUtility.OpenFolderPanel("选择包含Excel的文件夹", "表", ""); + if (!string.IsNullOrEmpty(folder)) + { + var files = Directory.GetFiles(folder, "*.xlsx") + .Concat(Directory.GetFiles(folder, "*.xlsm")) + .Where(f => f != _templatePath) + .ToList(); + + foreach (var file in files) + { + if (!_targetPaths.Contains(file)) + { + _targetPaths.Add(file); + } + } + } + } + if (GUILayout.Button("清空")) + { + _targetPaths.Clear(); + } + EditorGUILayout.EndHorizontal(); + + // 目标文件列表 + _targetScrollPos = EditorGUILayout.BeginScrollView(_targetScrollPos, GUILayout.Height(100)); + var toRemove = new List(); + foreach (var path in _targetPaths) + { + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField(Path.GetFileName(path), GUILayout.Width(150)); + EditorGUILayout.LabelField(path, EditorStyles.miniLabel); + if (GUILayout.Button("X", GUILayout.Width(20))) + { + toRemove.Add(path); + } + EditorGUILayout.EndHorizontal(); + } + foreach (var path in toRemove) + { + _targetPaths.Remove(path); + } + EditorGUILayout.EndScrollView(); + + EditorGUILayout.LabelField($"共 {_targetPaths.Count} 个目标文件"); + } + + /// + /// 选项区域 + /// + private void DrawOptionsSection() + { + EditorGUILayout.LabelField("同步选项", EditorStyles.boldLabel); + + _syncDescriptionOnly = EditorGUILayout.ToggleLeft("仅同步注释(不修改列名和类型)", _syncDescriptionOnly); + _showStructurePreview = EditorGUILayout.ToggleLeft("显示结构预览", _showStructurePreview); + } + + /// + /// 操作按钮 + /// + private void DrawActionButtons() + { + EditorGUILayout.BeginHorizontal(); + + GUI.enabled = _templateStructure != null && _targetPaths.Count > 0; + if (GUILayout.Button("同步表头", GUILayout.Height(30))) + { + DoSync(); + } + GUI.enabled = true; + + GUI.enabled = _templatePath != "" && _targetPaths.Count > 0; + if (GUILayout.Button("对比结构", GUILayout.Height(30))) + { + DoCompare(); + } + GUI.enabled = true; + + EditorGUILayout.EndHorizontal(); + } + + /// + /// 对比结果区域 + /// + private void DrawCompareResults() + { + if (_compareResults == null || _compareResults.Count == 0) + return; + + EditorGUILayout.LabelField("结构差异", EditorStyles.boldLabel); + + _compareScrollPos = EditorGUILayout.BeginScrollView(_compareScrollPos, GUILayout.Height(150)); + + foreach (var kvp in _compareResults) + { + EditorGUILayout.BeginVertical(GUI.skin.box); + EditorGUILayout.LabelField(kvp.Key, EditorStyles.boldLabel); + + foreach (var diff in kvp.Value) + { + EditorGUILayout.LabelField($" • {diff}", EditorStyles.miniLabel); + } + + EditorGUILayout.EndVertical(); + } + + EditorGUILayout.EndScrollView(); + } + + /// + /// 结构预览区域 + /// + private void DrawStructurePreview() + { + if (!_showStructurePreview || _templateStructure == null) + return; + + EditorGUILayout.LabelField("表结构预览", EditorStyles.boldLabel); + + var previewScrollPos = EditorGUILayout.BeginScrollView(Vector2.zero, GUILayout.Height(150)); + + // 表头 + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("列名", EditorStyles.boldLabel, GUILayout.Width(150)); + EditorGUILayout.LabelField("类型", EditorStyles.boldLabel, GUILayout.Width(100)); + EditorGUILayout.LabelField("注释", EditorStyles.boldLabel); + EditorGUILayout.EndHorizontal(); + + // 数据行 + foreach (var col in _templateStructure.Columns) + { + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField(col.ColumnName, GUILayout.Width(150)); + EditorGUILayout.LabelField(col.ColumnType, GUILayout.Width(100)); + EditorGUILayout.LabelField(col.Description, EditorStyles.miniLabel); + EditorGUILayout.EndHorizontal(); + } + + EditorGUILayout.EndScrollView(); + } + + /// + /// 状态消息 + /// + private void DrawStatusMessage() + { + if (!string.IsNullOrEmpty(_statusMessage)) + { + EditorGUILayout.HelpBox(_statusMessage, _statusType); + } + } + + /// + /// 加载模板文件 + /// + private void LoadTemplate(string path) + { + _templatePath = path; + _templateSheets = _syncTool.GetSheetNames(path); + + if (_templateSheets.Count > 0) + { + _selectedTemplateSheet = _templateSheets[0]; + LoadTemplateStructure(); + } + + SetStatus($"已加载模板: {Path.GetFileName(path)}", MessageType.Info); + } + + /// + /// 加载模板结构 + /// + private void LoadTemplateStructure() + { + if (string.IsNullOrEmpty(_templatePath) || string.IsNullOrEmpty(_selectedTemplateSheet)) + return; + + _templateStructure = _syncTool.ReadSheetStructure(_templatePath, _selectedTemplateSheet); + } + + /// + /// 执行同步 + /// + private void DoSync() + { + if (_templateStructure == null) + { + SetStatus("模板结构为空,请先选择模板", MessageType.Error); + return; + } + + if (_targetPaths.Count == 0) + { + SetStatus("没有目标文件", MessageType.Error); + return; + } + + var result = _syncTool.BatchSync(_templateStructure, _targetPaths, _selectedTemplateSheet, _syncDescriptionOnly); + + SetStatus($"同步完成: 成功{result.SuccessCount}个, 失败{result.FailCount}个", + result.FailCount == 0 ? MessageType.Info : MessageType.Warning); + } + + /// + /// 执行对比 + /// + private void DoCompare() + { + if (string.IsNullOrEmpty(_templatePath)) + { + SetStatus("请先选择模板文件", MessageType.Error); + return; + } + + if (_targetPaths.Count == 0) + { + SetStatus("没有目标文件", MessageType.Error); + return; + } + + // 对比第一个目标文件 + _compareResults = _syncTool.CompareExcelStructure(_templatePath, _targetPaths[0]); + + if (_compareResults.Count == 0) + { + SetStatus("结构完全一致", MessageType.Info); + } + else + { + SetStatus($"发现{_compareResults.Count}个差异", MessageType.Warning); + } + } + + /// + /// 设置状态消息 + /// + private void SetStatus(string message, MessageType type) + { + _statusMessage = message; + _statusType = type; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/ExcelTemplateSyncWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/ExcelTemplateSyncWindow.cs.meta new file mode 100644 index 0000000..ff54bf0 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/ExcelTemplateSyncWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f14ef053d9ddae545b06ef033afd4e2d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/GameplayVerificationSetupWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/GameplayVerificationSetupWindow.cs new file mode 100644 index 0000000..319bee9 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/GameplayVerificationSetupWindow.cs @@ -0,0 +1,2081 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameplayEditor.Config; +using GameplayEditor.Core; +using GameplayEditor.Nodes; +using GameplayEditor.Nodes.Actions; +using GameplayEditor.Runtime; +using Cinemachine; +using FlowCanvas; +using FlowCanvas.Nodes; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using ParadoxNotion; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace GameplayEditor.Editor +{ + /// + /// GameplayEditor 功能验证一键设置窗口 + /// 根据《功能验证教程.md》自动创建所有验证所需的资产和场景 + /// + public class GameplayVerificationSetupWindow : EditorWindow + { + private Vector2 _scrollPos; + private string _logText = ""; + private bool _generateScene = true; + private bool _autoPlay = true; + private bool _runValidator = true; + + // 固定路径配置 + private const string VERIFY_ROOT = "Assets/Verification"; + private const string BT_PATH = VERIFY_ROOT + "/BT"; + private const string CONFIG_PATH = VERIFY_ROOT + "/Configs"; + private const string LUT_PATH = VERIFY_ROOT + "/Luts"; + private const string MATERIAL_PATH = VERIFY_ROOT + "/Materials"; + private const string SCENE_PATH = "Assets/Scenes/Scene_TestLevel.unity"; + + [MenuItem("Window/Activity Editor/Verification Setup")] + public static void ShowWindow() + { + var window = GetWindow("验证一键设置"); + window.minSize = new Vector2(450, 500); + window.Show(); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("GameplayEditor 功能验证一键设置", EditorStyles.boldLabel); + EditorGUILayout.HelpBox( + "本工具将根据《功能验证教程.md》自动创建所有验证所需的资产、行为树和场景。\n" + + "生成后可直接点击 Play 运行验证。", MessageType.Info); + + EditorGUILayout.Space(10); + + // 选项 + EditorGUILayout.LabelField("生成选项", EditorStyles.boldLabel); + _generateScene = EditorGUILayout.Toggle("生成测试场景", _generateScene); + _autoPlay = EditorGUILayout.Toggle("生成后自动播放", _autoPlay); + _runValidator = EditorGUILayout.Toggle("运行配置验证", _runValidator); + + EditorGUILayout.Space(10); + + // 操作按钮 + EditorGUILayout.BeginHorizontal(); + GUI.backgroundColor = new Color(0.4f, 0.8f, 0.4f); + if (GUILayout.Button("一键生成全部", GUILayout.Height(40))) + { + if (Event.current != null) Event.current.Use(); + EditorApplication.delayCall += () => + { + RunFullSetup(); + Repaint(); + }; + } + GUI.backgroundColor = Color.white; + + if (GUILayout.Button("仅生成资产", GUILayout.Height(40))) + { + if (Event.current != null) Event.current.Use(); + EditorApplication.delayCall += () => + { + GenerateAssetsOnly(); + Repaint(); + }; + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(5); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("生成/刷新场景")) + { + if (Event.current != null) Event.current.Use(); + EditorApplication.delayCall += () => + { + // 先生成资产,再生成场景 + EnsureAllAssetsValid(); + GenerateScene(); + ForceReimportAssets(); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + Repaint(); + Log("场景生成完成!可以点击Play运行。"); + }; + } + if (GUILayout.Button("打开验证场景")) + { + if (Event.current != null) Event.current.Use(); + EditorApplication.delayCall += () => + { + OpenVerificationScene(); + Repaint(); + }; + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(2); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("修复当前场景")) + { + if (Event.current != null) Event.current.Use(); + EditorApplication.delayCall += () => + { + // 先确保资产有效,再修复场景 + ForceReimportAssets(); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + RepairCurrentScene(); + Repaint(); + }; + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(5); + + EditorGUILayout.BeginHorizontal(); + GUI.backgroundColor = new Color(0.6f, 0.7f, 0.9f); + if (GUILayout.Button("导出配置表 (Excel)")) + { + if (Event.current != null) Event.current.Use(); + EditorApplication.delayCall += () => + { + ExportVerificationExcel(); + Repaint(); + }; + } + GUI.backgroundColor = new Color(0.9f, 0.7f, 0.6f); + if (GUILayout.Button("查看表信息")) + { + if (Event.current != null) Event.current.Use(); + EditorApplication.delayCall += () => + { + ViewExcelInfo(); + Repaint(); + }; + } + GUI.backgroundColor = Color.white; + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(10); + + // 日志显示 + EditorGUILayout.LabelField("操作日志", EditorStyles.boldLabel); + _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.Height(200)); + EditorGUILayout.TextArea(_logText, GUILayout.ExpandHeight(true)); + EditorGUILayout.EndScrollView(); + + if (GUILayout.Button("清空日志")) + { + _logText = ""; + } + } + + #region 主流程 + + private void RunFullSetup() + { + Log("========== 开始一键生成验证环境 =========="); + EditorUtility.DisplayProgressBar("验证设置", "创建目录...", 0.05f); + + try + { + EnsureDirectories(); + + EditorUtility.DisplayProgressBar("验证设置", "生成LUT与ID配置...", 0.15f); + var cameraLut = GenerateCameraLut(); + var mapInfoLut = GenerateMapInfoLut(); + var idConfig = EnsureIDRangeConfig(); + + EditorUtility.DisplayProgressBar("验证设置", "生成对话与事件配置...", 0.30f); + var dialogConfig = GenerateDialogConfig(); + var (eventBuilder, eventMapping) = GenerateEventBuilderConfig(); + + EditorUtility.DisplayProgressBar("验证设置", "生成行为树...", 0.50f); + var headerTree = GenerateHeaderTree(); + var bodyTree = GenerateBodyTree(); + + EditorUtility.DisplayProgressBar("验证设置", "生成验证行为树...", 0.55f); + var verificationTree = GenerateVerificationBT(); + + EditorUtility.DisplayProgressBar("验证设置", "生成关卡配置...", 0.65f); + var stageConfig = GenerateStageConfig(headerTree, bodyTree, mapInfoLut, cameraLut); + + EditorUtility.DisplayProgressBar("验证设置", "保存资产...", 0.75f); + AssetDatabase.SaveAssets(); + + if (_runValidator) + { + EditorUtility.DisplayProgressBar("验证设置", "运行配置验证...", 0.80f); + RunConfigValidation(stageConfig, eventBuilder, dialogConfig, headerTree, bodyTree); + } + + if (_generateScene) + { + EditorUtility.DisplayProgressBar("验证设置", "刷新脚本...", 0.85f); + // 强制刷新确保脚本已编译 + AssetDatabase.Refresh(); + + EditorUtility.DisplayProgressBar("验证设置", "生成验证场景...", 0.90f); + GenerateScene(); + } + + Log("========== 全部完成 =========="); + + if (_autoPlay && _generateScene) + { + EditorApplication.delayCall += () => + { + // 进入Play Mode前强制刷新所有资产 + Log("准备进入Play Mode,强制刷新资产..."); + ForceReimportAssets(); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + OpenVerificationScene(); + + // 延迟一帧再进入Play Mode,确保场景加载完成 + EditorApplication.delayCall += () => + { + EditorApplication.isPlaying = true; + }; + }; + } + } + finally + { + EditorUtility.ClearProgressBar(); + } + } + + private void GenerateAssetsOnly() + { + Log("========== 开始生成资产 =========="); + try + { + // 使用统一的资产生成逻辑 + EnsureAllAssetsValid(); + + // 额外生成验证行为树 + var verificationTree = GenerateVerificationBT(); + EditorUtility.SetDirty(verificationTree); + + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + if (_runValidator) + { + var stageConfig = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/StageConfig_1001.asset"); + var eventBuilder = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/EventBuilder_1001.asset"); + var dialogConfig = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/DialogInfo_Level1001.asset"); + var headerTree = AssetDatabase.LoadAssetAtPath(BT_PATH + "/BT_10010.asset"); + var bodyTree = AssetDatabase.LoadAssetAtPath(BT_PATH + "/BT_1001.asset"); + RunConfigValidation(stageConfig, eventBuilder, dialogConfig, headerTree, bodyTree); + } + + Log("========== 资产生成完成 =========="); + Log("资产已生成,可以点击'生成/刷新场景'创建场景,或直接点击'一键生成全部'。"); + } + finally + { + EditorUtility.ClearProgressBar(); + } + } + + #endregion + + #region 目录与通用 + + private void EnsureDirectories() + { + string[] dirs = new[] { VERIFY_ROOT, BT_PATH, CONFIG_PATH, LUT_PATH, MATERIAL_PATH, "Assets/Scenes" }; + foreach (var dir in dirs) + { + if (!AssetDatabase.IsValidFolder(dir)) + { + var parent = Path.GetDirectoryName(dir).Replace('\\', '/'); + var name = Path.GetFileName(dir); + AssetDatabase.CreateFolder(parent, name); + } + } + Log("目录检查完成"); + } + + private void Log(string msg) + { + _logText += $"[{System.DateTime.Now:HH:mm:ss}] {msg}\n"; + Debug.Log($"[VerificationSetup] {msg}"); + } + + /// + /// 确保所有资产有效(损坏的重新生成) + /// + private void EnsureAllAssetsValid() + { + Log("检查资产完整性..."); + + // 检查并重建关键资产 + var cameraLut = GenerateCameraLut(); + var mapInfoLut = GenerateMapInfoLut(); + var idConfig = EnsureIDRangeConfig(); + var dialogConfig = GenerateDialogConfig(); + var (eventBuilder, eventMapping) = GenerateEventBuilderConfig(); + var headerTree = GenerateHeaderTree(); + var bodyTree = GenerateBodyTree(); + var stageConfig = GenerateStageConfig(headerTree, bodyTree, mapInfoLut, cameraLut); + + // 强制保存并刷新 + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + Log("资产检查完成"); + } + + /// + /// 强制重新导入所有验证资产,修复脚本引用 + /// + private void ForceReimportAssets() + { + string[] assetPaths = new string[] + { + LUT_PATH + "/MapInfo_10001.asset", + LUT_PATH + "/CameraLut_101.asset", + CONFIG_PATH + "/DialogInfoDatabase.asset", + CONFIG_PATH + "/DialogInfoByStageConfig.asset", + CONFIG_PATH + "/DialogInfo_Level1001.asset", + }; + + foreach (var path in assetPaths) + { + if (File.Exists(path)) + { + AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceSynchronousImport); + } + } + + AssetDatabase.Refresh(); + } + + private T CreateOrLoadAsset(string path) where T : ScriptableObject, new() + { + // 首先检查文件是否存在,并验证 YAML 中的脚本引用 + if (File.Exists(path)) + { + bool isCorrupted = IsAssetCorrupted(path); + if (isCorrupted) + { + LogWarning($"检测到损坏资产,删除重建: {path}"); + AssetDatabase.DeleteAsset(path); + AssetDatabase.Refresh(); + } + } + + var existing = AssetDatabase.LoadAssetAtPath(path); + if (existing != null) + { + try + { + var typeName = existing.GetType().Name; + Log($"已存在: {path} ({typeName})"); + return existing; + } + catch (Exception ex) + { + LogWarning($"资产损坏({ex.Message}),删除重建: {path}"); + AssetDatabase.DeleteAsset(path); + AssetDatabase.Refresh(); + existing = null; + } + } + + // 创建新资产 + var asset = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(asset, path); + + // 保存并强制导入 + EditorUtility.SetDirty(asset); + AssetDatabase.SaveAssets(); + AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceSynchronousImport); + AssetDatabase.Refresh(); + + Log($"创建: {path}"); + return asset; + } + + /// + /// 检查资产文件是否损坏(通过检查 YAML 中的 m_Script 字段) + /// + private bool IsAssetCorrupted(string path) + { + try + { + var content = File.ReadAllText(path); + // 检查是否包含 m_Script: {fileID: 0}(脚本引用丢失) + if (content.Contains("m_Script: {fileID: 0}")) + { + return true; + } + } + catch + { + // 无法读取文件,视为损坏 + return true; + } + return false; + } + + /// + /// 检查组件是否有效(脚本引用未损坏) + /// + private bool IsComponentValid(Component component) + { + if (component == null) return false; + try + { + // 尝试访问组件的 GetType(),如果脚本引用损坏会抛出异常或返回 null + var type = component.GetType(); + if (type == null) return false; + + // 进一步验证:检查组件的名称是否能正常获取 + var name = type.Name; + return !string.IsNullOrEmpty(name); + } + catch + { + return false; + } + } + + #endregion + + #region LUT & ID + + private ActivityCameraLut GenerateCameraLut() + { + var path = LUT_PATH + "/CameraLut_101.asset"; + var lut = CreateOrLoadAsset(path); + lut.CameraMappings.Clear(); + + lut.CameraMappings.Add(new CameraMapping + { + MappingID = 101, PrefabPath = "", FOV = 60f, Priority = 10, + FollowOffset = new Vector3(0, 5, -10), LookAtOffset = Vector3.zero, BlendTime = 0.5f + }); + lut.CameraMappings.Add(new CameraMapping + { + MappingID = 102, PrefabPath = "", FOV = 50f, Priority = 15, + FollowOffset = new Vector3(0, 15, 0), LookAtOffset = Vector3.zero, BlendTime = 1f + }); + lut.CameraMappings.Add(new CameraMapping + { + MappingID = 103, PrefabPath = "", FOV = 40f, Priority = 20, + FollowOffset = new Vector3(0, 2, -3), LookAtOffset = Vector3.up, BlendTime = 0.8f + }); + lut.CameraMappings.Add(new CameraMapping + { + MappingID = 104, PrefabPath = "", FOV = 55f, Priority = 15, + FollowOffset = new Vector3(8, 4, 0), LookAtOffset = Vector3.zero, BlendTime = 0.6f + }); + + EditorUtility.SetDirty(lut); + Log($"CameraLut 生成完成 ({lut.CameraMappings.Count}个相机配置)"); + return lut; + } + + private ActivityMapInfoLut GenerateMapInfoLut() + { + var path = LUT_PATH + "/MapInfo_10001.asset"; + var lut = CreateOrLoadAsset(path); + lut.InfoID = 10001; + lut.Points = new List + { + new MapPoint { PointID = "PlayerSpawn", Position = new Vector3(0, 0, 0) }, + new MapPoint { PointID = "EnemySpawn_1", Position = new Vector3(5, 0, 5) }, + new MapPoint { PointID = "EnemySpawn_2", Position = new Vector3(-5, 0, 5) }, + new MapPoint { PointID = "BattleArena", Position = new Vector3(8, 0, 12) }, + new MapPoint { PointID = "ChoicePoint", Position = new Vector3(0, 0, 14) }, + new MapPoint { PointID = "RewardPoint", Position = new Vector3(-8, 0, 12) }, + new MapPoint { PointID = "ShopPosition", Position = new Vector3(4, 0, 22) }, + new MapPoint { PointID = "RestPosition", Position = new Vector3(-4, 0, 22) }, + new MapPoint { PointID = "BossShop", Position = new Vector3(0, 0, 30) }, + new MapPoint { PointID = "CameraSpot_Top", Position = new Vector3(0, 15, 15) }, + new MapPoint { PointID = "CameraSpot_Close",Position = new Vector3(0, 3, -2) }, + }; + EditorUtility.SetDirty(lut); + Log($"MapInfo_10001 生成完成 ({lut.Points.Count}个点位)"); + return lut; + } + + private IDRangeConfig EnsureIDRangeConfig() + { + var path = CONFIG_PATH + "/IDRangeConfig_Verify.asset"; + var config = CreateOrLoadAsset(path); + + bool hasTest = config.Ranges.Any(r => r.OwnerName == "TestDesigner"); + if (!hasTest) + { + config.Ranges.Add(new IDRange + { + OwnerName = "TestDesigner", + StartID = 1001, + EndID = 1099, + CurrentUsedID = 1000, + Description = "验证教程用ID段" + }); + EditorUtility.SetDirty(config); + Log("添加 TestDesigner ID段 1001-1099"); + } + return config; + } + + #endregion + + #region Dialog & Event + + private DialogInfoConfig GenerateDialogConfig() + { + var path = CONFIG_PATH + "/DialogInfo_Level1001.asset"; + var config = CreateOrLoadAsset(path); + config.SheetName = "DialogInfo_Level1001"; + config.Dialogs = new List + { + new DialogInfoData { ID = 1, CharacterName = "系统", + Text = "欢迎来到Roguelike关卡!探索三层区域,战斗、抉择、购物、休息。", + Duration = 4f, EnableRichText = true }, + new DialogInfoData { ID = 2, CharacterName = "导师", + Text = "前方有敌人!准备战斗!", + Duration = 2f, IsMask = true, MaskTarget = new Vector2(0.5f, 0.5f), ClickKey = "Space", EnableRichText = true }, + new DialogInfoData { ID = 3, CharacterName = "NPC", + Text = "你好,冒险者!", Text_EN = "Hello, Adventurer!", Text_JP = "こんにちは、冒険者さん!", Text_FR = "Bonjour, Aventurier!", + Duration = 0f, EnableRichText = true }, + new DialogInfoData { ID = 4, CharacterName = "系统", + Text = "选择你的道路...\n[A] 勇敢之路 - 直面挑战\n[B] 谨慎之路 - 迂回前进", + Duration = 0f, EnableRichText = true }, + new DialogInfoData { ID = 5, CharacterName = "商人", + Text = "欢迎光临!限时打折中!\n购买装备提升战力。", + Duration = 3f, EnableRichText = true }, + new DialogInfoData { ID = 6, CharacterName = "系统", + Text = "你选择了勇敢之路!战斗经验+50%", + Duration = 2f, EnableRichText = true }, + new DialogInfoData { ID = 7, CharacterName = "系统", + Text = "你选择了谨慎之路!防御力+30%", + Duration = 2f, EnableRichText = true }, + new DialogInfoData { ID = 8, CharacterName = "Boss", + Text = "准备迎接最终挑战!\n我已等候多时...", + Text_EN = "Prepare for the final challenge!", + Duration = 0f, EnableRichText = true }, + }; + EditorUtility.SetDirty(config); + Log("DialogInfo_Level1001 生成完成 (8条对话)"); + + // 创建对话数据库 + var dbPath = CONFIG_PATH + "/DialogInfoDatabase.asset"; + var database = CreateOrLoadAsset(dbPath); + if (!database.Configs.Contains(config)) + { + database.Configs.Add(config); + } + database.BuildStageCache(new Dictionary { { 1001, "DialogInfo_Level1001" } }); + EditorUtility.SetDirty(database); + Log("DialogInfoDatabase 生成完成"); + + // 创建关卡到对话表的映射配置 + var byStagePath = CONFIG_PATH + "/DialogInfoByStageConfig.asset"; + var byStageConfig = CreateOrLoadAsset(byStagePath); + byStageConfig.Entries.RemoveAll(e => e.StageID == 1001); + byStageConfig.Entries.Add(new DialogInfoByStageEntry + { + StageID = 1001, + DialogInfoSheetName = "DialogInfo_Level1001", + Doc = "验证关卡1001的对话配置" + }); + EditorUtility.SetDirty(byStageConfig); + Log("DialogInfoByStageConfig 生成完成"); + + return config; + } + + private (EventBuilderConfig builder, EventMappingConfig mapping) GenerateEventBuilderConfig() + { + var builderPath = CONFIG_PATH + "/EventBuilder_1001.asset"; + var mappingPath = CONFIG_PATH + "/EventMapping_1001.asset"; + + var builder = CreateOrLoadAsset(builderPath); + var mapping = CreateOrLoadAsset(mappingPath); + + builder.LevelID = 1001; + builder.NodeConfigs = new List + { + new EventNodeConfig + { + RowIndex = 1, LevelID = 1001, NodeID = 10001, NodeLayer = 1, + EventTypeGroup = new List { 1,2,3,4,5 }, + Priority = new List { 2000, 2000, 2000, 2000, 2000 }, + EventMappingID = 100001, InteractVisible = true, NodeState = true + }, + new EventNodeConfig + { + RowIndex = 2, LevelID = 1001, NodeID = 10002, NodeLayer = 2, + EventTypeGroup = new List { 1,2,3,4,5 }, + Priority = new List { 0, 0, 0, 5000, 5000 }, + EventMappingID = 100002, InteractVisible = true, NodeState = true + }, + new EventNodeConfig + { + RowIndex = 3, LevelID = 1001, NodeID = 10003, NodeLayer = 3, + EventTypeGroup = new List { 1,2,3,4,5 }, + Priority = new List { 0, 0, 0, 7000, 3000 }, + EventMappingID = 100003, InteractVisible = true, NodeState = true + } + }; + + builder.ActionConfigs = new List + { + new EventActionConfig { EventID = 9001, Type = Config.EventType.Battle, Pool = EventPool.Battle, Removed = true }, + new EventActionConfig { EventID = 9002, Type = Config.EventType.Choice, Pool = EventPool.Choice, Removed = true }, + new EventActionConfig { EventID = 9003, Type = Config.EventType.Reward, Pool = EventPool.Reward, Removed = true }, + new EventActionConfig { EventID = 9004, Type = Config.EventType.Shop, Pool = EventPool.Shop, Removed = false }, + new EventActionConfig { EventID = 9005, Type = Config.EventType.Rest, Pool = EventPool.Rest, Removed = false } + }; + + // Mapping + mapping.Entries = new List + { + new EventMappingEntry { MappingID = 100001, Doc = "Layer1映射", EventIDs = new List { 9001, 9002, 9003, 9004, 9005 } }, + new EventMappingEntry { MappingID = 100002, Doc = "Layer2映射", EventIDs = new List { 9004, 9005 } }, + new EventMappingEntry { MappingID = 100003, Doc = "Layer3映射", EventIDs = new List { 9004, 9005 } } + }; + EditorUtility.SetDirty(mapping); + + builder.EventMappingConfig = mapping; + EditorUtility.SetDirty(builder); + + Log("EventBuilder_1001 + EventMapping_1001 生成完成"); + return (builder, mapping); + } + + #endregion + + #region 行为树生成 + + private BehaviourTree GenerateHeaderTree() + { + var path = BT_PATH + "/BT_10010.asset"; + var tree = AssetDatabase.LoadAssetAtPath(path); + if (tree != null) + { + AssetDatabase.DeleteAsset(path); + } + tree = ScriptableObject.CreateInstance(); + tree.name = "BT_10010"; + AssetDatabase.CreateAsset(tree, path); + + // 根节点:Sequence(顺序) + var root = (Sequencer)tree.AddNode(typeof(Sequencer)); + root.name = "顺序"; + tree.primeNode = root; + + // 子节点:初始化黑板字典 + var initNode = CreateActionNode(tree, "InitializeBlackboardDicts", "初始化黑板字典", System.Array.Empty()); + tree.ConnectNodes(root, initNode); + + // 子节点:Log + var logNode = CreateActionNode(tree, "DoLog", "输出日志", new[] { "[验证] 头文件执行: 黑板4域初始化完成" }); + tree.ConnectNodes(root, logNode); + + ApplyTreeLayout(tree); + tree.SelfSerialize(); + EditorUtility.SetDirty(tree); + AssetDatabase.SaveAssets(); + Log("BT_10010 (头文件) 生成完成"); + return tree; + } + + private BehaviourTree GenerateBodyTree() + { + var path = BT_PATH + "/BT_1001.asset"; + var tree = AssetDatabase.LoadAssetAtPath(path); + if (tree != null) + { + AssetDatabase.DeleteAsset(path); + } + tree = ScriptableObject.CreateInstance(); + tree.name = "BT_1001"; + AssetDatabase.CreateAsset(tree, path); + + // ═══ 根节点:主流程 Sequencer ═══ + var root = (Sequencer)tree.AddNode(typeof(Sequencer)); + root.name = "主流程"; + tree.primeNode = root; + + // 开场对话 + 等待 + tree.ConnectNodes(root, CreateActionNode(tree, "DoShowDialog", "开场对话", new[] { "1" })); + tree.ConnectNodes(root, CreateActionNode(tree, "DoWait", "等待", new[] { "2" })); + + // ═══ Layer1 - 探索期 ═══ + var layer1 = (Sequencer)tree.AddNode(typeof(Sequencer)); + layer1.name = "Layer1-探索期"; + tree.ConnectNodes(root, layer1); + + tree.ConnectNodes(layer1, CreateActionNode(tree, "DoLog", "日志", new[] { "[Layer1] 进入探索期" })); + tree.ConnectNodes(layer1, CreateActionNode(tree, "DoSetVariable", "设置层级", new[] { "CurrentLayer", "1" })); + + // Layer1 加权乱选 [2000,2000,2000,2000,2000] + var sel1 = (WeightedProbabilitySelector)tree.AddNode(typeof(WeightedProbabilitySelector)); + sel1.name = "L1加权乱选"; + sel1.SetWeights(new List { 2000, 2000, 2000, 2000, 2000 }); + tree.ConnectNodes(layer1, sel1); + + // Battle分支 + var battle1 = (Sequencer)tree.AddNode(typeof(Sequencer)); + battle1.name = "Battle事件"; + tree.ConnectNodes(sel1, battle1); + tree.ConnectNodes(battle1, CreateActionNode(tree, "DoShowDialog", "战斗对话", new[] { "2" })); + tree.ConnectNodes(battle1, CreateActionNode(tree, "DoChangeCamera", "切换战斗相机", new[] { "104" })); + tree.ConnectNodes(battle1, CreateActionNode(tree, "DoSpawnNPC", "生成敌人", new[] { "3001", "BattleArena" })); + tree.ConnectNodes(battle1, CreateActionNode(tree, "DoSetFightTarget", "设置目标", new[] { "2001", "3001" })); + tree.ConnectNodes(battle1, CreateActionNode(tree, "DoWait", "战斗时间", new[] { "3" })); + tree.ConnectNodes(battle1, CreateActionNode(tree, "DoChangeCamera", "恢复相机", new[] { "101" })); + + // Choice分支 + var choice1 = (Sequencer)tree.AddNode(typeof(Sequencer)); + choice1.name = "Choice事件"; + tree.ConnectNodes(sel1, choice1); + tree.ConnectNodes(choice1, CreateActionNode(tree, "DoShowDialog", "选择对话", new[] { "4" })); + tree.ConnectNodes(choice1, CreateActionNode(tree, "DoWait", "思考时间", new[] { "2" })); + tree.ConnectNodes(choice1, CreateActionNode(tree, "DoSetVariable", "选择结果", new[] { "PlayerChoice", "A" })); + tree.ConnectNodes(choice1, CreateActionNode(tree, "DoShowDialog", "勇敢之路", new[] { "6" })); + tree.ConnectNodes(choice1, CreateActionNode(tree, "DoWait", "等待", new[] { "2" })); + + // Reward分支 + var reward1 = (Sequencer)tree.AddNode(typeof(Sequencer)); + reward1.name = "Reward事件"; + tree.ConnectNodes(sel1, reward1); + tree.ConnectNodes(reward1, CreateActionNode(tree, "DoLog", "日志", new[] { "[Reward] 获得战利品奖励" })); + tree.ConnectNodes(reward1, CreateActionNode(tree, "DoWait", "等待", new[] { "1" })); + + // Shop分支 + var shop1 = (Sequencer)tree.AddNode(typeof(Sequencer)); + shop1.name = "Shop事件"; + tree.ConnectNodes(sel1, shop1); + tree.ConnectNodes(shop1, CreateActionNode(tree, "DoShowDialog", "商店对话", new[] { "5" })); + tree.ConnectNodes(shop1, CreateActionNode(tree, "DoWait", "购物时间", new[] { "2" })); + + // Rest分支 + var rest1 = (Sequencer)tree.AddNode(typeof(Sequencer)); + rest1.name = "Rest事件"; + tree.ConnectNodes(sel1, rest1); + tree.ConnectNodes(rest1, CreateActionNode(tree, "DoLog", "日志", new[] { "[Rest] 休息恢复HP" })); + tree.ConnectNodes(rest1, CreateActionNode(tree, "DoWait", "休息时间", new[] { "2" })); + + // ═══ Layer2 - 恢复期 ═══ + var layer2 = (Sequencer)tree.AddNode(typeof(Sequencer)); + layer2.name = "Layer2-恢复期"; + tree.ConnectNodes(root, layer2); + + tree.ConnectNodes(layer2, CreateActionNode(tree, "DoLog", "日志", new[] { "[Layer2] 进入恢复期" })); + tree.ConnectNodes(layer2, CreateActionNode(tree, "DoSetVariable", "设置层级", new[] { "CurrentLayer", "2" })); + tree.ConnectNodes(layer2, CreateActionNode(tree, "DoChangeCamera", "切换俯视", new[] { "102" })); + + var sel2 = (WeightedProbabilitySelector)tree.AddNode(typeof(WeightedProbabilitySelector)); + sel2.name = "L2加权乱选"; + sel2.SetWeights(new List { 0, 0, 0, 5000, 5000 }); + tree.ConnectNodes(layer2, sel2); + + tree.ConnectNodes(sel2, CreateActionNode(tree, "DoLog", "日志", new[] { "[L2] 无战斗" })); + tree.ConnectNodes(sel2, CreateActionNode(tree, "DoLog", "日志", new[] { "[L2] 无抉择" })); + tree.ConnectNodes(sel2, CreateActionNode(tree, "DoLog", "日志", new[] { "[L2] 无奖励" })); + + var shop2 = (Sequencer)tree.AddNode(typeof(Sequencer)); + shop2.name = "L2商店"; + tree.ConnectNodes(sel2, shop2); + tree.ConnectNodes(shop2, CreateActionNode(tree, "DoShowDialog", "商店", new[] { "5" })); + tree.ConnectNodes(shop2, CreateActionNode(tree, "DoWait", "等待", new[] { "2" })); + + var rest2 = (Sequencer)tree.AddNode(typeof(Sequencer)); + rest2.name = "L2休息"; + tree.ConnectNodes(sel2, rest2); + tree.ConnectNodes(rest2, CreateActionNode(tree, "DoLog", "日志", new[] { "[L2] 休息恢复" })); + tree.ConnectNodes(rest2, CreateActionNode(tree, "DoWait", "等待", new[] { "2" })); + + // ═══ Layer3 - 决战 ═══ + var layer3 = (Sequencer)tree.AddNode(typeof(Sequencer)); + layer3.name = "Layer3-决战"; + tree.ConnectNodes(root, layer3); + + tree.ConnectNodes(layer3, CreateActionNode(tree, "DoLog", "日志", new[] { "[Layer3] 进入决战!" })); + tree.ConnectNodes(layer3, CreateActionNode(tree, "DoSetVariable", "设置层级", new[] { "CurrentLayer", "3" })); + tree.ConnectNodes(layer3, CreateActionNode(tree, "DoChangeCamera", "Boss特写", new[] { "103" })); + tree.ConnectNodes(layer3, CreateActionNode(tree, "DoShowDialog", "Boss对话", new[] { "8" })); + tree.ConnectNodes(layer3, CreateActionNode(tree, "DoWait", "Boss登场", new[] { "3" })); + + var sel3 = (WeightedProbabilitySelector)tree.AddNode(typeof(WeightedProbabilitySelector)); + sel3.name = "L3加权乱选"; + sel3.SetWeights(new List { 0, 0, 0, 7000, 3000 }); + tree.ConnectNodes(layer3, sel3); + + tree.ConnectNodes(sel3, CreateActionNode(tree, "DoLog", "日志", new[] { "[L3] 无战斗" })); + tree.ConnectNodes(sel3, CreateActionNode(tree, "DoLog", "日志", new[] { "[L3] 无抉择" })); + tree.ConnectNodes(sel3, CreateActionNode(tree, "DoLog", "日志", new[] { "[L3] 无奖励" })); + + var shop3 = (Sequencer)tree.AddNode(typeof(Sequencer)); + shop3.name = "L3商店"; + tree.ConnectNodes(sel3, shop3); + tree.ConnectNodes(shop3, CreateActionNode(tree, "DoShowDialog", "Boss商店", new[] { "5" })); + tree.ConnectNodes(shop3, CreateActionNode(tree, "DoWait", "等待", new[] { "2" })); + + var rest3 = (Sequencer)tree.AddNode(typeof(Sequencer)); + rest3.name = "L3休息"; + tree.ConnectNodes(sel3, rest3); + tree.ConnectNodes(rest3, CreateActionNode(tree, "DoLog", "日志", new[] { "[L3] 最后的休息" })); + tree.ConnectNodes(rest3, CreateActionNode(tree, "DoWait", "等待", new[] { "2" })); + + // ═══ 结束 ═══ + tree.ConnectNodes(root, CreateActionNode(tree, "DoChangeCamera", "恢复默认", new[] { "101" })); + tree.ConnectNodes(root, CreateActionNode(tree, "DoLog", "日志", new[] { "[完成] Roguelike关卡流程结束" })); + + ApplyTreeLayout(tree); + tree.SelfSerialize(); + EditorUtility.SetDirty(tree); + AssetDatabase.SaveAssets(); + Log("BT_1001 (正文-Roguelike三层) 生成完成"); + return tree; + } + + private ActionNode CreateActionNode(BehaviourTree tree, string codeName, string displayName, string[] rawParams) + { + var task = NodeTypeRegistry.CreateTask(codeName, displayName, rawParams); + var actionNode = (ActionNode)tree.AddNode(typeof(ActionNode)); + actionNode.action = task; + actionNode.name = displayName; + return actionNode; + } + + private void ApplyTreeLayout(BehaviourTree tree) + { + if (tree.primeNode == null) return; + int leafCounter = 0; + const float xStep = 220f; + const float yStep = 150f; + + float LayoutNode(Node node, int depth) + { + var children = node.outConnections; + if (children == null || children.Count == 0) + { + float x = leafCounter * xStep; + node.position = new Vector2(x, depth * yStep); + leafCounter++; + return x; + } + + float firstX = float.MaxValue; + float lastX = float.MinValue; + foreach (var conn in children) + { + float cx = LayoutNode(conn.targetNode, depth + 1); + if (cx < firstX) firstX = cx; + if (cx > lastX) lastX = cx; + } + float centerX = (firstX + lastX) / 2f; + node.position = new Vector2(centerX, depth * yStep); + return centerX; + } + + LayoutNode(tree.primeNode, 0); + } + + #endregion + + #region 验证行为树生成 + + private BehaviourTree GenerateVerificationBT() + { + var path = BT_PATH + "/BT_Verification.asset"; + var tree = AssetDatabase.LoadAssetAtPath(path); + if (tree != null) + { + AssetDatabase.DeleteAsset(path); + } + tree = ScriptableObject.CreateInstance(); + tree.name = "BT_Verification"; + AssetDatabase.CreateAsset(tree, path); + + // 根节点:顺序 + var root = (Sequencer)tree.AddNode(typeof(Sequencer)); + root.name = "验证流程"; + tree.primeNode = root; + + // 开始日志 + var logStart = CreateActionNode(tree, "DoLog", "输出日志", new[] { "[验证] 开始功能验证..." }); + tree.ConnectNodes(root, logStart); + + // 环境检查组 + var envGroup = (Sequencer)tree.AddNode(typeof(Sequencer)); + envGroup.name = "环境检查"; + tree.ConnectNodes(root, envGroup); + ConnectVerifyNode(tree, envGroup, "VerifyIDAllocator", "验证_ID分配器"); + ConnectVerifyNode(tree, envGroup, "VerifyStageConfig", "验证_关卡配置"); + ConnectVerifyNode(tree, envGroup, "VerifyTickMode", "验证_Tick模式"); + ConnectVerifyNode(tree, envGroup, "VerifyCameraLut", "验证_CameraLut"); + + // 资源映射组 + var resGroup = (Sequencer)tree.AddNode(typeof(Sequencer)); + resGroup.name = "资源映射"; + tree.ConnectNodes(root, resGroup); + ConnectVerifyNode(tree, resGroup, "VerifyMapInfoLut", "验证_MapInfoLut"); + ConnectVerifyNode(tree, resGroup, "VerifyDialogInfo", "验证_DialogInfo"); + ConnectVerifyNode(tree, resGroup, "VerifyBlackboard4D", "验证_黑板4域"); + + // 事件系统组 + var eventGroup = (Sequencer)tree.AddNode(typeof(Sequencer)); + eventGroup.name = "事件系统"; + tree.ConnectNodes(root, eventGroup); + ConnectVerifyNode(tree, eventGroup, "VerifyThreeLayerNodes", "验证_三层节点"); + ConnectVerifyNode(tree, eventGroup, "VerifyWeightRandom", "验证_权重随机"); + ConnectVerifyNode(tree, eventGroup, "VerifyFiveEventTypes", "验证_5种事件"); + ConnectVerifyNode(tree, eventGroup, "VerifyEventPoolConsume", "验证_事件池消耗"); + + // 交互系统组 + var interactGroup = (Sequencer)tree.AddNode(typeof(Sequencer)); + interactGroup.name = "交互系统"; + tree.ConnectNodes(root, interactGroup); + ConnectVerifyNode(tree, interactGroup, "VerifyPlayerControl", "验证_玩家控制"); + ConnectVerifyNode(tree, interactGroup, "VerifyInteractionZones", "验证_交互区域"); + ConnectVerifyNode(tree, interactGroup, "VerifyFlowCanvas", "验证_FlowCanvas"); + + // 调试工具组 + var debugGroup = (Sequencer)tree.AddNode(typeof(Sequencer)); + debugGroup.name = "调试工具"; + tree.ConnectNodes(root, debugGroup); + ConnectVerifyNode(tree, debugGroup, "VerifyDebugBreakpoint", "验证_断点调试"); + ConnectVerifyNode(tree, debugGroup, "VerifyPerfMonitor", "验证_性能监控"); + ConnectVerifyNode(tree, debugGroup, "VerifyRuntimeViz", "验证_运行时可视化"); + + // 运行时行为树组(需要轮询,放最后) + var runtimeGroup = (Sequencer)tree.AddNode(typeof(Sequencer)); + runtimeGroup.name = "运行时行为树"; + tree.ConnectNodes(root, runtimeGroup); + ConnectVerifyNode(tree, runtimeGroup, "VerifyHeaderExec", "验证_头文件执行"); + ConnectVerifyNode(tree, runtimeGroup, "VerifyBodyExec", "验证_正文执行"); + + // 结束日志 + var logEnd = CreateActionNode(tree, "DoLog", "输出日志", new[] { "[验证] 所有检查完成" }); + tree.ConnectNodes(root, logEnd); + + ApplyTreeLayout(tree); + tree.SelfSerialize(); + EditorUtility.SetDirty(tree); + AssetDatabase.SaveAssets(); + Log("BT_Verification (验证行为树) 生成完成"); + return tree; + } + + private void ConnectVerifyNode(BehaviourTree tree, BTComposite parent, string codeName, string displayName) + { + var node = CreateActionNode(tree, codeName, displayName, System.Array.Empty()); + tree.ConnectNodes(parent, node); + } + + #endregion + + #region 关卡配置 + + private ActivityStageConfig GenerateStageConfig( + BehaviourTree headerTree, BehaviourTree bodyTree, + ActivityMapInfoLut mapInfoLut, ActivityCameraLut cameraLut) + { + var path = CONFIG_PATH + "/StageConfig_1001.asset"; + var config = CreateOrLoadAsset(path); + + config.ActivityStageID = 1001; + config.Doc = "第一章第一关测试(自动验证生成)"; + config.SceneName = "Scene_TestLevel"; + config.MapInfo = mapInfoLut.InfoID; + config.CloseLoadingDelay = 1.5f; + config.CameraID = 101; + config.TickMode = TickRateMode.Normal; + config.TickRate = 60; + config.HeaderTree = headerTree; + config.BodyTree = bodyTree; + + EditorUtility.SetDirty(config); + Log("StageConfig_1001 生成完成"); + return config; + } + + #endregion + + #region 场景生成 + + private void GenerateScene() + { + // 清理旧场景,确保从头生成 + if (File.Exists(SCENE_PATH)) + { + AssetDatabase.DeleteAsset(SCENE_PATH); + AssetDatabase.Refresh(); + Log("已删除旧场景"); + } + + // 先确保所有依赖资产都是最新的 + Log("场景生成前刷新资产..."); + EnsureAllAssetsValid(); + + // 获取已生成的资产引用 + var cameraLut = AssetDatabase.LoadAssetAtPath(LUT_PATH + "/CameraLut_101.asset"); + var mapInfoLut = AssetDatabase.LoadAssetAtPath(LUT_PATH + "/MapInfo_10001.asset"); + var idConfig = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/IDRangeConfig_Verify.asset"); + var dialogConfig = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/DialogInfo_Level1001.asset"); + var eventBuilder = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/EventBuilder_1001.asset"); + var eventMapping = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/EventMapping_1001.asset"); + var headerTree = AssetDatabase.LoadAssetAtPath(BT_PATH + "/BT_10010.asset"); + var bodyTree = AssetDatabase.LoadAssetAtPath(BT_PATH + "/BT_1001.asset"); + + Log("资产已保存,开始生成场景..."); + + var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + + // 生成验证材质 + var mats = GenerateVerificationMaterials(); + + // Directional Light + var light = new GameObject("Directional Light"); + var lightComp = light.AddComponent(); + lightComp.type = LightType.Directional; + light.transform.rotation = Quaternion.Euler(50, -30, 0); + + // Main Camera + Cinemachine + var camera = new GameObject("Main Camera"); + camera.tag = "MainCamera"; + var camComp = camera.AddComponent(); + camera.AddComponent(); + camera.transform.position = new Vector3(0, 8, -12); + camera.transform.LookAt(new Vector3(0, 0, 3)); + + // Cinemachine Virtual Cameras (ID=101-104) + // 101 - 默认跟随 + var vcamGo101 = new GameObject("CM_VCam_101"); + var vcam101 = vcamGo101.AddComponent(); + vcam101.Priority = 10; + vcamGo101.transform.position = new Vector3(0, 8, -12); + vcamGo101.transform.LookAt(new Vector3(0, 0, 3)); + + // 102 - 俯视 + var vcamGo102 = new GameObject("CM_VCam_102"); + var vcam102 = vcamGo102.AddComponent(); + vcam102.Priority = 0; + vcamGo102.transform.position = new Vector3(0, 15, 0); + vcamGo102.transform.LookAt(new Vector3(0, 0, 15)); + + // 103 - Boss特写 + var vcamGo103 = new GameObject("CM_VCam_103"); + var vcam103 = vcamGo103.AddComponent(); + vcam103.Priority = 0; + vcamGo103.transform.position = new Vector3(0, 2, -3); + vcamGo103.transform.LookAt(new Vector3(0, 1.5f, 0)); + + // 104 - 战斗侧面 + var vcamGo104 = new GameObject("CM_VCam_104"); + var vcam104 = vcamGo104.AddComponent(); + vcam104.Priority = 0; + vcamGo104.transform.position = new Vector3(8, 4, 0); + vcamGo104.transform.LookAt(new Vector3(0, 1, 0)); + + // ═══════ 地形层级 (3个不同颜色地面) ═══════ + mats.TryGetValue("Green", out var matGreen); + mats.TryGetValue("Red", out var matRed); + mats.TryGetValue("Yellow", out var matYellow); + mats.TryGetValue("Blue", out var matBlue); + mats.TryGetValue("Cyan", out var matCyan); + mats.TryGetValue("Purple", out var matPurple); + mats.TryGetValue("White", out var matWhite); + + // Layer1 地面(绿色,出生区域) + var layer1Ground = GameObject.CreatePrimitive(PrimitiveType.Plane); + layer1Ground.name = "Ground_Layer1"; + layer1Ground.transform.position = new Vector3(0, -0.01f, 5); + layer1Ground.transform.localScale = new Vector3(3, 1, 2); + if (matGreen != null) layer1Ground.GetComponent().sharedMaterial = matGreen; + + // Layer1 事件区地面(浅色) + var eventGround = GameObject.CreatePrimitive(PrimitiveType.Plane); + eventGround.name = "Ground_Events"; + eventGround.transform.position = new Vector3(0, -0.01f, 13); + eventGround.transform.localScale = new Vector3(3, 1, 1); + if (matWhite != null) eventGround.GetComponent().sharedMaterial = matWhite; + + // Layer2 地面(蓝色) + var layer2Ground = GameObject.CreatePrimitive(PrimitiveType.Plane); + layer2Ground.name = "Ground_Layer2"; + layer2Ground.transform.position = new Vector3(0, -0.01f, 22); + layer2Ground.transform.localScale = new Vector3(2, 1, 1); + if (matCyan != null) layer2Ground.GetComponent().sharedMaterial = matCyan; + + // Layer3 地面(红色) + var layer3Ground = GameObject.CreatePrimitive(PrimitiveType.Plane); + layer3Ground.name = "Ground_Layer3"; + layer3Ground.transform.position = new Vector3(0, -0.01f, 30); + layer3Ground.transform.localScale = new Vector3(2, 1, 1); + if (matRed != null) layer3Ground.GetComponent().sharedMaterial = matRed; + + // ═══════ NPC群组 ═══════ + var npcParent = new GameObject("NPCs"); + + // 友方NPC(蓝色,出生点附近) + var npcFriend = GameObject.CreatePrimitive(PrimitiveType.Capsule); + npcFriend.name = "NPC_Friend_2001"; + npcFriend.transform.SetParent(npcParent.transform); + npcFriend.transform.position = new Vector3(2, 1, 2); + npcFriend.transform.localScale = new Vector3(0.6f, 1f, 0.6f); + if (matBlue != null) npcFriend.GetComponent().sharedMaterial = matBlue; + + // 敌方NPC(红色,Battle区域) + var npcEnemy1 = GameObject.CreatePrimitive(PrimitiveType.Capsule); + npcEnemy1.name = "NPC_Enemy_3001"; + npcEnemy1.transform.SetParent(npcParent.transform); + npcEnemy1.transform.position = new Vector3(8, 1, 13); + npcEnemy1.transform.localScale = new Vector3(0.7f, 1.1f, 0.7f); + if (matRed != null) npcEnemy1.GetComponent().sharedMaterial = matRed; + + var npcEnemy2 = GameObject.CreatePrimitive(PrimitiveType.Capsule); + npcEnemy2.name = "NPC_Enemy_3002"; + npcEnemy2.transform.SetParent(npcParent.transform); + npcEnemy2.transform.position = new Vector3(10, 1, 11); + npcEnemy2.transform.localScale = new Vector3(0.7f, 1.1f, 0.7f); + if (matRed != null) npcEnemy2.GetComponent().sharedMaterial = matRed; + + // 商人NPC(黄色,商店区域) + var npcMerchant = GameObject.CreatePrimitive(PrimitiveType.Capsule); + npcMerchant.name = "NPC_Merchant"; + npcMerchant.transform.SetParent(npcParent.transform); + npcMerchant.transform.position = new Vector3(4, 1, 22); + npcMerchant.transform.localScale = new Vector3(0.6f, 1f, 0.6f); + if (matYellow != null) npcMerchant.GetComponent().sharedMaterial = matYellow; + + // Boss NPC(紫色大号,Layer3) + var npcBoss = GameObject.CreatePrimitive(PrimitiveType.Capsule); + npcBoss.name = "NPC_Boss"; + npcBoss.transform.SetParent(npcParent.transform); + npcBoss.transform.position = new Vector3(0, 1.5f, 30); + npcBoss.transform.localScale = new Vector3(1.2f, 1.8f, 1.2f); + if (matPurple != null) npcBoss.GetComponent().sharedMaterial = matPurple; + + // 注意: CM_VCam_101-104 已在 GenerateScene 开头创建 + // 相机已通过 FlowCanvas SwitchCameraNode 按名称查找,无需 CameraManager 配置 + + // ═══════ 地图点位标记 ═══════ + var markersParent = new GameObject("MapPoint_Markers"); + CreatePrimitiveMarker(markersParent, "Mark_PlayerSpawn", PrimitiveType.Cube, new Vector3(0, 0.25f, 0), matGreen, 0.4f); + CreatePrimitiveMarker(markersParent, "Mark_BattleArena", PrimitiveType.Cube, new Vector3(8, 0.25f, 12), matRed, 0.5f); + CreatePrimitiveMarker(markersParent, "Mark_ChoicePoint", PrimitiveType.Sphere, new Vector3(0, 0.3f, 14), matCyan, 0.4f); + CreatePrimitiveMarker(markersParent, "Mark_RewardPoint", PrimitiveType.Sphere, new Vector3(-8, 0.3f, 12), matYellow, 0.4f); + CreatePrimitiveMarker(markersParent, "Mark_Shop", PrimitiveType.Sphere, new Vector3(4, 0.3f, 22), matYellow, 0.4f); + CreatePrimitiveMarker(markersParent, "Mark_Rest", PrimitiveType.Sphere, new Vector3(-4, 0.3f, 22), matBlue, 0.4f); + CreatePrimitiveMarker(markersParent, "Mark_BossShop", PrimitiveType.Cube, new Vector3(0, 0.4f, 30), matPurple, 0.6f); + + // ═══════ LevelController (NodeCanvas/FlowCanvas 核心) ═══════ + // 先强制刷新,确保所有脚本都被正确加载 + AssetDatabase.Refresh(); + + var controllerGo = new GameObject("LevelController"); + + // BehaviourTreeController (NodeCanvas) + var btc = controllerGo.AddComponent(); + if (btc == null) + { + LogWarning("BehaviourTreeController 添加失败,尝试重新加载脚本..."); + AssetDatabase.Refresh(); + btc = controllerGo.AddComponent(); + } + btc.StageID = 1001; + + // 使用之前生成的行为树引用(在GenerateScene开头已加载) + if (headerTree == null) + LogWarning($"未找到头文件行为树: {BT_PATH}/BT_10010.asset"); + else + Log($"已加载头文件行为树: {headerTree.name}"); + + if (bodyTree == null) + LogWarning($"未找到正文行为树: {BT_PATH}/BT_1001.asset"); + else + Log($"已加载正文行为树: {bodyTree.name}"); + + btc.HeaderTree = headerTree; + btc.BodyTree = bodyTree; + btc.TickMode = TickRateMode.Normal; + btc.TickRate = 60; + btc.AutoStart = true; + + // GameSystemBridge + var bridgeGo = new GameObject("GameSystemBridge"); + bridgeGo.AddComponent(); + + // ═══════ 游戏管理器(对话、相机) ═══════ + var managersGo = new GameObject("GameManagers"); + + // 运行时初始化器(确保Play Mode下正确初始化) + var runtimeInit = managersGo.AddComponent(); + runtimeInit.ByStageConfig = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/DialogInfoByStageConfig.asset"); + runtimeInit.Database = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/DialogInfoDatabase.asset"); + + // DialogInfoManager + var dialogMgr = managersGo.AddComponent(); + // 设置运行时创建默认对话框 + + // CameraManager + var cameraMgr = managersGo.AddComponent(); + // 配置相机(从场景中的虚拟相机自动收集) + SetupCameraManager(cameraMgr); + + // ═══════ LevelController 附加组件 ═══════ + // 注意:这些组件需要与 BehaviourTreeController 在同一个 GameObject 上 + + // BTDebugger - 需要在 LevelController 上 + var dbg = controllerGo.AddComponent(); + dbg.EnableDebug = true; + dbg.AutoBreakOnError = true; + + // BTControllerEnhanced - 需要在 LevelController 上(依赖 BehaviourTreeController) + var enhanced = controllerGo.AddComponent(); + enhanced.TargetTickRate = 60; + enhanced.EnableAdaptiveTickRate = true; + + // ═══════ --- Tools (非游戏逻辑) --- ═══════ + var toolsParent = new GameObject("--- Tools (非游戏逻辑) ---"); + + // Verification Runner (工具) + var verifyGo = new GameObject("VerificationRunner"); + verifyGo.transform.SetParent(toolsParent.transform); + var runner = verifyGo.AddComponent(); + runner.TestIDRangeConfig = idConfig; + runner.TestEventBuilder = eventBuilder; + runner.TestMapInfoLut = mapInfoLut; + runner.TestDialogConfig = dialogConfig; + runner.TestCameraLut = cameraLut; + runner.VerificationBT = AssetDatabase.LoadAssetAtPath(BT_PATH + "/BT_Verification.asset"); + + // Verification UI Controller (工具) + var verifyUIGo = new GameObject("VerificationUIController"); + verifyUIGo.transform.SetParent(toolsParent.transform); + verifyUIGo.AddComponent(); + + // ═══════ 初始化所有管理器 ═══════ + InitializeManagers(); + + // ========== 玩家 + 交互区域 + FlowCanvas桥接 ========== + SetupPlayerAndInteractions(mats); + + // 强制刷新资产数据库,确保所有脚本引用正确 + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + // 标记所有 LevelController 组件为脏,确保保存时包含正确的脚本引用 + var levelCtrl = GameObject.Find("LevelController"); + if (levelCtrl != null) + { + foreach (var comp in levelCtrl.GetComponents()) + { + if (comp != null) + EditorUtility.SetDirty(comp); + } + } + + // 特别确保 BehaviourTreeController 被标记为脏并修复 + var btcFinal = levelCtrl?.GetComponent(); + if (btcFinal != null && IsComponentValid(btcFinal)) + { + EditorUtility.SetDirty(btcFinal); + // 强制序列化更新 + var serializedObject = new SerializedObject(btcFinal); + serializedObject.Update(); + serializedObject.ApplyModifiedProperties(); + Log("BehaviourTreeController 已标记为脏并序列化"); + } + else + { + LogWarning("BehaviourTreeController 脚本引用可能损坏,尝试修复..."); + RepairLevelController(); + } + + // 保存场景前再次刷新 + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + // 保存场景 + EditorSceneManager.SaveScene(scene, SCENE_PATH); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + Log($"场景已保存: {SCENE_PATH}"); + + // 标记需要刷新(下次播放前自动修复) + _sceneNeedsRepair = true; + Log("场景生成完成。点击Play运行前会自动修复组件引用。"); + } + + // 标记场景是否需要修复 + private bool _sceneNeedsRepair = false; + + // 监听PlayMode变化 + [InitializeOnLoadMethod] + private static void InitializeOnLoad() + { + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + } + + private static void OnPlayModeStateChanged(PlayModeStateChange state) + { + if (state == PlayModeStateChange.ExitingEditMode) + { + // 即将进入Play Mode,自动修复场景 + var window = EditorWindow.GetWindow("验证一键设置", false); + if (window != null && window._sceneNeedsRepair) + { + window.Log("[自动修复] 进入Play Mode前修复场景组件..."); + window.RepairCurrentScene(); + window._sceneNeedsRepair = false; + + // 强制保存并刷新,确保修复生效 + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + } + + // 无论是否标记修复,都检查 BehaviourTreeController + var controllerGo = GameObject.Find("LevelController"); + if (controllerGo != null) + { + var btc = controllerGo.GetComponent(); + if (btc == null || !window.IsComponentValid(btc)) + { + Debug.LogWarning("[自动修复] 检测到 BehaviourTreeController 损坏,尝试修复..."); + if (window != null) + { + window.RepairLevelController(); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + } + } + } + } + } + + /// + /// 修复当前场景的组件 + /// + private void RepairCurrentScene() + { + Log("========== 开始修复场景组件 =========="); + + // 查找或创建 LevelController + var controllerGo = GameObject.Find("LevelController"); + if (controllerGo == null) + { + LogWarning("未找到 LevelController,创建新的"); + controllerGo = new GameObject("LevelController"); + } + + // 修复 BehaviourTreeController + var btc = controllerGo.GetComponent(); + if (btc == null) + { + btc = controllerGo.AddComponent(); + Log("修复: 添加 BehaviourTreeController"); + } + + // 重新加载行为树引用 + if (btc.HeaderTree == null) + { + btc.HeaderTree = AssetDatabase.LoadAssetAtPath(BT_PATH + "/BT_10010.asset"); + if (btc.HeaderTree != null) Log($"修复: 加载 HeaderTree = {btc.HeaderTree.name}"); + } + if (btc.BodyTree == null) + { + btc.BodyTree = AssetDatabase.LoadAssetAtPath(BT_PATH + "/BT_1001.asset"); + if (btc.BodyTree != null) Log($"修复: 加载 BodyTree = {btc.BodyTree.name}"); + } + btc.StageID = 1001; + btc.TickMode = GameplayEditor.Core.TickRateMode.Normal; + btc.TickRate = 60; + btc.AutoStart = true; + + // 初始化管理器 + InitializeManagers(); + + // 确保运行时初始化器存在 + var managersGo = GameObject.Find("GameManagers"); + if (managersGo == null) + { + managersGo = new GameObject("GameManagers"); + Log("修复: 创建 GameManagers"); + } + + var runtimeInit = managersGo.GetComponent(); + if (runtimeInit == null) + { + runtimeInit = managersGo.AddComponent(); + Log("修复: 添加 GameplayRuntimeInitializer"); + } + + // 设置运行时初始化器的引用(如果为空) + if (runtimeInit.ByStageConfig == null) + { + runtimeInit.ByStageConfig = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/DialogInfoByStageConfig.asset"); + Log($"修复: 设置 ByStageConfig = {(runtimeInit.ByStageConfig != null ? "成功" : "失败")}"); + } + if (runtimeInit.Database == null) + { + runtimeInit.Database = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/DialogInfoDatabase.asset"); + Log($"修复: 设置 Database = {(runtimeInit.Database != null ? "成功" : "失败")}"); + } + + // 保存场景 + EditorSceneManager.SaveOpenScenes(); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + + Log("========== 场景修复完成 =========="); + } + + /// + /// 修复 LevelController 上可能缺失的组件(防止编译期间保存导致脚本引用损坏) + /// + private void RepairLevelController() + { + var controllerGo = GameObject.Find("LevelController"); + if (controllerGo == null) + { + LogWarning("场景中未找到 LevelController,跳过修复"); + return; + } + + bool repaired = false; + + var btc = controllerGo.GetComponent(); + // 检查脚本引用是否损坏(Missing Script) + bool isScriptMissing = btc != null && !IsComponentValid(btc); + + if (btc == null || isScriptMissing) + { + if (isScriptMissing) + { + LogWarning("BehaviourTreeController 脚本引用损坏,删除重建"); + DestroyImmediate(btc); + } + LogWarning("修复缺失的 BehaviourTreeController"); + btc = controllerGo.AddComponent(); + btc.StageID = 1001; + btc.TickMode = TickRateMode.Normal; + btc.TickRate = 60; + btc.AutoStart = true; + repaired = true; + } + + // 检查并修复行为树引用 + var headerTree = AssetDatabase.LoadAssetAtPath(BT_PATH + "/BT_10010.asset"); + var bodyTree = AssetDatabase.LoadAssetAtPath(BT_PATH + "/BT_1001.asset"); + + if (btc.HeaderTree == null && headerTree != null) + { + LogWarning("修复缺失的 HeaderTree 引用"); + btc.HeaderTree = headerTree; + repaired = true; + } + + if (btc.BodyTree == null && bodyTree != null) + { + LogWarning("修复缺失的 BodyTree 引用"); + btc.BodyTree = bodyTree; + repaired = true; + } + + if (controllerGo.GetComponent() == null) + { + LogWarning("修复缺失的 BTDebugger"); + var dbg = controllerGo.AddComponent(); + dbg.EnableDebug = true; + dbg.AutoBreakOnError = true; + repaired = true; + } + + if (controllerGo.GetComponent() == null) + { + LogWarning("修复缺失的 BehaviourTreeControllerEnhanced"); + var enh = controllerGo.AddComponent(); + enh.TargetTickRate = 60; + enh.EnableAdaptiveTickRate = true; + repaired = true; + } + + if (controllerGo.GetComponent() == null) + { + LogWarning("修复缺失的 GameplayRuntimeSetup"); + var setup = controllerGo.AddComponent(); + setup.AutoInitialize = true; + setup.UseMockManagers = false; + repaired = true; + } + + if (controllerGo.GetComponent() == null) + { + LogWarning("修复缺失的 GameplayVerificationRunner"); + var runner = controllerGo.AddComponent(); + runner.TestIDRangeConfig = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/IDRangeConfig_Verify.asset"); + runner.TestEventBuilder = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/EventBuilder_1001.asset"); + runner.TestMapInfoLut = AssetDatabase.LoadAssetAtPath(LUT_PATH + "/MapInfo_10001.asset"); + runner.TestDialogConfig = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/DialogInfo_Level1001.asset"); + runner.TestCameraLut = AssetDatabase.LoadAssetAtPath(LUT_PATH + "/CameraLut_101.asset"); + runner.VerificationBT = AssetDatabase.LoadAssetAtPath(BT_PATH + "/BT_Verification.asset"); + repaired = true; + } + + if (controllerGo.GetComponent() == null) + { + LogWarning("修复缺失的 VerificationUIController"); + controllerGo.AddComponent(); + repaired = true; + } + + // 检查并修复 GameManagers + var managersGo = GameObject.Find("GameManagers"); + if (managersGo == null) + { + LogWarning("修复缺失的 GameManagers"); + managersGo = new GameObject("GameManagers"); + + // 运行时初始化器 + var runtimeInit = managersGo.AddComponent(); + runtimeInit.ByStageConfig = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/DialogInfoByStageConfig.asset"); + runtimeInit.Database = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/DialogInfoDatabase.asset"); + + var dialogMgr = managersGo.AddComponent(); + var cameraMgr = managersGo.AddComponent(); + SetupCameraManager(cameraMgr); + + repaired = true; + } + else + { + if (managersGo.GetComponent() == null) + { + LogWarning("修复缺失的 GameplayRuntimeInitializer"); + var runtimeInit = managersGo.AddComponent(); + runtimeInit.ByStageConfig = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/DialogInfoByStageConfig.asset"); + runtimeInit.Database = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/DialogInfoDatabase.asset"); + repaired = true; + } + if (managersGo.GetComponent() == null) + { + LogWarning("修复缺失的 DialogInfoManagerImpl"); + managersGo.AddComponent(); + repaired = true; + } + if (managersGo.GetComponent() == null) + { + LogWarning("修复缺失的 CameraManagerImpl"); + var cameraMgr = managersGo.AddComponent(); + SetupCameraManager(cameraMgr); + repaired = true; + } + } + + if (repaired) + { + EditorSceneManager.SaveOpenScenes(); + AssetDatabase.Refresh(); + Log("LevelController 修复完成并保存"); + } + else + { + Log("LevelController 组件完整,无需修复"); + } + } + + /// + /// 生成验证用基础材质 + /// + private Dictionary GenerateVerificationMaterials() + { + var result = new Dictionary(); + var shader = Shader.Find("Universal Render Pipeline/Lit") + ?? Shader.Find("Standard") + ?? Shader.Find("Lit"); + + if (shader == null) + { + LogWarning("未找到Lit/Standard Shader,材质生成失败"); + return result; + } + + var colorDefs = new (string name, Color color)[] + { + ("Red", new Color(0.9f, 0.2f, 0.2f)), + ("Green", new Color(0.2f, 0.8f, 0.2f)), + ("Blue", new Color(0.2f, 0.5f, 0.9f)), + ("Yellow", new Color(0.9f, 0.8f, 0.2f)), + ("Cyan", new Color(0.2f, 0.8f, 0.9f)), + ("Purple", new Color(0.7f, 0.2f, 0.8f)), + ("White", Color.white) + }; + + foreach (var (name, color) in colorDefs) + { + var path = MATERIAL_PATH + $"/VerifyMat_{name}.mat"; + var mat = AssetDatabase.LoadAssetAtPath(path); + if (mat == null) + { + mat = new Material(shader) + { + name = $"VerifyMat_{name}", + color = color + }; + AssetDatabase.CreateAsset(mat, path); + } + else + { + mat.color = color; + mat.shader = shader; + EditorUtility.SetDirty(mat); + } + result[name] = mat; + } + + Log($"验证材质生成完成: {result.Count} 个"); + return result; + } + + /// + /// 创建场景中的Primitive标记物 + /// + private void CreatePrimitiveMarker(GameObject parent, string name, PrimitiveType type, Vector3 position, Material material, float scale) + { + var go = GameObject.CreatePrimitive(type); + go.name = name; + go.transform.SetParent(parent.transform); + go.transform.position = position; + go.transform.localScale = Vector3.one * scale; + if (material != null) + { + go.GetComponent().sharedMaterial = material; + } + // 移除碰撞器(不需要物理碰撞) + var col = go.GetComponent(); + if (col != null) UnityEngine.Object.DestroyImmediate(col); + } + + /// + /// 创建Roguelike事件类型可视化器 + /// + private void CreateEventVisualizers(Dictionary mats) + { + var parent = new GameObject("EventType_Visualizers"); + parent.transform.position = new Vector3(0, 0, 4); + + var defs = new (string label, string matKey, Vector3 offset)[] + { + ("Battle", "Red", new Vector3(-3, 0.25f, 0)), + ("Choice", "Cyan", new Vector3(-1.5f, 0.25f, 0)), + ("Reward", "Yellow", new Vector3(0, 0.25f, 0)), + ("Shop", "Green", new Vector3(1.5f, 0.25f, 0)), + ("Rest", "Blue", new Vector3(3, 0.25f, 0)) + }; + + for (int i = 0; i < defs.Length; i++) + { + var (label, matKey, offset) = defs[i]; + var go = GameObject.CreatePrimitive(PrimitiveType.Cube); + go.name = $"Event_{label}"; + go.transform.SetParent(parent.transform); + go.transform.localPosition = offset; + go.transform.localScale = new Vector3(0.5f, 0.5f, 0.5f); + if (mats.TryGetValue(matKey, out var mat)) + { + go.GetComponent().sharedMaterial = mat; + } + var col = go.GetComponent(); + if (col != null) UnityEngine.Object.DestroyImmediate(col); + } + } + + private void OpenVerificationScene() + { + if (File.Exists(SCENE_PATH)) + { + EditorSceneManager.OpenScene(SCENE_PATH, OpenSceneMode.Single); + Log("已打开验证场景"); + } + else + { + LogError("验证场景不存在,请先生成场景"); + } + } + + #endregion + + #region 配置验证 + + private void RunConfigValidation(ActivityStageConfig stage, EventBuilderConfig eventBuilder, + DialogInfoConfig dialog, BehaviourTree headerTree, BehaviourTree bodyTree) + { + var report = new ConfigValidator.ValidationReport(); + + report.Results.AddRange(ConfigValidator.ValidateActivityStageConfig(stage).Results); + report.Results.AddRange(ConfigValidator.ValidateEventBuilderConfig(eventBuilder).Results); + report.Results.AddRange(ConfigValidator.ValidateDialogInfo(dialog).Results); + report.Results.AddRange(ConfigValidator.ValidateBehaviourTree(headerTree).Results); + report.Results.AddRange(ConfigValidator.ValidateBehaviourTree(bodyTree).Results); + + Log($"配置验证完成: 错误={report.ErrorCount}, 警告={report.WarningCount}, 信息={report.InfoCount}"); + + if (report.HasErrors) + { + foreach (var r in report.Results.Where(r => r.IsError)) + { + LogError($"[{r.Category}] {r.Message}"); + } + } + if (report.HasWarnings) + { + foreach (var r in report.Results.Where(r => r.IsWarning)) + { + LogWarning($"[{r.Category}] {r.Message}"); + } + } + } + + private void LogError(string msg) + { + _logText += $"[{System.DateTime.Now:HH:mm:ss}] [错误] {msg}\n"; + Debug.LogError($"[VerificationSetup] {msg}"); + } + + /// + /// 配置相机管理器 + /// + private void SetupCameraManager(CameraManagerImpl cameraMgr) + { + if (cameraMgr == null) return; + + // 收集场景中的虚拟相机配置 + cameraMgr.CameraConfigs = new System.Collections.Generic.List(); + + // 相机ID与虚拟相机名称的映射 + var cameraMappings = new (int id, string name, int priority)[] + { + (101, "CM_VCam_101", 10), + (102, "CM_VCam_102", 15), + (103, "CM_VCam_103", 20), + (104, "CM_VCam_104", 15) + }; + + int foundCount = 0; + foreach (var (id, name, priority) in cameraMappings) + { + var vcam = GameObject.Find(name)?.GetComponent(); + if (vcam != null) + { + var config = new CameraManagerImpl.CameraConfig + { + CameraId = id, + CameraName = name, + VirtualCamera = vcam, + Priority = priority + }; + cameraMgr.CameraConfigs.Add(config); + foundCount++; + Log($" 找到相机: {name} (ID={id})"); + } + else + { + LogWarning($" 未找到相机: {name}"); + } + } + + cameraMgr.DefaultCameraId = 101; + cameraMgr.DefaultBlendTime = 0.5f; + + // 查找CinemachineBrain + var brain = GameObject.FindObjectOfType(); + if (brain != null) + { + cameraMgr.CinemachineBrain = brain; + Log($" 找到CinemachineBrain"); + } + + Log($"CameraManager配置完成: {foundCount}/4个相机"); + } + + /// + /// 初始化所有管理器 + /// + private void InitializeManagers() + { + Log("初始化管理器..."); + + // 1. 初始化 DialogInfoManager 单例(Config 命名空间) + try + { + var byStageConfig = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/DialogInfoByStageConfig.asset"); + var database = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/DialogInfoDatabase.asset"); + + if (byStageConfig == null) + { + LogWarning("未找到 DialogInfoByStageConfig,创建新的"); + byStageConfig = ScriptableObject.CreateInstance(); + byStageConfig.Entries = new System.Collections.Generic.List + { + new DialogInfoByStageEntry { StageID = 1001, DialogInfoSheetName = "DialogInfo_Level1001" } + }; + AssetDatabase.CreateAsset(byStageConfig, CONFIG_PATH + "/DialogInfoByStageConfig.asset"); + } + + if (database == null) + { + LogWarning("未找到 DialogInfoDatabase,创建新的"); + database = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(database, CONFIG_PATH + "/DialogInfoDatabase.asset"); + } + + // 确保数据库包含对话配置 + var dialogConfig = AssetDatabase.LoadAssetAtPath(CONFIG_PATH + "/DialogInfo_Level1001.asset"); + if (dialogConfig != null && !database.Configs.Contains(dialogConfig)) + { + database.Configs.Add(dialogConfig); + EditorUtility.SetDirty(database); + Log("已将对话配置添加到数据库"); + } + + // 初始化单例 + Config.DialogInfoManager.Instance.Initialize(byStageConfig, database); + Log("DialogInfoManager 单例初始化完成"); + } + catch (System.Exception ex) + { + LogWarning($"DialogInfoManager 初始化失败: {ex.Message}"); + } + + // 2. 初始化 DialogInfoManagerImpl(Runtime 命名空间) + var dialogMgr = FindObjectOfType(); + if (dialogMgr != null) + { + try + { + dialogMgr.Initialize(); + Log("DialogInfoManagerImpl 初始化完成"); + } + catch (System.Exception ex) + { + LogWarning($"DialogInfoManagerImpl 初始化失败: {ex.Message}"); + } + } + else + { + LogWarning("未找到 DialogInfoManagerImpl"); + } + + // 3. 初始化 CameraManager + var cameraMgr = FindObjectOfType(); + if (cameraMgr != null) + { + // 确保相机配置已经设置 + if (cameraMgr.CameraConfigs == null || cameraMgr.CameraConfigs.Count == 0) + { + LogWarning("CameraManager相机配置为空,重新配置..."); + SetupCameraManager(cameraMgr); + } + + try + { + cameraMgr.Initialize(); + Log($"CameraManager初始化完成,配置了{cameraMgr.CameraConfigs.Count}个相机"); + } + catch (System.Exception ex) + { + LogWarning($"CameraManager初始化失败: {ex.Message}"); + } + } + else + { + LogWarning("未找到 CameraManagerImpl"); + } + } + + private void LogWarning(string msg) + { + _logText += $"[{System.DateTime.Now:HH:mm:ss}] [警告] {msg}\n"; + Debug.LogWarning($"[VerificationSetup] {msg}"); + } + + /// + /// 创建玩家、交互区域和FlowCanvas桥接 + /// + private void SetupPlayerAndInteractions(Dictionary mats) + { + mats.TryGetValue("Cyan", out var cyanMat); + + // ═══ 创建可操控玩家 ═══ + var player = GameObject.CreatePrimitive(PrimitiveType.Capsule); + player.name = "Player_Verify"; + player.tag = "Player"; + player.transform.position = new Vector3(0, 1f, 0); + player.transform.localScale = new Vector3(0.6f, 1f, 0.6f); + if (cyanMat != null) player.GetComponent().sharedMaterial = cyanMat; + var cc = player.AddComponent(); + cc.radius = 0.3f; + cc.height = 2f; + var pc = player.AddComponent(); + pc.MoveSpeed = 6f; + pc.InteractRadius = 2f; + + // ═══ FlowCanvas 桥接 ═══ + var controllerGo = GameObject.Find("LevelController"); + if (controllerGo != null) + { + var flowBridge = controllerGo.AddComponent(); + var flowController = controllerGo.GetComponent(); + if (flowController == null) + flowController = controllerGo.AddComponent(); + + string flowPath = VERIFY_ROOT + "/FC_VerifyTriggers.asset"; + if (AssetDatabase.LoadAssetAtPath(flowPath) != null) + AssetDatabase.DeleteAsset(flowPath); + + var flowScript = ScriptableObject.CreateInstance(); + flowScript.name = "FC_VerifyTriggers"; + + // 所有 FlowCanvas 事件定义 + string[] events = { + "OnShowDialog:int", "OnCloseDialog:int", "OnChangeCamera:int", "OnResetCamera:", + "OnInteract:string", "OnZoneEnter:string", "OnZoneExit:string", "OnPlayerInteract:string", + "OnVerifyStart:", "OnVerifyItemResult:string", "OnVerifyComplete:int" + }; + foreach (var evt in events) + { + var parts = evt.Split(':'); + string name = parts[0]; + Type paramType = parts[1] switch { + "int" => typeof(int), "string" => typeof(string), _ => null + }; + AddCustomFunctionToFlow(flowScript, name, paramType); + } + + flowScript.SelfSerialize(); + AssetDatabase.CreateAsset(flowScript, flowPath); + AssetDatabase.SaveAssets(); + flowController.behaviour = flowScript; + Log("FlowCanvas桥接已创建: FC_VerifyTriggers (11个事件)"); + } + + // ═══ 交互区域(全部通过FlowCanvas事件驱动) ═══ + CreateZone("Zone_Battle", new Vector3(8, 0.5f, 12), "战斗区域", 2f, + ZoneTriggerType.Enter, true, 2, true, 104, new Color(0.9f, 0.2f, 0.2f, 0.3f)); + CreateZone("Zone_Choice", new Vector3(0, 0.5f, 14), "抉择区域", 2f, + ZoneTriggerType.Enter, true, 4, false, 0, new Color(0.2f, 0.8f, 0.9f, 0.3f)); + CreateZone("Zone_Reward", new Vector3(-8, 0.5f, 12), "奖励区域", 2f, + ZoneTriggerType.Enter, false, 0, false, 0, new Color(0.9f, 0.8f, 0.2f, 0.3f)); + CreateZone("Zone_Shop", new Vector3(4, 0.5f, 22), "商店区域", 2f, + ZoneTriggerType.Enter, true, 5, true, 102, new Color(0.9f, 0.8f, 0.2f, 0.3f)); + CreateZone("Zone_Rest", new Vector3(-4, 0.5f, 22), "休息区域", 2f, + ZoneTriggerType.Enter, false, 0, false, 0, new Color(0.2f, 0.5f, 0.9f, 0.3f)); + CreateZone("Zone_Boss", new Vector3(0, 0.5f, 30), "Boss区域", 3f, + ZoneTriggerType.Enter, true, 8, true, 103, new Color(0.7f, 0.2f, 0.8f, 0.3f)); + CreateZone("Zone_NPC", new Vector3(2, 0.5f, 2), "NPC对话区", 1.5f, + ZoneTriggerType.Manual, false, 3, false, 0, new Color(0.2f, 0.8f, 0.2f, 0.3f)); + + Log("玩家 + 7个交互区域 + FlowCanvas桥接 创建完成"); + } + + private void CreateZone(string zoneID, Vector3 pos, string name, float radius, + ZoneTriggerType triggerType, bool showDialog, int dialogID, + bool changeCamera, int cameraID, Color gizmoColor) + { + var go = new GameObject(zoneID); + go.transform.position = pos; + var iz = go.AddComponent(); + iz.ZoneID = zoneID; + iz.ZoneName = name; + iz.Radius = radius; + iz.TriggerType = triggerType; + iz.ShowDialogOnEnter = showDialog; + iz.DialogID = dialogID; + iz.ChangeCameraOnEnter = changeCamera; + iz.CameraID = cameraID; + iz.CameraBlendTime = 0.5f; + iz.GizmoColor = gizmoColor; + } + + private void AddCustomFunctionToFlow(FlowCanvas.FlowScript flow, string identifier, Type paramType) + { + var funcNode = flow.AddNode(new Vector2(UnityEngine.Random.Range(0, 300), UnityEngine.Random.Range(0, 300))) as FlowCanvas.Nodes.CustomFunctionEvent; + if (funcNode != null) + { + funcNode.identifier = identifier; + + // 添加参数 + var parametersField = typeof(FlowCanvas.Nodes.CustomFunctionEvent).GetField("_parameters", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (parametersField != null) + { + var parameters = parametersField.GetValue(funcNode) as System.Collections.Generic.List; + if (parameters != null && paramType != null) + { + parameters.Add(new DynamicParameterDefinition("param", paramType)); + parametersField.SetValue(funcNode, parameters); + } + } + + funcNode.GatherPorts(); + } + } + + private void ExportVerificationExcel() + { + Log("开始导出验证配置表..."); + var paths = VerificationExcelExporter.ExportAll(); + Log($"Excel导出完成,共 {paths.Count} 个文件"); + + // 自动显示主文件摘要 + foreach (var path in paths) + { + string summary = VerificationExcelExporter.GetExcelSummary(path); + Log($"========== {System.IO.Path.GetFileName(path)} =========="); + foreach (var line in summary.Split('\n')) + { + if (!string.IsNullOrWhiteSpace(line)) + Log(line.TrimEnd()); + } + } + Log("================================"); + } + + private void ViewExcelInfo() + { + string dir = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(Application.dataPath), "表/Verification/"); + dir = System.IO.Path.GetFullPath(dir); + + if (!System.IO.Directory.Exists(dir)) + { + LogWarning("导出目录不存在,请先点击'导出配置表'"); + return; + } + + Log("========== 查看表信息 =========="); + foreach (var file in System.IO.Directory.GetFiles(dir, "*.xlsx")) + { + string summary = VerificationExcelExporter.GetExcelSummary(file); + foreach (var line in summary.Split('\n')) + { + if (!string.IsNullOrWhiteSpace(line)) + Log(line.TrimEnd()); + } + } + Log("================================"); + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/GameplayVerificationSetupWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/GameplayVerificationSetupWindow.cs.meta new file mode 100644 index 0000000..9f5afa4 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/GameplayVerificationSetupWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0fa84d6286bf4c04b805ddad73d07819 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/HeaderBodyTestWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/HeaderBodyTestWindow.cs new file mode 100644 index 0000000..40c73d8 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/HeaderBodyTestWindow.cs @@ -0,0 +1,116 @@ +using UnityEditor; +using UnityEngine; +using GameplayEditor.Core; +using NodeCanvas.BehaviourTrees; + +namespace GameplayEditor.Editor +{ + /// + /// 头文件与正文分离功能测试窗口 + /// 用于验证功能2: 头文件与正文分离 + /// + public class HeaderBodyTestWindow : EditorWindow + { + private int _testTreeId = 30150249; + private BehaviourTreeRuntimeData _runtimeData; + private Vector2 _scrollPos; + private string _testLog = ""; + + [MenuItem("Window/Activity Editor/Header Body Test")] + public static void ShowWindow() + { + GetWindow("头文件正文测试"); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("功能2: 头文件与正文分离 - 测试", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + // ID测试区域 + EditorGUILayout.LabelField("ID转换测试:", EditorStyles.boldLabel); + _testTreeId = EditorGUILayout.IntField("测试ID", _testTreeId); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("检测是否为头文件")) + { + bool isHeader = BehaviourTreeExtended.IsHeaderFile(_testTreeId); + int baseId = BehaviourTreeExtended.GetBaseStageID(_testTreeId); + int headerId = BehaviourTreeExtended.GetHeaderFileID(baseId); + + _testLog += $"[检测] ID: {_testTreeId}\n"; + _testLog += $" 是否为头文件: {isHeader}\n"; + _testLog += $" 基础StageID: {baseId}\n"; + _testLog += $" 对应头文件ID: {headerId}\n\n"; + } + + if (GUILayout.Button("生成头文件ID")) + { + int headerId = BehaviourTreeExtended.GetHeaderFileID(_testTreeId); + _testLog += $"[生成] StageID {_testTreeId} → 头文件ID {headerId}\n\n"; + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + // 运行时数据测试 + EditorGUILayout.LabelField("运行时数据测试:", EditorStyles.boldLabel); + _runtimeData = EditorGUILayout.ObjectField("Runtime Data", _runtimeData, + typeof(BehaviourTreeRuntimeData), false) as BehaviourTreeRuntimeData; + + if (_runtimeData != null) + { + EditorGUILayout.LabelField($" StageID: {_runtimeData.StageID}"); + EditorGUILayout.LabelField($" TickRate: {_runtimeData.TickRate}"); + EditorGUILayout.LabelField($" AutoStart: {_runtimeData.AutoStartOnLoad}"); + + EditorGUILayout.ObjectField(" Header Tree", _runtimeData.HeaderTree, + typeof(BehaviourTree), false); + EditorGUILayout.ObjectField(" Body Tree", _runtimeData.BodyTree, + typeof(BehaviourTree), false); + } + + EditorGUILayout.Space(); + + // 示例按钮 + EditorGUILayout.LabelField("示例操作:", EditorStyles.boldLabel); + if (GUILayout.Button("创建示例 Runtime Data")) + { + CreateExampleRuntimeData(); + } + + EditorGUILayout.Space(); + + // 日志显示 + EditorGUILayout.LabelField("测试日志:", EditorStyles.boldLabel); + _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.Height(200)); + EditorGUILayout.TextArea(_testLog, GUILayout.ExpandHeight(true)); + EditorGUILayout.EndScrollView(); + + if (GUILayout.Button("清空日志")) + { + _testLog = ""; + } + } + + private void CreateExampleRuntimeData() + { + // 创建示例运行时数据 + var data = CreateInstance(); + data.StageID = 30150249; + data.TickRate = 60; + data.AutoStartOnLoad = true; + + // 保存到Assets + var path = "Assets/BT/TestRuntimeData.asset"; + AssetDatabase.CreateAsset(data, path); + AssetDatabase.SaveAssets(); + + _runtimeData = data; + _testLog += $"[创建] 示例RuntimeData已保存到: {path}\n"; + _testLog += $" StageID: {data.StageID}\n"; + _testLog += $" 期望头文件: BT_301502490\n"; + _testLog += $" 期望正文: BT_30150249\n\n"; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/HeaderBodyTestWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/HeaderBodyTestWindow.cs.meta new file mode 100644 index 0000000..0e5ca1b --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/HeaderBodyTestWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 91bf777becdb1f24ababe790e850b606 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/IDAllocatorWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/IDAllocatorWindow.cs new file mode 100644 index 0000000..35016fa --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/IDAllocatorWindow.cs @@ -0,0 +1,411 @@ +using System.Collections.Generic; +using System.Linq; +using GameplayEditor.Config; +using GameplayEditor.Excel; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// ID段分配器窗口 + /// 管理策划独立ID段 + /// + public class IDAllocatorWindow : EditorWindow + { + private IDRangeConfig _config; + private Vector2 _scrollPos; + private string _newOwnerName = ""; + private int _newStartID = 200000; + private int _newEndID = 299999; + private string _testID = ""; + private string _allocateOwner = ""; + + [MenuItem("Window/Activity Editor/ID Allocator")] + public static void ShowWindow() + { + GetWindow("ID分配器"); + } + + private void OnEnable() + { + LoadConfig(); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("ID段分配器", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + // 配置选择 + _config = EditorGUILayout.ObjectField("ID配置", _config, typeof(IDRangeConfig), false) as IDRangeConfig; + + if (_config == null) + { + EditorGUILayout.HelpBox("请创建或加载ID配置", MessageType.Info); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("创建新配置")) + { + CreateNewConfig(); + } + if (GUILayout.Button("加载配置")) + { + LoadConfigFromSelection(); + } + EditorGUILayout.EndHorizontal(); + + return; + } + + // 统计信息 + DrawStatistics(); + + EditorGUILayout.Space(); + + // ID段列表 + DrawRangesList(); + + EditorGUILayout.Space(); + + // 添加新段 + DrawAddRange(); + + EditorGUILayout.Space(); + + // 分配ID + DrawAllocateID(); + + EditorGUILayout.Space(); + + // ID查询 + DrawIDQuery(); + + EditorGUILayout.Space(); + + // Excel导入导出 + DrawExcelOperations(); + } + + private void DrawStatistics() + { + EditorGUILayout.LabelField("统计信息", EditorStyles.boldLabel); + + var warningRanges = _config.GetWarningRanges(0.8f); + var exhaustedRanges = _config.GetExhaustedRanges(); + + EditorGUILayout.LabelField($"总段数: {_config.Ranges.Count}"); + EditorGUILayout.LabelField($"预警段数: {warningRanges.Count}", warningRanges.Count > 0 ? ColorText("red") : GUIStyle.none); + EditorGUILayout.LabelField($"已耗尽段数: {exhaustedRanges.Count}", exhaustedRanges.Count > 0 ? ColorText("red") : GUIStyle.none); + + if (warningRanges.Count > 0) + { + EditorGUILayout.HelpBox($"警告: {string.Join(", ", warningRanges.Select(r => r.OwnerName))} 的ID段使用率超过80%", MessageType.Warning); + } + + if (exhaustedRanges.Count > 0) + { + EditorGUILayout.HelpBox($"错误: {string.Join(", ", exhaustedRanges.Select(r => r.OwnerName))} 的ID段已耗尽", MessageType.Error); + } + } + + private void DrawRangesList() + { + _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.Height(200)); + + var rangesToRemove = new List(); + + foreach (var range in _config.Ranges) + { + EditorGUILayout.BeginVertical(GUI.skin.box); + + // 头部信息 + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField(range.OwnerName, EditorStyles.boldLabel); + + var usageColor = range.UsageRate >= 0.9f ? "red" : (range.UsageRate >= 0.8f ? "orange" : "green"); + EditorGUILayout.LabelField($"使用率: {range.UsageRate:P0}", ColorText(usageColor)); + + if (GUILayout.Button("删除", GUILayout.Width(50))) + { + rangesToRemove.Add(range.OwnerName); + } + EditorGUILayout.EndHorizontal(); + + // 详细信息 + EditorGUILayout.LabelField($"范围: {range.StartID} - {range.EndID}"); + EditorGUILayout.LabelField($"已用: {range.CurrentUsedID} / {range.EndID - range.StartID + 1} (剩余: {range.RemainingCount})"); + + if (!string.IsNullOrEmpty(range.Description)) + { + EditorGUILayout.LabelField($"备注: {range.Description}", EditorStyles.wordWrappedLabel); + } + + // 进度条 + Rect rect = GUILayoutUtility.GetRect(18, 18, "TextField"); + EditorGUI.DrawRect(rect, new Color(0.2f, 0.2f, 0.2f)); + var fillWidth = rect.width * range.UsageRate; + var fillColor = range.UsageRate >= 0.9f ? Color.red : (range.UsageRate >= 0.8f ? new Color(1f, 0.5f, 0f) : Color.green); + EditorGUI.DrawRect(new Rect(rect.x, rect.y, fillWidth, rect.height), fillColor); + + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(5); + } + + EditorGUILayout.EndScrollView(); + + // 执行删除 + foreach (var ownerName in rangesToRemove) + { + _config.RemoveRange(ownerName); + } + } + + private void DrawAddRange() + { + EditorGUILayout.LabelField("添加新ID段", EditorStyles.boldLabel); + + _newOwnerName = EditorGUILayout.TextField("所有者名称", _newOwnerName); + _newStartID = EditorGUILayout.IntField("起始ID", _newStartID); + _newEndID = EditorGUILayout.IntField("结束ID", _newEndID); + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("添加")) + { + AddRange(); + } + if (GUILayout.Button("使用推荐配置")) + { + ApplyRecommendedRanges(); + } + EditorGUILayout.EndHorizontal(); + } + + private void DrawAllocateID() + { + EditorGUILayout.LabelField("分配新ID", EditorStyles.boldLabel); + + _allocateOwner = EditorGUILayout.TextField("所有者", _allocateOwner); + + if (GUILayout.Button("分配ID")) + { + var id = _config.AllocateID(_allocateOwner); + if (id > 0) + { + EditorUtility.DisplayDialog("成功", $"为 {_allocateOwner} 分配ID: {id}", "确定"); + } + else + { + EditorUtility.DisplayDialog("失败", $"无法为 {_allocateOwner} 分配ID", "确定"); + } + } + } + + private void DrawIDQuery() + { + EditorGUILayout.LabelField("ID查询", EditorStyles.boldLabel); + + _testID = EditorGUILayout.TextField("查询ID", _testID); + + if (GUILayout.Button("查询") && int.TryParse(_testID, out var id)) + { + var range = _config.FindRangeByID(id); + if (range != null) + { + EditorUtility.DisplayDialog("查询结果", + $"ID {id} 属于: {range.OwnerName}\n范围: {range.StartID}-{range.EndID}", "确定"); + } + else + { + EditorUtility.DisplayDialog("查询结果", $"ID {id} 未在任何段中", "确定"); + } + } + } + + private void AddRange() + { + var range = new IDRange + { + OwnerName = _newOwnerName, + StartID = _newStartID, + EndID = _newEndID, + CurrentUsedID = _newStartID - 1 + }; + + if (_config.AddRange(range, out var error)) + { + EditorUtility.DisplayDialog("成功", $"已添加ID段: {_newOwnerName}", "确定"); + _newOwnerName = ""; + } + else + { + EditorUtility.DisplayDialog("错误", error, "确定"); + } + } + + private void ApplyRecommendedRanges() + { + if (EditorUtility.DisplayDialog("确认", "这将添加推荐的ID段配置,是否继续?", "是", "否")) + { + var recommended = IDRangeConfig.GetRecommendedRanges(); + int added = 0; + foreach (var range in recommended) + { + range.CurrentUsedID = range.StartID - 1; + if (_config.AddRange(range, out _)) + { + added++; + } + } + EditorUtility.DisplayDialog("完成", $"添加了 {added} 个推荐ID段", "确定"); + } + } + + private void CreateNewConfig() + { + var path = EditorUtility.SaveFilePanelInProject( + "保存ID配置", "IDRangeConfig", "asset", "", "Assets/Configs"); + + if (!string.IsNullOrEmpty(path)) + { + _config = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(_config, path); + AssetDatabase.SaveAssets(); + } + } + + private void LoadConfig() + { + var guids = AssetDatabase.FindAssets("t:IDRangeConfig"); + if (guids.Length > 0) + { + var path = AssetDatabase.GUIDToAssetPath(guids[0]); + _config = AssetDatabase.LoadAssetAtPath(path); + } + } + + private void LoadConfigFromSelection() + { + var selected = Selection.GetFiltered(SelectionMode.Assets); + if (selected.Length > 0) + { + _config = selected[0]; + } + } + + private GUIStyle ColorText(string color) + { + var style = new GUIStyle(EditorStyles.label); + style.normal.textColor = color == "red" ? Color.red : (color == "orange" ? new Color(1f, 0.5f, 0f) : Color.green); + return style; + } + + /// + /// Excel操作区域 + /// + private void DrawExcelOperations() + { + EditorGUILayout.LabelField("Excel操作", EditorStyles.boldLabel); + + EditorGUILayout.BeginHorizontal(); + + if (GUILayout.Button("从Excel导入", GUILayout.Height(30))) + { + ImportFromExcel(); + } + + if (GUILayout.Button("导出到Excel", GUILayout.Height(30))) + { + ExportToExcel(); + } + + EditorGUILayout.EndHorizontal(); + } + + /// + /// 从Excel导入 + /// + private void ImportFromExcel() + { + var path = EditorUtility.OpenFilePanel("选择ID段配置Excel", "表", "xlsx,xlsm"); + if (string.IsNullOrEmpty(path)) return; + + var parser = new IDRangeExcelParser(); + var ranges = parser.ParseFromExcel(path); + + if (ranges.Count == 0) + { + EditorUtility.DisplayDialog("导入失败", "未找到有效的ID段配置", "确定"); + return; + } + + // 验证配置 + var errors = parser.ValidateRanges(ranges); + if (errors.Count > 0) + { + var errorMsg = string.Join("\n", errors.Take(5)); + if (!EditorUtility.DisplayDialog("发现警告", + $"配置存在以下问题:\n{errorMsg}\n\n是否继续导入?", "继续", "取消")) + { + return; + } + } + + // 合并到现有配置 + int added = 0; + int updated = 0; + foreach (var range in ranges) + { + var existing = _config.Ranges.Find(r => r.OwnerName == range.OwnerName); + if (existing != null) + { + // 更新现有配置 + existing.StartID = range.StartID; + existing.EndID = range.EndID; + existing.CurrentUsedID = range.CurrentUsedID; + existing.Description = range.Description; + updated++; + } + else + { + // 添加新配置 + if (_config.AddRange(range, out _)) + { + added++; + } + } + } + + EditorUtility.SetDirty(_config); + AssetDatabase.SaveAssets(); + + EditorUtility.DisplayDialog("导入完成", + $"成功导入{ranges.Count}个ID段\n新增: {added}个\n更新: {updated}个", "确定"); + } + + /// + /// 导出到Excel + /// + private void ExportToExcel() + { + if (_config.Ranges.Count == 0) + { + EditorUtility.DisplayDialog("导出失败", "没有ID段配置可导出", "确定"); + return; + } + + var path = EditorUtility.SaveFilePanel("导出ID段配置", "表", "IDRangeConfig", "xlsx"); + if (string.IsNullOrEmpty(path)) return; + + var parser = new IDRangeExcelParser(); + if (parser.ExportToExcel(_config, path)) + { + EditorUtility.DisplayDialog("导出完成", $"已导出{_config.Ranges.Count}个ID段到:\n{path}", "确定"); + } + else + { + EditorUtility.DisplayDialog("导出失败", "导出过程中发生错误", "确定"); + } + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/IDAllocatorWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/IDAllocatorWindow.cs.meta new file mode 100644 index 0000000..3dbac22 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/IDAllocatorWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 04b1cffb2da01174c8ca8dee845b4453 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/KartAdvancedSetupWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/KartAdvancedSetupWindow.cs new file mode 100644 index 0000000..ea728ad --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/KartAdvancedSetupWindow.cs @@ -0,0 +1,446 @@ +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using NodeCanvas.Tasks.Actions; +using NodeCanvas.Tasks.Conditions; +using KartGame.KartSystems; +using NLD.Nodes; + +namespace GameplayEditor.Editor +{ + public class KartAdvancedSetupWindow : EditorWindow + { + const string BT_PATH = "Assets/BP_Scripts"; + const string ANIM_PATH = "Assets/Karting/Animations/Controllers"; + const string SCENE_PATH = "Assets/Karting/Scenes/GameplayGyms/PhysicsPlayground.unity"; + + Vector2 m_Scroll; + string m_Log = ""; + + [MenuItem("Window/Activity Editor/Kart Advanced Setup")] + static void Open() => GetWindow("Kart Advanced Setup"); + + void OnGUI() + { + m_Scroll = GUILayout.BeginScrollView(m_Scroll); + GUILayout.Label("Karting 高级载具系统配置", EditorStyles.boldLabel); + GUILayout.Space(10); + + if (GUILayout.Button("1. 生成/刷新行为树资产", GUILayout.Height(32))) + GenerateBehaviourTrees(); + + if (GUILayout.Button("2. 生成 AnimatorController + 动画Clip", GUILayout.Height(32))) + GenerateAnimatorAssets(); + + if (GUILayout.Button("3. 配置 PhysicsPlayground 场景中的 Kart", GUILayout.Height(32))) + SetupKartInScene(); + + GUILayout.Space(10); + if (GUILayout.Button("【一键执行全部】", GUILayout.Height(40))) + { + GenerateBehaviourTrees(); + GenerateAnimatorAssets(); + SetupKartInScene(); + } + + GUILayout.Space(10); + GUILayout.Label("日志:", EditorStyles.boldLabel); + GUILayout.TextArea(m_Log, GUILayout.ExpandHeight(true)); + GUILayout.EndScrollView(); + } + + void Log(string msg) + { + m_Log += $"[{System.DateTime.Now:HH:mm:ss}] {msg}\n"; + Debug.Log($"[KartSetup] {msg}"); + } + + #region 行为树生成 + + void GenerateBehaviourTrees() + { + AssetDatabase.Refresh(); + + // --- 主行为树:BT_Kart_Advanced --- + var treePath = BT_PATH + "/BT_Kart_Advanced.asset"; + var tree = AssetDatabase.LoadAssetAtPath(treePath); + if (tree != null) AssetDatabase.DeleteAsset(treePath); + tree = ScriptableObject.CreateInstance(); + tree.name = "BT_Kart_Advanced"; + AssetDatabase.CreateAsset(tree, treePath); + + // 确保 Blackboard 变量存在 + EnsureBlackboardVariables(tree); + + // 根节点:Parallel(并行执行所有模块) + var root = (Parallel)tree.AddNode(typeof(Parallel)); + root.name = "车辆主控(并行)"; + tree.primeNode = root; + + // 模块1:输入同步(SetVehicleInput 读取 Blackboard 同步到 BTInput) + var inputSeq = CreateSequencer(tree, "输入同步模块"); + tree.ConnectNodes(root, inputSeq); + + var setInput = (ActionNode)tree.AddNode(typeof(ActionNode)); + var svc = new SetVehicleInput(); + svc.accelerate = CreateBBParam("Accelerate"); + svc.turn = CreateBBParam("Turn"); + svc.brake = CreateBBParam("Brake"); + svc.handbrake = CreateBBParam("Handbrake"); + svc.gear = CreateBBParam("Gear"); + svc.driveMode = CreateBBParam("DriveMode"); + svc.engineOn = CreateBBParam("EngineOn"); + svc.headlights = CreateBBParam("Lights"); + svc.horn = CreateBBParam("Horn"); + setInput.action = svc; + setInput.name = "同步输入到BaseInput"; + tree.ConnectNodes(inputSeq, setInput); + + // 模块2:状态监控(读取车辆状态到黑板) + var stateSeq = CreateSequencer(tree, "状态监控模块"); + tree.ConnectNodes(root, stateSeq); + + var readState = (ActionNode)tree.AddNode(typeof(ActionNode)); + var rvt = new ReadVehicleStatusTask(); + rvt.outLocalSpeed = CreateBBParam("LocalSpeed"); + rvt.outVelocity = CreateBBParam("Velocity"); + rvt.outGear = CreateBBParam("CurrentGear"); + rvt.outState = CreateBBParam("CurrentState"); + rvt.outFuelPercent = CreateBBParam("FuelPercent"); + rvt.outFuelConsumption = CreateBBParam("FuelConsumption"); + rvt.outEngineOn = CreateBBParam("EngineStatus"); + rvt.outLightsOn = CreateBBParam("LightsStatus"); + readState.action = rvt; + readState.name = "读取车辆状态到黑板"; + tree.ConnectNodes(stateSeq, readState); + + // 模块3:状态读取(用于其他系统观察) + var stateReadSeq = CreateSequencer(tree, "状态读取"); + tree.ConnectNodes(root, stateReadSeq); + + var readState2 = (ActionNode)tree.AddNode(typeof(ActionNode)); + readState2.action = new ReadVehicleStatusTask(); + readState2.name = "读取状态"; + tree.ConnectNodes(stateReadSeq, readState2); + + ApplyTreeLayout(tree); + tree.SelfSerialize(); + EditorUtility.SetDirty(tree); + AssetDatabase.SaveAssets(); + Log("BT_Kart_Advanced 生成完成(含Blackboard变量绑定)"); + } + + void EnsureBlackboardVariables(BehaviourTree tree) + { + // 通过 blackboard 添加变量 + var bb = tree.blackboard; + bb.AddVariable("Accelerate", 0f); + bb.AddVariable("Turn", 0f); + bb.AddVariable("Brake", false); + bb.AddVariable("Handbrake", false); + bb.AddVariable("Gear", GearMode.Neutral); + bb.AddVariable("DriveMode", DriveTrainMode.AllWheelDrive); + bb.AddVariable("EngineOn", true); + bb.AddVariable("Lights", false); + bb.AddVariable("Horn", false); + + bb.AddVariable("LocalSpeed", 0f); + bb.AddVariable("Velocity", 0f); + bb.AddVariable("CurrentGear", GearMode.Neutral); + bb.AddVariable("CurrentState", VehicleState.Off); + bb.AddVariable("FuelPercent", 1f); + bb.AddVariable("FuelConsumption", 0f); + bb.AddVariable("EngineStatus", true); + bb.AddVariable("LightsStatus", false); + } + + BBParameter CreateBBParam(string varName) + { + return new BBParameter { name = varName }; + } + + Sequencer CreateSequencer(BehaviourTree tree, string name) + { + var seq = (Sequencer)tree.AddNode(typeof(Sequencer)); + seq.name = name; + return seq; + } + + void ApplyTreeLayout(BehaviourTree tree) + { + if (tree.primeNode == null) return; + const float xStep = 220f; + const float yStep = 150f; + + System.Action traverse = null; + traverse = (node, x, y) => + { + if (node == null) return; + node.position = new Vector2(x, y); + if (node is BTComposite composite) + { + int childCount = composite.outConnections.Count; + float startX = x - (childCount - 1) * xStep * 0.5f; + for (int i = 0; i < childCount; i++) + { + var child = composite.outConnections[i].targetNode; + traverse(child, startX + i * xStep, y + yStep); + } + } + }; + traverse(tree.primeNode, 400f, 60f); + } + + #endregion + + #region Animator 生成 + + void GenerateAnimatorAssets() + { + if (!System.IO.Directory.Exists(ANIM_PATH)) + System.IO.Directory.CreateDirectory(ANIM_PATH); + + // Idle Vibration + var idleClip = new AnimationClip(); + idleClip.name = "Kart_Idle"; + idleClip.legacy = false; + idleClip.wrapMode = WrapMode.Loop; + var curveY = new AnimationCurve(); + curveY.AddKey(0f, 0f); + curveY.AddKey(0.033f, 0.005f); + curveY.AddKey(0.066f, 0f); + curveY.AddKey(0.1f, -0.005f); + curveY.AddKey(0.133f, 0f); + idleClip.SetCurve("", typeof(Transform), "localPosition.y", curveY); + var idlePath = ANIM_PATH + "/Kart_Idle.anim"; + AssetDatabase.CreateAsset(idleClip, idlePath); + + // Brake Pitch + var brakeClip = new AnimationClip(); + brakeClip.name = "Kart_Brake"; + var curvePitch = new AnimationCurve(); + curvePitch.AddKey(0f, 0f); + curvePitch.AddKey(0.2f, 3f); + brakeClip.SetCurve("", typeof(Transform), "localRotation.x", curvePitch); + var brakePath = ANIM_PATH + "/Kart_Brake.anim"; + AssetDatabase.CreateAsset(brakeClip, brakePath); + + // Accelerate Pitch + var accelClip = new AnimationClip(); + accelClip.name = "Kart_Accelerate"; + var curvePitch2 = new AnimationCurve(); + curvePitch2.AddKey(0f, 0f); + curvePitch2.AddKey(0.2f, -3f); + accelClip.SetCurve("", typeof(Transform), "localRotation.x", curvePitch2); + var accelPath = ANIM_PATH + "/Kart_Accelerate.anim"; + AssetDatabase.CreateAsset(accelClip, accelPath); + + // AnimatorController + // 使用 CreateAnimatorControllerAtPath 可确保正确生成含 StateMachine 的 Layer + var ctrlPath = ANIM_PATH + "/VehicleStateController.controller"; + if (System.IO.File.Exists(ctrlPath)) + { + AssetDatabase.DeleteAsset(ctrlPath); + } + var controller = UnityEditor.Animations.AnimatorController.CreateAnimatorControllerAtPath(ctrlPath); + controller.name = "VehicleStateController"; + controller.AddParameter("VehicleState", AnimatorControllerParameterType.Int); + controller.AddParameter("Speed", AnimatorControllerParameterType.Float); + controller.AddParameter("Steering", AnimatorControllerParameterType.Float); + controller.AddParameter("Grounded", AnimatorControllerParameterType.Bool); + controller.AddParameter("EngineOn", AnimatorControllerParameterType.Bool); + + var sm = controller.layers[0].stateMachine; + + var stateOff = sm.AddState("Off", new Vector3(300, 100, 0)); + var stateIgnition = sm.AddState("Ignition", new Vector3(300, 200, 0)); + var stateIdle = sm.AddState("Idle", new Vector3(300, 300, 0)); + var stateIdleAccel = sm.AddState("IdleAccelerate", new Vector3(500, 300, 0)); + var stateCruise = sm.AddState("Cruise", new Vector3(300, 400, 0)); + var stateCruiseBrake = sm.AddState("CruiseBrake", new Vector3(500, 400, 0)); + var stateReverse = sm.AddState("Reverse", new Vector3(100, 400, 0)); + var stateStuck = sm.AddState("Stuck", new Vector3(300, 500, 0)); + + stateIdle.motion = idleClip; + stateCruiseBrake.motion = brakeClip; + stateIdleAccel.motion = accelClip; + + // EngineOff -> Off + var anyToOff = sm.AddAnyStateTransition(stateOff); + anyToOff.AddCondition(UnityEditor.Animations.AnimatorConditionMode.IfNot, 0, "EngineOn"); + anyToOff.duration = 0.1f; + + // Off -> Ignition + var offToIgnition = stateOff.AddTransition(stateIgnition); + offToIgnition.AddCondition(UnityEditor.Animations.AnimatorConditionMode.If, 0, "EngineOn"); + offToIgnition.duration = 0.1f; + + // Ignition -> Idle + var ignitionToIdle = stateIgnition.AddTransition(stateIdle); + ignitionToIdle.duration = 1.0f; + ignitionToIdle.hasExitTime = true; + ignitionToIdle.exitTime = 1f; + + // Idle <-> IdleAccelerate (VehicleState == 3) + var idleToAccel = stateIdle.AddTransition(stateIdleAccel); + idleToAccel.AddCondition(UnityEditor.Animations.AnimatorConditionMode.Equals, 3, "VehicleState"); + idleToAccel.duration = 0.2f; + var accelToIdle = stateIdleAccel.AddTransition(stateIdle); + accelToIdle.AddCondition(UnityEditor.Animations.AnimatorConditionMode.NotEqual, 3, "VehicleState"); + accelToIdle.duration = 0.2f; + + // Idle <-> Cruise (VehicleState == 4) + var idleToCruise = stateIdle.AddTransition(stateCruise); + idleToCruise.AddCondition(UnityEditor.Animations.AnimatorConditionMode.Equals, 4, "VehicleState"); + idleToCruise.duration = 0.3f; + var cruiseToIdle = stateCruise.AddTransition(stateIdle); + cruiseToIdle.AddCondition(UnityEditor.Animations.AnimatorConditionMode.Equals, 2, "VehicleState"); + cruiseToIdle.duration = 0.3f; + + // Cruise <-> CruiseBrake (VehicleState == 5) + var cruiseToBrake = stateCruise.AddTransition(stateCruiseBrake); + cruiseToBrake.AddCondition(UnityEditor.Animations.AnimatorConditionMode.Equals, 5, "VehicleState"); + cruiseToBrake.duration = 0.1f; + var brakeToCruise = stateCruiseBrake.AddTransition(stateCruise); + brakeToCruise.AddCondition(UnityEditor.Animations.AnimatorConditionMode.Equals, 4, "VehicleState"); + brakeToCruise.duration = 0.2f; + + // Idle <-> Reverse (VehicleState == 6) + var idleToReverse = stateIdle.AddTransition(stateReverse); + idleToReverse.AddCondition(UnityEditor.Animations.AnimatorConditionMode.Equals, 6, "VehicleState"); + idleToReverse.duration = 0.2f; + var reverseToIdle = stateReverse.AddTransition(stateIdle); + reverseToIdle.AddCondition(UnityEditor.Animations.AnimatorConditionMode.NotEqual, 6, "VehicleState"); + reverseToIdle.duration = 0.2f; + + // Any -> Stuck (VehicleState == 7) + var anyToStuck = sm.AddAnyStateTransition(stateStuck); + anyToStuck.AddCondition(UnityEditor.Animations.AnimatorConditionMode.Equals, 7, "VehicleState"); + anyToStuck.duration = 0.1f; + var stuckToIdle = stateStuck.AddTransition(stateIdle); + stuckToIdle.AddCondition(UnityEditor.Animations.AnimatorConditionMode.NotEqual, 7, "VehicleState"); + stuckToIdle.duration = 0.3f; + + AssetDatabase.SaveAssets(); + + Log($"AnimatorController 生成完成: {ANIM_PATH}/VehicleStateController.controller"); + } + + #endregion + + #region 场景配置 + + void SetupKartInScene() + { + var scene = UnityEditor.SceneManagement.EditorSceneManager.OpenScene(SCENE_PATH, UnityEditor.SceneManagement.OpenSceneMode.Single); + + GameObject kartGo = null; + var roots = scene.GetRootGameObjects(); + foreach (var r in roots) + { + if (r.name.Contains("KartClassic_Player_02")) + { + kartGo = r; + break; + } + var found = r.transform.Find("KartClassic_Player_02"); + if (found != null) kartGo = found.gameObject; + } + + if (kartGo == null) + { + LogError("未在场景中找到 KartClassic_Player_02"); + return; + } + + var arcadeKart = kartGo.GetComponent(); + if (arcadeKart == null) + { + LogError("KartClassic_Player_02 上未找到 ArcadeKart 组件!"); + return; + } + + // 添加扩展组件 + var gearbox = kartGo.GetComponent() ?? kartGo.AddComponent(); + var fuel = kartGo.GetComponent() ?? kartGo.AddComponent(); + var electrical = kartGo.GetComponent() ?? kartGo.AddComponent(); + var physicsEffects = kartGo.GetComponent() ?? kartGo.AddComponent(); + var stateMachine = kartGo.GetComponent() ?? kartGo.AddComponent(); + + arcadeKart.ManualGearbox = gearbox; + arcadeKart.FuelSystem = fuel; + stateMachine.Kart = arcadeKart; + stateMachine.Gearbox = gearbox; + stateMachine.Fuel = fuel; + + if (physicsEffects.VisualBody == null) + { + var kartVisual = kartGo.transform.Find("KartVisual"); + if (kartVisual != null) + { + var car = kartVisual.Find("car"); + physicsEffects.VisualBody = car != null ? car : kartVisual; + } + } + + // Animator + var animator = kartGo.GetComponentInChildren(); + if (animator == null && physicsEffects.VisualBody != null) + { + animator = physicsEffects.VisualBody.gameObject.AddComponent(); + } + if (animator != null) + { + var ctrl = AssetDatabase.LoadAssetAtPath(ANIM_PATH + "/VehicleStateController.controller"); + if (ctrl != null) animator.runtimeAnimatorController = ctrl; + stateMachine.Animator = animator; + } + + // 输入系统 + var btInput = kartGo.GetComponent() ?? kartGo.AddComponent(); + var kbInput = kartGo.GetComponent() ?? kartGo.AddComponent(); + + // 移除旧的 IInput(如果有 KeyboardInput 继承 BaseInput 的旧版本) + // 由于我们已经重写了 KartKeyboardInput 不再继承 BaseInput,这里不需要移除 + + // BehaviourTreeOwner + var btOwner = kartGo.GetComponent(); + if (btOwner == null) btOwner = kartGo.AddComponent(); + var advancedBT = AssetDatabase.LoadAssetAtPath(BT_PATH + "/BT_Kart_Advanced.asset"); + if (advancedBT != null) + { + btOwner.graph = advancedBT; + btOwner.updateMode = Graph.UpdateMode.Manual; + } + + var bb = kartGo.GetComponent() ?? kartGo.AddComponent(); + + // 标记修改 + EditorUtility.SetDirty(kartGo); + EditorUtility.SetDirty(arcadeKart); + EditorUtility.SetDirty(gearbox); + EditorUtility.SetDirty(fuel); + EditorUtility.SetDirty(electrical); + EditorUtility.SetDirty(physicsEffects); + EditorUtility.SetDirty(stateMachine); + EditorUtility.SetDirty(btOwner); + EditorUtility.SetDirty(bb); + if (animator != null) EditorUtility.SetDirty(animator); + + UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(scene); + UnityEditor.SceneManagement.EditorSceneManager.SaveScene(scene); + + Log("KartClassic_Player_02 配置完成。请检查 Inspector 中的引用是否正确。"); + } + + void LogError(string msg) + { + m_Log += $"[Error] {msg}\n"; + Debug.LogError($"[KartSetup] {msg}"); + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/KartAdvancedSetupWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/KartAdvancedSetupWindow.cs.meta new file mode 100644 index 0000000..ee2fa18 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/KartAdvancedSetupWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 348844af3b5945e49902a0183bc77a27 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/LocalizationEditorWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/LocalizationEditorWindow.cs new file mode 100644 index 0000000..c952d15 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/LocalizationEditorWindow.cs @@ -0,0 +1,611 @@ +using GameplayEditor.Config; +using GameplayEditor.Runtime; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// 本地化编辑器窗口 + /// 提供多语言文本编辑、导入导出、语言切换预览等功能 + /// + public class LocalizationEditorWindow : EditorWindow + { + private Vector2 _dialogListScrollPos; + private Vector2 _editScrollPos; + + // 当前编辑状态 + private DialogInfoDatabase _database; + private List _dialogs = new List(); + private int _selectedDialogIndex = -1; + private LanguageType _previewLanguage = LanguageType.Chinese; + private LanguageType _editLanguage = LanguageType.Chinese; + + // 搜索过滤 + private string _searchFilter = ""; + + // 折叠状态 + private bool _showDialogList = true; + private bool _showEditPanel = true; + + // 编辑中的文本 + private string _editText = ""; + + // 样式 + private GUIStyle _selectedItemStyle; + private GUIStyle _missingTranslationStyle; + private bool _stylesInitialized; + + [MenuItem("Window/Activity Editor/Localization Editor")] + public static void ShowWindow() + { + var window = GetWindow("Localization"); + window.minSize = new Vector2(800, 600); + window.Show(); + } + + private void InitializeStyles() + { + if (_stylesInitialized) return; + + _selectedItemStyle = new GUIStyle(EditorStyles.label); + _selectedItemStyle.normal.background = MakeTexture(2, 2, new Color(0.2f, 0.4f, 0.8f, 0.5f)); + + _missingTranslationStyle = new GUIStyle(EditorStyles.label); + _missingTranslationStyle.normal.textColor = Color.red; + + _stylesInitialized = true; + } + + private Texture2D MakeTexture(int width, int height, Color color) + { + Color[] pixels = new Color[width * height]; + for (int i = 0; i < pixels.Length; i++) + pixels[i] = color; + Texture2D result = new Texture2D(width, height); + result.SetPixels(pixels); + result.Apply(); + return result; + } + + private void OnEnable() + { + LoadDatabase(); + } + + private void LoadDatabase() + { + // 查找数据库 + var guids = AssetDatabase.FindAssets("t:DialogInfoDatabase"); + if (guids.Length > 0) + { + var path = AssetDatabase.GUIDToAssetPath(guids[0]); + _database = AssetDatabase.LoadAssetAtPath(path); + + if (_database != null) + { + _dialogs = _database.GetAllDialogs().ToList(); + } + } + } + + private void OnGUI() + { + InitializeStyles(); + + DrawToolbar(); + + if (_database == null) + { + DrawNoDatabaseMessage(); + return; + } + + EditorGUILayout.BeginHorizontal(); + + // 左侧:对话列表 + DrawDialogListPanel(); + + // 右侧:编辑区域 + DrawEditPanel(); + + EditorGUILayout.EndHorizontal(); + } + + #region 工具栏 + + private void DrawToolbar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + if (GUILayout.Button("刷新", EditorStyles.toolbarButton, GUILayout.Width(50))) + { + LoadDatabase(); + } + + if (GUILayout.Button("导入CSV", EditorStyles.toolbarButton, GUILayout.Width(70))) + { + ImportCSV(); + } + + if (GUILayout.Button("导出CSV", EditorStyles.toolbarButton, GUILayout.Width(70))) + { + ExportCSV(); + } + + GUILayout.FlexibleSpace(); + + // 预览语言选择 + EditorGUILayout.LabelField("预览语言:", GUILayout.Width(60)); + _previewLanguage = (LanguageType)EditorGUILayout.EnumPopup(_previewLanguage, EditorStyles.toolbarPopup, GUILayout.Width(100)); + + GUILayout.Space(10); + + // 缺失翻译检查 + if (GUILayout.Button("检查缺失", EditorStyles.toolbarButton, GUILayout.Width(70))) + { + CheckMissingTranslations(); + } + + EditorGUILayout.EndHorizontal(); + } + + #endregion + + #region 对话列表面板 + + private void DrawDialogListPanel() + { + EditorGUILayout.BeginVertical(GUI.skin.box, GUILayout.Width(300), GUILayout.ExpandHeight(true)); + + _showDialogList = EditorGUILayout.Foldout(_showDialogList, "对话列表", EditorStyles.foldoutHeader); + if (!_showDialogList) + { + EditorGUILayout.EndVertical(); + return; + } + + // 搜索和过滤 + EditorGUILayout.BeginHorizontal(); + _searchFilter = EditorGUILayout.TextField(_searchFilter, EditorStyles.toolbarSearchField); + if (GUILayout.Button("×", GUILayout.Width(20))) + { + _searchFilter = ""; + } + EditorGUILayout.EndHorizontal(); + + // 统计信息 + int totalCount = _dialogs.Count; + int missingCount = _dialogs.Count(d => IsMissingTranslation(d, _previewLanguage)); + EditorGUILayout.LabelField($"总计: {totalCount} | 缺失翻译: {missingCount}", EditorStyles.miniLabel); + + EditorGUILayout.Space(5); + + // 列表 + _dialogListScrollPos = EditorGUILayout.BeginScrollView(_dialogListScrollPos); + + for (int i = 0; i < _dialogs.Count; i++) + { + var dialog = _dialogs[i]; + + // 搜索过滤 + if (!string.IsNullOrEmpty(_searchFilter)) + { + var searchLower = _searchFilter.ToLower(); + if (!dialog.ID.ToString().Contains(searchLower) && + !dialog.CharacterName.ToLower().Contains(searchLower) && + !GetDisplayText(dialog).ToLower().Contains(searchLower)) + { + continue; + } + } + + DrawDialogListItem(dialog, i); + } + + EditorGUILayout.EndScrollView(); + + EditorGUILayout.EndVertical(); + } + + private void DrawDialogListItem(DialogInfoData dialog, int index) + { + bool isSelected = _selectedDialogIndex == index; + bool isMissing = IsMissingTranslation(dialog, _previewLanguage); + + var style = isSelected ? _selectedItemStyle : EditorStyles.label; + if (isMissing && !isSelected) + { + style = _missingTranslationStyle; + } + + EditorGUILayout.BeginHorizontal(style); + + // 缺失翻译标记 + if (isMissing) + { + GUILayout.Label("⚠", GUILayout.Width(20)); + } + else + { + GUILayout.Label("✓", GUILayout.Width(20)); + } + + // ID + EditorGUILayout.LabelField($"{dialog.ID}", GUILayout.Width(50)); + + // 角色名 + EditorGUILayout.LabelField(dialog.CharacterName, GUILayout.Width(80)); + + // 预览文本(截断) + string displayText = GetDisplayText(dialog); + if (displayText.Length > 20) + { + displayText = displayText.Substring(0, 20) + "..."; + } + EditorGUILayout.LabelField(displayText, EditorStyles.miniLabel); + + EditorGUILayout.EndHorizontal(); + + // 点击选择 + var rect = GUILayoutUtility.GetLastRect(); + if (Event.current.type == UnityEngine.EventType.MouseDown && rect.Contains(Event.current.mousePosition)) + { + _selectedDialogIndex = index; + StartEdit(dialog); + Event.current.Use(); + } + } + + private string GetDisplayText(DialogInfoData dialog) + { + return _previewLanguage switch + { + LanguageType.Chinese => dialog.Text, + LanguageType.English => string.IsNullOrEmpty(dialog.Text_EN) ? dialog.Text : dialog.Text_EN, + LanguageType.Japanese => string.IsNullOrEmpty(dialog.Text_JP) ? dialog.Text : dialog.Text_JP, + LanguageType.Korean => string.IsNullOrEmpty(dialog.Text_KR) ? dialog.Text : dialog.Text_KR, + LanguageType.French => string.IsNullOrEmpty(dialog.Text_FR) ? dialog.Text : dialog.Text_FR, + LanguageType.German => string.IsNullOrEmpty(dialog.Text_DE) ? dialog.Text : dialog.Text_DE, + LanguageType.Spanish => string.IsNullOrEmpty(dialog.Text_ES) ? dialog.Text : dialog.Text_ES, + _ => dialog.Text + }; + } + + private bool IsMissingTranslation(DialogInfoData dialog, LanguageType language) + { + if (language == LanguageType.Chinese) return false; // 中文是默认语言 + + return language switch + { + LanguageType.English => string.IsNullOrEmpty(dialog.Text_EN), + LanguageType.Japanese => string.IsNullOrEmpty(dialog.Text_JP), + LanguageType.Korean => string.IsNullOrEmpty(dialog.Text_KR), + LanguageType.French => string.IsNullOrEmpty(dialog.Text_FR), + LanguageType.German => string.IsNullOrEmpty(dialog.Text_DE), + LanguageType.Spanish => string.IsNullOrEmpty(dialog.Text_ES), + _ => false + }; + } + + #endregion + + #region 编辑面板 + + private void DrawEditPanel() + { + EditorGUILayout.BeginVertical(GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); + + if (_selectedDialogIndex < 0 || _selectedDialogIndex >= _dialogs.Count) + { + EditorGUILayout.LabelField("请选择一个对话进行编辑", EditorStyles.centeredGreyMiniLabel); + EditorGUILayout.EndVertical(); + return; + } + + var dialog = _dialogs[_selectedDialogIndex]; + + // 工具栏 + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + EditorGUILayout.LabelField($"Dialog ID: {dialog.ID}", EditorStyles.boldLabel); + GUILayout.FlexibleSpace(); + + if (GUILayout.Button("保存", EditorStyles.toolbarButton, GUILayout.Width(50))) + { + SaveEdit(dialog); + } + + if (GUILayout.Button("取消", EditorStyles.toolbarButton, GUILayout.Width(50))) + { + CancelEdit(); + } + + EditorGUILayout.EndHorizontal(); + + _showEditPanel = EditorGUILayout.Foldout(_showEditPanel, "文本编辑", EditorStyles.foldoutHeader); + if (!_showEditPanel) + { + EditorGUILayout.EndVertical(); + return; + } + + _editScrollPos = EditorGUILayout.BeginScrollView(_editScrollPos); + + // 中文(默认) + EditorGUILayout.LabelField("中文 (默认)", EditorStyles.boldLabel); + EditorGUI.BeginDisabledGroup(true); + EditorGUILayout.TextArea(dialog.Text, GUILayout.MinHeight(60)); + EditorGUI.EndDisabledGroup(); + + EditorGUILayout.Space(10); + + // 语言选择 + _editLanguage = (LanguageType)EditorGUILayout.EnumPopup("编辑语言:", _editLanguage); + + EditorGUILayout.Space(5); + + // 编辑区域 + if (_editLanguage == LanguageType.Chinese) + { + EditorGUILayout.HelpBox("中文是默认语言,请在DialogInfo配置中编辑", MessageType.Info); + } + else + { + EditorGUILayout.LabelField($"{_editLanguage} 翻译:", EditorStyles.boldLabel); + _editText = EditorGUILayout.TextArea(_editText, GUILayout.MinHeight(100)); + + // 字符统计 + EditorGUILayout.LabelField($"字符数: {_editText.Length}", EditorStyles.miniLabel); + } + + EditorGUILayout.EndScrollView(); + + EditorGUILayout.EndVertical(); + } + + private void StartEdit(DialogInfoData dialog) + { + _editText = GetEditText(dialog, _editLanguage); + } + + private string GetEditText(DialogInfoData dialog, LanguageType language) + { + return language switch + { + LanguageType.English => dialog.Text_EN, + LanguageType.Japanese => dialog.Text_JP, + LanguageType.Korean => dialog.Text_KR, + LanguageType.French => dialog.Text_FR, + LanguageType.German => dialog.Text_DE, + LanguageType.Spanish => dialog.Text_ES, + _ => dialog.Text + }; + } + + private void SaveEdit(DialogInfoData dialog) + { + if (_editLanguage == LanguageType.Chinese) return; + + // 更新数据 + switch (_editLanguage) + { + case LanguageType.English: dialog.Text_EN = _editText; break; + case LanguageType.Japanese: dialog.Text_JP = _editText; break; + case LanguageType.Korean: dialog.Text_KR = _editText; break; + case LanguageType.French: dialog.Text_FR = _editText; break; + case LanguageType.German: dialog.Text_DE = _editText; break; + case LanguageType.Spanish: dialog.Text_ES = _editText; break; + } + + // 标记为已修改 + EditorUtility.SetDirty(_database); + + Debug.Log($"[LocalizationEditor] 保存对话 {dialog.ID} 的 {_editLanguage} 翻译"); + } + + private void CancelEdit() + { + if (_selectedDialogIndex >= 0 && _selectedDialogIndex < _dialogs.Count) + { + StartEdit(_dialogs[_selectedDialogIndex]); + } + } + + #endregion + + #region 导入导出 + + private void ImportCSV() + { + var path = EditorUtility.OpenFilePanel("导入本地化CSV", "", "csv"); + if (string.IsNullOrEmpty(path)) return; + + try + { + var csvContent = File.ReadAllText(path, Encoding.UTF8); + var lines = csvContent.Split('\n'); + + if (lines.Length < 2) + { + EditorUtility.DisplayDialog("错误", "CSV文件格式不正确", "确定"); + return; + } + + // 解析表头 + var headers = lines[0].Split(','); + var languageMap = new Dictionary(); + + for (int i = 1; i < headers.Length; i++) + { + if (System.Enum.TryParse(headers[i].Trim(), out var lang)) + { + languageMap[i] = lang; + } + } + + // 解析数据 + int successCount = 0; + for (int i = 1; i < lines.Length; i++) + { + var fields = lines[i].Split(','); + if (fields.Length < 2) continue; + + if (!int.TryParse(fields[0].Trim(), out var dialogId)) continue; + + var dialog = _dialogs.Find(d => d.ID == dialogId); + if (dialog == null) continue; + + for (int j = 1; j < fields.Length && j < headers.Length; j++) + { + if (languageMap.TryGetValue(j, out var lang)) + { + SetDialogText(dialog, lang, fields[j].Trim()); + } + } + + successCount++; + } + + EditorUtility.SetDirty(_database); + EditorUtility.DisplayDialog("导入完成", $"成功导入 {successCount} 条翻译", "确定"); + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("错误", $"导入失败: {ex.Message}", "确定"); + } + } + + private void SetDialogText(DialogInfoData dialog, LanguageType language, string text) + { + switch (language) + { + case LanguageType.English: dialog.Text_EN = text; break; + case LanguageType.Japanese: dialog.Text_JP = text; break; + case LanguageType.Korean: dialog.Text_KR = text; break; + case LanguageType.French: dialog.Text_FR = text; break; + case LanguageType.German: dialog.Text_DE = text; break; + case LanguageType.Spanish: dialog.Text_ES = text; break; + } + } + + private void ExportCSV() + { + var path = EditorUtility.SaveFilePanel("导出本地化CSV", "", "localization", "csv"); + if (string.IsNullOrEmpty(path)) return; + + try + { + var sb = new StringBuilder(); + + // 表头 + sb.AppendLine("ID,CharacterName,Chinese,English,Japanese,Korean,French,German,Spanish"); + + // 数据 + foreach (var dialog in _dialogs) + { + sb.AppendLine($"{dialog.ID},{EscapeCSV(dialog.CharacterName)},{EscapeCSV(dialog.Text)},{EscapeCSV(dialog.Text_EN)},{EscapeCSV(dialog.Text_JP)},{EscapeCSV(dialog.Text_KR)},{EscapeCSV(dialog.Text_FR)},{EscapeCSV(dialog.Text_DE)},{EscapeCSV(dialog.Text_ES)}"); + } + + File.WriteAllText(path, sb.ToString(), Encoding.UTF8); + + EditorUtility.DisplayDialog("导出完成", $"已导出 {_dialogs.Count} 条对话", "确定"); + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("错误", $"导出失败: {ex.Message}", "确定"); + } + } + + private string EscapeCSV(string text) + { + if (string.IsNullOrEmpty(text)) return ""; + + // 如果包含逗号、引号或换行,需要用引号包裹 + if (text.Contains(",") || text.Contains("\"") || text.Contains("\n") || text.Contains("\r")) + { + text = text.Replace("\"", "\"\""); // 双引号转义 + return $"\"{text}\""; + } + + return text; + } + + #endregion + + #region 工具方法 + + private void CheckMissingTranslations() + { + var missing = new List<(int id, LanguageType lang)>(); + + foreach (var dialog in _dialogs) + { + var languages = System.Enum.GetValues(typeof(LanguageType)) as LanguageType[]; + foreach (var lang in languages) + { + if (lang == LanguageType.Chinese) continue; + + if (IsMissingTranslation(dialog, lang)) + { + missing.Add((dialog.ID, lang)); + } + } + } + + if (missing.Count == 0) + { + EditorUtility.DisplayDialog("检查结果", "所有对话都有完整翻译!", "确定"); + } + else + { + var sb = new StringBuilder(); + sb.AppendLine($"发现 {missing.Count} 处缺失翻译:"); + + foreach (var (id, lang) in missing.Take(20)) + { + sb.AppendLine($" Dialog {id}: {lang}"); + } + + if (missing.Count > 20) + { + sb.AppendLine($" ... 还有 {missing.Count - 20} 处"); + } + + EditorUtility.DisplayDialog("检查结果", sb.ToString(), "确定"); + } + } + + private void DrawNoDatabaseMessage() + { + EditorGUILayout.Space(50); + EditorGUILayout.HelpBox("未找到 DialogInfoDatabase\n请先创建对话数据库", MessageType.Warning); + + if (GUILayout.Button("创建数据库", GUILayout.Height(40))) + { + CreateDatabase(); + } + } + + private void CreateDatabase() + { + var database = CreateInstance(); + var path = "Assets/Resources/DialogInfoDatabase.asset"; + + if (!Directory.Exists("Assets/Resources")) + { + Directory.CreateDirectory("Assets/Resources"); + } + + AssetDatabase.CreateAsset(database, path); + AssetDatabase.SaveAssets(); + + LoadDatabase(); + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/LocalizationEditorWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/LocalizationEditorWindow.cs.meta new file mode 100644 index 0000000..7a28c17 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/LocalizationEditorWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5f96b8b8f8860fa48adb209c15f85b5a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/LocalizationExportWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/LocalizationExportWindow.cs new file mode 100644 index 0000000..3dc98cf --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/LocalizationExportWindow.cs @@ -0,0 +1,391 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameplayEditor.Config; +using GameplayEditor.Excel; +using GameplayEditor.Runtime; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// 本地化导出/导入窗口 + /// 管理对话多语言的导出、导入和翻译进度 + /// + public class LocalizationExportWindow : EditorWindow + { + private DialogInfoDatabase _database; + private List _targetLanguages = new List(); + private Vector2 _scrollPos; + private string _exportPath = "Localization"; + private string _importPath = ""; + + // 统计信息 + private Dictionary _translationStats = new Dictionary(); + private bool _showStats = true; + private bool _showMissing = true; + private List _missingList = new List(); + + [MenuItem("Window/Activity Editor/Localization Manager")] + public static void ShowWindow() + { + var window = GetWindow("本地化 manager"); + window.minSize = new Vector2(500, 600); + window.Show(); + } + + private void OnEnable() + { + // 默认选择常用语言 + _targetLanguages = new List + { + LanguageType.English, + LanguageType.Japanese + }; + + RefreshStats(); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("对话本地化 manager", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + // 数据库选择 + DrawDatabaseSelection(); + + EditorGUILayout.Space(); + + if (_database == null) + { + EditorGUILayout.HelpBox("请选择一个DialogInfoDatabase", MessageType.Info); + return; + } + + // 统计信息 + if (_showStats) + { + DrawStatistics(); + EditorGUILayout.Space(); + } + + // 目标语言选择 + DrawLanguageSelection(); + + EditorGUILayout.Space(); + + // 导出/导入操作 + DrawExportImport(); + + EditorGUILayout.Space(); + + // 缺失翻译列表 + if (_showMissing) + { + DrawMissingTranslations(); + } + } + + private void DrawDatabaseSelection() + { + EditorGUILayout.BeginHorizontal(); + _database = EditorGUILayout.ObjectField("对话数据库", _database, + typeof(DialogInfoDatabase), false) as DialogInfoDatabase; + + if (GUILayout.Button("自动查找", GUILayout.Width(80))) + { + var guids = AssetDatabase.FindAssets("t:DialogInfoDatabase"); + if (guids.Length > 0) + { + var path = AssetDatabase.GUIDToAssetPath(guids[0]); + _database = AssetDatabase.LoadAssetAtPath(path); + RefreshStats(); + } + } + EditorGUILayout.EndHorizontal(); + } + + private void DrawStatistics() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + EditorGUILayout.LabelField("翻译统计", EditorStyles.boldLabel); + + // 计算统计 + CalculateTranslationStats(); + + // 显示各语言翻译完成度 + foreach (var kvp in _translationStats) + { + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField(GetLanguageDisplayName(kvp.Key), GUILayout.Width(100)); + + float percentage = (float)kvp.Value / GetTotalDialogCount() * 100; + var color = percentage >= 100 ? Color.green : percentage >= 50 ? Color.yellow : Color.red; + + GUI.color = color; + EditorGUILayout.LabelField($"{kvp.Value}/{GetTotalDialogCount()} ({percentage:F0}%)", GUILayout.Width(120)); + GUI.color = Color.white; + + // 进度条 + Rect rect = EditorGUILayout.GetControlRect(false, 20); + EditorGUI.ProgressBar(rect, percentage / 100, ""); + + EditorGUILayout.EndHorizontal(); + } + + EditorGUILayout.EndVertical(); + } + + private void DrawLanguageSelection() + { + EditorGUILayout.LabelField("目标语言", EditorStyles.boldLabel); + + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + var allLanguages = System.Enum.GetValues(typeof(LanguageType)).Cast(); + foreach (var lang in allLanguages) + { + if (lang == LanguageType.Chinese) continue; // 中文是源语言 + + bool isSelected = _targetLanguages.Contains(lang); + bool newSelected = EditorGUILayout.Toggle(GetLanguageDisplayName(lang), isSelected); + + if (newSelected && !isSelected) + _targetLanguages.Add(lang); + else if (!newSelected && isSelected) + _targetLanguages.Remove(lang); + } + + EditorGUILayout.EndVertical(); + } + + private void DrawExportImport() + { + EditorGUILayout.LabelField("导出/导入", EditorStyles.boldLabel); + + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + // 导出路径 + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("导出文件夹:", GUILayout.Width(80)); + _exportPath = EditorGUILayout.TextField(_exportPath); + if (GUILayout.Button("浏览", GUILayout.Width(60))) + { + var path = EditorUtility.OpenFolderPanel("选择导出文件夹", _exportPath, ""); + if (!string.IsNullOrEmpty(path)) + { + _exportPath = path; + } + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + // 按钮 + EditorGUILayout.BeginHorizontal(); + + GUI.enabled = _targetLanguages.Count > 0; + if (GUILayout.Button("导出Excel", GUILayout.Height(30))) + { + ExportToExcel(); + } + GUI.enabled = true; + + if (GUILayout.Button("导出CSV", GUILayout.Height(30))) + { + ExportToCSV(); + } + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + + // 导入 + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("导入文件:", GUILayout.Width(80)); + _importPath = EditorGUILayout.TextField(_importPath); + if (GUILayout.Button("浏览", GUILayout.Width(60))) + { + _importPath = EditorUtility.OpenFilePanel("选择导入文件", "", "xlsx,csv"); + } + EditorGUILayout.EndHorizontal(); + + if (GUILayout.Button("导入翻译", GUILayout.Height(30))) + { + ImportTranslations(); + } + + EditorGUILayout.EndVertical(); + } + + private void DrawMissingTranslations() + { + EditorGUILayout.LabelField($"缺失翻译 ({_missingList.Count})", EditorStyles.boldLabel); + + _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.Height(200)); + + foreach (var missing in _missingList.Take(50)) // 只显示前50条 + { + EditorGUILayout.BeginHorizontal(GUI.skin.box); + EditorGUILayout.LabelField($"ID:{missing.DialogId}", GUILayout.Width(80)); + EditorGUILayout.LabelField(missing.SourceText.Substring(0, Mathf.Min(20, missing.SourceText.Length)), GUILayout.Width(150)); + EditorGUILayout.LabelField(GetLanguageDisplayName(missing.TargetLanguage), GUILayout.Width(80)); + EditorGUILayout.EndHorizontal(); + } + + if (_missingList.Count > 50) + { + EditorGUILayout.LabelField($"... 还有 {_missingList.Count - 50} 条 ...", EditorStyles.centeredGreyMiniLabel); + } + + EditorGUILayout.EndScrollView(); + } + + private void ExportToExcel() + { + if (_database == null || _targetLanguages.Count == 0) return; + + var exporter = new DialogLocalizationExporter(); + string fileName = $"DialogLocalization_{System.DateTime.Now:yyyyMMdd_HHmmss}.xlsx"; + string fullPath = Path.Combine(_exportPath, fileName); + + exporter.ExportToExcel(_database, fullPath, _targetLanguages); + + EditorUtility.DisplayDialog("导出完成", $"已导出到:\n{fullPath}", "确定"); + + // 打开文件夹 + EditorUtility.RevealInFinder(fullPath); + } + + private void ExportToCSV() + { + if (_database == null || _targetLanguages.Count == 0) return; + + var exporter = new LocalizationManager(); + exporter.BuildFromDialogDatabase(_database); + + foreach (var lang in _targetLanguages) + { + string csv = exporter.ExportForTranslation(lang); + string fileName = $"DialogLocalization_{lang}_{System.DateTime.Now:yyyyMMdd}.csv"; + string fullPath = Path.Combine(_exportPath, fileName); + + File.WriteAllText(fullPath, csv); + } + + EditorUtility.DisplayDialog("导出完成", $"已导出 {_targetLanguages.Count} 个语言文件到:\n{_exportPath}", "确定"); + } + + private void ImportTranslations() + { + if (string.IsNullOrEmpty(_importPath) || !File.Exists(_importPath)) + { + EditorUtility.DisplayDialog("错误", "请选择有效的导入文件", "确定"); + return; + } + + var exporter = new DialogLocalizationExporter(); + exporter.ImportFromExcel(_importPath, _database); + + EditorUtility.DisplayDialog("导入完成", "翻译已导入到数据库", "确定"); + + RefreshStats(); + } + + private void RefreshStats() + { + CalculateTranslationStats(); + CalculateMissingTranslations(); + } + + private void CalculateTranslationStats() + { + _translationStats.Clear(); + + if (_database == null) return; + + foreach (var lang in System.Enum.GetValues(typeof(LanguageType)).Cast()) + { + if (lang == LanguageType.Chinese) continue; + + int count = 0; + foreach (var config in _database.Configs) + { + foreach (var dialog in config.Dialogs) + { + if (HasTranslation(dialog, lang)) + count++; + } + } + + _translationStats[lang] = count; + } + } + + private void CalculateMissingTranslations() + { + _missingList.Clear(); + + if (_database == null) return; + + var targetLangs = _targetLanguages.Count > 0 ? _targetLanguages : + System.Enum.GetValues(typeof(LanguageType)).Cast().Where(l => l != LanguageType.Chinese).ToList(); + + foreach (var config in _database.Configs) + { + foreach (var dialog in config.Dialogs) + { + foreach (var lang in targetLangs) + { + if (!HasTranslation(dialog, lang)) + { + _missingList.Add(new MissingTranslationInfo + { + DialogId = dialog.ID, + SheetName = config.SheetName, + SourceText = dialog.Text, + TargetLanguage = lang + }); + } + } + } + } + } + + private bool HasTranslation(DialogInfoData dialog, LanguageType lang) + { + return lang switch + { + LanguageType.English => !string.IsNullOrEmpty(dialog.Text_EN), + LanguageType.Japanese => !string.IsNullOrEmpty(dialog.Text_JP), + LanguageType.Korean => !string.IsNullOrEmpty(dialog.Text_KR), + LanguageType.French => !string.IsNullOrEmpty(dialog.Text_FR), + LanguageType.German => !string.IsNullOrEmpty(dialog.Text_DE), + LanguageType.Spanish => !string.IsNullOrEmpty(dialog.Text_ES), + _ => false + }; + } + + private int GetTotalDialogCount() + { + if (_database == null) return 0; + return _database.Configs.Sum(c => c.Dialogs.Count); + } + + private string GetLanguageDisplayName(LanguageType lang) + { + return lang switch + { + LanguageType.Chinese => "中文", + LanguageType.English => "English", + LanguageType.Japanese => "日本語", + LanguageType.Korean => "한국어", + LanguageType.French => "Français", + LanguageType.German => "Deutsch", + LanguageType.Spanish => "Español", + _ => lang.ToString() + }; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/LocalizationExportWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/LocalizationExportWindow.cs.meta new file mode 100644 index 0000000..e9a6903 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/LocalizationExportWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 417566fde51625c4b828809c61ea63c8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/MapPointCSVImporter.cs b/Assets/BP_Scripts/GameplayEditor/Editor/MapPointCSVImporter.cs new file mode 100644 index 0000000..28f17a8 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/MapPointCSVImporter.cs @@ -0,0 +1,152 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameplayEditor.Config; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// 地图点位CSV导入/导出工具 + /// + public static class MapPointCSVImporter + { + /// + /// 从CSV导入点位 + /// + public static void ImportFromCSV(ActivityMapInfoLut lut, string csvPath) + { + if (lut == null || !File.Exists(csvPath)) + { + Debug.LogError("[MapPointCSVImporter] 无效的Lut或CSV路径"); + return; + } + + Undo.RecordObject(lut, "Import Map Points from CSV"); + + var lines = File.ReadAllLines(csvPath); + int importedCount = 0; + + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#")) continue; + + var parts = line.Split(','); + if (parts.Length >= 4) + { + var point = new MapPoint + { + PointID = parts[0].Trim(), + Position = new Vector3( + ParseFloat(parts[1]), + ParseFloat(parts[2]), + ParseFloat(parts[3]) + ) + }; + + // 检查ID是否已存在 + var existing = lut.Points.Find(p => p.PointID == point.PointID); + if (existing != null) + { + existing.Position = point.Position; + } + else + { + lut.Points.Add(point); + } + importedCount++; + } + } + + EditorUtility.SetDirty(lut); + Debug.Log($"[MapPointCSVImporter] 导入完成: {importedCount} 个点位"); + } + + /// + /// 导出点位到CSV + /// + public static void ExportToCSV(ActivityMapInfoLut lut, string csvPath) + { + if (lut == null) + { + Debug.LogError("[MapPointCSVImporter] 无效的Lut"); + return; + } + + var lines = new List(); + lines.Add("# PointID,X,Y,Z"); + lines.Add($"# MapInfoID: {lut.InfoID}"); + lines.Add($"# Export Time: {System.DateTime.Now}"); + + foreach (var point in lut.Points) + { + lines.Add($"{point.PointID},{point.Position.x:F4},{point.Position.y:F4},{point.Position.z:F4}"); + } + + File.WriteAllLines(csvPath, lines); + Debug.Log($"[MapPointCSVImporter] 导出完成: {lut.Points.Count} 个点位到 {csvPath}"); + } + + private static float ParseFloat(string s) + { + float.TryParse(s.Trim(), System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var result); + return result; + } + } + + /// + /// 地图点位CSV导入窗口 + /// + public class MapPointCSVImportWindow : EditorWindow + { + private ActivityMapInfoLut _targetLut; + private string _csvPath = ""; + private bool _overwriteExisting = false; + + [MenuItem("Window/Activity Editor/Map Point CSV Import")] + public static void ShowWindow() + { + GetWindow("点位CSV导入"); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("地图点位CSV导入", EditorStyles.boldLabel); + + _targetLut = EditorGUILayout.ObjectField("目标Lut", _targetLut, + typeof(ActivityMapInfoLut), false) as ActivityMapInfoLut; + + EditorGUILayout.BeginHorizontal(); + _csvPath = EditorGUILayout.TextField("CSV文件", _csvPath); + if (GUILayout.Button("浏览", GUILayout.Width(60))) + { + _csvPath = EditorUtility.OpenFilePanel("选择CSV文件", "", "csv"); + } + EditorGUILayout.EndHorizontal(); + + _overwriteExisting = EditorGUILayout.Toggle("覆盖已存在", _overwriteExisting); + + EditorGUILayout.Space(); + + GUI.enabled = _targetLut != null && !string.IsNullOrEmpty(_csvPath); + + if (GUILayout.Button("导入", GUILayout.Height(30))) + { + MapPointCSVImporter.ImportFromCSV(_targetLut, _csvPath); + } + + if (GUILayout.Button("导出", GUILayout.Height(30))) + { + var path = EditorUtility.SaveFilePanel("导出CSV", "", $"{_targetLut.name}_Points", "csv"); + if (!string.IsNullOrEmpty(path)) + { + MapPointCSVImporter.ExportToCSV(_targetLut, path); + } + } + + GUI.enabled = true; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/MapPointCSVImporter.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/MapPointCSVImporter.cs.meta new file mode 100644 index 0000000..1b3f65a --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/MapPointCSVImporter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5dec57cdc48f2c341935435b804c9b6b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/MapPointEditorWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/MapPointEditorWindow.cs new file mode 100644 index 0000000..9a1c57f --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/MapPointEditorWindow.cs @@ -0,0 +1,670 @@ +using System.Collections.Generic; +using System.Linq; +using GameplayEditor.Config; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// 地图点位编辑器主窗口 + /// 用于管理ActivityMapInfoLut配置的点位 + /// + public class MapPointEditorWindow : EditorWindow + { + // 当前编辑的Lut + private ActivityMapInfoLut _currentLut; + + // 滚动位置 + private Vector2 _scrollPosition; + private Vector2 _listScrollPosition; + + // 编辑状态 + private int _selectedPointIndex = -1; + private string _searchFilter = ""; + private bool _autoFocusSceneView = true; + + // 新点位默认设置 + // private string _newPointId = ""; // 预留 + private Vector3 _newPointPosition = Vector3.zero; + + // 批量操作 + private bool _showBatchOperations = false; + private float _snapGridSize = 1f; + private bool _enableSnap = false; + + [MenuItem("Window/Activity Editor/Map Point Editor")] + public static void ShowWindow() + { + var window = GetWindow("地图点位编辑器"); + window.minSize = new Vector2(400, 500); + window.Show(); + } + + private void OnEnable() + { + // 从SceneEditor同步选中状态 + _selectedPointIndex = MapPointSceneEditor.GetSelectedPointIndex(); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("地图点位编辑器", EditorStyles.boldLabel); + EditorGUILayout.Space(10); + + // 选择MapInfoLut + DrawLutSelection(); + + EditorGUILayout.Space(10); + + if (_currentLut == null) + { + EditorGUILayout.HelpBox("请选择一个ActivityMapInfoLut配置开始编辑", MessageType.Info); + return; + } + + // 统计信息 + DrawStatistics(); + + EditorGUILayout.Space(10); + + // 点位列表和详情(左右布局) + EditorGUILayout.BeginHorizontal(); + + // 左侧:点位列表 + EditorGUILayout.BeginVertical(GUILayout.Width(200)); + DrawPointList(); + EditorGUILayout.EndVertical(); + + // 分隔线 + GUILayout.Box("", GUILayout.Width(1), GUILayout.ExpandHeight(true)); + + // 右侧:点位详情 + EditorGUILayout.BeginVertical(); + DrawPointDetails(); + EditorGUILayout.EndVertical(); + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(10); + + // 批量操作 + DrawBatchOperations(); + + EditorGUILayout.Space(10); + + // 工具按钮 + DrawToolbar(); + } + + /// + /// 绘制Lut选择区域 + /// + private void DrawLutSelection() + { + EditorGUILayout.BeginHorizontal(); + + EditorGUILayout.LabelField("MapInfoLut:", GUILayout.Width(80)); + + var newLut = EditorGUILayout.ObjectField( + _currentLut, + typeof(ActivityMapInfoLut), + false + ) as ActivityMapInfoLut; + + if (newLut != _currentLut) + { + SetCurrentLut(newLut); + } + + // 创建新Lut按钮 + if (GUILayout.Button("新建", GUILayout.Width(50))) + { + CreateNewLut(); + } + + EditorGUILayout.EndHorizontal(); + } + + /// + /// 设置当前编辑的Lut + /// + private void SetCurrentLut(ActivityMapInfoLut lut) + { + _currentLut = lut; + _selectedPointIndex = -1; + + // 通知SceneEditor + MapPointSceneEditor.SetEditingLut(lut); + + if (lut != null) + { + Debug.Log($"[MapPointEditorWindow] 开始编辑: {lut.name}"); + } + } + + /// + /// 创建新的MapInfoLut + /// + private void CreateNewLut() + { + string path = EditorUtility.SaveFilePanelInProject( + "创建MapInfoLut", + "MapInfoLut", + "asset", + "", + "Assets/Configs" + ); + + if (!string.IsNullOrEmpty(path)) + { + var newLut = ScriptableObject.CreateInstance(); + newLut.InfoID = 1000; // 默认ID + newLut.Points = new List(); + + AssetDatabase.CreateAsset(newLut, path); + AssetDatabase.SaveAssets(); + + SetCurrentLut(newLut); + + Debug.Log($"[MapPointEditorWindow] 创建新Lut: {path}"); + } + } + + /// + /// 绘制统计信息 + /// + private void DrawStatistics() + { + EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); + + EditorGUILayout.LabelField($"点位总数: {_currentLut.Points?.Count ?? 0}", GUILayout.Width(100)); + EditorGUILayout.LabelField($"InfoID: {_currentLut.InfoID}", GUILayout.Width(100)); + + // ID编辑 + EditorGUI.BeginChangeCheck(); + int newId = EditorGUILayout.IntField("ID:", _currentLut.InfoID, GUILayout.Width(100)); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(_currentLut, "Change InfoID"); + _currentLut.InfoID = newId; + EditorUtility.SetDirty(_currentLut); + } + + EditorGUILayout.EndHorizontal(); + } + + /// + /// 绘制点位列表 + /// + private void DrawPointList() + { + EditorGUILayout.LabelField("点位列表", EditorStyles.boldLabel); + + // 搜索框 + _searchFilter = EditorGUILayout.TextField( + EditorGUIUtility.IconContent("Search Icon"), + _searchFilter + ); + + EditorGUILayout.Space(5); + + // 列表 + _listScrollPosition = EditorGUILayout.BeginScrollView( + _listScrollPosition, + GUILayout.ExpandHeight(true) + ); + + if (_currentLut.Points != null) + { + var filteredPoints = GetFilteredPoints(); + + for (int i = 0; i < filteredPoints.Count; i++) + { + var point = filteredPoints[i]; + int actualIndex = GetActualIndex(point); + + EditorGUILayout.BeginHorizontal(); + + // 选中高亮 + GUI.backgroundColor = (actualIndex == _selectedPointIndex) + ? Color.yellow + : Color.white; + + if (GUILayout.Button($"{point.PointID}", EditorStyles.toolbarButton)) + { + SelectPoint(actualIndex); + } + + GUI.backgroundColor = Color.white; + + EditorGUILayout.EndHorizontal(); + } + } + + EditorGUILayout.EndScrollView(); + + // 快速操作 + EditorGUILayout.BeginHorizontal(); + + if (GUILayout.Button("+")) + { + CreateNewPoint(); + } + + GUI.enabled = (_selectedPointIndex >= 0); + + if (GUILayout.Button("-")) + { + DeleteSelectedPoint(); + } + + GUI.enabled = true; + + EditorGUILayout.EndHorizontal(); + } + + /// + /// 获取过滤后的点位列表 + /// + private List GetFilteredPoints() + { + if (string.IsNullOrEmpty(_searchFilter)) + { + return _currentLut.Points; + } + + return _currentLut.Points + .Where(p => p.PointID.ToLower().Contains(_searchFilter.ToLower())) + .ToList(); + } + + /// + /// 根据点位获取实际索引 + /// + private int GetActualIndex(MapPoint point) + { + return _currentLut.Points.IndexOf(point); + } + + /// + /// 选择点位 + /// + private void SelectPoint(int index) + { + _selectedPointIndex = index; + + // 通知SceneEditor + MapPointSceneEditor.SelectPoint(index); + + // 聚焦SceneView + if (_autoFocusSceneView && index >= 0 && index < _currentLut.Points.Count) + { + var point = _currentLut.Points[index]; + FocusSceneViewOnPoint(point.Position); + } + + Repaint(); + } + + /// + /// SceneView聚焦到点位 + /// + private void FocusSceneViewOnPoint(Vector3 position) + { + var sceneView = SceneView.lastActiveSceneView; + if (sceneView != null) + { + sceneView.pivot = position; + sceneView.Repaint(); + } + } + + /// + /// 绘制点位详情 + /// + private void DrawPointDetails() + { + EditorGUILayout.LabelField("点位详情", EditorStyles.boldLabel); + + if (_selectedPointIndex < 0 || _selectedPointIndex >= _currentLut.Points.Count) + { + EditorGUILayout.HelpBox("选择一个点位进行编辑", MessageType.Info); + return; + } + + var point = _currentLut.Points[_selectedPointIndex]; + + EditorGUI.BeginChangeCheck(); + + // ID编辑 + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("点位ID:", GUILayout.Width(60)); + string newId = EditorGUILayout.TextField(point.PointID); + if (newId != point.PointID && IsPointIdUnique(newId, _selectedPointIndex)) + { + point.PointID = newId; + } + EditorGUILayout.EndHorizontal(); + + // 位置编辑 + EditorGUILayout.Space(5); + EditorGUILayout.LabelField("位置:", EditorStyles.miniBoldLabel); + + Vector3 newPosition = EditorGUILayout.Vector3Field("", point.Position); + + // 快捷按钮 + EditorGUILayout.BeginHorizontal(); + + if (GUILayout.Button("归零")) + { + newPosition = Vector3.zero; + } + + if (GUILayout.Button("向上+1")) + { + newPosition.y += 1; + } + + if (GUILayout.Button("吸附网格")) + { + newPosition = SnapToGrid(newPosition); + } + + EditorGUILayout.EndHorizontal(); + + // 复制坐标按钮 + if (GUILayout.Button("复制坐标到剪贴板")) + { + EditorGUIUtility.systemCopyBuffer = $"{newPosition.x},{newPosition.y},{newPosition.z}"; + ShowNotification(new GUIContent("坐标已复制!"), 1f); + } + + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(_currentLut, $"Edit Point {point.PointID}"); + point.Position = newPosition; + EditorUtility.SetDirty(_currentLut); + + // 刷新SceneView + SceneView.RepaintAll(); + } + + EditorGUILayout.Space(10); + + // 删除按钮 + if (GUILayout.Button("删除此点位", GUILayout.Height(30))) + { + DeleteSelectedPoint(); + } + } + + /// + /// 检查点位ID是否唯一 + /// + private bool IsPointIdUnique(string id, int excludeIndex) + { + for (int i = 0; i < _currentLut.Points.Count; i++) + { + if (i != excludeIndex && _currentLut.Points[i].PointID == id) + { + return false; + } + } + return true; + } + + /// + /// 吸附到网格 + /// + private Vector3 SnapToGrid(Vector3 position) + { + return new Vector3( + Mathf.Round(position.x / _snapGridSize) * _snapGridSize, + Mathf.Round(position.y / _snapGridSize) * _snapGridSize, + Mathf.Round(position.z / _snapGridSize) * _snapGridSize + ); + } + + /// + /// 绘制批量操作 + /// + private void DrawBatchOperations() + { + _showBatchOperations = EditorGUILayout.Foldout(_showBatchOperations, "批量操作"); + + if (!_showBatchOperations) + return; + + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + // 网格吸附设置 + EditorGUILayout.BeginHorizontal(); + _enableSnap = EditorGUILayout.Toggle("启用网格吸附", _enableSnap); + _snapGridSize = EditorGUILayout.FloatField("网格大小", _snapGridSize); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(5); + + // 批量操作按钮 + EditorGUILayout.BeginHorizontal(); + + if (GUILayout.Button("全部归零Y")) + { + BatchSetY(0); + } + + if (GUILayout.Button("全部吸附网格")) + { + BatchSnapToGrid(); + } + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(5); + + // ID重命名 + EditorGUILayout.BeginHorizontal(); + + if (GUILayout.Button("重新编号")) + { + if (EditorUtility.DisplayDialog("确认", "将所有点位重新编号为Point_0, Point_1...?", "确定", "取消")) + { + BatchRenamePoints(); + } + } + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.EndVertical(); + } + + /// + /// 批量设置Y坐标 + /// + private void BatchSetY(float y) + { + Undo.RecordObject(_currentLut, "Batch Set Y"); + + foreach (var point in _currentLut.Points) + { + var pos = point.Position; + pos.y = y; + point.Position = pos; + } + + EditorUtility.SetDirty(_currentLut); + SceneView.RepaintAll(); + + Debug.Log($"[MapPointEditorWindow] 批量设置Y坐标为: {y}"); + } + + /// + /// 批量吸附到网格 + /// + private void BatchSnapToGrid() + { + Undo.RecordObject(_currentLut, "Batch Snap To Grid"); + + foreach (var point in _currentLut.Points) + { + point.Position = SnapToGrid(point.Position); + } + + EditorUtility.SetDirty(_currentLut); + SceneView.RepaintAll(); + + Debug.Log("[MapPointEditorWindow] 批量吸附到网格"); + } + + /// + /// 批量重命名点位 + /// + private void BatchRenamePoints() + { + Undo.RecordObject(_currentLut, "Batch Rename Points"); + + for (int i = 0; i < _currentLut.Points.Count; i++) + { + _currentLut.Points[i].PointID = $"Point_{i}"; + } + + EditorUtility.SetDirty(_currentLut); + Repaint(); + + Debug.Log("[MapPointEditorWindow] 批量重命名完成"); + } + + /// + /// 绘制工具栏 + /// + private void DrawToolbar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + // 在SceneView中显示/隐藏 + if (GUILayout.Toggle( + MapPointSceneEditor.IsEditing(), + "在SceneView中显示", + EditorStyles.toolbarButton + )) + { + if (!MapPointSceneEditor.IsEditing()) + { + MapPointSceneEditor.SetEditingLut(_currentLut); + } + } + else + { + if (MapPointSceneEditor.IsEditing()) + { + MapPointSceneEditor.StopEditing(); + } + } + + GUILayout.FlexibleSpace(); + + // 聚焦按钮 + if (GUILayout.Button("聚焦选中", EditorStyles.toolbarButton)) + { + if (_selectedPointIndex >= 0 && _selectedPointIndex < _currentLut.Points.Count) + { + FocusSceneViewOnPoint(_currentLut.Points[_selectedPointIndex].Position); + } + } + + // 导出按钮 + if (GUILayout.Button("导出数据", EditorStyles.toolbarButton)) + { + ExportPointData(); + } + + EditorGUILayout.EndHorizontal(); + } + + /// + /// 创建新点位 + /// + private void CreateNewPoint() + { + Vector3 position = Vector3.zero; + + // 如果有选中点位,在新位置附近创建 + if (_selectedPointIndex >= 0 && _selectedPointIndex < _currentLut.Points.Count) + { + position = _currentLut.Points[_selectedPointIndex].Position + Vector3.right * 2; + } + else + { + // 使用SceneView中心 + var sceneView = SceneView.lastActiveSceneView; + if (sceneView != null) + { + position = sceneView.pivot; + } + } + + // 通过SceneEditor创建 + MapPointSceneEditor.CreatePointAt(position); + + Repaint(); + } + + /// + /// 删除选中的点位 + /// + private void DeleteSelectedPoint() + { + MapPointSceneEditor.DeleteSelectedPoint(); + Repaint(); + } + + /// + /// 导出点位数据 + /// + private void ExportPointData() + { + string path = EditorUtility.SaveFilePanel( + "导出点位数据", + "", + $"{_currentLut.name}_Points", + "txt" + ); + + if (string.IsNullOrEmpty(path)) + return; + + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"# {_currentLut.name} 点位数据"); + sb.AppendLine($"# InfoID: {_currentLut.InfoID}"); + sb.AppendLine($"# 导出时间: {System.DateTime.Now}"); + sb.AppendLine(); + + foreach (var point in _currentLut.Points) + { + sb.AppendLine($"{point.PointID}\t{point.Position.x:F2}\t{point.Position.y:F2}\t{point.Position.z:F2}"); + } + + System.IO.File.WriteAllText(path, sb.ToString()); + + Debug.Log($"[MapPointEditorWindow] 导出到: {path}"); + ShowNotification(new GUIContent("导出成功!"), 2f); + } + + /// + /// SceneEditor选中点位时的回调 + /// + public static void OnPointSelected(int index) + { + var window = GetWindow(); + window._selectedPointIndex = index; + window.Repaint(); + } + + private void OnDisable() + { + // 停止SceneView编辑 + MapPointSceneEditor.StopEditing(); + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/MapPointEditorWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/MapPointEditorWindow.cs.meta new file mode 100644 index 0000000..581bc92 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/MapPointEditorWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d3b16afb6e5c42d429fbf328a0c1d20c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/MapPointSceneEditor.cs b/Assets/BP_Scripts/GameplayEditor/Editor/MapPointSceneEditor.cs new file mode 100644 index 0000000..16312e4 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/MapPointSceneEditor.cs @@ -0,0 +1,553 @@ +using System.Collections.Generic; +using System.Linq; +using GameplayEditor.Config; +using UnityEditor; +using UnityEngine; +using Event = UnityEngine.Event; +using EventType = UnityEngine.EventType; + +namespace GameplayEditor.Editor +{ + /// + /// 地图点位SceneView编辑器 + /// 在SceneView中可视化编辑ActivityMapInfoLut的点位 + /// + [InitializeOnLoad] + public static class MapPointSceneEditor + { + // 当前编辑的MapInfoLut + private static ActivityMapInfoLut _currentLut; + private static int _selectedPointIndex = -1; + private static bool _isEditing = false; + + // 编辑器状态 + private static Vector3 _dragStartPosition; + // private static bool _isDragging = false; // 预留 + private static int _draggingPointIndex = -1; + + // 视觉设置 + private const float POINT_SIZE = 0.5f; + private const float SELECTED_POINT_SIZE = 0.8f; + private const float LABEL_OFFSET = 1.0f; + + // 颜色设置 + private static readonly Color NORMAL_COLOR = new Color(0.2f, 0.8f, 0.2f, 0.8f); // 绿色 + private static readonly Color SELECTED_COLOR = new Color(1f, 0.8f, 0.2f, 0.9f); // 黄色 + private static readonly Color HOVER_COLOR = new Color(0.4f, 0.9f, 1f, 0.8f); // 青色 + private static readonly Color LINE_COLOR = new Color(1f, 1f, 1f, 0.3f); // 白色半透明 + + static MapPointSceneEditor() + { + // 注册SceneView绘制回调 + SceneView.duringSceneGui += OnSceneGUI; + } + + /// + /// 设置当前编辑的MapInfoLut + /// + public static void SetEditingLut(ActivityMapInfoLut lut) + { + _currentLut = lut; + _selectedPointIndex = -1; + _isEditing = (lut != null); + + if (lut != null) + { + Debug.Log($"[MapPointSceneEditor] 开始编辑: {lut.name} (InfoID: {lut.InfoID})"); + SceneView.RepaintAll(); + } + } + + /// + /// 获取当前编辑的Lut + /// + public static ActivityMapInfoLut GetCurrentLut() + { + return _currentLut; + } + + /// + /// SceneView GUI绘制 + /// + private static void OnSceneGUI(SceneView sceneView) + { + if (!_isEditing || _currentLut == null) + return; + + // 获取当前事件 + Event e = Event.current; + + // 绘制所有点位 + DrawAllPoints(); + + // 绘制连线(如果有多个点位) + DrawPointConnections(); + + // 处理输入事件 + HandleSceneViewInput(e); + + // 绘制工具栏 + DrawToolbar(sceneView); + } + + /// + /// 绘制所有点位 + /// + private static void DrawAllPoints() + { + if (_currentLut?.Points == null) + return; + + for (int i = 0; i < _currentLut.Points.Count; i++) + { + var point = _currentLut.Points[i]; + if (point == null) continue; + + bool isSelected = (i == _selectedPointIndex); + bool isDragging = (i == _draggingPointIndex); + + DrawPoint(point, i, isSelected, isDragging); + } + } + + /// + /// 绘制单个点位 + /// + private static void DrawPoint(MapPoint point, int index, bool isSelected, bool isDragging) + { + Vector3 position = point.Position; + + // 确定颜色 + Color color = isSelected ? SELECTED_COLOR : NORMAL_COLOR; + float size = isSelected ? SELECTED_POINT_SIZE : POINT_SIZE; + + // 绘制点(球体) + Handles.color = color; + Handles.SphereHandleCap( + 0, + position, + Quaternion.identity, + size, + EventType.Repaint + ); + + // 绘制标签 + string label = $"{point.PointID}\n({position.x:F1}, {position.y:F1}, {position.z:F1})"; + GUIStyle style = new GUIStyle(GUI.skin.label); + style.normal.textColor = Color.white; + style.fontSize = 11; + style.fontStyle = isSelected ? FontStyle.Bold : FontStyle.Normal; + style.alignment = TextAnchor.MiddleCenter; + + // 计算标签位置(屏幕空间偏移) + Vector3 labelWorldPos = position + Vector3.up * LABEL_OFFSET; + Handles.Label(labelWorldPos, label, style); + + // 如果是选中状态,绘制坐标轴 + if (isSelected) + { + DrawPositionHandle(position); + } + } + + /// + /// 绘制位置操控器(用于拖拽移动) + /// + private static void DrawPositionHandle(Vector3 position) + { + EditorGUI.BeginChangeCheck(); + + // 使用Unity的位置手柄 + Vector3 newPosition = Handles.PositionHandle( + position, + Quaternion.identity + ); + + if (EditorGUI.EndChangeCheck()) + { + // 位置变化,记录Undo + if (_selectedPointIndex >= 0 && _selectedPointIndex < _currentLut.Points.Count) + { + Undo.RecordObject(_currentLut, $"Move Point {_currentLut.Points[_selectedPointIndex].PointID}"); + _currentLut.Points[_selectedPointIndex].Position = newPosition; + EditorUtility.SetDirty(_currentLut); + } + } + } + + /// + /// 绘制点位连线 + /// + private static void DrawPointConnections() + { + if (_currentLut?.Points == null || _currentLut.Points.Count < 2) + return; + + Handles.color = LINE_COLOR; + + for (int i = 0; i < _currentLut.Points.Count - 1; i++) + { + var point1 = _currentLut.Points[i]; + var point2 = _currentLut.Points[i + 1]; + + if (point1 != null && point2 != null) + { + Handles.DrawLine(point1.Position, point2.Position); + } + } + } + + /// + /// 处理SceneView输入 + /// + private static void HandleSceneViewInput(Event e) + { + // 左键点击选择点位 + if (e.type == EventType.MouseDown && e.button == 0) + { + Ray ray = HandleUtility.GUIPointToWorldRay(e.mousePosition); + int clickedIndex = RaycastPoint(ray); + + if (clickedIndex >= 0) + { + SelectPoint(clickedIndex); + e.Use(); + } + else + { + // 点击空白处,取消选择 + if (!e.shift && !e.control) + { + DeselectAll(); + } + } + } + + // 右键菜单 + if (e.type == EventType.MouseDown && e.button == 1) + { + ShowContextMenu(e.mousePosition); + e.Use(); + } + + // 键盘删除 + if (e.type == EventType.KeyDown && e.keyCode == KeyCode.Delete) + { + if (_selectedPointIndex >= 0) + { + DeleteSelectedPoint(); + e.Use(); + } + } + + // Ctrl+D 复制 + if (e.type == EventType.KeyDown && e.keyCode == KeyCode.D && e.control) + { + if (_selectedPointIndex >= 0) + { + DuplicateSelectedPoint(); + e.Use(); + } + } + } + + /// + /// 射线检测点位 + /// + private static int RaycastPoint(Ray ray) + { + if (_currentLut?.Points == null) + return -1; + + float closestDistance = float.MaxValue; + int closestIndex = -1; + + for (int i = 0; i < _currentLut.Points.Count; i++) + { + var point = _currentLut.Points[i]; + if (point == null) continue; + + // 计算射线到点的距离 + float distance = HandleUtility.DistanceToCircle(point.Position, POINT_SIZE); + + if (distance < 0.5f && distance < closestDistance) + { + closestDistance = distance; + closestIndex = i; + } + } + + return closestIndex; + } + + /// + /// 选择点位 + /// + public static void SelectPoint(int index) + { + if (index >= 0 && index < _currentLut?.Points?.Count) + { + _selectedPointIndex = index; + + // 通知窗口更新 + MapPointEditorWindow.OnPointSelected(index); + + SceneView.RepaintAll(); + + Debug.Log($"[MapPointSceneEditor] 选中点位: {_currentLut.Points[index].PointID}"); + } + } + + /// + /// 取消所有选择 + /// + public static void DeselectAll() + { + _selectedPointIndex = -1; + MapPointEditorWindow.OnPointSelected(-1); + SceneView.RepaintAll(); + } + + /// + /// 在指定位置创建新点位 + /// + public static void CreatePointAt(Vector3 position, string pointId = null) + { + if (_currentLut == null) + return; + + Undo.RecordObject(_currentLut, "Create Map Point"); + + var newPoint = new MapPoint + { + PointID = pointId ?? GenerateUniquePointId(), + Position = position + }; + + _currentLut.Points.Add(newPoint); + EditorUtility.SetDirty(_currentLut); + + // 选中新点位 + SelectPoint(_currentLut.Points.Count - 1); + + Debug.Log($"[MapPointSceneEditor] 创建点位: {newPoint.PointID} at {position}"); + } + + /// + /// 在SceneView中心创建点位 + /// + public static void CreatePointAtSceneViewCenter() + { + var sceneView = SceneView.lastActiveSceneView; + if (sceneView != null) + { + Vector3 center = sceneView.pivot; + CreatePointAt(center); + } + } + + /// + /// 生成唯一的点位ID + /// + private static string GenerateUniquePointId() + { + if (_currentLut?.Points == null) + return "Point_0"; + + int index = _currentLut.Points.Count; + string id; + + do + { + id = $"Point_{index}"; + index++; + } while (_currentLut.Points.Any(p => p.PointID == id)); + + return id; + } + + /// + /// 删除选中的点位 + /// + public static void DeleteSelectedPoint() + { + if (_selectedPointIndex < 0 || _currentLut?.Points == null) + return; + + var point = _currentLut.Points[_selectedPointIndex]; + + Undo.RecordObject(_currentLut, $"Delete Point {point.PointID}"); + _currentLut.Points.RemoveAt(_selectedPointIndex); + EditorUtility.SetDirty(_currentLut); + + // 调整选中索引 + if (_selectedPointIndex >= _currentLut.Points.Count) + { + _selectedPointIndex = _currentLut.Points.Count - 1; + } + + MapPointEditorWindow.OnPointSelected(_selectedPointIndex); + SceneView.RepaintAll(); + + Debug.Log($"[MapPointSceneEditor] 删除点位: {point.PointID}"); + } + + /// + /// 复制选中的点位 + /// + public static void DuplicateSelectedPoint() + { + if (_selectedPointIndex < 0 || _currentLut?.Points == null) + return; + + var sourcePoint = _currentLut.Points[_selectedPointIndex]; + + Undo.RecordObject(_currentLut, $"Duplicate Point {sourcePoint.PointID}"); + + var newPoint = new MapPoint + { + PointID = GenerateUniquePointId(), + Position = sourcePoint.Position + Vector3.right * 2f // 偏移一点 + }; + + _currentLut.Points.Add(newPoint); + EditorUtility.SetDirty(_currentLut); + + // 选中新点位 + SelectPoint(_currentLut.Points.Count - 1); + + Debug.Log($"[MapPointSceneEditor] 复制点位: {sourcePoint.PointID} -> {newPoint.PointID}"); + } + + /// + /// 显示右键菜单 + /// + private static void ShowContextMenu(Vector2 mousePosition) + { + GenericMenu menu = new GenericMenu(); + + // 获取点击位置的3D坐标 + Ray ray = HandleUtility.GUIPointToWorldRay(mousePosition); + Vector3 worldPos = ray.origin + ray.direction * 10f; // 默认距离 + + // 射线检测平面 + Plane groundPlane = new Plane(Vector3.up, Vector3.zero); + if (groundPlane.Raycast(ray, out float distance)) + { + worldPos = ray.GetPoint(distance); + } + + menu.AddItem(new GUIContent("创建点位 (Create Point)"), false, () => + { + CreatePointAt(worldPos); + }); + + menu.AddSeparator(""); + + if (_selectedPointIndex >= 0) + { + menu.AddItem(new GUIContent("删除选中 (Delete)"), false, DeleteSelectedPoint); + menu.AddItem(new GUIContent("复制 (Duplicate)"), false, DuplicateSelectedPoint); + menu.AddSeparator(""); + } + + menu.AddItem(new GUIContent("全部选择 (Select All)"), false, () => + { + if (_currentLut?.Points?.Count > 0) + { + SelectPoint(0); + } + }); + + menu.AddItem(new GUIContent("取消选择 (Deselect)"), false, DeselectAll); + + menu.ShowAsContext(); + } + + /// + /// 绘制工具栏 + /// + private static void DrawToolbar(SceneView sceneView) + { + // 在SceneView左上角绘制工具栏 + Handles.BeginGUI(); + + GUILayout.BeginArea(new Rect(10, 10, 250, 120), EditorStyles.helpBox); + + GUILayout.Label("地图点位编辑器", EditorStyles.boldLabel); + + if (_currentLut != null) + { + GUILayout.Label($"当前: {_currentLut.name}", EditorStyles.miniLabel); + GUILayout.Label($"点位数: {_currentLut.Points?.Count ?? 0}", EditorStyles.miniLabel); + + GUILayout.Space(5); + + GUILayout.BeginHorizontal(); + + if (GUILayout.Button("+ 创建", GUILayout.Width(60))) + { + CreatePointAtSceneViewCenter(); + } + + GUI.enabled = (_selectedPointIndex >= 0); + + if (GUILayout.Button("- 删除", GUILayout.Width(60))) + { + DeleteSelectedPoint(); + } + + if (GUILayout.Button("复制", GUILayout.Width(60))) + { + DuplicateSelectedPoint(); + } + + GUI.enabled = true; + + GUILayout.EndHorizontal(); + + GUILayout.Space(5); + GUILayout.Label("快捷键: Delete=删除 Ctrl+D=复制", EditorStyles.miniLabel); + } + else + { + GUILayout.Label("未选择MapInfoLut", EditorStyles.miniLabel); + if (GUILayout.Button("打开编辑器窗口")) + { + MapPointEditorWindow.ShowWindow(); + } + } + + GUILayout.EndArea(); + + Handles.EndGUI(); + } + + /// + /// 获取选中的点位索引 + /// + public static int GetSelectedPointIndex() + { + return _selectedPointIndex; + } + + /// + /// 停止编辑 + /// + public static void StopEditing() + { + _isEditing = false; + _currentLut = null; + _selectedPointIndex = -1; + SceneView.RepaintAll(); + } + + /// + /// 是否正在编辑 + /// + public static bool IsEditing() + { + return _isEditing; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/MapPointSceneEditor.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/MapPointSceneEditor.cs.meta new file mode 100644 index 0000000..8cf1244 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/MapPointSceneEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 11e8907bd1e148e4e90aee3812ddba0e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/RowIndexTestWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/RowIndexTestWindow.cs new file mode 100644 index 0000000..952c10b --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/RowIndexTestWindow.cs @@ -0,0 +1,115 @@ +using UnityEditor; +using UnityEngine; +using GameplayEditor.Utils; +using GameplayEditor.Core; + +namespace GameplayEditor.Editor +{ + /// + /// 行索引功能测试窗口 + /// 用于验证功能1: 行索引ID自动生成 + /// + public class RowIndexTestWindow : EditorWindow + { + private RowIndexGenerator _generator; + private Vector2 _scrollPos; + private string _testLog = ""; + + [MenuItem("Window/Activity Editor/RowIndex Test")] + public static void ShowWindow() + { + GetWindow("行索引测试"); + } + + private void OnEnable() + { + _generator = new RowIndexGenerator(); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("功能1: 行索引ID自动生成 - 测试", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + // 状态显示 + EditorGUILayout.LabelField("当前状态:", EditorStyles.boldLabel); + EditorGUILayout.LabelField($" 当前最大索引: {_generator?.CurrentMaxIndex ?? 0}"); + EditorGUILayout.LabelField($" 已注册节点数: {_generator?.NodeIdToRowIndexMap?.Count ?? 0}"); + EditorGUILayout.Space(); + + // 测试按钮 + EditorGUILayout.LabelField("测试操作:", EditorStyles.boldLabel); + + if (GUILayout.Button("1. 重置生成器")) + { + _generator.Reset(); + _testLog += "[重置] 生成器已重置\n"; + } + + if (GUILayout.Button("2. 生成10个行索引")) + { + _testLog += "[生成] "; + for (int i = 0; i < 10; i++) + { + var idx = _generator.GenerateNext(); + _testLog += $"{idx} "; + } + _testLog += "\n"; + } + + if (GUILayout.Button("3. 注册节点ID映射 (1001→1, 1002→2, 1003→3)")) + { + _generator.RegisterNodeRowIndex(1001, 1); + _generator.RegisterNodeRowIndex(1002, 2); + _generator.RegisterNodeRowIndex(1003, 3); + _testLog += "[注册] 节点ID 1001→1, 1002→2, 1003→3\n"; + } + + if (GUILayout.Button("4. 查询节点1002的行索引")) + { + var idx = _generator.GetRowIndexByNodeId(1002); + _testLog += $"[查询] 节点1002的行索引 = {idx}\n"; + } + + EditorGUILayout.Space(); + + // 节点信息容器测试 + EditorGUILayout.LabelField("节点信息容器测试:", EditorStyles.boldLabel); + if (GUILayout.Button("5. 创建测试容器")) + { + var container = CreateInstance(); + container.BehaviourTreeName = "Test_BT_1001"; + container.TreeID = 1001; + + for (int i = 1; i <= 5; i++) + { + container.AddNodeInfo(new BehaviourTreeNodeInfo + { + RowIndex = i, + NodeID = 1000 + i, + NodeTypeName = $"TestNode_{i}", + ExcelRowNumber = i + 1 + }); + } + + _testLog += $"[容器] 创建容器: {container.BehaviourTreeName}, 节点数: {container.NodeInfos.Count}\n"; + _testLog += $"[容器] 查找RowIndex=3: {container.FindByRowIndex(3)?.NodeTypeName}\n"; + + DestroyImmediate(container); + } + + EditorGUILayout.Space(); + + // 日志显示 + EditorGUILayout.LabelField("测试日志:", EditorStyles.boldLabel); + _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.Height(200)); + EditorGUILayout.TextArea(_testLog, GUILayout.ExpandHeight(true)); + EditorGUILayout.EndScrollView(); + + if (GUILayout.Button("清空日志")) + { + _testLog = ""; + } + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/RowIndexTestWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/RowIndexTestWindow.cs.meta new file mode 100644 index 0000000..a776061 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/RowIndexTestWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fc418733e5fc54c4383ab33478f307cd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/TestFileGenerator.cs b/Assets/BP_Scripts/GameplayEditor/Editor/TestFileGenerator.cs new file mode 100644 index 0000000..f88526d --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/TestFileGenerator.cs @@ -0,0 +1,124 @@ +using System.IO; +using NPOI.SS.UserModel; +using NPOI.XSSF.UserModel; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// 生成用于测试导入的示例 xlsm 文件 + /// 菜单:Window → Activity Editor → 生成测试xlsm + /// + public static class TestFileGenerator + { + [MenuItem("Window/Activity Editor/生成测试xlsm")] + public static void Generate() + { + var outputPath = EditorUtility.SaveFilePanel("保存测试文件", "表", "test_patrol", "xlsx"); + if (string.IsNullOrEmpty(outputPath)) return; + + var workbook = new XSSFWorkbook(); + + WriteNodeTypeSheet(workbook); + WriteBehaviourTreeSheet(workbook); + + using (var file = new FileStream(outputPath, FileMode.Create, FileAccess.Write)) + { + workbook.Write(file); + } + + workbook.Close(); + + Debug.Log($"[TestGenerator] 测试文件已生成:{outputPath}"); + EditorUtility.DisplayDialog("完成", $"测试文件已生成:\n{outputPath}", "确定"); + } + + // ── 行为类型 Sheet ──────────────────────────────────────────── + // col0 = 代码名, col1 = (空), col2 = 显示名 + static void WriteNodeTypeSheet(XSSFWorkbook workbook) + { + var sheet = workbook.CreateSheet("行为类型"); + + // 表头 + var header = sheet.CreateRow(0); + header.CreateCell(0).SetCellValue("类型"); + header.CreateCell(1).SetCellValue("备注"); + header.CreateCell(2).SetCellValue("名字"); + + // 复合节点(代码名留空也可,这里填类名方便识别) + SetRow(sheet, 1, "Sequencer", "", "顺序"); + SetRow(sheet, 2, "Parallel", "", "平行"); + SetRow(sheet, 3, "Selector", "", "选择"); + + // 自定义动作节点 + SetRow(sheet, 4, "CheckNpcCamp", "", "检查阵营"); + SetRow(sheet, 5, "DoGetTime", "", "获取时间"); + SetRow(sheet, 6, "PatrolToPoint","", "巡逻到点"); + SetRow(sheet, 7, "Wait", "", "等待"); + } + + // ── 行为树 Sheet ────────────────────────────────────────────── + // col0 = AI树id(仅首行), col1 = 本地化, col2 = 说明 + // col3 = 深度0, col4 = 深度1, col5 = 深度2 + static void WriteBehaviourTreeSheet(XSSFWorkbook workbook) + { + var sheet = workbook.CreateSheet("行为树"); + + // 表头 + var header = sheet.CreateRow(0); + header.CreateCell(0).SetCellValue("AI树id"); + header.CreateCell(1).SetCellValue("本地化"); + header.CreateCell(2).SetCellValue("说明"); + header.CreateCell(3).SetCellValue("节点(深度0)"); + header.CreateCell(4).SetCellValue("节点(深度1)"); + header.CreateCell(5).SetCellValue("节点(深度2)"); + + // ── 树 1001:NPC巡逻 ────────────────────────────────────── + // 行1:树ID + 根节点(顺序) + var r1 = sheet.CreateRow(1); + r1.CreateCell(0).SetCellValue(1001); + r1.CreateCell(2).SetCellValue("NPC巡逻"); + r1.CreateCell(3).SetCellValue("顺序"); + + // 行2:检查阵营 #2001|1 + var r2 = sheet.CreateRow(2); + r2.CreateCell(4).SetCellValue("检查阵营 #2001|1"); + + // 行3:巡逻到点 A + var r3 = sheet.CreateRow(3); + r3.CreateCell(4).SetCellValue("巡逻到点 A"); + + // 行4:等待 3 + var r4 = sheet.CreateRow(4); + r4.CreateCell(4).SetCellValue("等待 3"); + + // 行5:巡逻到点 B + var r5 = sheet.CreateRow(5); + r5.CreateCell(4).SetCellValue("巡逻到点 B"); + + // ── 树 1002:并行攻击 ───────────────────────────────────── + // 行6:树ID + 根节点(平行) + var r6 = sheet.CreateRow(6); + r6.CreateCell(0).SetCellValue(1002); + r6.CreateCell(2).SetCellValue("并行攻击"); + r6.CreateCell(3).SetCellValue("平行"); + + // 行7:获取时间 #39990|9000 + var r7 = sheet.CreateRow(7); + r7.CreateCell(4).SetCellValue("获取时间 #39990|9000"); + + // 行8:检查阵营 #3001|0 + var r8 = sheet.CreateRow(8); + r8.CreateCell(4).SetCellValue("检查阵营 #3001|0"); + } + + static void SetRow(ISheet sheet, int rowIdx, string col0, string col1, string col2) + { + var row = sheet.CreateRow(rowIdx); + row.CreateCell(0).SetCellValue(col0); + row.CreateCell(1).SetCellValue(col1); + row.CreateCell(2).SetCellValue(col2); + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/TestFileGenerator.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/TestFileGenerator.cs.meta new file mode 100644 index 0000000..f277684 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/TestFileGenerator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 55f5cb455356af34ea0d75da6f126c20 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/TickModeConfigWindow.cs b/Assets/BP_Scripts/GameplayEditor/Editor/TickModeConfigWindow.cs new file mode 100644 index 0000000..fc5584f --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/TickModeConfigWindow.cs @@ -0,0 +1,233 @@ +using GameplayEditor.Config; +using GameplayEditor.Core; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// Tick模式配置窗口 + /// 用于配置行为树的Tick率模式(普通/无限制) + /// + public class TickModeConfigWindow : EditorWindow + { + private ActivityStageConfig _selectedConfig; + private BehaviourTreeController _runtimeController; + private Vector2 _scrollPos; + private bool _showHelp = true; + + [MenuItem("Window/Activity Editor/Tick Mode Config")] + public static void ShowWindow() + { + var window = GetWindow("Tick模式配置"); + window.minSize = new Vector2(350, 400); + window.Show(); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("行为树Tick模式配置", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + // 帮助信息 + _showHelp = EditorGUILayout.Foldout(_showHelp, "模式说明"); + if (_showHelp) + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + EditorGUILayout.LabelField("普通模式 (Normal)", EditorStyles.boldLabel); + EditorGUILayout.LabelField(" • 限制Tick率在20-120fps", EditorStyles.miniLabel); + EditorGUILayout.LabelField(" • 适用于大多数玩法", EditorStyles.miniLabel); + EditorGUILayout.LabelField(" • 可以节省性能", EditorStyles.miniLabel); + EditorGUILayout.Space(5); + EditorGUILayout.LabelField("无限制模式 (Unlimited)", EditorStyles.boldLabel); + EditorGUILayout.LabelField(" • 每帧都执行Tick", EditorStyles.miniLabel); + EditorGUILayout.LabelField(" • 适用于音游等精准玩法", EditorStyles.miniLabel); + EditorGUILayout.LabelField(" • 注意:可能消耗更多性能", EditorStyles.miniLabel); + EditorGUILayout.EndVertical(); + EditorGUILayout.Space(10); + } + + // 配置资产选择 + EditorGUILayout.LabelField("配置资产", EditorStyles.boldLabel); + _selectedConfig = EditorGUILayout.ObjectField("关卡配置", _selectedConfig, + typeof(ActivityStageConfig), false) as ActivityStageConfig; + + if (_selectedConfig != null) + { + DrawConfigEditor(); + } + + EditorGUILayout.Space(10); + + // 运行时控制器选择 + EditorGUILayout.LabelField("运行时控制(调试)", EditorStyles.boldLabel); + _runtimeController = EditorGUILayout.ObjectField("运行时控制器", _runtimeController, + typeof(BehaviourTreeController), true) as BehaviourTreeController; + + if (_runtimeController != null) + { + DrawRuntimeControl(); + } + } + + private void DrawConfigEditor() + { + EditorGUILayout.Space(5); + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + EditorGUILayout.LabelField($"当前配置: {_selectedConfig.ActivityStageID}", EditorStyles.boldLabel); + EditorGUILayout.LabelField($"场景: {_selectedConfig.SceneName}", EditorStyles.miniLabel); + + EditorGUILayout.Space(10); + + // Tick模式选择 + EditorGUI.BeginChangeCheck(); + var newMode = (TickRateMode)EditorGUILayout.EnumPopup("Tick模式", _selectedConfig.TickMode); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(_selectedConfig, "Change Tick Mode"); + _selectedConfig.TickMode = newMode; + EditorUtility.SetDirty(_selectedConfig); + } + + // 根据模式显示不同配置 + if (_selectedConfig.TickMode == TickRateMode.Normal) + { + EditorGUI.BeginChangeCheck(); + var newRate = EditorGUILayout.IntSlider("Tick率 (fps)", _selectedConfig.TickRate, 20, 120); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(_selectedConfig, "Change Tick Rate"); + _selectedConfig.TickRate = newRate; + EditorUtility.SetDirty(_selectedConfig); + } + + EditorGUILayout.HelpBox( + $"每帧间隔: {1000f / _selectedConfig.TickRate:F1}ms\n" + + $"每秒Tick次数: {_selectedConfig.TickRate}", + MessageType.Info); + } + else + { + EditorGUI.BeginChangeCheck(); + var useFixed = EditorGUILayout.Toggle("使用固定DeltaTime", _selectedConfig.UseFixedDeltaTimeInUnlimited); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(_selectedConfig, "Change Fixed DeltaTime Setting"); + _selectedConfig.UseFixedDeltaTimeInUnlimited = useFixed; + EditorUtility.SetDirty(_selectedConfig); + } + + if (_selectedConfig.UseFixedDeltaTimeInUnlimited) + { + EditorGUI.BeginChangeCheck(); + var newDelta = EditorGUILayout.FloatField("固定DeltaTime (秒)", _selectedConfig.UnlimitedDeltaTime); + newDelta = Mathf.Max(0.001f, newDelta); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObject(_selectedConfig, "Change DeltaTime"); + _selectedConfig.UnlimitedDeltaTime = newDelta; + EditorUtility.SetDirty(_selectedConfig); + } + + EditorGUILayout.HelpBox( + $"固定间隔: {_selectedConfig.UnlimitedDeltaTime * 1000:F1}ms\n" + + $"等效帧率: {1f / _selectedConfig.UnlimitedDeltaTime:F0}fps\n" + + $"适用场景: 音游、需要精准Tick的玩法", + MessageType.Info); + } + else + { + EditorGUILayout.HelpBox( + "使用Unity的Time.deltaTime\n" + + "适用场景: 需要与帧率同步的玩法", + MessageType.Info); + } + + EditorGUILayout.Space(5); + EditorGUILayout.HelpBox( + "⚠️ 警告:无限制模式会每帧都执行行为树,\n" + + "请确保行为树逻辑简单,避免性能问题。", + MessageType.Warning); + } + + EditorGUILayout.EndVertical(); + } + + private void DrawRuntimeControl() + { + EditorGUILayout.Space(5); + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + EditorGUILayout.LabelField("运行时状态", EditorStyles.boldLabel); + + // 显示当前模式 + EditorGUILayout.LabelField($"当前模式: {_runtimeController.TickMode}", EditorStyles.boldLabel); + + if (_runtimeController.IsUnlimitedMode) + { + EditorGUILayout.LabelField("状态: 无限制模式运行中", EditorStyles.miniLabel); + } + else + { + var currentRate = _runtimeController.GetCurrentTickRate(); + EditorGUILayout.LabelField($"Tick率: {currentRate}fps", EditorStyles.miniLabel); + } + + EditorGUILayout.Space(10); + + // 模式切换按钮 + EditorGUILayout.BeginHorizontal(); + + GUI.enabled = !_runtimeController.IsUnlimitedMode; + if (GUILayout.Button("启用无限制模式", GUILayout.Height(30))) + { + _runtimeController.EnableUnlimitedMode(); + Debug.Log("[TickModeConfig] 已启用无限制模式"); + } + GUI.enabled = true; + + GUI.enabled = _runtimeController.IsUnlimitedMode; + if (GUILayout.Button("恢复普通模式", GUILayout.Height(30))) + { + _runtimeController.DisableUnlimitedMode(); + Debug.Log("[TickModeConfig] 已恢复普通模式"); + } + GUI.enabled = true; + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(5); + + // 快速设置按钮 + EditorGUILayout.LabelField("快速设置", EditorStyles.miniBoldLabel); + EditorGUILayout.BeginHorizontal(); + + if (GUILayout.Button("音游模式")) + { + _runtimeController.SetTickMode(TickRateMode.Unlimited); + _runtimeController.UseFixedDeltaTimeInUnlimited = true; + _runtimeController.UnlimitedDeltaTime = 0.016f; // 60fps equivalent + Debug.Log("[TickModeConfig] 已设置为音游模式(无限制+16ms固定间隔)"); + } + + if (GUILayout.Button("性能优先")) + { + _runtimeController.SetTickMode(TickRateMode.Normal); + _runtimeController.TickRate = 30; + Debug.Log("[TickModeConfig] 已设置为性能优先模式(30fps)"); + } + + if (GUILayout.Button("平衡模式")) + { + _runtimeController.SetTickMode(TickRateMode.Normal); + _runtimeController.TickRate = 60; + Debug.Log("[TickModeConfig] 已设置为平衡模式(60fps)"); + } + + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.EndVertical(); + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/TickModeConfigWindow.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/TickModeConfigWindow.cs.meta new file mode 100644 index 0000000..b22cd15 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/TickModeConfigWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f6a6e18e7591ce141a7f74c2be18a5cb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/VerificationExcelExporter.cs b/Assets/BP_Scripts/GameplayEditor/Editor/VerificationExcelExporter.cs new file mode 100644 index 0000000..c843160 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/VerificationExcelExporter.cs @@ -0,0 +1,1183 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GameplayEditor.Config; +using GameplayEditor.Core; +using GameplayEditor.Nodes; +using GameplayEditor.Nodes.Actions; +using GameplayEditor.Runtime; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using NPOI.SS.UserModel; +using NPOI.XSSF.UserModel; +using ParadoxNotion.Design; +using UnityEditor; +using UnityEngine; + +namespace GameplayEditor.Editor +{ + /// + /// 验证配置综合导出器 + /// 将测试场景的所有配置导出到多个Excel文件 + /// 行为树使用列缩进格式,与g关卡C036.xlsm对齐 + /// + public static class VerificationExcelExporter + { + private const string EXPORT_DIR = "表/Verification/"; + private const string TEMPLATE_PATH = "Assets/BP_Scripts/GameplayEditor/Templates/StageTemplate.xlsm"; + + /// + /// 导出当前验证场景的所有配置到多个文件 + /// + public static List ExportAll() + { + string baseDir = Path.Combine(Application.dataPath, "../", EXPORT_DIR); + baseDir = Path.GetFullPath(baseDir); + Directory.CreateDirectory(baseDir); + + var exportedFiles = new List(); + + // 1. 关卡+行为树 主文件 + exportedFiles.Add(ExportStageWorkbook(baseDir)); + + // 2-7. 各独立配置文件 + exportedFiles.Add(ExportSingleSheetWorkbook(baseDir, "Verification_Dialog.xlsx", ExportDialogSheet)); + exportedFiles.Add(ExportSingleSheetWorkbook(baseDir, "Verification_Event.xlsx", ExportEventSheet)); + exportedFiles.Add(ExportSingleSheetWorkbook(baseDir, "Verification_Map.xlsx", ExportMapPointSheet)); + exportedFiles.Add(ExportSingleSheetWorkbook(baseDir, "Verification_Camera.xlsx", ExportCameraSheet)); + exportedFiles.Add(ExportSingleSheetWorkbook(baseDir, "Verification_Interaction.xlsx", ExportInteractionSheet)); + exportedFiles.Add(ExportSingleSheetWorkbook(baseDir, "Verification_Result.xlsx", ExportVerificationResultsSheet)); + + Debug.Log($"[VerificationExcelExporter] 导出完成,共 {exportedFiles.Count} 个文件: {baseDir}"); + return exportedFiles; + } + + /// + /// 导出关卡+行为树主文件 + /// 如果存在xlsm模板(含VBA宏),则使用模板输出xlsm;否则输出xlsx + /// + static string ExportStageWorkbook(string baseDir) + { + // 检查是否有xlsm模板 + string templateFullPath = Path.Combine(Application.dataPath, "../", TEMPLATE_PATH); + templateFullPath = Path.GetFullPath(templateFullPath); + bool useTemplate = File.Exists(templateFullPath); + + string ext = useTemplate ? ".xlsm" : ".xlsx"; + string filePath = Path.Combine(baseDir, "Verification_Stage" + ext); + + XSSFWorkbook workbook; + if (useTemplate) + { + // 打开模板(保留VBA宏),清空Sheet行数据但不删除Sheet(保留VBA绑定) + using (var templateStream = new FileStream(templateFullPath, FileMode.Open, FileAccess.Read)) + { + workbook = new XSSFWorkbook(templateStream); + } + Debug.Log($"[VerificationExcelExporter] 使用xlsm模板: {templateFullPath}"); + } + else + { + workbook = new XSSFWorkbook(); + Debug.Log("[VerificationExcelExporter] 未找到xlsm模板,输出xlsx(无宏)。运行 表/CreateTemplate.ps1 创建模板。"); + } + + ExportToolSettingsSheet(workbook); + ExportStageConfigSheet(workbook); + ExportBehaviourTreeSheetIndented(workbook); + ExportBehaviorTypeSheet(workbook); + ExportCompositeListSheet(workbook); + ExportBehaviorNodeSheet(workbook); + ExportBuildHeaderSheet(workbook); + + WriteWorkbook(workbook, filePath); + return filePath; + } + + /// + /// 导出单Sheet工作簿 + /// + static string ExportSingleSheetWorkbook(string baseDir, string fileName, Action sheetExporter) + { + string filePath = Path.Combine(baseDir, fileName); + var workbook = new XSSFWorkbook(); + + sheetExporter(workbook); + + WriteWorkbook(workbook, filePath); + return filePath; + } + + /// + /// 写入workbook到文件 + /// + static void WriteWorkbook(XSSFWorkbook workbook, string filePath) + { + for (int i = 0; i < workbook.NumberOfSheets; i++) + AutoSizeColumns(workbook.GetSheetAt(i), 12); + + using (var file = new FileStream(filePath, FileMode.Create, FileAccess.Write)) + { + workbook.Write(file); + } + workbook.Close(); + } + + #region 工具表设置 + + /// + /// 导出工具表设置Sheet(控制宏行为,对标正式xlsm) + /// + static void ExportToolSettingsSheet(XSSFWorkbook workbook) + { + var sheet = GetOrCreateSheet(workbook, "工具表设置"); + ClearSheetRows(sheet); + + var header = sheet.CreateRow(0); + header.CreateCell(0).SetCellValue("设置"); + header.CreateCell(1).SetCellValue("说明"); + header.CreateCell(2).SetCellValue("参数1"); + + var row1 = sheet.CreateRow(1); + row1.CreateCell(0).SetCellValue("行为节点显示注释"); + row1.CreateCell(1).SetCellValue("0:无操作\n1:编辑相关单元格后显示注释\n2:编辑相关单元格后删除注释"); + row1.CreateCell(2).SetCellValue(0); + + var row2 = sheet.CreateRow(2); + row2.CreateCell(0).SetCellValue("行为表格顶部显示说明"); + row2.CreateCell(1).SetCellValue("0:无操作\n1:编辑相关单元格后显示说明"); + row2.CreateCell(2).SetCellValue(1); + } + + #endregion + + #region 关卡配置 + + static void ExportStageConfigSheet(XSSFWorkbook workbook) + { + var sheet = GetOrCreateSheet(workbook, "关卡配置"); + ClearSheetRows(sheet); + + var header = sheet.CreateRow(0); + header.CreateCell(0).SetCellValue("配置项"); + header.CreateCell(1).SetCellValue("值"); + header.CreateCell(2).SetCellValue("说明"); + + var stage = AssetDatabase.LoadAssetAtPath("Assets/Verification/Configs/StageConfig_1001.asset"); + int rowIdx = 1; + + void AddRow(string key, string value, string desc) + { + var row = sheet.CreateRow(rowIdx++); + row.CreateCell(0).SetCellValue(key); + row.CreateCell(1).SetCellValue(value ?? ""); + row.CreateCell(2).SetCellValue(desc); + } + + if (stage != null) + { + AddRow("ActivityStageID", stage.ActivityStageID.ToString(), "关卡ID"); + AddRow("Doc", stage.Doc, "关卡说明"); + AddRow("SceneName", stage.SceneName, "场景名称"); + AddRow("MapInfo", stage.MapInfo.ToString(), "地图点位LUT ID"); + AddRow("CameraID", stage.CameraID.ToString(), "相机LUT ID"); + AddRow("TickMode", stage.TickMode.ToString(), "Tick模式"); + AddRow("TickRate", stage.TickRate.ToString(), "Tick频率"); + AddRow("HeaderTree", stage.HeaderTree ? stage.HeaderTree.name : "", "头文件行为树"); + AddRow("BodyTree", stage.BodyTree ? stage.BodyTree.name : "", "正文行为树"); + } + else + { + AddRow("状态", "未找到StageConfig_1001", "请先运行Verification Setup生成配置"); + } + } + + #endregion + + #region 行为树 - 列缩进格式 + + /// + /// 导出行为树Sheet(列缩进格式,对标g关卡C036.xlsm) + /// col0: AI树id(仅首行) + /// col1: 本地化 + /// col2: 说明 + /// col3+: 节点内容,depth=0在col3, depth=1在col4, ... + /// + static void ExportBehaviourTreeSheetIndented(XSSFWorkbook workbook) + { + var sheet = GetOrCreateSheet(workbook, "行为树"); + ClearSheetRows(sheet); + + // ── 行1-6: 元数据行(对标正式xlsm格式)── + // 行1: 特殊标记 + var row1 = sheet.CreateRow(0); + for (int i = 3; i < 20; i++) + row1.CreateCell(i).SetCellValue("特殊"); + + // 行2: 列偏移值 + var row2 = sheet.CreateRow(1); + for (int i = 3; i < 20; i++) + row2.CreateCell(i).SetCellValue(3 + (i - 3) * 4); + + // 行3: bool寄存器说明 + var row3 = sheet.CreateRow(2); + row3.CreateCell(0).SetCellValue("bool"); + row3.CreateCell(1).SetCellValue("【1000+】 1是否操作摇杆 2上一次是否三消 3是否在大招中..."); + + // 行4: npc寄存器说明 + var row4 = sheet.CreateRow(3); + row4.CreateCell(0).SetCellValue("npc"); + row4.CreateCell(1).SetCellValue("【2000+】 1自身(#2001) 2仇恨目标(#2002) 3召唤者..."); + + // 行5: int寄存器说明 + var row5 = sheet.CreateRow(4); + row5.CreateCell(0).SetCellValue("int"); + row5.CreateCell(1).SetCellValue("通用次数 / 目标坐标 / 伤害magic / 状态标记..."); + + // 行6: 额外参数说明 + var row6 = sheet.CreateRow(5); + for (int i = 16; i < 20; i++) + row6.CreateCell(i).SetCellValue($"参数{i - 1}"); + + // ── 行7: 真正的表头 ── + var headerRow = sheet.CreateRow(6); + headerRow.CreateCell(0).SetCellValue("AI树id"); + headerRow.CreateCell(1).SetCellValue("本地化"); + headerRow.CreateCell(2).SetCellValue("说明"); + for (int i = 3; i < 20; i++) + headerRow.CreateCell(i).SetCellValue("节点"); + + // ── 行8+: 行为树数据 ── + var bt10010 = AssetDatabase.LoadAssetAtPath("Assets/Verification/BT/BT_10010.asset"); + var bt1001 = AssetDatabase.LoadAssetAtPath("Assets/Verification/BT/BT_1001.asset"); + + int rowIdx = 7; + + if (bt10010 != null && bt10010.primeNode != null) + rowIdx = WriteBtNodeIndented(sheet, (BTNode)bt10010.primeNode, rowIdx, 10010, 0, true); + + if (bt1001 != null && bt1001.primeNode != null) + rowIdx = WriteBtNodeIndented(sheet, (BTNode)bt1001.primeNode, rowIdx, 1001, 0, true); + } + + /// + /// 递归写入节点(列缩进格式) + /// + static int WriteBtNodeIndented(ISheet sheet, BTNode node, int rowIdx, int treeId, int depth, bool isFirst) + { + var row = sheet.CreateRow(rowIdx); + + // col0: 树ID(仅首行写) + if (isFirst) + row.CreateCell(0).SetCellValue(treeId); + + // col2: 说明(使用节点name作为说明,如果和格式化内容不同) + var content = FormatBtNodeContent(node); + var nodeName = node.name ?? ""; + if (!string.IsNullOrEmpty(nodeName) && nodeName != content && !content.StartsWith(nodeName)) + row.CreateCell(2).SetCellValue(nodeName); + + // col(3+depth): 节点内容 + row.CreateCell(3 + depth).SetCellValue(content); + + rowIdx++; + + // 递归写子节点 + if (node.outConnections != null) + { + foreach (var conn in node.outConnections) + { + var child = (BTNode)conn.targetNode; + rowIdx = WriteBtNodeIndented(sheet, child, rowIdx, treeId, depth + 1, false); + } + } + + return rowIdx; + } + + /// + /// 格式化节点内容字符串(对标g关卡C036.xlsm格式) + /// 复合节点: "平行"/"顺序"/"选择"/"乱选" + /// 动作节点: "显示名 参数1|参数2|..." + /// 组合方法: "组合 方法:XXX" + /// + static string FormatBtNodeContent(BTNode node) + { + // 组合方法节点 + if (node is CompositeMethodNode compositeMethod) + return compositeMethod.DisplayName ?? $"组合 方法:{compositeMethod.MethodName}"; + + // 加权乱选节点(必须在BTComposite之前检查) + if (node is WeightedProbabilitySelector weightedNode) + { + var weights = weightedNode.GetWeightsConfig(); + return string.IsNullOrEmpty(weights) ? "乱选" : $"乱选 {weights}"; + } + + // 复合节点 + if (node is BTComposite composite) + { + var typeName = composite.GetType().Name; + return typeName switch + { + "Parallel" => "平行", + "Sequencer" => "顺序", + "Selector" => "选择", + "ProbabilitySelector" => "乱选", + "Iterate" => "循环", + _ => typeName + }; + } + + // 动作节点 + if (node is ActionNode actionNode && actionNode.action != null) + { + // DynamicBtActionTask + if (actionNode.action is DynamicBtActionTask dynTask) + { + var displayName = dynTask.NodeTypeName ?? node.name ?? "未知"; + if (dynTask.RawParams != null && dynTask.RawParams.Count > 0) + return $"{displayName} {string.Join("|", dynTask.RawParams)}"; + return displayName; + } + + // 其他注册Task — 通过反射提取参数 + var taskType = actionNode.action.GetType(); + var taskName = taskType.Name.Replace("Task", ""); + var registeredName = NodeTypeRegistry.GetDisplayNameByCodeName(taskName); + var paramStr = ExtractTaskParams(actionNode.action); + if (!string.IsNullOrEmpty(paramStr)) + return $"{registeredName} {paramStr}"; + return registeredName ?? node.name ?? taskName; + } + + return node.name ?? "未知节点"; + } + + /// + /// 通过反射提取Task的参数值(复用BtSheetWriter的逻辑) + /// + static string ExtractTaskParams(ActionTask task) + { + var paramList = new List(); + var fields = task.GetType().GetFields( + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public); + + foreach (var field in fields) + { + // 跳过Unity/NodeCanvas内部字段 + if (field.Name.StartsWith("_") || field.Name == "name" || field.Name == "hideFlags") + continue; + if (field.FieldType == typeof(string) && field.Name.ToLower().Contains("tooltip")) + continue; + + var value = field.GetValue(task); + if (value != null && !IsDefaultValue(value)) + paramList.Add(value.ToString()); + } + + return string.Join("|", paramList); + } + + static bool IsDefaultValue(object value) + { + if (value == null) return true; + if (value is string str) return string.IsNullOrEmpty(str); + if (value is int i) return i == 0; + if (value is float f) return f == 0f; + if (value is bool b) return b == false; + return false; + } + + #endregion + + #region 行为树节点 - BehaviorNode服务器格式 + + /// + /// 导出BehaviorNode Sheet(服务器格式) + /// 对标g关卡C036.xlsm建表表头中的BehaviorNode定义: + /// Id | Type | Negative | Name | Param | Children[1-20] + /// + static void ExportBehaviorNodeSheet(XSSFWorkbook workbook) + { + var sheet = GetOrCreateSheet(workbook, "行为树节点"); + ClearSheetRows(sheet); + + // 表头 + var headerRow = sheet.CreateRow(0); + headerRow.CreateCell(0).SetCellValue("Id"); + headerRow.CreateCell(1).SetCellValue("Type"); + headerRow.CreateCell(2).SetCellValue("Negative"); + headerRow.CreateCell(3).SetCellValue("Name"); + headerRow.CreateCell(4).SetCellValue("Param"); + for (int i = 1; i <= 20; i++) + headerRow.CreateCell(4 + i).SetCellValue($"Children[{i}]"); + + var bt10010 = AssetDatabase.LoadAssetAtPath("Assets/Verification/BT/BT_10010.asset"); + var bt1001 = AssetDatabase.LoadAssetAtPath("Assets/Verification/BT/BT_1001.asset"); + + // 先收集所有节点及其ID + var nodeIdMap = new Dictionary(); + int nextId = 1; + + void AssignIds(BehaviourTree tree) + { + if (tree == null || tree.primeNode == null) return; + AssignIdsRecursive(tree.primeNode, nodeIdMap, ref nextId); + } + + AssignIds(bt10010); + AssignIds(bt1001); + + // 写入节点行 + int rowIdx = 1; + + void WriteNodes(BehaviourTree tree) + { + if (tree == null || tree.primeNode == null) return; + rowIdx = WriteBehaviorNodeRows(sheet, (BTNode)tree.primeNode, rowIdx, nodeIdMap); + } + + WriteNodes(bt10010); + WriteNodes(bt1001); + } + + static void AssignIdsRecursive(Node node, Dictionary map, ref int nextId) + { + map[node] = nextId++; + if (node.outConnections != null) + { + foreach (var conn in node.outConnections) + AssignIdsRecursive(conn.targetNode, map, ref nextId); + } + } + + static int WriteBehaviorNodeRows(ISheet sheet, BTNode node, int rowIdx, Dictionary nodeIdMap) + { + var row = sheet.CreateRow(rowIdx++); + + int nodeId = nodeIdMap.TryGetValue(node, out var id) ? id : 0; + row.CreateCell(0).SetCellValue(nodeId); + + // Type + string nodeType = GetBehaviorNodeType(node); + row.CreateCell(1).SetCellValue(nodeType); + + // Negative (默认0) + row.CreateCell(2).SetCellValue(0); + + // Name + row.CreateCell(3).SetCellValue(node.name ?? ""); + + // Param + string param = GetBehaviorNodeParam(node); + row.CreateCell(4).SetCellValue(param); + + // Children + if (node.outConnections != null) + { + for (int i = 0; i < Math.Min(node.outConnections.Count, 20); i++) + { + var childId = nodeIdMap.TryGetValue(node.outConnections[i].targetNode, out var cid) ? cid : 0; + row.CreateCell(5 + i).SetCellValue(childId); + } + } + + // 递归子节点 + if (node.outConnections != null) + { + foreach (var conn in node.outConnections) + rowIdx = WriteBehaviorNodeRows(sheet, (BTNode)conn.targetNode, rowIdx, nodeIdMap); + } + + return rowIdx; + } + + static string GetBehaviorNodeType(BTNode node) + { + if (node is CompositeMethodNode) return "CompositeMethod"; + if (node is WeightedProbabilitySelector) return "WeightedSelector"; + + if (node is BTComposite composite) + { + return composite.GetType().Name switch + { + "Parallel" => "Parallel", + "Sequencer" => "Sequence", + "Selector" => "Selector", + "ProbabilitySelector" => "ProbabilitySelector", + "Iterate" => "Iterator", + _ => composite.GetType().Name + }; + } + + if (node is ActionNode actionNode && actionNode.action != null) + { + if (actionNode.action is DynamicBtActionTask dynTask) + return dynTask.NodeTypeName ?? "Action"; + return actionNode.action.GetType().Name; + } + + return node.GetType().Name; + } + + static string GetBehaviorNodeParam(BTNode node) + { + if (node is ActionNode actionNode && actionNode.action != null) + { + if (actionNode.action is DynamicBtActionTask dynTask) + { + if (dynTask.RawParams != null && dynTask.RawParams.Count > 0) + return string.Join("|", dynTask.RawParams); + } + else + { + // 注册Task — 通过反射提取参数 + var paramStr = ExtractTaskParams(actionNode.action); + if (!string.IsNullOrEmpty(paramStr)) + return paramStr; + } + } + + if (node is WeightedProbabilitySelector weighted) + { + var w = weighted.GetWeightsConfig(); + if (!string.IsNullOrEmpty(w)) return w; + } + + return ""; + } + + #endregion + + #region 行为类型Sheet + + /// + /// 导出行为类型Sheet(对标正式xlsm的行为类型Sheet) + /// 格式: 类型(代码名) | 描述 | 名字(显示名) | 参数1 | 参数2 | ... + /// + static void ExportBehaviorTypeSheet(XSSFWorkbook workbook) + { + var sheet = GetOrCreateSheet(workbook, "行为类型"); + ClearSheetRows(sheet); + + // 表头 + var header = sheet.CreateRow(0); + header.CreateCell(0).SetCellValue("类型"); + header.CreateCell(1).SetCellValue("描述"); + header.CreateCell(2).SetCellValue("名字(策划改)"); + for (int i = 1; i <= 22; i++) + header.CreateCell(2 + i).SetCellValue($"参数{i}"); + + int rowIdx = 1; + + // 先写复合节点 + foreach (var compositeName in NodeTypeRegistry.GetAllCompositeNames()) + { + var row = sheet.CreateRow(rowIdx++); + row.CreateCell(1).SetCellValue(compositeName); + row.CreateCell(2).SetCellValue(compositeName); + if (compositeName == "乱选") + row.CreateCell(3).SetCellValue("乱序执行子节点,直到某个节点返回true,停止执行并返回true"); + else if (compositeName == "循环") + row.CreateCell(3).SetCellValue("顺序执行子节点,并循环,直到某个节点返回false,跳出循环"); + } + + // 写所有注册的Action节点 + foreach (var codeName in NodeTypeRegistry.GetAllCodeNames()) + { + var row = sheet.CreateRow(rowIdx++); + row.CreateCell(0).SetCellValue(codeName); + + // 获取Task类型及其描述 + var taskType = NodeTypeRegistry.GetTaskType(codeName); + var descAttr = taskType.GetCustomAttributes(typeof(DescriptionAttribute), false); + if (descAttr.Length > 0) + row.CreateCell(1).SetCellValue(((DescriptionAttribute)descAttr[0]).description); + + // 显示名 + var displayName = NodeTypeRegistry.GetDisplayNameByCodeName(codeName); + row.CreateCell(2).SetCellValue(displayName); + + // 参数说明 — 从Task的字段中提取 + var fields = taskType.GetFields( + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public); + + int paramCol = 3; + foreach (var field in fields) + { + if (field.Name.StartsWith("_") || field.Name == "name" || field.Name == "hideFlags") + continue; + if (field.DeclaringType == typeof(ActionTask) || field.DeclaringType == typeof(NodeCanvas.Framework.Task)) + continue; + + var tooltip = field.GetCustomAttributes(typeof(UnityEngine.TooltipAttribute), false); + string desc = tooltip.Length > 0 + ? ((UnityEngine.TooltipAttribute)tooltip[0]).tooltip + : field.Name; + + row.CreateCell(paramCol++).SetCellValue($"{field.FieldType.Name}\n{desc}"); + if (paramCol > 24) break; + } + } + } + + #endregion + + #region 组合列表Sheet + + /// + /// 导出组合列表Sheet(对标正式xlsm的组合列表Sheet) + /// 格式: 来源 | 方法ID | 方法名称 + /// + static void ExportCompositeListSheet(XSSFWorkbook workbook) + { + var sheet = GetOrCreateSheet(workbook, "组合列表"); + ClearSheetRows(sheet); + + int rowIdx = 0; + + // 加载所有组合方法 + CompositeMethodRegistry.LoadAllMethods(); + + var methodsField = typeof(CompositeMethodRegistry).GetField("_methods", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + if (methodsField != null) + { + var methods = methodsField.GetValue(null) as System.Collections.IDictionary; + if (methods != null) + { + foreach (System.Collections.DictionaryEntry entry in methods) + { + var method = entry.Value as CompositeMethod; + if (method == null) continue; + + var row = sheet.CreateRow(rowIdx++); + row.CreateCell(1).SetCellValue(method.MethodID); + row.CreateCell(2).SetCellValue( + !string.IsNullOrEmpty(method.DisplayName) ? method.DisplayName : $"方法:{method.MethodName}"); + if (!string.IsNullOrEmpty(method.Doc)) + row.CreateCell(3).SetCellValue(method.Doc); + } + } + } + + if (rowIdx == 0) + { + // 没有组合方法数据时写说明 + var row = sheet.CreateRow(0); + row.CreateCell(0).SetCellValue("(未加载组合方法,请先在编辑器中加载)"); + } + } + + #endregion + + #region 建表表头Sheet + + /// + /// 导出建表表头Sheet(定义服务器导出的表结构) + /// 对标正式xlsm的建表表头 + /// + static void ExportBuildHeaderSheet(XSSFWorkbook workbook) + { + var sheet = GetOrCreateSheet(workbook, "建表表头"); + ClearSheetRows(sheet); + + int rowIdx = 0; + + // BehaviorNode 行为树节点表 + var bnTitle = sheet.CreateRow(rowIdx++); + bnTitle.CreateCell(0).SetCellValue("BehaviorNode"); + bnTitle.CreateCell(2).SetCellValue("行为树节点"); + + var bnHeader = sheet.CreateRow(rowIdx++); + string[] bnCols = { "Id", "Type", "Negative", "Name", "Param" }; + for (int i = 0; i < bnCols.Length; i++) + bnHeader.CreateCell(i).SetCellValue(bnCols[i]); + for (int i = 1; i <= 20; i++) + bnHeader.CreateCell(4 + i).SetCellValue($"Children[{i}]"); + + rowIdx++; // 空行 + + // BeHavior 行为树表 + var btTitle = sheet.CreateRow(rowIdx++); + btTitle.CreateCell(0).SetCellValue("BeHavior"); + btTitle.CreateCell(2).SetCellValue("行为树"); + + var btHeader = sheet.CreateRow(rowIdx++); + string[] btCols = { "Id", "Type", "Priority", "PrefixRoot", "Root", "PreLoadRoot", "RemoveRoot" }; + for (int i = 0; i < btCols.Length; i++) + btHeader.CreateCell(i).SetCellValue(btCols[i]); + + rowIdx++; // 空行 + + // Map 关卡表 + var mapTitle = sheet.CreateRow(rowIdx++); + mapTitle.CreateCell(0).SetCellValue("Map"); + mapTitle.CreateCell(2).SetCellValue("关卡"); + + var mapHeader = sheet.CreateRow(rowIdx++); + string[] mapCols = { "Id", "Behavior", "Name", "Environment", "TerrainRoot", + "CloseLoadingDelay", "IsAsyncLoadNpc", "SwitchNpcCd", "SceneCamera", "CameraId" }; + for (int i = 0; i < mapCols.Length; i++) + mapHeader.CreateCell(i).SetCellValue(mapCols[i]); + } + + #endregion + + #region 对话配置 + + static void ExportDialogSheet(XSSFWorkbook workbook) + { + var sheet = GetOrCreateSheet(workbook, "对话配置"); + ClearSheetRows(sheet); + + var header = sheet.CreateRow(0); + string[] cols = { "ID", "角色名", "文本", "英文", "日文", "法文", "时长", "蒙层", "点击键", "富文本" }; + for (int i = 0; i < cols.Length; i++) + header.CreateCell(i).SetCellValue(cols[i]); + + var dialog = AssetDatabase.LoadAssetAtPath("Assets/Verification/Configs/DialogInfo_Level1001.asset"); + if (dialog == null) return; + + int rowIdx = 1; + foreach (var d in dialog.Dialogs) + { + var row = sheet.CreateRow(rowIdx++); + row.CreateCell(0).SetCellValue(d.ID); + row.CreateCell(1).SetCellValue(d.CharacterName); + row.CreateCell(2).SetCellValue(d.Text); + row.CreateCell(3).SetCellValue(d.Text_EN ?? ""); + row.CreateCell(4).SetCellValue(d.Text_JP ?? ""); + row.CreateCell(5).SetCellValue(d.Text_FR ?? ""); + row.CreateCell(6).SetCellValue(d.Duration); + row.CreateCell(7).SetCellValue(d.IsMask ? "是" : "否"); + row.CreateCell(8).SetCellValue(d.ClickKey ?? ""); + row.CreateCell(9).SetCellValue(d.EnableRichText ? "是" : "否"); + } + } + + #endregion + + #region 事件配置 + + static void ExportEventSheet(XSSFWorkbook workbook) + { + var sheet = GetOrCreateSheet(workbook, "事件配置"); + ClearSheetRows(sheet); + + var header1 = sheet.CreateRow(0); + header1.CreateCell(0).SetCellValue("== Roguelike节点配置 =="); + + var header2 = sheet.CreateRow(1); + string[] nodeCols = { "行号", "关卡ID", "节点ID", "层数", "事件类型组", "权重", "映射ID", "可见", "状态" }; + for (int i = 0; i < nodeCols.Length; i++) + header2.CreateCell(i).SetCellValue(nodeCols[i]); + + var builder = AssetDatabase.LoadAssetAtPath("Assets/Verification/Configs/EventBuilder_1001.asset"); + int rowIdx = 2; + + if (builder != null) + { + foreach (var node in builder.NodeConfigs) + { + var row = sheet.CreateRow(rowIdx++); + row.CreateCell(0).SetCellValue(node.RowIndex); + row.CreateCell(1).SetCellValue(node.LevelID); + row.CreateCell(2).SetCellValue(node.NodeID); + row.CreateCell(3).SetCellValue(node.NodeLayer); + row.CreateCell(4).SetCellValue(string.Join(",", node.EventTypeGroup)); + row.CreateCell(5).SetCellValue(string.Join("|", node.Priority)); + row.CreateCell(6).SetCellValue(node.EventMappingID); + row.CreateCell(7).SetCellValue(node.InteractVisible ? "是" : "否"); + row.CreateCell(8).SetCellValue(node.NodeState ? "开启" : "关闭"); + } + + rowIdx++; + + var actionHeader = sheet.CreateRow(rowIdx++); + actionHeader.CreateCell(0).SetCellValue("== 事件Action配置 =="); + + var actionHeader2 = sheet.CreateRow(rowIdx++); + string[] actionCols = { "事件ID", "类型", "池", "消耗" }; + for (int i = 0; i < actionCols.Length; i++) + actionHeader2.CreateCell(i).SetCellValue(actionCols[i]); + + foreach (var action in builder.ActionConfigs) + { + var row = sheet.CreateRow(rowIdx++); + row.CreateCell(0).SetCellValue(action.EventID); + row.CreateCell(1).SetCellValue(action.Type.ToString()); + row.CreateCell(2).SetCellValue(action.Pool.ToString()); + row.CreateCell(3).SetCellValue(action.Removed ? "是" : "否"); + } + } + } + + #endregion + + #region 地图点位 + + static void ExportMapPointSheet(XSSFWorkbook workbook) + { + var sheet = GetOrCreateSheet(workbook, "地图点位"); + ClearSheetRows(sheet); + + var header = sheet.CreateRow(0); + string[] cols = { "点位ID", "X", "Y", "Z", "说明" }; + for (int i = 0; i < cols.Length; i++) + header.CreateCell(i).SetCellValue(cols[i]); + + var mapInfo = AssetDatabase.LoadAssetAtPath("Assets/Verification/Luts/MapInfo_10001.asset"); + + if (mapInfo == null || mapInfo.Points == null || mapInfo.Points.Count == 0) + { + var runner = GameObject.FindObjectOfType(); + if (runner != null && runner.TestMapInfoLut != null) + { + mapInfo = runner.TestMapInfoLut; + Debug.Log("[ExcelExport] 从Runner获取MapInfoLut数据"); + } + } + + if (mapInfo == null || mapInfo.Points == null || mapInfo.Points.Count == 0) + { + var emptyRow = sheet.CreateRow(1); + emptyRow.CreateCell(0).SetCellValue("(无数据)"); + emptyRow.CreateCell(1).SetCellValue("请先生成验证场景"); + Debug.LogWarning("[ExcelExport] MapInfo数据为空,请确保已生成验证场景"); + return; + } + + int rowIdx = 1; + foreach (var pt in mapInfo.Points) + { + var row = sheet.CreateRow(rowIdx++); + row.CreateCell(0).SetCellValue(pt.PointID); + row.CreateCell(1).SetCellValue(pt.Position.x); + row.CreateCell(2).SetCellValue(pt.Position.y); + row.CreateCell(3).SetCellValue(pt.Position.z); + + string desc = pt.PointID switch + { + "PlayerSpawn" => "玩家出生点", + "EnemySpawn_1" => "敌人出生点1", + "EnemySpawn_2" => "敌人出生点2", + "ShopPosition" => "商店位置", + "RestPosition" => "休息区位置", + _ => "" + }; + row.CreateCell(4).SetCellValue(desc); + } + } + + #endregion + + #region 相机配置 + + static void ExportCameraSheet(XSSFWorkbook workbook) + { + var sheet = GetOrCreateSheet(workbook, "相机配置"); + ClearSheetRows(sheet); + + var header = sheet.CreateRow(0); + string[] cols = { "MappingID", "FOV", "优先级", "跟随偏移", "注视偏移", "混合时间" }; + for (int i = 0; i < cols.Length; i++) + header.CreateCell(i).SetCellValue(cols[i]); + + var cameraLut = AssetDatabase.LoadAssetAtPath("Assets/Verification/Luts/CameraLut_101.asset"); + + if (cameraLut == null || cameraLut.CameraMappings == null || cameraLut.CameraMappings.Count == 0) + { + var runner = GameObject.FindObjectOfType(); + if (runner != null && runner.TestCameraLut != null) + { + cameraLut = runner.TestCameraLut; + Debug.Log("[ExcelExport] 从Runner获取CameraLut数据"); + } + } + + if (cameraLut == null || cameraLut.CameraMappings == null || cameraLut.CameraMappings.Count == 0) + { + var emptyRow = sheet.CreateRow(1); + emptyRow.CreateCell(0).SetCellValue("(无数据)"); + emptyRow.CreateCell(1).SetCellValue("请先生成验证场景"); + Debug.LogWarning("[ExcelExport] CameraLut数据为空,请确保已生成验证场景"); + return; + } + + int rowIdx = 1; + foreach (var mapping in cameraLut.CameraMappings) + { + var row = sheet.CreateRow(rowIdx++); + row.CreateCell(0).SetCellValue(mapping.MappingID); + row.CreateCell(1).SetCellValue(mapping.FOV); + row.CreateCell(2).SetCellValue(mapping.Priority); + row.CreateCell(3).SetCellValue(mapping.FollowOffset.ToString()); + row.CreateCell(4).SetCellValue(mapping.LookAtOffset.ToString()); + row.CreateCell(5).SetCellValue(mapping.BlendTime); + } + } + + #endregion + + #region 玩家交互配置 + + static void ExportInteractionSheet(XSSFWorkbook workbook) + { + var sheet = GetOrCreateSheet(workbook, "玩家交互配置"); + ClearSheetRows(sheet); + + var header1 = sheet.CreateRow(0); + header1.CreateCell(0).SetCellValue("== 玩家控制配置 =="); + + var header2 = sheet.CreateRow(1); + header2.CreateCell(0).SetCellValue("配置项"); + header2.CreateCell(1).SetCellValue("值"); + + int rowIdx = 2; + void AddRow(string k, string v) + { + var row = sheet.CreateRow(rowIdx++); + row.CreateCell(0).SetCellValue(k); + row.CreateCell(1).SetCellValue(v); + } + + AddRow("移动键", "WASD"); + AddRow("交互键", "F"); + AddRow("对话键", "Space"); + AddRow("相机切换键", "C"); + AddRow("控制器脚本", "SimplePlayerController"); + AddRow("触发器脚本", "InteractionZone"); + AddRow("FlowCanvas桥接", "FlowTriggerBridge"); + + var player = GameObject.Find("Player_Verify"); + if (player != null) + { + var pc = player.GetComponent(); + if (pc != null) + { + AddRow("移动速度", pc.MoveSpeed.ToString()); + AddRow("交互半径", pc.InteractRadius.ToString()); + } + } + + rowIdx++; + + var zoneHeader = sheet.CreateRow(rowIdx++); + zoneHeader.CreateCell(0).SetCellValue("== 交互区域配置 =="); + + var zoneHeader2 = sheet.CreateRow(rowIdx++); + string[] zoneCols = { "区域ID", "名称", "半径", "触发类型", "对话ID", "相机ID", "说明" }; + for (int i = 0; i < zoneCols.Length; i++) + zoneHeader2.CreateCell(i).SetCellValue(zoneCols[i]); + + var zones = UnityEngine.Object.FindObjectsOfType(); + foreach (var zone in zones) + { + var row = sheet.CreateRow(rowIdx++); + row.CreateCell(0).SetCellValue(zone.ZoneID); + row.CreateCell(1).SetCellValue(zone.ZoneName); + row.CreateCell(2).SetCellValue(zone.Radius); + row.CreateCell(3).SetCellValue(zone.TriggerType.ToString()); + row.CreateCell(4).SetCellValue(zone.ShowDialogOnEnter ? zone.DialogID.ToString() : "-"); + row.CreateCell(5).SetCellValue(zone.ChangeCameraOnEnter ? zone.CameraID.ToString() : "-"); + + string desc = zone.ZoneID switch + { + "Zone_Shop" => "进入商店区域触发对话+相机切换", + "Zone_Rest" => "进入休息区触发对话+相机切换", + "Zone_NPC" => "NPC附近按键触发对话", + _ => "" + }; + row.CreateCell(6).SetCellValue(desc); + } + + if (zones.Length == 0) + { + var row = sheet.CreateRow(rowIdx++); + row.CreateCell(0).SetCellValue("(场景中未找到InteractionZone,请先生成场景)"); + } + } + + #endregion + + #region 验证结果 + + static void ExportVerificationResultsSheet(XSSFWorkbook workbook) + { + var sheet = GetOrCreateSheet(workbook, "验证结果"); + ClearSheetRows(sheet); + + var header = sheet.CreateRow(0); + string[] cols = { "序号", "检查项", "CheckID", "结果", "详情" }; + for (int i = 0; i < cols.Length; i++) + header.CreateCell(i).SetCellValue(cols[i]); + + IBlackboard bb = null; + var owners = UnityEngine.Object.FindObjectsOfType(); + foreach (var owner in owners) + { + if (owner.blackboard != null) + { + string testKey = VerificationCheckIDs.ResultKey(VerificationCheckIDs.All[0]); + if (owner.blackboard.variables.ContainsKey(testKey)) + { + bb = owner.blackboard; + break; + } + } + } + + int rowIdx = 1; + for (int i = 0; i < VerificationCheckIDs.All.Length; i++) + { + var row = sheet.CreateRow(rowIdx++); + row.CreateCell(0).SetCellValue(i + 1); + row.CreateCell(1).SetCellValue(VerificationCheckIDs.DisplayNames[i]); + row.CreateCell(2).SetCellValue(VerificationCheckIDs.All[i]); + + string resultKey = VerificationCheckIDs.ResultKey(VerificationCheckIDs.All[i]); + string detailKey = VerificationCheckIDs.DetailKey(VerificationCheckIDs.All[i]); + + if (bb != null && bb.variables.ContainsKey(resultKey)) + { + var resultVar = bb.variables[resultKey]; + if (resultVar.value is bool passed) + { + row.CreateCell(3).SetCellValue(passed ? "通过" : "未通过"); + + string detail = bb.variables.ContainsKey(detailKey) + ? bb.variables[detailKey].value?.ToString() ?? "" + : ""; + row.CreateCell(4).SetCellValue(detail); + } + else + { + row.CreateCell(3).SetCellValue("未执行"); + row.CreateCell(4).SetCellValue("请先运行验证场景"); + } + } + else + { + row.CreateCell(3).SetCellValue("未执行"); + row.CreateCell(4).SetCellValue(bb == null ? "场景中未找到验证黑板数据" : "请先运行验证场景"); + } + } + + rowIdx++; + var summaryRow = sheet.CreateRow(rowIdx); + summaryRow.CreateCell(0).SetCellValue("汇总"); + + if (bb != null && bb.variables.ContainsKey(VerificationCheckIDs.PASS_COUNT_KEY)) + { + var passVar = bb.variables[VerificationCheckIDs.PASS_COUNT_KEY]; + int passCount = (passVar.value is int pc) ? pc : 0; + summaryRow.CreateCell(3).SetCellValue($"{passCount}/{VerificationCheckIDs.All.Length}"); + } + } + + #endregion + + #region 工具方法 + + /// + /// 清空单个Sheet的所有行数据(保留Sheet对象和VBA绑定) + /// + static void ClearSheetRows(ISheet sheet) + { + for (int r = sheet.LastRowNum; r >= 0; r--) + { + var row = sheet.GetRow(r); + if (row != null) sheet.RemoveRow(row); + } + } + + /// + /// 获取已有Sheet或创建新Sheet(模板模式下Sheet可能已存在) + /// + static ISheet GetOrCreateSheet(XSSFWorkbook workbook, string name) + { + var idx = workbook.GetSheetIndex(name); + if (idx >= 0) + return workbook.GetSheetAt(idx); + return workbook.CreateSheet(name); + } + + static void AutoSizeColumns(ISheet sheet, int maxCol) + { + for (int i = 0; i < maxCol; i++) + { + try + { + sheet.AutoSizeColumn(i); + } + catch + { + // 忽略异常 + } + } + } + + /// + /// 读取Excel并返回文本摘要(用于查看表信息) + /// + public static string GetExcelSummary(string xlsxPath) + { + if (!File.Exists(xlsxPath)) + return $"文件不存在: {xlsxPath}"; + + try + { + using (var file = new FileStream(xlsxPath, FileMode.Open, FileAccess.Read)) + { + var workbook = new XSSFWorkbook(file); + var sb = new System.Text.StringBuilder(); + sb.AppendLine($"===== Excel: {Path.GetFileName(xlsxPath)} ====="); + sb.AppendLine($"Sheet数量: {workbook.NumberOfSheets}"); + sb.AppendLine(); + + for (int s = 0; s < workbook.NumberOfSheets; s++) + { + var sheet = workbook.GetSheetAt(s); + sb.AppendLine($"--- [{sheet.SheetName}] ---"); + sb.AppendLine($"行数: {sheet.LastRowNum + 1}"); + + int previewRows = Math.Min(5, sheet.LastRowNum + 1); + for (int r = 0; r < previewRows; r++) + { + var row = sheet.GetRow(r); + if (row == null) continue; + + var cells = new List(); + for (int c = 0; c < row.LastCellNum && c < 8; c++) + { + var cell = row.GetCell(c); + cells.Add(cell?.ToString() ?? ""); + } + sb.AppendLine($" 行{r}: {string.Join(" | ", cells)}"); + } + if (sheet.LastRowNum + 1 > 5) + sb.AppendLine($" ... 还有 {sheet.LastRowNum + 1 - 5} 行"); + sb.AppendLine(); + } + + workbook.Close(); + return sb.ToString(); + } + } + catch (Exception ex) + { + return $"读取Excel失败: {ex.Message}"; + } + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Editor/VerificationExcelExporter.cs.meta b/Assets/BP_Scripts/GameplayEditor/Editor/VerificationExcelExporter.cs.meta new file mode 100644 index 0000000..6a2b5e6 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Editor/VerificationExcelExporter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 76bd933b8c0dc964b8862d1e1a2d1ac2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Excel.meta b/Assets/BP_Scripts/GameplayEditor/Excel.meta new file mode 100644 index 0000000..f2cc6ef --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 79f13f348651a804c999bf2bb5a063db +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/ActivityStageConfigParser.cs b/Assets/BP_Scripts/GameplayEditor/Excel/ActivityStageConfigParser.cs new file mode 100644 index 0000000..b47096c --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/ActivityStageConfigParser.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using System.Linq; +using ExcelDataReader; +using GameplayEditor.Config; +using UnityEngine; + +namespace GameplayEditor.Excel +{ + /// + /// 玩法关卡配置表解析器 + /// 从Excel解析ActivityStageConfig Sheet + /// + public class ActivityStageConfigParser + { + /// + /// 从Excel解析关卡配置 + /// + /// Excel文件路径 + /// 关卡配置数据列表 + public List ParseFromExcel(string xlsmPath) + { + var result = new List(); + + if (!File.Exists(xlsmPath)) + { + Debug.LogError($"[StageConfigParser] 文件不存在: {xlsmPath}"); + return result; + } + + try + { + using (var stream = File.Open(xlsmPath, FileMode.Open, FileAccess.Read)) + { + using (var reader = ExcelReaderFactory.CreateReader(stream)) + { + var dataset = reader.AsDataSet(); + + // 找ActivityStageConfig Sheet + DataTable configTable = null; + foreach (DataTable table in dataset.Tables) + { + if (table.TableName.Contains("StageConfig") || + table.TableName.Contains("关卡配置") || + table.TableName.Contains("ActivityStage")) + { + configTable = table; + break; + } + } + + if (configTable == null) + { + Debug.LogWarning("[StageConfigParser] 未找到ActivityStageConfig Sheet"); + return result; + } + + result = ParseConfigTable(configTable); + } + } + } + catch (Exception ex) + { + Debug.LogError($"[StageConfigParser] 解析错误: {ex.Message}"); + } + + return result; + } + + /// + /// 解析配置表 + /// + private List ParseConfigTable(DataTable table) + { + var result = new List(); + + // 查找表头行(通常是前3行:NAME, TYPE, DOC) + int headerRow = FindHeaderRow(table); + if (headerRow < 0) + { + Debug.LogError("[StageConfigParser] 未找到表头行"); + return result; + } + + // 读取字段名(第headerRow行) + var fieldNames = new List(); + for (int col = 0; col < table.Columns.Count; col++) + { + var cellValue = table.Rows[headerRow][col]?.ToString()?.Trim() ?? ""; + fieldNames.Add(cellValue); + } + + Debug.Log($"[StageConfigParser] 找到 {fieldNames.Count} 个字段: {string.Join(", ", fieldNames)}"); + + // 从第headerRow+3行开始读取数据(跳过NAME, TYPE, DOC行) + int dataStartRow = headerRow + 3; + + for (int row = dataStartRow; row < table.Rows.Count; row++) + { + var rowData = ParseDataRow(table.Rows[row], fieldNames); + if (rowData != null && rowData.ActivityStageID > 0) + { + result.Add(rowData); + Debug.Log($"[StageConfigParser] 解析配置: StageID={rowData.ActivityStageID}, Scene={rowData.SceneName}"); + } + } + + Debug.Log($"[StageConfigParser] 共解析 {result.Count} 条配置"); + return result; + } + + /// + /// 查找表头行 + /// + private int FindHeaderRow(DataTable table) + { + for (int row = 0; row < Math.Min(10, table.Rows.Count); row++) + { + var firstCell = table.Rows[row][0]?.ToString()?.Trim() ?? ""; + // 检查是否为NAME行或ActivityStageID字段 + if (firstCell == "NAME" || firstCell == "ActivityStageID" || + firstCell.Contains("StageID")) + { + return row; + } + } + return -1; + } + + /// + /// 解析数据行 + /// + private ActivityStageConfigData ParseDataRow(DataRow rowData, List fieldNames) + { + var dict = new Dictionary(); + + for (int col = 0; col < fieldNames.Count && col < rowData.Table.Columns.Count; col++) + { + var fieldName = fieldNames[col]; + if (string.IsNullOrWhiteSpace(fieldName)) continue; + + var cellValue = rowData[col]?.ToString()?.Trim() ?? ""; + dict[fieldName] = cellValue; + } + + // 检查是否有StageID + if (!dict.ContainsKey("ActivityStageID") || string.IsNullOrWhiteSpace(dict["ActivityStageID"])) + { + // 尝试其他可能的字段名 + foreach (var key in dict.Keys) + { + if (key.Contains("StageID") || key.Contains("ID")) + { + if (int.TryParse(dict[key], out var id) && id > 0) + { + dict["ActivityStageID"] = dict[key]; + break; + } + } + } + } + + var data = ActivityStageConfigData.FromDictionary(dict); + + // 验证有效性 + if (data.ActivityStageID <= 0) + { + return null; + } + + return data; + } + + /// + /// 创建ScriptableObject资产 + /// + /// 配置数据 + /// 输出路径 + /// 创建的资产 + public ActivityStageConfig CreateAsset(ActivityStageConfigData data, string outputPath) + { + var config = UnityEngine.ScriptableObject.CreateInstance(); + data.ApplyTo(config); + + #if UNITY_EDITOR + UnityEditor.AssetDatabase.CreateAsset(config, outputPath); + UnityEditor.AssetDatabase.SaveAssets(); + #endif + + Debug.Log($"[StageConfigParser] 创建资产: {outputPath}"); + return config; + } + + /// + /// 批量创建资产 + /// + public List CreateAssets(List datas, string outputFolder) + { + var result = new List(); + + #if UNITY_EDITOR + // 确保文件夹存在 + if (!UnityEditor.AssetDatabase.IsValidFolder(outputFolder)) + { + var parentFolder = System.IO.Path.GetDirectoryName(outputFolder).Replace('\\', '/'); + var folderName = System.IO.Path.GetFileName(outputFolder); + UnityEditor.AssetDatabase.CreateFolder(parentFolder, folderName); + } + #endif + + foreach (var data in datas) + { + var fileName = $"StageConfig_{data.ActivityStageID}.asset"; + var outputPath = $"{outputFolder}/{fileName}"; + + var config = CreateAsset(data, outputPath); + result.Add(config); + } + + return result; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/ActivityStageConfigParser.cs.meta b/Assets/BP_Scripts/GameplayEditor/Excel/ActivityStageConfigParser.cs.meta new file mode 100644 index 0000000..a7f1292 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/ActivityStageConfigParser.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4078174cf3b0954469d3da92dfc9f568 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/BtSheetParser.cs b/Assets/BP_Scripts/GameplayEditor/Excel/BtSheetParser.cs new file mode 100644 index 0000000..0f32c70 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/BtSheetParser.cs @@ -0,0 +1,500 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using System.Linq; +using ExcelDataReader; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using GameplayEditor.Nodes; +using GameplayEditor.Core; +using GameplayEditor.Utils; +using UnityEngine; + +namespace GameplayEditor.Excel +{ + /// + /// 行为树解析结果 + /// 包含行为树资产和对应的节点信息容器 + /// + public class BehaviourTreeParseResult + { + public BehaviourTree Tree; + public BehaviourTreeNodeInfoContainer NodeInfoContainer; + } + + /// + /// 行为树Sheet解析器 + /// 从xlsm的行为树Sheet读取,生成BehaviourTree资产 + /// 支持行索引自动生成,用于报错定位 + /// + public class BtSheetParser + { + private NodeTypeNameResolver _nameResolver; + private RowIndexGenerator _rowIndexGenerator; + + public BtSheetParser(NodeTypeNameResolver nameResolver) + { + _nameResolver = nameResolver; + _rowIndexGenerator = new RowIndexGenerator(); + } + + /// 从xlsm导入行为树(旧接口,仅返回BehaviourTree列表) + public List ParseFromExcel(string xlsmPath) + { + var results = ParseFromExcelWithInfo(xlsmPath); + return results.Select(r => r.Tree).ToList(); + } + + /// 从xlsm导入行为树(新接口,返回完整解析结果包含行索引信息) + public List ParseFromExcelWithInfo(string xlsmPath) + { + var result = new List(); + _rowIndexGenerator.Reset(); + + using (var stream = File.Open(xlsmPath, FileMode.Open, FileAccess.Read)) + { + using (var reader = ExcelReaderFactory.CreateReader(stream)) + { + var dataset = reader.AsDataSet(); + + // 找行为树Sheet + DataTable btTable = null; + foreach (DataTable table in dataset.Tables) + { + if (table.TableName.Contains("行为树")) + { + btTable = table; + break; + } + } + + if (btTable == null) + { + Debug.LogError("未找到行为树Sheet"); + return result; + } + + result = ParseBehaviourTreeTableWithInfo(btTable); + } + } + + return result; + } + + private List ParseBehaviourTreeTableWithInfo(DataTable table) + { + var results = new List(); + var currentTreeId = -1; + var currentTree = (BehaviourTree)null; + var currentNodeInfoContainer = (BehaviourTreeNodeInfoContainer)null; + var nodeStack = new Stack(); + var nodeInfoStack = new Stack(); + + for (int row = 0; row < table.Rows.Count; row++) + { + var excelRowNumber = row + 1; // Excel行号从1开始 + var rowData = table.Rows[row]; + var treeIdStr = rowData[0]?.ToString()?.Trim() ?? ""; + + // 注释行跳过 + if (treeIdStr.StartsWith('~')) + continue; + + // col0有整数 → 开始新树 + if (int.TryParse(treeIdStr, out var treeId)) + { + // 保存前一棵树 + if (currentTree != null) + { + ApplyLayout(currentTree); + results.Add(new BehaviourTreeParseResult + { + Tree = currentTree, + NodeInfoContainer = currentNodeInfoContainer + }); + } + + // 创建新树 + currentTree = ScriptableObject.CreateInstance(); + currentTree.name = $"BT_{treeId}"; + currentTreeId = treeId; + nodeStack.Clear(); + nodeInfoStack.Clear(); + + // 创建节点信息容器 + currentNodeInfoContainer = ScriptableObject.CreateInstance(); + currentNodeInfoContainer.BehaviourTreeName = currentTree.name; + currentNodeInfoContainer.TreeID = treeId; + + // 检查是否为头文件 + currentNodeInfoContainer.IsHeaderFile = BehaviourTreeExtended.IsHeaderFile(treeId); + var baseStageId = BehaviourTreeExtended.GetBaseStageID(treeId); + + if (currentNodeInfoContainer.IsHeaderFile) + { + Debug.Log($"[BtParser] 开始解析头文件树 {treeId} (StageID: {baseStageId})"); + } + else + { + Debug.Log($"[BtParser] 开始解析正文树 {treeId} (StageID: {baseStageId})"); + } + } + + // col0为空且没有当前树 → 头部杂行,跳过 + if (currentTree == null) continue; + + // 找第一个非空的节点列(col3+) + int nodeColIndex = -1; + int depth = 0; + for (int col = 3; col < table.Columns.Count; col++) + { + var cellValue = rowData[col]?.ToString()?.Trim() ?? ""; + if (!string.IsNullOrEmpty(cellValue)) + { + nodeColIndex = col; + depth = col - 3; + break; + } + } + + if (nodeColIndex < 0) continue; + + // 生成行索引 + var rowIndex = _rowIndexGenerator.GenerateNext(); + + // 解析节点 + var nodeStr = rowData[nodeColIndex].ToString().Trim(); + var (displayName, rawParams) = ParseNodeString(nodeStr); + + // 获取代码名 + var codeName = _nameResolver.GetCodeName(displayName); + + // 创建节点 + BTNode node = null; + + // 检查是否为组合方法(子树引用) + if (displayName.StartsWith("组合 方法:") || displayName.StartsWith("方法:")) + { + node = CreateCompositeMethodNode(displayName, currentTree); + } + // 检查是否为复合节点 + else if (_nameResolver.IsCompositeDisplayName(displayName)) + { + node = CreateCompositeNode(codeName, displayName, currentTree); + } + else + { + node = CreateActionNode(codeName, displayName, rawParams, currentTree); + } + + if (node == null) continue; + + // 创建节点信息 + var nodeInfo = new BehaviourTreeNodeInfo + { + RowIndex = rowIndex, + NodeTypeName = displayName, + RawData = nodeStr, + ExcelRowNumber = excelRowNumber + }; + currentNodeInfoContainer.AddNodeInfo(nodeInfo); + + // 挂载到树或父节点 + if (depth == 0) + { + // 根节点 + currentTree.primeNode = node; + nodeStack.Clear(); + nodeStack.Push(node); + nodeInfoStack.Clear(); + nodeInfoStack.Push(nodeInfo); + } + else + { + // 子节点:挂到栈顶的父节点 + while (nodeStack.Count > depth) + { + nodeStack.Pop(); + nodeInfoStack.Pop(); + } + + if (nodeStack.Count > 0) + { + var parent = nodeStack.Peek(); + if (parent is BTComposite composite) + { + currentTree.ConnectNodes(parent, node); + } + } + + nodeStack.Push(node); + nodeInfoStack.Push(nodeInfo); + } + } + + // 保存最后一棵树 + if (currentTree != null) + { + ApplyLayout(currentTree); + results.Add(new BehaviourTreeParseResult + { + Tree = currentTree, + NodeInfoContainer = currentNodeInfoContainer + }); + } + + Debug.Log($"[BtParser] 解析完成,共 {results.Count} 棵树,生成 {_rowIndexGenerator.CurrentMaxIndex} 个行索引"); + return results; + } + + private List ParseBehaviourTreeTable(DataTable table) + { + var result = new List(); + var currentTreeId = -1; + var currentTree = (BehaviourTree)null; + var nodeStack = new Stack(); + + for (int row = 0; row < table.Rows.Count; row++) + { + var rowData = table.Rows[row]; + var treeIdStr = rowData[0]?.ToString()?.Trim() ?? ""; + + // 注释行跳过 + if (treeIdStr.StartsWith('~')) + continue; + + // col0有整数 → 开始新树 + if (int.TryParse(treeIdStr, out var treeId)) + { + // 保存前一棵树 + if (currentTree != null) + { + ApplyLayout(currentTree); + result.Add(currentTree); + } + + // 创建新树 + currentTree = ScriptableObject.CreateInstance(); + currentTree.name = $"BT_{treeId}"; + currentTreeId = treeId; + nodeStack.Clear(); + + Debug.Log($"[BtParser] 开始解析树 {treeId}"); + } + + // col0为空且没有当前树 → 头部杂行,跳过 + if (currentTree == null) continue; + + // 找第一个非空的节点列(col3+) + int nodeColIndex = -1; + int depth = 0; + for (int col = 3; col < table.Columns.Count; col++) + { + var cellValue = rowData[col]?.ToString()?.Trim() ?? ""; + if (!string.IsNullOrEmpty(cellValue)) + { + nodeColIndex = col; + depth = col - 3; + break; + } + } + + if (nodeColIndex < 0) continue; + + // 解析节点 + var nodeStr = rowData[nodeColIndex].ToString().Trim(); + var (displayName, rawParams) = ParseNodeString(nodeStr); + + // 获取代码名 + var codeName = _nameResolver.GetCodeName(displayName); + + // 创建节点 + BTNode node = null; + + // 检查是否为复合节点 + if (_nameResolver.IsCompositeDisplayName(displayName)) + { + node = CreateCompositeNode(codeName, displayName, currentTree); + } + else + { + node = CreateActionNode(codeName, displayName, rawParams, currentTree); + } + + if (node == null) continue; + + // 挂载到树或父节点 + if (depth == 0) + { + // 根节点 + currentTree.primeNode = node; + nodeStack.Clear(); + nodeStack.Push(node); + } + else + { + // 子节点:挂到栈顶的父节点 + while (nodeStack.Count > depth) + nodeStack.Pop(); + + if (nodeStack.Count > 0) + { + var parent = nodeStack.Peek(); + if (parent is BTComposite composite) + { + currentTree.ConnectNodes(parent, node); + } + } + + nodeStack.Push(node); + } + } + + // 保存最后一棵树 + if (currentTree != null) + { + ApplyLayout(currentTree); + result.Add(currentTree); + } + + return result; + } + + // ── 布局 ──────────────────────────────────────────────────────────── + private const float X_STEP = 220f; + private const float Y_STEP = 150f; + private int _leafCounter; + + private void ApplyLayout(BehaviourTree tree) + { + if (tree.primeNode == null) return; + _leafCounter = 0; + LayoutNode(tree.primeNode, 0); + } + + /// 后序DFS:叶节点从左到右排,父节点居中于子节点之上 + private float LayoutNode(Node node, int depth) + { + var children = node.outConnections; + + if (children == null || children.Count == 0) + { + // 叶节点 + float x = _leafCounter * X_STEP; + node.position = new Vector2(x, depth * Y_STEP); + _leafCounter++; + return x; + } + + float firstX = float.MaxValue; + float lastX = float.MinValue; + foreach (var conn in children) + { + float cx = LayoutNode(conn.targetNode, depth + 1); + if (cx < firstX) firstX = cx; + if (cx > lastX) lastX = cx; + } + + float centerX = (firstX + lastX) / 2f; + node.position = new Vector2(centerX, depth * Y_STEP); + return centerX; + } + + // ──────────────────────────────────────────────────────────────────── + + private (string displayName, string[] rawParams) ParseNodeString(string nodeStr) + { + // 格式:显示名 参数1|参数2|参数3 + var parts = nodeStr.Split(new[] { ' ' }, 2); + var displayName = parts[0]; + var paramStr = parts.Length > 1 ? parts[1] : ""; + + var rawParams = string.IsNullOrEmpty(paramStr) + ? Array.Empty() + : paramStr.Split('|'); + + return (displayName, rawParams); + } + + private BTNode CreateCompositeNode(string codeName, string displayName, BehaviourTree tree) + { + // 检查是否为加权乱选节点(带权重参数) + if (displayName.StartsWith("乱选") || displayName.StartsWith("加权乱选")) + { + return CreateWeightedRandomNode(displayName, tree); + } + + if (NodeTypeRegistry.TryGetCompositeType(displayName, out var compositeType)) + { + var node = (BTComposite)tree.AddNode(compositeType); + node.name = displayName; + return node; + } + + Debug.LogWarning($"未知的复合节点类型:{displayName}"); + return null; + } + + /// + /// 创建加权乱选节点 + /// 格式: "乱选 30|40|30" 或 "加权乱选 0.3|0.4|0.3" + /// + private BTNode CreateWeightedRandomNode(string displayName, BehaviourTree tree) + { + // 解析权重参数 + var parts = displayName.Split(new[] { ' ' }, 2); + var baseName = parts[0]; + string weightString = parts.Length > 1 ? parts[1] : ""; + + // 创建加权乱选节点 + var weightedNode = (Nodes.WeightedProbabilitySelector)tree.AddNode(typeof(Nodes.WeightedProbabilitySelector)); + weightedNode.name = $"乱选({weightString})"; + + // 设置权重 + if (!string.IsNullOrEmpty(weightString)) + { + weightedNode.SetWeightsFromConfig(weightString); + Debug.Log($"[BtParser] 创建加权乱选节点,权重: {weightString}"); + } + else + { + Debug.Log($"[BtParser] 创建乱选节点(均匀随机,无权重配置)"); + } + + return weightedNode; + } + + private BTNode CreateActionNode(string codeName, string displayName, string[] rawParams, BehaviourTree tree) + { + var task = NodeTypeRegistry.CreateTask(codeName, displayName, rawParams); + var actionNode = (ActionNode)tree.AddNode(typeof(ActionNode)); + actionNode.action = task; + actionNode.name = displayName; + return actionNode; + } + + /// + /// 创建组合方法节点(子树引用) + /// + private BTNode CreateCompositeMethodNode(string displayName, BehaviourTree tree) + { + // 解析方法名称 + // 格式: "组合 方法:关卡初始化" 或 "方法:关卡初始化" + var methodName = displayName.Replace("组合 方法:", "").Replace("方法:", "").Trim(); + + // 查找方法ID(从注册表或工作表4) + var method = Nodes.CompositeMethodRegistry.GetMethodByName(methodName); + int methodId = method != null ? method.MethodID : 0; + + // 创建组合方法节点 + var compositeMethodNode = (Nodes.CompositeMethodNode)tree.AddNode(typeof(Nodes.CompositeMethodNode)); + compositeMethodNode.SetMethodInfo(methodId, methodName, displayName); + compositeMethodNode.name = displayName; + + Debug.Log($"[BtParser] 创建组合方法节点: {displayName} (ID:{methodId})"); + + return compositeMethodNode; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/BtSheetParser.cs.meta b/Assets/BP_Scripts/GameplayEditor/Excel/BtSheetParser.cs.meta new file mode 100644 index 0000000..41d6726 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/BtSheetParser.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 222d96fa2db0ecc459c2b8812e79adf7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/BtSheetWriter.cs b/Assets/BP_Scripts/GameplayEditor/Excel/BtSheetWriter.cs new file mode 100644 index 0000000..bacda38 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/BtSheetWriter.cs @@ -0,0 +1,453 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using NPOI.SS.UserModel; +using NPOI.XSSF.UserModel; +using ParadoxNotion.Design; +using GameplayEditor.Nodes; +using GameplayEditor.Core; +using UnityEngine; + +namespace GameplayEditor.Excel +{ + /// + /// 行为树导出器 + /// 将一组 BehaviourTree 导出到同一个 xlsx 文件的同一张行为树 Sheet + /// 支持组合方法节点、行索引信息、行为类型Sheet + /// + public class BtSheetWriter + { + private NodeTypeNameResolver _nameResolver; + + public BtSheetWriter(NodeTypeNameResolver nameResolver) + { + _nameResolver = nameResolver; + } + + /// + /// 导出多棵 BehaviourTree 到同一 xlsx + /// 包含行为树Sheet和行为类型Sheet + /// + public void ExportToExcel(IList trees, string xlsxPath) + { + var workbook = new XSSFWorkbook(); + + // 创建行为树Sheet(主数据) + var btSheet = CreateBehaviourTreeSheet(workbook, trees); + + // 创建行为类型Sheet(节点映射参考) + var typeSheet = CreateBehaviorTypeSheet(workbook); + + // 创建组合方法Sheet(子树定义) + var methodSheet = CreateCompositeMethodSheet(workbook); + + // 调整列宽 + AutoSizeColumns(btSheet); + AutoSizeColumns(typeSheet); + AutoSizeColumns(methodSheet); + + // 写入文件 + using (var file = new FileStream(xlsxPath, FileMode.Create, FileAccess.Write)) + { + workbook.Write(file); + } + + workbook.Close(); + Debug.Log($"[BtWriter] 导出完成:{xlsxPath},共 {trees.Count} 棵树"); + } + + /// + /// 创建行为树Sheet + /// + private ISheet CreateBehaviourTreeSheet(XSSFWorkbook workbook, IList trees) + { + var sheet = workbook.CreateSheet("行为树"); + + // 写表头(与导入格式一致) + // AI树id | 本地化 | 说明 | 节点 | 节点 | ... + var headerRow = sheet.CreateRow(0); + headerRow.CreateCell(0).SetCellValue("AI树id"); + headerRow.CreateCell(1).SetCellValue("本地化"); + headerRow.CreateCell(2).SetCellValue("说明"); + for (int i = 3; i < 30; i++) + headerRow.CreateCell(i).SetCellValue("节点"); + + // 写入每棵树 + int rowIndex = 1; + foreach (var tree in trees) + { + if (tree == null || tree.primeNode == null) continue; + + var treeId = ExtractTreeId(tree.name); + var treeInfo = GetTreeInfo(tree, treeId); + + rowIndex = WriteNodeRecursive(sheet, (BTNode)tree.primeNode, rowIndex, treeId, 0, true, treeInfo); + rowIndex++; // 树之间空一行 + } + + // 隐藏行索引列(第31列,0-indexed为30) + sheet.SetColumnHidden(30, true); + + return sheet; + } + + /// + /// 递归写入节点 + /// + private int WriteNodeRecursive(ISheet sheet, BTNode node, int rowIndex, int treeId, int depth, + bool isFirstNode, BtWriterNodeInfoHelper treeInfo) + { + var row = sheet.CreateRow(rowIndex); + + // 第一列:树ID(只有首行写) + if (isFirstNode) + { + row.CreateCell(0).SetCellValue(treeId); + + // 检查是否为头文件并添加说明 + if (BehaviourTreeExtended.IsHeaderFile(treeId)) + { + row.CreateCell(2).SetCellValue($"头文件 (StageID: {BehaviourTreeExtended.GetBaseStageID(treeId)})"); + } + } + + // 写入节点内容 + var nodeContent = FormatNodeContent(node); + row.CreateCell(3 + depth).SetCellValue(nodeContent); + + // 添加行索引信息(如果有) + if (treeInfo != null && treeInfo.NodeInfos.Count > depth) + { + var nodeInfo = treeInfo.NodeInfos[Math.Min(depth, treeInfo.NodeInfos.Count - 1)]; + // 使用隐藏列存储行索引(第31列,即AE列) + // 在 CreateBehaviourTreeSheet 中已通过 sheet.SetColumnHidden(30, true) 隐藏 + var indexCell = row.CreateCell(30); + indexCell.SetCellValue(nodeInfo.RowIndex); + } + + rowIndex++; + + // 递归写入子节点 + foreach (var connection in node.outConnections) + { + var child = (BTNode)connection.targetNode; + rowIndex = WriteNodeRecursive(sheet, child, rowIndex, treeId, depth + 1, false, treeInfo); + } + + return rowIndex; + } + + /// + /// 格式化节点内容 + /// + private string FormatNodeContent(BTNode node) + { + // 组合方法节点 + if (node is CompositeMethodNode compositeNode) + { + return compositeNode.DisplayName ?? $"方法:{compositeNode.MethodName}"; + } + + // 动态节点 + if (node is ActionNode actionNode && actionNode.action is DynamicBtActionTask dynTask) + { + var displayName = _nameResolver.GetDisplayName(dynTask.NodeTypeName); + + // 如果有原始参数,拼接回去 + if (dynTask.RawParams != null && dynTask.RawParams.Count > 0) + { + var paramStr = string.Join("|", dynTask.RawParams); + return $"{displayName} {paramStr}"; + } + + return displayName; + } + + // 注册的自定义Task + if (node is ActionNode an && an.action != null) + { + var taskType = an.action.GetType(); + var taskName = taskType.Name.Replace("Task", ""); + + // 尝试获取中文显示名 + var displayName = NodeTypeRegistry.GetDisplayNameByCodeName(taskName); + + // 尝试从IBtNodeWithParams获取参数 + if (an.action is IBtNodeWithParams paramTask) + { + // 通过反射获取参数字段(简化处理) + var paramStr = ExtractTaskParams(an.action); + if (!string.IsNullOrEmpty(paramStr)) + { + return $"{displayName} {paramStr}"; + } + } + + return displayName; + } + + // 复合节点 + if (node is BTComposite composite) + { + var typeName = composite.GetType().Name; + return typeName switch + { + "Parallel" => "平行", + "Sequencer" => "顺序", + "Selector" => "选择", + "ProbabilitySelector" => "乱选", + "Iterate" => "循环", + _ => typeName + }; + } + + return node.name ?? "未知节点"; + } + + /// + /// 提取Task参数(通过反射) + /// + private string ExtractTaskParams(ActionTask task) + { + var paramList = new List(); + + // 获取所有可序列化字段 + var fields = task.GetType().GetFields(System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public); + + foreach (var field in fields) + { + // 跳过Unity内部字段 + if (field.Name.StartsWith("_") || field.Name == "name" || field.Name == "hideFlags") + continue; + + // 跳过Tooltip等属性字段 + if (field.FieldType == typeof(string) && field.Name.ToLower().Contains("tooltip")) + continue; + + var value = field.GetValue(task); + if (value != null && !IsDefaultValue(value)) + { + paramList.Add(value.ToString()); + } + } + + return string.Join("|", paramList); + } + + /// + /// 检查是否为默认值 + /// + private bool IsDefaultValue(object value) + { + if (value == null) return true; + if (value is string str) return string.IsNullOrEmpty(str); + if (value is int i) return i == 0; + if (value is float f) return f == 0f; + if (value is bool b) return b == false; + return false; + } + + /// + /// 创建行为类型Sheet + /// 提供节点类型映射参考 + /// + private ISheet CreateBehaviorTypeSheet(XSSFWorkbook workbook) + { + var sheet = workbook.CreateSheet("行为类型"); + + // 表头 + var headerRow = sheet.CreateRow(0); + headerRow.CreateCell(0).SetCellValue("类型"); + headerRow.CreateCell(1).SetCellValue("参数"); + headerRow.CreateCell(2).SetCellValue("名字"); + headerRow.CreateCell(3).SetCellValue("说明"); + + int rowIndex = 1; + + // 写入所有注册的节点类型 + foreach (var codeName in NodeTypeRegistry.GetAllCodeNames()) + { + var row = sheet.CreateRow(rowIndex++); + row.CreateCell(0).SetCellValue(codeName); + row.CreateCell(2).SetCellValue(NodeTypeRegistry.GetDisplayNameByCodeName(codeName)); + + // 尝试获取描述 + var taskType = NodeTypeRegistry.GetTaskType(codeName); + var descAttr = taskType.GetCustomAttributes(typeof(DescriptionAttribute), false).FirstOrDefault() as DescriptionAttribute; + if (descAttr != null) + { + row.CreateCell(3).SetCellValue(descAttr.description); + } + } + + // 写入复合节点类型 + foreach (var compositeName in NodeTypeRegistry.GetAllCompositeNames()) + { + var row = sheet.CreateRow(rowIndex++); + row.CreateCell(2).SetCellValue(compositeName); + row.CreateCell(3).SetCellValue("复合节点"); + } + + return sheet; + } + + /// + /// 创建组合方法Sheet + /// 列出所有可用的组合方法 + /// + private ISheet CreateCompositeMethodSheet(XSSFWorkbook workbook) + { + var sheet = workbook.CreateSheet("组合方法"); + + // 表头 + var headerRow = sheet.CreateRow(0); + headerRow.CreateCell(0).SetCellValue("方法ID"); + headerRow.CreateCell(1).SetCellValue("方法名"); + headerRow.CreateCell(2).SetCellValue("显示名"); + headerRow.CreateCell(3).SetCellValue("说明"); + headerRow.CreateCell(4).SetCellValue("关联行为树"); + + // 加载所有组合方法 + CompositeMethodRegistry.LoadAllMethods(); + + int rowIndex = 1; + // 获取所有方法(通过反射访问私有字段) + var methodsField = typeof(CompositeMethodRegistry).GetField("_methods", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + if (methodsField != null) + { + var methods = methodsField.GetValue(null) as System.Collections.IDictionary; + if (methods != null) + { + foreach (System.Collections.DictionaryEntry entry in methods) + { + var method = entry.Value as CompositeMethod; + if (method != null) + { + var row = sheet.CreateRow(rowIndex++); + row.CreateCell(0).SetCellValue(method.MethodID); + row.CreateCell(1).SetCellValue(method.MethodName); + row.CreateCell(2).SetCellValue(method.DisplayName); + row.CreateCell(3).SetCellValue(method.Doc); + row.CreateCell(4).SetCellValue(method.Tree != null ? method.Tree.name : ""); + } + } + } + } + + return sheet; + } + + /// + /// 从树名称提取ID + /// + private int ExtractTreeId(string treeName) + { + if (treeName.StartsWith("BT_") && int.TryParse(treeName.Substring(3), out var id)) + return id; + return 0; + } + + /// + /// 获取树的节点信息 + /// + private BtWriterNodeInfoHelper GetTreeInfo(BehaviourTree tree, int treeId) + { + // 尝试从管理器获取节点信息 + var container = BehaviourTreeNodeInfoManager.GetContainer(tree.name); + if (container != null && container.NodeInfos.Count > 0) + { + return new BtWriterNodeInfoHelper + { + TreeID = treeId, + NodeInfos = container.NodeInfos + }; + } + return null; + } + + /// + /// 自动调整列宽 + /// + private void AutoSizeColumns(ISheet sheet) + { + for (int i = 0; i < 10; i++) + { + sheet.AutoSizeColumn(i); + } + } + + /// + /// 导出单棵 BehaviourTree(兼容旧调用) + /// + public void ExportToExcel(BehaviourTree tree, string xlsxPath) + { + ExportToExcel(new[] { tree }, xlsxPath); + } + + /// + /// 导出行为树节点信息(调试用) + /// 包含详细的行索引信息 + /// + public void ExportNodeInfo(IList trees, string xlsxPath) + { + var workbook = new XSSFWorkbook(); + var sheet = workbook.CreateSheet("节点信息"); + + // 表头 + var headerRow = sheet.CreateRow(0); + headerRow.CreateCell(0).SetCellValue("树ID"); + headerRow.CreateCell(1).SetCellValue("节点索引"); + headerRow.CreateCell(2).SetCellValue("行索引"); + headerRow.CreateCell(3).SetCellValue("节点类型"); + headerRow.CreateCell(4).SetCellValue("原始数据"); + headerRow.CreateCell(5).SetCellValue("Excel行号"); + + int rowIndex = 1; + foreach (var tree in trees) + { + if (tree == null) continue; + + var treeId = ExtractTreeId(tree.name); + var container = BehaviourTreeNodeInfoManager.GetContainer(tree.name); + + if (container != null) + { + for (int i = 0; i < container.NodeInfos.Count; i++) + { + var info = container.NodeInfos[i]; + var row = sheet.CreateRow(rowIndex++); + row.CreateCell(0).SetCellValue(treeId); + row.CreateCell(1).SetCellValue(i); + row.CreateCell(2).SetCellValue(info.RowIndex); + row.CreateCell(3).SetCellValue(info.NodeTypeName); + row.CreateCell(4).SetCellValue(info.RawData); + row.CreateCell(5).SetCellValue(info.ExcelRowNumber); + } + } + } + + using (var file = new FileStream(xlsxPath, FileMode.Create, FileAccess.Write)) + { + workbook.Write(file); + } + workbook.Close(); + + Debug.Log($"[BtWriter] 节点信息导出完成:{xlsxPath}"); + } + } + + /// + /// 树节点信息(内部用) + /// + internal class BtWriterNodeInfoHelper + { + public int TreeID; + public List NodeInfos = new List(); + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/BtSheetWriter.cs.meta b/Assets/BP_Scripts/GameplayEditor/Excel/BtSheetWriter.cs.meta new file mode 100644 index 0000000..6582827 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/BtSheetWriter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c83d83f4c78e10940b5bae0098d421e9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/DialogInfoParser.cs b/Assets/BP_Scripts/GameplayEditor/Excel/DialogInfoParser.cs new file mode 100644 index 0000000..2e9439c --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/DialogInfoParser.cs @@ -0,0 +1,500 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using ExcelDataReader; +using GameplayEditor.Config; +using GameplayEditor.Utils; +using UnityEngine; + +namespace GameplayEditor.Excel +{ + /// + /// DialogInfo Excel解析结果 + /// + public class DialogInfoParseResult + { + public string SheetName; + public List Dialogs = new List(); + public List Errors = new List(); + } + + /// + /// DialogInfo配置表解析器 + /// 解析Excel中的DialogInfo Sheet,生成对话配置数据 + /// + public class DialogInfoParser + { + /// + /// 从Excel解析所有DialogInfo配置 + /// 自动查找所有包含"Dialog"的Sheet + /// + public List ParseFromExcel(string xlsmPath) + { + var results = new List(); + + if (!File.Exists(xlsmPath)) + { + Debug.LogError($"[DialogInfoParser] 文件不存在: {xlsmPath}"); + return results; + } + + try + { + using (var stream = File.Open(xlsmPath, FileMode.Open, FileAccess.Read)) + { + using (var reader = ExcelReaderFactory.CreateReader(stream)) + { + var dataset = reader.AsDataSet(); + + // 查找所有包含"Dialog"的Sheet + foreach (DataTable table in dataset.Tables) + { + if (IsDialogInfoSheet(table.TableName)) + { + Debug.Log($"[DialogInfoParser] 找到Sheet: {table.TableName}"); + var result = ParseDialogInfoSheet(table); + result.SheetName = table.TableName; + results.Add(result); + } + } + } + } + + Debug.Log($"[DialogInfoParser] 解析完成,共 {results.Count} 个DialogInfo表"); + } + catch (Exception ex) + { + Debug.LogError($"[DialogInfoParser] 解析错误: {ex.Message}\n{ex.StackTrace}"); + } + + return results; + } + + /// + /// 解析单个DialogInfo Sheet + /// + private DialogInfoParseResult ParseDialogInfoSheet(DataTable table) + { + var result = new DialogInfoParseResult(); + + // 查找表头行 + int headerRow = FindHeaderRow(table); + if (headerRow < 0) + { + result.Errors.Add("未找到表头行"); + return result; + } + + // 读取字段名映射 + var fieldMap = ReadFieldMapping(table, headerRow); + + // 从第headerRow+3行开始读取数据 + int dataStartRow = headerRow + 3; + + for (int row = dataStartRow; row < table.Rows.Count; row++) + { + try + { + var dialog = ParseDialogRow(table.Rows[row], fieldMap, row + 1); + if (dialog != null) + { + if (dialog.Validate(out var error)) + { + result.Dialogs.Add(dialog); + } + else + { + result.Errors.Add($"第{row + 1}行: {error}"); + } + } + } + catch (Exception ex) + { + result.Errors.Add($"第{row + 1}行解析错误: {ex.Message}"); + } + } + + // 检查ID唯一性 + var idSet = new HashSet(); + foreach (var dialog in result.Dialogs) + { + if (idSet.Contains(dialog.ID)) + { + result.Errors.Add($"重复的ID: {dialog.ID}"); + } + idSet.Add(dialog.ID); + } + + Debug.Log($"[DialogInfoParser] Sheet '{table.TableName}' 解析了 {result.Dialogs.Count} 条对话,{result.Errors.Count} 个错误"); + return result; + } + + /// + /// 解析单条对话数据 + /// + private DialogInfoData ParseDialogRow(DataRow rowData, Dictionary fieldMap, int excelRowNumber) + { + var dialog = new DialogInfoData(); + bool hasData = false; + + foreach (var kvp in fieldMap) + { + var fieldName = kvp.Key.ToLower(); + var colIndex = kvp.Value; + + if (colIndex >= rowData.Table.Columns.Count) + continue; + + var cellValue = rowData[colIndex]?.ToString()?.Trim() ?? ""; + if (string.IsNullOrEmpty(cellValue)) + continue; + + hasData = true; + + // 根据字段名解析数据 + ParseField(dialog, fieldName, cellValue); + } + + return hasData ? dialog : null; + } + + /// + /// 解析单个字段 + /// + private void ParseField(DialogInfoData dialog, string fieldName, string value) + { + // ID + if (fieldName == "id" || fieldName == "配置id") + { + if (int.TryParse(value, out var id)) + dialog.ID = id; + } + // IsMask + else if (fieldName == "ismask" || fieldName == "是否开启蒙层") + { + dialog.IsMask = ParseBool(value); + } + // MaskTarget + else if (fieldName == "masktarget" || fieldName == "蒙层位置") + { + dialog.MaskTarget = ParseVector2(value); + } + // ClickKey + else if (fieldName == "clickkey" || fieldName == "关闭蒙层键值") + { + dialog.ClickKey = value; + } + // DialogType + else if (fieldName == "dialogtype" || fieldName == "战中类型") + { + if (int.TryParse(value, out var type)) + dialog.DialogType = type; + } + // UiType + else if (fieldName == "uitype" || fieldName == "uistyle" || fieldName == "uistyle" || fieldName == "ui样式") + { + if (int.TryParse(value, out var type)) + dialog.UiType = type; + } + // PrefabPath + else if (fieldName == "prefabpath" || fieldName == "prefab路径" || fieldName.Contains("prefab")) + { + dialog.PrefabPath = value; + } + // CharacterName + else if (fieldName == "charactername" || fieldName == "角色名" || fieldName == "显示角色名") + { + dialog.CharacterName = value; + } + // Text + else if (fieldName == "text" || fieldName == "正文" || fieldName == "对话正文") + { + dialog.Text = value; + } + // Duration + else if (fieldName == "duration" || fieldName == "显示时间") + { + if (float.TryParse(value, out var duration)) + dialog.Duration = duration; + } + // Params + else if (fieldName == "params1" || fieldName == "特殊参数1") + { + dialog.Params1 = value; + } + else if (fieldName == "params2" || fieldName == "特殊参数2") + { + dialog.Params2 = value; + } + else if (fieldName == "params3" || fieldName == "特殊参数3") + { + dialog.Params3 = value; + } + else if (fieldName == "params4" || fieldName == "特殊参数4") + { + dialog.Params4 = value; + } + else if (fieldName == "params5" || fieldName == "特殊参数5") + { + dialog.Params5 = value; + } + } + + /// + /// 读取字段映射 + /// + private Dictionary ReadFieldMapping(DataTable table, int headerRow) + { + var mapping = new Dictionary(StringComparer.OrdinalIgnoreCase); + + for (int col = 0; col < table.Columns.Count; col++) + { + var fieldName = table.Rows[headerRow][col]?.ToString()?.Trim() ?? ""; + if (!string.IsNullOrEmpty(fieldName)) + { + mapping[fieldName] = col; + } + } + + return mapping; + } + + /// + /// 查找表头行 + /// + private int FindHeaderRow(DataTable table) + { + for (int row = 0; row < Math.Min(10, table.Rows.Count); row++) + { + var firstCell = table.Rows[row][0]?.ToString()?.Trim() ?? ""; + if (firstCell == "NAME" || firstCell == "id" || firstCell == "ID" || + firstCell.Contains("配置ID") || firstCell.Contains("DialogInfoID")) + { + return row; + } + } + return -1; + } + + /// + /// 判断是否为DialogInfo Sheet + /// + private bool IsDialogInfoSheet(string sheetName) + { + var lowerName = sheetName.ToLower(); + return lowerName.Contains("dialog") && + !lowerName.Contains("bystage") && + !lowerName.Contains("index"); + } + + /// + /// 解析布尔值 + /// + private bool ParseBool(string value) + { + if (bool.TryParse(value, out var result)) + return result; + + var lower = value.ToLower().Trim(); + return lower == "1" || lower == "是" || lower == "yes" || lower == "true" || + lower.Contains("true") || lower.Contains("是"); + } + + /// + /// 解析Vector2(支持"x,y"或"(x,y)"格式) + /// + private Vector2 ParseVector2(string str) + { + str = str.Trim('(', ')', '(', ')'); + var parts = str.Split(new[] { ',', ',', '|' }, StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length >= 2) + { + if (float.TryParse(parts[0].Trim(), out var x) && + float.TryParse(parts[1].Trim(), out var y)) + { + return new Vector2(x, y); + } + } + + return Vector2.zero; + } + + // ═════════════════════════════════════════════════════════════════ + // ActivityDialogInfoByStage 解析 + // ═════════════════════════════════════════════════════════════════ + + /// + /// 解析ActivityDialogInfoByStage索引表 + /// + public DialogInfoByStageConfig ParseByStageConfig(string xlsmPath) + { + var config = ScriptableObject.CreateInstance(); + config.SourceExcelPath = xlsmPath; + + if (!File.Exists(xlsmPath)) + { + Debug.LogError($"[DialogInfoParser] 文件不存在: {xlsmPath}"); + return config; + } + + try + { + using (var stream = File.Open(xlsmPath, FileMode.Open, FileAccess.Read)) + { + using (var reader = ExcelReaderFactory.CreateReader(stream)) + { + var dataset = reader.AsDataSet(); + + // 查找ActivityDialogInfoByStage Sheet + DataTable table = null; + foreach (DataTable t in dataset.Tables) + { + if (IsByStageSheet(t.TableName)) + { + table = t; + break; + } + } + + if (table == null) + { + Debug.LogWarning("[DialogInfoParser] 未找到ActivityDialogInfoByStage Sheet"); + return config; + } + + Debug.Log($"[DialogInfoParser] 找到Sheet: {table.TableName}"); + ParseByStageTable(table, config); + } + } + } + catch (Exception ex) + { + Debug.LogError($"[DialogInfoParser] 解析索引表错误: {ex.Message}"); + } + + return config; + } + + /// + /// 解析索引表 + /// + private void ParseByStageTable(DataTable table, DialogInfoByStageConfig config) + { + int headerRow = FindHeaderRow(table); + if (headerRow < 0) + { + Debug.LogError("[DialogInfoParser] 索引表未找到表头行"); + return; + } + + // 读取字段映射 + var fieldMap = ReadFieldMapping(table, headerRow); + + // 从第headerRow+3行开始读取 + int dataStartRow = headerRow + 3; + + for (int row = dataStartRow; row < table.Rows.Count; row++) + { + var entry = ParseByStageRow(table.Rows[row], fieldMap); + if (entry != null && entry.StageID > 0) + { + config.Entries.Add(entry); + } + } + + Debug.Log($"[DialogInfoParser] 索引表解析了 {config.Entries.Count} 条映射"); + } + + /// + /// 解析单条索引条目 + /// + private DialogInfoByStageEntry ParseByStageRow(DataRow rowData, Dictionary fieldMap) + { + var entry = new DialogInfoByStageEntry(); + bool hasData = false; + + foreach (var kvp in fieldMap) + { + var fieldName = kvp.Key.ToLower(); + var colIndex = kvp.Value; + + if (colIndex >= rowData.Table.Columns.Count) + continue; + + var cellValue = rowData[colIndex]?.ToString()?.Trim() ?? ""; + if (string.IsNullOrEmpty(cellValue)) + continue; + + hasData = true; + + // StageID + if (fieldName.Contains("stageid") || fieldName.Contains("关卡id") || + fieldName.Contains("玩法关卡id")) + { + if (int.TryParse(cellValue, out var stageId)) + entry.StageID = stageId; + } + // DialogInfoSheetName + else if (fieldName.Contains("sheet") || fieldName.Contains("表名") || + fieldName.Contains("文本配置表") || fieldName.Contains("dialoginfosheetname")) + { + entry.DialogInfoSheetName = cellValue; + } + // Doc + else if (fieldName == "doc" || fieldName == "说明" || fieldName == "备注") + { + entry.Doc = cellValue; + } + } + + return hasData ? entry : null; + } + + /// + /// 判断是否为ByStage索引Sheet + /// + private bool IsByStageSheet(string sheetName) + { + var lowerName = sheetName.ToLower(); + return (lowerName.Contains("dialog") && lowerName.Contains("stage")) || + lowerName.Contains("activitydialoginfobystage") || + lowerName.Contains("dialoginfobystage"); + } + + // ═════════════════════════════════════════════════════════════════ + // 资产创建方法 + // ═════════════════════════════════════════════════════════════════ + + /// + /// 创建DialogInfoConfig资产 + /// + public DialogInfoConfig CreateConfigAsset(DialogInfoParseResult result, string outputPath) + { + var config = ScriptableObject.CreateInstance(); + config.SheetName = result.SheetName; + config.Dialogs = result.Dialogs; + + #if UNITY_EDITOR + UnityEditor.AssetDatabase.CreateAsset(config, outputPath); + UnityEditor.AssetDatabase.SaveAssets(); + #endif + + Debug.Log($"[DialogInfoParser] 创建资产: {outputPath}"); + return config; + } + + /// + /// 创建DialogInfoByStageConfig资产 + /// + public void SaveByStageAsset(DialogInfoByStageConfig config, string outputPath) + { + #if UNITY_EDITOR + UnityEditor.AssetDatabase.CreateAsset(config, outputPath); + UnityEditor.AssetDatabase.SaveAssets(); + Debug.Log($"[DialogInfoParser] 创建索引表资产: {outputPath}"); + #endif + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/DialogInfoParser.cs.meta b/Assets/BP_Scripts/GameplayEditor/Excel/DialogInfoParser.cs.meta new file mode 100644 index 0000000..0848aee --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/DialogInfoParser.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e8ed28434e8aeb446ae76e5ae5d3743e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/DialogLocalizationExporter.cs b/Assets/BP_Scripts/GameplayEditor/Excel/DialogLocalizationExporter.cs new file mode 100644 index 0000000..fc0b35b --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/DialogLocalizationExporter.cs @@ -0,0 +1,271 @@ +using System.Collections.Generic; +using System.Data; +using System.IO; +using GameplayEditor.Config; +using GameplayEditor.Runtime; +using UnityEngine; + +namespace GameplayEditor.Excel +{ + /// + /// 对话本地化导出工具 + /// 将DialogInfo导出为Excel供本地化团队翻译 + /// + public class DialogLocalizationExporter + { + /// + /// 导出所有对话到Excel + /// + public void ExportToExcel(DialogInfoDatabase database, string outputPath, + List targetLanguages = null) + { + if (database == null) + { + Debug.LogError("[DialogLocalizationExporter] 数据库为空"); + return; + } + + if (targetLanguages == null) + { + targetLanguages = new List + { + LanguageType.English, + LanguageType.Japanese, + LanguageType.Korean + }; + } + + try + { + // 创建DataSet + var dataSet = new DataSet(); + + foreach (var config in database.Configs) + { + var table = CreateLocalizationTable(config, targetLanguages); + dataSet.Tables.Add(table); + } + + // 导出到Excel + ExportDataSetToExcel(dataSet, outputPath); + + Debug.Log($"[DialogLocalizationExporter] 导出完成: {outputPath}"); + } + catch (System.Exception ex) + { + Debug.LogError($"[DialogLocalizationExporter] 导出失败: {ex.Message}"); + } + } + + /// + /// 创建本地化表 + /// + private DataTable CreateLocalizationTable(DialogInfoConfig config, + List targetLanguages) + { + var table = new DataTable(config.SheetName); + + // 添加列 + table.Columns.Add("DialogID", typeof(int)); + table.Columns.Add("CharacterName", typeof(string)); + table.Columns.Add("SourceText", typeof(string)); + table.Columns.Add("SourceLanguage", typeof(string)); + + foreach (var lang in targetLanguages) + { + table.Columns.Add(lang.ToString(), typeof(string)); + table.Columns.Add($"{lang}_Status", typeof(string)); + } + + table.Columns.Add("Notes", typeof(string)); + + // 添加数据行 + foreach (var dialog in config.Dialogs) + { + var row = table.NewRow(); + row["DialogID"] = dialog.ID; + row["CharacterName"] = dialog.CharacterName; + row["SourceText"] = dialog.Text; + row["SourceLanguage"] = "Chinese"; + + foreach (var lang in targetLanguages) + { + row[lang.ToString()] = ""; + row[$"{lang}_Status"] = "ToTranslate"; + } + + row["Notes"] = dialog.Params1 ?? ""; + + table.Rows.Add(row); + } + + return table; + } + + /// + /// 导出DataSet到Excel + /// + private void ExportDataSetToExcel(DataSet dataSet, string outputPath) + { + // 使用NPOI导出 + using (var workbook = new NPOI.XSSF.UserModel.XSSFWorkbook()) + { + foreach (DataTable table in dataSet.Tables) + { + var sheet = workbook.CreateSheet(table.TableName); + + // 创建表头样式 + var headerStyle = workbook.CreateCellStyle(); + headerStyle.FillForegroundColor = NPOI.SS.UserModel.IndexedColors.Grey25Percent.Index; + headerStyle.FillPattern = NPOI.SS.UserModel.FillPattern.SolidForeground; + + // 写入表头 + var headerRow = sheet.CreateRow(0); + for (int i = 0; i < table.Columns.Count; i++) + { + var cell = headerRow.CreateCell(i); + cell.SetCellValue(table.Columns[i].ColumnName); + cell.CellStyle = headerStyle; + } + + // 写入数据 + for (int i = 0; i < table.Rows.Count; i++) + { + var dataRow = sheet.CreateRow(i + 1); + for (int j = 0; j < table.Columns.Count; j++) + { + var value = table.Rows[i][j]; + dataRow.CreateCell(j).SetCellValue(value?.ToString() ?? ""); + } + } + + // 自动调整列宽 + for (int i = 0; i < table.Columns.Count; i++) + { + sheet.AutoSizeColumn(i); + } + } + + // 保存文件 + using (var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write)) + { + workbook.Write(fileStream); + } + } + } + + /// + /// 导入翻译后的Excel + /// + public void ImportFromExcel(string excelPath, DialogInfoDatabase database) + { + try + { + using (var stream = new FileStream(excelPath, FileMode.Open, FileAccess.Read)) + { + var workbook = new NPOI.XSSF.UserModel.XSSFWorkbook(stream); + + for (int i = 0; i < workbook.NumberOfSheets; i++) + { + var sheet = workbook.GetSheetAt(i); + string sheetName = sheet.SheetName; + + // 查找对应配置 + var config = database.Configs.Find(c => c.SheetName == sheetName); + if (config == null) continue; + + ImportSheet(sheet, config); + } + } + + Debug.Log($"[DialogLocalizationExporter] 导入完成: {excelPath}"); + } + catch (System.Exception ex) + { + Debug.LogError($"[DialogLocalizationExporter] 导入失败: {ex.Message}"); + } + } + + /// + /// 导入Sheet数据 + /// + private void ImportSheet(NPOI.SS.UserModel.ISheet sheet, DialogInfoConfig config) + { + // 获取表头 + var headerRow = sheet.GetRow(0); + if (headerRow == null) return; + + var headers = new List(); + for (int i = 0; i < headerRow.LastCellNum; i++) + { + headers.Add(headerRow.GetCell(i)?.ToString() ?? ""); + } + + // 解析数据行 + for (int i = 1; i <= sheet.LastRowNum; i++) + { + var row = sheet.GetRow(i); + if (row == null) continue; + + // 获取DialogID + int dialogId = 0; + var idCell = row.GetCell(0); + if (idCell != null && int.TryParse(idCell.ToString(), out dialogId)) + { + // 查找对话 + var dialog = config.FindByID(dialogId); + if (dialog != null) + { + // TODO: 更新翻译文本 + // 这里需要根据实际存储方式更新 + } + } + } + } + + /// + /// 检查缺失翻译 + /// + public List CheckMissingTranslations(DialogInfoDatabase database, + List targetLanguages) + { + var missingList = new List(); + + foreach (var config in database.Configs) + { + foreach (var dialog in config.Dialogs) + { + foreach (var lang in targetLanguages) + { + // TODO: 检查是否有翻译 + bool hasTranslation = false; + + if (!hasTranslation) + { + missingList.Add(new MissingTranslationInfo + { + DialogId = dialog.ID, + SheetName = config.SheetName, + SourceText = dialog.Text, + TargetLanguage = lang + }); + } + } + } + } + + return missingList; + } + } + + /// + /// 缺失翻译信息 + /// + public class MissingTranslationInfo + { + public int DialogId; + public string SheetName; + public string SourceText; + public LanguageType TargetLanguage; + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/DialogLocalizationExporter.cs.meta b/Assets/BP_Scripts/GameplayEditor/Excel/DialogLocalizationExporter.cs.meta new file mode 100644 index 0000000..4c044f3 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/DialogLocalizationExporter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 25cdd41ad41e35849b329b2771f1328d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/EventBuilderParser.cs b/Assets/BP_Scripts/GameplayEditor/Excel/EventBuilderParser.cs new file mode 100644 index 0000000..6b79a39 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/EventBuilderParser.cs @@ -0,0 +1,468 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using ExcelDataReader; +using GameplayEditor.Config; +using GameplayEditor.Utils; +using UnityEngine; +using EventType = GameplayEditor.Config.EventType; + +namespace GameplayEditor.Excel +{ + /// + /// 活动关卡事件构建表解析器 + /// 解析两个子表:初始化配置列表 + 事件Action配置列表 + /// + public class EventBuilderParser + { + private RowIndexGenerator _rowIndexGenerator; + + public EventBuilderParser() + { + _rowIndexGenerator = new RowIndexGenerator(); + } + + /// + /// 从Excel解析事件构建配置 + /// + public EventBuilderData ParseFromExcel(string xlsmPath) + { + var result = new EventBuilderData(); + _rowIndexGenerator.Reset(); + + if (!File.Exists(xlsmPath)) + { + Debug.LogError($"[EventBuilderParser] 文件不存在: {xlsmPath}"); + return result; + } + + try + { + using (var stream = File.Open(xlsmPath, FileMode.Open, FileAccess.Read)) + { + using (var reader = ExcelReaderFactory.CreateReader(stream)) + { + var dataset = reader.AsDataSet(); + + // 找活动关卡事件构建表 + DataTable eventTable = null; + foreach (DataTable table in dataset.Tables) + { + if (table.TableName.Contains("活动关卡事件构建") || + table.TableName.Contains("EventBuilder") || + table.TableName.Contains("事件构建")) + { + eventTable = table; + break; + } + } + + if (eventTable == null) + { + Debug.LogWarning("[EventBuilderParser] 未找到活动关卡事件构建表"); + return result; + } + + Debug.Log($"[EventBuilderParser] 找到Sheet: {eventTable.TableName}"); + ParseEventBuilderTable(eventTable, result); + + // 查找独立的 EventMapping 表 + DataTable mappingTable = null; + foreach (DataTable table in dataset.Tables) + { + if (table.TableName.Contains("EventMapping") || + table.TableName.Contains("事件映射")) + { + mappingTable = table; + break; + } + } + + if (mappingTable != null) + { + Debug.Log($"[EventBuilderParser] 找到EventMapping Sheet: {mappingTable.TableName}"); + var mappingConfig = ScriptableObject.CreateInstance(); + ParseEventMappingTable(mappingTable, mappingConfig); + result.EventMappingConfig = mappingConfig; + } + else + { + // 没有独立映射表时,基于 EventTypeGroup 自动生成默认映射 + result.EventMappingConfig = result.BuildDefaultMappings(); + Debug.Log($"[EventBuilderParser] 未找到EventMapping表,已自动生成默认映射,共 {result.EventMappingConfig.Entries.Count} 条"); + } + } + } + } + catch (Exception ex) + { + Debug.LogError($"[EventBuilderParser] 解析错误: {ex.Message}\n{ex.StackTrace}"); + } + + return result; + } + + /// + /// 解析 EventMapping 表 + /// 预期格式: MappingID | EventID1 | EventID2 | EventID3 | ... | Doc + /// + private void ParseEventMappingTable(DataTable table, EventMappingConfig config) + { + // 查找表头行(第一列包含 MappingID 的行) + int headerRow = -1; + for (int i = 0; i < Math.Min(5, table.Rows.Count); i++) + { + var firstCell = table.Rows[i][0]?.ToString()?.Trim() ?? ""; + if (firstCell.Contains("MappingID") || firstCell.Contains("映射ID")) + { + headerRow = i; + break; + } + } + if (headerRow < 0) return; + + // 读取表头,确定 EventID 列索引 + var eventIdCols = new List(); + for (int c = 1; c < table.Columns.Count; c++) + { + var cell = table.Rows[headerRow][c]?.ToString()?.Trim() ?? ""; + if (cell.Contains("EventID") || cell.Contains("事件ID") || int.TryParse(cell, out _)) + { + eventIdCols.Add(c); + } + } + + int dataStartRow = headerRow + 1; + for (int r = dataStartRow; r < table.Rows.Count; r++) + { + var row = table.Rows[r]; + var mappingIdStr = row[0]?.ToString()?.Trim() ?? ""; + if (!int.TryParse(mappingIdStr, out var mappingId) || mappingId <= 0) + continue; + + var entry = new EventMappingEntry + { + MappingID = mappingId + }; + + foreach (var col in eventIdCols) + { + var eventIdStr = row[col]?.ToString()?.Trim() ?? ""; + if (int.TryParse(eventIdStr, out var eventId) && eventId > 0) + { + entry.EventIDs.Add(eventId); + } + } + + // 最后一列尝试读取 Doc + if (table.Columns.Count > eventIdCols.Count + 1) + { + var doc = row[table.Columns.Count - 1]?.ToString()?.Trim() ?? ""; + if (!string.IsNullOrEmpty(doc)) entry.Doc = doc; + } + + if (entry.EventIDs.Count > 0) + { + config.AddOrUpdateEntry(entry); + } + } + + Debug.Log($"[EventBuilderParser] EventMapping 解析完成: {config.Entries.Count} 条映射"); + } + + /// + /// 解析事件构建表(包含两个子表) + /// + private void ParseEventBuilderTable(DataTable table, EventBuilderData result) + { + int currentRow = 0; + + // 查找第一个子表:初始化配置列表 + while (currentRow < table.Rows.Count) + { + var rowText = GetRowText(table.Rows[currentRow]); + + if (rowText.Contains("初始化配置列表") || rowText.Contains("LevelID")) + { + Debug.Log($"[EventBuilderParser] 找到初始化配置列表,行号: {currentRow + 1}"); + currentRow = ParseNodeConfigs(table, currentRow, result.NodeConfigs); + } + else if (rowText.Contains("事件Action配置列表") || rowText.Contains("EventID")) + { + Debug.Log($"[EventBuilderParser] 找到事件Action配置列表,行号: {currentRow + 1}"); + currentRow = ParseActionConfigs(table, currentRow, result.ActionConfigs); + } + else + { + currentRow++; + } + } + + Debug.Log($"[EventBuilderParser] 解析完成: {result.NodeConfigs.Count} 个节点, {result.ActionConfigs.Count} 个事件"); + } + + /// + /// 解析节点配置(初始化配置列表) + /// + private int ParseNodeConfigs(DataTable table, int startRow, List configs) + { + // 查找表头行(NAME行) + int headerRow = startRow; + for (int i = 0; i < 5 && (startRow + i) < table.Rows.Count; i++) + { + var rowText = GetRowText(table.Rows[startRow + i]); + if (rowText.Contains("NAME") || rowText.Contains("LevelID")) + { + headerRow = startRow + i; + break; + } + } + + // 读取字段名 + var fieldNames = new Dictionary(); + for (int col = 0; col < table.Columns.Count; col++) + { + var cellValue = table.Rows[headerRow][col]?.ToString()?.Trim() ?? ""; + if (!string.IsNullOrEmpty(cellValue)) + { + fieldNames[col] = cellValue; + } + } + + // 从第headerRow+3行开始读取数据 + int currentRow = headerRow + 3; + + while (currentRow < table.Rows.Count) + { + var rowData = table.Rows[currentRow]; + + // 检查是否是下一个子表的开始 + var rowText = GetRowText(rowData); + if (rowText.Contains("事件Action") || rowText.Contains("EventAction")) + { + break; + } + + // 解析节点配置 + var config = ParseNodeConfigRow(rowData, fieldNames); + if (config != null && config.NodeID > 0) + { + config.RowIndex = _rowIndexGenerator.GenerateNext(); + configs.Add(config); + } + + currentRow++; + } + + return currentRow; + } + + /// + /// 解析单个节点配置行 + /// + private EventNodeConfig ParseNodeConfigRow(DataRow rowData, Dictionary fieldNames) + { + var config = new EventNodeConfig(); + bool hasData = false; + + foreach (var kvp in fieldNames) + { + var col = kvp.Key; + var fieldName = kvp.Value; + var cellValue = rowData[col]?.ToString()?.Trim() ?? ""; + + if (string.IsNullOrEmpty(cellValue)) continue; + + switch (fieldName) + { + case "LevelID": + case "关卡章节ID": + if (int.TryParse(cellValue, out var levelId)) + config.LevelID = levelId; + break; + case "NodeID": + case "节点ID": + if (int.TryParse(cellValue, out var nodeId)) + { + config.NodeID = nodeId; + hasData = true; + } + break; + case "NodeLayer": + case "节点层级": + if (int.TryParse(cellValue, out var layer)) + config.NodeLayer = layer; + break; + case "EventTypeGroup": + case "节点池类型组": + config.EventTypeGroup = ArrayParser.ParseIntArray(cellValue); + break; + case "Priority": + case "类型权重": + config.Priority = ArrayParser.ParseIntArray(cellValue); + break; + case "EventMappingID": + case "存储事件ID映射": + if (int.TryParse(cellValue, out var mappingId)) + config.EventMappingID = mappingId; + break; + case "InteractVisible": + case "交互点是否可见": + config.InteractVisible = ParseBool(cellValue); + break; + case "NodeState": + case "节点是否可交互": + config.NodeState = ParseBool(cellValue); + break; + } + } + + return hasData ? config : null; + } + + /// + /// 解析事件Action配置 + /// + private int ParseActionConfigs(DataTable table, int startRow, List configs) + { + // 查找表头行 + int headerRow = startRow; + for (int i = 0; i < 5 && (startRow + i) < table.Rows.Count; i++) + { + var rowText = GetRowText(table.Rows[startRow + i]); + if (rowText.Contains("NAME") || rowText.Contains("EventID")) + { + headerRow = startRow + i; + break; + } + } + + // 读取字段名 + var fieldNames = new Dictionary(); + for (int col = 0; col < table.Columns.Count; col++) + { + var cellValue = table.Rows[headerRow][col]?.ToString()?.Trim() ?? ""; + if (!string.IsNullOrEmpty(cellValue)) + { + fieldNames[col] = cellValue; + } + } + + // 从第headerRow+3行开始读取数据 + int currentRow = headerRow + 3; + + while (currentRow < table.Rows.Count) + { + var rowData = table.Rows[currentRow]; + + // 检查是否为空行或结束 + if (IsEmptyRow(rowData)) + { + currentRow++; + continue; + } + + // 解析事件配置 + var config = ParseActionConfigRow(rowData, fieldNames); + if (config != null && config.EventID > 0) + { + configs.Add(config); + } + + currentRow++; + } + + return currentRow; + } + + /// + /// 解析单个事件Action配置行 + /// + private EventActionConfig ParseActionConfigRow(DataRow rowData, Dictionary fieldNames) + { + var config = new EventActionConfig(); + bool hasData = false; + + foreach (var kvp in fieldNames) + { + var col = kvp.Key; + var fieldName = kvp.Value; + var cellValue = rowData[col]?.ToString()?.Trim() ?? ""; + + if (string.IsNullOrEmpty(cellValue)) continue; + + switch (fieldName) + { + case "EventID": + case "事件ID": + if (int.TryParse(cellValue, out var eventId)) + { + config.EventID = eventId; + hasData = true; + } + break; + case "EventType": + case "节点类型": + if (int.TryParse(cellValue, out var type)) + config.Type = (EventType)type; + break; + case "EventPool": + case "事件池": + if (int.TryParse(cellValue, out var pool)) + config.Pool = (EventPool)pool; + break; + case "Removed": + case "消耗完后是否移除事件池": + config.Removed = ParseBool(cellValue); + break; + } + } + + return hasData ? config : null; + } + + /// + /// 获取行文本(用于识别子表) + /// + private string GetRowText(DataRow row) + { + var texts = new List(); + for (int col = 0; col < row.Table.Columns.Count && col < 10; col++) + { + var text = row[col]?.ToString()?.Trim() ?? ""; + if (!string.IsNullOrEmpty(text)) + { + texts.Add(text); + } + } + return string.Join(" ", texts); + } + + /// + /// 检查是否为空行 + /// + private bool IsEmptyRow(DataRow row) + { + for (int col = 0; col < row.Table.Columns.Count; col++) + { + var value = row[col]?.ToString()?.Trim() ?? ""; + if (!string.IsNullOrEmpty(value)) + return false; + } + return true; + } + + /// + /// 解析布尔值 + /// + private bool ParseBool(string value) + { + if (bool.TryParse(value, out var result)) + return result; + + var lower = value.ToLower(); + return lower == "true" || lower == "1" || lower == "是" || lower == "yes" || lower.Contains("true"); + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/EventBuilderParser.cs.meta b/Assets/BP_Scripts/GameplayEditor/Excel/EventBuilderParser.cs.meta new file mode 100644 index 0000000..9fab1da --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/EventBuilderParser.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6ed23aec1262c1946891492c04de69de +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/ExcelFileLocker.cs b/Assets/BP_Scripts/GameplayEditor/Excel/ExcelFileLocker.cs new file mode 100644 index 0000000..eea5df8 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/ExcelFileLocker.cs @@ -0,0 +1,167 @@ +using System.IO; +using UnityEngine; + +namespace GameplayEditor.Excel +{ + /// + /// Excel文件锁定管理器 + /// 防止多人同时编辑同一个Excel文件 + /// + public static class ExcelFileLocker + { + /// + /// 尝试锁定文件 + /// + /// Excel文件路径 + /// 用户名 + /// 是否成功锁定 + public static bool TryLockFile(string excelPath, string userName) + { + if (string.IsNullOrEmpty(excelPath)) + return false; + + string lockFilePath = GetLockFilePath(excelPath); + + // 检查是否已被锁定 + if (File.Exists(lockFilePath)) + { + var existingLock = File.ReadAllText(lockFilePath); + if (existingLock.StartsWith(userName + "|")) + { + // 自己锁定的,允许继续 + return true; + } + else + { + // 被其他人锁定 + var parts = existingLock.Split('|'); + string lockedBy = parts.Length > 0 ? parts[0] : "unknown"; + string lockTime = parts.Length > 1 ? parts[1] : "unknown"; + Debug.LogWarning($"[ExcelFileLocker] 文件已被 {lockedBy} 锁定 (时间: {lockTime})"); + return false; + } + } + + // 创建锁定文件 + try + { + var lockContent = $"{userName}|{System.DateTime.Now:yyyy-MM-dd HH:mm:ss}|{System.Environment.MachineName}"; + File.WriteAllText(lockFilePath, lockContent); + Debug.Log($"[ExcelFileLocker] 文件已锁定: {excelPath}"); + return true; + } + catch (System.Exception ex) + { + Debug.LogError($"[ExcelFileLocker] 锁定失败: {ex.Message}"); + return false; + } + } + + /// + /// 解锁文件 + /// + public static void UnlockFile(string excelPath, string userName) + { + if (string.IsNullOrEmpty(excelPath)) + return; + + string lockFilePath = GetLockFilePath(excelPath); + + if (!File.Exists(lockFilePath)) + return; + + var existingLock = File.ReadAllText(lockFilePath); + if (existingLock.StartsWith(userName + "|")) + { + try + { + File.Delete(lockFilePath); + Debug.Log($"[ExcelFileLocker] 文件已解锁: {excelPath}"); + } + catch (System.Exception ex) + { + Debug.LogError($"[ExcelFileLocker] 解锁失败: {ex.Message}"); + } + } + else + { + Debug.LogWarning("[ExcelFileLocker] 不能解锁其他用户的锁定"); + } + } + + /// + /// 检查文件是否被锁定 + /// + public static bool IsLocked(string excelPath, out string lockedBy, out string lockTime) + { + lockedBy = null; + lockTime = null; + + if (string.IsNullOrEmpty(excelPath)) + return false; + + string lockFilePath = GetLockFilePath(excelPath); + + if (!File.Exists(lockFilePath)) + return false; + + try + { + var existingLock = File.ReadAllText(lockFilePath); + var parts = existingLock.Split('|'); + lockedBy = parts.Length > 0 ? parts[0] : "unknown"; + lockTime = parts.Length > 1 ? parts[1] : "unknown"; + return true; + } + catch + { + return false; + } + } + + /// + /// 强制解锁(管理员使用) + /// + public static void ForceUnlock(string excelPath) + { + string lockFilePath = GetLockFilePath(excelPath); + if (File.Exists(lockFilePath)) + { + File.Delete(lockFilePath); + Debug.Log($"[ExcelFileLocker] 强制解锁: {excelPath}"); + } + } + + /// + /// 清理过期的锁定文件(超过24小时) + /// + public static void CleanExpiredLocks(string folderPath) + { + if (!Directory.Exists(folderPath)) + return; + + var lockFiles = Directory.GetFiles(folderPath, "*.xlsx.lock"); + foreach (var lockFile in lockFiles) + { + try + { + var creationTime = File.GetCreationTime(lockFile); + if (System.DateTime.Now - creationTime > System.TimeSpan.FromHours(24)) + { + File.Delete(lockFile); + Debug.Log($"[ExcelFileLocker] 清理过期锁定: {lockFile}"); + } + } + catch (System.Exception ex) + { + Debug.LogError($"[ExcelFileLocker] 清理失败: {ex.Message}"); + } + } + } + + private static string GetLockFilePath(string excelPath) + { + return excelPath + ".lock"; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/ExcelFileLocker.cs.meta b/Assets/BP_Scripts/GameplayEditor/Excel/ExcelFileLocker.cs.meta new file mode 100644 index 0000000..cb1239b --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/ExcelFileLocker.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 77bf87bf6b8c12d4c80a80d9934024f5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/ExcelMergeTool.cs b/Assets/BP_Scripts/GameplayEditor/Excel/ExcelMergeTool.cs new file mode 100644 index 0000000..a826aca --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/ExcelMergeTool.cs @@ -0,0 +1,600 @@ +using NPOI.SS.UserModel; +using NPOI.XSSF.UserModel; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEngine; + +namespace GameplayEditor.Excel +{ + /// + /// Excel合并工具 + /// 将多个策划分表合并为总表,支持冲突检测和解决 + /// + public class ExcelMergeTool + { + /// + /// 合并配置 + /// + public class MergeConfig + { + public string TemplatePath; // 模板表路径(定义表头结构) + public string OutputPath; // 输出总表路径 + public List SourcePaths; // 源表路径列表 + public string SheetName; // 要合并的Sheet名 + public string IdColumnName; // 主键列名(用于检测冲突) + public bool SkipEmptyRows = true; // 跳过空行 + public bool GenerateConflictReport = true; // 生成冲突报告 + } + + /// + /// 合并结果 + /// + public class MergeResult + { + public bool Success; + public string OutputPath; + public int TotalRows; + public int MergedRows; + public int ConflictCount; + public List Conflicts = new List(); + public List Errors = new List(); + public string ReportPath; + } + + /// + /// 冲突信息 + /// + public class ConflictInfo + { + public string SourceFile1; + public string SourceFile2; + public int RowIndex1; + public int RowIndex2; + public string IdValue; + public string ConflictColumn; + public string Value1; + public string Value2; + public ConflictResolution Resolution; + } + + /// + /// 冲突解决方式 + /// + public enum ConflictResolution + { + Pending, // 待解决 + UseFirst, // 使用第一个 + UseSecond, // 使用第二个 + UseNewest, // 使用最新修改的 + ManualMerge // 手动合并 + } + + /// + /// 执行合并 + /// + public MergeResult Merge(MergeConfig config) + { + var result = new MergeResult(); + + try + { + // 验证配置 + if (!ValidateConfig(config, result)) + return result; + + // 读取模板表结构 + var templateStructure = ReadTemplateStructure(config.TemplatePath, config.SheetName); + if (templateStructure == null) + { + result.Errors.Add("无法读取模板表结构"); + return result; + } + + // 收集所有源表数据 + var allRows = new List(); + var idIndex = FindColumnIndex(templateStructure, config.SheetName, config.IdColumnName); + + foreach (var sourcePath in config.SourcePaths) + { + var rows = ReadSourceRows(sourcePath, config.SheetName, idIndex, config.SkipEmptyRows); + foreach (var row in rows) + { + row.SourceFile = Path.GetFileName(sourcePath); + allRows.Add(row); + } + } + + // 检测冲突 + var conflicts = DetectConflicts(allRows, idIndex); + result.Conflicts = conflicts; + result.ConflictCount = conflicts.Count; + + // 自动解决简单冲突 + AutoResolveConflicts(conflicts); + + // 生成合并表 + var mergedRows = MergeRows(allRows, idIndex, conflicts); + result.MergedRows = mergedRows.Count; + result.TotalRows = allRows.Count; + + // 写入输出文件 + WriteMergedExcel(config, templateStructure, mergedRows); + result.OutputPath = config.OutputPath; + result.Success = true; + + // 生成冲突报告 + if (config.GenerateConflictReport && conflicts.Count > 0) + { + result.ReportPath = GenerateConflictReport(config, conflicts); + } + + Debug.Log($"[ExcelMergeTool] 合并完成: {result.MergedRows}行数据, {result.ConflictCount}个冲突"); + } + catch (Exception ex) + { + result.Success = false; + result.Errors.Add($"合并过程异常: {ex.Message}"); + Debug.LogError($"[ExcelMergeTool] 合并失败: {ex}"); + } + + return result; + } + + /// + /// 验证配置 + /// + private bool ValidateConfig(MergeConfig config, MergeResult result) + { + if (string.IsNullOrEmpty(config.TemplatePath) || !File.Exists(config.TemplatePath)) + { + result.Errors.Add($"模板文件不存在: {config.TemplatePath}"); + return false; + } + + if (string.IsNullOrEmpty(config.OutputPath)) + { + result.Errors.Add("输出路径不能为空"); + return false; + } + + if (config.SourcePaths == null || config.SourcePaths.Count == 0) + { + result.Errors.Add("源表列表不能为空"); + return false; + } + + var validSources = config.SourcePaths.Where(p => File.Exists(p)).ToList(); + if (validSources.Count == 0) + { + result.Errors.Add("没有有效的源表文件"); + return false; + } + + config.SourcePaths = validSources; + return true; + } + + /// + /// 读取模板表结构 + /// + private IWorkbook ReadTemplateStructure(string path, string sheetName) + { + try + { + using (var file = new FileStream(path, FileMode.Open, FileAccess.Read)) + { + return new XSSFWorkbook(file); + } + } + catch (Exception ex) + { + Debug.LogError($"[ExcelMergeTool] 读取模板失败: {ex.Message}"); + return null; + } + } + + /// + /// 查找列索引 + /// + private int FindColumnIndex(IWorkbook workbook, string sheetName, string columnName) + { + var sheet = workbook.GetSheet(sheetName); + if (sheet == null) return -1; + + var nameRow = sheet.GetRow(0); + if (nameRow == null) return -1; + + for (int i = 0; i < nameRow.LastCellNum; i++) + { + var cell = nameRow.GetCell(i); + if (cell != null && cell.StringCellValue == columnName) + { + return i; + } + } + + return -1; + } + + private int FindColumnIndex(ISheet sheet, string columnName) + { + if (sheet == null) return -1; + + var nameRow = sheet.GetRow(0); + if (nameRow == null) return -1; + + for (int i = 0; i < nameRow.LastCellNum; i++) + { + var cell = nameRow.GetCell(i); + if (cell != null && cell.StringCellValue == columnName) + { + return i; + } + } + + return -1; + } + + /// + /// 源表行数据 + /// + private class SourceRow + { + public string SourceFile; + public int SourceRowIndex; + public List Values = new List(); + public DateTime LastModified; + } + + /// + /// 读取源表数据 + /// + private List ReadSourceRows(string path, string sheetName, int idIndex, bool skipEmpty) + { + var rows = new List(); + var lastModified = File.GetLastWriteTime(path); + + try + { + using (var file = new FileStream(path, FileMode.Open, FileAccess.Read)) + { + var workbook = new XSSFWorkbook(file); + var sheet = workbook.GetSheet(sheetName); + + if (sheet == null) + { + Debug.LogWarning($"[ExcelMergeTool] Sheet不存在: {sheetName} in {path}"); + return rows; + } + + // 从第4行开始读取(跳过NAME/TYPE/DOC三行表头) + for (int i = 3; i <= sheet.LastRowNum; i++) + { + var row = sheet.GetRow(i); + if (row == null) continue; + + // 检查是否为空行 + if (skipEmpty && IsEmptyRow(row)) continue; + + // 检查ID列是否有值 + var idCell = row.GetCell(idIndex); + if (idCell == null || string.IsNullOrEmpty(GetCellValue(idCell))) continue; + + var sourceRow = new SourceRow + { + SourceRowIndex = i, + LastModified = lastModified + }; + + // 读取所有列 + for (int j = 0; j < row.LastCellNum; j++) + { + var cell = row.GetCell(j); + sourceRow.Values.Add(GetCellValue(cell)); + } + + rows.Add(sourceRow); + } + + workbook.Close(); + } + } + catch (Exception ex) + { + Debug.LogError($"[ExcelMergeTool] 读取源表失败 {path}: {ex.Message}"); + } + + return rows; + } + + /// + /// 检查是否为空行 + /// + private bool IsEmptyRow(IRow row) + { + for (int i = 0; i < row.LastCellNum; i++) + { + var cell = row.GetCell(i); + if (cell != null && !string.IsNullOrEmpty(GetCellValue(cell))) + return false; + } + return true; + } + + /// + /// 获取单元格值 + /// + private string GetCellValue(ICell cell) + { + if (cell == null) return ""; + + switch (cell.CellType) + { + case CellType.String: + return cell.StringCellValue; + case CellType.Numeric: + return cell.NumericCellValue.ToString(); + case CellType.Boolean: + return cell.BooleanCellValue.ToString(); + default: + return ""; + } + } + + /// + /// 检测冲突 + /// + private List DetectConflicts(List allRows, int idIndex) + { + var conflicts = new List(); + var idGroups = allRows.GroupBy(r => r.Values.Count > idIndex ? r.Values[idIndex] : ""); + + foreach (var group in idGroups) + { + var rows = group.ToList(); + if (rows.Count < 2) continue; // 无冲突 + + // 检查每列是否一致 + var firstRow = rows[0]; + for (int i = 1; i < rows.Count; i++) + { + var otherRow = rows[i]; + + for (int col = 0; col < Math.Min(firstRow.Values.Count, otherRow.Values.Count); col++) + { + var val1 = firstRow.Values[col]; + var val2 = otherRow.Values[col]; + + // 跳过ID列的空值比较 + if (col == idIndex) continue; + + // 跳过都为空的情况 + if (string.IsNullOrEmpty(val1) && string.IsNullOrEmpty(val2)) continue; + + // 检测到差异 + if (val1 != val2) + { + conflicts.Add(new ConflictInfo + { + SourceFile1 = firstRow.SourceFile, + SourceFile2 = otherRow.SourceFile, + RowIndex1 = firstRow.SourceRowIndex, + RowIndex2 = otherRow.SourceRowIndex, + IdValue = group.Key, + ConflictColumn = $"Column_{col}", + Value1 = val1, + Value2 = val2, + Resolution = ConflictResolution.Pending + }); + } + } + } + } + + return conflicts; + } + + /// + /// 自动解决冲突 + /// + private void AutoResolveConflicts(List conflicts) + { + foreach (var conflict in conflicts) + { + // 简单策略:使用非空值 + if (string.IsNullOrEmpty(conflict.Value1)) + { + conflict.Resolution = ConflictResolution.UseSecond; + } + else if (string.IsNullOrEmpty(conflict.Value2)) + { + conflict.Resolution = ConflictResolution.UseFirst; + } + // 如果都非空,标记为待解决 + } + } + + /// + /// 合并行 + /// + private List MergeRows(List allRows, int idIndex, List conflicts) + { + var merged = new Dictionary(); + + foreach (var row in allRows) + { + var id = row.Values.Count > idIndex ? row.Values[idIndex] : ""; + if (string.IsNullOrEmpty(id)) continue; + + if (!merged.ContainsKey(id)) + { + merged[id] = row; + } + else + { + // 存在冲突,根据解决方式处理 + var existing = merged[id]; + var conflictsForRow = conflicts.Where(c => c.IdValue == id).ToList(); + + foreach (var conflict in conflictsForRow) + { + int colIndex = int.Parse(conflict.ConflictColumn.Replace("Column_", "")); + + switch (conflict.Resolution) + { + case ConflictResolution.UseSecond: + if (existing.SourceFile == conflict.SourceFile1) + { + existing.Values[colIndex] = conflict.Value2; + } + break; + case ConflictResolution.UseNewest: + if (row.LastModified > existing.LastModified) + { + merged[id] = row; + } + break; + } + } + } + } + + return merged.Values.ToList(); + } + + /// + /// 写入合并后的Excel + /// + private void WriteMergedExcel(MergeConfig config, IWorkbook templateWorkbook, List mergedRows) + { + // 复制模板 + var outputWorkbook = new XSSFWorkbook(); + var templateSheet = templateWorkbook.GetSheet(config.SheetName); + + // 创建输出Sheet + var outputSheet = outputWorkbook.CreateSheet(config.SheetName); + + // 复制表头(前3行) + for (int i = 0; i < 3 && i <= templateSheet.LastRowNum; i++) + { + var templateRow = templateSheet.GetRow(i); + if (templateRow == null) continue; + + var outputRow = outputSheet.CreateRow(i); + CopyRow(templateRow, outputRow); + } + + // 写入数据行 + int outputRowIndex = 3; + foreach (var row in mergedRows) + { + var outputRow = outputSheet.CreateRow(outputRowIndex++); + + for (int i = 0; i < row.Values.Count; i++) + { + var cell = outputRow.CreateCell(i); + cell.SetCellValue(row.Values[i]); + } + } + + // 保存文件 + var dir = Path.GetDirectoryName(config.OutputPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + using (var outFile = new FileStream(config.OutputPath, FileMode.Create, FileAccess.Write)) + { + outputWorkbook.Write(outFile); + } + + outputWorkbook.Close(); + templateWorkbook.Close(); + + Debug.Log($"[ExcelMergeTool] 已写入合并文件: {config.OutputPath}"); + } + + /// + /// 复制行 + /// + private void CopyRow(IRow source, IRow target) + { + for (int i = 0; i < source.LastCellNum; i++) + { + var sourceCell = source.GetCell(i); + if (sourceCell == null) continue; + + var targetCell = target.CreateCell(i); + + switch (sourceCell.CellType) + { + case CellType.String: + targetCell.SetCellValue(sourceCell.StringCellValue); + break; + case CellType.Numeric: + targetCell.SetCellValue(sourceCell.NumericCellValue); + break; + case CellType.Boolean: + targetCell.SetCellValue(sourceCell.BooleanCellValue); + break; + } + } + } + + /// + /// 生成冲突报告 + /// + private string GenerateConflictReport(MergeConfig config, List conflicts) + { + var reportPath = config.OutputPath + ".conflict_report.txt"; + var sb = new System.Text.StringBuilder(); + + sb.AppendLine("╔════════════════════════════════════════════════════════════════╗"); + sb.AppendLine("║ Excel合并冲突报告 ║"); + sb.AppendLine("╚════════════════════════════════════════════════════════════════╝"); + sb.AppendLine(); + sb.AppendLine($"生成时间: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine($"输出文件: {config.OutputPath}"); + sb.AppendLine($"冲突总数: {conflicts.Count}"); + sb.AppendLine(); + + var pendingConflicts = conflicts.Where(c => c.Resolution == ConflictResolution.Pending).ToList(); + var autoResolved = conflicts.Where(c => c.Resolution != ConflictResolution.Pending).ToList(); + + if (autoResolved.Count > 0) + { + sb.AppendLine("══════════════════════════════════════════════════════════════════"); + sb.AppendLine($"【自动解决的冲突】({autoResolved.Count}个)"); + sb.AppendLine("══════════════════════════════════════════════════════════════════"); + foreach (var c in autoResolved) + { + sb.AppendLine($"ID={c.IdValue}, 列={c.ConflictColumn}, 方案={c.Resolution}"); + } + sb.AppendLine(); + } + + if (pendingConflicts.Count > 0) + { + sb.AppendLine("══════════════════════════════════════════════════════════════════"); + sb.AppendLine($"【需要手动解决的冲突】({pendingConflicts.Count}个)"); + sb.AppendLine("══════════════════════════════════════════════════════════════════"); + foreach (var c in pendingConflicts) + { + sb.AppendLine($"冲突ID: {c.IdValue}"); + sb.AppendLine($" 文件1: {c.SourceFile1} (行{c.RowIndex1})"); + sb.AppendLine($" 值: {c.Value1}"); + sb.AppendLine($" 文件2: {c.SourceFile2} (行{c.RowIndex2})"); + sb.AppendLine($" 值: {c.Value2}"); + sb.AppendLine($" 列: {c.ConflictColumn}"); + sb.AppendLine(); + } + } + + File.WriteAllText(reportPath, sb.ToString()); + return reportPath; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/ExcelMergeTool.cs.meta b/Assets/BP_Scripts/GameplayEditor/Excel/ExcelMergeTool.cs.meta new file mode 100644 index 0000000..789b4a5 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/ExcelMergeTool.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 189bb1a309f71554786808d7980efc23 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/ExcelTemplateSync.cs b/Assets/BP_Scripts/GameplayEditor/Excel/ExcelTemplateSync.cs new file mode 100644 index 0000000..bf9ba8b --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/ExcelTemplateSync.cs @@ -0,0 +1,376 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using System.Linq; +using ExcelDataReader; +using NPOI.SS.UserModel; +using NPOI.XSSF.UserModel; +using UnityEngine; + +namespace GameplayEditor.Excel +{ + /// + /// Excel表头列定义 + /// + [Serializable] + public class ColumnDefinition + { + public string ColumnName; // 列名 (如 LevelID) + public string ColumnType; // 类型 (如 int, string) + public string Description; // 注释/说明 + public int ColumnIndex; // 列索引 + + public ColumnDefinition(string name, string type, string desc, int index) + { + ColumnName = name; + ColumnType = type; + Description = desc; + ColumnIndex = index; + } + } + + /// + /// Sheet结构定义 + /// + [Serializable] + public class SheetStructure + { + public string SheetName; + public List Columns = new List(); + public List HeaderRows = new List(); // 表头行的原始内容 + public DateTime LastSyncTime; + + /// + /// 查找列定义 + /// + public ColumnDefinition FindColumn(string name) + { + return Columns.Find(c => c.ColumnName == name); + } + + /// + /// 验证与另一个结构是否一致 + /// + public bool ValidateConsistency(SheetStructure other, out List differences) + { + differences = new List(); + + if (other == null) + { + differences.Add("目标结构为空"); + return false; + } + + // 检查列数 + if (Columns.Count != other.Columns.Count) + { + differences.Add($"列数不一致: 模板{Columns.Count}列 vs 目标{other.Columns.Count}列"); + } + + // 检查每列 + foreach (var col in Columns) + { + var otherCol = other.FindColumn(col.ColumnName); + if (otherCol == null) + { + differences.Add($"目标缺少列: {col.ColumnName}"); + } + else if (col.ColumnType != otherCol.ColumnType) + { + differences.Add($"列[{col.ColumnName}]类型不一致: {col.ColumnType} vs {otherCol.ColumnType}"); + } + } + + return differences.Count == 0; + } + } + + /// + /// Excel模板同步工具 + /// 用于多策划表管理,同步表头结构和注释 + /// + public class ExcelTemplateSync + { + /// + /// 从Excel文件读取Sheet结构 + /// + public SheetStructure ReadSheetStructure(string excelPath, string sheetName) + { + if (!File.Exists(excelPath)) + { + Debug.LogError($"[ExcelTemplateSync] 文件不存在: {excelPath}"); + return null; + } + + try + { + using (var stream = File.Open(excelPath, FileMode.Open, FileAccess.Read)) + { + using (var reader = ExcelReaderFactory.CreateReader(stream)) + { + var dataset = reader.AsDataSet(); + var table = dataset.Tables[sheetName]; + + if (table == null) + { + Debug.LogError($"[ExcelTemplateSync] Sheet不存在: {sheetName}"); + return null; + } + + return ParseSheetStructure(table, sheetName); + } + } + } + catch (Exception ex) + { + Debug.LogError($"[ExcelTemplateSync] 读取错误: {ex.Message}"); + return null; + } + } + + /// + /// 获取Excel中的所有Sheet名称 + /// + public List GetSheetNames(string excelPath) + { + var names = new List(); + + if (!File.Exists(excelPath)) + return names; + + try + { + using (var stream = File.Open(excelPath, FileMode.Open, FileAccess.Read)) + { + using (var reader = ExcelReaderFactory.CreateReader(stream)) + { + var dataset = reader.AsDataSet(); + foreach (DataTable table in dataset.Tables) + { + names.Add(table.TableName); + } + } + } + } + catch (Exception ex) + { + Debug.LogError($"[ExcelTemplateSync] 获取Sheet名错误: {ex.Message}"); + } + + return names; + } + + /// + /// 解析Sheet结构 + /// 假设标准格式: + /// 第1行: NAME行 (列名) + /// 第2行: TYPE行 (类型) + /// 第3行: DOC行 (注释) + /// + private SheetStructure ParseSheetStructure(DataTable table, string sheetName) + { + var structure = new SheetStructure + { + SheetName = sheetName, + LastSyncTime = DateTime.Now + }; + + if (table.Rows.Count < 3) + { + Debug.LogWarning($"[ExcelTemplateSync] Sheet {sheetName} 行数不足3行,无法解析结构"); + return structure; + } + + // 读取NAME行 (第1行) + var nameRow = table.Rows[0]; + // 读取TYPE行 (第2行) + var typeRow = table.Rows[1]; + // 读取DOC行 (第3行) + var docRow = table.Rows[2]; + + // 保存原始表头内容 + structure.HeaderRows.Add(string.Join("|", nameRow.ItemArray)); + structure.HeaderRows.Add(string.Join("|", typeRow.ItemArray)); + structure.HeaderRows.Add(string.Join("|", docRow.ItemArray)); + + // 解析每列 + for (int col = 0; col < table.Columns.Count; col++) + { + var name = nameRow[col]?.ToString()?.Trim() ?? ""; + var type = typeRow[col]?.ToString()?.Trim() ?? ""; + var doc = docRow[col]?.ToString()?.Trim() ?? ""; + + // 只记录有列名的列 + if (!string.IsNullOrEmpty(name)) + { + structure.Columns.Add(new ColumnDefinition(name, type, doc, col)); + } + } + + Debug.Log($"[ExcelTemplateSync] 解析Sheet结构: {sheetName}, 共{structure.Columns.Count}列"); + return structure; + } + + /// + /// 同步表头到目标Excel + /// + public bool SyncToTarget(SheetStructure templateStructure, string targetExcelPath, string targetSheetName, bool syncDescriptionOnly = false) + { + if (templateStructure == null) + { + Debug.LogError("[ExcelTemplateSync] 模板结构为空"); + return false; + } + + if (!File.Exists(targetExcelPath)) + { + Debug.LogError($"[ExcelTemplateSync] 目标文件不存在: {targetExcelPath}"); + return false; + } + + try + { + // 使用NPOI打开目标文件 + IWorkbook workbook; + using (var file = new FileStream(targetExcelPath, FileMode.Open, FileAccess.Read)) + { + workbook = new XSSFWorkbook(file); + } + + // 获取或创建Sheet + ISheet sheet = workbook.GetSheet(targetSheetName); + if (sheet == null) + { + sheet = workbook.CreateSheet(targetSheetName); + Debug.Log($"[ExcelTemplateSync] 创建新Sheet: {targetSheetName}"); + } + + // 确保至少有3行 + while (sheet.LastRowNum < 2) + { + sheet.CreateRow(sheet.LastRowNum + 1); + } + + // 获取或创建NAME、TYPE、DOC行 + var nameRow = sheet.GetRow(0) ?? sheet.CreateRow(0); + var typeRow = sheet.GetRow(1) ?? sheet.CreateRow(1); + var docRow = sheet.GetRow(2) ?? sheet.CreateRow(2); + + // 同步表头 + foreach (var col in templateStructure.Columns) + { + // 确保单元格存在 + var nameCell = nameRow.GetCell(col.ColumnIndex) ?? nameRow.CreateCell(col.ColumnIndex); + var typeCell = typeRow.GetCell(col.ColumnIndex) ?? typeRow.CreateCell(col.ColumnIndex); + var docCell = docRow.GetCell(col.ColumnIndex) ?? docRow.CreateCell(col.ColumnIndex); + + if (!syncDescriptionOnly) + { + // 同步列名和类型 + nameCell.SetCellValue(col.ColumnName); + typeCell.SetCellValue(col.ColumnType); + } + + // 同步注释 + docCell.SetCellValue(col.Description); + } + + // 保存文件 + using (var outFile = new FileStream(targetExcelPath, FileMode.Create, FileAccess.Write)) + { + workbook.Write(outFile); + } + + workbook.Close(); + + Debug.Log($"[ExcelTemplateSync] 同步完成: {targetExcelPath}:{targetSheetName}"); + return true; + } + catch (Exception ex) + { + Debug.LogError($"[ExcelTemplateSync] 同步错误: {ex.Message}"); + return false; + } + } + + /// + /// 对比两个Excel文件的结构差异 + /// + public Dictionary> CompareExcelStructure(string templatePath, string targetPath) + { + var differences = new Dictionary>(); + + var templateSheets = GetSheetNames(templatePath); + var targetSheets = GetSheetNames(targetPath); + + // 对比每个Sheet + foreach (var sheetName in templateSheets) + { + var templateStruct = ReadSheetStructure(templatePath, sheetName); + var targetStruct = targetSheets.Contains(sheetName) + ? ReadSheetStructure(targetPath, sheetName) + : null; + + if (targetStruct == null) + { + differences[sheetName] = new List { "目标缺少此Sheet" }; + } + else + { + templateStruct.ValidateConsistency(targetStruct, out var diffs); + if (diffs.Count > 0) + { + differences[sheetName] = diffs; + } + } + } + + // 检查目标中多余的Sheet + foreach (var sheetName in targetSheets) + { + if (!templateSheets.Contains(sheetName)) + { + differences[$"{sheetName}(额外)"] = new List { "目标包含模板没有的Sheet" }; + } + } + + return differences; + } + + /// + /// 批量同步多个目标文件 + /// + public BatchSyncResult BatchSync(SheetStructure templateStructure, List targetPaths, string sheetName, bool syncDescriptionOnly = false) + { + var result = new BatchSyncResult(); + + foreach (var path in targetPaths) + { + if (SyncToTarget(templateStructure, path, sheetName, syncDescriptionOnly)) + { + result.SuccessCount++; + result.SuccessPaths.Add(path); + } + else + { + result.FailCount++; + result.FailPaths.Add(path); + } + } + + return result; + } + } + + /// + /// 批量同步结果 + /// + public class BatchSyncResult + { + public int SuccessCount = 0; + public int FailCount = 0; + public List SuccessPaths = new List(); + public List FailPaths = new List(); + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/ExcelTemplateSync.cs.meta b/Assets/BP_Scripts/GameplayEditor/Excel/ExcelTemplateSync.cs.meta new file mode 100644 index 0000000..a0c533d --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/ExcelTemplateSync.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 41b15fa8840f9d249ba58bc2eade5916 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/ExcelValidator.cs b/Assets/BP_Scripts/GameplayEditor/Excel/ExcelValidator.cs new file mode 100644 index 0000000..aa3662a --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/ExcelValidator.cs @@ -0,0 +1,451 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using ExcelDataReader; +using UnityEngine; + +namespace GameplayEditor.Excel +{ + /// + /// 验证结果类型 + /// + public enum ValidationResultType + { + Error, // 错误,必须修复 + Warning, // 警告,建议修复 + Info // 信息 + } + + /// + /// 验证结果条目 + /// + public class ValidationResult + { + public ValidationResultType Type; + public string SheetName; + public int Row; + public int Column; + public string Message; + public string Suggestion; + + public ValidationResult(ValidationResultType type, string sheet, int row, int col, string msg, string suggestion = "") + { + Type = type; + SheetName = sheet; + Row = row; + Column = col; + Message = msg; + Suggestion = suggestion; + } + + public override string ToString() + { + var typeStr = Type switch + { + ValidationResultType.Error => "[错误]", + ValidationResultType.Warning => "[警告]", + _ => "[信息]" + }; + return $"{typeStr} {SheetName} 行{Row}列{Column}: {Message}"; + } + } + + /// + /// Excel验证工具 + /// 验证Excel配置的正确性和完整性 + /// + public class ExcelValidator + { + private List _results = new List(); + + /// + /// 验证结果列表 + /// + public IReadOnlyList Results => _results; + + /// + /// 是否有错误 + /// + public bool HasErrors => _results.Any(r => r.Type == ValidationResultType.Error); + + /// + /// 是否有警告 + /// + public bool HasWarnings => _results.Any(r => r.Type == ValidationResultType.Warning); + + /// + /// 错误数量 + /// + public int ErrorCount => _results.Count(r => r.Type == ValidationResultType.Error); + + /// + /// 警告数量 + /// + public int WarningCount => _results.Count(r => r.Type == ValidationResultType.Warning); + + /// + /// 验证Excel文件 + /// + public bool ValidateExcel(string excelPath) + { + _results.Clear(); + + try + { + using (var stream = System.IO.File.Open(excelPath, System.IO.FileMode.Open, System.IO.FileAccess.Read)) + { + using (var reader = ExcelReaderFactory.CreateReader(stream)) + { + var dataset = reader.AsDataSet(); + + // 验证每个Sheet + foreach (DataTable table in dataset.Tables) + { + ValidateSheet(table); + } + + // 验证Sheet之间的引用关系 + ValidateCrossReferences(dataset); + } + } + } + catch (Exception ex) + { + AddError("Global", 0, 0, $"验证过程发生错误: {ex.Message}"); + return false; + } + + return !HasErrors; + } + + /// + /// 验证单个Sheet + /// + private void ValidateSheet(DataTable table) + { + var sheetName = table.TableName; + + // 检查空Sheet + if (table.Rows.Count == 0) + { + AddWarning(sheetName, 0, 0, "Sheet为空"); + return; + } + + // 检查表头 + if (table.Rows.Count < 3) + { + AddWarning(sheetName, 0, 0, "Sheet行数不足,可能缺少表头"); + } + + // 根据Sheet类型进行特定验证 + if (sheetName.Contains("行为树")) + { + ValidateBehaviourTreeSheet(table); + } + else if (sheetName.Contains("Dialog") || sheetName.Contains("对话")) + { + ValidateDialogSheet(table); + } + else if (sheetName.Contains("Event") || sheetName.Contains("事件")) + { + ValidateEventSheet(table); + } + else + { + // 通用验证 + ValidateGenericSheet(table); + } + } + + /// + /// 验证行为树Sheet + /// + private void ValidateBehaviourTreeSheet(DataTable table) + { + var sheetName = table.TableName; + + // 检查必需的列 + if (table.Columns.Count < 4) + { + AddError(sheetName, 0, 0, "行为树Sheet至少需要4列"); + return; + } + + // 验证每一行 + for (int rowIdx = 3; rowIdx < table.Rows.Count; rowIdx++) // 从第4行开始(跳过表头) + { + var row = table.Rows[rowIdx]; + var treeIdStr = row[0]?.ToString()?.Trim() ?? ""; + + // 如果是空行,跳过 + if (string.IsNullOrEmpty(treeIdStr) && IsEmptyRow(row)) + continue; + + // 验证树ID + if (!string.IsNullOrEmpty(treeIdStr) && !int.TryParse(treeIdStr, out _)) + { + AddError(sheetName, rowIdx + 1, 1, $"无效的树ID: {treeIdStr}", "树ID必须是整数"); + } + + // 查找节点列 + bool hasNode = false; + for (int col = 3; col < table.Columns.Count; col++) + { + var cellValue = row[col]?.ToString()?.Trim() ?? ""; + if (!string.IsNullOrEmpty(cellValue)) + { + hasNode = true; + ValidateNodeString(sheetName, rowIdx + 1, col + 1, cellValue); + } + } + + if (!hasNode && !string.IsNullOrEmpty(treeIdStr)) + { + AddWarning(sheetName, rowIdx + 1, 0, "树ID行没有节点数据"); + } + } + } + + /// + /// 验证节点字符串 + /// + private void ValidateNodeString(string sheetName, int row, int col, string nodeStr) + { + if (string.IsNullOrEmpty(nodeStr)) + return; + + // 检查组合方法 + if (nodeStr.StartsWith("组合 方法:") || nodeStr.StartsWith("方法:")) + { + var methodName = nodeStr.Replace("组合 方法:", "").Replace("方法:", "").Trim(); + if (string.IsNullOrEmpty(methodName)) + { + AddError(sheetName, row, col, "组合方法名称为空"); + } + return; + } + + // 检查复合节点 + var composites = new[] { "平行", "顺序", "选择", "乱选", "循环" }; + if (composites.Contains(nodeStr)) + return; + + // 检查参数格式 + var parts = nodeStr.Split(new[] { ' ' }, 2); + var nodeName = parts[0]; + var paramStr = parts.Length > 1 ? parts[1] : ""; + + // 检查参数分隔符 + if (!string.IsNullOrEmpty(paramStr) && paramStr.Contains(",")) + { + AddWarning(sheetName, row, col, "参数使用逗号分隔,建议使用竖线(|)分隔", "将逗号替换为竖线"); + } + } + + /// + /// 验证对话Sheet + /// + private void ValidateDialogSheet(DataTable table) + { + var sheetName = table.TableName; + + // 检查ID列 + var idCol = FindColumn(table, "id", "ID"); + if (idCol < 0) + { + AddError(sheetName, 0, 0, "未找到ID列"); + return; + } + + // 检查Text列 + var textCol = FindColumn(table, "text", "Text", "正文", "对话"); + if (textCol < 0) + { + AddWarning(sheetName, 0, 0, "未找到Text列"); + } + + // 检查ID重复 + var ids = new HashSet(); + for (int rowIdx = 3; rowIdx < table.Rows.Count; rowIdx++) + { + var idStr = table.Rows[rowIdx][idCol]?.ToString()?.Trim() ?? ""; + if (int.TryParse(idStr, out var id)) + { + if (ids.Contains(id)) + { + AddError(sheetName, rowIdx + 1, idCol + 1, $"重复的对话ID: {id}"); + } + ids.Add(id); + } + } + } + + /// + /// 验证事件Sheet + /// + private void ValidateEventSheet(DataTable table) + { + var sheetName = table.TableName; + + // 检查NodeID列 + var nodeIdCol = FindColumn(table, "nodeid", "NodeID", "节点ID"); + if (nodeIdCol < 0) + { + AddWarning(sheetName, 0, 0, "未找到NodeID列"); + return; + } + + // 检查EventTypeGroup格式 + var eventTypeCol = FindColumn(table, "eventtypegroup", "EventTypeGroup", "节点池类型组"); + if (eventTypeCol >= 0) + { + for (int rowIdx = 4; rowIdx < table.Rows.Count; rowIdx++) // 从第5行开始(跳过表头和示例行) + { + var typeStr = table.Rows[rowIdx][eventTypeCol]?.ToString()?.Trim() ?? ""; + if (!string.IsNullOrEmpty(typeStr) && !typeStr.Contains("|")) + { + AddWarning(sheetName, rowIdx + 1, eventTypeCol + 1, + "EventTypeGroup建议使用竖线分隔多个类型", "例如: 1|2|3"); + } + } + } + } + + /// + /// 通用Sheet验证 + /// + private void ValidateGenericSheet(DataTable table) + { + var sheetName = table.TableName; + + // 检查空行 + int emptyRowCount = 0; + for (int rowIdx = 0; rowIdx < table.Rows.Count; rowIdx++) + { + if (IsEmptyRow(table.Rows[rowIdx])) + { + emptyRowCount++; + } + } + + if (emptyRowCount > 5) + { + AddInfo(sheetName, 0, 0, $"Sheet包含{emptyRowCount}个空行,建议清理"); + } + } + + /// + /// 验证Sheet间引用关系 + /// + private void ValidateCrossReferences(DataSet dataset) + { + // 查找Dialog索引表和Dialog配置表 + var byStageTable = FindTable(dataset, "ActivityDialogInfoByStage", "DialogInfoByStage"); + var dialogTables = new List(); + + foreach (DataTable table in dataset.Tables) + { + if (table.TableName.Contains("DialogInfo") && !table.TableName.Contains("ByStage")) + { + dialogTables.Add(table); + } + } + + if (byStageTable != null && dialogTables.Count > 0) + { + // 验证索引表引用的配置表是否存在 + for (int rowIdx = 1; rowIdx < byStageTable.Rows.Count; rowIdx++) + { + var sheetName = byStageTable.Rows[rowIdx][1]?.ToString()?.Trim() ?? ""; + if (!string.IsNullOrEmpty(sheetName)) + { + var found = dialogTables.Any(t => t.TableName == sheetName || t.TableName.Contains(sheetName)); + if (!found) + { + AddWarning(byStageTable.TableName, rowIdx + 1, 2, + $"引用的Dialog配置表不存在: {sheetName}"); + } + } + } + } + } + + /// + /// 查找列索引 + /// + private int FindColumn(DataTable table, params string[] possibleNames) + { + if (table.Rows.Count == 0) + return -1; + + var nameRow = table.Rows[0]; + for (int col = 0; col < table.Columns.Count; col++) + { + var cellValue = nameRow[col]?.ToString()?.Trim().ToLower() ?? ""; + if (possibleNames.Any(n => cellValue == n.ToLower())) + { + return col; + } + } + return -1; + } + + /// + /// 查找表 + /// + private DataTable FindTable(DataSet dataset, params string[] possibleNames) + { + foreach (DataTable table in dataset.Tables) + { + var tableName = table.TableName.ToLower(); + if (possibleNames.Any(n => tableName == n.ToLower() || tableName.Contains(n.ToLower()))) + { + return table; + } + } + return null; + } + + /// + /// 检查是否为空行 + /// + private bool IsEmptyRow(DataRow row) + { + foreach (var item in row.ItemArray) + { + if (item != null && !string.IsNullOrWhiteSpace(item.ToString())) + return false; + } + return true; + } + + private void AddError(string sheet, int row, int col, string msg, string suggestion = "") + { + _results.Add(new ValidationResult(ValidationResultType.Error, sheet, row, col, msg, suggestion)); + } + + private void AddWarning(string sheet, int row, int col, string msg, string suggestion = "") + { + _results.Add(new ValidationResult(ValidationResultType.Warning, sheet, row, col, msg, suggestion)); + } + + private void AddInfo(string sheet, int row, int col, string msg) + { + _results.Add(new ValidationResult(ValidationResultType.Info, sheet, row, col, msg)); + } + } + + /// + /// 验证报告 + /// + public class ValidationReport + { + public List Results = new List(); + public int ErrorCount => Results.Count(r => r.Type == ValidationResultType.Error); + public int WarningCount => Results.Count(r => r.Type == ValidationResultType.Warning); + public bool HasErrors => ErrorCount > 0; + public bool HasWarnings => WarningCount > 0; + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/ExcelValidator.cs.meta b/Assets/BP_Scripts/GameplayEditor/Excel/ExcelValidator.cs.meta new file mode 100644 index 0000000..e2501d1 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/ExcelValidator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0326d0ea08694264c8a59cbab8dcaa7f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/IDRangeExcelParser.cs b/Assets/BP_Scripts/GameplayEditor/Excel/IDRangeExcelParser.cs new file mode 100644 index 0000000..5d7f19d --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/IDRangeExcelParser.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using ExcelDataReader; +using GameplayEditor.Config; +using UnityEngine; + +namespace GameplayEditor.Excel +{ + /// + /// ID段Excel解析器 + /// 从Excel读取ID段分配配置 + /// + public class IDRangeExcelParser + { + /// + /// 解析ID段配置表 + /// Excel格式: + /// 第1行:表头(所有者|起始ID|结束ID|当前使用|备注) + /// 第2行+:数据行 + /// + public List ParseFromExcel(string excelPath, string sheetName = "IDRange") + { + var ranges = new List(); + + if (!File.Exists(excelPath)) + { + Debug.LogError($"[IDRangeExcelParser] 文件不存在: {excelPath}"); + return ranges; + } + + try + { + using (var stream = File.Open(excelPath, FileMode.Open, FileAccess.Read)) + { + using (var reader = ExcelReaderFactory.CreateReader(stream)) + { + var dataset = reader.AsDataSet(); + + // 查找IDRange Sheet + DataTable table = null; + foreach (DataTable t in dataset.Tables) + { + if (t.TableName.Contains(sheetName) || + t.TableName.Contains("ID段") || + t.TableName.Contains("IDRange")) + { + table = t; + break; + } + } + + // 如果没有找到特定Sheet,使用第一个Sheet + if (table == null && dataset.Tables.Count > 0) + { + table = dataset.Tables[0]; + } + + if (table == null) + { + Debug.LogError("[IDRangeExcelParser] 未找到ID段配置表"); + return ranges; + } + + Debug.Log($"[IDRangeExcelParser] 读取Sheet: {table.TableName}"); + + // 解析数据 + // 假设第1行是表头,从第2行开始是数据 + int startRow = 0; + + // 尝试找到表头行 + for (int i = 0; i < Math.Min(5, table.Rows.Count); i++) + { + var row = table.Rows[i]; + var firstCell = row[0]?.ToString()?.Trim() ?? ""; + + // 如果第一列是"所有者"或"Owner",这是表头行 + if (firstCell.Contains("所有者") || firstCell.Contains("Owner") || + firstCell.Contains("策划") || firstCell.Contains("名称")) + { + startRow = i + 1; + break; + } + } + + // 解析每一行 + for (int rowIdx = startRow; rowIdx < table.Rows.Count; rowIdx++) + { + var row = table.Rows[rowIdx]; + var range = ParseRangeRow(row); + if (range != null) + { + ranges.Add(range); + } + } + + Debug.Log($"[IDRangeExcelParser] 解析完成,共{ranges.Count}个ID段"); + } + } + } + catch (Exception ex) + { + Debug.LogError($"[IDRangeExcelParser] 解析错误: {ex.Message}"); + } + + return ranges; + } + + /// + /// 解析单行数据 + /// + private IDRange ParseRangeRow(DataRow row) + { + try + { + // 所有者名称(第1列) + var ownerName = row[0]?.ToString()?.Trim(); + if (string.IsNullOrEmpty(ownerName)) + return null; + + // 起始ID(第2列) + int startId = 0; + if (row.Table.Columns.Count > 1) + { + var startStr = row[1]?.ToString()?.Trim(); + if (!int.TryParse(startStr, out startId)) + return null; + } + + // 结束ID(第3列) + int endId = 0; + if (row.Table.Columns.Count > 2) + { + var endStr = row[2]?.ToString()?.Trim(); + if (!int.TryParse(endStr, out endId)) + return null; + } + + // 当前使用ID(第4列,可选) + int currentUsedId = startId - 1; + if (row.Table.Columns.Count > 3) + { + var currentStr = row[3]?.ToString()?.Trim(); + if (!string.IsNullOrEmpty(currentStr)) + { + int.TryParse(currentStr, out currentUsedId); + } + } + + // 备注(第5列,可选) + string description = ""; + if (row.Table.Columns.Count > 4) + { + description = row[4]?.ToString()?.Trim(); + } + + // 验证数据 + if (startId <= 0 || endId < startId) + { + Debug.LogWarning($"[IDRangeExcelParser] 无效的ID段: {ownerName} [{startId}-{endId}]"); + return null; + } + + var range = new IDRange + { + OwnerName = ownerName, + StartID = startId, + EndID = endId, + CurrentUsedID = currentUsedId, + Description = description + }; + + Debug.Log($"[IDRangeExcelParser] 解析ID段: {ownerName} [{startId}-{endId}]"); + return range; + } + catch (Exception ex) + { + Debug.LogWarning($"[IDRangeExcelParser] 解析行错误: {ex.Message}"); + return null; + } + } + + /// + /// 导出ID段配置到Excel + /// + public bool ExportToExcel(IDRangeConfig config, string excelPath) + { + if (config == null || config.Ranges.Count == 0) + { + Debug.LogError("[IDRangeExcelParser] 配置为空"); + return false; + } + + try + { + var workbook = new NPOI.XSSF.UserModel.XSSFWorkbook(); + var sheet = workbook.CreateSheet("IDRange"); + + // 创建表头 + var headerRow = sheet.CreateRow(0); + headerRow.CreateCell(0).SetCellValue("所有者"); + headerRow.CreateCell(1).SetCellValue("起始ID"); + headerRow.CreateCell(2).SetCellValue("结束ID"); + headerRow.CreateCell(3).SetCellValue("当前使用"); + headerRow.CreateCell(4).SetCellValue("备注"); + + // 创建数据行 + int rowIdx = 1; + foreach (var range in config.Ranges) + { + var row = sheet.CreateRow(rowIdx++); + row.CreateCell(0).SetCellValue(range.OwnerName); + row.CreateCell(1).SetCellValue(range.StartID); + row.CreateCell(2).SetCellValue(range.EndID); + row.CreateCell(3).SetCellValue(range.CurrentUsedID); + row.CreateCell(4).SetCellValue(range.Description); + } + + // 自动调整列宽 + for (int i = 0; i < 5; i++) + { + sheet.AutoSizeColumn(i); + } + + // 写入文件 + using (var file = new FileStream(excelPath, FileMode.Create, FileAccess.Write)) + { + workbook.Write(file); + } + + workbook.Close(); + + Debug.Log($"[IDRangeExcelParser] 导出完成: {excelPath}"); + return true; + } + catch (Exception ex) + { + Debug.LogError($"[IDRangeExcelParser] 导出错误: {ex.Message}"); + return false; + } + } + + /// + /// 验证ID段配置是否有冲突 + /// + public List ValidateRanges(List ranges) + { + var errors = new List(); + + // 检查重叠 + for (int i = 0; i < ranges.Count; i++) + { + for (int j = i + 1; j < ranges.Count; j++) + { + var r1 = ranges[i]; + var r2 = ranges[j]; + + if (r1.StartID <= r2.EndID && r1.EndID >= r2.StartID) + { + errors.Add($"ID段重叠: {r1.OwnerName} [{r1.StartID}-{r1.EndID}] 与 {r2.OwnerName} [{r2.StartID}-{r2.EndID}]"); + } + } + + // 检查所有者名称重复 + for (int j = i + 1; j < ranges.Count; j++) + { + if (ranges[i].OwnerName == ranges[j].OwnerName) + { + errors.Add($"所有者名称重复: {ranges[i].OwnerName}"); + } + } + } + + return errors; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/IDRangeExcelParser.cs.meta b/Assets/BP_Scripts/GameplayEditor/Excel/IDRangeExcelParser.cs.meta new file mode 100644 index 0000000..80cc49d --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/IDRangeExcelParser.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 599cb75a096ae5343852af5364bad3c7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/LutConfigParser.cs b/Assets/BP_Scripts/GameplayEditor/Excel/LutConfigParser.cs new file mode 100644 index 0000000..7857eb5 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/LutConfigParser.cs @@ -0,0 +1,477 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using ExcelDataReader; +using GameplayEditor.Config; +using UnityEngine; + +namespace GameplayEditor.Excel +{ + /// + /// LUT配置解析器 + /// 统一解析UI/Camera/FX/MapInfo四种LUT表 + /// + public class LutConfigParser + { + /// + /// 解析所有LUT表 + /// + public LutConfigDatabase ParseAllLuts(string xlsmPath) + { + var database = ScriptableObject.CreateInstance(); + + if (!File.Exists(xlsmPath)) + { + Debug.LogError($"[LutParser] 文件不存在: {xlsmPath}"); + return database; + } + + try + { + using (var stream = File.Open(xlsmPath, FileMode.Open, FileAccess.Read)) + { + using (var reader = ExcelReaderFactory.CreateReader(stream)) + { + var dataset = reader.AsDataSet(); + + foreach (DataTable table in dataset.Tables) + { + var tableName = table.TableName; + + if (tableName.Contains("UiLut") || tableName.Contains("UI")) + { + var lut = ParseUiLut(table); + if (lut != null) database.UiLuts.Add(lut); + } + else if (tableName.Contains("CameraLut") || tableName.Contains("Camera")) + { + var lut = ParseCameraLut(table); + if (lut != null) database.CameraLuts.Add(lut); + } + else if (tableName.Contains("FXLut") || tableName.Contains("FX")) + { + var lut = ParseFXLut(table); + if (lut != null) database.FxLuts.Add(lut); + } + else if (tableName.Contains("MapInfoLut") || tableName.Contains("MapInfo")) + { + var lut = ParseMapInfoLut(table); + if (lut != null) database.MapInfoLuts.Add(lut); + } + } + } + } + + Debug.Log($"[LutParser] 解析完成: UI={database.UiLuts.Count}, " + + $"Camera={database.CameraLuts.Count}, FX={database.FxLuts.Count}, MapInfo={database.MapInfoLuts.Count}"); + } + catch (Exception ex) + { + Debug.LogError($"[LutParser] 解析错误: {ex.Message}"); + } + + return database; + } + + private ActivityUiLut ParseUiLut(DataTable table) + { + var lut = ScriptableObject.CreateInstance(); + var mappings = ParseResourceMappings(table, "UI"); + lut.UiMappings.AddRange(mappings); + return lut; + } + + private ActivityCameraLut ParseCameraLut(DataTable table) + { + var lut = ScriptableObject.CreateInstance(); + lut.UseDetailedConfig = true; + + // 查找表头行 + int headerRow = FindHeaderRow(table); + if (headerRow < 0) return lut; + + // 读取字段名 + var fieldNames = new Dictionary(); + for (int col = 0; col < table.Columns.Count; col++) + { + var cellValue = table.Rows[headerRow][col]?.ToString()?.Trim() ?? ""; + if (!string.IsNullOrEmpty(cellValue)) + { + fieldNames[col] = cellValue.ToLower(); + } + } + + // 读取数据(从第headerRow+3行开始) + int dataStartRow = headerRow + 3; + for (int row = dataStartRow; row < table.Rows.Count; row++) + { + var mapping = ParseCameraMappingRow(table.Rows[row], fieldNames); + if (mapping != null && mapping.MappingID > 0) + { + mapping.ApplyDefaults(); + lut.CameraMappings.Add(mapping); + } + } + + Debug.Log($"[LutParser] 解析相机LUT: {lut.CameraMappings.Count} 个相机配置"); + return lut; + } + + private ActivityFXLut ParseFXLut(DataTable table) + { + var lut = ScriptableObject.CreateInstance(); + lut.UseDetailedConfig = true; + + // 查找表头行 + int headerRow = FindHeaderRow(table); + if (headerRow < 0) return lut; + + // 读取字段名 + var fieldNames = new Dictionary(); + for (int col = 0; col < table.Columns.Count; col++) + { + var cellValue = table.Rows[headerRow][col]?.ToString()?.Trim() ?? ""; + if (!string.IsNullOrEmpty(cellValue)) + { + fieldNames[col] = cellValue.ToLower(); + } + } + + // 读取数据(从第headerRow+3行开始) + int dataStartRow = headerRow + 3; + for (int row = dataStartRow; row < table.Rows.Count; row++) + { + var mapping = ParseFXMappingRow(table.Rows[row], fieldNames); + if (mapping != null && mapping.MappingID > 0) + { + mapping.ApplyDefaults(); + lut.FxMappings.Add(mapping); + } + } + + Debug.Log($"[LutParser] 解析特效LUT: {lut.FxMappings.Count} 个特效配置"); + return lut; + } + + private List ParseResourceMappings(DataTable table, string typeName) + { + var mappings = new List(); + + // 查找表头行 + int headerRow = FindHeaderRow(table); + if (headerRow < 0) return mappings; + + // 读取字段名 + var fieldNames = new Dictionary(); + for (int col = 0; col < table.Columns.Count; col++) + { + var cellValue = table.Rows[headerRow][col]?.ToString()?.Trim() ?? ""; + if (!string.IsNullOrEmpty(cellValue)) + { + fieldNames[col] = cellValue; + } + } + + // 读取数据(从第headerRow+3行开始) + int dataStartRow = headerRow + 3; + for (int row = dataStartRow; row < table.Rows.Count; row++) + { + var mapping = ParseResourceMappingRow(table.Rows[row], fieldNames); + if (mapping != null && mapping.MappingID > 0) + { + mappings.Add(mapping); + } + } + + return mappings; + } + + private ResourceMapping ParseResourceMappingRow(DataRow rowData, Dictionary fieldNames) + { + var mapping = new ResourceMapping(); + bool hasData = false; + + foreach (var kvp in fieldNames) + { + var col = kvp.Key; + var fieldName = kvp.Value.ToLower(); + var cellValue = rowData[col]?.ToString()?.Trim() ?? ""; + + if (string.IsNullOrEmpty(cellValue)) continue; + + if (fieldName.Contains("stageid") || fieldName.Contains("关卡id")) + { + if (int.TryParse(cellValue, out var stageId)) + mapping.StageID = stageId; + } + else if (fieldName.Contains("mappingid") || fieldName.Contains("映射id") || + fieldName.Contains("uid") || fieldName.Contains("camid") || fieldName.Contains("fxid")) + { + if (int.TryParse(cellValue, out var mappingId)) + { + mapping.MappingID = mappingId; + hasData = true; + } + } + else if (fieldName.Contains("path") || fieldName.Contains("路径") || fieldName.Contains("prefab")) + { + mapping.PrefabPath = cellValue; + } + } + + return hasData ? mapping : null; + } + + /// + /// 解析相机映射行(支持详细参数) + /// + private CameraMapping ParseCameraMappingRow(DataRow rowData, Dictionary fieldNames) + { + var mapping = new CameraMapping(); + bool hasData = false; + + foreach (var kvp in fieldNames) + { + var col = kvp.Key; + var fieldName = kvp.Value; + var cellValue = rowData[col]?.ToString()?.Trim() ?? ""; + + if (string.IsNullOrEmpty(cellValue)) continue; + + // 基础字段 + if (fieldName.Contains("stageid") || fieldName.Contains("关卡id")) + { + if (int.TryParse(cellValue, out var stageId)) + mapping.StageID = stageId; + } + else if (fieldName.Contains("mappingid") || fieldName.Contains("映射id") || + fieldName.Contains("camid") || fieldName.Contains("cameraid")) + { + if (int.TryParse(cellValue, out var mappingId)) + { + mapping.MappingID = mappingId; + hasData = true; + } + } + else if (fieldName.Contains("path") || fieldName.Contains("路径") || fieldName.Contains("prefab")) + { + mapping.PrefabPath = cellValue; + } + // 详细参数字段 + else if (fieldName.Contains("fov") || fieldName.Contains("视场角")) + { + if (float.TryParse(cellValue, out var fov)) + mapping.FOV = fov; + } + else if (fieldName.Contains("priority") || fieldName.Contains("优先级")) + { + if (int.TryParse(cellValue, out var priority)) + mapping.Priority = priority; + } + else if (fieldName.Contains("followoffset") || fieldName.Contains("跟随偏移")) + { + mapping.FollowOffset = ParseVector3(cellValue); + } + else if (fieldName.Contains("lookatoffset") || fieldName.Contains("注视偏移")) + { + mapping.LookAtOffset = ParseVector3(cellValue); + } + else if (fieldName.Contains("blendtime") || fieldName.Contains("过渡时间")) + { + if (float.TryParse(cellValue, out var blendTime)) + mapping.BlendTime = blendTime; + } + else if (fieldName.Contains("blendstyle") || fieldName.Contains("过渡样式")) + { + if (System.Enum.TryParse(cellValue, true, out var style)) + mapping.BlendStyle = style; + } + else if (fieldName.Contains("description") || fieldName.Contains("desc") || fieldName.Contains("备注")) + { + mapping.CameraDescription = cellValue; + } + } + + return hasData ? mapping : null; + } + + /// + /// 解析特效映射行(支持生命周期参数) + /// + private FXMapping ParseFXMappingRow(DataRow rowData, Dictionary fieldNames) + { + var mapping = new FXMapping(); + bool hasData = false; + + foreach (var kvp in fieldNames) + { + var col = kvp.Key; + var fieldName = kvp.Value; + var cellValue = rowData[col]?.ToString()?.Trim() ?? ""; + + if (string.IsNullOrEmpty(cellValue)) continue; + + // 基础字段 + if (fieldName.Contains("stageid") || fieldName.Contains("关卡id")) + { + if (int.TryParse(cellValue, out var stageId)) + mapping.StageID = stageId; + } + else if (fieldName.Contains("mappingid") || fieldName.Contains("映射id") || + fieldName.Contains("fxid") || fieldName.Contains("effectid")) + { + if (int.TryParse(cellValue, out var mappingId)) + { + mapping.MappingID = mappingId; + hasData = true; + } + } + else if (fieldName.Contains("path") || fieldName.Contains("路径") || fieldName.Contains("prefab")) + { + mapping.PrefabPath = cellValue; + } + // 生命周期字段 + else if (fieldName.Contains("duration") || fieldName.Contains("持续时间")) + { + if (float.TryParse(cellValue, out var duration)) + mapping.Duration = duration; + } + else if (fieldName.Contains("delay") || fieldName.Contains("延迟")) + { + if (float.TryParse(cellValue, out var delay)) + mapping.Delay = delay; + } + else if (fieldName.Contains("loop") || fieldName.Contains("循环")) + { + if (bool.TryParse(cellValue, out var loop)) + mapping.Loop = loop; + else if (cellValue == "1" || cellValue.ToLower() == "true" || cellValue == "是") + mapping.Loop = true; + } + else if (fieldName.Contains("autodestroy") || fieldName.Contains("自动销毁")) + { + if (bool.TryParse(cellValue, out var autoDestroy)) + mapping.AutoDestroy = autoDestroy; + else if (cellValue == "1" || cellValue.ToLower() == "true" || cellValue == "是") + mapping.AutoDestroy = true; + } + else if (fieldName.Contains("destroydelay") || fieldName.Contains("销毁延迟")) + { + if (float.TryParse(cellValue, out var destroyDelay)) + mapping.DestroyDelay = destroyDelay; + } + else if (fieldName.Contains("speed") || fieldName.Contains("速度")) + { + if (float.TryParse(cellValue, out var speed)) + mapping.Speed = speed; + } + else if (fieldName.Contains("scale") || fieldName.Contains("缩放")) + { + if (float.TryParse(cellValue, out var scale)) + mapping.Scale = scale; + } + else if (fieldName.Contains("alpha") || fieldName.Contains("透明度")) + { + if (float.TryParse(cellValue, out var alpha)) + mapping.Alpha = Mathf.Clamp01(alpha); + } + else if (fieldName.Contains("attach") || fieldName.Contains("跟随")) + { + if (bool.TryParse(cellValue, out var attach)) + mapping.AttachToTarget = attach; + else if (cellValue == "1" || cellValue.ToLower() == "true" || cellValue == "是") + mapping.AttachToTarget = true; + } + else if (fieldName.Contains("usepool") || fieldName.Contains("对象池")) + { + if (bool.TryParse(cellValue, out var usePool)) + mapping.UseObjectPool = usePool; + else if (cellValue == "1" || cellValue.ToLower() == "true" || cellValue == "是") + mapping.UseObjectPool = true; + } + else if (fieldName.Contains("description") || fieldName.Contains("desc") || fieldName.Contains("备注")) + { + mapping.FXDescription = cellValue; + } + } + + return hasData ? mapping : null; + } + + private ActivityMapInfoLut ParseMapInfoLut(DataTable table) + { + var lut = ScriptableObject.CreateInstance(); + + // MapInfo表结构特殊:infoID | 点位ID | 坐标 | 点位ID | 坐标 | ... + // 需要特殊解析 + + int headerRow = FindHeaderRow(table); + if (headerRow < 0) return lut; + + // 读取数据 + int dataStartRow = headerRow + 3; + for (int row = dataStartRow; row < table.Rows.Count; row++) + { + var rowData = table.Rows[row]; + + // 第一列是infoID + var infoIdStr = rowData[0]?.ToString()?.Trim() ?? ""; + if (!int.TryParse(infoIdStr, out var infoId) || infoId <= 0) + continue; + + lut.InfoID = infoId; + + // 解析点位(每2列一组:点位ID + 坐标) + for (int col = 1; col < table.Columns.Count - 1; col += 2) + { + var pointId = rowData[col]?.ToString()?.Trim() ?? ""; + var positionStr = rowData[col + 1]?.ToString()?.Trim() ?? ""; + + if (string.IsNullOrEmpty(pointId)) continue; + + var point = new MapPoint + { + PointID = pointId, + Position = ParseVector3(positionStr) + }; + lut.Points.Add(point); + } + } + + return lut; + } + + private Vector3 ParseVector3(string str) + { + // 格式: "x,y,z" 或 "(x,y,z)" + str = str.Trim('(', ')'); + var parts = str.Split(','); + + if (parts.Length >= 3) + { + if (float.TryParse(parts[0], out var x) && + float.TryParse(parts[1], out var y) && + float.TryParse(parts[2], out var z)) + { + return new Vector3(x, y, z); + } + } + + return Vector3.zero; + } + + private int FindHeaderRow(DataTable table) + { + for (int row = 0; row < Math.Min(10, table.Rows.Count); row++) + { + var firstCell = table.Rows[row][0]?.ToString()?.Trim() ?? ""; + if (firstCell == "NAME" || firstCell.Contains("ID") || firstCell.Contains("StageID")) + { + return row; + } + } + return -1; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/LutConfigParser.cs.meta b/Assets/BP_Scripts/GameplayEditor/Excel/LutConfigParser.cs.meta new file mode 100644 index 0000000..899eea4 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/LutConfigParser.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 14785466eacdf9141b9ffd1a7573e37f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/NodeTypeNameResolver.cs b/Assets/BP_Scripts/GameplayEditor/Excel/NodeTypeNameResolver.cs new file mode 100644 index 0000000..38c5859 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/NodeTypeNameResolver.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using ExcelDataReader; +using GameplayEditor.Nodes; +using UnityEngine; + +namespace GameplayEditor.Excel +{ + /// + /// 节点类型名称解析器 + /// 从 xlsm 的 行为类型 Sheet 读取,建立: + /// - 显示名 → 代码名(导入时用) + /// - 代码名 → 显示名(导出时用) + /// + public class NodeTypeNameResolver + { + // 显示名 → 代码名 + private Dictionary _displayToCode = new Dictionary(); + // 代码名 → 显示名 + private Dictionary _codeToDisplay = new Dictionary(); + + /// 从 xlsm 文件加载行为类型映射 + public void LoadFromExcel(string xlsmPath) + { + _displayToCode.Clear(); + _codeToDisplay.Clear(); + + using (var stream = System.IO.File.Open(xlsmPath, System.IO.FileMode.Open, System.IO.FileAccess.Read)) + { + using (var reader = ExcelReaderFactory.CreateReader(stream)) + { + var result = reader.AsDataSet(); + + // 找 行为类型 Sheet + DataTable behaviorTypeTable = null; + foreach (DataTable table in result.Tables) + { + if (table.TableName.Contains("行为类型") || table.TableName.Contains("行为")) + { + behaviorTypeTable = table; + break; + } + } + + if (behaviorTypeTable == null) + { + Debug.LogWarning("未找到 行为类型 Sheet,使用默认映射"); + return; + } + + // 行为类型Sheet格式: + // col0 = 类型(代码名) + // col2 = 名字(显示名) + // 从第1行开始读数据(第0行是表头) + + for (int row = 1; row < behaviorTypeTable.Rows.Count; row++) + { + var rowData = behaviorTypeTable.Rows[row]; + if (rowData.ItemArray.Length < 3) continue; + + var codeName = rowData[0]?.ToString()?.Trim() ?? ""; + var displayName = rowData[2]?.ToString()?.Trim() ?? ""; + + if (string.IsNullOrEmpty(codeName)) continue; + + _displayToCode[displayName] = codeName; + _codeToDisplay[codeName] = displayName; + + Debug.Log($"[NodeTypeResolver] {displayName} ← → {codeName}"); + } + } + } + + Debug.Log($"[NodeTypeResolver] 加载了 {_displayToCode.Count} 个节点类型映射"); + } + + /// 通过显示名获取代码名(导入时用) + public string GetCodeName(string displayName) + { + if (_displayToCode.TryGetValue(displayName, out var code)) + return code; + // 如果没有映射,假设 displayName 本身就是代码名 + return displayName; + } + + /// 通过代码名获取显示名(导出时用) + public string GetDisplayName(string codeName) + { + if (_codeToDisplay.TryGetValue(codeName, out var display)) + return display; + // 如果没有映射,返回代码名本身 + return codeName; + } + + /// 检查是否为已知的复合节点显示名 + public bool IsCompositeDisplayName(string displayName) + { + var composites = new[] { "平行", "顺序", "选择", "乱选", "循环" }; + return composites.Contains(displayName); + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Excel/NodeTypeNameResolver.cs.meta b/Assets/BP_Scripts/GameplayEditor/Excel/NodeTypeNameResolver.cs.meta new file mode 100644 index 0000000..6c6c162 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Excel/NodeTypeNameResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e23cebdf75507414996a9bfbde4a9cc1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes.meta b/Assets/BP_Scripts/GameplayEditor/Nodes.meta new file mode 100644 index 0000000..88459a7 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c9ffd1d6ae2dc384c8ba70ba880871a0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions.meta b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions.meta new file mode 100644 index 0000000..cb2c1c4 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3de4d7a66e5a7fe48bf9a01a869a1c80 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/BattleTasks.cs b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/BattleTasks.cs new file mode 100644 index 0000000..51eab05 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/BattleTasks.cs @@ -0,0 +1,361 @@ +using System.Collections.Generic; +using System.Linq; +using GameplayEditor.Core; +using NodeCanvas.Framework; +using ParadoxNotion.Design; +using UnityEngine; + +namespace GameplayEditor.Nodes.Actions +{ + /// + /// 设置战斗目标节点 + /// Excel格式:DoSetFightTarget npcId1 npcId2 + /// 例:DoSetFightTarget 2001 2002 + /// + [Name("设置战斗目标")] + [Description("设置Npc1的攻击目标为Npc2")] + [Category("Activity/Battle")] + public class DoSetFightTargetTask : ActionTask, IBtNodeWithParams + { + [SerializeField] + [Tooltip("攻击者NPC ID")] + private string attackerNpcId = ""; + + [SerializeField] + [Tooltip("目标NPC ID")] + private string targetNpcId = ""; + + protected override string info + => $"设置目标 [{attackerNpcId}] -> [{targetNpcId}]"; + + public void SetRawParams(string[] rawParams) + { + if (rawParams.Length > 0) attackerNpcId = rawParams[0]; + if (rawParams.Length > 1) targetNpcId = rawParams[1]; + } + + protected override void OnExecute() + { + if (string.IsNullOrEmpty(attackerNpcId) || string.IsNullOrEmpty(targetNpcId)) + { + Debug.LogError("[DoSetFightTarget] NPC ID不能为空"); + EndAction(false); + return; + } + + Debug.Log($"[DoSetFightTarget] 设置战斗目标: {attackerNpcId} -> {targetNpcId}"); + + // 调用战斗系统 + var battleManager = GameplayManagerHub.Instance?.BattleManager; + if (battleManager != null) + { + battleManager.SetFightTarget(attackerNpcId, targetNpcId); + } + else + { + Debug.LogWarning("[DoSetFightTarget] 战斗管理器未注册,使用模拟实现"); + // 模拟实现:在控制台输出 + } + + EndAction(true); + } + } + + /// + /// 检查距离节点 + /// Excel格式:CheckDistance npcId1 npcId2 operator distance + /// 例:CheckDistance 2001 2002 < 10 + /// + [Name("检查距离")] + [Description("检查两个NPC之间的距离")] + [Category("Activity/Battle")] + public class CheckDistanceTask : ConditionTask, IBtNodeWithParams + { + public enum ComparisonOperator + { + LessThan, // < + LessOrEqual, // <= + Equal, // == + GreaterOrEqual, // >= + GreaterThan, // > + NotEqual // != + } + + [SerializeField] + [Tooltip("NPC1 ID")] + private string npcId1 = ""; + + [SerializeField] + [Tooltip("NPC2 ID")] + private string npcId2 = ""; + + [SerializeField] + [Tooltip("比较运算符")] + private ComparisonOperator comparison = ComparisonOperator.LessThan; + + [SerializeField] + [Tooltip("目标距离")] + private float targetDistance = 10f; + + protected override string info + => $"距离 [{npcId1}]-[{npcId2}] {GetOperatorString()} {targetDistance}"; + + private string GetOperatorString() + { + return comparison switch + { + ComparisonOperator.LessThan => "<", + ComparisonOperator.LessOrEqual => "<=", + ComparisonOperator.Equal => "==", + ComparisonOperator.GreaterOrEqual => ">=", + ComparisonOperator.GreaterThan => ">", + ComparisonOperator.NotEqual => "!=", + _ => "?" + }; + } + + public void SetRawParams(string[] rawParams) + { + if (rawParams.Length > 0) npcId1 = rawParams[0]; + if (rawParams.Length > 1) npcId2 = rawParams[1]; + if (rawParams.Length > 2) ParseOperator(rawParams[2]); + if (rawParams.Length > 3 && float.TryParse(rawParams[3], out var dist)) + targetDistance = dist; + } + + private void ParseOperator(string op) + { + comparison = op.Trim() switch + { + "<" => ComparisonOperator.LessThan, + "<=" => ComparisonOperator.LessOrEqual, + "=" or "==" => ComparisonOperator.Equal, + ">=" => ComparisonOperator.GreaterOrEqual, + ">" => ComparisonOperator.GreaterThan, + "!=" or "<>" => ComparisonOperator.NotEqual, + _ => ComparisonOperator.LessThan + }; + } + + protected override bool OnCheck() + { + if (string.IsNullOrEmpty(npcId1) || string.IsNullOrEmpty(npcId2)) + { + Debug.LogError("[CheckDistance] NPC ID不能为空"); + return false; + } + + // 获取NPC位置 + float distance = 0f; + var npcManager = GameplayManagerHub.Instance?.NPCManager; + var battleManager = GameplayManagerHub.Instance?.BattleManager; + + if (npcManager != null) + { + var pos1 = npcManager.GetNPCPosition(npcId1); + var pos2 = npcManager.GetNPCPosition(npcId2); + distance = Vector3.Distance(pos1, pos2); + } + else if (battleManager != null) + { + distance = battleManager.GetNPCDistance(npcId1, npcId2); + } + else + { + // 模拟计算:根据ID差值模拟距离 + if (int.TryParse(npcId1, out var id1) && int.TryParse(npcId2, out var id2)) + { + distance = Mathf.Abs(id1 - id2); + } + } + + bool result = comparison switch + { + ComparisonOperator.LessThan => distance < targetDistance, + ComparisonOperator.LessOrEqual => distance <= targetDistance, + ComparisonOperator.Equal => Mathf.Approximately(distance, targetDistance), + ComparisonOperator.GreaterOrEqual => distance >= targetDistance, + ComparisonOperator.GreaterThan => distance > targetDistance, + ComparisonOperator.NotEqual => !Mathf.Approximately(distance, targetDistance), + _ => false + }; + + Debug.Log($"[CheckDistance] {npcId1}与{npcId2}的距离: {distance}, 目标: {GetOperatorString()} {targetDistance}, 结果: {result}"); + return result; + } + } + + /// + /// 检查血量节点 + /// Excel格式:CheckHP npcId operator percent + /// 例:CheckHP 2001 < 50 + /// + [Name("检查血量")] + [Description("检查NPC的血量百分比")] + [Category("Activity/Battle")] + public class CheckHPTask : ConditionTask, IBtNodeWithParams + { + public enum ComparisonOperator + { + LessThan, // < + LessOrEqual, // <= + Equal, // == + GreaterOrEqual, // >= + GreaterThan, // > + NotEqual // != + } + + [SerializeField] + [Tooltip("NPC ID")] + private string npcId = ""; + + [SerializeField] + [Tooltip("比较运算符")] + private ComparisonOperator comparison = ComparisonOperator.LessThan; + + [SerializeField] + [Tooltip("目标血量百分比 (0-100)")] + [Range(0, 100)] + private float targetPercent = 50f; + + protected override string info + => $"血量 [{npcId}] {GetOperatorString()} {targetPercent}%"; + + private string GetOperatorString() + { + return comparison switch + { + ComparisonOperator.LessThan => "<", + ComparisonOperator.LessOrEqual => "<=", + ComparisonOperator.Equal => "==", + ComparisonOperator.GreaterOrEqual => ">=", + ComparisonOperator.GreaterThan => ">", + ComparisonOperator.NotEqual => "!=", + _ => "?" + }; + } + + public void SetRawParams(string[] rawParams) + { + if (rawParams.Length > 0) npcId = rawParams[0]; + if (rawParams.Length > 1) ParseOperator(rawParams[1]); + if (rawParams.Length > 2 && float.TryParse(rawParams[2], out var pct)) + targetPercent = Mathf.Clamp(pct, 0, 100); + } + + private void ParseOperator(string op) + { + comparison = op.Trim() switch + { + "<" => ComparisonOperator.LessThan, + "<=" => ComparisonOperator.LessOrEqual, + "=" or "==" => ComparisonOperator.Equal, + ">=" => ComparisonOperator.GreaterOrEqual, + ">" => ComparisonOperator.GreaterThan, + "!=" or "<>" => ComparisonOperator.NotEqual, + _ => ComparisonOperator.LessThan + }; + } + + protected override bool OnCheck() + { + if (string.IsNullOrEmpty(npcId)) + { + Debug.LogError("[CheckHP] NPC ID不能为空"); + return false; + } + + // 获取NPC血量 + float currentPercent = 100f; + var battleManager = GameplayManagerHub.Instance?.BattleManager; + + if (battleManager != null) + { + currentPercent = battleManager.GetNPCHealthPercent(npcId); + } + else + { + // 模拟血量:根据NPC ID计算 + if (int.TryParse(npcId, out var id)) + { + currentPercent = (id % 100); + } + } + + bool result = comparison switch + { + ComparisonOperator.LessThan => currentPercent < targetPercent, + ComparisonOperator.LessOrEqual => currentPercent <= targetPercent, + ComparisonOperator.Equal => Mathf.Approximately(currentPercent, targetPercent), + ComparisonOperator.GreaterOrEqual => currentPercent >= targetPercent, + ComparisonOperator.GreaterThan => currentPercent > targetPercent, + ComparisonOperator.NotEqual => !Mathf.Approximately(currentPercent, targetPercent), + _ => false + }; + + Debug.Log($"[CheckHP] {npcId}的血量: {currentPercent}%, 目标: {GetOperatorString()} {targetPercent}%, 结果: {result}"); + return result; + } + } + + /// + /// 检查NPC是否存在节点 + /// Excel格式:CheckNPCExists npcId + /// 例:CheckNPCExists 2001 + /// + [Name("检查NPC存在")] + [Description("检查指定NPC是否存在于场景中")] + [Category("Activity/Battle")] + public class CheckNPCExistsTask : ConditionTask, IBtNodeWithParams + { + [SerializeField] + [Tooltip("NPC ID")] + private string npcId = ""; + + [SerializeField] + [Tooltip("期望是否存在")] + private bool expectExists = true; + + protected override string info + => expectExists ? $"存在NPC [{npcId}]" : $"不存在NPC [{npcId}]"; + + public void SetRawParams(string[] rawParams) + { + if (rawParams.Length > 0) npcId = rawParams[0]; + } + + protected override bool OnCheck() + { + if (string.IsNullOrEmpty(npcId)) + { + Debug.LogError("[CheckNPCExists] NPC ID不能为空"); + return false; + } + + // 检查NPC是否存在 + bool exists = false; + var npcManager = GameplayManagerHub.Instance?.NPCManager; + var battleManager = GameplayManagerHub.Instance?.BattleManager; + + if (npcManager != null) + { + exists = npcManager.GetNPC(npcId) != null; + } + else if (battleManager != null) + { + exists = battleManager.NPCExists(npcId); + } + else + { + // 模拟存在检查:ID在2000-5000范围内的认为存在 + if (int.TryParse(npcId, out var id)) + { + exists = id >= 2000 && id < 5000; + } + } + + Debug.Log($"[CheckNPCExists] NPC {npcId} 存在: {exists}, 期望: {expectExists}"); + return exists == expectExists; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/BattleTasks.cs.meta b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/BattleTasks.cs.meta new file mode 100644 index 0000000..53a47ad --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/BattleTasks.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 83713bbdc5ad34e41b4ffa8cc95b3d27 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/DialogTasks.cs b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/DialogTasks.cs new file mode 100644 index 0000000..f6047ac --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/DialogTasks.cs @@ -0,0 +1,305 @@ +using System.Collections.Generic; +using NodeCanvas.Framework; +using ParadoxNotion.Design; +using UnityEngine; +using GameplayEditor.Config; + +namespace GameplayEditor.Nodes.Actions +{ + /// + /// 显示对话节点 + /// Excel格式:DoShowDialog dialogInfoId [waitForComplete] [overrideDuration] + /// 例:DoShowDialog 1001 (显示对话ID 1001) + /// 例:DoShowDialog 1001 true 5 (显示5秒,覆盖配置时间) + /// + [Name("显示对话")] + [Description("显示指定的对话UI")] + [Category("Activity/Dialog")] + public class DoShowDialogTask : ActionTask, IBtNodeWithParams + { + [SerializeField] + [Tooltip("对话配置ID")] + private int dialogInfoId = 0; + + [SerializeField] + [Tooltip("等待对话结束")] + private bool waitForComplete = true; + + [SerializeField] + [Tooltip("覆盖显示时间(0表示使用配置值)")] + private float overrideDuration = 0f; + + // 运行时状态 + private bool _waitingForDialog; + private float _targetTime; + + protected override string info + => $"显示对话 [{dialogInfoId}]"; + + public void SetRawParams(string[] rawParams) + { + // 格式: DoShowDialog dialogInfoId [waitForComplete] [overrideDuration] + if (rawParams.Length > 0 && int.TryParse(rawParams[0], out var id)) + dialogInfoId = id; + if (rawParams.Length > 1 && bool.TryParse(rawParams[1], out var wait)) + waitForComplete = wait; + if (rawParams.Length > 2 && float.TryParse(rawParams[2], out var duration)) + overrideDuration = duration; + } + + protected override void OnExecute() + { + if (dialogInfoId <= 0) + { + Debug.LogError("[DoShowDialog] 无效的对话ID"); + EndAction(false); + return; + } + + // 获取对话配置 + var dialogData = DialogInfoManager.Instance?.GetDialogByID(dialogInfoId); + if (dialogData == null) + { + Debug.LogWarning($"[DoShowDialog] 未找到对话配置: ID={dialogInfoId},尝试直接显示..."); + // 尝试从黑板获取对话文本(用于快速测试) + if (blackboard != null && blackboard.variables.ContainsKey($"Dialog_{dialogInfoId}_Text")) + { + var text = blackboard.GetVariableValue($"Dialog_{dialogInfoId}_Text"); + Debug.Log($"[DoShowDialog] 从黑板获取对话: {text}"); + } + else + { + Debug.LogError($"[DoShowDialog] 未找到对话配置: ID={dialogInfoId}"); + EndAction(false); + return; + } + } + else + { + Debug.Log($"[DoShowDialog] 显示对话: ID={dialogInfoId}, Text={dialogData.Text}"); + } + + // 调用DialogInfoManager显示对话 + bool success = DialogInfoManager.Instance?.ShowDialog(dialogInfoId) ?? false; + if (!success) + { + Debug.LogWarning($"[DoShowDialog] 显示对话失败,继续执行"); + } + + // 存储对话ID到黑板(用于后续节点引用) + if (blackboard != null) + { + blackboard.SetVariableValue("LastDialogID", dialogInfoId); + } + + if (waitForComplete) + { + // 计算等待时间 + var duration = overrideDuration > 0 ? overrideDuration : + (dialogData?.Duration ?? 0); + + if (duration > 0) + { + _targetTime = Time.time + duration; + _waitingForDialog = true; + } + else + { + // 永久显示或等待点击关闭 + _waitingForDialog = true; + } + } + else + { + EndAction(true); + } + } + + protected override void OnUpdate() + { + if (!_waitingForDialog) return; + + // 检查对话是否已关闭 + if (DialogInfoManager.Instance != null && + !DialogInfoManager.Instance.IsDialogActive(dialogInfoId)) + { + Debug.Log($"[DoShowDialog] 对话已关闭: ID={dialogInfoId}"); + _waitingForDialog = false; + EndAction(true); + return; + } + + // 检查超时 + if (_targetTime > 0 && Time.time >= _targetTime) + { + Debug.Log($"[DoShowDialog] 对话显示超时: ID={dialogInfoId}"); + _waitingForDialog = false; + EndAction(true); + } + } + + protected override void OnStop() + { + _waitingForDialog = false; + } + } + + /// + /// 关闭对话节点 + /// Excel格式:DoCloseDialog dialogInfoId + /// 例:DoCloseDialog 1001 (关闭指定对话) + /// 例:DoCloseDialog 0 (关闭所有对话) + /// + [Name("关闭对话")] + [Description("关闭指定的对话UI")] + [Category("Activity/Dialog")] + public class DoCloseDialogTask : ActionTask, IBtNodeWithParams + { + [SerializeField] + [Tooltip("对话配置ID(0表示关闭所有)")] + private int dialogInfoId = 0; + + [SerializeField] + [Tooltip("延迟关闭时间(秒,0表示立即关闭)")] + private float delay = 0f; + + protected override string info + => dialogInfoId > 0 ? $"关闭对话 [{dialogInfoId}]" : "关闭所有对话"; + + public void SetRawParams(string[] rawParams) + { + // 格式: DoCloseDialog dialogInfoId [delay] + if (rawParams.Length > 0 && int.TryParse(rawParams[0], out var id)) + dialogInfoId = id; + if (rawParams.Length > 1 && float.TryParse(rawParams[1], out var d)) + delay = d; + } + + protected override void OnExecute() + { + if (delay > 0) + { + StartCoroutine(DelayedClose()); + } + else + { + DoClose(); + } + } + + private System.Collections.IEnumerator DelayedClose() + { + yield return new WaitForSeconds(delay); + DoClose(); + } + + private void DoClose() + { + if (dialogInfoId <= 0) + { + // 关闭所有对话 + DialogInfoManager.Instance?.CloseAllDialogs(); + Debug.Log("[DoCloseDialog] 关闭所有对话"); + } + else + { + // 关闭指定对话 + DialogInfoManager.Instance?.CloseDialog(dialogInfoId); + Debug.Log($"[DoCloseDialog] 关闭对话: ID={dialogInfoId}"); + } + + EndAction(true); + } + } + + /// + /// 等待对话结束节点 + /// Excel格式:DoWaitDialog dialogInfoId [timeout] + /// 例:DoWaitDialog 1001 (永久等待直到对话关闭) + /// 例:DoWaitDialog 1001 10 (最多等待10秒) + /// + [Name("等待对话结束")] + [Description("等待指定对话结束或超时")] + [Category("Activity/Dialog")] + public class DoWaitDialogTask : ActionTask, IBtNodeWithParams + { + [SerializeField] + [Tooltip("对话配置ID(0表示等待最后一个显示对话)")] + private int dialogInfoId = 0; + + [SerializeField] + [Tooltip("超时时间(秒,0表示不超时)")] + private float timeout = 0f; + + [SerializeField] + [Tooltip("超时是否算成功")] + private bool timeoutIsSuccess = true; + + private float _startTime; + private float _targetDialogId; + + protected override string info + => $"等待对话 [{dialogInfoId}]"; + + public void SetRawParams(string[] rawParams) + { + // 格式: DoWaitDialog dialogInfoId [timeout] [timeoutIsSuccess] + if (rawParams.Length > 0 && int.TryParse(rawParams[0], out var id)) + dialogInfoId = id; + if (rawParams.Length > 1 && float.TryParse(rawParams[1], out var to)) + timeout = to; + if (rawParams.Length > 2 && bool.TryParse(rawParams[2], out var ts)) + timeoutIsSuccess = ts; + } + + protected override void OnExecute() + { + _startTime = Time.time; + + // 如果dialogInfoId为0,尝试从黑板获取最后一个显示的对话ID + if (dialogInfoId <= 0 && blackboard != null) + { + _targetDialogId = blackboard.GetVariableValue("LastDialogID"); + } + else + { + _targetDialogId = dialogInfoId; + } + + if (_targetDialogId <= 0) + { + Debug.LogWarning("[DoWaitDialog] 未指定对话ID且无法从黑板获取"); + EndAction(false); + return; + } + + Debug.Log($"[DoWaitDialog] 开始等待对话: ID={_targetDialogId}, 超时={timeout}s"); + } + + protected override void OnUpdate() + { + // 检查对话是否已结束 + if (DialogInfoManager.Instance != null) + { + if (!DialogInfoManager.Instance.IsDialogActive((int)_targetDialogId)) + { + Debug.Log($"[DoWaitDialog] 对话已结束: ID={_targetDialogId}"); + EndAction(true); + return; + } + } + + // 超时检查 + if (timeout > 0) + { + var elapsed = Time.time - _startTime; + if (elapsed >= timeout) + { + Debug.LogWarning($"[DoWaitDialog] 等待超时: ID={_targetDialogId}, 已等待{elapsed}s"); + EndAction(timeoutIsSuccess); + return; + } + } + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/DialogTasks.cs.meta b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/DialogTasks.cs.meta new file mode 100644 index 0000000..7055bdd --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/DialogTasks.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 672398f59880e0b4b99d846ed8935ad5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/ExampleTasks.cs b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/ExampleTasks.cs new file mode 100644 index 0000000..a8b2674 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/ExampleTasks.cs @@ -0,0 +1,396 @@ +using System.Collections.Generic; +using System.Linq; +using NodeCanvas.Framework; +using ParadoxNotion.Design; +using UnityEngine; + +namespace GameplayEditor.Nodes.Actions +{ + /// + /// 获取时间节点 + /// 参数1(帧数) = 当前关卡时间(帧数) + 参数2(秒 转 帧数) + /// + /// Excel格式:DoGetTime targetVar timeOffset [mode] + /// 例:DoGetTime TargetTime 5 (5秒后,存为帧数) + /// 例:DoGetTime TargetFrame 300 frame (300帧后) + /// + [Name("获取时间")] + [Description("计算目标时间/帧数并存储到黑板变量")] + [Category("Activity/Time")] + public class DoGetTimeTask : PerformanceTrackedActionTask, IBtNodeWithParams + { + [SerializeField] + [Tooltip("存储结果的黑板变量名")] + private string targetVariable = ""; + + [SerializeField] + [Tooltip("时间/帧数偏移")] + private float offsetValue = 0f; + + [SerializeField] + [Tooltip("计算模式")] + private TimeMode mode = TimeMode.SecondsToFrames; + + [SerializeField] + [Tooltip("帧率(默认60fps)")] + private int frameRate = 60; + + public enum TimeMode + { + SecondsToFrames, // 秒转帧数存储 + DirectFrames, // 直接使用帧数 + DirectSeconds // 直接使用秒数 + } + + protected override string info + { + get + { + var modeStr = mode switch + { + TimeMode.SecondsToFrames => $"+{offsetValue}s→帧", + TimeMode.DirectFrames => $"+{offsetValue}帧", + TimeMode.DirectSeconds => $"+{offsetValue}s", + _ => $"+{offsetValue}" + }; + return $"GetTime [{targetVariable}] = 当前+{modeStr}"; + } + } + + public void SetRawParams(string[] rawParams) + { + // 格式: DoGetTime targetVar offset [mode] + if (rawParams.Length > 0) targetVariable = rawParams[0]; + if (rawParams.Length > 1 && float.TryParse(rawParams[1], out var t)) + offsetValue = t; + if (rawParams.Length > 2) + { + var modeStr = rawParams[2].ToLower(); + mode = modeStr switch + { + "frame" or "frames" or "帧" => TimeMode.DirectFrames, + "second" or "seconds" or "秒" => TimeMode.DirectSeconds, + _ => TimeMode.SecondsToFrames + }; + } + } + + protected override void OnExecuteTracked() + { + if (string.IsNullOrEmpty(targetVariable)) + { + Debug.LogError("[DoGetTime] 目标变量名不能为空"); + EndAction(false); + return; + } + + if (blackboard == null) + { + Debug.LogError("[DoGetTime] 黑板为空"); + EndAction(false); + return; + } + + try + { + // 获取当前关卡时间(帧数) + int currentFrame = GetCurrentLevelFrame(); + int resultValue; + + // 根据模式计算目标值 + switch (mode) + { + case TimeMode.SecondsToFrames: + // 秒转帧数: offsetSeconds * frameRate + int offsetFrames = Mathf.RoundToInt(offsetValue * frameRate); + resultValue = currentFrame + offsetFrames; + Debug.Log($"[DoGetTime] 模式=秒转帧, 当前帧={currentFrame}, 偏移={offsetValue}s={offsetFrames}帧, 结果={resultValue}"); + break; + + case TimeMode.DirectFrames: + // 直接使用帧数偏移 + resultValue = currentFrame + Mathf.RoundToInt(offsetValue); + Debug.Log($"[DoGetTime] 模式=直接帧数, 当前帧={currentFrame}, 偏移={offsetValue}帧, 结果={resultValue}"); + break; + + case TimeMode.DirectSeconds: + // 存储秒数(浮点数) + float resultSeconds = GetCurrentLevelTime() + offsetValue; + blackboard.SetVariableValue(targetVariable, resultSeconds); + Debug.Log($"[DoGetTime] 模式=直接秒数, 当前时间={GetCurrentLevelTime()}s, 偏移={offsetValue}s, 结果={resultSeconds}s"); + EndAction(true); + return; + + default: + resultValue = currentFrame; + break; + } + + // 存储结果到黑板(int类型) + blackboard.SetVariableValue(targetVariable, resultValue); + Debug.Log($"[DoGetTime] 结果已存储: {targetVariable} = {resultValue}"); + EndAction(true); + } + catch (System.Exception ex) + { + Debug.LogError($"[DoGetTime] 执行错误: {ex.Message}"); + EndAction(false); + } + } + + /// + /// 获取当前关卡帧数 + /// + private int GetCurrentLevelFrame() + { + // 优先从黑板读取关卡开始帧数 + if (blackboard != null && blackboard.variables.ContainsKey("LevelStartFrame")) + { + var startFrame = blackboard.GetVariableValue("LevelStartFrame"); + return Time.frameCount - startFrame; + } + // 否则返回Time.frameCount + return Time.frameCount; + } + + /// + /// 获取当前关卡时间(秒) + /// + private float GetCurrentLevelTime() + { + // 优先从黑板读取关卡开始时间 + if (blackboard != null && blackboard.variables.ContainsKey("LevelStartTime")) + { + var startTime = blackboard.GetVariableValue("LevelStartTime"); + return Time.time - startTime; + } + // 否则返回Time.time + return Time.time; + } + } + + /// + /// 阵营类型枚举 + /// + public enum CampType + { + None = 0, + Player = 1, // 玩家阵营 + Enemy = 2, // 敌方阵营 + Neutral = 3, // 中立阵营 + Ally = 4, // 友方阵营 + Boss = 5, // BOSS阵营 + Monster = 6, // 怪物阵营 + } + + /// + /// 阵营关系检查模式 + /// + public enum CampCheckMode + { + Equal, // 相等 + Contains, // 包含(NPC阵营包含目标阵营) + Intersect, // 相交(有交集即为队友) + IsTeammate, // 是队友(包含、相交、相等都算队友) + IsEnemy // 是敌人 + } + + /// + /// 检查NPC阵营节点 + /// 比较阵营是否相等或包含参数1NPC参数2阵营,(包含、相交、相等都是队友) + /// + /// Excel格式:CheckNpcCamp npcId campId [mode] + /// 例:CheckNpcCamp 2001 1 (检查NPC 2001是否属于玩家阵营) + /// 例:CheckNpcCamp 2001 1|2|3 Contains (检查NPC 2001的阵营是否包含1|2|3中的任意) + /// + [Name("比较阵营")] + [Description("比较NPC阵营是否相等、包含或相交")] + [Category("Activity/Battle")] + public class CheckNpcCampTask : PerformanceTrackedConditionTask, IBtNodeWithParams + { + [SerializeField] + [Tooltip("NPC ID")] + private string npcId = ""; + + [SerializeField] + [Tooltip("目标阵营ID列表(支持多阵营,用|分隔)")] + private List targetCampIds = new List(); + + [SerializeField] + [Tooltip("检查模式")] + private CampCheckMode checkMode = CampCheckMode.IsTeammate; + + protected override string info + { + get + { + var campStr = targetCampIds.Count > 0 ? string.Join("|", targetCampIds) : "?"; + var modeStr = checkMode switch + { + CampCheckMode.Equal => "==", + CampCheckMode.Contains => "包含", + CampCheckMode.Intersect => "相交", + CampCheckMode.IsTeammate => "队友", + CampCheckMode.IsEnemy => "敌人", + _ => "?" + }; + return $"阵营 [{npcId}] {modeStr} [{campStr}]"; + } + } + + public void SetRawParams(string[] rawParams) + { + // 格式: CheckNpcCamp npcId campId [mode] + if (rawParams.Length > 0) npcId = rawParams[0]; + + if (rawParams.Length > 1) + { + // 解析阵营ID(支持多阵营用|分隔) + targetCampIds.Clear(); + var campParts = rawParams[1].Split('|'); + foreach (var part in campParts) + { + if (int.TryParse(part.Trim(), out var campId)) + targetCampIds.Add(campId); + } + } + + if (rawParams.Length > 2) + { + var modeStr = rawParams[2].ToLower(); + checkMode = modeStr switch + { + "equal" or "相等" or "==" => CampCheckMode.Equal, + "contains" or "包含" => CampCheckMode.Contains, + "intersect" or "相交" => CampCheckMode.Intersect, + "teammate" or "队友" or "isteammate" => CampCheckMode.IsTeammate, + "enemy" or "敌人" or "isenemy" => CampCheckMode.IsEnemy, + _ => CampCheckMode.IsTeammate + }; + } + } + + protected override bool OnCheckTracked() + { + if (string.IsNullOrEmpty(npcId)) + { + Debug.LogError("[CheckNpcCamp] NPC ID不能为空"); + return false; + } + + if (targetCampIds.Count == 0) + { + Debug.LogError("[CheckNpcCamp] 目标阵营ID不能为空"); + return false; + } + + try + { + // 获取NPC的阵营 + var npcCampIds = GetNpcCampIds(npcId); + if (npcCampIds == null || npcCampIds.Count == 0) + { + Debug.LogWarning($"[CheckNpcCamp] 无法获取NPC {npcId} 的阵营信息"); + return false; + } + + // 执行阵营关系检查 + bool result = CheckCampRelation(npcCampIds, targetCampIds, checkMode); + + Debug.Log($"[CheckNpcCamp] NPC={npcId}, NPC阵营=[{string.Join("|", npcCampIds)}], " + + $"目标阵营=[{string.Join("|", targetCampIds)}], 模式={checkMode}, 结果={result}"); + + return result; + } + catch (System.Exception ex) + { + Debug.LogError($"[CheckNpcCamp] 检查错误: {ex.Message}"); + return false; + } + } + + /// + /// 获取NPC的阵营ID列表 + /// + private List GetNpcCampIds(string npcId) + { + // TODO: 接入实际的阵营管理器 + // return CampManager.GetNpcCamps(npcId); + + // 临时实现:从黑板读取或返回默认值 + if (blackboard != null) + { + var varName = $"NPC_{npcId}_Camps"; + if (blackboard.variables.ContainsKey(varName)) + { + var value = blackboard.GetVariableValue(varName); + if (!string.IsNullOrEmpty(value)) + { + return value.Split('|').Select(int.Parse).ToList(); + } + } + } + + // 默认返回一些测试值 + // 根据NPC ID的奇偶性返回不同阵营(仅用于测试) + if (int.TryParse(npcId, out var id)) + { + if (id >= 2000 && id < 3000) + return new List { (int)CampType.Player }; + else if (id >= 3000 && id < 4000) + return new List { (int)CampType.Enemy }; + else if (id >= 4000 && id < 5000) + return new List { (int)CampType.Neutral }; + } + + return new List { (int)CampType.None }; + } + + /// + /// 检查阵营关系 + /// + private bool CheckCampRelation(List npcCamps, List targetCamps, CampCheckMode mode) + { + switch (mode) + { + case CampCheckMode.Equal: + // 完全相等 + if (npcCamps.Count != targetCamps.Count) + return false; + return npcCamps.All(c => targetCamps.Contains(c)) && + targetCamps.All(c => npcCamps.Contains(c)); + + case CampCheckMode.Contains: + // NPC阵营包含目标阵营(NPC阵营是目标阵营的超集) + return targetCamps.All(c => npcCamps.Contains(c)); + + case CampCheckMode.Intersect: + // 相交(有交集) + return npcCamps.Any(c => targetCamps.Contains(c)); + + case CampCheckMode.IsTeammate: + // 是队友(包含、相交、相等都算) + // 1. 检查是否相等 + if (npcCamps.Count == targetCamps.Count && + npcCamps.All(c => targetCamps.Contains(c))) + return true; + // 2. 检查是否包含 + if (targetCamps.All(c => npcCamps.Contains(c))) + return true; + if (npcCamps.All(c => targetCamps.Contains(c))) + return true; + // 3. 检查是否相交 + if (npcCamps.Any(c => targetCamps.Contains(c))) + return true; + return false; + + case CampCheckMode.IsEnemy: + // 是敌人(不是队友) + return !CheckCampRelation(npcCamps, targetCamps, CampCheckMode.IsTeammate); + + default: + return false; + } + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/ExampleTasks.cs.meta b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/ExampleTasks.cs.meta new file mode 100644 index 0000000..645a013 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/ExampleTasks.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a54598fb3e5a3db4e849e80433f72cc6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/InitializeDictsTask.cs b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/InitializeDictsTask.cs new file mode 100644 index 0000000..08553b2 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/InitializeDictsTask.cs @@ -0,0 +1,444 @@ +using System.Collections.Generic; +using NodeCanvas.Framework; +using ParadoxNotion.Design; +using UnityEngine; +using GameplayEditor.Config; +using GameplayEditor.Core; + +namespace GameplayEditor.Nodes.Actions +{ + /// + /// 初始化黑板字典节点 + /// 在头文件中执行,创建并初始化黑板4域字典 + /// + /// Excel格式:InitializeBlackboardDicts + /// 或:初始化黑板 + /// + [Name("初始化黑板字典")] + [Description("初始化黑板4域字典(头文件专用)")] + [Category("Activity/Initialization")] + public class InitializeBlackboardDictsTask : ActionTask, IBtNodeWithParams + { + [SerializeField] + [Tooltip("是否初始化当前行为树域")] + private bool initTreeDict = true; + + [SerializeField] + [Tooltip("是否初始化角色域")] + private bool initRoleDict = true; + + [SerializeField] + [Tooltip("是否初始化自定义域")] + private bool initCustomDict = true; + + [SerializeField] + [Tooltip("是否初始化全局域")] + private bool initGlobalDict = true; + + [SerializeField] + [Tooltip("记录关卡开始时间")] + private bool recordLevelStartTime = true; + + [SerializeField] + [Tooltip("记录关卡开始帧数")] + private bool recordLevelStartFrame = true; + + protected override string info + => "初始化黑板4域字典"; + + public void SetRawParams(string[] rawParams) + { + // 此节点不需要参数 + } + + protected override void OnExecute() + { + if (blackboard == null) + { + Debug.LogError("[InitializeBlackboardDicts] 黑板为空"); + EndAction(false); + return; + } + + try + { + Debug.Log("[InitializeBlackboardDicts] 开始初始化黑板4域字典..."); + + // 1. 初始化4域字典 + InitializeDomains(); + + // 2. 记录关卡开始时间和帧数(用于DoGetTime) + if (recordLevelStartTime) + { + blackboard.SetVariableValue("LevelStartTime", Time.time); + Debug.Log($"[InitializeBlackboardDicts] 记录关卡开始时间: {Time.time}"); + } + + if (recordLevelStartFrame) + { + blackboard.SetVariableValue("LevelStartFrame", Time.frameCount); + Debug.Log($"[InitializeBlackboardDicts] 记录关卡开始帧数: {Time.frameCount}"); + } + + Debug.Log("[InitializeBlackboardDicts] 黑板4域字典初始化完成"); + EndAction(true); + } + catch (System.Exception ex) + { + Debug.LogError($"[InitializeBlackboardDicts] 初始化错误: {ex.Message}"); + EndAction(false); + } + } + + /// + /// 初始化4域字典 + /// + private void InitializeDomains() + { + // 1# 当前行为树域 - 存储当前行为树特有的数据 + if (initTreeDict) + { + var treeDict = new Dictionary(); + blackboard.SetVariableValue(BehaviourTreeExtended.TREE_DICT_KEY, treeDict); + Debug.Log($"[InitializeBlackboardDicts] 初始化行为树域: {BehaviourTreeExtended.TREE_DICT_KEY}"); + } + + // 2# 角色域 - 存储角色相关数据 + if (initRoleDict) + { + var roleDict = new Dictionary(); + blackboard.SetVariableValue(BehaviourTreeExtended.ROLE_DICT_KEY, roleDict); + Debug.Log($"[InitializeBlackboardDicts] 初始化角色域: {BehaviourTreeExtended.ROLE_DICT_KEY}"); + } + + // 3# 自定义域 - 存储策划自定义数据 + if (initCustomDict) + { + var customDict = new Dictionary(); + blackboard.SetVariableValue(BehaviourTreeExtended.CUSTOM_DICT_KEY, customDict); + Debug.Log($"[InitializeBlackboardDicts] 初始化自定义域: {BehaviourTreeExtended.CUSTOM_DICT_KEY}"); + } + + // 4# 系统全局域 - 存储全局共享数据 + if (initGlobalDict) + { + var globalDict = new Dictionary(); + blackboard.SetVariableValue(BehaviourTreeExtended.GLOBAL_DICT_KEY, globalDict); + Debug.Log($"[InitializeBlackboardDicts] 初始化全局域: {BehaviourTreeExtended.GLOBAL_DICT_KEY}"); + } + } + } + + /// + /// 从LUT加载资源到字典节点 + /// 将UI/相机/特效/地图等LUT配置加载到黑板字典中 + /// + /// Excel格式:LoadLutToDict lutType [stageId] + /// 例:LoadLutToDict UI (加载UI LUT到字典) + /// 例:LoadLutToDict Camera 1001 (加载关卡1001的相机LUT) + /// + [Name("加载LUT到字典")] + [Description("将LUT资源配置加载到黑板字典")] + [Category("Activity/Initialization")] + public class LoadLutToDictTask : ActionTask, IBtNodeWithParams + { + public enum LutType + { + UI, // UI资源 + Camera, // 相机资源 + FX, // 特效资源 + MapInfo, // 地图点位 + All // 全部加载 + } + + [SerializeField] + [Tooltip("LUT类型")] + private LutType lutType = LutType.All; + + [SerializeField] + [Tooltip("关卡ID(0表示使用当前关卡)")] + private int stageId = 0; + + [SerializeField] + [Tooltip("目标字典域")] + private DictDomain targetDomain = DictDomain.Tree; + + [SerializeField] + [Tooltip("LUT数据库引用")] + private LutConfigDatabase lutDatabase; + + public enum DictDomain + { + Tree, // 当前行为树域 + Role, // 角色域 + Custom, // 自定义域 + Global // 全局域 + } + + protected override string info + => $"加载{lutType}LUT到{targetDomain}域"; + + public void SetRawParams(string[] rawParams) + { + // 格式: LoadLutToDict lutType [stageId] [domain] + if (rawParams.Length > 0) + { + var typeStr = rawParams[0].ToLower(); + lutType = typeStr switch + { + "ui" => LutType.UI, + "camera" or "cam" => LutType.Camera, + "fx" or "effect" => LutType.FX, + "map" or "mapinfo" => LutType.MapInfo, + "all" => LutType.All, + _ => LutType.All + }; + } + + if (rawParams.Length > 1 && int.TryParse(rawParams[1], out var sid)) + stageId = sid; + + if (rawParams.Length > 2) + { + var domainStr = rawParams[2].ToLower(); + targetDomain = domainStr switch + { + "tree" => DictDomain.Tree, + "role" => DictDomain.Role, + "custom" => DictDomain.Custom, + "global" => DictDomain.Global, + _ => DictDomain.Tree + }; + } + } + + protected override void OnExecute() + { + if (blackboard == null) + { + Debug.LogError("[LoadLutToDict] 黑板为空"); + EndAction(false); + return; + } + + // 如果没有指定LUT数据库,尝试从黑板或全局获取 + if (lutDatabase == null) + { + // TODO: 从全局配置获取 + Debug.LogWarning("[LoadLutToDict] LUT数据库未设置,尝试从黑板获取..."); + } + + try + { + Debug.Log($"[LoadLutToDict] 开始加载{lutType} LUT到{targetDomain}域..."); + + // 获取目标字典 + var targetDict = GetTargetDict(); + if (targetDict == null) + { + Debug.LogError($"[LoadLutToDict] 无法获取{targetDomain}域字典,请先初始化黑板字典"); + EndAction(false); + return; + } + + // 加载指定的LUT类型 + if (lutType == LutType.UI || lutType == LutType.All) + LoadUILut(targetDict); + + if (lutType == LutType.Camera || lutType == LutType.All) + LoadCameraLut(targetDict); + + if (lutType == LutType.FX || lutType == LutType.All) + LoadFXLut(targetDict); + + if (lutType == LutType.MapInfo || lutType == LutType.All) + LoadMapInfoLut(targetDict); + + Debug.Log($"[LoadLutToDict] LUT加载完成,字典项数: {targetDict.Count}"); + EndAction(true); + } + catch (System.Exception ex) + { + Debug.LogError($"[LoadLutToDict] 加载错误: {ex.Message}"); + EndAction(false); + } + } + + /// + /// 获取目标字典 + /// + private Dictionary GetTargetDict() + { + string dictKey = targetDomain switch + { + DictDomain.Tree => BehaviourTreeExtended.TREE_DICT_KEY, + DictDomain.Role => BehaviourTreeExtended.ROLE_DICT_KEY, + DictDomain.Custom => BehaviourTreeExtended.CUSTOM_DICT_KEY, + DictDomain.Global => BehaviourTreeExtended.GLOBAL_DICT_KEY, + _ => BehaviourTreeExtended.TREE_DICT_KEY + }; + + if (blackboard.variables.ContainsKey(dictKey)) + { + return blackboard.GetVariableValue>(dictKey); + } + + // 如果字典不存在,创建它 + var dict = new Dictionary(); + blackboard.SetVariableValue(dictKey, dict); + return dict; + } + + /// + /// 加载UI LUT + /// + private void LoadUILut(Dictionary dict) + { + Debug.Log("[LoadLutToDict] 加载UI LUT..."); + + if (lutDatabase != null) + { + foreach (var uiLut in lutDatabase.UiLuts) + { + foreach (var mapping in uiLut.UiMappings) + { + // 如果指定了关卡ID,只加载匹配的资源 + if (stageId > 0 && mapping.StageID != stageId) + continue; + + var key = $"UI_{mapping.MappingID}"; + var value = new UILutEntry + { + StageID = mapping.StageID, + MappingID = mapping.MappingID, + PrefabPath = mapping.PrefabPath + }; + dict[key] = value; + Debug.Log($"[LoadLutToDict] UI: {key} = {mapping.PrefabPath}"); + } + } + } + else + { + // 从黑板或全局获取UI配置 + // 这里可以添加从其他来源加载的逻辑 + Debug.LogWarning("[LoadLutToDict] LUT数据库为空,无法加载UI LUT"); + } + } + + /// + /// 加载相机LUT + /// + private void LoadCameraLut(Dictionary dict) + { + Debug.Log("[LoadLutToDict] 加载Camera LUT..."); + + if (lutDatabase != null) + { + foreach (var camLut in lutDatabase.CameraLuts) + { + foreach (var mapping in camLut.CameraMappings) + { + if (stageId > 0 && mapping.StageID != stageId) + continue; + + var key = $"Cam_{mapping.MappingID}"; + var value = new CameraLutEntry + { + StageID = mapping.StageID, + MappingID = mapping.MappingID, + PrefabPath = mapping.PrefabPath + }; + dict[key] = value; + Debug.Log($"[LoadLutToDict] Camera: {key} = {mapping.PrefabPath}"); + } + } + } + } + + /// + /// 加载特效LUT + /// + private void LoadFXLut(Dictionary dict) + { + Debug.Log("[LoadLutToDict] 加载FX LUT..."); + + if (lutDatabase != null) + { + foreach (var fxLut in lutDatabase.FxLuts) + { + foreach (var mapping in fxLut.FxMappings) + { + if (stageId > 0 && mapping.StageID != stageId) + continue; + + var key = $"FX_{mapping.MappingID}"; + var value = new FXLutEntry + { + StageID = mapping.StageID, + MappingID = mapping.MappingID, + PrefabPath = mapping.PrefabPath + }; + dict[key] = value; + Debug.Log($"[LoadLutToDict] FX: {key} = {mapping.PrefabPath}"); + } + } + } + } + + /// + /// 加载地图点位LUT + /// + private void LoadMapInfoLut(Dictionary dict) + { + Debug.Log("[LoadLutToDict] 加载MapInfo LUT..."); + + if (lutDatabase != null) + { + foreach (var mapLut in lutDatabase.MapInfoLuts) + { + var key = $"Map_{mapLut.InfoID}"; + var value = new MapInfoLutEntry + { + InfoID = mapLut.InfoID, + Points = mapLut.Points + }; + dict[key] = value; + Debug.Log($"[LoadLutToDict] MapInfo: {key}, 点位数={mapLut.Points.Count}"); + } + } + } + } + + // LUT条目数据结构 + [System.Serializable] + public class UILutEntry + { + public int StageID; + public int MappingID; + public string PrefabPath; + } + + [System.Serializable] + public class CameraLutEntry + { + public int StageID; + public int MappingID; + public string PrefabPath; + } + + [System.Serializable] + public class FXLutEntry + { + public int StageID; + public int MappingID; + public string PrefabPath; + } + + [System.Serializable] + public class MapInfoLutEntry + { + public int InfoID; + public List Points; + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/InitializeDictsTask.cs.meta b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/InitializeDictsTask.cs.meta new file mode 100644 index 0000000..dd96c5c --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/InitializeDictsTask.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c4c718d7d33d17a4085d193bc7026326 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/PerformanceTrackedTaskBase.cs b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/PerformanceTrackedTaskBase.cs new file mode 100644 index 0000000..f2617d2 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/PerformanceTrackedTaskBase.cs @@ -0,0 +1,154 @@ +using GameplayEditor.Core; +using NodeCanvas.Framework; +using System.Diagnostics; +using UnityEngine; + +namespace GameplayEditor.Nodes.Actions +{ + /// + /// 性能跟踪的任务基类 + /// 自动集成性能监控,记录执行时间和高消耗操作 + /// + public abstract class PerformanceTrackedActionTask : ActionTask + { + // 性能监控开关 + protected virtual bool EnablePerformanceTracking => true; + + // 是否检测Transform操作 + protected virtual bool DetectTransformOperations => true; + + // 执行计时器 + private Stopwatch _stopwatch; + + /// + /// 执行前的准备 + /// + protected sealed override void OnExecute() + { + if (EnablePerformanceTracking && BTPerformanceMonitor.Instance != null) + { + _stopwatch = Stopwatch.StartNew(); + BTPerformanceMonitor.Instance.BeginNodeExecution(this); + } + + try + { + // 调用子类的执行逻辑 + OnExecuteTracked(); + } + finally + { + if (EnablePerformanceTracking && BTPerformanceMonitor.Instance != null && _stopwatch != null) + { + _stopwatch.Stop(); + float elapsedMs = (float)_stopwatch.Elapsed.TotalMilliseconds; + BTPerformanceMonitor.Instance.EndNodeExecution(this, elapsedMs); + } + } + } + + /// + /// 子类实现的执行逻辑(带性能跟踪) + /// + protected abstract void OnExecuteTracked(); + + #region 高消耗操作记录 + + /// + /// 记录SetPosition操作 + /// + protected void RecordSetPosition(Transform transform) + { + if (DetectTransformOperations && BTPerformanceMonitor.Instance != null) + { + BTPerformanceMonitor.Instance.RecordSetPosition(transform); + } + } + + /// + /// 记录SetRotation操作 + /// + protected void RecordSetRotation(Transform transform) + { + if (DetectTransformOperations && BTPerformanceMonitor.Instance != null) + { + BTPerformanceMonitor.Instance.RecordSetRotation(transform); + } + } + + /// + /// 记录SetActive操作 + /// + protected void RecordSetActive(GameObject go) + { + if (DetectTransformOperations && BTPerformanceMonitor.Instance != null) + { + BTPerformanceMonitor.Instance.RecordSetActive(go); + } + } + + /// + /// 记录Instantiate操作 + /// + protected void RecordInstantiate(string prefabName) + { + if (BTPerformanceMonitor.Instance != null) + { + BTPerformanceMonitor.Instance.RecordInstantiate(prefabName); + } + } + + /// + /// 记录自定义高消耗操作 + /// + protected void RecordExpensiveOperation(string operationType, string context = "") + { + if (BTPerformanceMonitor.Instance != null) + { + BTPerformanceMonitor.Instance.RecordExpensiveOperation(operationType, context); + } + } + + #endregion + } + + /// + /// 性能跟踪的条件任务基类 + /// + public abstract class PerformanceTrackedConditionTask : ConditionTask + { + // 性能监控开关 + protected virtual bool EnablePerformanceTracking => true; + + // 执行计时器 + private Stopwatch _stopwatch; + + protected sealed override bool OnCheck() + { + if (EnablePerformanceTracking && BTPerformanceMonitor.Instance != null) + { + _stopwatch = Stopwatch.StartNew(); + BTPerformanceMonitor.Instance.BeginNodeExecution(this); + } + + try + { + return OnCheckTracked(); + } + finally + { + if (EnablePerformanceTracking && BTPerformanceMonitor.Instance != null && _stopwatch != null) + { + _stopwatch.Stop(); + float elapsedMs = (float)_stopwatch.Elapsed.TotalMilliseconds; + BTPerformanceMonitor.Instance.EndNodeExecution(this, elapsedMs); + } + } + } + + /// + /// 子类实现的检查逻辑(带性能跟踪) + /// + protected abstract bool OnCheckTracked(); + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/PerformanceTrackedTaskBase.cs.meta b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/PerformanceTrackedTaskBase.cs.meta new file mode 100644 index 0000000..1319713 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/PerformanceTrackedTaskBase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5c6ee86654110234c944c04785b03a69 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/SceneTasks.cs b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/SceneTasks.cs new file mode 100644 index 0000000..8a5020b --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/SceneTasks.cs @@ -0,0 +1,501 @@ +using System.Collections.Generic; +using GameplayEditor.Core; +using NodeCanvas.Framework; +using ParadoxNotion.Design; +using UnityEngine; + +namespace GameplayEditor.Nodes.Actions +{ + /// + /// 生成NPC节点 + /// Excel格式:DoSpawnNPC npcId position + /// 例:DoSpawnNPC 2001 10,0,20 + /// + [Name("生成NPC")] + [Description("在指定位置生成NPC")] + [Category("Activity/Scene")] + public class DoSpawnNPCTask : ActionTask, IBtNodeWithParams + { + [SerializeField] + [Tooltip("NPC配置ID")] + private string npcId = ""; + + [SerializeField] + [Tooltip("生成位置 (x,y,z)")] + private Vector3 spawnPosition = Vector3.zero; + + [SerializeField] + [Tooltip("生成朝向 (x,y,z)")] + private Vector3 spawnRotation = Vector3.zero; + + [SerializeField] + [Tooltip("是否等待生成完成")] + private bool waitForSpawn = false; + + [SerializeField] + [Tooltip("使用地图点位ID(优先级高于坐标)")] + private string mapPointId = ""; + + protected override string info + => string.IsNullOrEmpty(mapPointId) + ? $"生成NPC [{npcId}] 在 {spawnPosition}" + : $"生成NPC [{npcId}] 在点位 [{mapPointId}]"; + + public void SetRawParams(string[] rawParams) + { + if (rawParams.Length > 0) npcId = rawParams[0]; + if (rawParams.Length > 1) + { + // 尝试解析为Vector3 + spawnPosition = ParseVector3(rawParams[1]); + // 如果不是坐标格式,可能是地图点位ID + if (spawnPosition == Vector3.zero && !rawParams[1].Contains(",")) + { + mapPointId = rawParams[1]; + } + } + if (rawParams.Length > 2) spawnRotation = ParseVector3(rawParams[2]); + } + + protected override void OnExecute() + { + if (string.IsNullOrEmpty(npcId)) + { + Debug.LogError("[DoSpawnNPC] NPC ID不能为空"); + EndAction(false); + return; + } + + Vector3 finalPosition = spawnPosition; + + // 如果有地图点位ID,查询实际坐标 + if (!string.IsNullOrEmpty(mapPointId)) + { + // 从黑板字典查询地图点位 + if (blackboard != null && blackboard.variables.ContainsKey("ActivityTreeDict")) + { + var dict = blackboard.GetVariableValue>("ActivityTreeDict"); + var mapKey = $"Map_{mapPointId}"; + if (dict != null && dict.TryGetValue(mapKey, out var mapEntry)) + { + if (mapEntry is MapInfoLutEntry mapInfo && mapInfo.Points.Count > 0) + { + finalPosition = mapInfo.Points[0].Position; + Debug.Log($"[DoSpawnNPC] 从字典获取地图点位: {mapPointId} -> {finalPosition}"); + } + } + } + else + { + Debug.Log($"[DoSpawnNPC] 使用地图点位: {mapPointId}"); + } + } + + Debug.Log($"[DoSpawnNPC] 生成NPC: {npcId} 在位置 {finalPosition}, 朝向 {spawnRotation}"); + + // 调用NPC管理器生成 + var npcManager = GameplayManagerHub.Instance?.NPCManager; + if (npcManager != null) + { + npcManager.SpawnNPC(npcId, finalPosition, Quaternion.Euler(spawnRotation)); + } + else + { + Debug.LogWarning("[DoSpawnNPC] NPC管理器未注册,仅记录生成请求"); + } + + if (waitForSpawn) + { + StartCoroutine(WaitAndComplete(0.5f)); + } + else + { + EndAction(true); + } + } + + private System.Collections.IEnumerator WaitAndComplete(float seconds) + { + yield return new WaitForSeconds(seconds); + EndAction(true); + } + + private Vector3 ParseVector3(string str) + { + str = str.Trim('(', ')', '(', ')'); + var parts = str.Split(new[] { ',', ',', '|' }, System.StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length >= 3) + { + if (float.TryParse(parts[0].Trim(), out var x) && + float.TryParse(parts[1].Trim(), out var y) && + float.TryParse(parts[2].Trim(), out var z)) + { + return new Vector3(x, y, z); + } + } + else if (parts.Length == 2) + { + if (float.TryParse(parts[0].Trim(), out var x) && + float.TryParse(parts[1].Trim(), out var z)) + { + return new Vector3(x, 0, z); + } + } + + return Vector3.zero; + } + } + + /// + /// 播放动画节点 + /// Excel格式:DoPlayAnimation npcId animName + /// 例:DoPlayAnimation 2001 Idle + /// + [Name("播放动画")] + [Description("播放指定NPC的动画")] + [Category("Activity/Scene")] + public class DoPlayAnimationTask : ActionTask, IBtNodeWithParams + { + [SerializeField] + [Tooltip("NPC ID")] + private string npcId = ""; + + [SerializeField] + [Tooltip("动画名称")] + private string animationName = ""; + + [SerializeField] + [Tooltip("播放模式")] + private PlayMode playMode = PlayMode.Play; + + [SerializeField] + [Tooltip("是否等待动画完成")] + private bool waitForComplete = false; + + [SerializeField] + [Tooltip("播放速度")] + private float playSpeed = 1f; + + public enum PlayMode + { + Play, // 播放 + CrossFade, // 交叉淡入淡出 + PlayQueued // 队列播放 + } + + protected override string info + => $"播放动画 [{npcId}] [{animationName}]"; + + public void SetRawParams(string[] rawParams) + { + if (rawParams.Length > 0) npcId = rawParams[0]; + if (rawParams.Length > 1) animationName = rawParams[1]; + } + + protected override void OnExecute() + { + if (string.IsNullOrEmpty(npcId) || string.IsNullOrEmpty(animationName)) + { + Debug.LogError("[DoPlayAnimation] NPC ID和动画名称不能为空"); + EndAction(false); + return; + } + + Debug.Log($"[DoPlayAnimation] NPC {npcId} 播放动画: {animationName}, 模式: {playMode}"); + + // 调用NPC管理器播放动画 + var npcManager = GameplayManagerHub.Instance?.NPCManager; + if (npcManager != null) + { + npcManager.PlayAnimation(npcId, animationName); + } + else + { + Debug.LogWarning("[DoPlayAnimation] NPC管理器未注册"); + } + + if (waitForComplete) + { + // TODO: 获取动画时长并等待 + StartCoroutine(WaitAndComplete(1f)); + } + else + { + EndAction(true); + } + } + + private System.Collections.IEnumerator WaitAndComplete(float seconds) + { + yield return new WaitForSeconds(seconds / playSpeed); + EndAction(true); + } + } + + /// + /// 播放特效节点 + /// Excel格式:DoPlayEffect effectId position + /// 例:DoPlayEffect 1001 10,0,20 + /// + [Name("播放特效")] + [Description("在指定位置播放特效")] + [Category("Activity/Scene")] + public class DoPlayEffectTask : ActionTask, IBtNodeWithParams + { + [SerializeField] + [Tooltip("特效ID")] + private int effectId = 0; + + [SerializeField] + [Tooltip("特效位置")] + private Vector3 effectPosition = Vector3.zero; + + [SerializeField] + [Tooltip("父对象(NPC ID,为空则在世界坐标播放)")] + private string parentNpcId = ""; + + [SerializeField] + [Tooltip("持续时间(秒,0表示使用特效默认时长)")] + private float duration = 0f; + + [SerializeField] + [Tooltip("是否等待特效完成")] + private bool waitForComplete = false; + + protected override string info + => $"播放特效 [{effectId}]"; + + public void SetRawParams(string[] rawParams) + { + if (rawParams.Length > 0 && int.TryParse(rawParams[0], out var id)) + effectId = id; + if (rawParams.Length > 1) + { + effectPosition = ParseVector3(rawParams[1]); + } + } + + protected override void OnExecute() + { + if (effectId <= 0) + { + Debug.LogError("[DoPlayEffect] 特效ID无效"); + EndAction(false); + return; + } + + Vector3 finalPosition = effectPosition; + + // 如果有父对象,获取其位置 + if (!string.IsNullOrEmpty(parentNpcId)) + { + var npcManager = GameplayManagerHub.Instance?.NPCManager; + if (npcManager != null) + { + finalPosition = npcManager.GetNPCPosition(parentNpcId); + } + Debug.Log($"[DoPlayEffect] 特效绑定到NPC: {parentNpcId}, 位置={finalPosition}"); + } + + Debug.Log($"[DoPlayEffect] 播放特效: ID={effectId}, 位置={finalPosition}"); + + // 调用特效系统 + var fxManager = GameplayManagerHub.Instance?.FXManager; + if (fxManager != null) + { + if (!string.IsNullOrEmpty(parentNpcId)) + { + fxManager.PlayEffectOnNPC(effectId, parentNpcId, duration); + } + else + { + fxManager.PlayEffect(effectId, finalPosition, duration); + } + } + else + { + Debug.LogWarning("[DoPlayEffect] 特效管理器未注册"); + } + + if (waitForComplete && duration > 0) + { + StartCoroutine(WaitAndComplete(duration)); + } + else + { + EndAction(true); + } + } + + private System.Collections.IEnumerator WaitAndComplete(float seconds) + { + yield return new WaitForSeconds(seconds); + EndAction(true); + } + + private Vector3 ParseVector3(string str) + { + str = str.Trim('(', ')', '(', ')'); + var parts = str.Split(new[] { ',', ',', '|' }, System.StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length >= 3) + { + if (float.TryParse(parts[0].Trim(), out var x) && + float.TryParse(parts[1].Trim(), out var y) && + float.TryParse(parts[2].Trim(), out var z)) + { + return new Vector3(x, y, z); + } + } + + return Vector3.zero; + } + } + + /// + /// 切换相机节点 + /// Excel格式:DoChangeCamera cameraId + /// 例:DoChangeCamera 1001 + /// + [Name("切换相机")] + [Description("切换到指定相机")] + [Category("Activity/Scene")] + public class DoChangeCameraTask : ActionTask, IBtNodeWithParams + { + [SerializeField] + [Tooltip("相机ID")] + private int cameraId = 0; + + [SerializeField] + [Tooltip("过渡时间(秒)")] + private float transitionTime = 0.5f; + + [SerializeField] + [Tooltip("等待过渡完成")] + private bool waitForTransition = false; + + protected override string info + => $"切换相机 [{cameraId}]"; + + public void SetRawParams(string[] rawParams) + { + if (rawParams.Length > 0 && int.TryParse(rawParams[0], out var id)) + cameraId = id; + if (rawParams.Length > 1 && float.TryParse(rawParams[1], out var time)) + transitionTime = time; + } + + protected override void OnExecute() + { + if (cameraId <= 0) + { + Debug.LogError("[DoChangeCamera] 相机ID无效"); + EndAction(false); + return; + } + + Debug.Log($"[DoChangeCamera] 切换到相机: {cameraId}, 过渡时间: {transitionTime}s"); + + // 调用相机系统 + var cameraManager = GameplayManagerHub.Instance?.CameraManager; + if (cameraManager != null) + { + cameraManager.SwitchToCamera(cameraId, transitionTime); + } + else + { + Debug.LogWarning("[DoChangeCamera] 相机管理器未注册"); + } + + if (waitForTransition && transitionTime > 0) + { + StartCoroutine(WaitAndComplete(transitionTime)); + } + else + { + EndAction(true); + } + } + + private System.Collections.IEnumerator WaitAndComplete(float seconds) + { + yield return new WaitForSeconds(seconds); + EndAction(true); + } + } + + /// + /// 加载场景节点 + /// Excel格式:DoLoadScene sceneName + /// 例:DoLoadScene Level_01 + /// + [Name("加载场景")] + [Description("加载指定场景")] + [Category("Activity/Scene")] + public class DoLoadSceneTask : ActionTask, IBtNodeWithParams + { + [SerializeField] + [Tooltip("场景名称")] + private string sceneName = ""; + + [SerializeField] + [Tooltip("加载模式")] + private LoadMode loadMode = LoadMode.Single; + + [SerializeField] + [Tooltip("等待加载完成")] + private bool waitForLoad = true; + + // showLoading 暂未使用,预留字段 + // [SerializeField] + // [Tooltip("显示加载画面")] + // private bool showLoading = true; + + public enum LoadMode + { + Single, // 单场景 + Additive // 叠加场景 + } + + protected override string info + => $"加载场景 [{sceneName}]"; + + public void SetRawParams(string[] rawParams) + { + if (rawParams.Length > 0) sceneName = rawParams[0]; + } + + protected override void OnExecute() + { + if (string.IsNullOrEmpty(sceneName)) + { + Debug.LogError("[DoLoadScene] 场景名称不能为空"); + EndAction(false); + return; + } + + Debug.Log($"[DoLoadScene] 加载场景: {sceneName}, 模式: {loadMode}"); + + // TODO: 调用场景管理器 + // if (showLoading) UIManager.ShowLoading(); + // SceneManager.LoadScene(sceneName, loadMode == LoadMode.Additive ? LoadSceneMode.Additive : LoadSceneMode.Single); + + if (waitForLoad) + { + StartCoroutine(WaitForSceneLoad()); + } + else + { + EndAction(true); + } + } + + private System.Collections.IEnumerator WaitForSceneLoad() + { + // TODO: 监听场景加载完成事件 + yield return new WaitForSeconds(1f); + EndAction(true); + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/SceneTasks.cs.meta b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/SceneTasks.cs.meta new file mode 100644 index 0000000..fe2b2fc --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/SceneTasks.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f641e661f7ee6f6448a37cf1d4f83e77 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/SceneTasks_PerformanceSafe.cs b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/SceneTasks_PerformanceSafe.cs new file mode 100644 index 0000000..9936df5 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/SceneTasks_PerformanceSafe.cs @@ -0,0 +1,232 @@ +using GameplayEditor.Core; +using NodeCanvas.Framework; +using ParadoxNotion.Design; +using UnityEngine; + +namespace GameplayEditor.Nodes.Actions +{ + /// + /// 安全的设置位置节点 + /// 集成性能监控,检测高频SetPosition调用 + /// + [Name("设置位置(安全)")] + [Description("安全地设置GameObject位置,带性能监控")] + [Category("Activity/Scene/PerformanceSafe")] + public class DoSetPositionSafeTask : ActionTask, IBtNodeWithParams + { + [SerializeField] + private Vector3 _targetPosition; + + [SerializeField] + [Tooltip("是否使用局部坐标")] + private bool _useLocalPosition = false; + + [SerializeField] + [Tooltip("是否启用性能监控")] + private bool _enableMonitoring = true; + + // 性能监控阈值 + private const float MIN_CALL_INTERVAL = 0.016f; // 最小调用间隔(约60fps) + private float _lastCallTime; + + public void SetRawParams(string[] rawParams) + { + if (rawParams.Length >= 3 && + float.TryParse(rawParams[0], out float x) && + float.TryParse(rawParams[1], out float y) && + float.TryParse(rawParams[2], out float z)) + { + _targetPosition = new Vector3(x, y, z); + } + + if (rawParams.Length >= 4) + { + _useLocalPosition = rawParams[3].ToLower() == "local" || rawParams[3] == "1"; + } + } + + protected override void OnExecute() + { + if (agent == null) + { + EndAction(false); + return; + } + + // 性能监控 + if (_enableMonitoring) + { + float currentTime = Time.time; + float interval = currentTime - _lastCallTime; + + // 检查调用频率 + if (interval < MIN_CALL_INTERVAL) + { + BTPerformanceMonitor.Instance?.RecordSetPosition(agent); + + Debug.LogWarning($"[DoSetPositionSafe] 高频SetPosition检测: {agent.name} " + + $"间隔={interval:F4}s,建议优化行为树逻辑"); + } + + _lastCallTime = currentTime; + } + + // 执行设置 + if (_useLocalPosition) + { + agent.localPosition = _targetPosition; + } + else + { + agent.position = _targetPosition; + } + + EndAction(true); + } + } + + /// + /// 平滑移动节点(替代直接SetPosition) + /// + [Name("平滑移动")] + [Description("使用插值平滑移动到目标位置,避免突兀的SetPosition")] + [Category("Activity/Scene/PerformanceSafe")] + public class DoMoveSmoothTask : ActionTask, IBtNodeWithParams + { + [SerializeField] + private Vector3 _targetPosition; + + [SerializeField] + [Tooltip("移动时间(秒)")] + private float _duration = 1f; + + [SerializeField] + [Tooltip("缓动类型")] + private EaseType _easeType = EaseType.Linear; + + // 运行时 + private Vector3 _startPosition; + private float _startTime; + + public enum EaseType + { + Linear, + EaseIn, + EaseOut, + EaseInOut + } + + public void SetRawParams(string[] rawParams) + { + // 格式: x y z [duration] [easeType] + if (rawParams.Length >= 3 && + float.TryParse(rawParams[0], out float x) && + float.TryParse(rawParams[1], out float y) && + float.TryParse(rawParams[2], out float z)) + { + _targetPosition = new Vector3(x, y, z); + } + + if (rawParams.Length >= 4 && float.TryParse(rawParams[3], out float duration)) + { + _duration = duration; + } + } + + protected override void OnExecute() + { + if (agent == null) + { + EndAction(false); + return; + } + + _startPosition = agent.position; + _startTime = Time.time; + } + + protected override void OnUpdate() + { + float elapsed = Time.time - _startTime; + float t = Mathf.Clamp01(elapsed / _duration); + + // 应用缓动 + t = ApplyEase(t, _easeType); + + // 插值位置 + agent.position = Vector3.Lerp(_startPosition, _targetPosition, t); + + if (t >= 1f) + { + EndAction(true); + } + } + + private float ApplyEase(float t, EaseType ease) + { + return ease switch + { + EaseType.EaseIn => t * t, + EaseType.EaseOut => 1 - (1 - t) * (1 - t), + EaseType.EaseInOut => t < 0.5f ? 2 * t * t : 1 - Mathf.Pow(-2 * t + 2, 2) / 2, + _ => t + }; + } + } + + /// + /// 性能安全的对象激活节点 + /// + [Name("设置激活(安全)")] + [Description("安全地设置GameObject激活状态,带性能监控")] + [Category("Activity/Scene/PerformanceSafe")] + public class DoSetActiveSafeTask : ActionTask, IBtNodeWithParams + { + [SerializeField] + private bool _active = true; + + [SerializeField] + [Tooltip("是否启用性能监控")] + private bool _enableMonitoring = true; + + // 避免无意义的重复SetActive + private bool? _lastState; + + public void SetRawParams(string[] rawParams) + { + if (rawParams.Length > 0) + { + string val = rawParams[0].ToLower(); + _active = val == "true" || val == "1" || val == "on"; + } + } + + protected override void OnExecute() + { + if (agent == null) + { + EndAction(false); + return; + } + + // 检查是否无意义的状态切换 + if (_lastState.HasValue && _lastState.Value == _active && agent.activeSelf == _active) + { + // 跳过重复的SetActive调用 + EndAction(true); + return; + } + + // 性能监控 + if (_enableMonitoring) + { + BTPerformanceMonitor.Instance?.RecordSetActive(agent); + } + + agent.SetActive(_active); + _lastState = _active; + + EndAction(true); + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/SceneTasks_PerformanceSafe.cs.meta b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/SceneTasks_PerformanceSafe.cs.meta new file mode 100644 index 0000000..b8b6bdc --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/SceneTasks_PerformanceSafe.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c54cd0580a646de41ad0b05a54589f30 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/UtilityTasks.cs b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/UtilityTasks.cs new file mode 100644 index 0000000..0bf4324 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/UtilityTasks.cs @@ -0,0 +1,460 @@ +using System.Collections.Generic; +using NodeCanvas.Framework; +using ParadoxNotion.Design; +using UnityEngine; + +namespace GameplayEditor.Nodes.Actions +{ + /// + /// 等待节点 + /// Excel格式:DoWait timeSeconds + /// 例:DoWait 2.5 + /// + [Name("等待")] + [Description("等待指定秒数")] + [Category("Activity/Utility")] + public class DoWaitTask : ActionTask, IBtNodeWithParams + { + [SerializeField] + [Tooltip("等待时间(秒)")] + private float waitTime = 1f; + + [SerializeField] + [Tooltip("使用真实时间(不受Time.timeScale影响)")] + private bool useRealTime = false; + + private float _startTime; + + protected override string info + => $"等待 {waitTime}s"; + + public void SetRawParams(string[] rawParams) + { + if (rawParams.Length > 0 && float.TryParse(rawParams[0], out var time)) + waitTime = time; + } + + protected override void OnExecute() + { + _startTime = useRealTime ? Time.realtimeSinceStartup : Time.time; + } + + protected override void OnUpdate() + { + float elapsed = useRealTime + ? (Time.realtimeSinceStartup - _startTime) + : (Time.time - _startTime); + + if (elapsed >= waitTime) + { + EndAction(true); + } + } + } + + /// + /// 等待帧数节点 + /// Excel格式:DoWaitFrames frameCount + /// 例:DoWaitFrames 60 + /// + [Name("等待帧数")] + [Description("等待指定帧数")] + [Category("Activity/Utility")] + public class DoWaitFramesTask : ActionTask, IBtNodeWithParams + { + [SerializeField] + [Tooltip("等待帧数")] + private int frameCount = 1; + + private int _startFrame; + + protected override string info + => $"等待 {frameCount} 帧"; + + public void SetRawParams(string[] rawParams) + { + if (rawParams.Length > 0 && int.TryParse(rawParams[0], out var frames)) + frameCount = frames; + } + + protected override void OnExecute() + { + _startFrame = Time.frameCount; + } + + protected override void OnUpdate() + { + if (Time.frameCount - _startFrame >= frameCount) + { + EndAction(true); + } + } + } + + /// + /// 设置黑板变量节点 + /// Excel格式:DoSetVariable varName value + /// 例:DoSetVariable EnemyCount 5 + /// + [Name("设置变量")] + [Description("设置黑板变量值")] + [Category("Activity/Utility")] + public class DoSetVariableTask : ActionTask, IBtNodeWithParams + { + [SerializeField] + [Tooltip("变量名")] + private string variableName = ""; + + [SerializeField] + [Tooltip("变量值")] + private string variableValue = ""; + + [SerializeField] + [Tooltip("变量类型")] + private VariableType varType = VariableType.String; + + public enum VariableType + { + String, + Int, + Float, + Bool, + Vector3 + } + + protected override string info + => $"设置 [{variableName}] = {variableValue}"; + + public void SetRawParams(string[] rawParams) + { + if (rawParams.Length > 0) variableName = rawParams[0]; + if (rawParams.Length > 1) variableValue = rawParams[1]; + } + + protected override void OnExecute() + { + if (string.IsNullOrEmpty(variableName)) + { + Debug.LogError("[DoSetVariable] 变量名不能为空"); + EndAction(false); + return; + } + + // 解析变量值 + object value = ParseValue(variableValue); + + // 设置到黑板 + if (blackboard != null) + { + blackboard.SetVariableValue(variableName, value); + Debug.Log($"[DoSetVariable] 设置变量: {variableName} = {value} ({varType})"); + EndAction(true); + } + else + { + Debug.LogError("[DoSetVariable] 黑板为空"); + EndAction(false); + } + } + + private object ParseValue(string str) + { + switch (varType) + { + case VariableType.Int: + if (int.TryParse(str, out var intVal)) + return intVal; + return 0; + + case VariableType.Float: + if (float.TryParse(str, out var floatVal)) + return floatVal; + return 0f; + + case VariableType.Bool: + return str.ToLower() == "true" || str == "1" || str.ToLower() == "yes"; + + case VariableType.Vector3: + return ParseVector3(str); + + case VariableType.String: + default: + return str; + } + } + + private Vector3 ParseVector3(string str) + { + str = str.Trim('(', ')', '(', ')'); + var parts = str.Split(new[] { ',', ',', '|' }, System.StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length >= 3) + { + if (float.TryParse(parts[0].Trim(), out var x) && + float.TryParse(parts[1].Trim(), out var y) && + float.TryParse(parts[2].Trim(), out var z)) + { + return new Vector3(x, y, z); + } + } + + return Vector3.zero; + } + } + + /// + /// 检查变量节点 + /// Excel格式:CheckVariable varName operator value + /// 例:CheckVariable EnemyCount > 0 + /// + [Name("检查变量")] + [Description("检查黑板变量是否满足条件")] + [Category("Activity/Utility")] + public class CheckVariableTask : ConditionTask, IBtNodeWithParams + { + public enum ComparisonOperator + { + Equal, // == + NotEqual, // != + GreaterThan, // > + LessThan, // < + GreaterOrEqual, // >= + LessOrEqual, // <= + Contains, // 包含(字符串) + StartsWith, // 开头(字符串) + EndsWith // 结尾(字符串) + } + + [SerializeField] + [Tooltip("变量名")] + private string variableName = ""; + + [SerializeField] + [Tooltip("比较运算符")] + private ComparisonOperator comparison = ComparisonOperator.Equal; + + [SerializeField] + [Tooltip("目标值")] + private string targetValue = ""; + + [SerializeField] + [Tooltip("变量类型")] + private VariableType varType = VariableType.String; + + public enum VariableType + { + String, + Int, + Float, + Bool + } + + protected override string info + => $"检查 [{variableName}] {GetOperatorString()} {targetValue}"; + + private string GetOperatorString() + { + return comparison switch + { + ComparisonOperator.Equal => "==", + ComparisonOperator.NotEqual => "!=", + ComparisonOperator.GreaterThan => ">", + ComparisonOperator.LessThan => "<", + ComparisonOperator.GreaterOrEqual => ">=", + ComparisonOperator.LessOrEqual => "<=", + ComparisonOperator.Contains => "包含", + ComparisonOperator.StartsWith => "开头", + ComparisonOperator.EndsWith => "结尾", + _ => "?" + }; + } + + public void SetRawParams(string[] rawParams) + { + if (rawParams.Length > 0) variableName = rawParams[0]; + if (rawParams.Length > 1) ParseOperator(rawParams[1]); + if (rawParams.Length > 2) targetValue = rawParams[2]; + } + + private void ParseOperator(string op) + { + comparison = op.Trim() switch + { + "=" or "==" => ComparisonOperator.Equal, + "!=" or "<>" => ComparisonOperator.NotEqual, + ">" => ComparisonOperator.GreaterThan, + "<" => ComparisonOperator.LessThan, + ">=" => ComparisonOperator.GreaterOrEqual, + "<=" => ComparisonOperator.LessOrEqual, + "contains" or "包含" => ComparisonOperator.Contains, + "startswith" or "开头" => ComparisonOperator.StartsWith, + "endswith" or "结尾" => ComparisonOperator.EndsWith, + _ => ComparisonOperator.Equal + }; + } + + protected override bool OnCheck() + { + if (string.IsNullOrEmpty(variableName)) + { + Debug.LogError("[CheckVariable] 变量名不能为空"); + return false; + } + + if (blackboard == null) + { + Debug.LogError("[CheckVariable] 黑板为空"); + return false; + } + + // 获取变量值 + var variable = blackboard.GetVariable(variableName); + if (variable == null) + { + Debug.LogWarning($"[CheckVariable] 变量不存在: {variableName}"); + return false; + } + + var currentValue = variable.value; + if (currentValue == null) + { + Debug.LogWarning($"[CheckVariable] 变量值为null: {variableName}"); + return false; + } + + bool result = CompareValues(currentValue, targetValue); + + Debug.Log($"[CheckVariable] {variableName} = {currentValue}, 目标: {GetOperatorString()} {targetValue}, 结果: {result}"); + return result; + } + + private bool CompareValues(object current, string target) + { + try + { + switch (varType) + { + case VariableType.Int: + if (int.TryParse(target, out var targetInt) && current is int currentInt) + { + return comparison switch + { + ComparisonOperator.Equal => currentInt == targetInt, + ComparisonOperator.NotEqual => currentInt != targetInt, + ComparisonOperator.GreaterThan => currentInt > targetInt, + ComparisonOperator.LessThan => currentInt < targetInt, + ComparisonOperator.GreaterOrEqual => currentInt >= targetInt, + ComparisonOperator.LessOrEqual => currentInt <= targetInt, + _ => false + }; + } + break; + + case VariableType.Float: + if (float.TryParse(target, out var targetFloat) && current is float currentFloat) + { + return comparison switch + { + ComparisonOperator.Equal => Mathf.Approximately(currentFloat, targetFloat), + ComparisonOperator.NotEqual => !Mathf.Approximately(currentFloat, targetFloat), + ComparisonOperator.GreaterThan => currentFloat > targetFloat, + ComparisonOperator.LessThan => currentFloat < targetFloat, + ComparisonOperator.GreaterOrEqual => currentFloat >= targetFloat, + ComparisonOperator.LessOrEqual => currentFloat <= targetFloat, + _ => false + }; + } + break; + + case VariableType.Bool: + bool targetBool = target.ToLower() == "true" || target == "1"; + if (current is bool currentBool) + { + return comparison switch + { + ComparisonOperator.Equal => currentBool == targetBool, + ComparisonOperator.NotEqual => currentBool != targetBool, + _ => false + }; + } + break; + + case VariableType.String: + default: + string currentStr = current.ToString(); + return comparison switch + { + ComparisonOperator.Equal => currentStr == target, + ComparisonOperator.NotEqual => currentStr != target, + ComparisonOperator.Contains => currentStr.Contains(target), + ComparisonOperator.StartsWith => currentStr.StartsWith(target), + ComparisonOperator.EndsWith => currentStr.EndsWith(target), + _ => false + }; + } + } + catch (System.Exception ex) + { + Debug.LogError($"[CheckVariable] 比较错误: {ex.Message}"); + } + + return false; + } + } + + /// + /// 日志输出节点(调试用) + /// Excel格式:DoLog message + /// 例:DoLog "任务完成" + /// + [Name("输出日志")] + [Description("输出调试日志")] + [Category("Activity/Utility")] + public class DoLogTask : ActionTask, IBtNodeWithParams + { + [SerializeField] + [Tooltip("日志消息")] + private string message = ""; + + [SerializeField] + [Tooltip("日志级别")] + private LogType logType = LogType.Log; + + public enum LogType + { + Log, + Warning, + Error + } + + protected override string info + => $"日志: {message}"; + + public void SetRawParams(string[] rawParams) + { + if (rawParams.Length > 0) + message = string.Join("|", rawParams); + } + + protected override void OnExecute() + { + var formattedMessage = $"[BT] {message}"; + + switch (logType) + { + case LogType.Warning: + Debug.LogWarning(formattedMessage); + break; + case LogType.Error: + Debug.LogError(formattedMessage); + break; + case LogType.Log: + default: + Debug.Log(formattedMessage); + break; + } + + EndAction(true); + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/UtilityTasks.cs.meta b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/UtilityTasks.cs.meta new file mode 100644 index 0000000..4616d87 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/UtilityTasks.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1f7665e546fce57438869a70b397e908 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/VerificationTasks.cs b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/VerificationTasks.cs new file mode 100644 index 0000000..0a87921 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/VerificationTasks.cs @@ -0,0 +1,500 @@ +using System.Linq; +using GameplayEditor.Config; +using GameplayEditor.Core; +using GameplayEditor.Runtime; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using ParadoxNotion.Design; +using UnityEngine; + +namespace GameplayEditor.Nodes.Actions +{ + /// + /// 验证任务基类 + /// 支持两种执行模式: + /// 1. BT模式:通过BehaviourTreeOwner执行OnExecute() + /// 2. 直接模式:通过ExecuteDirect()执行,不依赖BT运行时 + /// + public abstract class VerificationTaskBase : ActionTask, IBtNodeWithParams + { + public abstract string CheckID { get; } + public abstract string CheckDisplayName { get; } + + protected override string info => $"验证: {CheckDisplayName}"; + + public virtual void SetRawParams(string[] rawParams) { } + + // 直接执行模式的结果 + public string LastDetail { get; protected set; } = ""; + public bool LastResult { get; protected set; } + + /// 直接执行(不依赖BT运行时) + public bool ExecuteDirect() + { + LastResult = false; + LastDetail = ""; + RunCheck(); + return LastResult; + } + + /// 子类实现具体验证逻辑,调用 SetResult 记录结果 + protected abstract void RunCheck(); + + /// 记录验证结果 + protected void SetResult(bool success, string detail) + { + LastResult = success; + LastDetail = detail ?? ""; + Debug.Log($"[Verify] {CheckDisplayName}: {(success ? "通过" : "未通过")} - {detail}"); + } + + // BT模式下自动调用 + protected override void OnExecute() + { + RunCheck(); + // 写入黑板(BT模式) + if (blackboard != null) + { + blackboard.SetVariableValue($"Verify_{CheckID}", LastResult); + blackboard.SetVariableValue($"Verify_{CheckID}_Detail", LastDetail); + } + EndAction(true); + } + } + + // ═══════════════════════════════════════════════════════════════════ + // 环境检查 + // ═══════════════════════════════════════════════════════════════════ + + [Name("验证_ID分配器")] + [Category("Activity/Verification")] + public class VerifyIDAllocatorTask : VerificationTaskBase + { + public override string CheckID => "IDAllocator"; + public override string CheckDisplayName => "ID分配器"; + + protected override void RunCheck() + { + bool ok = false; + var configs = Resources.FindObjectsOfTypeAll(); + foreach (var cfg in configs) + { + if (cfg.Ranges.Any(r => r.OwnerName == "TestDesigner" && r.StartID <= 1001 && r.EndID >= 1001)) + { ok = true; break; } + } + SetResult(ok, ok ? "TestDesigner ID段已配置" : "未找到TestDesigner的ID段配置"); + } + } + + [Name("验证_关卡配置")] + [Category("Activity/Verification")] + public class VerifyStageConfigTask : VerificationTaskBase + { + public override string CheckID => "StageConfig"; + public override string CheckDisplayName => "关卡配置"; + + protected override void RunCheck() + { + var btc = Object.FindObjectOfType(); + if (btc == null) + { + SetResult(false, "未找到BehaviourTreeController组件"); + return; + } + + if (btc.HeaderTree == null) + { + SetResult(false, "HeaderTree未赋值"); + return; + } + + if (btc.BodyTree == null) + { + SetResult(false, "BodyTree未赋值"); + return; + } + + bool ok = btc.StageID == 1001; + SetResult(ok, ok ? $"StageID={btc.StageID}, HeaderTree={btc.HeaderTree.name}, BodyTree={btc.BodyTree.name}" : $"StageID错误: {btc.StageID}"); + } + } + + [Name("验证_Tick模式")] + [Category("Activity/Verification")] + public class VerifyTickModeTask : VerificationTaskBase + { + public override string CheckID => "TickMode"; + public override string CheckDisplayName => "Tick模式"; + + protected override void RunCheck() + { + var btc = Object.FindObjectOfType(); + if (btc == null) + { + SetResult(false, "未找到BehaviourTreeController组件,请确保LevelController上已挂载"); + return; + } + + string detail = $"TickMode={btc.TickMode}, enabled={btc.enabled}, IsRunning={btc.IsRunning}"; + SetResult(true, detail); + } + } + + [Name("验证_CameraLut")] + [Category("Activity/Verification")] + public class VerifyCameraLutTask : VerificationTaskBase + { + public override string CheckID => "CameraLut"; + public override string CheckDisplayName => "CameraLut"; + + protected override void RunCheck() + { + bool ok = false; + var luts = Resources.FindObjectsOfTypeAll(); + foreach (var lut in luts) + { + if (lut.CameraMappings != null && lut.CameraMappings.Any(m => m.MappingID == 101)) + { ok = true; break; } + } + SetResult(ok, ok ? "CameraID=101映射已配置" : "未找到CameraID=101的映射"); + } + } + + // ═══════════════════════════════════════════════════════════════════ + // 资源映射 + // ═══════════════════════════════════════════════════════════════════ + + [Name("验证_MapInfoLut")] + [Category("Activity/Verification")] + public class VerifyMapInfoLutTask : VerificationTaskBase + { + public override string CheckID => "MapInfoLut"; + public override string CheckDisplayName => "MapInfoLut"; + + protected override void RunCheck() + { + bool ok = false; + int pointCount = 0; + var luts = Resources.FindObjectsOfTypeAll(); + foreach (var lut in luts) + { + if (lut.InfoID == 10001) + { + pointCount = lut.Points?.Count ?? 0; + ok = pointCount >= 5; + break; + } + } + SetResult(ok, ok ? $"找到{pointCount}个点位" : "MapInfo_10001点位不足5个"); + } + } + + [Name("验证_DialogInfo")] + [Category("Activity/Verification")] + public class VerifyDialogInfoTask : VerificationTaskBase + { + public override string CheckID => "DialogInfo"; + public override string CheckDisplayName => "DialogInfo"; + + protected override void RunCheck() + { + bool ok = false; + int dialogCount = 0; + bool hasLocalization = false; + var dialogs = Resources.FindObjectsOfTypeAll(); + foreach (var cfg in dialogs) + { + if (cfg.name == "DialogInfo_Level1001") + { + dialogCount = cfg.Dialogs.Count; + ok = dialogCount >= 3; + hasLocalization = cfg.Dialogs.Any(d => !string.IsNullOrEmpty(d.Text_EN) || !string.IsNullOrEmpty(d.Text_JP)); + break; + } + } + SetResult(ok, ok ? $"找到{dialogCount}条对话, 本地化={hasLocalization}" : "对话配置不足"); + } + } + + [Name("验证_黑板4域")] + [Category("Activity/Verification")] + public class VerifyBlackboard4DTask : VerificationTaskBase + { + public override string CheckID => "Blackboard4D"; + public override string CheckDisplayName => "黑板4域"; + + protected override void RunCheck() + { + IBlackboard bb = null; + var owners = Object.FindObjectsOfType(); + foreach (var owner in owners) + { + if (owner.blackboard != null && owner.blackboard.variables.ContainsKey(BehaviourTreeExtended.TREE_DICT_KEY)) + { bb = owner.blackboard; break; } + } + + if (bb == null) + { + var standalone = Object.FindObjectOfType(); + if (standalone != null) + { + IBlackboard ibb = standalone; + if (ibb.variables.ContainsKey(BehaviourTreeExtended.TREE_DICT_KEY)) + bb = ibb; + } + } + + if (bb == null) + { + SetResult(false, "Blackboard未找到"); + return; + } + + var keys = new[] + { + BehaviourTreeExtended.TREE_DICT_KEY, + BehaviourTreeExtended.ROLE_DICT_KEY, + BehaviourTreeExtended.CUSTOM_DICT_KEY, + BehaviourTreeExtended.GLOBAL_DICT_KEY + }; + + string missing = ""; + foreach (var key in keys) + { + if (!bb.variables.ContainsKey(key)) + missing += key + " "; + } + + bool allExist = string.IsNullOrEmpty(missing); + SetResult(allExist, allExist ? "4个域均已初始化" : $"缺少: {missing}"); + } + } + + // ═══════════════════════════════════════════════════════════════════ + // 事件系统 + // ═══════════════════════════════════════════════════════════════════ + + [Name("验证_三层节点")] + [Category("Activity/Verification")] + public class VerifyThreeLayerNodesTask : VerificationTaskBase + { + public override string CheckID => "ThreeLayerNodes"; + public override string CheckDisplayName => "三层节点"; + + protected override void RunCheck() + { + EventBuilderConfig builder = null; + foreach (var b in Resources.FindObjectsOfTypeAll()) + { if (b.name == "EventBuilder_1001") { builder = b; break; } } + + if (builder == null) { SetResult(false, "未找到EventBuilder_1001"); return; } + + bool ok = builder.NodeConfigs.Count >= 3 + && builder.NodeConfigs.Any(n => n.NodeLayer == 1) + && builder.NodeConfigs.Any(n => n.NodeLayer == 2) + && builder.NodeConfigs.Any(n => n.NodeLayer == 3); + SetResult(ok, ok ? $"节点数={builder.NodeConfigs.Count}" : "缺少某一层节点"); + } + } + + [Name("验证_权重随机")] + [Category("Activity/Verification")] + public class VerifyWeightRandomTask : VerificationTaskBase + { + public override string CheckID => "WeightRandom"; + public override string CheckDisplayName => "权重随机"; + + protected override void RunCheck() + { + EventBuilderConfig builder = null; + foreach (var b in Resources.FindObjectsOfTypeAll()) + { if (b.name == "EventBuilder_1001") { builder = b; break; } } + + if (builder == null) { SetResult(false, "未找到EventBuilder_1001"); return; } + + bool ok = true; + string detail = ""; + foreach (var node in builder.NodeConfigs) + { + int sum = node.Priority?.Sum() ?? 0; + if (sum != 10000) { ok = false; detail += $"Node{node.NodeID}总和={sum}; "; } + } + SetResult(ok, ok ? "所有节点权重总和=10000" : detail); + } + } + + [Name("验证_5种事件")] + [Category("Activity/Verification")] + public class VerifyFiveEventTypesTask : VerificationTaskBase + { + public override string CheckID => "FiveEventTypes"; + public override string CheckDisplayName => "5种事件"; + + protected override void RunCheck() + { + EventBuilderConfig builder = null; + foreach (var b in Resources.FindObjectsOfTypeAll()) + { if (b.name == "EventBuilder_1001") { builder = b; break; } } + + if (builder == null) { SetResult(false, "未找到EventBuilder_1001"); return; } + + bool eventTypeOk = builder.ActionConfigs.Count >= 5; + var expectedTypes = new[] { Config.EventType.Battle, Config.EventType.Choice, Config.EventType.Reward, Config.EventType.Shop, Config.EventType.Rest }; + bool hasAllTypes = expectedTypes.All(t => builder.ActionConfigs.Any(a => a.Type == t)); + bool ok = eventTypeOk && hasAllTypes; + SetResult(ok, ok ? $"事件数={builder.ActionConfigs.Count}" : "缺少某些事件类型"); + } + } + + [Name("验证_事件池消耗")] + [Category("Activity/Verification")] + public class VerifyEventPoolConsumeTask : VerificationTaskBase + { + public override string CheckID => "EventPoolConsume"; + public override string CheckDisplayName => "事件池消耗"; + + protected override void RunCheck() + { + EventBuilderConfig builder = null; + foreach (var b in Resources.FindObjectsOfTypeAll()) + { if (b.name == "EventBuilder_1001") { builder = b; break; } } + + if (builder == null) { SetResult(false, "未找到EventBuilder_1001"); return; } + + bool ok = builder.ActionConfigs.Any(a => a.Removed) && builder.ActionConfigs.Any(a => !a.Removed); + SetResult(ok, ok ? "存在可消耗和不可消耗事件" : "缺少Removed=true或false的事件"); + } + } + + // ═══════════════════════════════════════════════════════════════════ + // 交互系统 + // ═══════════════════════════════════════════════════════════════════ + + [Name("验证_玩家控制")] + [Category("Activity/Verification")] + public class VerifyPlayerControlTask : VerificationTaskBase + { + public override string CheckID => "PlayerControl"; + public override string CheckDisplayName => "玩家控制"; + + protected override void RunCheck() + { + var player = Object.FindObjectOfType(); + SetResult(player != null, player != null ? "找到玩家控制器" : "未找到SimplePlayerController"); + } + } + + [Name("验证_交互区域")] + [Category("Activity/Verification")] + public class VerifyInteractionZonesTask : VerificationTaskBase + { + public override string CheckID => "InteractionZones"; + public override string CheckDisplayName => "交互区域"; + + protected override void RunCheck() + { + var zones = Object.FindObjectsOfType(); + bool ok = zones.Length >= 2; + SetResult(ok, ok ? $"找到{zones.Length}个交互区域" : "交互区域不足2个"); + } + } + + [Name("验证_FlowCanvas")] + [Category("Activity/Verification")] + public class VerifyFlowCanvasTask : VerificationTaskBase + { + public override string CheckID => "FlowCanvas"; + public override string CheckDisplayName => "FlowCanvas"; + + protected override void RunCheck() + { + var bridge = Object.FindObjectOfType(); + SetResult(bridge != null, bridge != null ? "FlowCanvas桥接已启用" : "未找到FlowTriggerBridge"); + } + } + + // ═══════════════════════════════════════════════════════════════════ + // 调试工具 + // ═══════════════════════════════════════════════════════════════════ + + [Name("验证_断点调试")] + [Category("Activity/Verification")] + public class VerifyDebugBreakpointTask : VerificationTaskBase + { + public override string CheckID => "DebugBreakpoint"; + public override string CheckDisplayName => "断点调试"; + + protected override void RunCheck() + { + var debugger = Object.FindObjectOfType(); + bool ok = debugger != null && debugger.EnableDebug; + SetResult(ok, ok ? "BTDebugger已启用" : "BTDebugger未启用"); + } + } + + [Name("验证_性能监控")] + [Category("Activity/Verification")] + public class VerifyPerfMonitorTask : VerificationTaskBase + { + public override string CheckID => "PerfMonitor"; + public override string CheckDisplayName => "性能监控"; + + protected override void RunCheck() + { + bool ok = BTPerformanceMonitor.Instance != null || Object.FindObjectOfType() != null; + SetResult(ok, ok ? "性能监控器可用" : "未找到性能监控器实例"); + } + } + + [Name("验证_运行时可视化")] + [Category("Activity/Verification")] + public class VerifyRuntimeVizTask : VerificationTaskBase + { + public override string CheckID => "RuntimeViz"; + public override string CheckDisplayName => "运行时可视化"; + + protected override void RunCheck() + { + bool ok = BTRuntimeVisualizer.Instance != null || Object.FindObjectOfType() != null; + SetResult(ok, ok ? "运行时可视化器可用" : "未找到运行时可视化器实例"); + } + } + + // ═══════════════════════════════════════════════════════════════════ + // 运行时行为树(由Runner协程轮询,不在此处定义) + // HeaderExec 和 BodyExec 由 GameplayVerificationRunner 直接处理 + // ═══════════════════════════════════════════════════════════════════ + + // ═══════════════════════════════════════════════════════════════════ + // 工具:所有CheckID常量 + // ═══════════════════════════════════════════════════════════════════ + + public static class VerificationCheckIDs + { + public static readonly string[] All = new[] + { + "IDAllocator", "StageConfig", "HeaderExec", "BodyExec", + "TickMode", "Blackboard4D", + "ThreeLayerNodes", "WeightRandom", "FiveEventTypes", "EventPoolConsume", + "MapInfoLut", "DialogInfo", "CameraLut", + "PlayerControl", "InteractionZones", "FlowCanvas", + "DebugBreakpoint", "PerfMonitor", "RuntimeViz" + }; + + public static readonly string[] DisplayNames = new[] + { + "ID分配器", "关卡配置", "头文件执行", "正文执行", + "Tick模式", "黑板4域", + "三层节点", "权重随机", "5种事件", "事件池消耗", + "MapInfoLut", "DialogInfo", "CameraLut", + "玩家控制", "交互区域", "FlowCanvas", + "断点调试", "性能监控", "运行时可视化" + }; + + public const string PASS_COUNT_KEY = "Verify_PassCount"; + public const string TOTAL_COUNT_KEY = "Verify_TotalCount"; + + public static string ResultKey(string checkID) => $"Verify_{checkID}"; + public static string DetailKey(string checkID) => $"Verify_{checkID}_Detail"; + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/VerificationTasks.cs.meta b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/VerificationTasks.cs.meta new file mode 100644 index 0000000..ca2d345 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/Actions/VerificationTasks.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a323a5341b89a354884659be07d00b69 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/CompositeMethodNode.cs b/Assets/BP_Scripts/GameplayEditor/Nodes/CompositeMethodNode.cs new file mode 100644 index 0000000..2f321a9 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/CompositeMethodNode.cs @@ -0,0 +1,667 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using GameplayEditor.Core; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using ParadoxNotion.Design; +using UnityEngine; +using Debug = UnityEngine.Debug; + +namespace GameplayEditor.Nodes +{ + /// + /// 组合方法节点(子树引用) + /// 运行时展开为实际的子树逻辑 + /// 包含递归保护和参数验证 + /// + [Name("组合方法")] + [Description("引用可复用的子树/组合方法")] + [Category("Activity/Composite")] + public class CompositeMethodNode : BTComposite + { + [Tooltip("组合方法ID(如200009)")] + public int MethodID; + + [Tooltip("组合方法名称(如'关卡初始化')")] + public string MethodName; + + [Tooltip("完整显示名(如'方法:关卡初始化')")] + public string DisplayName; + + // 运行时实例化的子树,避免直接修改资产 + private BehaviourTree _runtimeTree; + + // 递归深度计数(静态,用于检测递归深度) + [NonSerialized] + private static Dictionary _methodRecursionDepth = new Dictionary(); + + // 当前调用链(用于循环依赖检测) + [NonSerialized] + private static HashSet _callChain = new HashSet(); + + // 最大递归深度限制 + private const int MAX_RECURSION_DEPTH = 10; + + // 参数验证错误信息 + private string _paramValidationError = null; + + /// + /// 重写名称显示 + /// + public override string name + { + get + { + if (!string.IsNullOrEmpty(DisplayName)) + return DisplayName.ToUpper(); + if (!string.IsNullOrEmpty(MethodName)) + return $"方法:{MethodName}".ToUpper(); + return $"方法ID:{MethodID}".ToUpper(); + } + } + + [SerializeField] + [Tooltip("传递给子树的参数(格式: key1=value1|key2=value2)")] + private string parameters = ""; + + [SerializeField] + [Tooltip("是否传递父节点黑板变量")] + private bool inheritBlackboard = true; + + [SerializeField] + [Tooltip("返回值映射到当前黑板变量名")] + private string returnValueKey = ""; + + protected override Status OnExecute(Component agent, IBlackboard blackboard) + { + // 开始性能监控 + Stopwatch stopwatch = null; + if (BTPerformanceMonitor.Instance != null) + { + stopwatch = Stopwatch.StartNew(); + BTPerformanceMonitor.Instance.BeginNodeExecution(this); + } + + try + { + var result = ExecuteInternal(agent, blackboard); + + // 结束性能监控 + if (BTPerformanceMonitor.Instance != null && stopwatch != null) + { + stopwatch.Stop(); + BTPerformanceMonitor.Instance.EndNodeExecution(this, (float)stopwatch.Elapsed.TotalMilliseconds); + } + + return result; + } + catch (System.Exception) + { + // 确保性能监控被结束 + if (BTPerformanceMonitor.Instance != null && stopwatch != null) + { + stopwatch.Stop(); + BTPerformanceMonitor.Instance.EndNodeExecution(this, (float)stopwatch.Elapsed.TotalMilliseconds); + } + throw; + } + } + + /// + /// 内部执行逻辑 + /// + private Status ExecuteInternal(Component agent, IBlackboard blackboard) + { + // 检查参数验证错误 + if (!string.IsNullOrEmpty(_paramValidationError)) + { + Debug.LogError($"[CompositeMethod] 参数验证失败: {_paramValidationError}"); + return Status.Failure; + } + + // 循环依赖检测 + if (_callChain.Contains(MethodID)) + { + Debug.LogError($"[CompositeMethod] 检测到循环依赖! 方法ID {MethodID} ({MethodName}) 已在调用链中。\n" + + $"调用链: {string.Join(" -> ", _callChain)}"); + return Status.Failure; + } + + // 递归深度检测 + int currentDepth = GetRecursionDepth(MethodID); + if (currentDepth >= MAX_RECURSION_DEPTH) + { + Debug.LogError($"[CompositeMethod] 递归深度超限! 方法ID {MethodID} ({MethodName}) 已递归 {currentDepth} 层," + + $"最大允许 {MAX_RECURSION_DEPTH} 层。可能存在无限递归。"); + return Status.Failure; + } + + // 获取组合方法 + var method = CompositeMethodRegistry.GetMethod(MethodID); + if (method == null) + { + // 尝试从Loader获取 + method = CompositeMethodLoader.Instance?.GetMethod(MethodID); + } + + if (method == null) + { + Debug.LogWarning($"[CompositeMethod] 未找到方法: ID={MethodID}, Name={MethodName}"); + return Status.Failure; + } + + if (method.Tree == null) + { + Debug.LogWarning($"[CompositeMethod] 方法未关联子树: {method.DisplayName}"); + return Status.Failure; + } + + // 增加递归深度和调用链 + IncrementRecursionDepth(MethodID); + _callChain.Add(MethodID); + + try + { + // 首次执行时实例化运行时子树 + if (_runtimeTree == null) + { + // 解析参数 + var paramDict = ParseParameters(); + + // 基础参数验证 + if (!ValidateParameters(paramDict, out var validationError)) + { + _paramValidationError = validationError; + Debug.LogError($"[CompositeMethod] 参数验证失败: {validationError}"); + return Status.Failure; + } + + // 使用方法定义的参数验证(如果方法定义了参数) + if (method.Parameters.Count > 0) + { + if (!method.ValidateParameters(paramDict, out var methodValidationError)) + { + _paramValidationError = methodValidationError; + Debug.LogError($"[CompositeMethod] 方法参数验证失败: {methodValidationError}"); + return Status.Failure; + } + + // 填充默认值 + foreach (var def in method.Parameters) + { + if (!paramDict.ContainsKey(def.Name) && !string.IsNullOrEmpty(def.DefaultValue)) + { + paramDict[def.Name] = method.GetDefaultValue(def); + } + } + } + + // 使用Loader创建带参数的运行时子树 + _runtimeTree = CompositeMethodLoader.Instance?.CreateRuntimeTree(MethodID, paramDict); + + if (_runtimeTree == null) + { + _runtimeTree = UnityEngine.Object.Instantiate(method.Tree); + } + + _runtimeTree.name = $"Runtime_{method.MethodName}_{System.Guid.NewGuid().ToString("N").Substring(0, 8)}"; + + // 设置初始参数 + if (paramDict != null && _runtimeTree.blackboard != null) + { + foreach (var kvp in paramDict) + { + _runtimeTree.blackboard.SetVariableValue(kvp.Key, kvp.Value); + } + } + } + + // 如果子树尚未启动,使用 Manual 模式启动 + if (!_runtimeTree.isRunning) + { + // 选择黑板:继承父黑板或使用子树自己的黑板 + var targetBlackboard = inheritBlackboard ? blackboard : _runtimeTree.blackboard; + _runtimeTree.StartGraph(agent, targetBlackboard, Graph.UpdateMode.Manual, null); + } + + // 手动 tick 子树 + _runtimeTree.UpdateGraph(); + var status = _runtimeTree.rootStatus; + + // 如果子树完成,处理返回值 + if (status != Status.Running && !string.IsNullOrEmpty(returnValueKey)) + { + // 从子树黑板获取返回值 + if (_runtimeTree.blackboard != null && + _runtimeTree.blackboard.variables.ContainsKey("ReturnValue")) + { + var returnValue = _runtimeTree.blackboard.GetVariableValue("ReturnValue"); + blackboard.SetVariableValue(returnValueKey, returnValue); + Debug.Log($"[CompositeMethod] 返回值映射: ReturnValue -> {returnValueKey} = {returnValue}"); + } + } + + // 如果子树完成或失败,减少递归深度 + if (status != Status.Running) + { + DecrementRecursionDepth(MethodID); + _callChain.Remove(MethodID); + } + + return status; + } + catch (System.Exception ex) + { + Debug.LogError($"[CompositeMethod] 执行错误: {ex.Message}"); + DecrementRecursionDepth(MethodID); + _callChain.Remove(MethodID); + return Status.Failure; + } + } + + protected override void OnReset() + { + base.OnReset(); + if (_runtimeTree != null) + { + _runtimeTree.Stop(); + // 销毁运行时实例 + if (Application.isPlaying) + { + UnityEngine.Object.Destroy(_runtimeTree); + } + } + _runtimeTree = null; + + // 清理递归深度和调用链 + DecrementRecursionDepth(MethodID); + _callChain.Remove(MethodID); + } + + /// + /// 解析参数字符串 + /// + private Dictionary ParseParameters() + { + var dict = new Dictionary(); + + if (string.IsNullOrEmpty(parameters)) + return dict; + + var pairs = parameters.Split('|'); + foreach (var pair in pairs) + { + var kv = pair.Split('='); + if (kv.Length == 2) + { + var key = kv[0].Trim(); + var valueStr = kv[1].Trim(); + + // 验证参数名 + if (!IsValidParameterName(key)) + { + Debug.LogWarning($"[CompositeMethod] 无效参数名: '{key}',跳过"); + continue; + } + + // 尝试解析为数字 + if (int.TryParse(valueStr, out var intVal)) + { + dict[key] = intVal; + } + else if (float.TryParse(valueStr, out var floatVal)) + { + dict[key] = floatVal; + } + else if (bool.TryParse(valueStr, out var boolVal)) + { + dict[key] = boolVal; + } + else + { + dict[key] = valueStr; + } + } + else if (kv.Length != 2 && !string.IsNullOrWhiteSpace(pair)) + { + Debug.LogWarning($"[CompositeMethod] 无效参数格式: '{pair}',应为 key=value"); + } + } + + return dict; + } + + /// + /// 验证参数名是否有效 + /// + private bool IsValidParameterName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return false; + + // 参数名只能包含字母、数字和下划线,且不能以数字开头 + if (!System.Text.RegularExpressions.Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$")) + { + return false; + } + + return true; + } + + /// + /// 验证参数 + /// + private bool ValidateParameters(Dictionary paramDict, out string errorMessage) + { + errorMessage = null; + + if (paramDict == null || paramDict.Count == 0) + return true; + + // 检查参数值类型 + foreach (var kvp in paramDict) + { + var key = kvp.Key; + var value = kvp.Value; + + // 检查值是否为null + if (value == null) + { + errorMessage = $"参数 '{key}' 的值为 null"; + return false; + } + + // 检查字符串参数是否为空 + if (value is string strValue && string.IsNullOrWhiteSpace(strValue)) + { + errorMessage = $"字符串参数 '{key}' 为空或空白"; + return false; + } + + // 检查数值参数是否为NaN或Infinity + if (value is float floatValue) + { + if (float.IsNaN(floatValue) || float.IsInfinity(floatValue)) + { + errorMessage = $"浮点参数 '{key}' 为无效数值 (NaN/Infinity)"; + return false; + } + } + + if (value is double doubleValue) + { + if (double.IsNaN(doubleValue) || double.IsInfinity(doubleValue)) + { + errorMessage = $"双精度参数 '{key}' 为无效数值 (NaN/Infinity)"; + return false; + } + } + } + + return true; + } + + #region 递归深度管理 + + /// + /// 获取当前递归深度 + /// + private int GetRecursionDepth(int methodId) + { + if (_methodRecursionDepth.TryGetValue(methodId, out var depth)) + return depth; + return 0; + } + + /// + /// 增加递归深度 + /// + private void IncrementRecursionDepth(int methodId) + { + if (!_methodRecursionDepth.ContainsKey(methodId)) + _methodRecursionDepth[methodId] = 0; + _methodRecursionDepth[methodId]++; + } + + /// + /// 减少递归深度 + /// + private void DecrementRecursionDepth(int methodId) + { + if (_methodRecursionDepth.ContainsKey(methodId)) + { + _methodRecursionDepth[methodId]--; + if (_methodRecursionDepth[methodId] <= 0) + _methodRecursionDepth.Remove(methodId); + } + } + + /// + /// 重置所有递归深度(用于错误恢复) + /// + public static void ResetAllRecursionDepth() + { + _methodRecursionDepth.Clear(); + _callChain.Clear(); + } + + #endregion + + /// + /// 设置方法信息 + /// + public void SetMethodInfo(int methodId, string methodName, string displayName) + { + MethodID = methodId; + MethodName = methodName; + DisplayName = displayName; + } + } + + /// + /// 参数定义 + /// + [System.Serializable] + public class ParameterDefinition + { + [Tooltip("参数名")] + public string Name; + + [Tooltip("参数类型")] + public ParameterType Type; + + [Tooltip("是否必需")] + public bool Required = false; + + [Tooltip("默认值(可选)")] + public string DefaultValue; + + [Tooltip("参数描述")] + public string Description; + + public enum ParameterType + { + Int, + Float, + Bool, + String, + Vector3, + GameObject + } + } + + /// + /// 组合方法定义 + /// + [CreateAssetMenu(fileName = "CompositeMethod", menuName = "Gameplay/Composite Method")] + public class CompositeMethod : ScriptableObject + { + [Tooltip("方法ID(如200009)")] + public int MethodID; + + [Tooltip("方法名称(如'关卡初始化')")] + public string MethodName; + + [Tooltip("完整显示名(如'方法:关卡初始化')")] + public string DisplayName; + + [Tooltip("关联的行为树(子树)")] + public BehaviourTree Tree; + + [Tooltip("方法注释")] + [TextArea(2, 4)] + public string Doc; + + [UnityEngine.Header("参数定义")] + [Tooltip("该方法接受的参数定义列表")] + public List Parameters = new List(); + + /// + /// 验证传入的参数是否符合定义 + /// + public bool ValidateParameters(Dictionary paramDict, out string errorMessage) + { + errorMessage = null; + + // 检查必需参数 + foreach (var def in Parameters) + { + if (def.Required) + { + if (paramDict == null || !paramDict.ContainsKey(def.Name)) + { + errorMessage = $"缺少必需参数: {def.Name} ({def.Type})"; + return false; + } + } + } + + // 检查参数类型 + if (paramDict != null) + { + foreach (var kvp in paramDict) + { + var def = Parameters.Find(p => p.Name == kvp.Key); + if (def != null) + { + if (!IsTypeMatch(kvp.Value, def.Type)) + { + errorMessage = $"参数类型不匹配: {kvp.Key} 期望 {def.Type},实际 {kvp.Value?.GetType().Name}"; + return false; + } + } + } + } + + return true; + } + + /// + /// 检查值是否匹配参数类型 + /// + private bool IsTypeMatch(object value, ParameterDefinition.ParameterType type) + { + if (value == null) return false; + + return type switch + { + ParameterDefinition.ParameterType.Int => value is int || value is long, + ParameterDefinition.ParameterType.Float => value is float || value is double || value is int, + ParameterDefinition.ParameterType.Bool => value is bool, + ParameterDefinition.ParameterType.String => value is string, + ParameterDefinition.ParameterType.Vector3 => value is Vector3, + ParameterDefinition.ParameterType.GameObject => value is GameObject, + _ => false + }; + } + + /// + /// 获取参数默认值 + /// + public object GetDefaultValue(ParameterDefinition def) + { + if (string.IsNullOrEmpty(def.DefaultValue)) + return null; + + return def.Type switch + { + ParameterDefinition.ParameterType.Int => int.TryParse(def.DefaultValue, out var i) ? i : 0, + ParameterDefinition.ParameterType.Float => float.TryParse(def.DefaultValue, out var f) ? f : 0f, + ParameterDefinition.ParameterType.Bool => bool.TryParse(def.DefaultValue, out var b) ? b : false, + ParameterDefinition.ParameterType.String => def.DefaultValue, + _ => null + }; + } + } + + /// + /// 组合方法注册表 + /// + public static class CompositeMethodRegistry + { + private static System.Collections.Generic.Dictionary _methods = + new System.Collections.Generic.Dictionary(); + + /// + /// 注册方法 + /// + public static void Register(CompositeMethod method) + { + if (method != null && method.MethodID > 0) + { + _methods[method.MethodID] = method; + } + } + + /// + /// 获取方法 + /// + public static CompositeMethod GetMethod(int methodId) + { + return _methods.TryGetValue(methodId, out var method) ? method : null; + } + + /// + /// 根据名称获取方法 + /// + public static CompositeMethod GetMethodByName(string methodName) + { + foreach (var method in _methods.Values) + { + if (method.MethodName == methodName || method.DisplayName == methodName) + return method; + } + return null; + } + + /// + /// 清空注册表 + /// + public static void Clear() + { + _methods.Clear(); + } + + /// + /// 加载所有组合方法资产 + /// + public static void LoadAllMethods() + { + #if UNITY_EDITOR + var guids = UnityEditor.AssetDatabase.FindAssets("t:CompositeMethod"); + foreach (var guid in guids) + { + var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guid); + var method = UnityEditor.AssetDatabase.LoadAssetAtPath(path); + if (method != null) + { + Register(method); + } + } + Debug.Log($"[CompositeMethodRegistry] 加载了 {_methods.Count} 个组合方法"); + #endif + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/CompositeMethodNode.cs.meta b/Assets/BP_Scripts/GameplayEditor/Nodes/CompositeMethodNode.cs.meta new file mode 100644 index 0000000..6a66440 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/CompositeMethodNode.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 30620e19388b1f048ac161e4f905170a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/DynamicBtActionTask.cs b/Assets/BP_Scripts/GameplayEditor/Nodes/DynamicBtActionTask.cs new file mode 100644 index 0000000..63e6ac7 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/DynamicBtActionTask.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using NodeCanvas.Framework; +using ParadoxNotion.Design; +using UnityEngine; + +namespace GameplayEditor.Nodes +{ + /// + /// 通用动态行为树节点 + /// 对所有未注册的节点类型作为 fallback,保留原始类型名和参数字符串 + /// 策划在 Inspector 中可以直接编辑原始参数;导出时原样写回 Excel + /// + [Name("动态节点")] + [Description("未注册的自定义节点,保留原始参数字符串")] + [Category("Activity/Dynamic")] + public class DynamicBtActionTask : ActionTask + { + [Tooltip("节点代码名(来自 行为类型Sheet 的 类型 列)")] + public string NodeTypeName = ""; + + [Tooltip("节点显示名(来自 行为类型Sheet 的 名字 列,或表格中直接填写的名称)")] + public string DisplayName = ""; + + [Tooltip("原始参数列表(|分隔的参数,格式与 Excel 表格一致)")] + public List RawParams = new List(); + + protected override string info + => string.IsNullOrEmpty(DisplayName) ? NodeTypeName + : $"{DisplayName}({NodeTypeName})"; + + protected override void OnExecute() + { + EndAction(true); + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/DynamicBtActionTask.cs.meta b/Assets/BP_Scripts/GameplayEditor/Nodes/DynamicBtActionTask.cs.meta new file mode 100644 index 0000000..cb9b9cb --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/DynamicBtActionTask.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cee01a2337f4b1c49ac6b2d4927ef46a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/GameplayFlowNodes.cs b/Assets/BP_Scripts/GameplayEditor/Nodes/GameplayFlowNodes.cs new file mode 100644 index 0000000..08fe93e --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/GameplayFlowNodes.cs @@ -0,0 +1,321 @@ +using Cinemachine; +using ParadoxNotion.Design; +using UnityEngine; +using UnityEngine.UI; + +namespace FlowCanvas.Nodes +{ + // ═══════════════════════════════════════════════════════════════════ + // 相机节点 + // ═══════════════════════════════════════════════════════════════════ + + [Name("切换相机")] + [Category("Gameplay/Camera")] + [Description("通过Cinemachine优先级切换虚拟相机")] + public class SwitchCameraNode : CallableActionNode + { + public override void Invoke(int cameraId, float blendTime) + { + var brain = Object.FindObjectOfType(); + if (brain != null && blendTime >= 0) + { + brain.m_DefaultBlend = new CinemachineBlendDefinition( + CinemachineBlendDefinition.Style.EaseInOut, blendTime); + } + + // 降低所有虚拟相机优先级 + var vcams = Object.FindObjectsOfType(); + CinemachineVirtualCamera target = null; + foreach (var vcam in vcams) + { + if (vcam.gameObject.name.Contains(cameraId.ToString())) + { + target = vcam; + } + else + { + vcam.Priority = 0; + } + } + + if (target != null) + { + target.Priority = 10; + Debug.Log($"[FC] 切换相机: {cameraId}, 混合: {blendTime}s"); + } + else + { + Debug.LogWarning($"[FC] 未找到包含ID {cameraId} 的虚拟相机"); + } + } + } + + [Name("恢复默认相机")] + [Category("Gameplay/Camera")] + [Description("恢复到默认相机(ID=101)")] + public class ResetCameraNode : CallableActionNode + { + public override void Invoke() + { + var vcams = Object.FindObjectsOfType(); + foreach (var vcam in vcams) + { + vcam.Priority = vcam.gameObject.name.Contains("101") ? 10 : 0; + } + Debug.Log("[FC] 恢复默认相机"); + } + } + + [Name("相机震动")] + [Category("Gameplay/Camera")] + [Description("Cinemachine Noise 震动效果")] + public class ShakeCameraNode : CallableActionNode + { + public override void Invoke(float amplitude, float frequency, float duration) + { + var vcams = Object.FindObjectsOfType(); + foreach (var vcam in vcams) + { + if (vcam.Priority <= 0) continue; + var noise = vcam.GetCinemachineComponent(); + if (noise != null) + { + noise.m_AmplitudeGain = amplitude; + noise.m_FrequencyGain = frequency; + // 通过协程恢复(使用 parentNode.StartCoroutine 或 DelayCall) + Debug.Log($"[FC] 相机震动: amp={amplitude}, freq={frequency}, dur={duration}s"); + } + } + } + } + + // ═══════════════════════════════════════════════════════════════════ + // 对话节点 + // ═══════════════════════════════════════════════════════════════════ + + [Name("显示对话")] + [Category("Gameplay/Dialog")] + [Description("在屏幕底部显示对话框")] + public class ShowDialogNode : CallableActionNode + { + // 静态面板引用,多次调用共享 + private static GameObject _dialogCanvas; + private static GameObject _dialogPanel; + private static Text _nameText; + private static Text _contentText; + + public override void Invoke(int dialogId) + { + if (dialogId <= 0) return; + + // 查找对话数据 + string charName = "系统"; + string text = $"对话 #{dialogId}"; + + var configs = Resources.FindObjectsOfTypeAll(); + foreach (var cfg in configs) + { + foreach (var d in cfg.Dialogs) + { + if (d.ID == dialogId) + { + charName = d.CharacterName; + text = d.Text; + break; + } + } + } + + EnsureDialogUI(); + _nameText.text = charName; + _contentText.text = text; + _contentText.supportRichText = true; + _dialogPanel.SetActive(true); + + Debug.Log($"[FC] 显示对话: ID={dialogId}, {charName}: {text}"); + } + + private void EnsureDialogUI() + { + if (_dialogCanvas != null && _dialogPanel != null) return; + + // Canvas + _dialogCanvas = GameObject.Find("FC_DialogCanvas"); + if (_dialogCanvas == null) + { + _dialogCanvas = new GameObject("FC_DialogCanvas"); + var c = _dialogCanvas.AddComponent(); + c.renderMode = RenderMode.ScreenSpaceOverlay; + c.sortingOrder = 100; + _dialogCanvas.AddComponent().uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; + _dialogCanvas.AddComponent(); + Object.DontDestroyOnLoad(_dialogCanvas); + } + + // Panel + _dialogPanel = new GameObject("DialogPanel"); + _dialogPanel.transform.SetParent(_dialogCanvas.transform, false); + var rect = _dialogPanel.AddComponent(); + rect.anchorMin = new Vector2(0.1f, 0); + rect.anchorMax = new Vector2(0.9f, 0); + rect.pivot = new Vector2(0.5f, 0); + rect.sizeDelta = new Vector2(0, 130); + rect.anchoredPosition = new Vector2(0, 30); + var bg = _dialogPanel.AddComponent(); + bg.color = new Color(0.08f, 0.08f, 0.12f, 0.92f); + + // Name + var nameGo = new GameObject("NameText"); + nameGo.transform.SetParent(_dialogPanel.transform, false); + var nameRect = nameGo.AddComponent(); + nameRect.anchorMin = new Vector2(0, 1); + nameRect.anchorMax = new Vector2(0, 1); + nameRect.pivot = new Vector2(0, 1); + nameRect.sizeDelta = new Vector2(250, 28); + nameRect.anchoredPosition = new Vector2(12, -4); + _nameText = nameGo.AddComponent(); + _nameText.font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); + _nameText.fontSize = 18; + _nameText.color = Color.yellow; + + // Content + var contentGo = new GameObject("ContentText"); + contentGo.transform.SetParent(_dialogPanel.transform, false); + var contentRect = contentGo.AddComponent(); + contentRect.anchorMin = Vector2.zero; + contentRect.anchorMax = Vector2.one; + contentRect.offsetMin = new Vector2(12, 8); + contentRect.offsetMax = new Vector2(-12, -34); + _contentText = contentGo.AddComponent(); + _contentText.font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); + _contentText.fontSize = 16; + _contentText.color = Color.white; + _contentText.supportRichText = true; + } + } + + [Name("关闭对话")] + [Category("Gameplay/Dialog")] + [Description("关闭当前对话框")] + public class CloseDialogNode : CallableActionNode + { + public override void Invoke() + { + var panel = GameObject.Find("DialogPanel"); + if (panel != null) panel.SetActive(false); + Debug.Log("[FC] 关闭对话"); + } + } + + // ═══════════════════════════════════════════════════════════════════ + // NPC节点 + // ═══════════════════════════════════════════════════════════════════ + + [Name("生成NPC")] + [Category("Gameplay/NPC")] + [Description("在指定位置生成NPC(Capsule占位)")] + public class SpawnNPCNode : CallableActionNode + { + public override void Invoke(string npcId, Vector3 position) + { + if (string.IsNullOrEmpty(npcId)) return; + + var existing = GameObject.Find($"NPC_{npcId}"); + if (existing != null) + { + existing.transform.position = position; + existing.SetActive(true); + Debug.Log($"[FC] NPC已存在,移动到: {npcId} → {position}"); + return; + } + + var go = GameObject.CreatePrimitive(PrimitiveType.Capsule); + go.name = $"NPC_{npcId}"; + go.transform.position = position + Vector3.up; + go.transform.localScale = new Vector3(0.7f, 1.1f, 0.7f); + + // 根据ID设置颜色(2xxx=友方蓝, 3xxx=敌方红, 其他=白) + Color color = Color.white; + if (int.TryParse(npcId, out int id)) + { + if (id >= 2000 && id < 3000) color = new Color(0.2f, 0.5f, 0.9f); + else if (id >= 3000 && id < 4000) color = new Color(0.9f, 0.2f, 0.2f); + } + go.GetComponent().material.color = color; + + Debug.Log($"[FC] 生成NPC: {npcId} 在 {position}"); + } + } + + [Name("移除NPC")] + [Category("Gameplay/NPC")] + [Description("从场景中移除NPC")] + public class DespawnNPCNode : CallableActionNode + { + public override void Invoke(string npcId) + { + var go = GameObject.Find($"NPC_{npcId}"); + if (go != null) + { + Object.Destroy(go); + Debug.Log($"[FC] 移除NPC: {npcId}"); + } + } + } + + // ═══════════════════════════════════════════════════════════════════ + // 通用节点 + // ═══════════════════════════════════════════════════════════════════ + + [Name("游戏日志")] + [Category("Gameplay/Utility")] + [Description("输出带颜色的游戏日志")] + public class GameLogNode : CallableActionNode + { + public override void Invoke(string message) + { + Debug.Log($"[Gameplay] {message}"); + } + } + + [Name("解析区域事件-ZoneID")] + [Category("Gameplay/Utility")] + [Description("从 zoneID|dialogID|cameraID 中提取 zoneID")] + public class ParseZoneIdNode : PureFunctionNode + { + public override string Invoke(string packed) + { + if (string.IsNullOrEmpty(packed)) return ""; + var parts = packed.Split('|'); + return parts.Length > 0 ? parts[0] : ""; + } + } + + [Name("解析区域事件-DialogID")] + [Category("Gameplay/Utility")] + [Description("从 zoneID|dialogID|cameraID 中提取 dialogID")] + public class ParseZoneDialogIdNode : PureFunctionNode + { + public override int Invoke(string packed) + { + if (string.IsNullOrEmpty(packed)) return 0; + var parts = packed.Split('|'); + if (parts.Length > 1 && int.TryParse(parts[1], out int id)) return id; + return 0; + } + } + + [Name("解析区域事件-CameraID")] + [Category("Gameplay/Utility")] + [Description("从 zoneID|dialogID|cameraID 中提取 cameraID")] + public class ParseZoneCameraIdNode : PureFunctionNode + { + public override int Invoke(string packed) + { + if (string.IsNullOrEmpty(packed)) return 0; + var parts = packed.Split('|'); + if (parts.Length > 2 && int.TryParse(parts[2], out int id)) return id; + return 0; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/GameplayFlowNodes.cs.meta b/Assets/BP_Scripts/GameplayEditor/Nodes/GameplayFlowNodes.cs.meta new file mode 100644 index 0000000..57a8d01 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/GameplayFlowNodes.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3aee05e72b55d1842aeefa26b2deb6bd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/NodeTypeRegistry.cs b/Assets/BP_Scripts/GameplayEditor/Nodes/NodeTypeRegistry.cs new file mode 100644 index 0000000..726e2f3 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/NodeTypeRegistry.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; + +namespace GameplayEditor.Nodes +{ + /// + /// 节点类型注册表 + /// 映射关系:代码名(如 DoGetTime)→ ActionTask 子类 + /// 所有未注册的类型自动回退到 DynamicBtActionTask + /// + public static class NodeTypeRegistry + { + // 代码名 → ActionTask 类型 + static readonly Dictionary _registry = new Dictionary + { + // ═════════════════════════════════════════════════════════════════ + // 初始化相关 + // ═════════════════════════════════════════════════════════════════ + { "InitializeBlackboardDicts", typeof(Actions.InitializeBlackboardDictsTask) }, + { "InitDicts", typeof(Actions.InitializeBlackboardDictsTask) }, + { "LoadLutToDict", typeof(Actions.LoadLutToDictTask) }, + + // ═════════════════════════════════════════════════════════════════ + // 时间相关 + // ═════════════════════════════════════════════════════════════════ + { "DoGetTime", typeof(Actions.DoGetTimeTask) }, + { "DoWait", typeof(Actions.DoWaitTask) }, + { "DoWaitFrames", typeof(Actions.DoWaitFramesTask) }, + + // ═════════════════════════════════════════════════════════════════ + // 战斗相关 + // ═════════════════════════════════════════════════════════════════ + { "CheckNpcCamp", typeof(Actions.CheckNpcCampTask) }, + { "DoSetFightTarget",typeof(Actions.DoSetFightTargetTask) }, + { "CheckDistance", typeof(Actions.CheckDistanceTask) }, + { "CheckHP", typeof(Actions.CheckHPTask) }, + { "CheckNPCExists", typeof(Actions.CheckNPCExistsTask) }, + + // ═════════════════════════════════════════════════════════════════ + // 对话相关 + // ═════════════════════════════════════════════════════════════════ + { "DoShowDialog", typeof(Actions.DoShowDialogTask) }, + { "DoCloseDialog", typeof(Actions.DoCloseDialogTask) }, + { "DoWaitDialog", typeof(Actions.DoWaitDialogTask) }, + + // ═════════════════════════════════════════════════════════════════ + // 场景相关 + // ═════════════════════════════════════════════════════════════════ + { "DoSpawnNPC", typeof(Actions.DoSpawnNPCTask) }, + { "DoPlayAnimation", typeof(Actions.DoPlayAnimationTask) }, + { "DoPlayEffect", typeof(Actions.DoPlayEffectTask) }, + { "DoChangeCamera", typeof(Actions.DoChangeCameraTask) }, + { "DoLoadScene", typeof(Actions.DoLoadSceneTask) }, + + // ═════════════════════════════════════════════════════════════════ + // 变量/工具 + // ═════════════════════════════════════════════════════════════════ + { "DoSetVariable", typeof(Actions.DoSetVariableTask) }, + { "CheckVariable", typeof(Actions.CheckVariableTask) }, + { "DoLog", typeof(Actions.DoLogTask) }, + + // ═════════════════════════════════════════════════════════════════ + // 验证相关 + // ═════════════════════════════════════════════════════════════════ + { "VerifyIDAllocator", typeof(Actions.VerifyIDAllocatorTask) }, + { "VerifyStageConfig", typeof(Actions.VerifyStageConfigTask) }, + // HeaderExec 和 BodyExec 由 Runner 协程处理,不注册为 ActionTask + { "VerifyTickMode", typeof(Actions.VerifyTickModeTask) }, + { "VerifyBlackboard4D", typeof(Actions.VerifyBlackboard4DTask) }, + { "VerifyThreeLayerNodes", typeof(Actions.VerifyThreeLayerNodesTask) }, + { "VerifyWeightRandom", typeof(Actions.VerifyWeightRandomTask) }, + { "VerifyFiveEventTypes", typeof(Actions.VerifyFiveEventTypesTask) }, + { "VerifyEventPoolConsume", typeof(Actions.VerifyEventPoolConsumeTask) }, + { "VerifyMapInfoLut", typeof(Actions.VerifyMapInfoLutTask) }, + { "VerifyDialogInfo", typeof(Actions.VerifyDialogInfoTask) }, + { "VerifyCameraLut", typeof(Actions.VerifyCameraLutTask) }, + { "VerifyPlayerControl", typeof(Actions.VerifyPlayerControlTask) }, + { "VerifyInteractionZones", typeof(Actions.VerifyInteractionZonesTask) }, + { "VerifyFlowCanvas", typeof(Actions.VerifyFlowCanvasTask) }, + { "VerifyDebugBreakpoint", typeof(Actions.VerifyDebugBreakpointTask) }, + { "VerifyPerfMonitor", typeof(Actions.VerifyPerfMonitorTask) }, + { "VerifyRuntimeViz", typeof(Actions.VerifyRuntimeVizTask) }, + }; + + // 复合节点名称映射(显示名 → BT复合节点类型) + // 这些来自 行为类型 Sheet 中 类型列为空的行 + static readonly Dictionary _compositeDisplayNames = new Dictionary + { + { "平行", typeof(Parallel) }, + { "顺序", typeof(Sequencer) }, + { "Selector", typeof(Selector) }, + { "选择", typeof(Selector) }, + { "乱选", typeof(ProbabilitySelector) }, + { "加权乱选", typeof(WeightedProbabilitySelector) }, + { "循环", typeof(Sequencer) }, + }; + + // 中文显示名 → 代码名 映射(用于导出时转换) + static readonly Dictionary _displayNameToCodeName = new Dictionary + { + // 初始化 + { "初始化黑板字典", "InitializeBlackboardDicts" }, + { "初始化黑板", "InitializeBlackboardDicts" }, + { "加载LUT到字典", "LoadLutToDict" }, + { "加载LUT", "LoadLutToDict" }, + + // 时间 + { "获取时间", "DoGetTime" }, + { "等待", "DoWait" }, + { "等待帧数", "DoWaitFrames" }, + + // 战斗 + { "比较阵营", "CheckNpcCamp" }, + { "设置战斗目标", "DoSetFightTarget" }, + { "检查距离", "CheckDistance" }, + { "检查血量", "CheckHP" }, + { "检查NPC存在", "CheckNPCExists" }, + + // 对话 + { "显示对话", "DoShowDialog" }, + { "关闭对话", "DoCloseDialog" }, + { "等待对话结束", "DoWaitDialog" }, + + // 场景 + { "生成NPC", "DoSpawnNPC" }, + { "播放动画", "DoPlayAnimation" }, + { "播放特效", "DoPlayEffect" }, + { "切换相机", "DoChangeCamera" }, + { "加载场景", "DoLoadScene" }, + + // 工具 + { "设置变量", "DoSetVariable" }, + { "检查变量", "CheckVariable" }, + { "输出日志", "DoLog" }, + + // 验证 + { "验证_ID分配器", "VerifyIDAllocator" }, + { "验证_关卡配置", "VerifyStageConfig" }, + { "验证_头文件执行", "VerifyHeaderExec" }, + { "验证_正文执行", "VerifyBodyExec" }, + { "验证_Tick模式", "VerifyTickMode" }, + { "验证_黑板4域", "VerifyBlackboard4D" }, + { "验证_三层节点", "VerifyThreeLayerNodes" }, + { "验证_权重随机", "VerifyWeightRandom" }, + { "验证_5种事件", "VerifyFiveEventTypes" }, + { "验证_事件池消耗", "VerifyEventPoolConsume" }, + { "验证_MapInfoLut", "VerifyMapInfoLut" }, + { "验证_DialogInfo", "VerifyDialogInfo" }, + { "验证_CameraLut", "VerifyCameraLut" }, + { "验证_玩家控制", "VerifyPlayerControl" }, + { "验证_交互区域", "VerifyInteractionZones" }, + { "验证_FlowCanvas", "VerifyFlowCanvas" }, + { "验证_断点调试", "VerifyDebugBreakpoint" }, + { "验证_性能监控", "VerifyPerfMonitor" }, + { "验证_运行时可视化", "VerifyRuntimeViz" }, + }; + + /// 注册自定义节点类型,支持运行时扩展 + public static void Register(string codeName, Type taskType) + { + _registry[codeName] = taskType; + } + + /// 通过代码名获取 ActionTask 类型(未注册返回 DynamicBtActionTask) + public static Type GetTaskType(string codeName) + { + if (_registry.TryGetValue(codeName, out var t)) return t; + return typeof(DynamicBtActionTask); + } + + /// 通过中文显示名获取代码名 + public static string GetCodeNameByDisplayName(string displayName) + { + if (_displayNameToCodeName.TryGetValue(displayName, out var codeName)) + return codeName; + return displayName; // 如果找不到,假设本身就是代码名 + } + + /// 通过代码名获取中文显示名 + public static string GetDisplayNameByCodeName(string codeName) + { + foreach (var kvp in _displayNameToCodeName) + { + if (kvp.Value == codeName) + return kvp.Key; + } + return codeName; + } + + /// 创建 ActionTask 实例,设置原始参数 + public static ActionTask CreateTask(string codeName, string displayName, string[] rawParams) + { + var type = GetTaskType(codeName); + var task = (ActionTask)Activator.CreateInstance(type); + + if (task is DynamicBtActionTask dyn) + { + dyn.NodeTypeName = codeName; + dyn.DisplayName = displayName; + dyn.RawParams = new List(rawParams ?? Array.Empty()); + } + else if (task is IBtNodeWithParams paramNode) + { + paramNode.SetRawParams(rawParams ?? Array.Empty()); + } + + return task; + } + + /// 检查是否为复合节点(Parallel/Sequence等)的显示名 + public static bool TryGetCompositeType(string displayName, out Type compositeType) + { + return _compositeDisplayNames.TryGetValue(displayName, out compositeType); + } + + /// 注册额外的复合节点显示名(从 行为类型Sheet 中读取后调用) + public static void RegisterComposite(string displayName, Type compositeType) + { + _compositeDisplayNames[displayName] = compositeType; + } + + /// 获取所有注册的节点代码名 + public static IEnumerable GetAllCodeNames() + { + return _registry.Keys; + } + + /// 获取所有复合节点显示名 + public static IEnumerable GetAllCompositeNames() + { + return _compositeDisplayNames.Keys; + } + } + + /// 自定义节点实现此接口以接收原始参数 + public interface IBtNodeWithParams + { + void SetRawParams(string[] rawParams); + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/NodeTypeRegistry.cs.meta b/Assets/BP_Scripts/GameplayEditor/Nodes/NodeTypeRegistry.cs.meta new file mode 100644 index 0000000..189ef53 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/NodeTypeRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f863fef063aa93a4ca8ee5eb76d08d7b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/WeightedProbabilitySelector.cs b/Assets/BP_Scripts/GameplayEditor/Nodes/WeightedProbabilitySelector.cs new file mode 100644 index 0000000..78a580f --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/WeightedProbabilitySelector.cs @@ -0,0 +1,242 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using ParadoxNotion.Design; +using UnityEngine; + +namespace GameplayEditor.Nodes +{ + /// + /// 加权概率选择器(乱选) + /// 支持从Excel配置权重的概率选择节点 + /// + [Name("加权乱选")] + [Description("根据配置的权重随机选择一个子节点执行")] + [Category("Composites")] + public class WeightedProbabilitySelector : BTComposite + { + [SerializeField] + [Tooltip("子节点权重列表(与outConnections顺序对应)")] + private List _weights = new List(); + + // 标记是否从Excel配置加载(调试用) + // ReSharper disable once UnusedMember.Local + private bool UseExcelWeights => !string.IsNullOrEmpty(_weightConfigString); + + [SerializeField] + [Tooltip("权重配置字符串(从Excel解析)")] + private string _weightConfigString = ""; + + // 运行时 + private int _runningIndex = -1; + private List _runtimeWeights = new List(); + private float _totalWeight; + + /// + /// 设置权重配置(从Excel调用) + /// 格式: "30|40|30" 或 "0.3|0.4|0.3" + /// + public void SetWeightsFromConfig(string config) + { + _weightConfigString = config; + + if (string.IsNullOrEmpty(config)) + { + _weights.Clear(); + return; + } + + // 解析权重字符串 + var parts = config.Split('|', ','); + _weights.Clear(); + + foreach (var part in parts) + { + if (float.TryParse(part.Trim(), out float weight)) + { + _weights.Add(weight); + } + } + + ValidateWeights(); + } + + /// + /// 获取权重配置字符串(用于导出) + /// + public string GetWeightsConfig() + { + return _weightConfigString; + } + + /// + /// 设置权重列表 + /// + public void SetWeights(List weights) + { + _weights = new List(weights); + ValidateWeights(); + } + + protected override void OnReset() + { + _runningIndex = -1; + } + + protected override Status OnExecute(Component agent, IBlackboard blackboard) + { + if (outConnections.Count == 0) + { + return Status.Optional; + } + + // 如果是第一次执行,选择随机子节点 + if (_runningIndex == -1) + { + _runningIndex = SelectRandomChild(); + + if (_runningIndex < 0 || _runningIndex >= outConnections.Count) + { + return Status.Failure; + } + } + + // 执行选中的子节点 + status = outConnections[_runningIndex].Execute(agent, blackboard); + + // 如果子节点完成,重置并返回结果 + if (status != Status.Running) + { + _runningIndex = -1; + } + + return status; + } + + /// + /// 根据权重随机选择子节点 + /// + private int SelectRandomChild() + { + PrepareRuntimeWeights(); + + if (_runtimeWeights.Count == 0) + { + // 没有权重配置,均匀随机 + return UnityEngine.Random.Range(0, outConnections.Count); + } + + // 加权随机 + float randomValue = UnityEngine.Random.Range(0f, _totalWeight); + float cumulative = 0f; + + for (int i = 0; i < _runtimeWeights.Count; i++) + { + cumulative += _runtimeWeights[i]; + if (randomValue <= cumulative) + { + return i; + } + } + + // 兜底返回最后一个 + return _runtimeWeights.Count - 1; + } + + /// + /// 准备运行时权重 + /// + private void PrepareRuntimeWeights() + { + _runtimeWeights.Clear(); + _totalWeight = 0f; + + int childCount = outConnections.Count; + + // 确保权重列表与子节点数量匹配 + for (int i = 0; i < childCount; i++) + { + float weight = (i < _weights.Count) ? _weights[i] : 1f; // 默认权重1 + _runtimeWeights.Add(weight); + _totalWeight += weight; + } + + // 如果总权重为0,使用均匀权重 + if (_totalWeight <= 0) + { + _runtimeWeights = Enumerable.Repeat(1f, childCount).ToList(); + _totalWeight = childCount; + } + } + + /// + /// 验证权重配置 + /// + private void ValidateWeights() + { + if (_weights.Count == 0) + { + Debug.LogWarning($"[WeightedProbabilitySelector] 权重列表为空,将使用均匀随机"); + return; + } + + // 检查是否有负数权重 + for (int i = 0; i < _weights.Count; i++) + { + if (_weights[i] < 0) + { + Debug.LogError($"[WeightedProbabilitySelector] 权重不能为负数: 索引{i} = {_weights[i]}"); + _weights[i] = 0; + } + } + + Debug.Log($"[WeightedProbabilitySelector] 权重配置: {string.Join(" | ", _weights)}"); + } + + public override void OnGraphStarted() + { + base.OnGraphStarted(); + ValidateWeights(); + } + } + + /// + /// 运行时权重数据(用于从Excel传递权重配置) + /// + [Serializable] + public class WeightedSelectorConfig + { + public string NodeName; + public List Weights = new List(); + + /// + /// 从Excel格式解析 + /// 格式: "30|40|30" + /// + public static WeightedSelectorConfig ParseFromString(string nodeName, string weightString) + { + var config = new WeightedSelectorConfig + { + NodeName = nodeName + }; + + if (string.IsNullOrEmpty(weightString)) + { + return config; + } + + var parts = weightString.Split('|', ','); + foreach (var part in parts) + { + if (float.TryParse(part.Trim(), out float weight)) + { + config.Weights.Add(weight); + } + } + + return config; + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Nodes/WeightedProbabilitySelector.cs.meta b/Assets/BP_Scripts/GameplayEditor/Nodes/WeightedProbabilitySelector.cs.meta new file mode 100644 index 0000000..9dcd876 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Nodes/WeightedProbabilitySelector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4fc82b010dc1c8945a0c7b9bf2ba880c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime.meta b/Assets/BP_Scripts/GameplayEditor/Runtime.meta new file mode 100644 index 0000000..625818b --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a2a6ceec43f544b4eb952b2f38c8dfa3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/CameraManagerImpl.cs b/Assets/BP_Scripts/GameplayEditor/Runtime/CameraManagerImpl.cs new file mode 100644 index 0000000..4a6eea3 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/CameraManagerImpl.cs @@ -0,0 +1,599 @@ +using System.Collections.Generic; +using Cinemachine; +using GameplayEditor.Config; +using GameplayEditor.Core; +using UnityEngine; +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace GameplayEditor.Runtime +{ + /// + /// 相机管理器实际实现 + /// 管理相机切换、过渡和效果 + /// + public class CameraManagerImpl : MonoBehaviour, ICameraManager + { + #region ICameraManager 接口实现 + + public string ManagerName => "CameraManager"; + public bool IsInitialized { get; private set; } + + #endregion + + [Header("相机配置")] + [Tooltip("虚拟相机配置表")] + public List CameraConfigs = new List(); + + [Tooltip("默认相机ID")] + public int DefaultCameraId = 1001; + + [Tooltip("主Cinemachine Brain")] + public CinemachineBrain CinemachineBrain; + + [Header("LUT配置(可选,自动生成CameraConfigs)")] + [Tooltip("相机LUT配置(优先于手动CameraConfigs)")] + public ActivityCameraLut CameraLut; + + [Header("过渡设置")] + [Tooltip("默认过渡时间")] + public float DefaultBlendTime = 0.5f; + + [Tooltip("混合定义")] + public CinemachineBlendDefinition.Style BlendStyle = + CinemachineBlendDefinition.Style.EaseInOut; + + // 当前相机 + private int _currentCameraId = -1; + private CameraConfig _currentCamera; + private Dictionary _cameraCache = new Dictionary(); + + // 相机配置 + [System.Serializable] + public class CameraConfig + { + [Tooltip("相机ID")] + public int CameraId; + + [Tooltip("相机名称")] + public string CameraName; + + [Tooltip("Cinemachine虚拟相机")] + public CinemachineVirtualCamera VirtualCamera; + + [Tooltip("优先级")] + public int Priority = 10; + + [Tooltip("跟随目标")] + public Transform FollowTarget; + + [Tooltip("注视目标")] + public Transform LookAtTarget; + } + + private void Awake() + { + if (GameplayManagerHub.Instance != null) + { + GameplayManagerHub.Instance.RegisterManager(this); + } + } + + #region IGameplayManager 接口 + + public void Initialize() + { + // 查找CinemachineBrain + if (CinemachineBrain == null) + { + CinemachineBrain = FindObjectOfType(); + if (CinemachineBrain == null) + { + Debug.LogWarning("[CameraManager] 未找到CinemachineBrain"); + } + } + + // 如果手动配置为空,尝试从 CameraLut 加载 + if (CameraConfigs.Count == 0) + { + if (CameraLut == null) + { +#if UNITY_EDITOR + CameraLut = AssetDatabase.LoadAssetAtPath( + "Assets/Verification/Luts/CameraLut_101.asset"); +#endif + } + if (CameraLut != null) + { + LoadFromCameraLut(CameraLut); + } + } + + // 构建相机缓存 + BuildCameraCache(); + + // 初始化所有相机为低优先级 + foreach (var config in CameraConfigs) + { + if (config.VirtualCamera != null) + { + config.VirtualCamera.Priority = 0; + } + } + + // 切换到默认相机 + if (DefaultCameraId > 0) + { + SwitchToCamera(DefaultCameraId, 0); + } + + IsInitialized = true; + Debug.Log("[CameraManager] 初始化完成"); + } + + public void Shutdown() + { + IsInitialized = false; + } + + #endregion + + #region ICameraManager 接口实现 + + /// + /// 切换到指定相机 + /// + public void SwitchToCamera(int cameraId, float transitionTime = 0.5f) + { + if (!TryGetCameraConfig(cameraId, out var config)) + { + // 尝试从 CameraLut 延迟加载 + if (TryLazyLoadFromLut() && TryGetCameraConfig(cameraId, out config)) + { + // 成功延迟加载 + } + else + { + Debug.LogError($"[CameraManager] 相机配置不存在: {cameraId}"); + return; + } + } + + if (config.VirtualCamera == null) + { + Debug.LogError($"[CameraManager] 相机未关联VirtualCamera: {cameraId}"); + return; + } + + // 设置混合时间 + if (CinemachineBrain != null && transitionTime >= 0) + { + var blendDef = new CinemachineBlendDefinition( + CinemachineBlendDefinition.Style.EaseInOut, + transitionTime + ); + CinemachineBrain.m_DefaultBlend = blendDef; + } + + // 降低当前相机优先级 + if (_currentCamera != null && _currentCamera.VirtualCamera != null) + { + _currentCamera.VirtualCamera.Priority = 0; + } + + // 提高新相机优先级 + config.VirtualCamera.Priority = config.Priority; + + // 更新当前相机 + _currentCameraId = cameraId; + _currentCamera = config; + + Debug.Log($"[CameraManager] 切换到相机: {cameraId} ({config.CameraName}), 过渡时间: {transitionTime}s"); + } + + #region 详细参数配置支持 + + /// + /// 从CameraMapping应用详细配置到虚拟相机 + /// + public void ApplyCameraMapping(CameraMapping mapping, CinemachineVirtualCamera vcam) + { + if (mapping == null || vcam == null) return; + + // 应用FOV + if (mapping.FOV > 0 && mapping.FOV < 180) + { + vcam.m_Lens.FieldOfView = mapping.FOV; + } + + // 应用优先级 + if (mapping.Priority > 0) + { + vcam.Priority = mapping.Priority; + } + + // 应用跟随偏移(通过Cinemachine组件) + var transposer = vcam.GetCinemachineComponent(); + if (transposer != null && mapping.FollowOffset != Vector3.zero) + { + transposer.m_FollowOffset = mapping.FollowOffset; + } + + // 应用注视偏移 + var composer = vcam.GetCinemachineComponent(); + if (composer != null) + { + if (mapping.LookAtOffset != Vector3.zero) + { + composer.m_TrackedObjectOffset = mapping.LookAtOffset; + } + + // 应用死区设置 + if (mapping.UseSoftZone) + { + composer.m_SoftZoneWidth = 0.8f; + composer.m_SoftZoneHeight = 0.8f; + composer.m_DeadZoneWidth = mapping.DeadZoneWidth; + composer.m_DeadZoneHeight = mapping.DeadZoneHeight; + } + } + + Debug.Log($"[CameraManager] 应用详细配置到相机: {mapping.MappingID}, FOV={mapping.FOV}, Priority={mapping.Priority}"); + } + + /// + /// 根据CameraMapping配置切换相机 + /// + public void SwitchToCameraWithMapping(int cameraId, CameraMapping mapping) + { + if (mapping == null) + { + SwitchToCamera(cameraId); + return; + } + + if (!TryGetCameraConfig(cameraId, out var config)) + { + Debug.LogError($"[CameraManager] 相机配置不存在: {cameraId}"); + return; + } + + if (config.VirtualCamera == null) + { + Debug.LogError($"[CameraManager] 相机未关联VirtualCamera: {cameraId}"); + return; + } + + // 先应用详细配置 + ApplyCameraMapping(mapping, config.VirtualCamera); + + // 使用mapping中的过渡设置 + float blendTime = mapping.BlendTime >= 0 ? mapping.BlendTime : DefaultBlendTime; + var blendStyle = ConvertBlendStyle(mapping.BlendStyle); + + // 设置混合时间 + if (CinemachineBrain != null) + { + var blendDef = new CinemachineBlendDefinition(blendStyle, blendTime); + CinemachineBrain.m_DefaultBlend = blendDef; + } + + // 降低当前相机优先级 + if (_currentCamera != null && _currentCamera.VirtualCamera != null) + { + _currentCamera.VirtualCamera.Priority = 0; + } + + // 提高新相机优先级(使用mapping中的优先级) + config.VirtualCamera.Priority = mapping.Priority > 0 ? mapping.Priority : config.Priority; + + // 更新当前相机 + _currentCameraId = cameraId; + _currentCamera = config; + + Debug.Log($"[CameraManager] 切换到相机: {cameraId} (使用详细配置), 过渡: {blendTime}s, 样式: {mapping.BlendStyle}"); + } + + /// + /// 转换混合样式 + /// + private CinemachineBlendDefinition.Style ConvertBlendStyle(CinemachineBlendStyle style) + { + return style switch + { + CinemachineBlendStyle.Cut => CinemachineBlendDefinition.Style.Cut, + CinemachineBlendStyle.EaseIn => CinemachineBlendDefinition.Style.EaseIn, + CinemachineBlendStyle.EaseOut => CinemachineBlendDefinition.Style.EaseOut, + CinemachineBlendStyle.HardIn => CinemachineBlendDefinition.Style.HardIn, + CinemachineBlendStyle.HardOut => CinemachineBlendDefinition.Style.HardOut, + CinemachineBlendStyle.Linear => CinemachineBlendDefinition.Style.Linear, + _ => CinemachineBlendDefinition.Style.EaseInOut + }; + } + + #endregion + + /// + /// 重置到默认相机 + /// + public void ResetToDefault() + { + SwitchToCamera(DefaultCameraId, DefaultBlendTime); + } + + #endregion + + #region 扩展功能 + + /// + /// 获取当前相机ID + /// + public int GetCurrentCameraId() + { + return _currentCameraId; + } + + /// + /// 设置相机跟随目标 + /// + public void SetFollowTarget(int cameraId, Transform target) + { + if (TryGetCameraConfig(cameraId, out var config)) + { + config.FollowTarget = target; + if (config.VirtualCamera != null) + { + config.VirtualCamera.Follow = target; + } + } + } + + /// + /// 设置相机注视目标 + /// + public void SetLookAtTarget(int cameraId, Transform target) + { + if (TryGetCameraConfig(cameraId, out var config)) + { + config.LookAtTarget = target; + if (config.VirtualCamera != null) + { + config.VirtualCamera.LookAt = target; + } + } + } + + /// + /// 震动相机 + /// + public void ShakeCamera(float amplitude, float frequency, float duration) + { + if (_currentCamera?.VirtualCamera == null) + return; + + var noise = _currentCamera.VirtualCamera.GetCinemachineComponent(); + if (noise != null) + { + noise.m_AmplitudeGain = amplitude; + noise.m_FrequencyGain = frequency; + + StartCoroutine(StopShakeAfter(duration, noise)); + } + } + + private System.Collections.IEnumerator StopShakeAfter(float delay, CinemachineBasicMultiChannelPerlin noise) + { + yield return new WaitForSeconds(delay); + noise.m_AmplitudeGain = 0; + noise.m_FrequencyGain = 0; + } + + /// + /// 移动相机到指定位置 + /// + public void MoveCameraTo(int cameraId, Vector3 position, float duration) + { + if (!TryGetCameraConfig(cameraId, out var config)) + return; + + if (config.VirtualCamera != null) + { + StartCoroutine(MoveCameraCoroutine(config.VirtualCamera, position, duration)); + } + } + + private System.Collections.IEnumerator MoveCameraCoroutine(CinemachineVirtualCamera vcam, Vector3 targetPos, float duration) + { + Vector3 startPos = vcam.transform.position; + float elapsed = 0; + + while (elapsed < duration) + { + vcam.transform.position = Vector3.Lerp(startPos, targetPos, elapsed / duration); + elapsed += Time.deltaTime; + yield return null; + } + + vcam.transform.position = targetPos; + } + + /// + /// 创建临时观察相机 + /// + public int CreateTempCamera(Vector3 position, Quaternion rotation, float duration) + { + // 创建临时GameObject + var tempCam = new GameObject($"TempCamera_{_nextTempCameraId}"); + tempCam.transform.position = position; + tempCam.transform.rotation = rotation; + + var vcam = tempCam.AddComponent(); + vcam.Priority = 20; // 高优先级 + + var config = new CameraConfig + { + CameraId = _nextTempCameraId, + CameraName = "TempCamera", + VirtualCamera = vcam, + Priority = 20 + }; + + _tempCameras[_nextTempCameraId] = config; + + // 切换到此相机 + SwitchToCamera(_nextTempCameraId, 0.3f); + + // 定时销毁 + StartCoroutine(DestroyTempCameraAfter(_nextTempCameraId, duration)); + + return _nextTempCameraId++; + } + + private int _nextTempCameraId = 900000; + private Dictionary _tempCameras = new Dictionary(); + + private System.Collections.IEnumerator DestroyTempCameraAfter(int cameraId, float delay) + { + yield return new WaitForSeconds(delay); + + if (_tempCameras.TryGetValue(cameraId, out var config)) + { + // 如果当前是这个相机,先切换回默认 + if (_currentCameraId == cameraId) + { + ResetToDefault(); + } + + if (config.VirtualCamera != null) + { + Destroy(config.VirtualCamera.gameObject); + } + + _tempCameras.Remove(cameraId); + } + } + + /// + /// 获取所有相机ID + /// + public List GetAllCameraIds() + { + var ids = new List(); + foreach (var config in CameraConfigs) + { + ids.Add(config.CameraId); + } + return ids; + } + + #endregion + + #region 私有方法 + + /// + /// 从 ActivityCameraLut 加载相机配置 + /// 为每个 CameraMapping 创建 CinemachineVirtualCamera 并注册到 CameraConfigs + /// + public void LoadFromCameraLut(ActivityCameraLut lut) + { + if (lut == null) return; + + CameraLut = lut; + + foreach (var mapping in lut.CameraMappings) + { + if (mapping.MappingID <= 0) continue; + + // 跳过已存在的 ID + bool exists = false; + foreach (var c in CameraConfigs) + { + if (c.CameraId == mapping.MappingID) + { + exists = true; + break; + } + } + if (exists) continue; + + // 创建临时 VirtualCamera + var camGo = new GameObject($"VCam_Lut_{mapping.MappingID}"); + camGo.transform.SetParent(transform); + var vcam = camGo.AddComponent(); + + // 应用 Lut 参数 + vcam.m_Lens.FieldOfView = mapping.FOV; + vcam.Priority = 0; // 默认低优先级,切换时才提高 + + // 添加 Transposer 用于跟随偏移 + var body = vcam.AddCinemachineComponent(); + body.m_FollowOffset = mapping.FollowOffset; + + // 添加 Composer 用于注视 + var aim = vcam.AddCinemachineComponent(); + aim.m_TrackedObjectOffset = mapping.LookAtOffset; + if (mapping.UseSoftZone) + { + aim.m_DeadZoneWidth = mapping.DeadZoneWidth; + aim.m_DeadZoneHeight = mapping.DeadZoneHeight; + } + + var config = new CameraConfig + { + CameraId = mapping.MappingID, + CameraName = $"Camera_{mapping.MappingID}", + VirtualCamera = vcam, + Priority = mapping.Priority + }; + + CameraConfigs.Add(config); + Debug.Log($"[CameraManager] 从LUT加载相机: ID={mapping.MappingID}, FOV={mapping.FOV}"); + } + } + + /// + /// 构建相机缓存 + /// + private void BuildCameraCache() + { + _cameraCache.Clear(); + foreach (var config in CameraConfigs) + { + _cameraCache[config.CameraId] = config; + } + } + + /// + /// 获取相机配置 + /// + private bool TryGetCameraConfig(int cameraId, out CameraConfig config) + { + return _cameraCache.TryGetValue(cameraId, out config); + } + + /// + /// 尝试从 CameraLut 延迟加载(首次调用时触发) + /// + private bool TryLazyLoadFromLut() + { + if (CameraLut == null) + { +#if UNITY_EDITOR + CameraLut = AssetDatabase.LoadAssetAtPath( + "Assets/Verification/Luts/CameraLut_101.asset"); +#endif + } + + if (CameraLut == null || CameraLut.CameraMappings.Count == 0) + return false; + + LoadFromCameraLut(CameraLut); + BuildCameraCache(); + Debug.Log($"[CameraManager] 延迟加载CameraLut完成,共 {CameraLut.CameraMappings.Count} 个相机"); + return true; + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/CameraManagerImpl.cs.meta b/Assets/BP_Scripts/GameplayEditor/Runtime/CameraManagerImpl.cs.meta new file mode 100644 index 0000000..c3437f0 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/CameraManagerImpl.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3b6f0b18ec20e7a4dbab048e9f4e22d9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/CampManagerImpl.cs b/Assets/BP_Scripts/GameplayEditor/Runtime/CampManagerImpl.cs new file mode 100644 index 0000000..1609198 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/CampManagerImpl.cs @@ -0,0 +1,423 @@ +using System.Collections.Generic; +using System.Linq; +using GameplayEditor.Core; +using UnityEngine; + +namespace GameplayEditor.Runtime +{ + /// + /// 阵营管理器实际实现 + /// 管理NPC阵营关系、阵营事件 + /// + public class CampManagerImpl : MonoBehaviour, ICampManager + { + #region ICampManager 接口实现 + + public string ManagerName => "CampManager"; + public bool IsInitialized { get; private set; } + + #endregion + + [Header("阵营配置")] + [Tooltip("阵营定义")] + public List Camps = new List(); + + [Tooltip("默认阵营关系矩阵")] + public List DefaultRelations = new List(); + + [Tooltip("默认阵营ID")] + public int DefaultCampId = 1; + + // NPC阵营数据 + private Dictionary _npcCampData = new Dictionary(); + + // 阵营关系缓存 + private Dictionary> _relationMatrix = + new Dictionary>(); + + // 阵营定义 + [System.Serializable] + public class CampDefinition + { + [Tooltip("阵营ID")] + public int CampId; + + [Tooltip("阵营名称")] + public string CampName; + + [Tooltip("阵营颜色")] + public Color CampColor = Color.white; + } + + // 阵营关系 + [System.Serializable] + public class CampRelation + { + [Tooltip("阵营1")] + public int Camp1; + + [Tooltip("阵营2")] + public int Camp2; + + [Tooltip("关系类型")] + public RelationType Relation; + } + + // 关系类型 + public enum RelationType + { + Enemy, // 敌对 + Neutral, // 中立 + Friendly, // 友好 + Same // 同一阵营 + } + + // NPC阵营数据 + private class NPCCampData + { + public string NPCId; + public List Camps = new List(); + public int PrimaryCamp; // 主阵营 + } + + private void Awake() + { + if (GameplayManagerHub.Instance != null) + { + GameplayManagerHub.Instance.RegisterManager(this); + } + } + + #region IGameplayManager 接口 + + public void Initialize() + { + // 构建关系矩阵 + BuildRelationMatrix(); + + IsInitialized = true; + Debug.Log("[CampManager] 初始化完成"); + } + + public void Shutdown() + { + _npcCampData.Clear(); + IsInitialized = false; + } + + #endregion + + #region ICampManager 接口实现 + + /// + /// 获取NPC的阵营列表 + /// + public List GetNpcCamps(string npcId) + { + if (_npcCampData.TryGetValue(npcId, out var data)) + { + return new List(data.Camps); + } + + // 从NPCManager获取阵营 + var npcManager = GameplayManagerHub.Instance?.NPCManager; + if (npcManager is NPCManagerImpl impl) + { + int camp = impl.GetNPCCamp(npcId); + if (camp > 0) + { + return new List { camp }; + } + } + + // 默认阵营 + return new List { DefaultCampId }; + } + + /// + /// 检查两个NPC是否同阵营 + /// + public bool IsSameCamp(string npcId1, string npcId2) + { + var camps1 = GetNpcCamps(npcId1); + var camps2 = GetNpcCamps(npcId2); + + // 检查是否有共同阵营 + foreach (var camp1 in camps1) + { + foreach (var camp2 in camps2) + { + if (camp1 == camp2) + return true; + + var relation = GetCampRelation(camp1, camp2); + if (relation == RelationType.Friendly || relation == RelationType.Same) + return true; + } + } + + return false; + } + + /// + /// 检查两个NPC是否敌对 + /// + public bool IsEnemyCamp(string npcId1, string npcId2) + { + var camps1 = GetNpcCamps(npcId1); + var camps2 = GetNpcCamps(npcId2); + + // 检查是否有敌对关系 + foreach (var camp1 in camps1) + { + foreach (var camp2 in camps2) + { + var relation = GetCampRelation(camp1, camp2); + if (relation == RelationType.Enemy) + return true; + } + } + + return false; + } + + #endregion + + #region 扩展功能 + + /// + /// 设置NPC阵营 + /// + public void SetNpcCamp(string npcId, int campId) + { + if (!_npcCampData.TryGetValue(npcId, out var data)) + { + data = new NPCCampData { NPCId = npcId }; + _npcCampData[npcId] = data; + } + + data.PrimaryCamp = campId; + if (!data.Camps.Contains(campId)) + { + data.Camps.Add(campId); + } + + // 同步到NPCManager + var npcManager = GameplayManagerHub.Instance?.NPCManager; + if (npcManager is NPCManagerImpl impl) + { + impl.SetNPCCamp(npcId, campId); + } + + Debug.Log($"[CampManager] 设置NPC {npcId} 阵营为 {campId}"); + } + + /// + /// 添加NPC到阵营 + /// + public void AddNpcToCamp(string npcId, int campId) + { + if (!_npcCampData.TryGetValue(npcId, out var data)) + { + data = new NPCCampData { NPCId = npcId, PrimaryCamp = campId }; + _npcCampData[npcId] = data; + } + + if (!data.Camps.Contains(campId)) + { + data.Camps.Add(campId); + } + } + + /// + /// 移除NPC从阵营 + /// + public void RemoveNpcFromCamp(string npcId, int campId) + { + if (_npcCampData.TryGetValue(npcId, out var data)) + { + data.Camps.Remove(campId); + } + } + + /// + /// 获取阵营关系 + /// + public RelationType GetCampRelation(int camp1, int camp2) + { + if (camp1 == camp2) + return RelationType.Same; + + if (_relationMatrix.TryGetValue(camp1, out var relations)) + { + if (relations.TryGetValue(camp2, out var relation)) + { + return relation; + } + } + + // 默认中立 + return RelationType.Neutral; + } + + /// + /// 设置阵营关系 + /// + public void SetCampRelation(int camp1, int camp2, RelationType relation) + { + if (!_relationMatrix.ContainsKey(camp1)) + { + _relationMatrix[camp1] = new Dictionary(); + } + if (!_relationMatrix.ContainsKey(camp2)) + { + _relationMatrix[camp2] = new Dictionary(); + } + + _relationMatrix[camp1][camp2] = relation; + _relationMatrix[camp2][camp1] = relation; + } + + /// + /// 获取指定阵营的所有NPC + /// + public List GetNPCsInCamp(int campId) + { + var result = new List(); + + foreach (var kvp in _npcCampData) + { + if (kvp.Value.Camps.Contains(campId)) + { + result.Add(kvp.Key); + } + } + + return result; + } + + /// + /// 获取NPC的主阵营 + /// + public int GetNPCPrimaryCamp(string npcId) + { + if (_npcCampData.TryGetValue(npcId, out var data)) + { + return data.PrimaryCamp; + } + return DefaultCampId; + } + + /// + /// 检查阵营是否存在 + /// + public bool CampExists(int campId) + { + return Camps.Any(c => c.CampId == campId); + } + + /// + /// 获取阵营名称 + /// + public string GetCampName(int campId) + { + var camp = Camps.Find(c => c.CampId == campId); + return camp?.CampName ?? $"Camp_{campId}"; + } + + /// + /// 获取阵营颜色 + /// + public Color GetCampColor(int campId) + { + var camp = Camps.Find(c => c.CampId == campId); + return camp?.CampColor ?? Color.white; + } + + /// + /// 获取所有阵营ID + /// + public List GetAllCampIds() + { + return Camps.Select(c => c.CampId).ToList(); + } + + /// + /// 改变NPC阵营(用于剧情转折等) + /// + public void ChangeNPCCamp(string npcId, int newCampId) + { + var oldCamp = GetNPCPrimaryCamp(npcId); + + SetNpcCamp(npcId, newCampId); + + // 触发阵营改变事件 + OnNPCCampChanged(npcId, oldCamp, newCampId); + } + + /// + /// 检查是否为盟友(包含友好关系) + /// + public bool IsAlly(string npcId1, string npcId2) + { + return IsSameCamp(npcId1, npcId2); + } + + /// + /// 获取NPC的敌对列表 + /// + public List GetEnemies(string npcId) + { + var npcManager = GameplayManagerHub.Instance?.NPCManager; + if (npcManager == null) return new List(); + + // 通过反射或其他方式获取NPC列表,或者需要扩展INPCManager接口 + var allNPCs = new List(); // 需要实际实现 + var enemies = new List(); + + foreach (var otherId in allNPCs) + { + if (otherId != npcId && IsEnemyCamp(npcId, otherId)) + { + enemies.Add(otherId); + } + } + + return enemies; + } + + #endregion + + #region 事件 + + /// + /// NPC阵营改变事件 + /// + public System.Action OnCampChanged; + + private void OnNPCCampChanged(string npcId, int oldCamp, int newCamp) + { + Debug.Log($"[CampManager] NPC {npcId} 阵营改变: {oldCamp} -> {newCamp}"); + OnCampChanged?.Invoke(npcId, oldCamp, newCamp); + } + + #endregion + + #region 私有方法 + + /// + /// 构建关系矩阵 + /// + private void BuildRelationMatrix() + { + _relationMatrix.Clear(); + + foreach (var relation in DefaultRelations) + { + SetCampRelation(relation.Camp1, relation.Camp2, relation.Relation); + } + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/CampManagerImpl.cs.meta b/Assets/BP_Scripts/GameplayEditor/Runtime/CampManagerImpl.cs.meta new file mode 100644 index 0000000..44302d8 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/CampManagerImpl.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2a2511e610fb9eb4fab0d505b92cf725 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/DialogInfoManagerImpl.cs b/Assets/BP_Scripts/GameplayEditor/Runtime/DialogInfoManagerImpl.cs new file mode 100644 index 0000000..8beaf19 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/DialogInfoManagerImpl.cs @@ -0,0 +1,1001 @@ +using System.Collections.Generic; +using GameplayEditor.Config; +using GameplayEditor.Core; +using UnityEngine; +using UnityEngine.UI; + +namespace GameplayEditor.Runtime +{ + /// + /// 对话信息管理器实际实现 + /// 管理对话的显示、关闭和本地化 + /// + public class DialogInfoManagerImpl : MonoBehaviour, IUIManager + { + #region IUIManager 接口实现 + + public string ManagerName => "DialogInfoManager"; + public bool IsInitialized { get; private set; } + + #endregion + + [Header("对话UI配置")] + [Tooltip("对话预制体")] + public GameObject DialogPrefab; + + [Tooltip("对话容器")] + public Transform DialogContainer; + + [Tooltip("默认对话框")] + public GameObject DefaultDialogPanel; + + [Header("组件引用")] + [Tooltip("角色名文本")] + public Text NameText; + + [Tooltip("对话内容文本")] + public Text ContentText; + + [Tooltip("角色头像")] + public Image CharacterImage; + + [Tooltip("蒙层")] + public GameObject MaskPanel; + + [Header("设置")] + [Tooltip("打字机效果速度")] + public float TypewriterSpeed = 0.05f; + + [Tooltip("是否使用打字机效果")] + public bool UseTypewriterEffect = true; + + // 运行时数据 + private Dictionary _activeDialogs = new Dictionary(); + private DialogInfoDatabase _dialogDatabase; + private int _lastDialogId = 0; + + // 对话实例 + private class DialogInstance + { + public int DialogId; + public DialogInfoData Data; + public GameObject GameObject; + public float StartTime; + public bool IsActive; + } + + private void Awake() + { + // 自动注册到Hub + if (GameplayManagerHub.Instance != null) + { + GameplayManagerHub.Instance.RegisterManager(this); + } + + // 监听语言切换事件 + if (LocalizationManager.Instance != null) + { + LocalizationManager.Instance.OnLanguageChanged += OnLanguageChanged; + } + } + + private void OnDestroy() + { + // 取消监听 + if (LocalizationManager.Instance != null) + { + LocalizationManager.Instance.OnLanguageChanged -= OnLanguageChanged; + } + } + + /// + /// 语言切换回调 + /// + private void OnLanguageChanged(LanguageType newLanguage) + { + Debug.Log($"[DialogInfoManager] 语言切换为: {newLanguage},刷新所有活跃对话"); + RefreshAllActiveDialogs(); + } + + /// + /// 刷新所有活跃对话(语言切换时调用) + /// + public void RefreshAllActiveDialogs() + { + foreach (var kvp in _activeDialogs) + { + var instance = kvp.Value; + if (instance == null || !instance.IsActive) continue; + + // 重新获取本地化的数据 + var originalData = _dialogDatabase?.FindDialogByID(instance.DialogId); + if (originalData != null) + { + var localizedData = ApplyLocalization(originalData); + instance.Data = localizedData; + + // 更新UI显示 + UpdateDialogContent(instance, localizedData); + } + } + } + + /// + /// 更新对话内容显示 + /// + private void UpdateDialogContent(DialogInstance instance, DialogInfoData data) + { + if (instance?.GameObject == null) return; + + var texts = instance.GameObject.GetComponentsInChildren(true); + foreach (var text in texts) + { + // 根据文本组件名称或位置判断是角色名还是内容 + if (text.gameObject.name.Contains("Name") || text.gameObject.name.Contains("角色")) + { + text.text = data.CharacterName; + } + else if (text.gameObject.name.Contains("Content") || text.gameObject.name.Contains("内容") || text.gameObject.name.Contains("Text")) + { + if (UseTypewriterEffect) + { + StopAllCoroutines(); + StartCoroutine(PlayTypewriterEffect(text, data.Text)); + } + else + { + text.text = data.Text; + } + } + } + } + + #region IGameplayManager 接口 + + public void Initialize() + { + // 避免重复初始化 + if (IsInitialized) + { + Debug.Log("[DialogInfoManager] 已经初始化,跳过"); + return; + } + + // 加载对话数据库(如果还没有设置的话) + LoadDialogDatabase(); + + // 确保容器存在 + if (DialogContainer == null) + { + DialogContainer = transform; + } + + IsInitialized = true; + Debug.Log("[DialogInfoManager] 初始化完成"); + } + + public void Shutdown() + { + // 关闭所有对话 + CloseAllDialogs(); + IsInitialized = false; + } + + #endregion + + #region IUIManager 接口实现 + + /// + /// 显示对话 + /// + public void ShowDialog(int dialogId) + { + ShowDialogInternal(dialogId, null); + } + + /// + /// 显示对话(带回调) + /// + public bool ShowDialog(int dialogId, System.Action onComplete = null) + { + return ShowDialogInternal(dialogId, onComplete); + } + + /// + /// 关闭指定对话 + /// + public void CloseDialog(int dialogId) + { + if (_activeDialogs.TryGetValue(dialogId, out var instance)) + { + CloseDialogInstance(instance); + } + } + + /// + /// 关闭所有对话 + /// + public void CloseAllDialogs() + { + var ids = new List(_activeDialogs.Keys); + foreach (var id in ids) + { + CloseDialog(id); + } + } + + /// + /// 检查对话是否活跃 + /// + public bool IsDialogActive(int dialogId) + { + return _activeDialogs.ContainsKey(dialogId) && _activeDialogs[dialogId].IsActive; + } + + public void ShowLoading() + { + // TODO: 实现加载画面 + Debug.Log("[DialogInfoManager] 显示加载画面"); + } + + public void HideLoading() + { + // TODO: 实现隐藏加载画面 + Debug.Log("[DialogInfoManager] 隐藏加载画面"); + } + + #endregion + + #region 核心功能 + + /// + /// 加载对话数据库 + /// + private void LoadDialogDatabase() + { + // 如果已经有数据库,不需要重新加载 + if (_dialogDatabase != null) + { + return; + } + + // 从Resources或AssetDatabase加载 + #if UNITY_EDITOR + // 先尝试特定路径 + _dialogDatabase = UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/Verification/Configs/DialogInfoDatabase.asset"); + + // 如果失败,尝试全局搜索 + if (_dialogDatabase == null) + { + var guids = UnityEditor.AssetDatabase.FindAssets("t:DialogInfoDatabase"); + if (guids.Length > 0) + { + var path = UnityEditor.AssetDatabase.GUIDToAssetPath(guids[0]); + _dialogDatabase = UnityEditor.AssetDatabase.LoadAssetAtPath(path); + Debug.Log($"[DialogInfoManager] 从 {path} 加载数据库"); + } + } + else + { + Debug.Log("[DialogInfoManager] 从 Assets/Verification/Configs/DialogInfoDatabase.asset 加载数据库"); + } + #else + _dialogDatabase = Resources.Load("DialogInfoDatabase"); + #endif + + if (_dialogDatabase == null) + { + Debug.LogWarning("[DialogInfoManager] 未找到对话数据库,请确保 DialogInfoDatabase.asset 存在"); + } + else + { + Debug.Log($"[DialogInfoManager] 数据库加载成功,包含 {_dialogDatabase.Configs?.Count ?? 0} 个配置"); + } + } + + /// + /// 显示对话内部实现 + /// + private bool ShowDialogInternal(int dialogId, System.Action onComplete) + { + // 获取对话数据 + var dialogData = GetDialogData(dialogId); + if (dialogData == null) + { + Debug.LogError($"[DialogInfoManager] 未找到对话配置: {dialogId}"); + return false; + } + + // 如果已存在,先关闭 + if (_activeDialogs.ContainsKey(dialogId)) + { + CloseDialog(dialogId); + } + + // 创建对话实例 + var instance = CreateDialogInstance(dialogId, dialogData); + if (instance == null) + { + return false; + } + + _activeDialogs[dialogId] = instance; + _lastDialogId = dialogId; + + // 应用配置 + ApplyDialogConfig(instance, dialogData); + + // 播放显示动画 + PlayShowAnimation(instance); + + // 自动关闭 + if (dialogData.Duration > 0) + { + StartCoroutine(AutoCloseDialog(dialogId, dialogData.Duration)); + } + + Debug.Log($"[DialogInfoManager] 显示对话: {dialogId} - {dialogData.Text}"); + return true; + } + + /// + /// 获取对话数据(自动应用本地化) + /// + private DialogInfoData GetDialogData(int dialogId) + { + // 先从数据库查找 + DialogInfoData data = null; + if (_dialogDatabase != null) + { + data = _dialogDatabase.FindDialogByID(dialogId); + } + + // 如果找不到,创建默认数据 + if (data == null) + { + data = CreateDefaultDialogData(dialogId); + } + + // 应用本地化 + data = ApplyLocalization(data); + + return data; + } + + /// + /// 应用本地化处理 + /// + private DialogInfoData ApplyLocalization(DialogInfoData original) + { + if (original == null) return null; + + // 创建副本以避免修改原始数据 + var localized = new DialogInfoData + { + ID = original.ID, + CharacterName = original.CharacterName, + Text = original.Text, + IsMask = original.IsMask, + MaskTarget = original.MaskTarget, + ClickKey = original.ClickKey, + DialogType = original.DialogType, + UiType = original.UiType, + PrefabPath = original.PrefabPath, + Duration = original.Duration, + Params1 = original.Params1, + Params2 = original.Params2, + Params3 = original.Params3, + Params4 = original.Params4, + Params5 = original.Params5, + Text_EN = original.Text_EN, + Text_JP = original.Text_JP, + Text_KR = original.Text_KR, + Text_FR = original.Text_FR, + Text_DE = original.Text_DE, + Text_ES = original.Text_ES + }; + + // 如果本地化管理器存在,获取当前语言的文本 + if (LocalizationManager.Instance != null) + { + string localizedText = LocalizationManager.Instance.GetDialogText(original.ID); + if (!string.IsNullOrEmpty(localizedText) && !localizedText.StartsWith("[Missing:")) + { + localized.Text = localizedText; + } + else + { + // 从DialogInfoData的多语言字段获取 + localized.Text = GetLocalizedTextFromData(original); + } + } + else + { + // 直接从DialogInfoData的多语言字段获取 + localized.Text = GetLocalizedTextFromData(original); + } + + return localized; + } + + /// + /// 从DialogInfoData的多语言字段获取当前语言的文本 + /// + private string GetLocalizedTextFromData(DialogInfoData data) + { + LanguageType currentLang = LanguageType.Chinese; + + if (LocalizationManager.Instance != null) + { + currentLang = LocalizationManager.Instance.CurrentLanguage; + } + + return currentLang switch + { + LanguageType.English => !string.IsNullOrEmpty(data.Text_EN) ? data.Text_EN : data.Text, + LanguageType.Japanese => !string.IsNullOrEmpty(data.Text_JP) ? data.Text_JP : data.Text, + LanguageType.Korean => !string.IsNullOrEmpty(data.Text_KR) ? data.Text_KR : data.Text, + LanguageType.French => !string.IsNullOrEmpty(data.Text_FR) ? data.Text_FR : data.Text, + LanguageType.German => !string.IsNullOrEmpty(data.Text_DE) ? data.Text_DE : data.Text, + LanguageType.Spanish => !string.IsNullOrEmpty(data.Text_ES) ? data.Text_ES : data.Text, + _ => data.Text + }; + } + + /// + /// 创建默认对话数据(测试用) + /// + private DialogInfoData CreateDefaultDialogData(int dialogId) + { + return new DialogInfoData + { + ID = dialogId, + CharacterName = "角色", + Text = $"这是对话 {dialogId} 的内容", + Duration = 3f, + IsMask = false + }; + } + + /// + /// 创建对话实例 + /// + private DialogInstance CreateDialogInstance(int dialogId, DialogInfoData data) + { + GameObject dialogGo = null; + + // 使用自定义预制体 + if (!string.IsNullOrEmpty(data.PrefabPath)) + { + #if UNITY_EDITOR + var prefab = UnityEditor.AssetDatabase.LoadAssetAtPath(data.PrefabPath); + if (prefab != null) + { + dialogGo = Instantiate(prefab, DialogContainer); + } + #endif + } + + // 使用默认预制体 + if (dialogGo == null && DialogPrefab != null) + { + dialogGo = Instantiate(DialogPrefab, DialogContainer); + } + + // 使用默认面板 + if (dialogGo == null && DefaultDialogPanel != null) + { + DefaultDialogPanel.SetActive(true); + dialogGo = DefaultDialogPanel; + } + + // 创建运行时默认对话框 + if (dialogGo == null) + { + dialogGo = CreateRuntimeDialogPanel(data); + } + + return new DialogInstance + { + DialogId = dialogId, + Data = data, + GameObject = dialogGo, + StartTime = Time.time, + IsActive = true + }; + } + + /// + /// 创建运行时默认对话框 + /// 在没有预制体时使用 + /// + private GameObject CreateRuntimeDialogPanel(DialogInfoData data) + { + // 创建Canvas + var canvas = GameObject.Find("DialogCanvas"); + if (canvas == null) + { + canvas = new GameObject("DialogCanvas"); + var c = canvas.AddComponent(); + c.renderMode = RenderMode.ScreenSpaceOverlay; + c.sortingOrder = 100; + canvas.AddComponent(); + canvas.AddComponent(); + DontDestroyOnLoad(canvas); + } + + // 创建对话框面板 + var panel = new GameObject("DialogPanel"); + panel.transform.SetParent(canvas.transform, false); + + var rect = panel.AddComponent(); + rect.anchorMin = new Vector2(0.5f, 0); + rect.anchorMax = new Vector2(0.5f, 0); + rect.pivot = new Vector2(0.5f, 0); + rect.sizeDelta = new Vector2(600, 150); + rect.anchoredPosition = new Vector2(0, 50); + + // 添加背景图片 + var image = panel.AddComponent(); + image.color = new Color(0.1f, 0.1f, 0.1f, 0.9f); + + // 创建角色名文本 + var nameObj = new GameObject("NameText"); + nameObj.transform.SetParent(panel.transform, false); + var nameRect = nameObj.AddComponent(); + nameRect.anchorMin = new Vector2(0, 1); + nameRect.anchorMax = new Vector2(0, 1); + nameRect.pivot = new Vector2(0, 1); + nameRect.sizeDelta = new Vector2(200, 30); + nameRect.anchoredPosition = new Vector2(10, -5); + + var nameText = nameObj.AddComponent(); + nameText.font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); + nameText.fontSize = 20; + nameText.color = Color.yellow; + nameText.text = data.CharacterName; + + // 创建内容文本 + var contentObj = new GameObject("ContentText"); + contentObj.transform.SetParent(panel.transform, false); + var contentRect = contentObj.AddComponent(); + contentRect.anchorMin = Vector2.zero; + contentRect.anchorMax = Vector2.one; + contentRect.pivot = new Vector2(0.5f, 0.5f); + contentRect.offsetMin = new Vector2(10, 10); + contentRect.offsetMax = new Vector2(-10, -40); + + var contentText = contentObj.AddComponent(); + contentText.font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); + contentText.fontSize = 18; + contentText.color = Color.white; + contentText.text = data.Text; + contentText.supportRichText = data.EnableRichText; + + Debug.Log("[DialogInfoManager] 创建运行时对话框"); + return panel; + } + + /// + /// 应用对话配置 + /// + private void ApplyDialogConfig(DialogInstance instance, DialogInfoData data) + { + // 蒙层设置 + if (MaskPanel != null) + { + MaskPanel.SetActive(data.IsMask); + } + + // 获取UI组件 + var nameText = instance.GameObject.GetComponentInChildren(true); + var contentText = GetContentText(instance.GameObject); + + // 设置文本(支持富文本处理) + string processedText = ProcessDialogText(data); + + if (nameText != null && !string.IsNullOrEmpty(data.CharacterName)) + { + nameText.text = data.CharacterName; + } + + if (contentText != null) + { + // 应用富文本样式 + ApplyRichTextStyle(contentText, data); + + if (UseTypewriterEffect) + { + // 打字机效果需要特殊处理富文本 + StartCoroutine(PlayTypewriterEffectWithRichText(contentText, processedText)); + } + else + { + contentText.text = processedText; + } + } + + // 设置位置(如果有蒙层目标) + if (data.IsMask && data.MaskTarget != Vector2.zero) + { + SetDialogPosition(instance.GameObject, data.MaskTarget); + } + } + + /// + /// 处理对话文本(富文本验证和清理) + /// + private string ProcessDialogText(DialogInfoData data) + { + string text = data.Text ?? ""; + + // 如果启用富文本且需要验证 + if (data.EnableRichText && data.ValidateRichText && text.Contains("<")) + { + var result = data.ValidateRichTextContent(); + if (!result.IsValid) + { + Debug.LogWarning($"[DialogInfoManager] 对话ID={data.ID} 的富文本验证失败,使用清理后的文本"); + } + return result.CleanedText ?? text; + } + + // 如果禁用富文本,移除所有标签 + if (!data.EnableRichText) + { + return System.Text.RegularExpressions.Regex.Replace(text, "<[^>]+>", ""); + } + + return text; + } + + /// + /// 应用富文本样式到Text组件 + /// + private void ApplyRichTextStyle(Text textComponent, DialogInfoData data) + { + if (textComponent == null) return; + + // 确保Text组件支持富文本 + textComponent.supportRichText = data.EnableRichText; + + // 如果有自定义样式,可以尝试应用 + if (!string.IsNullOrEmpty(data.RichTextStyleName)) + { + // 这里可以根据样式名称应用特定的字体、颜色等 + // 实际项目中可能需要更复杂的样式系统 + var config = data.GetEffectiveRichTextConfig(); + if (config != null) + { + // 查找是否有对应的字号预设 + var sizePreset = config.SizePresets.Find(s => s.SizeName == data.RichTextStyleName); + if (sizePreset != null) + { + textComponent.fontSize = sizePreset.SizeValue; + } + + // 查找是否有对应的颜色预设 + var colorPreset = config.ColorPresets.Find(c => c.ColorName == data.RichTextStyleName); + if (colorPreset != null) + { + textComponent.color = colorPreset.Color; + } + } + } + } + + /// + /// 带富文本支持的打字机效果 + /// + private System.Collections.IEnumerator PlayTypewriterEffectWithRichText(Text textComponent, string fullText) + { + textComponent.text = ""; + + // 如果文本包含富文本标签,需要特殊处理 + if (fullText.Contains("<")) + { + // 简单实现:逐步显示文本,同时保留标签 + // 注意:这是一个简化版本,完整的实现需要解析标签树 + int visibleChars = 0; + string plainText = System.Text.RegularExpressions.Regex.Replace(fullText, "<[^>]+>", ""); + + for (int i = 0; i <= plainText.Length; i++) + { + visibleChars = i; + textComponent.text = TruncateTextPreservingTags(fullText, visibleChars); + yield return new WaitForSeconds(TypewriterSpeed); + } + } + else + { + // 普通文本,逐字符显示 + foreach (char c in fullText) + { + textComponent.text += c; + yield return new WaitForSeconds(TypewriterSpeed); + } + } + } + + /// + /// 截断文本但保留标签完整性 + /// + private string TruncateTextPreservingTags(string text, int maxVisibleChars) + { + if (string.IsNullOrEmpty(text) || maxVisibleChars <= 0) + return ""; + + var result = new System.Text.StringBuilder(); + int visibleCount = 0; + bool insideTag = false; + + foreach (char c in text) + { + if (c == '<') + { + insideTag = true; + result.Append(c); + } + else if (c == '>') + { + insideTag = false; + result.Append(c); + } + else if (insideTag) + { + // 标签内部字符,直接添加 + result.Append(c); + } + else + { + // 可见字符 + if (visibleCount < maxVisibleChars) + { + result.Append(c); + visibleCount++; + } + else + { + // 已经超过可见字符数,但还要继续添加未闭合的标签 + break; + } + } + } + + // 关闭未闭合的标签 + CloseOpenTags(result, text); + + return result.ToString(); + } + + /// + /// 关闭未闭合的标签 + /// + private void CloseOpenTags(System.Text.StringBuilder result, string originalText) + { + // 这是一个简化实现,只处理简单的标签 + var openTags = new System.Collections.Generic.Stack(); + var tagRegex = new System.Text.RegularExpressions.Regex("<([a-zA-Z][a-zA-Z0-9]*)(?:\\s[^>]*)?>"); + var closeTagRegex = new System.Text.RegularExpressions.Regex(""); + + int searchLength = Mathf.Min(result.Length, originalText.Length); + string processedPart = originalText.Substring(0, searchLength); + + var openMatches = tagRegex.Matches(processedPart); + var closeMatches = closeTagRegex.Matches(processedPart); + + foreach (System.Text.RegularExpressions.Match match in openMatches) + { + string tagName = match.Groups[1].Value.ToLower(); + // 忽略自闭合标签 + if (tagName != "br" && tagName != "space") + { + openTags.Push(tagName); + } + } + + foreach (System.Text.RegularExpressions.Match match in closeMatches) + { + string tagName = match.Groups[1].Value.ToLower(); + if (openTags.Count > 0 && openTags.Peek() == tagName) + { + openTags.Pop(); + } + } + + // 关闭剩余的标签 + while (openTags.Count > 0) + { + string tagName = openTags.Pop(); + result.Append($""); + } + } + + /// + /// 获取内容文本组件 + /// + private Text GetContentText(GameObject dialogGo) + { + // 优先使用ContentText字段 + if (ContentText != null && dialogGo == DefaultDialogPanel) + { + return ContentText; + } + + // 否则查找 + var texts = dialogGo.GetComponentsInChildren(true); + if (texts.Length >= 2) + { + return texts[1]; // 假设第二个Text是内容 + } + return texts.Length > 0 ? texts[0] : null; + } + + /// + /// 设置对话位置 + /// + private void SetDialogPosition(GameObject dialogGo, Vector2 maskTarget) + { + var rectTransform = dialogGo.GetComponent(); + if (rectTransform != null) + { + // 万分比转换为屏幕坐标 + float x = (maskTarget.x / 10000f) * Screen.width; + float y = (maskTarget.y / 10000f) * Screen.height; + + rectTransform.position = new Vector3(x, y, 0); + } + } + + /// + /// 播放打字机效果 + /// + private System.Collections.IEnumerator PlayTypewriterEffect(Text textComponent, string fullText) + { + textComponent.text = ""; + + foreach (char c in fullText) + { + textComponent.text += c; + yield return new WaitForSeconds(TypewriterSpeed); + } + } + + /// + /// 播放显示动画 + /// + private void PlayShowAnimation(DialogInstance instance) + { + // 简单的缩放动画 + var rectTransform = instance.GameObject.GetComponent(); + if (rectTransform != null) + { + rectTransform.localScale = Vector3.zero; + // 动画效果 - 使用简单插值代替LeanTween + StartCoroutine(ScaleAnimation(rectTransform, Vector3.zero, Vector3.one, 0.3f)); + } + } + + /// + /// 播放关闭动画 + /// + private void PlayHideAnimation(DialogInstance instance, System.Action onComplete) + { + var rectTransform = instance.GameObject.GetComponent(); + if (rectTransform != null) + { + // 动画效果 + StartCoroutine(ScaleAnimationWithCallback(rectTransform, rectTransform.localScale, Vector3.zero, 0.2f, onComplete)); + } + else + { + onComplete?.Invoke(); + } + } + + /// + /// 缩放动画(无回调) + /// + private System.Collections.IEnumerator ScaleAnimation(RectTransform rect, Vector3 from, Vector3 to, float duration) + { + float elapsed = 0; + while (elapsed < duration) + { + elapsed += Time.deltaTime; + float t = elapsed / duration; + // EaseOutBack效果 + t = Mathf.Sin(t * Mathf.PI * 0.5f); + rect.localScale = Vector3.Lerp(from, to, t); + yield return null; + } + rect.localScale = to; + } + + /// + /// 缩放动画(带回调) + /// + private System.Collections.IEnumerator ScaleAnimationWithCallback(RectTransform rect, Vector3 from, Vector3 to, float duration, System.Action onComplete) + { + float elapsed = 0; + while (elapsed < duration) + { + elapsed += Time.deltaTime; + float t = elapsed / duration; + // EaseIn效果 + rect.localScale = Vector3.Lerp(from, to, t); + yield return null; + } + rect.localScale = to; + onComplete?.Invoke(); + } + + /// + /// 自动关闭对话 + /// + private System.Collections.IEnumerator AutoCloseDialog(int dialogId, float delay) + { + yield return new WaitForSeconds(delay); + + if (_activeDialogs.ContainsKey(dialogId)) + { + CloseDialog(dialogId); + } + } + + /// + /// 关闭对话实例 + /// + private void CloseDialogInstance(DialogInstance instance) + { + if (instance == null) return; + + instance.IsActive = false; + + PlayHideAnimation(instance, () => + { + if (instance.GameObject != null && instance.GameObject != DefaultDialogPanel) + { + Destroy(instance.GameObject); + } + else if (DefaultDialogPanel != null) + { + DefaultDialogPanel.SetActive(false); + } + }); + + _activeDialogs.Remove(instance.DialogId); + + Debug.Log($"[DialogInfoManager] 关闭对话: {instance.DialogId}"); + } + + #endregion + + #region 公共方法 + + /// + /// 获取上一个显示的对话ID + /// + public int GetLastDialogId() + { + return _lastDialogId; + } + + /// + /// 获取活跃的对话列表 + /// + public List GetActiveDialogIds() + { + return new List(_activeDialogs.Keys); + } + + /// + /// 设置对话数据库 + /// + public void SetDialogDatabase(DialogInfoDatabase database) + { + _dialogDatabase = database; + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/DialogInfoManagerImpl.cs.meta b/Assets/BP_Scripts/GameplayEditor/Runtime/DialogInfoManagerImpl.cs.meta new file mode 100644 index 0000000..ca62e24 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/DialogInfoManagerImpl.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bed6c6361e4efcc4aa4010b70db5ae75 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/FXManagerImpl.cs b/Assets/BP_Scripts/GameplayEditor/Runtime/FXManagerImpl.cs new file mode 100644 index 0000000..1812f7d --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/FXManagerImpl.cs @@ -0,0 +1,703 @@ +using System.Collections.Generic; +using GameplayEditor.Config; +using GameplayEditor.Core; +using UnityEngine; + +namespace GameplayEditor.Runtime +{ + /// + /// 特效管理器实际实现 + /// 管理特效的播放、停止和生命周期 + /// + public class FXManagerImpl : MonoBehaviour, IFXManager + { + #region IFXManager 接口实现 + + public string ManagerName => "FXManager"; + public bool IsInitialized { get; private set; } + + #endregion + + [Header("特效配置")] + [Tooltip("特效预制体配置表")] + public List FXPrefabs = new List(); + + [Tooltip("特效容器")] + public Transform FXContainer; + + [Tooltip("默认特效预制体")] + public GameObject DefaultFXPrefab; + + [Header("对象池设置")] + [Tooltip("使用对象池")] + public bool UseObjectPool = true; + + [Tooltip("对象池初始大小")] + public int PoolInitialSize = 10; + + // 特效实例管理 + private Dictionary _activeEffects = new Dictionary(); + private Dictionary> _effectPools = new Dictionary>(); + private int _nextEffectId = 1; + + // 特效配置 + [System.Serializable] + public class FXPrefabConfig + { + [Tooltip("特效ID")] + public int FXId; + + [Tooltip("特效预制体")] + public GameObject Prefab; + + [Tooltip("默认持续时间")] + public float DefaultDuration = 2f; + + [Tooltip("是否循环")] + public bool Loop = false; + + [Tooltip("是否自动销毁")] + public bool AutoDestroy = true; + } + + // 特效实例 + private class FXInstance + { + public int EffectId; + public int FXTypeId; + public GameObject GameObject; + public ParticleSystem ParticleSystem; + public float StartTime; + public float Duration; + public Transform Target; + public Vector3 Offset; + } + + private void Awake() + { + if (GameplayManagerHub.Instance != null) + { + GameplayManagerHub.Instance.RegisterManager(this); + } + } + + #region IGameplayManager 接口 + + public void Initialize() + { + if (FXContainer == null) + { + FXContainer = transform; + } + + // 初始化对象池 + if (UseObjectPool) + { + InitializeObjectPools(); + } + + IsInitialized = true; + Debug.Log("[FXManager] 初始化完成"); + } + + public void Shutdown() + { + // 停止所有特效 + var ids = new List(_activeEffects.Keys); + foreach (var id in ids) + { + StopEffect(id); + } + + IsInitialized = false; + } + + #endregion + + #region IFXManager 接口实现 + + /// + /// 在指定位置播放特效 + /// + public void PlayEffect(int effectId, Vector3 position, float duration = 0) + { + var config = GetFXConfig(effectId); + if (config == null && DefaultFXPrefab == null) + { + Debug.LogError($"[FXManager] 特效配置不存在: {effectId}"); + return; + } + + float finalDuration = duration > 0 ? duration : config.DefaultDuration; + + // 创建特效实例 + var instance = CreateEffectInstance(effectId, config); + if (instance == null) return; + + // 设置位置 + instance.GameObject.transform.position = position; + instance.GameObject.transform.rotation = Quaternion.identity; + + // 播放 + PlayEffectInstance(instance, finalDuration); + + Debug.Log($"[FXManager] 播放特效: {effectId} 在 {position}, 持续时间: {finalDuration}s"); + } + + #region FXMapping 详细配置支持 + + /// + /// 使用FXMapping配置播放特效 + /// + public void PlayEffectWithMapping(int effectId, FXMapping mapping, Vector3 position, Transform parent = null) + { + if (mapping == null) + { + PlayEffect(effectId, position); + return; + } + + // 延迟播放 + if (mapping.Delay > 0) + { + StartCoroutine(DelayedPlayEffect(effectId, mapping, position, parent)); + return; + } + + PlayEffectWithMappingInternal(effectId, mapping, position, parent); + } + + private System.Collections.IEnumerator DelayedPlayEffect(int effectId, FXMapping mapping, Vector3 position, Transform parent) + { + yield return new WaitForSeconds(mapping.Delay); + PlayEffectWithMappingInternal(effectId, mapping, position, parent); + } + + private void PlayEffectWithMappingInternal(int effectId, FXMapping mapping, Vector3 position, Transform parent) + { + // 创建特效实例(考虑对象池设置) + var instance = CreateEffectInstanceWithMapping(mapping); + if (instance == null) return; + + // 设置位置和父级 + instance.GameObject.transform.position = position; + if (parent != null && mapping.AttachToTarget) + { + instance.GameObject.transform.SetParent(parent); + instance.GameObject.transform.localPosition = mapping.LocalPositionOffset; + instance.GameObject.transform.localRotation = Quaternion.Euler(mapping.LocalRotationOffset); + } + + // 应用缩放 + if (mapping.Scale != 1f) + { + instance.GameObject.transform.localScale *= mapping.Scale; + } + + // 应用颜色覆盖 + if (mapping.OverrideColor) + { + ApplyColorOverride(instance.GameObject, mapping.OverrideTint); + } + + // 应用透明度 + if (mapping.Alpha < 1f) + { + ApplyAlpha(instance.GameObject, mapping.Alpha); + } + + // 确定持续时间 + float duration = mapping.Duration; + if (duration <= 0) + { + duration = GetFXConfig(effectId)?.DefaultDuration ?? 2f; + } + + // 播放(考虑速度和循环) + PlayEffectInstanceWithMapping(instance, mapping, duration); + + Debug.Log($"[FXManager] 播放特效: {effectId} (使用详细配置), 位置: {position}, 持续: {duration}s, 循环: {mapping.Loop}"); + } + + /// + /// 创建特效实例(支持FXMapping配置) + /// + private FXInstance CreateEffectInstanceWithMapping(FXMapping mapping) + { + GameObject fxGo = null; + int effectId = mapping.MappingID; + + // 根据对象池设置决定创建方式 + bool usePool = mapping.UseObjectPool && UseObjectPool; + + if (usePool) + { + fxGo = GetFromPoolWithMapping(mapping); + } + else + { + // 加载Prefab + GameObject prefab = null; + #if UNITY_EDITOR + prefab = UnityEditor.AssetDatabase.LoadAssetAtPath(mapping.PrefabPath); + #endif + + if (prefab != null) + { + fxGo = Instantiate(prefab, FXContainer); + } + else if (DefaultFXPrefab != null) + { + fxGo = Instantiate(DefaultFXPrefab, FXContainer); + } + } + + if (fxGo == null) + { + Debug.LogError($"[FXManager] 无法创建特效: {effectId}"); + return null; + } + + var instance = new FXInstance + { + EffectId = _nextEffectId++, + FXTypeId = effectId, + GameObject = fxGo, + ParticleSystem = fxGo.GetComponent(), + StartTime = Time.time, + Duration = mapping.Duration + }; + + _activeEffects[instance.EffectId] = instance; + + return instance; + } + + /// + /// 从对象池获取(支持FXMapping) + /// + private GameObject GetFromPoolWithMapping(FXMapping mapping) + { + int effectId = mapping.MappingID; + + if (_effectPools.TryGetValue(effectId, out var pool) && pool.Count > 0) + { + var obj = pool.Dequeue(); + obj.SetActive(true); + return obj; + } + + // 池为空,创建新的 + GameObject prefab = null; + #if UNITY_EDITOR + prefab = UnityEditor.AssetDatabase.LoadAssetAtPath(mapping.PrefabPath); + #endif + + if (prefab != null) + { + return Instantiate(prefab, FXContainer); + } + + return null; + } + + /// + /// 播放特效实例(支持FXMapping) + /// + private void PlayEffectInstanceWithMapping(FXInstance instance, FXMapping mapping, float duration) + { + if (instance?.ParticleSystem == null) return; + + var main = instance.ParticleSystem.main; + + // 应用速度 + if (mapping.Speed != 1f) + { + main.simulationSpeed = mapping.Speed; + } + + // 应用循环设置 + if (mapping.Loop) + { + main.loop = true; + } + + // 应用持续时间(如果不是循环) + if (!mapping.Loop && duration > 0) + { + main.duration = duration; + } + + // 播放 + instance.ParticleSystem.Play(); + + // 非循环特效,自动停止 + if (!mapping.Loop && duration > 0) + { + StartCoroutine(AutoStopEffect(instance.EffectId, duration)); + } + else if (mapping.Loop && mapping.AutoDestroy) + { + // 循环特效需要手动停止 + StartCoroutine(AutoStopEffect(instance.EffectId, duration > 0 ? duration : 5f)); + } + } + + /// + /// 应用颜色覆盖 + /// + private void ApplyColorOverride(GameObject fxGo, Color color) + { + var renderers = fxGo.GetComponentsInChildren(); + foreach (var renderer in renderers) + { + if (renderer.material != null) + { + renderer.material.color = color; + } + } + } + + /// + /// 应用透明度 + /// + private void ApplyAlpha(GameObject fxGo, float alpha) + { + var renderers = fxGo.GetComponentsInChildren(); + foreach (var renderer in renderers) + { + if (renderer.material != null) + { + var color = renderer.material.color; + color.a = alpha; + renderer.material.color = color; + } + } + } + + #endregion + + /// + /// 在NPC上播放特效 + /// + public void PlayEffectOnNPC(int effectId, string npcId, float duration = 0) + { + var npcManager = GameplayManagerHub.Instance?.NPCManager; + if (npcManager == null) + { + Debug.LogWarning("[FXManager] NPCManager未注册"); + return; + } + + var npc = npcManager.GetNPC(npcId); + if (npc == null) + { + Debug.LogWarning($"[FXManager] NPC不存在: {npcId}"); + return; + } + + // 在NPC位置播放 + PlayEffect(effectId, npc.transform.position, duration); + + // TODO: 可以创建跟随NPC的特效 + } + + #endregion + + #region 扩展功能 + + /// + /// 停止指定特效 + /// + public void StopEffect(int effectId) + { + if (!_activeEffects.TryGetValue(effectId, out var instance)) + return; + + StopEffectInstance(instance); + _activeEffects.Remove(effectId); + } + + /// + /// 停止所有特效 + /// + public void StopAllEffects() + { + var ids = new List(_activeEffects.Keys); + foreach (var id in ids) + { + StopEffect(id); + } + } + + /// + /// 创建跟随目标的特效 + /// + public int PlayEffectAttached(int effectId, Transform target, Vector3 offset, float duration = 0) + { + var config = GetFXConfig(effectId); + if (config == null) return -1; + + var instance = CreateEffectInstance(effectId, config); + if (instance == null) return -1; + + instance.Target = target; + instance.Offset = offset; + + // 初始位置 + if (target != null) + { + instance.GameObject.transform.position = target.position + offset; + } + + float finalDuration = duration > 0 ? duration : config.DefaultDuration; + PlayEffectInstance(instance, finalDuration); + + return instance.EffectId; + } + + /// + /// 播放序列特效 + /// + public void PlaySequenceEffect(List effectIds, List positions, float interval) + { + StartCoroutine(PlaySequenceCoroutine(effectIds, positions, interval)); + } + + private System.Collections.IEnumerator PlaySequenceCoroutine( + List effectIds, List positions, float interval) + { + for (int i = 0; i < effectIds.Count && i < positions.Count; i++) + { + PlayEffect(effectIds[i], positions[i]); + yield return new WaitForSeconds(interval); + } + } + + /// + /// 检查特效是否正在播放 + /// + public bool IsEffectPlaying(int effectId) + { + return _activeEffects.ContainsKey(effectId); + } + + /// + /// 获取活跃特效数量 + /// + public int GetActiveEffectCount() + { + return _activeEffects.Count; + } + + #endregion + + #region 私有方法 + + /// + /// 获取特效配置 + /// + private FXPrefabConfig GetFXConfig(int effectId) + { + return FXPrefabs.Find(config => config.FXId == effectId); + } + + /// + /// 初始化对象池 + /// + private void InitializeObjectPools() + { + foreach (var config in FXPrefabs) + { + if (config.Prefab == null) continue; + + var pool = new Queue(); + for (int i = 0; i < PoolInitialSize; i++) + { + var obj = Instantiate(config.Prefab, FXContainer); + obj.SetActive(false); + pool.Enqueue(obj); + } + + _effectPools[config.FXId] = pool; + } + + Debug.Log($"[FXManager] 初始化对象池,包含 {FXPrefabs.Count} 种特效"); + } + + /// + /// 从对象池获取特效 + /// + private GameObject GetFromPool(int effectId, FXPrefabConfig config) + { + if (_effectPools.TryGetValue(effectId, out var pool) && pool.Count > 0) + { + var obj = pool.Dequeue(); + obj.SetActive(true); + return obj; + } + + // 池为空,创建新的 + if (config?.Prefab != null) + { + return Instantiate(config.Prefab, FXContainer); + } + + return null; + } + + /// + /// 回收到对象池 + /// + private void ReturnToPool(int effectId, GameObject obj) + { + obj.SetActive(false); + + if (_effectPools.TryGetValue(effectId, out var pool)) + { + pool.Enqueue(obj); + } + else + { + Destroy(obj); + } + } + + /// + /// 创建特效实例 + /// + private FXInstance CreateEffectInstance(int effectId, FXPrefabConfig config) + { + GameObject fxGo = null; + + if (UseObjectPool) + { + fxGo = GetFromPool(effectId, config); + } + else + { + fxGo = Instantiate(config?.Prefab ?? DefaultFXPrefab, FXContainer); + } + + if (fxGo == null) + { + return null; + } + + var instance = new FXInstance + { + EffectId = _nextEffectId++, + FXTypeId = effectId, + GameObject = fxGo, + ParticleSystem = fxGo.GetComponent(), + StartTime = Time.time + }; + + _activeEffects[instance.EffectId] = instance; + + return instance; + } + + /// + /// 播放特效实例 + /// + private void PlayEffectInstance(FXInstance instance, float duration) + { + instance.Duration = duration; + + // 播放ParticleSystem + if (instance.ParticleSystem != null) + { + instance.ParticleSystem.Play(); + } + + // 非循环特效,自动停止 + if (duration > 0 && !GetFXConfig(instance.FXTypeId)?.Loop == true) + { + StartCoroutine(AutoStopEffect(instance.EffectId, duration)); + } + } + + /// + /// 停止特效实例 + /// + private void StopEffectInstance(FXInstance instance) + { + if (instance.ParticleSystem != null) + { + instance.ParticleSystem.Stop(); + } + + // 延迟回收 + StartCoroutine(DelayedRecycle(instance)); + } + + /// + /// 自动停止特效 + /// + private System.Collections.IEnumerator AutoStopEffect(int effectId, float delay) + { + yield return new WaitForSeconds(delay); + + if (_activeEffects.ContainsKey(effectId)) + { + StopEffect(effectId); + } + } + + /// + /// 延迟回收 + /// + private System.Collections.IEnumerator DelayedRecycle(FXInstance instance) + { + // 等待粒子停止 + if (instance.ParticleSystem != null) + { + yield return new WaitUntil(() => !instance.ParticleSystem.IsAlive()); + } + else + { + yield return new WaitForSeconds(0.5f); + } + + // 回收或销毁 + if (UseObjectPool) + { + ReturnToPool(instance.FXTypeId, instance.GameObject); + } + else + { + Destroy(instance.GameObject); + } + } + + private void Update() + { + // 更新跟随目标的特效位置 + var toRemove = new List(); + + foreach (var kvp in _activeEffects) + { + var instance = kvp.Value; + + if (instance.Target != null && instance.GameObject != null) + { + instance.GameObject.transform.position = instance.Target.position + instance.Offset; + } + + // 检查目标是否销毁 + if (instance.Target == null && instance.Offset != Vector3.zero) + { + toRemove.Add(kvp.Key); + } + } + + foreach (var id in toRemove) + { + StopEffect(id); + } + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/FXManagerImpl.cs.meta b/Assets/BP_Scripts/GameplayEditor/Runtime/FXManagerImpl.cs.meta new file mode 100644 index 0000000..4efa55a --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/FXManagerImpl.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 238220ebb94bfc2429dbbf7e188fb6dd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/FlowTriggerBridge.cs b/Assets/BP_Scripts/GameplayEditor/Runtime/FlowTriggerBridge.cs new file mode 100644 index 0000000..fe828ab --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/FlowTriggerBridge.cs @@ -0,0 +1,117 @@ +using System; +using UnityEngine; +using FlowCanvas; +using FlowCanvas.Nodes; +using ParadoxNotion; + +namespace GameplayEditor.Runtime +{ + /// + /// FlowCanvas 触发桥接器 + /// 只做两件事:1) 键盘输入→FlowCanvas事件 2) 提供TriggerFlowEvent供其他脚本调用 + /// 所有响应逻辑在 FlowCanvas 图中通过 GameplayNodes 实现 + /// + [RequireComponent(typeof(FlowScriptController))] + public class FlowTriggerBridge : MonoBehaviour + { + [Header("按键触发")] + public KeyCode DialogKey = KeyCode.Space; + public KeyCode CameraKey = KeyCode.C; + public KeyCode InteractKey = KeyCode.F; + + private FlowScriptController _flowController; + + void Start() + { + _flowController = GetComponent(); + if (_flowController == null) + { + Debug.LogWarning("[FlowTriggerBridge] 未找到FlowScriptController"); + return; + } + + if (_flowController.behaviour == null) + { + _flowController.behaviour = CreateDynamicFlowScript(); + } + + Debug.Log("[FlowTriggerBridge] FlowCanvas桥接已启用"); + } + + void Update() + { + if (_flowController == null) return; + + if (Input.GetKeyDown(DialogKey)) + TriggerFlowEvent("OnShowDialog", 1); + if (Input.GetKeyDown(CameraKey)) + TriggerFlowEvent("OnChangeCamera", 101); + if (Input.GetKeyDown(InteractKey)) + TriggerFlowEvent("OnPlayerInteract", "Interact"); + } + + /// + /// 触发 FlowCanvas 事件(供 InteractionZone、SimplePlayerController 等调用) + /// + public void TriggerFlowEvent(string eventName, object param = null) + { + if (_flowController == null) return; + + try + { + if (param != null) + _flowController.CallFunction(eventName, param); + else + _flowController.CallFunction(eventName); + + Debug.Log($"[FC Event] {eventName} → {param}"); + } + catch (Exception ex) + { + Debug.LogWarning($"[FlowTriggerBridge] {eventName} 调用失败: {ex.Message}"); + } + } + + FlowScript CreateDynamicFlowScript() + { + var flow = ScriptableObject.CreateInstance(); + flow.name = "DynamicFlowScript"; + + string[] events = { "OnShowDialog", "OnCloseDialog", "OnChangeCamera", "OnResetCamera", + "OnInteract", "OnZoneEnter", "OnZoneExit", "OnPlayerInteract" }; + + foreach (var evt in events) + { + AddEvent(flow, evt, evt.Contains("Camera") && !evt.Contains("Reset") ? typeof(int) : + evt.Contains("Dialog") && evt.Contains("Show") ? typeof(int) : + evt.Contains("Dialog") && evt.Contains("Close") ? typeof(int) : typeof(string)); + } + + flow.SelfSerialize(); + return flow; + } + + void AddEvent(FlowScript flow, string id, Type paramType) + { + var node = flow.AddNode( + new Vector2(UnityEngine.Random.Range(0, 300), UnityEngine.Random.Range(0, 300)) + ) as CustomFunctionEvent; + + if (node != null) + { + node.identifier = id; + if (paramType != null) + { + var field = typeof(CustomFunctionEvent).GetField("_parameters", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (field != null) + { + var list = field.GetValue(node) as System.Collections.Generic.List; + list?.Add(new DynamicParameterDefinition("param", paramType)); + } + node.GatherPorts(); + } + } + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/FlowTriggerBridge.cs.meta b/Assets/BP_Scripts/GameplayEditor/Runtime/FlowTriggerBridge.cs.meta new file mode 100644 index 0000000..5894672 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/FlowTriggerBridge.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0204b531fc5376a46b907a2f45783bb8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/GameSystemBridge.cs b/Assets/BP_Scripts/GameplayEditor/Runtime/GameSystemBridge.cs new file mode 100644 index 0000000..52dd396 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/GameSystemBridge.cs @@ -0,0 +1,263 @@ +using System; +using UnityEngine; + +namespace GameplayEditor.Runtime +{ + /// + /// 游戏系统桥接器 + /// 提供统一的接口供行为树节点调用实际游戏系统 + /// 如果实际系统不存在,则使用模拟实现 + /// + public class GameSystemBridge : MonoBehaviour + { + private static GameSystemBridge _instance; + public static GameSystemBridge Instance + { + get + { + if (_instance == null) + { + _instance = FindObjectOfType(); + if (_instance == null) + { + var go = new GameObject("GameSystemBridge"); + _instance = go.AddComponent(); + DontDestroyOnLoad(go); + } + } + return _instance; + } + } + + // 系统接口 + public IBattleSystem BattleSystem { get; private set; } + public IUISystem UISystem { get; private set; } + public INPCSystem NPCSystem { get; private set; } + public ICameraSystem CameraSystem { get; private set; } + public IEffectSystem EffectSystem { get; private set; } + + private void Awake() + { + if (_instance != null && _instance != this) + { + Destroy(gameObject); + return; + } + _instance = this; + DontDestroyOnLoad(gameObject); + + // 初始化系统(优先查找实际实现,否则使用模拟) + InitializeSystems(); + } + + /// + /// 初始化所有系统 + /// + private void InitializeSystems() + { + // 查找实际的游戏系统实现 + BattleSystem = FindObjectOfType() as IBattleSystem; + UISystem = FindObjectOfType() as IUISystem; + NPCSystem = FindObjectOfType() as INPCSystem; + CameraSystem = FindObjectOfType() as ICameraSystem; + EffectSystem = FindObjectOfType() as IEffectSystem; + + // 如果没有实际实现,使用模拟 + if (BattleSystem == null) + { + BattleSystem = new MockBattleSystem(); + Debug.Log("[GameSystemBridge] 使用模拟战斗系统"); + } + if (UISystem == null) + { + UISystem = new MockUISystem(); + Debug.Log("[GameSystemBridge] 使用模拟UI系统"); + } + if (NPCSystem == null) + { + NPCSystem = new MockNPCSystem(); + Debug.Log("[GameSystemBridge] 使用模拟NPC系统"); + } + if (CameraSystem == null) + { + CameraSystem = new MockCameraSystem(); + Debug.Log("[GameSystemBridge] 使用模拟相机系统"); + } + if (EffectSystem == null) + { + EffectSystem = new MockEffectSystem(); + Debug.Log("[GameSystemBridge] 使用模拟特效系统"); + } + } + + /// + /// 注册实际的系统实现 + /// + public void RegisterBattleSystem(IBattleSystem system) => BattleSystem = system; + public void RegisterUISystem(IUISystem system) => UISystem = system; + public void RegisterNPCSystem(INPCSystem system) => NPCSystem = system; + public void RegisterCameraSystem(ICameraSystem system) => CameraSystem = system; + public void RegisterEffectSystem(IEffectSystem system) => EffectSystem = system; + } + + #region 系统接口定义 + + public interface IBattleSystem + { + bool SetFightTarget(string npcId, string targetId); + int[] GetNpcCamps(string npcId); + bool IsNPCAlive(string npcId); + int GetNpcHP(string npcId); + } + + public interface IUISystem + { + bool ShowDialog(int dialogId); + bool CloseDialog(int dialogId); + bool ShowMask(Vector2 position); + bool HideMask(); + } + + public interface INPCSystem + { + GameObject SpawnNPC(string npcId, Vector3 position); + bool DespawnNPC(string npcId); + GameObject GetNPC(string npcId); + bool PlayAnimation(string npcId, string animationName); + } + + public interface ICameraSystem + { + bool SwitchCamera(string cameraId); + bool ShakeCamera(float duration, float intensity); + bool SetCameraTarget(string targetId); + } + + public interface IEffectSystem + { + GameObject PlayEffect(string effectId, Vector3 position); + GameObject PlayEffectAttached(string effectId, string targetId); + bool StopEffect(string effectInstanceId); + } + + #endregion + + #region 模拟实现 + + public class MockBattleSystem : IBattleSystem + { + public bool SetFightTarget(string npcId, string targetId) + { + Debug.Log($"[MockBattle] SetFightTarget: {npcId} -> {targetId}"); + return true; + } + + public int[] GetNpcCamps(string npcId) + { + // 模拟阵营分配 + if (int.TryParse(npcId, out var id)) + { + if (id >= 2000 && id < 3000) return new[] { 1 }; // 玩家 + if (id >= 3000 && id < 4000) return new[] { 2 }; // 敌人 + } + return new[] { 0 }; + } + + public bool IsNPCAlive(string npcId) => true; + public int GetNpcHP(string npcId) => 100; + } + + public class MockUISystem : IUISystem + { + public bool ShowDialog(int dialogId) + { + Debug.Log($"[MockUI] ShowDialog: {dialogId}"); + return true; + } + + public bool CloseDialog(int dialogId) + { + Debug.Log($"[MockUI] CloseDialog: {dialogId}"); + return true; + } + + public bool ShowMask(Vector2 position) + { + Debug.Log($"[MockUI] ShowMask at: {position}"); + return true; + } + + public bool HideMask() + { + Debug.Log($"[MockUI] HideMask"); + return true; + } + } + + public class MockNPCSystem : INPCSystem + { + public GameObject SpawnNPC(string npcId, Vector3 position) + { + Debug.Log($"[MockNPC] SpawnNPC: {npcId} at {position}"); + return null; + } + + public bool DespawnNPC(string npcId) + { + Debug.Log($"[MockNPC] DespawnNPC: {npcId}"); + return true; + } + + public GameObject GetNPC(string npcId) => null; + + public bool PlayAnimation(string npcId, string animationName) + { + Debug.Log($"[MockNPC] PlayAnimation: {npcId} - {animationName}"); + return true; + } + } + + public class MockCameraSystem : ICameraSystem + { + public bool SwitchCamera(string cameraId) + { + Debug.Log($"[MockCamera] SwitchCamera: {cameraId}"); + return true; + } + + public bool ShakeCamera(float duration, float intensity) + { + Debug.Log($"[MockCamera] ShakeCamera: {duration}s, intensity:{intensity}"); + return true; + } + + public bool SetCameraTarget(string targetId) + { + Debug.Log($"[MockCamera] SetCameraTarget: {targetId}"); + return true; + } + } + + public class MockEffectSystem : IEffectSystem + { + public GameObject PlayEffect(string effectId, Vector3 position) + { + Debug.Log($"[MockEffect] PlayEffect: {effectId} at {position}"); + return null; + } + + public GameObject PlayEffectAttached(string effectId, string targetId) + { + Debug.Log($"[MockEffect] PlayEffectAttached: {effectId} on {targetId}"); + return null; + } + + public bool StopEffect(string effectInstanceId) + { + Debug.Log($"[MockEffect] StopEffect: {effectInstanceId}"); + return true; + } + } + + #endregion +} diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/GameSystemBridge.cs.meta b/Assets/BP_Scripts/GameplayEditor/Runtime/GameSystemBridge.cs.meta new file mode 100644 index 0000000..99249e5 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/GameSystemBridge.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0d6552241c0fe1e4a84dff457cf030e2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayAutoInitializer.cs b/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayAutoInitializer.cs new file mode 100644 index 0000000..6db00b0 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayAutoInitializer.cs @@ -0,0 +1,140 @@ +using GameplayEditor.Config; +using GameplayEditor.Core; +using UnityEngine; +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace GameplayEditor.Runtime +{ + /// + /// 游戏运行时自动初始化器 + /// 确保所有管理器在行为树执行前正确初始化 + /// 使用 DefaultExecutionOrder(-100) 保证在 BehaviourTreeController 之前运行 + /// + [DefaultExecutionOrder(-100)] + public class GameplayAutoInitializer : MonoBehaviour + { + [Header("对话配置(可选,为空则自动加载)")] + public DialogInfoByStageConfig ByStageConfig; + public DialogInfoDatabase Database; + + [Header("相机配置(可选,为空则自动加载)")] + public ActivityCameraLut CameraLut; + + [Header("配置路径")] + public string ByStageConfigPath = "Assets/Verification/Configs/DialogInfoByStageConfig.asset"; + public string DatabasePath = "Assets/Verification/Configs/DialogInfoDatabase.asset"; + public string CameraLutPath = "Assets/Verification/Luts/CameraLut_101.asset"; + + private void Awake() + { + // 确保 GameplayManagerHub 存在 + var _ = GameplayManagerHub.Instance; + } + + private void Start() + { + InitializeDialogManager(); + InitializeCameraManager(); + + Debug.Log("[GameplayAutoInitializer] 所有管理器初始化完成"); + } + + /// + /// 初始化 DialogInfoManager + /// + private void InitializeDialogManager() + { + if (DialogInfoManager.Instance.IsInitialized) + { + Debug.Log("[GameplayAutoInitializer] DialogInfoManager 已初始化,跳过"); + return; + } + + // 尝试从 Inspector 引用或资产路径加载 + if (ByStageConfig == null || Database == null) + { +#if UNITY_EDITOR + LoadDialogConfigsFromAssetDatabase(); +#else + LoadDialogConfigsFromResources(); +#endif + } + + if (ByStageConfig != null && Database != null) + { + DialogInfoManager.Instance.Initialize(ByStageConfig, Database); + Debug.Log("[GameplayAutoInitializer] DialogInfoManager 初始化完成"); + } + else + { + Debug.LogWarning($"[GameplayAutoInitializer] 无法初始化 DialogInfoManager: " + + $"ByStageConfig={ByStageConfig != null}, Database={Database != null}"); + } + } + + /// + /// 初始化 CameraManager 并从 CameraLut 加载配置 + /// + private void InitializeCameraManager() + { + // 查找已有的 CameraManagerImpl + var cameraManager = FindObjectOfType(); + + if (cameraManager == null) + { + // 创建 CameraManager + var go = new GameObject("CameraManager"); + cameraManager = go.AddComponent(); + Debug.Log("[GameplayAutoInitializer] 创建 CameraManagerImpl"); + } + + // 加载 CameraLut + if (CameraLut == null) + { +#if UNITY_EDITOR + CameraLut = AssetDatabase.LoadAssetAtPath(CameraLutPath); +#endif + } + + // 从 CameraLut 填充配置 + if (CameraLut != null) + { + cameraManager.LoadFromCameraLut(CameraLut); + } + + // 初始化 + if (!cameraManager.IsInitialized) + { + cameraManager.Initialize(); + } + } + +#if UNITY_EDITOR + private void LoadDialogConfigsFromAssetDatabase() + { + if (ByStageConfig == null) + { + ByStageConfig = AssetDatabase.LoadAssetAtPath(ByStageConfigPath); + } + if (Database == null) + { + Database = AssetDatabase.LoadAssetAtPath(DatabasePath); + } + } +#endif + + private void LoadDialogConfigsFromResources() + { + if (ByStageConfig == null) + { + ByStageConfig = Resources.Load("DialogInfoByStageConfig"); + } + if (Database == null) + { + Database = Resources.Load("DialogInfoDatabase"); + } + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayAutoInitializer.cs.meta b/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayAutoInitializer.cs.meta new file mode 100644 index 0000000..d851c3b --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayAutoInitializer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 763a6d3f15959cb478dba3d9c9a72ad6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayRuntimeInitializer.cs b/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayRuntimeInitializer.cs new file mode 100644 index 0000000..df38d9a --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayRuntimeInitializer.cs @@ -0,0 +1,115 @@ +using System.Collections; +using GameplayEditor.Config; +using UnityEngine; +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace GameplayEditor.Runtime +{ + /// + /// 游戏运行时初始化器 + /// 确保所有管理器在Play Mode下正确初始化 + /// + public class GameplayRuntimeInitializer : MonoBehaviour + { + [Header("对话配置")] + public DialogInfoByStageConfig ByStageConfig; + public DialogInfoDatabase Database; + + [Header("延迟初始化")] + public float InitDelay = 0.5f; + + [Header("验证配置路径")] + public string ByStageConfigPath = "Assets/Verification/Configs/DialogInfoByStageConfig.asset"; + public string DatabasePath = "Assets/Verification/Configs/DialogInfoDatabase.asset"; + + void Start() + { + StartCoroutine(InitializeCoroutine()); + } + + private IEnumerator InitializeCoroutine() + { + // 等待一帧,确保所有对象都创建完成 + yield return null; + yield return new WaitForSeconds(InitDelay); + + // 初始化 DialogInfoManager 单例 + InitializeDialogManager(); + + Debug.Log("[GameplayRuntimeInitializer] 运行时初始化完成"); + } + + /// + /// 初始化对话管理器 + /// + private void InitializeDialogManager() + { + // 如果场景引用为空,尝试从资产路径加载 + if (ByStageConfig == null || Database == null) + { +#if UNITY_EDITOR + LoadFromAssetDatabase(); +#else + LoadFromResources(); +#endif + } + + // 如果找到了配置,初始化单例 + if (ByStageConfig != null && Database != null) + { + DialogInfoManager.Instance.Initialize(ByStageConfig, Database); + Debug.Log("[GameplayRuntimeInitializer] DialogInfoManager 初始化完成"); + } + else + { + Debug.LogWarning($"[GameplayRuntimeInitializer] 无法初始化 DialogInfoManager: ByStageConfig={(ByStageConfig != null)}, Database={(Database != null)}"); + } + } + +#if UNITY_EDITOR + /// + /// 从 AssetDatabase 加载配置(Editor模式) + /// + private void LoadFromAssetDatabase() + { + if (ByStageConfig == null) + { + ByStageConfig = AssetDatabase.LoadAssetAtPath(ByStageConfigPath); + if (ByStageConfig == null) + { + // 尝试其他可能的路径 + ByStageConfig = AssetDatabase.LoadAssetAtPath("Assets/Verification/Configs/DialogInfoByStageConfig.asset"); + } + } + + if (Database == null) + { + Database = AssetDatabase.LoadAssetAtPath(DatabasePath); + if (Database == null) + { + // 尝试其他可能的路径 + Database = AssetDatabase.LoadAssetAtPath("Assets/Verification/Configs/DialogInfoDatabase.asset"); + } + } + } +#endif + + /// + /// 从 Resources 加载配置(Runtime模式) + /// + private void LoadFromResources() + { + if (ByStageConfig == null) + { + ByStageConfig = Resources.Load("DialogInfoByStageConfig"); + } + + if (Database == null) + { + Database = Resources.Load("DialogInfoDatabase"); + } + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayRuntimeInitializer.cs.meta b/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayRuntimeInitializer.cs.meta new file mode 100644 index 0000000..d6bf288 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayRuntimeInitializer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b494286b6197c4545a17623c842bc74a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayRuntimeSetup.cs b/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayRuntimeSetup.cs new file mode 100644 index 0000000..aed5b88 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayRuntimeSetup.cs @@ -0,0 +1,263 @@ +using GameplayEditor.Core; +using UnityEngine; + +namespace GameplayEditor.Runtime +{ + /// + /// 游戏运行时初始化设置 + /// 自动创建和注册所有Manager + /// + public class GameplayRuntimeSetup : MonoBehaviour + { + [Header("Manager 预制体")] + [Tooltip("DialogInfoManager 预制体")] + public GameObject DialogManagerPrefab; + + [Tooltip("NPCManager 预制体")] + public GameObject NPCManagerPrefab; + + [Tooltip("FXManager 预制体")] + public GameObject FXManagerPrefab; + + [Tooltip("CameraManager 预制体")] + public GameObject CameraManagerPrefab; + + [Tooltip("CampManager 预制体")] + public GameObject CampManagerPrefab; + + [Header("设置")] + [Tooltip("自动初始化")] + public bool AutoInitialize = true; + + [Tooltip("使用MockManager(测试用)")] + public bool UseMockManagers = false; + + // 创建的Manager实例 + private DialogInfoManagerImpl _dialogManager; + private NPCManagerImpl _npcManager; + private FXManagerImpl _fxManager; + private CameraManagerImpl _cameraManager; + private CampManagerImpl _campManager; + + private void Start() + { + if (AutoInitialize) + { + Setup(); + } + } + + /// + /// 设置所有Manager + /// + [ContextMenu("Setup Managers")] + public void Setup() + { + // 确保Hub存在 + EnsureHubExists(); + + // 创建Manager + SetupDialogManager(); + SetupNPCManager(); + SetupFXManager(); + SetupCameraManager(); + SetupCampManager(); + + Debug.Log("[GameplayRuntimeSetup] 所有Manager设置完成"); + } + + /// + /// 确保Hub存在 + /// + private void EnsureHubExists() + { + if (GameplayManagerHub.Instance == null) + { + var hubGo = new GameObject("GameplayManagerHub"); + hubGo.AddComponent(); + DontDestroyOnLoad(hubGo); + } + } + + /// + /// 设置DialogManager + /// + private void SetupDialogManager() + { + if (UseMockManagers) + { + // 使用Mock实现(已有) + return; + } + + // 检查是否已存在 + var existing = FindObjectOfType(); + if (existing != null) + { + Debug.Log("[GameplayRuntimeSetup] DialogInfoManager已存在,执行初始化"); + existing.Initialize(); + return; + } + + GameObject managerGo; + if (DialogManagerPrefab != null) + { + managerGo = Instantiate(DialogManagerPrefab); + } + else + { + managerGo = new GameObject("DialogInfoManager"); + _dialogManager = managerGo.AddComponent(); + } + + managerGo.transform.SetParent(null); + DontDestroyOnLoad(managerGo); + + _dialogManager?.Initialize(); + + Debug.Log("[GameplayRuntimeSetup] DialogInfoManager已创建"); + } + + /// + /// 设置NPCManager + /// + private void SetupNPCManager() + { + var existing = FindObjectOfType(); + if (existing != null) + { + Debug.Log("[GameplayRuntimeSetup] NPCManager已存在,执行初始化"); + existing.Initialize(); + return; + } + + GameObject managerGo; + if (NPCManagerPrefab != null) + { + managerGo = Instantiate(NPCManagerPrefab); + } + else + { + managerGo = new GameObject("NPCManager"); + _npcManager = managerGo.AddComponent(); + } + + managerGo.transform.SetParent(null); + DontDestroyOnLoad(managerGo); + + _npcManager?.Initialize(); + + Debug.Log("[GameplayRuntimeSetup] NPCManager已创建"); + } + + /// + /// 设置FXManager + /// + private void SetupFXManager() + { + var existing = FindObjectOfType(); + if (existing != null) + { + Debug.Log("[GameplayRuntimeSetup] FXManager已存在,执行初始化"); + existing.Initialize(); + return; + } + + GameObject managerGo; + if (FXManagerPrefab != null) + { + managerGo = Instantiate(FXManagerPrefab); + } + else + { + managerGo = new GameObject("FXManager"); + _fxManager = managerGo.AddComponent(); + } + + managerGo.transform.SetParent(null); + DontDestroyOnLoad(managerGo); + + _fxManager?.Initialize(); + + Debug.Log("[GameplayRuntimeSetup] FXManager已创建"); + } + + /// + /// 设置CameraManager + /// + private void SetupCameraManager() + { + var existing = FindObjectOfType(); + if (existing != null) + { + Debug.Log("[GameplayRuntimeSetup] CameraManager已存在,执行初始化"); + existing.Initialize(); + return; + } + + GameObject managerGo; + if (CameraManagerPrefab != null) + { + managerGo = Instantiate(CameraManagerPrefab); + } + else + { + managerGo = new GameObject("CameraManager"); + _cameraManager = managerGo.AddComponent(); + } + + managerGo.transform.SetParent(null); + + _cameraManager?.Initialize(); + + Debug.Log("[GameplayRuntimeSetup] CameraManager已创建"); + } + + /// + /// 设置CampManager + /// + private void SetupCampManager() + { + var existing = FindObjectOfType(); + if (existing != null) + { + Debug.Log("[GameplayRuntimeSetup] CampManager已存在,执行初始化"); + existing.Initialize(); + return; + } + + GameObject managerGo; + if (CampManagerPrefab != null) + { + managerGo = Instantiate(CampManagerPrefab); + } + else + { + managerGo = new GameObject("CampManager"); + _campManager = managerGo.AddComponent(); + } + + managerGo.transform.SetParent(null); + DontDestroyOnLoad(managerGo); + + _campManager?.Initialize(); + + Debug.Log("[GameplayRuntimeSetup] CampManager已创建"); + } + + /// + /// 关闭所有Manager + /// + [ContextMenu("Shutdown Managers")] + public void Shutdown() + { + _dialogManager?.Shutdown(); + _npcManager?.Shutdown(); + _fxManager?.Shutdown(); + _cameraManager?.Shutdown(); + _campManager?.Shutdown(); + + Debug.Log("[GameplayRuntimeSetup] 所有Manager已关闭"); + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayRuntimeSetup.cs.meta b/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayRuntimeSetup.cs.meta new file mode 100644 index 0000000..bd4a16d --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayRuntimeSetup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 54d58bd1b63505845b46c4aac0433ad5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayVerificationRunner.cs b/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayVerificationRunner.cs new file mode 100644 index 0000000..07a123e --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayVerificationRunner.cs @@ -0,0 +1,411 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using GameplayEditor.Core; +using GameplayEditor.Nodes.Actions; +using NodeCanvas.BehaviourTrees; +using NodeCanvas.Framework; +using UnityEngine; +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace GameplayEditor.Runtime +{ + /// + /// GameplayEditor 功能验证运行器 + /// 直接实例化验证任务执行,通过 FlowCanvas 事件更新 UGUI 面板 + /// BT_Verification.asset 保留用于可视化/编辑 + /// + public class GameplayVerificationRunner : MonoBehaviour + { + [Header("验证设置")] + [Tooltip("启动时自动开始验证")] + public bool AutoRunOnStart = true; + + [Tooltip("每项验证之间的间隔(秒)")] + public float StepInterval = 0.3f; + + [Header("验证行为树(可视化用)")] + [Tooltip("BT_Verification.asset")] + public BehaviourTree VerificationBT; + + [Header("验证资产引用(自动生成时填充)")] + public Config.IDRangeConfig TestIDRangeConfig; + public Config.EventBuilderConfig TestEventBuilder; + public Config.ActivityMapInfoLut TestMapInfoLut; + public Config.DialogInfoConfig TestDialogConfig; + public Config.ActivityCameraLut TestCameraLut; + + // 运行时 + private Blackboard _blackboard; + private VerificationUIController _uiController; + private FlowTriggerBridge _flowBridge; + private bool _isRunning; + + // 验证任务列表 + private List _tasks; + + void Start() + { +#if UNITY_EDITOR + FillMissingReferences(); +#endif + SetupBlackboard(); + SetupUI(); + BuildTaskList(); + + // 调试:检查 BehaviourTreeController 状态 + CheckBTControllerStatus(); + + if (AutoRunOnStart) + { + StartCoroutine(DelayedRun()); + } + } + + /// + /// 检查 BehaviourTreeController 状态,输出详细日志 + /// + private void CheckBTControllerStatus() + { + // 使用更广泛的查找方式 + var allControllers = FindObjectsOfType(); + if (allControllers == null || allControllers.Length == 0) + { + Debug.LogError("[GameplayVerificationRunner] 未找到 BehaviourTreeController! 请确保 LevelController 上已挂载该组件。"); + + // 检查 LevelController 是否存在 + var levelCtrl = GameObject.Find("LevelController"); + if (levelCtrl == null) + { + Debug.LogError(" - LevelController GameObject 也不存在!"); + } + else + { + Debug.LogError($" - LevelController 存在,但缺少 BehaviourTreeController 组件。当前组件: {string.Join(", ", levelCtrl.GetComponents().Select(c => c.GetType().Name))}"); + } + return; + } + + var btc = allControllers[0]; + Debug.Log($"[GameplayVerificationRunner] 找到 {allControllers.Length} 个 BehaviourTreeController:"); + Debug.Log($" - GameObject: {btc.gameObject.name}"); + Debug.Log($" - StageID: {btc.StageID}"); + Debug.Log($" - HeaderTree: {(btc.HeaderTree != null ? btc.HeaderTree.name : "NULL")}"); + Debug.Log($" - BodyTree: {(btc.BodyTree != null ? btc.BodyTree.name : "NULL")}"); + Debug.Log($" - TickMode: {btc.TickMode}"); + Debug.Log($" - TickRate: {btc.TickRate}"); + Debug.Log($" - AutoStart: {btc.AutoStart}"); + Debug.Log($" - enabled: {btc.enabled}"); + } + + private IEnumerator DelayedRun() + { + // 等两帧让其他系统初始化 + yield return null; + yield return null; + RunVerification(); + } + + /// + /// 开始/重新开始验证 + /// + public void RunVerification() + { + if (_isRunning) return; + StartCoroutine(RunVerificationCoroutine()); + } + + #region 验证执行 + + private void BuildTaskList() + { + _tasks = new List + { + new VerifyIDAllocatorTask(), + new VerifyStageConfigTask(), + new VerifyTickModeTask(), + new VerifyCameraLutTask(), + new VerifyMapInfoLutTask(), + new VerifyDialogInfoTask(), + new VerifyBlackboard4DTask(), + new VerifyThreeLayerNodesTask(), + new VerifyWeightRandomTask(), + new VerifyFiveEventTypesTask(), + new VerifyEventPoolConsumeTask(), + new VerifyPlayerControlTask(), + new VerifyInteractionZonesTask(), + new VerifyFlowCanvasTask(), + new VerifyDebugBreakpointTask(), + new VerifyPerfMonitorTask(), + new VerifyRuntimeVizTask(), + }; + Debug.Log($"[GameplayVerification] 构建了 {_tasks.Count} 个验证任务"); + } + + private IEnumerator RunVerificationCoroutine() + { + _isRunning = true; + Debug.Log("[GameplayVerification] 开始自动功能验证..."); + + // 重置 + ResetResults(); + _uiController?.OnVerifyStart(); + _flowBridge?.TriggerFlowEvent("OnVerifyStart"); + + int passCount = 0; + int total = VerificationCheckIDs.All.Length; // 使用统一的验证项总数 + + // 执行即时验证任务 + foreach (var task in _tasks) + { + bool ok = task.ExecuteDirect(); + string detail = task.LastDetail; + + // 写入黑板 + _blackboard.SetVariableValue(VerificationCheckIDs.ResultKey(task.CheckID), ok); + _blackboard.SetVariableValue(VerificationCheckIDs.DetailKey(task.CheckID), detail); + + if (ok) passCount++; + + // 更新UI + _uiController?.UpdateItem(task.CheckID, ok, detail); + _flowBridge?.TriggerFlowEvent("OnVerifyItemResult", $"{task.CheckID}|{(ok ? "1" : "0")}|{detail}"); + + yield return new WaitForSeconds(StepInterval); + } + + // 轮询验证:头文件执行 + yield return StartCoroutine(PollVerify("HeaderExec", "头文件执行", () => + { + var btc = FindObjectOfType(); + if (btc == null) + return (false, "未找到BehaviourTreeController"); + if (!btc.enabled) + return (false, "BehaviourTreeController未启用"); + if (!btc.IsRunning) + return (false, "BehaviourTreeController未运行"); + if (btc.HeaderExecuted) + return (true, "头文件执行完成"); + return (false, $"等待头文件执行... (HeaderTree={(btc.HeaderTree != null ? btc.HeaderTree.name : "null")})"); + }, 15f, r => { if (r) passCount++; })); + + yield return new WaitForSeconds(StepInterval); + + // 轮询验证:正文执行 + int bodyTicks = 0; + yield return StartCoroutine(PollVerify("BodyExec", "正文执行", () => + { + var btc = FindObjectOfType(); + if (btc == null) + return (false, "未找到BehaviourTreeController"); + if (!btc.IsRunning) + return (false, "BehaviourTreeController未运行"); + if (!btc.HeaderExecuted) + return (false, "头文件尚未执行完成"); + + bodyTicks++; + if (bodyTicks > 10) + return (true, $"已tick次数={bodyTicks}"); + return (false, $"已tick={bodyTicks}(需>10)"); + }, 15f, r => { if (r) passCount++; })); + + // 完成 - 确保所有结果都写入黑板 + IBlackboard ibb = _blackboard; + foreach (var checkID in VerificationCheckIDs.All) + { + string resultKey = VerificationCheckIDs.ResultKey(checkID); + if (!ibb.variables.ContainsKey(resultKey)) + { + // 如果某个验证项没有结果,标记为未执行 + _blackboard.SetVariableValue(resultKey, false); + _blackboard.SetVariableValue(VerificationCheckIDs.DetailKey(checkID), "未执行"); + } + } + + _blackboard.SetVariableValue(VerificationCheckIDs.PASS_COUNT_KEY, passCount); + _blackboard.SetVariableValue(VerificationCheckIDs.TOTAL_COUNT_KEY, total); + + _uiController?.OnVerifyComplete(passCount); + _flowBridge?.TriggerFlowEvent("OnVerifyComplete", passCount); + + Debug.Log($"[GameplayVerification] 验证完成: {passCount}/{total}"); + + // 强制刷新UI + _uiController?.RefreshAllItems(); + + _isRunning = false; + } + + private IEnumerator PollVerify(string checkID, string displayName, Func<(bool ok, string detail)> check, + float timeout, Action onComplete) + { + float start = Time.time; + bool passed = false; + string lastDetail = "等待中..."; + + while (Time.time - start < timeout) + { + var (ok, detail) = check(); + lastDetail = detail; + if (ok) + { + passed = true; + lastDetail = detail; + break; + } + yield return new WaitForSeconds(0.5f); + } + + if (!passed) + lastDetail = $"超时({timeout}s): {lastDetail}"; + + _blackboard.SetVariableValue(VerificationCheckIDs.ResultKey(checkID), passed); + _blackboard.SetVariableValue(VerificationCheckIDs.DetailKey(checkID), lastDetail); + _uiController?.UpdateItem(checkID, passed, lastDetail); + _flowBridge?.TriggerFlowEvent("OnVerifyItemResult", $"{checkID}|{(passed ? "1" : "0")}|{lastDetail}"); + + Debug.Log($"[Verify] {displayName}: {(passed ? "通过" : "未通过")} - {lastDetail}"); + onComplete?.Invoke(passed); + } + + #endregion + + #region 初始化 + + private void SetupBlackboard() + { + _blackboard = gameObject.GetComponent(); + if (_blackboard == null) + _blackboard = gameObject.AddComponent(); + } + + private void SetupUI() + { + _uiController = GetComponent(); + if (_uiController == null) + _uiController = gameObject.AddComponent(); + + _uiController.Blackboard = _blackboard; + + _flowBridge = GetComponent(); + if (_flowBridge == null) + _flowBridge = FindObjectOfType(); + _uiController.FlowBridge = _flowBridge; + } + + private void ResetResults() + { + IBlackboard ibb = _blackboard; + foreach (var checkID in VerificationCheckIDs.All) + { + string rk = VerificationCheckIDs.ResultKey(checkID); + string dk = VerificationCheckIDs.DetailKey(checkID); + if (ibb.variables.ContainsKey(rk)) + ibb.RemoveVariable(rk); + if (ibb.variables.ContainsKey(dk)) + ibb.RemoveVariable(dk); + } + } + +#if UNITY_EDITOR + private void FillMissingReferences() + { + // 检查并修复 IDRangeConfig + if (TestIDRangeConfig == null) + { + TestIDRangeConfig = AssetDatabase.LoadAssetAtPath("Assets/Verification/Configs/IDRangeConfig_Verify.asset"); + if (TestIDRangeConfig == null) + Debug.LogWarning("[VerificationRunner] 未找到 IDRangeConfig_Verify.asset"); + } + + // 检查并修复 EventBuilder + if (TestEventBuilder == null) + { + TestEventBuilder = AssetDatabase.LoadAssetAtPath("Assets/Verification/Configs/EventBuilder_1001.asset"); + if (TestEventBuilder == null) + Debug.LogWarning("[VerificationRunner] 未找到 EventBuilder_1001.asset"); + } + + // 检查并修复 MapInfoLut(处理损坏的资产) + if (TestMapInfoLut == null) + { + TestMapInfoLut = AssetDatabase.LoadAssetAtPath("Assets/Verification/Luts/MapInfo_10001.asset"); + if (TestMapInfoLut == null) + { + Debug.LogWarning("[VerificationRunner] MapInfo_10001.asset 不存在或损坏,创建fallback"); + CreateFallbackMapInfoLut(); + } + } + + // 检查并修复 DialogConfig + if (TestDialogConfig == null) + { + TestDialogConfig = AssetDatabase.LoadAssetAtPath("Assets/Verification/Configs/DialogInfo_Level1001.asset"); + if (TestDialogConfig == null) + Debug.LogWarning("[VerificationRunner] 未找到 DialogInfo_Level1001.asset"); + } + + // 检查并修复 CameraLut(处理损坏的资产) + if (TestCameraLut == null) + { + TestCameraLut = AssetDatabase.LoadAssetAtPath("Assets/Verification/Luts/CameraLut_101.asset"); + if (TestCameraLut == null) + { + Debug.LogWarning("[VerificationRunner] CameraLut_101.asset 不存在或损坏,创建fallback"); + CreateFallbackCameraLut(); + } + } + + // 确保 MapInfoLut 有数据 + if (TestMapInfoLut == null || TestMapInfoLut.Points == null || TestMapInfoLut.Points.Count < 5) + { + CreateFallbackMapInfoLut(); + } + + // 确保 CameraLut 有数据 + if (TestCameraLut == null || TestCameraLut.CameraMappings == null || !TestCameraLut.CameraMappings.Any(m => m.MappingID == 101)) + { + CreateFallbackCameraLut(); + } + } + + /// + /// 创建 MapInfoLut fallback 数据 + /// + private void CreateFallbackMapInfoLut() + { + Debug.Log("[VerificationRunner] 创建 MapInfoLut fallback 数据"); + var fallback = ScriptableObject.CreateInstance(); + fallback.InfoID = 10001; + fallback.Points = new List + { + new Config.MapPoint { PointID = "PlayerSpawn", Position = new Vector3(0, 0, 0) }, + new Config.MapPoint { PointID = "EnemySpawn_1", Position = new Vector3(5, 0, 5) }, + new Config.MapPoint { PointID = "EnemySpawn_2", Position = new Vector3(-5, 0, 5) }, + new Config.MapPoint { PointID = "ShopPosition", Position = new Vector3(3, 0, -3) }, + new Config.MapPoint { PointID = "RestPosition", Position = new Vector3(-3, 0, -3) } + }; + TestMapInfoLut = fallback; + } + + /// + /// 创建 CameraLut fallback 数据 + /// + private void CreateFallbackCameraLut() + { + Debug.Log("[VerificationRunner] 创建 CameraLut fallback 数据"); + var fallback = ScriptableObject.CreateInstance(); + fallback.CameraMappings = new List + { + new Config.CameraMapping { MappingID = 101, PrefabPath = "", FOV = 60f, Priority = 10 } + }; + TestCameraLut = fallback; + } +#endif + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayVerificationRunner.cs.meta b/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayVerificationRunner.cs.meta new file mode 100644 index 0000000..ac2cb65 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/GameplayVerificationRunner.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e72c93ea43dece244b1a27f2da237223 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/InteractionZone.cs b/Assets/BP_Scripts/GameplayEditor/Runtime/InteractionZone.cs new file mode 100644 index 0000000..d8eb57a --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/InteractionZone.cs @@ -0,0 +1,287 @@ +using System; +using System.Linq; +using UnityEngine; +using GameplayEditor.Config; +using GameplayEditor.Core; +using NodeCanvas.Framework; +using Cinemachine; + +namespace GameplayEditor.Runtime +{ + /// + /// 交互区域触发器 + /// 只负责检测进入/离开/交互,所有响应通过 FlowCanvas 事件路由 + /// + public class InteractionZone : MonoBehaviour + { + [Header("基础设置")] + public string ZoneID = "Zone_01"; + public string ZoneName = "交互区"; + public float Radius = 2f; + public ZoneTriggerType TriggerType = ZoneTriggerType.Enter; + + [Header("NodeCanvas 行为树联动")] + public bool TriggerBehaviourTree = false; + public string BlackboardEventKey = ""; + + [Header("FlowCanvas 事件")] + public bool TriggerFlowEvent = true; + public string FlowEventName = "OnZoneTrigger"; + + [Header("区域配置(由 FlowActionRouter 读取)")] + public bool ShowDialogOnEnter = false; + public int DialogID = 1; + + public bool ChangeCameraOnEnter = false; + public int CameraID = 101; + public float CameraBlendTime = 0.5f; + + [Header("Gizmo")] + public Color GizmoColor = new Color(0.2f, 0.8f, 0.9f, 0.3f); + + // 事件 + public event Action OnEnter; + public event Action OnExit; + public event Action OnInteractEvent; + + void Awake() + { + var collider = GetComponent(); + if (collider == null) + { + var sphere = gameObject.AddComponent(); + sphere.isTrigger = true; + sphere.radius = Radius; + } + } + + public void OnPlayerEnter(SimplePlayerController player) + { + Debug.Log($"[InteractionZone] 进入区域: {ZoneName} ({ZoneID})"); + + OnEnter?.Invoke(this, player); + + if (TriggerType == ZoneTriggerType.Enter || TriggerType == ZoneTriggerType.Both) + { + // 通过 FlowCanvas 事件路由所有动作 + FireZoneEnterEvent(); + + // 直接执行对话和相机切换(确保功能可用) + ExecuteZoneActions(); + } + + // 写入黑板 + if (TriggerBehaviourTree && !string.IsNullOrEmpty(BlackboardEventKey)) + { + WriteToBlackboard(BlackboardEventKey, true); + } + } + + public void OnPlayerExit(SimplePlayerController player) + { + Debug.Log($"[InteractionZone] 离开区域: {ZoneName} ({ZoneID})"); + + OnExit?.Invoke(this, player); + + if (TriggerType == ZoneTriggerType.Exit || TriggerType == ZoneTriggerType.Both) + { + FireZoneExitEvent(); + } + + if (TriggerBehaviourTree && !string.IsNullOrEmpty(BlackboardEventKey)) + { + WriteToBlackboard(BlackboardEventKey, false); + } + } + + public void OnPlayerInteract(string interactType) + { + Debug.Log($"[InteractionZone] 交互: {ZoneName} ({ZoneID}), 类型={interactType}"); + + OnInteractEvent?.Invoke(this, interactType); + + // 通过 FlowCanvas 事件路由 + var bridge = FindFlowBridge(); + if (bridge != null) + { + string packed = $"{ZoneID}|{interactType}|{DialogID}"; + bridge.TriggerFlowEvent("OnInteract", packed); + } + } + + #region 区域动作执行 + + /// + /// 直接执行区域动作(对话显示和相机切换) + /// + private void ExecuteZoneActions() + { + // 显示对话 + if (ShowDialogOnEnter && DialogID > 0) + { + ShowDialogInternal(DialogID); + } + + // 切换相机 + if (ChangeCameraOnEnter && CameraID > 0) + { + SwitchCameraInternal(CameraID, CameraBlendTime); + } + } + + /// + /// 直接显示对话(不依赖FlowCanvas) + /// + private void ShowDialogInternal(int dialogId) + { + var dialogManager = FindDialogManager(); + if (dialogManager != null) + { + dialogManager.ShowDialog(dialogId); + Debug.Log($"[InteractionZone] 显示对话: {dialogId}"); + } + else + { + Debug.LogWarning($"[InteractionZone] 未找到DialogInfoManager,无法显示对话: {dialogId}"); + } + } + + /// + /// 直接切换相机(不依赖FlowCanvas) + /// + private void SwitchCameraInternal(int cameraId, float blendTime) + { + var cameraManager = FindCameraManager(); + if (cameraManager != null) + { + cameraManager.SwitchToCamera(cameraId, blendTime); + Debug.Log($"[InteractionZone] 切换到相机: {cameraId}, 过渡时间: {blendTime}s"); + } + else + { + // 备用方案:直接操作Cinemachine虚拟相机 + SwitchCameraByPriority(cameraId, blendTime); + } + } + + /// + /// 通过优先级切换相机(备用方案) + /// + private void SwitchCameraByPriority(int cameraId, float blendTime) + { + // 查找所有Cinemachine虚拟相机 + var vcams = FindObjectsOfType(); + if (vcams.Length == 0) + { + Debug.LogWarning($"[InteractionZone] 备用方案: 场景中未找到任何 CinemachineVirtualCamera!"); + return; + } + + Debug.Log($"[InteractionZone] 备用方案: 找到 {vcams.Length} 个虚拟相机,尝试切换到 ID={cameraId}"); + + bool found = false; + foreach (var vcam in vcams) + { + // 根据相机名称或ID匹配 (CM_VCam_101, CM_VCam_102, etc.) + if (vcam.name.Contains($"_{cameraId}") || vcam.name.EndsWith(cameraId.ToString())) + { + vcam.Priority = 100; // 提高优先级 + found = true; + Debug.Log($"[InteractionZone] 通过优先级切换到相机: {vcam.name}"); + } + else + { + vcam.Priority = 0; // 降低其他相机优先级 + } + } + + if (!found) + { + Debug.LogWarning($"[InteractionZone] 备用方案: 未找到相机 ID={cameraId}。可用相机: {string.Join(", ", vcams.Select(v => v.name))}"); + } + } + + /// + /// 查找对话管理器 + /// + private DialogInfoManagerImpl FindDialogManager() + { + return FindObjectOfType(); + } + + /// + /// 查找相机管理器 + /// + private CameraManagerImpl FindCameraManager() + { + return FindObjectOfType(); + } + + #endregion + + #region FlowCanvas 事件发射 + + private void FireZoneEnterEvent() + { + var bridge = FindFlowBridge(); + if (bridge == null) return; + + // 打包参数: zoneID|dialogID|cameraID + int dialogParam = ShowDialogOnEnter ? DialogID : 0; + int cameraParam = ChangeCameraOnEnter ? CameraID : 0; + string packed = $"{ZoneID}|{dialogParam}|{cameraParam}"; + bridge.TriggerFlowEvent("OnZoneEnter", packed); + } + + private void FireZoneExitEvent() + { + var bridge = FindFlowBridge(); + if (bridge == null) return; + + int cameraParam = ChangeCameraOnEnter ? CameraID : 0; + string packed = $"{ZoneID}|{cameraParam}"; + bridge.TriggerFlowEvent("OnZoneExit", packed); + } + + private FlowTriggerBridge FindFlowBridge() + { + var controllers = FindObjectsOfType(); + foreach (var fc in controllers) + { + var bridge = fc.GetComponent(); + if (bridge != null) return bridge; + } + return FindObjectOfType(); + } + + #endregion + + private void WriteToBlackboard(string key, object value) + { + var btControllers = FindObjectsOfType(); + foreach (var btc in btControllers) + { + if (btc.blackboard != null) + { + btc.blackboard.SetVariableValue(key, value); + } + } + } + + void OnDrawGizmos() + { + Gizmos.color = GizmoColor; + Gizmos.DrawSphere(transform.position, Radius); + Gizmos.color = new Color(GizmoColor.r, GizmoColor.g, GizmoColor.b, 1f); + Gizmos.DrawWireSphere(transform.position, Radius); + } + } + + public enum ZoneTriggerType + { + Enter, + Exit, + Both, + Manual + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/InteractionZone.cs.meta b/Assets/BP_Scripts/GameplayEditor/Runtime/InteractionZone.cs.meta new file mode 100644 index 0000000..c9d818a --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/InteractionZone.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3690d38c389239c41881d9643f6d4b8f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/LocalizationManager.cs b/Assets/BP_Scripts/GameplayEditor/Runtime/LocalizationManager.cs new file mode 100644 index 0000000..31edc95 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/LocalizationManager.cs @@ -0,0 +1,380 @@ +using System.Collections.Generic; +using GameplayEditor.Config; +using UnityEngine; + +namespace GameplayEditor.Runtime +{ + /// + /// 本地化语言类型 + /// + public enum LanguageType + { + Chinese, // 中文 + English, // 英文 + Japanese, // 日文 + Korean, // 韩文 + French, // 法文 + German, // 德文 + Spanish // 西班牙文 + } + + /// + /// 本地化管理器 + /// 管理多语言文本和语言切换 + /// + public class LocalizationManager : MonoBehaviour + { + [Header("语言设置")] + [Tooltip("默认语言")] + public LanguageType DefaultLanguage = LanguageType.Chinese; + + [Tooltip("当前语言")] + public LanguageType CurrentLanguage; + + [Header("本地化数据")] + [Tooltip("对话本地化数据")] + public List DialogLocalizations = new List(); + + // 本地化数据字典 (DialogID -> Language -> Text) + private Dictionary> _dialogTexts = + new Dictionary>(); + + // 通用文本字典 (Key -> Language -> Text) + private Dictionary> _generalTexts = + new Dictionary>(); + + // 语言切换事件 + public System.Action OnLanguageChanged; + + private static LocalizationManager _instance; + public static LocalizationManager Instance + { + get + { + if (_instance == null) + { + _instance = FindObjectOfType(); + } + return _instance; + } + } + + private void Awake() + { + if (_instance != null && _instance != this) + { + Destroy(gameObject); + return; + } + _instance = this; + DontDestroyOnLoad(gameObject); + + CurrentLanguage = DefaultLanguage; + BuildLocalizationCache(); + } + + #region 语言切换 + + /// + /// 切换语言 + /// + public void ChangeLanguage(LanguageType language) + { + if (CurrentLanguage == language) + return; + + CurrentLanguage = language; + OnLanguageChanged?.Invoke(language); + + Debug.Log($"[LocalizationManager] 语言切换为: {language}"); + } + + /// + /// 获取下一个语言 + /// + public void SwitchToNextLanguage() + { + var languages = System.Enum.GetValues(typeof(LanguageType)); + int currentIndex = (int)CurrentLanguage; + int nextIndex = (currentIndex + 1) % languages.Length; + ChangeLanguage((LanguageType)nextIndex); + } + + #endregion + + #region 对话文本 + + /// + /// 获取对话文本 + /// + public string GetDialogText(int dialogId) + { + return GetDialogText(dialogId, CurrentLanguage); + } + + /// + /// 获取指定语言的对话文本 + /// + public string GetDialogText(int dialogId, LanguageType language) + { + if (_dialogTexts.TryGetValue(dialogId, out var langDict)) + { + if (langDict.TryGetValue(language, out var text)) + { + return text; + } + + // 回退到默认语言 + if (langDict.TryGetValue(DefaultLanguage, out var defaultText)) + { + return defaultText; + } + } + + // 返回空文本提示 + return $"[Missing:{dialogId}]"; + } + + /// + /// 检查对话是否有翻译 + /// + public bool HasTranslation(int dialogId, LanguageType language) + { + return _dialogTexts.TryGetValue(dialogId, out var langDict) && + langDict.ContainsKey(language); + } + + /// + /// 检查对话是否缺失翻译 + /// + public bool IsMissingTranslation(int dialogId) + { + return !HasTranslation(dialogId, CurrentLanguage); + } + + #endregion + + #region 通用文本 + + /// + /// 获取通用文本 + /// + public string GetText(string key) + { + return GetText(key, CurrentLanguage); + } + + /// + /// 获取指定语言的通用文本 + /// + public string GetText(string key, LanguageType language) + { + if (_generalTexts.TryGetValue(key, out var langDict)) + { + if (langDict.TryGetValue(language, out var text)) + { + return text; + } + + // 回退到默认语言 + if (langDict.TryGetValue(DefaultLanguage, out var defaultText)) + { + return defaultText; + } + } + + return $"[Missing:{key}]"; + } + + /// + /// 添加通用文本 + /// + public void AddText(string key, LanguageType language, string text) + { + if (!_generalTexts.ContainsKey(key)) + { + _generalTexts[key] = new Dictionary(); + } + + _generalTexts[key][language] = text; + } + + #endregion + + #region 数据加载 + + /// + /// 加载CSV本地化文件 + /// + public void LoadLocalizationCSV(string csvContent) + { + var lines = csvContent.Split('\n'); + if (lines.Length < 2) return; + + // 解析表头 + var headers = lines[0].Split(','); + var languageIndices = new Dictionary(); + + for (int i = 1; i < headers.Length; i++) + { + if (System.Enum.TryParse(headers[i].Trim(), out var lang)) + { + languageIndices[i] = lang; + } + } + + // 解析数据 + for (int i = 1; i < lines.Length; i++) + { + var fields = lines[i].Split(','); + if (fields.Length < 2) continue; + + string key = fields[0].Trim(); + + for (int j = 1; j < fields.Length && j < headers.Length; j++) + { + if (languageIndices.TryGetValue(j, out var lang)) + { + AddText(key, lang, fields[j].Trim()); + } + } + } + + Debug.Log($"[LocalizationManager] 加载CSV完成,共{lines.Length - 1}条文本"); + } + + /// + /// 从DialogInfoDatabase构建本地化缓存 + /// + public void BuildFromDialogDatabase(DialogInfoDatabase database) + { + _dialogTexts.Clear(); + + if (database == null) return; + + foreach (var config in database.Configs) + { + foreach (var dialog in config.Dialogs) + { + if (!_dialogTexts.ContainsKey(dialog.ID)) + { + _dialogTexts[dialog.ID] = new Dictionary(); + } + + // 主文本作为默认语言 + _dialogTexts[dialog.ID][DefaultLanguage] = dialog.Text; + + // 从多语言字段读取 + if (!string.IsNullOrEmpty(dialog.Text_EN)) + _dialogTexts[dialog.ID][LanguageType.English] = dialog.Text_EN; + if (!string.IsNullOrEmpty(dialog.Text_JP)) + _dialogTexts[dialog.ID][LanguageType.Japanese] = dialog.Text_JP; + if (!string.IsNullOrEmpty(dialog.Text_KR)) + _dialogTexts[dialog.ID][LanguageType.Korean] = dialog.Text_KR; + if (!string.IsNullOrEmpty(dialog.Text_FR)) + _dialogTexts[dialog.ID][LanguageType.French] = dialog.Text_FR; + if (!string.IsNullOrEmpty(dialog.Text_DE)) + _dialogTexts[dialog.ID][LanguageType.German] = dialog.Text_DE; + if (!string.IsNullOrEmpty(dialog.Text_ES)) + _dialogTexts[dialog.ID][LanguageType.Spanish] = dialog.Text_ES; + } + } + + Debug.Log($"[LocalizationManager] 从数据库构建完成,共{_dialogTexts.Count}条对话"); + } + + /// + /// 构建本地化缓存 + /// + private void BuildLocalizationCache() + { + _dialogTexts.Clear(); + + foreach (var data in DialogLocalizations) + { + if (!_dialogTexts.ContainsKey(data.DialogId)) + { + _dialogTexts[data.DialogId] = new Dictionary(); + } + + _dialogTexts[data.DialogId][data.Language] = data.Text; + } + } + + #endregion + + #region 工具方法 + + /// + /// 获取缺失翻译的列表 + /// + public List GetMissingTranslations(LanguageType targetLanguage) + { + var missing = new List(); + + foreach (var kvp in _dialogTexts) + { + if (!kvp.Value.ContainsKey(targetLanguage)) + { + missing.Add(kvp.Key); + } + } + + return missing; + } + + /// + /// 导出待翻译文本 + /// + public string ExportForTranslation(LanguageType targetLanguage) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine("ID,SourceText,TargetText,Notes"); + + foreach (var kvp in _dialogTexts) + { + int dialogId = kvp.Key; + string sourceText = kvp.Value.ContainsKey(DefaultLanguage) ? + kvp.Value[DefaultLanguage] : ""; + string targetText = kvp.Value.ContainsKey(targetLanguage) ? + kvp.Value[targetLanguage] : ""; + + sb.AppendLine($"{dialogId},\"{sourceText}\",\"{targetText}\","); + } + + return sb.ToString(); + } + + /// + /// 获取语言名称 + /// + public string GetLanguageName(LanguageType language) + { + return language switch + { + LanguageType.Chinese => "中文", + LanguageType.English => "English", + LanguageType.Japanese => "日本語", + LanguageType.Korean => "한국어", + LanguageType.French => "Français", + LanguageType.German => "Deutsch", + LanguageType.Spanish => "Español", + _ => language.ToString() + }; + } + + #endregion + } + + /// + /// 对话本地化数据 + /// + [System.Serializable] + public class DialogLocalizationData + { + public int DialogId; + public LanguageType Language; + public string Text; + public string Notes; + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/LocalizationManager.cs.meta b/Assets/BP_Scripts/GameplayEditor/Runtime/LocalizationManager.cs.meta new file mode 100644 index 0000000..6a27cc6 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/LocalizationManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a5571f8b0081e364fb063a6e871fee16 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/NPCManagerImpl.cs b/Assets/BP_Scripts/GameplayEditor/Runtime/NPCManagerImpl.cs new file mode 100644 index 0000000..9137c6e --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/NPCManagerImpl.cs @@ -0,0 +1,425 @@ +using System.Collections.Generic; +using GameplayEditor.Core; +using UnityEngine; +using UnityEngine.AI; + +namespace GameplayEditor.Runtime +{ + /// + /// NPC管理器实际实现 + /// 管理NPC的生成、销毁、动画和状态 + /// + public class NPCManagerImpl : MonoBehaviour, INPCManager + { + #region INPCManager 接口实现 + + public string ManagerName => "NPCManager"; + public bool IsInitialized { get; private set; } + + #endregion + + [Header("NPC配置")] + [Tooltip("NPC预制体配置表")] + public List NPCPrefabs = new List(); + + [Tooltip("NPC容器")] + public Transform NPCContainer; + + [Tooltip("默认NPC预制体")] + public GameObject DefaultNPCPrefab; + + [Header("生成设置")] + [Tooltip("生成时是否随机偏移")] + public bool RandomOffset = false; + + [Tooltip("随机偏移范围")] + public float RandomOffsetRange = 0.5f; + + [Tooltip("使用NavMesh放置")] + public bool UseNavMeshPlacement = true; + + // NPC实例管理 + private Dictionary _npcs = new Dictionary(); + + // NPC预制体配置 + [System.Serializable] + public class NPCPrefabConfig + { + [Tooltip("NPC配置ID")] + public string NPCId; + + [Tooltip("NPC预制体")] + public GameObject Prefab; + + [Tooltip("默认阵营")] + public int DefaultCamp = 1; + + [Tooltip("默认血量")] + public float DefaultHealth = 100f; + } + + // NPC实例数据 + private class NPCInstance + { + public string NPCId; + public GameObject GameObject; + public Animator Animator; + public NavMeshAgent NavAgent; + public float Health; + public int CampId; + public Dictionary BlackboardData = new Dictionary(); + } + + private void Awake() + { + if (GameplayManagerHub.Instance != null) + { + GameplayManagerHub.Instance.RegisterManager(this); + } + } + + #region IGameplayManager 接口 + + public void Initialize() + { + if (NPCContainer == null) + { + NPCContainer = transform; + } + + IsInitialized = true; + Debug.Log("[NPCManager] 初始化完成"); + } + + public void Shutdown() + { + // 销毁所有NPC + var ids = new List(_npcs.Keys); + foreach (var id in ids) + { + DespawnNPC(id); + } + + IsInitialized = false; + } + + #endregion + + #region INPCManager 接口实现 + + /// + /// 生成NPC + /// + public GameObject SpawnNPC(string npcId, Vector3 position, Quaternion rotation) + { + // 如果已存在,先销毁 + if (_npcs.ContainsKey(npcId)) + { + DespawnNPC(npcId); + } + + // 获取预制体配置 + var config = GetNPCConfig(npcId); + GameObject prefab = config?.Prefab ?? DefaultNPCPrefab; + + if (prefab == null) + { + Debug.LogError($"[NPCManager] 无法生成NPC {npcId},没有可用的预制体"); + return null; + } + + // 计算最终位置 + Vector3 finalPosition = position; + if (RandomOffset) + { + finalPosition += new Vector3( + Random.Range(-RandomOffsetRange, RandomOffsetRange), + 0, + Random.Range(-RandomOffsetRange, RandomOffsetRange) + ); + } + + // NavMesh放置 + if (UseNavMeshPlacement && NavMesh.SamplePosition(finalPosition, out NavMeshHit hit, 5f, NavMesh.AllAreas)) + { + finalPosition = hit.position; + } + + // 实例化 + GameObject npcGo = Instantiate(prefab, finalPosition, rotation, NPCContainer); + npcGo.name = $"NPC_{npcId}"; + + // 创建实例数据 + var instance = new NPCInstance + { + NPCId = npcId, + GameObject = npcGo, + Animator = npcGo.GetComponent(), + NavAgent = npcGo.GetComponent(), + Health = config?.DefaultHealth ?? 100f, + CampId = config?.DefaultCamp ?? 0 + }; + + _npcs[npcId] = instance; + + // 触发出生事件 + OnNPCSpawned(instance); + + Debug.Log($"[NPCManager] 生成NPC: {npcId} 在 {finalPosition}"); + return npcGo; + } + + /// + /// 销毁NPC + /// + public void DespawnNPC(string npcId) + { + if (!_npcs.TryGetValue(npcId, out var instance)) + return; + + // 触发销毁事件 + OnNPCDespawned(instance); + + // 销毁GameObject + if (instance.GameObject != null) + { + Destroy(instance.GameObject); + } + + _npcs.Remove(npcId); + + Debug.Log($"[NPCManager] 销毁NPC: {npcId}"); + } + + /// + /// 获取NPC + /// + public GameObject GetNPC(string npcId) + { + if (_npcs.TryGetValue(npcId, out var instance)) + { + return instance.GameObject; + } + return null; + } + + /// + /// 获取NPC位置 + /// + public Vector3 GetNPCPosition(string npcId) + { + if (_npcs.TryGetValue(npcId, out var instance) && instance.GameObject != null) + { + return instance.GameObject.transform.position; + } + return Vector3.zero; + } + + /// + /// 播放动画 + /// + public void PlayAnimation(string npcId, string animName) + { + if (!_npcs.TryGetValue(npcId, out var instance)) + { + Debug.LogWarning($"[NPCManager] NPC不存在: {npcId}"); + return; + } + + if (instance.Animator == null) + { + Debug.LogWarning($"[NPCManager] NPC没有Animator: {npcId}"); + return; + } + + // 播放动画 + instance.Animator.Play(animName); + + Debug.Log($"[NPCManager] NPC {npcId} 播放动画: {animName}"); + } + + #endregion + + #region 扩展功能 + + /// + /// 移动NPC到指定位置 + /// + public void MoveNPCTo(string npcId, Vector3 destination) + { + if (!_npcs.TryGetValue(npcId, out var instance)) + return; + + if (instance.NavAgent != null) + { + instance.NavAgent.SetDestination(destination); + } + else + { + // 直接移动 + StartCoroutine(MoveNPCToCoroutine(instance, destination)); + } + } + + private System.Collections.IEnumerator MoveNPCToCoroutine(NPCInstance instance, Vector3 destination) + { + float speed = 3f; + + while (instance.GameObject != null && + Vector3.Distance(instance.GameObject.transform.position, destination) > 0.1f) + { + instance.GameObject.transform.position = Vector3.MoveTowards( + instance.GameObject.transform.position, + destination, + speed * Time.deltaTime + ); + yield return null; + } + } + + /// + /// 设置NPC朝向 + /// + public void SetNPCRotation(string npcId, Vector3 eulerAngles) + { + if (!_npcs.TryGetValue(npcId, out var instance) || instance.GameObject == null) + return; + + instance.GameObject.transform.rotation = Quaternion.Euler(eulerAngles); + } + + /// + /// 获取NPC血量百分比 + /// + public float GetNPCHealthPercent(string npcId) + { + if (!_npcs.TryGetValue(npcId, out var instance)) + return 0f; + + var config = GetNPCConfig(npcId); + float maxHealth = config?.DefaultHealth ?? 100f; + + return instance.Health / maxHealth; + } + + /// + /// 设置NPC血量 + /// + public void SetNPCHealth(string npcId, float health) + { + if (!_npcs.TryGetValue(npcId, out var instance)) + return; + + instance.Health = Mathf.Max(0, health); + + if (instance.Health <= 0) + { + OnNPCDeath(instance); + } + } + + /// + /// 获取NPC阵营 + /// + public int GetNPCCamp(string npcId) + { + if (_npcs.TryGetValue(npcId, out var instance)) + { + return instance.CampId; + } + return 0; + } + + /// + /// 设置NPC阵营 + /// + public void SetNPCCamp(string npcId, int campId) + { + if (_npcs.TryGetValue(npcId, out var instance)) + { + instance.CampId = campId; + } + } + + /// + /// 获取所有NPC ID + /// + public List GetAllNPCIds() + { + return new List(_npcs.Keys); + } + + /// + /// 检查NPC是否存在 + /// + public bool NPCExists(string npcId) + { + return _npcs.ContainsKey(npcId) && _npcs[npcId].GameObject != null; + } + + /// + /// 获取NPC与目标的距离 + /// + public float GetDistanceToTarget(string npcId, string targetId) + { + var npcPos = GetNPCPosition(npcId); + var targetPos = GetNPCPosition(targetId); + + return Vector3.Distance(npcPos, targetPos); + } + + #endregion + + #region 私有方法 + + /// + /// 获取NPC配置 + /// + private NPCPrefabConfig GetNPCConfig(string npcId) + { + return NPCPrefabs.Find(config => config.NPCId == npcId); + } + + /// + /// NPC生成回调 + /// + private void OnNPCSpawned(NPCInstance instance) + { + // 触发事件,可被其他系统监听 + Debug.Log($"[NPCManager] NPC生成事件: {instance.NPCId}"); + } + + /// + /// NPC销毁回调 + /// + private void OnNPCDespawned(NPCInstance instance) + { + Debug.Log($"[NPCManager] NPC销毁事件: {instance.NPCId}"); + } + + /// + /// NPC死亡回调 + /// + private void OnNPCDeath(NPCInstance instance) + { + Debug.Log($"[NPCManager] NPC死亡: {instance.NPCId}"); + + // 播放死亡动画 + if (instance.Animator != null) + { + instance.Animator.Play("Death"); + } + + // 延迟销毁 + StartCoroutine(DelayedDespawn(instance.NPCId, 2f)); + } + + private System.Collections.IEnumerator DelayedDespawn(string npcId, float delay) + { + yield return new WaitForSeconds(delay); + DespawnNPC(npcId); + } + + #endregion + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/NPCManagerImpl.cs.meta b/Assets/BP_Scripts/GameplayEditor/Runtime/NPCManagerImpl.cs.meta new file mode 100644 index 0000000..096962e --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/NPCManagerImpl.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b3fe189b0a71075469b449a626f156e1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/SimplePlayerController.cs b/Assets/BP_Scripts/GameplayEditor/Runtime/SimplePlayerController.cs new file mode 100644 index 0000000..446436e --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/SimplePlayerController.cs @@ -0,0 +1,124 @@ +using UnityEngine; + +namespace GameplayEditor.Runtime +{ + /// + /// 简单的玩家控制器 + /// 只负责移动和输入采集,所有交互通过 FlowCanvas 事件路由 + /// + public class SimplePlayerController : MonoBehaviour + { + [Header("移动设置")] + public float MoveSpeed = 5f; + public float RotationSpeed = 10f; + + [Header("交互设置")] + public KeyCode InteractKey = KeyCode.F; + public KeyCode DialogKey = KeyCode.Space; + public float InteractRadius = 2f; + + [Header("摄像机")] + public Camera MainCamera; + + private CharacterController _charController; + private Vector3 _velocity; + private FlowTriggerBridge _flowBridge; + + void Start() + { + _charController = GetComponent(); + if (MainCamera == null) + MainCamera = Camera.main; + + _flowBridge = FindObjectOfType(); + + Debug.Log("[SimplePlayerController] 玩家控制已启用: WASD移动, F交互, Space对话"); + } + + void Update() + { + HandleMovement(); + HandleInteraction(); + } + + void HandleMovement() + { + float h = Input.GetAxis("Horizontal"); + float v = Input.GetAxis("Vertical"); + + if (MainCamera != null) + { + Vector3 camForward = Vector3.Scale(MainCamera.transform.forward, new Vector3(1, 0, 1)).normalized; + Vector3 move = v * camForward + h * MainCamera.transform.right; + + if (move.magnitude > 0.01f) + { + move.Normalize(); + + if (_charController != null) + _charController.Move(move * MoveSpeed * Time.deltaTime); + else + transform.position += move * MoveSpeed * Time.deltaTime; + + Quaternion targetRot = Quaternion.LookRotation(move); + transform.rotation = Quaternion.Slerp(transform.rotation, targetRot, RotationSpeed * Time.deltaTime); + } + } + + // 重力 + if (_charController != null) + { + _velocity.y += Physics.gravity.y * Time.deltaTime; + _charController.Move(_velocity * Time.deltaTime); + + if (_charController.isGrounded) + _velocity.y = 0; + } + } + + void HandleInteraction() + { + if (_flowBridge == null) + _flowBridge = FindObjectOfType(); + + // F键 → 通过 FlowCanvas 事件路由 + if (Input.GetKeyDown(InteractKey)) + { + Debug.Log("[Player] 按下F键触发交互"); + _flowBridge?.TriggerFlowEvent("OnPlayerInteract", "Interact"); + } + + // Space → 通过 FlowCanvas 事件路由 + if (Input.GetKeyDown(DialogKey)) + { + Debug.Log("[Player] 按下Space触发对话"); + _flowBridge?.TriggerFlowEvent("OnPlayerInteract", "Dialog"); + } + } + + // 碰撞检测仍由 InteractionZone 的 OnTriggerEnter/Exit 处理 + void OnTriggerEnter(Collider other) + { + var zone = other.GetComponent(); + if (zone != null) + { + zone.OnPlayerEnter(this); + } + } + + void OnTriggerExit(Collider other) + { + var zone = other.GetComponent(); + if (zone != null) + { + zone.OnPlayerExit(this); + } + } + + void OnDrawGizmosSelected() + { + Gizmos.color = Color.cyan; + Gizmos.DrawWireSphere(transform.position, InteractRadius); + } + } +} diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/SimplePlayerController.cs.meta b/Assets/BP_Scripts/GameplayEditor/Runtime/SimplePlayerController.cs.meta new file mode 100644 index 0000000..10f0cd9 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/SimplePlayerController.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c326f364fa89f50469a47083c13415da +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/BP_Scripts/GameplayEditor/Runtime/VerificationUIController.cs b/Assets/BP_Scripts/GameplayEditor/Runtime/VerificationUIController.cs new file mode 100644 index 0000000..6586262 --- /dev/null +++ b/Assets/BP_Scripts/GameplayEditor/Runtime/VerificationUIController.cs @@ -0,0 +1,450 @@ +using System.Collections.Generic; +using GameplayEditor.Nodes.Actions; +using NodeCanvas.Framework; +using UnityEngine; +using UnityEngine.UI; + +namespace GameplayEditor.Runtime +{ + /// + /// 验证 UGUI 面板控制器 + /// 替代原来的 OnGUI 验证面板,通过黑板轮询 + FlowCanvas事件 更新 UI + /// + public class VerificationUIController : MonoBehaviour + { + [Header("面板设置")] + public bool ShowPanel = true; + public Vector2 PanelPosition = new Vector2(20, 20); + public Vector2 PanelSize = new Vector2(420, 520); + public float PollInterval = 0.5f; + + // 外部引用(由 Runner 设置) + [HideInInspector] public IBlackboard Blackboard; + [HideInInspector] public FlowTriggerBridge FlowBridge; + + // UGUI 引用 + private Canvas _canvas; + private GameObject _panelGo; + private Text _titleText; + private Text _summaryText; + private Image _progressFill; + private Dictionary _itemUIs = new Dictionary(); + private float _lastPollTime; + + private class VerifyItemUI + { + public Image Dot; + public Text NameText; + public Text DetailText; + } + + void Start() + { + if (ShowPanel) + CreatePanel(); + } + + void Update() + { + if (!ShowPanel) return; + + // 如果黑板为空,尝试获取 + if (Blackboard == null) + { + TryGetBlackboard(); + } + + if (Blackboard == null) return; + + if (Time.time - _lastPollTime >= PollInterval) + { + _lastPollTime = Time.time; + RefreshAllItems(); + } + } + + /// + /// 尝试从 Runner 获取黑板 + /// + private void TryGetBlackboard() + { + var runner = GetComponent(); + if (runner == null) + { + runner = FindObjectOfType(); + } + + if (runner != null) + { + // 使用反射获取私有字段 _blackboard + var field = typeof(GameplayVerificationRunner).GetField("_blackboard", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (field != null) + { + Blackboard = field.GetValue(runner) as IBlackboard; + if (Blackboard != null) + { + Debug.Log("[VerificationUI] 成功获取黑板引用"); + } + } + } + } + + void OnDestroy() + { + DestroyPanel(); + } + + #region 面板创建 + + public void CreatePanel() + { + if (_canvas != null) return; + + // Canvas + var canvasGo = new GameObject("VerificationCanvas"); + canvasGo.transform.SetParent(transform); + _canvas = canvasGo.AddComponent(); + _canvas.renderMode = RenderMode.ScreenSpaceOverlay; + _canvas.sortingOrder = 200; + var scaler = canvasGo.AddComponent(); + scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; + scaler.referenceResolution = new Vector2(1920, 1080); + canvasGo.AddComponent(); + + // Panel + _panelGo = CreateUIObject("VerificationPanel", canvasGo.transform); + var panelRect = _panelGo.GetComponent(); + panelRect.anchorMin = new Vector2(0, 1); + panelRect.anchorMax = new Vector2(0, 1); + panelRect.pivot = new Vector2(0, 1); + panelRect.anchoredPosition = new Vector2(PanelPosition.x, -PanelPosition.y); + panelRect.sizeDelta = PanelSize; + var panelBg = _panelGo.AddComponent(); + panelBg.color = new Color(0.08f, 0.08f, 0.12f, 0.92f); + + // VerticalLayoutGroup on panel + var vlg = _panelGo.AddComponent(); + vlg.padding = new RectOffset(10, 10, 8, 8); + vlg.spacing = 3; + vlg.childControlHeight = false; + vlg.childControlWidth = true; + vlg.childForceExpandWidth = true; + vlg.childForceExpandHeight = false; + + // Title + _titleText = CreateText(_panelGo.transform, "GameplayEditor 功能验证面板", 16, Color.cyan, FontStyle.Bold, 28); + + // Summary + _summaryText = CreateText(_panelGo.transform, "通过: 0 / 19 | 检测中: 0 | 待开始: 19", 13, Color.white, FontStyle.Normal, 20); + + // Progress bar + var progressBg = CreateUIObject("ProgressBg", _panelGo.transform); + var progressBgRect = progressBg.GetComponent(); + progressBgRect.sizeDelta = new Vector2(0, 14); + var bgImg = progressBg.AddComponent(); + bgImg.color = new Color(0.2f, 0.2f, 0.2f, 1f); + + var fillGo = CreateUIObject("ProgressFill", progressBg.transform); + var fillRect = fillGo.GetComponent(); + fillRect.anchorMin = Vector2.zero; + fillRect.anchorMax = new Vector2(0, 1); + fillRect.offsetMin = Vector2.zero; + fillRect.offsetMax = Vector2.zero; + _progressFill = fillGo.AddComponent(); + _progressFill.color = new Color(0.2f, 0.8f, 0.2f, 1f); + + // Scroll view area + var scrollGo = CreateUIObject("ScrollArea", _panelGo.transform); + var scrollRect = scrollGo.GetComponent(); + scrollRect.sizeDelta = new Vector2(0, PanelSize.y - 140); + var scrollLE = scrollGo.AddComponent(); + scrollLE.flexibleHeight = 1; + + var scrollView = scrollGo.AddComponent(); + scrollView.horizontal = false; + scrollView.vertical = true; + scrollView.movementType = ScrollRect.MovementType.Clamped; + + var viewport = CreateUIObject("Viewport", scrollGo.transform); + var viewportRect = viewport.GetComponent(); + viewportRect.anchorMin = Vector2.zero; + viewportRect.anchorMax = Vector2.one; + viewportRect.offsetMin = Vector2.zero; + viewportRect.offsetMax = Vector2.zero; + var viewportMask = viewport.AddComponent(); + viewportMask.showMaskGraphic = false; + var viewportImg = viewport.AddComponent(); + viewportImg.color = Color.white; + scrollView.viewport = viewportRect; + + var content = CreateUIObject("Content", viewport.transform); + var contentRect = content.GetComponent(); + contentRect.anchorMin = new Vector2(0, 1); + contentRect.anchorMax = new Vector2(1, 1); + contentRect.pivot = new Vector2(0.5f, 1); + contentRect.offsetMin = Vector2.zero; + contentRect.offsetMax = Vector2.zero; + var contentVlg = content.AddComponent(); + contentVlg.spacing = 2; + contentVlg.childControlHeight = false; + contentVlg.childControlWidth = true; + contentVlg.childForceExpandWidth = true; + contentVlg.childForceExpandHeight = false; + contentVlg.padding = new RectOffset(2, 2, 2, 2); + var csf = content.AddComponent(); + csf.verticalFit = ContentSizeFitter.FitMode.PreferredSize; + scrollView.content = contentRect; + + // Create 19 item rows + for (int i = 0; i < VerificationCheckIDs.All.Length; i++) + { + CreateItemRow(content.transform, VerificationCheckIDs.All[i], VerificationCheckIDs.DisplayNames[i]); + } + + // Button row + var btnRow = CreateUIObject("ButtonRow", _panelGo.transform); + var btnRowRect = btnRow.GetComponent(); + btnRowRect.sizeDelta = new Vector2(0, 30); + var btnHlg = btnRow.AddComponent(); + btnHlg.spacing = 10; + btnHlg.childControlHeight = true; + btnHlg.childControlWidth = true; + btnHlg.childForceExpandWidth = true; + + CreateButton(btnRow.transform, "重新验证", () => OnRerunClicked()); + CreateButton(btnRow.transform, "重置行为树", () => OnResetBTClicked()); + } + + public void DestroyPanel() + { + if (_canvas != null) + { + Destroy(_canvas.gameObject); + _canvas = null; + _panelGo = null; + _itemUIs.Clear(); + } + } + + private void CreateItemRow(Transform parent, string checkID, string displayName) + { + var row = CreateUIObject($"Item_{checkID}", parent); + var rowRect = row.GetComponent(); + rowRect.sizeDelta = new Vector2(0, 20); + var hlg = row.AddComponent(); + hlg.spacing = 4; + hlg.childControlHeight = true; + hlg.childControlWidth = false; + hlg.childForceExpandWidth = false; + hlg.childAlignment = TextAnchor.MiddleLeft; + + // Status dot + var dotGo = CreateUIObject("Dot", row.transform); + var dotImg = dotGo.AddComponent(); + dotImg.color = Color.gray; + var dotLE = dotGo.AddComponent(); + dotLE.preferredWidth = 14; + dotLE.preferredHeight = 14; + + // Name text + var nameText = CreateText(row.transform, displayName, 12, Color.white, FontStyle.Normal, 20); + var nameLE = nameText.gameObject.AddComponent(); + nameLE.preferredWidth = 90; + + // Detail text + var detailText = CreateText(row.transform, "待开始", 11, new Color(0.6f, 0.6f, 0.6f), FontStyle.Normal, 20); + var detailLE = detailText.gameObject.AddComponent(); + detailLE.flexibleWidth = 1; + + _itemUIs[checkID] = new VerifyItemUI + { + Dot = dotImg, + NameText = nameText, + DetailText = detailText + }; + } + + #endregion + + #region UI更新 + + public void RefreshAllItems() + { + if (Blackboard == null) return; + + int passCount = 0; + int runningCount = 0; + + foreach (var checkID in VerificationCheckIDs.All) + { + string resultKey = VerificationCheckIDs.ResultKey(checkID); + string detailKey = VerificationCheckIDs.DetailKey(checkID); + + if (Blackboard.variables.ContainsKey(resultKey) && Blackboard.variables[resultKey].value is bool passed) + { + string detail = ""; + if (Blackboard.variables.ContainsKey(detailKey) && Blackboard.variables[detailKey].value is string d) + detail = d; + + UpdateItem(checkID, passed, detail); + if (passed) passCount++; + else runningCount++; + } + else + { + // 未完成 + if (_itemUIs.TryGetValue(checkID, out var ui)) + { + ui.Dot.color = Color.gray; + ui.DetailText.text = "待开始"; + ui.DetailText.color = new Color(0.6f, 0.6f, 0.6f); + } + } + } + + int total = VerificationCheckIDs.All.Length; + int pending = total - passCount - runningCount; + SetSummary(passCount, total, runningCount, pending); + } + + public void UpdateItem(string checkID, bool passed, string detail) + { + if (!_itemUIs.TryGetValue(checkID, out var ui)) return; + + ui.Dot.color = passed ? Color.green : Color.yellow; + ui.DetailText.text = detail; + ui.DetailText.color = passed ? new Color(0.6f, 0.6f, 0.6f) : Color.white; + } + + public void SetSummary(int passCount, int total, int running = 0, int pending = 0) + { + if (_summaryText != null) + _summaryText.text = $"通过: {passCount} / {total} | 检测中: {running} | 待开始: {pending}"; + + if (_progressFill != null) + { + float progress = total > 0 ? (float)passCount / total : 0; + var rect = _progressFill.rectTransform; + rect.anchorMax = new Vector2(progress, 1); + _progressFill.color = progress >= 1f ? Color.green : new Color(0.2f, 0.8f, 0.2f); + } + } + + /// + /// FlowCanvas事件回调:单项验证结果 + /// 参数格式: "checkID|passed|detail" + /// + public void OnVerifyItemResult(string packed) + { + if (string.IsNullOrEmpty(packed)) return; + var parts = packed.Split('|'); + if (parts.Length < 3) return; + + string checkID = parts[0]; + bool passed = parts[1] == "1" || parts[1].ToLower() == "true"; + string detail = parts[2]; + UpdateItem(checkID, passed, detail); + } + + /// + /// FlowCanvas事件回调:验证完成 + /// + public void OnVerifyComplete(int passCount) + { + int total = VerificationCheckIDs.All.Length; + SetSummary(passCount, total); + Debug.Log($"[VerificationUI] 验证完成: {passCount}/{total}"); + } + + /// + /// FlowCanvas事件回调:验证开始 + /// + public void OnVerifyStart() + { + foreach (var checkID in VerificationCheckIDs.All) + { + if (_itemUIs.TryGetValue(checkID, out var ui)) + { + ui.Dot.color = Color.gray; + ui.DetailText.text = "待开始"; + ui.DetailText.color = new Color(0.6f, 0.6f, 0.6f); + } + } + SetSummary(0, VerificationCheckIDs.All.Length, 0, VerificationCheckIDs.All.Length); + } + + #endregion + + #region 按钮回调 + + private void OnRerunClicked() + { + var runner = GetComponent(); + if (runner != null) + { + runner.RunVerification(); + } + } + + private void OnResetBTClicked() + { + var btc = FindObjectOfType(); + if (btc != null) + { + btc.ResetExecution(); + Debug.Log("[VerificationUI] 行为树已重置"); + } + } + + #endregion + + #region UGUI工具方法 + + private static GameObject CreateUIObject(string name, Transform parent) + { + var go = new GameObject(name); + go.transform.SetParent(parent, false); + go.AddComponent(); + return go; + } + + private static Text CreateText(Transform parent, string content, int fontSize, Color color, FontStyle style, float height) + { + var go = CreateUIObject("Text", parent); + var rect = go.GetComponent(); + rect.sizeDelta = new Vector2(0, height); + + var text = go.AddComponent(); + text.text = content; + text.fontSize = fontSize; + text.color = color; + text.fontStyle = style; + text.font = Resources.GetBuiltinResource("LegacyRuntime.ttf"); + text.horizontalOverflow = HorizontalWrapMode.Overflow; + text.verticalOverflow = VerticalWrapMode.Truncate; + text.alignment = TextAnchor.MiddleLeft; + + return text; + } + + private static void CreateButton(Transform parent, string label, UnityEngine.Events.UnityAction onClick) + { + var go = CreateUIObject(label, parent); + var img = go.AddComponent(); + img.color = new Color(0.25f, 0.25f, 0.3f, 1f); + + var btn = go.AddComponent