Blog

【音乐发布】Flueve~假装是为小提琴所糊的协奏曲~

Sound Cloud

这是我自学七年以来,头一次写出的自满的作品。
从构思开始,总共用了半年,呕心沥血,参(chao)考(xi)了无数曲目所作。
我觉得它在我的人生中是具有里程碑意义的作品,也因此特地发文。
标题“假装”是指协奏曲,虽然确实听起来像是小提琴与乐团协奏,但其实并没有满足协奏曲的各种基本要求。

该曲将会和“疯帽子茶会”合作推出人声版,(如果有任何人看到了这篇文章的话)敬请期待。

为什么我玩STG得不到快乐

三天前,我接触了Zeroranger,这是一款好游戏,我十分兴奋,也玩得十分开心。而今天,我从账户中彻底移除了这款游戏,在还没有通关的情况下。
因为在游戏的最后,我陷入了深深的自责、痛苦与绝望,对我而言继续玩下去已经得不到任何快乐了。在最后一次挑战最终关卡失败后,我为了不让自己陷入更加负面的状态,选择彻底离开这款游戏。
而那之后,我开始思考一件事情——为什么一直以来我玩STG都得不到快乐?
七年前,我第一次接触东方正作,之后也去玩了其他各种类似的弹幕纵版卷轴STG。虽然接触得很早,但是我并没有长期、集中或者刻意地练习过,水平似乎从来没有什么明显的长进,以东方正作为例,大概是在需要少许背板、少量失误(主要指抱B撞)的情况下可以通关N难度的水平。CAVE社的作品的话单币大概能到四面,当然大复活比较特殊,巅峰的一次A机一币通关。现在想来,那一次大概是玩STG以来最为高兴的的一次,毕竟多次神穿让结果超出了个人水平。
除了那一次意外,我对STG再也没有什么好的印象。能记起来的,只有无数次后悔、遗憾和功亏一篑。印象最深的一次大概是东方风神录。我在连续尝试了三次,每次都将更多的残机带入麻将山的情况下,仍然倒在神奈子的威严之下。当时气急败坏的我写了一篇文,批判麻将山难度太高,难度梯度陡峭。
为什么我玩STG一直得不到快乐,其中一个重要的原因是,单局游戏的流程太长了。这种长不仅体现在时间上,更是因为高度集中的注意力会消耗的更多的精力——我很少连续玩2h以上的stg。
而STG是一类十分讲究规划的游戏——游戏过程中你会有数个残机,每个残机带有数个Bomb(以下按惯例简称B),你最多总共可以使用的B数是你的资源,即是你的容错次数。而玩家要做的也就是在资源消耗完之前通关。这种仅以通关为目的的打法又称混关,正常的流程是,玩家需要通读全文,确认哪些地方是自己的难点,各需要用掉几个B,做好规划,避免失误。当然失误总是难免的,因此规划也得预留一些防止失误的B。然而这里有一个问题——如果你未能在MISS(包括MISS的决死时间)之前按下B,你就连带着剩余的B一起丢掉了这个残机,又称“抱B撞(弹)”,这种失误会让你一次失去多个资源,是必须要避免的情况。
因此,在正常的混关流程中,一旦抱B撞,几乎就等于失去了通关的希望,难度大大增加,这种情况除非你之后数次神穿(等于是该用B的地方没用,省下来一个),否则都可以直接重来——换言之,功亏一篑。对我而言,这种功亏一篑带来的挫败感极强,基本上碰到这种情况后,我会直接关闭游戏而不是重来——因为心情十分糟糕。这也是我没有大段的玩STG的时间的原因。当然东方正作里有一个例外,即采用了大量checkpoint,彻底改变了传统逻辑的东方绀珠传。每次只需要考虑一小段难点怎么过,可以无限尝试。这种模式十分没有压力,让我最终花费数小时通关了H难度。
我不知道是因为过于敏感还是怎样,这种“差一点成功”对我的打击巨大,让我陷入数小时的自责中无法自拔:“如果当时早一点按下B就好了”。而如果我先经历一次“差一点”,再去打下一次,成功了,我也没有任何的成就感——因为我觉得这是我应得的,甚至觉得没有一次就成功本身即是一种耻辱,而成功本身也就不值一提了。
在这种心态下,我渐渐地很少玩STG了。在过去的三年内,我只玩过怒首领蜂大复活,这是一款自动B的STG,因此不存在抱B撞,我只需要专注于躲避子弹即可。最新的东方正作我也只玩了只有前三面的试玩版,之后的正式版我已经没有勇气去面对了。
然后,时间来到了2018年10月8日,我购买了Zeroranger。这是一款制作十分精良的STG,除了一些小毛病以外我觉得处处的设计都值得打高分。一开始我也玩得很开心,因为难度开头的难度大概略低于我的水平,恰好能在游戏过程中略微收获一些成就感。然后,对我来说2-4的地狱来了,我重试了无数次,都难以击破这个boss,仿佛游戏的难度瞬间升高数个等级,再加上推荐的人却都表示这里并不难,我开始陷入自我怀疑。
中间休息了一天,而最后,在我的心情几乎到了低谷的时候,我终于发现了问题所在——武器3也就是蓄力武器的消弹能力远比我想象得要强大。我之前从来没有考虑过使用它,因为在我的概念里,消弹在STG里是禁忌的存在,因为破坏了弹幕的完成性,弹幕的威胁也就不复存在了。我原先听说过3能消弹,但我擅自以为只能抵消一个并且有很长的CD,所以一直没有考虑用,而是照旧按我的理解,底力硬扭。然而实际上,3可以抵消大量的子弹,并能够免死一次并提供数秒的无敌,虽然之后会过热,但是恢复也很快。发现这一点并没有让我很兴奋,相反,我很沮丧,因为这不是传统STG了,而认识到了这一点的我,也可以很轻松地过2-4。一直以来攀登的山峰轰然倒塌,而在山上本已经失落的我更因为失去目标而伤心到了极点。
我终于进入了最终战,这是一个近战STG,大概只要躲避弹幕并到达某个地点即可。虽然没有什么的难的,但我仿佛已经无心游戏,虽然带着五币化成的五残进入,还是失败了。之后,我陷入了彻底的绝望,从账户中移除了这款游戏,我再也不想见到它。
Zeroranger是一款好游戏,而我最终并未从中获得快乐,着实可惜。一来是我过于自大,自以为对STG足够了解,对这个并不是那么传统的STG中未知的部分进行擅自的脑补,二来是我太菜,无法顺利扭过2-4。如果我更强一点,或这个更菜一点,也就是从来没玩过STG,我想我应该都能很好地享受这款游戏。
结束之后,我一直在反思——为什么我从STG中获得不了多少快乐。挑战高难度的游戏的快乐多源于成功后的成就感,然而STG的成就感对我来说仿佛是遥不可及的东西——很多时候即使通关了,我也并没有享受到快乐,但如果失败了,那等待我的一定是懊悔和自责。这种不对称决定了我从一开始就无法好好地享受STG。
而这种不对称的原因,则是一种自大。我无法正确认识我的失误,我盲目地觉得自己是很强的,对自己抱有十分高的期望,因此一丁点的失误都会对我造成沉重的打击。这种变态式的苛求让我完全无法心平气和地进行游戏,练习基本功,慢慢地提升自己——因为STG的技术提升需要长期的练习,在某一次游戏过程中获得心流体验是不可能的。
虽然我失去了一款好游戏,但是我觉得还是十分值得的。这么多年来我第一次静下心来考虑这个问题,而之后我要做的,也就是放弃期待,承认自己能力的不足。当然这样的事情也不是一蹴而就的,我甚至觉得我需要强行给自己灌输“自己很菜”的心理暗示才能抵消掉潜意识中对自己的过高评价。
当然,我的抗压能力的匮乏也在玩STG的过程中体现得淋漓尽致。换个角度来说,玩STG也是一个锻炼抗压能力的办法也说不定。

