实时游玩页与 WebSocket 状态管理实现

本阶段是我前端开发工作中最复杂的一部分,主要目标是完成 StoryVerse 的实时游玩页。这个页面同时依赖 REST 接口初始化、WebSocket 接收实时推送、本地缓存恢复、用户输入提交、暂停恢复、游戏结束和异常处理。相关代码集中在 frontend/src/views/PlayView.vuefrontend/src/api/play.tsfrontend/src/stores/savedGames.ts

我先在 api/play.ts 中定义了游玩相关接口和类型。这里的类型定义很重要,因为前端需要同时处理场景状态、情节状态、角色状态和游戏运行状态。如果不提前定义结构,页面里会出现大量不清晰的 any,后续调试 WebSocket 消息会很困难。

复制代码
export interface PlayStateMessage {
  session_id: number
  running_state: 'running' | 'terminated' | string
  action_text: string | null
  global_info: PlayGlobalInfo | null
  character_info: PlayCharacterInfo[] | null
}

这个结构对应后端推给前端的核心消息。running_state 决定游戏是否继续,action_text 是最新行动文本,global_info 决定左侧世界信息,character_info 决定右侧角色状态。页面中的大部分状态更新都围绕这个消息展开。

游玩页初始化时,前端从路由中读取 sessionId,先判断是否合法,再调用 /api/play/getInitInfo/{sessionId} 获取初始世界信息。这个步骤不能省略,因为 WebSocket 只负责后续推送,如果用户刷新页面,前端必须先通过 REST 接口恢复当前基础状态。

复制代码
const sessionId = computed(() => Number(route.params.sessionId))
const hasValidSessionId = computed(() => Number.isFinite(sessionId.value) && sessionId.value > 0)

async function loadInitialState() {
  loading.value = true
  pageError.value = ''
  if (!hasValidSessionId.value) {
    pageError.value = '当前游玩地址无效:缺少可用的 sessionId。请返回首页重新进入。'
    loading.value = false
    return
  }
  try {
    const response = (await getInitInfo(sessionId.value)) as PlayStateMessage
    currentState.value = response
    updateSavedGameFromState(response)
  } catch (error: any) {
    pageError.value = error?.response?.data?.message || error?.message || '游戏初始信息加载失败。'
  } finally {
    loading.value = false
  }
}

这段逻辑体现了 REST 初始化的作用:它负责"进入页面时先有一份可展示的状态"。如果只有 WebSocket,刷新页面后用户可能只能看到空页面并等待下一次推送,这种体验不可控。

实时推送部分通过 WebSocket 连接后端:

复制代码
function wsUrl() {
  return `ws://localhost:8080/ws/play/${sessionId.value}`
}

function connectSocket() {
  socket.value?.close()
  socketStatus.value = '连接中'
  const ws = new WebSocket(wsUrl())
  socket.value = ws

  ws.onmessage = (event) => {
    handleSocketMessage(String(event.data))
  }

  ws.onerror = () => {
    socketStatus.value = '连接异常'
    pageError.value = '游戏连接异常,请检查后端服务。'
  }
}

这里我不仅建立连接,还维护 socketStatus,用于页面调试和用户提示。实际联调时,WebSocket 的错误并不一定会直接表现为接口请求失败,很多时候只是"页面没有继续变化"。因此我在页面状态中加入 lastMessageAtsecondsSinceLastMessageruntimeHint,用于判断是 WebSocket 没连上、后端还没推送,还是后端正在等待 AI 服务返回。

消息处理是游玩页的核心。我把原始字符串解析为 PlayStateMessage,再交给 applyMessage 统一更新页面。

复制代码
function handleSocketMessage(raw: string) {
  lastMessageAt.value = Date.now()
  try {
    const message = JSON.parse(raw) as PlayStateMessage
    if (paused.value) {
      bufferedMessages.value.push(message)
      return
    }
    applyMessage(message)
  } catch {
    pageError.value = '收到的游戏数据无法解析。'
  }
}

这里有两个关键设计。第一,解析失败时给出前端错误,而不是让异常直接打断组件。第二,如果用户处于暂停状态,消息不会立即应用,而是进入 bufferedMessages。这样暂停不是简单禁用按钮,而是真正阻止 UI 状态继续推进。恢复时再把缓存消息依次应用:

复制代码
if (paused.value) {
  await resumeGame(sessionId.value)
  paused.value = false
  const messages = [...bufferedMessages.value]
  bufferedMessages.value = []
  messages.forEach(applyMessage)
}

用户介入模式也是本阶段的重点。页面有三种模式:ai 表示 AI 正常运行,waiting 表示用户已经申请介入但要等待当前 AI 行动结束,intervene 表示用户可以输入自己的行动。这个状态机控制了"介入""输入框""提交"按钮是否可用。

复制代码
const canIntervene = computed(
  () => mode.value === 'ai' && !paused.value && busyAction.value !== 'intervene',
)
const canEditInput = computed(
  () => mode.value === 'intervene' && !paused.value && !playerInputPending.value && busyAction.value !== 'input',
)
const canSubmit = computed(
  () => canEditInput.value && inputText.value.trim().length > 0,
)

