自定义事件与通信
前面我们学了引擎事件(AddServerPlayerEvent、ServerBlockUseEvent 等)——这些是引擎自动发布的。但实际开发中,你还需要:
- 自定义事件:定义自己的事件,在同一端内部广播
- 跨端通信:客户端和服务端之间传递消息
同时,跨端通信涉及安全问题——如果不做校验,外挂可以伪造客户端消息来作弊。
你将学到
- 定义和触发自定义事件
- 客户端 → 服务端通信(
NotifyToServer) - 服务端 → 客户端通信(
NotifyToClient/BroadcastToAllClient) __id__的含义和安全作用- 通信安全原则:服务端权威、通信校验、不暴露危险接口
自定义事件(同端广播)
有时你的 Mod 有多个 System(比如战斗系统和商店系统),它们之间需要通信。这时用 BroadcastEvent 在同一端内部广播自定义事件。
定义和触发
# 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)监听自定义事件
# 同端的另一个 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
# ClientSystem.py —— 客户端发送请求
def OnBuyButtonClick(self):
eventData = self.CreateEventData()
eventData["itemName"] = "tomato_seeds"
eventData["count"] = 8
self.NotifyToServer("RequestBuyItem", eventData)# 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
# ServerSystem.py —— 服务端发送给指定客户端
def SendResultToPlayer(self, playerId, success):
eventData = self.CreateEventData()
eventData["success"] = success
eventData["message"] = "购买成功" if success else "绿宝石不足"
self.NotifyToClient(playerId, "BuyItemResult", eventData)# ClientSystem.py —— 客户端接收
self.ListenForEvent(
"TomatoIslandMod",
"TomatoIslandServerSystem",
"BuyItemResult",
self,
self.OnBuyResult
)
def OnBuyResult(self, args):
if args["success"]:
print "购买成功!"
else:
print "失败: %s" % args["message"]服务端 → 所有客户端:BroadcastToAllClient
# 广播给所有玩家
eventData = self.CreateEventData()
eventData["announcement"] = "番茄鸡入侵了牧场!"
self.BroadcastToAllClient("GlobalAnnouncement", eventData)通信函数总结
| 函数 | 方向 | 使用场景 |
|---|---|---|
BroadcastEvent | 同端内部 | 一个 System 通知同端的其他 System |
NotifyToServer | 客户端 → 服务端 | 客户端发起请求(按钮点击、操作请求) |
NotifyToClient | 服务端 → 指定客户端 | 服务端回复某个玩家(购买结果、个人数据) |
BroadcastToAllClient | 服务端 → 所有客户端 | 服务端广播全服消息(公告、全局状态变化) |
NotifyToMultiClients | 服务端 → 多个客户端 | 批量发送,性能优于循环调用 NotifyToClient |
__id__:引擎注入的安全标识
这是通信安全中最关键的一个知识点。
当客户端通过 NotifyToServer 发送事件到服务端时,服务端的回调参数中会自动携带一个 __id__ 字段,其值为发送这条消息的客户端所对应的玩家 ID。
def OnRequestBuyItem(self, args):
playerId = args["__id__"] # 引擎自动注入,客户端无法伪造关键安全规则
__id__ 是引擎内部注入的,客户端无法篡改。 永远使用 args["__id__"] 来识别请求来源的玩家,不要信任客户端自己发送的 playerId 字段。
# 错误做法(不安全)
def OnRequest(self, args):
playerId = args["playerId"] # 客户端自己填的,外挂可以伪造成别人
# 正确做法(安全)
def OnRequest(self, args):
playerId = args["__id__"] # 引擎注入的,不可伪造通信安全原则
游戏行业有一句老话:「永远不要信任客户端」。外挂可以拦截、伪造、篡改客户端发出的任何数据。你的 Mod 必须遵循以下安全原则:
原则一:服务端权威
所有关键逻辑和数据修改必须在服务端执行。 客户端只负责发送「请求」,服务端决定是否执行。
# 错误:客户端直接修改数据
# 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__"]
# 服务端检查、扣款、发物品——一切在服务端完成原则二:通信校验
服务端收到客户端请求后,必须校验请求的合理性:
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 脚本)。不要在客户端或服务端留下任何"按客户端说的做"的万能接口。
# 错误:服务端无条件执行客户端传来的任意操作(这就是后门)
# ServerSystem.py
def OnGiveItem(self, args):
playerId = args["__id__"]
# 客户端说给什么就给什么——外挂填64个钻石就能刷物品
comp.SpawnItemToPlayerInv(
{"itemName": args["itemName"], "count": args["count"], "auxValue": 0},
playerId
)# 正确:服务端维护商品配置表,只接受合法的商品 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 从服务端自动同步到客户端,比用自定义事件更安全:
# 服务端修改数据(自动同步到客户端)
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:
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:
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 界面——使用编辑器创建界面布局。
