Skip to content

第 9 章:UI / EUI 与 CoreGui

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

你会学到什么

  • 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

lua
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

lua
local Hud = require("client.hud")
if not Hud.Init() then
    print("[SE Lua Guide][WARN] HUD 尚未就绪,请检查本地 PlayerGui 和节点 Name")
end

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

RemoteEvent 驱动 HUD

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

lua
return {
    RequestHudSnapshot = RemoteEvent.New("RequestHudSnapshot"),
    HudSnapshot = RemoteEvent.New("HudSnapshot"),
}

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

lua
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
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 节点类型(EUILayoutEUITextLabelEUIImageEUIButton 等),并把它挂到 EUI 根节点下:

lua
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
    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)PositionAnchor 共同决定节点相对父节点的对齐位置。不同布局下先在编辑器预览,不在代码里假设未经公开说明的屏幕原点语义。
  • 获取屏幕尺寸用 euiManager:GetDeviceResolution(),不要硬编码 1920/1080。
  • PositionSizeVector2.New(...)Vector2(...),不要用 table。
  • 图片属性如 ImageNormalImagePressImage 是字符串资源 URI(如 "official://image/10066"),不是裸数字。
  • 读取公开属性直接访问 node.Positionnode.Sizenode.Visiblenode.Opacity,不要凭命名习惯发明未公开 getter。

CoreGui 与 StarterGui

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

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

lua
local StarterGui = game:GetService("StarterGui")
if StarterGui then
    local wasEnabled = StarterGui:GetCoreGuiEnabled(Enums.CoreGuiType.All)
    StarterGui:SetCoreGuiEnabled(Enums.CoreGuiType.All, false)
    -- 功能结束时恢复进入前的状态,避免永久影响系统 UI。
    StarterGui:SetCoreGuiEnabled(Enums.CoreGuiType.All, wasEnabled)
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 明确“要找哪些节点、点哪里、期待什么变化”。

常见错误

错误:在服务端操作 EUI 节点

EUI 节点访问属于客户端。服务端只负责通过 RemoteEvent 通知客户端显示什么。

错误: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_pointlabel_timebtn_refresh,并确保它们的 Name 与代码一致。保存 UI。
  2. 编写 client/hud.lua,在 Hud.Init() 中获取节点并设置初始文本。
  3. 接入第 8 章的 RemoteEvents.HudSnapshot,让服务端回传快照后更新分数和倒计时。
  4. 为 HUD 写一份 test/testspec_hud.md

本章验收标准

  • [ ] 我知道 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 节点映射集中管理,让代码更容易维护。