1.5 客户端、服务端、平台与运营的边界
这一节明确各个系统的职责边界,避免“越界“导致的问题。
为什么需要明确边界?
很多项目的常见问题:
问题1:客户端做了太多逻辑
客户端:实现战斗判定、伤害计算
→ 结果:外挂泛滥,刷金币、无敌模式
→ 原因:逻辑应该在服务端
问题2:服务端做了太多渲染
服务端:计算每个特效的播放位置
→ 结果:服务器CPU负载高,承载低
→ 原因:渲染应该在客户端
问题3:平台SDK集成混乱
前端:直接接入微信登录、支付宝支付
→ 结果:代码耦合,无法复用,难以升级
→ 原因:应该封装平台SDK层
问题4:运营配置散落各处
活动配置:在代码里、在数据库里、在Excel里
→ 结果:配置混乱,无法统一管理
→ 原因:应该有统一的配置系统
客户端 vs 服务端边界
核心原则
渲染、输入、物理模拟 → 客户端 逻辑、数据、社交 → 服务端
详细职责划分
| 功能 | 客户端 | 服务端 | 理由 |
|---|---|---|---|
| 渲染 | ✅ 负责 | ❌ 不负责 | 服务端不知道玩家屏幕、显卡 |
| 输入 | ✅ 捕获 | ❌ 不捕获 | 服务端无法直接访问玩家输入设备 |
| 物理 | ✅ 模拟 | ❌ 不模拟 | 客户端物理引擎优化画面表现 |
| 网络 | ✅ 发送/接收 | ✅ 路由/广播 | 双方都需要网络层 |
| 逻辑 | ❌ 预测 | ✅ 权威 | 服务端防作弊 |
| 数据 | ❌ 缓存 | ✅ 持久化 | 服务端保证数据安全 |
| 社交 | ❌ 显示 | ✅ 管理 | 跨玩家数据必须在服务端 |
接口设计原则
原则1:客户端发送“意图“,服务端返回“结果“
// ✅ 正确:客户端发送意图
type MoveRequest struct {
Direction Vector3 // 玩家想往哪个方向移动
Duration float64 // 想移动多久
}
// 服务端验证并计算结果
func (s *GameServer) HandleMove(req *MoveRequest) *MoveResponse {
// 1. 验证玩家状态
if !s.canMove(player) {
return Error("player stunned")
}
// 2. 计算实际移动
actualDistance := s.calculateDistance(player, req.Direction, req.Duration)
// 3. 返回结果
return &MoveResponse{
NewPosition: player.Position + req.Direction * actualDistance,
}
}
// ❌ 错误:客户端发送结果
type MoveRequest struct {
NewPosition Vector3 // 客户端直接计算位置
}
// 服务端直接接受(危险!)
func (s *GameServer) HandleMove(req *MoveRequest) {
// 问题:客户端可以传送、穿墙
player.Position = req.NewPosition
}
原则2:服务端最小化信任客户端
// ✅ 正确:服务端验证所有数据
func (s *GameServer) HandleAttack(req *AttackRequest) error {
// 1. 验证攻击者状态
if !s.canAttack(req.AttackerID) {
return errors.New("cannot attack now")
}
// 2. 验证目标有效性
target := s.getEntity(req.TargetID)
if target == nil || !s.isInRange(req.AttackerID, target) {
return errors.New("invalid target")
}
// 3. 服务端计算伤害
damage := s.calculateDamage(req.AttackerID, target)
s.applyDamage(target, damage)
return nil
}
// ❌ 错误:服务端信任客户端的伤害计算
func (s *GameServer) HandleAttack(req *AttackRequest) error {
// 问题:客户端可以伪造伤害数值
s.applyDamage(req.TargetID, req.Damage) // 客户端算的伤害
return nil
}
原则3:客户端只做“视觉“相关逻辑
// ✅ 正确:客户端预测(视觉)
type ClientPrediction struct {
localPosition Vector3 // 本地预测位置
serverPosition Vector3 // 服务器确认位置
}
// 客户端立即显示预测位置
func (c *Client) OnMoveInput(dir Vector3) {
c.localPosition = c.predictPosition(dir)
c.renderPlayerAt(c.localPosition) // 立即显示
go c.sendMoveRequest(dir) // 异步发送给服务器
}
// 服务器纠正
func (c *Client) OnMoveResponse(resp *MoveResponse) {
c.serverPosition = resp.NewPosition
c.renderPlayerAt(c.serverPosition) // 纠正显示
}
// ✅ 正确:客户端插值(视觉)
func (c *Client) InterpolatePosition(prev, curr Vector3, t float64) Vector3 {
// 线性插值,平滑显示
return lerp(prev, curr, t)
}
// ❌ 错误:客户端做权威判定
func (c *Client) CheckCollision(a, b *Entity) bool {
// 问题:客户端可以关闭碰撞检测
return c.physicsEngine.CheckCollision(a, b)
}
数据流向
graph LR
A[客户端输入] --> B[发送意图]
B --> C[服务端接收]
C --> D[验证]
D --> E[计算逻辑]
E --> F[更新状态]
F --> G[广播结果]
G --> H[客户端接收]
H --> I[显示结果]
关键点:
- 客户端只发送“我想做什么“
- 服务端验证“你是否可以这样做“
- 服务端计算“实际发生了什么“
- 客户端只显示“发生了什么“
真实案例:战斗系统设计
场景:MMORPG的战斗系统
客户端职责:
// 客户端:战斗表现
type ClientCombat struct {
// 1. 捕获玩家输入
func OnPlayerClickTarget(targetID uint64) {
client.sendTargetRequest(targetID)
}
func OnPlayerCastSkill(skillID uint32) {
client.sendCastRequest(skillID)
}
// 2. 预测显示(客户端预测)
func OnCastRequestSent(skillID uint32) {
client.playSkillAnimation(skillID) // 立即播放动画
client.predictSkillEffect(skillID) // 预测效果
}
// 3. 插值显示(平滑画面)
func OnServerStateUpdate(state *GameState) {
client.interpolateEntities(state) // 插值位置
}
// 4. 特效渲染
func renderSkillEffect(effect *SkillEffect) {
client.particleSystem.Play(effect)
}
}
服务端职责:
// 服务端:战斗逻辑
type ServerCombat struct {
// 1. 验证请求
func (sc *ServerCombat) HandleCastRequest(req *CastRequest) error {
// 验证玩家状态
if !sc.canCast(req.PlayerID, req.SkillID) {
return errors.New("cannot cast")
}
// 验证目标
if !sc.isValidTarget(req.PlayerID, req.TargetID) {
return errors.New("invalid target")
}
// 验证距离
if !sc.isInRange(req.PlayerID, req.TargetID, req.SkillID) {
return errors.New("out of range")
}
return nil
}
// 2. 计算战斗逻辑(权威)
func (sc *ServerCombat) ProcessCombat() {
// 技能释放
sc.processSkillCasts()
// 伤害计算
sc.calculateDamage()
// 状态更新
sc.updateEntityStates()
}
// 3. 广播结果
func (sc *ServerCombat) BroadcastCombatResult(result *CombatResult) {
// 发送给所有相关玩家
sc.network.Broadcast(result)
}
}
数据流示例:
1. 玩家点击技能 → 客户端发送CastRequest
2. 客户端立即播放技能动画(预测)
3. 服务端接收请求,验证合法性
4. 服务端计算伤害、更新状态
5. 服务端广播CombatResult
6. 客户端接收结果,纠正显示
平台SDK集成边界
平台SDK的职责
平台SDK负责:
- 用户认证(登录)
- 支付(内购)
- 社交(好友、分享)
- 数据统计
集成架构
graph TD
A[游戏客户端] --> B[平台适配层]
B --> C[微信SDK]
B --> D[支付宝SDK]
B --> E[Apple SDK]
B --> F[Goggle SDK]
关键设计:
- 游戏代码不直接调用平台SDK
- 通过适配层封装平台差异
- 便于扩展新平台
接口设计
// 平台适配层接口
type PlatformAdapter interface {
// 登录
Login() (*LoginResult, error)
// 支付
Purchase(productID string) (*PurchaseResult, error)
// 分享
Share(content *ShareContent) error
// 获取用户信息
GetUserInfo() (*UserInfo, error)
}
// 微信平台实现
type WeChatAdapter struct {
sdk *WeChatSDK
}
func (w *WeChatAdapter) Login() (*LoginResult, error) {
// 调用微信登录
code := w.sdk.GetAuthCode()
return w.sdk.AuthWithCode(code)
}
// Apple平台实现
type AppleAdapter struct {
sdk *AppleSDK
}
func (a *AppleAdapter) Login() (*LoginResult, error) {
// 调用Apple登录
credential := a.sdk.GetCredential()
return a.sdk.AuthWithCredential(credential)
}
// 平台管理器
type PlatformManager struct {
adapters map[string]PlatformAdapter
}
func (pm *PlatformManager) Login(platform string) (*LoginResult, error) {
adapter, ok := pm.adapters[platform]
if !ok {
return nil, errors.New("unsupported platform")
}
return adapter.Login()
}
使用示例
// 游戏代码(不关心具体平台)
func (g *Game) DoLogin() {
// 获取当前平台
platform := g.getPlatform() // "wechat", "apple", etc.
// 调用登录(不关心具体实现)
result, err := g.platformMgr.Login(platform)
if err != nil {
g.showError("登录失败")
return
}
// 发送到服务器验证
g.sendAuthToServer(result)
}
真实案例:支付系统集成
场景:游戏需要支持微信支付、支付宝、Apple Pay
错误设计:
// ❌ 错误:游戏代码直接调用各平台SDK
func (g *Game) Purchase(productID string) {
switch g.platform {
case "wechat":
g.wechatSDK.Pay(productID) // 直接调用
case "alipay":
g.alipaySDK.Pay(productID)
case "ios":
g.appleSDK.Purchase(productID)
}
}
// 问题:
// 1. 代码耦合,难以维护
// 2. 添加新平台需要修改游戏代码
// 3. 无法统一处理支付结果
正确设计:
// ✅ 正确:通过适配层封装
type PaymentAdapter interface {
Purchase(productID string) (*PaymentResult, error)
Verify(receipt string) error
}
type WeChatPayment struct{}
func (w *WeChatPayment) Purchase(productID string) (*PaymentResult, error) {
// 微信支付逻辑
}
type AlipayPayment struct{}
func (a *AlipayPayment) Purchase(productID string) (*PaymentResult, error) {
// 支付宝支付逻辑
}
// 游戏代码
func (g *Game) Purchase(productID string) {
// 获取当前平台的支付适配器
adapter := g.paymentMgr.GetAdapter(g.platform)
// 调用支付(不关心具体实现)
result, err := adapter.Purchase(productID)
if err != nil {
g.showError("支付失败")
return
}
// 发送到服务器验证
g.verifyPaymentWithServer(result)
}
// 添加新平台只需实现PaymentAdapter接口
type NewPlatformPayment struct{}
func (n *NewPlatformPayment) Purchase(productID string) (*PaymentResult, error) {
// 新平台支付逻辑
}
运营系统边界
运营系统的职责
运营系统负责:
- 活动配置和管理
- 数据分析和报表
- 内容审核和监控
- 客服和GM工具
系统架构
graph TD
A[游戏客户端] --> B[游戏服务器]
B --> C[运营后台]
C --> D[活动系统]
C --> E[数据分析]
C --> F[客服系统]
C --> G[配置系统]
接口设计
活动系统接口:
// 活动配置(运营后台 → 游戏服务器)
type ActivityConfig struct {
ID string
Name string
StartTime time.Time
EndTime time.Time
Rewards []Reward
Conditions []Condition
}
// 游戏服务器查询活动
func (gs *GameServer) GetActivities(playerID uint64) ([]*Activity, error) {
// 从配置系统获取活动
configs, err := gs.configClient.GetActivityConfigs()
if err != nil {
return nil, err
}
// 过滤玩家可见的活动
activities := make([]*Activity, 0)
for _, config := range configs {
if gs.isPlayerEligible(playerID, config) {
activities = append(activities, gs.toActivity(config))
}
}
return activities, nil
}
数据分析接口:
// 埋点数据(游戏客户端 → 游戏服务器 → 数据分析)
type AnalyticsEvent struct {
PlayerID uint64
EventType string
EventData map[string]interface{}
Timestamp time.Time
}
// 游戏服务器收集埋点
func (gs *GameServer) TrackEvent(playerID uint64, eventType string, data map[string]interface{}) {
event := &AnalyticsEvent{
PlayerID: playerID,
EventType: eventType,
EventData: data,
Timestamp: time.Now(),
}
// 异步发送到数据分析系统
go gs.analyticsClient.SendEvent(event)
}
配置管理
错误设计:
配置散落各处:
- 活动配置:在代码里hardcode
- 商品配置:在数据库里
- 任务配置:在Excel里
→ 结果:混乱,难以统一管理
正确设计:
配置系统统一管理:
- 所有配置都在配置系统
- 通过配置后台修改
- 通过API推送到游戏服务器
→ 结果:统一、可追溯、可热更新
接口版本管理
前后端协议版本
问题:客户端和服务器版本不一致怎么办?
解决方案:
// 协议版本号
type ProtocolVersion struct {
Major uint8 // 主版本(不兼容)
Minor uint8 // 次版本(向后兼容)
Patch uint8 // 补丁版本(bug修复)
}
// 请求携带版本
type Request struct {
Version ProtocolVersion
Data []byte
}
// 服务器处理不同版本
func (s *GameServer) HandleRequest(req *Request) (*Response, error) {
switch req.Version.Major {
case 1:
return s.handleV1Request(req)
case 2:
return s.handleV2Request(req)
default:
return nil, errors.New("unsupported version")
}
}
协议兼容策略
策略1:字段增加(兼容)
// v1协议
message PlayerState {
uint64 player_id = 1;
Vector3 position = 2;
int32 hp = 3;
}
// v2协议(增加字段,兼容v1)
message PlayerState {
uint64 player_id = 1;
Vector3 position = 2;
int32 hp = 3;
int32 mp = 4; // 新增字段,老客户端忽略
BuffState buff = 5; // 新增字段,老客户端忽略
}
策略2:字段修改(不兼容,新版本)
// v1协议
message PlayerState {
int32 hp = 1;
}
// v2协议(修改字段类型,不兼容)
message PlayerState {
int64 hp = 1; // 类型改变
}
// 处理:主版本号+1
小结
这一节我们学习了:
-
客户端vs服务端边界:
- 渲染、输入、物理 → 客户端
- 逻辑、数据、社交 → 服务端
- 客户端发送“意图“,服务端返回“结果“
-
平台SDK集成:
- 通过适配层封装平台差异
- 游戏代码不直接调用平台SDK
- 便于扩展新平台
-
运营系统边界:
- 活动配置、数据分析、客服系统
- 配置统一管理,避免散落各处
-
接口版本管理:
- 协议版本号
- 兼容策略
关键要点:
- 明确职责边界,避免“越界“
- 服务端最小化信任客户端
- 平台SDK通过适配层封装
- 配置统一管理
实战建议: 在新项目开始时,先画出系统边界图,明确每个系统的职责,团队评审。
下一节(1.6)我们将学习:游戏项目生命周期与团队协作,了解不同阶段的技术重点。