diff --git a/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md new file mode 100644 index 00000000..75f6baca --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md @@ -0,0 +1,212 @@ +# Tunnel vision for survivors + +## Goal + +A survivor's view narrows as the situation gets worse: the screen edges darken and pulse like a +heartbeat when stamina runs low, when the Slender closes in, or both. The effect is per player, +continuous rather than on/off, and driven entirely by the server. + +## Why not a shader + +The obvious implementation is a post-processing shader, and on Minecraft 26.2 it does not work. + +A resource-pack post effect only runs in contexts vanilla decides: the menu blur, spectator mob +vision, the glowing outline, and the "Improved Transparency" video setting. None of them can be +switched on for one player from the server, and none carries an intensity parameter. The only way +to force one on 26.2 is hijacking spectator mob vision by pointing the player's camera at a hidden +enderman, which takes over the camera and makes the game unplayable. + +That changes in 26.3: snapshot 3 (7 July 2026) added `/posteffect add|remove ` +plus the always-on `minecraft:end_of_frame` context. 26.3 is still in snapshots, and Minestom +ships 26.2 (`net.minestom:minestom:2026.07.22-26.2`). + +So the effect is rendered as a HUD overlay through the title today, behind an interface that +a post-effect renderer can slot into once 26.3 and Minestom support land. The gameplay side does +not change when that happens. + +Reference: [Shader – Minecraft Wiki](https://minecraft.wiki/w/Shader), +[Java Edition 26.3 Snapshot 3](https://minecraft.wiki/w/Java_Edition_26.3_Snapshot_3). + +## Intensity + +`TunnelVisionIntensity` turns two inputs into a value in `[0, 1]`. It has no Minestom dependency +beyond positions, so it is testable without a server. + +**Stamina.** With `s = currentSpeedCount / 20`: + +``` +stamina = s >= 0.5 ? 0 : ((0.5 - s) / 0.5)^2 +``` + +Nothing happens above half a bar; below it the curve accelerates, so the last few percent are far +more dramatic than crossing the halfway mark. + +**Slender.** With `d` the distance between survivor and Slender: + +``` +proximity = clamp((25 - d) / (25 - 6), 0, 1) +view = 0.6 + 0.4 * max(0, dot(survivorLookDirection, directionToSlender)) +slender = proximity * view +``` + +The effect starts at 25 blocks and peaks at 6. Looking straight at him is worse than having him +behind you, but never by more than a factor of 1.67 — he is frightening either way. + +**Combination:** + +``` +combined = 1 - (1 - stamina) * (1 - slender) +``` + +Both sources add up noticeably but saturate cleanly at 1.0 instead of clamping hard, so neither +one can hide the other. + +**No line-of-sight raycast.** A wall between survivor and Slender does not dampen the effect. It +would cost a block walk per survivor per tick, and "I can feel him through the wall" is the better +atmosphere anyway. + +## Stages and pulse + +The continuous value is quantised to 16 stages, which double as the frames of the heartbeat. +Minecraft cannot animate a font texture — `.mcmeta` animation covers block, item, particle, +painting and effect textures only, and vanilla ships no font texture with one — so the animation +is the server walking through the frames. Two mechanisms sit on top, in this order: + +1. **Hysteresis on the base value.** `baseStage` starts as `round(combined * 16)` and afterwards + only moves when `combined * 16` is more than 0.6 stages away from it. Distance and stamina both + jitter constantly; without this the overlay flickers at every stage boundary. +2. **Pulse on top of the stabilised stage.** + +``` +depth = (16 / 16) * combined // one stage per 16, i.e. a fixed share of the scale +frequency = 1.0 + 1.5 * combined // Hz +display = clamp(round(baseStage + depth * (sin(2*pi * frequency * t) - 1)), 0, 16) +``` + +The heartbeat gets faster and deeper as it gets tighter, and stays nearly invisible at low +intensity — a depth that does not scale would make stage 1 flicker between 0 and 1. + +The pulse only ever opens the view back up, never past the base stage. A symmetric pulse would be +clipped away exactly where it matters most: at full intensity the base stage is already the +maximum, so everything above it is lost and the heartbeat disappears. + +The order matters: hysteresis applies to the base value, the pulse is added afterwards. Reversed, +the hysteresis would damp out exactly the pulsing it is there to allow. + +Stage 0 is not a texture. It clears the overlay. + +**Service tick: 100 ms.** The heartbeat reaches 2.5 Hz, and sampling it at 4 Hz — a 250 ms tick — +aliases it into something jerky. 100 ms samples it ten times per second, which is smooth and still +a tiny packet per survivor. + +## Pack assets + +In `cygnus-pack`, namespace `cygnus`: + +``` +pack/assets/cygnus/textures/gui/tunnel_vision/stage_1.png … stage_16.png +pack/assets/cygnus/font/tunnel_vision.json +``` + +Each texture is 256×128, 2:1 so it covers a widescreen viewport, and fully opaque at the outer +edge. The darkening closes in from all four edges rather than as a circle from the middle: it is a +superellipse whose exponent eases from 4 at stage 1 — a rounded rectangle framing the screen — to 2 +at stage 16, where a plain ellipse reads as a tunnel rather than a frame. The font is a bitmap +provider mapping `U+E000`–`U+E00F` to stages 1–16. + +**The 256 pixel limit is not cosmetic.** Font glyphs are stamped into 256×256 sheets at their +texture resolution, and a glyph that does not fit is dropped without a word in the log — the +client then draws the missing-glyph box. Anything larger simply does not work, however good it +looks in an image viewer. The glyph is still drawn several times that size; each texture carries a +`blur` mcmeta so that upscale stays smooth instead of banding into nearest-neighbour blocks. + +The server builds a `Component` carrying `font("cygnus:tunnel_vision")` and sends it as the title. +Three details that otherwise look broken: + +- `shadowColor` must be transparent, or Minecraft renders the vignette a second time, offset, + underneath itself. +- The title times are sent once per player with `fadeIn` and `fadeOut` at zero. A fade would make + the vignette pump on every update, and repeating the times ten times a second would double the + packet count for nothing — they stay in effect for every following title. +- `stay` is two seconds: long enough that the overlay never blinks between updates, short enough + that it disappears on its own if the server stops drawing. + +**The title is the channel, not the action bar.** Titles render centred on the screen and at four +times scale, which is what makes the position independent of the client's resolution: centring +needs `ascent = height/2 - 3` regardless of window size, where the action bar's anchor at the +bottom edge forced a recalibration for every viewport. `height: 70` covers a 240-pixel-high GUI +viewport four times over at that scale, and the 2:1 texture covers the width. + +## Components + +New package `net.onelitefeather.cygnus.tunnelvision`: + +- `TunnelVisionIntensity` — the calculation above. Pure, no server needed to test it. +- `TunnelVisionStage` — one survivor's overlay state: hysteresis and heartbeat. Also pure. +- `TunnelVisionRenderer` — `render(player, stage)` and `clear(player)`. This is the seam a + post-effect renderer slots into on 26.3. +- `TitleTunnelVisionRenderer` — the implementation described above. +- `TunnelVisionService` — holds a `TunnelVisionStage` per survivor and ticks all of them in one + scheduler task. +- `TunnelVisionCommand` — `/tunnelvision stage <0-16> | intensity <0.0-1.0> | off`, for judging the + vignette from the lobby without a running round. `stage` freezes one stage to calibrate the font + against; `intensity` runs the real heartbeat. + +One task for everyone rather than one per player as `StaminaBar` does: the Slender position is +read once per tick instead of once per survivor, and cleanup happens in one place. + +## Wiring + +`Cygnus` creates the service and the command. The service then listens for the round's lifecycle +itself, the way `SpectatorService` and `ResourcePackService` already do, rather than being called +from the existing listeners: + +| Event | What happens | +| --- | --- | +| `GameStartEvent` | starts drawing for the survivor team | +| `PlayerDeathEvent` | removes the player (transition to spectator) | +| `PlayerDisconnectEvent` | removes the player | +| `GameFinishEvent` | full cleanup | + +This keeps `GameStartListener`, `PlayerDeathListener` and `PlayerQuitListener` — and their tests — +untouched: none of them has anything the service needs beyond the moment itself. + +Two changes to existing code: + +- **`FoodBar` gains a getter** for normalised stamina. `currentSpeedCount` is private today. The + service could read `player.getExp()`, since `FoodBar` mirrors the value there, but that hangs + game logic off a display detail. +- **The service only exists when the resource pack is active.** `Cygnus` creates it only if + `resourcePackService` is present, reusing the `Optional` already in place. Without the pack the + font does not exist and players would see an empty box instead of a vignette. + +## Failure modes + +The service keeps running in all of these; none of them throws. + +| Situation | Behaviour | +| --- | --- | +| No Slender (disconnected, not yet assigned) | stamina share only | +| Slender in a different instance | slender share is 0 | +| No `FoodBar` registered for a player | stamina share is 0 | +| Stage drops to 0 | `clear()` rather than rendering — otherwise the last vignette lingers until the title's `stay` runs out | +| Player dies or becomes a spectator | explicit `clear()`, same reason | + +## Tests + +- `TunnelVisionIntensityTest` — plain JUnit: edge values (full stamina at long range gives 0, + empty stamina at close range gives 1), monotonicity in both inputs, and the view factor. +- `TunnelVisionStageTest` — plain JUnit: the pulse at full intensity, steadiness at low intensity, + hysteresis (a small oscillation around a stage boundary must not change the stage), and bounds. +- `TitleTunnelVisionRendererTest` — Cyano: the player receives a title packet with the expected + code point and the `cygnus:tunnel_vision` font, the shadow is transparent, the times hold rather + than fade and are sent only once, and `clear()` empties the title. +- `TunnelVisionServiceTest` — lifecycle: start and stop, removing a player, behaviour with no + Slender or one in another instance, and the four lifecycle events. +- `TunnelVisionCommandTest` — the command draws the requested stage, previews an intensity, and + clears on `off`. +- `FoodBarTest` — a fresh bar reports a full share. + +The pack side cannot be tested automatically. Glyph sizing and the look of the vignette are +verified in-game against a snapshot build of `cygnus-pack`; that is an explicit step in the +implementation plan, not an afterthought. diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index ced70c8a..7d0000b5 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.network.packet.client.play.ClientEntityActionPacket; import net.onelitefeather.cygnus.ambient.AmbientProvider; import net.onelitefeather.cygnus.command.StartCommand; +import net.onelitefeather.cygnus.command.TunnelVisionCommand; import net.onelitefeather.cygnus.common.ListenerHandling; import net.onelitefeather.cygnus.common.bootstrap.ServiceBootstrap; import net.onelitefeather.cygnus.common.config.GameConfig; @@ -73,14 +74,20 @@ import net.onelitefeather.cygnus.resourcepack.ResourcePackService; import net.onelitefeather.cygnus.stamina.SlenderBarTrigger; import net.onelitefeather.cygnus.stamina.StaminaService; +import net.onelitefeather.cygnus.stamina.FoodBar; +import net.onelitefeather.cygnus.tunnelvision.TitleTunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionService; 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; /** @@ -102,6 +109,8 @@ public final class Cygnus implements TeamCreator, ListenerHandling { private final JumpScareManager jumpscareManager; private final SpectatorService spectatorService; private final Optional resourcePackService; + private final TunnelVisionRenderer tunnelVisionRenderer; + private final TunnelVisionService tunnelVisionService; public Cygnus() { Path path = ServiceBootstrap.resolveWorkingDirectory(); @@ -125,6 +134,12 @@ public Cygnus() { .orElseThrow(() -> new IllegalStateException("Spectator team not found")); this.spectatorService = new SpectatorService(spectatorTeam, survivorTeam); this.resourcePackService = ResourcePackService.create(); + this.tunnelVisionRenderer = new TitleTunnelVisionRenderer(); + this.tunnelVisionService = new TunnelVisionService( + this.tunnelVisionRenderer, + this::remainingStamina, + this::currentSlender + ); this.initPhases(); this.initCommands(); this.initListener(); @@ -135,6 +150,40 @@ public Cygnus() { private void initCommands() { var manager = MinecraftServer.getCommandManager(); manager.register(new StartCommand(this.linearPhaseSeries)); + manager.register(new TunnelVisionCommand(this.tunnelVisionRenderer)); + } + + /** + * Reads a survivor's remaining stamina for the tunnel vision. + * + * @param player the survivor to read + * @return the remaining share, or a full bar while the player has none yet + */ + private double remainingStamina(Player player) { + FoodBar bar = this.staminaService.getFoodBar(player); + return bar == null ? 1.0D : bar.remainingShare(); + } + + /** + * 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); } @@ -189,6 +238,11 @@ 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. + this.resourcePackService.ifPresent( + _ -> this.tunnelVisionService.registerListener(handler, this::currentSurvivors)); } private void initPhases() { diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java new file mode 100644 index 00000000..741716b4 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java @@ -0,0 +1,116 @@ +package net.onelitefeather.cygnus.command; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.command.CommandSender; +import net.minestom.server.entity.Player; +import net.minestom.server.timer.Task; +import net.minestom.server.timer.TaskSchedule; +import net.onelitefeather.cygnus.common.Messages; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionStage; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Puts the tunnel vision on screen without a running round, so the glyph sizes in the resource + * pack can be judged from the lobby. + *

