固定 URL、席位同步与公共交通路线的一套完整实现

目录
01 · 项目概览
一个运行在 VRChat 世界里的中国地图:把每位玩家所在的城市点亮,并算出彼此之间的公共交通路线。
它解决什么问题
VRChat 世界里聚集着来自不同城市的人,但世界本身并不知道谁在哪。VRCLOC 用一条尽量不打扰玩家的链路,把这个信息补上:
- 进入世界后自动做城市级定位,不需要登记页、不需要输入城市;
- 所有玩家看到同一张中国地图,有人所在的城市会亮起轮廓;
- 同城玩家合并到一个标签里,例如“合肥 / Neko、Aki”;
- 选中另一位玩家,就能看到两地之间的公共交通耗时、票价与换乘方案;
- 自动定位不可靠时,可以在地图旁手动选城市,不依赖服务器。
它不是一个“查 IP 的网页”,而是一套完整的 UdonSharp + Node.js 工程:世界端负责展示与同步,服务端负责把 IP 变成一个城市编号。
功能清单
| 能力 | 说明 |
|---|---|
| 自动定位 | 优先请求 B 站国内 IP 接口,失败后回退到自建服务 |
| 多人同步 | 最多 80 个玩家槽位,城市编号通过 UdonSynced 广播 |
| 地图描边 | 369 个城市轮廓,有人时点亮青色描边与红色标记 |
| 标签避让 | 同城玩家合并;邻近城市标签自动错位,避免重叠 |
| 公共交通 | 369 个出发城市路线包,支持高德真实分段数据 |
| 手动定位 | 加速器/代理导致自动定位错误时,可在世界内手动选城市 |
| 国外识别 | 96 个国家/地区编号;国外只显示文字,不画在地图上 |
| 可移植安装 | 导出 .unitypackage,导入其他世界后可直接改服务器地址 |
一组数字
| 项目 | 数量 | 说明 |
|---|---|---|
| 国内城市 | 369 | 编号 1..369,顺序固定 |
| 国家/地区 | 96 | 编号从 370 开始 |
| 玩家槽位 | 80 | 对应 VRChat PC 实例上限,可裁剪 |
| 路线包 | 369 | 每个出发城市一个 JSON |
| 信标视频 | 466 | 编号 0..465,旧版视频尺寸通道 |
| 地图纹理 | 2048 × 1510 | 墨卡托投影 |
| 城市描边网格 | 369 | City_*.asset |
技术栈
- 世界端:Unity 2022.3.22f1、VRChat SDK3 Worlds、UdonSharp、TextMeshPro
- 服务端:Node.js 22、Fastify 5、
ip2region离线库 - 数据:阿里云 DataV.GeoAtlas 行政区划、高德开放平台(可选)
- 打包:Bun 单文件 exe、Docker、Nginx Proxy Manager、Sakura FRP
目录结构
VRCLOC/
├─ server/ Node.js 服务
│ ├─ src/ 路由、IP 解析、城市匹配、路线包
│ ├─ tools/ 地图、信标、路线包、exe 生成脚本
│ ├─ data/ cities.json、packs/、beacons/、ip2region
│ ├─ dist/ 可直接双击的 VRCLOC.exe
│ └─ test/ Node 原生测试
├─ 瑶瑶小窝/ Unity 工程
│ └─ Assets/VRCLocation/
│ ├─ Scripts/ UdonSharp 运行时脚本
│ ├─ Editor/ 预制件构建、校验、URL 修改、打包
│ ├─ Generated/ config.json、cities.json、geometry.bin
│ ├─ Prefabs/ Map / RoutePanel / Complete
│ └─ Textures/ ChinaMap.png
├─ world/ 指向 Unity 工程的 junction,批处理用
└─ VRCLOC_Installer.unitypackageworld/ 只是为了让 Unity 批处理避开中文路径,正常开发请用 Unity Hub 打开 瑶瑶小窝。
三个关键约束
这个项目的很多设计,都是被 VRChat 的运行环境“逼”出来的。理解这三条,后面的实现就顺了。
1. VRCUrl 只能在编辑器阶段构造
Udon 运行时不能把玩家名、IP、坐标拼进 URL。因此所有 URL 必须在预制件里预先序列化好。
2. Udon 没有任意网络 API
世界端只能使用 VRCStringDownloader、图片下载和视频播放器,不能开 WebSocket、不能发 POST、不能读响应头。
3. 定位只能到城市级
服务端看到的是连接出口 IP。VPN、代理、移动网络、CGNAT 都会让结果偏移,所以地图只展示城市轮廓,不展示街道或精确坐标。
设计取舍
面对这些约束,项目没有尝试“绕过”,而是把问题拆开:
身份 → 由 VRChat 的槽位所有权表达
位置 → 由服务端/国内 IP 接口返回一个城市编号
展示 → 由所有客户端各自读取同步槽位后渲染
路线 → 在构建期预生成 369 个静态 JSON由此得到四个直接结果:
- 没有登记页:玩家进世界就开始定位。
- 没有运行时动态 URL:所有请求地址在预制件里写死。
- 没有玩家名上传:服务器只收到 IP,返回一个整数编号。
- 失败有兜底:自动定位不可信时,玩家可以手动选城市。
阅读路线
| 章节 | 内容 | 适合谁 |
|---|---|---|
| 02 · 系统架构 | 三条链路与关键决策 | 想先看全貌的人 |
| 03 · 定位链路 | B 站优先、服务端回退、多库投票 | 负责定位/后端的人 |
| 04 · 槽位与地图 | 80 席位、Master 分配、标签避让 | UdonSharp 开发者 |
| 05 · 路线系统 | 369 个路线包与高德升级 | 数据/后端开发者 |
| 06 · 服务端实现 | Fastify、缓存、隐私、数据生成 | 后端/运维 |
| 07 · 部署指南 | 本地、exe、Docker、FRP + NPM | 准备上线的人 |
| 08 · 世界内使用 | 导入、放置、改地址、容量 | 世界作者 |
| 09 · 排错与边界 | 常见问题、限制与隐私 | 所有人 |
想一次读完,可以直接看合并稿:wordpress/VRCLOC-完整版.md。
当前状态
仓库里已经包含可运行的数据与预制件:
server/dist/VRCLOC.exe:双击启动,自带 Bun 运行时;VRCLOC_Installer.unitypackage:导入其他世界即可使用;- 默认服务地址:
https://map.u386706.nyat.app:53630; - 默认路线包以估算为主,配置
AMAP_KEY后可升级为真实公共交通。
下一章从整体架构开始,把“定位、同步、路线”三条链路拆开看。
02 · 系统架构
定位只负责得到一个城市编号;同步负责让所有人看见;路线负责把两个编号变成一段行程。

