From 3c5edb935fb32225eeb4792f6e0cf89d139c6d5b Mon Sep 17 00:00:00 2001
From: TheMeinerLP
Date: Wed, 12 Aug 2026 09:25:35 +0200
Subject: [PATCH 1/9] 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 53bfecf1cd7ce901ef9e69c0b37f18457a6a058c Mon Sep 17 00:00:00 2001
From: TheMeinerLP
Date: Wed, 12 Aug 2026 09:25:42 +0200
Subject: [PATCH 2/9] 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 924a41402d707389a3f78d0b29625ee7bb97c4ff Mon Sep 17 00:00:00 2001
From: TheMeinerLP
Date: Wed, 12 Aug 2026 09:25:49 +0200
Subject: [PATCH 3/9] 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 9e0b40671a339b68070de65d46109b2a7195015c Mon Sep 17 00:00:00 2001
From: TheMeinerLP
Date: Wed, 12 Aug 2026 09:25:55 +0200
Subject: [PATCH 4/9] 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.
---
.../main/java/net/onelitefeather/cygnus/Cygnus.java | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
index fee931e3..5dadbc48 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
@@ -41,6 +41,8 @@
import net.minestom.server.network.packet.client.play.ClientEntityActionPacket;
import net.onelitefeather.cygnus.ambient.AmbientProvider;
import net.onelitefeather.cygnus.command.GlitchCommand;
+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;
@@ -79,9 +81,6 @@
import net.onelitefeather.cygnus.resourcepack.ResourcePackService;
import net.onelitefeather.cygnus.stamina.SlenderBarTrigger;
import net.onelitefeather.cygnus.stamina.StaminaService;
-import net.onelitefeather.cygnus.overlay.ScreenOverlay;
-import net.onelitefeather.cygnus.overlay.EquipmentScreenOverlay;
-import net.onelitefeather.cygnus.overlay.OverlayProperties;
import net.onelitefeather.cygnus.utils.StaminaHelper;
import net.onelitefeather.cygnus.utils.ViewRuleUpdater;
import net.onelitefeather.cygnus.view.GameView;
@@ -90,6 +89,7 @@
import java.nio.file.Path;
import java.util.Optional;
+import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Supplier;
/**
@@ -121,6 +121,7 @@ public final class Cygnus implements TeamCreator, ListenerHandling {
*/
private final ScreenOverlay screenOverlay;
private final SlenderGazeService slenderGazeService;
+ private final BloodSplatterService bloodSplatterService;
public Cygnus() {
Path path = ServiceBootstrap.resolveWorkingDirectory();
@@ -147,6 +148,10 @@ public Cygnus() {
this.screenOverlay = new EquipmentScreenOverlay();
this.slenderGazeService = new SlenderGazeService(
this.screenOverlay, () -> TeamHelper.slenderOf(this.teamService));
+ this.bloodSplatterService = new BloodSplatterService(
+ this.screenOverlay,
+ bound -> ThreadLocalRandom.current().nextInt(bound)
+ );
this.initPhases();
this.initCommands();
this.initListener();
@@ -158,6 +163,7 @@ private void initCommands() {
var manager = MinecraftServer.getCommandManager();
manager.register(new StartCommand(this.linearPhaseSeries));
manager.register(new GlitchCommand(this.slenderGazeService));
+ manager.register(new BloodCommand(this.bloodSplatterService));
}
@@ -230,6 +236,7 @@ private void registerGameListener() {
private void registerOverlayListeners(GlobalEventHandler handler) {
if (!OverlayProperties.enabled()) return;
this.slenderGazeService.registerListener(handler, () -> TeamHelper.survivorsOf(this.teamService));
+ this.bloodSplatterService.registerListener(handler);
}
private void initPhases() {
From c9198f1c31e4d3fb39778d88a0857b4b4c78b294 Mon Sep 17 00:00:00 2001
From: TheMeinerLP
Date: Wed, 12 Aug 2026 09:26:01 +0200
Subject: [PATCH 5/9] 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.
From 409f76ba7c1a1b8b5200f6424ccc8afe5b6b484c Mon Sep 17 00:00:00 2001
From: TheMeinerLP
Date: Sun, 23 Aug 2026 10:41:23 +0200
Subject: [PATCH 6/9] refactor(blood): follow the conventions the rest of the
game already uses
PlayerState and RepeatingTask moved to game/utils in the foundation; the imports
follow.
The "only players can bleed" text becomes Messages.ONLY_PLAYERS_CAN_BLEED, next
to the other player-facing texts, now that CommandSenders takes a finished
Component rather than assembling one from a sentence fragment.
The recording ScreenOverlay the tests stood up twice is the foundation's
RecordingScreenOverlay instead - the same duplication this stack removes from
the production code had grown back in the test sources.
The comment gating the effect in Cygnus still described a vignette font that no
longer exists and tied the gate to the resource pack, which OverlayProperties
deliberately is not tied to.
Left alone on purpose: clear(Player) keeps its name. It removes one player, and
on main the no-arg cleanUp() is the round teardown while removePlayer(Player) is
the per-player counterpart - renaming it would have said the opposite of what it
does.
---
.../cygnus/common/Messages.java | 2 +
.../net/onelitefeather/cygnus/Cygnus.java | 2 +-
.../cygnus/blood/BloodSplatterService.java | 6 +-
.../cygnus/command/BloodCommand.java | 7 ++-
.../blood/BloodSplatterServiceTest.java | 59 +++---------------
.../cygnus/command/BloodCommandTest.java | 62 +++----------------
6 files changed, 30 insertions(+), 108 deletions(-)
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java b/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java
index 348eda3a..d43e57d3 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java
@@ -31,6 +31,7 @@ public final class Messages {
public static final Component SURVIVOR_WIN_MESSAGE;
public static final Component LIGHT_WENT_OUT;
public static final Component ONLY_PLAYERS_HAVE_A_VIEW;
+ public static final Component ONLY_PLAYERS_CAN_BLEED;
private static final Component PAGE_FOUND_PART;
private static final Component LEAVE_PART;
private static final Component JOIN_PART;
@@ -63,6 +64,7 @@ public final class Messages {
JOIN_PART = Component.text("joined the game!", NamedTextColor.GRAY);
LIGHT_WENT_OUT = withMiniPrefix("Your light went out!");
ONLY_PLAYERS_HAVE_A_VIEW = withMiniPrefix("Only players have a view to lose.");
+ ONLY_PLAYERS_CAN_BLEED = withMiniPrefix("Only players can bleed.");
SURVIVOR_JOIN_PART_UPPER = withMiniPrefix("You are a Survivor! Find various Pages").append(Component.space());
diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
index 5dadbc48..6e7efca7 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
@@ -94,7 +94,7 @@
/**
* @author theEvilReaper
- * @version 1.0.0
+ * @version 1.1.0
* @since 1.0.0
**/
@SuppressWarnings("java:S3252")
diff --git a/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java
index cec37114..cde88a49 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java
@@ -5,12 +5,12 @@
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 net.onelitefeather.cygnus.utils.PlayerState;
+import net.onelitefeather.cygnus.utils.RepeatingTask;
import java.time.temporal.ChronoUnit;
import java.util.Iterator;
@@ -26,7 +26,7 @@
*
*
* @author TheMeinerLP
- * @version 2.1.0
+ * @version 2.1.1
* @since 2.7.0
*/
public final class BloodSplatterService {
diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java
index ce198835..7a450de0 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java
@@ -6,6 +6,7 @@
import net.minestom.server.entity.Player;
import net.onelitefeather.cygnus.blood.BloodDirection;
import net.onelitefeather.cygnus.blood.BloodSplatterService;
+import net.onelitefeather.cygnus.common.Messages;
import java.util.concurrent.ThreadLocalRandom;
@@ -16,7 +17,7 @@
*
*
* @author TheMeinerLP
- * @version 1.0.0
+ * @version 1.1.0
* @since 2.7.0
*/
public final class BloodCommand extends Command {
@@ -33,14 +34,14 @@ public BloodCommand(BloodSplatterService service) {
.setFormat(ArgumentEnum.Format.LOWER_CASED);
this.setDefaultExecutor((sender, context) -> {
- Player player = CommandSenders.asPlayer(sender, "can bleed.");
+ Player player = CommandSenders.asPlayer(sender, Messages.ONLY_PLAYERS_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.");
+ Player player = CommandSenders.asPlayer(sender, Messages.ONLY_PLAYERS_CAN_BLEED);
if (player == null) return;
service.splatter(player, context.get(direction));
}, direction);
diff --git a/game/src/test/java/net/onelitefeather/cygnus/blood/BloodSplatterServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/blood/BloodSplatterServiceTest.java
index 9b9d9308..d53ca3eb 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/blood/BloodSplatterServiceTest.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/blood/BloodSplatterServiceTest.java
@@ -9,16 +9,10 @@
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 net.onelitefeather.cygnus.overlay.RecordingScreenOverlay;
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;
@@ -29,7 +23,7 @@
* Verifies the splatter that flashes up when a player is hit and fades out on its own.
*
* @author TheMeinerLP
- * @version 1.0.0
+ * @version 1.1.0
* @since 2.7.0
*/
class BloodSplatterServiceTest extends CygnusPlayerTestBase {
@@ -40,7 +34,7 @@ class BloodSplatterServiceTest extends CygnusPlayerTestBase {
@Test
@DisplayName("A hit puts the first frame on screen right away")
void hitShowsTheFirstFrame(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Player player = spawn(env);
BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT);
@@ -52,7 +46,7 @@ void hitShowsTheFirstFrame(Env env) {
@Test
@DisplayName("The direction of the hit picks a different set of frames")
void directionPicksItsOwnFrames(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Player player = spawn(env);
BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT);
@@ -65,7 +59,7 @@ void directionPicksItsOwnFrames(Env env) {
@Test
@DisplayName("The splatter fades frame by frame and disappears")
void splatterFadesAway(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Player player = spawn(env);
BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT);
service.splatter(player, BloodDirection.FRONT);
@@ -83,7 +77,7 @@ void splatterFadesAway(Env env) {
@Test
@DisplayName("A second hit restarts the splatter")
void secondHitRestarts(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Player player = spawn(env);
BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT);
service.splatter(player, BloodDirection.FRONT);
@@ -98,7 +92,7 @@ void secondHitRestarts(Env env) {
@Test
@DisplayName("Being hit is announced by the damage event")
void damageEventTriggersTheSplatter(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Player player = spawn(env);
BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT);
service.registerListener(env.process().eventHandler());
@@ -112,7 +106,7 @@ void damageEventTriggersTheSplatter(Env env) {
@Test
@DisplayName("Clearing takes the splatter off the screen")
void clearingRemovesTheSplatter(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Player player = spawn(env);
BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT);
service.splatter(player, BloodDirection.FRONT);
@@ -125,7 +119,7 @@ void clearingRemovesTheSplatter(Env env) {
@Test
@DisplayName("The fade task only runs while something is bleeding")
void fadeTaskTracksActiveSplatters(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Player player = spawn(env);
BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT);
assertFalse(service.fadeTask.isRunning(), "nothing is bleeding yet");
@@ -143,7 +137,7 @@ void fadeTaskTracksActiveSplatters(Env env) {
@Test
@DisplayName("Two players bleed independently")
void playersAreIndependent(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
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));
@@ -184,37 +178,4 @@ private Key textureOf(BloodDirection direction, int variant, int frame) {
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
index 06cc1cf5..76ad38ec 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/command/BloodCommandTest.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/command/BloodCommandTest.java
@@ -1,6 +1,5 @@
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;
@@ -11,22 +10,17 @@
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 net.onelitefeather.cygnus.overlay.RecordingScreenOverlay;
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
+ * @version 1.1.0
* @since 2.7.0
*/
class BloodCommandTest extends CygnusPlayerTestBase {
@@ -34,38 +28,38 @@ class BloodCommandTest extends CygnusPlayerTestBase {
@Test
@DisplayName("The command throws a splatter from the requested side")
void splatterIsThrownFromTheRequestedSide(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Player player = spawn(env);
register(overlay);
MinecraftServer.getCommandManager().execute(player, "blood left");
- assertNotNull(overlay.blood(), "the command has to put blood on screen");
+ assertNotNull(overlay.of(player, OverlayLayer.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();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Player player = spawn(env);
register(overlay);
MinecraftServer.getCommandManager().execute(player, "blood");
- assertNotNull(overlay.blood(), "the bare command still has to show something");
+ assertNotNull(overlay.of(player, OverlayLayer.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();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Player player = spawn(env);
register(overlay);
for (BloodDirection direction : BloodDirection.values()) {
- overlay.forget();
+ overlay.clear(player);
MinecraftServer.getCommandManager().execute(player, "blood " + direction.name().toLowerCase());
- assertNotNull(overlay.blood(), "no splatter for " + direction);
+ assertNotNull(overlay.of(player, OverlayLayer.BLOOD), "no splatter for " + direction);
}
}
@@ -76,7 +70,7 @@ void everySideCanBeRequested(Env env) {
*
* @param overlay the overlay the service draws into
*/
- private void register(RecordingOverlay overlay) {
+ private void register(RecordingScreenOverlay overlay) {
Command previous = MinecraftServer.getCommandManager().getCommand("blood");
if (previous != null) MinecraftServer.getCommandManager().unregister(previous);
MinecraftServer.getCommandManager().register(new BloodCommand(new BloodSplatterService(overlay, bound -> 0)));
@@ -92,40 +86,4 @@ 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 e7ef325accfce81cb93602b74543bc165d18afd3 Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Sun, 23 Aug 2026 21:43:18 +0200
Subject: [PATCH 7/9] chore(game): remove blood test command
---
.../net/onelitefeather/cygnus/Cygnus.java | 1 -
.../cygnus/command/BloodCommand.java | 49 ----------
.../cygnus/command/BloodCommandTest.java | 89 -------------------
3 files changed, 139 deletions(-)
delete mode 100644 game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java
delete mode 100644 game/src/test/java/net/onelitefeather/cygnus/command/BloodCommandTest.java
diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
index 6e7efca7..0993e304 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
@@ -42,7 +42,6 @@
import net.onelitefeather.cygnus.ambient.AmbientProvider;
import net.onelitefeather.cygnus.command.GlitchCommand;
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;
diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java
deleted file mode 100644
index 7a450de0..00000000
--- a/game/src/main/java/net/onelitefeather/cygnus/command/BloodCommand.java
+++ /dev/null
@@ -1,49 +0,0 @@
-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 net.onelitefeather.cygnus.common.Messages;
-
-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.1.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, Messages.ONLY_PLAYERS_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, Messages.ONLY_PLAYERS_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
deleted file mode 100644
index 76ad38ec..00000000
--- a/game/src/test/java/net/onelitefeather/cygnus/command/BloodCommandTest.java
+++ /dev/null
@@ -1,89 +0,0 @@
-package net.onelitefeather.cygnus.command;
-
-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.RecordingScreenOverlay;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-
-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.1.0
- * @since 2.7.0
- */
-class BloodCommandTest extends CygnusPlayerTestBase {
-
- @Test
- @DisplayName("The command throws a splatter from the requested side")
- void splatterIsThrownFromTheRequestedSide(Env env) {
- RecordingScreenOverlay overlay = new RecordingScreenOverlay();
- Player player = spawn(env);
- register(overlay);
-
- MinecraftServer.getCommandManager().execute(player, "blood left");
-
- assertNotNull(overlay.of(player, OverlayLayer.BLOOD), "the command has to put blood on screen");
- }
-
- @Test
- @DisplayName("Without a side the command picks one itself")
- void splatterWorksWithoutASide(Env env) {
- RecordingScreenOverlay overlay = new RecordingScreenOverlay();
- Player player = spawn(env);
- register(overlay);
-
- MinecraftServer.getCommandManager().execute(player, "blood");
-
- assertNotNull(overlay.of(player, OverlayLayer.BLOOD), "the bare command still has to show something");
- }
-
- @Test
- @DisplayName("Every side of the splatter can be requested")
- void everySideCanBeRequested(Env env) {
- RecordingScreenOverlay overlay = new RecordingScreenOverlay();
- Player player = spawn(env);
- register(overlay);
-
- for (BloodDirection direction : BloodDirection.values()) {
- overlay.clear(player);
- MinecraftServer.getCommandManager().execute(player, "blood " + direction.name().toLowerCase());
- assertNotNull(overlay.of(player, OverlayLayer.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(RecordingScreenOverlay 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));
- }
-}
From 6bb67450f0da30133abffc52d47b202b87448efb Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Sun, 23 Aug 2026 21:45:02 +0200
Subject: [PATCH 8/9] chore(blood): add cached variant
---
.../cygnus/blood/BloodDirection.java | 19 +++++++++++++++++--
.../cygnus/blood/BloodSplatterService.java | 4 ++--
2 files changed, 19 insertions(+), 4 deletions(-)
diff --git a/game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java
index 6408208d..f67b0e62 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java
@@ -19,10 +19,16 @@ public enum BloodDirection {
BACK,
LEFT;
- /** Above this alignment with the view direction a hit counts as coming from straight ahead. */
+ private static final BloodDirection[] VALUES = values();
+
+ /**
+ * 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. */
+ /**
+ * Below this distance the direction to the source carries no meaning any more.
+ */
private static final double DISTANCE_EPSILON = 1.0E-6D;
/**
@@ -51,4 +57,13 @@ public static BloodDirection between(Pos victim, Point source) {
// on, which for a player looking south is the east.
return facing.cross(towardsSource).y() > 0 ? LEFT : RIGHT;
}
+
+ /**
+ * Returns all possible values of this enum.
+ *
+ * @return values of this enum
+ */
+ public static BloodDirection[] getValues() {
+ return VALUES;
+ }
}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java
index cde88a49..394bad9c 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java
@@ -143,8 +143,8 @@ private void draw(Splatter splatter) {
private static Key[][][] buildTextures() {
return OverlayTextureKeys.cube(
TEXTURE_PATH,
- BloodDirection.values().length, VARIANTS, FRAMES,
- direction -> BloodDirection.values()[direction].name().toLowerCase(Locale.ROOT),
+ BloodDirection.getValues().length, VARIANTS, FRAMES,
+ direction -> BloodDirection.getValues()[direction].name().toLowerCase(Locale.ROOT),
OverlayTextureKeys.ONE_BASED,
OverlayTextureKeys.ONE_BASED
);
From f002dc735b3400a0e2774a070d6f71616a2e7d6b Mon Sep 17 00:00:00 2001
From: theEvilReaper
Date: Sun, 23 Aug 2026 21:48:42 +0200
Subject: [PATCH 9/9] chore(game): remove test command
---
game/src/main/java/net/onelitefeather/cygnus/Cygnus.java | 2 --
1 file changed, 2 deletions(-)
diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
index 0993e304..57a92ab7 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
@@ -162,10 +162,8 @@ private void initCommands() {
var manager = MinecraftServer.getCommandManager();
manager.register(new StartCommand(this.linearPhaseSeries));
manager.register(new GlitchCommand(this.slenderGazeService));
- manager.register(new BloodCommand(this.bloodSplatterService));
}
-
private void initListener() {
Supplier phaseSupplier = this.linearPhaseSeries::getCurrentPhase;
var manager = MinecraftServer.getGlobalEventHandler();