项目实训——Werewolf-Agent 多智能体狼人杀vue前端开发
前端开发日志——游戏页核心交互 + 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/wolfbody:{ 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_historywitch_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)
}
}
同时也对齐了后端的约束:save 和 poison 不能同时为真,否则后端会返回 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 在复杂交互里的优势。
更多推荐



所有评论(0)