Skip to content

自定义事件与通信 ​

前面我们学了引擎事件(AddServerPlayerEvent、ServerBlockUseEvent 等)——这些是引擎自动发布的。但实际开发中,你还需要:

  • 自定义事件:定义自己的事件,在同一端内部广播
  • 跨端通信:客户端和服务端之间传递消息

同时,跨端通信涉及安全问题——如果不做校验,外挂可以伪造客户端消息来作弊。

你将学到

  • 定义和触发自定义事件
  • 客户端 → 服务端通信(NotifyToServer)
  • 服务端 → 客户端通信(NotifyToClient / BroadcastToAllClient)
  • __id__ 的含义和安全作用
  • 通信安全原则:服务端权威、通信校验、不暴露危险接口

自定义事件(同端广播) ​

有时你的 Mod 有多个 System(比如战斗系统和商店系统),它们之间需要通信。这时用 BroadcastEvent 在同一端内部广播自定义事件。

定义和触发 ​

python
# ServerSystem.py

def __init__(self, namespace, systemName):
    ServerSystem.__init__(self, namespace, systemName)
    # 定义一个自定义事件
    self.DefineEvent("PlayerScoreChanged")

def AddScore(self, playerId, score):
    # 触发自定义事件
    eventData = self.CreateEventData()
    eventData["playerId"] = playerId
    eventData["score"] = score
    self.BroadcastEvent("PlayerScoreChanged", eventData)

监听自定义事件 ​

python
# 同端的另一个 System 监听这个事件
self.ListenForEvent(
    "TomatoIslandMod",          # 发布者的 namespace
    "TomatoIslandServerSystem", # 发布者的 systemName
    "PlayerScoreChanged",
    self,
    self.OnScoreChanged
)

def OnScoreChanged(self, args):
    print "玩家 %s 的分数变为 %d" % (args["playerId"], args["score"])

CreateEventData

CreateEventData() 返回一个 dict,支持嵌套 dict、list 和基本数据类型。不支持 tuple——tuple 会在传输过程中被转换为 list。

跨端通信 ​

客户端 → 服务端:NotifyToServer ​

python
# ClientSystem.py —— 客户端发送请求
def OnBuyButtonClick(self):
    eventData = self.CreateEventData()
    eventData["itemName"] = "tomato_seeds"
    eventData["count"] = 8
    self.NotifyToServer("RequestBuyItem", eventData)
python
# ServerSystem.py —— 服务端接收并处理
self.ListenForEvent(
    "TomatoIslandMod",
    "TomatoIslandClientSystem",
    "RequestBuyItem",
    self,
    self.OnRequestBuyItem
)

def OnRequestBuyItem(self, args):
    playerId = args["__id__"]      # ← 引擎自动注入的玩家 ID
    itemName = args["itemName"]
    count = args["count"]
    print "玩家 %s 请求购买 %s x %d" % (playerId, itemName, count)

服务端 → 指定客户端:NotifyToClient ​

python
# ServerSystem.py —— 服务端发送给指定客户端
def SendResultToPlayer(self, playerId, success):
    eventData = self.CreateEventData()
    eventData["success"] = success
    eventData["message"] = "购买成功" if success else "绿宝石不足"
    self.NotifyToClient(playerId, "BuyItemResult", eventData)
python
# ClientSystem.py —— 客户端接收
self.ListenForEvent(
    "TomatoIslandMod",
    "TomatoIslandServerSystem",
    "BuyItemResult",
    self,
    self.OnBuyResult
)

def OnBuyResult(self, args):
    if args["success"]:
        print "购买成功!"
    else:
        print "失败: %s" % args["message"]

服务端 → 所有客户端:BroadcastToAllClient ​

python
# 广播给所有玩家
eventData = self.CreateEventData()
eventData["announcement"] = "番茄鸡入侵了牧场!"
self.BroadcastToAllClient("GlobalAnnouncement", eventData)

通信函数总结 ​

函数方向使用场景
BroadcastEvent同端内部一个 System 通知同端的其他 System
NotifyToServer客户端 → 服务端客户端发起请求(按钮点击、操作请求)
NotifyToClient服务端 → 指定客户端服务端回复某个玩家(购买结果、个人数据)
BroadcastToAllClient服务端 → 所有客户端服务端广播全服消息(公告、全局状态变化)
NotifyToMultiClients服务端 → 多个客户端批量发送,性能优于循环调用 NotifyToClient

