├── .github └── workflows │ └── deploy-github-pages.yaml ├── LICENSE ├── README.md ├── backend ├── .gitignore ├── AkroGame.ECS.Inspector.sln └── src │ ├── AkroGame.ECS.Websocket │ ├── AkroGame.ECS.Websocket.csproj │ ├── EmptyContext.cs │ ├── Envelope.cs │ ├── IInspectorDataStream.cs │ ├── IOpenInspectors.cs │ ├── IWebSocket.cs │ ├── InspectorService.cs │ ├── QueryInvocation.cs │ ├── ReflectionUtil.cs │ ├── SocketUtil.cs │ ├── Standard │ │ └── HttpListenerWebSocket.cs │ ├── Streams │ │ ├── DashboardDataStream.cs │ │ ├── EntityComponentDataStream.cs │ │ ├── EntitySearchDataStream.cs │ │ └── GroupsDataStream.cs │ └── SveltoUtils.cs │ └── AkroGame.Ecs.Analyzer │ ├── AkroGame.ECS.Analyzer.csproj │ ├── AnalyzerReleases.Shipped.md │ ├── AnalyzerReleases.Unshipped.md │ ├── EngineQueriesGenerator.cs │ └── QueryInvocation.cs └── inspector ├── .gitignore ├── package.json ├── public ├── 404.html ├── index.html ├── manifest.json └── robots.txt ├── src ├── App.css ├── App.tsx ├── components │ ├── Dashboard.tsx │ ├── Engines.tsx │ ├── Entities.tsx │ ├── EntityInspector.tsx │ ├── EntityList.tsx │ └── Groups.tsx ├── index.css ├── index.tsx ├── layout │ └── Main.tsx ├── react-app-env.d.ts ├── reportWebVitals.ts └── streams │ └── WebSocketHelper.ts ├── tsconfig.json └── yarn.lock /.github/workflows/deploy-github-pages.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy to Pages 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: "pages" 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | deploy: 19 | defaults: 20 | run: 21 | working-directory: "inspector" 22 | environment: 23 | name: github-pages 24 | url: ${{ steps.deployment.outputs.page_url }} 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v3 29 | - name: Use Node.js 16 30 | uses: actions/setup-node@v2 31 | with: 32 | node-version: 16 33 | cache: "yarn" 34 | cache-dependency-path: "**/yarn.lock" 35 | - run: yarn install 36 | - run: yarn build 37 | - name: Setup Pages 38 | uses: actions/configure-pages@v2 39 | - name: Upload artifact 40 | uses: actions/upload-pages-artifact@v1 41 | with: 42 | path: "./inspector/build" 43 | - name: Deploy to GitHub Pages 44 | id: deployment 45 | uses: actions/deploy-pages@v1 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 akrogame 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Svelto ECS Inspector 2 | 3 | A Web based inspector for Svelto ECS library to visualize groups, entities and engines. 4 | 5 | ## Getting started 6 | 7 | Packages: 8 | 9 | You must include this in all of your assemblies where you have Engines defined: 10 | https://www.nuget.org/packages/AkroGame.ECS.Analyzer 11 | And this contains the inspector serving code itself: 12 | https://www.nuget.org/packages/AkroGame.ECS.Websocket 13 | 14 | You're going to need a websocket SERVER implementation for Unity. 15 | Recommended: https://github.com/James-Frowen/SimpleWebTransport 16 | With the following wrapper to tie it to the Inspector: 17 | 18 | ```cs 19 | using AkroGame.ECS.Websocket; 20 | using System; 21 | using JamesFrowen.SimpleWeb; 22 | 23 | public class WebSocketWrapper: IWebSocket 24 | { 25 | private readonly SimpleWebServer server; 26 | public WebSocketWrapper() 27 | { 28 | var tcpConfig = new TcpConfig(true, 5000, 5000); 29 | server = new SimpleWebServer(5000, tcpConfig, 32000, 5000, default); 30 | // listen for events 31 | server.onDisconnect += (id) => { OnClose?.Invoke(id); }; 32 | server.onData += (id, data) => { OnData?.Invoke(new Envelope>(id, data)); }; 33 | 34 | // start server listening on port 9300 35 | server.Start(9300); 36 | } 37 | 38 | public event Action>> OnData; 39 | public event Action OnClose; 40 | 41 | public void Send(int connectionId, ArraySegment source) 42 | { 43 | server.SendOne(connectionId, source); 44 | } 45 | 46 | /// 47 | /// Call this from Unity Main Thread! 48 | /// 49 | public void Update() 50 | { 51 | server.ProcessMessageQueue(); 52 | } 53 | } 54 | ``` 55 | 56 | If you are using the above WebSocket implementation you must call the Update from Unity Main Thread. 57 | 58 | Next create the inspector service 59 | 60 | ```cs 61 | IWebSocket ws = new WebSocketWrapper(); 62 | InspectorService inspector = InspectorService(ws, enginesRoot); 63 | ``` 64 | 65 | You **MUST** call `inspector.Update(deltaTime);` note: deltaTime is a TimeSpan! from your main loop (so Unity main thread / any step engine / whatever you use to tick your engines with) 66 | 67 | Open the UI and enjoy: https://akrogame.github.io/svelto-ecs-inspector/ 68 | note: the UI uses port 9300 so if you changed the port you must edit the port in the top left. 69 | 70 | ### Developing UI: 71 | 72 | Please make sure you have `yarn` and `node v17` installed on your machine. 73 | 74 | Run `yarn install` in the `/inspector` directory 75 | 76 | ### `yarn start` 77 | 78 | Runs the app in the development mode.\ 79 | Open [http://localhost:3000/svelto-ecs-inspector] to view it in the browser. 80 | 81 | The page will reload if you make edits.\ 82 | You will also see any lint errors in the console. 83 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | [Bb]uild/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # StyleCop 66 | StyleCopReport.xml 67 | 68 | # Files built by Visual Studio 69 | *_i.c 70 | *_p.c 71 | *_h.h 72 | *.ilk 73 | *.meta 74 | *.obj 75 | *.iobj 76 | *.pch 77 | *.pdb 78 | *.ipdb 79 | *.pgc 80 | *.pgd 81 | *.rsp 82 | *.sbr 83 | *.tlb 84 | *.tli 85 | *.tlh 86 | *.tmp 87 | *.tmp_proj 88 | *_wpftmp.csproj 89 | *.log 90 | *.vspscc 91 | *.vssscc 92 | .builds 93 | *.pidb 94 | *.svclog 95 | *.scc 96 | 97 | # Chutzpah Test files 98 | _Chutzpah* 99 | 100 | # Visual C++ cache files 101 | ipch/ 102 | *.aps 103 | *.ncb 104 | *.opendb 105 | *.opensdf 106 | *.sdf 107 | *.cachefile 108 | *.VC.db 109 | *.VC.VC.opendb 110 | 111 | # Visual Studio profiler 112 | *.psess 113 | *.vsp 114 | *.vspx 115 | *.sap 116 | 117 | # Visual Studio Trace Files 118 | *.e2e 119 | 120 | # TFS 2012 Local Workspace 121 | $tf/ 122 | 123 | # Guidance Automation Toolkit 124 | *.gpState 125 | 126 | # ReSharper is a .NET coding add-in 127 | _ReSharper*/ 128 | *.[Rr]e[Ss]harper 129 | *.DotSettings.user 130 | 131 | # JustCode is a .NET coding add-in 132 | .JustCode 133 | 134 | # TeamCity is a build add-in 135 | _TeamCity* 136 | 137 | # DotCover is a Code Coverage Tool 138 | *.dotCover 139 | 140 | # AxoCover is a Code Coverage Tool 141 | .axoCover/* 142 | !.axoCover/settings.json 143 | 144 | # Visual Studio code coverage results 145 | *.coverage 146 | *.coveragexml 147 | 148 | # NCrunch 149 | _NCrunch_* 150 | .*crunch*.local.xml 151 | nCrunchTemp_* 152 | 153 | # MightyMoose 154 | *.mm.* 155 | AutoTest.Net/ 156 | 157 | # Web workbench (sass) 158 | .sass-cache/ 159 | 160 | # Installshield output folder 161 | [Ee]xpress/ 162 | 163 | # DocProject is a documentation generator add-in 164 | DocProject/buildhelp/ 165 | DocProject/Help/*.HxT 166 | DocProject/Help/*.HxC 167 | DocProject/Help/*.hhc 168 | DocProject/Help/*.hhk 169 | DocProject/Help/*.hhp 170 | DocProject/Help/Html2 171 | DocProject/Help/html 172 | 173 | # Click-Once directory 174 | publish/ 175 | 176 | # Publish Web Output 177 | *.[Pp]ublish.xml 178 | *.azurePubxml 179 | # Note: Comment the next line if you want to checkin your web deploy settings, 180 | # but database connection strings (with potential passwords) will be unencrypted 181 | *.pubxml 182 | *.publishproj 183 | 184 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 185 | # checkin your Azure Web App publish settings, but sensitive information contained 186 | # in these scripts will be unencrypted 187 | PublishScripts/ 188 | 189 | # NuGet Packages 190 | *.nupkg 191 | # NuGet Symbol Packages 192 | *.snupkg 193 | # The packages folder can be ignored because of Package Restore 194 | **/[Pp]ackages/* 195 | # except build/, which is used as an MSBuild target. 196 | !**/[Pp]ackages/build/ 197 | # Uncomment if necessary however generally it will be regenerated when needed 198 | #!**/[Pp]ackages/repositories.config 199 | # NuGet v3's project.json files produces more ignorable files 200 | *.nuget.props 201 | *.nuget.targets 202 | 203 | # Microsoft Azure Build Output 204 | csx/ 205 | *.build.csdef 206 | 207 | # Microsoft Azure Emulator 208 | ecf/ 209 | rcf/ 210 | 211 | # Windows Store app package directories and files 212 | AppPackages/ 213 | BundleArtifacts/ 214 | Package.StoreAssociation.xml 215 | _pkginfo.txt 216 | *.appx 217 | *.appxbundle 218 | *.appxupload 219 | 220 | # Visual Studio cache files 221 | # files ending in .cache can be ignored 222 | *.[Cc]ache 223 | # but keep track of directories ending in .cache 224 | !?*.[Cc]ache/ 225 | 226 | # Others 227 | ClientBin/ 228 | ~$* 229 | *~ 230 | *.dbmdl 231 | *.dbproj.schemaview 232 | *.jfm 233 | *.pfx 234 | *.publishsettings 235 | orleans.codegen.cs 236 | 237 | # Including strong name files can present a security risk 238 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 239 | #*.snk 240 | 241 | # Since there are multiple workflows, uncomment next line to ignore bower_components 242 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 243 | #bower_components/ 244 | 245 | # RIA/Silverlight projects 246 | Generated_Code/ 247 | 248 | # Backup & report files from converting an old project file 249 | # to a newer Visual Studio version. Backup files are not needed, 250 | # because we have git ;-) 251 | _UpgradeReport_Files/ 252 | Backup*/ 253 | UpgradeLog*.XML 254 | UpgradeLog*.htm 255 | ServiceFabricBackup/ 256 | *.rptproj.bak 257 | 258 | # SQL Server files 259 | *.mdf 260 | *.ldf 261 | *.ndf 262 | 263 | # Business Intelligence projects 264 | *.rdl.data 265 | *.bim.layout 266 | *.bim_*.settings 267 | *.rptproj.rsuser 268 | *- [Bb]ackup.rdl 269 | *- [Bb]ackup ([0-9]).rdl 270 | *- [Bb]ackup ([0-9][0-9]).rdl 271 | 272 | # Microsoft Fakes 273 | FakesAssemblies/ 274 | 275 | # GhostDoc plugin setting file 276 | *.GhostDoc.xml 277 | 278 | # Node.js Tools for Visual Studio 279 | .ntvs_analysis.dat 280 | node_modules/ 281 | 282 | # Visual Studio 6 build log 283 | *.plg 284 | 285 | # Visual Studio 6 workspace options file 286 | *.opt 287 | 288 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 289 | *.vbw 290 | 291 | # Visual Studio LightSwitch build output 292 | **/*.HTMLClient/GeneratedArtifacts 293 | **/*.DesktopClient/GeneratedArtifacts 294 | **/*.DesktopClient/ModelManifest.xml 295 | **/*.Server/GeneratedArtifacts 296 | **/*.Server/ModelManifest.xml 297 | _Pvt_Extensions 298 | 299 | # Paket dependency manager 300 | .paket/paket.exe 301 | paket-files/ 302 | 303 | # FAKE - F# Make 304 | .fake/ 305 | 306 | # CodeRush personal settings 307 | .cr/personal 308 | 309 | # Python Tools for Visual Studio (PTVS) 310 | __pycache__/ 311 | *.pyc 312 | 313 | # Cake - Uncomment if you are using it 314 | # tools/** 315 | # !tools/packages.config 316 | 317 | # Tabs Studio 318 | *.tss 319 | 320 | # Telerik's JustMock configuration file 321 | *.jmconfig 322 | 323 | # BizTalk build output 324 | *.btp.cs 325 | *.btm.cs 326 | *.odx.cs 327 | *.xsd.cs 328 | 329 | # OpenCover UI analysis results 330 | OpenCover/ 331 | 332 | # Azure Stream Analytics local run output 333 | ASALocalRun/ 334 | 335 | # MSBuild Binary and Structured Log 336 | *.binlog 337 | 338 | # NVidia Nsight GPU debugger configuration file 339 | *.nvuser 340 | 341 | # MFractors (Xamarin productivity tool) working folder 342 | .mfractor/ 343 | 344 | # Local History for Visual Studio 345 | .localhistory/ 346 | 347 | # BeatPulse healthcheck temp database 348 | healthchecksdb 349 | 350 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 351 | MigrationBackup/ 352 | 353 | # Ionide (cross platform F# VS Code tools) working folder 354 | .ionide/ 355 | .vscode/.env -------------------------------------------------------------------------------- /backend/AkroGame.ECS.Inspector.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.32112.339 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{EA015054-01CE-4739-97DE-ACBB1EFD3868}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AkroGame.ECS.Analyzer", "src\AkroGame.ECS.Analyzer\AkroGame.ECS.Analyzer.csproj", "{7F5A7C2E-0ABA-4ED6-A73A-85B0AED32778}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AkroGame.ECS.Websocket", "src\AkroGame.ECS.Websocket\AkroGame.ECS.Websocket.csproj", "{4BD65BFD-5AFA-478D-BBAD-B677121FD762}" 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Release|Any CPU = Release|Any CPU 16 | EndGlobalSection 17 | GlobalSection(SolutionProperties) = preSolution 18 | HideSolutionNode = FALSE 19 | EndGlobalSection 20 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 21 | {7F5A7C2E-0ABA-4ED6-A73A-85B0AED32778}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 22 | {7F5A7C2E-0ABA-4ED6-A73A-85B0AED32778}.Debug|Any CPU.Build.0 = Debug|Any CPU 23 | {7F5A7C2E-0ABA-4ED6-A73A-85B0AED32778}.Release|Any CPU.ActiveCfg = Release|Any CPU 24 | {7F5A7C2E-0ABA-4ED6-A73A-85B0AED32778}.Release|Any CPU.Build.0 = Release|Any CPU 25 | {4BD65BFD-5AFA-478D-BBAD-B677121FD762}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {4BD65BFD-5AFA-478D-BBAD-B677121FD762}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {4BD65BFD-5AFA-478D-BBAD-B677121FD762}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {4BD65BFD-5AFA-478D-BBAD-B677121FD762}.Release|Any CPU.Build.0 = Release|Any CPU 29 | EndGlobalSection 30 | GlobalSection(NestedProjects) = preSolution 31 | {7F5A7C2E-0ABA-4ED6-A73A-85B0AED32778} = {EA015054-01CE-4739-97DE-ACBB1EFD3868} 32 | {4BD65BFD-5AFA-478D-BBAD-B677121FD762} = {EA015054-01CE-4739-97DE-ACBB1EFD3868} 33 | EndGlobalSection 34 | EndGlobal 35 | -------------------------------------------------------------------------------- /backend/src/AkroGame.ECS.Websocket/AkroGame.ECS.Websocket.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.1 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | true 17 | 18 | 19 | -------------------------------------------------------------------------------- /backend/src/AkroGame.ECS.Websocket/EmptyContext.cs: -------------------------------------------------------------------------------- 1 | namespace AkroGame.ECS.Websocket 2 | { 3 | public struct EmptyContext { } 4 | } 5 | -------------------------------------------------------------------------------- /backend/src/AkroGame.ECS.Websocket/Envelope.cs: -------------------------------------------------------------------------------- 1 | namespace AkroGame.ECS.Websocket 2 | { 3 | public struct Envelope 4 | { 5 | public Envelope(K id, T payload) 6 | { 7 | Id = id; 8 | Payload = payload; 9 | } 10 | 11 | public K Id { get; } 12 | public T Payload { get; } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/AkroGame.ECS.Websocket/IInspectorDataStream.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | 7 | namespace AkroGame.ECS.Websocket 8 | { 9 | public interface IInspectorDataStream 10 | { 11 | void UnSubscribe(int inspectorId); 12 | void PushAll(TimeSpan deltaTime, IWebSocket ws); 13 | } 14 | 15 | public abstract class InspectorDataStream : IInspectorDataStream 16 | { 17 | protected readonly ConcurrentDictionary inspectors; 18 | private readonly string key; 19 | private readonly TimeSpan sendInterval; 20 | private TimeSpan nextSendIn; 21 | 22 | protected InspectorDataStream(string key, TimeSpan sendInterval) 23 | { 24 | inspectors = new ConcurrentDictionary(); 25 | this.key = key; 26 | this.sendInterval = sendInterval; 27 | this.nextSendIn = sendInterval; 28 | } 29 | 30 | public void Subscribe(int inspectorId, TContext context) 31 | { 32 | inspectors.AddOrUpdate(inspectorId, context, (id, existing) => context); 33 | } 34 | 35 | public void UnSubscribe(int inspectorId) 36 | { 37 | inspectors.Remove(inspectorId, out var _); 38 | } 39 | 40 | protected Envelope MakeEnvelope(T payload) => 41 | new Envelope(key, payload); 42 | 43 | protected abstract ArraySegment FetchData(TContext context); 44 | 45 | public void PushAll(TimeSpan deltaTime, IWebSocket ws) 46 | { 47 | if (!inspectors.Any()) 48 | return; 49 | 50 | nextSendIn -= deltaTime; 51 | if (nextSendIn > TimeSpan.Zero) 52 | return; 53 | 54 | nextSendIn += sendInterval; 55 | foreach (var inspector in inspectors) 56 | { 57 | var data = FetchData(inspector.Value); 58 | ws.Send(inspector.Key, data); 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /backend/src/AkroGame.ECS.Websocket/IOpenInspectors.cs: -------------------------------------------------------------------------------- 1 | namespace AkroGame.ECS.Websocket 2 | { 3 | public interface IOpenInspector 4 | { 5 | int Id { get; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/AkroGame.ECS.Websocket/IWebSocket.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace AkroGame.ECS.Websocket 4 | { 5 | public interface IWebSocket 6 | { 7 | /// 8 | /// Callback for when data is received on the websocket for a certain connection 9 | /// 10 | /// Users should assume that the payload in the envelope is only safe to use during the callback 11 | /// and storing it could lead to undesirable effects 12 | /// 13 | event Action>> OnData; 14 | 15 | /// 16 | /// Should be called when the remote connection is closed 17 | /// 18 | event Action OnClose; 19 | 20 | /// 21 | /// Sends the byte array segment to the client specified by the id 22 | /// 23 | public void Send(int connectionId, ArraySegment source); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/AkroGame.ECS.Websocket/InspectorService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Text; 5 | using System.Web; 6 | using AkroGame.ECS.Websocket.Streams; 7 | using Newtonsoft.Json; 8 | using Svelto.DataStructures; 9 | using Svelto.ECS; 10 | 11 | namespace AkroGame.ECS.Websocket 12 | { 13 | public class InspectorService 14 | { 15 | private readonly IWebSocket ws; 16 | private readonly ConcurrentQueue> messages; 17 | private readonly Dictionary streams; 18 | private readonly EntitiesDB entitiesDB; 19 | private readonly List queryInvocation; 20 | private readonly EntityComponentDataStream entityComponentDataStream; 21 | private readonly GroupsDataStream groupsDataStream; 22 | private readonly EntitySearchDataStream entitySearchDataStream; 23 | private readonly DashboardDataStream dashboardDataStream; 24 | 25 | private const string STREAM_ENTITY_DATA = "entity-data"; 26 | private const string STREAM_ENTITIES = "entities"; 27 | private const string STREAM_DASHBOARD = "dashboard"; 28 | private const string STREAM_GROUPS = "groups"; 29 | private readonly int maxMessagesPerFrame; 30 | 31 | public InspectorService(IWebSocket ws, EnginesRoot enginesRoot) 32 | { 33 | this.maxMessagesPerFrame = 500; 34 | this.entitiesDB = enginesRoot.GetEntitiesDB(); 35 | var meta = EnginesMetadataFactory.GetMeta(); 36 | this.queryInvocation = meta.QueryInvocations; 37 | this.ws = ws; 38 | this.messages = new ConcurrentQueue>(); 39 | this.streams = new Dictionary() 40 | { 41 | { 42 | STREAM_ENTITY_DATA, 43 | entityComponentDataStream = new EntityComponentDataStream( 44 | STREAM_ENTITY_DATA, 45 | enginesRoot 46 | ) 47 | }, 48 | { 49 | STREAM_GROUPS, 50 | groupsDataStream = new GroupsDataStream(STREAM_GROUPS, enginesRoot) 51 | }, 52 | { 53 | STREAM_ENTITIES, 54 | entitySearchDataStream = new EntitySearchDataStream( 55 | STREAM_ENTITIES, 56 | enginesRoot 57 | ) 58 | }, 59 | { 60 | STREAM_DASHBOARD, 61 | dashboardDataStream = new DashboardDataStream( 62 | STREAM_DASHBOARD, 63 | meta, 64 | enginesRoot 65 | ) 66 | } 67 | }; 68 | 69 | this.ws.OnData += InspectorMessageReceived; 70 | this.ws.OnClose += InspectorClosed; 71 | } 72 | 73 | /// 74 | /// Call from the Main Svelto Thread of your application 75 | /// 76 | public void Update(TimeSpan deltaTime) 77 | { 78 | var c = maxMessagesPerFrame; 79 | while ((c-- >= 0 || maxMessagesPerFrame == 0) && messages.TryDequeue(out var envelope)) 80 | { 81 | var id = envelope.Id; 82 | var command = envelope.Payload[0]; 83 | var args = new Span(envelope.Payload, 1, envelope.Payload.Length - 1); 84 | switch (command) 85 | { 86 | case "sub": 87 | Subscribe(id, args); 88 | break; 89 | case "un-sub": 90 | UnSubscribe(id, args); 91 | break; 92 | case "update": 93 | UpdateComponentData(id, args); 94 | break; 95 | case "get-engines": 96 | GetEngines(id, args); 97 | break; 98 | default: 99 | break; 100 | } 101 | } 102 | foreach (var stream in streams) 103 | stream.Value.PushAll(deltaTime, ws); 104 | } 105 | 106 | private void InspectorClosed(int id) 107 | { 108 | foreach (var stream in streams) 109 | { 110 | stream.Value.UnSubscribe(id); 111 | } 112 | } 113 | 114 | private void InspectorMessageReceived(Envelope> data) 115 | { 116 | messages.Enqueue( 117 | new Envelope( 118 | data.Id, 119 | Encoding.UTF8.GetString(data.Payload).Split(" ") 120 | ) 121 | ); 122 | } 123 | 124 | private void Subscribe(int id, Span args) 125 | { 126 | var stream = args[0]; 127 | switch (stream) 128 | { 129 | case STREAM_ENTITY_DATA: 130 | 131 | var groupId = SveltoUtils.CreateExclusiveGroupStruct(uint.Parse(args[1])); 132 | var entityId = uint.Parse(args[2]); 133 | 134 | entityComponentDataStream.Subscribe(id, new Svelto.ECS.EGID(entityId, groupId)); 135 | break; 136 | case STREAM_GROUPS: 137 | groupsDataStream.Subscribe(id, default); 138 | break; 139 | case STREAM_ENTITIES: 140 | string searchQuery = args.Length > 1 ? args[1] : ""; 141 | entitySearchDataStream.Subscribe( 142 | id, 143 | new SearchContext(HttpUtility.UrlDecode(searchQuery)) 144 | ); 145 | break; 146 | case STREAM_DASHBOARD: 147 | dashboardDataStream.Subscribe(id, default); 148 | break; 149 | } 150 | } 151 | 152 | private void UnSubscribe(int id, Span args) 153 | { 154 | streams[args[0]].UnSubscribe(id); 155 | } 156 | 157 | private void UpdateComponentData(int _, Span args) 158 | { 159 | var groupId = SveltoUtils.CreateExclusiveGroupStruct(uint.Parse(args[0])); 160 | var entityId = uint.Parse(args[1]); 161 | var componentName = args[2]; 162 | 163 | // componentName is a fully qualified assembly name, so we should be able to find it here 164 | Type componentType = Type.GetType(componentName); 165 | 166 | // Knowing the type we can deserialize the json object 167 | var componentData = JsonConvert.DeserializeObject(args[3], componentType); 168 | if (componentData == null) 169 | return; 170 | 171 | // Get component data array and index of the component 172 | var queryEntitiesAndIndexParams = new object?[] { entitiesDB, entityId, groupId, null }; 173 | var componentDataArray = typeof(EntityNativeDBExtensions).GetMethod( 174 | "QueryEntitiesAndIndex", 175 | new[] 176 | { 177 | typeof(EntitiesDB), 178 | typeof(uint), 179 | typeof(ExclusiveGroupStruct), 180 | typeof(uint).MakeByRefType() 181 | } 182 | )?.MakeGenericMethod(componentType)?.Invoke(entitiesDB, queryEntitiesAndIndexParams); 183 | var indexObject = queryEntitiesAndIndexParams[3]; 184 | if (indexObject == null) 185 | return; 186 | 187 | // Get the underlying native array 188 | var nativeArrayObject = typeof(NB<>) 189 | .MakeGenericType(new Type[] { componentType }) 190 | .GetMethod("ToNativeArray")?.Invoke(componentDataArray, new object?[] { null }); 191 | if (nativeArrayObject is null) 192 | return; 193 | 194 | // Write the component data, into the native component array 195 | ReflectionUtil.WriteToUnsafeMemory( 196 | (IntPtr)nativeArrayObject, 197 | componentType, 198 | componentData, 199 | (uint)indexObject 200 | ); 201 | } 202 | 203 | private void GetEngines(int id, Span args) 204 | { 205 | ws.Send( 206 | id, 207 | SocketUtil.Serialize( 208 | new Envelope>("engines", queryInvocation) 209 | ) 210 | ); 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /backend/src/AkroGame.ECS.Websocket/QueryInvocation.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | 6 | namespace AkroGame.ECS.Websocket 7 | { 8 | public struct QueryInvocation 9 | { 10 | public string ClassName { get; } 11 | public List Components { get; } 12 | 13 | public QueryInvocation(string className, List components) 14 | { 15 | ClassName = className; 16 | Components = components; 17 | } 18 | } 19 | 20 | public class EnginesMetadata 21 | { 22 | public List QueryInvocations { get; } 23 | 24 | public EnginesMetadata(List queryInvocations) 25 | { 26 | QueryInvocations = queryInvocations; 27 | } 28 | } 29 | 30 | public static class EnginesMetadataFactory 31 | { 32 | public static EnginesMetadata GetMeta() 33 | { 34 | var metas = AppDomain.CurrentDomain 35 | .GetAssemblies() 36 | .SelectMany(s => s.GetTypes().Where(IsEngineType)) 37 | .Distinct(); 38 | 39 | return metas.Aggregate( 40 | new EnginesMetadata(new List()), 41 | (acc, meta) => 42 | { 43 | var field = meta.GetField( 44 | "QueryInvocations", 45 | BindingFlags.Public | BindingFlags.Static 46 | ); 47 | if (field?.GetValue(null) is Dictionary> queryInvocations) 48 | foreach (var i in queryInvocations) 49 | acc.QueryInvocations.Add(new QueryInvocation(i.Key, i.Value)); 50 | return acc; 51 | } 52 | ); 53 | } 54 | 55 | private static bool IsEngineType(Type t) => 56 | t.AssemblyQualifiedName?.StartsWith("Svelto.ECS.Meta") 57 | ?? false && t.Name == "EnginesMetadata" && t.IsClass && !t.IsAbstract; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /backend/src/AkroGame.ECS.Websocket/ReflectionUtil.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Reflection; 4 | using System.Runtime.CompilerServices; 5 | using Svelto.Common; 6 | 7 | namespace AkroGame.ECS.Websocket 8 | { 9 | public static class ReflectionUtil 10 | { 11 | private static readonly MethodInfo copyMethod = typeof(Unsafe) 12 | .GetMethods() 13 | .Single( 14 | x => x.Name == "Copy" && x.GetParameters().First().ParameterType == typeof(void*) 15 | ); 16 | 17 | public static T GetPrivateField(this S thisObj, string name) where T : class 18 | { 19 | var field = typeof(S).GetField(name, BindingFlags.NonPublic | BindingFlags.Instance); 20 | if (field == null) 21 | throw new ArgumentException( 22 | $"{name} is not a valid private field of {typeof(T).Name}" 23 | ); 24 | var instanceField = field.GetValue(thisObj); 25 | if (!(instanceField is T tmp)) 26 | throw new ArgumentException( 27 | $"{name} is not of type {typeof(T)}, it's {field.GetType()}" 28 | ); 29 | return tmp; 30 | } 31 | 32 | public static void WriteToUnsafeMemory(IntPtr array, Type t, object component, uint index) 33 | { 34 | MethodInfo? sizeOfMethod = typeof(MemoryUtilities).GetMethod( 35 | "SizeOf", 36 | Array.Empty() 37 | )?.MakeGenericMethod(new Type[] { t }); 38 | 39 | var sizeB = sizeOfMethod?.Invoke(null, null); 40 | if (sizeB == null) 41 | return; 42 | var offset = (int)((int)sizeB * index); 43 | 44 | copyMethod.MakeGenericMethod(new Type[] { t })?.Invoke( 45 | null, 46 | new object[] { IntPtr.Add(array, offset), component } 47 | ); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /backend/src/AkroGame.ECS.Websocket/SocketUtil.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using Newtonsoft.Json; 4 | 5 | namespace AkroGame.ECS.Websocket 6 | { 7 | public static class SocketUtil 8 | { 9 | public static ArraySegment Serialize(T t) => 10 | Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(t)); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/AkroGame.ECS.Websocket/Standard/HttpListenerWebSocket.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Net; 5 | using System.Net.WebSockets; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | 9 | namespace AkroGame.ECS.Websocket.Standard 10 | { 11 | public class HttpListenerWebSocket : IWebSocket 12 | { 13 | private readonly CancellationTokenSource tokenSource; 14 | private readonly CancellationToken cancellationToken; 15 | private readonly HttpListener httpListener; 16 | private readonly ConcurrentDictionary connections; 17 | 18 | private int clientId = 1; 19 | 20 | public event Action>>? OnData; 21 | public event Action? OnClose; 22 | 23 | public HttpListenerWebSocket(ushort port, string bindAddr = "localhost") 24 | { 25 | this.connections = new ConcurrentDictionary(); 26 | this.tokenSource = new CancellationTokenSource(); 27 | this.cancellationToken = tokenSource.Token; 28 | 29 | httpListener = new HttpListener(); 30 | httpListener.Prefixes.Add($"http://{bindAddr}:{port}/"); 31 | httpListener.Start(); 32 | 33 | Task t = new Task(() => ConnectionWorker().ConfigureAwait(false), cancellationToken); 34 | t.Start(); 35 | } 36 | 37 | public void Stop() 38 | { 39 | if (httpListener.IsListening) 40 | { 41 | tokenSource.Cancel(); 42 | httpListener.Stop(); 43 | httpListener.Close(); 44 | tokenSource.Dispose(); 45 | } 46 | } 47 | 48 | private async Task ConnectionWorker() 49 | { 50 | while (!cancellationToken.IsCancellationRequested) 51 | { 52 | HttpListenerContext context = await httpListener 53 | .GetContextAsync() 54 | .ConfigureAwait(false); 55 | if (context.Request.IsWebSocketRequest) 56 | { 57 | HttpListenerWebSocketContext webSocketContext = 58 | await context.AcceptWebSocketAsync(null); 59 | 60 | Interlocked.Increment(ref clientId); 61 | connections.TryAdd(clientId, webSocketContext.WebSocket); 62 | var t = new Task( 63 | () => WebSocketWorker(webSocketContext, clientId).ConfigureAwait(false) 64 | ); 65 | t.Start(); 66 | } 67 | } 68 | } 69 | 70 | private async Task WebSocketWorker(HttpListenerWebSocketContext ctx, int id) 71 | { 72 | var socket = ctx.WebSocket; 73 | try 74 | { 75 | byte[] buffer = new byte[4096]; 76 | while ( 77 | socket.State == WebSocketState.Open 78 | && !cancellationToken.IsCancellationRequested 79 | ) 80 | { 81 | WebSocketReceiveResult receiveResult = await socket.ReceiveAsync( 82 | new ArraySegment(buffer), 83 | cancellationToken 84 | ); 85 | if (receiveResult.MessageType == WebSocketMessageType.Close) 86 | { 87 | await socket.CloseAsync( 88 | WebSocketCloseStatus.NormalClosure, 89 | "", 90 | cancellationToken 91 | ); 92 | } 93 | else 94 | { 95 | if (receiveResult.MessageType != WebSocketMessageType.Binary) 96 | continue; 97 | if (!receiveResult.EndOfMessage) 98 | break; 99 | OnData?.Invoke( 100 | new Envelope>( 101 | id, 102 | new ArraySegment(buffer, 0, receiveResult.Count) 103 | ) 104 | ); 105 | } 106 | } 107 | } 108 | catch (OperationCanceledException) { } 109 | catch (Exception) { } 110 | finally 111 | { 112 | OnClose?.Invoke(id); 113 | connections.Remove(clientId, out var _); 114 | socket?.Dispose(); 115 | } 116 | } 117 | 118 | public void Send(int connectionId, ArraySegment source) 119 | { 120 | if (!connections.TryGetValue(connectionId, out var ws)) 121 | return; 122 | 123 | if (ws.State != WebSocketState.Open) 124 | return; 125 | 126 | ws.SendAsync(source, WebSocketMessageType.Binary, true, cancellationToken) 127 | .Wait(1000, cancellationToken); 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /backend/src/AkroGame.ECS.Websocket/Streams/DashboardDataStream.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Svelto.DataStructures; 4 | using Svelto.ECS; 5 | using Svelto.ECS.Internal; 6 | 7 | namespace AkroGame.ECS.Websocket.Streams 8 | { 9 | public struct DashboardData 10 | { 11 | public DashboardData(Dictionary groups) 12 | { 13 | Groups = groups; 14 | } 15 | 16 | public Dictionary Groups { get; } 17 | } 18 | 19 | public class DashboardDataStream : InspectorDataStream 20 | { 21 | private readonly FasterDictionary< 22 | ExclusiveGroupStruct, 23 | FasterDictionary 24 | > groupEntityComponentsDB; 25 | 26 | public DashboardDataStream(string key, EnginesMetadata meta, EnginesRoot enginesRoot) 27 | : base(key, TimeSpan.FromSeconds(1.0 / 3)) 28 | { 29 | groupEntityComponentsDB = enginesRoot.GetGroupEntityComponentsDB(); 30 | } 31 | 32 | protected override ArraySegment FetchData(EmptyContext context) 33 | { 34 | var groups = new Dictionary(); 35 | foreach (var componentsIt in groupEntityComponentsDB) 36 | { 37 | var group = componentsIt.key; 38 | var components = componentsIt.value; 39 | var entityCount = 0; 40 | 41 | foreach (var componentEntityMappingIt in components) 42 | { 43 | var componentEntityMapping = componentEntityMappingIt.value; 44 | entityCount = Math.Max(entityCount, componentEntityMapping.count); 45 | } 46 | groups[group.ToString()] = entityCount; 47 | } 48 | return SocketUtil.Serialize(MakeEnvelope(new DashboardData(groups))); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /backend/src/AkroGame.ECS.Websocket/Streams/EntityComponentDataStream.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using Newtonsoft.Json; 6 | using Newtonsoft.Json.Linq; 7 | using Svelto.DataStructures; 8 | using Svelto.ECS; 9 | using Svelto.ECS.Internal; 10 | 11 | namespace AkroGame.ECS.Websocket.Streams 12 | { 13 | public struct ComponentWithData 14 | { 15 | public ComponentWithData(string prettyName, JObject data) 16 | { 17 | PrettyName = prettyName; 18 | Data = data; 19 | } 20 | 21 | public string PrettyName { get; } 22 | public JObject Data { get; } 23 | } 24 | 25 | public class EntityComponentDataStream : InspectorDataStream 26 | { 27 | private readonly EntitiesDB entitiesDB; 28 | private readonly FasterDictionary< 29 | ExclusiveGroupStruct, 30 | FasterDictionary 31 | > groupEntityComponentsDB; 32 | private readonly JsonSerializer serializer; 33 | 34 | public EntityComponentDataStream(string key, EnginesRoot enginesRoot) 35 | : base(key, TimeSpan.FromSeconds(1.0 / 5)) 36 | { 37 | serializer = new JsonSerializer(); 38 | entitiesDB = enginesRoot.GetEntitiesDB(); 39 | groupEntityComponentsDB = enginesRoot.GetGroupEntityComponentsDB(); 40 | } 41 | 42 | protected override ArraySegment FetchData(EGID context) 43 | { 44 | // QueryEntity(this EntitiesDB entitiesDb, EGID entityGID) 45 | var components = new Dictionary(); 46 | if (!groupEntityComponentsDB.ContainsKey(context.groupID)) 47 | return SocketUtil.Serialize(MakeEnvelope(components)); 48 | foreach (var componentId in groupEntityComponentsDB[context.groupID].keys) 49 | { 50 | var componentType = ComponentTypeMap.FetchType(componentId); 51 | MethodInfo? queryMethod = typeof(EntityNativeDBExtensions) 52 | .GetMethods() 53 | .FirstOrDefault(x => x.Name == "TryGetEntity" && x.GetParameters().Length == 4); 54 | if (queryMethod is null) 55 | { 56 | Svelto.Console.LogError( 57 | "Could not find query method for component " + componentType.Name 58 | ); 59 | continue; 60 | } 61 | queryMethod = queryMethod.MakeGenericMethod(componentType); 62 | if (queryMethod is null) 63 | { 64 | Svelto.Console.LogError( 65 | "Could not make generic query method for component " + componentType.Name 66 | ); 67 | continue; 68 | } 69 | // This is just a quick dirty check because we can't serialize these components for sure 70 | if ( 71 | componentType.Name == "EntityInfoComponent" 72 | || componentType.Name == "EntityReferenceComponent" 73 | || componentType.Name == "EGIDComponent" 74 | || componentType 75 | .GetFields() 76 | .Any(x => x.FieldType.Name.StartsWith("NativeDynamicArray")) 77 | || componentType 78 | .GetProperties() 79 | .Any(x => x.PropertyType.Name.StartsWith("NativeDynamicArray")) 80 | ) 81 | continue; 82 | 83 | // Not all components are serializable, instead of trying to guess it just try to serialize it and see if it fails 84 | // (for example components with NativeDynamicArray fail serialization) 85 | try 86 | { 87 | var @params = new object[] 88 | { 89 | entitiesDB, 90 | context.entityID, 91 | context.groupID, 92 | null! 93 | }; 94 | var found = queryMethod?.Invoke(entitiesDB, @params); 95 | if (found is null || !(bool)found) 96 | continue; 97 | if (@params[3] is null) 98 | continue; 99 | var rawComponentData = JObject.FromObject(@params[3], serializer); 100 | var componentWithData = new ComponentWithData( 101 | componentType.Name, 102 | rawComponentData 103 | ); 104 | components.Add( 105 | componentType.AssemblyQualifiedName.Replace(" ", ""), 106 | componentWithData 107 | ); 108 | } 109 | catch (Exception ex) 110 | { 111 | Svelto.Console.LogException( 112 | ex, 113 | "Failed to serialize component " + componentType.Name 114 | ); 115 | } 116 | } 117 | 118 | return SocketUtil.Serialize(MakeEnvelope(components)); 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /backend/src/AkroGame.ECS.Websocket/Streams/EntitySearchDataStream.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using Newtonsoft.Json; 6 | using Svelto.DataStructures; 7 | using Svelto.ECS; 8 | using Svelto.ECS.Internal; 9 | 10 | namespace AkroGame.ECS.Websocket.Streams 11 | { 12 | // TODO: ability to search for any field in any component 13 | public struct SearchContext 14 | { 15 | public string SearchTerm { get; } 16 | 17 | public SearchContext(string searchTerm) 18 | { 19 | SearchTerm = searchTerm; 20 | } 21 | } 22 | 23 | public struct GroupData 24 | { 25 | public GroupData(string name, uint[] entities) 26 | { 27 | Name = name; 28 | Entities = entities; 29 | } 30 | 31 | public string Name { get; } 32 | public uint[] Entities { get; } 33 | } 34 | 35 | public class EntitySearchDataStream : InspectorDataStream 36 | { 37 | private readonly FasterDictionary< 38 | ExclusiveGroupStruct, 39 | FasterDictionary 40 | > groupEntityComponentsDB; 41 | 42 | public EntitySearchDataStream(string key, EnginesRoot enginesRoot) 43 | : base(key, TimeSpan.FromSeconds(1.0 / 2)) 44 | { 45 | groupEntityComponentsDB = enginesRoot.GetGroupEntityComponentsDB(); 46 | } 47 | 48 | protected override ArraySegment FetchData(SearchContext context) 49 | { 50 | var groups = new Dictionary(); 51 | foreach (var componentsIt in groupEntityComponentsDB) 52 | { 53 | var group = componentsIt.key; 54 | var components = componentsIt.value; 55 | var entities = new HashSet(); 56 | 57 | foreach (var componentEntityMappingIt in components) 58 | { 59 | var componentEntityMapping = componentEntityMappingIt.value; 60 | componentEntityMapping.KeysEvaluator( 61 | x => 62 | { 63 | if ( 64 | string.IsNullOrEmpty(context.SearchTerm) 65 | || x.ToString().Contains(context.SearchTerm) 66 | ) 67 | entities.Add(x); 68 | } 69 | ); 70 | } 71 | groups[group.ToIDAndBitmask()] = new GroupData( 72 | group.ToString(), 73 | entities.OrderBy(x => x).Take(10).ToArray() 74 | ); 75 | } 76 | return SocketUtil.Serialize(MakeEnvelope(groups)); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /backend/src/AkroGame.ECS.Websocket/Streams/GroupsDataStream.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Newtonsoft.Json; 5 | using Svelto.DataStructures; 6 | using Svelto.ECS; 7 | using Svelto.ECS.Internal; 8 | 9 | namespace AkroGame.ECS.Websocket.Streams 10 | { 11 | public class GroupsDataStream : InspectorDataStream 12 | { 13 | private readonly FasterDictionary< 14 | ExclusiveGroupStruct, 15 | FasterDictionary 16 | > groupEntityComponentsDB; 17 | 18 | public GroupsDataStream(string key, EnginesRoot enginesRoot) 19 | : base(key, TimeSpan.FromSeconds(1.0 / 1)) 20 | { 21 | groupEntityComponentsDB = enginesRoot.GetGroupEntityComponentsDB(); 22 | } 23 | 24 | protected override ArraySegment FetchData(EmptyContext inspector) 25 | { 26 | var groups = new Dictionary>(); 27 | foreach (var item in groupEntityComponentsDB) 28 | { 29 | var components = new List(); 30 | 31 | foreach (var componentEntry in item.value) 32 | { 33 | var componentType = ComponentTypeMap.FetchType(componentEntry.key); 34 | 35 | components.Add(componentType.Name.ToString()); 36 | } 37 | groups[item.key.ToString()] = components; 38 | } 39 | return SocketUtil.Serialize(MakeEnvelope(groups)); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/AkroGame.ECS.Websocket/SveltoUtils.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using Svelto.DataStructures; 3 | using Svelto.ECS; 4 | using Svelto.ECS.Internal; 5 | 6 | namespace AkroGame.ECS.Websocket 7 | { 8 | public static class SveltoUtils 9 | { 10 | public static ExclusiveGroupStruct CreateExclusiveGroupStruct(uint groupId) 11 | { 12 | var ctorInfo = typeof(ExclusiveGroupStruct)?.GetConstructor( 13 | BindingFlags.NonPublic | BindingFlags.Instance, 14 | null, 15 | new[] { typeof(uint) }, 16 | null 17 | ); 18 | 19 | var obj = ctorInfo?.Invoke(new object[] { groupId }); 20 | 21 | if (obj == null) 22 | throw new System.Exception( 23 | $"Could not construct ExclusiveGroupStruct with groupId {groupId}" 24 | ); 25 | 26 | return (ExclusiveGroupStruct)obj; 27 | } 28 | 29 | public static EntitiesDB GetEntitiesDB(this EnginesRoot enginesRoot) => 30 | enginesRoot.GetPrivateField("_entitiesDB"); 31 | 32 | public static FasterDictionary< 33 | ExclusiveGroupStruct, 34 | FasterDictionary 35 | > GetGroupEntityComponentsDB(this EnginesRoot enginesRoot) => 36 | enginesRoot.GetPrivateField< 37 | EnginesRoot, 38 | FasterDictionary< 39 | ExclusiveGroupStruct, 40 | FasterDictionary 41 | > 42 | >("_groupEntityComponentsDB"); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /backend/src/AkroGame.Ecs.Analyzer/AkroGame.ECS.Analyzer.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0 5 | AkroGame.ECS.Analyzer 6 | AkroGame 7 | AkroGame 8 | Code generator for Svelto ECS Inspector tool 9 | https://github.com/akrogame/svelto-ecs-inspector 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /backend/src/AkroGame.Ecs.Analyzer/AnalyzerReleases.Shipped.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akrogame/svelto-ecs-inspector/f34a4cc8da0185bc6477f770c021a72bd07fc335/backend/src/AkroGame.Ecs.Analyzer/AnalyzerReleases.Shipped.md -------------------------------------------------------------------------------- /backend/src/AkroGame.Ecs.Analyzer/AnalyzerReleases.Unshipped.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akrogame/svelto-ecs-inspector/f34a4cc8da0185bc6477f770c021a72bd07fc335/backend/src/AkroGame.Ecs.Analyzer/AnalyzerReleases.Unshipped.md -------------------------------------------------------------------------------- /backend/src/AkroGame.Ecs.Analyzer/EngineQueriesGenerator.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using Microsoft.CodeAnalysis; 3 | using Microsoft.CodeAnalysis.CSharp.Syntax; 4 | using Microsoft.CodeAnalysis.Text; 5 | using Microsoft.CodeAnalysis.CSharp; 6 | using System.Collections.Generic; 7 | using System.Text; 8 | 9 | namespace AkroGame.ECS.Analyzer 10 | { 11 | [Generator] 12 | public class EngineQueriesGenerator : ISourceGenerator 13 | { 14 | public void Initialize(GeneratorInitializationContext context) { } 15 | 16 | public void Execute(GeneratorExecutionContext context) 17 | { 18 | context.AddSource( 19 | $"EnginesMetadata.g.cs", 20 | GenerateEngineNames(FindQueryInvocations(context)) 21 | ); 22 | } 23 | 24 | private SourceText GenerateEngineNames(List methods) 25 | { 26 | var ns = "Svelto.ECS.Meta"; 27 | var className = "EnginesMetadata"; 28 | var listT = "global::System.Collections.Generic.List"; 29 | var dictT = $"global::System.Collections.Generic.Dictionary"; 30 | return SourceText.From( 31 | CSharpSyntaxTree 32 | .ParseText( 33 | $@" 34 | namespace {ns} 35 | {{ 36 | internal static class {className} 37 | {{ 38 | public static {dictT} QueryInvocations = 39 | new {dictT}() {{ 40 | {string.Join(",\n", methods.Select((invocation, i) => $@" 41 | 42 | {{ 43 | ""{invocation.ClassName}{i}"", 44 | new {listT}() 45 | {{ 46 | {string.Join(",", invocation.Components.Select(x => $@"""{x}"""))} 47 | }} 48 | }} 49 | 50 | "))} 51 | }}; 52 | }} 53 | }} 54 | ".ToString() 55 | ) 56 | .GetRoot() 57 | .NormalizeWhitespace() 58 | .ToFullString(), 59 | Encoding.UTF8 60 | ); 61 | } 62 | 63 | private bool IsQueryCall(SemanticModel semanticModel, InvocationExpressionSyntax invocation) 64 | { 65 | var symbol = semanticModel?.GetSymbolInfo(invocation).Symbol; 66 | if (symbol == null) 67 | return false; 68 | else 69 | return symbol.Name == "QueryEntities" || symbol.Name == "QueryEntity"; 70 | } 71 | 72 | private List ExtractGenericParameters(InvocationExpressionSyntax invocation) 73 | { 74 | var genericArguments = invocation 75 | .DescendantNodes() 76 | .OfType() 77 | .FirstOrDefault(); 78 | 79 | var components = new List(); 80 | if (genericArguments != null) 81 | foreach (var arg in genericArguments.TypeArgumentList.Arguments) 82 | components.Add(arg.ToString()); 83 | return components; 84 | } 85 | 86 | private List FindQueryInvocations(GeneratorExecutionContext context) 87 | { 88 | var allTypes = context.Compilation.SyntaxTrees.SelectMany( 89 | st => st.GetRoot().DescendantNodes().OfType() 90 | ); 91 | 92 | return allTypes 93 | .SelectMany( 94 | t => 95 | { 96 | var semanticModel = context.Compilation.GetSemanticModel(t.SyntaxTree); 97 | return t.DescendantNodes() 98 | .OfType() 99 | .Where(_ => IsQueryCall(semanticModel, _)) 100 | .Select( 101 | _ => 102 | new QueryInvocation( 103 | t.Identifier.ToString(), 104 | ExtractGenericParameters(_) 105 | ) 106 | ); 107 | } 108 | ) 109 | .ToList(); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /backend/src/AkroGame.Ecs.Analyzer/QueryInvocation.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace AkroGame.ECS.Analyzer 4 | { 5 | internal struct QueryInvocation 6 | { 7 | public string ClassName { get; } 8 | public List Components { get; } 9 | 10 | public QueryInvocation(string className, List components) 11 | { 12 | ClassName = className; 13 | Components = components; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /inspector/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /inspector/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelto-ecs-inspector", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://akrogame.github.io/svelto-ecs-inspector", 6 | "license": "MIT", 7 | "type": "module", 8 | "dependencies": { 9 | "@emotion/react": "^11.8.1", 10 | "@emotion/styled": "^11.8.1", 11 | "@fontsource/roboto": "^4.5.3", 12 | "@mui/icons-material": "^5.4.4", 13 | "@mui/lab": "^5.0.0-alpha.71", 14 | "@mui/material": "^5.4.4", 15 | "@testing-library/jest-dom": "^5.16.2", 16 | "@testing-library/react": "^12.1.3", 17 | "@testing-library/user-event": "^13.5.0", 18 | "@types/chart.js": "^2.9.37", 19 | "@types/jest": "^27.4.1", 20 | "@types/node": "^16.11.26", 21 | "@types/react": "^17.0.39", 22 | "@types/react-chartjs-2": "^2.5.7", 23 | "@types/react-dom": "^17.0.13", 24 | "axios": "^0.26.0", 25 | "chart.js": "^4.0.1", 26 | "chartjs-plugin-datalabels": "^2.2.0", 27 | "react": "^18.2.0", 28 | "react-chartjs-2": "^4.3.1", 29 | "react-dom": "^18.2.0", 30 | "react-json-view": "^1.21.3", 31 | "react-json-viewer": "^3.0.1", 32 | "react-query": "^3.34.16", 33 | "react-router": "^6.2.2", 34 | "react-router-dom": "^6.2.2", 35 | "react-scripts": "5.0.0", 36 | "react-use-websocket": "^4.2.0", 37 | "typescript": "^4.6.2", 38 | "web-vitals": "^2.1.4" 39 | }, 40 | "scripts": { 41 | "start": "react-scripts start", 42 | "build": "react-scripts build", 43 | "test": "react-scripts test", 44 | "eject": "react-scripts eject" 45 | }, 46 | "eslintConfig": { 47 | "extends": [ 48 | "react-app", 49 | "react-app/jest" 50 | ] 51 | }, 52 | "browserslist": { 53 | "production": [ 54 | ">0.2%", 55 | "not dead", 56 | "not op_mini all" 57 | ], 58 | "development": [ 59 | "last 1 chrome version", 60 | "last 1 firefox version", 61 | "last 1 safari version" 62 | ] 63 | }, 64 | "devDependencies": {} 65 | } 66 | -------------------------------------------------------------------------------- /inspector/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Single Page Apps for GitHub Pages 6 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /inspector/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Svelto ECS Inspector 10 | 11 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /inspector/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Svelto ECS Inspector", 3 | "name": "Svelto ECS Inspector", 4 | "icons": [ ], 5 | "start_url": ".", 6 | "display": "standalone", 7 | "theme_color": "#000000", 8 | "background_color": "#000000" 9 | } 10 | -------------------------------------------------------------------------------- /inspector/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /inspector/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /inspector/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Container, createTheme, ThemeProvider } from "@mui/material"; 2 | import React from "react"; 3 | import { QueryClient, QueryClientProvider } from "react-query"; 4 | import { HashRouter, Route, Routes } from "react-router-dom"; 5 | import "./App.css"; 6 | import { Dashboard } from "./components/Dashboard"; 7 | import Engines from "./components/Engines"; 8 | import Entities from "./components/Entities"; 9 | import EntityInspector from "./components/EntityInspector"; 10 | import Groups from "./components/Groups"; 11 | import Main from "./layout/Main"; 12 | 13 | const queryClient = new QueryClient(); 14 | 15 | function App() { 16 | const theme = React.useMemo( 17 | () => 18 | createTheme({ 19 | palette: { 20 | mode: "dark", 21 | }, 22 | }), 23 | [] 24 | ); 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | }> 32 | } /> 33 | } /> 34 | }> 35 | } 38 | /> 39 | 40 | } /> 41 | 42 | 43 | 44 | 45 | 46 | 47 | ); 48 | } 49 | 50 | export default App; 51 | -------------------------------------------------------------------------------- /inspector/src/components/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { Box, LinearProgress } from "@mui/material"; 2 | import { useState } from "react"; 3 | import { Bar } from "react-chartjs-2"; 4 | import { Envelope, useInspectorStream } from "../streams/WebSocketHelper"; 5 | import type { ChartData, ChartOptions } from "chart.js"; 6 | import { Chart as ChartJS, registerables } from "chart.js"; 7 | import ChartDataLabels from "chartjs-plugin-datalabels"; 8 | ChartJS.register(...registerables); 9 | ChartJS.register(ChartDataLabels); 10 | 11 | const options: ChartOptions<"bar"> = { 12 | indexAxis: "y" as const, 13 | scales: { 14 | x: { 15 | type: "linear", 16 | position: "top", 17 | }, 18 | }, 19 | elements: { 20 | bar: { 21 | borderWidth: 2, 22 | }, 23 | }, 24 | responsive: true, 25 | maintainAspectRatio: false, 26 | plugins: { 27 | legend: { 28 | display: false, 29 | }, 30 | title: { 31 | display: true, 32 | text: "# Entities in groups", 33 | }, 34 | datalabels: { 35 | color: "white", 36 | font: { 37 | weight: "bold", 38 | }, 39 | }, 40 | }, 41 | }; 42 | 43 | export function Dashboard() { 44 | const [data, setData] = useState< 45 | ChartData<"bar", number[], unknown> | undefined 46 | >(undefined); 47 | 48 | const { sendMessage } = useInspectorStream({ 49 | onMessageReceived: (e: Envelope) => { 50 | if (e.Id !== "dashboard") return; 51 | 52 | const groupCounts = new Map( 53 | Object.entries(e.Payload.Groups) 54 | ); 55 | 56 | const entries = Array.from(groupCounts.entries()).sort( 57 | (a, b) => b[1] - a[1] 58 | ); 59 | 60 | const data = { 61 | labels: entries.map((x) => x[0].split(" ")), 62 | datasets: [ 63 | { 64 | label: "# entities", 65 | data: entries.map((x) => x[1]), 66 | borderColor: "rgb(99, 132, 255)", 67 | backgroundColor: "rgba(99, 132, 255, 0.5)", 68 | }, 69 | ], 70 | }; 71 | 72 | setData(data); 73 | }, 74 | onOpen: () => { 75 | sendMessage("sub dashboard"); 76 | }, 77 | }); 78 | 79 | if (data === undefined) return ; 80 | 81 | return ( 82 | 83 | 84 | 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /inspector/src/components/Engines.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Box from "@mui/material/Box"; 3 | import Typography from "@mui/material/Typography"; 4 | import Autocomplete from "@mui/material/Autocomplete"; 5 | import CircularProgress from "@mui/material/CircularProgress"; 6 | import TextField from "@mui/material/TextField"; 7 | import { styled } from "@mui/material/styles"; 8 | import Paper from "@mui/material/Paper"; 9 | import Masonry from "@mui/lab/Masonry"; 10 | import { Envelope, useInspectorStream } from "../streams/WebSocketHelper"; 11 | 12 | const Item = styled(Paper)(({ theme }) => ({ 13 | backgroundColor: theme.palette.mode === "dark" ? "#1A2027" : "#fff", 14 | ...theme.typography.body2, 15 | padding: theme.spacing(0.5), 16 | textAlign: "center", 17 | color: theme.palette.text.secondary, 18 | })); 19 | 20 | type Engine = { 21 | ClassName: string; 22 | Components: Array; 23 | }; 24 | 25 | const hasComponent = (engine: Engine, component: string) => { 26 | return engine.Components.some((c) => c === component); 27 | }; 28 | 29 | export default function Engines() { 30 | const [data, setData] = React.useState(undefined); 31 | const { sendMessage } = useInspectorStream({ 32 | onMessageReceived: (e: Envelope) => { 33 | if (e.Id !== "engines") return; 34 | 35 | setData(e.Payload); 36 | }, 37 | onOpen: () => { 38 | sendMessage("get-engines"); 39 | }, 40 | }); 41 | 42 | const components = React.useMemo(() => { 43 | const set = 44 | data === undefined 45 | ? new Set() 46 | : new Set(data.flatMap((x) => x.Components)); 47 | return Array.from(set).sort(); 48 | }, [data]); 49 | 50 | const [filter, setFilter] = React.useState([]); 51 | 52 | if (data === undefined) return ; 53 | 54 | if (data.length === 0) 55 | return No systems; 56 | return ( 57 | 58 | 59 | option} 64 | defaultValue={undefined} 65 | onChange={(event, value) => setFilter(value)} 66 | renderInput={(params) => ( 67 | 73 | )} 74 | /> 75 | 76 | 77 | 78 | {data 79 | .filter((x) => filter.every((f) => hasComponent(x, f))) 80 | .map((system, index) => { 81 | if (system.Components === undefined) 82 | console.error("undefined", system); 83 | return ( 84 | 85 | 90 | {system.ClassName} 91 | 92 |
93 | {system.Components.map((component, qi) => { 94 | return ( 95 |
96 | f === component) 101 | ? "bold" 102 | : "initial" 103 | } 104 | color={ 105 | filter.some((f) => f === component) 106 | ? "primary" 107 | : "text.secondary" 108 | } 109 | gutterBottom 110 | > 111 | {component} 112 | 113 |
114 |
115 | ); 116 | })} 117 |
118 | ); 119 | })} 120 |
121 |
122 |
123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /inspector/src/components/Entities.tsx: -------------------------------------------------------------------------------- 1 | import { TextField } from "@mui/material"; 2 | import Typography from "@mui/material/Typography"; 3 | import * as React from "react"; 4 | import { Route, Routes } from "react-router-dom"; 5 | import EntityInspector from "./EntityInspector"; 6 | import EntityList from "./EntityList"; 7 | 8 | function NoEntitySelected() { 9 | return ( 10 | 11 | Please select an entity 12 | 13 | ); 14 | } 15 | 16 | export default function Entities() { 17 | const [searchQuery, setSearchQuery] = React.useState( 18 | undefined 19 | ); 20 | const updateSearchQuery = (event: React.ChangeEvent) => { 21 | if (event.target.value === "") setSearchQuery(undefined); 22 | else setSearchQuery(event.target.value); 23 | }; 24 | return ( 25 |
26 | 32 | 33 |
34 | 35 | } /> 36 | } /> 37 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /inspector/src/components/EntityInspector.tsx: -------------------------------------------------------------------------------- 1 | import Masonry from "@mui/lab/Masonry"; 2 | import CircularProgress from "@mui/material/CircularProgress"; 3 | import Paper from "@mui/material/Paper"; 4 | import { styled } from "@mui/material/styles"; 5 | import Typography from "@mui/material/Typography"; 6 | import * as React from "react"; 7 | import ReactJson, { InteractionProps } from "react-json-view"; 8 | import { useParams } from "react-router-dom"; 9 | import { Envelope, useInspectorStream } from "../streams/WebSocketHelper"; 10 | 11 | const Item = styled(Paper)(({ theme }) => ({ 12 | backgroundColor: theme.palette.mode === "dark" ? "#1A2027" : "#fff", 13 | ...theme.typography.body2, 14 | padding: theme.spacing(0.5), 15 | textAlign: "left", 16 | color: theme.palette.text.secondary, 17 | maxWidth: 400, 18 | })); 19 | 20 | type EntityComponent = { 21 | name: string; 22 | prettyName: string; 23 | data: any; 24 | }; 25 | export default function EntityInspector() { 26 | const params = useParams(); 27 | const groupId = params.groupId; 28 | const entityId = params.entityId; 29 | const [data, setData] = React.useState( 30 | undefined 31 | ); 32 | // const { isError, isLoading, data, error } = useQuery< 33 | // EntityComponents, 34 | // AxiosError 35 | // >( 36 | // ["entity-inspector", groupId, entityId], 37 | // async () => { 38 | // const x = await axios.get( 39 | // `/debug/group/${groupId}/entity/${entityId}` 40 | // ); 41 | // return x.data; 42 | // }, 43 | // { 44 | // refetchInterval: 100, 45 | // } 46 | // ); 47 | const { sendMessage, isOpen } = useInspectorStream({ 48 | onMessageReceived: (e: Envelope) => { 49 | if (e.Id !== "entity-data") return; 50 | var result: Array = []; 51 | 52 | for (var i in e.Payload) 53 | result.push({ 54 | name: i, 55 | prettyName: e.Payload[i].PrettyName, 56 | data: e.Payload[i].Data, 57 | }); 58 | 59 | setData(result); 60 | }, 61 | onOpen: () => {}, 62 | }); 63 | 64 | React.useEffect(() => { 65 | if (isOpen) sendMessage(`sub entity-data ${groupId} ${entityId}`); 66 | return () => { 67 | sendMessage(`un-sub entity-data ${groupId} ${entityId}`); 68 | }; 69 | }, [isOpen, sendMessage, groupId, entityId]); 70 | 71 | if (data === undefined) return ; 72 | 73 | if (groupId === undefined || entityId === undefined) 74 | return ( 75 | 76 | You need to provide an entityId and a groupId to use this component 77 | 78 | ); 79 | 80 | if (data.length === 0) 81 | return No Entities; 82 | return ( 83 |
84 | Showing entity: {entityId} 85 | 86 | {data.map((component, index) => { 87 | return ( 88 | 97 | ); 98 | })} 99 | 100 |
101 | ); 102 | } 103 | 104 | type EditableEntityComponentProps = { 105 | data: any; 106 | name: string; 107 | prettyName: string; 108 | groupId: string; 109 | entityId: string; 110 | sendMessage: (msg: string) => void; 111 | }; 112 | 113 | const EditableEntityComponent = React.memo( 114 | ({ 115 | groupId, 116 | entityId, 117 | data, 118 | prettyName, 119 | name, 120 | sendMessage, 121 | }: EditableEntityComponentProps) => { 122 | const editCommit = React.useCallback( 123 | (edit: InteractionProps) => { 124 | sendMessage( 125 | `update ${groupId} ${entityId} ${name} ${JSON.stringify( 126 | edit.updated_src 127 | )}` 128 | ); 129 | return true; 130 | }, 131 | [sendMessage, groupId, entityId, name] 132 | ); 133 | return ( 134 | 135 | 142 | 143 | ); 144 | }, 145 | (prevProps, nextProps) => 146 | prevProps.name === nextProps.name && 147 | JSON.stringify(prevProps.data) === JSON.stringify(nextProps.data) 148 | ); 149 | -------------------------------------------------------------------------------- /inspector/src/components/EntityList.tsx: -------------------------------------------------------------------------------- 1 | import Masonry from "@mui/lab/Masonry"; 2 | import CircularProgress from "@mui/material/CircularProgress"; 3 | import Link from "@mui/material/Link"; 4 | import Paper from "@mui/material/Paper"; 5 | import { styled } from "@mui/material/styles"; 6 | import Typography from "@mui/material/Typography"; 7 | import * as React from "react"; 8 | import { Link as RouterLink } from "react-router-dom"; 9 | import { Envelope, useInspectorStream } from "../streams/WebSocketHelper"; 10 | 11 | const Item = styled(Paper)(({ theme }) => ({ 12 | backgroundColor: theme.palette.mode === "dark" ? "#1A2027" : "#fff", 13 | ...theme.typography.body2, 14 | padding: theme.spacing(0.5), 15 | textAlign: "center", 16 | color: theme.palette.text.secondary, 17 | })); 18 | 19 | type GroupDataStreamItem = { 20 | Name: string; 21 | Entities: number[]; 22 | }; 23 | 24 | const GroupText = styled(Typography)({ 25 | fontSize: 12, 26 | color: "text.secondary", 27 | }); 28 | 29 | const groups = (m: Map) => { 30 | const r: Array = []; 31 | m.forEach((group: GroupDataStreamItem, groupId: number) => { 32 | r.push( 33 | 39 | ); 40 | }); 41 | return r; 42 | }; 43 | 44 | export type EntityListProps = { 45 | searchQuery: string | undefined; 46 | }; 47 | 48 | export default function EntityList({ searchQuery }: EntityListProps) { 49 | const [data, setData] = React.useState< 50 | Map | undefined 51 | >(undefined); 52 | 53 | const { sendMessage, isOpen } = useInspectorStream({ 54 | onMessageReceived: (e: Envelope>) => { 55 | if (e.Id !== "entities") return; 56 | 57 | const newMap = new Map( 58 | Object.entries(e.Payload) 59 | ); 60 | 61 | setData((existingData) => { 62 | const d = 63 | existingData === undefined 64 | ? new Map() 65 | : existingData; 66 | 67 | d.forEach((_, k) => { 68 | if (!newMap.has(k.toString())) d.delete(k); 69 | }); 70 | 71 | newMap.forEach((v, k) => { 72 | const id = parseInt(k); 73 | d.set(id, v); 74 | }); 75 | 76 | return d; 77 | }); 78 | }, 79 | onOpen: () => {}, 80 | }); 81 | 82 | React.useEffect(() => { 83 | if (isOpen) 84 | sendMessage( 85 | `sub entities ${ 86 | searchQuery === undefined ? "" : encodeURI(searchQuery) 87 | }` 88 | ); 89 | return () => { 90 | sendMessage(`un-sub entities`); 91 | }; 92 | }, [sendMessage, searchQuery, isOpen]); 93 | if (data === undefined) return ; 94 | 95 | if (data.size === 0) 96 | return No Entities; 97 | return ( 98 |
99 | 100 | {groups(data)} 101 | 102 |
103 | ); 104 | } 105 | 106 | type GroupedEntitiesProps = { 107 | groupId: number; 108 | name: string; 109 | entities: number[]; 110 | }; 111 | const GroupedEntities = React.memo( 112 | ({ groupId, name, entities }: GroupedEntitiesProps) => { 113 | return ( 114 | 115 | 116 | [{groupId}]: {name} 117 | 118 |
119 | {entities.map((entity) => { 120 | return ( 121 | 126 | ); 127 | })} 128 |
129 | ); 130 | } 131 | ); 132 | 133 | const EntityLinkText = styled(Typography)({ 134 | fontSize: 10, 135 | color: "text.secondary", 136 | }); 137 | type EntityLinkProps = { 138 | groupId: number; 139 | entity: number; 140 | }; 141 | const EntityLink = React.memo(({ groupId, entity }: EntityLinkProps) => { 142 | return ( 143 | 144 | 145 | {entity} 146 | 147 | 148 | ); 149 | }); 150 | -------------------------------------------------------------------------------- /inspector/src/components/Groups.tsx: -------------------------------------------------------------------------------- 1 | import Masonry from "@mui/lab/Masonry"; 2 | import CircularProgress from "@mui/material/CircularProgress"; 3 | import Paper from "@mui/material/Paper"; 4 | import { styled } from "@mui/material/styles"; 5 | import Typography from "@mui/material/Typography"; 6 | import { useState } from "react"; 7 | import { Envelope, useInspectorStream } from "../streams/WebSocketHelper"; 8 | 9 | const Item = styled(Paper)(({ theme }) => ({ 10 | backgroundColor: theme.palette.mode === "dark" ? "#1A2027" : "#fff", 11 | ...theme.typography.body2, 12 | padding: theme.spacing(0.5), 13 | textAlign: "center", 14 | color: theme.palette.text.secondary, 15 | })); 16 | 17 | type Group = { 18 | name: string; 19 | id: number; 20 | components: string[]; 21 | }; 22 | 23 | const renderComponents = (group: Group) => { 24 | if (group.components === undefined) 25 | return ( 26 | 32 | No component found 33 | 34 | ); 35 | else 36 | return group.components.map((component, ci) => { 37 | return ( 38 | 46 | {component} 47 | 48 | ); 49 | }); 50 | }; 51 | 52 | export default function Groups() { 53 | const [data, setData] = useState(undefined); 54 | 55 | const { sendMessage } = useInspectorStream({ 56 | onMessageReceived: (e: Envelope) => { 57 | if (e.Id !== "groups") return; 58 | var result: Array = []; 59 | 60 | for (var i in e.Payload) 61 | result.push({ 62 | name: i, 63 | id: 0, 64 | components: e.Payload[i].sort((a: any, b: any) => a.localeCompare(b)), 65 | }); 66 | 67 | setData(result); 68 | }, 69 | onOpen: () => { 70 | sendMessage("sub groups"); 71 | }, 72 | }); 73 | if (data === undefined) return ; 74 | 75 | if (data.length === 0) 76 | return No groups; 77 | return ( 78 | 79 | {data.map((group, index) => { 80 | return ( 81 | 82 | 87 | [{group.id}]: {group.name} 88 | 89 |
90 | {renderComponents(group)} 91 |
92 | ); 93 | })} 94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /inspector/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /inspector/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | import '@fontsource/roboto/300.css'; 7 | import '@fontsource/roboto/400.css'; 8 | import '@fontsource/roboto/500.css'; 9 | import '@fontsource/roboto/700.css'; 10 | 11 | ReactDOM.render( 12 | 13 | 14 | , 15 | document.getElementById('root') 16 | ); 17 | 18 | reportWebVitals(); 19 | -------------------------------------------------------------------------------- /inspector/src/layout/Main.tsx: -------------------------------------------------------------------------------- 1 | import DescriptionIcon from "@mui/icons-material/Description"; 2 | import HomeIcon from "@mui/icons-material/Home"; 3 | import PeopleIcon from "@mui/icons-material/People"; 4 | import TwoWheelerIcon from "@mui/icons-material/TwoWheeler"; 5 | import AppBar from "@mui/material/AppBar"; 6 | import Autocomplete from "@mui/material/Autocomplete"; 7 | import Box from "@mui/material/Box"; 8 | import CssBaseline from "@mui/material/CssBaseline"; 9 | import Divider from "@mui/material/Divider"; 10 | import Drawer from "@mui/material/Drawer"; 11 | import List from "@mui/material/List"; 12 | import ListItemButton from "@mui/material/ListItemButton"; 13 | import ListItemIcon from "@mui/material/ListItemIcon"; 14 | import ListItemText from "@mui/material/ListItemText"; 15 | import TextField from "@mui/material/TextField"; 16 | import Toolbar from "@mui/material/Toolbar"; 17 | import Typography from "@mui/material/Typography"; 18 | import axios from "axios"; 19 | import * as React from "react"; 20 | import { 21 | NavLink, 22 | Outlet, 23 | useLocation, 24 | useSearchParams, 25 | } from "react-router-dom"; 26 | 27 | const drawerWidth = 260; 28 | 29 | const pages = [ 30 | { 31 | display: "Summary", 32 | link: "", 33 | icon: , 34 | }, 35 | { 36 | display: "Groups", 37 | link: "groups", 38 | icon: , 39 | }, 40 | { 41 | display: "Entities", 42 | link: "entities", 43 | icon: , 44 | }, 45 | { 46 | display: "Engines", 47 | link: "engines", 48 | icon: , 49 | }, 50 | ]; 51 | 52 | export const ServerContext = React.createContext("localhost:9300"); 53 | 54 | const servers = ["localhost:9300", "localhost:9301", "localhost:9302"]; 55 | 56 | export default function PermanentDrawerLeft() { 57 | const [searchParams, setSearchParams] = useSearchParams(); 58 | const location = useLocation(); 59 | console.log(location); 60 | 61 | const findPage = () => { 62 | const p = pages.findIndex( 63 | (x, i) => i > 0 && location.pathname.startsWith(`/${x.link}`) 64 | ); 65 | return p < 0 ? 0 : p; 66 | }; 67 | 68 | const [currentPage, setCurrentPage] = React.useState(findPage()); 69 | console.log(currentPage); 70 | if (!searchParams.has("url")) setSearchParams({ url: servers[0] }); 71 | const urlFromParam = searchParams.get("url") || servers[0]; 72 | const [url, setUrl] = React.useState(urlFromParam); 73 | React.useEffect(() => { 74 | axios.defaults.baseURL = url; 75 | }, [url]); 76 | return ( 77 | 78 | 79 | 83 | 84 | 85 | {pages[currentPage].display} 86 | 87 | 88 | 89 | 101 | 102 | ( 107 | 111 | )} 112 | onChange={(_, newValue) => { 113 | const url = newValue === null ? "" : newValue; 114 | setUrl(url); 115 | setSearchParams({ 116 | url: url, 117 | }); 118 | }} 119 | /> 120 | 121 | 122 | 123 | {pages.map((page, index) => ( 124 | setCurrentPage(index)} 126 | component={NavLink} 127 | key={index} 128 | to={page.link} 129 | selected={currentPage === index} 130 | > 131 | {page.icon} 132 | 133 | 134 | ))} 135 | 136 | 137 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | ); 148 | } 149 | -------------------------------------------------------------------------------- /inspector/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /inspector/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /inspector/src/streams/WebSocketHelper.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useEffect } from "react"; 2 | import useWebSocket, { ReadyState } from "react-use-websocket"; 3 | import { ServerContext } from "../layout/Main"; 4 | 5 | export interface Envelope { 6 | Id: string; 7 | Payload: T; 8 | } 9 | export interface InspectorStreamOptions { 10 | onMessageReceived: (e: Envelope) => void; 11 | onOpen: () => void; 12 | } 13 | const enc = new TextEncoder(); 14 | export function useInspectorStream({ 15 | onOpen: propOnOpen, 16 | onMessageReceived: propMessageReceived, 17 | }: InspectorStreamOptions) { 18 | const serverAddr = useContext(ServerContext); 19 | const onMessageReceived = useCallback( 20 | async (msg: MessageEvent) => { 21 | const txt = await (msg.data as Blob).text(); 22 | const e = JSON.parse(txt) as Envelope; 23 | propMessageReceived(e); 24 | }, 25 | [propMessageReceived] 26 | ); 27 | 28 | const { sendMessage, readyState } = useWebSocket(`ws://${serverAddr}`, { 29 | share: true, 30 | onOpen: () => { 31 | //console.log("opened"); 32 | //opts.onOpen(); 33 | }, 34 | //Will attempt to reconnect on all close events, such as server shutting down 35 | shouldReconnect: (closeEvent) => true, 36 | reconnectInterval: 2000, 37 | onMessage: onMessageReceived, 38 | }); 39 | 40 | useEffect(() => { 41 | if (readyState === ReadyState.OPEN) propOnOpen(); 42 | }, [propOnOpen, readyState]); 43 | 44 | const sendStringMessage = useCallback( 45 | (s: string) => { 46 | sendMessage(enc.encode(s)); 47 | }, 48 | [sendMessage] 49 | ); 50 | 51 | return { 52 | sendMessage: sendStringMessage, 53 | readyState, 54 | isOpen: readyState === ReadyState.OPEN, 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /inspector/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "outDir": "dist", 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx" 19 | }, 20 | "exclude": ["node_modules"], 21 | "include": ["src/**/*"] 22 | } 23 | --------------------------------------------------------------------------------