前言
本文是 针对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() 执行连接,目前本演示项目的连接顺序如下(当前演示并未制作统一信号连接器,所以为手动连接)
玩家输入控制信号的 block_destroy_request 连接 world.try_destroy_tile_dir
玩家输入控制信号的 block_place_request 连接 world.try_place_tile
世界的 tile_cell_updated 连接 render_bridge.refresh_single_tile
世界的 gravity_updated 连接 _on_gravity_change(DI容器函数)
世界的 drop_added 连接 drop_bridge.on_drop_added
世界的 drop_removed 连接 drop_bridge.on_drop_removed
世界的 drop_apply_force 连接 drop_bridge.on_drop_apply_force
掉落物桥的 drop_position_updated 连接 world.receive_drop_updated_info
世界的 control_player_uid_changed 连接 _on_control_player_uid_change(DI容器函数)
世界的 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博客