diff --git a/docs/superpowers/specs/2026-08-11-blood-splatter-design.md b/docs/superpowers/specs/2026-08-11-blood-splatter-design.md new file mode 100644 index 00000000..f26bd1a5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-blood-splatter-design.md @@ -0,0 +1,104 @@ +# Blood splatter on damage + +## Goal + +Taking a hit throws blood across the screen: it appears at once, from the side the hit came from, +and fades away within about a second. It says nothing about how the player is doing — that is the +tunnel vision's job — it only says *you were just hit, from over there*. + +## Sharing the screen with the tunnel vision + +Both effects are full-screen overlays, and both are drawn as the `camera_overlay` of an item on the +player's head — the only mechanism in vanilla that scales a texture to the viewport instead of +being calibrated against one resolution. + +A player has one head, so **only one overlay can be shown at a time.** A `ScreenOverlay` owns the +head slot and decides: each effect hands it a texture for its layer (`OverlayLayer.TUNNEL_VISION`, +`OverlayLayer.BLOOD`, in drawing order) and the topmost one wins. A splatter therefore takes the +screen for the 1.2 seconds it lasts, and the tunnel vision comes back underneath it afterwards. + +The alternative was pre-rendering every combination of splatter frame and vignette stage, so both +stay visible at once. That is 48 × 4 extra images at the coarsest useful resolution, and every +change to either effect would force re-rendering all of them. + +Two smaller things follow from riding on an item: the carrier points its `asset_id` at an empty +equipment model so it is never drawn on the player's head, and the overlay is only re-sent when the +texture actually changes — an equipment update goes out to every viewer, not just the wearer. + +## Trigger + +Cygnus applies damage in `SlenderBarHelper.applyDamage` by setting health directly. That never +raises Minestom's `EntityDamageEvent`, so a listener on it would never fire. + +`applyDamage` therefore dispatches a `PlayerDamagedEvent` carrying the victim, the source position +and the amount — the same shape the project already uses for `StaminaStateChangeEvent` and +`SlenderReviveEvent`. The source position is what lets the splatter be aimed; the amount is not +used yet but is the natural handle for anything that should scale with how hard the hit was. + +## Direction + +`BloodDirection.between(victim, source)` reduces the hit to one of four sides, seen from the victim +rather than from the world: + +``` +alignment = dot(victimLookDirection, directionToSource) +alignment > 0.5 -> FRONT +alignment < -0.5 -> BACK +cross(facing, towardsSource).y > 0 -> LEFT, else RIGHT +``` + +A hit from the east lands on the left for a player looking south and on the right for one looking +north. From the exact same spot the direction is meaningless, so it falls back to FRONT. + +## Frames + +Textures are laid out as direction × variant × frame: 4 × 2 × 6 = 48. The variants keep repeated +hits from looking mechanical, and the frames are the fade — Minecraft cannot animate a camera +overlay, so the server steps through them, one every 200 ms, giving a splatter that lives 1.2 +seconds. A fresh hit restarts the sequence rather than queueing behind the old one. + +The task that drives the fade starts with the first splatter and stops once nothing is bleeding +any more, rather than spinning over an empty map between hits. + +Drawings are generated by `tools/generate_overlay.py` in `cygnus-pack`: drops are placed with a +power-law radius — many specks, few real blotches — weighted towards the side the hit came from, +then blurred and thresholded so they melt into shapes with ragged edges instead of reading as +confetti. Bigger blotches grow a run downwards that lengthens as the frame fades, and a band along +the edge the hit came from seals the gaps the drops leave — without it a side splatter looks like +it stops short of the border. Textures are 1024×576, matching the 16:9 they are stretched onto. + +## Wiring + +`Cygnus` creates the service and `/blood`, and the service listens for itself: + +| Event | What happens | +| --- | --- | +| `PlayerDamagedEvent` | throws a splatter from the direction of the source | +| `PlayerDisconnectEvent` | drops the player's splatter | + +Like the tunnel vision, it is only registered when a resource pack is configured — without the +pack the textures are missing and players would get a fullscreen missing-texture checkerboard. + +`/blood [front|right|back|left]` throws one on demand, with no side meaning a random one, so the +drawings can be judged without waiting to be hit. + +## Failure modes + +| Situation | Behaviour | +| --- | --- | +| Hit while a splatter is still fading | the old one is replaced, the sequence restarts | +| Hit from the victim's own position | falls back to `FRONT` | +| Player leaves mid-fade | the splatter is dropped with them | +| Tunnel vision changes during a splatter | the splatter keeps the screen; the new stage shows once it is over | + +## Tests + +- `BloodDirectionTest` — plain JUnit: each of the four sides, that the victim's facing decides + rather than the world, and the degenerate same-spot case. +- `BloodSplatterServiceTest` — the first frame appears immediately, the fade walks the frames and + cleans up, a second hit restarts, the damage event triggers it, players are independent. +- `SlenderBarHelperDamageTest` — damage announces the victim and the source, and leaves out the + player who dealt it. +- `BloodCommandTest` — every side can be requested, and the bare command picks one. +- `EquipmentScreenOverlayTest` — the blood wins over the tunnel vision, the tunnel vision returns + afterwards, the slot empties with the last layer, and an unchanged overlay is not re-sent. diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index 3dfeb864..12c8c29b 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -37,6 +37,8 @@ import net.minestom.server.listener.EntityActionListener; import net.minestom.server.network.packet.client.play.ClientEntityActionPacket; import net.onelitefeather.cygnus.ambient.AmbientProvider; +import net.onelitefeather.cygnus.blood.BloodSplatterService; +import net.onelitefeather.cygnus.command.BloodCommand; import net.onelitefeather.cygnus.command.StartCommand; import net.onelitefeather.cygnus.common.ListenerHandling; import net.onelitefeather.cygnus.common.bootstrap.ServiceBootstrap; @@ -66,6 +68,9 @@ import net.onelitefeather.cygnus.movement.CygnusEntityActionListener; import net.onelitefeather.cygnus.movement.PlayerStartSprintingEvent; import net.onelitefeather.cygnus.movement.PlayerStopSprintingEvent; +import net.onelitefeather.cygnus.overlay.EquipmentScreenOverlay; +import net.onelitefeather.cygnus.overlay.OverlayProperties; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; import net.onelitefeather.cygnus.phase.GamePhase; import net.onelitefeather.cygnus.phase.LobbyPhase; import net.onelitefeather.cygnus.phase.RestartPhase; @@ -82,6 +87,7 @@ import java.nio.file.Path; import java.util.Optional; +import java.util.concurrent.ThreadLocalRandom; import java.util.function.Supplier; /** @@ -103,6 +109,8 @@ public final class Cygnus implements TeamCreator, ListenerHandling { private final JumpScareManager jumpscareManager; private final SpectatorService spectatorService; private final Optional resourcePackService; + private final ScreenOverlay screenOverlay; + private final BloodSplatterService bloodSplatterService; public Cygnus() { Path path = ServiceBootstrap.resolveWorkingDirectory(); @@ -126,6 +134,11 @@ public Cygnus() { .orElseThrow(() -> new IllegalStateException("Spectator team not found")); this.spectatorService = new SpectatorService(spectatorTeam, survivorTeam); this.resourcePackService = ResourcePackService.create(); + this.screenOverlay = new EquipmentScreenOverlay(); + this.bloodSplatterService = new BloodSplatterService( + this.screenOverlay, + bound -> ThreadLocalRandom.current().nextInt(bound) + ); this.initPhases(); this.initCommands(); this.initListener(); @@ -136,6 +149,7 @@ public Cygnus() { private void initCommands() { var manager = MinecraftServer.getCommandManager(); manager.register(new StartCommand(this.linearPhaseSeries)); + manager.register(new BloodCommand(this.bloodSplatterService)); } @@ -191,6 +205,12 @@ private void registerGameListener() { MinecraftServer.getPacketListenerManager().setPlayListener(ClientEntityActionPacket.class, CygnusEntityActionListener::listener); spectatorService.registerListener(handler); + + // Without the pack the splatter textures do not exist, so the effect stays off wherever + // the pack is not delivered. + if (OverlayProperties.enabled()) { + this.bloodSplatterService.registerListener(handler); + } } private void initPhases() { diff --git a/game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java new file mode 100644 index 00000000..6408208d --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java @@ -0,0 +1,54 @@ +package net.onelitefeather.cygnus.blood; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; + +/** + * The side of the screen a splatter is thrown from, seen from the victim rather than from the + * world — being hit from the east means something different depending on where you are looking. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public enum BloodDirection { + + FRONT, + RIGHT, + BACK, + LEFT; + + /** Above this alignment with the view direction a hit counts as coming from straight ahead. */ + private static final double FORWARD_THRESHOLD = 0.5D; + + /** Below this distance the direction to the source carries no meaning any more. */ + private static final double DISTANCE_EPSILON = 1.0E-6D; + + /** + * Works out which side a hit came from. + * + * @param victim the victim's position, whose yaw and pitch supply the view direction + * @param source where the damage came from + * @return the side to throw the splatter from + */ + public static BloodDirection between(Pos victim, Point source) { + double distance = victim.distance(source); + if (distance < DISTANCE_EPSILON) return FRONT; + + Vec towardsSource = new Vec( + source.x() - victim.x(), + source.y() - victim.y(), + source.z() - victim.z() + ).div(distance); + Vec facing = victim.direction(); + + double alignment = facing.dot(towardsSource); + if (alignment > FORWARD_THRESHOLD) return FRONT; + if (alignment < -FORWARD_THRESHOLD) return BACK; + + // The cross product points up when the source sits on the side the victim's left hand is + // on, which for a player looking south is the east. + return facing.cross(towardsSource).y() > 0 ? LEFT : RIGHT; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java new file mode 100644 index 00000000..cec37114 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java @@ -0,0 +1,169 @@ +package net.onelitefeather.cygnus.blood; + +import net.kyori.adventure.key.Key; +import net.minestom.server.entity.Player; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.player.PlayerDisconnectEvent; +import net.onelitefeather.cygnus.common.util.PlayerState; +import net.onelitefeather.cygnus.common.util.RepeatingTask; +import net.onelitefeather.cygnus.event.PlayerDamagedEvent; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.OverlayTextureKeys; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; + +import java.time.temporal.ChronoUnit; +import java.util.Iterator; +import java.util.Locale; +import java.util.function.IntUnaryOperator; + +/** + * Throws a splatter of blood across the screen when a player is hit and fades it out again. + *

