Noita 元胞沙盒笔记
元胞自动机 = 网格 + 每格状态 + 局部规则,反复迭代 → 全局涌现。
本项目的沙盒把"规则"做成了 材质表 + N×N 反应表 + 五阶段主循环,固定 60Hz 推进。
这份笔记分三层:概念(CA 是什么)→ 运作(沙盒怎么动)→ 实现(数据怎么放、循环怎么写),最后附实测现象、勘误和配表。
工程位置:
Assets/Scripts/Simulation/------ 纯 C#,不引用 UnityEngine(元胞机本体)Assets/Scripts/Sandbox/------ Unity 前端(时钟、渲染、操作)Assets/Resources/Data/materials.csv、reactions.csv------ 配表
项目地址:https://github.com/ExcCoder/NoitaDemo.git
目录
- 元胞自动机是什么
- Noita 沙盒怎么运作
- 数据模型:状态 / 属性 / 契约
- 反应系统
- 主循环:Step 的五个阶段
- 调度:固定时间步长
- 易踩的坑(实测)
- 勘误:与初稿的差异
- 附录
一、元胞自动机是什么
1.1 词面意思
"元胞"就是格子(cell),"自动机"就是状态机:给定当前状态,按规则决定下一步变成什么。
所以:元胞自动机 = 一堆小状态机排成网格,每台只看自己和邻居,然后一起往前走一步。
1.2 三个"离散" + 四个要素
三个离散:
- 空间离散:整齐的网格,不是连续坐标;
- 状态离散:每格只存有限个状态,不是浮点物理量;
- 时间离散:按帧推进,一帧算一步。
四个要素:
| 要素 | 含义 | 本项目对应 |
|---|---|---|
| 网格 | 空间长什么样 | 384×256 扁平数组,index = y * Width + x,y = 0 在底部 |
| 状态 | 每格存什么 | Cell(4 字节):材质 id + 寿命 |
| 邻域 | 能看哪些邻居 | 移动看上下左右 4 邻;反应只配对"右 + 上" |
| 规则 | 怎么变 | 相态决定移动算法、行为决定特殊规则、N×N 反应表决定相遇结果 |
同步更新:所有格子一起算下一帧。工程上常用"原地扫描 + 本帧已处理标记"近似同时更新,见 5.2。
1.3 经典例子:生命游戏
每格只有"活 / 死"两种状态,规则只有四条:周围 8 格活邻居为 2~3 个就继续活,否则死;死格恰好有 3 个活邻居就复活。
就这几条规则,能跑出滑翔机、振荡器、繁殖结构------没有一行代码在描述"滑翔机"。
这就是 CA 的精髓:规则在局部,行为在全局。
1.4 和传统物理引擎的区别
| 物理引擎 | 元胞自动机 | |
|---|---|---|
| 计算对象 | 连续量(速度、力、碰撞响应) | 离散状态(材质 id) |
| 方法 | 解方程、迭代积分 | 查规则、局部替换 |
| 成本 | 随物体/接触点增长 | 随格子数增长,每帧扫一遍 |
| 优点 | 精确、可预测 | 便宜、天然并行、手感直观、易扩展 |
| 缺点 | 贵、复杂 | 不精确、没有守恒律 |
1.5 一句话
元胞自动机 = 规则足够简单,简单到只依赖邻居;迭代足够久,久到宏观行为自己长出来。
二、Noita 沙盒怎么运作
它相当于把"生命游戏"升级成 多状态 + 材质属性 + 概率反应。
2.1 世界 = 材质网格 + 寿命
每格一种材质(空 / 石 / 沙 / 水 / 油 / 木 / 火 / 烟 / 蒸汽 / 酸 / 岩浆 / 灰 / 玻璃 / bedrock)+ 一个寿命。
你看到的沙堆、水池,是大量同材质格子恰好连成一片,不是"一个物体"。所以没有"整体"的物理:沙堆塌了是因为每一格各自判断"下面能不能掉"。
2.2 每格每帧做的三件事
按顺序:
- 寿命:火 / 烟 / 蒸汽这类会衰减,到点变成下一种材质(火 → 烟 → 空);
- 专属行为:火点燃四邻、酸腐蚀接触到的固体、蒸汽被压住时凝结成水;
- 相态移动 :
- 粉末:下面空 / 更轻就往下掉,不行再试斜下 → 沙堆自然形成 45° 坡;
- 液体:先下落、再斜下、再向左右铺开 → 水往低处流、找平;
- 气体:往上飘,能穿过液体、穿不过固体 → 烟和蒸汽上升。
一帧里不同格子的"决策"是这样的:
沙:下面空 → 交换位置,下落 1 格
水:下面堵、左下空 → 交换位置,流向低处
火:点燃相邻木头 → 自身寿命 -1 → 再像气体一样上浮
烟:上方空 → 上浮,寿命 -1,到点消失
2.3 密度法则:谁挤得动谁
没有"浮力计算",只有一条规则:更重的能挤进更轻的那格。
本项目实际数值(materials.csv):
| 材质 | 密度 | 直观结果 |
|---|---|---|
| 石 / 玻璃 / bedrock | 255 | 谁都挤不动(而且是 static,根本不参与移动) |
| 沙 | 200 | 沉进水里、沉进油里 |
| 木 | 180 | static,不会塌 |
| 熔岩 / 灰 | 150 | 熔岩沉在水下 |
| 酸 | 110 | 比水重一点,沉底 |
| 水 | 100 | --- |
| 油 | 80 | 浮在水面 |
| 火 | 10 | 上浮 |
| 蒸汽 | 8 | 穿过水往上冒 |
| 烟 | 5 | 最轻,飘得最高 |
"能不能进"的完整判定(概念版):
目标为空 → 能进(空格是特判,不参与密度比较)
同种材质 → 不能进(水不会穿过水)
目标是 static → 不能进
向下移动:我比它重 → 能进
向上移动:我比它轻,且目标是液体/气体 → 能进
横向移动:目标只能是气体 → 能进
注意"空气"不是一种密度为 0 的材质参与比较,而是空格直接放行------所以"蒸汽比空气轻所以上浮"这句话在本项目里不成立;气体上浮是因为它先试正上方,而空格无条件通过。
2.4 相遇 = 查反应表
两个不同材质贴在一起时,按规则表结算"你变什么、我变什么、多大几率":
| 规则 | 概率 | 效果 |
|---|---|---|
| 水 + 熔岩 | 100% | 蒸汽 + 石 |
| 水 + 火 | 100% | 蒸汽 + 空 |
| 沙 + 熔岩 | 25% | 玻璃 + 熔岩 |
| 熔岩 + 油 | 60% | 熔岩 + 火 |
| 熔岩 + 木 | 40% | 熔岩 + 火 |
| 酸 + 石 / 木 / 沙 | 12% | 酸保留,目标变空 |
规则不是写死的 if,而是一张可配置的表------所以加反应不用改代码,见第四节。
2.5 涌现与局限
以上几条叠加,就有了:木箱烧穿后沙塌进岩浆变玻璃、酸慢慢啃石头、水浇灭火、蒸汽升到顶凝结再落下来。
它的"物理"只有这几条局部规则------没有全局求解器、没有压力场、没有温度场(温度字段是预留的)。所以别期待守恒律:
- 水量少的时候会摊成稀疏的一层,不会自动找到同一个水平面;
- 沙堆只会堆成 45° 左右,因为粉末没有"横向滑落";
- 燃烧是概率的,偶尔剩几格木头烧不着是正常的。
这些不是 bug,是 CA 这类实现的通病/特征。
2.6 一句话
沙盒的全部"物理"就是几条局部规则:往下掉、往低处流、往上飘、比密度、按表反应------宏观的沙、水、火、岩浆都是它们迭代出来的结果。
三、数据模型:状态 / 属性 / 契约
原则:格子(Cell)只存状态,材质(MaterialDef)才存属性,反应(Reaction)只存契约。
三层数据:
csharp
Cell[] cells; // 每格状态,4 字节;384×256 ≈ 384 KB
MaterialDef[] defs; // 每种材质的共享属性(flyweight),不随格子复制
Reaction[] reactions; // N×N 稠密表,index = a * N + b(当前 14×14 ≈ 0.6 KB)
// 另有 byte[] mMoved(96 KB),只做"本帧是否动过"的防冲撞标记,见 5.2
3.1 每格只存状态:Cell(4 字节)
| 字段 | 作用 |
|---|---|
material |
材质 id,对应材质表下标 |
temperature |
温度,预留,当前不参与模拟 |
lifetime |
剩余寿命,0 = 永不变质 |
reserved |
保留,凑满 4 字节 |
为什么这么小:网格动辄几万几十万格,每格多 1 字节就是几十 KB。而且热循环只做数组索引和字节比较,没有 class、没有虚调用、没有字典。
反过来,"本帧动没动过"这种临时标记故意不放进来------因为它会被交换一起搬走(见 5.2)。
3.2 材质属性表:共用一份(flyweight)
MaterialDef 是每种材质一份,被所有同 id 的格子共享:相态、行为、密度、可燃性、扩散、decayTo、初始寿命、flags、颜色。
好处:改一个材质属性,全图同材质立刻生效;每格不用为"密度是多少"付存储成本。
3.3 枚举用 byte(你的笔记,补本项目落点)
核心语法:
csharp
public enum Behavior : byte { None = 0, Fire = 1, Acid = 2, Steam = 3 }
public enum Phase : byte { Empty = 0, Static = 1, Powder = 2, Liquid = 3, Gas = 4 }
为什么可以指定基础类型:byte 能存 0~255,元胞机里"材质种类 / 相态种类"通常远小于 256,够用。
收益:普通 enum 底层是 int(4 字节),指定 byte 后只占 1 字节,省 4 倍。对几万几十万格的数组很划算。
本项目的实际落点 (补一句,免得笔记里对不上):Phase / Behavior 存在共享材质表 里,不是每格一份;每格只有 byte material 和 byte lifetime。所以省内存的主力是 "Cell 只有 4 字节",byte 枚举是配套习惯------它同时决定了 MaterialDef 的结构大小,以及 materials.csv 里这些列的取值范围。
避坑提示:
- 普通业务逻辑(对象只有几千几万个)不要用。默认
int处理最快(CPU 原生字长),没有溢出风险,代码也更干净; - Unity Inspector 面板序列化
byte枚举在旧版本可能有 Bug,暴露给编辑器调参的字段慎用; - 值域一旦超过 255(例如材质超过 256 种)会直接溢出,本项目用
byte存材质 id,所以材质上限就是 256。
3.4 结构体对齐与保留字段
现代 CPU 读内存喜欢按 4 / 8 字节的块来读;结构体大小刚好是 4 的倍数时效率最高。
Cell 正好 4 字节(material / temperature / lifetime / reserved 各 1 字节),就是按这个思路凑的:temperature 和 reserved 虽然当前没用,但让结构体对齐到 32 位字长,也顺手给以后留了字段。
缓存命中时提升有限(5%~10%),一旦要频繁回主内存拿数据,正确对齐能差出几倍------元胞机每帧要扫几十万格,正好属于后者。
3.5 反应表的"稠密存放"(Dense Storage)
核心公式:
index = a * N + b // a、b 是材质 id,N 是材质总数
为什么不用 Dictionary:每帧要做几十万次反应检测,哈希计算 + 指针跳转太慢。
直接开一个 N × N 数组,用一次乘法加法算下标,没有指针跳转,CPU 缓存预读友好 。代价是一点内存浪费(大部分格子的 chance = 0),换极致的查询速度,非常划算。
怎么判断"这条反应不存在":数据填满数组,用 chance == 0 表示不存在。
本项目的实际尺寸:N = 14,14 × 14 × 3 字节 ≈ 0.6 KB;如果材质涨到 256,就是 256 × 256 × 3 字节 ≈ 196 KB(仍可接受)。所以"直接开 256×256"是一个理论上限的估算,不是当前实际分配。
四、反应系统
4.1 反应结构体:只负责"一对一"的契约
csharp
struct Reaction
{
byte resultSelf; // 当前格变成什么
byte resultOther; // 邻居变成什么
byte chance; // 1~255 概率;0 = 没有这条反应
}
它只描述两个像素碰撞的瞬间:"A 遇到 B,各自变成什么,概率多少"。
它不关心周围是一圈还是几圈,也不存坐标------它只是一张没有空间信息的契约。
4.2 主循环负责"找对象"并执行
"遍历邻居"的逻辑写在主循环里,而不是写在数据结构里。
流程:扫到当前格 a → 找邻居 b → 查表 Reactionab → 判定概率 → 执行结果。
本项目的取舍 :不遍历 8 邻居,而是固定只配对 右 + 上 两个方向。因为每对相邻格只需要被结算一次,这样:
- 水平对在"左边那个格子"处理时结算,垂直对在"下面那个格子"处理时结算;
- 每对每帧正好一次,等效于 8 邻居但省一半以上的查表;
- 不用配置 8 个方向的规则。
代价:同一帧内一个格子理论上可能被多个邻居各配对一次(因为它右/上被查过,左/下又会作为别人的右/上被查)。这种"一帧多次反应"对沙盒来说可以接受。
初稿里"反应后当前格 ID 变了应立刻 break"更适合"一个格子遍历 8 邻居"的写法;本项目每个格子只发起 2 次固定配对,所以没有 break 这一步。
4.3 属性解耦:主动 vs 被动
不要试图用单一属性(如万能 Reactivity)概括所有交互。"燃烧"和"腐蚀"虽然结果都是改变目标像素,但底层逻辑完全不同:
| 燃烧 | 腐蚀 | |
|---|---|---|
| 方向 | 被动:木头靠 flammability 标签等火来触发 |
主动:酸液主动查询邻居,判定能否腐蚀 |
| 过程 | 需要寿命、有燃烧过程、产物复杂(木 → 火 → 烟) | 接触瞬间按概率结算,直接消失 |
| 结果 | 有中间态 | 没有中间态 |
正确设计:属性拆分 + 查反应表
- 材质表:只存"我是谁"(密度、可燃性、抗酸性......);
- 反应表:只存"我遇到你变成啥"(
Reaction[火][木头])。
4.4 反应的"正反向回退机制"
如果只配置了 water → lava 的反应,没配 lava → water:
当系统扫到 lava 挨着 water 时,查表 [lava][water] 发现没有,会自动反向查一遍 [water][lava];命中后把 resultSelf 和 resultOther 互换(因为视角反了)。
这样配置表只需要写一半规则,代码里做一次回退 + 结果对调。用极小的运行时代价换掉一半的配置量,是很划算的工程妥协。
4.5 现有关卡:三条典型循环
- 水火土循环:水 + 熔岩 → 蒸汽 + 石。蒸汽上浮、遇到阻挡凝结回水,水又被下一层熔岩变成蒸汽------水在循环,熔岩在变成石头。实测 200 步后水量不变、熔岩少了一大截、石头多出来;
- 沙 + 熔岩 → 玻璃:沙丘塌进岩浆,变成玻璃;
- 酸腐蚀:酸只对相邻的 static / powder 生效,按概率把目标变空,偶尔消耗自己。
五、主循环:Step 的五个阶段
5.1 流程
Step() 一帧:
┌─ ① 寿命 ──────── lifetime--,到 0 → 换成 decayTo(火→烟→空)
├─ ② 静态跳过 ──── static 且无 behavior(石/木/玻璃)直接跳过
├─ ③ 行为 ──────── 火点燃四邻 / 酸腐蚀 / 蒸汽受阻凝结
├─ ④ 相态移动 ──── powder 落 / liquid 落+横散 / gas 升(mMoved 限一次)
└─ ⑤ 反应 ──────── 全图再扫一遍,右/上邻居配对,按 chance/255 结算
顺序有讲究:
- 寿命在最前:到点的材质先变成下一种,不再参与本帧移动;
- 行为在移动前:火先点着邻居,再像气体一样上浮;酸/蒸汽只有"自毁 / 凝结"时才跳过移动;
- 反应在最后:移动全做完再统一配对,避免移动打断反应判定。
5.2 防冲撞:mMoved 标记
byte[] mMoved 标记"本帧已经移动过"的格子,保证每个格子每帧最多只行动一次。
- 每帧清空一次;
- 交换时给目标格打标记;
- 已标记的格子本帧直接跳过。
它的作用不只是防重复:扫描方向 + mMoved 一起,保证"一帧最多动一格"------落沙不会一帧穿好几格,气体也不会窜。
5.3 扫描方向与方向偏置
问题:主循环必须是某种固定顺序(本项目从下往上、从左往右),而不同相态的"自然方向"不一样------沙子想往下,气体想往上。
本项目的实际做法:从下往上扫,配合 mMoved:
- 沙子落到的那一格本帧已经处理过了,所以不会一帧掉多格;
- 气体升到的那一格本帧还没处理,但被 mMoved 拦住,所以也不会一帧升多格。
结论:落和浮都被归一成"一帧一格",不会出现瞬移/穿透。真正的偏置只剩下"同一帧内的处理优先级"(下面的格子先动,上面的后动),以及"新点燃的火本帧不再处理"这类细节。
三种通用解法(知道即可,本项目没用):
| 解法 | 做法 | 代价 |
|---|---|---|
| A. 交替扫描(蛇形) | 第一帧从下往上,第二帧从上往下 | 实现简单,但规则不再"同帧同序",调试变难 |
| B. 分相态独立循环 | 粉末/液体从下往上,气体单独从上往下 | 遍历次数变多(1 遍变 2~3 遍) |
| C. 双缓冲 | 读当前帧,写下一帧,最后统一 Swap | 彻底消除顺序影响,但双倍内存 |
本项目的取舍:单次遍历 + mMoved。用每帧一个 byte 的标记数组(384×256 时约 96 KB),换掉双缓冲的双倍内存和多次遍历。
一句话:一帧只有一次遍历,不可能完美兼顾两个方向;用标记把两个方向都压成"一帧一格",是最低成本的妥协。
六、调度:固定时间步长
6.1 为什么不能直接放 Update 里
Update 跟电脑帧率走。如果每帧直接 World.Step():
- 144Hz 屏幕上,沙子掉得飞快;
- 20Hz 卡顿时,沙子掉得极慢。
物理模拟必须与画面帧率解耦。
6.2 解决方案:累加器(Accumulator)
设定固定步长(本项目 1/60 秒):
每帧:累加真实时间(可乘倍速)
while 累加值 >= 1/60:
World.Step()
累加值 -= 1/60
- 高帧率:攒好几帧才触发一次逻辑更新;
- 低帧率:一帧内连续触发多次逻辑更新来"补课"。
6.3 为什么不用 Unity 的 FixedUpdate
- 累加器代码更轻量;
- 完全掌控:容易实现 2 倍速、暂停、单步调试;
- 不受引擎物理系统的时间配置限制。
6.4 逻辑与渲染的顺序
先算账:while 循环跑 World.Step(),更新网格数据;后作画:View.Render() 把最新数据画到屏幕上。
6.5 本项目参数
- 固定步长
1/60 s; - 一帧最多补 8 步,超过就丢弃累积(防止卡顿后"追帧"太久);
- 倍速 0.25x / 1x / 2x / 4x;
- 支持 Pause 与单步(暂停时点 Step 走一帧)。
七、易踩的坑(实测)
Step()的返回值不等于"真的变了" :火的点燃、酸的腐蚀这类"行为改动"不计入返回值。别用while (world.Step())做沉降判断,会提前退出。- 火是气体,会往上飘:在燃料上表面点一撮火,它常常直接飘走,下面的燃料烧不到。要烧东西得让火"困"在燃料内部或侧面(火球打进木箱、点油面)。
- 燃烧是概率性的:同一块木箱换不同随机种子,跑完剩下的木头格数不一样;可复现,但结果依赖种子。
- 液体没有压力/体积守恒:水量少时会摊成稀疏的 1 格层,还会出现孔洞;水不会自动找同一个水平面。
- 液体一步能横跳多格 :单格液体一帧最多横向移动
dispersion格(水 4、油/酸 3、熔岩 2),不是平滑流动。 - 反应只发生在相邻格:酸不接触石头就永远不腐蚀;把酸放在悬空石块下方,腐蚀几格后就停住,因为酸不会爬上去。
- 新增材质有格式约束 :id 必须从 0 连续、不超过 255、名字唯一、id 0 必须是空气;
decayTo必须是已存在的材质名。 - 渲染可能比模拟更贵:每帧把整张图重新上传成纹理,384×256 的 CPU→GPU 上传开销通常大于模拟本身。
八、勘误:与初稿的差异
下面是初稿里和这份代码对不上的地方,已按实际实现改写:
- 密度数值:初稿写"沙 100 > 水 50",实际是沙 200、水 100;油 80、熔岩 150、蒸汽 8、火 10、烟 5。
- "空气"不参与密度比较 :空格是
id == 0特判放行,不是"密度 0 的材质"。所以"蒸汽密度 < 空气所以上浮"的因果不成立,气体上浮是因为移动算法先试正上方。 - 反应邻居不是 8 个:本项目固定配对"右 + 上",每对每帧一次;没有"遍历邻居时 break"这一步。
- 方向偏置:本项目用 mMoved 把落/浮都压成"一帧一格",不会瞬移/穿透;偏置只剩同帧处理优先级。"容忍气体轻微偏置"改为"用每帧 1 字节的标记数组把双向归一化"。
- 反应表尺寸 :不是固定 256×256,而是
N×N(N = 材质数,当前 14);256 只是byte材质 id 的理论上限。 - enum 省内存的落点 :本项目的
Phase/Behavior存在共享材质表里,不是每格一份;省内存的主力是Cell只有 4 字节。
九、附录
9.1 材质表(materials.csv)
| id | 名称 | 相态 | 行为 | 密度 | 可燃性 | 扩散 | 寿命 | 产物 |
|---|---|---|---|---|---|---|---|---|
| 0 | empty(空气) | empty | none | 0 | 0 | 0 | 0 | --- |
| 1 | stone(石) | static | none | 255 | 0 | 0 | 0 | --- |
| 2 | bedrock(基岩) | static | none | 255 | 0 | 0 | 0 | 不可破坏 |
| 3 | sand(沙) | powder | none | 200 | 0 | 0 | 0 | --- |
| 4 | water(水) | liquid | none | 100 | 0 | 4 | 0 | --- |
| 5 | oil(油) | liquid | none | 80 | 100 | 3 | 0 | --- |
| 6 | wood(木) | static | none | 180 | 120 | 0 | 0 | --- |
| 7 | fire(火) | gas | fire | 10 | 0 | 0 | 45 | 烟 |
| 8 | smoke(烟) | gas | none | 5 | 0 | 0 | 140 | 空 |
| 9 | steam(蒸汽) | gas | steam | 8 | 0 | 0 | 110 | 水 |
| 10 | acid(酸) | liquid | acid | 110 | 0 | 3 | 0 | --- |
| 11 | lava(熔岩) | liquid | none | 150 | 0 | 2 | 0 | --- |
| 12 | ash(灰) | powder | none | 150 | 0 | 0 | 0 | --- |
| 13 | glass(玻璃) | static | none | 255 | 0 | 0 | 0 | --- |
9.2 反应表(reactions.csv)
| a | b | a 变成 | b 变成 | 概率 |
|---|---|---|---|---|
| water | lava | steam | stone | 100% |
| water | fire | steam | empty | 100% |
| acid | stone | acid | empty | 12% |
| acid | wood | acid | empty | 12% |
| acid | sand | acid | empty | 12% |
| sand | lava | glass | lava | 25% |
| lava | oil | lava | fire | 60% |
| lava | wood | lava | fire | 40% |
表中一行同时覆盖正向和反向(4.4 的回退机制);概率按
percent × 255 / 100量化成chance。
9.3 实测数字(本机无头 Release 跑出来的参考值)
| 现象 | 实测 |
|---|---|
| 落沙速度 | 1 格/步(4 格沙柱整体下移 1 格) |
| 气体上升速度 | 1 格/步 |
| 单格液体横向 | 一步最多 4 格(由 dispersion 决定) |
| 火 → 烟 | 第 45 步 |
| 烟 → 空 | 再约 140 步 |
| 水 + 熔岩 | 接触当帧结算(第 1 帧结算了 44 对) |
| 384×256 竞技场 | 约 0.33 ms/步(.NET Release,Unity Mono 会慢 2~4 倍) |
9.4 主要源码地图
| 文件 | 作用 |
|---|---|
Assets/Scripts/Simulation/PixelWorld.cs |
世界、主循环、移动、行为、反应、随机 |
Assets/Scripts/Simulation/MaterialTable.cs |
CSV → 材质表 + 反应表 |
Assets/Scripts/Simulation/Cell.cs |
每格状态(4 字节) |
Assets/Scripts/Simulation/MaterialDef.cs |
材质共享属性 |
Assets/Scripts/Simulation/Reaction.cs |
反应契约 |
Assets/Scripts/Simulation/Phase.cs / Behavior.cs |
相态 / 行为枚举 |
Assets/Scripts/Sandbox/SandboxRoot.cs |
时钟、暂停、单步、倍速 |
Assets/Scripts/Sandbox/PixelWorldView.cs |
网格 → 纹理 |
Assets/Scripts/Sandbox/PixelPainter.cs |
鼠标画笔 |
Assets/Scripts/Sandbox/PlayerController.cs |
玩家碰撞(AABB 查网格) |
Assets/Scripts/Sandbox/SpellProjectile.cs / DigBeam.cs |
水球/火球、挖洞 |
Assets/Scripts/Sandbox/SandboxArena.cs |
演示地图 |
Assets/Resources/Data/materials.csv / reactions.csv |
配表 |
9.5 一句话总结
网格上每个格子只管自己:寿命到了就变,该落就落、该流就流、该飘就飘,遇到不同材质就查表------重复六十次每秒,沙、水、火、岩浆就都"活"了。