Godot-GridGame架构DI容器全局联动设计

前言

本文是 针对Godot-GridGame(网格游戏)架构设计的研究(全程开源)-CSDN博客 中联动各层级的DI核心容器的设计,阅读前请先阅读该文章的总体架构设计,同时本文将会默认读者已经阅读过本架构的5层详细设计,这5个层级设计可以从主架构文章中跳转链接查看。

1. DI 核心设计总览

1. DI 核心容器设计思想

DI核心容器是各层之间执行注入操作以及初始化操作的总容器,信号连接,大部分层级类注入都在这里完成。此外,容器可以操作每一个层级的出入口,认识所有的层级,大部分层级之间的连接均在DI容器完成,同时大部分层级的tick比如逻辑层的tick由DI容器统一驱动

对于DI容器注入,在本框架中,一共有4种注入方式:

**1. 场景节点注入:**在Godot中使用 @export 可以将场景中的节点挂载进DI容器,然后DI容器将挂载的节点注入到需要注入的层级中

**2. 构造注入:**在DI容器中将需要注入的数据在对应层级类初始化时传入初始化数据,构造对应类。

**3. 引用注入:**类已经完成创建后,将数据引用传入类中

4. 资源注入: 层级对应的子场景注入,比如掉落物桥需要的掉落物实体场景注入到管理器中(前几篇已经演示过),与场景节点注入类似,不过此种注入目前不归DI管理,是直接通过 @export 注入到对应管理器中

DI 容器需要负责场景的加载流程,是游戏的总入口,对于本框架,加载顺序一般为

加载数据层数据 ==> 加载逻辑层数据,逻辑层依据数据层生成实体/物品层数据 ==> 建立逻辑层与桥接层,UI层的连接 ==> 调用逻辑层接口设置给予的世界参数并渲染地图

2. 基于DI核心容器设计思想设计的本项目实物

在详细介绍本项目的DI容器设计前,先给出本项目的DI容器框架图

对于本演示项目,DI容器使用场景节点注入持有 TileMapLayer , InputController , DropBridge , PlayerBridge ,UiBridge , GlobalCamera ,使用构造注入持有RenderBridge ,World ,其中 InputController 与 RenderBridge 同样也被DI容器使用引用注入分别注入 GlobalCamera 与 World。资源注入为各桥接层和UI层自己持有,本项目DI容器不负责注入。

本演示项信号连接方式分为死信号 和活信号。死信号是DI容器在启动时一键连接并后续不再更改的信号,活信号则是连接后会根据游戏当前情况动态换绑,比如玩家和UI层的信号绑定,DI容器会根据当前控制的玩家动态切换渲染的玩家数据为当前玩家

2. DI容器详细设计

本项目完整的DI容器启动游戏流程为

_tile_cell_load() 加载瓦片方块资源 ==>> _item_load() 加载物品方块集 ==>> 构建Godot的TileMapLayer图集并载入**==>>** 使用TileMapLayer构造渲染桥**==>>** _load_world()使用种子加载当前世界 ==>> 引用注入全局相机到 InputController**==>>** _debug_start()所有核心是否加载**==>>** World世界注入到渲染桥**==>>** _connect_signals()各个重要层级类建立信号连接 ==>> _render_map() 渲染地图 ==>> _set_world()设置世界重力等基本属性并重生玩家

需要注意的是:渲染桥的 World 注入是五层中唯一打破信号驱动的直持引用(渲染每帧高频读方块,走信号经容器中转有性能损失,详见桥接层篇)

对于加载顺序中的具体加载,如下

1. 资源加载

_tile_cell_load() 与 _item_load() 分别使用数据层的对外接口,流程为

调用瓦片属性与物品属性的 reset_counter() 刷新属性id计数器**==>>** 使用瓦片属性与物品属性的加载接口自动构建数据层属性映射表**==>>**调用瓦片实例与物品实体的加载接口自动构建实例的映射表

