diff --git a/mubble-core/src/client/java/fr/hugman/mubble/client/MubbleClient.java b/mubble-core/src/client/java/fr/hugman/mubble/client/MubbleClient.java
index 6586ba144..649f91f83 100644
--- a/mubble-core/src/client/java/fr/hugman/mubble/client/MubbleClient.java
+++ b/mubble-core/src/client/java/fr/hugman/mubble/client/MubbleClient.java
@@ -5,6 +5,8 @@
import fr.hugman.mubble.client.model.MubbleModelLayers;
import fr.hugman.mubble.client.network.MubbleClientPayloadReceivers;
import fr.hugman.mubble.client.renderer.MubbleRenderers;
+import fr.hugman.mubble.client.sound.AirMoveSounds;
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.Environment;
@@ -19,5 +21,7 @@ public void onInitializeClient() {
MubbleRenderers.registerLayers();
MubbleKeyBindings.registerEvents();
MubbleClientPayloadReceivers.register();
+
+ ClientTickEvents.END_CLIENT_TICK.register(AirMoveSounds::tick);
}
}
diff --git a/mubble-core/src/client/java/fr/hugman/mubble/client/mixin/LocalPlayerMixin.java b/mubble-core/src/client/java/fr/hugman/mubble/client/mixin/LocalPlayerMixin.java
new file mode 100644
index 000000000..87ccec9d3
--- /dev/null
+++ b/mubble-core/src/client/java/fr/hugman/mubble/client/mixin/LocalPlayerMixin.java
@@ -0,0 +1,20 @@
+package fr.hugman.mubble.client.mixin;
+
+import fr.hugman.mubble.world.entity.JumpKeyHolder;
+import net.minecraft.client.player.LocalPlayer;
+import org.spongepowered.asm.mixin.Mixin;
+
+/**
+ * Where a client reads its own jump key.
+ *
+ * The server is handed the key of every player through their input packets, but a client only ever has one
+ * to read: the one under the keyboard in front of it. That is enough, since a client only ever simulates the
+ * mid-air moves of the player it controls.
+ */
+@Mixin(LocalPlayer.class)
+public class LocalPlayerMixin implements JumpKeyHolder {
+ @Override
+ public boolean isJumpKeyHeld() {
+ return ((LocalPlayer) (Object) this).input.keyPresses.jump();
+ }
+}
diff --git a/mubble-core/src/client/java/fr/hugman/mubble/client/sound/AirMoveSoundInstance.java b/mubble-core/src/client/java/fr/hugman/mubble/client/sound/AirMoveSoundInstance.java
new file mode 100644
index 000000000..f6911b4d4
--- /dev/null
+++ b/mubble-core/src/client/java/fr/hugman/mubble/client/sound/AirMoveSoundInstance.java
@@ -0,0 +1,59 @@
+package fr.hugman.mubble.client.sound;
+
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.minecraft.client.resources.sounds.AbstractTickableSoundInstance;
+import net.minecraft.client.resources.sounds.SoundInstance;
+import net.minecraft.core.Holder;
+import net.minecraft.sounds.SoundEvent;
+import net.minecraft.sounds.SoundSource;
+import net.minecraft.world.entity.player.Player;
+
+/**
+ * The loop a mid-air move is heard as, for as long as it lasts.
+ *
+ * It follows the player rather than the move: what a flutter turning into a float should sound like is one
+ * loop giving way to another, so the instance stops as soon as the sound the player is owed is no longer
+ * its own, and {@link AirMoveSounds} starts whichever one has taken over.
+ */
+@Environment(EnvType.CLIENT)
+public class AirMoveSoundInstance extends AbstractTickableSoundInstance {
+ private final Player player;
+ private final Holder event;
+
+ public AirMoveSoundInstance(Player player, Holder event) {
+ super(event.value(), SoundSource.PLAYERS, SoundInstance.createUnseededRandom());
+ this.player = player;
+ this.event = event;
+ this.looping = true;
+ this.delay = 0;
+ this.volume = 0.2F;
+ }
+
+ /** The loop this instance is playing, so that the handler can tell it apart from the one now owed. */
+ public Holder event() {
+ return this.event;
+ }
+
+ @Override
+ public boolean canPlaySound() {
+ return !this.player.isSilent();
+ }
+
+ @Override
+ public boolean canStartSilent() {
+ return true;
+ }
+
+ @Override
+ public void tick() {
+ var wanted = AirMoveSounds.soundFor(this.player);
+ if (this.player.isRemoved() || wanted.isEmpty() || !wanted.get().equals(this.event)) {
+ this.stop();
+ return;
+ }
+ this.x = (float) this.player.getX();
+ this.y = (float) this.player.getY();
+ this.z = (float) this.player.getZ();
+ }
+}
diff --git a/mubble-core/src/client/java/fr/hugman/mubble/client/sound/AirMoveSounds.java b/mubble-core/src/client/java/fr/hugman/mubble/client/sound/AirMoveSounds.java
new file mode 100644
index 000000000..88a29807b
--- /dev/null
+++ b/mubble-core/src/client/java/fr/hugman/mubble/client/sound/AirMoveSounds.java
@@ -0,0 +1,64 @@
+package fr.hugman.mubble.client.sound;
+
+import fr.hugman.mubble.world.power_up.ability.FloatAbility;
+import fr.hugman.mubble.world.power_up.ability.FlutterAbility;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.minecraft.client.Minecraft;
+import net.minecraft.core.Holder;
+import net.minecraft.sounds.SoundEvent;
+import net.minecraft.world.entity.player.Player;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.WeakHashMap;
+
+/**
+ * Keeps one loop going per player on a mid-air move in sight.
+ *
+ * A sound instance stops itself once the move it belongs to is over, but nothing would stop a second one
+ * from being started on the very next tick, so the ones already playing are held onto here. The map is weak
+ * on purpose: a player that walked out of range, or left the game, takes their entry with them.
+ */
+@Environment(EnvType.CLIENT)
+public final class AirMoveSounds {
+ private static final Map PLAYING = new WeakHashMap<>();
+
+ private AirMoveSounds() {
+ }
+
+ /**
+ * @return the loop {@code player} is owed right now, the climb taking precedence over the descent for
+ * the tick or two in which a client believes they are on both
+ */
+ public static Optional> soundFor(Player player) {
+ if (player.isFluttering()) {
+ return player.getFlutterAbility().flatMap(FlutterAbility::sound);
+ }
+ if (player.isFloating()) {
+ return player.getFloatAbility().flatMap(FloatAbility::sound);
+ }
+ return Optional.empty();
+ }
+
+ public static void tick(Minecraft client) {
+ if (client.level == null) {
+ PLAYING.clear();
+ return;
+ }
+ for (Player player : client.level.players()) {
+ var wanted = soundFor(player);
+ if (wanted.isEmpty()) {
+ PLAYING.remove(player);
+ continue;
+ }
+ var playing = PLAYING.get(player);
+ if (playing != null && !playing.isStopped() && wanted.get().equals(playing.event())) {
+ continue;
+ }
+ var instance = new AirMoveSoundInstance(player, wanted.get());
+ PLAYING.put(player, instance);
+ client.getSoundManager().play(instance);
+ }
+ }
+}
diff --git a/mubble-core/src/client/resources/mubble.client.mixins.json b/mubble-core/src/client/resources/mubble.client.mixins.json
index 85f28a4b5..fd3728870 100644
--- a/mubble-core/src/client/resources/mubble.client.mixins.json
+++ b/mubble-core/src/client/resources/mubble.client.mixins.json
@@ -7,7 +7,8 @@
"ClientPacketListenerMixin",
"HudMixin",
"ItemInHandRendererMixin",
- "LivingEntityRendererMixin"
+ "LivingEntityRendererMixin",
+ "LocalPlayerMixin"
],
"injectors": {
"defaultRequire": 1
diff --git a/mubble-core/src/main/java/fr/hugman/mubble/mixin/PlayerMixin.java b/mubble-core/src/main/java/fr/hugman/mubble/mixin/PlayerMixin.java
index 5c4d0cbbc..7739e4cf7 100644
--- a/mubble-core/src/main/java/fr/hugman/mubble/mixin/PlayerMixin.java
+++ b/mubble-core/src/main/java/fr/hugman/mubble/mixin/PlayerMixin.java
@@ -4,16 +4,23 @@
import fr.hugman.mubble.network.protocol.common.custom.PowerUpChangePayload;
import fr.hugman.mubble.tags.MubblePowerUpTags;
import fr.hugman.mubble.world.entity.MubbleEntityTypes;
+import fr.hugman.mubble.world.entity.Floating;
+import fr.hugman.mubble.world.entity.Fluttering;
+import fr.hugman.mubble.world.entity.JumpKeyHolder;
import fr.hugman.mubble.world.entity.WaterRunner;
import fr.hugman.mubble.world.entity.item.collectible.CollectibleEntity;
import fr.hugman.mubble.world.power_up.PowerUp;
import fr.hugman.mubble.world.power_up.PowerUpHolder;
import fr.hugman.mubble.world.power_up.PowerUpProperties;
+import fr.hugman.mubble.world.power_up.ability.FloatAbility;
+import fr.hugman.mubble.world.power_up.ability.FlutterAbility;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Holder;
+import net.minecraft.core.particles.ParticleOptions;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.network.syncher.EntityDataAccessor;
+import net.minecraft.network.syncher.EntityDataSerializers;
import net.minecraft.network.syncher.SynchedEntityData;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.tags.FluidTags;
@@ -21,6 +28,7 @@
import net.minecraft.world.level.storage.ValueInput;
import net.minecraft.world.level.storage.ValueOutput;
import net.minecraft.world.phys.AABB;
+import net.minecraft.world.phys.Vec3;
import org.jspecify.annotations.Nullable;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
@@ -32,16 +40,39 @@
import java.util.Optional;
@Mixin(Player.class)
-public class PlayerMixin implements PowerUpHolder, WaterRunner {
+public class PlayerMixin implements PowerUpHolder, WaterRunner, JumpKeyHolder, Fluttering, Floating {
@Unique
private static final EntityDataAccessor> POWER_UP_PROPERTIES = SynchedEntityData.defineId(Player.class, MubbleEntityDataSerializers.POWER_UP_PROPERTIES);
@Unique
private static final EntityDataAccessor>> POWER_UP = SynchedEntityData.defineId(Player.class, MubbleEntityDataSerializers.OPTIONAL_POWER_UP);
+ /**
+ * Only ever written by the server, and only so that the other clients can tell. The flutter of the
+ * player a side is in charge of is simulated there rather than waited for, see {@link #mubble$tickFlutter}.
+ */
+ @Unique
+ private static final EntityDataAccessor FLUTTERING = SynchedEntityData.defineId(Player.class, EntityDataSerializers.BOOLEAN);
+ /** The other half of the same story, see {@link #mubble$tickAirMoves}. */
+ @Unique
+ private static final EntityDataAccessor FLOATING = SynchedEntityData.defineId(Player.class, EntityDataSerializers.BOOLEAN);
@Unique
private static final String POWER_UP_KEY = "power_up";
@Unique
private static final String POWER_UP_PROPERTIES_KEY = "power_up_properties";
+ @Unique
+ private static final String FLUTTER_TICKS_KEY = "flutter_ticks";
+ @Unique
+ private static final String FLUTTER_SPENT_KEY = "flutter_spent";
+
+ /** How many particles a player on a mid-air move leaves around their feet every tick. */
+ @Unique
+ private static final int AIR_MOVE_PARTICLES = 2;
+ /** How far those scatter around the feet, as a share of the width of the player. */
+ @Unique
+ private static final double AIR_MOVE_PARTICLE_SPREAD = 0.8D;
+ /** How fast they sink, so that they read as being left behind rather than carried along. */
+ @Unique
+ private static final double AIR_MOVE_PARTICLE_FALL = -0.05D;
/** How far a player runs between two splashes, in blocks. Vanilla footsteps land every 1.7 or so. */
@Unique
@@ -66,10 +97,25 @@ public class PlayerMixin implements PowerUpHolder, WaterRunner {
@Unique
private double mubble$distanceToNextSplash;
+ /** Whether a flutter is going on, as simulated by this side, see {@link #mubble$tickFlutter}. */
+ @Unique
+ private boolean mubble$fluttering;
+ /** How many ticks the flutter under way has already run, which is what the lift ramps up over. */
+ @Unique
+ private int mubble$flutterTicks;
+ /** Whether the jump the player is on has already spent its flutter. */
+ @Unique
+ private boolean mubble$flutterSpent;
+ /** Whether the player is being held to a float, as simulated by this side. */
+ @Unique
+ private boolean mubble$floating;
+
@Inject(method = "defineSynchedData", at = @At("TAIL"))
protected void mubble$initDataTracker(SynchedEntityData.Builder builder, CallbackInfo ci) {
builder.define(POWER_UP, Optional.empty());
builder.define(POWER_UP_PROPERTIES, Optional.empty());
+ builder.define(FLUTTERING, false);
+ builder.define(FLOATING, false);
}
@Inject(method = "addAdditionalSaveData", at = @At("TAIL"))
@@ -78,6 +124,8 @@ public class PlayerMixin implements PowerUpHolder, WaterRunner {
this_.getPowerUp().ifPresent(entry -> view.store(POWER_UP_KEY, PowerUp.CODEC, entry));
view.storeNullable(POWER_UP_PROPERTIES_KEY, PowerUpProperties.CODEC, this_.getPowerUpProperties());
+ view.putInt(FLUTTER_TICKS_KEY, this.mubble$fluttering ? this.mubble$flutterTicks : -1);
+ view.putBoolean(FLUTTER_SPENT_KEY, this.mubble$flutterSpent);
}
@Inject(method = "readAdditionalSaveData", at = @At("TAIL"))
@@ -85,6 +133,11 @@ public class PlayerMixin implements PowerUpHolder, WaterRunner {
var this_ = (Player) (Object) this;
view.read(POWER_UP_KEY, PowerUp.CODEC).ifPresent(entry -> this_.getEntityData().set(POWER_UP, Optional.of(entry)));
view.read(POWER_UP_PROPERTIES_KEY, PowerUpProperties.CODEC).ifPresent(properties -> this_.getEntityData().set(POWER_UP_PROPERTIES, Optional.of(properties)));
+ // A flutter is written as the ticks it had run, and as -1 when there was none going on at all.
+ int flutterTicks = view.getIntOr(FLUTTER_TICKS_KEY, -1);
+ this.mubble$flutterSpent = view.getBooleanOr(FLUTTER_SPENT_KEY, false);
+ this.mubble$setFluttering(this_, flutterTicks >= 0);
+ this.mubble$flutterTicks = Math.max(0, flutterTicks);
}
@Inject(method = "tick", at = @At("TAIL"))
@@ -280,6 +333,234 @@ public void clearPowerUp() {
PowerUp.onChange(this_, previous, Optional.empty());
}
+ /**
+ * Runs the two halves of a jump held on: the flutter climbing past its peak, and the float bringing the
+ * holder back down at a walking pace once nothing is lifting them any more.
+ *
+ * They are checked in that order and never both in the same tick, since a holder still being pushed up
+ * has no fall for the float to slow. Which of the two a power-up grants is up to it: a form with only a
+ * float floats down from wherever its plain jump took it.
+ *
+ * This sits at the head of {@code aiStep} because both write straight into the movement of the tick,
+ * which {@code travel()} then spends a little further down the very same call.
+ *
+ * Both sides run it, each for the player it is in charge of: the client so that the movement it predicts
+ * for itself actually goes where it should, the server so that it knows what the movement it is being
+ * sent is supposed to look like. Neither waits on the other, and they agree because they read the same
+ * jump key, the same ground and the same power-up.
+ */
+ @Inject(method = "aiStep", at = @At("HEAD"))
+ private void mubble$tickAirMoves(CallbackInfo ci) {
+ var this_ = (Player) (Object) this;
+ this.mubble$airMoveParticles(this_);
+
+ // Landing is what hands the next jump its flutter back, and the only thing that does.
+ if (this_.onGround()) {
+ this.mubble$flutterSpent = false;
+ this.mubble$endAirMoves(this_);
+ return;
+ }
+ // Anything holding the player up other than the air takes both abilities away with it, and so does
+ // letting go of the key. The flutter never comes back for that jump; the float does, on the next press.
+ if (mubble$heldBySomethingElse(this_) || !this_.isJumpKeyHeld()) {
+ this.mubble$endAirMoves(this_);
+ return;
+ }
+
+ if (this.mubble$tickFlutter(this_)) {
+ // Still being pushed up, so there is no fall to slow down yet.
+ this.mubble$setFloating(this_, false);
+ return;
+ }
+ this.mubble$tickFloat(this_);
+ }
+
+ /**
+ * The climb: starts once the player is past the peak of their jump, and builds for as long as it lasts.
+ *
+ * @return whether the flutter took this tick's movement for itself
+ */
+ @Unique
+ private boolean mubble$tickFlutter(Player player) {
+ var ability = this.getFlutterAbility();
+ if (ability.isEmpty()) {
+ // The form can be lost in mid-air, and the flutter goes with it.
+ this.mubble$setFluttering(player, false);
+ return false;
+ }
+ FlutterAbility flutter = ability.get();
+
+ if (this.mubble$fluttering) {
+ if (this.mubble$flutterTicks >= flutter.duration()) {
+ this.mubble$setFluttering(player, false);
+ return false;
+ }
+ } else {
+ // Nothing changes on the way up: the flutter waits for the player to start coming back down.
+ if (this.mubble$flutterSpent || player.getKnownMovement().y() >= 0.0D) {
+ return false;
+ }
+ this.mubble$flutterTicks = 0;
+ this.mubble$flutterSpent = true;
+ this.mubble$setFluttering(player, true);
+ }
+
+ Vec3 movement = player.getDeltaMovement();
+ player.setDeltaMovement(movement.x(), flutter.liftAt(this.mubble$flutterTicks), movement.z());
+ this.mubble$flutterTicks++;
+ return true;
+ }
+
+ /**
+ * The descent: holds the player to a walking pace on the way down, and spares them most of what the fall
+ * would otherwise be worth.
+ *
+ * It only ever slows a fall: a player still climbing under their own steam is left alone, and one already
+ * coming down slower than the float is never sped up to it.
+ */
+ @Unique
+ private void mubble$tickFloat(Player player) {
+ var ability = this.getFloatAbility();
+ if (ability.isEmpty() || player.getKnownMovement().y() >= 0.0D) {
+ this.mubble$setFloating(player, false);
+ return;
+ }
+ FloatAbility floating = ability.get();
+
+ Vec3 movement = player.getDeltaMovement();
+ if (movement.y() < -floating.speed()) {
+ player.setDeltaMovement(movement.x(), -floating.speed(), movement.z());
+ }
+ // The fall damage of a tick spent floating is written off here rather than at the landing: the player
+ // is falling the whole time, so this is the only place that knows the fall was a float and not a drop.
+ player.fallDistance = Math.max(0.0D, player.fallDistance - floating.fallForgivenPerTick());
+ this.mubble$setFloating(player, true);
+ }
+
+ /**
+ * The states a mid-air move cannot carry on through, landing aside: they are all ways of being held by
+ * something other than the air.
+ */
+ @Unique
+ private static boolean mubble$heldBySomethingElse(Player player) {
+ return player.isInWater() || player.onClimbable() || player.isFallFlying() || player.isPassenger();
+ }
+
+ @Unique
+ private void mubble$endAirMoves(Player player) {
+ this.mubble$setFluttering(player, false);
+ this.mubble$setFloating(player, false);
+ }
+
+ @Unique
+ private void mubble$setFluttering(Player player, boolean fluttering) {
+ if (this.mubble$fluttering == fluttering) {
+ return;
+ }
+ this.mubble$fluttering = fluttering;
+ if (!fluttering) {
+ this.mubble$flutterTicks = 0;
+ }
+ this.mubble$sync(player, FLUTTERING, fluttering);
+ }
+
+ @Unique
+ private void mubble$setFloating(Player player, boolean floating) {
+ if (this.mubble$floating == floating) {
+ return;
+ }
+ this.mubble$floating = floating;
+ this.mubble$sync(player, FLOATING, floating);
+ }
+
+ /**
+ * Tells the other clients about a mid-air move, which is all they get: they have no business simulating
+ * someone else's keys, they only draw what the move looks like.
+ */
+ @Unique
+ private void mubble$sync(Player player, EntityDataAccessor accessor, boolean value) {
+ if (!player.level().isClientSide()) {
+ player.getEntityData().set(accessor, value);
+ }
+ }
+
+ /**
+ * Leaves the trail of a mid-air move around the feet of the player.
+ *
+ * Every client draws it for every player it can see on one, rather than the server broadcasting it: the
+ * player fluttering right here should not have to wait on a round trip to see their own leaves. It hangs
+ * off the two states rather than off the tick right above, which only ever runs for the one player this
+ * side is in charge of.
+ */
+ @Unique
+ private void mubble$airMoveParticles(Player player) {
+ if (!player.level().isClientSide()) {
+ return;
+ }
+ Optional particle = Optional.empty();
+ if (player.isFluttering()) {
+ particle = this.getFlutterAbility().flatMap(FlutterAbility::particle);
+ } else if (player.isFloating()) {
+ particle = this.getFloatAbility().flatMap(FloatAbility::particle);
+ }
+ if (particle.isEmpty()) {
+ return;
+ }
+ double spread = player.getBbWidth() * AIR_MOVE_PARTICLE_SPREAD;
+ for (int i = 0; i < AIR_MOVE_PARTICLES; i++) {
+ player.level().addParticle(particle.get(),
+ player.getRandomX(spread), player.getY(), player.getRandomZ(spread),
+ 0.0D, AIR_MOVE_PARTICLE_FALL, 0.0D);
+ }
+ }
+
+ /**
+ * The server is told the jump key of every player it runs, tick after tick, by the input packets they
+ * send. A client only ever knows its own, which {@code LocalPlayerMixin} answers with: the players it
+ * merely watches never start a move of their own, they are shown the one the server tells them about.
+ */
+ @Override
+ public boolean isJumpKeyHeld() {
+ var this_ = (Player) (Object) this;
+ return this_ instanceof ServerPlayer serverPlayer && serverPlayer.getLastClientInput().jump();
+ }
+
+ @Override
+ public Optional getFlutterAbility() {
+ var this_ = (Player) (Object) this;
+ return this_.getPowerUp().flatMap(powerUp -> powerUp.value().abilities().flutter());
+ }
+
+ @Override
+ public Optional getFloatAbility() {
+ var this_ = (Player) (Object) this;
+ return this_.getPowerUp().flatMap(powerUp -> powerUp.value().abilities().floating());
+ }
+
+ @Override
+ public boolean isFluttering() {
+ var this_ = (Player) (Object) this;
+ // The two are the same truth seen from two places: the side simulating the player writes the field,
+ // and the clients watching someone else only ever get the flag.
+ return this.mubble$fluttering || this_.getEntityData().get(FLUTTERING);
+ }
+
+ @Override
+ public boolean isFloating() {
+ var this_ = (Player) (Object) this;
+ return this.mubble$floating || this_.getEntityData().get(FLOATING);
+ }
+
+ @Override
+ public int getFlutterTicks() {
+ return this.mubble$fluttering ? this.mubble$flutterTicks : 0;
+ }
+
+ @Override
+ public boolean hasFluttered() {
+ return this.mubble$flutterSpent;
+ }
+
@Override
public boolean isRunningOnWater() {
return this.mubble$runningOnWater;
diff --git a/mubble-core/src/main/java/fr/hugman/mubble/world/entity/Floating.java b/mubble-core/src/main/java/fr/hugman/mubble/world/entity/Floating.java
new file mode 100644
index 000000000..d4bd02afe
--- /dev/null
+++ b/mubble-core/src/main/java/fr/hugman/mubble/world/entity/Floating.java
@@ -0,0 +1,29 @@
+package fr.hugman.mubble.world.entity;
+
+import fr.hugman.mubble.world.power_up.ability.FloatAbility;
+
+import java.util.Optional;
+
+/**
+ * An entity that can come down slowly by leaning on its jump key, granted by whichever power-up it holds.
+ *
+ * Injected onto {@code Player}, and deliberately apart from {@link Fluttering}: a form is free to grant the
+ * float without the climb that usually comes before it.
+ *
+ * @see FloatAbility
+ */
+public interface Floating {
+ /**
+ * @return the float the currently held power-up grants, if it grants one at all
+ */
+ default Optional getFloatAbility() {
+ return Optional.empty();
+ }
+
+ /**
+ * @return whether the holder is being held to a float right now
+ */
+ default boolean isFloating() {
+ return false;
+ }
+}
diff --git a/mubble-core/src/main/java/fr/hugman/mubble/world/entity/Fluttering.java b/mubble-core/src/main/java/fr/hugman/mubble/world/entity/Fluttering.java
new file mode 100644
index 000000000..d6c2404d2
--- /dev/null
+++ b/mubble-core/src/main/java/fr/hugman/mubble/world/entity/Fluttering.java
@@ -0,0 +1,45 @@
+package fr.hugman.mubble.world.entity;
+
+import fr.hugman.mubble.world.power_up.ability.FlutterAbility;
+
+import java.util.Optional;
+
+/**
+ * An entity that can climb again past the peak of its jump, granted by whichever power-up it holds.
+ *
+ * Injected onto {@code Player}. Both sides run the same flutter tick for tick: the client so that the
+ * movement it predicts for itself actually rises, the server so that it knows what the movement it is being
+ * sent is supposed to look like.
+ *
+ * @see FlutterAbility
+ * @see Floating for the other half of a jump held on
+ */
+public interface Fluttering {
+ /**
+ * @return the flutter the currently held power-up grants, if it grants one at all
+ */
+ default Optional getFlutterAbility() {
+ return Optional.empty();
+ }
+
+ /**
+ * @return whether a flutter is going on right now
+ */
+ default boolean isFluttering() {
+ return false;
+ }
+
+ /**
+ * @return how many ticks the flutter under way has already run, 0 outside of one
+ */
+ default int getFlutterTicks() {
+ return 0;
+ }
+
+ /**
+ * @return whether the jump the holder is on has already spent its flutter
+ */
+ default boolean hasFluttered() {
+ return false;
+ }
+}
diff --git a/mubble-core/src/main/java/fr/hugman/mubble/world/entity/JumpKeyHolder.java b/mubble-core/src/main/java/fr/hugman/mubble/world/entity/JumpKeyHolder.java
new file mode 100644
index 000000000..66694b85c
--- /dev/null
+++ b/mubble-core/src/main/java/fr/hugman/mubble/world/entity/JumpKeyHolder.java
@@ -0,0 +1,17 @@
+package fr.hugman.mubble.world.entity;
+
+/**
+ * An entity whose jump key this side can read.
+ *
+ * Injected onto {@code Player}, and the one thing the mid-air abilities all hang off. The server is told the
+ * key of every player it runs by their input packets; a client only ever knows the one under the keyboard in
+ * front of it, which is enough, since a client only ever simulates the player it controls.
+ */
+public interface JumpKeyHolder {
+ /**
+ * @return whether the jump key is being held down right now, as far as this side can tell
+ */
+ default boolean isJumpKeyHeld() {
+ return false;
+ }
+}
diff --git a/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/PowerUp.java b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/PowerUp.java
index ec8cebe62..ab0cd529a 100644
--- a/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/PowerUp.java
+++ b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/PowerUp.java
@@ -4,6 +4,7 @@
import com.mojang.serialization.codecs.RecordCodecBuilder;
import fr.hugman.mubble.core.registries.MubbleRegistries;
import fr.hugman.mubble.world.entity.ai.attributes.EntityAttributeEntry;
+import fr.hugman.mubble.world.power_up.ability.PowerUpAbilities;
import fr.hugman.mubble.world.power_up.action.PowerUpAction;
import net.minecraft.core.Holder;
import net.minecraft.network.RegistryFriendlyByteBuf;
@@ -31,6 +32,7 @@ public record PowerUp(
Optional spriteId,
Optional> action,
Optional> attributesModifiers,
+ PowerUpAbilities abilities,
PowerUpCosmectics cosmectics
) {
//TODO: add a predicate/damage tag to determine if you can lose it to damage
@@ -42,6 +44,7 @@ public record PowerUp(
Identifier.CODEC.optionalFieldOf("sprite_id").forGetter(PowerUp::spriteId),
PowerUpAction.CODEC.optionalFieldOf("action").forGetter(PowerUp::action),
EntityAttributeEntry.CODEC.listOf().optionalFieldOf("attribute_modifiers").forGetter(PowerUp::attributesModifiers),
+ PowerUpAbilities.CODEC.optionalFieldOf("abilities", PowerUpAbilities.EMPTY).forGetter(PowerUp::abilities),
PowerUpCosmectics.CODEC.optionalFieldOf("cosmetics", PowerUpCosmectics.EMPTY).forGetter(PowerUp::cosmectics)
).apply(instance, PowerUp::new));
@@ -53,6 +56,7 @@ public record PowerUp(
Identifier.STREAM_CODEC.apply(ByteBufCodecs::optional), PowerUp::spriteId,
PowerUpAction.OPTIONAL_STREAM_CODEC, PowerUp::action,
EntityAttributeEntry.OPTIONAL_LIST_STREAM_CODEC, PowerUp::attributesModifiers,
+ PowerUpAbilities.STREAM_CODEC, PowerUp::abilities,
PowerUpCosmectics.STREAM_CODEC, PowerUp::cosmectics,
PowerUp::new
);
diff --git a/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/PowerUpBuilder.java b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/PowerUpBuilder.java
index 506a92e4d..b63b41a9b 100644
--- a/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/PowerUpBuilder.java
+++ b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/PowerUpBuilder.java
@@ -6,6 +6,9 @@
import java.util.List;
import java.util.Optional;
+import fr.hugman.mubble.world.power_up.ability.FloatAbility;
+import fr.hugman.mubble.world.power_up.ability.FlutterAbility;
+import fr.hugman.mubble.world.power_up.ability.PowerUpAbilities;
import fr.hugman.mubble.world.power_up.action.PowerUpAction;
import net.minecraft.core.Holder;
import net.minecraft.core.particles.ParticleOptions;
@@ -27,6 +30,8 @@ public class PowerUpBuilder {
private @Nullable Holder emitSound = null;
private @Nullable Holder looseSound = null;
private @Nullable Holder refillSound = null;
+ private @Nullable FlutterAbility flutter = null;
+ private @Nullable FloatAbility floating = null;
private @Nullable ParticleOptions particle = null;
private @Nullable Identifier humanoidOverlayAssetId = null;
private boolean emissiveOverlay = false;
@@ -87,6 +92,22 @@ public PowerUpBuilder attributesModifier(Holder attribute, double val
return this.attributesModifier(new EntityAttributeEntry(attribute, new AttributeModifier(Mubble.id("power_up/" + path), value, operation)));
}
+ /**
+ * Lets the holder climb again past the peak of their jumps, on the terms the ability is built with.
+ */
+ public PowerUpBuilder flutter(FlutterAbility flutter) {
+ this.flutter = flutter;
+ return this;
+ }
+
+ /**
+ * Lets the holder come down slowly by leaning on the jump key, on the terms the ability is built with.
+ */
+ public PowerUpBuilder floating(FloatAbility floating) {
+ this.floating = floating;
+ return this;
+ }
+
public PowerUpBuilder particle(ParticleOptions particle) {
this.particle = particle;
return this;
@@ -133,6 +154,7 @@ public PowerUp build() {
Optional.ofNullable(spriteId),
Optional.ofNullable(action),
Optional.ofNullable(attributesModifiers.isEmpty() ? null : attributesModifiers),
+ new PowerUpAbilities(Optional.ofNullable(this.flutter), Optional.ofNullable(this.floating)),
new PowerUpCosmectics(
Optional.ofNullable(this.particle),
Optional.ofNullable(this.obtainSound),
diff --git a/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/ability/FloatAbility.java b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/ability/FloatAbility.java
new file mode 100644
index 000000000..0843fbdfe
--- /dev/null
+++ b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/ability/FloatAbility.java
@@ -0,0 +1,77 @@
+package fr.hugman.mubble.world.power_up.ability;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+import net.minecraft.core.Holder;
+import net.minecraft.core.particles.ParticleOptions;
+import net.minecraft.core.particles.ParticleTypes;
+import net.minecraft.network.RegistryFriendlyByteBuf;
+import net.minecraft.network.codec.ByteBufCodecs;
+import net.minecraft.network.codec.StreamCodec;
+import net.minecraft.sounds.SoundEvent;
+import net.minecraft.util.Mth;
+
+import java.util.Optional;
+
+/**
+ * The falling half of a jump held on: a holder still leaning on the jump key comes down at a walking pace
+ * instead of dropping, and is spared most of what the fall would otherwise be worth.
+ *
+ * It only ever slows a fall down: it never pushes anyone up, and it leaves a holder who is still climbing
+ * alone. That is what makes it worth having on its own — a form granting nothing but this one floats down
+ * from wherever its jump took it, with no climb of its own to speak of first.
+ *
+ * Unlike the {@link FlutterAbility} that usually comes before it, it lasts as long as the key is held rather
+ * than for a set number of ticks, and letting go of the key only ends it until it is pressed again.
+ *
+ * @param speed the speed the holder comes down at, in blocks per tick
+ * @param fallDamage the share of a floated fall that still counts for fall damage, from 0 to 1
+ * @param sound the sound played in loop for as long as the float lasts
+ * @param particle the particle left around the feet of the holder while they float
+ */
+public record FloatAbility(
+ float speed,
+ float fallDamage,
+ Optional> sound,
+ Optional particle
+) {
+ public FloatAbility {
+ // A negative speed would send a floating holder upwards, which is the other ability's job.
+ speed = Math.max(0.0F, speed);
+ fallDamage = Mth.clamp(fallDamage, 0.0F, 1.0F);
+ }
+
+ public static final float DEFAULT_SPEED = 0.25F;
+ public static final float DEFAULT_FALL_DAMAGE = 0.5F;
+
+ public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group(
+ Codec.FLOAT.optionalFieldOf("speed", DEFAULT_SPEED).forGetter(FloatAbility::speed),
+ Codec.FLOAT.optionalFieldOf("fall_damage", DEFAULT_FALL_DAMAGE).forGetter(FloatAbility::fallDamage),
+ SoundEvent.CODEC.optionalFieldOf("sound").forGetter(FloatAbility::sound),
+ ParticleTypes.CODEC.optionalFieldOf("particle").forGetter(FloatAbility::particle)
+ ).apply(instance, FloatAbility::new));
+
+ public static final StreamCodec STREAM_CODEC = StreamCodec.composite(
+ ByteBufCodecs.FLOAT, FloatAbility::speed,
+ ByteBufCodecs.FLOAT, FloatAbility::fallDamage,
+ SoundEvent.STREAM_CODEC.apply(ByteBufCodecs::optional), FloatAbility::sound,
+ ParticleTypes.STREAM_CODEC.apply(ByteBufCodecs::optional), FloatAbility::particle,
+ FloatAbility::new
+ );
+
+ /**
+ * A float with nothing to see or hear.
+ */
+ public static FloatAbility of(float speed, float fallDamage) {
+ return new FloatAbility(speed, fallDamage, Optional.empty(), Optional.empty());
+ }
+
+ /**
+ * How much of a tick spent floating the holder is spared on landing.
+ *
+ * @return the blocks to take off the fall the holder has built up, for one tick of floating
+ */
+ public float fallForgivenPerTick() {
+ return this.speed * (1.0F - this.fallDamage);
+ }
+}
diff --git a/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/ability/FlutterAbility.java b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/ability/FlutterAbility.java
new file mode 100644
index 000000000..6849582a2
--- /dev/null
+++ b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/ability/FlutterAbility.java
@@ -0,0 +1,109 @@
+package fr.hugman.mubble.world.power_up.ability;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+import net.minecraft.core.Holder;
+import net.minecraft.core.particles.ParticleOptions;
+import net.minecraft.core.particles.ParticleTypes;
+import net.minecraft.network.RegistryFriendlyByteBuf;
+import net.minecraft.network.codec.ByteBufCodecs;
+import net.minecraft.network.codec.StreamCodec;
+import net.minecraft.sounds.SoundEvent;
+import net.minecraft.util.Mth;
+
+import java.util.Optional;
+
+/**
+ * The rising half of a jump held on: past the peak of it, a holder still leaning on the jump key climbs
+ * again for a moment instead of falling.
+ *
+ * It opens on a drop rather than on a lift. For the first few ticks the holder is still sinking, only less
+ * and less of it, and then the same acceleration that ate the drop carries them up hard. Those few ticks of
+ * hanging are what make the lift that follows read as a snap rather than as a balloon, so the whole thing is
+ * over in well under a second — a flutter that lifted from its very first tick would just be a second jump.
+ *
+ * What happens once the climb is over is not this ability's business: see {@link FloatAbility}, which a form
+ * is free to grant on its own.
+ *
+ * @param duration how many ticks the climb lasts at most
+ * @param drop how fast the holder is still sinking when it opens, in blocks per tick
+ * @param acceleration how much every tick takes off that drop, and then adds to the lift, in blocks per tick
+ * per tick
+ * @param sound the sound played in loop for as long as the climb lasts
+ * @param particle the particle left around the feet of the holder while they climb
+ */
+public record FlutterAbility(
+ int duration,
+ float drop,
+ float acceleration,
+ Optional> sound,
+ Optional particle
+) {
+ public FlutterAbility {
+ // A data pack is free to write anything; what it cannot do is turn the numbers around, on an ability
+ // whose whole point is to have the holder sink and then climb rather than the other way about.
+ duration = Math.max(0, duration);
+ drop = Math.max(0.0F, drop);
+ acceleration = Math.max(0.0F, acceleration);
+ }
+
+ public static final int DEFAULT_DURATION = 8;
+ public static final float DEFAULT_DROP = 0.2F;
+ public static final float DEFAULT_ACCELERATION = 0.1F;
+
+ public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group(
+ Codec.INT.optionalFieldOf("duration", DEFAULT_DURATION).forGetter(FlutterAbility::duration),
+ Codec.FLOAT.optionalFieldOf("drop", DEFAULT_DROP).forGetter(FlutterAbility::drop),
+ Codec.FLOAT.optionalFieldOf("acceleration", DEFAULT_ACCELERATION).forGetter(FlutterAbility::acceleration),
+ SoundEvent.CODEC.optionalFieldOf("sound").forGetter(FlutterAbility::sound),
+ ParticleTypes.CODEC.optionalFieldOf("particle").forGetter(FlutterAbility::particle)
+ ).apply(instance, FlutterAbility::new));
+
+ public static final StreamCodec STREAM_CODEC = StreamCodec.composite(
+ ByteBufCodecs.INT, FlutterAbility::duration,
+ ByteBufCodecs.FLOAT, FlutterAbility::drop,
+ ByteBufCodecs.FLOAT, FlutterAbility::acceleration,
+ SoundEvent.STREAM_CODEC.apply(ByteBufCodecs::optional), FlutterAbility::sound,
+ ParticleTypes.STREAM_CODEC.apply(ByteBufCodecs::optional), FlutterAbility::particle,
+ FlutterAbility::new
+ );
+
+ /**
+ * A flutter with nothing to see or hear.
+ */
+ public static FlutterAbility of(int duration, float drop, float acceleration) {
+ return new FlutterAbility(duration, drop, acceleration, Optional.empty(), Optional.empty());
+ }
+
+ /**
+ * The vertical speed the flutter is worth on one of its ticks, negative while the holder is still sinking
+ * and positive once the acceleration has turned that around.
+ *
+ * @param elapsed how many ticks the flutter has already run, the first one being 0
+ * @return the speed for that tick, in blocks per tick, upwards when positive
+ */
+ public float liftAt(int elapsed) {
+ return this.acceleration * elapsed - this.drop;
+ }
+
+ /**
+ * How many ticks the holder keeps sinking for before the flutter starts carrying them up.
+ */
+ public int hangTicks() {
+ if (this.acceleration <= 0.0F) {
+ return this.duration;
+ }
+ return Math.min(this.duration, Mth.ceil(this.drop / this.acceleration));
+ }
+
+ /**
+ * The net height a whole flutter is worth, the drop it opens on included and gravity left aside.
+ */
+ public float totalLift() {
+ float total = 0.0F;
+ for (int tick = 0; tick < this.duration; tick++) {
+ total += this.liftAt(tick);
+ }
+ return total;
+ }
+}
diff --git a/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/ability/PowerUpAbilities.java b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/ability/PowerUpAbilities.java
new file mode 100644
index 000000000..9ebf259b1
--- /dev/null
+++ b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/ability/PowerUpAbilities.java
@@ -0,0 +1,41 @@
+package fr.hugman.mubble.world.power_up.ability;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+import net.minecraft.network.RegistryFriendlyByteBuf;
+import net.minecraft.network.codec.ByteBufCodecs;
+import net.minecraft.network.codec.StreamCodec;
+
+import java.util.Optional;
+
+/**
+ * What a power-up lets its holder do beyond its action, on keys the game already has.
+ *
+ * An action is what the power-up trigger key is worth, and a power-up only ever has one. Abilities are the
+ * rest: they hang off the movement a player was going to make anyway, so several of them can sit on the same
+ * power-up without ever getting in each other's way. Each is a set of numbers rather than a piece of code
+ * bound to one form, which is what lets two forms grant the very same ability on their own terms.
+ *
+ * The two below are the halves of a jump held on. A form is free to take both, as the Flower form does, or
+ * only the one it wants.
+ *
+ * @param flutter how the holder climbs again past the peak of a jump, if they can at all
+ * @param floating how the holder comes down once nothing is lifting them any more, if they do it slowly
+ */
+public record PowerUpAbilities(
+ Optional flutter,
+ Optional floating
+) {
+ public static final PowerUpAbilities EMPTY = new PowerUpAbilities(Optional.empty(), Optional.empty());
+
+ public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group(
+ FlutterAbility.CODEC.optionalFieldOf("flutter").forGetter(PowerUpAbilities::flutter),
+ FloatAbility.CODEC.optionalFieldOf("float").forGetter(PowerUpAbilities::floating)
+ ).apply(instance, PowerUpAbilities::new));
+
+ public static final StreamCodec STREAM_CODEC = StreamCodec.composite(
+ FlutterAbility.STREAM_CODEC.apply(ByteBufCodecs::optional), PowerUpAbilities::flutter,
+ FloatAbility.STREAM_CODEC.apply(ByteBufCodecs::optional), PowerUpAbilities::floating,
+ PowerUpAbilities::new
+ );
+}
diff --git a/mubble-core/src/main/resources/fabric.mod.json b/mubble-core/src/main/resources/fabric.mod.json
index ef7ab70f1..0879a6353 100644
--- a/mubble-core/src/main/resources/fabric.mod.json
+++ b/mubble-core/src/main/resources/fabric.mod.json
@@ -27,7 +27,10 @@
"loom:injected_interfaces": {
"net/minecraft/world/entity/player/Player": [
"fr/hugman/mubble/world/power_up/PowerUpHolder",
- "fr/hugman/mubble/world/entity/WaterRunner"
+ "fr/hugman/mubble/world/entity/WaterRunner",
+ "fr/hugman/mubble/world/entity/JumpKeyHolder",
+ "fr/hugman/mubble/world/entity/Fluttering",
+ "fr/hugman/mubble/world/entity/Floating"
]
}
},
diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/model/FlowerModel.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/model/FlowerModel.java
new file mode 100644
index 000000000..d33f3f8ca
--- /dev/null
+++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/model/FlowerModel.java
@@ -0,0 +1,56 @@
+package fr.hugman.mubble.super_mario.client.model;
+
+import fr.hugman.mubble.super_mario.client.renderer.entity.state.FlowerRenderState;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.minecraft.client.model.EntityModel;
+import net.minecraft.client.model.geom.ModelPart;
+import net.minecraft.client.model.geom.PartPose;
+import net.minecraft.client.model.geom.builders.CubeDeformation;
+import net.minecraft.client.model.geom.builders.CubeListBuilder;
+import net.minecraft.client.model.geom.builders.LayerDefinition;
+import net.minecraft.client.model.geom.builders.MeshDefinition;
+
+/**
+ * The huge flower grown by the Super Flower Pot: a bloom sitting on a stem, with a leaf on either side.
+ *
+ * Everything is authored right side up, since the renderer behind it draws entities straight rather than
+ * flipping them the way the humanoid ones are. It is kept a little inside the 2×2 blocks the entity is
+ * worth, so that the flower never pokes out of its own hitbox.
+ */
+@Environment(EnvType.CLIENT)
+public class FlowerModel extends EntityModel {
+ public static final String STEM = "stem";
+ public static final String BLOOM = "bloom";
+ public static final String LOWER_LEAF = "lower_leaf";
+ public static final String UPPER_LEAF = "upper_leaf";
+
+ public static final int TEXTURE_WIDTH = 256;
+ public static final int TEXTURE_HEIGHT = 64;
+
+ public FlowerModel(ModelPart root) {
+ super(root);
+ }
+
+ public static LayerDefinition getTexturedModelData() {
+ MeshDefinition modelData = new MeshDefinition();
+ var modelPartData = modelData.getRoot();
+ modelPartData.addOrReplaceChild(STEM,
+ CubeListBuilder.create().texOffs(0, 0).addBox(-2.0F, 0.0F, -2.0F, 4, 24, 4, new CubeDeformation(0.0F)),
+ PartPose.ZERO
+ );
+ modelPartData.addOrReplaceChild(BLOOM,
+ CubeListBuilder.create().texOffs(32, 0).addBox(-14.0F, 24.0F, -14.0F, 28, 6, 28, new CubeDeformation(0.0F)),
+ PartPose.ZERO
+ );
+ modelPartData.addOrReplaceChild(LOWER_LEAF,
+ CubeListBuilder.create().texOffs(0, 32).addBox(-12.0F, 6.0F, -1.0F, 10, 2, 2, new CubeDeformation(0.0F)),
+ PartPose.ZERO
+ );
+ modelPartData.addOrReplaceChild(UPPER_LEAF,
+ CubeListBuilder.create().texOffs(0, 40).addBox(2.0F, 13.0F, -1.0F, 10, 2, 2, new CubeDeformation(0.0F)),
+ PartPose.ZERO
+ );
+ return LayerDefinition.create(modelData, TEXTURE_WIDTH, TEXTURE_HEIGHT);
+ }
+}
diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/model/SuperMarioModelLayers.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/model/SuperMarioModelLayers.java
index b8ff66601..d9b441988 100644
--- a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/model/SuperMarioModelLayers.java
+++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/model/SuperMarioModelLayers.java
@@ -11,6 +11,7 @@ public class SuperMarioModelLayers {
public static final ModelLayerLocation GOOMBA = register("goomba", GoombaModel::getTexturedModelData);
public static final ModelLayerLocation KOOPA_SHELL = register("koopa_shell", KoopaShellModel::getTexturedModelData);
public static final ModelLayerLocation CLOUD_PLATFORM = register("cloud_platform", CloudPlatformModel::getTexturedModelData);
+ public static final ModelLayerLocation FLOWER = register("flower", FlowerModel::getTexturedModelData);
private static ModelLayerLocation register(String path, String layerName, ModelLayerRegistry.TexturedLayerDefinitionProvider provider) {
var layer = new ModelLayerLocation(SuperMario.id(path), layerName);
diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/SuperMarioRenderers.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/SuperMarioRenderers.java
index 07788e203..a0e026415 100644
--- a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/SuperMarioRenderers.java
+++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/SuperMarioRenderers.java
@@ -4,6 +4,7 @@
import fr.hugman.mubble.client.renderer.entity.BallRenderer;
import fr.hugman.mubble.super_mario.client.renderer.entity.BubbleRenderer;
import fr.hugman.mubble.super_mario.client.renderer.entity.CloudPlatformRenderer;
+import fr.hugman.mubble.super_mario.client.renderer.entity.FlowerRenderer;
import fr.hugman.mubble.super_mario.client.renderer.entity.GoombaRenderer;
import fr.hugman.mubble.super_mario.client.renderer.entity.KoopaShellRenderer;
import fr.hugman.mubble.super_mario.world.entity.SuperMarioEntityTypes;
@@ -22,6 +23,7 @@ public static void registerEntities() {
EntityRenderers.register(SuperMarioEntityTypes.GOLD_FIREBALL, BallRenderer::new);
EntityRenderers.register(SuperMarioEntityTypes.CLOUD_PLATFORM, CloudPlatformRenderer::new);
EntityRenderers.register(SuperMarioEntityTypes.BUBBLE, BubbleRenderer::new);
+ EntityRenderers.register(SuperMarioEntityTypes.FLOWER, FlowerRenderer::new);
}
public static void registerBlockEntities() {
diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/FlowerRenderer.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/FlowerRenderer.java
new file mode 100644
index 000000000..9da9bb41b
--- /dev/null
+++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/FlowerRenderer.java
@@ -0,0 +1,63 @@
+package fr.hugman.mubble.super_mario.client.renderer.entity;
+
+import com.mojang.blaze3d.vertex.PoseStack;
+import com.mojang.math.Axis;
+import fr.hugman.mubble.super_mario.client.model.FlowerModel;
+import fr.hugman.mubble.super_mario.client.model.SuperMarioModelLayers;
+import fr.hugman.mubble.super_mario.client.renderer.entity.state.FlowerRenderState;
+import fr.hugman.mubble.super_mario.SuperMario;
+import fr.hugman.mubble.super_mario.world.entity.projectile.Flower;
+import net.minecraft.client.renderer.SubmitNodeCollector;
+import net.minecraft.client.renderer.entity.EntityRenderer;
+import net.minecraft.client.renderer.entity.EntityRendererProvider;
+import net.minecraft.client.renderer.rendertype.RenderTypes;
+import net.minecraft.client.renderer.state.level.CameraRenderState;
+import net.minecraft.client.renderer.texture.OverlayTexture;
+import net.minecraft.resources.Identifier;
+import net.minecraft.util.Mth;
+
+/**
+ * Draws the huge flower, spinning slowly as it grows.
+ */
+public class FlowerRenderer extends EntityRenderer {
+ private static final Identifier TEXTURE = SuperMario.id("textures/entity/flower.png");
+ /** How far the flower turns over one block travelled, in degrees. */
+ private static final float SPIN_PER_BLOCK = 40.0F;
+ /** How much of its height the flower loses at the peak of the squish of a bounce. */
+ private static final float SQUISH_FLATTEN = 0.35F;
+ /** How much wider it goes at the same time, so that the squash keeps its bulk. */
+ private static final float SQUISH_BULGE = 0.2F;
+
+ private final FlowerModel model;
+
+ public FlowerRenderer(EntityRendererProvider.Context context) {
+ super(context);
+ this.model = new FlowerModel(context.bakeLayer(SuperMarioModelLayers.FLOWER));
+ }
+
+ @Override
+ public FlowerRenderState createRenderState() {
+ return new FlowerRenderState();
+ }
+
+ @Override
+ public void extractRenderState(Flower entity, FlowerRenderState state, float partialTicks) {
+ super.extractRenderState(entity, state, partialTicks);
+ state.travelled = (float) (entity.getTravelled() + entity.getSpeed() * partialTicks);
+ state.squish = entity.getSquish(partialTicks);
+ }
+
+ @Override
+ public void submit(FlowerRenderState state, PoseStack poseStack, SubmitNodeCollector submitNodeCollector, CameraRenderState camera) {
+ poseStack.pushPose();
+ poseStack.mulPose(Axis.YP.rotationDegrees(Mth.wrapDegrees(state.travelled * SPIN_PER_BLOCK)));
+ // Squashed against the ceiling it hit and spreading sideways, easing back out over the next few ticks.
+ if (state.squish > 0.0F) {
+ poseStack.scale(1.0F + SQUISH_BULGE * state.squish, 1.0F - SQUISH_FLATTEN * state.squish, 1.0F + SQUISH_BULGE * state.squish);
+ }
+ submitNodeCollector.submitModel(this.model, state, poseStack, RenderTypes.entityCutout(TEXTURE), state.lightCoords, OverlayTexture.NO_OVERLAY, state.outlineColor, null);
+ poseStack.popPose();
+
+ super.submit(state, poseStack, submitNodeCollector, camera);
+ }
+}
diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/state/FlowerRenderState.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/state/FlowerRenderState.java
new file mode 100644
index 000000000..12eea3c68
--- /dev/null
+++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/state/FlowerRenderState.java
@@ -0,0 +1,10 @@
+package fr.hugman.mubble.super_mario.client.renderer.entity.state;
+
+import net.minecraft.client.renderer.entity.state.EntityRenderState;
+
+public class FlowerRenderState extends EntityRenderState {
+ /** How far the flower has travelled, in blocks, which is what it spins to. */
+ public float travelled;
+ /** How squashed the flower is by a ceiling it just hit, from 0 (upright) to 1 (fully squished). */
+ public float squish;
+}
diff --git a/mubble-super_mario/src/client/resources/assets/super_mario/textures/entity/flower.png b/mubble-super_mario/src/client/resources/assets/super_mario/textures/entity/flower.png
new file mode 100644
index 000000000..88a41f5b3
Binary files /dev/null and b/mubble-super_mario/src/client/resources/assets/super_mario/textures/entity/flower.png differ
diff --git a/mubble-super_mario/src/client/resources/assets/super_mario/textures/item/super_flower_pot.png b/mubble-super_mario/src/client/resources/assets/super_mario/textures/item/super_flower_pot.png
new file mode 100644
index 000000000..68a93772a
Binary files /dev/null and b/mubble-super_mario/src/client/resources/assets/super_mario/textures/item/super_flower_pot.png differ
diff --git a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/PowerUpItems.java b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/PowerUpItems.java
index 3b60646f0..01f56cd48 100644
--- a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/PowerUpItems.java
+++ b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/PowerUpItems.java
@@ -19,7 +19,8 @@ public record Entry(ResourceKey- item, ResourceKey powerUp) { }
new Entry(SuperMarioItemIds.ICE_FLOWER, SuperMarioPowerUpIds.ICE),
new Entry(SuperMarioItemIds.GOLD_FLOWER, SuperMarioPowerUpIds.GOLD),
new Entry(SuperMarioItemIds.CLOUD_FLOWER, SuperMarioPowerUpIds.CLOUD),
- new Entry(SuperMarioItemIds.BUBBLE_FLOWER, SuperMarioPowerUpIds.BUBBLE)
+ new Entry(SuperMarioItemIds.BUBBLE_FLOWER, SuperMarioPowerUpIds.BUBBLE),
+ new Entry(SuperMarioItemIds.SUPER_FLOWER_POT, SuperMarioPowerUpIds.FLOWER)
);
public static ResourceKey
- getItem(ResourceKey powerUp) {
diff --git a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioDamageTypeProvider.java b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioDamageTypeProvider.java
index 5adbd2b3b..ee61a92af 100644
--- a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioDamageTypeProvider.java
+++ b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioDamageTypeProvider.java
@@ -33,5 +33,6 @@ public static void bootstrap(BootstrapContext context) {
context.register(SuperMarioDamageTypeIds.FIREBALL, new DamageType(SuperMario.MOD_ID + ".fireball", 0.1f, DamageEffects.BURNING));
context.register(SuperMarioDamageTypeIds.ICEBALL, new DamageType(SuperMario.MOD_ID + ".iceball", 0.1f, DamageEffects.FREEZING));
context.register(SuperMarioDamageTypeIds.GOLD_FIREBALL, new DamageType(SuperMario.MOD_ID + ".gold_fireball", 0.1f));
+ context.register(SuperMarioDamageTypeIds.FLOWER, new DamageType(SuperMario.MOD_ID + ".flower", 0.1f));
}
}
\ No newline at end of file
diff --git a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioEnglishLangProvider.java b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioEnglishLangProvider.java
index 1753265ab..8ab459300 100644
--- a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioEnglishLangProvider.java
+++ b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioEnglishLangProvider.java
@@ -44,7 +44,9 @@ public void generateTranslations(HolderLookup.Provider wrapperLookup, Translatio
builder.add("power_up." + SuperMario.MOD_ID + ".mega.description.trade_off", "Faster, tougher and stronger, but slow to swing.");
builder.add("power_up." + SuperMario.MOD_ID + ".cloud.description.float", "You jump higher and fall slower.");
builder.add("power_up." + SuperMario.MOD_ID + ".cloud.description.weather", "Water and rain wash it away.");
+ builder.add("power_up." + SuperMario.MOD_ID + ".flower.description.flutter", "Hold jump past the top of a jump to climb, then float down.");
builder.add("power_up_action_type." + SuperMario.MOD_ID + ".spawn_cloud_platform.description", "Press %s to summon a cloud platform.");
+ builder.add("power_up_action_type." + SuperMario.MOD_ID + ".grow_flower.description", "Press %s to grow a huge flower.");
builder.add("entity." + SuperMario.MOD_ID + ".goomba.mini", "Mini Goomba");
builder.add("item." + SuperMario.MOD_ID + ".mini_goomba_spawn_egg", "Mini Goomba Spawn Egg");
@@ -92,5 +94,7 @@ public void generateTranslations(HolderLookup.Provider wrapperLookup, Translatio
builder.add("death.attack." + SuperMario.MOD_ID + ".iceball.player", "%1$s was iceballed while fighting %2$s");
builder.add("death.attack." + SuperMario.MOD_ID + ".gold_fireball", "%1$s was gold-blasted by %2$s");
builder.add("death.attack." + SuperMario.MOD_ID + ".gold_fireball.player", "%1$s was gold-blasted while fighting %2$s");
+ builder.add("death.attack." + SuperMario.MOD_ID + ".flower", "%1$s was uprooted by %2$s");
+ builder.add("death.attack." + SuperMario.MOD_ID + ".flower.player", "%1$s was uprooted while fighting %2$s");
}
}
\ No newline at end of file
diff --git a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioEntityTypeTagsProvider.java b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioEntityTypeTagsProvider.java
index e08cdb796..8f3891337 100644
--- a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioEntityTypeTagsProvider.java
+++ b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioEntityTypeTagsProvider.java
@@ -25,8 +25,10 @@ protected void addTags(HolderLookup.Provider wrapperLookup) {
builder(CAN_STOMP).add(PLAYER);
builder(STOMPABLE).add(GOOMBA, GREEN_KOOPA_SHELL);
+ builder(ENEMIES).add(GOOMBA);
+
// FIREBALL is qualified because vanilla has one under that name too.
- builder(ALL).add(GOOMBA, GREEN_KOOPA_SHELL, RED_KOOPA_SHELL, SuperMarioEntityTypeIds.FIREBALL, ICEBALL, GOLD_FIREBALL, CLOUD_PLATFORM, BUBBLE);
+ builder(ALL).add(GOOMBA, GREEN_KOOPA_SHELL, RED_KOOPA_SHELL, SuperMarioEntityTypeIds.FIREBALL, ICEBALL, GOLD_FIREBALL, CLOUD_PLATFORM, BUBBLE, FLOWER);
// bosses shrug an ice ball off; every other mob is judged on its bulk alone
builder(FREEZE_IMMUNE).add(ENDER_DRAGON, WITHER);
diff --git a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioModelProvider.java b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioModelProvider.java
index 6a927b501..5fead1d9d 100644
--- a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioModelProvider.java
+++ b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioModelProvider.java
@@ -85,6 +85,7 @@ public void generateItemModels(ItemModelGenerators gen) {
gen.generateFlatItem(SuperMarioItems.GOLD_FLOWER, ModelTemplates.FLAT_ITEM);
gen.generateFlatItem(SuperMarioItems.CLOUD_FLOWER, ModelTemplates.FLAT_ITEM);
gen.generateFlatItem(SuperMarioItems.BUBBLE_FLOWER, ModelTemplates.FLAT_ITEM);
+ gen.generateFlatItem(SuperMarioItems.SUPER_FLOWER_POT, ModelTemplates.FLAT_ITEM);
gen.generateFlatItem(SuperMarioItems.CAPE_FEATHER, ModelTemplates.FLAT_ITEM);
gen.generateFlatItem(SuperMarioItems.SUPER_CAPE_FEATHER, ModelTemplates.FLAT_ITEM);
diff --git a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioPowerUpProvider.java b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioPowerUpProvider.java
index 5f05e09c1..a94ce28b1 100644
--- a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioPowerUpProvider.java
+++ b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioPowerUpProvider.java
@@ -4,10 +4,14 @@
import fr.hugman.mubble.super_mario.core.particles.SuperMarioParticleTypes;
import fr.hugman.mubble.super_mario.sounds.SuperMarioSounds;
import fr.hugman.mubble.super_mario.world.entity.SuperMarioEntityTypes;
+import fr.hugman.mubble.super_mario.world.entity.projectile.Flower;
+import fr.hugman.mubble.super_mario.world.power_up.action.GrowFlowerPowerUpAction;
import fr.hugman.mubble.super_mario.world.power_up.action.SpawnCloudPlatformPowerUpAction;
import fr.hugman.mubble.world.power_up.PowerUp;
import fr.hugman.mubble.world.power_up.PowerUpBuilder;
import fr.hugman.mubble.world.power_up.PowerUpCharges;
+import fr.hugman.mubble.world.power_up.ability.FloatAbility;
+import fr.hugman.mubble.world.power_up.ability.FlutterAbility;
import fr.hugman.mubble.world.power_up.action.ShootProjectilePowerUpAction;
import net.fabricmc.fabric.api.datagen.v1.FabricPackOutput;
import net.fabricmc.fabric.api.datagen.v1.provider.FabricDynamicRegistryProvider;
@@ -15,6 +19,9 @@
import net.minecraft.core.HolderLookup;
import net.minecraft.data.worldgen.BootstrapContext;
import net.minecraft.resources.ResourceKey;
+import net.minecraft.core.particles.ParticleTypes;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.sounds.SoundEvents;
import net.minecraft.world.entity.ai.attributes.Attributes;
import java.util.Optional;
@@ -123,6 +130,32 @@ public static void bootstrap(BootstrapContext context) {
PowerUpCharges.burst(2, 24)
)))
.build());
+ context.register(FLOWER, builder(FLOWER)
+ .description(FLOWER, "flutter")
+ .action(Holder.direct(new GrowFlowerPowerUpAction(
+ SuperMarioEntityTypes.FLOWER,
+ Flower.DEFAULT_SPEED,
+ Flower.DEFAULT_LIFETIME,
+ Flower.DEFAULT_RANGE,
+ // One flower at a time, the next one coming half a second after the last.
+ PowerUpCharges.cooldownRecharge(1, 10)
+ )))
+ // Placeholders until the two halves of the jump get assets of their own: leaves and a wing
+ // beat are what they should read as, and both come from vanilla for now.
+ .flutter(new FlutterAbility(
+ FlutterAbility.DEFAULT_DURATION,
+ FlutterAbility.DEFAULT_DROP,
+ FlutterAbility.DEFAULT_ACCELERATION,
+ Optional.of(BuiltInRegistries.SOUND_EVENT.wrapAsHolder(SoundEvents.BAT_LOOP)),
+ Optional.of(ParticleTypes.CHERRY_LEAVES)
+ ))
+ .floating(new FloatAbility(
+ FloatAbility.DEFAULT_SPEED,
+ FloatAbility.DEFAULT_FALL_DAMAGE,
+ Optional.of(BuiltInRegistries.SOUND_EVENT.wrapAsHolder(SoundEvents.BAT_LOOP)),
+ Optional.of(ParticleTypes.CHERRY_LEAVES)
+ ))
+ .build());
}
public static PowerUpBuilder builder(ResourceKey key, boolean withOverlay) {
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioDamageTypeIds.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioDamageTypeIds.java
index 75b46a780..ed8e537e7 100644
--- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioDamageTypeIds.java
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioDamageTypeIds.java
@@ -11,6 +11,7 @@ public class SuperMarioDamageTypeIds {
public static final ResourceKey FIREBALL = createKey("fireball");
public static final ResourceKey ICEBALL = createKey("iceball");
public static final ResourceKey GOLD_FIREBALL = createKey("gold_fireball");
+ public static final ResourceKey FLOWER = createKey("flower");
private static ResourceKey createKey(String path) {
return ResourceKey.create(Registries.DAMAGE_TYPE, SuperMario.id(path));
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioEntityTypeIds.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioEntityTypeIds.java
index 19e151f46..ee0c6e591 100644
--- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioEntityTypeIds.java
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioEntityTypeIds.java
@@ -13,6 +13,7 @@ public class SuperMarioEntityTypeIds {
public static final ResourceKey> GOLD_FIREBALL = createKey("gold_fireball");
public static final ResourceKey> CLOUD_PLATFORM = createKey("cloud_platform");
public static final ResourceKey> BUBBLE = createKey("bubble");
+ public static final ResourceKey> FLOWER = createKey("flower");
private static ResourceKey> createKey(String path) {
return ResourceKey.create(Registries.ENTITY_TYPE, SuperMario.id(path));
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioItemIds.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioItemIds.java
index 7517aad91..81282c823 100644
--- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioItemIds.java
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioItemIds.java
@@ -22,6 +22,7 @@ public class SuperMarioItemIds {
public static final ResourceKey
- CLOUD_FLOWER = createKey("cloud_flower");
public static final ResourceKey
- BUBBLE_FLOWER = createKey("bubble_flower");
+ public static final ResourceKey
- SUPER_FLOWER_POT = createKey("super_flower_pot");
public static final ResourceKey
- CAPE_FEATHER = createKey("cape_feather");
public static final ResourceKey
- SUPER_CAPE_FEATHER = createKey("super_cape_feather");
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioPowerUpActionTypesIds.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioPowerUpActionTypesIds.java
index a5bf76a0e..39dc495ff 100644
--- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioPowerUpActionTypesIds.java
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioPowerUpActionTypesIds.java
@@ -7,6 +7,7 @@
public class SuperMarioPowerUpActionTypesIds {
public static final ResourceKey> SPAWN_CLOUD_PLATFORM = createKey("spawn_cloud_platform");
+ public static final ResourceKey> GROW_FLOWER = createKey("grow_flower");
private static ResourceKey> createKey(String path) {
return ResourceKey.create(MubbleRegistries.POWER_UP_ACTION_TYPE, SuperMario.id(path));
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioPowerUpIds.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioPowerUpIds.java
index 9e3530199..b0c9778e8 100644
--- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioPowerUpIds.java
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/references/SuperMarioPowerUpIds.java
@@ -13,6 +13,7 @@ public class SuperMarioPowerUpIds {
public static final ResourceKey GOLD = createKey("gold");
public static final ResourceKey CLOUD = createKey("cloud");
public static final ResourceKey BUBBLE = createKey("bubble");
+ public static final ResourceKey FLOWER = createKey("flower");
private static ResourceKey createKey(String path) {
return ResourceKey.create(MubbleRegistries.POWER_UP, SuperMario.id(path));
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/tags/SuperMarioEntityTypeTags.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/tags/SuperMarioEntityTypeTags.java
index 17978338b..ca4c17ff8 100644
--- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/tags/SuperMarioEntityTypeTags.java
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/tags/SuperMarioEntityTypeTags.java
@@ -14,6 +14,8 @@ public class SuperMarioEntityTypeTags {
public static final TagKey> FREEZE_IMMUNE = bind("freeze_immune");
public static final TagKey> ALL = bind("all");
+ /** The enemies of the universe, which some of its attacks defeat outright rather than chip at. */
+ public static final TagKey> ENEMIES = bind("enemies");
public static final TagKey> BUBBLE_CAN_TRAP = bind("bubble_can_trap");
public static final TagKey> BUBBLE_CANNOT_TRAP = bind("bubble_cannot_trap");
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/SuperMarioEntityTypes.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/SuperMarioEntityTypes.java
index 8d9f11e16..9f11caaea 100644
--- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/SuperMarioEntityTypes.java
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/SuperMarioEntityTypes.java
@@ -21,6 +21,7 @@ public final class SuperMarioEntityTypes {
public static final EntityType GOLD_FIREBALL = register(SuperMarioEntityTypeIds.GOLD_FIREBALL, EntityType.Builder.of(GoldFireball::new, MobCategory.MISC).sized(0.4F, 0.4F).clientTrackingRange(4).updateInterval(10));
public static final EntityType CLOUD_PLATFORM = register(SuperMarioEntityTypeIds.CLOUD_PLATFORM, EntityType.Builder.of(CloudPlatform::new, MobCategory.MISC).sized(4.0F, 1.0F).clientTrackingRange(10));
public static final EntityType BUBBLE = register(SuperMarioEntityTypeIds.BUBBLE, EntityType.Builder.of(Bubble::new, MobCategory.MISC).sized(0.75F, 0.75F).clientTrackingRange(4).updateInterval(2));
+ public static final EntityType FLOWER = register(SuperMarioEntityTypeIds.FLOWER, EntityType.Builder.of(Flower::new, MobCategory.MISC).sized(Flower.SIZE, Flower.SIZE).clientTrackingRange(10).updateInterval(2));
private static EntityType register(ResourceKey> id, EntityType.Builder type) {
return Registry.register(BuiltInRegistries.ENTITY_TYPE, id, type.build(id));
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Flower.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Flower.java
new file mode 100644
index 000000000..818a7c4dc
--- /dev/null
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Flower.java
@@ -0,0 +1,532 @@
+package fr.hugman.mubble.super_mario.world.entity.projectile;
+
+import fr.hugman.mubble.super_mario.references.SuperMarioDamageTypeIds;
+import fr.hugman.mubble.super_mario.tags.SuperMarioEntityTypeTags;
+import fr.hugman.mubble.super_mario.world.entity.SuperMarioEntityTypes;
+import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
+import it.unimi.dsi.fastutil.ints.IntSet;
+import net.fabricmc.api.EnvType;
+import net.fabricmc.api.Environment;
+import net.minecraft.core.Direction;
+import net.minecraft.core.particles.ParticleOptions;
+import net.minecraft.core.particles.ParticleTypes;
+import net.minecraft.network.syncher.SynchedEntityData;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.sounds.SoundEvent;
+import net.minecraft.sounds.SoundEvents;
+import net.minecraft.util.Mth;
+import net.minecraft.world.damagesource.DamageSource;
+import net.minecraft.world.entity.Entity;
+import net.minecraft.world.entity.EntityReference;
+import net.minecraft.world.entity.EntityType;
+import net.minecraft.world.entity.LivingEntity;
+import net.minecraft.world.entity.MoverType;
+import net.minecraft.world.entity.OwnableEntity;
+import net.minecraft.world.entity.projectile.Projectile;
+import net.minecraft.world.entity.projectile.ProjectileDeflection;
+import net.minecraft.world.level.ClipContext;
+import net.minecraft.world.level.Level;
+import net.minecraft.world.level.block.state.BlockState;
+import net.minecraft.world.level.storage.ValueInput;
+import net.minecraft.world.level.storage.ValueOutput;
+import net.minecraft.world.phys.BlockHitResult;
+import net.minecraft.world.phys.EntityHitResult;
+import net.minecraft.world.phys.HitResult;
+import net.minecraft.world.phys.Vec3;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * A huge flower grown by the Super Flower Pot power-up.
+ *
+ * It is aimed at nothing: it goes straight up from where it was planted, at a speed of its own that neither
+ * gravity nor drag ever touches, and defeats whatever it grows through on the way — outright, for the enemies
+ * of the module.
+ *
+ * It cannot go through blocks, and a ceiling does not stop it either: it is knocked down and forward, along
+ * the way its holder was facing when they grew it, and then arcs back up. What bends that arc is gravity
+ * upside down — the flower is pulled towards the sky rather than away from it, so a duck reads as a jump
+ * played the wrong way round rather than as two changes of direction. A flower grown under a roof keeps
+ * working its way out from under it rather than dying against the first slab, and only ever runs out of time
+ * or of distance.
+ *
+ * It is not something to stand on, to shoot down or to bounce off: it is only ever in the way of what it is
+ * about to hit. Blocks it runs into are hit the way any projectile hits them, and the ones it passes through
+ * are entered the way any entity enters them.
+ *
+ * @since v4.0.0
+ */
+public class Flower extends Projectile {
+ /** Both the width and the height of a flower: these are 2×2 blocks, not small projectiles. */
+ public static final float SIZE = 2.0F;
+
+ /** How fast a flower travels, in blocks per tick. */
+ public static final double DEFAULT_SPEED = 0.5D;
+ /** How long a flower lasts at most, in ticks. */
+ public static final int DEFAULT_LIFETIME = 30;
+ /** How far a flower can travel before it wilts, in blocks. */
+ public static final double DEFAULT_RANGE = 12.0D;
+ /** The damage a flower deals, the same as the ball projectiles of the mod. */
+ public static final float DAMAGE = 3.0F;
+
+ /**
+ * Gravity, upside down, as a share of the speed of the flower: what it gains towards the sky every tick,
+ * and therefore how tightly a duck arcs back into a climb.
+ */
+ private static final double LIFT = 0.16D;
+ /** The share of its speed a ceiling knocks a flower down by, the way a jump is a push off the ground. */
+ private static final double ESCAPE_DROP = 0.6D;
+ /** The share of its speed a ceiling sends a flower forward by, which is what carries it out from under. */
+ private static final double ESCAPE_FORWARD = 1.0D;
+ /** What the forward drift of a knock keeps every tick, so that it eases off rather than being switched off. */
+ private static final double ESCAPE_DRAG = 0.85D;
+ /** Below this, a drift is worth nothing and is dropped, so that a settled flower climbs exactly straight. */
+ private static final double MIN_DRIFT = 1.0E-4D;
+ /** Ticks the squish of hitting a ceiling lasts. */
+ public static final int SQUISH_DURATION = 6;
+ private static final byte EVENT_SQUISH = 100;
+
+ /** Particles spawned per tick, strung along the ground the flower covers during it. */
+ private static final int PARTICLES_PER_TICK = 3;
+ /** How far the particles scatter around the middle of the flower, as a share of its width. */
+ private static final double PARTICLE_SPREAD = 0.9D;
+ private static final int WILT_PARTICLES = 12;
+
+ private static final String AGE_KEY = "age";
+ private static final String TRAVELLED_KEY = "travelled";
+ private static final String SPEED_KEY = "speed";
+ private static final String LIFETIME_KEY = "lifetime";
+ private static final String RANGE_KEY = "range";
+ private static final String FORWARD_YAW_KEY = "forward_yaw";
+
+ private double speed = DEFAULT_SPEED;
+ private int lifetime = DEFAULT_LIFETIME;
+ private double range = DEFAULT_RANGE;
+ /** The way its holder was facing when they grew it, which is the way a bounce sends it on. */
+ private float forwardYaw;
+
+ private int age;
+ private double travelled;
+ /**
+ * Everything already hit, so that a flower only ever hits the same entity once.
+ *
+ * Deliberately not saved: these are network ids, which nothing hands back to the same entity once the
+ * world has been reloaded, and a flower lasting a second and a half is never around to see one anyway.
+ */
+ private final IntSet hitEntities = new IntOpenHashSet();
+
+ private int squishTicks;
+ private int squishTicksO;
+
+ public Flower(EntityType extends Flower> type, Level level) {
+ super(type, level);
+ this.setDeltaMovement(0.0D, DEFAULT_SPEED, 0.0D);
+ }
+
+ public Flower(Level level, LivingEntity owner) {
+ this(SuperMarioEntityTypes.FLOWER, level);
+ this.setOwner(owner);
+ this.setForwardYaw(owner.getYRot());
+ }
+
+ @Override
+ protected void defineSynchedData(SynchedEntityData.Builder builder) {
+ }
+
+ //region Settings
+
+ public double getSpeed() {
+ return this.speed;
+ }
+
+ public void setSpeed(double speed) {
+ this.speed = speed;
+ // A flower that has not gone anywhere yet is still pointing straight up, and takes the new speed
+ // right away — including in the packet that spawns it on the clients. One already on its way keeps
+ // whatever heading it is on, and is brought back up to the new speed by the pull towards the sky.
+ if (this.travelled <= 0.0D) {
+ this.setDeltaMovement(0.0D, speed, 0.0D);
+ }
+ }
+
+ public int getLifetime() {
+ return this.lifetime;
+ }
+
+ public void setLifetime(int lifetime) {
+ this.lifetime = lifetime;
+ }
+
+ /**
+ * @return how far the flower can travel, in blocks, counting the whole of its path and not just its climb
+ */
+ public double getRange() {
+ return this.range;
+ }
+
+ public void setRange(double range) {
+ this.range = range;
+ }
+
+ public float getForwardYaw() {
+ return this.forwardYaw;
+ }
+
+ public void setForwardYaw(float forwardYaw) {
+ this.forwardYaw = forwardYaw;
+ }
+
+ /**
+ * @return the way ducking out of a ceiling sends the flower on, as a horizontal unit vector
+ */
+ public Vec3 getForward() {
+ float yaw = this.forwardYaw * (float) (Math.PI / 180.0);
+ return new Vec3(-Mth.sin(yaw), 0.0D, Mth.cos(yaw));
+ }
+
+ /**
+ * @return how far the flower has already travelled, in blocks
+ */
+ public double getTravelled() {
+ return this.travelled;
+ }
+
+ /**
+ * @return how much a flower gains towards the sky every tick, in blocks per tick per tick
+ */
+ public double getLiftPerTick() {
+ return this.speed * LIFT;
+ }
+
+ /**
+ * @return whether the flower is still winning back the speed a ceiling knocked out of it, rather than
+ * climbing at its own
+ */
+ public boolean isEscaping() {
+ return this.getDeltaMovement().y() < this.speed - MIN_DRIFT;
+ }
+
+ //endregion
+
+ //region Ticking
+
+ @Override
+ public void tick() {
+ // Server-side only: the sound it plays is broadcast to every client, which would double up on
+ // the ones ticking the flower themselves.
+ if (this.firstTick && !this.level().isClientSide()) {
+ this.playSound(this.getGrowthSound(), 1.0F, 0.6F);
+ }
+
+ super.tick();
+ this.grow();
+
+ if (this.level().isClientSide()) {
+ this.tickSquish();
+ return;
+ }
+ if (this.isRemoved()) {
+ return;
+ }
+
+ this.age++;
+ this.hitEntitiesInTheWay();
+ if (this.age >= this.lifetime || this.travelled >= this.range) {
+ this.wilt();
+ }
+ }
+
+ /**
+ * Takes the flower along one tick's worth of its path.
+ *
+ * Nothing is added to the movement and nothing is taken off it: two flowers grown from the same spot
+ * follow the exact same path, whatever is going on around them.
+ */
+ private void grow() {
+ Vec3 movement = this.getDeltaMovement();
+ Vec3 before = this.position();
+ this.move(MoverType.SELF, movement);
+ // move() only records which blocks were crossed; this is what actually runs their "entity inside"
+ // behaviour. Without it the flower ignores tripwires, pressure plates and every other trigger block.
+ this.applyEffectsFromBlocks();
+ this.travelled += this.position().subtract(before).length();
+ this.needsSync = true;
+
+ if (this.level().isClientSide()) {
+ this.spawnGrowthParticles();
+ } else if (this.horizontalCollision || this.verticalCollision) {
+ this.hitBlocks(movement);
+ // Only a ceiling knocks the flower away. Everything else it runs into, the pull towards the sky
+ // sorts out on its own: the movement it just lost is won back over the ticks that follow.
+ if (this.verticalCollision && movement.y() > 0.0D) {
+ this.escape();
+ }
+ }
+ this.accelerate();
+ }
+
+ /**
+ * Pulls the flower towards the sky, and lets the drift of a duck ease off.
+ *
+ * This is the whole of what bends a duck back into a climb. Gravity upside down turns the knock of a
+ * ceiling around over several ticks rather than at once, and tops the flower out at its own speed on the
+ * way back up, so the path it takes is the arc of a jump rather than two corners.
+ */
+ private void accelerate() {
+ Vec3 movement = this.getDeltaMovement();
+ double y = Math.min(this.speed, movement.y() + this.getLiftPerTick());
+ double x = movement.x() * ESCAPE_DRAG;
+ double z = movement.z() * ESCAPE_DRAG;
+ this.setDeltaMovement(Math.abs(x) < MIN_DRIFT ? 0.0D : x, y, Math.abs(z) < MIN_DRIFT ? 0.0D : z);
+ }
+
+ /**
+ * Knocks the flower down and forward off the ceiling it just hit, along the way its holder was facing.
+ *
+ * This is not the end of its climb but the start of an attempt to get out from under whatever is in the
+ * way: it is a push and nothing more, and {@link #accelerate} is what turns it back into a climb. A
+ * ceiling the flower fails to clear simply knocks it down again, until it runs out of time or distance.
+ */
+ private void escape() {
+ Vec3 forward = this.getForward().scale(this.speed * ESCAPE_FORWARD);
+ this.setDeltaMovement(forward.x(), -this.speed * ESCAPE_DROP, forward.z());
+ this.level().broadcastEntityEvent(this, EVENT_SQUISH);
+ this.playSound(this.getEscapeSound(), 0.7F, 1.4F);
+ }
+
+ /**
+ * Hits whatever the flower ran into the way any projectile would, so that target blocks and everything
+ * else keyed on being shot at answer to it.
+ *
+ * {@link Entity#move} zeroes out whichever component ran into a block, which is how the faces that were
+ * actually hit are found.
+ *
+ * @param requested the movement the flower asked for, before collisions cut it short
+ */
+ private void hitBlocks(Vec3 requested) {
+ Vec3 actual = this.getDeltaMovement();
+ if (blocked(requested.x(), actual.x())) {
+ this.hitBlock(Direction.get(requested.x() > 0.0D ? Direction.AxisDirection.POSITIVE : Direction.AxisDirection.NEGATIVE, Direction.Axis.X));
+ }
+ if (blocked(requested.y(), actual.y())) {
+ this.hitBlock(Direction.get(requested.y() > 0.0D ? Direction.AxisDirection.POSITIVE : Direction.AxisDirection.NEGATIVE, Direction.Axis.Y));
+ }
+ if (blocked(requested.z(), actual.z())) {
+ this.hitBlock(Direction.get(requested.z() > 0.0D ? Direction.AxisDirection.POSITIVE : Direction.AxisDirection.NEGATIVE, Direction.Axis.Z));
+ }
+ }
+
+ private static boolean blocked(double requested, double actual) {
+ return Math.abs(requested) > 1.0E-7D && Math.abs(actual) < 1.0E-7D;
+ }
+
+ private void hitBlock(Direction direction) {
+ Vec3 from = this.getBoundingBox().getCenter();
+ Vec3 to = from.add(Vec3.atLowerCornerOf(direction.getUnitVec3i()).scale(SIZE / 2.0D + 0.25D));
+ BlockHitResult hit = this.level().clip(new ClipContext(from, to, ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this));
+ if (hit.getType() != HitResult.Type.BLOCK) {
+ return;
+ }
+ BlockState state = this.level().getBlockState(hit.getBlockPos());
+ state.onProjectileHit(this.level(), state, hit, this);
+ }
+
+ /**
+ * Defeats whatever the flower is growing through.
+ *
+ * A flower is not spent by what it hits: it keeps going until it runs out of time or of distance, which
+ * is what lets one flower clear a whole column of enemies. It only ever hits the same one once, though.
+ */
+ private void hitEntitiesInTheWay() {
+ if (!(this.level() instanceof ServerLevel serverLevel)) {
+ return;
+ }
+ for (Entity entity : this.level().getEntities(this, this.getBoundingBox(), this::canHurt)) {
+ if (!this.hitEntities.add(entity.getId())) {
+ continue;
+ }
+ if (this.getOwner() instanceof LivingEntity owner) {
+ owner.setLastHurtMob(entity);
+ }
+ // The enemies of the module go down in one, the way they do in the games they come from; anything
+ // else takes what a ball would have dealt.
+ float damage = entity.is(SuperMarioEntityTypeTags.ENEMIES) ? Float.MAX_VALUE : DAMAGE;
+ entity.hurtServer(serverLevel, this.damageSources().source(SuperMarioDamageTypeIds.FLOWER, this, this.getOwner()), damage);
+ }
+ }
+
+ /**
+ * @return whether the flower is allowed to hurt the given entity
+ */
+ private boolean canHurt(Entity target) {
+ if (!(target instanceof LivingEntity) || !target.isAlive() || target.isRemoved() || target.isSpectator()) {
+ return false;
+ }
+ if (!target.canBeHitByProjectile()) {
+ return false;
+ }
+ Entity owner = this.getOwner();
+ if (owner == null) {
+ return true;
+ }
+ if (target == owner || owner.isAlliedTo(target) || target.isAlliedTo(owner)) {
+ return false;
+ }
+ // A pet is spared whatever the teams say: it belongs to the very player who grew the flower.
+ return !(target instanceof OwnableEntity ownable) || ownable.getRootOwner() != owner;
+ }
+
+ private void wilt() {
+ if (this.level() instanceof ServerLevel serverLevel) {
+ serverLevel.sendParticles(this.getWiltParticle(),
+ this.getX(), this.getY() + SIZE / 2.0D, this.getZ(),
+ WILT_PARTICLES, SIZE / 4.0D, SIZE / 4.0D, SIZE / 4.0D, 0.0D);
+ }
+ this.level().playSound(null, this.getX(), this.getY(), this.getZ(), this.getWiltSound(), this.getSoundSource(), 0.7F, 1.0F);
+ this.discard();
+ }
+
+ /**
+ * Strings the growth particles along the ground the flower covered during the tick, rather than dropping
+ * them all where it ended up: a flower moving half a block a tick would otherwise leave a dotted line.
+ */
+ private void spawnGrowthParticles() {
+ ParticleOptions particle = this.getGrowthParticle();
+ Vec3 movement = this.getDeltaMovement();
+ double spread = this.getBbWidth() * PARTICLE_SPREAD;
+ for (int i = 0; i < PARTICLES_PER_TICK; i++) {
+ Vec3 back = movement.scale((i + 0.5D) / PARTICLES_PER_TICK);
+ this.level().addParticle(particle,
+ this.getRandomX(spread) - back.x(),
+ this.getY() + this.getBbHeight() / 2.0D - back.y(),
+ this.getRandomZ(spread) - back.z(),
+ 0.0D, 0.0D, 0.0D);
+ }
+ }
+
+ protected ParticleOptions getGrowthParticle() {
+ return ParticleTypes.HAPPY_VILLAGER;
+ }
+
+ protected ParticleOptions getWiltParticle() {
+ return ParticleTypes.CHERRY_LEAVES;
+ }
+
+ protected SoundEvent getGrowthSound() {
+ return SoundEvents.BONE_MEAL_USE;
+ }
+
+ protected SoundEvent getEscapeSound() {
+ return SoundEvents.AZALEA_LEAVES_HIT;
+ }
+
+ protected SoundEvent getWiltSound() {
+ return SoundEvents.AZALEA_LEAVES_BREAK;
+ }
+
+ //endregion
+
+ //region Animation
+
+ private void tickSquish() {
+ this.squishTicksO = this.squishTicks;
+ if (this.squishTicks > 0) {
+ this.squishTicks--;
+ }
+ }
+
+ /**
+ * @return how squashed the flower is by the ceiling it just hit, from 0 (upright) to 1 (fully squished)
+ */
+ public float getSquish(float partialTicks) {
+ float ticks = Mth.lerp(partialTicks, this.squishTicksO, this.squishTicks);
+ if (ticks <= 0.0F) {
+ return 0.0F;
+ }
+ // Fully squished on impact, easing back out.
+ return Mth.sin((ticks / SQUISH_DURATION) * (Mth.PI / 2.0F));
+ }
+
+ @Environment(EnvType.CLIENT)
+ @Override
+ public void handleEntityEvent(byte state) {
+ if (state == EVENT_SQUISH) {
+ this.squishTicks = SQUISH_DURATION;
+ this.squishTicksO = SQUISH_DURATION;
+ } else {
+ super.handleEntityEvent(state);
+ }
+ }
+
+ //endregion
+
+ //region Physics
+
+ @Override
+ protected double getDefaultGravity() {
+ return 0.0D;
+ }
+
+ /** Hits are decided from the bounding box, in {@link #hitEntitiesInTheWay}. */
+ @Override
+ protected boolean canHitEntity(Entity target) {
+ return false;
+ }
+
+ @Override
+ protected void onHitEntity(EntityHitResult result) {
+ }
+
+ /** Blocks are handled from the actual movement in {@link #grow}. */
+ @Override
+ protected void onHitBlock(BlockHitResult result) {
+ }
+
+ /** A flower is grown through, not stood on. */
+ @Override
+ public boolean canBeCollidedWith(@Nullable Entity other) {
+ return false;
+ }
+
+ @Override
+ public boolean isPickable() {
+ return false;
+ }
+
+ @Override
+ public boolean hurtServer(ServerLevel level, DamageSource source, float amount) {
+ return false;
+ }
+
+ @Override
+ public boolean deflect(ProjectileDeflection deflection, @Nullable Entity entity, @Nullable EntityReference owner, boolean fromAttack) {
+ return false;
+ }
+
+ //endregion
+
+ //region Saving
+
+ @Override
+ protected void addAdditionalSaveData(ValueOutput output) {
+ super.addAdditionalSaveData(output);
+ output.putInt(AGE_KEY, this.age);
+ output.putDouble(TRAVELLED_KEY, this.travelled);
+ output.putDouble(SPEED_KEY, this.speed);
+ output.putInt(LIFETIME_KEY, this.lifetime);
+ output.putDouble(RANGE_KEY, this.range);
+ output.putFloat(FORWARD_YAW_KEY, this.forwardYaw);
+ }
+
+ @Override
+ protected void readAdditionalSaveData(ValueInput input) {
+ super.readAdditionalSaveData(input);
+ this.age = input.getIntOr(AGE_KEY, 0);
+ this.travelled = input.getDoubleOr(TRAVELLED_KEY, 0.0D);
+ this.speed = input.getDoubleOr(SPEED_KEY, DEFAULT_SPEED);
+ this.lifetime = input.getIntOr(LIFETIME_KEY, DEFAULT_LIFETIME);
+ this.range = input.getDoubleOr(RANGE_KEY, DEFAULT_RANGE);
+ this.forwardYaw = input.getFloatOr(FORWARD_YAW_KEY, 0.0F);
+ }
+
+ //endregion
+}
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/item/SuperMarioCreativeModeTabs.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/item/SuperMarioCreativeModeTabs.java
index f1d53711d..702fd617e 100644
--- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/item/SuperMarioCreativeModeTabs.java
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/item/SuperMarioCreativeModeTabs.java
@@ -50,6 +50,7 @@ public static void appendItemGroups() {
entries.accept(SuperMarioItems.GOLD_FLOWER);
entries.accept(SuperMarioItems.CLOUD_FLOWER);
entries.accept(SuperMarioItems.BUBBLE_FLOWER);
+ entries.accept(SuperMarioItems.SUPER_FLOWER_POT);
entries.accept(SuperMarioItems.MINI_MUSHROOM);
entries.accept(SuperMarioItems.MEGA_MUSHROOM);
entries.accept(SuperMarioBlocks.QUESTION_BLOCK);
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/item/SuperMarioItems.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/item/SuperMarioItems.java
index d623e5e07..a3443de2a 100644
--- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/item/SuperMarioItems.java
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/item/SuperMarioItems.java
@@ -39,6 +39,7 @@ public class SuperMarioItems {
public static final PowerUpItem GOLD_FLOWER = registerPowerUp(SuperMarioItemIds.GOLD_FLOWER, SuperMarioPowerUpIds.GOLD);
public static final PowerUpItem CLOUD_FLOWER = registerPowerUp(SuperMarioItemIds.CLOUD_FLOWER, SuperMarioPowerUpIds.CLOUD);
public static final PowerUpItem BUBBLE_FLOWER = registerPowerUp(SuperMarioItemIds.BUBBLE_FLOWER, SuperMarioPowerUpIds.BUBBLE);
+ public static final PowerUpItem SUPER_FLOWER_POT = registerPowerUp(SuperMarioItemIds.SUPER_FLOWER_POT, SuperMarioPowerUpIds.FLOWER);
public static final CapeFeatherItem CAPE_FEATHER = register(SuperMarioItemIds.CAPE_FEATHER, s -> new CapeFeatherItem(s, false));
public static final CapeFeatherItem SUPER_CAPE_FEATHER = register(SuperMarioItemIds.SUPER_CAPE_FEATHER, s -> new CapeFeatherItem(s.rarity(Rarity.EPIC), true));
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/power_up/action/GrowFlowerPowerUpAction.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/power_up/action/GrowFlowerPowerUpAction.java
new file mode 100644
index 000000000..e0f30edfe
--- /dev/null
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/power_up/action/GrowFlowerPowerUpAction.java
@@ -0,0 +1,161 @@
+package fr.hugman.mubble.super_mario.world.power_up.action;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.MapCodec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+import fr.hugman.mubble.keybind.MubbleKeyBindingsKeys;
+import fr.hugman.mubble.super_mario.world.entity.projectile.Flower;
+import fr.hugman.mubble.world.power_up.PowerUpCharges;
+import fr.hugman.mubble.world.power_up.PowerUpProperties;
+import fr.hugman.mubble.world.power_up.action.PowerUpAction;
+import fr.hugman.mubble.world.power_up.action.PowerUpActionType;
+import net.minecraft.ChatFormatting;
+import net.minecraft.core.component.DataComponentGetter;
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.network.RegistryFriendlyByteBuf;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.codec.ByteBufCodecs;
+import net.minecraft.network.codec.StreamCodec;
+import net.minecraft.util.Mth;
+import net.minecraft.world.InteractionResult;
+import net.minecraft.world.entity.EntitySpawnReason;
+import net.minecraft.world.entity.EntityType;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.item.TooltipFlag;
+import net.minecraft.world.item.component.TooltipProvider;
+import net.minecraft.world.phys.Vec3;
+
+import java.util.function.Consumer;
+
+/**
+ * Grows a huge flower in front of its holder, which then rises on its own and defeats whatever it meets.
+ *
+ * Unlike the balls the other forms throw, nothing about the shot is aimed: the flower always goes straight
+ * up, and where the holder is looking only decides which side of them it is planted on.
+ *
+ * @param entity the flower to grow
+ * @param speed how fast it travels, in blocks per tick
+ * @param lifetime how long it lasts at most, in ticks
+ * @param range how far it can travel before it wilts, in blocks
+ * @param charges how many flowers the holder gets, and how spent ones come back
+ */
+public record GrowFlowerPowerUpAction(
+ EntityType> entity,
+ double speed,
+ int lifetime,
+ double range,
+ PowerUpCharges charges
+) implements PowerUpAction, TooltipProvider {
+ /** How far in front of the holder the flower is planted, so that its 2×2 model does not clip into them. */
+ private static final double REACH = 1.0D;
+
+ public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group(
+ BuiltInRegistries.ENTITY_TYPE.byNameCodec().fieldOf("entity").forGetter(GrowFlowerPowerUpAction::entity),
+ Codec.DOUBLE.optionalFieldOf("speed", Flower.DEFAULT_SPEED).forGetter(GrowFlowerPowerUpAction::speed),
+ Codec.INT.optionalFieldOf("lifetime", Flower.DEFAULT_LIFETIME).forGetter(GrowFlowerPowerUpAction::lifetime),
+ Codec.DOUBLE.optionalFieldOf("range", Flower.DEFAULT_RANGE).forGetter(GrowFlowerPowerUpAction::range),
+ PowerUpCharges.CODEC.optionalFieldOf("charges", PowerUpCharges.DEFAULT).forGetter(GrowFlowerPowerUpAction::charges)
+ ).apply(instance, GrowFlowerPowerUpAction::new));
+
+ public static final StreamCodec STREAM_CODEC = StreamCodec.composite(
+ ByteBufCodecs.registry(Registries.ENTITY_TYPE), GrowFlowerPowerUpAction::entity,
+ ByteBufCodecs.DOUBLE, GrowFlowerPowerUpAction::speed,
+ ByteBufCodecs.INT, GrowFlowerPowerUpAction::lifetime,
+ ByteBufCodecs.DOUBLE, GrowFlowerPowerUpAction::range,
+ PowerUpCharges.STREAM_CODEC, GrowFlowerPowerUpAction::charges,
+ GrowFlowerPowerUpAction::new
+ );
+
+ @Override
+ public PowerUpActionType> getType() {
+ return SuperMarioPowerUpActionTypes.GROW_FLOWER;
+ }
+
+ @Override
+ public boolean canBeRefilled() {
+ return true;
+ }
+
+ @Override
+ public PowerUpProperties setUpProperties() {
+ return this.charges.createProperties();
+ }
+
+ @Override
+ public boolean canBeTriggered(Player player) {
+ var properties = properties(player);
+ if (!player.level().isClientSide()) {
+ properties.doSoftChecks(player);
+ }
+ return properties.getChargeCount() > 0;
+ }
+
+ @Override
+ public InteractionResult trigger(Player player) {
+ var properties = properties(player);
+ var level = player.level();
+
+ if (level.isClientSide()) {
+ return InteractionResult.SUCCESS;
+ }
+
+ var entity = this.entity.create(level, EntitySpawnReason.TRIGGERED);
+ if (entity == null) {
+ return InteractionResult.FAIL;
+ }
+ if (entity instanceof Flower flower) {
+ flower.setOwner(player);
+ flower.setSpeed(this.speed);
+ flower.setLifetime(this.lifetime);
+ flower.setRange(this.range);
+ // Where a ceiling will send it on, since the holder is free to turn away in the meantime.
+ flower.setForwardYaw(player.getYRot());
+ }
+
+ Vec3 spot = plantingSpot(player, entity.getBbWidth());
+ entity.setPos(spot.x(), spot.y(), spot.z());
+ level.addFreshEntity(entity);
+
+ properties.useCharge();
+ properties.trackEntity(entity.getUUID());
+ return InteractionResult.SUCCESS;
+ }
+
+ /**
+ * Where the flower is planted: at the feet of the holder, one step ahead of them in the direction they
+ * are facing, and centred on the spot rather than standing next to it.
+ */
+ public static Vec3 plantingSpot(Player player, float width) {
+ float yaw = player.getYRot() * (float) (Math.PI / 180.0);
+ double reach = REACH + width / 2.0D;
+ return new Vec3(
+ player.getX() - Mth.sin(yaw) * reach,
+ player.getY(),
+ player.getZ() + Mth.cos(yaw) * reach
+ );
+ }
+
+ private PowerUpProperties properties(Player player) {
+ var properties = player.getPowerUpProperties();
+ if (properties == null) {
+ properties = this.setUpProperties();
+ player.setPowerUpProperties(properties);
+ }
+ return properties;
+ }
+
+ @Override
+ public boolean shouldSwingOtherHand() {
+ return true;
+ }
+
+ @Override
+ public void addToTooltip(Item.TooltipContext context, Consumer textConsumer, TooltipFlag type, DataComponentGetter components) {
+ this.getTranslationKey().ifPresent(key -> textConsumer.accept(Component.translatable(
+ key + ".description",
+ Component.keybind(MubbleKeyBindingsKeys.TRIGGER_POWER_UP)
+ ).withStyle(ChatFormatting.GRAY)));
+ }
+}
diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/power_up/action/SuperMarioPowerUpActionTypes.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/power_up/action/SuperMarioPowerUpActionTypes.java
index bc75cd59a..94d4d2b22 100644
--- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/power_up/action/SuperMarioPowerUpActionTypes.java
+++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/power_up/action/SuperMarioPowerUpActionTypes.java
@@ -12,6 +12,7 @@
public class SuperMarioPowerUpActionTypes {
public static final PowerUpActionType SPAWN_CLOUD_PLATFORM = register(SuperMarioPowerUpActionTypesIds.SPAWN_CLOUD_PLATFORM, SpawnCloudPlatformPowerUpAction.CODEC, SpawnCloudPlatformPowerUpAction.STREAM_CODEC);
+ public static final PowerUpActionType GROW_FLOWER = register(SuperMarioPowerUpActionTypesIds.GROW_FLOWER, GrowFlowerPowerUpAction.CODEC, GrowFlowerPowerUpAction.STREAM_CODEC);
public static PowerUpActionType register(ResourceKey> key, MapCodec codec, StreamCodec super RegistryFriendlyByteBuf, T> streamCodec) {
return Registry.register(MubbleBuiltInRegistries.POWER_UP_ACTION_TYPE, key, new PowerUpActionType<>(codec, streamCodec));
diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/datapack/PowerUpFixtures.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/datapack/PowerUpFixtures.java
index b7261cc72..728893174 100644
--- a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/datapack/PowerUpFixtures.java
+++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/datapack/PowerUpFixtures.java
@@ -48,6 +48,12 @@ public class PowerUpFixtures {
/** Shoots two snowballs, so that running out of charges takes two triggers and not a dozen. */
public static final ResourceKey SHOOTER = powerUp("shooter");
+ /** Grants nothing but the climb half of a jump held on, on numbers of its own rather than the defaults. */
+ public static final ResourceKey FLUTTERER = powerUp("flutterer");
+
+ /** Grants nothing but the descent half, which is the shape the Tanooki form will take. */
+ public static final ResourceKey FLOATER = powerUp("floater");
+
/** The power-up registry of the level the test runs in. */
public static HolderGetter registry(GameTestHelper helper) {
return helper.getLevel().registryAccess().lookupOrThrow(MubbleRegistries.POWER_UP);
diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/FloatGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/FloatGameTest.java
new file mode 100644
index 000000000..11d5cb035
--- /dev/null
+++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/FloatGameTest.java
@@ -0,0 +1,216 @@
+package fr.hugman.mubble.test.gametest.power_up;
+
+import fr.hugman.mubble.test.gametest.datapack.PowerUpFixtures;
+import fr.hugman.mubble.test.gametest.support.Arena;
+import fr.hugman.mubble.test.gametest.support.TestPlayers;
+import fr.hugman.mubble.world.power_up.PowerUp;
+import net.fabricmc.fabric.api.gametest.v1.GameTest;
+import net.minecraft.core.BlockPos;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.entity.player.Input;
+import net.minecraft.world.level.block.Blocks;
+
+/**
+ * The descent half of a jump held on: a holder leaning on the jump key comes down at a walking pace and is
+ * spared most of what the fall would otherwise be worth.
+ *
+ * The fixture behind these tests grants the float and no climb at all, which is the shape the Tanooki form
+ * will take: it has to work on its own, without a flutter first.
+ *
+ * @see FlutterGameTest for the other half
+ */
+public class FloatGameTest {
+ private static final BlockPos STAND = new BlockPos(4, Arena.FLOOR_Y + 1, 3);
+
+ /** The speed of the fixture, see {@code floater.json}. */
+ private static final double FLOAT_SPEED = 0.1D;
+ /** The share of a floated fall the fixture still counts. */
+ private static final double FLOAT_FALL_DAMAGE = 0.25D;
+ /** Enough ticks for a dropped player to be falling well past the float's pace. */
+ private static final int DROP_TICKS = 8;
+ private static final double EPSILON = 1.0E-4D;
+
+ private static final Input JUMP_HELD = TestPlayers.holdingJump();
+ private static final Input NOTHING_HELD = Input.EMPTY;
+
+ /** The Tanooki case: no climb to come first, just a jump that ends gently. */
+ @GameTest
+ public void aHeldJumpKeySlowsTheFall(GameTestHelper helper) {
+ var player = falling(helper, PowerUpFixtures.FLOATER);
+
+ fall(player, JUMP_HELD, 3);
+ double descent = descentOverOneTick(player, JUMP_HELD);
+
+ helper.assertTrue(player.isFloating(), "a jump key held on the way down should start a float");
+ helper.assertTrue(descent <= FLOAT_SPEED + EPSILON,
+ "a floating player should come down no faster than the float, dropped " + descent + " in a tick");
+ helper.succeed();
+ }
+
+ /**
+ * The descent is a pace rather than a slowing down: every tick of it covers the same ground, however long
+ * the float has been going on for.
+ */
+ @GameTest
+ public void aFloatComesDownAtOneSteadySpeed(GameTestHelper helper) {
+ var player = floatingPlayer(helper);
+
+ for (int tick = 0; tick < 5; tick++) {
+ // Not an exact comparison: the ability keeps its speed as a float, and the descent is a double.
+ double descent = descentOverOneTick(player, JUMP_HELD);
+ helper.assertTrue(Math.abs(descent - FLOAT_SPEED) < EPSILON,
+ "tick " + tick + " of a descent should cover the float's speed, covered " + descent);
+ }
+ helper.succeed();
+ }
+
+ @GameTest
+ public void aFloatIsSlowerThanAPlainFall(GameTestHelper helper) {
+ var floating = falling(helper, PowerUpFixtures.FLOATER);
+ var plain = falling(helper, PowerUpFixtures.EMPTY);
+
+ fall(floating, JUMP_HELD, 6);
+ fall(plain, JUMP_HELD, 6);
+
+ helper.assertTrue(floating.getY() > plain.getY(),
+ "a floating player should be higher up than one dropping plainly, was "
+ + floating.getY() + " against " + plain.getY());
+ helper.succeed();
+ }
+
+ @GameTest
+ public void lettingGoOfTheJumpKeyEndsTheFloat(GameTestHelper helper) {
+ var player = floatingPlayer(helper);
+
+ fall(player, NOTHING_HELD, 3);
+ double descent = descentOverOneTick(player, NOTHING_HELD);
+
+ helper.assertFalse(player.isFloating(), "letting go of the jump key should end the float");
+ helper.assertTrue(descent > FLOAT_SPEED,
+ "a player who let go should be dropping faster than the float again, dropped " + descent + " in a tick");
+ helper.succeed();
+ }
+
+ /** Unlike the flutter, which a jump only ever gets one of, the float comes back on the next press. */
+ @GameTest
+ public void aFloatCanBeStartedAgainOnTheSameFall(GameTestHelper helper) {
+ var player = floatingPlayer(helper);
+
+ fall(player, NOTHING_HELD, 3);
+ helper.assertFalse(player.isFloating(), "the float never ended, the test proves nothing");
+ fall(player, JUMP_HELD, 2);
+
+ helper.assertTrue(player.isFloating(), "pressing the jump key again should start the float back up");
+ helper.succeed();
+ }
+
+ /** It slows a fall down; it is not a second jump, and has no business lifting anyone. */
+ @GameTest
+ public void aFloatNeverLiftsAClimbingPlayer(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var player = TestPlayers.at(helper, STAND.above(2));
+ player.setPowerUp(PowerUpFixtures.get(helper, PowerUpFixtures.FLOATER));
+ player.setDeltaMovement(0.0D, 0.42D, 0.0D);
+ TestPlayers.tick(player, JUMP_HELD);
+
+ double climbing = player.getDeltaMovement().y();
+ TestPlayers.tick(player, JUMP_HELD);
+
+ helper.assertTrue(climbing > 0.0D, "the player is not on the way up, the test proves nothing");
+ helper.assertFalse(player.isFloating(), "a climbing player should not be floating");
+ helper.assertTrue(player.getDeltaMovement().y() < climbing,
+ "gravity should still be taking its share of a climb, even with a float in hand");
+ helper.succeed();
+ }
+
+ @GameTest
+ public void aFloatSparesMostOfTheFall(GameTestHelper helper) {
+ var floating = floatingPlayer(helper);
+ double before = floating.fallDistance;
+
+ int ticks = 10;
+ fall(floating, JUMP_HELD, ticks);
+
+ // Each floated tick is worth its speed, of which only the fixture's share is kept.
+ double covered = FLOAT_SPEED * ticks;
+ double expected = before + covered * FLOAT_FALL_DAMAGE;
+ helper.assertTrue(floating.fallDistance <= expected + 0.5D,
+ "a floated fall should mostly be written off, was " + floating.fallDistance + " against about " + expected);
+ helper.assertTrue(floating.fallDistance < before + covered,
+ "a floated fall should count for less than the distance actually covered");
+ helper.succeed();
+ }
+
+ @GameTest
+ public void aPowerUpWithoutAFloatNeverFloats(GameTestHelper helper) {
+ var player = falling(helper, PowerUpFixtures.EMPTY);
+
+ fall(player, JUMP_HELD, 4);
+
+ helper.assertFalse(player.isFloating(), "a power-up granting no float should not float");
+ helper.succeed();
+ }
+
+ @GameTest
+ public void waterCutsTheFloatShort(GameTestHelper helper) {
+ var player = floatingPlayer(helper);
+
+ for (int y = Arena.FLOOR_Y + 1; y < Arena.SIZE; y++) {
+ helper.setBlock(new BlockPos(STAND.getX(), y, STAND.getZ()), Blocks.WATER);
+ }
+ TestPlayers.tick(player, JUMP_HELD);
+
+ helper.assertTrue(player.isInWater(), "the player is not in the water, the test proves nothing");
+ helper.assertFalse(player.isFloating(), "going into the water should cut the float short");
+ helper.succeed();
+ }
+
+ @GameTest(maxTicks = 200)
+ public void landingEndsTheFloat(GameTestHelper helper) {
+ var player = floatingPlayer(helper);
+
+ fall(player, JUMP_HELD, 120);
+
+ helper.assertTrue(player.onGround(), "the player never landed, the test proves nothing");
+ helper.assertFalse(player.isFloating(), "a player back on the ground should not still be floating");
+ helper.succeed();
+ }
+
+ /** A player already floating, well into their descent. */
+ private static ServerPlayer floatingPlayer(GameTestHelper helper) {
+ var player = falling(helper, PowerUpFixtures.FLOATER);
+ fall(player, JUMP_HELD, 3);
+ helper.assertTrue(player.isFloating(), "the float never started, the test proves nothing");
+ return player;
+ }
+
+ /** A player dropped from the top of the arena, falling fast enough for a float to be worth something. */
+ private static ServerPlayer falling(GameTestHelper helper, ResourceKey powerUp) {
+ Arena.buildFloor(helper);
+ var player = TestPlayers.at(helper, STAND.above(Arena.SIZE - 2));
+ player.setPowerUp(PowerUpFixtures.get(helper, powerUp));
+
+ fall(player, NOTHING_HELD, DROP_TICKS);
+ return player;
+ }
+
+ /**
+ * How far the player actually drops over one tick.
+ *
+ * Not the same thing as their movement, which gravity is added back to once the tick has been spent: what
+ * a float holds down is the ground covered, and that is what has to be measured.
+ */
+ private static double descentOverOneTick(ServerPlayer player, Input keys) {
+ double before = player.getY();
+ TestPlayers.tick(player, keys);
+ return before - player.getY();
+ }
+
+ private static void fall(ServerPlayer player, Input keys, int ticks) {
+ for (int tick = 0; tick < ticks; tick++) {
+ TestPlayers.tick(player, keys);
+ }
+ }
+}
diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/FlutterGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/FlutterGameTest.java
new file mode 100644
index 000000000..a84fcda9b
--- /dev/null
+++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/FlutterGameTest.java
@@ -0,0 +1,259 @@
+package fr.hugman.mubble.test.gametest.power_up;
+
+import fr.hugman.mubble.test.gametest.datapack.PowerUpFixtures;
+import fr.hugman.mubble.test.gametest.support.Arena;
+import fr.hugman.mubble.test.gametest.support.TestPlayers;
+import net.fabricmc.fabric.api.gametest.v1.GameTest;
+import net.minecraft.core.BlockPos;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.resources.ResourceKey;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.entity.player.Input;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.phys.Vec3;
+import fr.hugman.mubble.world.power_up.PowerUp;
+
+/**
+ * The climb half of a jump held on: past the top of a jump, a holder still leaning on the jump key rises
+ * again instead of falling, once per jump and for as long as the ability lasts.
+ *
+ * The fixture behind these tests climbs for 20 ticks and grants no float, so that what happens after the
+ * climb is a plain fall and nothing else. Every test drives the player the way a client would, since the
+ * jump key and the movement the flutter reads both only ever reach the server as packets.
+ *
+ * @see FloatGameTest for the other half
+ */
+public class FlutterGameTest {
+ private static final BlockPos STAND = new BlockPos(4, Arena.FLOOR_Y + 1, 3);
+
+ /** The upward push a jump is worth, near enough to what {@code jumpFromGround} gives a player. */
+ private static final Vec3 JUMP = new Vec3(0.0D, 0.42D, 0.0D);
+ /** The duration of the fixture, see {@code flutterer.json}. */
+ private static final int FLUTTER_DURATION = 10;
+ /** Long enough for a jump to peak and for the flutter to have started, with all of it still to run. */
+ private static final int PEAK_TICKS = 8;
+ /** Long enough for a flutter to have turned its opening drop around and won height back. */
+ private static final int CARRY_TICKS = 16;
+ /** Long enough for anything left in the air to have come back down. */
+ private static final int LANDING_TICKS = 40;
+
+ private static final Input JUMP_HELD = TestPlayers.holdingJump();
+ private static final Input NOTHING_HELD = Input.EMPTY;
+
+ @GameTest
+ public void aHeldJumpKeyFluttersPastThePeak(GameTestHelper helper) {
+ var player = jumper(helper, PowerUpFixtures.FLUTTERER);
+
+ helper.assertFalse(player.isFluttering(), "the flutter should not start on the way up");
+ fall(player, JUMP_HELD, PEAK_TICKS);
+
+ helper.assertTrue(player.isFluttering(), "a jump key held past the peak should start a flutter");
+ helper.succeed();
+ }
+
+ /** Nothing changes on the way up: a flutter that started there would just be a stronger jump. */
+ @GameTest
+ public void theFlutterWaitsForTheWayDown(GameTestHelper helper) {
+ var player = jumper(helper, PowerUpFixtures.FLUTTERER);
+
+ helper.assertTrue(player.getKnownMovement().y() > 0.0D, "the player is not on the way up, the test proves nothing");
+ helper.assertFalse(player.isFluttering(), "a flutter should not start while the player is still climbing");
+
+ helper.succeed();
+ }
+
+ @GameTest
+ public void aFlutterCarriesThePlayerHigher(GameTestHelper helper) {
+ var fluttering = jumper(helper, PowerUpFixtures.FLUTTERER);
+ var plain = jumper(helper, PowerUpFixtures.EMPTY);
+
+ fall(fluttering, JUMP_HELD, CARRY_TICKS);
+ fall(plain, JUMP_HELD, CARRY_TICKS);
+
+ helper.assertTrue(fluttering.getY() > plain.getY(),
+ "a fluttering player should be higher up than one falling plainly, was "
+ + fluttering.getY() + " against " + plain.getY());
+ helper.succeed();
+ }
+
+ /**
+ * The feel of the thing: the holder keeps sinking for a moment after the key catches, and only then is
+ * carried up. A flutter that lifted from its very first tick would just be a second jump.
+ */
+ @GameTest
+ public void theFlutterHangsBeforeItLifts(GameTestHelper helper) {
+ var player = jumper(helper, PowerUpFixtures.FLUTTERER);
+
+ // Up to the very tick the flutter takes over, so that it is that one being measured.
+ double before = player.getY();
+ for (int tick = 0; tick < 20 && !player.isFluttering(); tick++) {
+ before = player.getY();
+ TestPlayers.tick(player, JUMP_HELD);
+ }
+ helper.assertTrue(player.isFluttering(), "the flutter never started, the test proves nothing");
+ helper.assertTrue(player.getY() < before, "the first tick of a flutter should still be sinking, not lifting");
+
+ double hung = player.getY();
+ fall(player, JUMP_HELD, 7);
+
+ helper.assertTrue(player.getY() > hung, "the flutter should turn its own drop around and carry the player up");
+ helper.succeed();
+ }
+
+ /** A flutter builds rather than holding one speed, which is what tells it apart from a second jump. */
+ @GameTest
+ public void theClimbKeepsBuilding(GameTestHelper helper) {
+ var player = flutteringPlayer(helper);
+
+ double before = player.getY();
+ fall(player, JUMP_HELD, 3);
+ double early = player.getY() - before;
+
+ before = player.getY();
+ fall(player, JUMP_HELD, 3);
+ double later = player.getY() - before;
+
+ helper.assertTrue(player.isFluttering(), "the flutter ended halfway through, the test proves nothing");
+ helper.assertTrue(later > early,
+ "three later ticks of a flutter should carry further than three earlier ones, was " + later + " against " + early);
+ helper.succeed();
+ }
+
+ @GameTest
+ public void lettingGoOfTheJumpKeyEndsTheFlutter(GameTestHelper helper) {
+ var player = flutteringPlayer(helper);
+
+ TestPlayers.tick(player, NOTHING_HELD);
+
+ helper.assertFalse(player.isFluttering(), "letting go of the jump key should end the flutter");
+ helper.succeed();
+ }
+
+ /** A jump only ever gets one flutter, whether it was spent whole or let go of halfway through. */
+ @GameTest
+ public void aFlutterLetGoOfCannotBeResumed(GameTestHelper helper) {
+ var player = flutteringPlayer(helper);
+
+ TestPlayers.tick(player, NOTHING_HELD);
+ fall(player, JUMP_HELD, 5);
+
+ helper.assertFalse(player.isFluttering(), "a flutter let go of should not start again on the same jump");
+ helper.succeed();
+ }
+
+ /** The tick count is what the lift ramps up over, so it has to count the flutter and nothing else. */
+ @GameTest
+ public void theFlutterCountsTheTicksItHasRunFor(GameTestHelper helper) {
+ var player = flutteringPlayer(helper);
+ int started = player.getFlutterTicks();
+
+ fall(player, JUMP_HELD, 3);
+ helper.assertValueEqual(player.getFlutterTicks(), started + 3, "the ticks a flutter has run for");
+
+ fall(player, NOTHING_HELD, 1);
+ helper.assertValueEqual(player.getFlutterTicks(), 0, "the ticks left on a flutter that is over");
+
+ helper.succeed();
+ }
+
+ @GameTest
+ public void aFlutterRunsOutAfterItsDuration(GameTestHelper helper) {
+ var player = flutteringPlayer(helper);
+
+ fall(player, JUMP_HELD, FLUTTER_DURATION + 1);
+
+ helper.assertFalse(player.isFluttering(), "a flutter should be over once its duration has run out");
+ helper.assertTrue(player.hasFluttered(), "the jump should be marked as having spent its flutter");
+ helper.succeed();
+ }
+
+ @GameTest(maxTicks = 200)
+ public void landingHandsTheNextJumpItsFlutterBack(GameTestHelper helper) {
+ var player = flutteringPlayer(helper);
+ fall(player, JUMP_HELD, FLUTTER_DURATION + 1);
+ helper.assertTrue(player.hasFluttered(), "the flutter was never spent, the test proves nothing");
+
+ fall(player, NOTHING_HELD, LANDING_TICKS);
+
+ helper.assertTrue(player.onGround(), "the player never landed, the test proves nothing");
+ helper.assertFalse(player.hasFluttered(), "landing should hand the next jump its flutter back");
+ helper.succeed();
+ }
+
+ @GameTest
+ public void aPowerUpWithoutAFlutterNeverFlutters(GameTestHelper helper) {
+ var player = jumper(helper, PowerUpFixtures.EMPTY);
+
+ fall(player, JUMP_HELD, PEAK_TICKS);
+
+ helper.assertFalse(player.isFluttering(), "a power-up granting no flutter should not flutter");
+ helper.succeed();
+ }
+
+ /** The form can be lost in mid-air, and the flutter has no business carrying on without it. */
+ @GameTest
+ public void losingTheFormMidAirEndsTheFlutter(GameTestHelper helper) {
+ var player = flutteringPlayer(helper);
+
+ player.clearPowerUp();
+ TestPlayers.tick(player, JUMP_HELD);
+
+ helper.assertFalse(player.isFluttering(), "losing the power-up should end the flutter");
+ helper.succeed();
+ }
+
+ @GameTest
+ public void waterCutsTheFlutterShort(GameTestHelper helper) {
+ var player = flutteringPlayer(helper);
+
+ for (int y = Arena.FLOOR_Y + 1; y < Arena.SIZE; y++) {
+ helper.setBlock(new BlockPos(STAND.getX(), y, STAND.getZ()), Blocks.WATER);
+ }
+ TestPlayers.tick(player, JUMP_HELD);
+
+ helper.assertTrue(player.isInWater(), "the player is not in the water, the test proves nothing");
+ helper.assertFalse(player.isFluttering(), "going into the water should cut the flutter short");
+ helper.succeed();
+ }
+
+ /** A player standing on the ground with the key held down is jumping, not fluttering. */
+ @GameTest
+ public void nothingFluttersOnTheGround(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var player = TestPlayers.at(helper, STAND);
+ player.setPowerUp(PowerUpFixtures.get(helper, PowerUpFixtures.FLUTTERER));
+
+ fall(player, JUMP_HELD, 5);
+
+ helper.assertTrue(player.onGround(), "the player is not on the ground, the test proves nothing");
+ helper.assertFalse(player.isFluttering(), "a player standing on the ground should not flutter");
+ helper.succeed();
+ }
+
+ /** A player in the middle of a jump, holding the key, with the flutter already going. */
+ private static ServerPlayer flutteringPlayer(GameTestHelper helper) {
+ var player = jumper(helper, PowerUpFixtures.FLUTTERER);
+ fall(player, JUMP_HELD, PEAK_TICKS);
+ helper.assertTrue(player.isFluttering(), "the flutter never started, the test proves nothing");
+ return player;
+ }
+
+ /**
+ * A player who has just jumped: off the ground, on the way up, and reporting it.
+ */
+ private static ServerPlayer jumper(GameTestHelper helper, ResourceKey powerUp) {
+ Arena.buildFloor(helper);
+ var player = TestPlayers.at(helper, STAND);
+ player.setPowerUp(PowerUpFixtures.get(helper, powerUp));
+
+ player.setDeltaMovement(JUMP);
+ TestPlayers.tick(player, TestPlayers.holdingJump());
+ return player;
+ }
+
+ private static void fall(ServerPlayer player, Input keys, int ticks) {
+ for (int tick = 0; tick < ticks; tick++) {
+ TestPlayers.tick(player, keys);
+ }
+ }
+}
diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/FlowerGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/FlowerGameTest.java
new file mode 100644
index 000000000..4fb2dd3f6
--- /dev/null
+++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/FlowerGameTest.java
@@ -0,0 +1,342 @@
+package fr.hugman.mubble.test.gametest.super_mario;
+
+import fr.hugman.mubble.super_mario.world.entity.SuperMarioEntityTypes;
+import fr.hugman.mubble.super_mario.world.entity.monster.goomba.Goomba;
+import fr.hugman.mubble.super_mario.world.entity.projectile.Flower;
+import fr.hugman.mubble.test.gametest.support.Arena;
+import fr.hugman.mubble.test.gametest.support.TestPlayers;
+import net.fabricmc.fabric.api.gametest.v1.GameTest;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.world.entity.EntityTypes;
+import net.minecraft.world.entity.animal.pig.Pig;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.TripWireHookBlock;
+import net.minecraft.world.level.block.state.properties.BlockStateProperties;
+import net.minecraft.world.phys.Vec3;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The huge flower the Super Flower Pot grows: it rises on its own, defeats whatever it grows through, arcs
+ * out from under the ceilings it meets rather than dying against them, and runs out of both time and
+ * distance so that it never travels forever.
+ */
+public class FlowerGameTest {
+ /** Where a flower is grown from, in structure-relative coordinates. */
+ private static final BlockPos GROUND = new BlockPos(3, Arena.FLOOR_Y + 1, 3);
+ /** Enough ticks for a flower on its own numbers to be well on its way, and none of them wasted. */
+ private static final int RISING_TICKS = 6;
+ /** Enough ticks to catch a whole duck, from the knock of the ceiling back to a full climb. */
+ private static final int ARC_SAMPLE_TICKS = 40;
+ private static final double EPSILON = 1.0E-6D;
+
+ @GameTest
+ public void aFlowerRisesStraightUp(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var flower = grow(helper);
+ Vec3 start = flower.position();
+
+ helper.startSequence()
+ .thenIdle(RISING_TICKS)
+ .thenExecute(() -> {
+ helper.assertTrue(flower.getY() > start.y(), "the flower should be going up");
+ helper.assertValueEqual(flower.getX(), start.x(), "the x a flower drifted to");
+ helper.assertValueEqual(flower.getZ(), start.z(), "the z a flower drifted to");
+ })
+ .thenSucceed();
+ }
+
+ /**
+ * Two flowers grown from the same spot have to follow the exact same path, which they only do as long
+ * as nothing touches their speed. Both halves of that are checked from what the flower has travelled so
+ * far, rather than from a tick count the test would have to guess at.
+ */
+ @GameTest
+ public void aFlowerRisesAtAConstantSpeed(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var flower = grow(helper);
+ double start = flower.getY();
+
+ helper.startSequence()
+ .thenIdle(RISING_TICKS)
+ .thenExecute(() -> {
+ helper.assertValueEqual(flower.getDeltaMovement(), new Vec3(0.0D, flower.getSpeed(), 0.0D),
+ "the movement of a flower that gravity and drag should never touch");
+ helper.assertValueEqual(flower.getTravelled(), flower.getSpeed() * flower.tickCount,
+ "the distance a flower travelled over its whole life");
+ helper.assertValueEqual(flower.getY() - start, flower.getTravelled(),
+ "the distance a flower travelled, against where it actually ended up");
+ })
+ .thenSucceed();
+ }
+
+ /** A flower cannot go through blocks: a ceiling knocks it down and forward rather than stopping it. */
+ @GameTest
+ public void aCeilingKnocksAFlowerDownAndForward(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var flower = grow(helper);
+ // Facing north, so the duck should carry it towards a smaller z.
+ flower.setForwardYaw(Direction.NORTH.toYRot());
+ ceilingAt(helper, 5);
+ double planted = flower.getZ();
+
+ helper.startSequence()
+ .thenWaitUntil(() -> helper.assertTrue(flower.isEscaping(), "the flower never came off the ceiling"))
+ .thenExecute(() -> {
+ helper.assertFalse(flower.isRemoved(), "a ceiling should send a flower on rather than end it");
+ helper.assertTrue(flower.getDeltaMovement().y() < 0.0D,
+ "a flower knocked off a ceiling should be heading back down, was " + flower.getDeltaMovement());
+ helper.assertTrue(flower.getDeltaMovement().z() < 0.0D,
+ "a flower thrown north should be carried north by the knock, was " + flower.getDeltaMovement());
+ })
+ .thenIdle(3)
+ .thenExecute(() -> helper.assertTrue(flower.getZ() < planted, "the flower never actually moved forward off the ceiling"))
+ .thenSucceed();
+ }
+
+ /**
+ * The shape of the whole thing: a ceiling has to bend the flower's path rather than break it.
+ *
+ * What that means tick by tick is that the vertical speed only ever climbs back, by no more than the pull
+ * towards the sky each time — a flower that snapped out of its duck would gain the whole of its speed in
+ * one tick, and one that snapped into it would never pass through the speeds in between.
+ */
+ @GameTest(maxTicks = 200)
+ public void aKnockArcsBackIntoAClimbRatherThanSnapping(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var flower = grow(helper);
+ flower.setLifetime(Integer.MAX_VALUE);
+ flower.setRange(Double.MAX_VALUE);
+ // A roof it can actually get out from under: knocked once, it drifts clear and climbs freely, which
+ // is the whole arc in one piece. Boxed in, it would be knocked again halfway back up.
+ roofUpTo(helper, 5, GROUND.getZ());
+
+ List climb = new ArrayList<>();
+ helper.startSequence()
+ .thenExecuteFor(ARC_SAMPLE_TICKS, () -> climb.add(flower.getDeltaMovement().y()))
+ .thenExecute(() -> assertArc(helper, climb, flower.getSpeed(), flower.getLiftPerTick()))
+ .thenSucceed();
+ }
+
+ /**
+ * Walks the vertical speeds of one duck, from the tick the ceiling knocked the flower down to the tick it
+ * is climbing at full speed again, and checks that every step in between is one the pull upwards could
+ * have made.
+ */
+ private static void assertArc(GameTestHelper helper, List climb, double speed, double lift) {
+ int knock = climb.indexOf(climb.stream().filter(y -> y < 0.0D).findFirst()
+ .orElseThrow(() -> new AssertionError("the flower never came off the ceiling at all, saw " + climb)));
+
+ double previous = climb.get(knock);
+ for (int tick = knock + 1; tick < climb.size(); tick++) {
+ double y = climb.get(tick);
+ if (y >= speed - EPSILON) {
+ // Back to a full climb, and it took more than the one tick a snap would have.
+ helper.assertTrue(tick - knock > 1, "the flower snapped back into its climb in a single tick");
+ return;
+ }
+ helper.assertTrue(y > previous, "the arc should keep coming back up, went from " + previous + " to " + y);
+ helper.assertTrue(y - previous <= lift + EPSILON,
+ "the arc should gain no more than the pull upwards in a tick, went from " + previous + " to " + y);
+ previous = y;
+ }
+ throw new AssertionError("the flower never got back to a full climb, saw " + climb);
+ }
+
+ /** Nothing it runs into ends a flower: it only ever runs out of time or of distance. */
+ @GameTest(maxTicks = 200)
+ public void aFlowerBoxedInKeepsTryingUntilItRunsOut(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var flower = grow(helper);
+ flower.setLifetime(Integer.MAX_VALUE);
+ flower.setRange(8.0D);
+ ceilingAt(helper, 5);
+
+ helper.startSequence()
+ .thenWaitUntil(() -> helper.assertTrue(flower.isRemoved(), "the flower never ran out at all"))
+ .thenExecute(() -> helper.assertTrue(flower.getTravelled() >= 8.0D,
+ "a boxed-in flower should have run out of distance rather than been stopped, travelled " + flower.getTravelled()))
+ .thenSucceed();
+ }
+
+ /** A flower hits what it runs into the way any projectile does, which is what target blocks answer to. */
+ @GameTest
+ public void aFlowerTriggersTargetBlocks(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ BlockPos target = new BlockPos(GROUND.getX(), 5, GROUND.getZ());
+ helper.setBlock(target, Blocks.TARGET);
+ grow(helper);
+
+ helper.succeedWhen(() -> helper.assertTrue(helper.getBlockState(target).getValue(BlockStateProperties.POWER) > 0,
+ "the flower should trigger the target block it grows into"));
+ }
+
+ /**
+ * A flower is a projectile like any other as far as the redstone it moves through is concerned: a whole
+ * tripwire, hooks and all, so that the test covers what a player would actually build.
+ */
+ @GameTest
+ public void aFlowerTriggersTripwireHooks(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ int y = GROUND.getY() + 3;
+ int z = GROUND.getZ();
+ // A wire strung between two hooks, running through the column the flower grows in.
+ helper.setBlock(new BlockPos(GROUND.getX() - 2, y, z), Blocks.STONE);
+ helper.setBlock(new BlockPos(GROUND.getX() + 2, y, z), Blocks.STONE);
+ BlockPos hook = new BlockPos(GROUND.getX() - 1, y, z);
+ BlockPos farHook = new BlockPos(GROUND.getX() + 1, y, z);
+ helper.setBlock(hook, Blocks.TRIPWIRE_HOOK.defaultBlockState().setValue(TripWireHookBlock.FACING, Direction.EAST));
+ helper.setBlock(new BlockPos(GROUND.getX(), y, z), Blocks.TRIPWIRE);
+ helper.setBlock(farHook, Blocks.TRIPWIRE_HOOK.defaultBlockState().setValue(TripWireHookBlock.FACING, Direction.WEST));
+ // Placing the blocks outright leaves the hooks unaware of each other; this is the pass vanilla runs
+ // when a player puts one down, and what actually strings the wire between them.
+ attach(helper, hook);
+ attach(helper, farHook);
+
+ helper.assertBlockProperty(hook, TripWireHookBlock.ATTACHED, true);
+ grow(helper);
+
+ helper.succeedWhen(() -> helper.assertBlockProperty(hook, TripWireHookBlock.POWERED, true));
+ }
+
+ @GameTest
+ public void aFlowerWiltsOnceItsTimeIsUp(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var flower = grow(helper);
+ flower.setLifetime(6);
+ // Well out of reach, so that the height limit cannot be what ends this one.
+ flower.setRange(Double.MAX_VALUE);
+
+ helper.startSequence()
+ .thenIdle(3)
+ .thenExecute(() -> helper.assertFalse(flower.isRemoved(), "the flower wilted before its time was up"))
+ .thenWaitUntil(() -> helper.assertTrue(flower.isRemoved(), "the flower should wilt once its lifetime has run out"))
+ .thenSucceed();
+ }
+
+ @GameTest
+ public void aFlowerWiltsOnceItHasTravelledFarEnough(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var flower = grow(helper);
+ flower.setLifetime(Integer.MAX_VALUE);
+ flower.setRange(2.0D);
+
+ helper.succeedWhen(() -> {
+ helper.assertTrue(flower.isRemoved(), "the flower should wilt once it has travelled its whole range");
+ helper.assertTrue(flower.getTravelled() >= 2.0D, "the flower wilted before travelling its whole range");
+ });
+ }
+
+ /** The enemies of the module go down in one, the way they do in the games they come from. */
+ @GameTest
+ public void aFlowerDefeatsMarioEnemiesOutright(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Goomba goomba = helper.spawnWithNoFreeWill(SuperMarioEntityTypes.GOOMBA, GROUND.above(2));
+ helper.assertTrue(goomba.getMaxHealth() > Flower.DAMAGE,
+ "a goomba that a plain hit would kill anyway proves nothing, it has " + goomba.getMaxHealth() + " health");
+ grow(helper);
+
+ helper.succeedWhen(() -> helper.assertTrue(goomba.isDeadOrDying(), "the flower should defeat a Mario enemy outright"));
+ }
+
+ /** Everything else takes what a ball would have dealt, and lives to tell the tale. */
+ @GameTest
+ public void aFlowerOnlyChipsAtEverythingElse(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, GROUND.above(2));
+ grow(helper);
+
+ helper.startSequence()
+ .thenWaitUntil(() -> helper.assertTrue(pig.getHealth() < pig.getMaxHealth(), "the pig was never hit at all"))
+ .thenExecute(() -> {
+ helper.assertFalse(pig.isDeadOrDying(), "only the enemies of the module should go down in one");
+ helper.assertValueEqual(pig.getHealth(), pig.getMaxHealth() - Flower.DAMAGE, "the health left on a hit pig");
+ })
+ .thenSucceed();
+ }
+
+ @GameTest
+ public void aFlowerDefeatsWhatItGrowsThrough(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, GROUND.above(2));
+ grow(helper);
+
+ helper.succeedWhen(() -> helper.assertTrue(pig.getHealth() < pig.getMaxHealth(), "the flower should hurt what it grows through"));
+ }
+
+ /**
+ * One flower is worth one hit per entity, however long it lingers inside their hitbox. It is grown
+ * slowly here so that it stays in the pig well past the invulnerability a second hit would land in.
+ */
+ @GameTest(maxTicks = 200)
+ public void aFlowerOnlyEverHitsTheSameEntityOnce(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, GROUND);
+ var flower = grow(helper);
+ flower.setSpeed(0.01D);
+ flower.setLifetime(Integer.MAX_VALUE);
+
+ float[] afterFirstHit = new float[1];
+ helper.startSequence()
+ .thenWaitUntil(() -> helper.assertTrue(pig.getHealth() < pig.getMaxHealth(), "the pig was never hit at all"))
+ .thenExecute(() -> afterFirstHit[0] = pig.getHealth())
+ // Twice the invulnerability window a second hit would have to wait out.
+ .thenIdle(40)
+ .thenExecute(() -> helper.assertValueEqual(pig.getHealth(), afterFirstHit[0],
+ "the health of a pig a single flower grew through"))
+ .thenSucceed();
+ }
+
+ @GameTest
+ public void aFlowerSparesWhoeverGrewIt(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var player = TestPlayers.at(helper, GROUND);
+ float health = player.getHealth();
+
+ var flower = grow(helper);
+ flower.setOwner(player);
+
+ helper.startSequence()
+ .thenIdle(RISING_TICKS)
+ .thenExecute(() -> helper.assertValueEqual(player.getHealth(), health, "the health of the player who grew the flower"))
+ .thenSucceed();
+ }
+
+ /** Standing on a flower would turn the form into a lift, which is not what it is for. */
+ @GameTest
+ public void aFlowerIsNotSomethingToStandOn(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var flower = grow(helper);
+ var player = TestPlayers.at(helper, GROUND.above(4));
+
+ helper.assertFalse(flower.canBeCollidedWith(player), "a flower should not be something to stand on");
+ helper.succeed();
+ }
+
+ /** {@code spawn} takes structure-relative coordinates and works the absolute ones out itself. */
+ private static void attach(GameTestHelper helper, BlockPos hook) {
+ var absolute = helper.absolutePos(hook);
+ TripWireHookBlock.calculateState(helper.getLevel(), absolute, helper.getLevel().getBlockState(absolute), false, false, -1, null);
+ }
+
+ private static Flower grow(GameTestHelper helper) {
+ return helper.spawn(SuperMarioEntityTypes.FLOWER, new Vec3(GROUND.getX() + 0.5D, GROUND.getY(), GROUND.getZ() + 0.5D));
+ }
+
+ /** Fills a whole layer of the arena, so that nothing can slip past the ceiling sideways. */
+ private static void ceilingAt(GameTestHelper helper, int y) {
+ roofUpTo(helper, y, Arena.SIZE - 1);
+ }
+
+ /** The same, but stopping at {@code lastZ}, so that a flower drifting south can get out from under it. */
+ private static void roofUpTo(GameTestHelper helper, int y, int lastZ) {
+ for (int x = 0; x < Arena.SIZE; x++) {
+ for (int z = 0; z <= lastZ; z++) {
+ helper.setBlock(new BlockPos(x, y, z), Blocks.STONE);
+ }
+ }
+ }
+}
diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/GrowFlowerActionGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/GrowFlowerActionGameTest.java
new file mode 100644
index 000000000..b06befb19
--- /dev/null
+++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/GrowFlowerActionGameTest.java
@@ -0,0 +1,171 @@
+package fr.hugman.mubble.test.gametest.super_mario;
+
+import fr.hugman.mubble.super_mario.world.entity.SuperMarioEntityTypes;
+import fr.hugman.mubble.super_mario.world.entity.projectile.Flower;
+import fr.hugman.mubble.super_mario.world.power_up.action.GrowFlowerPowerUpAction;
+import fr.hugman.mubble.test.gametest.support.Arena;
+import fr.hugman.mubble.test.gametest.support.TestPlayers;
+import fr.hugman.mubble.world.power_up.PowerUpCharges;
+import net.fabricmc.fabric.api.gametest.v1.GameTest;
+import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
+import net.minecraft.gametest.framework.GameTestHelper;
+import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.InteractionResult;
+import net.minecraft.world.phys.AABB;
+
+import java.util.List;
+
+/**
+ * The action behind the Super Flower Pot. Where the flower is planted is the whole of it: the form is not
+ * aimed like the ball throwers, so the direction the holder is looking only decides which side of them the
+ * flower comes out of, and never where it goes afterwards.
+ */
+public class GrowFlowerActionGameTest {
+ private static final BlockPos STAND = new BlockPos(4, Arena.FLOOR_Y + 1, 3);
+ private static final double EPSILON = 1.0E-4D;
+
+ /** The same action the flower power-up is built with, see {@code SuperMarioPowerUpProvider}. */
+ private static final GrowFlowerPowerUpAction ACTION = new GrowFlowerPowerUpAction(
+ SuperMarioEntityTypes.FLOWER,
+ Flower.DEFAULT_SPEED,
+ Flower.DEFAULT_LIFETIME,
+ Flower.DEFAULT_RANGE,
+ PowerUpCharges.cooldownRecharge(1, 10)
+ );
+
+ @GameTest
+ public void triggeringGrowsAFlower(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var player = TestPlayers.at(helper, STAND);
+
+ var result = ACTION.trigger(player);
+
+ helper.assertTrue(result == InteractionResult.SUCCESS, "the trigger did not report a success");
+ helper.assertValueEqual(flowersAround(helper, player).size(), 1, "flowers grown by one trigger");
+ helper.succeed();
+ }
+
+ @GameTest
+ public void theHolderOwnsWhatTheyGrow(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var player = TestPlayers.at(helper, STAND);
+
+ ACTION.trigger(player);
+
+ helper.assertTrue(flowerOf(helper, player).getOwner() == player, "the flower is not owned by whoever grew it");
+ helper.succeed();
+ }
+
+ @GameTest
+ public void theFlowerCarriesTheNumbersOfTheAction(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var player = TestPlayers.at(helper, STAND);
+ var action = new GrowFlowerPowerUpAction(SuperMarioEntityTypes.FLOWER, 0.25D, 17, 5.0D, PowerUpCharges.none());
+
+ action.trigger(player);
+
+ var flower = flowerOf(helper, player);
+ helper.assertValueEqual(flower.getSpeed(), 0.25D, "the speed the action asked for");
+ helper.assertValueEqual(flower.getLifetime(), 17, "the lifetime the action asked for");
+ helper.assertValueEqual(flower.getRange(), 5.0D, "the range the action asked for");
+ helper.succeed();
+ }
+
+ /** The flower is 2×2 blocks: planted on the spot, it would grow right through its holder. */
+ @GameTest
+ public void theFlowerIsPlantedInFrontOfItsHolder(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var player = TestPlayers.at(helper, STAND);
+ player.setYRot(Direction.SOUTH.toYRot());
+
+ ACTION.trigger(player);
+
+ var flower = flowerOf(helper, player);
+ helper.assertTrue(flower.getZ() > player.getZ() + 1.0D,
+ "a flower planted by a player facing south should be south of them, was at " + flower.getZ() + " against " + player.getZ());
+ helper.assertValueEqual(flower.getX(), player.getX(), "the x a flower was planted at");
+ helper.succeed();
+ }
+
+ @GameTest
+ public void theFlowerFollowsWhereTheHolderIsFacing(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var player = TestPlayers.at(helper, STAND);
+ player.setYRot(Direction.WEST.toYRot());
+
+ ACTION.trigger(player);
+
+ var flower = flowerOf(helper, player);
+ helper.assertTrue(flower.getX() < player.getX() - 1.0D,
+ "a flower planted by a player facing west should be west of them, was at " + flower.getX() + " against " + player.getX());
+ helper.succeed();
+ }
+
+ /** However the holder is looking, the flower is planted at their feet and goes straight up from there. */
+ @GameTest
+ public void lookingUpOrDownChangesNothing(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var player = TestPlayers.at(helper, STAND);
+ player.setXRot(-90.0F);
+
+ ACTION.trigger(player);
+
+ var flower = flowerOf(helper, player);
+ double x = flower.getX();
+ double z = flower.getZ();
+ helper.assertValueEqual(flower.getY(), player.getY(), "a flower should be planted at the feet of its holder");
+
+ // The heading only shows up once the flower has ticked: it is planted with no movement at all.
+ helper.startSequence()
+ .thenIdle(2)
+ .thenExecute(() -> {
+ helper.assertTrue(flower.getDeltaMovement().horizontalDistance() < EPSILON, "a flower should never be aimed sideways");
+ helper.assertValueEqual(flower.getX(), x, "the x a flower drifted to");
+ helper.assertValueEqual(flower.getZ(), z, "the z a flower drifted to");
+ })
+ .thenSucceed();
+ }
+
+ /** A ceiling sends the flower on the way its holder was facing, so it has to remember which that was. */
+ @GameTest
+ public void theFlowerRemembersTheWayItWasThrown(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var player = TestPlayers.at(helper, STAND);
+ player.setYRot(Direction.EAST.toYRot());
+
+ ACTION.trigger(player);
+
+ var forward = flowerOf(helper, player).getForward();
+ helper.assertTrue(forward.x() > 1.0D - EPSILON,
+ "a flower grown by a player facing east should carry east as its forward, was " + forward);
+ helper.assertTrue(Math.abs(forward.y()) < EPSILON, "the forward of a flower should be flat");
+ helper.succeed();
+ }
+
+ @GameTest
+ public void everyTriggerCostsACharge(GameTestHelper helper) {
+ Arena.buildFloor(helper);
+ var player = TestPlayers.at(helper, STAND);
+
+ helper.assertTrue(ACTION.canBeTriggered(player), "a fresh power-up should be triggerable");
+ ACTION.trigger(player);
+
+ helper.assertValueEqual(player.getPowerUpProperties().getChargeCount(), 0, "charges after one flower");
+ helper.assertFalse(ACTION.canBeTriggered(player), "a spent power-up should not report itself as triggerable");
+ helper.succeed();
+ }
+
+ private static List flowersAround(GameTestHelper helper, ServerPlayer player) {
+ return helper.getLevel().getEntitiesOfClass(Flower.class, around(player));
+ }
+
+ private static Flower flowerOf(GameTestHelper helper, ServerPlayer player) {
+ return flowersAround(helper, player).stream().findFirst()
+ .orElseThrow(() -> new AssertionError("no flower was grown at all"));
+ }
+
+ private static AABB around(ServerPlayer player) {
+ return player.getBoundingBox().inflate(8.0D);
+ }
+}
diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/support/TestPlayers.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/support/TestPlayers.java
index 1a37a4429..a83c39ae8 100644
--- a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/support/TestPlayers.java
+++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/support/TestPlayers.java
@@ -2,6 +2,7 @@
import net.minecraft.gametest.framework.GameTestHelper;
import net.minecraft.server.level.ServerPlayer;
+import net.minecraft.world.entity.player.Input;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.GameType;
@@ -35,6 +36,27 @@ public static ServerPlayer at(GameTestHelper helper, net.minecraft.core.BlockPos
return player;
}
+ /**
+ * Advances {@code player} by one tick with a client behind them: the keys they hold down going in, and
+ * the movement they made coming back out.
+ *
+ * Both halves normally arrive as packets, and both are read by the server rather than worked out by it:
+ * {@code getLastClientInput()} is the only place the jump key exists server-side, and
+ * {@code getKnownMovement()} the only honest reading of how a player is actually moving. A mock player
+ * has nobody sending either, so a test standing in for the client has to send both itself.
+ */
+ public static void tick(ServerPlayer player, Input keys) {
+ player.setLastClientInput(keys);
+ var before = player.position();
+ tick(player);
+ player.setKnownMovement(player.position().subtract(before));
+ }
+
+ /** The keys of a player leaning on the jump button, and on nothing else. */
+ public static Input holdingJump() {
+ return new Input(false, false, false, false, true, false, false);
+ }
+
/**
* Advances {@code player} by one tick, the way a connected client would.
*
diff --git a/mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up/floater.json b/mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up/floater.json
new file mode 100644
index 000000000..840d68f48
--- /dev/null
+++ b/mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up/floater.json
@@ -0,0 +1,11 @@
+{
+ "abilities": {
+ "float": {
+ "speed": 0.1,
+ "fall_damage": 0.25
+ }
+ },
+ "name": {
+ "translate": "power_up.mubble-gametest.floater"
+ }
+}
diff --git a/mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up/flutterer.json b/mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up/flutterer.json
new file mode 100644
index 000000000..2a7b85dfa
--- /dev/null
+++ b/mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up/flutterer.json
@@ -0,0 +1,12 @@
+{
+ "abilities": {
+ "flutter": {
+ "duration": 10,
+ "drop": 0.2,
+ "acceleration": 0.08
+ }
+ },
+ "name": {
+ "translate": "power_up.mubble-gametest.flutterer"
+ }
+}
diff --git a/mubble-test/src/gametest/resources/fabric.mod.json b/mubble-test/src/gametest/resources/fabric.mod.json
index 96534b412..db80ffa21 100644
--- a/mubble-test/src/gametest/resources/fabric.mod.json
+++ b/mubble-test/src/gametest/resources/fabric.mod.json
@@ -20,11 +20,15 @@
"fr.hugman.mubble.test.gametest.power_up.PowerUpCommandGameTest",
"fr.hugman.mubble.test.gametest.power_up.PowerUpItemGameTest",
"fr.hugman.mubble.test.gametest.power_up.RunOnWaterGameTest",
+ "fr.hugman.mubble.test.gametest.power_up.FlutterGameTest",
+ "fr.hugman.mubble.test.gametest.power_up.FloatGameTest",
"fr.hugman.mubble.test.gametest.super_mario.StompGameTest",
"fr.hugman.mubble.test.gametest.super_mario.GoombaGameTest",
"fr.hugman.mubble.test.gametest.super_mario.KoopaShellGameTest",
"fr.hugman.mubble.test.gametest.super_mario.CloudPlatformGameTest",
+ "fr.hugman.mubble.test.gametest.super_mario.FlowerGameTest",
"fr.hugman.mubble.test.gametest.super_mario.SpawnCloudPlatformActionGameTest",
+ "fr.hugman.mubble.test.gametest.super_mario.GrowFlowerActionGameTest",
"fr.hugman.mubble.test.gametest.super_mario.FreezeGameTest",
"fr.hugman.mubble.test.gametest.super_mario.FreezeCommandGameTest",
"fr.hugman.mubble.test.gametest.super_mario.BlockTransformGameTest",
diff --git a/mubble-test/src/test/java/fr/hugman/mubble/test/unit/FloatAbilityTest.java b/mubble-test/src/test/java/fr/hugman/mubble/test/unit/FloatAbilityTest.java
new file mode 100644
index 000000000..be4bf84a5
--- /dev/null
+++ b/mubble-test/src/test/java/fr/hugman/mubble/test/unit/FloatAbilityTest.java
@@ -0,0 +1,55 @@
+package fr.hugman.mubble.test.unit;
+
+import fr.hugman.mubble.world.power_up.ability.FloatAbility;
+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;
+
+/**
+ * The descent half of a jump held on, and the only half the Tanooki form will take: what a tick spent
+ * coming down slowly is worth, and how much of the fall it writes off.
+ */
+public class FloatAbilityTest {
+ private static final float SPEED = 0.1F;
+ private static final float FALL_DAMAGE = 0.25F;
+ private static final float EPSILON = 1.0E-6F;
+
+ @Test
+ @DisplayName("a floated tick only counts for its share of the fall")
+ void aFloatedTickIsMostlyForgiven() {
+ var floating = FloatAbility.of(SPEED, FALL_DAMAGE);
+
+ assertEquals(SPEED * (1.0F - FALL_DAMAGE), floating.fallForgivenPerTick(), EPSILON,
+ "the blocks a tick of floating writes off");
+ assertTrue(floating.fallForgivenPerTick() < SPEED,
+ "a float that forgave the whole tick would be free of fall damage, not reduced");
+ }
+
+ @Test
+ @DisplayName("a float that counts for nothing forgives the whole descent")
+ void noFallDamageForgivesEverything() {
+ assertEquals(SPEED, FloatAbility.of(SPEED, 0.0F).fallForgivenPerTick(), EPSILON, "a float taking no damage at all");
+ }
+
+ @Test
+ @DisplayName("a float that counts in full forgives nothing")
+ void fullFallDamageForgivesNothing() {
+ assertEquals(0.0F, FloatAbility.of(SPEED, 1.0F).fallForgivenPerTick(), EPSILON, "a float taking the whole fall");
+ }
+
+ @Test
+ @DisplayName("a share of the fall outside of zero to one is brought back into it")
+ void theShareOfTheFallIsAShare() {
+ assertEquals(0.0F, FloatAbility.of(SPEED, -2.0F).fallDamage(), EPSILON, "a negative share");
+ assertEquals(1.0F, FloatAbility.of(SPEED, 4.0F).fallDamage(), EPSILON, "a share above the whole fall");
+ }
+
+ /** A negative speed would send a floating holder upwards, which is the flutter's job and not this one's. */
+ @Test
+ @DisplayName("a speed that would lift the holder is refused")
+ void negativeSpeedIsRefused() {
+ assertEquals(0.0F, FloatAbility.of(-0.5F, FALL_DAMAGE).speed(), EPSILON, "a negative speed");
+ }
+}
diff --git a/mubble-test/src/test/java/fr/hugman/mubble/test/unit/FlutterAbilityTest.java b/mubble-test/src/test/java/fr/hugman/mubble/test/unit/FlutterAbilityTest.java
new file mode 100644
index 000000000..a204988bc
--- /dev/null
+++ b/mubble-test/src/test/java/fr/hugman/mubble/test/unit/FlutterAbilityTest.java
@@ -0,0 +1,103 @@
+package fr.hugman.mubble.test.unit;
+
+import fr.hugman.mubble.world.power_up.ability.FlutterAbility;
+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;
+
+/**
+ * The shape of the climb half of a jump held on: what each of its ticks is worth.
+ *
+ * A flutter opens on a drop and only turns that around a few ticks in, which is what makes the lift land as
+ * a snap rather than as a balloon. The curve behind that is worth pinning down on its own, away from a level
+ * and a player.
+ */
+public class FlutterAbilityTest {
+ private static final int DURATION = 8;
+ private static final float DROP = 0.2F;
+ private static final float ACCELERATION = 0.1F;
+
+ private static final FlutterAbility FLUTTER = FlutterAbility.of(DURATION, DROP, ACCELERATION);
+ private static final float EPSILON = 1.0E-6F;
+
+ @Test
+ @DisplayName("the flutter opens on a drop rather than on a lift")
+ void theFlutterOpensOnADrop() {
+ assertEquals(-DROP, FLUTTER.liftAt(0), EPSILON, "the very first tick of a flutter");
+ assertTrue(FLUTTER.liftAt(0) < 0.0F, "the first tick of a flutter should still be sinking");
+ }
+
+ @Test
+ @DisplayName("every tick takes the same bite out of the drop, and then out of the sky")
+ void everyTickIsWorthTheAcceleration() {
+ for (int tick = 1; tick < DURATION; tick++) {
+ assertEquals(ACCELERATION, FLUTTER.liftAt(tick) - FLUTTER.liftAt(tick - 1), EPSILON,
+ "the speed tick " + tick + " added to the one before it");
+ }
+ }
+
+ @Test
+ @DisplayName("the hang lasts until the acceleration has eaten the drop, and no longer")
+ void theHangGivesWayToLift() {
+ int hang = FLUTTER.hangTicks();
+
+ assertTrue(hang > 0, "a flutter should hang for at least a tick before lifting");
+ assertTrue(hang < DURATION, "a flutter that hung for its whole duration would never lift at all");
+ assertTrue(FLUTTER.liftAt(hang - 1) < 0.0F, "the last tick of the hang should still be sinking");
+ assertTrue(FLUTTER.liftAt(hang) >= 0.0F, "the tick after the hang should no longer be sinking");
+ }
+
+ /** A couple of ticks and no more: the hang is an anticipation, not a fall. */
+ @Test
+ @DisplayName("the hang is over in a couple of ticks")
+ void theHangIsShort() {
+ assertEquals(2, FLUTTER.hangTicks(), "the ticks a flutter hangs for");
+ }
+
+ /** A flutter that plateaued would carry its holder the way a rising platform does. */
+ @Test
+ @DisplayName("the lift never settles on a top speed")
+ void theLiftNeverPlateaus() {
+ assertTrue(FLUTTER.liftAt(DURATION - 1) > FLUTTER.liftAt(DURATION - 2),
+ "the last tick of a flutter should still be lifting harder than the one before it");
+ }
+
+ @Test
+ @DisplayName("a flutter is worth height overall, drop and all")
+ void theFlutterIsWorthHeightOverall() {
+ // The acceleration is added once on the second tick, twice on the third, and so on.
+ float ticks = DURATION;
+ float expected = ACCELERATION * (ticks - 1.0F) * ticks / 2.0F - DROP * ticks;
+
+ assertEquals(expected, FLUTTER.totalLift(), EPSILON, "the height a whole flutter is worth");
+ assertTrue(FLUTTER.totalLift() > 0.0F, "a flutter should be worth more than the drop it opens on");
+ }
+
+ @Test
+ @DisplayName("a flutter that never accelerates never stops sinking")
+ void noAccelerationMeansNoLift() {
+ var stalled = FlutterAbility.of(DURATION, DROP, 0.0F);
+
+ assertEquals(-DROP, stalled.liftAt(DURATION - 1), EPSILON, "the last tick of a flutter with no acceleration");
+ assertEquals(DURATION, stalled.hangTicks(), "a flutter with no acceleration hangs for its whole duration");
+ assertTrue(stalled.totalLift() < 0.0F, "a flutter that never lifts should not be worth any height");
+ }
+
+ @Test
+ @DisplayName("a flutter that lasts no time at all is worth nothing")
+ void anEmptyFlutterIsWorthNothing() {
+ assertEquals(0.0F, FlutterAbility.of(0, DROP, ACCELERATION).totalLift(), EPSILON, "a flutter of no duration");
+ }
+
+ @Test
+ @DisplayName("numbers written the wrong way round are refused")
+ void negativeNumbersAreRefused() {
+ var backwards = FlutterAbility.of(-10, -0.5F, -0.1F);
+
+ assertEquals(0, backwards.duration(), "a negative duration");
+ assertEquals(0.0F, backwards.drop(), EPSILON, "a negative drop");
+ assertEquals(0.0F, backwards.acceleration(), EPSILON, "a negative acceleration");
+ }
+}
diff --git a/mubble-test/src/test/java/fr/hugman/mubble/test/unit/PowerUpCodecTest.java b/mubble-test/src/test/java/fr/hugman/mubble/test/unit/PowerUpCodecTest.java
index 578fa3662..8c255a20c 100644
--- a/mubble-test/src/test/java/fr/hugman/mubble/test/unit/PowerUpCodecTest.java
+++ b/mubble-test/src/test/java/fr/hugman/mubble/test/unit/PowerUpCodecTest.java
@@ -7,6 +7,9 @@
import fr.hugman.mubble.world.power_up.PowerUp;
import fr.hugman.mubble.world.power_up.PowerUpBuilder;
import fr.hugman.mubble.world.power_up.PowerUpCosmectics;
+import fr.hugman.mubble.world.power_up.ability.FloatAbility;
+import fr.hugman.mubble.world.power_up.ability.FlutterAbility;
+import fr.hugman.mubble.world.power_up.ability.PowerUpAbilities;
import net.minecraft.core.Holder;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.core.registries.BuiltInRegistries;
@@ -103,6 +106,50 @@ void descriptionRoundTrips() {
);
}
+ @Test
+ @DisplayName("both halves of a jump held on keep every one of their numbers, and their own field")
+ void abilitiesRoundTrip() {
+ var decoded = CodecAssertions.assertJsonRoundTrip(PowerUp.DIRECT_CODEC, fullyPopulated()).abilities();
+
+ assertEquals(flutter(), decoded.flutter().orElseThrow(() -> new AssertionError("the flutter was dropped")), "the flutter ability");
+ assertEquals(floating(), decoded.floating().orElseThrow(() -> new AssertionError("the float was dropped")), "the float ability");
+ }
+
+ /** The Tanooki form will grant the float on its own, so a power-up has to be able to carry just the one. */
+ @Test
+ @DisplayName("a power-up can grant the float without the flutter")
+ void floatWithoutFlutterRoundTrips() {
+ var powerUp = new PowerUpBuilder().floating(floating()).build();
+
+ var decoded = CodecAssertions.assertJsonRoundTrip(PowerUp.DIRECT_CODEC, powerUp).abilities();
+
+ assertTrue(decoded.flutter().isEmpty(), "a power-up granting only a float should have no flutter");
+ assertEquals(floating(), decoded.floating().orElseThrow(() -> new AssertionError("the float was dropped")), "the float ability");
+ }
+
+ @Test
+ @DisplayName("abilities written with nothing but their defaults read back as the defaults")
+ void abilityDefaults() {
+ var decoded = PowerUp.DIRECT_CODEC.parse(
+ TestBootstrap.registries().createSerializationContext(JsonOps.INSTANCE),
+ JsonParser.parseString("""
+ {"abilities": {"flutter": {}, "float": {}}}
+ """))
+ .getOrThrow(error -> new AssertionError("could not read bare abilities: " + error))
+ .abilities();
+
+ assertEquals(
+ FlutterAbility.of(FlutterAbility.DEFAULT_DURATION, FlutterAbility.DEFAULT_DROP, FlutterAbility.DEFAULT_ACCELERATION),
+ decoded.flutter().orElseThrow(() -> new AssertionError("the flutter was dropped")),
+ "a flutter with no field of its own"
+ );
+ assertEquals(
+ FloatAbility.of(FloatAbility.DEFAULT_SPEED, FloatAbility.DEFAULT_FALL_DAMAGE),
+ decoded.floating().orElseThrow(() -> new AssertionError("the float was dropped")),
+ "a float with no field of its own"
+ );
+ }
+
@Test
@DisplayName("an unknown action type is rejected instead of being ignored")
void unknownActionTypeIsRejected() {
@@ -120,7 +167,7 @@ void unknownAttributeIsRejected() {
}
static PowerUp empty() {
- return new PowerUp(Optional.empty(), List.of(), Optional.empty(), Optional.empty(), Optional.empty(), PowerUpCosmectics.EMPTY);
+ return new PowerUp(Optional.empty(), List.of(), Optional.empty(), Optional.empty(), Optional.empty(), PowerUpAbilities.EMPTY, PowerUpCosmectics.EMPTY);
}
static PowerUpCosmectics cosmetics() {
@@ -149,9 +196,21 @@ static PowerUp fullyPopulated() {
.particle(ParticleTypes.FLAME)
.humanoidOverlay(Identifier.parse("mubble:entity/power_up/humanoid/test"))
.emissiveOverlay()
+ .flutter(flutter())
+ .floating(floating())
.build();
}
+ /** Every number distinct, so that two of them swapped cannot round trip unnoticed. */
+ static FlutterAbility flutter() {
+ return new FlutterAbility(24, 0.07F, 0.003F, Optional.of(sound(SoundEvents.BAT_LOOP)), Optional.of(ParticleTypes.CHERRY_LEAVES));
+ }
+
+ /** Distinct from the flutter above in every field, for the same reason. */
+ static FloatAbility floating() {
+ return new FloatAbility(0.13F, 0.4F, Optional.of(sound(SoundEvents.BEACON_AMBIENT)), Optional.of(ParticleTypes.COMPOSTER));
+ }
+
/** Most {@code SoundEvents} constants are bare events, but the mod stores them as holders. */
static Holder sound(SoundEvent event) {
return BuiltInRegistries.SOUND_EVENT.wrapAsHolder(event);