前端开发日志——游戏页核心交互 + SSE 流式展示 + 角色行动

0. 本周目标(在第 1 周基础上继续)

第 1 周我已经把“大厅 → 创建会话 → 启动游戏 → 进入游戏页”的闭环跑通了,但游戏页当时更偏“展示”。第 2 周我主要做两块:

  • 把推进流程做完整:点击“继续推进”→ 调 /advance → SSE 流式显示 AI 输出 → 最终刷新 state/next
  • 把“需要玩家操作”的回合做出来:当用户是狼/女巫且 next 指向相应节点时,出现操作面板并提交 /action/*

同时补一些“像真实产品”的体验:loading 遮罩、消息样式区分、玩家列表等。


1. 游戏页信息展示:阶段、身份、存活玩家

游戏页在 frontend/src/views/Game.vue。为了让用户能立刻理解当前局面,我把“阶段”“身份”做成顶部 tag,且阶段做了一层映射,把状态机节点翻译成中文。

const phaseLabel = computed(() => {
  const p = store.state?.phase
  const map: Record<string, string> = {
    setup: '准备中',
    wolf_turn: '狼人行动',
    witch_turn: '女巫行动',
    seer_turn: '预言家行动',
    night_resolution: '夜晚结算',
    day_discussion: '白天讨论',
    voting: '投票环节',
    voting_resolution: '投票结算',
    game_over: '游戏结束'
  }
  return p ? map[p] || p : '未知'
})

const roles = computed(() => store.state?.roles || {})
const userRole = computed(() => roles.value['User'])

存活玩家列表则来自 alive_players,UI 上固定渲染 User/Bot1~Bot5,并通过 alivePlayers.includes(p) 灰掉出局玩家(这部分做得比较“课程项目风格”,不追求完全动态玩家数,但展示效果直观)。


2. “继续推进”与 SSE 流式输出:把 token 拼成一条 AI 消息

本项目后端的 /advance 会通过 SSE 逐段输出 token,最后再推一次包含 done: True 的最终 state/next。所以我做了一个 handleStreamData,逻辑是:

  • 第一次收到 token:先在消息数组尾部 push 一条 {type:'ai', content:''}
  • 后续 token:不断追加 content,并替换 store 里对应索引的消息
  • 最终 done:用 finalData.state 覆盖 state,保证和后端完全一致

核心实现(真实代码片段):

const handleStreamData = (data: any, streamState: { isStreaming: boolean, currentMessageIndex: number, streamMessage: any }) => {
  if (data.type === 'token') {
    if (!streamState.isStreaming) {
      streamState.isStreaming = true;
      streamState.currentMessageIndex = store.state?.messages?.length || 0;
      const newMessages = [...(store.state?.messages || [])];
      newMessages.push(streamState.streamMessage);
      store.setState({ ...store.state!, messages: newMessages }, store.next);
    }
    streamState.streamMessage.content += data.content;

    const updatedMessages = [...(store.state?.messages || [])];
    updatedMessages[streamState.currentMessageIndex] = { ...streamState.streamMessage };
    store.setState({ ...store.state!, messages: updatedMessages }, store.next);
  }
}

const advanceStep = async () => {
  if (!store.threadId) return;
  store.setLoading(true)
  try {
    const streamState = { isStreaming: false, currentMessageIndex: 0, streamMessage: { type: 'ai', content: '' } };
    const finalData = await advance(store.threadId, (data) => handleStreamData(data, streamState))
    store.setState(finalData.state, finalData.next)
  } catch (e) {
    console.error(e)
  } finally {
    store.setLoading(false)
  }
}

我这周的体会:流式 UI 的关键是“让用户知道系统在工作”。如果只等最终结果再一次性显示,用户会误以为按钮没点上或程序卡死;把 token 实时刷到气泡里,体验立刻不一样。


3. 判断何时需要玩家行动:用 next 节点驱动 UI

后端会返回 next(状态机下一步节点列表),前端 store 里保存为 store.next。我用 nextNode = store.next?.[0] 来决定是否展示“你的回合”操作卡片:

const nextNode = computed(() => store.next?.[0])

const needsAction = computed(() => {
  if (userRole.value === 'Wolf' && nextNode.value === 'wolf_turn') return true
  if (userRole.value === 'Witch' && nextNode.value === 'witch_turn') return true
  return false
})

这样做的好处是:页面逻辑更“数据驱动”。后端状态机怎么走,前端只要遵循 next 的指示就行,不需要把复杂流程硬编码到前端。


4. 狼人行动:目标列表过滤 + 提交 /action/wolf(流式)

狼人行动的 UI 逻辑是:

  • 目标下拉框来自 alivePlayers
  • 过滤掉狼队友(roles[p] !== 'Wolf'
  • 点击确认后调用 wolfAction(threadId, target),并沿用同一套流式 token 拼接逻辑
const wolfTargets = computed(() => alivePlayers.value.filter(p => roles.value[p] !== 'Wolf'))
const wolfTarget = ref('')

const doWolfAction = async () => {
  if (!store.threadId || !wolfTarget.value) return;
  store.setLoading(true)
  try {
    const streamState = { isStreaming: false, currentMessageIndex: 0, streamMessage: { type: 'ai', content: '' } };
    const finalData = await wolfAction(store.threadId, wolfTarget.value, (data) => handleStreamData(data, streamState))
    store.setState(finalData.state, finalData.next)
  } finally {
    store.setLoading(false)
  }
}

对接后端真实接口(server.py):

  • POST /action/wolf body: { thread_id, target }
  • 后端会把 night_actions.wolf_target 写入 state,再继续跑图并 SSE 返回

5. 女巫行动:读取 night_actions + 提交 /action/witch(流式)

女巫行动需要展示“昨晚谁被袭击”,这个信息在 state 的 night_actions.wolf_target 里,所以我直接 computed:

const actions = computed(() => store.state?.night_actions || {})
const witchDead = computed(() => actions.value.wolf_target)

女巫操作还涉及“解药/毒药是否用完”,项目 state 里用的是:

  • witch_save_used_history
  • witch_poison_used_history

我在 UI 上用它们来 disable 对应操作。

提交接口:

const witchSave = ref(false)
const witchPoison = ref('')

const doWitchAction = async () => {
  if (!store.threadId) return;
  store.setLoading(true)
  try {
    const streamState = { isStreaming: false, currentMessageIndex: 0, streamMessage: { type: 'ai', content: '' } };
    const finalData = await witchAction(store.threadId, witchSave.value, witchPoison.value || null, (data) => handleStreamData(data, streamState))
    store.setState(finalData.state, finalData.next)
  } finally {
    store.setLoading(false)
  }
}

同时也对齐了后端的约束:savepoison 不能同时为真,否则后端会返回 400(cannot use both potions)。这让我意识到:前端最好在 UI 上就引导用户不要选出这种组合(后续可优化成互斥选择)。


6. UI/体验增强:loading 遮罩、消息类型样式、侧栏信息

这一周我把 Element Plus 的组件用起来,让页面更像“可交互产品”,而不是单纯输出 JSON:

  • store.loading 为 true 时显示遮罩 + “AI 正在思考中…”
  • 消息列表区分 human/ai/system 三类气泡(头像与气泡颜色不同)
  • 侧栏展示“存活玩家”和“AI 思考记录(上帝视角)”

体会:课程验收时老师通常会在意“能不能看懂”和“演示是否顺畅”。这些 UI 细节不会改变算法,但能明显提升可解释性和演示效果。


7. 本周问题与反思

  • 问题:Game.vue 逻辑开始变长
    为了赶进度,我把流式拼接、推进、狼人/女巫行动都写在一个页面里,第二周末明显感觉到维护压力变大。下一周需要把公共逻辑抽出来(例如抽成 composable 或封装一个“流式执行器”)。

  • 问题:baseURL 写死导致环境切换麻烦
    api.ts 里 axios baseURL 与 fetch url 都写死 http://localhost:8000。本周暂时不动它以免引入新 bug,但我已经把它列为第 3 周的改进点(做成可配置或读取环境变量/本地存储)。


8. 小结(第 2 周体会)

第二周最大的收获是把“状态机 + SSE 流”真正映射到 Vue 的响应式 UI 上:后端每吐一个 token,前端就能即时更新消息气泡;后端每推进一步,前端通过 state/next 自动切换展示与操作面板。这种“数据驱动 UI”的感觉很强,也让我更理解 Vue3 组合式 API 在复杂交互里的优势。

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