本阶段是我前端开发工作中最复杂的一部分,主要目标是完成 StoryVerse 的实时游玩页。这个页面同时依赖 REST 接口初始化、WebSocket 接收实时推送、本地缓存恢复、用户输入提交、暂停恢复、游戏结束和异常处理。相关代码集中在 frontend/src/views/PlayView.vue、frontend/src/api/play.ts 和 frontend/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 的错误并不一定会直接表现为接口请求失败,很多时候只是"页面没有继续变化"。因此我在页面状态中加入 lastMessageAt、secondsSinceLastMessage 和 runtimeHint,用于判断是 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_state 是 terminated。这说明问题不是前端请求不到数据,而是该会话已经结束,后端不会再继续推送 WebSocket 消息。这个排查过程让我明确了:实时页面的"卡住"不一定是浏览器问题,也可能是后端运行状态、AI 服务等待、WebSocket 推送或已结束会话造成的。前端必须提供足够的状态提示和错误处理,才能让问题可定位。
从工作量上看,本阶段我完成了游玩页三栏布局、REST 初始化、WebSocket 连接、实时消息解析、行动时间线、用户介入、输入提交、暂停恢复、游戏结束弹窗、错误弹窗、本地存档、重复消息过滤、自动滚动和运行状态调试提示。这个页面是 StoryVerse 前端的核心,它把前面阶段的登录、小说、节点、角色选择全部串联起来,并承接后端实时游戏循环。相比普通页面开发,这部分更考验前端对异步状态、用户操作和后端联调的综合处理能力。