跳至正文

弹幕&向量基础

在学习向量以及计算几何的知识之前,最好能先创建一个自己的弹幕。这样我们可以通过这个弹幕观察到数学是如何发挥作用的。


弹幕基础

什么是Projectile

Projectile的中文意思是抛射物,简称proj,也就是我们常说的弹幕。在泰拉瑞亚中,弹幕就是发射出去的那些东西,但是其实有一些武器本身就是弹幕,这个等我们讲到第三章的时候会涉及。


创建基本弹幕

在创建弹幕之前,我们需要在VS中创建一个文件夹,并且起名为Projectiles,然后给你的弹幕起一个英文名字作为文件名,起中文名作为文件名也没关系,但是要保证类名和文件名一致。

先准备一个弹幕的贴图,尺寸不要太大,跟物品一样是透明背景,并且把名字改成弹幕类名.png

右键Projectiles文件夹,选择添加新建项,名字和你的弹幕一样的cs文件。然后随便选一个之前做过的Mod物品的代码复制过来。把类名改成你的弹幕类名,然后把继承改为ModProjectile,最后把重写函数都删掉,命名空间也要改好,这样你就得到了一个ModProjectile类。这些基本的创建流程以后教程就不会讲了,如有需要自行参考TemplateMod2源码或者之前的教程。

比如我的是这样:

using Microsoft.Xna.Framework;
using System;
using TemplateMod2.Projectiles;
using Terraria;
using Terraria.ID;
using Terraria.Localization;
using Terraria.ModLoader;

namespace TemplateMod2.Projectiles {
    public class ProjProj : ModProjectile {
    }
}

因为这是一个弹幕,所以需要让这个类继承ModProjectile这个基类,才能拥有弹幕的属性以及行为。

接下来你要在里面写SetStaticDefaults这个重写函数来给弹幕命名,当然,弹幕就没有物品说明了,所以不需要Tooltips

弹幕基本属性


接下来就是弹幕的基本属性部分,它的属性跟物品的属性有很大的不同,但是和物品属性一样,都是写在SetDefaults里面。

首先我们来看一看这两个基本属性:

// 弹幕判定体积的宽度
projectile.width = 8;
// 弹幕判定体积的高度
projectile.height = 8;

注意这里的widthheight都是弹幕碰撞体积的高度和宽度。讲到这里我要说一下,如果你有下载之前教程中的TR贴图包,你会注意到那些弹幕贴图很多都是以头朝上的格式绘制的,这是为什么呢?

TR弹幕是按照碰撞体积(Hitbox)来判定伤害和碰撞的,一般来说,我们都把碰撞体积设置的比较小,这样弹幕能在视觉上合适的时候造成伤害。TR中碰撞体积是从图片左上角开始延伸的,如图所示

所以这时候碰撞体积就比较接近真实的伤害位置,如果你横着画:

伤害位置就不对了,所以一般来说,弹幕最好头朝上画,除非你使用Draw自己绘制弹幕,不过那是第三部分的内容了。你当然也可以把碰撞体积设为跟图片一样大,但是那样那你很有可能发射不出去(比如站在地上的时候弹幕碰撞体积大于人物,导致弹幕刚发射出去就判定碰到物块了),或者弹幕在很奇怪的地方造成伤害。

接下来是scale属性,这个才是用来放大弹幕的:

// 连贴图和碰撞体积一起放大的倍数
projectile.scale = 1.5f;

不过……碰撞体积位置也会变。所以暂时还是用无贴图的弹幕把╮(╯▽╰)╭,方法是用透明的图片。

如果你想让这个弹幕能对敌方造成伤害,需要把friendly设为true:

// 是否对敌对NPC造成伤害
projectile.friendly = true;

如果你想让这个弹幕能对友善NPC以及玩家造成伤害,需要把hostile设为true:

// 是否对玩家和友善NPC造成伤害
projectile.hostile = true;

两个属性可以共存!你还可以设置弹幕的伤害类型,比如近战、远程、魔法(享受魔法吸血),能够得到这种伤害的特殊效果以及伤害、暴击率增幅:

// 弹幕的伤害类型
projectile.melee = true;
// projectile.ranged = true;
// ...

ignoreWater这个属性可以决定弹幕在水里的时候会不会减速,默认false是会的

此外,你还需要给弹幕设置消散时间,不然弹幕会一直存在,消耗很多内存和CPU资源,造成游戏卡顿。这时候我们就可以用

// 弹幕的消散时间,同样是60帧 = 1秒,我这里设置了10秒后消散
projectile.timeLeft = 600;

这个消散时间并非是绝对的,如果你修改了弹幕的更新速度,消散时间也会变化。

tileCollide属性控制了弹幕能不能穿墙,false就是能,默认是true

接下来是一个很重要的属性,决定了这个弹幕附带原版AI特性的类型:

