可视化面板拓扑图:四边 Port 环形布局

上一篇写了 Flow 粒子动画的实现。这一篇记录可视化面板的后续大改——从固定 5 玩家到动态玩家管理,从底部单侧 port 到四边环绕 port,以及一次 INP 性能修复。

动态玩家管理:从固定 5 人到无限扩展

之前的可视化面板硬编码了 5 个玩家(p1~p5),调试时只能看预设场景。实际联机房间里玩家是动态加入离开的,固定人数的面板根本不够用。

Create/Join 弹窗

现在点”添加玩家”会弹出一个对话框,输入玩家名后选择 Create(创建新房间)或 Join(加入已有房间):

1
2
3
4
5
function showAddPlayerDialog() {
// 弹窗:输入玩家名 + 选择 create/join
// Join 按钮旁实时列出已有房间的下拉框
// Create 自动创建 room_{name} 房间
}

Join 按钮旁边有个下拉框,实时列出当前所有房间。选好房间点 Join,玩家就加入去了。

动态 Store

Store 里加了 testPlayers Map,支持动态增删:

1
2
3
4
5
// store.js
const testPlayers = new Map();
function addTestPlayer(name, roomId) { ... }
function removeTestPlayer(name) { ... }
function getTestRooms() { return [...new Set([...testPlayers.values()].map(p => p.roomId))]; }

不再受限于 PLAYER_IDS 数组。创建几个玩家就有几个,删除后 room card 也跟着消失。

Room Card 内嵌操作按钮

之前的测试操作都在侧边栏,切来切去很麻烦。现在直接在 room card 的每个玩家行里放了三个按钮:

1
2
3
4
5
+-- Room A ----------------------+
| A00 (owner) |
| A01 [Send][HB][X] | <- 发送/心跳/离开
| A02 [Send][HB][X] |
+--------------------------------+

发送按钮旁边有内联消息输入框,Enter 可快捷发送。这些按钮只在 test 模式下显示,monitor 模式隐藏。

实现上的坑:输入框要阻止 mousedown 事件,否则拖拽 room card 时会误触输入框焦点。setMode 切换时要递增 _v 版本号触发 html() 重渲染,否则按钮状态不会更新。

INP 性能修复:30 秒到 100 毫秒

这次改动里最头疼的问题。之前点击”发送”按钮后,界面卡死 30 秒以上才能操作。

根因分析

debugAction 函数里有三个严重阻塞:

  1. waitForAnimationsDone(30s) — 等所有边动画跑完才释放按钮锁
  2. syncRooms 重试循环 — 每次触发全量 graph sync,3 次重试 × 300ms = 900ms+
  3. 末尾 renderPlayerCards() — 全量重建所有玩家卡片 DOM

三个加起来,一次操作要等 30 秒以上。

修复方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 修复前:
async function debugAction(...) {
lockButtons(); // 锁住按钮
await syncRooms(); // 900ms+ 重试
await fetch(...); // 发请求
await waitForAnimationsDone(30000); // 等 30 秒!
renderPlayerCards(); // 全量重渲
unlockButtons();
}

// 修复后:
async function debugAction(...) {
lockButtons();
await syncRooms(); // 改为单次调用
await fetch(...);
// 移除 waitForAnimationsDone(burst queue 已处理并发)
// 移除 renderPlayerCards(SSE 会推送更新)
unlockButtons();
}

核心改动:

  • 移除 waitForAnimationsDone — burst 队列已经处理了并发动画,不需要额外等待
  • syncRooms 从 3 次重试改为单次 — send/heartbeat 不改变房间结构,SSE 会处理 flow 事件
  • 移除末尾 renderPlayerCards — SSE 推送会触发数据更新,不需要手动重建 DOM

实测效果:create ~118ms,send ~103ms。从 30 秒降到 100 毫秒,体验完全不同。

四边 Port 系统:从 5 个到 20 个

之前的 proxy 只在底部有 5 个 port(p0~p4),所有玩家连线都从底部出发。房间多了之后连线全挤在一起,视觉上很乱。

新设计:四边各 5 个 port

