diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/SuperMarioClient.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/SuperMarioClient.java index 300110c97..ff734ae2b 100644 --- a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/SuperMarioClient.java +++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/SuperMarioClient.java @@ -1,6 +1,7 @@ package fr.hugman.mubble.super_mario.client; import fr.hugman.mubble.super_mario.client.gui.screens.inventory.BumpableScreen; +import fr.hugman.mubble.super_mario.client.keybind.FreezeStruggleHandler; import fr.hugman.mubble.super_mario.client.model.SuperMarioModelLayers; import fr.hugman.mubble.super_mario.client.particle.SuperMarioParticleResources; import fr.hugman.mubble.super_mario.client.renderer.SuperMarioRenderPipelines; @@ -9,6 +10,7 @@ import com.google.common.reflect.Reflection; import fr.hugman.mubble.super_mario.world.inventory.SuperMarioMenuTypes; import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; import net.minecraft.client.gui.screens.MenuScreens; @@ -25,6 +27,7 @@ public void onInitializeClient() { SuperMarioRenderers.registerEntities(); SuperMarioRenderers.registerBlockEntities(); SuperMarioParticleResources.register(); + ClientTickEvents.END_CLIENT_TICK.register(FreezeStruggleHandler::tick); } private static void registerHandledScreens() { diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/keybind/FreezeStruggleHandler.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/keybind/FreezeStruggleHandler.java new file mode 100644 index 000000000..d17a94664 --- /dev/null +++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/keybind/FreezeStruggleHandler.java @@ -0,0 +1,45 @@ +package fr.hugman.mubble.super_mario.client.keybind; + +import fr.hugman.mubble.super_mario.network.protocol.common.custom.StruggleFreePayload; +import fr.hugman.mubble.super_mario.world.entity.freeze.Freezing; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; + +/** + * Lets a frozen player smash their way out of the ice a little sooner by hammering the movement + * keys. + *

+ * Only the presses themselves count, never the keys being held down: holding a direction is what a + * player does anyway when they run into the ice ball that froze them. + */ +@Environment(EnvType.CLIENT) +public class FreezeStruggleHandler { + public static void tick(Minecraft client) { + var player = client.player; + var options = client.options; + if (player == null) { + return; + } + + int presses = consumeClicks(options.keyUp) + consumeClicks(options.keyDown) + + consumeClicks(options.keyLeft) + consumeClicks(options.keyRight); + // the keys are consumed either way: a press held over from before the freeze is not a struggle + if (presses == 0 || !Freezing.isFrozen(player)) { + return; + } + for (int i = 0; i < presses; i++) { + ClientPlayNetworking.send(StruggleFreePayload.INSTANCE); + } + } + + private static int consumeClicks(KeyMapping key) { + int presses = 0; + while (key.consumeClick()) { + presses++; + } + return presses; + } +} diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/mixin/FrozenEntityRendererMixin.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/mixin/FrozenEntityRendererMixin.java new file mode 100644 index 000000000..a5c4118f9 --- /dev/null +++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/mixin/FrozenEntityRendererMixin.java @@ -0,0 +1,66 @@ +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.entity.state.FreezeRenderData; +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.state.EntityRenderState; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.phys.Vec3; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * Wraps a frozen entity in the block of ice holding it, and holds its animations still while it is + * in there. + */ +@Mixin(EntityRenderer.class) +@Environment(EnvType.CLIENT) +public class FrozenEntityRendererMixin { + @Inject(method = "extractRenderState(Lnet/minecraft/world/entity/Entity;Lnet/minecraft/client/renderer/entity/state/EntityRenderState;F)V", at = @At("TAIL")) + private void super_mario$extractFreeze(T entity, S state, float partialTicks, CallbackInfo ci) { + var freeze = FreezeRenderData.of(entity, partialTicks); + // set even when absent: render states are handed down from one entity to the next + state.setData(SuperMarioRenderStateDataKeys.FREEZE, freeze); + if (freeze != null) { + // winding the age back to what it was when the ice took hold stops everything driven by it + state.ageInTicks -= freeze.frozenFor(); + } + } + + /** + * Shakes a block of ice that is about to give. + *

+ * The offset is added here rather than around the ice cube below, because this is the one the + * whole entity is drawn from: the ice and whatever is caught inside it shudder as the one thing. + */ + @Inject(method = "getRenderOffset", at = @At("RETURN"), cancellable = true) + private void super_mario$rattleTheIce(S state, CallbackInfoReturnable cir) { + var freeze = state.getData(SuperMarioRenderStateDataKeys.FREEZE); + // most of a freeze is spent perfectly still, and that half is not worth a vector for + if (freeze != null && freeze.rattle() != Vec3.ZERO) { + cir.setReturnValue(cir.getReturnValue().add(freeze.rattle())); + } + } + + @Inject(method = "submit", at = @At("TAIL")) + private void super_mario$submitIceCube(S state, PoseStack poseStack, SubmitNodeCollector submitNodeCollector, CameraRenderState camera, CallbackInfo ci) { + var freeze = state.getData(SuperMarioRenderStateDataKeys.FREEZE); + if (freeze == null) { + return; + } + poseStack.pushPose(); + // the block model spans a whole block from the corner it is drawn at, hence the centering + poseStack.scale(state.boundingBoxWidth, state.boundingBoxHeight, state.boundingBoxWidth); + poseStack.translate(-0.5F, 0.0F, -0.5F); + submitNodeCollector.submitMovingBlock(poseStack, freeze.iceCube(), 0); + poseStack.popPose(); + } +} diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/mixin/FrozenLivingEntityRendererMixin.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/mixin/FrozenLivingEntityRendererMixin.java new file mode 100644 index 000000000..a5abcca5d --- /dev/null +++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/mixin/FrozenLivingEntityRendererMixin.java @@ -0,0 +1,65 @@ +package fr.hugman.mubble.super_mario.client.mixin; + +import com.llamalad7.mixinextras.injector.ModifyReturnValue; +import fr.hugman.mubble.super_mario.client.references.SuperMarioRenderStateDataKeys; +import fr.hugman.mubble.super_mario.client.renderer.SuperMarioRenderTypes; +import fr.hugman.mubble.super_mario.world.entity.freeze.FreezeSnapshot; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +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.world.entity.LivingEntity; +import org.jspecify.annotations.Nullable; +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.callback.CallbackInfo; + +/** + * Gives a frozen entity the colour of the ice it is caught in, and holds the one animation the age + * alone does not drive. + * + * @see FrozenEntityRendererMixin + */ +@Mixin(LivingEntityRenderer.class) +@Environment(EnvType.CLIENT) +public class FrozenLivingEntityRendererMixin> { + @Shadow + public Identifier getTextureLocation(final S state) { + return null; + } + + /** + * Puts the limbs back where they were the moment the ice took hold. + *

+ * Vanilla reads them off a walk animation that runs itself down as soon as the entity stops + * moving, so a mob frozen mid-stride would ease into a resting pose over the next half second. + */ + @Inject(method = "extractRenderState(Lnet/minecraft/world/entity/LivingEntity;Lnet/minecraft/client/renderer/entity/state/LivingEntityRenderState;F)V", at = @At("TAIL")) + private void super_mario$holdThePoseWhileFrozen(T entity, S state, float partialTicks, CallbackInfo ci) { + if (state.getData(SuperMarioRenderStateDataKeys.FREEZE) == null) { + return; + } + var snapshot = (FreezeSnapshot) entity; + state.walkAnimationPos = snapshot.frozenWalkPos(); + state.walkAnimationSpeed = snapshot.frozenWalkSpeed(); + } + + /** + * Draws a frozen entity through the ice shader, which remaps it onto the ice block's palette. + *

+ * Whatever vanilla settled on is kept when it decided not to draw the entity at all, so an + * invisible mob stays invisible in there. + */ + @ModifyReturnValue(method = "getRenderType", at = @At("RETURN")) + private @Nullable RenderType super_mario$iceRenderTypeWhileFrozen(@Nullable RenderType original, S state, boolean isBodyVisible, boolean forceTransparent, boolean appearGlowing) { + if (original == null || state.getData(SuperMarioRenderStateDataKeys.FREEZE) == null) { + return original; + } + return SuperMarioRenderTypes.getFrozenEntity(this.getTextureLocation(state)); + } +} 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 index a052f0975..15733e984 100644 --- 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 @@ -1,5 +1,6 @@ package fr.hugman.mubble.super_mario.client.references; +import fr.hugman.mubble.super_mario.client.renderer.entity.state.FreezeRenderData; import net.fabricmc.api.EnvType; import net.fabricmc.api.Environment; import net.fabricmc.fabric.api.client.rendering.v1.RenderStateDataKey; @@ -7,6 +8,9 @@ @Environment(EnvType.CLIENT) public class SuperMarioRenderStateDataKeys { + /** The block of ice around the entity, {@code null} whenever it is not frozen. */ + public static final RenderStateDataKey FREEZE = RenderStateDataKey.create(() -> "Freeze"); + /** 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"); diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/SuperMarioRenderPipelines.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/SuperMarioRenderPipelines.java index e6dc07222..34ada6031 100644 --- a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/SuperMarioRenderPipelines.java +++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/SuperMarioRenderPipelines.java @@ -23,4 +23,17 @@ public class SuperMarioRenderPipelines { .withCull(false) .build() ); + + // Same as GOLDEN_ENTITY_PIPELINE, over the ice block's palette instead + public static final RenderPipeline FROZEN_ENTITY_PIPELINE = RenderPipelines.register( + RenderPipeline.builder(RenderPipelines.ENTITY_SNIPPET) + .withLocation(SuperMario.id("pipeline/frozen_entity")) + .withShaderDefine("ALPHA_CUTOUT", 0.1F) + .withShaderDefine("PER_FACE_LIGHTING") + .withFragmentShader(SuperMario.id("core/frozen_entity")) + .withBindGroupLayout(BindGroupLayouts.SAMPLER1) + .withColorTargetState(new ColorTargetState(BlendFunction.TRANSLUCENT)) + .withCull(false) + .build() + ); } \ No newline at end of file diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/SuperMarioRenderTypes.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/SuperMarioRenderTypes.java index 3aec1f2b3..73fc3ec6b 100644 --- a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/SuperMarioRenderTypes.java +++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/SuperMarioRenderTypes.java @@ -22,7 +22,25 @@ public class SuperMarioRenderTypes { return RenderType.create("super_mario_golden_entity", state); }); + // Based on RenderTypes#ENTITY_TRANSLUCENT + public static final BiFunction FROZEN_ENTITY = Util.memoize( + (texture, affectsOutline) -> { + RenderSetup state = RenderSetup.builder(SuperMarioRenderPipelines.FROZEN_ENTITY_PIPELINE) + .withTexture("Sampler0", texture) + .useLightmap() + .useOverlay() + .affectsCrumbling() + .sortOnUpload() + .setOutline(affectsOutline ? RenderSetup.OutlineProperty.AFFECTS_OUTLINE : RenderSetup.OutlineProperty.NONE) + .createRenderSetup(); + return RenderType.create("super_mario_frozen_entity", state); + }); + public static RenderType getGoldenEntity(Identifier texture) { return GOLDEN_ENTITY.apply(texture, true); } + + public static RenderType getFrozenEntity(Identifier texture) { + return FROZEN_ENTITY.apply(texture, true); + } } \ No newline at end of file diff --git a/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/state/FreezeRenderData.java b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/state/FreezeRenderData.java new file mode 100644 index 000000000..3ccb1dd5c --- /dev/null +++ b/mubble-super_mario/src/client/java/fr/hugman/mubble/super_mario/client/renderer/entity/state/FreezeRenderData.java @@ -0,0 +1,79 @@ +package fr.hugman.mubble.super_mario.client.renderer.entity.state; + +import fr.hugman.mubble.super_mario.world.entity.freeze.Freezing; +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.renderer.block.MovingBlockRenderState; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * What the renderers need to know about the block of ice an entity is trapped in. + * + * @param frozenFor how long the entity has been frozen for, in ticks, interpolated within the tick. + * Subtracting it from an age gives the very same value on every single frame, which + * is what holds the animations of a frozen entity still. + * @param rattle how far the block of ice is off its resting place this frame, which is nothing at + * all until it is nearly out of time + * @param iceCube the ice block filling the entity hitbox, ready to be handed to the block renderer + */ +@Environment(EnvType.CLIENT) +public record FreezeRenderData(float frozenFor, Vec3 rattle, MovingBlockRenderState iceCube) { + /** How far the ice throws itself around, in blocks, by the time it is about to give. */ + private static final double RATTLE_AMPLITUDE = 0.06D; + /** How fast it does so, in radians per tick. Fast enough to read as a shudder rather than a sway. */ + private static final float RATTLE_FREQUENCY = 2.7F; + /** The two axes are run at different rates so that the shudder never settles into a straight line. */ + private static final float RATTLE_CROSS_FREQUENCY = 3.9F; + + /** + * @return what to render around the entity, or {@code null} when it is not frozen + */ + @Nullable + public static FreezeRenderData of(Entity entity, float partialTicks) { + var freeze = Freezing.getState(entity); + if (freeze == null) { + return null; + } + long gameTime = entity.level().getGameTime(); + + var iceCube = new MovingBlockRenderState(); + var pos = entity.blockPosition(); + iceCube.randomSeedPos = pos; + iceCube.blockPos = pos; + iceCube.blockState = Blocks.ICE.defaultBlockState(); + if (entity.level() instanceof ClientLevel level) { + iceCube.biome = level.getBiome(pos); + iceCube.cardinalLighting = level.cardinalLighting(); + iceCube.lightEngine = level.getLightEngine(); + } + + float frozenFor = freeze.elapsed(gameTime) + partialTicks; + return new FreezeRenderData(frozenFor, rattleOf(freeze.remaining(gameTime) - partialTicks, frozenFor), iceCube); + } + + /** + * Works out how hard the ice is shaking, which is the only warning anyone gets that it is about to + * let go. + * + * @param remaining how much of the freeze is left, in ticks, interpolated within the tick + * @param frozenFor how long the freeze has run for, in ticks, interpolated within the tick. It is + * what the shudder is driven off, so that it keeps going rather than restarting + * every frame. + */ + private static Vec3 rattleOf(float remaining, float frozenFor) { + if (remaining >= Freezing.RATTLE_DURATION) { + return Vec3.ZERO; + } + // it starts as a barely-there tremor and works itself up to the moment the ice gives + double amplitude = RATTLE_AMPLITUDE * (1.0D - Math.max(remaining, 0.0F) / Freezing.RATTLE_DURATION); + return new Vec3( + Mth.sin(frozenFor * RATTLE_FREQUENCY) * amplitude, + 0.0D, + Mth.sin(frozenFor * RATTLE_CROSS_FREQUENCY) * amplitude); + } +} diff --git a/mubble-super_mario/src/client/resources/assets/super_mario/shaders/core/frozen_entity.fsh b/mubble-super_mario/src/client/resources/assets/super_mario/shaders/core/frozen_entity.fsh new file mode 100644 index 000000000..6ff03d185 --- /dev/null +++ b/mubble-super_mario/src/client/resources/assets/super_mario/shaders/core/frozen_entity.fsh @@ -0,0 +1,80 @@ +#version 330 + +#moj_import +#moj_import + +uniform sampler2D Sampler0; + +in float sphericalVertexDistance; +in float cylindricalVertexDistance; + +#ifdef PER_FACE_LIGHTING +in vec4 vertexPerFaceColorBack; +in vec4 vertexPerFaceColorFront; +#else +in vec4 vertexColor; +#endif + +#ifndef EMISSIVE +in vec4 lightMapColor; +#endif + +#ifndef NO_OVERLAY +in vec4 overlayColor; +#endif + +in vec2 texCoord0; + +out vec4 fragColor; + +// Ice gradient (the whole palette of the ice block texture, from darkest to brightest) +const vec3 C1 = vec3(0.525, 0.682, 0.992); +const vec3 C2 = vec3(0.549, 0.702, 0.996); +const vec3 C3 = vec3(0.573, 0.725, 0.996); +const vec3 C4 = vec3(0.631, 0.765, 1.000); +const vec3 C5 = vec3(0.737, 0.831, 1.000); +const vec3 C6 = vec3(0.784, 0.863, 1.000); + +vec3 getIceGradient(float luma) { + float val = luma * 5.0; + + if (val < 1.0) return mix(C1, C2, val); + if (val < 2.0) return mix(C2, C3, val - 1.0); + if (val < 3.0) return mix(C3, C4, val - 2.0); + if (val < 4.0) return mix(C4, C5, val - 3.0); + return mix(C5, C6, val - 4.0); +} + +void main() { + vec4 color = texture(Sampler0, texCoord0); + + #ifdef ALPHA_CUTOUT + if (color.a < ALPHA_CUTOUT) { + discard; + } + #endif + + #ifdef PER_FACE_LIGHTING + vec4 geometryLight = gl_FrontFacing ? vertexPerFaceColorFront : vertexPerFaceColorBack; + #else + vec4 geometryLight = vertexColor; + #endif + + float luma = dot(color.rgb, vec3(0.299, 0.587, 0.114)); + + vec3 iceColor = getIceGradient(luma); + + color = vec4(iceColor, color.a); + + color *= geometryLight * ColorModulator; + + #ifndef NO_OVERLAY + color.rgb = mix(overlayColor.rgb, color.rgb, overlayColor.a); + #endif + + #ifndef EMISSIVE + color *= lightMapColor; + #endif + + fragColor = apply_fog(color, sphericalVertexDistance, cylindricalVertexDistance, FogEnvironmentalStart, FogEnvironmentalEnd, FogRenderDistanceStart, FogRenderDistanceEnd, FogColor); +} 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 82adf2de5..d10b8932a 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,8 @@ "compatibilityLevel": "JAVA_21", "client": [ "ClientPacketListenerMixin", + "FrozenEntityRendererMixin", + "FrozenLivingEntityRendererMixin", "HumanoidMobRendererMixin", "LivingEntityRendererMixin", "AvatarRendererMixin" diff --git a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioDamageTypeTagsProvider.java b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioDamageTypeTagsProvider.java index 5d823f44a..79a1032a8 100644 --- a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioDamageTypeTagsProvider.java +++ b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioDamageTypeTagsProvider.java @@ -24,8 +24,18 @@ protected TagAppender builder(TagKey tag) { @Override protected void addTags(HolderLookup.Provider wrapperLookup) { + // what an enderman blinks away from, and what projectile protection is worth anything against this.builder(DamageTypeTags.IS_PROJECTILE) - .add(SuperMarioDamageTypeIds.KOOPA_SHELL); + .add(SuperMarioDamageTypeIds.KOOPA_SHELL) + .add(SuperMarioDamageTypeIds.FIREBALL) + .add(SuperMarioDamageTypeIds.ICEBALL) + .add(SuperMarioDamageTypeIds.GOLD_FIREBALL); + + this.builder(SuperMarioDamageTypeTags.MELTS_FROZEN_ENTITIES) + // optional only because nothing here generates the vanilla tag for the validator to find + .addOptionalTag(DamageTypeTags.IS_FIRE) + .add(SuperMarioDamageTypeIds.FIREBALL) + .add(SuperMarioDamageTypeIds.GOLD_FIREBALL); this.builder(SuperMarioDamageTypeTags.INSTANT_KILLS_GOOMBAS) .add(SuperMarioDamageTypeIds.STOMP) diff --git a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioEnglishLangProvider.java b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioEnglishLangProvider.java index 098fac3a4..1753265ab 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 @@ -28,6 +28,15 @@ public void generateTranslations(HolderLookup.Provider wrapperLookup, Translatio builder.add("block." + SuperMario.MOD_ID + ".bumpable.drop.one", "Drop one"); builder.add("block." + SuperMario.MOD_ID + ".bumpable.drop.one.description", "The block will drop one item per bump"); + builder.add("commands." + SuperMario.MOD_ID + ".freeze.set.frozen", "Froze %s for %s ticks"); + builder.add("commands." + SuperMario.MOD_ID + ".freeze.set.thawed", "Thawed %s"); + builder.add("commands." + SuperMario.MOD_ID + ".freeze.set.not_frozen", "Nothing changed. That entity is not frozen"); + builder.add("commands." + SuperMario.MOD_ID + ".freeze.set.unfreezable", "Nothing changed. That entity cannot be frozen"); + builder.add("commands." + SuperMario.MOD_ID + ".freeze.set.frozen_endlessly", "Froze %s until further notice"); + builder.add("commands." + SuperMario.MOD_ID + ".freeze.query.frozen", "%s is frozen for %s more ticks"); + builder.add("commands." + SuperMario.MOD_ID + ".freeze.query.frozen_endlessly", "%s is frozen until further notice"); + builder.add("commands." + SuperMario.MOD_ID + ".freeze.query.thawed", "%s is not frozen"); + builder.add("power_up." + SuperMario.MOD_ID + ".mini.description.size", "Shrinks you to a third of your size."); builder.add("power_up." + SuperMario.MOD_ID + ".mini.description.trade_off", "Weaker, but a better jumper."); builder.add("power_up." + SuperMario.MOD_ID + ".mini.description.water", "Sprint off land to run on water."); diff --git a/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioEntityTypeTagsProvider.java b/mubble-super_mario/src/datagen/java/fr/hugman/mubble/super_mario/data/provider/SuperMarioEntityTypeTagsProvider.java index 4d1d4135f..e08cdb796 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 @@ -28,6 +28,9 @@ protected void addTags(HolderLookup.Provider wrapperLookup) { // 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 shrug an ice ball off; every other mob is judged on its bulk alone + builder(FREEZE_IMMUNE).add(ENDER_DRAGON, WITHER); + // 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); diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/SuperMario.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/SuperMario.java index b52eeda28..64676fb7c 100644 --- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/SuperMario.java +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/SuperMario.java @@ -1,13 +1,18 @@ package fr.hugman.mubble.super_mario; import com.google.common.reflect.Reflection; +import fr.hugman.mubble.super_mario.commands.SuperMarioCommands; import fr.hugman.mubble.super_mario.core.component.SuperMarioDataComponents; import fr.hugman.mubble.super_mario.core.particles.SuperMarioParticleTypes; +import fr.hugman.mubble.super_mario.core.attachment.SuperMarioAttachmentTypes; import fr.hugman.mubble.super_mario.core.registries.SuperMarioBuiltInRegistries; +import fr.hugman.mubble.super_mario.network.protocol.SuperMarioServerReceivers; +import fr.hugman.mubble.super_mario.network.protocol.common.custom.SuperMarioPayloadTypes; import fr.hugman.mubble.super_mario.sounds.SuperMarioSounds; import fr.hugman.mubble.super_mario.world.attribute.SuperMarioEnvironmentAttributes; import fr.hugman.mubble.super_mario.world.entity.SuperMarioEntityEvents; import fr.hugman.mubble.super_mario.world.entity.SuperMarioEntityTypes; +import fr.hugman.mubble.super_mario.world.entity.freeze.FreezeEvents; import fr.hugman.mubble.super_mario.world.inventory.SuperMarioMenuTypes; import fr.hugman.mubble.super_mario.world.item.SuperMarioCreativeModeTabs; import fr.hugman.mubble.super_mario.world.item.SuperMarioItems; @@ -40,6 +45,7 @@ public void onInitialize() { Reflection.initialize(SuperMarioParticleTypes.class); Reflection.initialize(SuperMarioEnvironmentAttributes.class); + Reflection.initialize(SuperMarioAttachmentTypes.class); SuperMarioEntityTypes.registerAttributes(); SuperMarioCreativeModeTabs.appendItemGroups(); @@ -50,8 +56,14 @@ public void onInitialize() { SuperMarioBiomeModifications.register(); + SuperMarioPayloadTypes.registerTypes(); + SuperMarioServerReceivers.register(); + + SuperMarioCommands.register(); + // Events SuperMarioEntityEvents.registerListeners(); + FreezeEvents.register(); } public static Identifier id(String path) { diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/commands/SuperMarioCommands.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/commands/SuperMarioCommands.java new file mode 100644 index 000000000..4f4366f1e --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/commands/SuperMarioCommands.java @@ -0,0 +1,10 @@ +package fr.hugman.mubble.super_mario.commands; + +import fr.hugman.mubble.super_mario.server.commands.FreezeCommand; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; + +public class SuperMarioCommands { + public static void register() { + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> FreezeCommand.register(dispatcher)); + } +} diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/core/attachment/SuperMarioAttachmentTypes.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/core/attachment/SuperMarioAttachmentTypes.java new file mode 100644 index 000000000..a2be6b417 --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/core/attachment/SuperMarioAttachmentTypes.java @@ -0,0 +1,20 @@ +package fr.hugman.mubble.super_mario.core.attachment; + +import fr.hugman.mubble.super_mario.SuperMario; +import fr.hugman.mubble.super_mario.world.entity.freeze.FreezeState; +import net.fabricmc.fabric.api.attachment.v1.AttachmentRegistry; +import net.fabricmc.fabric.api.attachment.v1.AttachmentSyncPredicate; +import net.fabricmc.fabric.api.attachment.v1.AttachmentType; + +public class SuperMarioAttachmentTypes { + /** + * The block of ice an entity is trapped in, absent as long as it is not frozen. + *

+ * It is synced to every client and not only to the frozen entity itself: whoever is looking at it + * has to see the ice cube around it, and the frozen entity is very much not the only one looking. + */ + public static final AttachmentType FREEZE = AttachmentRegistry.builder() + .persistent(FreezeState.CODEC) + .syncWith(FreezeState.STREAM_CODEC, AttachmentSyncPredicate.all()) + .buildAndRegister(SuperMario.id("freeze")); +} diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/mixin/FrozenEntityMixin.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/mixin/FrozenEntityMixin.java new file mode 100644 index 000000000..6b58b0bb5 --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/mixin/FrozenEntityMixin.java @@ -0,0 +1,60 @@ +package fr.hugman.mubble.super_mario.mixin; + +import fr.hugman.mubble.super_mario.world.entity.freeze.Freezing; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.world.entity.Entity; +import org.jspecify.annotations.Nullable; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * Turns whatever is trapped in a block of ice into the block of ice it looks like. + * + * @see Freezing + */ +@Mixin(Entity.class) +public class FrozenEntityMixin { + @Inject(method = "tick", at = @At("HEAD")) + private void super_mario$tickFreeze(CallbackInfo ci) { + Freezing.tick((Entity) (Object) this); + } + + /** + * Makes a frozen entity as solid as the ice around it, so that others can walk into it and stand + * on top of it. + */ + @Inject(method = "canBeCollidedWith", at = @At("HEAD"), cancellable = true) + private void super_mario$collideWhileFrozen(@Nullable Entity other, CallbackInfoReturnable cir) { + if (Freezing.isFrozen((Entity) (Object) this)) { + cir.setReturnValue(true); + } + } + + /** + * Muffles a frozen entity, whether it is growling, hurting or walking into a wall. + *

+ * It is caught in the one method every sound an entity makes of its own accord goes through, + * rather than in {@code isSilent()} right below it: that flag is written back out when the entity + * is saved, and a mob that happened to be frozen at the time would come back mute for good. + */ + @Inject(method = "playSound(Lnet/minecraft/sounds/SoundEvent;FF)V", at = @At("HEAD"), cancellable = true) + private void super_mario$muteWhileFrozen(SoundEvent sound, float volume, float pitch, CallbackInfo ci) { + if (Freezing.isFrozen((Entity) (Object) this)) { + ci.cancel(); + } + } + + /** + * Keeps a frozen entity from catching fire. Putting one out is left to + * {@link Freezing#freezeFor}, which does it the moment the ice takes hold. + */ + @Inject(method = "setRemainingFireTicks", at = @At("HEAD"), cancellable = true) + private void super_mario$stayUnburntWhileFrozen(int ticks, CallbackInfo ci) { + if (ticks > 0 && Freezing.isFrozen((Entity) (Object) this)) { + ci.cancel(); + } + } +} diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/mixin/FrozenLivingEntityMixin.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/mixin/FrozenLivingEntityMixin.java new file mode 100644 index 000000000..1643e07b4 --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/mixin/FrozenLivingEntityMixin.java @@ -0,0 +1,111 @@ +package fr.hugman.mubble.super_mario.mixin; + +import com.llamalad7.mixinextras.injector.ModifyExpressionValue; +import fr.hugman.mubble.super_mario.world.entity.freeze.FreezeSnapshot; +import fr.hugman.mubble.super_mario.world.entity.freeze.Freezing; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.level.block.Blocks; +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; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +/** + * Everything a block of ice changes about a living entity: the say it has over where it goes, the + * pose it holds while it is in there, what reaches it through the ice — and the footing it gives + * whoever climbs on top of it. + * + * @see Freezing + */ +@Mixin(LivingEntity.class) +public class FrozenLivingEntityMixin implements FreezeSnapshot { + @Unique + private float super_mario$frozenWalkPos; + @Unique + private float super_mario$frozenWalkSpeed; + + @Inject(method = "tick", at = @At("HEAD")) + private void super_mario$rememberThePose(CallbackInfo ci) { + LivingEntity this_ = (LivingEntity) (Object) this; + if (Freezing.isFrozen(this_)) { + return; + } + this.super_mario$frozenWalkPos = this_.walkAnimation.position(); + this.super_mario$frozenWalkSpeed = this_.walkAnimation.speed(); + } + + @Override + public float frozenWalkPos() { + return this.super_mario$frozenWalkPos; + } + + @Override + public float frozenWalkSpeed() { + return this.super_mario$frozenWalkSpeed; + } + + @Inject(method = "isImmobile", at = @At("HEAD"), cancellable = true) + private void super_mario$immobileWhileFrozen(CallbackInfoReturnable cir) { + if (Freezing.isFrozen((LivingEntity) (Object) this)) { + cir.setReturnValue(true); + } + } + + /** + * Hands a frozen entity over to the ice physics, in place of the walking, swimming and flying it + * would otherwise be doing. + */ + @Inject(method = "travel", at = @At("HEAD"), cancellable = true) + private void super_mario$travelWhileFrozen(Vec3 input, CallbackInfo ci) { + LivingEntity this_ = (LivingEntity) (Object) this; + if (Freezing.isFrozen(this_)) { + Freezing.travelFrozen(this_); + ci.cancel(); + } + } + + /** + * Keeps a punch from lifting a block of ice off the floor: it is sent skidding along it instead. + *

+ * The lift is the one thing vanilla only adds to a knockback when the target is standing on + * something, so telling it the ice is mid-air leaves the horizontal shove untouched and the + * vertical speed exactly as it was. + */ + @ModifyExpressionValue( + method = "knockback(DDDLnet/minecraft/world/damagesource/DamageSource;FZ)V", + at = @At(value = "INVOKE", target = "Lnet/minecraft/world/entity/LivingEntity;onGround()Z")) + private boolean super_mario$noLiftWhileFrozen(boolean onGround) { + return onGround && !Freezing.isFrozen((LivingEntity) (Object) this); + } + + @Inject(method = "hurtServer", at = @At("HEAD")) + private void super_mario$shieldWhileFrozen(ServerLevel level, DamageSource source, float amount, CallbackInfoReturnable cir) { + Freezing.absorb(level, (LivingEntity) (Object) this, source, amount); + } + + @Inject(method = "isInvulnerableTo", at = @At("HEAD"), cancellable = true) + private void super_mario$shieldedWhileFrozen(ServerLevel level, DamageSource source, CallbackInfoReturnable cir) { + if (Freezing.shields((LivingEntity) (Object) this, source)) { + cir.setReturnValue(true); + } + } + + /** + * Gives whoever climbs on top of a block of ice the footing of one. + *

+ * Friction is a property of the block underfoot, and there is no block underfoot here: standing on + * an entity leaves vanilla reading the air below it and handing out ordinary ground. Reading the + * ice off the entity instead is what makes the top of a frozen mob as slippery as it looks. + */ + @ModifyExpressionValue( + method = "travelInAir", + at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/block/Block;getFriction()F")) + private float super_mario$slipperyOnTopOfIce(float friction) { + return Freezing.isStandingOnFrozen((LivingEntity) (Object) this) ? Blocks.ICE.getFriction() : friction; + } +} diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/network/protocol/SuperMarioServerReceivers.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/network/protocol/SuperMarioServerReceivers.java new file mode 100644 index 000000000..5fd3b3cb9 --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/network/protocol/SuperMarioServerReceivers.java @@ -0,0 +1,12 @@ +package fr.hugman.mubble.super_mario.network.protocol; + +import fr.hugman.mubble.super_mario.network.protocol.common.custom.SuperMarioPayloadTypes; +import fr.hugman.mubble.super_mario.world.entity.freeze.Freezing; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; + +public class SuperMarioServerReceivers { + public static void register() { + ServerPlayNetworking.registerGlobalReceiver(SuperMarioPayloadTypes.STRUGGLE_FREE, (payload, context) -> + context.server().execute(() -> Freezing.struggle(context.player()))); + } +} diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/network/protocol/common/custom/StruggleFreePayload.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/network/protocol/common/custom/StruggleFreePayload.java new file mode 100644 index 000000000..32daa8143 --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/network/protocol/common/custom/StruggleFreePayload.java @@ -0,0 +1,19 @@ +package fr.hugman.mubble.super_mario.network.protocol.common.custom; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; + +/** + * Sent every time a frozen player smashes one of their movement keys, to melt a bit of the ice they + * are stuck in. + */ +public class StruggleFreePayload implements CustomPacketPayload { + public static final StruggleFreePayload INSTANCE = new StruggleFreePayload(); + public static final StreamCodec STREAM_CODEC = StreamCodec.unit(INSTANCE); + + @Override + public Type type() { + return SuperMarioPayloadTypes.STRUGGLE_FREE; + } +} diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/network/protocol/common/custom/SuperMarioPayloadTypes.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/network/protocol/common/custom/SuperMarioPayloadTypes.java new file mode 100644 index 000000000..ec8c38b89 --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/network/protocol/common/custom/SuperMarioPayloadTypes.java @@ -0,0 +1,17 @@ +package fr.hugman.mubble.super_mario.network.protocol.common.custom; + +import fr.hugman.mubble.super_mario.SuperMario; +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; + +public class SuperMarioPayloadTypes { + public static final CustomPacketPayload.Type STRUGGLE_FREE = of("freeze/struggle"); + + public static CustomPacketPayload.Type of(String path) { + return new CustomPacketPayload.Type<>(SuperMario.id(path)); + } + + public static void registerTypes() { + PayloadTypeRegistry.serverboundPlay().register(SuperMarioPayloadTypes.STRUGGLE_FREE, StruggleFreePayload.STREAM_CODEC); + } +} diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/server/commands/FreezeCommand.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/server/commands/FreezeCommand.java new file mode 100644 index 000000000..40469927c --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/server/commands/FreezeCommand.java @@ -0,0 +1,101 @@ +package fr.hugman.mubble.super_mario.server.commands; + +import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.mojang.brigadier.exceptions.SimpleCommandExceptionType; +import fr.hugman.mubble.super_mario.SuperMario; +import fr.hugman.mubble.super_mario.world.entity.freeze.Freezing; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; + +/** + * {@code /freeze}, which puts an entity in a block of ice and also queries frozen states. + * + * @see Freezing + */ +public class FreezeCommand { + public static final String FREEZE = "freeze"; + + public static final String TARGET_ARG = "target"; + public static final String SET_ARG = "set"; + public static final String QUERY_ARG = "query"; + public static final String TICKS_ARG = "ticks"; + public static final String INFINITE_ARG = "infinite"; + + /** Stands in for a tick count on a freeze that never runs out on its own. */ + private static final int INFINITE_TICKS = -1; + + private static final SimpleCommandExceptionType UNFREEZABLE_EXCEPTION = new SimpleCommandExceptionType( + Component.translatable("commands." + SuperMario.MOD_ID + ".freeze.set.unfreezable") + ); + private static final SimpleCommandExceptionType NOT_FROZEN_EXCEPTION = new SimpleCommandExceptionType( + Component.translatable("commands." + SuperMario.MOD_ID + ".freeze.set.not_frozen") + ); + + public static void register(CommandDispatcher dispatcher) { + dispatcher.register(Commands.literal(FREEZE) + .requires(Commands.hasPermission(Commands.LEVEL_GAMEMASTERS)) + .then(Commands.literal(SET_ARG) + .then(Commands.argument(TARGET_ARG, EntityArgument.entity()) + .then(Commands.argument(TICKS_ARG, IntegerArgumentType.integer(0)) + .executes(cc -> setFrozen(cc, IntegerArgumentType.getInteger(cc, TICKS_ARG)))) + .then(Commands.literal(INFINITE_ARG) + .executes(cc -> setFrozen(cc, INFINITE_TICKS))))) + .then(Commands.literal(QUERY_ARG) + .then(Commands.argument(TARGET_ARG, EntityArgument.entity()) + .executes(cc -> queryFrozen(cc.getSource(), EntityArgument.getEntity(cc, TARGET_ARG)))))); + } + + private static int setFrozen(CommandContext cc, int ticks) throws CommandSyntaxException { + CommandSourceStack source = cc.getSource(); + Entity target = EntityArgument.getEntity(cc, TARGET_ARG); + // the target's own level, rather than the source's: the two part ways across dimensions + ServerLevel level = (ServerLevel) target.level(); + + if (ticks == 0) { + if (!Freezing.thaw(level, target)) { + throw NOT_FROZEN_EXCEPTION.create(); + } + source.sendSuccess(() -> Component.translatable("commands." + SuperMario.MOD_ID + ".freeze.set.thawed", target.getDisplayName()), true); + return 1; + } + + // the ice has nothing to hold on to on anything else, and bosses shatter it outright + if (Freezing.isUnfreezable(target)) { + throw UNFREEZABLE_EXCEPTION.create(); + } + LivingEntity living = (LivingEntity) target; + + if (ticks == INFINITE_TICKS) { + Freezing.freezeEndlessly(level, living); + source.sendSuccess(() -> Component.translatable("commands." + SuperMario.MOD_ID + ".freeze.set.frozen_endlessly", target.getDisplayName()), true); + return 1; + } + + Freezing.freezeFor(level, living, ticks); + source.sendSuccess(() -> Component.translatable("commands." + SuperMario.MOD_ID + ".freeze.set.frozen", target.getDisplayName(), ticks), true); + return 1; + } + + private static int queryFrozen(CommandSourceStack source, Entity target) { + var state = Freezing.getState(target); + if (state != null && state.isEndless()) { + source.sendSuccess(() -> Component.translatable("commands." + SuperMario.MOD_ID + ".freeze.query.frozen_endlessly", target.getDisplayName()), false); + return 1; + } + int remaining = Freezing.getRemainingTicks(target); + if (remaining <= 0) { + source.sendSuccess(() -> Component.translatable("commands." + SuperMario.MOD_ID + ".freeze.query.thawed", target.getDisplayName()), false); + return 0; + } + source.sendSuccess(() -> Component.translatable("commands." + SuperMario.MOD_ID + ".freeze.query.frozen", target.getDisplayName(), remaining), false); + return 1; + } +} diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/tags/SuperMarioDamageTypeTags.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/tags/SuperMarioDamageTypeTags.java index 47c45594e..15eeb9afe 100644 --- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/tags/SuperMarioDamageTypeTags.java +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/tags/SuperMarioDamageTypeTags.java @@ -8,6 +8,8 @@ public class SuperMarioDamageTypeTags { public static final TagKey INSTANT_KILLS_GOOMBAS = bind("instant_kills_goombas"); + public static final TagKey MELTS_FROZEN_ENTITIES = bind("melts_frozen_entities"); + private static TagKey bind(String path) { return TagKey.create(Registries.DAMAGE_TYPE, SuperMario.id(path)); } diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/tags/SuperMarioEntityTypeTags.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/tags/SuperMarioEntityTypeTags.java index c411154fa..17978338b 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,8 @@ public class SuperMarioEntityTypeTags { public static final TagKey> CAN_STOMP = bind("can_stomp"); public static final TagKey> STOMPABLE = bind("stompable"); + public static final TagKey> FREEZE_IMMUNE = bind("freeze_immune"); + 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"); diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeEvents.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeEvents.java new file mode 100644 index 000000000..aafcb2ded --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeEvents.java @@ -0,0 +1,38 @@ +package fr.hugman.mubble.super_mario.world.entity.freeze; + +import net.fabricmc.fabric.api.event.player.AttackBlockCallback; +import net.fabricmc.fabric.api.event.player.AttackEntityCallback; +import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents; +import net.fabricmc.fabric.api.event.player.UseBlockCallback; +import net.fabricmc.fabric.api.event.player.UseEntityCallback; +import net.fabricmc.fabric.api.event.player.UseItemCallback; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.Entity; + +/** + * Cuts off everything a frozen player could otherwise reach out and do. + *

+ * Being unable to move is not much of a punishment for someone who can still mine the block in front + * of them, so the whole of mining, placing, using and hitting goes with it. The callbacks fire on the + * client as much as on the server, which is what keeps a frozen player from watching a block crack + * open on their own screen before the server tells them otherwise. + * + * @see Freezing + */ +public final class FreezeEvents { + private FreezeEvents() { + } + + public static void register() { + AttackBlockCallback.EVENT.register((player, level, hand, pos, direction) -> refuseWhileFrozen(player)); + PlayerBlockBreakEvents.BEFORE.register((level, player, pos, state, blockEntity) -> !Freezing.isFrozen(player)); + UseBlockCallback.EVENT.register((player, level, hand, hitResult) -> refuseWhileFrozen(player)); + UseItemCallback.EVENT.register((player, level, hand) -> refuseWhileFrozen(player)); + AttackEntityCallback.EVENT.register((player, level, hand, entity, hitResult) -> refuseWhileFrozen(player)); + UseEntityCallback.EVENT.register((player, level, hand, entity, hitResult) -> refuseWhileFrozen(player)); + } + + private static InteractionResult refuseWhileFrozen(Entity player) { + return Freezing.isFrozen(player) ? InteractionResult.FAIL : InteractionResult.PASS; + } +} diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeResistance.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeResistance.java new file mode 100644 index 000000000..7b746d02a --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeResistance.java @@ -0,0 +1,14 @@ +package fr.hugman.mubble.super_mario.world.entity.freeze; + +/** + * How well an entity holds up against being frozen, which is what tells apart the three outcomes an + * ice ball can have on it. + */ +public enum FreezeResistance { + /** Trapped for the full duration. */ + NONE, + /** Big enough to crack the ice open well before it melts. */ + TOUGH, + /** Not to be trapped at all: the ice shatters on impact and leaves nothing behind. */ + IMMUNE +} diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeSnapshot.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeSnapshot.java new file mode 100644 index 000000000..5dda3d6d8 --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeSnapshot.java @@ -0,0 +1,14 @@ +package fr.hugman.mubble.super_mario.world.entity.freeze; + +/** + * The pose an entity was caught in, implemented by every living entity through the mixin on + * {@code LivingEntity}. The walk animation keeps running down to a standstill however immobile the + * entity is, so the limbs are read back from here rather than from it. + */ +public interface FreezeSnapshot { + /** @return how far into its walk cycle the entity was when it froze */ + float frozenWalkPos(); + + /** @return how wide the entity was swinging its limbs when it froze */ + float frozenWalkSpeed(); +} diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeState.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeState.java new file mode 100644 index 000000000..2901722e0 --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/FreezeState.java @@ -0,0 +1,76 @@ +package fr.hugman.mubble.super_mario.world.entity.freeze; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import io.netty.buffer.ByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; + +/** + * The block of ice an entity is trapped in, attached to it for as long as it lasts. + *

+ * Both ends are stored as absolute game times rather than as a countdown, so that the whole freeze + * only has to be sent to the clients once: they hold the very same clock and can work out on their + * own how far along it is on any given frame. A countdown would have to be synced every single tick. + * + * @param startedAt the game time the entity was frozen at + * @param endsAt the game time the entity thaws at, unless it breaks free sooner, or + * {@link #NEVER} for a freeze that never runs out on its own + */ +public record FreezeState(long startedAt, long endsAt) { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.LONG.fieldOf("started_at").forGetter(FreezeState::startedAt), + Codec.LONG.fieldOf("ends_at").forGetter(FreezeState::endsAt) + ).apply(instance, FreezeState::new)); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.VAR_LONG, FreezeState::startedAt, + ByteBufCodecs.VAR_LONG, FreezeState::endsAt, + FreezeState::new); + + /** An {@link #endsAt} that never arrives, for a freeze only a command or fire can end. */ + public static final long NEVER = Long.MAX_VALUE; + + public static FreezeState lasting(long gameTime, int ticks) { + return new FreezeState(gameTime, gameTime + ticks); + } + + public static FreezeState endless(long gameTime) { + return new FreezeState(gameTime, NEVER); + } + + public boolean isEndless() { + return this.endsAt == NEVER; + } + + /** @return how long the entity has been frozen for, in ticks, never below zero */ + public int elapsed(long gameTime) { + return (int) Math.max(gameTime - this.startedAt, 0L); + } + + /** + * @return how much longer the entity stays frozen, in ticks, never below zero, and + * {@link Integer#MAX_VALUE} for an endless freeze + */ + public int remaining(long gameTime) { + if (this.isEndless()) { + return Integer.MAX_VALUE; + } + return (int) Math.max(this.endsAt - gameTime, 0L); + } + + public boolean hasExpired(long gameTime) { + return !this.isEndless() && gameTime >= this.endsAt; + } + + /** + * @return the same freeze, cut short by {@code ticks}, never ending before it started. An + * endless freeze is returned untouched: nothing chips away at one, it is called off or it lasts. + */ + public FreezeState shortenedBy(int ticks) { + if (this.isEndless()) { + return this; + } + return new FreezeState(this.startedAt, Math.max(this.endsAt - ticks, this.startedAt)); + } +} diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/Freezing.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/Freezing.java new file mode 100644 index 000000000..b21db6092 --- /dev/null +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/freeze/Freezing.java @@ -0,0 +1,385 @@ +package fr.hugman.mubble.super_mario.world.entity.freeze; + +import fr.hugman.mubble.super_mario.core.attachment.SuperMarioAttachmentTypes; +import fr.hugman.mubble.super_mario.tags.SuperMarioDamageTypeTags; +import fr.hugman.mubble.super_mario.tags.SuperMarioEntityTypeTags; +import net.minecraft.core.Direction; +import net.minecraft.core.particles.BlockParticleOption; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.network.protocol.game.ClientboundSetEntityMotionPacket; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.tags.DamageTypeTags; +import net.minecraft.tags.FluidTags; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySelector; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.Nullable; + +/** + * Everything about entities trapped in a block of ice: freezing them, keeping them there, shoving + * them around and letting them out. + *

+ * A frozen entity is one carrying a {@link FreezeState} attachment and nothing else, which is what + * makes any living entity freezable without each of them having to know about it. + * + * @see FreezeState + */ +public final class Freezing { + /** How long a regular entity stays trapped, in ticks. */ + public static final int DURATION = 260; + /** How long a {@link FreezeResistance#TOUGH} entity stays trapped, in ticks. */ + public static final int TOUGH_DURATION = 80; + /** How much of the remaining freeze a single struggle from a frozen player melts away, in ticks. */ + public static final int STRUGGLE_RELIEF = 15; + /** How much of the remaining freeze a single point of damage melts away, in ticks. */ + public static final int MELT_PER_DAMAGE = 20; + /** How long the ice is left alone after a hit, in ticks, so that nothing grinds it away at once. */ + public static final int CRACK_COOLDOWN = 10; + /** How long before the end the block of ice starts rattling, in ticks. */ + public static final int RATTLE_DURATION = 40; + + /** Hitbox volume, in cubic blocks, from which an entity counts as big: above a horse, below an iron golem. */ + public static final double BIG_HITBOX_VOLUME = 2.0D; + + /** Horizontal speed a shoved block of ice sets off at, in blocks per tick. */ + public static final double SLIDE_SPEED = 0.4D; + /** Horizontal speed, in blocks per tick, from which running into a wall shatters the ice outright. */ + public static final double SHATTER_SPEED = 0.25D; + /** How much horizontal speed a sliding block of ice keeps every tick while on the ground. */ + private static final double GROUND_DRAG = 0.94D; + /** How much horizontal speed a falling block of ice keeps every tick. */ + private static final double AIR_DRAG = 0.98D; + /** How much horizontal speed a block of ice keeps every tick while it is in water. */ + private static final double WATER_DRAG = 0.8D; + /** How much vertical speed a block of ice keeps every tick while it is in water. */ + private static final double WATER_BOB_DRAG = 0.7D; + /** + * How much of a floating block of ice comes to rest below the waterline, as a fraction of its + * height. + *

+ * Real ice rides with almost all of itself under, which would leave whoever is inside it out of + * air. Sitting this high keeps their head clear and the cube plainly in view. + */ + private static final double FLOAT_SUBMERSION = 0.6D; + /** Horizontal speeds below this are rounded down to a standstill, so that ice does not creep. */ + private static final double SLIDE_EPSILON = 1.0e-3D; + /** How far around the block of ice a player counts as pushing it: being solid, it is never overlapped. */ + private static final double PUSH_REACH = 0.2D; + /** How far below the top of the ice a player has to stand to shove it rather than ride it. */ + private static final double PUSH_HEADROOM = 0.1D; + /** How far below its feet an entity looks for the block of ice it might be standing on. */ + private static final double STANDING_REACH = 1.0e-3D; + + private static final int THAW_PARTICLE_COUNT = 24; + private static final double THAW_PARTICLE_SPEED = 0.15D; + private static final int CRACK_PARTICLE_COUNT = 6; + private static final double CRACK_PARTICLE_SPEED = 0.05D; + + private Freezing() { + } + + @Nullable + public static FreezeState getState(Entity entity) { + return entity.getAttached(SuperMarioAttachmentTypes.FREEZE); + } + + public static boolean isFrozen(Entity entity) { + return getState(entity) != null; + } + + /** + * @return how much longer the entity stays frozen, in ticks, or {@code 0} when it is not frozen + */ + public static int getRemainingTicks(Entity entity) { + var state = getState(entity); + return state == null ? 0 : state.remaining(entity.level().getGameTime()); + } + + /** + * Whether no block of ice can hold this entity, whatever put it there — a command included. + *

+ * Narrower than {@link #resistanceOf}: a creative player shrugs an ice ball off but can still be + * frozen by hand, so creative is not in here. + */ + public static boolean isUnfreezable(Entity entity) { + return !(entity instanceof LivingEntity) + || entity.isSpectator() + || entity.is(SuperMarioEntityTypeTags.FREEZE_IMMUNE); + } + + /** How well the entity holds up against being frozen by an ice ball. */ + public static FreezeResistance resistanceOf(Entity entity) { + if (isUnfreezable(entity)) { + return FreezeResistance.IMMUNE; + } + // a creative player is busy building, and is not there to be caught out by a stray ice ball + if (entity instanceof Player player && player.isCreative()) { + return FreezeResistance.IMMUNE; + } + return isBig(entity) ? FreezeResistance.TOUGH : FreezeResistance.NONE; + } + + /** Whether the entity is standing on top of a block of ice someone else is trapped in. */ + public static boolean isStandingOnFrozen(Entity entity) { + // the sweep is not free, so it is kept behind the cheap tell: on the ground with no block holding it up + if (!entity.onGround() || entity.mainSupportingBlockPos.isPresent()) { + return false; + } + var feet = entity.getBoundingBox(); + var underfoot = new AABB(feet.minX, feet.minY - STANDING_REACH, feet.minZ, feet.maxX, feet.minY, feet.maxZ); + return !entity.level().getEntities(entity, underfoot, Freezing::isFrozen).isEmpty(); + } + + public static boolean isBig(Entity entity) { + double width = entity.getBbWidth(); + return width * width * entity.getBbHeight() >= BIG_HITBOX_VOLUME; + } + + /** @return how long the entity would stay trapped, in ticks, were it frozen right now */ + public static int durationFor(Entity entity) { + return resistanceOf(entity) == FreezeResistance.TOUGH ? TOUGH_DURATION : DURATION; + } + + /** + * Traps an entity in a block of ice, unless it is one of those nothing can hold. The freeze + * itself costs no health, on the way in or on the way out. + * + * @return how the entity took it, which tells whether it ended up frozen at all + */ + public static FreezeResistance freeze(ServerLevel level, LivingEntity entity) { + var resistance = resistanceOf(entity); + if (resistance == FreezeResistance.IMMUNE) { + return resistance; + } + freezeFor(level, entity, resistance == FreezeResistance.TOUGH ? TOUGH_DURATION : DURATION); + return resistance; + } + + /** Traps an entity in a block of ice for a set number of ticks, whatever it is. */ + public static void freezeFor(ServerLevel level, LivingEntity entity, int ticks) { + freezeWith(level, entity, FreezeState.lasting(level.getGameTime(), ticks)); + } + + /** Traps an entity in a block of ice that never runs out on its own. */ + public static void freezeEndlessly(ServerLevel level, LivingEntity entity) { + freezeWith(level, entity, FreezeState.endless(level.getGameTime())); + } + + private static void freezeWith(ServerLevel level, LivingEntity entity, FreezeState state) { + entity.setAttached(SuperMarioAttachmentTypes.FREEZE, state); + entity.setDeltaMovement(Vec3.ZERO); + entity.clearFire(); + level.playSound(null, entity.getX(), entity.getY(), entity.getZ(), SoundEvents.GLASS_PLACE, SoundSource.NEUTRAL, 0.8F, 1.2F); + } + + /** + * Ticks the freeze of a single entity, thawing it once its time is up. Server side only: the + * clients hold the same {@link FreezeState} and work out where it is at on their own. + */ + public static void tick(Entity entity) { + var state = getState(entity); + if (state == null || !(entity.level() instanceof ServerLevel level)) { + return; + } + // an entity that has since turned unfreezable — a player gone to spectator, say — is let out + if (state.hasExpired(level.getGameTime()) || !entity.isAlive() || isUnfreezable(entity)) { + thaw(level, entity); + return; + } + shoveAroundBy(level, entity); + } + + /** @return whether the entity was frozen in the first place */ + public static boolean thaw(ServerLevel level, Entity entity) { + if (entity.removeAttached(SuperMarioAttachmentTypes.FREEZE) == null) { + return false; + } + level.playSound(null, entity.getX(), entity.getY(), entity.getZ(), SoundEvents.GLASS_BREAK, SoundSource.NEUTRAL, 0.8F, 1.2F); + level.sendParticles(new BlockParticleOption(ParticleTypes.BLOCK, Blocks.ICE.defaultBlockState()), + entity.getX(), entity.getY(0.5D), entity.getZ(), + THAW_PARTICLE_COUNT, + entity.getBbWidth() / 2.0D, entity.getBbHeight() / 2.0D, entity.getBbWidth() / 2.0D, + THAW_PARTICLE_SPEED); + return true; + } + + /** + * Whether the block of ice takes this hit in place of whoever is inside it. It takes everything + * but fire, which melts it, and what nothing is ever safe from — the void and {@code /kill}. + */ + public static boolean shields(Entity entity, DamageSource source) { + return isFrozen(entity) + && !source.is(SuperMarioDamageTypeTags.MELTS_FROZEN_ENTITIES) + && !source.is(DamageTypeTags.BYPASSES_INVULNERABILITY); + } + + /** + * Puts a hit into the block of ice rather than into whoever is inside it. A hit that empties the + * ice does not thaw it here — the entity has to still count as frozen for the rest of this hit + * to be turned away, so {@link #tick} lets it out on the next tick. + */ + public static void absorb(ServerLevel level, Entity entity, DamageSource source, float amount) { + var state = getState(entity); + if (state == null) { + return; + } + if (source.is(SuperMarioDamageTypeTags.MELTS_FROZEN_ENTITIES)) { + // thawed right away, so that the fire that broke the ice still reaches what was inside it + thaw(level, entity); + return; + } + if (source.is(DamageTypeTags.BYPASSES_INVULNERABILITY) || entity.invulnerableTime > CRACK_COOLDOWN) { + return; + } + entity.invulnerableTime = CRACK_COOLDOWN * 2; + entity.setAttached(SuperMarioAttachmentTypes.FREEZE, state.shortenedBy(Math.max((int) (amount * MELT_PER_DAMAGE), 1))); + level.playSound(null, entity.getX(), entity.getY(), entity.getZ(), SoundEvents.GLASS_HIT, SoundSource.NEUTRAL, 0.9F, 1.4F); + level.sendParticles(new BlockParticleOption(ParticleTypes.BLOCK, Blocks.ICE.defaultBlockState()), + entity.getX(), entity.getY(0.5D), entity.getZ(), + CRACK_PARTICLE_COUNT, + entity.getBbWidth() / 2.0D, entity.getBbHeight() / 2.0D, entity.getBbWidth() / 2.0D, + CRACK_PARTICLE_SPEED); + shoveAwayFrom(entity, source); + } + + /** + * Sends the block of ice skidding away from whatever just hit it. Vanilla knocks back only once a + * hit has landed, and a hit the ice turns away never does, so the shove is dealt out here. + */ + private static void shoveAwayFrom(Entity entity, DamageSource source) { + var from = source.getSourcePosition(); + // a hit with nowhere to come from — drowning, starvation — says nothing about where to send it + if (from != null) { + shove(entity, entity.position().subtract(from)); + } + } + + /** + * Melts a slice off the remaining freeze, which is what a frozen player smashing their movement + * keys buys them. + * + * @return whether the entity was frozen in the first place + */ + public static boolean struggle(Entity entity) { + var state = getState(entity); + if (state == null) { + return false; + } + entity.setAttached(SuperMarioAttachmentTypes.FREEZE, state.shortenedBy(STRUGGLE_RELIEF)); + return true; + } + + /** + * Moves a frozen entity for the tick: it only falls, floats and slides. A slide that meets a wall + * before it has run itself out shatters against it. + */ + public static void travelFrozen(LivingEntity entity) { + var movement = entity.getDeltaMovement(); + double submerged = submergedFraction(entity); + double rise = movement.y() - entity.getGravity(); + double drag; + if (submerged > 0.0D) { + // Archimedes: the lift is what the ice displaces, scaled so that it exactly cancels + // gravity at FLOAT_SUBMERSION. A block riding lower than that is pushed up, one riding + // higher falls back, and it comes to rest at the surface. + rise = (rise + entity.getGravity() * submerged / FLOAT_SUBMERSION) * WATER_BOB_DRAG; + drag = WATER_DRAG; + } else { + drag = entity.onGround() ? GROUND_DRAG : AIR_DRAG; + } + entity.setDeltaMovement(movement.x() * drag, rise, movement.z() * drag); + + double speed = entity.getDeltaMovement().horizontalDistance(); + entity.move(MoverType.SELF, entity.getDeltaMovement()); + + if (entity.horizontalCollision && speed >= SHATTER_SPEED && entity.level() instanceof ServerLevel level) { + thaw(level, entity); + return; + } + + // rounding the last of a slide down keeps blocks of ice from drifting forever + var slowed = entity.getDeltaMovement(); + if (Math.abs(slowed.x()) < SLIDE_EPSILON && Math.abs(slowed.z()) < SLIDE_EPSILON) { + entity.setDeltaMovement(0.0D, slowed.y(), 0.0D); + } + } + + /** @return how much of the entity's height is under water, from 0 to 1 */ + private static double submergedFraction(Entity entity) { + double height = entity.getBbHeight(); + if (height <= 0.0D) { + return 0.0D; + } + return Math.min(entity.getFluidHeight(FluidTags.WATER) / height, 1.0D); + } + + /** Sends the block of ice sliding whenever a player walks into its side. */ + private static void shoveAroundBy(ServerLevel level, Entity entity) { + var hitBox = entity.getBoundingBox(); + var reach = hitBox.inflate(PUSH_REACH, 0.0D, PUSH_REACH); + + for (Player player : level.getEntitiesOfClass(Player.class, reach, EntitySelector.NO_SPECTATORS)) { + // whoever stands on top of the ice rides it, they do not push it + if (player.getBoundingBox().minY >= hitBox.maxY - PUSH_HEADROOM) { + continue; + } + var heading = flatten(player.getKnownMovement()); + if (heading == null) { + continue; + } + // ...and only when they are heading into the ice, rather than away from it + if (hitBox.getCenter().subtract(player.position()).dot(heading) <= 0.0D) { + continue; + } + // a player chasing the ice they just shoved must not keep resetting its speed + if (entity.getDeltaMovement().dot(heading) >= SLIDE_SPEED - SLIDE_EPSILON) { + return; + } + shove(entity, heading); + return; + } + } + + /** + * Sends the block of ice sliding along a heading, keeping whatever vertical motion it had. The + * heading is taken as it comes, so a shove that lands at an angle sends the ice off at that angle. + */ + public static void shove(Entity entity, Vec3 heading) { + var flat = flatten(heading); + if (flat == null) { + return; + } + var push = flat.scale(SLIDE_SPEED); + entity.setDeltaMovement(push.x(), entity.getDeltaMovement().y(), push.z()); + // a frozen player moves itself: the server has to tell it where it is being sent + if (entity instanceof ServerPlayer player) { + player.connection.send(new ClientboundSetEntityMotionPacket(player)); + } + } + + /** @see #shove(Entity, Vec3) */ + public static void shove(Entity entity, Direction direction) { + shove(entity, direction.getUnitVec3()); + } + + /** + * @return {@code heading} flattened onto the ground and brought down to unit length, or + * {@code null} when there is not enough of it left to point anywhere + */ + @Nullable + private static Vec3 flatten(Vec3 heading) { + if (heading.horizontalDistanceSqr() < SLIDE_EPSILON * SLIDE_EPSILON) { + return null; + } + return new Vec3(heading.x(), 0.0D, heading.z()).normalize(); + } +} diff --git a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Iceball.java b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Iceball.java index 40272dc50..9c5aa2e54 100644 --- a/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Iceball.java +++ b/mubble-super_mario/src/main/java/fr/hugman/mubble/super_mario/world/entity/projectile/Iceball.java @@ -5,6 +5,7 @@ import fr.hugman.mubble.super_mario.sounds.SuperMarioSounds; import fr.hugman.mubble.super_mario.world.attribute.SuperMarioEnvironmentAttributes; import fr.hugman.mubble.super_mario.world.entity.SuperMarioEntityTypes; +import fr.hugman.mubble.super_mario.world.entity.freeze.Freezing; import fr.hugman.mubble.world.attribute.BlockTransform; import fr.hugman.mubble.world.entity.projectile.Ball; import net.minecraft.core.BlockPos; @@ -13,10 +14,10 @@ import net.minecraft.core.Holder; import net.minecraft.core.particles.ParticleOptions; import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.server.level.ServerLevel; import net.minecraft.sounds.SoundEvent; import net.minecraft.sounds.SoundSource; -import net.minecraft.world.effect.MobEffectInstance; -import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.damagesource.DamageSource; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.LivingEntity; @@ -26,9 +27,17 @@ import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.phys.BlockHitResult; import net.minecraft.world.phys.EntityHitResult; +import net.minecraft.world.phys.Vec3; public class Iceball extends Ball { private static final ClientAsset.ResourceTexture TEXTURE = new ClientAsset.ResourceTexture(SuperMario.id("entity/iceball")); + /** + * How far the target may have moved during the hit and still be trapped by it, in blocks. + *

+ * Anything further has not been knocked about, it has left: an enderman teleports on being hit by + * a projectile, and it lands well clear of this. + */ + private static final double DODGE_LEEWAY = 1.0D; public Iceball(EntityType type, Level level) { super(type, level); @@ -61,20 +70,29 @@ protected ParticleOptions getTrailParticle() { protected void onHitEntity(EntityHitResult result) { super.onHitEntity(result); Entity entity = result.getEntity(); + // an entity already in a block of ice is a wall as far as the next ice ball is concerned: + // it shatters against it, leaving whoever is inside no better and no worse off + if (Freezing.isFrozen(entity)) { + this.finalHit(); + return; + } Entity owner = this.getOwner(); float damage = entity instanceof SnowGolem ? 1.0F : 3.0F; + DamageSource source = this.damageSources().source(SuperMarioDamageTypeIds.ICEBALL, this, owner); if (owner instanceof LivingEntity livingEntity) { livingEntity.setLastHurtMob(entity); } - if (!this.level().isClientSide()) { - if (!(entity instanceof SnowGolem) && entity instanceof LivingEntity) { - LivingEntity livingEntity = (LivingEntity) entity; - livingEntity.addEffect(new MobEffectInstance(MobEffects.SLOWNESS, 40, 1)); - } - } - entity.hurt(this.damageSources().source(SuperMarioDamageTypeIds.ICEBALL, this, this.getOwner()), damage); + Vec3 struckAt = entity.position(); + entity.hurt(source, damage); + // snow golems are made of the stuff: an ice ball is no more to them than the hit itself. And + // whatever blinked out of the way — an enderman — is no longer there for the ice to close on. + if (this.level() instanceof ServerLevel level && !(entity instanceof SnowGolem) + && entity instanceof LivingEntity living && living.isAlive() + && living.distanceToSqr(struckAt) < DODGE_LEEWAY * DODGE_LEEWAY) { + Freezing.freeze(level, living); + } this.finalHit(SuperMarioSounds.ICEBALL_HIT_ENTITY); } diff --git a/mubble-super_mario/src/main/resources/super_mario.mixins.json b/mubble-super_mario/src/main/resources/super_mario.mixins.json index 3c80d4dd5..cc2718da5 100644 --- a/mubble-super_mario/src/main/resources/super_mario.mixins.json +++ b/mubble-super_mario/src/main/resources/super_mario.mixins.json @@ -4,6 +4,8 @@ "compatibilityLevel": "JAVA_21", "mixins": [ "EntityMixin", + "FrozenEntityMixin", + "FrozenLivingEntityMixin", "LivingEntityMixin" ], "injectors": { diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpCommandGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpCommandGameTest.java index 3798d58f5..d1b9a8ee3 100644 --- a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpCommandGameTest.java +++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/power_up/PowerUpCommandGameTest.java @@ -4,12 +4,10 @@ import fr.hugman.mubble.test.gametest.datapack.PowerUpFixtures; import fr.hugman.mubble.test.gametest.support.TestPlayers; import net.fabricmc.fabric.api.gametest.v1.GameTest; -import net.minecraft.commands.CommandSource; -import net.minecraft.commands.CommandSourceStack; -import net.minecraft.network.chat.Component; -import net.minecraft.server.permissions.PermissionSet; import net.minecraft.gametest.framework.GameTestHelper; -import net.minecraft.server.level.ServerPlayer; + +import static fr.hugman.mubble.test.gametest.support.TestCommands.run; +import static fr.hugman.mubble.test.gametest.support.TestCommands.succeeds; /** * {@code /powerup}, the way a power-up is handed out without an item. It is also the only user of @@ -84,59 +82,4 @@ public void anUnknownPowerUpIsRefused(GameTestHelper helper) { helper.succeed(); } - - private static void run(GameTestHelper helper, ServerPlayer player, String command) { - var outcome = perform(helper, player, command); - helper.assertTrue(outcome.succeeded, "`/" + command + "` failed: " + outcome.message); - } - - private static boolean succeeds(GameTestHelper helper, ServerPlayer player, String command) { - return perform(helper, player, command).succeeded; - } - - /** Runs {@code command} as the server, on behalf of {@code player}, keeping whatever it answered. */ - private static Outcome perform(GameTestHelper helper, ServerPlayer player, String command) { - var server = helper.getLevel().getServer(); - var outcome = new Outcome(); - - CommandSourceStack source = new CommandSourceStack( - new CommandSource() { - @Override - public void sendSystemMessage(Component message) { - outcome.message = outcome.message + " | " + message.getString(); - } - - @Override - public boolean acceptsSuccess() { - return true; - } - - @Override - public boolean acceptsFailure() { - return true; - } - - @Override - public boolean shouldInformAdmins() { - return false; - } - }, - player.position(), - player.getRotationVector(), - helper.getLevel(), - PermissionSet.ALL_PERMISSIONS, - "gametest", - Component.literal("gametest"), - server, - player - ); - - server.getCommands().performPrefixedCommand(source.withCallback((success, result) -> outcome.succeeded = success), command); - return outcome; - } - - private static final class Outcome { - boolean succeeded; - String message = ""; - } } diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/BallGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/BallGameTest.java index b6abc4bf3..55b0dd1ae 100644 --- a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/BallGameTest.java +++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/BallGameTest.java @@ -1,11 +1,12 @@ package fr.hugman.mubble.test.gametest.super_mario; import fr.hugman.mubble.super_mario.world.entity.SuperMarioEntityTypes; +import fr.hugman.mubble.super_mario.world.entity.freeze.Freezing; +import fr.hugman.mubble.super_mario.world.entity.projectile.Iceball; import fr.hugman.mubble.world.entity.projectile.Ball; import net.fabricmc.fabric.api.gametest.v1.GameTest; import net.minecraft.core.BlockPos; import net.minecraft.gametest.framework.GameTestHelper; -import net.minecraft.world.effect.MobEffects; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.EntityTypes; import net.minecraft.world.entity.animal.pig.Pig; @@ -68,21 +69,42 @@ public void fireballSetsWhatItHitsOnFire(GameTestHelper helper) { } @GameTest(maxTicks = 100) - public void iceballSlowsDownWhatItHits(GameTestHelper helper) { + public void iceballFreezesWhatItHits(GameTestHelper helper) { buildFloor(helper); Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET_POS); shootAt(helper, SuperMarioEntityTypes.ICEBALL, TARGET_POS); helper.succeedWhen(() -> { - // Checked first: the vanilla assertion right below reports failures without any context. - helper.assertTrue(pig.hasEffect(MobEffects.SLOWNESS), "the iceball did not slow the pig down"); - helper.assertLivingEntityHasMobEffect(pig, MobEffects.SLOWNESS, 1); + helper.assertTrue(Freezing.isFrozen(pig), "the iceball did not freeze the pig"); helper.assertTrue(pig.getHealth() < pig.getMaxHealth(), "the iceball did not hurt the pig"); helper.assertTrue(pig.getRemainingFireTicks() <= 0, "the iceball set the pig on fire"); }); } + @GameTest(maxTicks = 100) + public void iceballShattersOnWhatIsAlreadyFrozen(GameTestHelper helper) { + buildFloor(helper); + Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET_POS); + + Freezing.freezeFor(helper.getLevel(), pig, 200); + float health = pig.getHealth(); + // the game time the ice is due to break: unlike the remaining ticks it only moves if something + // shortens the freeze, so it tells a chipped block of ice apart from one merely counting down + long endsAt = Freezing.getState(pig).endsAt(); + + shootAt(helper, SuperMarioEntityTypes.ICEBALL, TARGET_POS); + + // a block of ice is a wall to the next ice ball: it bursts against it and leaves it as it was + helper.succeedWhen(() -> { + helper.assertTrue(helper.getLevel().getEntitiesOfClass(Iceball.class, helper.getBounds()).isEmpty(), + "the iceball did not burst on the frozen pig"); + helper.assertTrue(pig.getHealth() == health, "the iceball hurt a pig that was already frozen"); + helper.assertTrue(Freezing.getState(pig) != null && Freezing.getState(pig).endsAt() == endsAt, + "the iceball chipped away at the ice it burst on"); + }); + } + /** Fills the bottom layer of the arena with stone, so that nothing falls out of the test. */ private static void buildFloor(GameTestHelper helper) { for (int x = 0; x < ARENA_SIZE; x++) { diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/FreezeCommandGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/FreezeCommandGameTest.java new file mode 100644 index 000000000..68caa73cc --- /dev/null +++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/FreezeCommandGameTest.java @@ -0,0 +1,150 @@ +package fr.hugman.mubble.test.gametest.super_mario; + +import fr.hugman.mubble.super_mario.world.entity.freeze.Freezing; +import fr.hugman.mubble.test.gametest.support.Arena; +import fr.hugman.mubble.test.gametest.support.TestPlayers; +import net.fabricmc.fabric.api.gametest.v1.GameTest; +import net.minecraft.core.BlockPos; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.entity.animal.pig.Pig; + +import static fr.hugman.mubble.test.gametest.support.TestCommands.perform; +import static fr.hugman.mubble.test.gametest.support.TestCommands.run; +import static fr.hugman.mubble.test.gametest.support.TestCommands.succeeds; + +/** + * {@code /freeze}, the way an entity is put in a block of ice by hand. + *

+ * Every target is named by its UUID rather than picked with a type selector: game tests share one + * level, and {@code @e[type=pig]} would just as happily reach into whatever another test is running. + */ +public class FreezeCommandGameTest { + private static final BlockPos TARGET = new BlockPos(4, Arena.FLOOR_Y + 1, 3); + + @GameTest + public void aTimeFreezesTheTarget(GameTestHelper helper) { + var pig = target(helper); + + run(helper, operator(helper), "freeze set " + pig.getUUID() + " 100"); + + helper.assertTrue(Freezing.isFrozen(pig), "the command left the pig unfrozen"); + helper.succeed(); + } + + @GameTest + public void aTimeOfZeroThawsTheTarget(GameTestHelper helper) { + var pig = target(helper); + var operator = operator(helper); + run(helper, operator, "freeze set " + pig.getUUID() + " 100"); + + run(helper, operator, "freeze set " + pig.getUUID() + " 0"); + + helper.assertFalse(Freezing.isFrozen(pig), "the command left the pig in the ice"); + helper.succeed(); + } + + @GameTest + public void omittingTheValueFails(GameTestHelper helper) { + var pig = target(helper); + var operator = operator(helper); + + helper.assertFalse(succeeds(helper, operator, "freeze set " + pig.getUUID()), + "the time is mandatory: leaving it off must not quietly flip the target"); + helper.assertFalse(Freezing.isFrozen(pig), "and the pig should have been left alone"); + + helper.succeed(); + } + + @GameTest + public void aDurationIsHonoured(GameTestHelper helper) { + var pig = target(helper); + + run(helper, operator(helper), "freeze set " + pig.getUUID() + " 7"); + + helper.assertTrue(Freezing.getRemainingTicks(pig) == 7, + "the freeze should last exactly the number of ticks asked for"); + helper.succeed(); + } + + @GameTest + public void anInfiniteFreezeNeverRunsOut(GameTestHelper helper) { + var pig = target(helper); + + run(helper, operator(helper), "freeze set " + pig.getUUID() + " infinite"); + + var state = Freezing.getState(pig); + helper.assertTrue(state != null && state.isEndless(), "the freeze should have been endless"); + // an endless freeze outlasts anything a countdown could hold + helper.assertFalse(state.hasExpired(pig.level().getGameTime() + 1_000_000L), + "an endless freeze ran out anyway"); + helper.succeed(); + } + + @GameTest + public void aNewTimeReplacesTheOldOne(GameTestHelper helper) { + var pig = target(helper); + var operator = operator(helper); + + run(helper, operator, "freeze set " + pig.getUUID() + " 200"); + run(helper, operator, "freeze set " + pig.getUUID() + " 20"); + + helper.assertTrue(Freezing.isFrozen(pig), "re-setting the time let the pig out"); + helper.assertTrue(Freezing.getRemainingTicks(pig) == 20, + "the second time should have replaced the first, not been turned down"); + helper.succeed(); + } + + @GameTest + public void thawingWhatIsNotFrozenFails(GameTestHelper helper) { + var pig = target(helper); + + helper.assertFalse(succeeds(helper, operator(helper), "freeze set " + pig.getUUID() + " 0"), + "thawing a pig that is not frozen should fail"); + helper.succeed(); + } + + @GameTest + public void aCreativePlayerCanBeFrozenByHand(GameTestHelper helper) { + // the framework hands out creative players and nothing else, which is exactly what is needed here + var player = TestPlayers.inLevel(helper); + + // an ice ball leaves a creative player alone, but the command is an operator's tool and does not + helper.assertTrue(succeeds(helper, player, "freeze set @s 100"), + "the command turned a creative player down"); + helper.assertTrue(Freezing.isFrozen(player), "and left them out of the ice"); + + // and the freeze has to survive the tick that lets genuinely unfreezable entities out + Freezing.tick(player); + helper.assertTrue(Freezing.isFrozen(player), "the next tick thawed them again"); + + helper.succeed(); + } + + @GameTest + public void queryAnswersWithTheUsualOneOrZero(GameTestHelper helper) { + var pig = target(helper); + var operator = operator(helper); + + helper.assertTrue(perform(helper, operator, "freeze query " + pig.getUUID()).result == 0, + "a query on an unfrozen entity should answer 0, so that `execute if` reads it as a no"); + + run(helper, operator, "freeze set " + pig.getUUID() + " 100"); + helper.assertTrue(perform(helper, operator, "freeze query " + pig.getUUID()).result == 1, + "a query on a frozen entity should answer 1"); + + helper.succeed(); + } + + /** Something freezable standing in the arena, for the command to be pointed at. */ + private static Pig target(GameTestHelper helper) { + Arena.buildFloor(helper); + return helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET); + } + + /** Whoever runs the command. Only its position and its level matter. */ + private static ServerPlayer operator(GameTestHelper helper) { + return TestPlayers.inLevel(helper); + } +} diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/FreezeGameTest.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/FreezeGameTest.java new file mode 100644 index 000000000..8c9d8c62e --- /dev/null +++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/super_mario/FreezeGameTest.java @@ -0,0 +1,380 @@ +package fr.hugman.mubble.test.gametest.super_mario; + +import fr.hugman.mubble.super_mario.references.SuperMarioDamageTypeIds; +import fr.hugman.mubble.super_mario.world.entity.freeze.FreezeResistance; +import fr.hugman.mubble.super_mario.world.entity.freeze.Freezing; +import fr.hugman.mubble.test.gametest.support.Arena; +import fr.hugman.mubble.test.gametest.support.TestPlayers; +import net.fabricmc.fabric.api.gametest.v1.GameTest; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.tags.FluidTags; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.MoverType; +import net.minecraft.world.entity.animal.pig.Pig; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.phys.Vec3; + +/** + * Entities caught in a block of ice: how long they stay in there, what it takes out of them, and + * what the block of ice itself behaves like while it lasts. + * + * @see Freezing + */ +public class FreezeGameTest { + private static final BlockPos TARGET = new BlockPos(4, Arena.FLOOR_Y + 1, 3); + /** Where a mob about to be shoved stands, with room to slide east from there. */ + private static final BlockPos SHOVE_START = new BlockPos(1, Arena.FLOOR_Y + 1, 3); + /** What a sliding mob is aimed at, for the tests about running into something. */ + private static final BlockPos WALL = new BlockPos(5, Arena.FLOOR_Y + 1, 3); + /** The waterline of the pool the floating tests fill, in structure-relative coordinates. */ + private static final int POOL_SURFACE_Y = Arena.FLOOR_Y + 5; + + @GameTest(maxTicks = 140) + public void aRegularMobStaysFrozenForTheWholeDuration(GameTestHelper helper) { + Arena.buildFloor(helper); + Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET); + + helper.assertTrue(freeze(helper, pig) == FreezeResistance.NONE, "a pig is small enough to be frozen outright"); + + helper.startSequence() + // well past the point a big mob would have broken out of the ice + .thenIdle(Freezing.TOUGH_DURATION + 20) + .thenExecute(() -> { + helper.assertTrue(Freezing.isFrozen(pig), "the pig thawed long before its freeze was up"); + helper.assertTrue(pig.getHealth() == pig.getMaxHealth(), "being frozen hurt the pig by itself"); + }) + .thenSucceed(); + } + + @GameTest(maxTicks = 140) + public void aBigMobBreaksOutOfTheIceUnharmed(GameTestHelper helper) { + Arena.buildFloor(helper); + var golem = helper.spawnWithNoFreeWill(EntityTypes.IRON_GOLEM, TARGET); + + helper.assertTrue(freeze(helper, golem) == FreezeResistance.TOUGH, "an iron golem is big enough to break out of the ice"); + helper.assertTrue(Freezing.isFrozen(golem), "a big mob is still frozen, only not for long"); + + helper.startSequence() + .thenIdle(Freezing.TOUGH_DURATION + 5) + .thenExecute(() -> { + helper.assertFalse(Freezing.isFrozen(golem), "the iron golem never broke out of the ice"); + helper.assertTrue(golem.getHealth() == golem.getMaxHealth(), "breaking out of the ice should cost the iron golem nothing"); + }) + .thenSucceed(); + } + + @GameTest(maxTicks = 20) + public void aBossIsLeftAloneRatherThanFrozen(GameTestHelper helper) { + Arena.buildFloor(helper); + var wither = helper.spawnWithNoFreeWill(EntityTypes.WITHER, TARGET); + float before = wither.getHealth(); + + helper.assertTrue(freeze(helper, wither) == FreezeResistance.IMMUNE, "a boss cannot be frozen at all"); + helper.assertFalse(Freezing.isFrozen(wither), "the wither ended up in a block of ice anyway"); + helper.assertTrue(wither.getHealth() == before, "the freeze hurt the wither on its own, on top of whatever threw it"); + helper.succeed(); + } + + @GameTest(maxTicks = 20) + public void frozenEntitiesCanBeStoodOn(GameTestHelper helper) { + Arena.buildFloor(helper); + Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET); + + helper.assertFalse(pig.canBeCollidedWith(null), "a pig is walked through, not into"); + freeze(helper, pig); + helper.assertTrue(pig.canBeCollidedWith(null), "the block of ice is not solid enough to stand on"); + helper.succeed(); + } + + @GameTest(maxTicks = 60) + public void shovedIceSlidesStraightAhead(GameTestHelper helper) { + Arena.buildFloor(helper); + Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, SHOVE_START); + freeze(helper, pig); + + double startX = pig.getX(); + double startZ = pig.getZ(); + Freezing.shove(pig, Direction.EAST); + + helper.startSequence() + .thenIdle(10) + .thenExecute(() -> { + helper.assertTrue(pig.getX() > startX + 1.0D, "the shoved block of ice barely moved"); + helper.assertTrue(Math.abs(pig.getZ() - startZ) < 0.1D, "the shoved block of ice veered off its axis"); + }) + .thenSucceed(); + } + + @GameTest(maxTicks = 60) + public void aShoveOffTheAxesSendsTheIceOffAtThatAngle(GameTestHelper helper) { + Arena.buildFloor(helper); + Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, SHOVE_START); + freeze(helper, pig); + + double startX = pig.getX(); + double startZ = pig.getZ(); + // straight between east and south, which snapping to the nearest way round would have flattened + Freezing.shove(pig, new Vec3(1.0D, 0.0D, 1.0D)); + + helper.startSequence() + .thenIdle(10) + .thenExecute(() -> { + double east = pig.getX() - startX; + double south = pig.getZ() - startZ; + helper.assertTrue(east > 0.5D && south > 0.5D, "the block of ice went off along an axis rather than the corner"); + helper.assertTrue(Math.abs(east - south) < 0.2D, "and it favoured one of the two over the other"); + }) + .thenSucceed(); + } + + @GameTest(maxTicks = 20) + public void theTopOfABlockOfIceIsSlippery(GameTestHelper helper) { + Arena.buildFloor(helper); + Pig ice = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET); + Pig rider = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET.above(2)); + freeze(helper, ice); + + // dropped the last inch onto the ice, which is the move that works out what is holding it up + rider.setPos(ice.getX(), ice.getBoundingBox().maxY + 0.05D, ice.getZ()); + rider.move(MoverType.SELF, new Vec3(0.0D, -0.2D, 0.0D)); + + helper.assertTrue(rider.onGround(), "the rider never came to rest on the block of ice"); + helper.assertTrue(rider.mainSupportingBlockPos.isEmpty(), "and it found a block under it rather than the ice"); + helper.assertTrue(Freezing.isStandingOnFrozen(rider), "standing on a frozen mob should count as standing on ice"); + helper.assertFalse(Freezing.isStandingOnFrozen(ice), "the block of ice is not standing on itself"); + helper.succeed(); + } + + @GameTest(maxTicks = 60) + public void aSlideRunsItselfOut(GameTestHelper helper) { + Arena.buildFloor(helper); + Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, SHOVE_START); + freeze(helper, pig); + // gently, so that the arena wall is never reached and only the friction can stop it + pig.setDeltaMovement(Freezing.SHATTER_SPEED * 0.8D, 0.0D, 0.0D); + + helper.startSequence() + .thenIdle(30) + .thenExecute(() -> { + helper.assertTrue(pig.getDeltaMovement().horizontalDistance() < 0.05D, + "the block of ice was still going as fast as ever"); + helper.assertTrue(Freezing.isFrozen(pig), "and it fell apart rather than coming to a stop"); + }) + .thenSucceed(); + } + + @GameTest(maxTicks = 60) + public void aFastSlideIntoAWallShattersTheIce(GameTestHelper helper) { + Pig pig = walledIn(helper); + Freezing.shove(pig, Direction.EAST); + + helper.startSequence() + .thenIdle(10) + .thenExecute(() -> helper.assertFalse(Freezing.isFrozen(pig), "the ice held up against a wall at full tilt")) + .thenSucceed(); + } + + @GameTest(maxTicks = 60) + public void aSpentSlideIntoAWallLeavesTheIceStanding(GameTestHelper helper) { + Pig pig = walledIn(helper); + pig.setDeltaMovement(Freezing.SHATTER_SPEED * 0.6D, 0.0D, 0.0D); + + helper.startSequence() + .thenIdle(25) + .thenExecute(() -> helper.assertTrue(Freezing.isFrozen(pig), "a slide that had run its course still broke the ice")) + .thenSucceed(); + } + + @GameTest(maxTicks = 20) + public void smashingTheKeysMeltsTheIceFaster(GameTestHelper helper) { + Arena.buildFloor(helper); + Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET); + freeze(helper, pig); + + int before = Freezing.getRemainingTicks(pig); + helper.assertTrue(Freezing.struggle(pig), "struggling did nothing to a frozen entity"); + helper.assertTrue(Freezing.getRemainingTicks(pig) == before - Freezing.STRUGGLE_RELIEF, + "struggling did not melt its share of the ice"); + helper.succeed(); + } + + @GameTest(maxTicks = 20) + public void strugglingDoesNothingWhenNotFrozen(GameTestHelper helper) { + Arena.buildFloor(helper); + Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET); + + helper.assertFalse(Freezing.struggle(pig), "an entity that is not frozen has nothing to struggle out of"); + helper.succeed(); + } + + @GameTest(maxTicks = 20) + public void theIceTakesTheHitInsteadOfTheEntity(GameTestHelper helper) { + Arena.buildFloor(helper); + Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET); + freeze(helper, pig); + + float health = pig.getHealth(); + int before = Freezing.getRemainingTicks(pig); + + helper.assertFalse(pig.hurtServer(helper.getLevel(), helper.getLevel().damageSources().generic(), 4.0F), + "the hit got through to the pig"); + helper.assertTrue(pig.getHealth() == health, "and took health off it"); + helper.assertTrue(Freezing.getRemainingTicks(pig) == before - 4 * Freezing.MELT_PER_DAMAGE, + "the hit went nowhere: it should have melted its share of the ice"); + helper.succeed(); + } + + @GameTest(maxTicks = 20) + public void theIceIsNoShieldAgainstTheThingsNothingIsSafeFrom(GameTestHelper helper) { + Arena.buildFloor(helper); + Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET); + freeze(helper, pig); + + helper.assertTrue(pig.hurtServer(helper.getLevel(), helper.getLevel().damageSources().genericKill(), Float.MAX_VALUE), + "a block of ice turned `/kill` away"); + helper.assertFalse(pig.isAlive(), "and left the pig standing"); + helper.succeed(); + } + + @GameTest(maxTicks = 20) + public void aBurnBreaksTheIceOpenAtOnce(GameTestHelper helper) { + Arena.buildFloor(helper); + Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET); + freeze(helper, pig); + + pig.hurtServer(helper.getLevel(), helper.getLevel().damageSources().inFire(), 1.0F); + + helper.assertFalse(Freezing.isFrozen(pig), "fire left the block of ice standing"); + helper.succeed(); + } + + @GameTest(maxTicks = 20) + public void aFireballBreaksTheIceOpenToo(GameTestHelper helper) { + Arena.buildFloor(helper); + Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET); + freeze(helper, pig); + + // the mod's own fireballs are fire in everything but the vanilla tag, hence `super_mario:melts_frozen_entities` + pig.hurtServer(helper.getLevel(), helper.getLevel().damageSources().source(SuperMarioDamageTypeIds.FIREBALL), 1.0F); + + helper.assertFalse(Freezing.isFrozen(pig), "a fireball left the block of ice standing"); + helper.succeed(); + } + + @GameTest(maxTicks = 20) + public void frozenMobsAreNotSetAlight(GameTestHelper helper) { + Arena.buildFloor(helper); + Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET); + pig.igniteForSeconds(8.0F); + + freeze(helper, pig); + helper.assertTrue(pig.getRemainingFireTicks() <= 0, "freezing a burning mob left it burning"); + + pig.igniteForSeconds(8.0F); + helper.assertTrue(pig.getRemainingFireTicks() <= 0, "a mob caught fire while sitting in a block of ice"); + helper.succeed(); + } + + @GameTest(maxTicks = 60) + public void aHitTheIceTurnsAwayStillSendsItSkidding(GameTestHelper helper) { + Arena.buildFloor(helper); + Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, SHOVE_START); + freeze(helper, pig); + var attacker = TestPlayers.at(helper, SHOVE_START.west(1)); + + double startX = pig.getX(); + double liftBefore = pig.getDeltaMovement().y(); + pig.hurtServer(helper.getLevel(), helper.getLevel().damageSources().playerAttack(attacker), 1.0F); + + helper.assertTrue(pig.getDeltaMovement().y() == liftBefore, "the hit lifted the block of ice off the floor"); + + helper.startSequence() + .thenIdle(10) + .thenExecute(() -> helper.assertTrue(pig.getX() > startX + 1.0D, + "a hit the ice turned away left it standing there rather than skidding out of reach")) + .thenSucceed(); + } + + @GameTest(maxTicks = 20) + public void knockbackNeverLiftsABlockOfIce(GameTestHelper helper) { + Arena.buildFloor(helper); + Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, TARGET); + freeze(helper, pig); + pig.setOnGround(true); + + double liftBefore = pig.getDeltaMovement().y(); + pig.knockback(0.5D, 1.0D, 0.0D, helper.getLevel().damageSources().generic(), 0.5F); + + helper.assertTrue(pig.getDeltaMovement().y() == liftBefore, "the punch sent the block of ice into the air"); + helper.assertTrue(pig.getDeltaMovement().horizontalDistanceSqr() > 0.0D, "and it did not send it skidding either"); + helper.succeed(); + } + + /** A frozen pig two blocks short of a wall, with room to build up speed on the way there. */ + @GameTest(maxTicks = 160) + public void frozenIceFloatsUpToTheWaterSurface(GameTestHelper helper) { + Pig pig = inThePool(helper); + freeze(helper, pig); + + double startY = pig.getY(); + // the pig's position is absolute, the pool was built in structure-relative coordinates + double waterline = helper.absolutePos(new BlockPos(0, POOL_SURFACE_Y, 0)).getY(); + + helper.startSequence() + .thenIdle(120) + .thenExecute(() -> { + helper.assertTrue(pig.getY() > startY + 1.0D, + "the block of ice sank instead of floating up"); + // it rides the surface rather than popping out of the water and landing back in + helper.assertTrue(pig.getY() < waterline + 0.5D, + "the block of ice was pushed clear of the water"); + helper.assertTrue(pig.getY() > waterline - pig.getBbHeight(), + "the block of ice settled below the surface instead of on it"); + helper.assertTrue(Math.abs(pig.getDeltaMovement().y()) < 0.05D, + "the block of ice never settled, it is still bobbing"); + }) + .thenSucceed(); + } + + @GameTest(maxTicks = 160) + public void aFloatingBlockOfIceKeepsItsRiderOutOfTheWater(GameTestHelper helper) { + Pig pig = inThePool(helper); + freeze(helper, pig); + + helper.startSequence() + .thenIdle(120) + .thenExecute(() -> helper.assertFalse(pig.isEyeInFluid(FluidTags.WATER), + "a floating block of ice left the head of whoever is inside it under water")) + .thenSucceed(); + } + + /** + * A pig sitting on the bottom of a pool deep enough that it has somewhere to float up to. + */ + private static Pig inThePool(GameTestHelper helper) { + Arena.buildFloor(helper); + for (int x = 0; x < Arena.SIZE; x++) { + for (int z = 0; z < Arena.SIZE; z++) { + for (int y = Arena.FLOOR_Y + 1; y < POOL_SURFACE_Y; y++) { + helper.setBlock(new BlockPos(x, y, z), Blocks.WATER); + } + } + } + return helper.spawnWithNoFreeWill(EntityTypes.PIG, new BlockPos(4, Arena.FLOOR_Y + 1, 3)); + } + + private static Pig walledIn(GameTestHelper helper) { + Arena.buildFloor(helper); + helper.setBlock(WALL, Blocks.STONE); + Pig pig = helper.spawnWithNoFreeWill(EntityTypes.PIG, WALL.west(2)); + freeze(helper, pig); + return pig; + } + + private static FreezeResistance freeze(GameTestHelper helper, LivingEntity entity) { + return Freezing.freeze(helper.getLevel(), entity); + } +} diff --git a/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/support/TestCommands.java b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/support/TestCommands.java new file mode 100644 index 000000000..0d98e59e4 --- /dev/null +++ b/mubble-test/src/gametest/java/fr/hugman/mubble/test/gametest/support/TestCommands.java @@ -0,0 +1,80 @@ +package fr.hugman.mubble.test.gametest.support; + +import net.minecraft.commands.CommandSource; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.permissions.PermissionSet; + +/** + * Running a command the way a test wants it: as the server, on behalf of a player, with whatever it + * answered kept rather than printed. + */ +public final class TestCommands { + private TestCommands() { + } + + /** Runs {@code command} and fails the test when it does not go through. */ + public static void run(GameTestHelper helper, ServerPlayer player, String command) { + var outcome = perform(helper, player, command); + helper.assertTrue(outcome.succeeded, "`/" + command + "` failed: " + outcome.message); + } + + /** @return whether {@code command} went through, without minding what it answered */ + public static boolean succeeds(GameTestHelper helper, ServerPlayer player, String command) { + return perform(helper, player, command).succeeded; + } + + /** Runs {@code command} as the server, on behalf of {@code player}, keeping whatever it answered. */ + public static Outcome perform(GameTestHelper helper, ServerPlayer player, String command) { + var server = helper.getLevel().getServer(); + var outcome = new Outcome(); + + CommandSourceStack source = new CommandSourceStack( + new CommandSource() { + @Override + public void sendSystemMessage(Component message) { + outcome.message = outcome.message + " | " + message.getString(); + } + + @Override + public boolean acceptsSuccess() { + return true; + } + + @Override + public boolean acceptsFailure() { + return true; + } + + @Override + public boolean shouldInformAdmins() { + return false; + } + }, + player.position(), + player.getRotationVector(), + helper.getLevel(), + PermissionSet.ALL_PERMISSIONS, + "gametest", + Component.literal("gametest"), + server, + player + ); + + server.getCommands().performPrefixedCommand(source.withCallback((success, result) -> { + outcome.succeeded = success; + outcome.result = result; + }), command); + return outcome; + } + + /** What a command left behind: whether it went through, what it returned, and what it said. */ + public static final class Outcome { + public boolean succeeded; + /** The number the command returned, which is what {@code execute if} hangs off. */ + public int result; + public String message = ""; + } +} diff --git a/mubble-test/src/gametest/resources/fabric.mod.json b/mubble-test/src/gametest/resources/fabric.mod.json index 5763a9917..96534b412 100644 --- a/mubble-test/src/gametest/resources/fabric.mod.json +++ b/mubble-test/src/gametest/resources/fabric.mod.json @@ -25,6 +25,8 @@ "fr.hugman.mubble.test.gametest.super_mario.KoopaShellGameTest", "fr.hugman.mubble.test.gametest.super_mario.CloudPlatformGameTest", "fr.hugman.mubble.test.gametest.super_mario.SpawnCloudPlatformActionGameTest", + "fr.hugman.mubble.test.gametest.super_mario.FreezeGameTest", + "fr.hugman.mubble.test.gametest.super_mario.FreezeCommandGameTest", "fr.hugman.mubble.test.gametest.super_mario.BlockTransformGameTest", "fr.hugman.mubble.test.gametest.collectible.CollectibleEntityGameTest", "fr.hugman.mubble.test.gametest.super_mario.BumpableBlockGameTest",