Compare commits

..

40 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
120 changed files with 6264 additions and 2161 deletions

2
.gitignore vendored
View File

@@ -27,6 +27,7 @@ UnrealEngine
*.vcproj
.ignore
.gitdeps-cache/**
.vscode/**
Config/**
Saved/**
@@ -52,3 +53,4 @@ GPF-output/**
__pycache__/
.clangd-query/
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.
- `Config/` — Unreal config files
- `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
## 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
- 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

@@ -84,3 +84,16 @@ DefaultTouchInterface=/Engine/MobileResources/HUD/DefaultVirtualJoysticks.Defaul
-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.

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,172 +0,0 @@
# Better Debugging With LLDB (in VS Code + CodeLLDB)
## The Problem
When debugging Unreal with VS Code + CodeLLDB, the **Variables** pane and
the **Watch** pane use two completely different evaluation paths:
- **Variables pane** walks a tree built by lldb's *synthetic children
providers* (Unreal's Python formatters for TArray, TMap, FName, FString,
and now our TObjectPtr/TSharedPtr/TWeakPtr). Values are looked up by
offset/type — no compilation. Base classes appear as named children
("SCompoundWidget", "UWidget"); smart-pointer inners get unwrapped;
container elements get indexed.
- **Watch pane** (without intervention) runs text through the `/se` (simple)
or `/nat` (Clang) evaluator. `/nat` fails for most Unreal paths;
`/se` transpiles to Python and walks the SBValue tree, but — as
originally shipped — fails on pointer auto-deref and can't find base
classes by type name.
When you right-click in Variables and **Add to Watch**, CodeLLDB sends
a path like `Top.Widget` (built itself for synthetic children, uniform
`.`, no pointer awareness). Without the fix below, that path fails in
Watch even though it identifies a real value Variables can show.
## What We Did
All changes live in `tools/UEDataFormatter.py`, loaded via `initCommands`
in every launch config in `Integration.code-workspace.tpl.json`.
### 1. Patched `codelldb.value.Value.__getattr__`
At module load, we monkey-patch CodeLLDB's `Value` class — the Python
wrapper it uses inside `/se` and `/py` evaluation — to:
- **Auto-deref pointers** before descending into a named child.
- **Fall back to iterating children** by `GetName()` when
`GetChildMemberWithName` returns invalid. This catches base classes
(which appear as named children when iterated but can't be looked up
by name).
**Result:** plain "Add to Watch" from the Variables pane produces a path
like `Top.Widget.SomeField` and Watch evaluates it correctly — same
expandable tree you'd see in Variables. No `/py fv(...)` wrapper, no
VS Code extension, no manual prefix typing.
Blast radius: every `/se` and `/py` expression that goes through
`Value.__getattr__`. The change is strictly more permissive (paths that
used to fail now succeed), but it is a global behavior change to
CodeLLDB internals. Breaks if CodeLLDB renames `Value` or the
`__sbvalue` slot; re-applied automatically on extension reload since
our patch runs on `command script import`.
### 2. Synth + Summary Providers for Smart Pointers
UE's engine formatter covers containers and TWeakObjectPtr, but not the
smart pointer family. We added providers for:
- **`TObjectPtr<T>`** — shows `nullptr` / `unresolved` / wrapped object's
summary. Expanding flattens straight to the target's members (no
intermediate `*DebugPtr` click).
- **`TSharedPtr<T>`** and **`TSharedRef<T>`** — shows `nullptr` / target
summary; expands straight to target's members.
- **`TWeakPtr<T>`** — checks
`WeakReferenceCount.ReferenceController.SharedReferenceCount` before
dereferencing. Expired weak refs show `expired` rather than garbage
from a dangling pointer.
All registration regexes are anchored with `^` — otherwise the greedy
`.+` matches nested occurrences (a `TArray<TObjectPtr<X>, ...>` would
get dispatched to the TObjectPtr provider instead of TArray).
### 3. Dynamic Type Resolution
Every launch config now sets:
settings set target.prefer-dynamic-value no-run-target
lldb reads the vtable at each polymorphic value and shows the runtime
type's members. A `UObject*` that actually points to a `UUserWidget`
expands to the full `UUserWidget` subtree, not just `UObject`.
`no-run-target` avoids running code in the debuggee, which is important
during synthesis.
### 4. Universal SIGTRAP Handling
`process handle SIGTRAP --notify false --pass false --stop false` is now
in every launch config. Unreal raises SIGTRAP internally in a number of
places (soft asserts, ensure-style checks); without this, the debugger
stops constantly.
## Reloading Without a Session Restart
If you edit `tools/UEDataFormatter.py`, reload in the Debug Console:
script import importlib; importlib.reload(UEDataFormatter); UEDataFormatter.__lldb_init_module(lldb.debugger, {})
The reload re-executes module code (updating the patch and the provider
classes). The explicit `__lldb_init_module` call re-runs the provider
registrations — lldb only fires `__lldb_init_module` on initial import,
not on reload.
## Summary of Workflow
| Action | Where | How |
|---|---|---|
| Explore a value | Variables pane | Click disclosure triangles |
| Track a value across steps | Watch pane | Right-click variable → Add to Watch (just works) |
| One-shot inspection | Debug Console | `v Widget.Object->SCompoundWidget.SWidget` (`v` = `frame variable`; use `->` for pointers explicitly) |
| Reload formatter edits | Debug Console | `script import importlib; importlib.reload(UEDataFormatter); UEDataFormatter.__lldb_init_module(lldb.debugger, {})` |
## Notes on the Design
### Why this works
CodeLLDB's `/se` evaluator transpiles user expressions into Python that
operates on `Value` objects. `Value.__getattr__` drives every `.field`
access. By making that method auto-deref and iterate for base classes,
every downstream mechanism (Watch, Debug Console, hover, conditional
breakpoints) inherits the fix.
### Why `fv` is no longer needed
Earlier we had an `fv(path)` helper plus a plan for a companion VS Code
extension to wrap "Add to Watch" results in `/py fv(...)`. The
`Value.__getattr__` patch makes the default path work, so that whole
layer is obsolete.
### Why not patch `SBValue` instead
Tempting, but much larger blast radius — affects every tool, every
adapter, every Python script using lldb. Patching `Value` confines the
change to CodeLLDB's expression pipeline.
## Ideas for Further Improvement
### Propose `/fv` mode to CodeLLDB upstream
Prefix dispatch is in CodeLLDB's Rust binary, so we can't add a new
prefix from Python. A clean feature request would be to add `/fv`
`SBFrame::GetValueForVariablePath(code)` as a native evaluator. That
would give direct access to lldb's own frame-variable walker without
the Python/Value indirection — though it has its own limitations
(no arithmetic, no casts).
### More synth providers
- **`TOptional<T>`** — hide the storage bytes and `bIsSet`; expose the
contained value (or `unset`) directly.
- **`TVariant<...>`** — expose the currently-held alternative as the
single child.
- **`FText`** — show the resolved localized string as the summary.
- **`FSoftObjectPtr` / `FSoftClassPtr`** — show the asset path.
### Enrich TObjectPtr summary
When resolved, show both class and name (`UUserWidget 'W_HUD_0'`)
instead of just the name. The class is reachable via
`ClassPrivate->NamePrivate`.
### A `fdump` helper
A Debug Console helper that prints an entire subtree as indented text —
useful for grabbing a snapshot of complex state into a log or comment.
### Get `Copy as Expression` to emit `->` for pointers
The path CodeLLDB builds for synthetic children uses `.` uniformly,
regardless of whether intermediate values are pointers. That's why
`v Top.Widget` fails but `v Top->Widget` works. A feature request to
have CodeLLDB emit `->` when traversing a pointer would make paths
`frame variable`-compatible out of the box.

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

@@ -1,129 +0,0 @@
# Keyboard Focus and Input Modes
Luprex provides a "window management system" that takes full ownership of: keyboard focus, mouse capture, pointer visibility, enhanced input routing, and input mode. Top-level widgets don't call `AddToViewport` or configure input modes directly — instead, they are added to a single root canvas, and the player controller arbitrates the per-tick state based on which widget is in front.
## Key types
- **`AlxPlayerControllerBase`** — owns the top-level `RootWidget`, builds the input stack, and reconciles focus / capture / pointer state every tick.
- **`UlxRootCanvasPanel`** — the one `UCanvasPanel` subclass that lives inside `RootWidget`. Every top-level UI widget in the game is a child of this canvas.
- **`UlxRootCanvasSlot`** — the slot type used for children of `UlxRootCanvasPanel`. Extends `UCanvasPanelSlot` with input-mode fields. A widget's slot carries **both** layout config (anchors, offsets, ZOrder) **and** its window-management declarations.
These types collapse what would otherwise be two parallel systems (layout + input-mode requests) into one. Adding a widget to the canvas = declaring its window-management intent.
## The core rule: ZOrder is authoritative
**The widget with the highest ZOrder wins everything.** It dictates:
- Whether the mouse pointer is shown.
- Whether the viewport captures the mouse.
- Which widget (or sub-widget) receives keyboard focus.
- Which input components end up on top of the input stack.
Draw order and input priority are the same fact. A widget you can see on top is the one that gets input.
## Slot fields (`UlxRootCanvasSlot`)
Inherited from `UCanvasPanelSlot`:
- **`ZOrder`** (int32) — Higher = drawn on top / higher input priority. Can be updated at runtime via `Slot->SetZOrder(N)`.
- **`Anchors`, `Offsets`, `Alignment`** — standard UMG canvas layout.
Added by `UlxRootCanvasSlot`:
- **`ShowPointer`** (bool) — When this widget is in front, the mouse cursor is visible and the viewport releases capture. When false, the cursor is hidden and the viewport captures the mouse (game-style input).
- **`BlockInput`** (bool) — When this widget's input component is pushed onto the stack, it blocks lower-priority components from receiving events.
- **`EnableEnhancedInput`** (bool, default true) — Whether this widget's `UInputComponent` is included on the input stack at all. Corresponds to "Enhanced Input events fire on this widget."
One property that lives on the **widget** rather than the slot:
- **`UUserWidget::DesiredFocusWidget`** — the sub-widget to receive keyboard focus when this widget is in front. Null means "focus the viewport."
## API
### Adding a widget
```cpp
// Static, BP-callable with DefaultToSelf + HideSelfPin,
// so in Blueprint it looks like a method on UUserWidget.
AlxPlayerControllerBase::AddWidgetToRoot(UUserWidget *Widget);
```
Adds the widget as a child of the player controller's `RootCanvas` at ZOrder 0 with default flags. If the widget is already a child, does nothing.
### Updating a widget's settings
```cpp
UlxRootCanvasPanel::SetWidgetWindowManagement(
UUserWidget *Widget,
bool ShowPointer, bool BlockInput, bool EnableEnhancedInput,
bool BringToFront, UWidget *DesiredFocusWidget);
```
Updates an already-added widget's slot. Writes all the input-mode flags, sets `DesiredFocusWidget`, and optionally bumps ZOrder to `GetMaxZOrder() + 1` if `BringToFront` is true. Logs an error if the widget isn't yet a root widget.
### Forcing a focus re-apply
```cpp
AlxPlayerControllerBase::RestoreFocusToFrontWidget(const UObject *Context);
```
Clears `LastWidgetGrantedFocus`, which causes the next tick of `UpdateInputMode` to re-apply focus based on the current front widget. Use after mutating `DesiredFocusWidget` or when a modal is dismissed and focus should return to the underlying widget.
## Per-tick flow
On every tick, the player controller runs two passes:
### `BuildInputStack`
Assembles the list of `UInputComponent`s that will receive input events this frame:
1. Controlled pawn's input component(s).
2. Level script actors.
3. The player controller's own input component.
4. `CurrentInputStack` (anything pushed via `PushInputComponent`, filtered to exclude input components already managed by a root canvas slot).
5. Root canvas widget input components, pushed in ZOrder-ascending order (so highest ZOrder ends up on top of the stack, which gets processed first).
Each widget-managed IC has its `bBlockInput` flag set from its slot's `BlockInput` before being pushed.
### `UpdateInputMode`
Reads the front widget's slot and applies the declared state:
1. Sorts the canvas slots by ZOrder; picks the front widget.
2. Calls `SetShowMouseCursor(Slot->ShowPointer)`.
3. Configures viewport capture mode based on `ShowPointer`:
- **Pointer on**: releases capture (only if held), unlocks mouse, sets `CaptureDuringMouseDown`.
- **Pointer off**: high-precision mouse movement, locks mouse to viewport, `CapturePermanently`.
4. Resolves focus:
- If `!ShowPointer`, re-applies focus every tick (keeps stealing focus back from anything that steals it).
- If `ShowPointer` and the front widget (identity) hasn't changed since the last grant, lets click-to-focus take over.
- Otherwise grants focus once to the widget's `DesiredFocusWidget` (or the viewport if none).
## Focus arbitration
`LastWidgetGrantedFocus` (a `TObjectKey<UWidget>`) tracks the last focus target the system applied. The comparison is identity-based: if the current front widget's `DesiredFocusWidget` differs from `LastWidgetGrantedFocus`, focus is re-granted.
To force a re-grant without a widget change (e.g., after mutating `DesiredFocusWidget`), call `RestoreFocusToFrontWidget`. This sets the key to null, so the next tick's comparison will always re-grant.
## Click-to-focus
When the user clicks on a focusable widget, the `LuprexViewportClient` notifies the player controller via `ClickToFocus(Widget)`. On the next `UpdateInputMode` tick, if the system isn't going to re-grant focus for other reasons, it forwards focus to the clicked widget.
This only takes effect in pointer mode. In non-pointer mode, focus is continuously re-applied to the front widget's `DesiredFocusWidget`, overriding click behavior.
## What NOT to do
- **Don't call `AddToViewport` on top-level game widgets.** All top-level widgets must be children of `RootCanvas`. Going directly to the viewport bypasses the entire system.
- **Don't set input mode via `SetInputModeGameOnly` / `SetInputModeUIOnly` / `SetInputModeGameAndUI`.** The window management system owns these. Adjust `ShowPointer` / `BlockInput` on the slot instead.
- **Don't call `SetShowMouseCursor` directly.** Same reason — driven by the front widget's `ShowPointer`.
- **Don't set focus manually.** Set `DesiredFocusWidget` on the widget; the system will pick it up.
## Design notes
**Why slot fields, not separate requests?** Earlier versions of this system kept a parallel `FlxInputModeRequests` array of per-widget state. Tying that to slot lifecycle (widget added = entry exists; widget removed = entry gone) eliminated a whole class of stale-entry and lifecycle bugs.
**Why ZOrder as the priority key?** The alternative is a separate priority field, which can diverge from visual order. Using ZOrder means "the widget you see on top is the one with input priority" is true by construction, not by convention.
**Why identity-based focus invalidation?** Tracking a sequence number worked but required callers to call an explicit re-request API whenever `DesiredFocusWidget` changed. Using `TObjectKey<UWidget>` lets the system notice any change in the intended focus target automatically.
**Why `ShowPointer` and not a whole `EInputMode` enum?** Two flags cover the behaviors that actually matter (`ShowPointer`, `BlockInput`). A three-state enum with `GameOnly` / `UIOnly` / `GameAndUI` would force callers into categories that don't map cleanly to how this system layers widgets.

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,20 +1,12 @@
* UE Wingman rename functions.
* ue Wingman 'structprop' doesn't work for UWingXXXRef types, or for Widget slots. It needs to be implemented on top of getdetails.
* In the console, do not allow multi-line lua expressions unless it's something that reasonably should be multi-line, like a function definition or an if-statement.
* Keyboard Event Handling
* Menus
* Add a slash-command to reload lua source code.
* Skeletal Mesh Tangible
* Implement Interactive Temporary Variables
* A better text console
* Get rid of 3x3 Gridpanel stuff
* Object-Oriented Lua Support

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": {
"[UNREALENGINE]/Engine/**": true,
"[UNREALENGINE]/Samples/**": true,
"[UNREALENGINE]/Templates/**": true
"[UNREALENGINE]/Templates/**": true,
"**/.*": true
},
"files.associations": {
"**/include/**": "cpp",
@@ -40,7 +41,7 @@
},
"editor.acceptSuggestionOnEnter": "off",
"C_Cpp.intelliSenseEngine": "disabled",
"clangd.path": "/usr/bin/clangd-15",
"clangd.path": "/usr/bin/clangd-16",
"clangd.arguments": [
"--log=verbose",
"--query-driver=/usr/bin/g++",
@@ -50,6 +51,9 @@
],
"C_Cpp.autocomplete": "disabled",
"search.useIgnoreFiles": true,
"files.readonlyInclude": {
"[UNREALENGINE]/**": true
},
"search.exclude": {
"**/Intermediate": true,
"**/Saved": true,
@@ -127,7 +131,7 @@
"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",
"target stop-hook add --one-liner \"p ::UngrabAllInputImpl()\""
"target stop-hook add --one-liner \"p FUnixPlatformMisc::UngrabAllInput()\""
]
}
},

