From e63881b00e7ba5f205d7e0c626d05d6a9b678602 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 12 Aug 2026 09:25:21 +0200 Subject: [PATCH 1/5] feat(tunnel-vision): add survivor tunnel vision effect Minecraft 26.2 has no way to switch a post-processing shader on for a single player, so the narrowing view is delivered as a camera_overlay rendered onto a head-slot item instead. The renderer drives 32 stages with hysteresis so the overlay does not flicker between adjacent stages, and layers a heartbeat pulse on top once stamina runs low. FoodBar gains a remainingShare() getter so the tunnel vision service can read a survivor's stamina without coupling to the experience-bar display detail. /tunnelvision lets a designer freeze a stage or run the heartbeat from the lobby to judge the resource pack's glyph sizes without starting a round. The vignette textures themselves ship separately in cygnus-pack; the overlay stays off wherever OverlayProperties reports the pack is not delivered, since without it survivors would stare at an empty box. --- .../specs/2026-08-10-tunnel-vision-design.md | 209 ++++++++++++++++ .../net/onelitefeather/cygnus/Cygnus.java | 22 ++ .../cygnus/command/TunnelVisionCommand.java | 106 ++++++++ .../cygnus/stamina/FoodBar.java | 13 + .../OverlayTunnelVisionRenderer.java | 61 +++++ .../tunnelvision/TunnelVisionIntensity.java | 45 ++++ .../tunnelvision/TunnelVisionRenderer.java | 35 +++ .../tunnelvision/TunnelVisionService.java | 161 ++++++++++++ .../tunnelvision/TunnelVisionStage.java | 79 ++++++ .../cygnus/tunnelvision/package-info.java | 4 + .../command/TunnelVisionCommandTest.java | 110 ++++++++ .../cygnus/stamina/FoodBarTest.java | 32 +++ .../OverlayTunnelVisionRendererTest.java | 134 ++++++++++ .../TunnelVisionIntensityTest.java | 51 ++++ .../tunnelvision/TunnelVisionServiceTest.java | 235 ++++++++++++++++++ .../tunnelvision/TunnelVisionStageTest.java | 100 ++++++++ 16 files changed, 1397 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-10-tunnel-vision-design.md create mode 100644 game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRenderer.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionRenderer.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java 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..4f6a0f32 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md @@ -0,0 +1,209 @@ +# Tunnel vision for survivors + +## Goal + +A survivor's view narrows as the situation gets worse: the screen edges darken and pulse like a +heartbeat when stamina runs low, when the Slender closes in, or both. The effect is per player, +continuous rather than on/off, and driven entirely by the server. + +## Why not a shader + +The obvious implementation is a post-processing shader, and on Minecraft 26.2 it does not work. + +A resource-pack post effect only runs in contexts vanilla decides: the menu blur, spectator mob +vision, the glowing outline, and the "Improved Transparency" video setting. None of them can be +switched on for one player from the server, and none carries an intensity parameter. The only way +to force one on 26.2 is hijacking spectator mob vision by pointing the player's camera at a hidden +enderman, which takes over the camera and makes the game unplayable. + +That changes in 26.3: snapshot 3 (7 July 2026) added `/posteffect add|remove ` +plus the always-on `minecraft:end_of_frame` context. 26.3 is still in snapshots, and Minestom +ships 26.2 (`net.minestom:minestom:2026.07.22-26.2`). + +So the effect is rendered as the `camera_overlay` of an item worn on the head — the mechanism +behind the carved pumpkin, and the one thing in vanilla that draws a texture across the whole +screen and scales it with the viewport. Behind an interface that a post-effect renderer can slot +into once 26.3 and Minestom support land; the gameplay side does not change when that happens. + +Reference: [Shader – Minecraft Wiki](https://minecraft.wiki/w/Shader), +[Java Edition 26.3 Snapshot 3](https://minecraft.wiki/w/Java_Edition_26.3_Snapshot_3). + +## Intensity + +`TunnelVisionIntensity` turns 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 an overlay texture — `.mcmeta` animation covers block, item, particle, +painting and effect textures only — 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/equipment/empty.json +``` + +Each texture is 1024×576 — 16:9, because the client stretches a camera overlay across the screen +rather than fitting it. The darkening closes in from all four edges rather than as a circle from +the middle: it is a superellipse whose exponent eases from 4 at stage 1, a rounded rectangle +framing the screen, to 2 at stage 16, where a plain ellipse reads as a tunnel. Textures are +generated by `tools/generate_overlay.py`, which also produces the blood splatter. + +**How it reaches the screen.** The server puts an item in the player's head slot carrying +`equippable{slot:head, camera_overlay:"cygnus:gui/tunnel_vision/stage_N"}`. Three details keep the +carrier out of the way: + +- `asset_id` points at `cygnus:empty`, an equipment model with no layers. Without it Minecraft + draws the item itself on the player's head. +- `swappable`, `dispensable` and `damage_on_hurt` are all off, so nobody strips the overlay by + accident and it is not treated as armour. +- The equip sound is `minecraft:intentionally_empty`; the default would click on every stage + change, ten times a second. + +**The position needs no calibration.** This is the whole reason for the mechanism: the client +scales the overlay to the viewport, so it fits every resolution and GUI scale on its own. A font +glyph cannot — its size is fixed in the pack, so it has to be calibrated against one resolution +and drifts on every other. + +## Components + +New package `net.onelitefeather.cygnus.tunnelvision`: + +- `TunnelVisionIntensity` — the calculation above. Pure, no server needed to test it. +- `TunnelVisionStage` — one survivor's overlay state: hysteresis and heartbeat. Also pure. +- `TunnelVisionRenderer` — `render(player, stage)` and `clear(player)`. This is the seam a + post-effect renderer slots into on 26.3. +- `OverlayTunnelVisionRenderer` — the implementation described above; it contributes a texture to + the shared `ScreenOverlay` rather than dressing the player itself. +- `TunnelVisionService` — holds a `TunnelVisionStage` per survivor and ticks all of them in one + scheduler task. +- `TunnelVisionCommand` — `/tunnelvision stage <0-16> | intensity <0.0-1.0> | off`, for judging the + vignette from the lobby without a running round. `stage` freezes one stage to judge the drawing; + `intensity` runs the real heartbeat. + +One task for everyone rather than one per player as `StaminaBar` does: 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 + textures do not exist and players would get a fullscreen missing-texture checkerboard. + +## 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 | the layer is dropped rather than drawn — otherwise the last vignette stays on the head | +| Player dies or becomes a spectator | explicit `clear()`, same reason | + +## 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. +- `OverlayTunnelVisionRendererTest` — Cyano: the renderer contributes the expected texture, and + `clear()` drops only its own layer rather than wiping the screen out from under the blood + splatter. +- `EquipmentScreenOverlayTest` — Cyano: a layer becomes a camera overlay on the head, the blood + wins over the tunnel vision and the tunnel vision returns afterwards, the last layer leaving + empties the slot, and an unchanged overlay is not re-sent. +- `TunnelVisionServiceTest` — lifecycle: start and stop, removing a player, 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 9c2efd93..02095902 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -44,6 +44,7 @@ import net.onelitefeather.cygnus.command.GlitchCommand; import net.onelitefeather.cygnus.blood.BloodSplatterService; 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; @@ -82,6 +83,10 @@ 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.OverlayTunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionService; import net.onelitefeather.cygnus.utils.StaminaHelper; import net.onelitefeather.cygnus.view.GameView; import net.onelitefeather.cygnus.view.GameViewImpl; @@ -114,6 +119,8 @@ public final class Cygnus implements TeamCreator, ListenerHandling { private final ScreenOverlay screenOverlay; private final SlenderGazeService slenderGazeService; private final BloodSplatterService bloodSplatterService; + private final TunnelVisionRenderer tunnelVisionRenderer; + private final TunnelVisionService tunnelVisionService; public Cygnus() { Path path = ServiceBootstrap.resolveWorkingDirectory(); @@ -144,6 +151,8 @@ public Cygnus() { this.screenOverlay, bound -> ThreadLocalRandom.current().nextInt(bound) ); + this.tunnelVisionRenderer = new OverlayTunnelVisionRenderer(this.screenOverlay); + this.tunnelVisionService = new TunnelVisionService(this.tunnelVisionRenderer, this::remainingStamina); this.initPhases(); this.initCommands(); this.initListener(); @@ -155,6 +164,18 @@ private void initCommands() { var manager = MinecraftServer.getCommandManager(); manager.register(new StartCommand(this.linearPhaseSeries)); manager.register(new GlitchCommand(this.slenderGazeService)); + 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(); } private void initListener() { @@ -225,6 +246,7 @@ private void registerOverlayListeners(GlobalEventHandler handler) { if (!OverlayProperties.enabled()) return; this.slenderGazeService.registerListener(handler, () -> TeamHelper.survivorsOf(this.teamService)); this.bloodSplatterService.registerListener(handler); + this.tunnelVisionService.registerListener(handler, () -> TeamHelper.survivorsOf(this.teamService)); } private void initPhases() { diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java new file mode 100644 index 00000000..5f8ad9f3 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java @@ -0,0 +1,106 @@ +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.common.util.PlayerState; +import net.onelitefeather.cygnus.common.util.RepeatingTask; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionStage; + +import java.time.temporal.ChronoUnit; + +/** + * 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.1.0 + * @since 2.7.0 + */ +public final class TunnelVisionCommand extends Command { + + private final TunnelVisionRenderer renderer; + private final PlayerState previews; + + /** + * Creates the command. + * + * @param renderer the renderer that draws the preview + */ + public TunnelVisionCommand(TunnelVisionRenderer renderer) { + super("tunnelvision"); + this.renderer = renderer; + this.previews = new PlayerState<>(); + + 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 = CommandSenders.asPlayer(sender, "can preview the tunnel vision."); + if (player == null) return; + this.stopPreview(player); + this.renderer.render(player, context.get(stage)); + }, ArgumentType.Literal("stage"), stage); + + this.addSyntax((sender, context) -> { + Player player = CommandSenders.asPlayer(sender, "can preview the tunnel vision."); + if (player == null) return; + this.startPreview(player, context.get(intensity)); + }, ArgumentType.Literal("intensity"), intensity); + + this.addSyntax((sender, context) -> { + Player player = CommandSenders.asPlayer(sender, "can preview the tunnel vision."); + 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(); + // The task alone would only draw from its first repetition onward, so the initial stage is + // rendered here, the same way BloodSplatterService and SlenderGazeService draw their first + // frame before ever starting their own repeating task. + this.renderer.render(player, stage.update(intensity)); + + RepeatingTask task = new RepeatingTask(() -> { + if (!player.isOnline()) { + this.stopPreview(player); + return; + } + this.renderer.render(player, stage.update(intensity)); + }); + this.previews.put(player, task); + task.start(TunnelVisionStage.TICK_MILLIS, ChronoUnit.MILLIS); + } + + /** + * Ends a running preview, leaving whatever is on screen untouched. + * + * @param player the player whose preview to end + */ + private void stopPreview(Player player) { + RepeatingTask task = this.previews.remove(player); + if (task != null) task.stop(); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java b/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java index d2a89e7e..38cf62d5 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java +++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java @@ -99,6 +99,19 @@ private float normalize(float current) { return Math.max(0.0f, current / MAX_FOOD); } + /** + * Returns the remaining stamina as a share of a full bar. + *

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

