下载 App
《我真没让他们建城啊!》开发者日志 03
修改于10/167 浏览综合
先让世界可复现,再让他们走进新时代 一个测不准的世界,和一个会自己进化的世界,是同一天修好的。 而这两件事,其实是同一件事。
日期: 2026-10-11 当前进度: 跨进程确定性修复完成(900 秒逐字节一致);文明纪元系统上线并实测 4/4 走进蒸汽时代;地表从平色块换成烘焙贴图
写在前面:02 留下的那句话 01 讲的是世界怎么长出来——七张热度场、Utility AI、20 项文明现象图鉴。
02 结尾我留了一句很重的话:
一个测不准的系统,比一个没做好的系统更危险——因为你会拿着假数据,自信地往错误的方向调。
今天就是把这句话兑现。
看起来做了三件事,其实是两件:
把「同一颗种子跑出两个结果」彻底修掉。 测量工具不可信之前,任何数值调整都是盲调。 让世界真的会「进化」。 当可复现终于成立之后,我第一次完整看到——他们自己从石器时代,一步一步走进了蒸汽时代。 第三件(地表贴图)是顺手做的表现层升级,但它服务于第二件:一个走进铁器时代的文明,脚下不该还是开局那块平草地。
一、确定性:从 t=467 找到那根断掉的线 1.1 先把「测不准」复现出来 02 里我说,同一颗种子 4242,22:00 跑出 0 场战争,22:17 跑出 5 场,中间没改过任何代码。当时只能推断「分歧出现在长跑后段」。
今天第一件事就是把它钉死到秒。写了一个状态指纹脚本(_proc/determinism_trace.lua),每秒输出一行:人口、所有小豆人坐标/饥饿/疲劳/恐惧之和、道路数、部落数、交易/出生/死亡/战斗/战争计数。
两个独立进程,同 seed 4242,各跑 900 秒:
text
复制 fpA: FP t=1 ... FP t=466 ← 逐秒完全一致 fpB: FP t=1 ... FP t=466 ← 逐秒完全一致 ───────────────────── t=467 开始分叉 fpA: FP t=467 pop=75 x=61689.0361 ... fpB: FP t=467 pop=75 x=6xxxx.xxxx ... ← 之后 434 行全不同 前 466 秒一模一样,第 467 秒开始分道扬镳。
这比 02 的「0 场 vs 5 场战争」精确得多:分歧不在开头(地图生成、初始性格都是确定的),而在中段某个具体动作上。
1.2 根因:pairs 的字符串哈希 确定性这个坑,02 之前其实已经踩过一次——Agent.lua 的 Utility 决策用 pairs(scores) 挑最高分动作,而 Lua 5.4 对字符串键做每进程随机化的哈希,导致遍历顺序逐进程不同。当时加了固定顺序表 ACTION_ORDER(13 个动作名),用 ipairs 遍历,解决了决策层的漂移。
但今天证明:顺序敏感的地方不止决策一处。 排查出还有两处:
位置 问题 后果 World.lua 掉落逻辑 死亡时按资源种类掉落,用 pairs 遍历 a.inv 谁先掉谁后掉决定堆叠落点,同 seed 落点漂移 World.lua 资源种类 resourceKinds 若用 pairs 遍历 采集/再生顺序漂移 修复很朴素:所有顺序敏感的地方,一律换成显式顺序表 + ipairs。
lua
复制 -- World.lua -- 固定顺序遍历资源种类:字符串键表的 pairs 顺序在 Lua 5.4 每进程随机化。 local resourceKinds = { "food", "wood", "stone" }
-- 固定顺序掉落:字符串键表的 pairs 遍历顺序在 Lua 5.4 每进程随机化, -- 会让同一 seed 的 DropPile 落点产生跨进程分歧。 for _, kind in ipairs(resourceKinds) do local amount = a.inv and a.inv[kind] or 0 if amount > 0 then W.DropPile(kind, cx, cy, amount); a.inv[kind] = 0 end end 1.3 验证:900 行逐字节相同 修复后重跑,两个独立进程、900 秒:
text
复制 diff <(grep '^FP ' fixA.txt) <(grep '^FP ' fixB.txt) IDENTICAL 900 行指纹完全一致,一行不差。
这是这个项目第一次在完整一局长度上做到跨进程可复现。之前 400 秒能一致、900 秒必崩,现在 900 秒也稳了。测量工具,终于可信了。
1.4 顺手修的一条:表现层不许碰模拟的随机序列 排查过程中还发现一个隐蔽的污染源:渲染抖动、音效变调这类纯视觉的随机,如果直接用 math.random,就会消耗模拟的随机序列。
后果很反直觉:
帧率、窗口大小、甚至「玩家有没有打开面板」,都会改变同一颗种子的世界演化。
所以加了一个独立的自由运行 LCG,表现层专用:
lua
复制 -- Config.lua -- 表现层专用伪随机:渲染抖动 / 音效变调绝不触碰模拟的 math.random 序列。 -- 否则帧率、窗口大小、是否打开面板都会改变同一 seed 的世界演化。 local visualSeed = 2463534242 function Config.VisualRand() visualSeed = (visualSeed * 1103515245 + 12345) % 2147483648 return visualSeed / 2147483648 end 一条原则:模拟是权威的,表现只能看,不能碰。 这和 01 里「涌现系统只能命名、不能创造」、02 里「教学是观察者、不能污染模拟」是同一条原则的第三次落地。
1.5 一个副作用:seed 4242 换了个世界 修好确定性之后,重跑体检——seed 4242 的结果变了:
修复前 修复后 战争 5 场 0 场 人口 — 42(峰值 76) 聚落 2 0 标准通过 — ❌ 不通过 第一反应是「修坏了?」——不是。
是以前那个「5 场战争、2 个聚落」的 4242,本来就是漂移出来的幻觉。 它每次跑都不一样,只是我恰好看到了热闹的那一次。现在它稳定了,露出了真实的样子:一个运气不好、没能建起聚落的种子。
这就是为什么我说「测不准最要命」:它不但会骗你,还会专门把最好看的结果喂给你看。
二、文明纪元:他们自己走进了新时代 2.1 设计:时代必须「熬」出来,不能「跳」出来 这是今天最想讲的新系统。
之前聚落只有「等级」(帐篷→房子),是空间尺度的成长。今天加了一条时间尺度的成长线:文明纪元。
每个聚落独立积累「时代资本」:
资本 = 人口 × 1 + 建筑 × 3 + 库存余粮 × 0.5 资本只升不降(文明不会倒退),越过阈值、并且持续保持 45 秒,才真正进入新时代:
lua
复制 Threshold = { 0, 30, 85, 190, 360 }, -- 石器/农耕/青铜/铁器/蒸汽 HoldDuration = 45, -- 必须持续达标这么久 CheckInterval = 5, HoldDuration 这条是刻意的:否则人口瞬时波动就会让聚落「跳代」,看起来像 bug。文明得有惯性。
五个时代,名字和图标:
时代 图标 阈值 目标节奏 石器时代 🪨 0 开局 农耕时代 🌾 30 单局稳定可见 青铜时代 ⚒ 85 单局稳定可见 铁器时代 🗡 190 偶发 蒸汽时代 ⚙ 360 罕见的文明巅峰 2.2 时代不是标签,是四样东西一起变 这是我坚持的一点:如果「进入农耕时代」只是弹个提示、换个图标,那它就是假的。 时代必须同时改变四件事:
① 行为——定居者的采集、繁殖、战斗效率随时代提升(SettledGather/SettledBreed/Combat,从 1.0 到 1.55/1.35/1.38)。一个青铜时代的聚落,确实比石器时代更能打、更能生、更能攒。
② 建筑——新建筑有时代门槛:
lua
复制 BuildingEra = { granary = 2, workshop = 3, wall = 4, chimney = 5 } -- 粮仓(农耕) / 工坊(青铜) / 城墙(铁器) / 烟囱(蒸汽) ③ 地貌——从农耕时代起,聚落会自己开垦农田(FarmFromEra = 2),在附近找可走空地种下 farm 资源(和浆果一样占格、可采、会再生,只是更快)。时代越高,农田越多(FarmMin=2 → FarmMax=7)。
④ 外观——同一座房子随时代换材质;小豆人的衣服按时代配色(石器麻布→农耕麦黄→青铜铜褐→铁器铁灰→蒸汽烟青);帐篷数量随时代减少,逐步变成真正的房子。
结算称号也优先认纪元:
lua
复制 if era >= 5 then return "蒸汽文明", "他们造出了会自己呼吸的机器。" end if era >= 4 then return "铁器文明", "他们用铁写下了自己的名字。" end 2.3 实测:4/4 走进蒸汽时代 写了个纪元诊断脚本(_proc/era_diag.lua),跑 4 个种子 × 900 秒。结果:
种子 最高时代 晋级次数 逐代时间点 农田 纪元建筑 21021 🪨→⚙ 蒸汽 4 农耕@169s 青铜@216s 铁器@426s 蒸汽@755s 5 粮仓/工坊/城墙/烟囱 各 1 777 铁器 6 两聚落各到铁器(汤丁@655s / 团丸@791s) 8 粮仓×2 工坊×2 城墙×2 4242 蒸汽 4 农耕@305s 青铜@351s 铁器@546s 蒸汽@765s 5 四类各 1 1234 蒸汽 4 农耕@214s 青铜@261s 铁器@355s 蒸汽@730s 5 四类各 1 几个观察:
4/4 局都至少走到铁器,3/4 局走到蒸汽。 节奏是:农耕/青铜在 3~6 分钟,铁器在 7~13 分钟,蒸汽在 12 分钟往后。和设计目标(农耕/青铜稳定、铁器偶发、蒸汽罕见)基本吻合,蒸汽比预期还更容易达到一点——这说明阈值 360 可能偏低,回头要收一收。 农田真的被种出来了。 seed 777 两座聚落一共 5+3=8 块农田,地图上肉眼可见聚落周围多了一圈地。 四种纪元建筑全部出现过。 烟囱(蒸汽时代)是最难看到的,四类齐全说明门槛链条是通的。 2.4 一个意外的涌现现象:「文明落差」 纪元系统上线后,图鉴里自动多解锁了一条新现象,是我没专门设计的:
图鉴发现:文明落差 同一片土地上,有的聚落还停在石器时代,有的已经铸起了青铜。
触发条件是「地图上同时存在 ≤1 级和 ≥3 级的聚落」(Systems.lua)。seed 777 在 377 秒解锁了它。
这条特别有意思——它不是我写死的一个剧本,是两个聚落各自独立演进、速度不同,自然拉开的结果。 一个聚落运气好、资源足、先一步进了青铜;另一个还在石器时代挣扎。差距自己长出来了。
这就是「涌现」该有的样子:我给了每个聚落同一套规则,它们自己跑出了不同的历史。
三、地表:从平色块到烘焙贴图 3.1 一个审美问题 之前地表是纯程序化平色块(Px(x, y, CELL, CELL, P.grass1)),够用、够轻,但当一个文明走进铁器时代,脚下还是开局那块纯绿草地,说不过去。
所以今天做了地表贴图烘焙(_proc/bake_terrain.lua),直接产出四张 64×64 的无缝贴图:
assets/Textures/{grass,dirt,stone,sand}.png 草地:暖绿,明暗起伏 + 细草簇 + 零星小花(白/黄/粉) 泥土:暖棕 + 碎石颗粒 石路:冷灰 + 砖缝 沙岸:河岸用 3.2 无缝平铺的两个关键 ① 格点坐标对周期取模。 值噪声的格点坐标 % period,保证左右/上下边缘严丝合缝。
② 各倍频的格点周期必须整除 N。 这是无缝的硬约束,写错一格,平铺时就有一道缝。
还有一个反直觉的取舍:
lua
复制 -- 偏重高频:低频大斑块最容易暴露平铺周期,只留一点点做明暗起伏。 return vnoise(x4, y4, 4) * 0.22 + vnoise(x8+2, y8+6, 8) * 0.36 + vnoise(x16+5, y16+1, 16) * 0.24 + vnoise(x32+9, y32+3, 32) * 0.18 低频大斑块是平铺的头号敌人——它一重复,眼睛立刻看出「这是瓷砖」。所以权重刻意压向高频细节。
3.3 渲染接入 Render.lua 里把地表绘制从 Px 平色块换成了 nvgImagePattern 贴图:
地表按视野范围贴 grass; 道路分级贴图:踩踏痕迹(trampled 半透明泥色)→ 泥路(dirt)→ 石路(stone); 水岸贴 sand。 配套地,房子的材质也按时代换(ERA_HOUSE),帐篷→房子的过程肉眼可见。
顺便说一句:nvgImagePattern 是上游 NanoVG 的接口,不支持染色。后面如果要给贴图叠季节/昼夜色,得用引擎扩展的 nvgImagePatternTinted。
四、战争:今天的战报(以及它为什么还是不稳) 战争这条线不是今天的主线,但必须记一笔,因为数据挺能说明问题。
war_diag(狼群开启)跑 3 个种子 900 秒:
种子 战斗热度峰值(需 ≥150) 部落峰值 跨部落击杀 战争 21021 133.6 0 0 0 777 142.9 0 0 0 4242 133.3 0 0 0 三局部落峰值全是 0——一个部落都没形成,所以「跨部落」这回事根本不存在。狼击杀倒是有 20~39 次(说明狼群系统是活的),但狼群的压力把人口压得太低,聚落根本长不到能分裂出第二个部落的规模。
但 era_diag 里 seed 777 打起来了:
text
复制 [涌现 736.1s] 真实战争:部落1与2,关联死亡=3 [系统 736.1s] 图鉴发现:war 两个聚落各自长到铁器时代,关系终于恶化到敌对,736 秒爆发了真实战争,war 图鉴解锁。
结论:战争可达,但极其脆弱。 它依赖「两个聚落都能活到足够大」这个前提,而狼群一开,这个前提经常不成立。这和 02 里「战争卡在三个不同门槛」是同一条遗留线——现在三个门槛都通了,但通得勉强。
留到下一步:把冲突判定限定在不同部落之间(当前 CheckConflict 不区分部落,同族斗殴也在消耗人口),而不是继续提高全局概率。
五、数值体检(修复后重跑) 确定性修好之后,重新跑 3 个种子 × 900 秒的体检:
指标 seed 21021 seed 777 seed 4242 均值 峰值人口 109 133 76 106.0 道路长度 124 158 81 121.0 聚落数 1 1 0 0.7 交易次数 78 144 52 91.3 图鉴解锁 7 8 8 7.7 标准通过 ✅ ✅ ❌ 2/3 首次现象 vs 设计目标:
现象 设计目标(秒) 实测 结论 首次采集 10~20 11 / 12 / 11 ✅ 3/3 首次道路 45~180 66 / 63 / 61 ✅ 3/3 首次交易 120~240 35 / 28 / 28 ⚠ 0/3(偏早) 首次死亡 240~420 224 / 134 / 119 ⚠ 0/3(偏早) 首次冲突 240~420 46 / 87 / 96 ⚠ 0/3(偏早) 和新手引导最相关的那条——首次道路 60 秒左右——稳稳定在「3 分钟内看到明显涌现」的验收区间里,3/3 命中。
但前段还是太吵:交易 28~35 秒、冲突 46~96 秒就来了,而设计上希望玩家先安静观察一分钟。02 里就发现了这个偏早问题,当时因为「数据不可信」搁置了;现在数据可信了,这个「偏早」是真的,可以放心动手压了。
还有一件必须正视的事:seed 4242 掉出了标准线(人口 42、聚落 0)。修好确定性之后,这个「失败」第一次是真的失败,而不是漂移的噪音。一个稳定的失败,比一个飘忽的成功有价值得多。
六、今天踩到的三个坑
- 顺序敏感不只在「决策」里,也在「掉落」里。 02 之前修的 ACTION_ORDER 只堵住了决策层的平局。今天才发现,死亡时按资源种类掉落、资源遍历这些看起来纯数据的地方,一样会因 pairs 顺序漂移。凡是「谁先谁后会影响结果」的遍历,都要固定顺序——判断标准不是「它重不重要」,而是「换个顺序,结果会不会变」。
- 表现层的随机必须和模拟隔离。 帧率、窗口大小、是否打开面板——这些看起来和玩法毫无关系的东西,只要表现层偷用了 math.random,就会改变世界演化。观察者必须零副作用,这条原则今天第三次被验证。
- 「修好一个 bug 会改变测试结果」,恰恰是修对了的证据。 seed 4242 从「5 场战争」变成「0 场战争、不通过」,第一反应是慌。但仔细想:一个会漂移的系统,它的「成功」和「失败」都不能信。修好之后出现的那个稳定结果,才是这个种子的真面目。 有时候修复的代价,就是失去一个你喜欢的假象。
七、下一步 [ ] 战争率:把冲突判定限定在不同部落之间(当前同族斗殴也消耗人口),而不是继续加全局概率 [ ] 压前段节奏:把首次交易/冲突/死亡推回设计目标区间(现在数据可信了,可以放心调) [ ] 收纪元阈值:蒸汽时代(360)比预期更容易达到,可能上调;铁器保持偶发 [ ] 纪元与结算/回放打通:结算界面按时代讲这一局的故事 [ ] 地表贴图细节:昼夜/季节染色(注意 nvgImagePatternTinted) [ ] 宣传 GIF、投稿材料