View File

@@ -16,14 +16,12 @@ blueprints, widget blueprints, and materials.
## 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
will execute them. You can actually type commands directly
from the command-line. Here's an example of what you might
see:
will execute them.
```
$ 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
output-pins OutputDelegate
@@ -32,33 +30,30 @@ see:
output-pins OutputDelegate, DeltaSeconds
```
There are tons of commands built in: Graph_Dump,
GraphNode_Add, GraphPin_Connect,
BlueprintComponent_Add, Widget_Add, and so forth.
Using these commands, it's possible to examine and modify
blueprints, widgets, and materials.
The ue-wingman command has tons of subcommands: Graph_Dump,
GraphNode_Add, GraphPin_Connect, BlueprintComponent_Add,
Widget_Add, and so forth. Using these commands, it's
possible to examine and modify blueprints, widgets, and
materials.
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
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."
They're intended for an AI agent.
## 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
reasonably skilled software engineer and I've designed
this plugin to be robust and capable of sustained development.
This MCP is also designed to be as broadly general as
possible. I've seen MCPs that claim "can create 22 different
This plugin is also designed to be as broadly general as
possible. I've seen plugins that claim "can create 22 different
kinds of graph nodes!" This makes me ask: why not just
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*
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.
Some of the MCPs out there expose the entire Unreal API to
@@ -76,15 +71,16 @@ commands.
## 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 python program "ue-wingman.py" which a human can
use to send commands to the plugin.
* The python program "ue-wingman.py"
* The python program "ue-wingman-mcp.py", which an AI
can use to send commands to the plugin.
The python program is actually less than 100 lines of code:
all it does is package up its command line arguments, send
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
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
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"
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.
When the AI agent starts up ue-wingman-mcp, it is
automatically told to read the user manual. From there, the
User Manual says, among other things, that the AI agent can
get a listing of built-in commands. You can see that too:
You should put a note into your agent's system prompt to
let it know about the ue-wingman.py command, and to let
it know that it can type ue-wingman.py Documentation_Manual.
This in turn will tell your agent about this command:
```
$ ue-wingman.py Documentation_Commands
```
With these two commands at your disposal, you'll have a better
understanding of what exactly your AI agent is doing with this
plugin, and how it all works.
Using these commands, you can learn more about what this
plugin can do.
## Fun things to Try

View File

@@ -25,7 +25,7 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Asset to delete"))
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;
virtual void Register() override

View File

@@ -21,13 +21,13 @@ class UWing_Asset_Search : public UWingHandler
GENERATED_BODY()
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;
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;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Maximum number of results (default 50)"))
UPROPERTY(EditAnywhere, meta=(Description="Maximum number of results (default 50)"))
int32 Limit = 50;
virtual void Register() override

View File

