跳至正文

TeddyTerri:使用绘制来实现影子拖尾

你们好,我是TeddyTerri,这是我第一篇教程,如果有不对的地方请在评论区使劲喷向我指出。

我之后主要会补充些在先前教程中被忽略的内容,可能也会随缘更新一些1.4的内容。

首先是前置知识:

  1. 简单绘制
  2. 对数螺线 : 帧图绘制与用绘制整出的花活
  3. C#数组

其次是效果图:

其中弹幕的判定范围始终只有头部,后边的便是我所说的“影子拖尾”。真难看啊

好的,教程结束了

这个效果是使用绘制实现的,你一定已经看完了简单绘制与帧图绘制,并且了解C#中列表的用法。

那么,我想你应该已经会了。看到这里,暂且打住吧。

去自己尝试一下吧,或者思考个5~10分钟,自己思考的过程比被我所“灌输”的过程更加有趣。


实现影子拖尾:记录与绘制

需要注意的是,本文使用的tml版本是1.4版本

代码会改变,但借此所传达的思想不会改变。 ——我自己说的

记录:打点计时器

自制的记录方式

首先,我们需要每隔一会记录弹幕所处的位置,以知道我们应该将拖尾绘制在何处。

这类似打点计时器,不过这次这个间隔由你自己决定。

我们使用Vector2的列表来记录这些位置,就像这样:

private Vector2[] oldPosi = new Vector2[16]; //示例中记录16个坐标用于绘制,你可以试着修改这个值,并思考这意味着什么。
private int frametime = 0;
public override void AI()
{
    if (Main.time % 2 == 0)    //每两帧记录一次(打一次点)
    {
        for (int i = oldVec.Length - 1; i > 0; i--) //你应该知道为什么这里要写int i = oldVec.Length - 1
        {
            oldPosi[i] = oldPosi[i - 1];
        }
        oldPosi[0] = Projectile.Center;
    }
    Projectile.rotation = Projectile.velocity.ToRotation() + (float)(0.5 * MathHelper.Pi);  //这是你的弹幕贴图方向朝上的情况下的,如果你的弹幕贴图的方向朝向其他位置,你应该知道你要做什么
    frametime++;
}

好的,现在你已经得到了许多坐标(点),如果把它们放到一个坐标系中,它们大概长这样:

这是你的打点计时器 仅作示例,具体如何取决于你的弹幕轨迹。

参考源码:使用原版的记录方式(可选择跳过)

实际上,原版就有一种记录弹幕路径的方式,它的使用方法大致是这样的:

public override void SetDefaults()
{
    //一些常规的弹幕设定:
    Projectile.height = 24;
    Projectile.width = 30;
    Projectile.scale = 1f;
    Projectile.friendly = true;
    Projectile.hostile = false;
    Projectile.DamageType = DamageClass.Magic;
    Projectile.aiStyle = -1;
    Projectile.timeLeft = 450;
    Projectile.extraUpdates = 1;
    //这里是重点:
    ProjectileID.Sets.TrailingMode[Projectile.type] = 0;
    ProjectileID.Sets.TrailCacheLength[Projectile.type] = 30;
}

其中,ProjectileID.Sets.TrailCacheLength[Projectile.type] ,顾名思义,代表的是记录的列表长度,你可以修改这个长度,但是实际如何记录轨迹取决于下一个属性,或者自己手动定制。一旦这个长度被设置,那么弹幕在 SetDefaults 的时候就会给 oldPosoldRotoldSpriteDirection 数组扩容到指定大小,否则的话,这些数组一开始长度都是一个固定值。

另外一个参数ProjectileID.Sets.TrailingMode[Projectile.type] 是我们这次研究的重点,也就是如何记录轨迹,以下简称modeX。

我们先看源码:

if (ProjectileID.Sets.TrailingMode[this.type] == 0)
{
	for (int num18 = this.oldPos.Length - 1; num18 > 0; num18--)
	{
		this.oldPos[num18] = this.oldPos[num18 - 1];
	}
	this.oldPos[0] = this.position;
//mode0的实现方法和我们先前自制的几乎完全相同
}
else if (ProjectileID.Sets.TrailingMode[this.type] == 1)
{
	if (this.frameCounter == 0 || this.oldPos[0] == Vector2.Zero) //mode1貌似在弹幕存在帧图的时候framecounter == 0,即一般情况下,帧图变动完成后的一帧记录下位置,想想你可以用它来干什么
	{
		for (int num19 = this.oldPos.Length - 1; num19 > 0; num19--)
		{
			this.oldPos[num19] = this.oldPos[num19 - 1];
		}
		this.oldPos[0] = this.position;
//mode1这一小段与mode0完全相同
//以下是在mode1下对于特殊的弹幕的粒子拖尾效果的制作,可以忽略
		if (this.velocity == Vector2.Zero && this.type == 466)
		{
			float num20 = this.rotation + 1.57079637f + ((Main.rand.Next(2) == 1) ? -1f : 1f) * 1.57079637f;
			float num21 = (float)Main.rand.NextDouble() * 2f + 2f;
			Vector2 vector = new Vector2((float)Math.Cos((double)num20) * num21, (float)Math.Sin((double)num20) * num21);
			int num22 = Dust.NewDust(this.oldPos[this.oldPos.Length - 1], 0, 0, 229, vector.X, vector.Y, 0, default(Color), 1f);
			Main.dust[num22].noGravity = true;
			Main.dust[num22].scale = 1.7f;
		}
		if (this.velocity == Vector2.Zero && this.type == 580)
		{
			float num23 = this.rotation + 1.57079637f + ((Main.rand.Next(2) == 1) ? -1f : 1f) * 1.57079637f;
			float num24 = (float)Main.rand.NextDouble() * 2f + 2f;
			Vector2 vector2 = new Vector2((float)Math.Cos((double)num23) * num24, (float)Math.Sin((double)num23) * num24);
			int num25 = Dust.NewDust(this.oldPos[this.oldPos.Length - 1], 0, 0, 229, vector2.X, vector2.Y, 0, default(Color), 1f);
			Main.dust[num25].noGravity = true;
			Main.dust[num25].scale = 1.7f;
		}
	}
}
else if (ProjectileID.Sets.TrailingMode[this.type] == 2)
{
	for (int num26 = this.oldPos.Length - 1; num26 > 0; num26--)
	{
		this.oldPos[num26] = this.oldPos[num26 - 1];
		this.oldRot[num26] = this.oldRot[num26 - 1];
		this.oldSpriteDirection[num26] = this.oldSpriteDirection[num26 - 1];
	}
	this.oldPos[0] = this.position;
	this.oldRot[0] = this.rotation;
	this.oldSpriteDirection[0] = this.spriteDirection;
//mode2除了记录坐标,还记录了rotation与spriteDirection。
}
else if (ProjectileID.Sets.TrailingMode[this.type] == 3)
{
	for (int num27 = this.oldPos.Length - 1; num27 > 0; num27--)
	{
		this.oldPos[num27] = this.oldPos[num27 - 1];
		this.oldRot[num27] = this.oldRot[num27 - 1];
		this.oldSpriteDirection[num27] = this.oldSpriteDirection[num27 - 1];
	}
	this.oldPos[0] = this.position;
	this.oldRot[0] = this.rotation;
	this.oldSpriteDirection[0] = this.spriteDirection;
//这一段与mode2完全相同
	float amount = 0.65f;
	int num28 = 1;  //天知道源码为什么不直接把这个1填到下面for的条件里?
	for (int num29 = 0; num29 < num28; num29++)  //天知道源码为什么要写这个for,不就只跑一次嘛?
	{
		for (int num30 = this.oldPos.Length - 1; num30 > 0; num30--)
		{
			if (!(this.oldPos[num30] == Vector2.Zero))
			{
				if (this.oldPos[num30].Distance(this.oldPos[num30 - 1]) > 2f)
				{
					this.oldPos[num30] = Vector2.Lerp(this.oldPos[num30], this.oldPos[num30 - 1], amount);
//这个Lerp貌似之前没有讲过,简单来说就是如果Lerp(a,b,c),那么返回值是a+(b-a)*c
				}
				this.oldRot[num30] = (this.oldPos[num30 - 1] - this.oldPos[num30]).SafeNormalize(Vector2.Zero).ToRotation();
//方向的记录方式很特殊,是通过两个坐标所“算”出来的
//如果你在使用这个mode的时候发现贴图绘制的方向并不是你所期望的方向,你应该知道你要做什么
			}
		}
	}
}
else if (ProjectileID.Sets.TrailingMode[this.type] == 4)
{
	Vector2 value = Main.player[this.owner].position - Main.player[this.owner].oldPosition;
	for (int num31 = this.oldPos.Length - 1; num31 > 0; num31--)
	{
		this.oldPos[num31] = this.oldPos[num31 - 1];
		this.oldRot[num31] = this.oldRot[num31 - 1];
		this.oldSpriteDirection[num31] = this.oldSpriteDirection[num31 - 1];
		if (this.numUpdates == 0 && this.oldPos[num31] != Vector2.Zero) 
		{
			this.oldPos[num31] += value;
		}
	}
	this.oldPos[0] = this.position;
	this.oldRot[0] = this.rotation;
	this.oldSpriteDirection[0] = this.spriteDirection;
//mode4与mode2不同的是,他会为记录的坐标额外加上一个玩家(所有者)在前一帧内的位移,想想你可以用它来干什么?
}

