Skip to content

第 9 章:UI / EUI 与 CoreGui ​

安全定位 EUI 节点、更新 HUD,并处理 UI 就绪和连接清理。

开始前与本章路线 ​

  • 准备:先跑通第 8 章。UI 编辑器创建可见 EUITextLabel label_point、label_time 和 EUIButton btn_refresh,保存 UI。
  • 安装范围:先做文本最小实验;完整 HUD 替换 client/hud.lua,并配套第 8 章服务端和本章事件定义。
  • 本次要看到:就绪后文本显示 0 与等待开始;按钮刷新快照;缺失节点超时有明确提示。

停止试玩后再换入口;这些章节是能力实验,第 22 章才提供统一的六文件项目。每次只改变一个条件,并保留本次输出用于对照。

你会学到什么 ​

  • UI 代码为什么主要写在 client。
  • 如何通过 PlayerGui.EuiManager 取到 EUI 根节点,并用稳定 Name 查找编辑器 UI 节点。
  • 如何更新文本、响应按钮点击、接收服务端 RemoteEvent。
  • 动态创建 EUI 节点时坐标、图片 URI、节点命名的基本规则。
  • 为什么 UI 模块要用 Init(),并同步写 testspec。

UI 的职责边界 ​

事情放在哪一端原因
获取 EUI 节点、修改文本、显示/隐藏面板clientUI 属于本地表现
监听按钮点击、键盘输入client输入来自本地玩家
分数、奖励、存档、是否允许重置server权威状态不能相信客户端
把权威结果通知 UIserver → client RemoteEvent第 8 章的通信模式

一句话:client 负责显示和请求,server 负责判定和回传结果。

从 UI 编辑器到 Lua ​

建议在 UI 编辑器中给关键节点设置稳定 Name,并使用前缀:

节点类型推荐前缀示例
EUIButtonbtn_btn_refresh
EUITextLabellabel_label_point
EUIImageimg_img_coin
EUILayout / 面板panel_panel_hud
全屏遮罩mask_mask_pause
LoadingBarbar_bar_hp
ProgressTimerring_ring_cd

保存 UI 后,再通过 PlayerGui.EuiManager 取到 EUI 根节点,用根节点的 FindFirstChild(name, true) 按稳定 Name 递归查找真实节点。本系列约定:脚本一律按节点 Name 查找,不依赖导出编号,这样改 UI、换导出顺序都不会影响代码。

获取 UI 节点 ​

运行端:client
文件:client/hud.lua

示例 1|完整模块 client/hud.lua;最小文本实验。 本次演示端:client。

lua
-- @runtime client
local Players = game:GetService("Players")

local Hud = {}

local function GetEuiManager()
    if not Players then return nil end
    local localPlayer = Players.LocalPlayer
    if not localPlayer or not localPlayer.PlayerGui then
        return nil
    end
    return localPlayer.PlayerGui.EuiManager
end

-- 按稳定 Name 递归查找节点:root 是 EuiManager:GetRootNode(),true 表示递归子树
local function GetNode(name)
    local euiManager = GetEuiManager()
    if not euiManager then return nil end
    local rootNode = euiManager:GetRootNode()
    if not rootNode then return nil end
    return rootNode:FindFirstChild(name, true)
end

function Hud.Init()
    local labelPoint = GetNode("label_point")
    if labelPoint and labelPoint:IsA("EUITextLabel") then
        labelPoint.Text = "当前得分:0"
        return true
    else
        print("[SE Lua Guide][WARN] 没有找到 EUITextLabel: label_point")
        return false
    end
end

return Hud

运行端:client
文件:client/main.lua

示例 2|配套入口 client/main.lua;先安装本节 hud.lua。 本次演示端:client。

lua
-- @runtime client
local Hud = require("client.hud")
local Task = game:GetService("Task")
Task:Spawn(function()
    for _ = 1, 20 do
        if Hud.Init() then return end
        Task:Wait(0.25)
    end
    print("[SE Lua Guide][WARN] HUD 初始化超时,请检查 PlayerGui、节点 Name 和类型后重新试玩")
end)

