Compare commits

...

137 Commits

Author SHA1 Message Date
d8f0833de5 Merge pull request 'main' (#9) from main into release/polylan-05-26
All checks were successful
Create tag and build when new code gets to main / ReleaseName (push) Successful in 4s
Create tag and build when new code gets to main / Release (push) Successful in 14m37s
Reviewed-on: #9
2026-05-17 11:01:58 +00:00
3cdb7e85c3 removed dependency on Forge Attribute Set node
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 35s
Create tag and build when new code gets to main / Export (push) Successful in 7m50s
2026-05-17 12:44:54 +02:00
7746a40542 trying manual attributeset attribution
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 25s
Create tag and build when new code gets to main / Export (push) Successful in 7m49s
2026-05-17 11:28:26 +02:00
33c0088ff8 removing old resources 2026-05-17 11:11:52 +02:00
b2ab80c54c trying to remove rider plugin before CI build
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 29s
Create tag and build when new code gets to main / Export (push) Successful in 7m46s
2026-05-17 11:08:41 +02:00
06ef5d892b test
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 25s
Create tag and build when new code gets to main / Export (push) Successful in 7m18s
2026-05-17 02:32:06 +02:00
e09714cf83 update forge
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 27s
Create tag and build when new code gets to main / Export (push) Successful in 7m25s
2026-05-17 00:06:44 +02:00
8b54f00814 trying workaround
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 25s
Create tag and build when new code gets to main / Export (push) Successful in 7m0s
2026-05-16 23:52:05 +02:00
415897b7b0 debug
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 24s
Create tag and build when new code gets to main / Export (push) Successful in 8m17s
2026-05-16 21:57:04 +02:00
d302d75238 c for crouching
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 24s
Create tag and build when new code gets to main / Export (push) Successful in 5m33s
Create tag and build when new code gets to main / ReleaseName (push) Successful in 3s
Create tag and build when new code gets to main / Release (push) Successful in 14m28s
2026-05-16 20:58:11 +02:00
dc81796d52 fixed cue issue and setup proper waves
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 26s
Create tag and build when new code gets to main / Export (push) Has been cancelled
2026-05-16 20:56:20 +02:00
2103832e46 all spawners available can be used on first wave tick 2026-05-16 19:56:07 +02:00
1898d91a28 wave behavior and fixed explosion 2026-05-16 19:48:48 +02:00
b3ae3e37ea fixed weapon loss bug 2026-05-16 13:19:01 +02:00
4cd67023d9 token manager for projectile 2026-05-16 01:29:02 +02:00
afa335e7bf stunnable targets on hit 2026-05-15 15:29:24 +02:00
a0e99a959f parrying projectiles
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 28s
Create tag and build when new code gets to main / Export (push) Successful in 6m44s
2026-05-14 16:11:22 +02:00
0cd942d90e homing projectiles 2026-05-10 12:26:43 +02:00
150e007b22 basic projectiles
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 26s
Create tag and build when new code gets to main / Export (push) Successful in 5m9s
2026-05-07 14:53:30 +02:00
01a2e7582b enemy grant hit ability + prep projectiles 2026-05-06 19:55:05 +02:00
7ba4a3db3f fixed a few issues
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 30s
Create tag and build when new code gets to main / Export (push) Successful in 6m0s
2026-05-06 16:25:56 +02:00
bcc748ca6b More weapon events and abilities 2026-05-06 11:05:55 +02:00
1db30eafd9 removed obsolete references and maps, fixed menu hide pause issue 2026-05-05 17:04:09 +02:00
68e36742af removed obsolete interfaces for health and damage
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 27s
Create tag and build when new code gets to main / Export (push) Successful in 5m25s
2026-05-05 11:51:35 +02:00
33f55d04f3 made explosion forge compliant 2026-05-05 10:55:12 +02:00
a139990390 knockback forge implemented 2026-05-04 16:22:30 +02:00
b2b7baffe8 making dash through target a dedicated dash action 2026-05-04 13:22:25 +02:00
bed1384dc7 fixed inventory + using Sync bindings
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 57s
Create tag and build when new code gets to main / Export (push) Successful in 5m13s
2026-05-04 10:19:00 +02:00
99f383be00 Damage dealing through meta attribute and custom exec
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 30s
Create tag and build when new code gets to main / Export (push) Successful in 6m8s
2026-05-03 10:47:56 +02:00
631935fdc8 added meta attribute set 2026-05-02 16:45:27 +02:00
852f265b9f made weapon into a dependant and used new forgeneitty node as a node3d 2026-05-02 16:13:23 +02:00
0e6211943d new forge entity node to benefit from autoinject 2026-05-02 15:24:28 +02:00
fb30a08b89 hitting is now an ability 2026-05-02 11:19:56 +02:00
24f057c15f used DI for forge managers where possible 2026-04-28 16:34:10 +02:00
ec44306d48 forge friendlier health and damage management
Removed knockback though
2026-04-28 11:22:24 +02:00
dcfd937e53 moved forge resources around 2026-04-26 18:19:45 +02:00
cd7a230615 made the initial inventory loadout into a resource to initialize the injected dependency with 2026-04-26 17:38:25 +02:00
319cbf722e changed ci
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 26s
Create tag and build when new code gets to main / OtherTest (push) Failing after 9m40s
Create tag and build when new code gets to main / Export (push) Successful in 9m16s
2026-04-26 12:45:33 +02:00
bb21920488 changed runsettings and workflow to always publish test reports
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 32s
Create tag and build when new code gets to main / Export (push) Successful in 9m31s
Create tag and build when new code gets to main / OtherTest (push) Failing after 11m57s
2026-04-26 12:24:51 +02:00
cd2b41b443 fix ci
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 28s
Create tag and build when new code gets to main / Export (push) Successful in 8m58s
Create tag and build when new code gets to main / OtherTest (push) Failing after 11m43s
2026-04-26 11:52:14 +02:00
d025618eb3 retrying new CI
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 29s
Create tag and build when new code gets to main / OtherTest (push) Failing after 1m47s
Create tag and build when new code gets to main / Export (push) Successful in 5m54s
2026-04-26 11:35:45 +02:00
26f6a619cb dependency injection test
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 28s
Create tag and build when new code gets to main / Export (push) Successful in 6m50s
2026-04-25 10:18:41 +02:00
ce48f3b9d7 fixed indent issue
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 1m21s
Create tag and build when new code gets to main / OtherTest (push) Failing after 1m32s
Create tag and build when new code gets to main / Export (push) Successful in 6m53s
Create tag and build when new code gets to main / Test (push) Failing after 8m42s
2026-04-24 19:07:20 +02:00
54796252ce retrying to CI tests
Some checks failed
Create tag and build when new code gets to main / Export (push) Has been cancelled
Create tag and build when new code gets to main / BumpTag (push) Has been cancelled
2026-04-24 19:06:31 +02:00
054115aa89 trying out autoinject on the CI
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 26s
Create tag and build when new code gets to main / Export (push) Successful in 5m46s
2026-04-24 18:48:10 +02:00
f5e47e9f5e minor fixes
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 26s
Create tag and build when new code gets to main / Export (push) Successful in 8m50s
2026-04-22 09:47:15 +02:00
99ed6375a2 fix: enemies dying from falling would remove the weapon from the world. Thrown weapon now respawn like the player. 2026-04-22 08:43:58 +02:00
6888499b78 ground slam damage depends on height
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 26s
Create tag and build when new code gets to main / Export (push) Successful in 7m2s
2026-04-21 18:47:58 +02:00
2a98137653 main scene back to main menu
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 28s
Create tag and build when new code gets to main / Export (push) Successful in 6m6s
2026-04-21 11:40:05 +02:00
b0fe2549ea Inventory management of granted abilities 2026-04-21 11:38:04 +02:00
667d6b2588 Starting an inventory manager 2026-04-20 11:41:22 +02:00
f9ca56e34a removed jump dash bug issue hand set back main scene to opening
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 25s
Create tag and build when new code gets to main / ReleaseName (push) Successful in 6s
Create tag and build when new code gets to main / Export (push) Successful in 7m10s
Create tag and build when new code gets to main / Release (push) Successful in 15m55s
2026-04-19 13:16:13 +02:00
5a59d50be5 Made a menu to select abilities and grant them (with a few hardcoded stuff)
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 26s
Create tag and build when new code gets to main / Export (push) Successful in 6m13s
2026-04-19 11:37:55 +02:00
9464fc7caa removed editor granted weapon abilities and prepared granting abilites through an inventory 2026-04-18 15:44:59 +02:00
9e57641a75 removend empower actions counter and references to previous tutorial 2026-04-18 09:41:38 +02:00
bb2b2ace06 cleanup and changed empowered action cost 2026-04-18 09:28:21 +02:00
585c2302d6 actually calling update effects
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 30s
Create tag and build when new code gets to main / Export (push) Successful in 6m46s
2026-04-14 19:29:43 +02:00
7ab78aa57f putting back Tool 2026-04-13 17:22:26 +02:00
4d10f4e9d7 some few fixes that don't fix anything 2026-04-13 16:34:23 +02:00
1d856fd937 Replicated the weapon flying tick setup using resources
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 26s
Create tag and build when new code gets to main / Export (push) Successful in 5m42s
2026-04-07 16:32:26 +02:00
cc7cb90041 Moving to Godot 4.6.2
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 22s
Create tag and build when new code gets to main / Export (push) Successful in 5m26s
2026-04-04 12:29:15 +02:00
7a787a36d6 Moved the exploding sword forge object from the code only hardcoded stuff to the resource based stuff
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 24s
Create tag and build when new code gets to main / Export (push) Failing after 3m55s
2026-04-04 12:06:48 +02:00
bfa1f251dd Replaced the entire mana usage and inhibition with the provided forge resources 2026-04-03 16:35:15 +02:00
673368a200 Added rider plugin and turned Empowered Action into a forge-resources-managed ability 2026-04-03 15:33:46 +02:00
c1108e96d7 moving further through forge godot-available resources and interfaces
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 25s
Create tag and build when new code gets to main / Export (push) Failing after 3h11m2s
2026-04-01 15:53:38 +02:00
15cb80d045 Using provided ForgeManager singleton and forge_data resource for tags 2026-04-01 15:07:28 +02:00
1d298b3080 more encapsulated effect application 2026-04-01 10:00:22 +02:00
42ff38f39b yow it's working or wat
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 24s
Create tag and build when new code gets to main / Export (push) Has been cancelled
2026-03-29 17:30:14 +02:00
dafb0c96cc fix manabar cue issue 2026-03-28 18:40:03 +01:00
ef454e9502 Trying custom execution periodic data 2026-03-28 18:20:47 +01:00
cc70fb361b WIP: integrating forge systems into the game, now trying periodic abilities 2026-03-28 11:43:34 +01:00
7bf19868e7 Setup the base for abilities and events
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 24s
Create tag and build when new code gets to main / Export (push) Successful in 5m6s
2026-03-22 16:28:57 +01:00
d1f83525b1 updating mana through cues 2026-03-18 16:59:52 +01:00
4bcbda9690 fix: inputs were eaten by a tutorial text because of node ordering
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 24s
Create tag and build when new code gets to main / Export (push) Successful in 5m12s
Create tag and build when new code gets to main / ReleaseName (push) Successful in 3s
Create tag and build when new code gets to main / Release (push) Successful in 13m52s
2026-03-18 11:10:06 +01:00
e51ef5a517 probably fixed stuttering of the camera and weapon animations 2026-03-18 11:02:08 +01:00
50de6abb5d mana bar 2026-03-15 21:26:59 +01:00
95616f61fc Implemented mana regeneration
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 40s
Create tag and build when new code gets to main / Export (push) Successful in 5m18s
2026-03-11 16:29:09 +01:00
b15a4fef95 empowered action as a forge ability 2026-03-11 15:56:17 +01:00
14d29d68bb Setup empowered action as a Forge ability 2026-03-10 09:22:39 +01:00
9d612682ec created a normal process function
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 41s
Create tag and build when new code gets to main / Export (push) Successful in 4m39s
2026-03-08 18:12:57 +01:00
9bfe37af62 trying cache
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 30s
Create tag and build when new code gets to main / Export (push) Successful in 4m56s
2026-03-08 10:08:06 +01:00
55eba7fcc8 trying to remove xunit
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 27s
Create tag and build when new code gets to main / Export (push) Failing after 3m37s
2026-03-08 09:49:18 +01:00
7a3e61b86f added lights in dark tutorial area 2026-03-08 09:46:33 +01:00
8153ec07e7 Revert "removing tests because they might break the solution"
This reverts commit 3a21f00528.
2026-03-08 09:44:02 +01:00
ddc85655be Revert "removed internal"
This reverts commit 5408f455af.
2026-03-08 09:43:38 +01:00
c92eb19a1c Revert "removed null!"
This reverts commit 290f79afd4.
2026-03-08 09:43:12 +01:00
290f79afd4 removed null!
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 20s
Create tag and build when new code gets to main / Export (push) Successful in 4m52s
2026-02-26 19:18:27 +01:00
5408f455af removed internal
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 20s
Create tag and build when new code gets to main / Export (push) Successful in 5m2s
2026-02-26 18:56:50 +01:00
3a21f00528 removing tests because they might break the solution
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 21s
Create tag and build when new code gets to main / Export (push) Successful in 4m50s
2026-02-26 18:42:46 +01:00
22e8c27878 trying somthing
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 20s
Create tag and build when new code gets to main / Export (push) Successful in 5m3s
2026-02-26 18:36:18 +01:00
6c4454848a removed something that has nothing to do on the vcs
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 25s
Create tag and build when new code gets to main / Export (push) Successful in 4m52s
2026-02-26 18:15:50 +01:00
175e67d2d6 export fix
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 21s
Create tag and build when new code gets to main / Export (push) Successful in 4m53s
2026-02-26 17:55:02 +01:00
ab69fa9323 trying to fix export
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 23s
Create tag and build when new code gets to main / Export (push) Successful in 5m24s
2026-02-26 17:36:33 +01:00
263990b086 more tuts
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 21s
Create tag and build when new code gets to main / Export (push) Successful in 5m8s
Create tag and build when new code gets to main / ReleaseName (push) Successful in 4s
Create tag and build when new code gets to main / Release (push) Successful in 10m59s
2026-02-25 10:27:28 +01:00
5da2aa31ab basic forge setup and refactored for some warning
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 30s
Create tag and build when new code gets to main / Export (push) Successful in 5m10s
2026-02-24 10:27:57 +01:00
4f64139d61 removed useless stuff
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 24s
Create tag and build when new code gets to main / Export (push) Successful in 4m59s
2026-02-23 15:29:08 +01:00
de41bbeb8d some other tests
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 20s
Create tag and build when new code gets to main / Export (push) Successful in 5m5s
2026-02-23 15:22:57 +01:00
a4873f183c back with more tests 2026-02-22 17:25:45 +01:00
d37ae8d26c remove tests from ci for now because the loading of the runtime is broken and 2dog might change that, but when it's available 2026-02-22 17:24:12 +01:00
5227fedf15 trying to fix CI
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 19s
Create tag and build when new code gets to main / Export (push) Successful in 6m2s
Create tag and build when new code gets to main / Test (push) Failing after 8m46s
2026-02-22 17:13:51 +01:00
c9738d9c61 bis
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 20s
Create tag and build when new code gets to main / Export (push) Successful in 6m8s
Create tag and build when new code gets to main / Test (push) Failing after 8m43s
2026-02-22 16:49:58 +01:00
f3eea3f171 trying to fix CI
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 19s
Create tag and build when new code gets to main / Export (push) Successful in 7m35s
Create tag and build when new code gets to main / Test (push) Failing after 9m25s
2026-02-22 16:25:17 +01:00
96b4fa7197 trying to fix CI
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 23s
Create tag and build when new code gets to main / Export (push) Successful in 7m9s
Create tag and build when new code gets to main / Test (push) Failing after 5m6s
2026-02-22 16:06:04 +01:00
8d2fe1001e trying to fix CI
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 22s
Create tag and build when new code gets to main / Export (push) Successful in 7m29s
Create tag and build when new code gets to main / Test (push) Failing after 9m58s
2026-02-22 15:25:15 +01:00
25aec40d03 trying to fix CI
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 22s
Create tag and build when new code gets to main / Test (push) Failing after 7m33s
Create tag and build when new code gets to main / Export (push) Successful in 8m6s
2026-02-21 10:56:07 +01:00
80306bd095 trying to fix CI
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 23s
Create tag and build when new code gets to main / Test (push) Failing after 6m45s
Create tag and build when new code gets to main / Export (push) Successful in 7m53s
2026-02-20 18:08:16 +01:00
9207295a99 basic tests for a wider variety of files
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 20s
Create tag and build when new code gets to main / Test (push) Failing after 7m29s
Create tag and build when new code gets to main / Export (push) Successful in 8m18s
2026-02-20 16:58:17 +01:00
4474ba22fa added xunit and a few tests with gdunit 2026-02-20 15:41:43 +01:00
6101406f45 trying out something to fix CI
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 22s
Create tag and build when new code gets to main / Test (push) Failing after 6m50s
Create tag and build when new code gets to main / Export (push) Successful in 8m1s
2026-02-17 23:22:03 +01:00
c792c98ad2 fixed a flaky test and let the godot bin be defined by env variable
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 18s
Create tag and build when new code gets to main / Test (push) Failing after 7m4s
Create tag and build when new code gets to main / Export (push) Successful in 7m53s
2026-02-17 23:08:10 +01:00
74876a9a5d started proper testing
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 19s
Create tag and build when new code gets to main / Test (push) Failing after 6m42s
Create tag and build when new code gets to main / Export (push) Successful in 8m7s
2026-02-17 22:51:58 +01:00
bbb5149184 made it that inputting a direction when hanging makes us leave the wall
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 22s
Create tag and build when new code gets to main / Test (push) Successful in 7m25s
Create tag and build when new code gets to main / Export (push) Successful in 8m55s
Create tag and build when new code gets to main / ReleaseName (push) Successful in 3s
Create tag and build when new code gets to main / Release (push) Successful in 16m7s
2026-02-17 15:33:07 +01:00
9e39528b9a redid tutorials 2026-02-17 15:28:04 +01:00
1d2290b025 new tuto triggers ready to be setup 2026-02-17 11:24:14 +01:00
b9ae83cd92 finally cleaned up input method detection
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 21s
Create tag and build when new code gets to main / Test (push) Successful in 6m46s
Create tag and build when new code gets to main / Export (push) Successful in 8m3s
2026-02-16 23:15:25 +01:00
759d972b6d removed automatic itch upload on every main run because it costs too much
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 20s
Create tag and build when new code gets to main / Test (push) Successful in 6m54s
Create tag and build when new code gets to main / Export (push) Successful in 8m21s
2026-02-13 19:21:44 +01:00
4df4585149 fixed escape menu input on keyboard
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 21s
Create tag and build when new code gets to main / Test (push) Successful in 7m14s
Create tag and build when new code gets to main / Export (push) Successful in 8m46s
2026-02-13 15:20:54 +01:00
88b0911c6c removed wall run tutorial and fixed wall run leave auto dash 2026-02-13 15:00:25 +01:00
81ce45a0dc Merge branch 'main' of https://git.game-dev.space/minimata/MovementTests
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 21s
Create tag and build when new code gets to main / Test (push) Successful in 7m13s
Create tag and build when new code gets to main / Export (push) Successful in 9m11s
2026-02-13 12:32:12 +01:00
1caf202310 fine tuned wall jump and run again 2026-02-13 12:31:51 +01:00
22c4301244 fix forge issue 2026-02-13 11:11:03 +01:00
032e059826 wall run is easier to control and triggers more consistently 2026-02-13 10:24:25 +01:00
a8683b77e8 Update .gitea/workflows/release-branch.yaml
Some checks failed
Create tag and build when new code gets to main / Export (push) Has been cancelled
Create tag and build when new code gets to main / Test (push) Has been cancelled
Create tag and build when new code gets to main / BumpTag (push) Has been cancelled
Create tag and build when new code gets to main / ReleaseName (push) Successful in 31s
Create tag and build when new code gets to main / Release (push) Successful in 15m21s
2026-02-11 18:46:16 +00:00
90e6cdbcde Update .gitea/workflows/release-branch.yaml
Some checks failed
Create tag and build when new code gets to main / Export (push) Has been cancelled
Create tag and build when new code gets to main / BumpTag (push) Has been cancelled
Create tag and build when new code gets to main / Test (push) Has been cancelled
Create tag and build when new code gets to main / ReleaseName (push) Successful in 3s
Create tag and build when new code gets to main / Release (push) Failing after 6m12s
2026-02-11 18:38:00 +00:00
c09dfd1e7b more tuts, respawn mechanic when falling, reworked wall run again
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 19s
Create tag and build when new code gets to main / Test (push) Successful in 7m37s
Create tag and build when new code gets to main / Export (push) Has been cancelled
Create tag and build when new code gets to main / ReleaseName (push) Successful in 10s
Create tag and build when new code gets to main / Release (push) Failing after 4m53s
2026-02-11 17:06:18 +01:00
9db0056c5d more tut 2026-02-11 10:30:21 +01:00
7f1d33e4fc more sword tutorial 2026-02-10 22:33:16 +01:00
aaa9b102c1 started working on sword dash tutorial and bullet time is now unlimited while aiming in air
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 20s
Create tag and build when new code gets to main / Test (push) Successful in 6m44s
Create tag and build when new code gets to main / Export (push) Successful in 8m17s
2026-02-10 16:44:45 +01:00
cb348667f5 recover weapon if walking on it 2026-02-10 15:27:30 +01:00
8d23a95c68 ci back
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 18s
Create tag and build when new code gets to main / Test (push) Successful in 6m13s
Create tag and build when new code gets to main / Export (push) Successful in 8m45s
2026-02-10 09:41:44 +01:00
a9809abc27 removing sa mere
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 20s
Create tag and build when new code gets to main / Test (push) Failing after 5m10s
2026-02-10 09:35:26 +01:00
db65c02e75 trying a simpler workflow 2026-02-10 09:26:09 +01:00
5d49d6d681 player back at correct location for tuto sword
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 20s
Create tag and build when new code gets to main / Test (push) Successful in 7m17s
Create tag and build when new code gets to main / Export (push) Successful in 9m40s
2026-02-10 09:11:09 +01:00
fa0e511b3a removed ability to scale up wall by mashing dash and jump buttons
Some checks failed
Create tag and build when new code gets to main / BumpTag (push) Successful in 21s
Create tag and build when new code gets to main / Test (push) Failing after 2m44s
Create tag and build when new code gets to main / Export (push) Successful in 8m44s
2026-02-09 17:58:13 +01:00
3efbd41f56 aim assist in inputs
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 30s
Create tag and build when new code gets to main / Test (push) Successful in 9m0s
Create tag and build when new code gets to main / Export (push) Successful in 10m24s
2026-02-09 11:20:57 +01:00
3148d3b69b basic aim assist system
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 19s
Create tag and build when new code gets to main / Test (push) Successful in 7m42s
Create tag and build when new code gets to main / Export (push) Successful in 10m3s
2026-02-08 20:34:54 +01:00
c4be97e0de added forge addon
All checks were successful
Create tag and build when new code gets to main / BumpTag (push) Successful in 21s
Create tag and build when new code gets to main / Test (push) Successful in 6m56s
Create tag and build when new code gets to main / Export (push) Successful in 9m3s
2026-02-08 15:16:01 +01:00
7364 changed files with 44583 additions and 146145 deletions

View File

@@ -9,6 +9,7 @@ on:
env:
GODOT_VERSION: 4.6
DOTNET_VERSION: 'net9.0'
GAME_NAME: MovementTests
ITCHIO_USERNAME: Minimata
ITCHIO_GAMEID: MovementTests
@@ -38,35 +39,116 @@ jobs:
INITIAL_VERSION: 0.1.0
DEFAULT_BUMP: patch
Test:
runs-on: godot
# Test:
# runs-on: godot
# env:
# RUNNER_TOOL_CACHE: /toolcache # Runner Tool Cache
steps:
- name: Checkout with LFS
uses: https://git.game-dev.space/minimata/checkout-with-lfs.git@main
- name: Run tests
uses: godot-gdunit-labs/gdUnit4-action@v1
with:
godot-version: ${GODOT_VERSION}
godot-net: true
godot-force-mono: true
dotnet-version: 'net9.0'
paths: |
res://tests/
timeout: 1
publish-report: false
upload-report: false
- name: Upload test report
uses: actions/upload-artifact@v3-node20
with:
name: Test Report
path: ${{ github.workspace }}/reports/test-result.html
# steps:
# - name: Checkout with LFS
# uses: https://git.game-dev.space/minimata/checkout-with-lfs.git@main
#
# - name: Setup Godot
# id: setup-godot
# uses: https://git.game-dev.space/minimata/setup-godot.git@main
# with:
# godot-version: ${GODOT_VERSION}
# dotnet-version: ${DOTNET_VERSION}
#
# - name: 🔬 Verify Setup
# run: |
# dotnet --version
# ${{ steps.setup-godot.outputs.godot_bin }} --version
#
# - name: 🧑‍🔬 Generate .NET Bindings
# run: ${{ steps.setup-godot.outputs.godot_bin }} --headless --build-solutions --quit || exit 0
#
# - name: Run C# Tests
# env:
# GODOT_BIN: ${{ steps.setup-godot.outputs.godot_bin }}
# shell: bash
# run: |
# dotnet test --no-build --settings .runsettings --results-directory ./reports --logger "console;verbosity=normal" --logger "trx;LogFileName=results.xml" -- GdUnit4.Parameters="--verbose --headless --import"
#
# # - name: Run tests
# # uses: godot-gdunit-labs/gdUnit4-action@v1
# # with:
# # godot-version: ${GODOT_VERSION}
# # godot-net: true
# # godot-force-mono: true
# # dotnet-version: ${DOTNET_VERSION}
# # paths: |
# # res://tests/
# # publish-report: false
# # upload-report: false
# # console-verbosity: 'normal'
# # arguments: "--verbose --headless --import"
#
# - name: Upload test report
# uses: actions/upload-artifact@v3-node20
# with:
# name: Test Report
# path: ${{ github.workspace }}/reports/test-result.html
#
# OtherTest:
# runs-on: godot
# env:
# RUNNER_TOOL_CACHE: /toolcache # Runner Tool Cache
# steps:
# - name: Checkout with LFS
# uses: https://git.game-dev.space/minimata/checkout-with-lfs.git@main
#
# - uses: actions/setup-dotnet@v5
# name: 💽 Setup .NET SDK
# with:
# dotnet-version: '9.0.x'
#
# - name: 📦 Restore Dependencies
# run: |
# dotnet --version
# dotnet restore
# dotnet build
# dotnet list package
#
# - uses: chickensoft-games/setup-godot@v2
# name: 🤖 Setup Godot
# with:
# # Version must include major, minor, and patch, and be >= 4.0.0
# # Pre-release label is optional.
# version: '4.6.2'
# # Use .NET-enabled version of Godot (the default is also true).
# use-dotnet: true
# # Include the Godot Export Templates (the default is false).
# include-templates: true
#
# - name: 🔬 Verify Setup
# run: |
# dotnet --version
# godot --version
#
# - name: 🧑‍🔬 Generate .NET Bindings
# run: godot --headless --build-solutions --import --quit || exit 0
#
# - name: 🦺 Build Projects
# run: dotnet build --configuration ExportRelease
#
# - name: Run C# Tests
# env:
# GODOT_BIN: /root/bin/godot
# shell: bash
# run: |
# dotnet test --no-build --settings .runsettings --results-directory ./reports --logger "console;verbosity=normal" --logger "trx;LogFileName=results.xml" -- GdUnit4.Parameters="--headless --import --quit"
#
# - name: Upload test report
# uses: actions/upload-artifact@v3-node20
# if: always()
# with:
# name: Test Report
# path: ${{ github.workspace }}/reports/test-result.html
Export:
runs-on: godot
env:
RUNNER_TOOL_CACHE: /toolcache # Runner Tool Cache
needs:
- BumpTag
@@ -74,43 +156,50 @@ jobs:
- name: Checkout with LFS
uses: https://git.game-dev.space/minimata/checkout-with-lfs.git@main
- name: Remove problematic addons
run: |
rm -rf ${{ gitea.workspace }}/addons/gdUnit4
rm -rf ${{ gitea.workspace }}/addons/rider-plugin
- name: Setup Godot
id: setup-godot
uses: https://git.game-dev.space/minimata/setup-godot.git@main
with:
godot-version: '4.6'
dotnet-version: 'net9.0'
- name: Remove GDUnit addon
run: |
rm -rf ${{ gitea.workspace }}/addons/gdUnit4
godot-version: ${GODOT_VERSION}
dotnet-version: ${DOTNET_VERSION}
- name: Build Windows
run: |
mkdir -v -p build/windows
${{ steps.setup-godot.outputs.godot_bin }} --headless --verbose --export-release "Windows Desktop" build/windows/${{ env.GAME_NAME }}.exe
ls -la build/windows
zip -r Windows.zip build/windows
- name: Setup Butler
shell: bash
env:
BUTLER_API_KEY: ${{ secrets.BUTLER_TOKEN }}
run: |
mkdir ./tools 2>/dev/null || true
pushd tools
curl -sSLfo ./butler.zip "https://broth.itch.zone/butler/linux-amd64/LATEST/archive/default"
unzip butler.zip
chmod +x ./butler
popd
./tools/butler -V
- name: Upload to itch.io
shell: bash
env:
BUTLER_API_KEY: ${{ secrets.BUTLER_TOKEN }}
run: |
versionArgument="--userversion ${{ needs.BumpTag.outputs.tag_name }}"
./tools/butler push \
"Windows.zip" \
${{ env.ITCHIO_USERNAME }}/${{ env.ITCHIO_GAMEID }}:windows ${versionArgument}
- name: Upload build as artifact
uses: actions/upload-artifact@v3-node20
with:
name: Windows build
path: ${{ github.workspace }}/build/windows
# - name: Setup Butler
# shell: bash
# env:
# BUTLER_API_KEY: ${{ secrets.BUTLER_TOKEN }}
# run: |
# mkdir ./tools 2>/dev/null || true
# pushd tools
# curl -sSLfo ./butler.zip "https://broth.itch.zone/butler/linux-amd64/LATEST/archive/default"
# unzip butler.zip
# chmod +x ./butler
# popd
# ./tools/butler -V
#
# - name: Upload to itch.io
# shell: bash
# env:
# BUTLER_API_KEY: ${{ secrets.BUTLER_TOKEN }}
# run: |
# versionArgument="--userversion ${{ needs.BumpTag.outputs.tag_name }}"
# ./tools/butler push \
# "Windows.zip" \
# ${{ env.ITCHIO_USERNAME }}/${{ env.ITCHIO_GAMEID }}:windows ${versionArgument}

View File

@@ -55,10 +55,15 @@ jobs:
popd
./tools/butler -V
- name: Remove GDUnit addon
run: |
rm -rf ${{ gitea.workspace }}/addons/gdUnit4
- name: Build Windows
run: |
mkdir -v -p build/windows
godot --headless --verbose --build-solutions --export-release "Windows Desktop" build/windows/${{ env.GAME_NAME }}.exe
${{ steps.setup-godot.outputs.godot_bin }} --headless --verbose --build-solutions --export-release "Windows Desktop" build/windows/${{ env.GAME_NAME }}.exe
ls -la build/windows
zip -r Windows.zip build/windows
- name: Upload Windows to itch.io
shell: bash
@@ -73,9 +78,9 @@ jobs:
- name: Build Windows ARM
run: |
mkdir -v -p build/windowsArm
godot --headless --verbose --build-solutions --export-release "Windows ARM" build/windowsArm/${{ env.GAME_NAME }}.exe
${{ steps.setup-godot.outputs.godot_bin }} --headless --verbose --build-solutions --export-release "Windows ARM" build/windowsArm/${{ env.GAME_NAME }}.exe
zip -r WindowsArm.zip build/windowsArm
- name: Upload Windows to itch.io
- name: Upload Windows ARM to itch.io
shell: bash
env:
BUTLER_API_KEY: ${{ secrets.BUTLER_TOKEN }}
@@ -88,9 +93,9 @@ jobs:
- name: Linux Build
run: |
mkdir -v -p build/linux
godot --headless --verbose --export-release "Linux/X11" build/linux/${{ env.GAME_NAME }}.x86_64
${{ steps.setup-godot.outputs.godot_bin }} --headless --verbose --export-release "Linux/X11" build/linux/${{ env.GAME_NAME }}.x86_64
zip -r Linux.zip build/linux
- name: Upload Windows to itch.io
- name: Upload Linux to itch.io
shell: bash
env:
BUTLER_API_KEY: ${{ secrets.BUTLER_TOKEN }}
@@ -103,9 +108,9 @@ jobs:
- name: Mac Build
run: |
mkdir -v -p build/mac
godot --headless --verbose --export-release "macOS" build/mac/${{ env.GAME_NAME }}.zip
${{ steps.setup-godot.outputs.godot_bin }} --headless --verbose --export-release "macOS" build/mac/${{ env.GAME_NAME }}.zip
zip -r Mac.zip build/mac
- name: Upload Windows to itch.io
- name: Upload Mac to itch.io
shell: bash
env:
BUTLER_API_KEY: ${{ secrets.BUTLER_TOKEN }}

14
.gitignore vendored
View File

@@ -11,6 +11,20 @@
/builds
/communication
/reports
# Imported translations (automatically generated from CSV files)
*.translation
docs/legal/
.output.txt
*.suo
*.user
*.csproj.old*
_ReSharper.*
*.DotSettings.user
bin
obj
packages

View File

@@ -4,11 +4,8 @@
<MaxCpuCount>1</MaxCpuCount>
<ResultsDirectory>./TestResults</ResultsDirectory>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<TestSessionTimeout>180000</TestSessionTimeout>
<TestSessionTimeout>60000</TestSessionTimeout>
<TreatNoTestsAsError>true</TreatNoTestsAsError>
<EnvironmentVariables>
<GODOT_BIN>d:\development\Godot_v4.5-stable_mono_win64\Godot_v4.5-stable_mono_win64.exe</GODOT_BIN>
</EnvironmentVariables>
</RunConfiguration>
<LoggerRunSettings>
@@ -33,7 +30,7 @@
<GdUnit4>
<!-- Additional Godot runtime parameters. These are passed to the Godot executable when running tests.-->
<Parameters>"--verbose"</Parameters>
<Parameters>"--verbose --headless --import --quit"</Parameters>
<!-- Controls the display name format of test cases in the test results.
Allowed values:

View File

@@ -1,8 +1,10 @@
<Project Sdk="Godot.NET.Sdk/4.6.0">
<Project Sdk="Godot.NET.Sdk/4.6.2">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
<RootNamespace>Movementtests</RootNamespace>
<!-- Catch compiler-mismatch issues with the Introspection generator -->
<WarningsAsErrors>CS9057</WarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<Content Include=".runsettings" />
@@ -125,10 +127,21 @@
</ItemGroup>
<ItemGroup>
<Folder Include="addons\" />
<Folder Include="tests\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Chickensoft.Sync" Version="2.3.0" />
<PackageReference Include="RustyOptions" Version="0.10.1" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Chickensoft.GodotNodeInterfaces" Version="3.0.12" />
<PackageReference Include="Chickensoft.Introspection" Version="3.0.2" />
<PackageReference Include="Chickensoft.Introspection.Generator" Version="3.0.2" PrivateAssets="all" OutputItemType="analyzer" />
<PackageReference Include="Chickensoft.AutoInject" Version="2.12.8" PrivateAssets="all" />
<PackageReference Include="Chickensoft.AutoInject.Analyzers" Version="2.12.8" PrivateAssets="all" OutputItemType="analyzer" />
</ItemGroup>
<Import Project="addons/forge/Forge.props" />
<!-- gdUnit4 package dependencies -->
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />

View File

@@ -1,131 +0,0 @@
<Project Sdk="Godot.NET.Sdk/4.4.1">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
<RootNamespace>Movementtests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Content Include="export_presets.cfg" />
<Content Include="menus\assets\git_logo\Git-Logo-2Color.png" />
<Content Include="menus\assets\git_logo\Git-Logo-2Color.png.import" />
<Content Include="menus\assets\git_logo\LICENSE.txt" />
<Content Include="menus\assets\godot_engine_logo\LICENSE.txt" />
<Content Include="menus\assets\godot_engine_logo\logo_vertical_color_dark.png" />
<Content Include="menus\assets\godot_engine_logo\logo_vertical_color_dark.png.import" />
<Content Include="menus\assets\icon.png" />
<Content Include="menus\assets\icon.png.import" />
<Content Include="menus\ATTRIBUTION.md" />
<Content Include="menus\resources\themes\expedition.tres" />
<Content Include="menus\resources\themes\gravity.tres" />
<Content Include="menus\resources\themes\grow.tres" />
<Content Include="menus\resources\themes\lab.tres" />
<Content Include="menus\resources\themes\lore.tres" />
<Content Include="menus\resources\themes\steal_this_theme.tres" />
<Content Include="menus\scenes\credits\scrollable_credits.gd" />
<Content Include="menus\scenes\credits\scrollable_credits.gd.uid" />
<Content Include="menus\scenes\credits\scrollable_credits.tscn" />
<Content Include="menus\scenes\credits\scrolling_credits.gd" />
<Content Include="menus\scenes\credits\scrolling_credits.gd.uid" />
<Content Include="menus\scenes\credits\scrolling_credits.tscn" />
<Content Include="menus\scenes\end_credits\end_credits.gd" />
<Content Include="menus\scenes\end_credits\end_credits.gd.uid" />
<Content Include="menus\scenes\end_credits\end_credits.tscn" />
<Content Include="menus\scenes\game_scene\configurable_sub_viewport.gd" />
<Content Include="menus\scenes\game_scene\configurable_sub_viewport.gd.uid" />
<Content Include="menus\scenes\game_scene\game_ui.tscn" />
<Content Include="menus\scenes\game_scene\input_display_label.gd" />
<Content Include="menus\scenes\game_scene\input_display_label.gd.uid" />
<Content Include="menus\scenes\game_scene\levels\level.gd" />
<Content Include="menus\scenes\game_scene\levels\level.gd.uid" />
<Content Include="menus\scenes\game_scene\levels\level_1.tscn" />
<Content Include="menus\scenes\game_scene\levels\level_2.tscn" />
<Content Include="menus\scenes\game_scene\levels\level_3.tscn" />
<Content Include="menus\scenes\game_scene\tutorials\tutorial_1.tscn" />
<Content Include="menus\scenes\game_scene\tutorials\tutorial_2.tscn" />
<Content Include="menus\scenes\game_scene\tutorials\tutorial_3.tscn" />
<Content Include="menus\scenes\game_scene\tutorial_manager.gd" />
<Content Include="menus\scenes\game_scene\tutorial_manager.gd.uid" />
<Content Include="menus\scenes\loading_screen\level_loading_screen.tscn" />
<Content Include="menus\scenes\loading_screen\loading_screen.gd" />
<Content Include="menus\scenes\loading_screen\loading_screen.gd.uid" />
<Content Include="menus\scenes\loading_screen\loading_screen.tscn" />
<Content Include="menus\scenes\loading_screen\loading_screen_with_shader_caching.gd" />
<Content Include="menus\scenes\loading_screen\loading_screen_with_shader_caching.gd.uid" />
<Content Include="menus\scenes\loading_screen\loading_screen_with_shader_caching.tscn" />
<Content Include="menus\scenes\menus\level_select_menu\level_select_menu.gd" />
<Content Include="menus\scenes\menus\level_select_menu\level_select_menu.gd.uid" />
<Content Include="menus\scenes\menus\level_select_menu\level_select_menu.tscn" />
<Content Include="menus\scenes\menus\main_menu\main_menu.gd" />
<Content Include="menus\scenes\menus\main_menu\main_menu.gd.uid" />
<Content Include="menus\scenes\menus\main_menu\main_menu.tscn" />
<Content Include="menus\scenes\menus\main_menu\main_menu_with_animations.gd" />
<Content Include="menus\scenes\menus\main_menu\main_menu_with_animations.gd.uid" />
<Content Include="menus\scenes\menus\main_menu\main_menu_with_animations.tscn" />
<Content Include="menus\scenes\menus\options_menu\audio\audio_input_option_control.gd" />
<Content Include="menus\scenes\menus\options_menu\audio\audio_input_option_control.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\audio\audio_input_option_control.tscn" />
<Content Include="menus\scenes\menus\options_menu\audio\audio_options_menu.gd" />
<Content Include="menus\scenes\menus\options_menu\audio\audio_options_menu.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\audio\audio_options_menu.tscn" />
<Content Include="menus\scenes\menus\options_menu\game\game_options_menu.gd" />
<Content Include="menus\scenes\menus\options_menu\game\game_options_menu.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\game\game_options_menu.tscn" />
<Content Include="menus\scenes\menus\options_menu\game\reset_game_control\reset_game_control.gd" />
<Content Include="menus\scenes\menus\options_menu\game\reset_game_control\reset_game_control.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\game\reset_game_control\reset_game_control.tscn" />
<Content Include="menus\scenes\menus\options_menu\input\input_extras_menu.tscn" />
<Content Include="menus\scenes\menus\options_menu\input\input_options_menu.gd" />
<Content Include="menus\scenes\menus\options_menu\input\input_options_menu.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\input\input_options_menu.tscn" />
<Content Include="menus\scenes\menus\options_menu\input\input_options_menu_with_mouse_sensitivity.tscn" />
<Content Include="menus\scenes\menus\options_menu\master_options_menu.gd" />
<Content Include="menus\scenes\menus\options_menu\master_options_menu.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\master_options_menu.tscn" />
<Content Include="menus\scenes\menus\options_menu\master_options_menu_with_tabs.tscn" />
<Content Include="menus\scenes\menus\options_menu\mini_options_menu.gd" />
<Content Include="menus\scenes\menus\options_menu\mini_options_menu.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\mini_options_menu.tscn" />
<Content Include="menus\scenes\menus\options_menu\mini_options_menu_with_reset.gd" />
<Content Include="menus\scenes\menus\options_menu\mini_options_menu_with_reset.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\mini_options_menu_with_reset.tscn" />
<Content Include="menus\scenes\menus\options_menu\video\video_options_menu.gd" />
<Content Include="menus\scenes\menus\options_menu\video\video_options_menu.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\video\video_options_menu.tscn" />
<Content Include="menus\scenes\menus\options_menu\video\video_options_menu_with_extras.tscn" />
<Content Include="menus\scenes\opening\opening.gd" />
<Content Include="menus\scenes\opening\opening.gd.uid" />
<Content Include="menus\scenes\opening\opening.tscn" />
<Content Include="menus\scenes\opening\opening_with_logo.tscn" />
<Content Include="menus\scenes\overlaid_menus\game_won_menu.gd" />
<Content Include="menus\scenes\overlaid_menus\game_won_menu.gd.uid" />
<Content Include="menus\scenes\overlaid_menus\game_won_menu.tscn" />
<Content Include="menus\scenes\overlaid_menus\level_lost_menu.gd" />
<Content Include="menus\scenes\overlaid_menus\level_lost_menu.gd.uid" />
<Content Include="menus\scenes\overlaid_menus\level_lost_menu.tscn" />
<Content Include="menus\scenes\overlaid_menus\level_won_menu.gd" />
<Content Include="menus\scenes\overlaid_menus\level_won_menu.gd.uid" />
<Content Include="menus\scenes\overlaid_menus\level_won_menu.tscn" />
<Content Include="menus\scenes\overlaid_menus\mini_options_overlaid_menu.tscn" />
<Content Include="menus\scenes\overlaid_menus\overlaid_menu.gd" />
<Content Include="menus\scenes\overlaid_menus\overlaid_menu.gd.uid" />
<Content Include="menus\scenes\overlaid_menus\overlaid_menu.tscn" />
<Content Include="menus\scenes\overlaid_menus\overlaid_menu_container.gd" />
<Content Include="menus\scenes\overlaid_menus\overlaid_menu_container.gd.uid" />
<Content Include="menus\scenes\overlaid_menus\overlaid_menu_container.tscn" />
<Content Include="menus\scenes\overlaid_menus\pause_menu.gd" />
<Content Include="menus\scenes\overlaid_menus\pause_menu.gd.uid" />
<Content Include="menus\scenes\overlaid_menus\pause_menu.tscn" />
<Content Include="menus\scripts\game_state.gd" />
<Content Include="menus\scripts\game_state.gd.uid" />
<Content Include="menus\scripts\level_list_and_state_manager.gd" />
<Content Include="menus\scripts\level_list_and_state_manager.gd.uid" />
<Content Include="menus\scripts\level_state.gd" />
<Content Include="menus\scripts\level_state.gd.uid" />
</ItemGroup>
<ItemGroup>
<Folder Include="addons\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="RustyOptions" Version="0.10.1" />
</ItemGroup>
</Project>

View File

@@ -1,142 +0,0 @@
<Project Sdk="Godot.NET.Sdk/4.5.0">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
<RootNamespace>Movementtests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Content Include=".runsettings" />
<Content Include="export_presets.cfg" />
<Content Include="menus\assets\git_logo\Git-Logo-2Color.png" />
<Content Include="menus\assets\git_logo\Git-Logo-2Color.png.import" />
<Content Include="menus\assets\git_logo\LICENSE.txt" />
<Content Include="menus\assets\godot_engine_logo\LICENSE.txt" />
<Content Include="menus\assets\godot_engine_logo\logo_vertical_color_dark.png" />
<Content Include="menus\assets\godot_engine_logo\logo_vertical_color_dark.png.import" />
<Content Include="menus\assets\icon.png" />
<Content Include="menus\assets\icon.png.import" />
<Content Include="menus\ATTRIBUTION.md" />
<Content Include="menus\resources\themes\expedition.tres" />
<Content Include="menus\resources\themes\gravity.tres" />
<Content Include="menus\resources\themes\grow.tres" />
<Content Include="menus\resources\themes\lab.tres" />
<Content Include="menus\resources\themes\lore.tres" />
<Content Include="menus\resources\themes\steal_this_theme.tres" />
<Content Include="menus\scenes\credits\scrollable_credits.gd" />
<Content Include="menus\scenes\credits\scrollable_credits.gd.uid" />
<Content Include="menus\scenes\credits\scrollable_credits.tscn" />
<Content Include="menus\scenes\credits\scrolling_credits.gd" />
<Content Include="menus\scenes\credits\scrolling_credits.gd.uid" />
<Content Include="menus\scenes\credits\scrolling_credits.tscn" />
<Content Include="menus\scenes\end_credits\end_credits.gd" />
<Content Include="menus\scenes\end_credits\end_credits.gd.uid" />
<Content Include="menus\scenes\end_credits\end_credits.tscn" />
<Content Include="menus\scenes\game_scene\configurable_sub_viewport.gd" />
<Content Include="menus\scenes\game_scene\configurable_sub_viewport.gd.uid" />
<Content Include="menus\scenes\game_scene\game_ui.tscn" />
<Content Include="menus\scenes\game_scene\input_display_label.gd" />
<Content Include="menus\scenes\game_scene\input_display_label.gd.uid" />
<Content Include="menus\scenes\game_scene\levels\level.gd" />
<Content Include="menus\scenes\game_scene\levels\level.gd.uid" />
<Content Include="menus\scenes\game_scene\levels\level_1.tscn" />
<Content Include="menus\scenes\game_scene\levels\level_2.tscn" />
<Content Include="menus\scenes\game_scene\levels\level_3.tscn" />
<Content Include="menus\scenes\game_scene\tutorials\tutorial_1.tscn" />
<Content Include="menus\scenes\game_scene\tutorials\tutorial_2.tscn" />
<Content Include="menus\scenes\game_scene\tutorials\tutorial_3.tscn" />
<Content Include="menus\scenes\game_scene\tutorial_manager.gd" />
<Content Include="menus\scenes\game_scene\tutorial_manager.gd.uid" />
<Content Include="menus\scenes\loading_screen\level_loading_screen.tscn" />
<Content Include="menus\scenes\loading_screen\loading_screen.gd" />
<Content Include="menus\scenes\loading_screen\loading_screen.gd.uid" />
<Content Include="menus\scenes\loading_screen\loading_screen.tscn" />
<Content Include="menus\scenes\loading_screen\loading_screen_with_shader_caching.gd" />
<Content Include="menus\scenes\loading_screen\loading_screen_with_shader_caching.gd.uid" />
<Content Include="menus\scenes\loading_screen\loading_screen_with_shader_caching.tscn" />
<Content Include="menus\scenes\menus\level_select_menu\level_select_menu.gd" />
<Content Include="menus\scenes\menus\level_select_menu\level_select_menu.gd.uid" />
<Content Include="menus\scenes\menus\level_select_menu\level_select_menu.tscn" />
<Content Include="menus\scenes\menus\main_menu\main_menu.gd" />
<Content Include="menus\scenes\menus\main_menu\main_menu.gd.uid" />
<Content Include="menus\scenes\menus\main_menu\main_menu.tscn" />
<Content Include="menus\scenes\menus\main_menu\main_menu_with_animations.gd" />
<Content Include="menus\scenes\menus\main_menu\main_menu_with_animations.gd.uid" />
<Content Include="menus\scenes\menus\main_menu\main_menu_with_animations.tscn" />
<Content Include="menus\scenes\menus\options_menu\audio\audio_input_option_control.gd" />
<Content Include="menus\scenes\menus\options_menu\audio\audio_input_option_control.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\audio\audio_input_option_control.tscn" />
<Content Include="menus\scenes\menus\options_menu\audio\audio_options_menu.gd" />
<Content Include="menus\scenes\menus\options_menu\audio\audio_options_menu.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\audio\audio_options_menu.tscn" />
<Content Include="menus\scenes\menus\options_menu\game\game_options_menu.gd" />
<Content Include="menus\scenes\menus\options_menu\game\game_options_menu.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\game\game_options_menu.tscn" />
<Content Include="menus\scenes\menus\options_menu\game\reset_game_control\reset_game_control.gd" />
<Content Include="menus\scenes\menus\options_menu\game\reset_game_control\reset_game_control.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\game\reset_game_control\reset_game_control.tscn" />
<Content Include="menus\scenes\menus\options_menu\input\input_extras_menu.tscn" />
<Content Include="menus\scenes\menus\options_menu\input\input_options_menu.gd" />
<Content Include="menus\scenes\menus\options_menu\input\input_options_menu.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\input\input_options_menu.tscn" />
<Content Include="menus\scenes\menus\options_menu\input\input_options_menu_with_mouse_sensitivity.tscn" />
<Content Include="menus\scenes\menus\options_menu\master_options_menu.gd" />
<Content Include="menus\scenes\menus\options_menu\master_options_menu.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\master_options_menu.tscn" />
<Content Include="menus\scenes\menus\options_menu\master_options_menu_with_tabs.tscn" />
<Content Include="menus\scenes\menus\options_menu\mini_options_menu.gd" />
<Content Include="menus\scenes\menus\options_menu\mini_options_menu.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\mini_options_menu.tscn" />
<Content Include="menus\scenes\menus\options_menu\mini_options_menu_with_reset.gd" />
<Content Include="menus\scenes\menus\options_menu\mini_options_menu_with_reset.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\mini_options_menu_with_reset.tscn" />
<Content Include="menus\scenes\menus\options_menu\video\video_options_menu.gd" />
<Content Include="menus\scenes\menus\options_menu\video\video_options_menu.gd.uid" />
<Content Include="menus\scenes\menus\options_menu\video\video_options_menu.tscn" />
<Content Include="menus\scenes\menus\options_menu\video\video_options_menu_with_extras.tscn" />
<Content Include="menus\scenes\opening\opening.gd" />
<Content Include="menus\scenes\opening\opening.gd.uid" />
<Content Include="menus\scenes\opening\opening.tscn" />
<Content Include="menus\scenes\opening\opening_with_logo.tscn" />
<Content Include="menus\scenes\overlaid_menus\game_won_menu.gd" />
<Content Include="menus\scenes\overlaid_menus\game_won_menu.gd.uid" />
<Content Include="menus\scenes\overlaid_menus\game_won_menu.tscn" />
<Content Include="menus\scenes\overlaid_menus\level_lost_menu.gd" />
<Content Include="menus\scenes\overlaid_menus\level_lost_menu.gd.uid" />
<Content Include="menus\scenes\overlaid_menus\level_lost_menu.tscn" />
<Content Include="menus\scenes\overlaid_menus\level_won_menu.gd" />
<Content Include="menus\scenes\overlaid_menus\level_won_menu.gd.uid" />
<Content Include="menus\scenes\overlaid_menus\level_won_menu.tscn" />
<Content Include="menus\scenes\overlaid_menus\mini_options_overlaid_menu.tscn" />
<Content Include="menus\scenes\overlaid_menus\overlaid_menu.gd" />
<Content Include="menus\scenes\overlaid_menus\overlaid_menu.gd.uid" />
<Content Include="menus\scenes\overlaid_menus\overlaid_menu.tscn" />
<Content Include="menus\scenes\overlaid_menus\overlaid_menu_container.gd" />
<Content Include="menus\scenes\overlaid_menus\overlaid_menu_container.gd.uid" />
<Content Include="menus\scenes\overlaid_menus\overlaid_menu_container.tscn" />
<Content Include="menus\scenes\overlaid_menus\pause_menu.gd" />
<Content Include="menus\scenes\overlaid_menus\pause_menu.gd.uid" />
<Content Include="menus\scenes\overlaid_menus\pause_menu.tscn" />
<Content Include="menus\scripts\game_state.gd" />
<Content Include="menus\scripts\game_state.gd.uid" />
<Content Include="menus\scripts\level_list_and_state_manager.gd" />
<Content Include="menus\scripts\level_list_and_state_manager.gd.uid" />
<Content Include="menus\scripts\level_state.gd" />
<Content Include="menus\scripts\level_state.gd.uid" />
</ItemGroup>
<ItemGroup>
<Folder Include="addons\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="RustyOptions" Version="0.10.1" />
</ItemGroup>
<!-- gdUnit4 package dependencies -->
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.0"/>
<PackageReference Include="gdUnit4.api" Version="5.1.0-rc3"/>
<PackageReference Include="gdUnit4.test.adapter" Version="3.0.0"/>
<PackageReference Include="gdUnit4.analyzers" Version="1.0.0">
<PrivateAssets>none</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeEditing/SuppressNullableWarningFix/Enabled/@EntryValue">False</s:Boolean></wpf:ResourceDictionary>

View File

@@ -1,12 +0,0 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAction_00601_002Ecs_002Fl_003AC_0021_003FUsers_003FMinimata_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F7c0f83388bfc4d2c9d09befcec9dd79bc90908_003Fb8_003F4d300c4d_003FAction_00601_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAction_00602_002Ecs_002Fl_003AC_0021_003FUsers_003FMinimata_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F7c0f83388bfc4d2c9d09befcec9dd79bc90908_003F87_003Fded27e2d_003FAction_00602_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEnemy_005FScriptMethods_002Egenerated_002Ecs_002Fl_003AC_0021_003FUsers_003FMinimata_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F8e71dc81611862c01a2cb998a1f327de14747655_003FEnemy_005FScriptMethods_002Egenerated_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANode_002Ecs_002Fl_003AC_0021_003FUsers_003FMinimata_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F716d154fef5cbe863cd637bd32beda6e3cec5f12e8fed2dc5b2d8149a0d558ab_003FNode_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANode_002Ecs_002Fl_003AC_0021_003FUsers_003FMinimata_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fdf73a4db74df89d59655c5fb6326406f47fbfa9af1fa81518fe0a07c49d34133_003FNode_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASceneTree_002Ecs_002Fl_003AC_0021_003FUsers_003FMinimata_003FAppData_003FRoaming_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F8d6960554e939a669841b1ece03d27df4ab42f92bb80be3767eaec8cdaccf84b_003FSceneTree_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=dd9a7ac6_002Dbb9b_002D4001_002Db145_002D15e6509b7e78/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;&#xD;
&lt;Solution /&gt;&#xD;
&lt;/SessionState&gt;</s:String>
<s:String x:Key="/Default/Housekeeping/UnitTestingMru/UnitTestRunner/RunConfigurationFilename/@EntryValue">D:\Godot\Projects\movement-tests\.runsettings</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=floorplane/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

BIN
addons/forge-godot-main.zip Normal file

Binary file not shown.

8
addons/forge/Forge.props Normal file
View File

@@ -0,0 +1,8 @@
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Gamesmiths.Forge" Version="0.3.3" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,289 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using Gamesmiths.Forge.Core;
using Gamesmiths.Forge.Godot.Core;
using Gamesmiths.Forge.Godot.Editor;
using Gamesmiths.Forge.Godot.Editor.Attributes;
using Gamesmiths.Forge.Godot.Editor.Cues;
using Gamesmiths.Forge.Godot.Editor.Statescript;
using Gamesmiths.Forge.Godot.Editor.Tags;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot;
[Tool]
public partial class ForgePluginLoader : EditorPlugin
{
private const string AutoloadPath = "uid://ba8fquhtwu5mu";
private TagsEditorDock? _tagsEditorDock;
private TagContainerInspectorPlugin? _tagContainerInspectorPlugin;
private TagInspectorPlugin? _tagInspectorPlugin;
private AttributeSetInspectorPlugin? _attributeSetInspectorPlugin;
private CueHandlerInspectorPlugin? _cueHandlerInspectorPlugin;
private AttributeEditorPlugin? _attributeEditorPlugin;
private SharedVariableSetInspectorPlugin? _sharedVariableSetInspectorPlugin;
private StatescriptGraphEditorDock? _statescriptGraphEditorDock;
private EditorFileSystem? _fileSystem;
private Callable _resourcesReimportedCallable;
public override void _EnterTree()
{
EnsureForgeDataExists();
_tagsEditorDock = new TagsEditorDock();
AddDock(_tagsEditorDock);
_tagContainerInspectorPlugin = new TagContainerInspectorPlugin();
AddInspectorPlugin(_tagContainerInspectorPlugin);
_tagInspectorPlugin = new TagInspectorPlugin();
AddInspectorPlugin(_tagInspectorPlugin);
_attributeSetInspectorPlugin = new AttributeSetInspectorPlugin();
AddInspectorPlugin(_attributeSetInspectorPlugin);
_cueHandlerInspectorPlugin = new CueHandlerInspectorPlugin();
AddInspectorPlugin(_cueHandlerInspectorPlugin);
_attributeEditorPlugin = new AttributeEditorPlugin();
AddInspectorPlugin(_attributeEditorPlugin);
_sharedVariableSetInspectorPlugin = new SharedVariableSetInspectorPlugin();
_sharedVariableSetInspectorPlugin.SetUndoRedo(GetUndoRedo());
AddInspectorPlugin(_sharedVariableSetInspectorPlugin);
_statescriptGraphEditorDock = new StatescriptGraphEditorDock();
_statescriptGraphEditorDock.SetUndoRedo(GetUndoRedo());
AddDock(_statescriptGraphEditorDock);
AddToolMenuItem("Repair assets tags", new Callable(this, MethodName.CallAssetRepairTool));
_fileSystem = EditorInterface.Singleton.GetResourceFilesystem();
_resourcesReimportedCallable = new Callable(this, nameof(OnResourcesReimported));
_fileSystem.Connect(EditorFileSystem.SignalName.ResourcesReimported, _resourcesReimportedCallable);
Validation.Enabled = true;
}
public override void _ExitTree()
{
if (_fileSystem?.IsConnected(EditorFileSystem.SignalName.ResourcesReimported, _resourcesReimportedCallable)
== true)
{
_fileSystem.Disconnect(EditorFileSystem.SignalName.ResourcesReimported, _resourcesReimportedCallable);
}
if (_tagsEditorDock is not null)
{
RemoveDock(_tagsEditorDock);
_tagsEditorDock.Free();
_tagsEditorDock = null;
}
RemoveInspectorPluginAndRelease(ref _tagContainerInspectorPlugin);
RemoveInspectorPluginAndRelease(ref _tagInspectorPlugin);
RemoveInspectorPluginAndRelease(ref _attributeSetInspectorPlugin);
RemoveInspectorPluginAndRelease(ref _cueHandlerInspectorPlugin);
RemoveInspectorPluginAndRelease(ref _attributeEditorPlugin);
RemoveInspectorPluginAndRelease(ref _sharedVariableSetInspectorPlugin);
if (_statescriptGraphEditorDock is not null)
{
RemoveDock(_statescriptGraphEditorDock);
_statescriptGraphEditorDock.Free();
_statescriptGraphEditorDock = null;
}
_fileSystem = null;
_resourcesReimportedCallable = default;
RemoveToolMenuItem("Repair assets tags");
}
public override bool _Handles(GodotObject @object)
{
return @object is StatescriptGraph;
}
public override void _Edit(GodotObject? @object)
{
if (@object is StatescriptGraph graph && _statescriptGraphEditorDock is not null)
{
_statescriptGraphEditorDock.OpenGraph(graph);
}
}
public override void _MakeVisible(bool visible)
{
if (_statescriptGraphEditorDock is null)
{
return;
}
if (visible)
{
_statescriptGraphEditorDock.Open();
}
_statescriptGraphEditorDock.Visible = visible;
}
public override void _EnablePlugin()
{
base._EnablePlugin();
EnsureForgeDataExists();
bool config = ProjectSettings.LoadResourcePack(AutoloadPath);
if (config)
{
GD.PrintErr("Failed to load script at res://addons/forge/core/ForgeBootstrap.cs");
return;
}
if (!ProjectSettings.HasSetting("autoload/Forge Bootstrap"))
{
ProjectSettings.SetSetting("autoload/Forge Bootstrap", AutoloadPath);
ProjectSettings.Save();
}
}
public override void _DisablePlugin()
{
if (ProjectSettings.HasSetting("autoload/Forge Bootstrap"))
{
ProjectSettings.Clear("autoload/Forge Bootstrap");
ProjectSettings.Save();
}
}
public override void _SaveExternalData()
{
_statescriptGraphEditorDock?.SaveAllOpenGraphs();
}
public override string _GetPluginName()
{
return "Forge";
}
public override void _GetWindowLayout(ConfigFile configuration)
{
if (_statescriptGraphEditorDock is null)
{
return;
}
string[] paths = _statescriptGraphEditorDock.GetOpenResourcePaths();
if (paths.Length == 0)
{
return;
}
configuration.SetValue("Forge", "open_tabs", string.Join(";", paths));
configuration.SetValue("Forge", "active_tab", _statescriptGraphEditorDock.GetActiveTabIndex());
bool[] varStates = _statescriptGraphEditorDock.GetVariablesPanelStates();
configuration.SetValue("Forge", "variables_states", string.Join(";", varStates));
}
public override void _SetWindowLayout(ConfigFile configuration)
{
if (_statescriptGraphEditorDock is null)
{
return;
}
Variant tabsValue = configuration.GetValue("Forge", "open_tabs", string.Empty);
Variant active = configuration.GetValue("Forge", "active_tab", -1);
string tabsString = tabsValue.AsString();
if (string.IsNullOrEmpty(tabsString))
{
return;
}
string[] paths = tabsString.Split(';', StringSplitOptions.RemoveEmptyEntries);
int activeIndex = active.AsInt32();
bool[]? variablesStates = null;
Variant varStatesValue = configuration.GetValue("Forge", "variables_states", string.Empty);
string varString = varStatesValue.AsString();
if (!string.IsNullOrEmpty(varString))
{
string[] parts = varString.Split(';');
variablesStates = new bool[parts.Length];
for (int i = 0; i < parts.Length; i++)
{
variablesStates[i] = bool.TryParse(parts[i], out bool v) && v;
}
}
_statescriptGraphEditorDock.RestoreFromPaths(paths, activeIndex, variablesStates);
}
private static void EnsureForgeDataExists()
{
if (ResourceLoader.Exists(ForgeData.ForgeDataResourcePath))
{
return;
}
var forgeData = new ForgeData();
Error error = ResourceSaver.Save(forgeData, ForgeData.ForgeDataResourcePath);
if (error != Error.Ok)
{
GD.PrintErr($"Failed to create ForgeData resource: {error}");
return;
}
EditorInterface.Singleton.GetResourceFilesystem().Scan();
GD.Print("Created default ForgeData resource at ", ForgeData.ForgeDataResourcePath);
}
private static void CallAssetRepairTool()
{
AssetRepairTool.RepairAllAssetsTags();
}
private void RemoveInspectorPluginAndRelease<TPlugin>(ref TPlugin? plugin)
where TPlugin : EditorInspectorPlugin
{
if (plugin is null)
{
return;
}
RemoveInspectorPlugin(plugin);
plugin = null;
}
private void OnResourcesReimported(string[] resources)
{
foreach (string path in resources)
{
if (!ResourceLoader.Exists(path))
{
continue;
}
string fileType = EditorInterface.Singleton.GetResourceFilesystem().GetFileType(path);
if (fileType != "StatescriptGraph" && fileType != "Resource")
{
continue;
}
Resource resource = ResourceLoader.Load(path);
if (resource is StatescriptGraph graph)
{
_statescriptGraphEditorDock?.OpenGraph(graph);
}
}
}
}
#endif

View File

@@ -0,0 +1 @@
uid://686m2ah4as6w

21
addons/forge/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Gamesmiths Guild
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

61
addons/forge/README.md Normal file
View File

@@ -0,0 +1,61 @@
# Forge for Godot
Forge for Godot is an Unreal GAS-like gameplay framework for the Godot Engine.
It integrates the [Forge Gameplay System](https://github.com/gamesmiths-guild/forge) into Godot, providing a robust, data-driven foundation for gameplay features such as attributes, effects, gameplay tags, abilities, events, cues, and visual ability scripting through Statescript, fully aligned with Godots node, resource, and editor workflows.
This plugin enables you to:
- Use **ForgeEntity** nodes or implement `IForgeEntity` to integrate core Forge systems like attributes, effects, abilities, events and tags.
- Define attributes, effects, abilities, cues, and tags directly in the Godot editor.
- Apply and manage gameplay effects with area or raycasting nodes.
- Create hierarchical gameplay tags using the built-in Tags Editor.
- Trigger visual and audio feedback with the Cues system.
- Create player skills, attacks, or behaviors, with support for custom logic, costs, cooldowns, and triggers.
- Build ability behaviors visually with the Statescript graph editor, or implement custom behaviors in C#.
## Features
- **Effects System**: Comprehensive effect application and management, including stacking, periodic, instant, and infinite effects.
- **Attributes System**: Attribute management, supporting sets, modifiers, and configuration.
- **Tags System**: Full hierarchical tag system with Godot editor integration.
- **Abilities System**: Feature-complete ability system, supporting grant/removal, custom behaviors, triggers, cooldowns, and costs.
- **Events System**: Gameplay event bus supporting event-driven logic, subscriptions, and triggers.
- **Cues System**: Visual/audio feedback layer; decouples presentation from game logic.
- **Statescript**: Visual state-based scripting system for implementing ability behaviors with a built-in graph editor.
- **Editor Extensions**: Custom inspector elements, tag editor, and Statescript graph editor with Godot integration.
- **Custom Nodes**: Includes nodes like `ForgeEntity`, `ForgeAttributeSet`, `EffectArea2D`, and more.
## Installation
### Requirements
- Godot 4.6 or later with .NET support.
- .NET SDK 8.0 or later.
### Steps
1. Install the plugin via the Godot Asset Library or manually by copying the `addons` folder.
- [Godot Asset Library](https://godotengine.org/asset-library/asset/4239)
- [Manual installation guide](https://docs.godotengine.org/en/stable/tutorials/plugins/editor/installing_plugins.html)
2. Add the following line in your `.csproj` file (before the closing `</Project>` tag). The `.csproj` file can be created through Godot by navigating to `Project > Tools > C# > Create C# solution`:
```xml
<Import Project="addons/forge/Forge.props" />
```
3. Back in the Godot editor, build your project by clicking `Build` in the top-right corner of the script editor.
4. Enable **Forge Gameplay System** in `Project > Project Settings > Plugins`.
## Getting Started
- See the [Quick Start Guide](https://github.com/gamesmiths-guild/forge-godot/blob/main/docs/quick-start.md) for a basic setup.
- Explore [sample scenes](https://github.com/gamesmiths-guild/forge-godot/tree/main/examples) by cloning the full repo.
## Documentation
Full documentation, examples, and advanced usage are available in the [Forge for Godot GitHub repository](https://github.com/gamesmiths-guild/forge-godot).
For Statescript documentation, see the [Statescript guide](https://github.com/gamesmiths-guild/forge-godot/blob/main/docs/statescript/README.md).
For technical details about core systems, see the [Forge Gameplay System documentation](https://github.com/gamesmiths-guild/forge/blob/main/docs/README.md).
## License
This plugin is licensed under the same terms as the core [Forge Gameplay System](https://github.com/gamesmiths-guild/forge).

View File

@@ -0,0 +1,312 @@
// Copyright © Gamesmiths Guild.
using System.Collections.Generic;
using Gamesmiths.Forge.Core;
using Gamesmiths.Forge.Effects;
using Gamesmiths.Forge.Godot.Nodes;
using Godot;
namespace Gamesmiths.Forge.Godot.Core;
internal sealed class EffectApplier
{
private record struct EffectKey(EffectData EffectData, EffectOwnership EffectOwnership, int Level);
private readonly List<EffectData> _effects = [];
private readonly Dictionary<IForgeEntity, List<ActiveEffectHandle>> _effectInstances = [];
private readonly Dictionary<EffectKey, Effect> _effectsCache = [];
public EffectApplier(Node node)
{
foreach (Node child in node.GetChildren())
{
if (child is ForgeEffect effectNode && effectNode.EffectData is not null)
{
_effects.Add(effectNode.EffectData.GetEffectData());
}
}
}
public void ApplyEffects(
Node node,
IForgeEntity? effectOwner,
IForgeEntity? effectSource,
int level = 1)
{
if (node is IForgeEntity forgeEntity)
{
ApplyEffects(forgeEntity, effectOwner, effectSource, level);
return;
}
foreach (Node? child in node.GetChildren())
{
if (child is IForgeEntity forgeEntityChild)
{
ApplyEffects(forgeEntityChild, effectOwner, effectSource, level);
return;
}
}
}
public void ApplyEffects<TData>(
Node node,
TData contextData,
IForgeEntity? effectOwner,
IForgeEntity? effectSource,
int level = 1)
{
if (node is IForgeEntity forgeEntity)
{
ApplyEffects(forgeEntity, contextData, effectOwner, effectSource, level);
return;
}
foreach (Node? child in node.GetChildren())
{
if (child is IForgeEntity forgeEntityChild)
{
ApplyEffects(forgeEntityChild, contextData, effectOwner, effectSource, level);
return;
}
}
}
public void AddEffects(
Node node,
IForgeEntity? effectOwner,
IForgeEntity? effectSource,
int level)
{
if (node is IForgeEntity forgeEntity)
{
AddEffects(forgeEntity, effectOwner, effectSource, level);
return;
}
foreach (Node? child in node.GetChildren())
{
if (child is IForgeEntity forgeEntityChild)
{
AddEffects(forgeEntityChild, effectOwner, effectSource, level);
return;
}
}
}
public void AddEffects<TData>(
Node node,
TData contextData,
IForgeEntity? effectOwner,
IForgeEntity? effectSource,
int level)
{
if (node is IForgeEntity forgeEntity)
{
AddEffects(forgeEntity, contextData, effectOwner, effectSource, level);
return;
}
foreach (Node? child in node.GetChildren())
{
if (child is IForgeEntity forgeEntityChild)
{
AddEffects(forgeEntityChild, contextData, effectOwner, effectSource, level);
return;
}
}
}
public void RemoveEffects(Node node)
{
if (node is IForgeEntity forgeEntity)
{
RemoveEffects(forgeEntity);
return;
}
foreach (Node? child in node.GetChildren())
{
if (child is IForgeEntity forgeEntityChild)
{
RemoveEffects(forgeEntityChild);
return;
}
}
}
private void ApplyEffects(
IForgeEntity forgeEntity,
IForgeEntity? effectOwner,
IForgeEntity? effectSource,
int level)
{
var effectOwnership = new EffectOwnership(effectOwner, effectSource);
foreach (EffectData effectData in _effects)
{
var key = new EffectKey(effectData, effectOwnership, level);
if (_effectsCache.TryGetValue(key, out Effect? cachedEffect))
{
forgeEntity.EffectsManager.ApplyEffect(cachedEffect);
continue;
}
var effect = new Effect(
effectData,
new EffectOwnership(effectOwner, effectSource),
level);
_effectsCache[key] = effect;
forgeEntity.EffectsManager.ApplyEffect(effect);
}
}
private void ApplyEffects<TData>(
IForgeEntity forgeEntity,
TData contextData,
IForgeEntity? effectOwner,
IForgeEntity? effectSource,
int level)
{
var effectOwnership = new EffectOwnership(effectOwner, effectSource);
foreach (EffectData effectData in _effects)
{
var key = new EffectKey(effectData, effectOwnership, level);
if (_effectsCache.TryGetValue(key, out Effect? cachedEffect))
{
forgeEntity.EffectsManager.ApplyEffect(cachedEffect);
continue;
}
var effect = new Effect(
effectData,
new EffectOwnership(effectOwner, effectSource),
level);
_effectsCache[key] = effect;
forgeEntity.EffectsManager.ApplyEffect(effect, contextData);
}
}
private void AddEffects(
IForgeEntity forgeEntity,
IForgeEntity?
effectOwner,
IForgeEntity? effectSource,
int level)
{
var instanceEffects = new List<ActiveEffectHandle>();
if (!_effectInstances.TryAdd(forgeEntity, instanceEffects))
{
instanceEffects = _effectInstances[forgeEntity];
}
var effectOwnership = new EffectOwnership(effectOwner, effectSource);
foreach (EffectData effectData in _effects)
{
var key = new EffectKey(effectData, effectOwnership, level);
ActiveEffectHandle? handle;
if (_effectsCache.TryGetValue(key, out Effect? cachedEffect))
{
handle = forgeEntity.EffectsManager.ApplyEffect(cachedEffect);
if (handle is null)
{
continue;
}
instanceEffects.Add(handle);
continue;
}
var effect = new Effect(
effectData,
new EffectOwnership(effectOwner, effectSource),
level);
handle = forgeEntity.EffectsManager.ApplyEffect(effect);
if (handle is null)
{
continue;
}
instanceEffects.Add(handle);
}
}
private void AddEffects<TData>(
IForgeEntity forgeEntity,
TData contextData,
IForgeEntity? effectOwner,
IForgeEntity? effectSource,
int level)
{
var instanceEffects = new List<ActiveEffectHandle>();
if (!_effectInstances.TryAdd(forgeEntity, instanceEffects))
{
instanceEffects = _effectInstances[forgeEntity];
}
var effectOwnership = new EffectOwnership(effectOwner, effectSource);
foreach (EffectData effectData in _effects)
{
var key = new EffectKey(effectData, effectOwnership, level);
ActiveEffectHandle? handle;
if (_effectsCache.TryGetValue(key, out Effect? cachedEffect))
{
handle = forgeEntity.EffectsManager.ApplyEffect(cachedEffect);
if (handle is null)
{
continue;
}
instanceEffects.Add(handle);
continue;
}
var effect = new Effect(
effectData,
new EffectOwnership(effectOwner, effectSource),
level);
handle = forgeEntity.EffectsManager.ApplyEffect(effect, contextData);
if (handle is null)
{
continue;
}
instanceEffects.Add(handle);
}
}
private void RemoveEffects(IForgeEntity forgeEntity)
{
if (!_effectInstances.TryGetValue(forgeEntity, out List<ActiveEffectHandle>? value))
{
return;
}
foreach (ActiveEffectHandle handle in value)
{
forgeEntity.EffectsManager.RemoveEffect(handle);
}
_effectInstances[forgeEntity] = [];
}
}

View File

@@ -0,0 +1 @@
uid://bs52uo5esaiu2

View File

@@ -0,0 +1,14 @@
// Copyright © Gamesmiths Guild.
using Godot;
namespace Gamesmiths.Forge.Godot.Core;
public partial class ForgeBootstrap : Node
{
public override void _Ready()
{
ForgeData pluginData = ResourceLoader.Load<ForgeData>(ForgeData.ForgeDataResourcePath);
_ = new ForgeManagers(pluginData);
}
}

View File

@@ -0,0 +1 @@
uid://ba8fquhtwu5mu

View File

@@ -0,0 +1,21 @@
// Copyright © Gamesmiths Guild.
using Gamesmiths.Forge.Core;
using Godot;
namespace Gamesmiths.Forge.Godot.Core;
public readonly struct ForgeCurve(Curve? curve) : ICurve
{
private readonly Curve? _curve = curve;
public float Evaluate(float value)
{
if (_curve is null)
{
return 1;
}
return _curve.Sample(value);
}
}

View File

@@ -0,0 +1 @@
uid://cei2cgvy84iy6

View File

@@ -0,0 +1,15 @@
// Copyright © Gamesmiths Guild.
using Godot;
using Godot.Collections;
namespace Gamesmiths.Forge.Godot.Core;
[Tool]
public partial class ForgeData : Resource
{
public const string ForgeDataResourcePath = "res://forge/forge_data.tres";
[Export]
public Array<string> RegisteredTags { get; set; } = [];
}

View File

@@ -0,0 +1 @@
uid://bq4vlbfx00hea

View File

@@ -0,0 +1,30 @@
// Copyright © Gamesmiths Guild.
using Gamesmiths.Forge.Core;
using Gamesmiths.Forge.Cues;
using Gamesmiths.Forge.Tags;
namespace Gamesmiths.Forge.Godot.Core;
public class ForgeManagers
{
public static ForgeManagers Instance { get; private set; } = null!;
public TagsManager TagsManager { get; private set; }
public CuesManager CuesManager { get; private set; }
public ForgeManagers(ForgeData pluginData)
{
Instance = this;
#if DEBUG
Validation.Enabled = true;
#else
Validation.Enabled = false;
#endif
TagsManager = new TagsManager([.. pluginData.RegisteredTags]);
CuesManager = new CuesManager();
}
}

View File

@@ -0,0 +1 @@
uid://djeiinm1gclh4

View File

@@ -0,0 +1,149 @@
// Copyright © Gamesmiths Guild.
using System;
using Gamesmiths.Forge.Core;
using Godot;
namespace Gamesmiths.Forge.Godot.Core;
public class ForgeRandom : IRandom, IDisposable
{
private readonly RandomNumberGenerator _randomNumberGenerator;
public ForgeRandom()
{
_randomNumberGenerator = new RandomNumberGenerator();
_randomNumberGenerator.Randomize();
}
public void NextBytes(byte[] buffer)
{
for (int i = 0; i < buffer.Length; i++)
{
buffer[i] = (byte)_randomNumberGenerator.RandiRange(0, 255);
}
}
public void NextBytes(Span<byte> buffer)
{
for (int i = 0; i < buffer.Length; i++)
{
buffer[i] = (byte)_randomNumberGenerator.RandiRange(0, 255);
}
}
public double NextDouble()
{
double value;
do
{
value = _randomNumberGenerator.Randf();
}
while (value >= 1.0d);
return value;
}
public double NextDoubleInclusive()
{
return _randomNumberGenerator.Randf();
}
public int NextInt()
{
return (int)_randomNumberGenerator.Randi();
}
public int NextInt(int maxValue)
{
return _randomNumberGenerator.RandiRange(0, maxValue - 1);
}
public int NextInt(int minValue, int maxValue)
{
return _randomNumberGenerator.RandiRange(minValue, maxValue - 1);
}
public int NextIntInclusive(int minValue, int maxValue)
{
return _randomNumberGenerator.RandiRange(minValue, maxValue);
}
public long NextInt64()
{
unchecked
{
uint high = _randomNumberGenerator.Randi();
uint low = _randomNumberGenerator.Randi();
return ((long)high << 32) | low;
}
}
public long NextInt64(long maxValue)
{
return NextInt64(0, maxValue);
}
public long NextInt64(long minValue, long maxValue)
{
if (minValue >= maxValue)
{
throw new ArgumentOutOfRangeException(nameof(minValue), "minValue must be less than maxValue.");
}
ulong range = (ulong)(maxValue - minValue);
ulong rand = (ulong)NextInt64();
return (long)(rand % range) + minValue;
}
public long NextInt64Inclusive(long minValue, long maxValue)
{
if (minValue > maxValue)
{
throw new ArgumentOutOfRangeException(nameof(minValue), "minValue must be less than or equal to maxValue.");
}
if (minValue == maxValue)
{
return minValue;
}
if (maxValue == long.MaxValue)
{
ulong inclusiveRange = (ulong)(maxValue - minValue) + 1UL;
ulong rand = (ulong)NextInt64();
return (long)(rand % inclusiveRange) + minValue;
}
return NextInt64(minValue, maxValue + 1);
}
public float NextSingle()
{
float value;
do
{
value = _randomNumberGenerator.Randf();
}
while (value >= 1.0f);
return value;
}
public float NextSingleInclusive()
{
return _randomNumberGenerator.Randf();
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
_randomNumberGenerator.Dispose();
}
}

View File

@@ -0,0 +1 @@
uid://dap6x2ddf6baj

View File

@@ -0,0 +1,366 @@
// Copyright © Gamesmiths Guild.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using Gamesmiths.Forge.Core;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers;
using Gamesmiths.Forge.Statescript;
using Gamesmiths.Forge.Statescript.Nodes;
using Godot;
using ForgeNode = Gamesmiths.Forge.Statescript.Node;
using GodotVariant = Godot.Variant;
namespace Gamesmiths.Forge.Godot.Core;
/// <summary>
/// Builds a runtime <see cref="Graph"/> from a serialized <see cref="StatescriptGraph"/> resource.
/// Resolves concrete node types from the Forge DLL and other assemblies using reflection and recreates all connections.
/// </summary>
public static class StatescriptGraphBuilder
{
/// <summary>
/// Builds a runtime <see cref="Graph"/> from the given <see cref="StatescriptGraph"/> resource.
/// </summary>
/// <param name="graphResource">The serialized graph resource.</param>
/// <returns>A fully constructed runtime graph ready for execution.</returns>
/// <exception cref="InvalidOperationException">Thrown when a node type cannot be resolved or instantiated.
/// </exception>
public static Graph Build(StatescriptGraph graphResource)
{
var graph = new Graph();
var nodeMap = new Dictionary<string, ForgeNode>();
foreach (StatescriptNode nodeResource in graphResource.Nodes)
{
switch (nodeResource.NodeType)
{
case StatescriptNodeType.Entry:
nodeMap[nodeResource.NodeId] = graph.EntryNode;
break;
case StatescriptNodeType.Exit:
var exitNode = new ExitNode();
graph.AddNode(exitNode);
nodeMap[nodeResource.NodeId] = exitNode;
break;
default:
ForgeNode runtimeNode = InstantiateNode(nodeResource);
graph.AddNode(runtimeNode);
nodeMap[nodeResource.NodeId] = runtimeNode;
break;
}
}
foreach (StatescriptConnection connectionResource in graphResource.Connections)
{
if (!nodeMap.TryGetValue(connectionResource.FromNode, out ForgeNode? fromNode))
{
GD.PushWarning(
$"Statescript: Connection references unknown source node '{connectionResource.FromNode}'.");
continue;
}
if (!nodeMap.TryGetValue(connectionResource.ToNode, out ForgeNode? toNode))
{
GD.PushWarning(
$"Statescript: Connection references unknown target node '{connectionResource.ToNode}'.");
continue;
}
int outputPortIndex = connectionResource.OutputPort;
int inputPortIndex = connectionResource.InputPort;
if (outputPortIndex < 0 || outputPortIndex >= fromNode.OutputPorts.Length)
{
GD.PushWarning(
$"Statescript: Output port index {outputPortIndex} out of range on node " +
$"'{connectionResource.FromNode}'.");
continue;
}
if (inputPortIndex < 0 || inputPortIndex >= toNode.InputPorts.Length)
{
GD.PushWarning(
$"Statescript: Input port index {inputPortIndex} out of range on node " +
$"'{connectionResource.ToNode}'.");
continue;
}
var connection = new Connection(
fromNode.OutputPorts[outputPortIndex],
toNode.InputPorts[inputPortIndex]);
graph.AddConnection(connection);
}
RegisterGraphVariables(graph, graphResource);
BindNodeProperties(graph, graphResource, nodeMap);
ValidateActivationDataProviders(graphResource);
return graph;
}
private static void RegisterGraphVariables(Graph graph, StatescriptGraph graphResource)
{
foreach (StatescriptGraphVariable variable in graphResource.Variables)
{
if (string.IsNullOrEmpty(variable.VariableName))
{
continue;
}
Type clrType = StatescriptVariableTypeConverter.ToSystemType(variable.VariableType);
if (variable.IsArray)
{
var initialValues = new Variant128[variable.InitialArrayValues.Count];
for (int i = 0; i < variable.InitialArrayValues.Count; i++)
{
initialValues[i] = StatescriptVariableTypeConverter.GodotVariantToForge(
variable.InitialArrayValues[i],
variable.VariableType);
}
graph.VariableDefinitions.ArrayVariableDefinitions.Add(
new ArrayVariableDefinition(
new StringKey(variable.VariableName),
initialValues,
clrType));
}
else
{
Variant128 initialValue = StatescriptVariableTypeConverter.GodotVariantToForge(
variable.InitialValue,
variable.VariableType);
graph.VariableDefinitions.VariableDefinitions.Add(
new VariableDefinition(
new StringKey(variable.VariableName),
initialValue,
clrType));
}
}
}
private static void BindNodeProperties(
Graph graph,
StatescriptGraph graphResource,
Dictionary<string, ForgeNode> nodeMap)
{
foreach (StatescriptNode nodeResource in graphResource.Nodes)
{
if (!nodeMap.TryGetValue(nodeResource.NodeId, out ForgeNode? runtimeNode))
{
continue;
}
foreach (StatescriptNodeProperty binding in nodeResource.PropertyBindings)
{
if (binding.Resolver is null)
{
continue;
}
byte index = (byte)binding.PropertyIndex;
if (binding.Direction == StatescriptPropertyDirection.Input)
{
if (index >= runtimeNode.InputProperties.Length)
{
GD.PushWarning(
$"Statescript: Input property index {index} out of range on node " +
$"'{nodeResource.NodeId}'.");
continue;
}
binding.Resolver.BindInput(graph, runtimeNode, nodeResource.NodeId, index);
}
else
{
if (index >= runtimeNode.OutputVariables.Length)
{
GD.PushWarning(
$"Statescript: Output variable index {index} out of range on node " +
$"'{nodeResource.NodeId}'.");
continue;
}
binding.Resolver.BindOutput(runtimeNode, index);
}
}
}
}
private static void ValidateActivationDataProviders(StatescriptGraph graphResource)
{
string? firstProvider = null;
foreach (StatescriptNode node in graphResource.Nodes)
{
foreach (StatescriptNodeProperty binding in node.PropertyBindings)
{
if (binding.Resolver is ActivationDataResolverResource { ProviderClassName.Length: > 0 } resolver)
{
if (firstProvider is null)
{
firstProvider = resolver.ProviderClassName;
}
else if (resolver.ProviderClassName != firstProvider)
{
GD.PushError(
"Statescript: Graph uses multiple activation data providers " +
$"('{firstProvider}' and '{resolver.ProviderClassName}'). " +
"A graph supports only one activation data provider at a time. " +
"Combine the data into a single provider.");
}
}
}
}
}
private static ForgeNode InstantiateNode(StatescriptNode nodeResource)
{
if (string.IsNullOrEmpty(nodeResource.RuntimeTypeName))
{
throw new InvalidOperationException(
$"Node '{nodeResource.NodeId}' of type {nodeResource.NodeType} has no RuntimeTypeName set.");
}
Type? nodeType = ResolveType(nodeResource.RuntimeTypeName);
if (nodeType is null)
{
throw new InvalidOperationException(
$"Could not resolve runtime type '{nodeResource.RuntimeTypeName}' for node " +
$"'{nodeResource.NodeId}'.");
}
ConstructorInfo[] constructors = nodeType.GetConstructors(BindingFlags.Public | BindingFlags.Instance);
if (constructors.Length == 0)
{
return (ForgeNode)Activator.CreateInstance(nodeType)!;
}
ConstructorInfo constructor = constructors.OrderByDescending(x => x.GetParameters().Length).First();
ParameterInfo[] parameters = constructor.GetParameters();
object[] args = new object[parameters.Length];
for (int i = 0; i < parameters.Length; i++)
{
ParameterInfo param = parameters[i];
string paramName = param.Name ?? string.Empty;
if (nodeResource.CustomData.TryGetValue(paramName, out GodotVariant value))
{
args[i] = ConvertParameter(value, param.ParameterType);
}
else
{
args[i] = GetDefaultValue(param.ParameterType);
}
}
return (ForgeNode)constructor.Invoke(args);
}
private static Type? ResolveType(string typeName)
{
var type = Type.GetType(typeName);
if (type is not null)
{
return type;
}
foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
{
type = assembly.GetType(typeName);
if (type is not null)
{
return type;
}
}
return null;
}
private static object ConvertParameter(GodotVariant value, Type targetType)
{
if (targetType.IsEnum)
{
if (value.VariantType == GodotVariant.Type.Int || value.VariantType == GodotVariant.Type.Float)
{
return Enum.ToObject(targetType, value.AsInt32());
}
string enumText = value.AsString();
if (!string.IsNullOrEmpty(enumText))
{
return Enum.Parse(targetType, enumText, ignoreCase: true);
}
}
if (targetType == typeof(StringKey))
{
return new StringKey(value.AsString());
}
if (targetType == typeof(string))
{
return value.AsString();
}
if (targetType == typeof(int))
{
return value.AsInt32();
}
if (targetType == typeof(float))
{
return value.AsSingle();
}
if (targetType == typeof(double))
{
return value.AsDouble();
}
if (targetType == typeof(bool))
{
return value.AsBool();
}
if (targetType == typeof(long))
{
return value.AsInt64();
}
return Convert.ChangeType(value.AsString(), targetType, CultureInfo.InvariantCulture);
}
private static object GetDefaultValue(Type type)
{
if (type == typeof(StringKey))
{
return new StringKey("_default_");
}
if (type == typeof(string))
{
return string.Empty;
}
if (type.IsValueType)
{
return Activator.CreateInstance(type)!;
}
return null!;
}
}

View File

@@ -0,0 +1 @@
uid://btkf3jeisyh8j

View File

@@ -0,0 +1,86 @@
// Copyright © Gamesmiths Guild.
using Gamesmiths.Forge.Core;
using Gamesmiths.Forge.Statescript;
using GodotPlane = Godot.Plane;
using GodotQuaternion = Godot.Quaternion;
using GodotVector2 = Godot.Vector2;
using GodotVector3 = Godot.Vector3;
using GodotVector4 = Godot.Vector4;
using SysPlane = System.Numerics.Plane;
using SysQuaternion = System.Numerics.Quaternion;
using SysVector2 = System.Numerics.Vector2;
using SysVector3 = System.Numerics.Vector3;
using SysVector4 = System.Numerics.Vector4;
namespace Gamesmiths.Forge.Godot.Core;
/// <summary>
/// Extension methods for <see cref="Variables"/> that provide seamless support for Godot types. These methods
/// automatically convert Godot math types (e.g., <see cref="GodotVector3"/>) to their System.Numerics equivalents
/// before storing them in the variable bag.
/// </summary>
/// <remarks>
/// Use these overloads in data binder delegates (e.g., when implementing
/// <see cref="Resources.IActivationDataProvider.CreateBehavior"/>) to avoid manual Godot-to-System.Numerics
/// conversions.
/// </remarks>
public static class VariablesExtensions
{
/// <summary>
/// Sets a variable from a <see cref="GodotVector2"/> value, converting it to <see cref="SysVector2"/>.
/// </summary>
/// <param name="variables">The variables bag.</param>
/// <param name="name">The name of the variable to set.</param>
/// <param name="value">The Godot Vector2 value to store.</param>
public static void SetGodotVar(this Variables variables, StringKey name, GodotVector2 value)
{
variables.SetVariant(name, new Variant128(new SysVector2(value.X, value.Y)));
}
/// <summary>
/// Sets a variable from a <see cref="GodotVector3"/> value, converting it to <see cref="SysVector3"/>.
/// </summary>
/// <param name="variables">The variables bag.</param>
/// <param name="name">The name of the variable to set.</param>
/// <param name="value">The Godot Vector3 value to store.</param>
public static void SetGodotVar(this Variables variables, StringKey name, GodotVector3 value)
{
variables.SetVariant(name, new Variant128(new SysVector3(value.X, value.Y, value.Z)));
}
/// <summary>
/// Sets a variable from a <see cref="GodotVector4"/> value, converting it to <see cref="SysVector4"/>.
/// </summary>
/// <param name="variables">The variables bag.</param>
/// <param name="name">The name of the variable to set.</param>
/// <param name="value">The Godot Vector4 value to store.</param>
public static void SetGodotVar(this Variables variables, StringKey name, GodotVector4 value)
{
variables.SetVariant(name, new Variant128(new SysVector4(value.X, value.Y, value.Z, value.W)));
}
/// <summary>
/// Sets a variable from a <see cref="GodotPlane"/> value, converting it to <see cref="SysPlane"/>.
/// </summary>
/// <param name="variables">The variables bag.</param>
/// <param name="name">The name of the variable to set.</param>
/// <param name="value">The Godot Plane value to store.</param>
public static void SetGodotVar(this Variables variables, StringKey name, GodotPlane value)
{
variables.SetVariant(
name,
new Variant128(new SysPlane(value.Normal.X, value.Normal.Y, value.Normal.Z, value.D)));
}
/// <summary>
/// Sets a variable from a <see cref="GodotQuaternion"/> value, converting it to <see cref="SysQuaternion"/>.
/// </summary>
/// <param name="variables">The variables bag.</param>
/// <param name="name">The name of the variable to set.</param>
/// <param name="value">The Godot Quaternion value to store.</param>
public static void SetGodotVar(this Variables variables, StringKey name, GodotQuaternion value)
{
variables.SetVariant(name, new Variant128(new SysQuaternion(value.X, value.Y, value.Z, value.W)));
}
}

View File

@@ -0,0 +1 @@
uid://tkifxnyfxgrp

View File

@@ -0,0 +1,7 @@
[gd_resource type="Resource" format=3 uid="uid://8j4xg16o3qnl"]
[ext_resource type="Script" uid="uid://bq4vlbfx00hea" path="res://addons/forge/core/ForgeData.cs" id="1_x0pne"]
[resource]
script = ExtResource("1_x0pne")
RegisteredTags = Array[String](["effect.fire", "effect.wet", "cue.floating.text", "cue.vfx.fire", "cue.vfx.wet", "cue.vfx.regen", "cooldown.enemy.attack", "set_by_caller.damage", "event.damage", "cooldown", "cooldown.skill.projectile", "cooldown.skill.shield", "cooldown.skill.dash", "movement.block", "immunity.damage", "effect.mana_shield", "cue.vfx.shield", "event.damage.taken", "event.damage.dealt", "event", "set_by_caller", "trait.flammable", "trait.healable", "trait.damageable", "trait.wettable", "cue.vfx.reflect", "cue.vfx", "cooldown.skill", "cooldown.skill.reflect", "test"])

View File

@@ -0,0 +1,83 @@
// Copyright © Gamesmiths Guild.
using System;
using System.Collections.Generic;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Statescript.Nodes.Action;
/// <summary>
/// Action node that resolves an input value of any supported type and prints it through
/// <see cref="GD.Print(params Variant[])"/>.
/// Useful for validating resolver chains while testing Statescript graphs in the editor.
/// </summary>
public sealed class DebugNode : ActionNode
{
private readonly StatescriptVariableType _valueType;
/// <inheritdoc/>
public override string Description => "Prints the resolved input value to the Godot console for debugging.";
public DebugNode(StatescriptVariableType valueType = StatescriptVariableType.Int)
{
_valueType = valueType;
}
/// <inheritdoc/>
protected override void DefineParameters(
List<InputProperty> inputProperties,
List<OutputVariable> outputVariables)
{
inputProperties.Add(new InputProperty("Value", StatescriptVariableTypeConverter.ToSystemType(_valueType)));
}
/// <inheritdoc/>
protected override void Execute(GraphContext graphContext)
{
if (!graphContext.TryResolveVariant(InputProperties[0].BoundName, out Variant128 value))
{
GD.Print("[Statescript Debug] <unresolved>");
return;
}
GD.Print("[Statescript Debug] ", FormatValue(value));
}
private string FormatValue(Variant128 value)
{
return _valueType switch
{
StatescriptVariableType.Bool => value.AsBool().ToString(),
StatescriptVariableType.Byte => value.AsByte().ToString(
System.Globalization.CultureInfo.InvariantCulture),
StatescriptVariableType.SByte => value.AsSByte().ToString(
System.Globalization.CultureInfo.InvariantCulture),
StatescriptVariableType.Char => value.AsChar().ToString(),
StatescriptVariableType.Decimal => value.AsDecimal().ToString(
System.Globalization.CultureInfo.InvariantCulture),
StatescriptVariableType.Double => value.AsDouble().ToString(
System.Globalization.CultureInfo.InvariantCulture),
StatescriptVariableType.Float => value.AsFloat().ToString(
System.Globalization.CultureInfo.InvariantCulture),
StatescriptVariableType.Int => value.AsInt().ToString(
System.Globalization.CultureInfo.InvariantCulture),
StatescriptVariableType.UInt => value.AsUInt().ToString(
System.Globalization.CultureInfo.InvariantCulture),
StatescriptVariableType.Long => value.AsLong().ToString(
System.Globalization.CultureInfo.InvariantCulture),
StatescriptVariableType.ULong => value.AsULong().ToString(
System.Globalization.CultureInfo.InvariantCulture),
StatescriptVariableType.Short => value.AsShort().ToString(
System.Globalization.CultureInfo.InvariantCulture),
StatescriptVariableType.UShort => value.AsUShort().ToString(
System.Globalization.CultureInfo.InvariantCulture),
StatescriptVariableType.Vector2 => value.AsVector2().ToString(),
StatescriptVariableType.Vector3 => value.AsVector3().ToString(),
StatescriptVariableType.Vector4 => value.AsVector4().ToString(),
StatescriptVariableType.Plane => value.AsPlane().ToString(),
StatescriptVariableType.Quaternion => value.AsQuaternion().ToString(),
_ => Convert.ToHexString(value.ToBytes()),
};
}
}

View File

@@ -0,0 +1 @@
uid://byp7r8mspi3df

View File

@@ -0,0 +1,210 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using System.Linq;
using Gamesmiths.Forge.Godot.Core;
using Gamesmiths.Forge.Godot.Resources;
using Gamesmiths.Forge.Tags;
using Godot;
using Godot.Collections;
namespace Gamesmiths.Forge.Godot.Editor;
[Tool]
public partial class AssetRepairTool : EditorPlugin
{
public static void RepairAllAssetsTags()
{
ForgeData pluginData = ResourceLoader.Load<ForgeData>(ForgeData.ForgeDataResourcePath);
var tagsManager = new TagsManager([.. pluginData.RegisteredTags]);
List<string> scenes = GetScenePaths("res://");
GD.Print($"Found {scenes.Count} scene(s) to process.");
string[] openedScenes = EditorInterface.Singleton.GetOpenScenes();
foreach (string originalScenePath in scenes)
{
// For some weird reason scenes from the GetScenePath are coming with 3 slashes instead of just two.
string scenePath = originalScenePath.Replace("res:///", "res://");
GD.Print($"Processing scene: {scenePath}.");
PackedScene? packedScene = ResourceLoader.Load<PackedScene>(scenePath);
if (packedScene is null)
{
GD.PrintErr($"Failed to load scene: {scenePath}.");
continue;
}
Node sceneInstance = packedScene.Instantiate();
bool modified = ProcessNode(sceneInstance, tagsManager);
if (!modified)
{
GD.Print($"No changes needed for {scenePath}.");
continue;
}
// 'sceneInstance' is the modified scene instance in memory, need to save to disk and reload if needed.
var newScene = new PackedScene();
Error error = newScene.Pack(sceneInstance);
if (error != Error.Ok)
{
GD.PrintErr($"Failed to pack scene: {error}.");
continue;
}
error = ResourceSaver.Save(newScene, scenePath);
if (error != Error.Ok)
{
GD.PrintErr($"Failed to save scene: {error}.");
continue;
}
if (openedScenes.Contains(scenePath))
{
GD.Print($"Scene was opened, reloading background scene: {scenePath}.");
EditorInterface.Singleton.ReloadSceneFromPath(scenePath);
}
}
}
/// <summary>
/// Recursively get scene files from a folder.
/// </summary>
/// <param name="basePath">Current path iteration.</param>
/// <returns>List of scenes found.</returns>
private static List<string> GetScenePaths(string basePath)
{
var scenePaths = new List<string>();
var dir = DirAccess.Open(basePath);
if (dir is null)
{
GD.PrintErr($"Failed to open directory: {basePath}");
return scenePaths;
}
// Start listing directory entries; skip navigational and hidden files.
dir.ListDirBegin();
while (true)
{
string fileName = dir.GetNext();
if (string.IsNullOrEmpty(fileName))
{
break;
}
string filePath = $"{basePath}/{fileName}";
if (dir.CurrentIsDir())
{
// Recursively scan subdirectories.
scenePaths.AddRange(GetScenePaths(filePath));
}
else if (fileName.EndsWith(".tscn", StringComparison.InvariantCultureIgnoreCase)
|| fileName.EndsWith(".scn", StringComparison.InvariantCultureIgnoreCase))
{
scenePaths.Add(filePath);
}
}
dir.ListDirEnd();
return scenePaths;
}
/// <summary>
/// Recursively process nodes; returns true if any ForgeEntity was modified.
/// </summary>
/// <param name="node">Current node iteration.</param>
/// <param name="tagsManager">The tags manager used to validate tags.</param>
/// <returns><see langword="true"/> if any ForgeEntity was modified.</returns>
private static bool ProcessNode(Node node, TagsManager tagsManager)
{
bool modified = ValidateNode(node, tagsManager);
foreach (Node child in node.GetChildren())
{
modified |= ProcessNode(child, tagsManager);
}
return modified;
}
private static bool ValidateNode(Node node, TagsManager tagsManager)
{
bool modified = false;
foreach (Dictionary propertyInfo in node.GetPropertyList())
{
if (!propertyInfo.TryGetValue("class_name", out Variant className))
{
continue;
}
if (className.AsString() != "TagContainer")
{
continue;
}
if (!propertyInfo.TryGetValue("name", out Variant nameObj))
{
continue;
}
string propertyName = nameObj.AsString();
Variant value = node.Get(propertyName);
if (value.VariantType != Variant.Type.Object)
{
continue;
}
if (value.As<Resource>() is ForgeTagContainer tagContainer)
{
modified |= ValidateTagContainerProperty(tagContainer, node.Name, tagsManager);
}
}
return modified;
}
private static bool ValidateTagContainerProperty(
ForgeTagContainer container,
string nodeName,
TagsManager tagsManager)
{
if (container.ContainerTags is null)
{
return false;
}
Array<string> originalTags = container.ContainerTags;
var newTags = new Array<string>();
bool modified = false;
foreach (string tag in originalTags)
{
try
{
Tag.RequestTag(tagsManager, tag);
newTags.Add(tag);
}
catch (TagNotRegisteredException)
{
GD.PrintRich(
$"[color=LIGHT_STEEL_BLUE][RepairTool] Removing invalid tag [{tag}] from node {nodeName}.");
modified = true;
}
}
if (modified)
{
container.ContainerTags = newTags;
}
return modified;
}
}
#endif

View File

@@ -0,0 +1 @@
uid://1runivyr5don

View File

@@ -0,0 +1,59 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Gamesmiths.Forge.Attributes;
namespace Gamesmiths.Forge.Godot.Editor;
internal static class EditorUtils
{
/// <summary>
/// Uses reflection to gather all classes inheriting from AttributeSet and their fields of type Attribute.
/// </summary>
/// <returns>An array with the available attributes.</returns>
public static string[] GetAttributeSetOptions()
{
var options = new List<string>();
foreach (Type attributeSetType in AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => a.GetTypes())
.Where(x => x.IsSubclassOf(typeof(AttributeSet))))
{
options.Add(attributeSetType.Name);
}
return [.. options];
}
/// <summary>
/// Uses reflection to gather all classes inheriting from AttributeSet and their fields of type Attribute.
/// </summary>
/// <param name="attributeSet">The attribute set used to search for the attributes.</param>
/// <returns>An array with the available attributes.</returns>
public static string[] GetAttributeOptions(string? attributeSet)
{
if (string.IsNullOrEmpty(attributeSet))
{
return [];
}
Type? type = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => a.GetTypes())
.FirstOrDefault(x => x.IsSubclassOf(typeof(AttributeSet)) && x.Name == attributeSet);
if (type is null)
{
return [];
}
IEnumerable<PropertyInfo> properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(x => x.PropertyType == typeof(EntityAttribute));
return [.. properties.Select(x => $"{x.Name}")];
}
}
#endif

View File

@@ -0,0 +1 @@
uid://dcvmf0r1f43m6

View File

@@ -0,0 +1,14 @@
[gd_scene format=3 uid="uid://pjscvogl6jak"]
[ext_resource type="PackedScene" uid="uid://c17f812by5x23" path="res://addons/forge/editor/tags/TagsEditor.tscn" id="1_bxwfw"]
[node name="Forge" type="PanelContainer" unique_id=249446352]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="Tags" parent="." unique_id=654228508 instance=ExtResource("1_bxwfw")]
unique_name_in_owner = true
layout_mode = 2

View File

@@ -0,0 +1,34 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Attributes;
[Tool]
public partial class AttributeEditorPlugin : EditorInspectorPlugin
{
public override bool _CanHandle(GodotObject @object)
{
return @object is Resources.ForgeModifier || @object is Resources.ForgeCue;
}
public override bool _ParseProperty(
GodotObject @object,
Variant.Type type,
string name,
PropertyHint hintType,
string hintString,
PropertyUsageFlags usageFlags,
bool wide)
{
if (name == "Attribute" || name == "CapturedAttribute" || name == "MagnitudeAttribute")
{
AddPropertyEditor(name, new AttributeEditorProperty());
return true;
}
return false;
}
}
#endif

View File

@@ -0,0 +1 @@
uid://bl2w0vp6b5p8k

View File

@@ -0,0 +1,167 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Attributes;
[Tool]
public partial class AttributeEditorProperty : EditorProperty, ISerializationListener
{
private const int ButtonSize = 26;
private const int PopupSize = 300;
private Label? _label;
private Button? _button;
private Popup? _popup;
private Tree? _tree;
public override void _Ready()
{
Texture2D dropdownIcon = EditorInterface.Singleton.GetEditorTheme().GetIcon("GuiDropdown", "EditorIcons");
var hBox = new HBoxContainer();
_label = new Label { Text = "None", SizeFlagsHorizontal = SizeFlags.ExpandFill };
_button = new Button { Icon = dropdownIcon, CustomMinimumSize = new Vector2(ButtonSize, 0) };
hBox.AddChild(_label);
hBox.AddChild(_button);
AddChild(hBox);
_popup = new Popup { Size = new Vector2I(PopupSize, PopupSize) };
_tree = new Tree
{
HideRoot = true,
AnchorRight = 1,
AnchorBottom = 1,
};
_popup.AddChild(_tree);
var bg = new StyleBoxFlat
{
BgColor = EditorInterface.Singleton
.GetEditorTheme()
.GetColor("dark_color_2", "Editor"),
};
_tree.AddThemeStyleboxOverride("panel", bg);
AddChild(_popup);
BuildAttributeTree(_tree);
_button.Pressed += OnButtonPressed;
_tree.ItemActivated += OnTreeItemActivated;
}
public override void _UpdateProperty()
{
if (_label is null || !IsInstanceValid(_label))
{
return;
}
string value = GetEditedObject().Get(GetEditedProperty()).AsString();
_label.Text = string.IsNullOrEmpty(value) ? "None" : value;
}
public override void _ExitTree()
{
ReleaseUiState();
FreeAllChildren();
base._ExitTree();
}
public void OnBeforeSerialize()
{
ReleaseUiState();
FreeAllChildren();
}
public void OnAfterDeserialize()
{
// This method was intentionally left blank.
}
private static void BuildAttributeTree(Tree tree)
{
TreeItem root = tree.CreateItem();
foreach (string attributeSet in EditorUtils.GetAttributeSetOptions())
{
TreeItem setItem = tree.CreateItem(root);
setItem.SetText(0, attributeSet);
setItem.Collapsed = true;
foreach (string attribute in EditorUtils.GetAttributeOptions(attributeSet))
{
TreeItem attributeItem = tree.CreateItem(setItem);
string attributePath = $"{attributeSet}.{attribute}";
attributeItem.SetText(0, attribute);
attributeItem.SetMeta("attribute_path", attributePath);
}
}
}
private void OnButtonPressed()
{
if (_button is null || _popup is null || !IsInstanceValid(_button) || !IsInstanceValid(_popup))
{
return;
}
Window win = GetWindow();
_popup.Position = (Vector2I)_button.GlobalPosition
+ win.Position
- new Vector2I(PopupSize - ButtonSize, -30);
_popup.Popup();
}
private void OnTreeItemActivated()
{
if (_tree is null || _popup is null || _label is null
|| !IsInstanceValid(_tree) || !IsInstanceValid(_popup) || !IsInstanceValid(_label))
{
return;
}
TreeItem item = _tree.GetSelected();
if (item?.HasMeta("attribute_path") != true)
{
return;
}
string fullPath = item.GetMeta("attribute_path").AsString();
_label.Text = fullPath;
EmitChanged(GetEditedProperty(), fullPath);
_popup.Hide();
}
private void ReleaseUiState()
{
if (_button is not null && IsInstanceValid(_button))
{
_button.Pressed -= OnButtonPressed;
}
if (_tree is not null && IsInstanceValid(_tree))
{
_tree.ItemActivated -= OnTreeItemActivated;
}
_label = null;
_button = null;
_popup = null;
_tree = null;
}
private void FreeAllChildren()
{
for (int i = GetChildCount() - 1; i >= 0; i--)
{
Node child = GetChild(i);
RemoveChild(child);
child.Free();
}
}
}
#endif

View File

@@ -0,0 +1 @@
uid://dvjqj637kfav

View File

@@ -0,0 +1,139 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Linq;
using System.Reflection;
using Gamesmiths.Forge.Attributes;
using Gamesmiths.Forge.Godot.Nodes;
using Godot;
using Godot.Collections;
namespace Gamesmiths.Forge.Godot.Editor.Attributes;
[Tool]
public partial class AttributeSetClassEditorProperty : EditorProperty, ISerializationListener
{
private OptionButton? _optionButton;
public override void _Ready()
{
_optionButton = new OptionButton();
AddChild(_optionButton);
_optionButton.AddItem("Select AttributeSet Class");
foreach (string option in EditorUtils.GetAttributeSetOptions())
{
_optionButton.AddItem(option);
}
_optionButton.ItemSelected += OnItemSelected;
}
public override void _UpdateProperty()
{
if (_optionButton is null || !IsInstanceValid(_optionButton))
{
return;
}
GodotObject obj = GetEditedObject();
StringName property = GetEditedProperty();
string val = obj.Get(property).AsString();
for (int i = 0; i < _optionButton.GetItemCount(); i++)
{
if (_optionButton.GetItemText(i) == val)
{
_optionButton.Selected = i;
break;
}
}
}
public override void _ExitTree()
{
ReleaseUiState();
FreeAllChildren();
base._ExitTree();
}
public void OnBeforeSerialize()
{
ReleaseUiState();
FreeAllChildren();
}
public void OnAfterDeserialize()
{
}
private void OnItemSelected(long index)
{
if (_optionButton is null || !IsInstanceValid(_optionButton))
{
return;
}
string className = _optionButton.GetItemText((int)index);
EmitChanged(GetEditedProperty(), className);
GodotObject @object = GetEditedObject();
if (@object is null)
{
return;
}
var dictionary = new Dictionary<string, AttributeValues>();
var assembly = Assembly.GetAssembly(typeof(ForgeAttributeSet));
Type? targetType = System.Array.Find(assembly?.GetTypes() ?? [], x => x.Name == className);
if (targetType is not null)
{
System.Collections.Generic.IEnumerable<PropertyInfo> attributeProperties = targetType
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(x => x.PropertyType == typeof(EntityAttribute));
foreach (string? propertyName in attributeProperties.Select(x => x.Name))
{
if (@object is not ForgeAttributeSet forgeAttributeSet)
{
dictionary[propertyName] = new AttributeValues(0, 0, int.MaxValue);
continue;
}
AttributeSet? attributeSet = forgeAttributeSet.GetAttributeSet();
if (attributeSet is null)
{
dictionary[propertyName] = new AttributeValues(0, 0, int.MaxValue);
continue;
}
EntityAttribute key = attributeSet.AttributesMap[className + "." + propertyName];
dictionary[propertyName] = new AttributeValues(key.CurrentValue, key.Min, key.Max);
}
}
EmitChanged("InitialAttributeValues", dictionary);
}
private void ReleaseUiState()
{
if (_optionButton is not null && IsInstanceValid(_optionButton))
{
_optionButton.ItemSelected -= OnItemSelected;
}
_optionButton = null;
}
private void FreeAllChildren()
{
for (int i = GetChildCount() - 1; i >= 0; i--)
{
Node child = GetChild(i);
RemoveChild(child);
child.Free();
}
}
}
#endif

View File

@@ -0,0 +1 @@
uid://bumuxlivyt66b

View File

@@ -0,0 +1,43 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using Gamesmiths.Forge.Godot.Nodes;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Attributes;
[Tool]
public partial class AttributeSetInspectorPlugin : EditorInspectorPlugin
{
private PackedScene? _inspectorScene;
public override bool _CanHandle(GodotObject @object)
{
return @object is ForgeAttributeSet;
}
public override bool _ParseProperty(
GodotObject @object,
Variant.Type type,
string name,
PropertyHint hintType,
string hintString,
PropertyUsageFlags usageFlags,
bool wide)
{
if (name == "AttributeSetClass")
{
AddPropertyEditor(name, new AttributeSetClassEditorProperty());
return true;
}
if (name == "InitialAttributeValues")
{
AddPropertyEditor(name, new AttributeSetValuesEditorProperty());
return true;
}
return false;
}
}
#endif

View File

@@ -0,0 +1 @@
uid://t3gpjlcyqor

View File

@@ -0,0 +1,190 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using Gamesmiths.Forge.Attributes;
using Gamesmiths.Forge.Godot.Nodes;
using Godot;
using Godot.Collections;
namespace Gamesmiths.Forge.Godot.Editor.Attributes;
[Tool]
public partial class AttributeSetValuesEditorProperty : EditorProperty, ISerializationListener
{
public override void _Ready()
{
var attributesRoot = new VBoxContainer { Name = "AttributesRoot" };
AddChild(attributesRoot);
SetBottomEditor(attributesRoot);
}
public override void _UpdateProperty()
{
VBoxContainer attributesRoot = GetNodeOrNull<VBoxContainer>("AttributesRoot");
if (attributesRoot is null)
{
return;
}
FreeAllChildren(attributesRoot);
if (GetEditedObject() is not ForgeAttributeSet obj
|| string.IsNullOrEmpty(obj.AttributeSetClass)
|| obj.InitialAttributeValues is null)
{
return;
}
string className = obj.AttributeSetClass;
var assembly = Assembly.GetAssembly(typeof(ForgeAttributeSet));
Type? targetType = System.Array.Find(assembly?.GetTypes() ?? [], x => x.Name == className);
if (targetType is null)
{
return;
}
System.Collections.Generic.IEnumerable<PropertyInfo> attributeProperties = targetType
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(x => x.PropertyType == typeof(EntityAttribute));
foreach (string? attributeName in attributeProperties.Select(x => x.Name))
{
var groupVBox = new VBoxContainer();
groupVBox.AddChild(AttributeHeader(attributeName));
AttributeValues value = obj.InitialAttributeValues.TryGetValue(attributeName, out AttributeValues? v)
? v
: new AttributeValues(0, 0, int.MaxValue);
SpinBox spinDefault = CreateSpinBox(value.Min, value.Max, value.Default);
SpinBox spinMin = CreateSpinBox(int.MinValue, value.Max, value.Min);
SpinBox spinMax = CreateSpinBox(value.Min, int.MaxValue, value.Max);
groupVBox.AddChild(AttributeFieldRow("Default", spinDefault));
groupVBox.AddChild(AttributeFieldRow("Min", spinMin));
groupVBox.AddChild(AttributeFieldRow("Max", spinMax));
spinDefault.ValueChanged += x =>
{
UpdateAndEmit(obj, attributeName, (int)x, (int)spinMin.Value, (int)spinMax.Value);
};
spinMin.ValueChanged += x =>
{
spinDefault.MinValue = x;
spinMax.MinValue = x;
UpdateAndEmit(obj, attributeName, (int)spinDefault.Value, (int)x, (int)spinMax.Value);
};
spinMax.ValueChanged += x =>
{
spinDefault.MaxValue = x;
spinMin.MaxValue = x;
UpdateAndEmit(obj, attributeName, (int)spinDefault.Value, (int)spinMin.Value, (int)x);
};
attributesRoot.AddChild(groupVBox);
}
}
public void OnBeforeSerialize()
{
VBoxContainer? attributesRoot = GetNodeOrNull<VBoxContainer>("AttributesRoot");
if (attributesRoot is not null)
{
for (int i = attributesRoot.GetChildCount() - 1; i >= 0; i--)
{
Node child = attributesRoot.GetChild(i);
attributesRoot.RemoveChild(child);
child.Free();
}
}
}
public void OnAfterDeserialize()
{
}
private static PanelContainer AttributeHeader(string text)
{
var headerPanel = new PanelContainer
{
CustomMinimumSize = new Vector2(0, 28),
};
var style = new StyleBoxFlat
{
BgColor = new Color(0.16f, 0.17f, 0.20f),
};
headerPanel.AddThemeStyleboxOverride("panel", style);
var label = new Label
{
Text = text,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
SizeFlagsHorizontal = (SizeFlags)(int)SizeFlags.ExpandFill,
CustomMinimumSize = new Vector2(0, 22),
AutowrapMode = TextServer.AutowrapMode.Off,
};
headerPanel.AddChild(label);
return headerPanel;
}
private static HBoxContainer AttributeFieldRow(string label, SpinBox spinBox)
{
var hBox = new HBoxContainer();
hBox.AddChild(new Label
{
Text = label,
CustomMinimumSize = new Vector2(80, 0),
SizeFlagsHorizontal = SizeFlags.ExpandFill,
});
hBox.AddChild(spinBox);
return hBox;
}
private static SpinBox CreateSpinBox(int min, int max, int value)
{
return new SpinBox
{
MinValue = min,
MaxValue = max,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
SelectAllOnFocus = true,
Value = value,
};
}
private static void FreeAllChildren(Node node)
{
for (int i = node.GetChildCount() - 1; i >= 0; i--)
{
node.GetChild(i).QueueFree();
}
}
private void UpdateAndEmit(ForgeAttributeSet obj, string name, int def, int min, int max)
{
Debug.Assert(obj.InitialAttributeValues is not null, "InitialAttributeValues should not be null here.");
var dict = new Dictionary<string, AttributeValues>(obj.InitialAttributeValues)
{
[name] = new AttributeValues(def, min, max),
};
EmitChanged(nameof(ForgeAttributeSet.InitialAttributeValues), dict);
}
}
#endif

View File

@@ -0,0 +1 @@
uid://cdj20gbpxkda1

View File

@@ -0,0 +1,29 @@
// Copyright © Gamesmiths Guild.
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Attributes;
[Tool]
public partial class AttributeValues : RefCounted
{
[Export]
public int Default { get; set; }
[Export]
public int Min { get; set; }
[Export]
public int Max { get; set; }
public AttributeValues()
{
}
public AttributeValues(int @default, int min, int max)
{
Default = @default;
Min = min;
Max = max;
}
}

View File

@@ -0,0 +1 @@
uid://ccovd5i0wr3kk

View File

@@ -0,0 +1,53 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using Gamesmiths.Forge.Godot.Nodes;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Cues;
[Tool]
public partial class CueHandlerInspectorPlugin : EditorInspectorPlugin
{
public override bool _CanHandle(GodotObject @object)
{
// Find out if its an implementation of CueHandler without having to add [Tool] attribute to them.
if (@object?.GetScript().As<CSharpScript>() is CSharpScript script)
{
StringName className = script.GetGlobalName();
Type baseType = typeof(ForgeCueHandler);
System.Reflection.Assembly assembly = baseType.Assembly;
Type? implementationType =
Array.Find(assembly.GetTypes(), x =>
x.Name == className &&
baseType.IsAssignableFrom(x));
return implementationType is not null;
}
return false;
}
public override bool _ParseProperty(
GodotObject @object,
Variant.Type type,
string name,
PropertyHint hintType,
string hintString,
PropertyUsageFlags usageFlags,
bool wide)
{
if (name == "CueTag")
{
var cueKeyEditorProperty = new CueKeyEditorProperty();
AddPropertyEditor(name, cueKeyEditorProperty);
return true;
}
return base._ParseProperty(@object, type, name, hintType, hintString, usageFlags, wide);
}
}
#endif

View File

@@ -0,0 +1 @@
uid://dattkelp87mhv

View File

@@ -0,0 +1,171 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System.Collections.Generic;
using Gamesmiths.Forge.Godot.Core;
using Gamesmiths.Forge.Tags;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Cues;
[Tool]
public partial class CueKeyEditorProperty : EditorProperty, ISerializationListener
{
private const int ButtonSize = 26;
private const int PopupSize = 300;
private Label? _label;
private Button? _button;
private Popup? _popup;
private Tree? _tree;
public override void _Ready()
{
Texture2D dropdownIcon = EditorInterface.Singleton
.GetEditorTheme()
.GetIcon("GuiDropdown", "EditorIcons");
var hbox = new HBoxContainer();
_label = new Label { Text = "None", SizeFlagsHorizontal = SizeFlags.ExpandFill };
_button = new Button { Icon = dropdownIcon, CustomMinimumSize = new Vector2(ButtonSize, 0) };
hbox.AddChild(_label);
hbox.AddChild(_button);
AddChild(hbox);
_popup = new Popup { Size = new Vector2I(PopupSize, PopupSize) };
_tree = new Tree
{
HideRoot = true,
AnchorRight = 1,
AnchorBottom = 1,
};
_popup.AddChild(_tree);
var backgroundStyle = new StyleBoxFlat
{
BgColor = EditorInterface.Singleton.GetEditorTheme().GetColor("base_color", "Editor"),
};
_tree.AddThemeStyleboxOverride("panel", backgroundStyle);
AddChild(_popup);
ForgeData pluginData = ResourceLoader.Load<ForgeData>(ForgeData.ForgeDataResourcePath);
var tagsManager = new TagsManager([.. pluginData.RegisteredTags]);
TreeItem root = _tree.CreateItem();
BuildTreeRecursively(_tree, root, tagsManager.RootNode);
_button.Pressed += OnButtonPressed;
_tree.ItemActivated += OnTreeItemActivated;
}
public override void _UpdateProperty()
{
string property = GetEditedObject().Get(GetEditedProperty()).AsString();
if (_label is not null && IsInstanceValid(_label))
{
_label.Text = string.IsNullOrEmpty(property) ? "None" : property;
}
}
public override void _ExitTree()
{
ReleaseUiState();
FreeAllChildren();
base._ExitTree();
}
public void OnBeforeSerialize()
{
ReleaseUiState();
FreeAllChildren();
}
public void OnAfterDeserialize()
{
}
private static void BuildTreeRecursively(Tree tree, TreeItem currentTreeItem, TagNode currentNode)
{
foreach (TagNode childTagNode in currentNode.ChildTags)
{
TreeItem childTreeNode = tree.CreateItem(currentTreeItem);
childTreeNode.SetText(0, childTagNode.TagKey);
childTreeNode.Collapsed = true;
BuildTreeRecursively(tree, childTreeNode, childTagNode);
}
}
private void OnButtonPressed()
{
if (_button is null || _popup is null || !IsInstanceValid(_button) || !IsInstanceValid(_popup))
{
return;
}
Window win = GetWindow();
_popup.Position = (Vector2I)_button.GlobalPosition
+ win.Position
- new Vector2I(PopupSize - ButtonSize, -30);
_popup.Popup();
}
private void OnTreeItemActivated()
{
if (_tree is null || _popup is null || _label is null
|| !IsInstanceValid(_tree) || !IsInstanceValid(_popup) || !IsInstanceValid(_label))
{
return;
}
TreeItem item = _tree.GetSelected();
if (item is null)
{
return;
}
var segments = new List<string>();
TreeItem current = item;
while (current.GetParent() is not null)
{
segments.Insert(0, current.GetText(0));
current = current.GetParent();
}
string fullPath = string.Join(".", segments);
_label.Text = fullPath;
EmitChanged(GetEditedProperty(), fullPath);
_popup.Hide();
}
private void ReleaseUiState()
{
if (_button is not null && IsInstanceValid(_button))
{
_button.Pressed -= OnButtonPressed;
}
if (_tree is not null && IsInstanceValid(_tree))
{
_tree.ItemActivated -= OnTreeItemActivated;
}
_label = null;
_button = null;
_popup = null;
_tree = null;
}
private void FreeAllChildren()
{
for (int i = GetChildCount() - 1; i >= 0; i--)
{
Node child = GetChild(i);
RemoveChild(child);
child.Free();
}
}
}
#endif

View File

@@ -0,0 +1 @@
uid://csmr2puffid4k

View File

@@ -0,0 +1 @@
uid://dnsy7p8h1ujjv

View File

@@ -0,0 +1,307 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Base class for custom node property editors. Implementations override the default input-property / output-variable
/// sections rendered by <see cref="StatescriptGraphNode"/> for specific node types. Analogous to Godot's
/// <c>EditorInspectorPlugin</c> pattern.
/// </summary>
/// <remarks>
/// <para>
/// If a <see cref="CustomNodeEditor"/> is registered for a node's <c>RuntimeTypeName</c>, its
/// <see cref="BuildPropertySections"/> method is called instead of the default property rendering. The base class
/// provides helper methods that mirror the default behavior so that custom editors can reuse them selectively.
/// </para>
/// <para>
/// Because this class extends <see cref="RefCounted"/>, signal handlers defined on subclasses can be connected
/// directly to Godot signals (e.g. <c>dropdown.ItemSelected += OnItemSelected</c>) without needing wrapper nodes
/// or workarounds for serialization.
/// </para>
/// </remarks>
[Tool]
internal abstract partial class CustomNodeEditor : RefCounted
{
private StatescriptGraphNode? _graphNode;
private StatescriptGraph? _graph;
private StatescriptNode? _nodeResource;
private Dictionary<PropertySlotKey, NodeEditorProperty>? _activeResolverEditors;
/// <summary>
/// Gets the runtime type name this editor handles (e.g.,
/// <c>"Gamesmiths.Forge.Statescript.Nodes.Action.SetVariableNode"</c>).
/// </summary>
public abstract string HandledRuntimeTypeName { get; }
/// <summary>
/// Builds the custom input-property and output-variable sections for the node.
/// </summary>
/// <param name="typeInfo">Discovered metadata about the node type.</param>
public abstract void BuildPropertySections(StatescriptNodeDiscovery.NodeTypeInfo typeInfo);
/// <summary>
/// Gets the input property section color.
/// </summary>
protected static Color InputPropertyColor { get; } = new(0x61afefff);
/// <summary>
/// Gets the output variable section color.
/// </summary>
protected static Color OutputVariableColor { get; } = new(0xe5c07bff);
/// <summary>
/// Gets the active resolver editors dictionary.
/// </summary>
protected Dictionary<PropertySlotKey, NodeEditorProperty> ActiveResolverEditors => _activeResolverEditors!;
/// <summary>
/// Gets the owning graph resource.
/// </summary>
protected StatescriptGraph Graph => _graph!;
/// <summary>
/// Gets the node resource.
/// </summary>
protected StatescriptNode NodeResource => _nodeResource!;
/// <summary>
/// Gets the undo/redo manager, if available.
/// </summary>
protected EditorUndoRedoManager? UndoRedo => _graphNode?.GetUndoRedo();
/// <summary>
/// Stores references needed by helper methods. Called once after the instance is created.
/// </summary>
/// <param name="graphNode">The graph node this editor is bound to.</param>
/// <param name="graph">The graph resource this node belongs to.</param>
/// <param name="nodeResource">The node resource being edited.</param>
/// <param name="activeResolverEditors">A dictionary of active resolver editors.</param>
internal void Bind(
StatescriptGraphNode graphNode,
StatescriptGraph graph,
StatescriptNode nodeResource,
Dictionary<PropertySlotKey, NodeEditorProperty> activeResolverEditors)
{
_graphNode = graphNode;
_graph = graph;
_nodeResource = nodeResource;
_activeResolverEditors = activeResolverEditors;
}
/// <summary>
/// Clears all references stored by <see cref="Bind"/>. Called before the owning graph node is freed or serialized
/// to prevent accessing disposed objects.
/// </summary>
internal virtual void Unbind()
{
_graphNode = null;
_graph = null;
_nodeResource = null;
_activeResolverEditors = null;
}
/// <summary>
/// Clears all children from a container control.
/// </summary>
/// <param name="container">The container control to clear.</param>
protected static void ClearContainer(Control container)
{
foreach (Node child in container.GetChildren())
{
container.RemoveChild(child);
child.Free();
}
}
/// <summary>
/// Adds a foldable section divider to the graph node.
/// </summary>
/// <param name="sectionTitle">Title displayed on the divider.</param>
/// <param name="color">Color of the divider.</param>
/// <param name="foldKey">Key used to persist the fold state.</param>
/// <param name="folded">Initial fold state.</param>
protected FoldableContainer AddPropertySectionDivider(
string sectionTitle,
Color color,
string foldKey,
bool folded)
{
return _graphNode!.AddPropertySectionDividerInternal(sectionTitle, color, foldKey, folded);
}
/// <summary>
/// Renders a standard input-property row (resolver dropdown + editor UI).
/// </summary>
/// <param name="propInfo">Metadata about the input property.</param>
/// <param name="index">Index of the input property.</param>
/// <param name="container">Container to add the input property row to.</param>
protected void AddInputPropertyRow(
StatescriptNodeDiscovery.InputPropertyInfo propInfo,
int index,
Control container)
{
_graphNode!.AddInputPropertyRowInternal(propInfo, index, container);
}
/// <summary>
/// Renders a standard output-variable row (variable dropdown).
/// </summary>
/// <param name="varInfo">Metadata about the output variable.</param>
/// <param name="index">Index of the output variable.</param>
/// <param name="container">Container to add the output variable row to.</param>
protected void AddOutputVariableRow(
StatescriptNodeDiscovery.OutputVariableInfo varInfo,
int index,
FoldableContainer container)
{
_graphNode!.AddOutputVariableRowInternal(varInfo, index, container);
}
/// <summary>
/// Gets the persisted fold state for a given key.
/// </summary>
/// <param name="key">The key used to persist the fold state.</param>
protected bool GetFoldState(string key)
{
return _graphNode!.GetFoldStateInternal(key);
}
/// <summary>
/// Gets the persisted fold state for a given key, with a custom default when unset.
/// </summary>
/// <param name="key">The key used to persist the fold state.</param>
/// <param name="defaultValue">The default fold state when no persisted value exists.</param>
protected bool GetFoldState(string key, bool defaultValue)
{
return _graphNode!.GetFoldStateInternal(key, defaultValue);
}
/// <summary>
/// Persists a fold state change with undo support.
/// </summary>
/// <param name="key">The key used to persist the fold state.</param>
/// <param name="folded">The new folded state.</param>
protected void SetFoldStateWithUndo(string key, bool folded)
{
_graphNode!.SetFoldStateWithUndoInternal(key, folded);
}
/// <summary>
/// Finds an existing property binding by direction and index.
/// </summary>
/// <param name="direction">The direction of the property (input or output).</param>
/// <param name="propertyIndex">The index of the property.</param>
protected StatescriptNodeProperty? FindBinding(
StatescriptPropertyDirection direction,
int propertyIndex)
{
return _graphNode!.FindBindingInternal(direction, propertyIndex);
}
/// <summary>
/// Ensures a property binding exists for the given direction and index, creating one if needed.
/// </summary>
/// <param name="direction">The direction of the property (input or output).</param>
/// <param name="propertyIndex">The index of the property.</param>
protected StatescriptNodeProperty EnsureBinding(
StatescriptPropertyDirection direction,
int propertyIndex)
{
return _graphNode!.EnsureBindingInternal(direction, propertyIndex);
}
/// <summary>
/// Removes a property binding by direction and index.
/// </summary>
/// <param name="direction">The direction of the property (input or output).</param>
/// <param name="propertyIndex">The index of the property.</param>
protected void RemoveBinding(
StatescriptPropertyDirection direction,
int propertyIndex)
{
_graphNode!.RemoveBindingInternal(direction, propertyIndex);
}
/// <summary>
/// Shows a resolver editor inside the given container.
/// </summary>
/// <param name="factory">A factory function to create the resolver editor.</param>
/// <param name="existingBinding">The existing binding, if any.</param>
/// <param name="expectedType">The expected type for the resolver editor.</param>
/// <param name="container">The container to add the resolver editor to.</param>
/// <param name="direction">The direction of the property (input or output).</param>
/// <param name="propertyIndex">The index of the property.</param>
/// <param name="isArray">Whether the input expects an array of values.</param>
protected void ShowResolverEditorUI(
Func<NodeEditorProperty> factory,
StatescriptNodeProperty? existingBinding,
Type expectedType,
VBoxContainer container,
StatescriptPropertyDirection direction,
int propertyIndex,
bool isArray = false)
{
_graphNode!.ShowResolverEditorUIInternal(
factory,
existingBinding,
expectedType,
container,
direction,
propertyIndex,
isArray);
}
/// <summary>
/// Requests the owning graph node to recalculate its size.
/// </summary>
protected void ResetSize()
{
_graphNode!.ResetSize();
}
/// <summary>
/// Refreshes standard input-property foldable summaries.
/// </summary>
protected void RefreshInputPropertyFoldableTitles()
{
_graphNode!.UpdateInputPropertyFoldableTitlesInternal();
}
/// <summary>
/// Raises the <see cref="StatescriptGraphNode.PropertyBindingChanged"/> event.
/// </summary>
protected void RaisePropertyBindingChanged()
{
_graphNode!.RaisePropertyBindingChangedInternal();
}
/// <summary>
/// Records an undo/redo action for changing a resolver binding, then rebuilds the node.
/// </summary>
/// <param name="direction">The direction of the property.</param>
/// <param name="propertyIndex">The index of the property.</param>
/// <param name="oldResolver">The previous resolver resource.</param>
/// <param name="newResolver">The new resolver resource.</param>
/// <param name="actionName">The name for the undo/redo action.</param>
protected void RecordResolverBindingChange(
StatescriptPropertyDirection direction,
int propertyIndex,
StatescriptResolverResource? oldResolver,
StatescriptResolverResource? newResolver,
string actionName = "Change Node Property")
{
_graphNode!.RecordResolverBindingChangeInternal(
direction,
propertyIndex,
oldResolver,
newResolver,
actionName);
}
}
#endif

View File

@@ -0,0 +1 @@
uid://f47pprjqcskr

View File

@@ -0,0 +1,52 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Registry of <see cref="CustomNodeEditor"/> implementations. Custom node editors are discovered automatically via
/// reflection. Any concrete subclass of <see cref="CustomNodeEditor"/> in the executing assembly is registered and
/// overrides the default property rendering for its handled node type.
/// </summary>
internal static class CustomNodeEditorRegistry
{
private static readonly Dictionary<string, Func<CustomNodeEditor>> _factories = [];
static CustomNodeEditorRegistry()
{
Type[] allTypes = Assembly.GetExecutingAssembly().GetTypes();
foreach (Type type in allTypes.Where(
x => x.IsSubclassOf(typeof(CustomNodeEditor)) && !x.IsAbstract))
{
Type captured = type;
using var temp = (CustomNodeEditor)Activator.CreateInstance(captured)!;
_factories[temp.HandledRuntimeTypeName] = () => (CustomNodeEditor)Activator.CreateInstance(captured)!;
}
}
/// <summary>
/// Tries to create a new custom node editor for the given runtime type name.
/// </summary>
/// <param name="runtimeTypeName">The runtime type name of the node.</param>
/// <param name="editor">The newly created editor, or <see langword="null"/> if none is registered.</param>
/// <returns><see langword="true"/> if a custom editor was created.</returns>
public static bool TryCreate(string runtimeTypeName, [NotNullWhen(true)] out CustomNodeEditor? editor)
{
if (_factories.TryGetValue(runtimeTypeName, out Func<CustomNodeEditor>? factory))
{
editor = factory();
return true;
}
editor = null;
return false;
}
}
#endif

View File

@@ -0,0 +1 @@
uid://dk4rjrm6ky3rd

View File

@@ -0,0 +1,656 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
internal static class InlineConstantSummaryFormatter
{
private const string SummaryBadgeMetaKey = "forge_inline_summary_badge";
private const string SummaryBadgeInstanceIdMetaKey = "forge_inline_summary_badge_instance_id";
private const string SummaryBadgeResizeHookMetaKey = "forge_inline_summary_badge_resize_hook";
private const string SummaryBadgeKindMetaKey = "forge_inline_summary_badge_kind";
private const string SummaryBadgeTextMetaKey = "forge_inline_summary_badge_text";
private const string SummaryBadgeSelectedVariableMetaKey = "forge_inline_summary_badge_selected_variable";
private const string SummaryBadgeSelectedSharedVariableSetPathMetaKey =
"forge_inline_summary_badge_selected_shared_set_path";
private const string SummaryBadgeSelectedSharedVariableMetaKey =
"forge_inline_summary_badge_selected_shared_variable";
private const float MinimumBadgeWidth = 76f;
private const float FoldableTitleChromeWidth = 30f;
private const float FoldableTitleBadgeGap = 6f;
private const float FoldableTitleRightPadding = 8f;
private static readonly Color _numericIconColor = new(0x3dbcc9ff);
private static readonly Color _numericBackgroundColor = new(0x3dbcc918);
private static readonly Color _numericBorderColor = new(0x3dbcc9ff);
private static readonly Color _vectorIconColor = new(0xd48a3aff);
private static readonly Color _vectorBackgroundColor = new(0xd48a3a18);
private static readonly Color _vectorBorderColor = new(0xd48a3aff);
private static readonly Color _booleanIconColor = new(0xc2a24fff);
private static readonly Color _booleanBackgroundColor = new(0xc2a24f18);
private static readonly Color _booleanBorderColor = new(0xc2a24fff);
private static readonly Color _resolverIconColor = new(0xc06bcfff);
private static readonly Color _resolverBackgroundColor = new(0xc06bcf18);
private static readonly Color _resolverBorderColor = new(0xc06bcfff);
private static readonly Color _variableIconColor = new(0x5d7be0ff);
private static readonly Color _variableBackgroundColor = new(0x5d7be018);
private static readonly Color _variableBorderColor = new(0x5d7be0ff);
private static readonly Color _sharedVariableIconColor = new(0x46a86fff);
private static readonly Color _sharedVariableBackgroundColor = new(0x46a86f18);
private static readonly Color _sharedVariableBorderColor = new(0x46a86fff);
private static readonly Color _enumIconColor = new(0xc0c6d1ff);
private static readonly Color _enumBackgroundColor = new(0xc0c6d118);
private static readonly Color _enumBorderColor = new(0xc0c6d1ff);
public static void ApplyFoldableTitle(
string baseTitle,
FoldableContainer foldable,
NodeEditorProperty? editor)
{
EnsureResizeSyncHook(foldable);
foldable.Title = baseTitle;
SummaryBadgeData badgeData = GetBadgeData(foldable, editor);
PanelContainer badge = GetOrCreateSummaryBadge(foldable);
ConfigureSummaryBadge(badge, badgeData);
SynchronizeSiblingBadgeWidths(foldable);
}
public static void ApplyFoldableTitle(
string baseTitle,
FoldableContainer foldable,
string? summary,
InlineSummaryBadgeKind badgeKind,
bool isConstant = false,
string? highlightedVariableName = null,
string? highlightedSharedVariableSetPath = null,
string? highlightedSharedVariableName = null)
{
EnsureResizeSyncHook(foldable);
foldable.Title = baseTitle;
SummaryBadgeData badgeData = foldable.Folded && !string.IsNullOrWhiteSpace(summary)
? CreateBadgeData(
summary,
badgeKind,
isConstant,
highlightedVariableName,
highlightedSharedVariableSetPath,
highlightedSharedVariableName)
: SummaryBadgeData.Hidden;
PanelContainer badge = GetOrCreateSummaryBadge(foldable);
ConfigureSummaryBadge(badge, badgeData);
SynchronizeSiblingBadgeWidths(foldable);
}
public static string GetFoldableTitle(
string baseTitle,
FoldableContainer foldable,
NodeEditorProperty? editor)
{
if (!foldable.Folded || editor is null)
{
return baseTitle;
}
if (editor.TryGetInlineSummary(out string summary) && !string.IsNullOrWhiteSpace(summary))
{
return $"{baseTitle} {summary}";
}
return string.IsNullOrWhiteSpace(editor.DisplayName)
? baseTitle
: $"{baseTitle} {editor.DisplayName}";
}
public static string FormatVariant(Variant value, StatescriptVariableType valueType)
{
return valueType switch
{
StatescriptVariableType.Bool => value.AsBool() ? "True" : "False",
StatescriptVariableType.Byte => value.AsInt32().ToString(CultureInfo.InvariantCulture),
StatescriptVariableType.SByte => value.AsInt32().ToString(CultureInfo.InvariantCulture),
StatescriptVariableType.Char => ((char)value.AsInt32()).ToString(),
StatescriptVariableType.Decimal => value.AsDouble().ToString("G", CultureInfo.InvariantCulture),
StatescriptVariableType.Double => value.AsDouble().ToString("G", CultureInfo.InvariantCulture),
StatescriptVariableType.Float => value.AsSingle().ToString("G", CultureInfo.InvariantCulture),
StatescriptVariableType.Int => value.AsInt32().ToString(CultureInfo.InvariantCulture),
StatescriptVariableType.UInt => value.AsInt64().ToString(CultureInfo.InvariantCulture),
StatescriptVariableType.Long => value.AsInt64().ToString(CultureInfo.InvariantCulture),
StatescriptVariableType.ULong => value.AsInt64().ToString(CultureInfo.InvariantCulture),
StatescriptVariableType.Short => value.AsInt32().ToString(CultureInfo.InvariantCulture),
StatescriptVariableType.UShort => value.AsInt32().ToString(CultureInfo.InvariantCulture),
StatescriptVariableType.Vector2 => FormatVector2(value.AsVector2()),
StatescriptVariableType.Vector3 => FormatVector3(value.AsVector3()),
StatescriptVariableType.Vector4 => FormatVector4(value.AsVector4()),
StatescriptVariableType.Plane => FormatPlane(value.AsPlane()),
StatescriptVariableType.Quaternion => FormatQuaternion(value.AsQuaternion()),
_ => value.ToString(),
};
}
public static InlineSummaryBadgeKind GetBadgeKind(StatescriptVariableType valueType)
{
return valueType switch
{
StatescriptVariableType.Bool => InlineSummaryBadgeKind.Boolean,
StatescriptVariableType.Vector2 => InlineSummaryBadgeKind.Vector,
StatescriptVariableType.Vector3 => InlineSummaryBadgeKind.Vector,
StatescriptVariableType.Vector4 => InlineSummaryBadgeKind.Vector,
StatescriptVariableType.Plane => InlineSummaryBadgeKind.Vector,
StatescriptVariableType.Quaternion => InlineSummaryBadgeKind.Vector,
_ => InlineSummaryBadgeKind.Numeric,
};
}
internal static bool TryGetSummaryBadgeForHighlighting(
FoldableContainer foldable,
[NotNullWhen(true)] out PanelContainer? badge)
{
return TryGetSummaryBadge(foldable, out badge);
}
private static SummaryBadgeData GetBadgeData(FoldableContainer foldable, NodeEditorProperty? editor)
{
if (!foldable.Folded || editor is null)
{
return SummaryBadgeData.Hidden;
}
string? highlightedVariableName = null;
string? highlightedSharedVariableSetPath = null;
string? highlightedSharedVariableName = null;
if (editor.TryGetHighlightedVariableName(out string propagatedVariableName)
&& !string.IsNullOrWhiteSpace(propagatedVariableName))
{
highlightedVariableName = propagatedVariableName;
}
if (editor.TryGetHighlightedSharedVariable(
out string propagatedSharedVariableSetPath,
out string propagatedSharedVariableName)
&& !string.IsNullOrWhiteSpace(propagatedSharedVariableSetPath)
&& !string.IsNullOrWhiteSpace(propagatedSharedVariableName))
{
highlightedSharedVariableSetPath = propagatedSharedVariableSetPath;
highlightedSharedVariableName = propagatedSharedVariableName;
}
if (editor.TryGetInlineSummary(out string summary) && !string.IsNullOrWhiteSpace(summary))
{
InlineSummaryBadgeKind badgeKind = editor.GetInlineSummaryBadgeKind();
return CreateBadgeData(
summary,
badgeKind,
IsConstantBadgeKind(badgeKind),
highlightedVariableName,
highlightedSharedVariableSetPath,
highlightedSharedVariableName);
}
return string.IsNullOrWhiteSpace(editor.DisplayName)
? SummaryBadgeData.Hidden
: CreateBadgeData(
editor.DisplayName,
InlineSummaryBadgeKind.Resolver,
false,
highlightedVariableName,
highlightedSharedVariableSetPath,
highlightedSharedVariableName);
}
private static SummaryBadgeData CreateBadgeData(
string text,
InlineSummaryBadgeKind badgeKind,
bool isConstant,
string? highlightedVariableName = null,
string? highlightedSharedVariableSetPath = null,
string? highlightedSharedVariableName = null)
{
BadgeVisualStyle style = GetBadgeStyle(badgeKind);
return new SummaryBadgeData(
GetBadgeIcon(badgeKind, isConstant),
text,
highlightedVariableName ?? string.Empty,
highlightedSharedVariableSetPath ?? string.Empty,
highlightedSharedVariableName ?? string.Empty,
badgeKind,
isConstant,
style.IconColor,
style.BackgroundColor,
style.BorderColor,
true);
}
private static PanelContainer GetOrCreateSummaryBadge(FoldableContainer foldable)
{
if (TryGetSummaryBadge(foldable, out PanelContainer? existingBadge))
{
return existingBadge;
}
var badge = new PanelContainer
{
Name = "InlineSummaryBadge",
Visible = false,
MouseFilter = Control.MouseFilterEnum.Ignore,
SizeFlagsHorizontal = Control.SizeFlags.ShrinkCenter,
};
var row = new HBoxContainer
{
Name = "Row",
MouseFilter = Control.MouseFilterEnum.Ignore,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
row.AddThemeConstantOverride("separation", 4);
badge.AddChild(row);
var iconLabel = new Label
{
Name = "Icon",
MouseFilter = Control.MouseFilterEnum.Ignore,
SizeFlagsHorizontal = Control.SizeFlags.ShrinkBegin,
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Center,
};
row.AddChild(iconLabel);
var textLabel = new Label
{
Name = "Text",
MouseFilter = Control.MouseFilterEnum.Ignore,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Center,
TextOverrunBehavior = TextServer.OverrunBehavior.TrimEllipsis,
};
row.AddChild(textLabel);
foldable.AddTitleBarControl(badge);
foldable.SetMeta(SummaryBadgeMetaKey, Variant.From(true));
foldable.SetMeta(SummaryBadgeInstanceIdMetaKey, Variant.From((long)badge.GetInstanceId()));
return badge;
}
private static void EnsureResizeSyncHook(FoldableContainer foldable)
{
if (foldable.HasMeta(SummaryBadgeResizeHookMetaKey)
&& foldable.GetMeta(SummaryBadgeResizeHookMetaKey).AsBool())
{
return;
}
foldable.Resized += () => SynchronizeSiblingBadgeWidths(foldable);
foldable.SetMeta(SummaryBadgeResizeHookMetaKey, Variant.From(true));
}
private static void ConfigureSummaryBadge(PanelContainer badge, SummaryBadgeData badgeData)
{
badge.Visible = badgeData.Visible;
if (!badgeData.Visible)
{
badge.SetMeta(SummaryBadgeKindMetaKey, Variant.From((int)InlineSummaryBadgeKind.Resolver));
badge.SetMeta(SummaryBadgeTextMetaKey, Variant.From(string.Empty));
badge.CustomMinimumSize = Vector2.Zero;
return;
}
badge.SetMeta(SummaryBadgeKindMetaKey, Variant.From((int)badgeData.BadgeKind));
badge.SetMeta(SummaryBadgeTextMetaKey, Variant.From(badgeData.Text));
badge.SetMeta(
"forge_inline_summary_badge_highlight_variable",
Variant.From(badgeData.HighlightVariableName));
badge.SetMeta(
"forge_inline_summary_badge_highlight_shared_set_path",
Variant.From(badgeData.HighlightSharedVariableSetPath));
badge.SetMeta(
"forge_inline_summary_badge_highlight_shared_variable",
Variant.From(badgeData.HighlightSharedVariableName));
StyleBoxFlat styleBox = new()
{
BgColor = badgeData.BackgroundColor,
BorderColor = badgeData.BorderColor,
CornerRadiusTopLeft = 8,
CornerRadiusTopRight = 8,
CornerRadiusBottomRight = 8,
CornerRadiusBottomLeft = 8,
BorderWidthLeft = 1,
BorderWidthTop = 1,
BorderWidthRight = 1,
BorderWidthBottom = 1,
ContentMarginLeft = 8,
ContentMarginTop = 3,
ContentMarginRight = 8,
ContentMarginBottom = 3,
};
badge.AddThemeStyleboxOverride("panel", styleBox);
Label? iconLabel = badge.GetNodeOrNull<Label>("Row/Icon");
Label? textLabel = badge.GetNodeOrNull<Label>("Row/Text");
if (iconLabel is null || textLabel is null)
{
return;
}
iconLabel.SizeFlagsHorizontal = Control.SizeFlags.ShrinkBegin;
textLabel.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
iconLabel.Text = badgeData.IconText;
textLabel.Text = badgeData.Text;
iconLabel.CustomMinimumSize = new Vector2(MeasureWidestBadgeIconWidth(iconLabel), 0);
iconLabel.AddThemeColorOverride("font_color", badgeData.IconColor);
textLabel.AddThemeColorOverride("font_color", textLabel.GetThemeColor("font_color", "Label"));
textLabel.CustomMinimumSize = Vector2.Zero;
if (badge.HasMeta(SummaryBadgeSelectedVariableMetaKey))
{
string selectedVariableName = badge.GetMeta(SummaryBadgeSelectedVariableMetaKey).AsString();
if (!string.IsNullOrEmpty(selectedVariableName)
&& selectedVariableName == badgeData.HighlightVariableName)
{
styleBox.BorderWidthLeft = Math.Max(styleBox.BorderWidthLeft, 2);
styleBox.BorderWidthTop = Math.Max(styleBox.BorderWidthTop, 2);
styleBox.BorderWidthRight = Math.Max(styleBox.BorderWidthRight, 2);
styleBox.BorderWidthBottom = Math.Max(styleBox.BorderWidthBottom, 2);
styleBox.BorderColor = new Color(0x56b6c2ff);
styleBox.BgColor = new Color(0x56b6c2ff);
iconLabel.AddThemeColorOverride("font_color", Colors.Black);
textLabel.AddThemeColorOverride("font_color", Colors.Black);
}
}
if (badge.HasMeta(SummaryBadgeSelectedSharedVariableSetPathMetaKey)
&& badge.HasMeta(SummaryBadgeSelectedSharedVariableMetaKey))
{
string selectedSharedVariableSetPath = badge.GetMeta(SummaryBadgeSelectedSharedVariableSetPathMetaKey)
.AsString();
string selectedSharedVariableName = badge.GetMeta(SummaryBadgeSelectedSharedVariableMetaKey)
.AsString();
if (!string.IsNullOrEmpty(selectedSharedVariableSetPath)
&& !string.IsNullOrEmpty(selectedSharedVariableName)
&& selectedSharedVariableSetPath == badgeData.HighlightSharedVariableSetPath
&& selectedSharedVariableName == badgeData.HighlightSharedVariableName)
{
styleBox.BorderWidthLeft = Math.Max(styleBox.BorderWidthLeft, 2);
styleBox.BorderWidthTop = Math.Max(styleBox.BorderWidthTop, 2);
styleBox.BorderWidthRight = Math.Max(styleBox.BorderWidthRight, 2);
styleBox.BorderWidthBottom = Math.Max(styleBox.BorderWidthBottom, 2);
styleBox.BorderColor = new Color(0x56b6c2ff);
styleBox.BgColor = new Color(0x56b6c2ff);
iconLabel.AddThemeColorOverride("font_color", Colors.Black);
textLabel.AddThemeColorOverride("font_color", Colors.Black);
}
}
}
private static float MeasureWidestBadgeIconWidth(Label label)
{
float maxWidth = 0f;
foreach (InlineSummaryBadgeKind badgeKind in Enum.GetValues<InlineSummaryBadgeKind>())
{
maxWidth = Math.Max(maxWidth, MeasureLabelTextWidth(label, GetBadgeIcon(badgeKind, false)));
}
return Math.Max(maxWidth, MeasureLabelTextWidth(label, GetBadgeIcon(InlineSummaryBadgeKind.Resolver, true)));
}
private static bool TryGetSummaryBadge(FoldableContainer foldable, [NotNullWhen(true)] out PanelContainer? badge)
{
badge = null;
if (!foldable.HasMeta(SummaryBadgeMetaKey) || !foldable.HasMeta(SummaryBadgeInstanceIdMetaKey))
{
return false;
}
ulong badgeInstanceId = (ulong)foldable.GetMeta(SummaryBadgeInstanceIdMetaKey).AsInt64();
if (badgeInstanceId == 0)
{
return false;
}
if (GodotObject.InstanceFromId(badgeInstanceId) is PanelContainer existingBadge
&& GodotObject.IsInstanceValid(existingBadge))
{
badge = existingBadge;
return true;
}
return false;
}
private static void SynchronizeSiblingBadgeWidths(FoldableContainer foldable)
{
if (foldable.GetParent() is not Node parent)
{
return;
}
var siblingFoldables = new List<FoldableContainer>();
var siblingBadges = new List<PanelContainer>();
foreach (Node child in parent.GetChildren())
{
if (child is FoldableContainer siblingFoldable
&& TryGetSummaryBadge(siblingFoldable, out PanelContainer? badge))
{
siblingFoldables.Add(siblingFoldable);
siblingBadges.Add(badge);
}
}
if (siblingBadges.Count == 0)
{
return;
}
foreach (PanelContainer badge in siblingBadges)
{
badge.CustomMinimumSize = Vector2.Zero;
}
float widestTitleWidth = 0;
foreach (FoldableContainer siblingFoldable in siblingFoldables)
{
widestTitleWidth = Math.Max(widestTitleWidth, MeasureFoldableTitleWidth(siblingFoldable));
}
float availableWidth = parent is Control parentControl
? parentControl.Size.X
- widestTitleWidth
- FoldableTitleChromeWidth
- FoldableTitleBadgeGap
- FoldableTitleRightPadding
: float.MaxValue;
float maxWidth = MinimumBadgeWidth;
if (!float.IsPositiveInfinity(availableWidth) && !float.IsNaN(availableWidth))
{
maxWidth = Math.Max(0f, availableWidth);
}
for (int i = 0; i < siblingBadges.Count; i++)
{
PanelContainer badge = siblingBadges[i];
badge.CustomMinimumSize = badge.Visible
? new Vector2(maxWidth, 0)
: Vector2.Zero;
}
}
private static BadgeVisualStyle GetBadgeStyle(InlineSummaryBadgeKind badgeKind)
{
return badgeKind switch
{
InlineSummaryBadgeKind.Numeric => new BadgeVisualStyle(
_numericIconColor,
_numericBackgroundColor,
_numericBorderColor),
InlineSummaryBadgeKind.Vector => new BadgeVisualStyle(
_vectorIconColor,
_vectorBackgroundColor,
_vectorBorderColor),
InlineSummaryBadgeKind.Boolean => new BadgeVisualStyle(
_booleanIconColor,
_booleanBackgroundColor,
_booleanBorderColor),
InlineSummaryBadgeKind.Variable => new BadgeVisualStyle(
_variableIconColor,
_variableBackgroundColor,
_variableBorderColor),
InlineSummaryBadgeKind.SharedVariable => new BadgeVisualStyle(
_sharedVariableIconColor,
_sharedVariableBackgroundColor,
_sharedVariableBorderColor),
InlineSummaryBadgeKind.Enum => new BadgeVisualStyle(
_enumIconColor,
_enumBackgroundColor,
_enumBorderColor),
_ => new BadgeVisualStyle(
_resolverIconColor,
_resolverBackgroundColor,
_resolverBorderColor),
};
}
private static bool IsConstantBadgeKind(InlineSummaryBadgeKind badgeKind)
{
return badgeKind is InlineSummaryBadgeKind.Numeric
or InlineSummaryBadgeKind.Vector
or InlineSummaryBadgeKind.Boolean;
}
private static float MeasureFoldableTitleWidth(FoldableContainer foldable)
{
Font? font = foldable.GetThemeDefaultFont();
if (font is null)
{
return 0;
}
int fontSize = foldable.GetThemeDefaultFontSize();
return font.GetStringSize(
foldable.Title,
HorizontalAlignment.Left,
-1,
fontSize,
TextServer.JustificationFlag.None,
TextServer.Direction.Auto,
TextServer.Orientation.Horizontal).X;
}
private static string GetBadgeIcon(InlineSummaryBadgeKind badgeKind, bool isConstant)
{
return badgeKind switch
{
InlineSummaryBadgeKind.Numeric => "#",
InlineSummaryBadgeKind.Vector => "▦",
InlineSummaryBadgeKind.Boolean => "●",
InlineSummaryBadgeKind.Variable => "𝑥",
InlineSummaryBadgeKind.SharedVariable => "𝑦",
InlineSummaryBadgeKind.Enum => "≡",
_ => isConstant ? "#" : "ƒ",
};
}
private static float MeasureLabelTextWidth(Label label, string text)
{
Font? font = label.GetThemeFont("font", "Label") ?? label.GetThemeDefaultFont();
if (font is null)
{
return 0;
}
int fontSize = label.GetThemeFontSize("font_size", "Label");
if (fontSize <= 0)
{
fontSize = label.GetThemeDefaultFontSize();
}
return font.GetStringSize(
text,
HorizontalAlignment.Left,
-1,
fontSize,
TextServer.JustificationFlag.None,
TextServer.Direction.Auto,
TextServer.Orientation.Horizontal).X;
}
private static string FormatVector2(Vector2 value)
{
return $"({FormatNumber(value.X)}, {FormatNumber(value.Y)})";
}
private static string FormatVector3(Vector3 value)
{
return $"({FormatNumber(value.X)}, {FormatNumber(value.Y)}, {FormatNumber(value.Z)})";
}
private static string FormatVector4(Vector4 value)
{
return $"({FormatNumber(value.X)}, {FormatNumber(value.Y)}, {FormatNumber(value.Z)}, {FormatNumber(value.W)})";
}
private static string FormatPlane(Plane value)
{
return $"({FormatNumber(value.Normal.X)}, {FormatNumber(value.Normal.Y)}, {FormatNumber(value.Normal.Z)}, " +
$"{FormatNumber(value.D)})";
}
private static string FormatQuaternion(Quaternion value)
{
return $"({FormatNumber(value.X)}, {FormatNumber(value.Y)}, {FormatNumber(value.Z)}, {FormatNumber(value.W)})";
}
private static string FormatNumber(float value)
{
return value.ToString("G", CultureInfo.InvariantCulture);
}
private readonly record struct BadgeVisualStyle(
Color IconColor,
Color BackgroundColor,
Color BorderColor);
private readonly record struct SummaryBadgeData(
string IconText,
string Text,
string HighlightVariableName,
string HighlightSharedVariableSetPath,
string HighlightSharedVariableName,
InlineSummaryBadgeKind BadgeKind,
bool IsConstant,
Color IconColor,
Color BackgroundColor,
Color BorderColor,
bool Visible)
{
public static SummaryBadgeData Hidden => new(
string.Empty,
string.Empty,
string.Empty,
string.Empty,
string.Empty,
InlineSummaryBadgeKind.Resolver,
false,
Colors.Transparent,
Colors.Transparent,
Colors.Transparent,
false);
}
}
#endif

View File

@@ -0,0 +1 @@
uid://pn08sf5w3i4s

View File

@@ -0,0 +1,16 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
internal enum InlineSummaryBadgeKind
{
Resolver = 0,
Numeric = 1,
Vector = 2,
Boolean = 3,
Variable = 4,
SharedVariable = 5,
Enum = 6,
}
#endif

View File

@@ -0,0 +1 @@
uid://b48mdbwtemd3y

View File

@@ -0,0 +1,9 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
internal sealed record InputPropertyFoldableContext(FoldableContainer Foldable, string BaseTitle);
#endif

View File

@@ -0,0 +1 @@
uid://cfavkpp486uto

View File

@@ -0,0 +1,137 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Base class for all Statescript property resolver editor controls. Extends <see cref="PanelContainer"/> so it can be
/// added directly to the graph node UI and participates in the Godot scene tree (lifecycle, disposal, etc.).
/// </summary>
[Tool]
internal abstract partial class NodeEditorProperty : PanelContainer
{
private Type[] _allowedExpectedTypes = [];
/// <summary>
/// Gets the display name shown in the resolver type dropdown (e.g., "Variable", "Constant", "Attribute").
/// </summary>
public abstract string DisplayName { get; }
/// <summary>
/// Gets the resolver type identifier string used for matching against serialized resources.
/// </summary>
public abstract string ResolverTypeId { get; }
/// <summary>
/// Checks whether this resolver is compatible with the given expected type.
/// </summary>
/// <param name="expectedType">The type expected by the node's input property.</param>
/// <returns><see langword="true"/> if this resolver can provide a value of the expected type.</returns>
public abstract bool IsCompatibleWith(Type expectedType);
/// <summary>
/// Initializes the resolver editor UI. Called once after the control is created.
/// </summary>
/// <param name="graph">The current graph resource (for accessing variables, etc.).</param>
/// <param name="property">The existing property binding to restore state from, or null for a new binding.</param>
/// <param name="expectedType">The type expected by the node's input property.</param>
/// <param name="onChanged">Callback invoked when the resolver configuration changes.</param>
/// <param name="isArray">Whether the input expects an array of values.</param>
public abstract void Setup(
StatescriptGraph graph,
StatescriptNodeProperty? property,
Type expectedType,
Action onChanged,
bool isArray);
/// <summary>
/// Writes the current resolver configuration to the given property binding resource.
/// </summary>
/// <param name="property">The property binding to write to.</param>
public abstract void SaveTo(StatescriptNodeProperty property);
/// <summary>
/// Raised when the editor's layout size has changed (e.g. nested resolver swap, foldable toggle) so that the owning
/// <see cref="GraphNode"/> can call <see cref="Control.ResetSize"/>.
/// </summary>
public event Action? LayoutSizeChanged;
/// <summary>
/// Configures the concrete input types allowed for this editor when the surrounding context accepts more than one.
/// </summary>
/// <param name="allowedExpectedTypes">The allowed expected types.</param>
public void ConfigureAllowedExpectedTypes(params Type[] allowedExpectedTypes)
{
_allowedExpectedTypes = allowedExpectedTypes;
}
/// <summary>
/// Tries to provide a short inline summary for the current editor state when embedded in a collapsed foldout.
/// </summary>
/// <param name="summary">The inline summary, when available.</param>
/// <returns><see langword="true"/> when an inline summary is available.</returns>
public virtual bool TryGetInlineSummary(out string summary)
{
summary = string.Empty;
return false;
}
/// <summary>
/// Gets the preferred badge style for inline foldout summaries.
/// </summary>
public virtual InlineSummaryBadgeKind GetInlineSummaryBadgeKind()
{
return InlineSummaryBadgeKind.Resolver;
}
/// <summary>
/// Tries to provide the graph-variable name represented by this editor or one of its nested editors for highlight
/// propagation in folded summaries.
/// </summary>
/// <param name="variableName">The variable name, when available.</param>
public virtual bool TryGetHighlightedVariableName(out string variableName)
{
variableName = string.Empty;
return false;
}
/// <summary>
/// Tries to provide the shared-variable identity represented by this editor or one of its nested editors for
/// highlight propagation in folded summaries.
/// </summary>
/// <param name="sharedVariableSetPath">The shared-variable set path, when available.</param>
/// <param name="variableName">The shared variable name, when available.</param>
public virtual bool TryGetHighlightedSharedVariable(out string sharedVariableSetPath, out string variableName)
{
sharedVariableSetPath = string.Empty;
variableName = string.Empty;
return false;
}
/// <summary>
/// Clears all delegate fields to prevent serialization issues during hot-reload. Called before the editor is
/// serialized or freed.
/// </summary>
public virtual void ClearCallbacks()
{
LayoutSizeChanged = null;
}
/// <summary>
/// Notifies listeners that the editor layout has changed size.
/// </summary>
protected void RaiseLayoutSizeChanged()
{
LayoutSizeChanged?.Invoke();
}
protected Type[] GetAllowedExpectedTypes(Type fallbackExpectedType)
{
return _allowedExpectedTypes.Length > 0 ? _allowedExpectedTypes : [fallbackExpectedType];
}
}
#endif

View File

@@ -0,0 +1 @@
uid://djb18x1m1rukn

View File

@@ -0,0 +1,95 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
internal static class SharedVariableHighlightState
{
private static string? _selectedSetPath;
private static string? _selectedVariableName;
private static string? _activeInspectorSetPath;
public static event Action? Changed;
public static void SetInspectorContext(string? setPath)
{
string normalized = string.IsNullOrWhiteSpace(setPath) ? string.Empty : setPath;
if (string.Equals(_activeInspectorSetPath, normalized, StringComparison.Ordinal))
{
return;
}
_activeInspectorSetPath = normalized;
Changed?.Invoke();
}
public static void ClearInspectorContext(string? setPath = null)
{
if (setPath is not null
&& !string.Equals(_activeInspectorSetPath, setPath, StringComparison.Ordinal))
{
return;
}
if (string.IsNullOrEmpty(_activeInspectorSetPath))
{
return;
}
_activeInspectorSetPath = string.Empty;
Changed?.Invoke();
}
public static void SetSelection(string? setPath, string? variableName)
{
string normalizedSetPath = string.IsNullOrWhiteSpace(setPath) ? string.Empty : setPath;
string normalizedVariableName = string.IsNullOrWhiteSpace(variableName) ? string.Empty : variableName;
if (string.IsNullOrEmpty(normalizedSetPath) || string.IsNullOrEmpty(normalizedVariableName))
{
normalizedSetPath = string.Empty;
normalizedVariableName = string.Empty;
}
if (string.Equals(_selectedSetPath, normalizedSetPath, StringComparison.Ordinal)
&& string.Equals(_selectedVariableName, normalizedVariableName, StringComparison.Ordinal))
{
return;
}
_selectedSetPath = normalizedSetPath;
_selectedVariableName = normalizedVariableName;
Changed?.Invoke();
}
public static bool TryGetActiveSelection(out string setPath, out string variableName)
{
if (string.IsNullOrEmpty(_selectedSetPath)
|| string.IsNullOrEmpty(_selectedVariableName)
|| !string.Equals(_activeInspectorSetPath, _selectedSetPath, StringComparison.Ordinal))
{
setPath = string.Empty;
variableName = string.Empty;
return false;
}
setPath = _selectedSetPath;
variableName = _selectedVariableName;
return true;
}
public static bool HasAnySelection()
{
return !string.IsNullOrEmpty(_selectedSetPath) && !string.IsNullOrEmpty(_selectedVariableName);
}
public static bool IsSelected(string? setPath, string? variableName)
{
return TryGetActiveSelection(out string activeSetPath, out string activeVariableName)
&& string.Equals(activeSetPath, setPath, StringComparison.Ordinal)
&& string.Equals(activeVariableName, variableName, StringComparison.Ordinal);
}
}
#endif

View File

@@ -0,0 +1 @@
uid://2gcypje3sxpa

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
uid://co05oybb4l5fp

View File

@@ -0,0 +1,53 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using Gamesmiths.Forge.Godot.Resources;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Inspector plugin that replaces the default <see cref="ForgeSharedVariableSet.Variables"/> array editor with a
/// polished UI matching the graph variable panel style.
/// </summary>
public partial class SharedVariableSetInspectorPlugin : EditorInspectorPlugin
{
private EditorUndoRedoManager? _undoRedo;
/// <summary>
/// Sets the <see cref="EditorUndoRedoManager"/> used for undo/redo support.
/// </summary>
/// <param name="undoRedo">The undo/redo manager from the editor plugin.</param>
public void SetUndoRedo(EditorUndoRedoManager undoRedo)
{
_undoRedo = undoRedo;
}
/// <inheritdoc/>
public override bool _CanHandle(GodotObject @object)
{
return @object is ForgeSharedVariableSet;
}
/// <inheritdoc/>
public override bool _ParseProperty(
GodotObject @object,
Variant.Type type,
string name,
PropertyHint hintType,
string hintString,
PropertyUsageFlags usageFlags,
bool wide)
{
if (name != "Variables")
{
return false;
}
var editorProperty = new SharedVariableSetEditorProperty();
editorProperty.SetUndoRedo(_undoRedo);
AddPropertyEditor(name, editorProperty);
return true;
}
}
#endif

View File

@@ -0,0 +1 @@
uid://bi7wqecgc87xl

View File

@@ -0,0 +1,425 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Statescript.Nodes;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// A popup dialog for adding Statescript nodes to a graph. Features a search bar, categorized tree view, description
/// panel, and Create/Cancel buttons.
/// </summary>
[Tool]
internal sealed partial class StatescriptAddNodeDialog : ConfirmationDialog, ISerializationListener
{
private const int DialogWidth = 244;
private const int DialogHeight = 400;
private static readonly string _exitNodeDescription = new ExitNode().Description;
private LineEdit? _searchBar;
private MenuButton? _expandCollapseButton;
private PopupMenu? _expandCollapsePopup;
private Tree? _tree;
private Label? _descriptionHeader;
private RichTextLabel? _descriptionLabel;
private bool _isFiltering;
/// <summary>
/// Raised when the user confirms node creation. The first argument is the selected
/// <see cref="StatescriptNodeDiscovery.NodeTypeInfo"/> (null for Exit node), the second is the
/// <see cref="StatescriptNodeType"/>, and the third is the graph-local position to place the node.
/// </summary>
public event Action<StatescriptNodeDiscovery.NodeTypeInfo?, StatescriptNodeType, Vector2>? NodeCreationRequested;
/// <summary>
/// Gets or sets the graph-local position where the new node should be placed.
/// </summary>
public Vector2 SpawnPosition { get; set; }
public StatescriptAddNodeDialog()
{
Title = "Add Statescript Node";
Exclusive = true;
Unresizable = false;
MinSize = new Vector2I(DialogWidth, DialogHeight);
Size = new Vector2I(DialogWidth, DialogHeight);
OkButtonText = "Create";
}
public override void _Ready()
{
base._Ready();
Transient = true;
TransientToFocused = true;
BuildUI();
PopulateTree();
GetOkButton().Disabled = true;
Confirmed += OnConfirmed;
Canceled += OnCanceled;
}
public override void _ExitTree()
{
base._ExitTree();
DisconnectSignals();
}
public void OnBeforeSerialize()
{
DisconnectSignals();
NodeCreationRequested = null;
}
public void OnAfterDeserialize()
{
ConnectSignals();
}
/// <summary>
/// Shows the dialog at the specified screen position, resets search and selection state.
/// </summary>
/// <param name="spawnPosition">The graph-local position where the node should be created.</param>
/// <param name="screenPosition">The screen position to show the dialog at.</param>
public void ShowAtPosition(Vector2 spawnPosition, Vector2I screenPosition)
{
SpawnPosition = spawnPosition;
if (_isFiltering)
{
_searchBar?.Clear();
PopulateTree();
}
else
{
_searchBar?.Clear();
}
_tree?.DeselectAll();
GetOkButton().Disabled = true;
UpdateDescription(null);
Position = screenPosition;
Size = new Vector2I(DialogWidth, DialogHeight);
Popup();
_searchBar?.GrabFocus();
}
private static void SetAllCollapsed(TreeItem root, bool collapsed)
{
TreeItem? child = root.GetFirstChild();
while (child is not null)
{
child.Collapsed = collapsed;
SetAllCollapsed(child, collapsed);
child = child.GetNext();
}
}
private void BuildUI()
{
var vBox = new VBoxContainer
{
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
AddChild(vBox);
var searchHBox = new HBoxContainer();
vBox.AddChild(searchHBox);
_searchBar = new LineEdit
{
PlaceholderText = "Search...",
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
ClearButtonEnabled = true,
RightIcon = EditorInterface.Singleton.GetEditorTheme().GetIcon("Search", "EditorIcons"),
};
_searchBar.TextChanged += OnSearchTextChanged;
searchHBox.AddChild(_searchBar);
_expandCollapseButton = new MenuButton
{
Flat = true,
Icon = EditorInterface.Singleton.GetEditorTheme().GetIcon("Tools", "EditorIcons"),
TooltipText = "Options",
};
_expandCollapsePopup = _expandCollapseButton.GetPopup();
_expandCollapsePopup.AddItem("Expand All", 0);
_expandCollapsePopup.AddItem("Collapse All", 1);
_expandCollapsePopup.IdPressed += OnExpandCollapseMenuPressed;
searchHBox.AddChild(_expandCollapseButton);
_tree = new Tree
{
SizeFlagsVertical = Control.SizeFlags.ExpandFill,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
HideRoot = true,
SelectMode = Tree.SelectModeEnum.Single,
};
_tree.ItemSelected += OnTreeItemSelected;
_tree.ItemActivated += OnTreeItemActivated;
vBox.AddChild(_tree);
_descriptionHeader = new Label
{
Text = "Description:",
};
vBox.AddChild(_descriptionHeader);
_descriptionLabel = new RichTextLabel
{
BbcodeEnabled = true,
ScrollActive = true,
CustomMinimumSize = new Vector2(0, 70),
};
vBox.AddChild(_descriptionLabel);
}
private void PopulateTree(string filter = "")
{
if (_tree is null)
{
return;
}
_isFiltering = !string.IsNullOrWhiteSpace(filter);
_tree.Clear();
TreeItem root = _tree.CreateItem();
IReadOnlyList<StatescriptNodeDiscovery.NodeTypeInfo> discoveredTypes =
StatescriptNodeDiscovery.GetDiscoveredNodeTypes();
string filterLower = filter.ToLowerInvariant();
TreeItem? actionCategory = null;
TreeItem? conditionCategory = null;
TreeItem? stateCategory = null;
foreach (StatescriptNodeDiscovery.NodeTypeInfo typeInfo in discoveredTypes)
{
if (_isFiltering && !typeInfo.DisplayName.Contains(filterLower, StringComparison.OrdinalIgnoreCase))
{
continue;
}
TreeItem categoryItem;
switch (typeInfo.NodeType)
{
case StatescriptNodeType.Action:
actionCategory ??= CreateCategoryItem(root, "Action");
categoryItem = actionCategory;
break;
case StatescriptNodeType.Condition:
conditionCategory ??= CreateCategoryItem(root, "Condition");
categoryItem = conditionCategory;
break;
case StatescriptNodeType.State:
stateCategory ??= CreateCategoryItem(root, "State");
categoryItem = stateCategory;
break;
default:
continue;
}
TreeItem item = _tree.CreateItem(categoryItem);
item.SetText(0, typeInfo.DisplayName);
item.SetMetadata(0, typeInfo.RuntimeTypeName);
}
if (!_isFiltering || "exit".Contains(filterLower, StringComparison.OrdinalIgnoreCase)
|| "exit node".Contains(filterLower, StringComparison.OrdinalIgnoreCase))
{
TreeItem exitItem = _tree.CreateItem(root);
exitItem.SetText(0, "Exit");
exitItem.SetMetadata(0, "__exit__");
}
SetAllCollapsed(root, !_isFiltering);
UpdateDescription(null);
}
private TreeItem CreateCategoryItem(TreeItem parent, string name)
{
TreeItem item = _tree!.CreateItem(parent);
item.SetText(0, name);
item.SetSelectable(0, false);
return item;
}
private void OnSearchTextChanged(string newText)
{
PopulateTree(newText);
GetOkButton().Disabled = true;
}
private void OnExpandCollapseMenuPressed(long id)
{
if (_tree is null)
{
return;
}
TreeItem? root = _tree.GetRoot();
if (root is null)
{
return;
}
SetAllCollapsed(root, id != 0);
}
private void OnTreeItemSelected()
{
if (_tree is null)
{
return;
}
TreeItem? selected = _tree.GetSelected();
if (selected?.IsSelectable(0) != true)
{
GetOkButton().Disabled = true;
UpdateDescription(null);
return;
}
GetOkButton().Disabled = false;
string metadata = selected.GetMetadata(0).AsString();
UpdateDescription(metadata);
}
private void OnTreeItemActivated()
{
if (_tree?.GetSelected() is not null && !GetOkButton().Disabled)
{
OnConfirmed();
Hide();
}
}
private void OnConfirmed()
{
if (_tree is null)
{
return;
}
TreeItem? selected = _tree.GetSelected();
if (selected?.IsSelectable(0) != true)
{
return;
}
string metadata = selected.GetMetadata(0).AsString();
if (metadata == "__exit__")
{
NodeCreationRequested?.Invoke(null, StatescriptNodeType.Exit, SpawnPosition);
}
else
{
StatescriptNodeDiscovery.NodeTypeInfo? typeInfo =
StatescriptNodeDiscovery.FindByRuntimeTypeName(metadata);
if (typeInfo is not null)
{
NodeCreationRequested?.Invoke(typeInfo, typeInfo.NodeType, SpawnPosition);
}
}
}
private void OnCanceled()
{
// Method intentionally left blank, no action needed on cancel.
}
private void UpdateDescription(string? runtimeTypeName)
{
if (_descriptionLabel is null)
{
return;
}
if (runtimeTypeName is null)
{
_descriptionLabel.Text = string.Empty;
return;
}
if (runtimeTypeName == "__exit__")
{
_descriptionLabel.Text = _exitNodeDescription;
return;
}
StatescriptNodeDiscovery.NodeTypeInfo? typeInfo =
StatescriptNodeDiscovery.FindByRuntimeTypeName(runtimeTypeName);
_descriptionLabel.Text = typeInfo?.Description ?? string.Empty;
}
private void DisconnectSignals()
{
Confirmed -= OnConfirmed;
Canceled -= OnCanceled;
if (_searchBar is not null)
{
_searchBar.TextChanged -= OnSearchTextChanged;
}
if (_expandCollapsePopup is not null)
{
_expandCollapsePopup.IdPressed -= OnExpandCollapseMenuPressed;
}
if (_tree is not null)
{
_tree.ItemSelected -= OnTreeItemSelected;
_tree.ItemActivated -= OnTreeItemActivated;
}
}
private void ConnectSignals()
{
Confirmed += OnConfirmed;
Canceled += OnCanceled;
if (_searchBar is not null)
{
_searchBar.TextChanged += OnSearchTextChanged;
}
if (_expandCollapsePopup is not null)
{
_expandCollapsePopup.IdPressed += OnExpandCollapseMenuPressed;
}
if (_tree is not null)
{
_tree.ItemSelected += OnTreeItemSelected;
_tree.ItemActivated += OnTreeItemActivated;
}
}
}
#endif

View File

@@ -0,0 +1 @@
uid://dcwnu7ebs2h1c

View File

@@ -0,0 +1,157 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Signal handler helpers used by <see cref="StatescriptEditorControls"/> to avoid lambdas on Godot signals.
/// Each handler is a <see cref="Node"/> so it can be parented to its owning control and freed automatically.
/// </summary>
internal static partial class StatescriptEditorControls
{
/// <summary>
/// Handles <see cref="BaseButton.Toggled"/> for boolean editors, forwarding to an <see cref="Action{T}"/>.
/// </summary>
[Tool]
internal sealed partial class BoolSignalHandler : Node
{
public Action<bool>? OnChanged { get; set; }
public void HandleToggled(bool pressed)
{
OnChanged?.Invoke(pressed);
}
}
/// <summary>
/// Handles <see cref="EditorSpinSlider"/> signals (<c>ValueChanged</c>, <c>Grabbed</c>, <c>Ungrabbed</c>,
/// <c>FocusExited</c>) for numeric editors with drag-commit semantics.
/// </summary>
[Tool]
internal sealed partial class NumericSpinHandler : Node
{
private readonly EditorSpinSlider _spin;
private bool _isDragging;
public Action<double>? OnChanged { get; set; }
public NumericSpinHandler()
{
_spin = null!;
}
public NumericSpinHandler(EditorSpinSlider spin)
{
_spin = spin;
}
public void HandleValueChanged(double value)
{
if (!_isDragging)
{
OnChanged?.Invoke(value);
}
}
public void HandleGrabbed()
{
_isDragging = true;
}
public void HandleUngrabbed()
{
_isDragging = false;
OnChanged?.Invoke(_spin.Value);
}
public void HandleFocusExited()
{
_isDragging = false;
}
}
/// <summary>
/// Holds the shared state (values array, drag flag, callback) for a multi-component vector editor.
/// </summary>
[Tool]
internal sealed partial class VectorComponentHandler : Node
{
private readonly double[] _values;
public Action<double[]>? OnChanged { get; set; }
public bool IsDragging { get; set; }
public VectorComponentHandler()
{
_values = [];
}
public VectorComponentHandler(double[] values)
{
_values = values;
}
public void SetValue(int index, double value)
{
_values[index] = value;
}
public void RaiseChanged()
{
OnChanged?.Invoke(_values);
}
}
/// <summary>
/// Handles <see cref="EditorSpinSlider"/> signals for a single component of a vector editor.
/// Forwards to the shared <see cref="VectorComponentHandler"/>.
/// </summary>
[Tool]
internal sealed partial class VectorSpinHandler : Node
{
private readonly VectorComponentHandler _parent;
private readonly int _componentIndex;
public VectorSpinHandler()
{
_parent = null!;
}
public VectorSpinHandler(VectorComponentHandler parent, int componentIndex)
{
_parent = parent;
_componentIndex = componentIndex;
}
public void HandleValueChanged(double value)
{
_parent.SetValue(_componentIndex, value);
if (!_parent.IsDragging)
{
_parent.RaiseChanged();
}
}
public void HandleGrabbed()
{
_parent.IsDragging = true;
}
public void HandleUngrabbed()
{
_parent.IsDragging = false;
_parent.RaiseChanged();
}
public void HandleFocusExited()
{
_parent.IsDragging = false;
}
}
}
#endif

View File

@@ -0,0 +1 @@
uid://cssljh632gdln

View File

@@ -0,0 +1,421 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Shared factory methods for creating value-editor controls used by both the variable panel and resolver editors.
/// </summary>
internal static partial class StatescriptEditorControls
{
private static readonly Color _axisXColor = new(0.96f, 0.37f, 0.37f);
private static readonly Color _axisYColor = new(0.54f, 0.83f, 0.01f);
private static readonly Color _axisZColor = new(0.33f, 0.55f, 0.96f);
private static readonly Color _axisWColor = new(0.66f, 0.66f, 0.66f);
private static StyleBox? _cachedPanelStyle;
/// <summary>
/// Returns <see langword="true"/> for integer-like variable types.
/// </summary>
/// <param name="type">The variable type to check.</param>
public static bool IsIntegerType(StatescriptVariableType type)
{
return type is StatescriptVariableType.Int or StatescriptVariableType.UInt
or StatescriptVariableType.Long or StatescriptVariableType.ULong
or StatescriptVariableType.Short or StatescriptVariableType.UShort
or StatescriptVariableType.Byte or StatescriptVariableType.SByte
or StatescriptVariableType.Char;
}
/// <summary>
/// Returns <see langword="true"/> for floating-point variable types.
/// </summary>
/// <param name="type">The variable type to check.</param>
public static bool IsFloatType(StatescriptVariableType type)
{
return type is StatescriptVariableType.Float or StatescriptVariableType.Double
or StatescriptVariableType.Decimal;
}
/// <summary>
/// Returns <see langword="true"/> for multi-component vector/quaternion/plane variable types.
/// </summary>
/// <param name="type">The variable type to check.</param>
public static bool IsVectorType(StatescriptVariableType type)
{
return type is StatescriptVariableType.Vector2 or StatescriptVariableType.Vector3
or StatescriptVariableType.Vector4 or StatescriptVariableType.Plane
or StatescriptVariableType.Quaternion;
}
/// <summary>
/// Creates a <see cref="PanelContainer"/> wrapping a <see cref="CheckBox"/> for boolean editing.
/// </summary>
/// <param name="value">The initial value of the boolean.</param>
/// <param name="onChanged">An action invoked on value change.</param>
/// <returns>A <see cref="PanelContainer"/> containing a <see cref="CheckBox"/>.</returns>
public static PanelContainer CreateBoolEditor(bool value, Action<bool> onChanged)
{
var container = new PanelContainer
{
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
container.AddThemeStyleboxOverride("panel", GetPanelStyle());
var checkButton = new CheckBox
{
Text = "On",
ButtonPressed = value,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
};
var handler = new BoolSignalHandler { OnChanged = onChanged };
checkButton.AddChild(handler);
checkButton.Toggled += handler.HandleToggled;
container.AddChild(checkButton);
return container;
}
/// <summary>
/// Creates an <see cref="EditorSpinSlider"/> configured for the given numeric variable type.
/// </summary>
/// <param name="type">The type of the numeric variable.</param>
/// <param name="value">The initial value of the numeric variable.</param>
/// <param name="onChanged">An action invoked on value change.</param>
/// <returns>An <see cref="EditorSpinSlider"/> configured for the specified numeric variable type.</returns>
public static EditorSpinSlider CreateNumericSpinSlider(
StatescriptVariableType type,
double value,
Action<double>? onChanged = null)
{
NumericConfig config = GetNumericConfig(type);
var spin = new EditorSpinSlider
{
Step = config.Step,
Rounded = config.IsInteger,
EditingInteger = config.IsInteger,
MinValue = config.MinValue,
MaxValue = config.MaxValue,
AllowGreater = config.AllowBeyondRange,
AllowLesser = config.AllowBeyondRange,
ControlState = config.IsInteger
? EditorSpinSlider.ControlStateEnum.Default
: EditorSpinSlider.ControlStateEnum.Hide,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
Value = value,
};
var handler = new NumericSpinHandler(spin) { OnChanged = onChanged };
spin.AddChild(handler);
if (onChanged is not null)
{
spin.ValueChanged += handler.HandleValueChanged;
}
spin.Grabbed += handler.HandleGrabbed;
spin.Ungrabbed += handler.HandleUngrabbed;
spin.FocusExited += handler.HandleFocusExited;
return spin;
}
/// <summary>
/// Creates a panel with a row of labelled <see cref="EditorSpinSlider"/> controls for editing a vector value.
/// </summary>
/// <param name="type">The type of the vector/quaternion/plane.</param>
/// <param name="getComponent">A function to retrieve the value of a specific component.</param>
/// <param name="onChanged">An action to invoke when any component value changes.</param>
/// <returns>A <see cref="VBoxContainer"/> containing the vector editor controls.</returns>
public static VBoxContainer CreateVectorEditor(
StatescriptVariableType type,
Func<int, double> getComponent,
Action<double[]>? onChanged)
{
int componentCount = GetVectorComponentCount(type);
string[] labels = GetVectorComponentLabels(type);
var vBox = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
var row = new HBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
row.AddThemeConstantOverride("separation", 0);
var panelContainer = new PanelContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
panelContainer.AddThemeStyleboxOverride("panel", GetPanelStyle());
vBox.AddChild(panelContainer);
panelContainer.AddChild(row);
double[] values = new double[componentCount];
var handler = new VectorComponentHandler(values) { OnChanged = onChanged };
vBox.AddChild(handler);
for (int i = 0; i < componentCount; i++)
{
values[i] = getComponent(i);
var spin = new EditorSpinSlider
{
Label = labels[i],
Step = 0.001,
Rounded = false,
EditingInteger = false,
AllowGreater = true,
AllowLesser = true,
Flat = false,
ControlState = EditorSpinSlider.ControlStateEnum.Hide,
SizeFlagsHorizontal = Control.SizeFlags.ExpandFill,
SizeFlagsStretchRatio = 1,
CustomMinimumSize = new Vector2(71, 0),
Value = values[i],
};
spin.AddThemeColorOverride("label_color", GetComponentColor(i));
var componentHandler = new VectorSpinHandler(handler, i);
spin.AddChild(componentHandler);
spin.ValueChanged += componentHandler.HandleValueChanged;
spin.Grabbed += componentHandler.HandleGrabbed;
spin.Ungrabbed += componentHandler.HandleUngrabbed;
spin.FocusExited += componentHandler.HandleFocusExited;
row.AddChild(spin);
}
return vBox;
}
/// <summary>
/// Reads a single component from a vector/quaternion/plane variant.
/// </summary>
/// <param name="value">The variant containing the vector/quaternion/plane value.</param>
/// <param name="type">The type of the vector/quaternion/plane.</param>
/// <param name="index">The index of the component to retrieve.</param>
/// <exception cref="NotImplementedException">Exception thrown if the provided type is not a vector/quaternion/plane
/// type.</exception>
public static double GetVectorComponent(Variant value, StatescriptVariableType type, int index)
{
return type switch
{
StatescriptVariableType.Vector2 => index == 0
? value.AsVector2().X
: value.AsVector2().Y,
StatescriptVariableType.Vector3 => index switch
{
0 => value.AsVector3().X,
1 => value.AsVector3().Y,
_ => value.AsVector3().Z,
},
StatescriptVariableType.Vector4 => index switch
{
0 => value.AsVector4().X,
1 => value.AsVector4().Y,
2 => value.AsVector4().Z,
_ => value.AsVector4().W,
},
StatescriptVariableType.Plane => index switch
{
0 => value.AsPlane().Normal.X,
1 => value.AsPlane().Normal.Y,
2 => value.AsPlane().Normal.Z,
_ => value.AsPlane().D,
},
StatescriptVariableType.Quaternion => index switch
{
0 => value.AsQuaternion().X,
1 => value.AsQuaternion().Y,
2 => value.AsQuaternion().Z,
_ => value.AsQuaternion().W,
},
StatescriptVariableType.Bool => throw new NotImplementedException(),
StatescriptVariableType.Byte => throw new NotImplementedException(),
StatescriptVariableType.SByte => throw new NotImplementedException(),
StatescriptVariableType.Char => throw new NotImplementedException(),
StatescriptVariableType.Decimal => throw new NotImplementedException(),
StatescriptVariableType.Double => throw new NotImplementedException(),
StatescriptVariableType.Float => throw new NotImplementedException(),
StatescriptVariableType.Int => throw new NotImplementedException(),
StatescriptVariableType.UInt => throw new NotImplementedException(),
StatescriptVariableType.Long => throw new NotImplementedException(),
StatescriptVariableType.ULong => throw new NotImplementedException(),
StatescriptVariableType.Short => throw new NotImplementedException(),
StatescriptVariableType.UShort => throw new NotImplementedException(),
_ => 0,
};
}
/// <summary>
/// Builds a Godot <see cref="Variant"/> from a component array for the given vector/quaternion/plane type.
/// </summary>
/// <param name="type">The type of the vector/quaternion/plane.</param>
/// <param name="values">The array of component values.</param>
/// <returns>A <see cref="Variant"/> representing the vector/quaternion/plane.</returns>
/// <exception cref="NotImplementedException">Exception thrown if the provided type is not a vector/quaternion/plane
/// type.</exception>
public static Variant BuildVectorVariant(StatescriptVariableType type, double[] values)
{
return type switch
{
StatescriptVariableType.Vector2 => Variant.From(
new Vector2((float)values[0], (float)values[1])),
StatescriptVariableType.Vector3 => Variant.From(
new Vector3(
(float)values[0],
(float)values[1],
(float)values[2])),
StatescriptVariableType.Vector4 => Variant.From(
new Vector4(
(float)values[0],
(float)values[1],
(float)values[2],
(float)values[3])),
StatescriptVariableType.Plane => Variant.From(
new Plane(
new Vector3(
(float)values[0],
(float)values[1],
(float)values[2]),
(float)values[3])),
StatescriptVariableType.Quaternion => Variant.From(
new Quaternion(
(float)values[0],
(float)values[1],
(float)values[2],
(float)values[3])),
StatescriptVariableType.Bool => throw new NotImplementedException(),
StatescriptVariableType.Byte => throw new NotImplementedException(),
StatescriptVariableType.SByte => throw new NotImplementedException(),
StatescriptVariableType.Char => throw new NotImplementedException(),
StatescriptVariableType.Decimal => throw new NotImplementedException(),
StatescriptVariableType.Double => throw new NotImplementedException(),
StatescriptVariableType.Float => throw new NotImplementedException(),
StatescriptVariableType.Int => throw new NotImplementedException(),
StatescriptVariableType.UInt => throw new NotImplementedException(),
StatescriptVariableType.Long => throw new NotImplementedException(),
StatescriptVariableType.ULong => throw new NotImplementedException(),
StatescriptVariableType.Short => throw new NotImplementedException(),
StatescriptVariableType.UShort => throw new NotImplementedException(),
_ => Variant.From(0),
};
}
private static int GetVectorComponentCount(StatescriptVariableType type)
{
return type switch
{
StatescriptVariableType.Vector2 => 2,
StatescriptVariableType.Vector3 => 3,
StatescriptVariableType.Vector4 => 4,
StatescriptVariableType.Plane => 4,
StatescriptVariableType.Quaternion => 4,
StatescriptVariableType.Bool => throw new NotImplementedException(),
StatescriptVariableType.Byte => throw new NotImplementedException(),
StatescriptVariableType.SByte => throw new NotImplementedException(),
StatescriptVariableType.Char => throw new NotImplementedException(),
StatescriptVariableType.Decimal => throw new NotImplementedException(),
StatescriptVariableType.Double => throw new NotImplementedException(),
StatescriptVariableType.Float => throw new NotImplementedException(),
StatescriptVariableType.Int => throw new NotImplementedException(),
StatescriptVariableType.UInt => throw new NotImplementedException(),
StatescriptVariableType.Long => throw new NotImplementedException(),
StatescriptVariableType.ULong => throw new NotImplementedException(),
StatescriptVariableType.Short => throw new NotImplementedException(),
StatescriptVariableType.UShort => throw new NotImplementedException(),
_ => 4,
};
}
private static string[] GetVectorComponentLabels(StatescriptVariableType type)
{
return type switch
{
StatescriptVariableType.Vector2 => ["x", "y"],
StatescriptVariableType.Vector3 => ["x", "y", "z"],
StatescriptVariableType.Plane => ["x", "y", "z", "d"],
StatescriptVariableType.Vector4 => ["x", "y", "z", "w"],
StatescriptVariableType.Quaternion => ["x", "y", "z", "w"],
StatescriptVariableType.Bool => throw new NotImplementedException(),
StatescriptVariableType.Byte => throw new NotImplementedException(),
StatescriptVariableType.SByte => throw new NotImplementedException(),
StatescriptVariableType.Char => throw new NotImplementedException(),
StatescriptVariableType.Decimal => throw new NotImplementedException(),
StatescriptVariableType.Double => throw new NotImplementedException(),
StatescriptVariableType.Float => throw new NotImplementedException(),
StatescriptVariableType.Int => throw new NotImplementedException(),
StatescriptVariableType.UInt => throw new NotImplementedException(),
StatescriptVariableType.Long => throw new NotImplementedException(),
StatescriptVariableType.ULong => throw new NotImplementedException(),
StatescriptVariableType.Short => throw new NotImplementedException(),
StatescriptVariableType.UShort => throw new NotImplementedException(),
_ => ["x", "y", "z", "w"],
};
}
private static Color GetComponentColor(int index)
{
return index switch
{
0 => _axisXColor,
1 => _axisYColor,
2 => _axisZColor,
_ => _axisWColor,
};
}
private static NumericConfig GetNumericConfig(StatescriptVariableType type)
{
return type switch
{
StatescriptVariableType.Byte => new NumericConfig(byte.MinValue, byte.MaxValue, 1, true, false),
StatescriptVariableType.SByte => new NumericConfig(sbyte.MinValue, sbyte.MaxValue, 1, true, false),
StatescriptVariableType.Char => new NumericConfig(char.MinValue, char.MaxValue, 1, true, false),
StatescriptVariableType.Short => new NumericConfig(short.MinValue, short.MaxValue, 1, true, false),
StatescriptVariableType.UShort => new NumericConfig(ushort.MinValue, ushort.MaxValue, 1, true, false),
StatescriptVariableType.Int => new NumericConfig(int.MinValue, int.MaxValue, 1, true, false),
StatescriptVariableType.UInt => new NumericConfig(uint.MinValue, uint.MaxValue, 1, true, false),
// Godot's interface starts acting weird if we try to use the full range of long/ulong, so we clamp to +/-
// 9e18 which should be sufficient for most use cases.
StatescriptVariableType.Long => new NumericConfig(-9e18, 9e18, 1, true, false),
StatescriptVariableType.ULong => new NumericConfig(0, 9e18, 1, true, false),
StatescriptVariableType.Float => new NumericConfig(-1e10, 1e10, 0.001, false, true),
StatescriptVariableType.Double => new NumericConfig(-1e10, 1e10, 0.001, false, true),
StatescriptVariableType.Decimal => new NumericConfig(-1e10, 1e10, 0.001, false, true),
StatescriptVariableType.Bool => throw new NotImplementedException(),
StatescriptVariableType.Vector2 => throw new NotImplementedException(),
StatescriptVariableType.Vector3 => throw new NotImplementedException(),
StatescriptVariableType.Vector4 => throw new NotImplementedException(),
StatescriptVariableType.Plane => throw new NotImplementedException(),
StatescriptVariableType.Quaternion => throw new NotImplementedException(),
_ => new NumericConfig(-1e10, 1e10, 0.001, false, true),
};
}
private readonly record struct NumericConfig(
double MinValue,
double MaxValue,
double Step,
bool IsInteger,
bool AllowBeyondRange);
private static StyleBox GetPanelStyle()
{
if (_cachedPanelStyle is null || !GodotObject.IsInstanceValid(_cachedPanelStyle))
{
Control baseControl = EditorInterface.Singleton.GetBaseControl();
_cachedPanelStyle = (StyleBox)baseControl.GetThemeStylebox("normal", "LineEdit").Duplicate();
_cachedPanelStyle.SetContentMarginAll(0);
}
return _cachedPanelStyle;
}
}
#endif

View File

@@ -0,0 +1 @@
uid://dpoji1y5vib4o

View File

@@ -0,0 +1,328 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
public partial class StatescriptGraphEditorDock
{
private void OnFileMenuIdPressed(long id)
{
switch ((int)id)
{
case 0:
ShowNewStatescriptDialog();
break;
case 1:
ShowLoadStatescriptDialog();
break;
case 2:
OnSavePressed();
break;
case 3:
ShowSaveAsDialog();
break;
case 4:
CloseCurrentTab();
break;
}
}
private void ShowNewStatescriptDialog()
{
_newStatescriptDialog?.QueueFree();
_newStatescriptDialog = new AcceptDialog
{
Title = "Create Statescript",
Size = new Vector2I(400, 140),
Exclusive = true,
};
var vBox = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
var pathRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
vBox.AddChild(pathRow);
pathRow.AddChild(new Label { Text = "Path:", CustomMinimumSize = new Vector2(50, 0) });
_newStatescriptPathEdit = new LineEdit
{
Text = "res://new_statescript.tres",
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
pathRow.AddChild(_newStatescriptPathEdit);
_newStatescriptDialog.AddChild(vBox);
_newStatescriptDialog.Confirmed += OnNewStatescriptConfirmed;
AddChild(_newStatescriptDialog);
_newStatescriptDialog.PopupCentered();
}
private void OnNewStatescriptConfirmed()
{
if (_newStatescriptPathEdit is null)
{
return;
}
string path = _newStatescriptPathEdit.Text.Trim();
if (string.IsNullOrEmpty(path))
{
return;
}
if (!path.EndsWith(".tres", System.StringComparison.OrdinalIgnoreCase))
{
path += ".tres";
}
var graph = new StatescriptGraph();
graph.EnsureEntryNode();
graph.StatescriptName = path.GetFile().GetBaseName();
ResourceSaver.Save(graph, path);
EditorInterface.Singleton.GetResourceFilesystem().Scan();
graph = ResourceLoader.Load<StatescriptGraph>(path);
if (graph is not null)
{
OpenGraph(graph);
}
_newStatescriptDialog?.QueueFree();
_newStatescriptDialog = null;
}
private void ShowLoadStatescriptDialog()
{
var dialog = new EditorFileDialog
{
FileMode = FileDialog.FileModeEnum.OpenFile,
Title = "Load Statescript File",
Access = FileDialog.AccessEnum.Resources,
};
dialog.AddFilter("*.tres;StatescriptGraph");
dialog.FileSelected += path =>
{
Resource? graph = ResourceLoader.Load(path);
if (graph is StatescriptGraph statescriptGraph)
{
OpenGraph(statescriptGraph);
}
else
{
GD.PushWarning($"Failed to load StatescriptGraph from: {path}");
}
dialog.QueueFree();
};
dialog.Canceled += dialog.QueueFree;
AddChild(dialog);
dialog.PopupCentered(new Vector2I(700, 500));
}
private void ShowSaveAsDialog()
{
StatescriptGraph? graph = CurrentGraph;
if (graph is null)
{
return;
}
var dialog = new EditorFileDialog
{
FileMode = FileDialog.FileModeEnum.SaveFile,
Title = "Save Statescript As",
Access = FileDialog.AccessEnum.Resources,
};
dialog.AddFilter("*.tres", "Godot Resource");
dialog.FileSelected += path =>
{
if (_graphEdit is not null)
{
graph.ScrollOffset = _graphEdit.ScrollOffset;
graph.Zoom = _graphEdit.Zoom;
SyncVisualNodePositionsToGraph();
SyncConnectionsToCurrentGraph();
}
ResourceSaver.Save(graph, path);
EditorInterface.Singleton.GetResourceFilesystem().Scan();
GD.Print($"Statescript graph saved as: {path}");
StatescriptGraph? savedGraph = ResourceLoader.Load<StatescriptGraph>(path);
if (savedGraph is not null)
{
OpenGraph(savedGraph);
}
dialog.QueueFree();
};
dialog.Canceled += dialog.QueueFree;
AddChild(dialog);
dialog.PopupCentered(new Vector2I(700, 500));
}
private void OnSavePressed()
{
StatescriptGraph? graph = CurrentGraph;
if (graph is null || _graphEdit is null)
{
return;
}
graph.ScrollOffset = _graphEdit.ScrollOffset;
graph.Zoom = _graphEdit.Zoom;
SyncVisualNodePositionsToGraph();
SyncConnectionsToCurrentGraph();
if (string.IsNullOrEmpty(graph.ResourcePath))
{
ShowSaveAsDialog();
return;
}
SaveGraphResource(graph);
GD.Print($"Statescript graph saved: {graph.ResourcePath}");
}
private void OnGraphEditPopupRequest(Vector2 atPosition)
{
if (CurrentGraph is null || _graphEdit is null || _addNodeDialog is null)
{
return;
}
ClearPendingConnection();
Vector2 graphPosition = (_graphEdit.ScrollOffset + atPosition) / _graphEdit.Zoom;
var screenPosition = (Vector2I)(_graphEdit.GetScreenPosition() + atPosition);
_addNodeDialog.ShowAtPosition(graphPosition, screenPosition);
}
private void OnConnectionToEmpty(StringName fromNode, long fromPort, Vector2 releasePosition)
{
if (CurrentGraph is null || _graphEdit is null || _addNodeDialog is null)
{
return;
}
_pendingConnectionNode = fromNode;
_pendingConnectionPort = (int)fromPort;
_pendingConnectionIsOutput = true;
Vector2 graphPosition = (_graphEdit.ScrollOffset + releasePosition) / _graphEdit.Zoom;
var screenPosition = (Vector2I)(_graphEdit.GetScreenPosition() + releasePosition);
_addNodeDialog.ShowAtPosition(graphPosition, screenPosition);
}
private void OnConnectionFromEmpty(StringName toNode, long toPort, Vector2 releasePosition)
{
if (CurrentGraph is null || _graphEdit is null || _addNodeDialog is null)
{
return;
}
_pendingConnectionNode = toNode;
_pendingConnectionPort = (int)toPort;
_pendingConnectionIsOutput = false;
Vector2 graphPosition = (_graphEdit.ScrollOffset + releasePosition) / _graphEdit.Zoom;
var screenPosition = (Vector2I)(_graphEdit.GetScreenPosition() + releasePosition);
_addNodeDialog.ShowAtPosition(graphPosition, screenPosition);
}
private void OnDialogNodeCreationRequested(
StatescriptNodeDiscovery.NodeTypeInfo? typeInfo,
StatescriptNodeType nodeType,
Vector2 position)
{
string newNodeId;
if (typeInfo is not null)
{
newNodeId = AddNodeAtPosition(nodeType, typeInfo.DisplayName, typeInfo.RuntimeTypeName, position);
}
else
{
newNodeId = AddNodeAtPosition(StatescriptNodeType.Exit, "Exit", string.Empty, position);
}
if (_pendingConnectionNode is not null && _graphEdit is not null)
{
if (_pendingConnectionIsOutput)
{
int inputPort = FindFirstEnabledInputPort(newNodeId);
if (inputPort >= 0)
{
OnConnectionRequest(
_pendingConnectionNode,
_pendingConnectionPort,
newNodeId,
inputPort);
}
}
else
{
int outputPort = FindFirstEnabledOutputPort(newNodeId);
if (outputPort >= 0)
{
OnConnectionRequest(
newNodeId,
outputPort,
_pendingConnectionNode,
_pendingConnectionPort);
}
}
ClearPendingConnection();
}
}
private void OnDialogCanceled()
{
ClearPendingConnection();
}
private void ClearPendingConnection()
{
_pendingConnectionNode = null;
_pendingConnectionPort = 0;
_pendingConnectionIsOutput = false;
}
private void OnAddNodeButtonPressed()
{
if (CurrentGraph is null || _graphEdit is null || _addNodeDialog is null || _addNodeButton is null)
{
return;
}
ClearPendingConnection();
var screenPosition = (Vector2I)(_addNodeButton.GetScreenPosition() + new Vector2(0, _addNodeButton.Size.Y));
Vector2 centerPosition = (_graphEdit.ScrollOffset + (_graphEdit.Size / 2)) / _graphEdit.Zoom;
_addNodeDialog.ShowAtPosition(centerPosition, screenPosition);
}
}
#endif

View File

@@ -0,0 +1 @@
uid://drxix8xbwpfin

View File

@@ -0,0 +1,494 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System.Collections.Generic;
using Gamesmiths.Forge.Core;
using Gamesmiths.Forge.Godot.Core;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
using GodotCollections = Godot.Collections;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
public partial class StatescriptGraphEditorDock
{
private static bool WouldCreateLoop(
StatescriptGraph graphResource,
string fromNodeId,
int fromPort,
string toNodeId,
int toPort)
{
var tempConnection = new StatescriptConnection
{
FromNode = fromNodeId,
OutputPort = fromPort,
ToNode = toNodeId,
InputPort = toPort,
};
graphResource.Connections.Add(tempConnection);
try
{
StatescriptGraphBuilder.Build(graphResource);
}
catch (ValidationException)
{
return true;
}
finally
{
graphResource.Connections.Remove(tempConnection);
}
return false;
}
private void OnConnectionRequest(StringName fromNode, long fromPort, StringName toNode, long toPort)
{
StatescriptGraph? graph = CurrentGraph;
if (graph is null || _graphEdit is null)
{
return;
}
if (WouldCreateLoop(graph, fromNode.ToString(), (int)fromPort, toNode.ToString(), (int)toPort))
{
ShowLoopWarningDialog();
return;
}
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Connect Statescript Nodes", customContext: graph);
_undoRedo.AddDoMethod(
this,
MethodName.DoConnect,
fromNode.ToString(),
(int)fromPort,
toNode.ToString(),
(int)toPort);
_undoRedo.AddUndoMethod(
this,
MethodName.UndoConnect,
fromNode.ToString(),
(int)fromPort,
toNode.ToString(),
(int)toPort);
_undoRedo.CommitAction();
}
else
{
DoConnect(fromNode.ToString(), (int)fromPort, toNode.ToString(), (int)toPort);
}
}
private void DoConnect(string fromNode, int fromPort, string toNode, int toPort)
{
_graphEdit?.ConnectNode(fromNode, fromPort, toNode, toPort);
SyncConnectionsToCurrentGraph();
}
private void UndoConnect(string fromNode, int fromPort, string toNode, int toPort)
{
_graphEdit?.DisconnectNode(fromNode, fromPort, toNode, toPort);
SyncConnectionsToCurrentGraph();
}
private void OnDisconnectionRequest(StringName fromNode, long fromPort, StringName toNode, long toPort)
{
StatescriptGraph? graph = CurrentGraph;
if (graph is null || _graphEdit is null)
{
return;
}
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Disconnect Statescript Nodes", customContext: graph);
_undoRedo.AddDoMethod(
this,
MethodName.UndoConnect,
fromNode.ToString(),
(int)fromPort,
toNode.ToString(),
(int)toPort);
_undoRedo.AddUndoMethod(
this,
MethodName.DoConnect,
fromNode.ToString(),
(int)fromPort,
toNode.ToString(),
(int)toPort);
_undoRedo.CommitAction();
}
else
{
_graphEdit.DisconnectNode(fromNode, (int)fromPort, toNode, (int)toPort);
SyncConnectionsToCurrentGraph();
}
}
private void OnDeleteNodesRequest(GodotCollections.Array<StringName> deletedNodes)
{
StatescriptGraph? graph = CurrentGraph;
if (graph is null || _graphEdit is null)
{
return;
}
foreach (StringName nodeName in deletedNodes)
{
Node? child = _graphEdit.GetNodeOrNull(nodeName.ToString());
if (child is not StatescriptGraphNode graphNode)
{
continue;
}
if (graphNode.NodeResource?.NodeType == StatescriptNodeType.Entry)
{
GD.PushWarning("Cannot delete the Entry statescriptNode.");
continue;
}
if (graphNode.NodeResource is null)
{
continue;
}
var affectedConnections = new List<StatescriptConnection>();
foreach (GodotCollections.Dictionary connection in _graphEdit.GetConnectionList())
{
StringName from = connection["from_node"].AsStringName();
StringName to = connection["to_node"].AsStringName();
if (from == nodeName || to == nodeName)
{
affectedConnections.Add(new StatescriptConnection
{
FromNode = connection["from_node"].AsString(),
OutputPort = connection["from_port"].AsInt32(),
ToNode = connection["to_node"].AsString(),
InputPort = connection["to_port"].AsInt32(),
});
}
}
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Delete Statescript Node", customContext: graph);
_undoRedo.AddDoMethod(
this,
MethodName.DoDeleteNode,
graph,
graphNode.NodeResource,
new GodotCollections.Array<StatescriptConnection>(affectedConnections));
_undoRedo.AddUndoMethod(
this,
MethodName.UndoDeleteNode,
graph,
graphNode.NodeResource,
new GodotCollections.Array<StatescriptConnection>(affectedConnections));
_undoRedo.CommitAction();
}
else
{
DoDeleteNode(
graph,
graphNode.NodeResource,
[.. affectedConnections]);
}
}
}
private void DoDeleteNode(
StatescriptGraph graph,
StatescriptNode nodeResource,
GodotCollections.Array<StatescriptConnection> affectedConnections)
{
if (_graphEdit is not null && CurrentGraph == graph)
{
foreach (StatescriptConnection connection in affectedConnections)
{
_graphEdit.DisconnectNode(
connection.FromNode,
connection.OutputPort,
connection.ToNode,
connection.InputPort);
}
Node? child = _graphEdit.GetNodeOrNull(nodeResource.NodeId);
child?.QueueFree();
}
graph.Nodes.Remove(nodeResource);
SyncConnectionsToCurrentGraph();
}
private void UndoDeleteNode(
StatescriptGraph graph,
StatescriptNode nodeResource,
GodotCollections.Array<StatescriptConnection> affectedConnections)
{
graph.Nodes.Add(nodeResource);
graph.Connections.AddRange(affectedConnections);
if (CurrentGraph == graph)
{
InvalidateCachedGraphVisuals(graph);
LoadGraphIntoEditor(graph);
}
}
private void OnBeginNodeMove()
{
if (_graphEdit is null)
{
return;
}
_preMovePositions.Clear();
foreach (Node child in _graphEdit.GetChildren())
{
if (child is StatescriptGraphNode { Selected: true } sgn)
{
_preMovePositions[sgn.Name] = sgn.PositionOffset;
}
}
}
private void OnEndNodeMove()
{
StatescriptGraph? graph = CurrentGraph;
if (graph is null || _graphEdit is null || _preMovePositions.Count == 0)
{
return;
}
var movedNodes = new GodotCollections.Dictionary<StringName, Vector2>();
var oldPositions = new GodotCollections.Dictionary<StringName, Vector2>();
foreach (Node child in _graphEdit.GetChildren())
{
if (child is not StatescriptGraphNode sgn || !_preMovePositions.TryGetValue(sgn.Name, out Vector2 oldPos))
{
continue;
}
Vector2 newPos = sgn.PositionOffset;
if (oldPos != newPos)
{
movedNodes[sgn.Name] = newPos;
oldPositions[sgn.Name] = oldPos;
}
}
_preMovePositions.Clear();
if (movedNodes.Count == 0)
{
return;
}
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Move Statescript Node(s)", customContext: graph);
_undoRedo.AddDoMethod(this, MethodName.DoMoveNodes, graph, movedNodes);
_undoRedo.AddUndoMethod(this, MethodName.DoMoveNodes, graph, oldPositions);
_undoRedo.CommitAction(false);
}
SyncNodePositionsToResource(graph, movedNodes);
}
private void DoMoveNodes(
StatescriptGraph graph,
GodotCollections.Dictionary<StringName, Vector2> positions)
{
foreach (StatescriptNode node in graph.Nodes)
{
if (positions.TryGetValue(node.NodeId, out Vector2 pos))
{
node.PositionOffset = pos;
}
}
if (CurrentGraph == graph && _graphEdit is not null)
{
foreach (Node child in _graphEdit.GetChildren())
{
if (child is StatescriptGraphNode sgn && positions.TryGetValue(sgn.Name, out Vector2 pos))
{
sgn.PositionOffset = pos;
}
}
}
}
private string AddNodeAtPosition(
StatescriptNodeType nodeType,
string title,
string runtimeTypeName,
Vector2 position)
{
StatescriptGraph? graph = CurrentGraph;
if (graph is null || _graphEdit is null)
{
return string.Empty;
}
string nodeId = $"node_{_nextNodeId++}";
var nodeResource = new StatescriptNode
{
NodeId = nodeId,
Title = title,
NodeType = nodeType,
RuntimeTypeName = runtimeTypeName,
PositionOffset = position,
};
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Add Statescript Node", customContext: graph);
_undoRedo.AddDoMethod(this, MethodName.DoAddNode, graph, nodeResource);
_undoRedo.AddUndoMethod(this, MethodName.UndoAddNode, graph, nodeResource);
_undoRedo.CommitAction();
}
else
{
DoAddNode(graph, nodeResource);
}
return nodeId;
}
private void DoAddNode(StatescriptGraph graph, StatescriptNode nodeResource)
{
graph.Nodes.Add(nodeResource);
if (CurrentGraph == graph && _graphEdit is not null)
{
GraphTab? tab = FindTab(graph);
StatescriptGraphNode graphNode = AddGraphNodeVisual(nodeResource, graph);
tab?.CachedGraphNodes.Add(graphNode);
}
}
private void UndoAddNode(StatescriptGraph graph, StatescriptNode nodeResource)
{
graph.Nodes.Remove(nodeResource);
if (CurrentGraph == graph)
{
InvalidateCachedGraphVisuals(graph);
LoadGraphIntoEditor(graph);
}
}
private void DuplicateSelectedNodes()
{
StatescriptGraph? graph = CurrentGraph;
if (graph is null || _graphEdit is null)
{
return;
}
var selectedNodes = new List<StatescriptGraphNode>();
foreach (Node child in _graphEdit.GetChildren())
{
if (child is StatescriptGraphNode { Selected: true } statescriptNode
&& statescriptNode.NodeResource is not null
&& statescriptNode.NodeResource.NodeType != StatescriptNodeType.Entry)
{
selectedNodes.Add(statescriptNode);
}
}
if (selectedNodes.Count == 0)
{
return;
}
foreach (StatescriptGraphNode sgn in selectedNodes)
{
sgn.Selected = false;
}
var duplicatedIds = new Dictionary<string, string>();
const float offset = 40f;
foreach (StatescriptGraphNode sgn in selectedNodes)
{
StatescriptNode original = sgn.NodeResource!;
string newNodeId = $"node_{_nextNodeId++}";
duplicatedIds[original.NodeId] = newNodeId;
var duplicated = new StatescriptNode
{
NodeId = newNodeId,
Title = original.Title,
NodeType = original.NodeType,
RuntimeTypeName = original.RuntimeTypeName,
PositionOffset = original.PositionOffset + new Vector2(offset, offset),
};
foreach (KeyValuePair<string, Variant> kvp in original.CustomData)
{
duplicated.CustomData[kvp.Key] = kvp.Value;
}
foreach (StatescriptNodeProperty binding in original.PropertyBindings)
{
var newBinding = new StatescriptNodeProperty
{
Direction = binding.Direction,
PropertyIndex = binding.PropertyIndex,
Resolver = binding.Resolver is not null
? (StatescriptResolverResource)binding.Resolver.Duplicate(true)
: null,
};
duplicated.PropertyBindings.Add(newBinding);
}
graph.Nodes.Add(duplicated);
GraphTab? tab = FindTab(graph);
StatescriptGraphNode graphNode = AddGraphNodeVisual(duplicated, graph);
tab?.CachedGraphNodes.Add(graphNode);
graphNode.Selected = true;
}
foreach (StatescriptConnection connection in graph.Connections)
{
if (duplicatedIds.TryGetValue(connection.FromNode, out string? newFrom)
&& duplicatedIds.TryGetValue(connection.ToNode, out string? newTo))
{
_graphEdit.ConnectNode(newFrom, connection.OutputPort, newTo, connection.InputPort);
}
}
SyncConnectionsToCurrentGraph();
}
private void ShowLoopWarningDialog()
{
var dialog = new AcceptDialog
{
Title = "Connection Rejected",
DialogText = "This connection would create a loop in the graph, which is not allowed.",
Exclusive = true,
};
dialog.Confirmed += dialog.QueueFree;
dialog.Canceled += dialog.QueueFree;
AddChild(dialog);
dialog.PopupCentered();
}
}
#endif

View File

@@ -0,0 +1 @@
uid://c0pse6qnrsdg0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
uid://1mt1aejs15yr

View File

@@ -0,0 +1,391 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
public partial class StatescriptGraphNode
{
private readonly Dictionary<OptionButton, StyleBoxFlat> _baseDropdownStyles = [];
private StyleBoxFlat? _basePanelStyle;
private StyleBoxFlat? _baseSelectedPanelStyle;
private static bool ResolverReferencesVariable(StatescriptResolverResource? resolver, string variableName)
{
if (resolver is null || string.IsNullOrEmpty(variableName))
{
return false;
}
var visited = new HashSet<nint>();
return ResolverReferencesVariableRecursive(resolver, variableName, visited);
}
private static bool ResolverReferencesVariableRecursive(
StatescriptResolverResource resolver,
string variableName,
HashSet<nint> visited)
{
nint instanceId = (nint)resolver.GetInstanceId();
if (!visited.Add(instanceId))
{
return false;
}
if (resolver is VariableResolverResource variableResolver
&& string.Equals(variableResolver.VariableName, variableName, StringComparison.Ordinal))
{
return true;
}
foreach (PropertyInfo property in resolver.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public))
{
if (!property.CanRead || !typeof(StatescriptResolverResource).IsAssignableFrom(property.PropertyType))
{
continue;
}
if (property.GetValue(resolver) is StatescriptResolverResource nestedResolver
&& ResolverReferencesVariableRecursive(nestedResolver, variableName, visited))
{
return true;
}
}
return false;
}
private static bool ResolverReferencesSharedVariable(
StatescriptResolverResource? resolver,
string sharedVariableSetPath,
string variableName)
{
if (resolver is null
|| string.IsNullOrEmpty(sharedVariableSetPath)
|| string.IsNullOrEmpty(variableName))
{
return false;
}
var visited = new HashSet<nint>();
return ResolverReferencesSharedVariableRecursive(resolver, sharedVariableSetPath, variableName, visited);
}
private static bool ResolverReferencesSharedVariableRecursive(
StatescriptResolverResource resolver,
string sharedVariableSetPath,
string variableName,
HashSet<nint> visited)
{
nint instanceId = (nint)resolver.GetInstanceId();
if (!visited.Add(instanceId))
{
return false;
}
if (resolver is SharedVariableResolverResource sharedVariableResolver
&& string.Equals(sharedVariableResolver.SharedVariableSetPath, sharedVariableSetPath, StringComparison.Ordinal)
&& string.Equals(sharedVariableResolver.VariableName, variableName, StringComparison.Ordinal))
{
return true;
}
foreach (PropertyInfo property in resolver.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public))
{
if (!property.CanRead || !typeof(StatescriptResolverResource).IsAssignableFrom(property.PropertyType))
{
continue;
}
if (property.GetValue(resolver) is StatescriptResolverResource nestedResolver
&& ResolverReferencesSharedVariableRecursive(
nestedResolver,
sharedVariableSetPath,
variableName,
visited))
{
return true;
}
}
return false;
}
private bool ReferencesVariable(string variableName)
{
if (NodeResource is null)
{
return false;
}
return NodeResource.PropertyBindings.Any(binding => ResolverReferencesVariable(binding.Resolver, variableName));
}
private bool ReferencesSharedVariable(string? sharedVariableSetPath, string? variableName)
{
if (NodeResource is null
|| string.IsNullOrEmpty(sharedVariableSetPath)
|| string.IsNullOrEmpty(variableName))
{
return false;
}
return NodeResource.PropertyBindings.Any(binding =>
ResolverReferencesSharedVariable(binding.Resolver, sharedVariableSetPath, variableName));
}
private void ApplyHighlightBorder()
{
EnsureBasePanelStylesStored();
if (_basePanelStyle is null || _baseSelectedPanelStyle is null)
{
return;
}
if (_isHighlighted)
{
StyleBoxFlat baseStyle = _basePanelStyle;
var highlightStyle = (StyleBoxFlat)baseStyle.Duplicate();
highlightStyle.BorderColor = _highlightColor;
highlightStyle.BorderWidthTop = 2;
highlightStyle.BorderWidthBottom = 2;
highlightStyle.BorderWidthLeft = 2;
highlightStyle.BorderWidthRight = 2;
highlightStyle.BgColor = baseStyle.BgColor.Lerp(_highlightColor, 0.15f);
AddThemeStyleboxOverride("panel", highlightStyle);
AddThemeStyleboxOverride("panel_selected", highlightStyle);
}
else
{
AddThemeStyleboxOverride("panel", (StyleBoxFlat)_basePanelStyle.Duplicate());
AddThemeStyleboxOverride("panel_selected", (StyleBoxFlat)_baseSelectedPanelStyle.Duplicate());
}
}
private void UpdateChildHighlights()
{
UpdateHighlightsRecursive(this);
}
private void UpdateHighlightsRecursive(Node parent)
{
foreach (Node child in parent.GetChildren())
{
if (child is OptionButton optionButton)
{
HighlightOptionButtonIfMatches(optionButton);
}
else if (child is Label label)
{
HighlightLabelIfMatches(label);
}
else if (child is FoldableContainer foldable)
{
HighlightFoldableSummaryBadgeIfPresent(foldable);
}
else if (child is PanelContainer badge && badge.HasMeta("forge_inline_summary_badge_kind"))
{
HighlightSummaryBadgeIfMatches(badge);
}
UpdateHighlightsRecursive(child);
}
}
private void HighlightFoldableSummaryBadgeIfPresent(FoldableContainer foldable)
{
if (!InlineConstantSummaryFormatter.TryGetSummaryBadgeForHighlighting(foldable, out PanelContainer? badge))
{
return;
}
HighlightSummaryBadgeIfMatches(badge);
}
private void HighlightOptionButtonIfMatches(OptionButton dropdown)
{
EnsureBaseDropdownStyleStored(dropdown);
if (!dropdown.HasMeta("is_variable_dropdown") && !dropdown.HasMeta("is_shared_variable_dropdown"))
{
return;
}
if (!_baseDropdownStyles.TryGetValue(dropdown, out StyleBoxFlat? baseStyle))
{
return;
}
if (string.IsNullOrEmpty(_highlightedVariableName)
&& (string.IsNullOrEmpty(_highlightedSharedVariableSetPath)
|| string.IsNullOrEmpty(_highlightedSharedVariableName)))
{
dropdown.AddThemeStyleboxOverride("normal", (StyleBoxFlat)baseStyle.Duplicate());
return;
}
int selectedIdx = dropdown.Selected;
if (selectedIdx < 0)
{
dropdown.AddThemeStyleboxOverride("normal", (StyleBoxFlat)baseStyle.Duplicate());
return;
}
string selectedText = dropdown.GetItemText(selectedIdx);
bool isSharedVariableDropdown = dropdown.HasMeta("is_shared_variable_dropdown");
string dropdownSetPath = dropdown.HasMeta("shared_variable_set_path")
? dropdown.GetMeta("shared_variable_set_path").AsString()
: string.Empty;
bool isMatch = isSharedVariableDropdown
? !string.IsNullOrEmpty(_highlightedSharedVariableSetPath)
&& !string.IsNullOrEmpty(_highlightedSharedVariableName)
&& selectedText == _highlightedSharedVariableName
&& dropdownSetPath == _highlightedSharedVariableSetPath
: selectedText == _highlightedVariableName;
if (isMatch)
{
var highlightStyle = (StyleBoxFlat)baseStyle.Duplicate();
highlightStyle.BgColor = baseStyle.BgColor.Lerp(_highlightColor, 0.25f);
dropdown.AddThemeStyleboxOverride("normal", highlightStyle);
}
else
{
dropdown.AddThemeStyleboxOverride("normal", (StyleBoxFlat)baseStyle.Duplicate());
}
}
private void EnsureBasePanelStylesStored()
{
if (_basePanelStyle is null && GetThemeStylebox("panel") is StyleBoxFlat panelStyle)
{
_basePanelStyle = (StyleBoxFlat)panelStyle.Duplicate();
}
if (_baseSelectedPanelStyle is null
&& GetThemeStylebox("panel_selected") is StyleBoxFlat selectedPanelStyle)
{
_baseSelectedPanelStyle = (StyleBoxFlat)selectedPanelStyle.Duplicate();
}
}
private void EnsureBaseDropdownStyleStored(OptionButton dropdown)
{
if (_baseDropdownStyles.ContainsKey(dropdown))
{
return;
}
if (dropdown.GetThemeStylebox("normal") is StyleBoxFlat baseStyle)
{
_baseDropdownStyles[dropdown] = (StyleBoxFlat)baseStyle.Duplicate();
}
}
private void HighlightLabelIfMatches(Label label)
{
if (string.IsNullOrEmpty(_highlightedVariableName))
{
if (label.HasMeta("is_highlight_colored"))
{
label.RemoveThemeColorOverride("font_color");
label.RemoveMeta("is_highlight_colored");
}
return;
}
if (label.Text == _highlightedVariableName)
{
label.AddThemeColorOverride("font_color", _highlightColor);
label.SetMeta("is_highlight_colored", true);
}
else if (label.HasMeta("is_highlight_colored"))
{
label.RemoveThemeColorOverride("font_color");
label.RemoveMeta("is_highlight_colored");
}
}
private void HighlightSummaryBadgeIfMatches(PanelContainer badge)
{
if (badge.GetNodeOrNull<Label>("Row/Text") is not Label textLabel
|| badge.GetNodeOrNull<Label>("Row/Icon") is not Label iconLabel)
{
return;
}
if (!badge.HasMeta("forge_inline_summary_badge_base_stylebox")
&& badge.GetThemeStylebox("panel") is StyleBoxFlat baseStyle)
{
badge.SetMeta("forge_inline_summary_badge_base_stylebox", Variant.From(baseStyle.Duplicate()));
}
if (!badge.HasMeta("forge_inline_summary_badge_base_stylebox")
|| badge.GetMeta("forge_inline_summary_badge_base_stylebox").Obj is not StyleBoxFlat storedBase)
{
return;
}
string propagatedVariableName = badge.HasMeta("forge_inline_summary_badge_highlight_variable")
? badge.GetMeta("forge_inline_summary_badge_highlight_variable").AsString()
: string.Empty;
string propagatedSharedVariableName = badge.HasMeta("forge_inline_summary_badge_highlight_shared_variable")
? badge.GetMeta("forge_inline_summary_badge_highlight_shared_variable").AsString()
: string.Empty;
string propagatedSharedVariableSetPath = badge.HasMeta("forge_inline_summary_badge_highlight_shared_set_path")
? badge.GetMeta("forge_inline_summary_badge_highlight_shared_set_path").AsString()
: string.Empty;
bool isMatch = !string.IsNullOrEmpty(_highlightedVariableName)
&& (textLabel.Text == _highlightedVariableName
|| propagatedVariableName == _highlightedVariableName);
isMatch = isMatch
|| (!string.IsNullOrEmpty(_highlightedSharedVariableSetPath)
&& !string.IsNullOrEmpty(_highlightedSharedVariableName)
&& (textLabel.Text == _highlightedSharedVariableName
|| propagatedSharedVariableName == _highlightedSharedVariableName)
&& propagatedSharedVariableSetPath == _highlightedSharedVariableSetPath);
badge.SetMeta("forge_inline_summary_badge_selected_variable", Variant.From(_highlightedVariableName ?? string.Empty));
badge.SetMeta(
"forge_inline_summary_badge_selected_shared_set_path",
Variant.From(_highlightedSharedVariableSetPath ?? string.Empty));
badge.SetMeta(
"forge_inline_summary_badge_selected_shared_variable",
Variant.From(_highlightedSharedVariableName ?? string.Empty));
var style = (StyleBoxFlat)storedBase.Duplicate();
if (isMatch)
{
style.BorderWidthLeft = Math.Max(style.BorderWidthLeft, 2);
style.BorderWidthTop = Math.Max(style.BorderWidthTop, 2);
style.BorderWidthRight = Math.Max(style.BorderWidthRight, 2);
style.BorderWidthBottom = Math.Max(style.BorderWidthBottom, 2);
style.BorderColor = _highlightColor;
style.BgColor = _highlightColor;
iconLabel.AddThemeColorOverride("font_color", Colors.Black);
textLabel.AddThemeColorOverride("font_color", Colors.Black);
}
else
{
iconLabel.RemoveThemeColorOverride("font_color");
textLabel.RemoveThemeColorOverride("font_color");
}
badge.AddThemeStyleboxOverride("panel", style);
}
}
#endif

View File

@@ -0,0 +1 @@
uid://dmp3vltauax62

View File

@@ -0,0 +1,221 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
public partial class StatescriptGraphNode
{
private void SetupNodeByType(StatescriptNodeType nodeType)
{
switch (nodeType)
{
case StatescriptNodeType.Entry:
SetupEntryNode();
break;
case StatescriptNodeType.Exit:
SetupExitNode();
break;
case StatescriptNodeType.Action:
SetupActionNode();
break;
case StatescriptNodeType.Condition:
SetupConditionNode();
break;
case StatescriptNodeType.State:
SetupStateNode();
break;
}
}
private void SetupEntryNode()
{
CustomMinimumSize = new Vector2(100, 0);
var label = new Label { Text = "Start" };
AddChild(label);
SetSlotEnabledRight(0, true);
SetSlotColorRight(0, _eventColor);
ApplyTitleBarColor(_entryColor);
}
private void SetupExitNode()
{
CustomMinimumSize = new Vector2(100, 0);
var label = new Label { Text = "End" };
AddChild(label);
SetSlotEnabledLeft(0, true);
SetSlotColorLeft(0, _eventColor);
ApplyTitleBarColor(_exitColor);
}
private void SetupActionNode()
{
var label = new Label { Text = "Execute" };
AddChild(label);
SetSlotEnabledLeft(0, true);
SetSlotColorLeft(0, _eventColor);
SetSlotEnabledRight(0, true);
SetSlotColorRight(0, _eventColor);
ApplyTitleBarColor(_actionColor);
}
private void SetupConditionNode()
{
var hBox = new HBoxContainer();
hBox.AddThemeConstantOverride("separation", 16);
AddChild(hBox);
var inputLabel = new Label { Text = "Condition" };
hBox.AddChild(inputLabel);
SetSlotEnabledLeft(0, true);
SetSlotColorLeft(0, _eventColor);
var trueLabel = new Label
{
Text = "True",
HorizontalAlignment = HorizontalAlignment.Right,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
hBox.AddChild(trueLabel);
SetSlotEnabledRight(0, true);
SetSlotColorRight(0, _eventColor);
var falseLabel = new Label
{
Text = "False",
HorizontalAlignment = HorizontalAlignment.Right,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
AddChild(falseLabel);
SetSlotEnabledRight(1, true);
SetSlotColorRight(1, _eventColor);
ApplyTitleBarColor(_conditionColor);
}
private void SetupStateNode()
{
var hBox1 = new HBoxContainer();
hBox1.AddThemeConstantOverride("separation", 16);
AddChild(hBox1);
var inputLabel = new Label { Text = "Begin" };
hBox1.AddChild(inputLabel);
SetSlotEnabledLeft(0, true);
SetSlotColorLeft(0, _eventColor);
var activateLabel = new Label
{
Text = "OnActivate",
HorizontalAlignment = HorizontalAlignment.Right,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
hBox1.AddChild(activateLabel);
SetSlotEnabledRight(0, true);
SetSlotColorRight(0, _eventColor);
var hBox2 = new HBoxContainer();
hBox2.AddThemeConstantOverride("separation", 16);
AddChild(hBox2);
var abortLabel = new Label { Text = "Abort" };
hBox2.AddChild(abortLabel);
SetSlotEnabledLeft(1, true);
SetSlotColorLeft(1, _eventColor);
var deactivateLabel = new Label
{
Text = "OnDeactivate",
HorizontalAlignment = HorizontalAlignment.Right,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
hBox2.AddChild(deactivateLabel);
SetSlotEnabledRight(1, true);
SetSlotColorRight(1, _eventColor);
var abortOutputLabel = new Label
{
Text = "OnAbort",
HorizontalAlignment = HorizontalAlignment.Right,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
AddChild(abortOutputLabel);
SetSlotEnabledRight(2, true);
SetSlotColorRight(2, _eventColor);
var subgraphLabel = new Label
{
Text = "Subgraph",
HorizontalAlignment = HorizontalAlignment.Right,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
AddChild(subgraphLabel);
SetSlotEnabledRight(3, true);
SetSlotColorRight(3, _subgraphColor);
ApplyTitleBarColor(_stateColor);
}
private void ClearSlots()
{
foreach (Node child in GetChildren())
{
RemoveChild(child);
child.Free();
}
}
private void ApplyTitleBarColor(Color color)
{
var titleBarStyleBox = new StyleBoxFlat
{
BgColor = color,
ContentMarginLeft = 12,
ContentMarginRight = 12,
ContentMarginTop = 6,
ContentMarginBottom = 6,
CornerRadiusTopLeft = 4,
CornerRadiusTopRight = 4,
};
AddThemeStyleboxOverride("titlebar", titleBarStyleBox);
var selectedTitleBarStyleBox = (StyleBoxFlat)titleBarStyleBox.Duplicate();
selectedTitleBarStyleBox.BgColor = color.Lightened(0.2f);
AddThemeStyleboxOverride("titlebar_selected", selectedTitleBarStyleBox);
}
private void ApplyBottomPadding()
{
StyleBox? existing = GetThemeStylebox("panel");
if (existing is not null)
{
var panelStyle = (StyleBox)existing.Duplicate();
panelStyle.ContentMarginBottom = 10;
AddThemeStyleboxOverride("panel", panelStyle);
}
StyleBox? selectedExisting = GetThemeStylebox("panel_selected");
if (selectedExisting is not null)
{
var selectedPanelStyle = (StyleBox)selectedExisting.Duplicate();
selectedPanelStyle.ContentMarginBottom = 10;
AddThemeStyleboxOverride("panel_selected", selectedPanelStyle);
}
}
}
#endif

View File

@@ -0,0 +1 @@
uid://civ3te4ediqxn

View File

@@ -0,0 +1,405 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Godot.Resources.Statescript.Resolvers;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
public partial class StatescriptGraphNode
{
private readonly Dictionary<PropertySlotKey, InputPropertyContext> _inputPropertyContexts = [];
private void AddInputPropertyRow(
StatescriptNodeDiscovery.InputPropertyInfo propInfo,
int index,
Control sectionContainer)
{
if (NodeResource is null)
{
return;
}
var container = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
var key = new PropertySlotKey(StatescriptPropertyDirection.Input, index);
string baseTitle = $"{propInfo.Label}:";
var propertyFoldable = new FoldableContainer
{
Title = baseTitle,
Folded = GetFoldState(GetInputPropertyFoldKey(index), true),
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
_foldableKeys[propertyFoldable] = GetInputPropertyFoldKey(index);
_inputPropertyFoldables[key] = new InputPropertyFoldableContext(propertyFoldable, baseTitle);
propertyFoldable.FoldingChanged += OnSectionFoldingChanged;
sectionContainer.AddChild(propertyFoldable);
propertyFoldable.AddChild(container);
var headerRow = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
container.AddChild(headerRow);
List<Func<NodeEditorProperty>> resolverFactories =
StatescriptResolverRegistry.GetCompatibleFactories(propInfo.ExpectedType);
if (propInfo.IsArray)
{
resolverFactories.RemoveAll(factory =>
{
return StatescriptResolverRegistry.GetResolverTypeId(factory) != "ArrayVariable";
});
}
else
{
resolverFactories.RemoveAll(factory =>
{
return StatescriptResolverRegistry.GetResolverTypeId(factory) == "ArrayVariable";
});
}
if (resolverFactories.Count == 0)
{
var errorLabel = new Label
{
Text = "No compatible resolvers.",
};
errorLabel.AddThemeColorOverride("font_color", Colors.Red);
container.AddChild(errorLabel);
UpdateInputPropertyFoldableTitle(key);
return;
}
var resolverDropdown = new OptionButton
{
SizeFlagsHorizontal = SizeFlags.ExpandFill,
CustomMinimumSize = new Vector2(80, 0),
};
foreach (Func<NodeEditorProperty> factory in resolverFactories)
{
resolverDropdown.AddItem(StatescriptResolverRegistry.GetDisplayName(factory));
}
StatescriptNodeProperty? binding = FindBinding(StatescriptPropertyDirection.Input, index);
int selectedIndex = 0;
if (binding?.Resolver is not null)
{
for (int i = 0; i < resolverFactories.Count; i++)
{
if (StatescriptResolverRegistry.GetResolverTypeId(resolverFactories[i])
== GetResolverTypeId(binding.Resolver))
{
selectedIndex = i;
break;
}
}
}
else
{
selectedIndex = StatescriptResolverRegistry.GetDefaultFactoryIndex(resolverFactories, propInfo.IsArray);
}
resolverDropdown.Selected = selectedIndex;
headerRow.AddChild(resolverDropdown);
var editorContainer = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
container.AddChild(editorContainer);
_inputPropertyContexts[key] = new InputPropertyContext(resolverFactories, propInfo, editorContainer);
ShowResolverEditorUI(
resolverFactories[selectedIndex],
binding,
propInfo.ExpectedType,
editorContainer,
StatescriptPropertyDirection.Input,
index,
propInfo.IsArray);
UpdateInputPropertyFoldableTitle(key);
int capturedIndex = index;
resolverDropdown.ItemSelected +=
selectedItem => OnInputResolverDropdownItemSelected(selectedItem, capturedIndex);
}
private void OnInputResolverDropdownItemSelected(long x, int index)
{
var key = new PropertySlotKey(StatescriptPropertyDirection.Input, index);
if (!_inputPropertyContexts.TryGetValue(key, out InputPropertyContext? ctx))
{
return;
}
var oldResolver = FindBinding(StatescriptPropertyDirection.Input, index)?.Resolver?.Duplicate()
as StatescriptResolverResource;
if (_activeResolverEditors.TryGetValue(key, out NodeEditorProperty? old))
{
_activeResolverEditors.Remove(key);
}
ClearContainer(ctx.EditorContainer);
if (NodeResource is null)
{
return;
}
ShowResolverEditorUI(
ctx.ResolverFactories[(int)x],
null,
ctx.PropInfo.ExpectedType,
ctx.EditorContainer,
StatescriptPropertyDirection.Input,
index,
ctx.PropInfo.IsArray);
if (_activeResolverEditors.TryGetValue(key, out NodeEditorProperty? editor))
{
SaveResolverEditor(editor, StatescriptPropertyDirection.Input, index);
}
UpdateInputPropertyFoldableTitle(key);
StatescriptNodeProperty? updated = FindBinding(StatescriptPropertyDirection.Input, index);
var newResolver = updated?.Resolver?.Duplicate() as StatescriptResolverResource;
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Change Resolver Type", customContext: _graph);
_undoRedo.AddDoMethod(
this,
MethodName.ApplyResolverBinding,
(int)StatescriptPropertyDirection.Input,
index,
Variant.From(newResolver));
_undoRedo.AddUndoMethod(
this,
MethodName.ApplyResolverBinding,
(int)StatescriptPropertyDirection.Input,
index,
Variant.From(oldResolver));
_undoRedo.CommitAction(false);
}
PropertyBindingChanged?.Invoke();
ResetSize();
}
private void AddOutputVariableRow(
StatescriptNodeDiscovery.OutputVariableInfo varInfo,
int index,
FoldableContainer sectionContainer)
{
if (NodeResource is null || _graph is null)
{
return;
}
var hBox = new HBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
sectionContainer.AddChild(hBox);
var nameLabel = new Label
{
Text = varInfo.Label,
CustomMinimumSize = new Vector2(60, 0),
};
nameLabel.AddThemeColorOverride("font_color", _outputVariableColor);
hBox.AddChild(nameLabel);
var variableDropdown = new OptionButton { SizeFlagsHorizontal = SizeFlags.ExpandFill };
variableDropdown.SetMeta("is_variable_dropdown", true);
variableDropdown.SetMeta("output_index", index);
foreach (StatescriptGraphVariable v in _graph.Variables)
{
variableDropdown.AddItem(v.VariableName);
}
StatescriptNodeProperty? binding = FindBinding(StatescriptPropertyDirection.Output, index);
int selectedIndex = 0;
if (binding?.Resolver is VariableResolverResource varRes
&& !string.IsNullOrEmpty(varRes.VariableName))
{
for (int i = 0; i < _graph.Variables.Count; i++)
{
if (_graph.Variables[i].VariableName == varRes.VariableName)
{
selectedIndex = i;
break;
}
}
}
if (_graph.Variables.Count > 0)
{
variableDropdown.Selected = selectedIndex;
if (binding is null)
{
string variableName = _graph.Variables[selectedIndex].VariableName;
EnsureBinding(StatescriptPropertyDirection.Output, index).Resolver =
new VariableResolverResource { VariableName = variableName };
}
}
int capturedIndex = index;
variableDropdown.ItemSelected +=
selectedItem => OnOutputVariableDropdownItemSelected(selectedItem, capturedIndex);
hBox.AddChild(variableDropdown);
}
private void OnOutputVariableDropdownItemSelected(long x, int index)
{
if (NodeResource is null || _graph is null)
{
return;
}
var oldResolver = FindBinding(StatescriptPropertyDirection.Output, index)?.Resolver?.Duplicate()
as StatescriptResolverResource;
string variableName = _graph.Variables[(int)x].VariableName;
var newResolver = new VariableResolverResource { VariableName = variableName };
EnsureBinding(StatescriptPropertyDirection.Output, index).Resolver = newResolver;
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Change Output Variable", customContext: _graph);
_undoRedo.AddDoMethod(
this,
MethodName.ApplyResolverBinding,
(int)StatescriptPropertyDirection.Output,
index,
(StatescriptResolverResource)newResolver.Duplicate());
_undoRedo.AddUndoMethod(
this,
MethodName.ApplyResolverBinding,
(int)StatescriptPropertyDirection.Output,
index,
Variant.From(oldResolver));
_undoRedo.CommitAction(false);
}
PropertyBindingChanged?.Invoke();
}
private void ShowResolverEditorUI(
Func<NodeEditorProperty> factory,
StatescriptNodeProperty? existingBinding,
Type expectedType,
VBoxContainer container,
StatescriptPropertyDirection direction,
int propertyIndex,
bool isArray = false)
{
if (_graph is null)
{
return;
}
NodeEditorProperty resolverEditor = factory();
resolverEditor.ConfigureAllowedExpectedTypes(expectedType);
var key = new PropertySlotKey(direction, propertyIndex);
resolverEditor.Setup(
_graph,
existingBinding,
expectedType,
() => SaveResolverEditorWithUndo(resolverEditor, direction, propertyIndex),
isArray);
resolverEditor.LayoutSizeChanged += ResetSize;
container.AddChild(resolverEditor);
_activeResolverEditors[key] = resolverEditor;
}
private void SaveResolverEditorWithUndo(
NodeEditorProperty resolverEditor,
StatescriptPropertyDirection direction,
int propertyIndex)
{
if (NodeResource is null)
{
return;
}
StatescriptNodeProperty? existing = FindBinding(direction, propertyIndex);
var oldResolver = existing?.Resolver?.Duplicate() as StatescriptResolverResource;
SaveResolverEditor(resolverEditor, direction, propertyIndex);
StatescriptNodeProperty? updated = FindBinding(direction, propertyIndex);
var newResolver = updated?.Resolver?.Duplicate() as StatescriptResolverResource;
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Change Node Property", customContext: _graph);
_undoRedo.AddDoMethod(
this,
MethodName.ApplyResolverBinding,
(int)direction,
propertyIndex,
Variant.From(newResolver));
_undoRedo.AddUndoMethod(
this,
MethodName.ApplyResolverBinding,
(int)direction,
propertyIndex,
Variant.From(oldResolver));
_undoRedo.CommitAction(false);
}
PropertyBindingChanged?.Invoke();
}
private void SaveResolverEditor(
NodeEditorProperty resolverEditor,
StatescriptPropertyDirection direction,
int propertyIndex)
{
if (NodeResource is null)
{
return;
}
StatescriptNodeProperty binding = EnsureBinding(direction, propertyIndex);
resolverEditor.SaveTo(binding);
}
private sealed class InputPropertyContext(
List<Func<NodeEditorProperty>> resolverFactories,
StatescriptNodeDiscovery.InputPropertyInfo propInfo,
VBoxContainer editorContainer)
{
public List<Func<NodeEditorProperty>> ResolverFactories { get; } = resolverFactories;
public StatescriptNodeDiscovery.InputPropertyInfo PropInfo { get; } = propInfo;
public VBoxContainer EditorContainer { get; } = editorContainer;
}
}
#endif

View File

@@ -0,0 +1 @@
uid://b8iw3e8i3f0w8

View File

@@ -0,0 +1,702 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Visual GraphNode representation for a single Statescript node in the editor.
/// Supports both built-in node types (Entry/Exit) and dynamically discovered concrete types.
/// </summary>
[Tool]
public partial class StatescriptGraphNode : GraphNode, ISerializationListener
{
private const string FoldInputKey = "_fold_input";
private const string FoldOutputKey = "_fold_output";
private const string FoldInputPropertyKeyPrefix = "_fold_input_property_";
private const string CustomWidthKey = "_custom_width";
private static readonly Color _entryColor = new(0x2a4a8dff);
private static readonly Color _exitColor = new(0x8a549aff);
private static readonly Color _actionColor = new(0x3a7856ff);
private static readonly Color _conditionColor = new(0x99811fff);
private static readonly Color _stateColor = new(0xa52c38ff);
private static readonly Color _eventColor = new(0xabb2bfff);
private static readonly Color _subgraphColor = new(0xc678ddff);
private static readonly Color _inputPropertyColor = new(0x61afefff);
private static readonly Color _outputVariableColor = new(0xe5c07bff);
private static readonly Color _highlightColor = new(0x56b6c2ff);
private readonly Dictionary<PropertySlotKey, NodeEditorProperty> _activeResolverEditors = [];
private readonly Dictionary<FoldableContainer, string> _foldableKeys = [];
private readonly Dictionary<PropertySlotKey, InputPropertyFoldableContext> _inputPropertyFoldables = [];
private StatescriptNodeDiscovery.NodeTypeInfo? _typeInfo;
private StatescriptGraph? _graph;
private EditorUndoRedoManager? _undoRedo;
private CustomNodeEditor? _activeCustomEditor;
private bool _resizeConnected;
private float _widthBeforeResize;
private string? _highlightedVariableName;
private string? _highlightedSharedVariableSetPath;
private string? _highlightedSharedVariableName;
private bool _isHighlighted;
/// <summary>
/// Raised when a property binding has been modified in the UI.
/// </summary>
public event Action? PropertyBindingChanged;
/// <summary>
/// Gets the underlying node resource.
/// </summary>
public StatescriptNode? NodeResource { get; private set; }
/// <summary>
/// Sets the <see cref="EditorUndoRedoManager"/> used for undo/redo support.
/// </summary>
/// <param name="undoRedo">The undo/redo manager from the editor plugin.</param>
public void SetUndoRedo(EditorUndoRedoManager? undoRedo)
{
_undoRedo = undoRedo;
}
/// <summary>
/// Gets the <see cref="EditorUndoRedoManager"/> used for undo/redo support.
/// </summary>
/// <returns>The undo/redo manager, or null if not set.</returns>
public EditorUndoRedoManager? GetUndoRedo()
{
return _undoRedo;
}
/// <summary>
/// Updates the highlight state based on the given variable name.
/// </summary>
/// <param name="variableName">The variable name to highlight, or null to clear.</param>
public void SetHighlightedVariable(string? variableName)
{
_highlightedVariableName = variableName;
RefreshHighlightState();
}
public void SetHighlightedSharedVariable(string? sharedVariableSetPath, string? variableName)
{
_highlightedSharedVariableSetPath = sharedVariableSetPath;
_highlightedSharedVariableName = variableName;
RefreshHighlightState();
}
/// <summary>
/// Initializes this visual node from a resource, optionally within the context of a graph.
/// </summary>
/// <param name="resource">The node resource to display.</param>
/// <param name="graph">The owning graph resource (needed for variable dropdowns).</param>
public void Initialize(StatescriptNode resource, StatescriptGraph? graph = null)
{
NodeResource = resource;
_graph = graph;
_activeResolverEditors.Clear();
_foldableKeys.Clear();
_inputPropertyFoldables.Clear();
Name = resource.NodeId;
Title = resource.Title;
PositionOffset = resource.PositionOffset;
CustomMinimumSize = new Vector2(240, 0);
Resizable = true;
RestoreCustomWidth();
if (!_resizeConnected)
{
_widthBeforeResize = CustomMinimumSize.X;
ResizeRequest += OnResizeRequest;
ResizeEnd += OnResizeEnd;
_resizeConnected = true;
}
ClearSlots();
if (resource.NodeType is StatescriptNodeType.Entry or StatescriptNodeType.Exit
|| string.IsNullOrEmpty(resource.RuntimeTypeName))
{
SetupNodeByType(resource.NodeType);
ApplyBottomPadding();
RefreshHighlightState();
return;
}
_typeInfo = StatescriptNodeDiscovery.FindByRuntimeTypeName(resource.RuntimeTypeName);
if (_typeInfo is not null)
{
SetupFromTypeInfo(_typeInfo);
}
else
{
SetupNodeByType(resource.NodeType);
}
ApplyBottomPadding();
RefreshHighlightState();
}
public void OnBeforeSerialize()
{
_inputPropertyContexts.Clear();
_foldableKeys.Clear();
_inputPropertyFoldables.Clear();
_activeCustomEditor?.Unbind();
_activeCustomEditor = null;
foreach (KeyValuePair<PropertySlotKey, NodeEditorProperty> kvp in
_activeResolverEditors.Where(kvp => IsInstanceValid(kvp.Value)))
{
kvp.Value.ClearCallbacks();
}
_activeResolverEditors.Clear();
PropertyBindingChanged = null;
}
public void OnAfterDeserialize()
{
}
internal FoldableContainer AddPropertySectionDividerInternal(
string sectionTitle,
Color color,
string foldKey,
bool folded)
{
return AddPropertySectionDivider(sectionTitle, color, foldKey, folded);
}
internal void AddInputPropertyRowInternal(
StatescriptNodeDiscovery.InputPropertyInfo propInfo,
int index,
Control container)
{
AddInputPropertyRow(propInfo, index, container);
}
internal void AddOutputVariableRowInternal(
StatescriptNodeDiscovery.OutputVariableInfo varInfo,
int index,
FoldableContainer container)
{
AddOutputVariableRow(varInfo, index, container);
}
internal bool GetFoldStateInternal(string key)
{
return GetFoldState(key);
}
internal bool GetFoldStateInternal(string key, bool defaultValue)
{
return GetFoldState(key, defaultValue);
}
internal void SetFoldStateWithUndoInternal(string key, bool folded)
{
SetFoldStateWithUndo(key, folded);
}
internal StatescriptNodeProperty? FindBindingInternal(
StatescriptPropertyDirection direction,
int propertyIndex)
{
return FindBinding(direction, propertyIndex);
}
internal StatescriptNodeProperty EnsureBindingInternal(
StatescriptPropertyDirection direction,
int propertyIndex)
{
return EnsureBinding(direction, propertyIndex);
}
internal void RemoveBindingInternal(
StatescriptPropertyDirection direction,
int propertyIndex)
{
RemoveBinding(direction, propertyIndex);
}
internal void RecordResolverBindingChangeInternal(
StatescriptPropertyDirection direction,
int propertyIndex,
StatescriptResolverResource? oldResolver,
StatescriptResolverResource? newResolver,
string actionName)
{
if (_undoRedo is null)
{
return;
}
_undoRedo.CreateAction(actionName, customContext: _graph);
_undoRedo.AddDoMethod(
this,
MethodName.ApplyResolverBinding,
(int)direction,
propertyIndex,
Variant.From(newResolver));
_undoRedo.AddUndoMethod(
this,
MethodName.ApplyResolverBinding,
(int)direction,
propertyIndex,
Variant.From(oldResolver));
_undoRedo.CommitAction(false);
}
internal void ShowResolverEditorUIInternal(
Func<NodeEditorProperty> factory,
StatescriptNodeProperty? existingBinding,
Type expectedType,
VBoxContainer container,
StatescriptPropertyDirection direction,
int propertyIndex,
bool isArray = false)
{
ShowResolverEditorUI(factory, existingBinding, expectedType, container, direction, propertyIndex, isArray);
}
internal void RaisePropertyBindingChangedInternal()
{
PropertyBindingChanged?.Invoke();
}
internal void UpdateInputPropertyFoldableTitlesInternal()
{
UpdateInputPropertyFoldableTitles();
}
private static string GetResolverTypeId(StatescriptResolverResource resolver)
{
return resolver.ResolverTypeId;
}
private static void ClearContainer(Control container)
{
foreach (Node child in container.GetChildren())
{
container.RemoveChild(child);
child.Free();
}
}
private static string GetInputPropertyFoldKey(int propertyIndex)
{
return $"{FoldInputPropertyKeyPrefix}{propertyIndex}";
}
private void SetupFromTypeInfo(StatescriptNodeDiscovery.NodeTypeInfo typeInfo)
{
int maxSlots = Math.Max(typeInfo.InputPortLabels.Length, typeInfo.OutputPortLabels.Length);
for (int slot = 0; slot < maxSlots; slot++)
{
var hBox = new HBoxContainer();
hBox.AddThemeConstantOverride("separation", 16);
AddChild(hBox);
if (slot < typeInfo.InputPortLabels.Length)
{
var inputLabel = new Label
{
Text = typeInfo.InputPortLabels[slot],
};
hBox.AddChild(inputLabel);
SetSlotEnabledLeft(slot, true);
SetSlotColorLeft(slot, _eventColor);
}
else
{
var spacer = new Control();
hBox.AddChild(spacer);
}
if (slot < typeInfo.OutputPortLabels.Length)
{
var outputLabel = new Label
{
Text = typeInfo.OutputPortLabels[slot],
HorizontalAlignment = HorizontalAlignment.Right,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
hBox.AddChild(outputLabel);
SetSlotEnabledRight(slot, true);
Color portColor = typeInfo.IsSubgraphPort[slot] ? _subgraphColor : _eventColor;
SetSlotColorRight(slot, portColor);
}
}
if (CustomNodeEditorRegistry.TryCreate(typeInfo.RuntimeTypeName, out CustomNodeEditor? customEditor))
{
Debug.Assert(_graph is not null, "Graph context is required for custom node editors.");
Debug.Assert(NodeResource is not null, "Node resource is required for custom node editors.");
_activeCustomEditor = customEditor;
customEditor.Bind(this, _graph, NodeResource, _activeResolverEditors);
customEditor.BuildPropertySections(typeInfo);
}
else
{
_activeCustomEditor = null;
BuildDefaultPropertySections(typeInfo);
}
Color titleColor = typeInfo.NodeType switch
{
StatescriptNodeType.Action => _actionColor,
StatescriptNodeType.Condition => _conditionColor,
StatescriptNodeType.State => _stateColor,
StatescriptNodeType.Entry => _entryColor,
StatescriptNodeType.Exit => _exitColor,
_ => _entryColor,
};
ApplyTitleBarColor(titleColor);
}
private void BuildDefaultPropertySections(StatescriptNodeDiscovery.NodeTypeInfo typeInfo)
{
if (typeInfo.InputPropertiesInfo.Length > 0)
{
bool folded = GetFoldState(FoldInputKey);
FoldableContainer inputContainer = AddPropertySectionDivider(
"Input Properties",
_inputPropertyColor,
FoldInputKey,
folded);
for (int i = 0; i < typeInfo.InputPropertiesInfo.Length; i++)
{
AddInputPropertyRow(typeInfo.InputPropertiesInfo[i], i, inputContainer);
}
}
if (typeInfo.OutputVariablesInfo.Length > 0)
{
bool folded = GetFoldState(FoldOutputKey);
FoldableContainer outputContainer = AddPropertySectionDivider(
"Output Variables",
_outputVariableColor,
FoldOutputKey,
folded);
for (int i = 0; i < typeInfo.OutputVariablesInfo.Length; i++)
{
AddOutputVariableRow(typeInfo.OutputVariablesInfo[i], i, outputContainer);
}
}
}
private FoldableContainer AddPropertySectionDivider(
string sectionTitle,
Color color,
string foldKey,
bool folded)
{
var divider = new HSeparator { CustomMinimumSize = new Vector2(0, 4) };
AddChild(divider);
var sectionContainer = new FoldableContainer
{
Title = sectionTitle,
Folded = folded,
CustomMinimumSize = new Vector2(192, 0),
};
sectionContainer.AddThemeColorOverride("font_color", color);
_foldableKeys[sectionContainer] = foldKey;
sectionContainer.FoldingChanged += OnSectionFoldingChanged;
AddChild(sectionContainer);
return sectionContainer;
}
private void OnSectionFoldingChanged(bool isFolded)
{
foreach (KeyValuePair<FoldableContainer, string> kvp in _foldableKeys.Where(kvp => IsInstanceValid(kvp.Key)))
{
bool stored = GetFoldState(kvp.Value);
if (kvp.Key.Folded != stored)
{
SetFoldStateWithUndo(kvp.Value, kvp.Key.Folded);
}
}
UpdateInputPropertyFoldableTitles();
RefreshHighlightState();
ResetSize();
}
private void UpdateInputPropertyFoldableTitle(PropertySlotKey key)
{
if (!_inputPropertyFoldables.TryGetValue(key, out InputPropertyFoldableContext? context)
|| !IsInstanceValid(context.Foldable))
{
return;
}
_activeResolverEditors.TryGetValue(key, out NodeEditorProperty? editor);
InlineConstantSummaryFormatter.ApplyFoldableTitle(context.BaseTitle, context.Foldable, editor);
}
private void UpdateInputPropertyFoldableTitles()
{
foreach (PropertySlotKey key in _inputPropertyFoldables.Keys.ToArray())
{
UpdateInputPropertyFoldableTitle(key);
}
}
private bool GetFoldState(string key)
{
return GetFoldState(key, false);
}
private bool GetFoldState(string key, bool defaultValue)
{
if (NodeResource is not null && NodeResource.CustomData.TryGetValue(key, out Variant value))
{
return value.AsBool();
}
return defaultValue;
}
private void SetFoldState(string key, bool folded)
{
if (NodeResource is null)
{
return;
}
NodeResource.CustomData[key] = Variant.From(folded);
}
private void SetFoldStateWithUndo(string key, bool folded)
{
if (NodeResource is null)
{
return;
}
bool oldFolded = GetFoldState(key);
if (oldFolded == folded)
{
return;
}
SetFoldState(key, folded);
if (_undoRedo is not null)
{
_undoRedo.CreateAction("Toggle Fold", customContext: _graph);
_undoRedo.AddDoMethod(
this,
MethodName.ApplyFoldState,
key,
folded);
_undoRedo.AddUndoMethod(
this,
MethodName.ApplyFoldState,
key,
oldFolded);
_undoRedo.CommitAction(false);
}
}
private void ApplyFoldState(string key, bool folded)
{
SetFoldState(key, folded);
RebuildNode();
}
private void OnResizeRequest(Vector2 newMinSize)
{
CustomMinimumSize = new Vector2(newMinSize.X, 0);
Size = new Vector2(newMinSize.X, 0);
SaveCustomWidth(newMinSize.X);
}
private void OnResizeEnd(Vector2 newSize)
{
float newWidth = CustomMinimumSize.X;
if (_undoRedo is not null && NodeResource is not null
&& !Mathf.IsEqualApprox(_widthBeforeResize, newWidth))
{
float oldWidth = _widthBeforeResize;
_undoRedo.CreateAction("Resize Node", customContext: _graph);
_undoRedo.AddDoMethod(
this,
MethodName.ApplyCustomWidth,
newWidth);
_undoRedo.AddUndoMethod(
this,
MethodName.ApplyCustomWidth,
oldWidth);
_undoRedo.CommitAction(false);
}
_widthBeforeResize = newWidth;
}
private void ApplyCustomWidth(float width)
{
CustomMinimumSize = new Vector2(width, 0);
Size = new Vector2(width, 0);
SaveCustomWidth(width);
}
private void RestoreCustomWidth()
{
if (NodeResource is not null
&& NodeResource.CustomData.TryGetValue(CustomWidthKey, out Variant value))
{
float width = (float)value.AsDouble();
if (width > 0)
{
CustomMinimumSize = new Vector2(width, 0);
}
}
}
private void SaveCustomWidth(float width)
{
if (NodeResource is null)
{
return;
}
NodeResource.CustomData[CustomWidthKey] = Variant.From(width);
}
private void ApplyResolverBinding(
int directionInt,
int propertyIndex,
Variant resolverVariant)
{
if (NodeResource is null)
{
return;
}
var direction = (StatescriptPropertyDirection)directionInt;
StatescriptNodeProperty binding = EnsureBinding(direction, propertyIndex);
binding.Resolver = resolverVariant.VariantType == Variant.Type.Nil
? null
: resolverVariant.AsGodotObject() as StatescriptResolverResource;
RebuildNode();
}
private void RebuildNode()
{
if (NodeResource is null)
{
return;
}
EditorUndoRedoManager? savedUndoRedo = _undoRedo;
Initialize(NodeResource, _graph);
_undoRedo = savedUndoRedo;
Size = new Vector2(Size.X, 0);
}
private void RefreshHighlightState()
{
_isHighlighted = (!string.IsNullOrEmpty(_highlightedVariableName) && ReferencesVariable(_highlightedVariableName))
|| ReferencesSharedVariable(_highlightedSharedVariableSetPath, _highlightedSharedVariableName);
ApplyHighlightBorder();
UpdateChildHighlights();
}
private StatescriptNodeProperty? FindBinding(
StatescriptPropertyDirection direction,
int propertyIndex)
{
if (NodeResource is null)
{
return null;
}
foreach (StatescriptNodeProperty binding in NodeResource.PropertyBindings)
{
if (binding.Direction == direction && binding.PropertyIndex == propertyIndex)
{
return binding;
}
}
return null;
}
private StatescriptNodeProperty EnsureBinding(
StatescriptPropertyDirection direction,
int propertyIndex)
{
StatescriptNodeProperty? binding = FindBinding(direction, propertyIndex);
if (binding is null)
{
binding = new StatescriptNodeProperty
{
Direction = direction,
PropertyIndex = propertyIndex,
};
NodeResource!.PropertyBindings.Add(binding);
}
return binding;
}
private void RemoveBinding(StatescriptPropertyDirection direction, int propertyIndex)
{
if (NodeResource is null)
{
return;
}
for (int i = NodeResource.PropertyBindings.Count - 1; i >= 0; i--)
{
StatescriptNodeProperty binding = NodeResource.PropertyBindings[i];
if (binding.Direction == direction && binding.PropertyIndex == propertyIndex)
{
NodeResource.PropertyBindings.RemoveAt(i);
}
}
}
}
/// <summary>
/// Identifies a property binding slot by direction and index.
/// </summary>
/// <param name="Direction">The direction of the property (input or output).</param>
/// <param name="PropertyIndex">The index of the property within its direction.</param>
internal readonly record struct PropertySlotKey(StatescriptPropertyDirection Direction, int PropertyIndex);
#endif

View File

@@ -0,0 +1 @@
uid://cgb5kncrbsgb4

View File

@@ -0,0 +1,540 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Gamesmiths.Forge.Statescript;
using Gamesmiths.Forge.Statescript.Nodes;
using Gamesmiths.Forge.Statescript.Ports;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Discovers concrete Statescript node types from loaded assemblies using reflection.
/// </summary>
/// <remarks>
/// Provides port layout information for the editor without requiring node instantiation.
/// </remarks>
internal static class StatescriptNodeDiscovery
{
private static List<NodeTypeInfo>? _cachedNodeTypes;
/// <summary>
/// Gets all discovered concrete node types. Results are cached after first discovery.
/// </summary>
/// <returns>A read-only list of node type info.</returns>
internal static IReadOnlyList<NodeTypeInfo> GetDiscoveredNodeTypes()
{
_cachedNodeTypes ??= DiscoverNodeTypes();
return _cachedNodeTypes;
}
/// <summary>
/// Clears the cached discovery results, forcing re-discovery on next access.
/// </summary>
internal static void InvalidateCache()
{
_cachedNodeTypes = null;
}
/// <summary>
/// Finds the <see cref="NodeTypeInfo"/> for the given runtime type name.
/// </summary>
/// <param name="runtimeTypeName">The full type name stored in the resource.</param>
/// <returns>The matching node type info, or null if not found.</returns>
internal static NodeTypeInfo? FindByRuntimeTypeName(string runtimeTypeName)
{
IReadOnlyList<NodeTypeInfo> types = GetDiscoveredNodeTypes();
for (int i = 0; i < types.Count; i++)
{
if (types[i].RuntimeTypeName == runtimeTypeName)
{
return types[i];
}
}
return null;
}
private static List<NodeTypeInfo> DiscoverNodeTypes()
{
var results = new List<NodeTypeInfo>();
Type actionNodeType = typeof(ActionNode);
Type conditionNodeType = typeof(ConditionNode);
Type stateNodeOpenType = typeof(StateNode<>);
// Scan all loaded assemblies for concrete node types.
foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
{
Type[] types;
try
{
types = assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
types = ex.Types.Where(x => x is not null).ToArray()!;
}
foreach (Type type in types)
{
if (type.IsAbstract || type.IsGenericTypeDefinition)
{
continue;
}
// Skip the built-in Entry/Exit nodes — they are handled separately.
if (type == typeof(EntryNode) || type == typeof(ExitNode))
{
continue;
}
if (actionNodeType.IsAssignableFrom(type))
{
results.Add(BuildNodeTypeInfo(type, StatescriptNodeType.Action));
}
else if (conditionNodeType.IsAssignableFrom(type))
{
results.Add(BuildNodeTypeInfo(type, StatescriptNodeType.Condition));
}
else if (IsConcreteStateNode(type, stateNodeOpenType))
{
results.Add(BuildNodeTypeInfo(type, StatescriptNodeType.State));
}
}
}
results.Sort((a, b) => string.CompareOrdinal(a.DisplayName, b.DisplayName));
return results;
}
private static bool IsConcreteStateNode(Type type, Type stateNodeOpenType)
{
Type? current = type.BaseType;
while (current is not null)
{
if (current.IsGenericType && current.GetGenericTypeDefinition() == stateNodeOpenType)
{
return true;
}
current = current.BaseType;
}
return false;
}
private static NodeTypeInfo BuildNodeTypeInfo(Type type, StatescriptNodeType nodeType)
{
string displayName = FormatDisplayName(type.Name);
string runtimeTypeName = type.FullName!;
// Get constructor parameter names.
string[] constructorParamNames = GetConstructorParameterNames(type);
// Determine ports and description by instantiating a temporary node.
string[] inputLabels;
string[] outputLabels;
bool[] isSubgraph;
string description;
InputPropertyInfo[] inputPropertiesInfo;
OutputVariableInfo[] outputVariablesInfo;
try
{
Node tempNode = CreateTemporaryNode(type);
inputLabels = GetInputPortLabels(tempNode, nodeType);
outputLabels = GetOutputPortLabels(tempNode, nodeType);
isSubgraph = GetSubgraphFlags(tempNode);
description = tempNode.Description;
inputPropertiesInfo = GetInputPropertiesInfo(tempNode);
outputVariablesInfo = GetOutputVariablesInfo(tempNode);
}
catch
{
// Fallback to default port layout based on base type.
PortLayout[] portLayouts = GetDefaultPortLayout(nodeType);
inputLabels = [.. portLayouts.Select(x => x.InputLabel)];
outputLabels = [.. portLayouts.Select(x => x.OutputLabel)];
isSubgraph = [.. portLayouts.Select(x => x.IsSubgraph)];
description = $"{displayName} node.";
inputPropertiesInfo = [];
outputVariablesInfo = [];
}
return new NodeTypeInfo(
displayName,
runtimeTypeName,
nodeType,
inputLabels,
outputLabels,
isSubgraph,
constructorParamNames,
description,
inputPropertiesInfo,
outputVariablesInfo);
}
private static Node CreateTemporaryNode(Type type)
{
// Try to find the primary constructor or the one with the fewest parameters.
ConstructorInfo[] constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance);
if (constructors.Length == 0)
{
return (Node)Activator.CreateInstance(type)!;
}
// Sort by parameter count, prefer the fewest.
ConstructorInfo constructor = constructors.OrderBy(x => x.GetParameters().Length).First();
ParameterInfo[] parameters = constructor.GetParameters();
object[] args = new object[parameters.Length];
for (int i = 0; i < parameters.Length; i++)
{
Type paramType = parameters[i].ParameterType;
if (paramType == typeof(Forge.Core.StringKey))
{
args[i] = new Forge.Core.StringKey("_placeholder_");
}
else if (paramType == typeof(string))
{
args[i] = string.Empty;
}
else if (paramType.IsValueType)
{
args[i] = Activator.CreateInstance(paramType)!;
}
else
{
args[i] = null!;
}
}
return (Node)constructor.Invoke(args);
}
private static string[] GetConstructorParameterNames(Type type)
{
ConstructorInfo[] constructors = type.GetConstructors(BindingFlags.Public | BindingFlags.Instance);
if (constructors.Length == 0)
{
return [];
}
// Use the constructor with the most parameters (primary constructor).
ConstructorInfo constructor = constructors.OrderByDescending(x => x.GetParameters().Length).First();
return [.. constructor.GetParameters().Select(x => x.Name ?? string.Empty)];
}
private static string[] GetInputPortLabels(Node node, StatescriptNodeType nodeType)
{
int count = node.InputPorts.Length;
string[] labels = new string[count];
switch (nodeType)
{
case StatescriptNodeType.Action:
if (count >= 1)
{
labels[0] = "Execute";
}
break;
case StatescriptNodeType.Condition:
if (count >= 1)
{
labels[0] = "Condition";
}
break;
case StatescriptNodeType.State:
if (count >= 1)
{
labels[0] = "Begin";
}
if (count >= 2)
{
labels[1] = "Abort";
}
for (int i = 2; i < count; i++)
{
labels[i] = $"Input {i}";
}
break;
default:
for (int i = 0; i < count; i++)
{
labels[i] = $"Input {i}";
}
break;
}
return labels;
}
private static string[] GetOutputPortLabels(Node node, StatescriptNodeType nodeType)
{
int count = node.OutputPorts.Length;
string[] labels = new string[count];
switch (nodeType)
{
case StatescriptNodeType.Action:
if (count >= 1)
{
labels[0] = "Done";
}
break;
case StatescriptNodeType.Condition:
if (count >= 1)
{
labels[0] = "True";
}
if (count >= 2)
{
labels[1] = "False";
}
break;
case StatescriptNodeType.State:
if (count >= 1)
{
labels[0] = "OnActivate";
}
if (count >= 2)
{
labels[1] = "OnDeactivate";
}
if (count >= 3)
{
labels[2] = "OnAbort";
}
if (count >= 4)
{
labels[3] = "Subgraph";
}
for (int i = 4; i < count; i++)
{
labels[i] = $"Event {i}";
}
break;
default:
for (int i = 0; i < count; i++)
{
labels[i] = $"Output {i}";
}
break;
}
return labels;
}
private static bool[] GetSubgraphFlags(Node node)
{
int count = node.OutputPorts.Length;
bool[] flags = new bool[count];
for (int i = 0; i < count; i++)
{
flags[i] = node.OutputPorts[i] is SubgraphPort;
}
return flags;
}
private static InputPropertyInfo[] GetInputPropertiesInfo(Node node)
{
var propertiesInfo = new InputPropertyInfo[node.InputProperties.Length];
for (int i = 0; i < node.InputProperties.Length; i++)
{
propertiesInfo[i] = new InputPropertyInfo(
node.InputProperties[i].Label,
node.InputProperties[i].ExpectedType);
}
return propertiesInfo;
}
private static OutputVariableInfo[] GetOutputVariablesInfo(Node node)
{
var variablesInfo = new OutputVariableInfo[node.OutputVariables.Length];
for (int i = 0; i < node.OutputVariables.Length; i++)
{
variablesInfo[i] = new OutputVariableInfo(
node.OutputVariables[i].Label,
node.OutputVariables[i].ValueType,
node.OutputVariables[i].Scope);
}
return variablesInfo;
}
private static PortLayout[] GetDefaultPortLayout(
StatescriptNodeType nodeType)
{
return nodeType switch
{
StatescriptNodeType.Action => [new PortLayout("Execute", "Done", false)],
StatescriptNodeType.Condition => [
new PortLayout("Condition", "True", false),
new PortLayout(string.Empty, "False", false)],
StatescriptNodeType.State => [
new PortLayout("Begin", "OnActivate", false),
new PortLayout("Abort", "OnDeactivate", false),
new PortLayout(string.Empty, "OnAbort", false),
new PortLayout(string.Empty, "Subgraph", true)],
StatescriptNodeType.Entry => throw new NotImplementedException(),
StatescriptNodeType.Exit => throw new NotImplementedException(),
_ => [new PortLayout("Input", "Output", false)],
};
}
private static string FormatDisplayName(string typeName)
{
// Remove common suffixes.
if (typeName.EndsWith("Node", StringComparison.Ordinal))
{
typeName = typeName[..^4];
}
// Insert spaces before capital letters for camelCase names.
var result = new System.Text.StringBuilder();
for (int i = 0; i < typeName.Length; i++)
{
if (i > 0 && char.IsUpper(typeName[i]) && !char.IsUpper(typeName[i - 1]))
{
result.Append(' ');
}
result.Append(typeName[i]);
}
return result.ToString();
}
/// <summary>
/// Describes a discovered concrete node type and its port layout.
/// </summary>
internal sealed class NodeTypeInfo
{
/// <summary>
/// Gets the display name for this node type (e.g., "Timer", "Set Variable", "Expression").
/// </summary>
public string DisplayName { get; }
/// <summary>
/// Gets the CLR type name used for serialization (typically the type's full name).
/// </summary>
public string RuntimeTypeName { get; }
/// <summary>
/// Gets the node category (Action, Condition, State).
/// </summary>
public StatescriptNodeType NodeType { get; }
/// <summary>
/// Gets the input port labels for this node type.
/// </summary>
public string[] InputPortLabels { get; }
/// <summary>
/// Gets the output port labels for this node type.
/// </summary>
public string[] OutputPortLabels { get; }
/// <summary>
/// Gets whether each output port is a subgraph port.
/// </summary>
public bool[] IsSubgraphPort { get; }
/// <summary>
/// Gets the constructor parameter names for this node type.
/// </summary>
public string[] ConstructorParameterNames { get; }
/// <summary>
/// Gets a brief description for this node type, shown in the Add Node dialog.
/// Read from the <see cref="Node.Description"/> property at discovery time.
/// </summary>
public string Description { get; }
/// <summary>
/// Gets the input property declarations for this node type.
/// </summary>
public InputPropertyInfo[] InputPropertiesInfo { get; }
/// <summary>
/// Gets the output variable declarations for this node type.
/// </summary>
public OutputVariableInfo[] OutputVariablesInfo { get; }
public NodeTypeInfo(
string displayName,
string runtimeTypeName,
StatescriptNodeType nodeType,
string[] inputPortLabels,
string[] outputPortLabels,
bool[] isSubgraphPort,
string[] constructorParameterNames,
string description,
InputPropertyInfo[] inputPropertiesInfo,
OutputVariableInfo[] outputVariablesInfo)
{
DisplayName = displayName;
RuntimeTypeName = runtimeTypeName;
NodeType = nodeType;
InputPortLabels = inputPortLabels;
OutputPortLabels = outputPortLabels;
IsSubgraphPort = isSubgraphPort;
ConstructorParameterNames = constructorParameterNames;
Description = description;
InputPropertiesInfo = inputPropertiesInfo;
OutputVariablesInfo = outputVariablesInfo;
}
}
/// <summary>
/// Describes an input property declared by a node type.
/// </summary>
/// <param name="Label">The human-readable label for this input property.</param>
/// <param name="ExpectedType">The type the node expects to read.</param>
/// <param name="IsArray">Whether the input expects an array of values.</param>
internal readonly record struct InputPropertyInfo(string Label, Type ExpectedType, bool IsArray = false);
/// <summary>
/// Describes an output variable declared by a node type.
/// </summary>
/// <param name="Label">The human-readable label for this output variable.</param>
/// <param name="ValueType">The type the node writes.</param>
/// <param name="Scope">The default scope for this output variable.</param>
internal readonly record struct OutputVariableInfo(string Label, Type ValueType, VariableScope Scope);
private record struct PortLayout(string InputLabel, string OutputLabel, bool IsSubgraph);
}
#endif

View File

@@ -0,0 +1 @@
uid://cb2mf4xojoxal

View File

@@ -0,0 +1,96 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
/// <summary>
/// Registry of available <see cref="NodeEditorProperty"/> implementations. Resolver editors are discovered
/// automatically via reflection. Any concrete subclass of <see cref="NodeEditorProperty"/> in the executing assembly is
/// registered and becomes available in node input property dropdowns.
/// </summary>
internal static class StatescriptResolverRegistry
{
private static readonly List<Func<NodeEditorProperty>> _factories = [];
static StatescriptResolverRegistry()
{
Type[] allTypes = Assembly.GetExecutingAssembly().GetTypes();
foreach (Type type in allTypes.Where(
x => x.IsSubclassOf(typeof(NodeEditorProperty)) && !x.IsAbstract))
{
Type captured = type;
_factories.Add(() => (NodeEditorProperty)Activator.CreateInstance(captured)!);
}
}
/// <summary>
/// Gets factory functions for all resolver editors compatible with the given expected type.
/// </summary>
/// <param name="expectedType">The type expected by the node input property.</param>
/// <returns>A list of compatible resolver editor factories.</returns>
public static List<Func<NodeEditorProperty>> GetCompatibleFactories(Type expectedType)
{
return [.. _factories.Where(factory => IsCompatibleFactory(factory, expectedType))];
}
public static int GetDefaultFactoryIndex(List<Func<NodeEditorProperty>> factories, bool isArray)
{
for (int i = 0; i < factories.Count; i++)
{
if (isArray)
{
if (GetResolverTypeId(factories[i]) == "ArrayVariable")
{
return i;
}
}
else if (GetResolverTypeId(factories[i]) == "Variant")
{
return i;
}
}
return 0;
}
public static string GetDisplayName(Func<NodeEditorProperty> factory)
{
return UseTemporaryEditor(factory, static editor => editor.DisplayName);
}
public static string GetResolverTypeId(Func<NodeEditorProperty> factory)
{
return UseTemporaryEditor(factory, static editor => editor.ResolverTypeId);
}
public static bool IsCompatibleFactory(Func<NodeEditorProperty> factory, Type expectedType)
{
return UseTemporaryEditor(factory, editor => editor.IsCompatibleWith(expectedType));
}
private static TResult UseTemporaryEditor<TResult>(
Func<NodeEditorProperty> factory,
Func<NodeEditorProperty, TResult> selector)
{
NodeEditorProperty editor = factory();
try
{
return selector(editor);
}
finally
{
if (global::Godot.GodotObject.IsInstanceValid(editor))
{
editor.Free();
}
}
}
}
#endif

View File

@@ -0,0 +1 @@
uid://bq3g4cbysmedf

View File

@@ -0,0 +1,159 @@
// Copyright © Gamesmiths Guild.
#if TOOLS
using Gamesmiths.Forge.Godot.Resources.Statescript;
using Godot;
namespace Gamesmiths.Forge.Godot.Editor.Statescript;
internal sealed partial class StatescriptVariablePanel
{
private static void SetArrayElementValue(StatescriptGraphVariable variable, int index, Variant newValue)
{
variable.InitialArrayValues[index] = newValue;
variable.EmitChanged();
}
private void SetVariableValue(StatescriptGraphVariable variable, Variant newValue)
{
Variant oldValue = variable.InitialValue;
variable.InitialValue = newValue;
variable.EmitChanged();
if (_undoRedo is not null)
{
_undoRedo.CreateAction(
$"Change Variable '{variable.VariableName}'",
customContext: _graph);
_undoRedo.AddDoMethod(
this,
MethodName.ApplyVariableValue,
variable,
newValue);
_undoRedo.AddUndoMethod(
this,
MethodName.ApplyVariableValue,
variable,
oldValue);
_undoRedo.CommitAction(false);
}
}
private void ApplyVariableValue(StatescriptGraphVariable variable, Variant value)
{
variable.InitialValue = value;
variable.EmitChanged();
RebuildList();
VariableUndoRedoPerformed?.Invoke();
}
private void DoAddVariable(StatescriptGraph graph, StatescriptGraphVariable variable)
{
graph.Variables.Add(variable);
RebuildList();
VariablesChanged?.Invoke();
}
private void UndoAddVariable(StatescriptGraph graph, StatescriptGraphVariable variable)
{
graph.Variables.Remove(variable);
RebuildList();
VariablesChanged?.Invoke();
VariableUndoRedoPerformed?.Invoke();
}
private void DoRemoveVariable(StatescriptGraph graph, StatescriptGraphVariable variable, int index)
{
if (_selectedVariableName == variable.VariableName)
{
_selectedVariableName = null;
VariableHighlightChanged?.Invoke(null);
}
graph.Variables.RemoveAt(index);
ClearReferencesToVariable(variable.VariableName);
RebuildList();
VariablesChanged?.Invoke();
}
private void UndoRemoveVariable(StatescriptGraph graph, StatescriptGraphVariable variable, int index)
{
if (index >= graph.Variables.Count)
{
graph.Variables.Add(variable);
}
else
{
graph.Variables.Insert(index, variable);
}
RebuildList();
VariablesChanged?.Invoke();
VariableUndoRedoPerformed?.Invoke();
}
private void DoAddArrayElement(StatescriptGraphVariable variable, Variant value)
{
variable.InitialArrayValues.Add(value);
variable.EmitChanged();
_expandedArrays.Add(variable.VariableName);
SaveExpandedArrayState();
RebuildList();
}
private void UndoAddArrayElement(StatescriptGraphVariable variable)
{
if (variable.InitialArrayValues.Count > 0)
{
variable.InitialArrayValues.RemoveAt(variable.InitialArrayValues.Count - 1);
variable.EmitChanged();
}
RebuildList();
VariableUndoRedoPerformed?.Invoke();
}
private void DoRemoveArrayElement(StatescriptGraphVariable variable, int index)
{
variable.InitialArrayValues.RemoveAt(index);
variable.EmitChanged();
RebuildList();
}
private void UndoRemoveArrayElement(StatescriptGraphVariable variable, int index, Variant value)
{
if (index >= variable.InitialArrayValues.Count)
{
variable.InitialArrayValues.Add(value);
}
else
{
variable.InitialArrayValues.Insert(index, value);
}
variable.EmitChanged();
RebuildList();
VariableUndoRedoPerformed?.Invoke();
}
private void DoSetArrayExpanded(string variableName, bool expanded)
{
if (expanded)
{
_expandedArrays.Add(variableName);
}
else
{
_expandedArrays.Remove(variableName);
}
SaveExpandedArrayState();
RebuildList();
VariableUndoRedoPerformed?.Invoke();
}
}
#endif

View File

@@ -0,0 +1 @@
uid://cbrse4fxsk87x

Some files were not shown because too many files have changed in this diff Show More