一个小坑

这两天碰到了一个小坑,起因是接手了一个数据 pipeline,然后我需要加一点新的功能。
这个功能需要取原来的表 left join 另外一张表取一个字段,然后有一个 where 条件是需要判定时间段是否符合,即 date between start_date and end_date。然而不知道同事脑子里装的什么,原来的表是把日期按‘yyyyMMdd'的格式存成字符串。
说不出话,于是面向爆栈编程,看到两种转换方法:

cast(concat(substr(STR_DMY,1,4), '-', substr(STR_DMY,5,2), '-', substr(STR_DMY,7,2))as date

from_unixtime(unix_timestamp('20180901' , 'yyyyMMdd'))

这还用说么,当然是第二种优雅。随后是漫长的 map reduce 过程。由于两张表都巨大无比,一个2B条记录一个10M条记录,运行时间高达两个多小时。
好不容易跑完,发现一个问题——新做的表比原来的表记录要少。经过漫长的排查(毕竟运行时间巨长),我终于发现,有一部分uid的所有记录都丢失了。
查其时间段发现,大概长这样—— (2018-08-01,2018-09-01) (2018-09-02, 2099-01-01)。看起来没什么问题,然而那是对于使用date类型的正常人而言的。
from_unixtime(unix_timestamp('20180901' , 'yyyyMMdd')) 输出的类型是 datetime 而不是 date,这就导致了

from_unixtime(unix_timestamp('20180901' , 'yyyyMMdd')) between '2018-08-01' and '2018-09-01' 
from_unixtime(unix_timestamp('20180901' , 'yyyyMMdd')) between '2018-09-02' and '2099-01-01' 

全部返回false。
至此,破案。

这是个小坑,但是浪费了我挺长时间,算是留个纪念,也算我求你们——别特么用字符串存时间了!

再谈两种兴趣爱好

曾经我和别人讨论过这样一个问题:“烹饪、运动、园艺之类现实世界的兴趣爱好,是否要比数学、音乐、哲学等精神世界的兴趣爱好更具有生活感?”“具有生活感”可以理解成享受生活,然而当时的讨论并未达到一个较为明确的共识。

如今,我对这个问题有了新的认识和思考,简单分享一下:

首先,原问题试图将兴趣爱好分为两类——一类看起来与生活密切相关、更具体,而另一类看起来多活跃于精神世界、更抽象。这个分类我现在看来并不合理,因为这两个类别并不是完全独立、非此即彼的——尤其比如音乐,虽然音乐很抽象,但音乐活动是不能离开听觉体验的。当然,寻找一套二元的分类方法并不现实,这里我打算换一种分类方式:结果导向和过程导向。

拥有结果导向的兴趣爱好的人会希望在这方面不断地前进,往往有着更明确的目标。比如数学、哲学之类的理论,爱好者们会希望学习研究更多的东西,不断寻找新的知识、技能,不断地挑战自己。最终乐趣所在往往在于达成目标的成就感。这类爱好往往我们会用动词“学习”或者“练习”来描述,比如学习吉他,练习射箭等等。

而拥有过程导向的兴趣爱好的人更注重兴趣爱好活动中的过程体验。作为目标的结果往往是一种假想性质的方向,而并不会太多地影响人在该活动中获得的愉悦。这一类兴趣爱好往往不需要太多变数,每周或者每月去体验一次同样的事情也十分满足了。许多户外运动大多是过程导向的。这里我想举一个例子,前几天我跟别人提到过可以搞一个电动车,骑车一路到海边看海。然而他表示魔都的海根本没什么好看的,我当时这么回应:“你弄错了一件事情,骑车看海这件事情更重要的是骑车去看海的过程而不是看海的结果。”

然而有一点我想提起各位注意——过程导向和结果导向这个分类针对的是不同的人对待它们的兴趣爱好的具体态度,而不是脱离了行为人去描述兴趣爱好本身。比如登山,有些人享受的人户外运动的本身,只要是山都可以爬,一个月来个一两次就很满足——这是过程导向;而另外一些人对待登山这件事情要更认真,他们不断地挑战新的山峰,可能最终目标是珠穆朗玛——这就是结果导向。

现在,回到开头的问题来,我试图描述的两类兴趣爱好的区别应该这么说:“过程导向的兴趣爱好要比结果导向的兴趣爱好更有生活感”因为我理解的所谓生活感强调的是享受生活的过程——除非你想享受生活的结果也就是死亡。用这样一种标准去分类,我觉得这个结论还是十分显而易见的——不过说不定这个总结也太自然了以至于没有太大的参考价值了。

WaveFunctionCollapse源码阅读笔记

前言
去年,牛关有一个地牢生成的问题,在这个问题下的回答提到了一个 WaveFunctionCollapse 的 repo。我和认识的许多人在看到这个 repo 的介绍,尤其是电路板那里,无不眼睛一亮。它到底是如何运作的?自看到这个问题的第一天我就想找个机会读一下它的代码。

概览
整个代码行数不多,接近四位数。主要的类有三个

class            Model ~220行
class OverlappingModel ~230行
class SimpleTiledModel ~270行

其中后面两个继承自前者。 Model 类是整个程序的核心部分,有关生成的代码都在这里面。 后两者提供的是实用上的功能补全,其中 OverlappingModel 自行处理给定的样例,稠密地划分许多 pattern ,并自动计算它们之间的连通性;而 SimpleTiledModel 则是读取给定的数个 pattern 以及描述了它们之间连通性的 xml 文件。它们处理好数据后,将数据赋给 Model 类中一些 protected 属性的域(或者说成员变量),随后就是调用 Model 类的相关流程去生成最终模型啦。

输入
现在我们来看看这些 protected 属性的域。

protected bool[][] wave;
protected int[] observed;
protected bool periodic;

protected int FMX, FMY, T;

protected int[][][] propagator;
protected double[] weights;

protected Random random;

wave 是程序的一个核心,但是它是 protected 属性是为了输出而不是输入。observed 同样。 periodic 描述的是最终模型的循环连通性,在此也不提(而 Model 类型也没有用到)

FMX 和 FMY 描述的是最终模型生成的大小,而 T 是可用的 pattern 的数量。这三个变量是最基础的三个。

propagator 描述了 pattern 之间的连通性,它的大小是 4 × T × 不确定。其中 4 显而易见地代表四个方向, T 则是对所有 pattern 的遍历,最后一个维度即这个 pattern 在某个方向上所有可能连接的 pattern 的列表。

weights 描述了 pattern 的权重。权重越大的 pattern 出现的几率越高。在 SimpleTileModel 中,权重是指定的,而在 OverlappingModel 中,权重根据出现次数计算,出现得越多,权重越大。下文中 weights 简称为 w。

random 则提供了一个随机器,没什么好说的。

核心方法
Model 的 run 过程如下

if (wave == null) Init();

Clear();
random = new Random(seed);

for (int l = 0; l < limit || limit == 0; l++)
{
bool? result = Observe();
if (result != null) return (bool)result;
Propagate();
}

return true;
这里可以看到它描述了生成的全过程,涉及了四个方法 Init(), Clear(), Observe() 和 Propagate()。

Init() 和 Clear()
字面意思的初始化,联合 Clear() 方法来看,它们总共干了这么几件事:

生成 大小为 (FMX * FMY) * T 的 bool 数组 wave,初始全部为 true; wave 数组描述的是最终模型的某个点在目前的阶段是否可以采用某一个 pattern,最开始当然是都可以啦。
生成 大小为 (FMX * FMY) * T * 4 的 int 数组 compatible, compatible[i][t][d] 值为 i 位置采用第 t 个 pattern 在 d 这个方向上可用的 pattern 数,从 propagator 中获取。
计算大小为 T 的 weightLogWeights 数组(以下简称 wLW ),如字面意思 。

wLW[t] = w[t] * log(w[t])

计算 sumOfWeights ,即对所有 w 求和,生成 sumsOfWeights 数组(下称sOW),全部初始化为 sumOfWeights 。
计算 sumOfWeightsLogWeights ,对所有 wLW 求和,生成对应数组(下称 sOWLW),同样全部初始化为求和结果。
计算 sumOfOnes,好吧,这玩意就等于T,同样生成数组(下称 sOO),全部初始化为T。
计算初始熵,

startingEntropy = Math.Log(sumOfWeights) - sumOfWeightLogWeights / sumOfWeights

同样生成对应数组。
生成二元数对的栈 stack。

Observe()
初始化完成后,我们就进入了 Observe() 和 Propagate() 的循环中。

Observe() 首先挑选最终模型的某一个点,要求它必须有多个可选 pattern ,即这个点的 sOO >1,然后要求这个点的熵最小。在获取最小的时候,不会用直接的熵进行比较,而是还会加上一个 0~10^(-6) 的噪音。

然后,如果没有找到这样的点,说明最终模型已经收敛,返回 true 咯~

对于选择的点 p ,首先选择一个它可用的 pattern t, 依赖于 w, 如上文所述 w 越大选到的可能性越大。此时也就意味着将这个点收敛到这个pattern t。

随后对于所有非 t ,调用 Ban(p, t) 方法。

Ban(p, t)
Ban(p, t) 方法意味着 p 无法选择 t,要将它从各种 sumOf 中去除。具体的计算方式如下,看一看就好,更复杂的原理参见论文。

double sum = sumsOfWeights[i];
entropies[i] += sumsOfWeightLogWeights[i] / sum - Math.Log(sum);

sumsOfOnes[i] -= 1;
sumsOfWeights[i] -= weights[t];
sumsOfWeightLogWeights[i] -= weightLogWeights[t];

sum = sumsOfWeights[i];
entropies[i] -= sumsOfWeightLogWeights[i] / sum - Math.Log(sum)

此外,每 Ban 一对 (p, t) 就把他加入栈中, Propagate() 中会用到,同时它也会再次调用 Ban()。

Propagate()
Propagate() 是一个清栈的过程,所做的事情也十分简单:只要栈中有东西,就取出来。对于这样一个数对 (p, t) 意味着 p 点无法选择 pattern t,于是就要对应地处理 compatible 数组。由于 compatible 记录的是某个方向可以选择的 pattern 数,于是考察 p 临近的四个方向,如果有任何一个点对于某个 pattern 的 compatible 数为0,即说明它无法再选到这个 pattern 了,于是再次调用 Ban 方法。就这样,循环直至栈清空。

就这样不断循环,直到所有的点收敛到某个固定的 pattern 上。若发现某个点无法选到任何一个 pattern ,那就从头来一遍生成过程。在 Main.cs 中可以看到,它会进行数次尝试。

总结
总的来说,这个算法的原理还是非常容易理解的,基本上是根据既定规则生成模型,只不过在选择 pattern 时参考了波函数塌缩,提供了非常随机化的结果。公式的部分应有更为详细的论证过程,可以在论文中找到,此外论文中也提供了大量更复杂的实验结果。

WFC 这个模型其实非常抽象,可以用来生成很多东西。原 repo 下的 Notable ports, forks and spinoffs 板块提供了许多相关 repo ,比如这个通过相同原理来生成诗歌的。更多的用途就等待你去发掘啦~

你的游戏中有没有可以用到它的地方呢?