现在 proxy 的上、下、左、右四边各有 5 个 port,总共 20 个。port id 格式改为 ${side}-p${slot},例如 top-p0、right-p3。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
["top", "right", "bottom", "left"].forEach((side) => {
for (let i = 0; i < cfg.PROXY_PORTS.length; i++) {
portItems.push({
id: `${side}-p${i}`,
group: side,
markup: portMarkup,
attrs: {
circle: {
magnet: true,
r: LAYOUT.portR, // 7px
fill: "#2563eb",
stroke: "#93c5fd",
},
portLabel: {
text: String(i),
// ...
},
},
});
}
});

port 间距:相邻圆心距离 2 * portR + portClusterGap = 19px,圆球边缘视觉间隔 5px。

环形房间布局

房间不再排成一列,而是环绕 proxy 四周排列。room 按顺序分配到 top/right/bottom/left 四侧:

  • top/bottom 侧横向排列
  • left/right 侧纵向排列
  • 6 房以内 ringGap = 74,尽量落在首屏
  • 超过 6 房或 Monitor 模式 ringGap = 96

这样每个房间的连线方向不同,视觉上不会全挤在一起。

地铁线路由:玩家边的走线规则

之前用 X6 的 manhattan 自动 router 做玩家边,结果连线经常穿过 proxy 节点或者互相重叠。现在改成手写的地铁线路由。

核心规则

1
2
3
4
5
6
function computeMetroEdgeGeometry(source, target, side, rank) {
// 1. port 端第一段必须是短斜段,不允许纯竖直或纯水平离开
// 2. player pin 端最后一段也必须是短斜段
// 3. 每条线先进入所属侧的独立 lane,再折向目标
// 4. lane 偏移由 sideRank + mainLaneGap 决定
}

connector 改用 jumpover 类型,遇到其他边时画弧线跳过:

1
2
3
4
5
connector: {
type: "arc",
size: 7,
radius: 6,
}

每条边的 label 是 playerId@portId,例如 A00@9201,方便定位。

心跳功能:后端 + 前端联动

联机中心跳包是业务层的,proxy 主路径无法识别(心跳包夹在普通数据包里,没有独立 magic)。所以走 debug 显式触发:

后端

新增 /proxy/debug/heartbeat 入口:

1
2
3
4
5
6
// cmd/proxy/debug.go
func handleDebugHeartbeat(c *gin.Context) {
// 解析 playerId + source 标签
// 发布 type=heartbeat 的 SSE 事件
// 不真发 UDP 包,只通知 visualizer
}

前端

心跳事件用半透明灰粒子渲染(区别于 ingress 的绿色),点击❤️按钮触发。

UI/UX 改进

  • 中文 i18n:侧边栏和 UI 文本全部改为中文
  • Loading 动画:侧边栏数据加载时显示 loading
  • Tooltip 图标:说明文本改为可点击 ? 图标 tooltip
  • 指标栏整合:Proxy PID 和运行时间从侧边栏迁移到顶部指标栏
  • 侧边栏始终展开:移除收缩功能,避免误触
  • 消息输入框加宽:从 80px 逐步加宽到 210px,适配 350px 卡片宽度

浏览器几何检查脚本

拓扑改完后需要验证连线几何是否正确。注入 6 房 × 5 玩家后跑这段脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const graph = window.MM.x6Topology.getGraph();
const edges = graph.getEdges().filter(e => (e.getData() || {}).kind === "player-edge");
edges.map(e => {
const source = e.getSource();
const target = e.getTarget();
const vertices = e.getVertices();
const first = vertices[0];
const last = vertices[vertices.length - 1];
return {
label: e.prop("labels/0/attrs/text/text"),
firstDx: first.x - source.x,
firstDy: first.y - source.y,
lastDx: target.x - last.x,
lastDy: target.y - last.y,
};
});

验收条件:所有 player edge 的首段和末段都不能是纯水平或纯垂直(firstDx !== 0 && firstDy !== 0)。

commit 时间线

commit 类型 内容
dd6024e feat 动态玩家管理——不限人数、Create/Join 弹窗
6e9db3b feat room card 内嵌玩家操作按钮
9f9d5b9 perf INP 卡顿修复:30s → 100ms
aaddb38 fix manhattan 路由替换 orth 路由
e349378 fix jumpover 连接器改用 arc 类型
0921bf8 docs 记录拓扑绘制规则
5d5943f refactor 移除侧边栏测试面板
d3bde5f feat heartbeat 入口 + SSE 事件
4a8227c i18n UI 文本全部改为中文

下一步打算接入真实的多人联机数据做压力测试,或者加 Prometheus 指标监控。如果你有其他想法,欢迎提 issue。