From 37c9215c527741cfd559e2fa757d5ef12dbad0b7 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Mon, 10 Aug 2026 20:44:14 +0200 Subject: [PATCH 1/5] docs(tunnel-vision): add design spec for survivor tunnel vision Vignette that narrows the survivor's view as stamina drops and the Slender closes in. Rendered as an action bar HUD overlay because per-player post effects only become possible with Minecraft 26.3; the renderer interface keeps that path open. Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/2026-08-10-tunnel-vision-design.md | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-10-tunnel-vision-design.md 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..9b158488 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md @@ -0,0 +1,182 @@ +# Tunnel vision for survivors + +## Goal + +A survivor's view narrows as the situation gets worse: the screen edges darken and pulse like a +heartbeat when stamina runs low, when the Slender closes in, or both. The effect is per player, +continuous rather than on/off, and driven entirely by the server. + +## Why not a shader + +The obvious implementation is a post-processing shader, and on Minecraft 26.2 it does not work. + +A resource-pack post effect only runs in contexts vanilla decides: the menu blur, spectator mob +vision, the glowing outline, and the "Improved Transparency" video setting. None of them can be +switched on for one player from the server, and none carries an intensity parameter. The only way +to force one on 26.2 is hijacking spectator mob vision by pointing the player's camera at a hidden +enderman, which takes over the camera and makes the game unplayable. + +That changes in 26.3: snapshot 3 (7 July 2026) added `/posteffect add|remove ` +plus the always-on `minecraft:end_of_frame` context. 26.3 is still in snapshots, and Minestom +ships 26.2 (`net.minestom:minestom:2026.07.22-26.2`). + +So the effect is rendered as a HUD overlay through the action bar today, behind an interface that +a post-effect renderer can slot into once 26.3 and Minestom support land. The gameplay side does +not change when that happens. + +Reference: [Shader – Minecraft Wiki](https://minecraft.wiki/w/Shader), +[Java Edition 26.3 Snapshot 3](https://minecraft.wiki/w/Java_Edition_26.3_Snapshot_3). + +## Intensity + +`TunnelVisionIntensity` turns two inputs into a value in `[0, 1]`. It has no Minestom dependency +beyond positions, so it is testable without a server. + +**Stamina.** With `s = currentSpeedCount / 20`: + +``` +stamina = s >= 0.5 ? 0 : ((0.5 - s) / 0.5)^2 +``` + +Nothing happens above half a bar; below it the curve accelerates, so the last few percent are far +more dramatic than crossing the halfway mark. + +**Slender.** With `d` the distance between survivor and Slender: + +``` +proximity = clamp((25 - d) / (25 - 6), 0, 1) +view = 0.6 + 0.4 * max(0, dot(survivorLookDirection, directionToSlender)) +slender = proximity * view +``` + +The effect starts at 25 blocks and peaks at 6. Looking straight at him is worse than having him +behind you, but never by more than a factor of 1.67 — he is frightening either way. + +**Combination:** + +``` +combined = 1 - (1 - stamina) * (1 - slender) +``` + +Both sources add up noticeably but saturate cleanly at 1.0 instead of clamping hard, so neither +one can hide the other. + +**No line-of-sight raycast.** A wall between survivor and Slender does not dampen the effect. It +would cost a block walk per survivor per tick, and "I can feel him through the wall" is the better +atmosphere anyway. + +## Stages and pulse + +The continuous value is quantised to 8 stages. Two mechanisms sit on top, in this order: + +1. **Hysteresis on the base value.** `baseStage` starts as `round(combined * 8)` and afterwards + only moves when `combined * 8` 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.** + +``` +amplitude = 0.5 * combined +frequency = 1.0 + 1.5 * combined // Hz +display = clamp(round(baseStage + amplitude * sin(2*pi * frequency * t)), 0, 8) +``` + +The heartbeat gets faster and deeper as it gets tighter, and stays nearly invisible at low +intensity — an amplitude that does not scale would make stage 1 flicker between 0 and 1. + +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: 250 ms. + +## Pack assets + +In `cygnus-pack`, namespace `cygnus`: + +``` +pack/assets/cygnus/textures/gui/tunnel_vision/stage_1.png … stage_8.png +pack/assets/cygnus/font/tunnel_vision.json +``` + +Each texture is a soft radial darkening, 512×512, fully opaque at the outer edge. The font is a +bitmap provider mapping `U+E000`–`U+E007` to stages 1–8. + +The server builds a `Component` carrying `font("cygnus:tunnel_vision")` and sends it with +`sendActionBar`. Two details that otherwise look broken: + +- `shadowColor` must be transparent, or Minecraft renders the vignette a second time, offset, + underneath itself. +- The action bar fades after 3 seconds. The 250 ms tick refreshes it long before that. + +**Positioning is approximate by construction.** Font glyphs render relative to the action bar, and +the server knows neither the client's resolution nor its GUI scale, so pixel-accurate centring is +impossible. The texture is deliberately larger than any realistic viewport and fully opaque at the +edge: the overhang is clipped, and because the vignette is soft, the offset does not read as an +error. `height` and `ascent` in the font provider are calibration values — they start at `height: +512`, `ascent: 200` and get adjusted in-game against a snapshot build of the pack. + +This is the cost of the action-bar approach against a real post effect, which would be +full-screen by nature. + +## Components + +New package `net.onelitefeather.cygnus.tunnelvision`: + +- `TunnelVisionIntensity` — the calculation above. Pure, no server needed to test it. +- `TunnelVisionRenderer` — `render(player, stage)` and `clear(player)`. This is the seam a + post-effect renderer slots into on 26.3. +- `ActionBarTunnelVisionRenderer` — the implementation described above. +- `TunnelVisionService` — holds per-survivor state (current stage for hysteresis, pulse phase) and + ticks all survivors in one scheduler task. + +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 + +Along the paths `StaminaService` already uses: + +| Point | What happens | +| --- | --- | +| `Cygnus` | creates the service | +| `GameStartListener` | starts it for the survivor set | +| `PlayerDeathListener` | removes the player (transition to spectator) | +| `PlayerQuitListener` | removes the player | +| wherever `staminaService.cleanUp()` runs | full cleanup | + +Two changes to existing code: + +- **`FoodBar` gains a getter** for normalised stamina. `currentSpeedCount` is private today. The + service could read `player.getExp()`, since `FoodBar` mirrors the value there, but that hangs + game logic off a display detail. +- **The service only exists when the resource pack is active.** `Cygnus` creates it only if + `resourcePackService` is present, reusing the `Optional` already in place. Without the pack the + font does not exist and players would see an empty box instead of a vignette. + +## Failure modes + +The service keeps running in all of these; none of them throws. + +| Situation | Behaviour | +| --- | --- | +| No Slender (disconnected, not yet assigned) | stamina share only | +| Slender in a different instance | slender share is 0 | +| No `FoodBar` registered for a player | stamina share is 0 | +| Stage drops to 0 | `clear()` rather than rendering — otherwise the last vignette lingers for three seconds until the action bar fades on its own | +| 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, the view factor, and + hysteresis — a small oscillation around a stage boundary must not change the stage. +- `ActionBarTunnelVisionRendererTest` — Cyano: the player receives an action bar packet with the + expected code point and the `cygnus:tunnel_vision` font, the shadow is transparent, and + `clear()` sends an empty component. +- `TunnelVisionServiceTest` — lifecycle: start and stop, removing a player, behaviour with no + Slender. + +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. From ab12d5f9baa763171462d5f9e3ec4ae6a3bf073c Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Mon, 10 Aug 2026 21:07:10 +0200 Subject: [PATCH 2/5] feat(tunnel-vision): narrow the survivor's view under pressure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen edges darken and pulse like a heartbeat as stamina drains and the slender closes in. Rendered as an action bar overlay in a pack font, because Minecraft 26.2 has no per-player post effect; TunnelVisionRenderer is the seam a post-effect implementation slots into once 26.3 lands. The service listens for the round lifecycle itself rather than reaching into GameStartListener and friends, and stays off entirely when no resource pack is configured — without the pack the font is missing and survivors would see an empty box. /tunnelvision previews stages and intensities from the lobby to calibrate the glyphs. Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/2026-08-10-tunnel-vision-design.md | 62 +++-- .../net/onelitefeather/cygnus/Cygnus.java | 54 ++++ .../cygnus/command/TunnelVisionCommand.java | 116 ++++++++ .../cygnus/stamina/FoodBar.java | 13 + .../ActionBarTunnelVisionRenderer.java | 76 ++++++ .../tunnelvision/TunnelVisionIntensity.java | 119 +++++++++ .../tunnelvision/TunnelVisionRenderer.java | 35 +++ .../tunnelvision/TunnelVisionService.java | 170 ++++++++++++ .../tunnelvision/TunnelVisionStage.java | 67 +++++ .../cygnus/tunnelvision/package-info.java | 4 + .../command/TunnelVisionCommandTest.java | 110 ++++++++ .../cygnus/stamina/FoodBarTest.java | 32 +++ .../ActionBarTunnelVisionRendererTest.java | 113 ++++++++ .../TunnelVisionIntensityTest.java | 102 +++++++ .../tunnelvision/TunnelVisionServiceTest.java | 248 ++++++++++++++++++ .../tunnelvision/TunnelVisionStageTest.java | 95 +++++++ 16 files changed, 1395 insertions(+), 21 deletions(-) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/tunnelvision/ActionBarTunnelVisionRenderer.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/ActionBarTunnelVisionRendererTest.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 index 9b158488..f8d746b9 100644 --- a/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md +++ b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md @@ -75,20 +75,26 @@ The continuous value is quantised to 8 stages. Two mechanisms sit on top, in thi 2. **Pulse on top of the stabilised stage.** ``` -amplitude = 0.5 * combined +depth = 0.5 * combined frequency = 1.0 + 1.5 * combined // Hz -display = clamp(round(baseStage + amplitude * sin(2*pi * frequency * t)), 0, 8) +display = clamp(round(baseStage + depth * (sin(2*pi * frequency * t) - 1)), 0, 8) ``` The heartbeat gets faster and deeper as it gets tighter, and stays nearly invisible at low -intensity — an amplitude that does not scale would make stage 1 flicker between 0 and 1. +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: 250 ms. +**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 @@ -99,22 +105,24 @@ pack/assets/cygnus/textures/gui/tunnel_vision/stage_1.png … stage_8.png pack/assets/cygnus/font/tunnel_vision.json ``` -Each texture is a soft radial darkening, 512×512, fully opaque at the outer edge. The font is a -bitmap provider mapping `U+E000`–`U+E007` to stages 1–8. +Each texture is a soft radial darkening, 1024×512, fully opaque at the outer edge — 2:1 rather +than square so it covers a widescreen viewport. The font is a bitmap provider mapping +`U+E000`–`U+E007` to stages 1–8. The server builds a `Component` carrying `font("cygnus:tunnel_vision")` and sends it with `sendActionBar`. Two details that otherwise look broken: - `shadowColor` must be transparent, or Minecraft renders the vignette a second time, offset, underneath itself. -- The action bar fades after 3 seconds. The 250 ms tick refreshes it long before that. +- The action bar fades after 3 seconds. The 100 ms tick refreshes it long before that. **Positioning is approximate by construction.** Font glyphs render relative to the action bar, and the server knows neither the client's resolution nor its GUI scale, so pixel-accurate centring is impossible. The texture is deliberately larger than any realistic viewport and fully opaque at the edge: the overhang is clipped, and because the vignette is soft, the offset does not read as an -error. `height` and `ascent` in the font provider are calibration values — they start at `height: -512`, `ascent: 200` and get adjusted in-game against a snapshot build of the pack. +error. `height` and `ascent` in the font provider are calibration values. They start at `height: +540`, `ascent: 478`, which centres the vignette on a 1080p client at GUI scale 2, and get adjusted +in-game with `/tunnelvision stage `. This is the cost of the action-bar approach against a real post effect, which would be full-screen by nature. @@ -124,26 +132,34 @@ full-screen by nature. 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. - `ActionBarTunnelVisionRenderer` — the implementation described above. -- `TunnelVisionService` — holds per-survivor state (current stage for hysteresis, pulse phase) and - ticks all survivors in one scheduler task. +- `TunnelVisionService` — holds a `TunnelVisionStage` per survivor and ticks all of them in one + scheduler task. +- `TunnelVisionCommand` — `/tunnelvision stage <0-8> | intensity <0.0-1.0> | off`, for judging the + vignette from the lobby without a running round. `stage` freezes one stage to calibrate the font + against; `intensity` runs the real heartbeat. One task for everyone rather than one per player as `StaminaBar` does: the Slender position is read once per tick instead of once per survivor, and cleanup happens in one place. ## Wiring -Along the paths `StaminaService` already uses: +`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: -| Point | What happens | +| Event | What happens | | --- | --- | -| `Cygnus` | creates the service | -| `GameStartListener` | starts it for the survivor set | -| `PlayerDeathListener` | removes the player (transition to spectator) | -| `PlayerQuitListener` | removes the player | -| wherever `staminaService.cleanUp()` runs | full cleanup | +| `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: @@ -169,13 +185,17 @@ The service keeps running in all of these; none of them throws. ## Tests - `TunnelVisionIntensityTest` — plain JUnit: edge values (full stamina at long range gives 0, - empty stamina at close range gives 1), monotonicity in both inputs, the view factor, and - hysteresis — a small oscillation around a stage boundary must not change the stage. + 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. - `ActionBarTunnelVisionRendererTest` — Cyano: the player receives an action bar packet with the expected code point and the `cygnus:tunnel_vision` font, the shadow is transparent, and `clear()` sends an empty component. - `TunnelVisionServiceTest` — lifecycle: start and stop, removing a player, behaviour with no - Slender. + 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 diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index ced70c8a..ae72394d 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -37,6 +37,7 @@ import net.minestom.server.network.packet.client.play.ClientEntityActionPacket; import net.onelitefeather.cygnus.ambient.AmbientProvider; import net.onelitefeather.cygnus.command.StartCommand; +import net.onelitefeather.cygnus.command.TunnelVisionCommand; import net.onelitefeather.cygnus.common.ListenerHandling; import net.onelitefeather.cygnus.common.bootstrap.ServiceBootstrap; import net.onelitefeather.cygnus.common.config.GameConfig; @@ -73,14 +74,20 @@ import net.onelitefeather.cygnus.resourcepack.ResourcePackService; import net.onelitefeather.cygnus.stamina.SlenderBarTrigger; import net.onelitefeather.cygnus.stamina.StaminaService; +import net.onelitefeather.cygnus.stamina.FoodBar; +import net.onelitefeather.cygnus.tunnelvision.ActionBarTunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionService; import net.onelitefeather.cygnus.utils.StaminaHelper; import net.onelitefeather.cygnus.utils.ViewRuleUpdater; import net.onelitefeather.cygnus.view.GameView; import net.onelitefeather.cygnus.view.GameViewImpl; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.nio.file.Path; import java.util.Optional; +import java.util.Set; import java.util.function.Supplier; /** @@ -102,6 +109,8 @@ public final class Cygnus implements TeamCreator, ListenerHandling { private final JumpScareManager jumpscareManager; private final SpectatorService spectatorService; private final Optional resourcePackService; + private final TunnelVisionRenderer tunnelVisionRenderer; + private final TunnelVisionService tunnelVisionService; public Cygnus() { Path path = ServiceBootstrap.resolveWorkingDirectory(); @@ -125,6 +134,12 @@ public Cygnus() { .orElseThrow(() -> new IllegalStateException("Spectator team not found")); this.spectatorService = new SpectatorService(spectatorTeam, survivorTeam); this.resourcePackService = ResourcePackService.create(); + this.tunnelVisionRenderer = new ActionBarTunnelVisionRenderer(); + this.tunnelVisionService = new TunnelVisionService( + this.tunnelVisionRenderer, + this::remainingStamina, + this::currentSlender + ); this.initPhases(); this.initCommands(); this.initListener(); @@ -135,6 +150,40 @@ public Cygnus() { private void initCommands() { var manager = MinecraftServer.getCommandManager(); manager.register(new StartCommand(this.linearPhaseSeries)); + manager.register(new TunnelVisionCommand(this.tunnelVisionRenderer)); + } + + /** + * Reads a survivor's remaining stamina for the tunnel vision. + * + * @param player the survivor to read + * @return the remaining share, or a full bar while the player has none yet + */ + private double remainingStamina(Player player) { + FoodBar bar = this.staminaService.getFoodBar(player); + return bar == null ? 1.0D : bar.remainingShare(); + } + + /** + * Looks up the player currently playing the slender. + * + * @return the slender, or {@code null} while the role is unassigned + */ + private @Nullable Player currentSlender() { + return this.teamService.getTeam(GameConfig.SLENDER_KEY) + .flatMap(team -> team.getPlayers().stream().findFirst()) + .orElse(null); + } + + /** + * Collects the players that are currently survivors. + * + * @return the survivor team's players + */ + private Set currentSurvivors() { + return this.teamService.getTeam(GameConfig.SURVIVOR_KEY) + .map(team -> Set.copyOf(team.getPlayers())) + .orElseGet(Set::of); } @@ -189,6 +238,11 @@ private void registerGameListener() { MinecraftServer.getPacketListenerManager().setPlayListener(ClientEntityActionPacket.class, CygnusEntityActionListener::listener); spectatorService.registerListener(handler); + + // Without the pack the vignette font does not exist and survivors would stare at an + // empty box, so the effect stays off wherever the pack is not delivered. + this.resourcePackService.ifPresent( + _ -> this.tunnelVisionService.registerListener(handler, this::currentSurvivors)); } private void initPhases() { diff --git a/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java b/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java new file mode 100644 index 00000000..7067b58e --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java @@ -0,0 +1,116 @@ +package net.onelitefeather.cygnus.command; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.command.builder.Command; +import net.minestom.server.command.builder.arguments.ArgumentType; +import net.minestom.server.command.CommandSender; +import net.minestom.server.entity.Player; +import net.minestom.server.timer.Task; +import net.minestom.server.timer.TaskSchedule; +import net.onelitefeather.cygnus.common.Messages; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionStage; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Puts the tunnel vision on screen without a running round, so the glyph sizes in the resource + * pack can be judged from the lobby. + *

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

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

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

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

