359 lines
12 KiB
GDScript
359 lines
12 KiB
GDScript
@tool
|
|
## Helper class for formatting GUIDE input for the UI.
|
|
class_name GUIDEInputFormatter
|
|
|
|
const IconMaker = preload("icon_maker/icon_maker.gd")
|
|
const KeyRenderer = preload("renderers/keyboard/key_renderer.tscn")
|
|
const MouseRenderer = preload("renderers/mouse/mouse_renderer.tscn")
|
|
const TouchRenderer = preload("renderers/touch/touch_renderer.tscn")
|
|
const JoyRenderer = preload("renderers/joy/joy_renderer.tscn")
|
|
const XboxRenderer = preload("renderers/controllers/xbox/xbox_controller_renderer.tscn")
|
|
const PlayStationRenderer = preload("renderers/controllers/playstation/playstation_controller_renderer.tscn")
|
|
const SwitchRenderer = preload("renderers/controllers/switch/switch_controller_renderer.tscn")
|
|
const ActionRenderer = preload("renderers/misc/action_renderer.tscn")
|
|
const FallbackRenderer = preload("renderers/misc/fallback_renderer.tscn")
|
|
const DefaultTextProvider = preload("text_providers/default_text_provider.gd")
|
|
const XboxTextProvider = preload("text_providers/controllers/xbox/xbox_controller_text_provider.gd")
|
|
const PlayStationTextProvider = preload("text_providers/controllers/playstation/playstation_controller_text_provider.gd")
|
|
const SwitchTextProvider = preload("text_providers/controllers/switch/switch_controller_text_provider.gd")
|
|
|
|
# These are shared across all instances
|
|
static var _icon_maker:IconMaker
|
|
static var _icon_renderers:Array[GUIDEIconRenderer] = []
|
|
static var _text_providers:Array[GUIDETextProvider] = []
|
|
static var _is_ready:bool = false
|
|
|
|
## Separator to separate mixed input.
|
|
static var mixed_input_separator:String = ", "
|
|
## Separator to separate chorded input.
|
|
static var chorded_input_separator:String = " + "
|
|
## Separator to separate combo input.
|
|
static var combo_input_separator:String = " > "
|
|
|
|
# These are per-instance
|
|
var _action_resolver:Callable
|
|
var _icon_size:int
|
|
|
|
static func _ensure_readiness():
|
|
if _is_ready:
|
|
return
|
|
|
|
# reconnect to an icon maker that might be there
|
|
var root = Engine.get_main_loop().root
|
|
for child in root.get_children():
|
|
if child is IconMaker:
|
|
_icon_maker = child
|
|
|
|
if _icon_maker == null:
|
|
_icon_maker = preload("icon_maker/icon_maker.tscn").instantiate()
|
|
root.add_child.call_deferred(_icon_maker)
|
|
|
|
add_icon_renderer(KeyRenderer.instantiate())
|
|
add_icon_renderer(MouseRenderer.instantiate())
|
|
add_icon_renderer(TouchRenderer.instantiate())
|
|
add_icon_renderer(ActionRenderer.instantiate())
|
|
add_icon_renderer(JoyRenderer.instantiate())
|
|
add_icon_renderer(XboxRenderer.instantiate())
|
|
add_icon_renderer(PlayStationRenderer.instantiate())
|
|
add_icon_renderer(SwitchRenderer.instantiate())
|
|
add_icon_renderer(FallbackRenderer.instantiate())
|
|
|
|
add_text_provider(DefaultTextProvider.new())
|
|
add_text_provider(XboxTextProvider.new())
|
|
add_text_provider(PlayStationTextProvider.new())
|
|
add_text_provider(SwitchTextProvider.new())
|
|
|
|
_is_ready = true
|
|
|
|
|
|
## This will clean up the rendering infrastructure used for generating
|
|
## icons. Note that in a normal game you will have no need to call this
|
|
## as the infrastructure is needed throughout the run of your game.
|
|
## It might be useful in tests though, to get rid of spurious warnings
|
|
## about orphaned nodes.
|
|
static func cleanup():
|
|
_is_ready = false
|
|
|
|
# free all the nodes to avoid memory leaks
|
|
for renderer in _icon_renderers:
|
|
renderer.queue_free()
|
|
|
|
_icon_renderers.clear()
|
|
|
|
_text_providers.clear()
|
|
if is_instance_valid(_icon_maker):
|
|
_icon_maker.queue_free()
|
|
|
|
|
|
func _init(icon_size:int = 32, resolver:Callable = func(action) -> GUIDEActionMapping: return null ):
|
|
_icon_size = icon_size
|
|
_action_resolver = resolver
|
|
|
|
|
|
## Adds an icon renderer for rendering icons.
|
|
static func add_icon_renderer(renderer:GUIDEIconRenderer) -> void:
|
|
_icon_renderers.append(renderer)
|
|
_icon_renderers.sort_custom(func(r1, r2): return r1.priority < r2.priority)
|
|
|
|
## Removes an icon renderer.
|
|
static func remove_icon_renderer(renderer:GUIDEIconRenderer) -> void:
|
|
_icon_renderers.erase(renderer)
|
|
|
|
## Adds a text provider for rendering text.
|
|
static func add_text_provider(provider:GUIDETextProvider) -> void:
|
|
_text_providers.append(provider)
|
|
_text_providers.sort_custom(func(r1, r2): return r1.priority < r2.priority)
|
|
|
|
|
|
## Removes a text provider
|
|
static func remove_text_provider(provider:GUIDETextProvider) -> void:
|
|
_text_providers.erase(provider)
|
|
|
|
|
|
## Returns an input formatter that can format actions using the currently active inputs.
|
|
static func for_active_contexts(icon_size:int = 32) -> GUIDEInputFormatter:
|
|
var resolver = func(action:GUIDEAction) -> GUIDEActionMapping:
|
|
for mapping in GUIDE._active_action_mappings:
|
|
if mapping.action == action:
|
|
return mapping
|
|
return null
|
|
return GUIDEInputFormatter.new(icon_size, resolver)
|
|
|
|
|
|
## Returns an input formatter that can format actions using the given context.
|
|
static func for_context(context:GUIDEMappingContext, icon_size:int = 32) -> GUIDEInputFormatter:
|
|
var resolver:Callable = func(action:GUIDEAction) -> GUIDEActionMapping:
|
|
for mapping in context.mappings:
|
|
if mapping.action == action:
|
|
return mapping
|
|
return null
|
|
|
|
return GUIDEInputFormatter.new(icon_size, resolver)
|
|
|
|
|
|
## Formats the action input as richtext with icons suitable for a RichTextLabel. This function
|
|
## is async as icons may need to be rendered in the background which can take a few frames, so
|
|
## you will need to await on it.
|
|
func action_as_richtext_async(action:GUIDEAction) -> String:
|
|
return await _materialized_as_richtext_async(_materialize_action_input(action))
|
|
|
|
|
|
## Formats the action input as plain text which can be used in any UI component. This is a bit
|
|
## more light-weight than formatting as icons and returns immediately.
|
|
func action_as_text(action:GUIDEAction) -> String:
|
|
return _materialized_as_text(_materialize_action_input(action))
|
|
|
|
## Formats the input as richtext with icons suitable for a RichTextLabel. This function
|
|
## is async as icons may need to be rendered in the background which can take a few frames, so
|
|
## you will need to await on it.
|
|
func input_as_richtext_async(input:GUIDEInput, materialize_actions:bool = true) -> String:
|
|
return await _materialized_as_richtext_async(_materialize_input(input, materialize_actions))
|
|
|
|
|
|
## Formats the input as plain text which can be used in any UI component. This is a bit
|
|
## more light-weight than formatting as icons and returns immediately.
|
|
func input_as_text(input:GUIDEInput, materialize_actions:bool = true) -> String:
|
|
return _materialized_as_text(_materialize_input(input, materialize_actions))
|
|
|
|
|
|
## Renders materialized input as text.
|
|
func _materialized_as_text(input:MaterializedInput) -> String:
|
|
_ensure_readiness()
|
|
if input is MaterializedSimpleInput:
|
|
var text:String = ""
|
|
for provider in _text_providers:
|
|
if provider.supports(input.input):
|
|
text = provider.get_text(input.input)
|
|
# first provider wins
|
|
break
|
|
if text == "":
|
|
pass
|
|
## push_warning("No formatter found for input ", input)
|
|
return text
|
|
|
|
var separator = _separator_for_input(input)
|
|
if separator == "" or input.parts.is_empty():
|
|
return ""
|
|
|
|
var parts:Array[String] = []
|
|
for part in input.parts:
|
|
parts.append(_materialized_as_text(part))
|
|
|
|
return separator.join(parts)
|
|
|
|
## Renders materialized input as rich text.
|
|
func _materialized_as_richtext_async(input:MaterializedInput) -> String:
|
|
_ensure_readiness()
|
|
if input is MaterializedSimpleInput:
|
|
var icon:Texture2D = null
|
|
for renderer in _icon_renderers:
|
|
if renderer.supports(input.input):
|
|
icon = await _icon_maker.make_icon(input.input, renderer, _icon_size)
|
|
# first renderer wins
|
|
break
|
|
if icon == null:
|
|
push_warning("No renderer found for input ", input)
|
|
return ""
|
|
|
|
return "[img]%s[/img]" % [icon.resource_path]
|
|
|
|
|
|
var separator = _separator_for_input(input)
|
|
if separator == "" or input.parts.is_empty():
|
|
return ""
|
|
|
|
var parts:Array[String] = []
|
|
for part in input.parts:
|
|
parts.append(await _materialized_as_richtext_async(part))
|
|
|
|
return separator.join(parts)
|
|
|
|
|
|
func _separator_for_input(input:MaterializedInput) -> String:
|
|
if input is MaterializedMixedInput:
|
|
return mixed_input_separator
|
|
elif input is MaterializedComboInput:
|
|
return combo_input_separator
|
|
elif input is MaterializedChordedInput:
|
|
return chorded_input_separator
|
|
|
|
push_error("Unknown materialized input type")
|
|
return ""
|
|
|
|
|
|
## Materializes action input.
|
|
func _materialize_action_input(action:GUIDEAction) -> MaterializedInput:
|
|
var result := MaterializedMixedInput.new()
|
|
if action == null:
|
|
push_warning("Trying to get inputs for a null action.")
|
|
return result
|
|
|
|
# get the mapping for this action
|
|
var mapping:GUIDEActionMapping = _action_resolver.call(action)
|
|
|
|
# if we have no mapping, well that's it, return an empty mixed input
|
|
if mapping == null:
|
|
return result
|
|
|
|
# collect input mappings
|
|
for input_mapping in mapping.input_mappings:
|
|
var chorded_actions:Array[MaterializedInput] = []
|
|
var combos:Array[MaterializedInput] = []
|
|
|
|
for trigger in input_mapping.triggers:
|
|
# if we have a combo trigger, materialize its input.
|
|
if trigger is GUIDETriggerCombo:
|
|
var combo := MaterializedComboInput.new()
|
|
for step:GUIDETriggerComboStep in trigger.steps:
|
|
combo.parts.append(_materialize_action_input(step.action))
|
|
combos.append(combo)
|
|
|
|
# if we have a chorded action, materialize its input
|
|
if trigger is GUIDETriggerChordedAction:
|
|
chorded_actions.append(_materialize_action_input(trigger.action))
|
|
|
|
if not chorded_actions.is_empty():
|
|
# if we have chorded action then the whole mapping is chorded.
|
|
var chord := MaterializedChordedInput.new()
|
|
for chorded_action in chorded_actions:
|
|
chord.parts.append(chorded_action)
|
|
for combo in combos:
|
|
chord.parts.append(combo)
|
|
if combos.is_empty():
|
|
if input_mapping.input != null:
|
|
chord.parts.append(_materialize_input(input_mapping.input))
|
|
result.parts.append(chord)
|
|
else:
|
|
for combo in combos:
|
|
result.parts.append(combo)
|
|
if combos.is_empty():
|
|
if input_mapping.input != null:
|
|
result.parts.append(_materialize_input(input_mapping.input))
|
|
return result
|
|
|
|
## Materializes direct input.
|
|
func _materialize_input(input:GUIDEInput, materialize_actions:bool = true) -> MaterializedInput:
|
|
if input == null:
|
|
push_warning("Trying to materialize a null input.")
|
|
return MaterializedMixedInput.new()
|
|
|
|
# if its an action input, get its parts
|
|
if input is GUIDEInputAction:
|
|
if materialize_actions:
|
|
return _materialize_action_input(input.action)
|
|
else:
|
|
return MaterializedSimpleInput.new(input)
|
|
|
|
# if its a key input, split out the modifiers
|
|
if input is GUIDEInputKey:
|
|
var chord := MaterializedChordedInput.new()
|
|
if input.control:
|
|
var ctrl = GUIDEInputKey.new()
|
|
ctrl.key = KEY_CTRL
|
|
chord.parts.append(MaterializedSimpleInput.new(ctrl))
|
|
if input.alt:
|
|
var alt = GUIDEInputKey.new()
|
|
alt.key = KEY_ALT
|
|
chord.parts.append(MaterializedSimpleInput.new(alt))
|
|
if input.shift:
|
|
var shift = GUIDEInputKey.new()
|
|
shift.key = KEY_SHIFT
|
|
chord.parts.append(MaterializedSimpleInput.new(shift))
|
|
if input.meta:
|
|
var meta = GUIDEInputKey.new()
|
|
meta.key = KEY_META
|
|
chord.parts.append(MaterializedSimpleInput.new(meta))
|
|
|
|
# got no modifiers?
|
|
if chord.parts.is_empty():
|
|
return MaterializedSimpleInput.new(input)
|
|
|
|
chord.parts.append(MaterializedSimpleInput.new(input))
|
|
return chord
|
|
|
|
# everything else is just a simple input
|
|
return MaterializedSimpleInput.new(input)
|
|
|
|
class MaterializedInput:
|
|
pass
|
|
|
|
class MaterializedSimpleInput:
|
|
extends MaterializedInput
|
|
var input:GUIDEInput
|
|
|
|
func _init(input:GUIDEInput):
|
|
self.input = input
|
|
|
|
class MaterializedMixedInput:
|
|
extends MaterializedInput
|
|
var parts:Array[MaterializedInput] = []
|
|
|
|
class MaterializedChordedInput:
|
|
extends MaterializedInput
|
|
var parts:Array[MaterializedInput] = []
|
|
|
|
class MaterializedComboInput:
|
|
extends MaterializedInput
|
|
var parts:Array[MaterializedInput] = []
|
|
|
|
|
|
## Returns the name of the associated joystick/pad of the given input.
|
|
## If the input is no joy input or the device name cannot be determined
|
|
## returns an empty string.
|
|
static func _joy_name_for_input(input:GUIDEInput) -> String:
|
|
if not input is GUIDEInputJoyBase:
|
|
return ""
|
|
|
|
var joypads:Array[int] = Input.get_connected_joypads()
|
|
var joy_index = input.joy_index
|
|
if joy_index < 0:
|
|
# pick the first one
|
|
joy_index = 0
|
|
|
|
# We don't have such a controller, so bail out.
|
|
if joypads.size() <= joy_index:
|
|
return ""
|
|
|
|
var id = joypads[joy_index]
|
|
return Input.get_joy_name(id)
|