MeteorCat / 事件信号推送

Created Sun, 25 Feb 2024 01:54:48 +0800 Modified Wed, 29 Oct 2025 23:24:59 +0800

事件信号推送

无论什么游戏引擎都有类似的跨场景推送要求, 本质上就是不同节点的事件监听:

  • Unity3D的A预制件如何通知B预制件?
  • Godot的A场景对象如何通知到B场景?
  • ……

这里说个最简单的场景应用: 玩家通过点击商人购物的场景, 需要同步更新玩家GUI的金额和背包内部物品信息, 内部蕴含的就是这种不同场景( 商品界面GUI|玩家界面GUI|背包界面GUI )的多处同步更新.

甚至大型游戏需要细致到点击之后同步不同节点播放多个复杂动画效果, 让游戏效果更加绚丽

这里制作个简单样例:

Game

  • Merchant 是商店购买界面
  • Status 是玩家信息状态界面
  • Player 是详细购买信息提示界面

可以看到以上都是分布三个节点, Merchant 具体负责触发购买事件, 之后需要让 StatusPlayer 获取到购买信息.

具体难点就是 Button 信号事件全在 Merchant 身上, 最简单就是 Shop 根节点挂载具体处理脚本, 之后把由他通知其他所有节点.

但是这种父节点通知下属节点方式是有很大问题的, 因为 Merchant 所有 Button 信号触发事件必须绑定到最上层的 Shop 父节点; 这种绑定锁死上下级关系导致节点千万不能随意变动, 因为按键信号绑定需要类型 ../../Shop.gd 反向绑定节点, 随意变动上下级关系会导致直接事件失效, 而且场景复用频繁也会因为这样没办法绑定指定触发事件.

观察者模式

这是常见的设计模式, 采用全局唯一管理器挂载在进程中等待订阅和推送:

  • Subscrib: 订阅端, 初始化的时候绑定监听调用服务函数方法
  • Publish: 推送端, 运行时推送数据给订阅类对象

这里伪代码编写:

# =============================================================
# 假设商人对象等待触发支付
# =============================================================
extends Node
class_name Merchant

# 初始化的时候商人需要订阅结算事件, 因为其无法获取是否能够完成支付
func _init():
    PubSub.subscribe("merchant_balance", self)

# 删除对象回调方法
func queue_free():
    # 解除订阅
    PubSub.unsubscribe(self).queue_free()

# 监听订阅回调
func event_published(event_key, payload):
    # 转发到内部函数方法让其结算商店内部数据
    if event_key=="merchant_balance":
        balance(payload)

# 这里是商人自定义按键购买, 点击的时候会触发
func pay():
    # item_id 代表需要支付的道具id, 用于结算的时候返回 id 确定金额足够
    # amount 代表需要支付的金币, 由触发的选项所需金额推送
    payload = { "item_id":item_id, "gold": gold }
    PubSub.publish("player_pay", payload)

# 等待结算, 因为不能获取玩家信息无法确定是否金额足够, 所以需要等待结算
func balance(payload):
    print(payload)


# =============================================================
# 假设这里是 Player.gd 维护脚本
# =============================================================
extends Node
class_name Player

# 身上金币数额
var gold:int = 100

# 初始化的时候玩家订阅支付事件
func _init():
    PubSub.subscribe("player_pay", self)

# 删除对象回调方法
func queue_free():
    # 解除订阅
    PubSub.unsubscribe(self).queue_free()

# 监听订阅回调
func event_published(event_key, payload):
    # 转发到内部函数方法让其结算商店内部数据
    if event_key=="player_pay":
        player_pay(payload)


# 触发玩家支付事件
func player_pay(payload):
    # 如果金额不够响应错误提示标识金币不足
    if payload.gold > gold:
        pass
    else:
        # 扣除玩家金额
        gold = gold - payload.gold
        
        # 金额足够直接推送商人结算道具id即可
        PubSub.publish("merchant_balance", { "item_id": payload.item_id })

这种设计可以无视节点关联性, 只需要在自己模块当中订阅需要的事件Key即可.

这里推荐 Github的Godot管理器插件 来进行游戏事件管理.

一般用现成事件绑定库就行了, 主要思路都能看源代码就能了解大概功能.

该库只能在 Godot3 生效, 后续 Godot4 改写大量规则导致无法以静态对象来读写, 所以需要更新了新的 Godot4 版本事件管理器:

"""
Publish-Subscribe mechanism In Godot4
"""
extends Node
class_name PubSub

static var subscriptions = {}

static var instant_subscriptions = {}

static var published_async = []

static var all_events = []

static var services = {}

static func subscribe(event_key, listener)->void:
	"""
	Subscribes listener to the given event_key.
	"""
	if !listener.has_method("event_published"):
		# No method to call
		return
	if event_key=="":
		# Subscribe to all events
		all_events.append(listener)
	elif subscriptions.has(event_key):
		if !subscriptions[event_key].has(listener):
			subscriptions[event_key].append(listener)
	else:
		subscriptions[event_key] = [listener]

