当前位置: 首页 > 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.jsqmd.com/news/1064322/

相关文章:

  • 嵌入式C标准库实战:数学函数、内存管理与文件I/O的深度解析与避坑指南
  • 长沙企业 AI 流量获客难?5 家专业 GEO 优化公司全方位对比推荐 - GEO优化
  • 2026年 4/6联二氧化硫测定仪推荐榜:高精度检测与稳定性能的全新标杆之选 - 品牌发掘
  • CodeWarrior编译器核心命令行选项解析:诊断、预处理与优化实战指南
  • Selenium自动化测试:从WebDriver原理到Page Object工程实践
  • 解决ESP32-C2在Arduino-ESP32生态中的集成挑战与技术实践
  • Boss Show Time:4大招聘平台时间展示插件,让你不再错过最新工作机会
  • 【大数据_数仓架构-DolphinScheduler_一次性讲解清楚如何用DolphinScheduler编排数仓任务】
  • 实战指南:使用SMUDebugTool解锁AMD Ryzen处理器深度调试与性能优化
  • 解锁二手iPhone激活锁:applera1n免费工具完整使用指南
  • 2026年 宣伟防腐涂料推荐榜单:环氧云铁中间漆/环氧富锌底漆/氟碳漆,高性能与长效防护之选 - 品牌发掘
  • 【毕业设计】面向汽车行业的销售数据可视化系统设计(基于 Django) 基于 Web 的汽车销售数据可视化分析系统(源码+文档+远程调试,全bao定制等)
  • Linux 系统随机熵(entropy)不足
  • 如何用HS2-HF_Patch彻底改造你的Honey Select 2游戏体验?
  • 西安企业做 GEO 优化怎么选服务商?本地 5 家实力派机构实测解析 - GEO优化
  • 别再混淆!AI助手≠数字员工,企业业务人必看的落地避坑
  • 【置顶须知】博主信息与源码获取途径
  • 嵌入式流式协议与智能传感框架:高效数据采集与实时通信实战
  • 粒子生命模拟:用简单规则创造复杂世界的奇妙之旅
  • Mermaid Live Editor:高效智能的实时图表编辑器一站式解决方案
  • c语言用gcc编译过后,执行 ./hello.c 报错 ./hello.c: 权限不够
  • 0.1B参数ProgVLA:轻量VLA模型如何颠覆具身智能范式
  • 3分钟部署FindSomething:重新定义网页信息安全的终极方案
  • ATtiny85超低功耗设计实战:从睡眠模式到系统优化,实现年续航
  • 北京 GEO 服务商 TOP5 评测:高合规要求下的优质服务商甄选 - GEO优化
  • PUBG智能压枪工具终极指南:如何通过图像识别实现精准自动化控制
  • HEIF Utility:让Windows用户轻松处理iPhone照片的实用工具
  • 上海 GEO 服务商 TOP5 汇总:助力品牌抢占 AI 流量的核心服务商解析 - GEO优化
  • FanControl终极指南:5步让你的Windows风扇控制更智能高效
  • 191、影像系统全链路质量评估体系:从 Sensor 原始数据到最终成片的客观指标链