三条链路
VRCLOC 可以拆成三条互相独立的链路。把它们分开,是为了让每一条都能单独失败、单独排查。
链路 A:定位
玩家客户端
→ 首选:B 站 getIpInfo(国内直连)
→ 回退:GET /v1/whereami?slot=N
→ 得到一个城市编号 code链路 B:同步
客户端把 code 写入自己拥有的 VRCLOCSlot
→ RequestSerialization()
→ 其他客户端通过 UdonSynced 看到
→ 地图根据所有槽位重绘链路 C:路线
本地城市编号 98
→ 取 packUrls[97](编辑器内预置)
→ GET /v1/packs/98.json?v=2
→ 按目标城市编号查 routes["73"]
→ 显示耗时、票价、换乘三条链路只在两个整数上汇合:自己的城市编号和对方的城市编号。这也是整个系统最简单的抽象。
为什么这样分层
身份与位置分离
Udon 运行时不能动态拼 URL,但 VRChat 本身提供了另一套能力:网络对象所有权。
于是项目把“我是谁”交给 VRChat:
- 每个玩家拥有一个
VRCLocationSlot; - 槽位里有
playerId、locationCode、occupied; - 其他客户端读槽位就知道“谁在哪”。
服务器完全不需要知道玩家名,也不需要维护会话。它只回答一个问题:
GET /v1/whereami?slot=23{
"ok": true,
"code": 98,
"kind": "cn",
"city": "合肥市",
"province": "安徽省",
"on_map": true,
"source": "amap",
"proxy_suspect": false
}slot 参数甚至不参与定位计算,它只是让每个席位的 URL 不同,避免共享缓存。
服务器只返回编号
这是隐私和简洁性的共同选择:
- 玩家名不上传;
- 不建立玩家数据库;
- 不持久化 IP;
- 返回体里最多包含城市名、省份、数据源和可靠性标记。
地图端拿到 code 后,用本地 cities.json 查名字和坐标。即使服务端只返回一个整数,世界也能完整渲染。
路线在构建期完成
Udon 不能动态拼 URL,所以路线不能做成 /v1/route?from=98&to=73。项目改成:
- 为每个出发城市生成一个静态包:
data/packs/98.json; - 预制件里预置 369 个固定
VRCUrl; - 运行时只做“取下标 + 查字典”。
高德实时查询只保留在服务端 /v1/route,用于调试和数据升级,不进入世界端主链路。
失败要能降级
| 失败点 | 降级方式 |
|---|---|
| B 站接口失败 | 回退到自建服务器 |
| 服务器第一次结果异常 | 用同席位 &r=2 再请求一次 |
| 多个 IP 库分歧 | 按省份/国家投票,并标记 proxy_suspect |
| 自动定位不可信 | 地图不画错误位置,提示手动选择 |
| 路线包下载失败 | 路线框显示错误提示,不影响地图 |
| 某个玩家没有槽位 | 路线框显示“槽位同步中” |
世界端的组件
| 组件 | 职责 | 同步模式 |
|---|---|---|
VRCLocationBeacon | 发起定位请求、解析结果、重试、诊断 | None |
VRCLocationRegistry | 管理 80 个槽位、Master 分配、本地状态 | None |
VRCLocationSlot | 保存单个玩家的城市编号 | Manual |
VRCLocationMap | 读取槽位、点亮城市、放置标签 | None |
VRCLocationRoutePanel | 下载路线包、显示文本、处理按钮 | None |
组件之间是“引用 + 轮询”的关系,而不是事件总线。UdonSharp 里事件和委托的限制较多,轮询反而更稳:
Registry每 0.75 秒把本地状态写入槽位;- 每 1.5 秒由 Master 检查一次槽位分配;
Map每 0.6 秒刷新一次;RoutePanel每 0.5 秒刷新目标列表与文本。
这些间隔都留了余量,避免每帧遍历 80 个对象。
服务端的结构
src/server.js HTTP 路由、限流、响应头
src/ip.js IP 规范化、私网判断、多库投票、代理诊断
src/cities.js 省市名归一化、城市编号匹配
src/amap.js 高德 IP 定位与公共交通
src/baidu.js 百度 IP 库
src/ipapi.js ip-api(分歧时投票)
src/routepacks.js 路线包读取、按需生成
src/beacon.js 视频尺寸编码(旧通道)
src/estimate.js 球面距离估算服务端没有数据库。所有状态要么在文件里,要么在进程内缓存里:
- 城市表、路线包、信标:启动后按需读取;
- IP 查询结果:进程内 LRU,默认 6 小时;
- 限流桶:进程内,随机盐哈希,重启即失效;
- 路线包按需生成:写入
data/packs/,下次直接读。
关键代码:配置只有一个入口
Assets/VRCLocation/Generated/config.json 决定预制件里所有 URL 的前缀:
{
"baseUrl": "https://loc.example.com",
"prefabName": "VRCLOC_Map",
"routePrefabName": "VRCLOC_RoutePanel"
}构建器会把它展开成:
定位回退:{baseUrl}/v1/whereami?slot=1 .. 80
重试: {baseUrl}/v1/whereami?slot=1&r=2 .. 80
视频旧通道:{baseUrl}/v1/beacon.mp4?slot=1 .. 80
路线包: {baseUrl}/v1/packs/1.json?v=2 .. 369.json?v=2改域名时,优先用菜单 VRCLocation / 8. Change Server URL…,它会同时改写三个预制件和当前场景实例;只有改地图尺寸、字体或城市数据时,才需要执行菜单 2 完整重建。
一句话记住
世界端通过固定 URL 向 B 站或自建服务要一个城市编号;编号写入自己拥有的槽位,由 VRChat 同步给同一实例的其他客户端;每个客户端拿到编号后各自渲染地图。服务端只参与“IP → 编号”,不参与玩家之间的同步。
下一章进入定位链路:为什么先请求 B 站、服务端如何投票、什么情况下应该放弃自动结果。
03 · 定位链路
先拿得到,再判断能不能信。自动定位不可信时,明确告诉玩家,而不是画一个自信的错误位置。

为什么不能直接把 IP 拼进 URL
Udon 的 VRCUrl 只能在编辑器阶段构造。运行时即使拿到了玩家 IP,也无法生成:
https://loc.example.com/v1/whereami?ip=1.2.3.4 ❌ 运行时拼不出来所以项目反过来做:URL 固定,让服务器自己看连接 IP。
https://loc.example.com/v1/whereami?slot=23 ✅ 预制件里写死slot 不参与计算,只用于让每个席位的 URL 不同。这样即使 VRChat 或 CDN 忽略了 Cache-Control: no-store,也不会把 23 号玩家的结果缓存给 24 号。
第一优先级:B 站国内 IP 接口
新版预制件不再一上来就请求自己的服务器,而是先请求:
https://api.live.bilibili.com/xlive/web-room/v1/index/getIpInfo?slot=N这是一个公开的国内接口,返回结构大致是:
{
"code": 0,
"data": {
"country": "中国",
"province": "安徽省",
"city": "合肥市",
"isp": "中国电信"
}
}为什么是它
游戏加速器(例如 UU)常见的“进程模式”只接管 VRChat 的游戏服务器流量,不一定接管这条普通 HTTPS 请求。B 站接口在国内,因此这条请求往往能直连,B 站看到的就是玩家真实的出口 IP,而不是加速器的日本节点。
这解决了一个很常见的问题:
玩家 → 游戏加速器日本节点 → VRChat 世界
玩家 → 国内直连 → B 站接口 ← 这条仍能看到真实城市同样的思路也被其他 VRChat 世界使用(例如 Reality Anchor)。
客户端如何匹配城市
B 站只返回省市名,世界端需要把它变成 1..369 的编号。VRCLocationMap.FindCityCode 做三层匹配:
- 省市同时匹配:先在同省内找同名城市,避免“吉林市/吉林省”“海南/海南州”这类歧义;
- 短名匹配:部分数据源会把自治州缩短,例如“恩施州”,则按前两个字匹配;
- 省会兜底:只拿到省名时,用该省省会代替。
// VRCLocationBeacon._HandleDomestic(节选)
string country = _StringField(data, "country");
string province = _StringField(data, "province");
string city = _StringField(data, "city");
int code = map.FindCityCode(province, city);
if (code == 0) code = map.FindCountryCode(country);
if (code <= 0) {
_DomesticFailed("unmapped-city");
return;
}
_stringCode = code;
_stringSource = "bilibili-ip";
_stringHint = "B站IP:" + (city != "" ? city : country);
_Finish(code, true);这一步完全在客户端完成,不经过自建服务器。
第二优先级:服务端回退
如果以下任一情况发生,客户端会回退到自己的服务器:
- 玩家没有允许
Allow Untrusted URLs; - B 站接口超时、返回错误或
code != 0; - 返回的省市无法匹配到城市表;
- 玩家所在网络连不上 B 站。
回退使用每席位固定的 URL:
第一次:https://loc.example.com/v1/whereami?slot=23
第二次:https://loc.example.com/v1/whereami?slot=23&r=2&r=2 只是为了绕过 VRChat 的短时 URL 缓存,让第二次请求真的到达服务器。客户端最多尝试两次,字符串下载超时为 8 秒。
// 第一次结果不是“国内且可靠”时才重试
bool domesticReliable = valid && _IsDomestic(code) && !_stringUnreliable;
bool retry = !domesticReliable && _stringAttempt + 1 < MAX_STRING_ATTEMPTS;
if (retry) {
_stringAttempt++;
_nextStringAt = Time.time + 0.6f;
return;
}服务端如何决定城市
服务端不是只信高德,也不是只信离线库,而是同时查询多个来源,再投票:
| 来源 | 特点 | 默认 |
|---|---|---|
ip2region | 完全离线,快,但部分 IP 段陈旧 | 始终启用 |
| 高德 IP 定位 | 国内城市较准,有 QPS 限制 | 配置 AMAP_KEY 后启用 |
| 百度 IP 库 | 国内访问快,作为独立投票 | 启用 |
ip-api | 有代理/机房标记,国内较慢 | 仅在前三者分歧时查询 |
投票规则
1. 国内结果按“省份”分组,国外结果按“国家”分组
2. 票数最多的组获胜
3. 组内优先精确到城市的结果
4. 同级时按来源优先级:高德 > 百度 > ip2region > ip-api// src/ip.js(简化)
const groups = new Map()
for (const entry of entries) {
const key = entry.location.kind === 'cn'
? 'p:' + provinceKey(entry.lookup)
: 'c:' + countryKey(entry.lookup)
if (!key) continue
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(entry)
}
// 票数最多的组获胜;组内按精度和来源优先级选一个这样做的目的不是“更精确”,而是避免单个陈旧数据库单独决定结果。如果高德说北京、百度和 ip2region 都说南京,最终会选南京。
可靠性诊断
投票之后,服务端还会生成一组诊断标记:
| 标记 | 含义 |
|---|---|
country_mismatch | 各库返回的国家不一致 |
province_mismatch | 国内各库返回的省份不一致 |
proxy_flag | ip-api 标记为代理 |
hosting_flag | ip-api 标记为机房/托管 |
proxy_isp | ISP 名称像加速器、VPN、IDC、云厂商 |
只要存在标记,响应里的 proxy_suspect 就是 true。世界端不会把这个结果当成可靠位置。
客户端如何判断“不可靠”
除了服务端标记,客户端还会自己做一次交叉验证:
- 第一次请求和第二次请求使用了不同 URL/连接;
- 如果两次返回的城市不同,说明出口可能在轮换(常见于加速器);
- 此时标记
localUnreliable,路线框显示“网络出口:…”并提示手动选择。
if (valid && _stringAttempt > 0 && _previousCode >= 0 && code != _previousCode) {
_stringUnreliable = true;
_stringHint = "两次请求结果不同:" + _PreviousLabel() + " → " + ...;
}手动兜底
手动定位是整条链路的最后一道保险,也是唯一不依赖网络的路径。
操作方式:
- 按路线框的
手动; - 用
</>切换省份; - 按
刷新在该省内切换城市; - 再按一次
手动确认。
确认后:
registry.manualLocation = true;- 城市编号直接写入本地槽位并同步;
- 迟到的自动定位结果不会再覆盖它;
- 按
定位可清除手动状态并重新尝试自动定位。
public void SetManualLocation(int code)
{
manualLocation = true;
localUnreliable = false;
localHint = "";
localSource = "manual";
localError = "";
_ApplyLocalLocation(code, true);
}这也是 UU 用户最简单的修复方式:不必改加速器模式,进世界按 手动 选真实城市即可。
返回字段说明
/v1/whereami 的典型响应:
{
"ok": true,
"code": 98,
"kind": "cn",
"precision": "city",
"city": "合肥市",
"province": "安徽省",
"country": "中国",
"region": "合肥市",
"on_map": true,
"reason": "ok",
"source": "amap",
"proxy_suspect": false,
"proxy_reasons": [],
"providers": {
"amap": "合肥市",
"baidu": "合肥市",
"ip2region": "合肥市",
"ip_api": null
}
}| 字段 | 说明 |
|---|---|
code | 0 未知;1..369 国内城市;>=370 国家 |
kind | cn / foreign / unknown |
precision | city / province / country / unknown |
on_map | 是否应该画在地图上 |
reason | ok / private_or_invalid_ip / not_found |
providers | 各 IP 库分别看到的城市,便于排查 |
这一章的关键结论
- 先国内直连:B 站接口是加速器场景下最有效的“旁路”。
- 回退按席位:固定且互不相同的 URL,解决缓存串号。
- 投票而非单库:宁可保守,也不要让一个陈旧数据库决定所有人的位置。
- 不可信就不画:错误的地图标记比没有标记更糟。
- 手动永远可用:这是所有自动方案都失败时的最终答案。
下一章看城市编号如何从一个客户端传到所有客户端,以及地图如何把这些编号变成描边和标签。
04 · 槽位与地图
Udon 没有“给所有人发一条消息”的自由 API,那就让每个玩家拥有一个同步对象。

