Skip to content

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 注册回调,属性变化时自动触发
  • 同步走引擎内部通道,不经过自定义事件的网络包,外挂更难劫持
  • 支持持久化存储(实体卸载后数据不丢失)

基本用法 ​

服务端设置属性(自动同步到客户端):

python
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)

客户端读取属性 + 监听变化:

python
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,你应该能理解每一行的含义了:

python
@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:

python
class TomatoIslandServerSystem(ServerSystem):

    def __init__(self, namespace, systemName):
        ServerSystem.__init__(self, namespace, systemName)
        print "====== [Server] 我运行在服务端 ======"
        print "====== [Server] 我负责游戏逻辑 ======"

ClientSystem.py:

python
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 中所有交互的基础。