static func unsubscribe(listener, event_key = null)->void:
	"""
	Unsubscribes listener from event_key, or all event_keys if not supplied
	"""
	if event_key==null:
		for arr in subscriptions.values():
			if arr.has(listener): arr.erase(listener)
		if all_events.has(listener):
			all_events.erase(listener)
	else:
		subscriptions[event_key].erase(listener)

static func publish(event_key, payload = null)->void:
	"""
	Publishes the given event_key and payload. Subscribers to event_key will 
	have their eventPublished methods called.
	"""
	if !subscriptions.has(event_key): return
	var listeners = subscriptions[event_key] as Array
	var toRemove = []
	for listener in listeners:
		if listener.is_queued_for_deletion():
			toRemove.add(listener)
		else:
			listener.event_published(event_key, payload)
	# Tidy up any deleted listener objects
	for listenerToRemove in toRemove:
		listeners.erase(listenerToRemove)

static func publish_to_random(event_key:String, payload)->void:
	"""
	Publish an event to a single randomly-chosen subscriber
	"""
	if !subscriptions.has(event_key): return
	var listeners = subscriptions[event_key]
	var index = randi() % listeners.size()
	listeners[index].eventPublished(event_key, payload)


static func publish_async(event_key, payload)->void:
	"""
	Queue the given event for async publishing. PubSub._process() MUST be called for this to work!
	"""
	if !subscriptions.has(event_key): return
	var listeners = subscriptions[event_key]
	var toRemove = []
	for listener in listeners:
		if listener.is_queued_for_deletion():
			toRemove.add(listener)
		elif listener.has_method("event_published"):
			published_async.push_front([listener, event_key, payload])
	# Tidy up any deleted listener objects
	for listenerToRemove in toRemove:
		listeners.erase(listenerToRemove)
	
static func subscribe_instant(event_key, listener)->void:
	"""
	Subscribes listener to the given instant event_key.
	"""
	if instant_subscriptions.has(event_key):
		if !instant_subscriptions[event_key].has(listener):
			instant_subscriptions[event_key].append(listener)
	else:
		instant_subscriptions[event_key] = [listener]

static func unsubscribe_instant(listener, event_key = null)->void:
	"""
	Unsubscribes listener from instant event_key, or all event_keys if not supplied
	"""
	if event_key==null:
		for arr in instant_subscriptions.values():
			if arr.has(listener): arr.erase(listener)
	else:
		instant_subscriptions[event_key].erase(listener)


static func publish_instant(event_key, payload)->Array:
	"""
	Publish the given instant event key to all listeners and return an array of their responses
	"""
	var result = []
	if !subscriptions.has(event_key): return result
	var listeners = subscriptions[event_key] as Array
	for listener in listeners:
		result.append(listener.instantEventPublished(event_key, payload))
	return result


static func process()->void:
	"""
	Process the next outstanding async event, if any.
	"""
	if published_async.size()==0: return

	var arr = published_async.pop_back() as Array
	var listener = arr[0]
	var event_key = arr[1]
	var payload = arr[2]
	if listener.has_method("event_published"):
		listener.event_published(event_key, payload)


static func register_service(service_id:int, provider)->void:
	"""
	Registers a service for the given int service_id
	"""
	services[service_id] = provider


static func get_service(service_id:int):
	"""
	Returns the service registered under the given int service_id
	"""
	return services[service_id]


static func clear()->void:
	"""
	Clears all event subscriptions
	"""
	subscriptions.clear()
	instant_subscriptions.clear()
	published_async.clear()
	all_events.clear()
	services.clear()

static func clear_instant_events()->void:
	"""
	Clears all instant event subscriptions
	"""
	instant_subscriptions.clear()

跨界点监听

现在回过头去实现之前的游戏样例功能, 先按照库部署设置设置为全局挂载对象.

首先是商人类来绑定对应事件:

'''
商人加载Button事件管理, 
'''
extends Node
class_name MerchantNode

func _init():
	PubSub.subscribe("player_payment", self)
	
func _exit_tree():
	PubSub.unsubscribe(self)

func event_published(event_key, payload):
	print_debug(event_key,payload)

func _on_small_button_up():
	var payload = {"gold":100,"item_id":1}
	PubSub.publish("player_payment",payload)
	
	
func _on_middle_button_up():
	var payload = {"gold":200,"item_id":2}
	PubSub.publish("player_payment",payload)
	

func _on_large_button_up():
	var payload = {"gold":300,"item_id":3}
	PubSub.publish("player_payment",payload)

之后就是转发到玩家状态栏类:

extends CanvasLayer

func _init():
	PubSub.subscribe("player_payment", self)

func _exit_tree():
	PubSub.unsubscribe(self)

func event_published(event_key, payload):
	print_debug(event_key,payload)

之后试着启动项目测试下点击是否会全局推送事件, 最后补充上所有欠缺功能即可.