VRCLOC:在 VRChat 里点亮同城玩家的中国地图

一个运行在 VRChat 世界里的中国地图:玩家进入世界后自动做城市级定位,同城的人会在地图上亮起;选中另一位玩家,还能看到两地之间的公共交通耗时、票价与换乘。

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

VRCLOC:在 VRChat 里点亮同城玩家的中国地图
VRCLOC:固定 URL + 席位同步 + 公共交通路线

Table of Contents

目录

01 · 项目概览

它解决什么问题

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墨卡托投影
城市描边网格369City_*.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.unitypackage

三个关键约束

这个项目的很多设计,都是被 VRChat 的运行环境“逼”出来的。理解这三条,后面的实现就顺了。

1. VRCUrl 只能在编辑器阶段构造

Udon 运行时不能把玩家名、IP、坐标拼进 URL。因此所有 URL 必须在预制件里预先序列化好。

2. Udon 没有任意网络 API

世界端只能使用 VRCStringDownloader、图片下载和视频播放器,不能开 WebSocket、不能发 POST、不能读响应头。

3. 定位只能到城市级

服务端看到的是连接出口 IP。VPN、代理、移动网络、CGNAT 都会让结果偏移,所以地图只展示城市轮廓,不展示街道或精确坐标。

设计取舍

面对这些约束,项目没有尝试“绕过”,而是把问题拆开:

身份  → 由 VRChat 的槽位所有权表达
位置  → 由服务端/国内 IP 接口返回一个城市编号
展示  → 由所有客户端各自读取同步槽位后渲染
路线  → 在构建期预生成 369 个静态 JSON

由此得到四个直接结果:

  1. 没有登记页:玩家进世界就开始定位。
  2. 没有运行时动态 URL:所有请求地址在预制件里写死。
  3. 没有玩家名上传:服务器只收到 IP,返回一个整数编号。
  4. 失败有兜底:自动定位不可信时,玩家可以手动选城市。

阅读路线

章节内容适合谁
02 · 系统架构三条链路与关键决策想先看全貌的人
03 · 定位链路B 站优先、服务端回退、多库投票负责定位/后端的人
04 · 槽位与地图80 席位、Master 分配、标签避让UdonSharp 开发者
05 · 路线系统369 个路线包与高德升级数据/后端开发者
06 · 服务端实现Fastify、缓存、隐私、数据生成后端/运维
07 · 部署指南本地、exe、Docker、FRP + NPM准备上线的人
08 · 世界内使用导入、放置、改地址、容量世界作者
09 · 排错与边界常见问题、限制与隐私所有人

当前状态

仓库里已经包含可运行的数据与预制件:

  • server/dist/VRCLOC.exe:双击启动,自带 Bun 运行时;
  • VRCLOC_Installer.unitypackage:导入其他世界即可使用;
  • 默认服务地址:https://map.u386706.nyat.app:53630;
  • 默认路线包以估算为主,配置 AMAP_KEY 后可升级为真实公共交通。

下一章从整体架构开始,把“定位、同步、路线”三条链路拆开看。

02 · 系统架构

VRCLOC:在 VRChat 里点亮同城玩家的中国地图
整体架构

三条链路

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 · 定位链路

VRCLOC:在 VRChat 里点亮同城玩家的中国地图
定位时序

为什么不能直接把 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 做三层匹配:

  1. 省市同时匹配:先在同省内找同名城市,避免“吉林市/吉林省”“海南/海南州”这类歧义;
  2. 短名匹配:部分数据源会把自治州缩短,例如“恩施州”,则按前两个字匹配;
  3. 省会兜底:只拿到省名时,用该省省会代替。
// 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_flagip-api 标记为代理
hosting_flagip-api 标记为机房/托管
proxy_ispISP 名称像加速器、VPN、IDC、云厂商

只要存在标记,响应里的 proxy_suspect 就是 true。世界端不会把这个结果当成可靠位置。

客户端如何判断“不可靠”

除了服务端标记,客户端还会自己做一次交叉验证:

  • 第一次请求和第二次请求使用了不同 URL/连接;
  • 如果两次返回的城市不同,说明出口可能在轮换(常见于加速器);
  • 此时标记 localUnreliable,路线框显示“网络出口:…”并提示手动选择。
if (valid && _stringAttempt > 0 && _previousCode >= 0 && code != _previousCode) {
    _stringUnreliable = true;
    _stringHint = "两次请求结果不同:" + _PreviousLabel() + " → " + ...;
}

手动兜底

手动定位是整条链路的最后一道保险,也是唯一不依赖网络的路径。

操作方式:

  1. 按路线框的 手动;
  2. 用 < / > 切换省份;
  3. 按 刷新 在该省内切换城市;
  4. 再按一次 手动 确认。

确认后:

  • 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
  }
}
字段说明
code0 未知;1..369 国内城市;>=370 国家
kindcn / foreign / unknown
precisioncity / province / country / unknown
on_map是否应该画在地图上
reasonok / private_or_invalid_ip / not_found
providers各 IP 库分别看到的城市,便于排查

这一章的关键结论

  1. 先国内直连:B 站接口是加速器场景下最有效的“旁路”。
  2. 回退按席位:固定且互不相同的 URL,解决缓存串号。
  3. 投票而非单库:宁可保守,也不要让一个陈旧数据库决定所有人的位置。
  4. 不可信就不画:错误的地图标记比没有标记更糟。
  5. 手动永远可用:这是所有自动方案都失败时的最终答案。

下一章看城市编号如何从一个客户端传到所有客户端,以及地图如何把这些编号变成描边和标签。

04 · 槽位与地图

VRCLOC:在 VRChat 里点亮同城玩家的中国地图
槽位同步

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 秒检查一次:

  1. 已占用槽位的玩家是否还在实例里;不在就回收;
  2. 同一玩家是否占了多个槽位;是就合并;
  3. 新玩家是否已有槽位或预留;没有就分配一个空闲槽位。

分配顺序很关键:

// 先由玩家自己 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°N

u 是经度归一化,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:在 VRChat 里点亮同城玩家的中国地图
世界内 UI 示意
  • 左侧是地图,固定展示,不放置缩放/平移按钮,避免误触;
  • 右侧是路线框,显示自己、目标玩家、目标城市、耗时、票价与换乘;
  • 地图和路线框可以独立放置,也可以使用一体化的 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:

  1. VRCLocationRegistry.slots;
  2. VRCLocationMap.slotMarkers;
  3. VRCLocationMap.slotLabels。

三者长度必须一致,否则会出现空引用。验证菜单会检查这一点:

slots=80 markers=80 labels=80

对大多数 PC 世界,保留 80 个槽位更省事;空槽位不产生同步流量。

这一章的关键结论

  1. 槽位同时承担身份、状态和缓存隔离,是整个世界端设计的核心。
  2. 先 Reserve 再 SetOwner,否则 Master 永远拿不到槽位。
  3. 本地乐观写入让手动选择立即生效,同步随后到达。
  4. 不可靠就不画,地图宁可少一个点,也不要多一个错误城市。
  5. 标签避让是必需的,中国城市密度足以让简单布局失效。

下一章看路线数据:如何把“实时查询”提前到构建期,并让 Udon 只做一次字典查找。

05 · 路线系统

VRCLOC:在 VRChat 里点亮同城玩家的中国地图
路线包结构

为什么是静态包

理想情况下,路线接口应该长这样:

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分段详情,换行分隔文本
srca 高德真实路线,e 估算—

键是目标城市的字符串编号。Udon 的 DataDictionary 键必须是字符串,所以客户端会显式转换:

DataToken keyToken = "" + targetCode;
DataToken entryToken;
if (!_routes.TryGetValue(keyToken, out entryToken) ||
    entryToken.TokenType != TokenType.DataDictionary) {
    text += "\n暂无该城市对的公共交通数据。";
    return;
}

客户端下载流程

路线框不会一进世界就下载 369 个包。它只在满足以下条件时下载当前出发城市的那一个:

  1. 本地玩家已经定位;
  2. 本地城市在国内;
  3. 至少有一位其他玩家;
  4. 当前没有正在下载的包;
  5. 出发城市与已加载的包不同。
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 km18 + km / 18 * 60 分钟2 + min(6, km * 0.18) 元地铁 / 公交
30–250 km35 + km / 65 * 60 分钟max(8, km * 0.32) 元城际巴士 / 普速铁路
> 250 km50 + 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

