当前位置: 首页 > news >正文

实时抽奖游戏里的倒计时状态机:接口、WebSocket、排行榜如何协作

我以前写大屏抽奖游戏时,最容易被低估的不是动画,也不是排行榜样式,而是倒计时。

倒计时看起来只是每秒减一,但放进真实业务里,它会和开始接口、结束接口、WebSocket 通知、排行榜轮询、支付截止、暂停恢复、页面刷新全部绑在一起。

这篇文章就聊一个典型问题:如何把一个实时抽奖游戏从“几个定时器拼起来”,整理成可维护的状态机。

业务背景

一个大屏抽奖游戏大概有三个阶段:

  • 未开始:展示奖品、二维码、规则。
  • 进行中:用户扫码参与,排行榜实时刷新,倒计时运行。
  • 已结束:停止参与,结算排名,展示获奖结果。

实际项目里还会多几件事:

  • 开始前可能播放开场动画。
  • 游戏中每秒向服务端发送当前大屏状态。
  • 排行榜每隔一小段时间刷新。
  • 倒计时到某个阈值时停止购买或停止报名。
  • 竞赛模式下,最后几十秒会进入特殊阶段。
  • 页面刷新后需要从缓存恢复剩余时间。

这些都是异步操作,而且顺序非常重要。

问题现象

如果只用定时器硬写,容易出现:

  • 开始按钮连续点击,游戏启动两次。
  • 倒计时结束后,结束接口被调用多次。
  • 页面刷新后,前端剩余时间和服务端状态不一致。
  • 排行榜轮询在游戏结束后仍然继续。
  • 暂停后恢复,倒计时少减或多减一秒。
  • WebSocket 断开后,前端还以为游戏正常进行。

这些问题很隐蔽,因为单人测试时很少连续点击、断网、刷新、暂停恢复一起测。

初始实现

常见写法类似这样:

startGame() {api.startGame({ screenId }).then(res => {this.gameStage = 'playing'this.gameId = res.data.idthis.startCountdown(res.data.endTime - res.data.startTime)this.rankTimer = setInterval(this.fetchRank, 1200)this.statusTimer = setInterval(this.sendPlayingStatus, 1000)})
}startCountdown(time) {clearInterval(this.timer)let count = timethis.timer = setInterval(() => {count -= 1000if (count <= 0) {clearInterval(this.timer)this.finishGame()}}, 1000)
}

这段逻辑能跑,但它没有把“游戏阶段”当成核心模型。

倒计时、排行榜、WebSocket、结束接口都在各自运行。只要某一步失败或重复触发,状态就可能乱。

根因

抽奖游戏的前端不是一个倒计时组件,而是一个流程控制器。

它至少有这些状态:

const GAME_STATE = {IDLE: 'idle',STARTING: 'starting',OPENING: 'opening',PLAYING: 'playing',PAUSED: 'paused',SETTLING: 'settling',FINISHED: 'finished',DESTROYED: 'destroyed'
}

每个状态能做什么、不能做什么,要明确。

比如:

  • STARTING 状态不能再次开始。
  • PLAYING 状态才能发送游戏进行中的 WebSocket 状态。
  • SETTLING 状态不能再次调用结束接口。
  • FINISHED 状态要清理所有轮询和倒计时。

设计方案

我会把游戏拆成三个层:

GameController-> Countdown-> Polling-> SocketNotifier

GameController 管状态,其他模块只做自己的事。

倒计时不直接调用结束接口,而是通知控制器:

countdown finish-> controller.dispatch('TIME_UP')-> state PLAYING -> SETTLING-> call finish api-> state FINISHED

这样就能避免多个地方同时调用 finishGame

核心实现

下面是一个脱敏后的控制器:

function createGameController(options) {let state = GAME_STATE.IDLElet gameId = nulllet endAt = 0let countdownTimer = nulllet rankTimer = nulllet statusTimer = nullfunction setState(next) {options.onStateChange && options.onStateChange(next, state)state = next}function clearTimers() {clearInterval(countdownTimer)clearInterval(rankTimer)clearInterval(statusTimer)countdownTimer = nullrankTimer = nullstatusTimer = null}async function start() {if (state !== GAME_STATE.IDLE && state !== GAME_STATE.FINISHED) returnsetState(GAME_STATE.STARTING)if (options.hasOpeningAnimation) {setState(GAME_STATE.OPENING)await options.playOpening()}const res = await options.api.startGame({screenId: options.screenId})gameId = res.idendAt = res.endAtsetState(GAME_STATE.PLAYING)startCountdown()startRankPolling()startStatusNotify()}function startCountdown() {clearInterval(countdownTimer)countdownTimer = setInterval(() => {const left = Math.max(0, endAt - Date.now())options.onTick && options.onTick(left)if (left <= options.stopJoinBeforeEnd) {options.api.stopJoinOnce && options.api.stopJoinOnce(gameId)}if (left <= 0) {dispatch('TIME_UP')}}, 1000)}function startRankPolling() {clearInterval(rankTimer)rankTimer = setInterval(async () => {if (state !== GAME_STATE.PLAYING) returnconst rank = await options.api.fetchRank(gameId)options.onRank && options.onRank(rank)}, 1200)}function startStatusNotify() {clearInterval(statusTimer)statusTimer = setInterval(() => {if (state !== GAME_STATE.PLAYING) returnoptions.socket.send({code: 3,message: {screenId: options.screenId}})}, 1000)}async function finish() {if (state !== GAME_STATE.PLAYING && state !== GAME_STATE.PAUSED) returnsetState(GAME_STATE.SETTLING)clearTimers()const result = await options.api.finishGame(gameId)options.socket.send({code: 4,message: {screenId: options.screenId}})options.onFinish && options.onFinish(result)setState(GAME_STATE.FINISHED)}function pause() {if (state !== GAME_STATE.PLAYING) returnclearTimers()setState(GAME_STATE.PAUSED)}function resume() {if (state !== GAME_STATE.PAUSED) returnsetState(GAME_STATE.PLAYING)startCountdown()startRankPolling()startStatusNotify()}function dispatch(event) {if (event === 'TIME_UP') {finish()}}function destroy() {clearTimers()setState(GAME_STATE.DESTROYED)}return {start,pause,resume,finish,destroy,getState: () => state}
}

这里有几个关键点:

  • start 有状态保护,不能重复启动。
  • finish 只有 PLAYINGPAUSED 能进入。
  • 所有定时器都由控制器统一清理。
  • 倒计时基于 endAt - Date.now(),比每秒自减更稳。
  • WebSocket 状态通知只在 PLAYING 状态发送。

为什么不用纯组件状态

组件里的 gamebefore = 0/1/2 可以控制 UI 显示,但不适合承载完整流程。

UI 状态可以是:

before -> playing -> result

但业务状态更细:

idle -> starting -> opening -> playing -> settling -> finished

两者不是一回事。把它们混在一起,后面处理“正在开始中”“正在结算中”“暂停中”就会很难。

页面刷新恢复

如果页面刷新,需要以服务端状态为准,而不是只信本地缓存。

比较稳的恢复流程:

created-> fetch current game detail-> if server says playinguse server endAt to resume countdownrestart rank pollingrestart socket status notifyelse if server says finishedrender resultelserender idle

本地 sessionStorage 可以作为体验优化,但不能作为唯一依据。

方案对比

第一种方案是多个定时器分散控制。优点是写起来快,缺点是重复开始、重复结束和漏清理很难避免。

第二种方案是把所有状态塞进 Vuex。优点是全局能看见,缺点是副作用太多,Vuex 会变成一个大杂烩。

第三种方案是控制器加 UI 状态映射。控制器负责流程,Vue 组件负责展示。这个方案更适合游戏类大屏。

