From 536fa774dfad2b04d21c1da01a49768934ed7193 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 12 Aug 2026 09:25:35 +0200 Subject: [PATCH 1/5] feat(blood): add blood splatter service and direction resolution Cygnus needed a way to tell a survivor they were just hit and from which side, separate from the tunnel vision's read on how they are doing overall. The splatter rides the shared screen overlay's BLOOD layer, fades over twelve 100 ms frames (1.2 seconds total), and BloodDirection.between resolves the hit side from the victim's own facing rather than world coordinates, so a hit from the east reads differently depending on which way the player is looking. --- .../cygnus/blood/BloodDirection.java | 54 +++++ .../cygnus/blood/BloodSplatterService.java | 169 ++++++++++++++ .../cygnus/blood/package-info.java | 4 + .../cygnus/blood/BloodDirectionTest.java | 57 +++++ .../blood/BloodSplatterServiceTest.java | 220 ++++++++++++++++++ 5 files changed, 504 insertions(+) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/blood/package-info.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/blood/BloodDirectionTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/blood/BloodSplatterServiceTest.java 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/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); + } + } +} From 63e4bc56fd6e3e1bb3cdb342555bf82246797935 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 12 Aug 2026 09:25:42 +0200 Subject: [PATCH 2/5] feat(event): dispatch PlayerDamagedEvent when stamina damage is applied SlenderBarHelper.applyDamage sets a target's health directly, which never raises Minestom's own EntityDamageEvent, so nothing reacting to a hit - the blood splatter above all - would otherwise hear about it. This adds PlayerDamagedEvent, carrying the victim, the source position and the amount, and dispatches it right after the health is lowered. The source position is what lets the splatter be aimed at the side the hit came from. --- .../cygnus/event/PlayerDamagedEvent.java | 65 +++++++++++++++++++ .../cygnus/stamina/SlenderBarHelper.java | 5 ++ .../stamina/SlenderBarHelperDamageTest.java | 63 ++++++++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/event/PlayerDamagedEvent.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperDamageTest.java 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/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(); + } +} From 1f346d87d89641e6f35afdd82bda355ddbeea9b5 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 12 Aug 2026 09:25:49 +0200 Subject: [PATCH 3/5] feat(command): add /blood command to trigger splatters on demand Waiting to get hit is a slow way to judge a splatter drawing. /blood throws one from a random side, and /blood front|right|back|left asks for a specific one, so the four directions and their variants can be checked without needing a slender in the game. --- .../cygnus/command/BloodCommand.java | 48 +++++++ .../cygnus/command/BloodCommandTest.java | 131 ++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/command/BloodCommandTest.java 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/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(); + } + } +} From ea91755f90cec45cb2babedcf217cdc90a3af992 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 12 Aug 2026 09:25:55 +0200 Subject: [PATCH 4/5] feat(cygnus): wire blood splatter service into game bootstrap Builds the shared screen overlay and the blood splatter service, registers /blood, and hooks the service's listener behind the same OverlayProperties guard the other overlay effects use, since the splatter textures only exist when a resource pack that ships them is configured. --- .../net/onelitefeather/cygnus/Cygnus.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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() { From 7f3f77d24a26ed7baa117c6e85d9dfadeef9bd98 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 12 Aug 2026 09:26:01 +0200 Subject: [PATCH 5/5] docs(blood): add blood splatter design spec Records why the splatter shares the screen overlay's head slot with the tunnel vision instead of pre-rendering every combination, how the direction is worked out from the victim's facing, and the frame/texture layout the cygnus-pack generator relies on. --- .../specs/2026-08-11-blood-splatter-design.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-11-blood-splatter-design.md 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.