脚本会:

  1. 读取 data/packs/{origin}.json;
  2. 对每个目的地调用高德 v3/direction/transit/integrated;
  3. 取耗时最短的方案;
  4. 提取铁路车次、上下车站、地铁/公交线路、换乘站、步行距离;
  5. 写回 p 字段,并把 src 设为 a;
  6. 失败时保留原估算值。

写入后的包会被服务端直接读取。更新路线包不需要重新上传 VRChat 世界,因为 Udon 下载的是服务器上的静态文件。

服务端的实时调试接口

/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 个城市描边网格 + 预制件

这一章的关键结论

  1. 路线包是数据,不是服务:世界端只需要静态文件。
  2. 按出发城市分包:一次只下载一个文件,切换目标不重复下载。
  3. 估算是兜底,不是伪装:文本明确标注,避免误导。
  4. 高德是增强项:有 key 更准,没 key 也能跑。
  5. 更新数据不必重传世界:服务器换文件即可。

下一章进入服务端:Fastify 路由、缓存、隐私和数据生成脚本。

06 · 服务端实现

技术选型

项选择原因
运行时Node.js 22+ / Bun原生 fetch、node:test、无额外构建
Web 框架Fastify 5轻量、trustProxy 支持好、性能足够
IP 库ip2region完全离线,不依赖第三方
容器Docker + Compose一行命令部署
WindowsBun 单文件 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/cities369 城市 + 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

同时每个来源都有独立的最小调用间隔:

来源最小间隔说明
高德 IP350 ms免费配额 QPS 较低
百度 IP250 ms国内访问快
ip-api1400 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)。

这一章的关键结论

  1. 服务端可以没有数据库:文件 + 进程内缓存足够。
  2. 并发要合并:同一玩家的两个请求不应消耗两次配额。
  3. 隐私默认保守:不存 IP,不存玩家,不默认开日志。
  4. 数据生成是工程的一部分:地图、城市、信标、路线包都有可复现脚本。

下一章把服务真正部署起来:本地、exe、Docker,以及 FRP + NPM 的完整链路。

07 · 部署指南

VRCLOC:在 VRChat 里点亮同城玩家的中国地图
部署拓扑

目标链路

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-only

amap=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 --build

docker-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: 3

NPM 反代到 http://宿主机IP:8787 即可。

环境变量

变量默认说明
PORT8787监听端口
HOST0.0.0.0监听地址
AMAP_KEY空高德 Web 服务 key;留空则路线为估算
AMAP_IP_LOOKUPtrue有 key 时是否用高德做 IP 定位
AMAP_IP_TIMEOUT_MS1500高德 IP 超时
AMAP_IP_CACHE_HOURS6高德 IP 缓存
BAIDU_IP_LOOKUPtrue是否使用百度 IP 库
BAIDU_IP_TIMEOUT_MS2500百度超时
IP_API_LOOKUPtrue分歧时是否查询 ip-api
IP_API_TIMEOUT_MS5000ip-api 超时
LOG_LOOKUPSfalse写 data/ip-lookups.log
LOG_LOOKUP_IPSfalse日志是否保留完整 IP
PUBLIC_BASE_URL空仅用于文档和诊断
TRUSTED_PROXY_CIDRSloopback,linklocal,uniquelocal信任的反向代理网段
ENABLE_DEBUG_IPfalse开启 /v1/debug/ip
ROUTE_CACHE_DAYS30预留的路线包新鲜度配置

Sakura FRP

  1. 在 Sakura FRP 创建一个 HTTP 隧道,本地目标填 NPM 的 HTTP 地址和端口;
  2. 使用 Sakura 提供的域名做 CNAME 映射;
  3. 在 DNS 服务商把 loc.example.com CNAME 到 Sakura 分配的域名;
  4. 如果隧道设置里有“传递真实 IP / X-Forwarded-For”,打开它。

NPM 需要收到客户端的原始 IP,而不是 Sakura 节点的地址。

Nginx Proxy Manager

新建 Proxy Host:

项值
Domain Namesloc.example.com
Forward Hostname / IP运行服务的宿主机地址
Forward Port8787
Schemehttp
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_chainSakura/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、加速器
路线包返回 HTMLNPM 错误页/域名不对看 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换域名时首选