2. Godot图集加载与渲染桥构建

DI容器在构件中主要负责注入节点树中的TileMapLayer节点供渲染桥渲染,具体的构建流程如下

使用TileSetBuilder调用build_tileset()构建新的TileMapLayer图集 ==>> 将构建完成的图集载入场景树中的TileMapLayer节点**==>>** 将场景树中的TileMapLayer作为初始化参数构建渲染桥

3. 世界逻辑加载

_load_world() 使用种子作为初始化参数构建新的World世界类,后续创建流程由逻辑层出入口World类完成

4. 玩家控制加载与核心校验

DI容器在这一步将全局相机注入到 InputController中,并使用 _debug_start()校验各个层级核心是否加载,流程如下

注入全局相机 camera 到 player_input_control中 ==>> 校验渲染桥是否存在 ==>> 校验World世界,逻辑层出口是否存在

5. 核心层级类信号连接

DI容器需要在完成上述加载与校验后连接各个核心层级类的信号,会调用 _connect_signals() 执行连接,目前本演示项目的连接顺序如下(当前演示并未制作统一信号连接器,所以为手动连接)

  1. 玩家输入控制信号的 block_destroy_request 连接 world.try_destroy_tile_dir

  2. 玩家输入控制信号的 block_place_request 连接 world.try_place_tile

  3. 世界的 tile_cell_updated 连接 render_bridge.refresh_single_tile

  4. 世界的 gravity_updated 连接 _on_gravity_change(DI容器函数)

  5. 世界的 drop_added 连接 drop_bridge.on_drop_added

  6. 世界的 drop_removed 连接 drop_bridge.on_drop_removed

  7. 世界的 drop_apply_force 连接 drop_bridge.on_drop_apply_force

  8. 掉落物桥的 drop_position_updated 连接 world.receive_drop_updated_info

  9. 世界的 control_player_uid_changed 连接 _on_control_player_uid_change(DI容器函数)

  10. 世界的 spawn_player_data 连接 sync_player_entity(DI容器函数)

上述为死信号连接,一般用于层级核心类之间固定的信号绑定连接,下面还用动态信号连接,为当前控制的玩家与UI层的信号连接,因为UI层渲染的信息应为当前控制的玩家的信息。当前DI容器的活信号连接流程为

_on_control_player_uid_change收到世界发送的主玩家变更uid**==>>** 校验当前玩家信息,换绑全局相机跟随并调用 _set_ui() ==>> 调用当前缓存的旧玩家数据并执行解绑,同时换用从World中拿到的新的控制玩家数据,并重新安装UI层的数据**==>>**更新当前控制玩家的 uid 数据到DI

6. 地图渲染

完成上述操作后,DI容器将会进行地图渲染。需要注意的是,渲染桥没有渲染存储,只负责渲染,渲染存储在DI容器处(读者可以自行分离功能到渲染桥),而世界中存储的区块除了渲染的区块,还有未渲染的模拟区块。具体流程如下

DI容器调用 _render_map() ==>> 从World拿到全部玩家数据**==>>** 遍历玩家,将全部玩家指定范围内的区块全部加入渲染存储**==>>** 判断区块变化,调用 _render_around_player渲染区块**==>>**遍历使用 render_bridge.clear_chunk_render清除旧区块,遍历使用 render_bridge.render_chunk 渲染新区块

以上为一次渲染流程,DI容器运行的时候会将渲染放置于每帧 tick 中循环

7. 设置世界

完成以上所有流程后,设置世界的属性比如重力和重生玩家等,目前演示项目中调用 _set_world(),后调用 world.set_gravity 与 world.spawn_new_player 完成设置,需要注意的是,世界只是持有重力数据,完成更改后要发送信号并由DI完成实际更改(因为当前更改重力更改的是AREA_PARAM_GRAVITY,需要对场景根节点使用)

8. 完整本项目DI容器设计

本项目的完整DI容器设计如下