__id__:引擎注入的安全标识 ​

这是通信安全中最关键的一个知识点。

当客户端通过 NotifyToServer 发送事件到服务端时,服务端的回调参数中会自动携带一个 __id__ 字段,其值为发送这条消息的客户端所对应的玩家 ID。

python
def OnRequestBuyItem(self, args):
    playerId = args["__id__"]   # 引擎自动注入,客户端无法伪造

关键安全规则

__id__ 是引擎内部注入的,客户端无法篡改。 永远使用 args["__id__"] 来识别请求来源的玩家,不要信任客户端自己发送的 playerId 字段。

python
# 错误做法(不安全)
def OnRequest(self, args):
    playerId = args["playerId"]    # 客户端自己填的,外挂可以伪造成别人

# 正确做法(安全)
def OnRequest(self, args):
    playerId = args["__id__"]      # 引擎注入的,不可伪造

通信安全原则 ​

游戏行业有一句老话:「永远不要信任客户端」。外挂可以拦截、伪造、篡改客户端发出的任何数据。你的 Mod 必须遵循以下安全原则:

原则一:服务端权威 ​

所有关键逻辑和数据修改必须在服务端执行。 客户端只负责发送「请求」,服务端决定是否执行。

python
# 错误:客户端直接修改数据
# ClientSystem.py
def OnBuyClick(self):
    self.playerGold -= 100     # 客户端直接扣金币?外挂直接改内存
    self.NotifyToServer("BuySuccess", {})

# 正确:客户端只发请求,服务端执行逻辑
# ClientSystem.py
def OnBuyClick(self):
    self.NotifyToServer("RequestBuy", {"shopId": "seeds_shop"})

# ServerSystem.py
def OnRequestBuy(self, args):
    playerId = args["__id__"]
    # 服务端检查、扣款、发物品——一切在服务端完成

原则二:通信校验 ​

服务端收到客户端请求后,必须校验请求的合理性:

python
def OnRequestBuy(self, args):
    playerId = args["__id__"]    # 用引擎注入的 ID
    shopId = args.get("shopId", "")

    # 校验 1:shopId 是否合法
    if shopId not in self.validShops:
        print "非法的商店 ID: %s" % shopId
        return

    # 校验 2:玩家是否有足够的绿宝石
    emeraldCount = self.GetPlayerEmeraldCount(playerId)
    if emeraldCount < 1:
        self.NotifyToClient(playerId, "BuyResult", {
            "success": False, "msg": "绿宝石不足"
        })
        return

    # 校验 3:玩家背包是否有空位
    # ...

    # 校验通过,执行购买逻辑
    self.DeductEmerald(playerId, 1)
    itemComp = serverApi.GetEngineCompFactory().CreateItem(playerId)
    itemComp.SpawnItemToPlayerInv(
        {"itemName": "tomato_island:tomato_seeds", "count": 8, "auxValue": 0},
        playerId
    )
    self.NotifyToClient(playerId, "BuyResult", {"success": True})

原则三:不在客户端暴露危险接口 ​

客户端代码对所有玩家可见(外挂可以反编译 Python 脚本)。不要在客户端或服务端留下任何"按客户端说的做"的万能接口。

python
# 错误:服务端无条件执行客户端传来的任意操作(这就是后门)
# ServerSystem.py
def OnGiveItem(self, args):
    playerId = args["__id__"]
    # 客户端说给什么就给什么——外挂填64个钻石就能刷物品
    comp.SpawnItemToPlayerInv(
        {"itemName": args["itemName"], "count": args["count"], "auxValue": 0},
        playerId
    )
python
# 正确:服务端维护商品配置表,只接受合法的商品 ID
# ServerSystem.py
SHOP_CONFIG = {
    "seeds":  {"item": "tomato_island:tomato_seeds", "count": 8, "price": 1},
    "soup":   {"item": "tomato_island:tomato_soup",  "count": 1, "price": 2},
}

