From e972d6279489abd5494ad823ecf2b5c9f5015a68 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 21:58:36 +0000 Subject: [PATCH 1/5] Add the Super Flower Pot power-up Resolves #133. The Flower form gets both of the abilities the issue asks for. Growing a flower is a new power-up action, `super_mario:grow_flower`: a 2x2 entity planted at the holder's feet, one step ahead of them, which then rises straight up at a speed of its own that neither gravity nor drag ever touches. It grows through blocks the way the flowers of the source game rise through ceilings, and runs out of both time and height so that it never climbs forever; a data pack that would rather have them pop against blocks can say so instead, and every one of those numbers is a field of the action. It defeats what it grows through, once per entity, and spares its owner along with their team and their pets. The flutter is a reusable ability of the power-up system rather than something the Flower form owns: `PowerUpAbilities` hangs off a power-up the way its cosmetics do, and `FlutterAbility` holds the numbers the flutter is worth. The Tanooki form will only have to name it. Both sides run the same state machine, each for the player it is in charge of, so the client's own movement actually rises rather than waiting on a round trip; the jump key is read from the input packets on the server and from the local input on a client. Cosmetics are placeholders, as the issue reserves them for later: the item texture, the flower model and its texture, and vanilla stand-ins for the growth, wilt and flutter sounds along with the flutter particles. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T42CAmosnCqdPJnhN8dF6D --- .../fr/hugman/mubble/client/MubbleClient.java | 4 + .../mubble/client/mixin/LocalPlayerMixin.java | 20 + .../client/sound/FlutterSoundInstance.java | 47 +++ .../mubble/client/sound/FlutterSounds.java | 47 +++ .../resources/mubble.client.mixins.json | 3 +- .../fr/hugman/mubble/mixin/PlayerMixin.java | 193 +++++++++- .../mubble/world/entity/Fluttering.java | 51 +++ .../hugman/mubble/world/power_up/PowerUp.java | 4 + .../mubble/world/power_up/PowerUpBuilder.java | 12 + .../power_up/ability/FlutterAbility.java | 98 +++++ .../power_up/ability/PowerUpAbilities.java | 34 ++ .../src/main/resources/fabric.mod.json | 3 +- .../super_mario/client/model/FlowerModel.java | 56 +++ .../client/model/SuperMarioModelLayers.java | 1 + .../client/renderer/SuperMarioRenderers.java | 2 + .../renderer/entity/FlowerRenderer.java | 54 +++ .../entity/state/FlowerRenderState.java | 8 + .../super_mario/textures/entity/flower.png | Bin 0 -> 465 bytes .../textures/item/super_flower_pot.png | Bin 0 -> 173 bytes .../mubble/super_mario/data/PowerUpItems.java | 3 +- .../SuperMarioDamageTypeProvider.java | 1 + .../SuperMarioEnglishLangProvider.java | 4 + .../SuperMarioEntityTypeTagsProvider.java | 2 +- .../provider/SuperMarioModelProvider.java | 1 + .../provider/SuperMarioPowerUpProvider.java | 27 ++ .../references/SuperMarioDamageTypeIds.java | 1 + .../references/SuperMarioEntityTypeIds.java | 1 + .../references/SuperMarioItemIds.java | 1 + .../SuperMarioPowerUpActionTypesIds.java | 1 + .../references/SuperMarioPowerUpIds.java | 1 + .../world/entity/SuperMarioEntityTypes.java | 1 + .../world/entity/projectile/Flower.java | 343 ++++++++++++++++++ .../item/SuperMarioCreativeModeTabs.java | 1 + .../world/item/SuperMarioItems.java | 1 + .../action/GrowFlowerPowerUpAction.java | 164 +++++++++ .../action/SuperMarioPowerUpActionTypes.java | 1 + .../gametest/datapack/PowerUpFixtures.java | 3 + .../gametest/power_up/FlutterGameTest.java | 212 +++++++++++ .../gametest/super_mario/FlowerGameTest.java | 189 ++++++++++ .../super_mario/GrowFlowerActionGameTest.java | 157 ++++++++ .../test/gametest/support/TestPlayers.java | 22 ++ .../mubble/power_up/flutterer.json | 12 + .../src/gametest/resources/fabric.mod.json | 3 + .../mubble/test/unit/FlutterAbilityTest.java | 85 +++++ .../mubble/test/unit/PowerUpCodecTest.java | 36 +- 45 files changed, 1904 insertions(+), 6 deletions(-) create mode 100644 mubble-core/src/client/java/fr/hugman/mubble/client/mixin/LocalPlayerMixin.java create mode 100644 mubble-core/src/client/java/fr/hugman/mubble/client/sound/FlutterSoundInstance.java create mode 100644 mubble-core/src/client/java/fr/hugman/mubble/client/sound/FlutterSounds.java create mode 100644 mubble-core/src/main/java/fr/hugman/mubble/world/entity/Fluttering.java create mode 100644 mubble-core/src/main/java/fr/hugman/mubble/world/power_up/ability/FlutterAbility.java create mode 100644 mubble-core/src/main/java/fr/hugman/mubble/world/power_up/ability/PowerUpAbilities.java create mode 100644 mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/model/FlowerModel.java create mode 100644 mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/FlowerRenderer.java create mode 100644 mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/state/FlowerRenderState.java create mode 100644 mubble-super_mario/src/client/resources/assets/super_mario/textures/entity/flower.png create mode 100644 mubble-super_mario/src/client/resources/assets/super_mario/textures/item/super_flower_pot.png create mode 100644 mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Flower.java create mode 100644 mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/power_up/action/GrowFlowerPowerUpAction.java create mode 100644 mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/FlutterGameTest.java create mode 100644 mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/FlowerGameTest.java create mode 100644 mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/GrowFlowerActionGameTest.java create mode 100644 mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up/flutterer.json create mode 100644 mubble-test/src/test/java/fr/hugman/mubble/test/unit/FlutterAbilityTest.java 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..4caebb803 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.FlutterSounds; +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(FlutterSounds::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..8f3dc18af --- /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.Fluttering; +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 + * flutter of the player it controls. + */ +@Mixin(LocalPlayer.class) +public class LocalPlayerMixin implements Fluttering { + @Override + public boolean isJumpKeyHeld() { + return ((LocalPlayer) (Object) this).input.keyPresses.jump(); + } +} diff --git a/mubble-core/src/client/java/fr/hugman/mubble/client/sound/FlutterSoundInstance.java b/mubble-core/src/client/java/fr/hugman/mubble/client/sound/FlutterSoundInstance.java new file mode 100644 index 000000000..69e8bcfaa --- /dev/null +++ b/mubble-core/src/client/java/fr/hugman/mubble/client/sound/FlutterSoundInstance.java @@ -0,0 +1,47 @@ +package fr.hugman.mubble.client.sound; + +import fr.hugman.mubble.world.entity.Fluttering; +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.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.player.Player; + +/** + * The loop a flutter is heard as, for as long as it lasts. + */ +@Environment(EnvType.CLIENT) +public class FlutterSoundInstance extends AbstractTickableSoundInstance { + private final Player player; + + public FlutterSoundInstance(Player player, SoundEvent event) { + super(event, SoundSource.PLAYERS, SoundInstance.createUnseededRandom()); + this.player = player; + this.looping = true; + this.delay = 0; + this.volume = 0.2F; + } + + @Override + public boolean canPlaySound() { + return !this.player.isSilent(); + } + + @Override + public boolean canStartSilent() { + return true; + } + + @Override + public void tick() { + if (this.player.isRemoved() || !((Fluttering) this.player).isFluttering()) { + 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/FlutterSounds.java b/mubble-core/src/client/java/fr/hugman/mubble/client/sound/FlutterSounds.java new file mode 100644 index 000000000..c661bcd8e --- /dev/null +++ b/mubble-core/src/client/java/fr/hugman/mubble/client/sound/FlutterSounds.java @@ -0,0 +1,47 @@ +package fr.hugman.mubble.client.sound; + +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.world.entity.player.Player; + +import java.util.Map; +import java.util.WeakHashMap; + +/** + * Keeps one flutter loop going per player fluttering in sight. + *

