前言
本文是 针对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容器设计如下
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博客