Puppet 复制代码
extends Node2D
## 主节点DI容器
## 场景节点注入
@export var tile_map_layer: TileMapLayer
@export var player_input_control: InputController
@export var drop_bridge: DropBridge
@export var player_bridge: PlayerBridge
@export var ui_bridge: UiBridge
@export var camera:GlobalCamera
## 核心类(渲染器,世界)
var render_bridge: RenderBridge
var world: World
var player_chunk_pos:Vector2i
var render_distance: int = 1   # 1 = 3x3 区域,2 = 5x5 区域
## 玩家所在区块
var is_render_ready: bool = true
## 保存当前已经绘制在TileMap上的区块
var rendered_chunks:Dictionary = {}
## 存储上一个绑定的玩家数据实例(用于安全解绑信号)
var bound_player_data:PlayerData = null
## 存储上一个主玩家uid(仅日志/调试)
var last_control_uid: int = -1

## 启动检查
func _ready() -> void:
	# 加载资源ca
	# 先加载属性再加载实例最后图集贴图
	_tile_cell_load()
	_item_load()
	TileSetBuilder.build_tileset()
	# 加载图集
	tile_map_layer.tile_set = TileSetBuilder.built_tileset
	# 创建世界
	render_bridge = RenderBridge.new(tile_map_layer)
	_load_world()
	DebugSystem.console_common_log("核心节点", "创建世界: %s" % world)
	# 注入节点给玩家控制检测(玩家并没有创建)
	player_input_control.camera = camera
	# 测试所需核心是否加载
	_debug_start()
	render_bridge.world = world
	if is_render_ready:
		_connect_signals()
		_render_map()
		_set_world()	
		
## 瓦片加载
func _tile_cell_load() -> void:
	TileCellAttribute.reset_counter()
	TileCellAttributes.load_mapping_attributes()
	TileCellInstances.load_mapping_instance()
	
## 物品加载
func _item_load() -> void:
	ItemAttribute.reset_counter()
	ItemAttributes.load_mapping_attributes()
	ItemInstances.load_mapping_instance()
	
## 创建并加入玩家
func sync_player_entity(new_player_data: PlayerData) -> void:
	# 同步玩家实体与数据:PlayerEntity视图节点持有,DI本身不持有
	player_bridge.on_add_player(new_player_data)
	DebugSystem.console_common_log("核心节点", "向玩家桥发送玩家实体创建信息: %s" % [new_player_data])

## 世界加载
func _load_world() -> void:
	world = World.new(54213)

## 世界属性设置
func _set_world() -> void:
	world.set_gravity(980.665)
	world.spawn_new_player()
	
## Ui 加载(这里ui信号连接不放在 _connect_signals, 方便统一更改, 活信号)
func _set_ui() -> void:
	# 优先用缓存的实例引用解绑,不再通过uid去查找
	if bound_player_data != null:
		bound_player_data.inventory_change.disconnect(_inventory_change_transfer)
		DebugSystem.console_common_log("核心节点", "解绑旧玩家物品信号,uid=%s" % bound_player_data.get_uid())
		bound_player_data = null
	
	var temp_player_data: PlayerData = world.get_player_data_by_uid(world.get_control_player_uid())
	if temp_player_data == null:
		DebugSystem.console_warn_log("核心节点", "获取控制玩家数据失败,UI无法初始化")
		return
	
	bound_player_data = temp_player_data
	# 初始化UI全量加载背包数据
	ui_bridge.setup(temp_player_data)
	# 绑定新玩家物品变更信号
	temp_player_data.inventory_change.connect(_inventory_change_transfer)
	# 更新记录uid
	last_control_uid = temp_player_data.get_uid()
	
