跳至正文

计时器、插值及其他

适配版本 (1.4.4ver)

为了让弹幕的轨迹能够更加随心所欲,我们还需要补充一些基础知识。


计时器

上一章我们讲解了如何利用三角函数来计算发射向量以及如何修改弹幕的速度向量。但是,有时候我们会对弹幕的轨迹有着更高的要求,比如说随着时间的变化有一些不同的行为。那么如何测量这样一段时间呢?当然使用计时器啦~

但是我们这里所说的计时器不是真正的计时器,而是一个抽象的概念。既然我们知道在某些重写函数(比如AI)中,函数每秒钟会执行60次,那么我们可不可以利用这个60次呢?

比如说,如果我想让弹幕0.5秒钟以后速度开始急剧下降,那么我们是不是可以从这个函数执行了30次入手呢?

我们可以给自己的弹幕类定义一个成员变量,然后每次执行这个函数我们的变量都加一就行了。那么这个变量就叫做计时器变量

有两种方法可以实现这个计时器变量,一个就是真的定义一个成员变量,还有一个就是利用Projectile.ai数组。

不过我打算把这两个方法先放到后面介绍,我们现在其实有一个天然的计时器变量,那就是Projectile.timeLeft。弹幕的timeleft属性前面介绍过,是弹幕的存活时间。TR的内部判定是每次弹幕更新的时候,Projectile.timeLeft都减去1,当Projectile.timeLeft降到0的时候,弹幕就会被Kill,也就是消失。

可惜的是,Projectile.timeLeft是不断减少的,不过我们仍然可以用它来做一些事情,比如刚刚说的0.5秒后弹幕速度急剧下降。

为了达成这个效果,我们先要确定这个弹幕的最长生存时间是多少帧。

假设我这里是200,那么已知 \(0.5s = 60 \times 0.5 = 30\) 帧,我们需要在Projectile.timeLeft为170以下的时候将弹幕减速,代码看起来就像这样:

if (Projectile.timeLeft < 170) {
    Projectile.velocity *= 0.9f;
}

以上代码是写在ModProjectileAI重写函数里面,如果没有特别说明,默认所有代码都是在AI重写函数里面。

这样弹幕在前0.5s都是沿着直线匀速飞行,一旦越过0.5s弹幕就是每帧减速至90%。你可能觉得90%很少,但是因为减速是指数级下降,所以弹幕可以在很短的时间内停下。

推导
设弹幕速度大小为 \(x\),每帧速度是上一帧的 \(0.9\)倍,那么过了\(y\) 帧以后弹幕的速度应该是\[ x(0.9)^y\]此时弹幕速度小于 \(z\),可得方程 \[ x(0.9)^y = z\]解得 \[ y = \frac{\ln(\frac{z}{x})}{\ln(0.9)}\]设 \(x=100,z=0.001\),解得 \(y=109\),也就是哪怕弹幕速度是 \(100\),也能在1秒多的时间内停下来。这个公式的意义在于,你可以用它来估计弹幕减速大概会滑行多久。

除了最基础的判断弹幕经过时间以外,我们还可以利用取模(%百分号)去截取经过时间的周期。比如说,我想让弹幕每过1秒就换个方向运动,那么如何判定这每过一秒就可以交给取模操作了。当你对一个数取模的时候,比如说10模7,那么就是10除以7以后的余数,那么10模7就是3。如果是10模5,因为5能整除10,所以这里的余数是0,那么10模5就是0。

\[\{0, 1, 2, 3, 4, 5, 0, 1, 2, 3, 4, 5, 0, 1, 2 ,3 ,4, 5 \cdots \}\]

应用到Projectile.timeLeft上,如果说我们检测到Projectile.timeLeft模60是0,那么我们就可以知道,这个弹幕距离上次模60是0,已经经过了60帧。那么我们可以利用这个周期性质,每次Projectile.timeLeft模60余0的时候都随机旋转一次弹幕速度,就可以实现这个效果了。

if (Projectile.timeLeft % 60 == 0) {
    Projectile.velocity = Projectile.velocity.RotatedByRandom(3.14f);
}

插值

插值听上去很高大上,但是其实并没有。在弹幕AI里面,我们可以这么理解插值:假设我们已知弹幕已经经过了它的存活时间的一半,我们就可以说,这个弹幕的进度已经达到一半了,或者用 \(t=0.5\) 来表示,这个弹幕已经只剩半条命了。此时的这个 \(t\) 就是弹幕的一个进度插值,进度插值的范围在 \([0,1]\) 之间。

