feat: screenshot API, camera control, pipe/wire crossing detection

New endpoints:
- POST /api/action/screenshot: capture screenshot to file
- GET /api/screenshot/latest: serve PNG binary
- POST /api/action/camera: move camera to (x,y) + set zoom
- GET /api/state/camera: get current camera position/zoom

Pipe/Wire crossing intelligence:
- build_pipe/build_wire now detect existing pipes/wires at each cell
- mode='line' (default): new pipe merges with existing
- mode='cross': auto-places bridges at intersections to avoid connection
- mode='single': one segment

CLI: snapshot [file.png], camera <x> <y> [zoom], camera_status
All high-level commands: auto pause, execute, resume
This commit is contained in:
root
2026-05-22 09:17:12 +08:00
parent 205ff7668a
commit f91ac2eb95
3 changed files with 402 additions and 44 deletions

View File

@ -63,6 +63,13 @@ namespace ONIAgentBridge
var method = ctx.Request.HttpMethod;
var query = ctx.Request.QueryString;
// Special case: serve screenshot PNG binary
if (path == "/api/screenshot/latest" && method == "GET")
{
ServeLatestScreenshot(ctx);
return;
}
string responseJson;
switch (path, method)
@ -139,6 +146,9 @@ namespace ONIAgentBridge
case ("/api/state/saves", "GET"):
responseJson = GetSaves();
break;
case ("/api/state/camera", "GET"):
responseJson = GetCamera();
break;
// --- Cell-level map data ---
case ("/api/state/cell", "GET"):
@ -235,6 +245,12 @@ namespace ONIAgentBridge
case ("/api/action/build_wire", "POST"):
responseJson = ExecuteBuildWire(ctx);
break;
case ("/api/action/screenshot", "POST"):
responseJson = ExecuteScreenshot(ctx);
break;
case ("/api/action/camera", "POST"):
responseJson = ExecuteCamera(ctx);
break;
default:
ctx.Response.StatusCode = 404;
@ -2024,7 +2040,153 @@ namespace ONIAgentBridge
}
// ===================================================================
// Build Pipe Path
// Camera
// ===================================================================
private string GetCamera()
{
try
{
var cam = CameraController.Instance;
if (cam == null) return JsonSerializer.Serialize(new { error = "camera_unavailable" });
var pos = cam.transform.position;
float zoom = 1f;
try { zoom = Camera.main?.orthographicSize ?? 30f; } catch { }
return JsonSerializer.Serialize(new
{
x = pos.x,
y = pos.y,
z = pos.z,
zoom
});
}
catch (Exception e) { return JsonSerializer.Serialize(new { error = e.Message }); }
}
private string ExecuteCamera(HttpListenerContext ctx)
{
try
{
var data = ReadBody<CameraRequest>(ctx);
if (data == null)
return JsonSerializer.Serialize(FailInvalid("invalid_request"));
var cam = CameraController.Instance;
if (cam == null)
return JsonSerializer.Serialize(FailWithReason("camera_unavailable", "Camera not available"));
if (data.x.HasValue && data.y.HasValue)
{
float targetX = data.x.Value;
float targetY = data.y.Value;
cam.transform.position = new UnityEngine.Vector3(targetX, targetY, cam.transform.position.z);
}
if (data.zoom.HasValue)
{
float z = Mathf.Clamp(data.zoom.Value, 5f, 80f);
try { Camera.main.orthographicSize = z; } catch { }
}
PushEvent("camera", "info", "Camera moved", $"Camera to ({data.x},{data.y}) zoom={data.zoom}", "system");
var pos = cam.transform.position;
float currentZoom = 1f;
try { currentZoom = Camera.main?.orthographicSize ?? 30f; } catch { }
return JsonSerializer.Serialize(ActionOk("camera_moved", new
{
x = pos.x,
y = pos.y,
zoom = currentZoom
}));
}
catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); }
}
// ===================================================================
// Screenshot
// ===================================================================
private static string _lastScreenshotPath = null;
private string ExecuteScreenshot(HttpListenerContext ctx)
{
try
{
string dir = System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(SaveLoader.GetActiveSaveFilePath()),
"oni_agent_screenshots");
if (!System.IO.Directory.Exists(dir))
System.IO.Directory.CreateDirectory(dir);
string filename = $"screenshot_{GameClock.Instance?.GetCycle() ?? 0}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}.png";
string path = System.IO.Path.Combine(dir, filename);
string relPath = path;
// Use Unity's ScreenCapture
ScreenCapture.CaptureScreenshot(path);
_lastScreenshotPath = path;
PushEvent("screenshot", "info", "Screenshot taken", filename, "system");
return JsonSerializer.Serialize(ActionOk("screenshot_taken", new
{
filename,
path,
url = $"/api/screenshot/latest"
}));
}
catch (Exception e) { return JsonSerializer.Serialize(FailException(e)); }
}
private void ServeLatestScreenshot(HttpListenerContext ctx)
{
try
{
string path = _lastScreenshotPath;
if (path == null || !System.IO.File.Exists(path))
{
// Fallback: look for most recent screenshot
string dir = System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(SaveLoader.GetActiveSaveFilePath()),
"oni_agent_screenshots");
if (System.IO.Directory.Exists(dir))
{
var files = System.IO.Directory.GetFiles(dir, "*.png")
.OrderByDescending(f => new System.IO.FileInfo(f).LastWriteTime)
.ToArray();
if (files.Length > 0) path = files[0];
}
}
if (path != null && System.IO.File.Exists(path))
{
var bytes = System.IO.File.ReadAllBytes(path);
ctx.Response.ContentType = "image/png";
ctx.Response.ContentLength64 = bytes.Length;
ctx.Response.OutputStream.Write(bytes, 0, bytes.Length);
}
else
{
ctx.Response.StatusCode = 404;
ctx.Response.ContentType = "text/plain";
var buf = Encoding.UTF8.GetBytes("no_screenshot_available");
ctx.Response.OutputStream.Write(buf, 0, buf.Length);
}
}
catch
{
ctx.Response.StatusCode = 500;
}
finally
{
ctx.Response.OutputStream.Close();
}
}
// ===================================================================
// Build Pipe Path — with crossing/bridge detection
// ===================================================================
private string ExecuteBuildPipe(HttpListenerContext ctx)
{
@ -2035,41 +2197,82 @@ namespace ONIAgentBridge
return JsonSerializer.Serialize(FailInvalid("invalid_request"));
string pipeType = data.type ?? "liquid";
string material = data.material ?? "Irons";
bool isBridge = data.bridge ?? false;
string mode = data.mode ?? "line";
// mode: "line" = straight path, "connect" = connect building to network,
// "cross" = explicitly cross existing pipes with bridges, "single" = one segment
string buildingId = pipeType == "gas"
? (isBridge ? "GasConduitBridge" : "GasConduit")
: (isBridge ? "LiquidConduitBridge" : "LiquidConduit");
string conduitId = pipeType == "gas" ? "GasConduit" : "LiquidConduit";
string bridgeId = pipeType == "gas" ? "GasConduitBridge" : "LiquidConduitBridge";
int cellsPlaced = 0;
int bridgesPlaced = 0;
var placements = new List<object>();
if (data.x2.HasValue && data.y2.HasValue)
if (mode == "single" || mode == "segment")
{
// Single segment at (x1,y1), optional direction and bridge
string bid = (data.bridge ?? false) ? bridgeId : conduitId;
placements.Add(new { x = data.x1, y = data.y1, buildingId = bid });
cellsPlaced = 1;
if (data.bridge ?? false) bridgesPlaced = 1;
}
else if (data.x2.HasValue && data.y2.HasValue)
{
int dx = Math.Sign(data.x2.Value - data.x1);
int dy = Math.Sign(data.y2.Value - data.y1);
bool horizontal = dy == 0;
int cx = data.x1, cy = data.y1;
string prevBid = conduitId;
while (cx != data.x2.Value + dx || cy != data.y2.Value + dy)
{
placements.Add(new { x = cx, y = cy, buildingId });
cx += dx; cy += dy;
int cell = Grid.XYToCell(cx, cy);
bool hasCrossing = false;
// Detect existing pipe of same type
if (cell >= 0 && cell < Grid.CellCount)
{
var building = Grid.Objects[cell, (int)ObjectLayer.Building];
if (building != null)
{
string bName = building.name;
if (pipeType == "gas" && (bName == "GasConduit" || bName == "GasConduitBridge"))
hasCrossing = true;
else if (pipeType == "liquid" && (bName == "LiquidConduit" || bName == "LiquidConduitBridge"))
hasCrossing = true;
}
}
string bid;
if (hasCrossing && mode == "cross")
{
// Place bridge to cross without connecting
bid = bridgeId;
bridgesPlaced++;
}
else
{
bid = conduitId;
}
placements.Add(new { x = cx, y = cy, buildingId = bid, crossing = hasCrossing });
cellsPlaced++;
cx += dx; cy += dy;
if (cellsPlaced > 100) break;
}
}
else
{
placements.Add(new { x = data.x1, y = data.y1, buildingId });
placements.Add(new { x = data.x1, y = data.y1, buildingId = conduitId });
cellsPlaced = 1;
}
return JsonSerializer.Serialize(ActionOk("pipe_build_queued", new
{
type = pipeType,
bridge = isBridge,
mode,
segmentCount = cellsPlaced,
bridgesPlaced,
segments = placements
}));
}
@ -2077,7 +2280,7 @@ namespace ONIAgentBridge
}
// ===================================================================
// Build Wire Path
// Build Wire Path — with crossing/bridge detection
// ===================================================================
private string ExecuteBuildWire(HttpListenerContext ctx)
{
@ -2088,20 +2291,35 @@ namespace ONIAgentBridge
return JsonSerializer.Serialize(FailInvalid("invalid_request"));
string wireType = data.type ?? "regular";
bool isBridge = data.bridge ?? false;
string mode = data.mode ?? "line";
string buildingId = wireType switch
string conduitId = wireType switch
{
"heavy" => isBridge ? "HeaviWatBridge" : "HeaviWatWire",
"conductive" => isBridge ? "ConductiveWireBridge" : "ConductiveWire",
"heavy_conductive" => isBridge ? "HeaviWatConductiveBridge" : "HeaviWatConductiveWire",
_ => isBridge ? "WireBridge" : "Wire"
"heavy" => "HeaviWatWire",
"conductive" => "ConductiveWire",
"heavy_conductive" => "HeaviWatConductiveWire",
_ => "Wire"
};
string bridgeId = wireType switch
{
"heavy" => "HeaviWatBridge",
"conductive" => "ConductiveWireBridge",
"heavy_conductive" => "HeaviWatConductiveBridge",
_ => "WireBridge"
};
int cellsPlaced = 0;
int bridgesPlaced = 0;
var placements = new List<object>();
if (data.x2.HasValue && data.y2.HasValue)
if (mode == "single" || mode == "segment")
{
string bid = (data.bridge ?? false) ? bridgeId : conduitId;
placements.Add(new { x = data.x1, y = data.y1, buildingId = bid });
cellsPlaced = 1;
if (data.bridge ?? false) bridgesPlaced = 1;
}
else if (data.x2.HasValue && data.y2.HasValue)
{
int dx = Math.Sign(data.x2.Value - data.x1);
int dy = Math.Sign(data.y2.Value - data.y1);
@ -2109,23 +2327,51 @@ namespace ONIAgentBridge
while (cx != data.x2.Value + dx || cy != data.y2.Value + dy)
{
placements.Add(new { x = cx, y = cy, buildingId });
cx += dx; cy += dy;
int cell = Grid.XYToCell(cx, cy);
bool hasCrossing = false;
if (cell >= 0 && cell < Grid.CellCount)
{
var building = Grid.Objects[cell, (int)ObjectLayer.Building];
if (building != null)
{
string bName = building.name;
if (bName == "Wire" || bName == "WireBridge" ||
bName == "HeaviWatWire" || bName == "HeaviWatBridge" ||
bName == "ConductiveWire" || bName == "ConductiveWireBridge")
hasCrossing = true;
}
}
string bid;
if (hasCrossing && mode == "cross")
{
bid = bridgeId;
bridgesPlaced++;
}
else
{
bid = conduitId;
}
placements.Add(new { x = cx, y = cy, buildingId = bid, crossing = hasCrossing });
cellsPlaced++;
cx += dx; cy += dy;
if (cellsPlaced > 100) break;
}
}
else
{
placements.Add(new { x = data.x1, y = data.y1, buildingId });
placements.Add(new { x = data.x1, y = data.y1, buildingId = conduitId });
cellsPlaced = 1;
}
return JsonSerializer.Serialize(ActionOk("wire_build_queued", new
{
type = wireType,
bridge = isBridge,
mode,
segmentCount = cellsPlaced,
bridgesPlaced,
segments = placements
}));
}
@ -2247,7 +2493,8 @@ namespace ONIAgentBridge
internal class SaveRequest { public string name { get; set; } }
internal class LoadRequest { public string name { get; set; } }
internal class AssignJobRequest { public string duplicantId { get; set; } public string choreGroup { get; set; } public string buildingId { get; set; } }
internal class PipeWireRequest { public int x1 { get; set; } public int y1 { get; set; } public int? x2 { get; set; } public int? y2 { get; set; } public string type { get; set; } public string material { get; set; } public bool? bridge { get; set; } }
internal class PipeWireRequest { public int x1 { get; set; } public int y1 { get; set; } public int? x2 { get; set; } public int? y2 { get; set; } public string type { get; set; } public string material { get; set; } public bool? bridge { get; set; } public string mode { get; set; } }
internal class CameraRequest { public int? x { get; set; } public int? y { get; set; } public float? zoom { get; set; } }
internal class BatchRequest
{

View File

@ -702,24 +702,60 @@ def cmd_assign_job(args):
# ---------------------------------------------------------------------------
def cmd_build_pipe_line(args):
"""Build a pipe line. Usage: build_pipe_line <gas|liquid> <x1> <y1> <x2> <y2> [bridge]"""
"""Build a pipe line with crossing mode. Usage: build_pipe_line <gas|liquid> <x1> <y1> <x2> <y2> [mode]"""
if len(args) < 5:
print("Usage: build_pipe_line gas|liquid <x1> <y1> <x2> <y2> [bridge]")
print("Usage: build_pipe_line gas|liquid <x1> <y1> <x2> <y2> [mode]")
print(" mode: 'line' (default, auto-merge), 'cross' (use bridges at intersections), 'single' (one segment)")
return
payload = {"type": args[0], "x1": int(args[1]), "y1": int(args[2]),
"x2": int(args[3]), "y2": int(args[4]), "bridge": len(args) > 5 and args[5] == 'bridge'}
result = api_post('/api/action/build_pipe', payload)
_print_feedback(result)
ptype = args[0]
x1, y1, x2, y2 = int(args[1]), int(args[2]), int(args[3]), int(args[4])
mode = args[5] if len(args) > 5 else 'line'
if mode not in ('line', 'cross', 'single'):
print("[!] mode must be 'line', 'cross', or 'single'")
return
result = api_post('/api/action/build_pipe', {
"type": ptype, "x1": x1, "y1": y1,
"x2": x2, "y2": y2, "mode": mode
})
if result.get('success'):
d = result.get('data', {})
segs = d.get('segmentCount', 0)
bridges = d.get('bridgesPlaced', 0)
xing = sum(1 for s in d.get('segments', []) if s.get('crossing'))
print(f"[OK] {ptype} pipe: {segs} segments, {bridges} bridges, {xing} crossings handled")
if xing > 0:
print(f" Mode '{mode}': {'bridges used at crossings' if mode == 'cross' else 'merged at crossings'}")
else:
_print_feedback(result)
def cmd_build_wire_line(args):
"""Build a wire line. Usage: build_wire_line <type> <x1> <y1> <x2> <y2> [bridge]"""
"""Build a wire line with crossing mode. Usage: build_wire_line <type> <x1> <y1> <x2> <y2> [mode]"""
if len(args) < 5:
print("Usage: build_wire_line regular|heavy|conductive <x1> <y1> <x2> <y2> [bridge]")
print("Usage: build_wire_line regular|heavy|conductive <x1> <y1> <x2> <y2> [mode]")
print(" mode: 'line' (default, auto-merge), 'cross' (use bridges at intersections), 'single' (one segment)")
return
payload = {"type": args[0], "x1": int(args[1]), "y1": int(args[2]),
"x2": int(args[3]), "y2": int(args[4]), "bridge": len(args) > 5 and args[5] == 'bridge'}
result = api_post('/api/action/build_wire', payload)
_print_feedback(result)
wtype = args[0]
x1, y1, x2, y2 = int(args[1]), int(args[2]), int(args[3]), int(args[4])
mode = args[5] if len(args) > 5 else 'line'
if mode not in ('line', 'cross', 'single'):
print("[!] mode must be 'line', 'cross', or 'single'")
return
result = api_post('/api/action/build_wire', {
"type": wtype, "x1": x1, "y1": y1,
"x2": x2, "y2": y2, "mode": mode
})
if result.get('success'):
d = result.get('data', {})
segs = d.get('segmentCount', 0)
bridges = d.get('bridgesPlaced', 0)
xing = sum(1 for s in d.get('segments', []) if s.get('crossing'))
print(f"[OK] {wtype} wire: {segs} segments, {bridges} bridges, {xing} crossings handled")
else:
_print_feedback(result)
def _print_feedback(result):
"""Pretty-print action feedback."""
@ -734,6 +770,52 @@ def _print_feedback(result):
if sug:
print(f" -> {sug}")
# ---------------------------------------------------------------------------
# Screenshot / Camera
# ---------------------------------------------------------------------------
def cmd_snapshot(args):
"""Take a screenshot and save locally. Usage: snapshot [output.png]"""
output = args[0] if args else f"oni_snapshot_{int(__import__('time').time())}.png"
result = api_post('/api/action/screenshot', {})
if not result.get('success'):
print(f"[!] Screenshot failed: {result.get('errorMessage', '')}")
return
filename = result.get('data', {}).get('filename', '?')
print(f"[OK] Screenshot taken: {filename}")
# Download the image
try:
import urllib.request
from oni_api import api_url
url = api_url('/api/screenshot/latest')
urllib.request.urlretrieve(url, output)
print(f"[OK] Saved to {output} ({os.path.getsize(output)} bytes)")
except Exception as e:
print(f"[!] Download failed: {e}")
def cmd_camera(args):
"""Control camera. Usage: camera <x> <y> [zoom]"""
if len(args) < 2:
print("Usage: camera <x> <y> [zoom]")
print(" zoom: 5 (close) to 80 (far), default 30")
return
x, y = int(args[0]), int(args[1])
zoom = float(args[2]) if len(args) > 2 else None
payload = {"x": x, "y": y}
if zoom is not None:
payload["zoom"] = zoom
result = api_post('/api/action/camera', payload)
_print_feedback(result)
def cmd_camera_status(args):
"""Get current camera position. Usage: camera_status"""
data = api_get('/api/state/camera')
if 'error' in data:
print(f"Error: {data['error']}")
return
print(f"Camera at ({data.get('x', 0):.0f}, {data.get('y', 0):.0f}), zoom={data.get('zoom', 30):.0f}")
def cmd_explore(args):
"""AI-friendly exploration: reads a region and returns structured text summary."""
x = int(args[0]) if len(args) > 0 else 0
@ -849,6 +931,9 @@ COMMANDS = {
'assign_job': cmd_assign_job,
'build_pipe_line': cmd_build_pipe_line,
'build_wire_line': cmd_build_wire_line,
'snapshot': cmd_snapshot,
'camera': cmd_camera,
'camera_status': cmd_camera_status,
}
if __name__ == '__main__':
@ -920,6 +1005,11 @@ if __name__ == '__main__':
print(" assign_job <name> <job> Assign dupe to a chore group")
print(" morale Duplicant morale levels")
print("")
print("=== Screenshot / Camera ===")
print(" snapshot [file.png] Take screenshot + save locally")
print(" camera <x> <y> [zoom] Move camera view to coordinates")
print(" camera_status Get current camera position/zoom")
print("")
print("=== Batch / Priority (Advanced) ===")
print(" batch <json_file> Execute batch plan")
print(" priority_global <t> <p> Set global default priority")

View File

@ -141,13 +141,14 @@ def cmd_fix_overload(args):
def cmd_build_pipe_line(args):
"""铺设管道路径。"""
"""铺设管道路径(带交叉模式)"""
if len(args) < 5:
print("Usage: build_pipe_line gas|liquid <x1> <y1> <x2> <y2> [bridge]")
print("Usage: build_pipe_line gas|liquid <x1> <y1> <x2> <y2> [mode]")
print(" mode: 'line'(默认,与已有管线合并) | 'cross'(跨接器跳过) | 'single'(单段)")
return
ptype = args[0]
x1, y1, x2, y2 = int(args[1]), int(args[2]), int(args[3]), int(args[4])
bridge = len(args) > 5 and args[5] == 'bridge'
mode = args[5] if len(args) > 5 else 'line'
pause_before()
@ -157,11 +158,15 @@ def cmd_build_pipe_line(args):
result = api_post('/api/action/build_pipe', {
"type": ptype, "x1": x1, "y1": y1,
"x2": x2, "y2": y2, "bridge": bridge
"x2": x2, "y2": y2, "mode": mode
})
if result.get('success'):
segs = result.get('data', {}).get('segmentCount', 0)
d = result.get('data', {})
segs = d.get('segmentCount', 0)
bridges = d.get('bridgesPlaced', 0)
print(f"[OK] {ptype} pipe: {segs} segments from ({x1},{y1}) to ({x2},{y2})")
if bridges > 0:
print(f" {bridges} bridges placed at crossings (mode={mode})")
else:
print(f"[!] Failed: {result.get('errorMessage', 'unknown')}")
@ -295,8 +300,20 @@ COMMANDS = {
'emergency_o2': cmd_emergency_o2,
'build_pipe_line': cmd_build_pipe_line,
'build_wire_line': cmd_build_wire_line,
'snapshot': cmd_snapshot,
'camera': cmd_camera,
}
def cmd_snapshot(args):
"""Take screenshot + view. Usage: snapshot [name]"""
from oni_api import cmd_snapshot as api_snapshot
api_snapshot(args)
def cmd_camera(args):
"""Move camera. Usage: camera <x> <y> [zoom]"""
from oni_api import cmd_camera as api_camera
api_camera(args)
if __name__ == '__main__':
cmd = sys.argv[1] if len(sys.argv) > 1 else 'help'
@ -316,11 +333,15 @@ if __name__ == '__main__':
print(" expand_base <x> <y> <w> <h> 挖掘+建造墙壁(一键拓展房间)")
print()
print("=== Pipe / Wire Lines ===")
print(" build_pipe_line <t> <x1> <y1> <x2> <y2> [bridge]")
print(" t: gas | liquid")
print(" build_wire_line <t> <x1> <y1> <x2> <y2> [bridge]")
print(" build_pipe_line <t> <x1> <y1> <x2> <y2> [mode]")
print(" t: gas | liquid | mode: line(merge) | cross(bridge)")
print(" build_wire_line <t> <x1> <y1> <x2> <y2> [mode]")
print(" t: regular | heavy | conductive | heavy_conductive")
print()
print("=== Screenshot / Camera ===")
print(" snapshot [file.png] Take screenshot")
print(" camera <x> <y> [zoom] Move camera view")
print()
print("All high-level commands auto-pause/resume the game.")
else:
COMMANDS[cmd](sys.argv[2:])