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 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 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);