粒子
粒子效果是使单调的方块世界变得丰富的重要方法之一。编写合适的粒子并在需要时生成,可以极大地提升模组的观感。
我们将以MadParticle中的粒子为例,向你讲述如何编写你自己的粒子。
概述
为方便起见,以及降低难度,以下示例代码将会是MadParticle(侧重于强大的灵活性)和Extinguish(侧重于专门的实用性)的混合。
像之前一样,我们先展示出完整代码,然后再来慢慢讲解。在阅读时,你可以暂时跳过以下示例代码中较长的方法。
你可以在GitHub查看MadParticle的完整代码。在编写粒子时,MadParticle的代码可以是非常有用的参考。
以下展示的内容可能在MadParticle的后续更新中有修改。新旧版本应该不会对你学习粒子编 写有很大影响。
为了使你在一开始有个比较完整的认识,我们首先介绍各个类及其作用。
- 显然,你需要一个
Particle
类,负责粒子的具体逻辑; - 我们当然还需要指定粒子对应的
ParticleType
,这样才能知道谁是谁; - 还要一个
ParticleOption
,这样才能方便粒子生成的消息通过网络传输,以及指令生成; - 一个
ParticleProvider
,类似一个工厂类,将ParticleOption
转换为Particle
。
当创建一个粒子时,我们是先有一个ParticleOption
,再由MC调用对应的Provider
来生成Particle
实例。
由于粒子与世界其他部分没什么连接,粒子也仅在客户端运算(这样你就不需要考虑同步问题),故粒子逻辑的的自由度非常大。
以下展示的一些修改只是无限可能中的一小部分。
Particle
首先,我们使自己的新粒子需要继承TextureSheetParticle
:
public class MadParticle extends TextureSheetParticle {
protected SpriteFrom spriteFrom;
protected float rollSpeed = 0.01;
private float scale = 1;
protected final MadParticleOption child;
protected final int bounceTime = 3;
private int bounceCount = 0;
...
private static final double MAXIMUM_COLLISION_VELOCITY_SQUARED = Mth.square(100.0D);
...
public MadParticle(ClientLevel pLevel, SpriteSet spriteSet, SpriteFrom spriteFrom,
double pX, double pY, double pZ, double vx, double vy, double vz,
...) {
super(pLevel, pX, pY, pZ);
this.spriteFrom = spriteFrom;
switch (spriteFrom) {
case AGE -> this.setSpriteFromAge(spriteSet);
default -> this.pickSprite(spriteSet);
}
...
}
@Override
public ParticleRenderType getRenderType() {
return ParticleRenderType.PARTICLE_SHEET_TRANSLUCENT;
}
@Override
public void tick() {
if (this.age++ >= this.lifetime) {
this.remove();
} else {
normalTick();
}
}
private void normalTick() {
this.xo = this.x;
this.yo = this.y;
this.zo = this.z;
//alpha
this.alpha = ...;
//size
if (endScale != beginScale) {
float newScale = scaleMode.lerp(beginScale, endScale, age, lifetime);
this.scale(1 / scale * newScale);
scale = newScale;
}
//sprite
if (this.spriteFrom == SpriteFrom.AGE) {
setSpriteFromAge(sprites);
}
//interact with Entity
LivingEntity entity = level.getNearestEntity(LivingEntity.class, TargetingConditions.forNonCombat().range(4), null, x, y, z, this.getBoundingBox().inflate(0.7));
if (entity != null) {
Vec3 v = entity.getDeltaMovement();
this.xd += v.x * random.nextFloat() * horizontalInteractFactor;
double y0;
if (entity.onGround()) {
y0 = Math.max(Math.abs(v.y), Math.sqrt(v.x * v.x + v.z * v.z));
} else {
y0 = v.y;
}
y0 *= verticalInteractFactor;
if (y0 > 0) {
this.onGround = false;
}
this.yd += (v.y < 0 ? -y0 : y0);
this.zd += v.z * random.nextFloat() * horizontalInteractFactor;
this.gravity = beginGravity;
this.friction = frictionInitial;
}
//gravity
this.yd -= 0.04 * (double) this.gravity;
//move
this.move(this.xd, this.yd, this.zd);
this.xd *= this.friction;
this.yd *= this.friction;
this.zd *= this.friction;
//roll
this.oRoll = this.roll;
if (!this.onGround) {
this.roll -= (float) Math.PI * rollSpeed * 2.0F;
}
}
@SuppressWarnings("AlibabaAvoidDoubleOrFloatEqualCompare")
@Override
public void move(double pX, double pY, double pZ) {
double x0 = pX;
double y0 = pY;
double z0 = pZ;
double r2 = pX * pX + pY * pY + pZ * pZ;
if (collision && this.hasPhysics && (pX != 0.0D || pY != 0.0D || pZ != 0.0D) && r2 < MAXIMUM_COLLISION_VELOCITY_SQUARED) {
Vec3 vec3 = Entity.collideBoundingBox((Entity) null, new Vec3(pX, pY, pZ), this.getBoundingBox(), this.level, List.of());
pX = vec3.x;
pY = vec3.y;
pZ = vec3.z;
}
if (pX != 0.0D || pY != 0.0D || pZ != 0.0D) {
this.setBoundingBox(this.getBoundingBox().move(pX, pY, pZ));
this.setLocationFromBoundingbox();
}
if (collision) {
//hit XOZ
if (y0 != pY) {
if (bounceCount < bounceTime) {
Vec2 v = horizontalRelativeCollision(r2, xd, zd);
this.xd = v.x;
this.yd = -y0 * (random.nextDouble() * verticalRelativeCollisionBounce);
this.zd = v.y;
updateAfterCollision();
} else {
this.gravity = 0;
this.onGround = true;
}
this.friction = afterCollisionFriction;
return;
}
//hit YOZ
if (x0 != pX) {
if (bounceCount < bounceTime) {
Vec2 v = horizontalRelativeCollision(r2, yd, zd);
this.xd = -x0 * (random.nextDouble() * verticalRelativeCollisionBounce);
this.yd = v.x;
this.zd = v.y;
updateAfterCollision();
}
this.friction = afterCollisionFriction;
return;
}
//hit XOY
if (z0 != pZ) {
if (bounceCount < bounceTime) {
Vec2 v = horizontalRelativeCollision(r2, xd, yd);
this.xd = v.x;
this.yd = v.y;
this.zd = -z0 * (random.nextDouble() * verticalRelativeCollisionBounce);
updateAfterCollision();
}
this.friction = afterCollisionFriction;
return;
}
}
}
private void updateAfterCollision() {
bounceCount++;
this.gravity = afterCollisionGravity;
}
public Vec2 horizontalRelativeCollision(double r2, double d1, double d2) {
if (horizontalRelativeCollisionDiffuse == 0) {
return new Vec2(0, 0);
}
//generalLoss controls radius of spread circle.
r2 *= horizontalRelativeCollisionDiffuse;
float r = (float) Math.sqrt(r2);
float a = (float) Math.random() * r * (random.nextBoolean() ? -1 : 1);
float b = (float) Math.sqrt(r2 - a * a) * (random.nextBoolean() ? -1 : 1);
//lose energy/speed when bouncing to different directions.
//lose less speed when going forward. lose more speed when going backward.
float d = (float) Math.sqrt((d1 - a) * (d1 - a) + (d2 - b) * (d2 - b));
float directionalLoss = 1 - d / (2 * r) * MAX_DIRECTIONAL_LOSS;
return new Vec2((float) (a * directionalLoss * Math.random()), (float) (b * directionalLoss * Math.random()));
}
@Override
public void remove() {
super.remove();
if (this.child != null) {
AddParticleHelper.addParticle(child.inheritOrContinue(this));
}
}
@Override
protected int getLightColor(float pPartialTick) {
int unmodified = super.getLightColor(pPartialTick);
int light = 15;
return unmodified & 0b00000000_11110000_00000000_00000000 | light << 4;
}
public static class Provider implements ParticleProvider<MadParticleOption> {
private final SpriteSet sprites;
public Provider(SpriteSet pSprites) {
this.sprites = pSprites;
}
@Nullable
@Override
public Particle createParticle(MadParticleOption op, ClientLevel pLevel, double pX, double pY, double pZ, double pXSpeed, double pYSpeed, double pZSpeed) {
return new MadParticle(pLevel, sprites, op.spriteFrom(),
pX, pY, pZ, pXSpeed, pYSpeed, pZSpeed,
...);
}
}
}
你可以看到,我们首先为一些特定功能增加了一堆字段。
在初始化时,除了满足父类的构造方法,对粒子最重要的之一就是使用setSpriteFromAge
(根据age
选择)或者pickSprite
(随机选择)来选择材质。
如果粒子需要一些初始化操作,你可以向粒子的构造方法传入需要的参数,在其中进行初始化;也可以在下文即将提到的Provider#createParticle
方法中先初始化再返回。你可以选一个你喜欢的方式。
像实体一样,大多数主动逻辑都将在tick
里执行。在这里为方便起见,我们将实际执行的部分拆出来成为normalTick
。
会有抽象方法getRenderType
要我们实现。现阶段直接返回ParticleRenderType.PARTICLE_SHEET_TRANSLUCENT
就可以。如果粒子确定会是永远不透明的,那PARTICLE_SHEET_OPAQUE
也不错。
tick
各个逻辑部分已由代码中的简单注释表明。
-
一开始是更新粒子的位置缓存——显然这是为了粒子的平滑运动。
-
然后是一些附加功能的逻辑,改变粒子的透明度和大小。
改变子大小的scale
方法用的是*=
来实现——这意味着你在需要多次改变大小时尤其注意。
-
如果粒子的材质需要根据时间而变化,我们需要使用
setSpriteFromAge
。 -
接下来我们想要粒子被周围的实体运动所影响,就像实体路过时带起一阵风一样。
- 使用
level.getNearestEntity
来获取最近的实体,然后根据实体的运动速度来计算粒子受影响的程度; - 按
entity.onGround()
分别处理,这样就能让实体在地上运动时也能产生一些垂直方向的扰动; - 我们使用了
verticalInteractFactor
和horizontalInteractFactor
来灵活地控制具体程度大小;
- 使用
-
接下来计算中重力对运动的影响。
-
然后使用
move
执行以上种种算出来的运动。
以上各个部分通常不需要特别讲究前后顺序,你可以先move
再算摩擦力,也可以一上来就算玩家的扰动。
前后顺序的不同确实会对粒子的实际效果会有微妙的影响,但通常这种影响没有达到必须考虑它的程度——出了问题再说吧。
- 最后我们给粒子加上了自转。
move
为了在碰撞后执行特殊逻辑,我们重写了move
方法。
我们在这里使用了自己的collision
字段,而原版提供的字段是stoppedByCollision
,请注意区分。
重写后的move
方法前半部分并未改变,还是对碰撞的计算。
计算完成后,你可以看到我们有三段注释,分别对应粒子撞到各个平面时的逻辑:
- 我们使用了
bounceCount
和bounceTime
来控制粒子最多发生碰撞的次数,以此来减少性能消耗。- 要是碰撞次数上限已经达到,我们就让天花板和地板上的粒子不再下落,而墙壁上的粒子随墙下落。
- 通过比较原来的
y0
和碰撞之后算得的pY
的大小(这里有一些不好的形参重赋值,已在MadParticle更新中改进),我们能够知道粒子是否撞上水平面,进一步的判断也能让我们知道是撞上了地板还是天花板。 - 在确认撞上之后,我们可以在这里做任何我们想要的工作,而此处是一个基于能量守恒但守恒不多的非完全弹性碰撞算法:
- 在垂直碰撞平面的方向上,我们使用简单的反弹;
- 在水平方向上,稍复杂的
horizontalRelativeCollision
会根据入射方向,使反射方向更有可能倾向于继续入射方向,而更少地向后方反弹(想象一个斜水柱冲到地面,形成的层流区域的样子)。
其他
我们随后重写了remove
方法,用于在消失时生成子粒子(MadParticle的特性);
为了点亮粒子,使粒子不论环境光照如何都保持明亮,我们重写了getLightColor
,修改了返回值中的方块光部分。
getLightColor
返回值的高二字节高四位是0-15的天空光,低首字节的高四位是0-15的方块光。
你需要了解原版父类TextureSheetParticle
和Particle
提供的字段,对应的默认值/初始值和作用。有些时候你不需要自己写复杂的逻辑,只需要控制对应的字段值就行。
Provider
如你在上面的代码中见到的,ParticleProvider
是一个简单的工厂类,继承ParticleProvider<ParticleOption>
,在createParticle
方法中实例化新粒子。
ParticleOption
ParticleOption
本质上是一个存储生成粒子所需数据的容器。由于MadParticle
所需要的参数非常多,我们在这里使用了record
的形式。
public record MadParticleOption(int targetParticle, SpriteFrom spriteFrom, int lifeTime,
InheritableBoolean alwaysRender, int amount,
double px, double py, double pz, double xDiffuse, double yDiffuse, double zDiffuse,
double vx, double vy, double vz, double vxDiffuse, double vyDiffuse, double vzDiffuse,
float friction,...
) implements ParticleOptions {
public static final Deserializer<MadParticleOption> DESERIALIZER = new Deserializer<MadParticleOption>() {
@Override
public MadParticleOption fromCommand(ParticleType<MadParticleOption> pParticleType, StringReader pReader) throws CommandSyntaxException {
return null;
}
@Override
public MadParticleOption fromNetwork(ParticleType<MadParticleOption> pParticleType, FriendlyByteBuf buf) {
int targetParticle = buf.readInt();
SpriteFrom spriteFrom = buf.readEnum(SpriteFrom.class);
int lifeTime = buf.readInt();
InheritableBoolean alwaysRender = buf.readEnum(InheritableBoolean.class);
int amount = buf.readInt();
double px = buf.readDouble(), py = buf.readDouble(), pz = buf.readDouble();
double xDiffuse = buf.readDouble(), yDiffuse = buf.readDouble(), zDiffuse = buf.readDouble();
double vx = buf.readDouble(), vy = buf.readDouble(), vz = buf.readDouble();
double vxDiffuse = buf.readDouble(), vyDiffuse = buf.readDouble(), vzDiffuse = buf.readDouble();
float friction = buf.readFloat();
...
return new MadParticleOption(targetParticle, spriteFrom, lifeTime, alwaysRender, amount,
px, py, pz, xDiffuse, yDiffuse, zDiffuse, vx, vy, vz, vxDiffuse, vyDiffuse, vzDiffuse,
friction,...);
}
};
@Override
public void writeToNetwork(FriendlyByteBuf buf) {
buf.writeInt(targetParticle);
buf.writeEnum(spriteFrom);
buf.writeInt(lifeTime);
buf.writeEnum(alwaysRender);
buf.writeInt(amount);
buf.writeDouble(px);
buf.writeDouble(py);
buf.writeDouble(pz);
buf.writeDouble(xDiffuse);
buf.writeDouble(yDiffuse);
buf.writeDouble(zDiffuse);
buf.writeDouble(vx);
buf.writeDouble(vy);
buf.writeDouble(vz);
buf.writeDouble(vxDiffuse);
buf.writeDouble(vyDiffuse);
buf.writeDouble(vzDiffuse);
buf.writeFloat(friction);
...
}
@Override
public @NotNull ParticleType<?> getType() {
return ModParticleRegistry.MAD_PARTICLE.get();
}
@Override
public @NotNull String writeToString() {
return "MadParticle";
}
}
我们首先需要创建一个从命令行和网络包中读取信息的Decoder,在这里是一个Deserializer<MadParticleOption>
对象DESERIALIZER
。
DESERIALIZER
需要实现的两个方法非常明了:一个用于从命令行中解析出ParticleOption
,另一个负责从网络包中解析。如果你的目的并不在于使用/particle
来召唤粒子,那你可以直接返回null
;如果你希望进行解析,那你就需要慢慢读取参数StringReader
里面的内容进行解析。
fromNetwork
方法则是必要的,其写入数据的过程与网络包中的@Decoder
是相同的。
回到ParticleOption
本身。writeToNetwork
方法与网络包中的@Encoder
是相同的。
getType
需要返回对应的ParticleType
,我们接下来会提到。
writeToString
填入粒子名字就行。
ParticleType
这是一个非常简单的例子:
public class MadParticleType extends ParticleType<MadParticleOption> {
public MadParticleType() {
super(false,MadParticleOption.DESERIALIZER);
}
@Override
public Codec<MadParticleOption> codec() {
return null;
}
}
没什么好说的。
基础设施建设完成!下面投入使用吧。