无尽模式。
手机ui适配
一些奇怪的bug
下载 App
《我真没让他们建城啊!》开发者日志
—— 画了,但看不见 09 的标题是"先接上线,再让人看见"。今天发现,接上线之后还有一层: 画了,但画不出来。
一个篝火放下去,数据里存在、坐标正确、扣了神力——地图上却一片空白。 一块石碑立起来,程序每一步都执行了——但它是纯黑无描边的,在深色地形上直接糊进去。 一个顶栏每帧都在绘制,日志里 uiRects=21 从没掉过——但它永远是 nan 尺寸,画在了屏幕外。
还有最狠的一条:一个 nil 值,毁掉了整层 UI。
日期: 2026-10-18 当前进度: UI 全面重构(底部 Hotbar + 统一缩放旋钮);修复读档崩溃与"崩溃后 UI 全错位";修复法则卡选卡崩溃;修复篝火/石碑不可见;图鉴/编年史/玩法说明改为滚动;面板居中与层级修正
写在前面:今天的问题,几乎都是"看不见" 今天玩家反馈很集中,三张截图砸过来,说的其实是三件事:
玩家原话 我一开始以为的 实际是
1 手机游戏这么多 ui 挡住,我怎么玩 UI 布局问题 整个 UI 架构要重做 2 这个 ui 什么情况,你到底能不能修 还是 UI 布局 一个 nil 炸了世界层,把 UI 一起带走了 3 放置了篝火地图上看不见 放置逻辑坏了 绘制函数从没被调用过 第 2 条和第 3 条,各自都是一个"看不见":
第 2 条:错误让本该可见的东西不可见了 第 3 条:本该可见的东西,从来没人叫它画 和 09 一样,我又一次把"做了"和"看得见"混为一谈了。
一、UI 挡住画面 —— 从"三块常驻面板"到"一条底部栏" 玩家第一条反馈很直白:
平时应该是收纳起来,使用的话再弹出,而且要正常。 我建议是在底部弹出一行道具,像我的世界一样。
看截图,左侧堆了三块常驻面板:工具栏(13 个工具)、知识面板(6 门知识)、复合知识行,再加上左下的法则摘要、底部的快捷键提示行。手机横屏的逻辑高度只有 390,这些面板加起来早就把画面吃光了。
改法是把它们全部收进一条贴底居中的 hotbar:
[⏸速度] [🍎资源] [🐺危险] [🔥诱因] [⚡天罚] [♻回收] [🗿知识] 每个分类是一格,点开才在它上方弹出该类工具。这样常驻高度与工具总数解耦——全解锁也只有 7 格,不会因为工具变多就撑爆屏幕。
后面又按玩家要求迭代了两轮:
“不需要再收纳” → 删掉收纳态(原来点 ✕ 会缩成两个小把手) “点击应该还是出现在上面” → 分类面板从右侧改回 hotbar 正上方 “这两个面板不能居中吗” → 图鉴/编年史从左右停靠改为居中(两个同时开时并排) “设置应该在暂停的上层” → 调整绘制顺序,暂停先画、设置后画 最后一条值得记一笔:单通道渲染下,后画即上层。原来 Help.Frame() 里暂停菜单是最后画的,注释还写着"它是最外层的模态"——但设置是从暂停菜单里点开的子面板,理应盖在它上面。注释写的是当时的直觉,不是现在的结构。
二、一个 nil,毁掉整层 UI ★ 这是今天最重要的一条。
玩家发来第二张截图:底部 hotbar 整个消失、法则卡跑到左上角、顶栏也没了。
我先看日志,一眼看到三行:
[渲染错误] world: [string "Apostasy"]:361: attempt to index a nil value (upvalue 'misery') [UI错误] 组件=situation err=[string "Apostasy"]:361: attempt to index a nil value (upvalue 'misery') [HUD] 布局无效(w=nan,h=nan),本帧跳过 HUD 渲染 第一行是根因:Apostasy 的 misery(民怨状态表)是 nil。为什么是 nil?因为这一局是读档继续的——而存档代码写的是:
lua
复制 -- SaveGame.lua(错误写法) Apostasy.misery = ap.misery or {} misery 是 Apostasy.lua 里的 local 变量:
lua
复制 ---@type table<integer, table>|nil local misery = nil Apostasy.misery = ... 写的是模块表上的字段,跟那个 local 一点关系都没有。读档后 local 依然是 nil,渲染层每帧 misery[s.id] 就炸。
这个坑在 Systems 里已经踩过一次,注释还留着: -- 时间轴变量是 Systems 的 local,必须由它自己导出 同一个坑,换了个模块,又踩了一次。
2.1 但真正可怕的是第二层 修完 misery,我盯着"hotbar 为什么消失"想了一会儿。
hotbar 明明在画——日志里 uiRects=21 从没掉过。它画了,但看不见。
看渲染帧的结构:
lua
复制 -- Render.lua / FrameWorld nvgSave(vg) nvgTranslate(vg, sw/2 - Camera.x * Camera.zoom, ...) -- 相机变换 nvgScale(vg, Camera.zoom, Camera.zoom) DrawWorld() -- ← 这里炸了 ... nvgRestore(vg) -- ← 永远没执行到 DrawWorld() 炸在 Apostasy:361,pcall 捕获后跳到 FrameUI——但 nvgRestore 被跳过了。
于是相机的平移和缩放留在了 NanoVG 上下文里。世界层之后画的所有 UI,全部被套上了那套相机变换——被缩放到别的位置去了。
一个渲染错误,变成了整层 UI 的错位。
2.2 修法:保证 nvgRestore 必达 修 misery 是治标,治本是不让局部错误污染全局状态:
lua
复制 nvgSave(vg) nvgTranslate(vg, ...) nvgScale(vg, Camera.zoom, Camera.zoom) -- 世界绘制包 pcall:任何一次渲染炸都不能跳过下面的 nvgRestore local okWorld, worldErr = pcall(function() DrawWorld() ... 工具预览、立碑预览 ... end) nvgRestore(vg) -- ← 无论成败,必达 if not okWorld then error(worldErr, 0) -- 状态已恢复,再把异常抛给外层限频打日志 end 原则很简单:画内容可以失败,但状态必须还原。 一帧世界内容缺失,玩家几乎看不出来;一整层 UI 错位,玩家立刻就知道出事了。
顺带把 misery/tower 改成模块加载时就建表(不再是 nil), 并给 Apostasy 补了 ExportState / RestoreState 两个接口—— 让存档真正能进到 local 里去。
三、顶栏消失 —— 一个画在屏幕外的 UI 同一个截图里,顶栏(时间/人口/聚落/神力/纪元)也不见了。
日志只有一行:
[HUD] 布局无效(w=nan,h=nan),本帧跳过 HUD 渲染 顶栏当时是用 urhox-libs/UI 库画的。该库的 Yoga 布局基于 graphics.width / graphics.height:
lua
复制 -- urhox-libs/UI/Core/UI.lua local baseWidth = screenWidth_ / scale_ local baseHeight = screenHeight_ / scale_ YGNodeCalculateLayout(root_.node, baseWidth, baseHeight, YGDirectionLTR) 启动首帧 graphics.width 是 nan(窗口还没量出来)→ baseWidth 是 nan → Yoga 算出 nan 布局 → 而 dirty 标志已经清掉了,这个 nan 会逐帧固化。
顶栏因此永久画在屏幕外。
UI 库是只读的,改不了它内部对 graphics.width 的依赖。所以我把顶栏和右上按钮群改回 Render.lua 自绘——和 hotbar 用同一套安全区坐标和命中系统。
UIHud.lua 从"持有控件树"退化成"只负责建一个 NanoVG 上下文":
lua
复制 -- 顶栏 / 右上按钮群 / 底部速度条 均已迁回 Render.lua 自绘: -- · UI 库 Yoga 布局在 graphics.width 首帧为 nan 时固化 root=(0,0,nan,nan) -- 本模块因此只负责:建立唯一 NanoVG 上下文 + 暴露给世界渲染使用。 一条经验:UI 库帮你解决布局,但也把"布局什么时候算错"这件事藏起来了。 当它的输入不可靠时(首帧 nan),错误会静默固化,比直接报错难查得多。
四、法则卡选不了 —— 存档把函数丢了 还有一条崩溃:
Execute Lua function failed: [string "Systems"]:168: attempt to call a nil value (field 'apply') [string "Systems"]:168: in field 'ChooseRule' [string "Render"]:3205: in field 'cb' 法则卡每张都带一个 apply 闭包:
lua
复制 { id = "war_era", icon = "战", name = "铁血之世", apply = function() W.mods.warEra = true end }, 而存档时经过 Clean() 序列化——它对 function 类型直接返回 nil:
lua
复制 local function Clean(v, depth) ... elseif tv == "table" then for k, val in pairs(v) do local cv = Clean(val, depth + 1) if cv ~= nil then out[k] = cv end -- ← 函数被静默丢弃 end 于是存档里的卡是"无 apply 的纯数据"。玩家在法则卡弹出时存了档,读档后再选卡 → card.apply() → 崩。
修法:读档时按 id 把卡绑回卡池里的真身:
lua
复制 for _, c in ipairs(s.pendingRule) do local real = type(c) == "table" and c.id and Systems.RuleCardById(c.id) or nil if not real then okAll = false break end restored[#restored + 1] = real end 外加两道防御:ChooseRule 里判空 card.apply;空表也归 nil(空表是 truthy,会永久挡住新卡发放)。
存档序列化丢函数,是 Lua 项目的常见坑。 凡是"数据里带闭包"的结构,存档时都要想一遍:它恢复得回来吗?
五、篝火看不见 —— 第二次踩同一个坑 玩家第三条反馈:
放置了篝火地图上看不见
我先怀疑放置逻辑坏了,写了个 headless 探针:
[probe] StructureSpacing=3 CELL=24 判定半径=72 [probe] campfire cost=25 [probe] 第1次 Apply campfire => ok=true err=nil 结构数 1 -> 2 [probe] 第2次(同点) => ok=false err=同类诱因距离太近 [probe] 第3次(远5格) => ok=true err=nil 结构数 3 放置逻辑完全正常。 再核对坐标:
[probe] campfire #1 cell=(42,48) world=(1020.0,1164.0) paidCost=nil ← 开局自带 [probe] campfire #2 cell=(48,48) world=(1164.0,1164.0) paidCost=25 ← 玩家放的 数据侧一切正常。那就是渲染侧。
我去 DrawStructure(结构渲染的总分派函数)里看它的分支:
market / graveyard / camp / totem / shrine / bell / granary / workshop / monolith ... 没有 campfire。
再看:DrawCampfire 函数在 996 行定义得好好的,但全工程搜索它的调用——零次。
bash
复制 $ grep -c "DrawCampfire(" scripts/Render.lua 1 # 只有定义那一行 09 里我写的第一条是"定义了,但没接线"。 今天篝火是同一类,只是这次连函数都写完了。
修法就一行:
lua
复制 elseif s.kind == "campfire" then DrawCampfire(s) 顺手排查了其它绘制函数(DrawStall/DrawTent/DrawHouse/DrawWolf/DrawBabels),引用数都 ≥2(定义 + 调用)——只有篝火是 1,是唯一漏网的。
09 我说要"让机器替我记得"——列了"构建期校验"进下一步。 今天证明,这个校验里应该加一条: 每个 local function Draw* 都必须有调用点,否则构建失败。
六、石碑"太小" —— 其实是纯黑无描边 玩家说"石碑怎么这么小"。我去看绘制代码:
lua
复制 local w = 11 Px(x - w / 2, y - h, w, h, { 12, 12, 16 }) -- 纯黑,无描边 碑高 22、宽 11,而格子是 24 像素。更要命的是颜色 {12, 12, 16}——在深色地形上,一块纯黑无描边的方块,根本看不出来。
玩家说的"小",其实是"看不见"。
修法三件事:
项 原来 现在 碑高 22(低于小人) 30(高于小人,醒目) 碑宽 11 16 描边 无 该知识的强调色描边 描边一举两得:既让碑从地形里"跳"出来,又让玩家一眼认出是哪门知识的碑(矿物=灰、农耕=绿、建筑=褐)。
玩家描述症状用的词,不一定是根因。 说"太小"的,可能是"看不见";说"放不下"的,可能是"选中的根本不是这个工具"(见下)。
七、"篝火放不下"的另一半 —— 选中的不是篝火 修完渲染,我又回头看玩家那句话:“诱因里面的篝火为什么无法放置”。
探针已经证明放置逻辑没问题。那还有一种可能:玩家点的根本不是篝火。
看分组格的点击回调:
lua
复制 Camera.RegisterUI("grp_" .. gname, ..., function() Camera.toolGroupOpen = isOpen and "" or gname if isOpen then Camera.toolSel = nil end -- ← 只在"收起当前组"时清空 end) 问题在这里:只在收起时清空。如果玩家在"资源"组选了食物,然后切到"诱因"组——isOpen 是 false,toolSel 不会被清。面板换成了诱因,但选中的还是食物。
玩家点地图 → 投的是食物(或落点不合规直接失败)→ 看起来就是"篝火放不下"。
修法:
lua
复制 Camera.RegisterUI("grp_" .. gname, ..., function() Camera.toolGroupOpen = isOpen and "" or gname -- 切换分组(开/关/换组)都清空已选工具 Camera.toolSel = nil end) 同时点知识格也会清空工具选中并收起工具面板——两者都是"待投放"状态,不能并存。
这条和第五条合起来看:玩家描述的是现象,我们要找的是机制。 “篝火放不下” = 渲染没画(第五条) + 选中串了(这一条)。 两个独立 bug,合起来才凑成玩家看到的那一句话。
八、内容看不全 —— 滚动视图 玩法说明、文明图鉴、文明编年史,三个面板都被玩家反馈"下面看不到"。
原来它们的处理方式是截断:
lua
复制 if ry > y + h - 60 then Text(x + 18, ry, 11.5, "…… 更多现象,开局见分晓", ...) break end 图鉴 41 种现象、编年史 200 条记录——截断意味着大部分内容玩家永远看不到。
我给它们加了一个通用的滚动模块(Scroll),三个面板共用:
lua
复制 local sc = Scroll.Begin(key, viewX, viewY, viewW, viewH, contentH) -- ... 用 Scroll.Y(sc, contentY) 换算每条内容的 y ... Scroll.End(sc) 滚轮:注册 Camera.wheelConsumer,面板打开时滚轮滚内容而非缩放地图 拖动:可视区内按住左键拖动 裁剪:nvgScissor 裁到可视区 滚动条:有溢出时才显示 还顺手修了一个老 bug:图鉴/编年史的高度写成 h = SafeB() - 70,但 SafeB() 已经含屏幕高度了,再叠 y = SafeT() + 40 就出屏——玩家说"底部的看不到"正是这个。
lua
复制 local availH = SafeB() - SafeT() -- 安全区可用高度 local h = availH - 40 local y = SafeT() + (availH - h) / 2 -- 上下左右都居中 九、一个旋钮 今天改动里,我自己最喜欢的是这条玩家要求:
整体 ui 的大小参数写成一个统一的参数,方便我自己调整
原来 UI 尺寸是散落在各处的魔数:hotbar 格子 34–54、顶栏字号 15、按钮 46、引导条宽 520、态势面板宽 380……想整体调大一点,得改十几处。
现在收敛成一个:
lua
复制 -- Config.lua -- UI 全局缩放(唯一调节入口) -- 所有 HUD 尺寸/字号/间距都乘这个系数。想整体放大或缩小 UI,只改这一个数。 Config.UI_SCALE = 0.8 Render.lua 里所有 UI 尺寸都改成「1.0 基准值 × UK」:
lua
复制 local UK = Config.UI_SCALE or 1.0 ... local slot = Config.Clamp(math.floor(slot * UK), math.floor(34 * UK), math.floor(54 * UK)) local th = 34 * UK local fs = 15 * UK 这个旋钮立刻就被用上了——我最初设的是 0.6,现在文件里是 0.8。改一个数,全部 UI 一起变。
这条经验跟技术无关,跟协作有关: 把散落的魔数收敛成一个旋钮,比把每个数调对更重要。 前者是一次性投入,后者是每次都要投入。
十、当前状态 项 状态 底部 Hotbar(分类按钮) 已完成 UI 全局缩放旋钮 Config.UI_SCALE 已加入(当前 0.8) 读档崩溃(misery nil) 已修复(含导出/恢复接口) 崩溃后 UI 全错位 已修复(nvgRestore 必达) 顶栏消失(UI 库 nan) 已修复(改回自绘) 法则卡选卡崩溃 已修复(按 id 重绑 + 判空) 篝火不可见 已修复(补上绘制分派) 石碑不可见 已修复(放大 + 描边) 图鉴/编年史/玩法说明 已改为滚动 面板居中 / 层级 已修正 切分组串选中 已修复 篝火放置提示 已明确(“附近已有篝火,需间隔 3 格”) Render.lua 4485 行(还在长) 十一、下一步 [ ] 拆分 Render.lua:4467 行。09 提过,今天又长了 500 多行 [ ] 构建期校验(升级版):09 说校验"调色板键、字体路径",今天要加两条—— ① 每个 local function Draw* 必须有调用点;② 存档字段必须能恢复(local 变量要走 Export/Restore) [ ] NanoVG 状态审计:nvgSave 与 nvgRestore 必须成对,最好由框架保证 [ ] 篝火/石碑这类"世界物件"的可见性自查:新加的 kind 都要在 DrawStructure 里有分支 [ ] 觉醒/通天塔震荡(04 记过,06 修了一半,仍有反复) [ ] 正式上架(需要人工确认) 写在最后 今天修完最后一个 bug,我回头把三个玩家反馈排在一起看:
“这么多 ui 挡住画面” “这个 ui 什么情况” “篝火看不见” 三条反馈,三个不同的原因:
第 1 条:UI 设计问题(东西太多,要收纳) 第 2 条:状态泄漏问题(一个 nil 让相机变换泄漏给了整层 UI) 第 3 条:接线问题(函数写好了,没人调用) 但它们对玩家的呈现是一模一样的:屏幕上的东西不对。
而 09 我学到的教训是"做了 ≠ 玩家碰得到"。 今天要补上后半句:
碰得到 ≠ 看得见。 看得见 ≠ 看得对。
三层。每一层都有各自的失败方式:
层 失败方式 今天的例子 做了 没接线 篝火 DrawCampfire 从未调用 接上了 环境不支持 / 输入不可靠 顶栏 nan 布局 画了 状态被污染 nil 崩溃 → nvgRestore 跳过 → 整层错位 第三层最凶,因为它是唯一的"非局部"失败: 一个篝火画不出来,只影响那个篝火; 一次 nvgRestore 被跳过,影响那一帧里它之后的所有东西。
所以今天我给自己定了一条新规矩,写在 FrameWorld 的注释里:
画内容可以失败,但状态必须还原。
这条规矩的价值不在于修好了今天这个 bug,而在于: 下次不管是谁、因为什么原因让 DrawWorld 炸了,UI 都不会跟着陪葬。
今天把"看不见"的三个层次都摸了一遍。 下次,还是那句话——让机器替我记得: nvgSave / nvgRestore 成对检查、Draw* 调用点检查、存档字段可恢复检查。 这三条进了构建期校验的清单。#聚光灯gamejam开发者日志

