From fd072dcf02422a63966a0acd73491d4345693b9d Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 12 Aug 2026 09:17:04 +0200 Subject: [PATCH 01/12] feat(common): add RepeatingTask to unify scheduler start/stop guards Four different services (tunnel vision, blood splatter, slender gaze, and the stamina bar) each hand-rolled the same start/stop guard around a Minestom Task: a nullable field, a null-check before scheduling, and a null-check before cancelling, repeated with small variations at every call site. Extract that guard once as RepeatingTask so the follow-up feature branches can adopt a single, tested implementation instead of copying the pattern a fifth time. --- .../cygnus/common/util/RepeatingTask.java | 79 +++++++++++++++ .../util/RepeatingTaskIntegrationTest.java | 99 +++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 common/src/main/java/net/onelitefeather/cygnus/common/util/RepeatingTask.java create mode 100644 common/src/test/java/net/onelitefeather/cygnus/common/util/RepeatingTaskIntegrationTest.java diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/RepeatingTask.java b/common/src/main/java/net/onelitefeather/cygnus/common/util/RepeatingTask.java new file mode 100644 index 00000000..3193a424 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/util/RepeatingTask.java @@ -0,0 +1,79 @@ +package net.onelitefeather.cygnus.common.util; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.timer.Task; +import org.jetbrains.annotations.Nullable; + +import java.time.temporal.TemporalUnit; + +/** + * Owns a single Minestom repeating scheduler {@link Task}. + *

+ * {@code AmbientProvider}, {@code TunnelVisionService}, {@code SlenderGazeService} and + * {@code BloodSplatterService} each hand-rolled the same {@code @Nullable Task} field with a + * guard-and-return {@code startTask()}/{@code stopTask()} pair. This type is that field, extracted + * once: start and stop are both idempotent, so a caller never has to remember whether it already + * called either of them. + *

+ *

+ * The action to run is constructor-injected rather than passed to {@link #start(long, TemporalUnit)}, + * because every one of the four services above ran exactly one action for the lifetime of the task + * and never swapped it out. + *

+ * + *

Usage:

+ *
{@code
+ * RepeatingTask task = new RepeatingTask(this::tick);
+ * task.start(1, ChronoUnit.SECONDS);
+ * // ...
+ * task.stop();
+ * }
+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class RepeatingTask { + + private final Runnable action; + private @Nullable Task task; + + /** + * Creates a task that, once started, runs the given action on every repetition. + * + * @param action the action to run + */ + public RepeatingTask(Runnable action) { + this.action = action; + } + + /** + * Starts the task with the given period. Does nothing if the task is already running. + * + * @param period the amount of {@code unit}s between two runs + * @param unit the unit {@code period} is measured in + */ + public void start(long period, TemporalUnit unit) { + if (this.task != null) return; + this.task = MinecraftServer.getSchedulerManager() + .buildTask(this.action) + .repeat(period, unit) + .schedule(); + } + + /** + * Stops the task. Does nothing if the task is not running. + */ + public void stop() { + if (this.task == null) return; + this.task.cancel(); + this.task = null; + } + + /** + * @return {@code true} if the task is currently running + */ + public boolean isRunning() { + return this.task != null; + } +} diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/util/RepeatingTaskIntegrationTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/util/RepeatingTaskIntegrationTest.java new file mode 100644 index 00000000..429d5368 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/util/RepeatingTaskIntegrationTest.java @@ -0,0 +1,99 @@ +package net.onelitefeather.cygnus.common.util; + +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.time.temporal.ChronoUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that {@link RepeatingTask} owns exactly one scheduler task no matter how many times + * start and stop are called. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +@ExtendWith(MicrotusExtension.class) +class RepeatingTaskIntegrationTest { + + @Test + void notRunningBeforeStart() { + RepeatingTask task = new RepeatingTask(() -> { + }); + + assertFalse(task.isRunning()); + } + + @Test + void runsOnceStarted(Env env) { + AtomicInteger ticks = new AtomicInteger(); + RepeatingTask task = new RepeatingTask(ticks::incrementAndGet); + + task.start(50, ChronoUnit.MILLIS); + assertTrue(task.isRunning()); + for (int i = 0; i < 10; i++) { + env.tick(); + } + + assertTrue(ticks.get() > 0, "the action should have run at least once by now"); + } + + @Test + void startIsIdempotent(Env env) { + AtomicInteger ticks = new AtomicInteger(); + RepeatingTask task = new RepeatingTask(ticks::incrementAndGet); + + task.start(50, ChronoUnit.MILLIS); + task.start(50, ChronoUnit.MILLIS); + for (int i = 0; i < 10; i++) { + env.tick(); + } + int afterFirstBatch = ticks.get(); + + // Stopping cancels the single task this class is meant to own. If start() had scheduled a + // second task on the repeated call, stop() would only ever reach one of them and the other + // would keep running forever, still incrementing the counter below. + task.stop(); + for (int i = 0; i < 10; i++) { + env.tick(); + } + + assertEquals(afterFirstBatch, ticks.get(), "a leaked second task would still be ticking"); + } + + @Test + void stopStopsTheTask(Env env) { + AtomicInteger ticks = new AtomicInteger(); + RepeatingTask task = new RepeatingTask(ticks::incrementAndGet); + task.start(50, ChronoUnit.MILLIS); + for (int i = 0; i < 10; i++) { + env.tick(); + } + + task.stop(); + assertFalse(task.isRunning()); + int afterStop = ticks.get(); + for (int i = 0; i < 10; i++) { + env.tick(); + } + + assertEquals(afterStop, ticks.get(), "no more runs should happen after stop()"); + } + + @Test + void stopIsIdempotent() { + RepeatingTask task = new RepeatingTask(() -> { + }); + + task.stop(); + + assertFalse(task.isRunning(), "stopping a task that never ran must not throw"); + } +} From 6d0ce2f04042b3b373c4aca57dc08b79787f484b Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 12 Aug 2026 09:17:12 +0200 Subject: [PATCH 02/12] feat(common): add PlayerState to replace hand-rolled per-player maps Five separate hand-rolled per-player maps existed across the tunnel vision, blood splatter, and slender gaze code, each keyed by UUID and each managing its own put-on-join/remove-on-leave lifecycle by hand. The duplication made every one of those call sites a place a leak or a stale-entry bug could hide. PlayerState wraps that lifecycle once behind a small, tested type, so the three follow-up feature branches can store their per-player values without re-deriving the same map bookkeeping. --- .../cygnus/common/util/PlayerState.java | 105 ++++++++++++++++ .../cygnus/common/util/PlayerStateTest.java | 116 ++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 common/src/main/java/net/onelitefeather/cygnus/common/util/PlayerState.java create mode 100644 common/src/test/java/net/onelitefeather/cygnus/common/util/PlayerStateTest.java diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/PlayerState.java b/common/src/main/java/net/onelitefeather/cygnus/common/util/PlayerState.java new file mode 100644 index 00000000..33445809 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/util/PlayerState.java @@ -0,0 +1,105 @@ +package net.onelitefeather.cygnus.common.util; + +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.Nullable; + +import java.util.Collection; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * Keeps one value per player, keyed by {@link Player#getUuid()}. + *

+ * {@code EquipmentScreenOverlay}, {@code TunnelVisionService}, {@code BloodSplatterService}, + * {@code SlenderGazeService} and {@code TunnelVisionCommand} each hand-rolled their own + * {@code Map} field for this, disagreeing along the way on {@link ConcurrentHashMap} versus + * {@link java.util.LinkedHashMap}. This type settles that: it is backed by a + * {@code ConcurrentHashMap}, because state that outlives a single tick has to survive being written + * from a scheduler task and cleared from a disconnect or death listener in the same round, and + * nothing in this project pins both of those to the same thread. Three of the five call sites this + * type replaces already reached for {@code ConcurrentHashMap} for exactly that reason; the other two + * used a {@code LinkedHashMap} only for its insertion order, which none of the five ever relied on. + * Correctness under a race a caller does not control beats an ordering guarantee nobody asked for. + *

+ * + * @param the kind of value tracked per player + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class PlayerState { + + private final Map values = new ConcurrentHashMap<>(); + + /** + * Stores a value for the given player, replacing whatever was tracked before. + * + * @param player the player to store a value for + * @param value the value to store + */ + public void put(Player player, V value) { + this.values.put(player.getUuid(), value); + } + + /** + * Reads the value tracked for the given player. + * + * @param player the player to read + * @return the tracked value, or {@code null} if none is tracked + */ + public @Nullable V get(Player player) { + return this.values.get(player.getUuid()); + } + + /** + * Reads the value tracked for the given player, computing and storing one first if none is + * tracked yet. + * + * @param player the player to read + * @param supplier supplies the value to store when none is tracked yet + * @return the tracked value, existing or freshly computed + */ + public V computeIfAbsent(Player player, Supplier supplier) { + return this.values.computeIfAbsent(player.getUuid(), _ -> supplier.get()); + } + + /** + * Stops tracking the given player. + * + * @param player the player to forget + * @return the value that was tracked for them, or {@code null} if none was + */ + public @Nullable V remove(Player player) { + return this.values.remove(player.getUuid()); + } + + /** + * The tracked values, without the players they belong to. + *

+ * The returned collection is a live view over the backing map: removing through its iterator + * also stops tracking that player, which is what lets a caller fade values out one by one while + * walking them, the way {@code BloodSplatterService} does. + *

+ * + * @return a live view over the tracked values + */ + public Collection values() { + return this.values.values(); + } + + /** + * @return {@code true} if no player is currently tracked + */ + public boolean isEmpty() { + return this.values.isEmpty(); + } + + /** + * Stops tracking every player. + */ + public void clear() { + this.values.clear(); + } +} diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/util/PlayerStateTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/util/PlayerStateTest.java new file mode 100644 index 00000000..cb4fc585 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/util/PlayerStateTest.java @@ -0,0 +1,116 @@ +package net.onelitefeather.cygnus.common.util; + +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.Iterator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that {@link PlayerState} tracks one value per player, keeps players apart, and forgets + * them cleanly. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +@ExtendWith(MicrotusExtension.class) +class PlayerStateTest { + + @Test + void nothingIsTrackedForAFreshPlayer(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + + assertNull(state.get(player)); + assertTrue(state.isEmpty()); + } + + @Test + void putThenGetReturnsTheStoredValue(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + + state.put(player, "value"); + + assertEquals("value", state.get(player)); + assertFalse(state.isEmpty()); + } + + @Test + void playersAreKeptApart(Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createPlayer(instance); + Player second = env.createPlayer(instance); + PlayerState state = new PlayerState<>(); + + state.put(first, "first"); + state.put(second, "second"); + + assertEquals("first", state.get(first)); + assertEquals("second", state.get(second)); + } + + @Test + void removeForgetsThePlayerAndReturnsTheOldValue(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + state.put(player, "value"); + + assertEquals("value", state.remove(player)); + assertNull(state.get(player)); + assertNull(state.remove(player), "removing an untracked player must not throw"); + } + + @Test + void computeIfAbsentStoresAndReusesTheComputedValue(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + + StringBuilder first = state.computeIfAbsent(player, StringBuilder::new); + StringBuilder second = state.computeIfAbsent(player, StringBuilder::new); + + assertEquals(first, second, "a second call must not overwrite the already-tracked value"); + } + + @Test + void clearForgetsEveryPlayer(Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createPlayer(instance); + Player second = env.createPlayer(instance); + PlayerState state = new PlayerState<>(); + state.put(first, "first"); + state.put(second, "second"); + + state.clear(); + + assertTrue(state.isEmpty()); + } + + @Test + void removingThroughValuesForgetsThePlayerToo(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + state.put(player, "value"); + + Iterator values = state.values().iterator(); + values.next(); + values.remove(); + + assertTrue(state.isEmpty(), "the map backing values() must be live, the way BloodSplatterService needs it"); + assertNull(state.get(player)); + } + + private Player spawn(Env env) { + Instance instance = env.createFlatInstance(); + return env.createPlayer(instance); + } +} From 7bd5e90c28f34ed451f3e10031504b499a05805c Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 12 Aug 2026 09:17:19 +0200 Subject: [PATCH 03/12] feat(common): add Helper.clamp to replace hand-rolled min/max clamping The tunnel vision and slender gaze code each clamped values into range with their own Math.min(max, Math.max(min, value)) expression, which is easy to get backwards (min/max swapped) and gives no shared place to fix it once. Add int and double overloads of Helper.clamp so the follow-up feature branches can call one well-tested method instead of repeating the expression. --- .../cygnus/common/util/Helper.java | 30 ++++++++++- .../cygnus/common/util/HelperTest.java | 54 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 common/src/test/java/net/onelitefeather/cygnus/common/util/HelperTest.java diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/Helper.java b/common/src/main/java/net/onelitefeather/cygnus/common/util/Helper.java index d45d0eee..1764491c 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/util/Helper.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/util/Helper.java @@ -11,7 +11,7 @@ * and game-specific identifiers or timings. * * @author theEvilReaper - * @version 1.0.2 + * @version 1.1.0 * @since 1.0.0 **/ public final class Helper { @@ -69,6 +69,34 @@ public static int getRandomInt(int maximumValue) { return ThreadLocalRandom.current().nextInt(0, maximumValue); } + /** + * Clamps a value to lie within the given bounds, in place of hand-rolling + * {@code Math.min(max, Math.max(min, value))} at every call site. + * + * @param value the value to clamp + * @param min the inclusive lower bound + * @param max the inclusive upper bound + * @return {@code min} if {@code value} is lower, {@code max} if it is higher, {@code value} otherwise + */ + @Contract(pure = true) + public static int clamp(int value, int min, int max) { + return Math.clamp(value, min, max); + } + + /** + * Clamps a value to lie within the given bounds, in place of hand-rolling + * {@code Math.min(max, Math.max(min, value))} at every call site. + * + * @param value the value to clamp + * @param min the inclusive lower bound + * @param max the inclusive upper bound + * @return {@code min} if {@code value} is lower, {@code max} if it is higher, {@code value} otherwise + */ + @Contract(pure = true) + public static double clamp(double value, double min, double max) { + return Math.clamp(value, min, max); + } + /** * Adjusts the placement coordinates of a collectible page entity based on the * block face/direction it is attached to, ensuring it aligns correctly and remains visible. diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/util/HelperTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/util/HelperTest.java new file mode 100644 index 00000000..1bb53b16 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/util/HelperTest.java @@ -0,0 +1,54 @@ +package net.onelitefeather.cygnus.common.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies {@link Helper#clamp(int, int, int)} and {@link Helper#clamp(double, double, double)}, + * which replace the hand-rolled {@code Math.min(hi, Math.max(lo, x))} scattered across the tunnel + * vision and slender gaze code. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class HelperTest { + + @Test + @DisplayName("An int within bounds is returned unchanged") + void intWithinBoundsIsUnchanged() { + assertEquals(5, Helper.clamp(5, 0, 10)); + } + + @Test + @DisplayName("An int below the lower bound is raised to it") + void intBelowLowerBoundIsRaised() { + assertEquals(0, Helper.clamp(-5, 0, 10)); + } + + @Test + @DisplayName("An int above the upper bound is lowered to it") + void intAboveUpperBoundIsLowered() { + assertEquals(10, Helper.clamp(15, 0, 10)); + } + + @Test + @DisplayName("A double within bounds is returned unchanged") + void doubleWithinBoundsIsUnchanged() { + assertEquals(0.5D, Helper.clamp(0.5D, 0.0D, 1.0D)); + } + + @Test + @DisplayName("A double below the lower bound is raised to it") + void doubleBelowLowerBoundIsRaised() { + assertEquals(0.0D, Helper.clamp(-0.5D, 0.0D, 1.0D)); + } + + @Test + @DisplayName("A double above the upper bound is lowered to it") + void doubleAboveUpperBoundIsLowered() { + assertEquals(1.0D, Helper.clamp(1.5D, 0.0D, 1.0D)); + } +} From c3078f69e8634717d24ef5b011867a97b15a45e8 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 12 Aug 2026 09:17:31 +0200 Subject: [PATCH 04/12] feat(overlay): add shared screen overlay foundation Three texture-key builders existed across the tunnel vision, blood splatter, and slender gaze designs, each assembling equipment-slot texture paths with its own naming convention (stage_, __, level__), so nothing about them could be reused or tested together. Add the shared overlay package: ScreenOverlay and OverlayLayer describe where an overlay sits and how it renders, OverlayProperties and EquipmentScreenOverlay handle applying it to a player's equipment slot, and OverlayTextureKeys unifies the three texture-key conventions behind one type. This is the rendering base the three follow-up feature branches (tunnel vision, blood splatter, slender gaze) build their per-effect logic on top of. --- .../overlay/EquipmentScreenOverlay.java | 142 ++++++++++++++++++ .../cygnus/overlay/OverlayLayer.java | 21 +++ .../cygnus/overlay/OverlayProperties.java | 35 +++++ .../cygnus/overlay/OverlayTextureKeys.java | 112 ++++++++++++++ .../cygnus/overlay/ScreenOverlay.java | 35 +++++ .../cygnus/overlay/package-info.java | 4 + .../overlay/EquipmentScreenOverlayTest.java | 140 +++++++++++++++++ .../cygnus/overlay/OverlayPropertiesTest.java | 50 ++++++ .../overlay/OverlayTextureKeysTest.java | 54 +++++++ 9 files changed, 593 insertions(+) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayLayer.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayProperties.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeys.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/overlay/ScreenOverlay.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/overlay/package-info.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlayTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayPropertiesTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeysTest.java diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java new file mode 100644 index 00000000..244d80f9 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java @@ -0,0 +1,142 @@ +package net.onelitefeather.cygnus.overlay; + +import net.kyori.adventure.key.Key; +import net.minestom.server.component.DataComponents; +import net.minestom.server.entity.EquipmentSlot; +import net.minestom.server.entity.Player; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.item.component.Equippable; +import net.minestom.server.sound.SoundEvent; +import net.onelitefeather.cygnus.common.util.PlayerState; +import org.jetbrains.annotations.Nullable; + +import java.util.EnumMap; +import java.util.Map; + +/** + * Puts the overlay on screen as the {@code camera_overlay} of an item worn on the head. + *

+ * This is the one mechanism in vanilla that draws a texture across the whole screen and scales it + * with the viewport — the same one the carved pumpkin uses. A font glyph cannot do that: its size + * is fixed in the pack, so it has to be calibrated against a resolution and drifts on every other. + *

+ *

+ * A player has one head, so only one layer can be shown at a time. The topmost one wins, which + * means a splatter of blood takes the screen for as long as it lasts and the tunnel vision comes + * back underneath it afterwards. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class EquipmentScreenOverlay implements ScreenOverlay { + + /** + * What the overlay rides on. The item itself is never seen — {@link #EMPTY_ASSET} makes sure of + * that — so the material only has to exist. + */ + private static final Material CARRIER = Material.PAPER; + + /** + * An equipment model with no layers, from the resource pack. Without an asset id Minecraft + * falls back to drawing the item itself on the player's head. + */ + private static final String EMPTY_ASSET = "cygnus:empty"; + + /** Vanilla's silent sound; the default equip sound would click on every stage change. */ + private static final SoundEvent SILENT = SoundEvent.of(Key.key("minecraft:intentionally_empty"), null); + + private final PlayerState> 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] feat(blood): add blood splatter overlay on damage (#180) --- .../cygnus/common/Messages.java | 2 + .../specs/2026-08-11-blood-splatter-design.md | 104 ++++++++++ .../net/onelitefeather/cygnus/Cygnus.java | 13 +- .../cygnus/blood/BloodDirection.java | 69 +++++++ .../cygnus/blood/BloodSplatterService.java | 169 ++++++++++++++++ .../cygnus/blood/package-info.java | 4 + .../cygnus/event/PlayerDamagedEvent.java | 65 +++++++ .../cygnus/stamina/SlenderBarHelper.java | 32 +--- .../cygnus/blood/BloodDirectionTest.java | 57 ++++++ .../blood/BloodSplatterServiceTest.java | 181 ++++++++++++++++++ .../stamina/SlenderBarHelperDamageTest.java | 63 ++++++ 11 files changed, 732 insertions(+), 27 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-11-blood-splatter-design.md create mode 100644 game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/blood/package-info.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/event/PlayerDamagedEvent.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/blood/BloodDirectionTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/blood/BloodSplatterServiceTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperDamageTest.java diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java b/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java index cffeb1a2..960bebd6 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java @@ -31,6 +31,7 @@ public final class Messages { public static final Component SURVIVOR_WIN_MESSAGE; public static final Component LIGHT_WENT_OUT; public static final Component ONLY_PLAYERS_HAVE_A_VIEW; + public static final Component ONLY_PLAYERS_CAN_BLEED; private static final Component PAGE_FOUND_PART; private static final Component LEAVE_PART; private static final Component JOIN_PART; @@ -64,6 +65,7 @@ public final class Messages { JOIN_PART = Component.text("joined the game!", NamedTextColor.GRAY); LIGHT_WENT_OUT = withMiniPrefix("Your light went out!"); ONLY_PLAYERS_HAVE_A_VIEW = withMiniPrefix("Only players have a view to lose."); + ONLY_PLAYERS_CAN_BLEED = withMiniPrefix("Only players can bleed."); SURVIVOR_JOIN_PART_UPPER = withMiniPrefix("You are a Survivor! Find various Pages").append(Component.space()); diff --git a/docs/superpowers/specs/2026-08-11-blood-splatter-design.md b/docs/superpowers/specs/2026-08-11-blood-splatter-design.md new file mode 100644 index 00000000..f26bd1a5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-blood-splatter-design.md @@ -0,0 +1,104 @@ +# Blood splatter on damage + +## Goal + +Taking a hit throws blood across the screen: it appears at once, from the side the hit came from, +and fades away within about a second. It says nothing about how the player is doing — that is the +tunnel vision's job — it only says *you were just hit, from over there*. + +## Sharing the screen with the tunnel vision + +Both effects are full-screen overlays, and both are drawn as the `camera_overlay` of an item on the +player's head — the only mechanism in vanilla that scales a texture to the viewport instead of +being calibrated against one resolution. + +A player has one head, so **only one overlay can be shown at a time.** A `ScreenOverlay` owns the +head slot and decides: each effect hands it a texture for its layer (`OverlayLayer.TUNNEL_VISION`, +`OverlayLayer.BLOOD`, in drawing order) and the topmost one wins. A splatter therefore takes the +screen for the 1.2 seconds it lasts, and the tunnel vision comes back underneath it afterwards. + +The alternative was pre-rendering every combination of splatter frame and vignette stage, so both +stay visible at once. That is 48 × 4 extra images at the coarsest useful resolution, and every +change to either effect would force re-rendering all of them. + +Two smaller things follow from riding on an item: the carrier points its `asset_id` at an empty +equipment model so it is never drawn on the player's head, and the overlay is only re-sent when the +texture actually changes — an equipment update goes out to every viewer, not just the wearer. + +## Trigger + +Cygnus applies damage in `SlenderBarHelper.applyDamage` by setting health directly. That never +raises Minestom's `EntityDamageEvent`, so a listener on it would never fire. + +`applyDamage` therefore dispatches a `PlayerDamagedEvent` carrying the victim, the source position +and the amount — the same shape the project already uses for `StaminaStateChangeEvent` and +`SlenderReviveEvent`. The source position is what lets the splatter be aimed; the amount is not +used yet but is the natural handle for anything that should scale with how hard the hit was. + +## Direction + +`BloodDirection.between(victim, source)` reduces the hit to one of four sides, seen from the victim +rather than from the world: + +``` +alignment = dot(victimLookDirection, directionToSource) +alignment > 0.5 -> FRONT +alignment < -0.5 -> BACK +cross(facing, towardsSource).y > 0 -> LEFT, else RIGHT +``` + +A hit from the east lands on the left for a player looking south and on the right for one looking +north. From the exact same spot the direction is meaningless, so it falls back to FRONT. + +## Frames + +Textures are laid out as direction × variant × frame: 4 × 2 × 6 = 48. The variants keep repeated +hits from looking mechanical, and the frames are the fade — Minecraft cannot animate a camera +overlay, so the server steps through them, one every 200 ms, giving a splatter that lives 1.2 +seconds. A fresh hit restarts the sequence rather than queueing behind the old one. + +The task that drives the fade starts with the first splatter and stops once nothing is bleeding +any more, rather than spinning over an empty map between hits. + +Drawings are generated by `tools/generate_overlay.py` in `cygnus-pack`: drops are placed with a +power-law radius — many specks, few real blotches — weighted towards the side the hit came from, +then blurred and thresholded so they melt into shapes with ragged edges instead of reading as +confetti. Bigger blotches grow a run downwards that lengthens as the frame fades, and a band along +the edge the hit came from seals the gaps the drops leave — without it a side splatter looks like +it stops short of the border. Textures are 1024×576, matching the 16:9 they are stretched onto. + +## Wiring + +`Cygnus` creates the service and `/blood`, and the service listens for itself: + +| Event | What happens | +| --- | --- | +| `PlayerDamagedEvent` | throws a splatter from the direction of the source | +| `PlayerDisconnectEvent` | drops the player's splatter | + +Like the tunnel vision, it is only registered when a resource pack is configured — without the +pack the textures are missing and players would get a fullscreen missing-texture checkerboard. + +`/blood [front|right|back|left]` throws one on demand, with no side meaning a random one, so the +drawings can be judged without waiting to be hit. + +## Failure modes + +| Situation | Behaviour | +| --- | --- | +| Hit while a splatter is still fading | the old one is replaced, the sequence restarts | +| Hit from the victim's own position | falls back to `FRONT` | +| Player leaves mid-fade | the splatter is dropped with them | +| Tunnel vision changes during a splatter | the splatter keeps the screen; the new stage shows once it is over | + +## Tests + +- `BloodDirectionTest` — plain JUnit: each of the four sides, that the victim's facing decides + rather than the world, and the degenerate same-spot case. +- `BloodSplatterServiceTest` — the first frame appears immediately, the fade walks the frames and + cleans up, a second hit restarts, the damage event triggers it, players are independent. +- `SlenderBarHelperDamageTest` — damage announces the victim and the source, and leaves out the + player who dealt it. +- `BloodCommandTest` — every side can be requested, and the bare command picks one. +- `EquipmentScreenOverlayTest` — the blood wins over the tunnel vision, the tunnel vision returns + afterwards, the slot empties with the last layer, and an unchanged overlay is not re-sent. diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index 39f52085..9c2efd93 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -42,6 +42,7 @@ import net.minestom.server.network.packet.client.play.ClientEntityActionPacket; import net.onelitefeather.cygnus.ambient.AmbientProvider; import net.onelitefeather.cygnus.command.GlitchCommand; +import net.onelitefeather.cygnus.blood.BloodSplatterService; import net.onelitefeather.cygnus.command.StartCommand; import net.onelitefeather.cygnus.common.ListenerHandling; import net.onelitefeather.cygnus.common.bootstrap.ServiceBootstrap; @@ -81,9 +82,6 @@ import net.onelitefeather.cygnus.resourcepack.ResourcePackService; import net.onelitefeather.cygnus.stamina.SlenderBarTrigger; import net.onelitefeather.cygnus.stamina.StaminaService; -import net.onelitefeather.cygnus.overlay.ScreenOverlay; -import net.onelitefeather.cygnus.overlay.EquipmentScreenOverlay; -import net.onelitefeather.cygnus.overlay.OverlayProperties; import net.onelitefeather.cygnus.utils.StaminaHelper; import net.onelitefeather.cygnus.view.GameView; import net.onelitefeather.cygnus.view.GameViewImpl; @@ -91,11 +89,12 @@ import java.nio.file.Path; import java.util.Optional; +import java.util.concurrent.ThreadLocalRandom; import java.util.function.Supplier; /** * @author theEvilReaper - * @version 1.0.0 + * @version 1.1.0 * @since 1.0.0 **/ @SuppressWarnings("java:S3252") @@ -114,6 +113,7 @@ public final class Cygnus implements TeamCreator, ListenerHandling { private final Optional resourcePackService; private final ScreenOverlay screenOverlay; private final SlenderGazeService slenderGazeService; + private final BloodSplatterService bloodSplatterService; public Cygnus() { Path path = ServiceBootstrap.resolveWorkingDirectory(); @@ -140,6 +140,10 @@ public Cygnus() { this.screenOverlay = new EquipmentScreenOverlay(); this.slenderGazeService = new SlenderGazeService( this.screenOverlay, () -> TeamHelper.slenderOf(this.teamService)); + this.bloodSplatterService = new BloodSplatterService( + this.screenOverlay, + bound -> ThreadLocalRandom.current().nextInt(bound) + ); this.initPhases(); this.initCommands(); this.initListener(); @@ -220,6 +224,7 @@ private void registerGameListener() { private void registerOverlayListeners(GlobalEventHandler handler) { if (!OverlayProperties.enabled()) return; this.slenderGazeService.registerListener(handler, () -> TeamHelper.survivorsOf(this.teamService)); + this.bloodSplatterService.registerListener(handler); } private void initPhases() { diff --git a/game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java new file mode 100644 index 00000000..f67b0e62 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodDirection.java @@ -0,0 +1,69 @@ +package net.onelitefeather.cygnus.blood; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; + +/** + * The side of the screen a splatter is thrown from, seen from the victim rather than from the + * world — being hit from the east means something different depending on where you are looking. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public enum BloodDirection { + + FRONT, + RIGHT, + BACK, + LEFT; + + private static final BloodDirection[] VALUES = values(); + + /** + * Above this alignment with the view direction a hit counts as coming from straight ahead. + */ + private static final double FORWARD_THRESHOLD = 0.5D; + + /** + * Below this distance the direction to the source carries no meaning any more. + */ + private static final double DISTANCE_EPSILON = 1.0E-6D; + + /** + * Works out which side a hit came from. + * + * @param victim the victim's position, whose yaw and pitch supply the view direction + * @param source where the damage came from + * @return the side to throw the splatter from + */ + public static BloodDirection between(Pos victim, Point source) { + double distance = victim.distance(source); + if (distance < DISTANCE_EPSILON) return FRONT; + + Vec towardsSource = new Vec( + source.x() - victim.x(), + source.y() - victim.y(), + source.z() - victim.z() + ).div(distance); + Vec facing = victim.direction(); + + double alignment = facing.dot(towardsSource); + if (alignment > FORWARD_THRESHOLD) return FRONT; + if (alignment < -FORWARD_THRESHOLD) return BACK; + + // The cross product points up when the source sits on the side the victim's left hand is + // on, which for a player looking south is the east. + return facing.cross(towardsSource).y() > 0 ? LEFT : RIGHT; + } + + /** + * Returns all possible values of this enum. + * + * @return values of this enum + */ + public static BloodDirection[] getValues() { + return VALUES; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java new file mode 100644 index 00000000..394bad9c --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/blood/BloodSplatterService.java @@ -0,0 +1,169 @@ +package net.onelitefeather.cygnus.blood; + +import net.kyori.adventure.key.Key; +import net.minestom.server.entity.Player; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.player.PlayerDisconnectEvent; +import net.onelitefeather.cygnus.event.PlayerDamagedEvent; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.OverlayTextureKeys; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; +import net.onelitefeather.cygnus.utils.PlayerState; +import net.onelitefeather.cygnus.utils.RepeatingTask; + +import java.time.temporal.ChronoUnit; +import java.util.Iterator; +import java.util.Locale; +import java.util.function.IntUnaryOperator; + +/** + * Throws a splatter of blood across the screen when a player is hit and fades it out again. + *

+ * The textures are laid out as direction × variant × frame. The direction aims the splatter at the + * side the hit came from, the variant keeps repeated hits from looking mechanical, and the frames + * are the fade — Minecraft cannot animate a camera overlay, so the server steps through them. + *

+ * + * @author TheMeinerLP + * @version 2.1.1 + * @since 2.7.0 + */ +public final class BloodSplatterService { + + /** How many drawings exist per direction. */ + static final int VARIANTS = 2; + + /** How many frames a splatter fades over. */ + static final int FRAMES = 12; + + /** + * How long a single frame stays on screen. Twelve frames at this rate keep the splatter alive + * for the same 1.2 seconds as six did at twice the interval, but it runs down the screen + * smoothly rather than in visible steps. + */ + static final int FRAME_MILLIS = 100; + + /** Where the splatter textures live, as {@code camera_overlay} resolves them. */ + static final String TEXTURE_PATH = "gui/blood/"; + + /** The keys, indexed {@code [direction][variant][frame]}. */ + private static final Key[][][] TEXTURES = buildTextures(); + + private final ScreenOverlay overlay; + private final IntUnaryOperator variantPicker; + private final PlayerState active = new PlayerState<>(); + + /** Fades every active splatter forward by one frame. Runs only while someone is bleeding. */ + final RepeatingTask fadeTask = new RepeatingTask(this::tick); + + /** + * Creates a new service. + * + * @param overlay the overlay that owns the player's screen + * @param variantPicker picks a variant below the given bound + */ + public BloodSplatterService(ScreenOverlay overlay, IntUnaryOperator variantPicker) { + this.overlay = overlay; + this.variantPicker = variantPicker; + } + + /** + * Listens for hits and for players leaving. + * + * @param node the node to register on + */ + public void registerListener(EventNode node) { + node.addListener(PlayerDamagedEvent.class, event -> this.splatter( + event.getPlayer(), + BloodDirection.between(event.getPlayer().getPosition(), event.getSource()) + )); + node.addListener(PlayerDisconnectEvent.class, event -> this.clear(event.getPlayer())); + } + + /** + * Throws a fresh splatter, replacing whatever is still fading. + * + * @param player the player who was hit + * @param direction the side the hit came from + */ + public void splatter(Player player, BloodDirection direction) { + Splatter splatter = new Splatter(player, direction, this.variantPicker.applyAsInt(VARIANTS)); + this.active.put(player, splatter); + this.draw(splatter); + this.fadeTask.start(FRAME_MILLIS, ChronoUnit.MILLIS); + } + + /** + * Takes the splatter off a player's screen. + * + * @param player the player to clear + */ + public void clear(Player player) { + if (this.active.remove(player) == null) return; + this.overlay.set(player, OverlayLayer.BLOOD, null); + } + + /** + * Advances every splatter by one frame and drops the ones that have faded out. + */ + void tick() { + Iterator splatters = this.active.values().iterator(); + while (splatters.hasNext()) { + Splatter splatter = splatters.next(); + splatter.frame++; + + if (splatter.frame >= FRAMES) { + splatters.remove(); + this.overlay.set(splatter.player, OverlayLayer.BLOOD, null); + continue; + } + this.draw(splatter); + } + + // Nothing is bleeding; the task would only spin over an empty map until the next hit. + if (this.active.isEmpty()) this.fadeTask.stop(); + } + + /** + * Puts a splatter's current frame on its player's screen. + * + * @param splatter the splatter to draw + */ + private void draw(Splatter splatter) { + this.overlay.set(splatter.player, OverlayLayer.BLOOD, + TEXTURES[splatter.direction.ordinal()][splatter.variant][splatter.frame]); + } + + /** + * Builds the texture key for every cell of the direction × variant × frame grid. + * + * @return the keys, indexed {@code [direction][variant][frame]} + */ + private static Key[][][] buildTextures() { + return OverlayTextureKeys.cube( + TEXTURE_PATH, + BloodDirection.getValues().length, VARIANTS, FRAMES, + direction -> BloodDirection.getValues()[direction].name().toLowerCase(Locale.ROOT), + OverlayTextureKeys.ONE_BASED, + OverlayTextureKeys.ONE_BASED + ); + } + + /** + * One player's running splatter. + */ + private static final class Splatter { + + private final Player player; + private final BloodDirection direction; + private final int variant; + private int frame; + + private Splatter(Player player, BloodDirection direction, int variant) { + this.player = player; + this.direction = direction; + this.variant = variant; + } + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/blood/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/blood/package-info.java new file mode 100644 index 00000000..86addd53 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/blood/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.onelitefeather.cygnus.blood; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/main/java/net/onelitefeather/cygnus/event/PlayerDamagedEvent.java b/game/src/main/java/net/onelitefeather/cygnus/event/PlayerDamagedEvent.java new file mode 100644 index 00000000..15fab507 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/event/PlayerDamagedEvent.java @@ -0,0 +1,65 @@ +package net.onelitefeather.cygnus.event; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.entity.Player; +import net.minestom.server.event.trait.PlayerEvent; + +/** + * Called when a player takes damage from the game. + *

+ * Cygnus applies damage by setting health directly, which never raises Minestom's + * {@code EntityDamageEvent}. This event fills that gap for everything that needs to react to a + * hit — the blood splatter above all — and carries where the hit came from, so the reaction can + * be aimed. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +@SuppressWarnings("java:S6206") +public final class PlayerDamagedEvent implements PlayerEvent { + + private final Player player; + private final Point source; + private final float amount; + + /** + * Creates a new instance of the {@link PlayerDamagedEvent}. + * + * @param player the player who was hit + * @param source where the damage came from + * @param amount how much health was taken + */ + public PlayerDamagedEvent(Player player, Point source, float amount) { + this.player = player; + this.source = source; + this.amount = amount; + } + + /** + * {@inheritDoc} + */ + @Override + public Player getPlayer() { + return this.player; + } + + /** + * Returns where the damage came from. + * + * @return the position of the source + */ + public Point getSource() { + return this.source; + } + + /** + * Returns how much health the hit took. + * + * @return the damage amount + */ + public float getAmount() { + return this.amount; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarHelper.java b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarHelper.java index 79cf4f25..489c01d3 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarHelper.java +++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/SlenderBarHelper.java @@ -3,12 +3,14 @@ import net.kyori.adventure.sound.Sound; import net.minestom.server.coordinate.Pos; import net.minestom.server.entity.Entity; +import net.minestom.server.event.EventDispatcher; import net.minestom.server.entity.Player; import net.minestom.server.instance.Instance; import net.minestom.server.potion.Potion; import net.minestom.server.potion.PotionEffect; import net.minestom.server.potion.TimedPotion; import net.minestom.server.sound.SoundEvent; +import net.onelitefeather.cygnus.event.PlayerDamagedEvent; import net.onelitefeather.cygnus.team.TeamHelper; import java.util.Collection; @@ -84,32 +86,16 @@ default void applyDamage(Instance instance, UUID uuid, Pos center, int range, fl Collection nearbyEntities = instance.getNearbyEntities(center, range); if (nearbyEntities.isEmpty()) return; for (Entity nearbyEntity : nearbyEntities) { - if (!(nearbyEntity instanceof Player target)) continue; - if (UUID_COMPARATOR.test(uuid, target.getUuid())) continue; - if (!isDamageableSurvivor(target)) continue; - target.setHealth(target.getHealth() - damage); + boolean hasSameUUID = UUID_COMPARATOR.test(uuid, nearbyEntity.getUuid()); + if (nearbyEntity instanceof Player target && !hasSameUUID && (target.getHealth() > 0)) { + target.setHealth(target.getHealth() - damage); + // Setting health never raises Minestom's own damage event, so anything reacting to + // a hit — the blood splatter above all — would otherwise never hear about it. + EventDispatcher.call(new PlayerDamagedEvent(target, center, damage)); + } } } - /** - * Checks whether the given player may take damage from the slender. - *

- * Only players of the survivor team are valid targets. The slender itself and every spectator - * must stay untouched, otherwise a spectator would slowly bleed out and trigger the whole death - * pipeline a second time. Because {@link Player#setHealth(float)} bypasses the damage event - * chain, the game mode is checked as a second, independent guard: it stays correct even if the - * team tag and the game mode ever drift apart. - * - * @param target the player to check - * @return {@code true} if the player is a living survivor that may take damage - */ - private static boolean isDamageableSurvivor(Player target) { - return TeamHelper.isSurvivorTeam(target) - && !target.getGameMode().invulnerable() - && !target.isDead() - && target.getHealth() > 0; - } - /** * Plays the spawn sound to all players in the given range. * diff --git a/game/src/test/java/net/onelitefeather/cygnus/blood/BloodDirectionTest.java b/game/src/test/java/net/onelitefeather/cygnus/blood/BloodDirectionTest.java new file mode 100644 index 00000000..1b7817dd --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/blood/BloodDirectionTest.java @@ -0,0 +1,57 @@ +package net.onelitefeather.cygnus.blood; + +import net.minestom.server.coordinate.Pos; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies from which side the blood is thrown across the screen. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class BloodDirectionTest { + + /** A victim in the origin looking towards positive Z, which is a yaw of zero. */ + private static final Pos VICTIM = new Pos(0, 40, 0, 0, 0); + + @Test + @DisplayName("A hit from straight ahead lands in front") + void hitFromAheadIsFront() { + assertEquals(BloodDirection.FRONT, BloodDirection.between(VICTIM, new Pos(0, 40, 6))); + } + + @Test + @DisplayName("A hit from behind lands in the back") + void hitFromBehindIsBack() { + assertEquals(BloodDirection.BACK, BloodDirection.between(VICTIM, new Pos(0, 40, -6))); + } + + @Test + @DisplayName("Looking south, a hit from the east lands on the left") + void hitFromEastIsLeft() { + assertEquals(BloodDirection.LEFT, BloodDirection.between(VICTIM, new Pos(6, 40, 0))); + } + + @Test + @DisplayName("Looking south, a hit from the west lands on the right") + void hitFromWestIsRight() { + assertEquals(BloodDirection.RIGHT, BloodDirection.between(VICTIM, new Pos(-6, 40, 0))); + } + + @Test + @DisplayName("The victim's own facing decides, not the world") + void facingDecides() { + Pos turned = new Pos(0, 40, 0, 180, 0); + assertEquals(BloodDirection.BACK, BloodDirection.between(turned, new Pos(0, 40, 6))); + } + + @Test + @DisplayName("A hit from the exact same spot still picks a side") + void hitFromTheSameSpotIsFront() { + assertEquals(BloodDirection.FRONT, BloodDirection.between(VICTIM, new Pos(0, 40, 0))); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/blood/BloodSplatterServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/blood/BloodSplatterServiceTest.java new file mode 100644 index 00000000..d53ca3eb --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/blood/BloodSplatterServiceTest.java @@ -0,0 +1,181 @@ +package net.onelitefeather.cygnus.blood; + +import net.kyori.adventure.key.Key; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.event.PlayerDamagedEvent; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.RecordingScreenOverlay; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the splatter that flashes up when a player is hit and fades out on its own. + * + * @author TheMeinerLP + * @version 1.1.0 + * @since 2.7.0 + */ +class BloodSplatterServiceTest extends CygnusPlayerTestBase { + + /** Always picks the first variant, so the expected code points are predictable. */ + private static final java.util.function.IntUnaryOperator FIRST_VARIANT = bound -> 0; + + @Test + @DisplayName("A hit puts the first frame on screen right away") + void hitShowsTheFirstFrame(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + + service.splatter(player, BloodDirection.FRONT); + + assertEquals(textureOf(BloodDirection.FRONT, 0, 0), overlay.of(player, OverlayLayer.BLOOD)); + } + + @Test + @DisplayName("The direction of the hit picks a different set of frames") + void directionPicksItsOwnFrames(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + + service.splatter(player, BloodDirection.LEFT); + + assertEquals(textureOf(BloodDirection.LEFT, 0, 0), overlay.of(player, OverlayLayer.BLOOD)); + assertNotEquals(textureOf(BloodDirection.FRONT, 0, 0), overlay.of(player, OverlayLayer.BLOOD)); + } + + @Test + @DisplayName("The splatter fades frame by frame and disappears") + void splatterFadesAway(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + service.splatter(player, BloodDirection.FRONT); + + service.tick(); + assertEquals(textureOf(BloodDirection.FRONT, 0, 1), overlay.of(player, OverlayLayer.BLOOD), "the second frame follows"); + + for (int remaining = 1; remaining < BloodSplatterService.FRAMES; remaining++) { + service.tick(); + } + + assertNull(overlay.of(player, OverlayLayer.BLOOD), "the splatter has to clean up after itself"); + } + + @Test + @DisplayName("A second hit restarts the splatter") + void secondHitRestarts(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + service.splatter(player, BloodDirection.FRONT); + service.tick(); + service.tick(); + + service.splatter(player, BloodDirection.FRONT); + + assertEquals(textureOf(BloodDirection.FRONT, 0, 0), overlay.of(player, OverlayLayer.BLOOD), "a fresh hit starts over"); + } + + @Test + @DisplayName("Being hit is announced by the damage event") + void damageEventTriggersTheSplatter(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + service.registerListener(env.process().eventHandler()); + + EventDispatcher.call(new PlayerDamagedEvent(player, new Pos(0, 40, 6), 1.0F)); + + assertNull(overlay.of(player, OverlayLayer.TUNNEL_VISION), "only the blood layer belongs to this service"); + assertTrue(overlay.of(player, OverlayLayer.BLOOD) != null, "a hit has to show blood"); + } + + @Test + @DisplayName("Clearing takes the splatter off the screen") + void clearingRemovesTheSplatter(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + service.splatter(player, BloodDirection.FRONT); + + service.clear(player); + + assertNull(overlay.of(player, OverlayLayer.BLOOD)); + } + + @Test + @DisplayName("The fade task only runs while something is bleeding") + void fadeTaskTracksActiveSplatters(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Player player = spawn(env); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + assertFalse(service.fadeTask.isRunning(), "nothing is bleeding yet"); + + service.splatter(player, BloodDirection.FRONT); + assertTrue(service.fadeTask.isRunning(), "a hit has to keep the fade task alive"); + + for (int remaining = 0; remaining < BloodSplatterService.FRAMES; remaining++) { + service.tick(); + } + + assertFalse(service.fadeTask.isRunning(), "the task stops itself once nothing is bleeding any more"); + } + + @Test + @DisplayName("Two players bleed independently") + void playersAreIndependent(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Instance instance = env.createFlatInstance(); + Player first = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player second = env.createConnection().connect(instance, new Pos(4, 40, 0)); + BloodSplatterService service = new BloodSplatterService(overlay, FIRST_VARIANT); + + service.splatter(first, BloodDirection.FRONT); + service.tick(); + service.splatter(second, BloodDirection.BACK); + + assertEquals(textureOf(BloodDirection.FRONT, 0, 1), overlay.of(first, OverlayLayer.BLOOD)); + assertEquals(textureOf(BloodDirection.BACK, 0, 0), overlay.of(second, OverlayLayer.BLOOD)); + } + + /** + * Connects a player into a fresh instance. + * + * @param env the test environment + * @return the connected player + */ + private Player spawn(Env env) { + Instance instance = env.createFlatInstance(); + return env.createConnection().connect(instance, new Pos(0, 40, 0)); + } + + /** + * Works out the texture a direction, variant and frame map to. + * + * @param direction the direction of the hit + * @param variant the variant index + * @param frame the frame index + * @return the texture key + */ + private Key textureOf(BloodDirection direction, int variant, int frame) { + return Key.key("cygnus", "%s%s_%d_%d".formatted( + BloodSplatterService.TEXTURE_PATH, + direction.name().toLowerCase(java.util.Locale.ROOT), + variant + 1, + frame + 1 + )); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperDamageTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperDamageTest.java new file mode 100644 index 00000000..b7925619 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderBarHelperDamageTest.java @@ -0,0 +1,63 @@ +package net.onelitefeather.cygnus.stamina; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventFilter; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Collector; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.event.PlayerDamagedEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies that damage dealt by the slender is announced, since setting health directly never + * raises Minestom's own damage event. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class SlenderBarHelperDamageTest extends CygnusPlayerTestBase { + + private static final float DAMAGE = 0.5F; + private static final int RANGE = 3; + + private final SlenderBarHelper helper = new SlenderBarHelper() { + }; + + @Test + @DisplayName("A damaged player is announced together with where the hit came from") + void damageIsAnnounced(Env env) { + Instance instance = env.createFlatInstance(); + Player attacker = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Player victim = env.createConnection().connect(instance, new Pos(1, 40, 0)); + Pos center = new Pos(0, 40, 0); + Collector collector = + env.trackEvent(PlayerDamagedEvent.class, EventFilter.PLAYER, victim); + + this.helper.applyDamage(instance, attacker.getUuid(), center, RANGE, DAMAGE); + + collector.assertSingle(event -> { + assertEquals(victim, event.getPlayer(), "the victim has to be the one that was hit"); + assertEquals(center, event.getSource(), "the source is what aims the splatter"); + assertEquals(DAMAGE, event.getAmount(), "the amount travels along for anything that scales with it"); + }); + } + + @Test + @DisplayName("The player dealing the damage is left out") + void attackerIsNotAnnounced(Env env) { + Instance instance = env.createFlatInstance(); + Player attacker = env.createConnection().connect(instance, new Pos(0, 40, 0)); + Collector collector = + env.trackEvent(PlayerDamagedEvent.class, EventFilter.PLAYER, attacker); + + this.helper.applyDamage(instance, attacker.getUuid(), new Pos(0, 40, 0), RANGE, DAMAGE); + + collector.assertEmpty(); + } +}