def OnRequestBuyItem(self, args):
    playerId = args["__id__"]
    shopItemId = args.get("shopItemId", "")

    if shopItemId not in SHOP_CONFIG:
        return  # 非法请求,直接忽略

    config = SHOP_CONFIG[shopItemId]
    # 从服务端配置读取物品名和价格,不信任客户端

原则四:使用 ModAttr 下发关键数据 ​

在 S2. Client-Server 架构 中我们介绍了 ModAttr。对于金币、分数、等级等关键游戏状态,用 ModAttr 从服务端自动同步到客户端,比用自定义事件更安全:

python
# 服务端修改数据(自动同步到客户端)
comp = serverApi.GetEngineCompFactory().CreateModAttr(playerId)
comp.SetAttr("emerald_count", newCount)

# 客户端监听变化(被动接收,无法篡改)
comp = clientApi.GetEngineCompFactory().CreateModAttr(playerId)
comp.RegisterUpdateFunc("emerald_count", self.OnEmeraldChanged)

安全检查清单 ​

每次编写跨端通信代码时,对照这个清单:

检查项说明
用 __id__ 识别玩家不信任客户端自己发的 playerId
请求参数校验服务端校验每个参数的合法性和范围
服务端查配置表物品名、价格等从服务端配置读取,不接受客户端指定
不暴露万能接口不提供"按客户端说的给物品/加钱"之类的通用接口
关键数据用 ModAttr金币、分数等状态走 ModAttr 同步,不走事件
客户端只做展示客户端只显示数据和发送请求,不做逻辑判断

实战:安全的商店购买流程 ​

完整的安全购买流程代码:

ClientSystem.py:

python
def OnBuyButtonClick(self, shopItemId):
    # 客户端只发送商品 ID,不发物品名和价格
    eventData = self.CreateEventData()
    eventData["shopItemId"] = shopItemId
    self.NotifyToServer("RequestBuyItem", eventData)

def OnBuyResult(self, args):
    if args["success"]:
        print "购买成功!"
    else:
        print "购买失败: %s" % args["message"]

ServerSystem.py:

python
SHOP_CONFIG = {
    "seeds":  {"item": "tomato_island:tomato_seeds", "count": 8, "price": 1},
    "soup":   {"item": "tomato_island:tomato_soup",  "count": 1, "price": 2},
    "staff":  {"item": "tomato_island:emerald_staff", "count": 1, "price": 5},
}

def OnRequestBuyItem(self, args):
    playerId = args["__id__"]                       # 1. 引擎注入的可信 ID
    shopItemId = args.get("shopItemId", "")

    if shopItemId not in SHOP_CONFIG:                # 2. 校验商品 ID
        return

    config = SHOP_CONFIG[shopItemId]
    price = config["price"]

    modAttrComp = serverApi.GetEngineCompFactory().CreateModAttr(playerId)
    currentGold = modAttrComp.GetAttr("emerald_count", 0)

    if currentGold < price:                          # 3. 校验余额
        self.NotifyToClient(playerId, "BuyResult", {
            "success": False, "message": "绿宝石不足"
        })
        return

    # 4. 服务端执行扣款(ModAttr 自动同步到客户端)
    modAttrComp.SetAttr("emerald_count", currentGold - price)

    # 5. 服务端发放物品
    itemComp = serverApi.GetEngineCompFactory().CreateItem(playerId)
    itemComp.SpawnItemToPlayerInv(
        {"itemName": config["item"], "count": config["count"], "auxValue": 0},
        playerId
    )

    # 6. 通知客户端结果
    self.NotifyToClient(playerId, "BuyResult", {"success": True})

小结 ​

要点说明
自定义事件DefineEvent + CreateEventData + BroadcastEvent,同端内部通信
客户端→服务端NotifyToServer,回调中用 args["__id__"] 获取发送者
服务端→客户端NotifyToClient(指定玩家)/ BroadcastToAllClient(全服广播)
__id__引擎内部注入的玩家 ID,不可伪造,是识别请求来源的唯一可信方式
服务端权威关键逻辑和数据修改只在服务端执行
通信校验服务端校验每个参数的合法性,不信任客户端数据
不暴露接口不提供"按客户端说的操作"的万能接口
ModAttr关键状态数据走 ModAttr 同步,比事件更安全

接下来我们将学习制作 UI 界面——使用编辑器创建界面布局。