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> layers = new PlayerState<>(); + private final PlayerState shown = new PlayerState<>(); + + /** + * {@inheritDoc} + */ + @Override + public void set(Player player, OverlayLayer layer, @Nullable Key texture) { + Map current = this.layers + .computeIfAbsent(player, () -> new EnumMap<>(OverlayLayer.class)); + + if (texture == null) { + current.remove(layer); + } else { + current.put(layer, texture); + } + + this.apply(player, current); + } + + /** + * {@inheritDoc} + */ + @Override + public void clear(Player player) { + this.layers.remove(player); + this.shown.remove(player); + player.setHelmet(ItemStack.AIR); + } + + /** + * Works out which layer is on top and puts it on the player's head. + * + * @param player the player to draw for + * @param current the layers currently set for them + */ + private void apply(Player player, Map current) { + Key topmost = this.topmost(current); + + if (topmost == null) { + if (this.shown.remove(player) == null) return; + player.setHelmet(ItemStack.AIR); + return; + } + + // The overlay is refreshed many times a second; re-sending an unchanged item would put an + // equipment update on the wire for every viewer each time. + if (topmost.equals(this.shown.get(player))) return; + + this.shown.put(player, topmost); + player.setHelmet(carrierFor(topmost)); + } + + /** + * Picks the layer that is drawn on top of the others. + * + * @param current the layers currently set + * @return the texture to show, or {@code null} if nothing is set + */ + private @Nullable Key topmost(Map current) { + Key topmost = null; + // Declaration order of OverlayLayer is drawing order, so the last hit wins. + for (OverlayLayer layer : OverlayLayer.values()) { + Key texture = current.get(layer); + if (texture != null) topmost = texture; + } + return topmost; + } + + /** + * Builds the item that carries a given overlay texture. + * + * @param texture the texture to show + * @return the item to put in the head slot + */ + private static ItemStack carrierFor(Key texture) { + Equippable equippable = new Equippable( + EquipmentSlot.HELMET, + SILENT, + EMPTY_ASSET, + texture.asString(), + null, + false, + false, + false, + false, + false, + SILENT + ); + return ItemStack.builder(CARRIER).set(DataComponents.EQUIPPABLE, equippable).build(); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayLayer.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayLayer.java new file mode 100644 index 00000000..656ed319 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayLayer.java @@ -0,0 +1,21 @@ +package net.onelitefeather.cygnus.overlay; + +/** + * The full-screen layers a player can have on their HUD at once, in drawing order — later + * constants are drawn on top of earlier ones. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public enum OverlayLayer { + + /** The narrowing view, driven by how much stamina a survivor has left. */ + TUNNEL_VISION, + + /** The tearing that comes over a survivor while the slender is in their view. */ + GLITCH, + + /** The splatter that flashes up when the player is hit; sits closest to the eye. */ + BLOOD +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayProperties.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayProperties.java new file mode 100644 index 00000000..76581c82 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayProperties.java @@ -0,0 +1,35 @@ +package net.onelitefeather.cygnus.overlay; + +/** + * Decides whether the full-screen overlays — the tunnel vision 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 + * server can be run without handing out a pack while the people testing it have the pack enabled + * locally, and in that setup the effects silently never started. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class OverlayProperties { + + static final String ENABLED_PROPERTY = "cygnus.overlays"; + + private OverlayProperties() { + } + + /** + * Tells whether the overlays should run. + *

+ * On unless the property says {@code false}. Anything unreadable leaves them on: the effects + * are part of the game, and a typo in a start script should not quietly remove them. + *

+ * + * @return whether to register the overlay services + */ + public static boolean enabled() { + return !"false".equalsIgnoreCase(System.getProperty(ENABLED_PROPERTY, "true").trim()); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeys.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeys.java new file mode 100644 index 00000000..fe97ff2e --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeys.java @@ -0,0 +1,112 @@ +package net.onelitefeather.cygnus.overlay; + +import net.kyori.adventure.key.Key; + +import java.util.function.IntFunction; + +/** + * Builds the {@code cygnus:} {@link Key}s a full-screen overlay draws from the resource pack. + *

+ * {@code OverlayTunnelVisionRenderer}, {@code SlenderGazeService} and {@code BloodSplatterService} + * each hand-rolled their own {@code buildTextures()}, one per axis count: a flat table indexed by + * stage, a two-dimensional one indexed by level and frame, and a three-dimensional one (flattened + * into a single array) indexed by direction, variant and frame. All three follow the same shape once + * written out: the texture path, followed by every axis's label joined with {@code _}. This type is + * that shape, extracted once for every table rank the three renderers need. + *

+ *

+ * An axis's labels come from an {@link IntFunction}, not a plain 1-based count, because the blood + * splatter's outermost axis is a {@code BloodDirection} name such as {@code left} rather than a + * number. {@link #ONE_BASED} covers the common case of a numbered axis. + *

+ * + *

Usage:

+ *
{@code
+ * Key[] stages = OverlayTextureKeys.flat("gui/tunnel_vision/stage_", 16, OverlayTextureKeys.ONE_BASED);
+ * Key[][] glitch = OverlayTextureKeys.table(
+ *         "gui/glitch/level_", LEVELS, FRAMES, OverlayTextureKeys.ONE_BASED, OverlayTextureKeys.ONE_BASED);
+ * Key[][][] blood = OverlayTextureKeys.cube(
+ *         "gui/blood/", DIRECTIONS, VARIANTS, FRAMES,
+ *         direction -> BloodDirection.values()[direction].name().toLowerCase(Locale.ROOT),
+ *         OverlayTextureKeys.ONE_BASED, OverlayTextureKeys.ONE_BASED);
+ * }
+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class OverlayTextureKeys { + + /** The namespace every overlay texture lives under. */ + public static final String NAMESPACE = "cygnus"; + + /** Labels an axis {@code 1}, {@code 2}, {@code 3}, ... from its zero-based index. */ + public static final IntFunction ONE_BASED = index -> Integer.toString(index + 1); + + private OverlayTextureKeys() { + // Prevent instantiation of utility class + } + + /** + * Builds a flat table of texture keys, one per index. + * + * @param path the texture path every key is built from, without a trailing separator + * @param size how many keys to build + * @param label labels each index + * @return the keys, indexed the same way + */ + public static Key[] flat(String path, int size, IntFunction label) { + Key[] keys = new Key[size]; + for (int i = 0; i < size; i++) { + keys[i] = Key.key(NAMESPACE, path + label.apply(i)); + } + return keys; + } + + /** + * Builds a two-dimensional table of texture keys, one per row and column. + * + * @param path the texture path every key is built from, without a trailing separator + * @param rows how many rows to build + * @param columns how many columns to build + * @param rowLabel labels each row + * @param columnLabel labels each column + * @return the keys, indexed {@code [row][column]} + */ + public static Key[][] table(String path, int rows, int columns, IntFunction rowLabel, + IntFunction columnLabel) { + Key[][] keys = new Key[rows][columns]; + for (int row = 0; row < rows; row++) { + for (int column = 0; column < columns; column++) { + keys[row][column] = Key.key(NAMESPACE, path + rowLabel.apply(row) + "_" + columnLabel.apply(column)); + } + } + return keys; + } + + /** + * Builds a three-dimensional table of texture keys, one per plane, row and column. + * + * @param path the texture path every key is built from, without a trailing separator + * @param planes how many planes to build + * @param rows how many rows to build + * @param columns how many columns to build + * @param planeLabel labels each plane + * @param rowLabel labels each row + * @param columnLabel labels each column + * @return the keys, indexed {@code [plane][row][column]} + */ + public static Key[][][] cube(String path, int planes, int rows, int columns, IntFunction planeLabel, + IntFunction rowLabel, IntFunction columnLabel) { + Key[][][] keys = new Key[planes][rows][columns]; + for (int plane = 0; plane < planes; plane++) { + for (int row = 0; row < rows; row++) { + for (int column = 0; column < columns; column++) { + keys[plane][row][column] = Key.key(NAMESPACE, + path + planeLabel.apply(plane) + "_" + rowLabel.apply(row) + "_" + columnLabel.apply(column)); + } + } + } + return keys; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/ScreenOverlay.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/ScreenOverlay.java new file mode 100644 index 00000000..de9ca55b --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/ScreenOverlay.java @@ -0,0 +1,35 @@ +package net.onelitefeather.cygnus.overlay; + +import net.kyori.adventure.key.Key; +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.Nullable; + +/** + * Owns the full-screen overlay of a player and decides what ends up on it. + *

+ * The effects hand over a texture for their layer rather than drawing themselves, because a player + * only has one screen to give: whichever effect drew last would otherwise wipe the other. + *

+ * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.7.0 + */ +public interface ScreenOverlay { + + /** + * Sets or removes what a layer contributes to the player's screen. + * + * @param player the player to draw for + * @param layer the layer to change + * @param texture the overlay texture to show, or {@code null} to drop the layer + */ + void set(Player player, OverlayLayer layer, @Nullable Key texture); + + /** + * Drops every layer and clears the player's screen. + * + * @param player the player to clear + */ + void clear(Player player); +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/package-info.java new file mode 100644 index 00000000..8ec288a2 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.onelitefeather.cygnus.overlay; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/test/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlayTest.java b/game/src/test/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlayTest.java new file mode 100644 index 00000000..d9366eaf --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlayTest.java @@ -0,0 +1,140 @@ +package net.onelitefeather.cygnus.overlay; + +import net.kyori.adventure.key.Key; +import net.minestom.server.component.DataComponents; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.component.Equippable; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that the overlay reaches the client as a camera overlay on the player's head, which is + * the only way to have it scale with the screen rather than with a font size. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class EquipmentScreenOverlayTest extends CygnusPlayerTestBase { + + private static final Key TUNNEL = Key.key("cygnus", "gui/tunnel_vision/stage_7"); + private static final Key BLOOD = Key.key("cygnus", "gui/blood/left_1_1"); + + private final EquipmentScreenOverlay overlay = new EquipmentScreenOverlay(); + + @Test + @DisplayName("A layer becomes a camera overlay on the player's head") + void layerBecomesCameraOverlay(Env env) { + Player player = spawn(env); + + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + + assertEquals(TUNNEL.asString(), equippable(player).cameraOverlay()); + } + + @Test + @DisplayName("The blood takes the screen while it is up") + void bloodWinsOverTheTunnelVision(Env env) { + Player player = spawn(env); + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + + this.overlay.set(player, OverlayLayer.BLOOD, BLOOD); + + assertEquals(BLOOD.asString(), equippable(player).cameraOverlay(), + "only one camera overlay exists, and a hit is what matters most"); + } + + @Test + @DisplayName("Once the blood is gone the tunnel vision comes back") + void tunnelVisionReturnsAfterTheBlood(Env env) { + Player player = spawn(env); + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + this.overlay.set(player, OverlayLayer.BLOOD, BLOOD); + + this.overlay.set(player, OverlayLayer.BLOOD, null); + + assertEquals(TUNNEL.asString(), equippable(player).cameraOverlay()); + } + + @Test + @DisplayName("The last layer leaving empties the head slot") + void lastLayerEmptiesTheSlot(Env env) { + Player player = spawn(env); + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, null); + + assertTrue(player.getHelmet().isAir(), "an item left behind would keep the overlay up"); + } + + @Test + @DisplayName("Clearing empties the head slot and forgets the layers") + void clearingEmptiesTheSlot(Env env) { + Player player = spawn(env); + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + this.overlay.set(player, OverlayLayer.BLOOD, BLOOD); + + this.overlay.clear(player); + + assertTrue(player.getHelmet().isAir()); + } + + @Test + @DisplayName("The carrier item cannot be taken off or seen") + void carrierItemStaysPutAndInvisible(Env env) { + Player player = spawn(env); + + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + + Equippable equippable = equippable(player); + assertFalse(equippable.swappable(), "right-clicking must not strip the overlay"); + assertFalse(equippable.dispensable(), "a dispenser must not hand out overlays"); + assertFalse(equippable.damageOnHurt(), "the carrier is not armour"); + assertNotNull(equippable.assetId(), "without an asset the item is drawn on the player's head"); + } + + @Test + @DisplayName("Setting the same layer twice does not churn the slot") + void repeatedSetKeepsTheSameItem(Env env) { + Player player = spawn(env); + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + ItemStack first = player.getHelmet(); + + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + + assertEquals(first, player.getHelmet(), "an unchanged overlay must not be re-sent"); + } + + /** + * 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)); + } + + /** + * Reads the equippable component off the player's head slot. + * + * @param player the player to read + * @return the component + */ + private Equippable equippable(Player player) { + Equippable equippable = player.getHelmet().get(DataComponents.EQUIPPABLE); + assertNotNull(equippable, "nothing is carrying an overlay"); + return equippable; + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayPropertiesTest.java b/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayPropertiesTest.java new file mode 100644 index 00000000..23210808 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayPropertiesTest.java @@ -0,0 +1,50 @@ +package net.onelitefeather.cygnus.overlay; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the switch that decides whether the full-screen overlays run. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class OverlayPropertiesTest { + + @AfterEach + void clearProperty() { + System.clearProperty(OverlayProperties.ENABLED_PROPERTY); + } + + @Test + @DisplayName("The overlays run unless somebody says otherwise") + void enabledByDefault() { + assertTrue(OverlayProperties.enabled(), "the effects are part of the game, not an extra"); + } + + @Test + @DisplayName("They can be switched off") + void canBeSwitchedOff() { + System.setProperty(OverlayProperties.ENABLED_PROPERTY, "false"); + assertFalse(OverlayProperties.enabled()); + } + + @Test + @DisplayName("Switching them on explicitly works too") + void canBeSwitchedOn() { + System.setProperty(OverlayProperties.ENABLED_PROPERTY, "true"); + assertTrue(OverlayProperties.enabled()); + } + + @Test + @DisplayName("Anything unreadable leaves them on rather than silently off") + void nonsenseLeavesThemOn() { + System.setProperty(OverlayProperties.ENABLED_PROPERTY, "perhaps"); + assertTrue(OverlayProperties.enabled(), "a typo must not take the effects out of the game"); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeysTest.java b/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeysTest.java new file mode 100644 index 00000000..a05927c9 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeysTest.java @@ -0,0 +1,54 @@ +package net.onelitefeather.cygnus.overlay; + +import net.kyori.adventure.key.Key; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies that {@link OverlayTextureKeys} reproduces the {@code cygnus:} key conventions the + * tunnel vision, glitch and blood renderers built by hand. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class OverlayTextureKeysTest { + + @Test + @DisplayName("A flat table matches the tunnel vision's stage_ convention") + void flatMatchesTunnelVisionConvention() { + Key[] keys = OverlayTextureKeys.flat("gui/tunnel_vision/stage_", 3, OverlayTextureKeys.ONE_BASED); + + assertEquals(Key.key("cygnus", "gui/tunnel_vision/stage_1"), keys[0]); + assertEquals(Key.key("cygnus", "gui/tunnel_vision/stage_2"), keys[1]); + assertEquals(Key.key("cygnus", "gui/tunnel_vision/stage_3"), keys[2]); + } + + @Test + @DisplayName("A two-dimensional table matches the glitch's level__ convention") + void tableMatchesGlitchConvention() { + Key[][] keys = OverlayTextureKeys.table( + "gui/glitch/level_", 2, 2, OverlayTextureKeys.ONE_BASED, OverlayTextureKeys.ONE_BASED); + + assertEquals(Key.key("cygnus", "gui/glitch/level_1_1"), keys[0][0]); + assertEquals(Key.key("cygnus", "gui/glitch/level_1_2"), keys[0][1]); + assertEquals(Key.key("cygnus", "gui/glitch/level_2_1"), keys[1][0]); + assertEquals(Key.key("cygnus", "gui/glitch/level_2_2"), keys[1][1]); + } + + @Test + @DisplayName("A three-dimensional table matches the blood's __ convention") + void cubeMatchesBloodConvention() { + String[] directions = {"left", "right"}; + + Key[][][] keys = OverlayTextureKeys.cube( + "gui/blood/", 2, 2, 2, + index -> directions[index], OverlayTextureKeys.ONE_BASED, OverlayTextureKeys.ONE_BASED); + + assertEquals(Key.key("cygnus", "gui/blood/left_1_1"), keys[0][0][0]); + assertEquals(Key.key("cygnus", "gui/blood/left_2_1"), keys[0][1][0]); + assertEquals(Key.key("cygnus", "gui/blood/right_1_2"), keys[1][0][1]); + } +} From 1a52775b2d6e24fb3709680ac49cc9ac8e38f57d Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 12 Aug 2026 09:17:38 +0200 Subject: [PATCH 05/14] feat(command): add CommandSenders to unify command sender checks The three preview commands (tunnel vision, blood, glitch) each hand-rolled an identical private static asPlayer(CommandSender) check to narrow a CommandSender down to a Player, differing only in the error string sent back to the console. Extract that check once as CommandSenders.asPlayer, a stateless static method rather than an abstract base command, since narrowing the sender is the only thing the three commands have in common and a shared base class would force them into one constructor shape and inheritance chain for a single one-line check. --- .../cygnus/command/CommandSenders.java | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java 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; + } +} From e3e6f3eb698dacda2979e7760852a1d522462a17 Mon Sep 17 00:00:00 2001 From: Phillipp Glanz <6745190+TheMeinerLP@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:22:54 +0200 Subject: [PATCH 06/14] feat(gaze): add slender gaze glitch effect (#179) --- .../net/onelitefeather/cygnus/Cygnus.java | 40 +++ .../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 +++++ 8 files changed, 826 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/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index 09584278..c3c5b07e 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -38,6 +38,7 @@ 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.command.StartCommand; import net.onelitefeather.cygnus.common.ListenerHandling; import net.onelitefeather.cygnus.common.bootstrap.ServiceBootstrap; @@ -47,6 +48,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; @@ -76,12 +78,17 @@ 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; +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); } private void initListener() { @@ -188,6 +222,12 @@ private void registerGameListener() { MinecraftServer.getPacketListenerManager().setPlayListener(ClientSettingsPacket.class, CygnusSettingsListener::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/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 730f32e1f2d1b2c6a04bce179fb8d610a4e5dcd0 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 23 Aug 2026 10:32:36 +0200 Subject: [PATCH 07/14] refactor(overlay): move the shared helpers to where they are used PlayerState and RepeatingTask sat in common/util, but common is what game and setup share, and setup uses neither. They move to game/utils, next to the other game-side helpers (Items, ScoreboardDisplay, StaminaHelper, ViewRuleUpdater), which also removes the second util package common/util vs game/utils opened up. Helper.clamp is dropped again: its body was a straight delegation to Math.clamp, and the codebase already calls Math.clamp directly in LobbyWaitingTask, CygnusPlayer and ColorUtil. HelperTest covered nothing else and goes with it. AmbientProvider now uses RepeatingTask instead of its own nullable Task field with the guard-and-return pair. It was one of the four copies the type was extracted from, so the extraction pays for itself here rather than only in the effects that land on top of this branch. RecordingScreenOverlay joins the test sources: every effect test needs the same recording ScreenOverlay, and writing it per test class is the same duplication this branch removes from the production code. --- .../cygnus/common/util/Helper.java | 30 +------- .../cygnus/common/util/HelperTest.java | 54 -------------- .../cygnus/ambient/AmbientProvider.java | 22 ++---- .../overlay/EquipmentScreenOverlay.java | 2 +- .../cygnus/utils}/PlayerState.java | 2 +- .../cygnus/utils}/RepeatingTask.java | 2 +- .../overlay/RecordingScreenOverlay.java | 71 +++++++++++++++++++ .../cygnus/utils}/PlayerStateTest.java | 2 +- .../utils}/RepeatingTaskIntegrationTest.java | 2 +- 9 files changed, 84 insertions(+), 103 deletions(-) delete mode 100644 common/src/test/java/net/onelitefeather/cygnus/common/util/HelperTest.java rename {common/src/main/java/net/onelitefeather/cygnus/common/util => game/src/main/java/net/onelitefeather/cygnus/utils}/PlayerState.java (98%) rename {common/src/main/java/net/onelitefeather/cygnus/common/util => game/src/main/java/net/onelitefeather/cygnus/utils}/RepeatingTask.java (98%) create mode 100644 game/src/test/java/net/onelitefeather/cygnus/overlay/RecordingScreenOverlay.java rename {common/src/test/java/net/onelitefeather/cygnus/common/util => game/src/test/java/net/onelitefeather/cygnus/utils}/PlayerStateTest.java (98%) rename {common/src/test/java/net/onelitefeather/cygnus/common/util => game/src/test/java/net/onelitefeather/cygnus/utils}/RepeatingTaskIntegrationTest.java (98%) 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 1764491c..d45d0eee 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.1.0 + * @version 1.0.2 * @since 1.0.0 **/ public final class Helper { @@ -69,34 +69,6 @@ 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 deleted file mode 100644 index 1bb53b16..00000000 --- a/common/src/test/java/net/onelitefeather/cygnus/common/util/HelperTest.java +++ /dev/null @@ -1,54 +0,0 @@ -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/game/src/main/java/net/onelitefeather/cygnus/ambient/AmbientProvider.java b/game/src/main/java/net/onelitefeather/cygnus/ambient/AmbientProvider.java index c760c0ff..0e940e2e 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/ambient/AmbientProvider.java +++ b/game/src/main/java/net/onelitefeather/cygnus/ambient/AmbientProvider.java @@ -2,15 +2,13 @@ import net.theevilreaper.xerus.api.team.Team; import net.kyori.adventure.sound.Sound; -import net.minestom.server.MinecraftServer; import net.minestom.server.entity.Player; 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.minestom.server.timer.Task; import net.onelitefeather.cygnus.common.Messages; -import org.jetbrains.annotations.Nullable; +import net.onelitefeather.cygnus.utils.RepeatingTask; import java.time.temporal.ChronoUnit; import java.util.List; @@ -34,7 +32,7 @@ * } * * @author theEvilReaper - * @version 2.0.0 + * @version 2.1.0 * @since 1.0.0 */ public final class AmbientProvider { @@ -50,7 +48,7 @@ public final class AmbientProvider { }; private final Team team; - private @Nullable Task task; + private final RepeatingTask task = new RepeatingTask(this::tick); private int currentTicks; /** @@ -62,23 +60,17 @@ public AmbientProvider(Team team) { } /** - * Starts the ambient task. + * Starts the ambient task. Does nothing if it is already running. */ public void startTask() { - if (task != null) return; - task = MinecraftServer.getSchedulerManager() - .buildTask(this::tick) - .repeat(1, ChronoUnit.SECONDS) - .schedule(); + this.task.start(1, ChronoUnit.SECONDS); } /** - * Stops the ambient task. + * Stops the ambient task. Does nothing if it is not running. */ public void stopTask() { - if (task == null) return; - task.cancel(); - task = null; + this.task.stop(); } /** diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java index 244d80f9..e16cd564 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java @@ -8,7 +8,7 @@ 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 net.onelitefeather.cygnus.utils.PlayerState; import org.jetbrains.annotations.Nullable; import java.util.EnumMap; diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/PlayerState.java b/game/src/main/java/net/onelitefeather/cygnus/utils/PlayerState.java similarity index 98% rename from common/src/main/java/net/onelitefeather/cygnus/common/util/PlayerState.java rename to game/src/main/java/net/onelitefeather/cygnus/utils/PlayerState.java index 33445809..91ec357b 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/util/PlayerState.java +++ b/game/src/main/java/net/onelitefeather/cygnus/utils/PlayerState.java @@ -1,4 +1,4 @@ -package net.onelitefeather.cygnus.common.util; +package net.onelitefeather.cygnus.utils; import net.minestom.server.entity.Player; import org.jetbrains.annotations.Nullable; diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/RepeatingTask.java b/game/src/main/java/net/onelitefeather/cygnus/utils/RepeatingTask.java similarity index 98% rename from common/src/main/java/net/onelitefeather/cygnus/common/util/RepeatingTask.java rename to game/src/main/java/net/onelitefeather/cygnus/utils/RepeatingTask.java index 3193a424..30ca3882 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/util/RepeatingTask.java +++ b/game/src/main/java/net/onelitefeather/cygnus/utils/RepeatingTask.java @@ -1,4 +1,4 @@ -package net.onelitefeather.cygnus.common.util; +package net.onelitefeather.cygnus.utils; import net.minestom.server.MinecraftServer; import net.minestom.server.timer.Task; diff --git a/game/src/test/java/net/onelitefeather/cygnus/overlay/RecordingScreenOverlay.java b/game/src/test/java/net/onelitefeather/cygnus/overlay/RecordingScreenOverlay.java new file mode 100644 index 00000000..f0628bdd --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/overlay/RecordingScreenOverlay.java @@ -0,0 +1,71 @@ +package net.onelitefeather.cygnus.overlay; + +import net.kyori.adventure.key.Key; +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.Nullable; + +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * A {@link ScreenOverlay} that records what was set instead of dressing a player. + *

+ * Every effect that draws onto the screen needs the same thing from a test: which texture ended up + * on which layer, without a real equipment slot in the way. Written per test class, that is the same + * {@code Map>} five times over - the very duplication the overlay + * foundation removes from the production code. It lives here rather than beside any one effect + * because no effect owns it. + *

+ * + *

Usage:

+ *
{@code
+ * RecordingScreenOverlay overlay = new RecordingScreenOverlay();
+ * service.show(player, 3);
+ * assertEquals(expected, overlay.of(player, OverlayLayer.TUNNEL_VISION));
+ * }
+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class RecordingScreenOverlay 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()); + } + + /** + * Reads back what a layer currently holds for a player. + * + * @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 + */ + public @Nullable Key of(Player player, OverlayLayer layer) { + return this.layers.getOrDefault(player.getUuid(), Map.of()).get(layer); + } + + /** + * @param player the player to look up + * @return {@code true} if no layer is currently set for that player + */ + public boolean isEmpty(Player player) { + return this.layers.getOrDefault(player.getUuid(), Map.of()).isEmpty(); + } +} diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/util/PlayerStateTest.java b/game/src/test/java/net/onelitefeather/cygnus/utils/PlayerStateTest.java similarity index 98% rename from common/src/test/java/net/onelitefeather/cygnus/common/util/PlayerStateTest.java rename to game/src/test/java/net/onelitefeather/cygnus/utils/PlayerStateTest.java index cb4fc585..a1a695aa 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/util/PlayerStateTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/utils/PlayerStateTest.java @@ -1,4 +1,4 @@ -package net.onelitefeather.cygnus.common.util; +package net.onelitefeather.cygnus.utils; import net.minestom.server.entity.Player; import net.minestom.server.instance.Instance; diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/util/RepeatingTaskIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/utils/RepeatingTaskIntegrationTest.java similarity index 98% rename from common/src/test/java/net/onelitefeather/cygnus/common/util/RepeatingTaskIntegrationTest.java rename to game/src/test/java/net/onelitefeather/cygnus/utils/RepeatingTaskIntegrationTest.java index 429d5368..c38a5312 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/util/RepeatingTaskIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/utils/RepeatingTaskIntegrationTest.java @@ -1,4 +1,4 @@ -package net.onelitefeather.cygnus.common.util; +package net.onelitefeather.cygnus.utils; import net.minestom.testing.Env; import net.minestom.testing.extension.MicrotusExtension; From ead898322c13051537578a9e47897b295bbcfd46 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 23 Aug 2026 10:41:12 +0200 Subject: [PATCH 08/14] refactor(command): let CommandSenders take the message instead of building it asPlayer assembled "Only players " + reason from a sentence fragment each caller passed in. That put message assembly in a command helper while every other player-facing text in the project is a Component in Messages, and it left each caller holding half a sentence that only made sense once concatenated here. It now takes the finished Component and only sends it. The callers land in the three follow-up PRs and each bring their own Messages entry. --- .../cygnus/command/CommandSenders.java | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java b/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java index 8ef1d271..b394ec95 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java +++ b/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java @@ -1,17 +1,21 @@ package net.onelitefeather.cygnus.command; +import net.kyori.adventure.text.Component; 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. + * 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 @@ -36,13 +40,15 @@ 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."} + * @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, String reason) { + public static @Nullable Player asPlayer(CommandSender sender, Component message) { if (sender instanceof Player player) return player; - sender.sendMessage(Messages.withMiniPrefix("Only players " + reason)); + sender.sendMessage(message); return null; } } From 887695927e1280c573f543d0ac51fd1c041938d0 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 23 Aug 2026 10:48:48 +0200 Subject: [PATCH 09/14] refactor(overlay): own the shared overlay and the roster lookups once --- .../net/onelitefeather/cygnus/Cygnus.java | 30 +---- .../cygnus/team/TeamHelper.java | 37 +++++- .../cygnus/team/TeamRosterTest.java | 122 ++++++++++++++++++ 3 files changed, 163 insertions(+), 26 deletions(-) create mode 100644 game/src/test/java/net/onelitefeather/cygnus/team/TeamRosterTest.java diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index c3c5b07e..8d66c15e 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -14,6 +14,8 @@ import net.onelitefeather.cygnus.map.event.GameMapLoadEvent; import net.onelitefeather.cygnus.map.event.GameMapLoadedEvent; import net.onelitefeather.cygnus.map.event.GamePrepareEvent; +import net.onelitefeather.cygnus.overlay.EquipmentScreenOverlay; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; import net.onelitefeather.cygnus.spectator.SpectatorService; import net.onelitefeather.cygnus.team.TeamCreator; import net.onelitefeather.cygnus.team.TeamHelper; @@ -88,7 +90,6 @@ import java.nio.file.Path; import java.util.Optional; -import java.util.Set; import java.util.function.Supplier; /** @@ -136,7 +137,8 @@ public Cygnus() { this.spectatorService = new SpectatorService(spectatorTeam, survivorTeam); this.resourcePackService = ResourcePackService.create(); this.screenOverlay = new EquipmentScreenOverlay(); - this.slenderGazeService = new SlenderGazeService(this.screenOverlay, this::currentSlender); + this.slenderGazeService = new SlenderGazeService( + this.screenOverlay, () -> TeamHelper.slenderOf(this.teamService)); this.initPhases(); this.initCommands(); this.initListener(); @@ -150,28 +152,6 @@ private void initCommands() { 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); - } - private void initListener() { Supplier phaseSupplier = this.linearPhaseSeries::getCurrentPhase; var manager = MinecraftServer.getGlobalEventHandler(); @@ -226,7 +206,7 @@ private void registerGameListener() { // 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); + this.slenderGazeService.registerListener(handler, () -> TeamHelper.survivorsOf(this.teamService)); } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java b/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java index 4b7f24ba..ade5f9fe 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java +++ b/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java @@ -13,6 +13,7 @@ import net.minestom.server.entity.Player; import net.minestom.server.instance.Instance; import net.minestom.server.utils.validate.Check; +import org.jetbrains.annotations.Nullable; import net.onelitefeather.cygnus.common.Tags; import net.onelitefeather.cygnus.common.config.GameConfig; import net.onelitefeather.cygnus.common.map.GameMap; @@ -24,7 +25,7 @@ * This class provides utility methods for the team handling in the game. * * @author theEvilReaper - * @version 1.0.0 + * @version 1.1.0 * @since 1.0.0 */ public final class TeamHelper { @@ -172,6 +173,40 @@ public static void updateTabList(TeamService teamService) { }); } + /** + * Reads the players currently on the survivor team. + *

+ * Every full-screen effect draws for the survivors and needs them once per tick, so each one + * would otherwise carry its own copy of this lookup. Returns an empty set rather than throwing + * while the teams are not set up yet: the effects are ticked from a scheduler, which runs + * before a round starts and after it ends. + *

+ * + * @param teamService the service to read the team from + * @return the survivors, or an empty set while there is no survivor team + */ + public static Set survivorsOf(TeamService teamService) { + return teamService.getTeam(GameConfig.SURVIVOR_KEY) + .map(team -> Set.copyOf(team.getPlayers())) + .orElseGet(Set::of); + } + + /** + * Reads the player currently playing the slender. + *

+ * The slender team has a capacity of one, which {@link #prepareTeamAllocation} enforces, so the + * first player on it is the only one. + *

+ * + * @param teamService the service to read the team from + * @return the slender, or {@code null} while the role is unassigned + */ + public static @Nullable Player slenderOf(TeamService teamService) { + return teamService.getTeam(GameConfig.SLENDER_KEY) + .flatMap(team -> team.getPlayers().stream().findFirst()) + .orElse(null); + } + /** * Check if the player is in the slender team * diff --git a/game/src/test/java/net/onelitefeather/cygnus/team/TeamRosterTest.java b/game/src/test/java/net/onelitefeather/cygnus/team/TeamRosterTest.java new file mode 100644 index 00000000..87121e9d --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/team/TeamRosterTest.java @@ -0,0 +1,122 @@ +package net.onelitefeather.cygnus.team; + +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.cygnus.common.config.GameConfig; +import net.onelitefeather.cygnus.common.config.GameConfigReader; +import net.theevilreaper.xerus.api.team.TeamService; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.nio.file.Paths; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies {@link TeamHelper#survivorsOf(TeamService)} and {@link TeamHelper#slenderOf(TeamService)}, + * the roster lookups the full-screen effects read once per tick. + *

+ * The empty cases carry the weight here: the effects are ticked from a scheduler that runs before a + * round has set up its teams and after it has torn them down, so both lookups have to answer for a + * game that is not running rather than throw into a scheduler task. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +@ExtendWith(MicrotusExtension.class) +class TeamRosterTest { + + private static GameConfig gameConfig; + + @BeforeAll + static void init() { + gameConfig = new GameConfigReader(Paths.get("")).getConfig(); + } + + /** + * @return a service holding the round's two teams, both still empty + */ + private static TeamService teamsWithoutPlayers() { + TeamService teamService = TeamService.of(); + TeamCreator teamCreator = new TeamCreator() { + }; + teamCreator.createTeams(gameConfig, teamService); + return teamService; + } + + @Test + @DisplayName("Before the teams exist the survivors are an empty set") + void survivorsWithoutTeams() { + assertTrue(TeamHelper.survivorsOf(TeamService.of()).isEmpty()); + } + + @Test + @DisplayName("Before the teams exist there is no slender") + void slenderWithoutTeams() { + assertNull(TeamHelper.slenderOf(TeamService.of())); + } + + @Test + @DisplayName("An empty survivor team yields an empty set rather than throwing") + void emptySurvivorTeam() { + assertTrue(TeamHelper.survivorsOf(teamsWithoutPlayers()).isEmpty()); + } + + @Test + @DisplayName("An unassigned slender role reads as null") + void emptySlenderTeam() { + assertNull(TeamHelper.slenderOf(teamsWithoutPlayers())); + } + + @Test + @DisplayName("The players on the survivor team are the ones reported") + void survivorsAreReported(Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createPlayer(instance); + Player second = env.createPlayer(instance); + + TeamService teamService = teamsWithoutPlayers(); + teamService.getTeam(GameConfig.SURVIVOR_KEY).orElseThrow().addPlayer(first); + teamService.getTeam(GameConfig.SURVIVOR_KEY).orElseThrow().addPlayer(second); + + assertEquals(Set.of(first, second), TeamHelper.survivorsOf(teamService)); + } + + @Test + @DisplayName("The player on the slender team is the slender") + void slenderIsReported(Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance); + + TeamService teamService = teamsWithoutPlayers(); + teamService.getTeam(GameConfig.SLENDER_KEY).orElseThrow().addPlayer(slender); + + assertSame(slender, TeamHelper.slenderOf(teamService)); + } + + @Test + @DisplayName("The reported survivors are a snapshot, not the team's own collection") + void survivorsAreASnapshot(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createPlayer(instance); + + TeamService teamService = teamsWithoutPlayers(); + teamService.getTeam(GameConfig.SURVIVOR_KEY).orElseThrow().addPlayer(survivor); + + Set survivors = TeamHelper.survivorsOf(teamService); + + assertThrows(UnsupportedOperationException.class, () -> survivors.add(survivor), + "an effect iterating the roster mid-tick must not be able to change the team through it"); + } +} From e732026729f3b5ffe2ff3630db5969adf0d9b1bf Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 23 Aug 2026 10:54:27 +0200 Subject: [PATCH 10/14] refactor(overlay): put the overlay gate in one place Each effect branch wrapped its own registration in its own `if (OverlayProperties.enabled())`. Those blocks land on the same lines, and the closing brace sits behind the conflict marker, so resolving one by keeping both sides yields two opened ifs and one brace - it does not compile, which is at least loud, but it is a conflict nobody should have to think about three times. registerOverlayListeners holds the gate once. Each effect adds its own line to it and nothing else, and the property named cygnus.overlays now actually governs all of them rather than however many blocks happened to be written. --- .../net/onelitefeather/cygnus/Cygnus.java | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index 8d66c15e..39f52085 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -15,6 +15,7 @@ import net.onelitefeather.cygnus.map.event.GameMapLoadedEvent; import net.onelitefeather.cygnus.map.event.GamePrepareEvent; import net.onelitefeather.cygnus.overlay.EquipmentScreenOverlay; +import net.onelitefeather.cygnus.overlay.OverlayProperties; import net.onelitefeather.cygnus.overlay.ScreenOverlay; import net.onelitefeather.cygnus.spectator.SpectatorService; import net.onelitefeather.cygnus.team.TeamCreator; @@ -202,12 +203,23 @@ private void registerGameListener() { MinecraftServer.getPacketListenerManager().setPlayListener(ClientSettingsPacket.class, CygnusSettingsListener::listener); spectatorService.registerListener(handler); + this.registerOverlayListeners(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, () -> TeamHelper.survivorsOf(this.teamService)); - } + /** + * Registers the full-screen effects, unless the overlays are switched off. + *

+ * The effects are drawn as {@code camera_overlay} textures and are gated by + * {@link OverlayProperties} alone - deliberately not by whether this server hands out a resource + * pack. One gate for all of them, so that {@code cygnus.overlays} means what its name says and + * an effect cannot end up outside it by being wired in somewhere else. + *

+ * + * @param handler the node the effects register their listeners on + */ + private void registerOverlayListeners(GlobalEventHandler handler) { + if (!OverlayProperties.enabled()) return; + this.slenderGazeService.registerListener(handler, () -> TeamHelper.survivorsOf(this.teamService)); } private void initPhases() { From 202acf2d37e853b6676d2efbff7bd5d83567248e Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 23 Aug 2026 10:42:25 +0200 Subject: [PATCH 11/14] refactor(gaze): follow the conventions the rest of the game already uses PlayerState and RepeatingTask moved to game/utils in the foundation, and Helper.clamp is gone in favour of Math.clamp; both follow here. clearAll() becomes cleanUp(), which is what the round teardown is called on main (PageProvider, StaminaService, JumpScareManager). The two player-facing texts move into Messages: the usage line as a builder, next to the other builders that interpolate a value, and the players-only message as a constant now that CommandSenders takes a finished Component. show(Player, int) and hide(Player) collapse into preview(Player, int), where SlenderGaze.NONE clears. That is not an invented sentinel - tick() already branches on NONE coming out of SlenderGaze.levelOf, so hide() was a second spelling of a level the domain type already had. The recording ScreenOverlay both tests stood up is the foundation's shared one. The comment gating the effect in Cygnus described a vignette font that no longer exists, and OverlayProperties only named two of its three layers. --- .../cygnus/common/Messages.java | 15 +++- .../cygnus/command/GlitchCommand.java | 17 ++--- .../cygnus/gaze/SlenderGaze.java | 5 +- .../cygnus/gaze/SlenderGazeService.java | 40 +++++------ .../cygnus/overlay/OverlayProperties.java | 5 +- .../cygnus/command/GlitchCommandTest.java | 61 +++-------------- .../cygnus/gaze/SlenderGazeServiceTest.java | 68 ++++--------------- 7 files changed, 74 insertions(+), 137 deletions(-) diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java b/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java index 50721410..cffeb1a2 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java @@ -14,7 +14,7 @@ /** * @author theEvilReaper - * @version 1.2.0 + * @version 1.3.0 * @since 1.0.0 **/ public final class Messages { @@ -30,6 +30,7 @@ public final class Messages { public static final Component SLENDER_WIN_MESSAGE; public static final Component SURVIVOR_WIN_MESSAGE; public static final Component LIGHT_WENT_OUT; + public static final Component ONLY_PLAYERS_HAVE_A_VIEW; private static final Component PAGE_FOUND_PART; private static final Component LEAVE_PART; private static final Component JOIN_PART; @@ -62,6 +63,7 @@ public final class Messages { LEAVE_PART = Component.text("left the game!", NamedTextColor.GRAY); 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."); SURVIVOR_JOIN_PART_UPPER = withMiniPrefix("You are a Survivor! Find various Pages").append(Component.space()); @@ -216,6 +218,17 @@ public static Component getLeaveMessage(Player player) { .append(Component.space()).append(LEAVE_PART); } + /** + * Returns the usage line of the glitch preview command. + * + * @param levels how many degrees of tearing there are to pick from + * @return the created {@link Component} reference + */ + @Contract(value = "_ -> new", pure = true) + public static Component getGlitchUsageMessage(int levels) { + return withMiniPrefix("Usage: /glitch <1-" + levels + "> | off"); + } + @Contract(value = "_ -> new", pure = true) public static Component getSurvivorJoinMessage(String pageCount) { return SURVIVOR_JOIN_PART_UPPER.append(withMini("(" + pageCount + " TO WIN)")) diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java index 199d25b1..3ead6384 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java +++ b/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java @@ -15,11 +15,13 @@ *

* * @author TheMeinerLP - * @version 1.0.0 + * @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. * @@ -30,20 +32,19 @@ public GlitchCommand(SlenderGazeService service) { var level = ArgumentType.Integer("level").between(1, SlenderGaze.LEVELS); - this.setDefaultExecutor((sender, context) -> sender.sendMessage( - Messages.withMiniPrefix("Usage: /glitch <1-" + SlenderGaze.LEVELS + "> | off") - )); + this.setDefaultExecutor((sender, context) -> + sender.sendMessage(Messages.getGlitchUsageMessage(SlenderGaze.LEVELS))); this.addSyntax((sender, context) -> { - Player player = CommandSenders.asPlayer(sender, "have a view to lose."); + Player player = CommandSenders.asPlayer(sender, Messages.ONLY_PLAYERS_HAVE_A_VIEW); if (player == null) return; - service.show(player, context.get(level) - 1); + service.preview(player, context.get(level) - 1); }, level); this.addSyntax((sender, context) -> { - Player player = CommandSenders.asPlayer(sender, "have a view to lose."); + Player player = CommandSenders.asPlayer(sender, Messages.ONLY_PLAYERS_HAVE_A_VIEW); if (player == null) return; - service.hide(player); + service.preview(player, SlenderGaze.NONE); }, 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 index 01fbb31a..381544e3 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java +++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java @@ -2,7 +2,6 @@ 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. @@ -13,7 +12,7 @@ *

* * @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); + } +}