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/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/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/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));
+ }
+}
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);
+ }
+}
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");
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
index 3dfeb864..a0fcd1f2 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
@@ -37,6 +37,7 @@
import net.minestom.server.listener.EntityActionListener;
import net.minestom.server.network.packet.client.play.ClientEntityActionPacket;
import net.onelitefeather.cygnus.ambient.AmbientProvider;
+import net.onelitefeather.cygnus.command.GlitchCommand;
import net.onelitefeather.cygnus.command.StartCommand;
import net.onelitefeather.cygnus.common.ListenerHandling;
import net.onelitefeather.cygnus.common.bootstrap.ServiceBootstrap;
@@ -46,6 +47,7 @@
import net.onelitefeather.cygnus.common.page.PageProvider;
import net.onelitefeather.cygnus.common.page.event.PageExpiredEvent;
import net.onelitefeather.cygnus.event.GameFinishEvent;
+import net.onelitefeather.cygnus.gaze.SlenderGazeService;
import net.onelitefeather.cygnus.event.SlenderReviveEvent;
import net.onelitefeather.cygnus.event.StaminaStateChangeEvent;
import net.onelitefeather.cygnus.jumpscare.JumpScareManager;
@@ -74,14 +76,19 @@
import net.onelitefeather.cygnus.resourcepack.ResourcePackService;
import net.onelitefeather.cygnus.stamina.SlenderBarTrigger;
import net.onelitefeather.cygnus.stamina.StaminaService;
+import net.onelitefeather.cygnus.overlay.ScreenOverlay;
+import net.onelitefeather.cygnus.overlay.EquipmentScreenOverlay;
+import net.onelitefeather.cygnus.overlay.OverlayProperties;
import net.onelitefeather.cygnus.utils.StaminaHelper;
import net.onelitefeather.cygnus.utils.ViewRuleUpdater;
import net.onelitefeather.cygnus.view.GameView;
import net.onelitefeather.cygnus.view.GameViewImpl;
import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
import java.nio.file.Path;
import java.util.Optional;
+import java.util.Set;
import java.util.function.Supplier;
/**
@@ -103,6 +110,8 @@ public final class Cygnus implements TeamCreator, ListenerHandling {
private final JumpScareManager jumpscareManager;
private final SpectatorService spectatorService;
private final Optional resourcePackService;
+ private final ScreenOverlay screenOverlay;
+ private final SlenderGazeService slenderGazeService;
public Cygnus() {
Path path = ServiceBootstrap.resolveWorkingDirectory();
@@ -126,6 +135,8 @@ public Cygnus() {
.orElseThrow(() -> new IllegalStateException("Spectator team not found"));
this.spectatorService = new SpectatorService(spectatorTeam, survivorTeam);
this.resourcePackService = ResourcePackService.create();
+ this.screenOverlay = new EquipmentScreenOverlay();
+ this.slenderGazeService = new SlenderGazeService(this.screenOverlay, this::currentSlender);
this.initPhases();
this.initCommands();
this.initListener();
@@ -136,6 +147,29 @@ public Cygnus() {
private void initCommands() {
var manager = MinecraftServer.getCommandManager();
manager.register(new StartCommand(this.linearPhaseSeries));
+ manager.register(new GlitchCommand(this.slenderGazeService));
+ }
+
+ /**
+ * Looks up the player currently playing the slender.
+ *
+ * @return the slender, or {@code null} while the role is unassigned
+ */
+ private @Nullable Player currentSlender() {
+ return this.teamService.getTeam(GameConfig.SLENDER_KEY)
+ .flatMap(team -> team.getPlayers().stream().findFirst())
+ .orElse(null);
+ }
+
+ /**
+ * Collects the players that are currently survivors.
+ *
+ * @return the survivor team's players
+ */
+ private Set currentSurvivors() {
+ return this.teamService.getTeam(GameConfig.SURVIVOR_KEY)
+ .map(team -> Set.copyOf(team.getPlayers()))
+ .orElseGet(Set::of);
}
@@ -191,6 +225,12 @@ private void registerGameListener() {
MinecraftServer.getPacketListenerManager().setPlayListener(ClientEntityActionPacket.class, CygnusEntityActionListener::listener);
spectatorService.registerListener(handler);
+
+ // Without the pack the vignette font does not exist and survivors would stare at an
+ // empty box, so the effect stays off wherever the pack is not delivered.
+ if (OverlayProperties.enabled()) {
+ this.slenderGazeService.registerListener(handler, this::currentSurvivors);
+ }
}
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
new file mode 100644
index 00000000..8ef1d271
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java
@@ -0,0 +1,48 @@
+package net.onelitefeather.cygnus.command;
+
+import net.minestom.server.command.CommandSender;
+import net.minestom.server.entity.Player;
+import net.onelitefeather.cygnus.common.Messages;
+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.
+ *
+ * {@code TunnelVisionCommand}, {@code BloodCommand} and {@code GlitchCommand} each hand-rolled an
+ * identical {@code private static @Nullable Player asPlayer(CommandSender)}, differing only in the
+ * error string sent back to the console. This type is that method, extracted once.
+ *
+ *
+ * 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 reason the rest of the sentence after {@code "Only players "}, e.g. {@code "can bleed."}
+ * @return the player, or {@code null} if the sender has no screen to draw on
+ */
+ public static @Nullable Player asPlayer(CommandSender sender, String reason) {
+ if (sender instanceof Player player) return player;
+ sender.sendMessage(Messages.withMiniPrefix("Only players " + reason));
+ 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
new file mode 100644
index 00000000..199d25b1
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java
@@ -0,0 +1,49 @@
+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 1.0.0
+ * @since 2.7.0
+ */
+public final class GlitchCommand extends Command {
+
+ /**
+ * 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.withMiniPrefix("Usage: /glitch <1-" + SlenderGaze.LEVELS + "> | off")
+ ));
+
+ this.addSyntax((sender, context) -> {
+ Player player = CommandSenders.asPlayer(sender, "have a view to lose.");
+ if (player == null) return;
+ service.show(player, context.get(level) - 1);
+ }, level);
+
+ this.addSyntax((sender, context) -> {
+ Player player = CommandSenders.asPlayer(sender, "have a view to lose.");
+ if (player == null) return;
+ service.hide(player);
+ }, ArgumentType.Literal("off"));
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java
new file mode 100644
index 00000000..01fbb31a
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java
@@ -0,0 +1,69 @@
+package net.onelitefeather.cygnus.gaze;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+import net.onelitefeather.cygnus.common.util.Helper;
+
+/**
+ * Works out how badly the sight of the slender tears a survivor's view apart.
+ *
+ * This is about seeing him, not about him being there: standing behind a survivor does nothing at
+ * all, however close he is. Only once he is inside their field of view does the picture start to
+ * come apart, and it gets worse the nearer he is.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.7.0
+ */
+public final class SlenderGaze {
+
+ /** Nothing to draw: he is out of range, or out of sight. */
+ public static final int NONE = -1;
+
+ /** How many degrees of tearing there are between just visible and right in front. */
+ public static final int LEVELS = 4;
+
+ /** Beyond this distance he is too far away to unsettle anything. */
+ private static final double RANGE = 32.0D;
+
+ /** The distance at which the tearing is at its worst. */
+ private static final double CLOSE = 6.0D;
+
+ /**
+ * How far off the view direction he may stand and still count as seen. Roughly the horizontal
+ * field of view of a default client — the effect belongs on the screen he is on.
+ */
+ private static final double FIELD_OF_VIEW = 0.55D;
+
+ /** Below this distance the direction to him carries no meaning any more. */
+ private static final double DISTANCE_EPSILON = 1.0E-6D;
+
+ private SlenderGaze() {
+ }
+
+ /**
+ * Works out the tearing a survivor gets from where the slender stands.
+ *
+ * @param survivor the survivor's position, whose yaw and pitch supply the view direction
+ * @param slender the slender's position
+ * @return a level between {@code 0} and {@code LEVELS - 1}, or {@link #NONE}
+ */
+ public static int levelOf(Pos survivor, Pos slender) {
+ double distance = survivor.distance(slender);
+ if (distance > RANGE) return NONE;
+ if (distance < DISTANCE_EPSILON) return LEVELS - 1;
+
+ Vec towardsSlender = new Vec(
+ slender.x() - survivor.x(),
+ slender.y() - survivor.y(),
+ slender.z() - survivor.z()
+ ).div(distance);
+
+ 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);
+ 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
new file mode 100644
index 00000000..a21eda61
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java
@@ -0,0 +1,208 @@
+package net.onelitefeather.cygnus.gaze;
+
+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.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 org.jetbrains.annotations.Nullable;
+
+import java.time.temporal.ChronoUnit;
+import java.util.Set;
+import java.util.function.Supplier;
+
+/**
+ * Tears a survivor's picture apart while the slender stands in their view.
+ *
+ * This replaces what the tunnel vision used to do when he came near, and it asks a different
+ * question: not how close he is, but whether they can see him. Standing behind a survivor does
+ * nothing at all.
+ *
+ *
+ * A real colour-space shift would need a post-processing shader, and on Minecraft 26.2 those
+ * cannot be switched on for a single player, so this is a camera overlay like the others — the
+ * colour is laid over the world rather than the world being recalculated.
+ *
+ *
+ * @author TheMeinerLP
+ * @version 2.0.0
+ * @since 2.7.0
+ */
+public final class SlenderGazeService {
+
+ /** Where the glitch textures live, as {@code camera_overlay} resolves them. */
+ static final String TEXTURE_PATH = "gui/glitch/level_";
+
+ /** How many frames the tearing runs through. */
+ static final int FRAMES = 4;
+
+ /** How long a frame stays on screen. */
+ static final int TICK_MILLIS = 100;
+
+ private static final Key[][] TEXTURES = OverlayTextureKeys.table(
+ TEXTURE_PATH, SlenderGaze.LEVELS, FRAMES, OverlayTextureKeys.ONE_BASED, OverlayTextureKeys.ONE_BASED);
+
+ private final ScreenOverlay overlay;
+ private final Supplier<@Nullable Player> slender;
+ private final PlayerState survivors = new PlayerState<>();
+ private final RepeatingTask task = new RepeatingTask(this::tick);
+
+ private int frame;
+
+ /**
+ * Creates a new service.
+ *
+ * @param overlay the overlay that owns the players' screens
+ * @param slender supplies the current slender, or {@code null} while there is none
+ */
+ public SlenderGazeService(ScreenOverlay overlay, Supplier<@Nullable Player> slender) {
+ this.overlay = overlay;
+ this.slender = slender;
+ }
+
+ /**
+ * Hooks the service into the round's lifecycle.
+ *
+ * Mirrors {@code TunnelVisionService}: the service listens for itself rather than being called
+ * from {@code GameStartListener} and friends, because — unlike {@code AmbientProvider}, which
+ * has no per-player state to speak of — it has to drop an individual survivor's tracking the
+ * moment they die or disconnect, not only when the whole round ends. Folding that into the
+ * round's start and finish hooks would mean widening their signatures for every service that
+ * needs it; listening for itself keeps this self-contained instead.
+ *
+ *
+ * @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.clearAll();
+ this.stopTask();
+ });
+ }
+
+ /**
+ * Starts the update task. Does nothing if it is already running.
+ */
+ public void startTask() {
+ this.task.start(TICK_MILLIS, ChronoUnit.MILLIS);
+ }
+
+ /**
+ * 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
+ * too.
+ */
+ public void stopTask() {
+ this.task.stop();
+ }
+
+ /**
+ * Starts drawing for a survivor.
+ *
+ * @param survivor the survivor to draw for
+ */
+ public void track(Player survivor) {
+ this.survivors.put(survivor, survivor);
+ }
+
+ /**
+ * Stops drawing for a survivor and clears what is left on their screen.
+ *
+ * @param player the survivor to drop
+ */
+ public void remove(Player player) {
+ if (this.survivors.remove(player) == null) return;
+ this.overlay.set(player, OverlayLayer.GLITCH, null);
+ }
+
+ /**
+ * Clears every tracked survivor's screen and forgets all of them.
+ */
+ public void clearAll() {
+ for (Player survivor : this.survivors.values()) {
+ this.overlay.set(survivor, OverlayLayer.GLITCH, null);
+ }
+ this.survivors.clear();
+ }
+
+ /**
+ * Puts one level on a player's screen and leaves it there, for judging the drawings without a
+ * slender to walk in front of.
+ *
+ * This sits on the service rather than a separate type because it draws from the very texture
+ * table {@link #tick()} already builds; splitting it out would mean either rebuilding that table
+ * a second time or exposing it, trading one seam for a worse one over two lines of
+ * {@code GlitchCommand} preview code.
+ *
+ *
+ * @param player the player to draw for
+ * @param level the level between {@code 0} and {@code SlenderGaze.LEVELS - 1}
+ */
+ public void show(Player player, int level) {
+ int clamped = Helper.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.
+ */
+ void tick() {
+ if (this.survivors.isEmpty()) return;
+
+ Player currentSlender = this.slender.get();
+ this.frame++;
+
+ for (Player survivor : this.survivors.values()) {
+ int level = this.levelFor(survivor, currentSlender);
+ if (level == SlenderGaze.NONE) {
+ this.overlay.set(survivor, OverlayLayer.GLITCH, null);
+ continue;
+ }
+ this.overlay.set(survivor, OverlayLayer.GLITCH, TEXTURES[level][this.frame % FRAMES]);
+ }
+ }
+
+ /**
+ * Works out the tearing one survivor gets.
+ *
+ * @param survivor the survivor to look at
+ * @param slender the current slender, may be {@code null}
+ * @return the level, or {@link SlenderGaze#NONE}
+ */
+ private int levelFor(Player survivor, @Nullable Player slender) {
+ if (slender == null) return SlenderGaze.NONE;
+
+ Instance instance = slender.getInstance();
+ if (instance == null || !instance.equals(survivor.getInstance())) return SlenderGaze.NONE;
+
+ return SlenderGaze.levelOf(survivor.getPosition(), slender.getPosition());
+ }
+}
diff --git a/game/src/main/java/net/onelitefeather/cygnus/gaze/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/gaze/package-info.java
new file mode 100644
index 00000000..2ce2129c
--- /dev/null
+++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/package-info.java
@@ -0,0 +1,4 @@
+@NotNullByDefault
+package net.onelitefeather.cygnus.gaze;
+
+import org.jetbrains.annotations.NotNullByDefault;
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