+ * Each stage is a glyph of the {@code cygnus:tunnel_vision} bitmap font shipped with the resource + * pack. The action bar is the only HUD channel a survivor has free — the progress bar in + * {@code StaminaColors} belongs to the slender — and unlike a title it needs no fade timing. + *

+ *

+ * Font glyphs are positioned relative to the action bar, and the server knows neither the client's + * resolution nor its GUI scale, so the vignette cannot be centred exactly. The textures are + * therefore larger than any realistic viewport and opaque at their edge, which turns the offset + * into something the soft vignette hides. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class ActionBarTunnelVisionRenderer implements TunnelVisionRenderer { + + /** Bitmap font provided by {@code cygnus-pack} that carries the vignette glyphs. */ + static final Key FONT = Key.key("cygnus", "tunnel_vision"); + + /** Code point of stage 1; the remaining stages follow consecutively. */ + static final int FIRST_CODE_POINT = 0xE000; + + /** Prepared once: the overlay is refreshed ten times per second per survivor. */ + private static final Component[] GLYPHS = buildGlyphs(); + + /** + * {@inheritDoc} + */ + @Override + public void render(Player player, int stage) { + if (stage <= 0) { + this.clear(player); + return; + } + player.sendActionBar(GLYPHS[Math.min(stage, TunnelVisionStage.MAX_STAGE) - 1]); + } + + /** + * {@inheritDoc} + */ + @Override + public void clear(Player player) { + player.sendActionBar(Component.empty()); + } + + /** + * Builds the component for every stage. + *

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

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

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

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

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

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

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

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

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

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

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

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

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