稍微总结一下吧:

一般在模组开发过程中,我们常用的 TrailingMode 只有0和2,如果需要定制那么一般是不使用原版的 TrailingMode 而是手动操作弹幕的 oldPosoldRot 等属性。

mode0仅仅记录坐标

mode1在弹幕存在帧图时仅在framecounter == 0,即一般情况下,帧图切换完成后的一帧记录下位置,想想你可以用它来干什么。

mode2除了记录了坐标,还记录了rotationspriteDirection

mode3的记录方法很特殊,在记录后会遍历所有记录过的点(oldPos列表),假设其索引值为n,那么当n != 0时会计算oldPos[n-1] - oldPos[n]的方向并将oldRot[n] 的值更改为这个方向我说的好绕,不理解还是自己去看源码吧。也就是说,在mode3下,oldRot并不是记录下来之后便不改变的,不过实际上在应用中,这一点小误差基本可以忽略,除非你有着特别的需求。

mode4每帧都会会为记录的所有坐标额外加上一个玩家(所有者)在前一帧内的位移,想想你可以用它来干些什么反正我目前没想出需要用到这个模式的场景

然而,在原版的所有追踪中,oldPos记录下的所有都是Projectile.Position,而不是我们最经常使用的 Projectile. Center ,读完这篇文章后你可以试着用oldPos直接进行绘制,你能发现什么问题?可以给予的信息是,通过翻源码可以很快地解决这个问题,并了解 Projectile.PositionProjectile.Center 的换算关系。

不过,教程接下来的部分,我们仍然使用我们自制的记录方式。

真是的一个oldPos[i] += new Vector2(Projectile.width * .5f ,Projectile.height * .5f);的小问题卖那么大的关子干什么。

绘制:遍历你的列表

将弹幕的图像绘制到每一个你记录下的每一个点上,使其方向指向你记录下的下一个点。

此图片的alt属性为空;文件名为image-4.png
容我再放一遍这张图

拿上图来说,为了实现影子拖尾的效果,假设从A至F为弹幕的运动轨迹,那么在A处的“影子”应该指向B,在B处的“影子”应该指向C,以此类推。并且我们希望“影子”的大小与透明度由F至A递减。

那么,开始重写我们的PostDraw吧:

private Texture2D tex;
//别忘了在Setdefaults()中tex = ModContent.Request<Texture2D>("你贴图的路径").Value;以加载贴图
public override void PostDraw(Color lightColor)
{
    for (int i = oldPosi.Length - 1; i > 0; i--)
    {
        if (oldPosi[i] != Vector2.Zero)
        {
            Main.spriteBatch.Draw(tex, oldPosi[i] - Main.screenPosition, null, Color.White * 1 * (1 - .05f * i), (oldPosi[i - 1] - oldPosi[i]).ToRotation() + (float)(0.5 * MathHelper.Pi), tex.Size() * .5f, 1 * (1 - .02f * i), SpriteEffects.None, 0);  //如果贴图不在你所期望的方向上,你应该知道你要做什么
            //试试修改这里的.05f与.02f,想想它们意味着什么
        }
    }
}

