跳至主要內容

用游戏引擎做 Web 应用——Juyiting 前端架构揭秘

布衣云水客大约 5 分钟文章前端TypeScript游戏引擎melonJS架构设计

概要

当大多数 Web 前端还在用 Vue/React 写表单和列表时,我们做了一个大胆的决定:用游戏引擎来驱动一个策略管理类 Web 应用的前端

Juyiting(聚义厅)是一个基于 melonJS 游戏引擎 + Vue 3 混合架构的项目。它不是游戏,但它用了游戏的渲染管线、摄像机系统、寻路算法和动画引擎——因为业务场景本身就长着一张"地图"的脸。

本文将解析这套架构的核心设计,以及为什么游戏引擎比传统前端框架更适合某些场景。

为什么用游戏引擎?

业务场景是这样的:一张大厅地图,上面有多个角色(Agent),它们需要在大厅中自主移动、到达指定位置、执行行为。这不就是 RTS 游戏的基本操作吗?

传统前端方案会遇到的问题:

问题传统方案游戏引擎方案
地图渲染Canvas 手绘或 DOM 拼接TMX 瓦片地图,原生支持
角色动画CSS animation / LottieSprite sheet + 帧动画
路径移动自己实现 A*内置寻路 + 插值移动
摄像机控制手动计算视口内置 Camera + Zoom
移动端适配各种 hack游戏引擎天然处理

结论:当你的 UI 长得像游戏时,用游戏引擎比用 UI 框架更合理。

架构总览

核心思想是双线程协作

  • Vue 负责 UI 层(菜单、弹窗、表单)
  • Game Engine 负责世界渲染(地图、角色、动画、移动)

两者通过共享状态和事件总线通信。

地图系统:TMX + 运行时数据

地图使用标准的 TMX(Tile Map XML) 格式,由 Tiled 编辑器制作。但我们不仅仅是渲染一张静态地图——我们需要运行时数据结构来驱动模拟:

export interface MapRuntimeData {
  sceneId: string
  width: number
  height: number
  regions: Region[]       // 区域(带容量、风险等级)
  nodes: NavNode[]         // 导航节点
  edges: NavEdge[]         // 导航边
  slots: Slot[]            // 可站立位置
  obstacles: MapPolygon[]  // 障碍物
}

每个概念都有明确的运行时语义:

元素含义运行时作用
Region逻辑区域(如"聚义厅"、"兵器库")角色目标位置、容量控制
NavNode导航网格节点A* 寻路的顶点
NavEdge导航网格边A* 寻路的连接
Slot角色站立位置分配角色在区域内的具体坐标
Obstacle障碍物多边形寻路避障、点击检测

摄像机系统:适配一切屏幕

移动端适配是整个项目最头疼的问题。软键盘弹出、屏幕旋转、横屏竖屏——每一种变化都会改变可视区域。

export const classifyViewportResize = (resize: ViewportResize): ViewportResizeKind => {
  // 键盘弹出检测:宽度几乎不变,视觉高度大幅减小
  const isKeyboard = oldWidth === newWidth && visualHeightDelta >= 120
  
  // 屏幕旋转检测:宽高对调
  const orientationChanged = (oldW > oldH) !== (newW > newH)
  
  if (isKeyboard) return 'keyboard'
  if (orientationChanged) return 'orientation'
  return 'layout'
}

三种 resize 类型对应不同的处理策略:

  • keyboard:保持游戏画面不动,避免闪烁
  • orientation:重新计算缩放比例和视口
  • layout:响应浏览器窗口变化

核心策略是 scaleMethod: 'fit'——保持地图完整可见,上下或左右留黑边。未来计划加入"摄像机覆盖"方案消除黑边。

移动引擎:不只画出来,还要"演"出来

角色的移动不是简单的 CSS transition,而是一个完整的模拟引擎:

export type MovementEngine = {
  enqueue(command: MovementCommand): MovementCommandPushResult
  cancel(agentId: string, stateVersion?: number): boolean
  setLocalPatrols(patrols: readonly LocalPatrolAssignment[]): void
  update(deltaMs: number): void
  snapshots(): AgentSnapshot[]
  drainPhaseEvents(): SimulationPhaseEvent[]
}

关键设计:

  • 命令队列:所有移动请求排队执行,支持取消和替换
  • A* 寻路:基于导航网格的最短路径计算
  • 槽位分配:多个角色到达同一区域时自动分配不重叠的站立位置
  • 时间驱动update(deltaMs) 每帧调用,基于时间戳插值计算位置
  • 后端状态同步:通过 SSE 接收服务端的角色状态指令,前端做进度恢复(recover movement progress),确保刷新页面后角色仍在正确位置

后端状态恢复

这是最精巧的部分。后端通过 SSE 推送角色的移动指令,包含开始时间和预计到达时间。前端断线重连后,根据时间戳反算出角色应该走到哪里:

export function normalizedProgress(
  startedAt: BackendTimestamp,
  expectedArrivalAt: BackendTimestamp | undefined,
  nowMs: number,
): number {
  const start = timestampMs(startedAt)
  const end = timestampMs(expectedArrivalAt)
  if (end === start) return nowMs <= start ? 0 : 1
  return clamp((nowMs - start) / (end - start), 0, 1)
}

这样既保证了服务端是权威状态源,又实现了前端流畅的动画表现——不需要每秒轮询位置,只需要在状态变更时收到通知。

输入系统:鼠标和手指一视同仁

// pointerGesture.ts —— 统一的指针手势抽象
export type PointerGesture = {
  onPointerDown(point: MapPoint): void
  onPointerMove(point: MapPoint): void
  onPointerUp(point: MapPoint): void
}

无论是鼠标点击还是手指触摸,最终都映射为"地图上的一个点"。点击检测通过 hitTest.ts 完成,判断点击落在了哪个区域、哪个角色上。

小结

这套架构的几个核心原则值得复用:

  1. 用对的工具做对的事——地图和角色用游戏引擎,表单和菜单用 Vue
  2. 时间戳驱动,而非帧驱动——用时间戳做动画插值,保证不同帧率下表现一致
  3. 后端权威 + 前端恢复——SSE 保证状态一致性,前端做平滑过渡
  4. 移动端优先考虑 resize——键盘、旋转、窗口变化都要分类处理

如果你面对的场景也"长得像游戏"——有地图、有角色、有移动——不妨考虑跳出传统前端的盒子,试试游戏引擎这个选项。


Juyiting 源码见 D:\workspace\chcbz\project\jia\web\jia-web-kit\src\game\