导入到另一个世界

  1. 目标工程准备好 SDK3 Worlds、UdonSharp、TMP Essentials;
  2. 双击 VRCLOC_Installer.unitypackage,全部导入;
  3. 执行 VRCLocation / 6. Add Complete Prefab to Scene,或直接把 Assets/VRCLocation/Prefabs/VRCLOC_Complete.prefab 拖进场景;
  4. 一个世界只放一个 VRCLOC_Complete;
  5. 如果使用自己的服务器,执行菜单 8 改地址;
  6. 上传世界。

安装包包含:

  • Assets/VRCLocation 下的完整源码;
  • Editor 构建、校验、URL 修改、打包工具;
  • UdonSharp 程序资源、着色器、材质、网格、纹理;
  • 城市数据、三个预制件、安装说明;
  • 中文字体资源的依赖。

不包含 UdonSharp/TMP 包本身,也不包含后端服务器。

修改服务器地址

方式一:菜单 8(推荐)

  1. 执行 VRCLocation / 8. Change Server URL...;
  2. 输入新的 https://你的域名;
  3. 点“应用到预制件和当前场景”。

它会同时改写:

  • 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,运行时改不了。

放置

一体化预制件

推荐直接使用 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 字段手动拖引用。

重建后旧实例不更新

如果场景里的旧实例没有自动更新:

  1. 删除旧实例;
  2. 重新拖入新的预制件;
  3. 重新上传世界。

旧实例可能保留旧的序列化字段和 _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

重点检查:

字段期望
cities369
outlines369
markers / labels与 slots 相同
routeUrls369
slotUrls / slotRetryUrls / domesticUrls80
complete panelRefsTrue
broken0

玩家操作

第一次进入

玩家需要开启:

VRChat 设置 → 允许不受信任 URL / Allow Untrusted URLs

否则字符串下载会失败:

  • 自动定位失败;
  • 路线包下载失败;
  • 地图上只有自己或什么都没有。

开启后重新进入世界,信标只在进入时请求一次。

路线框按钮

按钮作用
< / >切换目标玩家
刷新重新拉取当前出发城市的路线包
上页 / 下页路线详情超过一屏时翻页
定位重新尝试自动 IP 定位,并清除手动选择
手动进入/确认手动选择城市

手动模式:

  • < / > 切换省份;
  • 刷新 切换该省内的城市;
  • 再按一次 手动 确认;
  • 确认后显示“(手动)”,并覆盖自动定位;
  • 按 定位 可重新尝试自动定位。

文本含义

我:Neko
位置:合肥(手动)
目标:Mika
对方位置:上海

公共交通
耗时:约 4小时12分
票价:约 ¥198
线路:
· 打车 8.2 公里 到车站
· G7258 合肥南→上海虹桥 2小时58分
· 地铁2号线 上海虹桥→人民广场 28分钟 9站
数据:高德路线

特殊情况:

显示含义
定位中还在请求
未知请求完成但没有结果
网络出口:…(可能是加速器/代理)多库分歧或 IP 像代理
槽位同步中自己的槽位还没同步完成
对方位于国外(…)不在地图显示,不规划跨境路线
暂无该城市对的公共交通数据路线包缺少该目的地

玩家容量

默认 80 个槽位。如果只想要 32 人:

  1. 删除多余的 Slot_* GameObject;
  2. 同步缩小 VRCLocationRegistry.slots;
  3. 同步缩小 VRCLocationMap.slotMarkers;
  4. 同步缩小 VRCLocationMap.slotLabels。

四个数组长度必须一致,否则验证会报错,运行时可能空引用。

对大多数 PC 世界,建议保留 80 个槽位;空槽位不产生同步流量,调整数组反而容易出错。

面向玩家的说明模板

如果这是公开世界,可以在世界规则或公告里写:

这一章的关键结论

  1. 优先用一体化预制件,引用已经接好。
  2. 换域名优先用菜单 8,不要手动改 369 个 URL。
  3. 改域名后必须重建或批量改写,否则运行时无法修改。
  4. 玩家必须允许不受信任 URL,这是所有下载链路的前提。
  5. 旧实例要删掉重拖,不要指望资产重建自动清理序列化字段。

