前端h5娱乐游戏 状态、倒计时和断线恢复

把实时骰子

做活动 H5 的时候,小游戏经常被低估。

很多人以为它只是一个页面:点按钮、播动画、等服务端返回结果。真正做过实时对战类小游戏才知道,难点不是 UI,而是时序。

我之前做过一个酒吧互动 H5,里面有一个实时骰子游戏。用户报名后匹配对手,进入对局,双方轮流叫骰,可以开骰,可以淘汰,可以复活,最后产生冠军。消息通过 WebSocket 下发,前端要根据消息推进界面。

这类页面如果只靠一堆布尔值撑着,很快会失控。因为它同时涉及:

  • WebSocket 异步消息。
  • 本地倒计时。
  • 用户点击行为。
  • 服务端对局状态。
  • 支付复活回调。
  • 页面退出后重新进入的恢复。

这篇就复盘一下:一个实时骰子游戏,前端到底应该怎么把状态写稳。

一开始最容易出现的问题

这个页面最常见的问题不是“按钮点不了”,而是状态错位:

  1. 匹配成功动画还没播完,游戏消息已经到了。
  2. 用户刚进入游戏页,服务端已经在一局进行中,需要恢复倒计时。
  3. 我方叫骰后,本地倒计时没清掉,对方倒计时又开了。
  4. 游戏结束后,输家进入复活倒计时,赢家自动进入下一轮。
  5. 用户支付复活成功后,要重置局内状态,但不能重置整个活动状态。
  6. 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 这类消息最容易被写成“显示匹配成功弹窗”。

但它其实承担了初始化对局的工作:

  • 清掉上一轮的双方倒计时。
  • 关闭复活倒计时。
  • 写入我方和对方的头像、昵称、桌号。
  • 写入双方骰子点数。
  • 写入 diceRoundsIddiceRoundsUserId
  • 判断谁先叫骰。
  • 延迟 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() 都考虑了 mysecondenemysecond

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)
  }
}

这样文章里也可以讲一个经验:实时游戏不要只画“正常流程”,一定要画“分叉流程”。

复活支付为什么容易写乱

复活功能表面上是支付,实际是游戏状态恢复的一部分。

用户输了以后,在复活倒计时内可以发起复活:

  1. 判断复活倒计时是否还有效。
  2. 请求服务端创建复活订单。
  3. 如果免费复活,直接恢复等待下一轮。
  4. 如果需要支付,拉起微信支付。
  5. 支付成功后清理复活倒计时。
  6. 增加已复活次数。
  7. 重置局内状态并等待下一轮匹配。

这里最容易出错的是:支付成功后只关弹窗,但没有恢复游戏状态。

项目里支付成功后会做:

  • clearInterval(resurgencetime)
  • resurgencesecond = 20
  • revivalsNum++
  • again()

这个方向是对的。支付回调不能停留在“支付模块”,它必须通知游戏状态机进入“等待下一轮”。

我更建议把支付结果转成游戏事件:

onRevivalPaySuccess() {
  dispatchGameEvent({
    type: 'REVIVAL_SUCCESS'
  })
}

然后状态机处理:

REVIVAL_COUNTDOWN + REVIVAL_SUCCESS -> WAIT_NEXT_ROUND

这样支付模块就不会直接改一堆游戏字段。

如果现在重构,我会先做状态表

我不会一上来就拆组件。先拆组件很容易变成“代码挪家”,状态仍然散着。

我会先写一张状态表:

状态 进入方式 允许操作 退出条件
WAIT_MATCH 进入游戏页、下一轮重置 等待匹配 MATCH_USER_REPLY
MATCHED 匹配成功 展示匹配动画 2 秒后进入摇骰
SHAKING 匹配动画结束 播放摇骰音效和动画 动画结束
MY_TURN 我方回合 叫骰、开骰 PLAY_GAMEGAME_OVER
OPPONENT_TURN 对方回合 等待对方 PLAY_GAMEGAME_OVER
ROUND_RESULT 本局结束 看结果 输进入复活,赢进入下一轮
REVIVAL_COUNTDOWN 输掉本局 复活支付 支付成功或倒计时结束
WAIT_NEXT_ROUND 赢或复活成功 等待匹配 MATCH_USER_DELAY
CHAMPION 冠军消息 展示冠军 结束

有了这张表,再拆代码才有方向。

页面里的布尔值,比如 IsmateIswaitIsbyeIslowerwheelIsResult,可以逐步收敛成一个主状态 gameStatus。UI 根据主状态派生,而不是每条消息都直接改很多布尔值。

验证时我会重点测这些场景

实时游戏的测试不能只测一把正常流程。

我会重点测这些场景:

  1. 匹配成功后,动画未播完又收到叫骰消息。
  2. 我方叫骰后,确认我方倒计时清理,对方倒计时启动。
  3. 对方叫骰后,确认对方倒计时清理,我方倒计时启动。
  4. 刷新页面进入正在进行的对局,倒计时从服务端剩余秒数恢复。
  5. 输掉本局后,复活倒计时结束,不能再支付复活。
  6. 复活支付成功后,不能残留上一局的骰子、按钮和倒计时。
  7. 最后一轮结束后,不能再自动进入下一轮,要等待冠军消息。
  8. WebSocket 重连后,相同消息不能把状态重复推进一次。

如果这些场景能跑稳,这个游戏才算真的稳。

最后一点经验

实时游戏前端最重要的不是动画,而是“状态可信”。

动画可以慢一点,样式可以后面调,但状态一旦错,用户会立刻觉得不公平:明明轮到我了却不能点,明明赢了却被淘汰,明明付了复活却回不来。

所以这类页面我现在会优先做三件事:

  1. 先画状态机。
  2. 再统一倒计时资源的生命周期。
  3. 最后才拆 UI 组件。

这比一开始就追求组件拆得漂亮更有用。

因为实时游戏不是“页面开发”,它更像一个小型前端运行时:消息来了,状态推进;页面退出,资源清理;用户回来,状态恢复。

只要按这个思路写,后面加新玩法、加复活、加冠军页,都会轻松很多。

posted @ 2026-06-22 15:47  万物皆object  阅读(14)  评论(0)    收藏  举报