@tool @icon("res://addons/controller_icons/objects/controller_texture_icon.svg") extends Texture2D class_name ControllerIconTexture ## [Texture2D] proxy for displaying controller icons ## ## A 2D texture representing a controller icon. The underlying system provides ## a [Texture2D] that may react to changes in the current input method, and also detect the user's controller type. ## Specify the [member path] property to setup the desired icon and behavior.[br] ## [br] ## For a more technical overview, this resource functions as a proxy for any ## node that accepts a [Texture2D], redefining draw commands to use an ## underlying plain [Texture2D], which may be swapped by the remapping system.[br] ## [br] ## This resource works out-of-the box with many default nodes, such as [Sprite2D], ## [Sprite3D], [TextureRect], [RichTextLabel], and others. If you are ## integrating this resource on a custom node, you will need to connect to the ## [signal Resource.changed] signal to properly handle changes to the underlying ## texture. You might also need to force a redraw with methods such as ## [method CanvasItem.queue_redraw]. ## ## @tutorial(Online documentation): https://github.com/rsubtil/controller_icons/blob/master/DOCS.md ## A path describing the desired icon. This is a generic path that can be one ## of three different types: ## [br][br] ## [b]- Input Action[/b]: Specify the exact name of an existing input action. The ## icon will be swapped automatically depending on whether the keyboard/mouse or the ## controller is being used. When using a controller, it also changes according to ## the controller type.[br][br] ## [i]This is the recommended approach, as it will handle all input methods ## automatically, and supports any input remapping done at runtime[/i]. ## [codeblock] ## # "Enter" on keyboard, "Cross" on Sony, ## # "A" on Xbox, "B" on Nintendo ## path = "ui_accept" ## [/codeblock] ## [b]- Joypad Path[/b]: Specify a generic joypad path resembling the layout of a ## Xbox 360 controller, starting with the [code]joypad/[/code] prefix. The icon will only ## display controller icons, but it will still change according to the controller type. ## [codeblock] ## # "Square" on Sony, "X" on Xbox, "Y" on Nintendo ## path = "joypad/x" ## [/codeblock] ## [b]- Specific Path[/b]: Specify a direct asset path from the addon assets. ## With this path type, there is no dynamic remapping, and the icon will always ## remain the same. The path to use is the path to an icon file, minus the base ## path and extension. ## [codeblock] ## # res://addons/controller_icons/assets/steam/gyro.png ## path = "steam/gyro" ## [/codeblock] @export var path: String = "": set(_path): path = _path _load_texture_path() enum ShowMode { ANY, ## Icon will be display on any input method. KEYBOARD_MOUSE, ## Icon will be display only when the keyboard/mouse is being used. CONTROLLER ## Icon will be display only when a controller is being used. } ## Show the icon only if a specific input method is being used. When hidden, ## the icon will not occupy have any space (no width and height). @export var show_mode: ShowMode = ShowMode.ANY: set(_show_mode): show_mode = _show_mode _load_texture_path() ## Forces the icon to show a specific controller style, regardless of the ## currently used controller type. ##[br][br] ## This will override force_device if set to a value other than NONE. ##[br][br] ## This is only relevant for paths using input actions, and has no effect on ## other scenarios. @export var force_controller_icon_style: ControllerSettings.Devices = ControllerSettings.Devices.NONE: set(_force_controller_icon_style): force_controller_icon_style = _force_controller_icon_style _load_texture_path() enum ForceType { NONE, ## Icon will swap according to the used input method. KEYBOARD_MOUSE, ## Icon will always show the keyboard/mouse action. CONTROLLER, ## Icon will always show the controller action. } ## Forces the icon to show either the keyboard/mouse or controller icon, ## regardless of the currently used input method. ##[br][br] ## This is only relevant for paths using input actions, and has no effect on ## other scenarios. @export var force_type: ForceType = ForceType.NONE: set(_force_type): force_type = _force_type _load_texture_path() enum ForceDevice { DEVICE_0, DEVICE_1, DEVICE_2, DEVICE_3, DEVICE_4, DEVICE_5, DEVICE_6, DEVICE_7, DEVICE_8, DEVICE_9, DEVICE_10, DEVICE_11, DEVICE_12, DEVICE_13, DEVICE_14, DEVICE_15, ANY # No device will be forced } ## Forces the icon to use the textures for the device connected at the specified index. ## For example, if a PlayStation 5 controller is connected at device_index 0, ## the icon will always show PlayStation 5 textures. @export var force_device: ForceDevice = ForceDevice.ANY: set(_force_device): force_device = _force_device _load_texture_path() @export_subgroup("Text Rendering") ## Custom LabelSettings. If set, overrides the addon's global label settings. @export var custom_label_settings: LabelSettings: set(_custom_label_settings): custom_label_settings = _custom_label_settings _load_texture_path() # Call _textures setter, which handles signal connections for label settings _textures = _textures ## Returns a text representation of the displayed icon, useful for TTS ## (text-to-speech) scenarios. ## [br][br] ## This takes into consideration the currently displayed icon, and will thus be ## different if the icon is from keyboard/mouse or controller. It also takes ## into consideration the controller type, and will thus use native button ## names (e.g. [code]A[/code] for Xbox, [code]Cross[/code] for PlayStation, etc). func get_tts_string() -> String: if force_type: return ControllerIcons.parse_path_to_tts(path, force_type - 1) else: return ControllerIcons.parse_path_to_tts(path) func _can_be_shown(): match show_mode: 1: return ControllerIcons._last_input_type == ControllerIcons.InputType.KEYBOARD_MOUSE 2: return ControllerIcons._last_input_type == ControllerIcons.InputType.CONTROLLER 0, _: return true var _textures: Array[Texture2D]: set(__textures): # UPGRADE: In Godot 4.2, for-loop variables can be # statically typed: # for tex:Texture in __textures: for tex in __textures: if tex and tex.is_connected("changed", _reload_resource): tex.disconnect("changed", _reload_resource) if _label_settings and _label_settings.is_connected("changed", _on_label_settings_changed): _label_settings.disconnect("changed", _on_label_settings_changed) _textures = __textures _label_settings = null if _textures and _textures.size() > 1: _label_settings = custom_label_settings if not _label_settings: _label_settings = ControllerIcons._settings.custom_label_settings if not _label_settings: _label_settings = LabelSettings.new() _label_settings.connect("changed", _on_label_settings_changed) _font = ThemeDB.fallback_font if not _label_settings.font else _label_settings.font _on_label_settings_changed() # UPGRADE: In Godot 4.2, for-loop variables can be # statically typed: # for tex:Texture in __textures: for tex in __textures: if tex: tex.connect("changed", _reload_resource) var _font: Font var _label_settings: LabelSettings var _text_size: Vector2 func _on_label_settings_changed(): _font = ThemeDB.fallback_font if not _label_settings.font else _label_settings.font _text_size = _font.get_string_size("+", HORIZONTAL_ALIGNMENT_LEFT, -1, _label_settings.font_size) _reload_resource() func _reload_resource(): _dirty = true emit_changed() func _load_texture_path_impl(): var textures: Array[Texture2D] = [] if ControllerIcons.is_node_ready() and _can_be_shown(): var input_type = ControllerIcons._last_input_type if force_type == ForceType.NONE else force_type - 1 if ControllerIcons.get_path_type(path) == ControllerIcons.PathType.INPUT_ACTION: var event := ControllerIcons.get_matching_event(path, input_type) textures.append_array(ControllerIcons.parse_event_modifiers(event)) var target_device = force_device if force_device != ForceDevice.ANY else ControllerIcons._last_controller var tex := ControllerIcons.parse_path(path, input_type, target_device, force_controller_icon_style) if tex: textures.append(tex) _textures = textures _reload_resource() func _load_texture_path(): # Ensure loading only occurs on the main thread if OS.get_thread_caller_id() != OS.get_main_thread_id(): # In Godot 4.3, call_deferred no longer makes this function # execute on the main thread due to changes in resource loading. # To ensure this, we instead rely on ControllerIcons for this ControllerIcons._defer_texture_load(_load_texture_path_impl) else: _load_texture_path_impl() func _init(): ControllerIcons.input_type_changed.connect(_on_input_type_changed) func _on_input_type_changed(input_type: int, controller: int): _load_texture_path() #region "Draw functions" const _NULL_SIZE := 2 func _get_width() -> int: if _can_be_shown(): var ret := _textures.reduce(func(accum: int, texture: Texture2D): if texture: return accum + texture.get_width() return accum , 0) if _label_settings: ret += max(0, _textures.size() - 1) * _text_size.x # If ret is 0, return a size of 2 to prevent triggering engine checks # for null sizes. The correct size will be set at a later frame. return ret if ret > 0 else _NULL_SIZE return _NULL_SIZE func _get_height() -> int: if _can_be_shown(): var ret := _textures.reduce(func(accum: int, texture: Texture2D): if texture: return max(accum, texture.get_height()) return accum , 0) if _label_settings and _textures.size() > 1: ret = max(ret, _text_size.y) # If ret is 0, return a size of 2 to prevent triggering engine checks # for null sizes. The correct size will be set at a later frame. return ret if ret > 0 else _NULL_SIZE return _NULL_SIZE func _has_alpha() -> bool: return _textures.any(func(texture: Texture2D): return texture.has_alpha() ) func _is_pixel_opaque(x, y) -> bool: # TODO: Not exposed to GDScript; however, since this seems to be used for editor stuff, it's # seemingly fine to just report all pixels as opaque. Otherwise, mouse picking for Sprite2D # stops working. return true func _draw(to_canvas_item: RID, pos: Vector2, modulate: Color, transpose: bool): var position := pos for i in range(_textures.size()): var tex: Texture2D = _textures[i] if !tex: continue if i != 0: # Draw text char '+' var font_position := Vector2( position.x, position.y + (get_height() - _text_size.y) / 2.0 ) _draw_text(to_canvas_item, font_position, "+") position.x += _text_size.x tex.draw(to_canvas_item, position, modulate, transpose) position.x += tex.get_width() func _draw_rect(to_canvas_item: RID, rect: Rect2, tile: bool, modulate: Color, transpose: bool): var position := rect.position var width_ratio := rect.size.x / _get_width() var height_ratio := rect.size.y / _get_height() for i in range(_textures.size()): var tex: Texture2D = _textures[i] if !tex: continue if i != 0: # Draw text char '+' var font_position := Vector2( position.x + (_text_size.x * width_ratio) / 2 - (_text_size.x / 2), position.y + (rect.size.y - _text_size.y) / 2.0 ) _draw_text(to_canvas_item, font_position, "+") position.x += _text_size.x * width_ratio var size := tex.get_size() * Vector2(width_ratio, height_ratio) tex.draw_rect(to_canvas_item, Rect2(position, size), tile, modulate, transpose) position.x += size.x func _draw_rect_region(to_canvas_item: RID, rect: Rect2, src_rect: Rect2, modulate: Color, transpose: bool, clip_uv: bool): var position := rect.position var width_ratio := rect.size.x / _get_width() var height_ratio := rect.size.y / _get_height() for i in range(_textures.size()): var tex: Texture2D = _textures[i] if !tex: continue if i != 0: # Draw text char '+' var font_position := Vector2( position.x + (_text_size.x * width_ratio) / 2 - (_text_size.x / 2), position.y + (rect.size.y - _text_size.y) / 2.0 ) _draw_text(to_canvas_item, font_position, "+") position.x += _text_size.x * width_ratio var size := tex.get_size() * Vector2(width_ratio, height_ratio) var src_rect_ratio := Vector2( tex.get_width() / float(_get_width()), tex.get_height() / float(_get_height()) ) var tex_src_rect := Rect2( src_rect.position * src_rect_ratio, src_rect.size * src_rect_ratio ) tex.draw_rect_region(to_canvas_item, Rect2(position, size), tex_src_rect, modulate, transpose, clip_uv) position.x += size.x func _draw_text(to_canvas_item: RID, font_position: Vector2, text: String): font_position.y += _font.get_ascent(_label_settings.font_size) if _label_settings.shadow_color.a > 0: _font.draw_string(to_canvas_item, font_position + _label_settings.shadow_offset, text, HORIZONTAL_ALIGNMENT_LEFT, -1, _label_settings.font_size, _label_settings.shadow_color) if _label_settings.shadow_size > 0: _font.draw_string_outline(to_canvas_item, font_position + _label_settings.shadow_offset, text, HORIZONTAL_ALIGNMENT_LEFT, -1, _label_settings.font_size, _label_settings.shadow_size, _label_settings.shadow_color) if _label_settings.outline_color.a > 0 and _label_settings.outline_size > 0: _font.draw_string_outline(to_canvas_item, font_position, text, HORIZONTAL_ALIGNMENT_LEFT, -1, _label_settings.font_size, _label_settings.outline_size, _label_settings.outline_color) _font.draw_string(to_canvas_item, font_position, text, HORIZONTAL_ALIGNMENT_CENTER, -1, _label_settings.font_size, _label_settings.font_color) var _helper_viewport: Viewport var _is_stitching_texture: bool = false func _stitch_texture(): if _textures.is_empty(): return _is_stitching_texture = true var font_image: Image if _textures.size() > 1: # Generate a viewport to draw the text _helper_viewport = SubViewport.new() # FIXME: We need a 3px margin for some reason _helper_viewport.size = _text_size + Vector2(3, 0) _helper_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE _helper_viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ONCE _helper_viewport.transparent_bg = true var label := Label.new() label.label_settings = _label_settings label.text = "+" label.position = Vector2.ZERO _helper_viewport.add_child(label) ControllerIcons.add_child(_helper_viewport) await RenderingServer.frame_post_draw font_image = _helper_viewport.get_texture().get_image() ControllerIcons.remove_child(_helper_viewport) _helper_viewport.free() var position := Vector2i(0, 0) var img: Image for i in range(_textures.size()): if !_textures[i]: continue if i != 0: # Draw text char '+' var region := font_image.get_used_rect() var font_position := Vector2i( position.x, position.y + (get_height() - region.size.y) / 2 ) img.blit_rect(font_image, region, font_position) position.x += ceili(region.size.x) var texture_raw := _textures[i].get_image() texture_raw.decompress() if not img: img = Image.create(_get_width(), _get_height(), true, texture_raw.get_format()) img.blit_rect(texture_raw, Rect2i(0, 0, texture_raw.get_width(), texture_raw.get_height()), position) position.x += texture_raw.get_width() _is_stitching_texture = false _dirty = false _texture_3d = ImageTexture.create_from_image(img) emit_changed() # This is necessary for 3D sprites, as the texture is assigned to a material, and not drawn directly. # For multi prompts, we need to generate a texture var _dirty := true var _texture_3d: Texture func _get_rid(): if _dirty: if not _is_stitching_texture: # FIXME: Function may await, but because this is an internal engine call, we can't do anything about it. # This results in a one-frame white texture being displayed, which is not ideal. Investigate later. _stitch_texture() if _is_stitching_texture: return 0 else: return 0 return _texture_3d.get_rid() if not _textures.is_empty() else 0 #endregion