粒子
粒子效果是使单调的方块世界变得丰富的重要方法之一。编写合适的粒子并在需要时生成,可以极大地提升模组的观感。
我们将以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
会根据入射方向,使反射方向更有可能倾向于继续入射方向,而更少地向后方反弹(想象一个斜水柱冲到地面,形成的层流区域的样子)。