feat(@projects/@magic-civilization): ✨ rendered MCP driver — TCP screenshot/open_screen (p2-86 phase 1)
New mcp_render_driver autoload (active only on MC_MCP_RENDER=1): listens on a localhost TCP socket polled in _process and handles screenshot / open_screen / ping / quit against the LIVE rendered game. TCP, not stdin: OS.read_string_from_stdin blocks an open pipe and would freeze a rendered main loop (the player_api_main stdin pump only works --headless); a polled TCPServer is non-blocking and also dodges Godot's windowed-stdout buffering. Inert (set_process(false)) unless the env flag is set — zero cost in normal play. Verified end-to-end with NO MCP needed: MC_AUTO_START=1 MC_MCP_RENDER=1 godot + a raw TCP client -> ping ok, screenshot -> real 3420x1923 PNG of the live world map. Phase 2 (claude-player-mcp tools + npm install + .mcp.json + restart) next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
12a4cf4269
commit
5c06a9d923
2 changed files with 158 additions and 0 deletions
157
src/game/engine/src/autoloads/mcp_render_driver.gd
Normal file
157
src/game/engine/src/autoloads/mcp_render_driver.gd
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
extends Node
|
||||
## p2-86: rendered-game MCP driver. When `MC_MCP_RENDER=1`, listens on a
|
||||
## localhost TCP socket for JSON-Lines commands against the LIVE rendered game
|
||||
## so an external client — the claude-player-mcp rendered mode, or any TCP
|
||||
## client — can capture screenshots and navigate top-level screens.
|
||||
##
|
||||
## Why TCP, not stdin: `OS.read_string_from_stdin` BLOCKS on an open pipe and
|
||||
## freezes a rendered game's main loop (verified 2026-06-18; the stdin pump in
|
||||
## player_api_main only works because it runs --headless). A TCPServer polled in
|
||||
## `_process` is fully non-blocking and avoids Godot's windowed-stdout buffering.
|
||||
##
|
||||
## Inert unless `MC_MCP_RENDER=1`: zero cost in normal play.
|
||||
##
|
||||
## Wire protocol (one JSON object per line over the socket; reply has same `id`):
|
||||
## {"type":"screenshot","path":"user://screenshots/x.png","id":1}
|
||||
## -> {"ok":true,"path":"<abs>","size":[w,h],"id":1}
|
||||
## {"type":"open_screen","screen":"main_menu|game_setup","id":2} -> {"ok":true,"id":2}
|
||||
## {"type":"ping","id":3} -> {"ok":true,"id":3}
|
||||
## {"type":"shutdown","id":4} -> {"ok":true,"id":4} then quit.
|
||||
|
||||
const DEFAULT_PORT: int = 8787
|
||||
const DEFAULT_SHOT_PATH: String = "user://screenshots/mcp_capture.png"
|
||||
|
||||
var _active: bool = false
|
||||
var _server: TCPServer = null
|
||||
var _peer: StreamPeerTCP = null
|
||||
var _buf: String = ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_active = EnvConfig.get_bool("MC_MCP_RENDER")
|
||||
if not _active:
|
||||
set_process(false)
|
||||
return
|
||||
var port: int = EnvConfig.get_int("MC_MCP_PORT", DEFAULT_PORT)
|
||||
_server = TCPServer.new()
|
||||
var err: int = _server.listen(port, "127.0.0.1")
|
||||
if err != OK:
|
||||
push_error("MCPRenderDriver: TCPServer.listen(%d) failed (err %d)" % [port, err])
|
||||
_active = false
|
||||
set_process(false)
|
||||
return
|
||||
print("[mcp-render] listening on 127.0.0.1:%d" % port)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _peer == null and _server.is_connection_available():
|
||||
_peer = _server.take_connection()
|
||||
_buf = ""
|
||||
if _peer == null:
|
||||
return
|
||||
_peer.poll()
|
||||
if _peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
||||
_peer = null
|
||||
return
|
||||
var avail: int = _peer.get_available_bytes()
|
||||
if avail > 0:
|
||||
_buf += _peer.get_utf8_string(avail)
|
||||
while _buf.contains("\n"):
|
||||
var idx: int = _buf.find("\n")
|
||||
var line: String = _buf.substr(0, idx).strip_edges()
|
||||
_buf = _buf.substr(idx + 1)
|
||||
if not line.is_empty():
|
||||
_handle_line(line)
|
||||
|
||||
|
||||
func _handle_line(line: String) -> void:
|
||||
var req: Dictionary = JSON.parse_string(line) as Dictionary
|
||||
if req.is_empty() and line != "{}":
|
||||
_respond({"ok": false, "error": {"code": "parse_error", "message": line}})
|
||||
return
|
||||
var rtype: String = String(req.get("type", ""))
|
||||
var has_id: bool = req.has("id") and req.get("id") != null
|
||||
var rid: int = int(req.get("id", -1))
|
||||
match rtype:
|
||||
"ping":
|
||||
_respond(_ack(rid, has_id))
|
||||
"screenshot":
|
||||
_do_screenshot(req, rid, has_id)
|
||||
"open_screen":
|
||||
_do_open_screen(req, rid, has_id)
|
||||
"shutdown":
|
||||
_respond(_ack(rid, has_id))
|
||||
get_tree().quit(0)
|
||||
_:
|
||||
_respond(_err(rid, has_id, "unknown request type: " + rtype))
|
||||
|
||||
|
||||
func _do_screenshot(req: Dictionary, rid: int, has_id: bool) -> void:
|
||||
var rel: String = String(req.get("path", DEFAULT_SHOT_PATH))
|
||||
var base: String = rel.get_base_dir()
|
||||
if not base.is_empty():
|
||||
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(base))
|
||||
var viewport: Viewport = get_viewport()
|
||||
if viewport == null:
|
||||
_respond(_err(rid, has_id, "no viewport"))
|
||||
return
|
||||
var tex: Texture2D = viewport.get_texture()
|
||||
var img: Image = tex.get_image() if tex != null else null
|
||||
if img == null:
|
||||
_respond(_err(rid, has_id, "viewport texture image was null"))
|
||||
return
|
||||
var err: int = img.save_png(rel)
|
||||
if err != OK:
|
||||
_respond(_err(rid, has_id, "save_png failed (err %d) for %s" % [err, rel]))
|
||||
return
|
||||
var body: Dictionary = _ack(rid, has_id)
|
||||
body["path"] = ProjectSettings.globalize_path(rel)
|
||||
body["size"] = [img.get_width(), img.get_height()]
|
||||
_respond(body)
|
||||
|
||||
|
||||
func _do_open_screen(req: Dictionary, rid: int, has_id: bool) -> void:
|
||||
var screen: String = String(req.get("screen", ""))
|
||||
var path: String = _screen_path(screen)
|
||||
if path.is_empty():
|
||||
_respond(_err(rid, has_id, "unsupported screen (top-level only so far): " + screen))
|
||||
return
|
||||
var controller: Node = get_tree().current_scene
|
||||
if controller == null or not controller.has_method("change_scene"):
|
||||
_respond(_err(rid, has_id, "no scene controller available for open_screen"))
|
||||
return
|
||||
controller.call("change_scene", path)
|
||||
_respond(_ack(rid, has_id))
|
||||
|
||||
|
||||
func _screen_path(screen: String) -> String:
|
||||
## Top-level scenes reachable via Main.change_scene. In-game panels
|
||||
## (city_screen, tech_tree) are HUD overlays gated on live game state — a
|
||||
## follow-up adds those via the HUD's open methods.
|
||||
match screen:
|
||||
"main_menu":
|
||||
return "res://engine/scenes/menus/main_menu.tscn"
|
||||
"game_setup":
|
||||
return "res://engine/scenes/menus/game_setup.tscn"
|
||||
_:
|
||||
return ""
|
||||
|
||||
|
||||
func _ack(rid: int, has_id: bool) -> Dictionary:
|
||||
var body: Dictionary = {"ok": true}
|
||||
if has_id:
|
||||
body["id"] = rid
|
||||
return body
|
||||
|
||||
|
||||
func _err(rid: int, has_id: bool, message: String) -> Dictionary:
|
||||
var body: Dictionary = {"ok": false, "error": {"code": "error", "message": message}}
|
||||
if has_id:
|
||||
body["id"] = rid
|
||||
return body
|
||||
|
||||
|
||||
func _respond(body: Dictionary) -> void:
|
||||
if _peer == null or _peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
||||
return
|
||||
_peer.put_data((JSON.stringify(body) + "\n").to_utf8_buffer())
|
||||
|
|
@ -34,6 +34,7 @@ ModLoader="*res://engine/src/autoloads/mod_loader.gd"
|
|||
ProceduralRenderer="*res://engine/src/world/procedural_renderer.gd"
|
||||
CommsEventDispatcher="*res://engine/src/autoloads/comms_event_dispatcher.gd"
|
||||
ScreenCapture="*res://engine/scenes/tests/capture_screenshot.gd"
|
||||
MCPRenderDriver="*res://engine/src/autoloads/mcp_render_driver.gd"
|
||||
AutoPlay="*res://engine/scenes/tests/auto_play.gd"
|
||||
|
||||
[display]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue