├── .gitignore ├── Docs~ ├── example_setup.png ├── localize_string_event.png ├── manual_settings_input_variable.png ├── manual_setup_localization_settings.png ├── preview.gif ├── preview.png └── use_in_control_scheme.png ├── Editor.meta ├── Editor ├── ControlPathToSpriteMappingDrawer.cs ├── ControlPathToSpriteMappingDrawer.cs.meta ├── ControlSchemeDropdownAttributeDrawer.cs ├── ControlSchemeDropdownAttributeDrawer.cs.meta ├── InputHintsConfigInspector.cs ├── InputHintsConfigInspector.cs.meta ├── MissingControlPathDrawer.cs ├── MissingControlPathDrawer.cs.meta ├── SpriteCategoryToAssetMappingDrawer.cs ├── SpriteCategoryToAssetMappingDrawer.cs.meta ├── SpriteSearchWindow.cs ├── SpriteSearchWindow.cs.meta ├── TextInputDialog.cs ├── TextInputDialog.cs.meta ├── games.noio.InputHints.Editor.asmdef └── games.noio.InputHints.Editor.asmdef.meta ├── LICENSE ├── LICENSE.meta ├── README.md ├── README.md.meta ├── Runtime.meta ├── Runtime ├── ControlPathToSpriteMapping.cs ├── ControlPathToSpriteMapping.cs.meta ├── ControlSchemeDropdownAttribute.cs ├── ControlSchemeDropdownAttribute.cs.meta ├── ControlType.cs ├── ControlType.cs.meta ├── DeviceDetectorSample.cs ├── DeviceDetectorSample.cs.meta ├── InputActionVariable.cs ├── InputActionVariable.cs.meta ├── InputActionVariableGroup.cs ├── InputActionVariableGroup.cs.meta ├── InputHints.cs ├── InputHints.cs.meta ├── InputHintsConfig.cs ├── InputHintsConfig.cs.meta ├── MissingControlPath.cs ├── MissingControlPath.cs.meta ├── SpriteCategory.cs ├── SpriteCategory.cs.meta ├── SpriteCategoryToAssetMapping.cs ├── SpriteCategoryToAssetMapping.cs.meta ├── games.noio.InputHints.asmdef └── games.noio.InputHints.asmdef.meta ├── package.json ├── package.json.meta ├── todo.txt └── todo.txt.meta /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .DS_Store 3 | Samples.meta 4 | -------------------------------------------------------------------------------- /Docs~/example_setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noio/games.noio.input-hints/203e058cfde5a48e39b80810b40b15c2784d2b46/Docs~/example_setup.png -------------------------------------------------------------------------------- /Docs~/localize_string_event.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noio/games.noio.input-hints/203e058cfde5a48e39b80810b40b15c2784d2b46/Docs~/localize_string_event.png -------------------------------------------------------------------------------- /Docs~/manual_settings_input_variable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noio/games.noio.input-hints/203e058cfde5a48e39b80810b40b15c2784d2b46/Docs~/manual_settings_input_variable.png -------------------------------------------------------------------------------- /Docs~/manual_setup_localization_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noio/games.noio.input-hints/203e058cfde5a48e39b80810b40b15c2784d2b46/Docs~/manual_setup_localization_settings.png -------------------------------------------------------------------------------- /Docs~/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noio/games.noio.input-hints/203e058cfde5a48e39b80810b40b15c2784d2b46/Docs~/preview.gif -------------------------------------------------------------------------------- /Docs~/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noio/games.noio.input-hints/203e058cfde5a48e39b80810b40b15c2784d2b46/Docs~/preview.png -------------------------------------------------------------------------------- /Docs~/use_in_control_scheme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noio/games.noio.input-hints/203e058cfde5a48e39b80810b40b15c2784d2b46/Docs~/use_in_control_scheme.png -------------------------------------------------------------------------------- /Editor.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 3081cdeb617c4554a50bb537a2911e6b 3 | timeCreated: 1660300510 -------------------------------------------------------------------------------- /Editor/ControlPathToSpriteMappingDrawer.cs: -------------------------------------------------------------------------------- 1 | using UnityEditor; 2 | using UnityEngine; 3 | 4 | namespace games.noio.InputHints.Editor 5 | { 6 | [CustomPropertyDrawer(typeof(ControlPathToSpriteMapping))] 7 | public class ControlPathToSpriteMappingDrawer : PropertyDrawer 8 | { 9 | // Draw the property inside the given rect 10 | public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) 11 | { 12 | var controlPathProp = property.FindPropertyRelative("_controlPath"); 13 | var spriteNameProp = property.FindPropertyRelative("_spriteName"); 14 | var spriteCategoryProp = property.FindPropertyRelative("_spriteCategory"); 15 | 16 | // Using BeginProperty / EndProperty on the parent property means that 17 | // prefab override logic works on the entire property. 18 | EditorGUI.BeginProperty(position, label, property); 19 | 20 | // Don't make child fields be indented 21 | var indent = EditorGUI.indentLevel; 22 | EditorGUI.indentLevel = 0; 23 | 24 | // Calculate rects 25 | var controlPathRect = position; 26 | controlPathRect.width = 80; 27 | 28 | /* 29 | * Sprite sheet rect on the right 30 | */ 31 | var spriteCategoryRect = position; 32 | spriteCategoryRect.xMin = spriteCategoryRect.xMax - 90; 33 | 34 | var searchButtonRect = position; 35 | searchButtonRect.xMax = spriteCategoryRect.xMin - 5; 36 | searchButtonRect.xMin = searchButtonRect.xMax - 40; 37 | 38 | /* 39 | * Sprite name fills up the remaining space in the middle: 40 | */ 41 | var spriteNameRect = position; 42 | spriteNameRect.xMin = controlPathRect.xMax + 5; 43 | spriteNameRect.xMax = searchButtonRect.xMin - 5; 44 | 45 | // Draw fields - passs GUIContent.none to each so they are drawn without labels 46 | EditorGUI.PropertyField(controlPathRect, controlPathProp, 47 | GUIContent.none); 48 | EditorGUI.PropertyField(spriteNameRect, spriteNameProp, 49 | GUIContent.none); 50 | 51 | if (GUI.Button(searchButtonRect, EditorGUIUtility.IconContent("d_SearchWindow"))) 52 | { 53 | EditorApplication.delayCall += () => 54 | { 55 | var pickedSprite = SpriteSearchWindow.ShowWindow(); 56 | if (pickedSprite != null) 57 | { 58 | spriteNameProp.stringValue = pickedSprite; 59 | spriteNameProp.serializedObject.ApplyModifiedProperties(); 60 | } 61 | }; 62 | } 63 | 64 | if (EditorGUI.DropdownButton(spriteCategoryRect, 65 | new GUIContent(spriteCategoryProp.stringValue), 66 | FocusType.Keyboard)) 67 | { 68 | var menu = new GenericMenu(); 69 | var config = property.serializedObject.targetObject as InputHintsConfig; 70 | foreach (var sheetKey in config.GetSpriteCategories()) 71 | { 72 | menu.AddItem(new GUIContent(sheetKey), false, () => 73 | { 74 | spriteCategoryProp.stringValue = sheetKey; 75 | spriteCategoryProp.serializedObject.ApplyModifiedProperties(); 76 | }); 77 | } 78 | 79 | menu.DropDown(spriteCategoryRect); 80 | } 81 | 82 | // Set indent back to what it was 83 | EditorGUI.indentLevel = indent; 84 | 85 | EditorGUI.EndProperty(); 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /Editor/ControlPathToSpriteMappingDrawer.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 9fe668aab0ea4b0db50246f3de260ceb 3 | timeCreated: 1662475080 -------------------------------------------------------------------------------- /Editor/ControlSchemeDropdownAttributeDrawer.cs: -------------------------------------------------------------------------------- 1 | // (C)2024 @noio_games 2 | // Thomas van den Berg 3 | 4 | using UnityEditor; 5 | using UnityEngine; 6 | 7 | namespace games.noio.InputHints.Editor 8 | { 9 | [CustomPropertyDrawer(typeof(ControlSchemeDropdownAttribute))] 10 | public class ControlSchemeDropdownAttributeDrawer : PropertyDrawer 11 | { 12 | #region MONOBEHAVIOUR METHODS 13 | 14 | public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) 15 | { 16 | position = EditorGUI.PrefixLabel(position, label); 17 | if (EditorGUI.DropdownButton(position, new GUIContent(property.stringValue), FocusType.Keyboard)) 18 | { 19 | var config = property.serializedObject.targetObject as InputHintsConfig; 20 | var menu = new GenericMenu(); 21 | foreach (var controlScheme in config.GetInputControlSchemes()) 22 | { 23 | menu.AddItem(new GUIContent(controlScheme.name), false, () => 24 | { 25 | property.stringValue = controlScheme.name; 26 | property.serializedObject.ApplyModifiedProperties(); 27 | }); 28 | } 29 | 30 | menu.DropDown(position); 31 | } 32 | } 33 | 34 | #endregion 35 | } 36 | } -------------------------------------------------------------------------------- /Editor/ControlSchemeDropdownAttributeDrawer.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 50f79657c4f548a2aad887dfbb7e8374 3 | timeCreated: 1707902231 -------------------------------------------------------------------------------- /Editor/InputHintsConfigInspector.cs: -------------------------------------------------------------------------------- 1 | // (C)2024 @noio_games 2 | // Thomas van den Berg 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.IO; 7 | using System.Linq; 8 | using UnityEditor; 9 | using UnityEditorInternal; 10 | using UnityEngine; 11 | using UnityEngine.Localization.Settings; 12 | using UnityEngine.Localization.SmartFormat.Extensions; 13 | using UnityEngine.Localization.SmartFormat.PersistentVariables; 14 | 15 | namespace games.noio.InputHints.Editor 16 | { 17 | [CustomEditor(typeof(InputHintsConfig))] 18 | public class InputHintsConfigInspector : UnityEditor.Editor 19 | { 20 | const string DefaultVariablesGroupAssetName = "global"; 21 | const string DefaultVariableGroup = "input"; 22 | 23 | static readonly Lazy ExplanationTextStyle = new(() => new GUIStyle(EditorStyles.label) 24 | { 25 | wordWrap = true, 26 | fontSize = 11 27 | }); 28 | 29 | InputHintsConfig _config; 30 | SerializedProperty _scriptProp; 31 | SerializedProperty _inputActionsProp; 32 | SerializedProperty _spriteFormatProp; 33 | SerializedProperty _spriteCategoriesProp; 34 | SerializedProperty _controlTypesProp; 35 | SerializedProperty _spritesProp; 36 | SerializedProperty _missingControlPathsProp; 37 | ReorderableList _spriteCategoriesList; 38 | ReorderableList _controlTypesList; 39 | bool _categoriesOpen = true; 40 | bool _controlTypesOpen = true; 41 | bool _spritesOpen = true; 42 | bool _missingControlPathsOpen = true; 43 | bool _localizationVariablesAdded; 44 | string _localizationFormatString; 45 | 46 | #region MONOBEHAVIOUR METHODS 47 | 48 | void OnEnable() 49 | { 50 | _config = target as InputHintsConfig; 51 | _scriptProp = serializedObject.FindProperty("m_Script"); 52 | _inputActionsProp = serializedObject.FindProperty("_inputActions"); 53 | _spriteFormatProp = serializedObject.FindProperty("_spriteFormat"); 54 | _spriteCategoriesProp = serializedObject.FindProperty("_spriteCategories"); 55 | _controlTypesProp = serializedObject.FindProperty("_controlTypes"); 56 | _spritesProp = serializedObject.FindProperty("_sprites"); 57 | _missingControlPathsProp = serializedObject.FindProperty("_missingControlPaths"); 58 | 59 | CheckLocalizationVariablesAdded(); 60 | 61 | // Setup the ReorderableList for _spriteCategoriesProp 62 | _spriteCategoriesList = 63 | new ReorderableList(serializedObject, _spriteCategoriesProp, true, true, true, true) 64 | { 65 | drawHeaderCallback = rect => { EditorGUI.LabelField(rect, "Categories"); }, 66 | drawElementCallback = (rect, index, isActive, isFocused) => 67 | { 68 | var element = _spriteCategoriesProp.GetArrayElementAtIndex(index); 69 | 70 | // rect.y += 2; 71 | var name = element.FindPropertyRelative("_name"); 72 | GUI.Label(rect, name.stringValue); 73 | 74 | // EditorGUI.PropertyField( 75 | // new Rect(rect.x, rect.y, rect.width, EditorGUIUtility.singleLineHeight), 76 | // element, GUIContent.none); 77 | }, 78 | onAddCallback = list => 79 | { 80 | EditorApplication.delayCall += () => 81 | { 82 | var newCategory = TextInputDialog.Show("Add Sprite Category", 83 | "Enter name for new category:", "Gamepad"); 84 | if (_config.GetSpriteCategories().Contains(newCategory) == 85 | false) 86 | { 87 | // This is where you can customize the add operation 88 | var index = list.serializedProperty.arraySize; 89 | list.serializedProperty.arraySize++; 90 | list.index = index; // Automatically select the new item 91 | 92 | var newElement = list.serializedProperty.GetArrayElementAtIndex(index); 93 | newElement.FindPropertyRelative("_name").stringValue = newCategory; 94 | serializedObject.ApplyModifiedProperties(); 95 | } 96 | }; 97 | } 98 | }; 99 | 100 | _controlTypesList = 101 | new ReorderableList(serializedObject, _controlTypesProp, true, false, true, true) 102 | { 103 | drawElementCallback = (rect, index, isActive, isFocused) => 104 | { 105 | var element = _controlTypesProp.GetArrayElementAtIndex(index); 106 | var devicesString = element.FindPropertyRelative("_devices").stringValue; 107 | 108 | /* 109 | * Draw PREVIEW button 110 | */ 111 | var buttonRect = rect; 112 | buttonRect.yMin += 1; 113 | 114 | // buttonRect.height = EditorGUIUtility.singleLineHeight; 115 | buttonRect.width = 60; 116 | buttonRect.yMax -= 1; 117 | if (GUI.Button(buttonRect, "Preview")) 118 | { 119 | _config.SetControlTypeFromDevicesString(devicesString); 120 | } 121 | 122 | /* 123 | * DRAW REST OF FIELDS 124 | */ 125 | var fieldsRect = rect; 126 | fieldsRect.xMin += 75; 127 | 128 | // fieldsRect.yMin += 2 + EditorGUIUtility.singleLineHeight; 129 | var label = string.IsNullOrEmpty(devicesString) ? "Default" : devicesString; 130 | EditorGUI.PropertyField(fieldsRect, element, new GUIContent(label), true); 131 | }, 132 | elementHeightCallback = index => 133 | { 134 | var element = _controlTypesProp.GetArrayElementAtIndex(index); 135 | return EditorGUI.GetPropertyHeight(element, true); 136 | 137 | // + 138 | // EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; 139 | } 140 | }; 141 | } 142 | 143 | #endregion 144 | 145 | public override void OnInspectorGUI() 146 | { 147 | serializedObject.Update(); 148 | 149 | using (new EditorGUI.DisabledScope(true)) 150 | { 151 | EditorGUILayout.PropertyField(_scriptProp); 152 | } 153 | 154 | EditorGUILayout.Space(); 155 | // Draw the ReorderableList instead of the default property field for _spriteCategoriesProp 156 | 157 | bool inputActionsLinked = _inputActionsProp.objectReferenceValue != null; 158 | var localizationSettings = LocalizationSettings.Instance; 159 | 160 | if (inputActionsLinked == false) 161 | { 162 | EditorGUILayout.HelpBox("Please create and link an Input Action Asset", MessageType.Error); 163 | } 164 | else 165 | { 166 | if (_localizationVariablesAdded) 167 | { 168 | EditorGUILayout.HelpBox( 169 | $"You can insert Input Hints into Localized Strings using {{{_localizationFormatString}.ActionName}}", 170 | MessageType.Info); 171 | } 172 | else 173 | { 174 | using (new GUILayout.HorizontalScope()) 175 | { 176 | EditorGUILayout.HelpBox( 177 | "The Localization System is not set up to format Input Hints.", 178 | MessageType.Error); 179 | if (GUILayout.Button("Configure Now", GUILayout.Height(38))) 180 | { 181 | AutoConfigureSetup(); 182 | } 183 | } 184 | } 185 | } 186 | 187 | EditorGUILayout.Space(); 188 | 189 | EditorGUILayout.PropertyField(_inputActionsProp); 190 | using (new EditorGUI.DisabledScope(true)) 191 | { 192 | EditorGUILayout.ObjectField(new GUIContent("Localization Settings"), localizationSettings, 193 | typeof(LocalizationSettings)); 194 | } 195 | 196 | EditorGUILayout.PropertyField(_spriteFormatProp); 197 | 198 | EditorGUILayout.Space(); 199 | using var scope = 200 | new EditorGUI.DisabledScope(inputActionsLinked == false || 201 | _localizationVariablesAdded == false); 202 | 203 | if (DrawHeader(ref _categoriesOpen, new GUIContent("Categories"), 204 | "Categories are mapped to specific TMP_SpriteAssets by the Control Type.")) 205 | { 206 | _spriteCategoriesList.DoLayoutList(); 207 | } 208 | 209 | // EditorGUILayout.HelpBox( 210 | // "Sprite Sheets are divided into categories. " + 211 | // "For example, \"Gamepad\" can be \"Xbox\" or \"DualSense\", " + 212 | // "but both have a sprite named \"Stick-L\"", 213 | // MessageType.None); 214 | 215 | if (DrawHeader(ref _controlTypesOpen, new GUIContent("Control Types"), 216 | "Control Types take a connected controller and map specific Sprite Assets " + 217 | "to each relevant category. Leave the last \"Devices\" string empty to use it as the " + 218 | "default.")) 219 | { 220 | EditorGUI.indentLevel++; 221 | 222 | // EditorGUILayout.PropertyField(_controlTypesProp, GUIContent.none, true); 223 | _controlTypesList.DoLayoutList(); 224 | 225 | EditorGUI.indentLevel--; 226 | } 227 | 228 | if (DrawHeader(ref _spritesOpen, new GUIContent("Sprites"), 229 | "These are the mappings from the Input System's 'Control Path' to " + 230 | "character names defined on the Sprite Asset.")) 231 | { 232 | using (var changeScope = new EditorGUI.ChangeCheckScope()) 233 | { 234 | // _spritesList.DoLayoutList(); 235 | EditorGUI.indentLevel++; 236 | EditorGUILayout.PropertyField(_spritesProp, GUIContent.none, true); 237 | EditorGUI.indentLevel--; 238 | 239 | if (changeScope.changed) 240 | { 241 | serializedObject.ApplyModifiedProperties(); 242 | 243 | EditorApplication.delayCall += () => { _config.OnChanged(); }; 244 | } 245 | } 246 | } 247 | 248 | if (DrawHeader(ref _missingControlPathsOpen, new GUIContent("Missing Control Paths"), 249 | "In the Editor, when a sprite is requested for a control path that has no mapping, " + 250 | "it is logged here, and you can add it directly.")) 251 | { 252 | for (var i = 0; i < _missingControlPathsProp.arraySize; i++) 253 | { 254 | var item = _missingControlPathsProp.GetArrayElementAtIndex(i); 255 | EditorGUILayout.PropertyField(item, GUIContent.none); 256 | } 257 | } 258 | 259 | serializedObject.ApplyModifiedProperties(); 260 | } 261 | 262 | void CheckLocalizationVariablesAdded() 263 | { 264 | foreach (var source in LocalizationSettings.StringDatabase.SmartFormatter.SourceExtensions) 265 | { 266 | if (source is PersistentVariablesSource persistentVariablesSource) 267 | { 268 | /* 269 | * Go through all the "Variables Group Assets" 270 | */ 271 | foreach (KeyValuePair group in persistentVariablesSource) 272 | { 273 | if (group.Value != null) 274 | { 275 | /* 276 | * Go through each Variables Group 277 | */ 278 | foreach (KeyValuePair variable in group.Value) 279 | { 280 | /* 281 | * See if an InputActionVariable group is added, 282 | * then we'll just assume it's this one 283 | */ 284 | if (variable.Value is InputActionVariableGroup inputActionVariableGroup) 285 | { 286 | if (inputActionVariableGroup.Config == _config) 287 | { 288 | _localizationVariablesAdded = true; 289 | _localizationFormatString = $"{group.Key}.{variable.Key}"; 290 | } 291 | } 292 | } 293 | } 294 | } 295 | } 296 | } 297 | } 298 | 299 | void AutoConfigureSetup() 300 | { 301 | VariablesGroupAsset defaultGroup = null; 302 | PersistentVariablesSource variablesSource = null; 303 | foreach (var source in LocalizationSettings.StringDatabase.SmartFormatter.SourceExtensions) 304 | { 305 | if (source is PersistentVariablesSource persistentVariablesSource) 306 | { 307 | variablesSource = persistentVariablesSource; 308 | foreach (KeyValuePair group in persistentVariablesSource) 309 | { 310 | if (group.Key == DefaultVariablesGroupAssetName) 311 | { 312 | defaultGroup = group.Value; 313 | } 314 | } 315 | } 316 | } 317 | 318 | /* 319 | * Create a Variables Group Asset if it doesn't exist 320 | */ 321 | if (defaultGroup == null) 322 | { 323 | defaultGroup = CreateInstance(); 324 | defaultGroup.name = "Global Localization Variables"; 325 | var folder = Path.GetDirectoryName(AssetDatabase.GetAssetPath(_config)); 326 | AssetDatabase.CreateAsset(defaultGroup, Path.Combine(folder, defaultGroup.name + ".asset")); 327 | } 328 | 329 | /* 330 | * Add Variables Group Asset to the Localization Settings "Variables Source" 331 | */ 332 | variablesSource.Add(DefaultVariablesGroupAssetName, defaultGroup); 333 | 334 | /* 335 | * Now we need to create & add our InputActionVariableGroup (to the Asset) 336 | */ 337 | var variableGroup = new InputActionVariableGroup() { Config = _config }; 338 | 339 | Undo.RecordObject(defaultGroup, "Set Up Input Hints"); 340 | 341 | /* 342 | * Need to do some hacking to add a managed Reference to the GroupsAsset 343 | * (just calling "add" is not enough because it uses SerializeReference) 344 | */ 345 | var groupSerializedOb = new SerializedObject(defaultGroup); 346 | var variablesList = groupSerializedOb.FindProperty("m_Variables"); 347 | var index = variablesList.arraySize; 348 | variablesList.InsertArrayElementAtIndex(index); 349 | var element = variablesList.GetArrayElementAtIndex(index); 350 | var variable = element.FindPropertyRelative("variable"); 351 | variable.managedReferenceValue = variableGroup; 352 | 353 | var name = element.FindPropertyRelative("name"); 354 | name.stringValue = DefaultVariableGroup; 355 | groupSerializedOb.ApplyModifiedProperties(); 356 | 357 | // defaultGroup.Add(DefaultVariableGroup, variableGroup); 358 | 359 | CheckLocalizationVariablesAdded(); 360 | } 361 | 362 | static bool DrawHeader(ref bool isOpen, GUIContent title, string description) 363 | { 364 | EditorGUILayout.Space(); 365 | 366 | // isOpen = EditorGUILayout.Foldout(isOpen, title, EditorStyles.foldoutHeader); 367 | 368 | EditorGUILayout.LabelField(title, EditorStyles.boldLabel); 369 | 370 | if (isOpen) 371 | { 372 | EditorGUILayout.Space(2); 373 | GUI.color = new Color(1f, 1f, 1f, 0.8f); 374 | EditorGUILayout.LabelField(description, 375 | ExplanationTextStyle.Value); 376 | GUI.color = Color.white; 377 | } 378 | 379 | EditorGUILayout.Space(9); 380 | 381 | return isOpen; 382 | } 383 | } 384 | } -------------------------------------------------------------------------------- /Editor/InputHintsConfigInspector.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: d3bf96ec293b4c1e90a5e976a3a946c1 3 | timeCreated: 1660300422 -------------------------------------------------------------------------------- /Editor/MissingControlPathDrawer.cs: -------------------------------------------------------------------------------- 1 | using UnityEditor; 2 | using UnityEngine; 3 | 4 | namespace games.noio.InputHints.Editor 5 | { 6 | [CustomPropertyDrawer(typeof(MissingControlPath))] 7 | public class MissingControlPathDrawer : PropertyDrawer 8 | { 9 | #region MONOBEHAVIOUR METHODS 10 | 11 | // Draw the property inside the given rect 12 | public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) 13 | { 14 | var controlPathProp = property.FindPropertyRelative("_controlPath"); 15 | var controlSchemeProp = property.FindPropertyRelative("_controlScheme"); 16 | 17 | // Using BeginProperty / EndProperty on the parent property means that 18 | // prefab override logic works on the entire property. 19 | EditorGUI.BeginProperty(position, label, property); 20 | 21 | // Don't make child fields be indented 22 | var indent = EditorGUI.indentLevel; 23 | EditorGUI.indentLevel = 0; 24 | 25 | var iconRect = position; 26 | iconRect.width = 18; 27 | 28 | var clearButtonRect = position; 29 | clearButtonRect.xMin = clearButtonRect.xMax - 60; 30 | 31 | var addButtonRect = position; 32 | addButtonRect.xMax = clearButtonRect.xMin - 5; 33 | addButtonRect.xMin = addButtonRect.xMax - 60; 34 | 35 | var textRect = position; 36 | textRect.xMin = iconRect.xMax; 37 | textRect.xMax = addButtonRect.xMin - 5; 38 | 39 | EditorGUI.LabelField(iconRect, EditorGUIUtility.IconContent("d_console.warnicon.sml")); 40 | 41 | EditorGUI.LabelField(textRect, 42 | $"No mapping found for \"{controlPathProp.stringValue}\" " + 43 | $"in Control Scheme \"{controlSchemeProp.stringValue}\""); 44 | 45 | if (GUI.Button(addButtonRect, "Add")) 46 | { 47 | var config = controlPathProp.serializedObject.targetObject as InputHintsConfig; 48 | 49 | EditorApplication.delayCall += () => 50 | { 51 | var spriteName = SpriteSearchWindow.ShowWindow(); 52 | 53 | config.AddSprite(controlPathProp.stringValue, spriteName, controlSchemeProp.stringValue); 54 | config.ClearMissingControlPath(controlPathProp.stringValue, 55 | controlSchemeProp.stringValue); 56 | controlPathProp.serializedObject.Update(); 57 | }; 58 | } 59 | 60 | if (GUI.Button(clearButtonRect, "Clear")) 61 | { 62 | var config = controlPathProp.serializedObject.targetObject as InputHintsConfig; 63 | config.ClearMissingControlPath(controlPathProp.stringValue, controlSchemeProp.stringValue); 64 | controlPathProp.serializedObject.Update(); 65 | } 66 | 67 | // Set indent back to what it was 68 | EditorGUI.indentLevel = indent; 69 | 70 | EditorGUI.EndProperty(); 71 | } 72 | 73 | #endregion 74 | } 75 | } -------------------------------------------------------------------------------- /Editor/MissingControlPathDrawer.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: ad61d1e0965d7470db56512bd2f86de0 3 | MonoImporter: 4 | externalObjects: {} 5 | serializedVersion: 2 6 | defaultReferences: [] 7 | executionOrder: 0 8 | icon: {instanceID: 0} 9 | userData: 10 | assetBundleName: 11 | assetBundleVariant: 12 | -------------------------------------------------------------------------------- /Editor/SpriteCategoryToAssetMappingDrawer.cs: -------------------------------------------------------------------------------- 1 | using UnityEditor; 2 | using UnityEngine; 3 | 4 | namespace games.noio.InputHints.Editor 5 | { 6 | [CustomPropertyDrawer(typeof(SpriteCategoryToAssetMapping))] 7 | public class SpriteCategoryToAssetMappingDrawer : PropertyDrawer 8 | { 9 | // Draw the property inside the given rect 10 | public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) 11 | { 12 | var spriteCategoryProp = property.FindPropertyRelative("_spriteCategory"); 13 | var spriteAssetProp = property.FindPropertyRelative("_spriteAsset"); 14 | 15 | // Using BeginProperty / EndProperty on the parent property means that 16 | // prefab override logic works on the entire property. 17 | EditorGUI.BeginProperty(position, label, property); 18 | 19 | position.yMin += 1; 20 | 21 | // Don't make child fields be indented 22 | var indent = EditorGUI.indentLevel; 23 | EditorGUI.indentLevel = 0; 24 | 25 | // Calculate rects 26 | var leftRect = position; 27 | leftRect.width = 80; 28 | 29 | // var midRect = position; 30 | // midRect.xMin = leftRect.xMax + 5; 31 | // midRect.width = 16; 32 | 33 | var rightRect = position; 34 | rightRect.xMin = leftRect.xMax + 5; 35 | rightRect.height -= 2; 36 | 37 | var config = property.serializedObject.targetObject as InputHintsConfig; 38 | 39 | /* 40 | * Draw Dropdown with all SpriteCategories 41 | */ 42 | if (EditorGUI.DropdownButton(leftRect, 43 | new GUIContent(spriteCategoryProp.stringValue), 44 | FocusType.Keyboard)) 45 | { 46 | var menu = new GenericMenu(); 47 | 48 | foreach (var sheetKey in config.GetSpriteCategories()) 49 | { 50 | menu.AddItem(new GUIContent(sheetKey), false, () => 51 | { 52 | spriteCategoryProp.stringValue = sheetKey; 53 | spriteCategoryProp.serializedObject.ApplyModifiedProperties(); 54 | config.OnChanged(); 55 | }); 56 | } 57 | 58 | menu.DropDown(leftRect); 59 | } 60 | 61 | // EditorGUI.LabelField(midRect, "\u27a1"); 62 | 63 | /* 64 | * Draw Dropdown with all SpriteSheets in project: 65 | */ 66 | 67 | using (var changeScope = new EditorGUI.ChangeCheckScope()) 68 | { 69 | EditorGUI.PropertyField(rightRect, spriteAssetProp, GUIContent.none); 70 | if (changeScope.changed) 71 | { 72 | config.OnChanged(); 73 | } 74 | } 75 | 76 | // if (EditorGUI.DropdownButton(rightRect, 77 | // new GUIContent(spriteAssetNameProp.stringValue), 78 | // FocusType.Keyboard)) 79 | // { 80 | // var menu = new GenericMenu(); 81 | // 82 | // foreach (var asset in InputHintsConfig.GetSpriteAssetsInProject()) 83 | // { 84 | // menu.AddItem(new GUIContent(asset.name), false, () => 85 | // { 86 | // spriteAssetNameProp.stringValue = asset.name; 87 | // spriteAssetNameProp.serializedObject.ApplyModifiedProperties(); 88 | // InputHints.OnBindingsChanged(); 89 | // }); 90 | // } 91 | // 92 | // menu.DropDown(rightRect); 93 | // } 94 | 95 | // Set indent back to what it was 96 | EditorGUI.indentLevel = indent; 97 | 98 | EditorGUI.EndProperty(); 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /Editor/SpriteCategoryToAssetMappingDrawer.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: d644caef07ca46b58f1f16436be92071 3 | timeCreated: 1662475811 -------------------------------------------------------------------------------- /Editor/SpriteSearchWindow.cs: -------------------------------------------------------------------------------- 1 | // (C)2024 @noio_games 2 | // Thomas van den Berg 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using TMPro; 8 | using UnityEditor; 9 | using UnityEngine; 10 | 11 | namespace games.noio.InputHints.Editor 12 | { 13 | public class SpriteSearchWindow : EditorWindow 14 | { 15 | public event Action SpritePicked; 16 | List<(TMP_SpriteAsset spriteAsset, TMP_SpriteCharacter spriteCharacter)> _sprites; 17 | Vector2 _scrollPosition; 18 | 19 | Dictionary> 20 | _sprites2; 21 | 22 | List _spriteNames; 23 | string _searchString; 24 | bool _initializedPosition; 25 | bool _shouldClose; 26 | Vector2 MaxScreenPos { get; set; } 27 | 28 | #region MONOBEHAVIOUR METHODS 29 | 30 | void OnEnable() 31 | { 32 | _sprites2 = 33 | new Dictionary>(); 35 | 36 | var spriteSheets = AssetDatabase.FindAssets("t:TMP_SpriteAsset") 37 | .Select(AssetDatabase.GUIDToAssetPath) 38 | .Select(AssetDatabase.LoadAssetAtPath) 39 | .Where(sheet => sheet != null) 40 | .ToList(); 41 | 42 | foreach (var spriteAsset in spriteSheets) 43 | { 44 | foreach (var character in spriteAsset.spriteCharacterTable) 45 | { 46 | if (_sprites2.TryGetValue(character.name, out var sprites) == false) 47 | { 48 | _sprites2[character.name] = sprites = 49 | new List<(TMP_SpriteAsset spriteAsset, TMP_SpriteCharacter spriteCharacter)>(); 50 | } 51 | 52 | sprites.Add((spriteAsset, character)); 53 | } 54 | } 55 | 56 | _spriteNames = _sprites2.Keys.ToList(); 57 | _spriteNames.Sort(); 58 | 59 | spriteSheets.SelectMany(spriteAsset => 60 | spriteAsset.spriteCharacterTable.Select(spriteCharacter => 61 | (spriteAsset, spriteCharacter))) 62 | .ToList(); 63 | } 64 | 65 | void OnGUI() 66 | { 67 | 68 | 69 | if (_shouldClose) 70 | { 71 | Close(); 72 | } 73 | 74 | /* 75 | * SEARCH BAR 76 | */ 77 | using (new GUILayout.HorizontalScope(GUI.skin.FindStyle("Toolbar"))) 78 | { 79 | GUILayout.FlexibleSpace(); 80 | GUI.SetNextControlName("spriteSearch"); 81 | _searchString = GUILayout.TextField(_searchString, 82 | GUI.skin.FindStyle("ToolbarSearchTextField"), 83 | GUILayout.Width(150)); 84 | if (GUILayout.Button("", GUI.skin.FindStyle("ToolbarSearchCancelButton"))) 85 | { 86 | // Remove focus if cleared 87 | _searchString = ""; 88 | GUI.FocusControl(null); 89 | } 90 | } 91 | 92 | /* 93 | * SPRITE LISTING 94 | */ 95 | using (var scrollView = new EditorGUILayout.ScrollViewScope(_scrollPosition)) 96 | { 97 | foreach (var spriteName in _spriteNames) 98 | { 99 | if (string.IsNullOrEmpty(_searchString) == false && 100 | spriteName.ToLower().Contains(_searchString.ToLower()) == false) 101 | { 102 | continue; 103 | } 104 | 105 | using (new EditorGUILayout.HorizontalScope()) 106 | { 107 | if (GUILayout.Button("Pick", GUILayout.Width(40))) 108 | { 109 | SpritePicked?.Invoke(spriteName); 110 | _shouldClose = true; 111 | } 112 | 113 | EditorGUILayout.LabelField(spriteName, EditorStyles.boldLabel); 114 | } 115 | 116 | GUILayout.Space(5); 117 | 118 | using (new EditorGUILayout.HorizontalScope()) 119 | { 120 | GUILayout.Space(16); 121 | foreach (var (spriteAsset, spriteCharacter) in _sprites2[spriteName]) 122 | { 123 | using (new EditorGUILayout.VerticalScope(GUILayout.Width(50))) 124 | { 125 | var rect = EditorGUILayout.GetControlRect(false, 40, 126 | GUILayout.ExpandWidth(true)); 127 | rect.width = 40; 128 | DrawSpriteGlyph(rect, spriteCharacter, spriteAsset); 129 | GUI.Label(rect, new GUIContent("", spriteAsset.name)); 130 | } 131 | } 132 | } 133 | 134 | GUILayout.Space(10); 135 | } 136 | 137 | _scrollPosition = scrollView.scrollPosition; 138 | } 139 | 140 | /* 141 | * INITIALIZATION 142 | */ 143 | var e = Event.current; 144 | 145 | // Set dialog position next to mouse position 146 | if (!_initializedPosition && e.type == EventType.Layout) 147 | { 148 | _initializedPosition = true; 149 | 150 | // Move window to a new position. Make sure we're inside visible window 151 | var mousePos = GUIUtility.GUIToScreenPoint(Event.current.mousePosition); 152 | mousePos.x += 32; 153 | if (mousePos.x + position.width > MaxScreenPos.x) 154 | { 155 | mousePos.x -= position.width + 64; // Display on left side of mouse 156 | } 157 | 158 | if (mousePos.y + position.height > MaxScreenPos.y) 159 | { 160 | mousePos.y = MaxScreenPos.y - position.height; 161 | } 162 | 163 | position = new Rect(mousePos.x, mousePos.y, position.width, position.height); 164 | 165 | // Focus current window 166 | GUI.FocusControl("spriteSearch"); 167 | Focus(); 168 | } 169 | } 170 | 171 | #endregion 172 | 173 | public static string ShowWindow() 174 | { 175 | var window = CreateInstance(); 176 | window.titleContent = new GUIContent("Sprite Listing"); 177 | window.minSize = window.maxSize = new Vector2(600, 700); 178 | window.MaxScreenPos = GUIUtility.GUIToScreenPoint(new Vector2(Screen.width, Screen.height)); 179 | string pickedSprite = null; 180 | window.SpritePicked += spriteName => pickedSprite = spriteName; 181 | 182 | window.ShowModal(); 183 | 184 | return pickedSprite; 185 | } 186 | 187 | void DrawSpriteGlyph(Rect position, TMP_SpriteCharacter character, TMP_SpriteAsset spriteAsset) 188 | { 189 | // Get a reference to the sprite glyph table 190 | // TMP_SpriteAsset spriteAsset = property.serializedObject.targetObject as TMP_SpriteAsset; 191 | 192 | if (spriteAsset == null) 193 | { 194 | return; 195 | } 196 | 197 | // int glyphIndex = property.FindPropertyRelative("m_GlyphIndex").intValue; 198 | var glyphIndex = (int)character.glyphIndex; 199 | 200 | // Lookup glyph and draw glyph (if available) 201 | var elementIndex = spriteAsset.spriteGlyphTable.FindIndex(item => item.index == glyphIndex); 202 | 203 | if (elementIndex != -1) 204 | { 205 | var glyphRect = spriteAsset.spriteGlyphTable[elementIndex].glyphRect; 206 | 207 | // Get a reference to the sprite texture 208 | var tex = spriteAsset.spriteSheet; 209 | 210 | // Return if we don't have a texture assigned to the sprite asset. 211 | if (tex == null) 212 | { 213 | Debug.LogWarning( 214 | "Please assign a valid Sprite Atlas texture to the [" + spriteAsset.name + 215 | "] Sprite Asset.", spriteAsset); 216 | return; 217 | } 218 | 219 | var spriteTexPosition = new Vector2(position.x, position.y); 220 | var spriteSize = new Vector2(48, 48); 221 | var alignmentOffset = new Vector2((58 - spriteSize.x) / 2, (58 - spriteSize.y) / 2); 222 | 223 | float x = glyphRect.x; 224 | float y = glyphRect.y; 225 | float spriteWidth = glyphRect.width; 226 | float spriteHeight = glyphRect.height; 227 | 228 | if (spriteWidth >= spriteHeight) 229 | { 230 | spriteSize.y = spriteHeight * spriteSize.x / spriteWidth; 231 | spriteTexPosition.y += (spriteSize.x - spriteSize.y) / 2; 232 | } 233 | else 234 | { 235 | spriteSize.x = spriteWidth * spriteSize.y / spriteHeight; 236 | spriteTexPosition.x += (spriteSize.y - spriteSize.x) / 2; 237 | } 238 | 239 | // Compute the normalized texture coordinates 240 | var texCoords = new Rect(x / tex.width, y / tex.height, spriteWidth / tex.width, 241 | spriteHeight / tex.height); 242 | 243 | GUI.DrawTextureWithTexCoords(position, tex, texCoords, true); 244 | } 245 | } 246 | } 247 | } -------------------------------------------------------------------------------- /Editor/SpriteSearchWindow.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: b3421a4a16764eeab43505fef724e40c 3 | timeCreated: 1707743869 -------------------------------------------------------------------------------- /Editor/TextInputDialog.cs: -------------------------------------------------------------------------------- 1 | // (C)2024 @noio_games 2 | // Thomas van den Berg 3 | 4 | using System; 5 | using UnityEditor; 6 | using UnityEngine; 7 | 8 | namespace games.noio.InputHints.Editor 9 | { 10 | internal class TextInputDialog : EditorWindow 11 | { 12 | string _description; 13 | string _inputText; 14 | string _okButton; 15 | string _cancelButton; 16 | bool _initializedPosition; 17 | Action _onOKButton; 18 | bool _shouldClose; 19 | Vector2 _maxScreenPos; 20 | 21 | #region MONOBEHAVIOUR METHODS 22 | 23 | #region OnGUI() 24 | 25 | void OnGUI() 26 | { 27 | // Check if Esc/Return have been pressed 28 | var e = Event.current; 29 | if (e.type == EventType.KeyDown) 30 | { 31 | switch (e.keyCode) 32 | { 33 | // Escape pressed 34 | case KeyCode.Escape: 35 | _shouldClose = true; 36 | e.Use(); 37 | break; 38 | 39 | // Enter pressed 40 | case KeyCode.Return: 41 | case KeyCode.KeypadEnter: 42 | _onOKButton?.Invoke(); 43 | _shouldClose = true; 44 | e.Use(); 45 | break; 46 | } 47 | } 48 | 49 | if (_shouldClose) 50 | { 51 | // Close this dialog 52 | Close(); 53 | 54 | //return; 55 | } 56 | 57 | // Draw our control 58 | var rect = EditorGUILayout.BeginVertical(); 59 | 60 | EditorGUILayout.Space(12); 61 | EditorGUILayout.LabelField(_description); 62 | 63 | EditorGUILayout.Space(8); 64 | GUI.SetNextControlName("inText"); 65 | _inputText = EditorGUILayout.TextField("", _inputText); 66 | GUI.FocusControl("inText"); // Focus text field 67 | EditorGUILayout.Space(12); 68 | 69 | // Draw OK / Cancel buttons 70 | var r = EditorGUILayout.GetControlRect(); 71 | r.width /= 2; 72 | if (GUI.Button(r, _okButton)) 73 | { 74 | _onOKButton?.Invoke(); 75 | _shouldClose = true; 76 | } 77 | 78 | r.x += r.width; 79 | if (GUI.Button(r, _cancelButton)) 80 | { 81 | _inputText = null; // Cancel - delete inputText 82 | _shouldClose = true; 83 | } 84 | 85 | EditorGUILayout.Space(8); 86 | EditorGUILayout.EndVertical(); 87 | 88 | // Force change size of the window 89 | if (rect.width != 0 && minSize != rect.size) 90 | { 91 | minSize = maxSize = rect.size; 92 | } 93 | 94 | // Set dialog position next to mouse position 95 | if (!_initializedPosition && e.type == EventType.Layout) 96 | { 97 | _initializedPosition = true; 98 | 99 | // Move window to a new position. Make sure we're inside visible window 100 | var mousePos = GUIUtility.GUIToScreenPoint(Event.current.mousePosition); 101 | mousePos.x += 32; 102 | if (mousePos.x + position.width > _maxScreenPos.x) 103 | { 104 | mousePos.x -= position.width + 64; // Display on left side of mouse 105 | } 106 | 107 | if (mousePos.y + position.height > _maxScreenPos.y) 108 | { 109 | mousePos.y = _maxScreenPos.y - position.height; 110 | } 111 | 112 | position = new Rect(mousePos.x, mousePos.y, position.width, position.height); 113 | 114 | // Focus current window 115 | Focus(); 116 | } 117 | } 118 | 119 | #endregion OnGUI() 120 | 121 | #endregion 122 | 123 | #region Show() 124 | 125 | /// 126 | /// Returns text player entered, or null if player cancelled the dialog. 127 | /// 128 | /// 129 | /// 130 | /// 131 | /// 132 | /// 133 | /// 134 | public static string Show( 135 | string title, 136 | string description, 137 | string inputText, 138 | string okButton = "OK", 139 | string cancelButton = "Cancel") 140 | { 141 | // Make sure our popup is always inside parent window, and never offscreen 142 | // So get caller's window size 143 | var maxPos = GUIUtility.GUIToScreenPoint(new Vector2(Screen.width, Screen.height)); 144 | 145 | string ret = null; 146 | 147 | //var window = EditorWindow.GetWindow(); 148 | var window = CreateInstance(); 149 | window._maxScreenPos = maxPos; 150 | window.titleContent = new GUIContent(title); 151 | window._description = description; 152 | window._inputText = inputText; 153 | window._okButton = okButton; 154 | window._cancelButton = cancelButton; 155 | window._onOKButton += () => ret = window._inputText; 156 | 157 | //window.ShowPopup(); 158 | window.ShowModal(); 159 | 160 | return ret; 161 | } 162 | 163 | #endregion Show() 164 | } 165 | } -------------------------------------------------------------------------------- /Editor/TextInputDialog.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 03f8c3ac057c4fc3bf46f470f8f228cd 3 | timeCreated: 1707737134 -------------------------------------------------------------------------------- /Editor/games.noio.InputHints.Editor.asmdef: -------------------------------------------------------------------------------- 1 | { 2 | "name": "games.noio.InputHints.Editor", 3 | "rootNamespace": "", 4 | "references": [ 5 | "GUID:f5a062bd525a84181bde8a099d881c55", 6 | "GUID:75469ad4d38634e559750d17036d5f7c", 7 | "GUID:eec0964c48f6f4e40bc3ec2257ccf8c5", 8 | "GUID:2e77701fe0ceda44fb763f03ee704c37", 9 | "GUID:6055be8ebefd69e48b49212b09b47b2f" 10 | ], 11 | "includePlatforms": [ 12 | "Editor" 13 | ], 14 | "excludePlatforms": [], 15 | "allowUnsafeCode": false, 16 | "overrideReferences": false, 17 | "precompiledReferences": [], 18 | "autoReferenced": true, 19 | "defineConstraints": [], 20 | "versionDefines": [], 21 | "noEngineReferences": false 22 | } -------------------------------------------------------------------------------- /Editor/games.noio.InputHints.Editor.asmdef.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: b53b43a2a556340ba991f4ffe57a30d3 3 | AssemblyDefinitionImporter: 4 | externalObjects: {} 5 | userData: 6 | assetBundleName: 7 | assetBundleVariant: 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Thomas van den Berg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LICENSE.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 3570c1ef6405a4253a6d9580a36e3ef4 3 | DefaultImporter: 4 | externalObjects: {} 5 | userData: 6 | assetBundleName: 7 | assetBundleVariant: 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Input Hints 2 | 3 | ![Preview](Docs~/preview.gif)![Preview](Docs~/localize_string_event.png) 4 | 5 | Input hints are little prompts in game that display the button, key or action that the player needs to perform to 6 | execute a certain action. 7 | 8 | > Press [A] to Jump 9 | 10 | Unity's new Input and Localization systems are perfect for displaying dynamic Input Hints, changing depending on 11 | the actual bindings set up in the Input Actions. TextMesh Pro is used for displaying sprites inline in text strings. 12 | All that it needs is a little bit of plumbing, and this package is that plumbing! 13 | 14 | ## Installation 15 | 16 | You will need actual icons to display in the text. I can recommend the excellent 17 | [PC & Consoles Controller Buttons Icons Pack](https://assetstore.unity.com/packages/2d/gui/icons/pc-consoles-controller-buttons-icons-pack-85215). 18 | This package is based around the way they set up sprites. 19 | 20 | 1. Install the package from the git URL 21 | 2. I'm assuming you already have [Input Actions](https://docs.unity3d.com/Packages/com.unity.inputsystem@0.9/manual/ActionAssets.html) 22 | and the [Localization System](https://docs.unity3d.com/Packages/com.unity.localization@1.4/manual/QuickStartGuideWithVariants.html) set up. 23 | 3. In Unity, go to `Assets/Create/Noio/Input Hints Config` 24 | 4. Follow instructions on the Input Hints Config asset. 25 | 5. Check out `DeviceDetectorSample.cs` 26 | 27 | ### Manual Installation 28 | If the button to automatically hook up the Localization Settings "Smart Format Source" does not work, this is 29 | the manual setup required: 30 | 31 | 1. Click to **Assets > Create > Localization > Variables Group** 32 | 2. In the newly created _Variables Group Asset_, add a variable, name it "input" and link the Input Hints Config. 33 | 34 | ![Set Up Input Variables](Docs~/manual_settings_input_variable.png) 35 | 36 | 3. Go into the Localization Settings Asset and open up **String Database > Smart Format > Sources > Persistent Variables Source** 37 | 4. Add the _Variables Group Asset_ that you created in Step 1: 38 | 39 | ![Added Variables Group in Settings](Docs~/manual_setup_localization_settings.png) 40 | 41 | ## How to Use 42 | 43 | ### Sample Configuration 44 | 45 | See below for a sample setup that uses the actions defined in Unity's Default Input Action Map (Look, Move, Fire), and 46 | the controller icons linked above. 47 | 48 | ![Sample Setup](Docs~/example_setup.png) 49 | 50 | ## Common Issues 51 | 52 | ### Warnings like '_No binding found for ActionName with Control Scheme "Keyboard&Mouse"_' 53 | 54 | Makes sure to select at least one _"Use in control scheme"_ for each binding in the Input Actions. 55 | 56 | ![Input Actions Settings: Use in control scheme](Docs~/use_in_control_scheme.png) 57 | 58 | ### Texts are not updating when switching input device 59 | 60 | Try using `LocalizeStringEvent` instead of `GameObjectLocalizer`. It seems that the latter component sometimes 61 | does not respond when a localization variable is changed. -------------------------------------------------------------------------------- /README.md.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 2fbd65117a9d4443a83ca7f348198535 3 | TextScriptImporter: 4 | externalObjects: {} 5 | userData: 6 | assetBundleName: 7 | assetBundleVariant: 8 | -------------------------------------------------------------------------------- /Runtime.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: cdb8ee9fb844b47e1a482b8d4df29c29 3 | folderAsset: yes 4 | DefaultImporter: 5 | externalObjects: {} 6 | userData: 7 | assetBundleName: 8 | assetBundleVariant: 9 | -------------------------------------------------------------------------------- /Runtime/ControlPathToSpriteMapping.cs: -------------------------------------------------------------------------------- 1 | // (C)2024 @noio_games 2 | // Thomas van den Berg 3 | 4 | using System; 5 | using UnityEngine; 6 | using UnityEngine.Serialization; 7 | 8 | namespace games.noio.InputHints 9 | { 10 | [Serializable] 11 | public class ControlPathToSpriteMapping 12 | { 13 | #region PUBLIC AND SERIALIZED FIELDS 14 | 15 | [SerializeField] 16 | string _controlPath; 17 | 18 | [SerializeField] 19 | string _spriteName; 20 | 21 | [SerializeField] 22 | string _spriteCategory; 23 | 24 | #endregion 25 | 26 | public ControlPathToSpriteMapping(string controlPath, string spriteName, string spriteCategory) 27 | { 28 | _controlPath = controlPath; 29 | _spriteName = spriteName; 30 | _spriteCategory = spriteCategory; 31 | } 32 | 33 | #region PROPERTIES 34 | 35 | public string ControlPath => _controlPath; 36 | public string SpriteName 37 | { 38 | get => _spriteName; 39 | set => _spriteName = value; 40 | } 41 | 42 | public string SpriteCategory => _spriteCategory; 43 | 44 | #endregion 45 | } 46 | } -------------------------------------------------------------------------------- /Runtime/ControlPathToSpriteMapping.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 0e9865f067e249cb9260191d23682f0c 3 | timeCreated: 1707732757 -------------------------------------------------------------------------------- /Runtime/ControlSchemeDropdownAttribute.cs: -------------------------------------------------------------------------------- 1 | // (C)2024 @noio_games 2 | // Thomas van den Berg 3 | 4 | using System; 5 | using UnityEngine; 6 | 7 | namespace games.noio.InputHints 8 | { 9 | public class ControlSchemeDropdownAttribute : PropertyAttribute 10 | { 11 | } 12 | } -------------------------------------------------------------------------------- /Runtime/ControlSchemeDropdownAttribute.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: d7b63348f1414d5eb9cbbba639fb6a3b 3 | timeCreated: 1707902206 -------------------------------------------------------------------------------- /Runtime/ControlType.cs: -------------------------------------------------------------------------------- 1 | // (C)2024 @noio_games 2 | // Thomas van den Berg 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Text.RegularExpressions; 7 | using UnityEngine; 8 | 9 | namespace games.noio.InputHints 10 | { 11 | [Serializable] 12 | public class ControlType 13 | { 14 | #region SERIALIZED FIELDS 15 | 16 | [Tooltip("A regular expression that is matched against the current Input Device's path.")] 17 | [SerializeField] 18 | string _devices = ""; 19 | 20 | [ControlSchemeDropdown] [SerializeField] string _inputControlScheme; 21 | [SerializeField] List _spriteAssets; 22 | 23 | #endregion 24 | 25 | Regex _deviceMatcher; 26 | 27 | public ControlType(string devices, string inputControlScheme, 28 | List spriteAssets) 29 | { 30 | _devices = devices; 31 | _inputControlScheme = inputControlScheme; 32 | _spriteAssets = spriteAssets; 33 | } 34 | 35 | #region PROPERTIES 36 | 37 | public string Devices => _devices; 38 | public string InputControlScheme => _inputControlScheme; 39 | public List SpriteAssets => _spriteAssets; 40 | 41 | public Regex DeviceMatcher 42 | { 43 | get 44 | { 45 | if (_deviceMatcher == null) 46 | { 47 | try 48 | { 49 | _deviceMatcher = new Regex(_devices); 50 | } 51 | catch (Exception) 52 | { 53 | _deviceMatcher = new Regex(@"^\b$"); // Match nothing 54 | } 55 | } 56 | 57 | return _deviceMatcher; 58 | } 59 | } 60 | 61 | public bool IsEmpty => string.IsNullOrEmpty(_inputControlScheme); 62 | 63 | #endregion 64 | 65 | public override string ToString() 66 | { 67 | return $"{nameof(ControlType)} for {_devices} and scheme {_inputControlScheme}"; 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /Runtime/ControlType.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: ff46e1f6acee4402bac03fcb6ea4eda6 3 | timeCreated: 1707732772 -------------------------------------------------------------------------------- /Runtime/DeviceDetectorSample.cs: -------------------------------------------------------------------------------- 1 | // (C)2024 @noio_games 2 | // Thomas van den Berg 3 | 4 | using games.noio.InputHints; 5 | using UnityEngine; 6 | using UnityEngine.InputSystem; 7 | 8 | namespace games.noio.InputHints 9 | { 10 | public class DeviceDetectorSample : MonoBehaviour 11 | { 12 | #region PUBLIC AND SERIALIZED FIELDS 13 | 14 | [SerializeField] InputActionReference _moveAction; 15 | [SerializeField] InputActionReference _lookAction; 16 | [SerializeField] InputActionReference _fireAction; 17 | 18 | #endregion 19 | 20 | #region MONOBEHAVIOUR METHODS 21 | 22 | void Awake() 23 | { 24 | /* 25 | * The way I like to do switching of input is just to listen for 26 | * the most used actions in a game: moving and the 'primary' action 27 | * (such as "use" or "fire") 28 | * And then switch hints depending on the last device used for those actions. 29 | * That means if the player picks up a new gamepad and wiggles the stick, 30 | * or presses the "A" button (or equivalent), the input hints change immediately. 31 | * 32 | * I'm using "Move" and "Fire" here because those are defined in Unity's 'Default Input Actions' 33 | */ 34 | if (_moveAction.action != null) 35 | { 36 | _moveAction.action.Enable(); 37 | _moveAction.action.performed += HandleMoveActionPerformed; 38 | } 39 | 40 | if (_lookAction.action != null) 41 | { 42 | _lookAction.action.Enable(); 43 | _lookAction.action.performed += HandleLookActionPerformed; 44 | } 45 | 46 | if (_fireAction.action != null) 47 | { 48 | _fireAction.action.Enable(); 49 | _fireAction.action.performed += HandleFireActionPerformed; 50 | } 51 | } 52 | 53 | #endregion 54 | 55 | void HandleLookActionPerformed(InputAction.CallbackContext ctx) 56 | { 57 | InputHints.SetUsedDevice(ctx.control.device); 58 | } 59 | 60 | void HandleFireActionPerformed(InputAction.CallbackContext ctx) 61 | { 62 | InputHints.SetUsedDevice(ctx.control.device); 63 | } 64 | 65 | void HandleMoveActionPerformed(InputAction.CallbackContext ctx) 66 | { 67 | InputHints.SetUsedDevice(ctx.control.device); 68 | } 69 | } 70 | } -------------------------------------------------------------------------------- /Runtime/DeviceDetectorSample.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 419d631a7dfe24b6aab73586734a1164 3 | MonoImporter: 4 | externalObjects: {} 5 | serializedVersion: 2 6 | defaultReferences: [] 7 | executionOrder: 0 8 | icon: {instanceID: 0} 9 | userData: 10 | assetBundleName: 11 | assetBundleVariant: 12 | -------------------------------------------------------------------------------- /Runtime/InputActionVariable.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using UnityEngine; 3 | using UnityEngine.InputSystem; 4 | using UnityEngine.Localization.SmartFormat.Core.Extensions; 5 | using UnityEngine.Localization.SmartFormat.PersistentVariables; 6 | 7 | // ReSharper disable once CheckNamespace 8 | namespace games.noio.InputHints 9 | { 10 | internal struct InputActionVariable : IVariableValueChanged 11 | { 12 | public event Action ValueChanged; 13 | readonly InputAction _action; 14 | readonly InputHintsConfig _source; 15 | 16 | public InputActionVariable(InputAction action, InputHintsConfig source) 17 | { 18 | _action = action; 19 | _source = source; 20 | ValueChanged = null; 21 | } 22 | 23 | public override string ToString() 24 | { 25 | return $"InputActionVariable({_action.name}, {_action.GetBindingDisplayString()})"; 26 | } 27 | 28 | public object GetSourceValue(ISelectorInfo selector) 29 | { 30 | // Debug.Log($"F{Time.frameCount} InputActionVariable.GetSourceValue({selector.SelectorOperator}{selector.SelectorText})"); 31 | return _source.GetSprite(_action); 32 | } 33 | 34 | public void OnValueChanged() 35 | { 36 | // Debug.Log($"F{Time.frameCount} Sending OnValueChanged to {this} Has Listeners: {ValueChanged != null}"); 37 | ValueChanged?.Invoke(this); 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /Runtime/InputActionVariable.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 5fe77fd930dd40ab984c98ee15aca34e 3 | timeCreated: 1662450322 -------------------------------------------------------------------------------- /Runtime/InputActionVariableGroup.cs: -------------------------------------------------------------------------------- 1 | // (C)2024 @noio_games 2 | // Thomas van den Berg 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using UnityEngine; 8 | using UnityEngine.Localization.SmartFormat.Core.Extensions; 9 | using UnityEngine.Localization.SmartFormat.PersistentVariables; 10 | 11 | namespace games.noio.InputHints 12 | { 13 | [Serializable] 14 | public class InputActionVariableGroup : IVariableValueChanged, IVariableGroup 15 | { 16 | public event Action ValueChanged; 17 | 18 | #region SERIALIZED FIELDS 19 | 20 | [SerializeField] InputHintsConfig _config; 21 | 22 | #endregion 23 | 24 | Dictionary _cachedVariables; 25 | 26 | #region PROPERTIES 27 | 28 | public InputHintsConfig Config 29 | { 30 | get => _config; 31 | set => _config = value; 32 | } 33 | 34 | #endregion 35 | 36 | #region INTERFACE IMPLEMENTATIONS 37 | 38 | public bool TryGetValue(string key, out IVariable value) 39 | { 40 | // Debug.Log($"F{Time.frameCount} InputActionVariableGroup.TryGetValue({key})"); 41 | 42 | if (_cachedVariables == null) 43 | { 44 | _config.Changed += OnValueChanged; 45 | _cachedVariables = new Dictionary(); 46 | } 47 | 48 | if (_cachedVariables.TryGetValue(key, out var cachedVariable)) 49 | { 50 | value = cachedVariable; 51 | return true; 52 | } 53 | 54 | var action = _config.InputActions.FindAction(key); 55 | if (action != null) 56 | { 57 | value = _cachedVariables[key] = new InputActionVariable(action, _config); 58 | return true; 59 | } 60 | 61 | /* 62 | * Try to find action by replacing underscores or dashes with spaces: 63 | * (In case the action name has spaces in it..) 64 | * We first try the exact match above in case the action 65 | * ACTUALLY has a dash or underscore in the name. 66 | */ 67 | key = key.Replace("-", " "); 68 | key = key.Replace("_", " "); 69 | action = _config.InputActions.FindAction(key); 70 | if (action != null) 71 | { 72 | value = _cachedVariables[key] = new InputActionVariable(action, _config); 73 | return true; 74 | } 75 | 76 | value = default; 77 | return false; 78 | } 79 | 80 | public object GetSourceValue(ISelectorInfo selector) 81 | { 82 | // Debug.Log($"F{Time.frameCount} InputActionVariableGroup.GetSourceValue({selector.SelectorOperator}{selector.SelectorText})"); 83 | // Debug.Log($"F{Time.frameCount} GetSourceVal"); 84 | // Debug.Log( 85 | // $"F{Time.frameCount} GetSourceVal {selector.SelectorText} {selector.CurrentValue} {selector.SelectorIndex} {selector.Result} {selector.SelectorOperator}"); 86 | return this; 87 | } 88 | 89 | #endregion 90 | 91 | void OnValueChanged() 92 | { 93 | ValueChanged?.Invoke(this); 94 | } 95 | } 96 | } -------------------------------------------------------------------------------- /Runtime/InputActionVariableGroup.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: fa32ce79ec53482aad22f4f60d094205 3 | timeCreated: 1662450751 -------------------------------------------------------------------------------- /Runtime/InputHints.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using TMPro; 4 | using UnityEditor; 5 | using UnityEngine; 6 | using UnityEngine.InputSystem; 7 | 8 | // ReSharper disable once CheckNamespace 9 | namespace games.noio.InputHints 10 | { 11 | public static class InputHints 12 | { 13 | public static event Action UsedDeviceChanged; 14 | 15 | #region PROPERTIES 16 | 17 | public static InputDevice UsedDevice { get; private set; } 18 | 19 | #endregion 20 | 21 | public static void SetUsedDevice(InputDevice device) 22 | { 23 | if (device != UsedDevice) 24 | { 25 | UsedDevice = device; 26 | UsedDeviceChanged?.Invoke(device); 27 | } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /Runtime/InputHints.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: a943ab0a0d0645c2aa704212adddf988 3 | timeCreated: 1662450328 -------------------------------------------------------------------------------- /Runtime/InputHintsConfig.cs: -------------------------------------------------------------------------------- 1 | // (C)2024 @noio_games 2 | // Thomas van den Berg 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using TMPro; 8 | using UnityEditor; 9 | using UnityEngine; 10 | using UnityEngine.InputSystem; 11 | using UnityEngine.Localization.Settings; 12 | 13 | namespace games.noio.InputHints 14 | { 15 | [CreateAssetMenu(menuName = "Noio/Input Hints Config")] 16 | public class InputHintsConfig : ScriptableObject 17 | { 18 | public event Action Changed; 19 | 20 | #region PUBLIC AND SERIALIZED FIELDS 21 | 22 | [SerializeField] InputActionAsset _inputActions; 23 | [SerializeField] string _spriteFormat = ""; 24 | [SerializeField] List _spriteCategories; 25 | [SerializeField] List _controlTypes; 26 | [SerializeField] List _sprites; 27 | [SerializeField] List _missingControlPaths; 28 | 29 | #endregion 30 | 31 | Dictionary _generatedVariables; 32 | ControlType _usedControlType; 33 | 34 | #region PROPERTIES 35 | 36 | public InputActionAsset InputActions => _inputActions; 37 | 38 | #endregion 39 | 40 | #region MONOBEHAVIOUR METHODS 41 | 42 | void OnEnable() 43 | { 44 | InputHints.UsedDeviceChanged += HandleUsedDeviceChanged; 45 | } 46 | 47 | void OnDisable() 48 | { 49 | InputHints.UsedDeviceChanged -= HandleUsedDeviceChanged; 50 | } 51 | 52 | #endregion 53 | 54 | public string GetSprite(InputAction action) 55 | { 56 | if (_usedControlType == null || _usedControlType.IsEmpty) 57 | { 58 | _usedControlType = GetControlType(InputHints.UsedDevice); 59 | } 60 | 61 | var bindingIndex = action.GetBindingIndex(_usedControlType.InputControlScheme); 62 | 63 | // if (action.bindings[bindingIndex].isPartOfComposite) 64 | // { 65 | // PrintBinding(action, bindingIndex - 1); 66 | // } 67 | 68 | if (bindingIndex <= -1) 69 | { 70 | #if UNITY_EDITOR 71 | Debug.LogWarning($"No binding found for \"{action.name}\" " + 72 | $"with Control Scheme \"{_usedControlType.InputControlScheme}\"",this); 73 | #endif 74 | return $"[{action.name}]"; 75 | } 76 | 77 | var path = action.bindings[bindingIndex].path; 78 | 79 | InputControlPath.ToHumanReadableString(path, out _, out var controlPath); 80 | 81 | // PrintBinding(action, bindingIndex); 82 | 83 | // var bindingString = action.GetBindingDisplayString(bindingIndex, 84 | // InputBinding.DisplayStringOptions.DontIncludeInteractions); 85 | 86 | foreach (var sprite in _sprites) 87 | { 88 | if (sprite.ControlPath == controlPath) 89 | { 90 | /* 91 | * See if there is a sprite mapping that matches any of the sprite 92 | * sheets defined by the current ControlType 93 | * The ControlType is set by the current used device. 94 | * 95 | * So: 96 | * 97 | * 1. If an Xbox controller is used, that determines the Control Type "Gamepad". 98 | * 2. The Gamepad control type defines a "gamepad" sprite sheet so 99 | * 3. We look for a sprite mapping that uses the "gamepad" sprite sheet 100 | * 101 | * ControlTypes can define MULTIPLE sprite sheets, e.g. "mouse" and "keyboard", 102 | * and we just look for a sprite that matches ANY of the sprite sheets defined 103 | * by the current ControlType 104 | */ 105 | var asset = 106 | _usedControlType.SpriteAssets.FirstOrDefault( 107 | m => m.SpriteCategory == sprite.SpriteCategory); 108 | 109 | if (asset == null) 110 | { 111 | continue; 112 | } 113 | 114 | return string.Format(_spriteFormat, asset.SpriteAsset.name, sprite.SpriteName); 115 | } 116 | } 117 | 118 | #if UNITY_EDITOR 119 | Debug.LogWarning($"[No sprite found for \"{controlPath}\"]", this); 120 | if (_missingControlPaths.Any(mcp => mcp.Matches(controlPath, _usedControlType)) == false) 121 | { 122 | Debug.Log("Adding missing control path entry"); 123 | _missingControlPaths.Add(new MissingControlPath(controlPath, _usedControlType)); 124 | EditorUtility.SetDirty(this); 125 | } 126 | #endif 127 | 128 | return $"[{controlPath}]"; 129 | } 130 | 131 | void PrintBinding(InputAction action, int bindingIndex) 132 | { 133 | var binding = action.bindings[bindingIndex]; 134 | var bindingPath = binding.path; 135 | var displayString = action.GetBindingDisplayString(bindingIndex, out _, out string controlPath); 136 | var humanReadable = 137 | InputControlPath.ToHumanReadableString(bindingPath, out _, out var humanReadablePath); 138 | var composite = binding.isPartOfComposite 139 | ? "(Part of Composite)" 140 | : binding.isComposite 141 | ? "(Composite)" 142 | : ""; 143 | 144 | Debug.Log($"[{action.name}] " + 145 | $"Path: \"{bindingPath}\" " + 146 | $"DisplayString: \"{displayString}\"+\"{controlPath}\" " + 147 | $"HumanReadable: \"{humanReadable}\"+\"{humanReadablePath}\" {composite}"); 148 | } 149 | 150 | public void OnChanged() 151 | { 152 | Changed?.Invoke(); 153 | } 154 | 155 | public void SetControlTypeFromDevicesString(string devicesString) 156 | { 157 | var index = _controlTypes.FindIndex(ct => ct.Devices == devicesString); 158 | 159 | if (index > -1) 160 | { 161 | _usedControlType = _controlTypes[index]; 162 | Changed?.Invoke(); 163 | } 164 | } 165 | 166 | void HandleUsedDeviceChanged(InputDevice inputDevice) 167 | { 168 | _usedControlType = GetControlType(inputDevice); 169 | Changed?.Invoke(); 170 | } 171 | 172 | ControlType GetControlType(InputDevice usedDevice) 173 | { 174 | if (usedDevice != null) 175 | { 176 | foreach (var controlType in _controlTypes) 177 | { 178 | var usedDevicePath = usedDevice.path; 179 | if (controlType.DeviceMatcher.IsMatch(usedDevicePath)) 180 | { 181 | return controlType; 182 | } 183 | } 184 | } 185 | 186 | /* 187 | * If no controltypes match (or UsedDevice is not set if e.g. the game is not playing), 188 | * then use the LAST control type, because the last position in the list is also where you 189 | * would place the default 'fall-through' option that matches any device that 190 | * wasn't matched by the earlier control types. 191 | */ 192 | return _controlTypes.Count > 0 ? _controlTypes[^1] : null; 193 | } 194 | 195 | #if UNITY_EDITOR 196 | 197 | #region EDITOR 198 | 199 | void Reset() 200 | { 201 | _inputActions = AssetDatabase.FindAssets("t:InputActionAsset") 202 | .Select(AssetDatabase.GUIDToAssetPath) 203 | .Select(AssetDatabase.LoadAssetAtPath) 204 | .FirstOrDefault(); 205 | 206 | if (_inputActions == null) 207 | { 208 | Debug.LogError("No Input Actions Asset Found."); 209 | } 210 | 211 | _spriteCategories = new List 212 | { 213 | new("Keyboard"), 214 | new("Mouse"), 215 | new("Gamepad") 216 | }; 217 | 218 | var allSpriteAssets = GetSpriteAssetsInProject(); 219 | 220 | _controlTypes = new List 221 | { 222 | new("(Mouse|Keyboard)", "Keyboard&Mouse", new List 223 | { 224 | new("Keyboard", allSpriteAssets.Length > 0 ? allSpriteAssets[0] : null), 225 | new("Mouse", allSpriteAssets.Length > 0 ? allSpriteAssets[0] : null) 226 | }) 227 | }; 228 | } 229 | 230 | public IEnumerable GetSpriteCategories() 231 | { 232 | return _spriteCategories.Select(s => s.Name); 233 | } 234 | 235 | static TMP_SpriteAsset[] GetSpriteAssetsInProject() 236 | { 237 | if (TMP_Settings.instance != null) 238 | { 239 | var spriteAssetsPath = TMP_Settings.defaultSpriteAssetPath; 240 | var spriteAssets = Resources.LoadAll(spriteAssetsPath); 241 | return spriteAssets; 242 | } 243 | else 244 | { 245 | return Array.Empty(); 246 | } 247 | } 248 | 249 | public IEnumerable GetInputControlSchemes() 250 | { 251 | return _inputActions == null 252 | ? Enumerable.Empty() 253 | : _inputActions.controlSchemes; 254 | } 255 | 256 | public void AddSprite(string controlPath, string spriteName, string controlScheme) 257 | { 258 | Undo.RecordObject(this, "Add Sprite Mapping"); 259 | 260 | var category = FindCategoryFor(spriteName, controlScheme); 261 | 262 | var mapping = _sprites.FirstOrDefault(m => m.ControlPath == controlPath); 263 | if (mapping == null) 264 | { 265 | mapping = new ControlPathToSpriteMapping(controlPath, spriteName, category); 266 | _sprites.Add(mapping); 267 | } 268 | else 269 | { 270 | mapping.SpriteName = spriteName; 271 | } 272 | 273 | LocalizationSettings.SelectedLocale = LocalizationSettings.SelectedLocale; 274 | 275 | OnChanged(); 276 | } 277 | 278 | /// 279 | /// Starting from the ControlType that matches the given controlScheme, 280 | /// finds the first Category that has a sprite with the given name. 281 | /// 282 | /// 283 | /// 284 | /// 285 | string FindCategoryFor(string spriteName, string controlScheme) 286 | { 287 | var allSpriteAssets = GetSpriteAssetsInProject().ToDictionary(asset => asset.name); 288 | 289 | foreach (var controlType in _controlTypes) 290 | { 291 | if (controlType.InputControlScheme != controlScheme) 292 | { 293 | continue; 294 | } 295 | 296 | foreach (var mapping in controlType.SpriteAssets) 297 | { 298 | if (mapping.SpriteAsset != null && 299 | allSpriteAssets.TryGetValue(mapping.SpriteAsset.name, out var asset)) 300 | { 301 | if (asset.spriteCharacterTable.Any(sc => sc.name == spriteName)) 302 | { 303 | return mapping.SpriteCategory; 304 | } 305 | } 306 | } 307 | } 308 | 309 | return null; 310 | } 311 | 312 | public void ClearMissingControlPath(string controlPath, string controlScheme) 313 | { 314 | var index = _missingControlPaths.FindIndex(missing => 315 | missing.ControlPath == controlPath && missing.ControlScheme == controlScheme); 316 | if (index > -1) 317 | { 318 | Undo.RecordObject(this, $"Clear Missing Control Path Entry for {controlPath}"); 319 | _missingControlPaths.RemoveAt(index); 320 | } 321 | } 322 | 323 | #endregion 324 | 325 | #endif 326 | } 327 | } -------------------------------------------------------------------------------- /Runtime/InputHintsConfig.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: aa4dad2aee4f469280c179c2eda6f724 3 | timeCreated: 1660223193 -------------------------------------------------------------------------------- /Runtime/MissingControlPath.cs: -------------------------------------------------------------------------------- 1 | // (C)2024 @noio_games 2 | // Thomas van den Berg 3 | 4 | using System; 5 | using UnityEngine; 6 | 7 | namespace games.noio.InputHints 8 | { 9 | [Serializable] 10 | public class MissingControlPath 11 | { 12 | #region PUBLIC AND SERIALIZED FIELDS 13 | 14 | [SerializeField] string _controlPath; 15 | [SerializeField] string _controlScheme; 16 | 17 | #endregion 18 | 19 | public MissingControlPath(string controlPath, ControlType controlType) 20 | { 21 | _controlPath = controlPath; 22 | _controlScheme = controlType.InputControlScheme; 23 | } 24 | 25 | #region PROPERTIES 26 | 27 | public string ControlPath => _controlPath; 28 | public string ControlScheme => _controlScheme; 29 | 30 | #endregion 31 | 32 | public bool Matches(string controlPath, ControlType controlType) 33 | { 34 | return controlPath == _controlPath && controlType.InputControlScheme == _controlScheme; 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /Runtime/MissingControlPath.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: ef2ae8dced6d479299b7a722c876a7ee 3 | timeCreated: 1707732767 -------------------------------------------------------------------------------- /Runtime/SpriteCategory.cs: -------------------------------------------------------------------------------- 1 | // (C)2024 @noio_games 2 | // Thomas van den Berg 3 | 4 | using System; 5 | using UnityEngine; 6 | 7 | namespace games.noio.InputHints 8 | { 9 | [Serializable] 10 | internal class SpriteCategory 11 | { 12 | #region PUBLIC AND SERIALIZED FIELDS 13 | 14 | [SerializeField] string _name; 15 | 16 | public SpriteCategory(string name) 17 | { 18 | _name = name; 19 | } 20 | 21 | #endregion 22 | 23 | #region PROPERTIES 24 | 25 | public string Name => _name; 26 | 27 | #endregion 28 | } 29 | } -------------------------------------------------------------------------------- /Runtime/SpriteCategory.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: ad6aa146f7c84dbb973dad965188b997 3 | timeCreated: 1707732963 -------------------------------------------------------------------------------- /Runtime/SpriteCategoryToAssetMapping.cs: -------------------------------------------------------------------------------- 1 | // (C)2024 @noio_games 2 | // Thomas van den Berg 3 | 4 | using System; 5 | using TMPro; 6 | using UnityEngine; 7 | 8 | namespace games.noio.InputHints 9 | { 10 | /// 11 | /// Maps a Sprite Category to a specific Sprite Sheet for a particular Control type. 12 | /// For example, the control type that matches "(DualSense)" could 13 | /// map the category "Gamepad" to the SpriteSheet "DualSense-Filled" 14 | /// 15 | [Serializable] 16 | public class SpriteCategoryToAssetMapping 17 | { 18 | #region PUBLIC AND SERIALIZED FIELDS 19 | 20 | [Tooltip("Sprite Category as defined in Input Hints Config")] 21 | [SerializeField] 22 | string _spriteCategory; 23 | 24 | [SerializeField] 25 | TMP_SpriteAsset _spriteAsset; 26 | 27 | public SpriteCategoryToAssetMapping(string spriteCategory, TMP_SpriteAsset spriteAsset) 28 | { 29 | _spriteCategory = spriteCategory; 30 | _spriteAsset = spriteAsset; 31 | } 32 | 33 | #endregion 34 | 35 | #region PROPERTIES 36 | 37 | public string SpriteCategory 38 | { 39 | get => _spriteCategory; 40 | set => _spriteCategory = value; 41 | } 42 | 43 | public TMP_SpriteAsset SpriteAsset => _spriteAsset; 44 | 45 | #endregion 46 | } 47 | } -------------------------------------------------------------------------------- /Runtime/SpriteCategoryToAssetMapping.cs.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 1dda172185bf4f32aca258b9d7f9cbd6 3 | timeCreated: 1707732707 -------------------------------------------------------------------------------- /Runtime/games.noio.InputHints.asmdef: -------------------------------------------------------------------------------- 1 | { 2 | "name": "games.noio.InputHints", 3 | "rootNamespace": "", 4 | "references": [ 5 | "GUID:eec0964c48f6f4e40bc3ec2257ccf8c5", 6 | "GUID:75469ad4d38634e559750d17036d5f7c", 7 | "GUID:6055be8ebefd69e48b49212b09b47b2f" 8 | ], 9 | "includePlatforms": [], 10 | "excludePlatforms": [], 11 | "allowUnsafeCode": false, 12 | "overrideReferences": false, 13 | "precompiledReferences": [], 14 | "autoReferenced": true, 15 | "defineConstraints": [], 16 | "versionDefines": [], 17 | "noEngineReferences": false 18 | } -------------------------------------------------------------------------------- /Runtime/games.noio.InputHints.asmdef.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: f5a062bd525a84181bde8a099d881c55 3 | AssemblyDefinitionImporter: 4 | externalObjects: {} 5 | userData: 6 | assetBundleName: 7 | assetBundleVariant: 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "games.noio.input-hints", 3 | "version": "0.0.3", 4 | "displayName": "Input Hints", 5 | "description": "Use Unity's Localization System to show Input Control Hints", 6 | "unity": "2022.2", 7 | "unityRelease": "0b5", 8 | "dependencies": { 9 | "com.unity.inputsystem": "1.7.0", 10 | "com.unity.localization": "1.4.5" 11 | }, 12 | "keywords": [ 13 | "Build" 14 | ], 15 | "author": { 16 | "name": "Thomas van den Berg", 17 | "email": "thomas@noio.nl", 18 | "url": "https://noio.games" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/noio/games.noio.input-hints.git" 23 | } 24 | } -------------------------------------------------------------------------------- /package.json.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 79ee0eab78ad64155912a3daf72d6f69 3 | TextScriptImporter: 4 | externalObjects: {} 5 | userData: 6 | assetBundleName: 7 | assetBundleVariant: 8 | -------------------------------------------------------------------------------- /todo.txt: -------------------------------------------------------------------------------- 1 | TODO: 2 | 3 | [x] Auto update when control scheme changes (this is like the most important one) 4 | [ ] Deal with (fallback) text, if Icon not found. 5 | [ ] Provide hooks for looping fallback text through Localization (again) 6 | [x] Cache generated variables 7 | [x] Disambiguate between X (keyboard) and X (Gamepad), don't just look up the BindingString directly 8 | [x] Provide "Search" button for sprite mappings that goes through all sprite maps 9 | [ ] Editor-time "Test Device" dropdown 10 | [x] Make UI (IMGUI or UI Toolkit??) 11 | [x] Button to set up default Global Localization Variables + Input Action Variable Group 12 | [x] Auto select SpriteCategory when adding new mapping from missing. 13 | [ ] Composite bindings selection (or show all) 14 | [ ] New new input system: test with LocalizeGameObject component 15 | 16 | 17 | The way to deal with Composite bindings is probably to make them a "Formatter" with : syntax. 18 | 19 | Drilling down the groups with period (.) would be nice but it would require some weird logic 20 | to determine whether to return an IVariable (if there's no composite indicator) or an IVariableGroup 21 | if there IS a composite indicator. That's kind of hard because you don't know beforehand (While parsing the Selector) 22 | whether it's going to have ANOTHER sub selector. I.e. while parsing the "Move" part of {global.input.Move} you do not 23 | know if it is followed by ".Up", so you cannot decide whether to return an IVariable (for "Move") or an IVariableGroup 24 | that allows parsing of ".Up". 25 | 26 | So doing it as a formatter with :Up is easier, then just always return an IVariable 27 | and decide how to treat composites when putting together the sprite in GetSprite. -------------------------------------------------------------------------------- /todo.txt.meta: -------------------------------------------------------------------------------- 1 | fileFormatVersion: 2 2 | guid: 14c0c1f4171d401ba269c3c102864240 3 | timeCreated: 1707737666 --------------------------------------------------------------------------------