保存,进游戏,加载,你的弹幕便有了影子拖尾。

好的,教程结束了


小裙子曾经说过:

作为一个程序员,我们不可能满足够用就好,我们一定要超纲,不,是找到一个适用范围更广,写起来更舒服的方式。

所以,接下来,我将介绍两种方法,让我们的程序能够写的更舒服。


函数封装:同样的代码我不想写第二遍

此处的前置知识:C#方法C#泛型

对于绘制拖尾这么常用的功能,如果能将它封装成函数,需要的时候随时调用就好了……

处理数据:将新的坐标“Push”进列表中

这是每次“打点”时保存坐标时的操作,具体说便是保存一个新的点,并且删除在列表中的最后一个点,下边是代码:

public static void Push<T>(T ele, ref T[] list)  //使用泛型使得我们能够在使用时再去定义它的类型。之所以这样做,因为Push的操作不仅适用于Vector2的列表,之后若是需要对别的类型的列表进行Push的操作,我们也可以调用这个函数。
{
    for (int i = list.Length - 1; i > 0; i--)  //将所有的元素“后移一格”,最后一个元素会被直接删除,位置0会被空出来
    {
        list[i] = list[i - 1];
    }
    list[0] = ele;  //将我们想放入的元素放在列表的0位置
}
//我将它放到了一个名为Fucs的类里,以便随时调用

至于如何使用,大概不用我多说:

private Vector2[] oldPosi = new Vector2[16]; 
private int frametime = 0;
public override void AI()
{
    if (frametime % 2 == 0)
    {
        Fucs.Push<Vector2>(Projectile.Center, ref oldPosi);
        //对照下我们先前写的,想想有什么不同
        //for (int i = oldPosi.Length - 1; i > 0; i--)
        //{
        //    oldPosi[i] = oldPosi[i - 1];
        //}
        //oldPosi[0] = Projectile.Center;
    }
    Projectile.rotation = Projectile.velocity.ToRotation() + (float)(0.5 * MathHelper.Pi);  //弹幕方向对正
    frametime++;
}

绘制:DrawVector2ListDirect

先看代码:

private Texture2D tex;
//别忘了在Setdefaults()中tex = ModContent.Request<Texture2D>("你贴图的路径").Value;以加载贴图
public static void DrawVector2ListDirect(SpriteBatch spriteBatch, Vector2[] vecList, Texture2D tex, float tramsparency, float scale, float turn, int advance = 1, float tramsparencyStep = 0, float scaleStep = 0)
{
    for (int i = vecList.Length - 1; i > advance - 1; i--)
    {
        if (vecList[i] != Vector2.Zero) //遍历你的列表
        {
            spriteBatch.Draw(tex, Fucs.FixedDrawPosi(vecList[i]), null, Color.White * tramsparency * (1 - tramsparencyStep * i), (vecList[i - 1] - vecList[i]).ToRotation() + turn, tex.Size() * .5f, scale * (1 - scaleStep * i), SpriteEffects.None, 0);  //绘制拖尾
        }
    }
}

public static Vector2 FixedDrawPosi(Vector2 posi)  //将世界坐标转换为屏幕坐标的函数
{
    return new Vector2(posi.X, posi.Y) - Main.screenPosition;
}
//与上面那段一样,我也将它放到了一个名为Fucs的类里

我们来看下它的参数:

首先是 spriteBatch ,这是一个绘制接口,毕竟这是个涉及绘制操作的函数,绘制接口时必不可少的。

接下来是 vecList,这是一个Vector2列表,代表所有需要绘制的点。

然后是 tex,贴图,不作多解释。

tramsparencyscale 分别是绘制贴图的初始透明度与大小。

turn 即旋转量,如果你的贴图的朝向并不是向左,你应该知道要做什么。

接下来三个参数可填可不填,不过它们是重点。

