Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

4.5 回放、观战与裁决

回放系统是游戏的重要组成部分,既能满足玩家观看比赛的需求,又能用于反作弊裁决和技术分析。

核心问题:如何重现游戏过程?

问题场景

// 场景:玩家想回看刚才的比赛
// 问题1:如何保存游戏过程?
// 问题2:如何重现游戏过程?
// 问题3:如何减少回放文件大小?

type ReplaySystem struct {
    // 如何设计?
}

回放系统设计

1. 录制策略

// ReplayRecorder 回放录制器
type ReplayRecorder struct {
    // 录制模式
    mode RecordingMode

    // 输入录制
    inputRecording *InputRecording

    // 状态录制
    stateRecording *StateRecording

    // 快照间隔
    snapshotInterval uint32  // 每N帧保存一次快照
}

type RecordingMode int

const (
    RecordingModeInput RecordingMode = iota  // 只录制输入
    RecordingModeState                        // 录制状态
    RecordingModeHybrid                       // 混合模式
)

// InputRecording 输入录制
type InputRecording struct {
    frames    []FrameInput
    startTime int64
}

type FrameInput struct {
    FrameNum uint32
    Inputs   []PlayerInput
}

type PlayerInput struct {
    PlayerID uint64
    Input    Input
}

// 录制输入
func (ir *InputRecording) RecordFrame(frameNum uint32, inputs []PlayerInput) {
    ir.frames = append(ir.frames, FrameInput{
        FrameNum: frameNum,
        Inputs:   inputs,
    })
}

// 压缩输入
func (ir *InputRecording) Compress() []byte {
    // 1. 使用增量编码
    compressed := make([]byte, 0)

    for i, frame := range ir.frames {
        // 2. 只记录变化的部分
        if i > 0 {
            delta := ir.calculateDelta(ir.frames[i-1], frame)
            compressed = append(compressed, delta...)
        } else {
            // 第一帧,完整记录
            data := frame.Serialize()
            compressed = append(compressed, data...)
        }
    }

    // 3. 使用zlib压缩
    return zlib.Compress(compressed)
}

// StateRecording 状态录制
type StateRecording struct {
    snapshots []GameStateSnapshot
    startTime int64
}

type GameStateSnapshot struct {
    FrameNum  uint32
    State     *GameState
}

// 录制状态
func (sr *StateRecording) RecordSnapshot(frameNum uint32, state *GameState) {
    sr.snapshots = append(sr.snapshots, GameStateSnapshot{
        FrameNum: frameNum,
        State:    state.Clone(),
    })
}

// 压缩状态
func (sr *StateRecording) Compress() []byte {
    // 1. 增量编码
    compressed := make([]byte, 0)

    for i, snapshot := range sr.snapshots {
        if i > 0 {
            // 只记录变化的部分
            delta := sr.calculateDelta(sr.snapshots[i-1].State, snapshot.State)
            compressed = append(compressed, delta...)
        } else {
            // 第一帧,完整记录
            data := snapshot.State.Serialize()
            compressed = append(compressed, data...)
        }
    }

    // 2. 使用zlib压缩
    return zlib.Compress(compressed)
}

2. 混合录制策略

// HybridRecording 混合录制
type HybridRecording struct {
    // 关键帧:完整状态
    keyFrames []GameStateSnapshot

    // 增量帧:输入
    deltaFrames []FrameInput

    // 关键帧间隔
    keyFrameInterval uint32
}

// 录制帧
func (hr *HybridRecording) RecordFrame(frameNum uint32, inputs []PlayerInput, state *GameState) {
    // 1. 判断是否是关键帧
    if frameNum % hr.keyFrameInterval == 0 {
        // 保存关键帧
        hr.keyFrames = append(hr.keyFrames, GameStateSnapshot{
            FrameNum: frameNum,
            State:    state.Clone(),
        })
    } else {
        // 保存增量帧(输入)
        hr.deltaFrames = append(hr.deltaFrames, FrameInput{
            FrameNum: frameNum,
            Inputs:   inputs,
        })
    }
}

// 压缩
func (hr *HybridRecording) Compress() []byte {
    data := make([]byte, 0)

    // 1. 写入关键帧
    for _, kf := range hr.keyFrames {
        frameData := kf.Serialize()
        data = append(data, frameData...)
    }

    // 2. 写入增量帧
    for _, df := range hr.deltaFrames {
        frameData := df.Serialize()
        data = append(data, frameData...)
    }

    // 3. 压缩
    return zlib.Compress(data)
}

