212 lines
7.0 KiB
Python
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()
|