godot-cli-control 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- godot_cli_control/__init__.py +6 -0
- godot_cli_control/__main__.py +5 -0
- godot_cli_control/bridge.py +129 -0
- godot_cli_control/cli.py +158 -0
- godot_cli_control/client.py +246 -0
- godot_cli_control/runner.py +61 -0
- godot_cli_control-0.1.0.dist-info/METADATA +59 -0
- godot_cli_control-0.1.0.dist-info/RECORD +11 -0
- godot_cli_control-0.1.0.dist-info/WHEEL +4 -0
- godot_cli_control-0.1.0.dist-info/entry_points.txt +2 -0
- godot_cli_control-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""同步 Bridge —— 封装 GameClient,让 Python 脚本只需写操作逻辑。
|
|
2
|
+
|
|
3
|
+
用法:脚本只需定义 run(bridge) 函数即可。
|
|
4
|
+
|
|
5
|
+
def run(bridge):
|
|
6
|
+
bridge.click("/root/MainMenu/.../NewGameButton")
|
|
7
|
+
bridge.wait(2)
|
|
8
|
+
bridge.hold("move_right", 1.5)
|
|
9
|
+
bridge.press("attack_melee")
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .client import GameClient
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GameBridge:
|
|
22
|
+
"""同步接口,内部通过事件循环调用异步 GameClient。"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, port: int = 9877) -> None:
|
|
25
|
+
self._loop = asyncio.new_event_loop()
|
|
26
|
+
self._client = GameClient(port=port)
|
|
27
|
+
self._loop.run_until_complete(
|
|
28
|
+
self._client.connect(retries=15, backoff=1.0)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def close(self) -> None:
|
|
32
|
+
self._loop.run_until_complete(self._client.disconnect())
|
|
33
|
+
self._loop.close()
|
|
34
|
+
|
|
35
|
+
def _run(self, coro: Any) -> Any:
|
|
36
|
+
return self._loop.run_until_complete(coro)
|
|
37
|
+
|
|
38
|
+
# ── 等待 ──
|
|
39
|
+
|
|
40
|
+
def wait(self, seconds: float) -> None:
|
|
41
|
+
"""按 Godot game time 等待指定秒数。
|
|
42
|
+
|
|
43
|
+
在 --write-movie 模式下,此方法等待的是 game time(录像帧数基准),
|
|
44
|
+
而非 wall time,确保录像内容与脚本期望对齐。
|
|
45
|
+
"""
|
|
46
|
+
self._run(self._client.wait_game_time(seconds))
|
|
47
|
+
|
|
48
|
+
# ── 场景树 ──
|
|
49
|
+
|
|
50
|
+
def tree(self, depth: int = 3) -> dict:
|
|
51
|
+
"""获取场景树。"""
|
|
52
|
+
return self._run(self._client.get_scene_tree(depth=depth))
|
|
53
|
+
|
|
54
|
+
def node_exists(self, path: str) -> bool:
|
|
55
|
+
"""检查节点是否存在。"""
|
|
56
|
+
return self._run(self._client.node_exists(path))
|
|
57
|
+
|
|
58
|
+
def wait_for_node(self, path: str, timeout: float = 5.0) -> bool:
|
|
59
|
+
"""等待节点出现。"""
|
|
60
|
+
return self._run(self._client.wait_for_node(path, timeout=timeout))
|
|
61
|
+
|
|
62
|
+
# ── UI 交互 ──
|
|
63
|
+
|
|
64
|
+
def click(self, path: str) -> dict:
|
|
65
|
+
"""点击 UI 节点。"""
|
|
66
|
+
return self._run(self._client.click(path))
|
|
67
|
+
|
|
68
|
+
# ── 输入模拟 ──
|
|
69
|
+
|
|
70
|
+
def press(self, action: str) -> None:
|
|
71
|
+
"""按下并释放一个动作。"""
|
|
72
|
+
self._run(self._client.action_tap(action, 0.1))
|
|
73
|
+
|
|
74
|
+
def hold(self, action: str, duration: float) -> None:
|
|
75
|
+
"""按住一个动作指定时长。"""
|
|
76
|
+
self._run(self._client.hold(action, duration))
|
|
77
|
+
|
|
78
|
+
def tap(self, action: str, duration: float = 0.1) -> None:
|
|
79
|
+
"""快速点按。"""
|
|
80
|
+
self._run(self._client.action_tap(action, duration))
|
|
81
|
+
|
|
82
|
+
def move(self, x: float, y: float, duration: float) -> None:
|
|
83
|
+
"""方向移动。"""
|
|
84
|
+
self._run(self._client.move(x, y, duration))
|
|
85
|
+
|
|
86
|
+
def action_press(self, action: str) -> None:
|
|
87
|
+
"""按下动作(不释放,需手动 release)。"""
|
|
88
|
+
self._run(self._client.action_press(action))
|
|
89
|
+
|
|
90
|
+
def action_release(self, action: str) -> None:
|
|
91
|
+
"""释放动作。"""
|
|
92
|
+
self._run(self._client.action_release(action))
|
|
93
|
+
|
|
94
|
+
def release_all(self) -> None:
|
|
95
|
+
"""释放所有按住的动作。"""
|
|
96
|
+
self._run(self._client.release_all())
|
|
97
|
+
|
|
98
|
+
def combo(self, steps: list[dict]) -> dict:
|
|
99
|
+
"""执行连续动作序列。"""
|
|
100
|
+
return self._run(self._client.combo(steps))
|
|
101
|
+
|
|
102
|
+
# ── 截图 ──
|
|
103
|
+
|
|
104
|
+
def screenshot(self, path: str | None = None) -> bytes:
|
|
105
|
+
"""截图,返回 PNG 字节。可选保存到文件。"""
|
|
106
|
+
data = self._run(self._client.screenshot())
|
|
107
|
+
if path:
|
|
108
|
+
p = Path(path)
|
|
109
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
p.write_bytes(data)
|
|
111
|
+
return data
|
|
112
|
+
|
|
113
|
+
# ── 属性读写 ──
|
|
114
|
+
|
|
115
|
+
def get_property(self, path: str, prop: str) -> Any:
|
|
116
|
+
"""获取节点属性。"""
|
|
117
|
+
return self._run(self._client.get_property(path, prop))
|
|
118
|
+
|
|
119
|
+
def set_property(self, path: str, prop: str, value: Any) -> None:
|
|
120
|
+
"""设置节点属性。"""
|
|
121
|
+
self._run(self._client.set_property(path, prop, value))
|
|
122
|
+
|
|
123
|
+
def call_method(self, path: str, method: str, args: list | None = None) -> Any:
|
|
124
|
+
"""调用节点方法。"""
|
|
125
|
+
return self._run(self._client.call_method(path, method, args))
|
|
126
|
+
|
|
127
|
+
def get_children(self, path: str, type_filter: str = "") -> list[dict]:
|
|
128
|
+
"""获取子节点列表。"""
|
|
129
|
+
return self._run(self._client.get_children(path, type_filter=type_filter))
|
godot_cli_control/cli.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""CLI commands for one-off GameClient operations.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python3 -m godot_cli_control click <node_path>
|
|
5
|
+
python3 -m godot_cli_control screenshot [output.png]
|
|
6
|
+
python3 -m godot_cli_control tree [depth]
|
|
7
|
+
python3 -m godot_cli_control press <action_name>
|
|
8
|
+
python3 -m godot_cli_control hold <action_name> <duration>
|
|
9
|
+
python3 -m godot_cli_control combo <sequence.json>
|
|
10
|
+
|
|
11
|
+
<action_name> 依赖目标工程的 InputMap;<node_path> 形如
|
|
12
|
+
/root/<Scene>/... 由 `tree` 查出。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import base64
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from collections.abc import Callable, Coroutine
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .client import GameClient
|
|
26
|
+
|
|
27
|
+
DEFAULT_PORT: int = 9877
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def cmd_click(client: GameClient, args: list[str]) -> None:
|
|
31
|
+
if not args:
|
|
32
|
+
print("Usage: click <node_path>", file=sys.stderr)
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
result = await client.click(args[0])
|
|
35
|
+
print(f"clicked: {result}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def cmd_screenshot(client: GameClient, args: list[str]) -> None:
|
|
39
|
+
data = await client.screenshot()
|
|
40
|
+
if args:
|
|
41
|
+
output = Path(args[0])
|
|
42
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
output.write_bytes(data)
|
|
44
|
+
print(f"screenshot saved: {output} ({len(data)} bytes)")
|
|
45
|
+
else:
|
|
46
|
+
print(base64.b64encode(data).decode())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def cmd_tree(client: GameClient, args: list[str]) -> None:
|
|
50
|
+
depth = int(args[0]) if args else 3
|
|
51
|
+
tree = await client.get_scene_tree(depth=depth)
|
|
52
|
+
print(json.dumps(tree, indent=2, ensure_ascii=False))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def cmd_press(client: GameClient, args: list[str]) -> None:
|
|
56
|
+
if not args:
|
|
57
|
+
print("Usage: press <action>", file=sys.stderr)
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
result = await client.action_press(args[0])
|
|
60
|
+
print(f"pressed: {result}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def cmd_release(client: GameClient, args: list[str]) -> None:
|
|
64
|
+
if not args:
|
|
65
|
+
print("Usage: release <action>", file=sys.stderr)
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
result = await client.action_release(args[0])
|
|
68
|
+
print(f"released: {result}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def cmd_tap(client: GameClient, args: list[str]) -> None:
|
|
72
|
+
if not args:
|
|
73
|
+
print("Usage: tap <action> [duration]", file=sys.stderr)
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
duration = float(args[1]) if len(args) > 1 else 0.1
|
|
76
|
+
result = await client.action_tap(args[0], duration)
|
|
77
|
+
print(f"tapped: {result}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def cmd_hold(client: GameClient, args: list[str]) -> None:
|
|
81
|
+
if len(args) < 2:
|
|
82
|
+
print("Usage: hold <action> <duration>", file=sys.stderr)
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
result = await client.hold(args[0], float(args[1]))
|
|
85
|
+
print(f"holding: {result}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def cmd_move(client: GameClient, args: list[str]) -> None:
|
|
89
|
+
if len(args) < 3:
|
|
90
|
+
print("Usage: move <x> <y> <duration>", file=sys.stderr)
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
result = await client.move(float(args[0]), float(args[1]), float(args[2]))
|
|
93
|
+
print(f"moving: {result}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def cmd_combo(client: GameClient, args: list[str]) -> None:
|
|
97
|
+
if not args:
|
|
98
|
+
print("Usage: combo <json_file>", file=sys.stderr)
|
|
99
|
+
sys.exit(1)
|
|
100
|
+
steps = json.loads(Path(args[0]).read_text())
|
|
101
|
+
if isinstance(steps, dict):
|
|
102
|
+
steps = steps.get("steps", [])
|
|
103
|
+
result = await client.combo(steps)
|
|
104
|
+
print(f"combo done: {result}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def cmd_release_all(client: GameClient, _args: list[str]) -> None:
|
|
108
|
+
result = await client.release_all()
|
|
109
|
+
print(f"released all: {result}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
COMMANDS: dict[
|
|
113
|
+
str, Callable[[GameClient, list[str]], Coroutine[Any, Any, None]]
|
|
114
|
+
] = {
|
|
115
|
+
"click": cmd_click,
|
|
116
|
+
"screenshot": cmd_screenshot,
|
|
117
|
+
"tree": cmd_tree,
|
|
118
|
+
"press": cmd_press,
|
|
119
|
+
"release": cmd_release,
|
|
120
|
+
"tap": cmd_tap,
|
|
121
|
+
"hold": cmd_hold,
|
|
122
|
+
"move": cmd_move,
|
|
123
|
+
"combo": cmd_combo,
|
|
124
|
+
"release-all": cmd_release_all,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main() -> None:
|
|
129
|
+
args: list[str] = sys.argv[1:]
|
|
130
|
+
port: int = DEFAULT_PORT
|
|
131
|
+
|
|
132
|
+
if "--port" in args:
|
|
133
|
+
idx = args.index("--port")
|
|
134
|
+
if idx + 1 >= len(args):
|
|
135
|
+
print("Error: --port requires a value", file=sys.stderr)
|
|
136
|
+
sys.exit(1)
|
|
137
|
+
port = int(args[idx + 1])
|
|
138
|
+
args = args[:idx] + args[idx + 2 :]
|
|
139
|
+
|
|
140
|
+
if not args or args[0] not in COMMANDS:
|
|
141
|
+
cmds = ", ".join(COMMANDS.keys())
|
|
142
|
+
print(
|
|
143
|
+
f"Usage: python3 -m godot_cli_control [--port N] <{cmds}> [args...]",
|
|
144
|
+
file=sys.stderr,
|
|
145
|
+
)
|
|
146
|
+
sys.exit(1)
|
|
147
|
+
|
|
148
|
+
cmd = args[0]
|
|
149
|
+
|
|
150
|
+
async def run() -> None:
|
|
151
|
+
async with GameClient(port=port) as client:
|
|
152
|
+
await COMMANDS[cmd](client, args[1:])
|
|
153
|
+
|
|
154
|
+
asyncio.run(run())
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
main()
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""GameClient - WebSocket client for connecting to Godot GameBridge."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import websockets
|
|
11
|
+
from websockets.asyncio.client import ClientConnection
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GameClient:
|
|
17
|
+
"""WebSocket client that connects to Godot's GameBridge service."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, port: int = 9877) -> None:
|
|
20
|
+
self._port = port
|
|
21
|
+
self._ws: ClientConnection | None = None
|
|
22
|
+
self._pending: dict[str, asyncio.Future[dict]] = {}
|
|
23
|
+
self._listen_task: asyncio.Task | None = None
|
|
24
|
+
|
|
25
|
+
async def __aenter__(self) -> "GameClient":
|
|
26
|
+
await self.connect()
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
async def __aexit__(self, *_: object) -> None:
|
|
30
|
+
await self.disconnect()
|
|
31
|
+
|
|
32
|
+
async def connect(
|
|
33
|
+
self, retries: int = 10, backoff: float = 1.0, max_wait: float = 3.0
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Connect to GameBridge with exponential backoff retry.
|
|
36
|
+
|
|
37
|
+
显式 ``proxy=None``:GameBridge 永远跑在 localhost,而 websockets>=14
|
|
38
|
+
会自动读取 ``all_proxy``/``http_proxy``/``https_proxy`` 环境变量并把
|
|
39
|
+
localhost 连接也塞进 SOCKS/HTTP 代理,导致 TCP 被代理立刻 EOF、
|
|
40
|
+
抛 ``InvalidMessage: did not receive a valid HTTP response``。用户
|
|
41
|
+
shell 里的 ``all_proxy=socks5://127.0.0.1:7897`` 就是典型触发点;
|
|
42
|
+
显式传 ``proxy=None`` 比依赖 ``no_proxy`` 顺序更稳(no_proxy 对
|
|
43
|
+
localhost 的匹配规则各库不一致)。
|
|
44
|
+
"""
|
|
45
|
+
for attempt in range(retries):
|
|
46
|
+
try:
|
|
47
|
+
self._ws = await websockets.connect(
|
|
48
|
+
f"ws://localhost:{self._port}",
|
|
49
|
+
proxy=None,
|
|
50
|
+
)
|
|
51
|
+
self._listen_task = asyncio.create_task(self._listen())
|
|
52
|
+
logger.info("Connected to GameBridge on port %d", self._port)
|
|
53
|
+
return
|
|
54
|
+
except (
|
|
55
|
+
ConnectionRefusedError,
|
|
56
|
+
OSError,
|
|
57
|
+
websockets.exceptions.InvalidMessage,
|
|
58
|
+
) as e:
|
|
59
|
+
if attempt < retries - 1:
|
|
60
|
+
wait = min(backoff * (2**attempt), max_wait)
|
|
61
|
+
logger.warning(
|
|
62
|
+
"Connection attempt %d failed: %s. Retrying in %.1fs...",
|
|
63
|
+
attempt + 1, e, wait,
|
|
64
|
+
)
|
|
65
|
+
await asyncio.sleep(wait)
|
|
66
|
+
else:
|
|
67
|
+
raise ConnectionError(
|
|
68
|
+
f"Failed to connect after {retries} attempts"
|
|
69
|
+
) from e
|
|
70
|
+
|
|
71
|
+
async def disconnect(self) -> None:
|
|
72
|
+
"""Disconnect from GameBridge."""
|
|
73
|
+
if self._listen_task:
|
|
74
|
+
self._listen_task.cancel()
|
|
75
|
+
try:
|
|
76
|
+
await self._listen_task
|
|
77
|
+
except asyncio.CancelledError:
|
|
78
|
+
pass
|
|
79
|
+
if self._ws:
|
|
80
|
+
await self._ws.close()
|
|
81
|
+
self._ws = None
|
|
82
|
+
logger.info("Disconnected from GameBridge")
|
|
83
|
+
|
|
84
|
+
async def _listen(self) -> None:
|
|
85
|
+
"""Background task that listens for messages from GameBridge.
|
|
86
|
+
|
|
87
|
+
finally 里清空所有 pending future —— 涵盖 ConnectionClosed、
|
|
88
|
+
CancelledError 以及任何其它退出路径,防止调用方的 await 在
|
|
89
|
+
connection 消失后挂死到超时。
|
|
90
|
+
"""
|
|
91
|
+
assert self._ws is not None
|
|
92
|
+
close_reason: str | None = None
|
|
93
|
+
try:
|
|
94
|
+
async for raw in self._ws:
|
|
95
|
+
msg = json.loads(raw)
|
|
96
|
+
if "id" in msg:
|
|
97
|
+
req_id = msg["id"]
|
|
98
|
+
if req_id in self._pending:
|
|
99
|
+
self._pending[req_id].set_result(msg)
|
|
100
|
+
del self._pending[req_id]
|
|
101
|
+
except websockets.ConnectionClosed:
|
|
102
|
+
close_reason = "Connection closed by server"
|
|
103
|
+
logger.info(close_reason)
|
|
104
|
+
finally:
|
|
105
|
+
reason = close_reason or "listen task stopped"
|
|
106
|
+
for future in self._pending.values():
|
|
107
|
+
if not future.done():
|
|
108
|
+
future.set_exception(ConnectionError(reason))
|
|
109
|
+
self._pending.clear()
|
|
110
|
+
|
|
111
|
+
async def request(
|
|
112
|
+
self, method: str, params: dict | None = None, timeout: float = 30.0
|
|
113
|
+
) -> dict:
|
|
114
|
+
"""Send a JSON-RPC request and wait for the response."""
|
|
115
|
+
assert self._ws is not None, "Not connected"
|
|
116
|
+
req_id = str(uuid.uuid4())[:8]
|
|
117
|
+
msg = {"id": req_id, "method": method, "params": params or {}}
|
|
118
|
+
future: asyncio.Future[dict] = asyncio.get_running_loop().create_future()
|
|
119
|
+
self._pending[req_id] = future
|
|
120
|
+
await self._ws.send(json.dumps(msg))
|
|
121
|
+
try:
|
|
122
|
+
response = await asyncio.wait_for(future, timeout=timeout)
|
|
123
|
+
except asyncio.TimeoutError:
|
|
124
|
+
self._pending.pop(req_id, None)
|
|
125
|
+
raise
|
|
126
|
+
if "error" in response:
|
|
127
|
+
raise RuntimeError(
|
|
128
|
+
f"GameBridge error: {response['error']['message']}"
|
|
129
|
+
)
|
|
130
|
+
return response.get("result", {})
|
|
131
|
+
|
|
132
|
+
# ---- Low-level API ----
|
|
133
|
+
|
|
134
|
+
async def click(self, path: str) -> dict:
|
|
135
|
+
return await self.request("click", {"path": path})
|
|
136
|
+
|
|
137
|
+
async def get_property(self, path: str, prop: str) -> Any:
|
|
138
|
+
result = await self.request(
|
|
139
|
+
"get_property", {"path": path, "property": prop}
|
|
140
|
+
)
|
|
141
|
+
return result.get("value")
|
|
142
|
+
|
|
143
|
+
async def set_property(self, path: str, prop: str, value: Any) -> dict:
|
|
144
|
+
return await self.request(
|
|
145
|
+
"set_property", {"path": path, "property": prop, "value": value}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
async def call_method(
|
|
149
|
+
self, path: str, method: str, args: list | None = None
|
|
150
|
+
) -> Any:
|
|
151
|
+
result = await self.request(
|
|
152
|
+
"call_method",
|
|
153
|
+
{"path": path, "method": method, "args": args or []},
|
|
154
|
+
)
|
|
155
|
+
return result.get("result")
|
|
156
|
+
|
|
157
|
+
async def get_text(self, path: str) -> str:
|
|
158
|
+
result = await self.request("get_text", {"path": path})
|
|
159
|
+
return result.get("text", "")
|
|
160
|
+
|
|
161
|
+
async def node_exists(self, path: str) -> bool:
|
|
162
|
+
result = await self.request("node_exists", {"path": path})
|
|
163
|
+
return result.get("exists", False)
|
|
164
|
+
|
|
165
|
+
async def is_visible(self, path: str) -> bool:
|
|
166
|
+
result = await self.request("is_visible", {"path": path})
|
|
167
|
+
return result.get("visible", False)
|
|
168
|
+
|
|
169
|
+
async def get_children(
|
|
170
|
+
self, path: str, type_filter: str = ""
|
|
171
|
+
) -> list[dict]:
|
|
172
|
+
result = await self.request(
|
|
173
|
+
"get_children", {"path": path, "type_filter": type_filter}
|
|
174
|
+
)
|
|
175
|
+
return result.get("children", [])
|
|
176
|
+
|
|
177
|
+
async def screenshot(self) -> bytes:
|
|
178
|
+
"""Take a screenshot and return PNG bytes."""
|
|
179
|
+
result = await self.request("screenshot")
|
|
180
|
+
return base64.b64decode(result.get("image", ""))
|
|
181
|
+
|
|
182
|
+
async def get_scene_tree(self, depth: int = 5) -> dict:
|
|
183
|
+
return await self.request("get_scene_tree", {"depth": depth})
|
|
184
|
+
|
|
185
|
+
async def wait_for_node(self, path: str, timeout: float = 5.0) -> bool:
|
|
186
|
+
result = await self.request(
|
|
187
|
+
"wait_for_node",
|
|
188
|
+
{"path": path, "timeout": timeout},
|
|
189
|
+
timeout=timeout + 5.0,
|
|
190
|
+
)
|
|
191
|
+
return result.get("found", False)
|
|
192
|
+
|
|
193
|
+
async def wait_game_time(self, seconds: float) -> dict:
|
|
194
|
+
"""按 Godot game time 等待 N 秒。
|
|
195
|
+
|
|
196
|
+
Movie Maker (--write-movie) 模式下 wall time 比 game time 慢约 2-3×,
|
|
197
|
+
客户端 timeout 用 seconds * 3 + 10 安全系数,与 combo() 一致。
|
|
198
|
+
seconds <= 0 时客户端短路返回,不发 RPC。
|
|
199
|
+
"""
|
|
200
|
+
if seconds <= 0:
|
|
201
|
+
return {"success": True}
|
|
202
|
+
return await self.request(
|
|
203
|
+
"wait_game_time",
|
|
204
|
+
{"seconds": seconds},
|
|
205
|
+
timeout=seconds * 3.0 + 10.0,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# ---- Input simulation API ----
|
|
209
|
+
|
|
210
|
+
async def action_press(self, action: str) -> dict:
|
|
211
|
+
return await self.request("input_action_press", {"action": action})
|
|
212
|
+
|
|
213
|
+
async def action_release(self, action: str) -> dict:
|
|
214
|
+
return await self.request("input_action_release", {"action": action})
|
|
215
|
+
|
|
216
|
+
async def action_tap(self, action: str, duration: float = 0.1) -> dict:
|
|
217
|
+
return await self.request(
|
|
218
|
+
"input_action_tap", {"action": action, "duration": duration}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
async def hold(self, action: str, duration: float) -> dict:
|
|
222
|
+
return await self.request(
|
|
223
|
+
"input_hold", {"action": action, "duration": duration}
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
async def move(self, x: float, y: float, duration: float) -> dict:
|
|
227
|
+
return await self.request(
|
|
228
|
+
"input_move", {"direction": {"x": x, "y": y}, "duration": duration}
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
async def combo(self, steps: list[dict]) -> dict:
|
|
232
|
+
total = sum(
|
|
233
|
+
s.get("duration", 0) or s.get("wait", 0) for s in steps
|
|
234
|
+
)
|
|
235
|
+
# movie maker (--write-movie) 模式下 Godot 渲染比实时慢(大分辨率 +
|
|
236
|
+
# MJPEG 编码开销),客户端墙钟等待需要 3× 安全系数 + 10s base,
|
|
237
|
+
# 避免 combo 还没在游戏时间跑完就被 TimeoutError 打断。
|
|
238
|
+
return await self.request(
|
|
239
|
+
"input_combo", {"steps": steps}, timeout=total * 3.0 + 10.0
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
async def combo_cancel(self) -> dict:
|
|
243
|
+
return await self.request("input_combo_cancel")
|
|
244
|
+
|
|
245
|
+
async def release_all(self) -> dict:
|
|
246
|
+
return await self.request("input_release_all")
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""脚本运行器 —— 加载用户脚本并注入 GameBridge。
|
|
2
|
+
|
|
3
|
+
用法: python3 -m godot_cli_control.runner <script.py> [args...]
|
|
4
|
+
|
|
5
|
+
用户脚本只需定义 run(bridge) 函数:
|
|
6
|
+
|
|
7
|
+
def run(bridge):
|
|
8
|
+
bridge.click("/root/MainMenu/.../NewGameButton")
|
|
9
|
+
bridge.wait(2)
|
|
10
|
+
bridge.hold("move_right", 1.5)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib.util
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .bridge import GameBridge
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> None:
|
|
23
|
+
if len(sys.argv) < 2:
|
|
24
|
+
print("用法: python3 -m godot_cli_control.runner <script.py> [args...]", file=sys.stderr)
|
|
25
|
+
sys.exit(1)
|
|
26
|
+
|
|
27
|
+
script_path = Path(sys.argv[1])
|
|
28
|
+
if not script_path.exists():
|
|
29
|
+
print(f"错误:找不到脚本: {script_path}", file=sys.stderr)
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
|
|
32
|
+
# 解析 --port 参数
|
|
33
|
+
port = 9877
|
|
34
|
+
args = sys.argv[2:]
|
|
35
|
+
if "--port" in args:
|
|
36
|
+
idx = args.index("--port")
|
|
37
|
+
port = int(args[idx + 1])
|
|
38
|
+
args = args[:idx] + args[idx + 2:]
|
|
39
|
+
|
|
40
|
+
# 动态加载用户脚本
|
|
41
|
+
spec = importlib.util.spec_from_file_location("user_script", script_path)
|
|
42
|
+
if spec is None or spec.loader is None:
|
|
43
|
+
print(f"错误:无法加载脚本: {script_path}", file=sys.stderr)
|
|
44
|
+
sys.exit(1)
|
|
45
|
+
module = importlib.util.module_from_spec(spec)
|
|
46
|
+
spec.loader.exec_module(module)
|
|
47
|
+
|
|
48
|
+
if not hasattr(module, "run"):
|
|
49
|
+
print(f"错误:脚本 {script_path} 中缺少 run(bridge) 函数", file=sys.stderr)
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
|
|
52
|
+
# 创建 bridge 并执行
|
|
53
|
+
bridge = GameBridge(port=port)
|
|
54
|
+
try:
|
|
55
|
+
module.run(bridge)
|
|
56
|
+
finally:
|
|
57
|
+
bridge.close()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
main()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: godot-cli-control
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: WebSocket bridge for headless / scripted control of Godot scenes.
|
|
5
|
+
Author: kesar
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: websockets<16,>=14
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# godot-cli-control
|
|
13
|
+
|
|
14
|
+
WebSocket bridge for headless / scripted control of Godot 4 scenes — Python client.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install godot-cli-control
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires Python ≥ 3.10. Companion Godot plugin must be installed and enabled in your Godot project (see [the plugin README](https://github.com/kesar/godot-2d-skeleton/blob/main/addons/godot_cli_control/README.md) for setup).
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import asyncio
|
|
28
|
+
from godot_cli_control import GameClient
|
|
29
|
+
|
|
30
|
+
async def main():
|
|
31
|
+
async with GameClient(port=9877) as client:
|
|
32
|
+
# Inspect scene
|
|
33
|
+
tree = await client.get_scene_tree(depth=3)
|
|
34
|
+
print(tree)
|
|
35
|
+
|
|
36
|
+
# Interact
|
|
37
|
+
await client.click("/root/MyScene/Button")
|
|
38
|
+
await client.action_press("jump")
|
|
39
|
+
await client.wait_game_time(0.5)
|
|
40
|
+
await client.action_release("jump")
|
|
41
|
+
|
|
42
|
+
# Capture
|
|
43
|
+
png_bytes = await client.screenshot()
|
|
44
|
+
open("frame.png", "wb").write(png_bytes)
|
|
45
|
+
|
|
46
|
+
asyncio.run(main())
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## CLI
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python -m godot_cli_control tree 3
|
|
53
|
+
python -m godot_cli_control click /root/MyScene/Button
|
|
54
|
+
python -m godot_cli_control screenshot /tmp/frame.png
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Documentation
|
|
58
|
+
|
|
59
|
+
See the [Godot plugin README](https://github.com/kesar/godot-2d-skeleton/blob/main/addons/godot_cli_control/README.md) for the full RPC reference, activation modes, security model, and known limitations.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
godot_cli_control/__init__.py,sha256=AnccyQWirNpfA1mIOKKqSyIj9ZCHbePX-9OcstrN4JA,191
|
|
2
|
+
godot_cli_control/__main__.py,sha256=b4ZjDWfx_1HURqDT_NppUrSdsJHDnWrTmTqEm-fot7I,104
|
|
3
|
+
godot_cli_control/bridge.py,sha256=Ft6ccUI4bYqzvCAeBWLOh3wLYe6qvRimZYTkQ5awC3w,4372
|
|
4
|
+
godot_cli_control/cli.py,sha256=4ernAGcxQsHMPRrS5THB6r4D-2cvFacjR7ZrPNrIqhM,4576
|
|
5
|
+
godot_cli_control/client.py,sha256=gifAURi2u6SZNkjLjNcT3eG3f-MzP-q_mpIsjDO_in4,9360
|
|
6
|
+
godot_cli_control/runner.py,sha256=rPn9YBepJ_V4PCVO7JS-V55RuiZ1gQJjbU03vowuJMI,1660
|
|
7
|
+
godot_cli_control-0.1.0.dist-info/METADATA,sha256=fEapVMZN_deFZJC51sNr9PHKfaey2BPt4rjyjgekkK8,1639
|
|
8
|
+
godot_cli_control-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
godot_cli_control-0.1.0.dist-info/entry_points.txt,sha256=FE3vot-aNS_kea_ZtdBwM1jG7LGLfNTYe4Tr5ev-T_U,65
|
|
10
|
+
godot_cli_control-0.1.0.dist-info/licenses/LICENSE,sha256=EX5PjSSWg24GE8fMU2BhRYT-gGNthdfJ1oD20pzSYEI,1062
|
|
11
|
+
godot_cli_control-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kesar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|