+ * + * @return the remaining stamina between {@code 0.0f} and {@code 1.0f} + */ + public float remainingShare() { + return normalize(this.currentSpeedCount); + } + /** * Returns an indication state if the bar could be consumed. * diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRenderer.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRenderer.java new file mode 100644 index 00000000..7916d1af --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRenderer.java @@ -0,0 +1,61 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.kyori.adventure.key.Key; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.OverlayTextureKeys; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; + +/** + * Contributes the tunnel vision to the shared screen overlay. + *

+ * Each stage is a camera overlay texture from the resource pack. Minecraft cannot animate one, so + * the heartbeat is the server walking through the stages, one texture per frame. + *

+ * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.7.0 + */ +public final class OverlayTunnelVisionRenderer implements TunnelVisionRenderer { + + /** Where the stage textures live, as {@code camera_overlay} resolves them. */ + static final String TEXTURE_PATH = "gui/tunnel_vision/stage_"; + + private static final Key[] TEXTURES = + OverlayTextureKeys.flat(TEXTURE_PATH, TunnelVisionStage.MAX_STAGE, OverlayTextureKeys.ONE_BASED); + + private final ScreenOverlay overlay; + + /** + * Creates a renderer drawing into the given overlay. + * + * @param overlay the overlay that owns the player's screen + */ + public OverlayTunnelVisionRenderer(ScreenOverlay overlay) { + this.overlay = overlay; + } + + /** + * {@inheritDoc} + */ + @Override + public void render(Player player, int stage) { + if (stage <= 0) { + this.clear(player); + return; + } + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TEXTURES[Math.min(stage, TunnelVisionStage.MAX_STAGE) - 1]); + } + + /** + * {@inheritDoc} + *

+ * Only this layer is dropped. Clearing the screen would take the blood splatter with it. + *

+ */ + @Override + public void clear(Player player) { + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, null); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java new file mode 100644 index 00000000..ee58c9be --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java @@ -0,0 +1,45 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.onelitefeather.cygnus.common.util.Helper; + +/** + * Turns a draining stamina bar into an intensity in {@code [0, 1]} that drives how far the + * survivor's view narrows. + *

+ * The slender used to feed into this intensity as well; he now speaks through + * {@code gaze.SlenderGazeService} instead, which tears the view independently rather than adding + * to this gauge. + *

+ *

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

+ * + * @author TheMeinerLP + * @version 2.0.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; + + 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 Helper.clamp(drained * drained, 0.0D, 1.0D); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionRenderer.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionRenderer.java new file mode 100644 index 00000000..1e9a4955 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionRenderer.java @@ -0,0 +1,35 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.minestom.server.entity.Player; + +/** + * Displays a tunnel vision stage to a survivor. + *

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

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public interface TunnelVisionRenderer { + + /** + * Shows the given stage to the player. + * + * @param player the player to draw for + * @param stage the stage between {@code 0} and {@link TunnelVisionStage#MAX_STAGE}, where + * {@code 0} means no overlay + */ + void render(Player player, int stage); + + /** + * Removes the overlay from the player's screen. + * + * @param player the player to clear + */ + void clear(Player player); +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java new file mode 100644 index 00000000..91991ea9 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java @@ -0,0 +1,161 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.minestom.server.entity.Player; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.player.PlayerDeathEvent; +import net.minestom.server.event.player.PlayerDisconnectEvent; +import net.onelitefeather.cygnus.common.util.PlayerState; +import net.onelitefeather.cygnus.common.util.RepeatingTask; +import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.event.GameStartEvent; + +import java.time.temporal.ChronoUnit; +import java.util.Set; +import java.util.function.Supplier; +import java.util.function.ToDoubleFunction; + +/** + * Drives the tunnel vision of every survivor from a single repeating task. + *

+ * One task rather than one per player, as {@code StaminaBar} does it, so there is a single place to + * clean up. + *

+ *

+ * The stamina arrives as a function rather than as a service: it only needs a number, so the + * dependency does not have to be a live object the service keeps in sync. + *

+ *

+ * The slender used to feed into this as well. He now speaks through {@code SlenderGazeService} + * instead, which asks whether a survivor can see him rather than how near he is. + *

+ *

+ * Unlike {@code AmbientProvider}, this service is not merely started and stopped by name from + * {@code GameStartListener} and {@code Cygnus.finishGame()} — it still exposes {@link #startTask()} + * and {@link #stopTask()} for exactly that purpose, but it also has to react the moment a single + * survivor dies or disconnects, or the vignette they last saw keeps showing on a screen nobody is + * playing through any more. Neither of those listeners knows about individual players today, and + * teaching them to would spread a tunnel-vision concern into files that otherwise have nothing to do + * with it. Registering here, scoped to this service's own node, keeps that mapping local to the one + * class that needs it — {@code BloodSplatterService} and {@code SlenderGazeService} register + * themselves for the same reason. + *

+ * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.7.0 + */ +public final class TunnelVisionService { + + private final TunnelVisionRenderer renderer; + private final ToDoubleFunction stamina; + private final PlayerState survivors = new PlayerState<>(); + private final RepeatingTask task = new RepeatingTask(this::tick); + + /** + * Creates a new service. + * + * @param renderer the renderer that puts a stage on the screen + * @param stamina supplies a survivor's remaining stamina as a share of a full bar + */ + public TunnelVisionService(TunnelVisionRenderer renderer, ToDoubleFunction stamina) { + this.renderer = renderer; + this.stamina = stamina; + } + + /** + * Starts the update task. Does nothing if it is already running. + */ + public void startTask() { + this.task.start(TunnelVisionStage.TICK_MILLIS, ChronoUnit.MILLIS); + } + + /** + * Stops the update task. Does nothing if it is not running. + */ + public void stopTask() { + this.task.stop(); + } + + /** + * Starts drawing for a survivor, with a fresh stage. + *

+ * This is bookkeeping only: it does not touch the update task, so {@link #registerListener} can + * compose it with {@link #startTask()} instead of the two always happening together. + *

+ * + * @param survivor the survivor to draw for + */ + public void track(Player survivor) { + this.survivors.put(survivor, new Tracked(survivor, new TunnelVisionStage())); + } + + /** + * Hooks the service into the round's lifecycle. + *

+ * See the class documentation for why this service registers itself rather than being called by + * name the way {@code AmbientProvider} is. + *

+ * + * @param node the node to register on + * @param survivors supplies the survivors of the starting round + */ + public void registerListener(EventNode node, Supplier> survivors) { + node.addListener(GameStartEvent.class, event -> { + this.startTask(); + for (Player survivor : survivors.get()) { + this.track(survivor); + } + }); + node.addListener(PlayerDeathEvent.class, event -> this.remove(event.getPlayer())); + node.addListener(PlayerDisconnectEvent.class, event -> this.remove(event.getPlayer())); + node.addListener(GameFinishEvent.class, event -> { + this.clearAll(); + this.stopTask(); + }); + } + + /** + * Stops drawing for a survivor and clears whatever is still on their screen — on death, on + * the way into the spectator team, or on quit. + * + * @param player the survivor to drop + */ + public void remove(Player player) { + if (this.survivors.remove(player) == null) return; + this.renderer.clear(player); + } + + /** + * Clears every survivor's screen and stops tracking all of them, without touching the update + * task — pair with {@link #stopTask()} to end a round the way {@link #registerListener} does. + */ + public void clearAll() { + for (Tracked tracked : this.survivors.values()) { + this.renderer.clear(tracked.player()); + } + this.survivors.clear(); + } + + /** + * Updates every tracked survivor once. + */ + void tick() { + if (this.survivors.isEmpty()) return; + + for (Tracked tracked : this.survivors.values()) { + Player survivor = tracked.player(); + double intensity = TunnelVisionIntensity.fromStamina(this.stamina.applyAsDouble(survivor)); + this.renderer.render(survivor, tracked.stage().update(intensity)); + } + } + + /** + * Pairs a survivor with the overlay state that belongs to them. + * + * @param player the survivor + * @param stage their stage state, carrying hysteresis and heartbeat + */ + private record Tracked(Player player, TunnelVisionStage stage) { + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java new file mode 100644 index 00000000..8db40ff6 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java @@ -0,0 +1,79 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.onelitefeather.cygnus.common.util.Helper; + +/** + * 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 an overlay texture, so + * the animation is the server walking through the stages. Thirty-two of them make the view + * close smoothly; at sixteen the steps were visible as the tunnel narrowed. + *

+ */ + public static final int MAX_STAGE = 32; + + /** Interval the service updates at, which is also the sampling rate of the heartbeat. */ + public static final int TICK_MILLIS = 100; + + /** Distance in stages the intensity has to travel before the base stage follows. */ + private static final double HYSTERESIS = 0.6D; + + /** + * Depth of the heartbeat in stages at full intensity, as a fraction of the whole scale so it + * stays equally visible whatever {@link #MAX_STAGE} is. + */ + private static final double PULSE_DEPTH = MAX_STAGE / 16.0D; + + /** Heartbeat frequency in hertz while the survivor is barely threatened. */ + private static final double BASE_FREQUENCY = 1.0D; + + /** Additional heartbeat frequency in hertz at full intensity. */ + private static final double FREQUENCY_GAIN = 1.5D; + + private static final double TICK_SECONDS = TICK_MILLIS / 1000.0D; + + /** Negative until the first update, so the first intensity is adopted without hysteresis. */ + private int baseStage = -1; + + private double elapsedSeconds; + + /** + * Advances the heartbeat by one tick and reports the stage to render. + * + * @param 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 Helper.clamp(rendered, 0, MAX_STAGE); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java new file mode 100644 index 00000000..f6ea8e77 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.onelitefeather.cygnus.tunnelvision; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/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..2716638c --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java @@ -0,0 +1,110 @@ +package net.onelitefeather.cygnus.command; + +import net.minestom.server.MinecraftServer; +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.component.Equippable; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.overlay.EquipmentScreenOverlay; +import net.onelitefeather.cygnus.tunnelvision.OverlayTunnelVisionRenderer; +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.assertNotNull; +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 2.0.0 + * @since 2.7.0 + */ +class TunnelVisionCommandTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("A requested stage is drawn right away") + void stageIsDrawnOnRequest(Env env) { + Player player = spawn(env); + register(); + + MinecraftServer.getCommandManager().execute(player, "tunnelvision stage 5"); + + assertEquals(textureOf(5), cameraOverlay(player), "the command must draw the requested stage"); + } + + @Test + @DisplayName("Switching the preview off clears the screen") + void offClearsTheScreen(Env env) { + Player player = spawn(env); + register(); + MinecraftServer.getCommandManager().execute(player, "tunnelvision stage 5"); + + MinecraftServer.getCommandManager().execute(player, "tunnelvision off"); + + assertTrue(player.getHelmet().isAir(), "the preview must disappear"); + } + + @Test + @DisplayName("A previewed intensity starts at its stage") + void intensityStartsDrawing(Env env) { + Player player = spawn(env); + register(); + + MinecraftServer.getCommandManager().execute(player, "tunnelvision intensity 1.0"); + + assertEquals( + textureOf(TunnelVisionStage.MAX_STAGE), + cameraOverlay(player), + "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 OverlayTunnelVisionRenderer(new EquipmentScreenOverlay()))); + } + + /** + * 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 camera overlay the player is currently wearing. + * + * @param player the player to read + * @return the overlay texture as a string + */ + private String cameraOverlay(Player player) { + Equippable equippable = player.getHelmet().get(DataComponents.EQUIPPABLE); + assertNotNull(equippable, "nothing is carrying an overlay"); + return equippable.cameraOverlay(); + } + + /** + * Builds the texture expected for a stage. + * + * @param stage the stage + * @return the texture as a string + */ + private String textureOf(int stage) { + return "cygnus:gui/tunnel_vision/stage_" + stage; + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java new file mode 100644 index 00000000..1fe45c52 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java @@ -0,0 +1,32 @@ +package net.onelitefeather.cygnus.stamina; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.player.CygnusPlayer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies the stamina share other systems read off the survivor's bar. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class FoodBarTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("A fresh bar reports a full share") + void freshBarIsFull(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createConnection().connect(instance, new Pos(0, 40, 0)); + FoodBar bar = (FoodBar) StaminaFactory.createFoodStamina((CygnusPlayer) player); + + assertEquals(1.0f, bar.remainingShare(), 1.0E-6f); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java new file mode 100644 index 00000000..edac3d84 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java @@ -0,0 +1,134 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.kyori.adventure.key.Key; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Verifies which texture the tunnel vision contributes to the shared screen overlay. + * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.7.0 + */ +class OverlayTunnelVisionRendererTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("A stage is contributed as its overlay texture") + void stageIsContributedAsTexture(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + + new OverlayTunnelVisionRenderer(overlay).render(player, 3); + + assertEquals( + Key.key("cygnus", OverlayTunnelVisionRenderer.TEXTURE_PATH + "3"), + overlay.of(OverlayLayer.TUNNEL_VISION), + "the texture must match the stage" + ); + } + + @Test + @DisplayName("Clearing drops only the tunnel vision layer") + void clearingDropsOnlyItsOwnLayer(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + OverlayTunnelVisionRenderer renderer = new OverlayTunnelVisionRenderer(overlay); + renderer.render(player, 4); + + renderer.clear(player); + + assertNull(overlay.of(OverlayLayer.TUNNEL_VISION), "the layer must be gone"); + assertFalse(overlay.wasWiped(), "wiping the screen would take the blood splatter with it"); + } + + @Test + @DisplayName("Stage zero drops the layer instead of drawing an empty texture") + void zeroStageDropsTheLayer(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + + new OverlayTunnelVisionRenderer(overlay).render(player, 0); + + assertNull(overlay.of(OverlayLayer.TUNNEL_VISION)); + } + + @Test + @DisplayName("The tightest stage has a texture of its own") + void tightestStageHasItsOwnTexture(Env env) { + RecordingOverlay overlay = new RecordingOverlay(); + Player player = spawn(env); + + new OverlayTunnelVisionRenderer(overlay).render(player, TunnelVisionStage.MAX_STAGE); + + assertEquals( + Key.key("cygnus", OverlayTunnelVisionRenderer.TEXTURE_PATH + TunnelVisionStage.MAX_STAGE), + overlay.of(OverlayLayer.TUNNEL_VISION) + ); + } + + /** + * Connects a player into a fresh instance. + * + * @param env the test environment + * @return the connected player + */ + private Player spawn(Env env) { + Instance instance = env.createFlatInstance(); + return env.createConnection().connect(instance, new Pos(0, 40, 0)); + } + + /** + * Records what a renderer contributes, standing in for the equipment-backed overlay. + */ + private static final class RecordingOverlay implements ScreenOverlay { + + private final Map layers = new EnumMap<>(OverlayLayer.class); + private boolean wiped; + + @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.wiped = true; + this.layers.clear(); + } + + /** + * @param layer the layer to look up + * @return the texture currently set for the layer, or {@code null} if there is none + */ + private @Nullable Key of(OverlayLayer layer) { + return this.layers.get(layer); + } + + /** + * @return whether the whole screen was cleared rather than a single layer + */ + private boolean wasWiped() { + return this.wiped; + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java new file mode 100644 index 00000000..d976aaea --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java @@ -0,0 +1,51 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the intensity curves that drive the survivor's tunnel vision. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class TunnelVisionIntensityTest { + + private static final double DELTA = 1.0E-6D; + + @DisplayName("Stamina above half a bar produces no tunnel vision") + @ParameterizedTest + @CsvSource({"1.0", "0.75", "0.5"}) + void staminaAboveHalfIsCalm(double stamina) { + assertEquals(0.0D, TunnelVisionIntensity.fromStamina(stamina), DELTA); + } + + @Test + @DisplayName("An empty stamina bar produces full intensity") + void emptyStaminaIsFull() { + assertEquals(1.0D, TunnelVisionIntensity.fromStamina(0.0D), DELTA); + } + + @Test + @DisplayName("The stamina curve accelerates towards the empty bar") + void staminaCurveIsQuadratic() { + assertEquals(0.25D, TunnelVisionIntensity.fromStamina(0.25D), DELTA); + } + + @Test + @DisplayName("Draining stamina never lowers the intensity") + void staminaIsMonotonic() { + double previous = -1.0D; + for (int step = 20; step >= 0; step--) { + double current = TunnelVisionIntensity.fromStamina(step / 20.0D); + assertTrue(current >= previous, "intensity dropped at stamina " + step / 20.0D); + previous = current; + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java new file mode 100644 index 00000000..f01d9c5b --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java @@ -0,0 +1,235 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.kyori.adventure.text.Component; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.event.player.PlayerDeathEvent; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.event.GameStartEvent; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies how the service feeds survivors through the intensity calculation. + *

+ * The slender no longer feeds into this — he speaks through {@code SlenderGazeService} — so what + * is left here is the stamina and the lifecycle. + *

+ * + * @author TheMeinerLP + * @version 1.0.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") + void exhaustedSurvivorIsFullyNarrowed(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.track(survivor); + + service.tick(); + + assertEquals(TunnelVisionStage.MAX_STAGE, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A rested survivor alone in the dark sees nothing") + void restedSurvivorSeesNothing(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> FULL_STAMINA); + service.track(survivor); + + service.tick(); + + assertEquals(0, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A removed survivor gets their screen back and is no longer drawn") + void removedSurvivorIsCleared(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.track(survivor); + service.tick(); + renderer.forget(); + + service.remove(survivor); + service.tick(); + + assertTrue(renderer.wasCleared(survivor), "the last vignette would otherwise linger"); + assertNull(renderer.stageOf(survivor), "a removed survivor must not be drawn any more"); + } + + @Test + @DisplayName("Clearing everyone gives every survivor their screen back") + void clearAllClearsEveryone(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Instance instance = env.createFlatInstance(); + Player first = spawn(env, instance, new Pos(0, 40, 0)); + Player second = spawn(env, instance, new Pos(4, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.track(first); + service.track(second); + service.tick(); + + service.clearAll(); + + assertTrue(renderer.wasCleared(first)); + assertTrue(renderer.wasCleared(second)); + + renderer.forget(); + service.tick(); + assertNull(renderer.stageOf(first), "clearing must stop the drawing as well"); + } + + @Test + @DisplayName("Starting and stopping the task is idempotent") + void startAndStopTaskAreIdempotent(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + + service.startTask(); + service.startTask(); + service.stopTask(); + service.stopTask(); + } + + @Test + @DisplayName("The start of a round takes the survivors on board") + void gameStartRegistersSurvivors(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + + EventDispatcher.call(new GameStartEvent()); + service.tick(); + + assertEquals(TunnelVisionStage.MAX_STAGE, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A dying survivor gets their screen back") + void deathClearsTheOverlay(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.track(survivor); + service.tick(); + renderer.forget(); + + EventDispatcher.call(new PlayerDeathEvent(survivor, Component.empty(), Component.empty())); + service.tick(); + + assertTrue(renderer.wasCleared(survivor)); + assertNull(renderer.stageOf(survivor), "a dead survivor must not be drawn any more"); + } + + @Test + @DisplayName("The end of a round clears everyone") + void gameFinishCleansUp(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.track(survivor); + service.tick(); + renderer.forget(); + + EventDispatcher.call(new GameFinishEvent(GameFinishEvent.Reason.TIME_OVER)); + + assertTrue(renderer.wasCleared(survivor)); + } + + /** + * Spawns a player in a fresh instance. + * + * @param env the test environment + * @param position where to place the player + * @return the connected player + */ + private Player spawn(Env env, Pos position) { + return this.spawn(env, env.createFlatInstance(), position); + } + + /** + * Spawns a player in the given instance. + * + * @param env the test environment + * @param instance the instance to connect into + * @param position where to place the player + * @return the connected player + */ + private Player spawn(Env env, Instance instance, Pos position) { + return env.createConnection().connect(instance, position); + } + + /** + * Records what the service asked to be drawn, standing in for the action bar renderer. + */ + private static final class RecordingRenderer implements TunnelVisionRenderer { + + private final Map stages = new HashMap<>(); + private final Set cleared = new HashSet<>(); + + @Override + public void render(Player player, int stage) { + this.stages.put(player.getUuid(), stage); + } + + @Override + public void clear(Player player) { + this.cleared.add(player.getUuid()); + this.stages.remove(player.getUuid()); + } + + /** + * @param player the player to look up + * @return the stage last drawn for the player, or {@code null} if nothing was drawn + */ + private @Nullable Integer stageOf(Player player) { + return this.stages.get(player.getUuid()); + } + + /** + * @param player the player to look up + * @return whether the player's overlay was cleared + */ + private boolean wasCleared(Player player) { + return this.cleared.contains(player.getUuid()); + } + + /** + * Drops everything recorded so far, to tell repeated draws apart. + */ + private void forget() { + this.stages.clear(); + this.cleared.clear(); + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java new file mode 100644 index 00000000..407e80bc --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java @@ -0,0 +1,100 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies how a continuous intensity becomes the discrete, pulsing stage the overlay renders. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class TunnelVisionStageTest { + + /** Enough updates to cover several periods of the slowest heartbeat. */ + private static final int SAMPLES = 60; + + @Test + @DisplayName("Without any threat the overlay stays off") + void calmIntensityStaysOff() { + TunnelVisionStage stage = new TunnelVisionStage(); + assertEquals(0, stage.update(0.0D)); + } + + @Test + @DisplayName("Full intensity pulses across the top of the scale") + void fullIntensityPulses() { + TunnelVisionStage stage = new TunnelVisionStage(); + int lowest = TunnelVisionStage.MAX_STAGE; + int highest = 0; + for (int sample = 0; sample < SAMPLES; sample++) { + int current = stage.update(1.0D); + lowest = Math.min(lowest, current); + highest = Math.max(highest, current); + } + assertEquals(TunnelVisionStage.MAX_STAGE, highest, "the pulse never reaches the peak"); + // Stated as a share of the scale rather than as a stage count, so raising the number of + // stages does not turn this into a test of one particular pulse depth. + assertTrue(lowest < highest, "the pulse does not open up again"); + assertTrue(lowest >= TunnelVisionStage.MAX_STAGE - TunnelVisionStage.MAX_STAGE / 4, + "the pulse swings the view too far open at full intensity"); + } + + @Test + @DisplayName("Low intensity barely pulses at all") + void lowIntensityIsSteady() { + TunnelVisionStage stage = new TunnelVisionStage(); + int first = stage.update(0.125D); + for (int sample = 0; sample < SAMPLES; sample++) { + assertEquals(first, stage.update(0.125D), "a barely threatened survivor should not flicker"); + } + } + + @Test + @DisplayName("A small fluctuation does not move the stage") + void hysteresisHoldsTheStage() { + TunnelVisionStage stage = new TunnelVisionStage(); + int settled = highestOver(stage, 0.5D); + assertEquals(TunnelVisionStage.MAX_STAGE / 2, settled, "half intensity should settle on the middle stage"); + assertEquals(settled, highestOver(stage, 0.51D), "the stage moved on a small fluctuation"); + } + + @Test + @DisplayName("A real change moves the stage") + void largerChangeMovesTheStage() { + TunnelVisionStage stage = new TunnelVisionStage(); + assertEquals(TunnelVisionStage.MAX_STAGE / 2, highestOver(stage, 0.5D)); + assertEquals(TunnelVisionStage.MAX_STAGE / 2 + 1, highestOver(stage, 0.53D), + "the stage should follow a real change"); + } + + @Test + @DisplayName("The stage never leaves its bounds") + void stageStaysWithinBounds() { + TunnelVisionStage stage = new TunnelVisionStage(); + for (int sample = 0; sample < SAMPLES; sample++) { + int current = stage.update(sample % 2 == 0 ? 1.0D : 0.0D); + assertTrue(current >= 0 && current <= TunnelVisionStage.MAX_STAGE, "stage out of bounds: " + current); + } + } + + /** + * Feeds a constant intensity for a while and reports the highest stage seen, which is the + * stage the pulse starts from. + * + * @param stage the stage state to drive + * @param combined the constant intensity to feed + * @return the highest stage observed + */ + private int highestOver(TunnelVisionStage stage, double combined) { + int highest = 0; + for (int sample = 0; sample < SAMPLES; sample++) { + highest = Math.max(highest, stage.update(combined)); + } + return highest; + } +} From e27b3cb01a518393b189ca76b305897f78d5ab37 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 23 Aug 2026 10:43:11 +0200 Subject: [PATCH 2/5] refactor(tunnel-vision): follow the conventions the rest of the game already uses PlayerState and RepeatingTask moved to game/utils in the foundation, and Helper.clamp is gone in favour of Math.clamp; both follow here. clearAll() becomes cleanUp(), which is what the round teardown is called on main (PageProvider, StaminaService, JumpScareManager). The stage count said three different things: MAX_STAGE is 32, the pack ships stage_1 through stage_32, but the command advertised <0-16> and the design spec described sixteen stages at 1024x576. The command now derives its usage line from MAX_STAGE so the two cannot drift apart again, and the spec is corrected to 32 stages at 768x432 - which is what the textures on cygnus-pack's master actually are. The usage line itself moves into Messages as a builder, next to the other builders that interpolate a value, and the players-only message becomes a constant now that CommandSenders takes a finished Component. The recording ScreenOverlay the tests stood up is the foundation's shared one. The comment gating the effect in Cygnus described a vignette font that no longer exists and tied the gate to the resource pack, which OverlayProperties deliberately is not tied to. --- .../cygnus/common/Messages.java | 18 +++++ .../specs/2026-08-10-tunnel-vision-design.md | 22 +++--- .../cygnus/command/TunnelVisionCommand.java | 24 +++--- .../tunnelvision/TunnelVisionIntensity.java | 5 +- .../tunnelvision/TunnelVisionService.java | 10 +-- .../tunnelvision/TunnelVisionStage.java | 5 +- .../command/TunnelVisionCommandTest.java | 40 +++++----- .../OverlayTunnelVisionRendererTest.java | 73 +++++-------------- .../tunnelvision/TunnelVisionServiceTest.java | 6 +- 9 files changed, 95 insertions(+), 108 deletions(-) diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java b/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java index 960bebd6..01fd40d8 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java @@ -32,6 +32,7 @@ public final class Messages { public static final Component LIGHT_WENT_OUT; public static final Component ONLY_PLAYERS_HAVE_A_VIEW; public static final Component ONLY_PLAYERS_CAN_BLEED; + public static final Component ONLY_PLAYERS_CAN_PREVIEW; private static final Component PAGE_FOUND_PART; private static final Component LEAVE_PART; private static final Component JOIN_PART; @@ -66,6 +67,7 @@ public final class Messages { LIGHT_WENT_OUT = withMiniPrefix("Your light went out!"); ONLY_PLAYERS_HAVE_A_VIEW = withMiniPrefix("Only players have a view to lose."); ONLY_PLAYERS_CAN_BLEED = withMiniPrefix("Only players can bleed."); + ONLY_PLAYERS_CAN_PREVIEW = withMiniPrefix("Only players can preview the tunnel vision."); SURVIVOR_JOIN_PART_UPPER = withMiniPrefix("You are a Survivor! Find various Pages").append(Component.space()); @@ -208,6 +210,22 @@ public static Component getSlenderWinMessage(@Nullable Player player) { .append(Component.newline()); } + /** + * Returns a {@link Component} explaining how the tunnel vision preview command is used. + *

+ * The upper bound is passed in rather than written out, so it cannot drift away from the + * number of stages the overlay actually has. + *

+ * + * @param maxStage the highest stage the preview accepts + * @return the created {@link Component} reference + */ + @Contract(value = "_ -> new", pure = true) + public static Component getTunnelVisionUsageMessage(int maxStage) { + return withMiniPrefix("Usage: /tunnelvision stage <0-" + maxStage + + "> | intensity <0.0-1.0> | off"); + } + @Contract(value = "_ -> new", pure = true) public static Component getJoinMessage(Player player) { return PREFIX.append(Component.space()).append(withMini("" + player.getUsername() + "")) diff --git a/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md index 4f6a0f32..6ed1554d 100644 --- a/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md +++ b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md @@ -68,19 +68,21 @@ atmosphere anyway. ## Stages and pulse -The continuous value is quantised to 16 stages, which double as the frames of the heartbeat. +The continuous value is quantised to 32 stages, which double as the frames of the heartbeat. Minecraft cannot animate an overlay texture — `.mcmeta` animation covers block, item, particle, -painting and effect textures only — so the animation is the server walking through the frames. Two mechanisms sit on top, in this order: +painting and effect textures only — so the animation is the server walking through the frames. +Thirty-two of them make the view close smoothly; at sixteen the steps were visible as the tunnel +narrowed. Two mechanisms sit on top, in this order: -1. **Hysteresis on the base value.** `baseStage` starts as `round(combined * 16)` and afterwards - only moves when `combined * 16` is more than 0.6 stages away from it. Distance and stamina both +1. **Hysteresis on the base value.** `baseStage` starts as `round(combined * 32)` and afterwards + only moves when `combined * 32` 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 +depth = (32 / 16) * combined // a sixteenth of the scale, whatever the stage count is frequency = 1.0 + 1.5 * combined // Hz -display = clamp(round(baseStage + depth * (sin(2*pi * frequency * t) - 1)), 0, 16) +display = clamp(round(baseStage + depth * (sin(2*pi * frequency * t) - 1)), 0, 32) ``` The heartbeat gets faster and deeper as it gets tighter, and stays nearly invisible at low @@ -104,14 +106,14 @@ a tiny packet per survivor. In `cygnus-pack`, namespace `cygnus`: ``` -pack/assets/cygnus/textures/gui/tunnel_vision/stage_1.png … stage_16.png +pack/assets/cygnus/textures/gui/tunnel_vision/stage_1.png … stage_32.png pack/assets/cygnus/equipment/empty.json ``` -Each texture is 1024×576 — 16:9, because the client stretches a camera overlay across the screen +Each texture is 768×432 — 16:9, because the client stretches a camera overlay across the screen rather than fitting it. The darkening closes in from all four edges rather than as a circle from the middle: it is a superellipse whose exponent eases from 4 at stage 1, a rounded rectangle -framing the screen, to 2 at stage 16, where a plain ellipse reads as a tunnel. Textures are +framing the screen, to 2 at stage 32, where a plain ellipse reads as a tunnel. Textures are generated by `tools/generate_overlay.py`, which also produces the blood splatter. **How it reaches the screen.** The server puts an item in the player's head slot carrying @@ -142,7 +144,7 @@ New package `net.onelitefeather.cygnus.tunnelvision`: the shared `ScreenOverlay` rather than dressing the player itself. - `TunnelVisionService` — holds a `TunnelVisionStage` per survivor and ticks all of them in one scheduler task. -- `TunnelVisionCommand` — `/tunnelvision stage <0-16> | intensity <0.0-1.0> | off`, for judging the +- `TunnelVisionCommand` — `/tunnelvision stage <0-32> | intensity <0.0-1.0> | off`, for judging the vignette from the lobby without a running round. `stage` freezes one stage to judge the drawing; `intensity` runs the real heartbeat. diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java index 5f8ad9f3..e8225ded 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java +++ b/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java @@ -4,10 +4,10 @@ import net.minestom.server.command.builder.arguments.ArgumentType; import net.minestom.server.entity.Player; import net.onelitefeather.cygnus.common.Messages; -import net.onelitefeather.cygnus.common.util.PlayerState; -import net.onelitefeather.cygnus.common.util.RepeatingTask; import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer; import net.onelitefeather.cygnus.tunnelvision.TunnelVisionStage; +import net.onelitefeather.cygnus.utils.PlayerState; +import net.onelitefeather.cygnus.utils.RepeatingTask; import java.time.temporal.ChronoUnit; @@ -15,18 +15,20 @@ * 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}. + * {@code /tunnelvision stage <0-32>} freezes a single stage, so the drawing of a single overlay + * texture can be judged on its own. {@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}. The upper bound of {@code stage} follows + * {@link TunnelVisionStage#MAX_STAGE}. *

* * @author TheMeinerLP - * @version 1.1.0 + * @version 1.2.0 * @since 2.7.0 */ public final class TunnelVisionCommand extends Command { + private final TunnelVisionRenderer renderer; private final PlayerState previews; @@ -44,24 +46,24 @@ public TunnelVisionCommand(TunnelVisionRenderer renderer) { 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") + Messages.getTunnelVisionUsageMessage(TunnelVisionStage.MAX_STAGE) )); this.addSyntax((sender, context) -> { - Player player = CommandSenders.asPlayer(sender, "can preview the tunnel vision."); + Player player = CommandSenders.asPlayer(sender, Messages.ONLY_PLAYERS_CAN_PREVIEW); if (player == null) return; this.stopPreview(player); this.renderer.render(player, context.get(stage)); }, ArgumentType.Literal("stage"), stage); this.addSyntax((sender, context) -> { - Player player = CommandSenders.asPlayer(sender, "can preview the tunnel vision."); + Player player = CommandSenders.asPlayer(sender, Messages.ONLY_PLAYERS_CAN_PREVIEW); if (player == null) return; this.startPreview(player, context.get(intensity)); }, ArgumentType.Literal("intensity"), intensity); this.addSyntax((sender, context) -> { - Player player = CommandSenders.asPlayer(sender, "can preview the tunnel vision."); + Player player = CommandSenders.asPlayer(sender, Messages.ONLY_PLAYERS_CAN_PREVIEW); if (player == null) return; this.stopPreview(player); this.renderer.clear(player); diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java index ee58c9be..a4139060 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensity.java @@ -1,6 +1,5 @@ package net.onelitefeather.cygnus.tunnelvision; -import net.onelitefeather.cygnus.common.util.Helper; /** * Turns a draining stamina bar into an intensity in {@code [0, 1]} that drives how far the @@ -16,7 +15,7 @@ *

* * @author TheMeinerLP - * @version 2.0.0 + * @version 2.0.1 * @since 2.7.0 */ public final class TunnelVisionIntensity { @@ -40,6 +39,6 @@ private TunnelVisionIntensity() { public static double fromStamina(double normalizedStamina) { if (normalizedStamina >= STAMINA_THRESHOLD) return 0.0D; double drained = (STAMINA_THRESHOLD - normalizedStamina) / STAMINA_THRESHOLD; - return Helper.clamp(drained * drained, 0.0D, 1.0D); + return Math.clamp(drained * drained, 0.0D, 1.0D); } } diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java index 91991ea9..e56fc8d5 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionService.java @@ -5,10 +5,10 @@ import net.minestom.server.event.EventNode; import net.minestom.server.event.player.PlayerDeathEvent; import net.minestom.server.event.player.PlayerDisconnectEvent; -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.utils.PlayerState; +import net.onelitefeather.cygnus.utils.RepeatingTask; import java.time.temporal.ChronoUnit; import java.util.Set; @@ -42,7 +42,7 @@ *

* * @author TheMeinerLP - * @version 2.0.0 + * @version 2.1.0 * @since 2.7.0 */ public final class TunnelVisionService { @@ -110,7 +110,7 @@ public void registerListener(EventNode node, Supplier> surviv node.addListener(PlayerDeathEvent.class, event -> this.remove(event.getPlayer())); node.addListener(PlayerDisconnectEvent.class, event -> this.remove(event.getPlayer())); node.addListener(GameFinishEvent.class, event -> { - this.clearAll(); + this.cleanUp(); this.stopTask(); }); } @@ -130,7 +130,7 @@ public void remove(Player player) { * Clears every survivor's screen and stops tracking all of them, without touching the update * task — pair with {@link #stopTask()} to end a round the way {@link #registerListener} does. */ - public void clearAll() { + public void cleanUp() { for (Tracked tracked : this.survivors.values()) { this.renderer.clear(tracked.player()); } diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java index 8db40ff6..55a84651 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java @@ -1,6 +1,5 @@ package net.onelitefeather.cygnus.tunnelvision; -import net.onelitefeather.cygnus.common.util.Helper; /** * Holds the overlay state of a single survivor: which of the discrete stages is currently shown, @@ -13,7 +12,7 @@ *

* * @author TheMeinerLP - * @version 1.0.0 + * @version 1.0.1 * @since 2.7.0 */ public final class TunnelVisionStage { @@ -74,6 +73,6 @@ public int update(double combined) { double pulse = depth * (Math.sin(2.0D * Math.PI * frequency * this.elapsedSeconds) - 1.0D); int rendered = (int) Math.round(this.baseStage + pulse); - return Helper.clamp(rendered, 0, MAX_STAGE); + return Math.clamp(rendered, 0, MAX_STAGE); } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java b/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java index 2716638c..f631f1dc 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java @@ -1,32 +1,38 @@ package net.onelitefeather.cygnus.command; +import net.kyori.adventure.key.Key; import net.minestom.server.MinecraftServer; -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.component.Equippable; import net.minestom.testing.Env; import net.onelitefeather.cygnus.CygnusPlayerTestBase; -import net.onelitefeather.cygnus.overlay.EquipmentScreenOverlay; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.RecordingScreenOverlay; import net.onelitefeather.cygnus.tunnelvision.OverlayTunnelVisionRenderer; 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.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** * Verifies the command used to eyeball the vignette while the round has not started yet. + *

+ * The command is registered once for the whole class, so the overlay it draws into has to outlive a + * single test as well. How a layer reaches the head slot is {@code EquipmentScreenOverlayTest}'s + * business; what is checked here is which stage the command asks for. + *

* * @author TheMeinerLP - * @version 2.0.0 + * @version 2.1.0 * @since 2.7.0 */ class TunnelVisionCommandTest extends CygnusPlayerTestBase { + private static final RecordingScreenOverlay OVERLAY = new RecordingScreenOverlay(); + @Test @DisplayName("A requested stage is drawn right away") void stageIsDrawnOnRequest(Env env) { @@ -35,7 +41,7 @@ void stageIsDrawnOnRequest(Env env) { MinecraftServer.getCommandManager().execute(player, "tunnelvision stage 5"); - assertEquals(textureOf(5), cameraOverlay(player), "the command must draw the requested stage"); + assertEquals(textureOf(5), drawnFor(player), "the command must draw the requested stage"); } @Test @@ -47,7 +53,7 @@ void offClearsTheScreen(Env env) { MinecraftServer.getCommandManager().execute(player, "tunnelvision off"); - assertTrue(player.getHelmet().isAir(), "the preview must disappear"); + assertTrue(OVERLAY.isEmpty(player), "the preview must disappear"); } @Test @@ -60,7 +66,7 @@ void intensityStartsDrawing(Env env) { assertEquals( textureOf(TunnelVisionStage.MAX_STAGE), - cameraOverlay(player), + drawnFor(player), "full intensity starts at the tightest stage" ); } @@ -72,7 +78,7 @@ void intensityStartsDrawing(Env env) { private void register() { if (MinecraftServer.getCommandManager().getCommand("tunnelvision") != null) return; MinecraftServer.getCommandManager().register( - new TunnelVisionCommand(new OverlayTunnelVisionRenderer(new EquipmentScreenOverlay()))); + new TunnelVisionCommand(new OverlayTunnelVisionRenderer(OVERLAY))); } /** @@ -87,24 +93,22 @@ private Player spawn(Env env) { } /** - * Reads the camera overlay the player is currently wearing. + * Reads the tunnel vision texture the command last drew for a player. * * @param player the player to read - * @return the overlay texture as a string + * @return the texture currently on the tunnel vision layer, or {@code null} if there is none */ - private String cameraOverlay(Player player) { - Equippable equippable = player.getHelmet().get(DataComponents.EQUIPPABLE); - assertNotNull(equippable, "nothing is carrying an overlay"); - return equippable.cameraOverlay(); + private Key drawnFor(Player player) { + return OVERLAY.of(player, OverlayLayer.TUNNEL_VISION); } /** * Builds the texture expected for a stage. * * @param stage the stage - * @return the texture as a string + * @return the texture key */ - private String textureOf(int stage) { - return "cygnus:gui/tunnel_vision/stage_" + stage; + private Key textureOf(int stage) { + return Key.key("cygnus", "gui/tunnel_vision/stage_" + stage); } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java index edac3d84..c3c41730 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java @@ -7,38 +7,35 @@ import net.minestom.testing.Env; import net.onelitefeather.cygnus.CygnusPlayerTestBase; import net.onelitefeather.cygnus.overlay.OverlayLayer; -import net.onelitefeather.cygnus.overlay.ScreenOverlay; -import org.jetbrains.annotations.Nullable; +import net.onelitefeather.cygnus.overlay.RecordingScreenOverlay; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.util.EnumMap; -import java.util.Map; - import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; /** * Verifies which texture the tunnel vision contributes to the shared screen overlay. * * @author TheMeinerLP - * @version 2.0.0 + * @version 2.1.0 * @since 2.7.0 */ class OverlayTunnelVisionRendererTest extends CygnusPlayerTestBase { + private static final Key BLOOD_TEXTURE = Key.key("cygnus", "gui/blood/stage_1"); + @Test @DisplayName("A stage is contributed as its overlay texture") void stageIsContributedAsTexture(Env env) { - RecordingOverlay overlay = new RecordingOverlay(); + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); Player player = spawn(env); new OverlayTunnelVisionRenderer(overlay).render(player, 3); assertEquals( Key.key("cygnus", OverlayTunnelVisionRenderer.TEXTURE_PATH + "3"), - overlay.of(OverlayLayer.TUNNEL_VISION), + overlay.of(player, OverlayLayer.TUNNEL_VISION), "the texture must match the stage" ); } @@ -46,39 +43,44 @@ void stageIsContributedAsTexture(Env env) { @Test @DisplayName("Clearing drops only the tunnel vision layer") void clearingDropsOnlyItsOwnLayer(Env env) { - RecordingOverlay overlay = new RecordingOverlay(); + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); Player player = spawn(env); + overlay.set(player, OverlayLayer.BLOOD, BLOOD_TEXTURE); OverlayTunnelVisionRenderer renderer = new OverlayTunnelVisionRenderer(overlay); renderer.render(player, 4); renderer.clear(player); - assertNull(overlay.of(OverlayLayer.TUNNEL_VISION), "the layer must be gone"); - assertFalse(overlay.wasWiped(), "wiping the screen would take the blood splatter with it"); + assertNull(overlay.of(player, OverlayLayer.TUNNEL_VISION), "the layer must be gone"); + assertEquals( + BLOOD_TEXTURE, + overlay.of(player, OverlayLayer.BLOOD), + "wiping the screen would take the blood splatter with it" + ); } @Test @DisplayName("Stage zero drops the layer instead of drawing an empty texture") void zeroStageDropsTheLayer(Env env) { - RecordingOverlay overlay = new RecordingOverlay(); + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); Player player = spawn(env); new OverlayTunnelVisionRenderer(overlay).render(player, 0); - assertNull(overlay.of(OverlayLayer.TUNNEL_VISION)); + assertNull(overlay.of(player, OverlayLayer.TUNNEL_VISION)); } @Test @DisplayName("The tightest stage has a texture of its own") void tightestStageHasItsOwnTexture(Env env) { - RecordingOverlay overlay = new RecordingOverlay(); + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); Player player = spawn(env); new OverlayTunnelVisionRenderer(overlay).render(player, TunnelVisionStage.MAX_STAGE); assertEquals( Key.key("cygnus", OverlayTunnelVisionRenderer.TEXTURE_PATH + TunnelVisionStage.MAX_STAGE), - overlay.of(OverlayLayer.TUNNEL_VISION) + overlay.of(player, OverlayLayer.TUNNEL_VISION) ); } @@ -92,43 +94,4 @@ private Player spawn(Env env) { Instance instance = env.createFlatInstance(); return env.createConnection().connect(instance, new Pos(0, 40, 0)); } - - /** - * Records what a renderer contributes, standing in for the equipment-backed overlay. - */ - private static final class RecordingOverlay implements ScreenOverlay { - - private final Map layers = new EnumMap<>(OverlayLayer.class); - private boolean wiped; - - @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.wiped = true; - this.layers.clear(); - } - - /** - * @param layer the layer to look up - * @return the texture currently set for the layer, or {@code null} if there is none - */ - private @Nullable Key of(OverlayLayer layer) { - return this.layers.get(layer); - } - - /** - * @return whether the whole screen was cleared rather than a single layer - */ - private boolean wasWiped() { - return this.wiped; - } - } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java index f01d9c5b..dda7b455 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java @@ -33,7 +33,7 @@ *

* * @author TheMeinerLP - * @version 1.0.0 + * @version 1.0.1 * @since 2.7.0 */ class TunnelVisionServiceTest extends CygnusPlayerTestBase { @@ -86,7 +86,7 @@ void removedSurvivorIsCleared(Env env) { @Test @DisplayName("Clearing everyone gives every survivor their screen back") - void clearAllClearsEveryone(Env env) { + void cleanUpClearsEveryone(Env env) { RecordingRenderer renderer = new RecordingRenderer(); Instance instance = env.createFlatInstance(); Player first = spawn(env, instance, new Pos(0, 40, 0)); @@ -96,7 +96,7 @@ void clearAllClearsEveryone(Env env) { service.track(second); service.tick(); - service.clearAll(); + service.cleanUp(); assertTrue(renderer.wasCleared(first)); assertTrue(renderer.wasCleared(second)); From d1092fd0bade025ad39033d0910152796360cd07 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 23 Aug 2026 10:59:36 +0200 Subject: [PATCH 3/5] refactor(tunnel-vision): read the stamina share through StaminaHelper The adapter sat as a private method in Cygnus, right after initCommands, which put that method's closing brace inside the region the sibling effect branches also touch: resolving the merge by keeping both sides left remainingStamina unclosed and the registrations after it stranded inside it. It belongs with the other stamina helpers anyway, next to initStaminaObjects and alongside TeamHelper.survivorsOf, both of which answer the same kind of question for a service. Cygnus keeps none of it. --- .../net/onelitefeather/cygnus/Cygnus.java | 13 +---- .../cygnus/utils/StaminaHelper.java | 22 +++++++- .../cygnus/utils/StaminaShareTest.java | 51 +++++++++++++++++++ 3 files changed, 73 insertions(+), 13 deletions(-) create mode 100644 game/src/test/java/net/onelitefeather/cygnus/utils/StaminaShareTest.java diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index 02095902..a613276c 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -83,7 +83,6 @@ 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.OverlayTunnelVisionRenderer; import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer; import net.onelitefeather.cygnus.tunnelvision.TunnelVisionService; @@ -152,7 +151,7 @@ public Cygnus() { bound -> ThreadLocalRandom.current().nextInt(bound) ); this.tunnelVisionRenderer = new OverlayTunnelVisionRenderer(this.screenOverlay); - this.tunnelVisionService = new TunnelVisionService(this.tunnelVisionRenderer, this::remainingStamina); + this.tunnelVisionService = new TunnelVisionService(this.tunnelVisionRenderer, player -> StaminaHelper.remainingShare(this.staminaService, player)); this.initPhases(); this.initCommands(); this.initListener(); @@ -167,16 +166,6 @@ private void initCommands() { 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(); - } private void initListener() { Supplier phaseSupplier = this.linearPhaseSeries::getCurrentPhase; diff --git a/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java b/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java index 3a7b0668..8f1e6f83 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java +++ b/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java @@ -4,6 +4,8 @@ import net.onelitefeather.cygnus.team.TeamHelper; import net.theevilreaper.xerus.api.team.Team; import net.theevilreaper.xerus.api.team.TeamService; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.stamina.FoodBar; import net.onelitefeather.cygnus.stamina.StaminaService; @@ -11,7 +13,7 @@ * The {@link StaminaHelper} is a utility class which contains some helper methods for the stamina system. * * @author theEvilReaper - * @version 1.2.0 + * @version 1.3.0 * @since 1.0.0 */ public final class StaminaHelper { @@ -33,6 +35,24 @@ public static void initStaminaObjects(TeamService teamService, StaminaService st staminaService.createStaminaBars(allocation.survivors()); } + /** + * Reads how much of a survivor's stamina is left, as a share between zero and one. + *

+ * Reads a full bar for a player who has none registered yet. The effects that drive themselves + * off stamina are ticked from a scheduler, so they ask about players who have joined but are not + * playing a round - answering "untouched" there is what keeps the effect off for them, and it + * keeps a null check out of every caller. + *

+ * + * @param staminaService the service holding the bars + * @param player the player to read + * @return the remaining share in {@code [0, 1]}, or {@code 1} while the player has no bar + */ + public static double remainingShare(StaminaService staminaService, Player player) { + FoodBar bar = staminaService.getFoodBar(player); + return bar == null ? 1.0D : bar.remainingShare(); + } + private StaminaHelper() { throw new UnsupportedOperationException(); } diff --git a/game/src/test/java/net/onelitefeather/cygnus/utils/StaminaShareTest.java b/game/src/test/java/net/onelitefeather/cygnus/utils/StaminaShareTest.java new file mode 100644 index 00000000..f8b4919e --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/utils/StaminaShareTest.java @@ -0,0 +1,51 @@ +package net.onelitefeather.cygnus.utils; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.stamina.StaminaService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies {@link StaminaHelper#remainingShare(StaminaService, Player)}, the stamina reading the + * tunnel vision drives itself off. + *

+ * The case that matters is the player without a bar. The effect is ticked from a scheduler, so it + * asks about players who are connected but not playing a round; reading a full bar there is what + * keeps the effect off for them instead of closing their view completely. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class StaminaShareTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("A player without a bar reads as untouched rather than empty") + void playerWithoutBarIsFull(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createConnection().connect(instance, new Pos(0, 40, 0)); + + assertEquals(1.0D, StaminaHelper.remainingShare(new StaminaService(), player), 1.0E-6D); + } + + @Test + @DisplayName("A survivor with a fresh bar reads as untouched") + void freshBarIsFull(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createConnection().connect(instance, new Pos(0, 40, 0)); + + StaminaService staminaService = new StaminaService(); + staminaService.createStaminaBars(Set.of(player)); + + assertEquals(1.0D, StaminaHelper.remainingShare(staminaService, player), 1.0E-6D); + } +} From ca68d187b6210823f65599d52f4c2302e093bfe3 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 23 Aug 2026 11:04:12 +0200 Subject: [PATCH 4/5] docs(tunnel-vision): bring the design spec back in line with the code The spec still described the effect as it was first drafted, before the slender half of it became its own feature: - "Intensity" documented a proximity term, a view factor and a combination formula. TunnelVisionIntensity has only fromStamina; what closed the view as the Slender approached is now the gaze glitch, on its own overlay layer. The section says why they are apart rather than pretending they are together. - "Wiring" claimed the service exists only when a resource pack is configured. It is gated by OverlayProperties, which is deliberately not the same question - a player can arrive with the pack already installed, and can decline one a server hands out. - "Failure modes" listed a missing Slender and a Slender in another instance, neither of which this effect reads any more, and had a missing FoodBar reading as an empty bar when it reads as a full one. - "Tests" named assertions about the Slender that no test makes. TunnelVisionStage.update took a parameter called `combined` for a value that is no longer a combination of anything; it is `intensity` now, which is what every constant around it already called it. --- .../specs/2026-08-10-tunnel-vision-design.md | 85 +++++++++---------- .../tunnelvision/TunnelVisionStage.java | 10 +-- 2 files changed, 46 insertions(+), 49 deletions(-) diff --git a/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md index 6ed1554d..f4936771 100644 --- a/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md +++ b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md @@ -30,8 +30,8 @@ Reference: [Shader – Minecraft Wiki](https://minecraft.wiki/w/Shader), ## 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. +`TunnelVisionIntensity` turns the survivor's stamina into a value in `[0, 1]`. It has no Minestom +dependency at all, so it is testable without a server. **Stamina.** With `s = currentSpeedCount / 20`: @@ -42,27 +42,14 @@ 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: +**Why the Slender is not an input.** An earlier draft folded a proximity term into this value, so +that the view also narrowed as he closed in. That half of the idea became its own effect: the +slender gaze glitch tears the screen when he is in view, driven by `SlenderGaze` with its own range +and field-of-view constants, and both draw onto the same `ScreenOverlay` as separate layers. Keeping +them apart means each can be tuned - and switched off - without touching the other, and a survivor +who is merely exhausted does not get the effect meant for one who is being hunted. -``` -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 +**No line-of-sight raycast.** Neither effect dampens on a wall between survivor and Slender. It would cost a block walk per survivor per tick, and "I can feel him through the wall" is the better atmosphere anyway. @@ -74,14 +61,15 @@ painting and effect textures only — so the animation is the server walking thr Thirty-two of them make the view close smoothly; at sixteen the steps were visible as the tunnel narrowed. Two mechanisms sit on top, in this order: -1. **Hysteresis on the base value.** `baseStage` starts as `round(combined * 32)` and afterwards - only moves when `combined * 32` is more than 0.6 stages away from it. Distance and stamina both - jitter constantly; without this the overlay flickers at every stage boundary. +1. **Hysteresis on the base value.** `baseStage` starts as `round(intensity * 32)` and afterwards + only moves when `intensity * 32` is more than 0.6 stages away from it. Stamina jitters constantly + as a survivor starts and stops sprinting; without this the overlay flickers at every stage + boundary. 2. **Pulse on top of the stabilised stage.** ``` -depth = (32 / 16) * combined // a sixteenth of the scale, whatever the stage count is -frequency = 1.0 + 1.5 * combined // Hz +depth = (32 / 16) * intensity // a sixteenth of the scale, whatever the stage count is +frequency = 1.0 + 1.5 * intensity // Hz display = clamp(round(baseStage + depth * (sin(2*pi * frequency * t) - 1)), 0, 32) ``` @@ -148,8 +136,9 @@ New package `net.onelitefeather.cygnus.tunnelvision`: vignette from the lobby without a running round. `stage` freezes one stage to judge the drawing; `intensity` runs the real heartbeat. -One task for everyone rather than one per player as `StaminaBar` does: the Slender position is -read once per tick instead of once per survivor, and cleanup happens in one place. +One task for everyone rather than one per player as `StaminaBar` does: a single 100 ms task walks +every tracked survivor, so the count of scheduler tasks does not grow with the lobby, and cleanup +happens in one place. ## Wiring @@ -171,10 +160,14 @@ 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 - textures do not exist and players would get a fullscreen missing-texture checkerboard. + game logic off a display detail. `StaminaHelper.remainingShare` wraps the lookup, so the service + takes a plain `ToDoubleFunction` and `Cygnus` keeps none of it. +- **The effects are gated by `OverlayProperties`, not by the resource pack.** An earlier draft tied + them to `resourcePackService` being present, on the grounds that the textures would otherwise be + missing. That conflated two questions: whether this server hands out a pack, and whether a player + has one loaded - a player can arrive with the pack already installed, and a server can hand one + out that a player declines. `cygnus.overlays` answers the second directly, and + `Cygnus.registerOverlayListeners` holds that one gate for all three effects. ## Failure modes @@ -182,16 +175,16 @@ 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 | +| No `FoodBar` registered for a player | reads as a full bar, so the effect stays off | +| Ticked outside a round (before `GameStartEvent`, after `GameFinishEvent`) | nobody is tracked; the task does nothing | | Stage drops to 0 | the layer is dropped rather than drawn — otherwise the last vignette stays on the head | | Player dies or becomes a spectator | explicit `clear()`, same reason | +| The pack is not loaded on a client | the overlay resolves to a missing texture for that player only; the server side is unaffected | ## Tests -- `TunnelVisionIntensityTest` — plain JUnit: edge values (full stamina at long range gives 0, - empty stamina at close range gives 1), monotonicity in both inputs, and the view factor. +- `TunnelVisionIntensityTest` — plain JUnit: the threshold at half a bar, an empty bar giving 1, + and monotonicity as the bar drains. - `TunnelVisionStageTest` — plain JUnit: the pulse at full intensity, steadiness at low intensity, hysteresis (a small oscillation around a stage boundary must not change the stage), and bounds. - `OverlayTunnelVisionRendererTest` — Cyano: the renderer contributes the expected texture, and @@ -200,12 +193,16 @@ The service keeps running in all of these; none of them throws. - `EquipmentScreenOverlayTest` — Cyano: a layer becomes a camera overlay on the head, the blood wins over the tunnel vision and the tunnel vision returns afterwards, the last layer leaving empties the slot, and an unchanged overlay is not re-sent. -- `TunnelVisionServiceTest` — lifecycle: start and stop, removing a player, behaviour with no - Slender or one in another instance, and the four lifecycle events. +- `TunnelVisionServiceTest` — lifecycle: start and stop, removing a player, and the four lifecycle + events it registers for. - `TunnelVisionCommandTest` — the command draws the requested stage, previews an intensity, and clears on `off`. - `FoodBarTest` — a fresh bar reports a full share. - -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. +- `StaminaShareTest` — `StaminaHelper.remainingShare` reads a full bar for a player who has none, + which is what keeps the effect off for anyone not playing a round. + +The pack side cannot be tested automatically. The look of the vignette is verified in-game against a +snapshot build of `cygnus-pack`; that is an explicit step in the implementation plan, not an +afterthought. What *can* be checked mechanically is that both sides agree on the texture paths: +`OverlayTextureKeys` builds them and `tools/README.md` in `cygnus-pack` documents them, and a +mismatch shows up as a fullscreen missing-texture checkerboard with nothing in any log. diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java index 55a84651..0a1ba83a 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java @@ -55,18 +55,18 @@ public final class TunnelVisionStage { /** * Advances the heartbeat by one tick and reports the stage to render. * - * @param combined the combined intensity from {@link TunnelVisionIntensity} + * @param intensity the 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; + public int update(double intensity) { + double exactStage = intensity * MAX_STAGE; if (this.baseStage < 0 || Math.abs(exactStage - this.baseStage) > HYSTERESIS) { this.baseStage = (int) Math.round(exactStage); } this.elapsedSeconds += TICK_SECONDS; - double frequency = BASE_FREQUENCY + FREQUENCY_GAIN * combined; - double depth = PULSE_DEPTH * combined; + double frequency = BASE_FREQUENCY + FREQUENCY_GAIN * intensity; + double depth = PULSE_DEPTH * intensity; // The heartbeat only ever opens the view up, never beyond the base stage: at full // intensity the base stage is the maximum, and a symmetric pulse would be clipped away // exactly where it matters most. From 4eabe7c56efdf36fc93dfbaa055fb7b81aaaf2dd Mon Sep 17 00:00:00 2001 From: theEvilReaper Date: Sun, 23 Aug 2026 22:00:13 +0200 Subject: [PATCH 5/5] chore(game): remove debug commands and tests --- .../net/onelitefeather/cygnus/Cygnus.java | 4 - .../cygnus/command/CommandSenders.java | 54 --------- .../cygnus/command/GlitchCommand.java | 50 -------- .../cygnus/command/TunnelVisionCommand.java | 108 ----------------- .../cygnus/command/GlitchCommandTest.java | 91 -------------- .../command/TunnelVisionCommandTest.java | 114 ------------------ 6 files changed, 421 deletions(-) delete mode 100644 game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java delete mode 100644 game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java delete mode 100644 game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java delete mode 100644 game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java delete mode 100644 game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index a613276c..c86e9f2f 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -41,10 +41,8 @@ import net.minestom.server.network.packet.client.common.ClientSettingsPacket; import net.minestom.server.network.packet.client.play.ClientEntityActionPacket; import net.onelitefeather.cygnus.ambient.AmbientProvider; -import net.onelitefeather.cygnus.command.GlitchCommand; import net.onelitefeather.cygnus.blood.BloodSplatterService; import net.onelitefeather.cygnus.command.StartCommand; -import net.onelitefeather.cygnus.command.TunnelVisionCommand; import net.onelitefeather.cygnus.common.ListenerHandling; import net.onelitefeather.cygnus.common.bootstrap.ServiceBootstrap; import net.onelitefeather.cygnus.common.config.GameConfig; @@ -162,8 +160,6 @@ public Cygnus() { private void initCommands() { var manager = MinecraftServer.getCommandManager(); manager.register(new StartCommand(this.linearPhaseSeries)); - manager.register(new GlitchCommand(this.slenderGazeService)); - manager.register(new TunnelVisionCommand(this.tunnelVisionRenderer)); } diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java b/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java deleted file mode 100644 index b394ec95..00000000 --- a/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java +++ /dev/null @@ -1,54 +0,0 @@ -package net.onelitefeather.cygnus.command; - -import net.kyori.adventure.text.Component; -import net.minestom.server.command.CommandSender; -import net.minestom.server.entity.Player; -import org.jetbrains.annotations.Nullable; - -/** - * Narrows a {@link CommandSender} down to a {@link Player}, since every preview command in this - * package draws on a screen and only a player has one. - *

- * On the branch these preview commands were cut from, {@code TunnelVisionCommand}, - * {@code BloodCommand} and {@code GlitchCommand} each hand-rolled an identical - * {@code private static @Nullable Player asPlayer(CommandSender)}, differing only in the message - * sent back to the console. This type is that method, extracted once, so that the three land on top - * of it instead of bringing a fourth copy each. The message itself is passed in rather than - * assembled here, so it can stay with the other player-facing texts in - * {@link net.onelitefeather.cygnus.common.Messages}. - *

- *

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

- * - * @author TheMeinerLP - * @version 1.0.0 - * @since 2.7.0 - */ -public final class CommandSenders { - - private CommandSenders() { - } - - /** - * Narrows the given sender down to a player, telling them why not if it cannot. - * - * @param sender the sender to narrow - * @param message the message to send back when the sender is not a player, taken from - * {@link net.onelitefeather.cygnus.common.Messages} like every other - * player-facing text - * @return the player, or {@code null} if the sender has no screen to draw on - */ - public static @Nullable Player asPlayer(CommandSender sender, Component message) { - if (sender instanceof Player player) return player; - sender.sendMessage(message); - return null; - } -} diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java deleted file mode 100644 index 3ead6384..00000000 --- a/game/src/main/java/net/onelitefeather/cygnus/command/GlitchCommand.java +++ /dev/null @@ -1,50 +0,0 @@ -package net.onelitefeather.cygnus.command; - -import net.minestom.server.command.builder.Command; -import net.minestom.server.command.builder.arguments.ArgumentType; -import net.minestom.server.entity.Player; -import net.onelitefeather.cygnus.common.Messages; -import net.onelitefeather.cygnus.gaze.SlenderGaze; -import net.onelitefeather.cygnus.gaze.SlenderGazeService; - -/** - * Puts the slender's glitch on screen without him being there, so the drawings can be judged from - * the lobby. - *

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

- * - * @author TheMeinerLP - * @version 2.0.0 - * @since 2.7.0 - */ -public final class GlitchCommand extends Command { - - /** Completes the sentence {@code CommandSenders} sends back to a sender without a screen. */ - - /** - * Creates the command. - * - * @param service the service that draws the tearing - */ - public GlitchCommand(SlenderGazeService service) { - super("glitch"); - - var level = ArgumentType.Integer("level").between(1, SlenderGaze.LEVELS); - - this.setDefaultExecutor((sender, context) -> - sender.sendMessage(Messages.getGlitchUsageMessage(SlenderGaze.LEVELS))); - - this.addSyntax((sender, context) -> { - Player player = CommandSenders.asPlayer(sender, Messages.ONLY_PLAYERS_HAVE_A_VIEW); - if (player == null) return; - service.preview(player, context.get(level) - 1); - }, level); - - this.addSyntax((sender, context) -> { - Player player = CommandSenders.asPlayer(sender, Messages.ONLY_PLAYERS_HAVE_A_VIEW); - if (player == null) return; - service.preview(player, SlenderGaze.NONE); - }, ArgumentType.Literal("off")); - } -} diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java deleted file mode 100644 index e8225ded..00000000 --- a/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java +++ /dev/null @@ -1,108 +0,0 @@ -package net.onelitefeather.cygnus.command; - -import net.minestom.server.command.builder.Command; -import net.minestom.server.command.builder.arguments.ArgumentType; -import net.minestom.server.entity.Player; -import net.onelitefeather.cygnus.common.Messages; -import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer; -import net.onelitefeather.cygnus.tunnelvision.TunnelVisionStage; -import net.onelitefeather.cygnus.utils.PlayerState; -import net.onelitefeather.cygnus.utils.RepeatingTask; - -import java.time.temporal.ChronoUnit; - -/** - * 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-32>} freezes a single stage, so the drawing of a single overlay - * texture can be judged on its own. {@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}. The upper bound of {@code stage} follows - * {@link TunnelVisionStage#MAX_STAGE}. - *

- * - * @author TheMeinerLP - * @version 1.2.0 - * @since 2.7.0 - */ -public final class TunnelVisionCommand extends Command { - - - private final TunnelVisionRenderer renderer; - private final PlayerState previews; - - /** - * Creates the command. - * - * @param renderer the renderer that draws the preview - */ - public TunnelVisionCommand(TunnelVisionRenderer renderer) { - super("tunnelvision"); - this.renderer = renderer; - this.previews = new PlayerState<>(); - - 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.getTunnelVisionUsageMessage(TunnelVisionStage.MAX_STAGE) - )); - - this.addSyntax((sender, context) -> { - Player player = CommandSenders.asPlayer(sender, Messages.ONLY_PLAYERS_CAN_PREVIEW); - if (player == null) return; - this.stopPreview(player); - this.renderer.render(player, context.get(stage)); - }, ArgumentType.Literal("stage"), stage); - - this.addSyntax((sender, context) -> { - Player player = CommandSenders.asPlayer(sender, Messages.ONLY_PLAYERS_CAN_PREVIEW); - if (player == null) return; - this.startPreview(player, context.get(intensity)); - }, ArgumentType.Literal("intensity"), intensity); - - this.addSyntax((sender, context) -> { - Player player = CommandSenders.asPlayer(sender, Messages.ONLY_PLAYERS_CAN_PREVIEW); - 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(); - // The task alone would only draw from its first repetition onward, so the initial stage is - // rendered here, the same way BloodSplatterService and SlenderGazeService draw their first - // frame before ever starting their own repeating task. - this.renderer.render(player, stage.update(intensity)); - - RepeatingTask task = new RepeatingTask(() -> { - if (!player.isOnline()) { - this.stopPreview(player); - return; - } - this.renderer.render(player, stage.update(intensity)); - }); - this.previews.put(player, task); - task.start(TunnelVisionStage.TICK_MILLIS, ChronoUnit.MILLIS); - } - - /** - * Ends a running preview, leaving whatever is on screen untouched. - * - * @param player the player whose preview to end - */ - private void stopPreview(Player player) { - RepeatingTask task = this.previews.remove(player); - if (task != null) task.stop(); - } -} diff --git a/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java b/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java deleted file mode 100644 index f42226e2..00000000 --- a/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package net.onelitefeather.cygnus.command; - -import net.minestom.server.MinecraftServer; -import net.minestom.server.command.builder.Command; -import net.minestom.server.coordinate.Pos; -import net.minestom.server.entity.Player; -import net.minestom.server.instance.Instance; -import net.minestom.testing.Env; -import net.onelitefeather.cygnus.CygnusPlayerTestBase; -import net.onelitefeather.cygnus.gaze.SlenderGaze; -import net.onelitefeather.cygnus.gaze.SlenderGazeService; -import net.onelitefeather.cygnus.overlay.OverlayLayer; -import net.onelitefeather.cygnus.overlay.RecordingScreenOverlay; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; - -/** - * Verifies the command used to preview the slender's glitch without him being there. - * - * @author TheMeinerLP - * @version 2.0.0 - * @since 2.7.0 - */ -class GlitchCommandTest extends CygnusPlayerTestBase { - - @Test - @DisplayName("A requested level is drawn right away") - void levelIsDrawnOnRequest(Env env) { - RecordingScreenOverlay overlay = new RecordingScreenOverlay(); - Player player = spawn(env); - register(overlay); - - MinecraftServer.getCommandManager().execute(player, "glitch 2"); - - assertNotNull(overlay.of(player, OverlayLayer.GLITCH), "the command has to put the glitch on screen"); - } - - @Test - @DisplayName("Switching the preview off clears the screen") - void offClearsTheScreen(Env env) { - RecordingScreenOverlay overlay = new RecordingScreenOverlay(); - Player player = spawn(env); - register(overlay); - MinecraftServer.getCommandManager().execute(player, "glitch 2"); - - MinecraftServer.getCommandManager().execute(player, "glitch off"); - - assertNull(overlay.of(player, OverlayLayer.GLITCH), "the preview must disappear"); - } - - @Test - @DisplayName("Every level of the tearing can be requested") - void everyLevelCanBeRequested(Env env) { - RecordingScreenOverlay overlay = new RecordingScreenOverlay(); - Player player = spawn(env); - register(overlay); - - for (int level = 1; level <= SlenderGaze.LEVELS; level++) { - overlay.clear(player); - MinecraftServer.getCommandManager().execute(player, "glitch " + level); - assertNotNull(overlay.of(player, OverlayLayer.GLITCH), "no glitch for level " + level); - } - } - - /** - * Registers the command under test against the given overlay. The environment is shared across - * the tests in this class, so any command left over from an earlier one — still drawing into - * that test's overlay — has to go first. - * - * @param overlay the overlay the service draws into - */ - private void register(RecordingScreenOverlay overlay) { - Command previous = MinecraftServer.getCommandManager().getCommand("glitch"); - if (previous != null) MinecraftServer.getCommandManager().unregister(previous); - MinecraftServer.getCommandManager().register(new GlitchCommand(new SlenderGazeService(overlay, () -> null))); - } - - /** - * Connects a player into a fresh instance. - * - * @param env the test environment - * @return the connected player - */ - private Player spawn(Env env) { - Instance instance = env.createFlatInstance(); - return env.createConnection().connect(instance, new Pos(0, 40, 0)); - } -} diff --git a/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java b/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java deleted file mode 100644 index f631f1dc..00000000 --- a/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java +++ /dev/null @@ -1,114 +0,0 @@ -package net.onelitefeather.cygnus.command; - -import net.kyori.adventure.key.Key; -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.testing.Env; -import net.onelitefeather.cygnus.CygnusPlayerTestBase; -import net.onelitefeather.cygnus.overlay.OverlayLayer; -import net.onelitefeather.cygnus.overlay.RecordingScreenOverlay; -import net.onelitefeather.cygnus.tunnelvision.OverlayTunnelVisionRenderer; -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. - *

- * The command is registered once for the whole class, so the overlay it draws into has to outlive a - * single test as well. How a layer reaches the head slot is {@code EquipmentScreenOverlayTest}'s - * business; what is checked here is which stage the command asks for. - *

- * - * @author TheMeinerLP - * @version 2.1.0 - * @since 2.7.0 - */ -class TunnelVisionCommandTest extends CygnusPlayerTestBase { - - private static final RecordingScreenOverlay OVERLAY = new RecordingScreenOverlay(); - - @Test - @DisplayName("A requested stage is drawn right away") - void stageIsDrawnOnRequest(Env env) { - Player player = spawn(env); - register(); - - MinecraftServer.getCommandManager().execute(player, "tunnelvision stage 5"); - - assertEquals(textureOf(5), drawnFor(player), "the command must draw the requested stage"); - } - - @Test - @DisplayName("Switching the preview off clears the screen") - void offClearsTheScreen(Env env) { - Player player = spawn(env); - register(); - MinecraftServer.getCommandManager().execute(player, "tunnelvision stage 5"); - - MinecraftServer.getCommandManager().execute(player, "tunnelvision off"); - - assertTrue(OVERLAY.isEmpty(player), "the preview must disappear"); - } - - @Test - @DisplayName("A previewed intensity starts at its stage") - void intensityStartsDrawing(Env env) { - Player player = spawn(env); - register(); - - MinecraftServer.getCommandManager().execute(player, "tunnelvision intensity 1.0"); - - assertEquals( - textureOf(TunnelVisionStage.MAX_STAGE), - drawnFor(player), - "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 OverlayTunnelVisionRenderer(OVERLAY))); - } - - /** - * 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 tunnel vision texture the command last drew for a player. - * - * @param player the player to read - * @return the texture currently on the tunnel vision layer, or {@code null} if there is none - */ - private Key drawnFor(Player player) { - return OVERLAY.of(player, OverlayLayer.TUNNEL_VISION); - } - - /** - * Builds the texture expected for a stage. - * - * @param stage the stage - * @return the texture key - */ - private Key textureOf(int stage) { - return Key.key("cygnus", "gui/tunnel_vision/stage_" + stage); - } -}