From 117ad68f79b452829c304c23f840d093b0fdcdae Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 12 Aug 2026 09:25:28 +0200 Subject: [PATCH 1/2] feat(gaze): add slender gaze glitch effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A survivor's picture now tears apart while the slender stands in their field of view, in four levels that grow as he closes in, and it stops entirely the moment they cannot see him — standing behind someone does nothing, however close. That question (can he be seen) is different from proximity, so it lives as its own effect instead of being folded into tunnel vision. A real colour-space shift would need a post-processing shader, and Minecraft 26.2 has no way to switch one on per player, so the colour is laid over the world as a camera_overlay rather than recalculated from it. Only survivors are affected; the textures themselves ship separately in cygnus-pack. `/glitch <1-4>` previews a level from the lobby without a slender present, and `/glitch off` clears it, so the four drawings can be judged before a match. --- .../cygnus/command/GlitchCommand.java | 49 ++++ .../cygnus/gaze/SlenderGaze.java | 69 +++++ .../cygnus/gaze/SlenderGazeService.java | 208 +++++++++++++++ .../cygnus/gaze/package-info.java | 4 + .../cygnus/command/GlitchCommandTest.java | 132 +++++++++ .../cygnus/gaze/SlenderGazeServiceTest.java | 252 ++++++++++++++++++ .../cygnus/gaze/SlenderGazeTest.java | 72 +++++ 7 files changed, 786 insertions(+) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/gaze/package-info.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeServiceTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeTest.java 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/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java b/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java new file mode 100644 index 00000000..772756b1 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java @@ -0,0 +1,132 @@ +package net.onelitefeather.cygnus.command; + +import net.kyori.adventure.key.Key; +import net.minestom.server.MinecraftServer; +import net.minestom.server.command.builder.Command; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.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 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; + +/** + * Verifies the command used to preview the slender's glitch without him being there. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class GlitchCommandTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("A requested level is drawn right away") + void levelIsDrawnOnRequest(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + register(overlay); + + MinecraftServer.getCommandManager().execute(player, "glitch 2"); + + assertNotNull(overlay.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(); + 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"); + } + + @Test + @DisplayName("Every level of the tearing can be requested") + void everyLevelCanBeRequested(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + register(overlay); + + for (int level = 1; level <= SlenderGaze.LEVELS; level++) { + overlay.forget(); + MinecraftServer.getCommandManager().execute(player, "glitch " + level); + assertNotNull(overlay.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(RecordingOverlay 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)); + } + + /** + * 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 new file mode 100644 index 00000000..3186ee9b --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeServiceTest.java @@ -0,0 +1,252 @@ +package net.onelitefeather.cygnus.gaze; + +import net.kyori.adventure.key.Key; +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 net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Verifies the tearing a survivor gets while the slender stands in their view. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class SlenderGazeServiceTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("Seeing the slender tears the survivor's view") + void seeingHimTearsTheView(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + 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)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(survivor); + + service.tick(); + + assertNotNull(overlay.of(survivor, OverlayLayer.GLITCH), "he is right in front of them"); + } + + @Test + @DisplayName("With him behind them there is nothing to see") + void behindThemNothingHappens(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + 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)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(survivor); + + service.tick(); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH), "the effect is about seeing him"); + } + + @Test + @DisplayName("Looking away takes it off again") + void lookingAwayClearsIt(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + 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)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(survivor); + service.tick(); + + survivor.teleport(new Pos(0, 40, 0, 180, 0)); + service.tick(); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + @Test + @DisplayName("The tearing runs on while he stays in view") + void tearingKeepsMoving(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + 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)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(survivor); + + service.tick(); + Key first = overlay.of(survivor, OverlayLayer.GLITCH); + service.tick(); + + assertNotEquals(first, overlay.of(survivor, OverlayLayer.GLITCH), + "a still picture is not a glitch"); + } + + @Test + @DisplayName("Without a slender nothing happens at all") + void withoutASlenderNothingHappens(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player survivor = connect(env, env.createFlatInstance(), new Pos(0, 40, 0, 0, 0)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> null); + service.track(survivor); + + service.tick(); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + @Test + @DisplayName("A removed survivor gets their view back") + void removedSurvivorIsCleared(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + 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)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(survivor); + service.tick(); + + service.remove(survivor); + service.tick(); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + @Test + @DisplayName("Clearing everyone gives every survivor their screen back") + void clearAllWipesEveryone(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + 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)); + Player slender = connect(env, instance, new Pos(0, 40, 5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(first); + service.track(second); + service.tick(); + + service.clearAll(); + + 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"); + } + + @Test + @DisplayName("The start of a round takes the survivors on board") + void gameStartTracksSurvivors(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + 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)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + + EventDispatcher.call(new GameStartEvent()); + service.tick(); + + assertNotNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + @Test + @DisplayName("A dying survivor gets their screen back") + void deathRemovesTheSurvivor(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + 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)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.track(survivor); + service.tick(); + + EventDispatcher.call(new PlayerDeathEvent(survivor, Component.empty(), Component.empty())); + service.tick(); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + @Test + @DisplayName("The end of a round clears everyone") + void gameFinishClearsEveryone(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + 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)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.track(survivor); + service.tick(); + + EventDispatcher.call(new GameFinishEvent(GameFinishEvent.Reason.TIME_OVER)); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + /** + * Connects a player at the given position. + * + * @param env the test environment + * @param instance the instance to connect into + * @param position where to place them + * @return the connected player + */ + 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); + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeTest.java b/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeTest.java new file mode 100644 index 00000000..dadbbdb3 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeTest.java @@ -0,0 +1,72 @@ +package net.onelitefeather.cygnus.gaze; + +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; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies when the sight of the slender starts to tear a survivor's view apart. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class SlenderGazeTest { + + /** A survivor in the origin looking towards positive Z, which is a yaw of zero. */ + private static final Pos SURVIVOR = new Pos(0, 40, 0, 0, 0); + + @Test + @DisplayName("A slender straight ahead and close tears the view apart") + void closeAndAheadIsStrongest() { + assertEquals(SlenderGaze.LEVELS - 1, SlenderGaze.levelOf(SURVIVOR, new Pos(0, 40, 4))); + } + + @Test + @DisplayName("A slender ahead but far off barely registers") + void farAheadIsWeak() { + int level = SlenderGaze.levelOf(SURVIVOR, new Pos(0, 40, 28)); + assertTrue(level >= 0 && level < SlenderGaze.LEVELS - 1, "expected a weak level, got " + level); + } + + @Test + @DisplayName("Out of range there is nothing, however clear the line") + void beyondRangeIsNothing() { + assertEquals(SlenderGaze.NONE, SlenderGaze.levelOf(SURVIVOR, new Pos(0, 40, 80))); + } + + @Test + @DisplayName("Standing behind a survivor does nothing, however close") + void behindIsNothing() { + assertEquals(SlenderGaze.NONE, SlenderGaze.levelOf(SURVIVOR, new Pos(0, 40, -4)), + "the effect is about seeing him, not about him being there"); + } + + @Test + @DisplayName("Just outside the corner of the eye does nothing either") + void besideIsNothing() { + assertEquals(SlenderGaze.NONE, SlenderGaze.levelOf(SURVIVOR, new Pos(6, 40, 0))); + } + + @Test + @DisplayName("Turning towards him brings it on") + void turningTowardsHimBringsItOn() { + Pos turned = new Pos(0, 40, 0, -90, 0); + assertTrue(SlenderGaze.levelOf(turned, new Pos(6, 40, 0)) > SlenderGaze.NONE, + "he is in front of the survivor now"); + } + + @Test + @DisplayName("Closing in never weakens the effect") + void levelIsMonotonic() { + int previous = SlenderGaze.NONE; + for (int distance = 40; distance >= 1; distance--) { + int current = SlenderGaze.levelOf(SURVIVOR, new Pos(0, 40, distance)); + assertTrue(current >= previous, "the tearing eased off at distance " + distance); + previous = current; + } + } +} From c487003bf753c43f9f69beeda74410cd5ef64a62 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 12 Aug 2026 09:25:41 +0200 Subject: [PATCH 2/2] feat(cygnus): wire slender gaze glitch into the game bootstrap Constructs SlenderGazeService on the shared screen overlay and registers its listener behind the existing OverlayProperties.enabled() guard, so it only runs where the resource pack that carries the glitch textures is delivered. Also registers /glitch so a level can be previewed from the lobby before a match. --- .../net/onelitefeather/cygnus/Cygnus.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index 3dfeb864..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() {