+ * {@code /tunnelvision stage <0-16>} freezes a single stage, which is what the font's + * {@code height} and {@code ascent} are calibrated against. {@code /tunnelvision intensity + * <0.0-1.0>} runs the same heartbeat the game uses, to judge how the pulse feels. Both are ended + * by {@code /tunnelvision off}. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class TunnelVisionCommand extends Command { + + private final TunnelVisionRenderer renderer; + private final Map previews; + + /** + * Creates the command. + * + * @param renderer the renderer that draws the preview + */ + public TunnelVisionCommand(TunnelVisionRenderer renderer) { + super("tunnelvision"); + this.renderer = renderer; + this.previews = new ConcurrentHashMap<>(); + + var stage = ArgumentType.Integer("level").between(0, TunnelVisionStage.MAX_STAGE); + var intensity = ArgumentType.Double("amount").between(0.0D, 1.0D); + + this.setDefaultExecutor((sender, context) -> sender.sendMessage( + Messages.withMiniPrefix("Usage: /tunnelvision stage <0-16> | intensity <0.0-1.0> | off") + )); + + this.addSyntax((sender, context) -> { + Player player = asPlayer(sender); + if (player == null) return; + this.stopPreview(player); + this.renderer.render(player, context.get(stage)); + }, ArgumentType.Literal("stage"), stage); + + this.addSyntax((sender, context) -> { + Player player = asPlayer(sender); + if (player == null) return; + this.startPreview(player, context.get(intensity)); + }, ArgumentType.Literal("intensity"), intensity); + + this.addSyntax((sender, context) -> { + Player player = asPlayer(sender); + if (player == null) return; + this.stopPreview(player); + this.renderer.clear(player); + }, ArgumentType.Literal("off")); + } + + /** + * Draws a constant intensity with its heartbeat running until the preview is stopped. + * + * @param player the player to draw for + * @param intensity the intensity to hold + */ + private void startPreview(Player player, double intensity) { + this.stopPreview(player); + + TunnelVisionStage stage = new TunnelVisionStage(); + // submitTask runs its first pass immediately, which is the initial draw. + Task task = MinecraftServer.getSchedulerManager().submitTask(() -> { + if (!player.isOnline()) return TaskSchedule.stop(); + this.renderer.render(player, stage.update(intensity)); + return TaskSchedule.millis(TunnelVisionStage.TICK_MILLIS); + }); + this.previews.put(player.getUuid(), task); + } + + /** + * Ends a running preview, leaving whatever is on screen untouched. + * + * @param player the player whose preview to end + */ + private void stopPreview(Player player) { + Task running = this.previews.remove(player.getUuid()); + if (running != null) running.cancel(); + } + + /** + * Narrows a sender down to a player, since the preview needs a screen to draw on. + * + * @param sender the sender to narrow + * @return the player, or {@code null} if the sender has no screen + */ + private static @Nullable Player asPlayer(CommandSender sender) { + if (sender instanceof Player player) return player; + sender.sendMessage(Messages.withMiniPrefix("Only players can preview the tunnel vision.")); + return null; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java b/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java index de36789e..77b632fc 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java +++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java @@ -98,6 +98,19 @@ private float normalize(float current) { return Math.max(0.0f, current / MAX_FOOD); } + /** + * Returns the remaining stamina as a share of a full bar. + *

+ * This is what drives the survivor's tunnel vision. The bar mirrors the same value into the + * experience bar, but reading it back from there would tie game logic to a display detail. + *