+ *

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

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

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

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

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

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

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

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class TunnelVisionStage { + + /** Number of stages the overlay is quantised to; stage {@code 0} means no overlay. */ + public static final int MAX_STAGE = 8; + + /** 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. */ + private static final double PULSE_DEPTH = 0.5D; + + /** Heartbeat frequency in hertz while the survivor is barely threatened. */ + private static final double BASE_FREQUENCY = 1.0D; + + /** Additional heartbeat frequency in hertz at full intensity. */ + private static final double FREQUENCY_GAIN = 1.5D; + + private static final double TICK_SECONDS = TICK_MILLIS / 1000.0D; + + /** Negative until the first update, so the first intensity is adopted without hysteresis. */ + private int baseStage = -1; + + private double elapsedSeconds; + + /** + * Advances the heartbeat by one tick and reports the stage to render. + * + * @param combined the combined intensity from {@link TunnelVisionIntensity} + * @return the stage to render, between {@code 0} and {@link #MAX_STAGE} + */ + public int update(double combined) { + double exactStage = combined * MAX_STAGE; + if (this.baseStage < 0 || Math.abs(exactStage - this.baseStage) > HYSTERESIS) { + this.baseStage = (int) Math.round(exactStage); + } + + this.elapsedSeconds += TICK_SECONDS; + double frequency = BASE_FREQUENCY + FREQUENCY_GAIN * combined; + double depth = PULSE_DEPTH * combined; + // The heartbeat only ever opens the view up, never beyond the base stage: at full + // intensity the base stage is the maximum, and a symmetric pulse would be clipped away + // exactly where it matters most. + double pulse = depth * (Math.sin(2.0D * Math.PI * frequency * this.elapsedSeconds) - 1.0D); + + int rendered = (int) Math.round(this.baseStage + pulse); + return Math.min(MAX_STAGE, Math.max(0, rendered)); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java new file mode 100644 index 00000000..f6ea8e77 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.onelitefeather.cygnus.tunnelvision; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java b/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java new file mode 100644 index 00000000..997b37fe --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java @@ -0,0 +1,110 @@ +package net.onelitefeather.cygnus.command; + +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.minestom.server.MinecraftServer; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.network.packet.server.play.ActionBarPacket; +import net.minestom.testing.Collector; +import net.minestom.testing.Env; +import net.minestom.testing.TestConnection; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.tunnelvision.ActionBarTunnelVisionRenderer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the command used to eyeball the vignette while the round has not started yet. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class TunnelVisionCommandTest extends CygnusPlayerTestBase { + + /** First glyph of the pack font; stage 1 lives here, the rest follows consecutively. */ + private static final int FIRST_CODE_POINT = 0xE000; + + @Test + @DisplayName("A requested stage is drawn right away") + void stageIsDrawnOnRequest(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + Collector collector = connection.trackIncoming(ActionBarPacket.class); + register(); + + MinecraftServer.getCommandManager().execute(player, "tunnelvision stage 5"); + + collector.assertSingle(packet -> assertEquals( + glyphOf(5), + plain(packet), + "the command must draw the requested stage" + )); + } + + @Test + @DisplayName("Switching the preview off clears the screen") + void offClearsTheScreen(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + Collector collector = connection.trackIncoming(ActionBarPacket.class); + register(); + + MinecraftServer.getCommandManager().execute(player, "tunnelvision off"); + + collector.assertSingle(packet -> assertTrue(plain(packet).isEmpty(), "the preview must disappear")); + } + + @Test + @DisplayName("A previewed intensity starts at its stage") + void intensityStartsDrawing(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + Collector collector = connection.trackIncoming(ActionBarPacket.class); + register(); + + MinecraftServer.getCommandManager().execute(player, "tunnelvision intensity 1.0"); + + collector.assertSingle(packet -> assertEquals( + glyphOf(8), + plain(packet), + "full intensity starts at the tightest stage" + )); + } + + /** + * Registers the command under test. The environment is shared across the tests in this class, + * so a second registration would be rejected. + */ + private void register() { + if (MinecraftServer.getCommandManager().getCommand("tunnelvision") != null) return; + MinecraftServer.getCommandManager().register(new TunnelVisionCommand(new ActionBarTunnelVisionRenderer())); + } + + /** + * Reads the bare text out of an action bar packet. + * + * @param packet the packet to read + * @return the plain text + */ + private String plain(ActionBarPacket packet) { + return PlainTextComponentSerializer.plainText().serialize(packet.text()); + } + + /** + * Builds the glyph expected for a stage. + * + * @param stage the stage + * @return the glyph as a string + */ + private String glyphOf(int stage) { + return new String(Character.toChars(FIRST_CODE_POINT + stage - 1)); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java new file mode 100644 index 00000000..1fe45c52 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/FoodBarTest.java @@ -0,0 +1,32 @@ +package net.onelitefeather.cygnus.stamina; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.player.CygnusPlayer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies the stamina share other systems read off the survivor's bar. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class FoodBarTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("A fresh bar reports a full share") + void freshBarIsFull(Env env) { + Instance instance = env.createFlatInstance(); + Player player = env.createConnection().connect(instance, new Pos(0, 40, 0)); + FoodBar bar = (FoodBar) StaminaFactory.createFoodStamina((CygnusPlayer) player); + + assertEquals(1.0f, bar.remainingShare(), 1.0E-6f); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/ActionBarTunnelVisionRendererTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/ActionBarTunnelVisionRendererTest.java new file mode 100644 index 00000000..1235a6b5 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/ActionBarTunnelVisionRendererTest.java @@ -0,0 +1,113 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.ShadowColor; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.network.packet.server.play.ActionBarPacket; +import net.minestom.testing.Collector; +import net.minestom.testing.Env; +import net.minestom.testing.TestConnection; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that the vignette reaches the client as an action bar carrying the pack's font. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class ActionBarTunnelVisionRendererTest extends CygnusPlayerTestBase { + + private final ActionBarTunnelVisionRenderer renderer = new ActionBarTunnelVisionRenderer(); + + @Test + @DisplayName("A stage is sent as its glyph in the pack font") + void stageIsSentAsGlyph(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + Collector collector = connection.trackIncoming(ActionBarPacket.class); + + this.renderer.render(player, 3); + + collector.assertSingle(packet -> { + Component message = packet.text(); + assertEquals(ActionBarTunnelVisionRenderer.FONT, message.style().font(), "the overlay must use the pack font"); + assertEquals(glyphOf(3), plain(message), "the glyph must match the stage"); + }); + } + + @Test + @DisplayName("The overlay is drawn without a text shadow") + void overlayHasNoShadow(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + Collector collector = connection.trackIncoming(ActionBarPacket.class); + + this.renderer.render(player, 8); + + collector.assertSingle(packet -> assertEquals( + ShadowColor.none(), + packet.text().style().shadowColor(), + "a shadow would render the vignette a second time, offset" + )); + } + + @Test + @DisplayName("Clearing sends an empty action bar") + void clearingSendsEmptyActionBar(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + Collector collector = connection.trackIncoming(ActionBarPacket.class); + + this.renderer.clear(player); + + collector.assertSingle(packet -> assertTrue( + plain(packet.text()).isEmpty(), + "the overlay must disappear rather than linger" + )); + } + + @Test + @DisplayName("Stage zero clears instead of drawing a glyph") + void zeroStageClears(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + Collector collector = connection.trackIncoming(ActionBarPacket.class); + + this.renderer.render(player, 0); + + collector.assertSingle(packet -> assertTrue(plain(packet.text()).isEmpty(), "stage zero has no glyph")); + } + + /** + * Serialises a component down to its bare text. + * + * @param component the component to serialise + * @return the plain text + */ + private String plain(Component component) { + return PlainTextComponentSerializer.plainText().serialize(component); + } + + /** + * Builds the glyph expected for a stage. + * + * @param stage the stage + * @return the glyph as a string + */ + private String glyphOf(int stage) { + return new String(Character.toChars(ActionBarTunnelVisionRenderer.FIRST_CODE_POINT + stage - 1)); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java new file mode 100644 index 00000000..cbf04fbf --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java @@ -0,0 +1,102 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.minestom.server.coordinate.Pos; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the intensity curves that drive the survivor's tunnel vision. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class TunnelVisionIntensityTest { + + private static final double DELTA = 1.0E-6D; + + /** Survivor standing in the origin, looking towards positive Z. */ + private static final Pos SURVIVOR = new Pos(0, 0, 0, 0, 0); + + @DisplayName("Stamina above half a bar produces no tunnel vision") + @ParameterizedTest + @CsvSource({"1.0", "0.75", "0.5"}) + void staminaAboveHalfIsCalm(double stamina) { + assertEquals(0.0D, TunnelVisionIntensity.fromStamina(stamina), DELTA); + } + + @Test + @DisplayName("An empty stamina bar produces full intensity") + void emptyStaminaIsFull() { + assertEquals(1.0D, TunnelVisionIntensity.fromStamina(0.0D), DELTA); + } + + @Test + @DisplayName("The stamina curve accelerates towards the empty bar") + void staminaCurveIsQuadratic() { + assertEquals(0.25D, TunnelVisionIntensity.fromStamina(0.25D), DELTA); + } + + @Test + @DisplayName("Draining stamina never lowers the intensity") + void staminaIsMonotonic() { + double previous = -1.0D; + for (int step = 20; step >= 0; step--) { + double current = TunnelVisionIntensity.fromStamina(step / 20.0D); + assertTrue(current >= previous, "intensity dropped at stamina " + step / 20.0D); + previous = current; + } + } + + @Test + @DisplayName("A slender beyond the outer radius stays unnoticed") + void distantSlenderIsUnnoticed() { + assertEquals(0.0D, TunnelVisionIntensity.fromSlender(SURVIVOR, new Pos(0, 0, 25)), DELTA); + } + + @Test + @DisplayName("Looking straight at a nearby slender produces full intensity") + void facingNearbySlenderIsFull() { + assertEquals(1.0D, TunnelVisionIntensity.fromSlender(SURVIVOR, new Pos(0, 0, 5)), DELTA); + } + + @Test + @DisplayName("A slender in the back is dampened by the view factor") + void slenderBehindIsDampened() { + assertEquals(0.6D, TunnelVisionIntensity.fromSlender(SURVIVOR, new Pos(0, 0, -5)), DELTA); + } + + @Test + @DisplayName("Approaching the slender never lowers the intensity") + void slenderIsMonotonic() { + double previous = -1.0D; + for (int distance = 30; distance >= 1; distance--) { + double current = TunnelVisionIntensity.fromSlender(SURVIVOR, new Pos(0, 0, distance)); + assertTrue(current >= previous, "intensity dropped at distance " + distance); + previous = current; + } + } + + @Test + @DisplayName("Without either source the combination is calm") + void combinationOfNothingIsCalm() { + assertEquals(0.0D, TunnelVisionIntensity.combine(0.0D, 0.0D), DELTA); + } + + @Test + @DisplayName("A saturated source saturates the combination") + void combinationSaturates() { + assertEquals(1.0D, TunnelVisionIntensity.combine(1.0D, 0.3D), DELTA); + } + + @Test + @DisplayName("Both sources add up without exceeding the maximum") + void combinationAddsUp() { + assertEquals(0.75D, TunnelVisionIntensity.combine(0.5D, 0.5D), DELTA); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java new file mode 100644 index 00000000..6393f47c --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java @@ -0,0 +1,248 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.kyori.adventure.text.Component; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.event.player.PlayerDeathEvent; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.event.GameStartEvent; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies how the service feeds survivors through the intensity calculation and what happens + * when a source of it is missing. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class TunnelVisionServiceTest extends CygnusPlayerTestBase { + + private static final double FULL_STAMINA = 1.0D; + private static final double NO_STAMINA = 0.0D; + + @Test + @DisplayName("An exhausted survivor sees the tightest stage even without a slender") + void exhaustedSurvivorIsFullyNarrowed(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA, () -> null); + service.start(Set.of(survivor)); + + service.tick(); + + assertEquals(TunnelVisionStage.MAX_STAGE, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A rested survivor alone in the dark sees nothing") + void restedSurvivorSeesNothing(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> FULL_STAMINA, () -> null); + service.start(Set.of(survivor)); + + service.tick(); + + assertEquals(0, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A slender standing close narrows the view of a rested survivor") + void nearbySlenderNarrowsTheView(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Instance instance = env.createFlatInstance(); + Player survivor = spawn(env, instance, new Pos(0, 40, 0)); + Player slender = spawn(env, instance, new Pos(0, 40, 5)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> FULL_STAMINA, () -> slender); + service.start(Set.of(survivor)); + + service.tick(); + + assertEquals(TunnelVisionStage.MAX_STAGE, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A slender in another instance is out of reach") + void slenderInAnotherInstanceIsIgnored(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + Player slender = spawn(env, new Pos(0, 40, 5)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> FULL_STAMINA, () -> slender); + service.start(Set.of(survivor)); + + service.tick(); + + assertEquals(0, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A removed survivor gets their screen back and is no longer drawn") + void removedSurvivorIsCleared(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA, () -> null); + service.start(Set.of(survivor)); + service.tick(); + renderer.forget(); + + service.remove(survivor); + service.tick(); + + assertTrue(renderer.wasCleared(survivor), "the last vignette would otherwise linger"); + assertNull(renderer.stageOf(survivor), "a removed survivor must not be drawn any more"); + } + + @Test + @DisplayName("Cleaning up gives every survivor their screen back") + void cleanUpClearsEveryone(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Instance instance = env.createFlatInstance(); + Player first = spawn(env, instance, new Pos(0, 40, 0)); + Player second = spawn(env, instance, new Pos(4, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA, () -> null); + service.start(Set.of(first, second)); + service.tick(); + + service.cleanUp(); + + assertTrue(renderer.wasCleared(first)); + assertTrue(renderer.wasCleared(second)); + + renderer.forget(); + service.tick(); + assertNull(renderer.stageOf(first), "cleanup must stop the drawing as well"); + } + + @Test + @DisplayName("The start of a round takes the survivors on board") + void gameStartRegistersSurvivors(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA, () -> null); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + + EventDispatcher.call(new GameStartEvent()); + service.tick(); + + assertEquals(TunnelVisionStage.MAX_STAGE, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A dying survivor gets their screen back") + void deathClearsTheOverlay(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA, () -> null); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.start(Set.of(survivor)); + service.tick(); + renderer.forget(); + + EventDispatcher.call(new PlayerDeathEvent(survivor, Component.empty(), Component.empty())); + service.tick(); + + assertTrue(renderer.wasCleared(survivor)); + assertNull(renderer.stageOf(survivor), "a dead survivor must not be drawn any more"); + } + + @Test + @DisplayName("The end of a round clears everyone") + void gameFinishCleansUp(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA, () -> null); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.start(Set.of(survivor)); + service.tick(); + renderer.forget(); + + EventDispatcher.call(new GameFinishEvent(GameFinishEvent.Reason.TIME_OVER)); + + assertTrue(renderer.wasCleared(survivor)); + } + + /** + * Spawns a player in a fresh instance. + * + * @param env the test environment + * @param position where to place the player + * @return the connected player + */ + private Player spawn(Env env, Pos position) { + return this.spawn(env, env.createFlatInstance(), position); + } + + /** + * Spawns a player in the given instance. + * + * @param env the test environment + * @param instance the instance to connect into + * @param position where to place the player + * @return the connected player + */ + private Player spawn(Env env, Instance instance, Pos position) { + return env.createConnection().connect(instance, position); + } + + /** + * Records what the service asked to be drawn, standing in for the action bar renderer. + */ + private static final class RecordingRenderer implements TunnelVisionRenderer { + + private final Map stages = new HashMap<>(); + private final Set cleared = new HashSet<>(); + + @Override + public void render(Player player, int stage) { + this.stages.put(player.getUuid(), stage); + } + + @Override + public void clear(Player player) { + this.cleared.add(player.getUuid()); + this.stages.remove(player.getUuid()); + } + + /** + * @param player the player to look up + * @return the stage last drawn for the player, or {@code null} if nothing was drawn + */ + private @Nullable Integer stageOf(Player player) { + return this.stages.get(player.getUuid()); + } + + /** + * @param player the player to look up + * @return whether the player's overlay was cleared + */ + private boolean wasCleared(Player player) { + return this.cleared.contains(player.getUuid()); + } + + /** + * Drops everything recorded so far, to tell repeated draws apart. + */ + private void forget() { + this.stages.clear(); + this.cleared.clear(); + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java new file mode 100644 index 00000000..4b594972 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java @@ -0,0 +1,95 @@ +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 between the last two stages") + void fullIntensityPulses() { + TunnelVisionStage stage = new TunnelVisionStage(); + int lowest = TunnelVisionStage.MAX_STAGE; + int highest = 0; + for (int sample = 0; sample < SAMPLES; sample++) { + int current = stage.update(1.0D); + lowest = Math.min(lowest, current); + highest = Math.max(highest, current); + } + assertEquals(TunnelVisionStage.MAX_STAGE, highest, "the pulse never reaches the peak"); + assertEquals(TunnelVisionStage.MAX_STAGE - 1, lowest, "the pulse does not open up again"); + } + + @Test + @DisplayName("Low intensity barely pulses at all") + void lowIntensityIsSteady() { + TunnelVisionStage stage = new TunnelVisionStage(); + int first = stage.update(0.125D); + for (int sample = 0; sample < SAMPLES; sample++) { + assertEquals(first, stage.update(0.125D), "a barely threatened survivor should not flicker"); + } + } + + @Test + @DisplayName("A small fluctuation does not move the stage") + void hysteresisHoldsTheStage() { + TunnelVisionStage stage = new TunnelVisionStage(); + int settled = highestOver(stage, 0.5D); + assertEquals(4, settled, "half intensity should settle on the middle stage"); + assertEquals(settled, highestOver(stage, 0.55D), "the stage moved on a small fluctuation"); + } + + @Test + @DisplayName("A real change moves the stage") + void largerChangeMovesTheStage() { + TunnelVisionStage stage = new TunnelVisionStage(); + assertEquals(4, highestOver(stage, 0.5D)); + assertEquals(5, highestOver(stage, 0.6D), "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 379680bfecfa4b8487fd28490f886075f6a8589f Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Mon, 10 Aug 2026 21:14:21 +0200 Subject: [PATCH 3/5] docs(tunnel-vision): record the font atlas size limit Glyphs larger than 256x256 in texture resolution are dropped silently, which is what produced the missing-glyph box on the first try. --- .../specs/2026-08-10-tunnel-vision-design.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 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 f8d746b9..363099db 100644 --- a/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md +++ b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md @@ -105,9 +105,15 @@ pack/assets/cygnus/textures/gui/tunnel_vision/stage_1.png … stage_8.png pack/assets/cygnus/font/tunnel_vision.json ``` -Each texture is a soft radial darkening, 1024×512, fully opaque at the outer edge — 2:1 rather -than square so it covers a widescreen viewport. The font is a bitmap provider mapping -`U+E000`–`U+E007` to stages 1–8. +Each texture is a soft radial darkening, 256×128, fully opaque at the outer edge — 2:1 rather than +square so it covers a widescreen viewport. The font is a bitmap provider mapping `U+E000`–`U+E007` +to stages 1–8. + +**The 256 pixel limit is not cosmetic.** Font glyphs are stamped into 256×256 sheets at their +texture resolution, and a glyph that does not fit is dropped without a word in the log — the +client then draws the missing-glyph box. Anything larger simply does not work, however good it +looks in an image viewer. The glyph is still drawn at 540 pixels high; each texture carries a +`blur` mcmeta so that upscale stays smooth instead of banding into nearest-neighbour blocks. The server builds a `Component` carrying `font("cygnus:tunnel_vision")` and sends it with `sendActionBar`. Two details that otherwise look broken: From 692f4a3ab7573519b419672dbfe6ba54b43980b6 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Mon, 10 Aug 2026 21:19:50 +0200 Subject: [PATCH 4/5] docs(tunnel-vision): describe the superellipse shape and new calibration --- .../specs/2026-08-10-tunnel-vision-design.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 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 363099db..a7592bfa 100644 --- a/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md +++ b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md @@ -105,14 +105,16 @@ pack/assets/cygnus/textures/gui/tunnel_vision/stage_1.png … stage_8.png pack/assets/cygnus/font/tunnel_vision.json ``` -Each texture is a soft radial darkening, 256×128, fully opaque at the outer edge — 2:1 rather than -square so it covers a widescreen viewport. The font is a bitmap provider mapping `U+E000`–`U+E007` -to stages 1–8. +Each texture is 256×128, 2:1 so it covers a widescreen viewport, and fully opaque at the outer +edge. The darkening closes in from all four edges rather than as a circle from the middle: it is a +superellipse whose exponent eases from 4 at stage 1 — a rounded rectangle framing the screen — to 2 +at stage 8, where a plain ellipse reads as a tunnel rather than a frame. The font is a bitmap +provider mapping `U+E000`–`U+E007` to stages 1–8. **The 256 pixel limit is not cosmetic.** Font glyphs are stamped into 256×256 sheets at their texture resolution, and a glyph that does not fit is dropped without a word in the log — the client then draws the missing-glyph box. Anything larger simply does not work, however good it -looks in an image viewer. The glyph is still drawn at 540 pixels high; each texture carries a +looks in an image viewer. The glyph is still drawn several times that size; each texture carries a `blur` mcmeta so that upscale stays smooth instead of banding into nearest-neighbour blocks. The server builds a `Component` carrying `font("cygnus:tunnel_vision")` and sends it with @@ -126,9 +128,11 @@ The server builds a `Component` carrying `font("cygnus:tunnel_vision")` and send the server knows neither the client's resolution nor its GUI scale, so pixel-accurate centring is impossible. The texture is deliberately larger than any realistic viewport and fully opaque at the edge: the overhang is clipped, and because the vignette is soft, the offset does not read as an -error. `height` and `ascent` in the font provider are calibration values. They start at `height: -540`, `ascent: 478`, which centres the vignette on a 1080p client at GUI scale 2, and get adjusted -in-game with `/tunnelvision stage `. +error. `height` and `ascent` in the font provider are calibration values, currently `height: 280` +and `ascent: 195`, which centre the vignette on a 427×240 GUI viewport (an 854×480 window at auto +scale). A different window size moves it, and `/tunnelvision stage ` is how it gets pulled back +into place: `ascent` ≈ `screenHeight/2 - 65 + height/2`, with `height` at least the screen height +so the edges stay covered. This is the cost of the action-bar approach against a real post effect, which would be full-screen by nature. From 8571acb6842511b5f2494bd35cf2490b42197ea8 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Mon, 10 Aug 2026 21:30:14 +0200 Subject: [PATCH 5/5] feat(tunnel-vision): draw through the title and double the frames The action bar anchors a glyph to the bottom edge, so the vignette had to be recalibrated for every window size and read as darkness welling up from below. Titles render centred and at 4x scale: centring is now ascent = height/2 - 3 whatever the resolution, and the times are sent once per player so the overlay neither fades nor pumps between updates. Minecraft cannot animate a font texture, so the heartbeat is the server walking through frames. Sixteen instead of eight make that read as motion, with the pulse depth expressed as a share of the scale so it stays equally deep. --- .../specs/2026-08-10-tunnel-vision-design.md | 62 ++-- .../net/onelitefeather/cygnus/Cygnus.java | 4 +- .../cygnus/command/TunnelVisionCommand.java | 4 +- .../ActionBarTunnelVisionRenderer.java | 76 ----- .../TitleTunnelVisionRenderer.java | 114 +++++++ .../tunnelvision/TunnelVisionStage.java | 18 +- .../command/TunnelVisionCommandTest.java | 21 +- ...derSpectatorVisibilityIntegrationTest.java | 223 +++++++++++++ .../SlenderVisibilityIntegrationTest.java | 301 ++++++++++++++++++ ...ava => TitleTunnelVisionRendererTest.java} | 67 +++- .../tunnelvision/TunnelVisionStageTest.java | 13 +- 11 files changed, 755 insertions(+), 148 deletions(-) delete mode 100644 game/src/main/java/net/onelitefeather/cygnus/tunnelvision/ActionBarTunnelVisionRenderer.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TitleTunnelVisionRenderer.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderSpectatorVisibilityIntegrationTest.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderVisibilityIntegrationTest.java rename game/src/test/java/net/onelitefeather/cygnus/tunnelvision/{ActionBarTunnelVisionRendererTest.java => TitleTunnelVisionRendererTest.java} (52%) 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 a7592bfa..75f6baca 100644 --- a/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md +++ b/docs/superpowers/specs/2026-08-10-tunnel-vision-design.md @@ -20,7 +20,7 @@ That changes in 26.3: snapshot 3 (7 July 2026) added `/posteffect add|remove ` is how it gets pulled back -into place: `ascent` ≈ `screenHeight/2 - 65 + height/2`, with `height` at least the screen height -so the edges stay covered. - -This is the cost of the action-bar approach against a real post effect, which would be -full-screen by nature. +- The title times are sent once per player with `fadeIn` and `fadeOut` at zero. A fade would make + the vignette pump on every update, and repeating the times ten times a second would double the + packet count for nothing — they stay in effect for every following title. +- `stay` is two seconds: long enough that the overlay never blinks between updates, short enough + that it disappears on its own if the server stops drawing. + +**The title is the channel, not the action bar.** Titles render centred on the screen and at four +times scale, which is what makes the position independent of the client's resolution: centring +needs `ascent = height/2 - 3` regardless of window size, where the action bar's anchor at the +bottom edge forced a recalibration for every viewport. `height: 70` covers a 240-pixel-high GUI +viewport four times over at that scale, and the 2:1 texture covers the width. ## Components @@ -145,10 +145,10 @@ New package `net.onelitefeather.cygnus.tunnelvision`: - `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. -- `ActionBarTunnelVisionRenderer` — the implementation described above. +- `TitleTunnelVisionRenderer` — the implementation described above. - `TunnelVisionService` — holds a `TunnelVisionStage` per survivor and ticks all of them in one scheduler task. -- `TunnelVisionCommand` — `/tunnelvision stage <0-8> | intensity <0.0-1.0> | off`, for judging the +- `TunnelVisionCommand` — `/tunnelvision stage <0-16> | intensity <0.0-1.0> | off`, for judging the vignette from the lobby without a running round. `stage` freezes one stage to calibrate the font against; `intensity` runs the real heartbeat. @@ -189,7 +189,7 @@ The service keeps running in all of these; none of them throws. | No Slender (disconnected, not yet assigned) | stamina share only | | Slender in a different instance | slender share is 0 | | No `FoodBar` registered for a player | stamina share is 0 | -| Stage drops to 0 | `clear()` rather than rendering — otherwise the last vignette lingers for three seconds until the action bar fades on its own | +| Stage drops to 0 | `clear()` rather than rendering — otherwise the last vignette lingers until the title's `stay` runs out | | Player dies or becomes a spectator | explicit `clear()`, same reason | ## Tests @@ -198,9 +198,9 @@ The service keeps running in all of these; none of them throws. 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. -- `ActionBarTunnelVisionRendererTest` — Cyano: the player receives an action bar packet with the - expected code point and the `cygnus:tunnel_vision` font, the shadow is transparent, and - `clear()` sends an empty component. +- `TitleTunnelVisionRendererTest` — Cyano: the player receives a title packet with the expected + code point and the `cygnus:tunnel_vision` font, the shadow is transparent, the times hold rather + than fade and are sent only once, and `clear()` empties the title. - `TunnelVisionServiceTest` — lifecycle: start and stop, removing a player, behaviour with no Slender or one in another instance, and the four lifecycle events. - `TunnelVisionCommandTest` — the command draws the requested stage, previews an intensity, and diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index ae72394d..7d0000b5 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -75,7 +75,7 @@ import net.onelitefeather.cygnus.stamina.SlenderBarTrigger; import net.onelitefeather.cygnus.stamina.StaminaService; import net.onelitefeather.cygnus.stamina.FoodBar; -import net.onelitefeather.cygnus.tunnelvision.ActionBarTunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TitleTunnelVisionRenderer; import net.onelitefeather.cygnus.tunnelvision.TunnelVisionRenderer; import net.onelitefeather.cygnus.tunnelvision.TunnelVisionService; import net.onelitefeather.cygnus.utils.StaminaHelper; @@ -134,7 +134,7 @@ public Cygnus() { .orElseThrow(() -> new IllegalStateException("Spectator team not found")); this.spectatorService = new SpectatorService(spectatorTeam, survivorTeam); this.resourcePackService = ResourcePackService.create(); - this.tunnelVisionRenderer = new ActionBarTunnelVisionRenderer(); + this.tunnelVisionRenderer = new TitleTunnelVisionRenderer(); this.tunnelVisionService = new TunnelVisionService( this.tunnelVisionRenderer, this::remainingStamina, 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 7067b58e..741716b4 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java +++ b/game/src/main/java/net/onelitefeather/cygnus/command/TunnelVisionCommand.java @@ -20,7 +20,7 @@ * 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-8>} freezes a single stage, which is what the font's + * {@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}. @@ -49,7 +49,7 @@ 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-8> | intensity <0.0-1.0> | off") + Messages.withMiniPrefix("Usage: /tunnelvision stage <0-16> | intensity <0.0-1.0> | off") )); this.addSyntax((sender, context) -> { diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/ActionBarTunnelVisionRenderer.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/ActionBarTunnelVisionRenderer.java deleted file mode 100644 index 74735210..00000000 --- a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/ActionBarTunnelVisionRenderer.java +++ /dev/null @@ -1,76 +0,0 @@ -package net.onelitefeather.cygnus.tunnelvision; - -import net.kyori.adventure.key.Key; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.ShadowColor; -import net.minestom.server.entity.Player; - -/** - * Draws the tunnel vision as a HUD overlay through the action bar. - *

- * Each stage is a glyph of the {@code cygnus:tunnel_vision} bitmap font shipped with the resource - * pack. The action bar is the only HUD channel a survivor has free — the progress bar in - * {@code StaminaColors} belongs to the slender — and unlike a title it needs no fade timing. - *

- *

- * Font glyphs are positioned relative to the action bar, and the server knows neither the client's - * resolution nor its GUI scale, so the vignette cannot be centred exactly. The textures are - * therefore larger than any realistic viewport and opaque at their edge, which turns the offset - * into something the soft vignette hides. - *

- * - * @author TheMeinerLP - * @version 1.0.0 - * @since 2.7.0 - */ -public final class ActionBarTunnelVisionRenderer implements TunnelVisionRenderer { - - /** Bitmap font provided by {@code cygnus-pack} that carries the vignette glyphs. */ - static final Key FONT = Key.key("cygnus", "tunnel_vision"); - - /** Code point of stage 1; the remaining stages follow consecutively. */ - static final int FIRST_CODE_POINT = 0xE000; - - /** Prepared once: the overlay is refreshed ten times per second per survivor. */ - private static final Component[] GLYPHS = buildGlyphs(); - - /** - * {@inheritDoc} - */ - @Override - public void render(Player player, int stage) { - if (stage <= 0) { - this.clear(player); - return; - } - player.sendActionBar(GLYPHS[Math.min(stage, TunnelVisionStage.MAX_STAGE) - 1]); - } - - /** - * {@inheritDoc} - */ - @Override - public void clear(Player player) { - player.sendActionBar(Component.empty()); - } - - /** - * Builds the component for every stage. - *

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

- * - * @return the prepared components, indexed by {@code stage - 1} - */ - private static Component[] buildGlyphs() { - Component[] glyphs = new Component[TunnelVisionStage.MAX_STAGE]; - for (int stage = 1; stage <= TunnelVisionStage.MAX_STAGE; stage++) { - String glyph = new String(Character.toChars(FIRST_CODE_POINT + stage - 1)); - glyphs[stage - 1] = Component.text(glyph) - .font(FONT) - .shadowColor(ShadowColor.none()); - } - return glyphs; - } -} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TitleTunnelVisionRenderer.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TitleTunnelVisionRenderer.java new file mode 100644 index 00000000..99167f7a --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TitleTunnelVisionRenderer.java @@ -0,0 +1,114 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.ShadowColor; +import net.kyori.adventure.title.Title; +import net.kyori.adventure.title.TitlePart; +import net.minestom.server.entity.Player; + +import java.time.Duration; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Draws the tunnel vision as a HUD overlay through the title. + *

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

+ *

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

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

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

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

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

+ * + * @return the prepared components, indexed by {@code stage - 1} + */ + private static Component[] buildGlyphs() { + Component[] glyphs = new Component[TunnelVisionStage.MAX_STAGE]; + for (int stage = 1; stage <= TunnelVisionStage.MAX_STAGE; stage++) { + String glyph = new String(Character.toChars(FIRST_CODE_POINT + stage - 1)); + glyphs[stage - 1] = Component.text(glyph) + .font(FONT) + .shadowColor(ShadowColor.none()); + } + return glyphs; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java index a829bf0e..7116784d 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStage.java @@ -16,8 +16,15 @@ */ public final class TunnelVisionStage { - /** Number of stages the overlay is quantised to; stage {@code 0} means no overlay. */ - public static final int MAX_STAGE = 8; + /** + * Number of stages the overlay is quantised to; stage {@code 0} means no overlay. + *

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

+ */ + public static final int MAX_STAGE = 16; /** Interval the service updates at, which is also the sampling rate of the heartbeat. */ public static final int TICK_MILLIS = 100; @@ -25,8 +32,11 @@ public final class TunnelVisionStage { /** 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. */ - private static final double PULSE_DEPTH = 0.5D; + /** + * 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; 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 997b37fe..47b1ee6c 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/command/TunnelVisionCommandTest.java @@ -5,12 +5,13 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.entity.Player; import net.minestom.server.instance.Instance; -import net.minestom.server.network.packet.server.play.ActionBarPacket; +import net.minestom.server.network.packet.server.play.SetTitleTextPacket; import net.minestom.testing.Collector; import net.minestom.testing.Env; import net.minestom.testing.TestConnection; import net.onelitefeather.cygnus.CygnusPlayerTestBase; -import net.onelitefeather.cygnus.tunnelvision.ActionBarTunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TitleTunnelVisionRenderer; +import net.onelitefeather.cygnus.tunnelvision.TunnelVisionStage; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -35,7 +36,7 @@ void stageIsDrawnOnRequest(Env env) { Instance instance = env.createFlatInstance(); TestConnection connection = env.createConnection(); Player player = connection.connect(instance, new Pos(0, 40, 0)); - Collector collector = connection.trackIncoming(ActionBarPacket.class); + Collector collector = connection.trackIncoming(SetTitleTextPacket.class); register(); MinecraftServer.getCommandManager().execute(player, "tunnelvision stage 5"); @@ -53,7 +54,7 @@ void offClearsTheScreen(Env env) { Instance instance = env.createFlatInstance(); TestConnection connection = env.createConnection(); Player player = connection.connect(instance, new Pos(0, 40, 0)); - Collector collector = connection.trackIncoming(ActionBarPacket.class); + Collector collector = connection.trackIncoming(SetTitleTextPacket.class); register(); MinecraftServer.getCommandManager().execute(player, "tunnelvision off"); @@ -67,13 +68,13 @@ void intensityStartsDrawing(Env env) { Instance instance = env.createFlatInstance(); TestConnection connection = env.createConnection(); Player player = connection.connect(instance, new Pos(0, 40, 0)); - Collector collector = connection.trackIncoming(ActionBarPacket.class); + Collector collector = connection.trackIncoming(SetTitleTextPacket.class); register(); MinecraftServer.getCommandManager().execute(player, "tunnelvision intensity 1.0"); collector.assertSingle(packet -> assertEquals( - glyphOf(8), + glyphOf(TunnelVisionStage.MAX_STAGE), plain(packet), "full intensity starts at the tightest stage" )); @@ -85,17 +86,17 @@ void intensityStartsDrawing(Env env) { */ private void register() { if (MinecraftServer.getCommandManager().getCommand("tunnelvision") != null) return; - MinecraftServer.getCommandManager().register(new TunnelVisionCommand(new ActionBarTunnelVisionRenderer())); + MinecraftServer.getCommandManager().register(new TunnelVisionCommand(new TitleTunnelVisionRenderer())); } /** - * Reads the bare text out of an action bar packet. + * Reads the bare text out of a title packet. * * @param packet the packet to read * @return the plain text */ - private String plain(ActionBarPacket packet) { - return PlainTextComponentSerializer.plainText().serialize(packet.text()); + private String plain(SetTitleTextPacket packet) { + return PlainTextComponentSerializer.plainText().serialize(packet.title()); } /** diff --git a/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderSpectatorVisibilityIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderSpectatorVisibilityIntegrationTest.java new file mode 100644 index 00000000..c8b6b086 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/stamina/SlenderSpectatorVisibilityIntegrationTest.java @@ -0,0 +1,223 @@ +package net.onelitefeather.cygnus.stamina; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.GameMode; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.TestConnection; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.common.Tags; +import net.onelitefeather.cygnus.common.config.GameConfig; +import net.onelitefeather.cygnus.player.CygnusPlayer; +import net.onelitefeather.cygnus.utils.ViewRuleUpdater; +import net.theevilreaper.xerus.api.team.Team; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies how the spectator system introduced in 2.7.0 interacts with the slender + * visibility rule. + *

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

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

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

+ * The closing {@code updateViewableRule()} is not part of the production + * path — production sets the tag without re-evaluating. It is added here so the + * remaining tests start from the intended state; the missing call itself is covered + * by {@link #testRoundStartHidesSlender(Env)}. + * + * @param slender the player acting as the slender + * @param survivorTeam the survivor team + */ + private void startRound(@NotNull Player slender, @NotNull Team survivorTeam) { + assignSlender(slender); + survivorTeam.getPlayers().forEach(survivor -> { + survivor.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + survivor.setTag(Tags.HIDDEN, SlenderBarHelper.VISIBLE); + }); + slender.setTag(Tags.HIDDEN, SlenderBarHelper.HIDDEN); + slender.updateViewableRule(); + } + + /** + * Replays {@code TeamHelper.assignSlender} verbatim, including the order in which the + * rule is installed relative to the {@link Tags#HIDDEN} tag. + * + * @param slender the player to become the slender + */ + private void assignSlender(@NotNull Player slender) { + slender.setTag(Tags.TEAM_KEY, GameConfig.SLENDER_KEY); + slender.updateViewableRule(_ -> !ViewRuleUpdater.isHidden(slender)); + } + + /** + * Replays an eye press: {@code SlenderBarTrigger.trigger}, i.e. changeStatus plus + * changeVisibilityStatus plus the real {@link ViewRuleUpdater#updateViewer}. + * + * @param bar the slender bar to toggle + * @param slender the player acting as the slender + * @param survivorTeam the survivor team handed to the view rule updater + */ + private void pressEye(@NotNull SlenderBar bar, @NotNull Player slender, @NotNull Team survivorTeam) { + if (!bar.changeStatus()) return; + // SlenderBarTrigger.changeVisibilityStatus toggles the tag on the slender itself. + Byte value = slender.getTag(Tags.HIDDEN); + byte current = value != null ? value : SlenderBarHelper.VISIBLE; + slender.setTag(Tags.HIDDEN, current == SlenderBarHelper.VISIBLE + ? SlenderBarHelper.HIDDEN : SlenderBarHelper.VISIBLE); + ViewRuleUpdater.updateViewer(slender, survivorTeam); + } + + /** + * Sanity check: without this the remaining assertions could pass vacuously. + * The eye press must make the slender a registered viewer of the survivor. + */ + @Test + @DisplayName("Vorbedingung: Augendruck macht den Slender sichtbar") + void testEyePressMakesSlenderVisible(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + Team survivorTeam = createSurvivorTeam(survivor); + startRound(slender, survivorTeam); + + assertFalse(slender.isViewer(survivor), "Vor dem Augendruck darf niemand den Slender sehen"); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + pressEye(slenderBar, slender, survivorTeam); + env.tick(); + + assertTrue(slender.isViewer(survivor), + "Nach dem Augendruck muss der Survivor registrierter Viewer sein"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } + + /** + * The round start installs the rule before the HIDDEN tag exists, so the rule + * evaluates to visible and registers every nearby player. Setting the tag afterwards + * without re-evaluating leaves that registration in place. + */ + @Test + @DisplayName("Rundenstart laesst den Slender nicht registriert zurueck") + void testRoundStartHidesSlender(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + + // Exact production order: TeamHelper.assignSlender, then GameStartListener. + assignSlender(slender); + survivor.setTag(Tags.TEAM_KEY, GameConfig.SURVIVOR_KEY); + slender.setTag(Tags.HIDDEN, SlenderBarHelper.HIDDEN); + survivor.setTag(Tags.HIDDEN, SlenderBarHelper.VISIBLE); + env.tick(); + + assertFalse(slender.isViewer(survivor), + "Zu Rundenbeginn darf der Slender fuer keinen Survivor registriert sein - " + + "die Regel wird installiert, bevor der HIDDEN-Tag existiert, und " + + "danach nicht erneut ausgewertet"); + + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("Auto-Timeout traegt den Viewer serverseitig aus") + void testAutoTimeoutUnregistersViewer(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + Team survivorTeam = createSurvivorTeam(survivor); + startRound(slender, survivorTeam); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + + pressEye(slenderBar, slender, survivorTeam); + env.tick(); + assertTrue(slender.isViewer(survivor), "Vorbedingung: Slender muss sichtbar sein"); + + // Bar auslaufen lassen - der Pfad, der den Trigger umgeht. + for (int i = 0; i < TICKS_TO_TIMEOUT; i++) { + slenderBar.consume(); + } + env.tick(); + + assertFalse(slender.isViewer(survivor), + "Nach dem Auto-Timeout darf der Survivor kein registrierter Viewer mehr sein - " + + "sonst wird der Slender beim Wiedereintritt in die View-Distance neu gespawnt"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("Manueller Doppeldruck bleibt korrekt") + void testManualToggleStaysCorrect(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + Team survivorTeam = createSurvivorTeam(survivor); + startRound(slender, survivorTeam); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + + pressEye(slenderBar, slender, survivorTeam); // READY -> DRAINING + env.tick(); + pressEye(slenderBar, slender, survivorTeam); // DRAINING -> REGENERATING + env.tick(); + + assertFalse(slender.isViewer(survivor), + "Nach dem manuellen Zurueckschalten darf kein Viewer registriert bleiben"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("Polaritaet bleibt ueber mehrere Zyklen stabil") + void testPolarityStableAcrossCycles(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + Team survivorTeam = createSurvivorTeam(survivor); + startRound(slender, survivorTeam); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + + // Zyklus 1: Angriff, dann auslaufen lassen. + pressEye(slenderBar, slender, survivorTeam); + for (int i = 0; i < TICKS_TO_TIMEOUT; i++) { + slenderBar.consume(); + } + env.tick(); + + // Zyklus 2: erneuter Angriff - der Slender MUSS jetzt sichtbar sein. + pressEye(slenderBar, slender, survivorTeam); + env.tick(); + + assertTrue(slender.isViewer(survivor), + "Im Angriffsmodus muss der Slender sichtbar sein - " + + "ist er es nicht, ist die Sichtbarkeits-Polaritaet verdreht"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("View-Distance-Zyklus spawnt den Slender nicht neu") + void testViewDistanceCycleDoesNotRespawn(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + TestConnection slenderConnection = env.createConnection(); + TestConnection survivorConnection = env.createConnection(); + + CygnusPlayer slender = (CygnusPlayer) slenderConnection.connect(instance, new Pos(0, 40, 0)); + Player survivor = survivorConnection.connect(instance, new Pos(5, 40, 5)); + Team survivorTeam = createSurvivorTeam(survivor); + startRound(slender, survivorTeam); + + SlenderBar slenderBar = (SlenderBar) StaminaFactory.createSlenderStamina(slender); + slenderBar.start(); + + pressEye(slenderBar, slender, survivorTeam); + for (int i = 0; i < TICKS_TO_TIMEOUT; i++) { + slenderBar.consume(); + } + env.tick(); + + // Ab hier auf Spawn-Pakete horchen - VOR dem Teleport starten. + var spawnCollector = survivorConnection.trackIncoming( + net.minestom.server.network.packet.server.play.SpawnEntityPacket.class); + + // Raus: 6 Chunks weit (> ENTITY_VIEW_DISTANCE = 5). + survivor.teleport(new Pos(6 * 16, 40, 0)).join(); + env.tick(); + + // Und wieder rein. + survivor.teleport(new Pos(5, 40, 5)).join(); + env.tick(); + + assertEquals(0, spawnCollector.collect().size(), + "Beim Wiedereintritt in die View-Distance darf der unsichtbare Slender " + + "nicht neu gespawnt werden"); + + slenderBar.stop(); + env.destroyInstance(instance, true); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/ActionBarTunnelVisionRendererTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TitleTunnelVisionRendererTest.java similarity index 52% rename from game/src/test/java/net/onelitefeather/cygnus/tunnelvision/ActionBarTunnelVisionRendererTest.java rename to game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TitleTunnelVisionRendererTest.java index 1235a6b5..2dfc4cd8 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/ActionBarTunnelVisionRendererTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TitleTunnelVisionRendererTest.java @@ -6,7 +6,8 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.entity.Player; import net.minestom.server.instance.Instance; -import net.minestom.server.network.packet.server.play.ActionBarPacket; +import net.minestom.server.network.packet.server.play.SetTitleTextPacket; +import net.minestom.server.network.packet.server.play.SetTitleTimePacket; import net.minestom.testing.Collector; import net.minestom.testing.Env; import net.minestom.testing.TestConnection; @@ -18,15 +19,15 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Verifies that the vignette reaches the client as an action bar carrying the pack's font. + * Verifies that the vignette reaches the client as a title carrying the pack's font. * * @author TheMeinerLP * @version 1.0.0 * @since 2.7.0 */ -class ActionBarTunnelVisionRendererTest extends CygnusPlayerTestBase { +class TitleTunnelVisionRendererTest extends CygnusPlayerTestBase { - private final ActionBarTunnelVisionRenderer renderer = new ActionBarTunnelVisionRenderer(); + private final TitleTunnelVisionRenderer renderer = new TitleTunnelVisionRenderer(); @Test @DisplayName("A stage is sent as its glyph in the pack font") @@ -34,46 +35,78 @@ void stageIsSentAsGlyph(Env env) { Instance instance = env.createFlatInstance(); TestConnection connection = env.createConnection(); Player player = connection.connect(instance, new Pos(0, 40, 0)); - Collector collector = connection.trackIncoming(ActionBarPacket.class); + Collector collector = connection.trackIncoming(SetTitleTextPacket.class); this.renderer.render(player, 3); collector.assertSingle(packet -> { - Component message = packet.text(); - assertEquals(ActionBarTunnelVisionRenderer.FONT, message.style().font(), "the overlay must use the pack font"); + Component message = packet.title(); + assertEquals(TitleTunnelVisionRenderer.FONT, message.style().font(), "the overlay must use the pack font"); assertEquals(glyphOf(3), plain(message), "the glyph must match the stage"); }); } + @Test + @DisplayName("The title is set to hold instead of fading") + void titleHoldsWithoutFading(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + Collector collector = connection.trackIncoming(SetTitleTimePacket.class); + + this.renderer.render(player, 3); + + collector.assertSingle(packet -> { + assertEquals(0, packet.fadeIn(), "a fade in would make the vignette pump on every update"); + assertEquals(0, packet.fadeOut(), "a fade out would do the same"); + assertTrue(packet.stay() > 0, "the vignette has to survive between updates"); + }); + } + + @Test + @DisplayName("The times are sent once rather than with every update") + void timesAreSentOnce(Env env) { + Instance instance = env.createFlatInstance(); + TestConnection connection = env.createConnection(); + Player player = connection.connect(instance, new Pos(0, 40, 0)); + this.renderer.render(player, 3); + Collector collector = connection.trackIncoming(SetTitleTimePacket.class); + + this.renderer.render(player, 4); + this.renderer.render(player, 5); + + collector.assertEmpty(); + } + @Test @DisplayName("The overlay is drawn without a text shadow") void overlayHasNoShadow(Env env) { Instance instance = env.createFlatInstance(); TestConnection connection = env.createConnection(); Player player = connection.connect(instance, new Pos(0, 40, 0)); - Collector collector = connection.trackIncoming(ActionBarPacket.class); + Collector collector = connection.trackIncoming(SetTitleTextPacket.class); - this.renderer.render(player, 8); + this.renderer.render(player, TunnelVisionStage.MAX_STAGE); collector.assertSingle(packet -> assertEquals( ShadowColor.none(), - packet.text().style().shadowColor(), + packet.title().style().shadowColor(), "a shadow would render the vignette a second time, offset" )); } @Test - @DisplayName("Clearing sends an empty action bar") - void clearingSendsEmptyActionBar(Env env) { + @DisplayName("Clearing empties the title") + void clearingEmptiesTheTitle(Env env) { Instance instance = env.createFlatInstance(); TestConnection connection = env.createConnection(); Player player = connection.connect(instance, new Pos(0, 40, 0)); - Collector collector = connection.trackIncoming(ActionBarPacket.class); + Collector collector = connection.trackIncoming(SetTitleTextPacket.class); this.renderer.clear(player); collector.assertSingle(packet -> assertTrue( - plain(packet.text()).isEmpty(), + plain(packet.title()).isEmpty(), "the overlay must disappear rather than linger" )); } @@ -84,11 +117,11 @@ void zeroStageClears(Env env) { Instance instance = env.createFlatInstance(); TestConnection connection = env.createConnection(); Player player = connection.connect(instance, new Pos(0, 40, 0)); - Collector collector = connection.trackIncoming(ActionBarPacket.class); + Collector collector = connection.trackIncoming(SetTitleTextPacket.class); this.renderer.render(player, 0); - collector.assertSingle(packet -> assertTrue(plain(packet.text()).isEmpty(), "stage zero has no glyph")); + collector.assertSingle(packet -> assertTrue(plain(packet.title()).isEmpty(), "stage zero has no glyph")); } /** @@ -108,6 +141,6 @@ private String plain(Component component) { * @return the glyph as a string */ private String glyphOf(int stage) { - return new String(Character.toChars(ActionBarTunnelVisionRenderer.FIRST_CODE_POINT + stage - 1)); + return new String(Character.toChars(TitleTunnelVisionRenderer.FIRST_CODE_POINT + stage - 1)); } } diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java index 4b594972..b3a08b67 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java @@ -26,7 +26,7 @@ void calmIntensityStaysOff() { } @Test - @DisplayName("Full intensity pulses between the last two stages") + @DisplayName("Full intensity pulses across the top of the scale") void fullIntensityPulses() { TunnelVisionStage stage = new TunnelVisionStage(); int lowest = TunnelVisionStage.MAX_STAGE; @@ -37,7 +37,7 @@ void fullIntensityPulses() { highest = Math.max(highest, current); } assertEquals(TunnelVisionStage.MAX_STAGE, highest, "the pulse never reaches the peak"); - assertEquals(TunnelVisionStage.MAX_STAGE - 1, lowest, "the pulse does not open up again"); + assertEquals(TunnelVisionStage.MAX_STAGE - 2, lowest, "the pulse does not open up again"); } @Test @@ -55,16 +55,17 @@ void lowIntensityIsSteady() { void hysteresisHoldsTheStage() { TunnelVisionStage stage = new TunnelVisionStage(); int settled = highestOver(stage, 0.5D); - assertEquals(4, settled, "half intensity should settle on the middle stage"); - assertEquals(settled, highestOver(stage, 0.55D), "the stage moved on a small fluctuation"); + assertEquals(TunnelVisionStage.MAX_STAGE / 2, settled, "half intensity should settle on the middle stage"); + assertEquals(settled, highestOver(stage, 0.52D), "the stage moved on a small fluctuation"); } @Test @DisplayName("A real change moves the stage") void largerChangeMovesTheStage() { TunnelVisionStage stage = new TunnelVisionStage(); - assertEquals(4, highestOver(stage, 0.5D)); - assertEquals(5, highestOver(stage, 0.6D), "the stage should follow a real change"); + assertEquals(TunnelVisionStage.MAX_STAGE / 2, highestOver(stage, 0.5D)); + assertEquals(TunnelVisionStage.MAX_STAGE / 2 + 1, highestOver(stage, 0.56D), + "the stage should follow a real change"); } @Test