+ * The textures are laid out as direction × variant × frame. The direction aims the splatter at the + * side the hit came from, the variant keeps repeated hits from looking mechanical, and the frames + * are the fade — Minecraft cannot animate a camera overlay, so the server steps through them. + *

+ * + * @author TheMeinerLP + * @version 2.1.0 + * @since 2.7.0 + */ +public final class BloodSplatterService { + + /** How many drawings exist per direction. */ + static final int VARIANTS = 2; + + /** How many frames a splatter fades over. */ + static final int FRAMES = 12; + + /** + * How long a single frame stays on screen. Twelve frames at this rate keep the splatter alive + * for the same 1.2 seconds as six did at twice the interval, but it runs down the screen + * smoothly rather than in visible steps. + */ + static final int FRAME_MILLIS = 100; + + /** Where the splatter textures live, as {@code camera_overlay} resolves them. */ + static final String TEXTURE_PATH = "gui/blood/"; + + /** The keys, indexed {@code [direction][variant][frame]}. */ + private static final Key[][][] TEXTURES = buildTextures(); + + private final ScreenOverlay overlay; + private final IntUnaryOperator variantPicker; + private final PlayerState active = new PlayerState<>(); + + /** Fades every active splatter forward by one frame. Runs only while someone is bleeding. */ + final RepeatingTask fadeTask = new RepeatingTask(this::tick); + + /** + * Creates a new service. + * + * @param overlay the overlay that owns the player's screen + * @param variantPicker picks a variant below the given bound + */ + public BloodSplatterService(ScreenOverlay overlay, IntUnaryOperator variantPicker) { + this.overlay = overlay; + this.variantPicker = variantPicker; + } + + /** + * Listens for hits and for players leaving. + * + * @param node the node to register on + */ + public void registerListener(EventNode node) { + node.addListener(PlayerDamagedEvent.class, event -> this.splatter( + event.getPlayer(), + BloodDirection.between(event.getPlayer().getPosition(), event.getSource()) + )); + node.addListener(PlayerDisconnectEvent.class, event -> this.clear(event.getPlayer())); + } + + /** + * Throws a fresh splatter, replacing whatever is still fading. + * + * @param player the player who was hit + * @param direction the side the hit came from + */ + public void splatter(Player player, BloodDirection direction) { + Splatter splatter = new Splatter(player, direction, this.variantPicker.applyAsInt(VARIANTS)); + this.active.put(player, splatter); + this.draw(splatter); + this.fadeTask.start(FRAME_MILLIS, ChronoUnit.MILLIS); + } + + /** + * Takes the splatter off a player's screen. + * + * @param player the player to clear + */ + public void clear(Player player) { + if (this.active.remove(player) == null) return; + this.overlay.set(player, OverlayLayer.BLOOD, null); + } + + /** + * Advances every splatter by one frame and drops the ones that have faded out. + */ + void tick() { + Iterator splatters = this.active.values().iterator(); + while (splatters.hasNext()) { + Splatter splatter = splatters.next(); + splatter.frame++; + + if (splatter.frame >= FRAMES) { + splatters.remove(); + this.overlay.set(splatter.player, OverlayLayer.BLOOD, null); + continue; + } + this.draw(splatter); + } + + // Nothing is bleeding; the task would only spin over an empty map until the next hit. + if (this.active.isEmpty()) this.fadeTask.stop(); + } + + /** + * Puts a splatter's current frame on its player's screen. + * + * @param splatter the splatter to draw + */ + private void draw(Splatter splatter) { + this.overlay.set(splatter.player, OverlayLayer.BLOOD, + TEXTURES[splatter.direction.ordinal()][splatter.variant][splatter.frame]); + } + + /** + * Builds the texture key for every cell of the direction × variant × frame grid. + * + * @return the keys, indexed {@code [direction][variant][frame]} + */ + private static Key[][][] buildTextures() { + return OverlayTextureKeys.cube( + TEXTURE_PATH, + BloodDirection.values().length, VARIANTS, FRAMES, + direction -> BloodDirection.values()[direction].name().toLowerCase(Locale.ROOT), + OverlayTextureKeys.ONE_BASED, + OverlayTextureKeys.ONE_BASED + ); + } + + /** + * One player's running splatter. + */ + private static final class Splatter { + + private final Player player; + private final BloodDirection direction; + private final int variant; + private int frame; + + private Splatter(Player player, BloodDirection direction, int variant) { + this.player = player; + this.direction = direction; + this.variant = variant; + } + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/blood/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/blood/package-info.java new file mode 100644 index 00000000..86addd53 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/blood/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.onelitefeather.cygnus.blood; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java new file mode 100644 index 00000000..ce198835 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java @@ -0,0 +1,48 @@ +package net.onelitefeather.cygnus.command; + +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.arguments.ArgumentEnum; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.blood.BloodDirection; +import net.onelitefeather.cygnus.blood.BloodSplatterService; + +import java.util.concurrent.ThreadLocalRandom; + +/** + * Throws a blood splatter on demand, so the drawings can be judged without waiting to be hit. + *

+ * {@code /blood} picks a side at random, {@code /blood front|right|back|left} asks for one. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class BloodCommand extends Command { + + /** + * Creates the command. + * + * @param service the service that throws the splatter + */ + public BloodCommand(BloodSplatterService service) { + super("blood"); + + var direction = ArgumentType.Enum("side", BloodDirection.class) + .setFormat(ArgumentEnum.Format.LOWER_CASED); + + this.setDefaultExecutor((sender, context) -> { + Player player = CommandSenders.asPlayer(sender, "can bleed."); + if (player == null) return; + BloodDirection[] sides = BloodDirection.values(); + service.splatter(player, sides[ThreadLocalRandom.current().nextInt(sides.length)]); + }); + + this.addSyntax((sender, context) -> { + Player player = CommandSenders.asPlayer(sender, "can bleed."); + if (player == null) return; + service.splatter(player, context.get(direction)); + }, direction); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/event/PlayerDamagedEvent.java b/game/src/main/java/net/onelitefeather/cygnus/event/PlayerDamagedEvent.java new file mode 100644 index 00000000..15fab507 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/event/PlayerDamagedEvent.java @@ -0,0 +1,65 @@ +package net.onelitefeather.cygnus.event; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.entity.Player; +import net.minestom.server.event.trait.PlayerEvent; + +/** + * Called when a player takes damage from the game. + *

+ * Cygnus applies damage by setting health directly, which never raises Minestom's + * {@code EntityDamageEvent}. This event fills that gap for everything that needs to react to a + * hit — the blood splatter above all — and carries where the hit came from, so the reaction can + * be aimed. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +@SuppressWarnings("java:S6206") +public final class PlayerDamagedEvent implements PlayerEvent { + + private final Player player; + private final Point source; + private final float amount; + + /** + * Creates a new instance of the {@link PlayerDamagedEvent}. + * + * @param player the player who was hit + * @param source where the damage came from + * @param amount how much health was taken + */ + public PlayerDamagedEvent(Player player, Point source, float amount) { + this.player = player; + this.source = source; + this.amount = amount; + } + + /** + * {@inheritDoc} + */ + @Override + public Player getPlayer() { + return this.player; + } + + /** + * Returns where the damage came from. + * + * @return the position of the source + */ + public Point getSource() { + return this.source; + } + + /** + * Returns how much health the hit took. + * + * @return the damage amount + */ + public float getAmount() { + return this.amount; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarHelper.java b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarHelper.java index ad148080..3fc8115c 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarHelper.java +++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarHelper.java @@ -3,12 +3,14 @@ import net.kyori.adventure.sound.Sound; import net.minestom.server.coordinate.Pos; import net.minestom.server.entity.Entity; +import net.minestom.server.event.EventDispatcher; import net.minestom.server.entity.Player; import net.minestom.server.instance.Instance; import net.minestom.server.potion.Potion; import net.minestom.server.potion.PotionEffect; import net.minestom.server.potion.TimedPotion; import net.minestom.server.sound.SoundEvent; +import net.onelitefeather.cygnus.event.PlayerDamagedEvent; import java.util.Collection; import java.util.UUID; @@ -86,6 +88,9 @@ default void applyDamage(Instance instance, UUID uuid, Pos center, int range, fl boolean hasSameUUID = UUID_COMPARATOR.test(uuid, nearbyEntity.getUuid()); if (nearbyEntity instanceof Player target && !hasSameUUID && (target.getHealth() > 0)) { target.setHealth(target.getHealth() - damage); + // Setting health never raises Minestom's own damage event, so anything reacting to + // a hit — the blood splatter above all — would otherwise never hear about it. + EventDispatcher.call(new PlayerDamagedEvent(target, center, damage)); } } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/blood/BloodDirectionTest.java b/game/src/test/java/net/onelitefeather/cygnus/blood/BloodDirectionTest.java new file mode 100644 index 00000000..1b7817dd --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/blood/BloodDirectionTest.java @@ -0,0 +1,57 @@ +package net.onelitefeather.cygnus.blood; + +import net.minestom.server.coordinate.Pos; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies from which side the blood is thrown across the screen. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class BloodDirectionTest { + + /** A victim in the origin looking towards positive Z, which is a yaw of zero. */ + private static final Pos VICTIM = new Pos(0, 40, 0, 0, 0); + + @Test + @DisplayName("A hit from straight ahead lands in front") + void hitFromAheadIsFront() { + assertEquals(BloodDirection.FRONT, BloodDirection.between(VICTIM, new Pos(0, 40, 6))); + } + + @Test + @DisplayName("A hit from behind lands in the back") + void hitFromBehindIsBack() { + assertEquals(BloodDirection.BACK, BloodDirection.between(VICTIM, new Pos(0, 40, -6))); + } + + @Test + @DisplayName("Looking south, a hit from the east lands on the left") + void hitFromEastIsLeft() { + assertEquals(BloodDirection.LEFT, BloodDirection.between(VICTIM, new Pos(6, 40, 0))); + } + + @Test + @DisplayName("Looking south, a hit from the west lands on the right") + void hitFromWestIsRight() { + assertEquals(BloodDirection.RIGHT, BloodDirection.between(VICTIM, new Pos(-6, 40, 0))); + } + + @Test + @DisplayName("The victim's own facing decides, not the world") + void facingDecides() { + Pos turned = new Pos(0, 40, 0, 180, 0); + assertEquals(BloodDirection.BACK, BloodDirection.between(turned, new Pos(0, 40, 6))); + } + + @Test + @DisplayName("A hit from the exact same spot still picks a side") + void hitFromTheSameSpotIsFront() { + assertEquals(BloodDirection.FRONT, BloodDirection.between(VICTIM, new Pos(0, 40, 0))); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/blood/BloodSplatterServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/blood/BloodSplatterServiceTest.java new file mode 100644 index 00000000..9b9d9308 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/blood/BloodSplatterServiceTest.java @@ -0,0 +1,220 @@ +package net.onelitefeather.cygnus.blood; + +import net.kyori.adventure.key.Key; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.event.PlayerDamagedEvent; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the splatter that flashes up when a player is hit and fades out on its own. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class BloodSplatterServiceTest extends CygnusPlayerTestBase { + + /** Always picks the first variant, so the expected code points are predictable. */ + private static final java.util.function.IntUnaryOperator FIRST_VARIANT = bound -> 0; + + @Test + @DisplayName("A hit puts the first frame on screen right away") + void hitShowsTheFirstFrame(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + + service.splatter(player, BloodDirection.FRONT); + + assertEquals(textureOf(BloodDirection.FRONT, 0, 0), overlay.of(player, OverlayLayer.BLOOD)); + } + + @Test + @DisplayName("The direction of the hit picks a different set of frames") + void directionPicksItsOwnFrames(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + + service.splatter(player, BloodDirection.LEFT); + + assertEquals(textureOf(BloodDirection.LEFT, 0, 0), overlay.of(player, OverlayLayer.BLOOD)); + assertNotEquals(textureOf(BloodDirection.FRONT, 0, 0), overlay.of(player, OverlayLayer.BLOOD)); + } + + @Test + @DisplayName("The splatter fades frame by frame and disappears") + void splatterFadesAway(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + service.splatter(player, BloodDirection.FRONT); + + service.tick(); + assertEquals(textureOf(BloodDirection.FRONT, 0, 1), overlay.of(player, OverlayLayer.BLOOD), "the second frame follows"); + + for (int remaining = 1; remaining < BloodSplatterService.FRAMES; remaining++) { + service.tick(); + } + + assertNull(overlay.of(player, OverlayLayer.BLOOD), "the splatter has to clean up after itself"); + } + + @Test + @DisplayName("A second hit restarts the splatter") + void secondHitRestarts(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + service.splatter(player, BloodDirection.FRONT); + service.tick(); + service.tick(); + + service.splatter(player, BloodDirection.FRONT); + + assertEquals(textureOf(BloodDirection.FRONT, 0, 0), overlay.of(player, OverlayLayer.BLOOD), "a fresh hit starts over"); + } + + @Test + @DisplayName("Being hit is announced by the damage event") + void damageEventTriggersTheSplatter(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + service.registerListener(env.process().eventHandler()); + + EventDispatcher.call(new PlayerDamagedEvent(player, new Pos(0, 40, 6), 1.0F)); + + assertNull(overlay.of(player, OverlayLayer.TUNNEL_VISION), "only the blood layer belongs to this service"); + assertTrue(overlay.of(player, OverlayLayer.BLOOD) != null, "a hit has to show blood"); + } + + @Test + @DisplayName("Clearing takes the splatter off the screen") + void clearingRemovesTheSplatter(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + service.splatter(player, BloodDirection.FRONT); + + service.clear(player); + + assertNull(overlay.of(player, OverlayLayer.BLOOD)); + } + + @Test + @DisplayName("The fade task only runs while something is bleeding") + void fadeTaskTracksActiveSplatters(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + assertFalse(service.fadeTask.isRunning(), "nothing is bleeding yet"); + + service.splatter(player, BloodDirection.FRONT); + assertTrue(service.fadeTask.isRunning(), "a hit has to keep the fade task alive"); + + for (int remaining = 0; remaining < BloodSplatterService.FRAMES; remaining++) { + service.tick(); + } + + assertFalse(service.fadeTask.isRunning(), "the task stops itself once nothing is bleeding any more"); + } + + @Test + @DisplayName("Two players bleed independently") + void playersAreIndependent(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Instance instance = env.createFlatInstance(); + Player first = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player second = env.createConnection().connect(instance, new Pos(4, 40, 0)); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + + service.splatter(first, BloodDirection.FRONT); + service.tick(); + service.splatter(second, BloodDirection.BACK); + + assertEquals(textureOf(BloodDirection.FRONT, 0, 1), overlay.of(first, OverlayLayer.BLOOD)); + assertEquals(textureOf(BloodDirection.BACK, 0, 0), overlay.of(second, OverlayLayer.BLOOD)); + } + + /** + * Connects a player into a fresh instance. + * + * @param env the test environment + * @return the connected player + */ + private Player spawn(Env env) { + Instance instance = env.createFlatInstance(); + return env.createConnection().connect(instance, new Pos(0, 40, 0)); + } + + /** + * Works out the texture a direction, variant and frame map to. + * + * @param direction the direction of the hit + * @param variant the variant index + * @param frame the frame index + * @return the texture key + */ + private Key textureOf(BloodDirection direction, int variant, int frame) { + return Key.key("cygnus", "%s%s_%d_%d".formatted( + BloodSplatterService.TEXTURE_PATH, + direction.name().toLowerCase(java.util.Locale.ROOT), + variant + 1, + frame + 1 + )); + } + + /** + * Records what the service contributes, standing in for the title-backed overlay. + */ + private static final class RecordingOverlay implements ScreenOverlay { + + private final Map> layers = new HashMap<>(); + + @Override + public void set(Player player, OverlayLayer layer, @Nullable Key texture) { + Map current = + this.layers.computeIfAbsent(player.getUuid(), key -> new EnumMap<>(OverlayLayer.class)); + if (texture == null) { + current.remove(layer); + return; + } + current.put(layer, texture); + } + + @Override + public void clear(Player player) { + this.layers.remove(player.getUuid()); + } + + /** + * @param player the player to look up + * @param layer the layer to look up + * @return the glyph currently set, or {@code null} if there is none + */ + private @Nullable Key of(Player player, OverlayLayer layer) { + return this.layers.getOrDefault(player.getUuid(), Map.of()).get(layer); + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/command/BloodCommandTest.java b/game/src/test/java/net/onelitefeather/cygnus/command/BloodCommandTest.java new file mode 100644 index 00000000..06cc1cf5 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/command/BloodCommandTest.java @@ -0,0 +1,131 @@ +package net.onelitefeather.cygnus.command; + +import net.kyori.adventure.key.Key; +import net.minestom.server.MinecraftServer; +import net.minestom.server.command.builder.Command; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.blood.BloodDirection; +import net.onelitefeather.cygnus.blood.BloodSplatterService; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.EnumMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Verifies the command used to throw a splatter without waiting to be hit. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class BloodCommandTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("The command throws a splatter from the requested side") + void splatterIsThrownFromTheRequestedSide(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + register(overlay); + + MinecraftServer.getCommandManager().execute(player, "blood left"); + + assertNotNull(overlay.blood(), "the command has to put blood on screen"); + } + + @Test + @DisplayName("Without a side the command picks one itself") + void splatterWorksWithoutASide(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + register(overlay); + + MinecraftServer.getCommandManager().execute(player, "blood"); + + assertNotNull(overlay.blood(), "the bare command still has to show something"); + } + + @Test + @DisplayName("Every side of the splatter can be requested") + void everySideCanBeRequested(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + register(overlay); + + for (BloodDirection direction : BloodDirection.values()) { + overlay.forget(); + MinecraftServer.getCommandManager().execute(player, "blood " + direction.name().toLowerCase()); + assertNotNull(overlay.blood(), "no splatter for " + direction); + } + } + + /** + * Registers the command under test against the given overlay. The environment is shared across + * the tests in this class, so any command left over from an earlier one — still drawing into + * that test's overlay — has to go first. + * + * @param overlay the overlay the service draws into + */ + private void register(RecordingOverlay overlay) { + Command previous = MinecraftServer.getCommandManager().getCommand("blood"); + if (previous != null) MinecraftServer.getCommandManager().unregister(previous); + MinecraftServer.getCommandManager().register(new BloodCommand(new BloodSplatterService(overlay, bound -> 0))); + } + + /** + * Connects a player into a fresh instance. + * + * @param env the test environment + * @return the connected player + */ + private Player spawn(Env env) { + Instance instance = env.createFlatInstance(); + return env.createConnection().connect(instance, new Pos(0, 40, 0)); + } + + /** + * Records what the service contributes, standing in for the title-backed overlay. + */ + private static final class RecordingOverlay implements ScreenOverlay { + + private final Map layers = new EnumMap<>(OverlayLayer.class); + + @Override + public void set(Player player, OverlayLayer layer, @Nullable Key texture) { + if (texture == null) { + this.layers.remove(layer); + return; + } + this.layers.put(layer, texture); + } + + @Override + public void clear(Player player) { + this.layers.clear(); + } + + /** + * @return the texture currently on the blood layer, or {@code null} if there is none + */ + private @Nullable Key blood() { + return this.layers.get(OverlayLayer.BLOOD); + } + + /** + * Drops everything recorded so far, to tell repeated draws apart. + */ + private void forget() { + this.layers.clear(); + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperDamageTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperDamageTest.java new file mode 100644 index 00000000..b7925619 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperDamageTest.java @@ -0,0 +1,63 @@ +package net.onelitefeather.cygnus.stamina; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventFilter; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Collector; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.event.PlayerDamagedEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies that damage dealt by the slender is announced, since setting health directly never + * raises Minestom's own damage event. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class SlenderBarHelperDamageTest extends CygnusPlayerTestBase { + + private static final float DAMAGE = 0.5F; + private static final int RANGE = 3; + + private final SlenderBarHelper helper = new SlenderBarHelper() { + }; + + @Test + @DisplayName("A damaged player is announced together with where the hit came from") + void damageIsAnnounced(Env env) { + Instance instance = env.createFlatInstance(); + Player attacker = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player victim = env.createConnection().connect(instance, new Pos(1, 40, 0)); + Pos center = new Pos(0, 40, 0); + Collector collector = + env.trackEvent(PlayerDamagedEvent.class, EventFilter.PLAYER, victim); + + this.helper.applyDamage(instance, attacker.getUuid(), center, RANGE, DAMAGE); + + collector.assertSingle(event -> { + assertEquals(victim, event.getPlayer(), "the victim has to be the one that was hit"); + assertEquals(center, event.getSource(), "the source is what aims the splatter"); + assertEquals(DAMAGE, event.getAmount(), "the amount travels along for anything that scales with it"); + }); + } + + @Test + @DisplayName("The player dealing the damage is left out") + void attackerIsNotAnnounced(Env env) { + Instance instance = env.createFlatInstance(); + Player attacker = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Collector collector = + env.trackEvent(PlayerDamagedEvent.class, EventFilter.PLAYER, attacker); + + this.helper.applyDamage(instance, attacker.getUuid(), new Pos(0, 40, 0), RANGE, DAMAGE); + + collector.assertEmpty(); + } +}