// 大小对比
// 纯输入录制:1场比赛 ≈ 100KB
// 纯状态录制:1场比赛 ≈ 50MB
// 混合录制:1场比赛 ≈ 500KB(推荐)

回放系统

1. 回放引擎

// ReplayEngine 回放引擎
type ReplayEngine struct {
    // 游戏引擎
    gameEngine *GameEngine

    // 回放数据
    replayData *ReplayData

    // 当前帧
    currentFrame uint32

    // 回放状态
    state ReplayState
}

type ReplayState int

const (
    ReplayStatePaused ReplayState = iota
    ReplayStatePlaying
    ReplayStateSeeking
)

type ReplayData struct {
    Mode       RecordingMode
    KeyFrames  []GameStateSnapshot
    DeltaFrames []FrameInput
    StartTime  int64
    EndTime    int64
}

// 加载回放
func (re *ReplayEngine) LoadReplay(data []byte) error {
    // 1. 解压数据
    decompressed := zlib.Decompress(data)

    // 2. 反序列化
    re.replayData = deserializeReplay(decompressed)

    // 3. 初始化状态
    re.currentFrame = re.replayData.KeyFrames[0].FrameNum
    re.state = ReplayStatePaused

    return nil
}

// 播放回放
func (re *ReplayEngine) Play() {
    re.state = ReplayStatePlaying

    // 从最近的快照开始
    snapshot := re.findClosestSnapshot(re.currentFrame)
    re.gameEngine.SetState(snapshot.State)

    // 播放增量帧
    for _, frame := range re.replayData.DeltaFrames {
        if frame.FrameNum < re.currentFrame {
            continue
        }

        // 执行输入
        for _, input := range frame.Inputs {
            re.gameEngine.ProcessInput(input)
        }

        // 渲染
        re.gameEngine.Render()

        // 控制播放速度
        time.Sleep(time.Second / 60)
    }
}

// 暂停
func (re *ReplayEngine) Pause() {
    re.state = ReplayStatePaused
}

// 跳转到指定帧
func (re *ReplayEngine) Seek(frameNum uint32) {
    re.state = ReplayStateSeeking

    // 1. 找到最近的快照
    snapshot := re.findClosestSnapshot(frameNum)
    re.gameEngine.SetState(snapshot.State)

    // 2. 重放到目标帧
    for _, frame := range re.replayData.DeltaFrames {
        if frame.FrameNum < snapshot.FrameNum {
            continue
        }
        if frame.FrameNum >= frameNum {
            break
        }

        // 执行输入
        for _, input := range frame.Inputs {
            re.gameEngine.ProcessInput(input)
        }
    }

    re.currentFrame = frameNum
    re.state = ReplayStatePaused
}

// 查找最近的快照
func (re *ReplayEngine) findClosestSnapshot(frameNum uint32) *GameStateSnapshot {
    for i := len(re.replayData.KeyFrames) - 1; i >= 0; i-- {
        if re.replayData.KeyFrames[i].FrameNum <= frameNum {
            return &re.replayData.KeyFrames[i]
        }
    }

    return &re.replayData.KeyFrames[0]
}

2. 回放控制

// ReplayController 回放控制器
type ReplayController struct {
    engine *ReplayEngine

    // 播放速度
    playbackSpeed float64

    // 循环播放
    loop bool
}

// 设置播放速度
func (rc *ReplayController) SetPlaybackSpeed(speed float64) {
    rc.playbackSpeed = speed
    rc.engine.SetSpeed(speed)
}

// 快进
func (rc *ReplayController) FastForward() {
    rc.SetPlaybackSpeed(2.0)
}

// 慢放
func (rc *ReplayController) SlowMotion() {
    rc.SetPlaybackSpeed(0.5)
}

// 单帧前进
func (rc *ReplayController) StepForward() {
    rc.engine.Pause()
    rc.engine.Seek(rc.engine.currentFrame + 1)
}

// 单帧后退
func (rc *ReplayController) StepBackward() {
    rc.engine.Pause()
    rc.engine.Seek(rc.engine.currentFrame - 1)
}

// 跳转到时间点
func (rc *ReplayController) SeekToTime(timestamp int64) {
    frameNum := rc.timeToFrame(timestamp)
    rc.engine.Seek(frameNum)
}

