From 27b85fd8750ace5dc1c931326e7699189dba6a4f Mon Sep 17 00:00:00 2001 From: "Bob.Song" Date: Mon, 9 Mar 2026 17:50:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .com-unity-codely.json | 9 + Assets/AssetCaches.asset | 15 + Assets/ResRaw/Prefabs/Player/Player.prefab | 12 +- Assets/Scenes/StartUp.unity | 30 +- .../Scripts/Common/Assets/PlayerModelAsset.cs | 4 +- .../Common/Services/Camera/CameraManager.cs | 21 +- .../Scripts/Common/Def/SelectorRodSetting.cs | 10 - .../Common/Def/SelectorRodSetting.cs.meta | 3 - Assets/Scripts/Fishing/Data/FPlayerData.cs | 1429 +++-- .../Scripts/Fishing/Data/LocalDataManager.cs | 94 + .../Fishing/Data/LocalDataManager.cs.meta | 3 + .../Fishing/Data/NetworkDataManager.cs | 60 + .../Fishing/Data/NetworkDataManager.cs.meta | 3 + .../Scripts/Fishing/Data/PlayerDataManager.cs | 56 + .../Fishing/Data/PlayerDataManager.cs.meta | 3 + .../Scripts/Fishing/Data/StateEnterParams.cs | 234 + .../Fishing/Data/StateEnterParams.cs.meta | 3 + Assets/Scripts/Fishing/Fishing.cs | 28 +- Assets/Scripts/Fishing/FishingMap.cs | 7 - Assets/Scripts/Fishing/FishingMap.cs.meta | 3 - Assets/Scripts/Fishing/New.meta | 3 + Assets/Scripts/Fishing/New/Data.meta | 3 + Assets/Scripts/Fishing/New/Data/MapRoom.cs | 63 + .../Scripts/Fishing/New/Data/MapRoom.cs.meta | 3 + Assets/Scripts/Fishing/New/Data/Player.cs | 97 + .../Scripts/Fishing/New/Data/Player.cs.meta | 3 + Assets/Scripts/Fishing/New/Data/PlayerItem.cs | 12 + .../Fishing/New/Data/PlayerItem.cs.meta | 3 + Assets/Scripts/Fishing/New/View.meta | 3 + Assets/Scripts/Fishing/New/View/Mono.meta | 3 + .../Fishing/New/View/Mono/PlayerAnimator.cs | 185 + .../View/Mono}/PlayerAnimator.cs.meta | 0 .../{Player => New/View/Mono}/PlayerArm.cs | 4 +- .../View/Mono}/PlayerArm.cs.meta | 0 .../{Player => New/View/Mono}/PlayerChest.cs | 5 +- .../View/Mono}/PlayerChest.cs.meta | 0 .../Scripts/Fishing/New/View/Mono/PlayerIK.cs | 58 + .../View/Mono}/PlayerIK.cs.meta | 0 .../New/View/Mono/PlayerMonoBehaviour.cs | 22 + .../New/View/Mono/PlayerMonoBehaviour.cs.meta | 3 + .../New/View/Mono/PlayerUnityComponent.cs | 25 + .../View/Mono/PlayerUnityComponent.cs.meta | 3 + .../Fishing/New/View/PlayerInputComponent.cs | 200 + .../New/View/PlayerInputComponent.cs.meta | 3 + .../Fishing/New/View/PlayerViewComponent.cs | 89 + .../New/View/PlayerViewComponent.cs.meta | 3 + .../Scripts/Fishing/Player/FPlayer.Input.cs | 93 - .../Fishing/Player/FPlayer.Input.cs.meta | 3 - Assets/Scripts/Fishing/Player/FPlayer.Move.cs | 146 - .../Fishing/Player/FPlayer.Move.cs.meta | 3 - Assets/Scripts/Fishing/Player/FPlayer.cs | 307 +- Assets/Scripts/Fishing/Player/FPlayerData.cs | 87 - .../Fishing/Player/FPlayerData.cs.meta | 3 - .../Scripts/Fishing/Player/PlayerAnimator.cs | 306 -- Assets/Scripts/Fishing/Player/PlayerIK.cs | 156 - Assets/Scripts/Fishing/Player/States.meta | 3 - .../{States => States~}/PlayerStateBase.cs | 4 +- .../PlayerStateBase.cs.meta | 0 .../{States => States~}/PlayerStateFight.cs | 0 .../PlayerStateFight.cs.meta | 0 .../{States => States~}/PlayerStateFishing.cs | 30 +- .../PlayerStateFishing.cs.meta | 0 .../{States => States~}/PlayerStateIdle.cs | 0 .../PlayerStateIdle.cs.meta | 0 .../{States => States~}/PlayerStatePrepare.cs | 0 .../PlayerStatePrepare.cs.meta | 0 .../{States => States~}/PlayerStateThrow.cs | 0 .../PlayerStateThrow.cs.meta | 0 Assets/Scripts/Fishing/Tackle/FGearBase.cs | 6 +- Assets/Scripts/Fishing/Tackle/FHandItem.cs | 3 +- Assets/Scripts/Fishing/Tackle/FRod.cs | 37 +- Assets/Scripts/UI/Login/LoginPanel.cs | 4 +- .../cn.tuanjie.codely.bridge/CHANGELOG.md | 512 ++ .../CHANGELOG.md.meta | 7 + Packages/cn.tuanjie.codely.bridge/Editor.meta | 8 + .../Editor/AssemblyInfo.cs | 3 + .../Editor/AssemblyInfo.cs.meta | 11 + .../Editor/Helpers.meta | 8 + .../Editor/Helpers/AsyncOperationTracker.cs | 315 ++ .../Helpers/AsyncOperationTracker.cs.meta | 11 + .../Editor/Helpers/BinaryFrameHelper.cs | 157 + .../Editor/Helpers/BinaryFrameHelper.cs.meta | 11 + .../Editor/Helpers/CompilationHelper.cs | 232 + .../Editor/Helpers/CompilationHelper.cs.meta | 11 + .../Editor/Helpers/ExecPath.cs | 274 + .../Editor/Helpers/ExecPath.cs.meta | 11 + .../Editor/Helpers/GameObjectSerializer.cs | 536 ++ .../Helpers/GameObjectSerializer.cs.meta | 11 + .../Editor/Helpers/JsonCommandHelper.cs | 67 + .../Editor/Helpers/JsonCommandHelper.cs.meta | 11 + .../Editor/Helpers/MainThreadHelper.cs | 86 + .../Editor/Helpers/MainThreadHelper.cs.meta | 11 + .../Editor/Helpers/PortManager.cs | 479 ++ .../Editor/Helpers/PortManager.cs.meta | 11 + .../Editor/Helpers/Response.cs | 144 + .../Editor/Helpers/Response.cs.meta | 11 + .../Editor/Helpers/ServerPathResolver.cs | 76 + .../Editor/Helpers/ServerPathResolver.cs.meta | 11 + .../Editor/Helpers/StateComposer.cs | 783 +++ .../Editor/Helpers/StateComposer.cs.meta | 11 + .../Editor/Helpers/StatusHelper.cs | 94 + .../Editor/Helpers/StatusHelper.cs.meta | 11 + .../Editor/Helpers/TcpLog.cs | 33 + .../Editor/Helpers/TcpLog.cs.meta | 13 + .../Editor/Helpers/TelemetryHelper.cs | 224 + .../Editor/Helpers/TelemetryHelper.cs.meta | 11 + .../Editor/Helpers/UnityStateDirtyHook.cs | 301 ++ .../Helpers/UnityStateDirtyHook.cs.meta | 11 + .../Editor/Helpers/Vector3Helper.cs | 25 + .../Editor/Helpers/Vector3Helper.cs.meta | 11 + .../Editor/Helpers/WriteGuard.cs | 196 + .../Editor/Helpers/WriteGuard.cs.meta | 11 + .../Editor/Icons.meta | 8 + .../Editor/Icons/cli.png | Bin 0 -> 664 bytes .../Editor/Icons/cli.png.meta | 117 + .../Editor/Icons/codely.png | Bin 0 -> 2258 bytes .../Editor/Icons/codely.png.meta | 117 + .../Editor/Icons/connect.png | Bin 0 -> 5375 bytes .../Editor/Icons/connect.png.meta | 117 + .../Editor/Icons/connect_hover.png | Bin 0 -> 5768 bytes .../Editor/Icons/connect_hover.png.meta | 117 + .../Editor/Icons/connect_pressed.png | Bin 0 -> 5551 bytes .../Editor/Icons/connect_pressed.png.meta | 117 + .../Editor/Icons/connecting.png | Bin 0 -> 6317 bytes .../Editor/Icons/connecting.png.meta | 117 + .../Editor/Icons/disconnect.png | Bin 0 -> 5677 bytes .../Editor/Icons/disconnect.png.meta | 117 + .../Editor/Icons/disconnect_hover.png | Bin 0 -> 5741 bytes .../Editor/Icons/disconnect_hover.png.meta | 117 + .../Editor/Icons/disconnect_pressed.png | Bin 0 -> 5491 bytes .../Editor/Icons/disconnect_pressed.png.meta | 117 + .../Editor/Icons/jetbrains.png | Bin 0 -> 724 bytes .../Editor/Icons/jetbrains.png.meta | 117 + .../Editor/Icons/title_icon.png | Bin 0 -> 1041 bytes .../Editor/Icons/title_icon.png.meta | 117 + .../Editor/Icons/tuanjie.png | Bin 0 -> 1549 bytes .../Editor/Icons/tuanjie.png.meta | 117 + .../Editor/Icons/unity_editor.png | Bin 0 -> 1176 bytes .../Editor/Icons/unity_editor.png.meta | 117 + .../Editor/Icons/visualstudio.png | Bin 0 -> 855 bytes .../Editor/Icons/visualstudio.png.meta | 117 + .../Editor/Icons/vscode.png | Bin 0 -> 966 bytes .../Editor/Icons/vscode.png.meta | 117 + .../Editor/Serialization.meta | 8 + .../Serialization/UnityTypeConverters.cs | 294 ++ .../Serialization/UnityTypeConverters.cs.meta | 11 + .../Editor/Tools.meta | 8 + .../Tools/CodelyUnityValidationTools.cs | 1686 ++++++ .../Tools/CodelyUnityValidationTools.cs.meta | 11 + .../Editor/Tools/CommandRegistry.cs | 57 + .../Editor/Tools/CommandRegistry.cs.meta | 11 + .../Editor/Tools/ExecuteCSharpScript.cs | 204 + .../Editor/Tools/ExecuteCSharpScript.cs.meta | 11 + .../Editor/Tools/ExecuteCustomTool.cs | 217 + .../Editor/Tools/ExecuteCustomTool.cs.meta | 11 + .../Editor/Tools/ExecuteMenuItem.cs | 130 + .../Editor/Tools/ExecuteMenuItem.cs.meta | 11 + .../Editor/Tools/ManageAsset.cs | 2537 +++++++++ .../Editor/Tools/ManageAsset.cs.meta | 11 + .../Editor/Tools/ManageBake.cs | 740 +++ .../Editor/Tools/ManageBake.cs.meta | 11 + .../Editor/Tools/ManageEditor.cs | 1330 +++++ .../Editor/Tools/ManageEditor.cs.meta | 11 + .../Editor/Tools/ManageGameObject.cs | 4592 +++++++++++++++++ .../Editor/Tools/ManageGameObject.cs.meta | 11 + .../Editor/Tools/ManagePackage.cs | 271 + .../Editor/Tools/ManagePackage.cs.meta | 11 + .../Editor/Tools/ManageScene.cs | 722 +++ .../Editor/Tools/ManageScene.cs.meta | 11 + .../Editor/Tools/ManageScreenshot.cs | 662 +++ .../Editor/Tools/ManageScreenshot.cs.meta | 11 + .../Editor/Tools/ManageScript.cs | 2636 ++++++++++ .../Editor/Tools/ManageScript.cs.meta | 11 + .../Editor/Tools/ManageShader.cs | 545 ++ .../Editor/Tools/ManageShader.cs.meta | 11 + .../Editor/Tools/ManageUIToolkit.cs | 383 ++ .../Editor/Tools/ManageUIToolkit.cs.meta | 11 + .../Editor/Tools/ManageWorkflow.cs | 699 +++ .../Editor/Tools/ManageWorkflow.cs.meta | 11 + .../Editor/Tools/ReadConsole.cs | 879 ++++ .../Editor/Tools/ReadConsole.cs.meta | 11 + .../Tools/_InternalStateDirtyNotifier.cs | 124 + .../Tools/_InternalStateDirtyNotifier.cs.meta | 11 + .../Editor/UnityTcp.Editor.asmdef | 25 + .../Editor/UnityTcp.Editor.asmdef.meta | 7 + .../Editor/UnityTcpBridge.cs | 1059 ++++ .../Editor/UnityTcpBridge.cs.meta | 11 + .../Editor/Windows.meta | 8 + .../Editor/Windows/README_Windows.md | 58 + .../Editor/Windows/README_Windows.md.meta | 7 + .../Editor/Windows/TcpBridgeControlWindow.cs | 470 ++ .../Windows/TcpBridgeControlWindow.cs.meta | 11 + .../Editor/Windows/TcpBridgeMenuItems.cs | 114 + .../Editor/Windows/TcpBridgeMenuItems.cs.meta | 11 + .../Editor/Windows/TcpBridgeStatusWindow.cs | 92 + .../Windows/TcpBridgeStatusWindow.cs.meta | 11 + .../Editor/fonts.meta | 8 + .../Editor/fonts/FiraMono-Regular.ttf | Bin 0 -> 170204 bytes .../Editor/fonts/FiraMono-Regular.ttf.meta | 21 + .../Editor/fonts/Inter.ttc | Bin 0 -> 13172948 bytes .../Editor/fonts/Inter.ttc.meta | 21 + .../cn.tuanjie.codely.bridge/Plugins.meta | 8 + ...icrosoft.CodeAnalysis.CSharp.Scripting.dll | Bin 0 -> 35120 bytes ...oft.CodeAnalysis.CSharp.Scripting.dll.meta | 68 + .../Codely.Microsoft.CodeAnalysis.CSharp.dll | Bin 0 -> 7365896 bytes ...ely.Microsoft.CodeAnalysis.CSharp.dll.meta | 70 + ...odely.Microsoft.CodeAnalysis.Scripting.dll | Bin 0 -> 137488 bytes ....Microsoft.CodeAnalysis.Scripting.dll.meta | 68 + .../Plugins/Codely.Microsoft.CodeAnalysis.dll | Bin 0 -> 5051192 bytes .../Codely.Microsoft.CodeAnalysis.dll.meta | 68 + .../Plugins/Codely.Newtonsoft.Json.dll | Bin 0 -> 705536 bytes .../Plugins/Codely.Newtonsoft.Json.dll.meta | 68 + .../Codely.System.Collections.Immutable.dll | Bin 0 -> 259360 bytes ...dely.System.Collections.Immutable.dll.meta | 68 + .../Codely.System.Reflection.Metadata.dll | Bin 0 -> 511240 bytes ...Codely.System.Reflection.Metadata.dll.meta | 68 + ...System.Runtime.CompilerServices.Unsafe.dll | Bin 0 -> 19224 bytes ...m.Runtime.CompilerServices.Unsafe.dll.meta | 68 + Packages/cn.tuanjie.codely.bridge/README.md | 233 + .../cn.tuanjie.codely.bridge/README.md.meta | 7 + Packages/cn.tuanjie.codely.bridge/Tests.meta | 8 + .../cn.tuanjie.codely.bridge/package.json | 21 + .../package.json.meta | 7 + .../cn.tuanjie.codely.bridge/scripts.meta | 8 + .../scripts/register-version.js | 74 + .../scripts/register-version.js.meta | 7 + Packages/packages-lock.json | 8 + UserSettings/EditorUserSettings.asset | 10 +- 228 files changed, 30829 insertions(+), 1509 deletions(-) create mode 100644 .com-unity-codely.json delete mode 100644 Assets/Scripts/Common/Def/SelectorRodSetting.cs delete mode 100644 Assets/Scripts/Common/Def/SelectorRodSetting.cs.meta create mode 100644 Assets/Scripts/Fishing/Data/LocalDataManager.cs create mode 100644 Assets/Scripts/Fishing/Data/LocalDataManager.cs.meta create mode 100644 Assets/Scripts/Fishing/Data/NetworkDataManager.cs create mode 100644 Assets/Scripts/Fishing/Data/NetworkDataManager.cs.meta create mode 100644 Assets/Scripts/Fishing/Data/PlayerDataManager.cs create mode 100644 Assets/Scripts/Fishing/Data/PlayerDataManager.cs.meta create mode 100644 Assets/Scripts/Fishing/Data/StateEnterParams.cs create mode 100644 Assets/Scripts/Fishing/Data/StateEnterParams.cs.meta delete mode 100644 Assets/Scripts/Fishing/FishingMap.cs delete mode 100644 Assets/Scripts/Fishing/FishingMap.cs.meta create mode 100644 Assets/Scripts/Fishing/New.meta create mode 100644 Assets/Scripts/Fishing/New/Data.meta create mode 100644 Assets/Scripts/Fishing/New/Data/MapRoom.cs create mode 100644 Assets/Scripts/Fishing/New/Data/MapRoom.cs.meta create mode 100644 Assets/Scripts/Fishing/New/Data/Player.cs create mode 100644 Assets/Scripts/Fishing/New/Data/Player.cs.meta create mode 100644 Assets/Scripts/Fishing/New/Data/PlayerItem.cs create mode 100644 Assets/Scripts/Fishing/New/Data/PlayerItem.cs.meta create mode 100644 Assets/Scripts/Fishing/New/View.meta create mode 100644 Assets/Scripts/Fishing/New/View/Mono.meta create mode 100644 Assets/Scripts/Fishing/New/View/Mono/PlayerAnimator.cs rename Assets/Scripts/Fishing/{Player => New/View/Mono}/PlayerAnimator.cs.meta (100%) rename Assets/Scripts/Fishing/{Player => New/View/Mono}/PlayerArm.cs (84%) rename Assets/Scripts/Fishing/{Player => New/View/Mono}/PlayerArm.cs.meta (100%) rename Assets/Scripts/Fishing/{Player => New/View/Mono}/PlayerChest.cs (82%) rename Assets/Scripts/Fishing/{Player => New/View/Mono}/PlayerChest.cs.meta (100%) create mode 100644 Assets/Scripts/Fishing/New/View/Mono/PlayerIK.cs rename Assets/Scripts/Fishing/{Player => New/View/Mono}/PlayerIK.cs.meta (100%) create mode 100644 Assets/Scripts/Fishing/New/View/Mono/PlayerMonoBehaviour.cs create mode 100644 Assets/Scripts/Fishing/New/View/Mono/PlayerMonoBehaviour.cs.meta create mode 100644 Assets/Scripts/Fishing/New/View/Mono/PlayerUnityComponent.cs create mode 100644 Assets/Scripts/Fishing/New/View/Mono/PlayerUnityComponent.cs.meta create mode 100644 Assets/Scripts/Fishing/New/View/PlayerInputComponent.cs create mode 100644 Assets/Scripts/Fishing/New/View/PlayerInputComponent.cs.meta create mode 100644 Assets/Scripts/Fishing/New/View/PlayerViewComponent.cs create mode 100644 Assets/Scripts/Fishing/New/View/PlayerViewComponent.cs.meta delete mode 100644 Assets/Scripts/Fishing/Player/FPlayer.Input.cs delete mode 100644 Assets/Scripts/Fishing/Player/FPlayer.Input.cs.meta delete mode 100644 Assets/Scripts/Fishing/Player/FPlayer.Move.cs delete mode 100644 Assets/Scripts/Fishing/Player/FPlayer.Move.cs.meta delete mode 100644 Assets/Scripts/Fishing/Player/FPlayerData.cs delete mode 100644 Assets/Scripts/Fishing/Player/FPlayerData.cs.meta delete mode 100644 Assets/Scripts/Fishing/Player/PlayerAnimator.cs delete mode 100644 Assets/Scripts/Fishing/Player/PlayerIK.cs delete mode 100644 Assets/Scripts/Fishing/Player/States.meta rename Assets/Scripts/Fishing/Player/{States => States~}/PlayerStateBase.cs (75%) rename Assets/Scripts/Fishing/Player/{States => States~}/PlayerStateBase.cs.meta (100%) rename Assets/Scripts/Fishing/Player/{States => States~}/PlayerStateFight.cs (100%) rename Assets/Scripts/Fishing/Player/{States => States~}/PlayerStateFight.cs.meta (100%) rename Assets/Scripts/Fishing/Player/{States => States~}/PlayerStateFishing.cs (83%) rename Assets/Scripts/Fishing/Player/{States => States~}/PlayerStateFishing.cs.meta (100%) rename Assets/Scripts/Fishing/Player/{States => States~}/PlayerStateIdle.cs (100%) rename Assets/Scripts/Fishing/Player/{States => States~}/PlayerStateIdle.cs.meta (100%) rename Assets/Scripts/Fishing/Player/{States => States~}/PlayerStatePrepare.cs (100%) rename Assets/Scripts/Fishing/Player/{States => States~}/PlayerStatePrepare.cs.meta (100%) rename Assets/Scripts/Fishing/Player/{States => States~}/PlayerStateThrow.cs (100%) rename Assets/Scripts/Fishing/Player/{States => States~}/PlayerStateThrow.cs.meta (100%) create mode 100644 Packages/cn.tuanjie.codely.bridge/CHANGELOG.md create mode 100644 Packages/cn.tuanjie.codely.bridge/CHANGELOG.md.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/AssemblyInfo.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/AssemblyInfo.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/AsyncOperationTracker.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/AsyncOperationTracker.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/BinaryFrameHelper.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/BinaryFrameHelper.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/CompilationHelper.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/CompilationHelper.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/ExecPath.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/ExecPath.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/GameObjectSerializer.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/GameObjectSerializer.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/JsonCommandHelper.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/JsonCommandHelper.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/MainThreadHelper.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/MainThreadHelper.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/PortManager.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/PortManager.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/Response.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/Response.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/ServerPathResolver.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/ServerPathResolver.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/StateComposer.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/StateComposer.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/StatusHelper.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/StatusHelper.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/TcpLog.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/TcpLog.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/TelemetryHelper.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/TelemetryHelper.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/UnityStateDirtyHook.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/UnityStateDirtyHook.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/Vector3Helper.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/Vector3Helper.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/WriteGuard.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Helpers/WriteGuard.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/cli.png create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/cli.png.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/codely.png create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/codely.png.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect.png create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect.png.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect_hover.png create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect_hover.png.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect_pressed.png create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect_pressed.png.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/connecting.png create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/connecting.png.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect.png create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect.png.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect_hover.png create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect_hover.png.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect_pressed.png create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect_pressed.png.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/jetbrains.png create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/jetbrains.png.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/title_icon.png create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/title_icon.png.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/tuanjie.png create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/tuanjie.png.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/unity_editor.png create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/unity_editor.png.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/visualstudio.png create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/visualstudio.png.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/vscode.png create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Icons/vscode.png.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Serialization.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Serialization/UnityTypeConverters.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Serialization/UnityTypeConverters.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/CodelyUnityValidationTools.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/CodelyUnityValidationTools.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/CommandRegistry.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/CommandRegistry.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteCSharpScript.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteCSharpScript.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteCustomTool.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteCustomTool.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteMenuItem.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteMenuItem.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageAsset.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageAsset.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageBake.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageBake.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageEditor.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageEditor.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageGameObject.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageGameObject.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManagePackage.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManagePackage.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScene.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScene.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScreenshot.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScreenshot.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScript.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScript.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageShader.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageShader.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageUIToolkit.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageUIToolkit.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageWorkflow.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageWorkflow.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ReadConsole.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/ReadConsole.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/_InternalStateDirtyNotifier.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Tools/_InternalStateDirtyNotifier.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/UnityTcp.Editor.asmdef create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/UnityTcp.Editor.asmdef.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/UnityTcpBridge.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/UnityTcpBridge.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Windows.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Windows/README_Windows.md create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Windows/README_Windows.md.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Windows/TcpBridgeControlWindow.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Windows/TcpBridgeControlWindow.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Windows/TcpBridgeMenuItems.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Windows/TcpBridgeMenuItems.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Windows/TcpBridgeStatusWindow.cs create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/Windows/TcpBridgeStatusWindow.cs.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/fonts.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/fonts/FiraMono-Regular.ttf create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/fonts/FiraMono-Regular.ttf.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/fonts/Inter.ttc create mode 100644 Packages/cn.tuanjie.codely.bridge/Editor/fonts/Inter.ttc.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Plugins.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Plugins/Codely.Microsoft.CodeAnalysis.CSharp.Scripting.dll create mode 100644 Packages/cn.tuanjie.codely.bridge/Plugins/Codely.Microsoft.CodeAnalysis.CSharp.Scripting.dll.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Plugins/Codely.Microsoft.CodeAnalysis.CSharp.dll create mode 100644 Packages/cn.tuanjie.codely.bridge/Plugins/Codely.Microsoft.CodeAnalysis.CSharp.dll.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Plugins/Codely.Microsoft.CodeAnalysis.Scripting.dll create mode 100644 Packages/cn.tuanjie.codely.bridge/Plugins/Codely.Microsoft.CodeAnalysis.Scripting.dll.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Plugins/Codely.Microsoft.CodeAnalysis.dll create mode 100644 Packages/cn.tuanjie.codely.bridge/Plugins/Codely.Microsoft.CodeAnalysis.dll.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Plugins/Codely.Newtonsoft.Json.dll create mode 100644 Packages/cn.tuanjie.codely.bridge/Plugins/Codely.Newtonsoft.Json.dll.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Plugins/Codely.System.Collections.Immutable.dll create mode 100644 Packages/cn.tuanjie.codely.bridge/Plugins/Codely.System.Collections.Immutable.dll.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Plugins/Codely.System.Reflection.Metadata.dll create mode 100644 Packages/cn.tuanjie.codely.bridge/Plugins/Codely.System.Reflection.Metadata.dll.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Plugins/Codely.System.Runtime.CompilerServices.Unsafe.dll create mode 100644 Packages/cn.tuanjie.codely.bridge/Plugins/Codely.System.Runtime.CompilerServices.Unsafe.dll.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/README.md create mode 100644 Packages/cn.tuanjie.codely.bridge/README.md.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/Tests.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/package.json create mode 100644 Packages/cn.tuanjie.codely.bridge/package.json.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/scripts.meta create mode 100644 Packages/cn.tuanjie.codely.bridge/scripts/register-version.js create mode 100644 Packages/cn.tuanjie.codely.bridge/scripts/register-version.js.meta diff --git a/.com-unity-codely.json b/.com-unity-codely.json new file mode 100644 index 000000000..4de79c0e1 --- /dev/null +++ b/.com-unity-codely.json @@ -0,0 +1,9 @@ +{ + "unity_port": 25916, + "created_date": "2026-02-10T01:48:10.3375388Z", + "project_path": "D:/myself/Fishing2/Assets", + "reloading": false, + "reason": "ready", + "seq": 1, + "last_heartbeat": "2026-03-09T09:50:14.4988583Z" +} \ No newline at end of file diff --git a/Assets/AssetCaches.asset b/Assets/AssetCaches.asset index 3be78ae65..702d3a0a8 100644 --- a/Assets/AssetCaches.asset +++ b/Assets/AssetCaches.asset @@ -18658,6 +18658,21 @@ MonoBehaviour: - {fileID: 102900000, guid: aa3f5467c0c153642ac320466aee0ec1, type: 3} FilterEnum: 0 Filter: '*' + - Path: Assets/ResRaw/Prefabs/Line/LineHand1.prefab + Address: Plyaer/LineHand1 + Type: GameObject + Bundle: main/plyaer.bundle + Tags: + Group: + Name: Plyaer + Enable: 1 + BundleMode: 0 + AddressMode: 2 + Tags: + Collectors: + - {fileID: 102900000, guid: aa3f5467c0c153642ac320466aee0ec1, type: 3} + FilterEnum: 0 + Filter: '*' - Path: Assets/ResRaw/Prefabs/Line/LineSolver.prefab Address: Plyaer/LineSolver Type: GameObject diff --git a/Assets/ResRaw/Prefabs/Player/Player.prefab b/Assets/ResRaw/Prefabs/Player/Player.prefab index b92e36691..3314a71f9 100644 --- a/Assets/ResRaw/Prefabs/Player/Player.prefab +++ b/Assets/ResRaw/Prefabs/Player/Player.prefab @@ -171,7 +171,7 @@ GameObject: - component: {fileID: 4477616030203838514} - component: {fileID: 8101446342893690422} - component: {fileID: 2923025939212586282} - - component: {fileID: 9164732011369635724} + - component: {fileID: 2431960384537678220} m_Layer: 14 m_Name: Player m_TagString: Untagged @@ -342,7 +342,7 @@ MonoBehaviour: _standingDownwardForceScale: 1 _camera: {fileID: 0} cameraParent: {fileID: 6835675132305341997} ---- !u!114 &9164732011369635724 +--- !u!114 &2431960384537678220 MonoBehaviour: m_ObjectHideFlags: 0 m_CorrespondingSourceObject: {fileID: 0} @@ -351,16 +351,16 @@ MonoBehaviour: m_GameObject: {fileID: 8172838236951268422} m_Enabled: 1 m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 625346c970c542bab9f3a36f78720a77, type: 3} + m_Script: {fileID: 11500000, guid: a25908f34e4e4464a922b88337a5b733, type: 3} m_Name: - m_EditorClassIdentifier: Assembly-CSharp::NBF.FPlayer + m_EditorClassIdentifier: Assembly-CSharp::NBF.PlayerUnityComponent Root: {fileID: 8695154010886802211} Eye: {fileID: 5745877877928638952} FppLook: {fileID: 2969532427624891124} IK: {fileID: 1593568502960682634} ModelAsset: {fileID: 0} - Character: {fileID: 0} - FirstPerson: {fileID: 0} + Character: {fileID: 8101446342893690422} + FirstPerson: {fileID: 2923025939212586282} MouseSensitivity: 0.1 invertLook: 1 minPitch: -75 diff --git a/Assets/Scenes/StartUp.unity b/Assets/Scenes/StartUp.unity index e4e7bd081..0fdb9e05c 100644 --- a/Assets/Scenes/StartUp.unity +++ b/Assets/Scenes/StartUp.unity @@ -388,7 +388,7 @@ Transform: m_GameObject: {fileID: 174907465} serializedVersion: 2 m_LocalRotation: {x: -0, y: -0, z: -0, w: 1} - m_LocalPosition: {x: 8, y: -5, z: 0} + m_LocalPosition: {x: 8.888889, y: -5, z: 0} m_LocalScale: {x: 1, y: 1, z: 1} m_ConstrainProportionsScale: 0 m_Children: [] @@ -1629,8 +1629,8 @@ Camera: y: 0 width: 1 height: 1 - near clip plane: 0.01 - far clip plane: 5000 + near clip plane: 0.1 + far clip plane: 3000 field of view: 60.000004 orthographic: 0 orthographic size: 5 @@ -1784,7 +1784,6 @@ GameObject: - component: {fileID: 1341717235351337375} - component: {fileID: 7388915548948935574} - component: {fileID: 7388915548948935578} - - component: {fileID: 7388915548948935575} - component: {fileID: 7388915548948935576} - component: {fileID: 7388915548948935577} m_Layer: 0 @@ -1806,29 +1805,6 @@ MonoBehaviour: m_Script: {fileID: 11500000, guid: 2101c084ab66498bb634f122db410852, type: 3} m_Name: m_EditorClassIdentifier: Assembly-CSharp::NBF.InputManager ---- !u!114 &7388915548948935575 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 7388915548948935573} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 53629a9cec2b4caf9a739c21f7abdf3c, type: 3} - m_Name: - m_EditorClassIdentifier: Assembly-CSharp::NBF.FPlayerData - ChangeItem: 0 - Run: 0 - IsGrounded: 0 - Speed: 0 - RotationSpeed: 0 - ReelSpeed: 0 - LineTension: 0 - IsLureRod: 0 - MoveInput: {x: 0, y: 0} - EyeAngle: 0 - NextState: 0 --- !u!114 &7388915548948935576 MonoBehaviour: m_ObjectHideFlags: 0 diff --git a/Assets/Scripts/Common/Assets/PlayerModelAsset.cs b/Assets/Scripts/Common/Assets/PlayerModelAsset.cs index 5c2c08144..d2ec988fb 100644 --- a/Assets/Scripts/Common/Assets/PlayerModelAsset.cs +++ b/Assets/Scripts/Common/Assets/PlayerModelAsset.cs @@ -44,9 +44,9 @@ namespace NBF PlayerAnimator = GetComponent(); } - public void SetPlayer(FPlayer player) + public void SetPlayer(Transform FppLook) { - LookIk.solver.target = player.FppLook; + LookIk.solver.target = FppLook; } } } \ No newline at end of file diff --git a/Assets/Scripts/Common/Common/Services/Camera/CameraManager.cs b/Assets/Scripts/Common/Common/Services/Camera/CameraManager.cs index 10d56e98b..74f29ef16 100644 --- a/Assets/Scripts/Common/Common/Services/Camera/CameraManager.cs +++ b/Assets/Scripts/Common/Common/Services/Camera/CameraManager.cs @@ -17,6 +17,8 @@ namespace NBF [SerializeField] private CameraAsset _cameraAsset; private CameraShowMode _lastMode = CameraShowMode.None; + private PlayerUnityComponent FollowPlayer; + private void Update() { if (_lastMode == Mode) return; @@ -43,27 +45,26 @@ namespace NBF private void SetFPPCam() { - var player = FPlayer.Instance; - if (player != null) + if (FollowPlayer != null) { - _cameraAsset.fppVCam.LookAt = player.FppLook; - _cameraAsset.fppVCam.Follow = player.ModelAsset.NeckTransform; + _cameraAsset.fppVCam.LookAt = FollowPlayer.FppLook; + _cameraAsset.fppVCam.Follow = FollowPlayer.ModelAsset.NeckTransform; } _cameraAsset.fppVCam.Priority = 10; _cameraAsset.tppVCam.Priority = 0; - // StartCoroutine(SnapToTarget()); } - public void SetFppLook(Transform fppCamLook) + public void SetFppLook(PlayerUnityComponent playerUnityComponent) { - _cameraAsset.fppVCam.LookAt = fppCamLook; + FollowPlayer = playerUnityComponent; + _cameraAsset.fppVCam.LookAt = FollowPlayer.FppLook; + Mode = CameraShowMode.FPP; } - public void SetFppFollow(Transform fppCamFollow) + public void SetFppFollow(PlayerUnityComponent playerUnityComponent) { - _cameraAsset.fppVCam.Follow = fppCamFollow; + _cameraAsset.fppVCam.Follow = FollowPlayer.ModelAsset.NeckTransform; } - } } \ No newline at end of file diff --git a/Assets/Scripts/Common/Def/SelectorRodSetting.cs b/Assets/Scripts/Common/Def/SelectorRodSetting.cs deleted file mode 100644 index c19ddbf49..000000000 --- a/Assets/Scripts/Common/Def/SelectorRodSetting.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace NBF -{ - public enum SelectorRodSetting - { - Speed = 0, - Drag = 1, - Leeder = 2 - } - -} \ No newline at end of file diff --git a/Assets/Scripts/Common/Def/SelectorRodSetting.cs.meta b/Assets/Scripts/Common/Def/SelectorRodSetting.cs.meta deleted file mode 100644 index b6c6746ff..000000000 --- a/Assets/Scripts/Common/Def/SelectorRodSetting.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: ca512e729d2e4ef290f30f075bcdd698 -timeCreated: 1744039906 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Data/FPlayerData.cs b/Assets/Scripts/Fishing/Data/FPlayerData.cs index 5306000f7..b667dd1b1 100644 --- a/Assets/Scripts/Fishing/Data/FPlayerData.cs +++ b/Assets/Scripts/Fishing/Data/FPlayerData.cs @@ -1,414 +1,1015 @@ -using System; -using System.Text; -using cfg; -using UnityEngine; - -namespace NBF -{ - public enum GearType - { - SpinningFloat = 0, - Spinning = 1, - Hand = 2, - Feeder = 3, - Pole = 4 - } - - [Serializable] - public enum PlayerState : uint - { - None = 0, - - /// - /// 闲置等待中 - /// - Idle = 1, - - /// - /// 准备抛竿 - /// - Prepare = 2, - - /// - /// 抛竿中 - /// - Throw = 3, - - /// - /// 钓鱼中 - /// - Fishing = 4, - - /// - /// 溜鱼中 - /// - Fight = 5 - } - - [Serializable] - public class FPlayerData : MonoService - { - public int PlayerID; - - /// - /// 玩家当前装备的钓组 - /// - public FPlayerGearData currentGear; - - /// - /// 玩家位置 - /// - public Vector3 position; - - /// - /// 玩家角度 - /// - public Quaternion rotation; - - public float currentReelingSpeed; - public bool isHandOnHandle; - - /// - /// 线长度 - /// - public float lineLength; - - /// - /// 收线速度 - /// - public float reelSpeed; - - /// - /// 打开手电筒 - /// - public bool openLight; - - /// - /// 打开望远镜 - /// - public bool openTelescope; - - public float EyeAngle; - - public bool Run; - - public bool ChangeItem; - - public bool IsLureRod; - - /// - /// 是否地面 - /// - public bool IsGrounded; - - /// - /// 移动速度 - /// - public float Speed; - - public float RotationSpeed; - - public Vector2 MoveInput; - - public SelectorRodSetting selectorRodSetting = SelectorRodSetting.Speed; - - private PlayerState _previousPlayerState = PlayerState.Idle; - private PlayerState _playerState; - public PlayerState PreviousState => _previousPlayerState; - - public PlayerState State - { - get => _playerState; - set - { - _previousPlayerState = _playerState; - _playerState = value; - NextState = value; - OnStateChange?.Invoke(_playerState); - } - } - - [SerializeField] private PlayerState NextState; - - public event Action OnStateChange; - - - - private void Start() - { - NextState = State; - } - - private void Update() - { - if (NextState != State) - { - State = NextState; - } - } - } - - /// - /// 玩家钓组数据 - /// - [Serializable] - public class FPlayerGearData - { - public GearType Type = GearType.Spinning; - - public FRodData rod; - public FReelData reel; - public FBobberData bobber; - public FHookData hook; - public FBaitData bait; - public FLureData lure; - public FWeightData weight; - public FLineData line; - public FLeaderData leader; - public FFeederData feeder; - - /// - /// 获得唯一id - /// - /// - public int GetUnitId() - { - int result = 0; - if (rod != null) - { - result += rod.configId; - } - - if (reel != null) - { - result += reel.configId; - } - - if (bobber != null) - { - result += bobber.configId; - } - - if (hook != null) - { - result += hook.configId; - } - - if (bait != null) - { - result += bait.configId; - } - - if (lure != null) - { - result += lure.configId; - } - - if (weight != null) - { - result += weight.configId; - } - - if (line != null) - { - result += line.configId; - } - - if (leader != null) - { - result += leader.configId; - } - - if (feeder != null) - { - result += feeder.configId; - } - - return result; - } - - public void SetBobberLastSetGroundValue(float value) - { - bobber.lastSetGroundValue = value; - } - - public void SetReelSettings(int setType, float val, bool saveToPrefs = false) - { - if (setType == 0) - { - reel.currentSpeed = val; - } - - if (setType == 1) - { - reel.currentDrag = val; - } - - if (saveToPrefs) - { - // Instance._playerData.SaveEquipment(ItemType.Reel); - } - } - } - - [Serializable] - public abstract class FGearData - { - /// - /// 唯一id - /// - public int id; - - /// - /// 配置id - /// - public int configId; - - private Item _itemConfig; - - public Item ItemConfig - { - get { return _itemConfig ??= Game.Tables.TbItem.Get(configId); } - } - } - - /// - /// 鱼竿数据 - /// - [Serializable] - public class FRodData : FGearData - { - private Rod _config; - - public Rod Config - { - get { return _config ??= Game.Tables.TbRod.Get(configId); } - } - } - - /// - /// 线轴数据 - /// - [Serializable] - public class FReelData : FGearData - { - private Reel _config; - - public Reel Config - { - get { return _config ??= Game.Tables.TbReel.Get(configId); } - } - - public float currentSpeed = 0.5f; - - public float currentDrag = 0.7f; - } - - /// - /// 浮漂数据 - /// - [Serializable] - public class FBobberData : FGearData - { - private Bobber _config; - - public Bobber Config - { - get { return _config ??= Game.Tables.TbBobber.Get(configId); } - } - - public float lastSetGroundValue; - } - - /// - /// 鱼钩数据 - /// - [Serializable] - public class FHookData : FGearData - { - private Hook _config; - - public Hook Config - { - get { return _config ??= Game.Tables.TbHook.Get(configId); } - } - } - - /// - /// 鱼饵数据 - /// - [Serializable] - public class FBaitData : FGearData - { - private Bait _config; - - public Bait Config - { - get { return _config ??= Game.Tables.TbBait.Get(configId); } - } - } - - /// - /// 鱼饵数据 - /// - [Serializable] - public class FLureData : FGearData - { - private Lure _config; - - public Lure Config - { - get { return _config ??= Game.Tables.TbLure.Get(configId); } - } - } - - /// - /// 沉子数据 - /// - [Serializable] - public class FWeightData : FGearData - { - } - - /// - /// 线数据 - /// - [Serializable] - public class FLineData : FGearData - { - private Line _config; - - public Line Config - { - get { return _config ??= Game.Tables.TbLine.Get(configId); } - } - } - - /// - /// 引线数据 - /// - [Serializable] - public class FLeaderData : FGearData - { - // private Leader _config; - // - // public Leader Config - // { - // get { return _config ??= Game.Tables.TbLeader.Get(configId); } - // } - } - - /// - /// 菲德数据 - /// - [Serializable] - public class FFeederData : FGearData - { - private Feeder _config; - - public Feeder Config - { - get { return _config ??= Game.Tables.TbFeeder.Get(configId); } - } - } -} \ No newline at end of file +// using System; +// using System.Collections.Generic; +// using System.Text; +// using cfg; +// using UnityEngine; +// +// namespace NBF +// { +// /// +// /// 钓组类型 +// /// +// public enum GearType +// { +// SpinningFloat = 0, +// Spinning = 1, +// Hand = 2, +// Feeder = 3, +// Pole = 4 +// } +// +// /// +// /// 装备物品类型枚举(用于快速判断) +// /// +// public enum GearItemType +// { +// None = 0, +// Rod = 1, +// Reel = 2, +// Bobber = 3, +// Hook = 4, +// Bait = 5, +// Lure = 6, +// Weight = 7, +// Line = 8, +// Leader = 9, +// Feeder = 10 +// } +// + // [Serializable] + public enum PlayerState : uint + { + None = 0, + + /// + /// 闲置等待中 + /// + Idle = 1, + + /// + /// 准备抛竿 + /// + Prepare = 2, + + /// + /// 抛竿中 + /// + Throw = 3, + + /// + /// 钓鱼中 + /// + Fishing = 4, + + /// + /// 溜鱼中 + /// + Fight = 5 + } +// +// public enum HeldItemType +// { +// None = 0, +// Gear = 1, // 钓组 +// Tool = 2, // 工具(铲子等) +// Consumable = 3, // 消耗品 +// Special = 4 // 特殊物品 +// } +// +// /// +// /// 手持物品信息 +// /// +// [Serializable] +// public class HeldItemInfo +// { +// public HeldItemType ItemType; // 物品类型 +// public int ConfigID; // 配置 ID(钓组或普通物品) +// +// // 如果是钓组,包含完整钓组数据 +// public FPlayerGearData GearData; +// } +// +// +// [Serializable] +// public class FPlayerData +// { +// // ========== 基础标识 ========== +// public int PlayerID; +// public bool IsLocalPlayer; // 是否为本地玩家 +// public bool IsServer; // 是否由服务器控制 +// +// // ========== 持久化数据(低频同步) ========== +// /// +// /// 所有可用钓组 +// /// +// public List AllGears = new(); +// +// // ========== 当前状态(中高频同步) ========== +// /// +// /// 当前手持的物品 +// /// +// public HeldItemInfo CurrentHeldItem = new(); +// +// /// +// /// 当前装备的钓组(从 CurrentHeldItem 派生,方便访问) +// /// +// public FPlayerGearData CurrentGear => +// CurrentHeldItem.ItemType == HeldItemType.Gear ? CurrentHeldItem.GearData : null; +// +// // ========== 物理状态(高频同步) ========== +// public Vector3 position; +// public Quaternion rotation; +// public Vector2 MoveInput; +// public float Speed; +// public float RotationSpeed; +// public bool IsGrounded; +// public bool Run; +// +// // ========== 钓鱼相关状态(中频同步) ========== +// public float currentReelingSpeed; +// public bool isHandOnHandle; +// public float lineLength; +// public float reelSpeed; +// public float EyeAngle; +// public bool IsLureRod; +// +// // ========== 动作和道具状态(低频同步) ========== +// public bool openLight; +// public bool openTelescope; +// public bool ChangeItem; +// +// // ========== 状态机 ========== +// private PlayerState _previousPlayerState = PlayerState.Idle; +// private PlayerState _playerState; +// public PlayerState PreviousState => _previousPlayerState; +// +// /// +// /// 当前状态的进入参数(本地和远程都适用) +// /// +// public StateEnterParams CurrentStateParams { get; private set; } = new StateEnterParams(); +// +// public PlayerState State +// { +// get => _playerState; +// set +// { +// if (_playerState != value) +// { +// _previousPlayerState = _playerState; +// _playerState = value; +// NextState = value; +// +// // 先触发事件(带参数) +// OnStateChange?.Invoke(_playerState, CurrentStateParams); +// +// // 触发网络同步(如果有) +// if (PlayerDataManager.Instance != null) +// { +// PlayerDataManager.Instance.OnPlayerStateChanged(this, value); +// } +// } +// } +// } +// +// /// +// /// 设置状态并传入参数 +// /// +// public void SetState(PlayerState newState, StateEnterParams enterParams = null) +// { +// // 复制参数到当前状态 +// if (enterParams != null) +// { +// CurrentStateParams = enterParams.Clone(); +// } +// else +// { +// CurrentStateParams.Clear(); +// } +// +// State = newState; +// } +// +// [SerializeField] private PlayerState NextState; +// public event Action OnStateChange; +// +// +// // ========== 网络同步相关 ========== +// /// +// /// 最后接收到的网络快照时间 +// /// +// public float LastNetworkSyncTime { get; set; } +// +// /// +// /// 是否需要网络插值 +// /// +// public bool NeedInterpolation => !IsLocalPlayer && !IsServer; +// +// // ========== 脏标记系统(关键优化) ========== +// private SyncFlags _dirtyFlags = SyncFlags.None; +// +// /// +// /// 标记某个数据块已变化,需要同步 +// /// +// public void MarkDirty(SyncFlags flag) +// { +// _dirtyFlags |= flag; +// } +// +// /// +// /// 检查是否有数据需要同步 +// /// +// public bool HasDirtyData => _dirtyFlags != SyncFlags.None; +// +// /// +// /// 获取并清除脏标记 +// /// +// public SyncFlags GetAndClearDirtyFlags() +// { +// var flags = _dirtyFlags; +// _dirtyFlags = SyncFlags.None; +// return flags; +// } +// +// /// +// /// 清除所有脏标记 +// /// +// public void ClearDirtyFlags() +// { +// _dirtyFlags = SyncFlags.None; +// } +// +// +// // ========== 生命周期 ========== +// private void Start() +// { +// NextState = State; +// InitializeDefaultItems(); +// } +// +// private void Update() +// { +// if (NextState != State) +// { +// State = NextState; +// } +// } +// +// // ========== 物品系统方法 ========== +// +// /// +// /// 切换手持物品 +// /// +// public void SwitchHeldItem(HeldItemInfo newItem) +// { +// CurrentHeldItem = newItem; +// ChangeItem = true; +// +// // 如果切换到钓组,更新 CurrentGear 引用 +// if (newItem.ItemType == HeldItemType.Gear) +// { +// // 确保钓组数据有效 +// if (newItem.GearData == null) +// { +// newItem.GearData = GetGearByConfigID(newItem.ConfigID); +// } +// } +// +// // 通知网络层 +// if (PlayerDataManager.Instance != null) +// { +// PlayerDataManager.Instance.OnHeldItemChanged(this, newItem); +// } +// } +// +// /// +// /// 装备钓组 +// /// +// public void EquipGear(int gearIndex) +// { +// if (gearIndex < 0 || gearIndex >= AllGears.Count) return; +// +// var gear = AllGears[gearIndex]; +// var heldItem = new HeldItemInfo +// { +// ItemType = HeldItemType.Gear, +// ConfigID = gear.GetUnitId(), +// GearData = gear +// }; +// +// SwitchHeldItem(heldItem); +// } +// +// /// +// /// 根据配置 ID 获取钓组 +// /// +// public FPlayerGearData GetGearByConfigID(int configID) +// { +// return AllGears.Find(g => g.GetUnitId() == configID); +// } +// +// /// +// /// 初始化默认物品(示例) +// /// +// private void InitializeDefaultItems() +// { +// // 这里可以加载默认钓组和物品 +// // 实际逻辑应该从存档或服务器加载 +// } +// +// // ========== 网络快照转换 ========== +// +// /// +// /// 转换为网络快照 +// /// +// public PlayerStateSnapshot ToNetworkSnapshot(uint sequenceNumber) +// { +// var snapshot = new PlayerStateSnapshot +// { +// PlayerID = PlayerID, +// SequenceNumber = sequenceNumber, +// Timestamp = Time.time, +// +// Position = position, +// Rotation = rotation, +// MoveInput = MoveInput, +// Speed = Speed, +// IsGrounded = IsGrounded, +// IsRunning = Run, +// +// State = State, +// LineLength = lineLength, +// ReelSpeed = reelSpeed, +// EyeAngle = EyeAngle, +// IsHandOnHandle = isHandOnHandle, +// +// HeldItem = CurrentHeldItem, +// +// // 同步状态参数 +// StateParams = CurrentStateParams, +// +// FloatPosition = null, // 根据需要设置 +// HookPosition = null, +// CaughtFishID = 0 +// }; +// +// // 设置标志位 +// snapshot.SetFlag(0, openLight); +// snapshot.SetFlag(1, openTelescope); +// snapshot.SetFlag(2, ChangeItem); +// snapshot.SetFlag(3, IsLureRod); +// +// return snapshot; +// } +// +// /// +// /// 从网络快照应用状态 +// /// +// public void ApplyFromNetworkSnapshot(PlayerStateSnapshot snapshot) +// { +// if (snapshot.PlayerID != PlayerID) return; +// +// LastNetworkSyncTime = (float)snapshot.Timestamp; +// +// // 保存旧状态用于对比 +// var oldState = State; +// var stateChanged = snapshot.State != oldState; +// +// position = snapshot.Position; +// rotation = snapshot.Rotation; +// MoveInput = snapshot.MoveInput; +// Speed = snapshot.Speed; +// IsGrounded = snapshot.IsGrounded; +// Run = snapshot.IsRunning; +// +// // 如果状态改变,先更新状态和参数 +// if (stateChanged) +// { +// _previousPlayerState = oldState; +// _playerState = snapshot.State; +// NextState = snapshot.State; +// +// // 应用状态参数 +// if (snapshot.StateParams != null) +// { +// CurrentStateParams = snapshot.StateParams.Clone(); +// } +// +// // 触发事件(带参数) +// OnStateChange?.Invoke(_playerState, CurrentStateParams); +// } +// +// lineLength = snapshot.LineLength; +// reelSpeed = snapshot.ReelSpeed; +// EyeAngle = snapshot.EyeAngle; +// isHandOnHandle = snapshot.IsHandOnHandle; +// +// CurrentHeldItem = snapshot.HeldItem; +// +// // 读取标志位 +// openLight = snapshot.GetFlag(0); +// openTelescope = snapshot.GetFlag(1); +// ChangeItem = snapshot.GetFlag(2); +// IsLureRod = snapshot.GetFlag(3); +// } +// } +// +// /// +// /// 同步标志位枚举 +// /// +// [Flags] +// public enum SyncFlags : byte +// { +// None = 0, +// Physics = 1 << 0, // 位置、旋转、移动 +// Fishing = 1 << 1, // 钓鱼相关状态 +// HeldItem = 1 << 2, // 手持物品 +// Flags = 1 << 3, // 布尔标志 +// Extension = 1 << 4, // 扩展数据 +// All = Physics | Fishing | HeldItem | Flags | Extension +// } +// +// /// +// /// 网络同步用的玩家状态快照(精简、可序列化) +// /// +// [Serializable] +// public class PlayerStateSnapshot +// { +// // ========== 元数据 ========== +// public int PlayerID; +// public uint SequenceNumber; // 序列号,处理乱序包 +// public double Timestamp; // 时间戳,用于插值 +// +// // ========== 同步标志位(关键优化) ========== +// /// +// /// 指示哪些数据块需要同步(位掩码) +// /// bit0: 基础物理状态 | bit1: 钓鱼状态 | bit2: 手持物品 | bit3: 布尔标志 | bit4: 扩展数据 +// /// +// public SyncFlags SyncFlags = SyncFlags.All; +// +// // ========== 基础状态(高频同步 - 可选) ========== +// public Vector3 Position; +// public Quaternion Rotation; +// public Vector2 MoveInput; +// public float Speed; +// public bool IsGrounded; +// public bool IsRunning; +// +// // ========== 钓鱼状态(中频同步 - 可选) ========== +// public PlayerState State; +// public float LineLength; +// public float ReelSpeed; +// public float EyeAngle; +// public bool IsHandOnHandle; +// +// // ========== 手持物品信息(低频同步 - 可选) ========== +// public HeldItemInfo HeldItem; +// +// // ========== 状态进入参数(状态变化时同步 - 可选) ========== +// public StateEnterParams StateParams; +// +// // ========== 布尔标志位(用 byte 压缩 - 可选) ========== +// public byte Flags; // bit0:开灯 bit1:望远镜 bit2:换物品 bit3:路亚竿 +// +// // ========== 扩展数据(按需同步 - 可选) ========== +// public Vector3? FloatPosition; // 浮漂位置(nullable) +// public Vector3? HookPosition; // 鱼钩位置 +// public uint CaughtFishID; // 钓到的鱼 ID +// +// // ========== 辅助方法 ========== +// +// public bool GetFlag(int index) => (Flags & (1 << index)) != 0; +// +// public void SetFlag(int index, bool value) +// { +// if (value) Flags |= (byte)(1 << index); +// else Flags &= (byte)~(1 << index); +// } +// +// public bool HasFlag(SyncFlags flag) => (SyncFlags & flag) != 0; +// +// public void SetFlag(SyncFlags flag, bool value) +// { +// if (value) SyncFlags |= flag; +// else SyncFlags &= ~flag; +// } +// +// /// +// /// 创建完整快照(所有字段) +// /// +// public static PlayerStateSnapshot CreateFull(FPlayerData player, uint sequenceNumber) +// { +// var snapshot = new PlayerStateSnapshot +// { +// PlayerID = player.PlayerID, +// SequenceNumber = sequenceNumber, +// Timestamp = Time.time, +// SyncFlags = SyncFlags.All, +// +// Position = player.position, +// Rotation = player.rotation, +// MoveInput = player.MoveInput, +// Speed = player.Speed, +// IsGrounded = player.IsGrounded, +// IsRunning = player.Run, +// +// State = player.State, +// LineLength = player.lineLength, +// ReelSpeed = player.reelSpeed, +// EyeAngle = player.EyeAngle, +// IsHandOnHandle = player.isHandOnHandle, +// +// HeldItem = player.CurrentHeldItem, +// StateParams = player.CurrentStateParams?.Clone(), +// +// FloatPosition = null, +// HookPosition = null, +// CaughtFishID = 0 +// }; +// +// snapshot.SetFlag(0, player.openLight); +// snapshot.SetFlag(1, player.openTelescope); +// snapshot.SetFlag(2, player.ChangeItem); +// snapshot.SetFlag(3, player.IsLureRod); +// +// return snapshot; +// } +// +// /// +// /// 创建增量快照(仅包含标记的字段) +// /// +// public static PlayerStateSnapshot CreateDelta(FPlayerData player, uint sequenceNumber, SyncFlags flags) +// { +// var snapshot = new PlayerStateSnapshot +// { +// PlayerID = player.PlayerID, +// SequenceNumber = sequenceNumber, +// Timestamp = Time.time, +// SyncFlags = flags +// }; +// +// // 根据标志位选择性地填充数据 +// if (flags.HasFlag(SyncFlags.Physics)) +// { +// snapshot.Position = player.position; +// snapshot.Rotation = player.rotation; +// snapshot.MoveInput = player.MoveInput; +// snapshot.Speed = player.Speed; +// snapshot.IsGrounded = player.IsGrounded; +// snapshot.IsRunning = player.Run; +// } +// +// if (flags.HasFlag(SyncFlags.Fishing)) +// { +// snapshot.State = player.State; +// snapshot.LineLength = player.lineLength; +// snapshot.ReelSpeed = player.reelSpeed; +// snapshot.EyeAngle = player.EyeAngle; +// snapshot.IsHandOnHandle = player.isHandOnHandle; +// snapshot.StateParams = player.CurrentStateParams?.Clone(); +// } +// +// if (flags.HasFlag(SyncFlags.HeldItem)) +// { +// snapshot.HeldItem = player.CurrentHeldItem; +// } +// +// if (flags.HasFlag(SyncFlags.Flags)) +// { +// snapshot.Flags = 0; +// snapshot.SetFlag(0, player.openLight); +// snapshot.SetFlag(1, player.openTelescope); +// snapshot.SetFlag(2, player.ChangeItem); +// snapshot.SetFlag(3, player.IsLureRod); +// } +// +// if (flags.HasFlag(SyncFlags.Extension)) +// { +// snapshot.FloatPosition = null; // 根据需要设置 +// snapshot.HookPosition = null; +// snapshot.CaughtFishID = 0; +// } +// +// return snapshot; +// } +// +// /// +// /// 应用快照到玩家(智能合并) +// /// +// public void ApplyTo(FPlayerData player) +// { +// if (PlayerID != player.PlayerID) return; +// +// player.LastNetworkSyncTime = (float)Timestamp; +// +// // 根据标志位选择性应用 +// if (SyncFlags.HasFlag(SyncFlags.Physics)) +// { +// player.position = Position; +// player.rotation = Rotation; +// player.MoveInput = MoveInput; +// player.Speed = Speed; +// player.IsGrounded = IsGrounded; +// player.Run = IsRunning; +// } +// +// if (SyncFlags.HasFlag(SyncFlags.Fishing)) +// { +// // 状态变化时才触发事件 +// if (State != player.State) +// { +// player.SetState(State, StateParams); +// } +// +// player.lineLength = LineLength; +// player.reelSpeed = ReelSpeed; +// player.EyeAngle = EyeAngle; +// player.isHandOnHandle = IsHandOnHandle; +// } +// +// if (SyncFlags.HasFlag(SyncFlags.HeldItem)) +// { +// player.CurrentHeldItem = HeldItem; +// } +// +// if (SyncFlags.HasFlag(SyncFlags.Flags)) +// { +// player.openLight = GetFlag(0); +// player.openTelescope = GetFlag(1); +// player.ChangeItem = GetFlag(2); +// player.IsLureRod = GetFlag(3); +// } +// +// if (SyncFlags.HasFlag(SyncFlags.Extension)) +// { +// if (FloatPosition.HasValue) +// { +// /* 应用浮漂位置 */ +// } +// +// if (HookPosition.HasValue) +// { +// /* 应用鱼钩位置 */ +// } +// +// if (CaughtFishID != 0) +// { +// /* 应用鱼 ID */ +// } +// } +// } +// } +// +// +// /// +// /// 玩家钓组数据 +// /// +// [Serializable] +// public class FPlayerGearData +// { +// public GearType Type = GearType.Spinning; +// +// public FRodData rod; +// public FReelData reel; +// public FBobberData bobber; +// public FHookData hook; +// public FBaitData bait; +// public FLureData lure; +// public FWeightData weight; +// public FLineData line; +// public FLeaderData leader; +// public FFeederData feeder; +// +// /// +// /// 获得唯一 id +// /// +// /// +// public int GetUnitId() +// { +// int result = 0; +// if (rod != null) +// { +// result += rod.configId; +// } +// +// if (reel != null) +// { +// result += reel.configId; +// } +// +// if (bobber != null) +// { +// result += bobber.configId; +// } +// +// if (hook != null) +// { +// result += hook.configId; +// } +// +// if (bait != null) +// { +// result += bait.configId; +// } +// +// if (lure != null) +// { +// result += lure.configId; +// } +// +// if (weight != null) +// { +// result += weight.configId; +// } +// +// if (line != null) +// { +// result += line.configId; +// } +// +// if (leader != null) +// { +// result += leader.configId; +// } +// +// if (feeder != null) +// { +// result += feeder.configId; +// } +// +// return result; +// } +// +// public void SetBobberLastSetGroundValue(float value) +// { +// bobber.lastSetGroundValue = value; +// } +// +// public void SetReelSettings(int setType, float val, bool saveToPrefs = false) +// { +// if (setType == 0) +// { +// reel.currentSpeed = val; +// } +// +// if (setType == 1) +// { +// reel.currentDrag = val; +// } +// +// if (saveToPrefs) +// { +// // Instance._playerData.SaveEquipment(ItemType.Reel); +// } +// } +// +// /// +// /// 获取所有非空装备部件 +// /// +// public List GetAllComponents() +// { +// var components = new List(); +// +// if (rod != null) components.Add(rod); +// if (reel != null) components.Add(reel); +// if (bobber != null) components.Add(bobber); +// if (hook != null) components.Add(hook); +// if (bait != null) components.Add(bait); +// if (lure != null) components.Add(lure); +// if (weight != null) components.Add(weight); +// if (line != null) components.Add(line); +// if (leader != null) components.Add(leader); +// if (feeder != null) components.Add(feeder); +// +// return components; +// } +// } +// +// [Serializable] +// public abstract class FGearData +// { +// /// +// /// 唯一 id(运行时生成,区分同一配置的多个实例) +// /// +// public int id; +// +// /// +// /// 配置 id(来自表格) +// /// +// public int configId; +// +// /// +// /// 物品类型(用于快速判断) +// /// +// public abstract GearItemType ItemType { get; } +// +// /// +// /// 获取基础物品配置 +// /// +// public Item ItemConfig => Game.Tables.TbItem.Get(configId); +// +// /// +// /// 创建该类型的默认数据(用于网络同步时重建) +// /// +// public abstract FGearData CreateDefault(); +// +// /// +// /// 是否已加载配置 +// /// +// public bool HasConfigLoaded => configId != 0; +// +// /// +// /// 复制基础数据到新实例 +// /// +// public void CopyBaseTo(FGearData other) +// { +// other.id = this.id; +// other.configId = this.configId; +// } +// } +// +// /// +// /// 鱼竿数据 +// /// +// [Serializable] +// public class FRodData : FGearData +// { +// public override GearItemType ItemType => GearItemType.Rod; +// +// [NonSerialized] private Rod _config; +// +// public Rod Config => _config ??= Game.Tables.TbRod.Get(configId); +// +// public override FGearData CreateDefault() +// { +// return new FRodData { configId = this.configId }; +// } +// } +// +// /// +// /// 线轴数据 +// /// +// [Serializable] +// public class FReelData : FGearData +// { +// public override GearItemType ItemType => GearItemType.Reel; +// +// [NonSerialized] private Reel _config; +// +// public Reel Config => _config ??= Game.Tables.TbReel.Get(configId); +// +// public float currentSpeed = 0.5f; +// public float currentDrag = 0.7f; +// +// public override FGearData CreateDefault() +// { +// return new FReelData +// { +// configId = this.configId, +// currentSpeed = this.currentSpeed, +// currentDrag = this.currentDrag +// }; +// } +// } +// +// /// +// /// 浮漂数据 +// /// +// [Serializable] +// public class FBobberData : FGearData +// { +// public override GearItemType ItemType => GearItemType.Bobber; +// +// [NonSerialized] private Bobber _config; +// +// public Bobber Config => _config ??= Game.Tables.TbBobber.Get(configId); +// +// public float lastSetGroundValue; +// +// public override FGearData CreateDefault() +// { +// return new FBobberData +// { +// configId = this.configId, +// lastSetGroundValue = this.lastSetGroundValue +// }; +// } +// } +// +// /// +// /// 鱼钩数据 +// /// +// [Serializable] +// public class FHookData : FGearData +// { +// public override GearItemType ItemType => GearItemType.Hook; +// +// [NonSerialized] private Hook _config; +// +// public Hook Config => _config ??= Game.Tables.TbHook.Get(configId); +// +// public override FGearData CreateDefault() +// { +// return new FHookData { configId = this.configId }; +// } +// } +// +// /// +// /// 鱼饵数据 +// /// +// [Serializable] +// public class FBaitData : FGearData +// { +// public override GearItemType ItemType => GearItemType.Bait; +// +// [NonSerialized] private Bait _config; +// +// public Bait Config => _config ??= Game.Tables.TbBait.Get(configId); +// +// public override FGearData CreateDefault() +// { +// return new FBaitData { configId = this.configId }; +// } +// } +// +// /// +// /// 假饵数据 +// /// +// [Serializable] +// public class FLureData : FGearData +// { +// public override GearItemType ItemType => GearItemType.Lure; +// +// [NonSerialized] private Lure _config; +// +// public Lure Config => _config ??= Game.Tables.TbLure.Get(configId); +// +// public override FGearData CreateDefault() +// { +// return new FLureData { configId = this.configId }; +// } +// } +// +// /// +// /// 沉子数据 +// /// +// [Serializable] +// public class FWeightData : FGearData +// { +// public override GearItemType ItemType => GearItemType.Weight; +// +// public override FGearData CreateDefault() +// { +// return new FWeightData { configId = this.configId }; +// } +// } +// +// /// +// /// 主线数据 +// /// +// [Serializable] +// public class FLineData : FGearData +// { +// public override GearItemType ItemType => GearItemType.Line; +// +// [NonSerialized] private Line _config; +// +// public Line Config => _config ??= Game.Tables.TbLine.Get(configId); +// +// public override FGearData CreateDefault() +// { +// return new FLineData { configId = this.configId }; +// } +// } +// +// /// +// /// 前导线数据 +// /// +// [Serializable] +// public class FLeaderData : FGearData +// { +// public override GearItemType ItemType => GearItemType.Leader; +// +// public override FGearData CreateDefault() +// { +// return new FLeaderData { configId = this.configId }; +// } +// } +// +// /// +// /// 菲德数据 +// /// +// [Serializable] +// public class FFeederData : FGearData +// { +// public override GearItemType ItemType => GearItemType.Feeder; +// +// [NonSerialized] private Feeder _config; +// +// public Feeder Config => _config ??= Game.Tables.TbFeeder.Get(configId); +// +// public override FGearData CreateDefault() +// { +// return new FFeederData { configId = this.configId }; +// } +// } +// } \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Data/LocalDataManager.cs b/Assets/Scripts/Fishing/Data/LocalDataManager.cs new file mode 100644 index 000000000..8209703e9 --- /dev/null +++ b/Assets/Scripts/Fishing/Data/LocalDataManager.cs @@ -0,0 +1,94 @@ +// // 新文件:D:\myself\Fishing2\Assets\Scripts\Fishing\Data\LocalDataManager.cs +// +// using System.Collections.Generic; +// using UnityEngine; +// +// namespace NBF +// { +// /// +// /// 本地单机模式的数据管理器(模拟服务器转发) +// /// +// public class LocalDataManager : PlayerDataManager +// { +// public override bool IsLocalMode => true; +// +// private Dictionary _localPlayers = new(); +// private uint _sequenceCounter; +// +// protected void Awake() +// { +// Instance = this; +// } +// +// public void RegisterPlayer(FPlayerData player) +// { +// if (!_localPlayers.ContainsKey(player.PlayerID)) +// { +// _localPlayers.Add(player.PlayerID, player); +// player.IsLocalPlayer = true; +// } +// } +// +// public override void OnPlayerStateChanged(FPlayerData player, PlayerState newState) +// { +// // 本地模式下,广播给其他本地玩家(分屏) +// foreach (var kvp in _localPlayers) +// { +// if (kvp.Value != player) +// { +// // 直接应用状态(或者加入简单的延迟模拟) +// kvp.Value.State = newState; +// } +// } +// } +// +// public override void OnHeldItemChanged(FPlayerData player, HeldItemInfo newItem) +// { +// foreach (var kvp in _localPlayers) +// { +// if (kvp.Value != player) +// { +// kvp.Value.CurrentHeldItem = newItem; +// } +// } +// } +// +// public override void SendStateSnapshot(FPlayerData player) +// { +// _sequenceCounter++; +// var snapshot = player.ToNetworkSnapshot(_sequenceCounter); +// +// // 本地广播 +// foreach (var kvp in _localPlayers) +// { +// if (kvp.Value != player) +// { +// ReceiveStateSnapshot(kvp.Key, snapshot); +// } +// } +// } +// +// public override void ReceiveStateSnapshot(int playerID, PlayerStateSnapshot snapshot) +// { +// if (_localPlayers.TryGetValue(playerID, out var player)) +// { +// player.ApplyFromNetworkSnapshot(snapshot); +// } +// } +// +// // 定时同步(例如每秒 10 次) +// private float _syncTimer; +// private void Update() +// { +// _syncTimer += Time.deltaTime; +// if (_syncTimer >= 0.1f) // 10Hz +// { +// _syncTimer = 0; +// foreach (var player in _localPlayers.Values) +// { +// SendStateSnapshot(player); +// } +// } +// } +// } +// } diff --git a/Assets/Scripts/Fishing/Data/LocalDataManager.cs.meta b/Assets/Scripts/Fishing/Data/LocalDataManager.cs.meta new file mode 100644 index 000000000..7c4676e83 --- /dev/null +++ b/Assets/Scripts/Fishing/Data/LocalDataManager.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: ec2bd63eb6c143fdb528da693a8c6969 +timeCreated: 1773028161 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Data/NetworkDataManager.cs b/Assets/Scripts/Fishing/Data/NetworkDataManager.cs new file mode 100644 index 000000000..3a458bab0 --- /dev/null +++ b/Assets/Scripts/Fishing/Data/NetworkDataManager.cs @@ -0,0 +1,60 @@ +// using UnityEngine; +// +// namespace NBF +// { +// /// +// /// 网络模式的数据管理器 +// /// +// public class NetworkDataManager : PlayerDataManager +// { +// public override bool IsLocalMode => false; +// +// // TODO: 这里集成你的网络库(Steamworks、Photon、Mirror 等) +// // public SteamNetworkClient NetworkClient; +// +// protected void Awake() +// { +// Instance = this; +// } +// +// public override void OnPlayerStateChanged(FPlayerData player, PlayerState newState) +// { +// // 如果是本地玩家,发送到服务器 +// if (player.IsLocalPlayer) +// { +// SendStateSnapshot(player); +// } +// } +// +// public override void OnHeldItemChanged(FPlayerData player, HeldItemInfo newItem) +// { +// if (player.IsLocalPlayer) +// { +// // TODO: 发送物品切换消息到服务器 +// Debug.Log($"发送物品切换:{newItem.ItemType}, ConfigID={newItem.ConfigID}"); +// } +// } +// +// public override void SendStateSnapshot(FPlayerData player) +// { +// if (!player.IsLocalPlayer) return; +// +// // TODO: 通过 Steam 或其他网络库发送 +// // NetworkClient.SendStateSnapshot(player.ToNetworkSnapshot()); +// } +// +// public override void ReceiveStateSnapshot(int playerID, PlayerStateSnapshot snapshot) +// { +// // TODO: 从网络接收其他玩家的状态 +// // 找到或创建对应的玩家对象 +// var player = FindOrCreatePlayer(playerID); +// player.ApplyFromNetworkSnapshot(snapshot); +// } +// +// private FPlayerData FindOrCreatePlayer(int playerID) +// { +// // TODO: 实现玩家对象池或动态生成 +// return FindObjectOfType(); +// } +// } +// } \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Data/NetworkDataManager.cs.meta b/Assets/Scripts/Fishing/Data/NetworkDataManager.cs.meta new file mode 100644 index 000000000..e0720da5e --- /dev/null +++ b/Assets/Scripts/Fishing/Data/NetworkDataManager.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f1a06ba516ea46f0920729b2af6484c1 +timeCreated: 1773028213 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Data/PlayerDataManager.cs b/Assets/Scripts/Fishing/Data/PlayerDataManager.cs new file mode 100644 index 000000000..806b587a5 --- /dev/null +++ b/Assets/Scripts/Fishing/Data/PlayerDataManager.cs @@ -0,0 +1,56 @@ +// using System; +// using System.Collections.Generic; +// using UnityEngine; +// +// namespace NBF +// { +// public interface IDataSource +// { +// } +// +// /// +// /// 数据管理器基类(本地和网络共享接口) +// /// +// public class PlayerDataManager : MonoBehaviour +// { +// public static PlayerDataManager Instance { get; private set; } +// +// public FPlayerData Self { get; set; } +// +// private Dictionary _players = new Dictionary(); +// +// protected void Awake() +// { +// Instance = this; +// } +// +// +// /// +// /// 玩家状态变更时调用 +// /// +// public void OnPlayerStateChanged(FPlayerData player, PlayerState newState) +// { +// } +// +// /// +// /// 手持物品变更时调用 +// /// +// public void OnHeldItemChanged(FPlayerData player, HeldItemInfo newItem) +// { +// } +// +// /// +// /// 发送玩家状态快照 +// /// +// public void SendStateSnapshot(FPlayerData player) +// { +// } +// +// /// +// /// 接收并应用网络快照 +// /// +// public void ReceiveStateSnapshot(int playerID, PlayerStateSnapshot snapshot) +// { +// } +// } +// } \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Data/PlayerDataManager.cs.meta b/Assets/Scripts/Fishing/Data/PlayerDataManager.cs.meta new file mode 100644 index 000000000..783cfccdc --- /dev/null +++ b/Assets/Scripts/Fishing/Data/PlayerDataManager.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 0478bf9a256a46beb65fd0307c7b5b9b +timeCreated: 1773028149 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Data/StateEnterParams.cs b/Assets/Scripts/Fishing/Data/StateEnterParams.cs new file mode 100644 index 000000000..2fd78927a --- /dev/null +++ b/Assets/Scripts/Fishing/Data/StateEnterParams.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace NBF +{ + /// + /// 状态进入参数(用于网络同步和动画/表现播放) + /// + [Serializable] + public class StateEnterParams + { + // 序列化友好的数据存储 + [SerializeField] private List _keys = new(); + [SerializeField] private List _intValues = new(); + [SerializeField] private List _floatValues = new(); + [SerializeField] private List _vector3Values = new(); + [SerializeField] private List _quaternionValues = new(); + + // 快速访问缓存 + private Dictionary _intCache; + private Dictionary _floatCache; + private Dictionary _vector3Cache; + private Dictionary _quaternionCache; + + public StateEnterParams() + { + InitializeCaches(); + } + + private void InitializeCaches() + { + _intCache = new Dictionary(); + _floatCache = new Dictionary(); + _vector3Cache = new Dictionary(); + _quaternionCache = new Dictionary(); + } + + /// + /// 清空所有参数 + /// + public void Clear() + { + _keys.Clear(); + _intValues.Clear(); + _floatValues.Clear(); + _vector3Values.Clear(); + _quaternionValues.Clear(); + + _intCache.Clear(); + _floatCache.Clear(); + _vector3Cache.Clear(); + _quaternionCache.Clear(); + } + + /// + /// 设置 int 参数 + /// + public void SetInt(string key, int value) + { + if (_intCache.TryGetValue(key, out int index)) + { + _intValues[index] = value; + } + else + { + _keys.Add(key); + _intValues.Add(value); + _intCache[key] = _intValues.Count - 1; + } + } + + /// + /// 设置 float 参数 + /// + public void SetFloat(string key, float value) + { + if (_floatCache.TryGetValue(key, out int index)) + { + _floatValues[index] = value; + } + else + { + _keys.Add(key); + _floatValues.Add(value); + _floatCache[key] = _floatValues.Count - 1; + } + } + + /// + /// 设置 Vector3 参数 + /// + public void SetVector3(string key, Vector3 value) + { + if (_vector3Cache.TryGetValue(key, out int index)) + { + _vector3Values[index] = value; + } + else + { + _keys.Add(key); + _vector3Values.Add(value); + _vector3Cache[key] = _vector3Values.Count - 1; + } + } + + /// + /// 设置 Quaternion 参数 + /// + public void SetQuaternion(string key, Quaternion value) + { + if (_quaternionCache.TryGetValue(key, out int index)) + { + _quaternionValues[index] = value; + } + else + { + _keys.Add(key); + _quaternionValues.Add(value); + _quaternionCache[key] = _quaternionValues.Count - 1; + } + } + + /// + /// 设置 bool 参数 + /// + public void SetBool(string key, bool value) + { + if (_intCache.TryGetValue(key, out int index)) + { + _intValues[index] = value ? 1 : 0; + } + else + { + _keys.Add(key); + _intValues.Add(value ? 1 : 0); + _intCache[key] = _intValues.Count - 1; + } + } + + /// + /// 获取 int 参数 + /// + public int GetInt(string key, int defaultValue = 0) + { + if (_intCache.TryGetValue(key, out int index) && index < _intValues.Count) + { + return _intValues[index]; + } + + return defaultValue; + } + + /// + /// 获取 float 参数 + /// + public float GetFloat(string key, float defaultValue = 0f) + { + if (_floatCache.TryGetValue(key, out int index) && index < _floatValues.Count) + { + return _floatValues[index]; + } + + return defaultValue; + } + + /// + /// 获取 Vector3 参数 + /// + public Vector3 GetVector3(string key, Vector3 defaultValue = default) + { + if (_vector3Cache.TryGetValue(key, out int index) && index < _vector3Values.Count) + { + return _vector3Values[index]; + } + + return defaultValue; + } + + /// + /// 获取 Quaternion 参数 + /// + public Quaternion GetQuaternion(string key, Quaternion defaultValue = default) + { + if (_quaternionCache.TryGetValue(key, out int index) && index < _quaternionValues.Count) + { + return _quaternionValues[index]; + } + + return defaultValue; + } + + /// + /// 获取 bool 参数 + /// + public bool GetBool(string key, bool defaultValue = false) + { + if (_intCache.TryGetValue(key, out int index) && index < _intValues.Count) + { + return _intValues[index] == 1; + } + + return defaultValue; + } + + /// + /// 是否包含某个参数 + /// + public bool HasKey(string key) + { + return _intCache.ContainsKey(key) || + _floatCache.ContainsKey(key) || + _vector3Cache.ContainsKey(key) || + _quaternionCache.ContainsKey(key); + } + + /// + /// 复制当前参数 + /// + public StateEnterParams Clone() + { + var copy = new StateEnterParams + { + _keys = new List(_keys), + _intValues = new List(_intValues), + _floatValues = new List(_floatValues), + _vector3Values = new List(_vector3Values), + _quaternionValues = new List(_quaternionValues) + }; + copy.InitializeCaches(); + return copy; + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Data/StateEnterParams.cs.meta b/Assets/Scripts/Fishing/Data/StateEnterParams.cs.meta new file mode 100644 index 000000000..b6fe4a25e --- /dev/null +++ b/Assets/Scripts/Fishing/Data/StateEnterParams.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a0ff43bbc7dd46b387e62f574ceb2523 +timeCreated: 1773029210 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Fishing.cs b/Assets/Scripts/Fishing/Fishing.cs index aa2849057..ce3522dbe 100644 --- a/Assets/Scripts/Fishing/Fishing.cs +++ b/Assets/Scripts/Fishing/Fishing.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using Fantasy; using Fantasy.Async; +using Fantasy.Entitas; using NBF.Fishing2; using RootMotion.FinalIK; using Log = NBC.Log; @@ -9,7 +10,6 @@ namespace NBF { public class Fishing { - public FPlayer Player { get; private set; } private static Fishing _instance; public static Fishing Instance @@ -22,6 +22,10 @@ namespace NBF } } + public MapRoom OldMap { get; private set; } + + public MapRoom Map { get; private set; } + public async FTask Go(int mapId, string roomCode = "") { @@ -37,6 +41,11 @@ namespace NBF RoomCode = roomCode }); Log.Info($"进入地图请求返回={response.ErrorCode}"); + if (response.ErrorCode != 0) + { + Notices.Error("enter room error"); + return false; + } LoadingPanel.Show(); await ChangeMap(response.MapId, response.RoomCode, response.Units); LoadingPanel.Hide(); @@ -46,18 +55,17 @@ namespace NBF public async FTask ChangeMap(int mapId, string roomCode, List units) { + OldMap = Map; + Map = Entity.Create(Game.Main,true, true); + Map.Code = roomCode; + Map.Map = mapId; var sceneName = "Map1"; //加载场景== await SceneHelper.LoadScene(sceneName); - CreateUnit(); - } - - - private void CreateUnit() - { - var gameObject = PrefabsHelper.CreatePlayer(SceneSettings.Instance.Node); - Player = gameObject.GetComponent(); - CameraManager.Instance.Mode = CameraShowMode.FPP; + foreach (var mapUnitInfo in units) + { + Map.AddUnit(mapUnitInfo); + } } } } \ No newline at end of file diff --git a/Assets/Scripts/Fishing/FishingMap.cs b/Assets/Scripts/Fishing/FishingMap.cs deleted file mode 100644 index b7b01ea7d..000000000 --- a/Assets/Scripts/Fishing/FishingMap.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace NBF -{ - public class FishingMap - { - - } -} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/FishingMap.cs.meta b/Assets/Scripts/Fishing/FishingMap.cs.meta deleted file mode 100644 index d5cbc7e9c..000000000 --- a/Assets/Scripts/Fishing/FishingMap.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: c361da1bcda647eb96fa27af894d2a7a -timeCreated: 1766417823 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New.meta b/Assets/Scripts/Fishing/New.meta new file mode 100644 index 000000000..35374bd2b --- /dev/null +++ b/Assets/Scripts/Fishing/New.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: b727e6041c1e459aabd0d5c41752dd8e +timeCreated: 1773036926 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/Data.meta b/Assets/Scripts/Fishing/New/Data.meta new file mode 100644 index 000000000..8d0965077 --- /dev/null +++ b/Assets/Scripts/Fishing/New/Data.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 0b61ff9bc02946a287bd0cca1aa2b6cb +timeCreated: 1773037834 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/Data/MapRoom.cs b/Assets/Scripts/Fishing/New/Data/MapRoom.cs new file mode 100644 index 000000000..429af501e --- /dev/null +++ b/Assets/Scripts/Fishing/New/Data/MapRoom.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using Fantasy; +using Fantasy.Entitas; + +namespace NBF +{ + /// + /// 地图房间 + /// + public class MapRoom : Entity + { + /// + /// 是否本地房间 + /// + public bool IsLocalRoom; + + /// + /// 房间序号id + /// + public int RoomId; + + /// + /// 房间代码 + /// + public string Code = string.Empty; + + + /// + /// 房间玩家 + /// + public Dictionary Units = new Dictionary(); + + /// + /// 房主 + /// + public long Owner; + + /// + /// 创建时间 + /// + public long CreateTime; + + /// + /// 房间地图 + /// + public int Map; + + public void AddUnit(MapUnitInfo unit) + { + var player = Create(Game.Main, unit.Id, true, true); + Units[unit.Id] = player; + player.InitPlayer(unit); + } + + public void RemoveUnit(long id) + { + if (Units.Remove(id, out var player)) + { + player.Dispose(); + } + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/Data/MapRoom.cs.meta b/Assets/Scripts/Fishing/New/Data/MapRoom.cs.meta new file mode 100644 index 000000000..558de6c87 --- /dev/null +++ b/Assets/Scripts/Fishing/New/Data/MapRoom.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 41009853ac444d87809e98fae2d1c597 +timeCreated: 1773036879 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/Data/Player.cs b/Assets/Scripts/Fishing/New/Data/Player.cs new file mode 100644 index 000000000..5db3b8875 --- /dev/null +++ b/Assets/Scripts/Fishing/New/Data/Player.cs @@ -0,0 +1,97 @@ +using System.Collections.Generic; +using Fantasy; +using Fantasy.Entitas; +using UnityEngine; + +namespace NBF +{ + public class Player : Entity + { + /// + /// 是否本地玩家 + /// + public bool IsLocalPlayer; + + public bool IsLureRod => false; + + public bool IsSelf => RoleModel.Instance.Id == Id; + + // ========== 物理状态(高频同步) ========== + public Vector3 position; + public Quaternion rotation; + public Vector2 MoveInput; + public float Speed; + public float RotationSpeed; + public bool IsGrounded; + public bool Run; + + // ========== 钓鱼相关状态(中频同步) ========== + public float currentReelingSpeed; + public float lineLength; + public float reelSpeed; + public float EyeAngle; + + /// + /// 标志量 + /// + public long TagValue; + + + // ========== 状态机 ========== + private PlayerState _previousPlayerState = PlayerState.Idle; + private PlayerState _playerState; + public PlayerState PreviousState => _previousPlayerState; + + /// + /// 当前状态的进入参数(本地和远程都适用) + /// + public StateEnterParams CurrentStateParams { get; private set; } = new StateEnterParams(); + + public PlayerState State + { + get => _playerState; + set + { + if (_playerState != value) + { + _previousPlayerState = _playerState; + _playerState = value; + } + } + } + + /// + /// 玩家的物品 + /// + public Dictionary Items = new Dictionary(); + + /// + /// 当前手持物品id + /// + public long HandItemId; + + /// + /// 当前手持物品 + /// + public PlayerItem HandItem => Items[HandItemId]; + + public void InitPlayer(MapUnitInfo unitInfo) + { + AddComponent(); + if (unitInfo.Id == RoleModel.Instance.Id) + { + //自己 + AddComponent(); + } + } + + + public void UnUseItem() + { + } + + public void UseItem(ItemInfo item) + { + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/Data/Player.cs.meta b/Assets/Scripts/Fishing/New/Data/Player.cs.meta new file mode 100644 index 000000000..d8acfcf35 --- /dev/null +++ b/Assets/Scripts/Fishing/New/Data/Player.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 8f7d8cb1b2cd4e25913e17e2b54e7ad9 +timeCreated: 1773036936 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/Data/PlayerItem.cs b/Assets/Scripts/Fishing/New/Data/PlayerItem.cs new file mode 100644 index 000000000..0b206c0ad --- /dev/null +++ b/Assets/Scripts/Fishing/New/Data/PlayerItem.cs @@ -0,0 +1,12 @@ +using Fantasy.Entitas; + +namespace NBF +{ + public class PlayerItem : Entity + { + /// + /// 配置id + /// + public int ConfigID; + } +} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/Data/PlayerItem.cs.meta b/Assets/Scripts/Fishing/New/Data/PlayerItem.cs.meta new file mode 100644 index 000000000..61afce821 --- /dev/null +++ b/Assets/Scripts/Fishing/New/Data/PlayerItem.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 6649b122db2f46aea8147228c674a38c +timeCreated: 1773037313 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View.meta b/Assets/Scripts/Fishing/New/View.meta new file mode 100644 index 000000000..342b82689 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a6965e70ae1742089580926e5b1adabf +timeCreated: 1773037850 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/Mono.meta b/Assets/Scripts/Fishing/New/View/Mono.meta new file mode 100644 index 000000000..019d0cae2 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/Mono.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: c38d17daa1164359ad9f9466be692b7c +timeCreated: 1773038185 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/Mono/PlayerAnimator.cs b/Assets/Scripts/Fishing/New/View/Mono/PlayerAnimator.cs new file mode 100644 index 000000000..291852ee2 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/Mono/PlayerAnimator.cs @@ -0,0 +1,185 @@ +using System; +using KINEMATION.MagicBlend.Runtime; +using NBC; +using UnityEngine; + +namespace NBF +{ + public class PlayerAnimator : PlayerMonoBehaviour + { + public Animator _Animator; + + private bool _isRodLayerEnabled; + private bool _isInit; + private PlayerIK _IK; + private MagicBlending _magicBlending; + private bool _IsInVehicle; + + #region 参数定义 + + // public static readonly int IsSwiming = Animator.StringToHash("Swim"); + // + // public static readonly int ThrowFar = Animator.StringToHash("ThrowFar"); + // + // public static readonly int BoatDriving = Animator.StringToHash("BoatDriving"); + // + // public static readonly int BaitInWater = Animator.StringToHash("BaitInWater"); + // + // public static readonly int HeldRod = Animator.StringToHash("HeldRod"); + // + // public static readonly int RodArming = Animator.StringToHash("RodArming"); + + public static readonly int Forward = Animator.StringToHash("Forward"); + + public static readonly int Turn = Animator.StringToHash("Turn"); + + public static readonly int OnGroundHash = Animator.StringToHash("OnGround"); + public static readonly int PrepareThrowHash = Animator.StringToHash("PrepareThrow"); + public static readonly int StartThrowHash = Animator.StringToHash("StartThrow"); + public static readonly int BaitThrownHash = Animator.StringToHash("BaitThrown"); + private static readonly int FishingUpHash = Animator.StringToHash("FishingUp"); + + public static readonly string LureRodLayer = "LureRod"; + public static readonly string HandRodLayer = "HandRod"; + + + public float FishingUp + { + get => _Animator.GetFloat(FishingUpHash); + set => _Animator.SetFloat(FishingUpHash, value); + } + + public bool OnGround + { + get => _Animator.GetBool(OnGroundHash); + set => _Animator.SetBool(OnGroundHash, value); + } + + public bool StartThrow + { + get => _Animator.GetBool(StartThrowHash); + set => _Animator.SetBool(StartThrowHash, value); + } + + public bool BaitThrown + { + get => _Animator.GetBool(BaitThrownHash); + set => _Animator.SetBool(BaitThrownHash, value); + } + + public bool PrepareThrow + { + get => _Animator.GetBool(PrepareThrowHash); + set => _Animator.SetBool(PrepareThrowHash, value); + } + + #endregion + + + protected override void OnAwake() + { + _magicBlending = GetComponent(); + _Animator = GetComponent(); + _Animator.keepAnimatorStateOnDisable = true; + _IK = GetComponent(); + _isInit = true; + // Player.OnFishingSetEquiped += OnFishingSetEquiped_OnRaised; + // Player.OnFishingSetUnequip += OnFishingSetUnequip; + } + + + private void OnDestroy() + { + // Player.OnFishingSetEquiped -= OnFishingSetEquiped_OnRaised; + // Player.OnFishingSetUnequip -= OnFishingSetUnequip; + } + + + private void OnFishingSetUnequip() + { + _isRodLayerEnabled = false; + } + + + private void OnFishingSetEquiped_OnRaised(FHandItem item) + { + if (item is FRod rod) + { + _isRodLayerEnabled = true; + // var reel = Player.Rod.Reel; + // _IK.SetBipedLeftHandIK(enabled: false, reel.FingersIKAnchor); + } + else + { + } + } + + + public void SetLayerWeight(string layer, float weight) + { + _Animator.SetLayerWeight(_Animator.GetLayerIndex(layer), weight); + } + + + private void LateUpdate() + { + { + float value3 = Mathf.Lerp(_Animator.GetFloat(Forward), Player.Speed / 5f, + Time.deltaTime * 20f); + float value4 = Mathf.Lerp(_Animator.GetFloat(Turn), Player.RotationSpeed, + Time.deltaTime * 15f); + _Animator.SetFloat(Forward, Mathf.Clamp01(value3)); + _Animator.SetFloat(Turn, Mathf.Clamp(value4, -1f, 1f)); + } + + + _Animator.SetBool(OnGroundHash, _IsInVehicle || Player.IsGrounded); + + + var isHandRodLayerEnabled = _isRodLayerEnabled && !Player.IsLureRod ? 1 : 0; + + float handRodLayerWeight = _Animator.GetLayerWeight(_Animator.GetLayerIndex(HandRodLayer)); + SetLayerWeight(HandRodLayer, + Mathf.MoveTowards(handRodLayerWeight, isHandRodLayerEnabled, Time.deltaTime * 3f)); + + + var isLureRodLayerEnabled = _isRodLayerEnabled && Player.IsLureRod ? 1 : 0; + float lureRodLayerWeight = _Animator.GetLayerWeight(_Animator.GetLayerIndex(LureRodLayer)); + SetLayerWeight(LureRodLayer, + Mathf.MoveTowards(lureRodLayerWeight, isLureRodLayerEnabled, Time.deltaTime * 3f)); + } + + #region 动画事件 + + /// + /// 抬杆到底动画事件 + /// + public void OnRodPowerUp() + { + } + + /// + /// 开始抛出动画事件 + /// + public void OnRodThrowStart() + { + // if (Player.State is PlayerStateThrow playerStateThrow) + // { + // playerStateThrow.OnRodThrowStart(); + // } + } + + /// + /// 抛竿结束动画事件 + /// + public void OnRodThrownEnd() + { + // if (Player.Fsm.CurrentState is PlayerStateThrow playerStateThrow) + // { + // playerStateThrow.OnRodThrownEnd(); + // } + } + + #endregion + } +} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Player/PlayerAnimator.cs.meta b/Assets/Scripts/Fishing/New/View/Mono/PlayerAnimator.cs.meta similarity index 100% rename from Assets/Scripts/Fishing/Player/PlayerAnimator.cs.meta rename to Assets/Scripts/Fishing/New/View/Mono/PlayerAnimator.cs.meta diff --git a/Assets/Scripts/Fishing/Player/PlayerArm.cs b/Assets/Scripts/Fishing/New/View/Mono/PlayerArm.cs similarity index 84% rename from Assets/Scripts/Fishing/Player/PlayerArm.cs rename to Assets/Scripts/Fishing/New/View/Mono/PlayerArm.cs index 80ab2efc3..87cbc7013 100644 --- a/Assets/Scripts/Fishing/Player/PlayerArm.cs +++ b/Assets/Scripts/Fishing/New/View/Mono/PlayerArm.cs @@ -4,7 +4,7 @@ using UnityEngine; namespace NBF { - public class PlayerArm : MonoBehaviour + public class PlayerArm : PlayerMonoBehaviour { public bool FixLowerArm; public bool IsLeft; @@ -18,7 +18,7 @@ namespace NBF private const int MaxFixEyeAngle = 15; - public void Awake() + protected override void OnAwake() { } diff --git a/Assets/Scripts/Fishing/Player/PlayerArm.cs.meta b/Assets/Scripts/Fishing/New/View/Mono/PlayerArm.cs.meta similarity index 100% rename from Assets/Scripts/Fishing/Player/PlayerArm.cs.meta rename to Assets/Scripts/Fishing/New/View/Mono/PlayerArm.cs.meta diff --git a/Assets/Scripts/Fishing/Player/PlayerChest.cs b/Assets/Scripts/Fishing/New/View/Mono/PlayerChest.cs similarity index 82% rename from Assets/Scripts/Fishing/Player/PlayerChest.cs rename to Assets/Scripts/Fishing/New/View/Mono/PlayerChest.cs index 52614bbde..4e22b3eb8 100644 --- a/Assets/Scripts/Fishing/Player/PlayerChest.cs +++ b/Assets/Scripts/Fishing/New/View/Mono/PlayerChest.cs @@ -2,7 +2,7 @@ namespace NBF { - public class PlayerChest : MonoBehaviour + public class PlayerChest : PlayerMonoBehaviour { private const int MaxFixEyeAngle = 15; private const int MinFixEyeAngle = -10; @@ -14,8 +14,7 @@ namespace NBF private void FixArmAngle() { - var angle = FPlayerData.Instance.EyeAngle; - + var angle = Player.EyeAngle; if (angle > MaxFixEyeAngle) angle = MaxFixEyeAngle; else if (angle < MinFixEyeAngle) angle = MinFixEyeAngle; var val = transform.localEulerAngles; diff --git a/Assets/Scripts/Fishing/Player/PlayerChest.cs.meta b/Assets/Scripts/Fishing/New/View/Mono/PlayerChest.cs.meta similarity index 100% rename from Assets/Scripts/Fishing/Player/PlayerChest.cs.meta rename to Assets/Scripts/Fishing/New/View/Mono/PlayerChest.cs.meta diff --git a/Assets/Scripts/Fishing/New/View/Mono/PlayerIK.cs b/Assets/Scripts/Fishing/New/View/Mono/PlayerIK.cs new file mode 100644 index 000000000..57dbab084 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/Mono/PlayerIK.cs @@ -0,0 +1,58 @@ +using System; +using RootMotion.FinalIK; +using UnityEngine; + +namespace NBF +{ + public class PlayerIK : PlayerMonoBehaviour + { + public enum UpdateType + { + Update = 0, + FixedUpdate = 1, + LateUpdate = 2, + Default = 3 + } + + public UpdateType UpdateSelected; + + private LookAtIK _LookAtIK; + + [SerializeField] private float transitionWeightTimeScale = 1f; + + protected override void OnAwake() + { + _LookAtIK = GetComponent(); + } + + + private void Update() + { + if (UpdateSelected == UpdateType.Update) + { + IKUpdateHandler(); + } + } + + private void FixedUpdate() + { + if (UpdateSelected == UpdateType.FixedUpdate) + { + IKUpdateHandler(); + } + } + + private void LateUpdate() + { + if (UpdateSelected == UpdateType.LateUpdate) + { + IKUpdateHandler(); + } + } + + private void IKUpdateHandler() + { + _LookAtIK.UpdateSolverExternal(); + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Player/PlayerIK.cs.meta b/Assets/Scripts/Fishing/New/View/Mono/PlayerIK.cs.meta similarity index 100% rename from Assets/Scripts/Fishing/Player/PlayerIK.cs.meta rename to Assets/Scripts/Fishing/New/View/Mono/PlayerIK.cs.meta diff --git a/Assets/Scripts/Fishing/New/View/Mono/PlayerMonoBehaviour.cs b/Assets/Scripts/Fishing/New/View/Mono/PlayerMonoBehaviour.cs new file mode 100644 index 000000000..9bf54689d --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/Mono/PlayerMonoBehaviour.cs @@ -0,0 +1,22 @@ +using UnityEngine; + +namespace NBF +{ + public abstract class PlayerMonoBehaviour : MonoBehaviour + { + public Player Player { get; private set; } + + public PlayerUnityComponent UnityComponent { get; private set; } + + protected void Awake() + { + UnityComponent = GetComponentInParent(); + Player = UnityComponent.Player; + OnAwake(); + } + + protected virtual void OnAwake() + { + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/Mono/PlayerMonoBehaviour.cs.meta b/Assets/Scripts/Fishing/New/View/Mono/PlayerMonoBehaviour.cs.meta new file mode 100644 index 000000000..1543d0ce8 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/Mono/PlayerMonoBehaviour.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 7fbde40efe5345cd8c05ea6c1f1914cf +timeCreated: 1773040970 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/Mono/PlayerUnityComponent.cs b/Assets/Scripts/Fishing/New/View/Mono/PlayerUnityComponent.cs new file mode 100644 index 000000000..7ff6f9484 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/Mono/PlayerUnityComponent.cs @@ -0,0 +1,25 @@ +using ECM2; +using ECM2.Examples.FirstPerson; +using UnityEngine; + +namespace NBF +{ + public class PlayerUnityComponent : MonoBehaviour + { + public Player Player { get; set; } + public Transform Root; + public Transform Eye; + public Transform FppLook; + public Transform IK; + public PlayerModelAsset ModelAsset; + public CharacterMovement Character; + public FirstPersonCharacter FirstPerson; + + [Header("视角相关")] public float MouseSensitivity = 0.1f; + [Space(15f)] public bool invertLook = true; + + public float minPitch = -60f; + + public float maxPitch = 60f; + } +} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/Mono/PlayerUnityComponent.cs.meta b/Assets/Scripts/Fishing/New/View/Mono/PlayerUnityComponent.cs.meta new file mode 100644 index 000000000..f6de6a72d --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/Mono/PlayerUnityComponent.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a25908f34e4e4464a922b88337a5b733 +timeCreated: 1773038189 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/PlayerInputComponent.cs b/Assets/Scripts/Fishing/New/View/PlayerInputComponent.cs new file mode 100644 index 000000000..16c1327ae --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/PlayerInputComponent.cs @@ -0,0 +1,200 @@ +using Fantasy.Entitas; +using Fantasy.Entitas.Interface; +using NBC; +using UnityEngine; +using UnityEngine.InputSystem; + +namespace NBF +{ + public class PlayerInputComponent : Entity + { + public Player Player { get; private set; } + public PlayerViewComponent View { get; private set; } + + #region 生命周期 + + public class PlayerViewAwakeSystem : AwakeSystem + { + protected override void Awake(PlayerInputComponent self) + { + self.Player = self.GetParent(); + self.View = self.Player.GetComponent(); + self.AddInputEvent(); + } + } + + public class PlayerViewUpdateSystem : UpdateSystem + { + protected override void Update(PlayerInputComponent self) + { + self.UpdateMove(); + } + } + + public class PlayerViewDestroySystem : DestroySystem + { + protected override void Destroy(PlayerInputComponent self) + { + self.RemoveInputEvent(); + } + } + + #endregion + + #region Input + + private void AddInputEvent() + { + InputManager.OnPlayerPerformed += OnPlayerCanceled; + InputManager.OnPlayerPerformed += OnPlayerPerformed; + + InputManager.OnPlayerValueCanceled += OnPlayerValueCanceled; + InputManager.OnPlayerValuePerformed += OnPlayerValuePerformed; + } + + private void RemoveInputEvent() + { + InputManager.OnPlayerPerformed += OnPlayerCanceled; + InputManager.OnPlayerPerformed += OnPlayerPerformed; + + InputManager.OnPlayerValueCanceled += OnPlayerValueCanceled; + InputManager.OnPlayerValuePerformed += OnPlayerValuePerformed; + } + + private void OnPlayerPerformed(string action) + { + if (action == InputDef.Player.Run) + { + Player.Run = true; + } + } + + private void OnPlayerCanceled(string action) + { + if (action == InputDef.Player.Run) + { + Player.Run = false; + } + else if (action == InputDef.Player.ToBag) + { + //取消手持物品 + Log.Info($"取消手持物品"); + Player.UnUseItem(); + // Game.Instance.StartCoroutine(UnUseItem()); + } + else if (action.StartsWith(InputDef.Player.QuickStarts)) + { + var index = int.Parse(action.Replace(InputDef.Player.QuickStarts, string.Empty)); + Log.Info($"快速使用===={index}"); + var item = RoleModel.Instance.GetSlotItem(index - 1); + if (item != null) + { + Player.UseItem(item); + // Game.Instance.StartCoroutine(UseItem(item)); + } + } + } + + private void OnPlayerValueCanceled(InputAction.CallbackContext context) + { + var actionName = context.action.name; + if (actionName == InputDef.Player.Move) + { + Player.MoveInput = Vector2.zero; + } + } + + private void OnPlayerValuePerformed(InputAction.CallbackContext context) + { + var actionName = context.action.name; + if (actionName == InputDef.Player.Move) + { + var v2 = context.ReadValue(); + Player.MoveInput = v2; + } + else if (actionName == InputDef.Player.Look) + { + } + } + + #endregion + + #region Move + + private Quaternion lastRotation; + + private void UpdateMove() + { + UpdateGrounded(); + ProcessMoveStates(); + UpdateLookInput(); + } + + private void ProcessMoveStates() + { + { + var num2 = Player.Run ? 7 : 5; + Vector3 vector2 = View.Unity.FirstPerson.GetRightVector() * Player.MoveInput.x * num2; + vector2 += View.Unity.FirstPerson.GetForwardVector() * Player.MoveInput.y * num2; + // if (checkWaterBound) + // { + // SetMovementDirectionWithRaycastCheck(vector2); + // } + // else + { + View.Unity.FirstPerson.SetMovementDirection(vector2); + } + } + } + + private void UpdateGrounded() + { + Player.IsGrounded = View.Unity.FirstPerson.IsGrounded(); + Player.Speed = View.Unity.FirstPerson.velocity.magnitude; + + Quaternion rotation = View.Unity.FirstPerson.transform.rotation; + + // 计算当前帧与上一帧的旋转差异 + Quaternion rotationDelta = rotation * Quaternion.Inverse(lastRotation); + + // 将四元数转换为角度轴表示 + rotationDelta.ToAngleAxis(out float angle, out Vector3 axis); + + // 确保角度在0-360范围内 + if (angle > 180f) angle -= 360f; + + // 获取Y轴旋转分量(归一化处理) + float yRotation = 0f; + if (Mathf.Abs(angle) > 0.001f && Mathf.Abs(axis.y) > 0.1f) + { + // 计算Y轴方向的旋转角度(考虑旋转轴方向) + yRotation = angle * Mathf.Sign(axis.y); + } + + float maxTurnSpeed = 180f; // 度/秒 + // 转换为角速度并归一化到[-1, 1] + float angularSpeed = yRotation / Time.deltaTime; + float turnValue = Mathf.Clamp(angularSpeed / maxTurnSpeed, -1f, 1f); + + + Player.RotationSpeed = turnValue; + + lastRotation = rotation; + } + + #endregion + + #region Look + + private void UpdateLookInput() + { + Vector2 value = InputManager.GetLookInput(); + var u3d = View.Unity; + u3d.FirstPerson.AddControlYawInput(value.x * u3d.MouseSensitivity); + u3d.FirstPerson.AddControlPitchInput((u3d.invertLook ? 0f - value.y : value.y) * u3d.MouseSensitivity, + u3d.minPitch, u3d.maxPitch); + } + + #endregion + } +} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/PlayerInputComponent.cs.meta b/Assets/Scripts/Fishing/New/View/PlayerInputComponent.cs.meta new file mode 100644 index 000000000..789a43972 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/PlayerInputComponent.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 2d74cb6e741243478aeb0a3053211fcd +timeCreated: 1773039193 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/PlayerViewComponent.cs b/Assets/Scripts/Fishing/New/View/PlayerViewComponent.cs new file mode 100644 index 000000000..7ac25f989 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/PlayerViewComponent.cs @@ -0,0 +1,89 @@ +using Fantasy.Entitas; +using Fantasy.Entitas.Interface; +using NBF.Fishing2; +using UnityEngine; + +namespace NBF +{ + public class PlayerViewComponent : Entity + { + public Player Player { get; private set; } + + public PlayerUnityComponent Unity { get; private set; } + + #region 生命周期 + + public void Awake() + { + Player = GetParent(); + var gameObject = PrefabsHelper.CreatePlayer(SceneSettings.Instance.Node); + Unity = gameObject.GetComponent(); + Unity.Player = Player; + CreatePlayerModel(); + if (Player.IsSelf) + { + CameraManager.Instance.SetFppLook(Unity); + } + Unity.transform.localPosition = new Vector3(484, 1, 422); + } + + public void Update() + { + } + + public void LateUpdate() + { + Player.EyeAngle = GameUtils.GetVerticalAngle(Unity.transform, Unity.FppLook); + } + + public void Destroy() + { + } + + #endregion + + #region 模型创建 + + private void CreatePlayerModel() + { + var modelObject = PrefabsHelper.CreatePlayer(Unity.Root, "Human_Male"); + modelObject.transform.localPosition = Vector3.zero; + Unity.ModelAsset = modelObject.GetComponent(); + Unity.ModelAsset.SetPlayer(Unity.FppLook); + } + + #endregion + } + + public class PlayerViewAwakeSystem : AwakeSystem + { + protected override void Awake(PlayerViewComponent self) + { + self.Awake(); + } + } + + public class PlayerViewDestroySystem : DestroySystem + { + protected override void Destroy(PlayerViewComponent self) + { + self.Destroy(); + } + } + + public class PlayerViewUpdateSystem : UpdateSystem + { + protected override void Update(PlayerViewComponent self) + { + self.Update(); + } + } + + public class PlayerViewLateUpdateSystem : LateUpdateSystem + { + protected override void LateUpdate(PlayerViewComponent self) + { + self.LateUpdate(); + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/PlayerViewComponent.cs.meta b/Assets/Scripts/Fishing/New/View/PlayerViewComponent.cs.meta new file mode 100644 index 000000000..d2fa69943 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/PlayerViewComponent.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 418da378516646cdb672fa05c2066432 +timeCreated: 1773037811 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Player/FPlayer.Input.cs b/Assets/Scripts/Fishing/Player/FPlayer.Input.cs deleted file mode 100644 index 4c0f0370c..000000000 --- a/Assets/Scripts/Fishing/Player/FPlayer.Input.cs +++ /dev/null @@ -1,93 +0,0 @@ -using NBC; -using NBF.Utils; -using UnityEngine; -using UnityEngine.InputSystem; - -namespace NBF -{ - public partial class FPlayer - { - #region Input - - private void AddInputEvent() - { - InputManager.OnPlayerPerformed += OnPlayerCanceled; - InputManager.OnPlayerPerformed += OnPlayerPerformed; - - InputManager.OnPlayerValueCanceled += OnPlayerValueCanceled; - InputManager.OnPlayerValuePerformed += OnPlayerValuePerformed; - } - - private void RemoveInputEvent() - { - InputManager.OnPlayerPerformed += OnPlayerCanceled; - InputManager.OnPlayerPerformed += OnPlayerPerformed; - - InputManager.OnPlayerValueCanceled += OnPlayerValueCanceled; - InputManager.OnPlayerValuePerformed += OnPlayerValuePerformed; - } - - private void OnPlayerPerformed(string action) - { - if (action == InputDef.Player.Run) - { - Data.Run = true; - } - } - - private void OnPlayerCanceled(string action) - { - if (action == InputDef.Player.Run) - { - Data.Run = false; - } - else if (action == InputDef.Player.ToBag) - { - //取消手持物品 - Log.Info($"取消手持物品"); - Game.Instance.StartCoroutine(UnUseItem()); - } - else if (action.StartsWith(InputDef.Player.QuickStarts)) - { - var index = int.Parse(action.Replace(InputDef.Player.QuickStarts, string.Empty)); - Log.Info($"快速使用===={index}"); - var item = RoleModel.Instance.GetSlotItem(index - 1); - if (item != null) - { - Game.Instance.StartCoroutine(UseItem(item)); - } - } - } - - private void OnPlayerValueCanceled(InputAction.CallbackContext context) - { - var actionName = context.action.name; - if (actionName == InputDef.Player.Move) - { - // var v2 = context.ReadValue(); - Data.MoveInput = Vector2.zero; - // SendMoveMessage(v2, true); - } - } - - private void OnPlayerValuePerformed(InputAction.CallbackContext context) - { - // var mapUnit = Parent as MapUnit; - // Log.Info($"OnPlayerValuePerformed IsSelf={mapUnit.IsSelf()} id={mapUnit.Id}"); - var actionName = context.action.name; - if (actionName == InputDef.Player.Move) - { - var v2 = context.ReadValue(); - Data.MoveInput = v2; - // SendMoveMessage(v2, false); - } - else if (actionName == InputDef.Player.Look) - { - var v2 = context.ReadValue(); - // UpdatePlayerRotation(v2); - } - } - - #endregion - } -} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Player/FPlayer.Input.cs.meta b/Assets/Scripts/Fishing/Player/FPlayer.Input.cs.meta deleted file mode 100644 index 0da9d5061..000000000 --- a/Assets/Scripts/Fishing/Player/FPlayer.Input.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: ea2c2e2d4b4344f0bc9d58ba0aeec6fd -timeCreated: 1766505279 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Player/FPlayer.Move.cs b/Assets/Scripts/Fishing/Player/FPlayer.Move.cs deleted file mode 100644 index 90afd3df0..000000000 --- a/Assets/Scripts/Fishing/Player/FPlayer.Move.cs +++ /dev/null @@ -1,146 +0,0 @@ -using UnityEngine; - -namespace NBF -{ - public partial class FPlayer - { - #region Move - - private Quaternion lastRotation; - - private void UpdateMove() - { - UpdateGrounded(); - UpdateWater(); - ProcessMoveStates(); - UpdateLookInput(); - } - - private void UpdateGrounded() - { - Data.IsGrounded = FirstPerson.IsGrounded(); - Data.Speed = FirstPerson.velocity.magnitude; - - Quaternion rotation = FirstPerson.transform.rotation; - - // 计算当前帧与上一帧的旋转差异 - Quaternion rotationDelta = rotation * Quaternion.Inverse(lastRotation); - - // 将四元数转换为角度轴表示 - rotationDelta.ToAngleAxis(out float angle, out Vector3 axis); - - // 确保角度在0-360范围内 - if (angle > 180f) angle -= 360f; - - // 获取Y轴旋转分量(归一化处理) - float yRotation = 0f; - if (Mathf.Abs(angle) > 0.001f && Mathf.Abs(axis.y) > 0.1f) - { - // 计算Y轴方向的旋转角度(考虑旋转轴方向) - yRotation = angle * Mathf.Sign(axis.y); - } - - float maxTurnSpeed = 180f; // 度/秒 - // 转换为角速度并归一化到[-1, 1] - float angularSpeed = yRotation / Time.deltaTime; - float turnValue = Mathf.Clamp(angularSpeed / maxTurnSpeed, -1f, 1f); - - - Data.RotationSpeed = turnValue; - - lastRotation = rotation; - } - - private void UpdateWater() - { - // SceneSettings.Instance.Water.w - } - - private void ProcessMoveStates() - { - // if (CameraView.Value == CameraViewType.TPP) - // { - // float num = (IsRunPressed.Value ? MovementSpeed.Value : (MovementSpeed.Value * 0.5f)); - // num = (IsFlyModeEnabled ? (num * (float)FlySpeed) : num); - // Vector3 zero = Vector3.zero; - // zero += Vector3.right * MovementDirection.Value.x; - // zero += Vector3.forward * MovementDirection.Value.y; - // zero = zero.relativeTo(_CameraTPPTarget, _Character.GetUpVector()); - // _Character.RotateTowards(zero, Time.deltaTime * _RotateTPPSpeed); - // float value = Vector3.Dot(_Character.GetForwardVector(), zero); - // Vector3 vector = _Character.GetForwardVector() * Mathf.Clamp01(value) * num; - // if (checkWaterBound) - // { - // SetMovementDirectionWithRaycastCheck(vector); - // } - // else - // { - // _Character.SetMovementDirection(vector); - // } - // } - // else - { - var num2 = Data.Run ? 7 : 5; - //(IsRunPressed.Value ? MovementSpeed.Value : (MovementSpeed.Value * 0.5f)); - // num2 = (IsFlyModeEnabled ? (num2 * (float)FlySpeed) : num2); - Vector3 vector2 = FirstPerson.GetRightVector() * Data.MoveInput.x * num2; - vector2 += FirstPerson.GetForwardVector() * Data.MoveInput.y * num2; - // if (checkWaterBound) - // { - // SetMovementDirectionWithRaycastCheck(vector2); - // } - // else - { - FirstPerson.SetMovementDirection(vector2); - } - } - } - - #endregion - - #region Look - - public float MouseSensitivity = 0.1f; - [Space(15f)] public bool invertLook = true; - - public float minPitch = -60f; - - public float maxPitch = 60f; - - private void UpdateLookInput() - { - // TPPLookTarget.position = base.transform.position; - // if (CameraView.Value == CameraViewType.TPP) - // { - // lookXRot -= MouseInput.Value.y; - // lookXRot = Mathf.Clamp(lookXRot, -25f, 55f); - // lookYRot += MouseInput.Value.x; - // lookYRot = Mathf.Repeat(lookYRot, 360f); - // TPPLookTarget.localEulerAngles = new Vector3(lookXRot, lookYRot, 0f); - // } - // else if (CameraView.Value == CameraViewType.FPP) - { - // if (_IsInVehicle && PlayerState.Value == State.vehicle) - // { - // lookXRot -= MouseInput.Value.y; - // lookXRot = Mathf.Clamp(lookXRot, VehicleLookXMinMax.x, VehicleLookXMinMax.y); - // lookYRot += MouseInput.Value.x; - // lookYRot = Mathf.Clamp(lookYRot, VehicleLookYMinMax.x, VehicleLookYMinMax.y); - // VehicleLookTargetParent.localEulerAngles = new Vector3(lookXRot, lookYRot, 0f); - // _character.CameraPitch = 0f; - // } - // else - { - Vector2 value = InputManager.GetLookInput(); - FirstPerson.AddControlYawInput(value.x * (float)MouseSensitivity); - FirstPerson.AddControlPitchInput((invertLook ? (0f - value.y) : value.y) * (float)MouseSensitivity, - minPitch, maxPitch); - // lookXRot = base.transform.eulerAngles.x; - // lookYRot = base.transform.eulerAngles.y; - } - } - } - - #endregion - } -} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Player/FPlayer.Move.cs.meta b/Assets/Scripts/Fishing/Player/FPlayer.Move.cs.meta deleted file mode 100644 index e2dc5f111..000000000 --- a/Assets/Scripts/Fishing/Player/FPlayer.Move.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: ae72860c045147af8022a3cbb30481ab -timeCreated: 1766471468 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Player/FPlayer.cs b/Assets/Scripts/Fishing/Player/FPlayer.cs index 38011bee8..78d0f51d3 100644 --- a/Assets/Scripts/Fishing/Player/FPlayer.cs +++ b/Assets/Scripts/Fishing/Player/FPlayer.cs @@ -1,165 +1,142 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using ECM2; -using ECM2.Examples.FirstPerson; -using Fantasy; -using NBC; -using NBF.Fishing2; -using NBF.Utils; -using UnityEngine; -using UnityEngine.InputSystem; -using Object = UnityEngine.Object; - -namespace NBF -{ - public partial class FPlayer : MonoService - { - public Transform Root; - public Transform Eye; - public Transform FppLook; - public Transform IK; - public PlayerModelAsset ModelAsset; - public CharacterMovement Character; - public FirstPersonCharacter FirstPerson; - public GameObject ModelGameObject { get; set; } - - - public FPlayerData Data { get; private set; } - - public readonly List Tackles = new List(); - public FRod Rod { get; private set; } - public Fsm Fsm { get; private set; } - - public event Action OnFishingSetEquiped; - public event Action OnFishingSetUnequip; - - protected override void OnAwake() - { - Character = gameObject.GetComponent(); - FirstPerson = gameObject.GetComponent(); - Data = FPlayerData.Instance; - transform.localPosition = new Vector3(484, 1, 422); - // Data.NeedChangeRightArmAngle = true; - } - - private void Start() - { - InitFsm(); - AddInputEvent(); - CreatePlayerModel(); - } - - private void Update() - { - - } - - private void LateUpdate() - { - UpdateMove(); - Fsm?.Update(); - - Data.EyeAngle = GameUtils.GetVerticalAngle(transform, FppLook); - } - - - - private void OnDestroy() - { - RemoveInputEvent(); - } - - #region 状态机 - - private void InitFsm() - { - Fsm = new Fsm("Player", this, true); - Fsm.RegisterState(); - Fsm.RegisterState(); - Fsm.RegisterState(); - Fsm.RegisterState(); - Fsm.RegisterState(); - Fsm.Start(); - } - - #endregion - - #region 角色模型 - - private void CreatePlayerModel() - { - var modelObject = PrefabsHelper.CreatePlayer(Root, "Human_Male"); - modelObject.transform.localPosition = Vector3.zero; - ModelGameObject = modelObject; - ModelAsset = modelObject.GetComponent(); - ModelAsset.SetPlayer(this); - } - - #endregion - - #region 使用物品 - - public IEnumerator UseItem(ItemInfo item) - { - if (Data.ChangeItem) yield break; - Data.ChangeItem = true; - var itemType = item?.ConfigId.GetItemType(); - if (itemType == ItemType.Rod) - { - //判断旧的是否要收回 - yield return UnUseItemConfirm(); - - Data.IsLureRod = true; - var rodType = (ItemSubType)item.Config.Type; - if (rodType == ItemSubType.RodTele) - { - Data.IsLureRod = false; - } - - Rod = - item.Config.InstantiateAndComponent(SceneSettings.Instance.GearNode, Vector3.zero, - Quaternion.identity); - yield return Rod.InitRod(this, item); - Tackles.Add(Rod); - OnFishingSetEquiped?.Invoke(Rod); - } - - Data.ChangeItem = false; - } - - public IEnumerator UnUseItem() - { - if (Data.ChangeItem) yield break; - Data.ChangeItem = true; - yield return UnUseItemConfirm(); - Data.ChangeItem = false; - } - - private IEnumerator UnUseItemConfirm() - { - if (Rod != null) - { - OnFishingSetUnequip?.Invoke(); - yield return Rod.Destroy(); - yield return new WaitForSeconds(0.35f); - Destroy(Rod.gameObject); - Tackles.Remove(Rod); - Rod = null; - yield return new WaitForSeconds(0.15f); - } - } - - #endregion - - /// - /// 线断了 - /// - /// - /// - public void LineBreak(string msg, float loseBaitChance) - { - - } - } -} \ No newline at end of file +// using System; +// using System.Collections; +// using System.Collections.Generic; +// using ECM2; +// using ECM2.Examples.FirstPerson; +// using Fantasy; +// using NBC; +// using NBF.Fishing2; +// using NBF.Utils; +// using UnityEngine; +// using UnityEngine.InputSystem; +// using Object = UnityEngine.Object; +// +// namespace NBF +// { +// public partial class FPlayer : MonoService +// { +// public Transform Root; +// public Transform Eye; +// public Transform FppLook; +// public Transform IK; +// public PlayerModelAsset ModelAsset; +// public CharacterMovement Character; +// public FirstPersonCharacter FirstPerson; +// public GameObject ModelGameObject { get; set; } +// +// +// // public FPlayerData Data { get; private set; } +// +// public readonly List Tackles = new List(); +// public FRod Rod { get; private set; } +// public Fsm Fsm { get; private set; } +// +// public event Action OnFishingSetEquiped; +// public event Action OnFishingSetUnequip; +// +// protected override void OnAwake() +// { +// Character = gameObject.GetComponent(); +// FirstPerson = gameObject.GetComponent(); +// // Data = PlayerDataManager.Instance.Self; +// transform.localPosition = new Vector3(484, 1, 422); +// // Data.NeedChangeRightArmAngle = true; +// } +// +// private void Start() +// { +// InitFsm(); +// CreatePlayerModel(); +// } +// +// +// private void LateUpdate() +// { +// Fsm?.Update(); +// } +// +// #region 状态机 +// +// private void InitFsm() +// { +// Fsm = new Fsm("Player", this, true); +// Fsm.RegisterState(); +// Fsm.RegisterState(); +// Fsm.RegisterState(); +// Fsm.RegisterState(); +// Fsm.RegisterState(); +// Fsm.Start(); +// } +// +// #endregion +// +// #region 角色模型 +// +// private void CreatePlayerModel() +// { +// var modelObject = PrefabsHelper.CreatePlayer(Root, "Human_Male"); +// modelObject.transform.localPosition = Vector3.zero; +// ModelGameObject = modelObject; +// ModelAsset = modelObject.GetComponent(); +// ModelAsset.SetPlayer(this); +// } +// +// #endregion +// +// #region 使用物品 +// +// public IEnumerator UseItem(ItemInfo item) +// { +// // if (Data.ChangeItem) yield break; +// // Data.ChangeItem = true; +// // var itemType = item?.ConfigId.GetItemType(); +// // if (itemType == ItemType.Rod) +// // { +// // //判断旧的是否要收回 +// // yield return UnUseItemConfirm(); +// // +// // Data.IsLureRod = true; +// // var rodType = (ItemSubType)item.Config.Type; +// // if (rodType == ItemSubType.RodTele) +// // { +// // Data.IsLureRod = false; +// // } +// // +// // Rod = +// // item.Config.InstantiateAndComponent(SceneSettings.Instance.GearNode, Vector3.zero, +// // Quaternion.identity); +// // yield return Rod.InitRod(this, item); +// // Tackles.Add(Rod); +// // OnFishingSetEquiped?.Invoke(Rod); +// // } +// // +// // Data.ChangeItem = false; +// yield return null; +// } +// +// public IEnumerator UnUseItem() +// { +// // if (Data.ChangeItem) yield break; +// // Data.ChangeItem = true; +// // yield return UnUseItemConfirm(); +// // Data.ChangeItem = false; +// yield return null; +// } +// +// private IEnumerator UnUseItemConfirm() +// { +// if (Rod != null) +// { +// OnFishingSetUnequip?.Invoke(); +// yield return Rod.Destroy(); +// yield return new WaitForSeconds(0.35f); +// Destroy(Rod.gameObject); +// Tackles.Remove(Rod); +// Rod = null; +// yield return new WaitForSeconds(0.15f); +// } +// } +// +// #endregion +// } +// } \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Player/FPlayerData.cs b/Assets/Scripts/Fishing/Player/FPlayerData.cs deleted file mode 100644 index ac3900ebc..000000000 --- a/Assets/Scripts/Fishing/Player/FPlayerData.cs +++ /dev/null @@ -1,87 +0,0 @@ -// using System; -// using UnityEngine; -// -// namespace NBF -// { -// // [Serializable] -// // public enum PlayerState -// // { -// // idle = 0, -// // move = 1, -// // prepare = 2, -// // casting = 3, -// // fishing = 4, -// // baitFlies = 5, -// // fight = 6, -// // fishView = 7, -// // collectFish = 8, -// // throwFish = 9, -// // vehicle = 10, -// // swiming = 11, -// // flyModeDebug = 12, -// // vehicleFishing = 13, -// // preciseCastIdle = 14, -// // preciseCastThrow = 15 -// // } -// - -// -// public class FPlayerData : MonoService -// { -// private PlayerState _previousPlayerState = PlayerState.Idle; -// private PlayerState _playerState; -// -// public bool ChangeItem; -// public bool Run; -// public bool IsGrounded; -// public float Speed; -// public float RotationSpeed; -// public float ReelSpeed; -// public float LineTension; -// -// /// -// /// 是否路亚竿 -// /// -// public bool IsLureRod; -// -// public Vector2 MoveInput; -// -// /// -// /// -// /// -// public float EyeAngle; -// -// -// public PlayerState PreviousState => _previousPlayerState; -// -// public PlayerState State -// { -// get => _playerState; -// set -// { -// _previousPlayerState = _playerState; -// _playerState = value; -// NextState = value; -// OnStateChange?.Invoke(_playerState); -// } -// } -// -// [SerializeField] private PlayerState NextState; -// -// public event Action OnStateChange; -// -// -// private void Start() -// { -// NextState = State; -// } -// -// private void Update() -// { -// if (NextState != State) -// { -// State = NextState; -// } -// } -// } -// } \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Player/FPlayerData.cs.meta b/Assets/Scripts/Fishing/Player/FPlayerData.cs.meta deleted file mode 100644 index bb5caf10b..000000000 --- a/Assets/Scripts/Fishing/Player/FPlayerData.cs.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: 53629a9cec2b4caf9a739c21f7abdf3c -timeCreated: 1766471002 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Player/PlayerAnimator.cs b/Assets/Scripts/Fishing/Player/PlayerAnimator.cs deleted file mode 100644 index 7ffacb298..000000000 --- a/Assets/Scripts/Fishing/Player/PlayerAnimator.cs +++ /dev/null @@ -1,306 +0,0 @@ -using System; -using KINEMATION.MagicBlend.Runtime; -using NBC; -using UnityEngine; - -namespace NBF -{ - public class PlayerAnimator : MonoBehaviour - { - public Animator _Animator; - public FPlayer Player { get; private set; } - - private bool _isRodLayerEnabled; - private bool _isInit; - private PlayerIK _IK; - private MagicBlending _magicBlending; - private bool _IsInVehicle; - - #region 参数定义 - - // public static readonly int IsSwiming = Animator.StringToHash("Swim"); - // - // public static readonly int ThrowFar = Animator.StringToHash("ThrowFar"); - // - // public static readonly int BoatDriving = Animator.StringToHash("BoatDriving"); - // - // public static readonly int BaitInWater = Animator.StringToHash("BaitInWater"); - // - // public static readonly int HeldRod = Animator.StringToHash("HeldRod"); - // - // public static readonly int RodArming = Animator.StringToHash("RodArming"); - - public static readonly int Forward = Animator.StringToHash("Forward"); - - public static readonly int Turn = Animator.StringToHash("Turn"); - - public static readonly int OnGroundHash = Animator.StringToHash("OnGround"); - public static readonly int PrepareThrowHash = Animator.StringToHash("PrepareThrow"); - public static readonly int StartThrowHash = Animator.StringToHash("StartThrow"); - public static readonly int BaitThrownHash = Animator.StringToHash("BaitThrown"); - private static readonly int FishingUpHash = Animator.StringToHash("FishingUp"); - - public static readonly string LureRodLayer = "LureRod"; - public static readonly string HandRodLayer = "HandRod"; - - - public float FishingUp - { - get => _Animator.GetFloat(FishingUpHash); - set => _Animator.SetFloat(FishingUpHash, value); - } - - public bool OnGround - { - get => _Animator.GetBool(OnGroundHash); - set => _Animator.SetBool(OnGroundHash, value); - } - - public bool StartThrow - { - get => _Animator.GetBool(StartThrowHash); - set => _Animator.SetBool(StartThrowHash, value); - } - - public bool BaitThrown - { - get => _Animator.GetBool(BaitThrownHash); - set => _Animator.SetBool(BaitThrownHash, value); - } - - public bool PrepareThrow - { - get => _Animator.GetBool(PrepareThrowHash); - set => _Animator.SetBool(PrepareThrowHash, value); - } - - #endregion - - - private void Awake() - { - Player = GetComponentInParent(); - _magicBlending = GetComponent(); - _Animator = GetComponent(); - _Animator.keepAnimatorStateOnDisable = true; - _IK = GetComponent(); - _isInit = true; - Player.OnFishingSetEquiped += OnFishingSetEquiped_OnRaised; - Player.OnFishingSetUnequip += OnFishingSetUnequip; - Player.Data.OnStateChange += PlayerFSMState_OnValueChanged; - } - - - private void OnDestroy() - { - Player.OnFishingSetEquiped -= OnFishingSetEquiped_OnRaised; - Player.OnFishingSetUnequip -= OnFishingSetUnequip; - Player.Data.OnStateChange += PlayerFSMState_OnValueChanged; - } - - - private void OnFishingSetUnequip() - { - _isRodLayerEnabled = false; - // _IK.SetBipedLeftHandIK(enabled: false, null); - } - - - private void OnFishingSetEquiped_OnRaised(FHandItem item) - { - if (item is FRod rod) - { - _isRodLayerEnabled = true; - // var reel = Player.Rod.Reel; - // _IK.SetBipedLeftHandIK(enabled: false, reel.FingersIKAnchor); - } - else - { - } - } - - - public void SetLayerWeight(string layer, float weight) - { - _Animator.SetLayerWeight(_Animator.GetLayerIndex(layer), weight); - } - - private void PlayerFSMState_OnValueChanged(PlayerState state) - { - // switch (Player.Data.PreviousState) - // { - // case PlayerState.vehicle: - // _IsInVehicle = false; - // _Animator.SetBool(BoatDriving, value: false); - // break; - // case PlayerState.swiming: - // _Animator.SetBool(IsSwiming, value: false); - // break; - // case PlayerState.preciseCastIdle: - // _Animator.SetBool(PreciseIdle, value: false); - // break; - // case PlayerState.prepare: - // _Animator.SetBool(RodArming, value: false); - // break; - // case PlayerState.casting: - // _Animator.SetBool(ThrowFar, value: false); - // break; - // case PlayerState.collectFish: - // _magicBlending.BlendAsset.globalWeight = 0f; - // break; - // } - // - // switch (state) - // { - // switch (Player.Data.PreviousState) - // { - // case PlayerState.vehicle: - // _IsInVehicle = false; - // _Animator.SetBool(BoatDriving, value: false); - // break; - // case PlayerState.swiming: - // _Animator.SetBool(IsSwiming, value: false); - // break; - // case PlayerState.preciseCastIdle: - // _Animator.SetBool(PreciseIdle, value: false); - // break; - // case PlayerState.prepare: - // _Animator.SetBool(RodArming, value: false); - // break; - // case PlayerState.casting: - // _Animator.SetBool(ThrowFar, value: false); - // break; - // case PlayerState.collectFish: - // _magicBlending.BlendAsset.globalWeight = 0f; - // break; - // } - // - // switch (state) - // { - // case PlayerState.idle: - // case PlayerState.move: - // _Animator.SetBool(BaitInWater, value: false); - // _Animator.SetBool(HeldRod, value: false); - // _Animator.SetBool(ThrowFar, value: false); - // _Animator.SetBool(RodArming, value: false); - // break; - // case PlayerState.prepare: - // _Animator.SetBool(RodArming, value: true); - // _Animator.SetBool(HeldRod, value: true); - // break; - // case PlayerState.fishing: - // _Animator.SetBool(HeldRod, value: true); - // _Animator.SetBool(BaitInWater, value: true); - // break; - // case PlayerState.vehicle: - // _Animator.SetBool(BaitInWater, value: false); - // _Animator.SetBool(HeldRod, value: false); - // _Animator.SetBool(ThrowFar, value: false); - // _Animator.SetBool(RodArming, value: false); - // _Animator.SetBool(BoatDriving, value: true); - // _IK.SetBipedLeftHandIK(enabled: true); - // _IsInVehicle = true; - // break; - // case PlayerState.vehicleFishing: - // _Animator.SetBool(BaitInWater, value: false); - // _Animator.SetBool(HeldRod, value: false); - // _Animator.SetBool(ThrowFar, value: false); - // _Animator.SetBool(RodArming, value: false); - // _IsInVehicle = true; - // break; - // case PlayerState.swiming: - // _Animator.SetBool(IsSwiming, value: true); - // break; - // case PlayerState.collectFish: - // _Animator.SetBool(BaitInWater, value: false); - // _IK.SetAimIK(enabled: false); - // _magicBlending.BlendAsset.globalWeight = 1f; - // break; - // case PlayerState.preciseCastIdle: - // _Animator.SetBool(PreciseIdle, value: true); - // break; - // case PlayerState.casting: - // case PlayerState.baitFlies: - // case PlayerState.fight: - // case PlayerState.fishView: - // case PlayerState.throwFish: - // case PlayerState.flyModeDebug: - // break; - // } - } - - private void LateUpdate() - { - // if (Player.Data.State == PlayerState.swiming) - // { - // float value = Mathf.Lerp(_Animator.GetFloat(Forward), Player.Data.Speed / 2.5f, - // Time.deltaTime * 5f); - // float value2 = Mathf.Lerp(_Animator.GetFloat(Turn), Player.Data.RotationSpeed, Time.deltaTime * 5f); - // _Animator.SetFloat(Forward, Mathf.Clamp01(value)); - // _Animator.SetFloat(Turn, Mathf.Clamp(value2, -1f, 1f)); - // } - // else - { - float value3 = Mathf.Lerp(_Animator.GetFloat(Forward), Player.Data.Speed / 5f, - Time.deltaTime * 20f); - float value4 = Mathf.Lerp(_Animator.GetFloat(Turn), Player.Data.RotationSpeed, Time.deltaTime * 15f); - _Animator.SetFloat(Forward, Mathf.Clamp01(value3)); - _Animator.SetFloat(Turn, Mathf.Clamp(value4, -1f, 1f)); - } - - // var rod = Vector3.zero; - // if (Player.Rod) - // { - // rod = Player.Rod.transform.position; - // } - - _Animator.SetBool(OnGroundHash, _IsInVehicle || Player.Data.IsGrounded); - - - var isHandRodLayerEnabled = _isRodLayerEnabled && !Player.Data.IsLureRod ? 1 : 0; - - float handRodLayerWeight = _Animator.GetLayerWeight(_Animator.GetLayerIndex(HandRodLayer)); - SetLayerWeight(HandRodLayer, - Mathf.MoveTowards(handRodLayerWeight, isHandRodLayerEnabled, Time.deltaTime * 3f)); - - - var isLureRodLayerEnabled = _isRodLayerEnabled && Player.Data.IsLureRod ? 1 : 0; - float lureRodLayerWeight = _Animator.GetLayerWeight(_Animator.GetLayerIndex(LureRodLayer)); - SetLayerWeight(LureRodLayer, - Mathf.MoveTowards(lureRodLayerWeight, isLureRodLayerEnabled, Time.deltaTime * 3f)); - } - - #region 动画事件 - - /// - /// 抬杆到底动画事件 - /// - public void OnRodPowerUp() - { - } - - /// - /// 开始抛出动画事件 - /// - public void OnRodThrowStart() - { - if (Player.Fsm.CurrentState is PlayerStateThrow playerStateThrow) - { - playerStateThrow.OnRodThrowStart(); - } - } - - /// - /// 抛竿结束动画事件 - /// - public void OnRodThrownEnd() - { - if (Player.Fsm.CurrentState is PlayerStateThrow playerStateThrow) - { - playerStateThrow.OnRodThrownEnd(); - } - } - - #endregion - } -} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Player/PlayerIK.cs b/Assets/Scripts/Fishing/Player/PlayerIK.cs deleted file mode 100644 index 8587608e9..000000000 --- a/Assets/Scripts/Fishing/Player/PlayerIK.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System; -using RootMotion.FinalIK; -using UnityEngine; - -namespace NBF -{ - public class PlayerIK : MonoBehaviour - { - public enum UpdateType - { - Update = 0, - FixedUpdate = 1, - LateUpdate = 2, - Default = 3 - } - - public UpdateType UpdateSelected; - - // [SerializeField] private Transform _LeftHandTransform; - - private LookAtIK _LookAtIK; - - // private AimIK _AimIK; - - // private FullBodyBipedIK _FullBodyIK; - - // private ArmIK _ArmIK; - - // private bool _isLeftHandEnabled; - - // private bool _isRightHandEnabled; - - // public bool isAimEnabled; - - private bool _isFishingLeftArmEnabled; - - [SerializeField] private float transitionWeightTimeScale = 1f; - - // public Transform CurrentTarget => _FullBodyIK.solver.leftHandEffector.target; - - // public Transform LeftHandTransform => _LeftHandTransform; - - private void Awake() - { - _LookAtIK = GetComponent(); - // _AimIK = GetComponent(); - // _FullBodyIK = GetComponent(); - // _ArmIK = GetComponent(); - // SetAimIK(enabled: false); - } - - - - public void SetBipedIK(bool enabled) - { - } - - public void SetFishingLeftArm(bool enabled) - { - _isFishingLeftArmEnabled = enabled; - } - - // public void SetFishingLeftArm(bool enabled, Transform target) - // { - // _isFishingLeftArmEnabled = enabled; - // _ArmIK.solver.arm.target = target; - // } - - // public void SetBipedLeftHandIK(bool enabled, bool instant = false) - // { - // _isLeftHandEnabled = enabled; - // if (instant) - // { - // _FullBodyIK.solver.leftArmMapping.weight = (enabled ? 1f : 0f); - // } - // } - - // public void SetBipedRightHandIK(bool enabled, bool instant = false) - // { - // _isRightHandEnabled = enabled; - // if (instant) - // { - // _FullBodyIK.solver.rightArmMapping.weight = (enabled ? 1f : 0f); - // } - // } - - // public void SetBipedLeftHandIK(bool enabled, Transform target, bool instant = false) - // { - // _isLeftHandEnabled = enabled; - // _FullBodyIK.solver.leftHandEffector.target = target; - // if (instant) - // { - // _FullBodyIK.solver.leftArmMapping.weight = (enabled ? 1f : 0f); - // } - // } - - // public void SetBipedRightHandIK(bool enabled, Transform target, bool instant = false) - // { - // _isRightHandEnabled = enabled; - // _FullBodyIK.solver.rightHandEffector.target = target; - // if (instant) - // { - // _FullBodyIK.solver.rightArmMapping.weight = (enabled ? 1f : 0f); - // } - // } - - // public void SetAimIK(bool enabled) - // { - // isAimEnabled = enabled; - // } - - private void Update() - { - if (UpdateSelected == UpdateType.Update) - { - IKUpdateHandler(); - } - } - - private void FixedUpdate() - { - if (UpdateSelected == UpdateType.FixedUpdate) - { - IKUpdateHandler(); - } - } - - private void LateUpdate() - { - if (UpdateSelected == UpdateType.LateUpdate) - { - IKUpdateHandler(); - } - } - - private void IKUpdateHandler() - { - // _AimIK.UpdateSolverExternal(); - _LookAtIK.UpdateSolverExternal(); - // _FullBodyIK.UpdateSolverExternal(); - // _FullBodyIK.solver.Update(); - // _AimIK.solver.IKPositionWeight = Mathf.MoveTowards(_AimIK.solver.IKPositionWeight, isAimEnabled ? 1f : 0f, - // Time.deltaTime * transitionWeightTimeScale); - // _FullBodyIK.solver.leftArmMapping.weight = Mathf.MoveTowards(_FullBodyIK.solver.leftArmMapping.weight, - // _isLeftHandEnabled ? 1f : 0f, Time.deltaTime * transitionWeightTimeScale); - // _FullBodyIK.solver.rightArmMapping.weight = Mathf.MoveTowards(_FullBodyIK.solver.rightArmMapping.weight, - // _isRightHandEnabled ? 1f : 0f, Time.deltaTime * transitionWeightTimeScale); - // _FullBodyIK.solver.IKPositionWeight = Mathf.MoveTowards(_FullBodyIK.solver.IKPositionWeight, - // _isLeftHandEnabled ? 1f : 0f, Time.deltaTime * transitionWeightTimeScale); - // _ArmIK.solver.IKPositionWeight = Mathf.MoveTowards(_ArmIK.solver.IKPositionWeight, - // _isFishingLeftArmEnabled ? 1f : 0f, Time.deltaTime * transitionWeightTimeScale); - // _ArmIK.solver.IKRotationWeight = Mathf.MoveTowards(_ArmIK.solver.IKRotationWeight, - // _isFishingLeftArmEnabled ? 1f : 0f, Time.deltaTime * transitionWeightTimeScale); - } - } -} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Player/States.meta b/Assets/Scripts/Fishing/Player/States.meta deleted file mode 100644 index d90cc8646..000000000 --- a/Assets/Scripts/Fishing/Player/States.meta +++ /dev/null @@ -1,3 +0,0 @@ -fileFormatVersion: 2 -guid: a3b57223c7f94237869524280afe1672 -timeCreated: 1768138483 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Player/States/PlayerStateBase.cs b/Assets/Scripts/Fishing/Player/States~/PlayerStateBase.cs similarity index 75% rename from Assets/Scripts/Fishing/Player/States/PlayerStateBase.cs rename to Assets/Scripts/Fishing/Player/States~/PlayerStateBase.cs index 4d1ab4cdb..13462fd83 100644 --- a/Assets/Scripts/Fishing/Player/States/PlayerStateBase.cs +++ b/Assets/Scripts/Fishing/Player/States~/PlayerStateBase.cs @@ -3,9 +3,9 @@ using UnityEngine; namespace NBF { - public abstract class PlayerStateBase : FsmBaseState + public abstract class PlayerStateBase : FsmBaseState { - protected FPlayer Player => _owner; + protected Player Player => _owner; /// /// 检查状态超时 diff --git a/Assets/Scripts/Fishing/Player/States/PlayerStateBase.cs.meta b/Assets/Scripts/Fishing/Player/States~/PlayerStateBase.cs.meta similarity index 100% rename from Assets/Scripts/Fishing/Player/States/PlayerStateBase.cs.meta rename to Assets/Scripts/Fishing/Player/States~/PlayerStateBase.cs.meta diff --git a/Assets/Scripts/Fishing/Player/States/PlayerStateFight.cs b/Assets/Scripts/Fishing/Player/States~/PlayerStateFight.cs similarity index 100% rename from Assets/Scripts/Fishing/Player/States/PlayerStateFight.cs rename to Assets/Scripts/Fishing/Player/States~/PlayerStateFight.cs diff --git a/Assets/Scripts/Fishing/Player/States/PlayerStateFight.cs.meta b/Assets/Scripts/Fishing/Player/States~/PlayerStateFight.cs.meta similarity index 100% rename from Assets/Scripts/Fishing/Player/States/PlayerStateFight.cs.meta rename to Assets/Scripts/Fishing/Player/States~/PlayerStateFight.cs.meta diff --git a/Assets/Scripts/Fishing/Player/States/PlayerStateFishing.cs b/Assets/Scripts/Fishing/Player/States~/PlayerStateFishing.cs similarity index 83% rename from Assets/Scripts/Fishing/Player/States/PlayerStateFishing.cs rename to Assets/Scripts/Fishing/Player/States~/PlayerStateFishing.cs index af06c6a90..33607b8c7 100644 --- a/Assets/Scripts/Fishing/Player/States/PlayerStateFishing.cs +++ b/Assets/Scripts/Fishing/Player/States~/PlayerStateFishing.cs @@ -29,25 +29,25 @@ namespace NBF if (InputManager.IsOp1) { - if (!Player.Data.IsLureRod) - { - //抬杆 - isUpRod = true; - } - else - { - //收线 - isSubLine = true; - } + // if (!Player.Data.IsLureRod) + // { + // //抬杆 + // isUpRod = true; + // } + // else + // { + // //收线 + // isSubLine = true; + // } } if (InputManager.IsOp2) { - if (Player.Data.IsLureRod) - { - //抬杆 - isUpRod = true; - } + // if (Player.Data.IsLureRod) + // { + // //抬杆 + // isUpRod = true; + // } } //Player.ModelAsset.PlayerAnimator.FishingUp = 0; diff --git a/Assets/Scripts/Fishing/Player/States/PlayerStateFishing.cs.meta b/Assets/Scripts/Fishing/Player/States~/PlayerStateFishing.cs.meta similarity index 100% rename from Assets/Scripts/Fishing/Player/States/PlayerStateFishing.cs.meta rename to Assets/Scripts/Fishing/Player/States~/PlayerStateFishing.cs.meta diff --git a/Assets/Scripts/Fishing/Player/States/PlayerStateIdle.cs b/Assets/Scripts/Fishing/Player/States~/PlayerStateIdle.cs similarity index 100% rename from Assets/Scripts/Fishing/Player/States/PlayerStateIdle.cs rename to Assets/Scripts/Fishing/Player/States~/PlayerStateIdle.cs diff --git a/Assets/Scripts/Fishing/Player/States/PlayerStateIdle.cs.meta b/Assets/Scripts/Fishing/Player/States~/PlayerStateIdle.cs.meta similarity index 100% rename from Assets/Scripts/Fishing/Player/States/PlayerStateIdle.cs.meta rename to Assets/Scripts/Fishing/Player/States~/PlayerStateIdle.cs.meta diff --git a/Assets/Scripts/Fishing/Player/States/PlayerStatePrepare.cs b/Assets/Scripts/Fishing/Player/States~/PlayerStatePrepare.cs similarity index 100% rename from Assets/Scripts/Fishing/Player/States/PlayerStatePrepare.cs rename to Assets/Scripts/Fishing/Player/States~/PlayerStatePrepare.cs diff --git a/Assets/Scripts/Fishing/Player/States/PlayerStatePrepare.cs.meta b/Assets/Scripts/Fishing/Player/States~/PlayerStatePrepare.cs.meta similarity index 100% rename from Assets/Scripts/Fishing/Player/States/PlayerStatePrepare.cs.meta rename to Assets/Scripts/Fishing/Player/States~/PlayerStatePrepare.cs.meta diff --git a/Assets/Scripts/Fishing/Player/States/PlayerStateThrow.cs b/Assets/Scripts/Fishing/Player/States~/PlayerStateThrow.cs similarity index 100% rename from Assets/Scripts/Fishing/Player/States/PlayerStateThrow.cs rename to Assets/Scripts/Fishing/Player/States~/PlayerStateThrow.cs diff --git a/Assets/Scripts/Fishing/Player/States/PlayerStateThrow.cs.meta b/Assets/Scripts/Fishing/Player/States~/PlayerStateThrow.cs.meta similarity index 100% rename from Assets/Scripts/Fishing/Player/States/PlayerStateThrow.cs.meta rename to Assets/Scripts/Fishing/Player/States~/PlayerStateThrow.cs.meta diff --git a/Assets/Scripts/Fishing/Tackle/FGearBase.cs b/Assets/Scripts/Fishing/Tackle/FGearBase.cs index 15b809723..749422128 100644 --- a/Assets/Scripts/Fishing/Tackle/FGearBase.cs +++ b/Assets/Scripts/Fishing/Tackle/FGearBase.cs @@ -3,16 +3,14 @@ using UnityEngine; namespace NBF { - public abstract class FGearBase : MonoBehaviour + public abstract class FGearBase : PlayerMonoBehaviour { - public FPlayer Player { get; protected set; } public FRod Rod { get; protected set; } public ItemInfo ItemInfo; - public virtual void Init(FPlayer player, FRod rod) + public virtual void Init(FRod rod) { - Player = player; Rod = rod; OnInit(); } diff --git a/Assets/Scripts/Fishing/Tackle/FHandItem.cs b/Assets/Scripts/Fishing/Tackle/FHandItem.cs index 7d31b1be2..08365e056 100644 --- a/Assets/Scripts/Fishing/Tackle/FHandItem.cs +++ b/Assets/Scripts/Fishing/Tackle/FHandItem.cs @@ -2,8 +2,7 @@ namespace NBF { - public class FHandItem : MonoBehaviour + public class FHandItem : PlayerMonoBehaviour { - } } \ No newline at end of file diff --git a/Assets/Scripts/Fishing/Tackle/FRod.cs b/Assets/Scripts/Fishing/Tackle/FRod.cs index 4b9ddaaa6..1ff566801 100644 --- a/Assets/Scripts/Fishing/Tackle/FRod.cs +++ b/Assets/Scripts/Fishing/Tackle/FRod.cs @@ -12,7 +12,7 @@ namespace NBF public class FRod : FHandItem { private float _tension; - + /// /// 可用的 /// @@ -20,7 +20,6 @@ namespace NBF public RodAsset Asset; - public FPlayer Player { get; protected set; } public ItemInfo ItemInfo; public FReel Reel; @@ -61,7 +60,7 @@ namespace NBF } } } - + private void Awake() { @@ -125,22 +124,26 @@ namespace NBF yield return 1; } - public IEnumerator InitRod(FPlayer player, ItemInfo itemInfo) + public IEnumerator InitRod(ItemInfo itemInfo) { ItemInfo = itemInfo; - Player = player; + // Player = player; + + var playerView = Player.GetComponent(); + + var playerViewUnity = playerView.Unity; transform.localPosition = Vector3.zero; transform.localRotation = Quaternion.identity; transform.localScale = Vector3.one; - SceneSettings.Instance.GearNode.position = Player.transform.position; + SceneSettings.Instance.GearNode.position = playerViewUnity.transform.position; yield return 1; var obj = new GameObject($"rod_{itemInfo.Id}_{itemInfo.ConfigId}"); obj.transform.SetParent(SceneSettings.Instance.GearNode); // obj.transform.SetParent(player.transform); // obj.transform.localPosition = Vector3.zero; - obj.transform.position = player.transform.position; - obj.transform.rotation = player.transform.rotation; + obj.transform.position = playerViewUnity.transform.position; + obj.transform.rotation = playerViewUnity.transform.rotation; obj.transform.localScale = Vector3.one; GearRoot = obj.transform; @@ -205,39 +208,39 @@ namespace NBF Reel.transform.SetParent(Asset.ReelConnector); Reel.transform.localPosition = Vector3.zero; Reel.transform.localEulerAngles = Vector3.zero; - Reel.Init(player, this); + Reel.Init(this); } if (Bobber) { - Bobber.Init(Player, this); + Bobber.Init(this); } if (Hook) { - Hook.Init(Player, this); + Hook.Init(this); } if (Bait) { - Bait.Init(Player, this); + Bait.Init(this); } if (Lure) { - Lure.Init(Player, this); + Lure.Init(this); } if (Weight) { - Weight.Init(Player, this); + Weight.Init(this); } yield return 1; //等待1帧 - transform.SetParent(Player.ModelAsset.RodRoot); + transform.SetParent(playerViewUnity.ModelAsset.RodRoot); transform.localPosition = Vector3.zero; - transform.rotation = Player.ModelAsset.RodRoot.rotation; + transform.rotation = playerViewUnity.ModelAsset.RodRoot.rotation; Usable = true; } @@ -281,7 +284,7 @@ namespace NBF Line = obj.GetComponent(); Line.transform.position = Asset.lineConnector.position; - Line.Init(this.Player, this); + Line.Init(this); // var obiSolver = solver.GetComponent(); // obiSolver.parameters.ambientWind = Vector3.zero; diff --git a/Assets/Scripts/UI/Login/LoginPanel.cs b/Assets/Scripts/UI/Login/LoginPanel.cs index 921a8af99..02752eafa 100644 --- a/Assets/Scripts/UI/Login/LoginPanel.cs +++ b/Assets/Scripts/UI/Login/LoginPanel.cs @@ -32,9 +32,9 @@ namespace NBF { await LoginHelper.Login(InputAccount.text); - // await Fishing.Instance.Go(RoleModel.Instance.Info.MapId); + await Fishing.Instance.Go(RoleModel.Instance.Info.MapId); - ChatTestPanel.Show(); + // ChatTestPanel.Show(); // FishingShopPanel.Show(); diff --git a/Packages/cn.tuanjie.codely.bridge/CHANGELOG.md b/Packages/cn.tuanjie.codely.bridge/CHANGELOG.md new file mode 100644 index 000000000..d46b4eb4a --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/CHANGELOG.md @@ -0,0 +1,512 @@ +# Changelog + +All notable changes to Codely Bridge will be documented in this file. + +## [1.0.23] - 2026-02-25 + +### Changed + +- **UI** + - Codely bridge 页面改版 + +## [1.0.22] - 2026-02-11 + +### Enhanced + +- **ExecuteCSharpScript** + - Added Unity.InputSystem assembly support for C# script execution + - Scripts can now access Input System types and APIs when the package is installed + +## [1.0.21] - 2026-02-06 + +### Changed + +- **Batch Operations Refactoring** + - Split generic `batch` action into two distinct operations: `create_batch` for write-only deterministic sequences and `edit_batch` for search-then-write edits + - Added `HandleCreateBatch` method for write-only deterministic batch operations + - Added `HandleEditBatch` method for search-then-write batch operations with captureAs support + - Improved code clarity and prevented mixed read/write batch states + - Maintained parity with TypeScript client schema + - Added backward compatibility aliases for snake_case to camelCase parameters + - Updated ValidActions list to include new batch operation types + - Updated writeActions array to include new batch operations for state validation + +### Enhanced + +- **ManageAsset** + - Enhanced batch operation handling with clearer separation of concerns + - Improved parameter naming consistency with backward compatibility support + +- **ManageGameObject** + - Enhanced batch operation handling with clearer separation of concerns + - Improved parameter naming consistency with backward compatibility support + +### Test Coverage + +- Updated `unity_asset_full_coverage.md` to reflect new batch operations +- Updated `unity_gameobject_full_coverage.md` to reflect new batch operations +- Updated `unity_workflow_full_coverage.md` with refined batch operation workflows +- Updated `Tests/Coverage/README.md` documentation + + +## [1.0.20] - 2026-02-05 + +### Fixed + +- **TCP Port Management on macOS** + - Disabled ReuseAddress socket option on macOS in PortManager.cs + + +## [1.0.19] - 2026-02-04 + +### Fixed + +- **TCP Port Management on macOS** + - Disabled ReuseAddress socket option on macOS to prevent multiple Unity instances from listening on the same port + - Ensures proper port exclusivity across Unity Editor instances + + +## [1.0.18] - 2026-02-04 + +### Fixed + +- **TCP Connection Reliability** + - Add LingerState to test listener to send RST on close (same as actual listener) + - Increase immediate retry attempts from 3 to 5 + - Increase retry sleep time from 75ms to 150ms + - Extend wait time on Windows from 100ms to 500ms to allow TCP port full release + + +## [1.0.17] - 2026-02-03 + +### Fixed + +- **Revert IPV6 Loopback Support** + + +## [1.0.16] - 2026-02-03 + +### Added + +- **C# Script Execution** + - New `ExecuteCSharpScript` tool for executing arbitrary C# code at runtime using Microsoft.CodeAnalysis.CSharp.Scripting (Roslyn compiler services) + - Captures and returns Unity console logs during script execution + - Supports custom using directives and assembly references + - Enables dynamic C# code execution without requiring editor restart or recompilation + - Added bundled Roslyn assemblies: `Codely.Microsoft.CodeAnalysis.dll`, `Codely.Microsoft.CodeAnalysis.CSharp.dll`, `Codely.Microsoft.CodeAnalysis.Scripting.dll`, `Codely.Microsoft.CodeAnalysis.CSharp.Scripting.dll` + - Added supporting assemblies: `Codely.System.Collections.Immutable.dll`, `Codely.System.Reflection.Metadata.dll`, `Codely.System.Runtime.CompilerServices.Unsafe.dll` + +### Fixed + +- **UnityTcpBridge** + - Reverted accidental handshake string change that broke package functionality (changed from incorrect 'WELCOME UNITY-TCP 1 FRAMING=1' back to correct 'WELCOME Codely-Bridge 1 FRAMING=1') +- **Add IPV6 Loopback Support** + +### Changed + +- **Branding & Menu Structure** + - Renamed "Unity TCP" to "Codely Bridge" across logging and port management + - Simplified menu structure: removed redundant menu items and kept only "Window/Codely Bridge/Control Window" + - Updated menu organization for better user experience + +- **CI/CD** + - Updated register_version job rules in CI pipeline + +## [1.0.15] - 2026-01-29 + +### Changed + +- **ManageScreenshot** + - Simplified screenshot API: removed redundant `capture_game_view` action; its behavior is now covered by the unified `capture` action + - `capture` action now consistently uses GameView reflection to capture what the user sees in both edit and play modes + - Removed redundant `FlipTextureVertically` call and related comments for clearer, more maintainable code + +- **Package Publishing** + - Updated `.npmignore` to include `Tests/` directory in the published npm package so consumers can run and extend the test suite + +### Fixed + +- **Screenshot Capture** + - Fixed vertical flip of textures captured from RenderTexture so screenshots match on-screen orientation + +### Test Coverage + +- Updated `unity_screenshot_full_coverage.md` and `Tests/Coverage/README.md` to reflect the simplified screenshot API and current test structure + +## [1.0.14] - 2026-01-22 + +### Changed + +- **Dependency Management** + - Swapped to bundled `Codely.Newtonsoft.Json.dll` in `Plugins/` directory instead of external `Newtonsoft.Json` package + - Removed external `Newtonsoft.Json` dependency from `package.json` + - Updated all code references to use bundled Newtonsoft.Json assembly + +- **Architecture** + - Moved runtime implementation code from `Runtime/` to `Editor/` scope to align with Unity usage patterns + +### Added + +- **Package Publishing** + - Added `.npmignore` file to exclude unwanted files from npm publication + - Excludes CI/CD configuration, build artifacts, Git metadata, IDE files, and test directories + - Ensures only essential code and documentation are published to npm registry + +- **CI/CD Pipeline** + - Added automated npm pack and TOS (Tencent Object Storage) upload steps to deployment pipelines + - Added backup version (1.0.0) upload for both staging and production environments + - Improved deployment reliability with fallback version availability + +## [1.0.13] - 2026-01-20 + +### Fixed + +- **Test Assembly Dependencies** + - Fixed missing `Newtonsoft.Json.dll` reference in `UnityTcp.Editor.Tests.asmdef` + - Added `com.unity.ext.nunit` package dependency to `package.json` for proper NUnit framework support + - Ensures test assembly can properly reference required dependencies for compilation + +## [1.0.12] - 2026-01-19 + +### Added + +- **ManageGameObject** + - Added `list_children` action for listing GameObject children with configurable depth + - Support for three result modes: `auto` (default), `inline`, and `file` + - Automatic fallback to file output when hierarchy exceeds `maxInlineItems` threshold (default: 200) + - Depth-limited traversal with `depth` parameter (default: 1 for direct children) + - `includeInactive` parameter to control whether inactive GameObjects are included + - Iterative tree building to avoid stack overflow on deep hierarchies + - JSON streaming to file for large results to prevent memory issues + - New helper methods: `CountDescendantsUpToDepth`, `BuildChildrenTreeIterative`, `WriteChildrenTreeIterative` + +### Enhanced + +- **ManageScene** + - Improved large scene hierarchy handling (>500 GameObjects) + - Returns shallow root-only tree with hints instead of error when scene is too large + - Changed `CountGameObjectsRecursive` to use iterative traversal (stack-based) to avoid stack overflow on deep hierarchies + - Better user guidance for drilling down into large hierarchies incrementally + +- **Coverage Tools** + - Added `CodelyUnityCoverageTools` class for E2E test utilities + - New `codely.generate_large_hierarchy` custom tool for quickly generating test hierarchies + - Configurable generation parameters: root name, child/grandchild prefixes, and counts + +### Test Coverage + +- Added `unity_large_hierarchy_e2e_coverage.md` with 152 lines of E2E test scenarios for large hierarchy handling +- Updated `unity_gameobject_full_coverage.md` with `list_children` action coverage +- Updated `unity_scene_full_coverage.md` with improved large scene handling coverage + +## [1.0.11] - 2026-01-13 + +### Added + +- **Tuanjie Editor Scene File Support** + - Added support for `.scene` file extension used by Tuanjie Editor + - Implemented extension-aware scene path handling in `ManageScene` + - Support both `.unity` (Unity Editor) and `.scene` (Tuanjie Editor) extensions based on editor type + +### Enhanced + +- **UnityStateDirtyHook** + - Added `.scene` extension detection for scene file change tracking + - Ensures proper state tracking for both Unity and Tuanjie editor scene files + +- **Documentation** + - Improved documentation for scene file extension handling + +## [1.0.10] - 2026-01-12 + +### Fixed + +- **ManageGameObject** + - Updated default behavior for `searchInactive` parameter from `false` to `true` + - Ensures inactive GameObjects are included in search results unless explicitly specified otherwise + +- **UnityEngineObjectConverter** + - Added support for `{"find":"...", "method":"..."}` reference format in object deserialization + - Fixed deserialization errors when encountering find instruction format used by MCP tools for dynamic GameObject lookups + - Implemented delegate pattern to call `ManageGameObject.FindObjectByInstruction` from Runtime assembly without direct Editor assembly reference + +## [1.0.9] - 2026-01-05 + +### Changed + +- **Unity Project Metadata** + - Updated Unity project metadata GUIDs across all .meta files + - Refreshed GUIDs in Editor, Runtime, and Tests directories + +## [1.0.8] - 2025-12-16 + +### Enhanced + +- **Compilation Tracking** + - Improved compilation error/warning count tracking with nullable integers + - Changed `CompilationHelper.GetCompilationErrors()` and `GetCompilationWarnings()` to return `int?` instead of `int` + - Updated `GetCompilationSummary()` to only include known values in result (removes misleading default 0 values) + - Enhanced compilation result handling in `ManageEditor` to properly process nullable values + - Improved type handling for compilation result payloads (supports both dictionary and anonymous object formats) + - Added clarifying comments explaining why returning 0 for unknown counts is problematic + - Better distinction between "0 errors/warnings" (validated) vs "unknown count" (not yet validated) + +## [1.0.7] - 2025-12-15 + +### Changed + +- **Package Renaming** + - Renamed package from `com.unity.codely` to `cn.tuanjie.codely.bridge` + - Updated all internal references and documentation to reflect new package name + +- **Branding Update** + - Renamed all menu item paths and labels in the Unity Editor + +## [1.0.6] - 2025-12-10 + +### Added + +- **Compilation Pipeline** + - New `pipeline_kind: "compile"` field for structured hints to downstream tools + - `requires_console_validation: true` flag to guide compilation validation + - Comprehensive integration test documentation for compilation pipeline policy + - Enhanced play mode state synchronization with `playMode` field in responses + +### Enhanced + +- **StateComposer** + - Simplified state reporting to focus on essential information (compiling vs idle) + - Minimized state complexity with clear documentation directing users to specific diagnostic tools + +- **ManageAsset** + - Improved `AssetExists` method with ghost asset detection + - New `BuildAssetNotFoundResponse` for better error messaging about desync issues + - Enhanced asset validation and error handling + +- **Test Coverage** + - Added `unity_compile_pipeline_integration.md` with 152 lines of comprehensive test scenarios + - Updated `unity_editor_full_coverage.md` with compilation pipeline requirements + +## [1.0.5] - 2025-12-04 + +### Added + +- **Test Coverage** + - Added comprehensive test coverage for `ExecuteCustomTool` functionality + - UI Toolkit tools test coverage documentation and validation + +### Enhanced + +- **ManageUIToolkit** + - Enhanced `link_uss_to_uxml` action with GUID support + - Added `ResolveAssetPath` helper method for flexible path/GUID resolution + - Improved parameter validation for mixed path/GUID usage + +- **ManageShader** + - Enhanced `ensure_material_shader_for_srp` action with `material_guid` parameter support + - Improved parameter handling for material identification via path or GUID + - Better error messages for missing material parameters + +- **CodelyUnityValidationTools** + - Added nested field path support in `validate_response` + - Support for dot-notation field paths (e.g., "state.project.srp", "project.srp") + - Automatic recursive field search when direct path fails + - Enhanced field validation with path tracking and debugging + +- **ManageBake** + - Refactored NavMesh operations to use runtime reflection + - Improved AI Navigation package detection and type resolution + - Better compatibility with optional AI Navigation package installation + - Enhanced error handling for missing package scenarios + +## [1.0.4] - 2025-12-03 + +### Added + +- **Validation Tools Framework** + - New `CodelyUnityValidationTools`: 15+ validation helpers for automated testing + - `codely.validate_play_mode`: Validate current editor PlayMode state + - `codely.validate_active_tool`: Validate current active editor tool + - `codely.validate_not_compiling`: Ensure editor is not compiling + - `codely.validate_tag_and_layer_exist`: Verify Tag/Layer existence + - `codely.validate_window_open`: Check editor window state + - `codely.validate_console_contains`: Validate console messages with filter + - `codely.validate_console_count`: Verify console message counts + - `codely.validate_active_scene`: Validate active scene properties + - `codely.validate_scene_dirty`: Check scene dirty state + - `codely.validate_hierarchy_root_count`: Verify hierarchy root object count + - `codely.validate_gameobject_exists`: Check GameObject existence + - `codely.validate_response`: Generic response validation + +- **Compilation Pipeline** + - `CompilationHelper`: New helper class for compilation status checking and error tracking + - `start_compilation_pipeline` action in ManageEditor for standardized compile workflow + - Block compilation during play mode to prevent editor errors + +- **Test Coverage Documentation** + - Complete test coverage specs for all Unity tools + - `unity_editor_full_coverage.md`: 24 actions coverage + - `unity_console_full_coverage.md`: Console operations coverage + - `unity_scene_full_coverage.md`: Scene management coverage + - `unity_gameobject_full_coverage.md`: GameObject operations coverage + - `unity_asset_full_coverage.md`: Asset management coverage + - `unity_script_full_coverage.md`: Script management coverage + - `unity_shader_full_coverage.md`: Shader operations coverage + - `unity_package_full_coverage.md`: Package manager coverage + - `unity_menu_full_coverage.md`: Menu execution coverage + +### Enhanced + +- **State Management** + - State delta tracking added to async operation responses + - Client state revision validation for all write operations + - Enhanced console state tracking with `since_token` filtering + +- **ManageEditor** + - Idempotent `ensure_tag` and `ensure_layer` operations + - Extended with compilation pipeline integration + +- **ManageAsset** + - Enhanced robustness with better error handling + +- **ManageGameObject** + - Improved serialization with `GameObjectSerializer` enhancements + +- **ReadConsole** + - Enhanced filtering with `since_token` support for incremental reads + +- **ExecuteCustomTool** + - Improved tool registry with better parameter validation + +### Fixed + +- Improved Unity version compatibility across various tools + +## [1.0.3] - 2025-12-01 + +### Added + +- **State Management System** + - `AsyncOperationTracker`: Comprehensive async operation management with progress tracking and cancellation support + - `StateComposer`: Full Unity state composition including scene, project, packages, and shaders + - `UnityStateDirtyHook`: Automatic tracking of Unity Editor state changes (hierarchy, project, selection, console) + - `WriteGuard`: Thread-safe write operation protection with main thread enforcement + - New `get_current_state` endpoint for retrieving complete Unity state snapshots + +- **New Unity Tools** + - `ManageBake`: Light baking controls (start, cancel, clear, status queries) + - `ManagePackage`: Package manager operations with version pinning support (package@version syntax) + - `ManageUIToolkit`: UI Toolkit template instantiation with automatic USS/C# generation + +- **Custom Tool Execution Framework** + - `ExecuteCustomTool`: Reflection-based tool discovery and execution via `[CustomTool]` attribute + - Automatic tool registry with parameter validation and error handling + - Support for custom tools without modifying CommandRegistry + +- **Enhanced Existing Tools** + - `ManageEditor`: Extended with state-aware operations and full state retrieval + - `ManageGameObject`: Added find, query, parent/child operations, and component management + - `ManageAsset`: New asset import, export, and metadata operations + - `ManageScene`: Enhanced with scene creation and multi-scene management + - `ManageShader`: Expanded with shader compilation, variant queries, and global property management + - `ReadConsole`: Added scope-based console clearing and entry filtering + +### Enhanced + +- **Response Helpers**: New state-aware methods (`SuccessWithDelta`, `SuccessWithState`, `Conflict`) for better change tracking +- **CompilationHelper**: Improved compilation workflow handling with better async integration +- **Test Coverage**: Added unit tests for `AsyncOperationTracker`, `StateComposer`, and `WriteGuard` + +## [1.0.2] - 2025-11-11 + +### Fixed + +- **Unity Version Compatibility**: Added conditional compilation in `ManageGameObject.cs` + - Uses `FindObjectsByType` with `FindObjectsInactive` enum for Unity 2022.2+ + - Falls back to `FindObjectsOfType` for Unity 2021.3 and earlier + - Resolves CS0246 error: `FindObjectsInactive` type not found on Unity 2021 + - Maintains backward compatibility across Unity versions + +## [1.0.1] - 2025-11-07 + +### Fixed + +- 🐛 **Fixed build compilation error** + - Corrected assembly definition configuration for `UnityTcp.Editor.asmdef` + - Changed from platform exclusion list to explicit Editor platform inclusion + - Ensures Editor assembly only compiles in Unity Editor, not in game builds + - Resolves compilation errors during game packaging for all platforms + +## [1.0.0] - 2024-12-19 + +### Major Refactoring + +- 🔄 **Complete removal of MCP (Model Context Protocol) logic** + - Removed all MCP-specific components, tools, and protocol handling + - Eliminated MCP server integration and HTTP server components + - Removed MCP client models, configuration systems, and UI windows + +### New TCP-Focused Architecture + +- 🚀 **Pure TCP Socket Implementation** + - New `UnityTcpBridge` class for TCP server management + - Basic echo server implementation as starting point + - Async/await patterns for non-blocking operations + - Multi-client connection support with proper resource management + +### Core TCP Features + +- **Port Management** + - Automatic port discovery and allocation + - Project-specific port persistence + - Smart port conflict resolution + - Cross-platform compatibility + +- **Connection Handling** + - TCP listener with automatic client acceptance + - Configurable socket options (keep-alive, timeouts) + - Graceful connection cleanup on shutdown + - Unity lifecycle integration (assembly reload, editor quit) + +### Updated Components + +- **Renamed Assemblies**: `UnityTcp.*` → `UnityTcp.*` +- **Updated Namespaces**: All classes moved to `UnityTcp.Editor.*` namespace +- **Simplified Helpers**: Kept only TCP-relevant utilities (PortManager, TcpLog) +- **Package Rebranding**: Updated from "Unity MCP" to "Unity TCP Bridge" + +### Removed Components + +- All MCP protocol handling and message processing +- MCP tool implementations (ManageScript, ManageAsset, etc.) +- MCP UI windows and editor integrations +- HTTP server and MCP server management +- Telemetry and MCP-specific logging +- Configuration builders and MCP client models + +### Technical Details + +- **Architecture**: Direct TCP socket server with customizable protocol handling +- **Performance**: Lightweight implementation focused on TCP networking +- **Compatibility**: Unity 2021.3+ with Newtonsoft.Json dependency +- **Protocol**: Basic TCP with welcome handshake (easily customizable) + +### Migration Guide + +This is a breaking change that removes all MCP functionality: + +1. **Previous MCP Users**: This package no longer provides MCP integration +2. **TCP Socket Users**: Replace any `UnityTcpBridge` references with `UnityTcpBridge` +3. **Custom Protocols**: Implement your protocol logic in `HandleClientAsync` method +4. **Port Management**: Use `PortManager` for dynamic port allocation needs + +### Development Notes + +- Codebase reduced by ~80% by removing MCP complexity +- Focus shifted to providing a clean TCP socket foundation +- Easy to extend for custom networking protocols +- Maintains Unity Editor integration for automatic lifecycle management + +## Previous Versions + +Previous versions (1.x.x) included MCP (Model Context Protocol) integration which has been completely removed in this version. diff --git a/Packages/cn.tuanjie.codely.bridge/CHANGELOG.md.meta b/Packages/cn.tuanjie.codely.bridge/CHANGELOG.md.meta new file mode 100644 index 000000000..02cf85e20 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/CHANGELOG.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 4cb996b11fa557143a68a6fdb4f0cb76 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor.meta b/Packages/cn.tuanjie.codely.bridge/Editor.meta new file mode 100644 index 000000000..92690a93d --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ff37f8f7a26ddcf4a8ec576dd133285c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/AssemblyInfo.cs b/Packages/cn.tuanjie.codely.bridge/Editor/AssemblyInfo.cs new file mode 100644 index 000000000..8f002cf8a --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UnityTcpTests.EditMode")] diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/AssemblyInfo.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/AssemblyInfo.cs.meta new file mode 100644 index 000000000..62f8bb82f --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7083f648bddd50d41afc42cc3deb2577 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers.meta new file mode 100644 index 000000000..551b80c7f --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 626ae8824a7df62489152ef051a0e2a9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/AsyncOperationTracker.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/AsyncOperationTracker.cs new file mode 100644 index 000000000..9f07fc3d3 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/AsyncOperationTracker.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace UnityTcp.Editor.Helpers +{ + /// + /// Manages long-running asynchronous operation states (compilation, UPM, baking, etc.) + /// Provides operation ID generation, status tracking, and timeout management. + /// + public static class AsyncOperationTracker + { + /// + /// Job status enum matching MCP protocol. + /// + public enum JobStatus + { + Pending, + Complete, + Error + } + + /// + /// Job type enum for categorizing operations. + /// + public enum JobType + { + Compilation, + UpmPackage, + NavMeshBake, + LightingBake, + Custom + } + + /// + /// Represents a tracked job/operation. + /// + public class Job + { + public string OpId { get; set; } + public JobType Type { get; set; } + public JobStatus Status { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? CompletedAt { get; set; } + public string Message { get; set; } + public object Data { get; set; } + public string ErrorMessage { get; set; } + public float Progress { get; set; } // 0.0 to 1.0 + } + + // Job storage + private static readonly Dictionary _jobs = new Dictionary(); + private static readonly object _jobsLock = new object(); + + // Default timeout in seconds + private const int DefaultTimeoutSeconds = 300; + + /// + /// Creates a new job with a unique op_id and registers it. + /// + public static Job CreateJob(JobType type, string message = null) + { + var job = new Job + { + OpId = GenerateOpId(), + Type = type, + Status = JobStatus.Pending, + CreatedAt = DateTime.UtcNow, + Message = message ?? $"{type} operation started", + Progress = 0.0f + }; + + lock (_jobsLock) + { + _jobs[job.OpId] = job; + } + + return job; + } + + /// + /// Gets a job by op_id. + /// + public static Job GetJob(string opId) + { + lock (_jobsLock) + { + return _jobs.TryGetValue(opId, out var job) ? job : null; + } + } + + /// + /// Updates job status to Complete. + /// + public static void CompleteJob(string opId, string message = null, object data = null) + { + lock (_jobsLock) + { + if (_jobs.TryGetValue(opId, out var job)) + { + job.Status = JobStatus.Complete; + job.CompletedAt = DateTime.UtcNow; + job.Progress = 1.0f; + if (message != null) job.Message = message; + if (data != null) job.Data = data; + } + } + } + + /// + /// Updates job status to Error. + /// + public static void FailJob(string opId, string errorMessage) + { + lock (_jobsLock) + { + if (_jobs.TryGetValue(opId, out var job)) + { + job.Status = JobStatus.Error; + job.CompletedAt = DateTime.UtcNow; + job.ErrorMessage = errorMessage; + job.Message = $"Operation failed: {errorMessage}"; + } + } + } + + /// + /// Updates job progress (0.0 to 1.0). + /// + public static void UpdateProgress(string opId, float progress, string message = null) + { + lock (_jobsLock) + { + if (_jobs.TryGetValue(opId, out var job)) + { + job.Progress = Mathf.Clamp01(progress); + if (message != null) job.Message = message; + } + } + } + + /// + /// Removes a job from tracking. + /// + public static void RemoveJob(string opId) + { + lock (_jobsLock) + { + _jobs.Remove(opId); + } + } + + /// + /// Gets all pending jobs of a specific type. + /// + public static List GetPendingJobs(JobType? type = null) + { + lock (_jobsLock) + { + return _jobs.Values + .Where(j => j.Status == JobStatus.Pending && (!type.HasValue || j.Type == type.Value)) + .ToList(); + } + } + + /// + /// Cleans up old jobs that have been completed or timed out. + /// Should be called periodically. + /// + public static void CleanupOldJobs(int maxAgeSeconds = 3600) + { + var cutoff = DateTime.UtcNow.AddSeconds(-maxAgeSeconds); + + lock (_jobsLock) + { + var toRemove = _jobs + .Where(kv => kv.Value.CompletedAt.HasValue && kv.Value.CompletedAt.Value < cutoff) + .Select(kv => kv.Key) + .ToList(); + + foreach (var opId in toRemove) + { + _jobs.Remove(opId); + } + + if (toRemove.Count > 0) + { + Debug.Log($"[AsyncOperationTracker] Cleaned up {toRemove.Count} old jobs"); + } + } + } + + /// + /// Checks if a job has timed out. + /// + public static bool IsJobTimedOut(string opId, int timeoutSeconds = DefaultTimeoutSeconds) + { + lock (_jobsLock) + { + if (_jobs.TryGetValue(opId, out var job)) + { + if (job.Status == JobStatus.Pending) + { + var elapsed = (DateTime.UtcNow - job.CreatedAt).TotalSeconds; + return elapsed > timeoutSeconds; + } + } + } + return false; + } + + /// + /// Creates a Pending async operation response for a job. + /// Protocol: status/poll_interval/op_id + /// + public static object CreatePendingResponse(Job job, object stateDelta = null) + { + var response = new Dictionary + { + ["status"] = "pending", + ["poll_interval"] = 1.0, // Poll every 1 second + ["op_id"] = job.OpId, + ["success"] = true, + ["message"] = job.Message, + ["data"] = new + { + type = job.Type.ToString(), + progress = job.Progress, + createdAt = job.CreatedAt.ToString("o") + } + }; + + // Add operations state delta showing the new pending operation + var opDelta = StateComposer.CreateOperationsDelta(new[] { + new { id = job.OpId, type = job.Type.ToString(), progress = job.Progress, message = job.Message } + }); + response["state_delta"] = stateDelta != null + ? StateComposer.MergeStateDeltas(opDelta, stateDelta) + : opDelta; + + return response; + } + + /// + /// Creates a Complete async operation response for a job. + /// Protocol: status/op_id + /// + public static object CreateCompleteResponse(Job job, object stateDelta = null) + { + var response = new Dictionary + { + ["status"] = "complete", + ["op_id"] = job.OpId, + ["success"] = true, + ["message"] = job.Message, + ["data"] = job.Data + }; + + // Include state_delta if provided + if (stateDelta != null) + { + response["state_delta"] = stateDelta; + } + + return response; + } + + /// + /// Creates an Error async operation response for a job. + /// Protocol: status/op_id + /// + public static object CreateErrorResponse(Job job, object stateDelta = null) + { + var response = new Dictionary + { + ["status"] = "error", + ["op_id"] = job.OpId, + ["success"] = false, + ["message"] = job.Message, + ["error"] = job.ErrorMessage + }; + + // Include state_delta if provided + if (stateDelta != null) + { + response["state_delta"] = stateDelta; + } + + return response; + } + + /// + /// Generates a unique operation ID. + /// + private static string GenerateOpId() + { + return Guid.NewGuid().ToString("N"); + } + + /// + /// Gets count of all jobs by status. + /// + public static Dictionary GetJobCounts() + { + lock (_jobsLock) + { + return _jobs.Values + .GroupBy(j => j.Status) + .ToDictionary(g => g.Key, g => g.Count()); + } + } + } +} + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/AsyncOperationTracker.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/AsyncOperationTracker.cs.meta new file mode 100644 index 000000000..e3f5b324b --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/AsyncOperationTracker.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ccdab4cb337ac174395ef110bfa8f3b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/BinaryFrameHelper.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/BinaryFrameHelper.cs new file mode 100644 index 000000000..6a2d67f3c --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/BinaryFrameHelper.cs @@ -0,0 +1,157 @@ +using System; +using System.IO; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace UnityTcp.Editor.Helpers +{ + /// + /// Helper class for handling binary framed TCP communication + /// + public static class BinaryFrameHelper + { + private const ulong MaxFrameBytes = 64UL * 1024 * 1024; // 64 MiB hard cap for framed payloads + private const int FrameIOTimeoutMs = 30000; // Per-read timeout to avoid stalled clients + + /// + /// Timeout-aware exact read helper with cancellation; avoids indefinite stalls and background task leaks + /// + public static async Task ReadExactAsync(NetworkStream stream, int count, int timeoutMs, CancellationToken cancel = default) + { + byte[] buffer = new byte[count]; + int offset = 0; + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + while (offset < count) + { + int remaining = count - offset; + int remainingTimeout = timeoutMs <= 0 + ? Timeout.Infinite + : timeoutMs - (int)stopwatch.ElapsedMilliseconds; + + // If a finite timeout is configured and already elapsed, fail immediately + if (remainingTimeout != Timeout.Infinite && remainingTimeout <= 0) + { + throw new System.IO.IOException("Read timed out"); + } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancel); + if (remainingTimeout != Timeout.Infinite) + { + cts.CancelAfter(remainingTimeout); + } + + try + { +#if NETSTANDARD2_1 || NET6_0_OR_GREATER + int read = await stream.ReadAsync(buffer.AsMemory(offset, remaining), cts.Token).ConfigureAwait(false); +#else + int read = await stream.ReadAsync(buffer, offset, remaining, cts.Token).ConfigureAwait(false); +#endif + if (read == 0) + { + throw new System.IO.IOException("Connection closed before reading expected bytes"); + } + offset += read; + } + catch (OperationCanceledException) when (!cancel.IsCancellationRequested) + { + throw new System.IO.IOException("Read timed out"); + } + } + + return buffer; + } + + /// + /// Write a framed payload to the stream with default timeout + /// + public static async Task WriteFrameAsync(NetworkStream stream, byte[] payload) + { + using var cts = new CancellationTokenSource(FrameIOTimeoutMs); + await WriteFrameAsync(stream, payload, cts.Token); + } + + /// + /// Write a framed payload to the stream with cancellation support + /// + public static async Task WriteFrameAsync(NetworkStream stream, byte[] payload, CancellationToken cancel) + { + if (payload == null) + { + throw new System.ArgumentNullException(nameof(payload)); + } + if ((ulong)payload.LongLength > MaxFrameBytes) + { + throw new System.IO.IOException($"Frame too large: {payload.LongLength}"); + } + byte[] header = new byte[8]; + WriteUInt64BigEndian(header, (ulong)payload.LongLength); +#if NETSTANDARD2_1 || NET6_0_OR_GREATER + await stream.WriteAsync(header.AsMemory(0, header.Length), cancel).ConfigureAwait(false); + await stream.WriteAsync(payload.AsMemory(0, payload.Length), cancel).ConfigureAwait(false); +#else + await stream.WriteAsync(header, 0, header.Length, cancel).ConfigureAwait(false); + await stream.WriteAsync(payload, 0, payload.Length, cancel).ConfigureAwait(false); +#endif + } + + /// + /// Read a framed UTF-8 string from the stream + /// + public static async Task ReadFrameAsUtf8Async(NetworkStream stream, int timeoutMs, CancellationToken cancel) + { + byte[] header = await ReadExactAsync(stream, 8, timeoutMs, cancel).ConfigureAwait(false); + ulong payloadLen = ReadUInt64BigEndian(header); + if (payloadLen > MaxFrameBytes) + { + throw new System.IO.IOException($"Invalid framed length: {payloadLen}"); + } + if (payloadLen == 0UL) + throw new System.IO.IOException("Zero-length frames are not allowed"); + if (payloadLen > int.MaxValue) + { + throw new System.IO.IOException("Frame too large for buffer"); + } + int count = (int)payloadLen; + byte[] payload = await ReadExactAsync(stream, count, timeoutMs, cancel).ConfigureAwait(false); + return System.Text.Encoding.UTF8.GetString(payload); + } + + /// + /// Read a UInt64 from a byte array in big-endian format + /// + public static ulong ReadUInt64BigEndian(byte[] buffer) + { + if (buffer == null || buffer.Length < 8) return 0UL; + return ((ulong)buffer[0] << 56) + | ((ulong)buffer[1] << 48) + | ((ulong)buffer[2] << 40) + | ((ulong)buffer[3] << 32) + | ((ulong)buffer[4] << 24) + | ((ulong)buffer[5] << 16) + | ((ulong)buffer[6] << 8) + | buffer[7]; + } + + /// + /// Write a UInt64 to a byte array in big-endian format + /// + public static void WriteUInt64BigEndian(byte[] dest, ulong value) + { + if (dest == null || dest.Length < 8) + { + throw new System.ArgumentException("Destination buffer too small for UInt64"); + } + dest[0] = (byte)(value >> 56); + dest[1] = (byte)(value >> 48); + dest[2] = (byte)(value >> 40); + dest[3] = (byte)(value >> 32); + dest[4] = (byte)(value >> 24); + dest[5] = (byte)(value >> 16); + dest[6] = (byte)(value >> 8); + dest[7] = (byte)(value); + } + } +} diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/BinaryFrameHelper.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/BinaryFrameHelper.cs.meta new file mode 100644 index 000000000..c99221ad7 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/BinaryFrameHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9addeba67f678854eada157f672b975d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/CompilationHelper.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/CompilationHelper.cs new file mode 100644 index 000000000..158d62503 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/CompilationHelper.cs @@ -0,0 +1,232 @@ +using UnityEditor; +using UnityEngine; + +namespace UnityTcp.Editor.Helpers +{ + /// + /// Helper class for Unity compilation status checking and error tracking + /// + public static class CompilationHelper + { + // Track last known compilation error/warning counts + // IMPORTANT: Keep these nullable. Returning 0 when counts are unknown is misleading + // (it can be interpreted as "validated: no errors/warnings"). + private static int? _lastErrorCount = null; + private static int? _lastWarningCount = null; + private static bool _trackingInitialized = false; + + /// + /// Helper to check compilation status across Unity versions + /// + public static bool IsCompiling() + { + if (EditorApplication.isCompiling) + { + return true; + } + try + { + System.Type pipeline = System.Type.GetType("UnityEditor.Compilation.CompilationPipeline, UnityEditor"); + var prop = pipeline?.GetProperty("isCompiling", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + if (prop != null) + { + return (bool)prop.GetValue(null); + } + } + catch { } + return false; + } + + /// + /// Gets the count of compilation errors from the console. + /// This is an approximation based on console log entries. + /// + public static int? GetCompilationErrors() + { + try + { + // Try to get error count from LogEntries (internal API) + var logEntriesType = typeof(EditorApplication).Assembly.GetType("UnityEditor.LogEntries"); + if (logEntriesType != null) + { + var getCountMethod = logEntriesType.GetMethod( + "GetCount", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic + ); + + // Get count with error filter (mode = 1 for errors) + var getCountByTypeMethod = logEntriesType.GetMethod( + "GetCountsByType", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic + ); + + if (getCountByTypeMethod != null) + { + // GetCountsByType returns counts for errors, warnings, logs + var counts = new int[3]; + getCountByTypeMethod.Invoke(null, new object[] { counts }); + _lastErrorCount = counts[0]; // Errors + return _lastErrorCount; + } + } + } + catch (System.Exception e) + { + Debug.LogWarning($"[CompilationHelper] Failed to get error count: {e.Message}"); + } + + return _lastErrorCount; + } + + /// + /// Gets the count of compilation warnings from the console. + /// This is an approximation based on console log entries. + /// + public static int? GetCompilationWarnings() + { + try + { + // Try to get warning count from LogEntries (internal API) + var logEntriesType = typeof(EditorApplication).Assembly.GetType("UnityEditor.LogEntries"); + if (logEntriesType != null) + { + var getCountByTypeMethod = logEntriesType.GetMethod( + "GetCountsByType", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic + ); + + if (getCountByTypeMethod != null) + { + // GetCountsByType returns counts for errors, warnings, logs + var counts = new int[3]; + getCountByTypeMethod.Invoke(null, new object[] { counts }); + _lastWarningCount = counts[1]; // Warnings + return _lastWarningCount; + } + } + } + catch (System.Exception e) + { + Debug.LogWarning($"[CompilationHelper] Failed to get warning count: {e.Message}"); + } + + return _lastWarningCount; + } + + /// + /// Resets tracked error/warning counts. + /// Should be called before starting a new compilation. + /// + public static void ResetCounts() + { + _lastErrorCount = null; + _lastWarningCount = null; + } + + /// + /// Starts a standard compilation pipeline: + /// 1. Clears console and gets since_token + /// 2. Requests compilation + /// 3. Returns pending response with token for later log reading + /// + /// This is the recommended pattern after any script modification. + /// + public static object StartCompilationPipeline() + { + try + { + // Step 1: Clear console and get since_token + var clearMethod = typeof(UnityTcp.Editor.Tools.ReadConsole).GetMethod( + "HandleCommand", + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public + ); + + string sinceToken = null; + if (clearMethod != null) + { + var clearParams = new Codely.Newtonsoft.Json.Linq.JObject + { + ["action"] = "clear" + }; + var clearResult = clearMethod.Invoke(null, new object[] { clearParams }); + + // Extract since_token from result + if (clearResult != null) + { + var resultType = clearResult.GetType(); + var dataProp = resultType.GetProperty("data"); + if (dataProp != null) + { + var data = dataProp.GetValue(clearResult); + if (data != null) + { + var tokenProp = data.GetType().GetProperty("sinceToken"); + sinceToken = tokenProp?.GetValue(data)?.ToString(); + } + } + } + } + + // Fallback: get token from StateComposer + if (string.IsNullOrEmpty(sinceToken)) + { + sinceToken = StateComposer.GetCurrentConsoleToken(); + } + + // Step 2: Reset error counts + ResetCounts(); + + // Step 3: Create compilation job + var job = AsyncOperationTracker.CreateJob( + AsyncOperationTracker.JobType.Compilation, + "Script compilation pipeline started" + ); + + // Step 4: Request compilation + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); + + // Step 5: Return pending response with token and structured pipeline hints + var response = AsyncOperationTracker.CreatePendingResponse(job) as System.Collections.Generic.Dictionary; + if (response != null) + { + response["since_token"] = sinceToken; + response["pipeline"] = new + { + step = "compiling", + sinceToken = sinceToken + }; + response["pipeline_kind"] = "compile"; + response["requires_console_validation"] = true; + } + + return response ?? AsyncOperationTracker.CreatePendingResponse(job); + } + catch (System.Exception e) + { + Debug.LogError($"[CompilationHelper] StartCompilationPipeline failed: {e}"); + return Response.Error($"Failed to start compilation pipeline: {e.Message}"); + } + } + + /// + /// Gets a summary of the last compilation result. + /// + public static object GetCompilationSummary() + { + var errors = GetCompilationErrors(); + var warnings = GetCompilationWarnings(); + + // Only include fields that are actually known; returning 0 is misleading. + var result = new System.Collections.Generic.Dictionary + { + ["isCompiling"] = IsCompiling() + }; + + if (errors.HasValue) result["errors"] = errors.Value; + if (warnings.HasValue) result["warnings"] = warnings.Value; + if (errors.HasValue) result["success"] = errors.Value == 0; + + return result; + } + } +} diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/CompilationHelper.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/CompilationHelper.cs.meta new file mode 100644 index 000000000..01e5f2ec3 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/CompilationHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 96c791cc231905b4ca2231ba84bc1f4f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/ExecPath.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/ExecPath.cs new file mode 100644 index 000000000..42005897d --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/ExecPath.cs @@ -0,0 +1,274 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Runtime.InteropServices; +using UnityEditor; + +namespace UnityTcp.Editor.Helpers +{ + internal static class ExecPath + { + private const string PrefClaude = "UnityTcp.ClaudeCliPath"; + + // Resolve Claude CLI absolute path. Pref → env → common locations → PATH. + internal static string ResolveClaude() + { + try + { + string pref = EditorPrefs.GetString(PrefClaude, string.Empty); + if (!string.IsNullOrEmpty(pref) && File.Exists(pref)) return pref; + } + catch { } + + string env = Environment.GetEnvironmentVariable("CLAUDE_CLI"); + if (!string.IsNullOrEmpty(env) && File.Exists(env)) return env; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + Path.Combine(home, ".local", "bin", "claude"), + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } + // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude + string nvmClaude = ResolveClaudeFromNvm(home); + if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude; +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + return Which("claude", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"); +#else + return null; +#endif + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { +#if UNITY_EDITOR_WIN + // Common npm global locations + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) ?? string.Empty; + string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData) ?? string.Empty; + string[] candidates = + { + // Prefer .cmd (most reliable from non-interactive processes) + Path.Combine(appData, "npm", "claude.cmd"), + Path.Combine(localAppData, "npm", "claude.cmd"), + // Fall back to PowerShell shim if only .ps1 is present + Path.Combine(appData, "npm", "claude.ps1"), + Path.Combine(localAppData, "npm", "claude.ps1"), + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } + string fromWhere = Where("claude.exe") ?? Where("claude.cmd") ?? Where("claude.ps1") ?? Where("claude"); + if (!string.IsNullOrEmpty(fromWhere)) return fromWhere; +#endif + return null; + } + + // Linux + { + string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) ?? string.Empty; + string[] candidates = + { + "/usr/local/bin/claude", + "/usr/bin/claude", + Path.Combine(home, ".local", "bin", "claude"), + }; + foreach (string c in candidates) { if (File.Exists(c)) return c; } + // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude + string nvmClaude = ResolveClaudeFromNvm(home); + if (!string.IsNullOrEmpty(nvmClaude)) return nvmClaude; +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + return Which("claude", "/usr/local/bin:/usr/bin:/bin"); +#else + return null; +#endif + } + } + + // Attempt to resolve claude from NVM-managed Node installations, choosing the newest version + private static string ResolveClaudeFromNvm(string home) + { + try + { + if (string.IsNullOrEmpty(home)) return null; + string nvmNodeDir = Path.Combine(home, ".nvm", "versions", "node"); + if (!Directory.Exists(nvmNodeDir)) return null; + + string bestPath = null; + Version bestVersion = null; + foreach (string versionDir in Directory.EnumerateDirectories(nvmNodeDir)) + { + string name = Path.GetFileName(versionDir); + if (string.IsNullOrEmpty(name)) continue; + if (name.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { + // Extract numeric portion: e.g., v18.19.0-nightly -> 18.19.0 + string versionStr = name.Substring(1); + int dashIndex = versionStr.IndexOf('-'); + if (dashIndex > 0) + { + versionStr = versionStr.Substring(0, dashIndex); + } + if (Version.TryParse(versionStr, out Version parsed)) + { + string candidate = Path.Combine(versionDir, "bin", "claude"); + if (File.Exists(candidate)) + { + if (bestVersion == null || parsed > bestVersion) + { + bestVersion = parsed; + bestPath = candidate; + } + } + } + } + } + return bestPath; + } + catch { return null; } + } + + // Explicitly set the Claude CLI absolute path override in EditorPrefs + internal static void SetClaudeCliPath(string absolutePath) + { + try + { + if (!string.IsNullOrEmpty(absolutePath) && File.Exists(absolutePath)) + { + EditorPrefs.SetString(PrefClaude, absolutePath); + } + } + catch { } + } + + // Clear any previously set Claude CLI override path + internal static void ClearClaudeCliPath() + { + try + { + if (EditorPrefs.HasKey(PrefClaude)) + { + EditorPrefs.DeleteKey(PrefClaude); + } + } + catch { } + } + + internal static bool TryRun( + string file, + string args, + string workingDir, + out string stdout, + out string stderr, + int timeoutMs = 15000, + string extraPathPrepend = null) + { + stdout = string.Empty; + stderr = string.Empty; + try + { + // Handle PowerShell scripts on Windows by invoking through powershell.exe + bool isPs1 = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + file.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase); + + var psi = new ProcessStartInfo + { + FileName = isPs1 ? "powershell.exe" : file, + Arguments = isPs1 + ? $"-NoProfile -ExecutionPolicy Bypass -File \"{file}\" {args}".Trim() + : args, + WorkingDirectory = string.IsNullOrEmpty(workingDir) ? Environment.CurrentDirectory : workingDir, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + if (!string.IsNullOrEmpty(extraPathPrepend)) + { + string currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(currentPath) + ? extraPathPrepend + : (extraPathPrepend + System.IO.Path.PathSeparator + currentPath); + } + + using var process = new Process { StartInfo = psi, EnableRaisingEvents = false }; + + var so = new StringBuilder(); + var se = new StringBuilder(); + process.OutputDataReceived += (_, e) => { if (e.Data != null) so.AppendLine(e.Data); }; + process.ErrorDataReceived += (_, e) => { if (e.Data != null) se.AppendLine(e.Data); }; + + if (!process.Start()) return false; + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (!process.WaitForExit(timeoutMs)) + { + try { process.Kill(); } catch { } + return false; + } + + // Ensure async buffers are flushed + process.WaitForExit(); + + stdout = so.ToString(); + stderr = se.ToString(); + return process.ExitCode == 0; + } + catch + { + return false; + } + } + +#if UNITY_EDITOR_OSX || UNITY_EDITOR_LINUX + private static string Which(string exe, string prependPath) + { + try + { + var psi = new ProcessStartInfo("/usr/bin/which", exe) + { + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + string path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + psi.EnvironmentVariables["PATH"] = string.IsNullOrEmpty(path) ? prependPath : (prependPath + Path.PathSeparator + path); + using var p = Process.Start(psi); + string output = p?.StandardOutput.ReadToEnd().Trim(); + p?.WaitForExit(1500); + return (!string.IsNullOrEmpty(output) && File.Exists(output)) ? output : null; + } + catch { return null; } + } +#endif + +#if UNITY_EDITOR_WIN + private static string Where(string exe) + { + try + { + var psi = new ProcessStartInfo("where", exe) + { + UseShellExecute = false, + RedirectStandardOutput = true, + CreateNoWindow = true, + }; + using var p = Process.Start(psi); + string first = p?.StandardOutput.ReadToEnd() + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + p?.WaitForExit(1500); + return (!string.IsNullOrEmpty(first) && File.Exists(first)) ? first : null; + } + catch { return null; } + } +#endif + } +} + + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/ExecPath.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/ExecPath.cs.meta new file mode 100644 index 000000000..0196a2be9 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/ExecPath.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a4a955ce7b85e184597177720c6d46b3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/GameObjectSerializer.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/GameObjectSerializer.cs new file mode 100644 index 000000000..ea6e03624 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/GameObjectSerializer.cs @@ -0,0 +1,536 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Codely.Newtonsoft.Json; +using Codely.Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityTcp.Editor.Serialization; // For Converters + +namespace UnityTcp.Editor.Helpers +{ + /// + /// Handles serialization of GameObjects and Components for MCP responses. + /// Includes reflection helpers and caching for performance. + /// + public static class GameObjectSerializer + { + // --- Data Serialization --- + + /// + /// Creates a serializable representation of a GameObject. + /// + public static object GetGameObjectData(GameObject go) + { + if (go == null) + return null; + return new + { + name = go.name, + instanceID = go.GetInstanceID(), + tag = go.tag, + layer = go.layer, + activeSelf = go.activeSelf, + activeInHierarchy = go.activeInHierarchy, + isStatic = go.isStatic, + scenePath = go.scene.path, // Identify which scene it belongs to + transform = new // Serialize transform components carefully to avoid JSON issues + { + // Serialize Vector3 components individually to prevent self-referencing loops. + // The default serializer can struggle with properties like Vector3.normalized. + position = new + { + x = go.transform.position.x, + y = go.transform.position.y, + z = go.transform.position.z, + }, + localPosition = new + { + x = go.transform.localPosition.x, + y = go.transform.localPosition.y, + z = go.transform.localPosition.z, + }, + rotation = new + { + x = go.transform.rotation.eulerAngles.x, + y = go.transform.rotation.eulerAngles.y, + z = go.transform.rotation.eulerAngles.z, + }, + localRotation = new + { + x = go.transform.localRotation.eulerAngles.x, + y = go.transform.localRotation.eulerAngles.y, + z = go.transform.localRotation.eulerAngles.z, + }, + scale = new + { + x = go.transform.localScale.x, + y = go.transform.localScale.y, + z = go.transform.localScale.z, + }, + forward = new + { + x = go.transform.forward.x, + y = go.transform.forward.y, + z = go.transform.forward.z, + }, + up = new + { + x = go.transform.up.x, + y = go.transform.up.y, + z = go.transform.up.z, + }, + right = new + { + x = go.transform.right.x, + y = go.transform.right.y, + z = go.transform.right.z, + }, + }, + parentInstanceID = go.transform.parent?.gameObject.GetInstanceID() ?? 0, // 0 if no parent + // Optionally include components, but can be large + // components = go.GetComponents().Select(c => GetComponentData(c)).ToList() + // Or just component names: + componentNames = go.GetComponents() + .Select(c => c.GetType().FullName) + .ToList(), + }; + } + + // --- Metadata Caching for Reflection --- + private class CachedMetadata + { + public readonly List SerializableProperties; + public readonly List SerializableFields; + + public CachedMetadata(List properties, List fields) + { + SerializableProperties = properties; + SerializableFields = fields; + } + } + // Key becomes Tuple + private static readonly Dictionary, CachedMetadata> _metadataCache = new Dictionary, CachedMetadata>(); + // --- End Metadata Caching --- + + /// + /// Creates a serializable representation of a Component, attempting to serialize + /// public properties and fields using reflection, with caching and control over non-public fields. + /// + // Add the flag parameter here + public static object GetComponentData(Component c, bool includeNonPublicSerializedFields = true) + { + // --- Add Early Logging --- + // Debug.Log($"[GetComponentData] Starting for component: {c?.GetType()?.FullName ?? "null"} (ID: {c?.GetInstanceID() ?? 0})"); + // --- End Early Logging --- + + if (c == null) return null; + Type componentType = c.GetType(); + + // --- Special handling for Transform to avoid reflection crashes and problematic properties --- + if (componentType == typeof(Transform)) + { + Transform tr = c as Transform; + // Debug.Log($"[GetComponentData] Manually serializing Transform (ID: {tr.GetInstanceID()})"); + return new Dictionary + { + { "typeName", componentType.FullName }, + { "instanceID", tr.GetInstanceID() }, + // Manually extract known-safe properties. Avoid Quaternion 'rotation' and 'lossyScale'. + { "position", CreateTokenFromValue(tr.position, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "localPosition", CreateTokenFromValue(tr.localPosition, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "eulerAngles", CreateTokenFromValue(tr.eulerAngles, typeof(Vector3))?.ToObject() ?? new JObject() }, // Use Euler angles + { "localEulerAngles", CreateTokenFromValue(tr.localEulerAngles, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "localScale", CreateTokenFromValue(tr.localScale, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "right", CreateTokenFromValue(tr.right, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "up", CreateTokenFromValue(tr.up, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "forward", CreateTokenFromValue(tr.forward, typeof(Vector3))?.ToObject() ?? new JObject() }, + { "parentInstanceID", tr.parent?.gameObject.GetInstanceID() ?? 0 }, + { "rootInstanceID", tr.root?.gameObject.GetInstanceID() ?? 0 }, + { "childCount", tr.childCount }, + // Include standard Object/Component properties + { "name", tr.name }, + { "tag", tr.tag }, + { "gameObjectInstanceID", tr.gameObject?.GetInstanceID() ?? 0 } + }; + } + // --- End Special handling for Transform --- + + // --- Special handling for Camera to avoid matrix-related crashes --- + if (componentType == typeof(Camera)) + { + Camera cam = c as Camera; + var cameraProperties = new Dictionary(); + + // List of safe properties to serialize + var safeProperties = new Dictionary> + { + { "nearClipPlane", () => cam.nearClipPlane }, + { "farClipPlane", () => cam.farClipPlane }, + { "fieldOfView", () => cam.fieldOfView }, + { "renderingPath", () => (int)cam.renderingPath }, + { "actualRenderingPath", () => (int)cam.actualRenderingPath }, + { "allowHDR", () => cam.allowHDR }, + { "allowMSAA", () => cam.allowMSAA }, + { "allowDynamicResolution", () => cam.allowDynamicResolution }, + { "forceIntoRenderTexture", () => cam.forceIntoRenderTexture }, + { "orthographicSize", () => cam.orthographicSize }, + { "orthographic", () => cam.orthographic }, + { "opaqueSortMode", () => (int)cam.opaqueSortMode }, + { "transparencySortMode", () => (int)cam.transparencySortMode }, + { "depth", () => cam.depth }, + { "aspect", () => cam.aspect }, + { "cullingMask", () => cam.cullingMask }, + { "eventMask", () => cam.eventMask }, + { "backgroundColor", () => cam.backgroundColor }, + { "clearFlags", () => (int)cam.clearFlags }, + { "stereoEnabled", () => cam.stereoEnabled }, + { "stereoSeparation", () => cam.stereoSeparation }, + { "stereoConvergence", () => cam.stereoConvergence }, + { "enabled", () => cam.enabled }, + { "name", () => cam.name }, + { "tag", () => cam.tag }, + { "gameObject", () => new { name = cam.gameObject.name, instanceID = cam.gameObject.GetInstanceID() } } + }; + + foreach (var prop in safeProperties) + { + try + { + var value = prop.Value(); + if (value != null) + { + AddSerializableValue(cameraProperties, prop.Key, value.GetType(), value); + } + } + catch (Exception) + { + // Silently skip any property that fails + continue; + } + } + + return new Dictionary + { + { "typeName", componentType.FullName }, + { "instanceID", cam.GetInstanceID() }, + { "properties", cameraProperties } + }; + } + // --- End Special handling for Camera --- + + var data = new Dictionary + { + { "typeName", componentType.FullName }, + { "instanceID", c.GetInstanceID() } + }; + + // --- Get Cached or Generate Metadata (using new cache key) --- + Tuple cacheKey = new Tuple(componentType, includeNonPublicSerializedFields); + if (!_metadataCache.TryGetValue(cacheKey, out CachedMetadata cachedData)) + { + var propertiesToCache = new List(); + var fieldsToCache = new List(); + + // Traverse the hierarchy from the component type up to MonoBehaviour + Type currentType = componentType; + while (currentType != null && currentType != typeof(MonoBehaviour) && currentType != typeof(object)) + { + // Get properties declared only at the current type level + BindingFlags propFlags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; + foreach (var propInfo in currentType.GetProperties(propFlags)) + { + // Basic filtering (readable, not indexer, not transform which is handled elsewhere) + if (!propInfo.CanRead || propInfo.GetIndexParameters().Length > 0 || propInfo.Name == "transform") continue; + // Add if not already added (handles overrides - keep the most derived version) + if (!propertiesToCache.Any(p => p.Name == propInfo.Name)) { + propertiesToCache.Add(propInfo); + } + } + + // Get fields declared only at the current type level (both public and non-public) + BindingFlags fieldFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly; + var declaredFields = currentType.GetFields(fieldFlags); + + // Process the declared Fields for caching + foreach (var fieldInfo in declaredFields) + { + if (fieldInfo.Name.EndsWith("k__BackingField")) continue; // Skip backing fields + + // Add if not already added (handles hiding - keep the most derived version) + if (fieldsToCache.Any(f => f.Name == fieldInfo.Name)) continue; + + bool shouldInclude = false; + if (includeNonPublicSerializedFields) + { + // If TRUE, include Public OR NonPublic with [SerializeField] + shouldInclude = fieldInfo.IsPublic || (fieldInfo.IsPrivate && fieldInfo.IsDefined(typeof(SerializeField), inherit: false)); + } + else // includeNonPublicSerializedFields is FALSE + { + // If FALSE, include ONLY if it is explicitly Public. + shouldInclude = fieldInfo.IsPublic; + } + + if (shouldInclude) + { + fieldsToCache.Add(fieldInfo); + } + } + + // Move to the base type + currentType = currentType.BaseType; + } + // --- End Hierarchy Traversal --- + + cachedData = new CachedMetadata(propertiesToCache, fieldsToCache); + _metadataCache[cacheKey] = cachedData; // Add to cache with combined key + } + // --- End Get Cached or Generate Metadata --- + + // --- Use cached metadata --- + var serializablePropertiesOutput = new Dictionary(); + + // --- Add Logging Before Property Loop --- + // Debug.Log($"[GetComponentData] Starting property loop for {componentType.Name}..."); + // --- End Logging Before Property Loop --- + + // Use cached properties + foreach (var propInfo in cachedData.SerializableProperties) + { + string propName = propInfo.Name; + + // --- Skip known obsolete/problematic Component shortcut properties --- + bool skipProperty = false; + if (propName == "rigidbody" || propName == "rigidbody2D" || propName == "camera" || + propName == "light" || propName == "animation" || propName == "constantForce" || + propName == "renderer" || propName == "audio" || propName == "networkView" || + propName == "collider" || propName == "collider2D" || propName == "hingeJoint" || + propName == "particleSystem" || + // Also skip potentially problematic Matrix properties prone to cycles/errors + propName == "worldToLocalMatrix" || propName == "localToWorldMatrix") + { + // Debug.Log($"[GetComponentData] Explicitly skipping generic property: {propName}"); // Optional log + skipProperty = true; + } + // --- End Skip Generic Properties --- + + // --- Skip Renderer.material / materials to avoid instantiating materials in edit mode --- + if (!skipProperty && + (typeof(Renderer).IsAssignableFrom(componentType)) && + (propName == "material" || propName == "materials")) + { + skipProperty = true; + } + // --- End skip Renderer material properties --- + + // --- Skip specific potentially problematic Camera properties --- + if (componentType == typeof(Camera) && + (propName == "pixelRect" || + propName == "rect" || + propName == "cullingMatrix" || + propName == "useOcclusionCulling" || + propName == "worldToCameraMatrix" || + propName == "projectionMatrix" || + propName == "nonJitteredProjectionMatrix" || + propName == "previousViewProjectionMatrix" || + propName == "cameraToWorldMatrix")) + { + // Debug.Log($"[GetComponentData] Explicitly skipping Camera property: {propName}"); + skipProperty = true; + } + // --- End Skip Camera Properties --- + + // --- Skip specific potentially problematic Transform properties --- + if (componentType == typeof(Transform) && + (propName == "lossyScale" || + propName == "rotation" || + propName == "worldToLocalMatrix" || + propName == "localToWorldMatrix")) + { + // Debug.Log($"[GetComponentData] Explicitly skipping Transform property: {propName}"); + skipProperty = true; + } + // --- End Skip Transform Properties --- + + // Skip if flagged + if (skipProperty) + { + continue; + } + + try + { + // --- Add detailed logging --- + // Debug.Log($"[GetComponentData] Accessing: {componentType.Name}.{propName}"); + // --- End detailed logging --- + object value = propInfo.GetValue(c); + Type propType = propInfo.PropertyType; + AddSerializableValue(serializablePropertiesOutput, propName, propType, value); + } + catch (Exception) + { + // Debug.LogWarning($"Could not read property {propName} on {componentType.Name}"); + } + } + + // --- Add Logging Before Field Loop --- + // Debug.Log($"[GetComponentData] Starting field loop for {componentType.Name}..."); + // --- End Logging Before Field Loop --- + + // Use cached fields + foreach (var fieldInfo in cachedData.SerializableFields) + { + try + { + // --- Add detailed logging for fields --- + // Debug.Log($"[GetComponentData] Accessing Field: {componentType.Name}.{fieldInfo.Name}"); + // --- End detailed logging for fields --- + object value = fieldInfo.GetValue(c); + string fieldName = fieldInfo.Name; + Type fieldType = fieldInfo.FieldType; + AddSerializableValue(serializablePropertiesOutput, fieldName, fieldType, value); + } + catch (Exception) + { + // Debug.LogWarning($"Could not read field {fieldInfo.Name} on {componentType.Name}"); + } + } + // --- End Use cached metadata --- + + if (serializablePropertiesOutput.Count > 0) + { + data["properties"] = serializablePropertiesOutput; + } + + return data; + } + + // Helper function to decide how to serialize different types + private static void AddSerializableValue(Dictionary dict, string name, Type type, object value) + { + // Simplified: Directly use CreateTokenFromValue which uses the serializer + if (value == null) + { + dict[name] = null; + return; + } + + try + { + // Use the helper that employs our custom serializer settings + JToken token = CreateTokenFromValue(value, type); + if (token != null) // Check if serialization succeeded in the helper + { + // Convert JToken back to a basic object structure for the dictionary + dict[name] = ConvertJTokenToPlainObject(token); + } + // If token is null, it means serialization failed and a warning was logged. + } + catch (Exception e) + { + // Catch potential errors during JToken conversion or addition to dictionary + Debug.LogWarning($"[AddSerializableValue] Error processing value for '{name}' (Type: {type.FullName}): {e.Message}. Skipping."); + } + } + + // Helper to convert JToken back to basic object structure + private static object ConvertJTokenToPlainObject(JToken token) + { + if (token == null) return null; + + switch (token.Type) + { + case JTokenType.Object: + var objDict = new Dictionary(); + foreach (var prop in ((JObject)token).Properties()) + { + objDict[prop.Name] = ConvertJTokenToPlainObject(prop.Value); + } + return objDict; + + case JTokenType.Array: + var list = new List(); + foreach (var item in (JArray)token) + { + list.Add(ConvertJTokenToPlainObject(item)); + } + return list; + + case JTokenType.Integer: + return token.ToObject(); // Use long for safety + case JTokenType.Float: + return token.ToObject(); // Use double for safety + case JTokenType.String: + return token.ToObject(); + case JTokenType.Boolean: + return token.ToObject(); + case JTokenType.Date: + return token.ToObject(); + case JTokenType.Guid: + return token.ToObject(); + case JTokenType.Uri: + return token.ToObject(); + case JTokenType.TimeSpan: + return token.ToObject(); + case JTokenType.Bytes: + return token.ToObject(); + case JTokenType.Null: + return null; + case JTokenType.Undefined: + return null; // Treat undefined as null + + default: + // Fallback for simple value types not explicitly listed + if (token is JValue jValue && jValue.Value != null) + { + return jValue.Value; + } + // Debug.LogWarning($"Unsupported JTokenType encountered: {token.Type}. Returning null."); + return null; + } + } + + // --- Define custom JsonSerializerSettings for OUTPUT --- + private static readonly JsonSerializerSettings _outputSerializerSettings = new JsonSerializerSettings + { + Converters = new List + { + new Vector3Converter(), + new Vector2Converter(), + new QuaternionConverter(), + new ColorConverter(), + new RectConverter(), + new BoundsConverter(), + new UnityEngineObjectConverter() // Handles serialization of references + }, + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + // ContractResolver = new DefaultContractResolver { NamingStrategy = new CamelCaseNamingStrategy() } // Example if needed + }; + private static readonly JsonSerializer _outputSerializer = JsonSerializer.Create(_outputSerializerSettings); + // --- End Define custom JsonSerializerSettings --- + + // Helper to create JToken using the output serializer + private static JToken CreateTokenFromValue(object value, Type type) + { + if (value == null) return JValue.CreateNull(); + + try + { + // Use the pre-configured OUTPUT serializer instance + return JToken.FromObject(value, _outputSerializer); + } + catch (JsonSerializationException e) + { + Debug.LogWarning($"[GameObjectSerializer] Codely.Newtonsoft.Json Error serializing value of type {type.FullName}: {e.Message}. Skipping property/field."); + return null; // Indicate serialization failure + } + catch (Exception e) // Catch other unexpected errors + { + Debug.LogWarning($"[GameObjectSerializer] Unexpected error serializing value of type {type.FullName}: {e}. Skipping property/field."); + return null; // Indicate serialization failure + } + } + } +} diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/GameObjectSerializer.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/GameObjectSerializer.cs.meta new file mode 100644 index 000000000..26b5d6130 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/GameObjectSerializer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 29be5222623c0324ca01f6b7ffaaa602 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/JsonCommandHelper.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/JsonCommandHelper.cs new file mode 100644 index 000000000..c4955a420 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/JsonCommandHelper.cs @@ -0,0 +1,67 @@ +using System; +using System.Linq; +using Codely.Newtonsoft.Json.Linq; + +namespace UnityTcp.Editor.Helpers +{ + /// + /// Helper class for JSON command processing utilities + /// + public static class JsonCommandHelper + { + /// + /// Helper method to check if a string is valid JSON + /// + public static bool IsValidJson(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + text = text.Trim(); + if ( + (text.StartsWith("{") && text.EndsWith("}")) + || // Object + (text.StartsWith("[") && text.EndsWith("]")) + ) // Array + { + try + { + JToken.Parse(text); + return true; + } + catch + { + return false; + } + } + + return false; + } + + /// + /// Helper method to get a summary of parameters for error reporting + /// + public static string GetParamsSummary(JObject @params) + { + try + { + return @params == null || !@params.HasValues + ? "No parameters" + : string.Join( + ", ", + @params + .Properties() + .Select(static p => + $"{p.Name}: {p.Value?.ToString()?[..Math.Min(20, p.Value?.ToString()?.Length ?? 0)]}" + ) + ); + } + catch + { + return "Could not summarize parameters"; + } + } + } +} diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/JsonCommandHelper.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/JsonCommandHelper.cs.meta new file mode 100644 index 000000000..8a99a0597 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/JsonCommandHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6a17149005eb51642ab32aa65be61cc7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/MainThreadHelper.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/MainThreadHelper.cs new file mode 100644 index 000000000..e19dbadc6 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/MainThreadHelper.cs @@ -0,0 +1,86 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using UnityEditor; + +namespace UnityTcp.Editor.Helpers +{ + /// + /// Helper class for main thread operations + /// + public static class MainThreadHelper + { + private static int mainThreadId; + + /// + /// Initialize the main thread ID for safe thread checks + /// Call this from the main thread during static constructor + /// + public static void InitializeMainThreadId() + { + try { mainThreadId = Thread.CurrentThread.ManagedThreadId; } catch { mainThreadId = 0; } + } + + /// + /// Invoke the given function on the Unity main thread and wait up to timeoutMs for the result. + /// Returns null on timeout or error; caller should provide a fallback error response. + /// + public static object InvokeOnMainThreadWithTimeout(Func func, int timeoutMs) + { + if (func == null) return null; + try + { + // If mainThreadId is unknown, assume we're on main thread to avoid blocking the editor. + if (mainThreadId == 0) + { + try { return func(); } + catch (Exception ex) { throw new InvalidOperationException($"Main thread handler error: {ex.Message}", ex); } + } + // If we are already on the main thread, execute directly to avoid deadlocks + try + { + if (Thread.CurrentThread.ManagedThreadId == mainThreadId) + { + return func(); + } + } + catch { } + + object result = null; + Exception captured = null; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + EditorApplication.delayCall += () => + { + try + { + result = func(); + } + catch (Exception ex) + { + captured = ex; + } + finally + { + try { tcs.TrySetResult(true); } catch { } + } + }; + + // Wait for completion with timeout (Editor thread will pump delayCall) + bool completed = tcs.Task.Wait(timeoutMs); + if (!completed) + { + return null; // timeout + } + if (captured != null) + { + throw new InvalidOperationException($"Main thread handler error: {captured.Message}", captured); + } + return result; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to invoke on main thread: {ex.Message}", ex); + } + } + } +} diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/MainThreadHelper.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/MainThreadHelper.cs.meta new file mode 100644 index 000000000..04cdbf6b0 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/MainThreadHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f24f49a4ec33ffe448b5c69d381dfa9a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/PortManager.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/PortManager.cs new file mode 100644 index 000000000..dd3155e42 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/PortManager.cs @@ -0,0 +1,479 @@ +using System; +using System.IO; +using UnityEditor; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using Codely.Newtonsoft.Json; +using UnityEngine; + +namespace UnityTcp.Editor.Helpers +{ + /// + /// Manages dynamic port allocation and persistent storage for Codely Bridge connections + /// + public static class PortManager + { + private static bool IsDebugEnabled() + { + try { return EditorPrefs.GetBool("UnityTcp.DebugLogs", false); } + catch { return false; } + } + + private const int DefaultPort = 25916; + private const int MaxPortAttempts = 100; + private const string RegistryFileName = ".com-unity-codely.json"; + + [Serializable] + public class PortConfig + { + public int unity_port; + public string created_date; + public string project_path; + + // Status/heartbeat fields + public bool reloading; + public string reason; + public int seq; + public string last_heartbeat; + } + + /// + /// Get the port to use - either from storage or discover a new one + /// Will try stored port first, then fallback to discovering new port + /// + /// Port number to use + public static int GetPortWithFallback() + { + // Try to load stored port first, but only if it's from the current project + var storedConfig = GetStoredPortConfig(); + if (storedConfig != null && + storedConfig.unity_port > 0 && + string.Equals(storedConfig.project_path ?? string.Empty, Application.dataPath ?? string.Empty, StringComparison.OrdinalIgnoreCase) && + IsPortAvailable(storedConfig.unity_port)) + { + if (IsDebugEnabled()) Debug.Log($"Codely Bridge: Using stored port {storedConfig.unity_port} for current project"); + return storedConfig.unity_port; + } + + // If stored port exists but is currently busy, wait briefly for release + if (storedConfig != null && storedConfig.unity_port > 0) + { + if (WaitForPortRelease(storedConfig.unity_port, 1500)) + { + if (IsDebugEnabled()) Debug.Log($"Codely Bridge: Stored port {storedConfig.unity_port} became available after short wait"); + return storedConfig.unity_port; + } + // Prefer sticking to the same port; let the caller handle bind retries/fallbacks + return storedConfig.unity_port; + } + + // If no valid stored port, find a new one and save it + int newPort = FindAvailablePort(); + SavePort(newPort); + return newPort; + } + + /// + /// Discover and save a new available port (used by Auto-Connect button) + /// + /// New available port + public static int DiscoverNewPort() + { + int newPort = FindAvailablePort(); + SavePort(newPort); + if (IsDebugEnabled()) Debug.Log($"Codely Bridge: Discovered and saved new port: {newPort}"); + return newPort; + } + + /// + /// Find an available port starting from the default port + /// + /// Available port number + private static int FindAvailablePort() + { + // Always try default port first + if (IsPortAvailable(DefaultPort)) + { + if (IsDebugEnabled()) Debug.Log($"Codely Bridge: Using default port {DefaultPort}"); + return DefaultPort; + } + + if (IsDebugEnabled()) Debug.Log($"Codely Bridge: Default port {DefaultPort} is in use, searching for alternative..."); + + // Search for alternatives + for (int port = DefaultPort + 1; port < DefaultPort + MaxPortAttempts; port++) + { + if (IsPortAvailable(port)) + { + if (IsDebugEnabled()) Debug.Log($"Codely Bridge: Found available port {port}"); + return port; + } + } + + throw new Exception($"No available ports found in range {DefaultPort}-{DefaultPort + MaxPortAttempts}"); + } + + /// + /// Check if a specific port is available for binding + /// Uses same socket options as the actual TCP listener to ensure consistent behavior + /// + /// Port to check + /// True if port is available + public static bool IsPortAvailable(int port) + { + TcpListener testListener = null; + try + { + testListener = new TcpListener(IPAddress.Loopback, port); + + // Use same socket options as the actual listener for consistent checking +#if UNITY_EDITOR_WIN + // On Windows: no reuse + exclusive access for strict isolation + testListener.Server.SetSocketOption( + SocketOptionLevel.Socket, + SocketOptionName.ReuseAddress, + false + ); + try + { + testListener.ExclusiveAddressUse = true; // Require exclusive access for availability check + } + catch { } +#else + // On macOS/Linux: Disable port reuse + try + { + testListener.Server.SetSocketOption( + SocketOptionLevel.Socket, + SocketOptionName.ReuseAddress, + false + ); + } + catch { } +#endif + + // Minimize TIME_WAIT by sending RST on close (same as actual listener) + try + { + testListener.Server.LingerState = new LingerOption(true, 0); + } + catch (Exception) + { + // Ignore if not supported on platform + } + + testListener.Start(); + testListener.Stop(); + return true; + } + catch (SocketException) + { + return false; + } + finally + { + try { testListener?.Stop(); } catch { } + } + } + + /// + /// Check if a port is currently being used by Codely Bridge server + /// This helps avoid unnecessary port changes when Unity itself is using the port + /// + /// Port to check + /// True if port appears to be used by Codely Bridge server + public static bool IsPortUsedByUnityTcp(int port) + { + try + { + // Try to make a quick connection to see if it's a Codely Bridge server + using var client = new TcpClient(); + var connectTask = client.ConnectAsync(IPAddress.Loopback, port); + if (connectTask.Wait(100)) // 100ms timeout + { + // If connection succeeded, it's likely the Codely Bridge server + return client.Connected; + } + return false; + } + catch + { + return false; + } + } + + /// + /// Detect if another Codely Bridge instance is already using this port + /// Provides better error reporting for port conflicts + /// + /// Port to check + /// Detailed information about port usage + public static (bool inUse, string description) CheckPortConflict(int port) + { + // First check basic availability with exclusive access + if (IsPortAvailable(port)) + { + return (false, "Port is available"); + } + + // Port is in use, try to determine what's using it + if (IsPortUsedByUnityTcp(port)) + { + return (true, "Port is in use by another Codely Bridge Bridge instance"); + } + + // Port is in use by something else + return (true, "Port is in use by another process"); + } + + /// + /// Wait for a port to become available for a limited amount of time. + /// Used to bridge the gap during domain reload when the old listener + /// hasn't released the socket yet. + /// + private static bool WaitForPortRelease(int port, int timeoutMs) + { + int waited = 0; + const int step = 100; + while (waited < timeoutMs) + { + if (IsPortAvailable(port)) + { + return true; + } + + // If the port is in use by a Codely Bridge instance, continue waiting briefly + if (!IsPortUsedByUnityTcp(port)) + { + // In use by something else; don't keep waiting + return false; + } + + Thread.Sleep(step); + waited += step; + } + return IsPortAvailable(port); + } + + /// + /// Save port to persistent storage, preserving existing status information + /// + /// Port to save + private static void SavePort(int port) + { + try + { + // Load existing config to preserve status information + var existingConfig = GetStoredPortConfig(); + + var portConfig = new PortConfig + { + unity_port = port, + created_date = existingConfig?.created_date ?? DateTime.UtcNow.ToString("O"), + project_path = Application.dataPath, + + // Preserve existing status fields + reloading = existingConfig?.reloading ?? false, + reason = existingConfig?.reason ?? "ready", + seq = existingConfig?.seq ?? 0, + last_heartbeat = existingConfig?.last_heartbeat + }; + + SavePortConfig(portConfig); + + if (IsDebugEnabled()) Debug.Log($"Codely Bridge: Saved port {port} to storage"); + } + catch (Exception ex) + { + Debug.LogWarning($"Could not save port to storage: {ex.Message}"); + } + } + + /// + /// Save port configuration to persistent storage + /// + /// Port configuration to save + public static void SavePortConfig(PortConfig portConfig) + { + try + { + string registryDir = GetRegistryDirectory(); + Directory.CreateDirectory(registryDir); + + string registryFile = GetRegistryFilePath(); + string json = JsonConvert.SerializeObject(portConfig, Formatting.Indented); + // Write to project root config file + File.WriteAllText(registryFile, json, new System.Text.UTF8Encoding(false)); + + // Also maintain backwards compatibility by writing to legacy location + try + { + string legacyDir = GetLegacyRegistryDirectory(); + Directory.CreateDirectory(legacyDir); + string legacyFile = Path.Combine(legacyDir, "unity-tcp-port.json"); + File.WriteAllText(legacyFile, json, new System.Text.UTF8Encoding(false)); + } + catch + { + // Ignore legacy write failures + } + } + catch (Exception ex) + { + Debug.LogWarning($"Could not save port config to storage: {ex.Message}"); + throw; + } + } + + /// + /// Load port from persistent storage + /// + /// Stored port number, or 0 if not found + private static int LoadStoredPort() + { + try + { + string registryFile = GetRegistryFilePath(); + + if (!File.Exists(registryFile)) + { + // Backwards compatibility: try the legacy locations + // First try the new legacy location in project root + string projectLegacy = Path.Combine(GetRegistryDirectory(), "unity-tcp-port.json"); + if (File.Exists(projectLegacy)) + { + registryFile = projectLegacy; + } + else + { + // Then try the old user home location + string userHomeLegacy = Path.Combine(GetLegacyRegistryDirectory(), "unity-tcp-port.json"); + if (File.Exists(userHomeLegacy)) + { + registryFile = userHomeLegacy; + } + else + { + // Also check hash-based files in user home + string hashBased = Path.Combine(GetLegacyRegistryDirectory(), $"unity-tcp-port-{ComputeProjectHash(Application.dataPath)}.json"); + if (File.Exists(hashBased)) + { + registryFile = hashBased; + } + else + { + return 0; + } + } + } + } + + string json = File.ReadAllText(registryFile); + var portConfig = JsonConvert.DeserializeObject(json); + + return portConfig?.unity_port ?? 0; + } + catch (Exception ex) + { + Debug.LogWarning($"Could not load port from storage: {ex.Message}"); + return 0; + } + } + + /// + /// Get the current stored port configuration + /// + /// Port configuration if exists, null otherwise + public static PortConfig GetStoredPortConfig() + { + try + { + string registryFile = GetRegistryFilePath(); + + if (!File.Exists(registryFile)) + { + // Backwards compatibility: try the legacy locations + // First try the new legacy location in project root + string projectLegacy = Path.Combine(GetRegistryDirectory(), "unity-tcp-port.json"); + if (File.Exists(projectLegacy)) + { + registryFile = projectLegacy; + } + else + { + // Then try the old user home location + string userHomeLegacy = Path.Combine(GetLegacyRegistryDirectory(), "unity-tcp-port.json"); + if (File.Exists(userHomeLegacy)) + { + registryFile = userHomeLegacy; + } + else + { + // Also check hash-based files in user home + string hashBased = Path.Combine(GetLegacyRegistryDirectory(), $"unity-tcp-port-{ComputeProjectHash(Application.dataPath)}.json"); + if (File.Exists(hashBased)) + { + registryFile = hashBased; + } + else + { + return null; + } + } + } + } + + string json = File.ReadAllText(registryFile); + return JsonConvert.DeserializeObject(json); + } + catch (Exception ex) + { + Debug.LogWarning($"Could not load port config: {ex.Message}"); + return null; + } + } + + private static string GetRegistryDirectory() + { + // Use project root directory (parent of Assets folder) + string assetsPath = Application.dataPath; + string projectRoot = Directory.GetParent(assetsPath)?.FullName ?? assetsPath; + return projectRoot; + } + + private static string GetLegacyRegistryDirectory() + { + // Legacy location in user home directory + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-tcp"); + } + + private static string GetRegistryFilePath() + { + string dir = GetRegistryDirectory(); + return Path.Combine(dir, RegistryFileName); + } + + private static string ComputeProjectHash(string input) + { + try + { + using SHA1 sha1 = SHA1.Create(); + byte[] bytes = Encoding.UTF8.GetBytes(input ?? string.Empty); + byte[] hashBytes = sha1.ComputeHash(bytes); + var sb = new StringBuilder(); + foreach (byte b in hashBytes) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString()[..8]; // short, sufficient for filenames + } + catch + { + return "default"; + } + } + } +} diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/PortManager.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/PortManager.cs.meta new file mode 100644 index 000000000..e71b59232 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/PortManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: db27ee87f1c170b47928576e31cc2c9a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/Response.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/Response.cs new file mode 100644 index 000000000..0028111d3 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/Response.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; + +namespace UnityTcp.Editor.Helpers +{ + /// + /// Provides static methods for creating standardized success and error response objects. + /// Ensures consistent JSON structure for communication back to the Codely client. + /// + /// Response format aligns with the OpenAPI spec: + /// - ImmediateResponse: { success, message, data?, state?, state_delta? } + /// - PendingResponse: { _mcp_status, op_id, poll_interval, message, state?, state_delta? } + /// + public static class Response + { + /// + /// Creates a standardized success response object with optional state. + /// + /// A message describing the successful operation. + /// Optional additional data to include in the response. + /// Whether to include full state snapshot (default: false). + /// Optional state delta for incremental updates. + /// An object representing the success response. + public static object Success(string message, object data = null, bool includeState = false, object stateDelta = null) + { + var response = new Dictionary + { + { "success", true }, + { "message", message }, + // Always include current state revision so clients can keep client_state_rev in sync + { "rev", StateComposer.GetCurrentRevision() } + }; + + if (data != null) + { + response["data"] = data; + } + + // Include state if explicitly requested + if (includeState) + { + response["state"] = StateComposer.BuildFullState(); + } + + // Include state_delta if provided + if (stateDelta != null) + { + response["state_delta"] = stateDelta; + } + + return response; + } + + /// + /// Creates a standardized success response with automatic state delta. + /// Use this for write operations that modify Unity state. + /// + public static object SuccessWithDelta(string message, object data = null, object stateDelta = null) + { + StateComposer.IncrementRevision(); + return Success(message, data, includeState: false, stateDelta: stateDelta); + } + + /// + /// Creates a standardized success response with full state snapshot. + /// Use this for operations that require the client to have the latest state. + /// + public static object SuccessWithState(string message, object data = null) + { + StateComposer.IncrementRevision(); + return Success(message, data, includeState: true); + } + + /// + /// Creates a standardized error response object. + /// + /// A message describing the error. + /// Optional additional data (e.g., error details) to include. + /// Whether to include full state snapshot for recovery (default: false). + /// An object representing the error response. + public static object Error(string errorCodeOrMessage, object data = null, bool includeState = false) + { + var response = new Dictionary + { + { "success", false }, + { "code", errorCodeOrMessage }, + { "error", errorCodeOrMessage } + }; + + if (data != null) + { + response["data"] = data; + } + + // Include state on error for recovery scenarios + if (includeState) + { + response["state"] = StateComposer.BuildFullState(); + } + + return response; + } + + /// + /// Creates a conflict response for state revision mismatches. + /// This is returned when client_state_rev doesn't match server's revision. + /// + /// The client's provided revision. + /// The server's current revision. + /// A conflict response with full state for synchronization. + public static object Conflict(int clientRev, int serverRev) + { + return new Dictionary + { + { "success", false }, + { "code", "state_revision_conflict" }, + { "error", $"State revision mismatch. Client: {clientRev}, Server: {serverRev}. Please refresh state." }, + { "state", StateComposer.BuildFullState() } + }; + } + + /// + /// Legacy overload for backward compatibility. + /// + [Obsolete("Use Success(message, data, includeState, stateDelta) instead.")] + public static object SuccessLegacy(string message, object data = null) + { + if (data != null) + { + return new + { + success = true, + message = message, + data = data, + }; + } + else + { + return new { success = true, message = message }; + } + } + } +} + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/Response.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/Response.cs.meta new file mode 100644 index 000000000..c3218f9ac --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/Response.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 635f93405037a114993e3a9a44c54745 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/ServerPathResolver.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/ServerPathResolver.cs new file mode 100644 index 000000000..e3fa59301 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/ServerPathResolver.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace UnityTcp.Editor.Helpers +{ + public static class ServerPathResolver + { + /// + /// Attempts to locate the package root directory for cn.tuanjie.codely.bridge. + /// Returns true if found and sets packagePath to the package root folder. + /// + public static bool TryFindPackageRoot(out string packagePath, bool warnOnLegacyPackageId = true) + { + // Resolve via local package info (no network). Fall back to Client.List on older editors. + try + { +#if UNITY_2021_2_OR_NEWER + // Primary: the package that owns this assembly + var owner = UnityEditor.PackageManager.PackageInfo.FindForAssembly(typeof(ServerPathResolver).Assembly); + if (owner != null) + { + if (TryResolvePackage(owner, out packagePath, warnOnLegacyPackageId)) + { + return true; + } + } + + // Secondary: scan all registered packages locally + foreach (var p in UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages()) + { + if (TryResolvePackage(p, out packagePath, warnOnLegacyPackageId)) + { + return true; + } + } +#else + // Older Unity versions: use Package Manager Client.List as a fallback + var list = UnityEditor.PackageManager.Client.List(); + while (!list.IsCompleted) { } + if (list.Status == UnityEditor.PackageManager.StatusCode.Success) + { + foreach (var pkg in list.Result) + { + if (TryResolvePackage(pkg, out packagePath, warnOnLegacyPackageId)) + { + return true; + } + } + } +#endif + } + catch { /* ignore */ } + + packagePath = null; + return false; + } + + private static bool TryResolvePackage(UnityEditor.PackageManager.PackageInfo p, out string packagePath, bool warnOnLegacyPackageId) + { + const string CurrentId = "cn.tuanjie.codely.bridge"; + + packagePath = null; + if (p == null || p.name != CurrentId) + { + return false; + } + + packagePath = p.resolvedPath; + return !string.IsNullOrEmpty(packagePath) && Directory.Exists(packagePath); + } + } +} + + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/ServerPathResolver.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/ServerPathResolver.cs.meta new file mode 100644 index 000000000..14b369278 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/ServerPathResolver.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a4d1d7c2b1e94b3f8a7d9c6e5f403a21 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/StateComposer.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/StateComposer.cs new file mode 100644 index 000000000..6f0533d51 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/StateComposer.cs @@ -0,0 +1,783 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using UnityEditor; +using UnityEngine; + +namespace UnityTcp.Editor.Helpers +{ + /// + /// Centralized state composition and revision tracking for Unity Editor state. + /// Provides consistent state snapshots and incremental state_delta generation. + /// + public static class StateComposer + { + // Global state revision counter (incremented on every state change) + private static int _globalRevision = 0; + private static readonly object _revisionLock = new object(); + + // Console state tracking (shared with ReadConsole) + private static string _currentConsoleToken = null; + private static int _consoleUnreadCount = 0; + private static readonly List _lastConsoleErrors = new List(); + private static readonly object _consoleLock = new object(); + + // Touched assets tracking + private static readonly List _touchedAssets = new List(); + private static readonly object _assetsLock = new object(); + + // Pending operations tracking + private static readonly List _pendingOperations = new List(); + private static readonly object _operationsLock = new object(); + + /// + /// Increment and return the next global revision number. + /// Thread-safe. + /// + public static int IncrementRevision() + { + lock (_revisionLock) + { + return ++_globalRevision; + } + } + + /// + /// Get current global revision without incrementing. + /// + public static int GetCurrentRevision() + { + lock (_revisionLock) + { + return _globalRevision; + } + } + + /// + /// Builds a complete Unity state snapshot with current revision. + /// Note: Does NOT auto-increment revision - caller should decide when to increment. + /// + public static object BuildFullState() + { + int currentRev; + lock (_revisionLock) + { + currentRev = _globalRevision; + } + + var state = new + { + editor = BuildEditorState(), + project = BuildProjectState(), + scene = BuildSceneState(), + selection = BuildSelectionState(), + console = BuildConsoleState(), + assets = BuildAssetsState(), + operations = BuildOperationsState(), + policy = BuildPolicyState(), + rev = currentRev + }; + + return state; + } + + /// + /// Builds a complete Unity state snapshot and increments revision. + /// Use this for read operations that need to return fresh state. + /// + public static object BuildFullStateAndIncrement() + { + int newRev = IncrementRevision(); + + var state = new + { + editor = BuildEditorState(), + project = BuildProjectState(), + scene = BuildSceneState(), + selection = BuildSelectionState(), + console = BuildConsoleState(), + assets = BuildAssetsState(), + operations = BuildOperationsState(), + policy = BuildPolicyState(), + rev = newRev + }; + + return state; + } + + /// + /// Builds editor-specific state. + /// + public static object BuildEditorState() + { + var playMode = EditorApplication.isPlaying ? "playing" : + (EditorApplication.isPaused ? "paused" : "stopped"); + + // Get focused window + string focusedWindow = null; + if (EditorWindow.focusedWindow != null) + { + focusedWindow = EditorWindow.focusedWindow.GetType().Name; + } + + // Determine if operations require focus + // This is a heuristic - some operations need the editor to be focused + bool requiresFocusForOperations = DetermineIfFocusRequired(); + + return new + { + playMode = playMode, + focusedWindow = focusedWindow, + requiresFocusForOperations = requiresFocusForOperations, + isCompiling = EditorApplication.isCompiling, + isUpdating = EditorApplication.isUpdating, + lastCompilation = BuildLastCompilationState(), + timeSinceStartup = (float)EditorApplication.timeSinceStartup + }; + } + + /// + /// Builds last compilation state. + /// + /// NOTE: + /// - This is intentionally minimal and only reports whether Unity is + /// currently compiling ("started" vs "idle"). + /// - It is NOT a per-compilation snapshot and does NOT expose error/ + /// warning counts for any specific pipeline. + /// - For accurate diagnostics (including error/warning counts), callers + /// must use: + /// * Compilation deltas from StateComposer.CreateCompilationDelta + /// (returned by wait_for_compile), and + /// * The Unity console (read_console / unity_console) with sinceToken. + /// + private static object BuildLastCompilationState() + { + var status = EditorApplication.isCompiling ? "started" : "idle"; + + return new + { + status = status + }; + } + + /// + /// Determines if current operations require focus. + /// + private static bool DetermineIfFocusRequired() + { + // Heuristic: Some operations need focus, especially during Play mode + // or when performing visual operations like scene manipulation + if (EditorApplication.isPlaying || EditorApplication.isPaused) + { + return true; + } + + // Check if SceneView needs focus for certain operations + var sceneView = EditorWindow.focusedWindow as SceneView; + if (sceneView != null) + { + return false; // Already focused + } + + return false; // Default: focus not strictly required + } + + /// + /// Builds project-specific state. + /// + public static object BuildProjectState() + { + // Detect Render Pipeline + string srp = "builtin"; + var currentRP = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline; + if (currentRP != null) + { + string rpName = currentRP.GetType().Name.ToLowerInvariant(); + if (rpName.Contains("urp") || rpName.Contains("universal")) + { + srp = "urp"; + } + else if (rpName.Contains("hdrp") || rpName.Contains("highdefinition")) + { + srp = "hdrp"; + } + } + + return new + { + srp = srp, + defineSymbols = GetScriptingDefineSymbols(), + packages = GetInstalledPackages(), + dirty = false // Would track if project settings are modified + }; + } + + private static string[] GetScriptingDefineSymbols() + { + // Get scripting define symbols for current build target + var buildTargetGroup = EditorUserBuildSettings.selectedBuildTargetGroup; + var symbols = PlayerSettings.GetScriptingDefineSymbolsForGroup(buildTargetGroup); + return string.IsNullOrEmpty(symbols) ? + new string[0] : + symbols.Split(';', StringSplitOptions.RemoveEmptyEntries); + } + + private static string[] GetInstalledPackages() + { + // Simplified - in production would use PackageManager API + return new string[0]; + } + + /// + /// Builds scene-specific state. + /// + public static object BuildSceneState() + { + var activeScene = UnityEngine.SceneManagement.SceneManager.GetActiveScene(); + + return new + { + activeScenePath = activeScene.path, + dirty = activeScene.isDirty, + hasNavMeshData = HasNavMeshData(), + hasLightingData = HasLightingData() + }; + } + + private static bool HasNavMeshData() + { + // Check if current scene has NavMesh data using runtime reflection + try + { + // First, try to check NavMeshSurface components (com.unity.ai.navigation package) + Type navMeshSurfaceType = Type.GetType("Unity.AI.Navigation.NavMeshSurface, Unity.AI.Navigation"); + if (navMeshSurfaceType == null) + { + // Fallback: search in loaded assemblies + foreach (var assembly in System.AppDomain.CurrentDomain.GetAssemblies()) + { + navMeshSurfaceType = assembly.GetType("Unity.AI.Navigation.NavMeshSurface"); + if (navMeshSurfaceType != null) break; + } + } + + if (navMeshSurfaceType != null) + { + // Check NavMeshSurface components for navMeshData + var activeSurfacesProperty = navMeshSurfaceType.GetProperty("activeSurfaces", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + if (activeSurfacesProperty != null) + { + var activeSurfaces = activeSurfacesProperty.GetValue(null); + if (activeSurfaces is System.Collections.IList surfaceList && surfaceList.Count > 0) + { + var navMeshDataProperty = navMeshSurfaceType.GetProperty("navMeshData"); + if (navMeshDataProperty != null) + { + foreach (var surface in surfaceList) + { + if (surface != null) + { + var navMeshData = navMeshDataProperty.GetValue(surface); + if (navMeshData != null) + { + return true; + } + } + } + } + } + } + + // Also check all NavMeshSurface components in the scene (including inactive) + var allSurfaces = Resources.FindObjectsOfTypeAll(navMeshSurfaceType); + if (allSurfaces != null && allSurfaces.Length > 0) + { + var navMeshDataProperty = navMeshSurfaceType.GetProperty("navMeshData"); + if (navMeshDataProperty != null) + { + foreach (var surface in allSurfaces) + { + if (surface != null) + { + var navMeshData = navMeshDataProperty.GetValue(surface); + if (navMeshData != null) + { + return true; + } + } + } + } + } + } + + // Fallback: Try to find NavMesh type using reflection (for built-in NavMesh) + Type navMeshType = Type.GetType("UnityEngine.AI.NavMesh, UnityEngine.AIModule"); + if (navMeshType == null) + { + // Fallback: search in loaded assemblies + foreach (var assembly in System.AppDomain.CurrentDomain.GetAssemblies()) + { + navMeshType = assembly.GetType("UnityEngine.AI.NavMesh"); + if (navMeshType != null) break; + } + } + + if (navMeshType == null) + return false; + + // Get CalculateTriangulation method + MethodInfo calculateTriangulationMethod = navMeshType.GetMethod("CalculateTriangulation", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + if (calculateTriangulationMethod == null) + return false; + + // Call CalculateTriangulation using reflection + var triangulation = calculateTriangulationMethod.Invoke(null, null); + if (triangulation == null) + return false; + + // Get vertices property + var verticesProperty = triangulation.GetType().GetProperty("vertices"); + if (verticesProperty == null) + return false; + + var vertices = verticesProperty.GetValue(triangulation) as Array; + return vertices != null && vertices.Length > 0; + } + catch + { + // If any error occurs, assume no NavMesh data + return false; + } + } + + private static bool HasLightingData() + { + // Check if current scene has baked lighting + return Lightmapping.giWorkflowMode == Lightmapping.GIWorkflowMode.OnDemand || + Lightmapping.lightingDataAsset != null; + } + + /// + /// Builds selection state. + /// + public static object BuildSelectionState() + { + var activeObject = Selection.activeGameObject; + object activeObjectInfo = null; + + if (activeObject != null) + { + activeObjectInfo = new + { + id = activeObject.GetInstanceID(), + name = activeObject.name, + hierarchy_path = GetHierarchyPath(activeObject) + }; + } + + return new + { + activeObject = activeObjectInfo + }; + } + + private static string GetHierarchyPath(GameObject go) + { + if (go == null) return ""; + + var path = go.name; + var parent = go.transform.parent; + + while (parent != null) + { + path = parent.name + "/" + path; + parent = parent.parent; + } + + return path; + } + + /// + /// Builds console state with real tracking data. + /// + public static object BuildConsoleState() + { + lock (_consoleLock) + { + return new + { + sinceToken = _currentConsoleToken, + unreadCount = _consoleUnreadCount, + lastErrors = _lastConsoleErrors.ToArray() + }; + } + } + + /// + /// Updates console state tracking. Called by ReadConsole. + /// + public static void UpdateConsoleState(string sinceToken, int unreadCount = 0, object[] lastErrors = null) + { + lock (_consoleLock) + { + _currentConsoleToken = sinceToken; + _consoleUnreadCount = unreadCount; + _lastConsoleErrors.Clear(); + if (lastErrors != null) + { + _lastConsoleErrors.AddRange(lastErrors); + } + } + } + + /// + /// Gets the current console token. + /// + public static string GetCurrentConsoleToken() + { + lock (_consoleLock) + { + return _currentConsoleToken; + } + } + + /// + /// Builds assets state with tracked touched assets. + /// + public static object BuildAssetsState() + { + lock (_assetsLock) + { + return new + { + touched = _touchedAssets.ToArray() + }; + } + } + + /// + /// Adds a touched asset to tracking. Called by asset operations. + /// + public static void AddTouchedAsset(string path, bool imported = false, bool hasMeta = true) + { + lock (_assetsLock) + { + _touchedAssets.Add(new { path, imported, hasMeta }); + // Keep only last 100 entries + while (_touchedAssets.Count > 100) + { + _touchedAssets.RemoveAt(0); + } + } + } + + /// + /// Clears touched assets list. + /// + public static void ClearTouchedAssets() + { + lock (_assetsLock) + { + _touchedAssets.Clear(); + } + } + + /// + /// Builds pending operations state from AsyncOperationTracker. + /// + public static object BuildOperationsState() + { + // Get pending operations from AsyncOperationTracker + var pendingJobs = AsyncOperationTracker.GetPendingJobs(); + var pending = pendingJobs.Select(job => new + { + id = job.OpId, + type = job.Type.ToString(), + progress = job.Progress, + message = job.Message + }).ToArray(); + + return new + { + pending = pending + }; + } + + /// + /// Builds policy state. + /// + public static object BuildPolicyState() + { + return new + { + writeGuardInPlayMode = "deny", // Default: deny writes in Play mode + refreshMode = "debounced", + consoleReadPolicy = "must_clear_before_read" + }; + } + + /// + /// Creates a Console state delta. + /// + public static object CreateConsoleDelta(string sinceToken = null, int? unreadCount = null, object[] lastErrors = null) + { + var consoleDelta = new Dictionary(); + + if (sinceToken != null) consoleDelta["sinceToken"] = sinceToken; + if (unreadCount.HasValue) consoleDelta["unreadCount"] = unreadCount.Value; + if (lastErrors != null) consoleDelta["lastErrors"] = lastErrors; + + return new { console = consoleDelta }; + } + + /// + /// Creates a Compilation state delta. + /// + public static object CreateCompilationDelta(bool? isCompiling = null, string status = null, int? errors = null, int? warnings = null) + { + var editorDelta = new Dictionary(); + var compilationDelta = new Dictionary(); + + if (isCompiling.HasValue) editorDelta["isCompiling"] = isCompiling.Value; + + if (status != null) compilationDelta["status"] = status; + if (errors.HasValue) compilationDelta["errors"] = errors.Value; + if (warnings.HasValue) compilationDelta["warnings"] = warnings.Value; + + if (compilationDelta.Count > 0) + { + editorDelta["lastCompilation"] = compilationDelta; + } + + return new { editor = editorDelta }; + } + + /// + /// Creates a Scene state delta. + /// + public static object CreateSceneDelta(string activeScenePath = null, bool? dirty = null) + { + var sceneDelta = new Dictionary(); + + if (activeScenePath != null) sceneDelta["activeScenePath"] = activeScenePath; + if (dirty.HasValue) sceneDelta["dirty"] = dirty.Value; + + return new { scene = sceneDelta }; + } + + /// + /// Creates an Asset state delta. + /// + public static object CreateAssetDelta(object[] touchedAssets) + { + return new + { + assets = new + { + touched = touchedAssets + } + }; + } + + /// + /// Creates an Editor state delta. + /// + public static object CreateEditorDelta(string focusedWindow = null, bool? isUpdating = null) + { + var editorDelta = new Dictionary(); + + if (focusedWindow != null) editorDelta["focusedWindow"] = focusedWindow; + if (isUpdating.HasValue) editorDelta["isUpdating"] = isUpdating.Value; + + return new { editor = editorDelta }; + } + + /// + /// Creates an Operations state delta. + /// + public static object CreateOperationsDelta(object[] pendingOperations) + { + return new + { + operations = new + { + pending = pendingOperations + } + }; + } + + /// + /// Validates client state revision and returns conflict response if mismatched. + /// Returns null if validation passes. + /// + public static object ValidateClientRevision(int? clientRev) + { + if (!clientRev.HasValue) + { + // No client revision provided - accept but don't enforce + return null; + } + + int currentRev = GetCurrentRevision(); + if (clientRev.Value != currentRev) + { + // State mismatch - return 409-like conflict response with fresh state + return new + { + success = false, + message = $"State revision mismatch. Client: {clientRev.Value}, Server: {currentRev}. Please refresh state.", + code = "state_revision_conflict", + state = BuildFullStateAndIncrement() + }; + } + + return null; // Validation passed + } + + /// + /// Validates client state revision from JObject params. + /// Returns null if validation passes, error response if conflict. + /// + public static object ValidateClientRevisionFromParams(Codely.Newtonsoft.Json.Linq.JObject @params) + { + int? clientRev = @params?["client_state_rev"]?.ToObject(); + return ValidateClientRevision(clientRev); + } + + /// + /// Merges multiple state deltas into one combined delta. + /// + public static object MergeStateDeltas(params object[] deltas) + { + if (deltas == null || deltas.Length == 0) return null; + if (deltas.Length == 1) return deltas[0]; + + // Preserve legacy behavior: if only one non-null delta is provided, return it as-is. + int nonNullCount = 0; + object single = null; + foreach (var d in deltas) + { + if (d == null) continue; + nonNullCount++; + single = d; + if (nonNullCount > 1) break; + } + if (nonNullCount == 0) return null; + if (nonNullCount == 1) return single; + + var merged = new Dictionary(); + + foreach (var delta in deltas) + { + if (delta == null) continue; + + // Prefer a JSON/dictionary representation to avoid reflection issues + // (e.g., when a state_delta is already a JObject/JToken). + Dictionary deltaDict = null; + try + { + // Codely.Newtonsoft.Json.Linq types (JObject / JToken) + if (delta is Codely.Newtonsoft.Json.Linq.JObject jObj) + { + deltaDict = jObj.ToObject>(); + } + else if (delta is Codely.Newtonsoft.Json.Linq.JToken jTok && + jTok.Type == Codely.Newtonsoft.Json.Linq.JTokenType.Object) + { + var asObj = jTok as Codely.Newtonsoft.Json.Linq.JObject; + deltaDict = (asObj ?? Codely.Newtonsoft.Json.Linq.JObject.FromObject(jTok)) + .ToObject>(); + } + else if (delta is IDictionary iDict) + { + deltaDict = new Dictionary(iDict); + } + else + { + // Last resort: serialize arbitrary objects into a JObject then into a dictionary. + var obj = Codely.Newtonsoft.Json.Linq.JObject.FromObject(delta); + deltaDict = obj.ToObject>(); + } + } + catch + { + deltaDict = null; + } + + if (deltaDict != null) + { + foreach (var kv in deltaDict) + { + if (kv.Value == null) continue; + + if (merged.ContainsKey(kv.Key)) + { + // Merge nested dictionaries (one level deep, consistent with legacy behavior) + var existingDict = merged[kv.Key] as Dictionary; + var newDict = kv.Value as Dictionary; + if (existingDict != null && newDict != null) + { + foreach (var nk in newDict) + { + existingDict[nk.Key] = nk.Value; + } + } + else + { + merged[kv.Key] = kv.Value; + } + } + else + { + merged[kv.Key] = kv.Value; + } + } + continue; + } + + // Fallback: reflection-based merge (skip indexer properties to avoid invocation errors) + try + { + var props = delta.GetType().GetProperties(); + foreach (var prop in props) + { + if (prop.GetIndexParameters().Length > 0) continue; + + object value = null; + try { value = prop.GetValue(delta); } catch { continue; } + if (value == null) continue; + + if (merged.ContainsKey(prop.Name)) + { + // Merge nested dictionaries + if (merged[prop.Name] is Dictionary existingDict && + value is Dictionary newDict) + { + foreach (var kv in newDict) + { + existingDict[kv.Key] = kv.Value; + } + } + else + { + merged[prop.Name] = value; + } + } + else + { + merged[prop.Name] = value; + } + } + } + catch + { + // Ignore merge errors from unexpected delta shapes. + } + } + + return merged.Count > 0 ? merged : null; + } + } +} + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/StateComposer.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/StateComposer.cs.meta new file mode 100644 index 000000000..290b91ce9 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/StateComposer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6e6177725a55072419d7584603153d01 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/StatusHelper.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/StatusHelper.cs new file mode 100644 index 000000000..9bd85217d --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/StatusHelper.cs @@ -0,0 +1,94 @@ +using System; +using System.IO; +using Codely.Newtonsoft.Json; +using UnityEngine; + +namespace UnityTcp.Editor.Helpers +{ + /// + /// Helper class for status and heartbeat management + /// + public static class StatusHelper + { + /// + /// Write heartbeat status to the main config file + /// + public static void WriteHeartbeat(int currentUnityPort, bool reloading, int heartbeatSeq, string reason = null) + { + try + { + // Load existing config or create new one + var existingConfig = PortManager.GetStoredPortConfig(); + + var portConfig = new PortManager.PortConfig + { + unity_port = currentUnityPort, + created_date = existingConfig?.created_date ?? DateTime.UtcNow.ToString("O"), + project_path = Application.dataPath, + + // Update status fields + reloading = reloading, + reason = reason ?? (reloading ? "reloading" : "ready"), + seq = heartbeatSeq, + last_heartbeat = DateTime.UtcNow.ToString("O") + }; + + PortManager.SavePortConfig(portConfig); + + // Also maintain backwards compatibility by writing to legacy status location + try + { + // Allow override of status directory (useful in CI/containers) + string legacyDir = Environment.GetEnvironmentVariable("UNITY_TCP_STATUS_DIR"); + if (string.IsNullOrWhiteSpace(legacyDir)) + { + legacyDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".unity-tcp"); + } + Directory.CreateDirectory(legacyDir); + string legacyFilePath = Path.Combine(legacyDir, $"unity-tcp-status-{ComputeProjectHash(Application.dataPath)}.json"); + var legacyPayload = new + { + unity_port = currentUnityPort, + reloading, + reason = reason ?? (reloading ? "reloading" : "ready"), + seq = heartbeatSeq, + project_path = Application.dataPath, + last_heartbeat = DateTime.UtcNow.ToString("O") + }; + File.WriteAllText(legacyFilePath, JsonConvert.SerializeObject(legacyPayload), new System.Text.UTF8Encoding(false)); + } + catch + { + // Ignore legacy write failures + } + } + catch (Exception) + { + // Best-effort only + } + } + + /// + /// Compute a short hash of the project path for unique identification + /// + public static string ComputeProjectHash(string input) + { + try + { + using var sha1 = System.Security.Cryptography.SHA1.Create(); + byte[] bytes = System.Text.Encoding.UTF8.GetBytes(input ?? string.Empty); + byte[] hashBytes = sha1.ComputeHash(bytes); + var sb = new System.Text.StringBuilder(); + foreach (byte b in hashBytes) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString()[..8]; + } + catch + { + return "default"; + } + } + } +} diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/StatusHelper.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/StatusHelper.cs.meta new file mode 100644 index 000000000..da2e67f24 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/StatusHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 12eba3a4a35da834eba2ca7f63decd97 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/TcpLog.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/TcpLog.cs new file mode 100644 index 000000000..9957e4d9c --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/TcpLog.cs @@ -0,0 +1,33 @@ +using UnityEditor; +using UnityEngine; + +namespace UnityTcp.Editor.Helpers +{ + internal static class TcpLog + { + private const string Prefix = "Codely Bridge:"; + + private static bool IsDebugEnabled() + { + try { return EditorPrefs.GetBool("UnityTcp.DebugLogs", false); } catch { return false; } + } + + public static void Info(string message, bool always = true) + { + if (!always && !IsDebugEnabled()) return; + Debug.Log($"{Prefix} {message}"); + } + + public static void Warn(string message) + { + Debug.LogWarning($"{Prefix} {message}"); + } + + public static void Error(string message) + { + Debug.LogError($"{Prefix} {message}"); + } + } +} + + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/TcpLog.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/TcpLog.cs.meta new file mode 100644 index 000000000..106a96e28 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/TcpLog.cs.meta @@ -0,0 +1,13 @@ +fileFormatVersion: 2 +guid: a1da5b6ee62708c4a9df5c2a0f624f1c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/TelemetryHelper.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/TelemetryHelper.cs new file mode 100644 index 000000000..871f8da55 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/TelemetryHelper.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using UnityEngine; + +namespace UnityTcp.Editor.Helpers +{ + /// + /// Unity Bridge telemetry helper for collecting usage analytics + /// Following privacy-first approach with easy opt-out mechanisms + /// + public static class TelemetryHelper + { + private const string TELEMETRY_DISABLED_KEY = "UnityTcp.TelemetryDisabled"; + private const string CUSTOMER_UUID_KEY = "UnityTcp.CustomerUUID"; + private static Action> s_sender; + + /// + /// Check if telemetry is enabled (can be disabled via Environment Variable or EditorPrefs) + /// + public static bool IsEnabled + { + get + { + // Check environment variables first + var envDisable = Environment.GetEnvironmentVariable("DISABLE_TELEMETRY"); + if (!string.IsNullOrEmpty(envDisable) && + (envDisable.ToLower() == "true" || envDisable == "1")) + { + return false; + } + + var unityMcpDisable = Environment.GetEnvironmentVariable("UNITY_MCP_DISABLE_TELEMETRY"); + if (!string.IsNullOrEmpty(unityMcpDisable) && + (unityMcpDisable.ToLower() == "true" || unityMcpDisable == "1")) + { + return false; + } + + // Honor protocol-wide opt-out as well + var mcpDisable = Environment.GetEnvironmentVariable("MCP_DISABLE_TELEMETRY"); + if (!string.IsNullOrEmpty(mcpDisable) && + (mcpDisable.Equals("true", StringComparison.OrdinalIgnoreCase) || mcpDisable == "1")) + { + return false; + } + + // Check EditorPrefs + return !UnityEditor.EditorPrefs.GetBool(TELEMETRY_DISABLED_KEY, false); + } + } + + /// + /// Get or generate customer UUID for anonymous tracking + /// + public static string GetCustomerUUID() + { + var uuid = UnityEditor.EditorPrefs.GetString(CUSTOMER_UUID_KEY, ""); + if (string.IsNullOrEmpty(uuid)) + { + uuid = System.Guid.NewGuid().ToString(); + UnityEditor.EditorPrefs.SetString(CUSTOMER_UUID_KEY, uuid); + } + return uuid; + } + + /// + /// Disable telemetry (stored in EditorPrefs) + /// + public static void DisableTelemetry() + { + UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, true); + } + + /// + /// Enable telemetry (stored in EditorPrefs) + /// + public static void EnableTelemetry() + { + UnityEditor.EditorPrefs.SetBool(TELEMETRY_DISABLED_KEY, false); + } + + /// + /// Send telemetry data to Python server for processing + /// This is a lightweight bridge - the actual telemetry logic is in Python + /// + public static void RecordEvent(string eventType, Dictionary data = null) + { + if (!IsEnabled) + return; + + try + { + var telemetryData = new Dictionary + { + ["event_type"] = eventType, + ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + ["customer_uuid"] = GetCustomerUUID(), + ["unity_version"] = Application.unityVersion, + ["platform"] = Application.platform.ToString(), + ["source"] = "unity_bridge" + }; + + if (data != null) + { + telemetryData["data"] = data; + } + + // Send to Python server via existing bridge communication + // The Python server will handle actual telemetry transmission + SendTelemetryToPythonServer(telemetryData); + } + catch (Exception e) + { + // Never let telemetry errors interfere with functionality + if (IsDebugEnabled()) + { + Debug.LogWarning($"Telemetry error (non-blocking): {e.Message}"); + } + } + } + + /// + /// Allows the bridge to register a concrete sender for telemetry payloads. + /// + public static void RegisterTelemetrySender(Action> sender) + { + Interlocked.Exchange(ref s_sender, sender); + } + + public static void UnregisterTelemetrySender() + { + Interlocked.Exchange(ref s_sender, null); + } + + /// + /// Record bridge startup event + /// + public static void RecordBridgeStartup() + { + RecordEvent("bridge_startup", new Dictionary + { + ["bridge_version"] = "3.0.2", + ["auto_connect"] = "unknown" // TODO: we have no such field + }); + } + + /// + /// Record bridge connection event + /// + public static void RecordBridgeConnection(bool success, string error = null) + { + var data = new Dictionary + { + ["success"] = success + }; + + if (!string.IsNullOrEmpty(error)) + { + data["error"] = error.Substring(0, Math.Min(200, error.Length)); + } + + RecordEvent("bridge_connection", data); + } + + /// + /// Record tool execution from Unity side + /// + public static void RecordToolExecution(string toolName, bool success, float durationMs, string error = null) + { + var data = new Dictionary + { + ["tool_name"] = toolName, + ["success"] = success, + ["duration_ms"] = Math.Round(durationMs, 2) + }; + + if (!string.IsNullOrEmpty(error)) + { + data["error"] = error.Substring(0, Math.Min(200, error.Length)); + } + + RecordEvent("tool_execution_unity", data); + } + + private static void SendTelemetryToPythonServer(Dictionary telemetryData) + { + var sender = Volatile.Read(ref s_sender); + if (sender != null) + { + try + { + sender(telemetryData); + return; + } + catch (Exception e) + { + if (IsDebugEnabled()) + { + Debug.LogWarning($"Telemetry sender error (non-blocking): {e.Message}"); + } + } + } + + // Fallback: log when debug is enabled + if (IsDebugEnabled()) + { + Debug.Log($"MCP-TELEMETRY: {telemetryData["event_type"]}"); + } + } + + private static bool IsDebugEnabled() + { + try + { + return UnityEditor.EditorPrefs.GetBool("UnityTcp.DebugLogs", false); + } + catch + { + return false; + } + } + } +} \ No newline at end of file diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/TelemetryHelper.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/TelemetryHelper.cs.meta new file mode 100644 index 000000000..a45d51bf0 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/TelemetryHelper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 75e6d1e08c44ad84d91f82a4baeea37e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/UnityStateDirtyHook.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/UnityStateDirtyHook.cs new file mode 100644 index 000000000..c2314e2e1 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/UnityStateDirtyHook.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace UnityTcp.Editor.Helpers +{ + /// + /// Hook mechanism for external tools to notify Unity when they've modified files + /// that may affect Unity's state (scripts, assets, scenes, etc.). + /// This is NOT exposed as a tool to LLM - it's an internal notification system. + /// + public static class UnityStateDirtyHook + { + /// + /// File change types that can affect Unity state. + /// + public enum FileChangeType + { + ScriptModified, // .cs files modified + AssetModified, // Asset files (.prefab, .mat, .asset, etc.) modified + SceneModified, // .unity scene files modified + ShaderModified, // .shader files modified + ConfigModified, // Project settings, package.json, etc. modified + UIModified, // .uxml, .uss files modified + Unknown // Other file types + } + + private static readonly Queue _pendingNotifications = new Queue(); + private static readonly object _notificationLock = new object(); + private static bool _refreshScheduled = false; + + /// + /// Notification record for dirty file changes. + /// + public class DirtyNotification + { + public DateTime Timestamp { get; set; } + public FileChangeType ChangeType { get; set; } + public string FilePath { get; set; } + public string ToolName { get; set; } + public bool RequiresReimport { get; set; } + public bool RequiresCompilation { get; set; } + } + + /// + /// Called by external agentic tools (edit, write, etc.) to notify Unity of file changes. + /// This is the main entry point for the hook system. + /// + /// Path to the file that was modified + /// Name of the tool that made the change (for logging) + public static void NotifyFileChanged(string filePath, string toolName = "unknown") + { + if (string.IsNullOrEmpty(filePath)) + return; + + try + { + // Normalize path + filePath = filePath.Replace('\\', '/'); + + // Determine change type and required actions + var changeType = DetermineChangeType(filePath); + bool requiresReimport = ShouldReimport(filePath, changeType); + bool requiresCompilation = RequiresCompilation(filePath, changeType); + + var notification = new DirtyNotification + { + Timestamp = DateTime.UtcNow, + ChangeType = changeType, + FilePath = filePath, + ToolName = toolName, + RequiresReimport = requiresReimport, + RequiresCompilation = requiresCompilation + }; + + lock (_notificationLock) + { + _pendingNotifications.Enqueue(notification); + + // Schedule refresh on next editor update + if (!_refreshScheduled) + { + _refreshScheduled = true; + EditorApplication.delayCall += ProcessPendingNotifications; + } + } + + Debug.Log($"[UnityStateDirtyHook] Notified: {changeType} - {filePath} (from {toolName})"); + } + catch (Exception e) + { + Debug.LogWarning($"[UnityStateDirtyHook] Failed to process notification for {filePath}: {e.Message}"); + } + } + + /// + /// Process all pending dirty notifications and trigger appropriate Unity actions. + /// + private static void ProcessPendingNotifications() + { + List toProcess; + + lock (_notificationLock) + { + if (_pendingNotifications.Count == 0) + { + _refreshScheduled = false; + return; + } + + toProcess = new List(_pendingNotifications); + _pendingNotifications.Clear(); + _refreshScheduled = false; + } + + // Group by action type + var needsReimport = toProcess.Where(n => n.RequiresReimport).Select(n => n.FilePath).Distinct().ToList(); + var needsCompilation = toProcess.Any(n => n.RequiresCompilation); + + // Process reimports + if (needsReimport.Count > 0) + { + Debug.Log($"[UnityStateDirtyHook] Reimporting {needsReimport.Count} assets..."); + foreach (var path in needsReimport) + { + // Convert to Unity-relative path if needed + string unityPath = ConvertToUnityPath(path); + if (!string.IsNullOrEmpty(unityPath)) + { + AssetDatabase.ImportAsset(unityPath, ImportAssetOptions.ForceUpdate); + } + } + AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate); + } + + // Increment state revision + StateComposer.IncrementRevision(); + + // Log summary + var summary = new + { + processed = toProcess.Count, + reimported = needsReimport.Count, + needsCompilation = needsCompilation, + byType = toProcess.GroupBy(n => n.ChangeType).ToDictionary(g => g.Key.ToString(), g => g.Count()) + }; + + Debug.Log($"[UnityStateDirtyHook] Processed notifications: {Codely.Newtonsoft.Json.JsonConvert.SerializeObject(summary)}"); + + // If compilation is needed, it will happen automatically via Unity's asset pipeline + if (needsCompilation) + { + Debug.Log("[UnityStateDirtyHook] Script changes detected - Unity will trigger compilation automatically"); + } + } + + /// + /// Determine the type of change based on file extension. + /// + private static FileChangeType DetermineChangeType(string filePath) + { + string ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant(); + + switch (ext) + { + case ".cs": + return FileChangeType.ScriptModified; + case ".unity": + case ".scene": + return FileChangeType.SceneModified; + case ".shader": + case ".shadergraph": + case ".shadersubgraph": + return FileChangeType.ShaderModified; + case ".prefab": + case ".mat": + case ".asset": + case ".png": + case ".jpg": + case ".jpeg": + case ".psd": + case ".fbx": + case ".obj": + case ".mp3": + case ".wav": + case ".anim": + case ".controller": + return FileChangeType.AssetModified; + case ".uxml": + case ".uss": + return FileChangeType.UIModified; + case ".json": + case ".asmdef": + case ".asmref": + return FileChangeType.ConfigModified; + default: + return FileChangeType.Unknown; + } + } + + /// + /// Determine if a file change requires reimporting in Unity. + /// + private static bool ShouldReimport(string filePath, FileChangeType changeType) + { + // Check if file is in Assets/ folder + if (!IsInAssetsFolder(filePath)) + return false; + + switch (changeType) + { + case FileChangeType.ScriptModified: + case FileChangeType.ShaderModified: + case FileChangeType.AssetModified: + case FileChangeType.SceneModified: + case FileChangeType.UIModified: + return true; + case FileChangeType.ConfigModified: + return filePath.Contains("package.json") || filePath.Contains(".asmdef"); + default: + return false; + } + } + + /// + /// Determine if a file change requires script compilation. + /// + private static bool RequiresCompilation(string filePath, FileChangeType changeType) + { + return changeType == FileChangeType.ScriptModified && IsInAssetsFolder(filePath); + } + + /// + /// Check if a file path is within the Unity Assets folder. + /// + private static bool IsInAssetsFolder(string filePath) + { + string normalizedPath = filePath.Replace('\\', '/'); + return normalizedPath.Contains("/Assets/") || normalizedPath.StartsWith("Assets/"); + } + + /// + /// Convert an absolute or relative path to Unity-relative path (Assets/...). + /// + private static string ConvertToUnityPath(string filePath) + { + string normalized = filePath.Replace('\\', '/'); + + // Already Unity-relative + if (normalized.StartsWith("Assets/")) + return normalized; + + // Extract Assets/... portion + int assetsIndex = normalized.IndexOf("/Assets/"); + if (assetsIndex >= 0) + return normalized.Substring(assetsIndex + 1); // Skip the leading / + + // Check if it's relative to project root + string projectRoot = Application.dataPath.Replace("/Assets", "").Replace('\\', '/'); + if (normalized.StartsWith(projectRoot)) + { + string relativePath = normalized.Substring(projectRoot.Length).TrimStart('/'); + if (relativePath.StartsWith("Assets/")) + return relativePath; + } + + return null; + } + + /// + /// Get statistics about recent dirty notifications (for debugging). + /// + public static object GetStatistics() + { + lock (_notificationLock) + { + return new + { + pending = _pendingNotifications.Count, + refreshScheduled = _refreshScheduled + }; + } + } + + /// + /// Clear all pending notifications (for testing/debugging). + /// + public static void ClearPendingNotifications() + { + lock (_notificationLock) + { + _pendingNotifications.Clear(); + _refreshScheduled = false; + } + Debug.Log("[UnityStateDirtyHook] Cleared all pending notifications"); + } + } +} + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/UnityStateDirtyHook.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/UnityStateDirtyHook.cs.meta new file mode 100644 index 000000000..238e0a966 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/UnityStateDirtyHook.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 15dbae89d2b872442a3021294af9f5bd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/Vector3Helper.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/Vector3Helper.cs new file mode 100644 index 000000000..d2cf05ca0 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/Vector3Helper.cs @@ -0,0 +1,25 @@ +using Codely.Newtonsoft.Json.Linq; +using UnityEngine; + +namespace UnityTcp.Editor.Helpers +{ + /// + /// Helper class for Vector3 operations + /// + public static class Vector3Helper + { + /// + /// Parses a JArray into a Vector3 + /// + /// The array containing x, y, z coordinates + /// A Vector3 with the parsed coordinates + /// Thrown when array is invalid + public static Vector3 ParseVector3(JArray array) + { + if (array == null || array.Count != 3) + throw new System.Exception("Vector3 must be an array of 3 floats [x, y, z]."); + return new Vector3((float)array[0], (float)array[1], (float)array[2]); + } + } +} + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/Vector3Helper.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/Vector3Helper.cs.meta new file mode 100644 index 000000000..280381ca2 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/Vector3Helper.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f8514fd42f23cb641a36e52550825b35 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/WriteGuard.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/WriteGuard.cs new file mode 100644 index 000000000..22f2cceb5 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/WriteGuard.cs @@ -0,0 +1,196 @@ +using System; +using UnityEditor; +using UnityEngine; + +namespace UnityTcp.Editor.Helpers +{ + /// + /// Write protection guard for Unity Editor operations. + /// Prevents unsafe modifications during Play mode or other restricted states. + /// + public static class WriteGuard + { + /// + /// Write guard policy enum. + /// + public enum Policy + { + /// + /// Deny all writes in Play/Paused mode (default, safest) + /// + Deny, + + /// + /// Allow writes but log warnings (experimental, use with caution) + /// + AllowWithWarning + } + + // Current policy (default: Deny) + private static Policy _currentPolicy = Policy.Deny; + + /// + /// Gets the current write guard policy. + /// + public static Policy CurrentPolicy + { + get => _currentPolicy; + set => _currentPolicy = value; + } + + /// + /// Checks if write operations are allowed in current editor state. + /// Returns null if allowed, or an error response object if blocked. + /// + /// Name of the operation being attempted (for logging) + /// Error response if blocked, null if allowed + public static object CheckWriteAllowed(string operationName = "write operation") + { + // Check if we're in Play or Paused mode + bool inPlayMode = EditorApplication.isPlaying || EditorApplication.isPaused; + + if (!inPlayMode) + { + // Not in Play mode - writes always allowed + return null; + } + + // In Play mode - check policy + switch (_currentPolicy) + { + case Policy.Deny: + // Block the write and return error + string errorMessage = $"Cannot perform {operationName} in Play/Paused mode. " + + "Stop Play mode first, or change write guard policy to 'allow_with_warning' (experimental)."; + + Debug.LogWarning($"[WriteGuard] {errorMessage}"); + + return Response.Error("write_blocked_in_play_mode", new + { + code = "write_blocked_in_play_mode", + message = errorMessage, + currentPlayMode = EditorApplication.isPlaying ? "playing" : "paused", + policy = "deny", + suggestion = "Stop Play mode or change policy to 'allow_with_warning'" + }); + + case Policy.AllowWithWarning: + // Allow but warn + string warningMessage = $"[EXPERIMENTAL] Performing {operationName} in Play/Paused mode. " + + "This may cause unexpected behavior or data loss!"; + + Debug.LogWarning($"[WriteGuard] {warningMessage}"); + + // Log to audit trail + LogAuditEvent(operationName, "allowed_with_warning"); + + // Return null to allow operation + return null; + + default: + return Response.Error("Invalid write guard policy"); + } + } + + /// + /// Force-checks if write operations are blocked (returns true if blocked). + /// + public static bool IsWriteBlocked() + { + if (!EditorApplication.isPlaying && !EditorApplication.isPaused) + { + return false; // Not in Play mode - not blocked + } + + return _currentPolicy == Policy.Deny; + } + + /// + /// Sets the write guard policy. + /// + /// Policy to set ("deny" or "allow_with_warning") + /// Success or error response + public static object SetPolicy(string policy) + { + if (string.IsNullOrEmpty(policy)) + { + return Response.Error("Policy parameter is required"); + } + + string lowerPolicy = policy.ToLowerInvariant(); + + switch (lowerPolicy) + { + case "deny": + _currentPolicy = Policy.Deny; + Debug.Log("[WriteGuard] Write guard policy set to: Deny"); + return Response.Success($"Write guard policy set to 'deny'.", new { policy = "deny" }); + + case "allow_with_warning": + _currentPolicy = Policy.AllowWithWarning; + Debug.LogWarning("[WriteGuard] Write guard policy set to: AllowWithWarning (EXPERIMENTAL)"); + return Response.Success($"Write guard policy set to 'allow_with_warning' (experimental).", + new { policy = "allow_with_warning", warning = "This is experimental and may cause issues" }); + + default: + return Response.Error($"Invalid policy: '{policy}'. Valid policies are: 'deny', 'allow_with_warning'"); + } + } + + /// + /// Gets the current policy as a string. + /// + public static string GetPolicyString() + { + return _currentPolicy == Policy.Deny ? "deny" : "allow_with_warning"; + } + + /// + /// Logs audit events for write operations in Play mode. + /// + private static void LogAuditEvent(string operationName, string action) + { + // In production, this could write to a file or telemetry system + var auditEntry = new + { + timestamp = DateTime.UtcNow.ToString("o"), + operation = operationName, + action = action, + playMode = EditorApplication.isPlaying ? "playing" : (EditorApplication.isPaused ? "paused" : "stopped"), + scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name + }; + + Debug.Log($"[WriteGuard Audit] {Codely.Newtonsoft.Json.JsonConvert.SerializeObject(auditEntry)}"); + } + + /// + /// Creates a write-blocked error response with detailed information. + /// + public static object CreateBlockedResponse(string operationName, string additionalInfo = null) + { + string message = $"Write operation '{operationName}' blocked in Play/Paused mode."; + if (!string.IsNullOrEmpty(additionalInfo)) + { + message += $" {additionalInfo}"; + } + + return Response.Error("write_blocked_in_play_mode", new + { + code = "write_blocked_in_play_mode", + operation = operationName, + currentMode = EditorApplication.isPlaying ? "playing" : "paused", + policy = GetPolicyString(), + message = message, + remediation = new + { + options = new[] + { + "Stop Play mode (recommended)", + "Change write guard policy to 'allow_with_warning' (experimental, use with caution)" + } + } + }); + } + } +} + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/WriteGuard.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/WriteGuard.cs.meta new file mode 100644 index 000000000..bd577aa8d --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Helpers/WriteGuard.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 593f57416aee1924495c4971200b544b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Icons.meta new file mode 100644 index 000000000..6b881ede0 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Icons.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3d6ea1a77c9612d4e951e572b2ce2dbb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/cli.png b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/cli.png new file mode 100644 index 0000000000000000000000000000000000000000..e45b096b960e98051f581e070228018b275a76ad GIT binary patch literal 664 zcmV;J0%!e+P)P?!KXRjNQxTB)ZBb%HViBV+{5ki>l!r3!I^ zlMvZK%_pId=SO(I6Z^f`00jmA8xqwpn=Q+Z0bO*s#?vb3VGV?i+KrpnD2Gr1vyY|e zIq=N@OvnfJ&VX9yu5Je#Xqb1-g6EhejEO%L!3IIVbg{H@7W@IzGvG03G=c_3j-3dB zIo(08)v9kmA=uldYp^~>&^*}Kek>0BeqoPL#ecHh!9aEJ9Q;rF$|2o=xf=ANAfeL| z2aYP@bevQ`-Z2vT!wU{_N??dzH#kT4PtDOPNPGvb2t46|PiH1DZ$5edYJx@*3t;=; zeFnWE5(^*&113Z8R*9?%#(CsiK6j}7}$!qQzzB(8<{6pDv=RJ*TMDoZY9 zXHiRKUXA){B^hQGc_VWHEMD*j=d<%h<^p_UM8+nIypebe$dHYZthx9%vO<7v3IVz) z1n8y^pc|=xur+yA@QoIkPndv}|HLi9@HxhXvW9n@>+t3EBi2Dbp%x;Pu9)Xl{|pQ) z+#RtdZ%a_ge$^we14fuoehYViZoP5LYEms2Q@e31y!;3P_?kR*p_VHV7|RZ03Tq;# yem7lwT8vP>~qmsbK>yP@r~s%b}@^pqEsL4!IOcb0~VLE1*D8 zppAVCPzQD|MNy`{6bRzjhX6LOq zumkjph{y46VW6ky|IhCko`E(*guFZV%@`Sz5D_iMzOeN2)V=C!HV9a4c@d?j_wPCI z!A^10W_eUxjeT|hcG?u_U@c43Ygb-49jB=EJ6Al7w|;cs(7Vx?cA(9PYV8ar2EKUk z-F(6r+V7wmb$^@m}-4#$= z*AVyNXJ-ZI?{j3@J{gqLJ)BlG+$V zz|5Ong!mIVkWvLY)TN@47R|U;RuQx4);dxocdYN}h(=1QECF^!oaAA}b}}{KzE<2i4Dm5A0oUUkon=8%TmK7uj~gG0SAukF-vxBt)v?dk zh;z;U#lt1H4kkYOCg6IUTls_!y?4D*e7xv)eDp;?an~)!x#mdu9UogNpww;>=UjJf zF3vSeIX>FwTkX~@5wPU0W=d)|@FdPPO}f?b(Q^UavP9w>nF$CBYw;0&2_ECU1ri-= zahz+G6c{HB9utNqkt{R?p z1Ocf(8B1q4$@D6_IKcv}ySys~))9o>a5K8XqslgE{yUclVVvu~kA2ScF2M+>^mW;w zS-l8|)orS*QPz0fTOIopxb+^#TBKvjrb+o{@EI1}|HKuDfJQ;tW$CdVAG+}n?ZPOc zo~nJf+*3U#s{9WyCja#5qTUmX1wIumF1<Q6RnELR{vQ9%_+1+fuq9(s}R#&c^br3sgvPO*u(~ z%WjY$lE|y_i>+`PynxW1r-F`22k|80zkmb@XbHh|fI4;HHVNmGh|&w(kiA~} zq#504Z5U`vD-Z#tz3x5_BX?bh)|;GIdN!8$NT6S;d9OnkVeK)G@}x}0btLpZ{s{qD zpv_$q>$FjNTn3(y=mIOSPvWBt1QF5uo8F32rjV$7j&$T7gic=O)Ubd@GKs2>Ou zT>SQup?AP=2S#uB3)e_Cq&drM1P zm{4ck&WMbd)g2&eHF%u|oKDEIvSe#$0WFPzB_Z`V*L#ybyLFb!onP^Lza)(dXd%8N z5w7<#+y~_GTlM$W3dwPf--E}S+3J)pU!3$c3-gD1vt{uS@xN}IbKk@9?eWg{GUl=P z=z)82b5`6{>DT=fu0mjSU^zaX*hYNJ%JdM4bAGvNEdnp3H^j$=sETtfC`-0VXr4se z*yXx)o%ks8W-#&#zvEmzLiH%O79UYuyV|WQan2fFpB54hLfKF)^#iRVGTT+AZm^rb z4Iw3@b7M+Ke1wqG>Bejmg@EotgR%r1bZ7`!PfNB4w5$w5P7W4Va$%ep9!h2(%nr)d z0M2tq+_=lR<(D1>(h`3bDVoB7Z27VDo{}{~<)A>T2cbhl6pAISlmoX_Q9XB4;-GO% z>fYRg7R?&iIfzE0mFySw+_ZwR-!D4^H{^xf;#`nl%X%iLXk-vSg|RcsZW-e^`l`FN zox|DtcL$+EO~m>{BiVO~`m$_Gb>MIQm@|aeWcT56{>4s7^CWYu$=t%M`xRPa;HqdQ z`)h@?qq;8>r)47Q{d)eZpF@WY+|S)ki#DQ>Bg-O}**K#8@4xn0mh&3Qnot^V9Y~H` zYWF3f#PYsjgA&&j@oZbaRk1$di75fy|M}a{sw%!BA|hfwnHb0nCHE{q8$+ne*QOoiwY(6RF!s)o)Wt~*z z#c2guxltA$j6@?dJ@LJ>$sOX};-*WBvxyZ`*e6ADrOp!!nQP9nd4Eew!Eb=AQAZpW zl3su|fh}&CkUn*a*0owzS54#7qSzuM@yMvrFoFh#`OZEpuRZvzBZBd-9DCc{RWx71 gvh&$y7WX*c1MzOIS|N|h_W%F@07*qoM6N<$g6u3!CIA2c literal 0 HcmV?d00001 diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/codely.png.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/codely.png.meta new file mode 100644 index 000000000..a4b9efb04 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/codely.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 7edd824621050a447a76310d055e604a +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect.png b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect.png new file mode 100644 index 0000000000000000000000000000000000000000..d81741daed3e014f09755f0bb280c27840570f60 GIT binary patch literal 5375 zcmd5=`6HB3_aAE^O@&4%N>R#|t;jM8$uf4*jG?mcjTt-HqHL8dBl|jo8C!Nn6lERz zk~JY&hKw<`ypQ+$>-P_Me|VOA&pn@W?m729&pn^l2Kri@?0oDX5QtOz;R7QOh%o?o z&pOEp+>_(VZvqcCkB5&@AkZzHL21bl!z08hD`qwyT+TekV=H(|(1|z^9f&KchWuc7 za0&I^%h~Wr-s*WMEJy$Ddv)PuqcJauf&6Rv0wn@Zjmkhj7@iVrPLu~0T^B;%lAC)T zbU(JrvPVXV*fpg!16GT6Z%~@N(08zS)W|YYwN)uz*;5_yY(IyX)7O^FBJY;1asmYM zdC5HT`ZigUs@)Ol(bL*fe16rU(j0~Q`&pOXkGS{7AZsf zLn2!A{NV`TsGP4xZCO;6YvmH#Ie{UzUfr1P$7Q}aLML*&MT^V?0y%_YgCMz8n0lv; z;`8+0o=Y+u_OV%jWpbiEeAC~R7L#NmvGJ8j+U0X=nJa+7%e=j%WKl4oV5fn@LtYh! zD}z8UL!8F#5M zB!9FabR+6mx*su@)AWy~n}O_s(0em1VuMfvfElTK>Q5?1XC`JSqkB7Z#Xs6=!LHK~1eQwly9ypHycKLny1ORgO

%8S?Ti9TTOcPPxR{W>z_^meYskC4K<-`X?N@11*}Z7&)5|`7z1EIF(Ptn>+JE< z`=!iGazOilY`oC$WbQ{*_5T?_!jdR%vmC9D0ry@C%CGg##+J5t2UWXLr+}V~NPNK2 zF3~j*YeKhB0kYMipwxbPIkU6{^2+sC!#)&8C)H>z5%`a>x5&LCA0p%(f%lEUo+oS4 z9Ib)JI{+4uU>G5p)e1ywmRO`c+L{amxy3kyQLxw<5c{~mLx{G=N~X@;z++a9Q4qjh zI(fW@GK4pP!9H;<$I5sAX9W8)Os~lFOiIN_+q`@;YBZhVI=MHZcvNGj_`riP zu5BiG;DXjC6V8yeW0_*tzOvSWS}*ot>BnE9=s{a3ewQk5@&voF~)|ghz0U$Fsq!3!FpDi0SZNuR^DRtnK`A6{723x(3_dI)c znJNGvD*$+TBij>e-#1LfmwEr4cw?GPE@ZdC(sIS|0iZ|1)Xxy=-2HjD@QY!4qSKhp zZ{LE<$xOeW%2axW59r|qKb7>MZGnUal6T(=_e~>r(vPxFu`?6-M8#@qT72m(WqZqn z9n;e516z#~1;Q|WO(^EdboF!pD)EVe{27;0H6yz#PO&eh6@r3QyQ&H>ERJhWnj|M! zn-u)?T(y?cR%p{pg64iO_OZ5EBk)p=69gJ)a5Ynhe&X&(M(eWQ>1b5NSyxLlt--Pve+-a68UWA z5a{;K&75v_q3;^a-FQKEj+NlxgJz0kP`#53$)dhe4;36JRVn#PIUa!&ZxrX~gMIzH z+q@P%P3m(~jStPBIgb4GW^1a(*P_GEG-X)TJp78e#{Lt?*O`dFlBK8pT+y)W^_-$a z@um7@vh>@fKs0Hj{^CKV#(kOdx$}K!rmlN|ycpT#9K*;yzCdsbmd4dFFjlr3yTwh3Tj0i&Z5u`yjnp0Nk+Hjt1 z$U?4lwugbnxfddrrV&4k+-<|2+!oCi1DmEP%;#_9oPoc>6O9%Rzv1z}KFK}xZ#{i< z5HPos?d>{T!QJwS6TQmeB8c^t#-9vuh!ML?Xh3YcI;khAgrLKl+)UN=YwBWlS+}Cn z)H78D6es$NigSD^cCxuAD<$_7Skpsx(q z!Yqxh4P-^&I%pHrgoF!ym2#GKtC%7R+}j7(>d)VX{ysEt=w&a&ir8PX-!LJgBb1xK zTY@CPBW1p^_X4o*DxP|srJ2r|ztZ0}%sij-6ErDZ4?-ps1djfTs=16$ESUOu75cMx zG;ZnfftUDt?)VBT!!v8ZW4TikYR|Z{;2b3I4|d+i=?h!B7ZXe`np_{q8+a@lG@2Fb z#(v&%WpMkR_EzZ%<@Y7f&!2o%@5nPb90;=`ozdCUFY8Bpo-@(bBm3EJ^GU+Rk4-LH zWQ(oQxe!tN5y)5zGdT&pjGfEQ!Pl=k3mKAjpuuhMPJv{!u6?+l)LW+9P$<8H?#kR0 zZf3OVV%r1Z*TubDMQ4--jnlP%*U3%Py!o|TDIwrl7K3F+Kpi@zUy4X7Q$7m_7w_x6 zN-umpB4=15R>BuCK?HwfOmY*0Z^Ho`@O=I-IM7Jxho}BZ6(?XY+2h@ zzrP?0!0FDv%A>7>SAH6cY$T22q7@=QUF%%iX*VAg$LC#`*-bBr%qLtiv}IvamJr{tW$C-o;c19b z%x+8RgJ3z%{ku%HX*f&YP%}=trk8Z>v+RPd>vB0;Qg_#A`i zoMrn>tH>Jbh*B31oq|WWt}L;0jy5p7L~JrGsFoJtte7p9&1L;XqMDyaMYdp_Y^Y$G z0EZlkF$m*2^I3k7Cbct5Z}s*M^a<#uXXL$kezdb5FA~Ql>#V$Yq=&dsZEPl}-g@a3 z1MsUEuif4sq7>sX1wJWd6Lk`+=u={zEX`&Sh85sY=nhJUW0pYv{=%V6M{g~qx_lgt z+N?W6le*{4^ zF8RKwptTeVEn5bgI*-%jrR__#pClE((N-;UA>hker?brRJ>n#eB({FraM*xzqkylZ z)4~e1|AKM+T4|tPGOlAlv`cT%MrXfy<>xx~($)BqWpm!LcRg}5GeU)bJuw=*@0$z9 ztN;ha7zO$Ax1S9b@hw??Y`!&$t+o8w^_54;>JjeI;xOi3b+;1_7~8AxV}>$l>N_G& zF4?yhUbRU)OD7kV=C==|{vb~pe<56N=bSAYQ*PL z<4}WPg|(d0w8V;z;<&zurNZ@nA+@;Et(P<}0&!FFbsgW8DfP6>-!(DuO${ta-;+T! zei}-Rb`Bf?{_pnr64B~~E1o*YL?mGi`jdT*5cshJXOFDTgia2w{6MXk&y>ERdbU_( zVMfHeYQ>Q_mv>}-`(zndFq#cK9)%fq$xZdhfRP_ZWFOVXa>`7 zPgK1oLRn z;Qa{vj28tDY2Uk(ywf|1-I#}TR}>|v*m?#Fk6U#~OWR-NiXJLxH_fGhEF=L}+U^DL z@*;kDz3;|%YEK4B>=o@PYAiOU?S5@A92Qj`!|~+_)&DgWT}LLHAgb1#_xoRW+iAMC zZ4O7jdg>MCagYKrVluwhs>BZ0+G#vP0V4v4nu3Z2)XKpAe?~G+Bhtx*Klx^41Ayan zgRHi(=j|I+aU7*R#rz343Z?X5F9%<%t}%gfBqyj)QB6oiOkwWZ&K?akkdY`LBLm-! z5`=D2<@&)m-7zkNRD#r>2|^Ck1QOn+s@nIw>l*I+)@w@re)m@fQ9S#PaGsp9Bn9rC z;J-oKKQDEz)}$~KMXZn&>V;pRI~bv%p6?gzj&R{+Cy@Pl(IGnOsl}OQkMUL4b(1+Q zY}=lWpz5%{-p04~PJY_kQx$XJL6qnAx?bPfV^8;GdfL!>@NVo+ruJAE@npc*dG-fR znx?8aJcAAwUrjDoIo{C}){YMEzv^?2_9weVZnM%jDUM6otmEPM!gv*@Y20w_g#T{9 z@~F9SX(&=P>?4t^WuJ3I8KgYv1|kYpI0j_k%1T+E@Nt3%!ck zyR7W#Z{k*yhpN{cKJBMx$ztScz9QK^AHuLna}b|)mtQ}{HybK-hkcgsm?VvY%-J71ET z{@K9(vZ(CNk@Py&+JLcRc5?wFyGb{tk|=KpL`9qAbeRxYEe-)>8z@(v#M+Tr{EJ%nWcnFAH2IGcXHP zNhabO+VHi?T*E&fI@8-D&vUHx$ce-6^&TU# z1|^ED*Z@1NyYkNg5zflgAF{0HK5TeSYzqADG@?7ZQqVc$-5O|UlwKwEA?DwQWLLSv z;}tObc|h89<5twasTh?|xgV2tPgMivWt_>GAlk0eLb>3VA;1HWwub%#g1U9+e*ow@ B9>)Lx literal 0 HcmV?d00001 diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect.png.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect.png.meta new file mode 100644 index 000000000..bf6126ee9 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 01c5e5edc190ab348a87b7c144a18a70 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect_hover.png b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect_hover.png new file mode 100644 index 0000000000000000000000000000000000000000..729987081efadd1c7989b7970ac7bac130b5897b GIT binary patch literal 5768 zcmc&&_amGCw~xK5+M>g3KU&l%zE*1%rA6(M(hyO#XHk1ot=9O^YQ?5j>`g?18f~o@ z5f#Lqu_{)q`{=!Y!2RjoAD(sI=e(ZtKJW8B&vRby4fP+hGIKG5Kpc~*c2T6;A;H4vyOf#n!R z2LfH?)qbS*%#Uh)($a(TRc7~gp@GeF|5VnJR8bmY7m+S z$|ugteo0COSt^rD>FMOd5QI&;|Id~5;WuZLGn9Wp7oQ5U!fWK~Mv6MD$^B{JQmX!3 z+TL`zJ`D(jg}nV}uN9Ye*>OIhb^iFqWnpmiU+xu?43AHBpB0GQNWHa(_{x&-um`y{gE zc(d!dZckd*elsgDBgK9Y`;U}al=Vxcc!J5F>|&YFcZniU)R0F`e{iT=zwkwL=%M zxDF6BW;f?0gH?RsyBh8NE}lRuQZfLKy?$I(wa$Osr;4&^9^g7?_urG*Wo8!vpn5q3 z#5-qo2ui`Df|E8m9$(MrA(>YT#kXI-YC;Xg4+g)Gt;#z0@#E}aNQ0}*B9k7{>6fWb zLhHHa%Q%R0Gt$p`q5j|Hw@jSqs+1Iara-`PX^X(MWfGHUUL5$~dJ$ls$jy!jn&U)E zy+r<1Xp+qNCC_(&QlIe${0M_E!achg+@r!bbMlUmE6o6SqUicBKgz8OK~}mfJ%s?o z{73MX-0D$W>(bHWc|ae`oyZ(-#tk+_tDQqkG%ciz_P5um!_H&+@Lq7}dVp`6v|ZER zC(e(UTti7dP8C2i{ZH_)`Id6QOJL-O_`uo)L{MotaGwaylv;^U<E6 zGn|gm9DDH}gWN)VHue{f_ra_nnHph|c!sj-@qTK_7slWs7Ha-Q*!2B%%ktI*5p{7!L3$gek@+=?{Ek~a=N^kT#VM|z!q zTFKHDytwb@I7+$;#TCU0gj!KtTFDTe>ZwA(Dg4If;@CSl3`;L4y#8V~H^jeNBGgeo z$ZHUCgPRv)*~g)4*ANq_Fstv!+cxKI9e7L*y#aC%Y-TuW->mIpN*bH}l5V!@HG*tEFaHZL5V>1~u;8C-K=v)Wvkj=ctgGv%-ihYMOpabO|Ay0*k< z)vsAuArho7pXP6l?BEiA`>iJWpG-I|y&Tzh#G&ZuLGU1{#OmzW z+wi^O=T)bjI4}OLx9CPZU6_&pIVY1bi19HO5^lx0H+B6KYny{@IEx7Gn2l6LMvCx< z6=*JNv|wY{U|!{M6w)i!z{`3l)ahR5Xd4e#Q{e0$&E&TsSr=oSmwcm$brXb%G=^?b zHO)UkTk`3L8%YmN`!v1ghaM!STx_ftbl1!Gm>=5uGJFaN+f=~iO3 zc2=wbWxbKj5e#HY?|1w>$KIQ%=J5cc2|uqsZ-p*2)6JjGDXGVn3T|&@=x|~jD9(4Hu9^7p-ZM05 z(4*r`ik;{>1J|o^jB=k`QAG95)IE5=WSPU1rug7D!n|2u%lCjl16FuOKe1HM{7$K~ z=yOt|ee}=+}?YP&a1Q1Nre!x-U%)thsLHE6O>}olzxD2Lz=KLL}5*iVYjJc}H z1hsQT^|N_AQn~&yrs-=#@Pn(sY%2ZQ?5q5XEa?CoWU<18d0V- zCYqm^TvyF`n)&sRKG|^D#Nn6COlec$T?Swwq&dBG}OOv2Yb2YSt^Sdc1A0^dfjJ)&F^?vN7X}1LXK}ASn$&(70sR6 zGC!C>+`5}qVS0JB@g%0Q(DaFn)NX2tfSn(iuqDT8fdxf0T64si={NJP)_}RD^7b`o zH1821tglweb|Q7glg2y{*srw-seEgcG_s1rhSrJlYSy)NgF*XQ=AR0w%9kg;c&l%` zPL5s+HazPXI`t2jWNi?C3YLQpmp*;o)iNX(ItHE_Qe(I4Low#cdozXX4#&=+2lce@rSM;9Vv*Y_P{ zN<~rwH+*w!WLwYzpv#^o$b zbn8D8=tO|3APb~Q$JNtRWxr+H-%n&jWY?6!Ev~rXg@Q+qI8sI+>Cp&tpxnJ=Lorh& zFtq(a&7GKXO0opI83dyi-hS!FhJEr3;)l9e5YI98Tu;(tOp>vR^Go(*Th*Q(U1D2W{5 z74MtMnYoMGuld^e#vjDZz(E!;-IJannPM{}uhXg*AId!=OJplQ6}$ff|9!93*ZCOq zPTcoi)l~U@&JcS%A4Z}GJx;Ml3^ks8s2N>qLL?#?i^$T?cBH&mzYYu`|fs6QAHK{luR-VR&%Q_gl#8 z`a6A#^+Wce{q1r`ong)y^I{_vL!&X}SC42f*&iH<&Wz)SwME++FYP7Lp05m?oYL$Y=QK0f)nLvz{{e+NmInWx8BVX^GoK;nsY&xBOvuS%=1$hOx|w zT$OZuLEjzLD+dt0o>DVVwt0s*^vRgU9mXI%QbZmbH18QO((m}Vn!5F=`q+Q(f%wol zcuJo5FtXfJ#4$cEN#4r=o^CwPKy4x!^hswKd1%G%Zs*ZV;bUEpfp2R zP|h|4Ctoby*i;VQ({IKbt2I1syJCM`A2aK4)vi?wB~7%fp0XNfdv+)0?wXZ2$Ss`_2RCbaU%TMGA!>cN?HX#{ zDcQ-i^Etg2oD?jKd_2&3YfY^{vFJw`=7i5{xx_%vENv6A>JqE`+HvdQJ}a=NwAwJ7BgE>4w> zy_WII&kn2QKCc{>uV$uvA3|2&i{VMmaPluRy^9E9&M05nwR>J(mfDDHIz z1e<>|)s~~)8B-~q3Xb1nYC(_6-8&gl=m&7w09=}IwwZ@?;AIn>?LhD(Y~eO5U5)a7 znO9~iq~ zoS>@4d;Xgp&h0r+^Lu)%p+*d6w#kH$^18fdC_$?d<$c9BW154KiSe!b?e7krWlyD^ zif@i&_q$XHl((f>Ys2CjB1g@T9FG;!pmv-^5Whb+8Jmav^J0#T@>B3&JPJ4U_ zrR3D_yOcnI=__wbMkRkdjanGQCJ7t;v4k>_Y)$lLy=f#*OJUFDY3Eyg-x`y6QDh*) zU-iv~V%fsM#vfK#r@oMmEJl+`hv<_u`f_gq(~sK0bK9ihpBd?LReQhom2E(%P7!)e z@5$LZ5Nh?x*_%UrlcwMEqmF-Tuc3393BZ2^T-c)+v4l8vIuqVcC7lvOmQIm#nV)BX zU5CLsJB0%7=q2H=;j{m-ynV9sZL240Mp$!9qeaSrW^SbRXrw#lWt#OXT*B2TsN;$) zh5R@s60fU5_v=FzIW>#6FQ}e5LnN3v*9a$sKrY+9D6hz>*W}$Xx{>kDidj8NT=FhS z*yHY27NbgnH1Sl5=Te@J=Tcp5YQER$L@CDAlcbU|Z*!9rD;cCfvUkc8v#a953ZuCDo7?i#k1A z>EOhlT(tG|vl_wr=f~Nhk*Ua1bVX>w*5YQ^L<96?8*cTj ziFym3gueRx%9LA%%}3xS35L9MV4d$#7g#Cb67h?xQYNRW9jAISaFohshhxk$+PY0L zjiJVOg=?hlB@2gvx$1QqZuOwK%mN2a1$Woh&N%VQ3&w*%(GL0wZol`8fFuI?i4NmX zf#Bj|@9(Z3z86&8&KU5a#z7ppE*Y(_=;ulzLd=jh`qg^le7l>&+NmpGghAHNE|Zw{ zyZWON%eCbLE_olQ)}grTGVJRm~BFlbvN26{A; z5LvL%^YqW;C-T{QWH0K?8FKq1xiThT`)@~RNTruQ=;`&$t(ZGw__5H1^rCBY#^Ree zDd#u$?>{W3B}ntQ#9S;|P5VJr7xJvr;>xocNY;D(Afbp#aL<4(jF4WLTX0hAU0b@R zN7rJpQ8-8>4(@4$4y)20P1BWKX%87A*MgdmNwoB5;BF49uU3EQ66D5Fq0$M~htz7lRrZ&!FnCMByIAn_ecz8i*G3b38AK;}Pb~If@cvn%D04gCVEPZK%|_Qgp7A#3R)N z2J~QkJvef#8^H}orHMbDSNYwhbo)(nKR>Lwj5znwe|nSG&$h>qT%o>V`Rovo*r9Tq zHP3KE+=8?!riVe8#vVXutz}GX8CuQst7mw#%d6xPgdv6Fm}Uc6}EbEfcn#GXP;m9HRfMoP+56V%(hFl^bU zGP|+cC-z5pmno~CJ6CpR(p)+RgEJa9m}4UO75|ch_k@wVR{JNe<;CFHZ_Gjenm*ol zb-l}xDNm~kgdA>YYT!lc)N3}@FK88#F9{x?<*va=hcur%V+Y$Q+lDqxz1hWueNiwU1<-0eGkE3!%aX1BzXus9_AH zN}%AE)0G3A;y?KDapVNY2Y91@^fmg2vL^uB3y=8=prtgdw!8-YXlmRX*H6|F fn6r@)Y!-+Pwv>60o7@J30Hm#{|EOHuI^w?ovw0hT literal 0 HcmV?d00001 diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect_hover.png.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect_hover.png.meta new file mode 100644 index 000000000..30f41944a --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect_hover.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: a47b75ced50b68a4e877b83a995b288b +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect_pressed.png b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/connect_pressed.png new file mode 100644 index 0000000000000000000000000000000000000000..9a02f0fb632ca8470ddf885c8e4cb72397ecdc4d GIT binary patch literal 5551 zcmc(D`9D-`-2ae$i3r)dL(1A>Da>HVl58>Dh@nOHQI-)imP(>1gzRgUu`ffG8B{8_ zWH%Zyl4Uf^*oPU*bM*ZWzCS$A59gfg`dshNdam<2*XJE?V`a+EBf$d#f%whMjBkQK ztSDeyz{LUF)066zfd_Y>nNtu5q+)S&vAhGPR{%nmpqr*pP|bI#C1ApK-_X(!1gd|< zyXV0U0*M%#8ynh(v8+tF`bp2{epx%rIjI#WT$|v9;QG$4uI-+kz|*aEMZ@ftjnHUX z`j|sPt}&5%(_gAuaQVaS?EgBrsskOeebl=`OKQ?#EoWG^9}7JnQH8-UL^W;8h!dOv zJU%c}Oc?Ll=5FHUwMo68h54M{zps)6)-AqMc5*xxkOu=U?)_SO)dB5$jgh>*V9}>j zo>b*JHQpxy$3P&myx|o@q3V11&w;@4`NFHry#^=A{eefmMO|6HbU7JQa3`6#J}A{|eta95yC(PtL?hfHqG)9j0tqUMju z8VUdf6c<(DDqS8?mr|45YyMZ-B#bf zIT`jSp{%1SCq)OW+ei$408)Jck?-2vXogjfinWH}RzF0wlL9n=g{JEnmD~IiQ|Nr6 z%Vqukpf9Kxgx}$NCd5yJ#0moWJ*^gHqZ0{up$V7Hx8K9dmSboY`*u_&NgPnta_&U0 zqz5=I>E_LU{=ryHlzw}WH{!^jzkCk%%UQ;sDU1V+!y*m*K?X>l$sO+RcX$`)@=kjx zN&3_Sl7IvNCBkY~7}qm!yDtc6?Nv@l;%DrtH&9rE_V#`%Rd-Pmm;Eyi;hl#QzcbGV zRH*-UH>ABv-8&CI2SNSXO*NSSz!Nvxme)7p_jktu(<_pJv7Ib{w99iOceJU!&D_zl z6+?}seg@o4yu39+jg{8Y!3x6b+RTw_dx9Q7k=^Ym4Shb9o=#3Rs~2qm-2x>C*lxNq z>P7yRo~|abSRcls)Er(?J%y$Ezb$*5f(?J3g967Qn#R zV|<6Pb&~+Z#d9C%uztI zTt{D@i0KUAviu6`k3?5^`Iv=0c>hP*_|(=l3Nd{CU7sjq_v?aKB!V|T z@-7IZFKrb2TfsTDEAB>cATh`&e=1~UYrJ#cgfhK-jpn~Nu>ajtKa7;LAO1NhKW{#o z1Efq23t!I`$d2{YJ)~SsO7@EH`n(;-5k~IP*c@{W9Ypu!n{5onEKe+nOYR>!-V)ry zu^1@6_~=H8?S#uD z6iPNy7Tl>zVx8Ur3lOKX5iKTmSi~yU{J38^8w?*8fV)36|C`p~5f>HuAv!G&`DLuo z1*za>N;Yb|cwMaWQ$K${S42@{^kvRGFS9Yh39q3U1;=V9X3s&QeB5+cZ_>fKVmx6h za#uokw?&|lR^WZADF)`St-5BrvAh{k=FeF6rZc}}s1eFtr&^+z{FNrO8WBz3_s3jF zpUQ!p#KZj0Ffum_b&jL$8H0RmkXuEbKbz{bJ6ClH_915&Pn~J*8i6Cywukz$UGh40 z;7*F6Zt$$av1ltO`J+jJxma>cHm&YS9$w+nL8I_EeKvKd>xBwuVFi-ZEKNdxGKrj% zEh#ko2gAW?P-aUmek4|HJYy7WW2^byw9qo`{h53%)2O}cK4gmsH&FQ{sH}5*S6un< zLbjQchh61?AB}!pQ|)L>@HQ}S`tt=rYO$lPVb@J1Sz@H#%w#BL=P+R@jcBbW?(bf)z>9rZQN) zges6w?wsq_a}9su9?xaQLS9Aer(vjYJ?nM3d=C7UiU z_k6J(99bDo?|iYOIdpdd&toJLo_Mws^0xAJUZnEH zZ$<;2;jXxW`8*@VfYZHin1|!q3ZfgEg(_LWx<9Fu2w`skNv!l%b?7C300rrhyq4@nU}|0Cn(FPE4rh>ebb zT7soqbDxGwPiia$q29pXmJit(41ShqyrGsAe|GA4+3FkPu`-hvmFDKGeJ0(|Uj;9( z%q<7%OJYY|hGs<7u}vc|yFM+%XNzAVCrGtlDaj6LUzKF)_qzlR_ekPQuCu%eDv7!x|W-nuNzY^ckW?=ZSBf=E$?M9PP+9a zg^lmSe)NyzH=F=AR_c#F(oQHf)k*Gy+YMT5bBuX+eKHr?i-Q%;NmC-mZkQm?_s94>q% zY{g+s4~JH4NUdqVz>(ltL!Kgn5e3gU`!Suo_4SmVNHE&NdZ40Ja*O`0m2hS{f0q79 z`+SR)XPX-3>CdgRyaY-C{SE^McWqM%AkuNNYg+Q?E8<}xx3@aMuZkCMU{dmbvn9R+ zqSfH=557QxPV3mVO!A!a&Yf?(Azj>Q7ljaFZ^Vn`-2ewNIQ*+LH0i0MyF9-Y=20=e z^B_T4QbFQdlbGZ0znD>?uI`D^O`lkx4)#a~^;C2Dl`WgE62Xd+X zwOAxpZKIDAc5barrPheZEbPL>7;m|8k__ZwiKddHuAtn+7%8ulN8!Mu6O`>%ps+U$ z8*Dx@UEPkUAKzH*Nvya$gRajUT9(L!6}vBG@o0iAgkUUvHeZLjJEz^V!&5!(dlzkK z%2r~^{mTiQRx#IkdzlOOzYXiHRPK19r8gsuC$m-~jYn4B{8WrKaX-x|5esan(p2Y^ zP-YWZch__zYwoS0O&@LxwVU+`lcHvk)u+bG{<2EMmR%awIZE?W#mP!kqnc-;W0Ixp zI>;d}vXX_f+9+FbEnXiO^5a8cx+`D{siy3b7%N zM9|ma)wL&m+RKLxXRoSiUnd`o9P*lXCoQ<%y*5K}$9FAO8u&kL5{^httw*qW;Y4|K zt}zDZr8f%~S7kbCr`^u2iewHt3Y#c$Kw9{{QEBS91j@lHKv$;@%^n zON@1_#MN4ncisj!l;PE3E&iT3h1 z9yq6OFxg83mWoo`zr-R}^Pj>N|Ig+vp5t%Nw}JcfD7=6@YFn=6lr3d`!)o+Ignftp1{grPXD| zU~XT%Kphc;d0H1|A_oKr8$8KKDeXL`qv$HZ-h26Q)o(Tgt^Trxm(SaGIDNkI_OO6T zZmm)ky#`i!Gt4yO@+%po$8~X@1?DXuccHoCAu9oaoM@7B{hFSic?NUgy+szUT4mFc z&EL9@4x|2LU!% zkJCakKS4TZ7=ucK8kD`6k4 zxR$IVy^j`A7&6yEu$xS#lzh{v%Kc2>H}I%5<09;DYEq}IRSyp0q8P~RANn~Gr?D~* zg<@Melq2~x3TG#A)#!9Z;|RIsLKqk7Ia!{n+kJj>sX*fq$$ddM^YL5E6P*|)G&5aE zhY_$Z0!E3LY05#O2H-9ZJgoQ?S??jP5hXJUM}g;{p2ru*{jqtTUl7bC284^e_AH>^ zVaaY{gS}71!h4tFb$K64e|LFYU7W+2)qjdoca9xK$ZyRQVWkH2HkaQewL%ld8Y5P-rHf+gGFtHNy zVC)(NKTe^P`P+sWLKxA3>TidoT19n&W83wL+cxw%OJc3!A5(h_%9nhT zBw}h)Pi)uOdJXsJ*uEQ(4lb3r6>szI9j!-^m$NFYJtf_vZ~tGhCyI9UZ!R-_I~GNc zo(oq2FPQinB`-RXzd2)-?Zm$M^~1#k$h}aS`S=iV>k|{xY`D+asDdp`t<8fMkM`yH3E66!}royy7cZ65k->zz|;Mf>;vc?$+9qYZ3J5oks!ukj$ zu+wAP#+11L-0bW&N=n)t)|=fv(JE72sO0bp_*c;4v$m{T>j>ZI#slpg{C)e7t)D#M z{XeaLcfuzHvzeK+2sP@q>W=qYmGMs6zrU!>PCJ1`wR!`dYD zMC<>kasB`3s4JZWzaMb~N=iAkPF}o6tPaLG(6X(&gttC~VJP(nB-%5LM%OsHbV&2(`yShv$$teUN1Kc-U z>IT$v5)SL7c1+4d%h!)M1A|jm-oXKT14N8+#gV}8;jg`(8_3>t7$HAv{}YwU9DO|F z255IJG@j!7u{u|gCdMAGEj1Z6jHVqq@;lQf7vzXhm~IaB81y0n)ZK*f2X?04gZsmp zE<%XIg=JRDb^w=czFH2xxEl^gJLr#m7s{F|Yx|~lwT_4pCeOzc5vW-w(wNIl+TOx9 zN;5?id+aYyLbSOu|6#hY$%QSwX96#dw1XZI8ZO-TSL);O;Vl>^(eQ$LsdE_3~*z$8s8i@}gJx)UM~yxQw>x$G8McM+P;@zC#xLu;~mFPQil zjk>KdfNR+L!wbQ87R3%f=lzS*3UHfOLMc~d zBJ0PkdyaUTm*>cd?FGmQO_J8#ipkIH__REUj=orkCVgA@0Py{Xd(ri)t|xZ&?Y>2; zk6X$D8$Qi`w_R}4SXi4gtK;f&XogR3^It0RoZ3WYg>@cp7?tjPh2m^hwz4KR;4Hk) Z?R`7@+0{f2iuPTqwN;C1=@Lq6S5;ek)u_>-#NI2hYP?ZYt&$p{h#6{AvDzwa ztVCiZR_zfpM2gQV@BiWZ{o(a`-Fxo2=RD8j+r_Q4XMj#L~ z3~1+_VgugkiPhJE&uNcG7TzGxjVFwUsZcq+1Q0TL8)<8RD*FVNfX<0G>bmM6P<0Z= zUwc*%=%R+s19g)CrZtkThu~ax_h#s?mc$x^DA;Kob@o`zcGIf#=`h8xIV`Je_16|Y z&C(k}ilfbIgovvhZiUoWwA6w>V)|u@PUX_cqpaa4sMSjtCszJZe^amCR{r zY|8=yxrir&XU^2}hYnVunq07T&KK66)r;TdT*?30JUw9|xUp*RH|E{BRUjy(I2OBF zM=BBL_eb?Y#fxE9OjC*ac$43K^2p20V>V7eMU3A8QY2&9Vr`1GrL}7h01g(}z=wU^ zPHQHkQ|7W8+1W~Ngim)Ogh3#+2DuOXZ5c&+R(VzJ8E3v#DLTt*GmIeA5C3vSWdt^_UGFN+ zQto7+q8L7eY>!zN+y3H$y|FAEPzc2td!=y?&%Fz@H#6dh-lmpw+ESDCFo# z@OJNTtw_<{H12+IR#4RGG$s(}Koe)?Dms=SuWuWa;#L#b^Is5$;&C-r-ZFKnlda*D z-}pwS7pj1nolHkL*BA2%y3H%PO{FA(I}Di!c#Rwv?fSxD81DR%bJEf{*5fAwb1uHN zr7X~8wn31M5gT69N@kM8j{ysS|3iQ5TsGGtu%ADl3V_HHCam^NCG58QyFu}bEhj-B z1Ci1>IyzKfIHNK;X$}V%Sa#@c7lT`J!?5!x#uSJA>OYg?1>WY20C%@rvo%==wda65 zswu<$oqgK#=WQ4rU9k2HKjvS46%Z)wAP(VEq4)~WN0mGZPZ|aTEvSn@xH|)|fw?9N zuyw*L#Ww)+c)@UPz{&h6#-0LePXVS-ip(N_LF~DG#Ww8jl~62V z@Qg`l@enUeeFY1!^%4iu*0oqbv8$`GMNGWL1CXQr8o$-*!ycm6wbg6;*sl#APpE_T zhOnv3x&7UpT|VZ1@SWN5 zd+iNWWGA^WSI>l>YFV-bUHWd08dQ^M?0B*!qSa6Nev`kXZjwDHCe*(V)Q*hiPBR$6 zIdkACaH zebO*!ki6&yQVu-p5T(p}A6lw(xarj`QMB0@?XleRmOS`1C_6>JwS7@D@B80guPr8! ziri8@4{Pm9)lte}Qv?~q(f0J<>fDn4XAS3lqU7C^PAE!>L?&?L($4OmeTqewCTwSh zP>hVu^9^}gV@)iO?#r=nf%Uj0wjuU$esmk$D!q!ewynJbq(}3k!%l}7lm-I*j8%c1 znioheQwe_El~b^ADb&Yy*^bNChzNRrHb`anB;Nbo>d|2I9Qi{b8Dm*DH>^53*Dp}N z(an7$HRo7A+M zzU(#WOp5+~(~MmthI;xZ$-7}2Zj1IE!)f>XyX?{}#!>4RlRi&W)wTJLoBv8CcxzD3 zuO$ua$qUTj`{GB~4FwR0-?ni^G%Xf8X<}S&NB$S&m`Q!v?jgHduOclyYll6HKx`an}$aTYcB-9NlvyJSTI!* z&GsVm$8UKb8dc^Mc#HOj?6wi&g#NCTW@A#9_|$xG95e$ z*D#4f%x@Wo8@$5YZA~;gT>NqWZIGL*F}X_8&8qjrzkbr9@%M{E+*P=oa98O@(gspz zcwKYMQ!rG-Lzj%_gIWc@;nT%9`9)7}dN)*2^>bhBWjc{Gk*X-EK`u~=+4(3v$*m?d z_&HuwdUeR>CG?azB30rG%tQ*cA>9@AX`1e5UXQnO^zion$IP(c1AgWktIMh3qenlF z1~4}S@`&0l+>$A0_(#)9tBm8&cY|S*#{#e-dNejoc2cyRT3pC$c3th#>~9zAGWVx^&EJvWuqRy&*R} ze5gy;v2y?2<4vz2?HW=BVjpAV;X2nBJBRS6KUw}{+@?OMn0&@8$R)ILcDmCmJxZUf zU*92=MB3aggJ{#~nhRzte$0*W?J=D&KAMdnQ?aF>$Rz%@^sOBvvuc^2fxS-3EW4#~sq4p@_Am^dx0bBpS5ycTNi{Z(p!JqAr z;WwP>G}b?&28gr1)?-V-fe+)M-%8N^uLvOnQy1W>D*=uILl0FNRCCd7cC#ypw7qbd zUe;;7g-9~QVj)t)b;XW7w|ptPmJ?d(&h4pyu~C&AB8!*~k1_jp<#F=J`%JFmDbA&l zHu);*uNO|^PqoL^k6qI4udsY~XJY#eZ=CcF-el2PSYZ21UeB+D%2ggYCEe)k;#h)$ zVFUCTn7z*Ud>&ND)cFr0_jZ5egk)~e?Ol4{o+iQ3#Jpyh68Gsm<|PvD*^VO;b;z2R zrme;YN`6$94tA)K&g~m~M2J*AM}rp*I-`mnTpN-!0DE9GbiqnUaXC}9IoAl2flb!_ zj&ZCNk|$*QLb~)69PF44H6tJ%P&lK4Tqh^zNr@{9xkw8~ ziK;Q@i#CWb@!o=pWF_5sQz-$~cMzC-md}feX`B;S@HA%In^3!m*9L@8m{5}*@D-uV(AqApq1eod04yMwUA1E+sA&g9~XV#N? zJ`ck0>&9mhCZ%sx*C$3%ZL{>~Vk-eWtvQQ_)s5e=(`q-xH;`2!Qop$Zp%!==FFQ5D8p3vm%mxy9CRmMjN*=62$75sKh z%iP{-@x`(mMalTsQvXP*nN0^p*)*i{GpkVsgRK9Go z5bbdj@&)1=x}O;3u7tdUJinsRSKua?G$3%z4w4tm9c3z{R9ZB4#TIERVb|DsMP@{$ z%Glb; z*Z=6ypk*{(c=XTVwpwp{et8qQiUxbz);sfq0;Bf&x(l> z*+>8L=5A;A@6hJyqQwfkvJX}&iWvC%!mKxbj`xR|Yq*cGdpCora2wb;&f^Y@YEX4ND)%;-T# zu7xuQcczi1N_TfFVg!RQ%YQ!wJFMn#GrxL+@z%hDuY4PvhX3~y>2Ro|6DvL? zIMaIdLQ1nTN+h+7LyJ1Co@CSZ2^KAzW=i;%4JkF-99a6yIL8i-g0^qX}_5Xm44$&upK3Wbuq|}l z6Y@*5P6uPBm^(YM!}-i$ZORYIMNqs=pB^t!#nIj`6d58zftp(S^sgZF3fT8N67739 zbcQJ~cU+k)b1C46Chm9CB;J%%VuRk@Jg}_$sm2SeS1rC(;2e1^Z(kDlkPrLhxGH3m z@Vu-gY`G>Y#58PqrryhODDgEvBj--#JlzB~hJ*RiS;QD^Rq;6PNW?gIc z;@8I<4LZ7heFEo1Iktf!|M@HKBL~?UGrbQ--kaK^#A|mJNZ%;^-E}HU9eQ=@(vL2v zx?h3ET35sq;KTT{7T9ZJ(LIIn(Jy@7-lFwI;oDQ+Z6vY~tGjb`6Js<6>-IGlQbXz$ zRBWIRu}a-r_(ll@PKr#Bv!1naHG;=-A&M|RoX|6pw3iwJH>_yXcr)gGO>|LHm3TDN z1QCwGHNjdco`dtwFpEedJ1tT~SMo^-A&mx2yG z=&H4oR>fCGeY%o|Z55nbb|$>?ug5>vA_7lrzve zTr{cGr?%+I;{tYi{&hc-CQdOldLUCl)GeluF1r&Y*Au;Z9Biu^T!pRUqR`4$;@uzT zO(Y#J7cspC{^Hl3*W%xmh8VqONtcilPT&9y7o}y-=f~Yzj*zrR^RrubwW(Mv-yeMG zn;3b4Iu?Cr0ctcJJZHVJQdUAxa6}F}^qDuNP0&rczbU+aa@{`x^&${czESZKzr-(> zGWdyt;6kOj^8{}N9IzKK16R1~KH)<#X7vW!6C+6?{ib$H`AR^s_{wOmv>4)PZ?b(- z@4IebV7<O0~bz@Un#~pjecG#mHyn3TA$2oGM*-_Oi*u?ERQL%NIBS4t_^O&s6 z$oj#?73a;6nxHoWpp-?o90Ie-+Q%!~!Xf0v<=ck#xh|TpocarYgVx+mB$%yJ+~Fi= z&!5h)y57K#DP~GK2=UvLc0QrEEDa}}94GlI2K^V3gwcT_@xl#Xl1{<^awKeQpgpO! zL;j`b8p*0W?yc)P6^8I)vnsr{3>q80_731lM!ejNR`j+59WFgyx-(<_vWmA}kK0&#yqgf- z{4!M*WcN!7HD~d`5LZyk^>twC-FM&L<2P~L$CylYpHY~GIFDhUdD z2*gc`_4P7@j!3Ipc^fDGG(&-p=Rmlz6|?1lZsFX46uo8w?XZGzLna)N_5;8j|Nov- zV#P!=II~KAc32sMbCe#2EMfERg3Ty&7lOEzx-OJ| z_`z>FgVAk$Vo#YP9^>|Ae=5<2%eLijdEf!@KyVf|FqoDQTW-4~xc_H0ws3#)KdO=9 zIOb(t(*O{&oX>=Be%G(9_ZwsA14VFapS}JTs|t1qer8U`O?^jTL^+&bixQ zTw-#Mhw>k>DEj@!3*(^HOnWyyN3P2u9%EEsYx70XtHIbOo93E~Fm(s4Ybk6JFk|$q z(;TqAOyZB(G0ZjbeIPcUU(0Jw=6V)+Ug7#5##A5|iu*SALmA9bn6%U)cC2F2#JbEJ z%38Y>-fy@u-X#Axfx%*4OJh|oZf++oul{CoJ~G9&7ChVsa=ZBXTtm9-w_Lu}LIqi? z@6G4;ElcJpbZo1qkx;dR+&{;+HS5Vc5C(-h{-n#~?Hj|tfig$`rn|Z>}Q>1gRasMN5F_#a85XZipD literal 0 HcmV?d00001 diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/connecting.png.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/connecting.png.meta new file mode 100644 index 000000000..28198a268 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/connecting.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 4b978c21e8999664ca21dcb42e564385 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect.png b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect.png new file mode 100644 index 0000000000000000000000000000000000000000..c2c69da6c1d36f72ca9767d3faad983ab2af31b2 GIT binary patch literal 5677 zcmd5=`6JVR{GUn()l@^SR`EgP%8?_>5h|2x?js~~6LZhms2G)0k_sg@ZRQT!9N|-P zn`2{0Ip#h##~k0c&-V}b{`&o4+w1vyzxKRd&*$^~d_JC--7z}BBg_K=fll1IdBXw( zIuH%4KOQ{{e7{N~$^t*f0&m&{gFu&!_di^pwOAc&n+#nEt-3O7zkAFJG5+v7%S^a zqd2i$VzHe0`FXK{T#W%P=n7;hW|k7&bZQQ;z|b5tytE$69uH`$D6XilB9qDCQyn=~ z+a0@%y~VtPAdrq2Pwe6b$Jh_PD|`f&a1Pj!I=st%-)SqDeV7t4P*0=L9KseEJGi|S zWUNCVz!Z^EN@WfY4~rNZ8z+)Tq`OY06*AFpACF(%mD>AT12C;uFT|~1MPgBzve8)P zX34tX-d~L)AW&S8-I~x`d+(RKdlPL6lwtfzO6Gx4G_N3o6&9dX6Tk%mr8OmhQ3T4y z@08%$+FB8h*rJfJ)lAa*_DHM-AX~Hw3EkR)t)a{&8$P!zZT@6Pkvli)_GNxwqLSOP zv$F}9NXLM_gH}W0a{xb+a~>)#Z#cb$t^A=gTT>`<_LPi?g%yFrCg(WXB=K zdVL*Z*Y{2W5;FW*Ycsu7Ze?MK?i(iuhB)7Y{vHQZ_flGNsw1HLXL!@bS6vqa(n z6io%d3aQI+M`}V)$VF2*<`lp|(QhjkKfSy^~y6G-B-k3jzLCo$Z+d4nP`(f;Oxy76rAjf4j4Jb1}KX_m&`@!rA;P-zOUV_ zrJ)5mPUP8*=&*$v_qqM+x-9@!W{~O!W?8$v0K@Z_$UoN|M!*M08yXt+FGI(h(G@#+ z(sOEeWdE@SY>E}NdwmLsBG8L+3WdU09r;Gk1SpAH&Dt{iX>x3ppX~bDe_y@xw@A(e zg1i$R4-n7r26AmZVZe!-aLT@=&r}XT0Rg{{?*HqF_!4kNBCQ_q%QN-=2M>1Jxe)CW z>DL#|#ca5SO?8N^0|a2GBv(8b5wFKERv`U37df4qzqqs3dv}3J#Vst1)Sd*zP23o3 zvI=JweMzd)gj9!cSOYF2^Yp4@YL!dOW^=_QDEkkw^<1#5tn5OH0vSjNQK`^ zkC~tOh9&nx3r3^~H2A0{Xw|h7@nLFes%V7^l*S(qR=&mM7Zel}Zn;FK({WE_wrnfTaK8_@=U6#3m|Vk2tQcB z-&P7rejl^t6}B+^&2>G9AIor}#E{n3xlH8Yf8Z6Jqid*pZ_US>tSnh3PNfErv9)wo zV`GAd!Cr~afh0+LtD;{s8I)J9<{moq-cQdK$sB+6TZaJC4FS{3mcu-XIM!vT58=O` zOiP9GqHOSvMJnt&K(V#epViH*SuKS`&HhXjGL+qF*c4|EoiVq(9p#<13BN#< z;P<1s~F7fJqO}Ae%dk=#^=S zyD^_;^d=+ncO_RiA9tv*XcpDxb7R1?=usj#Hi9M)5AKbtKO$-&UNrPAVxsyQt?-`t z4NOjBT*8YMiGD8xT)R8CPpGW$S#v8zH~vtu&qXp!Ey$y6Y9N1m1}1F*(U_i$fPnUu1EPs=P7Jv{5< zNd2c!z9{=@K+Vn$>h&lT{h})-GY4@+X6}+LEnIE(efcqd;d-c+B#)40T7HYXH^;!W z^YECDTN2x#wO;bX;Gw5{+Fwkii}Xj@hDtnJbqP{YqHBy9_3tlHP`8zMC9Mk$dXzMD z$3UKU9?UFNIMF-g4sg+amQHLM*wmL8+#_lJovnX;0QPd8L4`rk+9JxY#XfwQXObS1 zpzQV2%-w;cJ76PA%gDqd>e78wl}@%)>A`nERS(!cpyX@)y))tnVwaM0-emg;)+aa&NVC@m6soPSN&(8z#-B zSfpxN-IlvtZ0u$XZA8NuKRvH zB`s0UN+^{2ijPDwg-IizKi~64`p#)XRnN(@xpX?e{B-6g?sSzP@g(9N7rQNTm@KlskY?N^#0OY6U(p_-5hR|dq$cey2BPk2!>6oKhmqkM5R7C5zwVnxOL%j zZIUaH`DLj}(K>0uw8|zYU#$xQRyD#35T7-svr@ePtsPhM-83g@q;ju;bspR*>eFy)Ws4OnHewNy?9^lgf2O1*W|)uRa3J~W-v&~LrN#LpMH9?Shm zXT5z}|LNiqm&Sx+nX#)x-=Me9G2CEG1E2^g)fG z5GZJymmhXss#4xbFhj#iNY>t{U0&?L89s<_-%*-90;!NM&_gv?Z1b)4hs*78a&Ko= zYNQSHR|WyP4qCkS?L~iBb%PeTO#zi30gea%`?&pGro3LFFw(SII_i?V!>@q@AJLR6 zKJT&y!|_4C?kubfGHsKbv&mO6@d1u4s3D>m2s$+?s)*aKC2k`QrZpl)#EK?n>QxJ* z;rLxa+<;q?=_`NJ;g>V$ z?W?YpwEVLAB}FO3$}N6EckJNFBCYjm&k|wycB!C`4YvuGS-cfT!<)+vP42wDcWtm5 z%Ta-(wU~RPsIvVYRZNIxtGByd7;Z^AHwBw^Ty*ogcu(NeMw~uz*4KNR5tMuwtpax} z_WlxBV1Tap*WWkD4mqDB>5`S&MO5qfBbo=Eubu&1QIde#evO@ZR9*;ivGRiX1bbeaNFwWvHXJ%~d}t zkTdoz}K)N(L6%i;1|ILH_W!xW$`H-56s1 z!>aCLe>VB7$aXbyk3NQa^T29Dk=nGRa2=DjTTdY}hrWz?!TV5c1ZCfc4!^EFN%W8kn`=Doc`4EC`zx7XlCU#VWT8&#i zVcKVw_;qT)7B@JnP=#MR<;xu^-x!~RFD1-`bPm%S@6M{Czi-wcfR(gBip1{w)J+wyJlePtMXZs^`bUdxS< zo5a6vZjf-iQ>`_VNQ*HS^rxZp7@sJ9CrykVE)f>kHeeeqT+IuFg zl&grBN18|GZCux7LS zQjAEO(OA=`YzGGitw50E)0QJe5ARVE=S&<`5j=P&g7TNc@)VS+cO}Y+nBUgrOCP9| z+a6-GXnq%uwTMUENn&IKy*r~uXRsUYSB+9jjz{ebf%VuIUaa!%ckKJ4w4@&}UH?Ct ztN)K_-*Su|v=0$b?*#b!`;P(3XIS0Ev9Wut|KSN74o53w<^W*Puk}f1YxJTeU}T)c zW^tGuKl#M}u6ahkkoFNRqbhj-UU(Y%lI1H#UjGlV_+1-(HDpw4`vX`M;QRt0M@1O) ztNs1`MS6lm=Ba&qp6_STpm$z(t1Ryp4)_CF-6=WP^hZbyl|x|!#~&Rx`p zZuNnq|AVc5Pp^0Zp|)qaan`000M1~`Kc061PE*v49;~hV_HC0^*vfJ|mV9^3bKjLu zKK^vhDv?^nON^2@yFEQG-be3)Kx68p+1jAJ+POsIBe3P%#h#Uw6}hm0i>(R!;?+;6 zh=fH&arUs=7DYgZ0;X&*-J7-XoMMu?8@J zugYSXUPGBF_=4L`H4)^L^_8BUo+Gfenb=N^ng~EJEl<{S+tt-|{QCa9Z;X#OZB<^5-__oEkqrKUM4` zI^^^QP|9ank%TSs)GF+d_wpo}BBz;f9=Hf?tz9+sy3yrkVjE1#&)47I87a0UwNY{! Vry3{&pcnv_W^m^QUhiJQ{{WM3){+1K literal 0 HcmV?d00001 diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect.png.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect.png.meta new file mode 100644 index 000000000..24faddd59 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 68c96eb36fea510439ebbd8d7fb58dbf +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect_hover.png b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect_hover.png new file mode 100644 index 0000000000000000000000000000000000000000..124f377538ce61fbc8652be41b6f2aa24ad4b3be GIT binary patch literal 5741 zcmd5=`y5PcjI>GY`7NKWM#p%gMTGB#!rIaSR0>_O6lr?QeTl*4A5VbmDX zVKq6kk>=QP80NS+`@Z%02fjaket2*9eO<5XK3?~IU)L+u-tLN|n35O>1d_DAYT*C^ z2}J>A>|PPz7n4pt1N`l~f7Lk%1Uh?t=iBwx7*hracLh0IF$WRblqZ48p1WqYW*`tb zLwo}%3mI4^RHock**gW9(&*V`NLk>J;ypAh1erU(k~%pPV$)~`nJv6 z#SJ_jhaX_yh&6SPL4^u#!r}Zy?l?s*d3u@UmR}dj8}z7S$M^&V1!=5f0;*iLg+L&i zXLcbj18bqWbgF(5c{U71(;wc2;v zUW-fmo}J4z3V+CF5?oGJWeUcpSTW;``+EbLLEb@~eB4IiF)z zjw^0e10DdN=~Qqfvtg*$X9oYXxx|rnc4G4epA)##sg-943N_Z(qS+X=-kc;JOIm2U z79Z&2GdXkCxxrch28<_8C;FUXZ)`-J5sw~cm=J@9TVQ-0?>P!!9_OEx)bP3dZJ>sD zv?_)gn{AkT;lb#$>HMNyfOhQ8^3r7Y;^u76Y^W$@TDbWz(A)(BYIXGzD<)KZx~mn& zA7s3e*j5I>=P;G!A;Cx_GQ`u9bDU=qFx>6;b9&#FHlTC=Ni#4tjqT2%9a8yNg67Y% z>je7@?*bYdk(uvW+S=NxE+v`<$$`LJP=9*)WgpU_G+j8hdU&b}4uctBjE8Rn@Ft6I z=YP7E`wHSU;AjpGt-1bCW2gPmQs2<%=*@l(hoiebxEoG&)*k+LsSjA8m$#>Wu*oR; zZhZOAz!6j-;KY}dB3xEyYv(+Xf&W&B0wZ4q>ObDA)*l3#%KZE6KyEduE?$${2A6ok z2Aa5<3|mcCHEk&UBOTwY1z>S+st0CFgSUHQE1PN+ckEu7PMH!26hVU|P`ldJmTNfv z!@18;X-D^Fd3o6qJ=q|;Q^6I@IN~kERe^y}fB(qI$qCw7YikGQ%@-PVzCiKBHSYZB zxmMuRfZp6As_fY42<^Z}_wF3T)Z=r2!HM)HVCb0_{s$BxdqDq$Pm}4*M0j0uu3>0% zyXlTcD2nbe!R`W%-u`xDfvf)XLE(n$FEo-O1+&1+YGmT2WJbQ7%%2{dvLEi6;m-_= zu-sy2Y8u#7^ckHp{pnQL`hHat<%YE>Jh1N}$L5g7PT8Ekr2Cob+PFSj7a* zG$%W|AWI5VLD(Dh@|u`8^Iqw2=;{LJ$A@U;g1$K`?$ddZ+VS(+w*4M(fa%QD^OQ97HT^}=SM$Z zL-Ay1b>yW%aStxP&?tT^RA}C>;1+ctW@SWF5F-{Xx?g!O;1+RBDP?a`WyNAF#gF47 zYreg82Z8!DfSKyHR)ArOtCJ=9`+>t{@MR7;<&%)))o-%+U0Z6?1{_6Q9#C*X38{wv z`ZuK^cJX&BtS-WPls-s{tdp@_s(%&if!eRT?gW0Wq+L3u4wEm|pn1{M(EbBlhgHWK zuq#1h!AyZT`}X0j;@q1mAI~A`+I;+0C5=OQi?}GZ6TK)$B?D0hdxVaqvCwTvp)=mc z>;t(~g9`tcQGCr=so>3`95pdXl+<0?;K{tO(RgkrgQ9N-Xh|{P_aGGqtEw6kqJY%fU)8QC<5YC39X@Km_K5? z3z>l(&uZJ+T(4`N1grUbrqK{t1E13WlE(!vVwxV&LWCu0vCtM)(C05cQ_Hu9yR{}a z9|>P3XAc=1j&?5%(x~^n((W05quo?rUm@DfuV1=zQ77DZVp+u~tb=x#;L5&1x-0_! zTJO|2lsbs&Sa+|Rc)qXElRot$r4gZ?*py;@X?S-{X~LsTGu6+0@680u14*EyUB>*J zz=kqtC$7sVc*35bm8QpaK3X)}Tx}U&{AiZM^5^I~&riMn#J{Rh7*xP=Ks)Cj6FUJ- zmhW4}j~3G^)|ybR=$33!G#NIDb`YqG_|gZoIv*xZBnql6}v)OuNcCqfDth!uhMuJueFFBs%>YX1Rtc>?gHmSU_j3=7Jn7hIc=#e+x^#=;9>j>nGuq2TPN^itfIk zm(l!XIUvPf>c$~}YbDA)P3RpWG5v#3AY#k7a}1^WatV=@eYtryVfuN?ot>V-z848G(05qQ*L(!qV9jJ=KXxKaJf0J zxiP(9BJ^@_UT}l#Q@l}Zo&u~TPc+ihgO^<^A$1PCH|kObl5m`PUCzGTu$Kuv(_)^l z{v)t)vu2=@LZ!l1lo1?!CDi8yw0hPB(l@UjlGLiM9SvNMBVoAV__6& zjfqkA9a_(6@{cizlltlH>mckno@!bhs!Ton_{-xT)C?_8p_0zc`dL&++Xo%svHVMy z-8>GBsVNO~7IVc(%k{*pO_qp$aSG=|fK3nr&jXr@a$t9EZcF7+yg&<)X2%w5n0g@F zI!83{X+EU`g?oLei*58}t%K~xBRV!w1?(@z5szW7@ll|seeax(&LLlEB}+{dFg$Og z_z^9sWbFATDl(t&OH+F!Iv?OX$uAUSt6j`(av!r9op+LNyW2aX+fZ!!Cm~z*!47oD zfqAOYx^P3EE<_L^ZQM5S{!65trUVl{FwZ45rZ>zYNoD(r*`KnR0d1D@`?EM_1}Q&*3aM&#IO=nc#?)O zh}?fj^L39;85lNRakU#|dZky41D8T4Vp5a5c$;$x!&o|KINy6@cSIO!Vyb#=3DuGo zCRfm^N9j+h5qY)ga`H|XJF@6y`Dhlr77E^8xt;bj>*5nZK;M)|JFdRst(4MWr9L`b zwYZ+^ktwwcbokG8lU;TGNe32NxKF{ecC_+y6c~gvucq`dMD$3nA%7p!w9!AHN0TR8 zOGXu)pYo@B@aU&Z@9gS+tN}kl=ME})T~8#aINtsKe5M-PI@K#|^!3g&Nbu2!k;2r< zgO8*@CPJ#2W35M2Larw!nSf8YhE~+8fxWBNiuq^766X^sV*gBb z+p-Y`@~!8474eneP`&<-81>Z7c8^`YvhaKa%U>owqoeOE-1MV&fAL@PeTVm$xTjIC z&@|XcLh3kVu5#|Am^RGL@~Az%FOW$zAG(_#El+UOJ$OVu-Ri*0dPc*PN4M6%MZE`4 z!^f|%qS(4~xCN8FVKUc1x2^!MN!Yp{E(=w6IWgfWv#g3T<;N15$&?m1sL8P^Z)Q#p z_Mv2trO+gj5m(BSI++Rh$UM0OR6M$)zp)$ZUD;9^#)Gio*FZ*3w4yGp&{y;Wrw2-5 z7jgD~5tycdMPX&sL3XLS6KSK+zjkdNmT;R{WJ+3&c#dpY$BqbxgL;8br>ulY+%3A_ zB3AD!-=PSL`IpNgUC9^{Hyadcsd*pWtK}GB<44m=BHX^s4^e=uqCZj;nERqOCpVel zQ<7m38CDd@Y=!_9gH}d=>6i*V#=KUR3Te^-qB`8^X&*@=()zU=y415T#4^7mZRk7P z5)ex|PF}1BjYE6HToD{xzzmM$YzCHpr>C!9ByfsL7ry0!s+bD1rllZ!e_u#heo%Nx z&bJdEWULsyw}Q|a%&pC=<~s~F#>Orw;zUf~GD@=@Y5R`9^a7$JFjNC4Td#3kI0Gpn z;-4cg{t9~ciu?>>WF97rwyV|*$V$qjF(I0daL_Jaj$zoGJ5nG0L@4q}+fkRGtRhTr z-+C#}=cjis#=)2vYEzzfx$y?#43Uv4Y~tcwe~n9GjT(Oab%u)3npFIlQdTIh3q35h zPfTG@^?`66GITR6t27;dmUdY4z8tg*XM`%aHeNJMR4sITeS7#UOx~*A@i^r?w7>0o z)xWeR!*rA;J!?>tQt?^YBhE4kNa2WmB84bOc}FR5it zgzt-fd8c{~Sy)!db2>0#f~zPWs8&TA!>PeO6IF(CS3P9pdI}4!@qwT=3G5$XOLfa_ zLc!_e`)|i@PIR}^D`Od^ zTlS@$kp;pulI!~}!D|>k+$MXzVlDG6hNn1SZPk9DPGkDx&m){PL1dt>>+e`i=Eob)$;iuBS?%`J(S%U?io@Dk8~ zd&WIleb@*WY3jslt}@04ev?ByG6FtOG&4G_-0yqZWvhaYD84%0w=V6DJQ-^|5&DUr z$E4t}h#sM0?5*F5vS(?Ih@^{bzrMuTK+eFMA~S|U;1hhQ1bZn+*ce_%O<%nq|0i^t zkfLpcWjti-WeJ+aHQyP!$$}NeAj79!-j{6@YW&2}?cbtrWfgznhLhZIB5M#zTk$nB zI4?=x20N=;cEmYk)UpvJEspqjYG~F?r@ASDSaCr|O&GOuZdvv}Jvi)~))dw~a>6nD z1jK@H7^-HhM5J~V-Z9gm}q^4lu~%NaxR&6=q9TVfQ+oF}goSzhf$ z3%W;(zP~w>^BOY#@kZWZysrbxv6IU}ml6YD7_C{@NxRp55}+@SVG@3hW`_>Ir3($gfWoDxz-`F-L2$(X z5ufm+g<#eUcIbpM7<71D2Phn|K6L01ekMCR`@b{;70AC!W_GwPA#F5(bm(zLAZGgv zqEvUNs1u$+L5r`qW<(l7>Nb%(J>0`A=VLa9y1Kglam4E$0z{b!ZIj(4`MnB)TE~{= z!GS;Bg|#%iQ(c+;`F=6o(E~_5K`*@-!Ok^?f!ndBb6`zPQYYgN(^6?uTi*Ykzdf@d z<-^&bXTnrGsT=xU3)@vx>RK$Ret|JF-7*AVK{kcYc#Ydzs~h?-awLua!&mh#I67^o zw`ae$zW=Nbna-;X9NLnB*X@WdseX<*H)iYs zuu?pLVS_zd?FI00WD1!)&&<}oyEU-BYSWom)gkEI*x0B7s6KB%-_-Yw`tU_ng@9)% z@JafQ-RleU)_kb~DWKK*@;sC1h=!ru9|e1iArJ`|BI;AbF;MWA-RE zAn55TIRg}57QO$)pDnxmB8e8n<#J;gynkIB2~Bq~7)-h+I66=y0U(8%?ENTHv@GD0 zxrgg{JHQ24K>U?-<<{V7&i7#?6>_6Y+73U9IzoBH%`_?XA0A#Dv1N} zutksL|Ew(ZEYt%2^rBJ$o0NmjX$h!`KLXGb#Q>7vzlk5X$GjXd%MKl)DJM^C?@|Ka V6dh7p061fS?y|JAAey@;{2!I+=)?d3 literal 0 HcmV?d00001 diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect_hover.png.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect_hover.png.meta new file mode 100644 index 000000000..9bf541867 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect_hover.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: d2ffef58569b87840a9ce9ae242e9d8b +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect_pressed.png b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect_pressed.png new file mode 100644 index 0000000000000000000000000000000000000000..b4ed6b74791f45f16815a3e915f4fc49a01e3196 GIT binary patch literal 5491 zcmd5=dmz*M`yY~;voV!)UjM*LXpGVT z8^dN;b1BDtE@8vWejn%j{rms>$3FYKpXdEN@8|tIpU>z0yk1F`=Eh=RX)p)`5;M7V z!x{tpazo!XQh>$O4N9}mY+vO}p72HQ-aBf8i?Z zyp=vP!@F)b%o%)`Pe7PdQ}j2`DYlngt;?>YCBa`OozM}G^L)@(xIxx=7Sg+R(NTMw z@BK1aWp1mguW+qyO3uHHyMS*dK5d&!C@d@#L-oX!o<;$&J6u5VFKcc$hD1hgcE&9( zvRE6;eJXVaH6xb34}JOa#S5}huKDN`2z31ei9fuw5mgh*+n^)qgkHgMY{Wbjql)Tm zSW2763IS6jP&vgUcKSW>dhC|psm%jXz?PY7BM3Fw!-3l|VFkz)=WvxQ-Dq}?guMAq zD*x9TzUE$9DCudibywTtb%Vtg;@L3Nb|;it@-z^MM0!4Wu(b>e4$uN-nHi4LH+f|y zNH>-=j5fa!)x>AtU2X&xZmBBW9wqrHDJiM4IULB)1CO$nnYOVS*evQF9}^;^oxDKop8+%=aAxH16I$ z1Jg_Y>K1MldgFfZdeTJ>njcEu3#5FrrRE4?6jf+q53j>A*W3a$0sC(SUvlDhx4yJg z1~v|SYG`aMjhB`=3@FwR8&r%9{@dqYpP#12O9PUT$ijEaUSc`o(aukFL7<1qjn+M! zD=S+Sobi8wH#fYof!ki3tBW}59>t$lFsgdfdMZRQI4qs zg!!;^vn9f7eamO5i66G7?VX&QG+Iv97#rT(apxQSFk}9dtgK7W-e@35^SdnshS&rK zzpDEO&o%5tSvNXMs0SXx>!g@}#<1v6jlCq^ukHt4B2cGvJdo-$A^Tv!THlWB8D24- z07C7Q{U1|sEDmP~WoBkBwI+y`C(ZQbq{8+;j%P3!Ar{#>Cs9iSU1{=68qWT)UPIWz zJ0Z}7=8;Xc<6|g}YYl-gS)ac`U*<3$i-;S!)fBeS|JfA)QGOSMNDa)-&zB5YLcp;R z%VVqyn*t!U7YAY-BC#JDVtMt^n=5$@aeR_c#c*$q-ZG50K1e6=2QBngH3UOmw1~*o zMXvss6>r-9Zp9E$F4q{3388}3%w07eKDas2=2A(Dytv&~rbqSj^DBROn!7cVBe4wv zIi6j)Tkgnjz*1vy{UmJHizeq93Wpgno@~Ss1gR}&B#MiE70hq_-rH-w9t=k1#QaJU z0-X`r2UETPr7Lct_&iR(lg`FmG;jlaG}cmD5)+E7A}~p>YluXkx@##gU<*!RKgXJ) zmj((=@jNrsu5AyeN42@u*9Kxpme?GNUQXVhHOn7buBg5L42`LdbLD7I2@7pRRmA*I zIFhT!-&ynFrk$9`N5*YVCKez^*zEk+sosky47Ie8vf3v>eyS8V7Fx)qugApU}zImaq z!%@l3a8x^PtF09dj(ZYoncAq+eFT@>4&FEawt%zDe_AsYV^H>}!|jP@ww;X_qW> z&1V$Pbil8y6i)BhRM<#(l>}xZQcDBvW}zFEiewz7q8O>TPw6jM>`vsmt|WSXqFn8m z(yOq;Wot3diJ9Pg;5zMC*W)r7xj%>4Ht}YRxL;EyKc`)h1(D{9IweWB@k&myJ=>`E z-kc4L-{^ZdeCI2DNUK`^i0Ylj6DSKZk+56*mEQk7&CVyz=B~wzEjO3@Wy$1JG22l8 za_FVf2X^G|c5j;m1|CqfR1OynTrE|-;|~eNC&(e}D>;#REfg(ExM8!6871YxzxhFx z%Z}J)CG@88}6|Biep-ZU_$RrKo%Ee zn9EgAANYQ!xZ7uqnwd)F`Ot503`@f;XNp;{Ssmc`kdJh86syg~M_-|41x!ZAK-%M$ zUz`s&WPqlR!0WH;E6SQFu75>%+g8{eRFYOW0v7aI5OGev^$gsi_*l<&^Q)2C!iEhz zNqfmbB_##Z?JgvI2&re=mj5%5q3pY+UexVWb@(W!PDg9qHn^ccWnf4EqRu?TEc;2 zjdP@}>C7pGeuu&L!Wmey?Wj@dLknn6A>z&a%XJ!C@`{1ApWOTYSnA11bd-Olq;sgh zoiW#GE&A37=qOV(Z^26dYhGE%)~bRKw)Xe1^+Vn4c}yy)y*1FDCm8?0a6(A&IdskJ z&yTqtP&LIV+d&(zbJGqJ);0(xMP@uwn}dq?hk6NZ#zXY0tJQ5k zC9c0uB8%7)#R&^}zl}`6EtRW^x222fHK@9P$SSMjh{J{TNulX%^(c_m$nV%kp`HG$3HvINRWrFFYBTBx0b@JEMnf?V2>Jn>V`gHG*oxM%8O z9|`U*8a|So^PzzYJ5;g91nKWruwgX!qafgO0v*h**BACI`Ovj}4SsI@E1EN^{K8IN zp?w-kWoKGn7ZoNIg-ni=OlkY?3$#Zc`prk%Lp?Mby{~t5CYAHyg=?y9&R~)0*Ac*- z`kWyCL->R#EmvXPwK?wRf+RMjATaBZeQ>K|CNcknpiM&FP((7k@~F)B+*ex3p2=k05?0N@td4&E6%hjH zb_6Z$st5g<$(#59$|q6TB=Rr(Zgk~uCyu?bW#kJwkV#J3W&TY}YPaxN_)IBIiO3_S zO|>I@7%97C*TpEa%5atN(sq%$DhYin%cF+_Sm0tt_>0Ybf>$MZJ8!-GtHcE$R}WX$?Y(| zFl!#f&^E}Ao96^ecvA0I+Z!UrDzzL2Cj>Fi!wqV)|A<8)->eMFNIQ#bts7k*$4gMF zc$DRYrLH3Qj=G9xc0bT#-4sp)pQX)TbogWxHdN;2cTNg%Y~Mm?241H2hQ0!k*9YU4 z-j9RPKwlAbTO|nz?uQiaJPntgs#nePrBbF1&MJh%*Bh*h11VSv z36W!nUGGXxb{bkZx~+yUnQU_zgp50km;LZuw+g#-O;X!w-d`FOLwzZwbVBma9=2iL zEn#Q>AS1^R@6zR*JNBkc7^B~BU!T7Wb|zF{M$uFf<1HlbCp~O{Z6jpeO|)R$LDdg! zqol>5;d%cJIR&(DIbk=#%2CJy^p%em}3 zFWZXgD31+m8suVnoiT?v`zzcTia2a0K_n}q~x1j5f5v}1nH^FZS zqi!}FQb{F>F#h7iM-kEYQ2^Yj{I_ccpW-YrO|L@CqqwdCjMh6edL=6Ying2iY%apD z=Vs=6)}grJEP?%z>Ag7yZuaE2VCOL7_HakPqMw)s6pmN^s4MTXI8gG|0Z3?qEHwIb z1Ybr}7u)Y+9qm!-j*Hg0Jy|^aWsDVRx%4#!|7yB{ zMzD=;&|8<16iee=)9Tg?xVaia(q{P@hIXyRL|=F#~2Go~UUBG+{JeZ)ON;9ZQ^c){4% z*q!mq*2e+mHECR4sRlZ+Prmuu9(H$JJsIenV;tY7u`u>8{_hUbY{c}LvXd+C_3cjL zc((!k2b#`CheSj~=uzlRts2e$&U@4s?#7_23XLa8$Olx+@4My#Ww5Z zzT0D1cDKwT)_mmNy}q-WNc{x__f2DixdzdAKJI*J_nz7b9XChE2LNO9`yLZf_p@#6 zJt=%ye>K`PPb6C_WEuinNA6KZ-;#fHe$T%%^@VRRcnRQ%T>2wlHYdbZ$Zk&qB+bJj zSHOBJI4EdkITfNuf^3i6Uo?TO>*}DktK>XLvw#P+N`Nw>!xm8z+^zfbT^2d8re(iv z*a{StxtE1MjC+a!EX8x4zie9mXk%jo!jm_E04p8R($V=o!esJF7K3%|zXUe(*stTh zIR9&-4~@2K%~o!B4v&a%w`7(_LK7*I($F$G@ZQ+D9iTX$w!Tq0IZK;; z5o|Wwi8h^Jc40*lGu@ZiEV{&(4;bors+VE+=nTL%s)NH}LYSd)JRTbSna^hfXRd0% bqg`>4;xU>sw+;m?1~R#6exuyL{n7scgWHt{ literal 0 HcmV?d00001 diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect_pressed.png.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect_pressed.png.meta new file mode 100644 index 000000000..be2aafd67 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/disconnect_pressed.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 1a6640d26697e1945a91315a31c97347 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/jetbrains.png b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/jetbrains.png new file mode 100644 index 0000000000000000000000000000000000000000..9aa34002f714010fe84f935d54bf5187b13bce81 GIT binary patch literal 724 zcmV;_0xSKAP)nCS7zzxq*9wvO=P%(lj6zEZm-ea01dBgcFc; z8_(knQi^SYXH=((Rm~?^wx6HJ^WMCf_r`$5VzF2XAW)ExpQEZPQD59&&N1BUG^a_O zhY~szMrxwdDUAU~qbT5z36iqQ81HrOttEsA2zjXt zm&mW-1QllU$ui0sLgVgs^T0&0tOf);LO(uyiF}>ndR8Ie%n_6-7bJ-aPuhL@yYf^y znGN_{%WG&Y25@{&c3PXmtei9NkdxQ1geW)~d<}LxFUS9Dt8fUB@E&4CbUMwM$kb87 z^Pc3rV>AM)CSlKnLV&uh=DXAfAoo+NM=UOILk1l4@jRWp*r42cNNa#9m!(9+mp*4n zRVg;O)v=J6al8gB@~@Q%C(4VuHu;@K|McGp%AIdyWx}CJAp=}Eb%OL0BAi^X!+$^ARsADzEd^*#!~ zoAbxGMRLn&?tCRx@0XBL|Cuu(cLXHxoh=Muu~;mYyUK47puG0K7rzJq0000xV literal 0 HcmV?d00001 diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/jetbrains.png.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/jetbrains.png.meta new file mode 100644 index 000000000..6fd03cd00 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/jetbrains.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: a257084e1bd2a1748aca50d95944a61f +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/title_icon.png b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/title_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ccd1ce1666c9592b0c6496ba9407c7a209f47ef9 GIT binary patch literal 1041 zcmV+s1n&EZP) z6-r2KCoPP`ky@v*eRJY_eSPeTWQ3EGlk)NL@x|fcp)VQ2k(2{Iz2u0o}Zt$B@}12FpbYxf|_w=1=!pJ(e)&^e@O7? z=%|W^b7?+B_m+eDldWJHK_d!JyIab^n=MNwT`T-@*9Q*l-`NgKmP*zFEt z?Ml7~u|a_7FIT=OIRXTybGnT&X|6Hsd$`Qwx-8~s;DLE0>jFwox09>W2nKzu05b^Q zH9TNTEHE`S<)i;9?EK`4qT7!#*o|v|(dy7Buhkm7g3Cg$Zen7hJU2IY9@ST6h70vG zz-NDHX{p|9HuK}-<5!XZc&zAZG-@kyJz3Syk5DT0kH|%!h|lKP+1Za$sdUYk)9IuW zgV+3`Kmfrv6>!yS9fT?cL}joeM(>@mySqCN#ih!5o+Lmk20s)3KS8)IV42J1rX>Tw z-fP}fE>1)o!2rgpm~}f`4iRq^$pElfWcl#$P@{cZw#9}Z-Y`IV*}?=UV6ARRX!fQ= z6{)Q?IXNk`kLTDCB%yh@Wv9alvaqnwRw_p;Z6(Sr zWxUoq5m2h88LDo_!4PPmvSWs?h5mX51M!0OoW+Ts+#0XAPQH$oz z(UjoGp(R*98!m=?O;_EH3f!P^A9R0zf8LkKSb8?7SWI?YLqY><^*WbB{&=I|v6=C( znc?iJ$mkL9db8bSG935;HzFvHqXLAU^AG zXDu$y;*jc}Cgpan%hiZ#?Kq1=>c7H>aC|CRRWXUPkpc0!A{pZ!@xp9pu literal 0 HcmV?d00001 diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/title_icon.png.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/title_icon.png.meta new file mode 100644 index 000000000..91ce61c20 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/title_icon.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 34c51cad0d2c48f4cb8250e55a0106e7 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/tuanjie.png b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/tuanjie.png new file mode 100644 index 0000000000000000000000000000000000000000..dcb47b0329aad4ed3e89482c13e6434d9a4ae225 GIT binary patch literal 1549 zcmV+o2J-odP)E&{Gcs}A4G`=BBExh+HPt4lfFsXnR&w;xa4+jb#sD9Y2KdQgfe*s; z^t3ZNI(i7Zwm=~8rMh0}yJU>5w$F5CB#P{;@ z!c9(2u7OGTkWJjx)#XBe7d9|m<9_htPv6AE#5XV*QK1NvpR>r}40|mWOD#4Ijpq^* z6YVUf zu(!7dx3{*4CD4oacFA)-k5@J3BiZga|1uSN)s(Brx2%r z4_hs^MZ9PG@$u1hets^zIWbv-n3x#Y-rk0rn;XDDM_=@wJePC*j)6NTpq*lD%~bs7ZnxBhrMV>r{WQxvAyY+ zg;7cnT}I`nVHEQDe2ZwbopOKCXrVmBW>*?d5al$1V_Jp$qNq#IFO{E2)Xq{nGlx+m zB9w{Z!$yM0@l;A`40*=J#(2<&7h5g)IT}9#jQEhE9^KB0GfAnhRdgXDRck;XET~q% zhJXlxtKgTr3q=q!jK_(wDgV=QA(}Z3B7+r4Sp9xKy%zvaB*^VTK5>vhD~gT7gztx; zaAjp>AR!@v5-uY0D})Z0cy9pT%z@O@RMHi;J~Z5gk?pv?zCM5vN|gwzR)FGmettf6 zZScCWegNB>*@=xp=#(T+D{y^%Esab1j#>0MU$KhrC+MSeTML>|h=eJx5#bOYst2oR z^t<>U1U6CM8&NHw;j7Y73wnf5Cul)8jOOO%0t@o;^2it$goya~cvpFOxv@_pva+&t z$8-tM3S?(zgAsCaa;#_wWMpIjD>5@Pg)-9Na2RfEn)%q+Sl#iU*(j|7)uzh2R0&#Q zs+r044i687Qv@yGhK7oY3V{F%$Z&=4tG@E6RzRLM(1vDN1-XH=8bpRjNkDfI3~y;^ zVK$y1&Au$X_yk4-2vXXB%}f3#H--h|PL%i4Z*g%k;IW|a8wzBz?+KP(=vHKu)8J1h zhB7y%$zOw*mdmH4M&27yREVEZ6K zL`(p~?k8Eb#tN#uTo_rH{5%*;Eokt7c1T%`$)k`M&nYa9csw3Im;lo&GO_3wq8X-Z zi2z;;-@MGO%08r|Bp?w78vPv!@MeG9{!igAO3zy5$Q=Nx00000NkvXXu0mjfbRV+B literal 0 HcmV?d00001 diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/tuanjie.png.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/tuanjie.png.meta new file mode 100644 index 000000000..3d5b4e92c --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/tuanjie.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 03d8d1c5cc7f4e043a3c6311264621ce +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/unity_editor.png b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/unity_editor.png new file mode 100644 index 0000000000000000000000000000000000000000..984ce04232d57df29df2d01df742c950b023e7ba GIT binary patch literal 1176 zcmV;J1ZVq+P)~2^{33t^GJ0Byi3-EZjK&_st}5y0v>0f<>$d*xrxlm=EU;KofsHDmb*_ zxNZ}VzuZ1Jc@Gxw!2o>X)pXERu5>0;fU}5%_kCD1v6Yqpvdh4S5I0xBj5)N`-1b2n z>Bfh$g@!)&;S$UT{|6BtdvOC4k$wYY7gXEk^!m%8;yAE@wpssY1d^{;)ty+);RYBU>%(9K@T~9<(M2$&CT> zqNdM#I4gx-0<%F->xrGT?GE4>ir552FsC#qJ|<1OPWDGSEpFiEV^F8lPas$cieEWp zW#(a~c5;&4bh@tVHa@(0aUyoy2uKHi3hG?AoHstyU!JL)w6k}#fCgwP*(!x4@Z@7q zyP$28L`D_~m)_M{puu8&KEi*Iju7AD55=~2DS=a7q&xU5Lm?hSpo#Vypy0weyJAub zd_IgHy9_osgDPlZR11M-DR5JYF!O9~r<_hp0htQZV2aMg^bKSY6=pa-)e&WtA~ppG zDvYsXVXFtYqs&sI3;`q3QeXh(A{_&+%ja}T0ad6Jp?k?5FD-!a`xlG#+8Wh4C?o>= zP$@FI_^!6@TsuExPg1r}FsgL|%%EqwrNAw)N_BxRsForE?HCZHz}DVT!v%L$LkLx_ zoA_4O!2shtuw0+tOiSfR_8f{OFSQF8Vr3Q*w!#d%j!EhqCbd=!?UIMJ#uh#^MU7v(Z$~8A{!1tkhbp^9|Z4p{QB|bE;EN;%=Z4H1P%_^wT!45@$79i1+>iJ_F za7%m?0Tr-~Rtg2vd0`Cf9G*1M=k(E`=(|^$3`=e|f!6qw7fcOe z!)B~L7%$%7u4TrVDnO75IsFT^>R_`tl+FIq0t6+-M-Q`TW9jY7U6qrR1rWo;a}Sr1 zjD=v%RG$~2B|u;yKBPX16k#Ml@a60Gr#u-%3bs=6c@b6w2qfae=%Yv;Rs|5Lt*Fh? q7`suZ>+>S43lL=DqhjjwB7Or<=r*TBx~6pi0000K;cg|_$J{()rpB_xE>_WN$nJ?Gq8;J|^qjfNf5={Uy=6GKumhPSw`o;?}JoWD~8 z|HK1@wVx-NhrypCG;2@oN!&GdW*|g-kQ-=vxBC3?RoEYPVn9&*IulZQ<7#NWerAu? z59r7l1@Vjp4;Q+NQSd;n1{B3dlAD1n#51u5_{K;`R^pjZ1FfD@Z1XnK;MGSd}XXGX^ zr7{kwcV8SU>Th|8XH>F~bu`DQ-TPMFc2H4#>=O+`HH}hcGMwPNP!Ufd+t4&fHE`>g zNPs^tdd|a{iTb_Ym`aMD>Yi7Ad4BryL*MB@5fKC2dsy&CeJf=OsRD`TFc1|#p3Py` zDyI`9B;J6Bv1_10sgy@I%!CC&s8zmvb#B*@5<+u;J3v=Q2?-{ff3Buqq$!>ep2Uev zc$iSgoJ95Ysz_xaHerwu3WJI0Ih0|##sVj`O8n|d1(h^9^)&IP;2~S240NahyQaUW z5HC`K)ms%|1HSkDE&0I;MAF?#l98yTV zMFLd2(XCaL7dnzp!Vf8rG8Gh&HxN@*RZJv7*1$lds^)28!+>rWMev?v0A6@h&um*{ zJQUE7%Y?Lnv`EWD&_Hf` zqgl;~Tz98aFtHKJxxXJ!G%@8YNasBW9@zU=Oq2l|Q5kR_U-I#2U+Qx-MY7-iyk=c) hbTw4#&4B}VmA|h$VD=yo#ODA2002ovPDHLkV1gYseoX)X literal 0 HcmV?d00001 diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/visualstudio.png.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/visualstudio.png.meta new file mode 100644 index 000000000..bbabbdbe3 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/visualstudio.png.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 62a56066f603166498bc1fee8767634b +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Icons/vscode.png b/Packages/cn.tuanjie.codely.bridge/Editor/Icons/vscode.png new file mode 100644 index 0000000000000000000000000000000000000000..acd54efa64c1c47497172ef98841c1d5a1f98a6c GIT binary patch literal 966 zcmV;%13CPOP)TtFdgZH_MWw=<4r4kMs9F%rx6!LQ)sN8T>9YP}E`leR1S> zF;xMW^IbfAhG;C;Stz*2hzcpupF9j9dhkT>ux2o(_78ZfA0QJjZW@3;gCzyg&Qwg%6k>OJp`jRr_!uK=NcR0cE&=E0& zBKQgrum;@IBCjD7#Unsq*I-6dBxluj*c!sy#}AMY9sxYT<2^0Mh}l(JPy{e*=r)8= z@k^mu!2K)o?zOVk-aRQ`J^t}z2xHGu9s%fAD3FEEy|xkC=iSMA;6Vd1>v2jc6=m7# z;9H)aR%JAZ?ZpcjG4e9N0o4txzy?#~EGaQ{pT!(lU%!nWig?FLbj5@(AZKN8Q7=J+ zkbpSI65$pgZDR|q^O8&;_>ZT=kh(KRB4`l<5=BVg+@(bfb~`SNSQ2Xj&x=THce-9a zuL>4CH&ppG_sP^haNOKIHkoC$1Rj}T@oepz2@mp#iBm*i;0_1-$k!X+H}>p5aRu&h o_|L2iKeKLYg;3x}jvU$Y7h)l5^gT}Te*gdg07*qoM6N<$g0t + { + public override void WriteJson(JsonWriter writer, Vector3 value, JsonSerializer serializer) + { + writer.WriteStartObject(); + writer.WritePropertyName("x"); + writer.WriteValue(value.x); + writer.WritePropertyName("y"); + writer.WriteValue(value.y); + writer.WritePropertyName("z"); + writer.WriteValue(value.z); + writer.WriteEndObject(); + } + + public override Vector3 ReadJson(JsonReader reader, Type objectType, Vector3 existingValue, bool hasExistingValue, JsonSerializer serializer) + { + JObject jo = JObject.Load(reader); + return new Vector3( + (float)jo["x"], + (float)jo["y"], + (float)jo["z"] + ); + } + } + + public class Vector2Converter : JsonConverter + { + public override void WriteJson(JsonWriter writer, Vector2 value, JsonSerializer serializer) + { + writer.WriteStartObject(); + writer.WritePropertyName("x"); + writer.WriteValue(value.x); + writer.WritePropertyName("y"); + writer.WriteValue(value.y); + writer.WriteEndObject(); + } + + public override Vector2 ReadJson(JsonReader reader, Type objectType, Vector2 existingValue, bool hasExistingValue, JsonSerializer serializer) + { + JObject jo = JObject.Load(reader); + return new Vector2( + (float)jo["x"], + (float)jo["y"] + ); + } + } + + public class QuaternionConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, Quaternion value, JsonSerializer serializer) + { + writer.WriteStartObject(); + writer.WritePropertyName("x"); + writer.WriteValue(value.x); + writer.WritePropertyName("y"); + writer.WriteValue(value.y); + writer.WritePropertyName("z"); + writer.WriteValue(value.z); + writer.WritePropertyName("w"); + writer.WriteValue(value.w); + writer.WriteEndObject(); + } + + public override Quaternion ReadJson(JsonReader reader, Type objectType, Quaternion existingValue, bool hasExistingValue, JsonSerializer serializer) + { + JObject jo = JObject.Load(reader); + return new Quaternion( + (float)jo["x"], + (float)jo["y"], + (float)jo["z"], + (float)jo["w"] + ); + } + } + + public class ColorConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, Color value, JsonSerializer serializer) + { + writer.WriteStartObject(); + writer.WritePropertyName("r"); + writer.WriteValue(value.r); + writer.WritePropertyName("g"); + writer.WriteValue(value.g); + writer.WritePropertyName("b"); + writer.WriteValue(value.b); + writer.WritePropertyName("a"); + writer.WriteValue(value.a); + writer.WriteEndObject(); + } + + public override Color ReadJson(JsonReader reader, Type objectType, Color existingValue, bool hasExistingValue, JsonSerializer serializer) + { + JObject jo = JObject.Load(reader); + return new Color( + (float)jo["r"], + (float)jo["g"], + (float)jo["b"], + (float)jo["a"] + ); + } + } + + public class RectConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, Rect value, JsonSerializer serializer) + { + writer.WriteStartObject(); + writer.WritePropertyName("x"); + writer.WriteValue(value.x); + writer.WritePropertyName("y"); + writer.WriteValue(value.y); + writer.WritePropertyName("width"); + writer.WriteValue(value.width); + writer.WritePropertyName("height"); + writer.WriteValue(value.height); + writer.WriteEndObject(); + } + + public override Rect ReadJson(JsonReader reader, Type objectType, Rect existingValue, bool hasExistingValue, JsonSerializer serializer) + { + JObject jo = JObject.Load(reader); + return new Rect( + (float)jo["x"], + (float)jo["y"], + (float)jo["width"], + (float)jo["height"] + ); + } + } + + public class BoundsConverter : JsonConverter + { + public override void WriteJson(JsonWriter writer, Bounds value, JsonSerializer serializer) + { + writer.WriteStartObject(); + writer.WritePropertyName("center"); + serializer.Serialize(writer, value.center); // Use serializer to handle nested Vector3 + writer.WritePropertyName("size"); + serializer.Serialize(writer, value.size); // Use serializer to handle nested Vector3 + writer.WriteEndObject(); + } + + public override Bounds ReadJson(JsonReader reader, Type objectType, Bounds existingValue, bool hasExistingValue, JsonSerializer serializer) + { + JObject jo = JObject.Load(reader); + Vector3 center = jo["center"].ToObject(serializer); // Use serializer to handle nested Vector3 + Vector3 size = jo["size"].ToObject(serializer); // Use serializer to handle nested Vector3 + return new Bounds(center, size); + } + } + + // Converter for UnityEngine.Object references (GameObjects, Components, Materials, Textures, etc.) + public class UnityEngineObjectConverter : JsonConverter + { + public override bool CanRead => true; // We need to implement ReadJson + public override bool CanWrite => true; + + ///

+ /// Delegate for finding UnityEngine.Object by instruction (e.g., {"find":"...", "method":"..."}). + /// This is set by the Editor assembly at startup to avoid cross-assembly reference issues. + /// + public static Func FindObjectByInstruction { get; set; } + + public override void WriteJson(JsonWriter writer, UnityEngine.Object value, JsonSerializer serializer) + { + if (value == null) + { + writer.WriteNull(); + return; + } + +#if UNITY_EDITOR // AssetDatabase and EditorUtility are Editor-only + if (UnityEditor.AssetDatabase.Contains(value)) + { + // It's an asset (Material, Texture, Prefab, etc.) + string path = UnityEditor.AssetDatabase.GetAssetPath(value); + if (!string.IsNullOrEmpty(path)) + { + writer.WriteValue(path); + } + else + { + // Asset exists but path couldn't be found? Write minimal info. + writer.WriteStartObject(); + writer.WritePropertyName("name"); + writer.WriteValue(value.name); + writer.WritePropertyName("instanceID"); + writer.WriteValue(value.GetInstanceID()); + writer.WritePropertyName("isAssetWithoutPath"); + writer.WriteValue(true); + writer.WriteEndObject(); + } + } + else + { + // It's a scene object (GameObject, Component, etc.) + writer.WriteStartObject(); + writer.WritePropertyName("name"); + writer.WriteValue(value.name); + writer.WritePropertyName("instanceID"); + writer.WriteValue(value.GetInstanceID()); + writer.WriteEndObject(); + } +#else + // Runtime fallback: Write basic info without AssetDatabase + writer.WriteStartObject(); + writer.WritePropertyName("name"); + writer.WriteValue(value.name); + writer.WritePropertyName("instanceID"); + writer.WriteValue(value.GetInstanceID()); + writer.WritePropertyName("warning"); + writer.WriteValue("UnityEngineObjectConverter running in non-Editor mode, asset path unavailable."); + writer.WriteEndObject(); +#endif + } + + public override UnityEngine.Object ReadJson(JsonReader reader, Type objectType, UnityEngine.Object existingValue, bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + { + return null; + } + +#if UNITY_EDITOR + if (reader.TokenType == JsonToken.String) + { + // Assume it's an asset path + string path = reader.Value.ToString(); + return UnityEditor.AssetDatabase.LoadAssetAtPath(path, objectType); + } + + if (reader.TokenType == JsonToken.StartObject) + { + JObject jo = JObject.Load(reader); + + // Handle {"find":"...", "method":"..."} reference lookup format + if (jo.TryGetValue("find", out JToken findToken)) + { + if (FindObjectByInstruction != null) + { + UnityEngine.Object foundObj = FindObjectByInstruction(jo, objectType); + if (foundObj != null) + { + return foundObj; + } + // Log warning if find instruction was provided but object wasn't found + Debug.LogWarning($"Could not find object using instruction: {jo}"); + return null; + } + else + { + Debug.LogWarning("FindObjectByInstruction delegate not registered. Cannot resolve find instruction."); + return null; + } + } + + if (jo.TryGetValue("instanceID", out JToken idToken) && idToken.Type == JTokenType.Integer) + { + int instanceId = idToken.ToObject(); + UnityEngine.Object obj = UnityEditor.EditorUtility.InstanceIDToObject(instanceId); + if (obj != null && objectType.IsAssignableFrom(obj.GetType())) + { + return obj; + } + } + // Could potentially try finding by name as a fallback if ID lookup fails/isn't present + // but that's less reliable. + } +#else + // Runtime deserialization is tricky without AssetDatabase/EditorUtility + // Maybe log a warning and return null or existingValue? + Debug.LogWarning("UnityEngineObjectConverter cannot deserialize complex objects in non-Editor mode."); + // Skip the token to avoid breaking the reader + if (reader.TokenType == JsonToken.StartObject) JObject.Load(reader); + else if (reader.TokenType == JsonToken.String) reader.ReadAsString(); + // Return null or existing value, depending on desired behavior + return existingValue; +#endif + + throw new JsonSerializationException($"Unexpected token type '{reader.TokenType}' when deserializing UnityEngine.Object"); + } + } +} diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Serialization/UnityTypeConverters.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Serialization/UnityTypeConverters.cs.meta new file mode 100644 index 000000000..a9fe82fa7 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Serialization/UnityTypeConverters.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1eb3a812e461dfa499f91f9500604ef3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Tools.meta new file mode 100644 index 000000000..43cc04bdd --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3f0bd257c0ef5f4448dc88dfe19562c4 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/CodelyUnityValidationTools.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/CodelyUnityValidationTools.cs new file mode 100644 index 000000000..f02acb9c6 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/CodelyUnityValidationTools.cs @@ -0,0 +1,1686 @@ +using System; +using System.Collections.Generic; +using Codely.Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityTcp.Editor.Helpers; + +namespace UnityTcp.Editor.Tools +{ + /// + /// Custom validation helpers that can be invoked via the execute_custom_tool MCP tool. + /// Each method validates a specific scenario and returns a standard response object: + /// + /// { + /// success: bool, + /// message: string, + /// data: { ... optional extra info ... } + /// } + /// + /// Tool registration is done via [ExecuteCustomTool.CustomTool] attribute. + /// + public static class CodelyUnityValidationTools + { + /// + /// Validates current play mode against an expected value. + /// tool_name: "codely.validate_play_mode" + /// + /// Parameters: + /// { + /// "expected": "stopped" | "playing" | "paused" + /// } + /// + [ExecuteCustomTool.CustomTool("codely.validate_play_mode", "Validate current editor play mode")] + public static object ValidatePlayMode(JObject parameters) + { + var expected = parameters?["expected"]?.ToString() ?? "stopped"; + + var actual = GetPlayModeString(); + + if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) + { + var msg = $"PlayMode mismatch. Expected={expected}, Actual={actual}"; + Debug.LogError($"[CodelyValidation] {msg}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msg, + ["data"] = new Dictionary + { + ["expected"] = expected, + ["actual"] = actual + } + }; + } + + var okMsg = $"PlayMode OK: {actual}"; + Debug.Log($"[CodelyValidation] {okMsg}"); + return new Dictionary + { + ["success"] = true, + ["message"] = okMsg, + ["data"] = new Dictionary + { + ["expected"] = expected, + ["actual"] = actual + } + }; + } + + /// + /// Validates that the latest console messages contain at least one entry + /// matching the given filter text and type set. + /// + /// tool_name: "codely.validate_console_contains" + /// + /// Parameters: + /// { + /// "filterText": "ConsoleSpamTest", + /// "types": ["error","warning","log","exception"], + /// "minCount": 1 + /// } + /// + [ExecuteCustomTool.CustomTool("codely.validate_console_contains", "Validate console contains messages matching filter")] + public static object ValidateConsoleContains(JObject parameters) + { + var filterText = parameters?["filterText"]?.ToString() ?? string.Empty; + var minCount = parameters?["minCount"]?.ToObject() ?? 1; + + var typesToken = parameters?["types"] as JArray; + var types = new List(); + if (typesToken != null) + { + foreach (var t in typesToken) + { + if (t.Type == JTokenType.String) + { + types.Add(t.ToString()); + } + } + } + if (types.Count == 0) + { + types.AddRange(new[] { "error", "warning", "log" }); + } + + var logEntries = ReadConsoleMessages(types, filterText); + + var matchedCount = logEntries.Count; + if (matchedCount < minCount) + { + var msg = $"Console validation failed. Expected at least {minCount} messages containing '{filterText}', but found {matchedCount}."; + Debug.LogError($"[CodelyValidation] {msg}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msg, + ["data"] = new Dictionary + { + ["filterText"] = filterText, + ["types"] = types, + ["matchedCount"] = matchedCount, + ["messages"] = logEntries + } + }; + } + + var okMsg = $"Console validation OK. Found {matchedCount} messages containing '{filterText}'."; + Debug.Log($"[CodelyValidation] {okMsg}"); + return new Dictionary + { + ["success"] = true, + ["message"] = okMsg, + ["data"] = new Dictionary + { + ["filterText"] = filterText, + ["types"] = types, + ["matchedCount"] = matchedCount, + ["messages"] = logEntries + } + }; + } + + /// + /// Validates that a specific Tag and Layer both exist in the project. + /// + /// tool_name: "codely.validate_tag_and_layer_exist" + /// + /// Parameters: + /// { + /// "tagName": "CodelyTestTag", + /// "layerName": "CodelyLayer" + /// } + /// + [ExecuteCustomTool.CustomTool("codely.validate_tag_and_layer_exist", "Validate that a specific Tag and Layer exist")] + public static object ValidateTagAndLayerExist(JObject parameters) + { + var tagName = parameters?["tagName"]?.ToString(); + var layerName = parameters?["layerName"]?.ToString(); + + var missing = new List(); + + if (!string.IsNullOrEmpty(tagName) && Array.IndexOf(UnityEditorInternal.InternalEditorUtility.tags, tagName) < 0) + { + missing.Add($"Tag '{tagName}'"); + } + + if (!string.IsNullOrEmpty(layerName) && Array.IndexOf(UnityEditorInternal.InternalEditorUtility.layers, layerName) < 0) + { + missing.Add($"Layer '{layerName}'"); + } + + if (missing.Count > 0) + { + var msg = "Missing project identifiers: " + string.Join(", ", missing); + Debug.LogError($"[CodelyValidation] {msg}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msg, + ["data"] = new Dictionary + { + ["tagName"] = tagName, + ["layerName"] = layerName + } + }; + } + + var okMsg = $"Tag/Layer validation OK. Tag='{tagName}', Layer='{layerName}'."; + Debug.Log($"[CodelyValidation] {okMsg}"); + return new Dictionary + { + ["success"] = true, + ["message"] = okMsg, + ["data"] = new Dictionary + { + ["tagName"] = tagName, + ["layerName"] = layerName + } + }; + } + + /// + /// Generic response validator - checks if a tool response contains expected fields/values. + /// + /// tool_name: "codely.validate_response" + /// + /// Parameters: + /// { + /// "hasField": "fieldName", // Check if response has this field + /// "fieldEquals": { "field": "value" }, // Check if field equals value + /// "isSuccess": true/false, // Check success field + /// "messageContains": "text" // Check if message contains text + /// } + /// + [ExecuteCustomTool.CustomTool("codely.validate_response", "Generic response validator")] + public static object ValidateResponse(JObject parameters) + { + // This tool is meant to be called after another tool call + // The caller should pass the previous response data for validation + var hasField = parameters?["hasField"]?.ToString(); + var fieldEquals = parameters?["fieldEquals"] as JObject; + var isSuccess = parameters?["isSuccess"]?.ToObject(); + var messageContains = parameters?["messageContains"]?.ToString(); + var responseData = parameters?["responseData"] as JObject; + + var errors = new List(); + + if (responseData == null) + { + return new Dictionary + { + ["success"] = false, + ["message"] = "No responseData provided for validation", + ["data"] = new Dictionary() + }; + } + + // Check hasField (supports nested paths like "state.project.srp" or "project.srp") + // Also supports automatic recursive search if direct path fails + if (!string.IsNullOrEmpty(hasField)) + { + JToken fieldValue = null; + string foundPath = null; + + if (hasField.Contains(".")) + { + // Handle explicit nested field paths (e.g., "state.project.srp" or "project.srp") + var pathParts = hasField.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries); + JToken current = responseData; + foreach (var part in pathParts) + { + if (current == null || current.Type != JTokenType.Object) + { + current = null; + break; + } + current = current[part]; + } + fieldValue = current; + if (fieldValue != null && fieldValue.Type != JTokenType.Null) + { + foundPath = hasField; + } + } + else + { + // First try simple top-level field check + fieldValue = responseData[hasField]; + if (fieldValue != null && fieldValue.Type != JTokenType.Null) + { + foundPath = hasField; + } + } + + // If not found and it's a simple field name (no dots), try recursive search + if ((fieldValue == null || fieldValue.Type == JTokenType.Null) && !hasField.Contains(".")) + { + var recursiveResult = FindFieldRecursive(responseData, hasField); + if (recursiveResult.found) + { + fieldValue = recursiveResult.value; + foundPath = recursiveResult.path; + } + } + + if (fieldValue == null || fieldValue.Type == JTokenType.Null) + { + errors.Add($"Missing field: {hasField}" + (foundPath == null ? "" : $" (searched recursively, not found)")); + } + else if (foundPath != null && foundPath != hasField) + { + // Log that we found it via recursive search (for debugging) + Debug.Log($"[CodelyValidation] Field '{hasField}' found at nested path: {foundPath}"); + } + } + + // Check fieldEquals + if (fieldEquals != null) + { + foreach (var prop in fieldEquals.Properties()) + { + var actualValue = responseData[prop.Name]; + if (actualValue == null) + { + errors.Add($"Field '{prop.Name}' not found"); + } + else if (!JToken.DeepEquals(actualValue, prop.Value)) + { + errors.Add($"Field '{prop.Name}' expected={prop.Value}, actual={actualValue}"); + } + } + } + + // Check isSuccess + if (isSuccess.HasValue) + { + var actualSuccess = responseData["success"]?.ToObject() ?? false; + if (actualSuccess != isSuccess.Value) + { + errors.Add($"success expected={isSuccess.Value}, actual={actualSuccess}"); + } + } + + // Check messageContains + if (!string.IsNullOrEmpty(messageContains)) + { + var message = responseData["message"]?.ToString() ?? ""; + if (!message.Contains(messageContains, StringComparison.OrdinalIgnoreCase)) + { + errors.Add($"message does not contain '{messageContains}'"); + } + } + + if (errors.Count > 0) + { + var msg = "Response validation failed: " + string.Join("; ", errors); + Debug.LogError($"[CodelyValidation] {msg}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msg, + ["data"] = new Dictionary { ["errors"] = errors } + }; + } + + var okMsg = "Response validation OK"; + Debug.Log($"[CodelyValidation] {okMsg}"); + return new Dictionary + { + ["success"] = true, + ["message"] = okMsg, + ["data"] = new Dictionary() + }; + } + + /// + /// Validates that the active editor tool matches expected. + /// + /// tool_name: "codely.validate_active_tool" + /// + /// Parameters: + /// { + /// "expected": "Move" | "Rotate" | "Scale" | "View" | "Rect" | "Transform" + /// } + /// + [ExecuteCustomTool.CustomTool("codely.validate_active_tool", "Validate current active editor tool")] + public static object ValidateActiveTool(JObject parameters) + { + var expected = parameters?["expected"]?.ToString(); + if (string.IsNullOrEmpty(expected)) + { + return new Dictionary + { + ["success"] = false, + ["message"] = "Parameter 'expected' is required", + ["data"] = new Dictionary() + }; + } + + var currentTool = UnityEditor.Tools.current; + var actual = currentTool.ToString(); + + // Map Unity's Tool enum to friendly names + var toolNameMap = new Dictionary + { + { UnityEditor.Tool.View, "View" }, + { UnityEditor.Tool.Move, "Move" }, + { UnityEditor.Tool.Rotate, "Rotate" }, + { UnityEditor.Tool.Scale, "Scale" }, + { UnityEditor.Tool.Rect, "Rect" }, + { UnityEditor.Tool.Transform, "Transform" } + }; + + if (toolNameMap.TryGetValue(currentTool, out var friendlyName)) + { + actual = friendlyName; + } + + if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) + { + var msg = $"Active tool mismatch. Expected={expected}, Actual={actual}"; + Debug.LogError($"[CodelyValidation] {msg}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msg, + ["data"] = new Dictionary + { + ["expected"] = expected, + ["actual"] = actual + } + }; + } + + var okMsg = $"Active tool OK: {actual}"; + Debug.Log($"[CodelyValidation] {okMsg}"); + return new Dictionary + { + ["success"] = true, + ["message"] = okMsg, + ["data"] = new Dictionary + { + ["expected"] = expected, + ["actual"] = actual + } + }; + } + + /// + /// Validates editor is not compiling. + /// + /// tool_name: "codely.validate_not_compiling" + /// + [ExecuteCustomTool.CustomTool("codely.validate_not_compiling", "Validate editor is not compiling")] + public static object ValidateNotCompiling(JObject parameters) + { + var isCompiling = EditorApplication.isCompiling; + + if (isCompiling) + { + var msg = "Editor is still compiling"; + Debug.LogError($"[CodelyValidation] {msg}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msg, + ["data"] = new Dictionary { ["isCompiling"] = true } + }; + } + + var okMsg = "Editor is not compiling"; + Debug.Log($"[CodelyValidation] {okMsg}"); + return new Dictionary + { + ["success"] = true, + ["message"] = okMsg, + ["data"] = new Dictionary { ["isCompiling"] = false } + }; + } + + /// + /// Validates console message count within expected range. + /// + /// tool_name: "codely.validate_console_count" + /// + /// Parameters: + /// { + /// "minCount": 0, + /// "maxCount": 100, + /// "types": ["error", "warning", "log"] + /// } + /// + [ExecuteCustomTool.CustomTool("codely.validate_console_count", "Validate console message count")] + public static object ValidateConsoleCount(JObject parameters) + { + var minCount = parameters?["minCount"]?.ToObject() ?? 0; + var maxCount = parameters?["maxCount"]?.ToObject() ?? int.MaxValue; + + var typesToken = parameters?["types"] as JArray; + var types = new List(); + if (typesToken != null) + { + foreach (var t in typesToken) + { + if (t.Type == JTokenType.String) + { + types.Add(t.ToString()); + } + } + } + if (types.Count == 0) + { + types.AddRange(new[] { "error", "warning", "log" }); + } + + var messages = ReadConsoleMessages(types, ""); + var count = messages.Count; + + if (count < minCount || count > maxCount) + { + var msg = $"Console count out of range. Expected [{minCount}, {maxCount}], Actual={count}"; + Debug.LogError($"[CodelyValidation] {msg}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msg, + ["data"] = new Dictionary + { + ["count"] = count, + ["minCount"] = minCount, + ["maxCount"] = maxCount + } + }; + } + + var okMsg = $"Console count OK: {count} (range [{minCount}, {maxCount}])"; + Debug.Log($"[CodelyValidation] {okMsg}"); + return new Dictionary + { + ["success"] = true, + ["message"] = okMsg, + ["data"] = new Dictionary + { + ["count"] = count, + ["minCount"] = minCount, + ["maxCount"] = maxCount + } + }; + } + + // --------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------- + + /// + /// Recursively searches for a field in a JToken tree. + /// Returns the found value and its path, or null if not found. + /// + private static (bool found, JToken value, string path) FindFieldRecursive(JToken token, string fieldName, string currentPath = "") + { + if (token == null || token.Type == JTokenType.Null) + return (false, null, null); + + // If it's an object, check if it has the field + if (token.Type == JTokenType.Object) + { + var obj = (JObject)token; + if (obj[fieldName] != null && obj[fieldName].Type != JTokenType.Null) + { + var path = string.IsNullOrEmpty(currentPath) ? fieldName : $"{currentPath}.{fieldName}"; + return (true, obj[fieldName], path); + } + + // Recursively search in all properties + foreach (var prop in obj.Properties()) + { + var newPath = string.IsNullOrEmpty(currentPath) ? prop.Name : $"{currentPath}.{prop.Name}"; + var result = FindFieldRecursive(prop.Value, fieldName, newPath); + if (result.found) + return result; + } + } + // If it's an array, search in each element + else if (token.Type == JTokenType.Array) + { + var arr = (JArray)token; + for (int i = 0; i < arr.Count; i++) + { + var newPath = $"{currentPath}[{i}]"; + var result = FindFieldRecursive(arr[i], fieldName, newPath); + if (result.found) + return result; + } + } + + return (false, null, null); + } + + private static string GetPlayModeString() + { + if (!Application.isPlaying) + { + return "stopped"; + } + + return EditorApplication.isPaused ? "paused" : "playing"; + } + + /// + /// Reads current console messages via Unity's internal LogEntries API. + /// We keep this intentionally simple: only type + message text for validation. + /// + private static List> ReadConsoleMessages(List allowedTypes, string filterText) + { + var results = new List>(); + + var logEntriesType = Type.GetType("UnityEditor.LogEntries, UnityEditor.dll"); + var logEntryType = Type.GetType("UnityEditor.LogEntry, UnityEditor.dll"); + if (logEntriesType == null || logEntryType == null) + { + Debug.LogWarning("[CodelyValidation] Unable to access UnityEditor.LogEntries/LogEntry types."); + return results; + } + + var getCountMethod = logEntriesType.GetMethod("GetCount", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public); + var getEntryMethod = logEntriesType.GetMethod("GetEntryInternal", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public); + var startGettingEntries = logEntriesType.GetMethod("StartGettingEntries", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public); + var endGettingEntries = logEntriesType.GetMethod("EndGettingEntries", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public); + + if (getCountMethod == null || getEntryMethod == null || startGettingEntries == null || endGettingEntries == null) + { + Debug.LogWarning("[CodelyValidation] Unable to reflect LogEntries methods."); + return results; + } + + var entry = Activator.CreateInstance(logEntryType); + var conditionField = logEntryType.GetField("condition"); + var modeField = logEntryType.GetField("mode"); + + startGettingEntries.Invoke(null, null); + try + { + var count = (int)getCountMethod.Invoke(null, null); + for (int i = 0; i < count; i++) + { + object[] args = { i, entry }; + getEntryMethod.Invoke(null, args); + + var condition = conditionField?.GetValue(entry)?.ToString() ?? string.Empty; + var modeValue = modeField != null ? (int)modeField.GetValue(entry) : 0; + var typeName = LogTypeFromMode(modeValue); + + if (allowedTypes.Count > 0 && !allowedTypes.Contains(typeName)) + continue; + + if (!string.IsNullOrEmpty(filterText) && !condition.Contains(filterText, StringComparison.OrdinalIgnoreCase)) + continue; + + results.Add(new Dictionary + { + ["type"] = typeName, + ["message"] = condition + }); + } + } + finally + { + endGettingEntries.Invoke(null, null); + } + + return results; + } + + private static string LogTypeFromMode(int mode) + { + // Unity uses bit flags; we map common ones to our string types. + // See: https://github.com/Unity-Technologies/UnityCsReference/blob/master/Editor/Mono/ConsoleWindow.cs + const int ErrorMask = 1; + const int AssertMask = 2; + const int LogMask = 4; + const int FatalMask = 16; + + if ((mode & ErrorMask) != 0 || (mode & FatalMask) != 0) + return "error"; + if ((mode & AssertMask) != 0) + return "assert"; + if ((mode & LogMask) != 0) + return "log"; + + // Fallback + return "log"; + } + + // ===================================================================== + // Shader / Material Validation Tools + // ===================================================================== + + /// + /// Validates that a shader file exists at the expected location and optionally + /// that its contents contain (or do not contain) specific substrings. + /// + /// tool_name: "codely.validate_shader_file" + /// + /// Parameters: + /// { + /// "name": "CodelyTestShader1", // Shader name without .shader (required) + /// "path": "Shaders/Custom", // Optional: same semantics as unity_shader.path (relative to Assets/) + /// "shouldExist": true, // Optional: default true + /// "mustContain": ["Shader", "_Color"], // Optional: substrings that must appear in file + /// "mustNotContain": ["TODO"] // Optional: substrings that must NOT appear + /// } + /// + [ExecuteCustomTool.CustomTool("codely.validate_shader_file", "Validate shader file existence and contents")] + public static object ValidateShaderFile(JObject parameters) + { + var name = parameters?["name"]?.ToString(); + if (string.IsNullOrEmpty(name)) + { + return new Dictionary + { + ["success"] = false, + ["message"] = "Parameter 'name' is required", + ["data"] = new Dictionary() + }; + } + + // Determine directory relative to Assets/, following the same rules as ManageShader + var pathParam = parameters?["path"]?.ToString(); + string relativeDir = pathParam ?? "Shaders"; + if (!string.IsNullOrEmpty(relativeDir)) + { + relativeDir = relativeDir.Replace('\\', '/').Trim('/'); + if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); + } + } + if (string.IsNullOrEmpty(relativeDir)) + { + relativeDir = "Shaders"; + } + + var shaderFileName = $"{name}.shader"; + var fullPathDir = System.IO.Path.Combine(Application.dataPath, relativeDir); + var fullPath = System.IO.Path.Combine(fullPathDir, shaderFileName); + var relativePath = System.IO.Path.Combine("Assets", relativeDir, shaderFileName) + .Replace('\\', '/'); + + var shouldExist = parameters?["shouldExist"]?.ToObject() ?? true; + var mustContainArray = parameters?["mustContain"] as JArray; + var mustNotContainArray = parameters?["mustNotContain"] as JArray; + + var errors = new List(); + bool fileExists = System.IO.File.Exists(fullPath); + + if (shouldExist && !fileExists) + { + errors.Add($"Shader file expected but not found at '{relativePath}'."); + } + else if (!shouldExist && fileExists) + { + errors.Add($"Shader file should not exist at '{relativePath}', but it was found."); + } + + string contents = null; + if (fileExists && (mustContainArray != null || mustNotContainArray != null)) + { + try + { + contents = System.IO.File.ReadAllText(fullPath); + } + catch (Exception e) + { + errors.Add($"Failed to read shader file '{relativePath}': {e.Message}"); + } + } + + if (!string.IsNullOrEmpty(contents) && mustContainArray != null) + { + foreach (var item in mustContainArray) + { + if (item.Type != JTokenType.String) continue; + var expected = item.ToString(); + if (!contents.Contains(expected, StringComparison.Ordinal)) + { + errors.Add($"Shader file '{relativePath}' does not contain required text: \"{expected}\"."); + } + } + } + + if (!string.IsNullOrEmpty(contents) && mustNotContainArray != null) + { + foreach (var item in mustNotContainArray) + { + if (item.Type != JTokenType.String) continue; + var forbidden = item.ToString(); + if (contents.Contains(forbidden, StringComparison.Ordinal)) + { + errors.Add($"Shader file '{relativePath}' contains forbidden text: \"{forbidden}\"."); + } + } + } + + if (errors.Count > 0) + { + var msg = "Shader file validation failed: " + string.Join("; ", errors); + Debug.LogError($"[CodelyValidation] {msg}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msg, + ["data"] = new Dictionary + { + ["name"] = name, + ["path"] = relativePath, + ["exists"] = fileExists, + ["errors"] = errors + } + }; + } + + var okMsg = $"Shader file validation OK for '{relativePath}'."; + Debug.Log($"[CodelyValidation] {okMsg}"); + return new Dictionary + { + ["success"] = true, + ["message"] = okMsg, + ["data"] = new Dictionary + { + ["name"] = name, + ["path"] = relativePath, + ["exists"] = fileExists + } + }; + } + + /// + /// Validates that a material is using the expected shader for the current SRP, + /// given a shader_for_srp mapping (same structure as unity_shader.ensure_material_shader_for_srp). + /// + /// tool_name: "codely.validate_material_shader_for_srp" + /// + /// Parameters: + /// { + /// "material_path": "Assets/Materials/CodelyShaderTestMat.mat", + /// "shader_for_srp": { "builtin": "Standard", "urp": "Universal Render Pipeline/Lit", "hdrp": "HDRP/Lit" } + /// } + /// + [ExecuteCustomTool.CustomTool("codely.validate_material_shader_for_srp", "Validate material shader against SRP mapping")] + public static object ValidateMaterialShaderForSrp(JObject parameters) + { + var materialPath = parameters?["material_path"]?.ToString() + ?? parameters?["material"]?.ToString(); + if (string.IsNullOrEmpty(materialPath)) + { + return new Dictionary + { + ["success"] = false, + ["message"] = "Parameter 'material_path' (or legacy 'material') is required", + ["data"] = new Dictionary() + }; + } + + var shaderMapping = parameters?["shader_for_srp"] as JObject; + if (shaderMapping == null) + { + return new Dictionary + { + ["success"] = false, + ["message"] = "Parameter 'shader_for_srp' is required", + ["data"] = new Dictionary() + }; + } + + if (!shaderMapping.ContainsKey("builtin")) + { + return new Dictionary + { + ["success"] = false, + ["message"] = "shader_for_srp.builtin is required as fallback shader", + ["data"] = new Dictionary() + }; + } + + try + { + var material = AssetDatabase.LoadAssetAtPath(materialPath); + if (material == null) + { + return new Dictionary + { + ["success"] = false, + ["message"] = $"Material not found at: {materialPath}", + ["data"] = new Dictionary() + }; + } + + // Detect current SRP (same logic as ManageShader) + var currentSrp = "builtin"; + var currentRP = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline; + if (currentRP != null) + { + var rpName = currentRP.GetType().Name.ToLowerInvariant(); + var rpFullName = currentRP.GetType().FullName.ToLowerInvariant(); + if (rpName.Contains("urp") || rpName.Contains("universal") || rpFullName.Contains("universal")) + { + currentSrp = "urp"; + } + else if (rpName.Contains("hdrp") || rpName.Contains("highdefinition") || rpFullName.Contains("highdefinition")) + { + currentSrp = "hdrp"; + } + } + + // Resolve expected shader name based on SRP + string expectedShaderName = null; + if (currentSrp == "urp" && shaderMapping.ContainsKey("urp")) + { + expectedShaderName = shaderMapping["urp"]?.ToString(); + } + else if (currentSrp == "hdrp" && shaderMapping.ContainsKey("hdrp")) + { + expectedShaderName = shaderMapping["hdrp"]?.ToString(); + } + else if (shaderMapping.ContainsKey("builtin")) + { + expectedShaderName = shaderMapping["builtin"]?.ToString(); + } + + if (string.IsNullOrEmpty(expectedShaderName)) + { + var msgNoMap = $"No shader mapping provided for current SRP: {currentSrp}"; + Debug.LogError($"[CodelyValidation] {msgNoMap}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msgNoMap, + ["data"] = new Dictionary() + }; + } + + var actualShaderName = material.shader != null ? material.shader.name : "None"; + if (!string.Equals(actualShaderName, expectedShaderName, StringComparison.Ordinal)) + { + var msgMismatch = + $"Material '{materialPath}' shader mismatch for SRP '{currentSrp}'. Expected='{expectedShaderName}', Actual='{actualShaderName}'."; + Debug.LogError($"[CodelyValidation] {msgMismatch}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msgMismatch, + ["data"] = new Dictionary + { + ["material"] = materialPath, + ["currentSrp"] = currentSrp, + ["expectedShader"] = expectedShaderName, + ["actualShader"] = actualShaderName + } + }; + } + + var okMsg = + $"Material '{materialPath}' shader is correct for SRP '{currentSrp}': '{actualShaderName}'."; + Debug.Log($"[CodelyValidation] {okMsg}"); + return new Dictionary + { + ["success"] = true, + ["message"] = okMsg, + ["data"] = new Dictionary + { + ["material"] = materialPath, + ["currentSrp"] = currentSrp, + ["shader"] = actualShaderName + } + }; + } + catch (Exception e) + { + var msg = $"Failed to validate material shader for SRP: {e.Message}"; + Debug.LogError($"[CodelyValidation] {msg}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msg, + ["data"] = new Dictionary() + }; + } + } + + /// + /// Simple render pipeline validation helper. + /// + /// tool_name: "codely.validate_render_pipeline" + /// + /// Parameters: + /// { + /// "expected": "builtin" | "urp" | "hdrp" // Optional, if provided will be checked + /// } + /// + [ExecuteCustomTool.CustomTool("codely.validate_render_pipeline", "Validate current render pipeline SRP value")] + public static object ValidateRenderPipeline(JObject parameters) + { + var expected = parameters?["expected"]?.ToString(); + + try + { + var srp = "builtin"; + var currentRP = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline; + + if (currentRP != null) + { + var rpName = currentRP.GetType().Name.ToLowerInvariant(); + var rpFullName = currentRP.GetType().FullName.ToLowerInvariant(); + if (rpName.Contains("urp") || rpName.Contains("universal") || rpFullName.Contains("universal")) + { + srp = "urp"; + } + else if (rpName.Contains("hdrp") || rpName.Contains("highdefinition") || rpFullName.Contains("highdefinition")) + { + srp = "hdrp"; + } + } + + var errors = new List(); + if (!string.IsNullOrEmpty(expected) && + !string.Equals(expected, srp, StringComparison.OrdinalIgnoreCase)) + { + errors.Add($"SRP mismatch. Expected='{expected}', Actual='{srp}'."); + } + + if (errors.Count > 0) + { + var msgErr = "Render pipeline validation failed: " + string.Join("; ", errors); + Debug.LogError($"[CodelyValidation] {msgErr}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msgErr, + ["data"] = new Dictionary + { + ["srp"] = srp, + ["errors"] = errors + } + }; + } + + var okMsg = $"Render pipeline validation OK. srp='{srp}'."; + Debug.Log($"[CodelyValidation] {okMsg}"); + return new Dictionary + { + ["success"] = true, + ["message"] = okMsg, + ["data"] = new Dictionary + { + ["srp"] = srp + } + }; + } + catch (Exception e) + { + var msg = $"Failed to validate render pipeline: {e.Message}"; + Debug.LogError($"[CodelyValidation] {msg}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msg, + ["data"] = new Dictionary() + }; + } + } + + // ===================================================================== + // Scene Validation Tools + // ===================================================================== + + /// + /// Validates the active scene matches expected name and/or path. + /// + /// tool_name: "codely.validate_active_scene" + /// + /// Parameters: + /// { + /// "expectedName": "SceneName", // Optional: expected scene name (without .unity) + /// "expectedPath": "Assets/Scenes/SceneName.unity" // Optional: expected full asset path + /// } + /// + [ExecuteCustomTool.CustomTool("codely.validate_active_scene", "Validate active scene name and path")] + public static object ValidateActiveScene(JObject parameters) + { + var expectedName = parameters?["expectedName"]?.ToString(); + var expectedPath = parameters?["expectedPath"]?.ToString(); + + if (string.IsNullOrEmpty(expectedName) && string.IsNullOrEmpty(expectedPath)) + { + return new Dictionary + { + ["success"] = false, + ["message"] = "At least one of 'expectedName' or 'expectedPath' is required", + ["data"] = new Dictionary() + }; + } + + var activeScene = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene(); + if (!activeScene.IsValid()) + { + return new Dictionary + { + ["success"] = false, + ["message"] = "No valid active scene", + ["data"] = new Dictionary() + }; + } + + var errors = new List(); + var actualName = activeScene.name; + var actualPath = activeScene.path; + + if (!string.IsNullOrEmpty(expectedName) && !string.Equals(expectedName, actualName, StringComparison.OrdinalIgnoreCase)) + { + errors.Add($"Scene name mismatch: expected='{expectedName}', actual='{actualName}'"); + } + + if (!string.IsNullOrEmpty(expectedPath) && !string.Equals(expectedPath, actualPath, StringComparison.OrdinalIgnoreCase)) + { + errors.Add($"Scene path mismatch: expected='{expectedPath}', actual='{actualPath}'"); + } + + if (errors.Count > 0) + { + var msg = string.Join("; ", errors); + Debug.LogError($"[CodelyValidation] {msg}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msg, + ["data"] = new Dictionary + { + ["expectedName"] = expectedName, + ["expectedPath"] = expectedPath, + ["actualName"] = actualName, + ["actualPath"] = actualPath + } + }; + } + + var okMsg = $"Active scene validation OK: name='{actualName}', path='{actualPath}'"; + Debug.Log($"[CodelyValidation] {okMsg}"); + return new Dictionary + { + ["success"] = true, + ["message"] = okMsg, + ["data"] = new Dictionary + { + ["actualName"] = actualName, + ["actualPath"] = actualPath + } + }; + } + + /// + /// Validates the active scene's dirty state. + /// + /// tool_name: "codely.validate_scene_dirty" + /// + /// Parameters: + /// { + /// "expectedDirty": true/false + /// } + /// + [ExecuteCustomTool.CustomTool("codely.validate_scene_dirty", "Validate scene dirty state")] + public static object ValidateSceneDirty(JObject parameters) + { + var expectedDirty = parameters?["expectedDirty"]?.ToObject() ?? parameters?["isDirty"]?.ToObject(); + + if (!expectedDirty.HasValue) + { + return new Dictionary + { + ["success"] = false, + ["message"] = "Parameter 'expectedDirty' (or 'isDirty') is required", + ["data"] = new Dictionary() + }; + } + + var activeScene = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene(); + if (!activeScene.IsValid()) + { + return new Dictionary + { + ["success"] = false, + ["message"] = "No valid active scene", + ["data"] = new Dictionary() + }; + } + + var actualDirty = activeScene.isDirty; + + if (expectedDirty.Value != actualDirty) + { + var msg = $"Scene dirty state mismatch: expected={expectedDirty.Value}, actual={actualDirty}"; + Debug.LogError($"[CodelyValidation] {msg}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msg, + ["data"] = new Dictionary + { + ["expectedDirty"] = expectedDirty.Value, + ["actualDirty"] = actualDirty, + ["sceneName"] = activeScene.name + } + }; + } + + var okMsg = $"Scene dirty state OK: isDirty={actualDirty} (scene='{activeScene.name}')"; + Debug.Log($"[CodelyValidation] {okMsg}"); + return new Dictionary + { + ["success"] = true, + ["message"] = okMsg, + ["data"] = new Dictionary + { + ["isDirty"] = actualDirty, + ["sceneName"] = activeScene.name + } + }; + } + + /// + /// Validates the hierarchy root count of the active scene. + /// + /// tool_name: "codely.validate_hierarchy_root_count" + /// + /// Parameters: + /// { + /// "minCount": 0, + /// "maxCount": 100 + /// } + /// + [ExecuteCustomTool.CustomTool("codely.validate_hierarchy_root_count", "Validate scene hierarchy root count")] + public static object ValidateHierarchyRootCount(JObject parameters) + { + var minCount = parameters?["minCount"]?.ToObject() ?? 0; + var maxCount = parameters?["maxCount"]?.ToObject() ?? int.MaxValue; + + var activeScene = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene(); + if (!activeScene.IsValid() || !activeScene.isLoaded) + { + return new Dictionary + { + ["success"] = false, + ["message"] = "No valid and loaded active scene", + ["data"] = new Dictionary() + }; + } + + var actualCount = activeScene.rootCount; + + if (actualCount < minCount || actualCount > maxCount) + { + var msg = $"Hierarchy root count out of range: expected [{minCount}, {maxCount}], actual={actualCount}"; + Debug.LogError($"[CodelyValidation] {msg}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msg, + ["data"] = new Dictionary + { + ["minCount"] = minCount, + ["maxCount"] = maxCount, + ["actualCount"] = actualCount + } + }; + } + + var okMsg = $"Hierarchy root count OK: {actualCount} (range [{minCount}, {maxCount}])"; + Debug.Log($"[CodelyValidation] {okMsg}"); + return new Dictionary + { + ["success"] = true, + ["message"] = okMsg, + ["data"] = new Dictionary + { + ["actualCount"] = actualCount + } + }; + } + + // ===================================================================== + // GameObject Validation Tools + // ===================================================================== + + /// + /// Validates that a GameObject exists in the scene. + /// + /// tool_name: "codely.validate_gameobject_exists" + /// + /// Parameters: + /// { + /// "name": "GameObjectName", + /// "shouldExist": true/false // default: true + /// } + /// + [ExecuteCustomTool.CustomTool("codely.validate_gameobject_exists", "Validate GameObject existence")] + public static object ValidateGameObjectExists(JObject parameters) + { + var name = parameters?["name"]?.ToString(); + var shouldExist = parameters?["shouldExist"]?.ToObject() ?? true; + + if (string.IsNullOrEmpty(name)) + { + return new Dictionary + { + ["success"] = false, + ["message"] = "Parameter 'name' is required", + ["data"] = new Dictionary() + }; + } + + var go = GameObject.Find(name); + var exists = go != null; + + if (exists != shouldExist) + { + var msg = shouldExist + ? $"GameObject '{name}' not found but expected to exist" + : $"GameObject '{name}' found but expected NOT to exist"; + Debug.LogError($"[CodelyValidation] {msg}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msg, + ["data"] = new Dictionary + { + ["name"] = name, + ["shouldExist"] = shouldExist, + ["actuallyExists"] = exists + } + }; + } + + var okMsg = shouldExist + ? $"GameObject '{name}' exists as expected" + : $"GameObject '{name}' does not exist as expected"; + Debug.Log($"[CodelyValidation] {okMsg}"); + return new Dictionary + { + ["success"] = true, + ["message"] = okMsg, + ["data"] = new Dictionary + { + ["name"] = name, + ["exists"] = exists + } + }; + } + + /// + /// Cleans up leftover test GameObjects in the active scene by name pattern. + /// + /// tool_name: "codely.cleanup_test_objects" + /// + /// Parameters: + /// { + /// "namePrefixes"?: ["Test"], // 按前缀匹配名称,可选 + /// "exactNames"?: ["ParentObject", ...], // 精确名称列表,可选 + /// "contains"?: ["Prefab"], // 按子串匹配名称,可选 + /// "includeInactive"?: true, // 是否包含 inactive 对象,默认 true + /// "logOnly"?: false, // 仅输出将被删除的对象,不实际删除 + /// "maxDeleted"?: 500 // 安全上限,超过则报错避免误删 + /// } + /// + /// 如果未提供任何匹配条件,默认使用 namePrefixes = ["Test"] 和 contains = ["Prefab"], + /// 以覆盖本测试规范中常见的测试对象命名(Test* / *Prefab*)。 + /// + [ExecuteCustomTool.CustomTool("codely.cleanup_test_objects", "Cleanup leftover test GameObjects by name pattern")] + public static object CleanupTestObjects(JObject parameters) + { + // 解析参数 + var namePrefixes = ToStringList(parameters?["namePrefixes"] as JArray); + var exactNames = ToStringList(parameters?["exactNames"] as JArray); + var contains = ToStringList(parameters?["contains"] as JArray); + var includeInactive = parameters?["includeInactive"]?.ToObject() ?? true; + var logOnly = parameters?["logOnly"]?.ToObject() ?? false; + var maxDeleted = parameters?["maxDeleted"]?.ToObject() ?? 500; + + // 如果没有任何过滤条件,使用一套保守的默认规则 + if (namePrefixes.Count == 0 && exactNames.Count == 0 && contains.Count == 0) + { + namePrefixes.Add("Test"); + contains.Add("Prefab"); + } + + var activeScene = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene(); + if (!activeScene.IsValid() || !activeScene.isLoaded) + { + return new Dictionary + { + ["success"] = false, + ["message"] = "No valid and loaded active scene for cleanup", + ["data"] = new Dictionary() + }; + } + + var toDelete = new HashSet(); + var visited = new HashSet(); + + foreach (var root in activeScene.GetRootGameObjects()) + { + CollectMatchingGameObjectsRecursive( + root, + namePrefixes, + exactNames, + contains, + includeInactive, + toDelete, + visited + ); + } + + var totalCandidates = toDelete.Count; + if (totalCandidates == 0) + { + var msgNone = "No matching test GameObjects found to cleanup."; + Debug.Log($"[CodelyCleanup] {msgNone}"); + return new Dictionary + { + ["success"] = true, + ["message"] = msgNone, + ["data"] = new Dictionary + { + ["deletedCount"] = 0, + ["candidates"] = new List() + } + }; + } + + if (totalCandidates > maxDeleted) + { + var msgTooMany = + $"Cleanup would delete {totalCandidates} GameObjects which exceeds safety limit maxDeleted={maxDeleted}. Aborting."; + Debug.LogError($"[CodelyCleanup] {msgTooMany}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msgTooMany, + ["data"] = new Dictionary + { + ["candidateCount"] = totalCandidates, + ["maxDeleted"] = maxDeleted + } + }; + } + + var deletedNames = new List(); + + if (logOnly) + { + foreach (var go in toDelete) + { + if (go != null) + { + deletedNames.Add(go.name); + } + } + + var msgLogOnly = + $"[Dry Run] Found {deletedNames.Count} GameObjects matching cleanup filters. No objects were deleted."; + Debug.Log($"[CodelyCleanup] {msgLogOnly}"); + return new Dictionary + { + ["success"] = true, + ["message"] = msgLogOnly, + ["data"] = new Dictionary + { + ["deletedCount"] = 0, + ["candidates"] = deletedNames + } + }; + } + + foreach (var go in toDelete) + { + if (go == null) continue; + deletedNames.Add(go.name); + Undo.DestroyObjectImmediate(go); + } + + var summary = $"Deleted {deletedNames.Count} GameObjects by cleanup_test_objects."; + Debug.Log($"[CodelyCleanup] {summary}"); + + return new Dictionary + { + ["success"] = true, + ["message"] = summary, + ["data"] = new Dictionary + { + ["deletedCount"] = deletedNames.Count, + ["deletedNames"] = deletedNames + } + }; + } + + private static List ToStringList(JArray array) + { + var result = new List(); + if (array == null) return result; + + foreach (var item in array) + { + if (item.Type == JTokenType.String) + { + var value = item.ToString(); + if (!string.IsNullOrEmpty(value)) + { + result.Add(value); + } + } + } + + return result; + } + + private static void CollectMatchingGameObjectsRecursive( + GameObject go, + List namePrefixes, + List exactNames, + List contains, + bool includeInactive, + HashSet matches, + HashSet visited + ) + { + if (go == null) return; + if (visited.Contains(go)) return; + visited.Add(go); + + if (includeInactive || go.activeInHierarchy) + { + if (MatchesNameFilters(go.name, namePrefixes, exactNames, contains)) + { + matches.Add(go); + } + } + + var transform = go.transform; + var childCount = transform.childCount; + for (var i = 0; i < childCount; i++) + { + var child = transform.GetChild(i); + if (child != null) + { + CollectMatchingGameObjectsRecursive( + child.gameObject, + namePrefixes, + exactNames, + contains, + includeInactive, + matches, + visited + ); + } + } + } + + private static bool MatchesNameFilters( + string name, + List namePrefixes, + List exactNames, + List contains + ) + { + if (string.IsNullOrEmpty(name)) return false; + + foreach (var exact in exactNames) + { + if (!string.IsNullOrEmpty(exact) && string.Equals(name, exact, StringComparison.Ordinal)) + { + return true; + } + } + + foreach (var prefix in namePrefixes) + { + if (!string.IsNullOrEmpty(prefix) && name.StartsWith(prefix, StringComparison.Ordinal)) + { + return true; + } + } + + foreach (var part in contains) + { + if (!string.IsNullOrEmpty(part) && name.IndexOf(part, StringComparison.Ordinal) >= 0) + { + return true; + } + } + + return false; + } + + // ===================================================================== + // Window Validation Tools + // ===================================================================== + + /// + /// Validates that an Editor window is open. + /// + /// tool_name: "codely.validate_window_open" + /// + /// Parameters: + /// { + /// "windowType": "Console" | "Inspector" | "Hierarchy" | "Project" | "Scene" | "Game", + /// "shouldBeOpen": true/false // default: true + /// } + /// + [ExecuteCustomTool.CustomTool("codely.validate_window_open", "Validate Editor window is open")] + public static object ValidateWindowOpen(JObject parameters) + { + var windowType = parameters?["windowType"]?.ToString() ?? parameters?["windowTitle"]?.ToString(); + var shouldBeOpen = parameters?["shouldBeOpen"]?.ToObject() ?? true; + + if (string.IsNullOrEmpty(windowType)) + { + return new Dictionary + { + ["success"] = false, + ["message"] = "Parameter 'windowType' is required", + ["data"] = new Dictionary() + }; + } + + // Map common names to actual EditorWindow types + var typeMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "Console", "UnityEditor.ConsoleWindow" }, + { "Inspector", "UnityEditor.InspectorWindow" }, + { "Hierarchy", "UnityEditor.SceneHierarchyWindow" }, + { "Project", "UnityEditor.ProjectBrowser" }, + { "Scene", "UnityEditor.SceneView" }, + { "Game", "UnityEditor.GameView" }, + { "Animator", "UnityEditor.Graphs.AnimatorControllerTool" }, + { "Animation", "UnityEditor.AnimationWindow" }, + { "Profiler", "UnityEditor.ProfilerWindow" } + }; + + bool isOpen = false; + string actualTypeName = windowType; + + if (typeMap.TryGetValue(windowType, out var fullTypeName)) + { + actualTypeName = fullTypeName; + } + + // Check if any window of this type is open + var allWindows = Resources.FindObjectsOfTypeAll(); + foreach (var window in allWindows) + { + var winTypeName = window.GetType().FullName; + if (winTypeName.Equals(actualTypeName, StringComparison.OrdinalIgnoreCase) || + winTypeName.EndsWith("." + windowType, StringComparison.OrdinalIgnoreCase) || + window.titleContent.text.Equals(windowType, StringComparison.OrdinalIgnoreCase)) + { + isOpen = true; + break; + } + } + + if (isOpen != shouldBeOpen) + { + var msg = shouldBeOpen + ? $"Window '{windowType}' is not open but expected to be open" + : $"Window '{windowType}' is open but expected to be closed"; + Debug.LogError($"[CodelyValidation] {msg}"); + return new Dictionary + { + ["success"] = false, + ["message"] = msg, + ["data"] = new Dictionary + { + ["windowType"] = windowType, + ["shouldBeOpen"] = shouldBeOpen, + ["actuallyOpen"] = isOpen + } + }; + } + + var okMsg = shouldBeOpen + ? $"Window '{windowType}' is open as expected" + : $"Window '{windowType}' is closed as expected"; + Debug.Log($"[CodelyValidation] {okMsg}"); + return new Dictionary + { + ["success"] = true, + ["message"] = okMsg, + ["data"] = new Dictionary + { + ["windowType"] = windowType, + ["isOpen"] = isOpen + } + }; + } + } +} + + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/CodelyUnityValidationTools.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/CodelyUnityValidationTools.cs.meta new file mode 100644 index 000000000..9d20e4ef4 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/CodelyUnityValidationTools.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8301b99c61e7b7e44befcf784c72a226 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/CommandRegistry.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/CommandRegistry.cs new file mode 100644 index 000000000..ff26cea8f --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/CommandRegistry.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using Codely.Newtonsoft.Json.Linq; + +namespace UnityTcp.Editor.Tools +{ + /// + /// Registry for all Unity Tool command handlers (Upgraded Version) + /// + public static class CommandRegistry + { + // Maps command names to the corresponding static HandleCommand method in tool classes + private static readonly Dictionary> _handlers = new() + { + // Core tools + { "HandleManageScript", ManageScript.HandleCommand }, + { "HandleManageScene", ManageScene.HandleCommand }, + { "HandleManageEditor", ManageEditor.HandleCommand }, + { "HandleManageGameObject", ManageGameObject.HandleCommand }, + { "HandleManageAsset", ManageAsset.HandleCommand }, + { "HandleReadConsole", ReadConsole.HandleCommand }, + { "HandleExecuteMenuItem", ExecuteMenuItem.HandleCommand }, + { "HandleManageShader", ManageShader.HandleCommand}, + { "HandleManageScreenshot", ManageScreenshot.HandleCommand }, + // [EXPERIMENTAL] Phase 3 tools + { "HandleManagePackage", ManagePackage.HandleCommand }, + { "HandleManageBake", ManageBake.HandleCommand }, + { "HandleManageUIToolkit", ManageUIToolkit.HandleCommand }, + // Custom tool execution (API Spec aligned) + { "HandleExecuteCustomTool", ExecuteCustomTool.HandleCommand }, + { "HandleExecuteCSharpScript", ExecuteCSharpScript.HandleCommand }, + // [INTERNAL] Not exposed to LLM - for agent execution layer only + { "Handle_InternalStateDirty", _InternalStateDirtyNotifier.HandleCommand }, + }; + + /// + /// Gets a command handler by name. + /// + /// Name of the command handler (e.g., "HandleManageAsset"). + /// The command handler function if found, null otherwise. + public static Func GetHandler(string commandName) + { + // Use case-insensitive comparison for flexibility, although Python side should be consistent + return _handlers.TryGetValue(commandName, out var handler) ? handler : null; + // Consider adding logging here if a handler is not found + /* + if (_handlers.TryGetValue(commandName, out var handler)) { + return handler; + } else { + UnityEngine.Debug.LogError($\"[CommandRegistry] No handler found for command: {commandName}\"); + return null; + } + */ + } + } +} + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/CommandRegistry.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/CommandRegistry.cs.meta new file mode 100644 index 000000000..7038e7d03 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/CommandRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 333f972789d338c4aafe2236cb5ac3cf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteCSharpScript.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteCSharpScript.cs new file mode 100644 index 000000000..403ba2996 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteCSharpScript.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Codely.Newtonsoft.Json.Linq; +using UnityEngine; +using UnityTcp.Editor.Helpers; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; + + +namespace UnityTcp.Editor.Tools +{ + /// + /// Executes C# scripts using Microsoft.CodeAnalysis.CSharp.Scripting (Roslyn). + /// Captures and returns logs generated during script execution. + /// Ensures execution happens on the main thread. + /// + public static class ExecuteCSharpScript + { + private static List _capturedLogs = new List(); + private static bool _isCapturingLogs = false; + + /// + /// Main handler for executing C# scripts. + /// + public static object HandleCommand(JObject @params) + { + string script = @params["script"]?.ToString(); + if (string.IsNullOrEmpty(script)) + { + return Response.Error("'script' parameter is required."); + } + + bool captureLogs = @params["capture_logs"]?.ToObject() ?? true; + string[] imports = @params["imports"]?.ToObject() ?? new string[] + { + "System", + "System.Linq", + "System.Collections.Generic", + "UnityEngine", + "UnityEditor", + "UnityEditor.SceneManagement", + "UnityEngine.SceneManagement" + }; + + try + { + Debug.Log($"[ExecuteCSharpScript] Executing C# script (length: {script.Length} chars)"); + + StartLogCapture(captureLogs); + + object result; + try + { + result = ExecuteScriptInternal(script, imports); + } + finally + { + // Always stop log capture, even on error + } + + var logs = captureLogs ? StopLogCapture() : new List(); + + return Response.Success( + "C# script executed successfully.", + new + { + result = result?.ToString(), + logs = logs, + log_count = logs.Count + } + ); + } + catch (Exception e) + { + var logs = captureLogs ? StopLogCapture() : new List(); + Debug.LogError($"[ExecuteCSharpScript] Failed to execute script: {e}"); + return Response.Error( + $"C# script execution failed: {e.Message}", + new + { + logs = logs, + exception = e.ToString() + } + ); + } + + } + + /// + /// Internal method to execute the script using Roslyn. + /// + private static object ExecuteScriptInternal(string script, string[] imports) + { + try + { + // Collect assembly references + var references = new List + { + typeof(UnityEngine.Debug).Assembly, + typeof(UnityEditor.EditorApplication).Assembly + }; + + // Add Assembly-CSharp if it exists (runtime user code) + var assemblyCSharp = System.AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == "Assembly-CSharp"); + if (assemblyCSharp != null) + { + references.Add(assemblyCSharp); + } + + // Add Assembly-CSharp-Editor if it exists (editor user code) + var assemblyCSharpEditor = System.AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == "Assembly-CSharp-Editor"); + if (assemblyCSharpEditor != null) + { + references.Add(assemblyCSharpEditor); + } + + // Add Unity.InputSystem if it exists (Input System package) + var inputSystemAssembly = System.AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == "Unity.InputSystem"); + if (inputSystemAssembly != null) + { + references.Add(inputSystemAssembly); + } + + // Create script options with imports + var options = ScriptOptions.Default + .WithReferences(references) + .WithImports(imports); + + // Execute the script synchronously + var scriptTask = CSharpScript.EvaluateAsync(script, options); + + // Wait for the task to complete + scriptTask.Wait(); + + return scriptTask.Result; + } + catch (AggregateException ae) + { + // Unwrap AggregateException to get the actual exception + if (ae.InnerException != null) + { + throw ae.InnerException; + } + throw; + } + } + + /// + /// Starts capturing Unity logs. + /// + private static void StartLogCapture(bool enabled) + { + if (!enabled) + { + _isCapturingLogs = false; + return; + } + + _capturedLogs.Clear(); + _isCapturingLogs = true; + Application.logMessageReceived += OnLogMessageReceived; + } + + /// + /// Stops capturing logs and returns the captured log list. + /// + private static List StopLogCapture() + { + Application.logMessageReceived -= OnLogMessageReceived; + _isCapturingLogs = false; + + var logs = new List(_capturedLogs); + _capturedLogs.Clear(); + + return logs; + } + + /// + /// Log message callback handler. + /// + private static void OnLogMessageReceived(string logString, string stackTrace, LogType type) + { + if (!_isCapturingLogs) + return; + + var logEntry = new StringBuilder(); + logEntry.Append($"[{type}] {logString}"); + + // Include stack trace for errors and exceptions + if ((type == LogType.Error || type == LogType.Exception) && !string.IsNullOrEmpty(stackTrace)) + { + logEntry.Append($"\n{stackTrace}"); + } + + _capturedLogs.Add(logEntry.ToString()); + } + } +} diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteCSharpScript.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteCSharpScript.cs.meta new file mode 100644 index 000000000..b9b8f1eb3 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteCSharpScript.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a919bae4d47922248a206faa7ba67ed7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteCustomTool.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteCustomTool.cs new file mode 100644 index 000000000..921f1b3aa --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteCustomTool.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Codely.Newtonsoft.Json.Linq; +using UnityEngine; +using UnityTcp.Editor.Helpers; + +namespace UnityTcp.Editor.Tools +{ + /// + /// Executes custom tools registered in the Unity project. + /// Custom tools must be static methods with a specific signature: + /// public static object ToolName(JObject parameters) + /// + /// Tools can be registered via the [CustomTool] attribute or + /// by convention in the CustomToolsRegistry static class. + /// + public static class ExecuteCustomTool + { + // Registry of custom tools: tool_name -> method info + private static readonly Dictionary _registeredTools = new Dictionary(); + private static bool _initialized = false; + private static readonly object _initLock = new object(); + + /// + /// Attribute to mark a method as a custom tool. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class CustomToolAttribute : Attribute + { + public string Name { get; } + public string Description { get; } + + public CustomToolAttribute(string name, string description = null) + { + Name = name; + Description = description; + } + } + + /// + /// Main handler for executing custom tools. + /// + public static object HandleCommand(JObject @params) + { + // Ensure tools are discovered + EnsureInitialized(); + + string toolName = @params["tool_name"]?.ToString(); + if (string.IsNullOrEmpty(toolName)) + { + return Response.Error("'tool_name' parameter is required."); + } + + JObject toolParams = @params["parameters"] as JObject ?? new JObject(); + + try + { + // Check if tool exists + if (!_registeredTools.TryGetValue(toolName, out MethodInfo method)) + { + // Try case-insensitive lookup + method = FindToolCaseInsensitive(toolName); + if (method == null) + { + return Response.Error($"Custom tool '{toolName}' not found. Available tools: {string.Join(", ", _registeredTools.Keys)}"); + } + } + + // Execute the tool + Debug.Log($"[ExecuteCustomTool] Executing custom tool: {toolName}"); + var result = method.Invoke(null, new object[] { toolParams }); + + // Wrap result in standard response format if not already + if (result is Dictionary dictResult && dictResult.ContainsKey("success")) + { + // Already in standard format + return result; + } + + // Wrap in success response + return new + { + success = true, + message = $"Custom tool '{toolName}' executed successfully.", + data = result, + state = StateComposer.BuildFullState() + }; + } + catch (TargetInvocationException tie) + { + // Unwrap the inner exception + var innerEx = tie.InnerException ?? tie; + Debug.LogError($"[ExecuteCustomTool] Tool '{toolName}' failed: {innerEx}"); + return Response.Error($"Custom tool '{toolName}' execution failed: {innerEx.Message}"); + } + catch (Exception e) + { + Debug.LogError($"[ExecuteCustomTool] Error executing tool '{toolName}': {e}"); + return Response.Error($"Error executing custom tool '{toolName}': {e.Message}"); + } + } + + /// + /// Registers a custom tool manually. + /// + public static void RegisterTool(string name, MethodInfo method) + { + lock (_initLock) + { + if (_registeredTools.ContainsKey(name)) + { + Debug.LogWarning($"[ExecuteCustomTool] Tool '{name}' is already registered. Overwriting."); + } + _registeredTools[name] = method; + Debug.Log($"[ExecuteCustomTool] Registered custom tool: {name}"); + } + } + + /// + /// Lists all registered custom tools. + /// + public static IEnumerable GetRegisteredTools() + { + EnsureInitialized(); + return _registeredTools.Keys; + } + + /// + /// Discovers and registers all custom tools in the project. + /// + private static void EnsureInitialized() + { + lock (_initLock) + { + if (_initialized) + return; + + try + { + // Scan all assemblies for methods with [CustomTool] attribute + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + // Skip system assemblies for performance, but keep UnityTcp assemblies + var assemblyName = assembly.GetName().Name; + if (assemblyName.StartsWith("UnityTcp")) + { + // Always scan our own assemblies + } + else if (assemblyName.StartsWith("System") || + assemblyName.StartsWith("mscorlib") || + assemblyName.StartsWith("Unity") || + assemblyName.StartsWith("Newtonsoft") || + assemblyName.StartsWith("netstandard") || + assemblyName.StartsWith("Microsoft")) + continue; + + try + { + foreach (var type in assembly.GetTypes()) + { + foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + var attr = method.GetCustomAttribute(); + if (attr != null) + { + // Validate signature + var parameters = method.GetParameters(); + if (parameters.Length == 1 && parameters[0].ParameterType == typeof(JObject)) + { + _registeredTools[attr.Name] = method; + Debug.Log($"[ExecuteCustomTool] Discovered custom tool: {attr.Name} ({type.FullName}.{method.Name})"); + } + else + { + Debug.LogWarning($"[ExecuteCustomTool] Invalid signature for tool '{attr.Name}'. Expected: public static object ToolName(JObject parameters)"); + } + } + } + } + } + catch (ReflectionTypeLoadException) + { + // Ignore assembly load errors + } + } + + Debug.Log($"[ExecuteCustomTool] Initialization complete. {_registeredTools.Count} custom tools registered."); + } + catch (Exception e) + { + Debug.LogError($"[ExecuteCustomTool] Failed to initialize: {e}"); + } + finally + { + _initialized = true; + } + } + } + + /// + /// Finds a tool by name (case-insensitive). + /// + private static MethodInfo FindToolCaseInsensitive(string toolName) + { + foreach (var kvp in _registeredTools) + { + if (string.Equals(kvp.Key, toolName, StringComparison.OrdinalIgnoreCase)) + { + return kvp.Value; + } + } + return null; + } + } +} + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteCustomTool.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteCustomTool.cs.meta new file mode 100644 index 000000000..433710862 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteCustomTool.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5ef1416a06c8bce4caaff3a2b88aaafe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteMenuItem.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteMenuItem.cs new file mode 100644 index 000000000..51665b085 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteMenuItem.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; // Added for HashSet +using Codely.Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityTcp.Editor.Helpers; // For Response class + +namespace UnityTcp.Editor.Tools +{ + /// + /// Handles executing Unity Editor menu items by path. + /// + public static class ExecuteMenuItem + { + // Basic blacklist to prevent accidental execution of potentially disruptive menu items. + // This can be expanded based on needs. + private static readonly HashSet _menuPathBlacklist = new HashSet( + StringComparer.OrdinalIgnoreCase + ) + { + "File/Quit", + // Add other potentially dangerous items like "Edit/Preferences...", "File/Build Settings..." if needed + }; + + /// + /// Main handler for executing menu items or getting available ones. + /// + public static object HandleCommand(JObject @params) + { + string action = (@params["action"]?.ToString())?.ToLowerInvariant() ?? "execute"; // Default action + + try + { + switch (action) + { + case "execute": + return ExecuteItem(@params); + case "get_available_menus": + // Getting a comprehensive list of *all* menu items dynamically is very difficult + // and often requires complex reflection or maintaining a manual list. + // Returning a placeholder/acknowledgement for now. + Debug.LogWarning( + "[ExecuteMenuItem] 'get_available_menus' action is not fully implemented. Dynamically listing all menu items is complex." + ); + // Returning an empty list as per the refactor plan's requirements. + return Response.Success( + "'get_available_menus' action is not fully implemented. Returning empty list.", + new List() + ); + // TODO: Consider implementing a basic list of common/known menu items or exploring reflection techniques if this feature becomes critical. + default: + return Response.Error( + $"Unknown action: '{action}'. Valid actions are 'execute', 'get_available_menus'." + ); + } + } + catch (Exception e) + { + Debug.LogError($"[ExecuteMenuItem] Action '{action}' failed: {e}"); + return Response.Error($"Internal error processing action '{action}': {e.Message}"); + } + } + + /// + /// Executes a specific menu item. + /// + private static object ExecuteItem(JObject @params) + { + // Try both naming conventions: snake_case and camelCase + string menuPath = @params["menu_path"]?.ToString() ?? @params["menuPath"]?.ToString(); + // Optional future param retained for API compatibility; not used in synchronous mode + // int timeoutMs = Math.Max(0, (@params["timeout_ms"]?.ToObject() ?? 2000)); + + // string alias = @params["alias"]?.ToString(); // TODO: Implement alias mapping based on refactor plan requirements. + // JObject parameters = @params["parameters"] as JObject; // TODO: Investigate parameter passing (often not directly supported by ExecuteMenuItem). + + if (string.IsNullOrWhiteSpace(menuPath)) + { + return Response.Error("Required parameter 'menu_path' or 'menuPath' is missing or empty."); + } + + // Validate against blacklist + if (_menuPathBlacklist.Contains(menuPath)) + { + return Response.Error( + $"Execution of menu item '{menuPath}' is blocked for safety reasons." + ); + } + + // TODO: Implement alias lookup here if needed (Map alias to actual menuPath). + // if (!string.IsNullOrEmpty(alias)) { menuPath = LookupAlias(alias); if(menuPath == null) return Response.Error(...); } + + // TODO: Handle parameters ('parameters' object) if a viable method is found. + // This is complex as EditorApplication.ExecuteMenuItem doesn't take arguments directly. + // It might require finding the underlying EditorWindow or command if parameters are needed. + + try + { + // Trace incoming execute requests (debug-gated) + TcpLog.Info($"[ExecuteMenuItem] Request to execute menu: '{menuPath}'", always: false); + + // Execute synchronously. This code runs on the Editor main thread in our bridge path. + bool executed = EditorApplication.ExecuteMenuItem(menuPath); + if (executed) + { + // Success trace (debug-gated) + TcpLog.Info($"[ExecuteMenuItem] Executed successfully: '{menuPath}'", always: false); + return Response.Success( + $"Executed menu item: '{menuPath}'", + new { executed = true, menuPath } + ); + } + Debug.LogWarning($"[ExecuteMenuItem] Failed (not found/disabled): '{menuPath}'"); + return Response.Error( + $"Failed to execute menu item (not found or disabled): '{menuPath}'", + new { executed = false, menuPath } + ); + } + catch (Exception e) + { + Debug.LogError($"[ExecuteMenuItem] Error executing '{menuPath}': {e}"); + return Response.Error($"Error executing menu item '{menuPath}': {e.Message}"); + } + } + + // TODO: Add helper for alias lookup if implementing aliases. + // private static string LookupAlias(string alias) { ... return actualMenuPath or null ... } + } +} + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteMenuItem.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteMenuItem.cs.meta new file mode 100644 index 000000000..d9520d98f --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ExecuteMenuItem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 896e8045986eb0d449ee68395479f1d6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageAsset.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageAsset.cs new file mode 100644 index 000000000..5d837fb2b --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageAsset.cs @@ -0,0 +1,2537 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using Codely.Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityTcp.Editor.Helpers; // For Response class +using static UnityTcp.Editor.Tools.ManageGameObject; + +#if UNITY_6000_0_OR_NEWER +using PhysicsMaterialType = UnityEngine.PhysicsMaterial; +using PhysicsMaterialCombine = UnityEngine.PhysicsMaterialCombine; +#else +using PhysicsMaterialType = UnityEngine.PhysicMaterial; +using PhysicsMaterialCombine = UnityEngine.PhysicMaterialCombine; +#endif + +namespace UnityTcp.Editor.Tools +{ + /// + /// Handles asset management operations within the Unity project. + /// + public static class ManageAsset + { + private const int MaxBatchOps = 10; + + // --- Main Handler --- + + // Define the list of valid actions + private static readonly List ValidActions = new List + { + "create_batch", + "edit_batch", + "ensure_has_meta", + "ensure_meta_integrity", + "import", + "create", + "modify", + "delete", + "duplicate", + "move", + "rename", + "search", + "get_info", + "create_folder", + "get_components", + }; + + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString().ToLower(); + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + + // Normalize public aliases to canonical server actions (keep parity with TS client) + if (action == "import_asset") + { + action = "import"; + @params["action"] = "import"; + } + + // Back-compat alias: assetType (camelCase) -> asset_type (snake_case) + if (@params["asset_type"] == null && @params["assetType"] != null) + { + @params["asset_type"] = @params["assetType"]; + } + + // Back-compat aliases: snake_case -> camelCase (keep parity with TS client schema) + if (@params["searchPattern"] == null && @params["search_pattern"] != null) + @params["searchPattern"] = @params["search_pattern"]; + if (@params["filterType"] == null && @params["filter_type"] != null) + @params["filterType"] = @params["filter_type"]; + if (@params["filterDateAfter"] == null && @params["filter_date_after"] != null) + @params["filterDateAfter"] = @params["filter_date_after"]; + if (@params["pageSize"] == null && @params["page_size"] != null) + @params["pageSize"] = @params["page_size"]; + if (@params["pageNumber"] == null && @params["page_number"] != null) + @params["pageNumber"] = @params["page_number"]; + if (@params["generatePreview"] == null && @params["generate_preview"] != null) + @params["generatePreview"] = @params["generate_preview"]; + + // Check if the action is valid before switching + if (!ValidActions.Contains(action)) + { + string validActionsList = string.Join(", ", ValidActions); + return Response.Error( + $"Unknown action: '{action}'. Valid actions are: {validActionsList}" + ); + } + + // --- Validate client_state_rev for write operations --- + var writeActions = new[] { "create_batch", "edit_batch", "ensure_has_meta", "ensure_meta_integrity", "import", "create", "modify", "delete", "duplicate", "move", "rename", "create_folder" }; + if (writeActions.Contains(action)) + { + var revConflict = StateComposer.ValidateClientRevisionFromParams(@params); + if (revConflict != null) return revConflict; + } + + // Common parameters + string path = @params["path"]?.ToString(); + + try + { + switch (action) + { + // Batch operations (strict) + case "create_batch": + return HandleCreateBatch(@params); + case "edit_batch": + return HandleEditBatch(@params); + + // Ensure operations (idempotent) + case "ensure_has_meta": + return EnsureHasMeta(path); + case "ensure_meta_integrity": + return EnsureMetaIntegrity(path); + + // Regular operations + case "import": + // Note: Unity typically auto-imports. This might re-import or configure import settings. + return ReimportAsset(path, @params["properties"] as JObject); + case "create": + return CreateAsset(@params); + case "modify": + return ModifyAsset(path, @params["properties"] as JObject); + case "delete": + return DeleteAsset(path); + case "duplicate": + return DuplicateAsset(path, @params["destination"]?.ToString()); + case "move": // Often same as rename if within Assets/ + case "rename": + return MoveOrRenameAsset(path, @params["destination"]?.ToString()); + case "search": + return SearchAssets(@params); + case "get_info": + return GetAssetInfo( + path, + @params["generatePreview"]?.ToObject() ?? false + ); + case "create_folder": // Added specific action for clarity + return CreateFolder(path); + case "get_components": + return GetComponentsFromAsset(path); + + default: + // This error message is less likely to be hit now, but kept here as a fallback or for potential future modifications. + string validActionsListDefault = string.Join(", ", ValidActions); + return Response.Error( + $"Unknown action: '{action}'. Valid actions are: {validActionsListDefault}" + ); + } + } + catch (Exception e) + { + Debug.LogError($"[ManageAsset] Action '{action}' failed for path '{path}': {e}"); + return Response.Error( + $"Internal error processing action '{action}' on '{path}': {e.Message}" + ); + } + } + + // --- Action Implementations --- + + private static object ReimportAsset(string path, JObject properties) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for reimport."); + string fullPath = SanitizeAssetPath(path); + bool ghostDesync; + if (!AssetExists(fullPath, out ghostDesync)) + return BuildAssetNotFoundResponse($"Asset not found at path: {fullPath}", fullPath, ghostDesync); + + try + { + // TODO: Apply importer properties before reimporting? + // This is complex as it requires getting the AssetImporter, casting it, + // applying properties via reflection or specific methods, saving, then reimporting. + if (properties != null && properties.HasValues) + { + Debug.LogWarning( + "[ManageAsset.Reimport] Modifying importer properties before reimport is not fully implemented yet." + ); + // AssetImporter importer = AssetImporter.GetAtPath(fullPath); + // if (importer != null) { /* Apply properties */ AssetDatabase.WriteImportSettingsIfDirty(fullPath); } + } + + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); + // AssetDatabase.Refresh(); // Usually ImportAsset handles refresh + return Response.Success($"Asset '{fullPath}' reimported.", GetAssetData(fullPath)); + } + catch (Exception e) + { + return Response.Error($"Failed to reimport asset '{fullPath}': {e.Message}"); + } + } + + private static object CreateAsset(JObject @params) + { + string path = @params["path"]?.ToString(); + // Support both 'assetType' (camelCase) and 'asset_type' (snake_case) for compatibility + string assetType = @params["assetType"]?.ToString() ?? @params["asset_type"]?.ToString(); + JObject properties = @params["properties"] as JObject; + + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for create."); + if (string.IsNullOrEmpty(assetType)) + return Response.Error("'assetType' is required for create."); + + string fullPath = SanitizeAssetPath(path); + string directory = Path.GetDirectoryName(fullPath); + + // Ensure directory exists + if (!Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), directory))) + { + Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), directory)); + AssetDatabase.Refresh(); // Make sure Unity knows about the new folder + } + + if (AssetExists(fullPath)) + return Response.Error($"Asset already exists at path: {fullPath}"); + + try + { + UnityEngine.Object newAsset = null; + string lowerAssetType = assetType.ToLowerInvariant(); + + // Handle common asset types + if (lowerAssetType == "folder") + { + return CreateFolder(path); // Use dedicated method + } + else if (lowerAssetType == "material") + { + // Prefer provided shader; fall back to common pipelines + var requested = properties?["shader"]?.ToString(); + Shader shader = + (!string.IsNullOrEmpty(requested) ? Shader.Find(requested) : null) + ?? Shader.Find("Universal Render Pipeline/Lit") + ?? Shader.Find("HDRP/Lit") + ?? Shader.Find("Standard") + ?? Shader.Find("Unlit/Color"); + if (shader == null) + return Response.Error($"Could not find a suitable shader (requested: '{requested ?? "none"}')."); + + var mat = new Material(shader); + if (properties != null) + ApplyMaterialProperties(mat, properties); + AssetDatabase.CreateAsset(mat, fullPath); + newAsset = mat; + } + else if (lowerAssetType == "physicsmaterial") + { + PhysicsMaterialType pmat = new PhysicsMaterialType(); + if (properties != null) + ApplyPhysicsMaterialProperties(pmat, properties); + AssetDatabase.CreateAsset(pmat, fullPath); + newAsset = pmat; + } + else if (lowerAssetType == "scriptableobject") + { + string scriptClassName = properties?["scriptClass"]?.ToString(); + if (string.IsNullOrEmpty(scriptClassName)) + return Response.Error( + "'scriptClass' property required when creating ScriptableObject asset." + ); + + // NOTE: + // Previously this used ComponentResolver.TryResolve, which is intentionally limited to + // Component/MonoBehaviour types. That meant any ScriptableObject type (including custom ones) + // would always fail to resolve, even after a successful compilation / domain reload. + // + // Here we use a dedicated resolver that searches for ScriptableObject-derived types instead. + string resolveError; + Type scriptType = ResolveScriptableObjectType(scriptClassName, out resolveError); + if (scriptType == null) + { + var reason = string.IsNullOrEmpty(resolveError) + ? "Type not found." + : resolveError; + return Response.Error( + $"Script class '{scriptClassName}' invalid: {reason}" + ); + } + + ScriptableObject so = ScriptableObject.CreateInstance(scriptType); + // TODO: Apply properties from JObject to the ScriptableObject instance? + AssetDatabase.CreateAsset(so, fullPath); + newAsset = so; + } + else if (lowerAssetType == "prefab") + { + // Creating prefabs usually involves saving an existing GameObject hierarchy. + // A common pattern is to create an empty GameObject, configure it, and then save it. + return Response.Error( + "Creating prefabs programmatically usually requires a source GameObject. Use manage_gameobject to create/configure, then save as prefab via a separate mechanism or future enhancement." + ); + // Example (conceptual): + // GameObject source = GameObject.Find(properties["sourceGameObject"].ToString()); + // if(source != null) PrefabUtility.SaveAsPrefabAsset(source, fullPath); + } + // TODO: Add more asset types (Animation Controller, Scene, etc.) + else + { + // Generic creation attempt (might fail or create empty files) + // For some types, just creating the file might be enough if Unity imports it. + // File.Create(Path.Combine(Directory.GetCurrentDirectory(), fullPath)).Close(); + // AssetDatabase.ImportAsset(fullPath); // Let Unity try to import it + // newAsset = AssetDatabase.LoadAssetAtPath(fullPath); + return Response.Error( + $"Creation for asset type '{assetType}' is not explicitly supported yet. Supported: Folder, Material, ScriptableObject." + ); + } + + if ( + newAsset == null + && !Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), fullPath)) + ) // Check if it wasn't a folder and asset wasn't created + { + return Response.Error( + $"Failed to create asset '{assetType}' at '{fullPath}'. See logs for details." + ); + } + + AssetDatabase.SaveAssets(); + // AssetDatabase.Refresh(); // CreateAsset often handles refresh + return Response.Success( + $"Asset '{fullPath}' created successfully.", + GetAssetData(fullPath) + ); + } + catch (Exception e) + { + return Response.Error($"Failed to create asset at '{fullPath}': {e.Message}"); + } + } + + private static object CreateFolder(string path) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for create_folder."); + string fullPath = SanitizeAssetPath(path); + string parentDir = Path.GetDirectoryName(fullPath); + string folderName = Path.GetFileName(fullPath); + + if (AssetExists(fullPath)) + { + // Check if it's actually a folder already + if (AssetDatabase.IsValidFolder(fullPath)) + { + return Response.Success( + $"Folder already exists at path: {fullPath}", + GetAssetData(fullPath) + ); + } + else + { + return Response.Error( + $"An asset (not a folder) already exists at path: {fullPath}" + ); + } + } + + try + { + // Ensure parent exists + if (!string.IsNullOrEmpty(parentDir) && !AssetDatabase.IsValidFolder(parentDir)) + { + // Recursively create parent folders if needed (AssetDatabase handles this internally) + // Or we can do it manually: Directory.CreateDirectory(Path.Combine(Directory.GetCurrentDirectory(), parentDir)); AssetDatabase.Refresh(); + } + + string guid = AssetDatabase.CreateFolder(parentDir, folderName); + if (string.IsNullOrEmpty(guid)) + { + return Response.Error( + $"Failed to create folder '{fullPath}'. Check logs and permissions." + ); + } + + // AssetDatabase.Refresh(); // CreateFolder usually handles refresh + return Response.Success( + $"Folder '{fullPath}' created successfully.", + GetAssetData(fullPath) + ); + } + catch (Exception e) + { + return Response.Error($"Failed to create folder '{fullPath}': {e.Message}"); + } + } + + /// + /// Resolve a ScriptableObject type by short or fully-qualified name. + /// Searches loaded assemblies and ensures the type derives from ScriptableObject. + /// Does NOT rely on ComponentResolver (which is Component/MonoBehaviour-specific). + /// + private static Type ResolveScriptableObjectType(string nameOrFullName, out string error) + { + error = string.Empty; + + if (string.IsNullOrWhiteSpace(nameOrFullName)) + { + error = "scriptClass cannot be null or empty."; + return null; + } + + // 1) Direct Type.GetType lookup (works for fully-qualified names with assembly, or some common cases) + Type type = Type.GetType(nameOrFullName, throwOnError: false); + if (IsValidScriptableObject(type)) + { + return type; + } + + // 2) Search all loaded assemblies, preferring Player (runtime) assemblies when available + var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); + +#if UNITY_EDITOR + var playerAsmNames = new HashSet( + UnityEditor.Compilation.CompilationPipeline + .GetAssemblies(UnityEditor.Compilation.AssembliesType.Player) + .Select(a => a.name), + StringComparer.Ordinal + ); + + IEnumerable playerAsms = + loadedAssemblies.Where(a => playerAsmNames.Contains(a.GetName().Name)); + IEnumerable editorAsms = + loadedAssemblies.Except(playerAsms); +#else + IEnumerable playerAsms = loadedAssemblies; + IEnumerable editorAsms = + Array.Empty(); +#endif + + static IEnumerable SafeGetTypes(System.Reflection.Assembly a) + { + try + { + return a.GetTypes(); + } + catch (System.Reflection.ReflectionTypeLoadException rtle) + { + return rtle.Types.Where(t => t != null)!; + } + } + + bool isShortName = !nameOrFullName.Contains("."); + Func match = isShortName + ? t => t.Name.Equals(nameOrFullName, StringComparison.Ordinal) + : t => t.FullName != null + && t.FullName.Equals(nameOrFullName, StringComparison.Ordinal); + + var fromPlayer = playerAsms + .SelectMany(SafeGetTypes) + .Where(IsValidScriptableObject) + .Where(match); + + var fromEditor = editorAsms + .SelectMany(SafeGetTypes) + .Where(IsValidScriptableObject) + .Where(match); + + var candidates = new List(fromPlayer); + if (candidates.Count == 0) + { + candidates.AddRange(fromEditor); + } + + if (candidates.Count == 1) + { + return candidates[0]; + } + + if (candidates.Count > 1) + { + var lines = candidates.Select( + t => $"{t.FullName} (assembly {t.Assembly.GetName().Name})" + ); + error = + $"Multiple ScriptableObject types matched '{nameOrFullName}':\n - " + + string.Join("\n - ", lines) + + "\nProvide a fully qualified type name (Namespace.TypeName) to disambiguate."; + return null; + } + + error = + $"ScriptableObject type '{nameOrFullName}' not found in loaded assemblies. " + + "Use a fully-qualified name (Namespace.TypeName) and ensure the script compiled."; + return null; + } + + private static bool IsValidScriptableObject(Type t) => + t != null && typeof(ScriptableObject).IsAssignableFrom(t); + + private static object ModifyAsset(string path, JObject properties) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for modify."); + if (properties == null || !properties.HasValues) + return Response.Error("'properties' are required for modify."); + + string fullPath = SanitizeAssetPath(path); + bool ghostDesync; + if (!AssetExists(fullPath, out ghostDesync)) + return BuildAssetNotFoundResponse($"Asset not found at path: {fullPath}", fullPath, ghostDesync); + + try + { + UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath( + fullPath + ); + if (asset == null) + return Response.Error($"Failed to load asset at path: {fullPath}"); + + bool modified = false; // Flag to track if any changes were made + + // --- NEW: Handle GameObject / Prefab Component Modification --- + if (asset is GameObject gameObject) + { + // Iterate through the properties JSON: keys are component names, values are properties objects for that component + foreach (var prop in properties.Properties()) + { + string componentName = prop.Name; // e.g., "Collectible" + // Check if the value associated with the component name is actually an object containing properties + if ( + prop.Value is JObject componentProperties + && componentProperties.HasValues + ) // e.g., {"bobSpeed": 2.0} + { + // Resolve component type via ComponentResolver, then fetch by Type + Component targetComponent = null; + bool resolved = ComponentResolver.TryResolve(componentName, out var compType, out var compError); + if (resolved) + { + targetComponent = gameObject.GetComponent(compType); + } + + // Only warn about resolution failure if component also not found + if (targetComponent == null && !resolved) + { + Debug.LogWarning( + $"[ManageAsset.ModifyAsset] Failed to resolve component '{componentName}' on '{gameObject.name}': {compError}" + ); + } + + if (targetComponent != null) + { + // Apply the nested properties (e.g., bobSpeed) to the found component instance + // Use |= to ensure 'modified' becomes true if any component is successfully modified + modified |= ApplyObjectProperties( + targetComponent, + componentProperties + ); + } + else + { + // Log a warning if a specified component couldn't be found + Debug.LogWarning( + $"[ManageAsset.ModifyAsset] Component '{componentName}' not found on GameObject '{gameObject.name}' in asset '{fullPath}'. Skipping modification for this component." + ); + } + } + else + { + // Log a warning if the structure isn't {"ComponentName": {"prop": value}} + // We could potentially try to apply this property directly to the GameObject here if needed, + // but the primary goal is component modification. + Debug.LogWarning( + $"[ManageAsset.ModifyAsset] Property '{prop.Name}' for GameObject modification should have a JSON object value containing component properties. Value was: {prop.Value.Type}. Skipping." + ); + } + } + // Note: 'modified' is now true if ANY component property was successfully changed. + } + // --- End NEW --- + + // --- Existing logic for other asset types (now as else-if) --- + // Example: Modifying a Material + else if (asset is Material material) + { + // Apply properties directly to the material. If this modifies, it sets modified=true. + // Use |= in case the asset was already marked modified by previous logic (though unlikely here) + modified |= ApplyMaterialProperties(material, properties); + } + // Example: Modifying a ScriptableObject + else if (asset is ScriptableObject so) + { + // Apply properties directly to the ScriptableObject. + modified |= ApplyObjectProperties(so, properties); // General helper + } + // Example: Modifying TextureImporter settings + else if (asset is Texture) + { + AssetImporter importer = AssetImporter.GetAtPath(fullPath); + if (importer is TextureImporter textureImporter) + { + bool importerModified = ApplyObjectProperties(textureImporter, properties); + if (importerModified) + { + // Importer settings need saving and reimporting + AssetDatabase.WriteImportSettingsIfDirty(fullPath); + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); // Reimport to apply changes + modified = true; // Mark overall operation as modified + } + } + else + { + Debug.LogWarning($"Could not get TextureImporter for {fullPath}."); + } + } + // TODO: Add modification logic for other common asset types (Models, AudioClips importers, etc.) + else // Fallback for other asset types OR direct properties on non-GameObject assets + { + // This block handles non-GameObject/Material/ScriptableObject/Texture assets. + // Attempts to apply properties directly to the asset itself. + Debug.LogWarning( + $"[ManageAsset.ModifyAsset] Asset type '{asset.GetType().Name}' at '{fullPath}' is not explicitly handled for component modification. Attempting generic property setting on the asset itself." + ); + modified |= ApplyObjectProperties(asset, properties); + } + // --- End Existing Logic --- + + // Check if any modification happened (either component or direct asset modification) + if (modified) + { + // Mark the asset as dirty (important for prefabs/SOs) so Unity knows to save it. + EditorUtility.SetDirty(asset); + // Save all modified assets to disk. + AssetDatabase.SaveAssets(); + // Refresh might be needed in some edge cases, but SaveAssets usually covers it. + // AssetDatabase.Refresh(); + return Response.Success( + $"Asset '{fullPath}' modified successfully.", + GetAssetData(fullPath) + ); + } + else + { + // If no changes were made (e.g., component not found, property names incorrect, value unchanged), return a success message indicating nothing changed. + return Response.Success( + $"No applicable or modifiable properties found for asset '{fullPath}'. Check component names, property names, and values.", + GetAssetData(fullPath) + ); + // Previous message: return Response.Success($"No applicable properties found to modify for asset '{fullPath}'.", GetAssetData(fullPath)); + } + } + catch (Exception e) + { + // Log the detailed error internally + Debug.LogError($"[ManageAsset] Action 'modify' failed for path '{path}': {e}"); + // Return a user-friendly error message + return Response.Error($"Failed to modify asset '{fullPath}': {e.Message}"); + } + } + + private static object DeleteAsset(string path) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for delete."); + string fullPath = SanitizeAssetPath(path); + bool ghostDesync; + if (!AssetExists(fullPath, out ghostDesync)) + return BuildAssetNotFoundResponse($"Asset not found at path: {fullPath}", fullPath, ghostDesync); + + try + { + bool success = AssetDatabase.DeleteAsset(fullPath); + if (success) + { + // AssetDatabase.Refresh(); // DeleteAsset usually handles refresh + return Response.Success($"Asset '{fullPath}' deleted successfully."); + } + else + { + // This might happen if the file couldn't be deleted (e.g., locked) + return Response.Error( + $"Failed to delete asset '{fullPath}'. Check logs or if the file is locked." + ); + } + } + catch (Exception e) + { + return Response.Error($"Error deleting asset '{fullPath}': {e.Message}"); + } + } + + private static object DuplicateAsset(string path, string destinationPath) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for duplicate."); + + string sourcePath = SanitizeAssetPath(path); + bool ghostDesync; + if (!AssetExists(sourcePath, out ghostDesync)) + return BuildAssetNotFoundResponse($"Source asset not found at path: {sourcePath}", sourcePath, ghostDesync); + + string destPath; + if (string.IsNullOrEmpty(destinationPath)) + { + // Generate a unique path if destination is not provided + destPath = AssetDatabase.GenerateUniqueAssetPath(sourcePath); + } + else + { + destPath = SanitizeAssetPath(destinationPath); + if (AssetExists(destPath)) + return Response.Error($"Asset already exists at destination path: {destPath}"); + // Ensure destination directory exists + EnsureDirectoryExists(Path.GetDirectoryName(destPath)); + } + + try + { + bool success = AssetDatabase.CopyAsset(sourcePath, destPath); + if (success) + { + // AssetDatabase.Refresh(); + return Response.Success( + $"Asset '{sourcePath}' duplicated to '{destPath}'.", + GetAssetData(destPath) + ); + } + else + { + return Response.Error( + $"Failed to duplicate asset from '{sourcePath}' to '{destPath}'." + ); + } + } + catch (Exception e) + { + return Response.Error($"Error duplicating asset '{sourcePath}': {e.Message}"); + } + } + + private static object MoveOrRenameAsset(string path, string destinationPath) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for move/rename."); + if (string.IsNullOrEmpty(destinationPath)) + return Response.Error("'destination' path is required for move/rename."); + + string sourcePath = SanitizeAssetPath(path); + string destPath = SanitizeAssetPath(destinationPath); + + bool ghostDesync; + if (!AssetExists(sourcePath, out ghostDesync)) + return BuildAssetNotFoundResponse($"Source asset not found at path: {sourcePath}", sourcePath, ghostDesync); + if (AssetExists(destPath)) + return Response.Error( + $"An asset already exists at the destination path: {destPath}" + ); + + // Ensure destination directory exists + EnsureDirectoryExists(Path.GetDirectoryName(destPath)); + + try + { + // Validate will return an error string if failed, empty string if successful + string validateError = AssetDatabase.ValidateMoveAsset(sourcePath, destPath); + if (!string.IsNullOrEmpty(validateError)) + { + return Response.Error( + $"Failed to move/rename asset from '{sourcePath}' to '{destPath}': {validateError}" + ); + } + + // MoveAsset returns an empty string on success, or an error message on failure + string moveError = AssetDatabase.MoveAsset(sourcePath, destPath); + if (string.IsNullOrEmpty(moveError)) + { + // AssetDatabase.Refresh(); // MoveAsset usually handles refresh + return Response.Success( + $"Asset moved/renamed from '{sourcePath}' to '{destPath}'.", + GetAssetData(destPath) + ); + } + else + { + return Response.Error( + $"Failed to move/rename asset from '{sourcePath}' to '{destPath}': {moveError}" + ); + } + } + catch (Exception e) + { + return Response.Error($"Error moving/renaming asset '{sourcePath}': {e.Message}"); + } + } + + private static object SearchAssets(JObject @params) + { + string searchPattern = @params["searchPattern"]?.ToString(); + string filterType = @params["filterType"]?.ToString(); + string pathScope = @params["path"]?.ToString(); // Use path as folder scope + string filterDateAfterStr = @params["filterDateAfter"]?.ToString(); + int pageSize = @params["pageSize"]?.ToObject() ?? 50; // Default page size + int pageNumber = @params["pageNumber"]?.ToObject() ?? 1; // Default page number (1-based) + bool generatePreview = @params["generatePreview"]?.ToObject() ?? false; + + List searchFilters = new List(); + if (!string.IsNullOrEmpty(searchPattern)) + searchFilters.Add(searchPattern); + if (!string.IsNullOrEmpty(filterType)) + searchFilters.Add($"t:{filterType}"); + + string[] folderScope = null; + if (!string.IsNullOrEmpty(pathScope)) + { + folderScope = new string[] { SanitizeAssetPath(pathScope) }; + if (!AssetDatabase.IsValidFolder(folderScope[0])) + { + // Maybe the user provided a file path instead of a folder? + // We could search in the containing folder, or return an error. + Debug.LogWarning( + $"Search path '{folderScope[0]}' is not a valid folder. Searching entire project." + ); + folderScope = null; // Search everywhere if path isn't a folder + } + } + + DateTime? filterDateAfter = null; + if (!string.IsNullOrEmpty(filterDateAfterStr)) + { + if ( + DateTime.TryParse( + filterDateAfterStr, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out DateTime parsedDate + ) + ) + { + filterDateAfter = parsedDate; + } + else + { + Debug.LogWarning( + $"Could not parse filterDateAfter: '{filterDateAfterStr}'. Expected ISO 8601 format." + ); + } + } + + try + { + string[] guids = AssetDatabase.FindAssets( + string.Join(" ", searchFilters), + folderScope + ); + List results = new List(); + int totalFound = 0; + + foreach (string guid in guids) + { + string assetPath = AssetDatabase.GUIDToAssetPath(guid); + if (string.IsNullOrEmpty(assetPath)) + continue; + + // Apply date filter if present + if (filterDateAfter.HasValue) + { + DateTime lastWriteTime = File.GetLastWriteTimeUtc( + Path.Combine(Directory.GetCurrentDirectory(), assetPath) + ); + if (lastWriteTime <= filterDateAfter.Value) + { + continue; // Skip assets older than or equal to the filter date + } + } + + totalFound++; // Count matching assets before pagination + results.Add(GetAssetData(assetPath, generatePreview)); + } + + // Apply pagination + int startIndex = (pageNumber - 1) * pageSize; + var pagedResults = results.Skip(startIndex).Take(pageSize).ToList(); + + return Response.Success( + $"Found {totalFound} asset(s). Returning page {pageNumber} ({pagedResults.Count} assets).", + new + { + totalAssets = totalFound, + pageSize = pageSize, + pageNumber = pageNumber, + assets = pagedResults, + } + ); + } + catch (Exception e) + { + return Response.Error($"Error searching assets: {e.Message}"); + } + } + + private static object GetAssetInfo(string path, bool generatePreview) + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for get_info."); + string fullPath = SanitizeAssetPath(path); + bool ghostDesync; + if (!AssetExists(fullPath, out ghostDesync)) + return BuildAssetNotFoundResponse($"Asset not found at path: {fullPath}", fullPath, ghostDesync); + + try + { + return Response.Success( + "Asset info retrieved.", + GetAssetData(fullPath, generatePreview) + ); + } + catch (Exception e) + { + return Response.Error($"Error getting info for asset '{fullPath}': {e.Message}"); + } + } + + /// + /// Retrieves components attached to a GameObject asset (like a Prefab). + /// + /// The asset path of the GameObject or Prefab. + /// A response object containing a list of component type names or an error. + private static object GetComponentsFromAsset(string path) + { + // 1. Validate input path + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for get_components."); + + // 2. Sanitize and check existence + string fullPath = SanitizeAssetPath(path); + bool ghostDesync; + if (!AssetExists(fullPath, out ghostDesync)) + return BuildAssetNotFoundResponse($"Asset not found at path: {fullPath}", fullPath, ghostDesync); + + try + { + // 3. Load the asset + UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath( + fullPath + ); + if (asset == null) + return Response.Error($"Failed to load asset at path: {fullPath}"); + + // 4. Check if it's a GameObject (Prefabs load as GameObjects) + GameObject gameObject = asset as GameObject; + if (gameObject == null) + { + // Also check if it's *directly* a Component type (less common for primary assets) + Component componentAsset = asset as Component; + if (componentAsset != null) + { + // If the asset itself *is* a component, maybe return just its info? + // This is an edge case. Let's stick to GameObjects for now. + return Response.Error( + $"Asset at '{fullPath}' is a Component ({asset.GetType().FullName}), not a GameObject. Components are typically retrieved *from* a GameObject." + ); + } + return Response.Error( + $"Asset at '{fullPath}' is not a GameObject (Type: {asset.GetType().FullName}). Cannot get components from this asset type." + ); + } + + // 5. Get components + Component[] components = gameObject.GetComponents(); + + // 6. Format component data + List componentList = components + .Select(comp => new + { + typeName = comp.GetType().FullName, + instanceID = comp.GetInstanceID(), + // TODO: Add more component-specific details here if needed in the future? + // Requires reflection or specific handling per component type. + }) + .ToList(); // Explicit cast for clarity if needed + + // 7. Return success response + return Response.Success( + $"Found {componentList.Count} component(s) on asset '{fullPath}'.", + componentList + ); + } + catch (Exception e) + { + Debug.LogError( + $"[ManageAsset.GetComponentsFromAsset] Error getting components for '{fullPath}': {e}" + ); + return Response.Error( + $"Error getting components for asset '{fullPath}': {e.Message}" + ); + } + } + + // --- Internal Helpers --- + + /// + /// Ensures the asset path starts with "Assets/". + /// + private static string SanitizeAssetPath(string path) + { + if (string.IsNullOrEmpty(path)) + return path; + path = path.Replace('\\', '/'); // Normalize separators + if (!path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + return "Assets/" + path.TrimStart('/'); + } + return path; + } + + /// + /// Checks if an asset exists at the given path (file or folder). + /// + /// NOTE: + /// We intentionally require a *real* backing asset on disk for non-folder assets. + /// Relying solely on AssetDatabase.AssetPathToGUID can report "ghost" assets + /// where a GUID/meta still exists in Unity's database but the actual asset file + /// has been deleted. + /// + /// If we detect a GUID in AssetDatabase but the corresponding file is missing + /// on disk, we trigger a one-off AssetDatabase.Refresh() to give Unity a chance + /// to heal the desync before returning "not found". + /// + private static bool AssetExists(string path, out bool ghostDesyncDetected) + { + ghostDesyncDetected = false; + + if (string.IsNullOrEmpty(path)) + return false; + + // Normalise path (adds "Assets/" prefix if missing, normalises slashes) + string sanitizedPath = SanitizeAssetPath(path); + string fullPath = Path.Combine(Directory.GetCurrentDirectory(), sanitizedPath); + + // --- Folder case: require BOTH AssetDatabase and filesystem directory --- + bool isFolder = AssetDatabase.IsValidFolder(sanitizedPath); + bool dirExists = Directory.Exists(fullPath); + + if (isFolder) + { + if (!dirExists) + { + // Ghost folder: AssetDatabase thinks folder exists but the directory is gone. + ghostDesyncDetected = true; + + Debug.LogWarning( + $"[ManageAsset.AssetExists] Detected valid folder '{sanitizedPath}' in AssetDatabase " + + $"but no directory found at '{fullPath}'. Triggering AssetDatabase.Refresh() to resync." + ); + + AssetDatabase.Refresh(); + + // Re-evaluate after refresh + isFolder = AssetDatabase.IsValidFolder(sanitizedPath); + dirExists = Directory.Exists(fullPath); + } + + // Only treat as existing if both ADB 和 FS 都确认存在 + return isFolder && dirExists; + } + + // --- Non-folder assets: look at both AssetDatabase (GUID) and filesystem file --- + string guid = AssetDatabase.AssetPathToGUID(sanitizedPath); + bool fileExists = File.Exists(fullPath); + + // Ghost case: AssetDatabase still has a GUID for this path, but the backing file + // is gone from disk. Trigger a refresh once to let Unity heal its cache. + if (!fileExists && !string.IsNullOrEmpty(guid)) + { + ghostDesyncDetected = true; + + Debug.LogWarning( + $"[ManageAsset.AssetExists] Detected GUID '{guid}' for '{sanitizedPath}' in AssetDatabase " + + $"but no asset file found at '{fullPath}'. Triggering AssetDatabase.Refresh() to resync." + ); + + AssetDatabase.Refresh(); + + // Re-evaluate after refresh + guid = AssetDatabase.AssetPathToGUID(sanitizedPath); + fileExists = File.Exists(fullPath); + } + + // Non-folder assets: require that the main asset file exists on disk + // *and* that AssetDatabase knows about it (has a GUID). This prevents + // "ghost" assets that only have a stale GUID/meta entry. + if (!fileExists) + { + return false; + } + + return !string.IsNullOrEmpty(guid); + } + + /// + /// Convenience overload when ghost-desync information is not needed. + /// + private static bool AssetExists(string path) + { + return AssetExists(path, out _); + } + + /// + /// Creates a standardized "asset not found" error with an extra hint for LLMs + /// about potential AssetDatabase / filesystem desync. + /// + private static object AssetNotFoundError(string message, string path) + { + return Response.Error( + message, + new + { + path = path, + llm_hint = + "The requested asset could not be found on disk. If this asset should exist (for example it was " + + "recently renamed, moved, or deleted outside the Unity Editor), Unity's AssetDatabase may be out of " + + "sync with the filesystem. Ask the user to refresh the AssetDatabase in the Unity Editor (for example " + + "via 'Assets → Reimport All' or by reopening the project) and then retry this tool call." + } + ); + } + + /// + /// Builds an "asset not found" response, only upgrading to AssetNotFoundError (with + /// LLM hint about AssetDatabase desync) when we have actually detected a ghost asset + /// scenario (GUID present in AssetDatabase but file missing on disk). + /// + private static object BuildAssetNotFoundResponse(string message, string path, bool ghostDesyncDetected) + { + if (ghostDesyncDetected) + { + // Ghost asset case: surface the richer error with LLM hint. + return AssetNotFoundError(message, path); + } + + // Normal "not found" case (e.g., bad path, never existed): keep error simple. + return Response.Error( + message, + new + { + path = path + } + ); + } + + /// + /// Ensures the directory for a given asset path exists, creating it if necessary. + /// + private static void EnsureDirectoryExists(string directoryPath) + { + if (string.IsNullOrEmpty(directoryPath)) + return; + string fullDirPath = Path.Combine(Directory.GetCurrentDirectory(), directoryPath); + if (!Directory.Exists(fullDirPath)) + { + Directory.CreateDirectory(fullDirPath); + AssetDatabase.Refresh(); // Let Unity know about the new folder + } + } + + /// + /// Applies properties from JObject to a Material. + /// + private static bool ApplyMaterialProperties(Material mat, JObject properties) + { + if (mat == null || properties == null) + return false; + bool modified = false; + + // Example: Set shader + if (properties["shader"]?.Type == JTokenType.String) + { + Shader newShader = Shader.Find(properties["shader"].ToString()); + if (newShader != null && mat.shader != newShader) + { + mat.shader = newShader; + modified = true; + } + } + // Example: Set color property + if (properties["color"] is JObject colorProps) + { + string propName = colorProps["name"]?.ToString() ?? "_Color"; // Default main color + if (colorProps["value"] is JArray colArr && colArr.Count >= 3) + { + try + { + Color newColor = new Color( + colArr[0].ToObject(), + colArr[1].ToObject(), + colArr[2].ToObject(), + colArr.Count > 3 ? colArr[3].ToObject() : 1.0f + ); + if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor) + { + mat.SetColor(propName, newColor); + modified = true; + } + } + catch (Exception ex) + { + Debug.LogWarning( + $"Error parsing color property '{propName}': {ex.Message}" + ); + } + } + } else if (properties["color"] is JArray colorArr) //Use color now with examples set in manage_asset.py + { + string propName = "_Color"; + try { + if (colorArr.Count >= 3) + { + Color newColor = new Color( + colorArr[0].ToObject(), + colorArr[1].ToObject(), + colorArr[2].ToObject(), + colorArr.Count > 3 ? colorArr[3].ToObject() : 1.0f + ); + if (mat.HasProperty(propName) && mat.GetColor(propName) != newColor) + { + mat.SetColor(propName, newColor); + modified = true; + } + } + } + catch (Exception ex) { + Debug.LogWarning( + $"Error parsing color property '{propName}': {ex.Message}" + ); + } + } + // Example: Set float property + if (properties["float"] is JObject floatProps) + { + string propName = floatProps["name"]?.ToString(); + if ( + !string.IsNullOrEmpty(propName) && + (floatProps["value"]?.Type == JTokenType.Float || floatProps["value"]?.Type == JTokenType.Integer) + ) + { + try + { + float newVal = floatProps["value"].ToObject(); + if (mat.HasProperty(propName) && mat.GetFloat(propName) != newVal) + { + mat.SetFloat(propName, newVal); + modified = true; + } + } + catch (Exception ex) + { + Debug.LogWarning( + $"Error parsing float property '{propName}': {ex.Message}" + ); + } + } + } + // Example: Set texture property + if (properties["texture"] is JObject texProps) + { + string propName = texProps["name"]?.ToString() ?? "_MainTex"; // Default main texture + string texPath = texProps["path"]?.ToString(); + if (!string.IsNullOrEmpty(texPath)) + { + Texture newTex = AssetDatabase.LoadAssetAtPath( + SanitizeAssetPath(texPath) + ); + if ( + newTex != null + && mat.HasProperty(propName) + && mat.GetTexture(propName) != newTex + ) + { + mat.SetTexture(propName, newTex); + modified = true; + } + else if (newTex == null) + { + Debug.LogWarning($"Texture not found at path: {texPath}"); + } + } + } + + // Handle common Standard/URP shader properties directly by name + // metallic -> _Metallic + if (properties["metallic"]?.Type == JTokenType.Float || properties["metallic"]?.Type == JTokenType.Integer) + { + try + { + float newVal = properties["metallic"].ToObject(); + string propName = "_Metallic"; + if (mat.HasProperty(propName) && mat.GetFloat(propName) != newVal) + { + mat.SetFloat(propName, newVal); + modified = true; + } + } + catch (Exception ex) + { + Debug.LogWarning($"Error parsing metallic property: {ex.Message}"); + } + } + // smoothness -> _Smoothness or _Glossiness (Standard shader uses _Glossiness) + if (properties["smoothness"]?.Type == JTokenType.Float || properties["smoothness"]?.Type == JTokenType.Integer) + { + try + { + float newVal = properties["smoothness"].ToObject(); + // Try both property names + string[] propNames = { "_Smoothness", "_Glossiness" }; + foreach (var propName in propNames) + { + if (mat.HasProperty(propName) && mat.GetFloat(propName) != newVal) + { + mat.SetFloat(propName, newVal); + modified = true; + break; + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"Error parsing smoothness property: {ex.Message}"); + } + } + + // TODO: Add handlers for other property types (Vectors, Ints, Keywords, RenderQueue, etc.) + return modified; + } + + /// + /// Applies properties from JObject to a PhysicsMaterial. + /// + private static bool ApplyPhysicsMaterialProperties(PhysicsMaterialType pmat, JObject properties) + { + if (pmat == null || properties == null) + return false; + bool modified = false; + + // Helper to check if a token is a number (Float or Integer) + bool IsNumber(JToken token) => token?.Type == JTokenType.Float || token?.Type == JTokenType.Integer; + + // Set dynamic friction + if (IsNumber(properties["dynamicFriction"])) + { + float dynamicFriction = properties["dynamicFriction"].ToObject(); + pmat.dynamicFriction = dynamicFriction; + modified = true; + } + + // Set static friction + if (IsNumber(properties["staticFriction"])) + { + float staticFriction = properties["staticFriction"].ToObject(); + pmat.staticFriction = staticFriction; + modified = true; + } + + // Set bounciness + if (IsNumber(properties["bounciness"])) + { + float bounciness = properties["bounciness"].ToObject(); + pmat.bounciness = bounciness; + modified = true; + } + + List averageList = new List { "ave", "Ave", "average", "Average" }; + List multiplyList = new List { "mul", "Mul", "mult", "Mult", "multiply", "Multiply" }; + List minimumList = new List { "min", "Min", "minimum", "Minimum" }; + List maximumList = new List { "max", "Max", "maximum", "Maximum" }; + + // Example: Set friction combine + if (properties["frictionCombine"]?.Type == JTokenType.String) + { + string frictionCombine = properties["frictionCombine"].ToString(); + if (averageList.Contains(frictionCombine)) + pmat.frictionCombine = PhysicsMaterialCombine.Average; + else if (multiplyList.Contains(frictionCombine)) + pmat.frictionCombine = PhysicsMaterialCombine.Multiply; + else if (minimumList.Contains(frictionCombine)) + pmat.frictionCombine = PhysicsMaterialCombine.Minimum; + else if (maximumList.Contains(frictionCombine)) + pmat.frictionCombine = PhysicsMaterialCombine.Maximum; + modified = true; + } + + // Example: Set bounce combine + if (properties["bounceCombine"]?.Type == JTokenType.String) + { + string bounceCombine = properties["bounceCombine"].ToString(); + if (averageList.Contains(bounceCombine)) + pmat.bounceCombine = PhysicsMaterialCombine.Average; + else if (multiplyList.Contains(bounceCombine)) + pmat.bounceCombine = PhysicsMaterialCombine.Multiply; + else if (minimumList.Contains(bounceCombine)) + pmat.bounceCombine = PhysicsMaterialCombine.Minimum; + else if (maximumList.Contains(bounceCombine)) + pmat.bounceCombine = PhysicsMaterialCombine.Maximum; + modified = true; + } + + return modified; + } + + /// + /// Generic helper to set properties on any UnityEngine.Object using reflection. + /// + private static bool ApplyObjectProperties(UnityEngine.Object target, JObject properties) + { + if (target == null || properties == null) + return false; + bool modified = false; + Type type = target.GetType(); + + foreach (var prop in properties.Properties()) + { + string propName = prop.Name; + JToken propValue = prop.Value; + if (SetPropertyOrField(target, propName, propValue, type)) + { + modified = true; + } + } + return modified; + } + + /// + /// Helper to set a property or field via reflection, handling basic types and Unity objects. + /// + private static bool SetPropertyOrField( + object target, + string memberName, + JToken value, + Type type = null + ) + { + type = type ?? target.GetType(); + System.Reflection.BindingFlags flags = + System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.IgnoreCase; + + try + { + System.Reflection.PropertyInfo propInfo = type.GetProperty(memberName, flags); + if (propInfo != null && propInfo.CanWrite) + { + object convertedValue = ConvertJTokenToType(value, propInfo.PropertyType); + if ( + convertedValue != null + && !object.Equals(propInfo.GetValue(target), convertedValue) + ) + { + propInfo.SetValue(target, convertedValue); + return true; + } + } + else + { + System.Reflection.FieldInfo fieldInfo = type.GetField(memberName, flags); + if (fieldInfo != null) + { + object convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType); + if ( + convertedValue != null + && !object.Equals(fieldInfo.GetValue(target), convertedValue) + ) + { + fieldInfo.SetValue(target, convertedValue); + return true; + } + } + } + } + catch (Exception ex) + { + Debug.LogWarning( + $"[SetPropertyOrField] Failed to set '{memberName}' on {type.Name}: {ex.Message}" + ); + } + return false; + } + + /// + /// Simple JToken to Type conversion for common Unity types and primitives. + /// + private static object ConvertJTokenToType(JToken token, Type targetType) + { + try + { + if (token == null || token.Type == JTokenType.Null) + return null; + + if (targetType == typeof(string)) + return token.ToObject(); + if (targetType == typeof(int)) + return token.ToObject(); + if (targetType == typeof(float)) + return token.ToObject(); + if (targetType == typeof(bool)) + return token.ToObject(); + if (targetType == typeof(Vector2) && token is JArray arrV2 && arrV2.Count == 2) + return new Vector2(arrV2[0].ToObject(), arrV2[1].ToObject()); + if (targetType == typeof(Vector3) && token is JArray arrV3 && arrV3.Count == 3) + return new Vector3( + arrV3[0].ToObject(), + arrV3[1].ToObject(), + arrV3[2].ToObject() + ); + if (targetType == typeof(Vector4) && token is JArray arrV4 && arrV4.Count == 4) + return new Vector4( + arrV4[0].ToObject(), + arrV4[1].ToObject(), + arrV4[2].ToObject(), + arrV4[3].ToObject() + ); + if (targetType == typeof(Quaternion) && token is JArray arrQ && arrQ.Count == 4) + return new Quaternion( + arrQ[0].ToObject(), + arrQ[1].ToObject(), + arrQ[2].ToObject(), + arrQ[3].ToObject() + ); + if (targetType == typeof(Color) && token is JArray arrC && arrC.Count >= 3) // Allow RGB or RGBA + return new Color( + arrC[0].ToObject(), + arrC[1].ToObject(), + arrC[2].ToObject(), + arrC.Count > 3 ? arrC[3].ToObject() : 1.0f + ); + if (targetType.IsEnum) + return Enum.Parse(targetType, token.ToString(), true); // Case-insensitive enum parsing + + // Handle loading Unity Objects (Materials, Textures, etc.) by path + if ( + typeof(UnityEngine.Object).IsAssignableFrom(targetType) + && token.Type == JTokenType.String + ) + { + string assetPath = SanitizeAssetPath(token.ToString()); + UnityEngine.Object loadedAsset = AssetDatabase.LoadAssetAtPath( + assetPath, + targetType + ); + if (loadedAsset == null) + { + Debug.LogWarning( + $"[ConvertJTokenToType] Could not load asset of type {targetType.Name} from path: {assetPath}" + ); + } + return loadedAsset; + } + + // Fallback: Try direct conversion (might work for other simple value types) + return token.ToObject(targetType); + } + catch (Exception ex) + { + Debug.LogWarning( + $"[ConvertJTokenToType] Could not convert JToken '{token}' (type {token.Type}) to type '{targetType.Name}': {ex.Message}" + ); + return null; + } + } + + + // --- Data Serialization --- + + /// + /// Creates a serializable representation of an asset. + /// + private static object GetAssetData(string path, bool generatePreview = false) + { + if (string.IsNullOrEmpty(path) || !AssetExists(path)) + return null; + + string guid = AssetDatabase.AssetPathToGUID(path); + Type assetType = AssetDatabase.GetMainAssetTypeAtPath(path); + UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(path); + string previewBase64 = null; + int previewWidth = 0; + int previewHeight = 0; + + if (generatePreview && asset != null) + { + Texture2D preview = AssetPreview.GetAssetPreview(asset); + + if (preview != null) + { + try + { + // Ensure texture is readable for EncodeToPNG + // Creating a temporary readable copy is safer + RenderTexture rt = null; + Texture2D readablePreview = null; + RenderTexture previous = RenderTexture.active; + try + { + rt = RenderTexture.GetTemporary(preview.width, preview.height); + Graphics.Blit(preview, rt); + RenderTexture.active = rt; + readablePreview = new Texture2D(preview.width, preview.height, TextureFormat.RGB24, false); + readablePreview.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0); + readablePreview.Apply(); + + var pngData = readablePreview.EncodeToPNG(); + if (pngData != null && pngData.Length > 0) + { + previewBase64 = Convert.ToBase64String(pngData); + previewWidth = readablePreview.width; + previewHeight = readablePreview.height; + } + } + finally + { + RenderTexture.active = previous; + if (rt != null) RenderTexture.ReleaseTemporary(rt); + if (readablePreview != null) UnityEngine.Object.DestroyImmediate(readablePreview); + } + } + catch (Exception ex) + { + Debug.LogWarning( + $"Failed to generate readable preview for '{path}': {ex.Message}. Preview might not be readable." + ); + // Fallback: Try getting static preview if available? + // Texture2D staticPreview = AssetPreview.GetMiniThumbnail(asset); + } + } + else + { + Debug.LogWarning( + $"Could not get asset preview for {path} (Type: {assetType?.Name}). Is it supported?" + ); + } + } + + return new + { + path = path, + guid = guid, + assetType = assetType?.FullName ?? "Unknown", + name = Path.GetFileNameWithoutExtension(path), + fileName = Path.GetFileName(path), + isFolder = AssetDatabase.IsValidFolder(path), + instanceID = asset?.GetInstanceID() ?? 0, + lastWriteTimeUtc = File.GetLastWriteTimeUtc( + Path.Combine(Directory.GetCurrentDirectory(), path) + ) + .ToString("o"), // ISO 8601 + // --- Preview Data --- + previewBase64 = previewBase64, // PNG data as Base64 string + previewWidth = previewWidth, + previewHeight = previewHeight, + // TODO: Add more metadata? Importer settings? Dependencies? + }; + } + // --- Ensure Methods (Idempotent Operations) --- + + /// + /// Ensures an asset has a .meta file. Idempotent - safe if .meta already exists. + /// + private static object EnsureHasMeta(string path) + { + try + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for ensure_has_meta."); + + string fullPath = SanitizeAssetPath(path); + bool ghostDesync; + if (!AssetExists(fullPath, out ghostDesync)) + return BuildAssetNotFoundResponse($"Asset not found at path: {fullPath}", fullPath, ghostDesync); + + string metaPath = fullPath + ".meta"; + bool metaExists = File.Exists(metaPath); + + if (metaExists) + { + return new + { + success = true, + message = "Asset .meta file already exists.", + data = new { path = fullPath, hasMeta = true, alreadyExists = true }, + state_delta = StateComposer.CreateAssetDelta(new[] { + new { path = fullPath, imported = false, hasMeta = true } + }) + }; + } + + // Meta doesn't exist - trigger reimport to generate it safely + AssetDatabase.ImportAsset(fullPath, ImportAssetOptions.ForceUpdate); + AssetDatabase.SaveAssets(); + + metaExists = File.Exists(metaPath); + if (!metaExists) + { + return Response.Error($"Failed to generate .meta file for: {fullPath}"); + } + + StateComposer.IncrementRevision(); + return new + { + success = true, + message = "Asset .meta file generated.", + data = new { path = fullPath, hasMeta = true, alreadyExists = false }, + state_delta = StateComposer.CreateAssetDelta(new[] { + new { path = fullPath, imported = true, hasMeta = true } + }) + }; + } + catch (Exception e) + { + return Response.Error($"Failed to ensure .meta file: {e.Message}"); + } + } + + /// + /// Checks .meta file integrity and consistency with asset. + /// Read-only check - provides recommendations without auto-fixing. + /// + private static object EnsureMetaIntegrity(string path) + { + try + { + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' is required for ensure_meta_integrity."); + + string fullPath = SanitizeAssetPath(path); + bool ghostDesync; + if (!AssetExists(fullPath, out ghostDesync)) + return BuildAssetNotFoundResponse($"Asset not found at path: {fullPath}", fullPath, ghostDesync); + + string metaPath = fullPath + ".meta"; + if (!File.Exists(metaPath)) + { + return new + { + success = false, + message = "Asset .meta file is missing.", + data = new + { + path = fullPath, + hasMeta = false, + issues = new[] { "meta_file_missing" }, + recommendation = "Use 'ensure_has_meta' action to generate .meta file" + } + }; + } + + var issues = new List(); + var recommendations = new List(); + + // Check GUID + string guid = AssetDatabase.AssetPathToGUID(fullPath); + if (string.IsNullOrEmpty(guid)) + { + issues.Add("guid_invalid"); + recommendations.Add("Reimport asset to regenerate GUID"); + } + + // Check importer settings exist + AssetImporter importer = AssetImporter.GetAtPath(fullPath); + if (importer == null) + { + issues.Add("importer_not_found"); + recommendations.Add("Reimport asset to fix importer"); + } + + // Check file timestamp consistency + DateTime assetModified = File.GetLastWriteTimeUtc(fullPath); + DateTime metaModified = File.GetLastWriteTimeUtc(metaPath); + + if (assetModified > metaModified.AddSeconds(5)) // 5 second grace period + { + issues.Add("meta_outdated"); + recommendations.Add("Reimport asset to update .meta file"); + } + + bool isHealthy = issues.Count == 0; + + return new + { + success = true, + message = isHealthy ? "Asset .meta file is healthy." : "Asset .meta file has issues.", + data = new + { + path = fullPath, + hasMeta = true, + guid = guid, + healthy = isHealthy, + issues = issues.ToArray(), + recommendations = recommendations.ToArray(), + timestamps = new + { + asset = assetModified.ToString("o"), + meta = metaModified.ToString("o") + } + } + }; + } + catch (Exception e) + { + return Response.Error($"Failed to check .meta integrity: {e.Message}"); + } + } + + /// + /// Create batch operation: execute multiple write-only asset operations in sequence. + /// + private static object HandleCreateBatch(JObject @params) + { + var opsToken = @params["ops"] as JArray; + if (opsToken == null || opsToken.Count == 0) + { + return Response.Error("'ops' array is required for create_batch action."); + } + + // Guardrail: keep batches small and deterministic (parity with TS client) + if (opsToken.Count > MaxBatchOps) + { + return Response.Error( + $"Too many ops for create_batch action: {opsToken.Count}. Please split into multiple batches of <= {MaxBatchOps} ops." + ); + } + + string mode = @params["mode"]?.ToString()?.ToLower() ?? "stop_on_error"; + if (mode != "stop_on_error" && mode != "continue_on_error") + { + return Response.Error($"Invalid mode: '{mode}'. Valid values are: stop_on_error, continue_on_error"); + } + + // create_batch is write-only (no reads). + + // mkdir -p semantics for create_folder inside batch (parity with TS client): + // auto-insert missing parent create_folder ops in depth order before execution. + opsToken = ExpandCreateFolderParentsInBatch(opsToken); + + var results = new List>(); + var stateDeltas = new List(); + int succeeded = 0; + int failed = 0; + + foreach (var opToken in opsToken) + { + var op = opToken as JObject; + if (op == null) + { + results.Add(new Dictionary + { + ["id"] = "unknown", + ["success"] = false, + ["message"] = "Invalid op format" + }); + failed++; + if (mode == "stop_on_error") break; + continue; + } + + string opId = op["id"]?.ToString() ?? "unknown"; + string opAction = op["action"]?.ToString()?.ToLower(); + bool allowFailure = op["allowFailure"]?.ToObject() ?? false; + + if (string.IsNullOrEmpty(opAction)) + { + results.Add(new Dictionary + { + ["id"] = opId, + ["success"] = false, + ["message"] = "Op action is required" + }); + failed++; + if (mode == "stop_on_error" && !allowFailure) break; + continue; + } + + if (opAction == "batch") + { + results.Add(new Dictionary + { + ["id"] = opId, + ["success"] = false, + ["message"] = "Nested batch is not allowed" + }); + failed++; + if (mode == "stop_on_error" && !allowFailure) break; + continue; + } + + // Reject read ops in create_batch + if (opAction == "search" || opAction == "get_info" || opAction == "get_components") + { + results.Add(new Dictionary + { + ["id"] = opId, + ["action"] = opAction, + ["success"] = false, + ["message"] = $"create_batch only supports write ops (got read op '{opAction}')", + ["code"] = "invalid_op" + }); + failed++; + if (mode == "stop_on_error" && !allowFailure) break; + continue; + } + + // Build params for the individual operation + var opParams = op["params"] as JObject ?? new JObject(); + opParams["action"] = opAction; + + // Execute the operation + try + { + var opResult = HandleCommand(opParams); + bool opSuccess = true; + string opMessage = "Success"; + string opCode = null; + object opData = null; + object opStateDelta = null; + + if (opResult is Dictionary resultDict) + { + if (resultDict.TryGetValue("success", out var successObj) && successObj is bool s) + opSuccess = s; + if (resultDict.TryGetValue("message", out var msgObj)) + opMessage = msgObj?.ToString(); + if (resultDict.TryGetValue("code", out var codeObj)) + opCode = codeObj?.ToString(); + if (resultDict.TryGetValue("state_delta", out var sd)) + opStateDelta = sd; + if (resultDict.TryGetValue("data", out var dataObj)) + opData = dataObj; + else + opData = resultDict; + } + else + { + opData = opResult; + try + { + var sdProp = opResult?.GetType()?.GetProperty("state_delta"); + if (sdProp != null) opStateDelta = sdProp.GetValue(opResult); + } + catch { } + } + + if (opStateDelta != null) stateDeltas.Add(opStateDelta); + + var resultEntry = new Dictionary + { + ["id"] = opId, + ["action"] = opAction, + ["success"] = opSuccess, + ["message"] = opMessage + }; + if (opCode != null) resultEntry["code"] = opCode; + if (opData != null) resultEntry["data"] = opData; + + results.Add(resultEntry); + + if (opSuccess) + succeeded++; + else + { + failed++; + if (mode == "stop_on_error" && !allowFailure) break; + } + } + catch (Exception e) + { + results.Add(new Dictionary + { + ["id"] = opId, + ["action"] = opAction, + ["success"] = false, + ["message"] = e.Message, + ["code"] = "exception" + }); + failed++; + if (mode == "stop_on_error" && !allowFailure) break; + } + } + + var mergedDelta = stateDeltas.Count > 0 + ? StateComposer.MergeStateDeltas(stateDeltas.ToArray()) + : null; + + bool success = failed == 0; + var message = success + ? "Unity Asset create_batch completed successfully." + : "Unity Asset create_batch completed with errors."; + + var response = new Dictionary + { + ["mode"] = mode, + ["summary"] = new Dictionary + { + ["total"] = opsToken.Count, + ["succeeded"] = succeeded, + ["failed"] = failed + }, + ["results"] = results, + ["success"] = success, + ["message"] = message + }; + + if (!success) + { + response["code"] = "create_batch_failed"; + response["error"] = message; + } + + if (mergedDelta != null) + { + response["state_delta"] = mergedDelta; + } + + return response; + } + + /// + /// Edit batch operation: "search-then-write" edits with early-stop on 0 matches. + /// + /// Contract: + /// - Phase 1: one or more `search` ops (must provide captureAs) to resolve a deterministic asset path. + /// - Determinism: search must match <= 1 asset (otherwise error). 0 matches => early-stop success. + /// - Phase 2: write ops that must use the captured `$alias` as their `path` (no direct literal paths). + /// + private static object HandleEditBatch(JObject @params) + { + var opsToken = @params["ops"] as JArray; + if (opsToken == null || opsToken.Count == 0) + { + return Response.Error("'ops' array is required for edit_batch action."); + } + + if (opsToken.Count > MaxBatchOps) + { + return Response.Error( + $"Too many ops for edit_batch action: {opsToken.Count}. Please split into multiple batches of <= {MaxBatchOps} ops." + ); + } + + string mode = @params["mode"]?.ToString()?.ToLower() ?? "stop_on_error"; + if (mode != "stop_on_error" && mode != "continue_on_error") + { + return Response.Error($"Invalid mode: '{mode}'. Valid values are: stop_on_error, continue_on_error"); + } + + var writeOps = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "ensure_has_meta", + "ensure_meta_integrity", + "import", + "create", + "modify", + "delete", + "duplicate", + "move", + "rename", + "create_folder", + }; + + var aliases = new Dictionary(StringComparer.OrdinalIgnoreCase); + var results = new List>(); + var stateDeltas = new List(); + int succeeded = 0; + int failed = 0; + + bool seenWrite = false; + int searchOpsCount = 0; + int writeOpsCount = 0; + + JToken ResolveAliasValue(JToken value) + { + if (value == null) return null; + switch (value.Type) + { + case JTokenType.String: + var s = value.ToString(); + if (s.StartsWith("$") && aliases.TryGetValue(s, out var path)) + { + return path; + } + return value; + case JTokenType.Object: + var obj = value as JObject; + if (obj != null && obj.Count == 1 && obj["ref"] != null) + { + var r = obj["ref"]?.ToString(); + if (!string.IsNullOrEmpty(r) && r.StartsWith("$") && aliases.TryGetValue(r, out var refPath)) + { + return refPath; + } + } + // recurse + var outObj = new JObject(); + foreach (var prop in obj.Properties()) + { + outObj[prop.Name] = ResolveAliasValue(prop.Value); + } + return outObj; + case JTokenType.Array: + var arr = value as JArray; + var outArr = new JArray(); + foreach (var item in arr) + { + outArr.Add(ResolveAliasValue(item)); + } + return outArr; + default: + return value; + } + } + + foreach (var opToken in opsToken) + { + var op = opToken as JObject; + if (op == null) + { + results.Add(new Dictionary + { + ["id"] = "unknown", + ["success"] = false, + ["message"] = "Invalid op format" + }); + failed++; + if (mode == "stop_on_error") break; + continue; + } + + string opId = op["id"]?.ToString() ?? "unknown"; + string opAction = op["action"]?.ToString()?.ToLower(); + bool allowFailure = op["allowFailure"]?.ToObject() ?? false; + string captureAs = op["captureAs"]?.ToString(); + + if (string.IsNullOrEmpty(opAction)) + { + results.Add(new Dictionary + { + ["id"] = opId, + ["success"] = false, + ["message"] = "Op action is required" + }); + failed++; + if (mode == "stop_on_error" && !allowFailure) break; + continue; + } + + if (opAction == "batch" || opAction == "create_batch" || opAction == "edit_batch") + { + return Response.Error($"Op '{opId}' invalid: nested batch is not allowed"); + } + + bool isWriteOp = writeOps.Contains(opAction); + + if (!isWriteOp) + { + // Phase 1: search only + if (opAction != "search") + { + return Response.Error( + $"Op '{opId}' invalid: edit_batch only supports read op action 'search' before write ops." + ); + } + if (seenWrite) + { + return Response.Error( + $"Op '{opId}' invalid: edit_batch requires all 'search' ops to come before write ops." + ); + } + if (allowFailure) + { + return Response.Error( + $"Op '{opId}' invalid: allowFailure is not supported for search ops in edit_batch." + ); + } + if (string.IsNullOrEmpty(captureAs) || !captureAs.StartsWith("$")) + { + return Response.Error( + $"Op '{opId}' invalid: search op must provide captureAs starting with '$' (e.g. '$asset')." + ); + } + if (aliases.ContainsKey(captureAs)) + { + return Response.Error($"Duplicate captureAs alias: {captureAs}"); + } + + var opParams = op["params"] as JObject ?? new JObject(); + opParams["action"] = "search"; + + // Enforce determinism: pageSize<=2, pageNumber==1 (we need to detect ambiguity) + int pageSize = opParams["pageSize"]?.ToObject() + ?? opParams["page_size"]?.ToObject() + ?? 2; + int pageNumber = opParams["pageNumber"]?.ToObject() + ?? opParams["page_number"]?.ToObject() + ?? 1; + if (pageSize > 2) + { + return Response.Error($"Op '{opId}' invalid: edit_batch search pageSize must be <= 2 for determinism."); + } + if (pageNumber != 1) + { + return Response.Error($"Op '{opId}' invalid: edit_batch search pageNumber must be 1 for determinism."); + } + opParams["pageSize"] = 2; + opParams["pageNumber"] = 1; + + var opResult = HandleCommand(opParams); + + bool opSuccess = true; + string opMessage = "Success"; + string opCode = null; + object opData = null; + object opStateDelta = null; + + if (opResult is Dictionary resultDict) + { + if (resultDict.TryGetValue("success", out var successObj) && successObj is bool s) + opSuccess = s; + if (resultDict.TryGetValue("message", out var msgObj)) + opMessage = msgObj?.ToString(); + if (resultDict.TryGetValue("code", out var codeObj)) + opCode = codeObj?.ToString(); + if (resultDict.TryGetValue("state_delta", out var sd)) + opStateDelta = sd; + if (resultDict.TryGetValue("data", out var dataObj)) + opData = dataObj; + else + opData = resultDict; + } + else + { + opData = opResult; + try + { + var sdProp = opResult?.GetType()?.GetProperty("state_delta"); + if (sdProp != null) opStateDelta = sdProp.GetValue(opResult); + } + catch { } + } + + if (opStateDelta != null) stateDeltas.Add(opStateDelta); + + var resultEntry = new Dictionary + { + ["id"] = opId, + ["action"] = "search", + ["success"] = opSuccess, + ["message"] = opMessage + }; + if (opCode != null) resultEntry["code"] = opCode; + if (opData != null) resultEntry["data"] = opData; + results.Add(resultEntry); + + if (!opSuccess) + { + failed++; + if (mode == "stop_on_error") break; + continue; + } + + // Parse search response: data.totalAssets + data.assets[0].path + var dataJ = opData != null ? JObject.FromObject(opData) : null; + int totalAssets = dataJ?["totalAssets"]?.ToObject() ?? 0; + var assetsArr = dataJ?["assets"] as JArray; + + if (totalAssets == 0 || assetsArr == null || assetsArr.Count == 0) + { + var mergedDelta = stateDeltas.Count > 0 + ? StateComposer.MergeStateDeltas(stateDeltas.ToArray()) + : null; + + var early = new Dictionary + { + ["mode"] = mode, + ["status"] = "early_stop", + ["reason"] = "no_assets_found", + ["aliases"] = aliases, + ["summary"] = new Dictionary + { + ["total"] = results.Count, + ["succeeded"] = succeeded, + ["failed"] = failed + }, + ["results"] = results, + ["success"] = true, + ["message"] = "Unity Asset edit_batch early-stopped (0 assets found)." + }; + if (mergedDelta != null) early["state_delta"] = mergedDelta; + return early; + } + + if (totalAssets > 1) + { + return Response.Error( + $"Op '{opId}' invalid: edit_batch search must match <= 1 asset (got {totalAssets}). Narrow the query." + ); + } + + var firstAsset = assetsArr[0] as JObject; + var path = firstAsset?["path"]?.ToString(); + if (string.IsNullOrEmpty(path)) + { + return Response.Error( + $"Op '{opId}' invalid: edit_batch could not extract 'path' from search result." + ); + } + + aliases[captureAs] = path; + succeeded++; + searchOpsCount++; + continue; + } + + // Phase 2: write ops (must use aliases) + seenWrite = true; + writeOpsCount++; + + var rawParams = op["params"] as JObject ?? new JObject(); + var rawPathTok = rawParams["path"]; + if (rawPathTok != null && rawPathTok.Type == JTokenType.String) + { + var p = rawPathTok.ToString(); + if (!p.StartsWith("$")) + { + return Response.Error( + $"Op '{opId}' invalid: edit_batch write ops must use path '$alias' captured by a previous search op." + ); + } + } + if (rawParams["path"] is JObject refObj && refObj["ref"] != null) + { + var r = refObj["ref"]?.ToString() ?? ""; + if (!r.StartsWith("$")) + { + return Response.Error( + $"Op '{opId}' invalid: edit_batch write ops must use path '$alias' captured by a previous search op." + ); + } + } + + var opParamsWrite = rawParams != null ? (JObject)ResolveAliasValue(rawParams) : new JObject(); + opParamsWrite["action"] = opAction; + + try + { + var opResult = HandleCommand(opParamsWrite); + bool opSuccess = true; + string opMessage = "Success"; + string opCode = null; + object opData = null; + object opStateDelta = null; + + if (opResult is Dictionary resultDict) + { + if (resultDict.TryGetValue("success", out var successObj) && successObj is bool s) + opSuccess = s; + if (resultDict.TryGetValue("message", out var msgObj)) + opMessage = msgObj?.ToString(); + if (resultDict.TryGetValue("code", out var codeObj)) + opCode = codeObj?.ToString(); + if (resultDict.TryGetValue("state_delta", out var sd)) + opStateDelta = sd; + if (resultDict.TryGetValue("data", out var dataObj)) + opData = dataObj; + else + opData = resultDict; + } + else + { + opData = opResult; + try + { + var sdProp = opResult?.GetType()?.GetProperty("state_delta"); + if (sdProp != null) opStateDelta = sdProp.GetValue(opResult); + } + catch { } + } + + if (opStateDelta != null) stateDeltas.Add(opStateDelta); + + var resultEntry = new Dictionary + { + ["id"] = opId, + ["action"] = opAction, + ["success"] = opSuccess, + ["message"] = opMessage + }; + if (opCode != null) resultEntry["code"] = opCode; + if (opData != null) resultEntry["data"] = opData; + results.Add(resultEntry); + + if (opSuccess) + succeeded++; + else + { + failed++; + if (mode == "stop_on_error" && !allowFailure) break; + } + } + catch (Exception e) + { + results.Add(new Dictionary + { + ["id"] = opId, + ["action"] = opAction, + ["success"] = false, + ["message"] = e.Message, + ["code"] = "exception" + }); + failed++; + if (mode == "stop_on_error" && !allowFailure) break; + } + } + + if (searchOpsCount == 0) + { + return Response.Error("edit_batch requires at least one search op with captureAs before write ops."); + } + if (writeOpsCount == 0) + { + return Response.Error("edit_batch requires at least one write op after search ops."); + } + + var merged = stateDeltas.Count > 0 + ? StateComposer.MergeStateDeltas(stateDeltas.ToArray()) + : null; + + bool success = failed == 0; + var message = success + ? "Unity Asset edit_batch completed successfully." + : "Unity Asset edit_batch completed with errors."; + + var response = new Dictionary + { + ["mode"] = mode, + ["aliases"] = aliases, + ["summary"] = new Dictionary + { + ["total"] = opsToken.Count, + ["succeeded"] = succeeded, + ["failed"] = failed + }, + ["results"] = results, + ["success"] = success, + ["message"] = message + }; + + if (!success) + { + response["code"] = "edit_batch_failed"; + response["error"] = message; + } + + if (merged != null) + { + response["state_delta"] = merged; + } + + return response; + } + + /// + /// Expand create_folder ops to include missing parent folders, shallow → deep. + /// This keeps behavior consistent with the TypeScript client preprocessor. + /// + private static JArray ExpandCreateFolderParentsInBatch(JArray opsToken) + { + // Collect existing create_folder targets + var existingTargets = new HashSet(StringComparer.OrdinalIgnoreCase); + var createFolderTargets = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var token in opsToken) + { + var op = token as JObject; + if (op == null) continue; + var opAction = op["action"]?.ToString()?.ToLower(); + if (opAction != "create_folder") continue; + var opParams = op["params"] as JObject; + var path = opParams?["path"]?.ToString(); + if (string.IsNullOrEmpty(path) || !path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) continue; + var cleaned = path.TrimEnd('/').Replace("\\", "/"); + existingTargets.Add(cleaned); + createFolderTargets.Add(cleaned); + } + + if (createFolderTargets.Count == 0) return opsToken; + + var parentsToEnsure = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var p in createFolderTargets) + { + var parts = p.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + // parts[0] should be "Assets" + for (int i = 2; i < parts.Length; i++) + { + var parent = string.Join("/", parts.Take(i)); + if (parent.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + parentsToEnsure.Add(parent); + } + } + } + + var missingParents = parentsToEnsure + // Skip parents that already exist on disk (coverage tests often create a root test folder up-front) + .Where(p => !existingTargets.Contains(p) && !AssetDatabase.IsValidFolder(p)) + .OrderBy(p => p.Split('/').Length) + .ToList(); + + if (missingParents.Count == 0) return opsToken; + + var expanded = new JArray(); + foreach (var parent in missingParents) + { + var ensureOp = new JObject + { + ["id"] = $"ensure-folder:{parent}", + ["action"] = "create_folder", + ["params"] = new JObject { ["path"] = parent }, + }; + expanded.Add(ensureOp); + } + foreach (var op in opsToken) + { + expanded.Add(op); + } + + return expanded; + } + } +} + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageAsset.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageAsset.cs.meta new file mode 100644 index 000000000..6de7a3d86 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageAsset.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d58d03981d97594b80e043a0ba78f55 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageBake.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageBake.cs new file mode 100644 index 000000000..1fb2b5ec9 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageBake.cs @@ -0,0 +1,740 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Codely.Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.PackageManager; +using UnityEngine; +using UnityTcp.Editor.Helpers; + +namespace UnityTcp.Editor.Tools +{ + /// + /// [EXPERIMENTAL] Handles baking operations (NavMesh, Lighting, etc.). + /// Compatible with Unity 2022.3 LTS. + /// + public static class ManageBake + { + // Store callbacks for proper unsubscription + private static readonly Dictionary _updateCallbacks = new Dictionary(); + private static readonly object _callbackLock = new object(); + // Store async operations for NavMesh baking + private static readonly Dictionary> _navMeshBakeOperations = new Dictionary>(); + + // Runtime check for AI Navigation package availability + private static bool? _hasAINavigation = null; + private static Type _navMeshSurfaceType = null; + private static MethodInfo _buildNavMeshMethod = null; + private static MethodInfo _updateNavMeshMethod = null; + private static PropertyInfo _activeSurfacesProperty = null; + private static Type _navMeshType = null; + private static MethodInfo _calculateTriangulationMethod = null; + private static MethodInfo _removeAllNavMeshDataMethod = null; + + /// + /// Reset the AI Navigation package cache. Call this after installing the package + /// to force re-checking for available types. + /// + private static void ResetAINavigationCache() + { + _hasAINavigation = null; + _navMeshSurfaceType = null; + _buildNavMeshMethod = null; + _updateNavMeshMethod = null; + _activeSurfacesProperty = null; + _navMeshType = null; + _calculateTriangulationMethod = null; + _removeAllNavMeshDataMethod = null; + } + + private static bool HasAINavigation() + { + if (_hasAINavigation.HasValue) + return _hasAINavigation.Value; + + try + { + // First, check if the package is installed via PackageManager + bool packageInstalled = false; + try + { +#if UNITY_2021_2_OR_NEWER + // Use GetAllRegisteredPackages for Unity 2021.2+ + var packages = UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages(); + packageInstalled = packages.Any(p => p.name == "com.unity.ai.navigation"); +#else + // Fallback for older Unity versions + var listRequest = Client.List(true, false); + while (!listRequest.IsCompleted) + { + System.Threading.Thread.Sleep(50); + } + if (listRequest.Status == StatusCode.Success) + { + packageInstalled = listRequest.Result.Any(p => p.name == "com.unity.ai.navigation"); + } +#endif + } + catch (Exception ex) + { + Debug.LogWarning($"[ManageBake] Error checking package installation: {ex.Message}"); + // Continue with type checking as fallback + } + + // Try to find NavMeshSurface type (Unity.AI.Navigation namespace from com.unity.ai.navigation package) + // Try multiple methods to find the type + _navMeshSurfaceType = Type.GetType("Unity.AI.Navigation.NavMeshSurface, Unity.AI.Navigation"); + + if (_navMeshSurfaceType == null) + { + // Try with full assembly qualified name variations + _navMeshSurfaceType = Type.GetType("Unity.AI.Navigation.NavMeshSurface, Unity.AI.Navigation, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"); + } + + if (_navMeshSurfaceType == null) + { + // Fallback: search in loaded assemblies by name first + System.Reflection.Assembly targetAssembly = null; + foreach (var assembly in System.AppDomain.CurrentDomain.GetAssemblies()) + { + var assemblyName = assembly.GetName().Name; + if (assemblyName == "Unity.AI.Navigation" || assemblyName.Contains("Unity.AI.Navigation")) + { + targetAssembly = assembly; + break; + } + } + + if (targetAssembly != null) + { + _navMeshSurfaceType = targetAssembly.GetType("Unity.AI.Navigation.NavMeshSurface"); + } + } + + if (_navMeshSurfaceType == null) + { + // Last resort: search all assemblies + foreach (var assembly in System.AppDomain.CurrentDomain.GetAssemblies()) + { + _navMeshSurfaceType = assembly.GetType("Unity.AI.Navigation.NavMeshSurface"); + if (_navMeshSurfaceType != null) break; + } + } + + if (_navMeshSurfaceType != null) + { + _buildNavMeshMethod = _navMeshSurfaceType.GetMethod("BuildNavMesh", BindingFlags.Public | BindingFlags.Instance); + _updateNavMeshMethod = _navMeshSurfaceType.GetMethod("UpdateNavMesh", BindingFlags.Public | BindingFlags.Instance); + _activeSurfacesProperty = _navMeshSurfaceType.GetProperty("activeSurfaces", BindingFlags.Public | BindingFlags.Static); + } + + // Try to find NavMesh type (UnityEngine.AI namespace - still used by the package) + _navMeshType = Type.GetType("UnityEngine.AI.NavMesh, UnityEngine.AIModule"); + if (_navMeshType == null) + { + foreach (var assembly in System.AppDomain.CurrentDomain.GetAssemblies()) + { + _navMeshType = assembly.GetType("UnityEngine.AI.NavMesh"); + if (_navMeshType != null) break; + } + } + + if (_navMeshType != null) + { + _calculateTriangulationMethod = _navMeshType.GetMethod("CalculateTriangulation", BindingFlags.Public | BindingFlags.Static); + _removeAllNavMeshDataMethod = _navMeshType.GetMethod("RemoveAllNavMeshData", BindingFlags.Public | BindingFlags.Static); + } + + // Check both package installation and required types/methods + bool hasRequiredTypes = _navMeshSurfaceType != null && _buildNavMeshMethod != null && _navMeshType != null; + + // If package is installed but types are missing, check compilation status + if (packageInstalled && !hasRequiredTypes) + { + bool isCompiling = EditorApplication.isCompiling; + string compilationStatus = isCompiling ? "compiling" : "idle"; + + // Collect diagnostic information + var loadedAssemblies = System.AppDomain.CurrentDomain.GetAssemblies() + .Where(a => a.GetName().Name.Contains("AI") || a.GetName().Name.Contains("Navigation")) + .Select(a => a.GetName().Name) + .ToList(); + + string diagnosticInfo = ""; + if (loadedAssemblies.Count > 0) + { + diagnosticInfo = $" Found related assemblies: {string.Join(", ", loadedAssemblies)}."; + } + else + { + diagnosticInfo = " No AI/Navigation assemblies found in loaded assemblies."; + } + + string typeStatus = ""; + if (_navMeshSurfaceType == null) + { + typeStatus += " NavMeshSurface type not found."; + } + else + { + typeStatus += $" NavMeshSurface found, but methods missing: BuildNavMesh={_buildNavMeshMethod != null}, UpdateNavMesh={_updateNavMeshMethod != null}, activeSurfaces={_activeSurfacesProperty != null}."; + } + + if (_navMeshType == null) + { + typeStatus += " NavMesh type not found."; + } + + Debug.LogWarning( + $"[ManageBake] com.unity.ai.navigation package is installed but required types/methods are not available. " + + $"Editor is currently {compilationStatus}.{diagnosticInfo}{typeStatus} " + + (isCompiling + ? "Please wait for compilation to complete, then call 'unity_editor { \"action\": \"wait_for_idle\" }' before retrying." + : "The package may need to be reloaded. Try restarting Unity or wait a moment and retry.") + ); + } + + // Package installation check is primary, but we also need the types to be available + // If package is installed but types are missing and we're not compiling, return false + // If we're compiling, also return false (types won't be available until compilation completes) + _hasAINavigation = packageInstalled && hasRequiredTypes && !EditorApplication.isCompiling; + } + catch (Exception ex) + { + Debug.LogWarning($"[ManageBake] Error checking for AI Navigation package: {ex.Message}"); + _hasAINavigation = false; + } + + return _hasAINavigation.Value; + } + + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString().ToLower(); + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + + try + { + switch (action) + { + case "bake_navmesh": + return BakeNavMesh(@params); + case "bake_lighting": + return BakeLighting(@params); + case "wait_for_bake": + return WaitForBake(@params); + case "clear_navmesh": + return ClearNavMesh(); + case "clear_baked_data": + return ClearBakedData(); + default: + return Response.Error( + $"Unknown action: '{action}'. Valid actions: bake_navmesh, bake_lighting, wait_for_bake, clear_navmesh, clear_baked_data." + ); + } + } + catch (Exception e) + { + Debug.LogError($"[ManageBake] Action '{action}' failed: {e}"); + return Response.Error($"[EXPERIMENTAL] Bake operation failed: {e.Message}"); + } + } + + private static object BakeNavMesh(JObject @params) + { + try + { + // Reset cache and re-check if first check fails (in case package was just installed) + if (!HasAINavigation()) + { + ResetAINavigationCache(); + if (!HasAINavigation()) + { + // Check if package is installed but types are not available + bool packageInstalled = false; + try + { +#if UNITY_2021_2_OR_NEWER + var packages = UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages(); + packageInstalled = packages.Any(p => p.name == "com.unity.ai.navigation"); +#else + var listRequest = Client.List(true, false); + while (!listRequest.IsCompleted) + { + System.Threading.Thread.Sleep(50); + } + if (listRequest.Status == StatusCode.Success) + { + packageInstalled = listRequest.Result.Any(p => p.name == "com.unity.ai.navigation"); + } +#endif + } + catch { } + + bool isCompiling = EditorApplication.isCompiling; + + string errorMessage; + if (packageInstalled && isCompiling) + { + errorMessage = + "[EXPERIMENTAL] NavMesh baking requires AI Navigation package types to be loaded. " + + "The package is installed but Unity is currently compiling. " + + "Please wait for compilation to complete by calling 'unity_editor { \"action\": \"wait_for_idle\" }', then retry."; + } + else if (packageInstalled) + { + errorMessage = + "[EXPERIMENTAL] NavMesh baking requires AI Navigation package types to be loaded. " + + "The package 'com.unity.ai.navigation' is installed but required types are not available. " + + "This may happen if: (1) compilation is in progress, (2) the package needs to be reloaded, or (3) Unity needs to be restarted. " + + "Try: (1) Call 'unity_editor { \"action\": \"wait_for_idle\" }' to ensure compilation is complete, " + + "(2) Wait a few seconds and retry, or (3) Restart Unity."; + } + else + { + errorMessage = + "[EXPERIMENTAL] NavMesh baking requires AI Navigation package. " + + "Install 'com.unity.ai.navigation' via Package Manager using: " + + "'unity_package { \"action\": \"install_package\", \"id_or_url\": \"com.unity.ai.navigation\" }', " + + "then wait for installation and compilation to complete using 'unity_editor { \"action\": \"wait_for_idle\" }'."; + } + + return Response.Error(errorMessage); + } + } + + var writeCheck = WriteGuard.CheckWriteAllowed("bake_navmesh"); + if (writeCheck != null) return writeCheck; + + var job = AsyncOperationTracker.CreateJob( + AsyncOperationTracker.JobType.NavMeshBake, + "Baking NavMesh..." + ); + + // Get all active NavMeshSurface components in the scene + List surfaces = new List(); + if (_activeSurfacesProperty != null) + { + var activeSurfaces = _activeSurfacesProperty.GetValue(null); + if (activeSurfaces is System.Collections.IList surfaceList) + { + foreach (var surface in surfaceList) + { + surfaces.Add(surface); + } + } + } + + if (surfaces.Count == 0) + { + // Fallback: find all NavMeshSurface components using Resources.FindObjectsOfTypeAll + if (_navMeshSurfaceType != null) + { + var allObjects = Resources.FindObjectsOfTypeAll(_navMeshSurfaceType); + foreach (var obj in allObjects) + { + if (obj != null) + { + surfaces.Add(obj); + } + } + } + } + + if (surfaces.Count == 0) + { + return Response.Error("[EXPERIMENTAL] No NavMeshSurface components found in the scene. Add a NavMeshSurface component to a GameObject to bake NavMesh."); + } + + // Check if we should use async baking (UpdateNavMesh) or sync baking (BuildNavMesh) + bool useAsync = @params["async"]?.ToObject() ?? false; + List asyncOps = new List(); + + if (useAsync && _updateNavMeshMethod != null) + { + // Use async UpdateNavMesh for each surface that has existing data + foreach (var surface in surfaces) + { + try + { + var navMeshDataProperty = _navMeshSurfaceType.GetProperty("navMeshData"); + if (navMeshDataProperty != null) + { + var navMeshData = navMeshDataProperty.GetValue(surface); + if (navMeshData != null) + { + var asyncOp = _updateNavMeshMethod.Invoke(surface, new object[] { navMeshData }) as AsyncOperation; + if (asyncOp != null) + { + asyncOps.Add(asyncOp); + } + } + } + } + catch + { + // If UpdateNavMesh fails, fall back to BuildNavMesh + } + } + } + + // If no async operations were started, use synchronous BuildNavMesh + if (asyncOps.Count == 0) + { + foreach (var surface in surfaces) + { + _buildNavMeshMethod?.Invoke(surface, null); + } + + // For synchronous baking, complete immediately + bool hasNavMeshData = false; + try + { + // First check NavMeshSurface components for navMeshData + if (_navMeshSurfaceType != null) + { + var navMeshDataProperty = _navMeshSurfaceType.GetProperty("navMeshData"); + if (navMeshDataProperty != null) + { + foreach (var surface in surfaces) + { + if (surface != null) + { + var navMeshData = navMeshDataProperty.GetValue(surface); + if (navMeshData != null) + { + hasNavMeshData = true; + break; + } + } + } + } + } + + // Fallback: check global NavMesh if NavMeshSurface check didn't find anything + if (!hasNavMeshData && _calculateTriangulationMethod != null) + { + var triangulation = _calculateTriangulationMethod.Invoke(null, null); + if (triangulation != null) + { + var verticesProperty = triangulation.GetType().GetProperty("vertices"); + if (verticesProperty != null) + { + var vertices = verticesProperty.GetValue(triangulation) as Array; + hasNavMeshData = vertices != null && vertices.Length > 0; + } + } + } + } + catch { } + + AsyncOperationTracker.CompleteJob(job.OpId, "NavMesh baking completed", new + { + hasNavMeshData = hasNavMeshData, + surfacesBaked = surfaces.Count + }); + StateComposer.IncrementRevision(); + + return AsyncOperationTracker.CreateCompleteResponse(job); + } + else + { + // Store async operations for tracking + lock (_callbackLock) + { + _navMeshBakeOperations[job.OpId] = asyncOps; + } + + // Create and store callback delegate for proper unsubscription + EditorApplication.CallbackFunction callback = () => CheckNavMeshBake(job.OpId); + lock (_callbackLock) + { + _updateCallbacks[job.OpId] = callback; + } + EditorApplication.update += callback; + + // Return standardized pending response + var response = AsyncOperationTracker.CreatePendingResponse(job) as Dictionary; + response["poll_interval"] = 2.0; + response["message"] = "[EXPERIMENTAL] NavMesh baking started (async)"; + response["data"] = new { type = "navmesh", surfacesCount = surfaces.Count }; + return response; + } + } + catch (Exception e) + { + return Response.Error($"[EXPERIMENTAL] Failed to start NavMesh baking: {e.Message}"); + } + } + + private static void CheckNavMeshBake(string opId) + { + if (!HasAINavigation()) + return; + + try + { + // Check if async operations are still running + List asyncOps = null; + lock (_callbackLock) + { + if (_navMeshBakeOperations.TryGetValue(opId, out asyncOps)) + { + // Check if all operations are done + bool allDone = asyncOps.All(op => op != null && op.isDone); + + if (allDone) + { + // Remove from tracking + _navMeshBakeOperations.Remove(opId); + + // Properly unsubscribe using stored delegate + if (_updateCallbacks.TryGetValue(opId, out var callback)) + { + EditorApplication.update -= callback; + _updateCallbacks.Remove(opId); + } + + // Check if NavMesh data exists using reflection + bool hasNavMeshData = false; + try + { + // First check NavMeshSurface components for navMeshData + if (_navMeshSurfaceType != null) + { + var navMeshDataProperty = _navMeshSurfaceType.GetProperty("navMeshData"); + if (navMeshDataProperty != null && _activeSurfacesProperty != null) + { + var activeSurfaces = _activeSurfacesProperty.GetValue(null); + if (activeSurfaces is System.Collections.IList surfaceList) + { + foreach (var surface in surfaceList) + { + if (surface != null) + { + var navMeshData = navMeshDataProperty.GetValue(surface); + if (navMeshData != null) + { + hasNavMeshData = true; + break; + } + } + } + } + } + } + + // Fallback: check global NavMesh if NavMeshSurface check didn't find anything + if (!hasNavMeshData && _calculateTriangulationMethod != null) + { + var triangulation = _calculateTriangulationMethod.Invoke(null, null); + if (triangulation != null) + { + var verticesProperty = triangulation.GetType().GetProperty("vertices"); + if (verticesProperty != null) + { + var vertices = verticesProperty.GetValue(triangulation) as Array; + hasNavMeshData = vertices != null && vertices.Length > 0; + } + } + } + } + catch + { + // If we can't check, assume no data + hasNavMeshData = false; + } + + AsyncOperationTracker.CompleteJob(opId, "NavMesh baking completed", new + { + hasNavMeshData = hasNavMeshData, + surfacesBaked = asyncOps.Count + }); + + StateComposer.IncrementRevision(); + } + } + } + } + catch (Exception ex) + { + Debug.LogError($"[ManageBake] Error in CheckNavMeshBake: {ex.Message}"); + } + } + + private static object BakeLighting(JObject @params) + { + try + { + var writeCheck = WriteGuard.CheckWriteAllowed("bake_lighting"); + if (writeCheck != null) return writeCheck; + + var job = AsyncOperationTracker.CreateJob( + AsyncOperationTracker.JobType.LightingBake, + "Baking lighting..." + ); + + // Start async bake + Lightmapping.BakeAsync(); + + // Create and store callback delegate for proper unsubscription + EditorApplication.CallbackFunction callback = () => CheckLightingBake(job.OpId); + lock (_callbackLock) + { + _updateCallbacks[job.OpId] = callback; + } + EditorApplication.update += callback; + + // Return standardized pending response + var response = AsyncOperationTracker.CreatePendingResponse(job) as Dictionary; + response["poll_interval"] = 2.0; + response["message"] = "[EXPERIMENTAL] Lighting baking started"; + response["data"] = new { type = "lighting" }; + return response; + } + catch (Exception e) + { + return Response.Error($"[EXPERIMENTAL] Failed to start lighting baking: {e.Message}"); + } + } + + private static void CheckLightingBake(string opId) + { + if (!Lightmapping.isRunning) + { + // Properly unsubscribe using stored delegate + EditorApplication.CallbackFunction callback; + lock (_callbackLock) + { + if (_updateCallbacks.TryGetValue(opId, out callback)) + { + EditorApplication.update -= callback; + _updateCallbacks.Remove(opId); + } + } + + AsyncOperationTracker.CompleteJob(opId, "Lighting baking completed", new + { + hasLightingData = Lightmapping.lightingDataAsset != null + }); + + StateComposer.IncrementRevision(); + } + } + + private static object WaitForBake(JObject @params) + { + try + { + string opId = @params["op_id"]?.ToString(); + int timeoutSeconds = @params["timeoutSeconds"]?.ToObject() ?? 600; + + if (string.IsNullOrEmpty(opId)) + return Response.Error("'op_id' parameter required for wait_for_bake."); + + var job = AsyncOperationTracker.GetJob(opId); + if (job == null) + return Response.Error($"Operation {opId} not found."); + + if (job.Type != AsyncOperationTracker.JobType.NavMeshBake && + job.Type != AsyncOperationTracker.JobType.LightingBake) + return Response.Error($"Operation {opId} is not a bake operation."); + + if (AsyncOperationTracker.IsJobTimedOut(opId, timeoutSeconds)) + { + AsyncOperationTracker.FailJob(opId, $"Bake operation timed out after {timeoutSeconds} seconds"); + return AsyncOperationTracker.CreateErrorResponse(job); + } + + switch (job.Status) + { + case AsyncOperationTracker.JobStatus.Complete: + var response = AsyncOperationTracker.CreateCompleteResponse(job); + return response; + case AsyncOperationTracker.JobStatus.Error: + return AsyncOperationTracker.CreateErrorResponse(job); + case AsyncOperationTracker.JobStatus.Pending: + return AsyncOperationTracker.CreatePendingResponse(job); + default: + return Response.Error($"Unknown job status: {job.Status}"); + } + } + catch (Exception e) + { + return Response.Error($"[EXPERIMENTAL] Failed to wait for bake: {e.Message}"); + } + } + + private static object ClearNavMesh() + { + try + { + if (!HasAINavigation()) + { + return Response.Error("[EXPERIMENTAL] NavMesh operations require AI Navigation package."); + } + + var writeCheck = WriteGuard.CheckWriteAllowed("clear_navmesh"); + if (writeCheck != null) return writeCheck; + + // Clear NavMesh using reflection - try RemoveAllNavMeshData first + int clearedCount = 0; + if (_removeAllNavMeshDataMethod != null) + { + _removeAllNavMeshDataMethod.Invoke(null, null); + clearedCount++; + } + + // Also clear all NavMeshSurface components + if (_activeSurfacesProperty != null) + { + var activeSurfaces = _activeSurfacesProperty.GetValue(null); + if (activeSurfaces is System.Collections.IList surfaceList) + { + var removeDataMethod = _navMeshSurfaceType.GetMethod("RemoveData", BindingFlags.Public | BindingFlags.Instance); + foreach (var surface in surfaceList) + { + try + { + removeDataMethod?.Invoke(surface, null); + clearedCount++; + } + catch { } + } + } + } + + StateComposer.IncrementRevision(); + + return Response.Success($"[EXPERIMENTAL] NavMesh data cleared ({clearedCount} surfaces)."); + } + catch (Exception e) + { + return Response.Error($"[EXPERIMENTAL] Failed to clear NavMesh: {e.Message}"); + } + } + + private static object ClearBakedData() + { + try + { + var writeCheck = WriteGuard.CheckWriteAllowed("clear_baked_data"); + if (writeCheck != null) return writeCheck; + + Lightmapping.Clear(); + StateComposer.IncrementRevision(); + + return Response.Success("[EXPERIMENTAL] Baked lighting data cleared."); + } + catch (Exception e) + { + return Response.Error($"[EXPERIMENTAL] Failed to clear baked data: {e.Message}"); + } + } + } +} + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageBake.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageBake.cs.meta new file mode 100644 index 000000000..c690df7c9 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageBake.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 16bc91f90f3df674486759e40dffb088 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageEditor.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageEditor.cs new file mode 100644 index 000000000..e5e90d4f1 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageEditor.cs @@ -0,0 +1,1330 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.IO; +using Codely.Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditorInternal; // Required for tag management +using UnityEngine; +using UnityTcp.Editor.Helpers; // For Response class + +namespace UnityTcp.Editor.Tools +{ + /// + /// Handles operations related to controlling and querying the Unity Editor state, + /// including managing Tags and Layers, and compilation workflow. + /// Compatible with Unity 2022.3 LTS. + /// + public static class ManageEditor + { + // Constant for starting user layer index + private const int FirstUserLayerIndex = 8; + + // Constant for total layer count + private const int TotalLayerCount = 32; + + // Compilation event tracking + private static bool _compilationCallbackRegistered = false; + private static readonly object _compilationLock = new object(); + + // Idle wait tracking + private static readonly Dictionary _idleCallbacks = new Dictionary(); + private static readonly object _idleLock = new object(); + + /// + /// Main handler for editor management actions. + /// + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString().ToLower(); + // Parameters for specific actions + string tagName = @params["tagName"]?.ToString(); + string layerName = @params["layerName"]?.ToString(); + bool waitForCompletion = @params["waitForCompletion"]?.ToObject() ?? false; // Example - not used everywhere + + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + + // Ensure compilation callbacks are registered + EnsureCompilationCallbacksRegistered(); + + // Route action + switch (action) + { + // Full State Retrieval (API Spec aligned) + case "get_current_state": + return GetCurrentState(); + + // Compilation Management + case "request_compile": + return RequestCompile(); + case "start_compilation_pipeline": + // Standard pipeline: clear console → request compile → return token + return CompilationHelper.StartCompilationPipeline(); + case "wait_for_compile": + string opId = @params["op_id"]?.ToString(); + int timeoutSeconds = @params["timeoutSeconds"]?.ToObject() ?? 60; + string sinceToken = @params["since_token"]?.ToString(); + if (string.IsNullOrEmpty(opId)) + return Response.Error("'op_id' parameter required for wait_for_compile."); + return WaitForCompile(opId, timeoutSeconds, sinceToken); + case "get_compilation_summary": + return CompilationHelper.GetCompilationSummary(); + case "wait_for_idle": + int idleTimeout = @params["timeoutSeconds"]?.ToObject() ?? 600; + return WaitForIdle(idleTimeout); + + // Play Mode Control + case "play": + try + { + if (!EditorApplication.isPlaying) + { + EditorApplication.isPlaying = true; + // Include updated playMode so clients can sync state + return Response.Success("Entered play mode.", new + { + playMode = "playing" + }); + } + return Response.Success("Already in play mode.", new + { + playMode = "playing" + }); + } + catch (Exception e) + { + return Response.Error($"Error entering play mode: {e.Message}"); + } + case "pause": + try + { + if (EditorApplication.isPlaying) + { + EditorApplication.isPaused = !EditorApplication.isPaused; + var isPaused = EditorApplication.isPaused; + return Response.Success( + isPaused ? "Game paused." : "Game resumed.", + new + { + playMode = isPaused ? "paused" : "playing" + } + ); + } + return Response.Error("Cannot pause/resume: Not in play mode."); + } + catch (Exception e) + { + return Response.Error($"Error pausing/resuming game: {e.Message}"); + } + case "stop": + try + { + if (EditorApplication.isPlaying) + { + EditorApplication.isPlaying = false; + return Response.Success("Exited play mode.", new + { + playMode = "stopped" + }); + } + return Response.Success("Already stopped (not in play mode).", new + { + playMode = "stopped" + }); + } + catch (Exception e) + { + return Response.Error($"Error stopping play mode: {e.Message}"); + } + + // Editor State/Info + case "get_state": + return GetEditorState(); + case "get_project_root": + return GetProjectRoot(); + case "get_windows": + return GetEditorWindows(); + case "get_active_tool": + return GetActiveTool(); + case "get_selection": + return GetSelection(); + case "set_active_tool": + string toolName = @params["toolName"]?.ToString(); + if (string.IsNullOrEmpty(toolName)) + return Response.Error("'toolName' parameter required for set_active_tool."); + return SetActiveTool(toolName); + + // Tag Management + case "ensure_tag": + if (string.IsNullOrEmpty(tagName)) + return Response.Error("'tagName' parameter required for ensure_tag."); + return EnsureTag(tagName); + case "add_tag": + if (string.IsNullOrEmpty(tagName)) + return Response.Error("'tagName' parameter required for add_tag."); + return AddTag(tagName); + case "remove_tag": + if (string.IsNullOrEmpty(tagName)) + return Response.Error("'tagName' parameter required for remove_tag."); + return RemoveTag(tagName); + case "get_tags": + return GetTags(); // Helper to list current tags + + // Layer Management + case "ensure_layer": + if (string.IsNullOrEmpty(layerName)) + return Response.Error("'layerName' parameter required for ensure_layer."); + return EnsureLayer(layerName); + case "add_layer": + if (string.IsNullOrEmpty(layerName)) + return Response.Error("'layerName' parameter required for add_layer."); + return AddLayer(layerName); + case "remove_layer": + if (string.IsNullOrEmpty(layerName)) + return Response.Error("'layerName' parameter required for remove_layer."); + return RemoveLayer(layerName); + case "get_layers": + return GetLayers(); // Helper to list current layers + + // Window Focus + case "focus_window": + string windowType = @params["windowType"]?.ToString(); + if (string.IsNullOrEmpty(windowType)) + return Response.Error("'windowType' parameter required for focus_window."); + return FocusWindow(windowType); + + // --- Settings (Example) --- + // case "set_resolution": + // int? width = @params["width"]?.ToObject(); + // int? height = @params["height"]?.ToObject(); + // if (!width.HasValue || !height.HasValue) return Response.Error("'width' and 'height' parameters required."); + // return SetGameViewResolution(width.Value, height.Value); + // case "set_quality": + // // Handle string name or int index + // return SetQualityLevel(@params["qualityLevel"]); + + default: + return Response.Error( + $"Unknown action: '{action}'. Supported actions include: get_current_state, request_compile, wait_for_compile, wait_for_idle, play, pause, stop, get_state, get_project_root, get_windows, get_active_tool, get_selection, set_active_tool, add_tag, remove_tag, get_tags, add_layer, remove_layer, get_layers, focus_window." + ); + } + } + + // --- Full State Retrieval --- + + /// + /// Returns the complete UnityCurrentState snapshot. + /// This is the primary entry point for LLMs to understand the full editor state. + /// + private static object GetCurrentState() + { + try + { + // Build state and increment revision atomically + var fullState = StateComposer.BuildFullStateAndIncrement(); + + return new + { + success = true, + message = "Retrieved full Unity state snapshot.", + state = fullState + }; + } + catch (Exception e) + { + return Response.Error($"Error getting current state: {e.Message}"); + } + } + + // --- Editor State/Info Methods --- + private static object GetEditorState() + { + try + { + // Use StateComposer to build comprehensive state + var fullState = StateComposer.BuildFullState(); + + // Also include legacy fields for backward compatibility + var legacyData = new + { + isPlaying = EditorApplication.isPlaying, + isPaused = EditorApplication.isPaused, + isCompiling = EditorApplication.isCompiling, + isUpdating = EditorApplication.isUpdating, + applicationPath = EditorApplication.applicationPath, + applicationContentsPath = EditorApplication.applicationContentsPath, + timeSinceStartup = EditorApplication.timeSinceStartup, + }; + + return new + { + success = true, + message = "Retrieved editor state.", + data = legacyData, + state = fullState // NEW: Full state snapshot + }; + } + catch (Exception e) + { + return Response.Error($"Error getting editor state: {e.Message}"); + } + } + + private static object GetProjectRoot() + { + try + { + // Application.dataPath points to /Assets + string assetsPath = Application.dataPath.Replace('\\', '/'); + string projectRoot = Directory.GetParent(assetsPath)?.FullName.Replace('\\', '/'); + if (string.IsNullOrEmpty(projectRoot)) + { + return Response.Error("Could not determine project root from Application.dataPath"); + } + return Response.Success("Project root resolved.", new { projectRoot }); + } + catch (Exception e) + { + return Response.Error($"Error getting project root: {e.Message}"); + } + } + + private static object GetEditorWindows() + { + try + { + // Get all types deriving from EditorWindow + var windowTypes = AppDomain + .CurrentDomain.GetAssemblies() + .SelectMany(assembly => assembly.GetTypes()) + .Where(type => type.IsSubclassOf(typeof(EditorWindow))) + .ToList(); + + var openWindows = new List(); + + // Find currently open instances + // Resources.FindObjectsOfTypeAll seems more reliable than GetWindow for finding *all* open windows + EditorWindow[] allWindows = Resources.FindObjectsOfTypeAll(); + + foreach (EditorWindow window in allWindows) + { + if (window == null) + continue; // Skip potentially destroyed windows + + try + { + openWindows.Add( + new + { + title = window.titleContent.text, + typeName = window.GetType().FullName, + isFocused = EditorWindow.focusedWindow == window, + position = new + { + x = window.position.x, + y = window.position.y, + width = window.position.width, + height = window.position.height, + }, + instanceID = window.GetInstanceID(), + } + ); + } + catch (Exception ex) + { + Debug.LogWarning( + $"Could not get info for window {window.GetType().Name}: {ex.Message}" + ); + } + } + + return Response.Success("Retrieved list of open editor windows.", openWindows); + } + catch (Exception e) + { + return Response.Error($"Error getting editor windows: {e.Message}"); + } + } + + /// + /// Focuses an editor window by type name. + /// Supports common window names like "Console", "Inspector", "Hierarchy", "Project", "Scene", "Game". + /// + private static object FocusWindow(string windowType) + { + try + { + // Map common names to actual EditorWindow type names + var typeMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "Console", "UnityEditor.ConsoleWindow" }, + { "Inspector", "UnityEditor.InspectorWindow" }, + { "Hierarchy", "UnityEditor.SceneHierarchyWindow" }, + { "Project", "UnityEditor.ProjectBrowser" }, + { "Scene", "UnityEditor.SceneView" }, + { "Game", "UnityEditor.GameView" }, + { "Animator", "UnityEditor.Graphs.AnimatorControllerTool" }, + { "Animation", "UnityEditor.AnimationWindow" }, + { "Profiler", "UnityEditor.ProfilerWindow" }, + { "AssetStore", "UnityEditor.AssetStoreWindow" }, + { "PackageManager", "UnityEditor.PackageManager.UI.PackageManagerWindow" }, + { "Build", "UnityEditor.BuildPlayerWindow" }, + { "Lighting", "UnityEditor.LightingWindow" }, + { "Navigation", "UnityEditor.NavMeshEditorWindow" }, + { "Occlusion", "UnityEditor.OcclusionCullingWindow" }, + { "FrameDebugger", "UnityEditor.FrameDebuggerWindow" }, + { "AudioMixer", "UnityEditor.AudioMixerWindow" } + }; + + string fullTypeName = windowType; + if (typeMap.TryGetValue(windowType, out var mappedType)) + { + fullTypeName = mappedType; + } + + // Find all open windows + EditorWindow[] allWindows = Resources.FindObjectsOfTypeAll(); + EditorWindow targetWindow = null; + + foreach (EditorWindow window in allWindows) + { + if (window == null) continue; + + var winTypeName = window.GetType().FullName; + // Match by full type name, short type name, or title + if (winTypeName.Equals(fullTypeName, StringComparison.OrdinalIgnoreCase) || + winTypeName.EndsWith("." + windowType, StringComparison.OrdinalIgnoreCase) || + window.GetType().Name.Equals(windowType, StringComparison.OrdinalIgnoreCase) || + window.titleContent.text.Equals(windowType, StringComparison.OrdinalIgnoreCase)) + { + targetWindow = window; + break; + } + } + + if (targetWindow == null) + { + // Try to open the window if it's a known type + Type windowTypeObj = null; + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + windowTypeObj = assembly.GetType(fullTypeName); + if (windowTypeObj != null) break; + } + + if (windowTypeObj != null && typeof(EditorWindow).IsAssignableFrom(windowTypeObj)) + { + // Use GetWindow to open it + targetWindow = EditorWindow.GetWindow(windowTypeObj); + } + else + { + return Response.Error( + $"Window '{windowType}' not found. Available windows can be queried with get_windows action. " + + $"Common window types: Console, Inspector, Hierarchy, Project, Scene, Game, Animator, Animation, Profiler." + ); + } + } + + // Focus the window + targetWindow.Focus(); + + // Verify focus was successful + bool isFocused = EditorWindow.focusedWindow == targetWindow; + + return Response.Success( + $"Focused window: {targetWindow.titleContent.text} ({targetWindow.GetType().Name})", + new + { + windowType = targetWindow.GetType().FullName, + title = targetWindow.titleContent.text, + isFocused = isFocused, + instanceID = targetWindow.GetInstanceID() + } + ); + } + catch (Exception e) + { + return Response.Error($"Error focusing window '{windowType}': {e.Message}"); + } + } + + private static object GetActiveTool() + { + try + { + Tool currentTool = UnityEditor.Tools.current; + string toolName = currentTool.ToString(); // Enum to string + bool customToolActive = UnityEditor.Tools.current == Tool.Custom; // Check if a custom tool is active + string activeToolName = customToolActive + ? EditorTools.GetActiveToolName() + : toolName; // Get custom name if needed + + // Convert Unity types to serializable arrays to avoid self-referencing loop + var handleRot = UnityEditor.Tools.handleRotation.eulerAngles; + var handlePos = UnityEditor.Tools.handlePosition; + + var toolInfo = new + { + activeTool = activeToolName, + isCustom = customToolActive, + pivotMode = UnityEditor.Tools.pivotMode.ToString(), + pivotRotation = UnityEditor.Tools.pivotRotation.ToString(), + handleRotation = new float[] { handleRot.x, handleRot.y, handleRot.z }, + handlePosition = new float[] { handlePos.x, handlePos.y, handlePos.z }, + }; + + return Response.Success("Retrieved active tool information.", toolInfo); + } + catch (Exception e) + { + return Response.Error($"Error getting active tool: {e.Message}"); + } + } + + private static object SetActiveTool(string toolName) + { + try + { + Tool targetTool; + if (Enum.TryParse(toolName, true, out targetTool)) // Case-insensitive parse + { + // Check if it's a valid built-in tool + if (targetTool != Tool.None && targetTool <= Tool.Custom) // Tool.Custom is the last standard tool + { + UnityEditor.Tools.current = targetTool; + return Response.Success($"Set active tool to '{targetTool}'."); + } + else + { + return Response.Error( + $"Cannot directly set tool to '{toolName}'. It might be None, Custom, or invalid." + ); + } + } + else + { + // Potentially try activating a custom tool by name here if needed + // This often requires specific editor scripting knowledge for that tool. + return Response.Error( + $"Could not parse '{toolName}' as a standard Unity Tool (View, Move, Rotate, Scale, Rect, Transform, Custom)." + ); + } + } + catch (Exception e) + { + return Response.Error($"Error setting active tool: {e.Message}"); + } + } + + private static object GetSelection() + { + try + { + var selectionInfo = new + { + activeObject = Selection.activeObject?.name, + activeGameObject = Selection.activeGameObject?.name, + activeTransform = Selection.activeTransform?.name, + activeInstanceID = Selection.activeInstanceID, + count = Selection.count, + objects = Selection + .objects.Select(obj => new + { + name = obj?.name, + type = obj?.GetType().FullName, + instanceID = obj?.GetInstanceID(), + }) + .ToList(), + gameObjects = Selection + .gameObjects.Select(go => new + { + name = go?.name, + instanceID = go?.GetInstanceID(), + }) + .ToList(), + assetGUIDs = Selection.assetGUIDs, // GUIDs for selected assets in Project view + }; + + return Response.Success("Retrieved current selection details.", selectionInfo); + } + catch (Exception e) + { + return Response.Error($"Error getting selection: {e.Message}"); + } + } + + // --- Tag Management Methods --- + + private static object AddTag(string tagName) + { + if (string.IsNullOrWhiteSpace(tagName)) + return Response.Error("Tag name cannot be empty or whitespace."); + + // Check if tag already exists + if (InternalEditorUtility.tags.Contains(tagName)) + { + return Response.Error($"Tag '{tagName}' already exists."); + } + + try + { + // Add the tag using the internal utility + InternalEditorUtility.AddTag(tagName); + // Force save assets to ensure the change persists in the TagManager asset + AssetDatabase.SaveAssets(); + StateComposer.IncrementRevision(); + return Response.Success($"Tag '{tagName}' added successfully."); + } + catch (Exception e) + { + return Response.Error($"Failed to add tag '{tagName}': {e.Message}"); + } + } + + /// + /// Idempotent ensure tag - adds tag if not exists, returns success if already exists. + /// + private static object EnsureTag(string tagName) + { + if (string.IsNullOrWhiteSpace(tagName)) + return Response.Error("Tag name cannot be empty or whitespace."); + + // Check if tag already exists + if (InternalEditorUtility.tags.Contains(tagName)) + { + return new + { + success = true, + message = $"Tag '{tagName}' already exists.", + data = new { tagName = tagName, alreadyExists = true } + }; + } + + // Tag doesn't exist, add it + try + { + InternalEditorUtility.AddTag(tagName); + AssetDatabase.SaveAssets(); + StateComposer.IncrementRevision(); + return new + { + success = true, + message = $"Tag '{tagName}' created successfully.", + data = new { tagName = tagName, alreadyExists = false } + }; + } + catch (Exception e) + { + return Response.Error($"Failed to ensure tag '{tagName}': {e.Message}"); + } + } + + private static object RemoveTag(string tagName) + { + if (string.IsNullOrWhiteSpace(tagName)) + return Response.Error("Tag name cannot be empty or whitespace."); + if (tagName.Equals("Untagged", StringComparison.OrdinalIgnoreCase)) + return Response.Error("Cannot remove the built-in 'Untagged' tag."); + + // Check if tag exists before attempting removal + if (!InternalEditorUtility.tags.Contains(tagName)) + { + return Response.Error($"Tag '{tagName}' does not exist."); + } + + try + { + // Remove the tag using the internal utility + InternalEditorUtility.RemoveTag(tagName); + // Force save assets + AssetDatabase.SaveAssets(); + return Response.Success($"Tag '{tagName}' removed successfully."); + } + catch (Exception e) + { + // Catch potential issues if the tag is somehow in use or removal fails + return Response.Error($"Failed to remove tag '{tagName}': {e.Message}"); + } + } + + private static object GetTags() + { + try + { + string[] tags = InternalEditorUtility.tags; + return Response.Success("Retrieved current tags.", tags); + } + catch (Exception e) + { + return Response.Error($"Failed to retrieve tags: {e.Message}"); + } + } + + // --- Layer Management Methods --- + + private static object AddLayer(string layerName) + { + if (string.IsNullOrWhiteSpace(layerName)) + return Response.Error("Layer name cannot be empty or whitespace."); + + // Access the TagManager asset + SerializedObject tagManager = GetTagManager(); + if (tagManager == null) + return Response.Error("Could not access TagManager asset."); + + SerializedProperty layersProp = tagManager.FindProperty("layers"); + if (layersProp == null || !layersProp.isArray) + return Response.Error("Could not find 'layers' property in TagManager."); + + // Check if layer name already exists (case-insensitive check recommended) + for (int i = 0; i < TotalLayerCount; i++) + { + SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); + if ( + layerSP != null + && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) + ) + { + return Response.Error($"Layer '{layerName}' already exists at index {i}."); + } + } + + // Find the first empty user layer slot (indices 8 to 31) + int firstEmptyUserLayer = -1; + for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) + { + SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); + if (layerSP != null && string.IsNullOrEmpty(layerSP.stringValue)) + { + firstEmptyUserLayer = i; + break; + } + } + + if (firstEmptyUserLayer == -1) + { + return Response.Error("No empty User Layer slots available (8-31 are full)."); + } + + // Assign the name to the found slot + try + { + SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( + firstEmptyUserLayer + ); + targetLayerSP.stringValue = layerName; + // Apply the changes to the TagManager asset + tagManager.ApplyModifiedProperties(); + // Save assets to make sure it's written to disk + AssetDatabase.SaveAssets(); + StateComposer.IncrementRevision(); + return Response.Success( + $"Layer '{layerName}' added successfully to slot {firstEmptyUserLayer}." + ); + } + catch (Exception e) + { + return Response.Error($"Failed to add layer '{layerName}': {e.Message}"); + } + } + + /// + /// Idempotent ensure layer - adds layer if not exists, returns success if already exists. + /// + private static object EnsureLayer(string layerName) + { + if (string.IsNullOrWhiteSpace(layerName)) + return Response.Error("Layer name cannot be empty or whitespace."); + + // Access the TagManager asset + SerializedObject tagManager = GetTagManager(); + if (tagManager == null) + return Response.Error("Could not access TagManager asset."); + + SerializedProperty layersProp = tagManager.FindProperty("layers"); + if (layersProp == null || !layersProp.isArray) + return Response.Error("Could not find 'layers' property in TagManager."); + + // Check if layer already exists + for (int i = 0; i < TotalLayerCount; i++) + { + SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); + if (layerSP != null && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase)) + { + return new + { + success = true, + message = $"Layer '{layerName}' already exists at index {i}.", + data = new { layerName = layerName, layerIndex = i, alreadyExists = true } + }; + } + } + + // Find first empty user layer slot + int firstEmptyUserLayer = -1; + for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) + { + SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); + if (layerSP != null && string.IsNullOrEmpty(layerSP.stringValue)) + { + firstEmptyUserLayer = i; + break; + } + } + + if (firstEmptyUserLayer == -1) + { + return Response.Error("No empty User Layer slots available (8-31 are full)."); + } + + // Add the layer + try + { + SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex(firstEmptyUserLayer); + targetLayerSP.stringValue = layerName; + tagManager.ApplyModifiedProperties(); + AssetDatabase.SaveAssets(); + StateComposer.IncrementRevision(); + return new + { + success = true, + message = $"Layer '{layerName}' created at slot {firstEmptyUserLayer}.", + data = new { layerName = layerName, layerIndex = firstEmptyUserLayer, alreadyExists = false } + }; + } + catch (Exception e) + { + return Response.Error($"Failed to ensure layer '{layerName}': {e.Message}"); + } + } + + private static object RemoveLayer(string layerName) + { + if (string.IsNullOrWhiteSpace(layerName)) + return Response.Error("Layer name cannot be empty or whitespace."); + + // Access the TagManager asset + SerializedObject tagManager = GetTagManager(); + if (tagManager == null) + return Response.Error("Could not access TagManager asset."); + + SerializedProperty layersProp = tagManager.FindProperty("layers"); + if (layersProp == null || !layersProp.isArray) + return Response.Error("Could not find 'layers' property in TagManager."); + + // Find the layer by name (must be user layer) + int layerIndexToRemove = -1; + for (int i = FirstUserLayerIndex; i < TotalLayerCount; i++) // Start from user layers + { + SerializedProperty layerSP = layersProp.GetArrayElementAtIndex(i); + // Case-insensitive comparison is safer + if ( + layerSP != null + && layerName.Equals(layerSP.stringValue, StringComparison.OrdinalIgnoreCase) + ) + { + layerIndexToRemove = i; + break; + } + } + + if (layerIndexToRemove == -1) + { + return Response.Error($"User layer '{layerName}' not found."); + } + + // Clear the name for that index + try + { + SerializedProperty targetLayerSP = layersProp.GetArrayElementAtIndex( + layerIndexToRemove + ); + targetLayerSP.stringValue = string.Empty; // Set to empty string to remove + // Apply the changes + tagManager.ApplyModifiedProperties(); + // Save assets + AssetDatabase.SaveAssets(); + return Response.Success( + $"Layer '{layerName}' (slot {layerIndexToRemove}) removed successfully." + ); + } + catch (Exception e) + { + return Response.Error($"Failed to remove layer '{layerName}': {e.Message}"); + } + } + + private static object GetLayers() + { + try + { + var layers = new Dictionary(); + for (int i = 0; i < TotalLayerCount; i++) + { + string layerName = LayerMask.LayerToName(i); + if (!string.IsNullOrEmpty(layerName)) // Only include layers that have names + { + layers.Add(i, layerName); + } + } + return Response.Success("Retrieved current named layers.", layers); + } + catch (Exception e) + { + return Response.Error($"Failed to retrieve layers: {e.Message}"); + } + } + + // --- Compilation Management Methods --- + + /// + /// Ensures compilation event callbacks are registered. + /// + private static void EnsureCompilationCallbacksRegistered() + { + lock (_compilationLock) + { + if (!_compilationCallbackRegistered) + { + UnityEditor.Compilation.CompilationPipeline.compilationStarted += OnCompilationStarted; + UnityEditor.Compilation.CompilationPipeline.compilationFinished += OnCompilationFinished; + _compilationCallbackRegistered = true; + Debug.Log("[ManageEditor] Compilation callbacks registered"); + } + } + } + + private static void OnCompilationStarted(object obj) + { + Debug.Log("[ManageEditor] Compilation started"); + // Update all pending compilation jobs + var pendingJobs = AsyncOperationTracker.GetPendingJobs(AsyncOperationTracker.JobType.Compilation); + foreach (var job in pendingJobs) + { + AsyncOperationTracker.UpdateProgress(job.OpId, 0.5f, "Compiling scripts..."); + } + } + + private static void OnCompilationFinished(object obj) + { + Debug.Log("[ManageEditor] Compilation finished"); + // Complete all pending compilation jobs + var pendingJobs = AsyncOperationTracker.GetPendingJobs(AsyncOperationTracker.JobType.Compilation); + foreach (var job in pendingJobs) + { + // Get compilation results + var errors = CompilationHelper.GetCompilationErrors(); + var warnings = CompilationHelper.GetCompilationWarnings(); + + // IMPORTANT: Do not claim errors/warnings are 0 unless we actually know. + var compilationResult = new Dictionary + { + ["status"] = "completed", + }; + if (errors.HasValue) compilationResult["errors"] = errors.Value; + if (warnings.HasValue) compilationResult["warnings"] = warnings.Value; + if (errors.HasValue) compilationResult["success"] = errors.Value == 0; + + var completionMessage = errors.HasValue + ? (errors.Value > 0 ? "Compilation completed with errors" : "Compilation completed successfully") + : "Compilation completed (validate via console)"; + + AsyncOperationTracker.CompleteJob(job.OpId, completionMessage, compilationResult); + } + } + + private static object RequestCompile() + { + try + { + // Do not allow script compilation while in Play/Paused mode + if (EditorApplication.isPlaying || EditorApplication.isPaused) + { + return Response.Error( + "compile_blocked_in_play_mode", + new + { + code = "compile_blocked_in_play_mode", + message = "Compilation is not allowed while the editor is in Play/Paused mode. Stop Play mode (unity_editor.stop) before requesting compilation.", + playMode = EditorApplication.isPlaying ? "playing" : "paused" + } + ); + } + + // Create a job for this compilation request + var job = AsyncOperationTracker.CreateJob( + AsyncOperationTracker.JobType.Compilation, + "Compilation requested" + ); + + // Request script compilation + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); + + // Return Pending response with op_id and structured pipeline hints + var pending = AsyncOperationTracker.CreatePendingResponse(job) as System.Collections.Generic.Dictionary; + if (pending != null) + { + pending["pipeline_kind"] = "compile"; + pending["requires_console_validation"] = true; + return pending; + } + + return AsyncOperationTracker.CreatePendingResponse(job); + } + catch (Exception e) + { + Debug.LogError($"[ManageEditor] Failed to request compilation: {e}"); + return Response.Error($"Failed to request compilation: {e.Message}"); + } + } + + private static object WaitForCompile(string opId, int timeoutSeconds, string sinceToken = null) + { + try + { + var job = AsyncOperationTracker.GetJob(opId); + + if (job == null) + { + // Fallback for domain reload / tracker reset: + // The compilation may still be in progress (or may have already finished), + // but our AsyncOperationTracker job can be lost. In this case, degrade + // gracefully instead of hard-failing so clients can continue with + // console-based validation. + bool isCompiling = CompilationHelper.IsCompiling(); + if (isCompiling) + { + var pendingDelta = StateComposer.CreateCompilationDelta(true, "compiling"); + return new Dictionary + { + ["status"] = "pending", + ["poll_interval"] = 1.0, + ["op_id"] = opId, + ["success"] = true, + ["message"] = $"Compilation in progress (operation {opId} not found; tracking may have been reset). Continue polling and validate via console.", + ["pipeline_kind"] = "compile", + ["requires_console_validation"] = true, + ["state_delta"] = pendingDelta + }; + } + + // Not compiling anymore – return a complete-ish response with best-effort diagnostics. + var errors = CompilationHelper.GetCompilationErrors(); + var warnings = CompilationHelper.GetCompilationWarnings(); + + var compilationResult = new Dictionary + { + ["status"] = "completed", + ["tracking_lost"] = true + }; + if (errors.HasValue) compilationResult["errors"] = errors.Value; + if (warnings.HasValue) compilationResult["warnings"] = warnings.Value; + if (errors.HasValue) compilationResult["success"] = errors.Value == 0; + + var completionMessage = errors.HasValue + ? (errors.Value > 0 ? "Compilation completed with errors" : "Compilation completed successfully") + : "Compilation completed (validate via console)"; + + var compilationDelta = StateComposer.CreateCompilationDelta( + false, + errors.HasValue && errors.Value > 0 ? "failed" : "completed", + errors, + warnings + ); + + var completeDict = new Dictionary + { + ["status"] = "complete", + ["op_id"] = opId, + ["success"] = true, + ["message"] = $"{completionMessage} (operation {opId} not found; tracking may have been reset). Validate via console.", + ["data"] = compilationResult, + ["state_delta"] = compilationDelta, + ["pipeline_kind"] = "compile", + ["requires_console_validation"] = true + }; + + var currentToken = StateComposer.GetCurrentConsoleToken(); + if (!string.IsNullOrEmpty(currentToken)) + { + completeDict["console_token"] = currentToken; + } + + StateComposer.IncrementRevision(); + return completeDict; + } + + if (job.Type != AsyncOperationTracker.JobType.Compilation) + { + return Response.Error($"Operation {opId} is not a compilation job."); + } + + // Check timeout + if (AsyncOperationTracker.IsJobTimedOut(opId, timeoutSeconds)) + { + AsyncOperationTracker.FailJob(opId, $"Compilation timed out after {timeoutSeconds} seconds"); + var errorStateDelta = StateComposer.CreateCompilationDelta(false, "timeout"); + return AsyncOperationTracker.CreateErrorResponse(job, errorStateDelta); + } + + // Check status + switch (job.Status) + { + case AsyncOperationTracker.JobStatus.Complete: + // Get compilation result data + int? errors = null; + int? warnings = null; + if (job.Data != null) + { + // Prefer dictionary payloads (most tools use these). + var dict = job.Data as IDictionary; + if (dict != null) + { + object eVal; + if (dict.TryGetValue("errors", out eVal)) + { + if (eVal is int) errors = (int)eVal; + else if (eVal is long) errors = (int)(long)eVal; + else + { + var eni = eVal as int?; + if (eni.HasValue) errors = eni.Value; + } + } + + object wVal; + if (dict.TryGetValue("warnings", out wVal)) + { + if (wVal is int) warnings = (int)wVal; + else if (wVal is long) warnings = (int)(long)wVal; + else + { + var wni = wVal as int?; + if (wni.HasValue) warnings = wni.Value; + } + } + } + else + { + // Fallback for anonymous objects. + var dataType = job.Data.GetType(); + var errorsProp = dataType.GetProperty("errors"); + var warningsProp = dataType.GetProperty("warnings"); + if (errorsProp != null) + { + var eVal = errorsProp.GetValue(job.Data); + if (eVal is int) errors = (int)eVal; + else if (eVal is long) errors = (int)(long)eVal; + else + { + var eni = eVal as int?; + if (eni.HasValue) errors = eni.Value; + } + } + if (warningsProp != null) + { + var wVal = warningsProp.GetValue(job.Data); + if (wVal is int) warnings = (int)wVal; + else if (wVal is long) warnings = (int)(long)wVal; + else + { + var wni = wVal as int?; + if (wni.HasValue) warnings = wni.Value; + } + } + } + } + + // Create compilation state delta + var compilationDelta = StateComposer.CreateCompilationDelta( + false, // isCompiling + errors.HasValue && errors.Value > 0 ? "failed" : "completed", + errors, + warnings + ); + + // Include console token info and structured pipeline hints in response + var completeResponse = AsyncOperationTracker.CreateCompleteResponse(job, compilationDelta); + if (completeResponse is Dictionary completeDict) + { + // Add console token for log reading + var currentToken = StateComposer.GetCurrentConsoleToken(); + if (!string.IsNullOrEmpty(currentToken)) + { + completeDict["console_token"] = currentToken; + } + + // Structured hints for downstream tools/LLMs + completeDict["pipeline_kind"] = "compile"; + completeDict["requires_console_validation"] = true; + } + + StateComposer.IncrementRevision(); + return completeResponse; + + case AsyncOperationTracker.JobStatus.Error: + var errorDelta = StateComposer.CreateCompilationDelta(false, "error"); + return AsyncOperationTracker.CreateErrorResponse(job, errorDelta); + + case AsyncOperationTracker.JobStatus.Pending: + // Still pending, return pending response with compilation state delta + var pendingDelta = StateComposer.CreateCompilationDelta(true, "compiling"); + return AsyncOperationTracker.CreatePendingResponse(job, pendingDelta); + + default: + return Response.Error($"Unknown job status: {job.Status}"); + } + } + catch (Exception e) + { + Debug.LogError($"[ManageEditor] wait_for_compile failed: {e}"); + return Response.Error($"Error waiting for compilation: {e.Message}"); + } + } + + private static object WaitForIdle(int timeoutSeconds) + { + try + { + // Check if editor is currently idle + bool isIdle = !EditorApplication.isCompiling && !EditorApplication.isUpdating; + + if (isIdle) + { + return Response.Success("Editor is idle.", new + { + isCompiling = false, + isUpdating = false, + elapsed = 0 + }); + } + + // Create a job to track the wait + var job = AsyncOperationTracker.CreateJob( + AsyncOperationTracker.JobType.Custom, + "Waiting for editor to become idle..." + ); + + // Create and store callback delegate for proper unsubscription + EditorApplication.CallbackFunction callback = () => CheckIdleState(job.OpId, timeoutSeconds); + lock (_idleLock) + { + _idleCallbacks[job.OpId] = callback; + } + EditorApplication.update += callback; + + // Return pending - client will need to poll + return AsyncOperationTracker.CreatePendingResponse(job); + } + catch (Exception e) + { + Debug.LogError($"[ManageEditor] wait_for_idle failed: {e}"); + return Response.Error($"Error waiting for idle: {e.Message}"); + } + } + + private static void CheckIdleState(string opId, int timeoutSeconds) + { + var job = AsyncOperationTracker.GetJob(opId); + if (job == null || job.Status != AsyncOperationTracker.JobStatus.Pending) + { + // Job already completed or doesn't exist, unsubscribe + UnsubscribeIdleCallback(opId); + return; + } + + // Check timeout + if (AsyncOperationTracker.IsJobTimedOut(opId, timeoutSeconds)) + { + UnsubscribeIdleCallback(opId); + AsyncOperationTracker.FailJob(opId, $"Idle wait timed out after {timeoutSeconds} seconds"); + return; + } + + // Check if editor is now idle + bool isIdle = !EditorApplication.isCompiling && !EditorApplication.isUpdating; + if (isIdle) + { + UnsubscribeIdleCallback(opId); + var elapsed = (DateTime.UtcNow - job.CreatedAt).TotalSeconds; + AsyncOperationTracker.CompleteJob(opId, "Editor is now idle.", new + { + isCompiling = false, + isUpdating = false, + elapsed = elapsed + }); + StateComposer.IncrementRevision(); + } + } + + private static void UnsubscribeIdleCallback(string opId) + { + EditorApplication.CallbackFunction callback; + lock (_idleLock) + { + if (_idleCallbacks.TryGetValue(opId, out callback)) + { + EditorApplication.update -= callback; + _idleCallbacks.Remove(opId); + } + } + } + + // --- Helper Methods --- + + /// + /// Gets the SerializedObject for the TagManager asset. + /// + private static SerializedObject GetTagManager() + { + try + { + // Load the TagManager asset from the ProjectSettings folder + UnityEngine.Object[] tagManagerAssets = AssetDatabase.LoadAllAssetsAtPath( + "ProjectSettings/TagManager.asset" + ); + if (tagManagerAssets == null || tagManagerAssets.Length == 0) + { + Debug.LogError("[ManageEditor] TagManager.asset not found in ProjectSettings."); + return null; + } + // The first object in the asset file should be the TagManager + return new SerializedObject(tagManagerAssets[0]); + } + catch (Exception e) + { + Debug.LogError($"[ManageEditor] Error accessing TagManager.asset: {e.Message}"); + return null; + } + } + + // --- Example Implementations for Settings --- + /* + private static object SetGameViewResolution(int width, int height) { ... } + private static object SetQualityLevel(JToken qualityLevelToken) { ... } + */ + } + + // Helper class to get custom tool names (remains the same) + internal static class EditorTools + { + public static string GetActiveToolName() + { + // This is a placeholder. Real implementation depends on how custom tools + // are registered and tracked in the specific Unity project setup. + // It might involve checking static variables, calling methods on specific tool managers, etc. + if (UnityEditor.Tools.current == Tool.Custom) + { + // Example: Check a known custom tool manager + // if (MyCustomToolManager.IsActive) return MyCustomToolManager.ActiveToolName; + return "Unknown Custom Tool"; + } + return UnityEditor.Tools.current.ToString(); + } + } +} + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageEditor.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageEditor.cs.meta new file mode 100644 index 000000000..b77b547f6 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: da88809c82aa8214eaa168ec9ce090af +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageGameObject.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageGameObject.cs new file mode 100644 index 000000000..72365e593 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageGameObject.cs @@ -0,0 +1,4592 @@ +#nullable disable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Codely.Newtonsoft.Json; // Added for JsonSerializationException +using Codely.Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.Compilation; // For CompilationPipeline +using UnityEditor.SceneManagement; +using UnityEditorInternal; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityTcp.Editor.Helpers; // For Response class +using UnityTcp.Editor.Serialization; + +namespace UnityTcp.Editor.Tools +{ + /// + /// Handles GameObject manipulation within the current scene (CRUD, find, components). + /// + public static partial class ManageGameObject + { + private const int MaxBatchOps = 10; + + // Shared JsonSerializer to avoid per-call allocation overhead + private static readonly JsonSerializer InputSerializer = JsonSerializer.Create(new JsonSerializerSettings + { + Converters = new List + { + new Vector3Converter(), + new Vector2Converter(), + new QuaternionConverter(), + new ColorConverter(), + new RectConverter(), + new BoundsConverter(), + new UnityEngineObjectConverter() + } + }); + + // --- Main Handler --- + + public static object HandleCommand(JObject @params) + { + if (@params == null) + { + return Response.Error("Parameters cannot be null."); + } + + string action = @params["action"]?.ToString().ToLower(); + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + + // Normalize public aliases to canonical server actions / params (keep parity with TS client) + if (action == "set_component_properties") + { + action = "set_component_property"; + @params["action"] = "set_component_property"; + } + + // Back-compat alias: componentType (camelCase) -> component_type (snake_case) + if (@params["component_type"] == null && @params["componentType"] != null) + { + @params["component_type"] = @params["componentType"]; + } + + // Back-compat structured targetRef -> target + searchMethod + if (@params["target"] == null && @params["targetRef"] is JObject targetRef) + { + if (targetRef["id"] != null) + { + @params["target"] = targetRef["id"]; + @params["searchMethod"] = "by_id"; + } + else if (targetRef["hierarchy_path"] != null) + { + @params["target"] = targetRef["hierarchy_path"]; + @params["searchMethod"] = "by_path"; + } + else if (targetRef["name"] != null) + { + @params["target"] = targetRef["name"]; + @params["searchMethod"] = "by_name"; + } + } + + // --- Validate client_state_rev for write operations --- + var writeActions = new[] { "create_batch", "edit_batch", "ensure_component", "ensure_renderer_material", "ensure_mesh_collider_mesh", + "ensure_prefab_default_sprite", "create", "modify", "delete", "add_component", "remove_component", "set_component_property" }; + if (Array.Exists(writeActions, a => a == action)) + { + var revConflict = StateComposer.ValidateClientRevisionFromParams(@params); + if (revConflict != null) return revConflict; + } + + // Parameters used by various actions + JToken targetToken = @params["target"]; // Can be string (name/path) or int (instanceID) + string searchMethod = @params["searchMethod"]?.ToString().ToLower(); + + // Get common parameters (consolidated) + string name = @params["name"]?.ToString(); + string tag = @params["tag"]?.ToString(); + string layer = @params["layer"]?.ToString(); + JToken parentToken = @params["parent"]; + + // --- Add parameter for controlling non-public field inclusion --- + bool includeNonPublicSerialized = @params["includeNonPublicSerialized"]?.ToObject() ?? true; // Default to true + // --- End add parameter --- + + // --- Prefab Redirection Check --- + string targetPath = + targetToken?.Type == JTokenType.String ? targetToken.ToString() : null; + if ( + !string.IsNullOrEmpty(targetPath) + && targetPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) + ) + { + // Allow 'create' (instantiate), 'find' (?), 'get_components' (?) + if (action == "modify" || action == "set_component_property") + { + Debug.Log( + $"[ManageGameObject->ManageAsset] Redirecting action '{action}' for prefab '{targetPath}' to ManageAsset." + ); + // Prepare params for ManageAsset.ModifyAsset + JObject assetParams = new JObject(); + assetParams["action"] = "modify"; // ManageAsset uses "modify" + assetParams["path"] = targetPath; + + // Extract properties. + // For 'set_component_property', combine componentName and componentProperties. + // For 'modify', directly use componentProperties. + JObject properties = null; + if (action == "set_component_property") + { + string compName = @params["componentName"]?.ToString(); + JObject compProps = @params["componentProperties"]?[compName] as JObject; // Handle potential nesting + if (string.IsNullOrEmpty(compName)) + return Response.Error( + "Missing 'componentName' for 'set_component_property' on prefab." + ); + if (compProps == null) + return Response.Error( + $"Missing or invalid 'componentProperties' for component '{compName}' for 'set_component_property' on prefab." + ); + + properties = new JObject(); + properties[compName] = compProps; + } + else // action == "modify" + { + properties = @params["componentProperties"] as JObject; + if (properties == null) + return Response.Error( + "Missing 'componentProperties' for 'modify' action on prefab." + ); + } + + assetParams["properties"] = properties; + + // Call ManageAsset handler + return ManageAsset.HandleCommand(assetParams); + } + else if ( + action == "delete" + || action == "add_component" + || action == "remove_component" + || action == "get_components" + ) // Added get_components here too + { + // Explicitly block other modifications on the prefab asset itself via manage_gameobject + return Response.Error( + $"Action '{action}' on a prefab asset ('{targetPath}') should be performed using the 'manage_asset' command." + ); + } + // Allow 'create' (instantiation) and 'find' to proceed, although finding a prefab asset by path might be less common via manage_gameobject. + // No specific handling needed here, the code below will run. + } + // --- End Prefab Redirection Check --- + + try + { + switch (action) + { + // Batch operation (strict) + case "create_batch": + return HandleCreateBatch(@params); + case "edit_batch": + return HandleEditBatch(@params); + + // Ensure operations (idempotent) + case "ensure_component": + return EnsureComponent(@params, targetToken, searchMethod); + case "ensure_renderer_material": + return EnsureRendererMaterial(@params, targetToken, searchMethod); + case "ensure_mesh_collider_mesh": + return EnsureMeshColliderMesh(@params, targetToken, searchMethod); + case "ensure_prefab_default_sprite": + return EnsurePrefabDefaultSprite(@params); + + // Regular operations + case "create": + return CreateGameObject(@params); + case "modify": + return ModifyGameObject(@params, targetToken, searchMethod); + case "delete": + return DeleteGameObject(targetToken, searchMethod); + case "find": + return FindGameObjects(@params, targetToken, searchMethod); + case "list_children": + return ListChildren(@params, targetToken, searchMethod); + case "get_components": + string getCompTarget = targetToken?.ToString(); // Expect name, path, or ID string + if (getCompTarget == null) + return Response.Error( + "'target' parameter required for get_components." + ); + // Pass the includeNonPublicSerialized flag here + return GetComponentsFromTarget(getCompTarget, searchMethod, includeNonPublicSerialized); + case "add_component": + return AddComponentToTarget(@params, targetToken, searchMethod); + case "remove_component": + return RemoveComponentFromTarget(@params, targetToken, searchMethod); + case "set_component_property": + return SetComponentPropertyOnTarget(@params, targetToken, searchMethod); + case "select": + return SelectGameObject(@params, targetToken, searchMethod); + + default: + return Response.Error($"Unknown action: '{action}'. Valid actions include: create_batch, edit_batch, ensure_component, ensure_renderer_material, ensure_mesh_collider_mesh, ensure_prefab_default_sprite, create, modify, delete, find, list_children, get_components, add_component, remove_component, set_component_property, select."); + } + } + catch (Exception e) + { + Debug.LogError($"[ManageGameObject] Action '{action}' failed: {e}"); + return Response.Error($"Internal error processing action '{action}': {e.Message}"); + } + } + + // --- Action Implementations --- + + private static object CreateGameObject(JObject @params) + { + string name = @params["name"]?.ToString(); + if (string.IsNullOrEmpty(name)) + { + return Response.Error("'name' parameter is required for 'create' action."); + } + + // Get prefab creation parameters + bool saveAsPrefab = @params["saveAsPrefab"]?.ToObject() ?? false; + string prefabPath = @params["prefabPath"]?.ToString(); + string tag = @params["tag"]?.ToString(); // Get tag for creation + string primitiveType = @params["primitiveType"]?.ToString(); // Keep primitiveType check + GameObject newGo = null; // Initialize as null + + // Guardrail: primitiveType creates MeshRenderer/MeshFilter primitives and conflicts with SpriteRenderer. + // Keep parity with TS client validation. + if (!string.IsNullOrEmpty(primitiveType) && @params["componentsToAdd"] is JArray cta) + { + foreach (var compToken in cta) + { + if (compToken?.Type == JTokenType.String + && string.Equals(compToken.ToString(), "SpriteRenderer", StringComparison.OrdinalIgnoreCase)) + { + return Response.Error( + "Cannot add 'SpriteRenderer' when creating a primitiveType GameObject. Create an empty GameObject (omit primitiveType) and add SpriteRenderer instead." + ); + } + } + } + + // --- Try Instantiating Prefab First --- + string originalPrefabPath = prefabPath; // Keep original for messages + if (!string.IsNullOrEmpty(prefabPath)) + { + // If no extension, search for the prefab by name + if ( + !prefabPath.Contains("/") + && !prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase) + ) + { + string prefabNameOnly = prefabPath; + Debug.Log( + $"[ManageGameObject.Create] Searching for prefab named: '{prefabNameOnly}'" + ); + string[] guids = AssetDatabase.FindAssets($"t:Prefab {prefabNameOnly}"); + if (guids.Length == 0) + { + return Response.Error( + $"Prefab named '{prefabNameOnly}' not found anywhere in the project." + ); + } + else if (guids.Length > 1) + { + string foundPaths = string.Join( + ", ", + guids.Select(g => AssetDatabase.GUIDToAssetPath(g)) + ); + return Response.Error( + $"Multiple prefabs found matching name '{prefabNameOnly}': {foundPaths}. Please provide a more specific path." + ); + } + else // Exactly one found + { + prefabPath = AssetDatabase.GUIDToAssetPath(guids[0]); // Update prefabPath with the full path + Debug.Log( + $"[ManageGameObject.Create] Found unique prefab at path: '{prefabPath}'" + ); + } + } + else if (!prefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + // If it looks like a path but doesn't end with .prefab, assume user forgot it and append it. + Debug.LogWarning( + $"[ManageGameObject.Create] Provided prefabPath '{prefabPath}' does not end with .prefab. Assuming it's missing and appending." + ); + prefabPath += ".prefab"; + // Note: This path might still not exist, AssetDatabase.LoadAssetAtPath will handle that. + } + // The logic above now handles finding or assuming the .prefab extension. + + GameObject prefabAsset = AssetDatabase.LoadAssetAtPath(prefabPath); + if (prefabAsset != null) + { + try + { + // Instantiate the prefab, initially place it at the root + // Parent will be set later if specified + newGo = PrefabUtility.InstantiatePrefab(prefabAsset) as GameObject; + + if (newGo == null) + { + // This might happen if the asset exists but isn't a valid GameObject prefab somehow + Debug.LogError( + $"[ManageGameObject.Create] Failed to instantiate prefab at '{prefabPath}', asset might be corrupted or not a GameObject." + ); + return Response.Error( + $"Failed to instantiate prefab at '{prefabPath}'." + ); + } + // Name the instance based on the 'name' parameter, not the prefab's default name + if (!string.IsNullOrEmpty(name)) + { + newGo.name = name; + } + // Register Undo for prefab instantiation + Undo.RegisterCreatedObjectUndo( + newGo, + $"Instantiate Prefab '{prefabAsset.name}' as '{newGo.name}'" + ); + Debug.Log( + $"[ManageGameObject.Create] Instantiated prefab '{prefabAsset.name}' from path '{prefabPath}' as '{newGo.name}'." + ); + } + catch (Exception e) + { + return Response.Error( + $"Error instantiating prefab '{prefabPath}': {e.Message}" + ); + } + } + else + { + // Only return error if prefabPath was specified but not found. + // If prefabPath was empty/null, we proceed to create primitive/empty. + Debug.LogWarning( + $"[ManageGameObject.Create] Prefab asset not found at path: '{prefabPath}'. Will proceed to create new object if specified." + ); + // Do not return error here, allow fallback to primitive/empty creation + } + } + + // --- Fallback: Create Primitive or Empty GameObject --- + bool createdNewObject = false; // Flag to track if we created (not instantiated) + if (newGo == null) // Only proceed if prefab instantiation didn't happen + { + if (!string.IsNullOrEmpty(primitiveType)) + { + try + { + PrimitiveType type = (PrimitiveType) + Enum.Parse(typeof(PrimitiveType), primitiveType, true); + newGo = GameObject.CreatePrimitive(type); + // Set name *after* creation for primitives + if (!string.IsNullOrEmpty(name)) + { + newGo.name = name; + } + else + { + UnityEngine.Object.DestroyImmediate(newGo); // cleanup leak + return Response.Error( + "'name' parameter is required when creating a primitive." + ); + } + createdNewObject = true; + } + catch (ArgumentException) + { + return Response.Error( + $"Invalid primitive type: '{primitiveType}'. Valid types: {string.Join(", ", Enum.GetNames(typeof(PrimitiveType)))}" + ); + } + catch (Exception e) + { + return Response.Error( + $"Failed to create primitive '{primitiveType}': {e.Message}" + ); + } + } + else // Create empty GameObject + { + if (string.IsNullOrEmpty(name)) + { + return Response.Error( + "'name' parameter is required for 'create' action when not instantiating a prefab or creating a primitive." + ); + } + newGo = new GameObject(name); + createdNewObject = true; + } + // Record creation for Undo *only* if we created a new object + if (createdNewObject) + { + Undo.RegisterCreatedObjectUndo(newGo, $"Create GameObject '{newGo.name}'"); + } + } + // --- Common Setup (Parent, Transform, Tag, Components) - Applied AFTER object exists --- + if (newGo == null) + { + // Should theoretically not happen if logic above is correct, but safety check. + return Response.Error("Failed to create or instantiate the GameObject."); + } + + // Record potential changes to the existing prefab instance or the new GO + // Record transform separately in case parent changes affect it + Undo.RecordObject(newGo.transform, "Set GameObject Transform"); + Undo.RecordObject(newGo, "Set GameObject Properties"); + + // Set Parent + JToken parentToken = @params["parent"]; + if (parentToken != null) + { + GameObject parentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); // Flexible parent finding + if (parentGo == null) + { + UnityEngine.Object.DestroyImmediate(newGo); // Clean up created object + return Response.Error($"Parent specified ('{parentToken}') but not found."); + } + newGo.transform.SetParent(parentGo.transform, true); // worldPositionStays = true + } + + // Set Transform + Vector3? position = ParseVector3(@params["position"] as JArray); + Vector3? rotation = ParseVector3(@params["rotation"] as JArray); + Vector3? scale = ParseVector3(@params["scale"] as JArray); + + if (position.HasValue) + newGo.transform.localPosition = position.Value; + if (rotation.HasValue) + newGo.transform.localEulerAngles = rotation.Value; + if (scale.HasValue) + newGo.transform.localScale = scale.Value; + + // Set Tag (added for create action) + if (!string.IsNullOrEmpty(tag)) + { + // Similar logic as in ModifyGameObject for setting/creating tags + string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; + try + { + newGo.tag = tagToSet; + } + catch (UnityException ex) + { + if (ex.Message.Contains("is not defined")) + { + Debug.LogWarning( + $"[ManageGameObject.Create] Tag '{tagToSet}' not found. Attempting to create it." + ); + try + { + InternalEditorUtility.AddTag(tagToSet); + newGo.tag = tagToSet; // Retry + Debug.Log( + $"[ManageGameObject.Create] Tag '{tagToSet}' created and assigned successfully." + ); + } + catch (Exception innerEx) + { + UnityEngine.Object.DestroyImmediate(newGo); // Clean up + return Response.Error( + $"Failed to create or assign tag '{tagToSet}' during creation: {innerEx.Message}." + ); + } + } + else + { + UnityEngine.Object.DestroyImmediate(newGo); // Clean up + return Response.Error( + $"Failed to set tag to '{tagToSet}' during creation: {ex.Message}." + ); + } + } + } + + // Set Layer (new for create action) + string layerName = @params["layer"]?.ToString(); + if (!string.IsNullOrEmpty(layerName)) + { + int layerId = LayerMask.NameToLayer(layerName); + if (layerId != -1) + { + newGo.layer = layerId; + } + else + { + Debug.LogWarning( + $"[ManageGameObject.Create] Layer '{layerName}' not found. Using default layer." + ); + } + } + + // Add Components + if (@params["componentsToAdd"] is JArray componentsToAddArray) + { + foreach (var compToken in componentsToAddArray) + { + string typeName = null; + JObject properties = null; + + if (compToken.Type == JTokenType.String) + { + typeName = compToken.ToString(); + } + else if (compToken is JObject compObj) + { + typeName = compObj["typeName"]?.ToString(); + properties = compObj["properties"] as JObject; + } + + if (!string.IsNullOrEmpty(typeName)) + { + var addResult = AddComponentInternal(newGo, typeName, properties); + if (addResult != null) // Check if AddComponentInternal returned an error object + { + UnityEngine.Object.DestroyImmediate(newGo); // Clean up + return addResult; // Return the error response + } + } + else + { + Debug.LogWarning( + $"[ManageGameObject] Invalid component format in componentsToAdd: {compToken}" + ); + } + } + } + + // Save as Prefab ONLY if we *created* a new object AND saveAsPrefab is true + GameObject finalInstance = newGo; // Use this for selection and return data + if (createdNewObject && saveAsPrefab) + { + string finalPrefabPath = prefabPath; // Use a separate variable for saving path + // This check should now happen *before* attempting to save + if (string.IsNullOrEmpty(finalPrefabPath)) + { + // Clean up the created object before returning error + UnityEngine.Object.DestroyImmediate(newGo); + return Response.Error( + "'prefabPath' is required when 'saveAsPrefab' is true and creating a new object." + ); + } + // Ensure the *saving* path ends with .prefab + if (!finalPrefabPath.EndsWith(".prefab", StringComparison.OrdinalIgnoreCase)) + { + Debug.Log( + $"[ManageGameObject.Create] Appending .prefab extension to save path: '{finalPrefabPath}' -> '{finalPrefabPath}.prefab'" + ); + finalPrefabPath += ".prefab"; + } + + try + { + // Ensure directory exists using the final saving path + string directoryPath = System.IO.Path.GetDirectoryName(finalPrefabPath); + if ( + !string.IsNullOrEmpty(directoryPath) + && !System.IO.Directory.Exists(directoryPath) + ) + { + System.IO.Directory.CreateDirectory(directoryPath); + AssetDatabase.Refresh(); // Refresh asset database to recognize the new folder + Debug.Log( + $"[ManageGameObject.Create] Created directory for prefab: {directoryPath}" + ); + } + // Use SaveAsPrefabAssetAndConnect with the final saving path + finalInstance = PrefabUtility.SaveAsPrefabAssetAndConnect( + newGo, + finalPrefabPath, + InteractionMode.UserAction + ); + + if (finalInstance == null) + { + // Destroy the original if saving failed somehow (shouldn't usually happen if path is valid) + UnityEngine.Object.DestroyImmediate(newGo); + return Response.Error( + $"Failed to save GameObject '{name}' as prefab at '{finalPrefabPath}'. Check path and permissions." + ); + } + Debug.Log( + $"[ManageGameObject.Create] GameObject '{name}' saved as prefab to '{finalPrefabPath}' and instance connected." + ); + // Mark the new prefab asset as dirty? Not usually necessary, SaveAsPrefabAsset handles it. + // EditorUtility.SetDirty(finalInstance); // Instance is handled by SaveAsPrefabAssetAndConnect + } + catch (Exception e) + { + // Clean up the instance if prefab saving fails + UnityEngine.Object.DestroyImmediate(newGo); // Destroy the original attempt + return Response.Error($"Error saving prefab '{finalPrefabPath}': {e.Message}"); + } + } + + // Select the instance in the scene (either prefab instance or newly created/saved one) + Selection.activeGameObject = finalInstance; + + // Determine appropriate success message using the potentially updated or original path + string messagePrefabPath = + finalInstance == null + ? originalPrefabPath + : AssetDatabase.GetAssetPath( + PrefabUtility.GetCorrespondingObjectFromSource(finalInstance) + ?? (UnityEngine.Object)finalInstance + ); + string successMessage; + if (!createdNewObject && !string.IsNullOrEmpty(messagePrefabPath)) // Instantiated existing prefab + { + successMessage = + $"Prefab '{messagePrefabPath}' instantiated successfully as '{finalInstance.name}'."; + } + else if (createdNewObject && saveAsPrefab && !string.IsNullOrEmpty(messagePrefabPath)) // Created new and saved as prefab + { + successMessage = + $"GameObject '{finalInstance.name}' created and saved as prefab to '{messagePrefabPath}'."; + } + else // Created new primitive or empty GO, didn't save as prefab + { + successMessage = + $"GameObject '{finalInstance.name}' created successfully in scene."; + } + + // Use the new serializer helper + //return Response.Success(successMessage, GetGameObjectData(finalInstance)); + return Response.Success(successMessage, Helpers.GameObjectSerializer.GetGameObjectData(finalInstance)); + } + + private static object ModifyGameObject( + JObject @params, + JToken targetToken, + string searchMethod + ) + { + GameObject targetGo = FindObjectInternal(targetToken, searchMethod); + if (targetGo == null) + { + return Response.Error( + $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + // Record state for Undo *before* modifications + Undo.RecordObject(targetGo.transform, "Modify GameObject Transform"); + Undo.RecordObject(targetGo, "Modify GameObject Properties"); + + bool modified = false; + + // Rename (using consolidated 'name' parameter) + string name = @params["name"]?.ToString(); + if (!string.IsNullOrEmpty(name) && targetGo.name != name) + { + targetGo.name = name; + modified = true; + } + + // Change Parent (using consolidated 'parent' parameter) + JToken parentToken = @params["parent"]; + if (parentToken != null) + { + GameObject newParentGo = FindObjectInternal(parentToken, "by_id_or_name_or_path"); + // Check for hierarchy loops + if ( + newParentGo == null + && !( + parentToken.Type == JTokenType.Null + || ( + parentToken.Type == JTokenType.String + && string.IsNullOrEmpty(parentToken.ToString()) + ) + ) + ) + { + return Response.Error($"New parent ('{parentToken}') not found."); + } + if (newParentGo != null && newParentGo.transform.IsChildOf(targetGo.transform)) + { + return Response.Error( + $"Cannot parent '{targetGo.name}' to '{newParentGo.name}', as it would create a hierarchy loop." + ); + } + if (targetGo.transform.parent != (newParentGo?.transform)) + { + targetGo.transform.SetParent(newParentGo?.transform, true); // worldPositionStays = true + modified = true; + } + } + + // Set Active State + bool? setActive = @params["setActive"]?.ToObject(); + if (setActive.HasValue && targetGo.activeSelf != setActive.Value) + { + targetGo.SetActive(setActive.Value); + modified = true; + } + + // Change Tag (using consolidated 'tag' parameter) + string tag = @params["tag"]?.ToString(); + // Only attempt to change tag if a non-null tag is provided and it's different from the current one. + // Allow setting an empty string to remove the tag (Unity uses "Untagged"). + if (tag != null && targetGo.tag != tag) + { + // Ensure the tag is not empty, if empty, it means "Untagged" implicitly + string tagToSet = string.IsNullOrEmpty(tag) ? "Untagged" : tag; + try + { + targetGo.tag = tagToSet; + modified = true; + } + catch (UnityException ex) + { + // Check if the error is specifically because the tag doesn't exist + if (ex.Message.Contains("is not defined")) + { + Debug.LogWarning( + $"[ManageGameObject] Tag '{tagToSet}' not found. Attempting to create it." + ); + try + { + // Attempt to create the tag using internal utility + InternalEditorUtility.AddTag(tagToSet); + // Wait a frame maybe? Not strictly necessary but sometimes helps editor updates. + // yield return null; // Cannot yield here, editor script limitation + + // Retry setting the tag immediately after creation + targetGo.tag = tagToSet; + modified = true; + Debug.Log( + $"[ManageGameObject] Tag '{tagToSet}' created and assigned successfully." + ); + } + catch (Exception innerEx) + { + // Handle failure during tag creation or the second assignment attempt + Debug.LogError( + $"[ManageGameObject] Failed to create or assign tag '{tagToSet}' after attempting creation: {innerEx.Message}" + ); + return Response.Error( + $"Failed to create or assign tag '{tagToSet}': {innerEx.Message}. Check Tag Manager and permissions." + ); + } + } + else + { + // If the exception was for a different reason, return the original error + return Response.Error($"Failed to set tag to '{tagToSet}': {ex.Message}."); + } + } + } + + // Change Layer (using consolidated 'layer' parameter) + string layerName = @params["layer"]?.ToString(); + if (!string.IsNullOrEmpty(layerName)) + { + int layerId = LayerMask.NameToLayer(layerName); + if (layerId == -1 && layerName != "Default") + { + return Response.Error( + $"Invalid layer specified: '{layerName}'. Use a valid layer name." + ); + } + if (layerId != -1 && targetGo.layer != layerId) + { + targetGo.layer = layerId; + modified = true; + } + } + + // Transform Modifications + Vector3? position = ParseVector3(@params["position"] as JArray); + Vector3? rotation = ParseVector3(@params["rotation"] as JArray); + Vector3? scale = ParseVector3(@params["scale"] as JArray); + + if (position.HasValue && targetGo.transform.localPosition != position.Value) + { + targetGo.transform.localPosition = position.Value; + modified = true; + } + if (rotation.HasValue && targetGo.transform.localEulerAngles != rotation.Value) + { + targetGo.transform.localEulerAngles = rotation.Value; + modified = true; + } + if (scale.HasValue && targetGo.transform.localScale != scale.Value) + { + targetGo.transform.localScale = scale.Value; + modified = true; + } + + // --- Component Modifications --- + // Note: These might need more specific Undo recording per component + + // Remove Components + if (@params["componentsToRemove"] is JArray componentsToRemoveArray) + { + foreach (var compToken in componentsToRemoveArray) + { + // ... (parsing logic as in CreateGameObject) ... + string typeName = compToken.ToString(); + if (!string.IsNullOrEmpty(typeName)) + { + var removeResult = RemoveComponentInternal(targetGo, typeName); + if (removeResult != null) + return removeResult; // Return error if removal failed + modified = true; + } + } + } + + // Add Components (similar to create) + if (@params["componentsToAdd"] is JArray componentsToAddArrayModify) + { + foreach (var compToken in componentsToAddArrayModify) + { + string typeName = null; + JObject properties = null; + if (compToken.Type == JTokenType.String) + typeName = compToken.ToString(); + else if (compToken is JObject compObj) + { + typeName = compObj["typeName"]?.ToString(); + properties = compObj["properties"] as JObject; + } + + if (!string.IsNullOrEmpty(typeName)) + { + var addResult = AddComponentInternal(targetGo, typeName, properties); + if (addResult != null) + return addResult; + modified = true; + } + } + } + + // Set Component Properties + var componentErrors = new List(); + if (@params["componentProperties"] is JObject componentPropertiesObj) + { + foreach (var prop in componentPropertiesObj.Properties()) + { + string compName = prop.Name; + JObject propertiesToSet = prop.Value as JObject; + if (propertiesToSet != null) + { + var setResult = SetComponentPropertiesInternal( + targetGo, + compName, + propertiesToSet + ); + if (setResult != null) + { + componentErrors.Add(setResult); + } + else + { + modified = true; + } + } + } + } + + // Return component errors if any occurred (after processing all components) + if (componentErrors.Count > 0) + { + // Aggregate flattened error strings to make tests/API assertions simpler + var aggregatedErrors = new System.Collections.Generic.List(); + foreach (var errorObj in componentErrors) + { + try + { + var dataProp = errorObj?.GetType().GetProperty("data"); + var dataVal = dataProp?.GetValue(errorObj); + if (dataVal != null) + { + var errorsProp = dataVal.GetType().GetProperty("errors"); + var errorsEnum = errorsProp?.GetValue(dataVal) as System.Collections.IEnumerable; + if (errorsEnum != null) + { + foreach (var item in errorsEnum) + { + var s = item?.ToString(); + if (!string.IsNullOrEmpty(s)) aggregatedErrors.Add(s); + } + } + } + } + catch { } + } + + return Response.Error( + $"One or more component property operations failed on '{targetGo.name}'.", + new { componentErrors = componentErrors, errors = aggregatedErrors } + ); + } + + if (!modified) + { + // Use the new serializer helper + // return Response.Success( + // $"No modifications applied to GameObject '{targetGo.name}'.", + // GetGameObjectData(targetGo)); + + return Response.Success( + $"No modifications applied to GameObject '{targetGo.name}'.", + Helpers.GameObjectSerializer.GetGameObjectData(targetGo) + ); + } + + EditorUtility.SetDirty(targetGo); // Mark scene as dirty + // Use the new serializer helper + return Response.Success( + $"GameObject '{targetGo.name}' modified successfully.", + Helpers.GameObjectSerializer.GetGameObjectData(targetGo) + ); + // return Response.Success( + // $"GameObject '{targetGo.name}' modified successfully.", + // GetGameObjectData(targetGo)); + + } + + private static object DeleteGameObject(JToken targetToken, string searchMethod) + { + // Find potentially multiple objects if name/tag search is used without find_all=false implicitly + List targets = FindObjectsInternal(targetToken, searchMethod, true); // find_all=true for delete safety + + if (targets.Count == 0) + { + return Response.Error( + $"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + List deletedObjects = new List(); + foreach (var targetGo in targets) + { + if (targetGo != null) + { + string goName = targetGo.name; + int goId = targetGo.GetInstanceID(); + // Use Undo.DestroyObjectImmediate for undo support + Undo.DestroyObjectImmediate(targetGo); + deletedObjects.Add(new { name = goName, instanceID = goId }); + } + } + + if (deletedObjects.Count > 0) + { + string message = + targets.Count == 1 + ? $"GameObject '{deletedObjects[0].GetType().GetProperty("name").GetValue(deletedObjects[0])}' deleted successfully." + : $"{deletedObjects.Count} GameObjects deleted successfully."; + return Response.Success(message, deletedObjects); + } + else + { + // Should not happen if targets.Count > 0 initially, but defensive check + return Response.Error("Failed to delete target GameObject(s)."); + } + } + + /// + /// Selects one or more GameObjects in the Unity Editor. + /// This sets Selection.activeGameObject (for single) or Selection.objects (for multiple). + /// + private static object SelectGameObject(JObject @params, JToken targetToken, string searchMethod) + { + if (targetToken == null) + { + // Clear selection if no target specified + Selection.activeGameObject = null; + Selection.objects = new UnityEngine.Object[0]; + return Response.Success("Selection cleared.", new { selectedCount = 0 }); + } + + bool selectAll = @params?["selectAll"]?.ToObject() ?? false; + List targets = FindObjectsInternal(targetToken, searchMethod, selectAll, @params); + + if (targets.Count == 0) + { + return Response.Error( + $"Target GameObject(s) ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + if (targets.Count == 1) + { + // Single selection + Selection.activeGameObject = targets[0]; + return Response.Success( + $"Selected GameObject '{targets[0].name}'.", + new + { + selectedCount = 1, + selected = new[] + { + new { name = targets[0].name, instanceID = targets[0].GetInstanceID() } + } + } + ); + } + else + { + // Multiple selection + Selection.objects = targets.ToArray(); + Selection.activeGameObject = targets[0]; // First one becomes active + var selectedInfo = targets.Select(go => new { name = go.name, instanceID = go.GetInstanceID() }).ToList(); + return Response.Success( + $"Selected {targets.Count} GameObjects.", + new + { + selectedCount = targets.Count, + selected = selectedInfo + } + ); + } + } + + private static object FindGameObjects( + JObject @params, + JToken targetToken, + string searchMethod + ) + { + bool findAll = @params["findAll"]?.ToObject() ?? false; + List foundObjects = FindObjectsInternal( + targetToken, + searchMethod, + findAll, + @params + ); + + if (foundObjects.Count == 0) + { + return Response.Success("No matching GameObjects found.", new List()); + } + + // Check if result would be too large + if (foundObjects.Count > 100) + { + string searchTerm = + @params?["searchTerm"]?.ToString() + ?? targetToken?.ToString() + ?? "unknown"; + return Response.Error( + $"Too many GameObjects found ({foundObjects.Count}). Response would be too large. " + + "Hints to narrow scope:\n" + + "1. Use more specific search criteria (exact names instead of partial matches)\n" + + "2. Add 'findAll': false to get only the first match\n" + + "3. Use different searchMethod: 'by_id', 'by_path', or 'by_name' for exact matches\n" + + "4. Search within a specific parent object instead of the entire scene\n" + + $"5. Found objects include: {string.Join(", ", foundObjects.Take(10).Select(go => go.name))}{(foundObjects.Count > 10 ? "..." : "")}" + ); + } + + // Use the new serializer helper + //var results = foundObjects.Select(go => GetGameObjectData(go)).ToList(); + var results = foundObjects.Select(go => Helpers.GameObjectSerializer.GetGameObjectData(go)).ToList(); + return Response.Success($"Found {results.Count} GameObject(s).", results); + } + + private static object ListChildren( + JObject @params, + JToken targetToken, + string searchMethod + ) + { + if (targetToken == null) + { + return Response.Error( + "'target' parameter is required for list_children. Provide a GameObject name, instance ID, or hierarchy path." + ); + } + + // Allow both names: includeInactive (preferred) and legacy searchInactive + bool includeInactive = + @params["includeInactive"]?.ToObject() + ?? @params["searchInactive"]?.ToObject() + ?? true; + + // Depth: 1 = direct children + int depth = 1; + try + { + var depthToken = @params["depth"] ?? @params["maxDepth"]; + if (depthToken != null && depthToken.Type != JTokenType.Null) + { + var depthStr = depthToken.ToString().Trim(); + if (int.TryParse(depthStr, out var parsedDepth)) + { + depth = parsedDepth; + } + else if (double.TryParse(depthStr, out var parsedDouble)) + { + depth = (int)parsedDouble; + } + } + } + catch { /* fall back to default */ } + + if (depth < 1) + { + return Response.Error("'depth' must be >= 1."); + } + + // resultMode: auto | inline | file + string resultMode = + (@params["resultMode"]?.ToString() ?? "auto").Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(resultMode)) resultMode = "auto"; + if (resultMode != "auto" && resultMode != "inline" && resultMode != "file") + { + return Response.Error( + $"Invalid resultMode '{resultMode}'. Valid values: auto, inline, file." + ); + } + + int maxInlineItems = 200; + try + { + var maxInlineToken = @params["maxInlineItems"] ?? @params["inlineLimit"]; + if (maxInlineToken != null && maxInlineToken.Type != JTokenType.Null) + { + var s = maxInlineToken.ToString().Trim(); + if (int.TryParse(s, out var parsed)) + { + maxInlineItems = parsed; + } + else if (double.TryParse(s, out var parsedDouble)) + { + maxInlineItems = (int)parsedDouble; + } + } + } + catch { /* fall back */ } + + if (maxInlineItems < 1) maxInlineItems = 1; + + // Resolve the target first (respect includeInactive via searchInactive) + var findParams = new JObject(); + findParams["searchInactive"] = includeInactive; + GameObject targetGo = FindObjectInternal(targetToken, searchMethod, findParams); + if (targetGo == null) + { + return Response.Error( + $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'.\n" + + "Next steps:\n" + + "1. Use action='find' with searchMethod='by_name' or 'by_path' to locate the exact object\n" + + "2. If you have an instanceID, set searchMethod='by_id'\n" + + "3. If path contains '/', prefer searchMethod='by_path' (e.g. 'Root/Child')" + ); + } + + // Determine whether to return inline tree or write to file (no truncation). + // We do a bounded count first so "auto" can decide without building the entire tree in memory. + int boundedCount = CountDescendantsUpToDepth(targetGo.transform, depth, includeInactive, maxInlineItems + 1); + bool shouldWriteToFile = resultMode == "file" || (resultMode == "auto" && boundedCount > maxInlineItems); + + if (resultMode == "inline" && boundedCount > maxInlineItems) + { + return Response.Error( + $"Result too large to return inline (>{maxInlineItems} nodes within depth={depth}). This tool will not truncate.\n" + + "Next steps:\n" + + "1. Set resultMode='file' (or 'auto') to write full JSON to disk\n" + + "2. Reduce depth (default is 1)\n" + + "3. Set includeInactive=false to reduce scope" + ); + } + + if (shouldWriteToFile) + { + try + { + string outputAssetsPath = ResolveOutputPathUnderAssets(@params, targetGo, "unity_gameobject_list_children"); + string outputDiskPath = ResolveAssetsPathToDiskPath(outputAssetsPath); + + // Stream JSON to file using iterative depth-limited traversal (no recursion). + int writtenCount = 0; + int visitedCount = 0; + + using (var sw = new StreamWriter(outputDiskPath, false, new System.Text.UTF8Encoding(false))) + using (var jw = new JsonTextWriter(sw) { Formatting = Formatting.None }) + { + jw.WriteStartObject(); + jw.WritePropertyName("target"); + WriteNodeSummary(jw, targetGo, "", 0, includeChildrenArray: false); + + jw.WritePropertyName("params"); + jw.WriteStartObject(); + jw.WritePropertyName("depth"); jw.WriteValue(depth); + jw.WritePropertyName("includeInactive"); jw.WriteValue(includeInactive); + jw.WritePropertyName("resultMode"); jw.WriteValue("file"); + jw.WriteEndObject(); + + jw.WritePropertyName("children"); + jw.WriteStartArray(); + + WriteChildrenTreeIterative(jw, targetGo.transform, depth, includeInactive, ref writtenCount, ref visitedCount); + + jw.WriteEndArray(); + jw.WritePropertyName("meta"); + jw.WriteStartObject(); + jw.WritePropertyName("includedCount"); jw.WriteValue(writtenCount); + jw.WritePropertyName("visitedCount"); jw.WriteValue(visitedCount); + jw.WriteEndObject(); + jw.WriteEndObject(); + jw.Flush(); + } + + // Make it visible in Unity as a text asset (best-effort) + try { AssetDatabase.ImportAsset(outputAssetsPath); } catch { } + + return Response.Success( + $"Listed child GameObjects of '{targetGo.name}' (tree up to depth={depth}). Output written to: {outputAssetsPath}", + new + { + target = CreateGameObjectChildSummary(targetGo, "", 0), + depth = depth, + includeInactive = includeInactive, + includedCount = writtenCount, + visitedCount = visitedCount, + output = new { mode = "file", path = outputAssetsPath }, + hints = new[] + { + "If the result is still too large, reduce 'depth' (default is 1).", + "Set 'includeInactive': false to skip inactive subtrees.", + "If the target is ambiguous/not found, use action='find' with searchMethod='by_path' (e.g. 'Root/Child') or searchMethod='by_id' (instanceID).", + "You can set 'outputPath' under Assets/ to control where the JSON is written.", + "If you really want inline output, increase 'maxInlineItems' or keep 'depth' small (no truncation)." + } + } + ); + } + catch (Exception e) + { + return Response.Error($"Error listing children to file: {e.Message}"); + } + } + + // Inline: build a tree structure in memory (depth-limited, non-recursive). + try + { + int visitedCount = 0; + int includedCount = 0; + var children = BuildChildrenTreeIterative(targetGo.transform, depth, includeInactive, ref includedCount, ref visitedCount); + + return Response.Success( + $"Listed child GameObjects of '{targetGo.name}' (tree up to depth={depth}).", + new + { + target = CreateGameObjectChildSummary(targetGo, "", 0), + depth = depth, + includeInactive = includeInactive, + includedCount = includedCount, + visitedCount = visitedCount, + children = children, + output = new { mode = "inline" } + } + ); + } + catch (Exception e) + { + return Response.Error($"Error listing children: {e.Message}"); + } + } + + private static int CountDescendantsUpToDepth(Transform root, int maxDepth, bool includeInactive, int cap) + { + if (root == null || maxDepth < 1) return 0; + int count = 0; + var q = new Queue>(); + + int childCount = root.childCount; + for (int i = 0; i < childCount; i++) + { + var c = root.GetChild(i); + if (c != null) q.Enqueue(new Tuple(c, 1)); + } + + while (q.Count > 0) + { + var item = q.Dequeue(); + var tr = item.Item1; + int d = item.Item2; + if (tr == null) continue; + var go = tr.gameObject; + if (go == null) continue; + + if (!includeInactive && !go.activeInHierarchy) + { + // Skip subtree + continue; + } + + count++; + if (count >= cap) return cap; + + if (d < maxDepth) + { + int cc = tr.childCount; + for (int i = 0; i < cc; i++) + { + var child = tr.GetChild(i); + if (child != null) q.Enqueue(new Tuple(child, d + 1)); + } + } + } + + return count; + } + + private static List BuildChildrenTreeIterative( + Transform root, + int maxDepth, + bool includeInactive, + ref int includedCount, + ref int visitedCount + ) + { + var result = new List(); + if (root == null || maxDepth < 1) return result; + + // Frame: (transform, depth, relativePath, childrenListRef, nextChildIndex) + var stack = new Stack, int>>(); + + // Seed with direct children in reverse order so output preserves sibling order + for (int i = root.childCount - 1; i >= 0; i--) + { + var c = root.GetChild(i); + if (c != null) + { + stack.Push(new Tuple, int>(c, 1, c.name, result, 0)); + } + } + + // Track node state separately: map Transform instanceID to its node + children list. + // Using instanceID avoids holding Transform keys for long. + var nodeById = new Dictionary>(); + + while (stack.Count > 0) + { + var frame = stack.Pop(); + var tr = frame.Item1; + int d = frame.Item2; + string relPath = frame.Item3; + var parentChildren = frame.Item4; + int nextChildIndex = frame.Item5; + + if (tr == null) continue; + var go = tr.gameObject; + if (go == null) continue; + visitedCount++; + + if (!includeInactive && !go.activeInHierarchy) + { + // Skip subtree entirely + continue; + } + + int id = go.GetInstanceID(); + if (!nodeById.TryGetValue(id, out var node)) + { + // First time: create node and attach to parent + node = CreateGameObjectChildSummary(go, relPath, d); + var children = new List(); + node["children"] = children; + parentChildren.Add(node); + includedCount++; + nodeById[id] = node; + + // Prepare to traverse children if we can go deeper + if (d < maxDepth && tr.childCount > 0) + { + // Push a continuation frame for this node after its children, then push children frames + // Continuation not needed because we store children directly; we just push children frames. + var childList = (List)node["children"]; + for (int i = tr.childCount - 1; i >= 0; i--) + { + var c = tr.GetChild(i); + if (c != null) + { + string childRel = string.IsNullOrEmpty(relPath) ? c.name : relPath + "/" + c.name; + stack.Push(new Tuple, int>(c, d + 1, childRel, childList, 0)); + } + } + } + } + else + { + // Should not generally happen in a tree, but keep safety (no-op). + // nextChildIndex currently unused; kept for future extensions. + _ = nextChildIndex; + } + } + + return result; + } + + private static void WriteChildrenTreeIterative( + JsonTextWriter jw, + Transform root, + int maxDepth, + bool includeInactive, + ref int includedCount, + ref int visitedCount + ) + { + if (root == null || maxDepth < 1) return; + + // Stack frame for streaming JSON without recursion: + // (transform, depth, relativePath, childIndex, childCount, started) + var stack = new Stack>(); + + // Seed direct children in reverse order so they are written in ascending sibling order + for (int i = root.childCount - 1; i >= 0; i--) + { + var c = root.GetChild(i); + if (c != null) + { + stack.Push(new Tuple(c, 1, c.name, 0, 0, false)); + } + } + + while (stack.Count > 0) + { + var frame = stack.Pop(); + var tr = frame.Item1; + int d = frame.Item2; + string relPath = frame.Item3; + int childIndex = frame.Item4; + int childCount = frame.Item5; + bool started = frame.Item6; + + if (tr == null) continue; + var go = tr.gameObject; + if (go == null) continue; + + if (!started) + { + visitedCount++; + if (!includeInactive && !go.activeInHierarchy) + { + // Skip subtree entirely + continue; + } + + // Start node object + WriteNodeSummary(jw, go, relPath, d, includeChildrenArray: true); + includedCount++; + + // Prepare to write children if within depth + childCount = (d < maxDepth) ? tr.childCount : 0; + // Push continuation frame to close this node after children are written + stack.Push(new Tuple(tr, d, relPath, 0, childCount, true)); + + // Push children frames (reverse order for stable output order) + for (int i = childCount - 1; i >= 0; i--) + { + var c = tr.GetChild(i); + if (c != null) + { + string childRel = string.IsNullOrEmpty(relPath) ? c.name : relPath + "/" + c.name; + stack.Push(new Tuple(c, d + 1, childRel, 0, 0, false)); + } + } + } + else + { + // Close children array + object + jw.WriteEndArray(); + jw.WriteEndObject(); + } + } + } + + private static string ResolveOutputPathUnderAssets(JObject @params, GameObject targetGo, string prefix) + { + string requested = @params["outputPath"]?.ToString(); + if (string.IsNullOrEmpty(requested)) + { + string dir = "Assets/Codely/ToolOutputs"; + string timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss"); + requested = $"{dir}/{prefix}_{timestamp}_{targetGo.GetInstanceID()}.json"; + } + requested = requested.Replace('\\', '/').Trim(); + if (!requested.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + throw new Exception($"outputPath must start with 'Assets/'. Provided: '{requested}'"); + } + return requested.Replace('\\', '/'); + } + + private static string ResolveAssetsPathToDiskPath(string assetsPath) + { + string requested = (assetsPath ?? "").Replace('\\', '/').Trim(); + if (!requested.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + throw new Exception($"Path must start with 'Assets/': '{requested}'"); + } + + string assetsDisk = Application.dataPath.Replace('\\', '/'); + string relUnderAssets = requested.Substring("Assets/".Length).TrimStart('/'); + string diskPath = Path.Combine(assetsDisk, relUnderAssets).Replace('\\', '/'); + string fullDisk = Path.GetFullPath(diskPath).Replace('\\', '/'); + string assetsFull = Path.GetFullPath(assetsDisk).Replace('\\', '/'); + + if ( + !fullDisk.StartsWith(assetsFull + "/", StringComparison.OrdinalIgnoreCase) + && !string.Equals(fullDisk, assetsFull, StringComparison.OrdinalIgnoreCase) + ) + { + throw new Exception($"outputPath escapes Assets/. Resolved: '{fullDisk}'"); + } + + string fullDir = Path.GetDirectoryName(fullDisk); + if (!string.IsNullOrEmpty(fullDir)) + { + Directory.CreateDirectory(fullDir); + } + + return fullDisk; + } + + private static void WriteNodeSummary( + JsonTextWriter jw, + GameObject go, + string relativePathFromTarget, + int depthFromTarget, + bool includeChildrenArray + ) + { + jw.WriteStartObject(); + jw.WritePropertyName("name"); jw.WriteValue(go.name); + jw.WritePropertyName("instanceID"); jw.WriteValue(go.GetInstanceID()); + jw.WritePropertyName("hierarchyPath"); jw.WriteValue(GetHierarchyPath(go)); + jw.WritePropertyName("relativePath"); jw.WriteValue(relativePathFromTarget ?? string.Empty); + jw.WritePropertyName("depth"); jw.WriteValue(depthFromTarget); + jw.WritePropertyName("activeSelf"); jw.WriteValue(go.activeSelf); + jw.WritePropertyName("activeInHierarchy"); jw.WriteValue(go.activeInHierarchy); + jw.WritePropertyName("tag"); jw.WriteValue(go.tag); + jw.WritePropertyName("layer"); jw.WriteValue(go.layer); + jw.WritePropertyName("isStatic"); jw.WriteValue(go.isStatic); + jw.WritePropertyName("childCount"); jw.WriteValue(go.transform != null ? go.transform.childCount : 0); + jw.WritePropertyName("siblingIndex"); jw.WriteValue(go.transform != null ? go.transform.GetSiblingIndex() : 0); + jw.WritePropertyName("scenePath"); jw.WriteValue(go.scene.path ?? string.Empty); + + if (includeChildrenArray) + { + jw.WritePropertyName("children"); + jw.WriteStartArray(); + } + // Caller closes children/object appropriately. + } + + private static Dictionary CreateGameObjectChildSummary( + GameObject go, + string relativePathFromTarget, + int depthFromTarget + ) + { + if (go == null) + { + return new Dictionary(); + } + + var tr = go.transform; + return new Dictionary + { + { "name", go.name }, + { "instanceID", go.GetInstanceID() }, + { "hierarchyPath", GetHierarchyPath(go) }, + { "relativePath", relativePathFromTarget ?? string.Empty }, + { "depth", depthFromTarget }, + { "activeSelf", go.activeSelf }, + { "activeInHierarchy", go.activeInHierarchy }, + { "tag", go.tag }, + { "layer", go.layer }, + { "isStatic", go.isStatic }, + { "childCount", tr != null ? tr.childCount : 0 }, + { "siblingIndex", tr != null ? tr.GetSiblingIndex() : 0 }, + { "scenePath", go.scene.path ?? string.Empty }, + }; + } + + private static string GetHierarchyPath(GameObject go) + { + if (go == null) return string.Empty; + + var path = go.name; + var parent = go.transform != null ? go.transform.parent : null; + while (parent != null) + { + path = parent.name + "/" + path; + parent = parent.parent; + } + return path; + } + + private static object GetComponentsFromTarget(string target, string searchMethod, bool includeNonPublicSerialized = true) + { + GameObject targetGo = FindObjectInternal(target, searchMethod); + if (targetGo == null) + { + return Response.Error( + $"Target GameObject ('{target}') not found using method '{searchMethod ?? "default"}'." + ); + } + + try + { + // --- Get components, immediately copy to list, and null original array --- + Component[] originalComponents = targetGo.GetComponents(); + List componentsToIterate = new List(originalComponents ?? Array.Empty()); // Copy immediately, handle null case + int componentCount = componentsToIterate.Count; + originalComponents = null; // Null the original reference + // Debug.Log($"[GetComponentsFromTarget] Found {componentCount} components on {targetGo.name}. Copied to list, nulled original. Starting REVERSE for loop..."); + // --- End Copy and Null --- + + // Check if component serialization would be too large + if (componentCount > 50) + { + return Response.Error( + $"GameObject '{targetGo.name}' has too many components ({componentCount}). Response would be too large. " + + "Hints to narrow scope:\n" + + "1. Use 'manage_gameobject' with action='find' to get basic GameObject info without components\n" + + "2. Query specific components by type instead of all components\n" + + "3. Use 'includeNonPublicSerialized': false to reduce serialized data per component\n" + + $"4. Component types found: {string.Join(", ", componentsToIterate.Take(10).Select(c => c.GetType().Name))}{(componentCount > 10 ? "..." : "")}" + ); + } + + var componentData = new List(); + + for (int i = componentCount - 1; i >= 0; i--) // Iterate backwards over the COPY + { + Component c = componentsToIterate[i]; // Use the copy + if (c == null) + { + // Debug.LogWarning($"[GetComponentsFromTarget REVERSE for] Encountered a null component at index {i} on {targetGo.name}. Skipping."); + continue; // Safety check + } + // Debug.Log($"[GetComponentsFromTarget REVERSE for] Processing component: {c.GetType()?.FullName ?? "null"} (ID: {c.GetInstanceID()}) at index {i} on {targetGo.name}"); + try + { + var data = Helpers.GameObjectSerializer.GetComponentData(c, includeNonPublicSerialized); + if (data != null) // Ensure GetComponentData didn't return null + { + componentData.Insert(0, data); // Insert at beginning to maintain original order in final list + } + // else + // { + // Debug.LogWarning($"[GetComponentsFromTarget REVERSE for] GetComponentData returned null for component {c.GetType().FullName} (ID: {c.GetInstanceID()}) on {targetGo.name}. Skipping addition."); + // } + } + catch (Exception ex) + { + Debug.LogError($"[GetComponentsFromTarget REVERSE for] Error processing component {c.GetType().FullName} (ID: {c.GetInstanceID()}) on {targetGo.name}: {ex.Message}\n{ex.StackTrace}"); + // Optionally add placeholder data or just skip + componentData.Insert(0, new JObject( // Insert error marker at beginning + new JProperty("typeName", c.GetType().FullName + " (Serialization Error)"), + new JProperty("instanceID", c.GetInstanceID()), + new JProperty("error", ex.Message) + )); + } + } + // Debug.Log($"[GetComponentsFromTarget] Finished REVERSE for loop."); + + // Cleanup the list we created + componentsToIterate.Clear(); + componentsToIterate = null; + + return Response.Success( + $"Retrieved {componentData.Count} components from '{targetGo.name}'.", + componentData // List was built in original order + ); + } + catch (Exception e) + { + return Response.Error( + $"Error getting components from '{targetGo.name}': {e.Message}" + ); + } + } + + private static object AddComponentToTarget( + JObject @params, + JToken targetToken, + string searchMethod + ) + { + GameObject targetGo = FindObjectInternal(targetToken, searchMethod); + if (targetGo == null) + { + return Response.Error( + $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + string typeName = null; + JObject properties = null; + + // Allow adding component specified directly or via componentsToAdd array (take first) + if (@params["componentName"] != null) + { + typeName = @params["componentName"]?.ToString(); + properties = @params["componentProperties"]?[typeName] as JObject; // Check if props are nested under name + } + else if ( + @params["componentsToAdd"] is JArray componentsToAddArray + && componentsToAddArray.Count > 0 + ) + { + var compToken = componentsToAddArray.First; + if (compToken.Type == JTokenType.String) + typeName = compToken.ToString(); + else if (compToken is JObject compObj) + { + typeName = compObj["typeName"]?.ToString(); + properties = compObj["properties"] as JObject; + } + } + + if (string.IsNullOrEmpty(typeName)) + { + return Response.Error( + "Component type name ('componentName' or first element in 'componentsToAdd') is required." + ); + } + + var addResult = AddComponentInternal(targetGo, typeName, properties); + if (addResult != null) + return addResult; // Return error + + EditorUtility.SetDirty(targetGo); + // Use the new serializer helper + return Response.Success( + $"Component '{typeName}' added to '{targetGo.name}'.", + Helpers.GameObjectSerializer.GetGameObjectData(targetGo) + ); // Return updated GO data + } + + private static object RemoveComponentFromTarget( + JObject @params, + JToken targetToken, + string searchMethod + ) + { + GameObject targetGo = FindObjectInternal(targetToken, searchMethod); + if (targetGo == null) + { + return Response.Error( + $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + string typeName = null; + // Allow removing component specified directly or via componentsToRemove array (take first) + if (@params["componentName"] != null) + { + typeName = @params["componentName"]?.ToString(); + } + else if ( + @params["componentsToRemove"] is JArray componentsToRemoveArray + && componentsToRemoveArray.Count > 0 + ) + { + typeName = componentsToRemoveArray.First?.ToString(); + } + + if (string.IsNullOrEmpty(typeName)) + { + return Response.Error( + "Component type name ('componentName' or first element in 'componentsToRemove') is required." + ); + } + + var removeResult = RemoveComponentInternal(targetGo, typeName); + if (removeResult != null) + return removeResult; // Return error + + EditorUtility.SetDirty(targetGo); + // Use the new serializer helper + return Response.Success( + $"Component '{typeName}' removed from '{targetGo.name}'.", + Helpers.GameObjectSerializer.GetGameObjectData(targetGo) + ); + } + + private static object SetComponentPropertyOnTarget( + JObject @params, + JToken targetToken, + string searchMethod + ) + { + GameObject targetGo = FindObjectInternal(targetToken, searchMethod); + if (targetGo == null) + { + return Response.Error( + $"Target GameObject ('{targetToken}') not found using method '{searchMethod ?? "default"}'." + ); + } + + string compName = @params["componentName"]?.ToString(); + JObject propertiesToSet = null; + + if (!string.IsNullOrEmpty(compName)) + { + // Properties might be directly under componentProperties or nested under the component name + if (@params["componentProperties"] is JObject compProps) + { + propertiesToSet = compProps[compName] as JObject ?? compProps; // Allow flat or nested structure + } + // Support simplified propertyName + propertyValue format for compatibility + else if (@params["propertyName"] != null && @params["propertyValue"] != null) + { + string propName = @params["propertyName"].ToString(); + JToken propValue = @params["propertyValue"]; + propertiesToSet = new JObject { [propName] = propValue }; + } + } + else + { + return Response.Error("'componentName' parameter is required."); + } + + if (propertiesToSet == null || !propertiesToSet.HasValues) + { + return Response.Error( + "'componentProperties' dictionary or 'propertyName'/'propertyValue' pair is required." + ); + } + + var setResult = SetComponentPropertiesInternal(targetGo, compName, propertiesToSet); + if (setResult != null) + return setResult; // Return error + + EditorUtility.SetDirty(targetGo); + // Use the new serializer helper + return Response.Success( + $"Properties set for component '{compName}' on '{targetGo.name}'.", + Helpers.GameObjectSerializer.GetGameObjectData(targetGo) + ); + } + + // --- Internal Helpers --- + + /// + /// Parses a JArray like [x, y, z] into a Vector3. + /// + private static Vector3? ParseVector3(JArray array) + { + if (array != null && array.Count == 3) + { + try + { + return new Vector3( + array[0].ToObject(), + array[1].ToObject(), + array[2].ToObject() + ); + } + catch (Exception ex) + { + Debug.LogWarning($"Failed to parse JArray as Vector3: {array}. Error: {ex.Message}"); + } + } + return null; + } + + /// + /// Finds a single GameObject based on token (ID, name, path) and search method. + /// + private static GameObject FindObjectInternal( + JToken targetToken, + string searchMethod, + JObject findParams = null + ) + { + // If find_all is not explicitly false, we still want only one for most single-target operations. + bool findAll = findParams?["findAll"]?.ToObject() ?? false; + // If a specific target ID is given, always find just that one. + if ( + targetToken?.Type == JTokenType.Integer + || (searchMethod == "by_id" && int.TryParse(targetToken?.ToString(), out _)) + ) + { + findAll = false; + } + List results = FindObjectsInternal( + targetToken, + searchMethod, + findAll, + findParams + ); + return results.Count > 0 ? results[0] : null; + } + + /// + /// Core logic for finding GameObjects based on various criteria. + /// + private static List FindObjectsInternal( + JToken targetToken, + string searchMethod, + bool findAll, + JObject findParams = null + ) + { + List results = new List(); + string searchTerm = findParams?["searchTerm"]?.ToString() ?? targetToken?.ToString(); // Use searchTerm if provided, else the target itself + bool searchInChildren = findParams?["searchInChildren"]?.ToObject() ?? false; + bool searchInactive = findParams?["searchInactive"]?.ToObject() ?? true; + bool hasExplicitSearchTerm = + findParams != null + && findParams["searchTerm"] != null + && findParams["searchTerm"].Type != JTokenType.Null; + + // Default search method if not specified + if (string.IsNullOrEmpty(searchMethod)) + { + if (targetToken?.Type == JTokenType.Integer) + searchMethod = "by_id"; + else if (!string.IsNullOrEmpty(searchTerm) && searchTerm.Contains('/')) + searchMethod = "by_path"; + else + searchMethod = "by_name"; // Default fallback + } + + GameObject rootSearchObject = null; + // If searching in children, find the initial target first + if (searchInChildren && targetToken != null) + { + rootSearchObject = FindObjectInternal(targetToken, "by_id_or_name_or_path"); // Find the root for child search + if (rootSearchObject == null) + { + Debug.LogWarning( + $"[ManageGameObject.Find] Root object '{targetToken}' for child search not found." + ); + return results; // Return empty if root not found + } + } + + switch (searchMethod) + { + case "by_id": + if (int.TryParse(searchTerm, out int instanceId)) + { + // EditorUtility.InstanceIDToObject is slow, iterate manually if possible + // GameObject obj = EditorUtility.InstanceIDToObject(instanceId) as GameObject; + var allObjects = GetAllSceneObjects(true); // Fetch all, filter by active state below + GameObject obj = allObjects.FirstOrDefault(go => + go != null && go.GetInstanceID() == instanceId + ); + if (obj != null && (searchInactive || obj.activeInHierarchy)) + results.Add(obj); + } + break; + case "by_name": + var searchPoolName = rootSearchObject + ? rootSearchObject + .GetComponentsInChildren(true) + .Select(t => t.gameObject) + : GetAllSceneObjects(true); + if (string.IsNullOrEmpty(searchTerm)) + { + break; + } + + // When searchTerm is explicitly provided, treat it as a fuzzy "contains" filter (case-insensitive). + // When only target is provided (no explicit searchTerm), keep exact name matching semantics. + if (hasExplicitSearchTerm) + { + results.AddRange( + searchPoolName.Where(go => + go != null + && !string.IsNullOrEmpty(go.name) + && go.name.IndexOf( + searchTerm, + StringComparison.OrdinalIgnoreCase + ) >= 0 + && (searchInactive || go.activeInHierarchy) + ) + ); + } + else + { + results.AddRange( + searchPoolName.Where(go => + go != null + && string.Equals( + go.name, + searchTerm, + StringComparison.OrdinalIgnoreCase + ) + && (searchInactive || go.activeInHierarchy) + ) + ); + } + break; + case "by_path": + // Path is relative to scene root or rootSearchObject + Transform foundTransform = rootSearchObject + ? rootSearchObject.transform.Find(searchTerm) + : GameObject.Find(searchTerm)?.transform; + if ( + foundTransform != null + && (searchInactive || foundTransform.gameObject.activeInHierarchy) + ) + results.Add(foundTransform.gameObject); + break; + case "by_tag": + var searchPoolTag = rootSearchObject + ? rootSearchObject + .GetComponentsInChildren(true) + .Select(t => t.gameObject) + : GetAllSceneObjects(true); + results.AddRange( + searchPoolTag.Where(go => + go != null + && go.CompareTag(searchTerm) + && (searchInactive || go.activeInHierarchy) + ) + ); + break; + case "by_layer": + var searchPoolLayer = rootSearchObject + ? rootSearchObject + .GetComponentsInChildren(true) + .Select(t => t.gameObject) + : GetAllSceneObjects(true); + if (int.TryParse(searchTerm, out int layerIndex)) + { + results.AddRange( + searchPoolLayer.Where(go => + go != null + && go.layer == layerIndex + && (searchInactive || go.activeInHierarchy) + ) + ); + } + else + { + int namedLayer = LayerMask.NameToLayer(searchTerm); + if (namedLayer != -1) + results.AddRange( + searchPoolLayer.Where(go => + go != null + && go.layer == namedLayer + && (searchInactive || go.activeInHierarchy) + ) + ); + } + break; + case "by_component": + Type componentType = FindType(searchTerm); + if (componentType != null) + { + IEnumerable searchPoolComp; + if (rootSearchObject) + { + searchPoolComp = rootSearchObject + .GetComponentsInChildren(componentType, searchInactive) + .Select(c => (c as Component).gameObject); + } + else + { +#if UNITY_2022_2_OR_NEWER + // Use FindObjectsByType for Unity 2022.2+ + FindObjectsInactive findInactive = searchInactive + ? FindObjectsInactive.Include + : FindObjectsInactive.Exclude; + searchPoolComp = UnityEngine + .Object.FindObjectsByType( + componentType, + findInactive, + FindObjectsSortMode.None + ) + .Select(c => (c as Component).gameObject); +#else + // Use deprecated FindObjectsOfType for Unity 2021 and earlier + searchPoolComp = UnityEngine + .Object.FindObjectsOfType(componentType, searchInactive) + .Select(c => (c as Component).gameObject); +#endif + } + results.AddRange( + searchPoolComp.Where(go => + go != null + && (searchInactive || go.activeInHierarchy) + ) + ); // Ensure GO is valid and respects active filter + } + else + { + Debug.LogWarning( + $"[ManageGameObject.Find] Component type not found: {searchTerm}" + ); + } + break; + case "by_id_or_name_or_path": // Helper method used internally + if (int.TryParse(searchTerm, out int id)) + { + var allObjectsId = GetAllSceneObjects(true); // Internal helper always sees all, filtered below + GameObject objById = allObjectsId.FirstOrDefault(go => + go != null && go.GetInstanceID() == id + ); + if (objById != null && (searchInactive || objById.activeInHierarchy)) + { + results.Add(objById); + break; + } + } + GameObject objByPath = GameObject.Find(searchTerm); + if (objByPath != null && (searchInactive || objByPath.activeInHierarchy)) + { + results.Add(objByPath); + break; + } + + var allObjectsName = GetAllSceneObjects(true); + results.AddRange( + allObjectsName.Where(go => + go != null + && string.Equals( + go.name, + searchTerm, + StringComparison.OrdinalIgnoreCase + ) + && (searchInactive || go.activeInHierarchy) + ) + ); + break; + default: + Debug.LogWarning( + $"[ManageGameObject.Find] Unknown search method: {searchMethod}" + ); + break; + } + + // If only one result is needed, return just the first one found. + if (!findAll && results.Count > 1) + { + return new List { results[0] }; + } + + return results.Distinct().ToList(); // Ensure uniqueness + } + + // Helper to get all scene objects efficiently + private static IEnumerable GetAllSceneObjects(bool includeInactive) + { + // SceneManager.GetActiveScene().GetRootGameObjects() is faster than FindObjectsOfType() + var rootObjects = SceneManager.GetActiveScene().GetRootGameObjects(); + var allObjects = new List(); + foreach (var root in rootObjects) + { + allObjects.AddRange( + root.GetComponentsInChildren(includeInactive) + .Select(t => t.gameObject) + ); + } + return allObjects; + } + + /// + /// Adds a component by type name and optionally sets properties. + /// Returns null on success, or an error response object on failure. + /// + private static object AddComponentInternal( + GameObject targetGo, + string typeName, + JObject properties + ) + { + Type componentType = FindType(typeName); + if (componentType == null) + { + return Response.Error( + $"Component type '{typeName}' not found or is not a valid Component." + ); + } + if (!typeof(Component).IsAssignableFrom(componentType)) + { + return Response.Error($"Type '{typeName}' is not a Component."); + } + + // Prevent adding Transform again + if (componentType == typeof(Transform)) + { + return Response.Error("Cannot add another Transform component."); + } + + // Check for 2D/3D physics component conflicts + bool isAdding2DPhysics = + typeof(Rigidbody2D).IsAssignableFrom(componentType) + || typeof(Collider2D).IsAssignableFrom(componentType); + bool isAdding3DPhysics = + typeof(Rigidbody).IsAssignableFrom(componentType) + || typeof(Collider).IsAssignableFrom(componentType); + + if (isAdding2DPhysics) + { + // Check if the GameObject already has any 3D Rigidbody or Collider + if ( + targetGo.GetComponent() != null + || targetGo.GetComponent() != null + ) + { + return Response.Error( + $"Cannot add 2D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 3D Rigidbody or Collider." + ); + } + } + else if (isAdding3DPhysics) + { + // Check if the GameObject already has any 2D Rigidbody or Collider + if ( + targetGo.GetComponent() != null + || targetGo.GetComponent() != null + ) + { + return Response.Error( + $"Cannot add 3D physics component '{typeName}' because the GameObject '{targetGo.name}' already has a 2D Rigidbody or Collider." + ); + } + } + + try + { + // Use Undo.AddComponent for undo support + Component newComponent = Undo.AddComponent(targetGo, componentType); + if (newComponent == null) + { + return Response.Error( + $"Failed to add component '{typeName}' to '{targetGo.name}'. It might be disallowed (e.g., adding script twice)." + ); + } + + // Set default values for specific component types + if (newComponent is Light light) + { + // Default newly added lights to directional + light.type = LightType.Directional; + } + + // Set properties if provided + if (properties != null) + { + var setResult = SetComponentPropertiesInternal( + targetGo, + typeName, + properties, + newComponent + ); // Pass the new component instance + if (setResult != null) + { + // If setting properties failed, maybe remove the added component? + Undo.DestroyObjectImmediate(newComponent); + return setResult; // Return the error from setting properties + } + } + + return null; // Success + } + catch (Exception e) + { + return Response.Error( + $"Error adding component '{typeName}' to '{targetGo.name}': {e.Message}" + ); + } + } + + /// + /// Removes a component by type name. + /// Returns null on success, or an error response object on failure. + /// + private static object RemoveComponentInternal(GameObject targetGo, string typeName) + { + Type componentType = FindType(typeName); + if (componentType == null) + { + return Response.Error($"Component type '{typeName}' not found for removal."); + } + + // Prevent removing essential components + if (componentType == typeof(Transform)) + { + return Response.Error("Cannot remove the Transform component."); + } + + Component componentToRemove = targetGo.GetComponent(componentType); + if (componentToRemove == null) + { + return Response.Error( + $"Component '{typeName}' not found on '{targetGo.name}' to remove." + ); + } + + try + { + // Handle known dependency chains before removing the primary component. + // Example: In URP, UniversalAdditionalLightData depends on Light, so we should + // remove the additional data component first to allow Light removal. + TryRemoveDependentComponents(targetGo, componentType); + + // Use Undo.DestroyObjectImmediate for undo support + Undo.DestroyObjectImmediate(componentToRemove); + return null; // Success + } + catch (Exception e) + { + return Response.Error( + $"Error removing component '{typeName}' from '{targetGo.name}': {e.Message}" + ); + } + } + + /// + /// Removes known dependent components that would otherwise block removal of the primary component. + /// This keeps behavior robust across render pipelines (e.g., URP additional light data). + /// + private static void TryRemoveDependentComponents(GameObject targetGo, Type primaryComponentType) + { + try + { + // Special-case Light in URP: UniversalAdditionalLightData depends on Light. + if (primaryComponentType == typeof(Light)) + { + // Try to resolve the URP additional light data type without hard assembly references. + Type urpAdditionalLightType = FindType("UnityEngine.Rendering.Universal.UniversalAdditionalLightData"); + if (urpAdditionalLightType != null) + { + Component extra = targetGo.GetComponent(urpAdditionalLightType); + if (extra != null) + { + Undo.DestroyObjectImmediate(extra); + } + } + } + } + catch (Exception ex) + { + Debug.LogWarning( + $"[ManageGameObject] Failed to remove dependent components for '{primaryComponentType.FullName}' on '{targetGo.name}': {ex.Message}" + ); + } + } + + /// + /// Sets properties on a component. + /// Returns null on success, or an error response object on failure. + /// + private static object SetComponentPropertiesInternal( + GameObject targetGo, + string compName, + JObject propertiesToSet, + Component targetComponentInstance = null + ) + { + Component targetComponent = targetComponentInstance; + if (targetComponent == null) + { + if (ComponentResolver.TryResolve(compName, out var compType, out var compError)) + { + targetComponent = targetGo.GetComponent(compType); + } + else + { + targetComponent = targetGo.GetComponent(compName); // fallback to string-based lookup + } + } + if (targetComponent == null) + { + return Response.Error( + $"Component '{compName}' not found on '{targetGo.name}' to set properties." + ); + } + + Undo.RecordObject(targetComponent, "Set Component Properties"); + + var failures = new List(); + foreach (var prop in propertiesToSet.Properties()) + { + string propName = prop.Name; + JToken propValue = prop.Value; + + try + { + string failureCode; + string failureMessage; + bool setResult = SetProperty(targetComponent, propName, propValue, out failureCode, out failureMessage); + if (!setResult) + { + string msg; + if (failureCode == "conversion_failed" || failureCode == "exception") + { + msg = failureMessage ?? $"Conversion failed for '{propName}'."; + } + else + { + var availableProperties = ComponentResolver.GetAllComponentProperties(targetComponent.GetType()); + var suggestions = ComponentResolver.GetAIPropertySuggestions(propName, availableProperties); + msg = suggestions.Any() + ? $"Property '{propName}' not found. Did you mean: {string.Join(", ", suggestions)}? Available: [{string.Join(", ", availableProperties)}]" + : $"Property '{propName}' not found. Available: [{string.Join(", ", availableProperties)}]"; + } + Debug.LogWarning($"[ManageGameObject] {msg}"); + failures.Add(new { property = propName, code = failureCode ?? "member_not_found", message = msg }); + } + } + catch (Exception e) + { + Debug.LogError( + $"[ManageGameObject] Error setting property '{propName}' on '{compName}': {e.Message}" + ); + failures.Add(new { property = propName, code = "exception", message = $"Error setting '{propName}': {e.Message}" }); + } + } + EditorUtility.SetDirty(targetComponent); + return failures.Count == 0 + ? null + : new + { + success = false, + code = "set_component_property_failed", + message = $"One or more properties failed on '{compName}'.", + data = new { errors = failures } + }; + } + + /// + /// Helper to set a property or field via reflection, handling basic types. + /// + private static bool SetProperty(object target, string memberName, JToken value, out string failureCode, out string failureMessage) + { + failureCode = null; + failureMessage = null; + Type type = target.GetType(); + BindingFlags flags = + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; + + // Use shared serializer to avoid per-call allocation + var inputSerializer = InputSerializer; + + try + { + // Handle special case for materials with dot notation (material.property) + // Examples: material.color, sharedMaterial.color, materials[0].color + if (memberName.Contains('.') || memberName.Contains('[')) + { + // Pass the inputSerializer down for nested conversions + bool ok = SetNestedProperty(target, memberName, value, inputSerializer); + if (!ok) + { + failureCode = "member_not_found"; + } + return ok; + } + + PropertyInfo propInfo = type.GetProperty(memberName, flags); + if (propInfo != null && propInfo.CanWrite) + { + object convertedValue = null; + try + { + // Use the inputSerializer for conversion + convertedValue = ConvertJTokenToType(value, propInfo.PropertyType, inputSerializer); + } + catch (Exception ex) + { + failureCode = "conversion_failed"; + failureMessage = $"Conversion failed for property '{memberName}' (Type: {propInfo.PropertyType.FullName}): {ex.Message}"; + return false; + } + if (convertedValue != null || value.Type == JTokenType.Null) // Allow setting null + { + propInfo.SetValue(target, convertedValue); + return true; + } + failureCode = "conversion_failed"; + failureMessage = $"Conversion failed for property '{memberName}' (Type: {propInfo.PropertyType.FullName}) from token: {value.ToString(Formatting.None)}"; + return false; + } + else + { + FieldInfo fieldInfo = type.GetField(memberName, flags); + if (fieldInfo != null) // Check if !IsLiteral? + { + object convertedValue = null; + try + { + // Use the inputSerializer for conversion + convertedValue = ConvertJTokenToType(value, fieldInfo.FieldType, inputSerializer); + } + catch (Exception ex) + { + failureCode = "conversion_failed"; + failureMessage = $"Conversion failed for field '{memberName}' (Type: {fieldInfo.FieldType.FullName}): {ex.Message}"; + return false; + } + if (convertedValue != null || value.Type == JTokenType.Null) // Allow setting null + { + fieldInfo.SetValue(target, convertedValue); + return true; + } + failureCode = "conversion_failed"; + failureMessage = $"Conversion failed for field '{memberName}' (Type: {fieldInfo.FieldType.FullName}) from token: {value.ToString(Formatting.None)}"; + return false; + } + else + { + // Try NonPublic [SerializeField] fields + var npField = type.GetField(memberName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (npField != null && npField.GetCustomAttribute() != null) + { + object convertedValue = null; + try + { + convertedValue = ConvertJTokenToType(value, npField.FieldType, inputSerializer); + } + catch (Exception ex) + { + failureCode = "conversion_failed"; + failureMessage = $"Conversion failed for field '{memberName}' (Type: {npField.FieldType.FullName}): {ex.Message}"; + return false; + } + if (convertedValue != null || value.Type == JTokenType.Null) + { + npField.SetValue(target, convertedValue); + return true; + } + failureCode = "conversion_failed"; + failureMessage = $"Conversion failed for field '{memberName}' (Type: {npField.FieldType.FullName}) from token: {value.ToString(Formatting.None)}"; + return false; + } + } + } + } + catch (Exception ex) + { + failureCode = "exception"; + failureMessage = $"Failed to set '{memberName}' on {type.Name}: {ex.Message}\nToken: {value.ToString(Formatting.None)}"; + } + if (failureCode == null) + { + failureCode = "member_not_found"; + } + return false; + } + + /// + /// Sets a nested property using dot notation (e.g., "material.color") or array access (e.g., "materials[0]") + /// + // Pass the input serializer for conversions + //Using the serializer helper + private static bool SetNestedProperty(object target, string path, JToken value, JsonSerializer inputSerializer) + { + try + { + // Split the path into parts (handling both dot notation and array indexing) + string[] pathParts = SplitPropertyPath(path); + if (pathParts.Length == 0) + return false; + + object currentObject = target; + Type currentType = currentObject.GetType(); + BindingFlags flags = + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase; + + // Traverse the path until we reach the final property + for (int i = 0; i < pathParts.Length - 1; i++) + { + string part = pathParts[i]; + bool isArray = false; + int arrayIndex = -1; + + // Check if this part contains array indexing + if (part.Contains("[")) + { + int startBracket = part.IndexOf('['); + int endBracket = part.IndexOf(']'); + if (startBracket > 0 && endBracket > startBracket) + { + string indexStr = part.Substring( + startBracket + 1, + endBracket - startBracket - 1 + ); + if (int.TryParse(indexStr, out arrayIndex)) + { + isArray = true; + part = part.Substring(0, startBracket); + } + } + } + // Get the property/field + PropertyInfo propInfo = currentType.GetProperty(part, flags); + FieldInfo fieldInfo = null; + if (propInfo == null) + { + fieldInfo = currentType.GetField(part, flags); + if (fieldInfo == null) + { + Debug.LogWarning( + $"[SetNestedProperty] Could not find property or field '{part}' on type '{currentType.Name}'" + ); + return false; + } + } + + // Get the value + currentObject = + propInfo != null + ? propInfo.GetValue(currentObject) + : fieldInfo.GetValue(currentObject); + //Need to stop if current property is null + if (currentObject == null) + { + Debug.LogWarning( + $"[SetNestedProperty] Property '{part}' is null, cannot access nested properties." + ); + return false; + } + // If this part was an array or list, access the specific index + if (isArray) + { + if (currentObject is Material[]) + { + var materials = currentObject as Material[]; + if (arrayIndex < 0 || arrayIndex >= materials.Length) + { + Debug.LogWarning( + $"[SetNestedProperty] Material index {arrayIndex} out of range (0-{materials.Length - 1})" + ); + return false; + } + currentObject = materials[arrayIndex]; + } + else if (currentObject is System.Collections.IList) + { + var list = currentObject as System.Collections.IList; + if (arrayIndex < 0 || arrayIndex >= list.Count) + { + Debug.LogWarning( + $"[SetNestedProperty] Index {arrayIndex} out of range (0-{list.Count - 1})" + ); + return false; + } + currentObject = list[arrayIndex]; + } + else + { + Debug.LogWarning( + $"[SetNestedProperty] Property '{part}' is not an array or list, cannot access by index." + ); + return false; + } + } + currentType = currentObject.GetType(); + } + + // Set the final property + string finalPart = pathParts[pathParts.Length - 1]; + + // Special handling for Material properties (shader properties) + if (currentObject is Material material && finalPart.StartsWith("_")) + { + // Use the serializer to convert the JToken value first + if (value is JArray jArray) + { + // Try converting to known types that SetColor/SetVector accept + if (jArray.Count == 4) { + try { Color color = value.ToObject(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } + try { Vector4 vec = value.ToObject(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { } + } else if (jArray.Count == 3) { + try { Color color = value.ToObject(inputSerializer); material.SetColor(finalPart, color); return true; } catch { } // ToObject handles conversion to Color + } else if (jArray.Count == 2) { + try { Vector2 vec = value.ToObject(inputSerializer); material.SetVector(finalPart, vec); return true; } catch { } + } + } + else if (value.Type == JTokenType.Float || value.Type == JTokenType.Integer) + { + try { material.SetFloat(finalPart, value.ToObject(inputSerializer)); return true; } catch { } + } + else if (value.Type == JTokenType.Boolean) + { + try { material.SetFloat(finalPart, value.ToObject(inputSerializer) ? 1f : 0f); return true; } catch { } + } + else if (value.Type == JTokenType.String) + { + // Try converting to Texture using the serializer/converter + try { + Texture texture = value.ToObject(inputSerializer); + if (texture != null) { + material.SetTexture(finalPart, texture); + return true; + } + } catch { } + } + + Debug.LogWarning( + $"[SetNestedProperty] Unsupported or failed conversion for material property '{finalPart}' from value: {value.ToString(Formatting.None)}" + ); + return false; + } + + // For standard properties (not shader specific) + PropertyInfo finalPropInfo = currentType.GetProperty(finalPart, flags); + if (finalPropInfo != null && finalPropInfo.CanWrite) + { + // Use the inputSerializer for conversion + object convertedValue = ConvertJTokenToType(value, finalPropInfo.PropertyType, inputSerializer); + if (convertedValue != null || value.Type == JTokenType.Null) + { + finalPropInfo.SetValue(currentObject, convertedValue); + return true; + } + else { + Debug.LogWarning($"[SetNestedProperty] Final conversion failed for property '{finalPart}' (Type: {finalPropInfo.PropertyType.Name}) from token: {value.ToString(Formatting.None)}"); + } + } + else + { + FieldInfo finalFieldInfo = currentType.GetField(finalPart, flags); + if (finalFieldInfo != null) + { + // Use the inputSerializer for conversion + object convertedValue = ConvertJTokenToType(value, finalFieldInfo.FieldType, inputSerializer); + if (convertedValue != null || value.Type == JTokenType.Null) + { + finalFieldInfo.SetValue(currentObject, convertedValue); + return true; + } + else { + Debug.LogWarning($"[SetNestedProperty] Final conversion failed for field '{finalPart}' (Type: {finalFieldInfo.FieldType.Name}) from token: {value.ToString(Formatting.None)}"); + } + } + else + { + Debug.LogWarning( + $"[SetNestedProperty] Could not find final writable property or field '{finalPart}' on type '{currentType.Name}'" + ); + } + } + } + catch (Exception ex) + { + Debug.LogError( + $"[SetNestedProperty] Error setting nested property '{path}': {ex.Message}\nToken: {value.ToString(Formatting.None)}" + ); + } + + return false; + } + + + /// + /// Split a property path into parts, handling both dot notation and array indexers + /// + private static string[] SplitPropertyPath(string path) + { + // Handle complex paths with both dots and array indexers + List parts = new List(); + int startIndex = 0; + bool inBrackets = false; + + for (int i = 0; i < path.Length; i++) + { + char c = path[i]; + + if (c == '[') + { + inBrackets = true; + } + else if (c == ']') + { + inBrackets = false; + } + else if (c == '.' && !inBrackets) + { + // Found a dot separator outside of brackets + parts.Add(path.Substring(startIndex, i - startIndex)); + startIndex = i + 1; + } + } + if (startIndex < path.Length) + { + parts.Add(path.Substring(startIndex)); + } + return parts.ToArray(); + } + + /// + /// Simple JToken to Type conversion for common Unity types, using JsonSerializer. + /// + // Pass the input serializer + private static object ConvertJTokenToType(JToken token, Type targetType, JsonSerializer inputSerializer) + { + if (token == null || token.Type == JTokenType.Null) + { + if (targetType.IsValueType && Nullable.GetUnderlyingType(targetType) == null) + { + Debug.LogWarning($"Cannot assign null to non-nullable value type {targetType.Name}. Returning default value."); + return Activator.CreateInstance(targetType); + } + return null; + } + + // Fast-path for common Unity structs where we want to gracefully accept both + // object-style {x,y,z} and array-style [x,y,z] representations. + try + { + if (targetType == typeof(Vector3)) + { + return ParseJTokenToVector3(token); + } + if (targetType == typeof(Vector2)) + { + return ParseJTokenToVector2(token); + } + if (targetType == typeof(Quaternion)) + { + return ParseJTokenToQuaternion(token); + } + if (targetType == typeof(Color)) + { + return ParseJTokenToColor(token); + } + if (targetType == typeof(Rect)) + { + return ParseJTokenToRect(token); + } + if (targetType == typeof(Bounds)) + { + return ParseJTokenToBounds(token); + } + } + catch (Exception ex) + { + Debug.LogWarning( + $"[ConvertJTokenToType] Fallback parse for {targetType.FullName} failed: {ex.Message}\nToken: {token.ToString(Formatting.None)}" + ); + // Fall through to serializer-based conversion below + } + + try + { + // Use the provided serializer instance which includes our custom converters + return token.ToObject(targetType, inputSerializer); + } + catch (JsonSerializationException jsonEx) + { + Debug.LogError( + $"JSON Deserialization Error converting token to {targetType.FullName}: {jsonEx.Message}\nToken: {token.ToString(Formatting.None)}" + ); + // As a last resort for known vector types, try the fallback parsers once more to + // avoid hard failures during property setting. + try + { + if (targetType == typeof(Vector3)) + return ParseJTokenToVector3(token); + if (targetType == typeof(Vector2)) + return ParseJTokenToVector2(token); + if (targetType == typeof(Quaternion)) + return ParseJTokenToQuaternion(token); + if (targetType == typeof(Color)) + return ParseJTokenToColor(token); + if (targetType == typeof(Rect)) + return ParseJTokenToRect(token); + if (targetType == typeof(Bounds)) + return ParseJTokenToBounds(token); + } + catch (Exception fallbackEx) + { + Debug.LogError( + $"[ConvertJTokenToType] Secondary fallback for {targetType.FullName} also failed: {fallbackEx.Message}\nToken: {token.ToString(Formatting.None)}" + ); + } + + // If everything failed, rethrow so callers can surface a clear error. + throw; + } + catch (ArgumentException argEx) + { + Debug.LogError( + $"Argument Error converting token to {targetType.FullName}: {argEx.Message}\nToken: {token.ToString(Formatting.None)}" + ); + throw; + } + catch (Exception ex) + { + Debug.LogError( + $"Unexpected error converting token to {targetType.FullName}: {ex}\nToken: {token.ToString(Formatting.None)}" + ); + throw; + } + } + + // --- ParseJTokenTo... helpers are likely redundant now with the serializer approach --- + // Keep them temporarily for reference or if specific fallback logic is ever needed. + + private static Vector3 ParseJTokenToVector3(JToken token) + { + // ... (implementation - likely replaced by Vector3Converter) ... + // Consider removing these if the serializer handles them reliably. + if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z")) + { + return new Vector3(obj["x"].ToObject(), obj["y"].ToObject(), obj["z"].ToObject()); + } + if (token is JArray arr && arr.Count >= 3) + { + return new Vector3(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject()); + } + Debug.LogWarning($"Could not parse JToken '{token}' as Vector3 using fallback. Returning Vector3.zero."); + return Vector3.zero; + + } + private static Vector2 ParseJTokenToVector2(JToken token) + { + // ... (implementation - likely replaced by Vector2Converter) ... + if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y")) + { + return new Vector2(obj["x"].ToObject(), obj["y"].ToObject()); + } + if (token is JArray arr && arr.Count >= 2) + { + return new Vector2(arr[0].ToObject(), arr[1].ToObject()); + } + Debug.LogWarning($"Could not parse JToken '{token}' as Vector2 using fallback. Returning Vector2.zero."); + return Vector2.zero; + } + private static Quaternion ParseJTokenToQuaternion(JToken token) + { + // ... (implementation - likely replaced by QuaternionConverter) ... + if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("z") && obj.ContainsKey("w")) + { + return new Quaternion(obj["x"].ToObject(), obj["y"].ToObject(), obj["z"].ToObject(), obj["w"].ToObject()); + } + if (token is JArray arr && arr.Count >= 4) + { + return new Quaternion(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); + } + Debug.LogWarning($"Could not parse JToken '{token}' as Quaternion using fallback. Returning Quaternion.identity."); + return Quaternion.identity; + } + private static Color ParseJTokenToColor(JToken token) + { + // ... (implementation - likely replaced by ColorConverter) ... + if (token is JObject obj && obj.ContainsKey("r") && obj.ContainsKey("g") && obj.ContainsKey("b") && obj.ContainsKey("a")) + { + return new Color(obj["r"].ToObject(), obj["g"].ToObject(), obj["b"].ToObject(), obj["a"].ToObject()); + } + if (token is JArray arr && arr.Count >= 4) + { + return new Color(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); + } + Debug.LogWarning($"Could not parse JToken '{token}' as Color using fallback. Returning Color.white."); + return Color.white; + } + private static Rect ParseJTokenToRect(JToken token) + { + // ... (implementation - likely replaced by RectConverter) ... + if (token is JObject obj && obj.ContainsKey("x") && obj.ContainsKey("y") && obj.ContainsKey("width") && obj.ContainsKey("height")) + { + return new Rect(obj["x"].ToObject(), obj["y"].ToObject(), obj["width"].ToObject(), obj["height"].ToObject()); + } + if (token is JArray arr && arr.Count >= 4) + { + return new Rect(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject(), arr[3].ToObject()); + } + Debug.LogWarning($"Could not parse JToken '{token}' as Rect using fallback. Returning Rect.zero."); + return Rect.zero; + } + private static Bounds ParseJTokenToBounds(JToken token) + { + // ... (implementation - likely replaced by BoundsConverter) ... + if (token is JObject obj && obj.ContainsKey("center") && obj.ContainsKey("size")) + { + // Requires Vector3 conversion, which should ideally use the serializer too + Vector3 center = ParseJTokenToVector3(obj["center"]); // Or use obj["center"].ToObject(inputSerializer) + Vector3 size = ParseJTokenToVector3(obj["size"]); // Or use obj["size"].ToObject(inputSerializer) + return new Bounds(center, size); + } + // Array fallback for Bounds is less intuitive, maybe remove? + // if (token is JArray arr && arr.Count >= 6) + // { + // return new Bounds(new Vector3(arr[0].ToObject(), arr[1].ToObject(), arr[2].ToObject()), new Vector3(arr[3].ToObject(), arr[4].ToObject(), arr[5].ToObject())); + // } + Debug.LogWarning($"Could not parse JToken '{token}' as Bounds using fallback. Returning new Bounds(Vector3.zero, Vector3.zero)."); + return new Bounds(Vector3.zero, Vector3.zero); + } + // --- End Redundant Parse Helpers --- + + /// + /// Finds a specific UnityEngine.Object based on a find instruction JObject. + /// Primarily used by UnityEngineObjectConverter during deserialization. + /// + // Made public static so UnityEngineObjectConverter can call it. Moved from ConvertJTokenToType. + public static UnityEngine.Object FindObjectByInstruction(JObject instruction, Type targetType) + { + string findTerm = instruction["find"]?.ToString(); + string method = instruction["method"]?.ToString()?.ToLower(); + string componentName = instruction["component"]?.ToString(); // Specific component to get + + if (string.IsNullOrEmpty(findTerm)) + { + Debug.LogWarning("Find instruction missing 'find' term."); + return null; + } + + // Use a flexible default search method if none provided + string searchMethodToUse = string.IsNullOrEmpty(method) ? "by_id_or_name_or_path" : method; + + // If the target is an asset (Material, Texture, ScriptableObject etc.) try AssetDatabase first + if (typeof(Material).IsAssignableFrom(targetType) || + typeof(Texture).IsAssignableFrom(targetType) || + typeof(ScriptableObject).IsAssignableFrom(targetType) || + targetType.FullName.StartsWith("UnityEngine.U2D") || // Sprites etc. + typeof(AudioClip).IsAssignableFrom(targetType) || + typeof(AnimationClip).IsAssignableFrom(targetType) || + typeof(Font).IsAssignableFrom(targetType) || + typeof(Shader).IsAssignableFrom(targetType) || + typeof(ComputeShader).IsAssignableFrom(targetType) || + typeof(GameObject).IsAssignableFrom(targetType) && findTerm.StartsWith("Assets/")) // Prefab check + { + // Try loading directly by path/GUID first + UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(findTerm, targetType); + if (asset != null) return asset; + asset = AssetDatabase.LoadAssetAtPath(findTerm); // Try generic if type specific failed + if (asset != null && targetType.IsAssignableFrom(asset.GetType())) return asset; + + + // If direct path failed, try finding by name/type using FindAssets + string searchFilter = $"t:{targetType.Name} {System.IO.Path.GetFileNameWithoutExtension(findTerm)}"; // Search by type and name + string[] guids = AssetDatabase.FindAssets(searchFilter); + + if (guids.Length == 1) + { + asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(guids[0]), targetType); + if (asset != null) return asset; + } + else if (guids.Length > 1) + { + Debug.LogWarning($"[FindObjectByInstruction] Ambiguous asset find: Found {guids.Length} assets matching filter '{searchFilter}'. Provide a full path or unique name."); + // Optionally return the first one? Or null? Returning null is safer. + return null; + } + // If still not found, fall through to scene search (though unlikely for assets) + } + + + // --- Scene Object Search --- + // Find the GameObject using the internal finder + GameObject foundGo = FindObjectInternal(new JValue(findTerm), searchMethodToUse); + + if (foundGo == null) + { + // Don't warn yet, could still be an asset not found above + // Debug.LogWarning($"Could not find GameObject using instruction: {instruction}"); + return null; + } + + // Now, get the target object/component from the found GameObject + if (targetType == typeof(GameObject)) + { + return foundGo; // We were looking for a GameObject + } + else if (typeof(Component).IsAssignableFrom(targetType)) + { + Type componentToGetType = targetType; + if (!string.IsNullOrEmpty(componentName)) + { + Type specificCompType = FindType(componentName); + if (specificCompType != null && typeof(Component).IsAssignableFrom(specificCompType)) + { + componentToGetType = specificCompType; + } + else + { + Debug.LogWarning($"Could not find component type '{componentName}' specified in find instruction. Falling back to target type '{targetType.Name}'."); + } + } + + Component foundComp = foundGo.GetComponent(componentToGetType); + if (foundComp == null) + { + Debug.LogWarning($"Found GameObject '{foundGo.name}' but could not find component of type '{componentToGetType.Name}'."); + } + return foundComp; + } + else + { + Debug.LogWarning($"Find instruction handling not implemented for target type: {targetType.Name}"); + return null; + } + } + + + /// + /// Robust component resolver that avoids Assembly.LoadFrom and works with asmdefs. + /// Searches already-loaded assemblies, prioritizing runtime script assemblies. + /// + private static Type FindType(string typeName) + { + if (ComponentResolver.TryResolve(typeName, out Type resolvedType, out string error)) + { + return resolvedType; + } + + // Log the resolver error if type wasn't found + if (!string.IsNullOrEmpty(error)) + { + Debug.LogWarning($"[FindType] {error}"); + } + + return null; + } + } + + /// + /// Robust component resolver that avoids Assembly.LoadFrom and supports assembly definitions. + /// Prioritizes runtime (Player) assemblies over Editor assemblies. + /// + internal static class ComponentResolver + { + private static readonly Dictionary CacheByFqn = new(StringComparer.Ordinal); + private static readonly Dictionary CacheByName = new(StringComparer.Ordinal); + + /// + /// Resolve a Component/MonoBehaviour type by short or fully-qualified name. + /// Prefers runtime (Player) script assemblies; falls back to Editor assemblies. + /// Never uses Assembly.LoadFrom. + /// + public static bool TryResolve(string nameOrFullName, out Type type, out string error) + { + error = string.Empty; + type = null!; + + // Handle null/empty input + if (string.IsNullOrWhiteSpace(nameOrFullName)) + { + error = "Component name cannot be null or empty"; + return false; + } + + // 1) Exact cache hits + if (CacheByFqn.TryGetValue(nameOrFullName, out type)) return true; + if (!nameOrFullName.Contains(".") && CacheByName.TryGetValue(nameOrFullName, out type)) return true; + type = Type.GetType(nameOrFullName, throwOnError: false); + if (IsValidComponent(type)) { Cache(type); return true; } + + // 2) Search loaded assemblies (prefer Player assemblies) + var candidates = FindCandidates(nameOrFullName); + if (candidates.Count == 1) { type = candidates[0]; Cache(type); return true; } + if (candidates.Count > 1) { error = Ambiguity(nameOrFullName, candidates); type = null!; return false; } + +#if UNITY_EDITOR + // 3) Last resort: Editor-only TypeCache (fast index) + var tc = TypeCache.GetTypesDerivedFrom() + .Where(t => NamesMatch(t, nameOrFullName)); + candidates = PreferPlayer(tc).ToList(); + if (candidates.Count == 1) { type = candidates[0]; Cache(type); return true; } + if (candidates.Count > 1) { error = Ambiguity(nameOrFullName, candidates); type = null!; return false; } +#endif + + error = $"Component type '{nameOrFullName}' not found in loaded runtime assemblies. " + + "Use a fully-qualified name (Namespace.TypeName) and ensure the script compiled."; + type = null!; + return false; + } + + private static bool NamesMatch(Type t, string q) => + t.Name.Equals(q, StringComparison.Ordinal) || + (t.FullName?.Equals(q, StringComparison.Ordinal) ?? false); + + private static bool IsValidComponent(Type t) => + t != null && typeof(Component).IsAssignableFrom(t); + + private static void Cache(Type t) + { + if (t.FullName != null) CacheByFqn[t.FullName] = t; + CacheByName[t.Name] = t; + } + + private static List FindCandidates(string query) + { + bool isShort = !query.Contains('.'); + var loaded = AppDomain.CurrentDomain.GetAssemblies(); + +#if UNITY_EDITOR + // Names of Player (runtime) script assemblies (asmdefs + Assembly-CSharp) + var playerAsmNames = new HashSet( + UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player).Select(a => a.name), + StringComparer.Ordinal); + + IEnumerable playerAsms = loaded.Where(a => playerAsmNames.Contains(a.GetName().Name)); + IEnumerable editorAsms = loaded.Except(playerAsms); +#else + IEnumerable playerAsms = loaded; + IEnumerable editorAsms = Array.Empty(); +#endif + static IEnumerable SafeGetTypes(System.Reflection.Assembly a) + { + try { return a.GetTypes(); } + catch (ReflectionTypeLoadException rtle) { return rtle.Types.Where(t => t != null)!; } + } + + Func match = isShort + ? (t => t.Name.Equals(query, StringComparison.Ordinal)) + : (t => t.FullName!.Equals(query, StringComparison.Ordinal)); + + var fromPlayer = playerAsms.SelectMany(SafeGetTypes) + .Where(IsValidComponent) + .Where(match); + var fromEditor = editorAsms.SelectMany(SafeGetTypes) + .Where(IsValidComponent) + .Where(match); + + var list = new List(fromPlayer); + if (list.Count == 0) list.AddRange(fromEditor); + return list; + } + +#if UNITY_EDITOR + private static IEnumerable PreferPlayer(IEnumerable seq) + { + var player = new HashSet( + UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Player).Select(a => a.name), + StringComparer.Ordinal); + + return seq.OrderBy(t => player.Contains(t.Assembly.GetName().Name) ? 0 : 1); + } +#endif + + private static string Ambiguity(string query, IEnumerable cands) + { + var lines = cands.Select(t => $"{t.FullName} (assembly {t.Assembly.GetName().Name})"); + return $"Multiple component types matched '{query}':\n - " + string.Join("\n - ", lines) + + "\nProvide a fully qualified type name to disambiguate."; + } + + /// + /// Gets all accessible property and field names from a component type. + /// + public static List GetAllComponentProperties(Type componentType) + { + if (componentType == null) return new List(); + + var properties = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.CanWrite) + .Select(p => p.Name); + + var fields = componentType.GetFields(BindingFlags.Public | BindingFlags.Instance) + .Where(f => !f.IsInitOnly && !f.IsLiteral) + .Select(f => f.Name); + + // Also include SerializeField private fields (common in Unity) + var serializeFields = componentType.GetFields(BindingFlags.NonPublic | BindingFlags.Instance) + .Where(f => f.GetCustomAttribute() != null) + .Select(f => f.Name); + + return properties.Concat(fields).Concat(serializeFields).Distinct().OrderBy(x => x).ToList(); + } + + /// + /// Uses AI to suggest the most likely property matches for a user's input. + /// + public static List GetAIPropertySuggestions(string userInput, List availableProperties) + { + if (string.IsNullOrWhiteSpace(userInput) || !availableProperties.Any()) + return new List(); + + // Simple caching to avoid repeated AI calls for the same input + var cacheKey = $"{userInput.ToLowerInvariant()}:{string.Join(",", availableProperties)}"; + if (PropertySuggestionCache.TryGetValue(cacheKey, out var cached)) + return cached; + + try + { + var prompt = $"A Unity developer is trying to set a component property but used an incorrect name.\n\n" + + $"User requested: \"{userInput}\"\n" + + $"Available properties: [{string.Join(", ", availableProperties)}]\n\n" + + $"Find 1-3 most likely matches considering:\n" + + $"- Unity Inspector display names vs actual field names (e.g., \"Max Reach Distance\" → \"maxReachDistance\")\n" + + $"- camelCase vs PascalCase vs spaces\n" + + $"- Similar meaning/semantics\n" + + $"- Common Unity naming patterns\n\n" + + $"Return ONLY the matching property names, comma-separated, no quotes or explanation.\n" + + $"If confidence is low (<70%), return empty string.\n\n" + + $"Examples:\n" + + $"- \"Max Reach Distance\" → \"maxReachDistance\"\n" + + $"- \"Health Points\" → \"healthPoints, hp\"\n" + + $"- \"Move Speed\" → \"moveSpeed, movementSpeed\""; + + // For now, we'll use a simple rule-based approach that mimics AI behavior + // This can be replaced with actual AI calls later + var suggestions = GetRuleBasedSuggestions(userInput, availableProperties); + + PropertySuggestionCache[cacheKey] = suggestions; + return suggestions; + } + catch (Exception ex) + { + Debug.LogWarning($"[AI Property Matching] Error getting suggestions for '{userInput}': {ex.Message}"); + return new List(); + } + } + + private static readonly Dictionary> PropertySuggestionCache = new(); + + /// + /// Rule-based suggestions that mimic AI behavior for property matching. + /// This provides immediate value while we could add real AI integration later. + /// + private static List GetRuleBasedSuggestions(string userInput, List availableProperties) + { + var suggestions = new List(); + var cleanedInput = userInput.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); + + foreach (var property in availableProperties) + { + var cleanedProperty = property.ToLowerInvariant().Replace(" ", "").Replace("-", "").Replace("_", ""); + + // Exact match after cleaning + if (cleanedProperty == cleanedInput) + { + suggestions.Add(property); + continue; + } + + // Check if property contains all words from input + var inputWords = userInput.ToLowerInvariant().Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries); + if (inputWords.All(word => cleanedProperty.Contains(word.ToLowerInvariant()))) + { + suggestions.Add(property); + continue; + } + + // Levenshtein distance for close matches + if (LevenshteinDistance(cleanedInput, cleanedProperty) <= Math.Max(2, cleanedInput.Length / 4)) + { + suggestions.Add(property); + } + } + + // Prioritize exact matches, then by similarity + return suggestions.OrderBy(s => LevenshteinDistance(cleanedInput, s.ToLowerInvariant().Replace(" ", ""))) + .Take(3) + .ToList(); + } + + /// + /// Calculates Levenshtein distance between two strings for similarity matching. + /// + private static int LevenshteinDistance(string s1, string s2) + { + if (string.IsNullOrEmpty(s1)) return s2?.Length ?? 0; + if (string.IsNullOrEmpty(s2)) return s1.Length; + + var matrix = new int[s1.Length + 1, s2.Length + 1]; + + for (int i = 0; i <= s1.Length; i++) matrix[i, 0] = i; + for (int j = 0; j <= s2.Length; j++) matrix[0, j] = j; + + for (int i = 1; i <= s1.Length; i++) + { + for (int j = 1; j <= s2.Length; j++) + { + int cost = (s2[j - 1] == s1[i - 1]) ? 0 : 1; + matrix[i, j] = Math.Min(Math.Min( + matrix[i - 1, j] + 1, // deletion + matrix[i, j - 1] + 1), // insertion + matrix[i - 1, j - 1] + cost); // substitution + } + } + + return matrix[s1.Length, s2.Length]; + } + } + + // Continue ManageGameObject class with Ensure methods + public static partial class ManageGameObject + { + // --- Ensure Methods (Idempotent Operations) --- + + /// + /// Ensures a GameObject has a specific component. Idempotent - only adds if missing. + /// + private static object EnsureComponent(JObject @params, JToken targetToken, string searchMethod) + { + try + { + var writeCheck = WriteGuard.CheckWriteAllowed("ensure_component"); + if (writeCheck != null) return writeCheck; + + string componentType = @params["component_type"]?.ToString(); + if (string.IsNullOrEmpty(componentType)) + return Response.Error("'component_type' parameter required for ensure_component."); + + GameObject go = FindObjectInternal(targetToken, searchMethod); + if (go == null) + return Response.Error($"GameObject not found."); + + Type compType = FindType(componentType); + if (compType == null) + return Response.Error($"Component type '{componentType}' not found."); + + Component existingComp = go.GetComponent(compType); + if (existingComp != null) + { + // Already exists - no change, so dirty=false (idempotent) + return new + { + success = true, + message = $"Component '{componentType}' already exists on '{go.name}'.", + data = new { gameObject = go.name, component = componentType, alreadyExists = true }, + state_delta = StateComposer.CreateSceneDelta(dirty: false) + }; + } + + Component newComp = go.AddComponent(compType); + EditorUtility.SetDirty(go); + StateComposer.IncrementRevision(); + + return new + { + success = true, + message = $"Component '{componentType}' added to '{go.name}'.", + data = new { gameObject = go.name, component = componentType, alreadyExists = false }, + state_delta = StateComposer.CreateSceneDelta(dirty: true) + }; + } + catch (Exception e) + { + return Response.Error($"Failed to ensure component: {e.Message}"); + } + } + + /// + /// Ensures a Renderer has a specific material assigned. Idempotent. + /// + private static object EnsureRendererMaterial(JObject @params, JToken targetToken, string searchMethod) + { + try + { + var writeCheck = WriteGuard.CheckWriteAllowed("ensure_renderer_material"); + if (writeCheck != null) return writeCheck; + + string materialPath = @params["material"]?.ToString(); + if (string.IsNullOrEmpty(materialPath)) + return Response.Error("'material' parameter required for ensure_renderer_material."); + + GameObject go = FindObjectInternal(targetToken, searchMethod); + if (go == null) + return Response.Error($"GameObject not found."); + + Renderer renderer = go.GetComponent(); + if (renderer == null) + return Response.Error($"GameObject '{go.name}' has no Renderer component."); + + Material material = AssetDatabase.LoadAssetAtPath(materialPath); + if (material == null) + return Response.Error($"Material not found at: {materialPath}"); + + // Check if material is already assigned + if (renderer.sharedMaterial == material) + { + return new + { + success = true, + message = $"Material already assigned.", + data = new { gameObject = go.name, material = materialPath, alreadyAssigned = true }, + state_delta = StateComposer.CreateSceneDelta(dirty: false) + }; + } + + renderer.sharedMaterial = material; + EditorUtility.SetDirty(go); + StateComposer.IncrementRevision(); + + return new + { + success = true, + message = $"Material assigned to renderer.", + data = new { gameObject = go.name, material = materialPath, alreadyAssigned = false }, + state_delta = StateComposer.CreateSceneDelta(dirty: true) + }; + } + catch (Exception e) + { + return Response.Error($"Failed to ensure renderer material: {e.Message}"); + } + } + + /// + /// Ensures a MeshCollider has a specific mesh assigned. Idempotent. + /// + private static object EnsureMeshColliderMesh(JObject @params, JToken targetToken, string searchMethod) + { + try + { + var writeCheck = WriteGuard.CheckWriteAllowed("ensure_mesh_collider_mesh"); + if (writeCheck != null) return writeCheck; + + string meshPath = @params["mesh"]?.ToString(); + if (string.IsNullOrEmpty(meshPath)) + return Response.Error("'mesh' parameter required for ensure_mesh_collider_mesh."); + + GameObject go = FindObjectInternal(targetToken, searchMethod); + if (go == null) + return Response.Error($"GameObject not found."); + + MeshCollider collider = go.GetComponent(); + if (collider == null) + return Response.Error($"GameObject '{go.name}' has no MeshCollider component."); + + Mesh mesh = AssetDatabase.LoadAssetAtPath(meshPath); + if (mesh == null) + return Response.Error($"Mesh not found at: {meshPath}"); + + if (collider.sharedMesh == mesh) + { + return new + { + success = true, + message = $"Mesh already assigned.", + data = new { gameObject = go.name, mesh = meshPath, alreadyAssigned = true }, + state_delta = StateComposer.CreateSceneDelta(dirty: false) + }; + } + + collider.sharedMesh = mesh; + EditorUtility.SetDirty(go); + StateComposer.IncrementRevision(); + + return new + { + success = true, + message = $"Mesh assigned to collider.", + data = new { gameObject = go.name, mesh = meshPath, alreadyAssigned = false }, + state_delta = StateComposer.CreateSceneDelta(dirty: true) + }; + } + catch (Exception e) + { + return Response.Error($"Failed to ensure mesh collider mesh: {e.Message}"); + } + } + + /// + /// Ensures a Prefab's default sprite is set. Idempotent. + /// + private static object EnsurePrefabDefaultSprite(JObject @params) + { + try + { + var writeCheck = WriteGuard.CheckWriteAllowed("ensure_prefab_default_sprite"); + if (writeCheck != null) return writeCheck; + + string prefabPath = @params["prefab"]?.ToString(); + string spritePath = @params["sprite"]?.ToString(); + + if (string.IsNullOrEmpty(prefabPath)) + return Response.Error("'prefab' parameter required."); + if (string.IsNullOrEmpty(spritePath)) + return Response.Error("'sprite' parameter required."); + + GameObject prefab = AssetDatabase.LoadAssetAtPath(prefabPath); + if (prefab == null) + return Response.Error($"Prefab not found at: {prefabPath}"); + + Sprite sprite = AssetDatabase.LoadAssetAtPath(spritePath); + if (sprite == null) + return Response.Error($"Sprite not found at: {spritePath}"); + + SpriteRenderer spriteRenderer = prefab.GetComponent(); + if (spriteRenderer == null) + return Response.Error($"Prefab '{prefabPath}' has no SpriteRenderer component."); + + if (spriteRenderer.sprite == sprite) + { + return new + { + success = true, + message = $"Sprite already assigned.", + data = new { prefab = prefabPath, sprite = spritePath, alreadyAssigned = true }, + state_delta = StateComposer.CreateAssetDelta(new[] { + new { path = prefabPath, imported = false, hasMeta = true } + }) + }; + } + + spriteRenderer.sprite = sprite; + EditorUtility.SetDirty(prefab); + AssetDatabase.SaveAssets(); + StateComposer.IncrementRevision(); + + return new + { + success = true, + message = $"Sprite assigned to prefab.", + data = new { prefab = prefabPath, sprite = spritePath, alreadyAssigned = false }, + state_delta = StateComposer.CreateAssetDelta(new[] { + new { path = prefabPath, imported = false, hasMeta = true } + }) + }; + } + catch (Exception e) + { + return Response.Error($"Failed to ensure prefab sprite: {e.Message}"); + } + } + + /// + /// Create batch operation: execute multiple write-only GameObject operations in sequence. + /// Supports alias capture (captureAs) and ref resolution ($alias). + /// + private static object HandleCreateBatch(JObject @params) + { + var opsToken = @params["ops"] as JArray; + if (opsToken == null || opsToken.Count == 0) + { + return Response.Error("'ops' array is required for create_batch action."); + } + + // Guardrail: keep batches small enough for reliable planning/retry (parity with TS client) + if (opsToken.Count > MaxBatchOps) + { + return Response.Error( + $"Too many ops for create_batch action: {opsToken.Count}. Please split into multiple batches of <= {MaxBatchOps} ops." + ); + } + + string mode = @params["mode"]?.ToString()?.ToLower() ?? "stop_on_error"; + if (mode != "stop_on_error" && mode != "continue_on_error") + { + return Response.Error($"Invalid mode: '{mode}'. Valid values are: stop_on_error, continue_on_error"); + } + + // create_batch is write-only (no reads). We still track whether there are + // write ops so we can apply additional guardrails for deterministic targeting. + var writeOps = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "ensure_component", + "ensure_renderer_material", + "ensure_mesh_collider_mesh", + "ensure_prefab_default_sprite", + "create", + "modify", + "delete", + "add_component", + "remove_component", + "set_component_property", + }; + bool hasWriteOps = false; + foreach (var token in opsToken) + { + var op = token as JObject; + if (op == null) continue; + string opId = op["id"]?.ToString() ?? "unknown"; + var opAction = op["action"]?.ToString()?.ToLower(); + if (string.IsNullOrEmpty(opAction)) continue; + if (opAction == "set_component_properties") opAction = "set_component_property"; + if (!writeOps.Contains(opAction)) + { + return Response.Error( + $"Op '{opId}' invalid: create_batch only supports write ops (no reads like '{opAction}')." + ); + } + hasWriteOps = true; + } + + // Extra guardrail for create_batch: enforce deterministic targeting (parity with TS client). + if (hasWriteOps) + { + foreach (var token in opsToken) + { + var op = token as JObject; + if (op == null) continue; + string opId = op["id"]?.ToString() ?? "unknown"; + string opAction = op["action"]?.ToString()?.ToLower(); + if (string.IsNullOrEmpty(opAction)) continue; + if (opAction == "set_component_properties") opAction = "set_component_property"; + + // Only validate deterministic targeting for write ops that can take target/parent. + if (!writeOps.Contains(opAction)) continue; + + var opParams = op["params"] as JObject ?? new JObject(); + + // target determinism: allow instanceID (number), $alias, or by_path targeting. + var searchMethod = opParams["searchMethod"]?.ToString()?.ToLower(); + var targetTok = opParams["target"]; + if (targetTok != null && targetTok.Type == JTokenType.String) + { + var t = targetTok.ToString(); + var isAlias = t.StartsWith("$"); + var isPath = searchMethod == "by_path"; + if (!isAlias && !isPath) + { + return Response.Error( + $"Op '{opId}' invalid: create_batch requires deterministic target. Use instanceID (number), $alias, or targetRef.hierarchy_path (searchMethod='by_path'). Do not use by_name targets inside create_batch." + ); + } + } + + // If the op uses targetRef.name, it will become by_name later; reject early for determinism. + if (targetTok == null && opParams["targetRef"] is JObject tr && tr["name"] != null) + { + return Response.Error( + $"Op '{opId}' invalid: create_batch requires deterministic target. Use instanceID (number), $alias, or targetRef.hierarchy_path (searchMethod='by_path'). Do not use by_name targets inside create_batch." + ); + } + + // parent determinism: allow instanceID (number) or $alias. + var parentTok = opParams["parent"]; + if (parentTok != null && parentTok.Type == JTokenType.String) + { + var p = parentTok.ToString(); + if (!p.StartsWith("$")) + { + return Response.Error( + $"Op '{opId}' invalid: create_batch requires deterministic parent. Use instanceID (number) or $alias for parent (avoid name-based parenting in create_batch)." + ); + } + } + } + } + + var aliases = new Dictionary(); + var results = new List>(); + var stateDeltas = new List(); + int succeeded = 0; + int failed = 0; + + foreach (var opToken in opsToken) + { + var op = opToken as JObject; + if (op == null) + { + results.Add(new Dictionary + { + ["id"] = "unknown", + ["success"] = false, + ["message"] = "Invalid op format" + }); + failed++; + if (mode == "stop_on_error") break; + continue; + } + + string opId = op["id"]?.ToString() ?? "unknown"; + string opAction = op["action"]?.ToString()?.ToLower(); + bool allowFailure = op["allowFailure"]?.ToObject() ?? false; + string captureAs = op["captureAs"]?.ToString(); + + if (string.IsNullOrEmpty(opAction)) + { + results.Add(new Dictionary + { + ["id"] = opId, + ["success"] = false, + ["message"] = "Op action is required" + }); + failed++; + if (mode == "stop_on_error" && !allowFailure) break; + continue; + } + + if (opAction == "batch") + { + results.Add(new Dictionary + { + ["id"] = opId, + ["success"] = false, + ["message"] = "Nested batch is not allowed" + }); + failed++; + if (mode == "stop_on_error" && !allowFailure) break; + continue; + } + + // Build params for the individual operation + var opParams = op["params"] as JObject ?? new JObject(); + opParams = ResolveRefs(opParams, aliases); + opParams["action"] = opAction; + + // Execute the operation + try + { + var opResult = HandleCommand(opParams); + bool opSuccess = true; + string opMessage = "Success"; + string opCode = null; + object opData = null; + int? instanceId = null; + object opStateDelta = null; + + if (opResult is Dictionary resultDict) + { + if (resultDict.TryGetValue("success", out var successObj) && successObj is bool s) + opSuccess = s; + if (resultDict.TryGetValue("message", out var msgObj)) + opMessage = msgObj?.ToString(); + if (resultDict.TryGetValue("code", out var codeObj)) + opCode = codeObj?.ToString(); + if (resultDict.TryGetValue("state_delta", out var sd)) + opStateDelta = sd; + if (resultDict.TryGetValue("data", out var dataObj)) + { + opData = dataObj; + // Try to extract instanceID for alias capture + if (dataObj is Dictionary dataDict) + { + instanceId = ExtractInstanceId(dataDict); + } + } + else + { + opData = resultDict; + instanceId = ExtractInstanceId(resultDict); + } + } + else + { + opData = opResult; + try + { + var sdProp = opResult?.GetType()?.GetProperty("state_delta"); + if (sdProp != null) opStateDelta = sdProp.GetValue(opResult); + } + catch { } + } + + if (opStateDelta != null) stateDeltas.Add(opStateDelta); + + // Best-effort instanceID extraction for alias capture (supports anonymous objects and JTokens) + if (!instanceId.HasValue) + { + instanceId = ExtractInstanceIdFromAny(opData) ?? ExtractInstanceIdFromAny(opResult); + } + + // Capture alias if specified and operation succeeded + if (opSuccess && !string.IsNullOrEmpty(captureAs) && instanceId.HasValue) + { + aliases[captureAs] = instanceId.Value; + } + + var resultEntry = new Dictionary + { + ["id"] = opId, + ["action"] = opAction, + ["success"] = opSuccess, + ["message"] = opMessage + }; + if (opCode != null) resultEntry["code"] = opCode; + if (opData != null) resultEntry["data"] = opData; + + results.Add(resultEntry); + + if (opSuccess) + succeeded++; + else + { + failed++; + if (mode == "stop_on_error" && !allowFailure) break; + } + } + catch (Exception e) + { + results.Add(new Dictionary + { + ["id"] = opId, + ["action"] = opAction, + ["success"] = false, + ["message"] = e.Message, + ["code"] = "exception" + }); + failed++; + if (mode == "stop_on_error" && !allowFailure) break; + } + } + + var mergedDelta = stateDeltas.Count > 0 + ? StateComposer.MergeStateDeltas(stateDeltas.ToArray()) + : null; + + bool success = failed == 0; + var message = success + ? "Unity GameObject create_batch completed successfully." + : "Unity GameObject create_batch completed with errors."; + + var response = new Dictionary + { + ["mode"] = mode, + ["aliases"] = aliases, + ["summary"] = new Dictionary + { + ["total"] = opsToken.Count, + ["succeeded"] = succeeded, + ["failed"] = failed + }, + ["results"] = results, + ["success"] = success, + ["message"] = message + }; + + if (!success) + { + response["code"] = "create_batch_failed"; + response["error"] = message; + } + + if (mergedDelta != null) + { + response["state_delta"] = mergedDelta; + } + + return response; + } + + /// + /// Edit batch operation: "find-then-write" edits with early-stop on 0 targets found. + /// + /// Contract: + /// - Phase 1: one or more `find` ops (must provide captureAs) to resolve deterministic instanceIDs. + /// - Phase 2: write ops that must target the captured `$alias` (no by_name/by_path targets in edit_batch writes). + /// - If a find resolves 0 targets, the workflow early-stops and returns success=true (not an error). + /// + private static object HandleEditBatch(JObject @params) + { + var opsToken = @params["ops"] as JArray; + if (opsToken == null || opsToken.Count == 0) + { + return Response.Error("'ops' array is required for edit_batch action."); + } + + if (opsToken.Count > MaxBatchOps) + { + return Response.Error( + $"Too many ops for edit_batch action: {opsToken.Count}. Please split into multiple batches of <= {MaxBatchOps} ops." + ); + } + + string mode = @params["mode"]?.ToString()?.ToLower() ?? "stop_on_error"; + if (mode != "stop_on_error" && mode != "continue_on_error") + { + return Response.Error($"Invalid mode: '{mode}'. Valid values are: stop_on_error, continue_on_error"); + } + + var writeOps = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "ensure_component", + "ensure_renderer_material", + "ensure_mesh_collider_mesh", + "ensure_prefab_default_sprite", + "create", + "modify", + "delete", + "add_component", + "remove_component", + "set_component_property", + }; + + var aliases = new Dictionary(); + var results = new List>(); + var stateDeltas = new List(); + int succeeded = 0; + int failed = 0; + + bool seenWrite = false; + int findOpsCount = 0; + int writeOpsCount = 0; + + foreach (var opToken in opsToken) + { + var op = opToken as JObject; + if (op == null) + { + results.Add(new Dictionary + { + ["id"] = "unknown", + ["success"] = false, + ["message"] = "Invalid op format" + }); + failed++; + if (mode == "stop_on_error") break; + continue; + } + + string opId = op["id"]?.ToString() ?? "unknown"; + string opAction = op["action"]?.ToString()?.ToLower(); + bool allowFailure = op["allowFailure"]?.ToObject() ?? false; + string captureAs = op["captureAs"]?.ToString(); + + if (string.IsNullOrEmpty(opAction)) + { + results.Add(new Dictionary + { + ["id"] = opId, + ["success"] = false, + ["message"] = "Op action is required" + }); + failed++; + if (mode == "stop_on_error" && !allowFailure) break; + continue; + } + + if (opAction == "set_component_properties") opAction = "set_component_property"; + + if (opAction == "batch" || opAction == "create_batch" || opAction == "edit_batch") + { + return Response.Error($"Op '{opId}' invalid: nested batch is not allowed"); + } + + bool isWriteOp = writeOps.Contains(opAction); + + if (!isWriteOp) + { + // Phase 1: find only + if (opAction != "find") + { + return Response.Error( + $"Op '{opId}' invalid: edit_batch only supports read op action 'find' before write ops." + ); + } + if (seenWrite) + { + return Response.Error( + $"Op '{opId}' invalid: edit_batch requires all 'find' ops to come before write ops." + ); + } + if (allowFailure) + { + return Response.Error( + $"Op '{opId}' invalid: allowFailure is not supported for find ops in edit_batch." + ); + } + if (string.IsNullOrEmpty(captureAs) || !captureAs.StartsWith("$")) + { + return Response.Error( + $"Op '{opId}' invalid: find op must provide captureAs starting with '$' (e.g. '$target')." + ); + } + if (aliases.ContainsKey(captureAs)) + { + return Response.Error($"Duplicate captureAs alias: {captureAs}"); + } + + // Build params for find (force deterministic single-result behavior) + var opParams = op["params"] as JObject ?? new JObject(); + if (opParams["findAll"]?.ToObject() == true) + { + return Response.Error($"Op '{opId}' invalid: findAll must be false for edit_batch."); + } + opParams["findAll"] = false; + opParams["action"] = "find"; + + var opResult = HandleCommand(opParams); + + bool opSuccess = true; + string opMessage = "Success"; + string opCode = null; + object opData = null; + object opStateDelta = null; + + if (opResult is Dictionary resultDict) + { + if (resultDict.TryGetValue("success", out var successObj) && successObj is bool s) + opSuccess = s; + if (resultDict.TryGetValue("message", out var msgObj)) + opMessage = msgObj?.ToString(); + if (resultDict.TryGetValue("code", out var codeObj)) + opCode = codeObj?.ToString(); + if (resultDict.TryGetValue("state_delta", out var sd)) + opStateDelta = sd; + if (resultDict.TryGetValue("data", out var dataObj)) + opData = dataObj; + else + opData = resultDict; + } + else + { + opData = opResult; + try + { + var sdProp = opResult?.GetType()?.GetProperty("state_delta"); + if (sdProp != null) opStateDelta = sdProp.GetValue(opResult); + } + catch { } + } + + if (opStateDelta != null) stateDeltas.Add(opStateDelta); + + var resultEntry = new Dictionary + { + ["id"] = opId, + ["action"] = "find", + ["success"] = opSuccess, + ["message"] = opMessage + }; + if (opCode != null) resultEntry["code"] = opCode; + if (opData != null) resultEntry["data"] = opData; + results.Add(resultEntry); + + if (!opSuccess) + { + failed++; + if (mode == "stop_on_error") break; + continue; + } + + // Expect a list of GameObject results (0 or 1, since findAll=false) + int foundCount = 0; + object first = null; + if (opData is List list) + { + foundCount = list.Count; + if (list.Count > 0) first = list[0]; + } + else if (opData is JArray jarr) + { + foundCount = jarr.Count; + if (jarr.Count > 0) first = jarr[0]; + } + + if (foundCount == 0) + { + // Early stop (not an error) + var mergedDelta = stateDeltas.Count > 0 + ? StateComposer.MergeStateDeltas(stateDeltas.ToArray()) + : null; + + var early = new Dictionary + { + ["mode"] = mode, + ["status"] = "early_stop", + ["reason"] = "no_targets_found", + ["aliases"] = aliases, + ["summary"] = new Dictionary + { + ["total"] = results.Count, + ["succeeded"] = succeeded, + ["failed"] = failed + }, + ["results"] = results, + ["success"] = true, + ["message"] = "Unity GameObject edit_batch early-stopped (0 targets found)." + }; + if (mergedDelta != null) early["state_delta"] = mergedDelta; + return early; + } + + if (foundCount > 1) + { + return Response.Error( + $"Op '{opId}' invalid: edit_batch find must return at most 1 result (set findAll=false)." + ); + } + + int? instanceId = ExtractInstanceIdFromAny(first); + if (!instanceId.HasValue) + { + return Response.Error( + $"Op '{opId}' invalid: edit_batch could not extract instanceID from find result." + ); + } + aliases[captureAs] = instanceId.Value; + + succeeded++; + findOpsCount++; + continue; + } + + // Phase 2: write ops (must target aliases) + seenWrite = true; + writeOpsCount++; + + if (!string.IsNullOrEmpty(captureAs)) + { + return Response.Error( + $"Op '{opId}' invalid: captureAs is not supported for write ops in edit_batch (capture aliases using 'find')." + ); + } + + var rawParams = op["params"] as JObject ?? new JObject(); + if (rawParams["targetRef"] is JObject) + { + return Response.Error( + $"Op '{opId}' invalid: edit_batch write ops must use target '$alias' (not targetRef)." + ); + } + + // If target/parent are strings, they must be $aliases (deterministic) + var targetTok = rawParams["target"]; + if (targetTok != null && targetTok.Type == JTokenType.Integer) + { + return Response.Error( + $"Op '{opId}' invalid: edit_batch write ops must target a $alias captured by a previous find op." + ); + } + if (targetTok != null && targetTok.Type == JTokenType.String) + { + var t = targetTok.ToString(); + if (!t.StartsWith("$")) + { + return Response.Error( + $"Op '{opId}' invalid: edit_batch write ops must target a $alias captured by a previous find op." + ); + } + } + if (targetTok is JObject targetObj && targetObj["ref"] != null) + { + var r = targetObj["ref"]?.ToString() ?? ""; + if (!r.StartsWith("$")) + { + return Response.Error( + $"Op '{opId}' invalid: edit_batch write ops must target a $alias captured by a previous find op." + ); + } + } + var parentTok = rawParams["parent"]; + if (parentTok != null && parentTok.Type == JTokenType.Integer) + { + return Response.Error( + $"Op '{opId}' invalid: edit_batch write ops must parent using a $alias captured by a previous find op." + ); + } + if (parentTok != null && parentTok.Type == JTokenType.String) + { + var p = parentTok.ToString(); + if (!p.StartsWith("$")) + { + return Response.Error( + $"Op '{opId}' invalid: edit_batch write ops must parent using a $alias captured by a previous find op." + ); + } + } + if (parentTok is JObject parentObj && parentObj["ref"] != null) + { + var r = parentObj["ref"]?.ToString() ?? ""; + if (!r.StartsWith("$")) + { + return Response.Error( + $"Op '{opId}' invalid: edit_batch write ops must parent using a $alias captured by a previous find op." + ); + } + } + + // Build params for the individual operation (resolve $aliases to instanceIDs) + var opParamsWrite = ResolveRefs(rawParams, aliases); + opParamsWrite["action"] = opAction; + + try + { + var opResult = HandleCommand(opParamsWrite); + bool opSuccess = true; + string opMessage = "Success"; + string opCode = null; + object opData = null; + int? instanceId = null; + object opStateDelta = null; + + if (opResult is Dictionary resultDict) + { + if (resultDict.TryGetValue("success", out var successObj) && successObj is bool s) + opSuccess = s; + if (resultDict.TryGetValue("message", out var msgObj)) + opMessage = msgObj?.ToString(); + if (resultDict.TryGetValue("code", out var codeObj)) + opCode = codeObj?.ToString(); + if (resultDict.TryGetValue("state_delta", out var sd)) + opStateDelta = sd; + if (resultDict.TryGetValue("data", out var dataObj)) + { + opData = dataObj; + if (dataObj is Dictionary dataDict) + { + instanceId = ExtractInstanceId(dataDict); + } + } + else + { + opData = resultDict; + instanceId = ExtractInstanceId(resultDict); + } + } + else + { + opData = opResult; + try + { + var sdProp = opResult?.GetType()?.GetProperty("state_delta"); + if (sdProp != null) opStateDelta = sdProp.GetValue(opResult); + } + catch { } + } + + if (opStateDelta != null) stateDeltas.Add(opStateDelta); + + if (!instanceId.HasValue) + { + instanceId = ExtractInstanceIdFromAny(opData) ?? ExtractInstanceIdFromAny(opResult); + } + + var resultEntry = new Dictionary + { + ["id"] = opId, + ["action"] = opAction, + ["success"] = opSuccess, + ["message"] = opMessage + }; + if (opCode != null) resultEntry["code"] = opCode; + if (opData != null) resultEntry["data"] = opData; + + results.Add(resultEntry); + + if (opSuccess) + succeeded++; + else + { + failed++; + if (mode == "stop_on_error" && !allowFailure) break; + } + } + catch (Exception e) + { + results.Add(new Dictionary + { + ["id"] = opId, + ["action"] = opAction, + ["success"] = false, + ["message"] = e.Message, + ["code"] = "exception" + }); + failed++; + if (mode == "stop_on_error" && !allowFailure) break; + } + } + + if (findOpsCount == 0) + { + return Response.Error("edit_batch requires at least one find op with captureAs before write ops."); + } + if (writeOpsCount == 0) + { + return Response.Error("edit_batch requires at least one write op after find ops."); + } + + var merged = stateDeltas.Count > 0 + ? StateComposer.MergeStateDeltas(stateDeltas.ToArray()) + : null; + + bool success = failed == 0; + var message = success + ? "Unity GameObject edit_batch completed successfully." + : "Unity GameObject edit_batch completed with errors."; + + var response = new Dictionary + { + ["mode"] = mode, + ["aliases"] = aliases, + ["summary"] = new Dictionary + { + ["total"] = opsToken.Count, + ["succeeded"] = succeeded, + ["failed"] = failed + }, + ["results"] = results, + ["success"] = success, + ["message"] = message + }; + + if (!success) + { + response["code"] = "edit_batch_failed"; + response["error"] = message; + } + + if (merged != null) + { + response["state_delta"] = merged; + } + + return response; + } + + /// + /// Resolve $alias references in params to actual instanceIDs. + /// + private static JObject ResolveRefs(JObject obj, Dictionary aliases) + { + var result = new JObject(); + foreach (var prop in obj.Properties()) + { + result[prop.Name] = ResolveRefValue(prop.Value, aliases); + } + return result; + } + + private static JToken ResolveRefValue(JToken value, Dictionary aliases) + { + if (value == null) return null; + + switch (value.Type) + { + case JTokenType.String: + string strVal = value.ToString(); + if (strVal.StartsWith("$") && aliases.TryGetValue(strVal, out int id)) + { + return id; + } + return value; + + case JTokenType.Object: + var objVal = value as JObject; + // Check for {ref: "$alias"} pattern + if (objVal != null && objVal.Count == 1 && objVal["ref"] != null) + { + string refStr = objVal["ref"].ToString(); + if (refStr.StartsWith("$") && aliases.TryGetValue(refStr, out int refId)) + { + return refId; + } + } + // Recursively resolve nested objects + return ResolveRefs(objVal, aliases); + + case JTokenType.Array: + var arrVal = value as JArray; + var newArr = new JArray(); + foreach (var item in arrVal) + { + newArr.Add(ResolveRefValue(item, aliases)); + } + return newArr; + + default: + return value; + } + } + + /// + /// Extract instanceID from result dictionary. + /// + private static int? ExtractInstanceId(Dictionary dict) + { + if (dict.TryGetValue("instanceID", out var id1) && id1 is int i1) return i1; + if (dict.TryGetValue("instanceId", out var id2) && id2 is int i2) return i2; + if (dict.TryGetValue("id", out var id3) && id3 is int i3) return i3; + + // Check nested data + if (dict.TryGetValue("data", out var dataObj) && dataObj is Dictionary dataDict) + { + if (dataDict.TryGetValue("instanceID", out var did1) && did1 is int di1) return di1; + if (dataDict.TryGetValue("instanceId", out var did2) && did2 is int di2) return di2; + if (dataDict.TryGetValue("id", out var did3) && did3 is int di3) return di3; + } + + return null; + } + + /// + /// Extract instanceID from any object shape (anonymous objects, dictionaries, JTokens). + /// Used by batch alias capture. + /// + private static int? ExtractInstanceIdFromAny(object obj) + { + if (obj == null) return null; + + try + { + if (obj is int ii) return ii; + if (obj is long ll) return (int)ll; + + if (obj is Dictionary dict) + { + return ExtractInstanceId(dict); + } + + if (obj is JObject jobj) + { + return ExtractInstanceIdFromJToken(jobj); + } + + if (obj is JToken jt) + { + return ExtractInstanceIdFromJToken(jt); + } + + // Reflection (anonymous objects) + var t = obj.GetType(); + var prop = + t.GetProperty("instanceID") + ?? t.GetProperty("instanceId") + ?? t.GetProperty("id"); + if (prop != null) + { + var v = prop.GetValue(obj); + var parsed = ExtractInstanceIdFromAny(v); + if (parsed.HasValue) return parsed.Value; + } + + var dataProp = t.GetProperty("data"); + if (dataProp != null) + { + var dataObj = dataProp.GetValue(obj); + var parsed = ExtractInstanceIdFromAny(dataObj); + if (parsed.HasValue) return parsed.Value; + } + + // Last resort: serialize to JObject and search + var j = JObject.FromObject(obj); + return ExtractInstanceIdFromJToken(j); + } + catch + { + return null; + } + } + + private static int? ExtractInstanceIdFromJToken(JToken token) + { + if (token == null) return null; + try + { + if (token.Type == JTokenType.Integer) + { + return token.ToObject(); + } + + if (token.Type != JTokenType.Object) + { + return null; + } + + var obj = token as JObject; + if (obj == null) return null; + + var direct = obj["instanceID"] ?? obj["instanceId"] ?? obj["id"]; + if (direct != null) + { + if (direct.Type == JTokenType.Integer) return direct.ToObject(); + if (direct.Type == JTokenType.String && int.TryParse(direct.ToString(), out var parsed)) + return parsed; + } + + var data = obj["data"]; + if (data != null) + { + return ExtractInstanceIdFromJToken(data); + } + + return null; + } + catch + { + return null; + } + } + } +} + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageGameObject.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageGameObject.cs.meta new file mode 100644 index 000000000..5f09410da --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageGameObject.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9c6230a18f5554a41aaac2188776332e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManagePackage.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManagePackage.cs new file mode 100644 index 000000000..b08440278 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManagePackage.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Codely.Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.PackageManager; +using UnityEditor.PackageManager.Requests; +using UnityEngine; +using UnityTcp.Editor.Helpers; + +namespace UnityTcp.Editor.Tools +{ + /// + /// [EXPERIMENTAL] Handles Unity Package Manager (UPM) operations. + /// Supports installing, removing, and querying packages. + /// Compatible with Unity 2022.3 LTS. + /// + public static class ManagePackage + { + private static readonly Dictionary _activeRequests = new Dictionary(); + private static readonly Dictionary _updateCallbacks = new Dictionary(); + private static readonly object _requestLock = new object(); + + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString().ToLower(); + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + + try + { + switch (action) + { + case "install_package": + return InstallPackage(@params); + case "remove_package": + return RemovePackage(@params); + case "wait_for_upm": + return WaitForUpm(@params); + case "list_packages": + return ListPackages(); + default: + return Response.Error( + $"Unknown action: '{action}'. Valid actions: install_package, remove_package, wait_for_upm, list_packages." + ); + } + } + catch (Exception e) + { + Debug.LogError($"[ManagePackage] Action '{action}' failed: {e}"); + return Response.Error($"[EXPERIMENTAL] Package operation failed: {e.Message}"); + } + } + + private static object InstallPackage(JObject @params) + { + try + { + string idOrUrl = @params["id_or_url"]?.ToString(); + if (string.IsNullOrEmpty(idOrUrl)) + return Response.Error("'id_or_url' parameter required for install_package."); + + // Handle optional version parameter + string version = @params["version"]?.ToString(); + + // Append version to package identifier if provided and not already in format + // e.g., "com.unity.package" + "1.2.3" -> "com.unity.package@1.2.3" + if (!string.IsNullOrEmpty(version) && + !idOrUrl.Contains("@") && + !idOrUrl.StartsWith("http")) + { + idOrUrl = $"{idOrUrl}@{version}"; + } + + // Create job + var job = AsyncOperationTracker.CreateJob( + AsyncOperationTracker.JobType.UpmPackage, + $"Installing package: {idOrUrl}" + ); + + // Start UPM request + AddRequest addRequest = Client.Add(idOrUrl); + lock (_requestLock) + { + _activeRequests[job.OpId] = addRequest; + } + + // Create and store callback delegate for proper unsubscription + EditorApplication.CallbackFunction callback = () => CheckUpmRequest(job.OpId); + lock (_requestLock) + { + _updateCallbacks[job.OpId] = callback; + } + EditorApplication.update += callback; + + // Return standardized pending response + var response = AsyncOperationTracker.CreatePendingResponse(job) as Dictionary; + response["poll_interval"] = 2.0; // UPM needs longer poll interval + response["message"] = $"[EXPERIMENTAL] Package installation started: {idOrUrl}"; + response["data"] = new { package = idOrUrl, type = "install" }; + return response; + } + catch (Exception e) + { + return Response.Error($"[EXPERIMENTAL] Failed to start package installation: {e.Message}"); + } + } + + private static object RemovePackage(JObject @params) + { + try + { + string packageName = @params["package_name"]?.ToString(); + if (string.IsNullOrEmpty(packageName)) + return Response.Error("'package_name' parameter required for remove_package."); + + var job = AsyncOperationTracker.CreateJob( + AsyncOperationTracker.JobType.UpmPackage, + $"Removing package: {packageName}" + ); + + RemoveRequest removeRequest = Client.Remove(packageName); + lock (_requestLock) + { + _activeRequests[job.OpId] = removeRequest; + } + + // Create and store callback delegate for proper unsubscription + EditorApplication.CallbackFunction callback = () => CheckUpmRequest(job.OpId); + lock (_requestLock) + { + _updateCallbacks[job.OpId] = callback; + } + EditorApplication.update += callback; + + // Return standardized pending response + var response = AsyncOperationTracker.CreatePendingResponse(job) as Dictionary; + response["poll_interval"] = 2.0; // UPM needs longer poll interval + response["message"] = $"[EXPERIMENTAL] Package removal started: {packageName}"; + response["data"] = new { package = packageName, type = "remove" }; + return response; + } + catch (Exception e) + { + return Response.Error($"[EXPERIMENTAL] Failed to start package removal: {e.Message}"); + } + } + + private static void CheckUpmRequest(string opId) + { + Request request; + EditorApplication.CallbackFunction callback; + lock (_requestLock) + { + if (!_activeRequests.TryGetValue(opId, out request)) + { + return; + } + _updateCallbacks.TryGetValue(opId, out callback); + } + + if (request.IsCompleted) + { + // Properly unsubscribe using stored delegate + if (callback != null) + { + EditorApplication.update -= callback; + } + + lock (_requestLock) + { + _activeRequests.Remove(opId); + _updateCallbacks.Remove(opId); + } + + if (request.Status == StatusCode.Success) + { + AsyncOperationTracker.CompleteJob(opId, "Package operation completed successfully"); + StateComposer.IncrementRevision(); + } + else + { + AsyncOperationTracker.FailJob(opId, $"Package operation failed: {request.Error?.message}"); + } + } + } + + private static object WaitForUpm(JObject @params) + { + try + { + string opId = @params["op_id"]?.ToString(); + int timeoutSeconds = @params["timeoutSeconds"]?.ToObject() ?? 300; + + if (string.IsNullOrEmpty(opId)) + return Response.Error("'op_id' parameter required for wait_for_upm."); + + var job = AsyncOperationTracker.GetJob(opId); + if (job == null) + return Response.Error($"Operation {opId} not found."); + + if (job.Type != AsyncOperationTracker.JobType.UpmPackage) + return Response.Error($"Operation {opId} is not a UPM operation."); + + if (AsyncOperationTracker.IsJobTimedOut(opId, timeoutSeconds)) + { + AsyncOperationTracker.FailJob(opId, $"UPM operation timed out after {timeoutSeconds} seconds"); + return AsyncOperationTracker.CreateErrorResponse(job); + } + + switch (job.Status) + { + case AsyncOperationTracker.JobStatus.Complete: + return AsyncOperationTracker.CreateCompleteResponse(job); + case AsyncOperationTracker.JobStatus.Error: + return AsyncOperationTracker.CreateErrorResponse(job); + case AsyncOperationTracker.JobStatus.Pending: + return AsyncOperationTracker.CreatePendingResponse(job); + default: + return Response.Error($"Unknown job status: {job.Status}"); + } + } + catch (Exception e) + { + return Response.Error($"[EXPERIMENTAL] Failed to wait for UPM: {e.Message}"); + } + } + + private static object ListPackages() + { + try + { + ListRequest listRequest = Client.List(true, false); + + // Wait for request to complete (synchronous for simplicity) + while (!listRequest.IsCompleted) + { + System.Threading.Thread.Sleep(100); + } + + if (listRequest.Status == StatusCode.Success) + { + var packages = listRequest.Result.Select(p => new + { + name = p.name, + version = p.version, + displayName = p.displayName, + description = p.description, + source = p.source.ToString() + }).ToList(); + + return Response.Success( + $"[EXPERIMENTAL] Retrieved {packages.Count} packages.", + packages + ); + } + else + { + return Response.Error($"[EXPERIMENTAL] Failed to list packages: {listRequest.Error?.message}"); + } + } + catch (Exception e) + { + return Response.Error($"[EXPERIMENTAL] Failed to list packages: {e.Message}"); + } + } + } +} + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManagePackage.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManagePackage.cs.meta new file mode 100644 index 000000000..94939ce6c --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManagePackage.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d06adc1d5b654a428a23760d94d5079 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScene.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScene.cs new file mode 100644 index 000000000..6874fa627 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScene.cs @@ -0,0 +1,722 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Codely.Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityTcp.Editor.Helpers; // For Response class + +namespace UnityTcp.Editor.Tools +{ + /// + /// Handles scene management operations like loading, saving, creating, and querying hierarchy. + /// + public static class ManageScene + { + private static readonly string[] AllowedSceneExtensions = new[] { ".unity", ".scene" }; + +#if TUANJIE_1_OR_NEWER || TUANJIE_1 + // Tuanjie Editor defines TUANJIE_* version macros (e.g. TUANJIE_1, TUANJIE_1_1, TUANJIE_1_1_2, TUANJIE_X_Y_OR_NEWER). + private const bool IsTuanjieEditor = true; +#else + // Unity Editor builds do NOT define TUANJIE_* macros. + private const bool IsTuanjieEditor = false; +#endif + + private static bool HasAllowedSceneExtension(string path) + { + if (string.IsNullOrEmpty(path)) return false; + return AllowedSceneExtensions.Any(ext => + path.EndsWith(ext, StringComparison.OrdinalIgnoreCase) + ); + } + + private static string GetSceneExtensionFromPathOrName(string path, string name) + { + // If caller provided an explicit scene file path, respect its extension. + if (!string.IsNullOrEmpty(path) && HasAllowedSceneExtension(path)) + { + return Path.GetExtension(path).ToLowerInvariant(); + } + + // If caller (incorrectly) included an extension in name, respect it as a hint. + if (!string.IsNullOrEmpty(name) && HasAllowedSceneExtension(name)) + { + return Path.GetExtension(name).ToLowerInvariant(); + } + + // Default depends on editor: + // - Unity: .unity + // - Tuanjie: .scene + return IsTuanjieEditor ? ".scene" : ".unity"; + } + + private static string StripSceneExtension(string name) + { + if (string.IsNullOrEmpty(name)) return name; + foreach (var ext in AllowedSceneExtensions) + { + if (name.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) + { + return name.Substring(0, name.Length - ext.Length); + } + } + return name; + } + + private sealed class SceneCommand + { + public string action { get; set; } = string.Empty; + public string name { get; set; } = string.Empty; + public string path { get; set; } = string.Empty; + public int? buildIndex { get; set; } + } + + private static SceneCommand ToSceneCommand(JObject p) + { + if (p == null) return new SceneCommand(); + int? BI(JToken t) + { + if (t == null || t.Type == JTokenType.Null) return null; + var s = t.ToString().Trim(); + if (s.Length == 0) return null; + if (int.TryParse(s, out var i)) return i; + if (double.TryParse(s, out var d)) return (int)d; + return t.Type == JTokenType.Integer ? t.Value() : (int?)null; + } + return new SceneCommand + { + action = (p["action"]?.ToString() ?? string.Empty).Trim().ToLowerInvariant(), + name = p["name"]?.ToString() ?? string.Empty, + path = p["path"]?.ToString() ?? string.Empty, + buildIndex = BI(p["buildIndex"] ?? p["build_index"]) + }; + } + + /// + /// Main handler for scene management actions. + /// + public static object HandleCommand(JObject @params) + { + try { TcpLog.Info("[ManageScene] HandleCommand: start", always: false); } catch { } + var cmd = ToSceneCommand(@params); + string action = cmd.action; + string name = string.IsNullOrEmpty(cmd.name) ? null : cmd.name; + string path = string.IsNullOrEmpty(cmd.path) ? null : cmd.path; // Relative to Assets/ + int? buildIndex = cmd.buildIndex; + // bool loadAdditive = @params["loadAdditive"]?.ToObject() ?? false; // Example for future extension + + // --- Validate client_state_rev for write operations --- + var writeActions = new[] { "ensure_scene_open", "ensure_scene_saved", "create", "load", "save" }; + if (Array.Exists(writeActions, a => a == action)) + { + var revConflict = StateComposer.ValidateClientRevisionFromParams(@params); + if (revConflict != null) return revConflict; + } + + // Ensure path is relative to Assets/, removing any leading "Assets/" + string relativeDir = path ?? string.Empty; + if (!string.IsNullOrEmpty(relativeDir)) + { + relativeDir = relativeDir.Replace('\\', '/').Trim('/'); + if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); + } + } + + // Apply default *after* sanitizing, using the original path variable for the check + if (string.IsNullOrEmpty(path) && action == "create") // Check original path for emptiness + { + relativeDir = "Scenes"; // Default relative directory + } + + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + + // Normalize name (strip extension if provided) + name = StripSceneExtension(name); + + // Determine extension for scene file operations (Unity: .unity, Tuanjie: .scene) + string sceneExt = GetSceneExtensionFromPathOrName(path, cmd.name); + + string sceneFileName = string.IsNullOrEmpty(name) ? null : $"{name}{sceneExt}"; + // Construct full system path correctly: ProjectRoot/Assets/relativeDir/sceneFileName + string fullPathDir = Path.Combine(Application.dataPath, relativeDir); // Combine with Assets path (Application.dataPath ends in Assets) + string fullPath = string.IsNullOrEmpty(sceneFileName) + ? null + : Path.Combine(fullPathDir, sceneFileName); + // Ensure relativePath always starts with "Assets/" and uses forward slashes + string relativePath = string.IsNullOrEmpty(sceneFileName) + ? null + : Path.Combine("Assets", relativeDir, sceneFileName).Replace('\\', '/'); + + // Ensure directory exists for 'create' + if (action == "create" && !string.IsNullOrEmpty(fullPathDir)) + { + try + { + Directory.CreateDirectory(fullPathDir); + } + catch (Exception e) + { + return Response.Error( + $"Could not create directory '{fullPathDir}': {e.Message}" + ); + } + } + + // Route action + try { TcpLog.Info($"[ManageScene] Route action='{action}' name='{name}' path='{path}' buildIndex={(buildIndex.HasValue ? buildIndex.Value.ToString() : "null")}", always: false); } catch { } + switch (action) + { + // Ensure operations (idempotent) + case "ensure_scene_open": + // For ensure_scene_open, the path parameter is the full scene path (e.g., "Assets/Scenes/MyScene.unity") + // NOT the directory path like other actions + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' parameter is required for 'ensure_scene_open' action."); + // Normalize the path - ensure it starts with Assets/ and uses forward slashes + string ensureScenePath = path.Replace('\\', '/'); + if (!ensureScenePath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + ensureScenePath = "Assets/" + ensureScenePath.TrimStart('/'); + if (!HasAllowedSceneExtension(ensureScenePath)) + return Response.Error("'path' must end with '.unity' or '.scene' for scene files."); + return EnsureSceneOpen(ensureScenePath); + case "ensure_scene_saved": + return EnsureSceneSaved(); + + // Regular operations + case "create": + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(relativePath)) + return Response.Error( + "'name' and 'path' parameters are required for 'create' action." + ); + return CreateScene(fullPath, relativePath); + case "load": + // Loading can be done by path/name or build index + if (!string.IsNullOrEmpty(relativePath)) + return LoadScene(relativePath); + else if (buildIndex.HasValue) + return LoadScene(buildIndex.Value); + else + return Response.Error( + "Either 'name'/'path' or 'buildIndex' must be provided for 'load' action." + ); + case "save": + // Save current scene, optionally to a new path + return SaveScene(fullPath, relativePath); + case "get_hierarchy": + try { TcpLog.Info("[ManageScene] get_hierarchy: entering", always: false); } catch { } + var gh = GetSceneHierarchy(); + try { TcpLog.Info("[ManageScene] get_hierarchy: exiting", always: false); } catch { } + return gh; + case "get_active": + try { TcpLog.Info("[ManageScene] get_active: entering", always: false); } catch { } + var ga = GetActiveSceneInfo(); + try { TcpLog.Info("[ManageScene] get_active: exiting", always: false); } catch { } + return ga; + case "get_build_settings": + return GetBuildSettingsScenes(); + // Add cases for modifying build settings, additive loading, unloading etc. + default: + return Response.Error( + $"Unknown action: '{action}'. Valid actions: ensure_scene_open, ensure_scene_saved, create, load, save, get_hierarchy, get_active, get_build_settings." + ); + } + } + + private static object CreateScene(string fullPath, string relativePath) + { + if (File.Exists(fullPath)) + { + return Response.Error($"Scene already exists at '{relativePath}'."); + } + + try + { + // Create a new empty scene + Scene newScene = EditorSceneManager.NewScene( + NewSceneSetup.EmptyScene, + NewSceneMode.Single + ); + // Save it to the specified path (creation is an authoring operation) + bool saved = EditorSceneManager.SaveScene(newScene, relativePath); + + if (saved) + { + AssetDatabase.Refresh(); // Ensure Unity sees the new scene file + return Response.Success( + $"Scene '{Path.GetFileName(relativePath)}' created successfully at '{relativePath}'.", + new { path = relativePath } + ); + } + else + { + // If SaveScene fails, it might leave an untitled scene open. + // Optionally try to close it, but be cautious. + return Response.Error($"Failed to save new scene to '{relativePath}'."); + } + } + catch (Exception e) + { + return Response.Error($"Error creating scene '{relativePath}': {e.Message}"); + } + } + + private static object LoadScene(string relativePath) + { + if ( + !File.Exists( + Path.Combine( + Application.dataPath.Substring( + 0, + Application.dataPath.Length - "Assets".Length + ), + relativePath + ) + ) + ) + { + return Response.Error($"Scene file not found at '{relativePath}'."); + } + + // Check for unsaved changes in the current scene + if (EditorSceneManager.GetActiveScene().isDirty) + { + // Optionally prompt the user or save automatically before loading + return Response.Error( + "Current scene has unsaved changes. Please save or discard changes before loading a new scene." + ); + // Example: bool saveOK = EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo(); + // if (!saveOK) return Response.Error("Load cancelled by user."); + } + + try + { + EditorSceneManager.OpenScene(relativePath, OpenSceneMode.Single); + return Response.Success( + $"Scene '{relativePath}' loaded successfully.", + new + { + path = relativePath, + name = Path.GetFileNameWithoutExtension(relativePath), + } + ); + } + catch (Exception e) + { + return Response.Error($"Error loading scene '{relativePath}': {e.Message}"); + } + } + + private static object LoadScene(int buildIndex) + { + if (buildIndex < 0 || buildIndex >= SceneManager.sceneCountInBuildSettings) + { + return Response.Error( + $"Invalid build index: {buildIndex}. Must be between 0 and {SceneManager.sceneCountInBuildSettings - 1}." + ); + } + + // Check for unsaved changes + if (EditorSceneManager.GetActiveScene().isDirty) + { + return Response.Error( + "Current scene has unsaved changes. Please save or discard changes before loading a new scene." + ); + } + + try + { + string scenePath = SceneUtility.GetScenePathByBuildIndex(buildIndex); + EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single); + return Response.Success( + $"Scene at build index {buildIndex} ('{scenePath}') loaded successfully.", + new + { + path = scenePath, + name = Path.GetFileNameWithoutExtension(scenePath), + buildIndex = buildIndex, + } + ); + } + catch (Exception e) + { + return Response.Error( + $"Error loading scene with build index {buildIndex}: {e.Message}" + ); + } + } + + private static object SaveScene(string fullPath, string relativePath) + { + try + { + Scene currentScene = EditorSceneManager.GetActiveScene(); + if (!currentScene.IsValid()) + { + return Response.Error("No valid scene is currently active to save."); + } + + bool saved; + string finalPath = currentScene.path; // Path where it was last saved or will be saved + + if (!string.IsNullOrEmpty(relativePath) && currentScene.path != relativePath) + { + // Save As... + // Ensure directory exists + string dir = Path.GetDirectoryName(fullPath); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + saved = EditorSceneManager.SaveScene(currentScene, relativePath); + finalPath = relativePath; + } + else + { + // Save (overwrite existing or save untitled) + if (string.IsNullOrEmpty(currentScene.path)) + { + // Scene is untitled, needs a path + return Response.Error( + "Cannot save an untitled scene without providing a 'name' and 'path'. Use Save As functionality." + ); + } + saved = EditorSceneManager.SaveScene(currentScene); + } + + if (saved) + { + AssetDatabase.Refresh(); + return Response.Success( + $"Scene '{currentScene.name}' saved successfully to '{finalPath}'.", + new { path = finalPath, name = currentScene.name } + ); + } + else + { + return Response.Error($"Failed to save scene '{currentScene.name}'."); + } + } + catch (Exception e) + { + return Response.Error($"Error saving scene: {e.Message}"); + } + } + + private static object GetActiveSceneInfo() + { + try + { + try { TcpLog.Info("[ManageScene] get_active: querying EditorSceneManager.GetActiveScene", always: false); } catch { } + Scene activeScene = EditorSceneManager.GetActiveScene(); + try { TcpLog.Info($"[ManageScene] get_active: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } + if (!activeScene.IsValid()) + { + return Response.Error("No active scene found."); + } + + var sceneInfo = new + { + name = activeScene.name, + path = activeScene.path, + buildIndex = activeScene.buildIndex, // -1 if not in build settings + isDirty = activeScene.isDirty, + isLoaded = activeScene.isLoaded, + rootCount = activeScene.rootCount, + }; + + return Response.Success("Retrieved active scene information.", sceneInfo); + } + catch (Exception e) + { + try { TcpLog.Error($"[ManageScene] get_active: exception {e.Message}"); } catch { } + return Response.Error($"Error getting active scene info: {e.Message}"); + } + } + + private static object GetBuildSettingsScenes() + { + try + { + var scenes = new List(); + for (int i = 0; i < EditorBuildSettings.scenes.Length; i++) + { + var scene = EditorBuildSettings.scenes[i]; + scenes.Add( + new + { + path = scene.path, + guid = scene.guid.ToString(), + enabled = scene.enabled, + buildIndex = i, // Actual build index considering only enabled scenes might differ + } + ); + } + return Response.Success("Retrieved scenes from Build Settings.", scenes); + } + catch (Exception e) + { + return Response.Error($"Error getting scenes from Build Settings: {e.Message}"); + } + } + + private static object GetSceneHierarchy() + { + try + { + try { TcpLog.Info("[ManageScene] get_hierarchy: querying EditorSceneManager.GetActiveScene", always: false); } catch { } + Scene activeScene = EditorSceneManager.GetActiveScene(); + try { TcpLog.Info($"[ManageScene] get_hierarchy: got scene valid={activeScene.IsValid()} loaded={activeScene.isLoaded} name='{activeScene.name}'", always: false); } catch { } + if (!activeScene.IsValid() || !activeScene.isLoaded) + { + return Response.Error( + "No valid and loaded scene is active to get hierarchy from." + ); + } + + try { TcpLog.Info("[ManageScene] get_hierarchy: fetching root objects", always: false); } catch { } + GameObject[] rootObjects = activeScene.GetRootGameObjects(); + try { TcpLog.Info($"[ManageScene] get_hierarchy: rootCount={rootObjects?.Length ?? 0}", always: false); } catch { } + + // Count total GameObjects to avoid massive responses + int totalObjectCount = 0; + foreach (var rootObj in rootObjects) + { + totalObjectCount += CountGameObjectsRecursive(rootObj); + } + + // If too many objects would be serialized, return a shallow root-only tree with hints. + if (totalObjectCount > 500) + { + var roots = rootObjects + .Where(go => go != null) + .Select(go => new Dictionary + { + { "name", go.name }, + { "instanceID", go.GetInstanceID() }, + { "activeSelf", go.activeSelf }, + { "activeInHierarchy", go.activeInHierarchy }, + { "tag", go.tag }, + { "layer", go.layer }, + { "isStatic", go.isStatic }, + { "childCount", go.transform != null ? go.transform.childCount : 0 }, + { "children", new List() }, // Keep tree shape (shallow) + }) + .ToList(); + + return Response.Success( + $"Scene hierarchy is large ({totalObjectCount} GameObjects). Returned root objects only (children omitted).", + new + { + partial = true, + mode = "roots_only", + totalObjectCount = totalObjectCount, + rootCount = roots.Count, + roots = roots, + hints = new[] + { + "Use unity_gameobject action='list_children' on a specific root GameObject to expand its subtree with a depth limit.", + "Use unity_gameobject action='find' with searchMethod='by_path' to locate a specific object (e.g. 'Root/Child') before listing children.", + "If you only need a specific area, pick a root from this list and then drill down incrementally (depth=1..N)." + } + } + ); + } + + var hierarchy = rootObjects.Select(go => GetGameObjectDataRecursive(go)).ToList(); + + var resp = Response.Success( + $"Retrieved hierarchy for scene '{activeScene.name}'.", + hierarchy + ); + try { TcpLog.Info("[ManageScene] get_hierarchy: success", always: false); } catch { } + return resp; + } + catch (Exception e) + { + try { TcpLog.Error($"[ManageScene] get_hierarchy: exception {e.Message}"); } catch { } + return Response.Error($"Error getting scene hierarchy: {e.Message}"); + } + } + + // --- Ensure Methods (Idempotent Operations) --- + + /// + /// Ensures a scene is open. Idempotent. + /// + private static object EnsureSceneOpen(string scenePath) + { + try + { + var writeCheck = WriteGuard.CheckWriteAllowed($"ensure_scene_open({scenePath})"); + if (writeCheck != null) return writeCheck; + + if (!File.Exists(scenePath)) + return Response.Error($"Scene file not found: {scenePath}"); + + Scene activeScene = SceneManager.GetActiveScene(); + if (activeScene.path.Equals(scenePath, StringComparison.OrdinalIgnoreCase)) + { + return new + { + success = true, + message = $"Scene is already active.", + data = new { scenePath = activeScene.path, dirty = activeScene.isDirty }, + state_delta = StateComposer.CreateSceneDelta(activeScene.path, activeScene.isDirty) + }; + } + + Scene loadedScene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single); + if (!loadedScene.IsValid()) + return Response.Error($"Failed to open scene: {scenePath}"); + + StateComposer.IncrementRevision(); + return new + { + success = true, + message = $"Scene opened.", + data = new { scenePath = loadedScene.path, dirty = loadedScene.isDirty }, + state_delta = StateComposer.CreateSceneDelta(loadedScene.path, loadedScene.isDirty) + }; + } + catch (Exception e) + { + return Response.Error($"Failed to ensure scene open: {e.Message}"); + } + } + + /// + /// Ensures the active scene is saved. Idempotent. + /// + private static object EnsureSceneSaved() + { + try + { + Scene activeScene = SceneManager.GetActiveScene(); + if (!activeScene.IsValid()) + return Response.Error("No active scene."); + + if (!activeScene.isDirty) + { + return new + { + success = true, + message = "Scene already saved.", + data = new { scenePath = activeScene.path, dirty = false }, + state_delta = StateComposer.CreateSceneDelta(activeScene.path, false) + }; + } + + var writeCheck = WriteGuard.CheckWriteAllowed($"ensure_scene_saved"); + if (writeCheck != null) return writeCheck; + + bool saved = EditorSceneManager.SaveScene(activeScene); + if (!saved) + return Response.Error($"Failed to save scene."); + + StateComposer.IncrementRevision(); + return new + { + success = true, + message = "Scene saved.", + data = new { scenePath = activeScene.path, dirty = false }, + state_delta = StateComposer.CreateSceneDelta(activeScene.path, false) + }; + } + catch (Exception e) + { + return Response.Error($"Failed to ensure scene saved: {e.Message}"); + } + } + + /// + /// Counts total GameObjects in a hierarchy. Uses an iterative traversal to avoid stack overflows on deep hierarchies. + /// + private static int CountGameObjectsRecursive(GameObject go) + { + if (go == null) return 0; + int count = 0; + var stack = new Stack(); + if (go.transform != null) + { + stack.Push(go.transform); + } + + while (stack.Count > 0) + { + var tr = stack.Pop(); + if (tr == null) continue; + count++; + int cc = tr.childCount; + for (int i = 0; i < cc; i++) + { + var child = tr.GetChild(i); + if (child != null) stack.Push(child); + } + } + + return count; + } + + /// + /// Recursively builds a data representation of a GameObject and its children. + /// + private static object GetGameObjectDataRecursive(GameObject go) + { + if (go == null) + return null; + + var childrenData = new List(); + foreach (Transform child in go.transform) + { + childrenData.Add(GetGameObjectDataRecursive(child.gameObject)); + } + + var gameObjectData = new Dictionary + { + { "name", go.name }, + { "activeSelf", go.activeSelf }, + { "activeInHierarchy", go.activeInHierarchy }, + { "tag", go.tag }, + { "layer", go.layer }, + { "isStatic", go.isStatic }, + { "instanceID", go.GetInstanceID() }, // Useful unique identifier + { + "transform", + new + { + position = new + { + x = go.transform.localPosition.x, + y = go.transform.localPosition.y, + z = go.transform.localPosition.z, + }, + rotation = new + { + x = go.transform.localRotation.eulerAngles.x, + y = go.transform.localRotation.eulerAngles.y, + z = go.transform.localRotation.eulerAngles.z, + }, // Euler for simplicity + scale = new + { + x = go.transform.localScale.x, + y = go.transform.localScale.y, + z = go.transform.localScale.z, + }, + } + }, + { "children", childrenData }, + }; + + return gameObjectData; + } + } +} + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScene.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScene.cs.meta new file mode 100644 index 000000000..9f17e3454 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScene.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2446b8cefbf129d40abfa286e9e3d137 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScreenshot.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScreenshot.cs new file mode 100644 index 000000000..af1d8c808 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScreenshot.cs @@ -0,0 +1,662 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using Codely.Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityTcp.Editor.Helpers; // For Response class + +namespace UnityTcp.Editor.Tools +{ + /// + /// Handles screenshot capture operations within Unity Editor. + /// + public static class ManageScreenshot + { + // --- Main Handler --- + + // Define the list of valid actions + private static readonly List ValidActions = new List + { + "capture", + "capture_main_camera", + "capture_specific_camera", + "capture_scene_camera" + }; + + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString().ToLower(); + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + + // Check if the action is valid before switching + if (!ValidActions.Contains(action)) + { + string validActionsList = string.Join(", ", ValidActions); + return Response.Error( + $"Unknown action: '{action}'. Valid actions are: {validActionsList}" + ); + } + + try + { + switch (action) + { + case "capture": + return CaptureGameView(@params); + case "capture_main_camera": + return CaptureMainCamera(@params); + case "capture_specific_camera": + return CaptureSpecificCamera(@params); + case "capture_scene_camera": + return CaptureSceneCamera(@params); + + default: + string validActionsListDefault = string.Join(", ", ValidActions); + return Response.Error( + $"Unknown action: '{action}'. Valid actions are: {validActionsListDefault}" + ); + } + } + catch (Exception e) + { + Debug.LogError($"[ManageScreenshot] Action '{action}' failed: {e}"); + return Response.Error( + $"Internal error processing action '{action}': {e.Message}" + ); + } + } + + // --- Action Implementations --- + + /// + /// Menu item to capture screenshot from game view + /// + public static void CaptureScreenshotMenuItem() + { + CaptureGameViewAndSave(); + } + + private static object CaptureGameView(JObject @params) + { + string customPath = @params["path"]?.ToString(); + string customFilename = @params["filename"]?.ToString(); + int? width = @params["width"]?.ToObject(); + int? height = @params["height"]?.ToObject(); + + try + { + string savedPath = CaptureGameViewAndSave(customPath, customFilename, width, height); + if (savedPath != null) + { + return Response.Success( + "Screenshot captured successfully from Game view.", + new { path = savedPath, camera = "Game View" } + ); + } + else + { + return Response.Error("Failed to capture screenshot from Game view."); + } + } + catch (Exception e) + { + return Response.Error($"Failed to capture screenshot from Game view: {e.Message}"); + } + } + + private static object CaptureMainCamera(JObject @params) + { + string customPath = @params["path"]?.ToString(); + string customFilename = @params["filename"]?.ToString(); + int? width = @params["width"]?.ToObject(); + int? height = @params["height"]?.ToObject(); + + try + { + string savedPath = CaptureAndSave(customPath, customFilename, width, height); + if (savedPath != null) + { + return Response.Success( + "Screenshot captured successfully from main camera.", + new { path = savedPath, camera = "Main Camera" } + ); + } + else + { + return Response.Error("Failed to capture screenshot from main camera."); + } + } + catch (Exception e) + { + return Response.Error($"Failed to capture screenshot: {e.Message}"); + } + } + + private static object CaptureSpecificCamera(JObject @params) + { + string cameraName = @params["cameraName"]?.ToString(); + string customPath = @params["path"]?.ToString(); + string customFilename = @params["filename"]?.ToString(); + int? width = @params["width"]?.ToObject(); + int? height = @params["height"]?.ToObject(); + + if (string.IsNullOrEmpty(cameraName)) + { + return Response.Error("'cameraName' parameter is required for capture_specific_camera action."); + } + + try + { + Camera targetCamera = GameObject.Find(cameraName)?.GetComponent(); + if (targetCamera == null) + { + // Try finding by name in all cameras + Camera[] cameras = UnityEngine.Object.FindObjectsOfType(); + foreach (Camera cam in cameras) + { + if (cam.name.Equals(cameraName, StringComparison.OrdinalIgnoreCase)) + { + targetCamera = cam; + break; + } + } + } + + if (targetCamera == null) + { + return Response.Error($"Camera '{cameraName}' not found in the scene."); + } + + string savedPath = CaptureAndSave(customPath, customFilename, width, height, targetCamera); + if (savedPath != null) + { + return Response.Success( + $"Screenshot captured successfully from camera '{cameraName}'.", + new { path = savedPath, camera = cameraName } + ); + } + else + { + return Response.Error($"Failed to capture screenshot from camera '{cameraName}'."); + } + } + catch (Exception e) + { + return Response.Error($"Failed to capture screenshot from camera '{cameraName}': {e.Message}"); + } + } + + private static object CaptureSceneCamera(JObject @params) + { + string customPath = @params["path"]?.ToString(); + string customFilename = @params["filename"]?.ToString(); + int? width = @params["width"]?.ToObject(); + int? height = @params["height"]?.ToObject(); + + try + { + // Get the active scene view camera + SceneView sceneView = SceneView.lastActiveSceneView; + if (sceneView == null) + { + return Response.Error("No active Scene view found. Please ensure a Scene view is open."); + } + + Camera sceneCamera = sceneView.camera; + if (sceneCamera == null) + { + return Response.Error("Scene view camera not found."); + } + + string savedPath = CaptureAndSave(customPath, customFilename, width, height, sceneCamera, "SceneCamera"); + if (savedPath != null) + { + return Response.Success( + "Screenshot captured successfully from Scene camera.", + new { path = savedPath, camera = "Scene Camera" } + ); + } + else + { + return Response.Error("Failed to capture screenshot from Scene camera."); + } + } + catch (Exception e) + { + return Response.Error($"Failed to capture screenshot from Scene camera: {e.Message}"); + } + } + + /// + /// Function to capture Game view screenshot and save to file + /// + public static string CaptureGameViewAndSave(string customPath = null, string customFilename = null, int? width = null, int? height = null) + { + try + { + // Generate filename with GameView prefix + string filename; + if (!string.IsNullOrEmpty(customFilename)) + { + filename = customFilename.EndsWith(".png") ? customFilename : customFilename + ".png"; + } + else + { + filename = $"GameView-{System.DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss")}.png"; + } + + // Determine save path + string savePath; + if (!string.IsNullOrEmpty(customPath)) + { + // Ensure directory exists + Directory.CreateDirectory(customPath); + savePath = Path.Combine(customPath, filename); + } + else + { + // Use project path with Screenshots folder + string projectPath = Path.GetDirectoryName(Application.dataPath); // Get project root (parent of Assets) + string screenshotsFolder = Path.Combine(projectPath, "Screenshots"); + + // Create Screenshots folder if it doesn't exist + if (!Directory.Exists(screenshotsFolder)) + { + Directory.CreateDirectory(screenshotsFolder); + Debug.Log($"Created Screenshots folder at: {screenshotsFolder}"); + } + + savePath = Path.Combine(screenshotsFolder, filename); + } + + // Try different capture methods based on mode and availability + Texture2D screenshot = null; + + // Always prioritize GameView reflection to capture what user actually sees + // This ensures screenshots match the GameView dimensions, multi-camera setups, and post-processing + screenshot = CaptureGameViewInEditMode(width, height); + + if (screenshot == null) + { + // Fallback to main camera rendering if GameView is not accessible + Camera mainCamera = Camera.main; + if (mainCamera != null) + { + screenshot = CaptureScreenByRenderTexture(mainCamera, width, height); + } + } + + if (screenshot == null) + { + Debug.LogError("Failed to capture screenshot using all available methods!"); + return null; + } + + // ReadPixels from RenderTexture often returns bottom-up (OpenGL origin); flip so saved PNG is top-down + FlipTextureVertically(screenshot); + + // Encode to PNG and save + byte[] bytes = screenshot.EncodeToPNG(); + UnityEngine.Object.DestroyImmediate(screenshot); // Free GPU memory (must use DestroyImmediate in edit mode) + + File.WriteAllBytes(savePath, bytes); + Debug.Log("Game view screenshot saved to: " + savePath); + return savePath; + } + catch (System.Exception e) + { + Debug.LogError($"Failed to capture game view screenshot: {e.Message}"); + return null; + } + } + + /// + /// Capture screenshot from camera using RenderTexture approach + /// + private static Texture2D CaptureScreenByRenderTexture(Camera camera, int? width = null, int? height = null) + { + if (camera == null) + { + Debug.LogError("Camera is null!"); + return null; + } + + try + { + // Use custom dimensions or default to reasonable size + int captureWidth = width ?? 1920; + int captureHeight = height ?? 1080; + + Rect rect = new Rect(0, 0, captureWidth, captureHeight); + + // Create a RenderTexture object + RenderTexture rt = new RenderTexture((int)rect.width, (int)rect.height, 24); + + // Store original target texture to restore later + RenderTexture originalTarget = camera.targetTexture; + + // Temporarily set the camera's targetTexture to rt, and manually render the camera + camera.targetTexture = rt; + camera.Render(); + + // Activate this rt, and read pixels from it. + RenderTexture previousActive = RenderTexture.active; + RenderTexture.active = rt; + + Texture2D screenShot = new Texture2D((int)rect.width, (int)rect.height, TextureFormat.RGB24, false); + // Note: At this time, it reads pixels from RenderTexture.active + screenShot.ReadPixels(rect, 0, 0); + screenShot.Apply(); + + // Reset related parameters so the camera continues to display on screen + camera.targetTexture = originalTarget; + RenderTexture.active = previousActive; + UnityEngine.Object.DestroyImmediate(rt); + + return screenShot; + } + catch (System.Exception e) + { + Debug.LogError($"Failed to capture screenshot using RenderTexture: {e.Message}"); + return null; + } + } + + /// + /// Flips the texture vertically in-place. Use before saving when the image is upside-down. + /// ReadPixels from RenderTexture uses bottom-left origin (e.g. OpenGL); PNG expects top-left, + /// so screenshots from GameView/camera RT can appear flipped. Call this before EncodeToPNG to correct. + /// + private static void FlipTextureVertically(Texture2D tex) + { + if (tex == null || tex.height <= 1) return; + int w = tex.width; + int h = tex.height; + Color[] pixels = tex.GetPixels(); + for (int y = 0; y < h / 2; y++) + { + int top = y * w; + int bottom = (h - 1 - y) * w; + for (int x = 0; x < w; x++) + { + Color tmp = pixels[top + x]; + pixels[top + x] = pixels[bottom + x]; + pixels[bottom + x] = tmp; + } + } + tex.SetPixels(pixels); + tex.Apply(); + } + + /// + /// Capture Game view using GameView's internal render texture via reflection + /// Works in both Edit and Play modes to capture what the user actually sees. + /// Note: ReadPixels from RT may yield bottom-up image (OpenGL); save path flips vertically before PNG. + /// + private static Texture2D CaptureGameViewInEditMode(int? width = null, int? height = null) + { + try + { + // Get active GameView + var gameView = GetActiveGameView(); + if (gameView == null) + { + Debug.LogWarning("Game View not found. Falling back to camera rendering."); + return CaptureWithCameraRenderingFallback(width, height); + } + + // Try to get GameView's internal render texture via reflection + // Different Unity versions may use different property/field names + RenderTexture gameViewRT = null; + var gameViewType = gameView.GetType(); + + // Try property "targetTexture" (some Unity versions) + var textureProperty = gameViewType.GetProperty("targetTexture", + BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance); + if (textureProperty != null) + { + gameViewRT = textureProperty.GetValue(gameView, null) as RenderTexture; + } + + // Try field "m_TargetTexture" (some Unity versions) + if (gameViewRT == null) + { + var textureField = gameViewType.GetField("m_TargetTexture", + BindingFlags.NonPublic | BindingFlags.Instance); + if (textureField != null) + { + gameViewRT = textureField.GetValue(gameView) as RenderTexture; + } + } + + // Try method "GetMainPlayModeView" or similar approaches + if (gameViewRT == null) + { + var renderDocField = gameViewType.GetField("m_RenderTexture", + BindingFlags.NonPublic | BindingFlags.Instance); + if (renderDocField != null) + { + gameViewRT = renderDocField.GetValue(gameView) as RenderTexture; + } + } + + if (gameViewRT == null) + { + Debug.LogWarning("GameView render texture not accessible. Falling back to camera rendering."); + return CaptureWithCameraRenderingFallback(width, height); + } + + RenderTexture activeRT = RenderTexture.active; + + // Determine final dimensions + int finalWidth = width ?? gameViewRT.width; + int finalHeight = height ?? gameViewRT.height; + + // Create temporary RenderTexture to read pixels + RenderTexture tempRT; + + // If custom dimensions are specified and different from game view, resize + if ((width.HasValue || height.HasValue) && (finalWidth != gameViewRT.width || finalHeight != gameViewRT.height)) + { + tempRT = RenderTexture.GetTemporary(finalWidth, finalHeight, 0, gameViewRT.format); + // Scale the game view texture to the desired size + Graphics.Blit(gameViewRT, tempRT); + } + else + { + // Use game view dimensions + tempRT = RenderTexture.GetTemporary(gameViewRT.width, gameViewRT.height, 0, gameViewRT.format); + Graphics.Blit(gameViewRT, tempRT); + } + + RenderTexture.active = tempRT; + Texture2D screenshot = new Texture2D(tempRT.width, tempRT.height, TextureFormat.RGB24, false); + screenshot.ReadPixels(new Rect(0, 0, tempRT.width, tempRT.height), 0, 0); + screenshot.Apply(); + + RenderTexture.active = activeRT; + RenderTexture.ReleaseTemporary(tempRT); + + return screenshot; + } + catch (System.Exception e) + { + Debug.LogError($"Failed to capture game view using reflection: {e.Message}. Falling back to camera rendering."); + return CaptureWithCameraRenderingFallback(width, height); + } + } + + /// + /// Get the active Game View window using reflection + /// + private static EditorWindow GetActiveGameView() + { + try + { + System.Type gameViewType = System.Type.GetType("UnityEditor.GameView,UnityEditor"); + if (gameViewType == null) + return null; + + // Try to get the focused game view first + EditorWindow focusedWindow = EditorWindow.focusedWindow; + if (focusedWindow != null && focusedWindow.GetType() == gameViewType) + return focusedWindow; + + // If no focused game view, get any game view that exists + EditorWindow[] gameViews = Resources.FindObjectsOfTypeAll(gameViewType) as EditorWindow[]; + if (gameViews != null && gameViews.Length > 0) + return gameViews[0]; + + return null; + } + catch (System.Exception e) + { + Debug.LogWarning($"Could not get Game View: {e.Message}"); + return null; + } + } + + /// + /// Fallback method using camera rendering when GameView access fails + /// + private static Texture2D CaptureWithCameraRenderingFallback(int? width = null, int? height = null) + { + try + { + int captureWidth = width ?? Screen.width; + int captureHeight = height ?? Screen.height; + + // Create render texture + RenderTexture rt = new RenderTexture(captureWidth, captureHeight, 24); + + // Get all cameras and render them to the render texture + Camera[] cameras = Camera.allCameras; + RenderTexture previousActive = RenderTexture.active; + RenderTexture.active = rt; + + // Clear the render texture + GL.Clear(true, true, Color.black); + + // Render all active cameras to the render texture in order + foreach (Camera cam in cameras) + { + if (cam != null && cam.enabled && cam.gameObject.activeInHierarchy) + { + RenderTexture prevTarget = cam.targetTexture; + cam.targetTexture = rt; + cam.Render(); + cam.targetTexture = prevTarget; + } + } + + // Read pixels from render texture + Texture2D screenshot = new Texture2D(captureWidth, captureHeight, TextureFormat.RGB24, false); + screenshot.ReadPixels(new Rect(0, 0, captureWidth, captureHeight), 0, 0); + screenshot.Apply(); + + // Cleanup + RenderTexture.active = previousActive; + UnityEngine.Object.DestroyImmediate(rt); + + return screenshot; + } + catch (System.Exception e) + { + Debug.LogError($"Camera rendering fallback failed: {e.Message}"); + return null; + } + } + + /// + /// Helper method to resize a texture + /// + private static Texture2D ResizeTexture(Texture2D source, int targetWidth, int targetHeight) + { + RenderTexture rt = RenderTexture.GetTemporary(targetWidth, targetHeight); + RenderTexture.active = rt; + Graphics.Blit(source, rt); + + Texture2D result = new Texture2D(targetWidth, targetHeight); + result.ReadPixels(new Rect(0, 0, targetWidth, targetHeight), 0, 0); + result.Apply(); + + RenderTexture.active = null; + RenderTexture.ReleaseTemporary(rt); + + return result; + } + + /// + /// Function to capture screenshot and save to file + /// + public static string CaptureAndSave(string customPath = null, string customFilename = null, int? width = null, int? height = null, Camera specificCamera = null, string cameraPrefix = null) + { + // Get the camera to use + Camera cam = specificCamera ?? Camera.main; + if (cam == null) + { + Debug.LogError("No main camera found!"); + return null; + } + + // Use the new RenderTexture approach + Texture2D screenshot = CaptureScreenByRenderTexture(cam, width, height); + if (screenshot == null) + { + Debug.LogError("Failed to capture screenshot using RenderTexture!"); + return null; + } + + // Encode to PNG + byte[] bytes = screenshot.EncodeToPNG(); + UnityEngine.Object.DestroyImmediate(screenshot); + + // Generate filename with camera prefix + string filename; + if (!string.IsNullOrEmpty(customFilename)) + { + filename = customFilename.EndsWith(".png") ? customFilename : customFilename + ".png"; + } + else + { + string prefix = cameraPrefix ?? (specificCamera != null ? specificCamera.name : "MainCamera"); + filename = $"{prefix}-{System.DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss")}.png"; + } + + // Determine save path + string savePath; + if (!string.IsNullOrEmpty(customPath)) + { + // Ensure directory exists + Directory.CreateDirectory(customPath); + savePath = Path.Combine(customPath, filename); + } + else + { + // Use project path with Screenshots folder + string projectPath = Path.GetDirectoryName(Application.dataPath); // Get project root (parent of Assets) + string screenshotsFolder = Path.Combine(projectPath, "Screenshots"); + + // Create Screenshots folder if it doesn't exist + if (!Directory.Exists(screenshotsFolder)) + { + Directory.CreateDirectory(screenshotsFolder); + Debug.Log($"Created Screenshots folder at: {screenshotsFolder}"); + } + + savePath = Path.Combine(screenshotsFolder, filename); + } + + // Save to file + File.WriteAllBytes(savePath, bytes); + + Debug.Log("Screenshot saved to: " + savePath); + return savePath; + } + } +} diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScreenshot.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScreenshot.cs.meta new file mode 100644 index 000000000..b8c20298f --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScreenshot.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5fd45617bc3cd52489b0ad49fe49e55b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScript.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScript.cs new file mode 100644 index 000000000..872d025bf --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScript.cs @@ -0,0 +1,2636 @@ +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Codely.Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityTcp.Editor.Helpers; +using System.Threading; +using System.Security.Cryptography; + +#if USE_ROSLYN +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Formatting; +#endif + +#if UNITY_EDITOR +using UnityEditor.Compilation; +#endif + + +namespace UnityTcp.Editor.Tools +{ + /// + /// Handles CRUD operations for C# scripts within the Unity project. + /// + /// ROSLYN INSTALLATION GUIDE: + /// To enable advanced syntax validation with Roslyn compiler services: + /// + /// 1. Install Microsoft.CodeAnalysis.CSharp NuGet package: + /// - Open Package Manager in Unity + /// - Follow the instruction on https://github.com/GlitchEnzo/NuGetForUnity + /// + /// 2. Open NuGet Package Manager and Install Microsoft.CodeAnalysis.CSharp: + /// + /// 3. Alternative: Manual DLL installation: + /// - Download Microsoft.CodeAnalysis.CSharp.dll and dependencies + /// - Place in Assets/Plugins/ folder + /// - Ensure .NET compatibility settings are correct + /// + /// 4. Define USE_ROSLYN symbol: + /// - Go to Player Settings > Scripting Define Symbols + /// - Add "USE_ROSLYN" to enable Roslyn-based validation + /// + /// 5. Restart Unity after installation + /// + /// Note: Without Roslyn, the system falls back to basic structural validation. + /// Roslyn provides full C# compiler diagnostics with line numbers and detailed error messages. + /// + public static class ManageScript + { + /// + /// Resolves a directory under Assets/, preventing traversal and escaping. + /// Returns fullPathDir on disk and canonical 'Assets/...' relative path. + /// + private static bool TryResolveUnderAssets(string relDir, out string fullPathDir, out string relPathSafe) + { + string assets = Application.dataPath.Replace('\\', '/'); + + // Normalize caller path: allow both "Scripts/..." and "Assets/Scripts/..." + string rel = (relDir ?? "Scripts").Replace('\\', '/').Trim(); + if (string.IsNullOrEmpty(rel)) rel = "Scripts"; + if (rel.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) rel = rel.Substring(7); + rel = rel.TrimStart('/'); + + string targetDir = Path.Combine(assets, rel).Replace('\\', '/'); + string full = Path.GetFullPath(targetDir).Replace('\\', '/'); + + bool underAssets = full.StartsWith(assets + "/", StringComparison.OrdinalIgnoreCase) + || string.Equals(full, assets, StringComparison.OrdinalIgnoreCase); + if (!underAssets) + { + fullPathDir = null; + relPathSafe = null; + return false; + } + + // Best-effort symlink guard: if the directory OR ANY ANCESTOR (up to Assets/) is a reparse point/symlink, reject + try + { + var di = new DirectoryInfo(full); + while (di != null) + { + if (di.Exists && (di.Attributes & FileAttributes.ReparsePoint) != 0) + { + fullPathDir = null; + relPathSafe = null; + return false; + } + var atAssets = string.Equals( + di.FullName.Replace('\\','/'), + assets, + StringComparison.OrdinalIgnoreCase + ); + if (atAssets) break; + di = di.Parent; + } + } + catch { /* best effort; proceed */ } + + fullPathDir = full; + string tail = full.Length > assets.Length ? full.Substring(assets.Length).TrimStart('/') : string.Empty; + relPathSafe = ("Assets/" + tail).TrimEnd('/'); + return true; + } + /// + /// Main handler for script management actions. + /// + public static object HandleCommand(JObject @params) + { + // Handle null parameters + if (@params == null) + { + return Response.Error("invalid_params", "Parameters cannot be null."); + } + + // Extract parameters + string action = @params["action"]?.ToString()?.ToLower(); + string name = @params["name"]?.ToString(); + string path = @params["path"]?.ToString(); // Relative to Assets/ + string contents = null; + + // --- Validate client_state_rev for write operations --- + var writeActions = new[] { "create", "update", "delete", "apply_text_edits", "edit" }; + if (writeActions.Contains(action)) + { + var revConflict = StateComposer.ValidateClientRevisionFromParams(@params); + if (revConflict != null) return revConflict; + } + + // Check if we have base64 encoded contents + bool contentsEncoded = @params["contentsEncoded"]?.ToObject() ?? false; + if (contentsEncoded && @params["encodedContents"] != null) + { + try + { + contents = DecodeBase64(@params["encodedContents"].ToString()); + } + catch (Exception e) + { + return Response.Error($"Failed to decode script contents: {e.Message}"); + } + } + else + { + contents = @params["contents"]?.ToString(); + } + + string scriptType = @params["scriptType"]?.ToString(); // For templates/validation + string namespaceName = @params["namespace"]?.ToString(); // For organizing code + + // Validate required parameters + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + if (string.IsNullOrEmpty(name)) + { + return Response.Error("Name parameter is required."); + } + // Basic name validation (alphanumeric, underscores, cannot start with number) + if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2))) + { + return Response.Error( + $"Invalid script name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." + ); + } + + // Resolve and harden target directory under Assets/ + if (!TryResolveUnderAssets(path, out string fullPathDir, out string relPathSafeDir)) + { + return Response.Error($"Invalid path. Target directory must be within 'Assets/'. Provided: '{(path ?? "(null)")}'"); + } + + // Construct file paths + string scriptFileName = $"{name}.cs"; + string fullPath = Path.Combine(fullPathDir, scriptFileName); + string relativePath = Path.Combine(relPathSafeDir, scriptFileName).Replace('\\', '/'); + + // Ensure the target directory exists for create/update + if (action == "create" || action == "update") + { + try + { + Directory.CreateDirectory(fullPathDir); + } + catch (Exception e) + { + return Response.Error( + $"Could not create directory '{fullPathDir}': {e.Message}" + ); + } + } + + // Route to specific action handlers + switch (action) + { + case "create": + return CreateScript( + fullPath, + relativePath, + name, + contents, + scriptType, + namespaceName + ); + case "read": + TcpLog.Warn("manage_script.read is deprecated; prefer resources/read. Serving read for backward compatibility."); + return ReadScript(fullPath, relativePath); + case "update": + TcpLog.Warn("manage_script.update is deprecated; prefer apply_text_edits. Serving update for backward compatibility."); + return UpdateScript(fullPath, relativePath, name, contents); + case "delete": + return DeleteScript(fullPath, relativePath); + case "apply_text_edits": + { + var textEdits = @params["edits"] as JArray; + string precondition = @params["precondition_sha256"]?.ToString(); + // Respect optional options + string refreshOpt = @params["options"]?["refresh"]?.ToString()?.ToLowerInvariant(); + string validateOpt = @params["options"]?["validate"]?.ToString()?.ToLowerInvariant(); + return ApplyTextEdits(fullPath, relativePath, name, textEdits, precondition, refreshOpt, validateOpt); + } + case "validate": + { + string level = @params["level"]?.ToString()?.ToLowerInvariant() ?? "standard"; + var chosen = level switch + { + "basic" => ValidationLevel.Basic, + "standard" => ValidationLevel.Standard, + "strict" => ValidationLevel.Strict, + "comprehensive" => ValidationLevel.Comprehensive, + _ => ValidationLevel.Standard + }; + string fileText; + try { fileText = File.ReadAllText(fullPath); } + catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + + bool ok = ValidateScriptSyntax(fileText, chosen, out string[] diagsRaw); + var diags = (diagsRaw ?? Array.Empty()).Select(s => + { + var m = Regex.Match( + s, + @"^(ERROR|WARNING|INFO): (.*?)(?: \(Line (\d+)\))?$", + RegexOptions.CultureInvariant | RegexOptions.Multiline, + TimeSpan.FromMilliseconds(250) + ); + string severity = m.Success ? m.Groups[1].Value.ToLowerInvariant() : "info"; + string message = m.Success ? m.Groups[2].Value : s; + int lineNum = m.Success && int.TryParse(m.Groups[3].Value, out var l) ? l : 0; + return new { line = lineNum, col = 0, severity, message }; + }).ToArray(); + + var result = new { diagnostics = diags }; + return ok ? Response.Success("Validation completed.", result) + : Response.Error("Validation failed.", result); + } + case "edit": + Debug.LogWarning("manage_script.edit is deprecated; prefer apply_text_edits. Serving structured edit for backward compatibility."); + var structEdits = @params["edits"] as JArray; + var options = @params["options"] as JObject; + return EditScript(fullPath, relativePath, name, structEdits, options); + case "get_sha": + { + try + { + if (!File.Exists(fullPath)) + return Response.Error($"Script not found at '{relativePath}'."); + + string text = File.ReadAllText(fullPath); + string sha = ComputeSha256(text); + var fi = new FileInfo(fullPath); + long lengthBytes; + try { lengthBytes = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetByteCount(text); } + catch { lengthBytes = fi.Exists ? fi.Length : 0; } + var data = new + { + uri = $"unity://path/{relativePath}", + path = relativePath, + sha256 = sha, + lengthBytes, + lastModifiedUtc = fi.Exists ? fi.LastWriteTimeUtc.ToString("o") : string.Empty + }; + return Response.Success($"SHA computed for '{relativePath}'.", data); + } + catch (Exception ex) + { + return Response.Error($"Failed to compute SHA: {ex.Message}"); + } + } + default: + return Response.Error( + $"Unknown action: '{action}'. Valid actions are: create, delete, apply_text_edits, validate, read (deprecated), update (deprecated), edit (deprecated)." + ); + } + } + + /// + /// Decode base64 string to normal text + /// + private static string DecodeBase64(string encoded) + { + byte[] data = Convert.FromBase64String(encoded); + return System.Text.Encoding.UTF8.GetString(data); + } + + /// + /// Encode text to base64 string + /// + private static string EncodeBase64(string text) + { + byte[] data = System.Text.Encoding.UTF8.GetBytes(text); + return Convert.ToBase64String(data); + } + + private static object CreateScript( + string fullPath, + string relativePath, + string name, + string contents, + string scriptType, + string namespaceName + ) + { + // Check if script already exists + if (File.Exists(fullPath)) + { + return Response.Error( + $"Script already exists at '{relativePath}'. Use 'update' action to modify." + ); + } + + // Generate default content if none provided + if (string.IsNullOrEmpty(contents)) + { + contents = GenerateDefaultScriptContent(name, scriptType, namespaceName); + } + + // Validate syntax with detailed error reporting using GUI setting + ValidationLevel validationLevel = GetValidationLevelFromGUI(); + bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); + if (!isValid) + { + return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); + } + else if (validationErrors != null && validationErrors.Length > 0) + { + // Log warnings but don't block creation + Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors)); + } + + try + { + // Atomic create without BOM; schedule refresh after reply + var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, contents, enc); + try + { + File.Move(tmp, fullPath); + } + catch (IOException) + { + File.Copy(tmp, fullPath, overwrite: true); + try { File.Delete(tmp); } catch { } + } + + var uri = $"unity://path/{relativePath}"; + var ok = Response.Success( + $"Script '{name}.cs' created successfully at '{relativePath}'.", + new { uri, scheduledRefresh = false } + ); + + ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath); + + return ok; + } + catch (Exception e) + { + return Response.Error($"Failed to create script '{relativePath}': {e.Message}"); + } + } + + private static object ReadScript(string fullPath, string relativePath) + { + if (!File.Exists(fullPath)) + { + return Response.Error($"Script not found at '{relativePath}'."); + } + + try + { + string contents = File.ReadAllText(fullPath); + + // Return both normal and encoded contents for larger files + bool isLarge = contents.Length > 10000; // If content is large, include encoded version + var uri = $"unity://path/{relativePath}"; + var responseData = new + { + uri, + path = relativePath, + contents = contents, + // For large files, also include base64-encoded version + encodedContents = isLarge ? EncodeBase64(contents) : null, + contentsEncoded = isLarge, + }; + + return Response.Success( + $"Script '{Path.GetFileName(relativePath)}' read successfully.", + responseData + ); + } + catch (Exception e) + { + return Response.Error($"Failed to read script '{relativePath}': {e.Message}"); + } + } + + private static object UpdateScript( + string fullPath, + string relativePath, + string name, + string contents + ) + { + if (!File.Exists(fullPath)) + { + return Response.Error( + $"Script not found at '{relativePath}'. Use 'create' action to add a new script." + ); + } + if (string.IsNullOrEmpty(contents)) + { + return Response.Error("Content is required for the 'update' action."); + } + + // Validate syntax with detailed error reporting using GUI setting + ValidationLevel validationLevel = GetValidationLevelFromGUI(); + bool isValid = ValidateScriptSyntax(contents, validationLevel, out string[] validationErrors); + if (!isValid) + { + return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = validationErrors ?? Array.Empty() }); + } + else if (validationErrors != null && validationErrors.Length > 0) + { + // Log warnings but don't block update + Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", validationErrors)); + } + + try + { + // Safe write with atomic replace when available, without BOM + var encoding = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + string tempPath = fullPath + ".tmp"; + File.WriteAllText(tempPath, contents, encoding); + + string backupPath = fullPath + ".bak"; + try + { + File.Replace(tempPath, fullPath, backupPath); + try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } + } + catch (PlatformNotSupportedException) + { + File.Copy(tempPath, fullPath, true); + try { File.Delete(tempPath); } catch { } + try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } + } + catch (IOException) + { + File.Copy(tempPath, fullPath, true); + try { File.Delete(tempPath); } catch { } + try { if (File.Exists(backupPath)) File.Delete(backupPath); } catch { } + } + + // Prepare success response BEFORE any operation that can trigger a domain reload + var uri = $"unity://path/{relativePath}"; + var ok = Response.Success( + $"Script '{name}.cs' updated successfully at '{relativePath}'.", + new { uri, path = relativePath, scheduledRefresh = true } + ); + + // Schedule a debounced import/compile on next editor tick to avoid stalling the reply + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + + return ok; + } + catch (Exception e) + { + return Response.Error($"Failed to update script '{relativePath}': {e.Message}"); + } + } + + /// + /// Apply simple text edits specified by line/column ranges. Applies transactionally and validates result. + /// + private const int MaxEditPayloadBytes = 64 * 1024; + + private static object ApplyTextEdits( + string fullPath, + string relativePath, + string name, + JArray edits, + string preconditionSha256, + string refreshModeFromCaller = null, + string validateMode = null) + { + if (!File.Exists(fullPath)) + return Response.Error($"Script not found at '{relativePath}'."); + // Refuse edits if the target or any ancestor is a symlink + try + { + var di = new DirectoryInfo(Path.GetDirectoryName(fullPath) ?? ""); + while (di != null && !string.Equals(di.FullName.Replace('\\','/'), Application.dataPath.Replace('\\','/'), StringComparison.OrdinalIgnoreCase)) + { + if (di.Exists && (di.Attributes & FileAttributes.ReparsePoint) != 0) + return Response.Error("Refusing to edit a symlinked script path."); + di = di.Parent; + } + } + catch + { + // If checking attributes fails, proceed without the symlink guard + } + if (edits == null || edits.Count == 0) + return Response.Error("No edits provided."); + + string original; + try { original = File.ReadAllText(fullPath); } + catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + + // Require precondition to avoid drift on large files + string currentSha = ComputeSha256(original); + if (string.IsNullOrEmpty(preconditionSha256)) + return Response.Error("precondition_required", new { status = "precondition_required", current_sha256 = currentSha }); + if (!preconditionSha256.Equals(currentSha, StringComparison.OrdinalIgnoreCase)) + return Response.Error("stale_file", new { status = "stale_file", expected_sha256 = preconditionSha256, current_sha256 = currentSha }); + + // Convert edits to absolute index ranges + var spans = new List<(int start, int end, string text)>(); + long totalBytes = 0; + foreach (var e in edits) + { + try + { + int sl = Math.Max(1, e.Value("startLine")); + int sc = Math.Max(1, e.Value("startCol")); + int el = Math.Max(1, e.Value("endLine")); + int ec = Math.Max(1, e.Value("endCol")); + string newText = e.Value("newText") ?? string.Empty; + + if (!TryIndexFromLineCol(original, sl, sc, out int sidx)) + return Response.Error($"apply_text_edits: start out of range (line {sl}, col {sc})"); + if (!TryIndexFromLineCol(original, el, ec, out int eidx)) + return Response.Error($"apply_text_edits: end out of range (line {el}, col {ec})"); + if (eidx < sidx) (sidx, eidx) = (eidx, sidx); + + spans.Add((sidx, eidx, newText)); + checked + { + totalBytes += System.Text.Encoding.UTF8.GetByteCount(newText); + } + } + catch (Exception ex) + { + return Response.Error($"Invalid edit payload: {ex.Message}"); + } + } + + // Header guard: refuse edits that touch before the first 'using ' directive (after optional BOM) to prevent file corruption + int headerBoundary = (original.Length > 0 && original[0] == '\uFEFF') ? 1 : 0; // skip BOM once if present + // Find first top-level using (supports alias, static, and dotted namespaces) + var mUsing = System.Text.RegularExpressions.Regex.Match( + original, + @"(?m)^\s*using\s+(?:static\s+)?(?:[A-Za-z_]\w*\s*=\s*)?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\s*;", + System.Text.RegularExpressions.RegexOptions.CultureInvariant, + TimeSpan.FromSeconds(2) + ); + if (mUsing.Success) + { + headerBoundary = Math.Min(Math.Max(headerBoundary, mUsing.Index), original.Length); + } + foreach (var sp in spans) + { + if (sp.start < headerBoundary) + { + return Response.Error("using_guard", new { status = "using_guard", hint = "Refusing to edit before the first 'using'. Use anchor_insert near a method or a structured edit." }); + } + } + + // Attempt auto-upgrade: if a single edit targets a method header/body, re-route as structured replace_method + if (spans.Count == 1) + { + var sp = spans[0]; + // Heuristic: around the start of the edit, try to match a method header in original + int searchStart = Math.Max(0, sp.start - 200); + int searchEnd = Math.Min(original.Length, sp.start + 200); + string slice = original.Substring(searchStart, searchEnd - searchStart); + var rx = new System.Text.RegularExpressions.Regex(@"(?m)^[\t ]*(?:\[[^\]]+\][\t ]*)*[\t ]*(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial)[\s\S]*?\b([A-Za-z_][A-Za-z0-9_]*)\s*\("); + var mh = rx.Match(slice); + if (mh.Success) + { + string methodName = mh.Groups[1].Value; + // Find class span containing the edit + if (TryComputeClassSpan(original, name, null, out var clsStart, out var clsLen, out _)) + { + if (TryComputeMethodSpan(original, clsStart, clsLen, methodName, null, null, null, out var mStart, out var mLen, out _)) + { + // If the edit overlaps the method span significantly, treat as replace_method + if (sp.start <= mStart + 2 && sp.end >= mStart + 1) + { + var structEdits = new JArray(); + + // Apply the edit to get a candidate string, then recompute method span on the edited text + string candidate = original.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); + string replacementText; + if (TryComputeClassSpan(candidate, name, null, out var cls2Start, out var cls2Len, out _) + && TryComputeMethodSpan(candidate, cls2Start, cls2Len, methodName, null, null, null, out var m2Start, out var m2Len, out _)) + { + replacementText = candidate.Substring(m2Start, m2Len); + } + else + { + // Fallback: adjust method start by the net delta if the edit was before the method + int delta = (sp.text?.Length ?? 0) - (sp.end - sp.start); + int adjustedStart = mStart + (sp.start <= mStart ? delta : 0); + adjustedStart = Math.Max(0, Math.Min(adjustedStart, candidate.Length)); + + // If the edit was within the original method span, adjust the length by the delta within-method + int withinMethodDelta = 0; + if (sp.start >= mStart && sp.start <= mStart + mLen) + { + withinMethodDelta = delta; + } + int adjustedLen = mLen + withinMethodDelta; + adjustedLen = Math.Max(0, Math.Min(candidate.Length - adjustedStart, adjustedLen)); + replacementText = candidate.Substring(adjustedStart, adjustedLen); + } + + var op = new JObject + { + ["mode"] = "replace_method", + ["className"] = name, + ["methodName"] = methodName, + ["replacement"] = replacementText + }; + structEdits.Add(op); + // Reuse structured path + return EditScript(fullPath, relativePath, name, structEdits, new JObject{ ["refresh"] = "immediate", ["validate"] = "standard" }); + } + } + } + } + } + + if (totalBytes > MaxEditPayloadBytes) + { + return Response.Error("too_large", new { status = "too_large", limitBytes = MaxEditPayloadBytes, hint = "split into smaller edits" }); + } + + // Ensure non-overlap and apply from back to front + spans = spans.OrderByDescending(t => t.start).ToList(); + for (int i = 1; i < spans.Count; i++) + { + if (spans[i].end > spans[i - 1].start) + { + var conflict = new[] { new { startA = spans[i].start, endA = spans[i].end, startB = spans[i - 1].start, endB = spans[i - 1].end } }; + return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); + } + } + + string working = original; + bool relaxed = string.Equals(validateMode, "relaxed", StringComparison.OrdinalIgnoreCase); + bool syntaxOnly = string.Equals(validateMode, "syntax", StringComparison.OrdinalIgnoreCase); + foreach (var sp in spans) + { + string next = working.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty); + if (relaxed) + { + // Scoped balance check: validate just around the changed region to avoid false positives + int originalLength = sp.end - sp.start; + int newLength = sp.text?.Length ?? 0; + int endPos = sp.start + newLength; + if (!CheckScopedBalance(next, Math.Max(0, sp.start - 500), Math.Min(next.Length, endPos + 500))) + { + return Response.Error("unbalanced_braces", new { status = "unbalanced_braces", line = 0, expected = "{}()[] (scoped)", hint = "Use standard validation or shrink the edit range." }); + } + } + working = next; + } + + // No-op guard: if resulting text is identical, avoid writes and return explicit no-op + if (string.Equals(working, original, StringComparison.Ordinal)) + { + string noChangeSha = ComputeSha256(original); + return Response.Success( + $"No-op: contents unchanged for '{relativePath}'.", + new + { + uri = $"unity://path/{relativePath}", + path = relativePath, + editsApplied = 0, + no_op = true, + sha256 = noChangeSha, + evidence = new { reason = "identical_content" } + } + ); + } + + // Always check final structural balance regardless of relaxed mode + if (!CheckBalancedDelimiters(working, out int line, out char expected)) + { + int startLine = Math.Max(1, line - 5); + int endLine = line + 5; + string hint = $"unbalanced_braces at line {line}. Call resources/read for lines {startLine}-{endLine} and resend a smaller apply_text_edits that restores balance."; + return Response.Error(hint, new { status = "unbalanced_braces", line, expected = expected.ToString(), evidenceWindow = new { startLine, endLine } }); + } + + string newSha = ComputeSha256(working); + + // Atomic write and schedule refresh + try + { + var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, working, enc); + string backup = fullPath + ".bak"; + try + { + File.Replace(tmp, fullPath, backup); + try { if (File.Exists(backup)) File.Delete(backup); } catch { /* ignore */ } + } + catch (PlatformNotSupportedException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + catch (IOException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + + // Respect refresh mode: immediate vs debounced + bool immediate = string.Equals(refreshModeFromCaller, "immediate", StringComparison.OrdinalIgnoreCase) || + string.Equals(refreshModeFromCaller, "sync", StringComparison.OrdinalIgnoreCase); + if (immediate) + { + TcpLog.Info($"[ManageScript] ApplyTextEdits: immediate refresh for '{relativePath}'"); + AssetDatabase.ImportAsset( + relativePath, + ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate + ); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + } + else + { + TcpLog.Info($"[ManageScript] ApplyTextEdits: debounced refresh scheduled for '{relativePath}'"); + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + } + + return Response.Success( + $"Applied {spans.Count} text edit(s) to '{relativePath}'.", + new + { + uri = $"unity://path/{relativePath}", + path = relativePath, + editsApplied = spans.Count, + sha256 = newSha, + scheduledRefresh = !immediate + } + ); + } + catch (Exception ex) + { + return Response.Error($"Failed to write edits: {ex.Message}"); + } + } + + private static bool TryIndexFromLineCol(string text, int line1, int col1, out int index) + { + // 1-based line/col to absolute index (0-based), col positions are counted in code points + int line = 1, col = 1; + for (int i = 0; i <= text.Length; i++) + { + if (line == line1 && col == col1) + { + index = i; + return true; + } + if (i == text.Length) break; + char c = text[i]; + if (c == '\r') + { + // Treat CRLF as a single newline; skip the LF if present + if (i + 1 < text.Length && text[i + 1] == '\n') + i++; + line++; + col = 1; + } + else if (c == '\n') + { + line++; + col = 1; + } + else + { + col++; + } + } + index = -1; + return false; + } + + private static string ComputeSha256(string contents) + { + using (var sha = SHA256.Create()) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(contents); + var hash = sha.ComputeHash(bytes); + return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + } + } + + private static bool CheckBalancedDelimiters(string text, out int line, out char expected) + { + var braceStack = new Stack(); + var parenStack = new Stack(); + var bracketStack = new Stack(); + bool inString = false, inChar = false, inSingle = false, inMulti = false, escape = false; + line = 1; expected = '\0'; + + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + char next = i + 1 < text.Length ? text[i + 1] : '\0'; + + if (c == '\n') { line++; if (inSingle) inSingle = false; } + + if (escape) { escape = false; continue; } + + if (inString) + { + if (c == '\\') { escape = true; } + else if (c == '"') inString = false; + continue; + } + if (inChar) + { + if (c == '\\') { escape = true; } + else if (c == '\'') inChar = false; + continue; + } + if (inSingle) continue; + if (inMulti) + { + if (c == '*' && next == '/') { inMulti = false; i++; } + continue; + } + + if (c == '"') { inString = true; continue; } + if (c == '\'') { inChar = true; continue; } + if (c == '/' && next == '/') { inSingle = true; i++; continue; } + if (c == '/' && next == '*') { inMulti = true; i++; continue; } + + switch (c) + { + case '{': braceStack.Push(line); break; + case '}': + if (braceStack.Count == 0) { expected = '{'; return false; } + braceStack.Pop(); + break; + case '(': parenStack.Push(line); break; + case ')': + if (parenStack.Count == 0) { expected = '('; return false; } + parenStack.Pop(); + break; + case '[': bracketStack.Push(line); break; + case ']': + if (bracketStack.Count == 0) { expected = '['; return false; } + bracketStack.Pop(); + break; + } + } + + if (braceStack.Count > 0) { line = braceStack.Peek(); expected = '}'; return false; } + if (parenStack.Count > 0) { line = parenStack.Peek(); expected = ')'; return false; } + if (bracketStack.Count > 0) { line = bracketStack.Peek(); expected = ']'; return false; } + + return true; + } + + // Lightweight scoped balance: checks delimiters within a substring, ignoring outer context + private static bool CheckScopedBalance(string text, int start, int end) + { + start = Math.Max(0, Math.Min(text.Length, start)); + end = Math.Max(start, Math.Min(text.Length, end)); + int brace = 0, paren = 0, bracket = 0; + bool inStr = false, inChr = false, esc = false; + for (int i = start; i < end; i++) + { + char c = text[i]; + char n = (i + 1 < end) ? text[i + 1] : '\0'; + if (inStr) + { + if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; + } + if (inChr) + { + if (!esc && c == '\'') inChr = false; esc = (!esc && c == '\\'); continue; + } + if (c == '"') { inStr = true; esc = false; continue; } + if (c == '\'') { inChr = true; esc = false; continue; } + if (c == '/' && n == '/') { while (i < end && text[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < end && !(text[i] == '*' && text[i + 1] == '/')) i++; i++; continue; } + if (c == '{') brace++; else if (c == '}') brace--; + else if (c == '(') paren++; else if (c == ')') paren--; + else if (c == '[') bracket++; else if (c == ']') bracket--; + // Allow temporary negative balance - will check tolerance at end + } + return brace >= -3 && paren >= -3 && bracket >= -3; // tolerate more context from outside region + } + + private static object DeleteScript(string fullPath, string relativePath) + { + if (!File.Exists(fullPath)) + { + return Response.Error($"Script not found at '{relativePath}'. Cannot delete."); + } + + try + { + // Use AssetDatabase.MoveAssetToTrash for safer deletion (allows undo) + bool deleted = AssetDatabase.MoveAssetToTrash(relativePath); + if (deleted) + { + AssetDatabase.Refresh(); + return Response.Success( + $"Script '{Path.GetFileName(relativePath)}' moved to trash successfully.", + new { deleted = true } + ); + } + else + { + // Fallback or error if MoveAssetToTrash fails + return Response.Error( + $"Failed to move script '{relativePath}' to trash. It might be locked or in use." + ); + } + } + catch (Exception e) + { + return Response.Error($"Error deleting script '{relativePath}': {e.Message}"); + } + } + + /// + /// Structured edits (AST-backed where available) on existing scripts. + /// Supports class-level replace/delete with Roslyn span computation if USE_ROSLYN is defined, + /// otherwise falls back to a conservative balanced-brace scan. + /// + private static object EditScript( + string fullPath, + string relativePath, + string name, + JArray edits, + JObject options) + { + if (!File.Exists(fullPath)) + return Response.Error($"Script not found at '{relativePath}'."); + // Refuse edits if the target is a symlink + try + { + var attrs = File.GetAttributes(fullPath); + if ((attrs & FileAttributes.ReparsePoint) != 0) + return Response.Error("Refusing to edit a symlinked script path."); + } + catch + { + // ignore failures checking attributes and proceed + } + if (edits == null || edits.Count == 0) + return Response.Error("No edits provided."); + + string original; + try { original = File.ReadAllText(fullPath); } + catch (Exception ex) { return Response.Error($"Failed to read script: {ex.Message}"); } + + string working = original; + + try + { + var replacements = new List<(int start, int length, string text)>(); + int appliedCount = 0; + + // Apply mode: atomic (default) computes all spans against original and applies together. + // Sequential applies each edit immediately to the current working text (useful for dependent edits). + string applyMode = options?["applyMode"]?.ToString()?.ToLowerInvariant(); + bool applySequentially = applyMode == "sequential"; + + foreach (var e in edits) + { + var op = (JObject)e; + var mode = (op.Value("mode") ?? op.Value("op") ?? string.Empty).ToLowerInvariant(); + + switch (mode) + { + case "replace_class": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string replacement = ExtractReplacement(op); + + if (string.IsNullOrWhiteSpace(className)) + return Response.Error("replace_class requires 'className'."); + if (replacement == null) + return Response.Error("replace_class requires 'replacement' (inline or base64)."); + + if (!TryComputeClassSpan(working, className, ns, out var spanStart, out var spanLength, out var why)) + return Response.Error($"replace_class failed: {why}"); + + if (!ValidateClassSnippet(replacement, className, out var vErr)) + return Response.Error($"Replacement snippet invalid: {vErr}"); + + if (applySequentially) + { + working = working.Remove(spanStart, spanLength).Insert(spanStart, NormalizeNewlines(replacement)); + appliedCount++; + } + else + { + replacements.Add((spanStart, spanLength, NormalizeNewlines(replacement))); + } + break; + } + + case "delete_class": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + if (string.IsNullOrWhiteSpace(className)) + return Response.Error("delete_class requires 'className'."); + + if (!TryComputeClassSpan(working, className, ns, out var s, out var l, out var why)) + return Response.Error($"delete_class failed: {why}"); + + if (applySequentially) + { + working = working.Remove(s, l); + appliedCount++; + } + else + { + replacements.Add((s, l, string.Empty)); + } + break; + } + + case "replace_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string methodName = op.Value("methodName"); + string replacement = ExtractReplacement(op); + string returnType = op.Value("returnType"); + string parametersSignature = op.Value("parametersSignature"); + string attributesContains = op.Value("attributesContains"); + + if (string.IsNullOrWhiteSpace(className)) return Response.Error("replace_method requires 'className'."); + if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("replace_method requires 'methodName'."); + if (replacement == null) return Response.Error("replace_method requires 'replacement' (inline or base64)."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return Response.Error($"replace_method failed to locate class: {whyClass}"); + + if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) + { + bool hasDependentInsert = edits.Any(j => j is JObject jo && + string.Equals(jo.Value("className"), className, StringComparison.Ordinal) && + string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && + ((jo.Value("mode") ?? jo.Value("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); + string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; + return Response.Error($"replace_method failed: {whyMethod}.{hint}"); + } + + if (applySequentially) + { + working = working.Remove(mStart, mLen).Insert(mStart, NormalizeNewlines(replacement)); + appliedCount++; + } + else + { + replacements.Add((mStart, mLen, NormalizeNewlines(replacement))); + } + break; + } + + case "delete_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string methodName = op.Value("methodName"); + string returnType = op.Value("returnType"); + string parametersSignature = op.Value("parametersSignature"); + string attributesContains = op.Value("attributesContains"); + + if (string.IsNullOrWhiteSpace(className)) return Response.Error("delete_method requires 'className'."); + if (string.IsNullOrWhiteSpace(methodName)) return Response.Error("delete_method requires 'methodName'."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return Response.Error($"delete_method failed to locate class: {whyClass}"); + + if (!TryComputeMethodSpan(working, clsStart, clsLen, methodName, returnType, parametersSignature, attributesContains, out var mStart, out var mLen, out var whyMethod)) + { + bool hasDependentInsert = edits.Any(j => j is JObject jo && + string.Equals(jo.Value("className"), className, StringComparison.Ordinal) && + string.Equals(jo.Value("methodName"), methodName, StringComparison.Ordinal) && + ((jo.Value("mode") ?? jo.Value("op") ?? string.Empty).ToLowerInvariant() == "insert_method")); + string hint = hasDependentInsert && !applySequentially ? " Hint: This batch inserts this method. Use options.applyMode='sequential' or split into separate calls." : string.Empty; + return Response.Error($"delete_method failed: {whyMethod}.{hint}"); + } + + if (applySequentially) + { + working = working.Remove(mStart, mLen); + appliedCount++; + } + else + { + replacements.Add((mStart, mLen, string.Empty)); + } + break; + } + + case "insert_method": + { + string className = op.Value("className"); + string ns = op.Value("namespace"); + string position = (op.Value("position") ?? "end").ToLowerInvariant(); + string afterMethodName = op.Value("afterMethodName"); + string afterReturnType = op.Value("afterReturnType"); + string afterParameters = op.Value("afterParametersSignature"); + string afterAttributesContains = op.Value("afterAttributesContains"); + string snippet = ExtractReplacement(op); + // Harden: refuse empty replacement for inserts + if (snippet == null || snippet.Trim().Length == 0) + return Response.Error("insert_method requires a non-empty 'replacement' text."); + + if (string.IsNullOrWhiteSpace(className)) return Response.Error("insert_method requires 'className'."); + if (snippet == null) return Response.Error("insert_method requires 'replacement' (inline or base64) containing a full method declaration."); + + if (!TryComputeClassSpan(working, className, ns, out var clsStart, out var clsLen, out var whyClass)) + return Response.Error($"insert_method failed to locate class: {whyClass}"); + + if (position == "after") + { + if (string.IsNullOrEmpty(afterMethodName)) return Response.Error("insert_method with position='after' requires 'afterMethodName'."); + if (!TryComputeMethodSpan(working, clsStart, clsLen, afterMethodName, afterReturnType, afterParameters, afterAttributesContains, out var aStart, out var aLen, out var whyAfter)) + return Response.Error($"insert_method(after) failed to locate anchor method: {whyAfter}"); + int insAt = aStart + aLen; + string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); + if (applySequentially) + { + working = working.Insert(insAt, text); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, text)); + } + } + else if (!TryFindClassInsertionPoint(working, clsStart, clsLen, position, out var insAt, out var whyIns)) + return Response.Error($"insert_method failed: {whyIns}"); + else + { + string text = NormalizeNewlines("\n\n" + snippet.TrimEnd() + "\n"); + if (applySequentially) + { + working = working.Insert(insAt, text); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, text)); + } + } + break; + } + + case "anchor_insert": + { + string anchor = op.Value("anchor"); + string position = (op.Value("position") ?? "before").ToLowerInvariant(); + string text = op.Value("text") ?? ExtractReplacement(op); + if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_insert requires 'anchor' (regex)."); + if (string.IsNullOrEmpty(text)) return Response.Error("anchor_insert requires non-empty 'text'."); + + try + { + var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); + var m = rx.Match(working); + if (!m.Success) return Response.Error($"anchor_insert: anchor not found: {anchor}"); + int insAt = position == "after" ? m.Index + m.Length : m.Index; + string norm = NormalizeNewlines(text); + if (!norm.EndsWith("\n")) + { + norm += "\n"; + } + + // Duplicate guard: if identical snippet already exists within this class, skip insert + if (TryComputeClassSpan(working, name, null, out var clsStartDG, out var clsLenDG, out _)) + { + string classSlice = working.Substring(clsStartDG, Math.Min(clsLenDG, working.Length - clsStartDG)); + if (classSlice.IndexOf(norm, StringComparison.Ordinal) >= 0) + { + // Do not insert duplicate; treat as no-op + break; + } + } + if (applySequentially) + { + working = working.Insert(insAt, norm); + appliedCount++; + } + else + { + replacements.Add((insAt, 0, norm)); + } + } + catch (Exception ex) + { + return Response.Error($"anchor_insert failed: {ex.Message}"); + } + break; + } + + case "anchor_delete": + { + string anchor = op.Value("anchor"); + if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_delete requires 'anchor' (regex)."); + try + { + var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); + var m = rx.Match(working); + if (!m.Success) return Response.Error($"anchor_delete: anchor not found: {anchor}"); + int delAt = m.Index; + int delLen = m.Length; + if (applySequentially) + { + working = working.Remove(delAt, delLen); + appliedCount++; + } + else + { + replacements.Add((delAt, delLen, string.Empty)); + } + } + catch (Exception ex) + { + return Response.Error($"anchor_delete failed: {ex.Message}"); + } + break; + } + + case "anchor_replace": + { + string anchor = op.Value("anchor"); + string replacement = op.Value("text") ?? op.Value("replacement") ?? ExtractReplacement(op) ?? string.Empty; + if (string.IsNullOrWhiteSpace(anchor)) return Response.Error("anchor_replace requires 'anchor' (regex)."); + try + { + var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); + var m = rx.Match(working); + if (!m.Success) return Response.Error($"anchor_replace: anchor not found: {anchor}"); + int at = m.Index; + int len = m.Length; + string norm = NormalizeNewlines(replacement); + if (applySequentially) + { + working = working.Remove(at, len).Insert(at, norm); + appliedCount++; + } + else + { + replacements.Add((at, len, norm)); + } + } + catch (Exception ex) + { + return Response.Error($"anchor_replace failed: {ex.Message}"); + } + break; + } + + default: + return Response.Error($"Unknown edit mode: '{mode}'. Allowed: replace_class, delete_class, replace_method, delete_method, insert_method, anchor_insert, anchor_delete, anchor_replace."); + } + } + + if (!applySequentially) + { + if (HasOverlaps(replacements)) + { + var ordered = replacements.OrderByDescending(r => r.start).ToList(); + for (int i = 1; i < ordered.Count; i++) + { + if (ordered[i].start + ordered[i].length > ordered[i - 1].start) + { + var conflict = new[] { new { startA = ordered[i].start, endA = ordered[i].start + ordered[i].length, startB = ordered[i - 1].start, endB = ordered[i - 1].start + ordered[i - 1].length } }; + return Response.Error("overlap", new { status = "overlap", conflicts = conflict, hint = "Sort ranges descending by start and compute from the same snapshot." }); + } + } + return Response.Error("overlap", new { status = "overlap" }); + } + + foreach (var r in replacements.OrderByDescending(r => r.start)) + working = working.Remove(r.start, r.length).Insert(r.start, r.text); + appliedCount = replacements.Count; + } + + // Guard against structural imbalance before validation + if (!CheckBalancedDelimiters(working, out int lineBal, out char expectedBal)) + return Response.Error("unbalanced_braces", new { status = "unbalanced_braces", line = lineBal, expected = expectedBal.ToString() }); + + // No-op guard for structured edits: if text unchanged, return explicit no-op + if (string.Equals(working, original, StringComparison.Ordinal)) + { + var sameSha = ComputeSha256(original); + return Response.Success( + $"No-op: contents unchanged for '{relativePath}'.", + new + { + path = relativePath, + uri = $"unity://path/{relativePath}", + editsApplied = 0, + no_op = true, + sha256 = sameSha, + evidence = new { reason = "identical_content" } + } + ); + } + + // Validate result using override from options if provided; otherwise GUI strictness + var level = GetValidationLevelFromGUI(); + try + { + var validateOpt = options?["validate"]?.ToString()?.ToLowerInvariant(); + if (!string.IsNullOrEmpty(validateOpt)) + { + level = validateOpt switch + { + "basic" => ValidationLevel.Basic, + "standard" => ValidationLevel.Standard, + "comprehensive" => ValidationLevel.Comprehensive, + "strict" => ValidationLevel.Strict, + _ => level + }; + } + } + catch { /* ignore option parsing issues */ } + if (!ValidateScriptSyntax(working, level, out var errors)) + return Response.Error("validation_failed", new { status = "validation_failed", diagnostics = errors ?? Array.Empty() }); + else if (errors != null && errors.Length > 0) + Debug.LogWarning($"Script validation warnings for {name}:\n" + string.Join("\n", errors)); + + // Atomic write with backup; schedule refresh + // Decide refresh behavior + string refreshMode = options?["refresh"]?.ToString()?.ToLowerInvariant(); + bool immediate = refreshMode == "immediate" || refreshMode == "sync"; + + // Persist changes atomically (no BOM), then compute/return new file SHA + var enc = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + var tmp = fullPath + ".tmp"; + File.WriteAllText(tmp, working, enc); + var backup = fullPath + ".bak"; + try + { + File.Replace(tmp, fullPath, backup); + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + catch (PlatformNotSupportedException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + catch (IOException) + { + File.Copy(tmp, fullPath, true); + try { File.Delete(tmp); } catch { } + try { if (File.Exists(backup)) File.Delete(backup); } catch { } + } + + var newSha = ComputeSha256(working); + var ok = Response.Success( + $"Applied {appliedCount} structured edit(s) to '{relativePath}'.", + new + { + path = relativePath, + uri = $"unity://path/{relativePath}", + editsApplied = appliedCount, + scheduledRefresh = !immediate, + sha256 = newSha + } + ); + + if (immediate) + { + TcpLog.Info($"[ManageScript] EditScript: immediate refresh for '{relativePath}'", always: false); + ManageScriptRefreshHelpers.ImportAndRequestCompile(relativePath); + } + else + { + ManageScriptRefreshHelpers.ScheduleScriptRefresh(relativePath); + } + return ok; + } + catch (Exception ex) + { + return Response.Error($"Edit failed: {ex.Message}"); + } + } + + private static bool HasOverlaps(IEnumerable<(int start, int length, string text)> list) + { + var arr = list.OrderBy(x => x.start).ToArray(); + for (int i = 1; i < arr.Length; i++) + { + if (arr[i - 1].start + arr[i - 1].length > arr[i].start) + return true; + } + return false; + } + + private static string ExtractReplacement(JObject op) + { + var inline = op.Value("replacement"); + if (!string.IsNullOrEmpty(inline)) return inline; + + var b64 = op.Value("replacementBase64"); + if (!string.IsNullOrEmpty(b64)) + { + try { return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(b64)); } + catch { return null; } + } + return null; + } + + private static string NormalizeNewlines(string t) + { + if (string.IsNullOrEmpty(t)) return t; + return t.Replace("\r\n", "\n").Replace("\r", "\n"); + } + + private static bool ValidateClassSnippet(string snippet, string expectedName, out string err) + { +#if USE_ROSLYN + try + { + var tree = CSharpSyntaxTree.ParseText(snippet); + var root = tree.GetRoot(); + var classes = root.DescendantNodes().OfType().ToList(); + if (classes.Count != 1) { err = "snippet must contain exactly one class declaration"; return false; } + // Optional: enforce expected name + // if (classes[0].Identifier.ValueText != expectedName) { err = $"snippet declares '{classes[0].Identifier.ValueText}', expected '{expectedName}'"; return false; } + err = null; return true; + } + catch (Exception ex) { err = ex.Message; return false; } +#else + if (string.IsNullOrWhiteSpace(snippet) || !snippet.Contains("class ")) { err = "no 'class' keyword found in snippet"; return false; } + err = null; return true; +#endif + } + + private static bool TryComputeClassSpan(string source, string className, string ns, out int start, out int length, out string why) + { +#if USE_ROSLYN + try + { + var tree = CSharpSyntaxTree.ParseText(source); + var root = tree.GetRoot(); + var classes = root.DescendantNodes() + .OfType() + .Where(c => c.Identifier.ValueText == className); + + if (!string.IsNullOrEmpty(ns)) + { + classes = classes.Where(c => + (c.FirstAncestorOrSelf()?.Name?.ToString() ?? "") == ns + || (c.FirstAncestorOrSelf()?.Name?.ToString() ?? "") == ns); + } + + var list = classes.ToList(); + if (list.Count == 0) { start = length = 0; why = $"class '{className}' not found" + (ns != null ? $" in namespace '{ns}'" : ""); return false; } + if (list.Count > 1) { start = length = 0; why = $"class '{className}' matched {list.Count} declarations (partial/nested?). Disambiguate."; return false; } + + var cls = list[0]; + var span = cls.FullSpan; // includes attributes & leading trivia + start = span.Start; length = span.Length; why = null; return true; + } + catch + { + // fall back below + } +#endif + return TryComputeClassSpanBalanced(source, className, ns, out start, out length, out why); + } + + private static bool TryComputeClassSpanBalanced(string source, string className, string ns, out int start, out int length, out string why) + { + start = length = 0; why = null; + var idx = IndexOfClassToken(source, className); + if (idx < 0) { why = $"class '{className}' not found (balanced scan)"; return false; } + + if (!string.IsNullOrEmpty(ns) && !AppearsWithinNamespaceHeader(source, idx, ns)) + { why = $"class '{className}' not under namespace '{ns}' (balanced scan)"; return false; } + + // Include modifiers/attributes on the same line: back up to the start of line + int lineStart = idx; + while (lineStart > 0 && source[lineStart - 1] != '\n' && source[lineStart - 1] != '\r') lineStart--; + + int i = idx; + while (i < source.Length && source[i] != '{') i++; + if (i >= source.Length) { why = "no opening brace after class header"; return false; } + + int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + int startSpan = lineStart; + for (; i < source.Length; i++) + { + char c = source[i]; + char n = i + 1 < source.Length ? source[i + 1] : '\0'; + + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') { depth++; } + else if (c == '}') + { + depth--; + if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; } + if (depth < 0) { why = "brace underflow"; return false; } + } + } + why = "unterminated class block"; return false; + } + + private static bool TryComputeMethodSpan( + string source, + int classStart, + int classLength, + string methodName, + string returnType, + string parametersSignature, + string attributesContains, + out int start, + out int length, + out string why) + { + start = length = 0; why = null; + int searchStart = classStart; + int searchEnd = Math.Min(source.Length, classStart + classLength); + + // 1) Find the method header using a stricter regex (allows optional attributes above) + string rtPattern = string.IsNullOrEmpty(returnType) ? @"[^\s]+" : Regex.Escape(returnType).Replace("\\ ", "\\s+"); + string namePattern = Regex.Escape(methodName); + // If a parametersSignature is provided, it may include surrounding parentheses. Strip them so + // we can safely embed the signature inside our own parenthesis group without duplicating. + string paramsPattern; + if (string.IsNullOrEmpty(parametersSignature)) + { + paramsPattern = @"[\s\S]*?"; // permissive when not specified + } + else + { + string ps = parametersSignature.Trim(); + if (ps.StartsWith("(") && ps.EndsWith(")") && ps.Length >= 2) + { + ps = ps.Substring(1, ps.Length - 2); + } + // Escape literal text of the signature + paramsPattern = Regex.Escape(ps); + } + string pattern = + @"(?m)^[\t ]*(?:\[[^\]]+\][\t ]*)*[\t ]*" + + @"(?:(?:public|private|protected|internal|static|virtual|override|sealed|async|extern|unsafe|new|partial|readonly|volatile|event|abstract|ref|in|out)\s+)*" + + rtPattern + @"[\t ]+" + namePattern + @"\s*(?:<[^>]+>)?\s*\(" + paramsPattern + @"\)"; + + string slice = source.Substring(searchStart, searchEnd - searchStart); + var headerMatch = Regex.Match(slice, pattern, RegexOptions.Multiline, TimeSpan.FromSeconds(2)); + if (!headerMatch.Success) + { + why = $"method '{methodName}' header not found in class"; return false; + } + int headerIndex = searchStart + headerMatch.Index; + + // Optional attributes filter: look upward from headerIndex for contiguous attribute lines + if (!string.IsNullOrEmpty(attributesContains)) + { + int attrScanStart = headerIndex; + while (attrScanStart > searchStart) + { + int prevNl = source.LastIndexOf('\n', attrScanStart - 1); + if (prevNl < 0 || prevNl < searchStart) break; + string prevLine = source.Substring(prevNl + 1, attrScanStart - (prevNl + 1)); + if (prevLine.TrimStart().StartsWith("[")) { attrScanStart = prevNl; continue; } + break; + } + string attrBlock = source.Substring(attrScanStart, headerIndex - attrScanStart); + if (attrBlock.IndexOf(attributesContains, StringComparison.Ordinal) < 0) + { + why = $"method '{methodName}' found but attributes filter did not match"; return false; + } + } + + // backtrack to the very start of header/attributes to include in span + int lineStart = headerIndex; + while (lineStart > searchStart && source[lineStart - 1] != '\n' && source[lineStart - 1] != '\r') lineStart--; + // If previous lines are attributes, include them + int attrStart = lineStart; + int probe = lineStart - 1; + while (probe > searchStart) + { + int prevNl = source.LastIndexOf('\n', probe); + if (prevNl < 0 || prevNl < searchStart) break; + string prev = source.Substring(prevNl + 1, attrStart - (prevNl + 1)); + if (prev.TrimStart().StartsWith("[")) { attrStart = prevNl + 1; probe = prevNl - 1; } + else break; + } + + // 2) Walk from the end of signature to detect body style ('{' or '=> ...;') and compute end + // Find the '(' that belongs to the method signature, not attributes + int nameTokenIdx = IndexOfTokenWithin(source, methodName, headerIndex, searchEnd); + if (nameTokenIdx < 0) { why = $"method '{methodName}' token not found after header"; return false; } + int sigOpenParen = IndexOfTokenWithin(source, "(", nameTokenIdx, searchEnd); + if (sigOpenParen < 0) { why = "method parameter list '(' not found"; return false; } + + int i = sigOpenParen; + int parenDepth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '(') parenDepth++; + if (c == ')') { parenDepth--; if (parenDepth == 0) { i++; break; } } + } + + // After params: detect expression-bodied or block-bodied + // Skip whitespace/comments + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (char.IsWhiteSpace(c)) continue; + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + break; + } + + // Tolerate generic constraints between params and body: multiple 'where T : ...' + for (;;) + { + // Skip whitespace/comments before checking for 'where' + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (char.IsWhiteSpace(c)) continue; + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + break; + } + + // Check word-boundary 'where' + bool hasWhere = false; + if (i + 5 <= searchEnd) + { + hasWhere = source[i] == 'w' && source[i + 1] == 'h' && source[i + 2] == 'e' && source[i + 3] == 'r' && source[i + 4] == 'e'; + if (hasWhere) + { + // Left boundary + if (i - 1 >= 0) + { + char lb = source[i - 1]; + if (char.IsLetterOrDigit(lb) || lb == '_') hasWhere = false; + } + // Right boundary + if (hasWhere && i + 5 < searchEnd) + { + char rb = source[i + 5]; + if (char.IsLetterOrDigit(rb) || rb == '_') hasWhere = false; + } + } + } + if (!hasWhere) break; + + // Advance past the entire where-constraint clause until we hit '{' or '=>' or ';' + i += 5; // past 'where' + while (i < searchEnd) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (c == '{' || c == ';' || (c == '=' && n == '>')) break; + // Skip comments inline + if (c == '/' && n == '/') { while (i < searchEnd && source[i] != '\n') i++; continue; } + if (c == '/' && n == '*') { i += 2; while (i + 1 < searchEnd && !(source[i] == '*' && source[i + 1] == '/')) i++; i++; continue; } + i++; + } + } + + // Re-check for expression-bodied after constraints + if (i < searchEnd - 1 && source[i] == '=' && source[i + 1] == '>') + { + // expression-bodied method: seek to terminating semicolon + int j = i; + bool done = false; + while (j < searchEnd) + { + char c = source[j]; + if (c == ';') { done = true; break; } + j++; + } + if (!done) { why = "unterminated expression-bodied method"; return false; } + start = attrStart; length = (j - attrStart) + 1; return true; + } + + if (i >= searchEnd || source[i] != '{') { why = "no opening brace after method signature"; return false; } + + int depth = 0; inStr = false; inChar = false; inSL = false; inML = false; esc = false; + int startSpan = attrStart; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') depth++; + else if (c == '}') + { + depth--; + if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; } + if (depth < 0) { why = "brace underflow in method"; return false; } + } + } + why = "unterminated method block"; return false; + } + + private static int IndexOfTokenWithin(string s, string token, int start, int end) + { + int idx = s.IndexOf(token, start, StringComparison.Ordinal); + return (idx >= 0 && idx < end) ? idx : -1; + } + + private static bool TryFindClassInsertionPoint(string source, int classStart, int classLength, string position, out int insertAt, out string why) + { + insertAt = 0; why = null; + int searchStart = classStart; + int searchEnd = Math.Min(source.Length, classStart + classLength); + + if (position == "start") + { + // find first '{' after class header, insert just after with a newline + int i = IndexOfTokenWithin(source, "{", searchStart, searchEnd); + if (i < 0) { why = "could not find class opening brace"; return false; } + insertAt = i + 1; return true; + } + else // end + { + // walk to matching closing brace of class and insert just before it + int i = IndexOfTokenWithin(source, "{", searchStart, searchEnd); + if (i < 0) { why = "could not find class opening brace"; return false; } + int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false; + for (; i < searchEnd; i++) + { + char c = source[i]; + char n = i + 1 < searchEnd ? source[i + 1] : '\0'; + if (inSL) { if (c == '\n') inSL = false; continue; } + if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; } + if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; } + if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; } + + if (c == '/' && n == '/') { inSL = true; i++; continue; } + if (c == '/' && n == '*') { inML = true; i++; continue; } + if (c == '"') { inStr = true; continue; } + if (c == '\'') { inChar = true; continue; } + + if (c == '{') depth++; + else if (c == '}') + { + depth--; + if (depth == 0) { insertAt = i; return true; } + if (depth < 0) { why = "brace underflow while scanning class"; return false; } + } + } + why = "could not find class closing brace"; return false; + } + } + + private static int IndexOfClassToken(string s, string className) + { + // simple token search; could be tightened with Regex for word boundaries + var pattern = "class " + className; + return s.IndexOf(pattern, StringComparison.Ordinal); + } + + private static bool AppearsWithinNamespaceHeader(string s, int pos, string ns) + { + int from = Math.Max(0, pos - 2000); + var slice = s.Substring(from, pos - from); + return slice.Contains("namespace " + ns); + } + + /// + /// Generates basic C# script content based on name and type. + /// + private static string GenerateDefaultScriptContent( + string name, + string scriptType, + string namespaceName + ) + { + string usingStatements = "using UnityEngine;\nusing System.Collections;\n"; + string classDeclaration; + string body = + "\n // Use this for initialization\n void Start() {\n\n }\n\n // Update is called once per frame\n void Update() {\n\n }\n"; + + string baseClass = ""; + if (!string.IsNullOrEmpty(scriptType)) + { + if (scriptType.Equals("MonoBehaviour", StringComparison.OrdinalIgnoreCase)) + baseClass = " : MonoBehaviour"; + else if (scriptType.Equals("ScriptableObject", StringComparison.OrdinalIgnoreCase)) + { + baseClass = " : ScriptableObject"; + body = ""; // ScriptableObjects don't usually need Start/Update + } + else if ( + scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase) + || scriptType.Equals("EditorWindow", StringComparison.OrdinalIgnoreCase) + ) + { + usingStatements += "using UnityEditor;\n"; + if (scriptType.Equals("Editor", StringComparison.OrdinalIgnoreCase)) + baseClass = " : Editor"; + else + baseClass = " : EditorWindow"; + body = ""; // Editor scripts have different structures + } + // Add more types as needed + } + + classDeclaration = $"public class {name}{baseClass}"; + + string fullContent = $"{usingStatements}\n"; + bool useNamespace = !string.IsNullOrEmpty(namespaceName); + + if (useNamespace) + { + fullContent += $"namespace {namespaceName}\n{{\n"; + // Indent class and body if using namespace + classDeclaration = " " + classDeclaration; + body = string.Join("\n", body.Split('\n').Select(line => " " + line)); + } + + fullContent += $"{classDeclaration}\n{{\n{body}\n}}"; + + if (useNamespace) + { + fullContent += "\n}"; // Close namespace + } + + return fullContent.Trim() + "\n"; // Ensure a trailing newline + } + + /// + /// Gets the validation level from the GUI settings + /// + private static ValidationLevel GetValidationLevelFromGUI() + { + string savedLevel = EditorPrefs.GetString("UnityTcp_ScriptValidationLevel", "standard"); + return savedLevel.ToLower() switch + { + "basic" => ValidationLevel.Basic, + "standard" => ValidationLevel.Standard, + "comprehensive" => ValidationLevel.Comprehensive, + "strict" => ValidationLevel.Strict, + _ => ValidationLevel.Standard // Default fallback + }; + } + + /// + /// Validates C# script syntax using multiple validation layers. + /// + private static bool ValidateScriptSyntax(string contents) + { + return ValidateScriptSyntax(contents, ValidationLevel.Standard, out _); + } + + /// + /// Advanced syntax validation with detailed diagnostics and configurable strictness. + /// + private static bool ValidateScriptSyntax(string contents, ValidationLevel level, out string[] errors) + { + var errorList = new System.Collections.Generic.List(); + errors = null; + + if (string.IsNullOrEmpty(contents)) + { + return true; // Empty content is valid + } + + // Basic structural validation + if (!ValidateBasicStructure(contents, errorList)) + { + errors = errorList.ToArray(); + return false; + } + +#if USE_ROSLYN + // Advanced Roslyn-based validation: only run for Standard+; fail on Roslyn errors + if (level >= ValidationLevel.Standard) + { + if (!ValidateScriptSyntaxRoslyn(contents, level, errorList)) + { + errors = errorList.ToArray(); + return false; + } + } +#endif + + // Unity-specific validation + if (level >= ValidationLevel.Standard) + { + ValidateScriptSyntaxUnity(contents, errorList); + } + + // Semantic analysis for common issues + if (level >= ValidationLevel.Comprehensive) + { + ValidateSemanticRules(contents, errorList); + } + +#if USE_ROSLYN + // Full semantic compilation validation for Strict level + if (level == ValidationLevel.Strict) + { + if (!ValidateScriptSemantics(contents, errorList)) + { + errors = errorList.ToArray(); + return false; // Strict level fails on any semantic errors + } + } +#endif + + errors = errorList.ToArray(); + return errorList.Count == 0 || (level != ValidationLevel.Strict && !errorList.Any(e => e.StartsWith("ERROR:"))); + } + + /// + /// Validation strictness levels + /// + private enum ValidationLevel + { + Basic, // Only syntax errors + Standard, // Syntax + Unity best practices + Comprehensive, // All checks + semantic analysis + Strict // Treat all issues as errors + } + + /// + /// Validates basic code structure (braces, quotes, comments) + /// + private static bool ValidateBasicStructure(string contents, System.Collections.Generic.List errors) + { + bool isValid = true; + int braceBalance = 0; + int parenBalance = 0; + int bracketBalance = 0; + bool inStringLiteral = false; + bool inCharLiteral = false; + bool inSingleLineComment = false; + bool inMultiLineComment = false; + bool escaped = false; + + for (int i = 0; i < contents.Length; i++) + { + char c = contents[i]; + char next = i + 1 < contents.Length ? contents[i + 1] : '\0'; + + // Handle escape sequences + if (escaped) + { + escaped = false; + continue; + } + + if (c == '\\' && (inStringLiteral || inCharLiteral)) + { + escaped = true; + continue; + } + + // Handle comments + if (!inStringLiteral && !inCharLiteral) + { + if (c == '/' && next == '/' && !inMultiLineComment) + { + inSingleLineComment = true; + continue; + } + if (c == '/' && next == '*' && !inSingleLineComment) + { + inMultiLineComment = true; + i++; // Skip next character + continue; + } + if (c == '*' && next == '/' && inMultiLineComment) + { + inMultiLineComment = false; + i++; // Skip next character + continue; + } + } + + if (c == '\n') + { + inSingleLineComment = false; + continue; + } + + if (inSingleLineComment || inMultiLineComment) + continue; + + // Handle string and character literals + if (c == '"' && !inCharLiteral) + { + inStringLiteral = !inStringLiteral; + continue; + } + if (c == '\'' && !inStringLiteral) + { + inCharLiteral = !inCharLiteral; + continue; + } + + if (inStringLiteral || inCharLiteral) + continue; + + // Count brackets and braces + switch (c) + { + case '{': braceBalance++; break; + case '}': braceBalance--; break; + case '(': parenBalance++; break; + case ')': parenBalance--; break; + case '[': bracketBalance++; break; + case ']': bracketBalance--; break; + } + + // Check for negative balances (closing without opening) + if (braceBalance < 0) + { + errors.Add("ERROR: Unmatched closing brace '}'"); + isValid = false; + } + if (parenBalance < 0) + { + errors.Add("ERROR: Unmatched closing parenthesis ')'"); + isValid = false; + } + if (bracketBalance < 0) + { + errors.Add("ERROR: Unmatched closing bracket ']'"); + isValid = false; + } + } + + // Check final balances + if (braceBalance != 0) + { + errors.Add($"ERROR: Unbalanced braces (difference: {braceBalance})"); + isValid = false; + } + if (parenBalance != 0) + { + errors.Add($"ERROR: Unbalanced parentheses (difference: {parenBalance})"); + isValid = false; + } + if (bracketBalance != 0) + { + errors.Add($"ERROR: Unbalanced brackets (difference: {bracketBalance})"); + isValid = false; + } + if (inStringLiteral) + { + errors.Add("ERROR: Unterminated string literal"); + isValid = false; + } + if (inCharLiteral) + { + errors.Add("ERROR: Unterminated character literal"); + isValid = false; + } + if (inMultiLineComment) + { + errors.Add("WARNING: Unterminated multi-line comment"); + } + + return isValid; + } + +#if USE_ROSLYN + /// + /// Cached compilation references for performance + /// + private static System.Collections.Generic.List _cachedReferences = null; + private static DateTime _cacheTime = DateTime.MinValue; + private static readonly TimeSpan CacheExpiry = TimeSpan.FromMinutes(5); + + /// + /// Validates syntax using Roslyn compiler services + /// + private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel level, System.Collections.Generic.List errors) + { + try + { + var syntaxTree = CSharpSyntaxTree.ParseText(contents); + var diagnostics = syntaxTree.GetDiagnostics(); + + bool hasErrors = false; + foreach (var diagnostic in diagnostics) + { + string severity = diagnostic.Severity.ToString().ToUpper(); + string message = $"{severity}: {diagnostic.GetMessage()}"; + + if (diagnostic.Severity == DiagnosticSeverity.Error) + { + hasErrors = true; + } + + // Include warnings in comprehensive mode + if (level >= ValidationLevel.Standard || diagnostic.Severity == DiagnosticSeverity.Error) //Also use Standard for now + { + var location = diagnostic.Location.GetLineSpan(); + if (location.IsValid) + { + message += $" (Line {location.StartLinePosition.Line + 1})"; + } + errors.Add(message); + } + } + + return !hasErrors; + } + catch (Exception ex) + { + errors.Add($"ERROR: Roslyn validation failed: {ex.Message}"); + return false; + } + } + + /// + /// Validates script semantics using full compilation context to catch namespace, type, and method resolution errors + /// + private static bool ValidateScriptSemantics(string contents, System.Collections.Generic.List errors) + { + try + { + // Get compilation references with caching + var references = GetCompilationReferences(); + if (references == null || references.Count == 0) + { + errors.Add("WARNING: Could not load compilation references for semantic validation"); + return true; // Don't fail if we can't get references + } + + // Create syntax tree + var syntaxTree = CSharpSyntaxTree.ParseText(contents); + + // Create compilation with full context + var compilation = CSharpCompilation.Create( + "TempValidation", + new[] { syntaxTree }, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + // Get semantic diagnostics - this catches all the issues you mentioned! + var diagnostics = compilation.GetDiagnostics(); + + bool hasErrors = false; + foreach (var diagnostic in diagnostics) + { + if (diagnostic.Severity == DiagnosticSeverity.Error) + { + hasErrors = true; + var location = diagnostic.Location.GetLineSpan(); + string locationInfo = location.IsValid ? + $" (Line {location.StartLinePosition.Line + 1}, Column {location.StartLinePosition.Character + 1})" : ""; + + // Include diagnostic ID for better error identification + string diagnosticId = !string.IsNullOrEmpty(diagnostic.Id) ? $" [{diagnostic.Id}]" : ""; + errors.Add($"ERROR: {diagnostic.GetMessage()}{diagnosticId}{locationInfo}"); + } + else if (diagnostic.Severity == DiagnosticSeverity.Warning) + { + var location = diagnostic.Location.GetLineSpan(); + string locationInfo = location.IsValid ? + $" (Line {location.StartLinePosition.Line + 1}, Column {location.StartLinePosition.Character + 1})" : ""; + + string diagnosticId = !string.IsNullOrEmpty(diagnostic.Id) ? $" [{diagnostic.Id}]" : ""; + errors.Add($"WARNING: {diagnostic.GetMessage()}{diagnosticId}{locationInfo}"); + } + } + + return !hasErrors; + } + catch (Exception ex) + { + errors.Add($"ERROR: Semantic validation failed: {ex.Message}"); + return false; + } + } + + /// + /// Gets compilation references with caching for performance + /// + private static System.Collections.Generic.List GetCompilationReferences() + { + // Check cache validity + if (_cachedReferences != null && DateTime.Now - _cacheTime < CacheExpiry) + { + return _cachedReferences; + } + + try + { + var references = new System.Collections.Generic.List(); + + // Core .NET assemblies + references.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); // mscorlib/System.Private.CoreLib + references.Add(MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location)); // System.Linq + references.Add(MetadataReference.CreateFromFile(typeof(System.Collections.Generic.List<>).Assembly.Location)); // System.Collections + + // Unity assemblies + try + { + references.Add(MetadataReference.CreateFromFile(typeof(UnityEngine.Debug).Assembly.Location)); // UnityEngine + } + catch (Exception ex) + { + Debug.LogWarning($"Could not load UnityEngine assembly: {ex.Message}"); + } + +#if UNITY_EDITOR + try + { + references.Add(MetadataReference.CreateFromFile(typeof(UnityEditor.Editor).Assembly.Location)); // UnityEditor + } + catch (Exception ex) + { + Debug.LogWarning($"Could not load UnityEditor assembly: {ex.Message}"); + } + + // Get Unity project assemblies + try + { + var assemblies = CompilationPipeline.GetAssemblies(); + foreach (var assembly in assemblies) + { + if (File.Exists(assembly.outputPath)) + { + references.Add(MetadataReference.CreateFromFile(assembly.outputPath)); + } + } + } + catch (Exception ex) + { + Debug.LogWarning($"Could not load Unity project assemblies: {ex.Message}"); + } +#endif + + // Cache the results + _cachedReferences = references; + _cacheTime = DateTime.Now; + + return references; + } + catch (Exception ex) + { + Debug.LogError($"Failed to get compilation references: {ex.Message}"); + return new System.Collections.Generic.List(); + } + } +#else + private static bool ValidateScriptSyntaxRoslyn(string contents, ValidationLevel level, System.Collections.Generic.List errors) + { + // Fallback when Roslyn is not available + return true; + } +#endif + + /// + /// Validates Unity-specific coding rules and best practices + /// //TODO: Naive Unity Checks and not really yield any results, need to be improved + /// + private static void ValidateScriptSyntaxUnity(string contents, System.Collections.Generic.List errors) + { + // Check for common Unity anti-patterns + if (contents.Contains("FindObjectOfType") && contents.Contains("Update()")) + { + errors.Add("WARNING: FindObjectOfType in Update() can cause performance issues"); + } + + if (contents.Contains("GameObject.Find") && contents.Contains("Update()")) + { + errors.Add("WARNING: GameObject.Find in Update() can cause performance issues"); + } + + // Check for proper MonoBehaviour usage + if (contents.Contains(": MonoBehaviour") && !contents.Contains("using UnityEngine")) + { + errors.Add("WARNING: MonoBehaviour requires 'using UnityEngine;'"); + } + + // Check for SerializeField usage + if (contents.Contains("[SerializeField]") && !contents.Contains("using UnityEngine")) + { + errors.Add("WARNING: SerializeField requires 'using UnityEngine;'"); + } + + // Check for proper coroutine usage + if (contents.Contains("StartCoroutine") && !contents.Contains("IEnumerator")) + { + errors.Add("WARNING: StartCoroutine typically requires IEnumerator methods"); + } + + // Check for Update without FixedUpdate for physics + if (contents.Contains("Rigidbody") && contents.Contains("Update()") && !contents.Contains("FixedUpdate()")) + { + errors.Add("WARNING: Consider using FixedUpdate() for Rigidbody operations"); + } + + // Check for missing null checks on Unity objects + if (contents.Contains("GetComponent<") && !contents.Contains("!= null")) + { + errors.Add("WARNING: Consider null checking GetComponent results"); + } + + // Check for proper event function signatures + if (contents.Contains("void Start(") && !contents.Contains("void Start()")) + { + errors.Add("WARNING: Start() should not have parameters"); + } + + if (contents.Contains("void Update(") && !contents.Contains("void Update()")) + { + errors.Add("WARNING: Update() should not have parameters"); + } + + // Check for inefficient string operations + if (contents.Contains("Update()") && contents.Contains("\"") && contents.Contains("+")) + { + errors.Add("WARNING: String concatenation in Update() can cause garbage collection issues"); + } + } + + /// + /// Validates semantic rules and common coding issues + /// + private static void ValidateSemanticRules(string contents, System.Collections.Generic.List errors) + { + // Check for potential memory leaks + if (contents.Contains("new ") && contents.Contains("Update()")) + { + errors.Add("WARNING: Creating objects in Update() may cause memory issues"); + } + + // Check for magic numbers + var magicNumberPattern = new Regex(@"\b\d+\.?\d*f?\b(?!\s*[;})\]])", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2)); + var matches = magicNumberPattern.Matches(contents); + if (matches.Count > 5) + { + errors.Add("WARNING: Consider using named constants instead of magic numbers"); + } + + // Check for long methods (simple line count check) + var methodPattern = new Regex(@"(public|private|protected|internal)?\s*(static)?\s*\w+\s+\w+\s*\([^)]*\)\s*{", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(2)); + var methodMatches = methodPattern.Matches(contents); + foreach (Match match in methodMatches) + { + int startIndex = match.Index; + int braceCount = 0; + int lineCount = 0; + bool inMethod = false; + + for (int i = startIndex; i < contents.Length; i++) + { + if (contents[i] == '{') + { + braceCount++; + inMethod = true; + } + else if (contents[i] == '}') + { + braceCount--; + if (braceCount == 0 && inMethod) + break; + } + else if (contents[i] == '\n' && inMethod) + { + lineCount++; + } + } + + if (lineCount > 50) + { + errors.Add("WARNING: Method is very long, consider breaking it into smaller methods"); + break; // Only report once + } + } + + // Check for proper exception handling + if (contents.Contains("catch") && contents.Contains("catch()")) + { + errors.Add("WARNING: Empty catch blocks should be avoided"); + } + + // Check for proper async/await usage + if (contents.Contains("async ") && !contents.Contains("await")) + { + errors.Add("WARNING: Async method should contain await or return Task"); + } + + // Check for hardcoded tags and layers + if (contents.Contains("\"Player\"") || contents.Contains("\"Enemy\"")) + { + errors.Add("WARNING: Consider using constants for tags instead of hardcoded strings"); + } + } + + //TODO: A easier way for users to update incorrect scripts (now duplicated with the updateScript method and need to also update server side, put aside for now) + /// + /// Public method to validate script syntax with configurable validation level + /// Returns detailed validation results including errors and warnings + /// + // public static object ValidateScript(JObject @params) + // { + // string contents = @params["contents"]?.ToString(); + // string validationLevel = @params["validationLevel"]?.ToString() ?? "standard"; + + // if (string.IsNullOrEmpty(contents)) + // { + // return Response.Error("Contents parameter is required for validation."); + // } + + // // Parse validation level + // ValidationLevel level = ValidationLevel.Standard; + // switch (validationLevel.ToLower()) + // { + // case "basic": level = ValidationLevel.Basic; break; + // case "standard": level = ValidationLevel.Standard; break; + // case "comprehensive": level = ValidationLevel.Comprehensive; break; + // case "strict": level = ValidationLevel.Strict; break; + // default: + // return Response.Error($"Invalid validation level: '{validationLevel}'. Valid levels are: basic, standard, comprehensive, strict."); + // } + + // // Perform validation + // bool isValid = ValidateScriptSyntax(contents, level, out string[] validationErrors); + + // var errors = validationErrors?.Where(e => e.StartsWith("ERROR:")).ToArray() ?? new string[0]; + // var warnings = validationErrors?.Where(e => e.StartsWith("WARNING:")).ToArray() ?? new string[0]; + + // var result = new + // { + // isValid = isValid, + // validationLevel = validationLevel, + // errorCount = errors.Length, + // warningCount = warnings.Length, + // errors = errors, + // warnings = warnings, + // summary = isValid + // ? (warnings.Length > 0 ? $"Validation passed with {warnings.Length} warnings" : "Validation passed with no issues") + // : $"Validation failed with {errors.Length} errors and {warnings.Length} warnings" + // }; + + // if (isValid) + // { + // return Response.Success("Script validation completed successfully.", result); + // } + // else + // { + // return Response.Error("Script validation failed.", result); + // } + // } + } +} + +// Debounced refresh/compile scheduler to coalesce bursts of edits +static class RefreshDebounce +{ + private static int _pending; + private static readonly object _lock = new object(); + private static readonly HashSet _paths = new HashSet(StringComparer.OrdinalIgnoreCase); + + // The timestamp of the most recent schedule request. + private static DateTime _lastRequest; + + // Guard to ensure we only have a single ticking callback running. + private static bool _scheduled; + + public static void Schedule(string relPath, TimeSpan window) + { + // Record that work is pending and track the path in a threadsafe way. + Interlocked.Exchange(ref _pending, 1); + lock (_lock) + { + _paths.Add(relPath); + _lastRequest = DateTime.UtcNow; + + // If a debounce timer is already scheduled it will pick up the new request. + if (_scheduled) + return; + + _scheduled = true; + } + + // Kick off a ticking callback that waits until the window has elapsed + // from the last request before performing the refresh. + EditorApplication.delayCall += () => Tick(window); + // Nudge the editor loop so ticks run even if the window is unfocused + EditorApplication.QueuePlayerLoopUpdate(); + } + + private static void Tick(TimeSpan window) + { + bool ready; + lock (_lock) + { + // Only proceed once the debounce window has fully elapsed. + ready = (DateTime.UtcNow - _lastRequest) >= window; + if (ready) + { + _scheduled = false; + } + } + + if (!ready) + { + // Window has not yet elapsed; check again on the next editor tick. + EditorApplication.delayCall += () => Tick(window); + return; + } + + if (Interlocked.Exchange(ref _pending, 0) == 1) + { + string[] toImport; + lock (_lock) { toImport = _paths.ToArray(); _paths.Clear(); } + foreach (var p in toImport) + { + var sp = ManageScriptRefreshHelpers.SanitizeAssetsPath(p); + AssetDatabase.ImportAsset(sp, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport); + } +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + // Fallback if needed: + // AssetDatabase.Refresh(); + } + } +} + +static class ManageScriptRefreshHelpers +{ + public static string SanitizeAssetsPath(string p) + { + if (string.IsNullOrEmpty(p)) return p; + p = p.Replace('\\', '/').Trim(); + if (p.StartsWith("unity://path/", StringComparison.OrdinalIgnoreCase)) + p = p.Substring("unity://path/".Length); + while (p.StartsWith("Assets/Assets/", StringComparison.OrdinalIgnoreCase)) + p = p.Substring("Assets/".Length); + if (!p.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + p = "Assets/" + p.TrimStart('/'); + return p; + } + + public static void ScheduleScriptRefresh(string relPath) + { + var sp = SanitizeAssetsPath(relPath); + RefreshDebounce.Schedule(sp, TimeSpan.FromMilliseconds(200)); + } + + public static void ImportAndRequestCompile(string relPath, bool synchronous = true) + { + var sp = SanitizeAssetsPath(relPath); + var opts = ImportAssetOptions.ForceUpdate; + if (synchronous) opts |= ImportAssetOptions.ForceSynchronousImport; + AssetDatabase.ImportAsset(sp, opts); +#if UNITY_EDITOR + UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation(); +#endif + } +} + diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScript.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScript.cs.meta new file mode 100644 index 000000000..680de90c2 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageScript.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 146e77f78721f9a4494cc01662cc082c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageShader.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageShader.cs new file mode 100644 index 000000000..dabedfc1c --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageShader.cs @@ -0,0 +1,545 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Codely.Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityTcp.Editor.Helpers; + +namespace UnityTcp.Editor.Tools +{ + /// + /// Handles CRUD operations for shader files within the Unity project. + /// + public static class ManageShader + { + /// + /// Main handler for shader management actions. + /// + public static object HandleCommand(JObject @params) + { + // Extract parameters + string action = @params["action"]?.ToString().ToLower(); + string name = @params["name"]?.ToString(); + string path = @params["path"]?.ToString(); // Relative to Assets/ + string contents = null; + + // --- Validate client_state_rev for write operations --- + var writeActions = new[] { "ensure_material_shader_for_srp", "create", "update", "delete" }; + if (writeActions.Contains(action)) + { + var revConflict = StateComposer.ValidateClientRevisionFromParams(@params); + if (revConflict != null) return revConflict; + } + + // Check if we have base64 encoded contents + bool contentsEncoded = @params["contentsEncoded"]?.ToObject() ?? false; + if (contentsEncoded && @params["encodedContents"] != null) + { + try + { + contents = DecodeBase64(@params["encodedContents"].ToString()); + } + catch (Exception e) + { + return Response.Error($"Failed to decode shader contents: {e.Message}"); + } + } + else + { + contents = @params["contents"]?.ToString(); + } + + // Validate required parameters + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + + // Skip name validation for SRP operations + if (action != "detect_render_pipeline" && action != "ensure_material_shader_for_srp") + { + if (string.IsNullOrEmpty(name)) + { + return Response.Error("Name parameter is required."); + } + // Basic name validation (alphanumeric, underscores, cannot start with number) + if (!Regex.IsMatch(name, @"^[a-zA-Z_][a-zA-Z0-9_]*$")) + { + return Response.Error( + $"Invalid shader name: '{name}'. Use only letters, numbers, underscores, and don't start with a number." + ); + } + } + + // Ensure path is relative to Assets/, removing any leading "Assets/" + // Set default directory to "Shaders" if path is not provided + string relativeDir = path ?? "Shaders"; // Default to "Shaders" if path is null + if (!string.IsNullOrEmpty(relativeDir)) + { + relativeDir = relativeDir.Replace('\\', '/').Trim('/'); + if (relativeDir.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + relativeDir = relativeDir.Substring("Assets/".Length).TrimStart('/'); + } + } + // Handle empty string case explicitly after processing + if (string.IsNullOrEmpty(relativeDir)) + { + relativeDir = "Shaders"; // Ensure default if path was provided as "" or only "/" or "Assets/" + } + + // Construct paths + string shaderFileName = $"{name}.shader"; + string fullPathDir = Path.Combine(Application.dataPath, relativeDir); + string fullPath = Path.Combine(fullPathDir, shaderFileName); + string relativePath = Path.Combine("Assets", relativeDir, shaderFileName) + .Replace('\\', '/'); // Ensure "Assets/" prefix and forward slashes + + // Ensure the target directory exists for create/update + if (action == "create" || action == "update") + { + try + { + if (!Directory.Exists(fullPathDir)) + { + Directory.CreateDirectory(fullPathDir); + // Refresh AssetDatabase to recognize new folders + AssetDatabase.Refresh(); + } + } + catch (Exception e) + { + return Response.Error( + $"Could not create directory '{fullPathDir}': {e.Message}" + ); + } + } + + // Route to specific action handlers + switch (action) + { + // SRP operations (don't require name validation) + case "detect_render_pipeline": + return DetectRenderPipeline(); + case "ensure_material_shader_for_srp": + return EnsureMaterialShaderForSRP(@params); + + // Regular shader file operations + case "create": + return CreateShader(fullPath, relativePath, name, contents); + case "read": + return ReadShader(fullPath, relativePath); + case "update": + return UpdateShader(fullPath, relativePath, name, contents); + case "delete": + return DeleteShader(fullPath, relativePath); + default: + return Response.Error( + $"Unknown action: '{action}'. Valid actions are: detect_render_pipeline, ensure_material_shader_for_srp, create, read, update, delete." + ); + } + } + + /// + /// Decode base64 string to normal text + /// + private static string DecodeBase64(string encoded) + { + byte[] data = Convert.FromBase64String(encoded); + return System.Text.Encoding.UTF8.GetString(data); + } + + /// + /// Encode text to base64 string + /// + private static string EncodeBase64(string text) + { + byte[] data = System.Text.Encoding.UTF8.GetBytes(text); + return Convert.ToBase64String(data); + } + + private static object CreateShader( + string fullPath, + string relativePath, + string name, + string contents + ) + { + // Check if shader already exists + if (File.Exists(fullPath)) + { + return Response.Error( + $"Shader already exists at '{relativePath}'. Use 'update' action to modify." + ); + } + + // Add validation for shader name conflicts in Unity + if (Shader.Find(name) != null) + { + return Response.Error( + $"A shader with name '{name}' already exists in the project. Choose a different name." + ); + } + + // Generate default content if none provided + if (string.IsNullOrEmpty(contents)) + { + contents = GenerateDefaultShaderContent(name); + } + + try + { + File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); + AssetDatabase.ImportAsset(relativePath); + AssetDatabase.Refresh(); // Ensure Unity recognizes the new shader + return Response.Success( + $"Shader '{name}.shader' created successfully at '{relativePath}'.", + new { path = relativePath } + ); + } + catch (Exception e) + { + return Response.Error($"Failed to create shader '{relativePath}': {e.Message}"); + } + } + + private static object ReadShader(string fullPath, string relativePath) + { + if (!File.Exists(fullPath)) + { + return Response.Error($"Shader not found at '{relativePath}'."); + } + + try + { + string contents = File.ReadAllText(fullPath); + + // Return both normal and encoded contents for larger files + //TODO: Consider a threshold for large files + bool isLarge = contents.Length > 10000; // If content is large, include encoded version + var responseData = new + { + path = relativePath, + contents = contents, + // For large files, also include base64-encoded version + encodedContents = isLarge ? EncodeBase64(contents) : null, + contentsEncoded = isLarge, + }; + + return Response.Success( + $"Shader '{Path.GetFileName(relativePath)}' read successfully.", + responseData + ); + } + catch (Exception e) + { + return Response.Error($"Failed to read shader '{relativePath}': {e.Message}"); + } + } + + private static object UpdateShader( + string fullPath, + string relativePath, + string name, + string contents + ) + { + if (!File.Exists(fullPath)) + { + return Response.Error( + $"Shader not found at '{relativePath}'. Use 'create' action to add a new shader." + ); + } + if (string.IsNullOrEmpty(contents)) + { + return Response.Error("Content is required for the 'update' action."); + } + + try + { + File.WriteAllText(fullPath, contents, new System.Text.UTF8Encoding(false)); + AssetDatabase.ImportAsset(relativePath); + AssetDatabase.Refresh(); + return Response.Success( + $"Shader '{Path.GetFileName(relativePath)}' updated successfully.", + new { path = relativePath } + ); + } + catch (Exception e) + { + return Response.Error($"Failed to update shader '{relativePath}': {e.Message}"); + } + } + + private static object DeleteShader(string fullPath, string relativePath) + { + if (!File.Exists(fullPath)) + { + return Response.Error($"Shader not found at '{relativePath}'."); + } + + try + { + // Delete the asset through Unity's AssetDatabase first + bool success = AssetDatabase.DeleteAsset(relativePath); + if (!success) + { + return Response.Error($"Failed to delete shader through Unity's AssetDatabase: '{relativePath}'"); + } + + // If the file still exists (rare case), try direct deletion + if (File.Exists(fullPath)) + { + File.Delete(fullPath); + } + + return Response.Success($"Shader '{Path.GetFileName(relativePath)}' deleted successfully."); + } + catch (Exception e) + { + return Response.Error($"Failed to delete shader '{relativePath}': {e.Message}"); + } + } + + //This is a CGProgram template + //TODO: making a HLSL template as well? + private static string GenerateDefaultShaderContent(string name) + { + return @"Shader """ + name + @""" + { + Properties + { + _MainTex (""Texture"", 2D) = ""white"" {} + } + SubShader + { + Tags { ""RenderType""=""Opaque"" } + LOD 100 + + Pass + { + CGPROGRAM + #pragma vertex vert + #pragma fragment frag + #include ""UnityCG.cginc"" + + struct appdata + { + float4 vertex : POSITION; + float2 uv : TEXCOORD0; + }; + + struct v2f + { + float2 uv : TEXCOORD0; + float4 vertex : SV_POSITION; + }; + + sampler2D _MainTex; + float4 _MainTex_ST; + + v2f vert (appdata v) + { + v2f o; + o.vertex = UnityObjectToClipPos(v.vertex); + o.uv = TRANSFORM_TEX(v.uv, _MainTex); + return o; + } + + fixed4 frag (v2f i) : SV_Target + { + fixed4 col = tex2D(_MainTex, i.uv); + return col; + } + ENDCG + } + } + }"; + } + // --- SRP/Shader Safety Methods --- + + /// + /// Detects the current render pipeline in use. + /// + private static object DetectRenderPipeline() + { + try + { + string srp = "builtin"; + var currentRP = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline; + + if (currentRP != null) + { + string rpName = currentRP.GetType().Name.ToLowerInvariant(); + string rpFullName = currentRP.GetType().FullName.ToLowerInvariant(); + + if (rpName.Contains("urp") || rpName.Contains("universal") || + rpFullName.Contains("universal")) + { + srp = "urp"; + } + else if (rpName.Contains("hdrp") || rpName.Contains("highdefinition") || + rpFullName.Contains("highdefinition")) + { + srp = "hdrp"; + } + } + + return new + { + success = true, + message = $"Current render pipeline: {srp}", + data = new + { + srp = srp, + rpAssetName = currentRP?.name, + rpTypeName = currentRP?.GetType().FullName + }, + state_delta = StateComposer.CreateEditorDelta(isUpdating: false) + }; + } + catch (Exception e) + { + return Response.Error($"Failed to detect render pipeline: {e.Message}"); + } + } + + /// + /// Ensures a material uses the appropriate shader for the current SRP. Idempotent. + /// Supports both "material" (legacy) and "material_path"/"material_guid" + /// as described in the Unity-Tools-Spec. + /// + private static object EnsureMaterialShaderForSRP(JObject @params) + { + try + { + var writeCheck = WriteGuard.CheckWriteAllowed("ensure_material_shader_for_srp"); + if (writeCheck != null) return writeCheck; + + // Accept multiple parameter shapes. + // Primary spec uses "material_path" / "material_guid", + // but we still accept legacy "material" for backwards compatibility. + string materialPath = @params["material_path"]?.ToString(); + + // Legacy fallback: allow "material" if material_path is not provided + if (string.IsNullOrEmpty(materialPath)) + { + materialPath = @params["material"]?.ToString(); + } + + // Resolve from GUID if path not provided + if (string.IsNullOrEmpty(materialPath)) + { + var guid = @params["material_guid"]?.ToString(); + if (!string.IsNullOrEmpty(guid)) + { + materialPath = AssetDatabase.GUIDToAssetPath(guid); + } + } + + if (string.IsNullOrEmpty(materialPath)) + return Response.Error("Either material_path or material_guid is required for ensure_material_shader_for_srp action"); + + // Validate path format (mirror TS-side validation) + if (!materialPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + return Response.Error("material_path must start with \"Assets/\""); + + JObject shaderMapping = @params["shader_for_srp"] as JObject; + if (shaderMapping == null) + return Response.Error("shader_for_srp is required for ensure_material_shader_for_srp action"); + + if (!shaderMapping.ContainsKey("builtin") || + string.IsNullOrWhiteSpace(shaderMapping["builtin"]?.ToString())) + { + return Response.Error("shader_for_srp.builtin is required as fallback shader"); + } + + // Load material + Material material = AssetDatabase.LoadAssetAtPath(materialPath); + if (material == null) + return Response.Error($"Material not found at: {materialPath}"); + + // Detect current SRP + string currentSrp = "builtin"; + var currentRP = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline; + if (currentRP != null) + { + string rpName = currentRP.GetType().Name.ToLowerInvariant(); + if (rpName.Contains("urp") || rpName.Contains("universal")) + currentSrp = "urp"; + else if (rpName.Contains("hdrp") || rpName.Contains("highdefinition")) + currentSrp = "hdrp"; + } + + // Get appropriate shader name + string targetShaderName = null; + if (currentSrp == "urp" && shaderMapping.ContainsKey("urp")) + targetShaderName = shaderMapping["urp"]?.ToString(); + else if (currentSrp == "hdrp" && shaderMapping.ContainsKey("hdrp")) + targetShaderName = shaderMapping["hdrp"]?.ToString(); + else if (shaderMapping.ContainsKey("builtin")) + targetShaderName = shaderMapping["builtin"]?.ToString(); // Fallback + + if (string.IsNullOrEmpty(targetShaderName)) + return Response.Error($"No shader mapping provided for current SRP: {currentSrp}"); + + // Find shader + Shader targetShader = Shader.Find(targetShaderName); + if (targetShader == null) + return Response.Error($"Shader not found: {targetShaderName}"); + + // Check if material already uses this shader (idempotent) + if (material.shader == targetShader) + { + return new + { + success = true, + message = $"Material already uses appropriate shader for {currentSrp}.", + data = new + { + material = materialPath, + currentSrp = currentSrp, + shader = targetShaderName, + alreadyCorrect = true + }, + state_delta = StateComposer.CreateAssetDelta(new[] { + new { path = materialPath, imported = false, hasMeta = true } + }) + }; + } + + // Cache old shader name BEFORE switching + string oldShaderName = material.shader?.name ?? "None"; + + // Switch shader + material.shader = targetShader; + EditorUtility.SetDirty(material); + AssetDatabase.SaveAssets(); + StateComposer.IncrementRevision(); + + return new + { + success = true, + message = $"Material shader switched for {currentSrp}.", + data = new + { + material = materialPath, + currentSrp = currentSrp, + oldShader = oldShaderName, + newShader = targetShaderName, + alreadyCorrect = false + }, + state_delta = StateComposer.CreateAssetDelta(new[] { + new { path = materialPath, imported = false, hasMeta = true } + }) + }; + } + catch (Exception e) + { + return Response.Error($"Failed to ensure material shader for SRP: {e.Message}"); + } + } + } +} diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageShader.cs.meta b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageShader.cs.meta new file mode 100644 index 000000000..008ed0120 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageShader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c4894079ca02cc34ab668aa21146f161 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageUIToolkit.cs b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageUIToolkit.cs new file mode 100644 index 000000000..516ec3d43 --- /dev/null +++ b/Packages/cn.tuanjie.codely.bridge/Editor/Tools/ManageUIToolkit.cs @@ -0,0 +1,383 @@ +using System; +using System.IO; +using Codely.Newtonsoft.Json.Linq; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; +using UnityTcp.Editor.Helpers; + +namespace UnityTcp.Editor.Tools +{ + /// + /// [EXPERIMENTAL] Handles UI Toolkit operations (UXML, USS, PanelSettings). + /// + public static class ManageUIToolkit + { + public static object HandleCommand(JObject @params) + { + string action = @params["action"]?.ToString().ToLower(); + if (string.IsNullOrEmpty(action)) + { + return Response.Error("Action parameter is required."); + } + + try + { + switch (action) + { + case "ensure_panel_settings_asset": + return EnsurePanelSettingsAsset(@params); + case "link_uss_to_uxml": + return LinkUssToUxml(@params); + case "create_uxml": + return CreateUxml(@params); + case "create_uss": + return CreateUss(@params); + default: + return Response.Error( + $"Unknown action: '{action}'. Valid actions: ensure_panel_settings_asset, link_uss_to_uxml, create_uxml, create_uss." + ); + } + } + catch (Exception e) + { + Debug.LogError($"[ManageUIToolkit] Action '{action}' failed: {e}"); + return Response.Error($"[EXPERIMENTAL] UI Toolkit operation failed: {e.Message}"); + } + } + + private static object EnsurePanelSettingsAsset(JObject @params) + { + try + { + var writeCheck = WriteGuard.CheckWriteAllowed("ensure_panel_settings_asset"); + if (writeCheck != null) return writeCheck; + + string path = @params["path"]?.ToString(); + if (string.IsNullOrEmpty(path)) + return Response.Error("'path' parameter required."); + + if (!path.EndsWith(".asset")) + path += ".asset"; + + // Check if already exists + var existingAsset = AssetDatabase.LoadAssetAtPath(path); + if (existingAsset != null) + { + return new + { + success = true, + message = "[EXPERIMENTAL] PanelSettings asset already exists.", + data = new { path = path, alreadyExists = true }, + state_delta = StateComposer.CreateAssetDelta(new[] { + new { path = path, imported = false, hasMeta = true } + }) + }; + } + + // Create directory if needed + string dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + // Create PanelSettings asset + var panelSettings = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(panelSettings, path); + AssetDatabase.SaveAssets(); + StateComposer.IncrementRevision(); + + return new + { + success = true, + message = "[EXPERIMENTAL] PanelSettings asset created.", + data = new { path = path, alreadyExists = false }, + state_delta = StateComposer.CreateAssetDelta(new[] { + new { path = path, imported = true, hasMeta = true } + }) + }; + } + catch (Exception e) + { + return Response.Error($"[EXPERIMENTAL] Failed to ensure PanelSettings asset: {e.Message}"); + } + } + + private static object LinkUssToUxml(JObject @params) + { + try + { + var writeCheck = WriteGuard.CheckWriteAllowed("link_uss_to_uxml"); + if (writeCheck != null) return writeCheck; + + // Support both parameter naming conventions: uxml/uss and uxml_path/uss_path, + // as well as GUID-based references via uxml_guid/uss_guid. + string uxmlShorthand = @params["uxml"]?.ToString(); + string uxmlPathParam = @params["uxml_path"]?.ToString(); + string uxmlGuidParam = @params["uxml_guid"]?.ToString(); + + string ussShorthand = @params["uss"]?.ToString(); + string ussPathParam = @params["uss_path"]?.ToString(); + string ussGuidParam = @params["uss_guid"]?.ToString(); + + bool hasUxmlIdentifier = + !string.IsNullOrEmpty(uxmlShorthand) + || !string.IsNullOrEmpty(uxmlPathParam) + || !string.IsNullOrEmpty(uxmlGuidParam); + bool hasUssIdentifier = + !string.IsNullOrEmpty(ussShorthand) + || !string.IsNullOrEmpty(ussPathParam) + || !string.IsNullOrEmpty(ussGuidParam); + + if (!hasUxmlIdentifier) + return Response.Error("Either 'uxml', 'uxml_path', or 'uxml_guid' parameter is required."); + if (!hasUssIdentifier) + return Response.Error("Either 'uss', 'uss_path', or 'uss_guid' parameter is required."); + + string uxmlGuidUsed; + string ussGuidUsed; + + string uxmlPath = ResolveAssetPath(uxmlShorthand, uxmlPathParam, uxmlGuidParam, out uxmlGuidUsed); + string ussPath = ResolveAssetPath(ussShorthand, ussPathParam, ussGuidParam, out ussGuidUsed); + + if (string.IsNullOrEmpty(uxmlPath)) + { + if (!string.IsNullOrEmpty(uxmlGuidUsed)) + { + return Response.Error($"UXML asset not found for GUID: {uxmlGuidUsed}"); + } + + return Response.Error("UXML path could not be resolved."); + } + + if (string.IsNullOrEmpty(ussPath)) + { + if (!string.IsNullOrEmpty(ussGuidUsed)) + { + return Response.Error($"USS asset not found for GUID: {ussGuidUsed}"); + } + + return Response.Error("USS path could not be resolved."); + } + + var uxmlAsset = AssetDatabase.LoadAssetAtPath(uxmlPath); + if (uxmlAsset == null) + return Response.Error($"UXML not found at: {uxmlPath}"); + + var ussAsset = AssetDatabase.LoadAssetAtPath(ussPath); + if (ussAsset == null) + return Response.Error($"USS not found at: {ussPath}"); + + // Read UXML file content + string uxmlContent = File.ReadAllText(uxmlPath); + + // Check if USS is already linked + string ussFileName = Path.GetFileName(ussPath); + if (uxmlContent.Contains($"src=\"{ussFileName}\"")) + { + return new + { + success = true, + message = "[EXPERIMENTAL] USS already linked to UXML.", + data = new { uxml = uxmlPath, uss = ussPath, alreadyLinked = true }, + state_delta = StateComposer.CreateAssetDelta(new[] { + new { path = uxmlPath, imported = false, hasMeta = true } + }) + }; + } + + // Add USS reference to UXML + // Insert after tag + int insertPos = uxmlContent.IndexOf("= 0) + { + insertPos = uxmlContent.IndexOf('>', insertPos) + 1; + string styleTag = $"\n