+ * + * @return the remaining stamina between {@code 0.0f} and {@code 1.0f} + */ + public float remainingShare() { + return normalize(this.currentSpeedCount); + } + /** * Returns an indication state if the bar could be consumed. * diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TitleTunnelVisionRenderer.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TitleTunnelVisionRenderer.java new file mode 100644 index 00000000..99167f7a --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TitleTunnelVisionRenderer.java @@ -0,0 +1,114 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.ShadowColor; +import net.kyori.adventure.title.Title; +import net.kyori.adventure.title.TitlePart; +import net.minestom.server.entity.Player; + +import java.time.Duration; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Draws the tunnel vision as a HUD overlay through the title. + *

+ * The title is the better channel of the two the server has. It renders centred on the screen and + * at four times scale, which makes the vignette's position independent of the client's resolution + * — the action bar hangs off the bottom edge, so a glyph anchored to it drifts with every window + * size. + *

+ *

+ * Each stage is a glyph of the {@code cygnus:tunnel_vision} bitmap font shipped with the resource + * pack. Minecraft cannot animate font textures — {@code .mcmeta} animation is limited to block, + * item, particle, painting and effect textures — so the heartbeat is the server walking through + * the stages, one glyph per frame. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class TitleTunnelVisionRenderer implements TunnelVisionRenderer { + + /** Bitmap font provided by {@code cygnus-pack} that carries the vignette glyphs. */ + static final Key FONT = Key.key("cygnus", "tunnel_vision"); + + /** Code point of stage 1; the remaining stages follow consecutively. */ + static final int FIRST_CODE_POINT = 0xE000; + + /** + * How long a frame survives without a follow-up. Comfortably longer than the service tick, so + * the overlay never blinks between updates, and short enough to disappear on its own should + * the server stop drawing. + */ + private static final Title.Times TIMES = Title.Times.times( + Duration.ZERO, + Duration.ofSeconds(2), + Duration.ZERO + ); + + /** Prepared once: the overlay is refreshed ten times per second per survivor. */ + private static final Component[] GLYPHS = buildGlyphs(); + + private final Set timed = ConcurrentHashMap.newKeySet(); + + /** + * {@inheritDoc} + */ + @Override + public void render(Player player, int stage) { + if (stage <= 0) { + this.clear(player); + return; + } + + this.ensureTimes(player); + player.sendTitlePart(TitlePart.TITLE, GLYPHS[Math.min(stage, TunnelVisionStage.MAX_STAGE) - 1]); + } + + /** + * {@inheritDoc} + */ + @Override + public void clear(Player player) { + this.timed.remove(player.getUuid()); + player.sendTitlePart(TitlePart.TITLE, Component.empty()); + } + + /** + * Sends the fade timings once per player. + *

+ * They stay in effect for every following title, so repeating them ten times a second would + * only double the packet count. + *

+ * + * @param player the player to prepare + */ + private void ensureTimes(Player player) { + if (!this.timed.add(player.getUuid())) return; + player.sendTitlePart(TitlePart.TIMES, TIMES); + } + + /** + * Builds the component for every stage. + *

+ * The shadow is switched off explicitly: with it, Minecraft renders the whole vignette a + * second time, offset by a pixel, underneath itself. + *

+ * + * @return the prepared components, indexed by {@code stage - 1} + */ + private static Component[] buildGlyphs() { + Component[] glyphs = new Component[TunnelVisionStage.MAX_STAGE]; + for (int stage = 1; stage <= TunnelVisionStage.MAX_STAGE; stage++) { + String glyph = new String(Character.toChars(FIRST_CODE_POINT + stage - 1)); + glyphs[stage - 1] = Component.text(glyph) + .font(FONT) + .shadowColor(ShadowColor.none()); + } + return glyphs; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java new file mode 100644 index 00000000..89f28a3a --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java @@ -0,0 +1,119 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; + +/** + * Turns the two sources of dread — a draining stamina bar and an approaching slender — into a + * single intensity in {@code [0, 1]} that drives how far the survivor's view narrows. + *

+ * The calculation is deliberately free of any server state so it can be exercised without a + * running instance. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class TunnelVisionIntensity { + + /** Share of the stamina bar below which the view starts to narrow. */ + private static final double STAMINA_THRESHOLD = 0.5D; + + /** Distance at which the slender starts to weigh on the survivor. */ + private static final double SLENDER_OUTER_RADIUS = 25.0D; + + /** Distance at which the slender's presence peaks. */ + private static final double SLENDER_INNER_RADIUS = 6.0D; + + /** Weight of the slender's presence while he is out of sight. */ + private static final double VIEW_BASE = 0.6D; + + /** Additional weight granted while the survivor looks straight at him. */ + private static final double VIEW_BONUS = 1.0D - VIEW_BASE; + + /** Below this distance the direction towards the slender is no longer meaningful. */ + private static final double DIRECTION_EPSILON = 1.0E-6D; + + private TunnelVisionIntensity() { + } + + /** + * Calculates the share contributed by the survivor's stamina. + *

+ * Nothing happens above half a bar; below it the curve accelerates quadratically, so the last + * few percent feel far more dramatic than crossing the halfway mark. + *

+ * + * @param normalizedStamina the remaining stamina as a share of a full bar + * @return the intensity share in {@code [0, 1]} + */ + public static double fromStamina(double normalizedStamina) { + if (normalizedStamina >= STAMINA_THRESHOLD) return 0.0D; + double drained = (STAMINA_THRESHOLD - normalizedStamina) / STAMINA_THRESHOLD; + return clamp(drained * drained); + } + + /** + * Calculates the share contributed by the slender's presence. + *

+ * The share rises from the outer to the inner radius and is dampened while the survivor looks + * away from him — being watched is worse than being followed, but never by much. + *

+ * + * @param survivor the survivor's position, whose yaw and pitch supply the view direction + * @param slender the slender's position + * @return the intensity share in {@code [0, 1]} + */ + public static double fromSlender(Pos survivor, Pos slender) { + double distance = survivor.distance(slender); + double span = SLENDER_OUTER_RADIUS - SLENDER_INNER_RADIUS; + double proximity = clamp((SLENDER_OUTER_RADIUS - distance) / span); + if (proximity == 0.0D) return 0.0D; + return proximity * viewFactor(survivor, slender, distance); + } + + /** + * Merges both shares into the intensity the renderer works with. + *

+ * The shares add up noticeably but saturate at {@code 1.0} instead of clamping hard, so + * neither source can mask the other. + *

+ * + * @param stamina the share from {@link #fromStamina(double)} + * @param slender the share from {@link #fromSlender(Pos, Pos)} + * @return the combined intensity in {@code [0, 1]} + */ + public static double combine(double stamina, double slender) { + return clamp(1.0D - (1.0D - clamp(stamina)) * (1.0D - clamp(slender))); + } + + /** + * Calculates how much the survivor's viewing direction amplifies the slender's presence. + * + * @param survivor the survivor's position including yaw and pitch + * @param slender the slender's position + * @param distance the distance between both, to avoid computing it twice + * @return the factor between {@link #VIEW_BASE} and {@code 1.0} + */ + private static double viewFactor(Pos survivor, Pos slender, double distance) { + if (distance < DIRECTION_EPSILON) return 1.0D; + Vec towardsSlender = new Vec( + slender.x() - survivor.x(), + slender.y() - survivor.y(), + slender.z() - survivor.z() + ).div(distance); + double alignment = Math.max(0.0D, survivor.direction().dot(towardsSlender)); + return VIEW_BASE + VIEW_BONUS * alignment; + } + + /** + * Restricts a value to the {@code [0, 1]} range the whole calculation operates in. + * + * @param value the value to restrict + * @return the restricted value + */ + private static double clamp(double value) { + return Math.min(1.0D, Math.max(0.0D, value)); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionRenderer.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionRenderer.java new file mode 100644 index 00000000..1e9a4955 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionRenderer.java @@ -0,0 +1,35 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.minestom.server.entity.Player; + +/** + * Displays a tunnel vision stage to a survivor. + *

+ * This is the seam between the game logic and the way the effect reaches the screen. Minecraft + * 26.2 offers no per-player post-processing effect, so the only implementation today draws the + * vignette as a HUD overlay. Once {@code /posteffect} is available a second implementation can + * take its place without the calculation or the service noticing. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public interface TunnelVisionRenderer { + + /** + * Shows the given stage to the player. + * + * @param player the player to draw for + * @param stage the stage between {@code 0} and {@link TunnelVisionStage#MAX_STAGE}, where + * {@code 0} means no overlay + */ + void render(Player player, int stage); + + /** + * Removes the overlay from the player's screen. + * + * @param player the player to clear + */ + void clear(Player player); +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java new file mode 100644 index 00000000..fed5fe07 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java @@ -0,0 +1,170 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.minestom.server.MinecraftServer; +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.minestom.server.timer.Task; +import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.event.GameStartEvent; +import org.jetbrains.annotations.Nullable; + +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; +import java.util.function.ToDoubleFunction; + +/** + * Drives the tunnel vision of every survivor from a single repeating task. + *

+ * One task rather than one per player, as {@code StaminaBar} does it: the slender's position is + * read once per tick instead of once per survivor, and there is a single place to clean up. + *

+ *

+ * Both inputs arrive as functions rather than as services. The stamina share only needs a number, + * and the slender may be absent at any moment, so neither dependency has to be a live object the + * service keeps in sync. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class TunnelVisionService { + + private final TunnelVisionRenderer renderer; + private final ToDoubleFunction stamina; + private final Supplier<@Nullable Player> slender; + private final Map survivors; + + private @Nullable Task task; + + /** + * Creates a new service. + * + * @param renderer the renderer that puts a stage on the screen + * @param stamina supplies a survivor's remaining stamina as a share of a full bar + * @param slender supplies the current slender, or {@code null} while there is none + */ + public TunnelVisionService( + TunnelVisionRenderer renderer, + ToDoubleFunction stamina, + Supplier<@Nullable Player> slender + ) { + this.renderer = renderer; + this.stamina = stamina; + this.slender = slender; + this.survivors = new LinkedHashMap<>(); + } + + /** + * Registers the given survivors and starts the update task if it is not already running. + * + * @param survivors the survivors to draw for + */ + public void start(Set survivors) { + for (Player survivor : survivors) { + this.survivors.put(survivor.getUuid(), new Tracked(survivor, new TunnelVisionStage())); + } + + if (this.task != null) return; + this.task = MinecraftServer.getSchedulerManager() + .buildTask(this::tick) + .repeat(TunnelVisionStage.TICK_MILLIS, ChronoUnit.MILLIS) + .schedule(); + } + + /** + * Hooks the service into the round's lifecycle. + *

+ * The service listens for itself rather than being called from {@code GameStartListener} and + * friends: it needs nothing from them beyond the moment, and keeping the wiring here leaves + * their signatures alone. + *

+ * + * @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.start(survivors.get())); + node.addListener(PlayerDeathEvent.class, event -> this.remove(event.getPlayer())); + node.addListener(PlayerDisconnectEvent.class, event -> this.remove(event.getPlayer())); + node.addListener(GameFinishEvent.class, event -> this.cleanUp()); + } + + /** + * Stops drawing for a survivor and clears whatever is still on their screen — on death, on + * the way into the spectator team, or on quit. + * + * @param player the survivor to drop + */ + public void remove(Player player) { + if (this.survivors.remove(player.getUuid()) == null) return; + this.renderer.clear(player); + } + + /** + * Clears every survivor's screen and stops the update task. + */ + public void cleanUp() { + for (Tracked tracked : this.survivors.values()) { + this.renderer.clear(tracked.player()); + } + this.survivors.clear(); + + if (this.task == null) return; + this.task.cancel(); + this.task = null; + } + + /** + * Updates every tracked survivor once. + */ + void tick() { + if (this.survivors.isEmpty()) return; + + Player currentSlender = this.slender.get(); + for (Tracked tracked : this.survivors.values()) { + Player survivor = tracked.player(); + double staminaShare = TunnelVisionIntensity.fromStamina(this.stamina.applyAsDouble(survivor)); + double slenderShare = this.slenderShare(survivor, currentSlender); + double combined = TunnelVisionIntensity.combine(staminaShare, slenderShare); + this.renderer.render(survivor, tracked.stage().update(combined)); + } + } + + /** + * Calculates the slender's share for one survivor. + *

+ * A slender who is absent or somewhere else entirely weighs nothing — distance across + * instances is meaningless. + *

+ * + * @param survivor the survivor to calculate for + * @param slender the current slender, may be {@code null} + * @return the share in {@code [0, 1]} + */ + private double slenderShare(Player survivor, @Nullable Player slender) { + if (slender == null) return 0.0D; + + Instance instance = slender.getInstance(); + if (instance == null || !instance.equals(survivor.getInstance())) return 0.0D; + + return TunnelVisionIntensity.fromSlender(survivor.getPosition(), slender.getPosition()); + } + + /** + * Pairs a survivor with the overlay state that belongs to them. + * + * @param player the survivor + * @param stage their stage state, carrying hysteresis and heartbeat + */ + private record Tracked(Player player, TunnelVisionStage stage) { + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java new file mode 100644 index 00000000..7116784d --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java @@ -0,0 +1,77 @@ +package net.onelitefeather.cygnus.tunnelvision; + +/** + * Holds the overlay state of a single survivor: which of the discrete stages is currently shown, + * and where the heartbeat that modulates it stands. + *

+ * Two mechanisms sit between the continuous intensity and the rendered stage. Hysteresis keeps the + * quantised base stage still while distance and stamina jitter around a boundary, and the pulse is + * added on top of the stabilised value — reversed, the hysteresis would damp out exactly the + * pulsing it exists to allow. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class TunnelVisionStage { + + /** + * Number of stages the overlay is quantised to; stage {@code 0} means no overlay. + *

+ * These double as the frames of the heartbeat: Minecraft cannot animate a font texture, so the + * animation is the server walking through the stages. Sixteen of them make that walk read as + * motion rather than as steps. + *

+ */ + public static final int MAX_STAGE = 16; + + /** Interval the service updates at, which is also the sampling rate of the heartbeat. */ + public static final int TICK_MILLIS = 100; + + /** Distance in stages the intensity has to travel before the base stage follows. */ + private static final double HYSTERESIS = 0.6D; + + /** + * Depth of the heartbeat in stages at full intensity, as a fraction of the whole scale so it + * stays equally visible whatever {@link #MAX_STAGE} is. + */ + private static final double PULSE_DEPTH = MAX_STAGE / 16.0D; + + /** Heartbeat frequency in hertz while the survivor is barely threatened. */ + private static final double BASE_FREQUENCY = 1.0D; + + /** Additional heartbeat frequency in hertz at full intensity. */ + private static final double FREQUENCY_GAIN = 1.5D; + + private static final double TICK_SECONDS = TICK_MILLIS / 1000.0D; + + /** Negative until the first update, so the first intensity is adopted without hysteresis. */ + private int baseStage = -1; + + private double elapsedSeconds; + + /** + * Advances the heartbeat by one tick and reports the stage to render. + * + * @param combined the combined intensity from {@link TunnelVisionIntensity} + * @return the stage to render, between {@code 0} and {@link #MAX_STAGE} + */ + public int update(double combined) { + double exactStage = combined * MAX_STAGE; + if (this.baseStage < 0 || Math.abs(exactStage - this.baseStage) > HYSTERESIS) { + this.baseStage = (int) Math.round(exactStage); + } + + this.elapsedSeconds += TICK_SECONDS; + double frequency = BASE_FREQUENCY + FREQUENCY_GAIN * combined; + double depth = PULSE_DEPTH * combined; + // The heartbeat only ever opens the view up, never beyond the base stage: at full + // intensity the base stage is the maximum, and a symmetric pulse would be clipped away + // exactly where it matters most. + double pulse = depth * (Math.sin(2.0D * Math.PI * frequency * this.elapsedSeconds) - 1.0D); + + int rendered = (int) Math.round(this.baseStage + pulse); + return Math.min(MAX_STAGE, Math.max(0, rendered)); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java new file mode 100644 index 00000000..f6ea8e77 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.onelitefeather.cygnus.tunnelvision; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java b/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java new file mode 100644 index 00000000..47b1ee6c --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java @@ -0,0 +1,111 @@ +package net.onelitefeather.cygnus.command; + +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.minestom.server.MinecraftServer; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.network.packet.server.play.SetTitleTextPacket; +import net.minestom.testing.Collector; +import net.minestom.testing.Env; +import net.minestom.testing.TestConnection; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.tunnelvision.TitleTunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionStage; +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 the command used to eyeball the vignette while the round has not started yet. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class TunnelVisionCommandTest extends CygnusPlayerTestBase { + + /** First glyph of the pack font; stage 1 lives here, the rest follows consecutively. */ + private static final int FIRST_CODE_POINT = 0xE000; + + @Test + @DisplayName("A requested stage is drawn right away") + void stageIsDrawnOnRequest(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + Collector collector = connection.trackIncoming(SetTitleTextPacket.class); + register(); + + MinecraftServer.getCommandManager().execute(player, "tunnelvision stage 5"); + + collector.assertSingle(packet -> assertEquals( + glyphOf(5), + plain(packet), + "the command must draw the requested stage" + )); + } + + @Test + @DisplayName("Switching the preview off clears the screen") + void offClearsTheScreen(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + Collector collector = connection.trackIncoming(SetTitleTextPacket.class); + register(); + + MinecraftServer.getCommandManager().execute(player, "tunnelvision off"); + + collector.assertSingle(packet -> assertTrue(plain(packet).isEmpty(), "the preview must disappear")); + } + + @Test + @DisplayName("A previewed intensity starts at its stage") + void intensityStartsDrawing(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + Collector collector = connection.trackIncoming(SetTitleTextPacket.class); + register(); + + MinecraftServer.getCommandManager().execute(player, "tunnelvision intensity 1.0"); + + collector.assertSingle(packet -> assertEquals( + glyphOf(TunnelVisionStage.MAX_STAGE), + plain(packet), + "full intensity starts at the tightest stage" + )); + } + + /** + * Registers the command under test. The environment is shared across the tests in this class, + * so a second registration would be rejected. + */ + private void register() { + if (MinecraftServer.getCommandManager().getCommand("tunnelvision") != null) return; + MinecraftServer.getCommandManager().register(new TunnelVisionCommand(new TitleTunnelVisionRenderer())); + } + + /** + * Reads the bare text out of a title packet. + * + * @param packet the packet to read + * @return the plain text + */ + private String plain(SetTitleTextPacket packet) { + return PlainTextComponentSerializer.plainText().serialize(packet.title()); + } + + /** + * Builds the glyph expected for a stage. + * + * @param stage the stage + * @return the glyph as a string + */ + private String glyphOf(int stage) { + return new String(Character.toChars(FIRST_CODE_POINT + stage - 1)); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java new file mode 100644 index 00000000..1fe45c52 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java @@ -0,0 +1,32 @@ +package net.onelitefeather.cygnus.stamina; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.player.CygnusPlayer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies the stamina share other systems read off the survivor's bar. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class FoodBarTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("A fresh bar reports a full share") + void freshBarIsFull(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createConnection().connect(instance, new Pos(0, 40, 0)); + FoodBar bar = (FoodBar) StaminaFactory.createFoodStamina((CygnusPlayer) player); + + assertEquals(1.0f, bar.remainingShare(), 1.0E-6f); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderSpectatorVisibilityIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderSpectatorVisibilityIntegrationTest.java new file mode 100644 index 00000000..c8b6b086 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderSpectatorVisibilityIntegrationTest.java @@ -0,0 +1,223 @@ +package net.onelitefeather.cygnus.stamina; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.GameMode; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.TestConnection; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.common.Tags; +import net.onelitefeather.cygnus.common.config.GameConfig; +import net.onelitefeather.cygnus.player.CygnusPlayer; +import net.onelitefeather.cygnus.utils.ViewRuleUpdater; +import net.theevilreaper.xerus.api.team.Team; +import org.jetbrains.annotations.NotNull; +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 how the spectator system introduced in 2.7.0 interacts with the slender + * visibility rule. + *

+ * A dead survivor leaves the survivor team, so + * {@link ViewRuleUpdater#updateViewer(Player, Team)} no longer reaches it through the team + * iteration. It is still covered by the online-player pass and by the slender's own + * re-evaluation, so a spectator must keep following the slender's visibility in both + * directions — it must see the slender during an attack and lose sight of it afterwards. + *

+ * Independently, {@code SpectatorService.join} installs {@code _ -> false} on the + * spectator itself, which must keep it invisible to everybody else. + * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.7.0 + */ +class SlenderSpectatorVisibilityIntegrationTest extends CygnusPlayerTestBase { + + /** + * Builds a survivor team holding the given players. + * + * @param survivors the players to put into the team + * @return the populated survivor team + */ + private Team createSurvivorTeam(Player @NotNull ... survivors) { + Team team = Team.of(GameConfig.SURVIVOR_KEY, 10); + for (Player survivor : survivors) { + team.addPlayer(survivor); + } + return team; + } + + /** + * Replays {@code TeamHelper.assignSlender} plus the round start tagging, ending in the + * intended state: rule installed, slender hidden. + * + * @param slender the player acting as the slender + * @param survivorTeam the survivor team + */ + private void startRound(@NotNull Player slender, @NotNull Team survivorTeam) { + slender.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + slender.updateViewableRule(_ -> !ViewRuleUpdater.isHidden(slender)); + survivorTeam.getPlayers().forEach(survivor -> { + survivor.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + survivor.setTag(Tags.HIDDEN, SlenderBarHelper.VISIBLE); + }); + slender.setTag(Tags.HIDDEN, SlenderBarHelper.HIDDEN); + slender.updateViewableRule(); + } + + /** + * Replays an eye press: {@code SlenderBarTrigger.trigger} plus the real + * {@link ViewRuleUpdater#updateViewer(Player, Team)}. + * + * @param bar the slender bar to toggle + * @param slender the player acting as the slender + * @param survivorTeam the survivor team handed to the view rule updater + */ + private void pressEye(@NotNull SlenderBar bar, @NotNull Player slender, @NotNull Team survivorTeam) { + if (!bar.changeStatus()) return; + Byte value = slender.getTag(Tags.HIDDEN); + byte current = value != null ? value : SlenderBarHelper.VISIBLE; + slender.setTag(Tags.HIDDEN, current == SlenderBarHelper.VISIBLE + ? SlenderBarHelper.HIDDEN : SlenderBarHelper.VISIBLE); + ViewRuleUpdater.updateViewer(slender, survivorTeam); + } + + /** + * Replays {@code PlayerDeathListener} followed by {@code SpectatorService.join}: the + * player leaves the survivor team, loses its team key, and gains the spectator key + * plus its own view rule. + * + * @param player the dying player + * @param survivorTeam the team the player leaves + */ + private void die(@NotNull Player player, @NotNull Team survivorTeam) { + // PlayerDeathListener + survivorTeam.removePlayer(player); + player.removeTag(Tags.TEAM_KEY); + // SpectatorService.join + player.setGameMode(GameMode.SPECTATOR); + player.setTag(Tags.TEAM_KEY, GameConfig.SPECTATOR_KEY); + player.updateViewableRule(_ -> false); + } + + /** + * Dying while the slender is invisible must not lock the spectator out of ever seeing + * the slender again — otherwise spectating is pointless. + */ + @Test + @DisplayName("Spectator sieht den Slender im Angriffsmodus") + void testSpectatorSeesSlenderDuringAttack(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection deadConnection = env.createConnection(); + TestConnection aliveConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player dying = deadConnection.connect(instance, new Pos(5, 40, 5)); + Player alive = aliveConnection.connect(instance, new Pos(-5, 40, -5)); + Team survivorTeam = createSurvivorTeam(dying, alive); + startRound(slender, survivorTeam); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + + // Der Survivor stirbt, bevor der Slender je sichtbar war. + die(dying, survivorTeam); + env.tick(); + + // Jetzt der Angriffsmodus - der Spectator muss den Slender sehen. + pressEye(slenderBar, slender, survivorTeam); + env.tick(); + + assertTrue(slender.isViewer(alive), + "Kontrolle: der lebende Survivor sieht den Slender im Angriffsmodus"); + assertTrue(slender.isViewer(dying), + "Ein Spectator muss den Slender im Angriffsmodus sehen - " + + "sonst schaut er einem unsichtbaren Spiel zu"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } + + /** + * Mirror case: a spectator that saw the slender during an attack must lose sight of it + * again when the slender goes back to hidden, otherwise the slender's position is + * leaked to a dead player. + */ + @Test + @DisplayName("Manueller Doppeldruck traegt auch den Spectator aus") + void testManualToggleAlsoUnregistersSpectator(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection deadConnection = env.createConnection(); + TestConnection aliveConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player dying = deadConnection.connect(instance, new Pos(5, 40, 5)); + Player alive = aliveConnection.connect(instance, new Pos(-5, 40, -5)); + Team survivorTeam = createSurvivorTeam(dying, alive); + startRound(slender, survivorTeam); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + + // Augendruck: Slender wird sichtbar. + pressEye(slenderBar, slender, survivorTeam); + env.tick(); + assertTrue(slender.isViewer(dying), "Vorbedingung: Slender muss sichtbar sein"); + + // Der Survivor stirbt, waehrend der Slender sichtbar ist. + die(dying, survivorTeam); + env.tick(); + + // Manueller Doppeldruck zurueck in den Regenerationsmodus. + pressEye(slenderBar, slender, survivorTeam); + env.tick(); + + assertFalse(slender.isViewer(alive), + "Kontrolle: fuer den lebenden Survivor raeumt der manuelle Pfad korrekt auf"); + assertFalse(slender.isViewer(dying), + "Auch der Spectator darf nach dem Zurueckschalten kein Viewer mehr sein - " + + "sonst verraet der unsichtbare Slender einem Toten seine Position"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } + + /** + * {@code SpectatorService.join} installs {@code _ -> false} on the spectator, so nobody + * may keep it as a registered viewer. + */ + @Test + @DisplayName("Spectator ist fuer andere unsichtbar") + void testSpectatorIsHiddenFromOthers(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection deadConnection = env.createConnection(); + TestConnection aliveConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player dying = deadConnection.connect(instance, new Pos(5, 40, 5)); + Player alive = aliveConnection.connect(instance, new Pos(-5, 40, -5)); + Team survivorTeam = createSurvivorTeam(dying, alive); + startRound(slender, survivorTeam); + env.tick(); + + assertTrue(dying.isViewer(alive), "Vorbedingung: lebende Survivor sehen sich gegenseitig"); + + die(dying, survivorTeam); + env.tick(); + + assertFalse(dying.isViewer(alive), + "Nach dem Wechsel in den Spectator-Modus darf ihn kein Survivor mehr sehen"); + assertFalse(dying.isViewer(slender), + "Auch der Slender darf den Spectator nicht mehr sehen"); + + env.destroyInstance(instance, true); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderVisibilityIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderVisibilityIntegrationTest.java new file mode 100644 index 00000000..9d9be1b9 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderVisibilityIntegrationTest.java @@ -0,0 +1,301 @@ +package net.onelitefeather.cygnus.stamina; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.TestConnection; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.common.Tags; +import net.onelitefeather.cygnus.common.config.GameConfig; +import net.onelitefeather.cygnus.player.CygnusPlayer; +import net.onelitefeather.cygnus.utils.ViewRuleUpdater; +import net.theevilreaper.xerus.api.team.Team; +import org.jetbrains.annotations.NotNull; +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.assertTrue; + +/** + * Verifies that the slender's server-side viewer registration stays in sync with the + * visual state across all transitions of the {@link SlenderBar}. + *

+ * The viewable rule installed by {@code TeamHelper.assignSlender} reads + * {@link ViewRuleUpdater#isHidden(Player)} of the slender and ignores the + * candidate viewer, so a transition only takes effect once someone re-evaluates the + * rule via {@link ViewRuleUpdater#updateViewer(Player, Team)}. These tests check that + * every exit out of {@code DRAINING} does exactly that. + * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.4.0 + */ +class SlenderVisibilityIntegrationTest extends CygnusPlayerTestBase { + + /** Ticks to run the bar dry: MAX_TIME 16 / TIME_STEP 0.5. */ + private static final int TICKS_TO_TIMEOUT = 34; + + /** + * Builds a survivor team holding the given players, mirroring the team the production + * code hands to {@link ViewRuleUpdater#updateViewer(Player, Team)}. + * + * @param survivors the players to put into the team + * @return the populated survivor team + */ + private Team createSurvivorTeam(Player @NotNull ... survivors) { + Team team = Team.of(GameConfig.SURVIVOR_KEY, 10); + for (Player survivor : survivors) { + team.addPlayer(survivor); + } + return team; + } + + /** + * Replays {@code TeamHelper.assignSlender} followed by + * {@code GameStartListener.handleSlenderStart} / {@code handleSurvivorStart}. + *

+ * The closing {@code updateViewableRule()} is not part of the production + * path — production sets the tag without re-evaluating. It is added here so the + * remaining tests start from the intended state; the missing call itself is covered + * by {@link #testRoundStartHidesSlender(Env)}. + * + * @param slender the player acting as the slender + * @param survivorTeam the survivor team + */ + private void startRound(@NotNull Player slender, @NotNull Team survivorTeam) { + assignSlender(slender); + survivorTeam.getPlayers().forEach(survivor -> { + survivor.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + survivor.setTag(Tags.HIDDEN, SlenderBarHelper.VISIBLE); + }); + slender.setTag(Tags.HIDDEN, SlenderBarHelper.HIDDEN); + slender.updateViewableRule(); + } + + /** + * Replays {@code TeamHelper.assignSlender} verbatim, including the order in which the + * rule is installed relative to the {@link Tags#HIDDEN} tag. + * + * @param slender the player to become the slender + */ + private void assignSlender(@NotNull Player slender) { + slender.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + slender.updateViewableRule(_ -> !ViewRuleUpdater.isHidden(slender)); + } + + /** + * Replays an eye press: {@code SlenderBarTrigger.trigger}, i.e. changeStatus plus + * changeVisibilityStatus plus the real {@link ViewRuleUpdater#updateViewer}. + * + * @param bar the slender bar to toggle + * @param slender the player acting as the slender + * @param survivorTeam the survivor team handed to the view rule updater + */ + private void pressEye(@NotNull SlenderBar bar, @NotNull Player slender, @NotNull Team survivorTeam) { + if (!bar.changeStatus()) return; + // SlenderBarTrigger.changeVisibilityStatus toggles the tag on the slender itself. + Byte value = slender.getTag(Tags.HIDDEN); + byte current = value != null ? value : SlenderBarHelper.VISIBLE; + slender.setTag(Tags.HIDDEN, current == SlenderBarHelper.VISIBLE + ? SlenderBarHelper.HIDDEN : SlenderBarHelper.VISIBLE); + ViewRuleUpdater.updateViewer(slender, survivorTeam); + } + + /** + * Sanity check: without this the remaining assertions could pass vacuously. + * The eye press must make the slender a registered viewer of the survivor. + */ + @Test + @DisplayName("Vorbedingung: Augendruck macht den Slender sichtbar") + void testEyePressMakesSlenderVisible(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + Team survivorTeam = createSurvivorTeam(survivor); + startRound(slender, survivorTeam); + + assertFalse(slender.isViewer(survivor), "Vor dem Augendruck darf niemand den Slender sehen"); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + pressEye(slenderBar, slender, survivorTeam); + env.tick(); + + assertTrue(slender.isViewer(survivor), + "Nach dem Augendruck muss der Survivor registrierter Viewer sein"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } + + /** + * The round start installs the rule before the HIDDEN tag exists, so the rule + * evaluates to visible and registers every nearby player. Setting the tag afterwards + * without re-evaluating leaves that registration in place. + */ + @Test + @DisplayName("Rundenstart laesst den Slender nicht registriert zurueck") + void testRoundStartHidesSlender(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + + // Exact production order: TeamHelper.assignSlender, then GameStartListener. + assignSlender(slender); + survivor.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + slender.setTag(Tags.HIDDEN, SlenderBarHelper.HIDDEN); + survivor.setTag(Tags.HIDDEN, SlenderBarHelper.VISIBLE); + env.tick(); + + assertFalse(slender.isViewer(survivor), + "Zu Rundenbeginn darf der Slender fuer keinen Survivor registriert sein - " + + "die Regel wird installiert, bevor der HIDDEN-Tag existiert, und " + + "danach nicht erneut ausgewertet"); + + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("Auto-Timeout traegt den Viewer serverseitig aus") + void testAutoTimeoutUnregistersViewer(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + Team survivorTeam = createSurvivorTeam(survivor); + startRound(slender, survivorTeam); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + + pressEye(slenderBar, slender, survivorTeam); + env.tick(); + assertTrue(slender.isViewer(survivor), "Vorbedingung: Slender muss sichtbar sein"); + + // Bar auslaufen lassen - der Pfad, der den Trigger umgeht. + for (int i = 0; i < TICKS_TO_TIMEOUT; i++) { + slenderBar.consume(); + } + env.tick(); + + assertFalse(slender.isViewer(survivor), + "Nach dem Auto-Timeout darf der Survivor kein registrierter Viewer mehr sein - " + + "sonst wird der Slender beim Wiedereintritt in die View-Distance neu gespawnt"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("Manueller Doppeldruck bleibt korrekt") + void testManualToggleStaysCorrect(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + Team survivorTeam = createSurvivorTeam(survivor); + startRound(slender, survivorTeam); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + + pressEye(slenderBar, slender, survivorTeam); // READY -> DRAINING + env.tick(); + pressEye(slenderBar, slender, survivorTeam); // DRAINING -> REGENERATING + env.tick(); + + assertFalse(slender.isViewer(survivor), + "Nach dem manuellen Zurueckschalten darf kein Viewer registriert bleiben"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("Polaritaet bleibt ueber mehrere Zyklen stabil") + void testPolarityStableAcrossCycles(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + Team survivorTeam = createSurvivorTeam(survivor); + startRound(slender, survivorTeam); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + + // Zyklus 1: Angriff, dann auslaufen lassen. + pressEye(slenderBar, slender, survivorTeam); + for (int i = 0; i < TICKS_TO_TIMEOUT; i++) { + slenderBar.consume(); + } + env.tick(); + + // Zyklus 2: erneuter Angriff - der Slender MUSS jetzt sichtbar sein. + pressEye(slenderBar, slender, survivorTeam); + env.tick(); + + assertTrue(slender.isViewer(survivor), + "Im Angriffsmodus muss der Slender sichtbar sein - " + + "ist er es nicht, ist die Sichtbarkeits-Polaritaet verdreht"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("View-Distance-Zyklus spawnt den Slender nicht neu") + void testViewDistanceCycleDoesNotRespawn(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + Team survivorTeam = createSurvivorTeam(survivor); + startRound(slender, survivorTeam); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + + pressEye(slenderBar, slender, survivorTeam); + for (int i = 0; i < TICKS_TO_TIMEOUT; i++) { + slenderBar.consume(); + } + env.tick(); + + // Ab hier auf Spawn-Pakete horchen - VOR dem Teleport starten. + var spawnCollector = survivorConnection.trackIncoming( + net.minestom.server.network.packet.server.play.SpawnEntityPacket.class); + + // Raus: 6 Chunks weit (> ENTITY_VIEW_DISTANCE = 5). + survivor.teleport(new Pos(6 * 16, 40, 0)).join(); + env.tick(); + + // Und wieder rein. + survivor.teleport(new Pos(5, 40, 5)).join(); + env.tick(); + + assertEquals(0, spawnCollector.collect().size(), + "Beim Wiedereintritt in die View-Distance darf der unsichtbare Slender " + + "nicht neu gespawnt werden"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TitleTunnelVisionRendererTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TitleTunnelVisionRendererTest.java new file mode 100644 index 00000000..2dfc4cd8 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TitleTunnelVisionRendererTest.java @@ -0,0 +1,146 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.ShadowColor; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.network.packet.server.play.SetTitleTextPacket; +import net.minestom.server.network.packet.server.play.SetTitleTimePacket; +import net.minestom.testing.Collector; +import net.minestom.testing.Env; +import net.minestom.testing.TestConnection; +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.assertTrue; + +/** + * Verifies that the vignette reaches the client as a title carrying the pack's font. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class TitleTunnelVisionRendererTest extends CygnusPlayerTestBase { + + private final TitleTunnelVisionRenderer renderer = new TitleTunnelVisionRenderer(); + + @Test + @DisplayName("A stage is sent as its glyph in the pack font") + void stageIsSentAsGlyph(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + Collector collector = connection.trackIncoming(SetTitleTextPacket.class); + + this.renderer.render(player, 3); + + collector.assertSingle(packet -> { + Component message = packet.title(); + assertEquals(TitleTunnelVisionRenderer.FONT, message.style().font(), "the overlay must use the pack font"); + assertEquals(glyphOf(3), plain(message), "the glyph must match the stage"); + }); + } + + @Test + @DisplayName("The title is set to hold instead of fading") + void titleHoldsWithoutFading(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + Collector collector = connection.trackIncoming(SetTitleTimePacket.class); + + this.renderer.render(player, 3); + + collector.assertSingle(packet -> { + assertEquals(0, packet.fadeIn(), "a fade in would make the vignette pump on every update"); + assertEquals(0, packet.fadeOut(), "a fade out would do the same"); + assertTrue(packet.stay() > 0, "the vignette has to survive between updates"); + }); + } + + @Test + @DisplayName("The times are sent once rather than with every update") + void timesAreSentOnce(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + this.renderer.render(player, 3); + Collector collector = connection.trackIncoming(SetTitleTimePacket.class); + + this.renderer.render(player, 4); + this.renderer.render(player, 5); + + collector.assertEmpty(); + } + + @Test + @DisplayName("The overlay is drawn without a text shadow") + void overlayHasNoShadow(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + Collector collector = connection.trackIncoming(SetTitleTextPacket.class); + + this.renderer.render(player, TunnelVisionStage.MAX_STAGE); + + collector.assertSingle(packet -> assertEquals( + ShadowColor.none(), + packet.title().style().shadowColor(), + "a shadow would render the vignette a second time, offset" + )); + } + + @Test + @DisplayName("Clearing empties the title") + void clearingEmptiesTheTitle(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + Collector collector = connection.trackIncoming(SetTitleTextPacket.class); + + this.renderer.clear(player); + + collector.assertSingle(packet -> assertTrue( + plain(packet.title()).isEmpty(), + "the overlay must disappear rather than linger" + )); + } + + @Test + @DisplayName("Stage zero clears instead of drawing a glyph") + void zeroStageClears(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + Collector collector = connection.trackIncoming(SetTitleTextPacket.class); + + this.renderer.render(player, 0); + + collector.assertSingle(packet -> assertTrue(plain(packet.title()).isEmpty(), "stage zero has no glyph")); + } + + /** + * Serialises a component down to its bare text. + * + * @param component the component to serialise + * @return the plain text + */ + private String plain(Component component) { + return PlainTextComponentSerializer.plainText().serialize(component); + } + + /** + * Builds the glyph expected for a stage. + * + * @param stage the stage + * @return the glyph as a string + */ + private String glyphOf(int stage) { + return new String(Character.toChars(TitleTunnelVisionRenderer.FIRST_CODE_POINT + stage - 1)); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java new file mode 100644 index 00000000..cbf04fbf --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java @@ -0,0 +1,102 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.minestom.server.coordinate.Pos; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the intensity curves that drive the survivor's tunnel vision. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class TunnelVisionIntensityTest { + + private static final double DELTA = 1.0E-6D; + + /** Survivor standing in the origin, looking towards positive Z. */ + private static final Pos SURVIVOR = new Pos(0, 0, 0, 0, 0); + + @DisplayName("Stamina above half a bar produces no tunnel vision") + @ParameterizedTest + @CsvSource({"1.0", "0.75", "0.5"}) + void staminaAboveHalfIsCalm(double stamina) { + assertEquals(0.0D, TunnelVisionIntensity.fromStamina(stamina), DELTA); + } + + @Test + @DisplayName("An empty stamina bar produces full intensity") + void emptyStaminaIsFull() { + assertEquals(1.0D, TunnelVisionIntensity.fromStamina(0.0D), DELTA); + } + + @Test + @DisplayName("The stamina curve accelerates towards the empty bar") + void staminaCurveIsQuadratic() { + assertEquals(0.25D, TunnelVisionIntensity.fromStamina(0.25D), DELTA); + } + + @Test + @DisplayName("Draining stamina never lowers the intensity") + void staminaIsMonotonic() { + double previous = -1.0D; + for (int step = 20; step >= 0; step--) { + double current = TunnelVisionIntensity.fromStamina(step / 20.0D); + assertTrue(current >= previous, "intensity dropped at stamina " + step / 20.0D); + previous = current; + } + } + + @Test + @DisplayName("A slender beyond the outer radius stays unnoticed") + void distantSlenderIsUnnoticed() { + assertEquals(0.0D, TunnelVisionIntensity.fromSlender(SURVIVOR, new Pos(0, 0, 25)), DELTA); + } + + @Test + @DisplayName("Looking straight at a nearby slender produces full intensity") + void facingNearbySlenderIsFull() { + assertEquals(1.0D, TunnelVisionIntensity.fromSlender(SURVIVOR, new Pos(0, 0, 5)), DELTA); + } + + @Test + @DisplayName("A slender in the back is dampened by the view factor") + void slenderBehindIsDampened() { + assertEquals(0.6D, TunnelVisionIntensity.fromSlender(SURVIVOR, new Pos(0, 0, -5)), DELTA); + } + + @Test + @DisplayName("Approaching the slender never lowers the intensity") + void slenderIsMonotonic() { + double previous = -1.0D; + for (int distance = 30; distance >= 1; distance--) { + double current = TunnelVisionIntensity.fromSlender(SURVIVOR, new Pos(0, 0, distance)); + assertTrue(current >= previous, "intensity dropped at distance " + distance); + previous = current; + } + } + + @Test + @DisplayName("Without either source the combination is calm") + void combinationOfNothingIsCalm() { + assertEquals(0.0D, TunnelVisionIntensity.combine(0.0D, 0.0D), DELTA); + } + + @Test + @DisplayName("A saturated source saturates the combination") + void combinationSaturates() { + assertEquals(1.0D, TunnelVisionIntensity.combine(1.0D, 0.3D), DELTA); + } + + @Test + @DisplayName("Both sources add up without exceeding the maximum") + void combinationAddsUp() { + assertEquals(0.75D, TunnelVisionIntensity.combine(0.5D, 0.5D), DELTA); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java new file mode 100644 index 00000000..6393f47c --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java @@ -0,0 +1,248 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.kyori.adventure.text.Component; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.event.player.PlayerDeathEvent; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.event.GameStartEvent; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies how the service feeds survivors through the intensity calculation and what happens + * when a source of it is missing. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class TunnelVisionServiceTest extends CygnusPlayerTestBase { + + private static final double FULL_STAMINA = 1.0D; + private static final double NO_STAMINA = 0.0D; + + @Test + @DisplayName("An exhausted survivor sees the tightest stage even without a slender") + void exhaustedSurvivorIsFullyNarrowed(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA, () -> null); + service.start(Set.of(survivor)); + + service.tick(); + + assertEquals(TunnelVisionStage.MAX_STAGE, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A rested survivor alone in the dark sees nothing") + void restedSurvivorSeesNothing(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> FULL_STAMINA, () -> null); + service.start(Set.of(survivor)); + + service.tick(); + + assertEquals(0, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A slender standing close narrows the view of a rested survivor") + void nearbySlenderNarrowsTheView(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Instance instance = env.createFlatInstance(); + Player survivor = spawn(env, instance, new Pos(0, 40, 0)); + Player slender = spawn(env, instance, new Pos(0, 40, 5)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> FULL_STAMINA, () -> slender); + service.start(Set.of(survivor)); + + service.tick(); + + assertEquals(TunnelVisionStage.MAX_STAGE, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A slender in another instance is out of reach") + void slenderInAnotherInstanceIsIgnored(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + Player slender = spawn(env, new Pos(0, 40, 5)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> FULL_STAMINA, () -> slender); + service.start(Set.of(survivor)); + + service.tick(); + + assertEquals(0, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A removed survivor gets their screen back and is no longer drawn") + void removedSurvivorIsCleared(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA, () -> null); + service.start(Set.of(survivor)); + service.tick(); + renderer.forget(); + + service.remove(survivor); + service.tick(); + + assertTrue(renderer.wasCleared(survivor), "the last vignette would otherwise linger"); + assertNull(renderer.stageOf(survivor), "a removed survivor must not be drawn any more"); + } + + @Test + @DisplayName("Cleaning up gives every survivor their screen back") + void cleanUpClearsEveryone(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Instance instance = env.createFlatInstance(); + Player first = spawn(env, instance, new Pos(0, 40, 0)); + Player second = spawn(env, instance, new Pos(4, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA, () -> null); + service.start(Set.of(first, second)); + service.tick(); + + service.cleanUp(); + + assertTrue(renderer.wasCleared(first)); + assertTrue(renderer.wasCleared(second)); + + renderer.forget(); + service.tick(); + assertNull(renderer.stageOf(first), "cleanup must stop the drawing as well"); + } + + @Test + @DisplayName("The start of a round takes the survivors on board") + void gameStartRegistersSurvivors(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA, () -> null); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + + EventDispatcher.call(new GameStartEvent()); + service.tick(); + + assertEquals(TunnelVisionStage.MAX_STAGE, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A dying survivor gets their screen back") + void deathClearsTheOverlay(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA, () -> null); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.start(Set.of(survivor)); + service.tick(); + renderer.forget(); + + EventDispatcher.call(new PlayerDeathEvent(survivor, Component.empty(), Component.empty())); + service.tick(); + + assertTrue(renderer.wasCleared(survivor)); + assertNull(renderer.stageOf(survivor), "a dead survivor must not be drawn any more"); + } + + @Test + @DisplayName("The end of a round clears everyone") + void gameFinishCleansUp(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA, () -> null); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.start(Set.of(survivor)); + service.tick(); + renderer.forget(); + + EventDispatcher.call(new GameFinishEvent(GameFinishEvent.Reason.TIME_OVER)); + + assertTrue(renderer.wasCleared(survivor)); + } + + /** + * Spawns a player in a fresh instance. + * + * @param env the test environment + * @param position where to place the player + * @return the connected player + */ + private Player spawn(Env env, Pos position) { + return this.spawn(env, env.createFlatInstance(), position); + } + + /** + * Spawns a player in the given instance. + * + * @param env the test environment + * @param instance the instance to connect into + * @param position where to place the player + * @return the connected player + */ + private Player spawn(Env env, Instance instance, Pos position) { + return env.createConnection().connect(instance, position); + } + + /** + * Records what the service asked to be drawn, standing in for the action bar renderer. + */ + private static final class RecordingRenderer implements TunnelVisionRenderer { + + private final Map stages = new HashMap<>(); + private final Set cleared = new HashSet<>(); + + @Override + public void render(Player player, int stage) { + this.stages.put(player.getUuid(), stage); + } + + @Override + public void clear(Player player) { + this.cleared.add(player.getUuid()); + this.stages.remove(player.getUuid()); + } + + /** + * @param player the player to look up + * @return the stage last drawn for the player, or {@code null} if nothing was drawn + */ + private @Nullable Integer stageOf(Player player) { + return this.stages.get(player.getUuid()); + } + + /** + * @param player the player to look up + * @return whether the player's overlay was cleared + */ + private boolean wasCleared(Player player) { + return this.cleared.contains(player.getUuid()); + } + + /** + * Drops everything recorded so far, to tell repeated draws apart. + */ + private void forget() { + this.stages.clear(); + this.cleared.clear(); + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java new file mode 100644 index 00000000..b3a08b67 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java @@ -0,0 +1,96 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies how a continuous intensity becomes the discrete, pulsing stage the overlay renders. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class TunnelVisionStageTest { + + /** Enough updates to cover several periods of the slowest heartbeat. */ + private static final int SAMPLES = 60; + + @Test + @DisplayName("Without any threat the overlay stays off") + void calmIntensityStaysOff() { + TunnelVisionStage stage = new TunnelVisionStage(); + assertEquals(0, stage.update(0.0D)); + } + + @Test + @DisplayName("Full intensity pulses across the top of the scale") + void fullIntensityPulses() { + TunnelVisionStage stage = new TunnelVisionStage(); + int lowest = TunnelVisionStage.MAX_STAGE; + int highest = 0; + for (int sample = 0; sample < SAMPLES; sample++) { + int current = stage.update(1.0D); + lowest = Math.min(lowest, current); + highest = Math.max(highest, current); + } + assertEquals(TunnelVisionStage.MAX_STAGE, highest, "the pulse never reaches the peak"); + assertEquals(TunnelVisionStage.MAX_STAGE - 2, lowest, "the pulse does not open up again"); + } + + @Test + @DisplayName("Low intensity barely pulses at all") + void lowIntensityIsSteady() { + TunnelVisionStage stage = new TunnelVisionStage(); + int first = stage.update(0.125D); + for (int sample = 0; sample < SAMPLES; sample++) { + assertEquals(first, stage.update(0.125D), "a barely threatened survivor should not flicker"); + } + } + + @Test + @DisplayName("A small fluctuation does not move the stage") + void hysteresisHoldsTheStage() { + TunnelVisionStage stage = new TunnelVisionStage(); + int settled = highestOver(stage, 0.5D); + assertEquals(TunnelVisionStage.MAX_STAGE / 2, settled, "half intensity should settle on the middle stage"); + assertEquals(settled, highestOver(stage, 0.52D), "the stage moved on a small fluctuation"); + } + + @Test + @DisplayName("A real change moves the stage") + void largerChangeMovesTheStage() { + TunnelVisionStage stage = new TunnelVisionStage(); + assertEquals(TunnelVisionStage.MAX_STAGE / 2, highestOver(stage, 0.5D)); + assertEquals(TunnelVisionStage.MAX_STAGE / 2 + 1, highestOver(stage, 0.56D), + "the stage should follow a real change"); + } + + @Test + @DisplayName("The stage never leaves its bounds") + void stageStaysWithinBounds() { + TunnelVisionStage stage = new TunnelVisionStage(); + for (int sample = 0; sample < SAMPLES; sample++) { + int current = stage.update(sample % 2 == 0 ? 1.0D : 0.0D); + assertTrue(current >= 0 && current <= TunnelVisionStage.MAX_STAGE, "stage out of bounds: " + current); + } + } + + /** + * Feeds a constant intensity for a while and reports the highest stage seen, which is the + * stage the pulse starts from. + * + * @param stage the stage state to drive + * @param combined the constant intensity to feed + * @return the highest stage observed + */ + private int highestOver(TunnelVisionStage stage, double combined) { + int highest = 0; + for (int sample = 0; sample < SAMPLES; sample++) { + highest = Math.max(highest, stage.update(combined)); + } + return highest; + } +}