实时游玩页与 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 前端的核心,它把前面阶段的登录、小说、节点、角色选择全部串联起来,并承接后端实时游戏循环。相比普通页面开发,这部分更考验前端对异步状态、用户操作和后端联调的综合处理能力。

相关推荐
小短腿的代码世界1 小时前
WebSocket协议在Qt中的工业级实现:5层架构设计与万级并发压测验证
qt·websocket·网络协议
葡萄皮sandy3 小时前
SSE和WebSocket
网络·websocket·网络协议
hyunbar7773 小时前
配置 Cloudflare Tunnel:把 Mac 上的 Web 服务变成安全域名
网络协议
酉鬼女又兒4 小时前
零基础入门IPv4地址:从基本概念、分类编址、子网划分到无分类编址与应用规划全解
网络·网络协议·计算机网络·考研·职场和发展·分类·智能路由器
未来侦察班5 小时前
网络协议 数据链路层,“帧”建立统一新秩序
网络·网络协议
极创信息6 小时前
信创产品适配测试认证,域名和SSL是必须的吗?
java·开发语言·网络·python·网络协议·ruby·ssl
未来侦察班6 小时前
网络协议物理层,“地基“是怎么练成的
网络·物联网·网络协议·物理层·tcpip
七夜zippoe6 小时前
DolphinDB HTTP API接入:RESTful数据推送
网络协议·http·api·restful·dolphindb
我是一颗柠檬6 小时前
【计算机网络全面教学】应用层核心协议,HTTP/DNS/DHCP/FTP/SMTP全解析Day5(2026年)
网络协议·计算机网络·http