在学习向量以及计算几何的知识之前,最好能先创建一个自己的弹幕。这样我们可以通过这个弹幕观察到数学是如何发挥作用的。
弹幕基础
什么是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;
注意这里的width
和height
都是弹幕碰撞体积的高度和宽度。讲到这里我要说一下,如果你有下载之前教程中的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;
这里说一下position
和Center
的区别,position
属性是指弹幕的左上角坐标,而Center
是指弹幕的中心坐标。这样操作以后弹幕的效果变成这样了:
具体哪种风格更合适,就要看你自己了。
向量基础
为了真正发挥弹幕(以及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}| \] 证明
其次向量有方向,向量的方向其实就是与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)\) ,但是左右方向是与笛卡尔坐标系相同。
试试这个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)\)
它的几何意义也就很简单了:把后面的向量反向,然后把尾部接在之前的向量的头部。
本章最后再说一下单位向量,这个东西在以后制作各种弹幕和游戏内容都会非常有用。什么是单位向量,就是长度为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了,然后我们明确一下要做的事情:
- 获取npc的位置,然后计算从玩家到npc的向量
- 把向量标准化(Normalize)
- 标准化以后的向量要乘以我们想要的速度
- 把这个速度的弹幕发射出去
于是我们可以在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); } }
练习
基础
- 从点 \(A(375.25, -98.776)\) 到点 \(B(489.764, 15.738)\) 的向量是?答案\(\overrightarrow{AB} = (114.514, 114.514)\)
- 向量 \(\vec{v} = (62.501, 95.954)\) 的长度是?答案\(|\vec{v}| = \sqrt{62.501^2 + 95.954^2} \approx 114.514\)
- 假设有向量 \(\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里面写一个计算 Vector2
的构造函数可以有哪些重载?传入数据都是什么类型?答案Vector2
可以有两个参数的构造函数,分别传入float
类型的x坐标和float
类型的y坐标,除此之外,还有可以放一个参数的弧度构造函数,它能根据弧度构造出一个单位向量。最后是一个默认构造函数,把xy都设为0.
进阶
- 试一下在TR里面用向量乘以向量,除以向量,看看会发生什么,他们的意义是什么?如果你学过向量乘法,它和你所学的一致吗?这样做的好处是什么?
- Vector2对象都有哪些方法,他们都有什么作用?可以去看看XNA关于Vector2的文档。
- 试着改一下TR里面其他带有Vector2的字段,看看会发生什么?
- 利用所学的知识,写一个简易的追踪弹幕吧,自动追踪一个最近的敌人。
好,不愧是你!
正是什么?233
为什么Shoot函数被VS标为无法重写?
确定你是在继承了ModItem的类里写的
同问
为什么我using 不了XNA和泰拉瑞亚的命名空间?也没打错字啊……
第三题的长度是 236.61
这个向量的坐标图是用什么软件画的?
Pingback: 物块制作第二节:多物块与动态物块 - 裙中世界
可以不用MyDustId.Fire,直接查询Dust表:https://www.bilibili.com/read/cv22694303/