From fd072dcf02422a63966a0acd73491d4345693b9d Mon Sep 17 00:00:00 2001
From: TheMeinerLP
Date: Wed, 12 Aug 2026 09:17:04 +0200
Subject: [PATCH 01/12] feat(common): add RepeatingTask to unify scheduler
start/stop guards
Four different services (tunnel vision, blood splatter, slender gaze, and the
stamina bar) each hand-rolled the same start/stop guard around a Minestom
Task: a nullable field, a null-check before scheduling, and a null-check
before cancelling, repeated with small variations at every call site.
Extract that guard once as RepeatingTask so the follow-up feature branches
can adopt a single, tested implementation instead of copying the pattern a
fifth time.
---
.../cygnus/common/util/RepeatingTask.java | 79 +++++++++++++++
.../util/RepeatingTaskIntegrationTest.java | 99 +++++++++++++++++++
2 files changed, 178 insertions(+)
create mode 100644 common/src/main/java/net/onelitefeather/cygnus/common/util/RepeatingTask.java
create mode 100644 common/src/test/java/net/onelitefeather/cygnus/common/util/RepeatingTaskIntegrationTest.java
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/RepeatingTask.java b/common/src/main/java/net/onelitefeather/cygnus/common/util/RepeatingTask.java
new file mode 100644
index 00000000..3193a424
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/util/RepeatingTask.java
@@ -0,0 +1,79 @@
+package net.onelitefeather.cygnus.common.util;
+
+import net.minestom.server.MinecraftServer;
+import net.minestom.server.timer.Task;
+import org.jetbrains.annotations.Nullable;
+
+import java.time.temporal.TemporalUnit;
+
+/**
+ * Owns a single Minestom repeating scheduler {@link Task}.
+ *
+ * {@code AmbientProvider}, {@code TunnelVisionService}, {@code SlenderGazeService} and
+ * {@code BloodSplatterService} each hand-rolled the same {@code @Nullable Task} field with a
+ * guard-and-return {@code startTask()}/{@code stopTask()} pair. This type is that field, extracted
+ * once: start and stop are both idempotent, so a caller never has to remember whether it already
+ * called either of them.
+ *
+ *
+ * The action to run is constructor-injected rather than passed to {@link #start(long, TemporalUnit)},
+ * because every one of the four services above ran exactly one action for the lifetime of the task
+ * and never swapped it out.
+ *
+ *
+ * Usage:
+ * {@code
+ * RepeatingTask task = new RepeatingTask(this::tick);
+ * task.start(1, ChronoUnit.SECONDS);
+ * // ...
+ * task.stop();
+ * }
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+public final class RepeatingTask {
+
+ private final Runnable action;
+ private @Nullable Task task;
+
+ /**
+ * Creates a task that, once started, runs the given action on every repetition.
+ *
+ * @param action the action to run
+ */
+ public RepeatingTask(Runnable action) {
+ this.action = action;
+ }
+
+ /**
+ * Starts the task with the given period. Does nothing if the task is already running.
+ *
+ * @param period the amount of {@code unit}s between two runs
+ * @param unit the unit {@code period} is measured in
+ */
+ public void start(long period, TemporalUnit unit) {
+ if (this.task != null) return;
+ this.task = MinecraftServer.getSchedulerManager()
+ .buildTask(this.action)
+ .repeat(period, unit)
+ .schedule();
+ }
+
+ /**
+ * Stops the task. Does nothing if the task is not running.
+ */
+ public void stop() {
+ if (this.task == null) return;
+ this.task.cancel();
+ this.task = null;
+ }
+
+ /**
+ * @return {@code true} if the task is currently running
+ */
+ public boolean isRunning() {
+ return this.task != null;
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/util/RepeatingTaskIntegrationTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/util/RepeatingTaskIntegrationTest.java
new file mode 100644
index 00000000..429d5368
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/util/RepeatingTaskIntegrationTest.java
@@ -0,0 +1,99 @@
+package net.onelitefeather.cygnus.common.util;
+
+import net.minestom.testing.Env;
+import net.minestom.testing.extension.MicrotusExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import java.time.temporal.ChronoUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies that {@link RepeatingTask} owns exactly one scheduler task no matter how many times
+ * start and stop are called.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+@ExtendWith(MicrotusExtension.class)
+class RepeatingTaskIntegrationTest {
+
+ @Test
+ void notRunningBeforeStart() {
+ RepeatingTask task = new RepeatingTask(() -> {
+ });
+
+ assertFalse(task.isRunning());
+ }
+
+ @Test
+ void runsOnceStarted(Env env) {
+ AtomicInteger ticks = new AtomicInteger();
+ RepeatingTask task = new RepeatingTask(ticks::incrementAndGet);
+
+ task.start(50, ChronoUnit.MILLIS);
+ assertTrue(task.isRunning());
+ for (int i = 0; i < 10; i++) {
+ env.tick();
+ }
+
+ assertTrue(ticks.get() > 0, "the action should have run at least once by now");
+ }
+
+ @Test
+ void startIsIdempotent(Env env) {
+ AtomicInteger ticks = new AtomicInteger();
+ RepeatingTask task = new RepeatingTask(ticks::incrementAndGet);
+
+ task.start(50, ChronoUnit.MILLIS);
+ task.start(50, ChronoUnit.MILLIS);
+ for (int i = 0; i < 10; i++) {
+ env.tick();
+ }
+ int afterFirstBatch = ticks.get();
+
+ // Stopping cancels the single task this class is meant to own. If start() had scheduled a
+ // second task on the repeated call, stop() would only ever reach one of them and the other
+ // would keep running forever, still incrementing the counter below.
+ task.stop();
+ for (int i = 0; i < 10; i++) {
+ env.tick();
+ }
+
+ assertEquals(afterFirstBatch, ticks.get(), "a leaked second task would still be ticking");
+ }
+
+ @Test
+ void stopStopsTheTask(Env env) {
+ AtomicInteger ticks = new AtomicInteger();
+ RepeatingTask task = new RepeatingTask(ticks::incrementAndGet);
+ task.start(50, ChronoUnit.MILLIS);
+ for (int i = 0; i < 10; i++) {
+ env.tick();
+ }
+
+ task.stop();
+ assertFalse(task.isRunning());
+ int afterStop = ticks.get();
+ for (int i = 0; i < 10; i++) {
+ env.tick();
+ }
+
+ assertEquals(afterStop, ticks.get(), "no more runs should happen after stop()");
+ }
+
+ @Test
+ void stopIsIdempotent() {
+ RepeatingTask task = new RepeatingTask(() -> {
+ });
+
+ task.stop();
+
+ assertFalse(task.isRunning(), "stopping a task that never ran must not throw");
+ }
+}
From 6d0ce2f04042b3b373c4aca57dc08b79787f484b Mon Sep 17 00:00:00 2001
From: TheMeinerLP
Date: Wed, 12 Aug 2026 09:17:12 +0200
Subject: [PATCH 02/12] feat(common): add PlayerState to replace hand-rolled
per-player maps
Five separate hand-rolled per-player maps existed across the tunnel vision,
blood splatter, and slender gaze code, each keyed by UUID and each managing
its own put-on-join/remove-on-leave lifecycle by hand. The duplication made
every one of those call sites a place a leak or a stale-entry bug could hide.
PlayerState wraps that lifecycle once behind a small, tested type, so the
three follow-up feature branches can store their per-player values without
re-deriving the same map bookkeeping.
---
.../cygnus/common/util/PlayerState.java | 105 ++++++++++++++++
.../cygnus/common/util/PlayerStateTest.java | 116 ++++++++++++++++++
2 files changed, 221 insertions(+)
create mode 100644 common/src/main/java/net/onelitefeather/cygnus/common/util/PlayerState.java
create mode 100644 common/src/test/java/net/onelitefeather/cygnus/common/util/PlayerStateTest.java
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/PlayerState.java b/common/src/main/java/net/onelitefeather/cygnus/common/util/PlayerState.java
new file mode 100644
index 00000000..33445809
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/util/PlayerState.java
@@ -0,0 +1,105 @@
+package net.onelitefeather.cygnus.common.util;
+
+import net.minestom.server.entity.Player;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Supplier;
+
+/**
+ * Keeps one value per player, keyed by {@link Player#getUuid()}.
+ *
+ * {@code EquipmentScreenOverlay}, {@code TunnelVisionService}, {@code BloodSplatterService},
+ * {@code SlenderGazeService} and {@code TunnelVisionCommand} each hand-rolled their own
+ * {@code Map} field for this, disagreeing along the way on {@link ConcurrentHashMap} versus
+ * {@link java.util.LinkedHashMap}. This type settles that: it is backed by a
+ * {@code ConcurrentHashMap}, because state that outlives a single tick has to survive being written
+ * from a scheduler task and cleared from a disconnect or death listener in the same round, and
+ * nothing in this project pins both of those to the same thread. Three of the five call sites this
+ * type replaces already reached for {@code ConcurrentHashMap} for exactly that reason; the other two
+ * used a {@code LinkedHashMap} only for its insertion order, which none of the five ever relied on.
+ * Correctness under a race a caller does not control beats an ordering guarantee nobody asked for.
+ *
+ *
+ * @param the kind of value tracked per player
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+public final class PlayerState {
+
+ private final Map values = new ConcurrentHashMap<>();
+
+ /**
+ * Stores a value for the given player, replacing whatever was tracked before.
+ *
+ * @param player the player to store a value for
+ * @param value the value to store
+ */
+ public void put(Player player, V value) {
+ this.values.put(player.getUuid(), value);
+ }
+
+ /**
+ * Reads the value tracked for the given player.
+ *
+ * @param player the player to read
+ * @return the tracked value, or {@code null} if none is tracked
+ */
+ public @Nullable V get(Player player) {
+ return this.values.get(player.getUuid());
+ }
+
+ /**
+ * Reads the value tracked for the given player, computing and storing one first if none is
+ * tracked yet.
+ *
+ * @param player the player to read
+ * @param supplier supplies the value to store when none is tracked yet
+ * @return the tracked value, existing or freshly computed
+ */
+ public V computeIfAbsent(Player player, Supplier supplier) {
+ return this.values.computeIfAbsent(player.getUuid(), _ -> supplier.get());
+ }
+
+ /**
+ * Stops tracking the given player.
+ *
+ * @param player the player to forget
+ * @return the value that was tracked for them, or {@code null} if none was
+ */
+ public @Nullable V remove(Player player) {
+ return this.values.remove(player.getUuid());
+ }
+
+ /**
+ * The tracked values, without the players they belong to.
+ *
+ * The returned collection is a live view over the backing map: removing through its iterator
+ * also stops tracking that player, which is what lets a caller fade values out one by one while
+ * walking them, the way {@code BloodSplatterService} does.
+ *
+ *
+ * @return a live view over the tracked values
+ */
+ public Collection values() {
+ return this.values.values();
+ }
+
+ /**
+ * @return {@code true} if no player is currently tracked
+ */
+ public boolean isEmpty() {
+ return this.values.isEmpty();
+ }
+
+ /**
+ * Stops tracking every player.
+ */
+ public void clear() {
+ this.values.clear();
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/util/PlayerStateTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/util/PlayerStateTest.java
new file mode 100644
index 00000000..cb4fc585
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/util/PlayerStateTest.java
@@ -0,0 +1,116 @@
+package net.onelitefeather.cygnus.common.util;
+
+import net.minestom.server.entity.Player;
+import net.minestom.server.instance.Instance;
+import net.minestom.testing.Env;
+import net.minestom.testing.extension.MicrotusExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import java.util.Iterator;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies that {@link PlayerState} tracks one value per player, keeps players apart, and forgets
+ * them cleanly.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+@ExtendWith(MicrotusExtension.class)
+class PlayerStateTest {
+
+ @Test
+ void nothingIsTrackedForAFreshPlayer(Env env) {
+ Player player = spawn(env);
+ PlayerState state = new PlayerState<>();
+
+ assertNull(state.get(player));
+ assertTrue(state.isEmpty());
+ }
+
+ @Test
+ void putThenGetReturnsTheStoredValue(Env env) {
+ Player player = spawn(env);
+ PlayerState state = new PlayerState<>();
+
+ state.put(player, "value");
+
+ assertEquals("value", state.get(player));
+ assertFalse(state.isEmpty());
+ }
+
+ @Test
+ void playersAreKeptApart(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player first = env.createPlayer(instance);
+ Player second = env.createPlayer(instance);
+ PlayerState state = new PlayerState<>();
+
+ state.put(first, "first");
+ state.put(second, "second");
+
+ assertEquals("first", state.get(first));
+ assertEquals("second", state.get(second));
+ }
+
+ @Test
+ void removeForgetsThePlayerAndReturnsTheOldValue(Env env) {
+ Player player = spawn(env);
+ PlayerState state = new PlayerState<>();
+ state.put(player, "value");
+
+ assertEquals("value", state.remove(player));
+ assertNull(state.get(player));
+ assertNull(state.remove(player), "removing an untracked player must not throw");
+ }
+
+ @Test
+ void computeIfAbsentStoresAndReusesTheComputedValue(Env env) {
+ Player player = spawn(env);
+ PlayerState state = new PlayerState<>();
+
+ StringBuilder first = state.computeIfAbsent(player, StringBuilder::new);
+ StringBuilder second = state.computeIfAbsent(player, StringBuilder::new);
+
+ assertEquals(first, second, "a second call must not overwrite the already-tracked value");
+ }
+
+ @Test
+ void clearForgetsEveryPlayer(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player first = env.createPlayer(instance);
+ Player second = env.createPlayer(instance);
+ PlayerState state = new PlayerState<>();
+ state.put(first, "first");
+ state.put(second, "second");
+
+ state.clear();
+
+ assertTrue(state.isEmpty());
+ }
+
+ @Test
+ void removingThroughValuesForgetsThePlayerToo(Env env) {
+ Player player = spawn(env);
+ PlayerState state = new PlayerState<>();
+ state.put(player, "value");
+
+ Iterator values = state.values().iterator();
+ values.next();
+ values.remove();
+
+ assertTrue(state.isEmpty(), "the map backing values() must be live, the way BloodSplatterService needs it");
+ assertNull(state.get(player));
+ }
+
+ private Player spawn(Env env) {
+ Instance instance = env.createFlatInstance();
+ return env.createPlayer(instance);
+ }
+}
From 7bd5e90c28f34ed451f3e10031504b499a05805c Mon Sep 17 00:00:00 2001
From: TheMeinerLP
Date: Wed, 12 Aug 2026 09:17:19 +0200
Subject: [PATCH 03/12] feat(common): add Helper.clamp to replace hand-rolled
min/max clamping
The tunnel vision and slender gaze code each clamped values into range with
their own Math.min(max, Math.max(min, value)) expression, which is easy to
get backwards (min/max swapped) and gives no shared place to fix it once.
Add int and double overloads of Helper.clamp so the follow-up feature
branches can call one well-tested method instead of repeating the
expression.
---
.../cygnus/common/util/Helper.java | 30 ++++++++++-
.../cygnus/common/util/HelperTest.java | 54 +++++++++++++++++++
2 files changed, 83 insertions(+), 1 deletion(-)
create mode 100644 common/src/test/java/net/onelitefeather/cygnus/common/util/HelperTest.java
diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/Helper.java b/common/src/main/java/net/onelitefeather/cygnus/common/util/Helper.java
index d45d0eee..1764491c 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/util/Helper.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/util/Helper.java
@@ -11,7 +11,7 @@
* and game-specific identifiers or timings.
*
* @author theEvilReaper
- * @version 1.0.2
+ * @version 1.1.0
* @since 1.0.0
**/
public final class Helper {
@@ -69,6 +69,34 @@ public static int getRandomInt(int maximumValue) {
return ThreadLocalRandom.current().nextInt(0, maximumValue);
}
+ /**
+ * Clamps a value to lie within the given bounds, in place of hand-rolling
+ * {@code Math.min(max, Math.max(min, value))} at every call site.
+ *
+ * @param value the value to clamp
+ * @param min the inclusive lower bound
+ * @param max the inclusive upper bound
+ * @return {@code min} if {@code value} is lower, {@code max} if it is higher, {@code value} otherwise
+ */
+ @Contract(pure = true)
+ public static int clamp(int value, int min, int max) {
+ return Math.clamp(value, min, max);
+ }
+
+ /**
+ * Clamps a value to lie within the given bounds, in place of hand-rolling
+ * {@code Math.min(max, Math.max(min, value))} at every call site.
+ *
+ * @param value the value to clamp
+ * @param min the inclusive lower bound
+ * @param max the inclusive upper bound
+ * @return {@code min} if {@code value} is lower, {@code max} if it is higher, {@code value} otherwise
+ */
+ @Contract(pure = true)
+ public static double clamp(double value, double min, double max) {
+ return Math.clamp(value, min, max);
+ }
+
/**
* Adjusts the placement coordinates of a collectible page entity based on the
* block face/direction it is attached to, ensuring it aligns correctly and remains visible.
diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/util/HelperTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/util/HelperTest.java
new file mode 100644
index 00000000..1bb53b16
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/util/HelperTest.java
@@ -0,0 +1,54 @@
+package net.onelitefeather.cygnus.common.util;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Verifies {@link Helper#clamp(int, int, int)} and {@link Helper#clamp(double, double, double)},
+ * which replace the hand-rolled {@code Math.min(hi, Math.max(lo, x))} scattered across the tunnel
+ * vision and slender gaze code.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+class HelperTest {
+
+ @Test
+ @DisplayName("An int within bounds is returned unchanged")
+ void intWithinBoundsIsUnchanged() {
+ assertEquals(5, Helper.clamp(5, 0, 10));
+ }
+
+ @Test
+ @DisplayName("An int below the lower bound is raised to it")
+ void intBelowLowerBoundIsRaised() {
+ assertEquals(0, Helper.clamp(-5, 0, 10));
+ }
+
+ @Test
+ @DisplayName("An int above the upper bound is lowered to it")
+ void intAboveUpperBoundIsLowered() {
+ assertEquals(10, Helper.clamp(15, 0, 10));
+ }
+
+ @Test
+ @DisplayName("A double within bounds is returned unchanged")
+ void doubleWithinBoundsIsUnchanged() {
+ assertEquals(0.5D, Helper.clamp(0.5D, 0.0D, 1.0D));
+ }
+
+ @Test
+ @DisplayName("A double below the lower bound is raised to it")
+ void doubleBelowLowerBoundIsRaised() {
+ assertEquals(0.0D, Helper.clamp(-0.5D, 0.0D, 1.0D));
+ }
+
+ @Test
+ @DisplayName("A double above the upper bound is lowered to it")
+ void doubleAboveUpperBoundIsLowered() {
+ assertEquals(1.0D, Helper.clamp(1.5D, 0.0D, 1.0D));
+ }
+}
From c3078f69e8634717d24ef5b011867a97b15a45e8 Mon Sep 17 00:00:00 2001
From: TheMeinerLP
Date: Wed, 12 Aug 2026 09:17:31 +0200
Subject: [PATCH 04/12] feat(overlay): add shared screen overlay foundation
Three texture-key builders existed across the tunnel vision, blood splatter,
and slender gaze designs, each assembling equipment-slot texture paths with
its own naming convention (stage_, __,
level__), so nothing about them could be reused or tested
together.
Add the shared overlay package: ScreenOverlay and OverlayLayer describe
where an overlay sits and how it renders, OverlayProperties and
EquipmentScreenOverlay handle applying it to a player's equipment slot, and
OverlayTextureKeys unifies the three texture-key conventions behind one
type. This is the rendering base the three follow-up feature branches
(tunnel vision, blood splatter, slender gaze) build their per-effect logic
on top of.
---
.../overlay/EquipmentScreenOverlay.java | 142 ++++++++++++++++++
.../cygnus/overlay/OverlayLayer.java | 21 +++
.../cygnus/overlay/OverlayProperties.java | 35 +++++
.../cygnus/overlay/OverlayTextureKeys.java | 112 ++++++++++++++
.../cygnus/overlay/ScreenOverlay.java | 35 +++++
.../cygnus/overlay/package-info.java | 4 +
.../overlay/EquipmentScreenOverlayTest.java | 140 +++++++++++++++++
.../cygnus/overlay/OverlayPropertiesTest.java | 50 ++++++
.../overlay/OverlayTextureKeysTest.java | 54 +++++++
9 files changed, 593 insertions(+)
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayLayer.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayProperties.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeys.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/overlay/ScreenOverlay.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/overlay/package-info.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlayTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayPropertiesTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeysTest.java
diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java
new file mode 100644
index 00000000..244d80f9
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java
@@ -0,0 +1,142 @@
+package net.onelitefeather.cygnus.overlay;
+
+import net.kyori.adventure.key.Key;
+import net.minestom.server.component.DataComponents;
+import net.minestom.server.entity.EquipmentSlot;
+import net.minestom.server.entity.Player;
+import net.minestom.server.item.ItemStack;
+import net.minestom.server.item.Material;
+import net.minestom.server.item.component.Equippable;
+import net.minestom.server.sound.SoundEvent;
+import net.onelitefeather.cygnus.common.util.PlayerState;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.EnumMap;
+import java.util.Map;
+
+/**
+ * Puts the overlay on screen as the {@code camera_overlay} of an item worn on the head.
+ *
+ * This is the one mechanism in vanilla that draws a texture across the whole screen and scales it
+ * with the viewport — the same one the carved pumpkin uses. A font glyph cannot do that: its size
+ * is fixed in the pack, so it has to be calibrated against a resolution and drifts on every other.
+ *
+ *
+ * A player has one head, so only one layer can be shown at a time. The topmost one wins, which
+ * means a splatter of blood takes the screen for as long as it lasts and the tunnel vision comes
+ * back underneath it afterwards.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+public final class EquipmentScreenOverlay implements ScreenOverlay {
+
+ /**
+ * What the overlay rides on. The item itself is never seen — {@link #EMPTY_ASSET} makes sure of
+ * that — so the material only has to exist.
+ */
+ private static final Material CARRIER = Material.PAPER;
+
+ /**
+ * An equipment model with no layers, from the resource pack. Without an asset id Minecraft
+ * falls back to drawing the item itself on the player's head.
+ */
+ private static final String EMPTY_ASSET = "cygnus:empty";
+
+ /** Vanilla's silent sound; the default equip sound would click on every stage change. */
+ private static final SoundEvent SILENT = SoundEvent.of(Key.key("minecraft:intentionally_empty"), null);
+
+ private final PlayerState
*
* @author TheMeinerLP
- * @version 1.0.0
+ * @version 1.1.0
* @since 2.7.0
*/
public final class SlenderGaze {
@@ -63,7 +62,7 @@ public static int levelOf(Pos survivor, Pos slender) {
if (survivor.direction().dot(towardsSlender) < FIELD_OF_VIEW) return NONE;
double nearness = (RANGE - distance) / (RANGE - CLOSE);
- double clamped = Helper.clamp(nearness, 0.0D, 1.0D);
+ double clamped = Math.clamp(nearness, 0.0D, 1.0D);
return (int) Math.round(clamped * (LEVELS - 1));
}
}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java
index a21eda61..494f0463 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java
@@ -7,14 +7,13 @@
import net.minestom.server.event.player.PlayerDeathEvent;
import net.minestom.server.event.player.PlayerDisconnectEvent;
import net.minestom.server.instance.Instance;
-import net.onelitefeather.cygnus.common.util.Helper;
-import net.onelitefeather.cygnus.common.util.PlayerState;
-import net.onelitefeather.cygnus.common.util.RepeatingTask;
import net.onelitefeather.cygnus.event.GameFinishEvent;
import net.onelitefeather.cygnus.event.GameStartEvent;
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 org.jetbrains.annotations.Nullable;
import java.time.temporal.ChronoUnit;
@@ -35,7 +34,7 @@
*
*
* @author TheMeinerLP
- * @version 2.0.0
+ * @version 3.0.0
* @since 2.7.0
*/
public final class SlenderGazeService {
@@ -94,7 +93,7 @@ public void registerListener(EventNode node, Supplier> surviv
node.addListener(PlayerDeathEvent.class, event -> this.remove(event.getPlayer()));
node.addListener(PlayerDisconnectEvent.class, event -> this.remove(event.getPlayer()));
node.addListener(GameFinishEvent.class, event -> {
- this.clearAll();
+ this.cleanUp();
this.stopTask();
});
}
@@ -108,7 +107,7 @@ public void startTask() {
/**
* Stops the update task. Does nothing if it is not running. Leaves whatever is on a tracked
- * survivor's screen where it is — pair with {@link #clearAll()} where every screen needs wiping
+ * survivor's screen where it is — pair with {@link #cleanUp()} where every screen needs wiping
* too.
*/
public void stopTask() {
@@ -137,7 +136,7 @@ public void remove(Player player) {
/**
* Clears every tracked survivor's screen and forgets all of them.
*/
- public void clearAll() {
+ public void cleanUp() {
for (Player survivor : this.survivors.values()) {
this.overlay.set(survivor, OverlayLayer.GLITCH, null);
}
@@ -153,24 +152,27 @@ public void clearAll() {
* a second time or exposing it, trading one seam for a worse one over two lines of
* {@code GlitchCommand} preview code.
*
+ *
+ * Showing and clearing are one method rather than two because {@link SlenderGaze#NONE} already
+ * says "nothing to draw" everywhere else in this class — {@link #tick()} reads it off
+ * {@link SlenderGaze#levelOf(net.minestom.server.coordinate.Pos, net.minestom.server.coordinate.Pos)}
+ * on every pass — so a separate {@code hide} would be a second spelling of a level the type
+ * already has.
+ *
*
* @param player the player to draw for
- * @param level the level between {@code 0} and {@code SlenderGaze.LEVELS - 1}
+ * @param level the level between {@code 0} and {@code SlenderGaze.LEVELS - 1}, or
+ * {@link SlenderGaze#NONE} to take the tearing off their screen
*/
- public void show(Player player, int level) {
- int clamped = Helper.clamp(level, 0, SlenderGaze.LEVELS - 1);
+ public void preview(Player player, int level) {
+ if (level == SlenderGaze.NONE) {
+ this.overlay.set(player, OverlayLayer.GLITCH, null);
+ return;
+ }
+ int clamped = Math.clamp(level, 0, SlenderGaze.LEVELS - 1);
this.overlay.set(player, OverlayLayer.GLITCH, TEXTURES[clamped][this.frame % FRAMES]);
}
- /**
- * Takes the tearing off a player's screen.
- *
- * @param player the player to clear
- */
- public void hide(Player player) {
- this.overlay.set(player, OverlayLayer.GLITCH, null);
- }
-
/**
* Advances the tearing by one frame and redraws every survivor.
*/
diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayProperties.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayProperties.java
index 76581c82..8ca15d86 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayProperties.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayProperties.java
@@ -1,7 +1,8 @@
package net.onelitefeather.cygnus.overlay;
/**
- * Decides whether the full-screen overlays — the tunnel vision and the blood splatter — run.
+ * Decides whether the full-screen overlays — the tunnel vision, the slender's glitch and the
+ * blood splatter — run.
*
* They used to be tied to the ResourcePack feature, on the grounds that without the pack their
* textures are missing and a player would get a fullscreen checkerboard. That was too blunt: a
@@ -10,7 +11,7 @@
*
*
* @author TheMeinerLP
- * @version 1.0.0
+ * @version 1.1.0
* @since 2.7.0
*/
public final class OverlayProperties {
diff --git a/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java b/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java
index 772756b1..f42226e2 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.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,14 +10,10 @@
import net.onelitefeather.cygnus.gaze.SlenderGaze;
import net.onelitefeather.cygnus.gaze.SlenderGazeService;
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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -26,7 +21,7 @@
* Verifies the command used to preview the slender's glitch without him being there.
*
* @author TheMeinerLP
- * @version 1.0.0
+ * @version 2.0.0
* @since 2.7.0
*/
class GlitchCommandTest extends CygnusPlayerTestBase {
@@ -34,39 +29,39 @@ class GlitchCommandTest extends CygnusPlayerTestBase {
@Test
@DisplayName("A requested level is drawn right away")
void levelIsDrawnOnRequest(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Player player = spawn(env);
register(overlay);
MinecraftServer.getCommandManager().execute(player, "glitch 2");
- assertNotNull(overlay.glitch(), "the command has to put the glitch on screen");
+ assertNotNull(overlay.of(player, OverlayLayer.GLITCH), "the command has to put the glitch on screen");
}
@Test
@DisplayName("Switching the preview off clears the screen")
void offClearsTheScreen(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Player player = spawn(env);
register(overlay);
MinecraftServer.getCommandManager().execute(player, "glitch 2");
MinecraftServer.getCommandManager().execute(player, "glitch off");
- assertNull(overlay.glitch(), "the preview must disappear");
+ assertNull(overlay.of(player, OverlayLayer.GLITCH), "the preview must disappear");
}
@Test
@DisplayName("Every level of the tearing can be requested")
void everyLevelCanBeRequested(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Player player = spawn(env);
register(overlay);
for (int level = 1; level <= SlenderGaze.LEVELS; level++) {
- overlay.forget();
+ overlay.clear(player);
MinecraftServer.getCommandManager().execute(player, "glitch " + level);
- assertNotNull(overlay.glitch(), "no glitch for level " + level);
+ assertNotNull(overlay.of(player, OverlayLayer.GLITCH), "no glitch for level " + level);
}
}
@@ -77,7 +72,7 @@ void everyLevelCanBeRequested(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("glitch");
if (previous != null) MinecraftServer.getCommandManager().unregister(previous);
MinecraftServer.getCommandManager().register(new GlitchCommand(new SlenderGazeService(overlay, () -> null)));
@@ -93,40 +88,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 glitch layer, or {@code null} if there is none
- */
- private @Nullable Key glitch() {
- return this.layers.get(OverlayLayer.GLITCH);
- }
-
- /**
- * Drops everything recorded so far, to tell repeated draws apart.
- */
- private void forget() {
- this.layers.clear();
- }
- }
}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeServiceTest.java
index 3186ee9b..67d072dd 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeServiceTest.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeServiceTest.java
@@ -12,16 +12,11 @@
import net.onelitefeather.cygnus.event.GameFinishEvent;
import net.onelitefeather.cygnus.event.GameStartEvent;
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.Set;
-import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
@@ -32,7 +27,7 @@
* Verifies the tearing a survivor gets while the slender stands in their view.
*
* @author TheMeinerLP
- * @version 1.0.0
+ * @version 2.0.0
* @since 2.7.0
*/
class SlenderGazeServiceTest extends CygnusPlayerTestBase {
@@ -40,7 +35,7 @@ class SlenderGazeServiceTest extends CygnusPlayerTestBase {
@Test
@DisplayName("Seeing the slender tears the survivor's view")
void seeingHimTearsTheView(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Instance instance = env.createFlatInstance();
Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0));
Player slender = connect(env, instance, new Pos(0, 40, 5));
@@ -55,7 +50,7 @@ void seeingHimTearsTheView(Env env) {
@Test
@DisplayName("With him behind them there is nothing to see")
void behindThemNothingHappens(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Instance instance = env.createFlatInstance();
Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0));
Player slender = connect(env, instance, new Pos(0, 40, -5));
@@ -70,7 +65,7 @@ void behindThemNothingHappens(Env env) {
@Test
@DisplayName("Looking away takes it off again")
void lookingAwayClearsIt(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Instance instance = env.createFlatInstance();
Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0));
Player slender = connect(env, instance, new Pos(0, 40, 5));
@@ -87,7 +82,7 @@ void lookingAwayClearsIt(Env env) {
@Test
@DisplayName("The tearing runs on while he stays in view")
void tearingKeepsMoving(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Instance instance = env.createFlatInstance();
Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0));
Player slender = connect(env, instance, new Pos(0, 40, 5));
@@ -105,7 +100,7 @@ void tearingKeepsMoving(Env env) {
@Test
@DisplayName("Without a slender nothing happens at all")
void withoutASlenderNothingHappens(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Player survivor = connect(env, env.createFlatInstance(), new Pos(0, 40, 0, 0, 0));
SlenderGazeService service = new SlenderGazeService(overlay, () -> null);
service.track(survivor);
@@ -118,7 +113,7 @@ void withoutASlenderNothingHappens(Env env) {
@Test
@DisplayName("A removed survivor gets their view back")
void removedSurvivorIsCleared(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Instance instance = env.createFlatInstance();
Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0));
Player slender = connect(env, instance, new Pos(0, 40, 5));
@@ -134,8 +129,8 @@ void removedSurvivorIsCleared(Env env) {
@Test
@DisplayName("Clearing everyone gives every survivor their screen back")
- void clearAllWipesEveryone(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ void cleanUpWipesEveryone(Env env) {
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Instance instance = env.createFlatInstance();
Player first = connect(env, instance, new Pos(0, 40, 0, 0, 0));
Player second = connect(env, instance, new Pos(4, 40, 0, 0, 0));
@@ -145,19 +140,19 @@ void clearAllWipesEveryone(Env env) {
service.track(second);
service.tick();
- service.clearAll();
+ service.cleanUp();
assertNull(overlay.of(first, OverlayLayer.GLITCH));
assertNull(overlay.of(second, OverlayLayer.GLITCH));
service.tick();
- assertNull(overlay.of(first, OverlayLayer.GLITCH), "clearAll must stop the drawing as well");
+ assertNull(overlay.of(first, OverlayLayer.GLITCH), "cleanUp must stop the drawing as well");
}
@Test
@DisplayName("The start of a round takes the survivors on board")
void gameStartTracksSurvivors(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Instance instance = env.createFlatInstance();
Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0));
Player slender = connect(env, instance, new Pos(0, 40, 5));
@@ -173,7 +168,7 @@ void gameStartTracksSurvivors(Env env) {
@Test
@DisplayName("A dying survivor gets their screen back")
void deathRemovesTheSurvivor(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Instance instance = env.createFlatInstance();
Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0));
Player slender = connect(env, instance, new Pos(0, 40, 5));
@@ -191,7 +186,7 @@ void deathRemovesTheSurvivor(Env env) {
@Test
@DisplayName("The end of a round clears everyone")
void gameFinishClearsEveryone(Env env) {
- RecordingOverlay overlay = new RecordingOverlay();
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
Instance instance = env.createFlatInstance();
Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0));
Player slender = connect(env, instance, new Pos(0, 40, 5));
@@ -216,37 +211,4 @@ void gameFinishClearsEveryone(Env env) {
private Player connect(Env env, Instance instance, Pos position) {
return env.createConnection().connect(instance, position);
}
-
- /**
- * Records what the service contributes, standing in for the equipment-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 texture 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 f2f377c7d031cd0910001e904a8cb102b8fe39d6 Mon Sep 17 00:00:00 2001
From: Phillipp Glanz <6745190+TheMeinerLP@users.noreply.github.com>
Date: Sun, 23 Aug 2026 21:53:27 +0200
Subject: [PATCH 12/12] feat(blood): add blood splatter overlay on damage
(#180)
---
.../cygnus/common/Messages.java | 2 +
.../specs/2026-08-11-blood-splatter-design.md | 104 ++++++++++
.../net/onelitefeather/cygnus/Cygnus.java | 13 +-
.../cygnus/blood/BloodDirection.java | 69 +++++++
.../cygnus/blood/BloodSplatterService.java | 169 ++++++++++++++++
.../cygnus/blood/package-info.java | 4 +
.../cygnus/event/PlayerDamagedEvent.java | 65 +++++++
.../cygnus/stamina/SlenderBarHelper.java | 32 +---
.../cygnus/blood/BloodDirectionTest.java | 57 ++++++
.../blood/BloodSplatterServiceTest.java | 181 ++++++++++++++++++
.../stamina/SlenderBarHelperDamageTest.java | 63 ++++++
11 files changed, 732 insertions(+), 27 deletions(-)
create mode 100644 docs/superpowers/specs/2026-08-11-blood-splatter-design.md
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/main/java/net/onelitefeather/cygnus/event/PlayerDamagedEvent.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
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperDamageTest.java
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 cffeb1a2..960bebd6 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;
@@ -64,6 +65,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/docs/superpowers/specs/2026-08-11-blood-splatter-design.md b/docs/superpowers/specs/2026-08-11-blood-splatter-design.md
new file mode 100644
index 00000000..f26bd1a5
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-11-blood-splatter-design.md
@@ -0,0 +1,104 @@
+# Blood splatter on damage
+
+## Goal
+
+Taking a hit throws blood across the screen: it appears at once, from the side the hit came from,
+and fades away within about a second. It says nothing about how the player is doing — that is the
+tunnel vision's job — it only says *you were just hit, from over there*.
+
+## Sharing the screen with the tunnel vision
+
+Both effects are full-screen overlays, and both are drawn as the `camera_overlay` of an item on the
+player's head — the only mechanism in vanilla that scales a texture to the viewport instead of
+being calibrated against one resolution.
+
+A player has one head, so **only one overlay can be shown at a time.** A `ScreenOverlay` owns the
+head slot and decides: each effect hands it a texture for its layer (`OverlayLayer.TUNNEL_VISION`,
+`OverlayLayer.BLOOD`, in drawing order) and the topmost one wins. A splatter therefore takes the
+screen for the 1.2 seconds it lasts, and the tunnel vision comes back underneath it afterwards.
+
+The alternative was pre-rendering every combination of splatter frame and vignette stage, so both
+stay visible at once. That is 48 × 4 extra images at the coarsest useful resolution, and every
+change to either effect would force re-rendering all of them.
+
+Two smaller things follow from riding on an item: the carrier points its `asset_id` at an empty
+equipment model so it is never drawn on the player's head, and the overlay is only re-sent when the
+texture actually changes — an equipment update goes out to every viewer, not just the wearer.
+
+## Trigger
+
+Cygnus applies damage in `SlenderBarHelper.applyDamage` by setting health directly. That never
+raises Minestom's `EntityDamageEvent`, so a listener on it would never fire.
+
+`applyDamage` therefore dispatches a `PlayerDamagedEvent` carrying the victim, the source position
+and the amount — the same shape the project already uses for `StaminaStateChangeEvent` and
+`SlenderReviveEvent`. The source position is what lets the splatter be aimed; the amount is not
+used yet but is the natural handle for anything that should scale with how hard the hit was.
+
+## Direction
+
+`BloodDirection.between(victim, source)` reduces the hit to one of four sides, seen from the victim
+rather than from the world:
+
+```
+alignment = dot(victimLookDirection, directionToSource)
+alignment > 0.5 -> FRONT
+alignment < -0.5 -> BACK
+cross(facing, towardsSource).y > 0 -> LEFT, else RIGHT
+```
+
+A hit from the east lands on the left for a player looking south and on the right for one looking
+north. From the exact same spot the direction is meaningless, so it falls back to FRONT.
+
+## Frames
+
+Textures are laid out as direction × variant × frame: 4 × 2 × 6 = 48. The variants keep repeated
+hits from looking mechanical, and the frames are the fade — Minecraft cannot animate a camera
+overlay, so the server steps through them, one every 200 ms, giving a splatter that lives 1.2
+seconds. A fresh hit restarts the sequence rather than queueing behind the old one.
+
+The task that drives the fade starts with the first splatter and stops once nothing is bleeding
+any more, rather than spinning over an empty map between hits.
+
+Drawings are generated by `tools/generate_overlay.py` in `cygnus-pack`: drops are placed with a
+power-law radius — many specks, few real blotches — weighted towards the side the hit came from,
+then blurred and thresholded so they melt into shapes with ragged edges instead of reading as
+confetti. Bigger blotches grow a run downwards that lengthens as the frame fades, and a band along
+the edge the hit came from seals the gaps the drops leave — without it a side splatter looks like
+it stops short of the border. Textures are 1024×576, matching the 16:9 they are stretched onto.
+
+## Wiring
+
+`Cygnus` creates the service and `/blood`, and the service listens for itself:
+
+| Event | What happens |
+| --- | --- |
+| `PlayerDamagedEvent` | throws a splatter from the direction of the source |
+| `PlayerDisconnectEvent` | drops the player's splatter |
+
+Like the tunnel vision, it is only registered when a resource pack is configured — without the
+pack the textures are missing and players would get a fullscreen missing-texture checkerboard.
+
+`/blood [front|right|back|left]` throws one on demand, with no side meaning a random one, so the
+drawings can be judged without waiting to be hit.
+
+## Failure modes
+
+| Situation | Behaviour |
+| --- | --- |
+| Hit while a splatter is still fading | the old one is replaced, the sequence restarts |
+| Hit from the victim's own position | falls back to `FRONT` |
+| Player leaves mid-fade | the splatter is dropped with them |
+| Tunnel vision changes during a splatter | the splatter keeps the screen; the new stage shows once it is over |
+
+## Tests
+
+- `BloodDirectionTest` — plain JUnit: each of the four sides, that the victim's facing decides
+ rather than the world, and the degenerate same-spot case.
+- `BloodSplatterServiceTest` — the first frame appears immediately, the fade walks the frames and
+ cleans up, a second hit restarts, the damage event triggers it, players are independent.
+- `SlenderBarHelperDamageTest` — damage announces the victim and the source, and leaves out the
+ player who dealt it.
+- `BloodCommandTest` — every side can be requested, and the bare command picks one.
+- `EquipmentScreenOverlayTest` — the blood wins over the tunnel vision, the tunnel vision returns
+ afterwards, the slot empties with the last layer, and an unchanged overlay is not re-sent.
diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
index 39f52085..9c2efd93 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
@@ -42,6 +42,7 @@
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.StartCommand;
import net.onelitefeather.cygnus.common.ListenerHandling;
import net.onelitefeather.cygnus.common.bootstrap.ServiceBootstrap;
@@ -81,9 +82,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.view.GameView;
import net.onelitefeather.cygnus.view.GameViewImpl;
@@ -91,11 +89,12 @@
import java.nio.file.Path;
import java.util.Optional;
+import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Supplier;
/**
* @author theEvilReaper
- * @version 1.0.0
+ * @version 1.1.0
* @since 1.0.0
**/
@SuppressWarnings("java:S3252")
@@ -114,6 +113,7 @@ public final class Cygnus implements TeamCreator, ListenerHandling {
private final Optional resourcePackService;
private final ScreenOverlay screenOverlay;
private final SlenderGazeService slenderGazeService;
+ private final BloodSplatterService bloodSplatterService;
public Cygnus() {
Path path = ServiceBootstrap.resolveWorkingDirectory();
@@ -140,6 +140,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();
@@ -220,6 +224,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() {
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..f67b0e62
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java
@@ -0,0 +1,69 @@
+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;
+
+ 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.
+ */
+ 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;
+ }
+
+ /**
+ * 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
new file mode 100644
index 00000000..394bad9c
--- /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.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;
+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.1
+ * @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.getValues().length, VARIANTS, FRAMES,
+ direction -> BloodDirection.getValues()[direction].name().toLowerCase(Locale.ROOT),
+ OverlayTextureKeys.ONE_BASED,
+ OverlayTextureKeys.ONE_BASED
+ );
+ }
+
+ /**
+ * One player's running splatter.
+ */
+ private static final class Splatter {
+
+ private final Player player;
+ private final BloodDirection direction;
+ private final int variant;
+ private int frame;
+
+ private Splatter(Player player, BloodDirection direction, int variant) {
+ this.player = player;
+ this.direction = direction;
+ this.variant = variant;
+ }
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/blood/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/blood/package-info.java
new file mode 100644
index 00000000..86addd53
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/blood/package-info.java
@@ -0,0 +1,4 @@
+@NotNullByDefault
+package net.onelitefeather.cygnus.blood;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/game/src/main/java/net/onelitefeather/cygnus/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 79cf4f25..489c01d3 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 net.onelitefeather.cygnus.team.TeamHelper;
import java.util.Collection;
@@ -84,32 +86,16 @@ default void applyDamage(Instance instance, UUID uuid, Pos center, int range, fl
Collection nearbyEntities = instance.getNearbyEntities(center, range);
if (nearbyEntities.isEmpty()) return;
for (Entity nearbyEntity : nearbyEntities) {
- if (!(nearbyEntity instanceof Player target)) continue;
- if (UUID_COMPARATOR.test(uuid, target.getUuid())) continue;
- if (!isDamageableSurvivor(target)) continue;
- target.setHealth(target.getHealth() - damage);
+ 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));
+ }
}
}
- /**
- * Checks whether the given player may take damage from the slender.
- *
- * Only players of the survivor team are valid targets. The slender itself and every spectator
- * must stay untouched, otherwise a spectator would slowly bleed out and trigger the whole death
- * pipeline a second time. Because {@link Player#setHealth(float)} bypasses the damage event
- * chain, the game mode is checked as a second, independent guard: it stays correct even if the
- * team tag and the game mode ever drift apart.
- *
- * @param target the player to check
- * @return {@code true} if the player is a living survivor that may take damage
- */
- private static boolean isDamageableSurvivor(Player target) {
- return TeamHelper.isSurvivorTeam(target)
- && !target.getGameMode().invulnerable()
- && !target.isDead()
- && target.getHealth() > 0;
- }
-
/**
* Plays the spawn sound to all players in the given range.
*
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..d53ca3eb
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/blood/BloodSplatterServiceTest.java
@@ -0,0 +1,181 @@
+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.RecordingScreenOverlay;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+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.1.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) {
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
+ 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) {
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
+ 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) {
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
+ 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) {
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
+ 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) {
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
+ 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) {
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
+ 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) {
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
+ 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) {
+ 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));
+ 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
+ ));
+ }
+}
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();
+ }
+}