新增 mcfold/rough.py:agent 日常游玩的感知层 - raycast(Amanatides & Woo 逐格体素遍历,不采样不插值) - can_see / can_stand / walkable / slice_view / surroundings - BlockRegistry 扩展 solid/transparent(从 blocks.json 读,不编) 修的真 bug: - can_see 射到 max_distance ⇒ 越过目标撞身后的墙,把「看得见」误报「被挡」 → 改为射程只到目标距离 - 切面从脚下往上取 ⇒ 全是空气看不出地形 → 基准下移让地面入画 - walkable 的 ? 漏统计「已加载但认不出名字」的方块 纪律: - 有界(超范围拒绝并说明,不静默缩小) - 不撒谎(unloaded / unknown-state / None 三态分明;把「没见过」当「空旷」 是这层最危险的错) - 不决策(只给观察,不告诉 agent 该走哪) 判据用构造场景(plan §4.5:粗渲染不需要细渲染参与): 单格薄墙、空走廊、未加载区、缺残注册表。 146 项全通过;6 项变异全被抓(其中一项初次未抓到 ⇒ 补判据覆盖面后才抓住)
434 lines
17 KiB
Python
434 lines
17 KiB
Python
"""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}°"
|