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:
parent
ab63851a74
commit
501e49cc20
25
README.md
25
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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://deqd06qi1cfca
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Reference in New Issue