"""M6 粗渲染(ass):agent 的**常驻感知层**。纯 Python,不依赖 JVM。 ## 它回答什么(这是它的全部职责) agent 日常游玩问的问题,全是「感知」类,且必须**廉价**: 1. **这里是什么** —— `describe_at` / `column_profile` 2. **挡不挡视线** —— `raycast`(到那个点看得见吗) 3. **那边是什么** —— `raycast`(我瞄着的地方是什么) 4. **前面走得通吗** —— `walkable` / `step_up`(跨台阶、能不能站上去) 5. **周围有什么** —— `surroundings`(有界统计,不是全量倾倒) **它不回答**「该不该走」「哪个更好」「这是不是陷阱」—— 那是决策,留给 agent。 ## 为什么不复用细渲染 细渲染要起 JVM(20-40s)。这些感知问题 agent 每一步都可能问, 用细渲染回答会拖垮整个循环。粗渲染是**独立的感知手段**, 不是细渲染的降级 —— 两者服务不同问题,**不要求像素级一致** (见 plan.md §4.5 的验证口径)。 ## 三条硬纪律 1. **有界**:所有返回都有上限,剩余部分给计数而非静默截断。 2. **不撒谎**:分不清就说分不清(`unloaded` / `unknown-state` / `None`), 绝不为了让回答好看而猜测。 3. **不决策**:给观察,不给建议。 """ from __future__ import annotations import math from typing import Any from .state import WorldState # ---------- 体素遍历(Amanatides & Woo) ---------- def raycast( world: WorldState, ox: float, oy: float, oz: float, dx: float, dy: float, dz: float, max_distance: float = 32.0, ) -> dict[str, Any]: """从 (ox,oy,oz) 沿方向投射,返回**第一个挡路的方块**。 ## 返回形状 ```python {"hit": bool, "block": str|None, "state_id": int|None, "position": (x,y,z)|None, "normal": (nx,ny,nz)|None, "distance": float, "reason": str, # 没命中时说明为什么 "steps": int, "truncated": bool} ``` ## 为什么要分「命中 / 没命中 / 走不完」 三者含义完全不同,混在一起会让 agent 误判: - `hit=True` —— 路上有东西(要么视线被挡,要么前方是实体位置) - `hit=False, reason="clear"` —— 确认通畅 - `hit=False, reason="unloaded"` —— **这段没加载**,不能当通畅 (把「没见过」当「空旷」是这类工具最典型的撒谎) ## 算法 Amanatides & Woo 的体素遍历:逐格前进,不采样不插值。 ★ 步进**必须**逐格做;用「每 N 格采样一次」会穿过薄墙(半砖、栅栏), 得到「明明有墙却说通畅」的错误结论。 """ dx, dy, dz = float(dx), float(dy), float(dz) norm = math.sqrt(dx * dx + dy * dy + dz * dz) if norm == 0.0: return _no_hit(0.0, "zero_direction", 0, False) dx, dy, dz = dx / norm, dy / norm, dz / norm x, y, z = int(math.floor(ox)), int(math.floor(oy)), int(math.floor(oz)) step_x = 1 if dx > 0 else (-1 if dx < 0 else 0) step_y = 1 if dy > 0 else (-1 if dy < 0 else 0) step_z = 1 if dz > 0 else (-1 if dz < 0 else 0) # 到下一格边界的 t INF = float("inf") t_max_x = ((x + (1 if step_x > 0 else 0)) - ox) / dx if step_x else INF t_max_y = ((y + (1 if step_y > 0 else 0)) - oy) / dy if step_y else INF t_max_z = ((z + (1 if step_z > 0 else 0)) - oz) / dz if step_z else INF t_delta_x = abs(1.0 / dx) if step_x else INF t_delta_y = abs(1.0 / dy) if step_y else INF t_delta_z = abs(1.0 / dz) if step_z else INF # 起点所在格:若已不可通行,也算命中(起点就在墙里) steps = 0 truncated = False LIMIT = 4096 while True: steps += 1 if steps > LIMIT: truncated = True return _no_hit(max_distance, "step_limit", steps, True) # 走到当前格中心 cx, cy, cz = x + 0.5, y + 0.5, z + 0.5 t = math.sqrt((cx - ox) ** 2 + (cy - oy) ** 2 + (cz - oz) ** 2) if t > max_distance: return _no_hit(max_distance, "clear", steps, False) hit = world.block_at(x, y, z) # ★ WorldState.block_at 返回 **dict**(含 source/name/state_id) if hit["source"] == "out-of-world": return _no_hit(t, "out-of-world", steps, False) if hit["source"] == "unloaded": # ★ 明确说「没加载」,绝不当成空气 return _no_hit(t, "unloaded", steps, False) solid = _solid_of(world, hit) if solid: # 命中面:从进入方向反推法线 nx = ny = nz = 0 if step_x: nx = -step_x elif step_y: ny = -step_y else: nz = -step_z return { "hit": True, "block": hit["name"], "state_id": hit["state_id"], "position": (x, y, z), "normal": (nx, ny, nz), "distance": round(t, 3), "reason": "solid", "steps": steps, "truncated": False, } if t > max_distance: return _no_hit(max_distance, "clear", steps, False) # 前进到下一格 if t_max_x < t_max_y and t_max_x < t_max_z: if t_max_x > max_distance: return _no_hit(max_distance, "clear", steps, False) x += step_x t_max_x += t_delta_x elif t_max_y < t_max_z: if t_max_y > max_distance: return _no_hit(max_distance, "clear", steps, False) y += step_y t_max_y += t_delta_y else: if t_max_z > max_distance: return _no_hit(max_distance, "clear", steps, False) z += step_z t_max_z += t_delta_z def _no_hit(dist: float, reason: str, steps: int, truncated: bool) -> dict[str, Any]: return { "hit": False, "block": None, "state_id": None, "position": None, "normal": None, "distance": round(dist, 3), "reason": reason, "steps": steps, "truncated": truncated, } def _solid_of(world: WorldState, hit: dict[str, Any]) -> bool | None: """这个方块挡不挡路?**未知返回 None**(不当成空气)。 ★ WorldState.block_at 返回的是 dict(含 source/name/state_id), 而 World.block_at 返回 BlockHit 对象 —— 两者形状不同。 本模块统一用 WorldState(dict 形状),避免混用。 """ reg = world.world.registry if reg is None: return None return reg.is_solid(hit["state_id"]) def can_see(world: WorldState, target: tuple[float, float, float], eye_height: float = 1.62, max_distance: float = 32.0) -> dict[str, Any]: """从当前位置能否**看见**某点(视线不被挡)。 ★★ 关键点:射线长度必须**只到目标为止**。 我先前写成「射到 max_distance,看第一个碰撞是不是目标格」⇒ 射线会**越过目标继续走**,撞上目标身后的墙 ⇒ 把「看得见」 误报成「被挡」(实测:目标 (1.5,2,0.5),墙在 x=3, 结果报 blocked by stone at (3,2,0))。 ★ 判定顺序也很重要:先看目标格本身是否可穿,再看路上有无阻挡。 """ p = world.player ex, ey, ez = p.x, p.y + eye_height, p.z dx = target[0] - ex dy = target[1] - ey dz = target[2] - ez dist = math.sqrt(dx * dx + dy * dy + dz * dz) if dist > max_distance: return {"visible": False, "reason": "too_far", "distance": round(dist, 3), "max_distance": max_distance} if dist == 0.0: return {"visible": True, "reason": "same_point", "distance": 0.0} # ★ 射程 = 到目标的距离(再放一点点容差,避免浮点误差把目标格本身漏掉) r = raycast(world, ex, ey, ez, dx, dy, dz, max_distance=dist + 1e-6) tx = int(math.floor(target[0])) ty = int(math.floor(target[1])) tz = int(math.floor(target[2])) if r["hit"]: # 命中目标所在格(或目标自身就是实体方块)⇒ 看得见 if r["position"] == (tx, ty, tz): return {"visible": True, "reason": "target_reached", "block": r["block"], "distance": r["distance"]} return {"visible": False, "reason": "blocked", "by": r["block"], "at": r["position"], "distance": r["distance"], "target": (tx, ty, tz)} if r["reason"] == "unloaded": return {"visible": False, "reason": "unknown_unloaded", "note": "这段区块未加载,无法断言可见性"} if r["reason"] == "out-of-world": return {"visible": False, "reason": "out_of_world", "note": "目标在世界外"} # ★ 射线走到目标距离仍未撞到时不能直接说「看得见」: # 必须确认目标格本身已被加载(否则「没见过」≠「空旷」)。 th = world.block_at(tx, ty, tz) if th["source"] in ("unloaded", "out-of-world"): return {"visible": False, "reason": "target_unloaded", "note": f"目标格 {th['source']},无法断言"} return {"visible": True, "reason": "clear", "distance": r["distance"], "target_block": th["name"]} # ---------- 行走(够用即可,不做完整寻路) ---------- #: 玩家能自动跨上的台阶高度(MC 规则:0.5 格) STEP_HEIGHT = 1.0 def can_stand(world: WorldState, x: int, y: int, z: int) -> dict[str, Any]: """能否站在 (x,y,z) 这个**脚部位置**上。 规则(简化但诚实): - 脚下方块必须可站立(solid) - 身体所处两格(脚下 + 头)必须可通行 - 未加载 ⇒ 报 unknown,不当可站 """ below = world.block_at(x, y - 1, z) here = world.block_at(x, y, z) head = world.block_at(x, y + 1, z) if any(b["source"] in ("unloaded", "out-of-world") for b in (below, here, head)): return {"standable": None, "reason": "unloaded_or_out_of_world", "below": below["source"]} reg = world.world.registry if reg is None: return {"standable": None, "reason": "no_registry"} s_below = reg.is_solid(below["state_id"]) s_here = reg.is_solid(here["state_id"]) s_head = reg.is_solid(head["state_id"]) return { "standable": bool(s_below and not s_here and not s_head), "ground": below["name"], "at": here["name"], "head": head["name"], "reason": "ok", } def walkable(world: WorldState, radius: int = 3, max_samples: int = 512) -> dict[str, Any]: """以玩家为中心的**可站立分布**(有界网格)。 输出是一张字符网格 —— 那是 LLM 最省 token 也能读懂的形状。 符号:`.` 可站 / `#` 障碍 / `?` 未知(未加载或认不出) """ p = world.player cx, cz = int(math.floor(p.x)), int(math.floor(p.z)) cy = int(math.floor(p.y)) if radius * 2 + 1 > max_samples: return {"error": "范围过大", "scanned": False, "requested_radius": radius} rows: list[str] = [] unknown = 0 blocked = 0 for dz in range(-radius, radius + 1): row = [] for dx in range(-radius, radius + 1): r = can_stand(world, cx + dx, cy, cz + dz) if r["standable"] is True: row.append(".") elif r["standable"] is False: row.append("#") blocked += 1 else: row.append("?") unknown += 1 rows.append("".join(row)) return { "center": (cx, cy, cz), "radius": radius, "scanned": True, "grid": rows, # 第 0 行 = 最北(z 最小) "legend": ". 可站 / # 障碍 / ? 未知", "stats": {"open": (2 * radius + 1) ** 2 - blocked - unknown, "blocked": blocked, "unknown": unknown}, "row0_is": "z = center_z - radius(最北)", } # ---------- ASCII 切面(agent 最爱看的形状) ---------- _GLYPH = { "air": " ", "cave_air": " ", "void_air": " ", "stone": "S", "dirt": "d", "grass_block": "g", "sand": "s", "bedrock": "B", "water": "~", "lava": "L", "oak_log": "T", "oak_planks": "P", "cobblestone": "c", "gravel": "v", "clay": "y", } def glyph_for(name: str | None) -> str: """方块 → 一个字符。**认不出一律 '?'**(不乱画)。""" if name is None: return "?" if name in _GLYPH: return _GLYPH[name] # 草/花/树叶等:按材质粗分类,也认不出就用 '?' if name.startswith(("oak_", "birch_", "spruce_", "jungle_")): if "leaves" in name: return "F" if "flower" in name or "tall_grass" in name or "fern" in name: return "f" return "T" if name.endswith("_leaves"): return "F" return "?" def slice_view( world: WorldState, axis: str = "z", at: int | None = None, width: int = 16, height: int = 12, max_cells: int = 4096, ) -> dict[str, Any]: """垂直切面的 ASCII 图(agent 判断地形起伏最直观的方式)。 axis: 'x'(东西切面)、'z'(南北切面)、'y'(水平切面) at: 切面所在的另一个水平坐标;省略则用玩家所在位置 ★ 有界:width×height 之外一律不扫;超 max_cells 直接拒绝。 ★ 认不出的方块画 '?',**不**画成看起来合理的形状 —— 一张「看起来对」的错图比一张带问号的图危险得多。 """ if width * height > max_cells: return {"error": f"{width}×{height} 超过上限 {max_cells}", "scanned": False} p = world.player bx, by, bz = int(math.floor(p.x)), int(math.floor(p.y)), int(math.floor(p.z)) if axis not in ("x", "z", "y"): return {"error": f"axis 须为 x/z/y,收到 {axis}", "scanned": False} # ★ 垂直切面要把**脚下**也包含进来:玩家站在地面上时, # 「从脚往上」全是空气 ⇒ 切面一片空白,看不出地形起伏(实测如此)。 # 改为以脚下方为基准往上取,让地面线落在图的下部。 base_y = by - max(2, height // 3) if axis == "x": at = bx if at is None else int(at) cols = [at + i - width // 2 for i in range(width)] rows = [base_y + j for j in range(height)] elif axis == "z": at = bz if at is None else int(at) cols = [at + i - width // 2 for i in range(width)] rows = [base_y + j for j in range(height)] else: ay = by if at is None else int(at) cols = [bx + i - width // 2 for i in range(width)] rows = [bz + i - width // 2 for i in range(width)] lines: list[str] = [] unknown = 0 # 行内自上而下打印(高处在上,符合直觉) for yy in reversed(rows): buf = [] for c in cols: if axis == "x": hit = world.block_at(c, yy, at) elif axis == "z": hit = world.block_at(at, yy, c) else: hit = world.block_at(c, ay, yy) if hit["source"] in ("unloaded", "out-of-world"): unknown += 1 buf.append("?") else: g = glyph_for(hit["name"]) if g == "?": unknown += 1 buf.append(g) lines.append("".join(buf)) return { "scanned": True, "axis": axis, "at": at, "width": width, "height": height, "rows_top_to_bottom": lines, "row0_is": (f"y = {rows[-1]}(最低)" if axis in ("x", "z") else f"z = {rows[-1]}(最南)"), "unknown_cells": unknown, "note": "? = 未加载 / 界外 / 认不出的方块(不猜)", } # ---------- 周边总览(一次回答多个日常问题) ---------- def surroundings(world: WorldState, radius: int = 4) -> dict[str, Any]: """一次回答「我在哪 / 脚踩什么 / 哪能站 / 前面是什么」。 这是 agent 最常问的一组问题,合成一次调用以**省往返**。 """ p = world.player bx, by, bz = int(math.floor(p.x)), int(math.floor(p.y)), int(math.floor(p.z)) look_dir = _look_vector(p.yaw, p.pitch) ahead = raycast(world, p.x, p.y + 1.62, p.z, *look_dir, max_distance=radius * 2.0) return { "player": {"x": p.x, "y": p.y, "z": p.z, "yaw": p.yaw, "pitch": p.pitch, "facing": _facing_name(p.yaw)}, "standing_on": world.block_at(bx, by - 1, bz), "feet": world.block_at(bx, by, bz), "head": world.block_at(bx, by + 1, bz), "can_stand_here": can_stand(world, bx, by, bz), "ahead": {"hit": ahead["hit"], "block": ahead["block"], "distance": ahead["distance"], "reason": ahead["reason"]}, "nearby_kinds": world.nearby_blocks(bx, by, bz, radius=radius), "chunk_columns_loaded": world.world.loaded_count, } def _look_vector(yaw: float, pitch: float) -> tuple[float, float, float]: """yaw/pitch → 单位方向向量(MC 约定:yaw 0 = +Z,yaw 90 = -X)。""" y = math.radians(yaw) p = math.radians(pitch) cp, sp = math.cos(p), math.sin(p) return (-math.sin(y) * cp, -sp, math.cos(y) * cp) def _facing_name(yaw: float) -> str: y = yaw % 360.0 names = {0.0: "南(+Z)", 90.0: "西(-X)", 180.0: "北(-Z)", 270.0: "东(+X)"} best = min(names, key=lambda n: min(abs(y - n), 360 - abs(y - n))) return names[best] if min(abs(y - best), 360 - abs(y - best)) < 45 else f"{y:.0f}°"