@@ -32,16 +32,15 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Type of graph: function or macro"))
FString GraphType;
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=(Description="Variables"))
FWingRestOfArgv Variables;
virtual void Register() override
{
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
{
@@ -79,8 +78,7 @@ public:
// Parse and validate variables before making changes
WingVariables Vars;
if (!Vars.InputVariables.ParseString(InputVariables, WingOut::Stdout)) return;
if (!Vars.OutputVariables.ParseString(OutputVariables, WingOut::Stdout)) return;
if (!Vars.Parse(Variables.Argv, false, WingOut::Stdout)) return;
// Create the Graph
UEdGraph* NewGraph = FBlueprintEditorUtils::CreateNewGraph(BP, InternalID,

View File

@@ -27,7 +27,7 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Interface name to remove"))
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;
virtual void Register() override

View File

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

View File

@@ -37,5 +37,6 @@ public:
if (!P) return;
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()
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
{
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
{
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()
public:
UPROPERTY(EditAnywhere, meta=(Optional, Description="Substring filter for command names"))
UPROPERTY(EditAnywhere, meta=(Description="Substring filter for command names"))
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;
virtual void Register() override

View File

@@ -12,41 +12,21 @@ class UWing_Documentation_Manual : public UWingHandler
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, meta=(Optional, Description="section of the manual"))
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());
UWingServer::AddHandler(this, TEXT("Print the entire manual."));
}
virtual void Handle() override
{
TSet<FName> Sections = WingManual::GetSections();
if (Section.IsEmpty())
{
UWingManualSections::FetcherPaths();
UWingManualSections::ExpressingTypes();
UWingManualSections::VariableDeclarations();
UWingManualSections::EscapeSequencesInFNames();
UWingManualSections::MaterialEditing();
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);
}
}
UWingManualSections::FetcherPaths();
UWingManualSections::ExpressingTypes();
UWingManualSections::VariableDeclarations();
UWingManualSections::EscapeSequencesInFNames();
UWingManualSections::MaterialEditing();
UWingManualSections::NodeContextMenus();
UWingManualSections::VariableGettersAndSetters();
UWingManualSections::BestPerformance();
UWingManualSections::ImportantCommands();
}
};

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"))
FString Dispatcher;
UPROPERTY(EditAnywhere, meta=(Description="Input Variables, one per line, expressed as: type var = value"))
FString InputVariables;
UPROPERTY(EditAnywhere, meta=(Description="Variables"))
FWingRestOfArgv Variables;
virtual void Register() override
{
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
{
@@ -49,7 +52,7 @@ public:
// Parse the arguments.
WingVariables Vars;
if (!Vars.InputVariables.ParseString(InputVariables, WingOut::Stdout)) return;
if (!Vars.Parse(Variables.Argv, false, WingOut::Stdout)) return;
// Add the delegate variable
FEdGraphPinType DelegateType;

View File

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

View File

@@ -21,7 +21,7 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Target 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;
virtual void Register() override

View File

@@ -19,15 +19,15 @@ class UWing_GraphNode_SearchTypes : public UWingHandler
GENERATED_BODY()
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"))
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
{
UWingServer::AddHandler(this,
@@ -40,22 +40,8 @@ public:
UEdGraph* TargetGraph = F.Walk(Graph).Cast<UEdGraph>();
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);
for (const FString& Query : QueryStrings)
for (const FString& Query : Queries.Argv)
{
WingOut::Stdout.Printf(TEXT("\n=== %s ===\n\n"), *Query);
TArray<FWingGraphAction*> Results = GraphActions.Search(Query, MaxResults, false);

View File

@@ -4,36 +4,15 @@
#include "WingBasics.h"
#include "WingServer.h"
#include "WingFetcher.h"
#include "WingProperty.h"
#include "WingUtils.h"
#include "EdGraph/EdGraphPin.h"
#include "EdGraphSchema_K2.h"
#include "MaterialGraph/MaterialGraphSchema.h"
#include "GraphNode_SetDefaults.generated.h"
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
USTRUCT()
struct FSetNodeDefaultEntry
{
GENERATED_BODY()
UPROPERTY()
FString Node;
UPROPERTY()
FString Name;
UPROPERTY()
FString Value;
};
#include "GraphNode_SetDefault.generated.h"
UCLASS()
class UWing_GraphNode_SetDefaults : public UWingHandler
class UWing_GraphNode_SetDefault : public UWingHandler
{
GENERATED_BODY()
@@ -41,8 +20,14 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Target graph"))
FString Graph;
UPROPERTY(EditAnywhere, meta=(Description="Array of {node, name, value} objects"))
FWingJsonArray Pins;
UPROPERTY(EditAnywhere, meta=(Description="Target node"))
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
{
@@ -53,15 +38,15 @@ public:
// 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);
UWingGraphPinRef* PinRef = F.Node(Entry.Node).Pin(Entry.Name).Cast<UWingGraphPinRef>();
UWingGraphPinRef* PinRef = F.Node(Node).Pin(Name).Cast<UWingGraphPinRef>();
if (!PinRef) return;
UEdGraphPin* Pin = WingUtils::CheckGetPin(PinRef->Node, PinRef->PinName, WingOut::Stdout);
if (!Pin) return;
UEdGraphNode* Node = Pin->GetOwningNode();
UEdGraphNode* FoundNode = Pin->GetOwningNode();
if (Pin->Direction != EGPD_Input)
{
@@ -72,34 +57,34 @@ public:
FString UseDefaultValue;
TObjectPtr<UObject> UseDefaultObject = nullptr;
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);
if (!Error.IsEmpty())
{
WingOut::Stdout.Printf(TEXT("error: %s: %s\n"), *WingUtils::FormatName(Pin), *Error);
return;
}
UWingServer::AddTouchedObject(Node);
K2Schema->TrySetDefaultValue(*Pin, Entry.Value);
UWingServer::AddTouchedObject(FoundNode);
K2Schema->TrySetDefaultValue(*Pin, Value);
}
// -----------------------------------------------------------------------
// Material graphs: set material expression properties.
// -----------------------------------------------------------------------
void HandleMaterialEntry(const FSetNodeDefaultEntry& Entry, UEdGraph* GraphObj)
void HandleMaterial(UEdGraph* GraphObj)
{
WingFetcher F(GraphObj, WingOut::Stdout);
UEdGraphNode* Node = F.Node(Entry.Node).Cast<UEdGraphNode>();
if (!Node) return;
UEdGraphNode* FoundNode = F.Node(Node).Cast<UEdGraphNode>();
if (!FoundNode) return;
TArray<FWingProperty> All = FWingProperty::GetDetails(Node, true);
FWingProperty *P = WingUtils::FindOneWithExternalID(Entry.Name, All, TEXT("Property"), WingOut::Stdout);
TArray<FWingProperty> All = FWingProperty::GetDetails(FoundNode, true);
FWingProperty *P = WingUtils::FindOneWithExternalID(Name, All, TEXT("Property"), WingOut::Stdout);
if (!P) return;
UWingServer::AddTouchedObject(Node);
UWingServer::AddTouchedObject(FoundNode);
if (!P->SetText(Entry.Value, WingOut::Stdout))
if (!P->SetText(Value, WingOut::Stdout))
return;
}
@@ -122,14 +107,8 @@ public:
return;
}
FSetNodeDefaultEntry Entry;
TArray<FWingProperty> Props = FWingProperty::GetAll(&Entry);
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);
}
if (K2Schema) HandleK2(GraphObj, K2Schema);
else if (MGSchema) HandleMaterial(GraphObj);
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 "WingBasics.h"
#include "WingFetcher.h"
#include "WingProperty.h"
#include "WingUtils.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphSchema.h"
@@ -12,23 +11,6 @@
#include "GraphPin_Connect.generated.h"
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
USTRUCT()
struct FConnectPinsEntry
{
GENERATED_BODY()
UPROPERTY()
FString SourcePin;
UPROPERTY()
FString TargetPin;
};
UCLASS()
class UWing_GraphPin_Connect : public UWingHandler
{
@@ -38,8 +20,8 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Target graph"))
FString Graph;
UPROPERTY(EditAnywhere, meta=(Description="Array of {sourcePin, targetPin} objects"))
FWingJsonArray Connections;
UPROPERTY(EditAnywhere, meta=(Description="Alternating source pin / target pin strings"))
FWingRestOfArgv SourcePin_TargetPin;
virtual void Register() override
{
@@ -54,24 +36,27 @@ public:
UEdGraph* G = F.Walk(Graph).Cast<UEdGraph>();
if (!G) return;
int32 SuccessCount = 0;
int32 TotalCount = Connections.Array.Num();
FConnectPinsEntry Entry;
TArray<FWingProperty> EntryProps = FWingProperty::GetAll(&Entry);
for (const TSharedPtr<FJsonValue>& ConnVal : Connections.Array)
if ((SourcePin_TargetPin.Argv.Num() % 2) != 0)
{
if (!FWingProperty::PopulateFromJson(EntryProps, *ConnVal, false, WingOut::Stdout))
continue;
WingOut::Stdout.Print(TEXT("ERROR: SourcePin_TargetPin must contain an even number of arguments.\n"));
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);
UWingGraphPinRef* SourcePinRef = FS.Walk(Entry.SourcePin).Cast<UWingGraphPinRef>();
UWingGraphPinRef* SourcePinRef = FS.Walk(SourcePinPath).Cast<UWingGraphPinRef>();
if (!SourcePinRef) continue;
UEdGraphPin* SourcePin = WingUtils::CheckGetPin(SourcePinRef->Node, SourcePinRef->PinName, WingOut::Stdout);
if (!SourcePin) continue;
WingFetcher FT(G, WingOut::Stdout);
UWingGraphPinRef* TargetPinRef = FT.Walk(Entry.TargetPin).Cast<UWingGraphPinRef>();
UWingGraphPinRef* TargetPinRef = FT.Walk(TargetPinPath).Cast<UWingGraphPinRef>();
if (!TargetPinRef) continue;
UEdGraphPin* TargetPin = WingUtils::CheckGetPin(TargetPinRef->Node, TargetPinRef->PinName, WingOut::Stdout);
if (!TargetPin) continue;

View File

@@ -4,7 +4,6 @@
#include "WingServer.h"
#include "WingBasics.h"
#include "WingFetcher.h"
#include "WingProperty.h"
#include "WingUtils.h"
#include "EdGraph/EdGraph.h"
#include "EdGraph/EdGraphPin.h"
@@ -24,8 +23,8 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Target graph"))
FString Graph;
UPROPERTY(EditAnywhere, meta=(Description="Array of pin ID strings"))
FWingJsonArray Pins;
UPROPERTY(EditAnywhere, meta=(Description="Pin ID strings"))
FWingRestOfArgv Pins;
virtual void Register() override
{
@@ -43,15 +42,8 @@ public:
int32 SuccessCount = 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);
UWingGraphPinRef* PinRef = FP.Walk(PinPath).Cast<UWingGraphPinRef>();
if (!PinRef) continue;
@@ -72,6 +64,6 @@ public:
}
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"))
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;
virtual void Register() override

View File

@@ -1,41 +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. The big win "
"performance-wise is that fewer MCP calls means fewer "
"round-trip invocations of the LLM."));
}
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.
WingOut::Stdout.Print(
TEXT("ERROR: Sequence inside a Sequence is not allowed.\n"));
}
};

View File

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

View File

