diff --git a/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/PowerUpCharges.java b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/PowerUpCharges.java new file mode 100644 index 000000000..4f01d81a5 --- /dev/null +++ b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/PowerUpCharges.java @@ -0,0 +1,84 @@ +package fr.hugman.mubble.world.power_up; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import fr.hugman.mubble.world.power_up.PowerUpProperties.ChargeCounting; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; + +/** + * How many charges a power-up hands out, and how spent ones come back. + * + * @param counting how spent charges come back + * @param max how many charges the power-up holds at once + * @param interval the tick count the counting runs on, when it needs one + */ +public record PowerUpCharges(ChargeCounting counting, int max, int interval) { + /** + * One charge per entity currently out, with no limit on how many that can be. + */ + public static final PowerUpCharges DEFAULT = fromActiveEntities(Integer.MAX_VALUE); + + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + ChargeCounting.CODEC.fieldOf("counting").forGetter(PowerUpCharges::counting), + Codec.INT.optionalFieldOf("max", Integer.MAX_VALUE).forGetter(PowerUpCharges::max), + Codec.INT.optionalFieldOf("interval", 0).forGetter(PowerUpCharges::interval) + ).apply(instance, PowerUpCharges::new)); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ChargeCounting.STREAM_CODEC, PowerUpCharges::counting, + ByteBufCodecs.INT, PowerUpCharges::max, + ByteBufCodecs.INT, PowerUpCharges::interval, + PowerUpCharges::new + ); + + /** + * Charges that are never counted, so the power-up can always be used. + */ + public static PowerUpCharges none() { + return new PowerUpCharges(ChargeCounting.NONE, Integer.MAX_VALUE, 0); + } + + /** + * One charge per entity the power-up currently has out; a charge comes back once its entity is gone. + */ + public static PowerUpCharges fromActiveEntities(int max) { + return new PowerUpCharges(ChargeCounting.FROM_ACTIVE_ENTITIES, max, 0); + } + + /** + * Charges that never come back, so the power-up runs out for good. + */ + public static PowerUpCharges onlyDecrease(int max) { + return new PowerUpCharges(ChargeCounting.ONLY_DECREASE, max, 0); + } + + /** + * One charge back {@code cooldown} ticks after the last use. + */ + public static PowerUpCharges cooldownRecharge(int max, int cooldown) { + return new PowerUpCharges(ChargeCounting.COOLDOWN_RECHARGE, max, cooldown); + } + + /** + * One charge back every {@code interval} ticks, for as long as charges are missing. + */ + public static PowerUpCharges timedRecharge(int max, int interval) { + return new PowerUpCharges(ChargeCounting.TIMED_RECHARGE, max, interval); + } + + /** + * All {@code max} charges at once, {@code window} ticks after the use that opened the window. + *

+ * The power-up is used in bursts: a first use opens a window of {@code window} ticks in which up to + * {@code max} uses fit, then everything comes back and the next use opens a fresh window. + */ + public static PowerUpCharges burst(int max, int window) { + return new PowerUpCharges(ChargeCounting.BURST_RECHARGE, max, window); + } + + public PowerUpProperties createProperties() { + return new PowerUpProperties(this.counting, this.max, this.interval); + } +} diff --git a/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/PowerUpProperties.java b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/PowerUpProperties.java index 5a8a7b2f5..e39cc141e 100644 --- a/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/PowerUpProperties.java +++ b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/PowerUpProperties.java @@ -23,6 +23,7 @@ public final class PowerUpProperties { public ChargeCounting chargeCounting; public int maxCharges; + public int interval; private int cooldown; private int chargeCount; @@ -31,6 +32,7 @@ public final class PowerUpProperties { public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( ChargeCounting.CODEC.fieldOf("charge_counting").forGetter(p -> p.chargeCounting), Codec.INT.fieldOf("max_charges").forGetter(p -> p.maxCharges), + Codec.INT.optionalFieldOf("interval", 0).forGetter(p -> p.interval), Codec.INT.fieldOf("cooldown").forGetter(p -> p.cooldown), Codec.INT.fieldOf("charge_count").forGetter(p -> p.chargeCount), Codec.list(UUIDUtil.CODEC).fieldOf("charge_entities").forGetter(p -> p.chargeEntities) @@ -39,6 +41,7 @@ public final class PowerUpProperties { public static final StreamCodec STREAM_CODEC = StreamCodec.composite( ChargeCounting.STREAM_CODEC, p -> p.chargeCounting, ByteBufCodecs.INT, p -> p.maxCharges, + ByteBufCodecs.INT, p -> p.interval, ByteBufCodecs.INT, p -> p.cooldown, ByteBufCodecs.INT, p -> p.chargeCount, UUIDUtil.STREAM_CODEC.apply(ByteBufCodecs.list()), p -> p.chargeEntities, @@ -50,32 +53,52 @@ public PowerUpProperties( ChargeCounting chargeCounting, int maxCharges ) { - this(chargeCounting, maxCharges, 0, maxCharges, new ArrayList<>()); + this(chargeCounting, maxCharges, 0, 0, maxCharges, new ArrayList<>()); } public PowerUpProperties( ChargeCounting chargeCounting, int maxCharges, + int interval + ) { + this(chargeCounting, maxCharges, interval, 0, maxCharges, new ArrayList<>()); + } + + public PowerUpProperties( + ChargeCounting chargeCounting, + int maxCharges, + int interval, int cooldown, int chargeCount, List chargeEntities ) { this.chargeCounting = chargeCounting; this.maxCharges = maxCharges; + this.interval = interval; this.cooldown = cooldown; this.chargeCount = chargeCount; this.chargeEntities = new ArrayList<>(chargeEntities); } - public void setCooldown(int cooldown) { - this.cooldown = cooldown; - this.dirty = true; + private void setCooldown(int cooldown) { + if (this.cooldown != cooldown) { + this.cooldown = cooldown; + this.dirty = true; + } } public int getChargeCount() { return this.chargeCount; } + private void setChargeCount(int chargeCount) { + int clamped = Math.clamp(chargeCount, 0, this.maxCharges); + if (this.chargeCount != clamped) { + this.chargeCount = clamped; + this.dirty = true; + } + } + public boolean checkDirty() { if (this.dirty) { this.dirty = false; @@ -88,22 +111,54 @@ public boolean isAtMax() { return this.chargeCount >= this.maxCharges && this.cooldown == 0; } - public void addEntity(UUID uuid) { + /** + * Spends one charge. + */ + public void useCharge() { + // A cooldown recharge pushes the next charge a full interval away on every use, while a burst window + // only opens on the first use: the ones that follow run out the window that is already going. + boolean startsCountdown = this.chargeCounting == ChargeCounting.COOLDOWN_RECHARGE + || (this.chargeCounting == ChargeCounting.BURST_RECHARGE && this.cooldown <= 0); + if (startsCountdown && this.interval > 0) { + this.setCooldown(this.interval); + } + this.setChargeCount(this.chargeCount - 1); + } + + /** + * Ties a charge to the lifetime of an entity. Only has an effect in {@link ChargeCounting#FROM_ACTIVE_ENTITIES}, + * where the charge comes back once the entity is gone. + */ + public void trackEntity(UUID uuid) { + if (this.chargeCounting != ChargeCounting.FROM_ACTIVE_ENTITIES) { + return; + } this.chargeEntities.add(uuid); - this.chargeCount--; this.dirty = true; } public void tick() { if (this.chargeCounting == ChargeCounting.FROM_ACTIVE_ENTITIES) { - this.chargeCount = this.maxCharges - this.chargeEntities.size(); + this.setChargeCount(this.maxCharges - this.chargeEntities.size()); + } + // A timed recharge always keeps a countdown running while charges are missing. + if (this.chargeCounting == ChargeCounting.TIMED_RECHARGE && this.interval > 0 && this.cooldown <= 0 && this.chargeCount < this.maxCharges) { + this.cooldown = this.interval; } if (this.cooldown > 0) { this.cooldown--; - if (this.cooldown == 0 && this.chargeCounting == ChargeCounting.COOLDOWN_RECHARGE) { - this.chargeCount++; + if (this.cooldown == 0) { + // The cooldown hitting zero is observable through isAtMax(), so it has to be synced. + // Only that transition is: syncing every single tick of the countdown would be pure spam. + this.dirty = true; + if (this.chargeCounting == ChargeCounting.COOLDOWN_RECHARGE || this.chargeCounting == ChargeCounting.TIMED_RECHARGE) { + this.setChargeCount(this.chargeCount + 1); + } + // A burst gives every charge back at once, so the next use starts from a full window. + if (this.chargeCounting == ChargeCounting.BURST_RECHARGE) { + this.setChargeCount(this.maxCharges); + } } - this.dirty = true; } } @@ -133,7 +188,9 @@ public enum ChargeCounting implements StringRepresentable { NONE(0, "none"), FROM_ACTIVE_ENTITIES(1, "from_active_entities"), // based on the number of active entities tied to the power-up trigger ONLY_DECREASE(2, "only_decrease"), // never increases once the power-up is triggered - COOLDOWN_RECHARGE(3, "cooldown_recharge"); // charges up once the cooldown is over + COOLDOWN_RECHARGE(3, "cooldown_recharge"), // charges up once the cooldown is over + TIMED_RECHARGE(4, "timed_recharge"), // charges up one at a time at a fixed interval + BURST_RECHARGE(5, "burst_recharge"); // charges up all at once, once the window opened by the first use is over public static final IntFunction BY_ID = ByIdMap.continuous(ChargeCounting::ordinal, values(), ByIdMap.OutOfBoundsStrategy.WRAP); public static final Codec CODEC = StringRepresentable.fromEnum(ChargeCounting::values); diff --git a/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/action/ShootProjectilePowerUpAction.java b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/action/ShootProjectilePowerUpAction.java index 2b90853f1..e97a79e21 100644 --- a/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/action/ShootProjectilePowerUpAction.java +++ b/mubble-core/src/main/java/fr/hugman/mubble/world/power_up/action/ShootProjectilePowerUpAction.java @@ -7,6 +7,7 @@ import java.util.Optional; import java.util.function.Consumer; +import fr.hugman.mubble.world.power_up.PowerUpCharges; import fr.hugman.mubble.world.power_up.PowerUpProperties; import net.minecraft.ChatFormatting; import net.minecraft.core.Holder; @@ -31,30 +32,26 @@ import net.minecraft.world.item.component.TooltipProvider; import net.minecraft.world.phys.Vec3; -//TODO: cooldown is not yet implemented public record ShootProjectilePowerUpAction( EntityType projectile, - Holder sound, + Optional> sound, float speed, - Optional maxProjectiles, - Optional cooldown + PowerUpCharges charges //TODO: add shooting algorithm //TODO: add projectile NBT ) implements PowerUpAction, TooltipProvider { public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( BuiltInRegistries.ENTITY_TYPE.byNameCodec().fieldOf("projectile").forGetter(ShootProjectilePowerUpAction::projectile), - SoundEvent.CODEC.fieldOf("sound").forGetter(ShootProjectilePowerUpAction::sound), + SoundEvent.CODEC.optionalFieldOf("sound").forGetter(ShootProjectilePowerUpAction::sound), Codec.FLOAT.optionalFieldOf("speed", 1.5F).forGetter(ShootProjectilePowerUpAction::speed), - Codec.INT.optionalFieldOf("max_projectiles").forGetter(ShootProjectilePowerUpAction::maxProjectiles), - Codec.INT.optionalFieldOf("cooldown").forGetter(ShootProjectilePowerUpAction::cooldown) + PowerUpCharges.CODEC.optionalFieldOf("charges", PowerUpCharges.DEFAULT).forGetter(ShootProjectilePowerUpAction::charges) ).apply(instance, ShootProjectilePowerUpAction::new)); public static final StreamCodec STREAM_CODEC = StreamCodec.composite( ByteBufCodecs.registry(Registries.ENTITY_TYPE), (ShootProjectilePowerUpAction::projectile), - SoundEvent.STREAM_CODEC, (ShootProjectilePowerUpAction::sound), + ByteBufCodecs.optional(SoundEvent.STREAM_CODEC), (ShootProjectilePowerUpAction::sound), ByteBufCodecs.FLOAT, (ShootProjectilePowerUpAction::speed), - ByteBufCodecs.optional(ByteBufCodecs.INT), (ShootProjectilePowerUpAction::maxProjectiles), - ByteBufCodecs.optional(ByteBufCodecs.INT), (ShootProjectilePowerUpAction::cooldown), + PowerUpCharges.STREAM_CODEC, (ShootProjectilePowerUpAction::charges), ShootProjectilePowerUpAction::new ); @@ -70,7 +67,7 @@ public boolean canBeRefilled() { @Override public PowerUpProperties setUpProperties() { - return new PowerUpProperties(PowerUpProperties.ChargeCounting.FROM_ACTIVE_ENTITIES, maxProjectiles.orElse(Integer.MAX_VALUE)); + return this.charges.createProperties(); } @Override @@ -103,7 +100,7 @@ public InteractionResult trigger(Player player) { return InteractionResult.SUCCESS; } else { - level.playSound(null, player.getX(), player.getY(), player.getZ(), this.sound, SoundSource.NEUTRAL, 0.5F, 1.0F); + this.sound.ifPresent(s -> level.playSound(null, player.getX(), player.getY(), player.getZ(), s, SoundSource.NEUTRAL, 0.5F, 1.0F)); var entity = this.projectile.create(level, EntitySpawnReason.TRIGGERED); if (null == entity) { return InteractionResult.FAIL; @@ -111,11 +108,13 @@ public InteractionResult trigger(Player player) { if (entity instanceof Projectile projectileEntity) { projectileEntity.setOwner(player); } - entity.setPos(player.getX(), player.getEyeY() - 0.1F, player.getZ()); + // setPos places the bottom of the bounding box, so the projectile has to be lowered by half its + // height to actually come out centered on the eye line. + entity.setPos(player.getX(), player.getEyeY() - 0.1F - entity.getBbHeight() / 2.0F, player.getZ()); setVelocity(entity, player, player.getXRot(), player.getYRot(), 0.0F, this.speed, 1.0F); level.addFreshEntity(entity); - properties.addEntity(entity.getUUID()); - properties.setCooldown(cooldown.orElse(0)); + properties.useCharge(); + properties.trackEntity(entity.getUUID()); } return InteractionResult.SUCCESS; } diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/mixin/HumanoidMobRendererMixin.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/mixin/HumanoidMobRendererMixin.java new file mode 100644 index 000000000..76ab2f788 --- /dev/null +++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/mixin/HumanoidMobRendererMixin.java @@ -0,0 +1,23 @@ +package fr.hugman.mubble.super_mario.client.mixin; + +import fr.hugman.mubble.super_mario.world.entity.projectile.Bubble; +import net.minecraft.client.renderer.entity.HumanoidMobRenderer; +import net.minecraft.world.entity.LivingEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(HumanoidMobRenderer.class) +public class HumanoidMobRendererMixin { + /** + * {@link net.minecraft.client.model.HumanoidModel} folds a humanoid's legs into a sitting pose as soon as it + * rides anything. An entity floating inside a bubble should keep standing. + */ + @Redirect( + method = "extractHumanoidRenderState(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/client/renderer/entity/state/HumanoidRenderState;FLnet/minecraft/client/renderer/item/ItemModelResolver;)V", + at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/LivingEntity;isPassenger()Z") + ) + private static boolean super_mario$standUpInsideBubbles(LivingEntity entity) { + return entity.isPassenger() && !(entity.getVehicle() instanceof Bubble); + } +} diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/mixin/LivingEntityRendererMixin.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/mixin/LivingEntityRendererMixin.java index 648cfade9..3f30838ef 100644 --- a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/mixin/LivingEntityRendererMixin.java +++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/mixin/LivingEntityRendererMixin.java @@ -1,19 +1,26 @@ package fr.hugman.mubble.super_mario.client.mixin; +import com.mojang.blaze3d.vertex.PoseStack; +import fr.hugman.mubble.super_mario.client.references.SuperMarioRenderStateDataKeys; import fr.hugman.mubble.super_mario.client.renderer.SuperMarioRenderTypes; import fr.hugman.mubble.super_mario.client.renderer.entity.state.GoombaRenderState; import fr.hugman.mubble.super_mario.references.SuperMarioPowerUpIds; +import fr.hugman.mubble.super_mario.world.entity.projectile.Bubble; import net.minecraft.client.model.EntityModel; import net.minecraft.client.renderer.entity.LivingEntityRenderer; import net.minecraft.client.renderer.entity.state.LivingEntityRenderState; import net.minecraft.client.renderer.rendertype.RenderType; import net.minecraft.resources.Identifier; +import net.minecraft.util.Mth; import net.minecraft.world.entity.LivingEntity; +import org.joml.Quaternionf; +import org.joml.Vector3fc; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.Redirect; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; @Mixin(LivingEntityRenderer.class) @@ -31,6 +38,74 @@ public Identifier getTextureLocation(final S state) { return state.deathTime; } + @Inject(method = "extractRenderState(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/client/renderer/entity/state/LivingEntityRenderState;F)V", at = @At("TAIL")) + private void super_mario$extractBubbleRide(T entity, S state, float partialTicks, CallbackInfo ci) { + // Render states are pooled, so the key has to be cleared for entities that are not in a bubble. + if (!(entity.getVehicle() instanceof Bubble bubble)) { + state.setData(SuperMarioRenderStateDataKeys.BUBBLE_RIDE, null); + return; + } + float ticks = entity.tickCount + partialTicks; + // Every entity gets its own phase, so two things caught at once do not tumble in lockstep. + float seed = entity.getId() * 0.7F; + float absorb = bubble.getAbsorbProgress(partialTicks); + float settle = bubble.getSettleProgress(partialTicks); + float spin = 1.0F - settle; + // Being swallowed spins it up on top of the idle tumble. Ramped from zero so nothing snaps. + float whirl = absorb * absorb * 7.0F; + + // The bubble's heading when it caught this leans the tumble: something scooped up at speed rolls end + // over end along the way the bubble was going, and rolls faster the harder it was hit. + Vector3fc caught = bubble.getCaptureMotion(); + float heading = Mth.sqrt(caught.x() * caught.x() + caught.z() * caught.z()); + float rate = 0.06F + heading * 0.35F; + // The roll is rounded to a whole number of turns. Landing on one at the end means the final rotation, + // by then around the upright axis, is the identity: the captive is drawn exactly where it will be + // standing once the bubble lets go, so nothing jumps at the moment it pops. + int turns = Math.max(1, Math.round(rate * bubble.getFilledLifetime() * 0.5F / Mth.TWO_PI)); + // Eases in with a rate falling linearly to nothing, so it keeps advancing the whole way instead of + // turning at full speed and then stopping dead. It never runs backwards. + float rolled = turns * Mth.TWO_PI * (2.0F * settle - settle * settle) + whirl; + + // The axis the roll happens around leans from horizontal to vertical as the captive settles, so the + // tumble turns into a plain spin on the spot and leaves it standing upright. Unwinding the roll + // instead would have to pick a direction to unwind in, and that choice flips once the roll passes + // half a turn, which reads as a snap. rotateAxis normalises, so the axis only has to point right. + float leanX = 0.0F; + float leanZ = 0.0F; + if (heading > 1.0E-4F) { + leanX = -caught.z() / heading * spin; + leanZ = caught.x() / heading * spin; + } else { + leanX = spin; + } + + // No yaw of its own: any leftover turn would have to be unwound at the end, and the whole point is + // that the last frame of the tumble already matches the orientation the entity keeps after the pop. + Quaternionf rotation = new Quaternionf().rotateAxis(rolled, leanX, settle, leanZ); + // Drifts at rates that share no common period, so the tumble never settles into a visible loop. They + // fade out as it comes to rest. + rotation.rotateX(Mth.sin(ticks * 0.023F + seed * 1.7F) * 0.9F * spin); + rotation.rotateZ(Mth.cos(ticks * 0.019F + seed * 0.6F) * 0.6F * spin); + + state.setData(SuperMarioRenderStateDataKeys.BUBBLE_RIDE, + new SuperMarioRenderStateDataKeys.BubbleRide(rotation, 1.0F - absorb)); + } + + @Inject(method = "setupRotations", at = @At("TAIL")) + private void super_mario$bubbleRideRotations(S state, PoseStack poseStack, float bodyRot, float scale, CallbackInfo ci) { + var ride = state.getData(SuperMarioRenderStateDataKeys.BUBBLE_RIDE); + if (ride == null) { + return; + } + // Tumble and shrink around the middle of the entity rather than around its feet. + float centerY = state.boundingBoxHeight / 2.0F; + poseStack.translate(0.0F, centerY, 0.0F); + poseStack.mulPose(ride.rotation()); + poseStack.scale(ride.scale(), ride.scale(), ride.scale()); + poseStack.translate(0.0F, -centerY, 0.0F); + } + @Inject(method = "getRenderType", at = @At("HEAD"), cancellable = true) private void super_mario$getRenderType(S state, boolean isBodyVisible, boolean forceTransparent, boolean appearGlowing, CallbackInfoReturnable cir) { var powerUp = state.getData(fr.hugman.mubble.client.references.MubbleRenderStateDataKeys.POWER_UP); 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 37b907f87..b8ff66601 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 @@ -1,6 +1,5 @@ package fr.hugman.mubble.super_mario.client.model; -import fr.hugman.mubble.client.model.BallModel; import fr.hugman.mubble.super_mario.SuperMario; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/references/SuperMarioRenderStateDataKeys.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/references/SuperMarioRenderStateDataKeys.java new file mode 100644 index 000000000..a052f0975 --- /dev/null +++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/references/SuperMarioRenderStateDataKeys.java @@ -0,0 +1,22 @@ +package fr.hugman.mubble.super_mario.client.references; + +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.fabricmc.fabric.api.client.rendering.v1.RenderStateDataKey; +import org.joml.Quaternionfc; + +@Environment(EnvType.CLIENT) +public class SuperMarioRenderStateDataKeys { + /** Set on entities held inside a {@link fr.hugman.mubble.super_mario.world.entity.projectile.Bubble}. */ + public static final RenderStateDataKey BUBBLE_RIDE = RenderStateDataKey.create(() -> "Bubble ride"); + + /** + * How an entity held inside a bubble is drawn: tumbling around, and shrinking away while the bubble + * swallows it. + * + * @param rotation the tumble to apply around the entity's middle + * @param scale 1 while merely trapped, going down to 0 over the course of a capture + */ + public record BubbleRide(Quaternionfc rotation, float scale) { + } +} 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 a234ced21..07788e203 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 @@ -2,6 +2,7 @@ import fr.hugman.mubble.super_mario.client.renderer.blockentity.BumpableBlockRenderer; 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.GoombaRenderer; import fr.hugman.mubble.super_mario.client.renderer.entity.KoopaShellRenderer; @@ -20,6 +21,7 @@ public static void registerEntities() { EntityRenderers.register(SuperMarioEntityTypes.ICEBALL, BallRenderer::new); EntityRenderers.register(SuperMarioEntityTypes.GOLD_FIREBALL, BallRenderer::new); EntityRenderers.register(SuperMarioEntityTypes.CLOUD_PLATFORM, CloudPlatformRenderer::new); + EntityRenderers.register(SuperMarioEntityTypes.BUBBLE, BubbleRenderer::new); } public static void registerBlockEntities() { diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/BubbleRenderer.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/BubbleRenderer.java new file mode 100644 index 000000000..c622c41a0 --- /dev/null +++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/BubbleRenderer.java @@ -0,0 +1,93 @@ +package fr.hugman.mubble.super_mario.client.renderer.entity; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import fr.hugman.mubble.super_mario.client.renderer.entity.state.BubbleRenderState; +import fr.hugman.mubble.super_mario.world.entity.projectile.Bubble; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +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.core.Direction; +import net.minecraft.util.LightCoordsUtil; + +@Environment(EnvType.CLIENT) +public class BubbleRenderer extends EntityRenderer { + /** How much the bubble flattens along the axis it hit, at the peak of the squish. */ + private static final float SQUISH_FLATTEN = 0.35F; + /** How much it bulges along the other axis, to keep it looking like it conserves its volume. */ + private static final float SQUISH_BULGE = 0.25F; + + public BubbleRenderer(EntityRendererProvider.Context ctx) { + super(ctx); + } + + @Override + public BubbleRenderState createRenderState() { + return new BubbleRenderState(); + } + + @Override + public void extractRenderState(Bubble bubble, BubbleRenderState state, float partialTicks) { + super.extractRenderState(bubble, state, partialTicks); + state.texture = bubble.getTexture(); + state.size = bubble.getRenderSize(partialTicks); + state.lightCoords = LightCoordsUtil.FULL_BRIGHT; + state.squish = bubble.getSquish(partialTicks); + state.squishAxis = bubble.getSquishAxis(); + state.captureWobble = bubble.getCaptureWobble(partialTicks); + } + + @Override + public void submit(BubbleRenderState state, PoseStack poseStack, SubmitNodeCollector submitNodeCollector, CameraRenderState cameraRenderState) { + // The entity origin sits at the bottom of the cube, the sprite has to be centered on it. + float centerY = state.size / 2.0F; + + // Whatever the bubble holds is a passenger, so the entity renderer draws it on its own. + poseStack.pushPose(); + poseStack.translate(0.0F, centerY, 0.0F); + poseStack.mulPose(cameraRenderState.orientation); + + // Squishing happens in camera space: the quad is a billboard, so this reads as a screen-space squash. + float flatten = 1.0F - SQUISH_FLATTEN * state.squish; + float bulge = 1.0F + SQUISH_BULGE * state.squish; + boolean vertical = state.squishAxis == Direction.Axis.Y; + // Closing around something squashes the bubble on the spot, on top of any block rebound. It keeps + // roughly the same volume, so the sides push out while the top comes down and the other way round. + float wobbleY = 1.0F + state.captureWobble; + float wobbleX = 1.0F - state.captureWobble * 0.5F; + poseStack.scale( + state.size * (vertical ? bulge : flatten) * wobbleX, + state.size * (vertical ? flatten : bulge) * wobbleY, + state.size * wobbleX + ); + + int light = state.lightCoords; + submitNodeCollector.submitCustomGeometry(poseStack, RenderTypes.entityTranslucent(state.texture.texturePath()), (pose, consumer) -> { + vertex(consumer, pose, -0.5f, -0.5f, 0.0f, 1.0f, light); + vertex(consumer, pose, 0.5f, -0.5f, 1.0f, 1.0f, light); + vertex(consumer, pose, 0.5f, 0.5f, 1.0f, 0.0f, light); + vertex(consumer, pose, -0.5f, 0.5f, 0.0f, 0.0f, light); + }); + poseStack.popPose(); + + super.submit(state, poseStack, submitNodeCollector, cameraRenderState); + } + + private static void vertex(VertexConsumer consumer, PoseStack.Pose pose, float x, float y, float u, float v, int light) { + consumer.addVertex(pose, x, y, 0.0f) + .setColor(255, 255, 255, 200) + .setUv(u, v) + .setOverlay(OverlayTexture.NO_OVERLAY) + .setLight(light) + // Written straight through instead of through the pose: the pose carries the billboard's + // camera orientation, so a transformed normal swings around with the camera and the entity + // shader dims the sprite accordingly -- most visibly when looking straight down. A fixed + // upright normal keeps the shading even from every angle. + .setNormal(0.0f, 1.0f, 0.0f); + } +} diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/state/BubbleRenderState.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/state/BubbleRenderState.java new file mode 100644 index 000000000..4092cca7f --- /dev/null +++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/state/BubbleRenderState.java @@ -0,0 +1,19 @@ +package fr.hugman.mubble.super_mario.client.renderer.entity.state; + +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.core.ClientAsset; +import net.minecraft.core.Direction; + +@Environment(EnvType.CLIENT) +public class BubbleRenderState extends EntityRenderState { + public ClientAsset.ResourceTexture texture; + /** Side of the bubble's cube, taken from the entity rather than from its cached bounding box. */ + public float size; + /** How flattened the bubble is against a block, from 0 (round) to 1 (fully squished). */ + public float squish; + public Direction.Axis squishAxis = Direction.Axis.Y; + /** Squash and stretch from having just closed around something: negative flattens, positive stretches. */ + public float captureWobble; +} diff --git a/mubble-super_mario/src/client/resources/assets/super_mario/sounds/entity/bubble/appear.ogg b/mubble-super_mario/src/client/resources/assets/super_mario/sounds/entity/bubble/appear.ogg new file mode 100644 index 000000000..584fb6132 Binary files /dev/null and b/mubble-super_mario/src/client/resources/assets/super_mario/sounds/entity/bubble/appear.ogg differ diff --git a/mubble-super_mario/src/client/resources/assets/super_mario/sounds/entity/bubble/fill.ogg b/mubble-super_mario/src/client/resources/assets/super_mario/sounds/entity/bubble/fill.ogg new file mode 100644 index 000000000..20055ecbe Binary files /dev/null and b/mubble-super_mario/src/client/resources/assets/super_mario/sounds/entity/bubble/fill.ogg differ diff --git a/mubble-super_mario/src/client/resources/assets/super_mario/sounds/entity/bubble/pop.ogg b/mubble-super_mario/src/client/resources/assets/super_mario/sounds/entity/bubble/pop.ogg new file mode 100644 index 000000000..8686a2d58 Binary files /dev/null and b/mubble-super_mario/src/client/resources/assets/super_mario/sounds/entity/bubble/pop.ogg differ diff --git a/mubble-super_mario/src/client/resources/assets/super_mario/textures/entity/bubble1.png b/mubble-super_mario/src/client/resources/assets/super_mario/textures/entity/bubble1.png new file mode 100644 index 000000000..9962943da Binary files /dev/null and b/mubble-super_mario/src/client/resources/assets/super_mario/textures/entity/bubble1.png differ diff --git a/mubble-super_mario/src/client/resources/assets/super_mario/textures/entity/bubble2.png b/mubble-super_mario/src/client/resources/assets/super_mario/textures/entity/bubble2.png new file mode 100644 index 000000000..efabc717a Binary files /dev/null and b/mubble-super_mario/src/client/resources/assets/super_mario/textures/entity/bubble2.png differ diff --git a/mubble-super_mario/src/client/resources/assets/super_mario/textures/entity/bubble3.png b/mubble-super_mario/src/client/resources/assets/super_mario/textures/entity/bubble3.png new file mode 100644 index 000000000..3d27e8467 Binary files /dev/null and b/mubble-super_mario/src/client/resources/assets/super_mario/textures/entity/bubble3.png differ diff --git a/mubble-super_mario/src/client/resources/assets/super_mario/textures/entity/bubble4.png b/mubble-super_mario/src/client/resources/assets/super_mario/textures/entity/bubble4.png new file mode 100644 index 000000000..0cce6202e Binary files /dev/null and b/mubble-super_mario/src/client/resources/assets/super_mario/textures/entity/bubble4.png differ diff --git a/mubble-super_mario/src/client/resources/super_mario.client.mixins.json b/mubble-super_mario/src/client/resources/super_mario.client.mixins.json index b7d1e7189..82adf2de5 100644 --- a/mubble-super_mario/src/client/resources/super_mario.client.mixins.json +++ b/mubble-super_mario/src/client/resources/super_mario.client.mixins.json @@ -4,6 +4,7 @@ "compatibilityLevel": "JAVA_21", "client": [ "ClientPacketListenerMixin", + "HumanoidMobRendererMixin", "LivingEntityRendererMixin", "AvatarRendererMixin" ], 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 7632e6272..3b60646f0 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 @@ -18,7 +18,8 @@ public record Entry(ResourceKey item, ResourceKey powerUp) { } new Entry(SuperMarioItemIds.FIRE_FLOWER, SuperMarioPowerUpIds.FIRE), new Entry(SuperMarioItemIds.ICE_FLOWER, SuperMarioPowerUpIds.ICE), new Entry(SuperMarioItemIds.GOLD_FLOWER, SuperMarioPowerUpIds.GOLD), - new Entry(SuperMarioItemIds.CLOUD_FLOWER, SuperMarioPowerUpIds.CLOUD) + new Entry(SuperMarioItemIds.CLOUD_FLOWER, SuperMarioPowerUpIds.CLOUD), + new Entry(SuperMarioItemIds.BUBBLE_FLOWER, SuperMarioPowerUpIds.BUBBLE) ); public static ResourceKey getItem(ResourceKey powerUp) { diff --git a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/SuperMarioDataGenerator.java b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/SuperMarioDataGenerator.java index f1f9648ec..be9314550 100644 --- a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/SuperMarioDataGenerator.java +++ b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/SuperMarioDataGenerator.java @@ -27,6 +27,7 @@ public void onInitializeDataGenerator(FabricDataGenerator fabricDataGenerator) { // - Loot tables pack.addProvider(SuperMarioBlockLootSubProvider::new); pack.addProvider(SuperMarioLootSubProvider::new); + pack.addProvider(SuperMarioGameplayLootSubProvider::new); // - Variants pack.addProvider(SuperMarioGoombaVariantProvider::new); 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 5f1eccf99..9a0be927c 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 @@ -56,6 +56,9 @@ public void generateTranslations(HolderLookup.Provider wrapperLookup, Translatio builder.add("subtitles." + SuperMario.MOD_ID + ".entity.iceball.hit", "Iceball hits"); builder.add("subtitles." + SuperMario.MOD_ID + ".entity.iceball.throw", "Iceball thrown"); builder.add(SuperMarioSounds.GOLD_FIREBALL_THROW.value(), "Gold Fireball thrown"); + builder.add(SuperMarioSounds.BUBBLE_APPEAR.value(), "Bubble appears"); + builder.add(SuperMarioSounds.BUBBLE_POP.value(), "Bubble pops"); + builder.add(SuperMarioSounds.BUBBLE_FILL.value(), "Bubble fills"); builder.add("subtitles." + SuperMario.MOD_ID + ".power_up.obtain", "Power-up obtained"); builder.add("subtitles." + SuperMario.MOD_ID + ".power_up.loose", "Power-up lost"); builder.add("subtitles." + SuperMario.MOD_ID + ".power_up.refill", "Power-up refilled"); 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 c35bbcb13..4d1d4135f 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 @@ -7,6 +7,8 @@ import java.util.concurrent.CompletableFuture; +import fr.hugman.mubble.super_mario.references.SuperMarioEntityTypeIds; + import static fr.hugman.mubble.super_mario.tags.SuperMarioEntityTypeTags.*; import static fr.hugman.mubble.super_mario.references.SuperMarioEntityTypeIds.*; import static net.minecraft.world.entity.EntityTypeIds.*; @@ -23,6 +25,13 @@ protected void addTags(HolderLookup.Provider wrapperLookup) { builder(CAN_STOMP).add(PLAYER); 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); + + // 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. + builder(BUBBLE_CANNOT_TRAP).add(PLAYER, ENDER_DRAGON, WITHER, WARDEN, ELDER_GUARDIAN, RAVAGER, IRON_GOLEM); + // Vanilla builder(EntityTypeTags.DISMOUNTS_UNDERWATER).add(GOOMBA); builder(EntityTypeTags.NOT_SCARY_FOR_PUFFERFISH).add(GOOMBA); diff --git a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioGameplayLootSubProvider.java b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioGameplayLootSubProvider.java new file mode 100644 index 000000000..1ce3c2cb4 --- /dev/null +++ b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioGameplayLootSubProvider.java @@ -0,0 +1,34 @@ +package fr.hugman.mubble.super_mario.data.provider; + +import fr.hugman.mubble.super_mario.world.item.SuperMarioItems; +import fr.hugman.mubble.super_mario.world.level.storage.loot.SuperMarioBuiltInLootTables; +import net.fabricmc.fabric.api.datagen.v1.FabricPackOutput; +import net.fabricmc.fabric.api.datagen.v1.provider.SimpleFabricLootTableSubProvider; +import net.minecraft.core.HolderLookup; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.storage.loot.LootPool; +import net.minecraft.world.level.storage.loot.LootTable; +import net.minecraft.world.level.storage.loot.entries.LootItem; +import net.minecraft.world.level.storage.loot.parameters.LootContextParamSets; + +import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; + +/** + * Loot tables rolled from a gameplay event rather than from a kill or a broken block: the only context they get + * is the entity involved and where it happened. + */ +public class SuperMarioGameplayLootSubProvider extends SimpleFabricLootTableSubProvider { + public SuperMarioGameplayLootSubProvider(FabricPackOutput output, CompletableFuture registryLookupFuture) { + super(output, registryLookupFuture, LootContextParamSets.GIFT); + } + + @Override + public void generate(BiConsumer, LootTable.Builder> output) { + // Data packs can branch on the swallowed entity from here; a plain coin is the default for everything. + output.accept(SuperMarioBuiltInLootTables.BUBBLE_CAPTURE, LootTable.lootTable() + .withPool(LootPool.lootPool() + .add(LootItem.lootTableItem(SuperMarioItems.COIN)) + )); + } +} diff --git a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioItemTagProvider.java b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioItemTagProvider.java index b851b48a7..75abef69c 100644 --- a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioItemTagProvider.java +++ b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioItemTagProvider.java @@ -22,7 +22,7 @@ public SuperMarioItemTagProvider(FabricPackOutput output, CompletableFuture context) { .emissiveOverlay() .action(Holder.direct(new ShootProjectilePowerUpAction( SuperMarioEntityTypes.FIREBALL, - SuperMarioSounds.FIREBALL_THROW, + Optional.of(SuperMarioSounds.FIREBALL_THROW), 0.4f, - Optional.of(3), - Optional.empty() + PowerUpCharges.fromActiveEntities(3) ))) .build()); context.register(ICE, builder(ICE, true) .emissiveOverlay() .action(Holder.direct(new ShootProjectilePowerUpAction( SuperMarioEntityTypes.ICEBALL, - SuperMarioSounds.ICEBALL_THROW, + Optional.of(SuperMarioSounds.ICEBALL_THROW), 0.4f, - Optional.of(3), - Optional.empty() + PowerUpCharges.fromActiveEntities(3) ))) .build()); context.register(GOLD, builder(GOLD, true) @@ -96,10 +95,9 @@ public static void bootstrap(BootstrapContext context) { .emitSound(SuperMarioSounds.POWER_UP_EMIT_GOLD) .action(Holder.direct(new ShootProjectilePowerUpAction( SuperMarioEntityTypes.GOLD_FIREBALL, - SuperMarioSounds.GOLD_FIREBALL_THROW, + Optional.of(SuperMarioSounds.GOLD_FIREBALL_THROW), 0.4f, - Optional.of(3), - Optional.empty() + PowerUpCharges.fromActiveEntities(3) ))) .particle(SuperMarioParticleTypes.COIN_SPARKLE) .build()); @@ -108,6 +106,14 @@ public static void bootstrap(BootstrapContext context) { .attributesModifier(Attributes.GRAVITY, -0.5, ADD_MULTIPLIED_BASE) .attributesModifier(Attributes.JUMP_STRENGTH, 0.35, ADD_MULTIPLIED_BASE) .build()); + context.register(BUBBLE, builder(BUBBLE) + .action(Holder.direct(new ShootProjectilePowerUpAction( + SuperMarioEntityTypes.BUBBLE, + Optional.empty(), // the bubble plays its own "appear" sound as it spawns + 0.4f, + PowerUpCharges.burst(2, 24) + ))) + .build()); } public static PowerUpBuilder builder(ResourceKey key, boolean withOverlay) { diff --git a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioSoundsProvider.java b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioSoundsProvider.java index 2e8f6c72f..bc394d4b1 100644 --- a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioSoundsProvider.java +++ b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioSoundsProvider.java @@ -78,6 +78,11 @@ protected void configure(HolderLookup.Provider wrapperLookup, SoundExporter soun soundExporter.add(SuperMarioSounds.POWER_UP_SPIN_ATTACK, variantSoundBuilder(SuperMarioSounds.POWER_UP_SPIN_ATTACK, 1).subtitle(null)); soundExporter.add(SuperMarioSounds.POWER_UP_LOOSE, variantSoundBuilder(SuperMarioSounds.POWER_UP_LOOSE, 1)); soundExporter.add(SuperMarioSounds.POWER_UP_REFILL, variantSoundBuilder(SuperMarioSounds.POWER_UP_REFILL, 1)); + + // Bubble + soundExporter.add(SuperMarioSounds.BUBBLE_APPEAR, variantSoundBuilder(SuperMarioSounds.BUBBLE_APPEAR, 1)); + soundExporter.add(SuperMarioSounds.BUBBLE_POP, variantSoundBuilder(SuperMarioSounds.BUBBLE_POP, 1)); + soundExporter.add(SuperMarioSounds.BUBBLE_FILL, variantSoundBuilder(SuperMarioSounds.BUBBLE_FILL, 1)); } private SoundTypeBuilder variantSoundBuilder(Holder soundEvent, int count) { diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/mixin/EntityMixin.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/mixin/EntityMixin.java index a1cd54bdf..8f456302e 100644 --- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/mixin/EntityMixin.java +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/mixin/EntityMixin.java @@ -4,6 +4,7 @@ import fr.hugman.mubble.super_mario.references.SuperMarioDamageTypeIds; import fr.hugman.mubble.super_mario.tags.SuperMarioEntityTypeTags; import fr.hugman.mubble.super_mario.tags.SuperMarioPowerUpTags; +import fr.hugman.mubble.super_mario.world.entity.FallGraced; import fr.hugman.mubble.super_mario.world.entity.Stompable; import fr.hugman.mubble.super_mario.world.level.block.HittableBlock; import fr.hugman.mubble.world.power_up.PowerUpHolder; @@ -24,6 +25,7 @@ import net.minecraft.world.phys.HitResult; import net.minecraft.world.phys.Vec3; import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; @@ -32,7 +34,12 @@ import java.util.function.Predicate; @Mixin(Entity.class) -public class EntityMixin implements Stompable { +public class EntityMixin implements Stompable, FallGraced { + @Unique + private double super_mario$fallGrace; + @Unique + private boolean super_mario$fallGraceArmed; + /** * Bumps whatever the entity hits with the top of its hitbox, right before it is moved there. *

@@ -65,6 +72,7 @@ public class EntityMixin implements Stompable { @Inject(method="tick", at=@At("HEAD")) private void mubble$tick(CallbackInfo ci) { Entity this_ = (Entity) (Object) this; + this.super_mario$tickFallGrace(this_); if (this.canBeStomped()) { AABB hitBox = this.getStompBox(); if (hitBox != null) { @@ -76,6 +84,42 @@ public class EntityMixin implements Stompable { } } + @Override + public void grantFallGrace(double blocks) { + this.super_mario$fallGrace = Math.max(this.super_mario$fallGrace, blocks); + this.super_mario$fallGraceArmed = false; + } + + /** + * Spends granted fall grace on the way down. + *

+ * It waits for the launch to show up in the entity's movement first: a player's fall distance is reset by + * the server on every movement packet that gains height, so a discount written at the moment of the bounce + * is wiped before the fall even starts. Once the entity is heading down again nothing resets it any more, + * which is where the grace can safely be taken off. + */ + @Unique + private void super_mario$tickFallGrace(Entity entity) { + if (this.super_mario$fallGrace <= 0.0) { + return; + } + if (entity.onGround()) { + // Back on the ground without ever falling: the grace does not carry over to a later fall. + this.super_mario$fallGrace = 0.0; + return; + } + // getKnownMovement() rather than the delta, which is stale for players. + double climb = entity.getKnownMovement().y(); + if (!this.super_mario$fallGraceArmed) { + this.super_mario$fallGraceArmed = climb > 0.0; + return; + } + if (climb < 0.0 && entity.fallDistance > 0.0) { + entity.fallDistance -= this.super_mario$fallGrace; + this.super_mario$fallGrace = 0.0; + } + } + @Override public boolean canBeStomped() { var this_ = ((Entity) (Object) this); @@ -119,7 +163,10 @@ public void onStompedBy(Entity entity) { else { // TODO: play sound } - entity.setDeltaMovement(entity.getDeltaMovement().x, 0.5D, entity.getDeltaMovement().z); + // A player's real momentum only lives on their client; the server-side delta is stale, and sending it + // back would kill the horizontal speed they came in with. getKnownMovement() is what the client reported. + Vec3 momentum = entity.getKnownMovement(); + entity.setDeltaMovement(momentum.x(), 0.5D, momentum.z()); if (entity instanceof Player player) { ((ServerPlayer) player).connection.send(new ClientboundSetEntityMotionPacket(player)); } 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 fcca33461..19e151f46 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 @@ -12,6 +12,7 @@ public class SuperMarioEntityTypeIds { public static final ResourceKey> ICEBALL = createKey("iceball"); 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"); 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 76abcdf6b..7517aad91 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 @@ -21,6 +21,8 @@ public class SuperMarioItemIds { public static final ResourceKey GOLD_FLOWER = createKey("gold_flower"); public static final ResourceKey CLOUD_FLOWER = createKey("cloud_flower"); + public static final ResourceKey BUBBLE_FLOWER = createKey("bubble_flower"); + public static final ResourceKey CAPE_FEATHER = createKey("cape_feather"); public static final ResourceKey SUPER_CAPE_FEATHER = createKey("super_cape_feather"); public static final ResourceKey GOOMBA_SPAWN_EGG = createKey("goomba_spawn_egg"); 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 3add5ec60..9e3530199 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 @@ -12,6 +12,7 @@ public class SuperMarioPowerUpIds { public static final ResourceKey ICE = createKey("ice"); public static final ResourceKey GOLD = createKey("gold"); public static final ResourceKey CLOUD = createKey("cloud"); + public static final ResourceKey BUBBLE = createKey("bubble"); 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/sounds/SuperMarioSounds.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/sounds/SuperMarioSounds.java index 60340a173..0d3e0cf00 100644 --- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/sounds/SuperMarioSounds.java +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/sounds/SuperMarioSounds.java @@ -58,6 +58,10 @@ public class SuperMarioSounds { public static final Holder.Reference POWER_UP_REFILL = registerForHolder("power_up.refill"); public static final Holder.Reference POWER_UP_LOOSE = registerForHolder("power_up.loose"); + public static final Holder.Reference BUBBLE_APPEAR = registerForHolder("entity.bubble.appear"); + public static final Holder.Reference BUBBLE_POP = registerForHolder("entity.bubble.pop"); + public static final Holder.Reference BUBBLE_FILL = registerForHolder("entity.bubble.fill"); + private static SoundEvent register(String path) { Identifier id = SuperMario.id(path); return Registry.register(BuiltInRegistries.SOUND_EVENT, id, SoundEvent.createVariableRangeEvent(id)); 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 25a99edae..c411154fa 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 @@ -11,6 +11,10 @@ public class SuperMarioEntityTypeTags { public static final TagKey> CAN_STOMP = bind("can_stomp"); public static final TagKey> STOMPABLE = bind("stompable"); + public static final TagKey> ALL = bind("all"); + public static final TagKey> BUBBLE_CAN_TRAP = bind("bubble_can_trap"); + public static final TagKey> BUBBLE_CANNOT_TRAP = bind("bubble_cannot_trap"); + private static TagKey> bind(String path) { return TagKey.create(Registries.ENTITY_TYPE, SuperMario.id(path)); } diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/tags/SuperMarioItemTags.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/tags/SuperMarioItemTags.java index 89b57070d..56cac9125 100644 --- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/tags/SuperMarioItemTags.java +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/tags/SuperMarioItemTags.java @@ -10,6 +10,8 @@ public class SuperMarioItemTags { public static final TagKey COINS = bind("coins"); public static final TagKey KOOPA_SHELLS = bind("koopa_shells"); + public static final TagKey BUBBLE_CATCH_AS_COLLECTIBLE = bind("bubble/catch_as_collectible"); + public static final TagKey BRICK_BLOCKS = bind("brick_blocks"); public static final TagKey EXCLAMATION_BLOCKS = bind("exclamation_blocks"); public static final TagKey MARIMBA_BLOCKS = bind("marimba_blocks"); diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/FallGraced.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/FallGraced.java new file mode 100644 index 000000000..36e148d85 --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/FallGraced.java @@ -0,0 +1,21 @@ +package fr.hugman.mubble.super_mario.world.entity; + +/** + * An entity that can be handed a few blocks of free fall, spent on its next descent. + * + * @author Hugman + * @since v4.0.0 + */ +public interface FallGraced { + /** + * Grants blocks of fall that will not count towards fall damage on the way down. + *

+ * The grace is held until the entity is actually falling again rather than taken off its fall distance + * straight away: the server wipes a player's fall distance on every movement packet that gains height, so + * anything written at the moment of a launch is gone by the next tick. + * + * @param blocks blocks of fall to forgive + */ + default void grantFallGrace(double blocks) { + } +} 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 b45796dd0..8d9f11e16 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 @@ -20,6 +20,7 @@ public final class SuperMarioEntityTypes { public static final EntityType ICEBALL = register(SuperMarioEntityTypeIds.ICEBALL, EntityType.Builder.of(Iceball::new, MobCategory.MISC).sized(0.4F, 0.4F).clientTrackingRange(4).updateInterval(10)); 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)); 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/item/SuperMarioCollectibles.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/item/SuperMarioCollectibles.java new file mode 100644 index 000000000..218430a1a --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/item/SuperMarioCollectibles.java @@ -0,0 +1,46 @@ +package fr.hugman.mubble.super_mario.world.entity.item; + +import fr.hugman.mubble.sounds.SoundConfig; +import fr.hugman.mubble.super_mario.core.particles.SuperMarioParticleTypes; +import fr.hugman.mubble.super_mario.sounds.SuperMarioSounds; +import fr.hugman.mubble.super_mario.world.item.SuperMarioItems; +import fr.hugman.mubble.world.entity.item.collectible.CollectibleEntity; +import net.minecraft.core.particles.ParticleOptions; +import net.minecraft.world.item.ItemStack; +import org.jspecify.annotations.Nullable; + +/** + * Cosmetics shared by every way a Super Mario collectible can come to life (placed by hand, bumped out of a + * block, left behind by a popping bubble...). + */ +public final class SuperMarioCollectibles { + private SuperMarioCollectibles() { + } + + public static void configure(CollectibleEntity entity, ItemStack stack) { + entity.setCollectSound(collectSound()); + entity.setBounceSound(new SoundConfig(SuperMarioSounds.COIN_BOUNCE, 1.0f, 1.0f)); + entity.setCollectParticle(sparkle(stack)); + } + + public static SoundConfig collectSound() { + return new SoundConfig(SuperMarioSounds.COIN_COLLECT, 0.2f, 1.0f); + } + + @Nullable + public static ParticleOptions sparkle(ItemStack stack) { + if (stack.is(SuperMarioItems.COIN)) { + return SuperMarioParticleTypes.COIN_SPARKLE; + } + if (stack.is(SuperMarioItems.RED_COIN)) { + return SuperMarioParticleTypes.RED_COIN_SPARKLE; + } + if (stack.is(SuperMarioItems.BLUE_COIN)) { + return SuperMarioParticleTypes.BLUE_COIN_SPARKLE; + } + if (stack.is(SuperMarioItems.FLOWER_COIN)) { + return SuperMarioParticleTypes.FLOWER_COIN_SPARKLE; + } + return null; + } +} diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Bubble.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Bubble.java new file mode 100644 index 000000000..9dea16653 --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Bubble.java @@ -0,0 +1,980 @@ +package fr.hugman.mubble.super_mario.world.entity.projectile; + +import fr.hugman.mubble.super_mario.SuperMario; +import fr.hugman.mubble.super_mario.sounds.SuperMarioSounds; +import fr.hugman.mubble.super_mario.tags.SuperMarioEntityTypeTags; +import fr.hugman.mubble.super_mario.tags.SuperMarioItemTags; +import fr.hugman.mubble.super_mario.world.entity.SuperMarioEntityTypes; +import fr.hugman.mubble.super_mario.world.entity.FallGraced; +import fr.hugman.mubble.super_mario.world.entity.Stompable; +import fr.hugman.mubble.super_mario.world.entity.item.SuperMarioCollectibles; +import fr.hugman.mubble.super_mario.world.level.storage.loot.SuperMarioBuiltInLootTables; +import fr.hugman.mubble.world.entity.item.collectible.CollectibleEntity; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.core.ClientAsset; +import net.minecraft.core.Direction; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.network.protocol.game.ClientboundSetEntityMotionPacket; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.util.Mth; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityDimensions; +import net.minecraft.world.entity.EntityEvent; +import net.minecraft.world.entity.EntitySelector; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.Pose; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.entity.projectile.Projectile; +import net.minecraft.world.item.ItemStack; +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.level.storage.loot.LootParams; +import net.minecraft.world.level.storage.loot.parameters.LootContextParamSets; +import net.minecraft.world.level.storage.loot.parameters.LootContextParams; +import net.minecraft.world.phys.AABB; +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.joml.Vector3f; +import org.joml.Vector3fc; +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.IntStream; + +/** + * A bubble shot by the Bubble Flower power-up. + *

+ * It travels forward, drifts upwards as it slows down, rebounds off blocks and can hold a single entity or item + * inside of it. Whatever is held is a regular passenger, so vanilla takes care of syncing and saving it. + * + * @author Hugman + * @since v4.0.0 + */ +public class Bubble extends Projectile implements Stompable { + /** + * The looks a bubble can take. Picked at random on spawn and kept for the bubble's whole life, so that a + * volley does not look stamped out of the same mould. Deliberately a plain index rather than a registry of + * variants: nothing else varies with it, and it stays settable from a command. + */ + private static final List TEXTURES = IntStream.rangeClosed(1, 4) + .mapToObj(i -> new ClientAsset.ResourceTexture(SuperMario.id("entity/bubble" + i))) + .toList(); + public static final int TEXTURE_COUNT = TEXTURES.size(); + + /** Ticks before an empty bubble pops on its own. */ + public static final int DEFAULT_LIFETIME = 100; + public static final int DEFAULT_FILLED_LIFETIME = 100; + /** Grace period during which the bubble ignores its owner, so it does not pop right where it spawned. */ + public static final int OWNER_POP_DELAY = 20; + /** + * Grace period during which the owner cannot stomp their own bubble. Without it, shooting one while airborne + * hands out a free bounce straight away, which is enough to fly forever. + */ + public static final int OWNER_STOMP_DELAY = 10; + /** Ticks the capture animation lasts before the caught entity turns into its loot. */ + public static final int ABSORB_DURATION = 12; + public static final int SQUISH_DURATION = 6; + public static final int CAPTURE_WOBBLE_DURATION = 10; + + public static final double ATTRACT_RADIUS = 2.0; + public static final float MAX_TRAPPABLE_SIZE = 2.0f; + /** Zombies and skeletons sit exactly at 20 HP, and the issue lists them as trappable. */ + public static final float MAX_TRAPPABLE_HEALTH = 20.0f; + public static final float BASE_SIZE = 0.75f; + public static final float TRAPPED_PADDING = 0.25f; + public static final float MAX_SIZE = MAX_TRAPPABLE_SIZE + TRAPPED_PADDING; + + private static final double AIR_FRICTION = 0.97; + /** Horizontal speed above which the bubble does not rise at all. */ + private static final double FLOAT_SPEED_THRESHOLD = 0.25; + private static final double FLOAT_MAX_UP = 0.04; + private static final double FLOAT_LERP = 0.12; + private static final double REBOUND_RESTITUTION = 0.6; + /** Below this, a blocked component is just a bubble resting against a block, not a rebound worth animating. */ + private static final double MIN_REBOUND_SPEED = 0.01; + private static final double AIM_ASSIST_STRENGTH = 0.09; + /** Cosine of the half-angle of the cone the target has to be in. Roughly 55 degrees. */ + private static final double AIM_ASSIST_MIN_DOT = 0.57; + private static final float STOMP_BOOST = 0.7f; + /** + * Blocks of free fall handed out by a bounce. Vanilla clamps accumulated fall to one block when a + * wind charge throws you up; a bubble is kinder than that and starts the next fall in credit, so that + * chaining bounces does not end in a broken ankle. + */ + private static final double BOUNCE_FALL_GRACE = 2.0; + private static final float SIZE_LERP = 0.4F; + private static final float CAPTURE_WOBBLE_AMOUNT = 0.3F; + + // Entity events, well above the vanilla range. + private static final byte EVENT_SQUISH_X = 100; + private static final byte EVENT_SQUISH_Y = 101; + private static final byte EVENT_SQUISH_Z = 102; + private static final byte EVENT_CAPTURE_WOBBLE = 103; + + private static final String AGE_KEY = "age"; + private static final String FILLED_AGE_KEY = "filled_age"; + private static final String LIFETIME_KEY = "lifetime"; + private static final String FILLED_LIFETIME_KEY = "filled_lifetime"; + private static final String CAPTURE_MOTION_KEY = "capture_motion"; + private static final String ABSORB_TICKS_KEY = "absorb_ticks"; + private static final String TRAPPED_NO_AI_KEY = "trapped_no_ai"; + private static final String TEXTURE_KEY = "texture"; + + private static final EntityDataAccessor DATA_CAPTURE_MOTION = SynchedEntityData.defineId(Bubble.class, EntityDataSerializers.VECTOR3); + private static final EntityDataAccessor DATA_ABSORBING = SynchedEntityData.defineId(Bubble.class, EntityDataSerializers.BOOLEAN); + private static final EntityDataAccessor DATA_TEXTURE = SynchedEntityData.defineId(Bubble.class, EntityDataSerializers.INT); + /** Synced because the client paces the settling animation over it. */ + private static final EntityDataAccessor DATA_FILLED_LIFETIME = SynchedEntityData.defineId(Bubble.class, EntityDataSerializers.INT); + + private int age; + private int filledAge; + private int lifetime = DEFAULT_LIFETIME; + private int absorbTicks; + /** Whether the trapped mob already had its AI disabled before it got caught. */ + private boolean trappedNoAi; + + private int squishTicks; + private int squishTicksO; + private Direction.Axis squishAxis = Direction.Axis.Y; + private int absorbClientTicks; + private int absorbClientTicksO; + private int captiveClientTicks; + private int captiveClientTicksO; + private int captureWobbleTicks; + private int captureWobbleTicksO; + /** Client-side eased size. Negative until the first tick, which is how the initial snap is detected. */ + private float renderSize = -1.0F; + private float renderSizeO = -1.0F; + + public Bubble(EntityType type, Level level) { + super(type, level); + if (!level.isClientSide()) { + // Rolled here rather than on the first tick so the choice ships with the spawn packet and the + // bubble never shows up wearing one texture and swapping to another. + this.setTextureIndex(this.random.nextInt(TEXTURE_COUNT) + 1); + } + } + + public Bubble(Level level, LivingEntity owner) { + this(SuperMarioEntityTypes.BUBBLE, level); + this.setOwner(owner); + } + + @Override + protected void defineSynchedData(SynchedEntityData.Builder builder) { + builder.define(DATA_CAPTURE_MOTION, new Vector3f()); + builder.define(DATA_ABSORBING, false); + builder.define(DATA_TEXTURE, 1); + builder.define(DATA_FILLED_LIFETIME, DEFAULT_FILLED_LIFETIME); + } + + //region State + + /** + * @return the motion the bubble had at the moment it caught what it holds. Purely cosmetic: it tilts the + * way the contents tumble, so something caught by a fast bubble spins along the bubble's flight path. + */ + public Vector3fc getCaptureMotion() { + return this.entityData.get(DATA_CAPTURE_MOTION); + } + + public boolean isAbsorbing() { + return this.entityData.get(DATA_ABSORBING); + } + + /** + * @return the entity held inside the bubble, if any. + */ + @Nullable + public Entity getTrappedEntity() { + return this.getFirstPassenger(); + } + + /** + * @return whether the bubble holds nothing at all. + */ + public boolean isEmpty() { + return this.getTrappedEntity() == null; + } + + public int getLifetime() { + return this.lifetime; + } + + public void setLifetime(int lifetime) { + this.lifetime = lifetime; + } + + public int getFilledLifetime() { + return this.entityData.get(DATA_FILLED_LIFETIME); + } + + public void setFilledLifetime(int filledLifetime) { + this.entityData.set(DATA_FILLED_LIFETIME, filledLifetime); + } + + //endregion + + //region Ticking + + @Override + public void tick() { + if (this.firstTick) { + this.playSound(SuperMarioSounds.BUBBLE_APPEAR.value(), 0.5F, 1.0F); + } + + super.tick(); + + if (!this.level().isClientSide()) { + this.age++; + if (this.isEmpty()) { + this.aimAtNearbyEntities(); + } + } + + this.tickMovement(); + + if (this.level().isClientSide()) { + this.tickClientAnimations(); + return; + } + + if (this.isAbsorbing()) { + this.tickAbsorption(); + } else { + this.checkEntityCollisions(); + } + if (this.isRemoved()) { + return; + } + this.tickLifetime(); + } + + private void tickMovement() { + Vec3 movement = this.getDeltaMovement().scale(AIR_FRICTION); + + // The slower the bubble travels horizontally, the more it drifts upwards. + double slowness = 1.0 - Math.clamp(movement.horizontalDistance() / FLOAT_SPEED_THRESHOLD, 0.0, 1.0); + movement = movement.with(Direction.Axis.Y, Mth.lerp(FLOAT_LERP, movement.y(), FLOAT_MAX_UP * slowness)); + + this.setDeltaMovement(movement); + this.move(MoverType.SELF, movement); + // move() only records which blocks were crossed; this is what actually runs their "entity inside" + // behaviour. Without it the bubble ignores portals, pressure plates and every other trigger block. + this.applyEffectsFromBlocks(); + this.reboundOffBlocks(movement); + this.needsSync = true; + } + + /** + * {@link Entity#move} zeroes out whichever component ran into a block, which is how the rebound axis is found. + */ + private void reboundOffBlocks(Vec3 requested) { + if (!this.horizontalCollision && !this.verticalCollision) { + return; + } + Vec3 actual = this.getDeltaMovement(); + Direction.Axis axis = null; + double x = actual.x(); + double y = actual.y(); + double z = actual.z(); + + // Anything slower than MIN_REBOUND_SPEED is a bubble idling against a block — bouncing it would + // otherwise replay the rebound sound every single tick while it floats under a ceiling. + if (blocked(requested.x(), actual.x())) { + x = -requested.x() * REBOUND_RESTITUTION; + axis = Direction.Axis.X; + } + if (blocked(requested.z(), actual.z())) { + z = -requested.z() * REBOUND_RESTITUTION; + axis = Direction.Axis.Z; + } + if (blocked(requested.y(), actual.y())) { + y = -requested.y() * REBOUND_RESTITUTION; + axis = Direction.Axis.Y; + } + if (axis == null) { + return; + } + + this.setDeltaMovement(x, y, z); + if (!this.level().isClientSide()) { + // No sound on rebound: bubbles bounce often enough that it turns into noise. The squish carries it. + this.level().broadcastEntityEvent(this, squishEvent(axis)); + this.notifyBlockHit(axis, switch (axis) { + case X -> requested.x(); + case Y -> requested.y(); + case Z -> requested.z(); + }); + } + } + + /** + * Lets the block the bubble bounced against react as it would to any thrown projectile. Target blocks, and + * anything else keyed on projectile hits, come from here. + */ + private void notifyBlockHit(Direction.Axis axis, double requestedOnAxis) { + Direction direction = Direction.get(requestedOnAxis > 0.0 ? Direction.AxisDirection.POSITIVE : Direction.AxisDirection.NEGATIVE, axis); + Vec3 from = this.getBoundingBox().getCenter(); + Vec3 to = from.add(Vec3.atLowerCornerOf(direction.getUnitVec3i()).scale(this.getBbWidth() / 2.0 + 0.25)); + 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); + } + + private static boolean blocked(double requested, double actual) { + return Math.abs(requested) > MIN_REBOUND_SPEED && Math.abs(actual) < 1.0E-7; + } + + private void tickLifetime() { + if (this.isEmpty()) { + if (this.lifetime >= 0 && this.age >= this.lifetime) { + this.pop(); + } + return; + } + this.filledAge++; + int filledLifetime = this.getFilledLifetime(); + if (filledLifetime >= 0 && this.filledAge >= filledLifetime) { + this.pop(); + } + } + + /** + * Nudges the bubble towards a nearby trappable entity. This steers the heading and leaves the speed alone, so + * it reads as an aim assist on a near miss rather than as a bubble homing in on whatever walks past. + */ + private void aimAtNearbyEntities() { + Vec3 velocity = this.getDeltaMovement(); + double speed = velocity.length(); + if (speed < 1.0E-4) { + return; + } + Entity owner = this.getOwner(); + Vec3 heading = velocity.scale(1.0 / speed); + Vec3 center = this.getBoundingBox().getCenter(); + + Vec3 bestDirection = null; + double bestDistance = Double.MAX_VALUE; + for (Entity entity : this.level().getEntities(this, this.getBoundingBox().inflate(ATTRACT_RADIUS), e -> e != owner && this.canTrap(e))) { + Vec3 direction = entity.getBoundingBox().getCenter().subtract(center).normalize(); + // Only assist towards what the bubble was already flying at. + if (heading.dot(direction) < AIM_ASSIST_MIN_DOT) { + continue; + } + double distance = this.distanceToSqr(entity); + if (distance < bestDistance) { + bestDistance = distance; + bestDirection = direction; + } + } + if (bestDirection == null) { + return; + } + this.setDeltaMovement(heading.lerp(bestDirection, AIM_ASSIST_STRENGTH).normalize().scale(speed)); + } + + //endregion + + //region Interactions + + /** + * Checks entities overlapping the bubble, in the priority order defined by the Bubble Flower design. + */ + private void checkEntityCollisions() { + Entity held = this.getTrappedEntity(); + // A bubble carrying a captive is not fragile: it keeps flying and goes out on its own timer, on a stomp, + // or when something damages it. Otherwise it bursts the moment it drifts past anything. + if (held != null && !isReward(held)) { + return; + } + Entity owner = this.getOwner(); + List entities = this.level().getEntities(this, this.getBoundingBox(), entity -> entity != held && !entity.isSpectator() && entity.isAlive()); + + for (Entity entity : entities) { + // Bubbles pass through one another: they are in the module tag, but popping or swallowing each other + // would mean a volley destroys itself. + if (entity instanceof Bubble) { + continue; + } + // A bubble holding a reward is a collectible: whoever touches it takes what is inside. + if (held != null) { + if (entity instanceof Player player && held instanceof CollectibleEntity collectible) { + collectible.collect(player); + } + this.pop(); + return; + } + // The owner is checked before the blacklist: players are blacklisted, but the owner still needs its + // grace period, otherwise every bubble would pop in its shooter's face on the very first tick. + if (entity == owner) { + if (this.age < OWNER_POP_DELAY) { + continue; + } + this.pop(); + return; + } + if (entity.is(SuperMarioEntityTypeTags.BUBBLE_CANNOT_TRAP)) { + this.pop(); + return; + } + if (entity.is(SuperMarioEntityTypeTags.ALL)) { + this.absorb(entity); + return; + } + if (this.canTrap(entity)) { + this.trap(entity); + return; + } + } + } + + /** + * @return whether the bubble is allowed to hold the given entity. + */ + public boolean canTrap(Entity entity) { + if (entity == this || entity.isRemoved() || !entity.isAlive() || entity.isSpectator()) { + return false; + } + if (entity.isPassenger() || entity.isVehicle()) { + return false; + } + if (entity.is(SuperMarioEntityTypeTags.BUBBLE_CANNOT_TRAP)) { + return false; + } + if (entity.is(SuperMarioEntityTypeTags.BUBBLE_CAN_TRAP)) { + return true; + } + if (!(entity instanceof LivingEntity living)) { + return false; + } + // Health is read from the base attribute, not from getMaxHealth(): a leader zombie carries a + // "leader_zombie_bonus" modifier that can take it past 90 HP, but it is still an ordinary zombie as far + // as a bubble is concerned. Size stays as measured, since fitting inside really is a physical limit. + return entity.getBbWidth() < MAX_TRAPPABLE_SIZE + && entity.getBbHeight() < MAX_TRAPPABLE_SIZE + && living.getAttributeBaseValue(Attributes.MAX_HEALTH) <= MAX_TRAPPABLE_HEALTH; + } + + /** + * Catches a living entity: it stops moving and attacking until the bubble pops. + */ + public void trap(Entity entity) { + if (this.level().isClientSide() || !this.hold(entity)) { + return; + } + this.filledAge = 0; + this.playSound(SuperMarioSounds.BUBBLE_FILL.value(), 0.5F, 1.0F); + } + + /** + * Swallows a Super Mario entity: it spins and shrinks away, then gets replaced by its capture loot. + */ + public void absorb(Entity entity) { + if (this.level().isClientSide() || !this.hold(entity)) { + return; + } + this.filledAge = 0; + this.absorbTicks = ABSORB_DURATION; + this.entityData.set(DATA_ABSORBING, true); + this.playSound(SuperMarioSounds.BUBBLE_FILL.value(), 0.5F, 1.0F); + } + + private boolean hold(Entity entity) { + this.trappedNoAi = entity instanceof Mob mob && mob.isNoAi(); + // Forced, so that mobs which normally refuse to ride anything still get caught. + if (!entity.startRiding(this, true, true)) { + return false; + } + this.entityData.set(DATA_CAPTURE_MOTION, this.getDeltaMovement().toVector3f()); + this.level().broadcastEntityEvent(this, EVENT_CAPTURE_WOBBLE); + if (entity instanceof Mob mob) { + mob.setNoAi(true); + } + return true; + } + + private void tickAbsorption() { + if (--this.absorbTicks > 0) { + return; + } + this.finishAbsorption(); + } + + /** + * Turns whatever is being swallowed into its capture loot right away, without waiting for the animation. + */ + private void finishAbsorption() { + Entity absorbed = this.getTrappedEntity(); + this.entityData.set(DATA_ABSORBING, false); + this.absorbTicks = 0; + // A dying entity stays aboard for the length of its death animation, which outlasts the swallow. If it + // was killed on the way, there is nothing left in there to turn into loot. + if (absorbed == null || !absorbed.isAlive()) { + return; + } + ItemStack loot = this.level() instanceof ServerLevel serverLevel ? this.rollCaptureLoot(serverLevel, absorbed) : ItemStack.EMPTY; + absorbed.stopRiding(); + absorbed.discard(); + this.holdReward(loot); + this.refreshDimensions(); + } + + private ItemStack rollCaptureLoot(ServerLevel level, Entity absorbed) { + LootParams params = new LootParams.Builder(level) + .withParameter(LootContextParams.THIS_ENTITY, absorbed) + .withParameter(LootContextParams.ORIGIN, absorbed.position()) + .create(LootContextParamSets.GIFT); + var items = level.getServer().reloadableRegistries().getLootTable(SuperMarioBuiltInLootTables.BUBBLE_CAPTURE).getRandomItems(params); + return items.isEmpty() ? ItemStack.EMPTY : items.getFirst(); + } + + /** + * Puts the capture loot inside the bubble as a real entity rather than as a stored stack, so that a coin + * keeps a coin's size and its own spinning animation. + */ + private void holdReward(ItemStack loot) { + if (loot.isEmpty()) { + return; + } + Entity reward = createReward(this.level(), loot); + Vec3 center = this.getBoundingBox().getCenter(); + reward.setPos(center.x(), center.y(), center.z()); + this.level().addFreshEntity(reward); + reward.startRiding(this, true, true); + } + + private static Entity createReward(Level level, ItemStack stack) { + if (stack.is(SuperMarioItemTags.BUBBLE_CATCH_AS_COLLECTIBLE)) { + CollectibleEntity collectible = new CollectibleEntity(level, 0.0, 0.0, 0.0, stack); + SuperMarioCollectibles.configure(collectible, stack); + // Held still by the bubble; it only starts falling once the bubble lets go of it. + collectible.setFixed(true); + return collectible; + } + return new ItemEntity(level, 0.0, 0.0, 0.0, stack); + } + + /** + * @return whether what the bubble holds is loot to hand out rather than a captive to carry. + */ + private static boolean isReward(Entity held) { + return held instanceof CollectibleEntity || held instanceof ItemEntity; + } + + public void pop() { + if (this.level().isClientSide() || this.isRemoved()) { + return; + } + // Popping a bubble mid-swallow does not rescue the enemy, it just finishes the job early. + if (this.isAbsorbing()) { + this.finishAbsorption(); + } + Entity trapped = this.getTrappedEntity(); + if (trapped != null) { + trapped.stopRiding(); + this.releaseReward(trapped); + } + this.level().broadcastEntityEvent(this, EntityEvent.DEATH); + this.playSound(SuperMarioSounds.BUBBLE_POP.value(), 0.5F, 1.0F); + this.discard(); + } + + /** + * Lets a reward drop out of a popping bubble instead of leaving it hanging where the bubble was. + */ + private void releaseReward(Entity reward) { + if (!isReward(reward)) { + return; + } + if (reward instanceof CollectibleEntity collectible) { + collectible.setFixed(false); + } + reward.setDeltaMovement(this.getDeltaMovement().scale(0.5)); + } + + @Override + public boolean hurtServer(ServerLevel level, DamageSource source, float amount) { + if (this.isRemoved()) { + return false; + } + // No health, like paintings: anything that lands a real hit pops it, whether that is an arrow, a sword + // or a bare fist. Wind charges deal no damage, so they only blow the bubble around. + if (amount <= 0.0F) { + return false; + } + this.pop(); + return true; + } + + @Override + protected void onHitBlock(BlockHitResult result) { + // Block rebounds are handled from the actual movement in reboundOffBlocks(). + } + + @Override + protected void onHitEntity(EntityHitResult result) { + // Entity interactions are handled from the bounding box in checkEntityCollisions(). + } + + @Override + protected boolean canHitEntity(Entity target) { + return false; + } + + @Override + protected double getDefaultGravity() { + return 0.0; + } + + @Override + public boolean isPickable() { + return !this.isRemoved(); + } + + //endregion + + //region Passengers + + @Override + protected boolean canAddPassenger(Entity passenger) { + return this.getPassengers().isEmpty(); + } + + @Override + protected void addPassenger(Entity passenger) { + super.addPassenger(passenger); + // Runs on both sides, which keeps the client bounding box (and therefore the sprite size) in sync. + this.refreshDimensions(); + } + + @Override + protected void removePassenger(Entity passenger) { + super.removePassenger(passenger); + // Every way out of a bubble goes through here — popping, being discarded, or the entity dismounting on + // its own (mobs tagged DISMOUNTS_UNDERWATER do that). Restoring the AI anywhere else leaves mobs frozen. + if (!this.level().isClientSide() && passenger instanceof Mob mob) { + mob.setNoAi(this.trappedNoAi); + } + this.refreshDimensions(); + } + + @Override + protected void positionRider(Entity passenger, Entity.MoveFunction callback) { + // The passenger's vehicle attachment point is deliberately skipped. Entity types declare it so riders sit + // properly on boats and horses -- zombies use ridingOffset(-0.7), which vanilla then subtracts, dropping + // them 0.7 blocks below where they were put. A bubble centres what it holds, whatever the type asks for. + Vec3 pos = this.getPassengerRidingPosition(passenger); + callback.accept(passenger, pos.x(), pos.y(), pos.z()); + } + + @Override + public Vec3 getPassengerRidingPosition(Entity passenger) { + // Derived from the passenger rather than from getBbHeight(): the cached dimensions are only refreshed + // once the passenger list has been applied, and reading them too early drops the rider under the bubble. + return new Vec3(this.getX(), this.getY() + (sizeFor(passenger) - passenger.getBbHeight()) / 2.0, this.getZ()); + } + + @Override + public EntityDimensions getDimensions(Pose pose) { + float size = sizeFor(this.getFirstPassenger()); + return EntityDimensions.fixed(size, size); + } + + /** + * @return the side of the bubble's cube. Read straight from what it holds rather than from the cached + * dimensions, so the sprite can never be drawn a tick behind the entity it contains. + */ + public float getSize() { + return sizeFor(this.getFirstPassenger()); + } + + /** + * @return the side of the bubble's cube, always a little bigger than what it holds. + */ + private static float sizeFor(@Nullable Entity trapped) { + if (trapped == null) { + return BASE_SIZE; + } + return Math.clamp(Math.max(trapped.getBbWidth(), trapped.getBbHeight()) + TRAPPED_PADDING, BASE_SIZE, MAX_SIZE); + } + + //endregion + + //region Stomping + + @Override + public boolean canBeStomped() { + return this.isAlive(); + } + + @Override + public AABB getStompBox() { + AABB box = this.getBoundingBox(); + return box.setMinY(box.maxY - 0.2D * box.getYsize()).setMaxY(box.maxY + 0.5D); + } + + @Override + public Predicate getStompableBy() { + Entity trapped = this.getTrappedEntity(); + Entity owner = this.getOwner(); + return EntitySelector.NO_SPECTATORS.and(entity -> entity != trapped + // Bubbles drift into each other constantly; they must not bounce off one another. + && !(entity instanceof Bubble) + && (entity != owner || this.age >= OWNER_STOMP_DELAY) + && entity.isAlive() + && !entity.onGround() + && entity.getDeltaMovement().y() < 0.0D); + } + + @Override + public void onStompedBy(Entity entity) { + if (!(this.level() instanceof ServerLevel)) { + return; + } + // A player's real momentum only lives on their client; the server-side delta is stale, and sending it + // back would kill the horizontal speed they came in with. getKnownMovement() is what the client reported. + Vec3 momentum = entity.getKnownMovement(); + entity.setDeltaMovement(momentum.x(), STOMP_BOOST, momentum.z()); + if (entity instanceof ServerPlayer player) { + player.connection.send(new ClientboundSetEntityMotionPacket(player)); + } + // Wipes the fall that led into the bounce, the way being thrown up by a wind charge does. + entity.fallDistance = 0.0F; + // The couple of blocks of credit on top cannot be written here: the server resets a player's fall + // distance on the way up. It is handed over instead, and taken off once the fall actually starts. + if (entity instanceof FallGraced graced) { + graced.grantFallGrace(BOUNCE_FALL_GRACE); + } + this.pop(); + } + + //endregion + + //region Client animations + + private static byte squishEvent(Direction.Axis axis) { + return switch (axis) { + case X -> EVENT_SQUISH_X; + case Y -> EVENT_SQUISH_Y; + case Z -> EVENT_SQUISH_Z; + }; + } + + private void tickClientAnimations() { + this.tickRenderSize(); + + this.squishTicksO = this.squishTicks; + if (this.squishTicks > 0) { + this.squishTicks--; + } + + this.captureWobbleTicksO = this.captureWobbleTicks; + if (this.captureWobbleTicks > 0) { + this.captureWobbleTicks--; + } + + this.absorbClientTicksO = this.absorbClientTicks; + if (this.isAbsorbing()) { + this.absorbClientTicks = Math.min(this.absorbClientTicks + 1, ABSORB_DURATION); + } else { + this.absorbClientTicks = 0; + this.absorbClientTicksO = 0; + } + + this.captiveClientTicksO = this.captiveClientTicks; + if (this.getTrappedEntity() != null) { + this.captiveClientTicks = Math.min(this.captiveClientTicks + 1, Math.max(this.getFilledLifetime(), 0)); + } else { + this.captiveClientTicks = 0; + this.captiveClientTicksO = 0; + } + } + + /** + * @return how far the contents have settled, from 0 right after being caught to 1 by the time the bubble + * is due to pop. Counted on the client, since nothing but the animation depends on it. + */ + public float getSettleProgress(float partialTicks) { + int span = this.getFilledLifetime(); + if (span <= 0) { + // A bubble set to hold on forever never settles. + return 0.0F; + } + return Math.clamp(Mth.lerp(partialTicks, this.captiveClientTicksO, this.captiveClientTicks) / span, 0.0F, 1.0F); + } + + private void tickRenderSize() { + float target = this.getSize(); + // First tick snaps: a bubble loading in already holding something must not inflate from nothing. + if (this.renderSize < 0.0F) { + this.renderSize = target; + this.renderSizeO = target; + return; + } + this.renderSizeO = this.renderSize; + this.renderSize = Mth.lerp(SIZE_LERP, this.renderSize, target); + } + + /** + * @return the size to draw the bubble at, eased so that swallowing something grows it smoothly. The hitbox + * itself still changes in one step, since that side is gameplay. + */ + public float getRenderSize(float partialTicks) { + if (this.renderSize < 0.0F) { + return this.getSize(); + } + return Mth.lerp(partialTicks, this.renderSizeO, this.renderSize); + } + + /** + * @return how far along the capture animation is, from 0 (untouched) to 1 (fully swallowed). + */ + public float getAbsorbProgress(float partialTicks) { + return Math.clamp(Mth.lerp(partialTicks, this.absorbClientTicksO, this.absorbClientTicks) / ABSORB_DURATION, 0.0F, 1.0F); + } + + /** + * @return how squished the bubble currently is, from 0 (round) to 1 (fully flattened). + */ + 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 to round. + return Mth.sin((ticks / SQUISH_DURATION) * (Mth.PI / 2.0F)); + } + + /** + * @return how the bubble is squashed by having just closed around something, negative while it is flattened + * and positive while it rebounds taller than it should be. Zero at both ends, so it blends in and out. + */ + public float getCaptureWobble(float partialTicks) { + float ticks = Mth.lerp(partialTicks, this.captureWobbleTicksO, this.captureWobbleTicks); + if (ticks <= 0.0F) { + return 0.0F; + } + // Counts down, so this runs from 0 to 1 over the wobble. + float progress = 1.0F - ticks / CAPTURE_WOBBLE_DURATION; + // One squash, one overshoot, damped to nothing: the classic squash and stretch. + return -Mth.sin(progress * Mth.TWO_PI) * (1.0F - progress) * CAPTURE_WOBBLE_AMOUNT; + } + + public Direction.Axis getSquishAxis() { + return this.squishAxis; + } + + @Environment(EnvType.CLIENT) + @Override + public void handleEntityEvent(byte state) { + switch (state) { + case EVENT_SQUISH_X -> this.startSquish(Direction.Axis.X); + case EVENT_SQUISH_Y -> this.startSquish(Direction.Axis.Y); + case EVENT_SQUISH_Z -> this.startSquish(Direction.Axis.Z); + case EVENT_CAPTURE_WOBBLE -> this.startCaptureWobble(); + case EntityEvent.DEATH -> this.spawnPopParticles(); + default -> super.handleEntityEvent(state); + } + } + + @Environment(EnvType.CLIENT) + private void startSquish(Direction.Axis axis) { + this.squishAxis = axis; + this.squishTicks = SQUISH_DURATION; + this.squishTicksO = SQUISH_DURATION; + } + + @Environment(EnvType.CLIENT) + private void startCaptureWobble() { + this.captureWobbleTicks = CAPTURE_WOBBLE_DURATION; + this.captureWobbleTicksO = CAPTURE_WOBBLE_DURATION; + } + + @Environment(EnvType.CLIENT) + private void spawnPopParticles() { + double radius = this.getBbWidth() / 2.0; + Vec3 center = this.getBoundingBox().getCenter(); + for (int i = 0; i < 10; i++) { + this.level().addParticle( + ParticleTypes.BUBBLE_POP, + center.x() + (this.random.nextDouble() - 0.5) * radius * 2.0, + center.y() + (this.random.nextDouble() - 0.5) * radius * 2.0, + center.z() + (this.random.nextDouble() - 0.5) * radius * 2.0, + (this.random.nextDouble() - 0.5) * 0.1, + (this.random.nextDouble() - 0.5) * 0.1, + (this.random.nextDouble() - 0.5) * 0.1 + ); + } + } + + /** + * @return which of the bubble looks this one wears, from 1 to {@link #TEXTURE_COUNT}. + */ + public int getTextureIndex() { + return this.entityData.get(DATA_TEXTURE); + } + + public void setTextureIndex(int index) { + this.entityData.set(DATA_TEXTURE, Math.clamp(index, 1, TEXTURE_COUNT)); + } + + public ClientAsset.ResourceTexture getTexture() { + return TEXTURES.get(this.getTextureIndex() - 1); + } + + //endregion + + //region Serialization + + @Override + protected void readAdditionalSaveData(ValueInput input) { + super.readAdditionalSaveData(input); + this.age = input.getIntOr(AGE_KEY, 0); + this.filledAge = input.getIntOr(FILLED_AGE_KEY, 0); + this.lifetime = input.getIntOr(LIFETIME_KEY, DEFAULT_LIFETIME); + this.setFilledLifetime(input.getIntOr(FILLED_LIFETIME_KEY, DEFAULT_FILLED_LIFETIME)); + this.absorbTicks = input.getIntOr(ABSORB_TICKS_KEY, 0); + this.trappedNoAi = input.getBooleanOr(TRAPPED_NO_AI_KEY, false); + input.read(CAPTURE_MOTION_KEY, Vec3.CODEC).ifPresent(motion -> this.entityData.set(DATA_CAPTURE_MOTION, motion.toVector3f())); + // Falls back to whatever was rolled on construction, so a bubble summoned without the tag still varies. + this.setTextureIndex(input.getIntOr(TEXTURE_KEY, this.getTextureIndex())); + this.entityData.set(DATA_ABSORBING, this.absorbTicks > 0); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + super.addAdditionalSaveData(output); + output.putInt(AGE_KEY, this.age); + output.putInt(FILLED_AGE_KEY, this.filledAge); + output.putInt(LIFETIME_KEY, this.lifetime); + output.putInt(FILLED_LIFETIME_KEY, this.getFilledLifetime()); + output.putInt(ABSORB_TICKS_KEY, this.absorbTicks); + output.putBoolean(TRAPPED_NO_AI_KEY, this.trappedNoAi); + output.putInt(TEXTURE_KEY, this.getTextureIndex()); + Vector3fc motion = this.getCaptureMotion(); + output.store(CAPTURE_MOTION_KEY, Vec3.CODEC, new Vec3(motion.x(), motion.y(), motion.z())); + } + + //endregion +} diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/item/CollectibleItem.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/item/CollectibleItem.java index a3c8a11fa..e6b5af71d 100644 --- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/item/CollectibleItem.java +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/item/CollectibleItem.java @@ -1,13 +1,9 @@ package fr.hugman.mubble.super_mario.world.item; -import fr.hugman.mubble.sounds.SoundConfig; -import fr.hugman.mubble.super_mario.core.particles.SuperMarioParticleTypes; -import fr.hugman.mubble.super_mario.sounds.SuperMarioSounds; -import fr.hugman.mubble.world.entity.MubbleEntityTypes; +import fr.hugman.mubble.super_mario.world.entity.item.SuperMarioCollectibles; import fr.hugman.mubble.world.entity.item.collectible.CollectibleEntity; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; -import net.minecraft.core.particles.ParticleOptions; import net.minecraft.server.level.ServerLevel; import net.minecraft.sounds.SoundEvents; import net.minecraft.sounds.SoundSource; @@ -42,22 +38,7 @@ public InteractionResult useOn(final UseOnContext context) { if (pos != null) { if (level instanceof ServerLevel serverLevel) { CollectibleEntity entity = new CollectibleEntity(serverLevel, pos.x(), pos.y(), pos.z(), itemStack.copyWithCount(1)); - entity.setCollectSound(new SoundConfig(SuperMarioSounds.COIN_COLLECT, 0.2f, 1.0f)); - entity.setBounceSound(new SoundConfig(SuperMarioSounds.COIN_BOUNCE, 1.0f, 1.0f)); - ParticleOptions particle = null; - if(itemStack.is(SuperMarioItems.COIN)) { - particle = SuperMarioParticleTypes.COIN_SPARKLE; - } - if(itemStack.is(SuperMarioItems.RED_COIN)) { - particle = SuperMarioParticleTypes.RED_COIN_SPARKLE; - } - if(itemStack.is(SuperMarioItems.BLUE_COIN)) { - particle = SuperMarioParticleTypes.BLUE_COIN_SPARKLE; - } - if(itemStack.is(SuperMarioItems.FLOWER_COIN)) { - particle = SuperMarioParticleTypes.FLOWER_COIN_SPARKLE; - } - entity.setCollectParticle(particle); + SuperMarioCollectibles.configure(entity, itemStack); EntityType.createDefaultStackConfig(serverLevel, itemStack, context.getPlayer()).apply(entity); if (entity == null) { return InteractionResult.FAIL; 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 7dc439ee5..f1d53711d 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 @@ -49,6 +49,7 @@ public static void appendItemGroups() { entries.accept(SuperMarioItems.ICE_FLOWER); entries.accept(SuperMarioItems.GOLD_FLOWER); entries.accept(SuperMarioItems.CLOUD_FLOWER); + entries.accept(SuperMarioItems.BUBBLE_FLOWER); 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 7fc6d65e7..d623e5e07 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 @@ -38,6 +38,7 @@ public class SuperMarioItems { public static final PowerUpItem ICE_FLOWER = registerPowerUp(SuperMarioItemIds.ICE_FLOWER, SuperMarioPowerUpIds.ICE); 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 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/level/storage/loot/SuperMarioBuiltInLootTables.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/level/storage/loot/SuperMarioBuiltInLootTables.java index 4839f80a1..f261dd827 100644 --- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/level/storage/loot/SuperMarioBuiltInLootTables.java +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/level/storage/loot/SuperMarioBuiltInLootTables.java @@ -7,6 +7,8 @@ public class SuperMarioBuiltInLootTables { public static final ResourceKey GOLDEN_KILL = register("golden_kill"); + /** What a bubble ends up holding after swallowing a Super Mario entity. */ + public static final ResourceKey BUBBLE_CAPTURE = register("gameplay/bubble_capture"); private static ResourceKey register(final String path) { return ResourceKey.create(Registries.LOOT_TABLE, SuperMario.id(path)); diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/power_up/action/SpawnCloudPlatformPowerUpAction.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/power_up/action/SpawnCloudPlatformPowerUpAction.java index 0b6a2b1b2..df3aa25c6 100644 --- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/power_up/action/SpawnCloudPlatformPowerUpAction.java +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/power_up/action/SpawnCloudPlatformPowerUpAction.java @@ -113,7 +113,7 @@ public InteractionResult trigger(Player player) { level.playSound(null, player.getX(), player.getY(), player.getZ(), SuperMarioSounds.POWER_UP_SPIN_ATTACK, SoundSource.PLAYERS, 0.5F, 1.0F); entity.setPos(player.getX(), platformY.getAsDouble(), player.getZ()); level.addFreshEntity(entity); - properties.addEntity(entity.getUUID()); + properties.useCharge(); // The platform had to be raised out of the ground, so the player rides up with it instead of // being left standing next to it. diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/datapack/DataPackPowerUpGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/datapack/DataPackPowerUpGameTest.java index 6296df6ba..2ea730c32 100644 --- a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/datapack/DataPackPowerUpGameTest.java +++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/datapack/DataPackPowerUpGameTest.java @@ -64,7 +64,7 @@ public void aPowerUpActionCanBeReferencedById(GameTestHelper helper) { var shoot = (ShootProjectilePowerUpAction) action.value(); helper.assertValueEqual(shoot.projectile(), EntityTypes.SNOWBALL, "the projectile of the referenced action"); - helper.assertValueEqual(shoot.maxProjectiles(), Optional.of(5), "the projectile count of the referenced action"); + helper.assertValueEqual(shoot.charges().max(), 5, "the projectile count of the referenced action"); helper.succeed(); } diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/datapack/DynamicContentGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/datapack/DynamicContentGameTest.java index c61d57b34..5de3f326d 100644 --- a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/datapack/DynamicContentGameTest.java +++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/datapack/DynamicContentGameTest.java @@ -116,7 +116,19 @@ public void everyShippedPowerUpHasASprite(GameTestHelper helper) { helper.succeed(); } + /** + * Tags that exist only so that data packs can put something in them. The mod ships nothing for them, not + * even the tag file, so neither the presence nor the emptiness check applies. + */ + private static final java.util.Set> DATA_PACK_HOOKS = java.util.Set.of( + SuperMarioEntityTypeTags.BUBBLE_CAN_TRAP + ); + private static void assertTagIsNotEmpty(GameTestHelper helper, net.minecraft.core.HolderLookup registry, TagKey tag) { + if (DATA_PACK_HOOKS.contains(tag)) { + return; + } + @SuppressWarnings({"unchecked", "rawtypes"}) var entries = ((net.minecraft.core.HolderLookup) registry).get((TagKey) tag); diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpHolderGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpHolderGameTest.java index d0aa3c1d6..b0a5defa4 100644 --- a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpHolderGameTest.java +++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpHolderGameTest.java @@ -124,7 +124,7 @@ public void aSpentPowerUpCanBeRefilledButAFullOneCannot(GameTestHelper helper) { helper.assertFalse(PowerUp.canRefill(player, shooter), "a power-up at full charge has nothing to refill"); // Spending a charge is what a trigger does; here it is done directly to keep the test about refilling. - player.getPowerUpProperties().addEntity(java.util.UUID.randomUUID()); + player.getPowerUpProperties().useCharge(); helper.assertTrue(PowerUp.canRefill(player, shooter), "a power-up that spent a charge should be refillable"); helper.assertTrue(PowerUp.canChange(player, shooter), "and taking it again should therefore be allowed"); diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpItemGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpItemGameTest.java index 85abc11da..e8fad6857 100644 --- a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpItemGameTest.java +++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpItemGameTest.java @@ -65,7 +65,7 @@ public void aSpentPowerUpAcceptsAnotherFlower(GameTestHelper helper) { player.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(SuperMarioItems.FIRE_FLOWER, 2)); SuperMarioItems.FIRE_FLOWER.use(helper.getLevel(), player, InteractionHand.MAIN_HAND); - player.getPowerUpProperties().addEntity(java.util.UUID.randomUUID()); + player.getPowerUpProperties().useCharge(); var refill = SuperMarioItems.FIRE_FLOWER.use(helper.getLevel(), player, InteractionHand.MAIN_HAND); diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpPersistenceGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpPersistenceGameTest.java index 713e30a9a..5db3384b3 100644 --- a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpPersistenceGameTest.java +++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpPersistenceGameTest.java @@ -32,7 +32,7 @@ public void theChargesSurviveSaveAndLoad(GameTestHelper helper) { var saved = TestPlayers.mock(helper); saved.setPowerUp(PowerUpFixtures.get(helper, PowerUpFixtures.SHOOTER)); // Spend one of the two charges, so that a reset to full would show. - saved.getPowerUpProperties().addEntity(java.util.UUID.randomUUID()); + saved.getPowerUpProperties().useCharge(); int spent = saved.getPowerUpProperties().getChargeCount(); var loaded = reload(helper, saved); diff --git a/mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up/shooter.json b/mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up/shooter.json index 1da852148..40c09a0d8 100644 --- a/mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up/shooter.json +++ b/mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up/shooter.json @@ -4,7 +4,10 @@ "projectile": "minecraft:snowball", "sound": "minecraft:entity.snowball.throw", "speed": 0.4, - "max_projectiles": 2 + "charges": { + "counting": "from_active_entities", + "max": 2 + } }, "name": { "translate": "power_up.mubble-gametest.shooter" diff --git a/mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up_action/snowball_barrage.json b/mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up_action/snowball_barrage.json index 2374a1c55..c75ffd8eb 100644 --- a/mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up_action/snowball_barrage.json +++ b/mubble-test/src/gametest/resources/data/mubble-gametest/mubble/power_up_action/snowball_barrage.json @@ -3,5 +3,8 @@ "projectile": "minecraft:snowball", "sound": "minecraft:entity.snowball.throw", "speed": 1.0, - "max_projectiles": 5 + "charges": { + "counting": "from_active_entities", + "max": 5 + } } diff --git a/mubble-test/src/test/java/fr/hugman/mubble/test/unit/PowerUpPropertiesTest.java b/mubble-test/src/test/java/fr/hugman/mubble/test/unit/PowerUpPropertiesTest.java index 78bed2646..d594f767a 100644 --- a/mubble-test/src/test/java/fr/hugman/mubble/test/unit/PowerUpPropertiesTest.java +++ b/mubble-test/src/test/java/fr/hugman/mubble/test/unit/PowerUpPropertiesTest.java @@ -35,7 +35,7 @@ void freshPropertiesAreAtMax() { void spendingAChargeLeavesTheMaximum() { var properties = new PowerUpProperties(PowerUpProperties.ChargeCounting.ONLY_DECREASE, 2); - properties.addEntity(UUID.randomUUID()); + properties.useCharge(); assertEquals(1, properties.getChargeCount()); assertFalse(properties.isAtMax()); @@ -44,9 +44,9 @@ void spendingAChargeLeavesTheMaximum() { @Test @DisplayName("a running cooldown keeps the power-up away from its maximum") void runningCooldownLeavesTheMaximum() { - var properties = new PowerUpProperties(PowerUpProperties.ChargeCounting.ONLY_DECREASE, 1); + var properties = new PowerUpProperties( + PowerUpProperties.ChargeCounting.ONLY_DECREASE, 1, 0, 2, 1, List.of()); - properties.setCooldown(2); assertFalse(properties.isAtMax()); properties.tick(); @@ -59,9 +59,9 @@ void runningCooldownLeavesTheMaximum() { @Test @DisplayName("COOLDOWN_RECHARGE gives a charge back once the cooldown runs out") void cooldownRechargeGivesAChargeBack() { - var properties = new PowerUpProperties(PowerUpProperties.ChargeCounting.COOLDOWN_RECHARGE, 1); - properties.addEntity(UUID.randomUUID()); - properties.setCooldown(2); + var properties = new PowerUpProperties(PowerUpProperties.ChargeCounting.COOLDOWN_RECHARGE, 1, 2); + // Spending the charge is what starts the countdown, so this is the whole trigger in one call. + properties.useCharge(); properties.tick(); assertEquals(0, properties.getChargeCount()); @@ -77,8 +77,11 @@ void activeEntitiesDriveTheChargeCount() { var properties = new PowerUpProperties(PowerUpProperties.ChargeCounting.FROM_ACTIVE_ENTITIES, 2); var firstProjectile = UUID.randomUUID(); - properties.addEntity(firstProjectile); - properties.addEntity(UUID.randomUUID()); + // What a trigger does: spend the charge, then tie it to the entity that was sent out. + properties.useCharge(); + properties.trackEntity(firstProjectile); + properties.useCharge(); + properties.trackEntity(UUID.randomUUID()); properties.tick(); assertEquals(0, properties.getChargeCount()); @@ -94,7 +97,7 @@ void dirtyFlagIsConsumedOnce() { var properties = new PowerUpProperties(PowerUpProperties.ChargeCounting.ONLY_DECREASE, 1); assertFalse(properties.checkDirty()); - properties.setCooldown(1); + properties.useCharge(); assertTrue(properties.checkDirty()); assertFalse(properties.checkDirty()); @@ -109,6 +112,7 @@ void roundTripsThroughJson() { var properties = new PowerUpProperties( PowerUpProperties.ChargeCounting.FROM_ACTIVE_ENTITIES, 3, + 5, 7, 2, List.of(UUID.fromString("f7e5b26e-9e4d-4b6f-9f7c-6c1f0f8f2a11")) @@ -122,6 +126,7 @@ void roundTripsThroughJson() { assertEquals(properties.getChargeCount(), decoded.getChargeCount()); assertEquals(properties.chargeCounting, decoded.chargeCounting); assertEquals(properties.maxCharges, decoded.maxCharges); + assertEquals(properties.interval, decoded.interval); } @Test @@ -131,6 +136,7 @@ void roundTripsThroughTheNetwork() { var properties = new PowerUpProperties( PowerUpProperties.ChargeCounting.COOLDOWN_RECHARGE, 9, + 6, 4, 2, List.of(UUID.fromString("f7e5b26e-9e4d-4b6f-9f7c-6c1f0f8f2a11")) @@ -143,6 +149,7 @@ void roundTripsThroughTheNetwork() { assertEquals(0, buf.readableBytes(), "the decoder left unread bytes behind"); assertEquals(properties.chargeCounting, decoded.chargeCounting, "charge counting"); assertEquals(properties.maxCharges, decoded.maxCharges, "max charges"); + assertEquals(properties.interval, decoded.interval, "interval"); assertEquals(properties.getChargeCount(), decoded.getChargeCount(), "charge count"); assertEquals( PowerUpProperties.CODEC.encodeStart(JsonOps.INSTANCE, properties).getOrThrow(),