From fd072dcf02422a63966a0acd73491d4345693b9d Mon Sep 17 00:00:00 2001
From: TheMeinerLP
Date: Wed, 12 Aug 2026 09:17:04 +0200
Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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();
+ }
+}
From 7d59c0f3834a2c1a2ee89d4261bfadd17db70e23 Mon Sep 17 00:00:00 2001
From: TheMeinerLP
Date: Tue, 25 Aug 2026 23:36:19 +0200
Subject: [PATCH 13/14] fix(stamina): restore the survivor guard applyDamage
lost to the blood splatter
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The blood splatter merge replaced the whole loop body of applyDamage with its own
version, which dropped the role check main added in #199. Everything in range was
taking slender damage again: the slender itself and, worse, spectators, who would
slowly bleed out and run through the death pipeline a second time.
Both intents belong together — only survivors are valid targets, and the hit still
has to be announced through PlayerDamagedEvent, because setHealth bypasses
Minestom's own damage event and the splatter listens for nothing else.
SlenderBarHelperDamageTest now tags its two players with the roles the guard
expects, so it exercises a hit that is actually allowed.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../cygnus/stamina/SlenderBarHelper.java | 33 +++++++++++++++----
.../stamina/SlenderBarHelperDamageTest.java | 5 +++
2 files changed, 31 insertions(+), 7 deletions(-)
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 489c01d3..74164bcd 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarHelper.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarHelper.java
@@ -86,16 +86,35 @@ 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) {
- 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));
- }
+ if (!(nearbyEntity instanceof Player target)) continue;
+ if (UUID_COMPARATOR.test(uuid, target.getUuid())) continue;
+ if (!isDamageableSurvivor(target)) continue;
+ 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/stamina/SlenderBarHelperDamageTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperDamageTest.java
index b7925619..3784ab4c 100644
--- a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperDamageTest.java
+++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperDamageTest.java
@@ -7,6 +7,8 @@
import net.minestom.testing.Collector;
import net.minestom.testing.Env;
import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import net.onelitefeather.cygnus.common.Tags;
+import net.onelitefeather.cygnus.common.config.GameConfig;
import net.onelitefeather.cygnus.event.PlayerDamagedEvent;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -34,7 +36,9 @@ class SlenderBarHelperDamageTest extends CygnusPlayerTestBase {
void damageIsAnnounced(Env env) {
Instance instance = env.createFlatInstance();
Player attacker = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ attacker.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY);
Player victim = env.createConnection().connect(instance, new Pos(1, 40, 0));
+ victim.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY);
Pos center = new Pos(0, 40, 0);
Collector collector =
env.trackEvent(PlayerDamagedEvent.class, EventFilter.PLAYER, victim);
@@ -53,6 +57,7 @@ void damageIsAnnounced(Env env) {
void attackerIsNotAnnounced(Env env) {
Instance instance = env.createFlatInstance();
Player attacker = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ attacker.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY);
Collector collector =
env.trackEvent(PlayerDamagedEvent.class, EventFilter.PLAYER, attacker);
From beaadd1a40fd809bd15055c19d33784be567d3ae Mon Sep 17 00:00:00 2001
From: Phillipp Glanz <6745190+TheMeinerLP@users.noreply.github.com>
Date: Wed, 26 Aug 2026 09:22:57 +0200
Subject: [PATCH 14/14] feat(tunnel-vision): add survivor tunnel vision effect
(#178)
---
.../cygnus/common/Messages.java | 18 ++
.../specs/2026-08-10-tunnel-vision-design.md | 208 ++++++++++++++++
.../net/onelitefeather/cygnus/Cygnus.java | 11 +-
.../cygnus/command/CommandSenders.java | 54 ----
.../cygnus/command/GlitchCommand.java | 50 ----
.../cygnus/stamina/FoodBar.java | 13 +
.../OverlayTunnelVisionRenderer.java | 61 +++++
.../tunnelvision/TunnelVisionIntensity.java | 44 ++++
.../tunnelvision/TunnelVisionRenderer.java | 35 +++
.../tunnelvision/TunnelVisionService.java | 161 ++++++++++++
.../tunnelvision/TunnelVisionStage.java | 78 ++++++
.../cygnus/tunnelvision/package-info.java | 4 +
.../cygnus/utils/StaminaHelper.java | 22 +-
.../cygnus/command/GlitchCommandTest.java | 91 -------
.../cygnus/stamina/FoodBarTest.java | 32 +++
.../OverlayTunnelVisionRendererTest.java | 97 ++++++++
.../TunnelVisionIntensityTest.java | 51 ++++
.../tunnelvision/TunnelVisionServiceTest.java | 235 ++++++++++++++++++
.../tunnelvision/TunnelVisionStageTest.java | 100 ++++++++
.../cygnus/utils/StaminaShareTest.java | 51 ++++
20 files changed, 1218 insertions(+), 198 deletions(-)
create mode 100644 docs/superpowers/specs/2026-08-10-tunnel-vision-design.md
delete mode 100644 game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java
delete mode 100644 game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRenderer.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionRenderer.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java
create mode 100644 game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java
delete mode 100644 game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java
create mode 100644 game/src/test/java/net/onelitefeather/cygnus/utils/StaminaShareTest.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 960bebd6..01fd40d8 100644
--- a/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java
+++ b/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java
@@ -32,6 +32,7 @@ public final class Messages {
public static final Component LIGHT_WENT_OUT;
public static final Component ONLY_PLAYERS_HAVE_A_VIEW;
public static final Component ONLY_PLAYERS_CAN_BLEED;
+ public static final Component ONLY_PLAYERS_CAN_PREVIEW;
private static final Component PAGE_FOUND_PART;
private static final Component LEAVE_PART;
private static final Component JOIN_PART;
@@ -66,6 +67,7 @@ public final class Messages {
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.");
+ ONLY_PLAYERS_CAN_PREVIEW = withMiniPrefix("Only players can preview the tunnel vision.");
SURVIVOR_JOIN_PART_UPPER = withMiniPrefix("You are a Survivor! Find various Pages").append(Component.space());
@@ -208,6 +210,22 @@ public static Component getSlenderWinMessage(@Nullable Player player) {
.append(Component.newline());
}
+ /**
+ * Returns a {@link Component} explaining how the tunnel vision preview command is used.
+ *
+ * The upper bound is passed in rather than written out, so it cannot drift away from the
+ * number of stages the overlay actually has.
+ *
+ *
+ * @param maxStage the highest stage the preview accepts
+ * @return the created {@link Component} reference
+ */
+ @Contract(value = "_ -> new", pure = true)
+ public static Component getTunnelVisionUsageMessage(int maxStage) {
+ return withMiniPrefix("Usage: /tunnelvision stage <0-" + maxStage
+ + "> | intensity <0.0-1.0> | off");
+ }
+
@Contract(value = "_ -> new", pure = true)
public static Component getJoinMessage(Player player) {
return PREFIX.append(Component.space()).append(withMini("" + player.getUsername() + ""))
diff --git a/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md
new file mode 100644
index 00000000..f4936771
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md
@@ -0,0 +1,208 @@
+# Tunnel vision for survivors
+
+## Goal
+
+A survivor's view narrows as the situation gets worse: the screen edges darken and pulse like a
+heartbeat when stamina runs low, when the Slender closes in, or both. The effect is per player,
+continuous rather than on/off, and driven entirely by the server.
+
+## Why not a shader
+
+The obvious implementation is a post-processing shader, and on Minecraft 26.2 it does not work.
+
+A resource-pack post effect only runs in contexts vanilla decides: the menu blur, spectator mob
+vision, the glowing outline, and the "Improved Transparency" video setting. None of them can be
+switched on for one player from the server, and none carries an intensity parameter. The only way
+to force one on 26.2 is hijacking spectator mob vision by pointing the player's camera at a hidden
+enderman, which takes over the camera and makes the game unplayable.
+
+That changes in 26.3: snapshot 3 (7 July 2026) added `/posteffect add|remove `
+plus the always-on `minecraft:end_of_frame` context. 26.3 is still in snapshots, and Minestom
+ships 26.2 (`net.minestom:minestom:2026.07.22-26.2`).
+
+So the effect is rendered as the `camera_overlay` of an item worn on the head — the mechanism
+behind the carved pumpkin, and the one thing in vanilla that draws a texture across the whole
+screen and scales it with the viewport. Behind an interface that a post-effect renderer can slot
+into once 26.3 and Minestom support land; the gameplay side does not change when that happens.
+
+Reference: [Shader – Minecraft Wiki](https://minecraft.wiki/w/Shader),
+[Java Edition 26.3 Snapshot 3](https://minecraft.wiki/w/Java_Edition_26.3_Snapshot_3).
+
+## Intensity
+
+`TunnelVisionIntensity` turns the survivor's stamina into a value in `[0, 1]`. It has no Minestom
+dependency at all, so it is testable without a server.
+
+**Stamina.** With `s = currentSpeedCount / 20`:
+
+```
+stamina = s >= 0.5 ? 0 : ((0.5 - s) / 0.5)^2
+```
+
+Nothing happens above half a bar; below it the curve accelerates, so the last few percent are far
+more dramatic than crossing the halfway mark.
+
+**Why the Slender is not an input.** An earlier draft folded a proximity term into this value, so
+that the view also narrowed as he closed in. That half of the idea became its own effect: the
+slender gaze glitch tears the screen when he is in view, driven by `SlenderGaze` with its own range
+and field-of-view constants, and both draw onto the same `ScreenOverlay` as separate layers. Keeping
+them apart means each can be tuned - and switched off - without touching the other, and a survivor
+who is merely exhausted does not get the effect meant for one who is being hunted.
+
+**No line-of-sight raycast.** Neither effect dampens on a wall between survivor and Slender. It
+would cost a block walk per survivor per tick, and "I can feel him through the wall" is the better
+atmosphere anyway.
+
+## Stages and pulse
+
+The continuous value is quantised to 32 stages, which double as the frames of the heartbeat.
+Minecraft cannot animate an overlay texture — `.mcmeta` animation covers block, item, particle,
+painting and effect textures only — so the animation is the server walking through the frames.
+Thirty-two of them make the view close smoothly; at sixteen the steps were visible as the tunnel
+narrowed. Two mechanisms sit on top, in this order:
+
+1. **Hysteresis on the base value.** `baseStage` starts as `round(intensity * 32)` and afterwards
+ only moves when `intensity * 32` is more than 0.6 stages away from it. Stamina jitters constantly
+ as a survivor starts and stops sprinting; without this the overlay flickers at every stage
+ boundary.
+2. **Pulse on top of the stabilised stage.**
+
+```
+depth = (32 / 16) * intensity // a sixteenth of the scale, whatever the stage count is
+frequency = 1.0 + 1.5 * intensity // Hz
+display = clamp(round(baseStage + depth * (sin(2*pi * frequency * t) - 1)), 0, 32)
+```
+
+The heartbeat gets faster and deeper as it gets tighter, and stays nearly invisible at low
+intensity — a depth that does not scale would make stage 1 flicker between 0 and 1.
+
+The pulse only ever opens the view back up, never past the base stage. A symmetric pulse would be
+clipped away exactly where it matters most: at full intensity the base stage is already the
+maximum, so everything above it is lost and the heartbeat disappears.
+
+The order matters: hysteresis applies to the base value, the pulse is added afterwards. Reversed,
+the hysteresis would damp out exactly the pulsing it is there to allow.
+
+Stage 0 is not a texture. It clears the overlay.
+
+**Service tick: 100 ms.** The heartbeat reaches 2.5 Hz, and sampling it at 4 Hz — a 250 ms tick —
+aliases it into something jerky. 100 ms samples it ten times per second, which is smooth and still
+a tiny packet per survivor.
+
+## Pack assets
+
+In `cygnus-pack`, namespace `cygnus`:
+
+```
+pack/assets/cygnus/textures/gui/tunnel_vision/stage_1.png … stage_32.png
+pack/assets/cygnus/equipment/empty.json
+```
+
+Each texture is 768×432 — 16:9, because the client stretches a camera overlay across the screen
+rather than fitting it. The darkening closes in from all four edges rather than as a circle from
+the middle: it is a superellipse whose exponent eases from 4 at stage 1, a rounded rectangle
+framing the screen, to 2 at stage 32, where a plain ellipse reads as a tunnel. Textures are
+generated by `tools/generate_overlay.py`, which also produces the blood splatter.
+
+**How it reaches the screen.** The server puts an item in the player's head slot carrying
+`equippable{slot:head, camera_overlay:"cygnus:gui/tunnel_vision/stage_N"}`. Three details keep the
+carrier out of the way:
+
+- `asset_id` points at `cygnus:empty`, an equipment model with no layers. Without it Minecraft
+ draws the item itself on the player's head.
+- `swappable`, `dispensable` and `damage_on_hurt` are all off, so nobody strips the overlay by
+ accident and it is not treated as armour.
+- The equip sound is `minecraft:intentionally_empty`; the default would click on every stage
+ change, ten times a second.
+
+**The position needs no calibration.** This is the whole reason for the mechanism: the client
+scales the overlay to the viewport, so it fits every resolution and GUI scale on its own. A font
+glyph cannot — its size is fixed in the pack, so it has to be calibrated against one resolution
+and drifts on every other.
+
+## Components
+
+New package `net.onelitefeather.cygnus.tunnelvision`:
+
+- `TunnelVisionIntensity` — the calculation above. Pure, no server needed to test it.
+- `TunnelVisionStage` — one survivor's overlay state: hysteresis and heartbeat. Also pure.
+- `TunnelVisionRenderer` — `render(player, stage)` and `clear(player)`. This is the seam a
+ post-effect renderer slots into on 26.3.
+- `OverlayTunnelVisionRenderer` — the implementation described above; it contributes a texture to
+ the shared `ScreenOverlay` rather than dressing the player itself.
+- `TunnelVisionService` — holds a `TunnelVisionStage` per survivor and ticks all of them in one
+ scheduler task.
+- `TunnelVisionCommand` — `/tunnelvision stage <0-32> | intensity <0.0-1.0> | off`, for judging the
+ vignette from the lobby without a running round. `stage` freezes one stage to judge the drawing;
+ `intensity` runs the real heartbeat.
+
+One task for everyone rather than one per player as `StaminaBar` does: a single 100 ms task walks
+every tracked survivor, so the count of scheduler tasks does not grow with the lobby, and cleanup
+happens in one place.
+
+## Wiring
+
+`Cygnus` creates the service and the command. The service then listens for the round's lifecycle
+itself, the way `SpectatorService` and `ResourcePackService` already do, rather than being called
+from the existing listeners:
+
+| Event | What happens |
+| --- | --- |
+| `GameStartEvent` | starts drawing for the survivor team |
+| `PlayerDeathEvent` | removes the player (transition to spectator) |
+| `PlayerDisconnectEvent` | removes the player |
+| `GameFinishEvent` | full cleanup |
+
+This keeps `GameStartListener`, `PlayerDeathListener` and `PlayerQuitListener` — and their tests —
+untouched: none of them has anything the service needs beyond the moment itself.
+
+Two changes to existing code:
+
+- **`FoodBar` gains a getter** for normalised stamina. `currentSpeedCount` is private today. The
+ service could read `player.getExp()`, since `FoodBar` mirrors the value there, but that hangs
+ game logic off a display detail. `StaminaHelper.remainingShare` wraps the lookup, so the service
+ takes a plain `ToDoubleFunction` and `Cygnus` keeps none of it.
+- **The effects are gated by `OverlayProperties`, not by the resource pack.** An earlier draft tied
+ them to `resourcePackService` being present, on the grounds that the textures would otherwise be
+ missing. That conflated two questions: whether this server hands out a pack, and whether a player
+ has one loaded - a player can arrive with the pack already installed, and a server can hand one
+ out that a player declines. `cygnus.overlays` answers the second directly, and
+ `Cygnus.registerOverlayListeners` holds that one gate for all three effects.
+
+## Failure modes
+
+The service keeps running in all of these; none of them throws.
+
+| Situation | Behaviour |
+| --- | --- |
+| No `FoodBar` registered for a player | reads as a full bar, so the effect stays off |
+| Ticked outside a round (before `GameStartEvent`, after `GameFinishEvent`) | nobody is tracked; the task does nothing |
+| Stage drops to 0 | the layer is dropped rather than drawn — otherwise the last vignette stays on the head |
+| Player dies or becomes a spectator | explicit `clear()`, same reason |
+| The pack is not loaded on a client | the overlay resolves to a missing texture for that player only; the server side is unaffected |
+
+## Tests
+
+- `TunnelVisionIntensityTest` — plain JUnit: the threshold at half a bar, an empty bar giving 1,
+ and monotonicity as the bar drains.
+- `TunnelVisionStageTest` — plain JUnit: the pulse at full intensity, steadiness at low intensity,
+ hysteresis (a small oscillation around a stage boundary must not change the stage), and bounds.
+- `OverlayTunnelVisionRendererTest` — Cyano: the renderer contributes the expected texture, and
+ `clear()` drops only its own layer rather than wiping the screen out from under the blood
+ splatter.
+- `EquipmentScreenOverlayTest` — Cyano: a layer becomes a camera overlay on the head, the blood
+ wins over the tunnel vision and the tunnel vision returns afterwards, the last layer leaving
+ empties the slot, and an unchanged overlay is not re-sent.
+- `TunnelVisionServiceTest` — lifecycle: start and stop, removing a player, and the four lifecycle
+ events it registers for.
+- `TunnelVisionCommandTest` — the command draws the requested stage, previews an intensity, and
+ clears on `off`.
+- `FoodBarTest` — a fresh bar reports a full share.
+- `StaminaShareTest` — `StaminaHelper.remainingShare` reads a full bar for a player who has none,
+ which is what keeps the effect off for anyone not playing a round.
+
+The pack side cannot be tested automatically. The look of the vignette is verified in-game against a
+snapshot build of `cygnus-pack`; that is an explicit step in the implementation plan, not an
+afterthought. What *can* be checked mechanically is that both sides agree on the texture paths:
+`OverlayTextureKeys` builds them and `tools/README.md` in `cygnus-pack` documents them, and a
+mismatch shows up as a fullscreen missing-texture checkerboard with nothing in any log.
diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
index 9c2efd93..c86e9f2f 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
@@ -41,7 +41,6 @@
import net.minestom.server.network.packet.client.common.ClientSettingsPacket;
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;
@@ -82,6 +81,9 @@
import net.onelitefeather.cygnus.resourcepack.ResourcePackService;
import net.onelitefeather.cygnus.stamina.SlenderBarTrigger;
import net.onelitefeather.cygnus.stamina.StaminaService;
+import net.onelitefeather.cygnus.tunnelvision.OverlayTunnelVisionRenderer;
+import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer;
+import net.onelitefeather.cygnus.tunnelvision.TunnelVisionService;
import net.onelitefeather.cygnus.utils.StaminaHelper;
import net.onelitefeather.cygnus.view.GameView;
import net.onelitefeather.cygnus.view.GameViewImpl;
@@ -114,6 +116,8 @@ public final class Cygnus implements TeamCreator, ListenerHandling {
private final ScreenOverlay screenOverlay;
private final SlenderGazeService slenderGazeService;
private final BloodSplatterService bloodSplatterService;
+ private final TunnelVisionRenderer tunnelVisionRenderer;
+ private final TunnelVisionService tunnelVisionService;
public Cygnus() {
Path path = ServiceBootstrap.resolveWorkingDirectory();
@@ -144,6 +148,8 @@ public Cygnus() {
this.screenOverlay,
bound -> ThreadLocalRandom.current().nextInt(bound)
);
+ this.tunnelVisionRenderer = new OverlayTunnelVisionRenderer(this.screenOverlay);
+ this.tunnelVisionService = new TunnelVisionService(this.tunnelVisionRenderer, player -> StaminaHelper.remainingShare(this.staminaService, player));
this.initPhases();
this.initCommands();
this.initListener();
@@ -154,9 +160,9 @@ public Cygnus() {
private void initCommands() {
var manager = MinecraftServer.getCommandManager();
manager.register(new StartCommand(this.linearPhaseSeries));
- manager.register(new GlitchCommand(this.slenderGazeService));
}
+
private void initListener() {
Supplier phaseSupplier = this.linearPhaseSeries::getCurrentPhase;
var manager = MinecraftServer.getGlobalEventHandler();
@@ -225,6 +231,7 @@ private void registerOverlayListeners(GlobalEventHandler handler) {
if (!OverlayProperties.enabled()) return;
this.slenderGazeService.registerListener(handler, () -> TeamHelper.survivorsOf(this.teamService));
this.bloodSplatterService.registerListener(handler);
+ this.tunnelVisionService.registerListener(handler, () -> TeamHelper.survivorsOf(this.teamService));
}
private void initPhases() {
diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java b/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java
deleted file mode 100644
index b394ec95..00000000
--- a/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java
+++ /dev/null
@@ -1,54 +0,0 @@
-package net.onelitefeather.cygnus.command;
-
-import net.kyori.adventure.text.Component;
-import net.minestom.server.command.CommandSender;
-import net.minestom.server.entity.Player;
-import org.jetbrains.annotations.Nullable;
-
-/**
- * Narrows a {@link CommandSender} down to a {@link Player}, since every preview command in this
- * package draws on a screen and only a player has one.
- *
- * On the branch these preview commands were cut from, {@code TunnelVisionCommand},
- * {@code BloodCommand} and {@code GlitchCommand} each hand-rolled an identical
- * {@code private static @Nullable Player asPlayer(CommandSender)}, differing only in the message
- * sent back to the console. This type is that method, extracted once, so that the three land on top
- * of it instead of bringing a fourth copy each. The message itself is passed in rather than
- * assembled here, so it can stay with the other player-facing texts in
- * {@link net.onelitefeather.cygnus.common.Messages}.
- *
- *
- * A static helper was chosen over an abstract base command on purpose. The narrowing check is the
- * only thing the three commands share — their constructors take different services, their default
- * executors print different usage lines, and {@code TunnelVisionCommand} alone runs a per-player
- * preview loop. An abstract base class would force every subclass into one constructor shape and
- * one inheritance chain to get a single one-line check, coupling command shape to something none of
- * them actually have in common. A stateless static method carries the shared behaviour without
- * dragging the unrelated parts of any one command onto the other two, which keeps each command free
- * to change its syntax, its executor and its scheduling independently.
- *
- *
- * @author TheMeinerLP
- * @version 1.0.0
- * @since 2.7.0
- */
-public final class CommandSenders {
-
- private CommandSenders() {
- }
-
- /**
- * Narrows the given sender down to a player, telling them why not if it cannot.
- *
- * @param sender the sender to narrow
- * @param message the message to send back when the sender is not a player, taken from
- * {@link net.onelitefeather.cygnus.common.Messages} like every other
- * player-facing text
- * @return the player, or {@code null} if the sender has no screen to draw on
- */
- public static @Nullable Player asPlayer(CommandSender sender, Component message) {
- if (sender instanceof Player player) return player;
- sender.sendMessage(message);
- return null;
- }
-}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java
deleted file mode 100644
index 3ead6384..00000000
--- a/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java
+++ /dev/null
@@ -1,50 +0,0 @@
-package net.onelitefeather.cygnus.command;
-
-import net.minestom.server.command.builder.Command;
-import net.minestom.server.command.builder.arguments.ArgumentType;
-import net.minestom.server.entity.Player;
-import net.onelitefeather.cygnus.common.Messages;
-import net.onelitefeather.cygnus.gaze.SlenderGaze;
-import net.onelitefeather.cygnus.gaze.SlenderGazeService;
-
-/**
- * Puts the slender's glitch on screen without him being there, so the drawings can be judged from
- * the lobby.
- *
- * {@code /glitch <1-4>} holds one level, {@code /glitch off} takes it away.
- *
- *
- * @author TheMeinerLP
- * @version 2.0.0
- * @since 2.7.0
- */
-public final class GlitchCommand extends Command {
-
- /** Completes the sentence {@code CommandSenders} sends back to a sender without a screen. */
-
- /**
- * Creates the command.
- *
- * @param service the service that draws the tearing
- */
- public GlitchCommand(SlenderGazeService service) {
- super("glitch");
-
- var level = ArgumentType.Integer("level").between(1, SlenderGaze.LEVELS);
-
- this.setDefaultExecutor((sender, context) ->
- sender.sendMessage(Messages.getGlitchUsageMessage(SlenderGaze.LEVELS)));
-
- this.addSyntax((sender, context) -> {
- Player player = CommandSenders.asPlayer(sender, Messages.ONLY_PLAYERS_HAVE_A_VIEW);
- if (player == null) return;
- service.preview(player, context.get(level) - 1);
- }, level);
-
- this.addSyntax((sender, context) -> {
- Player player = CommandSenders.asPlayer(sender, Messages.ONLY_PLAYERS_HAVE_A_VIEW);
- if (player == null) return;
- service.preview(player, SlenderGaze.NONE);
- }, ArgumentType.Literal("off"));
- }
-}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java b/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java
index d2a89e7e..38cf62d5 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java
@@ -99,6 +99,19 @@ private float normalize(float current) {
return Math.max(0.0f, current / MAX_FOOD);
}
+ /**
+ * Returns the remaining stamina as a share of a full bar.
+ *
+ * This is what drives the survivor's tunnel vision. The bar mirrors the same value into the
+ * experience bar, but reading it back from there would tie game logic to a display detail.
+ *
+ *
+ * @return the remaining stamina between {@code 0.0f} and {@code 1.0f}
+ */
+ public float remainingShare() {
+ return normalize(this.currentSpeedCount);
+ }
+
/**
* Returns an indication state if the bar could be consumed.
*
diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRenderer.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRenderer.java
new file mode 100644
index 00000000..7916d1af
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRenderer.java
@@ -0,0 +1,61 @@
+package net.onelitefeather.cygnus.tunnelvision;
+
+import net.kyori.adventure.key.Key;
+import net.minestom.server.entity.Player;
+import net.onelitefeather.cygnus.overlay.OverlayLayer;
+import net.onelitefeather.cygnus.overlay.OverlayTextureKeys;
+import net.onelitefeather.cygnus.overlay.ScreenOverlay;
+
+/**
+ * Contributes the tunnel vision to the shared screen overlay.
+ *
+ * Each stage is a camera overlay texture from the resource pack. Minecraft cannot animate one, so
+ * the heartbeat is the server walking through the stages, one texture per frame.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 2.0.0
+ * @since 2.7.0
+ */
+public final class OverlayTunnelVisionRenderer implements TunnelVisionRenderer {
+
+ /** Where the stage textures live, as {@code camera_overlay} resolves them. */
+ static final String TEXTURE_PATH = "gui/tunnel_vision/stage_";
+
+ private static final Key[] TEXTURES =
+ OverlayTextureKeys.flat(TEXTURE_PATH, TunnelVisionStage.MAX_STAGE, OverlayTextureKeys.ONE_BASED);
+
+ private final ScreenOverlay overlay;
+
+ /**
+ * Creates a renderer drawing into the given overlay.
+ *
+ * @param overlay the overlay that owns the player's screen
+ */
+ public OverlayTunnelVisionRenderer(ScreenOverlay overlay) {
+ this.overlay = overlay;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public void render(Player player, int stage) {
+ if (stage <= 0) {
+ this.clear(player);
+ return;
+ }
+ this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TEXTURES[Math.min(stage, TunnelVisionStage.MAX_STAGE) - 1]);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Only this layer is dropped. Clearing the screen would take the blood splatter with it.
+ *
+ */
+ @Override
+ public void clear(Player player) {
+ this.overlay.set(player, OverlayLayer.TUNNEL_VISION, null);
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java
new file mode 100644
index 00000000..a4139060
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java
@@ -0,0 +1,44 @@
+package net.onelitefeather.cygnus.tunnelvision;
+
+
+/**
+ * Turns a draining stamina bar into an intensity in {@code [0, 1]} that drives how far the
+ * survivor's view narrows.
+ *
+ * The slender used to feed into this intensity as well; he now speaks through
+ * {@code gaze.SlenderGazeService} instead, which tears the view independently rather than adding
+ * to this gauge.
+ *
+ *
+ * The calculation is deliberately free of any server state so it can be exercised without a
+ * running instance.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 2.0.1
+ * @since 2.7.0
+ */
+public final class TunnelVisionIntensity {
+
+ /** Share of the stamina bar below which the view starts to narrow. */
+ private static final double STAMINA_THRESHOLD = 0.5D;
+
+ private TunnelVisionIntensity() {
+ }
+
+ /**
+ * Calculates the share contributed by the survivor's stamina.
+ *
+ * Nothing happens above half a bar; below it the curve accelerates quadratically, so the last
+ * few percent feel far more dramatic than crossing the halfway mark.
+ *
+ *
+ * @param normalizedStamina the remaining stamina as a share of a full bar
+ * @return the intensity share in {@code [0, 1]}
+ */
+ public static double fromStamina(double normalizedStamina) {
+ if (normalizedStamina >= STAMINA_THRESHOLD) return 0.0D;
+ double drained = (STAMINA_THRESHOLD - normalizedStamina) / STAMINA_THRESHOLD;
+ return Math.clamp(drained * drained, 0.0D, 1.0D);
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionRenderer.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionRenderer.java
new file mode 100644
index 00000000..1e9a4955
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionRenderer.java
@@ -0,0 +1,35 @@
+package net.onelitefeather.cygnus.tunnelvision;
+
+import net.minestom.server.entity.Player;
+
+/**
+ * Displays a tunnel vision stage to a survivor.
+ *
+ * This is the seam between the game logic and the way the effect reaches the screen. Minecraft
+ * 26.2 offers no per-player post-processing effect, so the only implementation today draws the
+ * vignette as a HUD overlay. Once {@code /posteffect} is available a second implementation can
+ * take its place without the calculation or the service noticing.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+public interface TunnelVisionRenderer {
+
+ /**
+ * Shows the given stage to the player.
+ *
+ * @param player the player to draw for
+ * @param stage the stage between {@code 0} and {@link TunnelVisionStage#MAX_STAGE}, where
+ * {@code 0} means no overlay
+ */
+ void render(Player player, int stage);
+
+ /**
+ * Removes the overlay from the player's screen.
+ *
+ * @param player the player to clear
+ */
+ void clear(Player player);
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java
new file mode 100644
index 00000000..e56fc8d5
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java
@@ -0,0 +1,161 @@
+package net.onelitefeather.cygnus.tunnelvision;
+
+import net.minestom.server.entity.Player;
+import net.minestom.server.event.Event;
+import net.minestom.server.event.EventNode;
+import net.minestom.server.event.player.PlayerDeathEvent;
+import net.minestom.server.event.player.PlayerDisconnectEvent;
+import net.onelitefeather.cygnus.event.GameFinishEvent;
+import net.onelitefeather.cygnus.event.GameStartEvent;
+import net.onelitefeather.cygnus.utils.PlayerState;
+import net.onelitefeather.cygnus.utils.RepeatingTask;
+
+import java.time.temporal.ChronoUnit;
+import java.util.Set;
+import java.util.function.Supplier;
+import java.util.function.ToDoubleFunction;
+
+/**
+ * Drives the tunnel vision of every survivor from a single repeating task.
+ *
+ * One task rather than one per player, as {@code StaminaBar} does it, so there is a single place to
+ * clean up.
+ *
+ *
+ * The stamina arrives as a function rather than as a service: it only needs a number, so the
+ * dependency does not have to be a live object the service keeps in sync.
+ *
+ *
+ * The slender used to feed into this as well. He now speaks through {@code SlenderGazeService}
+ * instead, which asks whether a survivor can see him rather than how near he is.
+ *
+ *
+ * Unlike {@code AmbientProvider}, this service is not merely started and stopped by name from
+ * {@code GameStartListener} and {@code Cygnus.finishGame()} — it still exposes {@link #startTask()}
+ * and {@link #stopTask()} for exactly that purpose, but it also has to react the moment a single
+ * survivor dies or disconnects, or the vignette they last saw keeps showing on a screen nobody is
+ * playing through any more. Neither of those listeners knows about individual players today, and
+ * teaching them to would spread a tunnel-vision concern into files that otherwise have nothing to do
+ * with it. Registering here, scoped to this service's own node, keeps that mapping local to the one
+ * class that needs it — {@code BloodSplatterService} and {@code SlenderGazeService} register
+ * themselves for the same reason.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 2.1.0
+ * @since 2.7.0
+ */
+public final class TunnelVisionService {
+
+ private final TunnelVisionRenderer renderer;
+ private final ToDoubleFunction stamina;
+ private final PlayerState survivors = new PlayerState<>();
+ private final RepeatingTask task = new RepeatingTask(this::tick);
+
+ /**
+ * Creates a new service.
+ *
+ * @param renderer the renderer that puts a stage on the screen
+ * @param stamina supplies a survivor's remaining stamina as a share of a full bar
+ */
+ public TunnelVisionService(TunnelVisionRenderer renderer, ToDoubleFunction stamina) {
+ this.renderer = renderer;
+ this.stamina = stamina;
+ }
+
+ /**
+ * Starts the update task. Does nothing if it is already running.
+ */
+ public void startTask() {
+ this.task.start(TunnelVisionStage.TICK_MILLIS, ChronoUnit.MILLIS);
+ }
+
+ /**
+ * Stops the update task. Does nothing if it is not running.
+ */
+ public void stopTask() {
+ this.task.stop();
+ }
+
+ /**
+ * Starts drawing for a survivor, with a fresh stage.
+ *
+ * This is bookkeeping only: it does not touch the update task, so {@link #registerListener} can
+ * compose it with {@link #startTask()} instead of the two always happening together.
+ *
+ *
+ * @param survivor the survivor to draw for
+ */
+ public void track(Player survivor) {
+ this.survivors.put(survivor, new Tracked(survivor, new TunnelVisionStage()));
+ }
+
+ /**
+ * Hooks the service into the round's lifecycle.
+ *
+ * See the class documentation for why this service registers itself rather than being called by
+ * name the way {@code AmbientProvider} is.
+ *
+ *
+ * @param node the node to register on
+ * @param survivors supplies the survivors of the starting round
+ */
+ public void registerListener(EventNode node, Supplier> survivors) {
+ node.addListener(GameStartEvent.class, event -> {
+ this.startTask();
+ for (Player survivor : survivors.get()) {
+ this.track(survivor);
+ }
+ });
+ node.addListener(PlayerDeathEvent.class, event -> this.remove(event.getPlayer()));
+ node.addListener(PlayerDisconnectEvent.class, event -> this.remove(event.getPlayer()));
+ node.addListener(GameFinishEvent.class, event -> {
+ this.cleanUp();
+ this.stopTask();
+ });
+ }
+
+ /**
+ * Stops drawing for a survivor and clears whatever is still on their screen — on death, on
+ * the way into the spectator team, or on quit.
+ *
+ * @param player the survivor to drop
+ */
+ public void remove(Player player) {
+ if (this.survivors.remove(player) == null) return;
+ this.renderer.clear(player);
+ }
+
+ /**
+ * Clears every survivor's screen and stops tracking all of them, without touching the update
+ * task — pair with {@link #stopTask()} to end a round the way {@link #registerListener} does.
+ */
+ public void cleanUp() {
+ for (Tracked tracked : this.survivors.values()) {
+ this.renderer.clear(tracked.player());
+ }
+ this.survivors.clear();
+ }
+
+ /**
+ * Updates every tracked survivor once.
+ */
+ void tick() {
+ if (this.survivors.isEmpty()) return;
+
+ for (Tracked tracked : this.survivors.values()) {
+ Player survivor = tracked.player();
+ double intensity = TunnelVisionIntensity.fromStamina(this.stamina.applyAsDouble(survivor));
+ this.renderer.render(survivor, tracked.stage().update(intensity));
+ }
+ }
+
+ /**
+ * Pairs a survivor with the overlay state that belongs to them.
+ *
+ * @param player the survivor
+ * @param stage their stage state, carrying hysteresis and heartbeat
+ */
+ private record Tracked(Player player, TunnelVisionStage stage) {
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java
new file mode 100644
index 00000000..0a1ba83a
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java
@@ -0,0 +1,78 @@
+package net.onelitefeather.cygnus.tunnelvision;
+
+
+/**
+ * Holds the overlay state of a single survivor: which of the discrete stages is currently shown,
+ * and where the heartbeat that modulates it stands.
+ *
+ * Two mechanisms sit between the continuous intensity and the rendered stage. Hysteresis keeps the
+ * quantised base stage still while distance and stamina jitter around a boundary, and the pulse is
+ * added on top of the stabilised value — reversed, the hysteresis would damp out exactly the
+ * pulsing it exists to allow.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.0.1
+ * @since 2.7.0
+ */
+public final class TunnelVisionStage {
+
+ /**
+ * Number of stages the overlay is quantised to; stage {@code 0} means no overlay.
+ *
+ * These double as the frames of the heartbeat: Minecraft cannot animate an overlay texture, so
+ * the animation is the server walking through the stages. Thirty-two of them make the view
+ * close smoothly; at sixteen the steps were visible as the tunnel narrowed.
+ *
+ */
+ public static final int MAX_STAGE = 32;
+
+ /** Interval the service updates at, which is also the sampling rate of the heartbeat. */
+ public static final int TICK_MILLIS = 100;
+
+ /** Distance in stages the intensity has to travel before the base stage follows. */
+ private static final double HYSTERESIS = 0.6D;
+
+ /**
+ * Depth of the heartbeat in stages at full intensity, as a fraction of the whole scale so it
+ * stays equally visible whatever {@link #MAX_STAGE} is.
+ */
+ private static final double PULSE_DEPTH = MAX_STAGE / 16.0D;
+
+ /** Heartbeat frequency in hertz while the survivor is barely threatened. */
+ private static final double BASE_FREQUENCY = 1.0D;
+
+ /** Additional heartbeat frequency in hertz at full intensity. */
+ private static final double FREQUENCY_GAIN = 1.5D;
+
+ private static final double TICK_SECONDS = TICK_MILLIS / 1000.0D;
+
+ /** Negative until the first update, so the first intensity is adopted without hysteresis. */
+ private int baseStage = -1;
+
+ private double elapsedSeconds;
+
+ /**
+ * Advances the heartbeat by one tick and reports the stage to render.
+ *
+ * @param intensity the intensity from {@link TunnelVisionIntensity}
+ * @return the stage to render, between {@code 0} and {@link #MAX_STAGE}
+ */
+ public int update(double intensity) {
+ double exactStage = intensity * MAX_STAGE;
+ if (this.baseStage < 0 || Math.abs(exactStage - this.baseStage) > HYSTERESIS) {
+ this.baseStage = (int) Math.round(exactStage);
+ }
+
+ this.elapsedSeconds += TICK_SECONDS;
+ double frequency = BASE_FREQUENCY + FREQUENCY_GAIN * intensity;
+ double depth = PULSE_DEPTH * intensity;
+ // The heartbeat only ever opens the view up, never beyond the base stage: at full
+ // intensity the base stage is the maximum, and a symmetric pulse would be clipped away
+ // exactly where it matters most.
+ double pulse = depth * (Math.sin(2.0D * Math.PI * frequency * this.elapsedSeconds) - 1.0D);
+
+ int rendered = (int) Math.round(this.baseStage + pulse);
+ return Math.clamp(rendered, 0, MAX_STAGE);
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java
new file mode 100644
index 00000000..f6ea8e77
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java
@@ -0,0 +1,4 @@
+@NotNullByDefault
+package net.onelitefeather.cygnus.tunnelvision;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java b/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java
index 3a7b0668..8f1e6f83 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java
@@ -4,6 +4,8 @@
import net.onelitefeather.cygnus.team.TeamHelper;
import net.theevilreaper.xerus.api.team.Team;
import net.theevilreaper.xerus.api.team.TeamService;
+import net.minestom.server.entity.Player;
+import net.onelitefeather.cygnus.stamina.FoodBar;
import net.onelitefeather.cygnus.stamina.StaminaService;
@@ -11,7 +13,7 @@
* The {@link StaminaHelper} is a utility class which contains some helper methods for the stamina system.
*
* @author theEvilReaper
- * @version 1.2.0
+ * @version 1.3.0
* @since 1.0.0
*/
public final class StaminaHelper {
@@ -33,6 +35,24 @@ public static void initStaminaObjects(TeamService teamService, StaminaService st
staminaService.createStaminaBars(allocation.survivors());
}
+ /**
+ * Reads how much of a survivor's stamina is left, as a share between zero and one.
+ *
+ * Reads a full bar for a player who has none registered yet. The effects that drive themselves
+ * off stamina are ticked from a scheduler, so they ask about players who have joined but are not
+ * playing a round - answering "untouched" there is what keeps the effect off for them, and it
+ * keeps a null check out of every caller.
+ *
+ *
+ * @param staminaService the service holding the bars
+ * @param player the player to read
+ * @return the remaining share in {@code [0, 1]}, or {@code 1} while the player has no bar
+ */
+ public static double remainingShare(StaminaService staminaService, Player player) {
+ FoodBar bar = staminaService.getFoodBar(player);
+ return bar == null ? 1.0D : bar.remainingShare();
+ }
+
private StaminaHelper() {
throw new UnsupportedOperationException();
}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java b/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java
deleted file mode 100644
index f42226e2..00000000
--- a/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java
+++ /dev/null
@@ -1,91 +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.gaze.SlenderGaze;
-import net.onelitefeather.cygnus.gaze.SlenderGazeService;
-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;
-import static org.junit.jupiter.api.Assertions.assertNull;
-
-/**
- * Verifies the command used to preview the slender's glitch without him being there.
- *
- * @author TheMeinerLP
- * @version 2.0.0
- * @since 2.7.0
- */
-class GlitchCommandTest extends CygnusPlayerTestBase {
-
- @Test
- @DisplayName("A requested level is drawn right away")
- void levelIsDrawnOnRequest(Env env) {
- RecordingScreenOverlay overlay = new RecordingScreenOverlay();
- Player player = spawn(env);
- register(overlay);
-
- MinecraftServer.getCommandManager().execute(player, "glitch 2");
-
- 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) {
- RecordingScreenOverlay overlay = new RecordingScreenOverlay();
- Player player = spawn(env);
- register(overlay);
- MinecraftServer.getCommandManager().execute(player, "glitch 2");
-
- MinecraftServer.getCommandManager().execute(player, "glitch off");
-
- assertNull(overlay.of(player, OverlayLayer.GLITCH), "the preview must disappear");
- }
-
- @Test
- @DisplayName("Every level of the tearing can be requested")
- void everyLevelCanBeRequested(Env env) {
- RecordingScreenOverlay overlay = new RecordingScreenOverlay();
- Player player = spawn(env);
- register(overlay);
-
- for (int level = 1; level <= SlenderGaze.LEVELS; level++) {
- overlay.clear(player);
- MinecraftServer.getCommandManager().execute(player, "glitch " + level);
- assertNotNull(overlay.of(player, OverlayLayer.GLITCH), "no glitch for level " + level);
- }
- }
-
- /**
- * 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("glitch");
- if (previous != null) MinecraftServer.getCommandManager().unregister(previous);
- MinecraftServer.getCommandManager().register(new GlitchCommand(new SlenderGazeService(overlay, () -> null)));
- }
-
- /**
- * 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));
- }
-}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java
new file mode 100644
index 00000000..1fe45c52
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java
@@ -0,0 +1,32 @@
+package net.onelitefeather.cygnus.stamina;
+
+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.player.CygnusPlayer;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Verifies the stamina share other systems read off the survivor's bar.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+class FoodBarTest extends CygnusPlayerTestBase {
+
+ @Test
+ @DisplayName("A fresh bar reports a full share")
+ void freshBarIsFull(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createConnection().connect(instance, new Pos(0, 40, 0));
+ FoodBar bar = (FoodBar) StaminaFactory.createFoodStamina((CygnusPlayer) player);
+
+ assertEquals(1.0f, bar.remainingShare(), 1.0E-6f);
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java
new file mode 100644
index 00000000..c3c41730
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java
@@ -0,0 +1,97 @@
+package net.onelitefeather.cygnus.tunnelvision;
+
+import net.kyori.adventure.key.Key;
+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.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.assertNull;
+
+/**
+ * Verifies which texture the tunnel vision contributes to the shared screen overlay.
+ *
+ * @author TheMeinerLP
+ * @version 2.1.0
+ * @since 2.7.0
+ */
+class OverlayTunnelVisionRendererTest extends CygnusPlayerTestBase {
+
+ private static final Key BLOOD_TEXTURE = Key.key("cygnus", "gui/blood/stage_1");
+
+ @Test
+ @DisplayName("A stage is contributed as its overlay texture")
+ void stageIsContributedAsTexture(Env env) {
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
+ Player player = spawn(env);
+
+ new OverlayTunnelVisionRenderer(overlay).render(player, 3);
+
+ assertEquals(
+ Key.key("cygnus", OverlayTunnelVisionRenderer.TEXTURE_PATH + "3"),
+ overlay.of(player, OverlayLayer.TUNNEL_VISION),
+ "the texture must match the stage"
+ );
+ }
+
+ @Test
+ @DisplayName("Clearing drops only the tunnel vision layer")
+ void clearingDropsOnlyItsOwnLayer(Env env) {
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
+ Player player = spawn(env);
+ overlay.set(player, OverlayLayer.BLOOD, BLOOD_TEXTURE);
+ OverlayTunnelVisionRenderer renderer = new OverlayTunnelVisionRenderer(overlay);
+ renderer.render(player, 4);
+
+ renderer.clear(player);
+
+ assertNull(overlay.of(player, OverlayLayer.TUNNEL_VISION), "the layer must be gone");
+ assertEquals(
+ BLOOD_TEXTURE,
+ overlay.of(player, OverlayLayer.BLOOD),
+ "wiping the screen would take the blood splatter with it"
+ );
+ }
+
+ @Test
+ @DisplayName("Stage zero drops the layer instead of drawing an empty texture")
+ void zeroStageDropsTheLayer(Env env) {
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
+ Player player = spawn(env);
+
+ new OverlayTunnelVisionRenderer(overlay).render(player, 0);
+
+ assertNull(overlay.of(player, OverlayLayer.TUNNEL_VISION));
+ }
+
+ @Test
+ @DisplayName("The tightest stage has a texture of its own")
+ void tightestStageHasItsOwnTexture(Env env) {
+ RecordingScreenOverlay overlay = new RecordingScreenOverlay();
+ Player player = spawn(env);
+
+ new OverlayTunnelVisionRenderer(overlay).render(player, TunnelVisionStage.MAX_STAGE);
+
+ assertEquals(
+ Key.key("cygnus", OverlayTunnelVisionRenderer.TEXTURE_PATH + TunnelVisionStage.MAX_STAGE),
+ overlay.of(player, OverlayLayer.TUNNEL_VISION)
+ );
+ }
+
+ /**
+ * 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));
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java
new file mode 100644
index 00000000..d976aaea
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java
@@ -0,0 +1,51 @@
+package net.onelitefeather.cygnus.tunnelvision;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies the intensity curves that drive the survivor's tunnel vision.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+class TunnelVisionIntensityTest {
+
+ private static final double DELTA = 1.0E-6D;
+
+ @DisplayName("Stamina above half a bar produces no tunnel vision")
+ @ParameterizedTest
+ @CsvSource({"1.0", "0.75", "0.5"})
+ void staminaAboveHalfIsCalm(double stamina) {
+ assertEquals(0.0D, TunnelVisionIntensity.fromStamina(stamina), DELTA);
+ }
+
+ @Test
+ @DisplayName("An empty stamina bar produces full intensity")
+ void emptyStaminaIsFull() {
+ assertEquals(1.0D, TunnelVisionIntensity.fromStamina(0.0D), DELTA);
+ }
+
+ @Test
+ @DisplayName("The stamina curve accelerates towards the empty bar")
+ void staminaCurveIsQuadratic() {
+ assertEquals(0.25D, TunnelVisionIntensity.fromStamina(0.25D), DELTA);
+ }
+
+ @Test
+ @DisplayName("Draining stamina never lowers the intensity")
+ void staminaIsMonotonic() {
+ double previous = -1.0D;
+ for (int step = 20; step >= 0; step--) {
+ double current = TunnelVisionIntensity.fromStamina(step / 20.0D);
+ assertTrue(current >= previous, "intensity dropped at stamina " + step / 20.0D);
+ previous = current;
+ }
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java
new file mode 100644
index 00000000..dda7b455
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java
@@ -0,0 +1,235 @@
+package net.onelitefeather.cygnus.tunnelvision;
+
+import net.kyori.adventure.text.Component;
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.entity.Player;
+import net.minestom.server.event.EventDispatcher;
+import net.minestom.server.event.player.PlayerDeathEvent;
+import net.minestom.server.instance.Instance;
+import net.minestom.testing.Env;
+import net.onelitefeather.cygnus.CygnusPlayerTestBase;
+import net.onelitefeather.cygnus.event.GameFinishEvent;
+import net.onelitefeather.cygnus.event.GameStartEvent;
+import org.jetbrains.annotations.Nullable;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.HashSet;
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies how the service feeds survivors through the intensity calculation.
+ *
+ * The slender no longer feeds into this — he speaks through {@code SlenderGazeService} — so what
+ * is left here is the stamina and the lifecycle.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.0.1
+ * @since 2.7.0
+ */
+class TunnelVisionServiceTest extends CygnusPlayerTestBase {
+
+ private static final double FULL_STAMINA = 1.0D;
+ private static final double NO_STAMINA = 0.0D;
+
+ @Test
+ @DisplayName("An exhausted survivor sees the tightest stage")
+ void exhaustedSurvivorIsFullyNarrowed(Env env) {
+ RecordingRenderer renderer = new RecordingRenderer();
+ Player survivor = spawn(env, new Pos(0, 40, 0));
+ TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA);
+ service.track(survivor);
+
+ service.tick();
+
+ assertEquals(TunnelVisionStage.MAX_STAGE, renderer.stageOf(survivor));
+ }
+
+ @Test
+ @DisplayName("A rested survivor alone in the dark sees nothing")
+ void restedSurvivorSeesNothing(Env env) {
+ RecordingRenderer renderer = new RecordingRenderer();
+ Player survivor = spawn(env, new Pos(0, 40, 0));
+ TunnelVisionService service = new TunnelVisionService(renderer, player -> FULL_STAMINA);
+ service.track(survivor);
+
+ service.tick();
+
+ assertEquals(0, renderer.stageOf(survivor));
+ }
+
+ @Test
+ @DisplayName("A removed survivor gets their screen back and is no longer drawn")
+ void removedSurvivorIsCleared(Env env) {
+ RecordingRenderer renderer = new RecordingRenderer();
+ Player survivor = spawn(env, new Pos(0, 40, 0));
+ TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA);
+ service.track(survivor);
+ service.tick();
+ renderer.forget();
+
+ service.remove(survivor);
+ service.tick();
+
+ assertTrue(renderer.wasCleared(survivor), "the last vignette would otherwise linger");
+ assertNull(renderer.stageOf(survivor), "a removed survivor must not be drawn any more");
+ }
+
+ @Test
+ @DisplayName("Clearing everyone gives every survivor their screen back")
+ void cleanUpClearsEveryone(Env env) {
+ RecordingRenderer renderer = new RecordingRenderer();
+ Instance instance = env.createFlatInstance();
+ Player first = spawn(env, instance, new Pos(0, 40, 0));
+ Player second = spawn(env, instance, new Pos(4, 40, 0));
+ TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA);
+ service.track(first);
+ service.track(second);
+ service.tick();
+
+ service.cleanUp();
+
+ assertTrue(renderer.wasCleared(first));
+ assertTrue(renderer.wasCleared(second));
+
+ renderer.forget();
+ service.tick();
+ assertNull(renderer.stageOf(first), "clearing must stop the drawing as well");
+ }
+
+ @Test
+ @DisplayName("Starting and stopping the task is idempotent")
+ void startAndStopTaskAreIdempotent(Env env) {
+ RecordingRenderer renderer = new RecordingRenderer();
+ TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA);
+
+ service.startTask();
+ service.startTask();
+ service.stopTask();
+ service.stopTask();
+ }
+
+ @Test
+ @DisplayName("The start of a round takes the survivors on board")
+ void gameStartRegistersSurvivors(Env env) {
+ RecordingRenderer renderer = new RecordingRenderer();
+ Player survivor = spawn(env, new Pos(0, 40, 0));
+ TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA);
+ service.registerListener(env.process().eventHandler(), () -> Set.of(survivor));
+
+ EventDispatcher.call(new GameStartEvent());
+ service.tick();
+
+ assertEquals(TunnelVisionStage.MAX_STAGE, renderer.stageOf(survivor));
+ }
+
+ @Test
+ @DisplayName("A dying survivor gets their screen back")
+ void deathClearsTheOverlay(Env env) {
+ RecordingRenderer renderer = new RecordingRenderer();
+ Player survivor = spawn(env, new Pos(0, 40, 0));
+ TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA);
+ service.registerListener(env.process().eventHandler(), () -> Set.of(survivor));
+ service.track(survivor);
+ service.tick();
+ renderer.forget();
+
+ EventDispatcher.call(new PlayerDeathEvent(survivor, Component.empty(), Component.empty()));
+ service.tick();
+
+ assertTrue(renderer.wasCleared(survivor));
+ assertNull(renderer.stageOf(survivor), "a dead survivor must not be drawn any more");
+ }
+
+ @Test
+ @DisplayName("The end of a round clears everyone")
+ void gameFinishCleansUp(Env env) {
+ RecordingRenderer renderer = new RecordingRenderer();
+ Player survivor = spawn(env, new Pos(0, 40, 0));
+ TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA);
+ service.registerListener(env.process().eventHandler(), () -> Set.of(survivor));
+ service.track(survivor);
+ service.tick();
+ renderer.forget();
+
+ EventDispatcher.call(new GameFinishEvent(GameFinishEvent.Reason.TIME_OVER));
+
+ assertTrue(renderer.wasCleared(survivor));
+ }
+
+ /**
+ * Spawns a player in a fresh instance.
+ *
+ * @param env the test environment
+ * @param position where to place the player
+ * @return the connected player
+ */
+ private Player spawn(Env env, Pos position) {
+ return this.spawn(env, env.createFlatInstance(), position);
+ }
+
+ /**
+ * Spawns a player in the given instance.
+ *
+ * @param env the test environment
+ * @param instance the instance to connect into
+ * @param position where to place the player
+ * @return the connected player
+ */
+ private Player spawn(Env env, Instance instance, Pos position) {
+ return env.createConnection().connect(instance, position);
+ }
+
+ /**
+ * Records what the service asked to be drawn, standing in for the action bar renderer.
+ */
+ private static final class RecordingRenderer implements TunnelVisionRenderer {
+
+ private final Map stages = new HashMap<>();
+ private final Set cleared = new HashSet<>();
+
+ @Override
+ public void render(Player player, int stage) {
+ this.stages.put(player.getUuid(), stage);
+ }
+
+ @Override
+ public void clear(Player player) {
+ this.cleared.add(player.getUuid());
+ this.stages.remove(player.getUuid());
+ }
+
+ /**
+ * @param player the player to look up
+ * @return the stage last drawn for the player, or {@code null} if nothing was drawn
+ */
+ private @Nullable Integer stageOf(Player player) {
+ return this.stages.get(player.getUuid());
+ }
+
+ /**
+ * @param player the player to look up
+ * @return whether the player's overlay was cleared
+ */
+ private boolean wasCleared(Player player) {
+ return this.cleared.contains(player.getUuid());
+ }
+
+ /**
+ * Drops everything recorded so far, to tell repeated draws apart.
+ */
+ private void forget() {
+ this.stages.clear();
+ this.cleared.clear();
+ }
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java
new file mode 100644
index 00000000..407e80bc
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java
@@ -0,0 +1,100 @@
+package net.onelitefeather.cygnus.tunnelvision;
+
+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.assertTrue;
+
+/**
+ * Verifies how a continuous intensity becomes the discrete, pulsing stage the overlay renders.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+class TunnelVisionStageTest {
+
+ /** Enough updates to cover several periods of the slowest heartbeat. */
+ private static final int SAMPLES = 60;
+
+ @Test
+ @DisplayName("Without any threat the overlay stays off")
+ void calmIntensityStaysOff() {
+ TunnelVisionStage stage = new TunnelVisionStage();
+ assertEquals(0, stage.update(0.0D));
+ }
+
+ @Test
+ @DisplayName("Full intensity pulses across the top of the scale")
+ void fullIntensityPulses() {
+ TunnelVisionStage stage = new TunnelVisionStage();
+ int lowest = TunnelVisionStage.MAX_STAGE;
+ int highest = 0;
+ for (int sample = 0; sample < SAMPLES; sample++) {
+ int current = stage.update(1.0D);
+ lowest = Math.min(lowest, current);
+ highest = Math.max(highest, current);
+ }
+ assertEquals(TunnelVisionStage.MAX_STAGE, highest, "the pulse never reaches the peak");
+ // Stated as a share of the scale rather than as a stage count, so raising the number of
+ // stages does not turn this into a test of one particular pulse depth.
+ assertTrue(lowest < highest, "the pulse does not open up again");
+ assertTrue(lowest >= TunnelVisionStage.MAX_STAGE - TunnelVisionStage.MAX_STAGE / 4,
+ "the pulse swings the view too far open at full intensity");
+ }
+
+ @Test
+ @DisplayName("Low intensity barely pulses at all")
+ void lowIntensityIsSteady() {
+ TunnelVisionStage stage = new TunnelVisionStage();
+ int first = stage.update(0.125D);
+ for (int sample = 0; sample < SAMPLES; sample++) {
+ assertEquals(first, stage.update(0.125D), "a barely threatened survivor should not flicker");
+ }
+ }
+
+ @Test
+ @DisplayName("A small fluctuation does not move the stage")
+ void hysteresisHoldsTheStage() {
+ TunnelVisionStage stage = new TunnelVisionStage();
+ int settled = highestOver(stage, 0.5D);
+ assertEquals(TunnelVisionStage.MAX_STAGE / 2, settled, "half intensity should settle on the middle stage");
+ assertEquals(settled, highestOver(stage, 0.51D), "the stage moved on a small fluctuation");
+ }
+
+ @Test
+ @DisplayName("A real change moves the stage")
+ void largerChangeMovesTheStage() {
+ TunnelVisionStage stage = new TunnelVisionStage();
+ assertEquals(TunnelVisionStage.MAX_STAGE / 2, highestOver(stage, 0.5D));
+ assertEquals(TunnelVisionStage.MAX_STAGE / 2 + 1, highestOver(stage, 0.53D),
+ "the stage should follow a real change");
+ }
+
+ @Test
+ @DisplayName("The stage never leaves its bounds")
+ void stageStaysWithinBounds() {
+ TunnelVisionStage stage = new TunnelVisionStage();
+ for (int sample = 0; sample < SAMPLES; sample++) {
+ int current = stage.update(sample % 2 == 0 ? 1.0D : 0.0D);
+ assertTrue(current >= 0 && current <= TunnelVisionStage.MAX_STAGE, "stage out of bounds: " + current);
+ }
+ }
+
+ /**
+ * Feeds a constant intensity for a while and reports the highest stage seen, which is the
+ * stage the pulse starts from.
+ *
+ * @param stage the stage state to drive
+ * @param combined the constant intensity to feed
+ * @return the highest stage observed
+ */
+ private int highestOver(TunnelVisionStage stage, double combined) {
+ int highest = 0;
+ for (int sample = 0; sample < SAMPLES; sample++) {
+ highest = Math.max(highest, stage.update(combined));
+ }
+ return highest;
+ }
+}
diff --git a/game/src/test/java/net/onelitefeather/cygnus/utils/StaminaShareTest.java b/game/src/test/java/net/onelitefeather/cygnus/utils/StaminaShareTest.java
new file mode 100644
index 00000000..f8b4919e
--- /dev/null
+++ b/game/src/test/java/net/onelitefeather/cygnus/utils/StaminaShareTest.java
@@ -0,0 +1,51 @@
+package net.onelitefeather.cygnus.utils;
+
+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.stamina.StaminaService;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Verifies {@link StaminaHelper#remainingShare(StaminaService, Player)}, the stamina reading the
+ * tunnel vision drives itself off.
+ *
+ * The case that matters is the player without a bar. The effect is ticked from a scheduler, so it
+ * asks about players who are connected but not playing a round; reading a full bar there is what
+ * keeps the effect off for them instead of closing their view completely.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+class StaminaShareTest extends CygnusPlayerTestBase {
+
+ @Test
+ @DisplayName("A player without a bar reads as untouched rather than empty")
+ void playerWithoutBarIsFull(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createConnection().connect(instance, new Pos(0, 40, 0));
+
+ assertEquals(1.0D, StaminaHelper.remainingShare(new StaminaService(), player), 1.0E-6D);
+ }
+
+ @Test
+ @DisplayName("A survivor with a fresh bar reads as untouched")
+ void freshBarIsFull(Env env) {
+ Instance instance = env.createFlatInstance();
+ Player player = env.createConnection().connect(instance, new Pos(0, 40, 0));
+
+ StaminaService staminaService = new StaminaService();
+ staminaService.createStaminaBars(Set.of(player));
+
+ assertEquals(1.0D, StaminaHelper.remainingShare(staminaService, player), 1.0E-6D);
+ }
+}