那么我们为什么要把弹幕的进度化为 \([0,1]\) 之间的小数呢?这其实很像单位向量,既然我们可以直接操控向量,单位向量为什么还如此重要呢?所以插值其实就是相当于把弹幕的进度单位化了。假如我们想让这个弹幕从出生到消散速度是匀速减少,而不是像之前那样直接暴力乘以0.9减速,那么我们可以这么写代码:

Vector2 unit = Vector2.Normalize(Projectile.velocity);
// 弹幕的速度会匀速从10减少到0
Projectile.velocity = unit * (1.0f - factor) * 10f;

等等,我貌似还没说 \(factor\) 是什么,由于Projectile.timeLeft,是逐步递减的,所以我们可以这样去计算这个插值

// 这里的200就是弹幕的最大存活时间
float factor = (200f - Projectile.timeLeft) / 200f;

当弹幕刚出生的时候,插值为 \(0.0\) ,弹幕快要死的时候插值接近 \(1.0\),这是一个线性的插值。所谓的线性,意思就是每增加一个单位,这个插值的大小总是增加一个常数。比如说这个弹幕的进度插值,每经过1帧插值大小增加 1/总存活时间 那么多,而因为总存活时间是不变的,所以这个值是个常数。

有线性插值肯定就有非线性插值啊,比如说,我们把 \(t\) 变成 \(t^2\) ,这个插值用函数图像画出来是这个样子的

可以观察到,这个函数的增长趋势是先慢后快,如果我们应用到刚刚那个弹幕上会是什么样呢?速度的减少也会先慢后快

// 这里的100就是弹幕的最大存活时间,我调小了
float factor = (100f - Projectile.timeLeft) / 100f;
factor *= factor;

Vector2 unit = Vector2.Normalize(Projectile.velocity);
Projectile.velocity = unit * (1.0f - factor) * 10f;

那么有没有先快后慢的呢?当然有,就是 \(\sqrt{t}\):

// 这里的100就是弹幕的最大存活时间,我调小了
float factor = (100f - Projectile.timeLeft) / 100f;
factor = (float)Math.Sqrt(factor);

Vector2 unit = Vector2.Normalize(Projectile.velocity);
Projectile.velocity = unit * (1.0f - factor) * 10f;

其中红色是 \(t^2\),蓝色是 \(\sqrt{t}\),可以看出他们的增长趋势是相反的,在游戏里对比一下这两种插值的区别吧。还有一个很著名的SmoothStep函数:\(3t^2-2t^3\)。


正确的计时器

由于使用Projectile.timeLeft作为计时器有很多局限性,我们需要一个正确的定义计时器的方式

类内成员

直接定义一个私有变量即可:

简单,有效,当然也可以把这个变量的初始化放在SetDefaults里面,无论如何,你需要给它一个初始值。

但是这样做的缺点也很明显,就是以后可能会为了联机同步问题而头疼(如果你以后真的要做联机Mod)。

AI数组

Projectile有一个属性非常奇怪,那就是Projectile.ai,还有它的孪生兄弟Projectile.localAI

通过VS你可以知道Projectile.ai是一个float所组成的数组,但是这个属性具体有什么用却不太清楚。

其实,如果你不套用任何原版的aistyleaitypeProjectile.aiProjectile.localAI什么作用都没有,他们是保留的成员变量,具体有什么用要取决于程序员,也就是你。 值得一提的是,Projectile.aiProjectile.localAI数组都只有三个元素,并且Projectile.ai可以在弹幕发射的时候赋值,默认值都为0,如下图:

而我们就使用Projectile.ai[0]数组来实现计时器,这个是比较推荐的做法。

使用的时候一定要注意,不要设置任何原版的aistyleaitype,因为带有原版ai的弹幕也会操作Projectile.ai,如果冲突了就不好了。

Projectile.aiProjectile.localAI的区别在于,前者在联机模式会与服务器同步,而后者则不会,那么对于单机mod来说,两个属性没有任何区别。


注意extraUpdates

Projectile.extraUpdates 这个属性可以说是非常有用了,如果我们要做一个速度非常快的弹幕(比如高速子弹),有一种方法就是把这个弹幕的velocity的大小调的非常大,这样做的结果就是,当速度过快的时候子弹甚至能穿墙,而且,速度很大的子弹很难进行追踪等操作。

这时候我们就要从另一个角度想,既然我们不好把速度加大,那能不能让弹幕每一帧更新多次呢?

