Raycasting 3D engine + metaverse server + map editor + AI Agent platform.
从 2D 网格地图渲染第一人称视角。提供引擎库、地图编辑器、多人元宇宙、AI Agent 平台。
┌─────────────────────────────────────────────────────────────────────────┐
│ GhostEngine Metaverse v2 │
│ 同进程架构 · 无网络 · 共享 WorldState │
└─────────────────────────────────────────────────────────────────────────┘
┌───────────────┐
│ 地图编辑器 │
│ (PySide6) │
│ │
│ 传送门全地图扫描│
│ 跨图即时双向配对│
│ 墙壁-实体互斥 │
│ 定时自动保存 │
│ 越界幽灵清理 │
└──────┬────────┘
│ save/load + validate
▼
┌─────────────────┐
│ .json 地图文件 │
│ examples/*.json │
└────────┬────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ launch.py │ │ runner.pyw │ │ headless_agent.py│
│ launcher.py │ │ 单机预览(Ctrl+R) │ │ 无头调试模式 │
└────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘
│ │ │
└─────────────────────��─────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────┐
│ metaverse 核心 │
│ ┌─────────────┐ ┌─────────────┐ ┌──────────────────────────────┐ │
│ │local_client │ │local_agent │ │ server.py │ │
│ │ (pygame) │ │ (omp) │ │ say/move/goto/turn/pos │ │
│ │ 跨图深拷贝 │ │ 0.3s 轮询 │ │ pickup/place/give/look │ │
│ └──────┬──────┘ └──────┬──────┘ │ set_entity/edit_map/set_cell│ │
│ └────────┬───────┘ │ 传送门坐标越界保护 │ │
│ ▼ └──────────────┬───────────────┘ │
│ ┌──────────────────┐ │ │
│ │ WorldState │ ← 唯一权威数据源 │ │
│ │ grid/items/maps │ 碰撞检测 · A*寻路 │ │
│ │ avatars/inv │ 传送门 · 拾取 │ │
│ │ 深拷贝隔离Item │ 越界幽灵自动清理 │ │
│ └──────────────────┘ │ │
└───────────────────────────────────────────────────┼──────────────────┘
│
┌───────────────────────────────────────────────────┼──────────────────┐
│ ghostengine 渲染引擎 │ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ ┌────────────┐ │
│ │ renderer │ │ entity │ │ animation │ │ │ minimap │ │
│ │ 7阶段管线 │ │ 投影数学 │ │ 悬浮/脉动 │ │ │ 小地图渲染 │ │
│ │ 射线投射 │ │ 遮挡裁剪 │ │ 旋转/GIF │ │ │ │ │
│ └────────────┘ └────────────┘ └────────────┘ │ └────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
完整架构图见 docs/ARCHITECTURE.txt
| 文档 | 内容 |
|---|---|
| docs/HANDOFF.md | 现状与待办(接手先看这份) |
| docs/ARCHITECTURE.txt | 完整架构图、数据流、命令通道 |
| docs/SPEC.md | 需求对照(v2 完成项) |
| docs/PROTOCOL-agent-channel.md | Agent 通道协议(给别的程序接入用) |
| docs/DESIGN-agent-channel.md | Agent 通道重写设计(已确认) |
| docs/PLAN-agent-channel.md | Agent 通道重写实施计划(14 个任务已完成) |
环境要求:Python 3.10 – 3.13。Python 3.14 暂不支持(pygame 尚无预编译包)。
pip install git+https://github.com/Offblink/GhostWorld.git依赖:pygame, numpy——上面那条命令会自动装(pip install -e . --no-deps 那种才要自己补)。
编辑器额外需要 PySide6:pip install "ghostworld[editor] @ git+https://github.com/Offblink/GhostWorld.git",
或在克隆目录里 pip install -e ".[editor]"([full] 再加 Pillow)。
装完先跑一次它:会打印装在哪儿、运行时往哪儿写(装在 Program Files 这类只读位置会起不来,这里一眼看得出),
以及该填进 Fungi 的 ghostworld_dir 那一行:
ghostworld --where # 等价:python -m metaverse.launch --where| 症状 | 原因 | 解决 |
|---|---|---|
ModuleNotFoundError: No module named 'distutils.msvccompiler' |
Python 3.14 移除了 distutils,pygame 尚未适配 | 降级到 Python 3.12 或 3.13 |
SSL: UNEXPECTED_EOF_WHILE_READING |
中国大陆网络无法直连 GitHub / SDL 下载源 | 设置代理 set HTTPS_PROXY=http://127.0.0.1:7890 后重试 |
pygame.error: No available video device |
无头环境(SSH / Docker / WSL 无桌面) | 确认有图形环境;纯命令行使用需装 pygame 后调用引擎 API 不创建窗口 |
| pygame 源码编译失败 | 缺少 Visual Studio Build Tools | 先 pip install pygame 安装预编译 wheel,或安装 VS Build Tools |
中国大陆用户建议:如
pip install git+https://速度极慢或超时,先设置代理再执行安装命令。
python tools/build_exe.py --zip # -> dist/GhostWorld/ 和 dist/GhostWorld-<版本>-win64.zip出一个文件夹、三个 exe(共用同一个 _internal/):
| exe | 控制台 | 用途 |
|---|---|---|
GhostWorld.exe |
无(窗口版) | 双击 = GUI 启动器;--play [地图.json] = 直接进游戏;--editor [目录] 也还能用 |
GhostWorldEditor.exe |
无(窗口版) | 地图编辑器:双击就开,图标和任务栏按钮都是它自己的那枚 |
GhostWorldCLI.exe |
有 | send '<json>'、wait [--timeout N] [--follow]、where、play |
通道 CLI 必须是单独一个控制台 exe:Fungi 靠子进程运行它并读它的 stdout 来驱动角色,
窗口版进程没有 stdout,那条线会静默失效。三个 exe 共用一个 _internal/,所以第三个只多出自己的
启动器和字节码包,不会多带一份 Qt。
| 事实 | 说明 |
|---|---|
| 运行时写哪儿 | 打包版写 %LOCALAPPDATA%\GhostWorld\(地图、states/、.channel.json、两个 jsonl、snapshots/);源码检出仍写在代码旁边。exe 所在目录可以只读,装在 Program Files 也没事 |
| 首次启动 | 把内置的两张 demo 图拷进用户目录,之后只补不改(你改过的地图不会被覆盖) |
| 版本号 | exe 里带着 pyproject.toml,所以 --where 报的是它打包时的版本,不是残留的 pip 元数据 |
| 更新 | 打包版不 pip install 自己:_update_check 会提示去下载新 exe |
| 接 Fungi | ghostworld_dir 指向 exe 所在目录,通道命令换成 GhostWorldCLI.exe send/wait;GhostWorldCLI.exe where 会把这一行直接打出来 |
图标两枚,故意不一样(���个窗口会在任务栏挨着):
| 文件 | 用在哪 | 长什么样 |
|---|---|---|
assets/ghostworld.ico |
启动器 / 游戏 / 两个 exe 的内嵌图标 | 👻 在夜雾色圆角方块上(emoji 字形,Qt6 才有彩色) |
assets/ghostworld-editor.ico |
编辑器窗口 | 画出来的:编辑器自己那套墙壁调色板摆成 3×3,中心格空着、套上选中框 #0af |
两枚都是 16/24/32/48/64/128/256 逐档渲染(不是把大图缩小),小尺寸另有简化画法(编辑器 16/24 px 用 2×2 四块)。 重新生成:
python tools/make_icon.py --app launcher # assets/ghostworld.ico + .png
python tools/make_icon.py --app editor # assets/ghostworld-editor.ico + .pngpip cache purge && pip install --upgrade git+https://github.com/Offblink/GhostWorld.git(打包版不适用——它没有 pip;_update_check 对 exe 会改成提示下载新版 exe。)
⛔ 开发禁令:禁止 WebSocket 协议与 asyncio 网络 I/O;允许
127.0.0.1上的同步 socket + 线程向队列投递。 Windows 上 WebSocket 存在未修复的严重 bug,曾导致项目崩溃、数据丢失;MCP 同样禁用。 病因是「Windows WS + 异步网络 + 跨线程改世界」,所以还有一条硬约束:WorldState只在帧循环 线程里被修改,通道线程只能把命令投进cmd_queue、把事件投进EventBus。
启动时 launch.py 设置 sys.dont_write_bytecode = True,全程不产生 .pyc 文件。
每次运行从 .py 源码直接编译,彻底杜绝 __pycache__ 导致的旧代码污染。
如需手动清理历史残留,运行 pyclean(项目附带)。
python -m metaverse.launch # 默认地图
python -m metaverse.launch my_map.json # 指定地图
python launcher.pyw # GUI 启动器(需 PySide6)
python headless_player.py # 无头联调:服务 + 一个会说活的玩家
python headless_player.py my_map.json --say 你好 --after 3examples/ 里有两张互相配对的 demo 图(demo_metaverse.json ↔ demo_metaverse2.json):走到门口就会被送到对面那张的门口,
再走回去也能回来。
其中 demo_metaverse.json 是一张元素样板图:16×16,三进的室内(每进之间一道墙、各留一个窄门),一进摆 8 种
墙型(各一块,交错成两排)、二进是内柱与物品、三进深处才到那扇门。元素覆盖:可拾取物品与 pickup_label、三种动画
(float / pulse / rotation)、两种遮挡(center / per_column)、capture_for、metadata、隐形实体、
带 dialogue 的 NPC。配色是它自己那套夜雾幽绿(天空/地板/八种墙色都与隔壁那张不同)。没有贴图资源,实体按引擎的
几何画法渲染;那扇门通 demo_metaverse2.json——对面是一间 10×10 的明亮小房间(亮天蓝 + 亮草绿),
里面只有一扇回程门,走回去就回到这里。
Agent(omp、别的 harness、任何 bot)通过本机通道驱动自己的角色:游戏进程内起一个
127.0.0.1 的 socket 服务,端口与 token 落在 metaverse/.channel.json。命令不用轮询,
发一条拿一个 ack;等唤醒是阻塞的:
ghostworld-send '{"cmd":"pos"}' # 打印 ack JSON,退出码 0
ghostworld-send '{"cmd":"say","message":"来了"}'
ghostworld-wait --timeout 25 # 阻塞:玩家一发言就打印一行事件 JSON
ghostworld-wait --all --follow # 常驻观察者(含 see/goto_done 等非唤醒事件)两个 CLI 的 stdin/stdout/stderr 一律 UTF-8(与 Windows 控制台代码页无关),事件行一出就落地—— 逐行读、直接按 UTF-8 解码即可,中文不会变乱码。
唤醒语义:Agent 常驻 ghostworld-wait,玩家发言即被唤醒;不发言时零开销(阻塞在 socket 上,
不轮询、不烧 CPU、不烧 token)。思考期间玩家说的第二句会在通道里排队,下次 wait 一次性拿到。玩家按下空格暂停、或正在聊天输入框里打字时,客户端不跑帧循环,命令会被当场回绝({"type":"error","reason":…},退出码仍是 0)——那是暂停,不是通道坏了。
| 退出码 | ghostworld-send |
ghostworld-wait |
|---|---|---|
| 0 | 收到 ack | 打印了事件 |
| 1 | 连上了但没有 ack(帧循环没在跑) | — |
| 2 | 连不上(游戏没在跑)/用法错误 | 同左 |
| 3 | — | 超时:期限内没有事件 |
命令的 JSON 与下面的文件通道完全一致(cmd + 参数)。旧文件通道仍可用:向
metaverse/agent_commands.jsonl 写一行 JSON,agent 每 0.3s 读一次并交给帧循环执行(兼容层,
写进去的命令不再有读后即删的竞态)。
{"cmd":"say","message":"hello"}
{"cmd":"move","x":10,"y":3}
{"cmd":"goto","x":5,"y":5}
{"cmd":"turn","x":10,"y":5}
{"cmd":"pos"}
{"cmd":"inv"}
{"cmd":"look"}
{"cmd":"pickup"}
{"cmd":"place","item_id":"gem_B","x":3.5,"y":3.5}
{"cmd":"give","target":"player","item_id":"token"}
{"cmd":"edit_map","operations":[
{"op":"set_cell","x":5,"y":5,"wall":1},
{"op":"set_grid","x":0,"y":0,"grid":[[0,0],[0,0]]},
{"op":"set_color","key":"sky_top","rgb":[60,60,130]},
{"op":"set_entity","id":"portal_north","prop":"portal_target","value":{"x":10,"y":7}},
{"op":"delete_entity","id":"old_portal"},
{"op":"reload_maps"},
]}
{"cmd":"post_issue","caption":"screenshot","filepath":"snapshots/omp_1234567890.png"}
{"cmd":"snapshot","caption":"view"}
{"cmd":"dump_map"}dump_map 输出当前地图的完整状态:网格矩阵、items、avatars、预加载地图列表。适合调试跨地图传送。
python metaverse/tools/listen.py --once # 单次扫描,打印玩家新消息
python metaverse/tools/listen.py # 持续 tail(默认每 5 秒)玩家在游戏里说的话 → agent_output.jsonl 的 heard 事件(kind=wake)。
推荐直接用 ghostworld-wait 阻塞等唤醒——那是零轮询的,listen.py 留给 grep/看日志的场景。
listen.py 的进度按事件 seq 记在 tools/listen_cursor.json(不再用行号),
日志被轮转或被新进程重启都不会丢事件、也不会重放。
| 按键 | 功能 |
|---|---|
W S |
前进 / 后退 |
A D |
左 / 右平移 |
← → |
左右转向 |
| 鼠标 | 转动视角 |
M |
小地图开关 |
F |
全屏切换 |
Enter |
打开聊天输入框(打字期间世界与命令通道一起暂停) |
Space |
暂停 / 继续(屏幕中间显示 PAUSED;暂停期间 Agent 的命令会被回绝) |
L |
Agent 手电筒开关(小地图绿色锥形光照,默认开) |
Esc |
退出 |
from ghostengine import Frame, PlayerView, EntityView, ColorConfig, render
import numpy as np
frame = Frame(
player=PlayerView(x=5, y=5, angle=0, pitch=0),
walls=np.zeros((10, 10), dtype=int),
entities=[],
colors=ColorConfig(),
)
surface = pygame.display.set_mode((800, 600))
render(frame, surface)主要导出:
| 符号 | 说明 |
|---|---|
render(frame, dst) |
核心渲染函数,无状态,每帧调用一次 |
Frame / PlayerView / EntityView |
一帧的完整世界描述 |
ColorConfig / WallDef / FogConfig |
天空、地板、墙壁颜色和雾效配置 |
FirstPersonController |
第一人称控制器,含碰撞检测 |
AnimState / compute_animation() |
动画引擎(悬浮 / 脉动 / 旋转 / GIF 帧) |
TextureLoader |
纹理加载器,带 LRU 缓存 |
load_raw() / save_raw() |
地图 JSON 读写 |
draw_minimap() |
小地图渲染 |
python editor.pyw [项目目录]- 地图列表:列出项目
.json地图,双击加载,Delete 删除 - 场景颜色:天空顶部/底部、地板取色器
- 墙壁属性:墙壁类型(1–8)+ 颜色/贴图
- 物品属性:可拾取、拾取标签、动画(悬浮/脉动/旋转)、贴图、遮挡模式
- 精灵属性:名称、归属、朝向贴图(四向选择)
- 传送门属性:ID 显示 + 目标传送门下拉框(扫描项目全部地图的全部传送门,格式
[地图名] ID);已配对灰显标注;跨图点击即双向配对;换目标自动断旧配对
| 颜色 | 实体类型 |
|---|---|
| 🟣 紫色 | 传送门 |
| 🟡 金色 | 物品 |
| 🔵 蓝色 | NPC / 精灵 |
| ⚪ 青色圆点 + 白线 | 玩家出生点朝向 |
- 左键 = 当前工具主操作;右键 = 擦除墙壁+实体;拖拽 = 连续放置/擦除
- 墙壁与实体互斥:有墙处不能放实体,有实体处不覆盖(提示"��置已被占用")
- 加载时自动检测越界幽灵实体并清理
- Ctrl+Z/Y 撤销/重做;Delete 删除选中;Ctrl+S 保存(未命名弹出对话框);Ctrl+Shift+S 另存为
- Ctrl+R 用预览器打开;编辑器启动时恢复上次地图
- 每 3 秒自动保存已命名地图
{
"version": 3,
"grid": [[0,1,0,1,0], …],
"player_spawn": {"x": 7.5, "y": 7.5, "angle": 0.0},
"entities": [
{
"x": 10.0, "y": 7.0,
"kind": "avatar", "name": "", "owner": "",
"facing": 0.0,
"use_facing": false, "textures": {},
"size_3d": 800, "width_3d": 0.8,
"anim": {"float": {"speed": 0.003, "amp": 0.05}},
"occlusion": "per_column", "texture": "",
"capture_for": "", "portal_target": null,
"metadata": {}
},
{
"x": 7.5, "y": 7.5,
"kind": "item", "pickup": true, "pickup_label": "卷轴",
"size_3d": 140, "width_3d": 0.1,
"occlusion": "center", "texture": "",
"capture_for": ""
},
{
"x": 14.5, "y": 7.5,
"kind": "portal", "id": "portal_0",
"portal_target": {"portal_id": "portal_1", "map": "other.json"},
"size_3d": 150, "width_3d": 0.2,
"occlusion": "center"
}
],| 文件 | 作用 |
|---|---|
launch.py |
一键启动。同时启动服务器、人类客户端、agent |
local_client.py |
人类客户端。pygame 渲染第一人称视角,WASD 移动,Enter 聊天,Space 暂停,M 小地图 |
channel.py |
事件总线:单调 seq + 有界缓冲 + Condition;零 IO,跨线程唯一入口 |
channel_server.py |
通道服务:127.0.0.1 行 JSON socket;线程只碰 cmd_queue 与 EventBus |
channel_client.py |
通道客户端:纯 stdlib、可整文件拷走给别的项目用 |
cli_channel.py |
两个 CLI 入口(ghostworld-send / ghostworld-wait)与退出码 |
local_agent.py |
Agent。同进程运行:读 agent_commands.jsonl(兼容层)投队列;事件走 EventBus,由 FileSink 落 agent_output.jsonl |
headless_player.py |
联调夹具:无头起服务,并连一个人类玩家(可用 --say 让它说一句话)——给外部 Agent 试通道用 |
launch_config.json |
启动配置:玩家名、agent 名、贴图路径 |
| 命令 | 说明 |
|---|---|
say |
发言(global channel) |
move |
移动到指定坐标,服务器校验碰撞 |
goto |
A* 自动导航到目标 |
turn |
转向面对指定坐标(一次性) |
track |
持续面向目标 avatar/item |
pos |
查询当前位置和所在地图 |
pickup |
远程拾取:x,y(必填),可选 item_id |
place |
从背包取出物品放到指定坐标 |
give |
从背包取出物品丢脚下,设 capture_for |
snapshot |
从角色自己的位置与朝向前视渲染一张 PNG(���空 / 地面 / 建筑 / 视野里的人),存 snapshots/;ack 回 {"type":"snapshot_done","local":"<路径>"} |
post_issue |
拍照并发 GitHub Issue(需设 GHOSTENGINE_REPO 环境变量) |
dump_map |
调试:矩阵格式输出完整地图状态 |
edit_map |
批量编辑:set_cell / set_grid / set_color / reload_maps |
set_entity |
创建/修改/删除实体:id,x,y,kind,pickup,pickup_label,visible,delete |
set_cell |
修改单个墙壁:x,y,wall |
set_entity 示例:
{"cmd":"set_entity","id":"gem_1","x":5.5,"y":5.5,"kind":"item","pickup":true,"pickup_label":"宝石"}
{"cmd":"set_entity","id":"gem_1","prop":"pickup_label","value":"新名字"}
{"cmd":"set_entity","id":"gem_1","delete":true}pickup 远程拾取:
{"cmd":"pickup","x":7.0,"y":5.5}
{"cmd":"pickup","x":7.0,"y":5.5,"item_id":"test_gem"}| 问题 | 说明 |
|---|---|
| 不要发表情 | pygame 字体不支持 emoji,显示乱码 |
pytest tests/ -q --ignore=tests/scratch154 个测试,覆盖引擎渲染、实体投影、碰撞检测、地图 I/O、WorldState、Server 协议、跨地图传送、传送门配对/取消/重配对、编辑器验证(越界清理/墙壁重叠)、Item 深拷贝隔离,以及 Agent 通道(事件总线游标/容量/并发、服务端线程边界、端到端唤醒与排队、
暂停或打字时的诚实回绝、跟随子进程的 flush 与 UTF-8 编码、旧文件通道兼容、listen 游标轮转/重启、goto 到脚下坐标不再除零崩帧循环、安装布局与单实例锁的辨识)。
ruff check . # 代码门禁(配置在 pyproject.toml)