我以前写大屏抽奖游戏时,最容易被低估的不是动画,也不是排行榜样式,而是倒计时。
倒计时看起来只是每秒减一,但放进真实业务里,它会和开始接口、结束接口、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只有PLAYING和PAUSED能进入。- 所有定时器都由控制器统一清理。
- 倒计时基于
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、支付截止和结果结算时,就应该把它提升成状态机来设计。这样写起来会多一点结构,但换来的是现场稳定性。
