diff --git a/mubble-core/src/main/java/fr/hugman/mubble/world/entity/projectile/Ball.java b/mubble-core/src/main/java/fr/hugman/mubble/world/entity/projectile/Ball.java index 465c02d39..160d30f62 100644 --- a/mubble-core/src/main/java/fr/hugman/mubble/world/entity/projectile/Ball.java +++ b/mubble-core/src/main/java/fr/hugman/mubble/world/entity/projectile/Ball.java @@ -22,6 +22,15 @@ public abstract class Ball extends ThrowableProjectile { public static final String REBOUNDS_KEY = "rebounds"; + /** Trail particles spawned per tick, strung along the ground the ball covers during it. */ + private static final int TRAIL_PARTICLES_PER_TICK = 1; + /** Distance travelled in a tick under which a ball counts as standing still and trails nothing. */ + private static final double MIN_TRAIL_DISTANCE = 0.01D; + /** Share of the speed of the ball a trail particle is pushed back by, the rest of it being shed. */ + private static final double TRAIL_DRIFT = 0.05D; + /** Upwards drift of a trail particle, so that it lingers behind rather than sinking with the ball. */ + private static final double TRAIL_RISE = 0.02D; + protected int rebounds = 3; private boolean rotateClockwards = false; @@ -57,6 +66,9 @@ protected void defineSynchedData(SynchedEntityData.Builder builder) {} @Override public void tick() { super.tick(); + if (this.level().isClientSide()) { + this.spawnTrailParticles(); + } } @Nullable @@ -64,6 +76,15 @@ public void tick() { protected abstract ParticleOptions getDeathParticle(); + /** + * @return the particle the ball trails behind it while it moves, or {@code null} for a ball that + * leaves no trail at all + */ + @Nullable + protected ParticleOptions getTrailParticle() { + return null; + } + @Override protected double getDefaultGravity() { return 0.08; @@ -127,6 +148,43 @@ public void handleEntityEvent(byte state) { } } + /** + * Spawns the trail a moving ball leaves behind, in the manner of the one vanilla arrows leave: + * particles strung along the movement of the tick and pushed back the way the ball came, so that + * they fall behind it instead of riding along with it. Arrows hand the particles their whole speed, + * which is far too brisk for a ball, so only a fraction of it is passed on here. + *

+ * A ball only trails while it actually moves: one that has come to a halt emits nothing. + */ + protected void spawnTrailParticles() { + ParticleOptions particle = this.getTrailParticle(); + if (particle == null || this.isRemoved()) { + return; + } + Vec3 movement = this.getDeltaMovement(); + int count = trailParticleCount(movement.length()); + // The model of a ball is centred on its position rather than standing on it, so the particles + // need no offset of their own to sit in the middle of the sprite. + Vec3 from = this.position(); + Vec3 drift = movement.scale(-TRAIL_DRIFT).add(0.0D, TRAIL_RISE, 0.0D); + + for (int i = 0; i < count; i++) { + Vec3 pos = from.add(movement.scale((i + 0.5D) / count)); + this.level().addParticle(particle, pos.x, pos.y, pos.z, drift.x, drift.y, drift.z); + } + } + + /** + * The trail is worth as many particles whatever the speed: a fast ball spaces them out further + * instead of spawning more of them. + * + * @param distance how far the ball travelled during the tick, in blocks + * @return how many particles to spawn, none at all for a ball that barely moved + */ + public static int trailParticleCount(double distance) { + return distance < MIN_TRAIL_DISTANCE ? 0 : TRAIL_PARTICLES_PER_TICK; + } + protected void spawnDeathParticles() { for (int i = 0; i < 8; ++i) { float s1 = random.nextFloat() * 0.2F - 0.1F; diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Fireball.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Fireball.java index 1114395f7..61392d76e 100644 --- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Fireball.java +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Fireball.java @@ -55,6 +55,12 @@ protected ParticleOptions getDeathParticle() { return ParticleTypes.FLAME; } + @Override + protected ParticleOptions getTrailParticle() { + // Smaller than the flames of the burst, so that the trail reads as embers rather than as fire. + return ParticleTypes.SMALL_FLAME; + } + @Override protected void onHitEntity(EntityHitResult result) { super.onHitEntity(result); diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/GoldFireball.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/GoldFireball.java index 2a84af4e2..8af8d816c 100644 --- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/GoldFireball.java +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/GoldFireball.java @@ -42,6 +42,11 @@ protected ParticleOptions getDeathParticle() { return SuperMarioParticleTypes.COIN_SPARKLE; //TODO change } + @Override + protected ParticleOptions getTrailParticle() { + return SuperMarioParticleTypes.COIN_SPARKLE; + } + @Override protected void onHitEntity(EntityHitResult hitResult) { super.onHitEntity(hitResult); diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Iceball.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Iceball.java index 8f81cf70a..40272dc50 100644 --- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Iceball.java +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Iceball.java @@ -52,6 +52,11 @@ protected ParticleOptions getDeathParticle() { return ParticleTypes.CLOUD; } + @Override + protected ParticleOptions getTrailParticle() { + return ParticleTypes.SNOWFLAKE; + } + @Override protected void onHitEntity(EntityHitResult result) { super.onHitEntity(result); diff --git a/mubble-test/src/test/java/fr/hugman/mubble/test/unit/BallTrailTest.java b/mubble-test/src/test/java/fr/hugman/mubble/test/unit/BallTrailTest.java new file mode 100644 index 000000000..00eb12e85 --- /dev/null +++ b/mubble-test/src/test/java/fr/hugman/mubble/test/unit/BallTrailTest.java @@ -0,0 +1,51 @@ +package fr.hugman.mubble.test.unit; + +import fr.hugman.mubble.test.unit.support.TestBootstrap; +import fr.hugman.mubble.world.entity.projectile.Ball; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Whether a {@link Ball} trails anything at all depends on the ground it covers during the tick. The + * particles themselves only exist on a client, but the count behind them is plain maths and belongs + * here rather than in a game test. + */ +public class BallTrailTest { + /** Roughly the distance a ball thrown by a player covers in one tick. */ + private static final double THROWN_BALL_DISTANCE = 1.5D; + + @BeforeAll + static void bootstrapMinecraft() { + TestBootstrap.bootstrap(); + } + + @Test + @DisplayName("a ball that has come to a halt trails nothing") + void motionlessBallTrailsNothing() { + assertEquals(0, Ball.trailParticleCount(0.0D), "a ball standing still should not trail anything"); + // A ball resting on the ground still jitters by a fraction of a block, which is no movement to speak of. + assertEquals(0, Ball.trailParticleCount(0.001D), "a barely moving ball should not trail anything"); + } + + @Test + @DisplayName("a moving ball always trails at least one particle") + void movingBallAlwaysTrailsSomething() { + assertTrue(Ball.trailParticleCount(0.05D) >= 1, "a slowly moving ball should still trail something"); + assertTrue(Ball.trailParticleCount(THROWN_BALL_DISTANCE) >= 1, "a thrown ball should trail something"); + } + + @Test + @DisplayName("going faster spaces the trail out rather than thickening it") + void speedDoesNotThickenTheTrail() { + int slow = Ball.trailParticleCount(0.05D); + int fast = Ball.trailParticleCount(THROWN_BALL_DISTANCE); + int absurd = Ball.trailParticleCount(1000.0D); + + assertEquals(slow, fast, "a faster ball should trail no more particles than a slow one"); + assertEquals(slow, absurd, "however fast a ball goes, a single tick should never spawn a screenful of particles"); + } +}