@@ -21,22 +21,16 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Path to a blueprint, graph, or custom event node"))
FString Object;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Blueprint variables, one per line"))
FString BlueprintVariables;
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;
UPROPERTY(EditAnywhere, meta=(Description="Variable descriptions"))
FWingRestOfArgv Variables;
virtual void Register() override
{
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
{
@@ -46,10 +40,7 @@ public:
WingVariables Vars;
if (!Vars.SetBackingStore(Obj, WingOut::Stdout)) return;
if (!Vars.BlueprintVariables.ParseString(BlueprintVariables, 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.Parse(Variables.Argv, false, WingOut::Stdout)) return;
if (!Vars.Check(WingOut::Stdout)) return;
if (!Vars.Create(WingOut::Stdout)) return;
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"))
FString Object;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Blueprint variables, one per line"))
FString BlueprintVariables;
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;
UPROPERTY(EditAnywhere, meta=(Description="Variable descriptions"))
FWingRestOfArgv Variables;
virtual void Register() override
{
UWingServer::AddHandler(this,
TEXT("Modify variables of a blueprint, function graph, "
"macro graph, event dispatcher graph, or custom event node. "));
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
{
@@ -47,10 +40,7 @@ public:
WingVariables Vars;
if (!Vars.SetBackingStore(Obj, WingOut::Stdout)) return;
if (!Vars.BlueprintVariables.ParseString(BlueprintVariables, 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.Parse(Variables.Argv, false, WingOut::Stdout)) return;
if (!Vars.Check(WingOut::Stdout)) return;
if (!Vars.Modify(WingOut::Stdout)) return;
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"))
FString Object;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Blueprint variable names to remove, comma-separated"))
FString BlueprintVariables;
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;
UPROPERTY(EditAnywhere, meta=(Description="Variable descriptions"))
FWingRestOfArgv Variables;
virtual void Register() override
{
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
{
@@ -46,10 +39,7 @@ public:
WingVariables Vars;
if (!Vars.SetBackingStore(Obj, WingOut::Stdout)) return;
if (!Vars.BlueprintVariables.ParseNamesString(BlueprintVariables, 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.Parse(Variables.Argv, true, WingOut::Stdout)) return;
if (!Vars.Remove(WingOut::Stdout)) return;
WingOut::Stdout.Printf(TEXT("Success.\n"));
}

View File

@@ -33,10 +33,10 @@ public:
UPROPERTY(EditAnywhere, meta=(Description="Name for the new widget"))
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;
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;
virtual void Register() override
@@ -117,6 +117,10 @@ public:
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);
}
};

View File

@@ -17,12 +17,12 @@ class UWing_Widget_SearchTypes : public UWingHandler
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, meta=(Description="Array of query strings; each may contain *"))
FWingJsonArray Queries;
UPROPERTY(EditAnywhere, meta=(Optional, Description="Maximum number of results per query (default 50)"))
UPROPERTY(EditAnywhere, meta=(Description="Maximum number of results per query"))
int32 MaxResults = 50;
UPROPERTY(EditAnywhere, meta=(Description="Query strings; each may contain *"))
FWingRestOfArgv Queries;
virtual void Register() override
{
UWingServer::AddHandler(this,
@@ -31,22 +31,8 @@ public:
}
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;
for (const FString& Query : QueryStrings)
for (const FString& Query : Queries.Argv)
{
WingOut::Stdout.Printf(TEXT("\n=== %s ===\n\n"), *Query);
TArray<WingWidgets::Type> Results = Widgets.Search(Query, MaxResults, false);

View File

@@ -1,6 +1,7 @@
#include "WingFetcher.h"
#include "WingServer.h"
#include "WingBasics.h"
#include "WingProperty.h"
#include "WingUtils.h"
#include "WingComponent.h"
#include "Engine/Blueprint.h"
@@ -368,36 +369,27 @@ WingFetcher& WingFetcher::StructProp(const FString& Value)
return SetError();
}
FStructProperty* StructProp = nullptr;
TArray<FWingProperty> Details =
FWingProperty::GetDetails(Obj.Get(), true);
// The "host" is the object containing this property.
UObject *HostObject = Obj.Get();
void *HostBase = Obj.Get();
UStruct *HostType = Obj.Get()->GetClass();
bool HostEditable = true;
FWingProperty *WProp = WingUtils::FindOneWithInternalID(
InternalID, Details, TEXT("Property"), WingOut::Stdout);
if (!WProp) return SetError();
// If we are *already* inside a UWingStructRef, update the host
// fields, to make it possible to navigate even further inside.
if (UWingStructRef *SPtr = ::Cast<UWingStructRef>(Obj.Get()))
if (FStructProperty *FSProp = CastField<FStructProperty>(WProp->Prop))
{
HostObject = SPtr->Object;
HostBase = SPtr->StructBase;
HostType = SPtr->StructType;
HostEditable = SPtr->Editable;
UWingStructRef* Ptr = NewObject<UWingStructRef>();
Ptr->Object = WProp->Object.Get();
Ptr->StructType = FSProp->Struct;
Ptr->StructBase = FSProp->ContainerPtrToValuePtr<void>(WProp->Container);
Ptr->Editable = WProp->Editable;
SetObj(Ptr);
return *this;
}
StructProp = FindFProperty<FStructProperty>(HostType, InternalID);
if (!StructProp)
else
{
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();
}
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)
{
WingOut::Stdout.Print(TEXT("ue-wingman "));
WingOut::Stdout.Print(Handler.Name);
WingOut::Stdout.Print(TEXT("("));
bool bFirst = true;
for (TFieldIterator<FProperty> PropIt(Handler.HandlerClass.Get(), EFieldIterationFlags::None); PropIt; ++PropIt)
{
if (!bFirst) WingOut::Stdout.Print(TEXT(","));
bFirst = false;
if (PropIt->HasMetaData(TEXT("Optional"))) WingOut::Stdout.Print(TEXT("?"));
WingOut::Stdout.Print(PropIt->GetName());
FStructProperty* StructProp = CastField<FStructProperty>(*PropIt);
const bool bIsRest =
StructProp && (StructProp->Struct == FWingRestOfArgv::StaticStruct());
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)
@@ -26,197 +32,206 @@ void WingManual::PrintHandlerArguments(const FWingHandlerConfig& Handler)
{
FProperty* Prop = *PropIt;
FString Name = Prop->GetName();
FString Type = UWingTypes::TypeToText(Prop);
bool bOptional = Prop->HasMetaData(TEXT("Optional"));
const FString& Desc = Prop->GetMetaData(TEXT("Description"));
FString Desc = Prop->GetMetaData(TEXT("Description"));
if (Desc.IsEmpty()) Desc = TEXT("No documentation");
if (bOptional)
{
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"));
WingOut::Stdout.Printf(TEXT(" %s - %s\n"), *Name, *Desc);
}
}
void WingManual::PrintHandlerDescription(const FWingHandlerConfig& Handler)
{
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)
{
WingOut::Stdout.Print(TEXT("\n"));
PrintHandlerPrototype(Handler);
PrintHandlerArguments(Handler);
PrintHandlerDescription(Handler);
PrintHandlerPrototype(Handler);
PrintHandlerArguments(Handler);
PrintHandlerDescription(Handler);
WingOut::Stdout.Print(TEXT("\n"));
}
void UWingManualSections::FetcherPaths()
{
WingOut::Stdout.Print(TEXT(
"\n FETCHER PATHS:"
"\n"
"\n Most commands require you to specify a 'fetcher path'."
"\n A fetcher path starts with an asset name, followed by"
"\n steps that navigate into the asset. Some Examples:"
"\n"
"\n /Game/Widgets/WB_Hotkeys,widget:Canvas.122"
"\n /Game/Testing/BP_Test,graph:Rescale.Actor,node:K2Node_CallFunction_0,pin:Scale"
"\n /Game/Chars/BP_Manny,component:Camera.Boom"
"\n"
"\n The navigation steps supported are:"
"\n"
"\n graph — move from a blueprint or material to a graph."
"\n node — move from a graph to a graph node"
"\n pin — move from a graph node to a pin"
"\n component — move from a blueprint to a component"
"\n levelblueprint — move from a world to a blueprint"
"\n widget — move from a widget blueprint to a widget"
"\n structprop — move into a struct property of an object"
"\n"
"\n Notice that paths use escaped fnames. See the section"
"\n on escape sequences in fnames below sfor more information."
"\n"
"\n Steps do not always require a parameter. For example, materials"
"\n only have one graph, so you can just say:"
"\n"
"\n /Game/Materials/MyMaterial,graph"
"\n"
));
WingOut::Stdout.Print(TEXT(
"\n FETCHER PATHS:"
"\n"
"\n Most commands require you to specify a 'fetcher path'."
"\n A fetcher path starts with an asset name, followed by"
"\n steps that navigate into the asset. Some Examples:"
"\n"
"\n /Game/Widgets/WB_Hotkeys,widget:Canvas.122"
"\n /Game/Testing/BP_Test,graph:Rescale.Actor,node:K2Node_CallFunction_0,pin:Scale"
"\n /Game/Chars/BP_Manny,component:Camera.Boom"
"\n"
"\n The navigation steps supported are:"
"\n"
"\n graph — move from a blueprint or material to a graph."
"\n node — move from a graph to a graph node"
"\n pin — move from a graph node to a pin"
"\n component — move from a blueprint to a component"
"\n levelblueprint — move from a world to a blueprint"
"\n widget — move from a widget blueprint to a widget"
"\n structprop — move into a struct property of an object"
"\n"
"\n Notice that paths use escaped fnames. See the section"
"\n on escape sequences in fnames below sfor more information."
"\n"
"\n Steps do not always require a parameter. For example, materials"
"\n only have one graph, so you can just say:"
"\n"
"\n /Game/Materials/MyMaterial,graph"
"\n"
));
}
void UWingManualSections::ExpressingTypes()
{
WingOut::Stdout.Print(TEXT(
"\n EXPRESSING TYPES:"
"\n"
"\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"
"\n Bool, String, Vector, Rotator, HitResult, Actor, Character,"
"\n PlayerController, EBlendMode, EMovementMode, BP_Manny, BP_Quinn,"
"\n Array<Int>, Set<String>, Map<Int,Actor>"
"\n Soft<ABP_Manny>, Class<Pawn>, SoftClass<Pawn>"
"\n"
"\n Notice that it's 'Actor', not 'AActor'. Type names are not"
"\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 for valid types using the TypeName_Search command."
"\n"
));
WingOut::Stdout.Print(TEXT(
"\n EXPRESSING TYPES:"
"\n"
"\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"
"\n Bool, String, Vector, Rotator, HitResult, Actor, Character,"
"\n PlayerController, EBlendMode, EMovementMode, BP_Manny, BP_Quinn,"
"\n Array<Int>, Set<String>, Map<Int,Actor>"
"\n Soft<ABP_Manny>, Class<Pawn>, SoftClass<Pawn>"
"\n"
"\n Notice that it's 'Actor', not 'AActor'. Type names are not"
"\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 for valid types using the TypeName_Search command."
"\n"
));
}
void UWingManualSections::VariableDeclarations()
{
WingOut::Stdout.Print(TEXT(
"\n VARIABLE DECLARATIONS:"
"\n"
"\n We have our own syntax for variable declarations: a type,"
"\n a name, optional flags, and an optional default value,"
"\n always on one line:"
"\n"
"\n Array<Actor> Actors"
"\n Float F (InstanceEditable)"
"\n 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"
));
WingOut::Stdout.Print(TEXT(
"\n VARIABLE DECLARATIONS:"
"\n"
"\n We have our own syntax for variable declarations:"
"\n"
"\n kind type name (optional flags) = optional default value"
"\n"
"\n Kind can be:"
"\n"
"\n blueprint - eg, instance variables"
"\n input - a function argument or macro input"
"\n output - a function return value or macro output"
"\n local - local variables"
"\n"
"\n Here are some examples:"
"\n"
"\n input Array<Actor> Actors"
"\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()
{
WingOut::Stdout.Print(TEXT(
"\n ESCAPE SEQUENCES IN FNAMES:"
"\n"
"\n When we output FNames, we use HTML escape sequences for the"
"\n following marks: \\\"'(),.:;<=>&, and for certain other characters."
"\n We also translate spaces to periods."
"\n"
"\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 with a space in it, either use &#32; or a period."
"\n"
));
WingOut::Stdout.Print(TEXT(
"\n ESCAPE SEQUENCES IN FNAMES:"
"\n"
"\n When we output FNames, we use HTML escape sequences for the"
"\n following marks: \\\"'(),.:;<=>&, and for certain other characters."
"\n We also translate spaces to periods."
"\n"
"\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 with a space in it, either use &#32; or a period."
"\n"
));
}
void UWingManualSections::MaterialEditing()
{
WingOut::Stdout.Print(TEXT(
"\n MATERIAL EDITING:"
"\n"
"\n We do not expose material expressions directly. Instead, you"
"\n will be editing the material graph. However, if you Graph_Dump"
"\n a material graph, you will see that the nodes contain"
"\n properties which actually come from the material expressions."
"\n You can edit these using Details_Set on the node."
"\n"
"\n Don't overlook custom HLSL nodes. These can accomplish in\n"
"\n a single node what would otherwise take many.\n"
"\n"
));
WingOut::Stdout.Print(TEXT(
"\n MATERIAL EDITING:"
"\n"
"\n We do not expose material expressions directly. Instead, you"
"\n will be editing the material graph. However, if you Graph_Dump"
"\n a material graph, you will see that the nodes contain"
"\n properties which actually come from the material expressions."
"\n You can edit these using Details_Set on the node."
"\n"
"\n Don't overlook custom HLSL nodes. These can accomplish in\n"
"\n a single node what would otherwise take many.\n"
"\n"
));
}
void UWingManualSections::NodeContextMenus()
{
WingOut::Stdout.Print(TEXT(
"\n NODE CONTEXT MENUS:"
"\n"
"\n GraphNode_ShowMenu and GraphNode_ChooseMenu give access"
"\n to the node context menu. This menu includes both node"
"\n operations and pin operations (e.g. Split Struct Pin,"
"\n Add Pin)."
"\n"
));
WingOut::Stdout.Print(TEXT(
"\n NODE CONTEXT MENUS:"
"\n"
"\n GraphNode_ShowMenu and GraphNode_ChooseMenu give access"
"\n to the node context menu. This menu includes both node"
"\n operations and pin operations (e.g. Split Struct Pin,"
"\n Add Pin)."
"\n"
));
}
void UWingManualSections::VariableGettersAndSetters()
{
WingOut::Stdout.Print(TEXT(
"\n VARIABLE GETTERS AND SETTERS:"
"\n"
"\n Access to local vars, function parameters, and "
"\n blueprint vars is through getter and setter nodes. "
"\n These can be found in GraphNode_SearchTypes by "
"\n searching for 'Variable'. Some examples:"
"\n"
"\n SKEL_WB_Menu_C|Variables|Default|GetPlaceTangible"
"\n SKEL_WB_Menu_C|Variables|Default|SetPlaceTangible"
"\n SKEL_WB_Menu_C|Variables|WB_Menu|GetMenuPanel"
"\n"
));
WingOut::Stdout.Print(TEXT(
"\n VARIABLE GETTERS AND SETTERS:"
"\n"
"\n Access to local vars, function parameters, and "
"\n blueprint vars is through getter and setter nodes. "
"\n These can be found in GraphNode_SearchTypes by "
"\n searching for 'Variable'. Some examples:"
"\n"
"\n SKEL_WB_Menu_C|Variables|Default|GetPlaceTangible"
"\n SKEL_WB_Menu_C|Variables|Default|SetPlaceTangible"
"\n SKEL_WB_Menu_C|Variables|WB_Menu|GetMenuPanel"
"\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()
{
WingOut::Stdout.Print(TEXT(
"\n IMPORTANT COMMANDS:"
"\n"
"\n Documentation_Manual: print manual sections"
"\n Documentation_Commands: a list of all the main commands"
"\n Documentation_CreateAssets: Additional commands that create new assets"
"\n Blueprint_Dump: a summary of any blueprint"
"\n Graph_Dump: a fairly detailed listing of any Graph"
"\n Details_Dump: Dump the details panel for a given object"
"\n Details_Set: Manipulate the details panel for a given object"
"\n Sequence: Batch commands together for faster execution"
"\n"
"\n You can use Documentation_Commands(Query=Command,Verbose=true)"
"\n to get detailed help for a specific command."
"\n"
));
WingOut::Stdout.Print(TEXT(
"\n IMPORTANT COMMANDS:"
"\n"
"\n Documentation_Manual: print the entire manual"
"\n Documentation_Section: print a single section of the manual"
"\n Documentation_Commands: print concise list of all ue-wingman commands"
"\n Documentation_Command: detailed documentation for a single ue-wingman command"
"\n Documentation_CreateAssets: list of commands that create new assets"
"\n Blueprint_Dump: a summary of any blueprint"
"\n Graph_Dump: a fairly detailed listing of any Graph"
"\n Details_Dump: Dump the details panel for a given object"
"\n Details_Set: Manipulate the details panel for a given object"
"\n"
));
}
TSet<FName> WingManual::GetSections()
@@ -232,7 +247,7 @@ TSet<FName> WingManual::GetSections()
void WingManual::PrintSectionNames(const TCHAR *Prefix, const TSet<FName>& Sections, WingOut Output)
{
if (Sections.IsEmpty()) return;
if (Prefix) Output.Print(Prefix);
if (Prefix) Output.Print(Prefix);
bool bFirst = true;
for (const FName& Section : Sections)
{
@@ -240,7 +255,7 @@ void WingManual::PrintSectionNames(const TCHAR *Prefix, const TSet<FName>& Secti
bFirst = false;
Output.Printf(TEXT("%s"), *Section.ToString());
}
if (Prefix) Output.Print(TEXT("\n"));
if (Prefix) Output.Print(TEXT("\n"));
}
bool WingManual::PrintSection(FName Section)
@@ -256,10 +271,12 @@ void WingManual::Commands(EWingHandlerKind Kind, const FString& Query, bool Verb
FString QueryLower = Query.ToLower();
FString PrevGroup;
bool any = false;
for (const FWingHandlerConfig& H : UWingServer::AllHandlers())
{
if (H.Kind != Kind) continue;
if (!H.Name.ToLower().Contains(QueryLower)) continue;
any = true;
// Blank line between groups
if (!Verbose)
@@ -278,4 +295,9 @@ void WingManual::Commands(EWingHandlerKind Kind, const FString& Query, bool Verb
else
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.
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);
// Apply the update.
@@ -308,7 +308,7 @@ void FWingParameterEditor::Print(const Info &ID, const Metadata &Meta)
}
FWingParameterEditor Editor;
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"),
*StringID(ID), *StrVal);
}

View File

@@ -194,57 +194,6 @@ bool FWingProperty::SetText(FString Value, WingOut Errors) const
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
{
FObjectPropertyBase *OProp = CastField<FObjectPropertyBase>(Prop);
@@ -378,25 +327,25 @@ FString FWingProperty::GetCategory() const
return Result;
}
TArray<FWingProperty> FWingProperty::GetAll(FWingStructAndUStruct Obj)
TArray<FWingProperty> FWingProperty::GetAll(UObject *Obj, void *Container, UStruct *Struct, bool Mutable)
{
TArray<FWingProperty> Result;
for (TFieldIterator<FProperty> It(Obj.UStructPtr); It; ++It)
for (TFieldIterator<FProperty> It(Struct); It; ++It)
{
bool Editable = !It->HasAnyPropertyFlags(CPF_EditConst);
Result.Add(FWingProperty(*It, Obj.StructPtr, Editable));
bool Editable = Mutable && !It->HasAnyPropertyFlags(CPF_EditConst);
Result.Emplace(Obj, Container, *It, Editable);
}
return Result;
}
TArray<FWingProperty> FWingProperty::GetVisible(FWingStructAndUStruct Obj)
TArray<FWingProperty> FWingProperty::GetVisible(UObject *Obj, void *Container, UStruct *Struct, bool Mutable)
{
TArray<FWingProperty> Result;
for (TFieldIterator<FProperty> It(Obj.UStructPtr); It; ++It)
for (TFieldIterator<FProperty> It(Struct); It; ++It)
{
if (!It->HasAllPropertyFlags(CPF_Edit)) continue;
bool Editable = !It->HasAnyPropertyFlags(CPF_EditConst);
Result.Add(FWingProperty(*It, Obj.StructPtr, Editable));
bool Editable = Mutable && !It->HasAnyPropertyFlags(CPF_EditConst);
Result.Emplace(Obj, Container, *It, Editable);
}
return Result;
}
@@ -436,10 +385,8 @@ TArray<FWingProperty> FWingProperty::GetDetails(UObject* Obj, bool Mutable)
// of the struct instead. Propagate editability of the host.
if (UWingStructRef *SP = Cast<UWingStructRef>(Obj))
{
TArray<FWingProperty> Result =
GetVisible(FWingStructAndUStruct(SP->StructBase, SP->StructType));
if (!Mutable || (!SP->Editable)) StripEditable(Result);
return Result;
return GetVisible(SP->Object, SP->StructBase,
SP->StructType, Mutable && SP->Editable);
}
// Blueprints don't have editable properties. So
@@ -465,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
// the associated material expression.
@@ -474,7 +421,7 @@ TArray<FWingProperty> FWingProperty::GetDetails(UObject* Obj, bool Mutable)
{
if (UMaterialExpression* Expr = MatNode->MaterialExpression)
{
Result.Append(GetVisible(Expr));
Result.Append(GetVisible(Expr, Mutable));
}
}
@@ -486,66 +433,63 @@ TArray<FWingProperty> FWingProperty::GetDetails(UObject* Obj, bool Mutable)
FWingProperty::Remove(Result, TEXT("Slot"));
if (UPanelSlot* Slot = Widget->Slot)
{
Result.Append(GetVisible(Slot));
Result.Append(GetVisible(Slot, Mutable));
}
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;
}
bool FWingProperty::PopulateFromJson(TArray<FWingProperty>& Props, const FJsonObject& Json, bool AllOptional, WingOut Errors)
bool FWingProperty::PopulateFromArgv(TArray<FWingProperty>& Props, TConstArrayView<FString> Argv, WingOut Errors)
{
bool Ok = true;
// 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)
int32 ArgIndex = 0;
for (int32 PropIndex = 0; PropIndex < Props.Num(); ++PropIndex)
{
FName Name = WingUtils::CheckInternalizeID(KV.Key, Errors);
if (!KnownKeys.Contains(Name))
{
Errors.Printf(TEXT("ERROR: Unknown parameter '%s'\n"), *KV.Key);
Ok = false;
}
}
FWingProperty& P = Props[PropIndex];
FStructProperty* StructProp = CastField<FStructProperty>(P.Prop);
const bool bIsRest =
StructProp && (StructProp->Struct == FWingRestOfArgv::StaticStruct());
// Populate each property from JSON
for (FWingProperty& P : Props)
{
FString JsonKey = WingUtils::FormatName(P.Prop);
TSharedPtr<FJsonValue> Value = Json.TryGetField(JsonKey);
if (!Value)
if (bIsRest)
{
bool Optional = AllOptional || P.Prop->HasMetaData(TEXT("Optional"));
if (!Optional)
if (PropIndex + 1 != Props.Num())
{
Errors.Printf(TEXT("ERROR: Missing required parameter '%s'\n"), *JsonKey);
Ok = false;
Errors.Printf(TEXT("ERROR: '%s' must be the last parameter\n"),
*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;
}
if (!P.SetJson(*Value, Errors)) Ok = false;
}
return Ok;
}
bool FWingProperty::PopulateFromJson(TArray<FWingProperty>& Props, const FJsonValue& Json, bool AllOptional, WingOut Errors)
{
// Make sure they passed in a JSON object.
TSharedPtr<FJsonObject> Obj = Json.AsObject();
if (Obj == nullptr)
if (ArgIndex >= Argv.Num())
{
Errors.Printf(TEXT("ERROR: Missing parameter '%s'\n"),
*WingUtils::FormatName(P.Prop));
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 PopulateFromJson(Props, *Obj, AllOptional, Errors);
return true;
}
@@ -580,11 +524,6 @@ bool FWingProperty::CheckImportTextResult(const FString &Value, WingOut Errors)
return true;
}
void FWingProperty::StripEditable(TArray<FWingProperty> &Props)
{
for (FWingProperty &Elt : Props) Elt.Editable = false;
}
bool FWingProperty::CheckEditable(WingOut Errors) const
{
if (!Editable)

View File

@@ -4,6 +4,7 @@
#include "UObject/StrongObjectPtr.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "Misc/CoreDelegates.h"
#include "Misc/OutputDeviceRedirector.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
@@ -55,8 +56,7 @@ void UWingServer::Initialize(FSubsystemCollectionBase& Collection)
return;
}
BuildWingHandlerRegistry();
ModulesChangedHandle = FModuleManager::Get().OnModulesChanged().AddUObject(this, &UWingServer::OnModulesChanged);
LoadingPhasesCompleteHandle = FCoreDelegates::OnAllModuleLoadingPhasesComplete.AddUObject(this, &UWingServer::BuildWingHandlerRegistry);
LogCapture.bEnabled = false;
GLog->AddOutputDevice(&LogCapture);
bRunning = true;
@@ -65,7 +65,7 @@ void UWingServer::Initialize(FSubsystemCollectionBase& Collection)
void UWingServer::Deinitialize()
{
FModuleManager::Get().OnModulesChanged().Remove(ModulesChangedHandle);
FCoreDelegates::OnAllModuleLoadingPhasesComplete.Remove(LoadingPhasesCompleteHandle);
if (!bRunning)
{
@@ -81,7 +81,7 @@ void UWingServer::Deinitialize()
bShuttingDown = true;
for (auto& Msg : PendingMessages)
{
Msg->Response.SetValue(FString());
Msg->Response.SetValue(TArray<uint8>());
}
PendingMessages.Empty();
}
@@ -150,7 +150,7 @@ void UWingServer::Tick(float DeltaTime)
// If we have a request, process it.
if (Request.IsValid())
{
FString Response = HandleRequest(Request->Line);
TArray<uint8> Response = HandleRequest(Request->Request);
Request->Response.SetValue(Response);
}
}
@@ -169,71 +169,24 @@ TStatId UWingServer::GetStatId() const
// 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.
TSharedPtr<FJsonValue> Value;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Line);
if (!FJsonSerializer::Deserialize(Reader, Value))
return PackageResponses({TEXT("Invalid Json")});
TArray<FString> Argv;
FString ResponseText;
const TSharedPtr<FJsonObject>* RequestPtr = nullptr;
if (!Value->TryGetObject(RequestPtr))
return PackageResponses({TEXT("Json must be an object")});
TSharedPtr<FJsonObject> Request = *RequestPtr;
FString Command;
Request->TryGetStringField(TEXT("command"), Command);
if (Command == TEXT("Sequence"))
if (DeserializeArgv(RequestBytes, Argv))
{
const TArray<TSharedPtr<FJsonValue>>* Subcommands = nullptr;
if (!Request->TryGetArrayField(TEXT("subcommands"), Subcommands))
return PackageResponses({TEXT("Sequence requires a 'subcommands' array.")});
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);
PreCallHandler();
TryCallHandler(Argv);
ResponseText = PostCallHandler();
}
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)
{
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)
void UWingServer::PreCallHandler()
{
LogCapture.CapturedErrors.Empty();
LogCapture.bEnabled = true;
@@ -241,9 +194,10 @@ FString UWingServer::HandleJsonRequest(TSharedPtr<FJsonObject> Request)
SuggestedManualSections.Empty();
bSuggestHandlerHelp = false;
LastHandler = nullptr;
}
TryCallHandler(Request);
FString UWingServer::PostCallHandler()
{
Notifier.SendNotifications();
LogCapture.bEnabled = false;
for (const FString& Msg : LogCapture.CapturedErrors)
@@ -269,17 +223,21 @@ FString UWingServer::HandleJsonRequest(TSharedPtr<FJsonObject> Request)
return Result;
}
void UWingServer::TryCallHandler(TSharedPtr<FJsonObject> Request)
void UWingServer::TryCallHandler(TArrayView<const FString> Argv)
{
// Extract the command from the request.
FString Command;
if (!Request->TryGetStringField(TEXT("command"), Command))
FString Command = "Documentation_Manual";
if (Argv.Num() > 0)
{
WingOut::Stdout.Printf(TEXT("Request does not contain 'command' parameter"));
WingOut::Stdout.Printf(TEXT("We recommend sending command='Documentation_Manual'."));
return;
Command = Argv[0];
Argv = Argv.RightChop(1);
}
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.
FWingHandlerConfig* Found = FindHandler(Command);
@@ -296,9 +254,9 @@ void UWingServer::TryCallHandler(TSharedPtr<FJsonObject> Request)
UWingHandler* Handler = Cast<UWingHandler>(HandlerObj.Get());
Handler->Configuration = Found;
// Populate the handler object with the request parameters.
TArray<FWingProperty> Props = FWingProperty::GetVisible(Handler);
if (!FWingProperty::PopulateFromJson(Props, *Request, false, WingOut::Stdout))
// Populate the handler object with argv parameters.
TArray<FWingProperty> Props = FWingProperty::GetVisible(Handler, true);
if (!FWingProperty::PopulateFromArgv(Props, Argv, WingOut::Stdout))
{
UWingServer::SuggestHandlerHelp();
return;
@@ -356,102 +314,105 @@ void UWingServer::CleanupFinishedClients()
void UWingServer::ClientThreadFunc(UWingServer* Server, TSharedPtr<FClientConnection> Client)
{
constexpr int32 MaxRecvBufBytes = 1024 * 1024;
constexpr int32 MinUnusedRecvSpace = 4096;
FSocket* Socket = Client->Socket;
TArray<uint8> RecvBuf;
RecvBuf.SetNumUninitialized(MinUnusedRecvSpace);
int32 RecvLen = 0;
WaitForAssetRegistry();
while (true)
TArray<uint8> Request;
if (!ReceiveRequest(Socket, Request))
{
FString Request;
if (ExtractRequestFromBuffer(RecvBuf, RecvLen, Request))
{
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;
}
Client->bDone = true;
return;
}
TArray<uint8> Response;
if (!ProcessRequestOnGameThread(Request, Response))
{
Client->bDone = true;
return;
}
SendAll(Socket, Response.GetData(), Response.Num());
Client->bDone = true;
}
bool UWingServer::ExtractRequestFromBuffer(
TArray<uint8>& RecvBuf, int32& RecvLen, FString& OutRequest)
uint32 UWingServer::UnpackBigEndian(const uint8 *Data)
{
const uint8* EndOfRequest = static_cast<const uint8*>(
memchr(RecvBuf.GetData(), '\0', RecvLen));
if (EndOfRequest == nullptr)
return
((uint32)Data[0] << 24) |
((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;
}
bool UWingServer::ReceiveMoreBytesIntoBuffer(
FSocket* Socket, TArray<uint8>& RecvBuf, int32& RecvLen)
bool UWingServer::ReceiveRequest(FSocket* Socket, TArray<uint8>& OutRequest)
{
constexpr int32 MaxRecvBufBytes = 1024 * 1024;
constexpr int32 MinUnusedRecvSpace = 4096;
constexpr int32 ChunkSize = 8192;
int32 UnusedSpace = RecvBuf.Num() - RecvLen;
if (UnusedSpace < MinUnusedRecvSpace)
TArray<uint8> RecvBuf;
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;
}
RecvBuf.SetNumUninitialized(RecvBuf.Num() * 2);
UnusedSpace = RecvBuf.Num() - RecvLen;
RecvBuf.Append(Temp, BytesRead);
}
int32 BytesRead = 0;
if (!Socket->Recv(RecvBuf.GetData() + RecvLen, UnusedSpace, BytesRead))
{
return false;
}
if (BytesRead <= 0)
{
return false;
}
if (RecvBuf.Num() < 4) return false;
uint32 Size = UnpackBigEndian(RecvBuf.GetData());
if ((uint32)RecvBuf.Num() != (4u + Size)) return false;
RecvBuf.RemoveAt(0, 4);
RecvLen += BytesRead;
OutRequest = MoveTemp(RecvBuf);
return true;
}
@@ -471,13 +432,13 @@ bool UWingServer::SendAll(FSocket* Socket, const uint8* Data, int32 BytesToSend)
}
bool UWingServer::ProcessRequestOnGameThread(
const FString& Request, FString& Response)
const TArray<uint8>& Request, TArray<uint8>& Response)
{
// Enqueue the message for game-thread processing.
TSharedPtr<UWingServer::FPendingMessage> Msg =
MakeShared<UWingServer::FPendingMessage>();
Msg->Line = Request;
TFuture<FString> Future = Msg->Response.GetFuture();
Msg->Request = Request;
TFuture<TArray<uint8>> Future = Msg->Response.GetFuture();
{
FScopeLock Lock(&GWingServer->Mutex);
@@ -534,11 +495,6 @@ void UWingServer::BuildWingHandlerRegistry()
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)
{
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()))
{
Errors.Printf(TEXT("In this context, %s must be empty."), ListName);
Errors.Printf(TEXT("This object does not support %s.\n"), ListName);
return false;
}
for (const Var &Variable : Variables)
@@ -112,116 +112,6 @@ bool WingVariableList::CheckSanity(const TSet<FName> &GoodFlags, bool Allow, Win
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()
{
BlueprintVariables.Empty();
@@ -254,6 +144,107 @@ void WingVariables::Print(WingOut 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)
{
Empty();
@@ -325,7 +316,7 @@ WingVariables::Var WingVariables::LoadBlueprintVariableDescription(FBPVariableDe
FProperty* Prop = CDO->GetClass()->FindPropertyByName(Desc.VarName);
if (Prop)
{
Result.DefaultValue = FWingProperty(Prop, CDO, false).GetText();
Result.DefaultValue = FWingProperty(CDO, CDO, Prop, false).GetText();
Result.DefaultSpecified = true;
}
}
@@ -500,7 +491,7 @@ bool WingVariables::ModifyBlueprintDefaults(WingOut Errors)
*WingTokenizer::ExternalizeID(Input.Name));
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;

View File

@@ -62,32 +62,18 @@ public:
////////////////////////////////////////////////////////////
//
// Json wrappers.
//
// 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.
// A simple type to store the remaining arguments in
// an Argv Array.
//
////////////////////////////////////////////////////////////
USTRUCT()
struct FWingJsonObject
struct FWingRestOfArgv
{
GENERATED_BODY()
TSharedPtr<FJsonObject> Json;
};
// Marker struct for handler parameters that accept a JSON array.
// PopulateFromJson stashes the actual JSON array into the Array field.
//
USTRUCT()
struct FWingJsonArray
{
GENERATED_BODY()
TArray<TSharedPtr<FJsonValue>> Array;
UPROPERTY()
TArray<FString> Argv;
};
////////////////////////////////////////////////////////////
@@ -129,38 +115,6 @@ private:
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.
@@ -225,10 +179,11 @@ public:
UPROPERTY()
UObject* Object;
void *StructBase;
UPROPERTY()
UStruct* StructType;
void *StructBase;
bool Editable;
};

View File

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

View File

@@ -3,20 +3,24 @@
#include "CoreMinimal.h"
#include "WingBasics.h"
// A resolved property: the FProperty descriptor plus a pointer to
// the value's storage.
//
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;
FProperty* Prop = nullptr;
bool Editable = false;
// Construct a property reference.
//
FWingProperty(FProperty* InProp, void* InContainer, bool Edit)
: Prop(InProp), Container(InContainer), Editable(Edit) {}
FWingProperty(UObject *InObject, void* InContainer, FProperty* InProp, bool Edit)
: Object(InObject), Container(InContainer), Prop(InProp), Editable(Edit) {}
// Construct a null property reference.
//
@@ -37,7 +41,6 @@ struct FWingProperty
bool SetInt64(int64 I, WingOut Errors) const;
bool SetBool(bool B, 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
// mismatch, returns an empty optional and prints an
@@ -72,14 +75,24 @@ struct FWingProperty
//
// This gets the properties that are literally present in the
// 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.
//
// 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.
//
@@ -108,18 +121,7 @@ struct FWingProperty
//
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:
static void StripEditable(TArray<FWingProperty> &Props);
static bool IsUnsigned(FNumericProperty* Prop);
static bool IsPinTypeProperty(FProperty *Prop);
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 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:
static UWingServer* GWingServer;
@@ -76,14 +73,14 @@ private:
FLogCaptureOutputDevice LogCapture; // installed once at startup, enabled per-request
TArray<FWingHandlerConfig> WingHandlerRegistry; // sorted by Name
void BuildWingHandlerRegistry();
void OnModulesChanged(FName ModuleName, EModuleChangeReason Reason);
FDelegateHandle ModulesChangedHandle;
FDelegateHandle LoadingPhasesCompleteHandle;
FWingHandlerConfig* FindHandler(const FString& Name);
// Handle a complete JSON line and return the response JSON
FString HandleRequest(const FString& Line);
FString HandleJsonRequest(TSharedPtr<FJsonObject> Request);
void TryCallHandler(TSharedPtr<FJsonObject> Request);
// Handle a complete request and return the response bytes.
TArray<uint8> HandleRequest(const TArray<uint8>& RequestBytes);
void PreCallHandler();
FString PostCallHandler();
void TryCallHandler(TArrayView<const FString> Argv);
// ----- TCP server -----
FSocket* ListenSocket = nullptr;
@@ -100,22 +97,23 @@ private:
TArray<TSharedPtr<FClientConnection>> Clients;
void AcceptNewConnections();
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 bool ExtractRequestFromBuffer(
TArray<uint8>& RecvBuf, int32& RecvLen, FString& OutRequest);
static bool ReceiveMoreBytesIntoBuffer(
FSocket* Socket, TArray<uint8>& RecvBuf, int32& RecvLen);
static bool ReceiveRequest(
FSocket* Socket, TArray<uint8>& OutRequest);
static bool SendAll(FSocket* Socket, const uint8* Data, int32 BytesToSend);
static bool ProcessRequestOnGameThread(
const FString& Request, FString& Response);
const TArray<uint8>& Request, TArray<uint8>& Response);
static void WaitForAssetRegistry();
// ----- The Critical Section -----
struct FPendingMessage
{
FString Line;
TPromise<FString> Response;
FPendingMessage() : Response(TPromise<FString>()) {}
TArray<uint8> Request;
TPromise<TArray<uint8>> Response;
FPendingMessage() : Response(TPromise<TArray<uint8>>()) {}
};
FCriticalSection Mutex;
TArray<TSharedPtr<FPendingMessage>> PendingMessages;

View File

@@ -57,6 +57,9 @@ public:
// Empty the variable list.
void Empty() { Variables.Empty(); }
// Add a variable.
void Add(const Var &Var) { Variables.Add(Var); }
// Return true if the variables are empty.
bool IsEmpty() { return Variables.IsEmpty(); }
@@ -72,16 +75,6 @@ public:
// Check the sanity of the vars in the array. If allow
// is false, then no variables are allowed in the array.
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
@@ -125,6 +118,10 @@ public:
void Print(WingOut Out);
// Parse variables.
bool Parse(const TArray<FString> &Vars, bool NameOnly, WingOut Errors);
// Load: clear the workspace, then
// copy everything from the backing store into the workspace.
@@ -193,4 +190,8 @@ private:
void AddUserPinInfo(const Var &V, EEdGraphPinDirection Dir, UK2Node_EditablePinBase *Node);
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,178 +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 = 30
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()
return {"error": "Lost connection to Unreal Editor."}
def make_jsonrpc(msg_id, result):
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
def parse_editor_response(result):
"""Parse and validate a raw editor response into an MCP content list.
MCP expects `content` to be a list of objects, each with at least a
string "type" field (e.g. {"type": "text", "text": "..."}). Anything
else is replaced with a single error item so the client sees a clear
message instead of a schema violation.
"""
try:
parsed = json.loads(result)
except json.JSONDecodeError:
return [{"type": "text", "text": "Malformed response from editor: invalid JSON."}]
if not isinstance(parsed, list):
return [{"type": "text", "text": "Malformed response from editor: expected a list."}]
for item in parsed:
if not isinstance(item, dict):
return [{"type": "text", "text": "Malformed response from editor: list item is not an object."}]
if not isinstance(item.get("type"), str):
return [{"type": "text", "text": "Malformed response from editor: item missing string 'type' field."}]
return parsed
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:
content = parse_editor_response(result)
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
"""
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 ...]
Values starting with '[' or '{' are parsed as JSON.
Usage: ue-wingman.py <arg1> [arg2 ...]
"""
import sys
import json
import socket
import struct
HOST = "localhost"
PORT = 9851
TIMEOUT = 120
TIMEOUT = 15
def main():
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.settimeout(TIMEOUT)
@@ -41,7 +27,14 @@ def main():
print(f"Cannot connect to {HOST}:{PORT} — is the editor running?")
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""
while True:
@@ -49,29 +42,9 @@ def main():
if not chunk:
break
result += chunk
if b"\0" in result:
break
sock.close()
result = result[:result.index(b"\0")].decode() if b"\0" in result else result.decode()
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))
print(result.decode(), end="")
if __name__ == "__main__":
main()

View File

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

View File

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

View File

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

View File

@@ -105,9 +105,16 @@ public:
//
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
// 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.
//
UFUNCTION(BlueprintCallable, meta=(WorldContext = "Context", BlueprintInternalUseOnly = "true"))

View File

@@ -250,7 +250,7 @@ void UK2Node_FormatMessage::ExpandNode(class FKismetCompilerContext& CompilerCon
}
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.

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

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

View File

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

View File

@@ -63,6 +63,11 @@ public:
// Get the current Luprex Game Mode Base, given a Context object.
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.
UPROPERTY(EditAnywhere, Category="Debugging Tools")
ElxBreakToDebuggerThreshold BreakToDebuggerLogVerbosity;
@@ -76,6 +81,9 @@ public:
// This is always true unless you use the debugger to set it to false.
bool TickEnabled = true;
// True to trigger a source reload.
bool ReloadSource = false;
// Current Player ID
int64 PlayerId = 0;

View File

@@ -7,10 +7,11 @@
#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 "Widgets/SViewport.h"
#include "Slate/SObjectWidget.h"
UlxViewportClient::UlxViewportClient(const FObjectInitializer &ObjectInitializer)
: Super(ObjectInitializer)
@@ -18,44 +19,44 @@ UlxViewportClient::UlxViewportClient(const FObjectInitializer &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. Walk the widget path under the cursor and
// find the nearest focusable ancestor of whatever was hit. If it
// isn't the game viewport itself, hand it to the player controller
// to apply on its next UpdateInputMode pass; that's the point in
// the frame where we can override SViewport's own click-focus
// behaviour without fighting it.
// 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())
{
TSharedPtr<SViewport> ViewportWidget = GetGameViewportWidget();
for (int32 Idx = Path.Widgets.Num() - 1; Idx >= 0; --Idx)
{
TSharedRef<SWidget> Widget = Path.Widgets[Idx].Widget;
if (!Widget->SupportsKeyboardFocus()) continue;
if (ViewportWidget.IsValid() && Widget == ViewportWidget) break;
if (UGameInstance *GI = GetGameInstance())
{
if (AlxPlayerControllerBase *PC = Cast<AlxPlayerControllerBase>(
GI->GetFirstLocalPlayerController(GetWorld())))
{
PC->ClickToFocus(Widget);
}
}
break;
}
}
if (Path.IsValid() && TryBringToFront(Path)) return true;
}
return Super::InputKey(EventArgs);

View File

@@ -14,6 +14,7 @@
#include "CoreMinimal.h"
#include "Engine/GameViewportClient.h"
#include "Layout/WidgetPath.h"
#include "LuprexViewportClient.generated.h"
UCLASS()
@@ -25,4 +26,7 @@ public:
UlxViewportClient(const FObjectInitializer &ObjectInitializer);
virtual bool InputKey(const FInputKeyEventArgs &EventArgs) override;
private:
bool TryBringToFront(const FWidgetPath &Path);
};

View File

@@ -1,5 +1,7 @@
#include "PlayerControllerBase.h"
#include "Common.h"
#include "GameFramework/InputDeviceSubsystem.h"
#include "GameFramework/InputSettings.h"
#include "RootCanvas.h"
#include "Tangible.h"
#include "TangibleManager.h"
@@ -131,13 +133,6 @@ UInputComponent* AlxPlayerControllerBase::GetWidgetInputComponent(UUserWidget *W
return Cast<UInputComponent>(Value);
}
void AlxPlayerControllerBase::RestoreFocusToFrontWidget(const UObject *Context)
{
// This will trigger UpdateInputMode to shift focus back to
// the front window, if the front window wants focus.
FromContext(Context)->LastWidgetGrantedFocus = nullptr;
}
void AlxPlayerControllerBase::AddWidgetToRoot(UUserWidget *Widget)
{
if (!IsValid(Widget))
@@ -163,8 +158,7 @@ void AlxPlayerControllerBase::AddWidgetToRoot(UUserWidget *Widget)
if (Widget->GetParent() == PC->RootCanvas) return;
UlxRootCanvasSlot *Slot = PC->RootCanvas->AddChildToRootCanvas(Widget);
Slot->SetZOrder(0);
PC->RootCanvas->AddChildToRootCanvas(Widget);
}
void AlxPlayerControllerBase::BuildInputStack(TArray<UInputComponent*>& InputStack)
@@ -239,7 +233,7 @@ void AlxPlayerControllerBase::BuildInputStack(TArray<UInputComponent*>& InputSta
}
// Now add the widget-managed input components.
for (UlxRootCanvasSlot *Slot : ReverseIterate(WidgetSlots))
for (UlxRootCanvasSlot *Slot : WidgetSlots)
{
if (Slot->EnableEnhancedInput)
{
@@ -256,10 +250,6 @@ void AlxPlayerControllerBase::BuildInputStack(TArray<UInputComponent*>& InputSta
void AlxPlayerControllerBase::UpdateInputMode()
{
// Drain any deferred ZOrder writes from SetWidgetWindowManagement
// before we read the front-most slot.
RootCanvas->PropagateZOrderToSlate();
// Get all the various objects we need to be able to manipulate
// the input mode.
UGameViewportClient *GameViewportClient = GetWorld()->GetGameViewport();
@@ -273,15 +263,14 @@ void AlxPlayerControllerBase::UpdateInputMode()
if (!SlateUser.IsValid()) return;
// Get the desired configuration from the first widget.
// TODO: Maybe we don't have to sort the whole array.
TArray<UlxRootCanvasSlot*> WidgetSlots = RootCanvas->GetSortedUserWidgets();
RootCanvas->UpdateZOrders();
// Get the desired configuration from the top widget.
UUserWidget *Widget = nullptr;
UWidget *Focus = nullptr;
bool ShowPointer = false;
if (!WidgetSlots.IsEmpty())
if (UlxRootCanvasSlot *Top = RootCanvas->GetTopWidget())
{
UlxRootCanvasSlot *Top = WidgetSlots[0];
Widget = Cast<UUserWidget>(Top->GetContent());
Focus = Widget->GetDesiredFocusWidget();
ShowPointer = Top->ShowPointer;
@@ -315,42 +304,20 @@ void AlxPlayerControllerBase::UpdateInputMode()
GameViewportClient->SetIgnoreInput(false);
// How we handle focus depends on whether we're showing the pointer.
// In pointer mode, we set focus to the desired state just once,
// and then we let the pointer control it from there on. In
// no-pointer mode, we set focus to the desired state and
// keep putting it back forever.
//
// If the user clicks the mouse on a focusable widget, the
// viewport client notifies us of that fact. We then focus the
// widget if possible.
//
if ((!ShowPointer) || (LastWidgetGrantedFocus != Focus))
// 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 (Focus)
if (TSharedPtr<SWidget> SlateFocus = Focus->GetCachedWidget())
{
if (TSharedPtr<SWidget> SlateFocus = Focus->GetCachedWidget())
{
SlateOperations.SetUserFocus(SlateFocus.ToSharedRef());
LastWidgetGrantedFocus = Focus;
}
}
else
{
SlateOperations.SetUserFocus(ViewportWidgetRef);
LastWidgetGrantedFocus = nullptr;
SlateOperations.SetUserFocus(SlateFocus.ToSharedRef());
}
}
else if (TSharedPtr<SWidget> ClickedWidget = ClickToFocusTarget.Pin())
else
{
SlateOperations.SetUserFocus(ClickedWidget.ToSharedRef());
SlateOperations.SetUserFocus(ViewportWidgetRef);
}
ClickToFocusTarget.Reset();
}
void AlxPlayerControllerBase::ClickToFocus(TSharedRef<SWidget> Widget)
{
ClickToFocusTarget = Widget.ToWeakPtr();
}
void AlxPlayerControllerBase::UpdateLookAt()

View File

@@ -1,6 +1,7 @@
#pragma once
#include "CoreMinimal.h"
#include "Common.h"
#include "Engine/HitResult.h"
#include "GameFramework/PlayerController.h"
#include "UObject/ObjectKey.h"
@@ -65,10 +66,6 @@ public:
// FProperty so we always see the current value without caching it.
static class UInputComponent* GetWidgetInputComponent(class UUserWidget *Widget);
// Restore focus back to the window that is in front, if it wants focus.
UFUNCTION(BlueprintCallable, meta = (WorldContext = "Context"), Category = "Luprex|Root Canvas")
static void RestoreFocusToFrontWidget(const UObject *Context);
// 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"))
@@ -80,9 +77,6 @@ public:
UPROPERTY()
FHitResult CurrentLookAt;
// The last widget whose focus request was granted.
TObjectKey<UWidget> LastWidgetGrantedFocus;
// The single top-level UUserWidget added to the viewport. All
// top-level UI widgets are children of RootCanvas inside it.
UPROPERTY()
@@ -94,14 +88,5 @@ public:
UPROPERTY()
UlxRootCanvasPanel *RootCanvas = nullptr;
// The viewport client uses this to notify us that the user
// clicked on a focusable widget.
void ClickToFocus(TSharedRef<SWidget> Widget);
private:
TWeakPtr<SWidget> ClickToFocusTarget;
public:
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();
}

View File

@@ -0,0 +1,164 @@
//
// This class implements the layout calculatations for a radial
// menu.
//
#pragma once
#include "CoreMinimal.h"
#include "Components/Widget.h"
#include "Fonts/SlateFontInfo.h"
#include "RadialMenu.generated.h"
class SRadialMenu;
class UTexture2D;
USTRUCT(BlueprintType)
struct FRadialMenuConfig
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, Category="RadialMenu")
TArray<FString> MenuItems;
UPROPERTY(EditAnywhere, Category="RadialMenu")
FSlateFontInfo Font;
UPROPERTY(EditAnywhere, Category="RadialMenu")
float InnerRadius = 20.0f;
UPROPERTY(EditAnywhere, Category="RadialMenu")
float MinSpoke = 20.0f;
UPROPERTY(EditAnywhere, Category="RadialMenu")
float Spread = 20.0f;
UPROPERTY(EditAnywhere, Category="RadialMenu")
float LineThickness = 4.0f;
UPROPERTY(EditAnywhere, Category="RadialMenu")
FLinearColor ItemColor = FLinearColor::White;
UPROPERTY(EditAnywhere, Category="RadialMenu")
TObjectPtr<UTexture2D> DotTexture;
UPROPERTY(EditAnywhere, Category="RadialMenu")
float DotRadius = 3.0f;
UPROPERTY(EditAnywhere, Category="RadialMenu")
TObjectPtr<UTexture2D> Teardrop;
UPROPERTY(EditAnywhere, Category="RadialMenu")
float TeardropRadius = 0.0f;
UPROPERTY(EditAnywhere, Category="RadialMenu")
int32 SelectedItem = -1;
UPROPERTY(EditAnywhere, Category="RadialMenu")
FLinearColor SelectedColor = FLinearColor::Yellow;
UPROPERTY(EditAnywhere, Category="RadialMenu")
float SelectedThickness = 2.0f;
};
USTRUCT(BlueprintType)
struct FRadialMenuItem
{
GENERATED_BODY()
UPROPERTY(BlueprintReadOnly)
FVector2D Point1 = {0,0};
UPROPERTY(BlueprintReadOnly)
FVector2D Point2 = {0,0};
UPROPERTY(BlueprintReadOnly)
FVector2D Point3 = {0,0};
UPROPERTY(BlueprintReadOnly)
bool RightSide = false;
UPROPERTY(BlueprintReadOnly)
FVector2D TextSize = {0,0};
static TArray<FRadialMenuItem> Calculate(const FRadialMenuConfig &Config);
// Like Calculate, but only produces the unit-vector direction of
// each spoke. Cheaper to evaluate when only the spoke directions
// are needed (e.g. for hit-testing a pointer against the wheel).
static TArray<FVector2D> CalculateDirections(int32 NumItems);
private:
using View = TArrayView<FRadialMenuItem>;
// Give the unit vector for the selected spoke. NSide is
// the number of spokes on the side of the wheel that
// we're calculating, and NTotal is the total number of
// spokes on both sides. The spokes on a given side are
// organized top-to-bottom, and the angle between the
// spokes is always equal to (1/NTotal) of the circle.
static FVector2D SpokeVector(int32 I, int32 NSide, int32 NTotal);
// Populate Point1 and Point2, these are the
// endpoints of the spoke segment. Spokes are
// designed to always be long enough to make room
// for MinSpoke, but also to make enough room to
// keep the menu items from overlapping.
static void CalculateSpokes(View V, int32 TotalItems, float ItemHeight, float InnerRadius, float MinSpoke);
// Search for the widest spoke, and return its X coordinate.
static double WidestSpoke(View V);
// Populate Point3, this is the endpoint of the spread
// line that goes horizontal.
static void CalculateSpread(View V, double Offset);
// Flip everything in the specified view horizontally.
static void FlipHorizontal(View V);
};
UCLASS(BlueprintType, Blueprintable)
class URadialMenuWidget : public UWidget
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable)
void SetMenuItems(const TArray<FString>& NewMenuItems);
UFUNCTION(BlueprintCallable)
void ClearMenuItems() { SetMenuItems({}); }
UFUNCTION(BlueprintCallable)
void SetSelectedItem(int32 NewSelectedItem);
// Returns true if the selected item changed.
UFUNCTION(BlueprintCallable)
void AddPointer(FVector2D Direction, float Scale);
UFUNCTION(BlueprintCallable)
void SetPointer(FVector2D Direction, float Scale);
UFUNCTION(BlueprintCallable)
FVector2D GetPointer() const { return PointerVector; }
UFUNCTION(BlueprintCallable)
FString GetSelectedMenuItem() const;
protected:
UPROPERTY(EditAnywhere)
FRadialMenuConfig Config;
TArray<FVector2D> Directions;
FVector2D PointerVector = {0,0};
virtual TSharedRef<SWidget> RebuildWidget() override;
virtual void ReleaseSlateResources(bool bReleaseChildren) override;
virtual void SynchronizeProperties() override;
TSharedPtr<SRadialMenu> MySlateWidget;
};

View File

@@ -0,0 +1,226 @@
#include "ReadSlashCommand.h"
#include "BlueprintActionDatabaseRegistrar.h"
#include "BlueprintNodeSpawner.h"
#include "EdGraphSchema_K2.h"
#include "K2Node_CallFunction.h"
#include "KismetCompiler.h"
#include "LuaCall.h"
#include "SlashCommand.h"
#define LOCTEXT_NAMESPACE "ReadSlashCommand"
const FName UK2Node_ReadSlashCommand::PrototypePinName(TEXT("Prototype"));
const FName UK2Node_ReadSlashCommand::InputValuesPinName(TEXT("Input Values"));
const FName UK2Node_ReadSlashCommand::ErrorPinName(TEXT("Error"));
bool UK2Node_ReadSlashCommand::ParsePrototype(const FString &Prototype, TArray<ParsingStep>& Steps)
{
TArray<FString> Words;
Prototype.ParseIntoArrayWS(Words);
// The command name must be a slash followed by alphanumerics.
if (Words.Num() == 0 || !UlxSlashCommand::IsSlashCommand(Words[0]))
{
SetErrorMsg(TEXT("The prototype must start with a command name, e.g. \"/foo\"."));
Steps.Empty();
return false;
}
Steps.SetNum(Words.Num());
Steps[0].Word = Words[0];
TSet<FName> UsedNames;
for (int32 i = 1; i < Words.Num(); i++)
{
const FString& Word = Words[i];
FString FuncName = FString(TEXT("Read")) + Word;
UFunction* ReadFunc = UlxSlashCommand::StaticClass()->FindFunctionByName(FName(*FuncName));
if (ReadFunc == nullptr)
{
SetErrorMsg(FString::Printf(TEXT("Unknown value type: %s"), *Word));
Steps.Empty();
return false;
}
FName PinName = AddPrefix(Word, 'R');
for (int Suffix = 2; UsedNames.Contains(PinName); Suffix++)
{
PinName = AddPrefix(FString::Printf(TEXT("%s%d"), *Word, Suffix), 'R');
}
Steps[i].Word = Word;
Steps[i].PinName = PinName;
Steps[i].ReadFunction = ReadFunc;
UsedNames.Add(PinName);
}
return true;
}
FText UK2Node_ReadSlashCommand::GetTooltipText() const
{
static FText Tooltip = FText::FromString(TEXT(
"Parse a slash command.\n"
"\n"
"The prototype must be a hardwired string. The first word\n"
"is the command name; each remaining word names a value to\n"
"read and becomes an output pin.\n"
"\n"
"For example:\n"
"\n"
" /command Integer Float\n"
"\n"
"Supported types: Token, Integer, Float, Rest\n"));
return Tooltip;
}
void UK2Node_ReadSlashCommand::ReconstructNode()
{
// Save the value of the Prototype Pin before it gets reconstructed.
UEdGraphPin* PrototypePin = FindPin(PrototypePinName);
if (PrototypePin != nullptr)
{
ValuePrototype = PrototypePin->DefaultValue;
}
Super::ReconstructNode();
}
void UK2Node_ReadSlashCommand::AllocateDefaultPins()
{
Pins.Reset();
Super::AllocateDefaultPins();
TArray<ParsingStep> Steps;
ParsePrototype(ValuePrototype, Steps);
CreatePin(EGPD_Input, UEdGraphSchema_K2::PC_Exec, UEdGraphSchema_K2::PN_Execute);
CreatePin(EGPD_Output, UEdGraphSchema_K2::PC_Exec, UEdGraphSchema_K2::PN_Then);
CreatePin(EGPD_Output, UEdGraphSchema_K2::PC_Exec, ErrorPinName);
UEdGraphPin *PrototypePin = CreatePin(EGPD_Input, UEdGraphSchema_K2::PC_String, PrototypePinName);
PrototypePin->DefaultValue = ValuePrototype;
CreatePin(EGPD_Input, UEdGraphSchema_K2::PC_Object, UlxSlashCommand::StaticClass(), InputValuesPinName);
// Create an output pin for each value word.
for (int32 i = 1; i < Steps.Num(); i++)
{
// The value comes back through the function's "Result" out-parameter.
CreatePin(EGPD_Output, PropertyToPinType(Steps[i].ReadFunction->FindPropertyByName(TEXT("Result"))), Steps[i].PinName);
}
}
FText UK2Node_ReadSlashCommand::GetNodeTitle(ENodeTitleType::Type TitleType) const
{
return LOCTEXT("ReadSlashCommand_Title", "Read Slash Command");
}
FText UK2Node_ReadSlashCommand::GetPinDisplayName(const UEdGraphPin* Pin) const
{
// These pins don't need labels.
if ((Pin->PinName == UEdGraphSchema_K2::PN_Execute) ||
(Pin->PinName == UEdGraphSchema_K2::PN_Then) ||
(Pin->PinName == PrototypePinName))
{
return FText::GetEmpty();
}
// Return the pin name, removing R: prefix if present.
return FText::FromName(RemovePrefix(Pin->PinName));
}
void UK2Node_ReadSlashCommand::PinDefaultValueChanged(UEdGraphPin* Pin)
{
if ((Pin->PinName == PrototypePinName) && (Pin->DefaultValue != ValuePrototype))
{
ReconstructNode();
}
}
void UK2Node_ReadSlashCommand::ExpandNode(class FKismetCompilerContext& CompilerContext, UEdGraph* SourceGraph)
{
Super::ExpandNode(CompilerContext, SourceGraph);
TArray<ParsingStep> Steps;
if (!ParsePrototype(ValuePrototype, Steps))
{
CompilerContext.MessageLog.Error(*ErrorMsg);
BreakAllNodeLinks();
return;
}
UEdGraphPin *InputSlashCommandPin = FindPinChecked(InputValuesPinName);
UEdGraphPin *ErrorExecPin = FindPinChecked(ErrorPinName);
UFunction *CheckCommandFunc = UlxSlashCommand::StaticClass()->FindFunctionByName(TEXT("CheckCommand"));
UK2Node_CallFunction *CheckCommandNode = MakeCallFunctionNode(CompilerContext, SourceGraph, CheckCommandFunc);
CompilerContext.CopyPinLinksToIntermediate(*InputSlashCommandPin, *CheckCommandNode->FindPinChecked(UEdGraphSchema_K2::PN_Self));
CompilerContext.MovePinLinksToIntermediate(*GetExecPin(), *CheckCommandNode->GetExecPin());
CheckCommandNode->FindPinChecked(TEXT("Literal"))->DefaultValue = Steps[0].Word;
CheckCommandNode->FindPinChecked(TEXT("Prototype"))->DefaultValue = ValuePrototype;
CompilerContext.CopyPinLinksToIntermediate(*ErrorExecPin, *CheckCommandNode->FindPinChecked(TEXT("Error")));
UEdGraphPin *ThenPin = CheckCommandNode->FindPinChecked(TEXT("Success"));
for (int32 i = 1; i < Steps.Num(); i++)
{
UK2Node_CallFunction *ReadNode = MakeCallFunctionNode(CompilerContext, SourceGraph, Steps[i].ReadFunction);
CompilerContext.CopyPinLinksToIntermediate(*InputSlashCommandPin, *ReadNode->FindPinChecked(UEdGraphSchema_K2::PN_Self));
CompilerContext.CopyPinLinksToIntermediate(*ErrorExecPin, *ReadNode->FindPinChecked(TEXT("Error")));
UEdGraphPin *OutputPin = FindPinChecked(Steps[i].PinName);
CompilerContext.MovePinLinksToIntermediate(*OutputPin, *ReadNode->FindPinChecked(TEXT("Result")));
ThenPin = ChainExecPin(ThenPin, ReadNode, TEXT("Success"));
}
CompilerContext.MovePinLinksToIntermediate(*GetThenPin(), *ThenPin);
BreakAllNodeLinks();
}
UK2Node::ERedirectType UK2Node_ReadSlashCommand::DoPinsMatchForReconstruction(const UEdGraphPin* NewPin, int32 NewPinIndex, const UEdGraphPin* OldPin, int32 OldPinIndex) const
{
if (IsTemplate() || (GetGraph() == nullptr)) return ERedirectType_None;
if ((NewPin->PinName == OldPin->PinName) &&
(NewPin->Direction == OldPin->Direction) &&
(NewPin->PinType == OldPin->PinType))
{
return ERedirectType_Name;
}
return ERedirectType_None;
}
bool UK2Node_ReadSlashCommand::IsConnectionDisallowed(const UEdGraphPin* MyPin, const UEdGraphPin* OtherPin, FString& OutReason) const
{
// The prototype pin cannot be connected.
if (MyPin->PinName == PrototypePinName)
{
OutReason = LOCTEXT("Error_PrototypeMustBeHardwired", "Value prototype must be a hardwired constant.").ToString();
return true;
}
return Super::IsConnectionDisallowed(MyPin, OtherPin, OutReason);
}
void UK2Node_ReadSlashCommand::GetMenuActions(FBlueprintActionDatabaseRegistrar& ActionRegistrar) const
{
UClass* ActionKey = GetClass();
if (ActionRegistrar.IsOpenForRegistration(ActionKey))
{
UBlueprintNodeSpawner* NodeSpawner = UBlueprintNodeSpawner::Create(GetClass());
check(NodeSpawner != nullptr);
ActionRegistrar.AddBlueprintAction(ActionKey, NodeSpawner);
}
}
FText UK2Node_ReadSlashCommand::GetMenuCategory() const
{
return FText::FromString(FString(TEXT("Luprex|Slash Commands")));
}
#undef LOCTEXT_NAMESPACE

View File

@@ -0,0 +1,74 @@
////////////////////////////////////////////////////////////
//
// ReadSlashCommand.h
//
// K2Node that reads typed values from a UlxLuaValues array.
// Takes a prototype string like "string x, float y, int z"
// and creates output pins with the appropriate types.
//
////////////////////////////////////////////////////////////
#pragma once
#include "LuprexK2Node.h"
#include "ReadSlashCommand.generated.h"
class FBlueprintActionDatabaseRegistrar;
class FString;
class UEdGraph;
class UObject;
UCLASS(MinimalAPI)
class UK2Node_ReadSlashCommand : public UlxK2Node
{
GENERATED_BODY()
public:
//~ Begin UEdGraphNode Interface.
virtual void AllocateDefaultPins() override;
virtual FText GetNodeTitle(ENodeTitleType::Type TitleType) const override;
virtual bool ShouldShowNodeProperties() const override { return true; }
virtual void PinDefaultValueChanged(UEdGraphPin* Pin) override;
virtual FText GetTooltipText() const override;
virtual FText GetPinDisplayName(const UEdGraphPin* Pin) const override;
//~ End UEdGraphNode Interface.
//~ Begin UK2Node Interface.
virtual bool IsNodePure() const override { return false; }
virtual void ReconstructNode() override;
virtual bool NodeCausesStructuralBlueprintChange() const override { return true; }
virtual void ExpandNode(class FKismetCompilerContext& CompilerContext, UEdGraph* SourceGraph) override;
virtual ERedirectType DoPinsMatchForReconstruction(const UEdGraphPin* NewPin, int32 NewPinIndex, const UEdGraphPin* OldPin, int32 OldPinIndex) const override;
virtual bool IsConnectionDisallowed(const UEdGraphPin* MyPin, const UEdGraphPin* OtherPin, FString& OutReason) const override;
virtual void GetMenuActions(FBlueprintActionDatabaseRegistrar& ActionRegistrar) const override;
virtual FText GetMenuCategory() const override;
virtual int32 GetNodeRefreshPriority() const override { return EBaseNodeRefreshPriority::Low_UsesDependentWildcard; }
//~ End UK2Node Interface.
private:
static const FName PrototypePinName;
static const FName InputValuesPinName;
static const FName ErrorPinName;
struct ParsingStep
{
FString Word;
FName PinName;
UFunction *ReadFunction;
};
bool ParsePrototype(const FString &Prototype, TArray<ParsingStep>& Steps);
private:
// Whenever the prototype pin value changes, we call
// ReconstructNode, which backs up the value into this
// property. This cache is needed because during
// ReconstructNode, we blow away the prototype pin. The
// prototype pin is also absent when the node is first
// created.
//
UPROPERTY()
FString ValuePrototype = TEXT("/command Integer Float");
};

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