diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/Helper.java b/common/src/main/java/net/onelitefeather/cygnus/common/util/Helper.java index d45d0eee..1764491c 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/util/Helper.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/util/Helper.java @@ -11,7 +11,7 @@ * and game-specific identifiers or timings. * * @author theEvilReaper - * @version 1.0.2 + * @version 1.1.0 * @since 1.0.0 **/ public final class Helper { @@ -69,6 +69,34 @@ public static int getRandomInt(int maximumValue) { return ThreadLocalRandom.current().nextInt(0, maximumValue); } + /** + * Clamps a value to lie within the given bounds, in place of hand-rolling + * {@code Math.min(max, Math.max(min, value))} at every call site. + * + * @param value the value to clamp + * @param min the inclusive lower bound + * @param max the inclusive upper bound + * @return {@code min} if {@code value} is lower, {@code max} if it is higher, {@code value} otherwise + */ + @Contract(pure = true) + public static int clamp(int value, int min, int max) { + return Math.clamp(value, min, max); + } + + /** + * Clamps a value to lie within the given bounds, in place of hand-rolling + * {@code Math.min(max, Math.max(min, value))} at every call site. + * + * @param value the value to clamp + * @param min the inclusive lower bound + * @param max the inclusive upper bound + * @return {@code min} if {@code value} is lower, {@code max} if it is higher, {@code value} otherwise + */ + @Contract(pure = true) + public static double clamp(double value, double min, double max) { + return Math.clamp(value, min, max); + } + /** * Adjusts the placement coordinates of a collectible page entity based on the * block face/direction it is attached to, ensuring it aligns correctly and remains visible. diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/PlayerState.java b/common/src/main/java/net/onelitefeather/cygnus/common/util/PlayerState.java new file mode 100644 index 00000000..33445809 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/util/PlayerState.java @@ -0,0 +1,105 @@ +package net.onelitefeather.cygnus.common.util; + +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.Nullable; + +import java.util.Collection; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * Keeps one value per player, keyed by {@link Player#getUuid()}. + *

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

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

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

+ * + * @return a live view over the tracked values + */ + public Collection values() { + return this.values.values(); + } + + /** + * @return {@code true} if no player is currently tracked + */ + public boolean isEmpty() { + return this.values.isEmpty(); + } + + /** + * Stops tracking every player. + */ + public void clear() { + this.values.clear(); + } +} diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/RepeatingTask.java b/common/src/main/java/net/onelitefeather/cygnus/common/util/RepeatingTask.java new file mode 100644 index 00000000..3193a424 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/util/RepeatingTask.java @@ -0,0 +1,79 @@ +package net.onelitefeather.cygnus.common.util; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.timer.Task; +import org.jetbrains.annotations.Nullable; + +import java.time.temporal.TemporalUnit; + +/** + * Owns a single Minestom repeating scheduler {@link Task}. + *

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

+ *

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

+ * + *

Usage:

+ *
{@code
+ * RepeatingTask task = new RepeatingTask(this::tick);
+ * task.start(1, ChronoUnit.SECONDS);
+ * // ...
+ * task.stop();
+ * }
+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class RepeatingTask { + + private final Runnable action; + private @Nullable Task task; + + /** + * Creates a task that, once started, runs the given action on every repetition. + * + * @param action the action to run + */ + public RepeatingTask(Runnable action) { + this.action = action; + } + + /** + * Starts the task with the given period. Does nothing if the task is already running. + * + * @param period the amount of {@code unit}s between two runs + * @param unit the unit {@code period} is measured in + */ + public void start(long period, TemporalUnit unit) { + if (this.task != null) return; + this.task = MinecraftServer.getSchedulerManager() + .buildTask(this.action) + .repeat(period, unit) + .schedule(); + } + + /** + * Stops the task. Does nothing if the task is not running. + */ + public void stop() { + if (this.task == null) return; + this.task.cancel(); + this.task = null; + } + + /** + * @return {@code true} if the task is currently running + */ + public boolean isRunning() { + return this.task != null; + } +} diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/util/HelperTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/util/HelperTest.java new file mode 100644 index 00000000..1bb53b16 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/util/HelperTest.java @@ -0,0 +1,54 @@ +package net.onelitefeather.cygnus.common.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies {@link Helper#clamp(int, int, int)} and {@link Helper#clamp(double, double, double)}, + * which replace the hand-rolled {@code Math.min(hi, Math.max(lo, x))} scattered across the tunnel + * vision and slender gaze code. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class HelperTest { + + @Test + @DisplayName("An int within bounds is returned unchanged") + void intWithinBoundsIsUnchanged() { + assertEquals(5, Helper.clamp(5, 0, 10)); + } + + @Test + @DisplayName("An int below the lower bound is raised to it") + void intBelowLowerBoundIsRaised() { + assertEquals(0, Helper.clamp(-5, 0, 10)); + } + + @Test + @DisplayName("An int above the upper bound is lowered to it") + void intAboveUpperBoundIsLowered() { + assertEquals(10, Helper.clamp(15, 0, 10)); + } + + @Test + @DisplayName("A double within bounds is returned unchanged") + void doubleWithinBoundsIsUnchanged() { + assertEquals(0.5D, Helper.clamp(0.5D, 0.0D, 1.0D)); + } + + @Test + @DisplayName("A double below the lower bound is raised to it") + void doubleBelowLowerBoundIsRaised() { + assertEquals(0.0D, Helper.clamp(-0.5D, 0.0D, 1.0D)); + } + + @Test + @DisplayName("A double above the upper bound is lowered to it") + void doubleAboveUpperBoundIsLowered() { + assertEquals(1.0D, Helper.clamp(1.5D, 0.0D, 1.0D)); + } +} diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/util/PlayerStateTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/util/PlayerStateTest.java new file mode 100644 index 00000000..cb4fc585 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/util/PlayerStateTest.java @@ -0,0 +1,116 @@ +package net.onelitefeather.cygnus.common.util; + +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.Iterator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that {@link PlayerState} tracks one value per player, keeps players apart, and forgets + * them cleanly. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +@ExtendWith(MicrotusExtension.class) +class PlayerStateTest { + + @Test + void nothingIsTrackedForAFreshPlayer(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + + assertNull(state.get(player)); + assertTrue(state.isEmpty()); + } + + @Test + void putThenGetReturnsTheStoredValue(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + + state.put(player, "value"); + + assertEquals("value", state.get(player)); + assertFalse(state.isEmpty()); + } + + @Test + void playersAreKeptApart(Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createPlayer(instance); + Player second = env.createPlayer(instance); + PlayerState state = new PlayerState<>(); + + state.put(first, "first"); + state.put(second, "second"); + + assertEquals("first", state.get(first)); + assertEquals("second", state.get(second)); + } + + @Test + void removeForgetsThePlayerAndReturnsTheOldValue(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + state.put(player, "value"); + + assertEquals("value", state.remove(player)); + assertNull(state.get(player)); + assertNull(state.remove(player), "removing an untracked player must not throw"); + } + + @Test + void computeIfAbsentStoresAndReusesTheComputedValue(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + + StringBuilder first = state.computeIfAbsent(player, StringBuilder::new); + StringBuilder second = state.computeIfAbsent(player, StringBuilder::new); + + assertEquals(first, second, "a second call must not overwrite the already-tracked value"); + } + + @Test + void clearForgetsEveryPlayer(Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createPlayer(instance); + Player second = env.createPlayer(instance); + PlayerState state = new PlayerState<>(); + state.put(first, "first"); + state.put(second, "second"); + + state.clear(); + + assertTrue(state.isEmpty()); + } + + @Test + void removingThroughValuesForgetsThePlayerToo(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + state.put(player, "value"); + + Iterator values = state.values().iterator(); + values.next(); + values.remove(); + + assertTrue(state.isEmpty(), "the map backing values() must be live, the way BloodSplatterService needs it"); + assertNull(state.get(player)); + } + + private Player spawn(Env env) { + Instance instance = env.createFlatInstance(); + return env.createPlayer(instance); + } +} diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/util/RepeatingTaskIntegrationTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/util/RepeatingTaskIntegrationTest.java new file mode 100644 index 00000000..429d5368 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/util/RepeatingTaskIntegrationTest.java @@ -0,0 +1,99 @@ +package net.onelitefeather.cygnus.common.util; + +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.time.temporal.ChronoUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that {@link RepeatingTask} owns exactly one scheduler task no matter how many times + * start and stop are called. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +@ExtendWith(MicrotusExtension.class) +class RepeatingTaskIntegrationTest { + + @Test + void notRunningBeforeStart() { + RepeatingTask task = new RepeatingTask(() -> { + }); + + assertFalse(task.isRunning()); + } + + @Test + void runsOnceStarted(Env env) { + AtomicInteger ticks = new AtomicInteger(); + RepeatingTask task = new RepeatingTask(ticks::incrementAndGet); + + task.start(50, ChronoUnit.MILLIS); + assertTrue(task.isRunning()); + for (int i = 0; i < 10; i++) { + env.tick(); + } + + assertTrue(ticks.get() > 0, "the action should have run at least once by now"); + } + + @Test + void startIsIdempotent(Env env) { + AtomicInteger ticks = new AtomicInteger(); + RepeatingTask task = new RepeatingTask(ticks::incrementAndGet); + + task.start(50, ChronoUnit.MILLIS); + task.start(50, ChronoUnit.MILLIS); + for (int i = 0; i < 10; i++) { + env.tick(); + } + int afterFirstBatch = ticks.get(); + + // Stopping cancels the single task this class is meant to own. If start() had scheduled a + // second task on the repeated call, stop() would only ever reach one of them and the other + // would keep running forever, still incrementing the counter below. + task.stop(); + for (int i = 0; i < 10; i++) { + env.tick(); + } + + assertEquals(afterFirstBatch, ticks.get(), "a leaked second task would still be ticking"); + } + + @Test + void stopStopsTheTask(Env env) { + AtomicInteger ticks = new AtomicInteger(); + RepeatingTask task = new RepeatingTask(ticks::incrementAndGet); + task.start(50, ChronoUnit.MILLIS); + for (int i = 0; i < 10; i++) { + env.tick(); + } + + task.stop(); + assertFalse(task.isRunning()); + int afterStop = ticks.get(); + for (int i = 0; i < 10; i++) { + env.tick(); + } + + assertEquals(afterStop, ticks.get(), "no more runs should happen after stop()"); + } + + @Test + void stopIsIdempotent() { + RepeatingTask task = new RepeatingTask(() -> { + }); + + task.stop(); + + assertFalse(task.isRunning(), "stopping a task that never ran must not throw"); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index 3dfeb864..a0fcd1f2 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -37,6 +37,7 @@ import net.minestom.server.listener.EntityActionListener; import net.minestom.server.network.packet.client.play.ClientEntityActionPacket; import net.onelitefeather.cygnus.ambient.AmbientProvider; +import net.onelitefeather.cygnus.command.GlitchCommand; import net.onelitefeather.cygnus.command.StartCommand; import net.onelitefeather.cygnus.common.ListenerHandling; import net.onelitefeather.cygnus.common.bootstrap.ServiceBootstrap; @@ -46,6 +47,7 @@ import net.onelitefeather.cygnus.common.page.PageProvider; import net.onelitefeather.cygnus.common.page.event.PageExpiredEvent; import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.gaze.SlenderGazeService; import net.onelitefeather.cygnus.event.SlenderReviveEvent; import net.onelitefeather.cygnus.event.StaminaStateChangeEvent; import net.onelitefeather.cygnus.jumpscare.JumpScareManager; @@ -74,14 +76,19 @@ import net.onelitefeather.cygnus.resourcepack.ResourcePackService; import net.onelitefeather.cygnus.stamina.SlenderBarTrigger; import net.onelitefeather.cygnus.stamina.StaminaService; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; +import net.onelitefeather.cygnus.overlay.EquipmentScreenOverlay; +import net.onelitefeather.cygnus.overlay.OverlayProperties; import net.onelitefeather.cygnus.utils.StaminaHelper; import net.onelitefeather.cygnus.utils.ViewRuleUpdater; import net.onelitefeather.cygnus.view.GameView; import net.onelitefeather.cygnus.view.GameViewImpl; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.nio.file.Path; import java.util.Optional; +import java.util.Set; import java.util.function.Supplier; /** @@ -103,6 +110,8 @@ public final class Cygnus implements TeamCreator, ListenerHandling { private final JumpScareManager jumpscareManager; private final SpectatorService spectatorService; private final Optional resourcePackService; + private final ScreenOverlay screenOverlay; + private final SlenderGazeService slenderGazeService; public Cygnus() { Path path = ServiceBootstrap.resolveWorkingDirectory(); @@ -126,6 +135,8 @@ public Cygnus() { .orElseThrow(() -> new IllegalStateException("Spectator team not found")); this.spectatorService = new SpectatorService(spectatorTeam, survivorTeam); this.resourcePackService = ResourcePackService.create(); + this.screenOverlay = new EquipmentScreenOverlay(); + this.slenderGazeService = new SlenderGazeService(this.screenOverlay, this::currentSlender); this.initPhases(); this.initCommands(); this.initListener(); @@ -136,6 +147,29 @@ public Cygnus() { private void initCommands() { var manager = MinecraftServer.getCommandManager(); manager.register(new StartCommand(this.linearPhaseSeries)); + manager.register(new GlitchCommand(this.slenderGazeService)); + } + + /** + * Looks up the player currently playing the slender. + * + * @return the slender, or {@code null} while the role is unassigned + */ + private @Nullable Player currentSlender() { + return this.teamService.getTeam(GameConfig.SLENDER_KEY) + .flatMap(team -> team.getPlayers().stream().findFirst()) + .orElse(null); + } + + /** + * Collects the players that are currently survivors. + * + * @return the survivor team's players + */ + private Set currentSurvivors() { + return this.teamService.getTeam(GameConfig.SURVIVOR_KEY) + .map(team -> Set.copyOf(team.getPlayers())) + .orElseGet(Set::of); } @@ -191,6 +225,12 @@ private void registerGameListener() { MinecraftServer.getPacketListenerManager().setPlayListener(ClientEntityActionPacket.class, CygnusEntityActionListener::listener); spectatorService.registerListener(handler); + + // Without the pack the vignette font does not exist and survivors would stare at an + // empty box, so the effect stays off wherever the pack is not delivered. + if (OverlayProperties.enabled()) { + this.slenderGazeService.registerListener(handler, this::currentSurvivors); + } } private void initPhases() { diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java b/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java new file mode 100644 index 00000000..8ef1d271 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java @@ -0,0 +1,48 @@ +package net.onelitefeather.cygnus.command; + +import net.minestom.server.command.CommandSender; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.common.Messages; +import org.jetbrains.annotations.Nullable; + +/** + * Narrows a {@link CommandSender} down to a {@link Player}, since every preview command in this + * package draws on a screen and only a player has one. + *

+ * {@code TunnelVisionCommand}, {@code BloodCommand} and {@code GlitchCommand} each hand-rolled an + * identical {@code private static @Nullable Player asPlayer(CommandSender)}, differing only in the + * error string sent back to the console. This type is that method, extracted once. + *

+ *

+ * A static helper was chosen over an abstract base command on purpose. The narrowing check is the + * only thing the three commands share — their constructors take different services, their default + * executors print different usage lines, and {@code TunnelVisionCommand} alone runs a per-player + * preview loop. An abstract base class would force every subclass into one constructor shape and + * one inheritance chain to get a single one-line check, coupling command shape to something none of + * them actually have in common. A stateless static method carries the shared behaviour without + * dragging the unrelated parts of any one command onto the other two, which keeps each command free + * to change its syntax, its executor and its scheduling independently. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class CommandSenders { + + private CommandSenders() { + } + + /** + * Narrows the given sender down to a player, telling them why not if it cannot. + * + * @param sender the sender to narrow + * @param reason the rest of the sentence after {@code "Only players "}, e.g. {@code "can bleed."} + * @return the player, or {@code null} if the sender has no screen to draw on + */ + public static @Nullable Player asPlayer(CommandSender sender, String reason) { + if (sender instanceof Player player) return player; + sender.sendMessage(Messages.withMiniPrefix("Only players " + reason)); + return null; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java new file mode 100644 index 00000000..199d25b1 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java @@ -0,0 +1,49 @@ +package net.onelitefeather.cygnus.command; + +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.common.Messages; +import net.onelitefeather.cygnus.gaze.SlenderGaze; +import net.onelitefeather.cygnus.gaze.SlenderGazeService; + +/** + * Puts the slender's glitch on screen without him being there, so the drawings can be judged from + * the lobby. + *

+ * {@code /glitch <1-4>} holds one level, {@code /glitch off} takes it away. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class GlitchCommand extends Command { + + /** + * Creates the command. + * + * @param service the service that draws the tearing + */ + public GlitchCommand(SlenderGazeService service) { + super("glitch"); + + var level = ArgumentType.Integer("level").between(1, SlenderGaze.LEVELS); + + this.setDefaultExecutor((sender, context) -> sender.sendMessage( + Messages.withMiniPrefix("Usage: /glitch <1-" + SlenderGaze.LEVELS + "> | off") + )); + + this.addSyntax((sender, context) -> { + Player player = CommandSenders.asPlayer(sender, "have a view to lose."); + if (player == null) return; + service.show(player, context.get(level) - 1); + }, level); + + this.addSyntax((sender, context) -> { + Player player = CommandSenders.asPlayer(sender, "have a view to lose."); + if (player == null) return; + service.hide(player); + }, ArgumentType.Literal("off")); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java new file mode 100644 index 00000000..01fbb31a --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java @@ -0,0 +1,69 @@ +package net.onelitefeather.cygnus.gaze; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.cygnus.common.util.Helper; + +/** + * Works out how badly the sight of the slender tears a survivor's view apart. + *

+ * This is about seeing him, not about him being there: standing behind a survivor does nothing at + * all, however close he is. Only once he is inside their field of view does the picture start to + * come apart, and it gets worse the nearer he is. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class SlenderGaze { + + /** Nothing to draw: he is out of range, or out of sight. */ + public static final int NONE = -1; + + /** How many degrees of tearing there are between just visible and right in front. */ + public static final int LEVELS = 4; + + /** Beyond this distance he is too far away to unsettle anything. */ + private static final double RANGE = 32.0D; + + /** The distance at which the tearing is at its worst. */ + private static final double CLOSE = 6.0D; + + /** + * How far off the view direction he may stand and still count as seen. Roughly the horizontal + * field of view of a default client — the effect belongs on the screen he is on. + */ + private static final double FIELD_OF_VIEW = 0.55D; + + /** Below this distance the direction to him carries no meaning any more. */ + private static final double DISTANCE_EPSILON = 1.0E-6D; + + private SlenderGaze() { + } + + /** + * Works out the tearing a survivor gets from where the slender stands. + * + * @param survivor the survivor's position, whose yaw and pitch supply the view direction + * @param slender the slender's position + * @return a level between {@code 0} and {@code LEVELS - 1}, or {@link #NONE} + */ + public static int levelOf(Pos survivor, Pos slender) { + double distance = survivor.distance(slender); + if (distance > RANGE) return NONE; + if (distance < DISTANCE_EPSILON) return LEVELS - 1; + + Vec towardsSlender = new Vec( + slender.x() - survivor.x(), + slender.y() - survivor.y(), + slender.z() - survivor.z() + ).div(distance); + + if (survivor.direction().dot(towardsSlender) < FIELD_OF_VIEW) return NONE; + + double nearness = (RANGE - distance) / (RANGE - CLOSE); + double clamped = Helper.clamp(nearness, 0.0D, 1.0D); + return (int) Math.round(clamped * (LEVELS - 1)); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java new file mode 100644 index 00000000..a21eda61 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java @@ -0,0 +1,208 @@ +package net.onelitefeather.cygnus.gaze; + +import net.kyori.adventure.key.Key; +import net.minestom.server.entity.Player; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.player.PlayerDeathEvent; +import net.minestom.server.event.player.PlayerDisconnectEvent; +import net.minestom.server.instance.Instance; +import net.onelitefeather.cygnus.common.util.Helper; +import net.onelitefeather.cygnus.common.util.PlayerState; +import net.onelitefeather.cygnus.common.util.RepeatingTask; +import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.event.GameStartEvent; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.OverlayTextureKeys; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; +import org.jetbrains.annotations.Nullable; + +import java.time.temporal.ChronoUnit; +import java.util.Set; +import java.util.function.Supplier; + +/** + * Tears a survivor's picture apart while the slender stands in their view. + *

+ * This replaces what the tunnel vision used to do when he came near, and it asks a different + * question: not how close he is, but whether they can see him. Standing behind a survivor does + * nothing at all. + *

+ *

+ * A real colour-space shift would need a post-processing shader, and on Minecraft 26.2 those + * cannot be switched on for a single player, so this is a camera overlay like the others — the + * colour is laid over the world rather than the world being recalculated. + *

+ * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.7.0 + */ +public final class SlenderGazeService { + + /** Where the glitch textures live, as {@code camera_overlay} resolves them. */ + static final String TEXTURE_PATH = "gui/glitch/level_"; + + /** How many frames the tearing runs through. */ + static final int FRAMES = 4; + + /** How long a frame stays on screen. */ + static final int TICK_MILLIS = 100; + + private static final Key[][] TEXTURES = OverlayTextureKeys.table( + TEXTURE_PATH, SlenderGaze.LEVELS, FRAMES, OverlayTextureKeys.ONE_BASED, OverlayTextureKeys.ONE_BASED); + + private final ScreenOverlay overlay; + private final Supplier<@Nullable Player> slender; + private final PlayerState survivors = new PlayerState<>(); + private final RepeatingTask task = new RepeatingTask(this::tick); + + private int frame; + + /** + * Creates a new service. + * + * @param overlay the overlay that owns the players' screens + * @param slender supplies the current slender, or {@code null} while there is none + */ + public SlenderGazeService(ScreenOverlay overlay, Supplier<@Nullable Player> slender) { + this.overlay = overlay; + this.slender = slender; + } + + /** + * Hooks the service into the round's lifecycle. + *

+ * Mirrors {@code TunnelVisionService}: the service listens for itself rather than being called + * from {@code GameStartListener} and friends, because — unlike {@code AmbientProvider}, which + * has no per-player state to speak of — it has to drop an individual survivor's tracking the + * moment they die or disconnect, not only when the whole round ends. Folding that into the + * round's start and finish hooks would mean widening their signatures for every service that + * needs it; listening for itself keeps this self-contained instead. + *

+ * + * @param node the node to register on + * @param survivors supplies the survivors of the starting round + */ + public void registerListener(EventNode node, Supplier> survivors) { + node.addListener(GameStartEvent.class, event -> { + this.startTask(); + for (Player survivor : survivors.get()) { + this.track(survivor); + } + }); + node.addListener(PlayerDeathEvent.class, event -> this.remove(event.getPlayer())); + node.addListener(PlayerDisconnectEvent.class, event -> this.remove(event.getPlayer())); + node.addListener(GameFinishEvent.class, event -> { + this.clearAll(); + this.stopTask(); + }); + } + + /** + * Starts the update task. Does nothing if it is already running. + */ + public void startTask() { + this.task.start(TICK_MILLIS, ChronoUnit.MILLIS); + } + + /** + * Stops the update task. Does nothing if it is not running. Leaves whatever is on a tracked + * survivor's screen where it is — pair with {@link #clearAll()} where every screen needs wiping + * too. + */ + public void stopTask() { + this.task.stop(); + } + + /** + * Starts drawing for a survivor. + * + * @param survivor the survivor to draw for + */ + public void track(Player survivor) { + this.survivors.put(survivor, survivor); + } + + /** + * Stops drawing for a survivor and clears what is left on their screen. + * + * @param player the survivor to drop + */ + public void remove(Player player) { + if (this.survivors.remove(player) == null) return; + this.overlay.set(player, OverlayLayer.GLITCH, null); + } + + /** + * Clears every tracked survivor's screen and forgets all of them. + */ + public void clearAll() { + for (Player survivor : this.survivors.values()) { + this.overlay.set(survivor, OverlayLayer.GLITCH, null); + } + this.survivors.clear(); + } + + /** + * Puts one level on a player's screen and leaves it there, for judging the drawings without a + * slender to walk in front of. + *

+ * This sits on the service rather than a separate type because it draws from the very texture + * table {@link #tick()} already builds; splitting it out would mean either rebuilding that table + * a second time or exposing it, trading one seam for a worse one over two lines of + * {@code GlitchCommand} preview code. + *

+ * + * @param player the player to draw for + * @param level the level between {@code 0} and {@code SlenderGaze.LEVELS - 1} + */ + public void show(Player player, int level) { + int clamped = Helper.clamp(level, 0, SlenderGaze.LEVELS - 1); + this.overlay.set(player, OverlayLayer.GLITCH, TEXTURES[clamped][this.frame % FRAMES]); + } + + /** + * Takes the tearing off a player's screen. + * + * @param player the player to clear + */ + public void hide(Player player) { + this.overlay.set(player, OverlayLayer.GLITCH, null); + } + + /** + * Advances the tearing by one frame and redraws every survivor. + */ + void tick() { + if (this.survivors.isEmpty()) return; + + Player currentSlender = this.slender.get(); + this.frame++; + + for (Player survivor : this.survivors.values()) { + int level = this.levelFor(survivor, currentSlender); + if (level == SlenderGaze.NONE) { + this.overlay.set(survivor, OverlayLayer.GLITCH, null); + continue; + } + this.overlay.set(survivor, OverlayLayer.GLITCH, TEXTURES[level][this.frame % FRAMES]); + } + } + + /** + * Works out the tearing one survivor gets. + * + * @param survivor the survivor to look at + * @param slender the current slender, may be {@code null} + * @return the level, or {@link SlenderGaze#NONE} + */ + private int levelFor(Player survivor, @Nullable Player slender) { + if (slender == null) return SlenderGaze.NONE; + + Instance instance = slender.getInstance(); + if (instance == null || !instance.equals(survivor.getInstance())) return SlenderGaze.NONE; + + return SlenderGaze.levelOf(survivor.getPosition(), slender.getPosition()); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/gaze/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/gaze/package-info.java new file mode 100644 index 00000000..2ce2129c --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.onelitefeather.cygnus.gaze; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java new file mode 100644 index 00000000..244d80f9 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlay.java @@ -0,0 +1,142 @@ +package net.onelitefeather.cygnus.overlay; + +import net.kyori.adventure.key.Key; +import net.minestom.server.component.DataComponents; +import net.minestom.server.entity.EquipmentSlot; +import net.minestom.server.entity.Player; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.item.component.Equippable; +import net.minestom.server.sound.SoundEvent; +import net.onelitefeather.cygnus.common.util.PlayerState; +import org.jetbrains.annotations.Nullable; + +import java.util.EnumMap; +import java.util.Map; + +/** + * Puts the overlay on screen as the {@code camera_overlay} of an item worn on the head. + *

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

+ *

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

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class EquipmentScreenOverlay implements ScreenOverlay { + + /** + * What the overlay rides on. The item itself is never seen — {@link #EMPTY_ASSET} makes sure of + * that — so the material only has to exist. + */ + private static final Material CARRIER = Material.PAPER; + + /** + * An equipment model with no layers, from the resource pack. Without an asset id Minecraft + * falls back to drawing the item itself on the player's head. + */ + private static final String EMPTY_ASSET = "cygnus:empty"; + + /** Vanilla's silent sound; the default equip sound would click on every stage change. */ + private static final SoundEvent SILENT = SoundEvent.of(Key.key("minecraft:intentionally_empty"), null); + + private final PlayerState> 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/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; + } + } +} 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]); + } +}