Compare commits

..

53 Commits

Author SHA1 Message Date
d737879ed6 More slash command stuff 2026-05-26 18:42:48 -04:00
46051526e6 New slash command parser 2026-05-26 18:20:35 -04:00
8dab0d16b7 CPL command 2026-05-26 15:07:42 -04:00
933c1ac6c3 More tweaks on tracebacks 2026-05-21 19:40:30 -04:00
521d4726ad Make lua tracebacks a little better 2026-05-21 19:24:11 -04:00
2bfa3024f1 Lots of work on the lua read-eval-print loop 2026-05-21 18:41:09 -04:00
f7983b1f02 Final form of radial menus 2026-05-19 02:55:31 -04:00
36ec4a3b9b Add new input device processor 2026-05-18 19:26:11 -04:00
1c2be1b4d8 Radial menus are in good shape 2026-05-18 16:12:26 -04:00
0b23c82e73 Progress on radial menus 2026-05-18 02:26:28 -04:00
b1defd821b More work on radial menus 2026-05-16 02:27:18 -04:00
e17f5417f2 Real progress on radial menus 2026-05-16 01:49:26 -04:00
c0848c2670 Completing downgrade to 5.5.4 2026-05-15 19:37:34 -04:00
94e6385f14 Downgrading to Unreal 5.5.4 2026-05-15 18:28:41 -04:00
1328f6e5f7 Working on radial menus 2026-05-15 18:14:38 -04:00
5d2377df1d More work on the argv conversion of ue-wingman 2026-05-13 22:03:19 -04:00
e0d45cc1db Refactoring ue-wingman to be a command-line only tool 2026-05-13 21:36:40 -04:00
ff9c045c8e Initial code for radial rendering 2026-05-11 00:13:30 -04:00
e669140e2c Layout code for radial menu complete. 2026-05-09 03:16:41 -04:00
420ea088d7 More work on radial menus 2026-05-08 17:55:11 -04:00
b00ec49e91 A few tweaks for the latest engine version 2026-05-08 14:16:52 -04:00
1aa888ac82 THinking about radial menus 2026-05-08 04:26:54 -04:00
236693fca6 More work on Prompt Widget 2026-05-07 04:11:37 -04:00
b5e121f884 Update to latest engine 2026-05-05 19:12:03 -04:00
ac4302141c Add gitdeps cache 2026-05-05 18:19:30 -04:00
e16e0978b0 Upgrade to unreal 5.7.4 2026-05-05 16:26:49 -04:00
3be98f3617 Yet another fix for UEDataFormatter.py 2026-05-04 16:51:39 -04:00
3cf984ff65 Fewer log messages from UE Wingman 2026-05-04 16:11:06 -04:00
78c85660c9 Prompt widget is pretty good now 2026-05-04 15:52:05 -04:00
3e7e6a2ae4 More progress on prompt 2026-05-04 15:27:28 -04:00
9d37f02d44 More work on prompt widget 2026-05-04 11:55:08 -04:00
97b5a3c593 Initial work on prompt widget 2026-05-04 02:14:14 -04:00
ae0defbad9 WingProperty now contains an object pointer. This made it possible to implement structprop a lot better, it now can dig into objects it couldn't dig into before, including structprop-within-structprop 2026-04-27 19:06:57 -04:00
9598004e6d remove __intellisense__ 2026-04-27 04:15:33 -04:00
4680a0f3f4 Crosshair is back 2026-04-25 01:14:16 -04:00
3f6ef4b56c Simplify keyboard focus rule to just 'widget in front', full stop. 2026-04-24 20:17:08 -04:00
960abba07f Intellisense clangd fix 2026-04-22 23:32:50 -04:00
a689d59ea0 More work on focus, and good docs 2026-04-22 22:52:04 -04:00
a964211cc8 Fix some issues with new root canvas stuff 2026-04-22 17:02:24 -04:00
d985a6bc55 More fixing everything 2026-04-22 07:22:55 -04:00
4420c52b74 Working on new root canvas stuff 2026-04-21 22:28:22 -04:00
8e5d43fd24 Working on new root canvas stuff 2026-04-21 21:26:06 -04:00
ec983951fe InputModeRequests now use less-than operator to sort 2026-04-21 04:24:35 -04:00
9787522ef6 UE Data formatters done. 2026-04-21 01:00:45 -04:00
0d607ba277 More work on data formatters 2026-04-20 20:52:17 -04:00
4c1eebab96 Working on lldb data formatting some more 2026-04-20 08:42:36 -04:00
f3e1daf4fe More work on getting lldb data formatters in better shape. 2026-04-20 08:23:36 -04:00
21d8c40005 Better data formatters in progress. 2026-04-20 05:42:34 -04:00
275698c5aa Trying to improve lldb 2026-04-19 05:03:11 -04:00
dabb5b8f0b Lots of work on focus management 2026-04-19 03:01:23 -04:00
fd970f20c3 Lots of work on input processing 2026-04-18 01:11:21 -04:00
6388de9b39 Much work on input mode switching 2026-04-17 23:43:28 -04:00
7a09da8a4e Player controller code to sort input components better: widgets with 'Stop Input' go to top of priority stack, and also, priority actually works now. 2026-04-17 17:56:10 -04:00
123 changed files with 7602 additions and 1926 deletions

2
.gitignore vendored
View File