## 连接信号 (死信号,一般是固定管理器连接)
func _connect_signals() -> void:
	# 方块破坏
	player_input_control.block_destroy_request.connect(world.try_destroy_tile_dir)
	# 方块放置
	player_input_control.block_place_request.connect(world.try_place_tile)	
	# 信号转发(更改通知渲染)
	world.tile_cell_updated.connect(render_bridge.refresh_single_tile)
	world.gravity_updated.connect(_on_gravity_change)	
	# 连接掉落物信号
	world.drop_added.connect(drop_bridge.on_drop_added)
	world.drop_removed.connect(drop_bridge.on_drop_removed)
	world.drop_apply_force.connect(drop_bridge.on_drop_apply_force)
	drop_bridge.drop_position_updated.connect(world.receive_drop_updated_info)
	# 玩家创建信号
	world.control_player_uid_changed.connect(_on_control_player_uid_change)
	world.spawn_player_data.connect(sync_player_entity)
	
## 物理帧循环
func _physics_process(delta: float) -> void:
	# 向世界提供主循环
	world.tick_world(delta)
	
## 渲染帧循环
func _process(delta: float) -> void:
	if !is_render_ready:
		return
	_render_map()
	
## 多人完整区块渲染
func _render_map() -> void:
	# 从世界实体存储器拿到全部玩家数据
	var player_dict = world.get_player_entity_dict()
	var target_chunks:Dictionary = {}
	
	# 遍历所有玩家,各自生成周围区块,自动合并去重
	for uid in player_dict:
		var p_data:PlayerData = player_dict[uid]
		var tile_pos:Vector2i = p_data.get_player_grid_pos()
		var cx:int = int( floor( float(tile_pos.x) / Chunk.CHUNK_SIZE_X ) )
		var cy:int = int( floor( float(tile_pos.y) / Chunk.CHUNK_SIZE_Y ) )
		var chunk_center = Vector2i(cx, cy)
		
		# 当前玩家render_distance范围区块
		for dx in range(-render_distance, render_distance + 1):
			for dy in range(-render_distance, render_distance + 1):
				var c_pos = Vector2i(chunk_center.x + dx, chunk_center.y + dy)
				target_chunks[c_pos] = true # 字典key自动去重
	
	# 和上一帧渲染区块对比,加载/卸载,原有逻辑原样保留
	var need_refresh:bool = false
	# 判断集合是否变化
	if target_chunks.size() != rendered_chunks.size():
		need_refresh = true
	else:
		for old_c in rendered_chunks:
			if !target_chunks.has(old_c):
				need_refresh = true
				break
	
	if need_refresh:
		_render_around_player(target_chunks)

# 重载,接收合并后的目标区块字典
func _render_around_player(target_chunks:Dictionary):
	# 清除旧区块
	for old_chunk in rendered_chunks:
		if !target_chunks.has(old_chunk):
			render_bridge.clear_chunk_render(old_chunk)
			rendered_chunks.erase(old_chunk)
	# 新增区块
	for new_chunk in target_chunks:
		if !rendered_chunks.has(new_chunk):
			render_bridge.render_chunk(new_chunk)
			rendered_chunks[new_chunk] = true
	
	# 维护区块输出
	# DebugSystem.console_common_log("测试世界", "当前维护区块: %s" % rendered_chunks)

## 检查各项参数
func _debug_start() -> void:
	# 检查渲染桥
	if render_bridge != null:
		DebugSystem.console_common_log("核心节点", "渲染搭桥存在")
	else:
		DebugSystem.console_error_log("核心节点", "渲染搭桥不存在")
		is_render_ready = false
	if world != null:
		DebugSystem.console_common_log("核心节点", "世界本体存在")
	else:
		DebugSystem.console_error_log("核心节点", "世界本体不存在")
		is_render_ready = false
	## 显示渲染状态
	DebugSystem.console_common_log("核心节点", "渲染状态: %s" % is_render_ready)
		