// 泰拉之刃的AI类型,此外还有很多TR内置类型,但是本教程关注于自己写的AI类型,也就是值为-1的情况
// 所以你要问我哪个aiStyle对应哪种弹幕,我只能说看源码去吧
// 但我可以告诉你抛物线状弹幕可以设为1
projectile.aiStyle = 27;

这个教程不会去研究每种aiStyle都对应着什么样的行为,而是教大家怎样写出符合自己想法的弹幕,所以不会去关注这一部分。

如果你想让这个弹幕完全模仿一种弹幕的AI,可以用这个属性:

// 让这个弹幕完全继承一种弹幕的AI,后面填弹幕的ID就行了
// 这里的aiType是ModProjectile本身的字段,不是原版Projectile的
aiType = ProjectileID.TerraBeam;

这些就是一个自制弹幕的基本属性了,还有一些高级的属性我不打算现在讲,因为都比较复杂而且依赖情景,后面会有单独的篇幅去讲这些。

弹幕重写函数

一个超级超级超级重要的重写函数:public override void AI()。应该已经猜出这是什么了吧,这就是制定弹幕的行为的地方,也就是AI的重写函数。这个函数没有参数和返回值,每秒执行60次,可以修改弹幕的位置、速度、加速度、视觉特效…… 这是弹幕的核心函数,用处真的是太大了。

弹幕也是可以加粒子效果的哦,就在这里加。Emmm,比如我想让弹幕带火,我可以在AI里面这么写:

// 火焰粒子特效
Dust dust = Dust.NewDustDirect(projectile.position, projectile.width, projectile.height
    , MyDustId.Fire, 0f, 0f, 100, default(Color), 3f);
// 粒子特效不受重力
dust.noGravity = true;

我们暂时先不考虑弹幕的贴图问题,如果想要透明的弹幕可以把projectile.alpha设为255,或者弄个透明的弹幕贴图。于是上面的粒子效果应该差不多是这样:

嗯,像个火球了。但是我们也可以让粒子效果分布的不那么散:

Dust dust = Dust.NewDustDirect(projectile.position, projectile.width, projectile.height
    , MyDustId.Fire, 0f, 0f, 100, default(Color), 3f);
dust.noGravity = true;
// 让粒子默认的运动速度归零
dust.velocity *= 0;
// 让粒子始终处于弹幕的中心位置
dust.position = projectile.Center;

这里说一下positionCenter的区别,position属性是指弹幕的左上角坐标,而Center是指弹幕的中心坐标。这样操作以后弹幕的效果变成这样了:

具体哪种风格更合适,就要看你自己了。


向量基础

向量(Vector)

为了真正发挥弹幕(以及Mod其他内容)的作用,我们一定要对向量,位置,等概念有充分的了解,这也是Mod制作的重点。在Mod制作中,我们只关注平面向量(因为TR是个2D游戏啊)。

定义

平面向量是在二维平面内既有方向(direction)又有大小(magnitude)的量。与之相对的是只有大小、没有方向的量(标量,如\(0.142857, 200000\))。

TR所有的物体的运动情况都是用向量表示的,比如说:

这些白色箭头都是物体的速度向量,箭头越长代表这个物体的速度越大。你会发现,除了有长短以外,还有方向,这就是向量的定义了。

假设玩家往上跳的时候有一个速度向量,我们在坐标轴中画出这个向量,应该差不多长这个样子:

