本篇教程由作者设定使用 CC BY-NC-SA 协议。
前言
蟹妖@SINOFUMA
作为一个自驱多于任务驱动的mod,当咒法师们用水爆和电磁炮征服了大陆的每个角落,掌握了每一块紫水晶母岩与村民和悦灵的大脑,视凋灵与末影龙为尘土,每秒几百万媒质吞吐……下一步还有什么目标呢?
那当然是造巨构和写算法辣!
而门格海绵作为一个定义清晰、结构整齐、层次分明的巨构,两个目标一次满足,便可作为一个很好的阶段性目标供玩家去实现。
本文将记录一下笔者近一段时间就此目标开发的一些法术,并简述其分别的思路
——《回回回》 字的4种写法
——《回回回》 字的4种写法
——《回回回》 字的4种写法
前置信息
本章节的一些知识碎片可以帮助理解后文的代码与相应解释,
HexParse格式
HexParse是笔者自制的一个附属,主要功能是跳过法杖绘制六边形鬼画符阶段直接以文本的形式编写法术代码,并使用指令将代码文本快捷编译为游戏内法术。
本文所涉及的HexParse表达式主要有以下几种:
以对应注册名写出的法术图案
左右方括号“[...]”包裹的区域,会整体解析为单个列表iota
形如“114514”的单元将被解析为对应数字iota;形如“num_114514”的则会解析为生成对应数字的数字之精思
形如“vec_1_2_3”的单元会解析为向量iota,三个数字分别对应xyz轴;若不足3个数字则缺失部分将设为0
另外,在法术列表中加入不影响执行的换行、缩进与深绿色注释文本也是HexParse提供的功能::)
主体算法
本文所涉及的写法将以生成式的算法实现,即给定海绵中心点与扩散步长,从起点逐步扩散至每个最小单位方块所在的点(并放置方块);对应Python伪代码如下:
def menger(step: Int | Float, pos: Vec3):
if step >= 1: # 如果步长不小于1方块距离则继续扩散
for offset in get_menger_offsets(): # 门格海绵由中心扩散至棱+顶点共20个位置
menger(step/3, pos + offset * step)
else: # 否则在该位置放下方块
place_block(pos)
循环结构
门格海绵具有的分形层次结构,是难以通过单次顺序执行获得的;而显然咒法学本体并不具备天然的循环执行语句。故而咒法师们需要发挥自己的想象力与创造力和函数式编程经验去使用现有的法术图案生成循环。
如上一小节的伪代码所示,为了生成门格海绵,本文将主要涉及两种循环结构:一是对一个列表内元素进行分别处理的托特(for_each),一个是由递归结构转换而来的条件循环。
数据批量处理与托特
托特之策略(注册名:for_each)的主要功能是对一个列表内的每个元素执行特定的代码逻辑,并汇总所有处理后的结果至新列表;此外,其对于每次内部执行会分别创建新的iota栈,并继承原栈内的所有内容置于待处理元素下方,这就是咒法学独特的醍醐味口牙
其固定循环与数据处理的性质天然适用于处理上文for offset in get_menger_offsets()对应逻辑,以下会详细展开。
Quine与套娃
Quine这个概念概括为一句话就是“运行后输出自己的程序”,而这正是我们所需要的。
由于咒法学代码与iota不分家,一种常规的造Quine方式是在执行代码前制造套娃,将复制的代码塞进自己的特定位置以实现无限循环的效果。以下列出了其中一种形式:
( ([ // 下标1的列表可任意替换为想执行的循环体
\str_HELLO_WORLD,print,mask_v
])splat,eval,( 114514 )splat
duplicate,num_6,swap,replace,eval // 此处的eval若移除则为每次执行获得自己,可用于法术环中
)remove_comments,duplicate,num_6,swap,replace,eval
注意到上文的“套娃点”在代码列表内位于下标6的位置,故其内外部的赫尔墨斯(eval)之前均复制了代码体并写于下标6,以实现无限套娃的效果
伪Quine与渡鸦
考虑以下Python代码:
with open(__file__) as f:
print(f.read())
并不是只有学术正统的quine能实现自己输出自己,如果提前知道代码存在何处,就可以在循环执行的时候直接把代码拿出来,比如把代码存进渡鸦√
1.20新内容:执行上下文跳转
1.20新增的伊里斯之策略Mystique As Iris(注册名:eval/cc)相比托特的咒法学味儿则更偏向学院派函数式编程的醍醐味(continuation一等公民这啥),其作用在eval的基础上,在执行代码段前增加了一个跳转至执行该代码段后的跳转iota(ContinuationIota,写作[Jump])
如果在被执行的代码段内执行这个[Jump],可以起到中断当前逻辑直球跳出的效果绯红之王!,当然这不是本文的重点;
而如果把这个[Jump]储存下来,在之后的逻辑中调用它,一样会回到这个代码执行位置第三炸弹,败者食尘!,这便构成了循环。后文将会使用到这个性质。
内嵌iota与程序辅助生成
众所周知,被内省、反思扩起来的图案列表与跟随考察的图案并不会被执行,而会将图案本身放入栈内;而这个性质实际上不止适用于图案,对任意iota都是有效的;故可以使用这个性质将一些iota常量嵌入法术程序中:
对于上述门格海绵算法而言,现场计算get_menger_offsets()无疑是复杂而低效的,而幸运的是HexParse支持将部分常量iota直接解析为法术列表的一部分,因此笔者在第四面墙外使用Python提前计算好了所需的20个扩散坐标:
(vec_-1_-1_-1,vec_-1_-1,vec_-1_-1_1,vec_-1_0_-1,vec_-1_0_1,vec_-1_1_-1,vec_-1_1,vec_-1_1_1,vec_0_-1_-1,vec_0_-1_1,vec_0_1_-1,vec_0_1_1,vec_1_-1_-1,vec_1_-1,vec_1_-1_1,vec_1_0_-1,vec_1_0_1,vec_1_1_-1,vec_1_1,vec_1_1_1)
\[vec_-1_-1_-1,vec_-1_-1,vec_-1_-1_1,vec_-1_0_-1,vec_-1_0_1,vec_-1_1_-1,vec_-1_1,vec_-1_1_1,vec_0_-1_-1,vec_0_-1_1,vec_0_1_-1,vec_0_1_1,vec_1_-1_-1,vec_1_-1,vec_1_-1_1,vec_1_0_-1,vec_1_0_1,vec_1_1_-1,vec_1_1,vec_1_1_1] // 另一种写法
以上神秘字串将在每个成品代码中出现。
不重要的终端法术:放置方块
放置方块与放置方块,第二型均可用于实现算法终端的place_block(pos)步骤,它们分别需要从玩家的物品栏与物元中获取待放置方块,当然对应的拆除时也会掉落物品;
构筑方块可以通过媒质凭空生成方块——但,它生成的方块是透明的,仅有模糊的粒子显示,很不适合用于法术调试;
为了更加关注算法本身而非外物,笔者使用kubejs创建了一个新的图案:place_mageblock,其效果是在给定位置放置一个隔壁新生魔艺的法师方块,兼具显眼与无痕拆除的好处_(:з」∠)_
读者如有需要,可在下文对应位置将place_mageblock替换为自己需要的法术片段。
正文
(先算再造)Quine版
get_caster,entity_pos/foot,singleton
\3,write/local // 步长
comment_gen-list
\[ \[ \[ \[read/local,mul,add]
\[vec_-1_-1_-1,vec_-1_-1,vec_-1_-1_1,vec_-1_0_-1,vec_-1_0_1,vec_-1_1_-1,vec_-1_1,vec_-1_1_1,vec_0_-1_-1,vec_0_-1_1,vec_0_1_-1,vec_0_1_1,vec_1_-1_-1,vec_1_-1,vec_1_-1_1,vec_1_0_-1,vec_1_0_1,vec_1_1_-1,vec_1_1,vec_1_1_1]
for_each,mask_v-,splat
]swap,for_each // 先扩散
]eval comment_0-2
\[] comment_inject-at-4
read/local,\1,greater // 步长>1则继续扩散,否则结束
\[ comment_eval-and-quine
duplicate,\4,swap,replace
read/local,\3,div,write/local // 每轮步长/3
eval
]\pop,if,eval
]remove_comments
duplicate,\4,swap,replace,eval
comment_place-all // 统一绘制
(place_mageblock)swap,for_each,pop
大体思路:栈内存储一个列表,初始包含玩家坐标(起始点);Quine每次循环中托特栈列表,将其中每个点再托特20个扩散点位;步长信息记录在渡鸦中。
由于1.20的iota大小默认限制在1000出头个元素,该法术最多可绘制2级门格海绵(对应渡鸦输入=3),共20^2=400个点位;增加一级则会因无法存储长20^3=8000的坐标列表而中止;且因为该法术是先生成再绘制,在中止时未达到建造方块的逻辑,将不会有任何方块输出。
(1.20独占)(先算再造)Jump循环版
\[write/local,\3,get_caster,entity_pos/foot,singleton]eval/cc
comment_do-split
\[\[rotate,mul,add]
\[vec_-1_-1_-1,vec_-1_-1,vec_-1_-1_1,vec_-1_0_-1,vec_-1_0_1,vec_-1_1_-1,vec_-1_1,vec_-1_1_1,vec_0_-1_-1,vec_0_-1_1,vec_0_1_-1,vec_0_1_1,vec_1_-1_-1,vec_1_-1,vec_1_-1_1,vec_1_0_-1,vec_1_0_1,vec_1_1_-1,vec_1_1,vec_1_1_1]for_each,mask_vv-,splat
]swap,for_each
comment_while-i-less-1
swap,num_3,div,duplicate,rotate_reverse
num_1,greater_eq,\[read/local,eval]\[(place_mageblock),swap,for_each,pop]if,eval
与上一种大体思路一致,所以最多绘制2级的弊端也一致;但使用了伊里斯和[Jump]来构成循环体,由构成套娃的定式改为栈操作[Jump]之后,代码长度短了很多。
托特递归版
注意到托特的子执行环境具有独享的栈空间,并且会按照调用层次关系返回内容,是天然的递归的好工具,因而我们有了:
( ([comment_CODE,comment_DIST,comment_CENTER])splat,splat // 初始栈,栈底用于传递代码自身
num_1,fisherman/copy,num_1,greater_eq // 步长>=1则分裂,否则防止方块
( ( // code,dist,center,offset
num_2,fisherman/copy,mul,add // 新起始坐标
swap,num_3,div,swap // 新步长
num_2,fisherman/copy,rotate_reverse
num_3,last_n_list,num_1,swap,replace,eval // 开quine
)
(vec_-1_-1_-1,vec_-1_-1,vec_-1_-1_1,vec_-1_0_-1,vec_-1_0_1,vec_-1_1_-1,vec_-1_1,vec_-1_1_1,vec_0_-1_-1,vec_0_-1_1,vec_0_1_-1,vec_0_1_1,vec_1_-1_-1,vec_1_-1,vec_1_-1_1,vec_1_0_-1,vec_1_0_1,vec_1_1_-1,vec_1_1,vec_1_1_1)
for_each,mask_vvvv // 对所有分裂点位开托
)(
place_mageblock,mask_vv
)if,eval
)remove_comments
duplicate,\9,get_caster,entity_pos/eye
num_3,last_n_list,num_1,swap,replace,eval // 填入初值,开quine
以上代码改编自咒法学Discord服务器内的上古代码:包含初始栈的通用托特递归;由于其优先向内分裂,即使中断运行也会有一定的输出,如下图所示:
图中左侧为前两个法术的输出结果,右侧为此法术尝试绘制3级门格海绵时的输出结果:可以看到它建造了门格海绵半成品后中断了,原因是触及了1.20咒法学单次运行最多10万图案的限制。
为了输出更多的方块,我们需要尽可能减少无谓的图案消耗,以下为其中一种优化的可能性:
// recursion 2
\9,get_caster,entity_pos/eye
( comment_DIST,comment_CENTER
over,num_1,greater_eq
( ( // dist,center,offset
num_2,fisherman/copy,mul,add
swap,num_3,div,swap
over,num_1,greater_eq
(read/local,eval)
(place_mageblock,pop)if,eval
)
(vec_-1_-1_-1,vec_-1_-1,vec_-1_-1_1,vec_-1_0_-1,vec_-1_0_1,vec_-1_1_-1,vec_-1_1,vec_-1_1_1,vec_0_-1_-1,vec_0_-1_1,vec_0_1_-1,vec_0_1_1,vec_1_-1_-1,vec_1_-1,vec_1_-1_1,vec_1_0_-1,vec_1_0_1,vec_1_1_-1,vec_1_1,vec_1_1_1)
for_each,mask_vvv
)(mask_vv)if,eval
)remove_comments,write/local
read/local,eval
移除套娃结构,将代码置于渡鸦中,其运行结果相比于上个版本生成了更多方块。
整活:(1.20独占)递归改栈+Jump循环版
什么是递归?顺序调用,逆序返回,后进先出,深度优先。什么是栈?顺序调用,逆序返回,后进先出,深度优先。那么,为什么不用栈结构本身来实现递归呢?
// 递归化栈初号机
( stack_len,bool_coerce
( comment_1.do-menger
duplicate,num_1,index,num_1,greater_eq // 如果步长不小于1则取栈顶1分20
( comment_split-menger splat
((vec_-1_-1_-1,vec_-1_-1,vec_-1_-1_1,vec_-1_0_-1,vec_-1_0_1,vec_-1_1_-1,vec_-1_1,vec_-1_1_1,vec_0_-1_-1,vec_0_-1_1,vec_0_1_-1,vec_0_1_1,vec_1_-1_-1,vec_1_-1,vec_1_-1_1,vec_1_0_-1,vec_1_0_1,vec_1_1_-1,vec_1_1,vec_1_1_1))eval/cc // center,step,jump,offsets
duplicate,bool_coerce
( deconstruct,num_64,swizzle,2dup // jump,offsets o,center,step,center,step
num_4,fisherman,mul,add
over,num_3,div,num_2,last_n_list
num_112,swizzle,over,eval
)
(mask_vvvv)if,eval
)
( comment_put-block
num_0,index,place_mageblock
)if,eval
comment_2.loop read/local,eval
)empty_list,if,eval
)remove_comments,write/local
(9)get_caster,entity_pos/eye,construct
read/local,eval
这个法术同样将代码体存放于渡鸦,实现最外层循环的效果;法术栈初始包含[起点,步长]一个2元素列表iota;每次执行读取栈顶的一个元素并判断其步长决定下一步执行1分20还是放置方块。
对于内层的1分20逻辑,由于栈内存在大量无关数据(上一步的递归生成物),使用托特会产生不必要的麻烦,因此改用伊里斯构造jump循环+大量Lehmer Code操作的方式完成(其实也是为了整活);如果实在需要托特也可以在内层代码通过“stack_len,last_n_list”来收集整个栈数据并后续处理,此处留作习题
可以看到,由于无效操作增加,单次运行可放出的方块总数也降低了。
但是,低效运行也是这个程序的特点之一:它将递归过程完整暴露在了法术执行栈内,使得玩家相比方案3可以更自由地控制代码的执行流程;并且深度优先的实现方式决定了栈内最大记录数据量不会超过(19旁路*层数+1当前),远小于方案1、2的广度优先路径。那么,只要我们对它进行一波魔改,把运算数在时间上分摊,就可以造出更大的门格海绵了(智将)
1.20法术环与戴森海绵
1.20版本的法术环有一个重大修改:媒质波在到达石板之时就会直接运行石板上的图案,因此我们可以构造如下结构的Quine专用法术环:
角落坐标->获取画框实体->读取内部核心代码->进入无穷quine eval循环;同时所使用的quine也需要相应改造:移除内部的最后一个eval,仅将下一步需要执行的代码放在栈顶,由法术环开启最后一步。
另外,具体到本次使用的法术来说,由于法术环可怜的施法范围,需要某些远程施法的手段以实现在任意位置放置方块;笔者使用了一个简单的卓越传送投递咒灵法术片段(不是,都有咒灵了为啥不直接拿循环咒灵开quine(那当然是为了浪漫了)):
// 输入:目标坐标
((str_TARGET)splat,wisp/self,entity_pos/foot,sub,teleport
wisp/self,entity_pos/foot,place_mageblock,wisp/consume),num_1,rotate,replace,print
get_caster,entity_pos/foot,num_1000,wisp/summon/ticking
法术环使用的完整法术如下:
// 法术环版
( stack_len,bool_coerce
( comment_1.do-menger
duplicate,num_1,index,num_1,greater_eq
( comment_split-menger splat
((vec_-1_-1_-1,vec_-1_-1,vec_-1_-1_1,vec_-1_0_-1,vec_-1_0_1,vec_-1_1_-1,vec_-1_1,vec_-1_1_1,vec_0_-1_-1,vec_0_-1_1,vec_0_1_-1,vec_0_1_1,vec_1_-1_-1,vec_1_-1,vec_1_-1_1,vec_1_0_-1,vec_1_0_1,vec_1_1_-1,vec_1_1,vec_1_1_1))eval/cc // center,step,jump,offsets
duplicate,bool_coerce
( deconstruct,num_64,swizzle,2dup // jump,offsets o,center,step,center,step
num_4,fisherman,mul,add
over,num_3,div,num_2,last_n_list
num_112,swizzle,over,eval
)
(mask_vvvv)if,eval
)
( comment_remote-put-block
num_0,index
((str_TARGET)splat,wisp/self,entity_pos/foot,sub,teleport
wisp/self,entity_pos/foot,place_mageblock,wisp/self,wisp/consume),num_1,rotate,replace,print
get_caster,entity_pos/foot,num_1000,wisp/summon/ticking
)if,eval
comment_2.loop read/local,charge_media/circle,comment_eval-next-step
)empty_list,if,eval
)remove_comments,write/local
(vec_100_150_-50,27)
read/local,eval
法术环召唤的咒灵均从玩家脚下出发,携带刚好够一次传送的媒质,传送到目标地点并放置方块后销毁自己(悲
结语
以上就是四种写回字的法术,显然它们都还有很大的优化空间,本文仅作思路分享与抛砖引玉之用,希望读者们都可以造出自己的巨构,在邪恶咒法师的领域更上一层楼。