// 时间转帧号
func (rc *ReplayController) timeToFrame(timestamp int64) uint32 {
    elapsed := timestamp - rc.engine.replayData.StartTime
    seconds := float64(elapsed) / 1e9
    return uint32(seconds * 60)  // 60Hz
}

观战系统

1. 实时观战

// SpectatorSystem 观战系统
type SpectatorSystem struct {
    // 观战延迟
    delayDelay time.Duration

    // 观战视角
    cameraMode SpectatorCameraMode

    // 观战玩家
    spectatorPlayer *Player
}

type SpectatorCameraMode int

const (
    SpectatorCameraFree SpectatorCameraMode = iota  // 自由视角
    SpectatorCameraFollow                           // 跟随玩家
    SpectatorCameraMap                              // 小地图视角
)

// 观战主循环
func (ss *SpectatorSystem) Spectate(match *Match) {
    // 1. 延迟观看(避免作弊)
    delayedTime := time.Now().Add(-ss.delayDelay)

    // 2. 获取延迟后的状态
    state := match.GetStateAtTime(delayedTime)

    // 3. 渲染
    ss.render(state)
}

// 切换视角
func (ss *SpectatorSystem) SwitchCamera(mode SpectatorCameraMode) {
    ss.cameraMode = mode
}

// 跟随玩家
func (ss *SpectatorSystem) FollowPlayer(playerID uint64) {
    ss.cameraMode = SpectatorCameraFollow
    ss.spectatorPlayer = ss.getPlayer(playerID)
}

// 渲染(根据视角模式)
func (ss *SpectatorSystem) render(state *GameState) {
    switch ss.cameraMode {
    case SpectatorCameraFree:
        // 自由视角(观众控制)
        ss.renderFreeCamera(state)

    case SpectatorCameraFollow:
        // 跟随玩家
        if ss.spectatorPlayer != nil {
            ss.renderFollowPlayer(ss.spectatorPlayer)
        }

    case SpectatorCameraMap:
        // 小地图视角
        ss.renderMap(state)
    }
}

// 自由视角
func (ss *SpectatorSystem) renderFreeCamera(state *GameState) {
    // 渲染整个场景
    ss.renderScene(state)

    // 显示所有玩家位置
    for _, player := range state.Players {
        ss.renderPlayer(player)
    }

    // 显示小地图
    ss.renderMinimap(state)
}

// 跟随玩家视角
func (ss *SpectatorSystem) renderFollowPlayer(player *Player) {
    // 相机跟随玩家
    ss.camera.Position = player.Position
    ss.camera.LookAt(player.Position.Add(player.ForwardVector))

    // 渲染玩家视野
    ss.renderFromCamera(player.Position, player.Rotation)

    // 显示HUD
    ss.renderPlayerHUD(player)
}

2. 多人观战

// MultiSpectator 多人观战
type MultiSpectator struct {
    // 观战者列表
    spectators map[uint64]*Spectator

    // 观战者视角
    spectatorCameras map[uint64]*Camera
}

type Spectator struct {
    ID       uint64
    PlayerID uint64  // 观战的玩家ID
    CameraMode SpectatorCameraMode
}

// 添加观战者
func (ms *MultiSpectator) AddSpectator(spectatorID uint64, playerID uint64) {
    spectator := &Spectator{
        ID:        spectatorID,
        PlayerID:  playerID,
        CameraMode: SpectatorCameraFollow,
    }

    ms.spectators[spectatorID] = spectator
    ms.spectatorCameras[spectatorID] = &Camera{}
}

// 移除观战者
func (ms *MultiSpectator) RemoveSpectator(spectatorID uint64) {
    delete(ms.spectators, spectatorID)
    delete(ms.spectatorCameras, spectatorID)
}

// 广播观战数据
func (ms *MultiSpectator) BroadcastSpectatorData(state *GameState) {
    // 为每个观战者准备数据
    for _, spectator := range ms.spectators {
        // 获取观战视角
        camera := ms.spectatorCameras[spectator.ID]

        // 渲染视角
        viewData := ms.renderSpectatorView(state, spectator, camera)

        // 发送给观战者
        ms.sendToSpectator(spectator.ID, viewData)
    }
}

// 渲染观战视角
func (ms *MultiSpectator) renderSpectatorView(state *GameState, spectator *Spectator, camera *Camera) []byte {
    // 根据观战模式渲染
    switch spectator.CameraMode {
    case SpectatorCameraFollow:
        player := state.GetPlayer(spectator.PlayerID)
        camera.Position = player.Position
        camera.LookAt(player.Position.Add(player.ForwardVector))

    case SpectatorCameraFree:
        // 自由视角,不移动相机
    }

    // 渲染场景
    return ms.renderSceneFromCamera(camera)
}