这比直接在按钮点击里判断更清晰,因为 UI 可用性本身就是状态的一部分。用户点击介入后,前端先进入 waiting,调用 /api/play/intervene,再等待后端当前 AI 行动结束。如果 WebSocket 长时间没有新消息,我还设计了一个前端兜底机制,避免用户一直卡在等待状态。

复制代码
function scheduleInterventionFallback() {
  clearInterventionWaitTimer()
  const lastMessageAgeMs = lastMessageAt.value ? Date.now() - lastMessageAt.value : Number.POSITIVE_INFINITY
  const delay = lastMessageAgeMs >= STALE_PUSH_UNLOCK_MS ? 0 : INTERVENTION_WAIT_FALLBACK_MS

  interventionWaitTimer = window.setTimeout(() => {
    enterInterventionMode(delay === 0 ? 'stale websocket push' : 'waiting timeout')
  }, delay)
}

这个逻辑来自实际联调中的问题:后端有时会等待 AI 模型或角色调度结果,前端如果只依赖一次推送来切换模式,用户可能误以为页面坏了。兜底机制不是绕过后端,而是在长时间无推送时允许用户继续操作,并通过调试日志记录原因。

行动列表的处理也比较细。系统行动和玩家行动需要区分显示,玩家提交后要先本地追加到队列,让用户看到自己的输入已经提交;如果接口失败,再把最近的玩家行动标记为失败。

复制代码
function appendPlayerAction(text: string) {
  const id = `player-${Date.now()}-${actionItems.value.length}`
  actionItems.value.push({
    id,
    text,
    source: 'player',
    status: 'confirmed',
  })
  saveActionQueue(text)
  void scrollActionsToBottom()
}

系统推送的行动还做了去重处理:

复制代码
const normalizedText = normalizeActionText(text)
const recentDuplicate = actionItems.value
  .slice(-8)
  .some((item) => normalizeActionText(item.text) === normalizedText)
if (recentDuplicate) {
  return
}

这是因为在刷新页面、恢复本地缓存和接收后端推送时,可能会出现相同文本重复进入列表的情况。前端做近邻去重,可以避免时间线里连续出现相同内容,提升可读性。

本阶段一个典型调试案例是 /play/73 页面看起来卡住。我检查后发现三个服务都在运行,getInitInfo/73 也能返回,但返回的 running_stateterminated。这说明问题不是前端请求不到数据,而是该会话已经结束,后端不会再继续推送 WebSocket 消息。这个排查过程让我明确了:实时页面的"卡住"不一定是浏览器问题,也可能是后端运行状态、AI 服务等待、WebSocket 推送或已结束会话造成的。前端必须提供足够的状态提示和错误处理,才能让问题可定位。

从工作量上看,本阶段我完成了游玩页三栏布局、REST 初始化、WebSocket 连接、实时消息解析、行动时间线、用户介入、输入提交、暂停恢复、游戏结束弹窗、错误弹窗、本地存档、重复消息过滤、自动滚动和运行状态调试提示。这个页面是 StoryVerse 前端的核心,它把前面阶段的登录、小说、节点、角色选择全部串联起来,并承接后端实时游戏循环。相比普通页面开发,这部分更考验前端对异步状态、用户操作和后端联调的综合处理能力。

相关推荐
子不语1805 小时前
从0开始学习S7-1200+ET200SP(3)——两台S7-1200通过TCP连接
网络协议·学习·tcp/ip
折哥的程序人生 · 物流技术专研5 小时前
Java面试通关⑦:JavaWeb网络核心全集
网络协议·http·javaweb·校招·前后端交互·java面试·社招
小蜗牛的路8 小时前
使用OpenSSL生成本地证书https+nginx
网络协议·nginx·https
FPGA小迷弟9 小时前
vivado中的AXI Interconnect到底应该怎么用,他的底层原理是什么,一篇文档全部理清楚!!!
网络协议·tcp/ip·fpga开发·verilog·fpga
网络攻城狮_9 小时前
网络协议大全
运维·网络·网络协议·http
薛定谔的猫-菜鸟程序员10 小时前
从零构建一个“悬浮式“实时聊天室:Electron + Vue 3 + WebSocket + SQLite 全栈实践
vue.js·websocket·electron
ps酷教程10 小时前
WebSocketFrameEncoder&WebSocketFrameDecoder源码浅析
websocket·netty
hbugs0011 天前
【案例分享】全网首个华三数据中心流量可视化实验,基于EVE-NG V7平台
网络·网络协议·安全·devops·eve-ng
chase_my_dream1 天前
FAST-LIO src/IMU_Processing.hpp 完整详细讲解
c++·状态模式·slam
yxl874646461 天前
PCTG-1015型Profinet转Ethernet/IP协议转换器
服务器·网络·物联网·网络协议·自动化·信息与通信