图中的从\(O\)点开始,指向\(A\)点的向量就是玩家的一个速度向量,我们把它记作向量 \(\overrightarrow{OA}\) ,或者也可以记作\(\boldsymbol{OA}\)(变粗了而已。如果向量是从 \(A\) 到 \(O\),那我们就要记做 \(\overrightarrow{AO}\),这里的方向很重要,因为向量是包含方向的嘛。

图中的这个向量起点是 \(O(0, 0)\) ,终点是 \(A(1, 3)\) ,所以我们还可以把这个向量表示为二元组形式(或者说是坐标形式,反正就那个意思):\(\overrightarrow{OA} = (1, 3)\)。或者说,这才是向量的本体

假设我们有另一个向量 \(\overrightarrow{BC}\) ,从起点 \(B(0, -2)\) 到终点 \(C(1, 1)\) ,那么它的坐标表示形式为

\[\overrightarrow{BC} = C-B = (1,1)-(0, -2) = (1, 3)\]

我们就可以说 \(\overrightarrow{OA} = \overrightarrow{BC}\) ,因为他们的坐标形式相同。

也就是说我们并不关心这个向量是从哪个点到哪个点,我们只关心它的大小和方向。

如果我们考虑的向量是 \(\overrightarrow{CB}\) ,那么它的坐标形式为

\[\overrightarrow{CB} = B-C = (0,-2)\ -\ (1, 1) = (-1, -3)\]

可以看到,它的坐标形式刚好就是 \(\overrightarrow{BC}\) 的相反数。诶,那么我们是不是可以说 \(\overrightarrow{BC} =\ – \overrightarrow{CB} \) 了呢?确实如此,事实上我们这可以看做是对向量的运算。

向量运算

首先,向量有长度,那么它的长度是什么呢?你们应该都知道勾股定理吧,怎么求一个直角三角形斜边长度。向量也是一样。

假设有一个向量 \(\vec{v}\) ,同时 \(\vec{v} = (a, b)\) 那么我们记向量 \(\vec{v}\) 的长度 \(|\vec{v}|\) ,为\[ |\vec{v}| = \sqrt{a^2 + b^2}\]

如果我们把这个向量的坐标扩大两倍会怎么样?假设我们有向量 \(\vec{v} = (1, 3)\) ,扩大两倍就变成 \(2\vec{v} = (2, 6)\) ,那么向量长度变了多少呢?

\[|\vec{v}| = \sqrt{1^2+3^2} = \sqrt{10} \\ |2\vec{v}| = \sqrt{2^2+6^2} = \sqrt{40} = 2\sqrt{10}\]

所以,向量长度扩大了两倍。那我们是不是可以推出来,假设有一个常数 \(c\) ,那么

\[ |c\vec{v}| = c |\vec{v}| \] 证明

设向量 \(\vec{v} = (x, y)\),那么 \(c\vec{v} = (cx, cy)\)
那么长度为 \(|c\vec{v}| = \sqrt{c^2x^2+c^2y^2} = \sqrt{c^2(x^2+y^2)} = c\sqrt{x^2+y^2} = c|\vec{v}|\)

其次向量有方向,向量的方向其实就是与x轴正半轴的夹角,它的计算方式我们之后在说。

现在想象一下另外一个场景,玩家在奔跑的时候起跳,此时跳跃只有一个向上的向量,但是玩家的速度向量是水平的,那么跳跃的时候速度向量应该是什么样的呢?

,起跳的向量变化

我们可以把玩家速度和跳跃速度画在平面直角坐标系中:

然后我们定义向量的加法操作为,假设有向量 \(\vec{v} = (a, b)\) ,\(\vec{u} = (c, d)\) ,那么 \(\vec{v} + \vec{u} = (a + c, b + d)\)

很简单不是吗?但是这个操作为什么有意义呢?假设以上图为例,我们把向量 \(\overrightarrow{OA}\) 接到向量 \(\overrightarrow{OB}\) 上,就像接头霸王一样,

又要迫害我了吗?

那么新的 \(\overrightarrow{OA}\) 就是我们要求的 \( \overrightarrow{OA} + \overrightarrow{OB} \)。

我们可以从图中看出,这个向量确实是 \( \overrightarrow{OA’} = (2, 2)\),你也可以很轻易的验证其他形状的向量也是如此。所以说,向量加法本质上就是把一个向量的尾部接在另一个向量的头部。所以玩家跳跃的时候就是将玩家速度加上了一个向上的向量,我们可以在武器里面给玩家速度加上一个向量,看看会发生什么。

注意,在泰拉瑞亚世界中,坐标的原点是处在左上角,并且只有第四象限,此外,坐标的Y坐标从上到下从零开始递增。所以向上一个单位的向量其实是 \((0, -1)\) ,但是左右方向是与笛卡尔坐标系相同。

平面直角坐标系
TR坐标系

试试这个Shoot重写函数函数,位于物品文件内,其中Vector2就是我们的平面向量(来自Microsof.XNA.Framework,也就是XNA提供的),它的构造函数里面可以写x分量和y分量的值,player.velocity就是我们玩家的速度,TR世界里面玩家的位置每帧都会根据player.velocity改变,具体来说就是player.position += player.velocity,速度的大小越大,玩家位置变的也就越快。当然不只是玩家,像弹幕,NPC都会每帧更新速度。

public override bool Shoot(Player player, ref Vector2 position, ref float speedX, ref float speedY, ref int type, ref int damage, ref float knockBack) {
    // 武器射出弹幕的时候会给玩家一个5倍大小的速度
    player.velocity += new Vector2(speedX, speedY) * 5f;
    return true;
}

有了加法自然会想到减法,向量的减法其实就是加法,只不过加的是反向的向量,因为反向向量就是正向的相反数。

向量的减法操作:假设有向量 \(\vec{v} = (a, b)\) ,\(\vec{u} = (c, d)\) ,那么 \(\vec{v}-\vec{u} = \vec{v}+(-\vec{u}) = (a-c, b-d)\)

它的几何意义也就很简单了:把后面的向量反向,然后把尾部接在之前的向量的头部。

OA’即为结果

本章最后再说一下单位向量,这个东西在以后制作各种弹幕和游戏内容都会非常有用。什么是单位向量,就是长度为1的向量。

当你得到一个单位向量的时候,你就可以暂时不考虑向量大小的影响而是只用考虑方向,比如说,我们求一下射向敌人的子弹的速度。假设敌人在 \(A\) 点,我们在 \(B\) 点,那么射向敌人的向量将会是 \(A-B\) 或者 \(\overrightarrow{BA}\)(注意方向)。

此时如果我们把弹幕速度直接设为 \(\overrightarrow{BA}\) ,那显然弹幕速度取决于你和敌人直接的距离,这样是不好的(要么子弹慢如蜗牛,那么快到鬼畜)。很多时候我们弹幕射出的速度大小都是固定的,那么这个时候我们最好的解决办法就是把这个向量先缩到单位向量,这样我们直接乘以我们想要的速度大小,就能得到一个速度固定,方向是射向敌人的向量了。

把向量变成单位向量的操作叫做标准化(Normalization),具体操作方法就是\[\vec{v’} = \frac{\vec{v}}{|\vec{v}|}\]

也就是把向量的长度缩放到原来的 \( \frac{1}{|\vec{v}|} \) (除以向量\(\vec{v}\)的原长度),很简单吧?注意!零向量(\(\vec{0} = (0,0)\))没有单位向量(废话),如果你在计算的时候除以了零……

接下来我们就把刚刚说的自瞄(我没开挂)应用到游戏中吧,首先先准备一个能射出弹幕的武器。然后在Shoot重写函数里面写:

// 对于所有世界里的npc
foreach (var npc in Main.npc) {
    // 如果它活着并且是敌对生物
    if (npc.active && !npc.friendly) {
        // 射出自瞄弹幕(X)
    }
}

此时npc里面存储的就是符合条件的敌对npc了,然后我们明确一下要做的事情:

  1. 获取npc的位置,然后计算从玩家到npc的向量
  2. 把向量标准化(Normalize)
  3. 标准化以后的向量要乘以我们想要的速度
  4. 把这个速度的弹幕发射出去

于是我们可以在Shoot里写

// 对于所有世界里的npc
foreach (var npc in Main.npc) {
    // 如果它活着并且是敌对生物
    if (npc.active && !npc.friendly) {
        // 射出自瞄弹幕
        // 任务1,注意是谁减谁
        Vector2 plrToNPC = npc.Center - player.Center;
        // 任务2
        if (plrToNPC.Length() == 0) continue;
        // Length是Vector2这个结构体自带的函数,可以返回向量长度
        // 当然你也可以用Math.Sqrt(plrToNPC.X * plrToNPC.X + plrToNPC.Y * plrToNPC.Y)
        plrToNPC /= plrToNPC.Length();
        // 任务3,我们想要速度为10
        plrToNPC *= 10f;
        // 任务4,发射弹幕,关于这个函数的属性可以自己探索,下一章有介绍
        Projectile.NewProjectile(position, plrToNPC, type, 100, 10, player.whoAmI);
    }
}

练习

基础

  1. 从点 \(A(375.25, -98.776)\) 到点 \(B(489.764, 15.738)\) 的向量是?答案
    \(\overrightarrow{AB} = (114.514, 114.514)\)
  2. 向量 \(\vec{v} = (62.501, 95.954)\) 的长度是?答案
    \(|\vec{v}| = \sqrt{62.501^2 + 95.954^2} \approx 114.514\)
  3. 假设有向量 \(\vec{v} = (72.56,43.05)\),\(\vec{u} = (-19.5,77.89)\),那么 \(2\vec{v} + 0.9\vec{u}\) 是多少?答案
    不是\(114514\)啦
    是\((127.57,156.2)\),对x,y分量分别就算就好了,当然,最简单的办法是在TR里面写一个计算
  4. Vector2的构造函数可以有哪些重载?传入数据都是什么类型?答案
    Vector2可以有两个参数的构造函数,分别传入float类型的x坐标和float类型的y坐标,除此之外,还有可以放一个参数的弧度构造函数,它能根据弧度构造出一个单位向量。最后是一个默认构造函数,把xy都设为0.

进阶

  1. 试一下在TR里面用向量乘以向量,除以向量,看看会发生什么,他们的意义是什么?如果你学过向量乘法,它和你所学的一致吗?这样做的好处是什么?
  2. Vector2对象都有哪些方法,他们都有什么作用?可以去看看XNA关于Vector2的文档。
  3. 试着改一下TR里面其他带有Vector2的字段,看看会发生什么?
  4. 利用所学的知识,写一个简易的追踪弹幕吧,自动追踪一个最近的敌人。

《弹幕&向量基础》有10个想法

  1. Pingback: 物块制作第二节:多物块与动态物块 - 裙中世界

发表回复