gd-controls/install.py

212 lines
7.0 KiB
Python

#!/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()