白皮书讲「为什么」,这里回答「凭什么」。以下全部案例均取自 996M2 客户端真实源码(602 个 Lua 脚本 + 227 个 GUILayout 界面文件),每一条都标注了文件路径与代码原文——理论不是空中楼阁,代码就是证据。
客户端启动第一件事:把 13 个 cocos2d-x C++ 引擎控制器直接挂到 global 表。Lua 层没有自己造轮子,全部能力来自 C++ 暴露的底层接口——「寄生式」的具体形态:
global = global or {}
local M = global
M.frameworkCore = "mmoclient"
-- ************************** init cpp ctl **************************
M.Director = cc.Director:getInstance()
M.TextureCache = M.Director:getTextureCache()
M.Scheduler = M.Director:getScheduler()
M.OpenGLView = M.Director:getOpenGLView()
M.EventDispatcher = M.Director:getEventDispatcher()
M.ActionManager = M.Director:getActionManager()
M.FileUtilCtl = cc.FileUtils:getInstance()
M.WritablePath = M.FileUtilCtl:getWritablePath()
M.Platform = cc.Application:getInstance():getTargetPlatform()
M.GLProgramCache = cc.GLProgramCache:getInstance()
M.SpriteFrameCache = cc.SpriteFrameCache:getInstance()
M.ScriptHandlerMgr = ScriptHandlerMgr:getInstance()
「离开游戏世界」时,客户端直接把整个 GUI 模块从 Lua 虚拟机中卸载置空;再次进入世界时重新 require 加载。不需要重启、不需要重编译——TXT 引擎做不到,这是架构代差:
function SLBridge:onEnterWorld()
...
self:ReRegisterEvent()
require("GUILayout/GUIInit") -- 进入世界:动态加载全部界面模块
end
function SLBridge:onLeaveWorld()
SLBridge:onLUAEvent(LUA_EVENT_LEAVE_WORLD)
package.loaded["GUILayout/GUIInit"] = nil -- 离开世界:整模块卸载,从内存清除
GUI.WinLayers = {} -- GUI 界面管理
GUI.Mediators = {} -- GUI 界面管理
SLBridge.LUAEvent = {}
SLHandlerEvent.Events = {}
package.loaded[...] = nil 是 Lua 热更新的标准手法——模块级「阅后即焚」,秒级重载。同理还可以实现服务端下发新版界面脚本后立即生效。寄生是双向的:Lua 调 C++ 接口,C++ 引擎也在固定时机回调 Lua。227 个界面文件每一个都实现 main() 函数,注释明确写着「窗口被打开时引擎自动调用」。以「物品自动使用弹窗」为例:
AutoUsePop = {} -- 创建命名空间表(本文件所有逻辑都挂在它下面)
local WinID = UIConst.LAYERID.AutoUsePopGUI -- 从层级配置表取本窗口层级 ID
function AutoUsePop.main() -- 窗口被打开时引擎自动调用(C++ 引擎回调入口)
...
end
客户端网络协议定义表开篇即声明消息头加密规格——密文头 32 字节、明文头 24 字节,差值即加密填充。全部 555 个消息号(CS/SC 双向)在同一张表定义:
local M =
{
NETMSG_HEADER_ENCODE_SIZE = 32, -- in bytes, 32 bytes after encrypted.
NETMSG_HEADER_DECODE_SIZE = 24, -- in bytes, 24 bytes without encryption
-- CS = Client -> Server, SC = Server -> Client
MSG_SC_NETWORK_DISCONNECTED = 0x1FFFFFFE, --断线消息
MSG_CS_HEART_BEAT_KEEP = 60000, -- 客户端10s发个心跳,由于C++不好修改,Lua层处理
MSG_SC_SERVER_FORBIDDEN = 60004, -- 服务器踢人 - 维护
MSG_SC_OTHERPLACE_LOGIN = 57, -- 异地登录推送消息号
MSG_SC_GAME_CONFIG = 3, -- 游戏配置(服务端下发配置!)
MSG_SC_GAME_CONFIG——连「游戏配置」都是服务端消息号下发的,配置权不在客户端手里,验证「前端后置」。对 227 个界面文件的网络调用全量统计:界面层共发起 265 次 Request* 调用(使用物品、创建队伍、上架拍卖、发起交易……),而没有任何一处掉落概率、伤害公式、货币扣除的数值裁决——客户端收集输入 → 发送请求 → 等服务端消息 → 渲染结果:
客户端的渲染统一由事件桥驱动:SLBridge:onLUAEvent(name, data) 是引擎/服务端消息进入 Lua 界面层的唯一入口,全客户端共 337 处事件响应调用。例如隐身 BUFF 状态变化,客户端只做一件事——广播事件让界面刷新:
-- GUI/SL.lua:事件桥定义(界面层唯一事件入口)
function SL:onLUAEvent(name, data)
SLBridge:onLUAEvent(name, data)
end
-- buff/BuffEntitySneak.lua:隐身 BUFF 只是"通知界面刷新",不做逻辑裁决
SLBridge:onLUAEvent(LUA_EVENT_MAIN_NEAR_REFRESH, {actorID = self._actorID})
所有界面的层级 ID 只在 UIConst.LAYERID 一张表里定义(每个 ID 带中文注释),全客户端 123 个文件直接引用,无一硬编码:
UIConst.LAYERID =
{
MoveEventGUI = "MoveEventGUI", -- 准星事件
SightBeadGUI = "SightBeadGUI", -- 准星
MiniMapGUI = "MiniMapGUI", -- 小地图
MiniMapOtherGUI = "MiniMapOtherGUI", -- 其他小地图 (非当前地图)
GoldBoxGUI = "GoldBoxGUI", -- 宝箱打开页面
TreasureBoxGUI = "TreasureBoxGUI", ...
-- 任何界面打开自己:只查表,不写死字符串
local WinID = UIConst.LAYERID.AutoUsePopGUI
经对 config 目录全量静态挖掘,客户端共有 13 组公用配置表、4072 条常量,每组都是全局单例命名空间,任何模块按名即取:
典型消费方式(取值即用,没有第二份拷贝):
-- actor/gameActorPlayer.lua
local walkOnly = SL:GetValue("GAME_DATA","gameOption_WalkOnly") == 1
local horseHair= SL:GetValue("GAME_DATA","horse_hair") == 1
-- 装备穿戴位置映射:GUIDefine 单源表
[GUIDefine.EquipPosUI.Equip_Type_ArmRingL] = GUIDefine.EquipPosUI.Equip_Type_ArmRingR,
传统框架协议两端各定义一份、版本对不齐就炸服。这里 CS/SC 全部 555 个消息号在同一张 Lua 表中定义,带方向前缀与中文注释,收发两端共用同一份定义源:
MSG_CS_LOGIN_SERVER = 20, -- 登录(客户端→服务器)
MSG_SC_SYSTEM_INFO = 100, -- 系统信息(服务器→客户端)
MSG_CS_CREATE_ROLE = 101, -- 请求创建角色, uid + "/" + name + "/1/" + job + "/" + sex;
MSG_SC_RESPOINSE_ROLE_INFO = 520, -- 消息内容:附加消息格式:用/来间隔开属性,显示角色选择或者创建面板
整个界面层的启动装配只用了十行 require,顺序即依赖:常量 → 工具 → 核心命名空间 → 主循环 → 事件桥 → 公用函数库。这就是「前端结构配置」的最小完备样例:
require("GUI/SLDefine") -- ① 事件/常量定义
require("GUILayout/UIConst") -- ② UI 常量(层级表)
require("GUILayout/UIOperator") -- ③ UI 操作工具
require("GUILayout/GUIDefine") -- ④ GUI 层级定义表
require("GUI/SL") -- ⑤ SL 核心命名空间(事件桥/取值)
require("GUI/GUI") -- ⑥ GUI 窗口管理器
require("GUI/SLMain") -- ⑦ 主入口(场景根节点)
require("GUI/SLHandlerEvent") -- ⑧ 事件处理器
require("GUILayout/GUIFunction") -- ⑨ 公用函数库(102 个函数)
require("GUILayout/GUIEquipFunction") -- ⑩ 装备公用逻辑
GUIDefine.lua(1000 行)+ GUIDefineEx.lua(380 行)把双击间隔、资源路径、装备位、层级合并规则等全部做成带注释的声明式配置。界面属于哪一层、点击判定多长、资源放哪个目录——查这一张表即可:
GUIDefine = {}
-- 双击时间
GUIDefine.CLICK_DOUBLE_TIME = 0.3
-- pc tips 延迟时间
GUIDefine.PC_TIPS_DELAY_TIME = 0.05
-- private 目录
GUIDefine.PATH_RES_PRIVATE = "res/private/"
-- 引导配置目录
GUIDefine.PATH_GUIDE_CONFIG = "GUILayout/guide/GuideConfig"
GUILayout 按业务域组织 227 个界面文件为 48 个子目录,目录名即功能域,不需要任何文档解释结构:
进入世界时,代码构建场景组件链:引擎场景挂两个根容器(B 层/F 层),全部 141 个 GUI 层级最终都挂在这两条链上:
GUI._sceneRootNodeB = cc.Node:create()
rootB:addChild(GUI._sceneRootNodeB) -- B 层根节点挂到场景
GUI._sceneRootNodeB:retain()
GUI._sceneRootNodeF = cc.Node:create()
rootF:addChild(GUI._sceneRootNodeF) -- F 层根节点挂到场景
GUI._sceneRootNodeF:retain()
界面创建同样走链式 API(取层级 ID → 创建窗口 → 存容器):
-- CodeDOMMainUI.lua:创建窗口/层级,容器存到变量 parent(层级ID来自 UIConst.LAYERID)
GUI.SetLayerOpenParam(UIConst.LAYERID.XXX, data.orderParam)
链式调用在客户端俯拾皆是——一行代码穿越三层引擎对象。以下是 grep 出的真实原文:
-- 三连链:导演 → 事件分发器 → 注册触摸监听
cc.Director:getInstance():getEventDispatcher():addEventListenerWithSceneGraphPriority(listener, widget)
-- 两连链:导演 → 内容缩放系数
dp_pointSize = pointSize * cc.Director:getInstance():getContentScaleFactor()
-- 两连链:导演 → 动作管理器
local actionManager = cc.Director:getInstance():getActionManager()
-- 全局缓存:global.lua 启动即把链头缓存好,后续免链
M.Director = cc.Director:getInstance()
M.EventDispatcher = M.Director:getEventDispatcher()
业务界面按「框架 + 面板」组织成组件树:GuildFrame(行会界面框架)作为容器,主界面、成员管理、创建行会、邀请列表、聊天、行会战等子面板挂载其下,链式展开:
同样模式:SocialFrame(社交主框架)→ 好友/邮件/组队/关系;SetFrame(设置主框架)→ 12 个设置面板。主界面 CodeDOMMainUI 更是纯代码构建整棵 DOM 组件树。
装备评分、属性展示排序、耐久度格式化、职业名转换……凡是多个界面都要用的逻辑,全部沉到这一张 Base 函数库。业务界面只管调用,不重复实现:
GUIFunction:GetJobNameByID(jobID) -- 职业名转换
GUIFunction:CalculateAttPower(attList, ...) -- 战力计算
GUIFunction:GetEquipPower(item, param, isHero) -- 装备评分
GUIFunction:CompareEquipOnBody(equipData, from) -- 装备对比
GUIFunction:GetDuraStr(dura, maxdura, one) -- 耐久度格式化
GUIFunction:GetAttDataShow(att, stars, tipsShow) -- 属性文案展示
GUIFunction:CheckEquipExcludePos(item) -- 穿戴位互斥校验
...共 102 个
提示弹窗、气泡、描述悬浮、选择列表、红点——这些「每个游戏都要做一百遍」的共性界面被抽成通用组件,任何业务界面直接复用:
「共性归 Base」的镜像操作是「特性归组件」:移动端/PC 端的差异化实现不污染 Base 层,而是为需要特化的界面单独派生 _win32 变体,同屏共存、按平台选择加载:
代码中按平台直接分流(真实调用):
-- actor/gameActorDropItem.lua:同一段逻辑,按平台取不同参数
local itemScale = SL:GetValue("GAME_DATA","itemGroundSacle")
or (global.isWinPlayMode and 0.5 or 0.8)
控件层同样是「Base」:Button 控件被 77 个界面文件直接声明使用(全端 144 个文件用到各类控件),按钮的按下态、缩放动画、音效、事件绑定全部由 Base 层封装,业务界面只写一行声明:
核心白皮书案例库 · 全部案例取自 996M2 客户端真实源码(602 脚本 + 227 界面文件静态分析)· 幂尔框架学习中心
延伸阅读:底层接口总表 · API 接口知识库 · LAYOUT 详解 · COCOS 引擎接口