诶??我们不是说过弹幕每秒只更新60次吗?这其实是不一定的,通过在 SetDefaults 里将 extraUpdates 这个属性设为正整数,我们可以让弹幕每帧多更新几次,设为多少就多更新几次,默认为0。

这样,我们可以让弹幕每帧多更新5次,达到看上去弹幕是以6倍速度飞行的效果,但是要注意的是,因为弹幕更新速度变快了,所以计时器等操作也变快了,弹幕的消亡时间也变短了。具体的倍数就是 1 + Projectile.extraUpdates,或者你可以用 Projectile.MaxUpdates 来获取。所以原来的一秒是60帧,现在是360帧了。

通过 extraUpdates 所达成的速度增加比单纯的提升速度看上去要顺畅的多,所以要用到高速弹幕(17以上)的时候,最好是用 extraUpdates 来实现。

暗影射线和磁球的射线就把这个值设为了100,从而达到瞬发的效果。


实战

为了更好地说明计时器的作用,我们不如做一个简易的炮台吧。炮台由两个弹幕组成,一个是用于瞄准,寻敌,控制射速的控制器,另一个是发射出去的子弹本身。

可以看到,图中的炮台随着时间的推移射速越来越快,同时,射出的弹幕也会回到玩家身边。我们首先来实现一下射速越来越快的这个功能。

我们首先肯定需要一个计时器和一个计时器的进度插值,我们这次选用Projectile.ai[0]作为计时器的变量,但是这个Projectile.ai[0]写起来很麻烦,而且还不好看懂,不符合我们的简介、清晰代码的风格。

不过不要担心,我们有一个非常强大的工具可以用,那就是之前介绍过的Property:

// Timer1是类的成员,所以要写在正确的位置
public int Timer1 {
    get {
        return (int)Projectile.ai[0];
    }
    set {
        Projectile.ai[0] = value;
    }
}

通过Property,我们可以把所有对Timer1的赋值都替换为对projectile.ai[0]的赋值,而所有对于Timer1的读取都别替换为对projectile.ai[0]的读取。注意到ai数组的元素是float类型的,但是我们的Property却是int类型的,因为我想让计时器用起来像是int类型。

这样原先的代码:

Projectile.ai[0]++;
float factor = Math.Min(1.0f, Projectile.ai[0] / 300f);

就可以变成:

Timer1++;
float factor = Math.Min(1.0f, Timer1 / 300f);

是不是感觉意思清晰很多?这样我们就可以有一个计时器和一个插值了,注意到我对这个插值用了Min,因为弹幕的存活时间要比300长,而我希望弹幕的射速达到300以后就封顶了,剩下的300帧都会以最大射速进行射击。这时候这个插值就是对于射速封顶的插值,而不是对于弹幕存活的插值了。

那么我们如何控制射速呢?首先我们先通过插值得到射击间隔:

// 这里的射击间隔是这样定义的,随着时间增加射击间隔从10+20线性变到10+0
int shootCD = (int)(10 + (1 - factor) * 20);

这是一个线性变化的射击间隔,10是基础间隔,而剩下20我们乘以插值,就能得到一个30变化到10的线性函数。

接下来我们只要让弹幕以这个射击间隔射出即可,我们可以用Timer1 % shootCD == 0来确定射击间隔。但是这样做有一个问题,你的shootCD一直在变,但是你的Timer1却是匀速增加的,可能刚刚满足Timer1 % shootCD == 0下一帧又满足这个条件了。。。

于是会导致射速并不均匀,于是我们可以再来一个计时器Timer2(使用Projectile.ai[1]),这个Timer2的行为和Timer1有很大不同。Timer2会从0开始增加,当Timer2的值大于shootCD的时候,我们就可以发射弹幕了。之后,我们把Timer2重新变回0,让它重新开始增加。

这样就是一个很稳定的周期计时器了:

Timer2++;
if (Timer2 >= shootCD) {
    Timer2 = 0;
    // 射出弹幕
}

寻敌

接下来我们讲一下寻敌,我们希望这个炮台优先选中离玩家最近的敌人作为目标(为了保护玩家嘛)

所谓寻敌,就是找到指定的敌对NPC,至于怎么指定就要由你自己决定了。比如说我想找到离玩家最近的一个敌对npc,那么条件就是活着敌对并且距离最短的NPC。

这样我们就把问题确定下来了,下一步就是怎么解决这个问题。我们首先要知道,TR里所有活着的npc(有时候也有死了的)都会被存储在一个地方,那就是Main.npc数组。