本系列的 UI 模块统一采用 Init() / Destroy() 生命周期:require 时只返回 table,不在文件顶层绑定事件或创建复杂界面。Init() 返回 false 表示当前 PlayerGui 或节点尚未就绪;调用方可以记录诊断并在合适的公开生命周期点重试,不能靠固定延迟猜加载完成。

RemoteEvent 驱动 HUD ​

假设第 8 章的 common/remote_events.lua 中定义了:

示例 3|完整模块 common/remote_events.lua;替换同名协议文件。 本次演示端:server。

lua
-- @runtime server
return {
    RequestHudSnapshot = RemoteEvent.New("RequestHudSnapshot"),
    HudSnapshot = RemoteEvent.New("HudSnapshot"),
}

把 Hud.Init() 扩展为响应服务端消息和按钮点击:

示例 4|完整模块 client/hud.lua;整体替换最小版,依赖本节协议及第 8 章服务端。 本次演示端:client。

lua
-- @runtime client
local Players = game:GetService("Players")
local World = game:GetService("World")
local RunService = game:GetService("RunService")
local RemoteEvents = require("common.remote_events")

local Hud = {}
local labelPoint
local labelTime
local btnRefresh
local gameEndTime = -1
local nextUpdateTime = 0
local connections = {}

local function GetEuiManager()
    if not Players then return nil end
    local localPlayer = Players.LocalPlayer
    if not localPlayer or not localPlayer.PlayerGui then return nil end
    return localPlayer.PlayerGui.EuiManager
end

local function GetNode(name)
    local euiManager = GetEuiManager()
    if not euiManager then return nil end
    local rootNode = euiManager:GetRootNode()
    if not rootNode then return nil end
    return rootNode:FindFirstChild(name, true)
end

local function SetLabelText(node, nodeName, text)
    if not node or not node:IsA("EUITextLabel") then
        print("[SE Lua Guide][WARN] 没有找到 EUITextLabel:", nodeName)
        return false
    end
    node.Text = text
    return true
end