裁决系统

1. 反作弊裁决

// AntiCheatSystem 反作弊裁决系统
type AntiCheatSystem struct {
    // 可疑行为检测器
    detectors []*BehaviorDetector

    // 回放分析器
    replayAnalyzer *ReplayAnalyzer

    // 裁决队列
    reviewQueue []*ReviewCase
}

type BehaviorDetector struct {
    Name   string
    Detect func(player *Player, state *GameState) *Suspicion
}

type Suspicion struct {
    PlayerID   uint64
    Type       string
    Confidence float64
    Evidence   []string
}

type ReviewCase struct {
    CaseID     string
    PlayerID   uint64
    Suspicion  *Suspicion
    ReplayData []byte
    Timestamp  int64
}

// 检测可疑行为
func (acs *AntiCheatSystem) DetectSuspiciousBehavior(state *GameState) []*Suspicion {
    suspicions := make([]*Suspicion, 0)

    // 1. 运行所有检测器
    for _, detector := range acs.detectors {
        for _, player := range state.Players {
            suspicion := detector.Detect(player, state)
            if suspicion != nil {
                suspicions = append(suspicions, suspicion)
            }
        }
    }

    return suspicions
}

// 创建裁决案例
func (acs *AntiCheatSystem) CreateReviewCase(suspicion *Suspicion, replayData []byte) *ReviewCase {
    return &ReviewCase{
        CaseID:     generateCaseID(),
        PlayerID:   suspicion.PlayerID,
        Suspicion:  suspicion,
        ReplayData: replayData,
        Timestamp:  time.Now().UnixNano(),
    }
}

// 提交裁决
func (acs *AntiCheatSystem) SubmitForReview(reviewCase *ReviewCase) {
    acs.reviewQueue = append(acs.reviewQueue, reviewCase)
}

// 裁决员审查
func (acs *AntiCheatSystem) ReviewCase(caseID string, verdict Verdict) {
    // 1. 查找案例
    reviewCase := acs.findCase(caseID)

    // 2. 应用裁决
    switch verdict {
    case VerdictCheating:
        acs.banPlayer(reviewCase.PlayerID)
    case VerdictLegitimate:
        // 不处理
    case VerdictInconclusive:
        // 标记为需要进一步审查
        reviewCase.FlagForFurtherReview()
    }
}

type Verdict int

const (
    VerdictCheating Verdict = iota
    VerdictLegitimate
    VerdictInconclusive
)

2. 自动检测器

// AimBotDetector 自瞄检测器
type AimBotDetector struct {
    // 阈值
    maxSnapAngle float64  // 最大转身角度
    minReactionTime time.Duration  // 最小反应时间
}

func (ad *AimBotDetector) Detect(player *Player, state *GameState) *Suspicion {
    // 1. 检查转身速度
    if ad.checkSnapTurn(player) {
        return &Suspicion{
            PlayerID:   player.ID,
            Type:       "AimBot",
            Confidence: 0.8,
            Evidence:   []string{"异常快速转身"},
        }
    }

    // 2. 检查反应时间
    if ad.checkReactionTime(player) {
        return &Suspicion{
            PlayerID:   player.ID,
            Type:       "AimBot",
            Confidence: 0.7,
            Evidence:   []string{"反应时间过快"},
        }
    }

    return nil
}

// 检查转身速度
func (ad *AimBotDetector) checkSnapTurn(player *Player) bool {
    // 计算转身角度
    angleDelta := ad.calculateAngleDelta(player.LastRotation, player.Rotation)

    // 如果转身角度超过阈值,可疑
    return angleDelta > ad.maxSnapAngle
}

// 计算角度差
func (ad *AimBotDetector) calculateAngleDelta(a, b Quaternion) float64 {
    // 四元数角度差计算
    dot := a.X*b.X + a.Y*b.Y + a.Z*b.Z + a.W*b.W
    return 2 * math.Acos(abs(dot))
}

// WallHackDetector 透视检测器
type WallHackDetector struct {
    // 阈值
    maxTrackThroughWall float64
}