下一章汇总常见问题、边界与隐私注意事项。

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. 地图左右颠倒 / 只看到背板

  1. 确认场景实例根节点 Rotation 是 (0, 0, 0);
  2. 确认没有手动转过 180°;
  3. 删除旧实例,重新拖入预制件;
  4. 仍然反向时,关闭 Unity 后执行菜单 2 重建。

预制件内部已经按 -Z 正面排好图层顺序,正常情况下不需要旋转。

3. 地图没有描边,路线框显示“定位中/未知”

最常见原因:玩家没有开启 Allow Untrusted URLs。

处理:

  1. 让玩家在 VRChat 设置中允许不受信任 URL;
  2. 重新进入世界(信标只在进入时请求一次);
  3. 检查域名证书是否有效;
  4. 检查 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。

新版构建器已移除它。处理:

  1. 执行菜单 2 重建;
  2. 重新上传世界;
  3. 确认按钮有 BoxCollider;
  4. 玩家站在约 2 米内再试。

10. 手动选了城市,但地图没有描边 / 其他人看不到

可能原因:

  • 旧版 Master 把“默认拥有所有未占用对象”误判成“已经预留槽位”,导致 Master 自己拿不到槽位;
  • 槽位所有权还没同步完成;
  • 场景实例保留了旧序列化字段。

新版会:

  • 先 Reserve 再转移所有权;
  • 地图优先显示本地玩家自己的城市;
  • 允许本地乐观写入。

处理:重新编译并上传世界;必要时删除旧实例重新拖入预制件。

11. 某个玩家一直显示北京/日本,但他实际在南京

按顺序排查:

  1. 确认已上传最新世界,日志里应出现:

“text [VRCLOC] domestic result country=中国 province=江苏 city=南京 code=74 “

  1. 如果 B 站返回南京但地图仍不对:检查城市表是否最新,重新执行菜单 2;
  2. 如果 B 站也返回日本:这位玩家的 B 站 HTTP 请求同样被代理了,只能改用 UU 路由模式或手动;
  3. 如果回退到服务器:路线框会显示“网络出口:…(可能是加速器/代理)”和各库城市;
  4. 查看 dist/data/ip-lookups.log 最新一行,比较 amap / baidu / ip2region / ip_api 的 city 与 reasons;
  5. 如果所有库都显示北京/日本,说明该玩家请求的出口就是北京/日本,任何 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。

发布世界前请自行确认这些数据/服务的使用条款,尤其是商用场景。

可以继续做的方向

  1. 隐藏我的位置:给每个玩家一个本地开关,不写入槽位。
  2. Quest 优化:裁剪城市网格、压缩字体、降低纹理分辨率。
  3. 更细的路线:扩大高德升级范围,或接入其他公共交通数据源。
  4. 城市别名表:补充更多简称、旧称、民族自治州缩写。
  5. 世界内配置面板:不重建预制件也能切换服务器地址和数据源。
  6. 更完善的诊断:在路线框里显示当前使用的数据源和最后一次错误。

结语

VRCLOC 的核心并不复杂:

固定 URL + 一个城市编号 + 一个同步槽位

真正花时间的,是围绕这三个简单概念处理各种失败:

  • 加速器让 IP 不可信;
  • 共享缓存让结果串号;
  • 网络延迟让状态不同步;
  • 序列化字段让旧实例行为异常;
  • 城市名歧义让匹配出错。

把这些边界一个个补上之后,剩下的就是一张会亮的地图和一段可以查询的路线。

希望这套实现能给你的 VRChat 世界一点参考。如果只需要一个能用的版本,直接导入 VRCLOC_Installer.unitypackage;如果打算自己改,从 Assets/VRCLocation/Scripts/ 和 server/src/ 开始读即可。


本文基于仓库当前实现整理。代码、数据与预制件以仓库为准;如果你在自己的世界中部署,请先阅读第 07 章的部署清单和第 09 章的边界说明。

主题测试文章,只做测试使用。发布者:zy,转转请注明出处:https://zynetc.top/?p=2026102701

赞 (0)
zy的头像zy
不改 BIOS,不重装系统:用一块 U 盘让老服务器从 NVMe 启动 PVE
上一篇 6天前
KEY (KeyForge):原生 Windows 本地加密密钥生成与管理工具
下一篇 5天前

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注