前言
本文是 针对Godot-GridGame(网格游戏)架构设计的研究(全程开源)-CSDN博客 中UI层的研究,阅读前请先阅读该文章的详细设计,同时本文将会默认读者已经阅读过架构的数据层,逻辑层,实体/物品层,桥接层,这几个层级可以在主文章中跳转链接获取。
1. UI层总览
1. UI 层设计思想
前一层桥接层负责将数据接入到世界(TileMapLayer,物理体),提供对于世界本身的渲染。而对于将数据接上屏幕的层级本架架构称为UI层。同理,UI层也不会持有世界给予的UI层需要的数据,UI层只负责将数据渲染在屏幕上。同时UI层面上的比如玩家物品类拖动,UI层只发送请求而不是直接调用函数修改,修改完成后UI层应收到信号并渲染新数据
需要注意的是,UI层的大类虽然也有UiBridge这样的桥命名,但是实际上提供UI负责的是Control类节点,同时也不符合桥接层的定义,所以仍然属于UI层。(在本框架中,桥命名是转化的意思,而桥接层是一个完整的层级)
本框架UI层只负责渲染,渲染谁的数据由 World类 中选定的当前控制玩家决定,而DI核心容器则执行动态信号绑定,即UI层监听的数据信号由DI核心容器在检测到控制玩家发生变更的时候DI容器执行信号换绑,这样UI层完全就不管渲染谁的数据,而是交予逻辑层处理数据的发送了。
2. 基于UI层设计思想设计的本项目实物
在详细介绍UI层之前,先给出UI层的框架图
在已经完成的项目中,UI层最终出口到一个 CanvasLayer 节点上,其中UiBridge类作为UI层的接入和出口类绑定在CanvasLayer上。在出口类之下,还有UiInventory(UI物品栏类)、UiSingleSlot(单个物品栏格子类)。由于是带有场景的节点,所以演示项目采用将实体化场景转化为对应代码类存储于UiBridge的方式来控制UI场景的根脚本。
对于UI层每一个类的具体使用流程,将会放在下文对应类中讲解(子个体会在对应的大类中说明)。
2. 详细设计说明
1. 物品栏UI设计
1. UiInventory类
UiInventory在本项目中是物品栏容器,负责渲染完整的物品栏。UiInventory附着在Control节点上,同时持有Godot的GridContainer负责排版物品栏。为了方便物品栏的管理,以及分离代码职责,UiInventory带有物品栏节点的场景UiSingleSlot。
初始化时,使用 setup 函数设置物品栏的大小(列 + 行)等设置,UiInventroy同时也会在初始化时使用单个物品栏的初始化设置单个物品栏的外观。对于 UiInventroy,原理为
1. UiBridge实体化UiInventory并获得其根节点脚本,使用setup函数初始化(init没删干净,也会初始化一遍,读者可以自行删掉)
2. UiInventory 调用 _render_back_pack(),第一次通过遍历循环按照参数创建空的物品栏
3. UiBridge被调用setup,使用 UiInventory 的 setup_by_player_data() 遍历循环设置每一个单个格子,完成渲染
对于后续的物品栏更新,由DI核心容器转发World的信号后,调用 UiBridge后由Uibridge发出刷新的命令,UiInventory按照需求刷新物品栏即可。本项目的物品栏为创建一次性刷新,后只更新单个格子,对于单个格子的细节,会在对应子类UiSingleSlot中说明。以下是UiInventory的设计
extends Control ## 背包渲染,提供完整的背包渲染 class_name UiInventory ## 常量,默认的单个格子的间隔与尺寸 const DEFAULT_SINGLE_SLOT_GAP_X: int = 10 const DEFAULT_SINGLE_SLOT_GAP_Y: int = 10 const DEFAULT_SINGLE_SLOT_SIZE_X: int = 40 const DEFAULT_SINGLE_SLOT_SIZE_Y: int = 40 ## 背包自动排版节点以及各类外部节点 @export var grid_back_pack_container: GridContainer @export var background_panel: Panel @export var single_slot: PackedScene ## 内置变量,实际使用的单个格子的间隔与尺寸 var BackPackSize: Vector2i = Vector2i(1, 1) var single_slot_gap_x: int = DEFAULT_SINGLE_SLOT_GAP_X var single_slot_gap_y: int = DEFAULT_SINGLE_SLOT_GAP_Y var single_slot_size_x: int = DEFAULT_SINGLE_SLOT_SIZE_X var single_slot_size_y: int = DEFAULT_SINGLE_SLOT_SIZE_Y ## 存放单个物品栏的映射表 (Vector2i(col, row) -> UiSingleSlot) var slots_dict: Dictionary = {} ## 这是 _init 初始化,和setup功能一致时序不同,不过用的是setup,可以删掉 func _init( init_size: Vector2i = Vector2i(8, 3), slot_gap_x: int = DEFAULT_SINGLE_SLOT_GAP_X, slot_gap_y: int = DEFAULT_SINGLE_SLOT_GAP_Y, ) -> void: BackPackSize = init_size single_slot_gap_x = slot_gap_x single_slot_gap_y = slot_gap_y ## 初始化物品栏 func setup( init_size: Vector2i = Vector2i(8, 3), slot_gap_x: int = DEFAULT_SINGLE_SLOT_GAP_X, slot_gap_y: int = DEFAULT_SINGLE_SLOT_GAP_Y, ) -> void: BackPackSize = init_size single_slot_gap_x = slot_gap_x single_slot_gap_y = slot_gap_y _render_back_pack() ## 渲染背包 func _render_back_pack() -> void: # 检查尺寸是否在合理范围 if BackPackSize.x <=0 or BackPackSize.y <=0: DebugSystem.console_warn_log("物品栏UI", "物品栏行列不能小于等于0") return back_pack_clear() DebugSystem.console_common_log("物品栏UI", "物品栏UI渲染") # 背景板尺寸计算 var grid_inner_w = BackPackSize.x * single_slot_size_x + (BackPackSize.x - 1) * single_slot_gap_x var grid_inner_h = BackPackSize.y * single_slot_size_y + (BackPackSize.y - 1) * single_slot_gap_y var bg_total_w = grid_inner_w + single_slot_gap_x * 2 var bg_total_h = grid_inner_h + single_slot_gap_y * 2 background_panel.custom_minimum_size = Vector2i(bg_total_w, bg_total_h) # 贴图部分单独处理,不再用expand_margin乱算 # 背景板设置 var style_box = StyleBoxTexture.new() style_box.texture = CanvasTexture.new() # style_box.texture = preload("res://xxx.png") # 后续替换成背包底板贴图 background_panel.add_theme_stylebox_override("panel", style_box) # ========= GridContainer核心配置 ========= grid_back_pack_container.position = Vector2(single_slot_gap_x, single_slot_gap_y) grid_back_pack_container.columns = BackPackSize.x # 设置格子间隙(容器自带,代替手动算gap)注意 gridContain会自动算入宽度 grid_back_pack_container.add_theme_constant_override("h_separation", single_slot_gap_x) grid_back_pack_container.add_theme_constant_override("v_separation", single_slot_gap_y) # 循环创建格子,【不再手动计算坐标】 for row in range(BackPackSize.y): for column in range(BackPackSize.x): var new_slot = single_slot.instantiate() var new_slot_script = new_slot as UiSingleSlot # 单个Slot设置最小尺寸,GridContainer会尊重这个大小 new_slot_script.setup(Vector2i(single_slot_size_x, single_slot_size_y)) slots_dict[Vector2i(column, row)] = new_slot grid_back_pack_container.add_child(new_slot) # UiInventory.gd,_render_back_pack函数最后 # bg_total_w、bg_total_h就是之前算好的背包底板总宽高 custom_minimum_size = Vector2i(bg_total_w, bg_total_h) # DebugSystem.console_common_log("物品栏UI", "当前物品栏格子映射表: %s" % slots_dict) ## 增量更新单个格子( 物品数据变更只调用这个,不重建背包) func set_slot_item(slot_pos: Vector2i, item_name: String, item_count: int) -> void: if not slots_dict.has(slot_pos): DebugSystem.console_warn_log("物品栏UI", "不存在该坐标格子 %s" % slot_pos) return var target_slot: UiSingleSlot = slots_dict[slot_pos] target_slot.change_current_render_item(item_name, item_count) ## 清空全部格子数据(不销毁节点,只清空渲染内容) func clear_all_slot_items() -> void: for slot in slots_dict.values(): slot.change_current_render_item("", 0) ## 清除背包 func back_pack_clear() -> void: DebugSystem.console_common_log("物品栏UI", "清除物品栏所有格子节点") for slot in slots_dict.values(): slot.queue_free() slots_dict.clear() ## 根据玩家数据+InventoryType,刷新当前UiInventory的全部UI格子 ## 不持有PlayerData长期引用,仅读取一次数据用于全量刷新 func setup_by_player_data(player_data:PlayerData, inv_type:InventoryBase.InventoryType) -> void: var target_inv:InventoryBase # 检查格子是快捷栏还是背包,决定了从玩家数据的哪个部分获取数据 match inv_type: InventoryBase.InventoryType.BACKPACK: target_inv = player_data.get_player_back_pack() InventoryBase.InventoryType.QUICK_BAR: target_inv = player_data.get_player_inventory_quick_bar() _: DebugSystem.console_warn_log("物品栏UI", "不支持的物品栏类型 %s" % str(inv_type)) return # 使用目标物品栏尺寸重建UI容器 setup(target_inv.inventory_size) # 遍历槽位,全量填充UI var size_x = target_inv.inventory_size.x var size_y = target_inv.inventory_size.y for row in range(size_y): for col in range(size_x): var grid_pos = Vector2i(col, row) var slot_data = target_inv.get_slot_by_grid(col, row) var item = slot_data.get_item() var item_name:String var item_num:int if item == null: item_name = "" item_num = 0 else: item_name = item.get_item_instance().get_item_name() item_num = item.get_current_stack_num() set_slot_item(grid_pos, item_name, item_num)2.UiSingleSlot类
UiSingleSlot类是单个物品栏,需要渲染实际的物品,同样不持有物品Item本身,而是获取物品名字后直接寻找贴图,加载数量。UiSingleSlot同样附着在Control节点上,这样就可以在作为 GridContainer 的子节点的时候被自动排序。
UiSingleSlot也要提供setup函数,不过本演示中setup在这里只负责尺寸更改,而渲染更改的流程为
1. UiInventory调用UiSingleSlot提供的change_current_render_item(item_name: String, new_item_num: int)
2. UiSingleSlot 调用自身 _refresh_item_texture() 寻找对应 item_name 的贴图并修改当前堆叠数量
为了减少运算开销,渲染采用全局静态缓存,作为静态变量存储在 UiSingleSlot 中,加载贴图优先寻找静态映射表已经加载的贴图。UiSingleSlot的贴图节点使用 Panel 节点作为贴图显示,更改贴图需要更改 Panel 的主题盒子(需要注意的是,修改主题前需要深拷贝,防止修改全局主题资源)
早期开发把物品栏背景与物品渲染在同一个格子节点内,读者可改为分离渲染。这里先给出工具中替换主题的工具类UiUtil
extends RefCounted ## 各种 UI 工具 class_name UiUtil ## 更换Panel的ThemeOverride背景贴图 static func set_panel_texture(target_panel: Panel, tex: Texture2D): # 读取当前panel的stylebox,复制一份(防止全局资源污染) var style_box = target_panel.get_theme_stylebox("panel").duplicate() as StyleBoxTexture if style_box == null: # 如果原本不是StyleBoxTexture,新建一个 style_box = StyleBoxTexture.new() # 设置贴图 style_box.texture = tex # 可选:九宫格切片,做边框 style_box.texture_margin_left = 4 style_box.texture_margin_right = 4 style_box.texture_margin_top = 4 style_box.texture_margin_bottom = 4 # 覆盖ThemeOverride,只作用这个Panel实例 target_panel.add_theme_stylebox_override("panel", style_box)以下是UiSingleSlot的设计
extends Control ## 单个物品栏格子,提供渲染单个格子 class_name UiSingleSlot ## 常量 const ITEM_ENTITY_SCALE: float = 1.3 const DEFAULT_SLOT_SIZE: Vector2 = Vector2(40, 40) ## 背景贴图等资源(背景,物品,数值显示) @export var background_panel: Panel @export var itemEntity_panel: Panel @export var item_num_label: Label ## 内置参量 var slot_size: Vector2 = DEFAULT_SLOT_SIZE var render_item_name: String = "" var render_num: int = 0 ## 贴图缓存,避免重复加载 static var texture_cache: Dictionary = {} ## 初始化 func _init() -> void: # 只设默认值,不做业务初始化 slot_size = DEFAULT_SLOT_SIZE custom_minimum_size = slot_size ## 默认出现即渲染一次 func _ready() -> void: render_slot() ## 外部更改尺寸 func setup(new_size: Vector2 = DEFAULT_SLOT_SIZE) -> void: slot_size = new_size custom_minimum_size = new_size render_slot() # setup之后,刷新当前物品 _refresh_item_texture() ## 渲染基础布局(大小、物品图标居中,不再处理Label位置!) func render_slot() -> void: background_panel.size = slot_size itemEntity_panel.size = slot_size / ITEM_ENTITY_SCALE # 物品图标居中 var center_pos = (slot_size - itemEntity_panel.size) / 2.0 itemEntity_panel.position = center_pos # 保证容器布局读取到正确最小尺寸 custom_minimum_size = slot_size ## 外部接口:切换物品 func change_current_render_item(item_name: String, new_item_num: int): render_item_name = item_name render_num = new_item_num _refresh_item_texture() ## 修改当前贴图 + 数字 func _refresh_item_texture() -> void: if render_item_name == "" or render_num <= 0: _clear_render_item() return # 优先读取缓存 if texture_cache.has(render_item_name): var tex = texture_cache[render_item_name] UiUtil.set_panel_texture(itemEntity_panel, tex) else: var texture_path = "%s%s.png" % [ResourcePath.item_texture_path, render_item_name] var item_texture: Texture2D = load(texture_path) if item_texture == null: DebugSystem.console_warn_log("物品栏", "找不到贴图资源:%s" % texture_path) _clear_render_item() return texture_cache[render_item_name] = item_texture UiUtil.set_panel_texture(itemEntity_panel, item_texture) # 只需要设置文本,位置交给锚点自动管理 item_num_label.text = String.num_int64(render_num) ## 清除贴图、清空文字 func _clear_render_item() -> void: itemEntity_panel.add_theme_stylebox_override("panel", StyleBoxTexture.new()) item_num_label.text = "" ## GridContainer自动布局不需要这个(早期手动布局残留,读者可以删除) func set_new_position(new_position: Vector2) -> void: position = new_position2. UI层出入口(UiBridge类)
UI层出入口在本项目中就是UiBridge类(附着在CanvasLayer节点上),主要负责创建所有需要被创建的UI并挂在自己的节点下,同时UiBridge持有创建的所有的UI类,DI容器转发来的所有数据和请求都会经过UiBridge转发到对应的UI层。
当前UiBridge本身也负责回转请求比如物品栏方块替换,不过本项目在写本篇文章的时候还没实装,所以这里只会介绍拾取等逻辑层发送的请求产生的出入口流程
UiBridge应当将大部分UI操作的接口集中在自己的类中,这样外部调用的时候只需要使用UiBridge提供的接口即可而不用接触真正的UI,同时对于UiBridge的setup函数则应该统一更改所有持有的UI类的渲染数据。
需要注意的是,UiBridge也不持有数据本身,数据可以完全引用传入,但是仅获取数据,然后丢弃,这样不会造成多余的引用和职责分裂。
注意完整的UiBridge应该带有请求信号比如物品栏修改等信号,但是本项目没有实装所以演示代码中没有信号,以下是UiBridge的设计
extends CanvasLayer ## Ui桥,负责渲染对应玩家Ui ## 同时负责所有子 ui 的信号传输,统一从 Ui 桥发出数据更新请求与收到渲染请求 ## 所有 ui 仅负责渲染和反馈玩家操作,实际渲染由数据决定 class_name UiBridge ## 各个 Ui 组件 @export var ui_inventory: PackedScene @export var ui_control: Control @export var ui_vbox_container: VBoxContainer ## 存储对应实例化节点的核心代码(背包栏,快捷栏) var back_pack_script: UiInventory var inventory_quick_bar_script: UiInventory ## ui 实例存储 var ui_back_pack_instance var ui_inventory_quick_bar_instance ## 对应 ui 所处位置 var ui_position: Vector2 = Vector2(10, 10) ## 初始化(场景内初始化) func _ready() -> void: ui_vbox_container.position = ui_position # 背包和快捷栏之间的垂直间隔 ui_vbox_container.add_theme_constant_override("separation", 10) _back_pack_initialize() _inventory_quick_bar_initialize() ## 安装玩家数据到ui,重新渲染,传入完整玩家数据,但不引用仅取用数据 func setup(player_data: PlayerData) -> void: if player_data == null: DebugSystem.console_warn_log("Ui桥", "安装玩家数据为空") return if back_pack_script == null or inventory_quick_bar_script == null: DebugSystem.console_warn_log("Ui桥", "UI实例还未初始化,无法setup玩家数据") return var player_uid = player_data.get_uid() ## 获得物品栏数据并安装 ( size 拿回的类型是 Vector2i ) DebugSystem.console_common_log("Ui桥", "设置背包,当前玩家 uid: %s" % player_uid) back_pack_script.setup_by_player_data(player_data, InventoryBase.InventoryType.BACKPACK) DebugSystem.console_common_log("Ui桥", "设置快捷栏,当前玩家 uid: %s" % player_uid) inventory_quick_bar_script.setup_by_player_data(player_data, InventoryBase.InventoryType.QUICK_BAR) ## 背包初始化 func _back_pack_initialize() -> void: ui_back_pack_instance = ui_inventory.instantiate() # 将实体化的场景的根脚本拿出并存储方便修改 back_pack_script = ui_back_pack_instance as UiInventory back_pack_script.setup(Vector2i(8, 3)) DebugSystem.console_common_log("Ui桥", "创建背包Ui: %s" % ui_back_pack_instance) ui_back_pack_instance.visible = true ui_vbox_container.add_child(ui_back_pack_instance) ## 快捷栏初始化 func _inventory_quick_bar_initialize() -> void: ui_inventory_quick_bar_instance = ui_inventory.instantiate() inventory_quick_bar_script = ui_inventory_quick_bar_instance as UiInventory inventory_quick_bar_script.setup(Vector2i(8, 1)) DebugSystem.console_common_log("Ui桥", "创建快捷栏Ui: %s" % ui_inventory_quick_bar_instance) ui_inventory_quick_bar_instance.visible = true ui_vbox_container.add_child(ui_inventory_quick_bar_instance) ## 清理所有ui func destroy_all_inventory_ui(): if ui_back_pack_instance != null: ui_back_pack_instance.queue_free() ui_back_pack_instance = null back_pack_script = null if ui_inventory_quick_bar_instance != null: ui_inventory_quick_bar_instance.queue_free() ui_inventory_quick_bar_instance = null inventory_quick_bar_script = null ## 更新物品栏 func update_inventory_slot(pos:Vector2i, item_name:String, count:int, inv_type: InventoryBase.InventoryType) -> void: DebugSystem.console_common_log("Ui桥", "更新数据,当前更新类型: %s" % inv_type) match inv_type: # 更新主背包单个格子 InventoryBase.InventoryType.BACKPACK: if back_pack_script == null: DebugSystem.console_warn_log("Ui桥", "背包实例为空,无法更新") return back_pack_script.set_slot_item(pos, item_name, count) # 更新快捷栏单个格子 InventoryBase.InventoryType.QUICK_BAR: if inventory_quick_bar_script == null: DebugSystem.console_warn_log("Ui桥", "快捷栏实例为空,无法更新") return inventory_quick_bar_script.set_slot_item(pos, item_name, count) _: DebugSystem.console_warn_log("Ui桥", "快捷栏类型未识别")3. 完整流程说明
对于上述的UI层,一个完整的启动UI渲染流程如下
1. DI核心容器调用自己的 _set_ui(),然后调用 ui_bridge.setup(player_data: PlayerData)
2. UiBridge 调用 back_pack_script(背包脚本)与inventory_quick_bar_script(快捷栏脚本)的
setup_by_player_data() 安装玩家数据(两个脚本均为 UiInventory)
3. 背包和快捷栏遍历创建对应物品栏
一个完整的后续物品更改流程如下
1.InventoryBase.try_add_item/swap/clear 发送 inventory_changed(pos, item_name, num, inv_type)
2.PlayerData.inventory_change(转发器,背包+快捷栏两路合并)[信号]
3.DI核心容器调用 _inventory_change_transfer,然后调用 UiBridge.update_inventory_slot
4. match inv_type(物品栏类型) 分发,调用对应UiInventory.set_slot_item(pos, name, count)
5.UiInventory使用 slots_dict[pos].change_current_render_item,更新单个物品栏渲染
6.UiSingleSlot中,贴图缓存查询/加载 然后调用 UiUtil.set_panel_texture设置贴图,更改 Label 显示数值
4. 后续以及补充
本篇是UI层篇,也是5层中的最后一篇文章,这里返回总架构篇
针对Godot-GridGame(网格游戏)的DI注入+RefCounted数据驱动信号流架构设计的研究(全程开源)-CSDN博客