80 个槽位
地图预制件里有 80 个 VRCLocationSlot,对应 VRChat PC 实例的玩家上限。每个槽位只保存四件事:
[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class VRCLocationSlot : UdonSharpBehaviour
{
[UdonSynced] public int playerId = -1;
[UdonSynced] public int locationCode = 0;
[UdonSynced] public bool occupied = false;
[UdonSynced] public bool locationUnreliable = false;
}选择 Manual 而不是 Continuous,是为了避免每帧同步。只有状态真正变化时才 RequestSerialization()。
一个玩家,一个槽位
Master 分配
实例 Master 每 1.5 秒检查一次:
- 已占用槽位的玩家是否还在实例里;不在就回收;
- 同一玩家是否占了多个槽位;是就合并;
- 新玩家是否已有槽位或预留;没有就分配一个空闲槽位。
分配顺序很关键:
// 先由玩家自己 Reserve,再转移所有权
if (player.playerId == local.playerId) {
slot.Reserve(player.playerId);
}
Networking.SetOwner(player, slot.gameObject);为什么不能省掉 Reserve
场景里未被占用的物体默认归 Master 所有。如果直接把“Master 拥有”当成“Master 已经预留”,Master 自己就永远拿不到槽位。
所以项目明确区分两种状态:
- 拥有:
Networking.IsOwner(slot.gameObject); - 预留:
slot.occupied == true && slot.playerId == 某玩家。
Master 自己也要走一遍 Reserve,再保留所有权。
本地写入
玩家拿到槽位后,Registry 每 0.75 秒把本地状态写进去:
public void WriteLocal(int id, int code, bool unreliable)
{
if (!Networking.IsOwner(gameObject)) return;
if (playerId == id && locationCode == code &&
locationUnreliable == unreliable && occupied) return;
playerId = id;
locationCode = code;
locationUnreliable = unreliable;
occupied = true;
RequestSerialization();
}另外每 2 秒再 SyncNow() 一次,作为丢包后的兜底。
回收与去重
玩家离开时,Master 立刻回收他的槽位。玩家重进时,可能出现两个槽位都指向同一个 playerId 的情况。_EnsureAssignments 会把它们合并成一个:
玩家 A 离开 → 旧槽位 occupied=true, playerId=A
玩家 A 重进 → 新槽位 occupied=true, playerId=A
Master 检查 → 发现重复 → 保留当前拥有的那个,回收另一个这一步看起来琐碎,但少了它,地图上会出现同一个人的两个城市标记。
本地乐观写入
网络同步有延迟。如果等槽位广播回来再画地图,玩家手动选完城市后会看到一两秒的空白。
所以 VRCLocationMap 做了一个本地优先处理:
- 如果本地玩家已经定位,但槽位还没同步完成,先用
registry.localLocationCode画自己的城市; - 槽位同步完成后,再用槽位数据覆盖。
if (registry.localLocated) {
int localCode = registry.localLocationCode;
if (localCode >= 1 && localCode <= cityIds.Length &&
!registry.localUnreliable) {
_active[localCode] = true;
}
}这样手动选择是“立即生效”的,其他玩家则稍后看到。
地图如何生成
数据来源
server/data/cities.json → 城市编号、名称、省份、经纬度、UV
server/data/city_geometry.json → 简化后的城市多边形
↓ build-unity-data
Assets/VRCLocation/Generated/
cities.json
city_geometry.json
city_geometry.bin → VRLG 二进制,Udon 读取
Assets/VRCLocation/Textures/ChinaMap.png
Assets/VRCLocation/Meshes/City_*.asset地图纹理宽 2048 px,使用墨卡托投影,边界为:
西 73°E · 南 18°N · 东 135.5°E · 北 54°Nu 是经度归一化,v 是墨卡托纬度归一化。城市标记的位置直接来自 cities.json 的 u/v。
描边与标记
- 369 个城市各有一个描边网格
City_*.asset; - 有人时
SetActive(true),使用青色VRCLOC/MapOverlay着色器; - 每个有人城市显示一个红色细线 + 圆点标记;
- 标签是背景透明、仅青色描边的 TMP 文本。
标签分两行:
合肥
Neko、Aki同城超过 6 人时显示“等 N 人”。
标签避让
多个城市距离较近时,标签会按候选位置依次尝试:
上 → 下 → 右 → 左 → 右上 → 左上 → 右下 → 左下 → 更上 → 更下 → 更右 → 更左每个候选位置都做一次矩形重叠检测;如果全部失败,就继续向外/向上堆叠。这样即使长三角、珠三角同时有人,标签也不会叠成一团。
private bool _LabelOverlaps(float x, float y)
{
for (int i = 0; i < _placedCount; i++) {
if (Mathf.Abs(x - _placedX[i]) < LABEL_HALF_W * 2f + 0.012f &&
Mathf.Abs(y - _placedY[i]) < LABEL_HALF_H * 2f + 0.010f) {
return true;
}
}
return false;
}不可靠位置不画
如果槽位里的 locationUnreliable == true,地图不会画这个城市:
if (code >= 1 && code <= cityIds.Length && !slot.locationUnreliable) {
_active[code] = true;
_AddCityPlayer(code, i, player.displayName);
}路线框仍然会列出这位玩家,并提示他手动选择城市。这样不会因为加速器出口在北京/日本,就把一个南京玩家画到错误位置。
世界里的样子

- 左侧是地图,固定展示,不放置缩放/平移按钮,避免误触;
- 右侧是路线框,显示自己、目标玩家、目标城市、耗时、票价与换乘;
- 地图和路线框可以独立放置,也可以使用一体化的
VRCLOC_Complete.prefab。
预制件默认尺寸:
地图宽 1.62 m
路线框宽 1.18 m
间距 0.10 m
整体宽 2.90 m正面朝向本地 -Z。放到约 1.35 m 高,根节点 Rotation 保持 (0,0,0) 即可。
玩家容量
默认 80 个槽位。如果只需要 32 人,需要同时调整三个数组,而不只是删 GameObject:
VRCLocationRegistry.slots;VRCLocationMap.slotMarkers;VRCLocationMap.slotLabels。
三者长度必须一致,否则会出现空引用。验证菜单会检查这一点:
slots=80 markers=80 labels=80对大多数 PC 世界,保留 80 个槽位更省事;空槽位不产生同步流量。
这一章的关键结论
- 槽位同时承担身份、状态和缓存隔离,是整个世界端设计的核心。
- 先 Reserve 再 SetOwner,否则 Master 永远拿不到槽位。
- 本地乐观写入让手动选择立即生效,同步随后到达。
- 不可靠就不画,地图宁可少一个点,也不要多一个错误城市。
- 标签避让是必需的,中国城市密度足以让简单布局失效。
下一章看路线数据:如何把“实时查询”提前到构建期,并让 Udon 只做一次字典查找。
05 · 路线系统
把“实时查询”提前到构建期:运行时只做一次数组取下标和一次字典查找。

为什么是静态包
理想情况下,路线接口应该长这样:
GET /v1/route?from=98&to=73但 Udon 运行时不能拼这个 URL。项目改成:
每个出发城市一个固定文件:/v1/packs/98.json?v=2预制件里预置 369 个 VRCUrl:
routePanel.packUrls = new VRCUrl[catalog.cities.Length];
for (int i = 0; i < catalog.cities.Length; i++) {
routePanel.packUrls[i] = new VRCUrl(
baseUrl + "/v1/packs/" + catalog.cities[i].id + ".json?v=2");
}?v=2 是缓存版本号。需要强制所有客户端重新下载时,改这个数字并重建预制件即可。
一个路线包长什么样
/v1/packs/98.json(合肥出发):
{
"v": 1,
"origin": 98,
"source": "mixed",
"routes": {
"73": {
"d": 405000,
"t": 15120,
"f": 198,
"s": "G7258 → 地铁2号线",
"p": "· 打车 8.2 公里\n· G7258 合肥南→上海虹桥\n· 地铁2号线 9站",
"src": "a"
}
}
}| 键 | 含义 | 单位 |
|---|---|---|
d | 距离 | 米 |
t | 耗时 | 秒 |
f | 票价 | 元 |
s | 摘要 | 文本 |
p | 分段详情,换行分隔 | 文本 |
src | a 高德真实路线,e 估算 | — |
键是目标城市的字符串编号。Udon 的 DataDictionary 键必须是字符串,所以客户端会显式转换:
DataToken keyToken = "" + targetCode;
DataToken entryToken;
if (!_routes.TryGetValue(keyToken, out entryToken) ||
entryToken.TokenType != TokenType.DataDictionary) {
text += "\n暂无该城市对的公共交通数据。";
return;
}客户端下载流程
路线框不会一进世界就下载 369 个包。它只在满足以下条件时下载当前出发城市的那一个:
- 本地玩家已经定位;
- 本地城市在国内;
- 至少有一位其他玩家;
- 当前没有正在下载的包;
- 出发城市与已加载的包不同。
if (!registry.localLocated) return;
int origin = registry.localLocationCode;
if (!map.IsDomestic(origin)) return;
if (_loading) return;
if (_routesOrigin == origin) return;
if (Time.time < _nextRequest) return;
_loading = true;
_loadingOrigin = origin;
_nextRequest = Time.time + 5.5f;
VRCStringDownloader.LoadUrl(packUrls[origin - 1], this);失败后 5.5 秒可以重试。下载成功后整包保存在 DataDictionary 里,切换目标玩家时不需要重新下载。
文本与分页
路线框的显示顺序:
我:Neko
位置:合肥(手动)
目标:Mika
对方位置:上海
公共交通
耗时:约 4小时12分
票价:约 ¥198
线路:
· 打车 8.2 公里 到车站
· G7258 合肥南→上海虹桥 2小时58分
· 地铁2号线 上海虹桥→人民广场 28分钟 9站
数据:高德路线当内容超过一屏时,按 16 行、每行约 38 个字符粗略分页,末行显示:
— 2/3 —按钮映射:
| 按钮 | action | 普通模式 | 手动模式 |
|---|---|---|---|
< | 0 | 上一位玩家 | 上一个省 |
> | 1 | 下一位玩家 | 下一个省 |
刷新 | 2 | 重新下载路线包 | 切换该省城市 |
定位 | 3 | 重新自动定位 | 同左 |
手动 | 4 | 进入/确认手动选择 | 确认 |
上页 | 5 | 上一页 | — |
下页 | 6 | 下一页 | — |
没有高德 key 时:估算
默认路线包由球面距离生成,文本会明确标注“估算”。公式在 src/estimate.js:
| 距离 | 耗时 | 票价 | 摘要 |
|---|---|---|---|
< 30 km | 18 + km / 18 * 60 分钟 | 2 + min(6, km * 0.18) 元 | 地铁 / 公交 |
30–250 km | 35 + km / 65 * 60 分钟 | max(8, km * 0.32) 元 | 城际巴士 / 普速铁路 |
> 250 km | 50 + km / 210 * 60 分钟 | max(25, km * 0.46) 元 | 高铁 + 市内交通 |
这是一个保守、确定、可复现的估算,不是实时线路规划。它的价值在于:没有高德 key 时,世界仍然能给出一个量级合理的结果。
有高德 key 时:真实路线
配置 .env:
AMAP_KEY=你的高德Web服务key然后升级路线包:
cd server
# 默认只升级省会/直辖市之间的组合,避免一次打爆配额
npm run routes:upgrade
# 只升级一个出发城市到所有目的地(推荐先升级自己所在城市)
node tools/upgrade-route-packs.mjs --origin-ids=98 --destinations=all --delay=350 --force
# 或所有城市到所有省会
node tools/upgrade-route-packs.mjs --origins=all --destinations=capitals --delay=1200脚本会:
- 读取
data/packs/{origin}.json; - 对每个目的地调用高德
v3/direction/transit/integrated; - 取耗时最短的方案;
- 提取铁路车次、上下车站、地铁/公交线路、换乘站、步行距离;
- 写回
p字段,并把src设为a; - 失败时保留原估算值。
写入后的包会被服务端直接读取。更新路线包不需要重新上传 VRChat 世界,因为 Udon 下载的是服务器上的静态文件。
高德免费配额有 QPS 限制。--delay 不要设得太小;默认 1200 ms 是保守值,350 ms 适合小范围升级。
服务端的实时调试接口
/v1/route?from=1&to=74 只用于调试,Udon 不直接调用:
curl "https://loc.example.com/v1/route?from=1&to=74"行为:
- 有
AMAP_KEY时优先实时查询高德; - 高德失败时回退到路线包;
- 响应缓存 5 分钟。
这个接口的价值是:当你怀疑某个城市对的路线不对时,可以绕过 Udon,直接看服务端的原始结果。
数据生成流水线
DataV 行政区划
↓ npm run build:geo
cities.json + city_geometry.bin + ChinaMap.png
↓ npm run build:packs
369 个估算路线包
↓ npm run routes:upgrade(可选)
真实高德分段
↓ Unity / VRCLocation / 2. Build Map and Route Prefabs
369 个 packUrls + 369 个城市描边网格 + 预制件这一章的关键结论
- 路线包是数据,不是服务:世界端只需要静态文件。
- 按出发城市分包:一次只下载一个文件,切换目标不重复下载。
- 估算是兜底,不是伪装:文本明确标注,避免误导。
- 高德是增强项:有 key 更准,没 key 也能跑。
- 更新数据不必重传世界:服务器换文件即可。
下一章进入服务端:Fastify 路由、缓存、隐私和数据生成脚本。
06 · 服务端实现
没有数据库,没有账号系统,没有玩家档案。服务端只做三件事:看 IP、查路线、发文件。
技术选型
| 项 | 选择 | 原因 |
|---|---|---|
| 运行时 | Node.js 22+ / Bun | 原生 fetch、node:test、无额外构建 |
| Web 框架 | Fastify 5 | 轻量、trustProxy 支持好、性能足够 |
| IP 库 | ip2region | 完全离线,不依赖第三方 |
| 容器 | Docker + Compose | 一行命令部署 |
| Windows | Bun 单文件 exe | 双击即用,不需要装 Node |
依赖只有两个:
{
"dependencies": {
"fastify": "^5.12.7",
"ip2region": "^3.0.0"
}
}路由一览
| 方法 | 路径 | 作用 | 缓存 |
|---|---|---|---|
| GET | / | 服务信息与端点列表 | — |
| GET | /healthz | 健康检查、城市数、各数据源开关 | — |
| GET | /v1/whereami | 按请求 IP 返回城市级位置 | no-store |
| GET | /v1/beacon.mp4 | 旧版视频尺寸通道 | no-store |
| GET | /v1/cities | 369 城市 + 96 国家表 | 1 小时 |
| GET | /v1/packs/:originId.json | 出发城市的完整路线包 | no-store |
| GET | /v1/route?from=&to= | 实时/缓存路线调试 | 5 分钟 |
| GET | /v1/beacon-meta | 信标编码参数与示例 | — |
| GET | /v1/debug/ip | 调试真实 IP(需显式开启) | no-store |
健康检查
curl https://loc.example.com/healthz{
"ok": true,
"cities": 369,
"countries": 96,
"amap": true,
"amap_ip": true,
"baidu_ip": true,
"ip_api": true,
"log_lookups": false,
"ip2region": { "loaded": true, "error": null }
}上线后第一件事就是访问它:如果 cities 不是 369,说明 data/ 没放对位置。
请求处理流程
请求进入
→ onRequest 钩子
→ 解析客户端 IP(trustProxy)
→ 用随机盐哈希,按 IP 限流 300 次/分钟
→ 路由处理
→ /v1/whereami:lookupIp → 城市匹配 → 返回 code
→ /v1/packs:读取或按需生成 JSON
→ 响应头
→ X-Content-Type-Options: nosniff
→ Referrer-Policy: no-referrer
→ 地图/定位相关接口 no-store限流桶只存在进程内:
const RATE_LIMIT = 300
const RATE_WINDOW_MS = 60_000
const buckets = new Map()
function allowed(key) {
const now = Date.now()
const bucket = buckets.get(key)
if (!bucket || now - bucket.startedAt > RATE_WINDOW_MS) {
buckets.set(key, { startedAt: now, count: 1 })
return true
}
bucket.count += 1
return bucket.count <= RATE_LIMIT
}没有持久化,进程重启即清空。这既是隐私选择,也避免了引入 Redis 之类的额外组件。
IP 解析的并发控制
一个玩家进入世界时,字符串下载和视频信标可能同时请求。如果不做去重,一个玩家会消耗两次高德 IP 配额。
ip.js 为每个来源维护了 inFlight 表:
const existing = amapInFlight.get(ip)
if (existing) return existing
const pending = amapIpLookup(ip)
.catch(() => null)
.then((value) => {
writeAmapCache(ip, value)
return value
})
.finally(() => {
amapInFlight.delete(ip)
})
amapInFlight.set(ip, pending)
return pending同时每个来源都有独立的最小调用间隔:
| 来源 | 最小间隔 | 说明 |
|---|---|---|
| 高德 IP | 350 ms | 免费配额 QPS 较低 |
| 百度 IP | 250 ms | 国内访问快 |
| ip-api | 1400 ms | 免费接口约 45 次/分钟 |
缓存策略
| 数据 | 位置 | 有效期 |
|---|---|---|
| 城市表 | 进程内 | 启动后常驻 |
| 路线包 | 进程内 + 磁盘 | 常驻 |
| 高德/百度/ip-api 结果 | 进程内 | 默认 6 小时 |
| 查询失败 | 进程内 | 60 秒(避免反复打爆) |
| 限流桶 | 进程内 | 2 分钟清理 |
缓存键是完整 IP,但只在进程内存在,不写磁盘。
路线包按需生成
如果 data/packs/{origin}.json 不存在,服务端会用球面距离即时生成一个:
export async function ensurePack(originId) {
if (memory.has(originId)) return memory.get(originId)
const path = packPath(originId)
if (existsSync(path)) {
const pack = JSON.parse(await readFile(path, 'utf8'))
memory.set(originId, pack)
return pack
}
const pack = makePack(city)
memory.set(originId, pack)
await writeFile(path, JSON.stringify(pack), 'utf8')
return pack
}这让“只部署服务、不带 packs 目录”也能跑起来;第一次请求会稍慢,之后就是正常读取。
隐私设计
- 不记录完整 IP:限流只使用随机盐 SHA-256 的前 16 位,进程重启即失效;
- 不建立玩家数据库;
- 不把 IP、城市、玩家名持久化;
- 诊断日志默认关闭;
- 开启日志时默认只保留网段前缀,例如
117.64.x.x; ENABLE_DEBUG_IP=true时/v1/debug/ip才会返回调用者完整 IP,排查完必须关闭。
const HASH_SALT = randomBytes(16)
export function hashIp(value) {
const ip = normalizeIp(value)
if (!ip) return null
return createHash('sha256')
.update(HASH_SALT)
.update(ip)
.digest('hex')
.slice(0, 16)
}需要注意:客户端默认会请求 B 站公开接口,因此 B 站会看到玩家的出口 IP;服务端回退路径默认会使用百度 IP 库,配置 AMAP_KEY 后还会使用高德 IP 定位。如果希望完全离线,可以:
AMAP_IP_LOOKUP=false
BAIDU_IP_LOOKUP=false
IP_API_LOOKUP=false此时只使用本地 ip2region,代价是部分 IP 段精度下降。
数据生成脚本
城市与地图
cd server
npm run build:geo依次执行:
fetch-geo.mjs 下载 DataV 行政区划
build-cities.mjs 生成 cities.json、city_geometry.json
build-map-assets.mjs 生成 ChinaMap.png(SVG → PNG)
build-unity-data.mjs 生成 city_geometry.bin,复制到 Unity默认写入 ../world/Assets/VRCLocation。如果 Unity 工程在别处:
$env:UNITY_ASSETS_DIR="D:\path\to\Assets\VRCLocation"
$env:UNITY_GENERATED_DIR="D:\path\to\Assets\VRCLocation\Generated"信标视频
npm run build:beacons生成 466 个极小 MP4(编号 0..465),每个视频的像素尺寸编码城市编号:
code = 0 → 32 × 32
code >= 1 → width = 64 + (code - 1) % 32 * 32
height = 64 + floor((code - 1) / 32) * 32这是旧版视频通道,默认关闭。它的问题在于:视频加载失败或占位视频可能报告无关尺寸,产生“日本/北京”这种假结果。字符串通道稳定后,保留它只是为了兼容旧预制件。
路线包
npm run build:packs为 369 个城市各生成一个估算包。之后可用 npm run routes:upgrade 升级为高德真实路线。
Windows exe
npm run build:exe生成 dist/VRCLOC.exe,并复制 data/、ip2region 数据库、.env.example。已有的 dist/.env 不会被覆盖。
测试
cd server
npm test使用 Node 原生 node:test,覆盖:
- 信标尺寸编码/解码;
- 城市名归一化与匹配;
- 私网判断;
- 路线包结构;
- 多 IP 库投票;
- 一次真实 HTTP 冒烟测试(临时启动
127.0.0.1:18976,请求/healthz、/v1/whereami、/v1/beacon.mp4、/v1/packs/1.json、/v1/route)。
这一章的关键结论
- 服务端可以没有数据库:文件 + 进程内缓存足够。
- 并发要合并:同一玩家的两个请求不应消耗两次配额。
- 隐私默认保守:不存 IP,不存玩家,不默认开日志。
- 数据生成是工程的一部分:地图、城市、信标、路线包都有可复现脚本。
下一章把服务真正部署起来:本地、exe、Docker,以及 FRP + NPM 的完整链路。
07 · 部署指南
VRChat 只认 HTTPS 域名。把 NPM 放在前面,真实 IP 才能安全地传到应用。