## ========================= 核心 DI 容器全局信号 ======================
## 重力数据变更 → 调用引擎物理服务器
func _on_gravity_change(new_g:float):
	var world_2d = get_world_2d()
	if world_2d == null:
		DebugSystem.console_warn_log("核心节点: 重力", "无法获取2D世界空间")
		return
	var space_rid = world_2d.space
	# 设置重力
	PhysicsServer2D.area_set_param(space_rid, PhysicsServer2D.AREA_PARAM_GRAVITY, new_g)
	PhysicsServer2D.area_set_param(space_rid, PhysicsServer2D.AREA_PARAM_GRAVITY_VECTOR, Vector2(0, 1))
	DebugSystem.console_common_log("核心节点: 重力", "设置重力: %s" % new_g)

## 当前控制玩家发生变化
func _on_control_player_uid_change(new_uid: int) -> void:
	DebugSystem.console_common_log("核心节点", "当前控制玩家 uid 更改为: %s" % new_uid)
	var target_entity = player_bridge.get_player_uid_entity_map().get(new_uid)
	if target_entity != null:
		camera.set_follow_case(target_entity)
	# 这里注意新的值由 ui 设置赋予给当前存储的主玩家数据
	_set_ui()

## 物品栏(背包更改信号中转)
func _inventory_change_transfer(
	change_pos: Vector2i, 
	new_item_name: String, 
	new_item_num: int,
	inv_type: InventoryBase.InventoryType
) -> void:
	DebugSystem.console_common_log("核心节点", "执行物品栏数据与对应UI转发")
	## 物品栏传给ui渲染对应信息
	ui_bridge.update_inventory_slot(change_pos, new_item_name, new_item_num, inv_type)

3. 后续以及补充

当前项目主场景即是DI容器的代替,没有独立的DI容器类,想要在游戏中更改World以及渲染桥等的实现需要创建独立容器类并更改注入方式,读者可以自行尝试。

当前DI容器的注入并不完全集中在DI中,比如桥接层的子场景,同时加载顺序全部编码在 _ready中,为早期测试。读者可以更改为独立的DI容器并启用JSON规定注入顺序以及注入位置,脱离本演示测试写法

当前DI容器生命周期没有管理器,大量死信号方便测试,仅留必要的控制玩家动态管理信号,读者可以自行增加生命周期DI子管理器,负责信号生命周期的管理

当前DI容器脚本功能集中,因为早期测试无拆分,以及当前的世界创建等种子固定,无游戏主页面,读者都可以更改扩展。

当前文章为本架构文的最后一篇附属文,这里返回主架构文

针对Godot-GridGame(网格游戏)的DI注入+RefCounted数据驱动信号流架构设计的研究(全程开源)-CSDN博客

相关推荐
Dawson Zhu3 小时前
【AI编译器入门⑥】端到端编译流程实战:ONNX → TVM → TensorRT全链路
人工智能·语言模型·架构·aigc·agi
深圳市益普科技有限公司3 小时前
半导体SPC工具怎么选?从控制图算法到落地架构的对比
大数据·人工智能·架构
每天一道题3 小时前
微服务为什么会变慢?从 QPS、P99 到 Trace 与性能瓶颈
微服务·云原生·架构
xunberg3 小时前
【Agent 生产化手记-02】Agent 怎么向人提问
人工智能·算法·架构
利威尔xu4 小时前
Go IM 源码实战(11):token 只是个 HASH,多端互踢的顺序错了就踢错人
后端·微服务·面试·架构·golang
充值内卷5 小时前
把 Visio 和 Axure 塞进一个 1.5 MB 的 exe:Visioxure 的架构与取舍
架构·axure
QianhangQianping5 小时前
一块大屏的数据在浏览器里怎么变成画面:背景、子屏、前景三层结构
前端·低代码·架构·编辑器·数据可视化·大屏可视化
每天一道题6 小时前
微服务为什么会发生级联故障?从 Timeout、Retry 到熔断、限流与 Backpressure
微服务·云原生·架构
ESDWAN7 小时前
SD-WAN 是什么?工作原理、适用场景与选型要点
开发语言·网络·数据库·架构·php