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) <noreply@anthropic.com>
This commit is contained in:
Daniel Samson 2026-04-29 00:09:07 +01:00
parent ab63851a74
commit 501e49cc20
No known key found for this signature in database
GPG Key ID: A9EB2589C60F7268
5 changed files with 327 additions and 16 deletions

View File

@ -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.

211
install.py Normal file
View File

@ -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()

78
src/game_control_label.gd Normal file
View File

@ -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

View File

@ -0,0 +1 @@
uid://deqd06qi1cfca

View File

@ -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())