@@ -27,6 +27,7 @@ UnrealEngine
*.vcproj *.vcproj
.ignore .ignore
.gitdeps-cache/**
.vscode/** .vscode/**
Config/** Config/**
Saved/** Saved/**
@@ -52,3 +53,4 @@ GPF-output/**
__pycache__/ __pycache__/
.clangd-query/ .clangd-query/
COMMIT.txt COMMIT.txt
CLAUDE.md

View File

@@ -1,8 +0,0 @@
{
"mcpServers": {
"ue-wingman": {
"command": "python3",
"args": ["Plugins/UEWingman/ue-wingman-mcp.py"]
}
}
}

View File

@@ -25,9 +25,16 @@
- `Docs/` — Documentation. - `Docs/` — Documentation.
- `Config/` — Unreal config files - `Config/` — Unreal config files
- `EnginePatches/` — Custom engine modifications - `EnginePatches/` — Custom engine modifications
- `Plugins/UEWingman/' - An MCP that gives you control over the unreal editor. - `Plugins/UEWingman/` - A plugin that gives you control over the unreal editor.
- `../integration.UE/` - the unreal engine source tree - `../integration.UE/` - the unreal engine source tree
## Using ue-wingman
- Drive it from bash using: ue-wingman <Command> <Arg1> <Arg2> ...
- ue-wingman Documentation_Manual
- ue-wingman Documentation_Commands
- ue-wingman Documentation_Command <specific_command>
## Coding Conventions ## Coding Conventions
- Prefer early returns and `continue` to reduce nesting (never-nester style). - Prefer early returns and `continue` to reduce nesting (never-nester style).

1498
Art/gamepad.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 65 KiB

321
Art/radial.svg Normal file
View File

@@ -0,0 +1,321 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="200mm"
height="200mm"
viewBox="0 0 200 200"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="radial.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="2.8284271"
inkscape:cx="365.75098"
inkscape:cy="372.82205"
inkscape:window-width="1920"
inkscape:window-height="1026"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
type="xygrid"
id="grid132"
spacingx="0.99999999"
spacingy="0.99999999" />
</sodipodi:namedview>
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05555556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:center;text-anchor:middle;fill:none;stroke:#707070;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="120"
y="90"
id="text2926"><tspan
sodipodi:role="line"
id="tspan2924"
style="stroke-width:1;font-size:7.05555556px"
x="120"
y="90" /></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="116"
y="87"
id="text3271"><tspan
sodipodi:role="line"
id="tspan3269"
style="stroke-width:1"
x="116"
y="87">Light the furnace</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="120.365"
y="96.362228"
id="text3325"><tspan
sodipodi:role="line"
id="tspan3323"
style="stroke-width:1"
x="120.365"
y="96.362228">Replenish Fuel</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="121"
y="107"
id="text3329"><tspan
sodipodi:role="line"
id="tspan3327"
style="stroke-width:1"
x="121"
y="107">Bake chicken</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="116"
y="117"
id="text3333"><tspan
sodipodi:role="line"
id="tspan3331"
style="stroke-width:1"
x="116"
y="117">Cannibalism</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="84"
y="87"
id="text3337"><tspan
sodipodi:role="line"
id="tspan3335"
style="stroke-width:1"
x="84"
y="87">Banana Bread</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="79"
y="97"
id="text3341"><tspan
sodipodi:role="line"
id="tspan3339"
style="stroke-width:1"
x="79"
y="97">Electric Eel</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="79"
y="107"
id="text3345"><tspan
sodipodi:role="line"
id="tspan3343"
style="stroke-width:1"
x="79"
y="107">Engine Fire</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="84"
y="117"
id="text3349"><tspan
sodipodi:role="line"
id="tspan3347"
style="stroke-width:1"
x="84"
y="117">Radial Menu</tspan></text>
<path
style="fill:none;fill-opacity:1;stroke:#707070;stroke-width:0.3;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m 89.999999,84.999999 4,-10e-7"
id="path4558"
sodipodi:nodetypes="cc" />
<path
style="fill:none;fill-opacity:1;stroke:#707070;stroke-width:0.3;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m 106,84.999998 4,10e-7"
id="path4562"
sodipodi:nodetypes="cc" />
<path
style="fill:none;fill-opacity:1;stroke:#707070;stroke-width:0.3;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m 84.999999,94.999999 3,0"
id="path4564"
sodipodi:nodetypes="cc" />
<path
style="fill:none;fill-opacity:1;stroke:#707070;stroke-width:0.3;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m 112,94.999997 3,2e-6"
id="path4566"
sodipodi:nodetypes="cc" />
<path
style="fill:none;fill-opacity:1;stroke:#707070;stroke-width:0.3;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m 84.999999,105 3,0"
id="path4568"
sodipodi:nodetypes="cc" />
<path
style="fill:none;fill-opacity:1;stroke:#707070;stroke-width:0.3;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m 112,105 3,0"
id="path4570"
sodipodi:nodetypes="cc" />
<path
style="fill:none;fill-opacity:1;stroke:#707070;stroke-width:0.3;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m 89.999999,115 4,0"
id="path4572"
sodipodi:nodetypes="cc" />
<path
style="fill:none;fill-opacity:1;stroke:#707070;stroke-width:0.3;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m 106,115 4,0"
id="path4574"
sodipodi:nodetypes="cc" />
<path
id="path4576"
style="fill:#662066;stroke:#707070;stroke-width:0.3;stroke-linecap:round"
d="m 102,100.15803 c 0,1.10457 -0.89543,2 -2.000004,2 -1.10457,0 -2,-0.89543 -2,-2 0,-1.104575 2,-4.000005 2,-4.000005 0,0 2.000004,2.89543 2.000004,4.000005 z"
sodipodi:nodetypes="ssscs" />
<path
style="fill:none;stroke:#707070;stroke-width:0.3;stroke-linecap:round"
d="m 112,105 -5.72673,-2.38614"
id="path1147" />
<path
style="fill:none;stroke:#707070;stroke-width:0.3;stroke-linecap:round"
d="M 106.27327,97.386131 112,94.999997"
id="path1149" />
<path
style="fill:none;stroke:#707070;stroke-width:0.3;stroke-linecap:round"
d="M 102.52401,93.689963 106,84.999996"
id="path1151" />
<path
style="fill:none;stroke:#707070;stroke-width:0.3;stroke-linecap:round"
d="M 97.475986,93.689963 93.999999,84.999996"
id="path1153" />
<path
style="fill:none;stroke:#707070;stroke-width:0.3;stroke-linecap:round"
d="m 106,115 -3.47599,-8.68997"
id="path1155" />
<path
style="fill:none;stroke:#707070;stroke-width:0.3;stroke-linecap:round"
d="m 93.999999,115 3.475987,-8.68997"
id="path1157" />
<path
style="fill:none;stroke:#707070;stroke-width:0.3;stroke-linecap:round"
d="m 87.999998,105 5.726726,-2.38614"
id="path1159" />
<path
style="fill:none;stroke:#707070;stroke-width:0.3;stroke-linecap:round"
d="m 87.999998,94.999997 5.726726,2.386136"
id="path1161" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="path1220"
cx="102.52401"
cy="106.31003"
r="0.49999997" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="circle1274"
cx="97.475983"
cy="106.31003"
r="0.49999997" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="circle1276"
cx="93.726723"
cy="102.61386"
r="0.49999997" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="circle1278"
cx="106.27327"
cy="102.61386"
r="0.49999997" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="circle1280"
cx="106.27327"
cy="97.386131"
r="0.49999997" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="circle1282"
cx="102.52401"
cy="93.689964"
r="0.49999997" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="circle1284"
cx="90"
cy="85"
r="0.49999997" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="circle1286"
cx="93.726723"
cy="97.386131"
r="0.49999997" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="circle1290"
cx="97.475983"
cy="93.689964"
r="0.49999997" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="circle1292"
cx="110"
cy="85"
r="0.49999997" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="circle1294"
cx="115"
cy="95"
r="0.49999997" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="circle1296"
cx="115"
cy="105"
r="0.49999997" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="circle1298"
cx="110"
cy="115"
r="0.49999997" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="circle1300"
cx="90"
cy="115"
r="0.49999997" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="circle1302"
cx="85"
cy="105"
r="0.49999997" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="circle1304"
cx="85"
cy="95"
r="0.49999997" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

304
Art/radial2.svg Normal file
View File

@@ -0,0 +1,304 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="200mm"
height="200mm"
viewBox="0 0 200 200"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="radial2.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="2"
inkscape:cx="375.25"
inkscape:cy="379.25"
inkscape:window-width="1920"
inkscape:window-height="1026"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
type="xygrid"
id="grid132"
spacingx="0.99999999"
spacingy="0.99999999" />
</sodipodi:namedview>
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05555556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:center;text-anchor:middle;fill:none;stroke:#707070;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="120"
y="90"
id="text2926"><tspan
sodipodi:role="line"
id="tspan2924"
style="stroke-width:1;font-size:7.05555556px"
x="120"
y="90" /></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="130"
y="67.497505"
id="text3271"><tspan
sodipodi:role="line"
id="tspan3269"
style="stroke-width:1"
x="130"
y="67.497505">Light the furnace</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="130"
y="77.497505"
id="text3325"><tspan
sodipodi:role="line"
id="tspan3323"
style="stroke-width:1"
x="130"
y="77.497505">Replenish Fuel</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="130"
y="87.497505"
id="text3329"><tspan
sodipodi:role="line"
id="tspan3327"
style="stroke-width:1"
x="130"
y="87.497505">Bake chicken</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="130"
y="97.497505"
id="text3333"><tspan
sodipodi:role="line"
id="tspan3331"
style="stroke-width:1"
x="130"
y="97.497505">Cannibalism</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="130"
y="107.49751"
id="text3271-9"><tspan
sodipodi:role="line"
id="tspan3269-1"
style="stroke-width:1"
x="130"
y="107.49751">Light the furnace</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="130"
y="117.49751"
id="text3325-2"><tspan
sodipodi:role="line"
id="tspan3323-7"
style="stroke-width:1"
x="130"
y="117.49751">Replenish Fuel</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="130"
y="127.49751"
id="text3329-0"><tspan
sodipodi:role="line"
id="tspan3327-9"
style="stroke-width:1"
x="130"
y="127.49751">Bake chicken</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="130"
y="137.4975"
id="text3333-3"><tspan
sodipodi:role="line"
id="tspan3331-6"
style="stroke-width:1"
x="130"
y="137.4975">Cannibalism</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="70"
y="67.497505"
id="text3337"><tspan
sodipodi:role="line"
id="tspan3335"
style="stroke-width:1"
x="70"
y="67.497505">Banana Bread</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="70"
y="77.497505"
id="text3341"><tspan
sodipodi:role="line"
id="tspan3339"
style="stroke-width:1"
x="70"
y="77.497505">Electric Eel</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="70"
y="87.497505"
id="text3345"><tspan
sodipodi:role="line"
id="tspan3343"
style="stroke-width:1"
x="70"
y="87.497505">Engine Fire</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="70"
y="97.497505"
id="text3349"><tspan
sodipodi:role="line"
id="tspan3347"
style="stroke-width:1"
x="70"
y="97.497505">Radial Menu</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="70"
y="107.49751"
id="text3337-3"><tspan
sodipodi:role="line"
id="tspan3335-6"
style="stroke-width:1"
x="70"
y="107.49751">Banana Bread</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="70"
y="117.49751"
id="text3341-7"><tspan
sodipodi:role="line"
id="tspan3339-5"
style="stroke-width:1"
x="70"
y="117.49751">Electric Eel</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="70"
y="127.49751"
id="text3345-3"><tspan
sodipodi:role="line"
id="tspan3343-5"
style="stroke-width:1"
x="70"
y="127.49751">Engine Fire</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="70"
y="137.4975"
id="text3349-6"><tspan
sodipodi:role="line"
id="tspan3347-2"
style="stroke-width:1"
x="70"
y="137.4975">Radial Menu</tspan></text>
<path
id="path4576"
style="fill:#662066;stroke:#707070;stroke-width:0.3;stroke-linecap:round"
d="m 82.000003,188 c 0,1.10457 -0.89543,2 -2.000004,2 -1.10457,0 -2,-0.89543 -2,-2 0,-1.10457 2,-4.00001 2,-4.00001 0,0 2.000004,2.89544 2.000004,4.00001 z"
sodipodi:nodetypes="ssscs" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="circle1284"
cx="125"
cy="194.5"
r="0.49999997" />
<path
style="fill:none;stroke:#707070;stroke-width:1;stroke-linecap:round;stroke-opacity:1;stroke-dasharray:none"
d="m 135,160 h 30"
id="path1524" />
<path
style="fill:none;stroke:#707070;stroke-width:1;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 93.038085,64.999999 99.999992,99.999995 106.96191,65"
id="path1528"
sodipodi:nodetypes="ccc" />
<path
style="fill:none;stroke:#707070;stroke-width:1;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 99.999995,99.999995 83.295555,75"
id="path1530"
sodipodi:nodetypes="cc" />
<path
style="fill:none;stroke:#707070;stroke-width:1;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 116.70446,75 99.999995,99.999995 77.550899,84.999992"
id="path1532"
sodipodi:nodetypes="ccc" />
<path
style="fill:none;stroke:#707070;stroke-width:1;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 122.44906,84.99999 99.999995,99.999995 74.863225,95"
id="path1536"
sodipodi:nodetypes="ccc" />
<path
style="fill:none;stroke:#707070;stroke-width:1;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 99.999995,99.999995 74.863247,105"
id="path1538" />
<path
style="fill:none;stroke:#707070;stroke-width:1;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 99.999992,99.999995 77.550885,114.99999"
id="path1544" />
<path
style="fill:none;stroke:#707070;stroke-width:1;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 83.29551,125 99.999995,99.999995"
id="path1546" />
<path
style="fill:none;stroke:#707070;stroke-width:1;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="m 93.038045,135 6.96195,-35.000005"
id="path1548" />
<path
style="fill:none;stroke:#707070;stroke-width:1;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 106.96198,135 99.999992,99.999995"
id="path1550" />
<path
style="fill:none;stroke:#707070;stroke-width:1;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 116.70444,125 99.999995,99.999995"
id="path1552" />
<path
style="fill:none;stroke:#707070;stroke-width:1;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 122.44909,114.99999 99.999995,99.999995"
id="path1554" />
<path
style="fill:none;stroke:#707070;stroke-width:1;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
d="M 125.13655,95 99.999995,99.999995 125.13674,105"
id="path1556"
sodipodi:nodetypes="ccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

449
Art/radial3.svg Normal file
View File

@@ -0,0 +1,449 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="200mm"
height="200mm"
viewBox="0 0 200 200"
version="1.1"
id="svg5"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="radial3.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="2.8284271"
inkscape:cx="396.51013"
inkscape:cy="365.57421"
inkscape:window-width="1276"
inkscape:window-height="673"
inkscape:window-x="153"
inkscape:window-y="27"
inkscape:window-maximized="0"
inkscape:current-layer="layer1">
<inkscape:grid
type="xygrid"
id="grid132"
spacingx="0.99999999"
spacingy="0.99999999" />
</sodipodi:namedview>
<defs
id="defs2">
<inkscape:path-effect
effect="bspline"
id="path-effect7971"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect7967"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect7963"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect7959"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect7955"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect7951"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect7947"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect7943"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect7939"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect7935"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect7931"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect7927"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect7923"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect7919"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect7915"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
<inkscape:path-effect
effect="bspline"
id="path-effect7911"
is_visible="true"
lpeversion="1"
weight="33.333333"
steps="2"
helper_size="0"
apply_no_weight="true"
apply_with_weight="true"
only_selected="false" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05555556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:center;text-anchor:middle;fill:none;stroke:#707070;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="120"
y="90"
id="text2926"><tspan
sodipodi:role="line"
id="tspan2924"
style="stroke-width:1;font-size:7.05555556px"
x="120"
y="90" /></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="105"
y="67.497505"
id="text3271"><tspan
sodipodi:role="line"
id="tspan3269"
style="stroke-width:1"
x="105"
y="67.497505">Light the furnace</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="105"
y="77.497505"
id="text3325"><tspan
sodipodi:role="line"
id="tspan3323"
style="stroke-width:1"
x="105"
y="77.497505">Replenish Fuel</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="105"
y="87.497505"
id="text3329"><tspan
sodipodi:role="line"
id="tspan3327"
style="stroke-width:1"
x="105"
y="87.497505">Bake chicken</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="105"
y="97.497505"
id="text3333"><tspan
sodipodi:role="line"
id="tspan3331"
style="stroke-width:1"
x="105"
y="97.497505">Cannibalism</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="105"
y="107.49751"
id="text3271-9"><tspan
sodipodi:role="line"
id="tspan3269-1"
style="stroke-width:1"
x="105"
y="107.49751">Light the furnace</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="105"
y="117.49751"
id="text3325-2"><tspan
sodipodi:role="line"
id="tspan3323-7"
style="stroke-width:1"
x="105"
y="117.49751">Replenish Fuel</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="105"
y="127.49751"
id="text3329-0"><tspan
sodipodi:role="line"
id="tspan3327-9"
style="stroke-width:1"
x="105"
y="127.49751">Bake chicken</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:start;text-anchor:start;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="105"
y="137.4975"
id="text3333-3"><tspan
sodipodi:role="line"
id="tspan3331-6"
style="stroke-width:1"
x="105"
y="137.4975">Cannibalism</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="65"
y="67.497505"
id="text3337"><tspan
sodipodi:role="line"
id="tspan3335"
style="stroke-width:1"
x="65"
y="67.497505">Banana Bread</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="65"
y="77.497505"
id="text3341"><tspan
sodipodi:role="line"
id="tspan3339"
style="stroke-width:1"
x="65"
y="77.497505">Electric Eel</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="65"
y="87.497505"
id="text3345"><tspan
sodipodi:role="line"
id="tspan3343"
style="stroke-width:1"
x="65"
y="87.497505">Engine Fire</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="65"
y="97.497505"
id="text3349"><tspan
sodipodi:role="line"
id="tspan3347"
style="stroke-width:1"
x="65"
y="97.497505">Radial Menu</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="65"
y="107.49751"
id="text3337-3"><tspan
sodipodi:role="line"
id="tspan3335-6"
style="stroke-width:1"
x="65"
y="107.49751">Banana Bread</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="65"
y="117.49751"
id="text3341-7"><tspan
sodipodi:role="line"
id="tspan3339-5"
style="stroke-width:1"
x="65"
y="117.49751">Electric Eel</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="65"
y="127.49751"
id="text3345-3"><tspan
sodipodi:role="line"
id="tspan3343-5"
style="stroke-width:1"
x="65"
y="127.49751">Engine Fire</tspan></text>
<text
xml:space="preserve"
style="font-weight:bold;font-size:7.05556px;font-family:sans-serif;-inkscape-font-specification:'sans-serif Bold';text-align:end;text-anchor:end;fill:#666666;fill-opacity:1;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:1"
x="65"
y="137.4975"
id="text3349-6"><tspan
sodipodi:role="line"
id="tspan3347-2"
style="stroke-width:1"
x="65"
y="137.4975">Radial Menu</tspan></text>
<path
id="path4576"
style="fill:#662066;stroke:#707070;stroke-width:0.3;stroke-linecap:round"
d="m 82.000003,188 c 0,1.10457 -0.89543,2 -2.000004,2 -1.10457,0 -2,-0.89543 -2,-2 0,-1.10457 2,-4.00001 2,-4.00001 0,0 2.000004,2.89544 2.000004,4.00001 z"
sodipodi:nodetypes="ssscs" />
<circle
style="fill:#662066;fill-opacity:1;stroke:none;stroke-width:0.3;stroke-linecap:round"
id="circle1284"
cx="125"
cy="194.5"
r="0.49999997" />
<path
style="fill:none;stroke:#707070;stroke-width:1;stroke-linecap:round;stroke-opacity:1;stroke-dasharray:none"
d="m 135,160 h 30"
id="path1524" />
<path
sodipodi:type="star"
style="fill:none;stroke:#700726;stroke-width:1;stroke-linecap:round"
id="path9423"
inkscape:flatsided="false"
sodipodi:sides="8"
sodipodi:cx="84.999992"
sodipodi:cy="50"
sodipodi:r1="10"
sodipodi:r2="10"
sodipodi:arg1="1.5707963"
sodipodi:arg2="1.9634954"
inkscape:rounded="0"
inkscape:randomized="0"
d="M 84.999993,60 81.173158,59.238795 77.928925,57.071068 75.761197,53.826834 74.999992,50 75.761197,46.173166 77.928924,42.928932 81.173158,40.761205 84.999992,40 l 3.826835,0.761205 3.244233,2.167727 2.167728,3.244234 L 94.999992,50 l -0.761204,3.826834 -2.167728,3.244234 -3.244233,2.167727 z"
inkscape:transform-center-x="1.0036851e-06"
transform="rotate(11.25,-168.82926,75.000033)" />
<path
style="fill:none;stroke:#700726;stroke-width:1;stroke-linecap:round"
d="M 64.999999,64.999999 H 105 V 74.999997 84.999998 94.999997 105 v 10 10 10 H 64.999999 V 125 115 105 94.999997 84.999998 74.999997 64.999999"
id="path9425" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -4,7 +4,7 @@
[/Script/Engine.Engine] [/Script/Engine.Engine]
GameViewportClientClassName=/Script/CommonUI.CommonGameViewportClient GameViewportClientClassName=/Script/Integration.lxViewportClient
[/Script/EngineSettings.GameMapsSettings] [/Script/EngineSettings.GameMapsSettings]
GameDefaultMap=/Game/LpxLevel.LpxLevel GameDefaultMap=/Game/LpxLevel.LpxLevel

View File

@@ -84,3 +84,16 @@ DefaultTouchInterface=/Engine/MobileResources/HUD/DefaultVirtualJoysticks.Defaul
-ConsoleKeys=Tilde -ConsoleKeys=Tilde
+ConsoleKeys=Tilde +ConsoleKeys=Tilde
[InputPlatformSettings_Linux InputPlatformSettings]
MaxPlatformUserCount=8
input.DeviceMappingPolicy=1
MaxTriggerFeedbackPosition=8
MaxTriggerFeedbackStrength=8
MaxTriggerVibrationTriggerPosition=9
MaxTriggerVibrationFrequency=255
MaxTriggerVibrationAmplitude=8
+HardwareDevices=(InputClassName="",HardwareDeviceIdentifier="",PrimaryDeviceType=Unspecified,SupportedFeaturesMask=0)
+HardwareDevices=(InputClassName="DefaultKeyboardAndMouse",HardwareDeviceIdentifier="KBM",PrimaryDeviceType=KeyboardAndMouse,SupportedFeaturesMask=3)
+HardwareDevices=(InputClassName="DefaultGamepad",HardwareDeviceIdentifier="Gamepad",PrimaryDeviceType=Gamepad,SupportedFeaturesMask=4)
+HardwareDevices=(InputClassName="DefaultMobileTouch",HardwareDeviceIdentifier="MobileTouch",PrimaryDeviceType=Touch,SupportedFeaturesMask=8)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
Content/Luprex/lxPrompts.uasset LFS Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
Content/Widgets/teardrop.uasset LFS Normal file

Binary file not shown.

BIN
Content/Widgets/white-dot.uasset LFS Normal file

Binary file not shown.

View File

@@ -1,33 +0,0 @@
# Ideas for BlueprintMCP handler registration
UCLASS()
class USpawnNodeHandler : public UMCPHandler
{
UPROPERTY()
FString BlueprintName;
UPROPERTY();
FString GraphName;
UPROPERTY();
FString ActionName;
UPROPERTY(meta = "optional");
int PosX;
UPROPERTY(meta = "optional");
int PosY;
UPROPERTY()
ElxLuaValueType ValueType;
// Dummy field. Nothing is actually extracted by the argument parser,
// but this tells the argument parser that the parameter "subtree" is
// supposed to be there.
UPROPERTY()
void Subtree;
virtual void Handle(Json, Result) override;
};

View File

@@ -1,49 +0,0 @@
# Blueprint Text Export
Blueprints are stored as binary `.uasset` files that Claude Code cannot read directly. To work around this, a text exporter automatically converts blueprint graphs to readable text files whenever a blueprint is saved in the Unreal editor.
## How It Works
The game module (`FlxIntegrationModuleImpl` in `Source/Integration/Integration.cpp`) hooks into `UPackage::PackageSavedWithContextEvent`. When a blueprint is saved, it iterates each `UEdGraph` in the blueprint and runs `FlxBlueprintExporter` on it. The output is written to `Saved/BlueprintExports/<BlueprintName>/<GraphName>.txt`.
The exporter class (`Source/Integration/BlueprintExporter.h/.cpp`) processes one graph at a time. The constructor runs all passes and the result is available via `GetOutput()`.
## Output Format
The graph file is written to `Saved/BlueprintExports/<BlueprintName>/<GraphName>.txt`. A details file with node-name-to-GUID mappings is written to `Saved/BlueprintExports/<BlueprintName>/DETAILS/<GraphName>.txt`.
Every line in the graph file starts with a keyword, making it easy to parse. The format is:
```
node Event_Tick
return Output_Delegate, Delta_Seconds
goto Set_Tick_Delta_Seconds
node Set_Tick_Delta_Seconds
input Real Tick_Delta_Seconds = Event_Tick.Delta_Seconds
return Output_Get
goto CallFunctionByName
```
### Line Keywords
- `node Name` — starts a new node block.
- `input Type Name = Source` — an input data pin. Source is a `Node.Pin` reference, a literal value, `<self>`, or `<default>`.
- `return Pin1, Pin2` — output data pins.
- `goto Target` — exec flow (single output).
- `goto-if PinName Target` — exec flow (multiple outputs, e.g. branch true/false).
- `// comment text` — comment node.
### Special Handling
- String defaults are shown in quotes.
- Variable get nodes are inlined (the variable name appears directly at the point of use rather than as a separate node).
- Knot (reroute) nodes are followed through transparently.
## Node Ordering
Nodes are sorted by a traversal algorithm: find starter nodes (exec output but no exec input), sort them by Y position, then traverse each. The traversal visits a node's inputs first (so data sources appear before their consumers), then emits the node itself, then follows exec outputs. This produces a readable top-down flow. Any unvisited nodes are appended at the end.
## Node Naming
Names are derived from `ENodeTitleType::ListView` titles, sanitized to alphanumeric plus underscores. Duplicates get `_2`, `_3`, etc.

View File

@@ -0,0 +1,117 @@
# Getting the Gamepad USB Device Name
Unreal exposes `UInputDeviceSubsystem::GetMostRecentlyUsedHardwareDevice()`, which
returns an `FHardwareDeviceIdentifier`. In stock Unreal this is nearly useless: on
Windows it toggles between `"WindowsApplication"` (KBM) and `"XInputController"`
(any XInput pad); on Linux it returns nothing meaningful at all. It cannot
distinguish a DualSense from an Xbox pad.
The USB-reported device name (e.g. `"Sony Interactive Entertainment Wireless
Controller"`) is what we want to surface. This document describes the changes
required on each platform to put that name into `FHardwareDeviceIdentifier`.
## How `FHardwareDeviceIdentifier` Gets Populated
Input drivers wrap their `MessageHandler->OnControllerButton/Analog` calls in an
`FInputDeviceScope` (stack-allocated, thread-local stack of pointers). The scope
carries `InputDeviceName` (driver class) and `HardwareDeviceIdentifier` (physical
device string). When the input event reaches `UInputDeviceSubsystem`, those two
fields are copied into the `FHardwareDeviceIdentifier` record for that user.
So the fix on each platform is the same shape: **at device-connect time, cache
the USB device-name string; in the per-event scope, pass it as the
`HardwareDeviceIdentifier`.**
## Windows: Patch the GameInput Plugin
Location: `Engine/Plugins/Runtime/GameInput/Source/GameInputBase/`.
The Microsoft GameInput SDK already exposes the data we need on every device via
`GameInputDeviceInfo::displayName` (a `wchar_t*`). The plugin reads it for log
lines (`GameInputUtils.cpp:18-23`) but does not propagate it.
Steps:
1. **Cache at connect.** Add `FString CachedDisplayName` to
`FGameInputDeviceContainer`. In its constructor / device-info init path
(`GameInputDeviceContainer.cpp:44`, where `Info` is in scope), set:
```cpp
CachedDisplayName = FString(Info->displayName);
```
`TCHAR == wchar_t` on Windows, so the conversion is direct. Lifetime matches
the container — cleared automatically on disconnect.
2. **Make the container reachable from `FGameInputEventParams`.** The processor
already gets a `Device` pointer; add a `Container` pointer so
`GetHardwareDeviceIdentifierName` can read the cached string.
3. **Return it from `GetHardwareDeviceIdentifierName`**
(`GameInputDeviceProcessor.cpp:61`). After the existing
`bOverrideHardwareDeviceIdString` block, before the family-bucket switch:
```cpp
if (Params.Container && !Params.Container->CachedDisplayName.IsEmpty())
{
return Params.Container->CachedDisplayName;
}
```
The override path still wins (explicit dev intent); the family-bucket
fallback handles devices with empty `displayName`.
Total: ~10 lines plus the field. All inside `#if GAME_INPUT_SUPPORT`.
The user must enable the GameInput plugin and disable the default `XInputDevice`
plugin (otherwise both drivers will dispatch events for the same pad).
## Linux: Patch `LinuxApplication.cpp`
Location: `Engine/Source/Runtime/ApplicationCore/Private/Linux/LinuxApplication.cpp`.
SDL already provides the device name via `SDL_GameControllerName()`. The Linux
path calls it once for a log line in `AddGameController` (line 2175) and then
discards it. The Linux path also does not push an `FInputDeviceScope` at all
around its controller events — that's the second half of why
`GetMostRecentlyUsedHardwareDevice` is useless on Linux.
Steps:
1. **Store the name.** Add `FString DeviceName` to `SDLControllerState`. In
`AddGameController` (~line 2175), assign:
```cpp
ControllerState.DeviceName = UTF8_TO_TCHAR(SDL_GameControllerName(Controller));
```
2. **Wrap controller-event dispatch in a scope.** Around the
`MessageHandler->OnControllerButton*/OnControllerAnalog` block (starting
~line 518):
```cpp
FInputDeviceScope Scope(
nullptr,
FName("LinuxApplication"),
ControllerState.DeviceId.GetId(),
ControllerState.DeviceName);
// ... existing dispatch ...
```
Use `"LinuxApplication"` for `InputDeviceName` to mirror the Windows
convention.
Total: ~12 lines. No third-party dependencies, no engine plugins, no per-device
config tables.
## What This Does Not Solve
- **Stock XInput on Windows.** If we keep the default `XInputDevice` plugin,
events continue to come through `XInputInterface.cpp` with a fixed
`"XInputController"` identifier. XInput itself does not expose VID/PID or
device strings. Recovering the name there requires correlating the XInput
slot with a Raw Input HID via the `IG_xx` token in the Raw Input device path,
then calling `HidD_GetProductString` — a separate, more involved patch.
GameInput is the cleaner answer on Windows.
- **XInput-wrapped pads under GameInput.** A DualSense routed through Steam
Input or DS4Windows will surface as the wrapper's display name (e.g. ViGEm),
not as the underlying physical device. This is a property of the wrapper, not
fixable from our side.
- **`displayName` is not a stable identifier.** It is a human-readable name
whose exact text varies by OS version, driver, and USB descriptor. Use it for
prompt-set selection and logging. Do not use it as a save-game key.

View File

@@ -0,0 +1,311 @@
# Introduction
Unreal has several input mode-related subsystems that
interact with each other in complicated ways. These
subsystems include:
- keyboard focus
- mouse capture
- enhanced input routing
- pointer visibility
- window z-order
Unreal is littered with conditionals that cause these bits
of state to affect each other in unpredictable, often
illogical ways. If you set these bits of state in the wrong
order, or to the wrong values, it is all too easy to get
unreal into a non-functioning state. The system is *much*
too fragile.
For this reason, I have implemented a window management
system that orchestrates all of this from a centralized
location, in a way that guarantees reasonably predictable,
sane behavior.
# Core Design Choices
Our window management system, in order to keep things
simple, has to make some assumptions about how Luprex games
work. So, here are the rules.
Top-level UserWidgets get inserted into a "Root Canvas",
instead of into the viewport. The root canvas implements most
of the functionality of our window management system.
The keyboard focus rule is simple: the UserWidget in front
according to the z-order gets keyboard focus. The window
management system will put focus on the front widget and
will keep it there. The *only* way to give a UserWidget
keyboard focus is to raise it to the front of the z-order.
Mouse movements events are handled in two different ways:
the system can shift between "mouselook" mode and
"point-and-click" mode. Every top-level UserWidget declares
whether it wants a mouse pointer or not. If the front
UserWidget wants a pointer, the system shifts into
point-and-click mode.
In point-and-click mode, enhanced input mouse move events
cannot happen. In mouselook mode, widget OnMouseDown and
OnMouseMove events cannot happen. In both modes, you can
track mouse movement, but you have to use different
mechanisms.
Widgets that declare that they want a pointer are
automatically put in front of widgets that don't want a
pointer. Because of this rule, the system essentially
separates into the "mouselook" layer underneath, and the
"point-and-click" layer on top. When the point-and-click
layer gets out of the way, then you can drive the 3D world.
# State Variables of the Window Management System
I have made an effort to keep the number of state variables
that you have to control to an absolute minimum, and to
concentrate them all in one place. That place is the "Root
Canvas Slot."
Typically, in Unreal, when you create a new top-level
widget, you insert it into the viewport using AddToViewport.
But to use our window management system, you must instead
insert top-level widgets into a 'root canvas', using
AddWidgetToRoot.
The root canvas object associates a RootCanvasSlot to each
top-level widget. The RootCanvasSlot is a place where we can
store window management-related hints for that widget. The
contents of the RootCanvasSlot include the following:
- `ShowPointer`: If true, this is a point-and-click widget.
When this widget is in front, the pointer is visible,
and the system switches to point-and-click mode.
- `BlockInput`: If this window is in front, all enhanced
input events in *other* objects are blocked.
- `EnableEnhancedInput`: If false, enhanced input events in
*this* widget are disabled.
- `BringToFrontCount`: Effectively, a timestamp indicating
the last time this window was brought to the front. This
is the main factor determining the z-order of the widgets.
In addition, the top-level widget itself contains some
window-management related properties. Currently, these are:
- `DesiredFocusWidget`: Indicates which sub-widget, if any,
should be given focus. When the system grants focus to
the frontmost UserWidget, the focus actually goes here.
There are deliberately *no other variables* that control our
new window management system. If your blueprint is managing
these properties, then it is doing everything it needs to
do.
The function SetWidgetWindowManagement can set all of these
variables in a single operation. That one function is all
you need to control the entire window management system.
# Handling Keyboard and Gamepad Buttons
Here is a summary of how keyboard/gamepad button handling in
unreal works. We have tweaked this slightly, but this is
mostly just ordinary unreal input handling:
When you press a keyboard or gamepad button, the button
first goes to any widget that has keyboard focus. If that
widget doesn't declare the button to be "handled", then
button is offered to other widgets higher in the widget
heirarchy. If no widget handles the button, the button then
goes to the "enhanced input subsystem."
The enhanced input system puts the button through
an "input mapping context." Basically, that's a many-to-one
map that translates buttons into more abstract "enhanced
input events." Here's a fragment of a typical input
mapping context:
Key W --> IA_Move_Forward
Key S --> IA_Move_Backward
Left_Thumbstick_Forward --> IA_Move_Forward
Left_Thumbstick_Backward --> IA_Move_Backward
What the mapping context buys you is that you can handle
events like "IA_Move_Forward" without having to care
whether the player is driving with the WASD keys or with
the gamepad left thumbstick.
Typically, enhanced input events go to *all* of the
following: the player controller, the character, and
user-defined widgets. All of these consumers of enhanced
input are automatically registered to receive enhanced
input, which means that all they have to do is implement a
handler in their event graph, and they're ready. Other
actors can *also* receive enhanced input, but that requires
jumping through some hoops.
It's interesting that a widget can implement a handler for a
raw keyboard button, and then declare the button "not
handled". If the button proceeds to the enhanced input
system, and if the widget has a handler for enhanced input,
the widget can receive the same button again, in a
different form!
There is a priority order among consumers of enhanced input
events: user widgets first (front-to-back), then the player
controller, then the character. A consumer of enhanced
input has the option of blocking input to lower-priority
consumers.
This is all almost entirely unchanged from Unreal's default
behavior. We've only made two tiny tweaks: we send enhanced
input to widgets in front-to-back order, and, widgets
disable enhanced input by setting a flag instead of by
unregistering their input component. Other than that, this
is all just stock unreal.
# Handling mouse buttons
Mouse buttons behave differently than keyboard buttons.
Widgets have an OnMouseDown handler. This is only active in
point-and-click mode. OnMouseDown only fires when three
things are true: the system must be in point-and-click mode,
the pointer must be inside the rectangle of a widget, and
the widget must be marked hit-testable.
If no OnMouseDown event fires, or if OnMouseDown declares
the mouse down to be "not handled," then the mouse down
makes it to the enhanced input subsystem.
Once the mouse down reaches the enhanced input system, it
starts being treated the same as keyboard and gamepad
buttons. It can be mapped to an enhanced input event by the
input mapping context, and then from there, it can be
handled by any enhanced input event handler in a blueprint.
The upshot of all this is: if you want to think of a mouse
button as "just another button," then the way to achieve
that is to *not* write an OnMouseDown handler, but instead,
to deal with it through enhanced input.
We have tweaked the default behavior of unreal. If the
system is in point-and-click mode, and you click on a widget
that is hit-testable, but which has no OnMouseDown handler,
we provide a default OnMouseDown behavior: we bring the
top-level UserWidget to the front. Because our system grants
keyboard focus to the widget in front, this will also grant
focus.
# Handling Mouse Movement
In point-and-click mode, mouse movement moves the pointer
and doesn't generate any events at all.
There is one exception: mouse capture. If you click on a
hit-testable widget, that widget will "capture" the mouse
until you release the mouse button. As long as the widget
has capture, it receives OnMouseMove events. This is
mainly intended to implement click-and-drag, scroll
bar scrolling, and other movements like that.
Unreal has a *lot* of complicated mouse capture and mouse
lock options and modes. We don't support any of that. We
support only the basics: automatic capture when you click.
If you need more, we'll have to improve the Luprex window
management system.
In point-and-click mode, mouse movements do not go to the
enhanced input system at all.
When the system is in mouselook mode, mouse movements go
directly to the enhanced input system. They get mapped by
the input mapping context and turned into enhanced input
events. Handling these events is how mouselook works.
# Handling Analog Joysticks
Analog joysticks (including gamepad thumbsticks) generate
events that go directly to the enhanced input subsystem.
They get mapped to enhanced input events. From there,
they can be handled by any consumer of enhanced input.
# Functions you Should NOT CALL!
If you're using our Luprex window management system, there are
several things your blueprint should *NOT* do:
- DO NOT use SetKeyboardFocus, SetUserFocus, or any other
function with Set-Focus in the name. Instead, just
be aware that the frontmost UserWidget will get focus.
It can delegate that focus to one of its components by
setting DesiredFocusWidget.
- DO NOT use SetShowMouseCursor, or set the bShowMouseCursor
flag. Instead, set the ShowPointer flag in the
RootCanvasSlot of any top-level widget.
- DO NOT use UserWidget::RegisterInputComponent or
UserWidget::UnregisterInputComponent. These will be
ignored. Instead, set or unset the flag
EnableEnhancedInput in the RootCanvasSlot, which
effectively does the same thing.
- DO NOT use SetZOrder. If you try, you will be overridden
by our window management code. Currently, the only control
we're giving over window z-order is 'BringToFront'. If
you need more control, we'll have to enhance the window
management system.
- DO NOT use SetMouseCaptureMode, SetMouseLockMode,
SetHideCursorDuringCapture, CaptureMouse, ReleaseMouseCapture,
LockMouseToWidget, ReleaseMouseLock. We simply don't support
controlling mouse capture and mouse lock at this level of
granularity. Trying to use these will fight our window
management code. If you need this, we'll have to enhance the
window management system.
- DO NOT use AddToViewport or AddToPlayerScreen. Top level
UserWidgets should be inserted into the root canvas using
AddWidgetToRoot.
- DO NOT use SetIgnoreInput. You will be overridden. Our
window management system relies on the enhanced input
system being active, turning it off would cause everything
to fail. However, a widget can handle keyboard or
character events, causing them not to be propagated, it
can also block events to any widget lower in the z-order,
and to the player controller and character.
- DO NOT use SetInputModeXXX. Be aware that there is no
"input mode" enum or "input mode" variable anywhere in
Unreal. What these functions actually do is set a large
number of state variables - keyboard focus, mouse capture,
and so forth - from a single call. Naturally, then, these
will fight our window management system.
# Most *local* event-handling functions are allowed
There are many functions that gate or route events locally -
ie, within a single UserWidget, or within a single Actor.
Controlling and gating events within a single localized
entity does not create window-management confusion. Because
of that, all of these are still allowed:
- You CAN use EnableInput/DisableInput on actors, to turn
enhanced input events on/off for that actor.
- You CAN use PushInputComponent/PopInputComponent on the
player controller, if you want to register something that's
NOT a widget to receive enhanced input events. Seems
esoteric, but it still works.
- You CAN use methods of UUserWidget to bind or unbind
input events.
More broadly, functions that an actor or widget uses to
manipulate its *own* input component or input events
are no problem.

27
Docs/TASKS/Dictation.txt Normal file
View File

@@ -0,0 +1,27 @@
I need you to act as a secretary taking dictation. You will
be helping me to edit a markdown file.
I have a voice-to-speech program which is running in the background.
It records, and it sends my words to you. Most of what I say to you
will be meant as text to be put into the markdown file. But
occasionally, I will give you verbal instructions, for example:
"Reformat that bullet list into a numbered list"
It is your job to figure out intelligently which of the things I
say to you is meant as a directive, and which is meant as words to
go into the markdown file.
Do not edit the markdown file after every sentence. Instead,
quietly listen until you have a significant edit to make. I'd say,
roughly, when you have a paragraph, then it's time to make an edit.
I may occasionally commandeer the keyboard and edit the markdown
file myself. In those cases, you should notice that the file changed,
and read my changes.
It is also your job to make small corrections without comment.
If you see a really big mistake, stop and ask me what to do.

View File

@@ -1,19 +1,12 @@
* UE Wingman Function Overrides.
* Keyboard Event Handling
* Menus * Add a slash-command to reload lua source code.
* Skeletal Mesh Tangible * Skeletal Mesh Tangible
* Implement Interactive Temporary Variables * Implement Interactive Temporary Variables
* A better text console
* Get rid of 3x3 Gridpanel stuff
* Object-Oriented Lua Support * Object-Oriented Lua Support

View File

@@ -1,26 +1,3 @@
--- Engine/Extras/LLDBDataFormatters/UEDataFormatters_2ByteChars.py.orig 2026-04-12 22:58:33.989318455 -0400
+++ Engine/Extras/LLDBDataFormatters/UEDataFormatters_2ByteChars.py 2025-11-10 23:34:18.481538118 -0500
@@ -32,7 +32,7 @@
if DataVal == 0:
Val = 'NULL'
else:
- Expr = '(char16_t*)(%s)' % Data
+ Expr = '(char16_t*)(%s)' % DataVal
ValRef = valobj.CreateValueFromExpression('string', Expr)
Val = ValRef.GetSummary()
elif Type.IsReferenceType():
@@ -47,6 +47,11 @@
Expr = '(char16_t*)(%s)' % valobj.GetAddress()
ValRef = valobj.CreateValueFromExpression('string', Expr)
Val = ValRef.GetSummary()
+ else:
+ DataVal = valobj.GetValueAsUnsigned(0)
+ Expr = '(char16_t)(%s)' % DataVal
+ ValRef = valobj.CreateValueFromExpression('string', Expr)
+ Val = ValRef.GetSummary()
return Val
def UESignedCharSummaryProvider(valobj,dict):
--- Engine/Plugins/Developer/VisualStudioCodeSourceCodeAccess/Source/VisualStudioCodeSourceCodeAccess/Private/VisualStudioCodeSourceCodeAccessor.cpp.orig 2026-04-12 22:58:34.075320964 -0400 --- Engine/Plugins/Developer/VisualStudioCodeSourceCodeAccess/Source/VisualStudioCodeSourceCodeAccess/Private/VisualStudioCodeSourceCodeAccessor.cpp.orig 2026-04-12 22:58:34.075320964 -0400
+++ Engine/Plugins/Developer/VisualStudioCodeSourceCodeAccess/Source/VisualStudioCodeSourceCodeAccess/Private/VisualStudioCodeSourceCodeAccessor.cpp 2026-04-12 23:03:40.139226303 -0400 +++ Engine/Plugins/Developer/VisualStudioCodeSourceCodeAccess/Source/VisualStudioCodeSourceCodeAccess/Private/VisualStudioCodeSourceCodeAccessor.cpp 2026-04-12 23:03:40.139226303 -0400
@@ -149,7 +149,7 @@ @@ -149,7 +149,7 @@

View File

@@ -0,0 +1,286 @@
--- Engine/Plugins/Developer/VisualStudioCodeSourceCodeAccess/Source/VisualStudioCodeSourceCodeAccess/Private/VisualStudioCodeSourceCodeAccessor.cpp.orig 2026-05-08 14:11:02.262757499 -0400
+++ Engine/Plugins/Developer/VisualStudioCodeSourceCodeAccess/Source/VisualStudioCodeSourceCodeAccess/Private/VisualStudioCodeSourceCodeAccessor.cpp 2026-05-05 15:36:35.232152395 -0400
@@ -149,7 +149,7 @@
FString SolutionDir = GetSolutionPath();
TArray<FString> Args;
Args.Add(MakePath(SolutionDir));
- Args.Add(TEXT("-g ") + MakePath(FullPath) + FString::Printf(TEXT(":%d:%d"), LineNumber, ColumnNumber));
+ Args.Add(TEXT("-g ") + MakePath(FullPath + FString::Printf(TEXT(":%d:%d"), LineNumber, ColumnNumber)));
return Launch(Args);
}
--- Engine/Source/Editor/UnrealEd/Private/SourceCodeNavigation.cpp.orig 2026-05-08 14:11:02.370759731 -0400
+++ Engine/Source/Editor/UnrealEd/Private/SourceCodeNavigation.cpp 2026-05-05 15:36:35.232353795 -0400
@@ -557,7 +557,7 @@
ISourceCodeAccessModule& SourceCodeAccessModule = FModuleManager::LoadModuleChecked<ISourceCodeAccessModule>("SourceCodeAccess");
ISourceCodeAccessor& SourceCodeAccessor = SourceCodeAccessModule.GetAccessor();
-#if PLATFORM_WINDOWS
+#if PLATFORM_WINDOWS || PLATFORM_LINUX
FString SourceFileName;
uint32 SourceLineNumber = 1;
uint32 SourceColumnNumber = 0;
@@ -716,8 +716,8 @@
}
UE_LOG(LogSelectionDetails, Warning, TEXT("NavigateToFunctionSource: Unable to look up symbol: %s in module:%s"), *FunctionSymbolName, *FunctionModuleName);
-
-#endif // PLATFORM_WINDOWS
+
+#endif // PLATFORM_WINDOWS || PLATFORM_LINUX
}
--- Engine/Source/Runtime/ApplicationCore/Private/Linux/LinuxPlatformApplicationMisc.cpp.orig 2026-05-08 14:11:02.477761942 -0400
+++ Engine/Source/Runtime/ApplicationCore/Private/Linux/LinuxPlatformApplicationMisc.cpp 2026-05-05 15:36:35.232635087 -0400
@@ -317,6 +317,9 @@
// Furthermore SDL hides the mouse which we prevent by setting SDL_HINT_MOUSE_RELATIVE_CURSOR_VISIBLE
SDL_SetHint(SDL_HINT_MOUSE_RELATIVE_CURSOR_VISIBLE, "1"); // When relative mouse mode is active, don't hide cursor.
+ // Unreal does its own dynamic capturing, we don't need SDL to do it.
+ SDL_SetHint(SDL_HINT_MOUSE_AUTO_CAPTURE, "0");
+
// If we're rendering offscreen, use the "dummy" SDL video driver
if (FParse::Param(FCommandLine::Get(), TEXT("RenderOffScreen")) && !getenv("SDL_VIDEODRIVER"))
{
--- Engine/Source/Runtime/Core/Private/Unix/UnixPlatformStackWalk.cpp.orig 2026-05-08 14:11:02.584764153 -0400
+++ Engine/Source/Runtime/Core/Private/Unix/UnixPlatformStackWalk.cpp 2026-05-05 15:36:35.232750204 -0400
@@ -15,6 +15,7 @@
#include "HAL/ExceptionHandling.h"
#include "HAL/PlatformProcess.h"
#include "HAL/PlatformTime.h"
+#include "Modules/ModuleManager.h"
#include "AutoRTFM.h"
#include <link.h>
@@ -1063,3 +1064,69 @@
}
ReportLock.Unlock();
}
+
+bool FUnixPlatformStackWalk::GetFunctionDefinitionLocation(const FString& FunctionSymbolName, const FString& FunctionModuleName, FString& OutPathname, uint32& OutLineNumber, uint32& OutColumnNumber)
+{
+ // Find the .so path for this module.
+ FString ModulePath;
+ TArray<FModuleStatus> AllModules;
+ FModuleManager::Get().QueryModules(AllModules);
+ for (const FModuleStatus& Status : AllModules)
+ {
+ if (FPaths::GetBaseFilename(Status.FilePath) == FunctionModuleName)
+ {
+ ModulePath = Status.FilePath;
+ break;
+ }
+ }
+ if (ModulePath.IsEmpty())
+ {
+ return false;
+ }
+
+ // Debug symbols are in a separate .debug file alongside the .so.
+ FString DebugPath = FPaths::ChangeExtension(ModulePath, TEXT("debug"));
+ if (!FPaths::FileExists(DebugPath))
+ {
+ return false;
+ }
+
+ // Use lldb to look up the source file and line number.
+ // Run: lldb -b -o "image lookup -v -n ClassName::FuncName" <debug_file>
+ FString LldbParams = FString::Printf(TEXT("-b -o \"image lookup -v -n %s\" \"%s\""), *FunctionSymbolName, *DebugPath);
+ int32 ReturnCode = 0;
+ FString AllOutput;
+ FString Errors;
+ FPlatformProcess::ExecProcess(TEXT("/usr/bin/lldb"), *LldbParams, &ReturnCode, &AllOutput, &Errors);
+ if (ReturnCode != 0)
+ {
+ return false;
+ }
+
+ // Parse the LineEntry from lldb verbose output.
+ // Format: "LineEntry: [0x...-0x...): /path/to/file.cpp:132"
+ TArray<FString> Lines;
+ AllOutput.ParseIntoArrayLines(Lines);
+ for (const FString& Line : Lines)
+ {
+ FString Trimmed = Line.TrimStartAndEnd();
+ if (!Trimmed.StartsWith(TEXT("LineEntry:")))
+ continue;
+
+ int32 ParenIndex = Trimmed.Find(TEXT("): "));
+ if (ParenIndex == INDEX_NONE)
+ continue;
+ FString FileAndLine = Trimmed.Mid(ParenIndex + 3);
+
+ int32 ColonIndex;
+ if (!FileAndLine.FindLastChar(TCHAR(':'), ColonIndex))
+ continue;
+
+ OutPathname = FileAndLine.Left(ColonIndex);
+ OutLineNumber = FCString::Atoi(*FileAndLine.Mid(ColonIndex + 1));
+ OutColumnNumber = 0;
+ return true;
+ }
+
+ return false;
+}
--- Engine/Source/Runtime/Core/Public/Unix/UnixPlatformStackWalk.h.orig 2026-05-08 14:11:02.692766385 -0400
+++ Engine/Source/Runtime/Core/Public/Unix/UnixPlatformStackWalk.h 2026-05-05 15:36:35.232861435 -0400
@@ -24,6 +24,8 @@
static CORE_API void ThreadStackWalkAndDump(ANSICHAR* HumanReadableString, SIZE_T HumanReadableStringSize, int32 IgnoreCount, uint32 ThreadId);
static CORE_API int32 GetProcessModuleCount();
static CORE_API int32 GetProcessModuleSignatures(FStackWalkModuleInfo *ModuleSignatures, const int32 ModuleSignaturesSize);
+
+ static CORE_API bool GetFunctionDefinitionLocation(const FString& FunctionSymbolName, const FString& FunctionModuleName, FString& OutPathname, uint32& OutLineNumber, uint32& OutColumnNumber);
};
typedef FUnixPlatformStackWalk FPlatformStackWalk;
--- Engine/Source/Runtime/ApplicationCore/Public/Linux/LinuxApplication.h.orig 2026-05-08 14:11:02.802768658 -0400
+++ Engine/Source/Runtime/ApplicationCore/Public/Linux/LinuxApplication.h 2026-05-08 14:09:14.778161399 -0400
@@ -265,6 +265,12 @@
/** The input device Id of the controller that can be used to find the matching ULocalPlayer */
FInputDeviceId DeviceId;
+ /** SDL gamepad type string (e.g. "ps4", "xboxone"), used as HardwareDeviceIdentifier in FInputDeviceScope */
+ FName GamepadType;
+
+ /** SDL gamepad name (human-readable device name), used as InputDeviceName in FInputDeviceScope */
+ FName GamepadName;
+
/** Store axis values from events here to be handled once per frame. */
TMap<FGamepadKeyNames::Type, float> AxisEvents;
--- Engine/Source/Runtime/ApplicationCore/Private/Linux/LinuxApplication.cpp.orig 2026-05-08 14:11:02.910770890 -0400
+++ Engine/Source/Runtime/ApplicationCore/Private/Linux/LinuxApplication.cpp 2026-05-08 14:09:14.778345206 -0400
@@ -13,6 +13,13 @@
#include "IHapticDevice.h"
#include "GenericPlatform/GenericPlatformInputDeviceMapper.h"
+namespace UE::LinuxInput
+{
+ static const FName InputClassName = TEXT("LinuxApplication");
+ static const FString KBMInputHardwareName = TEXT("KBM");
+ static const FString TouchInputHardwareName = TEXT("MobileTouch");
+}
+
//
// GameController thresholds
//
@@ -320,6 +327,7 @@
{
case SDL_EVENT_KEY_DOWN:
{
+ FInputDeviceScope InputScope(nullptr, UE::LinuxInput::InputClassName, IPlatformInputDeviceMapper::Get().GetDefaultInputDevice().GetId(), UE::LinuxInput::KBMInputHardwareName);
const SDL_KeyboardEvent &KeyEvent = Event.key;
SDL_Keycode KeySym = KeyEvent.key;
const uint32 CharCode = CharCodeFromSDLKeySym(KeySym);
@@ -342,6 +350,7 @@
break;
case SDL_EVENT_KEY_UP:
{
+ FInputDeviceScope InputScope(nullptr, UE::LinuxInput::InputClassName, IPlatformInputDeviceMapper::Get().GetDefaultInputDevice().GetId(), UE::LinuxInput::KBMInputHardwareName);
const SDL_KeyboardEvent &KeyEvent = Event.key;
const SDL_Keycode KeySym = KeyEvent.key;
const uint32 CharCode = CharCodeFromSDLKeySym(KeySym);
@@ -352,6 +361,7 @@
break;
case SDL_EVENT_TEXT_INPUT:
{
+ FInputDeviceScope InputScope(nullptr, UE::LinuxInput::InputClassName, IPlatformInputDeviceMapper::Get().GetDefaultInputDevice().GetId(), UE::LinuxInput::KBMInputHardwareName);
// Slate now gets all its text from here, I hope.
const bool bIsRepeated = false; //Event.key.repeat != 0;
const FString TextStr(UTF8_TO_TCHAR(Event.text.text));
@@ -363,6 +373,7 @@
break;
case SDL_EVENT_MOUSE_MOTION:
{
+ FInputDeviceScope InputScope(nullptr, UE::LinuxInput::InputClassName, IPlatformInputDeviceMapper::Get().GetDefaultInputDevice().GetId(), UE::LinuxInput::KBMInputHardwareName);
SDL_MouseMotionEvent motionEvent = Event.motion;
FLinuxCursor *LinuxCursor = (FLinuxCursor*)Cursor.Get();
LinuxCursor->InvalidateCaches();
@@ -406,6 +417,7 @@
case SDL_EVENT_MOUSE_BUTTON_DOWN:
case SDL_EVENT_MOUSE_BUTTON_UP:
{
+ FInputDeviceScope InputScope(nullptr, UE::LinuxInput::InputClassName, IPlatformInputDeviceMapper::Get().GetDefaultInputDevice().GetId(), UE::LinuxInput::KBMInputHardwareName);
SDL_MouseButtonEvent buttonEvent = Event.button;
EMouseButtons::Type button;
@@ -484,6 +496,7 @@
break;
case SDL_EVENT_MOUSE_WHEEL:
{
+ FInputDeviceScope InputScope(nullptr, UE::LinuxInput::InputClassName, IPlatformInputDeviceMapper::Get().GetDefaultInputDevice().GetId(), UE::LinuxInput::KBMInputHardwareName);
SDL_MouseWheelEvent *WheelEvent = &Event.wheel;
float Amount = (float)WheelEvent->y * fMouseWheelScrollAccel;
@@ -515,6 +528,7 @@
SDLControllerState &ControllerState = ControllerStates[caxisEvent.which];
FPlatformUserId UserId = IPlatformInputDeviceMapper::Get().GetUserForInputDevice(ControllerState.DeviceId);
+ FInputDeviceScope InputScope(nullptr, ControllerState.GamepadName, ControllerState.DeviceId.GetId(), ControllerState.GamepadType.ToString());
switch (caxisEvent.axis)
{
@@ -740,15 +754,17 @@
if (Button != FGamepadKeyNames::Invalid)
{
- FPlatformUserId UserId = IPlatformInputDeviceMapper::Get().GetUserForInputDevice(ControllerStates[cbuttonEvent.which].DeviceId);
+ SDLControllerState& ButtonControllerState = ControllerStates[cbuttonEvent.which];
+ FPlatformUserId UserId = IPlatformInputDeviceMapper::Get().GetUserForInputDevice(ButtonControllerState.DeviceId);
+ FInputDeviceScope InputScope(nullptr, ButtonControllerState.GamepadName, ButtonControllerState.DeviceId.GetId(), ButtonControllerState.GamepadType.ToString());
if(cbuttonEvent.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN)
{
- MessageHandler->OnControllerButtonPressed(Button, UserId, ControllerStates[cbuttonEvent.which].DeviceId, false);
+ MessageHandler->OnControllerButtonPressed(Button, UserId, ButtonControllerState.DeviceId, false);
}
else
{
- MessageHandler->OnControllerButtonReleased(Button, UserId, ControllerStates[cbuttonEvent.which].DeviceId, false);
+ MessageHandler->OnControllerButtonReleased(Button, UserId, ButtonControllerState.DeviceId, false);
}
}
}
@@ -1018,6 +1034,7 @@
case SDL_EVENT_FINGER_DOWN:
{
+ FInputDeviceScope InputScope(nullptr, UE::LinuxInput::InputClassName, IPlatformInputDeviceMapper::Get().GetDefaultInputDevice().GetId(), UE::LinuxInput::TouchInputHardwareName);
UE_LOG(LogLinuxWindow, Verbose, TEXT("Finger %llu is down at (%f, %f)"), Event.tfinger.fingerID, Event.tfinger.x, Event.tfinger.y);
// touch events can have no window associated with them, in that case ignore (with a warning)
@@ -1053,6 +1070,7 @@
break;
case SDL_EVENT_FINGER_UP:
{
+ FInputDeviceScope InputScope(nullptr, UE::LinuxInput::InputClassName, IPlatformInputDeviceMapper::Get().GetDefaultInputDevice().GetId(), UE::LinuxInput::TouchInputHardwareName);
UE_LOG(LogLinuxWindow, Verbose, TEXT("Finger %llu is up at (%f, %f)"), Event.tfinger.fingerID, Event.tfinger.x, Event.tfinger.y);
// touch events can have no window associated with them, in that case ignore (with a warning)
@@ -1087,6 +1105,7 @@
break;
case SDL_EVENT_FINGER_MOTION:
{
+ FInputDeviceScope InputScope(nullptr, UE::LinuxInput::InputClassName, IPlatformInputDeviceMapper::Get().GetDefaultInputDevice().GetId(), UE::LinuxInput::TouchInputHardwareName);
// touch events can have no window associated with them, in that case ignore (with a warning)
if (LIKELY(!bWindowlessEvent))
{
@@ -1196,6 +1215,7 @@
IPlatformInputDeviceMapper& Mapper = IPlatformInputDeviceMapper::Get();
for(auto ControllerIt = ControllerStates.CreateIterator(); ControllerIt; ++ControllerIt)
{
+ FInputDeviceScope InputScope(nullptr, ControllerIt.Value().GamepadName, ControllerIt.Value().DeviceId.GetId(), ControllerIt.Value().GamepadType.ToString());
for(auto Event = ControllerIt.Value().AxisEvents.CreateConstIterator(); Event; ++Event)
{
FPlatformUserId UserId = Mapper.GetUserForInputDevice(ControllerIt.Value().DeviceId);
@@ -2075,6 +2095,9 @@
UE_LOG(LogLinux, Verbose, TEXT("Adding controller %i '%s'"), FirstUnusedIndex, UTF8_TO_TCHAR(SDL_GetGamepadName(Controller)));
auto& ControllerState = ControllerStates.Add(Id);
ControllerState.Controller = Controller;
+ ControllerState.GamepadName = FName(UTF8_TO_TCHAR(SDL_GetGamepadName(Controller)));
+ const char* GamepadTypeStr = SDL_GetGamepadStringForType(SDL_GetGamepadType(Controller));
+ ControllerState.GamepadType = FName(GamepadTypeStr ? UTF8_TO_TCHAR(GamepadTypeStr) : TEXT("unknown"));
FPlatformUserId UserId = FPlatformUserId::CreateFromInternalId(FirstUnusedIndex);
DeviceMapper.RemapControllerIdToPlatformUserAndDevice(FirstUnusedIndex, UserId, ControllerState.DeviceId);

View File

@@ -0,0 +1,47 @@
# Old Patches
Patches that were once part of `EnginePatch` but are no longer needed.
Kept here for reference in case the underlying issue resurfaces.
## LinuxWindow.cpp — force override_redirect for borderless child windows
Removed when the engine was upgraded to UE 5.7, which uses SDL3. SDL3 has
better Wayland support, so this workaround is believed to be unnecessary.
The original purpose was to make Unreal play better with Wayland: under
XWayland, non-override-redirect popup windows don't receive input events
from the compositor. SDL already sets override_redirect for tooltips and
popup menus, but other borderless child windows (notification popups,
dialogs) also needed it, so we temporarily enabled
`SDL_HINT_X11_FORCE_OVERRIDE_REDIRECT` around the `SDL_CreateWindow` call.
```diff
--- Engine/Source/Runtime/ApplicationCore/Private/Linux/LinuxWindow.cpp.orig 2026-04-12 22:58:34.538334467 -0400
+++ Engine/Source/Runtime/ApplicationCore/Private/Linux/LinuxWindow.cpp 2026-04-12 22:48:15.848291098 -0400
@@ -235,7 +235,26 @@
// The SDL window doesn't need to be reshaped.
// the size of the window you input is the sizeof the client.
+
+ // Under XWayland, non-override-redirect popup windows don't receive input
+ // events from the compositor. SDL already sets override_redirect for
+ // tooltips and popup menus, but other borderless child windows (like
+ // notification popups and dialogs) also need it. We temporarily enable
+ // the SDL hint to force override_redirect for these windows.
+ bool bForceOverrideRedirect = !Definition->HasOSWindowBorder
+ && InParent.IsValid()
+ && !(WindowStyle & (SDL_WINDOW_TOOLTIP | SDL_WINDOW_POPUP_MENU));
+ if (bForceOverrideRedirect)
+ {
+ SDL_SetHint(SDL_HINT_X11_FORCE_OVERRIDE_REDIRECT, "1");
+ }
+
HWnd = SDL_CreateWindow( TCHAR_TO_ANSI( *Definition->Title ), X, Y, ClientWidth, ClientHeight, WindowStyle );
+
+ if (bForceOverrideRedirect)
+ {
+ SDL_SetHint(SDL_HINT_X11_FORCE_OVERRIDE_REDIRECT, "0");
+ }
// produce a helpful message for common driver errors
if (HWnd == nullptr)
{
```

View File

@@ -28,7 +28,8 @@
"files.watcherExclude": { "files.watcherExclude": {
"[UNREALENGINE]/Engine/**": true, "[UNREALENGINE]/Engine/**": true,
"[UNREALENGINE]/Samples/**": true, "[UNREALENGINE]/Samples/**": true,
"[UNREALENGINE]/Templates/**": true "[UNREALENGINE]/Templates/**": true,
"**/.*": true
}, },
"files.associations": { "files.associations": {
"**/include/**": "cpp", "**/include/**": "cpp",
@@ -40,7 +41,7 @@
}, },
"editor.acceptSuggestionOnEnter": "off", "editor.acceptSuggestionOnEnter": "off",
"C_Cpp.intelliSenseEngine": "disabled", "C_Cpp.intelliSenseEngine": "disabled",
"clangd.path": "/usr/bin/clangd-15", "clangd.path": "/usr/bin/clangd-16",
"clangd.arguments": [ "clangd.arguments": [
"--log=verbose", "--log=verbose",
"--query-driver=/usr/bin/g++", "--query-driver=/usr/bin/g++",
@@ -50,6 +51,9 @@
], ],
"C_Cpp.autocomplete": "disabled", "C_Cpp.autocomplete": "disabled",
"search.useIgnoreFiles": true, "search.useIgnoreFiles": true,
"files.readonlyInclude": {
"[UNREALENGINE]/**": true
},
"search.exclude": { "search.exclude": {
"**/Intermediate": true, "**/Intermediate": true,
"**/Saved": true, "**/Saved": true,
@@ -123,10 +127,11 @@
"type": "lldb", "type": "lldb",
"console": "integratedTerminal", "console": "integratedTerminal",
"initCommands": [ "initCommands": [
"command script import [UNREALENGINE]/Engine/Extras/LLDBDataFormatters/UEDataFormatters_2ByteChars.py", "command script import [INTEGRATION]/tools/UEDataFormatter.py",
"settings set target.inline-breakpoint-strategy always", "settings set target.inline-breakpoint-strategy always",
"settings set target.prefer-dynamic-value no-run-target",
"process handle SIGTRAP --notify false --pass false --stop false", "process handle SIGTRAP --notify false --pass false --stop false",
"target stop-hook add --one-liner \"p ::UngrabAllInputImpl()\"" "target stop-hook add --one-liner \"p FUnixPlatformMisc::UngrabAllInput()\""
] ]
} }
}, },
@@ -146,8 +151,10 @@
"type": "lldb", "type": "lldb",
"console": "integratedTerminal", "console": "integratedTerminal",
"initCommands": [ "initCommands": [
"command script import [UNREALENGINE]/Engine/Extras/LLDBDataFormatters/UEDataFormatters_2ByteChars.py", "command script import [INTEGRATION]/tools/UEDataFormatter.py",
"settings set target.inline-breakpoint-strategy always" "settings set target.inline-breakpoint-strategy always",
"settings set target.prefer-dynamic-value no-run-target",
"process handle SIGTRAP --notify false --pass false --stop false"
] ]
}, },
{ {
@@ -160,7 +167,9 @@
"type": "lldb", "type": "lldb",
"console": "integratedTerminal", "console": "integratedTerminal",
"initCommands": [ "initCommands": [
"settings set target.inline-breakpoint-strategy always" "settings set target.inline-breakpoint-strategy always",
"settings set target.prefer-dynamic-value no-run-target",
"process handle SIGTRAP --notify false --pass false --stop false"
] ]
} }
] ]

View File

@@ -16,14 +16,12 @@ blueprints, widget blueprints, and materials.
## How Does it Work? ## How Does it Work?
This tool adds a command interpreter plugin to the Unreal This tool adds a command line interpreter plugin to the Unreal
Editor. You can type commands, and the plugin in the editor Editor. You can type commands, and the plugin in the editor
will execute them. You can actually type commands directly will execute them.
from the command-line. Here's an example of what you might
see:
``` ```
$ ue-wingman.py Graph_Dump Graph=/Game/Testing/BP_Test,graph:EventGraph $ ue-wingman Graph_Dump /Game/Testing/BP_Test,graph:EventGraph
node K2Node_Event_0: Event BeginPlay node K2Node_Event_0: Event BeginPlay
output-pins OutputDelegate output-pins OutputDelegate
@@ -32,33 +30,30 @@ see:
output-pins OutputDelegate, DeltaSeconds output-pins OutputDelegate, DeltaSeconds
``` ```
There are tons of commands built in: Graph_Dump, The ue-wingman command has tons of subcommands: Graph_Dump,
GraphNode_Add, GraphPin_Connect, GraphNode_Add, GraphPin_Connect, BlueprintComponent_Add,
BlueprintComponent_Add, Widget_Add, and so forth. Widget_Add, and so forth. Using these commands, it's
Using these commands, it's possible to examine and modify possible to examine and modify blueprints, widgets, and
blueprints, widgets, and materials. materials.
But, of course, these commands aren't really intended for humans. But, of course, these commands aren't really intended for humans.
They're intended for an AI agent. The AI is given access to these They're intended for an AI agent.
commands using what's called "Model Context Protocol," which is
a goofy name for "a mechanism that an AI can use to send
commands to other software."
## Why Choose this Particular Unreal Engine MCP? ## Why Choose this Particular Unreal AI Plugin?
There are a *lot* of Unreal Engine MCPs out there. Some of There are a *lot* of Unreal Engine AI plugins out there. Some of
them are, shall we say, not carefully engineered. I'm a them are, shall we say, not carefully engineered. I'm a
reasonably skilled software engineer and I've designed reasonably skilled software engineer and I've designed
this plugin to be robust and capable of sustained development. this plugin to be robust and capable of sustained development.
This MCP is also designed to be as broadly general as This plugin is also designed to be as broadly general as
possible. I've seen MCPs that claim "can create 22 different possible. I've seen plugins that claim "can create 22 different
kinds of graph nodes!" This makes me ask: why not just kinds of graph nodes!" This makes me ask: why not just
provide the *entire catalog* of all possible graph nodes? provide the *entire catalog* of all possible graph nodes?
I've seen MCPs claim "you can edit 15 different material I've seen plugins claim "you can edit 15 different material
expression properties!" Why not provide access to *all* expression properties!" Why not provide access to *all*
editable material expression properties? I've tried to make editable material expression properties? I've tried to make
every tool in this MCP as capable as possible, with as few every tool in this plugin as capable as possible, with as few
limits as possible. limits as possible.
Some of the MCPs out there expose the entire Unreal API to Some of the MCPs out there expose the entire Unreal API to
@@ -76,15 +71,16 @@ commands.
## Installation ## Installation
There are three parts to UE Wingman: There are two parts to UE Wingman:
* The Unreal Plugin, which does 99% of the work. * The Unreal Plugin, which does 99% of the work.
* The python program "ue-wingman.py" which a human can * The python program "ue-wingman.py"
use to send commands to the plugin.
The python program is actually less than 100 lines of code:
* The python program "ue-wingman-mcp.py", which an AI all it does is package up its command line arguments, send
can use to send commands to the plugin. them to the plugin, and let the plugin do the work. Then it
prints the output.
If you build Unreal from source, the best way to install the If you build Unreal from source, the best way to install the
plugin is to drop the entire UEWingman source folder into plugin is to drop the entire UEWingman source folder into
@@ -106,25 +102,6 @@ and no other dependencies.
To install the human version, ue-wingman.py, just drop it into To install the human version, ue-wingman.py, just drop it into
a folder on your PATH. a folder on your PATH.
To install the AI version, ue-wingman-mcp.py, you have to
usually set up some config file for your AI agent. I use
Claude Code, for that, you have to create a file ".mcp.json"
in your project folder, and it needs to have this inside it:
```
{
"mcpServers": {
"ue-wingman": {
"command": "python3",
"args": ["Plugins/UEWingman/ue-wingman-mcp.py"]
}
}
}
```
You can usually ask your AI agent for help creating this
config file.
## The "User Manual" ## The "User Manual"
You might be interested in seeing the "user manual" for the You might be interested in seeing the "user manual" for the
@@ -135,18 +112,17 @@ $ ue-wingman.py Documentation_Manual
``` ```
Of course, you're not the intended user: your AI agent is. Of course, you're not the intended user: your AI agent is.
When the AI agent starts up ue-wingman-mcp, it is You should put a note into your agent's system prompt to
automatically told to read the user manual. From there, the let it know about the ue-wingman.py command, and to let
User Manual says, among other things, that the AI agent can it know that it can type ue-wingman.py Documentation_Manual.
get a listing of built-in commands. You can see that too: This in turn will tell your agent about this command:
``` ```
$ ue-wingman.py Documentation_Commands $ ue-wingman.py Documentation_Commands
``` ```
With these two commands at your disposal, you'll have a better Using these commands, you can learn more about what this
understanding of what exactly your AI agent is doing with this plugin can do.
plugin, and how it all works.
## Fun things to Try ## Fun things to Try

View File

@@ -25,7 +25,7 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Asset to delete")) UPROPERTY(EditAnywhere, meta=(Description="Asset to delete"))
FString Asset; FString Asset;
UPROPERTY(EditAnywhere, meta=(Optional, Description="If true, skip reference check and force delete")) UPROPERTY(EditAnywhere, meta=(Description="If true, skip reference check and force delete"))
bool Force = false; bool Force = false;
virtual void Register() override virtual void Register() override

View File

@@ -21,13 +21,13 @@ class UWing_Asset_Search : public UWingHandler
GENERATED_BODY() GENERATED_BODY()
public: public:
UPROPERTY(EditAnywhere, meta=(Optional, Description="Substring to match against asset package paths")) UPROPERTY(EditAnywhere, meta=(Description="Substring to match against asset package paths"))
FString Query; FString Query;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Asset class name to filter by, e.g. Blueprint, Material, StaticMesh")) UPROPERTY(EditAnywhere, meta=(Description="Asset class name to filter by, e.g. Blueprint, Material, StaticMesh"))
FString Type; FString Type;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Maximum number of results (default 50)")) UPROPERTY(EditAnywhere, meta=(Description="Maximum number of results (default 50)"))
int32 Limit = 50; int32 Limit = 50;
virtual void Register() override virtual void Register() override

View File

@@ -32,16 +32,15 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Type of graph: function or macro")) UPROPERTY(EditAnywhere, meta=(Description="Type of graph: function or macro"))
FString GraphType; FString GraphType;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Input variables, one per line")) UPROPERTY(EditAnywhere, meta=(Description="Variables"))
FString InputVariables; FWingRestOfArgv Variables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Output variables, one per line"))
FString OutputVariables;
virtual void Register() override virtual void Register() override
{ {
UWingServer::AddHandler(this, UWingServer::AddHandler(this,
TEXT("Create a new function or macro graph in a Blueprint.")); TEXT("Create a new function or macro graph in a Blueprint. "
"Variables must be expressed as 'kind type name (flags) = default'. "
"Kind can be input, output, or local."));
} }
virtual void Handle() override virtual void Handle() override
{ {
@@ -79,8 +78,7 @@ public:
// Parse and validate variables before making changes // Parse and validate variables before making changes
WingVariables Vars; WingVariables Vars;
if (!Vars.InputVariables.ParseString(InputVariables, WingOut::Stdout)) return; if (!Vars.Parse(Variables.Argv, false, WingOut::Stdout)) return;
if (!Vars.OutputVariables.ParseString(OutputVariables, WingOut::Stdout)) return;
// Create the Graph // Create the Graph
UEdGraph* NewGraph = FBlueprintEditorUtils::CreateNewGraph(BP, InternalID, UEdGraph* NewGraph = FBlueprintEditorUtils::CreateNewGraph(BP, InternalID,

View File

@@ -27,7 +27,7 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Interface name to remove")) UPROPERTY(EditAnywhere, meta=(Description="Interface name to remove"))
FString Interface; FString Interface;
UPROPERTY(EditAnywhere, meta=(Optional, Description="If true, keep the function graphs as regular functions")) UPROPERTY(EditAnywhere, meta=(Description="If true, keep the function graphs as regular functions"))
bool PreserveFunctions = false; bool PreserveFunctions = false;
virtual void Register() override virtual void Register() override

View File

@@ -281,7 +281,7 @@ public:
} }
// Set the 'Class' property. // Set the 'Class' property.
TArray<FWingProperty> Props = FWingProperty::GetVisible(Factory); TArray<FWingProperty> Props = FWingProperty::GetVisible(Factory, true);
FWingProperty::Remove(Props, TEXT("BlueprintType")); FWingProperty::Remove(Props, TEXT("BlueprintType"));
if (Props.Num() != 1) if (Props.Num() != 1)
{ {

View File

@@ -37,5 +37,6 @@ public:
if (!P) return; if (!P) return;
WingOut::Stdout.Print(P->GetText()); WingOut::Stdout.Print(P->GetText());
WingOut::Stdout.Print(TEXT("\n"));
} }
}; };

View File

@@ -1,60 +0,0 @@
#pragma once
#include "CoreMinimal.h"
#include "WingServer.h"
#include "WingBasics.h"
#include "WingFetcher.h"
#include "WingProperty.h"
#include "WingUtils.h"
#include "Details_SetMany.generated.h"
UCLASS()
class UWing_Details_SetMany : public UWingHandler
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, meta=(Description="Target object"))
FString Object;
UPROPERTY(EditAnywhere, meta=(Description="Object mapping property names to new values in Unreal text format"))
FWingJsonObject Properties;
virtual void Register() override
{
UWingServer::AddHandler(this,
TEXT("Set one or more editable properties. Values use Unreal text format."));
}
virtual void Handle() override
{
WingFetcher F(WingOut::Stdout);
UObject* Obj = F.Walk(Object).Cast<UObject>();
if (!Obj) return;
if (!Properties.Json || Properties.Json->Values.Num() == 0)
{
WingOut::Stdout.Print(TEXT("Error: No properties specified\n"));
return;
}
TArray<FWingProperty> Props = FWingProperty::GetDetails(Obj, true);
// Validation pass — resolve all properties before modifying anything.
for (const auto& Pair : Properties.Json->Values)
{
FWingProperty* P = WingUtils::FindOneWithExternalID(Pair.Key, Props, TEXT("Property"), WingOut::Stdout);
if (!P) return;
}
// Assignment pass — store the values.
int SuccessCount = 0;
for (const auto& Pair : Properties.Json->Values)
{
FWingProperty* P = WingUtils::FindOneWithExternalID(Pair.Key, Props, TEXT("Property"), WingOut::Stdout);
if (P->SetJson(*Pair.Value, WingOut::Stdout)) SuccessCount++;
}
WingOut::Stdout.Printf(TEXT("Set %d/%d properties.\n"), SuccessCount, Properties.Json->Values.Num());
}
};

View File

@@ -0,0 +1,27 @@
#pragma once
#include "CoreMinimal.h"
#include "WingBasics.h"
#include "WingServer.h"
#include "WingManual.h"
#include "Documentation_Command.generated.h"
UCLASS()
class UWing_Documentation_Command : public UWingHandler
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, meta=(Description="Substring filter for command names"))
FString Command;
virtual void Register() override
{
UWingServer::AddHandler(this,
TEXT("Detailed documentation for one or more commands."));
}
virtual void Handle() override
{
WingManual::Commands(EWingHandlerKind::Normal, Command, true);
}
};

View File

@@ -12,19 +12,13 @@ class UWing_Documentation_Commands : public UWingHandler
GENERATED_BODY() GENERATED_BODY()
public: public:
UPROPERTY(EditAnywhere, meta=(Optional, Description="Substring filter for command names"))
FString Query;
UPROPERTY(EditAnywhere, meta=(Optional, Description="If true, return full details including parameter types and descriptions"))
bool Verbose = false;
virtual void Register() override virtual void Register() override
{ {
UWingServer::AddHandler(this, UWingServer::AddHandler(this,
TEXT("List all the main commands with their descriptions.")); TEXT("A concise list of all ue-wingman commands."));
} }
virtual void Handle() override virtual void Handle() override
{ {
WingManual::Commands(EWingHandlerKind::Normal, Query, Verbose); WingManual::Commands(EWingHandlerKind::Normal, TEXT(""), false);
} }
}; };

View File

@@ -12,10 +12,10 @@ class UWing_Documentation_CreateAssets : public UWingHandler
GENERATED_BODY() GENERATED_BODY()
public: public:
UPROPERTY(EditAnywhere, meta=(Optional, Description="Substring filter for command names")) UPROPERTY(EditAnywhere, meta=(Description="Substring filter for command names"))
FString Query; FString Query;
UPROPERTY(EditAnywhere, meta=(Optional, Description="If true, return full details including parameter types and descriptions")) UPROPERTY(EditAnywhere, meta=(Description="If true, return full details including parameter types and descriptions"))
bool Verbose = false; bool Verbose = false;
virtual void Register() override virtual void Register() override

View File

@@ -12,41 +12,21 @@ class UWing_Documentation_Manual : public UWingHandler
GENERATED_BODY() GENERATED_BODY()
public: public:
UPROPERTY(EditAnywhere, meta=(Optional, Description="section of the manual"))
FString Section;
virtual void Register() override virtual void Register() override
{ {
TStringBuilder<128> Docs; UWingServer::AddHandler(this, TEXT("Print the entire manual."));
Docs.Append(TEXT("Print a section of the manual. Valid sections: "));
WingManual::PrintSectionNames(nullptr, WingManual::GetSections(), Docs);
UWingServer::AddHandler(this, Docs.ToString());
} }
virtual void Handle() override virtual void Handle() override
{ {
TSet<FName> Sections = WingManual::GetSections(); UWingManualSections::FetcherPaths();
if (Section.IsEmpty()) UWingManualSections::ExpressingTypes();
{ UWingManualSections::VariableDeclarations();
UWingManualSections::FetcherPaths(); UWingManualSections::EscapeSequencesInFNames();
UWingManualSections::ExpressingTypes(); UWingManualSections::MaterialEditing();
UWingManualSections::VariableDeclarations(); UWingManualSections::NodeContextMenus();
UWingManualSections::EscapeSequencesInFNames(); UWingManualSections::VariableGettersAndSetters();
UWingManualSections::MaterialEditing(); UWingManualSections::BestPerformance();
UWingManualSections::ImportantCommands(); UWingManualSections::ImportantCommands();
}
else
{
FName SectionName(*Section);
if (WingManual::PrintSection(SectionName))
{
WingOut::Stdout.Printf(TEXT("\n"));
WingManual::PrintSectionNames(TEXT("Other manual sections:"), Sections, WingOut::Stdout);
}
else
{
WingOut::Stdout.Printf(TEXT("Unknown manual section '%s'\n"));
WingManual::PrintSectionNames(TEXT("Valid manual sections:"), Sections, WingOut::Stdout);
}
}
} }
}; };

View File

@@ -0,0 +1,39 @@
#pragma once
#include "CoreMinimal.h"
#include "WingBasics.h"
#include "WingManual.h"
#include "WingServer.h"
#include "Documentation_Section.generated.h"
UCLASS()
class UWing_Documentation_Section : public UWingHandler
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, meta=(Description="Manual section"))
FString Section;
virtual void Register() override
{
TStringBuilder<128> Docs;
Docs.Append(TEXT("Print a section of the manual. Valid sections: "));
WingManual::PrintSectionNames(nullptr, WingManual::GetSections(), Docs);
UWingServer::AddHandler(this, Docs.ToString());
}
virtual void Handle() override
{
FName SectionName(*Section);
if (WingManual::PrintSection(SectionName))
{
WingOut::Stdout.Printf(TEXT("\n"));
WingManual::PrintSectionNames(TEXT("Other manual sections:"), WingManual::GetSections(), WingOut::Stdout);
}
else
{
WingOut::Stdout.Printf(TEXT("Unknown manual section '%s'\n"), *Section);
WingManual::PrintSectionNames(TEXT("Valid manual sections:"), WingManual::GetSections(), WingOut::Stdout);
}
}
};

View File

@@ -28,13 +28,16 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Name of the new event dispatcher")) UPROPERTY(EditAnywhere, meta=(Description="Name of the new event dispatcher"))
FString Dispatcher; FString Dispatcher;
UPROPERTY(EditAnywhere, meta=(Description="Input Variables, one per line, expressed as: type var = value")) UPROPERTY(EditAnywhere, meta=(Description="Variables"))
FString InputVariables; FWingRestOfArgv Variables;
virtual void Register() override virtual void Register() override
{ {
UWingServer::AddHandler(this, UWingServer::AddHandler(this,
TEXT("Add a new event dispatcher to a Blueprint.")); TEXT("Add a new event dispatcher to a Blueprint. "
"Variables must be expressed as 'kind type name (flags) = default'. "
"Kind can only be 'input'."));
} }
virtual void Handle() override virtual void Handle() override
{ {
@@ -49,7 +52,7 @@ public:
// Parse the arguments. // Parse the arguments.
WingVariables Vars; WingVariables Vars;
if (!Vars.InputVariables.ParseString(InputVariables, WingOut::Stdout)) return; if (!Vars.Parse(Variables.Argv, false, WingOut::Stdout)) return;
// Add the delegate variable // Add the delegate variable
FEdGraphPinType DelegateType; FEdGraphPinType DelegateType;

View File

@@ -4,7 +4,6 @@
#include "WingServer.h" #include "WingServer.h"
#include "WingBasics.h" #include "WingBasics.h"
#include "WingFetcher.h" #include "WingFetcher.h"
#include "WingProperty.h"
#include "WingUtils.h" #include "WingUtils.h"
#include "WingGraphActions.h" #include "WingGraphActions.h"
#include "WingGraphExport.h" #include "WingGraphExport.h"
@@ -16,24 +15,6 @@
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
USTRUCT()
struct FSpawnNodeEntry
{
GENERATED_BODY()
UPROPERTY()
FString Type;
UPROPERTY()
int32 PosX = 0;
UPROPERTY()
int32 PosY = 0;
FWingGraphAction *Action;
};
UCLASS() UCLASS()
class UWing_GraphNode_Add : public UWingHandler class UWing_GraphNode_Add : public UWingHandler
{ {
@@ -43,8 +24,14 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Target graph")) UPROPERTY(EditAnywhere, meta=(Description="Target graph"))
FString Graph; FString Graph;
UPROPERTY(EditAnywhere, meta=(Description="Array of {Type, posX, posY} objects. Use GraphNode_SearchTypes to find types.")) UPROPERTY(EditAnywhere, meta=(Description="Node type, from GraphNode_SearchTypes"))
FWingJsonArray Nodes; FString Type;
UPROPERTY(EditAnywhere, meta=(Description="Node X position"))
int32 PosX = 0;
UPROPERTY(EditAnywhere, meta=(Description="Node Y position"))
int32 PosY = 0;
virtual void Register() override virtual void Register() override
{ {
@@ -58,38 +45,19 @@ public:
UEdGraph* TargetGraph = F.Walk(Graph).Cast<UEdGraph>(); UEdGraph* TargetGraph = F.Walk(Graph).Cast<UEdGraph>();
if (!TargetGraph) return; if (!TargetGraph) return;
int32 SuccessCount = 0;
int32 TotalCount = Nodes.Array.Num();
FWingGraphActions GraphActions(TargetGraph); FWingGraphActions GraphActions(TargetGraph);
TArray<FWingGraphAction*> Results = GraphActions.Search(Type, 2, true);
if (!WingUtils::CheckExactlyOneNamed(Results.Num(), TEXT("node type"), Type, WingOut::Stdout)) return;
// Parse the json array, turning it into an array of spawn node entries. UEdGraphNode* NewNode = Results[0]->Execute(FVector2D(PosX, PosY));
TArray<FSpawnNodeEntry> Entries; if (NewNode)
FSpawnNodeEntry Entry;
TArray<FWingProperty> Props = FWingProperty::GetAll(&Entry);
for (const TSharedPtr<FJsonValue>& Elt : Nodes.Array)
{ {
if (!FWingProperty::PopulateFromJson(Props, *Elt, false, WingOut::Stdout)) return; WingOut::Stdout.Printf(TEXT("Spawned: %s\n"), *Type);
TArray<FWingGraphAction*> Results = GraphActions.Search(Entry.Type, 2, true); WingGraphExport Export(NewNode, false, true);
if (!WingUtils::CheckExactlyOneNamed(Results.Num(), TEXT("node type"), Entry.Type, WingOut::Stdout)) return; WingOut::Stdout.Print(Export.GetOutput());
Entry.Action = Results[0]; return;
Entries.Add(Entry);
} }
// Execute all. WingOut::Stdout.Printf(TEXT("Failed: %s\n"), *Type);
for (const FSpawnNodeEntry &Entry : Entries)
{
UEdGraphNode* NewNode = Entry.Action->Execute(FVector2D(Entry.PosX, Entry.PosY));
if (NewNode)
{
WingOut::Stdout.Printf(TEXT("Spawned: %s\n"), *Entry.Type);
WingGraphExport Export(NewNode, false, true);
WingOut::Stdout.Print(Export.GetOutput());
}
else
{
WingOut::Stdout.Printf(TEXT("Failed: %s\n\n"), *Entry.Type);
continue;
}
}
} }
}; };

View File

@@ -21,7 +21,7 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Target node")) UPROPERTY(EditAnywhere, meta=(Description="Target node"))
FString Node; FString Node;
UPROPERTY(EditAnywhere, meta=(Optional, Description="True to show minor node properties")) UPROPERTY(EditAnywhere, meta=(Description="True to show minor node properties"))
bool Details = false; bool Details = false;
virtual void Register() override virtual void Register() override

View File

@@ -19,15 +19,15 @@ class UWing_GraphNode_SearchTypes : public UWingHandler
GENERATED_BODY() GENERATED_BODY()
public: public:
UPROPERTY(EditAnywhere, meta=(Description="Array of query strings; each may contain * wildcards"))
FWingJsonArray Queries;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Maximum number of results per query (default 50)"))
int32 MaxResults = 50;
UPROPERTY(EditAnywhere, meta=(Description="Target graph")) UPROPERTY(EditAnywhere, meta=(Description="Target graph"))
FString Graph; FString Graph;
UPROPERTY(EditAnywhere, meta=(Description="Maximum number of results per query"))
int32 MaxResults = 50;
UPROPERTY(EditAnywhere, meta=(Description="Query strings; each may contain * wildcards"))
FWingRestOfArgv Queries;
virtual void Register() override virtual void Register() override
{ {
UWingServer::AddHandler(this, UWingServer::AddHandler(this,
@@ -40,22 +40,8 @@ public:
UEdGraph* TargetGraph = F.Walk(Graph).Cast<UEdGraph>(); UEdGraph* TargetGraph = F.Walk(Graph).Cast<UEdGraph>();
if (!TargetGraph) return; if (!TargetGraph) return;
// Validate all entries are strings before running any searches.
TArray<FString> QueryStrings;
QueryStrings.Reserve(Queries.Array.Num());
for (const TSharedPtr<FJsonValue>& QueryVal : Queries.Array)
{
FString QueryStr;
if (!QueryVal->TryGetString(QueryStr))
{
WingOut::Stdout.Print(TEXT("ERROR: Queries must be an array of strings.\n"));
return;
}
QueryStrings.Add(QueryStr);
}
FWingGraphActions GraphActions(TargetGraph); FWingGraphActions GraphActions(TargetGraph);
for (const FString& Query : QueryStrings) for (const FString& Query : Queries.Argv)
{ {
WingOut::Stdout.Printf(TEXT("\n=== %s ===\n\n"), *Query); WingOut::Stdout.Printf(TEXT("\n=== %s ===\n\n"), *Query);
TArray<FWingGraphAction*> Results = GraphActions.Search(Query, MaxResults, false); TArray<FWingGraphAction*> Results = GraphActions.Search(Query, MaxResults, false);

View File

@@ -4,36 +4,15 @@
#include "WingBasics.h" #include "WingBasics.h"
#include "WingServer.h" #include "WingServer.h"
#include "WingFetcher.h" #include "WingFetcher.h"
#include "WingProperty.h"
#include "WingUtils.h" #include "WingUtils.h"
#include "EdGraph/EdGraphPin.h" #include "EdGraph/EdGraphPin.h"
#include "EdGraphSchema_K2.h" #include "EdGraphSchema_K2.h"
#include "MaterialGraph/MaterialGraphSchema.h" #include "MaterialGraph/MaterialGraphSchema.h"
#include "GraphNode_SetDefaults.generated.h" #include "GraphNode_SetDefault.generated.h"
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
USTRUCT()
struct FSetNodeDefaultEntry
{
GENERATED_BODY()
UPROPERTY()
FString Node;
UPROPERTY()
FString Name;
UPROPERTY()
FString Value;
};
UCLASS() UCLASS()
class UWing_GraphNode_SetDefaults : public UWingHandler class UWing_GraphNode_SetDefault : public UWingHandler
{ {
GENERATED_BODY() GENERATED_BODY()
@@ -41,8 +20,14 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Target graph")) UPROPERTY(EditAnywhere, meta=(Description="Target graph"))
FString Graph; FString Graph;
UPROPERTY(EditAnywhere, meta=(Description="Array of {node, name, value} objects")) UPROPERTY(EditAnywhere, meta=(Description="Target node"))
FWingJsonArray Pins; FString Node;
UPROPERTY(EditAnywhere, meta=(Description="Pin or property name"))
FString Name;
UPROPERTY(EditAnywhere, meta=(Description="New default value"))
FString Value;
virtual void Register() override virtual void Register() override
{ {
@@ -53,15 +38,15 @@ public:
// K2 graphs: set pin default values. // K2 graphs: set pin default values.
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
void HandleK2Entry(const FSetNodeDefaultEntry& Entry, UEdGraph* GraphObj, const UEdGraphSchema_K2* K2Schema) void HandleK2(UEdGraph* GraphObj, const UEdGraphSchema_K2* K2Schema)
{ {
WingFetcher F(GraphObj, WingOut::Stdout); WingFetcher F(GraphObj, WingOut::Stdout);
UWingGraphPinRef* PinRef = F.Node(Entry.Node).Pin(Entry.Name).Cast<UWingGraphPinRef>(); UWingGraphPinRef* PinRef = F.Node(Node).Pin(Name).Cast<UWingGraphPinRef>();
if (!PinRef) return; if (!PinRef) return;
UEdGraphPin* Pin = WingUtils::CheckGetPin(PinRef->Node, PinRef->PinName, WingOut::Stdout); UEdGraphPin* Pin = WingUtils::CheckGetPin(PinRef->Node, PinRef->PinName, WingOut::Stdout);
if (!Pin) return; if (!Pin) return;
UEdGraphNode* Node = Pin->GetOwningNode(); UEdGraphNode* FoundNode = Pin->GetOwningNode();
if (Pin->Direction != EGPD_Input) if (Pin->Direction != EGPD_Input)
{ {
@@ -72,34 +57,34 @@ public:
FString UseDefaultValue; FString UseDefaultValue;
TObjectPtr<UObject> UseDefaultObject = nullptr; TObjectPtr<UObject> UseDefaultObject = nullptr;
FText UseDefaultText; FText UseDefaultText;
K2Schema->GetPinDefaultValuesFromString(Pin->PinType, Node, Entry.Value, UseDefaultValue, UseDefaultObject, UseDefaultText, false); K2Schema->GetPinDefaultValuesFromString(Pin->PinType, FoundNode, Value, UseDefaultValue, UseDefaultObject, UseDefaultText, false);
FString Error = K2Schema->IsPinDefaultValid(Pin, UseDefaultValue, UseDefaultObject, UseDefaultText); FString Error = K2Schema->IsPinDefaultValid(Pin, UseDefaultValue, UseDefaultObject, UseDefaultText);
if (!Error.IsEmpty()) if (!Error.IsEmpty())
{ {
WingOut::Stdout.Printf(TEXT("error: %s: %s\n"), *WingUtils::FormatName(Pin), *Error); WingOut::Stdout.Printf(TEXT("error: %s: %s\n"), *WingUtils::FormatName(Pin), *Error);
return; return;
} }
UWingServer::AddTouchedObject(Node); UWingServer::AddTouchedObject(FoundNode);
K2Schema->TrySetDefaultValue(*Pin, Entry.Value); K2Schema->TrySetDefaultValue(*Pin, Value);
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Material graphs: set material expression properties. // Material graphs: set material expression properties.
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
void HandleMaterialEntry(const FSetNodeDefaultEntry& Entry, UEdGraph* GraphObj) void HandleMaterial(UEdGraph* GraphObj)
{ {
WingFetcher F(GraphObj, WingOut::Stdout); WingFetcher F(GraphObj, WingOut::Stdout);
UEdGraphNode* Node = F.Node(Entry.Node).Cast<UEdGraphNode>(); UEdGraphNode* FoundNode = F.Node(Node).Cast<UEdGraphNode>();
if (!Node) return; if (!FoundNode) return;
TArray<FWingProperty> All = FWingProperty::GetDetails(Node, true); TArray<FWingProperty> All = FWingProperty::GetDetails(FoundNode, true);
FWingProperty *P = WingUtils::FindOneWithExternalID(Entry.Name, All, TEXT("Property"), WingOut::Stdout); FWingProperty *P = WingUtils::FindOneWithExternalID(Name, All, TEXT("Property"), WingOut::Stdout);
if (!P) return; if (!P) return;
UWingServer::AddTouchedObject(Node); UWingServer::AddTouchedObject(FoundNode);
if (!P->SetText(Entry.Value, WingOut::Stdout)) if (!P->SetText(Value, WingOut::Stdout))
return; return;
} }
@@ -122,14 +107,8 @@ public:
return; return;
} }
FSetNodeDefaultEntry Entry; if (K2Schema) HandleK2(GraphObj, K2Schema);
TArray<FWingProperty> Props = FWingProperty::GetAll(&Entry); else if (MGSchema) HandleMaterial(GraphObj);
for (const TSharedPtr<FJsonValue>& PinVal : Pins.Array)
{
if (!FWingProperty::PopulateFromJson(Props, *PinVal, false, WingOut::Stdout)) continue;
if (K2Schema) HandleK2Entry(Entry, GraphObj, K2Schema);
else if (MGSchema) HandleMaterialEntry(Entry, GraphObj);
}
WingOut::Stdout.Printf(TEXT("Done.\n")); WingOut::Stdout.Printf(TEXT("Done.\n"));
} }

View File

@@ -0,0 +1,49 @@
#pragma once
#include "CoreMinimal.h"
#include "WingServer.h"
#include "WingBasics.h"
#include "WingFetcher.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
#include "GraphNode_SetPosition.generated.h"
UCLASS()
class UWing_GraphNode_SetPosition : public UWingHandler
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, meta=(Description="Target graph"))
FString Graph;
UPROPERTY(EditAnywhere, meta=(Description="Target node"))
FString Node;
UPROPERTY(EditAnywhere, meta=(Description="New X position"))
int32 X = 0;
UPROPERTY(EditAnywhere, meta=(Description="New Y position"))
int32 Y = 0;
virtual void Register() override
{
UWingServer::AddHandler(this,
TEXT("Reposition a node in a Blueprint graph."));
}
virtual void Handle() override
{
WingFetcher F(WingOut::Stdout);
UEdGraph* TargetGraph = F.Walk(Graph).Cast<UEdGraph>();
if (!TargetGraph) return;
WingFetcher FN(TargetGraph, WingOut::Stdout);
UEdGraphNode* FoundNode = FN.Node(Node).Cast<UEdGraphNode>();
if (!FoundNode) return;
FoundNode->NodePosX = X;
FoundNode->NodePosY = Y;
WingOut::Stdout.Print(TEXT("Moved node.\n"));
}
};

View File

@@ -1,73 +0,0 @@
#pragma once
#include "CoreMinimal.h"
#include "WingServer.h"
#include "WingBasics.h"
#include "WingFetcher.h"
#include "WingProperty.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphNode.h"
#include "GraphNode_SetPositions.generated.h"
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
USTRUCT()
struct FMoveNodeEntry
{
GENERATED_BODY()
UPROPERTY()
FString Node;
UPROPERTY()
int32 X = 0;
UPROPERTY()
int32 Y = 0;
};
UCLASS()
class UWing_GraphNode_SetPositions : public UWingHandler
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, meta=(Description="Target graph"))
FString Graph;
UPROPERTY(EditAnywhere, meta=(Description="Array of {node, x, y} objects"))
FWingJsonArray Nodes;
virtual void Register() override
{
UWingServer::AddHandler(this,
TEXT("Reposition one or more nodes in a Blueprint graph."));
}
virtual void Handle() override
{
WingFetcher F(WingOut::Stdout);
UEdGraph* TargetGraph = F.Walk(Graph).Cast<UEdGraph>();
if (!TargetGraph) return;
int32 SuccessCount = 0;
FMoveNodeEntry Entry;
TArray<FWingProperty> Props = FWingProperty::GetAll(&Entry);
for (const TSharedPtr<FJsonValue>& Elt : Nodes.Array)
{
if (!FWingProperty::PopulateFromJson(Props, *Elt, false, WingOut::Stdout)) continue;
WingFetcher FN(TargetGraph, WingOut::Stdout);
UEdGraphNode* Node = FN.Node(Entry.Node).Cast<UEdGraphNode>();
if (!Node) continue;
Node->NodePosX = Entry.X;
Node->NodePosY = Entry.Y;
SuccessCount++;
}
WingOut::Stdout.Printf(TEXT("Moved %d/%d nodes.\n"), SuccessCount, Nodes.Array.Num());
}
};

View File

@@ -4,7 +4,6 @@
#include "WingServer.h" #include "WingServer.h"
#include "WingBasics.h" #include "WingBasics.h"
#include "WingFetcher.h" #include "WingFetcher.h"
#include "WingProperty.h"
#include "WingUtils.h" #include "WingUtils.h"
#include "EdGraph/EdGraph.h" #include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphSchema.h" #include "EdGraph/EdGraphSchema.h"
@@ -12,23 +11,6 @@
#include "GraphPin_Connect.generated.h" #include "GraphPin_Connect.generated.h"
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
USTRUCT()
struct FConnectPinsEntry
{
GENERATED_BODY()
UPROPERTY()
FString SourcePin;
UPROPERTY()
FString TargetPin;
};
UCLASS() UCLASS()
class UWing_GraphPin_Connect : public UWingHandler class UWing_GraphPin_Connect : public UWingHandler
{ {
@@ -38,8 +20,8 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Target graph")) UPROPERTY(EditAnywhere, meta=(Description="Target graph"))
FString Graph; FString Graph;
UPROPERTY(EditAnywhere, meta=(Description="Array of {sourcePin, targetPin} objects")) UPROPERTY(EditAnywhere, meta=(Description="Alternating source pin / target pin strings"))
FWingJsonArray Connections; FWingRestOfArgv SourcePin_TargetPin;
virtual void Register() override virtual void Register() override
{ {
@@ -54,24 +36,27 @@ public:
UEdGraph* G = F.Walk(Graph).Cast<UEdGraph>(); UEdGraph* G = F.Walk(Graph).Cast<UEdGraph>();
if (!G) return; if (!G) return;
int32 SuccessCount = 0; if ((SourcePin_TargetPin.Argv.Num() % 2) != 0)
int32 TotalCount = Connections.Array.Num();
FConnectPinsEntry Entry;
TArray<FWingProperty> EntryProps = FWingProperty::GetAll(&Entry);
for (const TSharedPtr<FJsonValue>& ConnVal : Connections.Array)
{ {
if (!FWingProperty::PopulateFromJson(EntryProps, *ConnVal, false, WingOut::Stdout)) WingOut::Stdout.Print(TEXT("ERROR: SourcePin_TargetPin must contain an even number of arguments.\n"));
continue; return;
}
int32 SuccessCount = 0;
int32 TotalCount = SourcePin_TargetPin.Argv.Num() / 2;
for (int32 I = 0; I < SourcePin_TargetPin.Argv.Num(); I += 2)
{
const FString& SourcePinPath = SourcePin_TargetPin.Argv[I];
const FString& TargetPinPath = SourcePin_TargetPin.Argv[I + 1];
WingFetcher FS(G, WingOut::Stdout); WingFetcher FS(G, WingOut::Stdout);
UWingGraphPinRef* SourcePinRef = FS.Walk(Entry.SourcePin).Cast<UWingGraphPinRef>(); UWingGraphPinRef* SourcePinRef = FS.Walk(SourcePinPath).Cast<UWingGraphPinRef>();
if (!SourcePinRef) continue; if (!SourcePinRef) continue;
UEdGraphPin* SourcePin = WingUtils::CheckGetPin(SourcePinRef->Node, SourcePinRef->PinName, WingOut::Stdout); UEdGraphPin* SourcePin = WingUtils::CheckGetPin(SourcePinRef->Node, SourcePinRef->PinName, WingOut::Stdout);
if (!SourcePin) continue; if (!SourcePin) continue;
WingFetcher FT(G, WingOut::Stdout); WingFetcher FT(G, WingOut::Stdout);
UWingGraphPinRef* TargetPinRef = FT.Walk(Entry.TargetPin).Cast<UWingGraphPinRef>(); UWingGraphPinRef* TargetPinRef = FT.Walk(TargetPinPath).Cast<UWingGraphPinRef>();
if (!TargetPinRef) continue; if (!TargetPinRef) continue;
UEdGraphPin* TargetPin = WingUtils::CheckGetPin(TargetPinRef->Node, TargetPinRef->PinName, WingOut::Stdout); UEdGraphPin* TargetPin = WingUtils::CheckGetPin(TargetPinRef->Node, TargetPinRef->PinName, WingOut::Stdout);
if (!TargetPin) continue; if (!TargetPin) continue;

View File

@@ -4,7 +4,6 @@
#include "WingServer.h" #include "WingServer.h"
#include "WingBasics.h" #include "WingBasics.h"
#include "WingFetcher.h" #include "WingFetcher.h"
#include "WingProperty.h"
#include "WingUtils.h" #include "WingUtils.h"
#include "EdGraph/EdGraph.h" #include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphPin.h" #include "EdGraph/EdGraphPin.h"
@@ -24,8 +23,8 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Target graph")) UPROPERTY(EditAnywhere, meta=(Description="Target graph"))
FString Graph; FString Graph;
UPROPERTY(EditAnywhere, meta=(Description="Array of pin ID strings")) UPROPERTY(EditAnywhere, meta=(Description="Pin ID strings"))
FWingJsonArray Pins; FWingRestOfArgv Pins;
virtual void Register() override virtual void Register() override
{ {
@@ -43,15 +42,8 @@ public:
int32 SuccessCount = 0; int32 SuccessCount = 0;
int32 TotalDisconnected = 0; int32 TotalDisconnected = 0;
for (const TSharedPtr<FJsonValue>& PinVal : Pins.Array) for (const FString& PinPath : Pins.Argv)
{ {
FString PinPath;
if (!PinVal->TryGetString(PinPath))
{
WingOut::Stdout.Print(TEXT("ERROR: Expected a string pin ID.\n"));
continue;
}
WingFetcher FP(G, WingOut::Stdout); WingFetcher FP(G, WingOut::Stdout);
UWingGraphPinRef* PinRef = FP.Walk(PinPath).Cast<UWingGraphPinRef>(); UWingGraphPinRef* PinRef = FP.Walk(PinPath).Cast<UWingGraphPinRef>();
if (!PinRef) continue; if (!PinRef) continue;
@@ -72,6 +64,6 @@ public:
} }
WingOut::Stdout.Printf(TEXT("Done: %d/%d succeeded, %d links broken.\n"), WingOut::Stdout.Printf(TEXT("Done: %d/%d succeeded, %d links broken.\n"),
SuccessCount, Pins.Array.Num(), TotalDisconnected); SuccessCount, Pins.Argv.Num(), TotalDisconnected);
} }
}; };

View File

@@ -22,7 +22,7 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Path to graph")) UPROPERTY(EditAnywhere, meta=(Description="Path to graph"))
FString Graph; FString Graph;
UPROPERTY(EditAnywhere, meta=(Optional, Description="True to show minor node properties")) UPROPERTY(EditAnywhere, meta=(Description="True to show minor node properties"))
bool Details = false; bool Details = false;
virtual void Register() override virtual void Register() override

View File

@@ -1,40 +0,0 @@
#pragma once
#include "CoreMinimal.h"
#include "WingServer.h"
#include "WingBasics.h"
#include "Sequence.generated.h"
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
UCLASS()
class UWing_Sequence : public UWingHandler
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, meta=(Description=
"Array of subcommand JSON objects to execute in order. Each must contain 'command' and its parameters."))
FWingJsonArray Subcommands;
virtual void Register() override
{
UWingServer::AddHandler(this,
TEXT("Execute multiple commands in one request. Each subcommand produces its own content block in the response. "
"Nested Sequence commands are not allowed."));
}
virtual void Handle() override
{
// The actual code that implements Sequence is hardwired into
// WingServer. Because of that, this handler is never actually called
// under normal conditions. The handler exists for two reasons: to
// provide documentation, and also to catch the case where somebody
// nests a sequence inside another sequence (WingServer doesn't catch
// that).
//
WingOut::Stdout.Print(TEXT("ERROR: Sequence inside a Sequence is not allowed.\n"));
}
};

View File

@@ -0,0 +1,59 @@
#pragma once
#include "CoreMinimal.h"
#include "WingServer.h"
#include "WingBasics.h"
#include "Containers/BitArray.h"
#include "Containers/SparseArray.h"
#include "Test_TMaps.generated.h"
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
UCLASS()
class UWing_Test_TMaps : public UWingHandler
{
GENERATED_BODY()
public:
virtual void Register() override
{
UWingServer::AddHandler(this,
TEXT("Constructs a small TMap, TBitArray, and TSparseArray so that "
"a developer can set a breakpoint and inspect them with the "
"lldb data formatters."));
}
virtual void Handle() override
{
TMap<int32, FString> Map;
Map.Add(1, TEXT("one"));
Map.Add(2, TEXT("two"));
Map.Add(3, TEXT("three"));
Map.Add(42, TEXT("forty-two"));
TBitArray<> Bits;
Bits.Add(true);
Bits.Add(false);
Bits.Add(true);
Bits.Add(true);
Bits.Add(false);
// Add a few entries, then remove a middle one so the live set is
// non-contiguous. Exercises the sparse-array formatter against a hole.
TSparseArray<FString> Sparse;
Sparse.Add(TEXT("alpha"));
int32 BetaIdx = Sparse.Add(TEXT("beta"));
Sparse.Add(TEXT("gamma"));
Sparse.Add(TEXT("delta"));
Sparse.RemoveAt(BetaIdx);
TTuple<int32, FString, float, bool, FName> Tuple(1, TEXT("hello"), 3.14f, true, FName("world"));
// Set a breakpoint on the following line to inspect Map, Bits, and Sparse.
WingOut::Stdout.Printf(TEXT("Test_TMaps: Map has %d entries, Bits has %d bits, Sparse has %d entries.\n"),
Map.Num(), Bits.Num(), Sparse.Num());
}
};

View File

@@ -20,7 +20,7 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Substring filter for type names")) UPROPERTY(EditAnywhere, meta=(Description="Substring filter for type names"))
FString Query; FString Query;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Maximum number of results")) UPROPERTY(EditAnywhere, meta=(Description="Maximum number of results"))
int32 Limit = 100; int32 Limit = 100;
virtual void Register() override virtual void Register() override

View File

@@ -21,22 +21,16 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Path to a blueprint, graph, or custom event node")) UPROPERTY(EditAnywhere, meta=(Description="Path to a blueprint, graph, or custom event node"))
FString Object; FString Object;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Blueprint variables, one per line")) UPROPERTY(EditAnywhere, meta=(Description="Variable descriptions"))
FString BlueprintVariables; FWingRestOfArgv Variables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Input variables, one per line"))
FString InputVariables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Output variables, one per line"))
FString OutputVariables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Local variables, one per line"))
FString LocalVariables;
virtual void Register() override virtual void Register() override
{ {
UWingServer::AddHandler(this, UWingServer::AddHandler(this,
TEXT("Add new variables. Format: 'type name (flags) = default', one per line.")); TEXT("Add variables to a blueprint, function graph, "
"macro graph, event dispatcher graph, or custom event node. "
"Each variable must be expressed as: 'kind type name (flags) = default'. "
"Kind can be blueprint, input, output, or local."));
} }
virtual void Handle() override virtual void Handle() override
{ {
@@ -46,10 +40,7 @@ public:
WingVariables Vars; WingVariables Vars;
if (!Vars.SetBackingStore(Obj, WingOut::Stdout)) return; if (!Vars.SetBackingStore(Obj, WingOut::Stdout)) return;
if (!Vars.BlueprintVariables.ParseString(BlueprintVariables, WingOut::Stdout)) return; if (!Vars.Parse(Variables.Argv, false, WingOut::Stdout)) return;
if (!Vars.InputVariables.ParseString(InputVariables, WingOut::Stdout)) return;
if (!Vars.OutputVariables.ParseString(OutputVariables, WingOut::Stdout)) return;
if (!Vars.LocalVariables.ParseString(LocalVariables, WingOut::Stdout)) return;
if (!Vars.Check(WingOut::Stdout)) return; if (!Vars.Check(WingOut::Stdout)) return;
if (!Vars.Create(WingOut::Stdout)) return; if (!Vars.Create(WingOut::Stdout)) return;
WingOut::Stdout.Printf(TEXT("Success.\n")); WingOut::Stdout.Printf(TEXT("Success.\n"));

View File

@@ -21,23 +21,16 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Path to a blueprint, graph, or custom event node")) UPROPERTY(EditAnywhere, meta=(Description="Path to a blueprint, graph, or custom event node"))
FString Object; FString Object;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Blueprint variables, one per line")) UPROPERTY(EditAnywhere, meta=(Description="Variable descriptions"))
FString BlueprintVariables; FWingRestOfArgv Variables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Input variables, one per line"))
FString InputVariables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Output variables, one per line"))
FString OutputVariables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Local variables, one per line"))
FString LocalVariables;
virtual void Register() override virtual void Register() override
{ {
UWingServer::AddHandler(this, UWingServer::AddHandler(this,
TEXT("Modify variables of a blueprint, function graph, " TEXT("Add variables to a blueprint, function graph, "
"macro graph, event dispatcher graph, or custom event node. ")); "macro graph, event dispatcher graph, or custom event node. "
"Each variable must be expressed as: 'kind type name (flags) = default'. "
"Kind can be blueprint, input, output, or local."));
} }
virtual void Handle() override virtual void Handle() override
{ {
@@ -47,10 +40,7 @@ public:
WingVariables Vars; WingVariables Vars;
if (!Vars.SetBackingStore(Obj, WingOut::Stdout)) return; if (!Vars.SetBackingStore(Obj, WingOut::Stdout)) return;
if (!Vars.BlueprintVariables.ParseString(BlueprintVariables, WingOut::Stdout)) return; if (!Vars.Parse(Variables.Argv, false, WingOut::Stdout)) return;
if (!Vars.InputVariables.ParseString(InputVariables, WingOut::Stdout)) return;
if (!Vars.OutputVariables.ParseString(OutputVariables, WingOut::Stdout)) return;
if (!Vars.LocalVariables.ParseString(LocalVariables, WingOut::Stdout)) return;
if (!Vars.Check(WingOut::Stdout)) return; if (!Vars.Check(WingOut::Stdout)) return;
if (!Vars.Modify(WingOut::Stdout)) return; if (!Vars.Modify(WingOut::Stdout)) return;
WingOut::Stdout.Printf(TEXT("Success.\n")); WingOut::Stdout.Printf(TEXT("Success.\n"));

View File

@@ -21,22 +21,15 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Path to a blueprint, graph, or custom event node")) UPROPERTY(EditAnywhere, meta=(Description="Path to a blueprint, graph, or custom event node"))
FString Object; FString Object;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Blueprint variable names to remove, comma-separated")) UPROPERTY(EditAnywhere, meta=(Description="Variable descriptions"))
FString BlueprintVariables; FWingRestOfArgv Variables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Input variable names to remove, comma-separated"))
FString InputVariables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Output variable names to remove, comma-separated"))
FString OutputVariables;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Local variable names to remove, comma-separated"))
FString LocalVariables;
virtual void Register() override virtual void Register() override
{ {
UWingServer::AddHandler(this, UWingServer::AddHandler(this,
TEXT("Remove variables from a blueprint, graph, or custom event node.")); TEXT("Remove variables from a blueprint, graph, or custom event node. "
"Each variable must be expressed as: 'kind name'. "
"Kind can be blueprint, input, output, or local."));
} }
virtual void Handle() override virtual void Handle() override
{ {
@@ -46,10 +39,7 @@ public:
WingVariables Vars; WingVariables Vars;
if (!Vars.SetBackingStore(Obj, WingOut::Stdout)) return; if (!Vars.SetBackingStore(Obj, WingOut::Stdout)) return;
if (!Vars.BlueprintVariables.ParseNamesString(BlueprintVariables, WingOut::Stdout)) return; if (!Vars.Parse(Variables.Argv, true, WingOut::Stdout)) return;
if (!Vars.InputVariables.ParseNamesString(InputVariables, WingOut::Stdout)) return;
if (!Vars.OutputVariables.ParseNamesString(OutputVariables, WingOut::Stdout)) return;
if (!Vars.LocalVariables.ParseNamesString(LocalVariables, WingOut::Stdout)) return;
if (!Vars.Remove(WingOut::Stdout)) return; if (!Vars.Remove(WingOut::Stdout)) return;
WingOut::Stdout.Printf(TEXT("Success.\n")); WingOut::Stdout.Printf(TEXT("Success.\n"));
} }

View File

@@ -33,10 +33,10 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Name for the new widget")) UPROPERTY(EditAnywhere, meta=(Description="Name for the new widget"))
FString Name; FString Name;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Parent widget name. If omitted, sets as root.")) UPROPERTY(EditAnywhere, meta=(Description="Parent widget name. If omitted, sets as root."))
FString Parent; FString Parent;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Whether to expose the widget as a variable in the blueprint (default false)")) UPROPERTY(EditAnywhere, meta=(Description="Whether to expose the widget as a variable in the blueprint (default false)"))
bool IsVariable = false; bool IsVariable = false;
virtual void Register() override virtual void Register() override
@@ -117,6 +117,10 @@ public:
BP->WidgetTree->RootWidget = NewWidget; BP->WidgetTree->RootWidget = NewWidget;
} }
// Register a variable GUID for the new widget. UMG's compiler
// ensures every widget in the tree is present in this map.
// BP->OnVariableAdded(NewWidget->GetFName());
WingOut::Stdout.Printf(TEXT("Created widget '%s' of type '%s'\n"), *Name, *Type); WingOut::Stdout.Printf(TEXT("Created widget '%s' of type '%s'\n"), *Name, *Type);
} }
}; };

View File

@@ -17,12 +17,12 @@ class UWing_Widget_SearchTypes : public UWingHandler
GENERATED_BODY() GENERATED_BODY()
public: public:
UPROPERTY(EditAnywhere, meta=(Description="Array of query strings; each may contain *")) UPROPERTY(EditAnywhere, meta=(Description="Maximum number of results per query"))
FWingJsonArray Queries;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Maximum number of results per query (default 50)"))
int32 MaxResults = 50; int32 MaxResults = 50;
UPROPERTY(EditAnywhere, meta=(Description="Query strings; each may contain *"))
FWingRestOfArgv Queries;
virtual void Register() override virtual void Register() override
{ {
UWingServer::AddHandler(this, UWingServer::AddHandler(this,
@@ -31,22 +31,8 @@ public:
} }
virtual void Handle() override virtual void Handle() override
{ {
// Validate all entries are strings before running any searches.
TArray<FString> QueryStrings;
QueryStrings.Reserve(Queries.Array.Num());
for (const TSharedPtr<FJsonValue>& QueryVal : Queries.Array)
{
FString QueryStr;
if (!QueryVal->TryGetString(QueryStr))
{
WingOut::Stdout.Print(TEXT("ERROR: Queries must be an array of strings.\n"));
return;
}
QueryStrings.Add(QueryStr);
}
WingWidgets Widgets; WingWidgets Widgets;
for (const FString& Query : QueryStrings) for (const FString& Query : Queries.Argv)
{ {
WingOut::Stdout.Printf(TEXT("\n=== %s ===\n\n"), *Query); WingOut::Stdout.Printf(TEXT("\n=== %s ===\n\n"), *Query);
TArray<WingWidgets::Type> Results = Widgets.Search(Query, MaxResults, false); TArray<WingWidgets::Type> Results = Widgets.Search(Query, MaxResults, false);

View File

@@ -1,6 +1,7 @@
#include "WingFetcher.h" #include "WingFetcher.h"
#include "WingServer.h" #include "WingServer.h"
#include "WingBasics.h" #include "WingBasics.h"
#include "WingProperty.h"
#include "WingUtils.h" #include "WingUtils.h"
#include "WingComponent.h" #include "WingComponent.h"
#include "Engine/Blueprint.h" #include "Engine/Blueprint.h"
@@ -368,36 +369,27 @@ WingFetcher& WingFetcher::StructProp(const FString& Value)
return SetError(); return SetError();
} }
FStructProperty* StructProp = nullptr; TArray<FWingProperty> Details =
FWingProperty::GetDetails(Obj.Get(), true);
// The "host" is the object containing this property. FWingProperty *WProp = WingUtils::FindOneWithInternalID(
UObject *HostObject = Obj.Get(); InternalID, Details, TEXT("Property"), WingOut::Stdout);
void *HostBase = Obj.Get(); if (!WProp) return SetError();
UStruct *HostType = Obj.Get()->GetClass();
bool HostEditable = true;
// If we are *already* inside a UWingStructRef, update the host if (FStructProperty *FSProp = CastField<FStructProperty>(WProp->Prop))
// fields, to make it possible to navigate even further inside.
if (UWingStructRef *SPtr = ::Cast<UWingStructRef>(Obj.Get()))
{ {
HostObject = SPtr->Object; UWingStructRef* Ptr = NewObject<UWingStructRef>();
HostBase = SPtr->StructBase; Ptr->Object = WProp->Object.Get();
HostType = SPtr->StructType; Ptr->StructType = FSProp->Struct;
HostEditable = SPtr->Editable; Ptr->StructBase = FSProp->ContainerPtrToValuePtr<void>(WProp->Container);
Ptr->Editable = WProp->Editable;
SetObj(Ptr);
return *this;
} }
else
StructProp = FindFProperty<FStructProperty>(HostType, InternalID);
if (!StructProp)
{ {
Errors.Printf(TEXT("ERROR: No struct property '%s' found on %s\n"), *Value, *HostType->GetName()); Errors.Printf(TEXT("Property %s is not a struct property.\n"),
*WProp->Prop->GetName());
return SetError(); return SetError();
} }
UWingStructRef* Ptr = NewObject<UWingStructRef>();
Ptr->Object = HostObject;
Ptr->StructType = StructProp->Struct;
Ptr->StructBase = StructProp->ContainerPtrToValuePtr<void>(HostBase);
Ptr->Editable = HostEditable && StructProp->HasAllPropertyFlags(CPF_Edit);
SetObj(Ptr);
return *this;
} }

View File

@@ -6,17 +6,23 @@
void WingManual::PrintHandlerPrototype(const FWingHandlerConfig& Handler) void WingManual::PrintHandlerPrototype(const FWingHandlerConfig& Handler)
{ {
WingOut::Stdout.Print(TEXT("ue-wingman "));
WingOut::Stdout.Print(Handler.Name); WingOut::Stdout.Print(Handler.Name);
WingOut::Stdout.Print(TEXT("("));
bool bFirst = true;
for (TFieldIterator<FProperty> PropIt(Handler.HandlerClass.Get(), EFieldIterationFlags::None); PropIt; ++PropIt) for (TFieldIterator<FProperty> PropIt(Handler.HandlerClass.Get(), EFieldIterationFlags::None); PropIt; ++PropIt)
{ {
if (!bFirst) WingOut::Stdout.Print(TEXT(",")); FStructProperty* StructProp = CastField<FStructProperty>(*PropIt);
bFirst = false; const bool bIsRest =
if (PropIt->HasMetaData(TEXT("Optional"))) WingOut::Stdout.Print(TEXT("?")); StructProp && (StructProp->Struct == FWingRestOfArgv::StaticStruct());
WingOut::Stdout.Print(PropIt->GetName()); if (bIsRest)
{
WingOut::Stdout.Printf(TEXT(" [%s...]"), *PropIt->GetName());
}
else
{
WingOut::Stdout.Printf(TEXT(" %s"), *PropIt->GetName());
}
} }
WingOut::Stdout.Print(TEXT(")\n")); WingOut::Stdout.Print(TEXT("\n"));
} }
void WingManual::PrintHandlerArguments(const FWingHandlerConfig& Handler) void WingManual::PrintHandlerArguments(const FWingHandlerConfig& Handler)
@@ -26,196 +32,206 @@ void WingManual::PrintHandlerArguments(const FWingHandlerConfig& Handler)
{ {
FProperty* Prop = *PropIt; FProperty* Prop = *PropIt;
FString Name = Prop->GetName(); FString Name = Prop->GetName();
FString Type = UWingTypes::TypeToText(Prop); FString Desc = Prop->GetMetaData(TEXT("Description"));
bool bOptional = Prop->HasMetaData(TEXT("Optional")); if (Desc.IsEmpty()) Desc = TEXT("No documentation");
const FString& Desc = Prop->GetMetaData(TEXT("Description"));
if (bOptional) WingOut::Stdout.Printf(TEXT(" %s - %s\n"), *Name, *Desc);
{
WingOut::Stdout.Printf(TEXT(" %s (optional %s)"), *Name, *Type);
}
else
{
WingOut::Stdout.Printf(TEXT(" %s (%s)"), *Name, *Type);
}
if (!Desc.IsEmpty()) WingOut::Stdout.Printf(TEXT(" — %s"), *Desc);
WingOut::Stdout.Print(TEXT("\n"));
} }
} }
void WingManual::PrintHandlerDescription(const FWingHandlerConfig& Handler) void WingManual::PrintHandlerDescription(const FWingHandlerConfig& Handler)
{ {
if (Handler.Documentation.IsEmpty()) return; if (Handler.Documentation.IsEmpty()) return;
WingOut::Stdout.Print(WingUtils::WrapText(Handler.Documentation, 80, TEXT(" // "))); WingOut::Stdout.Printf(TEXT("\n%s\n\n"), *Handler.Documentation);
} }
void WingManual::PrintHandlerHelp(const FWingHandlerConfig& Handler) void WingManual::PrintHandlerHelp(const FWingHandlerConfig& Handler)
{ {
WingOut::Stdout.Print(TEXT("\n")); WingOut::Stdout.Print(TEXT("\n"));
PrintHandlerPrototype(Handler); PrintHandlerPrototype(Handler);
PrintHandlerArguments(Handler); PrintHandlerArguments(Handler);
PrintHandlerDescription(Handler); PrintHandlerDescription(Handler);
WingOut::Stdout.Print(TEXT("\n")); WingOut::Stdout.Print(TEXT("\n"));
} }
void UWingManualSections::FetcherPaths() void UWingManualSections::FetcherPaths()
{ {
WingOut::Stdout.Print(TEXT( WingOut::Stdout.Print(TEXT(
"\n FETCHER PATHS:" "\n FETCHER PATHS:"
"\n" "\n"
"\n Most commands require you to specify a 'fetcher path'." "\n Most commands require you to specify a 'fetcher path'."
"\n A fetcher path starts with an asset name, followed by" "\n A fetcher path starts with an asset name, followed by"
"\n steps that navigate into the asset. Some Examples:" "\n steps that navigate into the asset. Some Examples:"
"\n" "\n"
"\n /Game/Widgets/WB_Hotkeys,widget:Canvas.122" "\n /Game/Widgets/WB_Hotkeys,widget:Canvas.122"
"\n /Game/Testing/BP_Test,graph:Rescale.Actor,node:K2Node_CallFunction_0,pin:Scale" "\n /Game/Testing/BP_Test,graph:Rescale.Actor,node:K2Node_CallFunction_0,pin:Scale"
"\n /Game/Chars/BP_Manny,component:Camera.Boom" "\n /Game/Chars/BP_Manny,component:Camera.Boom"
"\n" "\n"
"\n The navigation steps supported are:" "\n The navigation steps supported are:"
"\n" "\n"
"\n graph — move from a blueprint or material to a graph." "\n graph — move from a blueprint or material to a graph."
"\n node — move from a graph to a graph node" "\n node — move from a graph to a graph node"
"\n pin — move from a graph node to a pin" "\n pin — move from a graph node to a pin"
"\n component — move from a blueprint to a component" "\n component — move from a blueprint to a component"
"\n levelblueprint — move from a world to a blueprint" "\n levelblueprint — move from a world to a blueprint"
"\n widget — move from a widget blueprint to a widget" "\n widget — move from a widget blueprint to a widget"
"\n structprop — move into a struct property of an object" "\n structprop — move into a struct property of an object"
"\n" "\n"
"\n Notice that paths use escaped fnames. See the section" "\n Notice that paths use escaped fnames. See the section"
"\n on escape sequences in fnames below sfor more information." "\n on escape sequences in fnames below sfor more information."
"\n" "\n"
"\n Steps do not always require a parameter. For example, materials" "\n Steps do not always require a parameter. For example, materials"
"\n only have one graph, so you can just say:" "\n only have one graph, so you can just say:"
"\n" "\n"
"\n /Game/Materials/MyMaterial,graph" "\n /Game/Materials/MyMaterial,graph"
"\n" "\n"
)); ));
} }
void UWingManualSections::ExpressingTypes() void UWingManualSections::ExpressingTypes()
{ {
WingOut::Stdout.Print(TEXT( WingOut::Stdout.Print(TEXT(
"\n EXPRESSING TYPES:" "\n EXPRESSING TYPES:"
"\n" "\n"
"\n To change the type of a variable, or to express function parameters," "\n To change the type of a variable, or to express function parameters,"
"\n you will use our syntax for types. Here are some valid examples:" "\n you will use our syntax for types. Here are some valid examples:"
"\n" "\n"
"\n Bool, String, Vector, Rotator, HitResult, Actor, Character," "\n Bool, String, Vector, Rotator, HitResult, Actor, Character,"
"\n PlayerController, EBlendMode, EMovementMode, BP_Manny, BP_Quinn," "\n PlayerController, EBlendMode, EMovementMode, BP_Manny, BP_Quinn,"
"\n Array<Int>, Set<String>, Map<Int,Actor>" "\n Array<Int>, Set<String>, Map<Int,Actor>"
"\n Soft<ABP_Manny>, Class<Pawn>, SoftClass<Pawn>" "\n Soft<ABP_Manny>, Class<Pawn>, SoftClass<Pawn>"
"\n" "\n"
"\n Notice that it's 'Actor', not 'AActor'. Type names are not" "\n Notice that it's 'Actor', not 'AActor'. Type names are not"
"\n case-sensitive. When a blueprint like /Game/Testing/BP_Foo" "\n case-sensitive. When a blueprint like /Game/Testing/BP_Foo"
"\n is used as a type, the typename is just BP_Foo. You can search" "\n is used as a type, the typename is just BP_Foo. You can search"
"\n for valid types using the TypeName_Search command." "\n for valid types using the TypeName_Search command."
"\n" "\n"
)); ));
} }
void UWingManualSections::VariableDeclarations() void UWingManualSections::VariableDeclarations()
{ {
WingOut::Stdout.Print(TEXT( WingOut::Stdout.Print(TEXT(
"\n VARIABLE DECLARATIONS:" "\n VARIABLE DECLARATIONS:"
"\n" "\n"
"\n We have our own syntax for variable declarations: a type," "\n We have our own syntax for variable declarations:"
"\n a name, optional flags, and an optional default value," "\n"
"\n always on one line:" "\n kind type name (optional flags) = optional default value"
"\n" "\n"
"\n Array<Actor> Actors" "\n Kind can be:"
"\n Float F (InstanceEditable)" "\n"
"\n String S = This is the default value" "\n blueprint - eg, instance variables"
"\n" "\n input - a function argument or macro input"
"\n The commands Variables_Add, Variables_Modify," "\n output - a function return value or macro output"
"\n and Variables_Remove can be used to edit " "\n local - local variables"
"\n blueprint variables, graph local variables, graph input" "\n"
"\n variables, graph output variables, and custom" "\n Here are some examples:"
"\n event node input variables. Event dispatchers are" "\n"
"\n also graphs, so they too can be edited." "\n input Array<Actor> Actors"
"\n" "\n output Float F (InstanceEditable)"
)); "\n blueprint String S = This is the default value"
"\n"
"\n The commands Variables_Add, Variables_Modify,"
"\n and Variables_Remove can be used to edit "
"\n blueprint variables, graph local variables, graph input"
"\n variables, graph output variables, and custom"
"\n event node input variables. Event dispatchers are"
"\n also graphs, so they too can be edited."
"\n"
));
} }
void UWingManualSections::EscapeSequencesInFNames() void UWingManualSections::EscapeSequencesInFNames()
{ {
WingOut::Stdout.Print(TEXT( WingOut::Stdout.Print(TEXT(
"\n ESCAPE SEQUENCES IN FNAMES:" "\n ESCAPE SEQUENCES IN FNAMES:"
"\n" "\n"
"\n When we output FNames, we use HTML escape sequences for the" "\n When we output FNames, we use HTML escape sequences for the"
"\n following marks: \\\"'(),.:;<=>&, and for certain other characters." "\n following marks: \\\"'(),.:;<=>&, and for certain other characters."
"\n We also translate spaces to periods." "\n We also translate spaces to periods."
"\n" "\n"
"\n When sending FNames to UE Wingman, you *must* escape the marks" "\n When sending FNames to UE Wingman, you *must* escape the marks"
"\n listed above, but you *may* escape any character. To send an FName" "\n listed above, but you *may* escape any character. To send an FName"
"\n with a space in it, either use &#32; or a period." "\n with a space in it, either use &#32; or a period."
"\n" "\n"
)); ));
} }
void UWingManualSections::MaterialEditing() void UWingManualSections::MaterialEditing()
{ {
WingOut::Stdout.Print(TEXT( WingOut::Stdout.Print(TEXT(
"\n MATERIAL EDITING:" "\n MATERIAL EDITING:"
"\n" "\n"
"\n We do not expose material expressions directly. Instead, you" "\n We do not expose material expressions directly. Instead, you"
"\n will be editing the material graph. However, if you Graph_Dump" "\n will be editing the material graph. However, if you Graph_Dump"
"\n a material graph, you will see that the nodes contain" "\n a material graph, you will see that the nodes contain"
"\n properties which actually come from the material expressions." "\n properties which actually come from the material expressions."
"\n You can edit these using Details_Set on the node." "\n You can edit these using Details_Set on the node."
"\n" "\n"
"\n Don't overlook custom HLSL nodes. These can accomplish in\n" "\n Don't overlook custom HLSL nodes. These can accomplish in\n"
"\n a single node what would otherwise take many.\n" "\n a single node what would otherwise take many.\n"
"\n" "\n"
)); ));
} }
void UWingManualSections::NodeContextMenus() void UWingManualSections::NodeContextMenus()
{ {
WingOut::Stdout.Print(TEXT( WingOut::Stdout.Print(TEXT(
"\n NODE CONTEXT MENUS:" "\n NODE CONTEXT MENUS:"
"\n" "\n"
"\n GraphNode_ShowMenu and GraphNode_ChooseMenu give access" "\n GraphNode_ShowMenu and GraphNode_ChooseMenu give access"
"\n to the node context menu. This menu includes both node" "\n to the node context menu. This menu includes both node"
"\n operations and pin operations (e.g. Split Struct Pin," "\n operations and pin operations (e.g. Split Struct Pin,"
"\n Add Pin)." "\n Add Pin)."
"\n" "\n"
)); ));
} }
void UWingManualSections::VariableGettersAndSetters() void UWingManualSections::VariableGettersAndSetters()
{ {
WingOut::Stdout.Print(TEXT( WingOut::Stdout.Print(TEXT(
"\n VARIABLE GETTERS AND SETTERS:" "\n VARIABLE GETTERS AND SETTERS:"
"\n" "\n"
"\n Access to local vars, function parameters, and " "\n Access to local vars, function parameters, and "
"\n blueprint vars is through getter and setter nodes. " "\n blueprint vars is through getter and setter nodes. "
"\n These can be found in GraphNode_SearchTypes by " "\n These can be found in GraphNode_SearchTypes by "
"\n searching for 'Variable'. Some examples:" "\n searching for 'Variable'. Some examples:"
"\n" "\n"
"\n SKEL_WB_Menu_C|Variables|Default|GetPlaceTangible" "\n SKEL_WB_Menu_C|Variables|Default|GetPlaceTangible"
"\n SKEL_WB_Menu_C|Variables|Default|SetPlaceTangible" "\n SKEL_WB_Menu_C|Variables|Default|SetPlaceTangible"
"\n SKEL_WB_Menu_C|Variables|WB_Menu|GetMenuPanel" "\n SKEL_WB_Menu_C|Variables|WB_Menu|GetMenuPanel"
"\n" "\n"
)); ));
}
void UWingManualSections::BestPerformance()
{
WingOut::Stdout.Print(TEXT(
"\n BEST PERFORMANCE:"
"\n"
"\n UE Wingman is much faster than the LLM. Therefore, it is"
"\n advantageous to batch: chain multiple ue-wingman commands"
"\n together using bash semicolon."
"\n"
));
} }
void UWingManualSections::ImportantCommands() void UWingManualSections::ImportantCommands()
{ {
WingOut::Stdout.Print(TEXT( WingOut::Stdout.Print(TEXT(
"\n IMPORTANT COMMANDS:" "\n IMPORTANT COMMANDS:"
"\n" "\n"
"\n Documentation_Manual: print manual sections" "\n Documentation_Manual: print the entire manual"
"\n Documentation_Commands: a list of all the main commands" "\n Documentation_Section: print a single section of the manual"
"\n Documentation_CreateAssets: Additional commands that create new assets" "\n Documentation_Commands: print concise list of all ue-wingman commands"
"\n Blueprint_Dump: a summary of any blueprint" "\n Documentation_Command: detailed documentation for a single ue-wingman command"
"\n Graph_Dump: a fairly detailed listing of any Graph" "\n Documentation_CreateAssets: list of commands that create new assets"
"\n Details_Dump: Dump the details panel for a given object" "\n Blueprint_Dump: a summary of any blueprint"
"\n Details_Set: Manipulate the details panel for a given object" "\n Graph_Dump: a fairly detailed listing of any Graph"
"\n" "\n Details_Dump: Dump the details panel for a given object"
"\n You can use Documentation_Commands(Query=Command,Verbose=true)" "\n Details_Set: Manipulate the details panel for a given object"
"\n to get detailed help for a specific command." "\n"
"\n" ));
));
} }
TSet<FName> WingManual::GetSections() TSet<FName> WingManual::GetSections()
@@ -231,7 +247,7 @@ TSet<FName> WingManual::GetSections()
void WingManual::PrintSectionNames(const TCHAR *Prefix, const TSet<FName>& Sections, WingOut Output) void WingManual::PrintSectionNames(const TCHAR *Prefix, const TSet<FName>& Sections, WingOut Output)
{ {
if (Sections.IsEmpty()) return; if (Sections.IsEmpty()) return;
if (Prefix) Output.Print(Prefix); if (Prefix) Output.Print(Prefix);
bool bFirst = true; bool bFirst = true;
for (const FName& Section : Sections) for (const FName& Section : Sections)
{ {
@@ -239,7 +255,7 @@ void WingManual::PrintSectionNames(const TCHAR *Prefix, const TSet<FName>& Secti
bFirst = false; bFirst = false;
Output.Printf(TEXT("%s"), *Section.ToString()); Output.Printf(TEXT("%s"), *Section.ToString());
} }
if (Prefix) Output.Print(TEXT("\n")); if (Prefix) Output.Print(TEXT("\n"));
} }
bool WingManual::PrintSection(FName Section) bool WingManual::PrintSection(FName Section)
@@ -255,10 +271,12 @@ void WingManual::Commands(EWingHandlerKind Kind, const FString& Query, bool Verb
FString QueryLower = Query.ToLower(); FString QueryLower = Query.ToLower();
FString PrevGroup; FString PrevGroup;
bool any = false;
for (const FWingHandlerConfig& H : UWingServer::AllHandlers()) for (const FWingHandlerConfig& H : UWingServer::AllHandlers())
{ {
if (H.Kind != Kind) continue; if (H.Kind != Kind) continue;
if (!H.Name.ToLower().Contains(QueryLower)) continue; if (!H.Name.ToLower().Contains(QueryLower)) continue;
any = true;
// Blank line between groups // Blank line between groups
if (!Verbose) if (!Verbose)
@@ -277,4 +295,9 @@ void WingManual::Commands(EWingHandlerKind Kind, const FString& Query, bool Verb
else else
PrintHandlerPrototype(H); PrintHandlerPrototype(H);
} }
if (!any)
{
WingOut::Stdout.Print(TEXT("No matching commands. To see a full list, type:\n"));
WingOut::Stdout.Print(TEXT(" ue-wingman Documentation_Commands.\n"));
}
} }

View File

@@ -265,7 +265,7 @@ bool FWingParameterEditor::AddOverride(
// Parse the value string. // Parse the value string.
FWingParameterEditor Editor; FWingParameterEditor Editor;
if (!FWingProperty(GS->Property, &Editor, true).SetText(StrVal, Errors)) return false; if (!FWingProperty(nullptr, &Editor, GS->Property, true).SetText(StrVal, Errors)) return false;
Meta->Value = GS->Getter(Editor); Meta->Value = GS->Getter(Editor);
// Apply the update. // Apply the update.
@@ -308,7 +308,7 @@ void FWingParameterEditor::Print(const Info &ID, const Metadata &Meta)
} }
FWingParameterEditor Editor; FWingParameterEditor Editor;
GS->Setter(Editor, Meta.Value); GS->Setter(Editor, Meta.Value);
FString StrVal = FWingProperty(GS->Property, &Editor, false).GetText(); FString StrVal = FWingProperty(nullptr, &Editor, GS->Property, false).GetText();
WingOut::Stdout.Printf(TEXT(" %s %s\n"), WingOut::Stdout.Printf(TEXT(" %s %s\n"),
*StringID(ID), *StrVal); *StringID(ID), *StrVal);
} }

View File

@@ -194,57 +194,6 @@ bool FWingProperty::SetText(FString Value, WingOut Errors) const
return true; return true;
} }
bool FWingProperty::SetJson(const FJsonValue &JsonValue, WingOut Errors) const
{
if (!CheckEditable(Errors)) return false;
if (JsonValue.Type == EJson::String)
{
return SetText(JsonValue.AsString(), Errors);
}
if (JsonValue.Type == EJson::Number)
{
return SetDouble(JsonValue.AsNumber(), Errors);
}
if (JsonValue.Type == EJson::Boolean)
{
return SetBool(JsonValue.AsBool(), Errors);
}
if (JsonValue.Type == EJson::Object)
{
FStructProperty* StructProp = CastField<FStructProperty>(Prop);
if (StructProp && (StructProp->Struct == FWingJsonObject::StaticStruct()))
{
FWingJsonObject Val;
Val.Json = JsonValue.AsObject();
Prop->SetValue_InContainer(Container, &Val);
return true;
}
PrintExpectsReceived(TEXT("json object"), Errors);
return false;
}
if (JsonValue.Type == EJson::Array)
{
FStructProperty* StructProp = CastField<FStructProperty>(Prop);
if (StructProp && (StructProp->Struct == FWingJsonArray::StaticStruct()))
{
FWingJsonArray Val;
Val.Array = JsonValue.AsArray();
Prop->SetValue_InContainer(Container, &Val);
return true;
}
PrintExpectsReceived(TEXT("json array"), Errors);
return false;
}
PrintExpectsReceived(TEXT("Unrecognized Json Data"), Errors);
return false;
}
TOptional<UObject*> FWingProperty::GetObject(WingOut Errors) const TOptional<UObject*> FWingProperty::GetObject(WingOut Errors) const
{ {
FObjectPropertyBase *OProp = CastField<FObjectPropertyBase>(Prop); FObjectPropertyBase *OProp = CastField<FObjectPropertyBase>(Prop);
@@ -341,7 +290,11 @@ FString FWingProperty::GetText() const
return TEXT("None"); return TEXT("None");
} }
FString Result; FString Result;
Prop->ExportTextItem_InContainer(Result, Container, nullptr, nullptr, PPF_None); // DefaultValue == PropertyValue makes ExportText_Direct's Data==Delta
// short-circuit fire for every subfield, so default-valued fields still
// get emitted (e.g. SizeRule=Automatic on FSlateChildSize).
const void* Value = Prop->ContainerPtrToValuePtr<void>(Container);
Prop->ExportTextItem_Direct(Result, Value, /*DefaultValue=*/Value, nullptr, PPF_None);
return Result; return Result;
} }
@@ -374,25 +327,25 @@ FString FWingProperty::GetCategory() const
return Result; return Result;
} }
TArray<FWingProperty> FWingProperty::GetAll(FWingStructAndUStruct Obj) TArray<FWingProperty> FWingProperty::GetAll(UObject *Obj, void *Container, UStruct *Struct, bool Mutable)
{ {
TArray<FWingProperty> Result; TArray<FWingProperty> Result;
for (TFieldIterator<FProperty> It(Obj.UStructPtr); It; ++It) for (TFieldIterator<FProperty> It(Struct); It; ++It)
{ {
bool Editable = !It->HasAnyPropertyFlags(CPF_EditConst); bool Editable = Mutable && !It->HasAnyPropertyFlags(CPF_EditConst);
Result.Add(FWingProperty(*It, Obj.StructPtr, Editable)); Result.Emplace(Obj, Container, *It, Editable);
} }
return Result; return Result;
} }
TArray<FWingProperty> FWingProperty::GetVisible(FWingStructAndUStruct Obj) TArray<FWingProperty> FWingProperty::GetVisible(UObject *Obj, void *Container, UStruct *Struct, bool Mutable)
{ {
TArray<FWingProperty> Result; TArray<FWingProperty> Result;
for (TFieldIterator<FProperty> It(Obj.UStructPtr); It; ++It) for (TFieldIterator<FProperty> It(Struct); It; ++It)
{ {
if (!It->HasAllPropertyFlags(CPF_Edit)) continue; if (!It->HasAllPropertyFlags(CPF_Edit)) continue;
bool Editable = !It->HasAnyPropertyFlags(CPF_EditConst); bool Editable = Mutable && !It->HasAnyPropertyFlags(CPF_EditConst);
Result.Add(FWingProperty(*It, Obj.StructPtr, Editable)); Result.Emplace(Obj, Container, *It, Editable);
} }
return Result; return Result;
} }
@@ -432,10 +385,8 @@ TArray<FWingProperty> FWingProperty::GetDetails(UObject* Obj, bool Mutable)
// of the struct instead. Propagate editability of the host. // of the struct instead. Propagate editability of the host.
if (UWingStructRef *SP = Cast<UWingStructRef>(Obj)) if (UWingStructRef *SP = Cast<UWingStructRef>(Obj))
{ {
TArray<FWingProperty> Result = return GetVisible(SP->Object, SP->StructBase,
GetVisible(FWingStructAndUStruct(SP->StructBase, SP->StructType)); SP->StructType, Mutable && SP->Editable);
if (!Mutable || (!SP->Editable)) StripEditable(Result);
return Result;
} }
// Blueprints don't have editable properties. So // Blueprints don't have editable properties. So
@@ -461,7 +412,7 @@ TArray<FWingProperty> FWingProperty::GetDetails(UObject* Obj, bool Mutable)
} }
} }
TArray<FWingProperty> Result = GetVisible(Obj); TArray<FWingProperty> Result = GetVisible(Obj, Mutable);
// If it's a Material Graph node, also collect properties from // If it's a Material Graph node, also collect properties from
// the associated material expression. // the associated material expression.
@@ -470,7 +421,7 @@ TArray<FWingProperty> FWingProperty::GetDetails(UObject* Obj, bool Mutable)
{ {
if (UMaterialExpression* Expr = MatNode->MaterialExpression) if (UMaterialExpression* Expr = MatNode->MaterialExpression)
{ {
Result.Append(GetVisible(Expr)); Result.Append(GetVisible(Expr, Mutable));
} }
} }
@@ -482,66 +433,63 @@ TArray<FWingProperty> FWingProperty::GetDetails(UObject* Obj, bool Mutable)
FWingProperty::Remove(Result, TEXT("Slot")); FWingProperty::Remove(Result, TEXT("Slot"));
if (UPanelSlot* Slot = Widget->Slot) if (UPanelSlot* Slot = Widget->Slot)
{ {
Result.Append(GetVisible(Slot)); Result.Append(GetVisible(Slot, Mutable));
} }
FProperty *VarProp = Widget->GetClass()->FindPropertyByName(TEXT("bIsVariable")); FProperty *VarProp = Widget->GetClass()->FindPropertyByName(TEXT("bIsVariable"));
if (VarProp) Result.Add(FWingProperty(VarProp, Widget, true)); if (VarProp) Result.Emplace(Widget, Widget, VarProp, true);
} }
if (!Mutable) StripEditable(Result);
return Result; return Result;
} }
bool FWingProperty::PopulateFromArgv(TArray<FWingProperty>& Props, TConstArrayView<FString> Argv, WingOut Errors)
bool FWingProperty::PopulateFromJson(TArray<FWingProperty>& Props, const FJsonObject& Json, bool AllOptional, WingOut Errors)
{ {
bool Ok = true; int32 ArgIndex = 0;
for (int32 PropIndex = 0; PropIndex < Props.Num(); ++PropIndex)
// Build a set of known property names for the unknown-field check.
TSet<FName> KnownKeys;
for (const FWingProperty& P : Props) KnownKeys.Add(P->GetFName());
// Check for unknown fields in the JSON
for (const auto& KV : Json.Values)
{ {
FName Name = WingUtils::CheckInternalizeID(KV.Key, Errors); FWingProperty& P = Props[PropIndex];
if (!KnownKeys.Contains(Name)) FStructProperty* StructProp = CastField<FStructProperty>(P.Prop);
{ const bool bIsRest =
Errors.Printf(TEXT("ERROR: Unknown parameter '%s'\n"), *KV.Key); StructProp && (StructProp->Struct == FWingRestOfArgv::StaticStruct());
Ok = false;
}
}
// Populate each property from JSON if (bIsRest)
for (FWingProperty& P : Props)
{
FString JsonKey = WingUtils::FormatName(P.Prop);
TSharedPtr<FJsonValue> Value = Json.TryGetField(JsonKey);
if (!Value)
{ {
bool Optional = AllOptional || P.Prop->HasMetaData(TEXT("Optional")); if (PropIndex + 1 != Props.Num())
if (!Optional)
{ {
Errors.Printf(TEXT("ERROR: Missing required parameter '%s'\n"), *JsonKey); Errors.Printf(TEXT("ERROR: '%s' must be the last parameter\n"),
Ok = false; *WingUtils::FormatName(P.Prop));
return false;
} }
FWingRestOfArgv Rest;
for (int32 I = ArgIndex; I < Argv.Num(); ++I)
{
Rest.Argv.Add(Argv[I]);
}
P.Prop->SetValue_InContainer(P.Container, &Rest);
ArgIndex = Argv.Num();
continue; continue;
} }
if (!P.SetJson(*Value, Errors)) Ok = false;
}
return Ok;
}
bool FWingProperty::PopulateFromJson(TArray<FWingProperty>& Props, const FJsonValue& Json, bool AllOptional, WingOut Errors) if (ArgIndex >= Argv.Num())
{ {
// Make sure they passed in a JSON object. Errors.Printf(TEXT("ERROR: Missing parameter '%s'\n"),
TSharedPtr<FJsonObject> Obj = Json.AsObject(); *WingUtils::FormatName(P.Prop));
if (Obj == nullptr) return false;
}
if (!P.SetText(Argv[ArgIndex], Errors)) return false;
ArgIndex++;
}
if (ArgIndex < Argv.Num())
{ {
Errors.Printf(TEXT("property data should be stored in a json object\n")); Errors.Printf(TEXT("ERROR: Too many parameters, starting with '%s'\n"),
*Argv[ArgIndex]);
return false; return false;
} }
return PopulateFromJson(Props, *Obj, AllOptional, Errors);
return true;
} }
@@ -576,11 +524,6 @@ bool FWingProperty::CheckImportTextResult(const FString &Value, WingOut Errors)
return true; return true;
} }
void FWingProperty::StripEditable(TArray<FWingProperty> &Props)
{
for (FWingProperty &Elt : Props) Elt.Editable = false;
}
bool FWingProperty::CheckEditable(WingOut Errors) const bool FWingProperty::CheckEditable(WingOut Errors) const
{ {
if (!Editable) if (!Editable)

View File

@@ -4,6 +4,7 @@
#include "UObject/StrongObjectPtr.h" #include "UObject/StrongObjectPtr.h"
#include "AssetRegistry/AssetRegistryModule.h" #include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h" #include "AssetRegistry/IAssetRegistry.h"
#include "Misc/CoreDelegates.h"
#include "Misc/OutputDeviceRedirector.h" #include "Misc/OutputDeviceRedirector.h"
#include "Serialization/JsonReader.h" #include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h" #include "Serialization/JsonSerializer.h"
@@ -55,8 +56,7 @@ void UWingServer::Initialize(FSubsystemCollectionBase& Collection)
return; return;
} }
BuildWingHandlerRegistry(); LoadingPhasesCompleteHandle = FCoreDelegates::OnAllModuleLoadingPhasesComplete.AddUObject(this, &UWingServer::BuildWingHandlerRegistry);
ModulesChangedHandle = FModuleManager::Get().OnModulesChanged().AddUObject(this, &UWingServer::OnModulesChanged);
LogCapture.bEnabled = false; LogCapture.bEnabled = false;
GLog->AddOutputDevice(&LogCapture); GLog->AddOutputDevice(&LogCapture);
bRunning = true; bRunning = true;
@@ -65,7 +65,7 @@ void UWingServer::Initialize(FSubsystemCollectionBase& Collection)
void UWingServer::Deinitialize() void UWingServer::Deinitialize()
{ {
FModuleManager::Get().OnModulesChanged().Remove(ModulesChangedHandle); FCoreDelegates::OnAllModuleLoadingPhasesComplete.Remove(LoadingPhasesCompleteHandle);
if (!bRunning) if (!bRunning)
{ {
@@ -81,7 +81,7 @@ void UWingServer::Deinitialize()
bShuttingDown = true; bShuttingDown = true;
for (auto& Msg : PendingMessages) for (auto& Msg : PendingMessages)
{ {
Msg->Response.SetValue(FString()); Msg->Response.SetValue(TArray<uint8>());
} }
PendingMessages.Empty(); PendingMessages.Empty();
} }
@@ -150,7 +150,7 @@ void UWingServer::Tick(float DeltaTime)
// If we have a request, process it. // If we have a request, process it.
if (Request.IsValid()) if (Request.IsValid())
{ {
FString Response = HandleRequest(Request->Line); TArray<uint8> Response = HandleRequest(Request->Request);
Request->Response.SetValue(Response); Request->Response.SetValue(Response);
} }
} }
@@ -169,71 +169,24 @@ TStatId UWingServer::GetStatId() const
// HandleRequest — Given a command, execute it. // HandleRequest — Given a command, execute it.
// ============================================================ // ============================================================
FString UWingServer::HandleRequest(const FString& Line) TArray<uint8> UWingServer::HandleRequest(const TArray<uint8>& RequestBytes)
{ {
// Parse the request as JSON before doing anything else. TArray<FString> Argv;
TSharedPtr<FJsonValue> Value; FString ResponseText;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Line);
if (!FJsonSerializer::Deserialize(Reader, Value))
return PackageResponses({TEXT("Invalid Json")});
const TSharedPtr<FJsonObject>* RequestPtr = nullptr;
if (!Value->TryGetObject(RequestPtr))
return PackageResponses({TEXT("Json must be an object")});
TSharedPtr<FJsonObject> Request = *RequestPtr;
FString Command; if (DeserializeArgv(RequestBytes, Argv))
Request->TryGetStringField(TEXT("command"), Command);
if (Command == TEXT("Sequence"))
{ {
const TArray<TSharedPtr<FJsonValue>>* Subcommands = nullptr; PreCallHandler();
if (!Request->TryGetArrayField(TEXT("subcommands"), Subcommands)) TryCallHandler(Argv);
return PackageResponses({TEXT("Sequence requires a 'subcommands' array.")}); ResponseText = PostCallHandler();
TArray<FString> Responses;
Responses.Reserve(Subcommands->Num());
for (const TSharedPtr<FJsonValue>& Sub : *Subcommands)
{
const TSharedPtr<FJsonObject>* SubObjPtr = nullptr;
if (!Sub->TryGetObject(SubObjPtr))
Responses.Add(TEXT("Subcommand must be a JSON object."));
else
Responses.Add(HandleJsonRequest(*SubObjPtr));
}
return PackageResponses(Responses);
} }
else ResponseText = TEXT("Invalid argv encoding (bug in ue-wingman.py)\n");
return PackageResponses({HandleJsonRequest(Request)}); FTCHARToUTF8 Utf8(*ResponseText);
return TArray<uint8>(reinterpret_cast<const uint8*>(Utf8.Get()), Utf8.Length());
} }
FString UWingServer::PackageResponses(const TArray<FString>& Responses) void UWingServer::PreCallHandler()
{
TArray<TSharedPtr<FJsonValue>> Blocks;
Blocks.Reserve(Responses.Num());
for (const FString& Response : Responses)
{
// Unreal's JSON writer terminates string serialization at the first
// embedded null byte rather than escaping it, which would silently
// truncate output. Sanitize null bytes to spaces.
FString Sanitized = Response;
for (int32 i = 0; i < Sanitized.Len(); ++i)
{
if (Sanitized[i] == TEXT('\0')) Sanitized[i] = TEXT(' ');
}
TSharedPtr<FJsonObject> Block = MakeShared<FJsonObject>();
Block->SetStringField(TEXT("type"), TEXT("text"));
Block->SetStringField(TEXT("text"), Sanitized);
Blocks.Add(MakeShared<FJsonValueObject>(Block));
}
FString OutJson;
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&OutJson);
FJsonSerializer::Serialize(Blocks, Writer);
return OutJson;
}
FString UWingServer::HandleJsonRequest(TSharedPtr<FJsonObject> Request)
{ {
LogCapture.CapturedErrors.Empty(); LogCapture.CapturedErrors.Empty();
LogCapture.bEnabled = true; LogCapture.bEnabled = true;
@@ -241,9 +194,10 @@ FString UWingServer::HandleJsonRequest(TSharedPtr<FJsonObject> Request)
SuggestedManualSections.Empty(); SuggestedManualSections.Empty();
bSuggestHandlerHelp = false; bSuggestHandlerHelp = false;
LastHandler = nullptr; LastHandler = nullptr;
}
TryCallHandler(Request); FString UWingServer::PostCallHandler()
{
Notifier.SendNotifications(); Notifier.SendNotifications();
LogCapture.bEnabled = false; LogCapture.bEnabled = false;
for (const FString& Msg : LogCapture.CapturedErrors) for (const FString& Msg : LogCapture.CapturedErrors)
@@ -269,17 +223,21 @@ FString UWingServer::HandleJsonRequest(TSharedPtr<FJsonObject> Request)
return Result; return Result;
} }
void UWingServer::TryCallHandler(TSharedPtr<FJsonObject> Request) void UWingServer::TryCallHandler(TArrayView<const FString> Argv)
{ {
// Extract the command from the request. FString Command = "Documentation_Manual";
FString Command; if (Argv.Num() > 0)
if (!Request->TryGetStringField(TEXT("command"), Command))
{ {
WingOut::Stdout.Printf(TEXT("Request does not contain 'command' parameter")); Command = Argv[0];
WingOut::Stdout.Printf(TEXT("We recommend sending command='Documentation_Manual'.")); Argv = Argv.RightChop(1);
return;
} }
Request->RemoveField(TEXT("command"));
if ((Command.Equals(TEXT("--help"))) ||
(Command.Equals(TEXT("-help"))) ||
(Command.Equals(TEXT("help"))))
{
Command = "Documentation_Manual";
}
// Find the handler for the specified command. // Find the handler for the specified command.
FWingHandlerConfig* Found = FindHandler(Command); FWingHandlerConfig* Found = FindHandler(Command);
@@ -296,9 +254,9 @@ void UWingServer::TryCallHandler(TSharedPtr<FJsonObject> Request)
UWingHandler* Handler = Cast<UWingHandler>(HandlerObj.Get()); UWingHandler* Handler = Cast<UWingHandler>(HandlerObj.Get());
Handler->Configuration = Found; Handler->Configuration = Found;
// Populate the handler object with the request parameters. // Populate the handler object with argv parameters.
TArray<FWingProperty> Props = FWingProperty::GetVisible(Handler); TArray<FWingProperty> Props = FWingProperty::GetVisible(Handler, true);
if (!FWingProperty::PopulateFromJson(Props, *Request, false, WingOut::Stdout)) if (!FWingProperty::PopulateFromArgv(Props, Argv, WingOut::Stdout))
{ {
UWingServer::SuggestHandlerHelp(); UWingServer::SuggestHandlerHelp();
return; return;
@@ -356,102 +314,105 @@ void UWingServer::CleanupFinishedClients()
void UWingServer::ClientThreadFunc(UWingServer* Server, TSharedPtr<FClientConnection> Client) void UWingServer::ClientThreadFunc(UWingServer* Server, TSharedPtr<FClientConnection> Client)
{ {
constexpr int32 MaxRecvBufBytes = 1024 * 1024;
constexpr int32 MinUnusedRecvSpace = 4096;
FSocket* Socket = Client->Socket; FSocket* Socket = Client->Socket;
TArray<uint8> RecvBuf;
RecvBuf.SetNumUninitialized(MinUnusedRecvSpace);
int32 RecvLen = 0;
WaitForAssetRegistry(); WaitForAssetRegistry();
while (true) TArray<uint8> Request;
if (!ReceiveRequest(Socket, Request))
{ {
FString Request; Client->bDone = true;
if (ExtractRequestFromBuffer(RecvBuf, RecvLen, Request)) return;
{
FString Response;
if (!ProcessRequestOnGameThread(Request, Response))
{
Client->bDone = true;
return;
}
// Write the response back, null-terminated (blocking)
FTCHARToUTF8 Utf8(*Response);
if (!SendAll(Socket, reinterpret_cast<const uint8*>(Utf8.Get()),
Utf8.Length() + 1))
{
Client->bDone = true;
return;
}
continue;
}
if (!ReceiveMoreBytesIntoBuffer(Socket, RecvBuf, RecvLen))
{
break;
}
} }
TArray<uint8> Response;
if (!ProcessRequestOnGameThread(Request, Response))
{
Client->bDone = true;
return;
}
SendAll(Socket, Response.GetData(), Response.Num());
Client->bDone = true; Client->bDone = true;
} }
bool UWingServer::ExtractRequestFromBuffer( uint32 UWingServer::UnpackBigEndian(const uint8 *Data)
TArray<uint8>& RecvBuf, int32& RecvLen, FString& OutRequest)
{ {
const uint8* EndOfRequest = static_cast<const uint8*>( return
memchr(RecvBuf.GetData(), '\0', RecvLen)); ((uint32)Data[0] << 24) |
if (EndOfRequest == nullptr) ((uint32)Data[1] << 16) |
((uint32)Data[2] << 8) |
(uint32)Data[3];
}
bool UWingServer::DeserializeArgv(
const TArray<uint8>& RequestBytes, TArray<FString>& Argv)
{
Argv.Empty();
int32 Offset = 0;
while (Offset < RequestBytes.Num())
{ {
return false; if (RequestBytes.Num() - Offset < 4)
{
Argv.Empty();
return false;
}
uint32 Length = UnpackBigEndian(RequestBytes.GetData() + Offset);
Offset += 4;
if ((uint32)(RequestBytes.Num() - Offset) < Length)
{
Argv.Empty();
return false;
}
Argv.Add(FString::ConstructFromPtrSize(
reinterpret_cast<const UTF8CHAR*>(RequestBytes.GetData() + Offset),
Length));
Offset += (int32)Length;
} }
const int32 MessageLen =
static_cast<int32>(EndOfRequest - RecvBuf.GetData());
OutRequest = FString::ConstructFromPtrSize(
reinterpret_cast<const UTF8CHAR*>(RecvBuf.GetData()), MessageLen);
const int32 RemainingBytes = RecvLen - (MessageLen + 1);
if (RemainingBytes > 0)
{
FMemory::Memmove(
RecvBuf.GetData(),
RecvBuf.GetData() + MessageLen + 1,
RemainingBytes);
}
RecvLen = RemainingBytes;
return true; return true;
} }
bool UWingServer::ReceiveMoreBytesIntoBuffer( bool UWingServer::ReceiveRequest(FSocket* Socket, TArray<uint8>& OutRequest)
FSocket* Socket, TArray<uint8>& RecvBuf, int32& RecvLen)
{ {
constexpr int32 MaxRecvBufBytes = 1024 * 1024; constexpr int32 MaxRecvBufBytes = 1024 * 1024;
constexpr int32 MinUnusedRecvSpace = 4096; constexpr int32 ChunkSize = 8192;
int32 UnusedSpace = RecvBuf.Num() - RecvLen; TArray<uint8> RecvBuf;
if (UnusedSpace < MinUnusedRecvSpace) RecvBuf.Reserve(ChunkSize);
// Unreal's FSocket API is fundamentally broken: recv cannot
// differentiate between a socket that has been cleanly closed
// and a socket that has had an error. So we have no choice
// but to just read until recv returns false (which could be a
// clean close or an error). Then, we check if we have a cleanly
// encoded payload: if so, we assume everything is fine.
while (true)
{ {
if (RecvBuf.Num() >= MaxRecvBufBytes) uint8 Temp[ChunkSize];
int32 BytesRead = 0;
if (!Socket->Recv(Temp, ChunkSize, BytesRead))
{
break;
}
if (BytesRead <= 0) break;
if (RecvBuf.Num() + BytesRead > MaxRecvBufBytes)
{ {
return false; return false;
} }
RecvBuf.SetNumUninitialized(RecvBuf.Num() * 2); RecvBuf.Append(Temp, BytesRead);
UnusedSpace = RecvBuf.Num() - RecvLen;
} }
int32 BytesRead = 0; if (RecvBuf.Num() < 4) return false;
if (!Socket->Recv(RecvBuf.GetData() + RecvLen, UnusedSpace, BytesRead)) uint32 Size = UnpackBigEndian(RecvBuf.GetData());
{ if ((uint32)RecvBuf.Num() != (4u + Size)) return false;
return false; RecvBuf.RemoveAt(0, 4);
}
if (BytesRead <= 0)
{
return false;
}
RecvLen += BytesRead; OutRequest = MoveTemp(RecvBuf);
return true; return true;
} }
@@ -471,13 +432,13 @@ bool UWingServer::SendAll(FSocket* Socket, const uint8* Data, int32 BytesToSend)
} }
bool UWingServer::ProcessRequestOnGameThread( bool UWingServer::ProcessRequestOnGameThread(
const FString& Request, FString& Response) const TArray<uint8>& Request, TArray<uint8>& Response)
{ {
// Enqueue the message for game-thread processing. // Enqueue the message for game-thread processing.
TSharedPtr<UWingServer::FPendingMessage> Msg = TSharedPtr<UWingServer::FPendingMessage> Msg =
MakeShared<UWingServer::FPendingMessage>(); MakeShared<UWingServer::FPendingMessage>();
Msg->Line = Request; Msg->Request = Request;
TFuture<FString> Future = Msg->Response.GetFuture(); TFuture<TArray<uint8>> Future = Msg->Response.GetFuture();
{ {
FScopeLock Lock(&GWingServer->Mutex); FScopeLock Lock(&GWingServer->Mutex);
@@ -534,11 +495,6 @@ void UWingServer::BuildWingHandlerRegistry()
WingHandlerRegistry.Sort([](const FWingHandlerConfig& A, const FWingHandlerConfig& B) { return A.Name < B.Name; }); WingHandlerRegistry.Sort([](const FWingHandlerConfig& A, const FWingHandlerConfig& B) { return A.Name < B.Name; });
} }
void UWingServer::OnModulesChanged(FName ModuleName, EModuleChangeReason Reason)
{
BuildWingHandlerRegistry();
}
FWingHandlerConfig* UWingServer::FindHandler(const FString& Name) FWingHandlerConfig* UWingServer::FindHandler(const FString& Name)
{ {
int32 Index = Algo::LowerBoundBy(WingHandlerRegistry, Name, [](const FWingHandlerConfig& H) { return H.Name; }); int32 Index = Algo::LowerBoundBy(WingHandlerRegistry, Name, [](const FWingHandlerConfig& H) { return H.Name; });

View File

@@ -82,7 +82,7 @@ bool WingVariableList::CheckSanity(const TSet<FName> &GoodFlags, bool Allow, Win
{ {
if ((!Allow) && (!Variables.IsEmpty())) if ((!Allow) && (!Variables.IsEmpty()))
{ {
Errors.Printf(TEXT("In this context, %s must be empty."), ListName); Errors.Printf(TEXT("This object does not support %s.\n"), ListName);
return false; return false;
} }
for (const Var &Variable : Variables) for (const Var &Variable : Variables)
@@ -112,116 +112,6 @@ bool WingVariableList::CheckSanity(const TSet<FName> &GoodFlags, bool Allow, Win
return true; return true;
} }
bool WingVariableList::ParseString(const FString &Input, WingOut Errors)
{
Variables.Empty();
TArray<FString> Lines;
Input.ParseIntoArrayLines(Lines);
for (const FString& Line : Lines)
{
WingTokenizer Tok(Line);
if (Tok.NextType() == 0) continue;
Var V;
V.DefaultSpecified = false;
if (!ParseOneVariable(Tok, V, Errors)) return false;
Variables.Add(MoveTemp(V));
}
return true;
}
bool WingVariableList::ParseNamesString(const FString &Input, WingOut Errors)
{
Variables.Empty();
WingTokenizer Tok(Input);
while (Tok.TokenIs(Tok.Identifier))
{
FName Name = Tok.NextName();
Var V;
V.Name = Name;
Variables.Add(V);
V.DefaultSpecified = false;
Tok.Advance();
if (Tok.TokenIs(',')) Tok.Advance();
}
if (!Tok.TokenIs(0))
{
Tok.SaveCursor(NAME_None);
Errors.Printf(TEXT("Unexpected token %s in variable list"),
*FString(Tok.GetRange(NAME_None, 1)));
return false;
}
return true;
}
bool WingVariableList::ParseOneVariable(WingTokenizer &Tok, Var &V, WingOut Errors)
{
// Parse type.
UWingTypes::Requirements Req;
Req.BlueprintType = true;
Req.Blueprintable = false;
Req.AllowContainer = true;
if (!UWingTypes::TextToType(Tok, V.Type, Req, false, Errors))
return false;
// Parse name.
if (Tok.NextType() != Tok.Identifier)
{
Errors.Print(TEXT("ERROR: Expected variable name after type\n"));
return false;
}
V.Name = Tok.NextName();
Tok.Advance();
// Parse optional flags: (flag1, flag2, ...)
if (Tok.TokenIs('('))
{
if (!ParseVariableFlags(Tok, V.Flags, Errors)) return false;
}
// Parse optional default value: = rest-of-line
if (Tok.NextType() == Tok.RestOfLine)
{
V.DefaultSpecified = true;
V.DefaultValue = FString(Tok.NextRest().TrimStartAndEnd());
Tok.Advance();
}
// Should be at end of line.
if (Tok.NextType() != 0)
{
Tok.SaveCursor(NAME_None);
Errors.Printf(TEXT("ERROR: Unexpected token after variable declaration: '%s'\n"),
*FString(Tok.GetRange(NAME_None, 1)));
return false;
}
return true;
}
bool WingVariableList::ParseVariableFlags(WingTokenizer &Tok, TSet<FName> &Out, WingOut Errors)
{
Tok.Advance(); // Step over open-paren
while (Tok.TokenIs(Tok.Identifier))
{
Out.Add(Tok.NextName());
Tok.Advance();
// Commas are optional.
if (Tok.TokenIs(',')) Tok.Advance();
}
if (!Tok.TokenIs(')'))
{
Tok.SaveCursor(NAME_None);
Errors.Printf(TEXT("ERROR: flag list contains invalid token '%s'\n"),
*FString(Tok.GetRange(NAME_None, 1)));
return false;
}
Tok.Advance(); // Step over close-paren
return true;
}
void WingVariables::Empty() void WingVariables::Empty()
{ {
BlueprintVariables.Empty(); BlueprintVariables.Empty();
@@ -254,6 +144,107 @@ void WingVariables::Print(WingOut Out)
OutputVariables.Print(Out); OutputVariables.Print(Out);
} }
WingVariableList *WingVariables::GetList(FName Name)
{
if (Name == TEXT("blueprint")) return &BlueprintVariables;
if (Name == TEXT("input")) return &InputVariables;
if (Name == TEXT("output")) return &OutputVariables;
if (Name == TEXT("local")) return &LocalVariables;
return nullptr;
}
bool WingVariables::ParseOneVariable(WingTokenizer &Tok, FName &Kind, Var &V, bool NameOnly, WingOut Errors)
{
// Parse Kind.
if (GetList(Tok.NextName()) == nullptr)
{
Errors.Print(TEXT("ERROR: Variable description should start with 'blueprint', 'input', 'output', or 'local'"));
return false;
}
Kind = Tok.NextName();
Tok.Advance();
// Parse type.
if (!NameOnly)
{
UWingTypes::Requirements Req;
Req.BlueprintType = true;
Req.Blueprintable = false;
Req.AllowContainer = true;
if (!UWingTypes::TextToType(Tok, V.Type, Req, false, Errors))
return false;
}
// Parse name.
if (Tok.NextType() != Tok.Identifier)
{
Errors.Print(TEXT("ERROR: Expected variable name after type\n"));
return false;
}
V.Name = Tok.NextName();
Tok.Advance();
// Parse optional flags: (flag1, flag2, ...)
if ((!NameOnly) && Tok.TokenIs('('))
{
if (!ParseVariableFlags(Tok, V.Flags, Errors)) return false;
}
// Parse optional default value: = rest-of-line
if (!NameOnly && (Tok.NextType() == Tok.RestOfLine))
{
V.DefaultSpecified = true;
V.DefaultValue = FString(Tok.NextRest().TrimStartAndEnd());
Tok.Advance();
}
// Should be at end of line.
if (Tok.NextType() != 0)
{
Tok.SaveCursor(NAME_None);
Errors.Printf(TEXT("ERROR: Unexpected token after variable declaration: '%s'\n"),
*FString(Tok.GetRange(NAME_None, 1)));
return false;
}
return true;
}
bool WingVariables::ParseVariableFlags(WingTokenizer &Tok, TSet<FName> &Out, WingOut Errors)
{
Tok.Advance(); // Step over open-paren
while (Tok.TokenIs(Tok.Identifier))
{
Out.Add(Tok.NextName());
Tok.Advance();
// Commas are optional.
if (Tok.TokenIs(',')) Tok.Advance();
}
if (!Tok.TokenIs(')'))
{
Tok.SaveCursor(NAME_None);
Errors.Printf(TEXT("ERROR: flag list contains invalid token '%s'\n"),
*FString(Tok.GetRange(NAME_None, 1)));
return false;
}
Tok.Advance(); // Step over close-paren
return true;
}
bool WingVariables::Parse(const TArray<FString> &Vars, bool NameOnly, WingOut Errors)
{
for (const FString& Onevar : Vars)
{
WingTokenizer Tok(Onevar);
FName Kind;
Var V;
if (!ParseOneVariable(Tok, Kind, V, NameOnly, Errors)) return false;
WingVariableList *List = GetList(Kind);
List->Add(V);
}
return true;
}
void WingVariables::Load(WingOut Errors) void WingVariables::Load(WingOut Errors)
{ {
Empty(); Empty();
@@ -325,7 +316,7 @@ WingVariables::Var WingVariables::LoadBlueprintVariableDescription(FBPVariableDe
FProperty* Prop = CDO->GetClass()->FindPropertyByName(Desc.VarName); FProperty* Prop = CDO->GetClass()->FindPropertyByName(Desc.VarName);
if (Prop) if (Prop)
{ {
Result.DefaultValue = FWingProperty(Prop, CDO, false).GetText(); Result.DefaultValue = FWingProperty(CDO, CDO, Prop, false).GetText();
Result.DefaultSpecified = true; Result.DefaultSpecified = true;
} }
} }
@@ -500,7 +491,7 @@ bool WingVariables::ModifyBlueprintDefaults(WingOut Errors)
*WingTokenizer::ExternalizeID(Input.Name)); *WingTokenizer::ExternalizeID(Input.Name));
return false; return false;
} }
if (!FWingProperty(Prop, CDO, true).SetText(Input.DefaultValue, Errors)) return false; if (!FWingProperty(CDO, CDO, Prop, true).SetText(Input.DefaultValue, Errors)) return false;
} }
} }
return true; return true;

View File

@@ -62,32 +62,18 @@ public:
//////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////
// //
// Json wrappers. // A simple type to store the remaining arguments in
// // an Argv Array.
// Normally, the json request is automatically used to
// populate the properties of the handler, so the handler
// doesn't have to deal with json. However, in a few cases,
// the handler actually does want to see some json. These
// wrappers allow a handler to request raw json data instead
// of pre-processed values.
// //
//////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////
USTRUCT() USTRUCT()
struct FWingJsonObject struct FWingRestOfArgv
{ {
GENERATED_BODY() GENERATED_BODY()
TSharedPtr<FJsonObject> Json;
};
// Marker struct for handler parameters that accept a JSON array. UPROPERTY()
// PopulateFromJson stashes the actual JSON array into the Array field. TArray<FString> Argv;
//
USTRUCT()
struct FWingJsonArray
{
GENERATED_BODY()
TArray<TSharedPtr<FJsonValue>> Array;
}; };
//////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////
@@ -129,38 +115,6 @@ private:
FStringBuilderBase *Buffer; FStringBuilderBase *Buffer;
}; };
////////////////////////////////////////////////////////////
//
// FWingStructAndUStruct.
//
// A pointer to a struct, and also a pointer to a UStruct
// that describes the struct. This also can store a
// UObject and the UClass that describes the UObject.
//
////////////////////////////////////////////////////////////
// A FWingStructAndUStruct is a pointer to a struct and its associated ustruct.
//
struct FWingStructAndUStruct
{
void *StructPtr;
UStruct *UStructPtr;
// Explicit constructor.
explicit FWingStructAndUStruct(void *Base, UStruct *S) : StructPtr(Base), UStructPtr(S) {}
// Copy constructor.
FWingStructAndUStruct(FWingStructAndUStruct &Src) : StructPtr(Src.StructPtr), UStructPtr(Src.UStructPtr) {}
// Construct from a UObject.
FWingStructAndUStruct(UObject *Obj) : StructPtr(Obj), UStructPtr(Obj->GetClass()) {}
// Construct from a UStruct pointer.
template<class T, typename = std::enable_if_t<!std::is_base_of_v<UObject, T>>>
FWingStructAndUStruct(T *Struct) : StructPtr(Struct), UStructPtr(Struct->StaticStruct()) {}
};
//////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////
// //
// References. // References.
@@ -224,11 +178,12 @@ class UWingStructRef : public UObject
public: public:
UPROPERTY() UPROPERTY()
UObject* Object; UObject* Object;
void *StructBase;
UPROPERTY() UPROPERTY()
UStruct* StructType; UStruct* StructType;
void *StructBase;
bool Editable; bool Editable;
}; };

View File

@@ -48,4 +48,7 @@ public:
UFUNCTION() UFUNCTION()
static void VariableGettersAndSetters(); static void VariableGettersAndSetters();
UFUNCTION()
static void BestPerformance();
}; };

View File

@@ -3,20 +3,24 @@
#include "CoreMinimal.h" #include "CoreMinimal.h"
#include "WingBasics.h" #include "WingBasics.h"
// A resolved property: the FProperty descriptor plus a pointer to
// the value's storage.
//
struct FWingProperty struct FWingProperty
{ {
// To understand the following fields, imagine an object
// that contains a struct, which contains another struct,
// which contains another struct, which contains a field F.
// In that case, Object points to the object that
// contains everything, whereas Container points to the
// innermost struct that contains the property.
FProperty* Prop = nullptr; TStrongObjectPtr<UObject> Object = nullptr;
void* Container = nullptr; void* Container = nullptr;
FProperty* Prop = nullptr;
bool Editable = false; bool Editable = false;
// Construct a property reference. // Construct a property reference.
// //
FWingProperty(FProperty* InProp, void* InContainer, bool Edit) FWingProperty(UObject *InObject, void* InContainer, FProperty* InProp, bool Edit)
: Prop(InProp), Container(InContainer), Editable(Edit) {} : Object(InObject), Container(InContainer), Prop(InProp), Editable(Edit) {}
// Construct a null property reference. // Construct a null property reference.
// //
@@ -37,7 +41,6 @@ struct FWingProperty
bool SetInt64(int64 I, WingOut Errors) const; bool SetInt64(int64 I, WingOut Errors) const;
bool SetBool(bool B, WingOut Errors) const; bool SetBool(bool B, WingOut Errors) const;
bool SetText(FString Value, WingOut Errors) const; bool SetText(FString Value, WingOut Errors) const;
bool SetJson(const FJsonValue &Value, WingOut Errors) const;
// Fetch a value. If an error occurs such as a type // Fetch a value. If an error occurs such as a type
// mismatch, returns an empty optional and prints an // mismatch, returns an empty optional and prints an
@@ -72,14 +75,24 @@ struct FWingProperty
// //
// This gets the properties that are literally present in the // This gets the properties that are literally present in the
// specified object or struct. No special interpretation is done. // specified object or struct. No special interpretation is done.
// If mutable is false, all properties will be marked non-editable.
// //
static TArray<FWingProperty> GetAll(FWingStructAndUStruct Obj); static TArray<FWingProperty> GetAll(UObject *Obj, void *Container, UStruct *Struct, bool Mutable);
// Get all the visible properties of the specified object or struct. // Get all the visible properties of the specified object or struct.
// //
// This gets the properties that have CPF_Edit marked on them. // This gets the properties that have CPF_Edit marked on them.
// If mutable is false, all properties will be marked non-editable.
// //
static TArray<FWingProperty> GetVisible(FWingStructAndUStruct Obj); static TArray<FWingProperty> GetVisible(UObject *Obj, void *Container, UStruct *Struct, bool Mutable);
static bool PopulateFromArgv(TArray<FWingProperty>& Props, TConstArrayView<FString> Argv, WingOut Errors);
// Convenience versions of GetAll and GetVisible for UObjects.
//
static TArray<FWingProperty> GetAll(UObject *Obj, bool Mutable)
{ return GetAll(Obj, Obj, Obj->GetClass(), Mutable); }
static TArray<FWingProperty> GetVisible(UObject *Obj, bool Mutable)
{ return GetVisible(Obj, Obj, Obj->GetClass(), Mutable); }
// Get just the names of the properties of the specified struct/class. // Get just the names of the properties of the specified struct/class.
// //
@@ -108,18 +121,7 @@ struct FWingProperty
// //
static TArray<FWingProperty> GetDetails(UObject* Obj, bool Mutable); static TArray<FWingProperty> GetDetails(UObject* Obj, bool Mutable);
// Functions to populate properties from a JSON object.
//
static bool PopulateFromJson(TArray<FWingProperty>& Props, const FJsonObject& Json,
bool AllOptional, WingOut Errors);
static bool PopulateFromJson(TArray<FWingProperty>& Props, const FJsonValue& Json,
bool AllOptional, WingOut Errors);
// Functions to populate properties from a JSON object.
//
private: private:
static void StripEditable(TArray<FWingProperty> &Props);
static bool IsUnsigned(FNumericProperty* Prop); static bool IsUnsigned(FNumericProperty* Prop);
static bool IsPinTypeProperty(FProperty *Prop); static bool IsPinTypeProperty(FProperty *Prop);
void PrintExpectsReceived(const TCHAR *Type, WingOut Errors) const; void PrintExpectsReceived(const TCHAR *Type, WingOut Errors) const;

View File

@@ -62,9 +62,6 @@ public:
static void AddHandler(UObject* Obj, const FString& Name, UObject* Config, EWingHandlerKind Kind, UClass* FactoryClass, const FString& Documentation); static void AddHandler(UObject* Obj, const FString& Name, UObject* Config, EWingHandlerKind Kind, UClass* FactoryClass, const FString& Documentation);
static const TArray<FWingHandlerConfig>& AllHandlers() { return GWingServer->WingHandlerRegistry; } static const TArray<FWingHandlerConfig>& AllHandlers() { return GWingServer->WingHandlerRegistry; }
/** Package a list of response texts into a single serialized JSON content-block array. */
static FString PackageResponses(const TArray<FString>& Responses);
private: private:
static UWingServer* GWingServer; static UWingServer* GWingServer;
@@ -76,14 +73,14 @@ private:
FLogCaptureOutputDevice LogCapture; // installed once at startup, enabled per-request FLogCaptureOutputDevice LogCapture; // installed once at startup, enabled per-request
TArray<FWingHandlerConfig> WingHandlerRegistry; // sorted by Name TArray<FWingHandlerConfig> WingHandlerRegistry; // sorted by Name
void BuildWingHandlerRegistry(); void BuildWingHandlerRegistry();
void OnModulesChanged(FName ModuleName, EModuleChangeReason Reason); FDelegateHandle LoadingPhasesCompleteHandle;
FDelegateHandle ModulesChangedHandle;
FWingHandlerConfig* FindHandler(const FString& Name); FWingHandlerConfig* FindHandler(const FString& Name);
// Handle a complete JSON line and return the response JSON // Handle a complete request and return the response bytes.
FString HandleRequest(const FString& Line); TArray<uint8> HandleRequest(const TArray<uint8>& RequestBytes);
FString HandleJsonRequest(TSharedPtr<FJsonObject> Request); void PreCallHandler();
void TryCallHandler(TSharedPtr<FJsonObject> Request); FString PostCallHandler();
void TryCallHandler(TArrayView<const FString> Argv);
// ----- TCP server ----- // ----- TCP server -----
FSocket* ListenSocket = nullptr; FSocket* ListenSocket = nullptr;
@@ -100,22 +97,23 @@ private:
TArray<TSharedPtr<FClientConnection>> Clients; TArray<TSharedPtr<FClientConnection>> Clients;
void AcceptNewConnections(); void AcceptNewConnections();
void CleanupFinishedClients(); void CleanupFinishedClients();
static uint32 UnpackBigEndian(const uint8 *Data);
static bool DeserializeArgv(
const TArray<uint8>& RequestBytes, TArray<FString>& Argv);
static void ClientThreadFunc(UWingServer* Server, TSharedPtr<FClientConnection> Client); static void ClientThreadFunc(UWingServer* Server, TSharedPtr<FClientConnection> Client);
static bool ExtractRequestFromBuffer( static bool ReceiveRequest(
TArray<uint8>& RecvBuf, int32& RecvLen, FString& OutRequest); FSocket* Socket, TArray<uint8>& OutRequest);
static bool ReceiveMoreBytesIntoBuffer(
FSocket* Socket, TArray<uint8>& RecvBuf, int32& RecvLen);
static bool SendAll(FSocket* Socket, const uint8* Data, int32 BytesToSend); static bool SendAll(FSocket* Socket, const uint8* Data, int32 BytesToSend);
static bool ProcessRequestOnGameThread( static bool ProcessRequestOnGameThread(
const FString& Request, FString& Response); const TArray<uint8>& Request, TArray<uint8>& Response);
static void WaitForAssetRegistry(); static void WaitForAssetRegistry();
// ----- The Critical Section ----- // ----- The Critical Section -----
struct FPendingMessage struct FPendingMessage
{ {
FString Line; TArray<uint8> Request;
TPromise<FString> Response; TPromise<TArray<uint8>> Response;
FPendingMessage() : Response(TPromise<FString>()) {} FPendingMessage() : Response(TPromise<TArray<uint8>>()) {}
}; };
FCriticalSection Mutex; FCriticalSection Mutex;
TArray<TSharedPtr<FPendingMessage>> PendingMessages; TArray<TSharedPtr<FPendingMessage>> PendingMessages;

View File

@@ -57,6 +57,9 @@ public:
// Empty the variable list. // Empty the variable list.
void Empty() { Variables.Empty(); } void Empty() { Variables.Empty(); }
// Add a variable.
void Add(const Var &Var) { Variables.Add(Var); }
// Return true if the variables are empty. // Return true if the variables are empty.
bool IsEmpty() { return Variables.IsEmpty(); } bool IsEmpty() { return Variables.IsEmpty(); }
@@ -72,21 +75,11 @@ public:
// Check the sanity of the vars in the array. If allow // Check the sanity of the vars in the array. If allow
// is false, then no variables are allowed in the array. // is false, then no variables are allowed in the array.
bool CheckSanity(const TSet<FName> &GoodFlags, bool Allow, WingOut Errors); bool CheckSanity(const TSet<FName> &GoodFlags, bool Allow, WingOut Errors);
// Parse variables from a string.
bool ParseString(const FString &Input, WingOut Errors);
// Parse variable names only from a string.
bool ParseNamesString(const FString &Input, WingOut Errors);
private:
bool ParseOneVariable(WingTokenizer &Tok, Var &V, WingOut Errors);
bool ParseVariableFlags(WingTokenizer &Tok, TSet<FName> &Out, WingOut Errors);
}; };
class WingVariables class WingVariables
{ {
public: public:
using Var = WingVariableList::Var; using Var = WingVariableList::Var;
WingVariables() {} WingVariables() {}
@@ -125,6 +118,10 @@ public:
void Print(WingOut Out); void Print(WingOut Out);
// Parse variables.
bool Parse(const TArray<FString> &Vars, bool NameOnly, WingOut Errors);
// Load: clear the workspace, then // Load: clear the workspace, then
// copy everything from the backing store into the workspace. // copy everything from the backing store into the workspace.
@@ -193,4 +190,8 @@ private:
void AddUserPinInfo(const Var &V, EEdGraphPinDirection Dir, UK2Node_EditablePinBase *Node); void AddUserPinInfo(const Var &V, EEdGraphPinDirection Dir, UK2Node_EditablePinBase *Node);
bool ErrorNoBackingStore(WingOut Errors); bool ErrorNoBackingStore(WingOut Errors);
bool ParseVariableFlags(WingTokenizer &Tok, TSet<FName> &Out, WingOut Errors);
bool ParseOneVariable(WingTokenizer &Tok, FName &Kind, Var &V, bool NameOnly, WingOut Errors);
WingVariableList *GetList(FName Name);
}; };

View File

@@ -1,165 +0,0 @@
#!/usr/bin/env python3
"""
MCP stdio-to-TCP bridge for UE Wingman.
Exposes a single MCP tool "unreal" that forwards JSON commands to the
UE Wingman TCP server in the Unreal Editor.
"""
import sys
import json
import socket
HOST = "localhost"
PORT = 9851
CONNECT_TIMEOUT = 2
READ_TIMEOUT = 120
TOOL_DESCRIPTION = (
"Send a command to the Unreal Editor's UE Wingman plugin. "
"The 'command' field specifies which operation to perform; "
"additional fields are command-specific parameters. "
'Use {"command": "Documentation_Manual"} to get an overview. '
"If the editor is not running, the call will return an error; "
"just ask the user to start the editor and try again."
)
TOOL_SCHEMA = {
"name": "unreal",
"description": TOOL_DESCRIPTION,
"inputSchema": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "The command to execute"},
},
"required": ["command"],
"additionalProperties": True,
},
}
sock = None
def connect():
"""Try to connect to the editor. Returns True on success."""
global sock
if sock is not None:
return True
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(CONNECT_TIMEOUT)
s.connect((HOST, PORT))
s.settimeout(READ_TIMEOUT)
sock = s
return True
except (ConnectionRefusedError, socket.timeout, OSError):
return False
def disconnect():
global sock
if sock is not None:
try:
sock.close()
except Exception:
pass
sock = None
def send_and_receive(message):
"""Send a JSON message to the editor and return the null-terminated response."""
data = json.dumps(message) + "\0"
sock.sendall(data.encode())
result = b""
while True:
chunk = sock.recv(65536)
if not chunk:
raise ConnectionError("Connection closed")
result += chunk
if b"\0" in result:
break
return result[:result.index(b"\0")].decode()
def forward_to_editor(arguments):
"""Forward arguments to the editor, return the result dict."""
if not connect():
return {"error": "Unreal Editor is not running. Start the editor and try again."}
try:
return send_and_receive(arguments)
except Exception:
disconnect()
# Retry once in case the connection was stale
if connect():
try:
return send_and_receive(arguments)
except Exception:
disconnect()
return {"error": "Lost connection to Unreal Editor."}
def make_jsonrpc(msg_id, result):
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
def handle_message(msg):
"""Handle one JSON-RPC message from Claude Code."""
msg_id = msg.get("id")
method = msg.get("method", "")
# Notifications don't get responses
if msg_id is None:
return None
if method == "initialize":
return make_jsonrpc(msg_id, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "ue-wingman", "version": "1.0.0"},
})
if method == "tools/list":
return make_jsonrpc(msg_id, {"tools": [TOOL_SCHEMA]})
if method == "tools/call":
params = msg.get("params", {})
arguments = params.get("arguments", {})
result = forward_to_editor(arguments)
if isinstance(result, dict) and "error" in result:
content = [{"type": "text", "text": result["error"]}]
else:
try:
content = json.loads(result)
except json.JSONDecodeError:
content = [{"type": "text", "text": "Malformed response from editor."}]
return make_jsonrpc(msg_id, {
"content": content,
})
return {
"jsonrpc": "2.0",
"id": msg_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
}
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
response = handle_message(msg)
if response is not None:
sys.stdout.write(json.dumps(response) + "\n")
sys.stdout.flush()
if __name__ == "__main__":
main()

View File

@@ -1,37 +1,23 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Human-friendly MCP test client. UE Wingman command-line tool. This tool simply packages up its
argv and sends it to the plugin, and then prints whatever the
plugin sends back. All the real work is done in the plugin.
Usage: ue-wingman.py <command> [key=value ...] Usage: ue-wingman.py <arg1> [arg2 ...]
Values starting with '[' or '{' are parsed as JSON.
""" """
import sys import sys
import json
import socket import socket
import struct
HOST = "localhost" HOST = "localhost"
PORT = 9851 PORT = 9851
TIMEOUT = 120 TIMEOUT = 15
def main(): def main():
args = sys.argv[1:] args = sys.argv[1:]
if not args:
print("Usage: ue-wingman.py <command> [key=value ...]")
sys.exit(1)
msg = {"command": args[0]}
for arg in args[1:]:
key, _, value = arg.partition("=")
if value and value[0] in ('[', '{'):
try:
value = json.loads(value)
except json.JSONDecodeError as e:
print(f"Bad JSON in {key}: {e.msg}")
sys.exit(1)
msg[key] = value
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(TIMEOUT) sock.settimeout(TIMEOUT)
@@ -41,7 +27,14 @@ def main():
print(f"Cannot connect to {HOST}:{PORT} — is the editor running?") print(f"Cannot connect to {HOST}:{PORT} — is the editor running?")
sys.exit(1) sys.exit(1)
sock.sendall((json.dumps(msg) + "\0").encode()) payload = bytearray()
for arg in args:
data = arg.encode()
payload += struct.pack("!I", len(data))
payload += data
sock.sendall(struct.pack("!I", len(payload)))
sock.sendall(payload)
sock.shutdown(socket.SHUT_WR)
result = b"" result = b""
while True: while True:
@@ -49,29 +42,9 @@ def main():
if not chunk: if not chunk:
break break
result += chunk result += chunk
if b"\0" in result:
break
sock.close() sock.close()
result = result[:result.index(b"\0")].decode() if b"\0" in result else result.decode() print(result.decode(), end="")
try:
parsed = json.loads(result)
except json.JSONDecodeError:
print("Error: response is not valid JSON.")
sys.exit(1)
if not isinstance(parsed, list):
print("Error: response is not a list of content blocks.")
sys.exit(1)
for block in parsed:
if not (isinstance(block, dict)
and block.get("type") == "text"
and isinstance(block.get("text"), str)):
print("Error: response contains a non-text block.")
sys.exit(1)
print("\n---\n".join(block["text"] for block in parsed))
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <string_view>
#include "CoreMinimal.h" #include "CoreMinimal.h"
#include "CoreUObject.h" #include "CoreUObject.h"
#include "Containers/Deque.h" #include "Containers/Deque.h"

View File

@@ -125,6 +125,21 @@ enum class ElxLuaSyntaxCheck : uint8 {
InvalidLua, InvalidLua,
}; };
////////////////////////////////////////////////////////////
//
// ElxControllerType
//
// The three types of controller recognized by luprex.
//
////////////////////////////////////////////////////////////
UENUM(BlueprintType)
enum class ElxControllerType : uint8 {
KeyboardMouse,
XboxGamepad,
PlayStationGamepad,
};
//////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////
// //
// Log Categories // Log Categories

View File

@@ -6,6 +6,8 @@
#include "Layout/Geometry.h" #include "Layout/Geometry.h"
#include "Widgets/Layout/Anchors.h" #include "Widgets/Layout/Anchors.h"
#include "Common.h" #include "Common.h"
#include "Engine/GameViewportClient.h"
#include "Slate/SGameLayerManager.h"
#include "Kismet/KismetTextLibrary.h" #include "Kismet/KismetTextLibrary.h"
#include "UObject/UObjectIterator.h" #include "UObject/UObjectIterator.h"
@@ -246,17 +248,32 @@ FFormatArgumentData UlxFormatDataLibrary::FormatArgumentDataTransform(const FTra
FFormatArgumentData UlxFormatDataLibrary::FormatArgumentDataGeometry(const FGeometry &AutoConvertedValue, const FString &Name) FFormatArgumentData UlxFormatDataLibrary::FormatArgumentDataGeometry(const FGeometry &AutoConvertedValue, const FString &Name)
{ {
FVector2D LocalSize = AutoConvertedValue.GetLocalSize(); FVector2D UL = AutoConvertedValue.GetAbsolutePosition();
FVector2D AbsPos = AutoConvertedValue.GetAbsolutePosition(); FVector2D LR = AutoConvertedValue.GetAbsolutePositionAtCoordinates(FVector2f(1.0f, 1.0f));
FVector2D AbsSize = AutoConvertedValue.GetAbsoluteSize(); if (GEngine && GEngine->GameViewport)
{
TSharedPtr<IGameLayerManager> GameLayerManager = GEngine->GameViewport->GetGameLayerManager();
if (GameLayerManager.IsValid())
{
const FGeometry ViewportGeometry = GameLayerManager->GetViewportWidgetHostGeometry();
const FVector2D ViewportLocalSize = FVector2D(ViewportGeometry.GetLocalSize());
FVector2D ViewportPixelSize;
GEngine->GameViewport->GetViewportSize(ViewportPixelSize);
if (ViewportLocalSize.X > 0.0 && ViewportLocalSize.Y > 0.0)
{
const FVector2D PixelScale = ViewportPixelSize / ViewportLocalSize;
UL = ViewportGeometry.AbsoluteToLocal(UL) * PixelScale;
LR = ViewportGeometry.AbsoluteToLocal(LR) * PixelScale;
}
}
}
FFormatArgumentData Result; FFormatArgumentData Result;
Result.ArgumentValueType = EFormatArgumentType::Text; Result.ArgumentValueType = EFormatArgumentType::Text;
Result.ArgumentName = Name; Result.ArgumentName = Name;
Result.ArgumentValue = FText::Format( Result.ArgumentValue = FText::Format(
INVTEXT("Geom(Local={0}x{1} Abs={2}x{3} Pos={4},{5})"), INVTEXT("UL={0},{1} LR={2},{3}"),
FText::AsNumber(LocalSize.X), FText::AsNumber(LocalSize.Y), FText::AsNumber(UL.X), FText::AsNumber(UL.Y),
FText::AsNumber(AbsSize.X), FText::AsNumber(AbsSize.Y), FText::AsNumber(LR.X), FText::AsNumber(LR.Y));
FText::AsNumber(AbsPos.X), FText::AsNumber(AbsPos.Y));
return Result; return Result;
} }
@@ -300,6 +317,12 @@ FFormatArgumentData UlxFormatDataLibrary::FormatArgumentDataEnum(uint8 Value, co
return Result; return Result;
} }
FText UlxFormatDataLibrary::FormatMessageInternal(const FString &InPattern, TArray<FFormatArgumentData> InArgs)
{
FText InPatternText(FText::FromString(InPattern));
return FTextFormatter::Format(MoveTemp(InPatternText), MoveTemp(InArgs), false, false);
}
void UlxFormatDataLibrary::FormatLogMessageInternal(UObject *Context, ElxFormatLogVerbosity Verbosity, const FString &InPattern, TArray<FFormatArgumentData> InArgs) void UlxFormatDataLibrary::FormatLogMessageInternal(UObject *Context, ElxFormatLogVerbosity Verbosity, const FString &InPattern, TArray<FFormatArgumentData> InArgs)
{ {
// For throttled verbosity levels, suppress repeated messages with the // For throttled verbosity levels, suppress repeated messages with the
@@ -312,7 +335,7 @@ void UlxFormatDataLibrary::FormatLogMessageInternal(UObject *Context, ElxFormatL
double Now = FPlatformTime::Seconds(); double Now = FPlatformTime::Seconds();
FString Key = Context->GetClass()->GetName() + TEXT("::") + InPattern; FString Key = Context->GetClass()->GetName() + TEXT("::") + InPattern;
double &Last = LastLogTime.FindOrAdd(Key, 0.0); double &Last = LastLogTime.FindOrAdd(Key, 0.0);
if (Now - Last < 1.0) if (Now - Last < 2.0)
{ {
return; return;
} }
@@ -321,8 +344,7 @@ void UlxFormatDataLibrary::FormatLogMessageInternal(UObject *Context, ElxFormatL
// Generate the formatted string. // Generate the formatted string.
// //
FText InPatternText(FText::FromString(InPattern)); FText Message = FormatMessageInternal(InPattern, MoveTemp(InArgs));
FText Message = FTextFormatter::Format(MoveTemp(InPatternText), MoveTemp(InArgs), false, false);
FString MessageString = Message.ToString(); FString MessageString = Message.ToString();
// Get the blueprint name. // Get the blueprint name.

View File

@@ -105,9 +105,16 @@ public:
// //
static UFunction* GetConverterForPinType(const UEdGraphSchema_K2 *Schema, const FEdGraphPinType& PinType, bool AllowWild); static UFunction* GetConverterForPinType(const UEdGraphSchema_K2 *Schema, const FEdGraphPinType& PinType, bool AllowWild);
// Format a message using FTextFormatter::Format.
// Meant to be used internally by the Format Message K2Node,
// which needs an impure wrapper around formatting.
//
UFUNCTION(BlueprintCallable, meta=(BlueprintInternalUseOnly = "true"))
static FText FormatMessageInternal(const FString &InPattern, TArray<FFormatArgumentData> InArgs);
// Format a message using FTextFormatter::Format, and send // Format a message using FTextFormatter::Format, and send
// it to UE_LOG. The Context object's name is used as the // it to UE_LOG. The Context object's name is used as the
// log category. Meant to be used internally by the Format // log category. Meant to be used internally by the Format
// Log Message K2Node. // Log Message K2Node.
// //
UFUNCTION(BlueprintCallable, meta=(WorldContext = "Context", BlueprintInternalUseOnly = "true")) UFUNCTION(BlueprintCallable, meta=(WorldContext = "Context", BlueprintInternalUseOnly = "true"))

View File

@@ -250,7 +250,7 @@ void UK2Node_FormatMessage::ExpandNode(class FKismetCompilerContext& CompilerCon
} }
else else
{ {
FormatFunction = UKismetTextLibrary::StaticClass()->FindFunctionByName(GET_MEMBER_NAME_CHECKED(UKismetTextLibrary, Format)); FormatFunction = UlxFormatDataLibrary::StaticClass()->FindFunctionByName(GET_MEMBER_NAME_CHECKED(UlxFormatDataLibrary, FormatMessageInternal));
} }
// This is the node that does all the Format work and outputs the message. // This is the node that does all the Format work and outputs the message.

View File

@@ -0,0 +1,105 @@
#include "InputDeviceTracker.h"
#include "Framework/Application/SlateApplication.h"
#include "GenericPlatform/GenericApplicationMessageHandler.h"
#include "InputCoreTypes.h"
// Last observed device classification. Read with no
// synchronization; updates happen on the game thread
// from Slate's input pipeline, and stale reads on
// other threads are acceptable for this use case.
//
static ElxControllerType GLastControllerType = ElxControllerType::KeyboardMouse;
// Keywords identifying a PlayStation-family gamepad.
// Matched case-insensitively against the InputDeviceName
// and HardwareDeviceIdentifier fields of the current
// FInputDeviceScope.
//
static const TCHAR* const PlaystationKeywords[] = {
TEXT("Playstation"),
TEXT("PS3"),
TEXT("PS4"),
TEXT("PS5"),
TEXT("PS6"),
TEXT("PS7"),
TEXT("Dualsense"),
TEXT("Dualshock"),
};
namespace
{
bool ContainsAnyPlaystationKeyword(const FString& Haystack)
{
for (const TCHAR* Keyword : PlaystationKeywords)
{
if (Haystack.Contains(Keyword, ESearchCase::IgnoreCase))
{
return true;
}
}
return false;
}
// Classifies the active gamepad by scanning the current
// FInputDeviceScope. Defaults to Xbox; switches to
// PlayStation only on a keyword match.
//
ElxControllerType ClassifyGamepadFromScope()
{
const FInputDeviceScope* Scope = FInputDeviceScope::GetCurrent();
if (Scope != nullptr)
{
if (ContainsAnyPlaystationKeyword(Scope->InputDeviceName.ToString()) ||
ContainsAnyPlaystationKeyword(Scope->HardwareDeviceIdentifier))
{
return ElxControllerType::PlayStationGamepad;
}
}
return ElxControllerType::XboxGamepad;
}
}
bool FInputDeviceTrackerProcessor::HandleKeyDownEvent(FSlateApplication& SlateApp, const FKeyEvent& KeyEvent)
{
if (KeyEvent.GetKey().IsGamepadKey())
{
GLastControllerType = ClassifyGamepadFromScope();
}
else
{
GLastControllerType = ElxControllerType::KeyboardMouse;
}
return false;
}
bool FInputDeviceTrackerProcessor::HandleMouseButtonDownEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent)
{
GLastControllerType = ElxControllerType::KeyboardMouse;
return false;
}
void UlxInputDeviceTracker::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
if (FSlateApplication::IsInitialized())
{
Processor = MakeShared<FInputDeviceTrackerProcessor>();
FSlateApplication::Get().RegisterInputPreProcessor(Processor);
}
}
void UlxInputDeviceTracker::Deinitialize()
{
if (Processor.IsValid() && FSlateApplication::IsInitialized())
{
FSlateApplication::Get().UnregisterInputPreProcessor(Processor);
}
Processor.Reset();
Super::Deinitialize();
}
ElxControllerType UlxInputDeviceTracker::GetLastControllerType()
{
return GLastControllerType;
}

View File

@@ -0,0 +1,64 @@
////////////////////////////////////////////////////////////
//
// InputDeviceTracker.h
//
// Tracks the most recently used input device, classifying
// it as keyboard/mouse, Xbox gamepad, or PlayStation
// gamepad. The subsystem registers a Slate input
// preprocessor that watches button-down events; analog
// and mouse-move events are ignored. Read the current
// classification via the static accessor.
//
////////////////////////////////////////////////////////////
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "Framework/Application/IInputProcessor.h"
#include "Common.h"
#include "InputDeviceTracker.generated.h"
////////////////////////////////////////////////////////////
//
// FInputDeviceTrackerProcessor
//
// Slate input preprocessor. Updates the device-class
// static on each button-down event. Never consumes
// events (always returns false).
//
////////////////////////////////////////////////////////////
class FInputDeviceTrackerProcessor : public IInputProcessor
{
public:
virtual void Tick(const float DeltaTime, FSlateApplication& SlateApp, TSharedRef<ICursor> Cursor) override {}
virtual bool HandleKeyDownEvent(FSlateApplication& SlateApp, const FKeyEvent& KeyEvent) override;
virtual bool HandleMouseButtonDownEvent(FSlateApplication& SlateApp, const FPointerEvent& MouseEvent) override;
};
////////////////////////////////////////////////////////////
//
// UlxInputDeviceTracker
//
////////////////////////////////////////////////////////////
UCLASS(MinimalAPI)
class UlxInputDeviceTracker : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
// Returns the classification of the most recently used
// input device. Defaults to KeyboardMouse until the
// first gamepad button event is observed.
//
UFUNCTION(BlueprintCallable, Category="Luprex|Input")
static ElxControllerType GetLastControllerType();
private:
TSharedPtr<FInputDeviceTrackerProcessor> Processor;
};

View File

@@ -1,112 +0,0 @@
////////////////////////////////////////////////////////////
//
// InputEvents.cpp
//
////////////////////////////////////////////////////////////
#include "InputEvents.h"
#include "Common.h"
bool FlxEventRequest::operator==(const FlxEventRequest &Other) const
{
return (Widget == Other.Widget) &&
(UseUIOnly == Other.UseUIOnly) &&
(ShowPointer == Other.ShowPointer) &&
(Hotkeys == Other.Hotkeys);
}
bool FlxEventRequests::SanityCheck(const FlxEventRequest &Request)
{
if (Request.Widget == nullptr)
{
UE_LOG(LogLuprexIntegration, Error, TEXT("RequestEvents called with null widget."));
return false;
}
if (Request.ShowPointer && !Request.UseUIOnly)
{
UE_LOG(LogLuprexIntegration, Error, TEXT("RequestEvents: ShowPointer requires UseUIOnly."));
return false;
}
if (Request.UseUIOnly && !Request.Hotkeys.IsEmpty())
{
UE_LOG(LogLuprexIntegration, Error, TEXT("RequestEvents: Widget asked for all events, and also, specific events"));
return false;
}
return true;
}
void FlxEventRequests::SplitHighLow(View &High, View &Low)
{
int32 NumHigh = 0;
while ((NumHigh < Requests.Num()) && (Requests[NumHigh].UseUIOnly)) NumHigh++;
int32 NumLow = Requests.Num() - NumHigh;
High = View(Requests.GetData(), NumHigh);
Low = View(Requests.GetData() + NumHigh, NumLow);
}
void FlxEventRequests::Request(const FlxEventRequest &NewRequest)
{
// Divide the array into a high-priority slice and a low-priority slice.
View High, Low;
SplitHighLow(High, Low);
// This is a simple test to see if anything is going to change.
// If not, we return early and avoid setting the dirty bit.
if (NewRequest.UseUIOnly)
{
if ((High.Num() > 0) && (High[0] == NewRequest)) return;
}
else
{
if ((Low.Num() > 0) && (Low[0] == NewRequest)) return;
}
// We're going to build a new version of the requests array.
TArray<FlxEventRequest> Updated;
// Add all high priority requests to the updated array, new request first.
if (NewRequest.UseUIOnly) Updated.Add(NewRequest);
for (const FlxEventRequest &Req : High)
if (Req.Widget != NewRequest.Widget) Updated.Add(Req);
// Add all low priority requests to the updated array, new request first.
if (!NewRequest.UseUIOnly) Updated.Add(NewRequest);
for (const FlxEventRequest &Req : Low)
if (Req.Widget != NewRequest.Widget) Updated.Add(Req);
Swap(Requests, Updated);
Dirty = true;
}
void FlxEventRequests::Remove(UUserWidget *Widget)
{
int32 N = Requests.Num();
Requests.RemoveAll([Widget](const FlxEventRequest &Entry)
{
return Entry.Widget == Widget;
});
if (Requests.Num() < N) Dirty = true;
}
void FlxEventRequests::GarbageCollect()
{
int32 N = Requests.Num();
Requests.RemoveAll([](const FlxEventRequest &Entry)
{
UUserWidget *W = Entry.Widget;
return W == nullptr || !IsValid(W) || W->GetParent() == nullptr;
});
if (Requests.Num() < N) Dirty = true;
}
FlxEventRequests::InputMode FlxEventRequests::GetRequestedMode() const
{
if ((Requests.Num() > 0) && (Requests[0].UseUIOnly))
{
return InputMode::UIOnly;
}
else
{
return InputMode::GameOnly;
}
}

View File

@@ -1,106 +0,0 @@
////////////////////////////////////////////////////////////
//
// InputEvents.h
//
// Custom input event dispatching system. Uses Unreal's
// built-in input modes (GameOnly / UIOnly) with an
// enhanced input component for GameOnly mode hotkeys.
//
////////////////////////////////////////////////////////////
#pragma once
#include "CoreMinimal.h"
#include "InputCoreTypes.h"
#include "Blueprint/UserWidget.h"
#include "InputEvents.generated.h"
////////////////////////////////////////////////////////////
//
// FlxEventRequest
//
// A widget's declaration of interest in input events.
//
// Widget: The widget that wants to receive events.
// UseUIOnly: If true, activating this request puts
// the system into UIOnly mode.
// ShowPointer: If true, the mouse pointer should be
// visible when this widget has control.
// Hotkeys: Keys that go to this widget when the
// player is in GameOnly mode.
//
////////////////////////////////////////////////////////////
USTRUCT(BlueprintType)
struct FlxEventRequest
{
GENERATED_BODY()
FlxEventRequest() = default;
FlxEventRequest(UUserWidget *InWidget, bool InUseUIOnly, bool InShowPointer, const TArray<FKey> &InHotkeys)
: Widget(InWidget), UseUIOnly(InUseUIOnly), ShowPointer(InShowPointer), Hotkeys(InHotkeys) {}
bool operator == (const FlxEventRequest &Other) const;
UPROPERTY(BlueprintReadWrite)
UUserWidget* Widget = nullptr;
UPROPERTY(BlueprintReadWrite)
bool UseUIOnly = false;
UPROPERTY(BlueprintReadWrite)
bool ShowPointer = false;
UPROPERTY(BlueprintReadWrite)
TArray<FKey> Hotkeys;
};
USTRUCT()
struct FlxEventRequests
{
GENERATED_BODY()
private:
UPROPERTY()
// High priority requests are always before low-priority.
// Otherwise, these are in order of most recent first.
TArray<FlxEventRequest> Requests;
UPROPERTY()
bool Dirty = true;
public:
enum class InputMode { UIOnly, GameOnly };
using View = TArrayView<FlxEventRequest>;
// Get the requests array.
const TArray<FlxEventRequest> &GetRequests() const { return Requests; }
// Sanity check a request to see if it is reasonable.
static bool SanityCheck(const FlxEventRequest &Request);
// Divide Requests into a high-priority slice and a low-priority slice.
void SplitHighLow(View &High, View &Low);
// Apply a request. Replaces any previous request by the same widget.
void Request(const FlxEventRequest &NewRequest);
// Remove all requests by the specified widget.
void Remove(UUserWidget *Widget);
// Remove any requests by dead widgets or widgets with no parents.
void GarbageCollect();
// Return true if the requests have changed since the last ClearDirty.
bool IsDirty() { return Dirty; }
// Clear the dirty flag.
void ClearDirty() { Dirty = false; }
// Set the dirty flag.
void SetDirty() { Dirty = true; }
// Get the currently-requested mode.
InputMode GetRequestedMode() const;
};

View File

@@ -31,6 +31,7 @@ public class Integration : ModuleRules
"UMG", "UMG",
"UMGEditor", "UMGEditor",
"EditorSubsystem", "EditorSubsystem",
"ApplicationCore",
}); });
PrivateDependencyModuleNames.Add("Slate"); PrivateDependencyModuleNames.Add("Slate");

View File

@@ -73,7 +73,6 @@ void ALuprexGameModeBase::UpdateConsoleOutput() {
} }
} }
#pragma optimize("", off)
void ALuprexGameModeBase::UpdateTangibles() { void ALuprexGameModeBase::UpdateTangibles() {
double radius = 1000.0; // Hardwired for now. double radius = 1000.0; // Hardwired for now.
using TanArray = UlxTangibleManager::TanArray; using TanArray = UlxTangibleManager::TanArray;
@@ -132,8 +131,9 @@ void ALuprexGameModeBase::UpdatePossessedTangible() {
void ALuprexGameModeBase::UpdateLuaSourceCode() { void ALuprexGameModeBase::UpdateLuaSourceCode() {
FlxLockedWrapper lockedwrap; FlxLockedWrapper lockedwrap;
if (lockedwrap->get_rescan_lua_source(lockedwrap.Get())) if (lockedwrap->get_rescan_lua_source(lockedwrap.Get()) || ReloadSource)
{ {
ReloadSource = false;
drvutil::ostringstream srcpak; drvutil::ostringstream srcpak;
FString LuprexRoot = FPaths::Combine(FPaths::ProjectDir(), TEXT("luprex")); FString LuprexRoot = FPaths::Combine(FPaths::ProjectDir(), TEXT("luprex"));
std::string srcpakerr = drvutil::package_lua_source(TCHAR_TO_UTF8(*LuprexRoot), &srcpak); std::string srcpakerr = drvutil::package_lua_source(TCHAR_TO_UTF8(*LuprexRoot), &srcpak);
@@ -172,6 +172,7 @@ void ALuprexGameModeBase::OnWorldPostActorTick(UWorld* InWorld, ELevelTick InLev
if (PC != nullptr) if (PC != nullptr)
{ {
PC->UpdateLookAt(); PC->UpdateLookAt();
PC->UpdateInputMode();
} }
} }
} }
@@ -258,4 +259,9 @@ ALuprexGameModeBase *ALuprexGameModeBase::FromContext(const UObject *context) {
return result; return result;
} }
void ALuprexGameModeBase::TriggerReloadSource(const UObject *WorldContextObject) {
ALuprexGameModeBase *GameMode = FromContext(WorldContextObject);
GameMode->ReloadSource = true;
}

View File

@@ -62,6 +62,11 @@ public:
// Get the current Luprex Game Mode Base, given a Context object. // Get the current Luprex Game Mode Base, given a Context object.
static ALuprexGameModeBase *FromContext(const UObject *Context); static ALuprexGameModeBase *FromContext(const UObject *Context);
// Set the ReloadSource flag on the current Luprex game mode, causing
// the Lua source to be reloaded on the next tick.
UFUNCTION(BlueprintCallable, Category = "Luprex|Miscellaneous", meta = (WorldContext = "WorldContextObject"))
static void TriggerReloadSource(const UObject *WorldContextObject);
// The sensitivity level at which a log message triggers a debugger breakpoint. // The sensitivity level at which a log message triggers a debugger breakpoint.
UPROPERTY(EditAnywhere, Category="Debugging Tools") UPROPERTY(EditAnywhere, Category="Debugging Tools")
@@ -76,6 +81,9 @@ public:
// This is always true unless you use the debugger to set it to false. // This is always true unless you use the debugger to set it to false.
bool TickEnabled = true; bool TickEnabled = true;
// True to trigger a source reload.
bool ReloadSource = false;
// Current Player ID // Current Player ID
int64 PlayerId = 0; int64 PlayerId = 0;

View File

@@ -0,0 +1,63 @@
////////////////////////////////////////////////////////////
//
// LuprexViewportClient.cpp
//
////////////////////////////////////////////////////////////
#include "LuprexViewportClient.h"
#include "Common.h"
#include "PlayerControllerBase.h"
#include "RootCanvas.h"
#include "Engine/GameInstance.h"
#include "Framework/Application/SlateApplication.h"
#include "Layout/WidgetPath.h"
#include "Slate/SObjectWidget.h"
UlxViewportClient::UlxViewportClient(const FObjectInitializer &ObjectInitializer)
: Super(ObjectInitializer)
{
UE_LOG(LogLuprexIntegration, Display, TEXT("UlxViewportClient constructed"));
}
bool UlxViewportClient::TryBringToFront(const FWidgetPath &Path)
{
UGameInstance *GI = GetGameInstance();
if (!GI) return false;
AlxPlayerControllerBase *PC = Cast<AlxPlayerControllerBase>(
GI->GetFirstLocalPlayerController(GetWorld()));
if (!PC) return false;
for (int32 Idx = 0; Idx < Path.Widgets.Num(); ++Idx)
{
SWidget &SW = Path.Widgets[Idx].Widget.Get();
if (SW.GetType() != FName(TEXT("SObjectWidget"))) continue;
UUserWidget *Widget = static_cast<SObjectWidget&>(SW).GetWidgetObject();
if (Widget && Widget->GetParent() == PC->RootCanvas)
{
UlxRootCanvasPanel::BringToFront(Widget);
return true;
}
}
return false;
}
bool UlxViewportClient::InputKey(const FInputKeyEventArgs &EventArgs)
{
UE_LOG(LogLuprexIntegration, Display, TEXT("UlxViewportClient::InputKey key=%s event=%d"),
*EventArgs.Key.ToString(), (int32)EventArgs.Event);
// Only act on left mouse button presses that bubbled up to the
// viewport unhandled. If the click landed on a descendant of a
// top-level widget in the root canvas, bring that top-level widget
// to the front.
if ((EventArgs.Event == IE_Pressed) && (EventArgs.Key == EKeys::LeftMouseButton))
{
FSlateApplication &Slate = FSlateApplication::Get();
FVector2D MousePos = Slate.GetCursorPos();
FWidgetPath Path = Slate.LocateWindowUnderMouse(
MousePos, Slate.GetInteractiveTopLevelWindows());
if (Path.IsValid() && TryBringToFront(Path)) return true;
}
return Super::InputKey(EventArgs);
}

View File

@@ -0,0 +1,32 @@
////////////////////////////////////////////////////////////
//
// LuprexViewportClient.h
//
// Custom game viewport client. Implements a project-wide
// click-to-focus rule: when a left-mouse-button click is
// not handled by any widget and bubbles up to the viewport,
// we hit-test under the cursor and focus the nearest
// focusable ancestor of whatever was hit.
//
////////////////////////////////////////////////////////////
#pragma once
#include "CoreMinimal.h"
#include "Engine/GameViewportClient.h"
#include "Layout/WidgetPath.h"
#include "LuprexViewportClient.generated.h"
UCLASS()
class INTEGRATION_API UlxViewportClient : public UGameViewportClient
{
GENERATED_BODY()
public:
UlxViewportClient(const FObjectInitializer &ObjectInitializer);
virtual bool InputKey(const FInputKeyEventArgs &EventArgs) override;
private:
bool TryBringToFront(const FWidgetPath &Path);
};

View File

@@ -1,9 +1,21 @@
#include "PlayerControllerBase.h" #include "PlayerControllerBase.h"
#include "Common.h" #include "Common.h"
#include "GameFramework/InputDeviceSubsystem.h"
#include "GameFramework/InputSettings.h"
#include "RootCanvas.h"
#include "Tangible.h" #include "Tangible.h"
#include "TangibleManager.h" #include "TangibleManager.h"
#include "Kismet/GameplayStatics.h" #include "Blueprint/UserWidget.h"
#include "Blueprint/WidgetTree.h"
#include "Components/InputComponent.h"
#include "Engine/GameInstance.h" #include "Engine/GameInstance.h"
#include "Engine/GameViewportClient.h"
#include "Engine/LevelScriptActor.h"
#include "Engine/LocalPlayer.h"
#include "Framework/Application/SlateApplication.h"
#include "Framework/Application/SlateUser.h"
#include "Kismet/GameplayStatics.h"
#include "Widgets/SViewport.h"
AlxPlayerControllerBase *AlxPlayerControllerBase::FromContext(const UObject *Context) AlxPlayerControllerBase *AlxPlayerControllerBase::FromContext(const UObject *Context)
{ {
@@ -53,6 +65,261 @@ FVector2D AlxPlayerControllerBase::GetLookAtPixel(const UObject *Context)
return ScreenPosition; return ScreenPosition;
} }
void AlxPlayerControllerBase::BeginPlay()
{
// Build the root UMG stack BEFORE Super::BeginPlay. Super calls
// ReceiveBeginPlay, which fires the Blueprint Event BeginPlay;
// BP code there may immediately try to add widgets to RootCanvas,
// so the canvas must already exist.
RootWidget = CreateWidget<UlxRootWidget>(this);
RootCanvas = RootWidget->WidgetTree->ConstructWidget<UlxRootCanvasPanel>();
RootWidget->WidgetTree->RootWidget = RootCanvas;
RootWidget->AddToViewport(0);
Super::BeginPlay();
if (FSlateApplication::IsInitialized())
{
FocusChangingHandle = FSlateApplication::Get().OnFocusChanging().AddUObject(
this, &AlxPlayerControllerBase::HandleFocusChanging);
}
}
void AlxPlayerControllerBase::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
if (FocusChangingHandle.IsValid() && FSlateApplication::IsInitialized())
{
FSlateApplication::Get().OnFocusChanging().Remove(FocusChangingHandle);
FocusChangingHandle.Reset();
}
if (IsValid(RootWidget))
{
RootWidget->RemoveFromParent();
}
RootWidget = nullptr;
RootCanvas = nullptr;
Super::EndPlay(EndPlayReason);
}
void AlxPlayerControllerBase::HandleFocusChanging(
const FFocusEvent &FocusEvent,
const FWeakWidgetPath &OldPath,
const TSharedPtr<SWidget> &OldFocusedWidget,
const FWidgetPath &NewPath,
const TSharedPtr<SWidget> &NewFocusedWidget)
{
UE_LOG(LogLuprexIntegration, Display,
TEXT("Focus changing: '%s' -> '%s' (cause: %s)"),
OldFocusedWidget.IsValid() ? *OldFocusedWidget->GetTypeAsString() : TEXT("<none>"),
NewFocusedWidget.IsValid() ? *NewFocusedWidget->GetTypeAsString() : TEXT("<none>"),
*UEnum::GetValueAsString(FocusEvent.GetCause()));
}
UInputComponent* AlxPlayerControllerBase::GetWidgetInputComponent(UUserWidget *Widget)
{
if (!IsValid(Widget)) return nullptr;
// Cache the FProperty on first call. FProperties are owned by
// the native UUserWidget UClass, which lives for the process
// lifetime, so the pointer stays valid without us needing to
// root anything against GC. Static local init is thread-safe.
static FObjectProperty *InputComponentProp = FindFProperty<FObjectProperty>(
UUserWidget::StaticClass(), TEXT("InputComponent"));
check(InputComponentProp);
UObject *Value = InputComponentProp->GetObjectPropertyValue_InContainer(Widget);
return Cast<UInputComponent>(Value);
}
void AlxPlayerControllerBase::AddWidgetToRoot(UUserWidget *Widget)
{
if (!IsValid(Widget))
{
UE_LOG(LogLuprexIntegration, Error, TEXT("AddWidgetToRoot called with an invalid widget."));
return;
}
APlayerController *OwningPC = Widget->GetOwningPlayer();
AlxPlayerControllerBase *PC = Cast<AlxPlayerControllerBase>(OwningPC);
if (PC == nullptr)
{
UE_LOG(LogLuprexIntegration, Error,
TEXT("AddWidgetToRoot: widget '%s' owning player is not an AlxPlayerControllerBase (got %s)."),
*Widget->GetName(), *GetNameSafe(OwningPC));
return;
}
if (PC->RootCanvas == nullptr)
{
UE_LOG(LogLuprexIntegration, Error,
TEXT("AddWidgetToRoot: root canvas is not initialized, this is probably an initialization order issue"));
return;
}
if (Widget->GetParent() == PC->RootCanvas) return;
PC->RootCanvas->AddChildToRootCanvas(Widget);
}
void AlxPlayerControllerBase::BuildInputStack(TArray<UInputComponent*>& InputStack)
{
// Controlled pawn gets last dibs on the input stack
APawn* ControlledPawn = GetPawnOrSpectator();
if (ControlledPawn)
{
if (ControlledPawn->InputEnabled())
{
// Get the explicit input component that is created upon Pawn possession. This one gets last dibs.
if (ControlledPawn->InputComponent)
{
InputStack.Push(ControlledPawn->InputComponent);
}
// See if there is another InputComponent that was added to the Pawn's components array (possibly by script).
for (UActorComponent* ActorComponent : ControlledPawn->GetComponents())
{
UInputComponent* PawnInputComponent = Cast<UInputComponent>(ActorComponent);
if (PawnInputComponent && PawnInputComponent != ControlledPawn->InputComponent)
{
InputStack.Push(PawnInputComponent);
}
}
}
}
// LevelScriptActors are put on the stack next
for (ULevel* Level : GetWorld()->GetLevels())
{
ALevelScriptActor* ScriptActor = Level->GetLevelScriptActor();
if (ScriptActor)
{
if (ScriptActor->InputEnabled() && ScriptActor->InputComponent)
{
InputStack.Push(ScriptActor->InputComponent);
}
}
}
if (InputEnabled())
{
InputStack.Push(InputComponent);
}
// Get the widget slots.
TArray<UlxRootCanvasSlot*> WidgetSlots = RootCanvas->GetSortedUserWidgets();
// Generate a set of input components that are being managed by the WidgetSlots.
TSet<UInputComponent*> WidgetManagedComponents;
for (UlxRootCanvasSlot *Slot : WidgetSlots)
{
UUserWidget *Widget = Cast<UUserWidget>(Slot->GetContent());
UInputComponent *IC = GetWidgetInputComponent(Widget);
if (IC) WidgetManagedComponents.Add(IC);
}
// Add components in the CurrentInputStack, *unless* they are being managed
// by widgets. If they're being managed by widgets, they get added later.
for (int32 Idx=0; Idx<CurrentInputStack.Num(); ++Idx)
{
UInputComponent* IC = CurrentInputStack[Idx].Get();
if (IsValid(IC))
{
if (!WidgetManagedComponents.Contains(IC)) InputStack.Push(IC);
}
else
{
CurrentInputStack.RemoveAt(Idx--);
}
}
// Now add the widget-managed input components.
for (UlxRootCanvasSlot *Slot : WidgetSlots)
{
if (Slot->EnableEnhancedInput)
{
UUserWidget *Widget = Cast<UUserWidget>(Slot->GetContent());
UInputComponent *IC = GetWidgetInputComponent(Widget);
if (IC)
{
IC->bBlockInput = Slot->BlockInput;
InputStack.Push(IC);
}
}
}
}
void AlxPlayerControllerBase::UpdateInputMode()
{
// Get all the various objects we need to be able to manipulate
// the input mode.
UGameViewportClient *GameViewportClient = GetWorld()->GetGameViewport();
ULocalPlayer *LocalPlayer = Cast<ULocalPlayer>(Player);
if (GameViewportClient == nullptr || LocalPlayer == nullptr) return;
TSharedPtr<SViewport> ViewportWidget = GameViewportClient->GetGameViewportWidget();
if (!ViewportWidget.IsValid()) return;
TSharedRef<SViewport> ViewportWidgetRef = ViewportWidget.ToSharedRef();
FReply &SlateOperations = LocalPlayer->GetSlateOperations();
TSharedPtr<FSlateUser> SlateUser = LocalPlayer->GetSlateUser();
if (!SlateUser.IsValid()) return;
RootCanvas->UpdateZOrders();
// Get the desired configuration from the top widget.
UUserWidget *Widget = nullptr;
UWidget *Focus = nullptr;
bool ShowPointer = false;
if (UlxRootCanvasSlot *Top = RootCanvas->GetTopWidget())
{
Widget = Cast<UUserWidget>(Top->GetContent());
Focus = Widget->GetDesiredFocusWidget();
ShowPointer = Top->ShowPointer;
}
SetShowMouseCursor(ShowPointer);
if (ShowPointer)
{
// Only release capture if the viewport is currently holding it
// (e.g. we just came from GameOnly). A blanket ReleaseMouseCapture
// every tick would yank capture away from widgets mid-gesture
// (scrollbar drags, slider drags, etc.).
if (SlateUser->DoesWidgetHaveAnyCapture(ViewportWidget))
{
SlateOperations.ReleaseMouseCapture();
}
SlateOperations.ReleaseMouseLock();
GameViewportClient->SetMouseLockMode(EMouseLockMode::DoNotLock);
GameViewportClient->SetHideCursorDuringCapture(false);
GameViewportClient->SetMouseCaptureMode(EMouseCaptureMode::CaptureDuringMouseDown);
}
else
{
// Also captures the mouse to the viewport.
SlateOperations.UseHighPrecisionMouseMovement(ViewportWidgetRef);
SlateOperations.LockMouseToWidget(ViewportWidgetRef);
GameViewportClient->SetMouseLockMode(EMouseLockMode::LockOnCapture);
GameViewportClient->SetMouseCaptureMode(EMouseCaptureMode::CapturePermanently);
}
GameViewportClient->SetIgnoreInput(false);
// We always put keyboard focus on whatever user widget is in
// front. If the front widget doesn't want keyboard focus,
// then we put keyboard focus on the viewport.
if (Focus)
{
if (TSharedPtr<SWidget> SlateFocus = Focus->GetCachedWidget())
{
SlateOperations.SetUserFocus(SlateFocus.ToSharedRef());
}
}
else
{
SlateOperations.SetUserFocus(ViewportWidgetRef);
}
}
void AlxPlayerControllerBase::UpdateLookAt() void AlxPlayerControllerBase::UpdateLookAt()
{ {
UlxTangibleManager *TM = GetGameInstance()->GetSubsystem<UlxTangibleManager>(); UlxTangibleManager *TM = GetGameInstance()->GetSubsystem<UlxTangibleManager>();

View File

@@ -1,10 +1,15 @@
#pragma once #pragma once
#include "CoreMinimal.h" #include "CoreMinimal.h"
#include "Common.h"
#include "Engine/HitResult.h" #include "Engine/HitResult.h"
#include "GameFramework/PlayerController.h" #include "GameFramework/PlayerController.h"
#include "UObject/ObjectKey.h"
#include "PlayerControllerBase.generated.h" #include "PlayerControllerBase.generated.h"
class UlxRootCanvasPanel;
class UWidget;
UCLASS(BlueprintType, Blueprintable) UCLASS(BlueprintType, Blueprintable)
class INTEGRATION_API AlxPlayerControllerBase : public APlayerController class INTEGRATION_API AlxPlayerControllerBase : public APlayerController
{ {
@@ -36,11 +41,52 @@ public:
// Called by GameMode each tick. // Called by GameMode each tick.
void UpdateLookAt(); void UpdateLookAt();
// Called by GameMode each tick. GCs dead requests and will
// eventually reconcile focus, pointer, and capture state.
void UpdateInputMode();
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
private:
FDelegateHandle FocusChangingHandle;
void HandleFocusChanging(
const struct FFocusEvent &FocusEvent,
const class FWeakWidgetPath &OldPath,
const TSharedPtr<class SWidget> &OldFocusedWidget,
const class FWidgetPath &NewPath,
const TSharedPtr<class SWidget> &NewFocusedWidget);
public:
virtual void BuildInputStack(TArray<UInputComponent*>& InputStack) override;
// Read UUserWidget::InputComponent via reflection. The field is
// protected and has no public accessor; this reaches through the
// FProperty so we always see the current value without caching it.
static class UInputComponent* GetWidgetInputComponent(class UUserWidget *Widget);
// Add a widget to the root canvas at ZOrder 0 with default slot flags.
UFUNCTION(BlueprintCallable, Category = "Luprex|Root Canvas",
meta = (DefaultToSelf = "Widget", HideSelfPin = "true"))
static void AddWidgetToRoot(class UUserWidget *Widget);
// Get the player controller, cast to AlxPlayerControllerBase. // Get the player controller, cast to AlxPlayerControllerBase.
static AlxPlayerControllerBase *FromContext(const UObject *Context); static AlxPlayerControllerBase *FromContext(const UObject *Context);
UPROPERTY() UPROPERTY()
FHitResult CurrentLookAt; FHitResult CurrentLookAt;
// The single top-level UUserWidget added to the viewport. All
// top-level UI widgets are children of RootCanvas inside it.
UPROPERTY()
UUserWidget *RootWidget = nullptr;
// The root canvas panel inside RootWidget. Children of this
// canvas are the top-level widgets; their slots carry both
// layout and input-mode configuration.
UPROPERTY()
UlxRootCanvasPanel *RootCanvas = nullptr;
bool MustCallLookAtChanged = false; bool MustCallLookAtChanged = false;
}; };

View File

@@ -0,0 +1,251 @@
#include "PromptWidget.h"
#include "InputDeviceTracker.h"
#include "UtilityLibrary.h"
#include "PlayerControllerBase.h"
#include "InputAction.h"
#include "InputMappingContext.h"
#include "Widgets/SOverlay.h"
#include "Widgets/Images/SImage.h"
#include "Widgets/Text/STextBlock.h"
#include "Widgets/Layout/SScaleBox.h"
//
// Atlas contents:
//
// Row 1: Keyboard Button no Glyph, Left Mouse, Middle Mouse, Right Mouse
// Row 2: Left Trigger, Right Trigger, Left Shoulder, Right Shoulder
// Row 3: XBFace Down, XBFace Left, XBFace Up, XBFace Right
// Row 4: PSFace Down, PSFace Left, PSFace Up, PSFace Right
// Row 5: Arrow Down, Arrow Left, Arrow Up, Arrow Right
// Row 6: DPad Down, DPad Left, DPad Up, DPad Right
// Row 7: Space Bar, unused, unused, unused
// Row 8: unused, unused, unused, unused.
int32 UlxPromptWidget::ChooseIcon(bool Playstation, FKey Key) const
{
// Mouse buttons (row 1)
if (Key == EKeys::LeftMouseButton) return 1;
if (Key == EKeys::MiddleMouseButton) return 2;
if (Key == EKeys::RightMouseButton) return 3;
// Gamepad triggers and shoulders (row 2)
if (Key == EKeys::Gamepad_LeftTrigger) return 4;
if (Key == EKeys::Gamepad_RightTrigger) return 5;
if (Key == EKeys::Gamepad_LeftShoulder) return 6;
if (Key == EKeys::Gamepad_RightShoulder) return 7;
// Gamepad Face buttons (rows 3 and 4)
if (Key == EKeys::Gamepad_FaceButton_Bottom) return Playstation ? 12 : 8;
if (Key == EKeys::Gamepad_FaceButton_Left) return Playstation ? 13 : 9;
if (Key == EKeys::Gamepad_FaceButton_Top) return Playstation ? 14 : 10;
if (Key == EKeys::Gamepad_FaceButton_Right) return Playstation ? 15 : 11;
// Arrow keys (row 5)
if (Key == EKeys::Down) return 16;
if (Key == EKeys::Left) return 17;
if (Key == EKeys::Up) return 18;
if (Key == EKeys::Right) return 19;
// Gamepad D-Pad (row 6)
if (Key == EKeys::Gamepad_DPad_Down) return 20;
if (Key == EKeys::Gamepad_DPad_Left) return 21;
if (Key == EKeys::Gamepad_DPad_Up) return 22;
if (Key == EKeys::Gamepad_DPad_Right) return 23;
// Space bar (row 7)
if (Key == EKeys::SpaceBar) return 24;
// No icon for this key. Use the blank icon with a label.
return 0;
}
void UlxPromptWidget::ChooseAppearance(int32 &OutIcon, FString &OutGlyph)
{
FKey Key = (ControllerType == ElxControllerType::KeyboardMouse) ? KeyboardKey : GamepadKey;
OutIcon = ChooseIcon(ControllerType == ElxControllerType::PlayStationGamepad, Key);
if (OutIcon == 0) OutGlyph = Key.GetDisplayName().ToString();
else OutGlyph = TEXT("");
}
FBox2f UlxPromptWidget::GetIconUVs(int32 IconIndex)
{
const float ColWidth = 1.0f / 4.0f;
const float RowHeight = 1.0f / 8.0f;
int32 Col = IconIndex % 4;
int32 Row = IconIndex / 4;
float U = Col * ColWidth;
float V = Row * RowHeight;
return FBox2f(FVector2f(U, V), FVector2f(U + ColWidth, V + RowHeight));
}
FMargin UlxPromptWidget::GetScaledMargins() const
{
return FMargin(
GlyphMargins.Left * Size.X,
GlyphMargins.Top * Size.Y,
GlyphMargins.Right * Size.X,
GlyphMargins.Bottom * Size.Y);
}
void UlxPromptWidget::SynchronizeProperties()
{
Super::SynchronizeProperties();
if (!MyImage.IsValid()) return;
int32 Icon = 0;
FString Glyph;
ChooseAppearance(Icon, Glyph);
MyBrush.SetUVRegion(GetIconUVs(Icon));
MyImage->InvalidateImage();
MyBox->SetWidthOverride(Size.X);
MyBox->SetHeightOverride(Size.Y);
const float Extra = Depressed ? 0.05f : 0.0f;
const FMargin ExtraPadding(Extra * Size.X, Extra * Size.Y, Extra * Size.X, Extra * Size.Y);
MyImageSlot->SetPadding(ExtraPadding);
MyGlyphSlot->SetPadding(GetScaledMargins() + ExtraPadding);
MyGlyph->SetColorAndOpacity(GlyphColor);
if (!Glyph.IsEmpty())
{
MyGlyph->SetText(FText::FromString(Glyph));
MyGlyph->SetVisibility(EVisibility::HitTestInvisible);
}
else
{
MyGlyph->SetVisibility(EVisibility::Collapsed);
}
}
void UlxPromptWidget::SetSize(FVector2D InSize)
{
if (Size != InSize)
{
Size = InSize;
SynchronizeProperties();
}
}
void UlxPromptWidget::SetGlyphMargins(FMargin InMargins)
{
if (GlyphMargins != InMargins)
{
GlyphMargins = InMargins;
SynchronizeProperties();
}
}
void UlxPromptWidget::SetGlyphColor(FLinearColor InColor)
{
if (GlyphColor != InColor)
{
GlyphColor = InColor;
SynchronizeProperties();
}
}
void UlxPromptWidget::SetDepressed(bool InDepressed)
{
if (Depressed != InDepressed)
{
Depressed = InDepressed;
SynchronizeProperties();
}
}
void UlxPromptWidget::SetKeys(FKey InGamepadKey, FKey InKeyboardKey)
{
if ((GamepadKey != InGamepadKey) || (KeyboardKey != InKeyboardKey))
{
GamepadKey = InGamepadKey;
KeyboardKey = InKeyboardKey;
SynchronizeProperties();
}
}
void UlxPromptWidget::SetKeysFromBindings(const UInputMappingContext* InputMappingContext, const UInputAction* EnhancedInputAction)
{
check(InputMappingContext);
check(EnhancedInputAction);
FKey NewFirstKey;
FKey NewGamepadKey;
FKey NewKeyboardKey;
for (const FEnhancedActionKeyMapping& Mapping : InputMappingContext->GetMappings())
{
if (Mapping.Action != EnhancedInputAction) continue;
FKey Key = Mapping.Key;
if (Key.IsDigital())
{
if (!NewFirstKey.IsValid()) NewFirstKey = Mapping.Key;
if (Key.IsTouch()) { /* not supported */ }
else if (Key.IsGesture()) { /* not supported */ }
else if (Key.IsGamepadKey()) { if (!NewGamepadKey.IsValid()) NewGamepadKey = Key; }
else { if (!NewKeyboardKey.IsValid()) NewKeyboardKey = Key; }
}
}
if (!NewGamepadKey.IsValid()) NewGamepadKey = NewFirstKey;
if (!NewKeyboardKey.IsValid()) NewKeyboardKey = NewFirstKey;
SetKeys(NewGamepadKey, NewKeyboardKey);
}
bool UlxPromptWidget::OnTick(float DeltaTime)
{
ElxControllerType Type = UlxInputDeviceTracker::GetLastControllerType();
if (ControllerType != Type)
{
ControllerType = Type;
SynchronizeProperties();
}
return true;
}
TSharedRef<SWidget> UlxPromptWidget::RebuildWidget()
{
if (!IsDesignTime())
{
ControllerType = UlxInputDeviceTracker::GetLastControllerType();
TickHandle = FTSTicker::GetCoreTicker().AddTicker(FTickerDelegate::CreateUObject(this, &UlxPromptWidget::OnTick));
}
MyBrush.SetResourceObject(ButtonAtlas.Get());
SAssignNew(MyImage, SImage).Image(&MyBrush);
SAssignNew(MyGlyph, STextBlock);
SAssignNew(MyScaleBox, SScaleBox)
.Stretch(EStretch::ScaleToFit)
[ MyGlyph.ToSharedRef() ];
MyOverlay = SNew(SOverlay);
MyOverlay->AddSlot().Expose(MyImageSlot)
[ MyImage.ToSharedRef() ];
MyOverlay->AddSlot().HAlign(HAlign_Fill).VAlign(VAlign_Fill).Expose(MyGlyphSlot)
[ MyScaleBox.ToSharedRef() ];
SAssignNew(MyBox, SBox)
[ MyOverlay.ToSharedRef() ];
SynchronizeProperties();
return MyBox.ToSharedRef();
}
void UlxPromptWidget::ReleaseSlateResources(bool bReleaseChildren)
{
Super::ReleaseSlateResources(bReleaseChildren);
if (!IsDesignTime())
{
FTSTicker::GetCoreTicker().RemoveTicker(TickHandle);
}
MyBox.Reset();
MyOverlay.Reset();
MyImage.Reset();
MyScaleBox.Reset();
MyGlyph.Reset();
MyImageSlot = nullptr;
MyGlyphSlot = nullptr;
}

View File

@@ -0,0 +1,89 @@
#pragma once
#include "CoreMinimal.h"
#include "Common.h"
#include "Components/Widget.h"
#include "InputCoreTypes.h"
#include "Widgets/SOverlay.h"
#include "Widgets/Layout/SBox.h"
#include "Widgets/Layout/SScaleBox.h"
#include "Containers/Ticker.h"
#include "PromptWidget.generated.h"
class UInputAction;
class UInputMappingContext;
UCLASS(BlueprintType, Blueprintable)
class INTEGRATION_API UlxPromptWidget : public UWidget
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, Category="Prompt")
TObjectPtr<UTexture2D> ButtonAtlas;
UPROPERTY(EditAnywhere, Category="Prompt")
ElxControllerType ControllerType = ElxControllerType::KeyboardMouse;
UPROPERTY(EditAnywhere, Category="Prompt")
FKey GamepadKey = EKeys::Gamepad_FaceButton_Left;
UPROPERTY(EditAnywhere, Category="Prompt")
FKey KeyboardKey = EKeys::Z;
UPROPERTY(EditAnywhere, Setter, Category="Prompt")
FVector2D Size = FVector2D(64, 64);
UPROPERTY(EditAnywhere, Setter, Category="Prompt")
FMargin GlyphMargins = FMargin(0.0f, 0.1f, 0.0f, 0.1f);
UPROPERTY(EditAnywhere, Setter, Category="Prompt")
FLinearColor GlyphColor = FLinearColor::White;
UPROPERTY(EditAnywhere, Setter, Category="Prompt")
bool Depressed = false;
public:
UFUNCTION(BlueprintCallable, Category="Prompt")
void SetKeys(FKey InGamepadKey, FKey InKeyboardKey);
UFUNCTION(BlueprintCallable, Category="Prompt")
void SetKeysFromBindings(const UInputMappingContext* InputMappingContext, const UInputAction* EnhancedInputAction);
UFUNCTION(BlueprintCallable, Category="Prompt")
void SetGlyphMargins(FMargin InMargins);
UFUNCTION(BlueprintCallable, Category="Prompt")
void SetGlyphColor(FLinearColor InColor);
UFUNCTION(BlueprintCallable, Category="Prompt")
void SetSize(FVector2D InSize);
UFUNCTION(BlueprintCallable, Category="Prompt")
void SetDepressed(bool InDepressed);
protected:
virtual TSharedRef<SWidget> RebuildWidget() override;
virtual void SynchronizeProperties() override;
virtual void ReleaseSlateResources(bool bReleaseChildren) override;
private:
FSlateBrush MyBrush;
TSharedPtr<SBox> MyBox;
TSharedPtr<SOverlay> MyOverlay;
TSharedPtr<SImage> MyImage;
TSharedPtr<SScaleBox> MyScaleBox;
TSharedPtr<STextBlock> MyGlyph;
SOverlay::FOverlaySlot* MyImageSlot = nullptr;
SOverlay::FOverlaySlot* MyGlyphSlot = nullptr;
FBox2f GetIconUVs(int32 IconIndex);
FMargin GetScaledMargins() const;
int32 ChooseIcon(bool Playstation, FKey Key) const;
void ChooseAppearance(int32 &OutIcon, FString &OutGlyph);
bool OnTick(float DeltaTime);
FTSTicker::FDelegateHandle TickHandle;
};

View File

@@ -0,0 +1,398 @@
#include "RadialMenu.h"
#include "Rendering/DrawElements.h"
#include "Engine/Texture2D.h"
#include "Fonts/FontMeasure.h"
#include "Framework/Application/SlateApplication.h"
#include "Styling/SlateBrush.h"
#include "Widgets/SLeafWidget.h"
TArray<FRadialMenuItem> FRadialMenuItem::Calculate(const FRadialMenuConfig &Config)
{
TArray<FRadialMenuItem> Items;
const int32 NumItems = Config.MenuItems.Num();
if (NumItems <= 0) return Items;
int32 NumRight = (NumItems / 2);
int32 NumLeft = NumItems - NumRight;
Items.SetNum(NumItems);
// Measure each non-empty label first; the spoke layout's item height
// is derived from the tallest label.
const TSharedRef<FSlateFontMeasure> FontMeasure = FSlateApplication::Get().GetRenderer()->GetFontMeasureService();
double MaxTextHeight = 5.0;
for (int32 I = 0; I < NumItems; I++)
{
if (Config.MenuItems[I].IsEmpty()) continue;
Items[I].TextSize = FontMeasure->Measure(Config.MenuItems[I], Config.Font);
MaxTextHeight = FMath::Max(MaxTextHeight, Items[I].TextSize.Y);
}
const float ItemHeight = static_cast<float>(MaxTextHeight * 1.2);
View LeftItems(Items.GetData(), NumLeft);
View RightItems(Items.GetData() + NumLeft, NumRight);
CalculateSpokes(LeftItems, NumItems, ItemHeight, Config.InnerRadius, Config.MinSpoke);
CalculateSpokes(RightItems, NumItems, ItemHeight, Config.InnerRadius, Config.MinSpoke);
double LeftWidth = WidestSpoke(LeftItems);
double RightWidth = WidestSpoke(RightItems);
double HalfWidth = FMath::Max(LeftWidth, RightWidth) + Config.Spread;
CalculateSpread(LeftItems, HalfWidth - LeftWidth);
CalculateSpread(RightItems, HalfWidth - RightWidth);
FlipHorizontal(LeftItems);
return Items;
}
TArray<FVector2D> FRadialMenuItem::CalculateDirections(int32 NumItems)
{
TArray<FVector2D> Result;
if (NumItems <= 0) return Result;
const int32 NumRight = (NumItems / 2);
const int32 NumLeft = NumItems - NumRight;
Result.SetNum(NumItems);
// Left side: SpokeVector with X flipped (matches FlipHorizontal in Calculate).
for (int32 I = 0; I < NumLeft; I++)
{
FVector2D V = SpokeVector(I, NumLeft, NumItems);
V.X = -V.X;
Result[I] = V;
}
for (int32 I = 0; I < NumRight; I++)
{
Result[NumLeft + I] = SpokeVector(I, NumRight, NumItems);
}
return Result;
}
FVector2D FRadialMenuItem::SpokeVector(int32 I, int32 NSide, int32 NTotal)
{
double SpokeAngle = 1.0 / NTotal;
double OffsetAngle = 0.5 * (0.5 - ((NSide - 1) * SpokeAngle));
double Revolutions = (I * SpokeAngle) + OffsetAngle;
double Radians = (Revolutions * 2.0 * UE_PI);
return FVector2D(FMath::Sin(Radians), -FMath::Cos(Radians));
}
void FRadialMenuItem::FlipHorizontal(View V)
{
for (FRadialMenuItem& Item : V)
{
Item.Point1.X = -Item.Point1.X;
Item.Point2.X = -Item.Point2.X;
Item.Point3.X = -Item.Point3.X;
Item.RightSide = !Item.RightSide;
}
}
double FRadialMenuItem::WidestSpoke(View V)
{
double Result = 0.0;
for (const FRadialMenuItem &Item : V)
{
Result = FMath::Max(Item.Point2.X, Result);
}
return Result;
}
void FRadialMenuItem::CalculateSpread(View V, double Offset)
{
for (FRadialMenuItem &Item : V)
{
Item.Point3 = Item.Point2 + FVector2D(Offset, 0.0);
}
}
void FRadialMenuItem::CalculateSpokes(View V, int32 TotalItems, float ItemHeight, float InnerRadius, float MinSpoke)
{
if (V.Num() == 0) return;
// RightSide is always initialized to true, it may get
// reversed by FlipHorizontal.
for (int32 I = 0; I < V.Num(); I++)
{
V[I].RightSide = true;
V[I].Point1 = SpokeVector(I, V.Num(), TotalItems) * InnerRadius;
}
// Calculate point2 for all spokes.
double NextLineMin = ItemHeight * 0.5;
int32 Mid = (V.Num() / 2);
if (V.Num() & 1)
{
V[Mid].Point2 = FVector2D(InnerRadius + MinSpoke, 0.0);
NextLineMin = ItemHeight;
Mid += 1;
}
for (int32 I = Mid; I < V.Num(); I++)
{
FVector2D UnitVec = SpokeVector(I, V.Num(), TotalItems);
double Y = (UnitVec.Y * (InnerRadius + MinSpoke));
if (Y < NextLineMin) Y = NextLineMin;
NextLineMin = Y + ItemHeight;
FVector2D Point2 = UnitVec * (Y / UnitVec.Y);
V[I].Point2 = Point2;
V[V.Num() - 1 - I].Point2 = Point2 * FVector2D(1.0,-1.0);
}
// The middle spoke is calculated using a different formula,
// which may result in a short spoke. If so, fix it to make
// it at least as long as the adjacent spoke.
if ((V.Num() & 1) && (V.Num() >= 3))
{
Mid = V.Num() / 2;
if (V[Mid].Point2.X < V[Mid + 1].Point2.X)
V[Mid].Point2.X = V[Mid + 1].Point2.X;
}
}
// Slate widget that paints the radial menu's polylines.
class SRadialMenu : public SLeafWidget
{
public:
SLATE_BEGIN_ARGS(SRadialMenu) {}
SLATE_END_ARGS()
void Construct(const FArguments& InArgs, const FRadialMenuConfig& InConfig)
{
Config = InConfig;
}
void SetConfig(const FRadialMenuConfig& NewConfig)
{
Config = NewConfig;
Items = FRadialMenuItem::Calculate(Config);
Invalidate(EInvalidateWidgetReason::Layout);
}
void SetPointer(FVector2D NewPointer)
{
if (PointerVector == NewPointer) return;
PointerVector = NewPointer;
Invalidate(EInvalidateWidgetReason::Paint);
}
protected:
virtual FVector2D ComputeDesiredSize(float) const override
{
FVector2D Min(0.0, 0.0);
FVector2D Max(0.0, 0.0);
for (const FRadialMenuItem &Item : Items)
{
for (const FVector2D &P : { Item.Point1, Item.Point2, Item.Point3 })
{
Min.X = FMath::Min(Min.X, P.X);
Min.Y = FMath::Min(Min.Y, P.Y);
Max.X = FMath::Max(Max.X, P.X);
Max.Y = FMath::Max(Max.Y, P.Y);
}
if (Item.TextSize.X <= 0) continue;
const double Gap = Config.Spread * 0.5;
const double TextLeft = Item.RightSide ? (Item.Point3.X + Gap) : (Item.Point3.X - Gap - Item.TextSize.X);
const double TextRight = Item.RightSide ? (Item.Point3.X + Gap + Item.TextSize.X) : (Item.Point3.X - Gap);
Min.X = FMath::Min(Min.X, TextLeft);
Max.X = FMath::Max(Max.X, TextRight);
Min.Y = FMath::Min(Min.Y, Item.Point3.Y - Item.TextSize.Y * 0.5);
Max.Y = FMath::Max(Max.Y, Item.Point3.Y + Item.TextSize.Y * 0.5);
}
// Symmetric around the origin so the menu draws centered.
double HalfW = FMath::Max(FMath::Abs(Min.X), FMath::Abs(Max.X));
double HalfH = FMath::Max(FMath::Abs(Min.Y), FMath::Abs(Max.Y));
return FVector2D(HalfW * 2.0, HalfH * 2.0);
}
virtual int32 OnPaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry,
const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements,
int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const override
{
const FVector2D Center = AllottedGeometry.GetLocalSize() * 0.5;
const FPaintGeometry PaintGeom = AllottedGeometry.ToPaintGeometry();
for (int32 I = 0; I < Items.Num(); I++)
{
const FRadialMenuItem& Item = Items[I];
const bool bSelected = (I == Config.SelectedItem);
const FLinearColor& Color = bSelected ? Config.SelectedColor : Config.ItemColor;
const float Thickness = bSelected ? Config.SelectedThickness : Config.LineThickness;
TArray<FVector2D> Points;
Points.Add(Center + Item.Point1);
Points.Add(Center + Item.Point2);
Points.Add(Center + Item.Point3);
FSlateDrawElement::MakeLines(OutDrawElements, LayerId, PaintGeom, Points, ESlateDrawEffect::None, Color, true, Thickness);
}
// Draw item labels, vertically centered on Point3. Right-side items
// are left-aligned to Point3; left-side items are right-aligned.
for (int32 I = 0; I < Items.Num(); I++)
{
const FRadialMenuItem& Item = Items[I];
if (Item.TextSize.X <= 0) continue;
const bool bSelected = (I == Config.SelectedItem);
const FLinearColor& Color = bSelected ? Config.SelectedColor : Config.ItemColor;
const double Gap = Config.Spread * 0.5;
FVector2D TextPos;
TextPos.X = Item.RightSide ? (Item.Point3.X + Gap) : (Item.Point3.X - Gap - Item.TextSize.X);
TextPos.Y = Item.Point3.Y - Item.TextSize.Y * 0.5;
FSlateDrawElement::MakeText(
OutDrawElements,
LayerId + 1,
AllottedGeometry.ToPaintGeometry(Item.TextSize, FSlateLayoutTransform(Center + TextPos)),
Config.MenuItems[I],
Config.Font,
ESlateDrawEffect::None,
Color);
}
if (Config.DotRadius > 0.0f && Config.DotTexture != nullptr)
{
FSlateBrush DotBrush;
DotBrush.SetResourceObject(Config.DotTexture);
const FVector2D DotSize(Config.DotRadius * 2.0f);
for (int32 I = 0; I < Items.Num(); I++)
{
const FRadialMenuItem& Item = Items[I];
const bool bSelected = (I == Config.SelectedItem);
const FLinearColor& Color = bSelected ? Config.SelectedColor : Config.ItemColor;
for (const FVector2D& Point : { Item.Point1, Item.Point3 })
{
const FVector2D LocalPoint = Center + Point;
FSlateDrawElement::MakeBox(
OutDrawElements,
LayerId + 1,
AllottedGeometry.ToPaintGeometry(DotSize, FSlateLayoutTransform(LocalPoint - FVector2D(Config.DotRadius))),
&DotBrush,
ESlateDrawEffect::None,
Color);
}
}
}
// Teardrop: visualizes PointerVector inside InnerRadius. The texture's
// tip naturally points up (0,-1); rotate it to align with PointerVector.
// When PointerVector is (0,0), the teardrop stays upright at the center.
if (Config.TeardropRadius > 0.0f && Config.Teardrop != nullptr)
{
FSlateBrush TeardropBrush;
TeardropBrush.SetResourceObject(Config.Teardrop);
const FVector2D TeardropSize(Config.TeardropRadius * 2.0f);
const FVector2D TeardropCenter = Center + PointerVector * Config.InnerRadius;
const float Angle = PointerVector.IsZero()
? 0.0f
: static_cast<float>(FMath::Atan2(PointerVector.X, -PointerVector.Y));
FSlateDrawElement::MakeRotatedBox(
OutDrawElements,
LayerId + 1,
AllottedGeometry.ToPaintGeometry(TeardropSize, FSlateLayoutTransform(TeardropCenter - FVector2D(Config.TeardropRadius))),
&TeardropBrush,
ESlateDrawEffect::None,
Angle);
}
return LayerId + 1;
}
private:
FRadialMenuConfig Config;
TArray<FRadialMenuItem> Items;
FVector2D PointerVector = {0,0};
};
void URadialMenuWidget::SetMenuItems(const TArray<FString>& NewMenuItems)
{
if (Config.MenuItems == NewMenuItems) return;
Config.MenuItems = NewMenuItems;
Config.SelectedItem = -1;
PointerVector = FVector2D();
SynchronizeProperties();
}
FString URadialMenuWidget::GetSelectedMenuItem() const
{
if (!Config.MenuItems.IsValidIndex(Config.SelectedItem)) return FString();
return Config.MenuItems[Config.SelectedItem];
}
void URadialMenuWidget::SetSelectedItem(int32 NewSelectedItem)
{
if (Config.SelectedItem == NewSelectedItem) return;
Config.SelectedItem = NewSelectedItem;
SynchronizeProperties();
}
void URadialMenuWidget::SynchronizeProperties()
{
Super::SynchronizeProperties();
if (Directions.Num() != Config.MenuItems.Num())
{
Directions = FRadialMenuItem::CalculateDirections(Config.MenuItems.Num());
}
if (MySlateWidget.IsValid())
{
MySlateWidget->SetConfig(Config);
MySlateWidget->SetPointer(PointerVector);
}
}
void URadialMenuWidget::AddPointer(FVector2D Direction, float Scale)
{
SetPointer(PointerVector + (Direction * Scale), 1.0);
}
void URadialMenuWidget::SetPointer(FVector2D Direction, float Scale)
{
if ((!IsVisible()) || (Directions.Num() == 0))
{
PointerVector = FVector2D();
SetSelectedItem(-1);
if (MySlateWidget.IsValid()) MySlateWidget->SetPointer(PointerVector);
return;
}
PointerVector = (Direction * Scale);
if (PointerVector.Length() < 0.75)
{
SetSelectedItem(-1);
if (MySlateWidget.IsValid()) MySlateWidget->SetPointer(PointerVector);
return;
}
if (PointerVector.Length() > 1.0)
{
PointerVector.Normalize();
}
int32 BestIndex = -1;
double BestDot = -2.0;
for (int32 I = 0; I < Directions.Num(); I++)
{
FVector2D SpokeDir = Directions[I];
if (!SpokeDir.Normalize()) continue;
double Dot = FVector2D::DotProduct(SpokeDir, PointerVector);
if (Dot <= BestDot) continue;
BestDot = Dot;
BestIndex = I;
}
SetSelectedItem(BestIndex);
if (MySlateWidget.IsValid()) MySlateWidget->SetPointer(PointerVector);
}
TSharedRef<SWidget> URadialMenuWidget::RebuildWidget()
{
MySlateWidget = SNew(SRadialMenu, Config);
return MySlateWidget.ToSharedRef();
}
void URadialMenuWidget::ReleaseSlateResources(bool bReleaseChildren)
{
Super::ReleaseSlateResources(bReleaseChildren);
MySlateWidget.Reset();
}

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