local function AddConnection(connection)
    connections[#connections + 1] = connection
end

local function RequestHudSnapshot(reason)
    RemoteEvents.RequestHudSnapshot:FireServer({
        kind = "hud_snapshot",
        reason = reason,
    })
end

local function BindEvents()
    AddConnection(RemoteEvents.HudSnapshot.OnClientEvent:Connect(function(snapshot)
        if type(snapshot) ~= "table"
            or type(snapshot.score) ~= "number"
            or type(snapshot.phase) ~= "string"
            or type(snapshot.endTime) ~= "number"
            or type(snapshot.serverTime) ~= "number" then
            print("[SE Lua Guide][WARN] 收到非法 HUD 快照")
            return
        end

        SetLabelText(labelPoint, "label_point", "当前得分:" .. tostring(snapshot.score))
        if snapshot.phase == "playing" then
            gameEndTime = snapshot.endTime
        else
            gameEndTime = -1
            SetLabelText(labelTime, "label_time", "等待开始")
        end
    end))

    if btnRefresh and btnRefresh:IsA("EUIButton") then
        AddConnection(btnRefresh.OnClicked:Connect(function()
            -- 按钮只请求刷新权威快照,不直接修改分数或回合状态。
            RequestHudSnapshot("button_refresh")
        end))
    else
        print("[SE Lua Guide][WARN] 没有找到 EUIButton: btn_refresh")
    end

    AddConnection(RunService.Heartbeat:Connect(function()
        if gameEndTime < 0 or not labelTime then return end
        local now = World:GetServerTime()
        if now < nextUpdateTime then return end
        nextUpdateTime = now + 1
        SetLabelText(
            labelTime,
            "label_time",
            "倒计时:" .. math.max(0, math.ceil(gameEndTime - now))
        )
    end))
end

function Hud.Destroy()
    for _, connection in ipairs(connections) do
        connection:Disconnect()
    end
    connections = {}
    labelPoint = nil
    labelTime = nil
    btnRefresh = nil
    gameEndTime = -1
    nextUpdateTime = 0
end

function Hud.Init()
    Hud.Destroy()  -- 重复初始化前先清理,避免重复监听。

    if not Players or not World or not RunService then
        print("[SE Lua Guide][ERROR] HUD 所需服务获取失败")
        return false
    end

    labelPoint = GetNode("label_point")
    labelTime = GetNode("label_time")
    btnRefresh = GetNode("btn_refresh")

    local pointReady = SetLabelText(labelPoint, "label_point", "当前得分:0")
    local timeReady = SetLabelText(labelTime, "label_time", "等待开始")
    if not pointReady or not timeReady then
        Hud.Destroy()
        return false
    end

    BindEvents()
    RequestHudSnapshot("hud_ready")
    return true
end

return Hud

注意:按钮点击只发送只读 HUD 快照请求,不直接修改分数或回合。任何会改变权威状态的按钮,都必须另行设计服务端业务校验。完整模块只保留一个 Hud.Destroy();重复定义同名方法会让后面的定义覆盖前面的连接清理实现。

动态创建 UI 的最小规则 ​

多数新手先用 UI 编辑器创建节点即可。需要运行时创建节点时,通过 World:CreateUnit 创建对应 EUI 节点类型(EUILayout、EUITextLabel、EUIImage、EUIButton 等),并把它挂到 EUI 根节点下:

示例 5|独立实验;整体替换 client/main.lua。 本次演示端:client。

lua
-- @runtime client
local Players = game:GetService("Players")
local World = game:GetService("World")

if not Players or not World then
    print("[SE Lua Guide][ERROR] Players 或 World 服务获取失败")
    return
end

local localPlayer = Players.LocalPlayer
if not localPlayer then
    print("[SE Lua Guide][WARN] LocalPlayer 尚未就绪")
    return
end

local playerGui = localPlayer.PlayerGui
if not playerGui then
    print("[SE Lua Guide][WARN] PlayerGui 尚未就绪")
    return
end

local euiManager = playerGui.EuiManager
if not euiManager then
    print("[SE Lua Guide][WARN] 本地 EUIManager 尚未就绪")
    return
end

local rootNode = euiManager:GetRootNode()
if not rootNode then
    print("[SE Lua Guide][WARN] EUI 根节点尚未就绪")
    return
end

local resolution = euiManager:GetDeviceResolution()
local sw, sh = resolution.x, resolution.y

-- 创建一个面板:Parent 指向 EUI 根节点,使其进入节点树
local panel = World:CreateUnit("EUILayout", {
    Name = "panel_runtime_tip",
    Parent = rootNode,
    Position = Vector2.New(sw / 2, sh / 2),
    Size = Vector2.New(500, 120),
    Visible = true,
})

if not panel or not panel:IsA("EUILayout") then
    print("[SE Lua Guide][ERROR] EUI 面板创建失败")
    return
end

-- 在面板下创建文本节点:Parent 指向 panel
local label = World:CreateUnit("EUITextLabel", {
    Name = "label_runtime_tip",
    Parent = panel,
    Position = Vector2.New(250, 60),
    Size = Vector2.New(460, 80),
    Text = "运行时提示",
})

if not label or not label:IsA("EUITextLabel") then
    panel:Destroy()
    print("[SE Lua Guide][ERROR] EUI 文本节点创建失败")
end

关键点:

  • 动态创建节点统一走 World:CreateUnit(节点类型, {...}),第一个参数是 EUI 节点类型名(EUILayout/EUITextLabel/EUIImage/EUIButton 等)。
  • 客户端 EUIManager 入口是 Players.LocalPlayer.PlayerGui.EuiManager,不是 game:GetService("EUIManager"),也不是 StarterGui.EuiManager。
  • 新节点的 Parent 必须指向 EUI 根节点或其子节点,否则不会进入节点树、不会被渲染。
  • Anchor 的公开默认值是 (0.5, 0.5);Position 与 Anchor 共同决定节点相对父节点的对齐位置。不同布局下先在编辑器预览,不在代码里假设未经公开说明的屏幕原点语义。
  • 获取屏幕尺寸用 euiManager:GetDeviceResolution(),不要硬编码 1920/1080。
  • Position、Size 用 Vector2.New(...) 或 Vector2(...),不要用 table。
  • 图片属性如 Image、NormalImage、PressImage 是字符串资源 URI(如 "official://image/10066"),不是裸数字。
  • 读取公开属性直接访问 node.Position、node.Size、node.Visible、node.Opacity,不要凭命名习惯发明未公开 getter。

CoreGui 与 StarterGui ​

StarterGui 不是客户端获取 EUIManager 的入口。它主要用于 CoreGui 显隐等系统 UI 控制:

兼容性提示:部分运行包可能在 client/main.lua 入口首帧尚未准备好 CoreGui。只有在目标版本实际复现首帧调用不生效时,才使用 Task:Delay(1, function() ... end) 延后重试,并记录运行包版本;固定延迟 1 秒不是 Meta 保证,也不是所有项目的必需步骤。

示例 6|独立实验;整体替换 client/main.lua。 本次演示端:client。

lua
-- @runtime client
local StarterGui = game:GetService("StarterGui")
if StarterGui then
    -- All 的一个布尔值不能保存各组件原来不同的开关状态。
    -- 按当前公开枚举逐项保存;API 增加组件时同步更新这份列表。
    local guiTypes = {
        Enums.CoreGuiType.Joystick, Enums.CoreGuiType.JumpButton,
        Enums.CoreGuiType.RollButton, Enums.CoreGuiType.RushButton,
        Enums.CoreGuiType.LiftButton, Enums.CoreGuiType.BattleButton,
        Enums.CoreGuiType.ResetButton, Enums.CoreGuiType.CommunicateButton,
        Enums.CoreGuiType.EmojiButton, Enums.CoreGuiType.PlayerList,
        Enums.CoreGuiType.Health, Enums.CoreGuiType.Backpack,
        Enums.CoreGuiType.Chat, Enums.CoreGuiType.TopbarContainer,
        Enums.CoreGuiType.EscapeMenu, Enums.CoreGuiType.ControlBar,
        Enums.CoreGuiType.EmotesMenu,
    }
    local originalStates = {}
    for _, guiType in ipairs(guiTypes) do
        originalStates[guiType] = StarterGui:GetCoreGuiEnabled(guiType)
    end
    for _, guiType in ipairs(guiTypes) do
        StarterGui:SetCoreGuiEnabled(guiType, false)
    end
    -- 功能结束时逐项恢复,不把原本禁用的组件误开启或覆盖混合状态。
    for _, guiType in ipairs(guiTypes) do
        StarterGui:SetCoreGuiEnabled(guiType, originalStates[guiType])
    end
else
    print("[SE Lua Guide][ERROR] StarterGui 服务获取失败")
end

普通玩法 HUD 优先通过 PlayerGui.EuiManager 操作。服务端代理 UI 和 CoreGui 是进阶主题,第一轮只需要知道不要混用入口。

testspec:让 UI 可测试 ​

只要章节或项目中新增一个可交互 UI 模块,就同步写测试规格:

文件:test/testspec_hud.md

markdown
## 一、模块信息

| 字段 | 值 |
|---|---|
| 模块路径 | `client.hud` |
| 入口方法 | `Hud.Init()` |
| 挂载根节点 | UI 编辑器导出的 HUD 节点 |

## 二、节点清单

| Name | 类型 | 父节点 Name | 初始可见性 | 说明 |
|---|---|---|---|---|
| `label_point` | EUITextLabel | `(root)` | true | 显示当前得分 |
| `label_time` | EUITextLabel | `(root)` | true | 显示倒计时 |
| `btn_refresh` | EUIButton | `(root)` | true | 请求刷新权威 HUD 快照 |

## 三、交互逻辑

### 功能组:快照刷新请求

| 字段 | 内容 |
|---|---|
| 触发方式 | 点击 |
| 触发节点 | `btn_refresh` |
| 效果描述 | 客户端发送只读 `RequestHudSnapshot` 请求,服务端回传权威 HUD 快照 |

## 四、关键常量

| 常量名 | 值 | 用途 |
|---|---|---|
| HUD_REFRESH_INTERVAL | 1 | 倒计时文本每秒刷新 |

## 五、设计意图

HUD 用于显示分数和倒计时,并把刷新按钮点击转换为只读的服务端快照请求。

testspec 不替代测试,但它能让后续 QA 明确“要找哪些节点、点哪里、期待什么变化”。

常见错误 ​

错误:混用本地 HUD 和服务端代理 UI 的职责 ​

本章选择在客户端获取并更新本地 HUD,服务端通过 RemoteEvent 下发权威状态;这是本教程的工程分工,不是 EUI 类型只能在客户端调用的 Meta 限制。服务端代理 UI 属于进阶能力,需按具体公开获取链、成员契约和目标运行包单独验证,不能把 Players.LocalPlayer 这条客户端入口搬到服务端。

错误:UI 节点没设稳定 Name ​

关键节点必须设置稳定 Name,并尽量使用 btn_、label_、panel_ 等前缀。否则脚本和测试都很难定位。

错误:把图片资源写成裸数字 ​

把 NormalImage 写成裸数字 10066 是错误的。EUI 图片属性需要字符串资源 URI,例如 NormalImage = "official://image/10066"。

错误:隐藏节点只设 Opacity = 0 ​

Opacity 是不透明度,0 只是透明,节点仍可能参与计算。需要隐藏时用 node:SetVisible(false) 或 node.Visible = false。

错误:require 时直接创建 UI ​

client/hud.lua 顶层不要直接创建节点或绑定事件。导出 Init(),由 client/main.lua 显式调用。

错误:文件末尾又写了一个同名 Destroy ​

Lua 后定义的同名函数会覆盖前面的函数。如果已经写好负责断开 Connection 的 Hud.Destroy(),不要在文件末尾再留一个空壳版本;否则 Hud.Init() 虽然调用了 Destroy,旧监听却不会真正清理,重复初始化后一次消息可能更新多次。

练习任务 ​

  1. 在 UI 编辑器中创建 label_point、label_time、btn_refresh,并确保它们的 Name 与代码一致。保存 UI。
  2. 编写 client/hud.lua,在 Hud.Init() 中获取节点并设置初始文本。
  3. 接入第 8 章的 RemoteEvents.HudSnapshot,让服务端回传快照后更新分数和倒计时。
  4. 为 HUD 写一份 test/testspec_hud.md。

练习参考与复核 ​

先完成练习,再展开对照;没有运行条件的项目填写“未验证”和缺少的条件。

参考思路与应观察的结果

先核对 Name、类型、Visible 和层级。第 8 章默认 waiting,因此不会自动出现运行中倒计时;在 server 设置 phase="playing" 和未来 endTime 后请求快照才会倒数。重复 Hud.Init 后一次点击只发一次请求。

  • [ ] 我已完成本章选定实验,记录了上面的预期现象。
  • [ ] 我能故意改变一个前置条件,解释失败发生在哪一步,并在恢复后重跑。

本章验收标准 ​

  • [ ] 我知道 UI 代码主要放在 client。
  • [ ] 我能通过 PlayerGui.EuiManager:GetRootNode() + FindFirstChild(name, true) 获取编辑器节点。
  • [ ] 我知道 EUI 坐标、图片 URI、属性直读的关键规则。
  • [ ] 我能用 RemoteEvent 驱动 UI 更新,而不是让客户端决定权威状态。
  • [ ] 我知道可交互 UI 需要同步 testspec。

本章产物 ​

  • client/hud.lua:通过 Players.LocalPlayer.PlayerGui.EuiManager 获取 UI 节点并更新文本。
  • test/testspec_hud.md:记录 HUD 节点、交互和截图验收标准。
  • 一份截图记录:证明分数和倒计时可读、无遮挡、低分辨率下不裁切。

这些产物会在第 22 章变成 Capstone HUD。真实发布前至少补一张 HUD 截图,并保存到 evidence/screenshots/。

本章 API 对照 ​

下一章预告 ​

UI 能显示了。下一章我们将学习 data/ 目录:把资源 URI、道具配置和 UI 节点映射集中管理,让代码更容易维护。