iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Vibe Coding

上岸用AI,看小白如何從無到有的用Vibe Codeing開發遊戲系列 第 19 篇

Day 19:門市離線收益演算法與雙向狀態同步 —— Server-Authoritative 時間戳驗證與 WebSocket 心跳包

  • 分享至 

  • xImage
  •  

歡迎來到第十九天!我們的門市遊戲平台包含「放置經營」與「離線點數累積」機制:當顧客離開門市、關閉 LINE WebView 後,其大廳內的設施與豆花怪依然會隨著時間推移持續產生收益(如離線點數與金幣)。

今天我們要解決兩個經典的系統設計問題:

  1. 離線收益演算法:如何在不消耗伺服器 CPU 的前提下,精準且安全地計算離線收益?
  2. 雙向狀態同步:當玩家重新開啟遊戲時,如何透過 WebSocket 即時同步最新數據並防止時間竄改?

1. 離線收益演算法 (Offline Earnings Algorithm) 與 Server-Authoritative 模型

離線收益的計算絕不能由手機客戶端計算並直接回傳(否則玩家只要修改手機系統時間就能「飛躍時間」刷爆點數)。我們採用 Server-Authoritative(伺服器權威)時間戳機制:

\[\text{有效離線秒數 } \Delta T = \min\left(T_{\text{now}} - T_{\text{last_harvest_time}}, T_{\text{max_offline_cap}}\right)\]

\[\text{獲得總點數} = \lfloor \Delta T \times \text{單位每秒點數產出 Rate} \rfloor\]

// OfflineProgressService.kt (Kotlin Ktor 後端)
object OfflineProgressService {
    private const val MAX_OFFLINE_SECONDS = 28800L // 上限 8 小時 (28800 秒 / 480 分鐘)
    private const val BASE_POINTS_PER_SECOND = 0.166L // 基礎產出速率

    fun calculateAndApplyOfflineEarnings(userId: UUID): OfflineRewardDTO {
        return transaction {
            val state = GameStateTable.select { GameStateTable.userId eq userId }.single()
            val currentTime = System.currentTimeMillis() / 1000L // 取得伺服器 NTP 標準時間戳 (秒)
            val lastHarvestTime = state[GameStateTable.lastHarvestTime] / 1000L

            // 1. 計算總離線秒數
            val rawOfflineSeconds = currentTime - lastHarvestTime
            if (rawOfflineSeconds < 60) {
                return@transaction OfflineRewardDTO(earnedPoints = 0, offlineSeconds = 0) // 未滿 1 分鐘不發放
            }

            // 2. 扣除超過上限的時間 (Cap Barrier: 480 分鐘 / 8 小時)
            val effectiveOfflineSeconds = Math.min(rawOfflineSeconds, MAX_OFFLINE_SECONDS)

            // 3. 根據家具/房間等級加成計算最終收益
            val hotelLevel = state[GameStateTable.hotelLevel]
            val totalEarnedPoints = (effectiveOfflineSeconds * BASE_POINTS_PER_SECOND * hotelLevel).toLong()

            // 4. 更新資料庫 atomically
            GameStateTable.update({ GameStateTable.userId eq userId }) {
                it[coins] = coins + totalEarnedPoints
                it[GameStateTable.lastHarvestTime] = currentTime * 1000L // 更新最後收成時間戳
            }

            OfflineRewardDTO(
                earnedPoints = totalEarnedPoints,
                offlineSeconds = effectiveOfflineSeconds
            )
        }
    }
}

2. Ktor WebSocket 雙向長連接與心跳包 (Heartbeat Ping/Pong) 實作

為了在線上發送即時活動通知或進行雙向狀態同步,我們在 Ktor 中建立了 WebSocket 路由:

// WebSocketRoutes.kt
fun Route.gameWebSocketRouting() {
    webSocket("/ws/game") {
        val session = this
        println("WebSocket 存取建立: ${session.hashCode()}")

        try {
            for (frame in incoming) {
                when (frame) {
                    is Frame.Text -> {
                        val text = frame.readText()
                        if (text == "PING") {
                            // 收到客戶端心跳包,即時回傳 PONG
                            outgoing.send(Frame.Text("PONG"))
                        } else {
                            handleClientMessage(session, text)
                        }
                    }
                    else -> {}
                }
            }
        } catch (e: Exception) {
            println("WebSocket 異常中斷: ${e.localizedMessage}")
        } finally {
            println("WebSocket Session 釋放")
        }
    }
}

前端 Cocos (TypeScript) 自動重連機制:

當手機螢幕關閉或網路切換時,WebSocket 會中斷。我們在前端實作了 Exponential Backoff(指數退避演算法) 重連機制:

// WebSocketClient.ts
export class WebSocketClient {
    private ws: WebSocket | null = null;
    private retryCount: number = 0;
    private maxRetryCount: number = 5;

    public connect(url: string) {
        this.ws = new WebSocket(url);

        this.ws.onopen = () => {
            this.retryCount = 0; // 重置重連次數
            this.startHeartbeat();
        };

        this.ws.onclose = () => {
            this.scheduleReconnect(url);
        };
    }

    private scheduleReconnect(url: string) {
        if (this.retryCount < this.maxRetryCount) {
            this.retryCount++;
            // 指數退避延遲:1s, 2s, 4s, 8s, 16s + 隨機抖動 (Jitter)
            const delay = Math.pow(2, this.retryCount) * 1000 + Math.random() * 500;
            setTimeout(() => this.connect(url), delay);
        }
    }
}

💡 鐵人賽小知識:如何防範玩家透過修改手機時間進行「時間旅行攻擊(Time-Travel Attack)」?

Tech Tip:單機遊戲常被玩家透過修改手機時間刷收益。在我們的 Server-Authoritative 架構中,客戶端傳來的系統時間只作為參考,甚至完全不採信!所有離線計算、任務冷卻與簽到判定,一律以 Ktor 伺服器的 UNIX 時間戳(由內網 NTP 伺服器校時)為唯一真理。就算玩家將手機時間調到 2030 年,伺服器計算出的離線秒數依然是精準的 0 秒!



上一篇
Day 18:WebGL 極速載入與 60 FPS 順暢度 —— DrawCall 批次渲染、Auto Atlas 與 Brotli 靜態壓縮策略
下一篇
Day 20:實體門市金流串接 —— ECPay Webhook 冪等性與 ACID 事務處理實作
系列文
上岸用AI,看小白如何從無到有的用Vibe Codeing開發遊戲 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言