所以我们就可以利用之前C#教程的那个foreach语句去遍历所有NPC了。

为了找到距离最近的那个,我们可以这样想:假设我们找到了一个敌对NPC,它与玩家距离为 \(300\),而且是当前最近的,如果下一个敌对NPC距离是 \(200\),显然 \(200\) 比 \(300\) 小,所以这时 \(200\) 就是最近的,如果新的NPC是距离是 \(301\),那么我们就忽略它。

写成代码就是这样

NPC target = null;
// 最大寻敌距离为1000像素
float distanceMax = 1000f;
foreach (NPC npc in Main.npc) {
    // 如果npc活着且敌对
    if (!npc.active || npc.friendly) continue;
    // 计算与玩家的距离,可以改成与弹幕的距离
    float currentDistance = Vector2.Distance(npc.Center, player.Center);
    // 如果npc距离比当前最大距离小
    if (currentDistance < distanceMax) {
        // 就把最大距离设置为npc和玩家的距离
        // 并且暂时选取这个npc为距离最近npc
        distanceMax = currentDistance;
        target = npc;
    }

}
// 如果找到符合条件的npc
if (target != null) {
    // 你的骚操作写在这里
}

然后我们只要以这个速度射出弹幕就好了:

Projectile.NewProjectile(Projectile.GetSource_FromAI(), Projectile.Center, Vector2.Normalize(target.Center - Projectile.Center) * 速度,
    ModContent.ProjectileType<你的弹幕类名>(), 100, 5f, Projectile.owner);

npc的条件你可以自己更改,比如加一个限制条件npc的生命上限必须大于5,这都比较自由。

我知道有人对那个粒子效果感兴趣,这是这个炮塔AI的代码

public override void AI() {
    Main.dayTime = false;
    Main.time = 0;
    Projectile.velocity *= 0f;
    for (float r = 6.28f; r > 0; r -= MathHelper.TwoPi / 10f) {
        float r2 = (float)Math.Cos(Projectile.ai[0]);
        for (int i = -1; i <= 1; i += 2) {
            Vector2 pos = Projectile.Center +
                new Vector2((float)Math.Cos(r + r2), (float)Math.Sin(r + r2)) * r * 10f * i;
            Dust dust = Dust.NewDustDirect(Projectile.position, Projectile.width, Projectile.height
            , i < 0 ? MyDustId.RedTorch : MyDustId.DemonTorch, 0f, 0f, 100, default(Color), 3f);
            dust.noGravity = true;
            dust.velocity *= 0;
            dust.position = pos;
        }
    }
    Timer1++;
    float factor = Math.Min(1.0f, Timer1 / 300f);
    if (factor < 0.2f) return;
    Timer2++;
    int shootCD = (int)(10 + (1 - factor) * 20);
    if (Timer2 >= shootCD) {
        float maxDis = 1000f;
        NPC target = null;
        // 选取最近npc,如果target是null说明没有临近的敌人
        foreach (var npc in Main.npc) {
            if (npc.active && !npc.friendly && npc.value > 0 && !npc.dontTakeDamage) {
                float dis = Vector2.Distance(npc.Center, Projectile.Center);
                if (dis < maxDis) {
                    maxDis = dis;
                    target = npc;
                }
            }
        }
        if (target != null) {
            Projectile.ai[1] = 0;
            Projectile.NewProjectile(Projectile.Center, Vector2.Normalize(target.Center - Projectile.Center) * 13f,
            ModContent.ProjectileType<ProjProj>(), 100, 5f, Projectile.owner, 0, Main.rand.Next(2));
        }
    }
}

如果要实现射出弹幕绕回玩家身边的这个效果,以下是核心代码:

Player player = Main.player[Projectile.owner];
Vector2 unit = Vector2.Normalize(player.Center - Projectile.Center).RotatedBy(1f);
// 这里的factor也是一个插值哦
Projectile.velocity = unit * factor * 50f;

把弹幕到玩家的向量向外旋转一定角度以后赋值给这个弹幕,就能实现弹幕圆周渐进的效果,这个旋转角越大弹幕就会离玩家越远。

至于怎么让弹幕一段时间以后才绕回玩家身边,这个运用计时器就能轻易实现。这个弹幕的源码会被上传到TemplateMod2的源码仓库,如果实现起来有问题可以去看一下源码的YinYangTower弹幕。

《计时器、插值及其他》有2个想法

  1. Pingback: Global系列-全局操作 - 裙中世界

发表回复