验证清单

1. 连点开始按钮 5 次,只能创建一个游戏。
2. 倒计时结束时,finish 接口只调用一次。
3. 游戏进行中切换页面,排行榜轮询和状态通知停止。
4. 游戏进行中刷新页面,倒计时根据服务端 endAt 恢复。
5. 断开 WebSocket 后恢复,能重新同步当前游戏状态。
6. 暂停后等待 10 秒再恢复,倒计时不出现负数和跳秒。
7. 结束前 N 秒,停止报名接口只触发一次。

小结

抽奖游戏的倒计时不是一个孤立的 UI 数字,而是整个实时流程的时钟。

当它同时驱动接口、排行榜、WebSocket、支付截止和结果结算时,就应该把它提升成状态机来设计。这样写起来会多一点结构,但换来的是现场稳定性。

http://www.gsyq.cn/news/1576262.html

相关文章:

  • 2026年 宣伟防腐涂料推荐榜单:环氧云铁中间漆/环氧富锌底漆/氟碳漆,高性能与长效防护之选 - 品牌发掘
  • Selenium自动化测试:从WebDriver原理到Page Object工程实践
  • 【大数据_数仓架构-DolphinScheduler_一次性讲解清楚如何用DolphinScheduler编排数仓任务】
  • 实战指南:使用SMUDebugTool解锁AMD Ryzen处理器深度调试与性能优化
  • 解锁二手iPhone激活锁:applera1n免费工具完整使用指南
  • 如何用HS2-HF_Patch彻底改造你的Honey Select 2游戏体验?
  • Mermaid Live Editor:高效智能的实时图表编辑器一站式解决方案
  • 0.1B参数ProgVLA:轻量VLA模型如何颠覆具身智能范式
  • FanControl终极指南:5步让你的Windows风扇控制更智能高效
  • ATtiny85超低功耗设计实战:从睡眠模式到系统优化,实现年续航
  • HEIF Utility:让Windows用户轻松处理iPhone照片的实用工具
  • USB安全弹出工具终极指南:告别“设备正在使用中“的烦恼
  • 武汉中央空调维修哪家好?鑫诚制冷、嘉一制冷2026本地口碑榜 - 我叫一
  • Seedance 2.0:AI视频工作流的工程化临界点
  • 2026年传统制造GEO优化行业服务商深度选型指南 - GEO优化
  • 2026年大湾区GEO优化公司实力榜单与选型指南 - GEO优化
  • 打卡第九天 - P4994 - 2026 - 6 - 22
  • 基于物理信息图神经网络的无人机群分散式连接恢复算法
  • 汽车无线充电基线功率方案:NXP MWCT100xA芯片架构与工程实践详解
  • 全芯片仿真(FCS)在嵌入式开发中的应用:以HC08外设调试为例
  • NXP MC3381x系列芯片在小型发动机ECU驱动电路中的选型与设计实战
  • 2026年 扬州中企动力社媒代运营服务榜单:内容策划/平台管理/粉丝增长等全流程代运营推荐! - 品牌发掘
  • 2026年 北京办公室地毯清洗保洁TOP5榜单:专业除菌与深度清洁的全方位推荐指南 - 品牌发掘
  • 2026年实践,合韵汤泉与周边洗浴中心实际体验差异是什么? - 资讯纵览
  • 医学图像分割后校准:TwinTrack双轨制处理标注不确定性与模型预测融合
  • 197、影像问题客诉处理体系:从用户反馈到复现、定位、修复的闭环流程
  • Ryzen AI NPU深度解析:XDNA2架构与Lemonade本地推理实战
  • 2026缙云木门定制,口碑厂家怎么选?
  • AntiMicroX 终极指南:5分钟让任何游戏手柄控制你的电脑
  • 番茄小说免费下载器:5分钟搭建个人数字图书馆的终极指南