feat: overhaul controller menu accessibility
This commit is contained in:
parent
a7837c3711
commit
4f9c85989b
46 changed files with 4091 additions and 396 deletions
448
settings/keyboard_mouse_mapping_manager.gd
Normal file
448
settings/keyboard_mouse_mapping_manager.gd
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
class_name KeyboardMouseMappingManager
|
||||
extends Node
|
||||
|
||||
signal mapping_changed
|
||||
|
||||
const FORMAT_VERSION: int = 1
|
||||
const MAPPING_PATH: String = "user://keyboard_mouse_bindings.json"
|
||||
const MAPPING_TEMP_PATH: String = "user://keyboard_mouse_bindings.json.tmp"
|
||||
const MAPPING_BACKUP_PATH: String = (
|
||||
"user://keyboard_mouse_bindings.json.backup"
|
||||
)
|
||||
const MAX_MAPPING_BYTES: int = 256 * 1024
|
||||
|
||||
const ROLE_MOVE_FORWARD: StringName = &"move_forward"
|
||||
const ROLE_MOVE_BACKWARD: StringName = &"move_backward"
|
||||
const ROLE_MOVE_LEFT: StringName = &"move_left"
|
||||
const ROLE_MOVE_RIGHT: StringName = &"move_right"
|
||||
const ROLE_SPRINT: StringName = &"sprint"
|
||||
const ROLE_JUMP: StringName = &"jump"
|
||||
const ROLE_SNEAK: StringName = &"sneak"
|
||||
const ROLE_SLOW_WALK: StringName = &"slow_walk"
|
||||
const ROLE_INTERACT: StringName = &"interact"
|
||||
const ROLE_PRIMARY_ACTION: StringName = &"fish_primary"
|
||||
const ROLE_CAMERA_DRAG: StringName = &"camera_drag"
|
||||
const ROLE_CAMERA_ZOOM_IN: StringName = &"camera_zoom_in"
|
||||
const ROLE_CAMERA_ZOOM_OUT: StringName = &"camera_zoom_out"
|
||||
const ROLE_PLAYER_MENU: StringName = &"open_backpack"
|
||||
const ROLE_TACKLE_BOX: StringName = &"open_tacklebox"
|
||||
const ROLE_PROP_BOOK: StringName = &"open_props"
|
||||
const ROLE_EMOTE_WHEEL: StringName = &"open_emotes"
|
||||
const ROLE_CHARACTER_CALL: StringName = &"character_call"
|
||||
const ROLE_CHAT: StringName = &"open_chat"
|
||||
const ROLE_FREE_CAMERA: StringName = &"toggle_free_camera"
|
||||
const ROLE_HIDE_HUD: StringName = &"toggle_hud"
|
||||
const ROLE_MENU_ACCEPT: StringName = &"ui_accept"
|
||||
const ROLE_MENU_BACK: StringName = &"ui_cancel"
|
||||
|
||||
const ROLE_ORDER: Array[StringName] = [
|
||||
ROLE_MOVE_FORWARD,
|
||||
ROLE_MOVE_BACKWARD,
|
||||
ROLE_MOVE_LEFT,
|
||||
ROLE_MOVE_RIGHT,
|
||||
ROLE_SPRINT,
|
||||
ROLE_JUMP,
|
||||
ROLE_SNEAK,
|
||||
ROLE_SLOW_WALK,
|
||||
ROLE_INTERACT,
|
||||
ROLE_PRIMARY_ACTION,
|
||||
ROLE_CAMERA_DRAG,
|
||||
ROLE_CAMERA_ZOOM_IN,
|
||||
ROLE_CAMERA_ZOOM_OUT,
|
||||
ROLE_PLAYER_MENU,
|
||||
ROLE_TACKLE_BOX,
|
||||
ROLE_PROP_BOOK,
|
||||
ROLE_EMOTE_WHEEL,
|
||||
ROLE_CHARACTER_CALL,
|
||||
ROLE_CHAT,
|
||||
ROLE_FREE_CAMERA,
|
||||
ROLE_HIDE_HUD,
|
||||
&"hotbar_1",
|
||||
&"hotbar_2",
|
||||
&"hotbar_3",
|
||||
&"hotbar_4",
|
||||
&"hotbar_5",
|
||||
&"hotbar_6",
|
||||
&"hotbar_7",
|
||||
&"hotbar_8",
|
||||
&"hotbar_9",
|
||||
ROLE_MENU_ACCEPT,
|
||||
ROLE_MENU_BACK,
|
||||
]
|
||||
|
||||
const ROLE_LABELS: Dictionary = {
|
||||
ROLE_MOVE_FORWARD: "move forward",
|
||||
ROLE_MOVE_BACKWARD: "move backward",
|
||||
ROLE_MOVE_LEFT: "move left",
|
||||
ROLE_MOVE_RIGHT: "move right",
|
||||
ROLE_SPRINT: "sprint",
|
||||
ROLE_JUMP: "jump",
|
||||
ROLE_SNEAK: "sneak",
|
||||
ROLE_SLOW_WALK: "slow walk",
|
||||
ROLE_INTERACT: "interact",
|
||||
ROLE_PRIMARY_ACTION: "primary action",
|
||||
ROLE_CAMERA_DRAG: "rotate camera",
|
||||
ROLE_CAMERA_ZOOM_IN: "camera zoom in",
|
||||
ROLE_CAMERA_ZOOM_OUT: "camera zoom out",
|
||||
ROLE_PLAYER_MENU: "player menu",
|
||||
ROLE_TACKLE_BOX: "tackle box",
|
||||
ROLE_PROP_BOOK: "prop book",
|
||||
ROLE_EMOTE_WHEEL: "emote wheel",
|
||||
ROLE_CHARACTER_CALL: "character call",
|
||||
ROLE_CHAT: "chat",
|
||||
ROLE_FREE_CAMERA: "free camera",
|
||||
ROLE_HIDE_HUD: "hide hud",
|
||||
&"hotbar_1": "hotbar 1",
|
||||
&"hotbar_2": "hotbar 2",
|
||||
&"hotbar_3": "hotbar 3",
|
||||
&"hotbar_4": "hotbar 4",
|
||||
&"hotbar_5": "hotbar 5",
|
||||
&"hotbar_6": "hotbar 6",
|
||||
&"hotbar_7": "hotbar 7",
|
||||
&"hotbar_8": "hotbar 8",
|
||||
&"hotbar_9": "hotbar 9",
|
||||
ROLE_MENU_ACCEPT: "menu accept",
|
||||
ROLE_MENU_BACK: "menu back / pause",
|
||||
}
|
||||
|
||||
var _default_events: Dictionary = {}
|
||||
var _default_bindings: Dictionary = {}
|
||||
var _bindings: Dictionary = {}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_capture_project_defaults()
|
||||
load_mapping()
|
||||
_apply_active_mapping()
|
||||
|
||||
|
||||
func load_mapping() -> bool:
|
||||
_recover_interrupted_write()
|
||||
if not FileAccess.file_exists(MAPPING_PATH):
|
||||
_bindings = {}
|
||||
return true
|
||||
var file := FileAccess.open(MAPPING_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
_bindings = {}
|
||||
return false
|
||||
if file.get_length() > MAX_MAPPING_BYTES:
|
||||
file.close()
|
||||
push_warning("Keyboard bindings are too large; using defaults.")
|
||||
_bindings = {}
|
||||
return false
|
||||
var json := JSON.new()
|
||||
var error: Error = json.parse(file.get_as_text())
|
||||
file.close()
|
||||
if error != OK or typeof(json.data) != TYPE_DICTIONARY:
|
||||
push_warning("Keyboard bindings are malformed; using defaults.")
|
||||
_bindings = {}
|
||||
return false
|
||||
var data := json.data as Dictionary
|
||||
if int(data.get("format_version", -1)) != FORMAT_VERSION:
|
||||
push_warning("Keyboard binding version is unsupported; using defaults.")
|
||||
_bindings = {}
|
||||
return false
|
||||
var raw_bindings: Variant = data.get("bindings", {})
|
||||
if (
|
||||
typeof(raw_bindings) != TYPE_DICTIONARY
|
||||
or not _validate_complete_bindings(raw_bindings as Dictionary)
|
||||
):
|
||||
push_warning("Keyboard bindings are incomplete; using defaults.")
|
||||
_bindings = {}
|
||||
return false
|
||||
_bindings = (raw_bindings as Dictionary).duplicate(true)
|
||||
return true
|
||||
|
||||
|
||||
func has_custom_mapping() -> bool:
|
||||
return not _bindings.is_empty()
|
||||
|
||||
|
||||
func get_active_bindings() -> Dictionary:
|
||||
if has_custom_mapping():
|
||||
return _bindings.duplicate(true)
|
||||
return _default_bindings.duplicate(true)
|
||||
|
||||
|
||||
func get_role_label(role: StringName) -> String:
|
||||
return str(ROLE_LABELS.get(role, str(role).replace("_", " ")))
|
||||
|
||||
|
||||
func binding_from_event(event: InputEvent) -> Dictionary:
|
||||
var key_event := event as InputEventKey
|
||||
if key_event != null:
|
||||
if not key_event.pressed or key_event.echo:
|
||||
return {}
|
||||
if key_event.keycode in [KEY_SHIFT, KEY_CTRL, KEY_ALT, KEY_META]:
|
||||
return {}
|
||||
if key_event.keycode == KEY_NONE and (
|
||||
key_event.physical_keycode == KEY_NONE
|
||||
):
|
||||
return {}
|
||||
return {
|
||||
"kind": "key",
|
||||
"keycode": int(key_event.keycode),
|
||||
"physical_keycode": int(key_event.physical_keycode),
|
||||
"alt": key_event.alt_pressed,
|
||||
"shift": key_event.shift_pressed,
|
||||
"ctrl": key_event.ctrl_pressed,
|
||||
"meta": key_event.meta_pressed,
|
||||
"location": int(key_event.location),
|
||||
}
|
||||
var mouse_event := event as InputEventMouseButton
|
||||
if mouse_event == null or not mouse_event.pressed:
|
||||
return {}
|
||||
return {
|
||||
"kind": "mouse_button",
|
||||
"button": int(mouse_event.button_index),
|
||||
}
|
||||
|
||||
|
||||
func validate_binding(binding: Dictionary) -> bool:
|
||||
var kind: String = str(binding.get("kind", ""))
|
||||
if kind == "mouse_button":
|
||||
var button: int = int(binding.get("button", 0))
|
||||
return (
|
||||
button >= int(MOUSE_BUTTON_LEFT)
|
||||
and button <= int(MOUSE_BUTTON_XBUTTON2)
|
||||
)
|
||||
if kind != "key":
|
||||
return false
|
||||
var keycode: int = int(binding.get("keycode", int(KEY_NONE)))
|
||||
var physical_keycode: int = int(
|
||||
binding.get("physical_keycode", int(KEY_NONE))
|
||||
)
|
||||
if keycode == int(KEY_NONE) and physical_keycode == int(KEY_NONE):
|
||||
return false
|
||||
for modifier: String in ["alt", "shift", "ctrl", "meta"]:
|
||||
if typeof(binding.get(modifier, false)) != TYPE_BOOL:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func set_binding(role: StringName, binding: Dictionary) -> bool:
|
||||
if role not in ROLE_ORDER or not validate_binding(binding):
|
||||
return false
|
||||
var updated: Dictionary = get_active_bindings()
|
||||
updated[str(role)] = binding.duplicate(true)
|
||||
return replace_active_bindings(updated)
|
||||
|
||||
|
||||
func replace_active_bindings(bindings: Dictionary) -> bool:
|
||||
if not _validate_complete_bindings(bindings):
|
||||
return false
|
||||
var previous: Dictionary = _bindings.duplicate(true)
|
||||
_bindings = bindings.duplicate(true)
|
||||
if not _save_mapping():
|
||||
_bindings = previous
|
||||
return false
|
||||
_apply_active_mapping()
|
||||
mapping_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func reset_mapping() -> bool:
|
||||
var previous: Dictionary = _bindings.duplicate(true)
|
||||
_bindings = {}
|
||||
if not _remove_if_present(MAPPING_PATH):
|
||||
_bindings = previous
|
||||
return false
|
||||
_remove_if_present(MAPPING_TEMP_PATH)
|
||||
_remove_if_present(MAPPING_BACKUP_PATH)
|
||||
_restore_project_defaults()
|
||||
mapping_changed.emit()
|
||||
return true
|
||||
|
||||
|
||||
func binding_label(binding: Dictionary) -> String:
|
||||
if str(binding.get("kind", "")) == "mouse_button":
|
||||
return _mouse_button_label(int(binding.get("button", 0)))
|
||||
if str(binding.get("kind", "")) != "key":
|
||||
return "unmapped"
|
||||
var event := _event_from_binding(binding) as InputEventKey
|
||||
if event == null:
|
||||
return "unmapped"
|
||||
var label: String = event.as_text_physical_keycode()
|
||||
if label.is_empty():
|
||||
label = event.as_text_keycode()
|
||||
return label.to_lower() if not label.is_empty() else "unknown key"
|
||||
|
||||
|
||||
static func bindings_conflict(first: Dictionary, second: Dictionary) -> bool:
|
||||
if str(first.get("kind", "")) != str(second.get("kind", "")):
|
||||
return false
|
||||
if str(first.get("kind", "")) == "mouse_button":
|
||||
return int(first.get("button", 0)) == int(second.get("button", 0))
|
||||
if str(first.get("kind", "")) != "key":
|
||||
return false
|
||||
return (
|
||||
int(first.get("keycode", 0)) == int(second.get("keycode", 0))
|
||||
and int(first.get("physical_keycode", 0))
|
||||
== int(second.get("physical_keycode", 0))
|
||||
and bool(first.get("alt", false)) == bool(second.get("alt", false))
|
||||
and bool(first.get("shift", false)) == bool(second.get("shift", false))
|
||||
and bool(first.get("ctrl", false)) == bool(second.get("ctrl", false))
|
||||
and bool(first.get("meta", false)) == bool(second.get("meta", false))
|
||||
)
|
||||
|
||||
|
||||
func _capture_project_defaults() -> void:
|
||||
_default_events.clear()
|
||||
_default_bindings.clear()
|
||||
for role: StringName in ROLE_ORDER:
|
||||
var action: StringName = role
|
||||
var events: Array[InputEvent] = []
|
||||
if InputMap.has_action(action):
|
||||
for event: InputEvent in InputMap.action_get_events(action):
|
||||
if not _is_keyboard_mouse_event(event):
|
||||
continue
|
||||
events.append(event.duplicate())
|
||||
if not _default_bindings.has(str(role)):
|
||||
var binding: Dictionary = binding_from_event(
|
||||
_pressed_copy(event)
|
||||
)
|
||||
if not binding.is_empty():
|
||||
_default_bindings[str(role)] = binding
|
||||
_default_events[action] = events
|
||||
|
||||
|
||||
func _validate_complete_bindings(bindings: Dictionary) -> bool:
|
||||
for role: StringName in ROLE_ORDER:
|
||||
var raw_binding: Variant = bindings.get(str(role), {})
|
||||
if (
|
||||
typeof(raw_binding) != TYPE_DICTIONARY
|
||||
or not validate_binding(raw_binding as Dictionary)
|
||||
):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _apply_active_mapping() -> void:
|
||||
if not has_custom_mapping():
|
||||
_restore_project_defaults()
|
||||
return
|
||||
_remove_managed_keyboard_mouse_events()
|
||||
for role: StringName in ROLE_ORDER:
|
||||
var binding: Variant = _bindings.get(str(role), {})
|
||||
if typeof(binding) != TYPE_DICTIONARY:
|
||||
continue
|
||||
var event: InputEvent = _event_from_binding(binding as Dictionary)
|
||||
if event != null:
|
||||
InputMap.action_add_event(role, event)
|
||||
|
||||
|
||||
func _restore_project_defaults() -> void:
|
||||
_remove_managed_keyboard_mouse_events()
|
||||
for action_value: Variant in _default_events:
|
||||
var action: StringName = StringName(action_value)
|
||||
for event: InputEvent in _default_events[action]:
|
||||
InputMap.action_add_event(action, event.duplicate())
|
||||
|
||||
|
||||
func _remove_managed_keyboard_mouse_events() -> void:
|
||||
for action: StringName in ROLE_ORDER:
|
||||
if not InputMap.has_action(action):
|
||||
continue
|
||||
for event: InputEvent in InputMap.action_get_events(action):
|
||||
if _is_keyboard_mouse_event(event):
|
||||
InputMap.action_erase_event(action, event)
|
||||
|
||||
|
||||
func _event_from_binding(binding: Dictionary) -> InputEvent:
|
||||
if not validate_binding(binding):
|
||||
return null
|
||||
if str(binding.get("kind", "")) == "mouse_button":
|
||||
var mouse_event := InputEventMouseButton.new()
|
||||
mouse_event.button_index = int(binding.get("button", 0)) as MouseButton
|
||||
return mouse_event
|
||||
var key_event := InputEventKey.new()
|
||||
key_event.keycode = int(binding.get("keycode", 0)) as Key
|
||||
key_event.physical_keycode = int(
|
||||
binding.get("physical_keycode", 0)
|
||||
) as Key
|
||||
key_event.alt_pressed = bool(binding.get("alt", false))
|
||||
key_event.shift_pressed = bool(binding.get("shift", false))
|
||||
key_event.ctrl_pressed = bool(binding.get("ctrl", false))
|
||||
key_event.meta_pressed = bool(binding.get("meta", false))
|
||||
key_event.location = int(binding.get("location", 0)) as KeyLocation
|
||||
return key_event
|
||||
|
||||
|
||||
static func _pressed_copy(event: InputEvent) -> InputEvent:
|
||||
var result: InputEvent = event.duplicate()
|
||||
if result is InputEventKey:
|
||||
(result as InputEventKey).pressed = true
|
||||
elif result is InputEventMouseButton:
|
||||
(result as InputEventMouseButton).pressed = true
|
||||
return result
|
||||
|
||||
|
||||
static func _is_keyboard_mouse_event(event: InputEvent) -> bool:
|
||||
return event is InputEventKey or event is InputEventMouseButton
|
||||
|
||||
|
||||
static func _mouse_button_label(button: int) -> String:
|
||||
var labels: Dictionary = {
|
||||
int(MOUSE_BUTTON_LEFT): "mouse left",
|
||||
int(MOUSE_BUTTON_RIGHT): "mouse right",
|
||||
int(MOUSE_BUTTON_MIDDLE): "mouse middle",
|
||||
int(MOUSE_BUTTON_WHEEL_UP): "wheel up",
|
||||
int(MOUSE_BUTTON_WHEEL_DOWN): "wheel down",
|
||||
int(MOUSE_BUTTON_WHEEL_LEFT): "wheel left",
|
||||
int(MOUSE_BUTTON_WHEEL_RIGHT): "wheel right",
|
||||
int(MOUSE_BUTTON_XBUTTON1): "mouse 4",
|
||||
int(MOUSE_BUTTON_XBUTTON2): "mouse 5",
|
||||
}
|
||||
return str(labels.get(button, "mouse %d" % button))
|
||||
|
||||
|
||||
func _save_mapping() -> bool:
|
||||
var file := FileAccess.open(MAPPING_TEMP_PATH, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return false
|
||||
file.store_string(JSON.stringify({
|
||||
"format_version": FORMAT_VERSION,
|
||||
"bindings": _bindings,
|
||||
}, "\t"))
|
||||
file.flush()
|
||||
var write_error: Error = file.get_error()
|
||||
file.close()
|
||||
if write_error != OK:
|
||||
_remove_if_present(MAPPING_TEMP_PATH)
|
||||
return false
|
||||
_remove_if_present(MAPPING_BACKUP_PATH)
|
||||
var had_primary: bool = FileAccess.file_exists(MAPPING_PATH)
|
||||
if had_primary and not _rename_file(MAPPING_PATH, MAPPING_BACKUP_PATH):
|
||||
_remove_if_present(MAPPING_TEMP_PATH)
|
||||
return false
|
||||
if not _rename_file(MAPPING_TEMP_PATH, MAPPING_PATH):
|
||||
if had_primary:
|
||||
_rename_file(MAPPING_BACKUP_PATH, MAPPING_PATH)
|
||||
return false
|
||||
_remove_if_present(MAPPING_BACKUP_PATH)
|
||||
return true
|
||||
|
||||
|
||||
func _recover_interrupted_write() -> void:
|
||||
if FileAccess.file_exists(MAPPING_PATH):
|
||||
_remove_if_present(MAPPING_TEMP_PATH)
|
||||
_remove_if_present(MAPPING_BACKUP_PATH)
|
||||
return
|
||||
if FileAccess.file_exists(MAPPING_BACKUP_PATH):
|
||||
_rename_file(MAPPING_BACKUP_PATH, MAPPING_PATH)
|
||||
_remove_if_present(MAPPING_TEMP_PATH)
|
||||
|
||||
|
||||
static func _rename_file(from_path: String, to_path: String) -> bool:
|
||||
return DirAccess.rename_absolute(
|
||||
ProjectSettings.globalize_path(from_path),
|
||||
ProjectSettings.globalize_path(to_path),
|
||||
) == OK
|
||||
|
||||
|
||||
static func _remove_if_present(path: String) -> bool:
|
||||
if not FileAccess.file_exists(path):
|
||||
return true
|
||||
return DirAccess.remove_absolute(
|
||||
ProjectSettings.globalize_path(path)
|
||||
) == OK
|
||||
Loading…
Add table
Add a link
Reference in a new issue