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) // 文件小很多
}
小结
回放、观战与裁决的核心要点:
- 回放系统:混合录制(关键帧+增量帧)
- 观战系统:延迟观看,多视角切换
- 裁决系统:自动检测+人工审查
录制策略:
- 纯输入录制:最小文件大小
- 纯状态录制:最大兼容性
- 混合录制:平衡大小和性能
真实案例:
- 《DOTA2》:增量编码 + 压缩,<5MB/60分钟
踩坑经验:
- ❌ 回放必须使用确定性计算
- ❌ 观战必须延迟
- ❌ 必须压缩回放数据
本章(第4章)完成!下一章(第5章)我们将学习:并发、执行模型与运行机制。