func (wd *WallHackDetector) Detect(player *Player, state *GameState) *Suspicion {
    // 1. 检查是否盯着墙后的敌人
    if wd.checkTrackingThroughWall(player, state) {
        return &Suspicion{
            PlayerID:   player.ID,
            Type:       "WallHack",
            Confidence: 0.6,
            Evidence:   []string{"持续盯着墙后目标"},
        }
    }

    return nil
}

// 检查穿墙追踪
func (wd *WallHackDetector) checkTrackingThroughWall(player *Player, state *GameState) bool {
    // 1. 获取玩家视角方向
    viewDirection := player.ForwardVector

    // 2. 射线检测
    for _, enemy := range state.Players {
        if enemy.ID == player.ID {
            continue
        }

        // 计算到敌人的方向
        toEnemy := enemy.Position.Sub(player.Position).Normalize()

        // 检查是否在看敌人
        dot := viewDirection.Dot(toEnemy)
        if dot > 0.95 {  // 视角很接近
            // 检查是否有墙遮挡
            if wd.hasObstacle(player.Position, enemy.Position, state) {
                // 有墙遮挡,但一直在看,可疑
                return true
            }
        }
    }

    return false
}

// 检查是否有障碍物
func (wd *WallHackDetector) hasObstacle(from, to Vector3, state *GameState) bool {
    // 射线检测
    return state.Raycast(from, to, nil)
}

真实案例分析

案例:《DOTA2》的回放系统

背景

  • 比赛时长:30-60分钟
  • 回放大小:<5MB
  • 特点:大量单位,复杂技能

技术方案

// DOTA2 回放系统
type DOTA2ReplaySystem struct {
    // 压缩算法
    compressor *ReplayCompressor

    // 增量编码
    deltaEncoder *DeltaEncoder
}

// 录制比赛
func (drs *DOTA2ReplaySystem) RecordMatch(match *Match) []byte {
    // 1. 记录初始状态
    initialState := match.GetInitialState()

    // 2. 记录每帧输入
    inputs := drs.recordInputs(match)

    // 3. 增量编码
    encoded := drs.deltaEncoder.Encode(initialState, inputs)

    // 4. 压缩
    compressed := drs.compressor.Compress(encoded)

    return compressed
}

// 增量编码
func (drs *DOTA2ReplaySystem) recordInputs(match *Match) []FrameInput {
    inputs := make([]FrameInput, 0)

    // 只记录玩家输入(不记录AI)
    for frame := uint32(0); frame < match.Duration; frame++ {
        frameInputs := match.GetFrameInputs(frame)

        // 过滤掉无关输入
        relevantInputs := drs.filterRelevantInputs(frameInputs)

        inputs = append(inputs, FrameInput{
            FrameNum: frame,
            Inputs:   relevantInputs,
        })
    }

    return inputs
}

效果

  • 回放大小:<5MB(60分钟比赛)
  • 压缩率:95%
  • 播放准确度:100%

踩坑经验

❌ 错误1:回放不使用确定性计算

// ❌ 错误:回放使用浮点数
func replayFrame() {
    position += velocity * deltaTime  // 不同播放结果不同
}

// ✅ 正确:使用定点数
func replayFrame() {
    position += velocity * FIXED_DELTA  // 每次播放结果相同
}

❌ 错误2:观战不延迟

// ❌ 错误:实时观战
func spectate() {
    state := match.GetCurrentState()  // 玩家可以利用观战作弊
}

// ✅ 正确:延迟观战
func spectate() {
    state := match.GetStateAtTime(time.Now().Add(-30*time.Second))  // 30秒延迟
}

❌ 错误3:不压缩回放数据

// ❌ 错误:不压缩
func saveReplay(data []byte) {
    file.Write(data)  // 文件很大
}

// ✅ 正确:压缩
func saveReplay(data []byte) {
    compressed := zlib.Compress(data)
    file.Write(compressed)  // 文件小很多
}

小结

回放、观战与裁决的核心要点:

  1. 回放系统:混合录制(关键帧+增量帧)
  2. 观战系统:延迟观看,多视角切换
  3. 裁决系统:自动检测+人工审查

录制策略

  • 纯输入录制:最小文件大小
  • 纯状态录制:最大兼容性
  • 混合录制:平衡大小和性能

真实案例

  • 《DOTA2》:增量编码 + 压缩,<5MB/60分钟

踩坑经验

  • ❌ 回放必须使用确定性计算
  • ❌ 观战必须延迟
  • ❌ 必须压缩回放数据

本章(第4章)完成!下一章(第5章)我们将学习:并发、执行模型与运行机制