目标链路
VRChat 客户端
→ https://loc.example.com(CNAME 到 Sakura FRP)
→ Sakura FRP HTTP 隧道
→ Nginx Proxy Manager
→ http://127.0.0.1:8787
→ VRCLOC这条链路的关键不是“能访问”,而是真实 IP 能透传。如果 NPM 只看到隧道节点的内网地址,定位会一直是 code: 0。
方式一:本地运行
适合开发和调试。
cd C:\path\to\VRCLOC\server
npm install
Copy-Item .env.example .env
# 编辑 .env,至少确认 PORT、AMAP_KEY(可选)、TRUSTED_PROXY_CIDRS
npm start默认监听 0.0.0.0:8787。启动日志:
vrcloc listening on http://0.0.0.0:8787
cities=369 amap=estimate-onlyamap=estimate-only 表示没有配置 AMAP_KEY,路线使用估算包;配置后显示 amap=enabled。
方式二:Windows exe
适合不想装 Node 的家庭服务器。
server/dist/
VRCLOC.exe
.env
.env.example
启动说明.txt
data/
cities.json
countries.json
packs/
beacons/
ip2region_v4.xdb
ip2region_v6.xdb双击 VRCLOC.exe 即可。注意事项:
- 整个
dist/要一起保留,不能只复制 exe; - exe 自带 Bun 运行时,不需要安装 Node;
.env从 exe 同目录读取,改完重启;- 关闭控制台窗口即停止服务;
- 开机自启用 Windows 任务计划程序,启动程序填
VRCLOC.exe,“起始于”填dist目录; - exe 没有代码签名,SmartScreen 可能提示“未知发布者”。
重新构建:
cd server
npm install
npm run build:exe更新路线包或信标后,要重新执行 npm run build:exe,或手动把新的 server/data/packs、server/data/beacons 复制到 dist/data/。
方式三:Docker
适合 NAS、云主机或长期运行。
cd server
docker compose up -d --builddocker-compose.yml 把 8787 暴露到宿主机,并带健康检查:
services:
vrcloc:
build: .
container_name: vrcloc
restart: unless-stopped
env_file:
- .env
ports:
- "8787:8787"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8787/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3NPM 反代到 http://宿主机IP:8787 即可。
环境变量
| 变量 | 默认 | 说明 |
|---|---|---|
PORT | 8787 | 监听端口 |
HOST | 0.0.0.0 | 监听地址 |
AMAP_KEY | 空 | 高德 Web 服务 key;留空则路线为估算 |
AMAP_IP_LOOKUP | true | 有 key 时是否用高德做 IP 定位 |
AMAP_IP_TIMEOUT_MS | 1500 | 高德 IP 超时 |
AMAP_IP_CACHE_HOURS | 6 | 高德 IP 缓存 |
BAIDU_IP_LOOKUP | true | 是否使用百度 IP 库 |
BAIDU_IP_TIMEOUT_MS | 2500 | 百度超时 |
IP_API_LOOKUP | true | 分歧时是否查询 ip-api |
IP_API_TIMEOUT_MS | 5000 | ip-api 超时 |
LOG_LOOKUPS | false | 写 data/ip-lookups.log |
LOG_LOOKUP_IPS | false | 日志是否保留完整 IP |
PUBLIC_BASE_URL | 空 | 仅用于文档和诊断 |
TRUSTED_PROXY_CIDRS | loopback,linklocal,uniquelocal | 信任的反向代理网段 |
ENABLE_DEBUG_IP | false | 开启 /v1/debug/ip |
ROUTE_CACHE_DAYS | 30 | 预留的路线包新鲜度配置 |
⚠️ .env 里可能包含高德 key。不要提交到公开仓库,也不要把带 key 的文件放进文章配图或安装包。
Sakura FRP
- 在 Sakura FRP 创建一个 HTTP 隧道,本地目标填 NPM 的 HTTP 地址和端口;
- 使用 Sakura 提供的域名做 CNAME 映射;
- 在 DNS 服务商把
loc.example.comCNAME 到 Sakura 分配的域名; - 如果隧道设置里有“传递真实 IP / X-Forwarded-For”,打开它。
NPM 需要收到客户端的原始 IP,而不是 Sakura 节点的地址。
Nginx Proxy Manager
新建 Proxy Host:
| 项 | 值 |
|---|---|
| Domain Names | loc.example.com |
| Forward Hostname / IP | 运行服务的宿主机地址 |
| Forward Port | 8787 |
| Scheme | http |
| Block Common Exploits | 开 |
| Websockets | 不需要 |
| Cache Assets | 关闭 |
Advanced 里建议加:
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_cache off;
proxy_no_cache 1;
proxy_cache_bypass 1;X-Forwarded-For 会保留 Sakura 传来的原始客户端 IP,并追加 NPM 看到的对端地址。应用默认信任环回/内网代理,按从右到左解析,取第一个非受信地址。
如果 Sakura 节点使用公网地址回源,把该地址/CIDR 加入 .env:
TRUSTED_PROXY_CIDRS=loopback,linklocal,uniquelocal,203.0.113.10/32验证真实 IP
临时打开:
ENABLE_DEBUG_IP=true访问:
https://loc.example.com/v1/debug/ip重点看:
| 字段 | 用途 |
|---|---|
ip | 服务端实际看到的客户端 IP |
forwarded_chain | Sakura/NPM 传来的完整 X-Forwarded-For |
ip2region.city | 离线库结果 |
amap.city | 高德 IP 结果 |
source / resolved.city | 最终采用的数据源和城市 |
然后访问:
https://loc.example.com/v1/whereami确认 city 是你的城市。验证完把 ENABLE_DEBUG_IP 改回 false。
如果 whereami 一直是:
{ "code": 0, "reason": "private_or_invalid_ip" }说明 NPM 只看到了 Sakura/NPM 的内网地址。检查 Sakura 是否传了 X-Forwarded-For,以及 NPM Advanced 配置是否保留了它。
上线前检查
- ☐ 玩家开启
Allow Untrusted URLs - ☐ NPM 关闭
Cache Assets - ☐ NPM 保留
X-Forwarded-For - ☐
ENABLE_DEBUG_IP=false - ☐
LOG_LOOKUPS=false - ☐ 改域名后执行菜单 8 或重建预制件
- ☐ HTTPS 证书有效
- ☐
.env没有泄露到公开仓库 - ☐
/healthz返回cities: 369
常见部署错误
| 现象 | 原因 | 处理 |
|---|---|---|
whereami 永远 code: 0 | 代理没透传真实 IP | 修 X-Forwarded-For |
| 所有人都同一个城市 | 出口 IP 相同或共享缓存 | 检查每席位 URL、加速器 |
| 路线包返回 HTML | NPM 错误页/域名不对 | 看 config.json 与 NPM 配置 |
| 世界端下载失败 | 未允许不受信任 URL | 玩家设置 + 重进世界 |
/healthz 城市数不对 | data/ 没放对位置 | 检查启动目录 |
下一章进入 Unity 世界端:导入、放置、改地址、容量与操作说明。
08 · 世界内使用
对世界作者来说,它是一个预制件;对玩家来说,它是一张会亮的地图和一个路线框。
前置条件
目标 Unity 工程需要:
- Unity 2022.3.22f1
- VRChat SDK3 Worlds
- UdonSharp
- TextMeshPro Essentials(TMP Essentials)
导入安装包之前,先在目标工程里确认 TMP Essentials 已导入,否则地图上的中文会变成方块或空白。
菜单一览
打开 瑶瑶小窝 工程,等待 UdonSharp 编译完成。菜单栏 VRCLocation 下有:
| 菜单 | 作用 | 什么时候用 |
|---|---|---|
1. Create UdonSharp Program Assets | 创建 UdonSharp 程序资源 | 新增/修改脚本后 |
2. Build Map and Route Prefabs | 重建 Map / RoutePanel / Complete | 改地图尺寸、字体、城市数据、URL 后 |
3. Validate Prefabs | 检查数组长度、引用、Udon 程序 | 构建后、上传前 |
5. Build Demo Scene | 生成 VRCLOC_Demo.unity | 想快速看效果 |
6. Add Complete Prefab to Scene | 把一体化预制件放进当前场景 | 新世界首次放置 |
7. Export Installer Package | 导出 .unitypackage | 分享给其他世界 |
8. Change Server URL... | 批量修改所有预制件和场景里的 URL | 换域名时首选 |
菜单编号不连续是正常的,以当前 Editor/ 目录里的 MenuItem 为准。
导入到另一个世界
- 目标工程准备好 SDK3 Worlds、UdonSharp、TMP Essentials;
- 双击
VRCLOC_Installer.unitypackage,全部导入; - 执行
VRCLocation / 6. Add Complete Prefab to Scene,或直接把Assets/VRCLocation/Prefabs/VRCLOC_Complete.prefab拖进场景; - 一个世界只放一个
VRCLOC_Complete; - 如果使用自己的服务器,执行菜单 8 改地址;
- 上传世界。
安装包包含:
Assets/VRCLocation下的完整源码;- Editor 构建、校验、URL 修改、打包工具;
- UdonSharp 程序资源、着色器、材质、网格、纹理;
- 城市数据、三个预制件、安装说明;
- 中文字体资源的依赖。
不包含 UdonSharp/TMP 包本身,也不包含后端服务器。
修改服务器地址
方式一:菜单 8(推荐)
- 执行
VRCLocation / 8. Change Server URL...; - 输入新的
https://你的域名; - 点“应用到预制件和当前场景”。
它会同时改写:
VRCLOC_Map.prefab的定位 URL;VRCLOC_RoutePanel.prefab的 369 个路线包 URL;VRCLOC_Complete.prefab的上述两组 URL;- 当前场景里已经放好的地图/路线框实例;
Generated/config.json的baseUrl。
改完重新上传世界即可,不需要重建 369 个城市网格。
方式二:改配置后重建
编辑:
Assets/VRCLocation/Generated/config.json{
"baseUrl": "https://loc.example.com",
"prefabName": "VRCLOC_Map",
"routePrefabName": "VRCLOC_RoutePanel"
}然后执行菜单 2。VRCUrl 在编辑器里被序列化,改域名后必须重建或使用菜单 8,运行时改不了。
默认预制件使用 https://map.u386706.nyat.app:53630。换成自己的域名后,记得同时更新服务端 .env 和 NPM 配置。
放置
一体化预制件
推荐直接使用 VRCLOC_Complete:
地图在左,路线框在右
整体宽约 2.90 m
正面朝向本地 -Z- 根节点 Rotation 保持
(0, 0, 0); - 放到约 1.35 m 高;
- 在 Scene 视图里把相机放在物体
-Z一侧,应该能看到正面; - 不需要旋转预制件,也没有运行时自动转身。
独立预制件
VRCLOC_Map 和 VRCLOC_RoutePanel 可以分开摆放。路线框启动时会自动查找:
GameObject found = GameObject.Find("VRCLOC_Map");如果你改过地图实例名,请在路线框的 Registry / Map / Beacon 字段手动拖引用。
重建后旧实例不更新
如果场景里的旧实例没有自动更新:
- 删除旧实例;
- 重新拖入新的预制件;
- 重新上传世界。
旧实例可能保留旧的序列化字段和 _syncMethod 覆盖,单靠重建预制件资产不一定会清除。
验证
执行 VRCLocation / 3. Validate Prefabs,日志里应看到类似:
[VRCLOC] map cities=369 provinces=369 outlines=369
markers=80 labels=80 cityPos=369 slots=80
routeUrls=369 routeBody=True ...
[VRCLOC] video=True stringOnly=True slotUrls=80
slotRetryUrls=80 slotVideoUrls=80 domesticUrls=80
[VRCLOC] complete map=True panel=True beacon=True panelRefs=True重点检查:
| 字段 | 期望 |
|---|---|
cities | 369 |
outlines | 369 |
markers / labels | 与 slots 相同 |
routeUrls | 369 |
slotUrls / slotRetryUrls / domesticUrls | 80 |
complete panelRefs | True |
broken | 0 |
玩家操作
第一次进入
玩家需要开启:
VRChat 设置 → 允许不受信任 URL / Allow Untrusted URLs否则字符串下载会失败:
- 自动定位失败;
- 路线包下载失败;
- 地图上只有自己或什么都没有。
开启后重新进入世界,信标只在进入时请求一次。
路线框按钮
| 按钮 | 作用 |
|---|---|
< / > | 切换目标玩家 |
刷新 | 重新拉取当前出发城市的路线包 |
上页 / 下页 | 路线详情超过一屏时翻页 |
定位 | 重新尝试自动 IP 定位,并清除手动选择 |
手动 | 进入/确认手动选择城市 |
手动模式:
</>切换省份;刷新切换该省内的城市;- 再按一次
手动确认; - 确认后显示“(手动)”,并覆盖自动定位;
- 按
定位可重新尝试自动定位。
文本含义
我:Neko
位置:合肥(手动)
目标:Mika
对方位置:上海
公共交通
耗时:约 4小时12分
票价:约 ¥198
线路:
· 打车 8.2 公里 到车站
· G7258 合肥南→上海虹桥 2小时58分
· 地铁2号线 上海虹桥→人民广场 28分钟 9站
数据:高德路线特殊情况:
| 显示 | 含义 |
|---|---|
定位中 | 还在请求 |
未知 | 请求完成但没有结果 |
网络出口:…(可能是加速器/代理) | 多库分歧或 IP 像代理 |
槽位同步中 | 自己的槽位还没同步完成 |
对方位于国外(…) | 不在地图显示,不规划跨境路线 |
暂无该城市对的公共交通数据 | 路线包缺少该目的地 |
玩家容量
默认 80 个槽位。如果只想要 32 人:
- 删除多余的
Slot_*GameObject; - 同步缩小
VRCLocationRegistry.slots; - 同步缩小
VRCLocationMap.slotMarkers; - 同步缩小
VRCLocationMap.slotLabels。
四个数组长度必须一致,否则验证会报错,运行时可能空引用。
对大多数 PC 世界,建议保留 80 个槽位;空槽位不产生同步流量,调整数组反而容易出错。
面向玩家的说明模板
如果这是公开世界,可以在世界规则或公告里写:
本世界会通过 IP 做城市级估算定位,用于在地图上显示同城玩家。默认优先请求 B 站公开 IP 接口;失败时请求本世界自建服务。不会记录玩家名,也不会保存 IP。若你使用加速器导致定位不准,可按路线框的“手动”选择真实城市。
这一章的关键结论
- 优先用一体化预制件,引用已经接好。
- 换域名优先用菜单 8,不要手动改 369 个 URL。
- 改域名后必须重建或批量改写,否则运行时无法修改。
- 玩家必须允许不受信任 URL,这是所有下载链路的前提。
- 旧实例要删掉重拖,不要指望资产重建自动清理序列化字段。
下一章汇总常见问题、边界与隐私注意事项。
09 · 排错与边界
先把“能下载”和“定位准”分开排查。大多数问题不是代码,而是网络路径或序列化状态。
快速判断表
| 现象 | 先看哪里 |
|---|---|
| 所有人都同一个城市 | 每席位 URL、共享缓存、出口 IP |
| 地图没有描边 | 玩家是否允许不受信任 URL |
| 路线框“定位中/未知” | 同上,并检查域名/证书 |
| 路线框“下载失败” | config.json 域名、NPM、预制件是否重建 |
| 按钮点不动 | 预制件版本、Collider、距离 |
| 自己手动选了但别人看不到 | 槽位所有权/同步 |
| 某个人一直北京/日本 | 加速器出口、B 站接口是否也被代理 |
常见问题
1. 所有人都显示同一个城市
新版已经用每个席位不同的 /v1/whereami?slot=N 避免共享缓存。如果仍然相同:
- 检查 NPM 是否覆盖了
Cache-Control: no-store; - 检查请求是否被 VRChat/加速器代理到同一个出口 IP;
- 看服务端
/healthz和访问日志里的客户端 IP。
如果服务端看到的所有请求都是同一个机房 IP,本方案无法从连接 IP 得到玩家位置,只能让玩家手动选择。
2. 地图左右颠倒 / 只看到背板
- 确认场景实例根节点 Rotation 是
(0, 0, 0); - 确认没有手动转过 180°;
- 删除旧实例,重新拖入预制件;
- 仍然反向时,关闭 Unity 后执行菜单 2 重建。
预制件内部已经按 -Z 正面排好图层顺序,正常情况下不需要旋转。
3. 地图没有描边,路线框显示“定位中/未知”
最常见原因:玩家没有开启 Allow Untrusted URLs。
处理:
- 让玩家在 VRChat 设置中允许不受信任 URL;
- 重新进入世界(信标只在进入时请求一次);
- 检查域名证书是否有效;
- 检查 B 站接口是否被玩家网络屏蔽。
4. 路线框显示“路线数据下载失败”
可能原因:
config.json的baseUrl与实际域名不一致;- 改域名后没有重建预制件,也没有执行菜单 8;
- NPM 返回了 HTML 错误页而不是 JSON;
- 字符串下载被 VRChat 代理/限流。
排查:
curl -D - "https://你的域名/v1/packs/98.json?v=2"应该返回 application/json,而不是 HTML。
5. 路线票价/时间明显不对
默认是估算包,文本会显示“估算”。配置 AMAP_KEY 后执行:
npm run routes:upgrade然后重新访问路线包。不需要重新上传世界,因为 Udon 下载的是服务器文件。
6. 世界包体较大
当前资源:
| 资源 | 大约 |
|---|---|
| 地图网格 | 14 MB |
| 中文 TMP 字体 | 37 MB |
| 地图纹理 | 1.3 MB |
PC 世界可以接受。Quest 需要自行压缩/裁剪字体和城市网格,或者只保留常用城市。
7. Unity 编译报 LazySwitch 错误
项目里:
Packages/idv.jlchntoz.lazyswitch/Editor/LazySwitch/LazySwitchEditor.cs已针对 VRChat SDK 3.10.3 做了最小兼容修改(SDK 移除了 Contact Box/size/useFaceProximity)。这是为了让整个工程能编译;以后升级 LazySwitch 包时可以恢复官方版本。
8. 浏览器显示合肥,但 /v1/whereami 显示马鞍山
先看返回里的 providers 和 proxy_suspect,或临时打开 /v1/debug/ip:
- 如果
ip与浏览器 IP 相同:
– 各库都落到马鞍山:该 IP 段在运营商处登记在别的城市,只能手动选择; – 只有高德错、百度和 ip2region 对:新版投票会选对;
- 如果
ip与浏览器 IP 不同:Sakura/NPM 没有透传真实 IP,先修代理头。
9. 按钮点不动
旧预制件把 VRCUiShape 加在了普通 Cube 上。这个组件只适用于真正的 Canvas,会妨碍 Cube 的普通 Interact。
新版构建器已移除它。处理:
- 执行菜单 2 重建;
- 重新上传世界;
- 确认按钮有
BoxCollider; - 玩家站在约 2 米内再试。
10. 手动选了城市,但地图没有描边 / 其他人看不到
可能原因:
- 旧版 Master 把“默认拥有所有未占用对象”误判成“已经预留槽位”,导致 Master 自己拿不到槽位;
- 槽位所有权还没同步完成;
- 场景实例保留了旧序列化字段。
新版会:
- 先
Reserve再转移所有权; - 地图优先显示本地玩家自己的城市;
- 允许本地乐观写入。
处理:重新编译并上传世界;必要时删除旧实例重新拖入预制件。
11. 某个玩家一直显示北京/日本,但他实际在南京
按顺序排查:
- 确认已上传最新世界,日志里应出现:
“text [VRCLOC] domestic result country=中国 province=江苏 city=南京 code=74 “
- 如果 B 站返回南京但地图仍不对:检查城市表是否最新,重新执行菜单 2;
- 如果 B 站也返回日本:这位玩家的 B 站 HTTP 请求同样被代理了,只能改用 UU 路由模式或手动;
- 如果回退到服务器:路线框会显示“网络出口:…(可能是加速器/代理)”和各库城市;
- 查看
dist/data/ip-lookups.log最新一行,比较amap/baidu/ip2region/ip_api的city与reasons; - 如果所有库都显示北京/日本,说明该玩家请求的出口就是北京/日本,任何 IP 库都无法还原南京。
12. /v1/whereami 一直 private_or_invalid_ip
说明服务端看到的是私网地址:
{ "ok": true, "code": 0, "reason": "private_or_invalid_ip" }检查:
- Sakura 是否传递了
X-Forwarded-For; - NPM Advanced 是否保留了该头;
TRUSTED_PROXY_CIDRS是否包含回源节点;- 是否把 NPM 指到了错误的端口/服务。
13. 路线框显示“槽位同步中”
说明本地玩家还没有拿到槽位,或槽位所有权还没有同步完成。
- 等待几秒;
- 检查 Master 是否正常分配;
- 检查
Registry.slots是否为空; - 检查实例里是否有多个
VRCLOC_Complete。
边界与限制
IP 定位只能到城市级
玩家 → 加速器日本节点 → 服务端服务端只能拿到日本出口 IP,原始公网 IP 在隧道里,无法通过 X-Forwarded-For、VRChat API 或 Udon 反推。
其他世界如果能在加速器开启时显示真实位置,通常不是“穿透”了加速器,而是使用了:
- 加速器的域名/进程分流,让定位请求直连;
- 世界内手动选择城市;
- 独立伴生程序/网页登记;
- 或者显示的其实是账号地区/实例区域。
加速器的两种模式
| 模式 | 行为 | 对定位的影响 |
|---|---|---|
| 进程模式 | 只接管 VRChat 游戏流量 | B 站 HTTP 可能直连,定位仍准 |
| 路由模式 | 接管整机/路由流量 | 所有请求都走代理,定位偏移 |
UU 用户可以:
- 把 VRChat 从“进程模式”改为“路由模式”,让定位请求直连;
- 或直接在世界内按
手动。
国外玩家
- 返回
kind: "foreign",编号>= 370; - 地图不描边;
- 路线框只显示国家文字,不规划跨境路线;
- 如果国外玩家实际在国内,通常是加速器出口,提示手动选择。
移动网络 / CGNAT / 企业出口
这些网络可能让大量用户共享同一个出口 IP:
- 城市可能偏移到运营商机房所在地;
- 同城多人可能被合并;
- 手动选择是最可靠的兜底。
隐私
默认行为:
- 不记录完整 IP;
- 不建立玩家数据库;
- 不持久化 IP/城市/玩家名;
- 限流只用随机盐哈希前 16 位,进程重启即失效;
- 诊断日志默认关闭。
但客户端默认会请求 B 站公开 IP 接口,服务端回退路径默认会使用高德/百度 IP 库。如果希望完全离线:
AMAP_IP_LOOKUP=false
BAIDU_IP_LOOKUP=false
IP_API_LOOKUP=false代价是部分 IP 段精度下降。
如果用于公开世界,建议在世界规则中明确告知“会显示城市级 IP 估算位置”,并考虑后续加入“隐藏我的位置”开关。
数据与第三方服务
- 行政区划轮廓:阿里云 DataV.GeoAtlas;
- IP 定位:
ip2region、高德、百度、ip-api(可关闭); - 路线数据:高德开放平台 Web 服务(可选);
- 中文字体:工程内已有的 Noto Sans SC SDF。
发布世界前请自行确认这些数据/服务的使用条款,尤其是商用场景。
可以继续做的方向
- 隐藏我的位置:给每个玩家一个本地开关,不写入槽位。
- Quest 优化:裁剪城市网格、压缩字体、降低纹理分辨率。
- 更细的路线:扩大高德升级范围,或接入其他公共交通数据源。
- 城市别名表:补充更多简称、旧称、民族自治州缩写。
- 世界内配置面板:不重建预制件也能切换服务器地址和数据源。
- 更完善的诊断:在路线框里显示当前使用的数据源和最后一次错误。
结语
VRCLOC 的核心并不复杂:
固定 URL + 一个城市编号 + 一个同步槽位真正花时间的,是围绕这三个简单概念处理各种失败:
- 加速器让 IP 不可信;
- 共享缓存让结果串号;
- 网络延迟让状态不同步;
- 序列化字段让旧实例行为异常;
- 城市名歧义让匹配出错。
把这些边界一个个补上之后,剩下的就是一张会亮的地图和一段可以查询的路线。
希望这套实现能给你的 VRChat 世界一点参考。如果只需要一个能用的版本,直接导入 VRCLOC_Installer.unitypackage;如果打算自己改,从 Assets/VRCLocation/Scripts/ 和 server/src/ 开始读即可。
本文基于仓库当前实现整理。代码、数据与预制件以仓库为准;如果你在自己的世界中部署,请先阅读第 07 章的部署清单和第 09 章的边界说明。
主题测试文章,只做测试使用。发布者:zy,转转请注明出处:https://zynetc.top/?p=2026102701