Client-Server 架构
上一节我们创建了 modMain.py、ServerSystem.py 和 ClientSystem.py 三个文件。你可能已经注意到了——为什么要分成 Server 和 Client 两个文件?为什么 modMain.py 里要分别注册 InitServer 和 InitClient?
这就是 Mod SDK 最核心的架构概念:Client-Server 分离。
你将学到
- 什么是 Client-Server 架构,为什么要这样设计
- 哪些逻辑写在服务端,哪些写在客户端
- 两端代码不能互相 import 的原因
- 两端之间如何通信
- 单人游戏也是 Client-Server
为什么要分两端
在我的世界中,即使你是单人游戏,游戏内部也运行着一个「本地服务端」和一个「客户端」。多人联机时,服务端运行在主机上,每个玩家的设备各运行一个客户端。
单人游戏:
┌────────────────────────────────┐
│ 你的电脑 │
│ ┌──────────┐ ┌──────────┐ │
│ │ 服务端 │ │ 客户端 │ │
│ │ (逻辑) │←→│ (表现) │ │
│ └──────────┘ └──────────┘ │
└────────────────────────────────┘
多人联机:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 服务端 │←──→│ 客户端 A │ │ 客户端 B │
│ (主机) │←──→│ (玩家A) │ │ (玩家B) │
└──────────┘ └──────────┘ └──────────┘这种分离的好处是:
- 防作弊:关键逻辑在服务端执行,客户端无法篡改
- 多人同步:服务端统一管理游戏状态,所有客户端看到一致的世界
- 职责清晰:逻辑和表现分离,代码更好维护
哪些逻辑属于哪一端
这是新手最常困惑的问题。记住一个简单的原则:
服务端管「规则」,客户端管「表现」。
| 类别 | 服务端(ServerSystem) | 客户端(ClientSystem) |
|---|---|---|
| 玩家 | 生命值、饥饿值、经验值、权限 | 相机视角、动画播放 |
| 物品 | 给予/移除物品、背包操作、耐久度 | 物品渲染、手持动画 |
| 实体 | 生成/销毁、AI 行为、伤害计算、属性 | 模型渲染、粒子特效 |
| 方块 | 放置/破坏、方块状态、掉落表 | 方块渲染、破坏动画 |
| 世界 | 天气、时间、游戏规则、记分板 | 音效、音乐 |
| UI | — | 界面创建/销毁、控件更新、按钮响应 |
| 特效 | — | 粒子播放、序列帧、模型特效 |
| 输入 | — | 按键检测、触屏操作 |
容易犯的错误
- 在客户端给玩家物品 → 不会生效(必须在服务端操作)
- 在服务端播放 UI → 不会生效(必须在客户端操作)
- 在服务端监听按键事件 → 不会生效(按键事件只在客户端触发)
代码隔离:不能互相 import
这是一条硬性规则:
ServerSystem.py 不能 import ClientSystem.py 中的任何内容
ClientSystem.py 不能 import ServerSystem.py 中的任何内容原因很简单:在多人联机中,服务端和客户端运行在不同的设备上。服务端代码运行在主机上,客户端代码运行在每个玩家的设备上——它们之间根本不在同一个 Python 进程里。
如果你在服务端 import 了客户端的模块,单人测试可能碰巧不报错(因为都在同一台电脑上),但一旦多人联机就会崩溃。
项目结构建议
为了避免意外互相 import,建议将代码按端分目录:
Scripts/
├── modMain.py
├── server/
│ ├── __init__.py
│ └── ServerSystem.py
└── client/
├── __init__.py
└── ClientSystem.py共享代码怎么办?
如果服务端和客户端需要共用一些常量或工具函数,可以放在一个独立的 common/ 目录中,两端都 import 它。但 common/ 中不能引用 serverApi 或 clientApi。
Scripts/
├── modMain.py
├── common/
│ ├── __init__.py
│ └── config.py ← 存放常量,两端共享
├── server/
│ └── ServerSystem.py ← 可以 import common.config
└── client/
└── ClientSystem.py ← 可以 import common.config两端之间如何通信
既然两端不能互相 import,那客户端检测到玩家按了一个按钮,要怎么通知服务端执行逻辑呢?
答案是自定义事件。Mod SDK 提供了四个跨端通信函数:
| 函数 | 方向 | 作用 |
|---|---|---|
NotifyToServer | 客户端 → 服务端 | 单个客户端通知服务端 |
NotifyToClient | 服务端 → 客户端 | 服务端通知指定客户端 |
BroadcastToAllClient | 服务端 → 所有客户端 | 服务端广播给所有客户端 |
BroadcastEvent | 同端广播 | 在当前端(服务端或客户端)内部广播 |
通信流程示例——玩家按下攻击按钮:
1. 客户端检测到按钮按下(客户端事件)
2. 客户端调用 NotifyToServer 发送「玩家要攻击」事件
3. 服务端收到事件,执行伤害计算(服务端逻辑)
4. 服务端调用 BroadcastToAllClient 发送「播放攻击特效」事件
5. 所有客户端收到事件,播放特效(客户端表现)自定义事件详解
自定义事件的具体写法将在 S5. 自定义事件与通信 中详细讲解。本节只需理解通信方向。
更安全的通信方式:ModAttr
自定义事件虽然灵活,但有一个隐患:事件数据在传输过程中可能被外挂劫持或篡改。如果你传输的是关键游戏数据(金币、分数、通关状态等),使用自定义事件并不安全。
Mod SDK 提供了一套更安全的数据同步机制——ModAttr(自定义属性)。它的核心特点是:
- 服务端
SetAttr后,数据自动同步到所有客户端,无需手动发事件 - 客户端通过
RegisterUpdateFunc注册回调,属性变化时自动触发 - 同步走引擎内部通道,不经过自定义事件的网络包,外挂更难劫持
- 支持持久化存储(实体卸载后数据不丢失)
基本用法
服务端设置属性(自动同步到客户端):
import mod.server.extraServerApi as serverApi
# 创建 ModAttr 组件
comp = serverApi.GetEngineCompFactory().CreateModAttr(playerId)
# 设置属性,值会自动同步到客户端
comp.SetAttr("emerald_count", 5)
comp.SetAttr("quest_status", "in_progress")
# 如果需要持久化(退出再进入后仍保留),第三个参数设为 True
comp.SetAttr("total_score", 100, True)客户端读取属性 + 监听变化:
import mod.client.extraClientApi as clientApi
comp = clientApi.GetEngineCompFactory().CreateModAttr(playerId)
# 直接读取(已自动同步过来的值)
count = comp.GetAttr("emerald_count", 0)
# 注册回调:当服务端修改 emerald_count 时自动触发
comp.RegisterUpdateFunc("emerald_count", self.OnEmeraldCountChanged)
def OnEmeraldCountChanged(self, data):
entityId = data["entityId"]
oldValue = data["oldValue"]
newValue = data["newValue"]
print "绿宝石数量变化: %d -> %d" % (oldValue, newValue)
# 在这里更新 UI 显示自定义事件 vs ModAttr
| 对比项 | 自定义事件 | ModAttr |
|---|---|---|
| 通信方向 | 双向(客户端↔服务端) | 单向(服务端→客户端自动同步) |
| 安全性 | 事件数据可能被劫持 | 走引擎内部通道,更安全 |
| 使用场景 | 一次性指令("执行攻击"、"打开商店") | 持续同步的状态数据(分数、血量、任务进度) |
| 持久化 | 不支持 | 支持(needRestore=True) |
| 客户端→服务端 | 支持(NotifyToServer) | 不支持(客户端 SetAttr 只是本地的) |
选择建议
- 关键游戏数据(金币、分数、通关状态、装备属性)→ 用 ModAttr,防篡改
- 一次性操作请求(玩家点击按钮、发起交互)→ 用自定义事件,服务端收到后校验再执行
- 客户端→服务端通信 → 只能用自定义事件(ModAttr 不支持反向同步)
最安全的做法:客户端通过事件请求服务端操作,服务端校验后修改 ModAttr,客户端通过 RegisterUpdateFunc 被动接收结果。
最佳实践流程
客户端按下按钮
│
▼ NotifyToServer("RequestBuySeeds", {}) ← 自定义事件(仅请求)
│
服务端收到请求
│ 校验:玩家绿宝石 >= 1 ?
│ 执行:扣绿宝石、给种子
│
▼ comp.SetAttr("emerald_count", newCount) ← ModAttr 自动同步
│
客户端 RegisterUpdateFunc 触发
│ 更新 UI 显示新的绿宝石数量这样即使外挂拦截了 RequestBuySeeds 事件,也无法篡改最终结果——因为数据变更由服务端通过 ModAttr 下发,客户端只是被动接收。
回到代码:重新理解 modMain.py
现在回头看上一节写的 modMain.py,你应该能理解每一行的含义了:
@Mod.Binding(name="TomatoIslandMod", version="0.0.1")
class TomatoIslandMod(object):
def __init__(self):
pass
@Mod.InitServer() # 服务端启动时调用
def initServer(self):
serverApi.RegisterSystem(
"TomatoIslandMod",
"TomatoIslandServerSystem",
"Scripts.ServerSystem.TomatoIslandServerSystem" # 注册服务端 System
)
@Mod.InitClient() # 客户端启动时调用
def initClient(self):
clientApi.RegisterSystem(
"TomatoIslandMod",
"TomatoIslandClientSystem",
"Scripts.ClientSystem.TomatoIslandClientSystem" # 注册客户端 System
)InitServer和InitClient是分别在两端各自调用的RegisterSystem把你写的 System 类注册进引擎,之后引擎才会把事件派发给你- 服务端和客户端的 System 是完全独立的两个实例
实践:在两端分别打印日志
修改上一节的代码,在两端分别打印不同的日志,验证它们确实是独立运行的:
ServerSystem.py:
class TomatoIslandServerSystem(ServerSystem):
def __init__(self, namespace, systemName):
ServerSystem.__init__(self, namespace, systemName)
print "====== [Server] 我运行在服务端 ======"
print "====== [Server] 我负责游戏逻辑 ======"ClientSystem.py:
class TomatoIslandClientSystem(ClientSystem):
def __init__(self, namespace, systemName):
ClientSystem.__init__(self, namespace, systemName)
print "====== [Client] 我运行在客户端 ======"
print "====== [Client] 我负责界面和特效 ======"运行游戏后,在脚本测试日志中你会看到两组日志分别出现:
====== [Server] 我运行在服务端 ======
====== [Server] 我负责游戏逻辑 ======
====== [Client] 我运行在客户端 ======
====== [Client] 我负责界面和特效 ======它们来自两个不同的 System 实例,运行在两个不同的环境中。
小结
| 要点 | 说明 |
|---|---|
| 架构 | 即使单人游戏也是 Client-Server |
| 服务端职责 | 游戏逻辑:物品、伤害、生成、方块、世界规则 |
| 客户端职责 | 表现:UI、特效、动画、音效、输入检测 |
| 代码隔离 | 两端 禁止互相 import,否则多人联机会崩溃 |
| 事件通信 | 自定义事件:灵活但可能被劫持,适合一次性操作请求 |
| ModAttr 通信 | 自动同步:更安全,适合持续同步的关键状态数据 |
| 最佳实践 | 客户端用事件请求,服务端校验后用 ModAttr 下发结果 |
| 共享代码 | 放在 common/ 目录,不引用任何端的 API |
接下来我们将学习如何使用事件系统——Mod SDK 中所有交互的基础。
