diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java b/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java index 960bebd6..01fd40d8 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java @@ -32,6 +32,7 @@ public final class Messages { public static final Component LIGHT_WENT_OUT; public static final Component ONLY_PLAYERS_HAVE_A_VIEW; public static final Component ONLY_PLAYERS_CAN_BLEED; + public static final Component ONLY_PLAYERS_CAN_PREVIEW; private static final Component PAGE_FOUND_PART; private static final Component LEAVE_PART; private static final Component JOIN_PART; @@ -66,6 +67,7 @@ public final class Messages { LIGHT_WENT_OUT = withMiniPrefix("Your light went out!"); ONLY_PLAYERS_HAVE_A_VIEW = withMiniPrefix("Only players have a view to lose."); ONLY_PLAYERS_CAN_BLEED = withMiniPrefix("Only players can bleed."); + ONLY_PLAYERS_CAN_PREVIEW = withMiniPrefix("Only players can preview the tunnel vision."); SURVIVOR_JOIN_PART_UPPER = withMiniPrefix("You are a Survivor! Find various Pages").append(Component.space()); @@ -208,6 +210,22 @@ public static Component getSlenderWinMessage(@Nullable Player player) { .append(Component.newline()); } + /** + * Returns a {@link Component} explaining how the tunnel vision preview command is used. + *

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

+ * + * @param maxStage the highest stage the preview accepts + * @return the created {@link Component} reference + */ + @Contract(value = "_ -> new", pure = true) + public static Component getTunnelVisionUsageMessage(int maxStage) { + return withMiniPrefix("Usage: /tunnelvision stage <0-" + maxStage + + "> | intensity <0.0-1.0> | off"); + } + @Contract(value = "_ -> new", pure = true) public static Component getJoinMessage(Player player) { return PREFIX.append(Component.space()).append(withMini("" + player.getUsername() + "")) diff --git a/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md new file mode 100644 index 00000000..f4936771 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md @@ -0,0 +1,208 @@ +# Tunnel vision for survivors + +## Goal + +A survivor's view narrows as the situation gets worse: the screen edges darken and pulse like a +heartbeat when stamina runs low, when the Slender closes in, or both. The effect is per player, +continuous rather than on/off, and driven entirely by the server. + +## Why not a shader + +The obvious implementation is a post-processing shader, and on Minecraft 26.2 it does not work. + +A resource-pack post effect only runs in contexts vanilla decides: the menu blur, spectator mob +vision, the glowing outline, and the "Improved Transparency" video setting. None of them can be +switched on for one player from the server, and none carries an intensity parameter. The only way +to force one on 26.2 is hijacking spectator mob vision by pointing the player's camera at a hidden +enderman, which takes over the camera and makes the game unplayable. + +That changes in 26.3: snapshot 3 (7 July 2026) added `/posteffect add|remove ` +plus the always-on `minecraft:end_of_frame` context. 26.3 is still in snapshots, and Minestom +ships 26.2 (`net.minestom:minestom:2026.07.22-26.2`). + +So the effect is rendered as the `camera_overlay` of an item worn on the head — the mechanism +behind the carved pumpkin, and the one thing in vanilla that draws a texture across the whole +screen and scales it with the viewport. Behind an interface that a post-effect renderer can slot +into once 26.3 and Minestom support land; the gameplay side does not change when that happens. + +Reference: [Shader – Minecraft Wiki](https://minecraft.wiki/w/Shader), +[Java Edition 26.3 Snapshot 3](https://minecraft.wiki/w/Java_Edition_26.3_Snapshot_3). + +## Intensity + +`TunnelVisionIntensity` turns the survivor's stamina into a value in `[0, 1]`. It has no Minestom +dependency at all, so it is testable without a server. + +**Stamina.** With `s = currentSpeedCount / 20`: + +``` +stamina = s >= 0.5 ? 0 : ((0.5 - s) / 0.5)^2 +``` + +Nothing happens above half a bar; below it the curve accelerates, so the last few percent are far +more dramatic than crossing the halfway mark. + +**Why the Slender is not an input.** An earlier draft folded a proximity term into this value, so +that the view also narrowed as he closed in. That half of the idea became its own effect: the +slender gaze glitch tears the screen when he is in view, driven by `SlenderGaze` with its own range +and field-of-view constants, and both draw onto the same `ScreenOverlay` as separate layers. Keeping +them apart means each can be tuned - and switched off - without touching the other, and a survivor +who is merely exhausted does not get the effect meant for one who is being hunted. + +**No line-of-sight raycast.** Neither effect dampens on a wall between survivor and Slender. It +would cost a block walk per survivor per tick, and "I can feel him through the wall" is the better +atmosphere anyway. + +## Stages and pulse + +The continuous value is quantised to 32 stages, which double as the frames of the heartbeat. +Minecraft cannot animate an overlay texture — `.mcmeta` animation covers block, item, particle, +painting and effect textures only — so the animation is the server walking through the frames. +Thirty-two of them make the view close smoothly; at sixteen the steps were visible as the tunnel +narrowed. Two mechanisms sit on top, in this order: + +1. **Hysteresis on the base value.** `baseStage` starts as `round(intensity * 32)` and afterwards + only moves when `intensity * 32` is more than 0.6 stages away from it. Stamina jitters constantly + as a survivor starts and stops sprinting; without this the overlay flickers at every stage + boundary. +2. **Pulse on top of the stabilised stage.** + +``` +depth = (32 / 16) * intensity // a sixteenth of the scale, whatever the stage count is +frequency = 1.0 + 1.5 * intensity // Hz +display = clamp(round(baseStage + depth * (sin(2*pi * frequency * t) - 1)), 0, 32) +``` + +The heartbeat gets faster and deeper as it gets tighter, and stays nearly invisible at low +intensity — a depth that does not scale would make stage 1 flicker between 0 and 1. + +The pulse only ever opens the view back up, never past the base stage. A symmetric pulse would be +clipped away exactly where it matters most: at full intensity the base stage is already the +maximum, so everything above it is lost and the heartbeat disappears. + +The order matters: hysteresis applies to the base value, the pulse is added afterwards. Reversed, +the hysteresis would damp out exactly the pulsing it is there to allow. + +Stage 0 is not a texture. It clears the overlay. + +**Service tick: 100 ms.** The heartbeat reaches 2.5 Hz, and sampling it at 4 Hz — a 250 ms tick — +aliases it into something jerky. 100 ms samples it ten times per second, which is smooth and still +a tiny packet per survivor. + +## Pack assets + +In `cygnus-pack`, namespace `cygnus`: + +``` +pack/assets/cygnus/textures/gui/tunnel_vision/stage_1.png … stage_32.png +pack/assets/cygnus/equipment/empty.json +``` + +Each texture is 768×432 — 16:9, because the client stretches a camera overlay across the screen +rather than fitting it. The darkening closes in from all four edges rather than as a circle from +the middle: it is a superellipse whose exponent eases from 4 at stage 1, a rounded rectangle +framing the screen, to 2 at stage 32, where a plain ellipse reads as a tunnel. Textures are +generated by `tools/generate_overlay.py`, which also produces the blood splatter. + +**How it reaches the screen.** The server puts an item in the player's head slot carrying +`equippable{slot:head, camera_overlay:"cygnus:gui/tunnel_vision/stage_N"}`. Three details keep the +carrier out of the way: + +- `asset_id` points at `cygnus:empty`, an equipment model with no layers. Without it Minecraft + draws the item itself on the player's head. +- `swappable`, `dispensable` and `damage_on_hurt` are all off, so nobody strips the overlay by + accident and it is not treated as armour. +- The equip sound is `minecraft:intentionally_empty`; the default would click on every stage + change, ten times a second. + +**The position needs no calibration.** This is the whole reason for the mechanism: the client +scales the overlay to the viewport, so it fits every resolution and GUI scale on its own. A font +glyph cannot — its size is fixed in the pack, so it has to be calibrated against one resolution +and drifts on every other. + +## Components + +New package `net.onelitefeather.cygnus.tunnelvision`: + +- `TunnelVisionIntensity` — the calculation above. Pure, no server needed to test it. +- `TunnelVisionStage` — one survivor's overlay state: hysteresis and heartbeat. Also pure. +- `TunnelVisionRenderer` — `render(player, stage)` and `clear(player)`. This is the seam a + post-effect renderer slots into on 26.3. +- `OverlayTunnelVisionRenderer` — the implementation described above; it contributes a texture to + the shared `ScreenOverlay` rather than dressing the player itself. +- `TunnelVisionService` — holds a `TunnelVisionStage` per survivor and ticks all of them in one + scheduler task. +- `TunnelVisionCommand` — `/tunnelvision stage <0-32> | intensity <0.0-1.0> | off`, for judging the + vignette from the lobby without a running round. `stage` freezes one stage to judge the drawing; + `intensity` runs the real heartbeat. + +One task for everyone rather than one per player as `StaminaBar` does: a single 100 ms task walks +every tracked survivor, so the count of scheduler tasks does not grow with the lobby, and cleanup +happens in one place. + +## Wiring + +`Cygnus` creates the service and the command. The service then listens for the round's lifecycle +itself, the way `SpectatorService` and `ResourcePackService` already do, rather than being called +from the existing listeners: + +| Event | What happens | +| --- | --- | +| `GameStartEvent` | starts drawing for the survivor team | +| `PlayerDeathEvent` | removes the player (transition to spectator) | +| `PlayerDisconnectEvent` | removes the player | +| `GameFinishEvent` | full cleanup | + +This keeps `GameStartListener`, `PlayerDeathListener` and `PlayerQuitListener` — and their tests — +untouched: none of them has anything the service needs beyond the moment itself. + +Two changes to existing code: + +- **`FoodBar` gains a getter** for normalised stamina. `currentSpeedCount` is private today. The + service could read `player.getExp()`, since `FoodBar` mirrors the value there, but that hangs + game logic off a display detail. `StaminaHelper.remainingShare` wraps the lookup, so the service + takes a plain `ToDoubleFunction` and `Cygnus` keeps none of it. +- **The effects are gated by `OverlayProperties`, not by the resource pack.** An earlier draft tied + them to `resourcePackService` being present, on the grounds that the textures would otherwise be + missing. That conflated two questions: whether this server hands out a pack, and whether a player + has one loaded - a player can arrive with the pack already installed, and a server can hand one + out that a player declines. `cygnus.overlays` answers the second directly, and + `Cygnus.registerOverlayListeners` holds that one gate for all three effects. + +## Failure modes + +The service keeps running in all of these; none of them throws. + +| Situation | Behaviour | +| --- | --- | +| No `FoodBar` registered for a player | reads as a full bar, so the effect stays off | +| Ticked outside a round (before `GameStartEvent`, after `GameFinishEvent`) | nobody is tracked; the task does nothing | +| Stage drops to 0 | the layer is dropped rather than drawn — otherwise the last vignette stays on the head | +| Player dies or becomes a spectator | explicit `clear()`, same reason | +| The pack is not loaded on a client | the overlay resolves to a missing texture for that player only; the server side is unaffected | + +## Tests + +- `TunnelVisionIntensityTest` — plain JUnit: the threshold at half a bar, an empty bar giving 1, + and monotonicity as the bar drains. +- `TunnelVisionStageTest` — plain JUnit: the pulse at full intensity, steadiness at low intensity, + hysteresis (a small oscillation around a stage boundary must not change the stage), and bounds. +- `OverlayTunnelVisionRendererTest` — Cyano: the renderer contributes the expected texture, and + `clear()` drops only its own layer rather than wiping the screen out from under the blood + splatter. +- `EquipmentScreenOverlayTest` — Cyano: a layer becomes a camera overlay on the head, the blood + wins over the tunnel vision and the tunnel vision returns afterwards, the last layer leaving + empties the slot, and an unchanged overlay is not re-sent. +- `TunnelVisionServiceTest` — lifecycle: start and stop, removing a player, and the four lifecycle + events it registers for. +- `TunnelVisionCommandTest` — the command draws the requested stage, previews an intensity, and + clears on `off`. +- `FoodBarTest` — a fresh bar reports a full share. +- `StaminaShareTest` — `StaminaHelper.remainingShare` reads a full bar for a player who has none, + which is what keeps the effect off for anyone not playing a round. + +The pack side cannot be tested automatically. The look of the vignette is verified in-game against a +snapshot build of `cygnus-pack`; that is an explicit step in the implementation plan, not an +afterthought. What *can* be checked mechanically is that both sides agree on the texture paths: +`OverlayTextureKeys` builds them and `tools/README.md` in `cygnus-pack` documents them, and a +mismatch shows up as a fullscreen missing-texture checkerboard with nothing in any log. diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index 9c2efd93..c86e9f2f 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -41,7 +41,6 @@ import net.minestom.server.network.packet.client.common.ClientSettingsPacket; import net.minestom.server.network.packet.client.play.ClientEntityActionPacket; import net.onelitefeather.cygnus.ambient.AmbientProvider; -import net.onelitefeather.cygnus.command.GlitchCommand; import net.onelitefeather.cygnus.blood.BloodSplatterService; import net.onelitefeather.cygnus.command.StartCommand; import net.onelitefeather.cygnus.common.ListenerHandling; @@ -82,6 +81,9 @@ import net.onelitefeather.cygnus.resourcepack.ResourcePackService; import net.onelitefeather.cygnus.stamina.SlenderBarTrigger; import net.onelitefeather.cygnus.stamina.StaminaService; +import net.onelitefeather.cygnus.tunnelvision.OverlayTunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionService; import net.onelitefeather.cygnus.utils.StaminaHelper; import net.onelitefeather.cygnus.view.GameView; import net.onelitefeather.cygnus.view.GameViewImpl; @@ -114,6 +116,8 @@ public final class Cygnus implements TeamCreator, ListenerHandling { private final ScreenOverlay screenOverlay; private final SlenderGazeService slenderGazeService; private final BloodSplatterService bloodSplatterService; + private final TunnelVisionRenderer tunnelVisionRenderer; + private final TunnelVisionService tunnelVisionService; public Cygnus() { Path path = ServiceBootstrap.resolveWorkingDirectory(); @@ -144,6 +148,8 @@ public Cygnus() { this.screenOverlay, bound -> ThreadLocalRandom.current().nextInt(bound) ); + this.tunnelVisionRenderer = new OverlayTunnelVisionRenderer(this.screenOverlay); + this.tunnelVisionService = new TunnelVisionService(this.tunnelVisionRenderer, player -> StaminaHelper.remainingShare(this.staminaService, player)); this.initPhases(); this.initCommands(); this.initListener(); @@ -154,9 +160,9 @@ public Cygnus() { private void initCommands() { var manager = MinecraftServer.getCommandManager(); manager.register(new StartCommand(this.linearPhaseSeries)); - manager.register(new GlitchCommand(this.slenderGazeService)); } + private void initListener() { Supplier phaseSupplier = this.linearPhaseSeries::getCurrentPhase; var manager = MinecraftServer.getGlobalEventHandler(); @@ -225,6 +231,7 @@ private void registerOverlayListeners(GlobalEventHandler handler) { if (!OverlayProperties.enabled()) return; this.slenderGazeService.registerListener(handler, () -> TeamHelper.survivorsOf(this.teamService)); this.bloodSplatterService.registerListener(handler); + this.tunnelVisionService.registerListener(handler, () -> TeamHelper.survivorsOf(this.teamService)); } private void initPhases() { diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java b/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java deleted file mode 100644 index b394ec95..00000000 --- a/game/src/main/java/net/onelitefeather/cygnus/command/CommandSenders.java +++ /dev/null @@ -1,54 +0,0 @@ -package net.onelitefeather.cygnus.command; - -import net.kyori.adventure.text.Component; -import net.minestom.server.command.CommandSender; -import net.minestom.server.entity.Player; -import org.jetbrains.annotations.Nullable; - -/** - * Narrows a {@link CommandSender} down to a {@link Player}, since every preview command in this - * package draws on a screen and only a player has one. - *

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

- *

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

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

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

- * - * @author TheMeinerLP - * @version 2.0.0 - * @since 2.7.0 - */ -public final class GlitchCommand extends Command { - - /** Completes the sentence {@code CommandSenders} sends back to a sender without a screen. */ - - /** - * Creates the command. - * - * @param service the service that draws the tearing - */ - public GlitchCommand(SlenderGazeService service) { - super("glitch"); - - var level = ArgumentType.Integer("level").between(1, SlenderGaze.LEVELS); - - this.setDefaultExecutor((sender, context) -> - sender.sendMessage(Messages.getGlitchUsageMessage(SlenderGaze.LEVELS))); - - this.addSyntax((sender, context) -> { - Player player = CommandSenders.asPlayer(sender, Messages.ONLY_PLAYERS_HAVE_A_VIEW); - if (player == null) return; - service.preview(player, context.get(level) - 1); - }, level); - - this.addSyntax((sender, context) -> { - Player player = CommandSenders.asPlayer(sender, Messages.ONLY_PLAYERS_HAVE_A_VIEW); - if (player == null) return; - service.preview(player, SlenderGaze.NONE); - }, ArgumentType.Literal("off")); - } -} diff --git a/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java b/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java index d2a89e7e..38cf62d5 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java +++ b/game/src/main/java/net/onelitefeather/cygnus/stamina/FoodBar.java @@ -99,6 +99,19 @@ private float normalize(float current) { return Math.max(0.0f, current / MAX_FOOD); } + /** + * Returns the remaining stamina as a share of a full bar. + *

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

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

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

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

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

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

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

+ *

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

+ * + * @author TheMeinerLP + * @version 2.0.1 + * @since 2.7.0 + */ +public final class TunnelVisionIntensity { + + /** Share of the stamina bar below which the view starts to narrow. */ + private static final double STAMINA_THRESHOLD = 0.5D; + + private TunnelVisionIntensity() { + } + + /** + * Calculates the share contributed by the survivor's stamina. + *

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

+ * + * @param normalizedStamina the remaining stamina as a share of a full bar + * @return the intensity share in {@code [0, 1]} + */ + public static double fromStamina(double normalizedStamina) { + if (normalizedStamina >= STAMINA_THRESHOLD) return 0.0D; + double drained = (STAMINA_THRESHOLD - normalizedStamina) / STAMINA_THRESHOLD; + return Math.clamp(drained * drained, 0.0D, 1.0D); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionRenderer.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionRenderer.java new file mode 100644 index 00000000..1e9a4955 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionRenderer.java @@ -0,0 +1,35 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.minestom.server.entity.Player; + +/** + * Displays a tunnel vision stage to a survivor. + *

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

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

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

+ *

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

+ *

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

+ *

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

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

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

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

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

+ * + * @param node the node to register on + * @param survivors supplies the survivors of the starting round + */ + public void registerListener(EventNode node, Supplier> survivors) { + node.addListener(GameStartEvent.class, event -> { + this.startTask(); + for (Player survivor : survivors.get()) { + this.track(survivor); + } + }); + node.addListener(PlayerDeathEvent.class, event -> this.remove(event.getPlayer())); + node.addListener(PlayerDisconnectEvent.class, event -> this.remove(event.getPlayer())); + node.addListener(GameFinishEvent.class, event -> { + this.cleanUp(); + this.stopTask(); + }); + } + + /** + * Stops drawing for a survivor and clears whatever is still on their screen — on death, on + * the way into the spectator team, or on quit. + * + * @param player the survivor to drop + */ + public void remove(Player player) { + if (this.survivors.remove(player) == null) return; + this.renderer.clear(player); + } + + /** + * Clears every survivor's screen and stops tracking all of them, without touching the update + * task — pair with {@link #stopTask()} to end a round the way {@link #registerListener} does. + */ + public void cleanUp() { + for (Tracked tracked : this.survivors.values()) { + this.renderer.clear(tracked.player()); + } + this.survivors.clear(); + } + + /** + * Updates every tracked survivor once. + */ + void tick() { + if (this.survivors.isEmpty()) return; + + for (Tracked tracked : this.survivors.values()) { + Player survivor = tracked.player(); + double intensity = TunnelVisionIntensity.fromStamina(this.stamina.applyAsDouble(survivor)); + this.renderer.render(survivor, tracked.stage().update(intensity)); + } + } + + /** + * Pairs a survivor with the overlay state that belongs to them. + * + * @param player the survivor + * @param stage their stage state, carrying hysteresis and heartbeat + */ + private record Tracked(Player player, TunnelVisionStage stage) { + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java new file mode 100644 index 00000000..0a1ba83a --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java @@ -0,0 +1,78 @@ +package net.onelitefeather.cygnus.tunnelvision; + + +/** + * Holds the overlay state of a single survivor: which of the discrete stages is currently shown, + * and where the heartbeat that modulates it stands. + *

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

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

+ * These double as the frames of the heartbeat: Minecraft cannot animate an overlay texture, so + * the animation is the server walking through the stages. Thirty-two of them make the view + * close smoothly; at sixteen the steps were visible as the tunnel narrowed. + *

+ */ + public static final int MAX_STAGE = 32; + + /** Interval the service updates at, which is also the sampling rate of the heartbeat. */ + public static final int TICK_MILLIS = 100; + + /** Distance in stages the intensity has to travel before the base stage follows. */ + private static final double HYSTERESIS = 0.6D; + + /** + * Depth of the heartbeat in stages at full intensity, as a fraction of the whole scale so it + * stays equally visible whatever {@link #MAX_STAGE} is. + */ + private static final double PULSE_DEPTH = MAX_STAGE / 16.0D; + + /** Heartbeat frequency in hertz while the survivor is barely threatened. */ + private static final double BASE_FREQUENCY = 1.0D; + + /** Additional heartbeat frequency in hertz at full intensity. */ + private static final double FREQUENCY_GAIN = 1.5D; + + private static final double TICK_SECONDS = TICK_MILLIS / 1000.0D; + + /** Negative until the first update, so the first intensity is adopted without hysteresis. */ + private int baseStage = -1; + + private double elapsedSeconds; + + /** + * Advances the heartbeat by one tick and reports the stage to render. + * + * @param intensity the intensity from {@link TunnelVisionIntensity} + * @return the stage to render, between {@code 0} and {@link #MAX_STAGE} + */ + public int update(double intensity) { + double exactStage = intensity * MAX_STAGE; + if (this.baseStage < 0 || Math.abs(exactStage - this.baseStage) > HYSTERESIS) { + this.baseStage = (int) Math.round(exactStage); + } + + this.elapsedSeconds += TICK_SECONDS; + double frequency = BASE_FREQUENCY + FREQUENCY_GAIN * intensity; + double depth = PULSE_DEPTH * intensity; + // The heartbeat only ever opens the view up, never beyond the base stage: at full + // intensity the base stage is the maximum, and a symmetric pulse would be clipped away + // exactly where it matters most. + double pulse = depth * (Math.sin(2.0D * Math.PI * frequency * this.elapsedSeconds) - 1.0D); + + int rendered = (int) Math.round(this.baseStage + pulse); + return Math.clamp(rendered, 0, MAX_STAGE); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java new file mode 100644 index 00000000..f6ea8e77 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.onelitefeather.cygnus.tunnelvision; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java b/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java index 3a7b0668..8f1e6f83 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java +++ b/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java @@ -4,6 +4,8 @@ import net.onelitefeather.cygnus.team.TeamHelper; import net.theevilreaper.xerus.api.team.Team; import net.theevilreaper.xerus.api.team.TeamService; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.stamina.FoodBar; import net.onelitefeather.cygnus.stamina.StaminaService; @@ -11,7 +13,7 @@ * The {@link StaminaHelper} is a utility class which contains some helper methods for the stamina system. * * @author theEvilReaper - * @version 1.2.0 + * @version 1.3.0 * @since 1.0.0 */ public final class StaminaHelper { @@ -33,6 +35,24 @@ public static void initStaminaObjects(TeamService teamService, StaminaService st staminaService.createStaminaBars(allocation.survivors()); } + /** + * Reads how much of a survivor's stamina is left, as a share between zero and one. + *

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

+ * + * @param staminaService the service holding the bars + * @param player the player to read + * @return the remaining share in {@code [0, 1]}, or {@code 1} while the player has no bar + */ + public static double remainingShare(StaminaService staminaService, Player player) { + FoodBar bar = staminaService.getFoodBar(player); + return bar == null ? 1.0D : bar.remainingShare(); + } + private StaminaHelper() { throw new UnsupportedOperationException(); } diff --git a/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java b/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java deleted file mode 100644 index f42226e2..00000000 --- a/game/src/test/java/net/onelitefeather/cygnus/command/GlitchCommandTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package net.onelitefeather.cygnus.command; - -import net.minestom.server.MinecraftServer; -import net.minestom.server.command.builder.Command; -import net.minestom.server.coordinate.Pos; -import net.minestom.server.entity.Player; -import net.minestom.server.instance.Instance; -import net.minestom.testing.Env; -import net.onelitefeather.cygnus.CygnusPlayerTestBase; -import net.onelitefeather.cygnus.gaze.SlenderGaze; -import net.onelitefeather.cygnus.gaze.SlenderGazeService; -import net.onelitefeather.cygnus.overlay.OverlayLayer; -import net.onelitefeather.cygnus.overlay.RecordingScreenOverlay; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; - -/** - * Verifies the command used to preview the slender's glitch without him being there. - * - * @author TheMeinerLP - * @version 2.0.0 - * @since 2.7.0 - */ -class GlitchCommandTest extends CygnusPlayerTestBase { - - @Test - @DisplayName("A requested level is drawn right away") - void levelIsDrawnOnRequest(Env env) { - RecordingScreenOverlay overlay = new RecordingScreenOverlay(); - Player player = spawn(env); - register(overlay); - - MinecraftServer.getCommandManager().execute(player, "glitch 2"); - - assertNotNull(overlay.of(player, OverlayLayer.GLITCH), "the command has to put the glitch on screen"); - } - - @Test - @DisplayName("Switching the preview off clears the screen") - void offClearsTheScreen(Env env) { - RecordingScreenOverlay overlay = new RecordingScreenOverlay(); - Player player = spawn(env); - register(overlay); - MinecraftServer.getCommandManager().execute(player, "glitch 2"); - - MinecraftServer.getCommandManager().execute(player, "glitch off"); - - assertNull(overlay.of(player, OverlayLayer.GLITCH), "the preview must disappear"); - } - - @Test - @DisplayName("Every level of the tearing can be requested") - void everyLevelCanBeRequested(Env env) { - RecordingScreenOverlay overlay = new RecordingScreenOverlay(); - Player player = spawn(env); - register(overlay); - - for (int level = 1; level <= SlenderGaze.LEVELS; level++) { - overlay.clear(player); - MinecraftServer.getCommandManager().execute(player, "glitch " + level); - assertNotNull(overlay.of(player, OverlayLayer.GLITCH), "no glitch for level " + level); - } - } - - /** - * Registers the command under test against the given overlay. The environment is shared across - * the tests in this class, so any command left over from an earlier one — still drawing into - * that test's overlay — has to go first. - * - * @param overlay the overlay the service draws into - */ - private void register(RecordingScreenOverlay overlay) { - Command previous = MinecraftServer.getCommandManager().getCommand("glitch"); - if (previous != null) MinecraftServer.getCommandManager().unregister(previous); - MinecraftServer.getCommandManager().register(new GlitchCommand(new SlenderGazeService(overlay, () -> null))); - } - - /** - * Connects a player into a fresh instance. - * - * @param env the test environment - * @return the connected player - */ - private Player spawn(Env env) { - Instance instance = env.createFlatInstance(); - return env.createConnection().connect(instance, new Pos(0, 40, 0)); - } -} diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java new file mode 100644 index 00000000..1fe45c52 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java @@ -0,0 +1,32 @@ +package net.onelitefeather.cygnus.stamina; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.player.CygnusPlayer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies the stamina share other systems read off the survivor's bar. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class FoodBarTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("A fresh bar reports a full share") + void freshBarIsFull(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createConnection().connect(instance, new Pos(0, 40, 0)); + FoodBar bar = (FoodBar) StaminaFactory.createFoodStamina((CygnusPlayer) player); + + assertEquals(1.0f, bar.remainingShare(), 1.0E-6f); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java new file mode 100644 index 00000000..c3c41730 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java @@ -0,0 +1,97 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.kyori.adventure.key.Key; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.RecordingScreenOverlay; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Verifies which texture the tunnel vision contributes to the shared screen overlay. + * + * @author TheMeinerLP + * @version 2.1.0 + * @since 2.7.0 + */ +class OverlayTunnelVisionRendererTest extends CygnusPlayerTestBase { + + private static final Key BLOOD_TEXTURE = Key.key("cygnus", "gui/blood/stage_1"); + + @Test + @DisplayName("A stage is contributed as its overlay texture") + void stageIsContributedAsTexture(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Player player = spawn(env); + + new OverlayTunnelVisionRenderer(overlay).render(player, 3); + + assertEquals( + Key.key("cygnus", OverlayTunnelVisionRenderer.TEXTURE_PATH + "3"), + overlay.of(player, OverlayLayer.TUNNEL_VISION), + "the texture must match the stage" + ); + } + + @Test + @DisplayName("Clearing drops only the tunnel vision layer") + void clearingDropsOnlyItsOwnLayer(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Player player = spawn(env); + overlay.set(player, OverlayLayer.BLOOD, BLOOD_TEXTURE); + OverlayTunnelVisionRenderer renderer = new OverlayTunnelVisionRenderer(overlay); + renderer.render(player, 4); + + renderer.clear(player); + + assertNull(overlay.of(player, OverlayLayer.TUNNEL_VISION), "the layer must be gone"); + assertEquals( + BLOOD_TEXTURE, + overlay.of(player, OverlayLayer.BLOOD), + "wiping the screen would take the blood splatter with it" + ); + } + + @Test + @DisplayName("Stage zero drops the layer instead of drawing an empty texture") + void zeroStageDropsTheLayer(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Player player = spawn(env); + + new OverlayTunnelVisionRenderer(overlay).render(player, 0); + + assertNull(overlay.of(player, OverlayLayer.TUNNEL_VISION)); + } + + @Test + @DisplayName("The tightest stage has a texture of its own") + void tightestStageHasItsOwnTexture(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Player player = spawn(env); + + new OverlayTunnelVisionRenderer(overlay).render(player, TunnelVisionStage.MAX_STAGE); + + assertEquals( + Key.key("cygnus", OverlayTunnelVisionRenderer.TEXTURE_PATH + TunnelVisionStage.MAX_STAGE), + overlay.of(player, OverlayLayer.TUNNEL_VISION) + ); + } + + /** + * Connects a player into a fresh instance. + * + * @param env the test environment + * @return the connected player + */ + private Player spawn(Env env) { + Instance instance = env.createFlatInstance(); + return env.createConnection().connect(instance, new Pos(0, 40, 0)); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java new file mode 100644 index 00000000..d976aaea --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java @@ -0,0 +1,51 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the intensity curves that drive the survivor's tunnel vision. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class TunnelVisionIntensityTest { + + private static final double DELTA = 1.0E-6D; + + @DisplayName("Stamina above half a bar produces no tunnel vision") + @ParameterizedTest + @CsvSource({"1.0", "0.75", "0.5"}) + void staminaAboveHalfIsCalm(double stamina) { + assertEquals(0.0D, TunnelVisionIntensity.fromStamina(stamina), DELTA); + } + + @Test + @DisplayName("An empty stamina bar produces full intensity") + void emptyStaminaIsFull() { + assertEquals(1.0D, TunnelVisionIntensity.fromStamina(0.0D), DELTA); + } + + @Test + @DisplayName("The stamina curve accelerates towards the empty bar") + void staminaCurveIsQuadratic() { + assertEquals(0.25D, TunnelVisionIntensity.fromStamina(0.25D), DELTA); + } + + @Test + @DisplayName("Draining stamina never lowers the intensity") + void staminaIsMonotonic() { + double previous = -1.0D; + for (int step = 20; step >= 0; step--) { + double current = TunnelVisionIntensity.fromStamina(step / 20.0D); + assertTrue(current >= previous, "intensity dropped at stamina " + step / 20.0D); + previous = current; + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java new file mode 100644 index 00000000..dda7b455 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java @@ -0,0 +1,235 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.kyori.adventure.text.Component; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.event.player.PlayerDeathEvent; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.event.GameStartEvent; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies how the service feeds survivors through the intensity calculation. + *

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

+ * + * @author TheMeinerLP + * @version 1.0.1 + * @since 2.7.0 + */ +class TunnelVisionServiceTest extends CygnusPlayerTestBase { + + private static final double FULL_STAMINA = 1.0D; + private static final double NO_STAMINA = 0.0D; + + @Test + @DisplayName("An exhausted survivor sees the tightest stage") + void exhaustedSurvivorIsFullyNarrowed(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.track(survivor); + + service.tick(); + + assertEquals(TunnelVisionStage.MAX_STAGE, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A rested survivor alone in the dark sees nothing") + void restedSurvivorSeesNothing(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> FULL_STAMINA); + service.track(survivor); + + service.tick(); + + assertEquals(0, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A removed survivor gets their screen back and is no longer drawn") + void removedSurvivorIsCleared(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.track(survivor); + service.tick(); + renderer.forget(); + + service.remove(survivor); + service.tick(); + + assertTrue(renderer.wasCleared(survivor), "the last vignette would otherwise linger"); + assertNull(renderer.stageOf(survivor), "a removed survivor must not be drawn any more"); + } + + @Test + @DisplayName("Clearing everyone gives every survivor their screen back") + void cleanUpClearsEveryone(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Instance instance = env.createFlatInstance(); + Player first = spawn(env, instance, new Pos(0, 40, 0)); + Player second = spawn(env, instance, new Pos(4, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.track(first); + service.track(second); + service.tick(); + + service.cleanUp(); + + assertTrue(renderer.wasCleared(first)); + assertTrue(renderer.wasCleared(second)); + + renderer.forget(); + service.tick(); + assertNull(renderer.stageOf(first), "clearing must stop the drawing as well"); + } + + @Test + @DisplayName("Starting and stopping the task is idempotent") + void startAndStopTaskAreIdempotent(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + + service.startTask(); + service.startTask(); + service.stopTask(); + service.stopTask(); + } + + @Test + @DisplayName("The start of a round takes the survivors on board") + void gameStartRegistersSurvivors(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + + EventDispatcher.call(new GameStartEvent()); + service.tick(); + + assertEquals(TunnelVisionStage.MAX_STAGE, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A dying survivor gets their screen back") + void deathClearsTheOverlay(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.track(survivor); + service.tick(); + renderer.forget(); + + EventDispatcher.call(new PlayerDeathEvent(survivor, Component.empty(), Component.empty())); + service.tick(); + + assertTrue(renderer.wasCleared(survivor)); + assertNull(renderer.stageOf(survivor), "a dead survivor must not be drawn any more"); + } + + @Test + @DisplayName("The end of a round clears everyone") + void gameFinishCleansUp(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.track(survivor); + service.tick(); + renderer.forget(); + + EventDispatcher.call(new GameFinishEvent(GameFinishEvent.Reason.TIME_OVER)); + + assertTrue(renderer.wasCleared(survivor)); + } + + /** + * Spawns a player in a fresh instance. + * + * @param env the test environment + * @param position where to place the player + * @return the connected player + */ + private Player spawn(Env env, Pos position) { + return this.spawn(env, env.createFlatInstance(), position); + } + + /** + * Spawns a player in the given instance. + * + * @param env the test environment + * @param instance the instance to connect into + * @param position where to place the player + * @return the connected player + */ + private Player spawn(Env env, Instance instance, Pos position) { + return env.createConnection().connect(instance, position); + } + + /** + * Records what the service asked to be drawn, standing in for the action bar renderer. + */ + private static final class RecordingRenderer implements TunnelVisionRenderer { + + private final Map stages = new HashMap<>(); + private final Set cleared = new HashSet<>(); + + @Override + public void render(Player player, int stage) { + this.stages.put(player.getUuid(), stage); + } + + @Override + public void clear(Player player) { + this.cleared.add(player.getUuid()); + this.stages.remove(player.getUuid()); + } + + /** + * @param player the player to look up + * @return the stage last drawn for the player, or {@code null} if nothing was drawn + */ + private @Nullable Integer stageOf(Player player) { + return this.stages.get(player.getUuid()); + } + + /** + * @param player the player to look up + * @return whether the player's overlay was cleared + */ + private boolean wasCleared(Player player) { + return this.cleared.contains(player.getUuid()); + } + + /** + * Drops everything recorded so far, to tell repeated draws apart. + */ + private void forget() { + this.stages.clear(); + this.cleared.clear(); + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java new file mode 100644 index 00000000..407e80bc --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java @@ -0,0 +1,100 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies how a continuous intensity becomes the discrete, pulsing stage the overlay renders. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class TunnelVisionStageTest { + + /** Enough updates to cover several periods of the slowest heartbeat. */ + private static final int SAMPLES = 60; + + @Test + @DisplayName("Without any threat the overlay stays off") + void calmIntensityStaysOff() { + TunnelVisionStage stage = new TunnelVisionStage(); + assertEquals(0, stage.update(0.0D)); + } + + @Test + @DisplayName("Full intensity pulses across the top of the scale") + void fullIntensityPulses() { + TunnelVisionStage stage = new TunnelVisionStage(); + int lowest = TunnelVisionStage.MAX_STAGE; + int highest = 0; + for (int sample = 0; sample < SAMPLES; sample++) { + int current = stage.update(1.0D); + lowest = Math.min(lowest, current); + highest = Math.max(highest, current); + } + assertEquals(TunnelVisionStage.MAX_STAGE, highest, "the pulse never reaches the peak"); + // Stated as a share of the scale rather than as a stage count, so raising the number of + // stages does not turn this into a test of one particular pulse depth. + assertTrue(lowest < highest, "the pulse does not open up again"); + assertTrue(lowest >= TunnelVisionStage.MAX_STAGE - TunnelVisionStage.MAX_STAGE / 4, + "the pulse swings the view too far open at full intensity"); + } + + @Test + @DisplayName("Low intensity barely pulses at all") + void lowIntensityIsSteady() { + TunnelVisionStage stage = new TunnelVisionStage(); + int first = stage.update(0.125D); + for (int sample = 0; sample < SAMPLES; sample++) { + assertEquals(first, stage.update(0.125D), "a barely threatened survivor should not flicker"); + } + } + + @Test + @DisplayName("A small fluctuation does not move the stage") + void hysteresisHoldsTheStage() { + TunnelVisionStage stage = new TunnelVisionStage(); + int settled = highestOver(stage, 0.5D); + assertEquals(TunnelVisionStage.MAX_STAGE / 2, settled, "half intensity should settle on the middle stage"); + assertEquals(settled, highestOver(stage, 0.51D), "the stage moved on a small fluctuation"); + } + + @Test + @DisplayName("A real change moves the stage") + void largerChangeMovesTheStage() { + TunnelVisionStage stage = new TunnelVisionStage(); + assertEquals(TunnelVisionStage.MAX_STAGE / 2, highestOver(stage, 0.5D)); + assertEquals(TunnelVisionStage.MAX_STAGE / 2 + 1, highestOver(stage, 0.53D), + "the stage should follow a real change"); + } + + @Test + @DisplayName("The stage never leaves its bounds") + void stageStaysWithinBounds() { + TunnelVisionStage stage = new TunnelVisionStage(); + for (int sample = 0; sample < SAMPLES; sample++) { + int current = stage.update(sample % 2 == 0 ? 1.0D : 0.0D); + assertTrue(current >= 0 && current <= TunnelVisionStage.MAX_STAGE, "stage out of bounds: " + current); + } + } + + /** + * Feeds a constant intensity for a while and reports the highest stage seen, which is the + * stage the pulse starts from. + * + * @param stage the stage state to drive + * @param combined the constant intensity to feed + * @return the highest stage observed + */ + private int highestOver(TunnelVisionStage stage, double combined) { + int highest = 0; + for (int sample = 0; sample < SAMPLES; sample++) { + highest = Math.max(highest, stage.update(combined)); + } + return highest; + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/utils/StaminaShareTest.java b/game/src/test/java/net/onelitefeather/cygnus/utils/StaminaShareTest.java new file mode 100644 index 00000000..f8b4919e --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/utils/StaminaShareTest.java @@ -0,0 +1,51 @@ +package net.onelitefeather.cygnus.utils; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.stamina.StaminaService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies {@link StaminaHelper#remainingShare(StaminaService, Player)}, the stamina reading the + * tunnel vision drives itself off. + *

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

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class StaminaShareTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("A player without a bar reads as untouched rather than empty") + void playerWithoutBarIsFull(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createConnection().connect(instance, new Pos(0, 40, 0)); + + assertEquals(1.0D, StaminaHelper.remainingShare(new StaminaService(), player), 1.0E-6D); + } + + @Test + @DisplayName("A survivor with a fresh bar reads as untouched") + void freshBarIsFull(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createConnection().connect(instance, new Pos(0, 40, 0)); + + StaminaService staminaService = new StaminaService(); + staminaService.createStaminaBars(Set.of(player)); + + assertEquals(1.0D, StaminaHelper.remainingShare(staminaService, player), 1.0E-6D); + } +}