advance,使得弹幕绘制时可以无视前 advance 个坐标(注意不是后),用于做出“飞过后并不立马产生拖尾,而是延迟一会后产生拖尾”的效果。你可以试着修改这个值。

然而,在我们的实现中,关于advance这个参数有一个小bug,而我想把这个留给你来修复它。

提示:想想我们实现这个拖尾的方式,试着在这里填0,想一想原版追踪中的mode3,你该如何解决它?我就懒得修了

接下来两个:tramsparencyStepscaleStep 我想放到一起讲,你一定已经尝试过在原来的代码中修改那两个值:.05f与.02f,这是每次绘制时它的透明度/大小的递减量,.05f即代表绘制时透明度递减5%,.02f即代表绘制时大小递减2%想想如果填负数会发生什么

你应该已经知道怎么使用它了:

public override void PostDraw(Color lightColor)
{
    Fucs.DrawVector2ListDirect(Main.spriteBatch, oldPosi, tex, 1, 1, (float)(0.5 * MathHelper.Pi), 0, .05f, .02f);
    //试着将上边函数的定义和我们先前所写的代码对比一下:
    //for (int i = oldPosi.Length - 1; i > 0; i--)
    //{
    //    if (oldPosi[i] != Vector2.Zero)
    //    {
    //        Main.spriteBatch.Draw(tex, Fucs.FixedDrawPosi(oldPosi[i]), null, Color.White * 1 * (1 - .05f * i), (oldPosi[i - 1] - oldPosi[i]).ToRotation() + (float)(0.5 * MathHelper.Pi), tex.Size() * .5f, 1 * (1 - .02f * i), SpriteEffects.None, .4f);
    //    }
    //}   
}

面向对象法:另一种“偷懒的方法”

此处的前置知识:C#基础知识(2)

在裙子的教程 AI编写与状态机不要看难得要死,裙子将状态机弹幕写成了一个类。那么这次,请你将这个“拖尾弹幕”写成一个类。

通过继承这个类,你可以将要写的弹幕变为“拖尾弹幕”。
你可以更改绘制的贴图,绘制拖尾的长度,透明度递减量与大小递减量。

最重要的,在不重写AI及任何关于绘制的函数的情况下能够正常绘制影子拖尾。

自己试试写写看吧~这不会很难所以我就懒得贴代码了


这个练习只是给你用来练手的,在你的个人项目中多用用无妨甚至会感觉很♂爽从而被自己的聪明才智所折服。需要注意的是,在大型项目开发时,继承类要慎用,功能拆的太散日后就会导致恐怖的多继承问题。

至于多继承问题有多恐怖……

我怎么知道是我还在写这篇文章的时候裙子告诉我的我又没开发过大型项目问裙子去

如果你对此感兴趣,这里有一篇文章是关于多继承问题的你可以读一读:论C#之多继承

如果你想读懂这篇文章的解决方案,那么你所需要的前置知识有:C# 接口

不过,等到你有能力开发大型项目的时候,你一定能够权衡好多继承的复杂与继承类的便利,也能想出更好的解决方案,不是吗?


最后写些什么呢,我们以一个小作业结尾吧。

一个小作业

对数螺线 : 帧图绘制与用绘制整出的花活 的最后,阿汪对数螺线放了两张图:

“这个大概是帧图绘制具体效果”
“这里是我用的贴图”

这两张图片都可以右键另存为,贴图中放的图甚至不用抠

现在,试着运用你所学过的所有知识,仔细想想你在这两篇文章里学到的,完成动图中的弹幕效果。

《TeddyTerri:使用绘制来实现影子拖尾》有3个想法

  1. 一个问题,如果直接在类里面定义tex的话,那么tex获取的是null
    因为Load时就会跑一遍实例,然后Load时又获取不到贴图,所以会变成null

    1. 已修改,感谢指正。
      很有意思的是,我写稿的时候裙子向我提出过这个问题,在当时的tml1.4下,直接在类里定义tex是没有问题的,现在的版本(指stable)下的确如你所述——画不出来
      (鬼知道tml更新了些什么(逃

发表回复