前端h5娱乐游戏 状态、倒计时和断线恢复
把实时骰子
做活动 H5 的时候,小游戏经常被低估。
很多人以为它只是一个页面:点按钮、播动画、等服务端返回结果。真正做过实时对战类小游戏才知道,难点不是 UI,而是时序。
我之前做过一个酒吧互动 H5,里面有一个实时骰子游戏。用户报名后匹配对手,进入对局,双方轮流叫骰,可以开骰,可以淘汰,可以复活,最后产生冠军。消息通过 WebSocket 下发,前端要根据消息推进界面。
这类页面如果只靠一堆布尔值撑着,很快会失控。因为它同时涉及:
- WebSocket 异步消息。
- 本地倒计时。
- 用户点击行为。
- 服务端对局状态。
- 支付复活回调。
- 页面退出后重新进入的恢复。
这篇就复盘一下:一个实时骰子游戏,前端到底应该怎么把状态写稳。
一开始最容易出现的问题
这个页面最常见的问题不是“按钮点不了”,而是状态错位:
- 匹配成功动画还没播完,游戏消息已经到了。
- 用户刚进入游戏页,服务端已经在一局进行中,需要恢复倒计时。
- 我方叫骰后,本地倒计时没清掉,对方倒计时又开了。
- 游戏结束后,输家进入复活倒计时,赢家自动进入下一轮。
- 用户支付复活成功后,要重置局内状态,但不能重置整个活动状态。
- WebSocket 断线重连后,不能把旧消息再当成新消息执行一遍。
这些问题的根因是:前端没有把“游戏状态”和“UI 状态”分开。
UI 上看到的是弹窗、头像、倒计时、骰子动画、按钮显隐;但背后真正驱动页面的是对局状态。
项目里的状态复杂度
这个页面在 pages/dicegame/game.vue 里,核心消息监听在 watch(lastmsg) 中。
服务端通过 code == 60 下发游戏相关事件,里面再用 content.type 区分具体动作:
MATCH_USER_REPLY:匹配成功。PLAY_GAME:有人叫骰。AUTO_LEVEL_UP:轮空自动晋级。GROUPS_TIPS:当前回合剩余组数。GAME_OVER:本局结束。CHAMPION:产生冠军。MATCH_USER_DELAY:下一轮延迟匹配。LAST_ROUNDS:最后一轮。FLOW_TIPS:留局提示。
这已经不是简单事件列表了,它天然就是一台状态机。
如果用流程图表示,大概是这样:
等待活动开始
-> 匹配中
-> 匹配成功动画
-> 摇骰动画
-> 我方回合 / 对方回合
-> 开骰结算
-> 胜者等待下一轮 / 败者复活倒计时
-> 下一轮匹配
-> 冠军结果
每一步都可能被异步消息打断。比如用户刷新页面后直接进入“我方回合”,前端不能从“匹配中”重新播一遍。
匹配成功不是结束,而是状态初始化
MATCH_USER_REPLY 这类消息最容易被写成“显示匹配成功弹窗”。
但它其实承担了初始化对局的工作:
- 清掉上一轮的双方倒计时。
- 关闭复活倒计时。
- 写入我方和对方的头像、昵称、桌号。
- 写入双方骰子点数。
- 写入
diceRoundsId和diceRoundsUserId。 - 判断谁先叫骰。
- 延迟 2 秒后进入游戏视图。
- 触发摇骰动画。
这说明一个问题:消息处理函数不能只做 UI 展示,它要完成“状态归档”。
我更建议把它拆成两个动作:
function applyMatchSuccess(payload) {
clearRoundTimers()
savePlayers(payload.players)
saveDice(payload.players)
saveRoundIdentity(payload.round)
enterState('MATCHED')
}
function playMatchTransition() {
showMatchAnimation()
delay(2000).then(() => {
enterState('SHAKING')
playDiceAnimation()
})
}
拆开以后,恢复对局时可以只执行 applyMatchSuccess,不必重新播过渡动画。
这就是状态机的价值:同一个状态可以被“实时消息”进入,也可以被“恢复接口”进入。
叫骰回合一定要清理上一个倒计时
项目里 PLAY_GAME 消息会根据 content.data.userId 判断是谁叫了骰。
如果是我方刚叫完:
- 写入我方叫的个数和点数。
- 关闭我方倒计时。
- 清理我方两个 interval。
- 打开对方倒计时。
如果是对方刚叫完:
- 写入对方叫的个数和点数。
- 关闭对方倒计时。
- 清理对方两个 interval。
- 打开我方倒计时。
这个逻辑看起来啰嗦,但非常关键。实时游戏里最怕两个倒计时同时跑。
当时页面里有四个定时器:
times_self 我方进度条
times_self1 我方数字倒计时
times_opp 对方进度条
times_opp1 对方数字倒计时
每次回合切换,都必须先清理对侧或本侧旧定时器,再开启新的。
更稳的写法是封装成一个统一方法:
function switchTurn(nextUserId, remainSeconds = 20) {
clearTurnTimers()
if (nextUserId === myUserId) {
enterState('MY_TURN')
startMyTimer(remainSeconds)
} else {
enterState('OPPONENT_TURN')
startOpponentTimer(remainSeconds)
}
}
这样不管是 WebSocket 推来的 PLAY_GAME,还是恢复接口给出的 currentUserId,都走同一套回合切换逻辑。
恢复对局是这类游戏的分水岭
这个页面里最值得写的点,其实是 resumegame()。
用户进入游戏页时,前端会请求当前用户在活动中的对局状态。如果服务端返回正在对局中,前端要恢复:
- 当前轮次。
- 双方用户信息。
- 双方骰子点数。
- 当前对局 id。
- 最后一次叫骰记录。
- 现在轮到谁。
- 剩余秒数。
- 是否已经淘汰。
- 可复活次数和复活价格。
这比“打开页面重新开始”难得多。
恢复对局最关键的原则是:不要补动画,要补状态。
用户刷新后看到的应该是“当前真实状态”,而不是“从头再走一遍流程”。比如服务端说还剩 8 秒轮到我叫骰,前端就应该直接进入我方回合,并用 8 秒启动倒计时,而不是从 20 秒开始。
项目里 self_time() 和 opp_time() 都考虑了 mysecond、enemysecond:
if (mysecond > 0) {
number = 100 / mysecond
countdown_self = mysecond
} else {
number = 5
countdown_self = 20
}
这个处理很实用。它让同一个倒计时函数既能处理新回合,也能处理恢复回合。
结算不是一个弹窗,是分叉流程
GAME_OVER 到来时,页面不能只显示“赢了/输了”。
它要做一串清理和分流:
- 记录最后喊骰的个数和点数。
- 展示实际场上数量。
- 清理双方倒计时。
- 关闭回合提示。
- 如果输了,开启复活倒计时。
- 如果赢了,延迟进入下一轮。
- 如果是最后一轮,等待冠军消息。
也就是说,GAME_OVER 不是终态,它只是一个分叉点。
这类逻辑我更愿意写成:
function handleGameOver(result) {
clearTurnTimers()
saveRoundResult(result)
if (result.isWinner) {
enterState(result.isLastRound ? 'WAIT_CHAMPION' : 'WAIT_NEXT_ROUND')
} else {
enterState('REVIVAL_COUNTDOWN')
startRevivalTimer(result.revivalSeconds)
}
}
这样文章里也可以讲一个经验:实时游戏不要只画“正常流程”,一定要画“分叉流程”。
复活支付为什么容易写乱
复活功能表面上是支付,实际是游戏状态恢复的一部分。
用户输了以后,在复活倒计时内可以发起复活:
- 判断复活倒计时是否还有效。
- 请求服务端创建复活订单。
- 如果免费复活,直接恢复等待下一轮。
- 如果需要支付,拉起微信支付。
- 支付成功后清理复活倒计时。
- 增加已复活次数。
- 重置局内状态并等待下一轮匹配。
这里最容易出错的是:支付成功后只关弹窗,但没有恢复游戏状态。
项目里支付成功后会做:
clearInterval(resurgencetime)resurgencesecond = 20revivalsNum++again()
这个方向是对的。支付回调不能停留在“支付模块”,它必须通知游戏状态机进入“等待下一轮”。
我更建议把支付结果转成游戏事件:
onRevivalPaySuccess() {
dispatchGameEvent({
type: 'REVIVAL_SUCCESS'
})
}
然后状态机处理:
REVIVAL_COUNTDOWN + REVIVAL_SUCCESS -> WAIT_NEXT_ROUND
这样支付模块就不会直接改一堆游戏字段。
如果现在重构,我会先做状态表
我不会一上来就拆组件。先拆组件很容易变成“代码挪家”,状态仍然散着。
我会先写一张状态表:
| 状态 | 进入方式 | 允许操作 | 退出条件 |
|---|---|---|---|
WAIT_MATCH |
进入游戏页、下一轮重置 | 等待匹配 | MATCH_USER_REPLY |
MATCHED |
匹配成功 | 展示匹配动画 | 2 秒后进入摇骰 |
SHAKING |
匹配动画结束 | 播放摇骰音效和动画 | 动画结束 |
MY_TURN |
我方回合 | 叫骰、开骰 | PLAY_GAME 或 GAME_OVER |
OPPONENT_TURN |
对方回合 | 等待对方 | PLAY_GAME 或 GAME_OVER |
ROUND_RESULT |
本局结束 | 看结果 | 输进入复活,赢进入下一轮 |
REVIVAL_COUNTDOWN |
输掉本局 | 复活支付 | 支付成功或倒计时结束 |
WAIT_NEXT_ROUND |
赢或复活成功 | 等待匹配 | MATCH_USER_DELAY |
CHAMPION |
冠军消息 | 展示冠军 | 结束 |
有了这张表,再拆代码才有方向。
页面里的布尔值,比如 Ismate、Iswait、Isbye、Islowerwheel、IsResult,可以逐步收敛成一个主状态 gameStatus。UI 根据主状态派生,而不是每条消息都直接改很多布尔值。
验证时我会重点测这些场景
实时游戏的测试不能只测一把正常流程。
我会重点测这些场景:
- 匹配成功后,动画未播完又收到叫骰消息。
- 我方叫骰后,确认我方倒计时清理,对方倒计时启动。
- 对方叫骰后,确认对方倒计时清理,我方倒计时启动。
- 刷新页面进入正在进行的对局,倒计时从服务端剩余秒数恢复。
- 输掉本局后,复活倒计时结束,不能再支付复活。
- 复活支付成功后,不能残留上一局的骰子、按钮和倒计时。
- 最后一轮结束后,不能再自动进入下一轮,要等待冠军消息。
- WebSocket 重连后,相同消息不能把状态重复推进一次。
如果这些场景能跑稳,这个游戏才算真的稳。
最后一点经验
实时游戏前端最重要的不是动画,而是“状态可信”。
动画可以慢一点,样式可以后面调,但状态一旦错,用户会立刻觉得不公平:明明轮到我了却不能点,明明赢了却被淘汰,明明付了复活却回不来。
所以这类页面我现在会优先做三件事:
- 先画状态机。
- 再统一倒计时资源的生命周期。
- 最后才拆 UI 组件。
这比一开始就追求组件拆得漂亮更有用。
因为实时游戏不是“页面开发”,它更像一个小型前端运行时:消息来了,状态推进;页面退出,资源清理;用户回来,状态恢复。
只要按这个思路写,后面加新玩法、加复活、加冠军页,都会轻松很多。

浙公网安备 33010602011771号