From f91ac2eb9504a743e8c2801495ecc855c9c59267 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 22 May 2026 09:17:12 +0800 Subject: [PATCH] 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 [zoom], camera_status All high-level commands: auto pause, execute, resume --- mod/ONIAgentBridge.cs | 295 +++++++++++++++++++++++++++++++++++++---- tools/oni_api.py | 114 ++++++++++++++-- tools/oni_commander.py | 37 ++++-- 3 files changed, 402 insertions(+), 44 deletions(-) diff --git a/mod/ONIAgentBridge.cs b/mod/ONIAgentBridge.cs index 79b203d..72c0397 100644 --- a/mod/ONIAgentBridge.cs +++ b/mod/ONIAgentBridge.cs @@ -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(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(); - 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(); - 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 { diff --git a/tools/oni_api.py b/tools/oni_api.py index a608b52..c34cefa 100644 --- a/tools/oni_api.py +++ b/tools/oni_api.py @@ -702,24 +702,60 @@ def cmd_assign_job(args): # --------------------------------------------------------------------------- def cmd_build_pipe_line(args): - """Build a pipe line. Usage: build_pipe_line [bridge]""" + """Build a pipe line with crossing mode. Usage: build_pipe_line [mode]""" if len(args) < 5: - print("Usage: build_pipe_line gas|liquid [bridge]") + print("Usage: build_pipe_line gas|liquid [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 [bridge]""" + """Build a wire line with crossing mode. Usage: build_wire_line [mode]""" if len(args) < 5: - print("Usage: build_wire_line regular|heavy|conductive [bridge]") + print("Usage: build_wire_line regular|heavy|conductive [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 [zoom]""" + if len(args) < 2: + print("Usage: camera [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 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 [zoom] Move camera view to coordinates") + print(" camera_status Get current camera position/zoom") + print("") print("=== Batch / Priority (Advanced) ===") print(" batch Execute batch plan") print(" priority_global

Set global default priority") diff --git a/tools/oni_commander.py b/tools/oni_commander.py index 9abf50a..b85c010 100644 --- a/tools/oni_commander.py +++ b/tools/oni_commander.py @@ -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 [bridge]") + print("Usage: build_pipe_line gas|liquid [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 [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 挖掘+建造墙壁(一键拓展房间)") print() print("=== Pipe / Wire Lines ===") - print(" build_pipe_line [bridge]") - print(" t: gas | liquid") - print(" build_wire_line [bridge]") + print(" build_pipe_line [mode]") + print(" t: gas | liquid | mode: line(merge) | cross(bridge)") + print(" build_wire_line [mode]") print(" t: regular | heavy | conductive | heavy_conductive") print() + print("=== Screenshot / Camera ===") + print(" snapshot [file.png] Take screenshot") + print(" camera [zoom] Move camera view") + print() print("All high-level commands auto-pause/resume the game.") else: COMMANDS[cmd](sys.argv[2:])