
前言本文是 针对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 的主题盒子(需要注意的是修改主题前需要深拷贝防止修改全局主题资源)早期开发把物品栏背景与物品渲染在同一个格子节点内读者可改为分离渲染。这里先给出工具中替换主题的工具类UiUtilextends 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_slot4. 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博客