+ * A sound instance stops itself once the flutter 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 FlutterSounds { + private static final Map PLAYING = new WeakHashMap<>(); + + private FlutterSounds() { + } + + public static void tick(Minecraft client) { + if (client.level == null) { + PLAYING.clear(); + return; + } + for (Player player : client.level.players()) { + if (!player.isFluttering()) { + PLAYING.remove(player); + continue; + } + var playing = PLAYING.get(player); + if (playing != null && !playing.isStopped()) { + continue; + } + player.getFlutterAbility().flatMap(FlutterAbility::sound).ifPresent(sound -> { + var instance = new FlutterSoundInstance(player, sound.value()); + 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..f4842173e 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,19 @@ 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.Fluttering; 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.FlutterAbility; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; 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 +24,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 +36,36 @@ import java.util.Optional; @Mixin(Player.class) -public class PlayerMixin implements PowerUpHolder, WaterRunner { +public class PlayerMixin implements PowerUpHolder, WaterRunner, Fluttering { @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); @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 fluttering player leaves around their feet every tick. */ + @Unique + private static final int FLUTTER_PARTICLES = 2; + /** How far those scatter around the feet, as a share of the width of the player. */ + @Unique + private static final double FLUTTER_PARTICLE_SPREAD = 0.8D; + /** How fast they sink, so that they read as being left behind by someone going up. */ + @Unique + private static final double FLUTTER_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 +90,21 @@ 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; + @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); } @Inject(method = "addAdditionalSaveData", at = @At("TAIL")) @@ -78,6 +113,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 +122,12 @@ 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$fluttering = flutterTicks >= 0; + this.mubble$flutterTicks = Math.max(0, flutterTicks); + this.mubble$flutterSpent = view.getBooleanOr(FLUTTER_SPENT_KEY, false); + this.mubble$syncFluttering(this_); } @Inject(method = "tick", at = @At("TAIL")) @@ -280,6 +323,154 @@ public void clearPowerUp() { PowerUp.onChange(this_, previous, Optional.empty()); } + /** + * Runs the flutter: past the peak of a jump, a player still leaning on the jump key rises again for a + * moment instead of falling. + *

+ * This sits at the head of {@code aiStep} because the lift is written 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 up, 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$tickFlutter(CallbackInfo ci) { + var this_ = (Player) (Object) this; + this.mubble$flutterParticles(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$endFlutter(this_); + return; + } + + var ability = this.getFlutterAbility(); + if (ability.isEmpty()) { + // The form can be lost in mid-air, and the flutter goes with it. + this.mubble$endFlutter(this_); + return; + } + FlutterAbility flutter = ability.get(); + + boolean jumpHeld = this_.isJumpKeyHeld(); + if (this.mubble$fluttering) { + // A released key cannot be leaned on again: that jump is done fluttering. + if (!jumpHeld || this.mubble$flutterTicks >= flutter.duration() || mubble$flutterCutShort(this_)) { + this.mubble$endFlutter(this_); + return; + } + } else { + if (this.mubble$flutterSpent || !jumpHeld || mubble$flutterCutShort(this_)) { + return; + } + // Nothing changes on the way up: the flutter waits for the player to start coming back down. + if (this_.getKnownMovement().y() >= 0.0D) { + return; + } + this.mubble$fluttering = true; + this.mubble$flutterTicks = 0; + this.mubble$flutterSpent = true; + this.mubble$syncFluttering(this_); + } + + Vec3 movement = this_.getDeltaMovement(); + this_.setDeltaMovement(movement.x(), flutter.liftAt(this.mubble$flutterTicks), movement.z()); + this.mubble$flutterTicks++; + } + + /** + * The states a flutter cannot carry on through, landing aside: they are all ways of being held by + * something other than the air. + */ + @Unique + private static boolean mubble$flutterCutShort(Player player) { + return player.isInWater() || player.onClimbable() || player.isFallFlying() || player.isPassenger(); + } + + @Unique + private void mubble$endFlutter(Player player) { + if (!this.mubble$fluttering) { + return; + } + this.mubble$fluttering = false; + this.mubble$flutterTicks = 0; + this.mubble$syncFluttering(player); + } + + /** + * Tells the other clients about the flutter, which is all they get: they have no business simulating + * someone else's keys, they only draw what the flutter looks like. + */ + @Unique + private void mubble$syncFluttering(Player player) { + if (!player.level().isClientSide()) { + player.getEntityData().set(FLUTTERING, this.mubble$fluttering); + } + } + + /** + * Leaves the trail of a flutter around the feet of the player. + *

+ * Every client draws it for every player it can see fluttering, 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 {@code isFluttering()} rather than off the flutter tick right below, which only ever + * runs for the one player this side is in charge of. + */ + @Unique + private void mubble$flutterParticles(Player player) { + if (!player.level().isClientSide() || !player.isFluttering()) { + return; + } + var particle = this.getFlutterAbility().flatMap(FlutterAbility::particle); + if (particle.isEmpty()) { + return; + } + double spread = player.getBbWidth() * FLUTTER_PARTICLE_SPREAD; + for (int i = 0; i < FLUTTER_PARTICLES; i++) { + player.level().addParticle(particle.get(), + player.getRandomX(spread), player.getY(), player.getRandomZ(spread), + 0.0D, FLUTTER_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 flutter 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 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 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/Fluttering.java b/mubble-core/src/main/java/fr/hugman/mubble/world/entity/Fluttering.java new file mode 100644 index 000000000..0386cc8a5 --- /dev/null +++ b/mubble-core/src/main/java/fr/hugman/mubble/world/entity/Fluttering.java @@ -0,0 +1,51 @@ +package fr.hugman.mubble.world.entity; + +import fr.hugman.mubble.world.power_up.ability.FlutterAbility; + +import java.util.Optional; + +/** + * An entity that can extend its jump by fluttering, 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 + */ +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 the jump key is being held down right now, as far as this side can tell + */ + default boolean isJumpKeyHeld() { + return false; + } + + /** + * @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/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..587f51b3d 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,8 @@ import java.util.List; import java.util.Optional; +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 +29,7 @@ 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 ParticleOptions particle = null; private @Nullable Identifier humanoidOverlayAssetId = null; private boolean emissiveOverlay = false; @@ -87,6 +90,14 @@ 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 extend their jumps by fluttering, on the terms the ability is built with. + */ + public PowerUpBuilder flutter(FlutterAbility flutter) { + this.flutter = flutter; + return this; + } + public PowerUpBuilder particle(ParticleOptions particle) { this.particle = particle; return this; @@ -133,6 +144,7 @@ public PowerUp build() { Optional.ofNullable(spriteId), Optional.ofNullable(action), Optional.ofNullable(attributesModifiers.isEmpty() ? null : attributesModifiers), + new PowerUpAbilities(Optional.ofNullable(this.flutter)), 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/FlutterAbility.java b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/ability/FlutterAbility.java new file mode 100644 index 000000000..031bc506f --- /dev/null +++ b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/ability/FlutterAbility.java @@ -0,0 +1,98 @@ +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 java.util.Optional; + +/** + * Extends a jump by fluttering: past the peak of it, a holder still leaning on the jump key rises again + * for a moment instead of falling. + *

+ * Everything the flutter is worth lives here rather than in whichever form happens to grant it, so that the + * next form to want one only has to hand over its own numbers. The lift is not handed out whole on the first + * tick either: it climbs over {@link #ramp} ticks, which is what tells a flutter apart from a second jump. + * + * @param duration how many ticks a flutter lasts at most + * @param ramp how many ticks the lift takes to reach its full strength + * @param strength the upward speed a flutter is worth once ramped up, in blocks per tick + * @param sound the sound played in loop for as long as the flutter lasts + * @param particle the particle left around the feet of the holder while they flutter + */ +public record FlutterAbility( + int duration, + int ramp, + float strength, + Optional> sound, + Optional particle +) { + public FlutterAbility { + // A data pack is free to write anything; what it cannot do is send the holder downwards on an + // ability whose whole point is to hold them up. + duration = Math.max(0, duration); + ramp = Math.max(0, ramp); + strength = Math.max(0.0F, strength); + } + + public static final int DEFAULT_DURATION = 20; + public static final int DEFAULT_RAMP = 5; + public static final float DEFAULT_STRENGTH = 0.12F; + + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.INT.optionalFieldOf("duration", DEFAULT_DURATION).forGetter(FlutterAbility::duration), + Codec.INT.optionalFieldOf("ramp", DEFAULT_RAMP).forGetter(FlutterAbility::ramp), + Codec.FLOAT.optionalFieldOf("strength", DEFAULT_STRENGTH).forGetter(FlutterAbility::strength), + 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.INT, FlutterAbility::ramp, + ByteBufCodecs.FLOAT, FlutterAbility::strength, + SoundEvent.STREAM_CODEC.apply(ByteBufCodecs::optional), FlutterAbility::sound, + ParticleTypes.STREAM_CODEC.apply(ByteBufCodecs::optional), FlutterAbility::particle, + FlutterAbility::new + ); + + /** + * A flutter on the default numbers, with nothing to see or hear. + */ + public static FlutterAbility of(int duration, int ramp, float strength) { + return new FlutterAbility(duration, ramp, strength, Optional.empty(), Optional.empty()); + } + + /** + * The upward speed the flutter is worth on one of its ticks. + *

+ * The first tick already lifts a little: a flutter that started with nothing would let the holder keep + * falling for as long as the ramp lasts, which reads as the jump key being ignored. + * + * @param elapsed how many ticks the flutter has already run, the first one being 0 + * @return the upward speed for that tick, in blocks per tick + */ + public float liftAt(int elapsed) { + if (this.ramp <= 0) { + return this.strength; + } + return this.strength * Math.min(1.0F, (float) (elapsed + 1) / (float) this.ramp); + } + + /** + * How high a whole flutter carries its holder, 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..84a66d986 --- /dev/null +++ b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/ability/PowerUpAbilities.java @@ -0,0 +1,34 @@ +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. + * + * @param flutter how the holder extends a jump by fluttering, if they can at all + */ +public record PowerUpAbilities( + Optional flutter +) { + public static final PowerUpAbilities EMPTY = new PowerUpAbilities(Optional.empty()); + + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + FlutterAbility.CODEC.optionalFieldOf("flutter").forGetter(PowerUpAbilities::flutter) + ).apply(instance, PowerUpAbilities::new)); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + FlutterAbility.STREAM_CODEC.apply(ByteBufCodecs::optional), PowerUpAbilities::flutter, + PowerUpAbilities::new + ); +} diff --git a/mubble-core/src/main/resources/fabric.mod.json b/mubble-core/src/main/resources/fabric.mod.json index ef7ab70f1..ff9758a73 100644 --- a/mubble-core/src/main/resources/fabric.mod.json +++ b/mubble-core/src/main/resources/fabric.mod.json @@ -27,7 +27,8 @@ "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/Fluttering" ] } }, 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..ccea2b954 --- /dev/null +++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/FlowerRenderer.java @@ -0,0 +1,54 @@ +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 of growth, in degrees. */ + private static final float SPIN_PER_BLOCK = 40.0F; + + 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.climbed = (float) (entity.getClimbed() + entity.getSpeed() * partialTicks); + } + + @Override + public void submit(FlowerRenderState state, PoseStack poseStack, SubmitNodeCollector submitNodeCollector, CameraRenderState camera) { + poseStack.pushPose(); + poseStack.mulPose(Axis.YP.rotationDegrees(Mth.wrapDegrees(state.climbed * SPIN_PER_BLOCK))); + 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..44f333808 --- /dev/null +++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/state/FlowerRenderState.java @@ -0,0 +1,8 @@ +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 grown, in blocks, which is what it sways to. */ + public float climbed; +} 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 0000000000000000000000000000000000000000..88a41f5b39989aa4fbe5d1d9dfcf3518b1092b23 GIT binary patch literal 465 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K595~p3>inN#}BcAtM$XprJPeS>E!#O`la zOI_u1{Hp)t;=1XxFHHXZagD}ohLU(SMgay!4ylGq@qru+OdtANdp})sV({QPaJ=eM z(Zl!ud)hHAVosVAFh9jcL$1s&2V5~0ntE- z7#5%f2aY!|H84yOVK8UnY+%?SdSmSYCIyClJWozL0BH!~V<}YVVBly_J}ASf#=!I- zAfUbJmI5P(Aj9z{?gpTQJewfMOnY86hLnlFIkSN_7ywO`U`z2`x^px#^2a?T$u+H{>+~>fd(-EO=H+2`W+l4AYo+S5SKo|ko(Y5d%;5> NVNX{-mvv4FO#qlRlGXqK literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..68a93772ab3f4c4be79466cf5276591656ea3d48 GIT binary patch literal 173 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`C7v#hAr-fh6BdY81R6ZIH(KUl z?AfM}{i9HsvB%TsNrykLAG1TzmhViPyeAk&8a}*~{4rkNyV>L{r?kz#!v>LT3B`@8xwP~7<2!A Vp4<3%>Qtcp44$rjF6*2UngFBcJ@fzo literal 0 HcmV?d00001 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 098fac3a4..d821eb856 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 @@ -35,7 +35,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 flutter."); 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"); @@ -83,5 +85,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 4d1d4135f..99b53f85a 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 @@ -26,7 +26,7 @@ protected void addTags(HolderLookup.Provider wrapperLookup) { builder(STOMPABLE).add(GOOMBA, GREEN_KOOPA_SHELL); // 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 and anything too big to make sense inside a bubble. Players are here on purpose: they fit the // automatic size and health criteria, but getting stuck inside someone else's bubble is not the point. 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..ef603e5ba 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,13 @@ 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.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 +18,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 +129,27 @@ 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_MAX_CLIMB, + false, + // One flower at a time, the next one coming half a second after the last. + PowerUpCharges.cooldownRecharge(1, 10) + ))) + // Placeholders until the flutter gets assets of its own: leaves and a wing beat are what + // it should read as, and both come from vanilla for now. + .flutter(new FlutterAbility( + FlutterAbility.DEFAULT_DURATION, + FlutterAbility.DEFAULT_RAMP, + FlutterAbility.DEFAULT_STRENGTH, + 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/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..842a12f18 --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Flower.java @@ -0,0 +1,343 @@ +package fr.hugman.mubble.super_mario.world.entity.projectile; + +import fr.hugman.mubble.super_mario.references.SuperMarioDamageTypeIds; +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.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.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.Level; +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.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. 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. + *

+ * In Super Mario Bros. Wonder the flowers rise through ceilings, which they keep doing here: a flower that + * stopped at the first block would be useless underground, where most of the game is played. What keeps that + * from reaching halfway across the world is that a flower runs out of both time and height, whichever comes + * first. A data pack that would rather have them pop against blocks can say so instead. + * + * @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 rises, 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 high a flower can climb before it wilts, in blocks. */ + public static final double DEFAULT_MAX_CLIMB = 12.0D; + /** The damage a flower deals, the same as the ball projectiles of the mod. */ + public static final float DAMAGE = 3.0F; + + /** Particles spawned per tick, strung along the height 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 CLIMBED_KEY = "climbed"; + private static final String SPEED_KEY = "speed"; + private static final String LIFETIME_KEY = "lifetime"; + private static final String MAX_CLIMB_KEY = "max_climb"; + private static final String STOPPED_BY_BLOCKS_KEY = "stopped_by_blocks"; + + private double speed = DEFAULT_SPEED; + private int lifetime = DEFAULT_LIFETIME; + private double maxClimb = DEFAULT_MAX_CLIMB; + private boolean stoppedByBlocks; + + private int age; + private double climbed; + /** + * 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(); + + public Flower(EntityType type, Level level) { + super(type, level); + this.noPhysics = true; + } + + public Flower(Level level, LivingEntity owner) { + this(SuperMarioEntityTypes.FLOWER, level); + this.setOwner(owner); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + } + + //region Settings + + public double getSpeed() { + return this.speed; + } + + public void setSpeed(double speed) { + this.speed = speed; + } + + public int getLifetime() { + return this.lifetime; + } + + public void setLifetime(int lifetime) { + this.lifetime = lifetime; + } + + public double getMaxClimb() { + return this.maxClimb; + } + + public void setMaxClimb(double maxClimb) { + this.maxClimb = maxClimb; + } + + /** + * @return whether the flower pops against the first solid block it meets, rather than growing through it + */ + public boolean isStoppedByBlocks() { + return this.stoppedByBlocks; + } + + public void setStoppedByBlocks(boolean stoppedByBlocks) { + this.stoppedByBlocks = stoppedByBlocks; + this.noPhysics = !stoppedByBlocks; + } + + /** + * @return how far the flower has already grown, in blocks + */ + public double getClimbed() { + return this.climbed; + } + + //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(); + + // A flower stopped by a block wilts on its way up, and a wilted one has nothing left to hit. + if (this.level().isClientSide() || this.isRemoved()) { + return; + } + + this.age++; + this.hitEntitiesInTheWay(); + if (this.age >= this.lifetime || this.climbed >= this.maxClimb) { + this.wilt(); + } + } + + /** + * Takes the flower up by one tick's worth of growth. + *

+ * 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 = new Vec3(0.0D, this.speed, 0.0D); + this.setDeltaMovement(movement); + this.move(MoverType.SELF, movement); + this.climbed += this.speed; + this.needsSync = true; + + if (this.level().isClientSide()) { + this.spawnGrowthParticles(); + } else if (this.stoppedByBlocks && this.verticalCollision) { + this.wilt(); + } + } + + /** + * 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 height, 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); + } + 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 height 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(); + double spread = this.getBbWidth() * PARTICLE_SPREAD; + for (int i = 0; i < PARTICLES_PER_TICK; i++) { + double y = this.getY() - this.speed * ((i + 0.5D) / PARTICLES_PER_TICK) + this.getBbHeight() / 2.0D; + this.level().addParticle(particle, this.getRandomX(spread), y, this.getRandomZ(spread), 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 getWiltSound() { + return SoundEvents.AZALEA_LEAVES_BREAK; + } + + //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) { + } + + @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(CLIMBED_KEY, this.climbed); + output.putDouble(SPEED_KEY, this.speed); + output.putInt(LIFETIME_KEY, this.lifetime); + output.putDouble(MAX_CLIMB_KEY, this.maxClimb); + output.putBoolean(STOPPED_BY_BLOCKS_KEY, this.stoppedByBlocks); + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + super.readAdditionalSaveData(input); + this.age = input.getIntOr(AGE_KEY, 0); + this.climbed = input.getDoubleOr(CLIMBED_KEY, 0.0D); + this.speed = input.getDoubleOr(SPEED_KEY, DEFAULT_SPEED); + this.lifetime = input.getIntOr(LIFETIME_KEY, DEFAULT_LIFETIME); + this.maxClimb = input.getDoubleOr(MAX_CLIMB_KEY, DEFAULT_MAX_CLIMB); + this.setStoppedByBlocks(input.getBooleanOr(STOPPED_BY_BLOCKS_KEY, false)); + } + + //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..35d6daaf7 --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/power_up/action/GrowFlowerPowerUpAction.java @@ -0,0 +1,164 @@ +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 rises, in blocks per tick + * @param lifetime how long it lasts at most, in ticks + * @param maxClimb how high it can climb before it wilts, in blocks + * @param stoppedByBlocks whether it pops against the first solid block, rather than growing through it + * @param charges how many flowers the holder gets, and how spent ones come back + */ +public record GrowFlowerPowerUpAction( + EntityType entity, + double speed, + int lifetime, + double maxClimb, + boolean stoppedByBlocks, + 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("max_climb", Flower.DEFAULT_MAX_CLIMB).forGetter(GrowFlowerPowerUpAction::maxClimb), + Codec.BOOL.optionalFieldOf("stopped_by_blocks", false).forGetter(GrowFlowerPowerUpAction::stoppedByBlocks), + 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::maxClimb, + ByteBufCodecs.BOOL, GrowFlowerPowerUpAction::stoppedByBlocks, + 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.setMaxClimb(this.maxClimb); + flower.setStoppedByBlocks(this.stoppedByBlocks); + } + + 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..378d0bcae 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,9 @@ 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 a flutter, on numbers of its own rather than on the defaults. */ + public static final ResourceKey FLUTTERER = powerUp("flutterer"); + /** 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/FlutterGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/FlutterGameTest.java new file mode 100644 index 000000000..dd4a7face --- /dev/null +++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/FlutterGameTest.java @@ -0,0 +1,212 @@ +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 flutter: 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 flutters for 10 ticks over a ramp of 4, short enough to play out whole + * inside an arena. Every one of them 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. + */ +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 be well under way. */ + private static final int PEAK_TICKS = 12; + /** 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, PEAK_TICKS); + fall(plain, JUMP_HELD, PEAK_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(); + } + + @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..8fa87a134 --- /dev/null +++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/FlowerGameTest.java @@ -0,0 +1,189 @@ +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.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.world.entity.EntityTypes; +import net.minecraft.world.entity.animal.pig.Pig; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.phys.Vec3; + +/** + * The huge flower the Super Flower Pot grows: it rises on its own, defeats whatever it grows through, + * and runs out of both time and height so that it never climbs 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; + + @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 climbed 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.getClimbed(), flower.getSpeed() * flower.tickCount, + "the height a flower climbed over its whole life"); + helper.assertValueEqual(flower.getY() - start, flower.getClimbed(), + "the height a flower climbed, against where it actually ended up"); + }) + .thenSucceed(); + } + + /** Rising through ceilings is the whole point: a flower stopping at the first block is useless indoors. */ + @GameTest + public void aFlowerGrowsThroughBlocks(GameTestHelper helper) { + Arena.buildFloor(helper); + var flower = grow(helper); + ceilingAt(helper, 5); + double ceiling = helper.absolutePos(new BlockPos(GROUND.getX(), 5, GROUND.getZ())).getY(); + + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue(flower.getY() > ceiling, "the flower never made it past the ceiling")) + .thenExecute(() -> helper.assertFalse(flower.isRemoved(), "a flower should grow through a ceiling rather than pop against it")) + .thenSucceed(); + } + + /** The other behaviour a data pack can ask for: pop against the first solid block instead. */ + @GameTest + public void aFlowerCanBeStoppedByBlocksInstead(GameTestHelper helper) { + Arena.buildFloor(helper); + var flower = grow(helper); + flower.setStoppedByBlocks(true); + ceilingAt(helper, 5); + + helper.succeedWhen(() -> helper.assertTrue(flower.isRemoved(), "a flower stopped by blocks should pop against the ceiling")); + } + + @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.setMaxClimb(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 aFlowerWiltsOnceItHasClimbedFarEnough(GameTestHelper helper) { + Arena.buildFloor(helper); + var flower = grow(helper); + flower.setLifetime(Integer.MAX_VALUE); + flower.setMaxClimb(2.0D); + + helper.succeedWhen(() -> { + helper.assertTrue(flower.isRemoved(), "the flower should wilt once it has climbed its whole height"); + helper.assertTrue(flower.getClimbed() >= 2.0D, "the flower wilted before climbing its whole height"); + }); + } + + @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 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) { + for (int x = 0; x < Arena.SIZE; x++) { + for (int z = 0; z < Arena.SIZE; 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..c95503a64 --- /dev/null +++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/GrowFlowerActionGameTest.java @@ -0,0 +1,157 @@ +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_MAX_CLIMB, + false, + 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, true, 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.getMaxClimb(), 5.0D, "the height limit the action asked for"); + helper.assertTrue(flower.isStoppedByBlocks(), "the action asked for a flower stopped by blocks"); + 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(); + } + + @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/flutterer.json b/mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up/flutterer.json new file mode 100644 index 000000000..a6b027f6d --- /dev/null +++ b/mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up/flutterer.json @@ -0,0 +1,12 @@ +{ + "abilities": { + "flutter": { + "duration": 10, + "ramp": 4, + "strength": 0.2 + } + }, + "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 5763a9917..fede88b21 100644 --- a/mubble-test/src/gametest/resources/fabric.mod.json +++ b/mubble-test/src/gametest/resources/fabric.mod.json @@ -20,11 +20,14 @@ "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.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.BlockTransformGameTest", "fr.hugman.mubble.test.gametest.collectible.CollectibleEntityGameTest", "fr.hugman.mubble.test.gametest.super_mario.BumpableBlockGameTest", 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..405b92e81 --- /dev/null +++ b/mubble-test/src/test/java/fr/hugman/mubble/test/unit/FlutterAbilityTest.java @@ -0,0 +1,85 @@ +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 a flutter: how much lift each of its ticks is worth. + *

+ * It is the ramp that tells a flutter apart from a second jump, so the curve behind it is worth + * pinning down on its own, away from a level and a player. + */ +public class FlutterAbilityTest { + private static final int DURATION = 20; + private static final int RAMP = 5; + private static final float STRENGTH = 0.12F; + + private static final FlutterAbility FLUTTER = FlutterAbility.of(DURATION, RAMP, STRENGTH); + private static final float EPSILON = 1.0E-6F; + + @Test + @DisplayName("the first tick already lifts, so that the jump key is never ignored") + void theFirstTickAlreadyLifts() { + assertTrue(FLUTTER.liftAt(0) > 0.0F, "the very first tick of a flutter should already carry the player"); + } + + @Test + @DisplayName("the lift climbs over the ramp instead of snapping to full strength") + void theLiftClimbsOverTheRamp() { + float previous = 0.0F; + for (int tick = 0; tick < RAMP; tick++) { + float lift = FLUTTER.liftAt(tick); + assertTrue(lift > previous, "tick " + tick + " should lift more than the one before it"); + assertTrue(lift <= STRENGTH + EPSILON, "no tick of the ramp should lift more than the full strength"); + previous = lift; + } + } + + @Test + @DisplayName("the lift reaches its full strength at the end of the ramp, and stays there") + void theLiftPlateausAfterTheRamp() { + assertEquals(STRENGTH, FLUTTER.liftAt(RAMP - 1), EPSILON, "the last tick of the ramp"); + assertEquals(STRENGTH, FLUTTER.liftAt(RAMP), EPSILON, "the tick right after the ramp"); + assertEquals(STRENGTH, FLUTTER.liftAt(DURATION - 1), EPSILON, "the last tick of the flutter"); + } + + @Test + @DisplayName("a flutter without a ramp lifts at full strength from the start") + void noRampMeansNoClimb() { + var abrupt = FlutterAbility.of(DURATION, 0, STRENGTH); + + assertEquals(STRENGTH, abrupt.liftAt(0), EPSILON, "the first tick of a flutter with no ramp"); + assertEquals(STRENGTH, abrupt.liftAt(DURATION - 1), EPSILON, "the last tick of a flutter with no ramp"); + } + + @Test + @DisplayName("a whole flutter is worth less than its strength held for its whole duration") + void theRampCostsSomeHeight() { + float held = STRENGTH * DURATION; + + assertTrue(FLUTTER.totalLift() < held, "the ramp should cost the flutter some of its height"); + assertTrue(FLUTTER.totalLift() > held * 0.5F, "the ramp should not cost the flutter most of its height either"); + assertEquals(held, FlutterAbility.of(DURATION, 0, STRENGTH).totalLift(), EPSILON, + "a flutter with no ramp should be worth its strength on every one of its ticks"); + } + + @Test + @DisplayName("a flutter that lasts no time at all lifts nothing") + void anEmptyFlutterLiftsNothing() { + assertEquals(0.0F, FlutterAbility.of(0, RAMP, STRENGTH).totalLift(), EPSILON, "a flutter of no duration"); + } + + @Test + @DisplayName("numbers that would push the holder down are refused") + void negativeNumbersAreRefused() { + var backwards = FlutterAbility.of(-10, -3, -0.5F); + + assertEquals(0, backwards.duration(), "a negative duration"); + assertEquals(0, backwards.ramp(), "a negative ramp"); + assertEquals(0.0F, backwards.strength(), EPSILON, "a negative strength"); + } +} 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..02700119e 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,8 @@ 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.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 +105,32 @@ void descriptionRoundTrips() { ); } + @Test + @DisplayName("the flutter ability keeps every one of its numbers") + void flutterAbilityRoundTrips() { + var decoded = CodecAssertions.assertJsonRoundTrip(PowerUp.DIRECT_CODEC, fullyPopulated()); + var decodedFlutter = decoded.abilities().flutter().orElseThrow(() -> new AssertionError("the flutter was dropped")); + + assertEquals(flutter(), decodedFlutter, "the flutter ability"); + } + + @Test + @DisplayName("a flutter written with nothing but its defaults reads back as the defaults") + void flutterAbilityDefaults() { + var decoded = PowerUp.DIRECT_CODEC.parse( + TestBootstrap.registries().createSerializationContext(JsonOps.INSTANCE), + JsonParser.parseString(""" + {"abilities": {"flutter": {}}} + """)) + .getOrThrow(error -> new AssertionError("could not read a bare flutter: " + error)); + + assertEquals( + FlutterAbility.of(FlutterAbility.DEFAULT_DURATION, FlutterAbility.DEFAULT_RAMP, FlutterAbility.DEFAULT_STRENGTH), + decoded.abilities().flutter().orElseThrow(() -> new AssertionError("the flutter was dropped")), + "a flutter with no field of its own" + ); + } + @Test @DisplayName("an unknown action type is rejected instead of being ignored") void unknownActionTypeIsRejected() { @@ -120,7 +148,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 +177,15 @@ static PowerUp fullyPopulated() { .particle(ParticleTypes.FLAME) .humanoidOverlay(Identifier.parse("mubble:entity/power_up/humanoid/test")) .emissiveOverlay() + .flutter(flutter()) .build(); } + /** Every number distinct, so that two of them swapped cannot round trip unnoticed. */ + static FlutterAbility flutter() { + return new FlutterAbility(24, 7, 0.19F, Optional.of(sound(SoundEvents.BAT_LOOP)), Optional.of(ParticleTypes.CHERRY_LEAVES)); + } + /** 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); From 705eb75ae76a34451adde0c522c415d67572766f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 08:13:19 +0000 Subject: [PATCH 2/5] Split the jump abilities in two, and make the flower bounce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flutter no longer holds one speed once it has ramped up: it opens on a speed of its own and is pushed a little harder on every tick, so the climb builds instead of reading as a rising platform. What happens once that climb is over is now an ability of its own. `FloatAbility` holds the holder to a walking pace on the way down and writes off most of what the fall would have been worth, for as long as the jump key stays pressed. It is deliberately independent of the climb that usually comes before it: the Tanooki form will take the float alone, so a power-up granting nothing else floats down from wherever its plain jump took it. The two are checked in that order and never both in the same tick, since a holder still being pushed up has no fall to slow. `Fluttering` and `Floating` split along the same line, with the jump key they both read moving out into `JumpKeyHolder`. The flower no longer goes through blocks. A ceiling sends it back down and onwards instead, along the way its holder was facing when they grew it, with a squish as it turns; it wilts on anything else it runs into, and on the ground it comes back to. What used to be a height limit is now the length of its whole path. It also runs the blocks it moves through, so tripwires and everything else keyed on a projectile passing by fire as they should, and it defeats the enemies of the module outright rather than chipping at them — a new `super_mario:enemies` tag says which those are. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T42CAmosnCqdPJnhN8dF6D --- .../fr/hugman/mubble/client/MubbleClient.java | 4 +- .../mubble/client/mixin/LocalPlayerMixin.java | 6 +- .../client/sound/AirMoveSoundInstance.java | 59 +++++ .../mubble/client/sound/AirMoveSounds.java | 64 ++++++ .../client/sound/FlutterSoundInstance.java | 47 ---- .../mubble/client/sound/FlutterSounds.java | 47 ---- .../fr/hugman/mubble/mixin/PlayerMixin.java | 206 ++++++++++++----- .../hugman/mubble/world/entity/Floating.java | 29 +++ .../mubble/world/entity/Fluttering.java | 10 +- .../mubble/world/entity/JumpKeyHolder.java | 17 ++ .../mubble/world/power_up/PowerUpBuilder.java | 14 +- .../world/power_up/ability/FloatAbility.java | 77 +++++++ .../power_up/ability/FlutterAbility.java | 57 +++-- .../power_up/ability/PowerUpAbilities.java | 15 +- .../src/main/resources/fabric.mod.json | 4 +- .../renderer/entity/FlowerRenderer.java | 15 +- .../entity/state/FlowerRenderState.java | 6 +- .../SuperMarioEnglishLangProvider.java | 2 +- .../SuperMarioEntityTypeTagsProvider.java | 2 + .../provider/SuperMarioPowerUpProvider.java | 18 +- .../tags/SuperMarioEntityTypeTags.java | 2 + .../world/entity/projectile/Flower.java | 213 +++++++++++++---- .../action/GrowFlowerPowerUpAction.java | 25 +- .../gametest/datapack/PowerUpFixtures.java | 5 +- .../test/gametest/power_up/FloatGameTest.java | 216 ++++++++++++++++++ .../gametest/power_up/FlutterGameTest.java | 37 ++- .../gametest/super_mario/FlowerGameTest.java | 121 ++++++++-- .../super_mario/GrowFlowerActionGameTest.java | 24 +- .../mubble/power_up/floater.json | 11 + .../mubble/power_up/flutterer.json | 6 +- .../src/gametest/resources/fabric.mod.json | 1 + .../mubble/test/unit/FloatAbilityTest.java | 55 +++++ .../mubble/test/unit/FlutterAbilityTest.java | 73 +++--- .../mubble/test/unit/PowerUpCodecTest.java | 49 +++- 34 files changed, 1172 insertions(+), 365 deletions(-) create mode 100644 mubble-core/src/client/java/fr/hugman/mubble/client/sound/AirMoveSoundInstance.java create mode 100644 mubble-core/src/client/java/fr/hugman/mubble/client/sound/AirMoveSounds.java delete mode 100644 mubble-core/src/client/java/fr/hugman/mubble/client/sound/FlutterSoundInstance.java delete mode 100644 mubble-core/src/client/java/fr/hugman/mubble/client/sound/FlutterSounds.java create mode 100644 mubble-core/src/main/java/fr/hugman/mubble/world/entity/Floating.java create mode 100644 mubble-core/src/main/java/fr/hugman/mubble/world/entity/JumpKeyHolder.java create mode 100644 mubble-core/src/main/java/fr/hugman/mubble/world/power_up/ability/FloatAbility.java create mode 100644 mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/FloatGameTest.java create mode 100644 mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up/floater.json create mode 100644 mubble-test/src/test/java/fr/hugman/mubble/test/unit/FloatAbilityTest.java 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 4caebb803..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,7 +5,7 @@ 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.FlutterSounds; +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; @@ -22,6 +22,6 @@ public void onInitializeClient() { MubbleKeyBindings.registerEvents(); MubbleClientPayloadReceivers.register(); - ClientTickEvents.END_CLIENT_TICK.register(FlutterSounds::tick); + 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 index 8f3dc18af..87ccec9d3 100644 --- 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 @@ -1,6 +1,6 @@ package fr.hugman.mubble.client.mixin; -import fr.hugman.mubble.world.entity.Fluttering; +import fr.hugman.mubble.world.entity.JumpKeyHolder; import net.minecraft.client.player.LocalPlayer; import org.spongepowered.asm.mixin.Mixin; @@ -9,10 +9,10 @@ *

* 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 - * flutter of the player it controls. + * mid-air moves of the player it controls. */ @Mixin(LocalPlayer.class) -public class LocalPlayerMixin implements Fluttering { +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/java/fr/hugman/mubble/client/sound/FlutterSoundInstance.java b/mubble-core/src/client/java/fr/hugman/mubble/client/sound/FlutterSoundInstance.java deleted file mode 100644 index 69e8bcfaa..000000000 --- a/mubble-core/src/client/java/fr/hugman/mubble/client/sound/FlutterSoundInstance.java +++ /dev/null @@ -1,47 +0,0 @@ -package fr.hugman.mubble.client.sound; - -import fr.hugman.mubble.world.entity.Fluttering; -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.sounds.SoundEvent; -import net.minecraft.sounds.SoundSource; -import net.minecraft.world.entity.player.Player; - -/** - * The loop a flutter is heard as, for as long as it lasts. - */ -@Environment(EnvType.CLIENT) -public class FlutterSoundInstance extends AbstractTickableSoundInstance { - private final Player player; - - public FlutterSoundInstance(Player player, SoundEvent event) { - super(event, SoundSource.PLAYERS, SoundInstance.createUnseededRandom()); - this.player = player; - this.looping = true; - this.delay = 0; - this.volume = 0.2F; - } - - @Override - public boolean canPlaySound() { - return !this.player.isSilent(); - } - - @Override - public boolean canStartSilent() { - return true; - } - - @Override - public void tick() { - if (this.player.isRemoved() || !((Fluttering) this.player).isFluttering()) { - 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/FlutterSounds.java b/mubble-core/src/client/java/fr/hugman/mubble/client/sound/FlutterSounds.java deleted file mode 100644 index c661bcd8e..000000000 --- a/mubble-core/src/client/java/fr/hugman/mubble/client/sound/FlutterSounds.java +++ /dev/null @@ -1,47 +0,0 @@ -package fr.hugman.mubble.client.sound; - -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.world.entity.player.Player; - -import java.util.Map; -import java.util.WeakHashMap; - -/** - * Keeps one flutter loop going per player fluttering in sight. - *

- * A sound instance stops itself once the flutter 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 FlutterSounds { - private static final Map PLAYING = new WeakHashMap<>(); - - private FlutterSounds() { - } - - public static void tick(Minecraft client) { - if (client.level == null) { - PLAYING.clear(); - return; - } - for (Player player : client.level.players()) { - if (!player.isFluttering()) { - PLAYING.remove(player); - continue; - } - var playing = PLAYING.get(player); - if (playing != null && !playing.isStopped()) { - continue; - } - player.getFlutterAbility().flatMap(FlutterAbility::sound).ifPresent(sound -> { - var instance = new FlutterSoundInstance(player, sound.value()); - PLAYING.put(player, instance); - client.getSoundManager().play(instance); - }); - } - } -} 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 f4842173e..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,20 @@ 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; @@ -36,7 +40,7 @@ import java.util.Optional; @Mixin(Player.class) -public class PlayerMixin implements PowerUpHolder, WaterRunner, Fluttering { +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 @@ -47,6 +51,9 @@ public class PlayerMixin implements PowerUpHolder, WaterRunner, Fluttering { */ @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"; @@ -57,15 +64,15 @@ public class PlayerMixin implements PowerUpHolder, WaterRunner, Fluttering { @Unique private static final String FLUTTER_SPENT_KEY = "flutter_spent"; - /** How many particles a fluttering player leaves around their feet every tick. */ + /** How many particles a player on a mid-air move leaves around their feet every tick. */ @Unique - private static final int FLUTTER_PARTICLES = 2; + 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 FLUTTER_PARTICLE_SPREAD = 0.8D; - /** How fast they sink, so that they read as being left behind by someone going up. */ + 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 FLUTTER_PARTICLE_FALL = -0.05D; + 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 @@ -99,12 +106,16 @@ public class PlayerMixin implements PowerUpHolder, WaterRunner, Fluttering { /** 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")) @@ -124,10 +135,9 @@ public class PlayerMixin implements PowerUpHolder, WaterRunner, Fluttering { 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$fluttering = flutterTicks >= 0; - this.mubble$flutterTicks = Math.max(0, flutterTicks); this.mubble$flutterSpent = view.getBooleanOr(FLUTTER_SPENT_KEY, false); - this.mubble$syncFluttering(this_); + this.mubble$setFluttering(this_, flutterTicks >= 0); + this.mubble$flutterTicks = Math.max(0, flutterTicks); } @Inject(method = "tick", at = @At("TAIL")) @@ -324,122 +334,190 @@ public void clearPowerUp() { } /** - * Runs the flutter: past the peak of a jump, a player still leaning on the jump key rises again for a - * moment instead of falling. + * 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 the lift is written straight into the movement of the - * tick, which {@code travel()} then spends a little further down the very same call. + * 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 up, 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. + * 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$tickFlutter(CallbackInfo ci) { + private void mubble$tickAirMoves(CallbackInfo ci) { var this_ = (Player) (Object) this; - this.mubble$flutterParticles(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$endFlutter(this_); + 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$endFlutter(this_); - return; + this.mubble$setFluttering(player, false); + return false; } FlutterAbility flutter = ability.get(); - boolean jumpHeld = this_.isJumpKeyHeld(); if (this.mubble$fluttering) { - // A released key cannot be leaned on again: that jump is done fluttering. - if (!jumpHeld || this.mubble$flutterTicks >= flutter.duration() || mubble$flutterCutShort(this_)) { - this.mubble$endFlutter(this_); - return; + if (this.mubble$flutterTicks >= flutter.duration()) { + this.mubble$setFluttering(player, false); + return false; } } else { - if (this.mubble$flutterSpent || !jumpHeld || mubble$flutterCutShort(this_)) { - return; - } // Nothing changes on the way up: the flutter waits for the player to start coming back down. - if (this_.getKnownMovement().y() >= 0.0D) { - return; + if (this.mubble$flutterSpent || player.getKnownMovement().y() >= 0.0D) { + return false; } - this.mubble$fluttering = true; this.mubble$flutterTicks = 0; this.mubble$flutterSpent = true; - this.mubble$syncFluttering(this_); + this.mubble$setFluttering(player, true); } - Vec3 movement = this_.getDeltaMovement(); - this_.setDeltaMovement(movement.x(), flutter.liftAt(this.mubble$flutterTicks), movement.z()); + 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 flutter cannot carry on through, landing aside: they are all ways of being held by + * 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$flutterCutShort(Player player) { + private static boolean mubble$heldBySomethingElse(Player player) { return player.isInWater() || player.onClimbable() || player.isFallFlying() || player.isPassenger(); } @Unique - private void mubble$endFlutter(Player player) { - if (!this.mubble$fluttering) { + 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 = false; - this.mubble$flutterTicks = 0; - this.mubble$syncFluttering(player); + 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 the flutter, which is all they get: they have no business simulating - * someone else's keys, they only draw what the flutter looks like. + * 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$syncFluttering(Player player) { + private void mubble$sync(Player player, EntityDataAccessor accessor, boolean value) { if (!player.level().isClientSide()) { - player.getEntityData().set(FLUTTERING, this.mubble$fluttering); + player.getEntityData().set(accessor, value); } } /** - * Leaves the trail of a flutter around the feet of the player. + * Leaves the trail of a mid-air move around the feet of the player. *

- * Every client draws it for every player it can see fluttering, 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 {@code isFluttering()} rather than off the flutter tick right below, which only ever - * runs for the one player this side is in charge of. + * 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$flutterParticles(Player player) { - if (!player.level().isClientSide() || !player.isFluttering()) { + private void mubble$airMoveParticles(Player player) { + if (!player.level().isClientSide()) { return; } - var particle = this.getFlutterAbility().flatMap(FlutterAbility::particle); + 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() * FLUTTER_PARTICLE_SPREAD; - for (int i = 0; i < FLUTTER_PARTICLES; i++) { + 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, FLUTTER_PARTICLE_FALL, 0.0D); + 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 flutter of their own, they are shown the one the server tells them about. + * merely watches never start a move of their own, they are shown the one the server tells them about. */ @Override public boolean isJumpKeyHeld() { @@ -453,6 +531,12 @@ public Optional getFlutterAbility() { 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; @@ -461,6 +545,12 @@ public boolean isFluttering() { 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; 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 index 0386cc8a5..d6c2404d2 100644 --- 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 @@ -5,13 +5,14 @@ import java.util.Optional; /** - * An entity that can extend its jump by fluttering, granted by whichever power-up it holds. + * 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 { /** @@ -21,13 +22,6 @@ default Optional getFlutterAbility() { return Optional.empty(); } - /** - * @return whether the jump key is being held down right now, as far as this side can tell - */ - default boolean isJumpKeyHeld() { - return false; - } - /** * @return whether a flutter is going on right now */ 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/PowerUpBuilder.java b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/PowerUpBuilder.java index 587f51b3d..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,7 @@ 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; @@ -30,6 +31,7 @@ public class PowerUpBuilder { 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; @@ -91,13 +93,21 @@ public PowerUpBuilder attributesModifier(Holder attribute, double val } /** - * Lets the holder extend their jumps by fluttering, on the terms the ability is built with. + * 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; @@ -144,7 +154,7 @@ public PowerUp build() { Optional.ofNullable(spriteId), Optional.ofNullable(action), Optional.ofNullable(attributesModifiers.isEmpty() ? null : attributesModifiers), - new PowerUpAbilities(Optional.ofNullable(this.flutter)), + 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..1e7318110 --- /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.1F; + public static final float DEFAULT_FALL_DAMAGE = 0.25F; + + 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 index 031bc506f..917da560f 100644 --- 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 @@ -13,23 +13,26 @@ import java.util.Optional; /** - * Extends a jump by fluttering: past the peak of it, a holder still leaning on the jump key rises again - * for a moment instead of falling. + * 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. *

- * Everything the flutter is worth lives here rather than in whichever form happens to grant it, so that the - * next form to want one only has to hand over its own numbers. The lift is not handed out whole on the first - * tick either: it climbs over {@link #ramp} ticks, which is what tells a flutter apart from a second jump. + * The climb builds rather than holding one speed — the holder is pushed a little harder on every tick of it, + * so the flutter starts as a hesitation and ends as a proper lift. That is what tells it apart from a second + * jump, which would hand over all of its height at once. + *

+ * 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 a flutter lasts at most - * @param ramp how many ticks the lift takes to reach its full strength - * @param strength the upward speed a flutter is worth once ramped up, in blocks per tick - * @param sound the sound played in loop for as long as the flutter lasts - * @param particle the particle left around the feet of the holder while they flutter + * @param duration how many ticks the climb lasts at most + * @param speed the upward speed the climb opens on, in blocks per tick + * @param acceleration how much speed every tick of the climb adds to it, 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, - int ramp, - float strength, + float speed, + float acceleration, Optional> sound, Optional particle ) { @@ -37,52 +40,46 @@ public record FlutterAbility( // A data pack is free to write anything; what it cannot do is send the holder downwards on an // ability whose whole point is to hold them up. duration = Math.max(0, duration); - ramp = Math.max(0, ramp); - strength = Math.max(0.0F, strength); + speed = Math.max(0.0F, speed); + acceleration = Math.max(0.0F, acceleration); } public static final int DEFAULT_DURATION = 20; - public static final int DEFAULT_RAMP = 5; - public static final float DEFAULT_STRENGTH = 0.12F; + public static final float DEFAULT_SPEED = 0.05F; + public static final float DEFAULT_ACCELERATION = 0.005F; public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( Codec.INT.optionalFieldOf("duration", DEFAULT_DURATION).forGetter(FlutterAbility::duration), - Codec.INT.optionalFieldOf("ramp", DEFAULT_RAMP).forGetter(FlutterAbility::ramp), - Codec.FLOAT.optionalFieldOf("strength", DEFAULT_STRENGTH).forGetter(FlutterAbility::strength), + Codec.FLOAT.optionalFieldOf("speed", DEFAULT_SPEED).forGetter(FlutterAbility::speed), + 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.INT, FlutterAbility::ramp, - ByteBufCodecs.FLOAT, FlutterAbility::strength, + ByteBufCodecs.FLOAT, FlutterAbility::speed, + ByteBufCodecs.FLOAT, FlutterAbility::acceleration, SoundEvent.STREAM_CODEC.apply(ByteBufCodecs::optional), FlutterAbility::sound, ParticleTypes.STREAM_CODEC.apply(ByteBufCodecs::optional), FlutterAbility::particle, FlutterAbility::new ); /** - * A flutter on the default numbers, with nothing to see or hear. + * A flutter with nothing to see or hear. */ - public static FlutterAbility of(int duration, int ramp, float strength) { - return new FlutterAbility(duration, ramp, strength, Optional.empty(), Optional.empty()); + public static FlutterAbility of(int duration, float speed, float acceleration) { + return new FlutterAbility(duration, speed, acceleration, Optional.empty(), Optional.empty()); } /** * The upward speed the flutter is worth on one of its ticks. - *

- * The first tick already lifts a little: a flutter that started with nothing would let the holder keep - * falling for as long as the ramp lasts, which reads as the jump key being ignored. * * @param elapsed how many ticks the flutter has already run, the first one being 0 * @return the upward speed for that tick, in blocks per tick */ public float liftAt(int elapsed) { - if (this.ramp <= 0) { - return this.strength; - } - return this.strength * Math.min(1.0F, (float) (elapsed + 1) / (float) this.ramp); + return this.speed + this.acceleration * elapsed; } /** 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 index 84a66d986..9ebf259b1 100644 --- 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 @@ -15,20 +15,27 @@ * 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 extends a jump by fluttering, if they can at all + * @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 flutter, + Optional floating ) { - public static final PowerUpAbilities EMPTY = new PowerUpAbilities(Optional.empty()); + 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) + 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 ff9758a73..0879a6353 100644 --- a/mubble-core/src/main/resources/fabric.mod.json +++ b/mubble-core/src/main/resources/fabric.mod.json @@ -28,7 +28,9 @@ "net/minecraft/world/entity/player/Player": [ "fr/hugman/mubble/world/power_up/PowerUpHolder", "fr/hugman/mubble/world/entity/WaterRunner", - "fr/hugman/mubble/world/entity/Fluttering" + "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/renderer/entity/FlowerRenderer.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/FlowerRenderer.java index ccea2b954..9da9bb41b 100644 --- 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 @@ -21,8 +21,12 @@ */ public class FlowerRenderer extends EntityRenderer { private static final Identifier TEXTURE = SuperMario.id("textures/entity/flower.png"); - /** How far the flower turns over one block of growth, in degrees. */ + /** 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; @@ -39,13 +43,18 @@ public FlowerRenderState createRenderState() { @Override public void extractRenderState(Flower entity, FlowerRenderState state, float partialTicks) { super.extractRenderState(entity, state, partialTicks); - state.climbed = (float) (entity.getClimbed() + entity.getSpeed() * 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.climbed * SPIN_PER_BLOCK))); + 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(); 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 index 44f333808..12eea3c68 100644 --- 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 @@ -3,6 +3,8 @@ import net.minecraft.client.renderer.entity.state.EntityRenderState; public class FlowerRenderState extends EntityRenderState { - /** How far the flower has grown, in blocks, which is what it sways to. */ - public float climbed; + /** 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/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 d821eb856..85ad837d6 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 @@ -35,7 +35,7 @@ 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 flutter."); + 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."); 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 99b53f85a..f1c723495 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 @@ -26,6 +26,8 @@ protected void addTags(HolderLookup.Provider wrapperLookup) { builder(STOMPABLE).add(GOOMBA, GREEN_KOOPA_SHELL); // FIREBALL is qualified because vanilla has one under that name too. + builder(ENEMIES).add(GOOMBA); + builder(ALL).add(GOOMBA, GREEN_KOOPA_SHELL, RED_KOOPA_SHELL, SuperMarioEntityTypeIds.FIREBALL, ICEBALL, GOLD_FIREBALL, CLOUD_PLATFORM, BUBBLE, FLOWER); // Bosses and anything too big to make sense inside a bubble. Players are here on purpose: they fit the 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 ef603e5ba..56b268520 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 @@ -10,6 +10,7 @@ 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; @@ -135,17 +136,22 @@ public static void bootstrap(BootstrapContext context) { SuperMarioEntityTypes.FLOWER, Flower.DEFAULT_SPEED, Flower.DEFAULT_LIFETIME, - Flower.DEFAULT_MAX_CLIMB, - false, + 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 flutter gets assets of its own: leaves and a wing beat are what - // it should read as, and both come from vanilla for now. + // 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_RAMP, - FlutterAbility.DEFAULT_STRENGTH, + FlutterAbility.DEFAULT_SPEED, + 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) )) 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 c411154fa..62f1e8ed3 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 @@ -12,6 +12,8 @@ public class SuperMarioEntityTypeTags { public static final TagKey> STOMPABLE = bind("stompable"); 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/projectile/Flower.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Flower.java index 842a12f18..e9f634fed 100644 --- 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 @@ -1,15 +1,19 @@ 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.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; @@ -31,13 +35,14 @@ * 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. 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. + * gravity nor drag ever touches, and defeats whatever it grows through on the way — outright, for the enemies + * of the module. Ceilings send it back down and onwards instead of stopping it, along the way its holder was + * facing when they grew it, so a flower grown indoors sweeps a room rather than dying against the first slab. + * 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. *

- * In Super Mario Bros. Wonder the flowers rise through ceilings, which they keep doing here: a flower that - * stopped at the first block would be useless underground, where most of the game is played. What keeps that - * from reaching halfway across the world is that a flower runs out of both time and height, whichever comes - * first. A data pack that would rather have them pop against blocks can say so instead. + * Its whole path is worth a set distance and a set number of ticks, whichever runs out first, and anything it + * runs into other than a ceiling ends it there and then. * * @since v4.0.0 */ @@ -45,35 +50,44 @@ 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 rises, in blocks per tick. */ + /** 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 high a flower can climb before it wilts, in blocks. */ - public static final double DEFAULT_MAX_CLIMB = 12.0D; + /** 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; - /** Particles spawned per tick, strung along the height the flower covers during it. */ + /** The share of its speed a flower carries forward once a ceiling has sent it back down. */ + private static final double BOUNCE_FORWARD = 0.6D; + /** Ticks the squish of a bounce 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 CLIMBED_KEY = "climbed"; + 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 MAX_CLIMB_KEY = "max_climb"; - private static final String STOPPED_BY_BLOCKS_KEY = "stopped_by_blocks"; + private static final String RANGE_KEY = "range"; + private static final String FORWARD_YAW_KEY = "forward_yaw"; + private static final String BOUNCED_KEY = "bounced"; private double speed = DEFAULT_SPEED; private int lifetime = DEFAULT_LIFETIME; - private double maxClimb = DEFAULT_MAX_CLIMB; - private boolean stoppedByBlocks; + 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 climbed; + private double travelled; + private boolean bounced; /** * Everything already hit, so that a flower only ever hits the same entity once. *

@@ -82,14 +96,18 @@ public class Flower extends Projectile { */ private final IntSet hitEntities = new IntOpenHashSet(); + private int squishTicks; + private int squishTicksO; + public Flower(EntityType type, Level level) { super(type, level); - this.noPhysics = true; + 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 @@ -104,6 +122,11 @@ public double getSpeed() { public void setSpeed(double speed) { this.speed = speed; + // The heading only ever changes on a bounce, so a flower yet to have one is still going straight up + // and takes the new speed right away — including in the packet that spawns it on the clients. + if (!this.bounced) { + this.setDeltaMovement(0.0D, speed, 0.0D); + } } public int getLifetime() { @@ -114,31 +137,45 @@ public void setLifetime(int lifetime) { this.lifetime = lifetime; } - public double getMaxClimb() { - return this.maxClimb; + /** + * @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 void setMaxClimb(double maxClimb) { - this.maxClimb = maxClimb; + public float getForwardYaw() { + return this.forwardYaw; + } + + public void setForwardYaw(float forwardYaw) { + this.forwardYaw = forwardYaw; } /** - * @return whether the flower pops against the first solid block it meets, rather than growing through it + * @return the way a bounce sends the flower on, as a horizontal unit vector */ - public boolean isStoppedByBlocks() { - return this.stoppedByBlocks; + public Vec3 getForward() { + float yaw = this.forwardYaw * (float) (Math.PI / 180.0); + return new Vec3(-Mth.sin(yaw), 0.0D, Mth.cos(yaw)); } - public void setStoppedByBlocks(boolean stoppedByBlocks) { - this.stoppedByBlocks = stoppedByBlocks; - this.noPhysics = !stoppedByBlocks; + /** + * @return how far the flower has already travelled, in blocks + */ + public double getTravelled() { + return this.travelled; } /** - * @return how far the flower has already grown, in blocks + * @return whether a ceiling has already sent the flower back down */ - public double getClimbed() { - return this.climbed; + public boolean hasBounced() { + return this.bounced; } //endregion @@ -156,43 +193,70 @@ public void tick() { super.tick(); this.grow(); - // A flower stopped by a block wilts on its way up, and a wilted one has nothing left to hit. - if (this.level().isClientSide() || this.isRemoved()) { + if (this.level().isClientSide()) { + this.tickSquish(); + return; + } + if (this.isRemoved()) { return; } this.age++; this.hitEntitiesInTheWay(); - if (this.age >= this.lifetime || this.climbed >= this.maxClimb) { + if (this.age >= this.lifetime || this.travelled >= this.range) { this.wilt(); } } /** - * Takes the flower up by one tick's worth of growth. + * 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 = new Vec3(0.0D, this.speed, 0.0D); - this.setDeltaMovement(movement); + Vec3 movement = this.getDeltaMovement(); + Vec3 before = this.position(); this.move(MoverType.SELF, movement); - this.climbed += this.speed; + // 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.stoppedByBlocks && this.verticalCollision) { + return; + } + if (this.horizontalCollision) { + // A flower cannot go through a wall, and has nowhere to go but out. this.wilt(); + } else if (this.verticalCollision) { + if (this.bounced) { + // Back on the ground it came from: the arc is over. + this.wilt(); + } else { + this.bounce(); + } } } + /** + * Sends the flower back down and onwards after a ceiling, along the way its holder was facing. + */ + private void bounce() { + this.bounced = true; + Vec3 forward = this.getForward().scale(this.speed * BOUNCE_FORWARD); + this.setDeltaMovement(forward.x(), -this.speed, forward.z()); + this.level().broadcastEntityEvent(this, EVENT_SQUISH); + this.playSound(this.getBounceSound(), 0.7F, 1.4F); + } + /** * 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 height, which is - * what lets one flower clear a whole column of enemies. It only ever hits the same one once, though. + * 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)) { @@ -205,7 +269,10 @@ private void hitEntitiesInTheWay() { if (this.getOwner() instanceof LivingEntity owner) { owner.setLastHurtMob(entity); } - entity.hurtServer(serverLevel, this.damageSources().source(SuperMarioDamageTypeIds.FLOWER, this, this.getOwner()), DAMAGE); + // 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); } } @@ -241,15 +308,20 @@ private void wilt() { } /** - * Strings the growth particles along the height the flower covered during the tick, rather than dropping + * 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++) { - double y = this.getY() - this.speed * ((i + 0.5D) / PARTICLES_PER_TICK) + this.getBbHeight() / 2.0D; - this.level().addParticle(particle, this.getRandomX(spread), y, this.getRandomZ(spread), 0.0D, 0.0D, 0.0D); + 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); } } @@ -265,12 +337,50 @@ protected SoundEvent getGrowthSound() { return SoundEvents.BONE_MEAL_USE; } + protected SoundEvent getBounceSound() { + 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 @@ -288,6 +398,7 @@ protected boolean canHitEntity(Entity target) { protected void onHitEntity(EntityHitResult result) { } + /** Blocks are handled from the actual movement in {@link #grow}. */ @Override protected void onHitBlock(BlockHitResult result) { } @@ -321,22 +432,24 @@ public boolean deflect(ProjectileDeflection deflection, @Nullable Entity entity, protected void addAdditionalSaveData(ValueOutput output) { super.addAdditionalSaveData(output); output.putInt(AGE_KEY, this.age); - output.putDouble(CLIMBED_KEY, this.climbed); + output.putDouble(TRAVELLED_KEY, this.travelled); output.putDouble(SPEED_KEY, this.speed); output.putInt(LIFETIME_KEY, this.lifetime); - output.putDouble(MAX_CLIMB_KEY, this.maxClimb); - output.putBoolean(STOPPED_BY_BLOCKS_KEY, this.stoppedByBlocks); + output.putDouble(RANGE_KEY, this.range); + output.putFloat(FORWARD_YAW_KEY, this.forwardYaw); + output.putBoolean(BOUNCED_KEY, this.bounced); } @Override protected void readAdditionalSaveData(ValueInput input) { super.readAdditionalSaveData(input); this.age = input.getIntOr(AGE_KEY, 0); - this.climbed = input.getDoubleOr(CLIMBED_KEY, 0.0D); + 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.maxClimb = input.getDoubleOr(MAX_CLIMB_KEY, DEFAULT_MAX_CLIMB); - this.setStoppedByBlocks(input.getBooleanOr(STOPPED_BY_BLOCKS_KEY, false)); + this.range = input.getDoubleOr(RANGE_KEY, DEFAULT_RANGE); + this.forwardYaw = input.getFloatOr(FORWARD_YAW_KEY, 0.0F); + this.bounced = input.getBooleanOr(BOUNCED_KEY, false); } //endregion 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 index 35d6daaf7..e0f30edfe 100644 --- 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 @@ -35,19 +35,17 @@ * 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 rises, in blocks per tick - * @param lifetime how long it lasts at most, in ticks - * @param maxClimb how high it can climb before it wilts, in blocks - * @param stoppedByBlocks whether it pops against the first solid block, rather than growing through it - * @param charges how many flowers the holder gets, and how spent ones come back + * @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 maxClimb, - boolean stoppedByBlocks, + 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. */ @@ -57,8 +55,7 @@ public record GrowFlowerPowerUpAction( 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("max_climb", Flower.DEFAULT_MAX_CLIMB).forGetter(GrowFlowerPowerUpAction::maxClimb), - Codec.BOOL.optionalFieldOf("stopped_by_blocks", false).forGetter(GrowFlowerPowerUpAction::stoppedByBlocks), + Codec.DOUBLE.optionalFieldOf("range", Flower.DEFAULT_RANGE).forGetter(GrowFlowerPowerUpAction::range), PowerUpCharges.CODEC.optionalFieldOf("charges", PowerUpCharges.DEFAULT).forGetter(GrowFlowerPowerUpAction::charges) ).apply(instance, GrowFlowerPowerUpAction::new)); @@ -66,8 +63,7 @@ public record GrowFlowerPowerUpAction( ByteBufCodecs.registry(Registries.ENTITY_TYPE), GrowFlowerPowerUpAction::entity, ByteBufCodecs.DOUBLE, GrowFlowerPowerUpAction::speed, ByteBufCodecs.INT, GrowFlowerPowerUpAction::lifetime, - ByteBufCodecs.DOUBLE, GrowFlowerPowerUpAction::maxClimb, - ByteBufCodecs.BOOL, GrowFlowerPowerUpAction::stoppedByBlocks, + ByteBufCodecs.DOUBLE, GrowFlowerPowerUpAction::range, PowerUpCharges.STREAM_CODEC, GrowFlowerPowerUpAction::charges, GrowFlowerPowerUpAction::new ); @@ -113,8 +109,9 @@ public InteractionResult trigger(Player player) { flower.setOwner(player); flower.setSpeed(this.speed); flower.setLifetime(this.lifetime); - flower.setMaxClimb(this.maxClimb); - flower.setStoppedByBlocks(this.stoppedByBlocks); + 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()); 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 378d0bcae..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,9 +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 a flutter, on numbers of its own rather than on the defaults. */ + /** 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 index dd4a7face..e1554c4ba 100644 --- 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 @@ -14,12 +14,14 @@ import fr.hugman.mubble.world.power_up.PowerUp; /** - * The flutter: 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 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 flutters for 10 ticks over a ramp of 4, short enough to play out whole - * inside an arena. Every one of them 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. + * 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); @@ -27,9 +29,9 @@ public class FlutterGameTest { /** 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 be well under way. */ - private static final int PEAK_TICKS = 12; + private static final int FLUTTER_DURATION = 20; + /** Long enough for a jump to peak and for the flutter to be under way, with room left in it. */ + private static final int PEAK_TICKS = 10; /** Long enough for anything left in the air to have come back down. */ private static final int LANDING_TICKS = 40; @@ -72,6 +74,25 @@ public void aFlutterCarriesThePlayerHigher(GameTestHelper helper) { 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); 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 index 8fa87a134..e4439309c 100644 --- 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 @@ -1,20 +1,24 @@ 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.phys.Vec3; /** - * The huge flower the Super Flower Pot grows: it rises on its own, defeats whatever it grows through, - * and runs out of both time and height so that it never climbs forever. + * The huge flower the Super Flower Pot grows: it rises on its own, defeats whatever it grows through, is + * sent back down and onwards by ceilings, 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. */ @@ -40,7 +44,7 @@ public void aFlowerRisesStraightUp(GameTestHelper helper) { /** * 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 climbed so + * 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 @@ -54,37 +58,79 @@ public void aFlowerRisesAtAConstantSpeed(GameTestHelper helper) { .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.getClimbed(), flower.getSpeed() * flower.tickCount, - "the height a flower climbed over its whole life"); - helper.assertValueEqual(flower.getY() - start, flower.getClimbed(), - "the height a flower climbed, against where it actually ended up"); + 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(); } - /** Rising through ceilings is the whole point: a flower stopping at the first block is useless indoors. */ + /** A flower cannot go through blocks: a ceiling sends it back down and onwards instead of stopping it. */ @GameTest - public void aFlowerGrowsThroughBlocks(GameTestHelper helper) { + public void aCeilingSendsAFlowerBackDownAndForward(GameTestHelper helper) { Arena.buildFloor(helper); var flower = grow(helper); + // Facing north, so a bounce should carry it towards a smaller z. + flower.setForwardYaw(Direction.NORTH.toYRot()); ceilingAt(helper, 5); - double ceiling = helper.absolutePos(new BlockPos(GROUND.getX(), 5, GROUND.getZ())).getY(); + double planted = flower.getZ(); helper.startSequence() - .thenWaitUntil(() -> helper.assertTrue(flower.getY() > ceiling, "the flower never made it past the ceiling")) - .thenExecute(() -> helper.assertFalse(flower.isRemoved(), "a flower should grow through a ceiling rather than pop against it")) + .thenWaitUntil(() -> helper.assertTrue(flower.hasBounced(), "the flower never bounced 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 bounced flower should be heading back down, was " + flower.getDeltaMovement()); + helper.assertTrue(flower.getDeltaMovement().z() < 0.0D, + "a flower thrown north should be carried north by its bounce, was " + flower.getDeltaMovement()); + }) + .thenIdle(3) + .thenExecute(() -> helper.assertTrue(flower.getZ() < planted, "the flower never actually moved forward after bouncing")) .thenSucceed(); } - /** The other behaviour a data pack can ask for: pop against the first solid block instead. */ + /** One arc and no more: back on the ground it came from, the flower is spent. */ @GameTest - public void aFlowerCanBeStoppedByBlocksInstead(GameTestHelper helper) { + public void aBouncedFlowerWiltsWhenItComesBackDown(GameTestHelper helper) { Arena.buildFloor(helper); var flower = grow(helper); - flower.setStoppedByBlocks(true); + flower.setLifetime(Integer.MAX_VALUE); + flower.setRange(Double.MAX_VALUE); ceilingAt(helper, 5); - helper.succeedWhen(() -> helper.assertTrue(flower.isRemoved(), "a flower stopped by blocks should pop against the ceiling")); + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue(flower.hasBounced(), "the flower never bounced off the ceiling")) + .thenWaitUntil(() -> helper.assertTrue(flower.isRemoved(), "a bounced flower should wilt once it is back on the ground")) + .thenSucceed(); + } + + /** + * 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 @@ -93,7 +139,7 @@ public void aFlowerWiltsOnceItsTimeIsUp(GameTestHelper helper) { var flower = grow(helper); flower.setLifetime(6); // Well out of reach, so that the height limit cannot be what ends this one. - flower.setMaxClimb(Double.MAX_VALUE); + flower.setRange(Double.MAX_VALUE); helper.startSequence() .thenIdle(3) @@ -103,18 +149,46 @@ public void aFlowerWiltsOnceItsTimeIsUp(GameTestHelper helper) { } @GameTest - public void aFlowerWiltsOnceItHasClimbedFarEnough(GameTestHelper helper) { + public void aFlowerWiltsOnceItHasTravelledFarEnough(GameTestHelper helper) { Arena.buildFloor(helper); var flower = grow(helper); flower.setLifetime(Integer.MAX_VALUE); - flower.setMaxClimb(2.0D); + flower.setRange(2.0D); helper.succeedWhen(() -> { - helper.assertTrue(flower.isRemoved(), "the flower should wilt once it has climbed its whole height"); - helper.assertTrue(flower.getClimbed() >= 2.0D, "the flower wilted before climbing its whole height"); + 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); @@ -174,6 +248,11 @@ public void aFlowerIsNotSomethingToStandOn(GameTestHelper helper) { } /** {@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)); } 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 index c95503a64..b06befb19 100644 --- 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 @@ -30,8 +30,7 @@ public class GrowFlowerActionGameTest { SuperMarioEntityTypes.FLOWER, Flower.DEFAULT_SPEED, Flower.DEFAULT_LIFETIME, - Flower.DEFAULT_MAX_CLIMB, - false, + Flower.DEFAULT_RANGE, PowerUpCharges.cooldownRecharge(1, 10) ); @@ -62,15 +61,14 @@ public void theHolderOwnsWhatTheyGrow(GameTestHelper helper) { 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, true, PowerUpCharges.none()); + 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.getMaxClimb(), 5.0D, "the height limit the action asked for"); - helper.assertTrue(flower.isStoppedByBlocks(), "the action asked for a flower stopped by blocks"); + helper.assertValueEqual(flower.getRange(), 5.0D, "the range the action asked for"); helper.succeed(); } @@ -129,6 +127,22 @@ public void lookingUpOrDownChangesNothing(GameTestHelper helper) { .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); 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 index a6b027f6d..517e34541 100644 --- 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 @@ -1,9 +1,9 @@ { "abilities": { "flutter": { - "duration": 10, - "ramp": 4, - "strength": 0.2 + "duration": 20, + "speed": 0.15, + "acceleration": 0.01 } }, "name": { diff --git a/mubble-test/src/gametest/resources/fabric.mod.json b/mubble-test/src/gametest/resources/fabric.mod.json index fede88b21..18c9ed9c7 100644 --- a/mubble-test/src/gametest/resources/fabric.mod.json +++ b/mubble-test/src/gametest/resources/fabric.mod.json @@ -21,6 +21,7 @@ "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", 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 index 405b92e81..b19775871 100644 --- 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 @@ -8,78 +8,77 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * The shape of a flutter: how much lift each of its ticks is worth. + * The shape of the climb half of a jump held on: how much lift each of its ticks is worth. *

- * It is the ramp that tells a flutter apart from a second jump, so the curve behind it is worth - * pinning down on its own, away from a level and a player. + * A flutter builds rather than holding one speed, which is what tells it apart from a second jump, so the + * curve behind it is worth pinning down on its own, away from a level and a player. */ public class FlutterAbilityTest { private static final int DURATION = 20; - private static final int RAMP = 5; - private static final float STRENGTH = 0.12F; + private static final float SPEED = 0.05F; + private static final float ACCELERATION = 0.005F; - private static final FlutterAbility FLUTTER = FlutterAbility.of(DURATION, RAMP, STRENGTH); + private static final FlutterAbility FLUTTER = FlutterAbility.of(DURATION, SPEED, ACCELERATION); private static final float EPSILON = 1.0E-6F; @Test - @DisplayName("the first tick already lifts, so that the jump key is never ignored") + @DisplayName("the flutter opens on its own speed, so that the jump key is never ignored") void theFirstTickAlreadyLifts() { - assertTrue(FLUTTER.liftAt(0) > 0.0F, "the very first tick of a flutter should already carry the player"); + assertEquals(SPEED, FLUTTER.liftAt(0), EPSILON, "the very first tick of a flutter"); + assertTrue(FLUTTER.liftAt(0) > 0.0F, "the very first tick should already carry the player"); } @Test - @DisplayName("the lift climbs over the ramp instead of snapping to full strength") - void theLiftClimbsOverTheRamp() { - float previous = 0.0F; - for (int tick = 0; tick < RAMP; tick++) { - float lift = FLUTTER.liftAt(tick); - assertTrue(lift > previous, "tick " + tick + " should lift more than the one before it"); - assertTrue(lift <= STRENGTH + EPSILON, "no tick of the ramp should lift more than the full strength"); - previous = lift; + @DisplayName("every tick lifts a little harder than the one before it") + void theLiftKeepsBuilding() { + 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"); } } + /** A flutter that plateaued would carry its holder the same way a rising platform does. */ @Test - @DisplayName("the lift reaches its full strength at the end of the ramp, and stays there") - void theLiftPlateausAfterTheRamp() { - assertEquals(STRENGTH, FLUTTER.liftAt(RAMP - 1), EPSILON, "the last tick of the ramp"); - assertEquals(STRENGTH, FLUTTER.liftAt(RAMP), EPSILON, "the tick right after the ramp"); - assertEquals(STRENGTH, FLUTTER.liftAt(DURATION - 1), EPSILON, "the last tick of the flutter"); + @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 without a ramp lifts at full strength from the start") - void noRampMeansNoClimb() { - var abrupt = FlutterAbility.of(DURATION, 0, STRENGTH); + @DisplayName("a flutter without acceleration holds one speed throughout") + void noAccelerationMeansOneSpeed() { + var steady = FlutterAbility.of(DURATION, SPEED, 0.0F); - assertEquals(STRENGTH, abrupt.liftAt(0), EPSILON, "the first tick of a flutter with no ramp"); - assertEquals(STRENGTH, abrupt.liftAt(DURATION - 1), EPSILON, "the last tick of a flutter with no ramp"); + assertEquals(SPEED, steady.liftAt(0), EPSILON, "the first tick of a flutter with no acceleration"); + assertEquals(SPEED, steady.liftAt(DURATION - 1), EPSILON, "the last tick of a flutter with no acceleration"); + assertEquals(SPEED * DURATION, steady.totalLift(), EPSILON, "the whole of a flutter with no acceleration"); } @Test - @DisplayName("a whole flutter is worth less than its strength held for its whole duration") - void theRampCostsSomeHeight() { - float held = STRENGTH * DURATION; + @DisplayName("a whole flutter is worth its opening speed plus everything the acceleration added") + void theAccelerationIsWorthTheClimb() { + // The acceleration is added once on the second tick, twice on the third, and so on. + float ticks = DURATION; + float expected = SPEED * ticks + ACCELERATION * (ticks - 1.0F) * ticks / 2.0F; - assertTrue(FLUTTER.totalLift() < held, "the ramp should cost the flutter some of its height"); - assertTrue(FLUTTER.totalLift() > held * 0.5F, "the ramp should not cost the flutter most of its height either"); - assertEquals(held, FlutterAbility.of(DURATION, 0, STRENGTH).totalLift(), EPSILON, - "a flutter with no ramp should be worth its strength on every one of its ticks"); + assertEquals(expected, FLUTTER.totalLift(), EPSILON, "the height a whole flutter is worth"); + assertTrue(FLUTTER.totalLift() > SPEED * DURATION, "the acceleration should be worth height of its own"); } @Test @DisplayName("a flutter that lasts no time at all lifts nothing") void anEmptyFlutterLiftsNothing() { - assertEquals(0.0F, FlutterAbility.of(0, RAMP, STRENGTH).totalLift(), EPSILON, "a flutter of no duration"); + assertEquals(0.0F, FlutterAbility.of(0, SPEED, ACCELERATION).totalLift(), EPSILON, "a flutter of no duration"); } @Test @DisplayName("numbers that would push the holder down are refused") void negativeNumbersAreRefused() { - var backwards = FlutterAbility.of(-10, -3, -0.5F); + var backwards = FlutterAbility.of(-10, -0.5F, -0.1F); assertEquals(0, backwards.duration(), "a negative duration"); - assertEquals(0, backwards.ramp(), "a negative ramp"); - assertEquals(0.0F, backwards.strength(), EPSILON, "a negative strength"); + assertEquals(0.0F, backwards.speed(), EPSILON, "a negative speed"); + 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 02700119e..6fac820dc 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,7 @@ 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; @@ -106,29 +107,47 @@ void descriptionRoundTrips() { } @Test - @DisplayName("the flutter ability keeps every one of its numbers") - void flutterAbilityRoundTrips() { - var decoded = CodecAssertions.assertJsonRoundTrip(PowerUp.DIRECT_CODEC, fullyPopulated()); - var decodedFlutter = decoded.abilities().flutter().orElseThrow(() -> new AssertionError("the flutter was dropped")); + @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(); - assertEquals(flutter(), decodedFlutter, "the flutter ability"); + 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("a flutter written with nothing but its defaults reads back as the defaults") - void flutterAbilityDefaults() { + @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": {}}} + {"abilities": {"flutter": {}, "float": {}}} """)) - .getOrThrow(error -> new AssertionError("could not read a bare flutter: " + error)); + .getOrThrow(error -> new AssertionError("could not read bare abilities: " + error)) + .abilities(); assertEquals( - FlutterAbility.of(FlutterAbility.DEFAULT_DURATION, FlutterAbility.DEFAULT_RAMP, FlutterAbility.DEFAULT_STRENGTH), - decoded.abilities().flutter().orElseThrow(() -> new AssertionError("the flutter was dropped")), + FlutterAbility.of(FlutterAbility.DEFAULT_DURATION, FlutterAbility.DEFAULT_SPEED, 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 @@ -178,12 +197,18 @@ static PowerUp fullyPopulated() { .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, 7, 0.19F, Optional.of(sound(SoundEvents.BAT_LOOP)), Optional.of(ParticleTypes.CHERRY_LEAVES)); + 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. */ From 07d258729436fe626c39ef335840d499d82f4cf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 19:46:14 +0000 Subject: [PATCH 3/5] Make the flower persistent and the flutter punchy A ceiling no longer ends the flower's climb. It ducks down and forward for a few ticks to get out from under whatever is in the way, then goes straight back to climbing from wherever that left it, ducking again if it has to. Nothing it runs into ends it any more: it only ever runs out of time or of distance, so a flower grown indoors keeps working the room instead of dying against the first slab. It also hits the blocks it runs into the way any projectile does, so target blocks and everything else keyed on being shot at answer to it, on top of the tripwires it already ran through. The flutter now opens on a drop instead of a lift. The holder keeps sinking for a couple of ticks while the same acceleration eats away at that drop, and then it carries them up hard and is over in well under a second. Those few ticks of hanging are what make the lift land as a snap rather than as a balloon. The float comes down closer to a plain fall and keeps more of the fall damage than it did. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T42CAmosnCqdPJnhN8dF6D --- .../world/power_up/ability/FloatAbility.java | 4 +- .../power_up/ability/FlutterAbility.java | 54 ++++--- .../provider/SuperMarioPowerUpProvider.java | 2 +- .../world/entity/projectile/Flower.java | 133 +++++++++++++----- .../gametest/power_up/FlutterGameTest.java | 36 ++++- .../gametest/super_mario/FlowerGameTest.java | 64 +++++++-- .../mubble/power_up/flutterer.json | 6 +- .../mubble/test/unit/FlutterAbilityTest.java | 83 ++++++----- .../mubble/test/unit/PowerUpCodecTest.java | 2 +- 9 files changed, 269 insertions(+), 115 deletions(-) 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 index 1e7318110..0843fbdfe 100644 --- 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 @@ -41,8 +41,8 @@ public record FloatAbility( fallDamage = Mth.clamp(fallDamage, 0.0F, 1.0F); } - public static final float DEFAULT_SPEED = 0.1F; - public static final float DEFAULT_FALL_DAMAGE = 0.25F; + 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), 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 index 917da560f..6849582a2 100644 --- 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 @@ -9,6 +9,7 @@ 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; @@ -16,41 +17,43 @@ * 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. *

- * The climb builds rather than holding one speed — the holder is pushed a little harder on every tick of it, - * so the flutter starts as a hesitation and ends as a proper lift. That is what tells it apart from a second - * jump, which would hand over all of its height at once. + * 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 speed the upward speed the climb opens on, in blocks per tick - * @param acceleration how much speed every tick of the climb adds to it, in blocks per tick per tick + * @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 speed, + float drop, float acceleration, Optional> sound, Optional particle ) { public FlutterAbility { - // A data pack is free to write anything; what it cannot do is send the holder downwards on an - // ability whose whole point is to hold them up. + // 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); - speed = Math.max(0.0F, speed); + drop = Math.max(0.0F, drop); acceleration = Math.max(0.0F, acceleration); } - public static final int DEFAULT_DURATION = 20; - public static final float DEFAULT_SPEED = 0.05F; - public static final float DEFAULT_ACCELERATION = 0.005F; + 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("speed", DEFAULT_SPEED).forGetter(FlutterAbility::speed), + 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) @@ -58,7 +61,7 @@ public record FlutterAbility( public static final StreamCodec STREAM_CODEC = StreamCodec.composite( ByteBufCodecs.INT, FlutterAbility::duration, - ByteBufCodecs.FLOAT, FlutterAbility::speed, + ByteBufCodecs.FLOAT, FlutterAbility::drop, ByteBufCodecs.FLOAT, FlutterAbility::acceleration, SoundEvent.STREAM_CODEC.apply(ByteBufCodecs::optional), FlutterAbility::sound, ParticleTypes.STREAM_CODEC.apply(ByteBufCodecs::optional), FlutterAbility::particle, @@ -68,22 +71,33 @@ public record FlutterAbility( /** * A flutter with nothing to see or hear. */ - public static FlutterAbility of(int duration, float speed, float acceleration) { - return new FlutterAbility(duration, speed, acceleration, Optional.empty(), Optional.empty()); + public static FlutterAbility of(int duration, float drop, float acceleration) { + return new FlutterAbility(duration, drop, acceleration, Optional.empty(), Optional.empty()); } /** - * The upward speed the flutter is worth on one of its ticks. + * 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 upward speed for that tick, in blocks per tick + * @return the speed for that tick, in blocks per tick, upwards when positive */ public float liftAt(int elapsed) { - return this.speed + this.acceleration * elapsed; + return this.acceleration * elapsed - this.drop; } /** - * How high a whole flutter carries its holder, gravity left aside. + * 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; 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 56b268520..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 @@ -144,7 +144,7 @@ public static void bootstrap(BootstrapContext context) { // beat are what they should read as, and both come from vanilla for now. .flutter(new FlutterAbility( FlutterAbility.DEFAULT_DURATION, - FlutterAbility.DEFAULT_SPEED, + FlutterAbility.DEFAULT_DROP, FlutterAbility.DEFAULT_ACCELERATION, Optional.of(BuiltInRegistries.SOUND_EVENT.wrapAsHolder(SoundEvents.BAT_LOOP)), Optional.of(ParticleTypes.CHERRY_LEAVES) 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 index e9f634fed..870019b1c 100644 --- 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 @@ -7,6 +7,7 @@ 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; @@ -23,11 +24,14 @@ 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; @@ -36,13 +40,16 @@ *

* 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. Ceilings send it back down and onwards instead of stopping it, along the way its holder was - * facing when they grew it, so a flower grown indoors sweeps a room rather than dying against the first slab. - * 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. + * of the module. + *

+ * It cannot go through blocks, and a ceiling does not stop it either: it ducks down and forward for a moment, + * along the way its holder was facing when they grew it, and then goes back to climbing from wherever that + * left it. A flower grown under a roof keeps trying to get out from under it rather than dying against the + * first slab, and only ever runs out of time or of distance. *

- * Its whole path is worth a set distance and a set number of ticks, whichever runs out first, and anything it - * runs into other than a ceiling ends it there and then. + * 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 */ @@ -59,9 +66,13 @@ public class Flower extends Projectile { /** The damage a flower deals, the same as the ball projectiles of the mod. */ public static final float DAMAGE = 3.0F; - /** The share of its speed a flower carries forward once a ceiling has sent it back down. */ - private static final double BOUNCE_FORWARD = 0.6D; - /** Ticks the squish of a bounce lasts. */ + /** How many ticks a flower spends ducking out from under a ceiling before it climbs again. */ + private static final int ESCAPE_TICKS = 6; + /** The share of its speed a flower sinks at while it ducks out, which is a dip and not a fall. */ + private static final double ESCAPE_DROP = 0.5D; + /** The share of its speed a flower carries forward while it ducks out, which is what clears the ceiling. */ + private static final double ESCAPE_FORWARD = 1.0D; + /** Ticks the squish of hitting a ceiling lasts. */ public static final int SQUISH_DURATION = 6; private static final byte EVENT_SQUISH = 100; @@ -77,7 +88,7 @@ public class Flower extends Projectile { private static final String LIFETIME_KEY = "lifetime"; private static final String RANGE_KEY = "range"; private static final String FORWARD_YAW_KEY = "forward_yaw"; - private static final String BOUNCED_KEY = "bounced"; + private static final String ESCAPE_TICKS_KEY = "escape_ticks"; private double speed = DEFAULT_SPEED; private int lifetime = DEFAULT_LIFETIME; @@ -87,7 +98,8 @@ public class Flower extends Projectile { private int age; private double travelled; - private boolean bounced; + /** Ticks left of the duck out of a ceiling, 0 while the flower is climbing. */ + private int escapeTicks; /** * Everything already hit, so that a flower only ever hits the same entity once. *

@@ -122,9 +134,9 @@ public double getSpeed() { public void setSpeed(double speed) { this.speed = speed; - // The heading only ever changes on a bounce, so a flower yet to have one is still going straight up - // and takes the new speed right away — including in the packet that spawns it on the clients. - if (!this.bounced) { + // A flower that is not ducking out of anything is climbing, so it takes the new speed right away — + // including in the packet that spawns it on the clients. + if (this.escapeTicks <= 0) { this.setDeltaMovement(0.0D, speed, 0.0D); } } @@ -157,7 +169,7 @@ public void setForwardYaw(float forwardYaw) { } /** - * @return the way a bounce sends the flower on, as a horizontal unit vector + * @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); @@ -172,10 +184,10 @@ public double getTravelled() { } /** - * @return whether a ceiling has already sent the flower back down + * @return whether the flower is currently ducking out from under a ceiling rather than climbing */ - public boolean hasBounced() { - return this.bounced; + public boolean isEscaping() { + return this.escapeTicks > 0; } //endregion @@ -228,28 +240,77 @@ private void grow() { this.spawnGrowthParticles(); return; } - if (this.horizontalCollision) { - // A flower cannot go through a wall, and has nowhere to go but out. - this.wilt(); - } else if (this.verticalCollision) { - if (this.bounced) { - // Back on the ground it came from: the arc is over. - this.wilt(); - } else { - this.bounce(); + + boolean blocked = this.horizontalCollision || this.verticalCollision; + if (blocked) { + this.hitBlocks(movement); + } + if (this.escapeTicks > 0) { + // The duck is over once its time is up, and early if it ran into something of its own. + if (--this.escapeTicks <= 0 || blocked) { + this.climb(); } + } else if (this.verticalCollision) { + this.escape(); } } + /** Points the flower back the only way it ever really wants to go. */ + private void climb() { + this.escapeTicks = 0; + this.setDeltaMovement(0.0D, this.speed, 0.0D); + } + /** - * Sends the flower back down and onwards after a ceiling, along the way its holder was facing. + * Ducks the flower down and forward for a moment, along the way its holder was facing. + *

+ * This is not the end of its climb but an attempt to get out from under whatever is in the way: once the + * duck is over the flower goes straight back up, from wherever it has got to. A ceiling it fails to clear + * simply sends it ducking again, until it runs out of time or of distance. */ - private void bounce() { - this.bounced = true; - Vec3 forward = this.getForward().scale(this.speed * BOUNCE_FORWARD); - this.setDeltaMovement(forward.x(), -this.speed, forward.z()); + private void escape() { + this.escapeTicks = ESCAPE_TICKS; + 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.getBounceSound(), 0.7F, 1.4F); + 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); } /** @@ -337,7 +398,7 @@ protected SoundEvent getGrowthSound() { return SoundEvents.BONE_MEAL_USE; } - protected SoundEvent getBounceSound() { + protected SoundEvent getEscapeSound() { return SoundEvents.AZALEA_LEAVES_HIT; } @@ -437,7 +498,7 @@ protected void addAdditionalSaveData(ValueOutput output) { output.putInt(LIFETIME_KEY, this.lifetime); output.putDouble(RANGE_KEY, this.range); output.putFloat(FORWARD_YAW_KEY, this.forwardYaw); - output.putBoolean(BOUNCED_KEY, this.bounced); + output.putInt(ESCAPE_TICKS_KEY, this.escapeTicks); } @Override @@ -449,7 +510,7 @@ protected void readAdditionalSaveData(ValueInput input) { 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); - this.bounced = input.getBooleanOr(BOUNCED_KEY, false); + this.escapeTicks = input.getIntOr(ESCAPE_TICKS_KEY, 0); } //endregion 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 index e1554c4ba..a84fcda9b 100644 --- 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 @@ -29,9 +29,11 @@ public class FlutterGameTest { /** 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 = 20; - /** Long enough for a jump to peak and for the flutter to be under way, with room left in it. */ - private static final int PEAK_TICKS = 10; + 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; @@ -65,8 +67,8 @@ public void aFlutterCarriesThePlayerHigher(GameTestHelper helper) { var fluttering = jumper(helper, PowerUpFixtures.FLUTTERER); var plain = jumper(helper, PowerUpFixtures.EMPTY); - fall(fluttering, JUMP_HELD, PEAK_TICKS); - fall(plain, JUMP_HELD, PEAK_TICKS); + 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 " @@ -74,6 +76,30 @@ public void aFlutterCarriesThePlayerHigher(GameTestHelper helper) { 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) { 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 index e4439309c..0ffc664cd 100644 --- 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 @@ -13,12 +13,13 @@ 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; /** - * The huge flower the Super Flower Pot grows: it rises on its own, defeats whatever it grows through, is - * sent back down and onwards by ceilings, and runs out of both time and distance so that it never travels - * forever. + * The huge flower the Super Flower Pot grows: it rises on its own, defeats whatever it grows through, ducks + * 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. */ @@ -66,33 +67,33 @@ public void aFlowerRisesAtAConstantSpeed(GameTestHelper helper) { .thenSucceed(); } - /** A flower cannot go through blocks: a ceiling sends it back down and onwards instead of stopping it. */ + /** A flower cannot go through blocks: a ceiling makes it duck down and forward rather than stopping it. */ @GameTest - public void aCeilingSendsAFlowerBackDownAndForward(GameTestHelper helper) { + public void aCeilingMakesAFlowerDuckDownAndForward(GameTestHelper helper) { Arena.buildFloor(helper); var flower = grow(helper); - // Facing north, so a bounce should carry it towards a smaller z. + // 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.hasBounced(), "the flower never bounced off the ceiling")) + .thenWaitUntil(() -> helper.assertTrue(flower.isEscaping(), "the flower never ducked out of 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 bounced flower should be heading back down, was " + flower.getDeltaMovement()); + "a flower ducking out should be heading back down, was " + flower.getDeltaMovement()); helper.assertTrue(flower.getDeltaMovement().z() < 0.0D, - "a flower thrown north should be carried north by its bounce, was " + flower.getDeltaMovement()); + "a flower thrown north should be carried north by its duck, was " + flower.getDeltaMovement()); }) .thenIdle(3) - .thenExecute(() -> helper.assertTrue(flower.getZ() < planted, "the flower never actually moved forward after bouncing")) + .thenExecute(() -> helper.assertTrue(flower.getZ() < planted, "the flower never actually moved forward while ducking")) .thenSucceed(); } - /** One arc and no more: back on the ground it came from, the flower is spent. */ - @GameTest - public void aBouncedFlowerWiltsWhenItComesBackDown(GameTestHelper helper) { + /** The duck is an attempt to get out, not the end of the climb: what follows it is more climbing. */ + @GameTest(maxTicks = 200) + public void aFlowerClimbsAgainAfterDuckingOut(GameTestHelper helper) { Arena.buildFloor(helper); var flower = grow(helper); flower.setLifetime(Integer.MAX_VALUE); @@ -100,11 +101,44 @@ public void aBouncedFlowerWiltsWhenItComesBackDown(GameTestHelper helper) { ceilingAt(helper, 5); helper.startSequence() - .thenWaitUntil(() -> helper.assertTrue(flower.hasBounced(), "the flower never bounced off the ceiling")) - .thenWaitUntil(() -> helper.assertTrue(flower.isRemoved(), "a bounced flower should wilt once it is back on the ground")) + .thenWaitUntil(() -> helper.assertTrue(flower.isEscaping(), "the flower never ducked out of the ceiling")) + .thenWaitUntil(() -> helper.assertFalse(flower.isEscaping(), "the flower never stopped ducking")) + .thenExecute(() -> { + helper.assertFalse(flower.isRemoved(), "a flower should still be around once it has ducked out"); + helper.assertValueEqual(flower.getDeltaMovement(), new Vec3(0.0D, flower.getSpeed(), 0.0D), + "the movement of a flower that has gone back to climbing"); + }) .thenSucceed(); } + /** 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. 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 index 517e34541..2a7b85dfa 100644 --- 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 @@ -1,9 +1,9 @@ { "abilities": { "flutter": { - "duration": 20, - "speed": 0.15, - "acceleration": 0.01 + "duration": 10, + "drop": 0.2, + "acceleration": 0.08 } }, "name": { 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 index b19775871..a204988bc 100644 --- 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 @@ -8,36 +8,55 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * The shape of the climb half of a jump held on: how much lift each of its ticks is worth. + * The shape of the climb half of a jump held on: what each of its ticks is worth. *

- * A flutter builds rather than holding one speed, which is what tells it apart from a second jump, so the - * curve behind it is worth pinning down on its own, away from a level and a player. + * 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 = 20; - private static final float SPEED = 0.05F; - private static final float ACCELERATION = 0.005F; + 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, SPEED, ACCELERATION); + private static final FlutterAbility FLUTTER = FlutterAbility.of(DURATION, DROP, ACCELERATION); private static final float EPSILON = 1.0E-6F; @Test - @DisplayName("the flutter opens on its own speed, so that the jump key is never ignored") - void theFirstTickAlreadyLifts() { - assertEquals(SPEED, FLUTTER.liftAt(0), EPSILON, "the very first tick of a flutter"); - assertTrue(FLUTTER.liftAt(0) > 0.0F, "the very first tick should already carry the player"); + @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 lifts a little harder than the one before it") - void theLiftKeepsBuilding() { + @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"); } } - /** A flutter that plateaued would carry its holder the same way a rising platform does. */ + @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() { @@ -46,39 +65,39 @@ void theLiftNeverPlateaus() { } @Test - @DisplayName("a flutter without acceleration holds one speed throughout") - void noAccelerationMeansOneSpeed() { - var steady = FlutterAbility.of(DURATION, SPEED, 0.0F); + @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(SPEED, steady.liftAt(0), EPSILON, "the first tick of a flutter with no acceleration"); - assertEquals(SPEED, steady.liftAt(DURATION - 1), EPSILON, "the last tick of a flutter with no acceleration"); - assertEquals(SPEED * DURATION, steady.totalLift(), EPSILON, "the whole of a flutter with no acceleration"); + 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 whole flutter is worth its opening speed plus everything the acceleration added") - void theAccelerationIsWorthTheClimb() { - // The acceleration is added once on the second tick, twice on the third, and so on. - float ticks = DURATION; - float expected = SPEED * ticks + ACCELERATION * (ticks - 1.0F) * ticks / 2.0F; + @DisplayName("a flutter that never accelerates never stops sinking") + void noAccelerationMeansNoLift() { + var stalled = FlutterAbility.of(DURATION, DROP, 0.0F); - assertEquals(expected, FLUTTER.totalLift(), EPSILON, "the height a whole flutter is worth"); - assertTrue(FLUTTER.totalLift() > SPEED * DURATION, "the acceleration should be worth height of its own"); + 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 lifts nothing") - void anEmptyFlutterLiftsNothing() { - assertEquals(0.0F, FlutterAbility.of(0, SPEED, ACCELERATION).totalLift(), EPSILON, "a flutter of no duration"); + @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 that would push the holder down are refused") + @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.speed(), EPSILON, "a negative speed"); + 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 6fac820dc..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 @@ -139,7 +139,7 @@ void abilityDefaults() { .abilities(); assertEquals( - FlutterAbility.of(FlutterAbility.DEFAULT_DURATION, FlutterAbility.DEFAULT_SPEED, FlutterAbility.DEFAULT_ACCELERATION), + 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" ); From c069cbd8a332907a4504d782c7fb14216820a9d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 20:04:57 +0000 Subject: [PATCH 4/5] Bend the flower's knock into an arc instead of a snap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ceiling used to switch the flower onto a fixed heading for six ticks and then switch it straight back, which read as two corners rather than as one movement. It is now gravity upside down: the flower is pulled towards the sky every tick and tops out at its own speed, so a ceiling only has to knock it down and forward once and the pull does the rest — the dip turns over on its own and eases back into a climb, the way a jump does with the world the other way up. The forward drift fades on drag rather than being switched off, so nothing about the path has a corner in it any more. That leaves the timed duck with nothing to do, and the state it needed along with it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T42CAmosnCqdPJnhN8dF6D --- .../world/entity/projectile/Flower.java | 95 +++++++++++-------- .../gametest/super_mario/FlowerGameTest.java | 76 +++++++++++---- 2 files changed, 113 insertions(+), 58 deletions(-) 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 index 870019b1c..818a7c4dc 100644 --- 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 @@ -42,10 +42,12 @@ * 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 ducks down and forward for a moment, - * along the way its holder was facing when they grew it, and then goes back to climbing from wherever that - * left it. A flower grown under a roof keeps trying to get out from under it rather than dying against the - * first slab, and only ever runs out of time or of distance. + * 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 @@ -66,12 +68,19 @@ public class Flower extends Projectile { /** The damage a flower deals, the same as the ball projectiles of the mod. */ public static final float DAMAGE = 3.0F; - /** How many ticks a flower spends ducking out from under a ceiling before it climbs again. */ - private static final int ESCAPE_TICKS = 6; - /** The share of its speed a flower sinks at while it ducks out, which is a dip and not a fall. */ - private static final double ESCAPE_DROP = 0.5D; - /** The share of its speed a flower carries forward while it ducks out, which is what clears the ceiling. */ + /** + * 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; @@ -88,7 +97,6 @@ public class Flower extends Projectile { private static final String LIFETIME_KEY = "lifetime"; private static final String RANGE_KEY = "range"; private static final String FORWARD_YAW_KEY = "forward_yaw"; - private static final String ESCAPE_TICKS_KEY = "escape_ticks"; private double speed = DEFAULT_SPEED; private int lifetime = DEFAULT_LIFETIME; @@ -98,8 +106,6 @@ public class Flower extends Projectile { private int age; private double travelled; - /** Ticks left of the duck out of a ceiling, 0 while the flower is climbing. */ - private int escapeTicks; /** * Everything already hit, so that a flower only ever hits the same entity once. *

@@ -134,9 +140,10 @@ public double getSpeed() { public void setSpeed(double speed) { this.speed = speed; - // A flower that is not ducking out of anything is climbing, so it takes the new speed right away — - // including in the packet that spawns it on the clients. - if (this.escapeTicks <= 0) { + // 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); } } @@ -184,10 +191,18 @@ public double getTravelled() { } /** - * @return whether the flower is currently ducking out from under a ceiling rather than climbing + * @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.escapeTicks > 0; + return this.getDeltaMovement().y() < this.speed - MIN_DRIFT; } //endregion @@ -238,38 +253,40 @@ private void grow() { if (this.level().isClientSide()) { this.spawnGrowthParticles(); - return; - } - - boolean blocked = this.horizontalCollision || this.verticalCollision; - if (blocked) { + } else if (this.horizontalCollision || this.verticalCollision) { this.hitBlocks(movement); - } - if (this.escapeTicks > 0) { - // The duck is over once its time is up, and early if it ran into something of its own. - if (--this.escapeTicks <= 0 || blocked) { - this.climb(); + // 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(); } - } else if (this.verticalCollision) { - this.escape(); } + this.accelerate(); } - /** Points the flower back the only way it ever really wants to go. */ - private void climb() { - this.escapeTicks = 0; - this.setDeltaMovement(0.0D, this.speed, 0.0D); + /** + * 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); } /** - * Ducks the flower down and forward for a moment, along the way its holder was facing. + * 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 an attempt to get out from under whatever is in the way: once the - * duck is over the flower goes straight back up, from wherever it has got to. A ceiling it fails to clear - * simply sends it ducking again, until it runs out of time or of distance. + * 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() { - this.escapeTicks = ESCAPE_TICKS; 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); @@ -498,7 +515,6 @@ protected void addAdditionalSaveData(ValueOutput output) { output.putInt(LIFETIME_KEY, this.lifetime); output.putDouble(RANGE_KEY, this.range); output.putFloat(FORWARD_YAW_KEY, this.forwardYaw); - output.putInt(ESCAPE_TICKS_KEY, this.escapeTicks); } @Override @@ -510,7 +526,6 @@ protected void readAdditionalSaveData(ValueInput input) { 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); - this.escapeTicks = input.getIntOr(ESCAPE_TICKS_KEY, 0); } //endregion 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 index 0ffc664cd..4fb2dd3f6 100644 --- 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 @@ -16,8 +16,11 @@ 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, ducks + * 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. */ @@ -26,6 +29,9 @@ public class FlowerGameTest { 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) { @@ -67,9 +73,9 @@ public void aFlowerRisesAtAConstantSpeed(GameTestHelper helper) { .thenSucceed(); } - /** A flower cannot go through blocks: a ceiling makes it duck down and forward rather than stopping it. */ + /** A flower cannot go through blocks: a ceiling knocks it down and forward rather than stopping it. */ @GameTest - public void aCeilingMakesAFlowerDuckDownAndForward(GameTestHelper helper) { + public void aCeilingKnocksAFlowerDownAndForward(GameTestHelper helper) { Arena.buildFloor(helper); var flower = grow(helper); // Facing north, so the duck should carry it towards a smaller z. @@ -78,39 +84,68 @@ public void aCeilingMakesAFlowerDuckDownAndForward(GameTestHelper helper) { double planted = flower.getZ(); helper.startSequence() - .thenWaitUntil(() -> helper.assertTrue(flower.isEscaping(), "the flower never ducked out of the ceiling")) + .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 ducking out should be heading back down, was " + flower.getDeltaMovement()); + "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 its duck, was " + flower.getDeltaMovement()); + "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 while ducking")) + .thenExecute(() -> helper.assertTrue(flower.getZ() < planted, "the flower never actually moved forward off the ceiling")) .thenSucceed(); } - /** The duck is an attempt to get out, not the end of the climb: what follows it is more climbing. */ + /** + * 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 aFlowerClimbsAgainAfterDuckingOut(GameTestHelper helper) { + public void aKnockArcsBackIntoAClimbRatherThanSnapping(GameTestHelper helper) { Arena.buildFloor(helper); var flower = grow(helper); flower.setLifetime(Integer.MAX_VALUE); flower.setRange(Double.MAX_VALUE); - ceilingAt(helper, 5); + // 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() - .thenWaitUntil(() -> helper.assertTrue(flower.isEscaping(), "the flower never ducked out of the ceiling")) - .thenWaitUntil(() -> helper.assertFalse(flower.isEscaping(), "the flower never stopped ducking")) - .thenExecute(() -> { - helper.assertFalse(flower.isRemoved(), "a flower should still be around once it has ducked out"); - helper.assertValueEqual(flower.getDeltaMovement(), new Vec3(0.0D, flower.getSpeed(), 0.0D), - "the movement of a flower that has gone back to climbing"); - }) + .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) { @@ -293,8 +328,13 @@ private static Flower grow(GameTestHelper helper) { /** 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 < Arena.SIZE; z++) { + for (int z = 0; z <= lastZ; z++) { helper.setBlock(new BlockPos(x, y, z), Blocks.STONE); } } From ce31f71e5325ff4911e9491e1099b433acecf67d Mon Sep 17 00:00:00 2001 From: Hugman Date: Wed, 9 Sep 2026 21:48:11 +0200 Subject: [PATCH 5/5] Add super flower pot texture --- .../textures/item/super_flower_pot.png | Bin 173 -> 319 bytes 1 file changed, 0 insertions(+), 0 deletions(-) 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 index 68a93772ab3f4c4be79466cf5276591656ea3d48..a068caa3827e0adea6452061e3ec33ef403862dd 100644 GIT binary patch delta 303 zcmZ3>xSwf)WIZzj1A~Sxe=v|@EDmyaVpw-h<|UBh5#STz3Z#KRR7Av3S9b~n!&)Vl zJvuyhjQAgz3cRooJ{rz^ErH>EGQ*c7hF_Ts|Ff8jHmX*fw0yqOzT>LR|GmC5-n#ui z82)htW+wMi#-s|x=Q=v;m$|*=wYf;oBo{68`w@x|Ev(Qy8=-gu_mmKrkxpfR04L_gl+gW@1#?yCo he_3YCU9MaClr8Nn+ln7cR;hqI@9FC2vd$@?2>`g`gFFBL delta 156 zcmV;N0Av5Z0<8g%8Gi-<001BJ|6u?C0CY)2K~#9!V_={acu_FtKQg9BGf9RZYxr>^ z0gOp9HG2zl<@n0`XgEY+v8$ABQFj)p*a{;oUcr~1}RHd6~p8Sx&o#U9rt({5e|=mHvj}c3bA2qY83%({qvoHF0000< KMNUMnLSTY%Ha(jF