From 501e49cc2000d7e1c1a13427bf7f7f3cb174bc1f Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:09:07 +0100 Subject: [PATCH] add GameControlLabel, install.py, manager fixes - GameControlLabel: RichTextLabel-based custom node with a ControlKey enum (24 device keys plus CUSTOM) and a separate instruction string. Uses BBCode [font] tag to scope the prompt font to the glyph so the instruction inherits the parent theme. Connects to GdControlsManager device_changed and refreshes on device swap; tool-mode safe. - install.py: idempotent merger that writes input actions from key_mapping/names.csv into project.godot's [input] section, preserving non-managed actions. - manager.gd: device_changed signal now carries only the device mode (was a four-tuple of category/type/id/name). Removed the class_name GdControlsManager declaration that collided with the autoload registration. Fixed UNKOWN -> UNKNOWN and _notifify_device_change -> _notify_device_change typos. - README: install and prompt-font regeneration instructions. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 25 +++- install.py | 211 ++++++++++++++++++++++++++++++++++ src/game_control_label.gd | 78 +++++++++++++ src/game_control_label.gd.uid | 1 + src/manager.gd | 28 +++-- 5 files changed, 327 insertions(+), 16 deletions(-) create mode 100644 install.py create mode 100644 src/game_control_label.gd create mode 100644 src/game_control_label.gd.uid diff --git a/README.md b/README.md index d80ca55..152984c 100644 --- a/README.md +++ b/README.md @@ -7,4 +7,27 @@ Standardize game controls framework ## Default Mapping -gd-controls comes with [default mapping](key_mapping/names.csv) and mapping to the [promptfont icon font set](key_mapping/promptfont.csv). +gd-controls comes with a [default mapping](key_mapping/names.csv) and mapping to the [promptfont icon font set](key_mapping/promptfont.csv). + +## Install + +1. Drop this addon into your project under `addons/gd-controls/` (clone, copy, or add as a submodule). +2. Enable the plugin: **Project → Project Settings → Plugins → gd-controls → Enable**. +3. Install the standard input actions into `project.godot`: + ```sh + python3 addons/gd-controls/install.py + ``` + This reads `key_mapping/names.csv` and adds one Godot input action per row to the `[input]` section. Existing actions whose names don't match a CSV row are preserved; same-named actions are overwritten. Re-run the script any time you edit `names.csv`. +4. Reopen the project in Godot so it picks up the new input actions and the prompt-font resources. + +Requires Python 3.9+ for `install.py`. + +## Regenerating the prompt-font mapping + +`src/prompt_font_mapping.gd` is generated from `key_mapping/promptfont.csv`. After editing the CSV, run: + +```sh +python3 addons/gd-controls/src/gen.py +``` + +The generator splices its output between the `# === STARTGEN ===` / `# === ENDGEN ===` markers in the `.gd` file; the surrounding scaffolding is left intact. diff --git a/install.py b/install.py new file mode 100644 index 0000000..257965f --- /dev/null +++ b/install.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Install gd-controls input actions into project.godot. + +Reads key_mapping/names.csv and writes one Godot input action per row +into the [input] section of project.godot. Existing actions whose names +match a CSV row are overwritten; other actions in [input] are preserved. + +Run from anywhere — paths are resolved relative to this script. +""" + +from __future__ import annotations + +from pathlib import Path +import csv +import re +import sys + +ADDON = Path(__file__).resolve().parent +CSV_PATH = ADDON / "key_mapping" / "names.csv" + + +def find_project_godot(start: Path) -> Path: + for parent in [start] + list(start.parents): + candidate = parent / "project.godot" + if candidate.exists(): + return candidate + raise SystemExit("project.godot not found above gd-controls install location") + + +# Physical keycodes (Godot 4 @GlobalScope.Key). Letters/digits use ASCII; +# specials live in KEY_SPECIAL (0x00400000) and modifiers above. +KEY_NAMES: dict[str, int] = { + "UP": 4194320, "DOWN": 4194322, "LEFT": 4194319, "RIGHT": 4194321, + "ENTER": 4194309, "BACKSPACE": 4194308, "TAB": 4194306, "ESCAPE": 4194305, + "SHIFT": 4194325, "CTRL": 4194326, "META": 4194327, "ALT": 4194328, + "SPACE": 32, +} + +MOUSE_BUTTONS: dict[str, int] = { + "MOUSE BUTTON LEFT": 1, + "MOUSE BUTTON RIGHT": 2, + "MOUSE BUTTON MIDDLE": 3, + "MOUSE WHEEL UP": 4, + "MOUSE WHEEL DOWN": 5, + "MOUSE THUMB BUTTON 1": 8, + "MOUSE THUMB BUTTON 2": 9, +} + +_JOY_BUTTON_RE = re.compile(r"^\s*Joypad Button\s*(\d+)\s*$", re.IGNORECASE) +_JOY_AXIS_RE = re.compile(r"^\s*Joypad Axis\s*(\d+)\s*([+\-])\s*$", re.IGNORECASE) + + +def keycode_for(name: str) -> int | None: + name = name.strip() + if not name: + return None + code = KEY_NAMES.get(name.upper()) + if code is not None: + return code + if len(name) == 1 and (name.isdigit() or name.isalpha()): + return ord(name.upper()) + return None + + +def make_keyboard_event(name: str) -> str | None: + code = keycode_for(name) + if code is None: + return None + return ( + 'Object(InputEventKey,"resource_local_to_scene":false,' + '"resource_name":"","device":-1,"window_id":0,' + '"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,' + '"meta_pressed":false,"pressed":false,"keycode":0,' + f'"physical_keycode":{code},"key_label":0,"unicode":0,' + '"location":0,"echo":false,"script":null)' + ) + + +def make_mouse_event(name: str) -> str | None: + button = MOUSE_BUTTONS.get(name.strip().upper()) + if button is None: + return None # MOUSE +X / -Y etc. — relative motion has no project.godot binding + return ( + 'Object(InputEventMouseButton,"resource_local_to_scene":false,' + '"resource_name":"","device":-1,"window_id":0,' + '"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,' + '"meta_pressed":false,"button_mask":0,"position":Vector2(0, 0),' + '"global_position":Vector2(0, 0),"factor":1.0,' + f'"button_index":{button},"canceled":false,"pressed":false,' + '"double_click":false,"script":null)' + ) + + +def make_joypad_event(spec: str) -> str | None: + spec = spec.strip() + if not spec: + return None + m = _JOY_BUTTON_RE.match(spec) + if m: + return ( + 'Object(InputEventJoypadButton,"resource_local_to_scene":false,' + '"resource_name":"","device":-1,' + f'"button_index":{int(m.group(1))},' + '"pressure":0.0,"pressed":false,"script":null)' + ) + m = _JOY_AXIS_RE.match(spec) + if m: + value = 1.0 if m.group(2) == "+" else -1.0 + return ( + 'Object(InputEventJoypadMotion,"resource_local_to_scene":false,' + f'"resource_name":"","device":-1,"axis":{int(m.group(1))},' + f'"axis_value":{value},"script":null)' + ) + return None + + +COLUMN_BUILDERS = [ + ("GODOT", make_joypad_event), + ("KEYBOARD_1", make_keyboard_event), + ("KEYBOARD_2", make_keyboard_event), + ("MOUSE", make_mouse_event), +] + + +def render_action(name: str, events: list[str]) -> str: + body = "\n, ".join(events) + return f'{name}={{\n"deadzone": 0.2,\n"events": [{body}]\n}}' + + +def build_actions(rows: list[dict], skipped: list[str]) -> dict[str, str]: + actions: dict[str, str] = {} + for row in rows: + key = row["KEY"].strip() + if not key: + continue + events: list[str] = [] + seen: set[str] = set() + for col, builder in COLUMN_BUILDERS: + val = row.get(col, "") + if not val or not val.strip(): + continue + ev = builder(val) + if ev is None: + skipped.append(f"{key}: {col}={val.strip()!r}") + continue + if ev in seen: + continue + seen.add(ev) + events.append(ev) + actions[key] = render_action(key, events) + return actions + + +def parse_actions(body: str) -> list[tuple[str, str]]: + actions: list[tuple[str, str]] = [] + lines = body.split("\n") + i = 0 + while i < len(lines): + m = re.match(r"^([A-Za-z_]\w*)=\{\s*$", lines[i]) + if not m: + i += 1 + continue + block_lines = [lines[i]] + i += 1 + while i < len(lines): + block_lines.append(lines[i]) + if lines[i].rstrip() == "}": + i += 1 + break + i += 1 + actions.append((m.group(1), "\n".join(block_lines))) + return actions + + +def replace_input_section(text: str, new_actions: dict[str, str]) -> str: + m = re.search(r"^\[input\][^\n]*\n", text, re.MULTILINE) + if m is None: + # Insert [input] in alphabetical position relative to existing sections + # (Godot normalizes to alphabetical order on save). + new_section = "[input]\n\n" + "\n\n".join(new_actions.values()) + "\n\n" + for s in re.finditer(r"^\[(\w+)\]", text, re.MULTILINE): + if s.group(1) > "input": + return text[:s.start()] + new_section + text[s.start():] + return text.rstrip() + "\n\n" + new_section + start = m.end() + n = re.search(r"^\[\w+\]\s*\n", text[start:], re.MULTILINE) + end = start + n.start() if n else len(text) + existing = parse_actions(text[start:end]) + blocks = [block.rstrip("\n") for name, block in existing if name not in new_actions] + blocks.extend(block.rstrip("\n") for block in new_actions.values()) + rendered = "\n" + "\n\n".join(blocks) + "\n\n" + return text[:start] + rendered + text[end:] + + +def main() -> None: + project = find_project_godot(ADDON) + with CSV_PATH.open(newline="", encoding="utf-8") as f: + rows = list(csv.DictReader(f)) + skipped: list[str] = [] + actions = build_actions(rows, skipped) + out = replace_input_section(project.read_text(encoding="utf-8"), actions) + project.write_text(out, encoding="utf-8") + print(f"installed {len(actions)} actions into {project}", file=sys.stderr) + if skipped: + print("skipped (no Godot binding for these cells):", file=sys.stderr) + for s in skipped: + print(f" {s}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/src/game_control_label.gd b/src/game_control_label.gd new file mode 100644 index 0000000..738e20e --- /dev/null +++ b/src/game_control_label.gd @@ -0,0 +1,78 @@ +@tool +extends RichTextLabel + +class_name GameControlLabel + +# Keep enum names in sync with the KEY_* constants in GdControlsDevice; +# the lookup uses ControlKey.find_key() to convert the selected value +# back to the device key string. CUSTOM bypasses the device lookup and +# renders the custom_text string directly. +enum ControlKey { + DPAD_UP, DPAD_RIGHT, DPAD_DOWN, DPAD_LEFT, + AXIS_LS_UP, AXIS_LS_RIGHT, AXIS_LS_DOWN, AXIS_LS_LEFT, + AXIS_RS_UP, AXIS_RS_RIGHT, AXIS_RS_DOWN, AXIS_RS_LEFT, + ACTION_LS, ACTION_RS, ACTION_START, ACTION_BACK, + ACTION_TOP, ACTION_BOTTOM, ACTION_LEFT, ACTION_RIGHT, + ACTION_LB, ACTION_RB, AXIS_LT, AXIS_RT, + CUSTOM, +} + +const _FONT_PATH = "res://addons/gd-controls/fonts/promptfont/promptfont.ttf" + +@export var control_key: ControlKey = ControlKey.DPAD_UP: + set(value): + control_key = value + notify_property_list_changed() + _refresh() + +@export var custom_text: String = "": + set(value): + custom_text = value + _refresh() + +@export var instruction: String = "": + set(value): + instruction = value + _refresh() + + +func _ready() -> void: + bbcode_enabled = true + fit_content = true + if not Engine.is_editor_hint(): + var manager = get_node_or_null("/root/GdControlsManager") + if manager: + manager.device_changed.connect(_on_device_changed) + _refresh() + + +func _validate_property(property: Dictionary) -> void: + if property.name == "custom_text" and control_key != ControlKey.CUSTOM: + property.usage = PROPERTY_USAGE_NO_EDITOR + + +func _on_device_changed(mode: GdControlsDevice.MODE) -> void: + _refresh(mode) + + +func _refresh(mode: int = -1) -> void: + if not is_inside_tree(): + return + var glyph: String + if control_key == ControlKey.CUSTOM: + glyph = custom_text + else: + if mode == -1: + var manager = get_node_or_null("/root/GdControlsManager") + mode = manager.get_device_mode() if manager else GdControlsDevice.MODE.KEYBOARD_1_AND_MOUSE + var key_name = ControlKey.find_key(control_key) + var by_mode = GdControlsPromptFontMapping.prompt_font_mapping.get(mode, {}) + glyph = by_mode.get(key_name, "") + var bbcode = "" + if glyph != "": + bbcode = "[font=%s]%s[/font]" % [_FONT_PATH, glyph] + if instruction != "": + if bbcode != "": + bbcode += " " + bbcode += instruction + text = bbcode diff --git a/src/game_control_label.gd.uid b/src/game_control_label.gd.uid new file mode 100644 index 0000000..66b43af --- /dev/null +++ b/src/game_control_label.gd.uid @@ -0,0 +1 @@ +uid://deqd06qi1cfca diff --git a/src/manager.gd b/src/manager.gd index cd921a5..4a91e03 100644 --- a/src/manager.gd +++ b/src/manager.gd @@ -1,13 +1,11 @@ extends Node -class_name GdControlsManager - -signal device_changed(device_category: DeviceCategory, device_type: DeviceType, device_id: int, device_name: String) +signal device_changed(device_mode: GdControlsDevice.MODE) const SETTINGS_PATH = "user://game.controls.json" enum DeviceCategory { - UNKOWN, + UNKNOWN, KEYBOARD, POINTER, CONTROLLER, @@ -15,7 +13,7 @@ enum DeviceCategory { } enum DeviceType { - UNKOWN, + UNKNOWN, KEYBOARD, MOUSE, PC_CONTROLLER, @@ -36,8 +34,8 @@ const _KEY_DEVICE_NAME = "device_name" const _KEY_DEVICE_MODE = "device_mode" var _data_defaults: Dictionary = { - _KEY_DEVICE_CATEGORY: DeviceCategory.UNKOWN, - _KEY_DEVICE_TYPE: DeviceType.UNKOWN, + _KEY_DEVICE_CATEGORY: DeviceCategory.UNKNOWN, + _KEY_DEVICE_TYPE: DeviceType.UNKNOWN, _KEY_DEVICE_ID: -2, _KEY_DEVICE_NAME: "", _KEY_DEVICE_MODE: GdControlsDevice.MODE.KEYBOARD_1_AND_MOUSE @@ -49,7 +47,7 @@ func _init(): _data = GdControlsFileManager.load_file(SETTINGS_PATH, _data_defaults) func _ready(): - _notifify_device_change() + _notify_device_change() func get_device_category() -> DeviceCategory: return _data[_KEY_DEVICE_CATEGORY] @@ -116,7 +114,7 @@ func _detect_input_category(event: InputEvent) -> DeviceCategory: elif event is InputEventJoypadButton: return DeviceCategory.CONTROLLER else: - return DeviceCategory.UNKOWN + return DeviceCategory.UNKNOWN func _on_controller_input(event: InputEvent): @@ -149,7 +147,7 @@ func _on_controller_input(event: InputEvent): GdControlsFileManager.save_file(SETTINGS_PATH, _data) - _notifify_device_change() + _notify_device_change() func _detect_controller(event: InputEvent) -> DeviceType: var device_id = event.get_device() @@ -180,7 +178,7 @@ func _on_keyboard_input(event: InputEvent): set_device_mode(GdControlsDevice.MODE.KEYBOARD_1_AND_MOUSE) GdControlsFileManager.save_file(SETTINGS_PATH, _data) - _notifify_device_change() + _notify_device_change() func _on_pointer_input(event: InputEvent): if(get_device_category() == DeviceCategory.POINTER): @@ -197,7 +195,7 @@ func _on_pointer_input(event: InputEvent): set_device_mode(GdControlsDevice.MODE.KEYBOARD_1_AND_MOUSE) GdControlsFileManager.save_file(SETTINGS_PATH, _data) - _notifify_device_change() + _notify_device_change() func _on_touch_input(event: InputEvent): if(get_device_category() == DeviceCategory.TOUCH): @@ -214,8 +212,8 @@ func _on_touch_input(event: InputEvent): set_device_mode(GdControlsDevice.MODE.KEYBOARD_1_AND_MOUSE) GdControlsFileManager.save_file(SETTINGS_PATH, _data) - _notifify_device_change() + _notify_device_change() -func _notifify_device_change(): - device_changed.emit(get_device_category(), get_device_type(), get_device_id(), get_device_name()) +func _notify_device_change(): + device_changed.emit(get_device_mode())