diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java b/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java index 6f9caad3..45e03a23 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/Messages.java @@ -14,7 +14,7 @@ /** * @author theEvilReaper - * @version 1.2.0 + * @version 1.3.0 * @since 1.0.0 **/ public final class Messages { @@ -30,6 +30,8 @@ public final class Messages { public static final Component SLENDER_WIN_MESSAGE; public static final Component SURVIVOR_WIN_MESSAGE; public static final Component LIGHT_WENT_OUT; + public static final Component ONLY_PLAYERS_HAVE_A_VIEW; + public static final Component ONLY_PLAYERS_CAN_PREVIEW; private static final Component PAGE_FOUND_PART; private static final Component LEAVE_PART; private static final Component JOIN_PART; @@ -61,6 +63,8 @@ public final class Messages { LEAVE_PART = Component.text("left the game!", NamedTextColor.GRAY); JOIN_PART = Component.text("joined the game!", NamedTextColor.GRAY); LIGHT_WENT_OUT = withMiniPrefix("Your light went out!"); + ONLY_PLAYERS_HAVE_A_VIEW = withMiniPrefix("Only players have a view to lose."); + ONLY_PLAYERS_CAN_PREVIEW = withMiniPrefix("Only players can preview the tunnel vision."); SURVIVOR_JOIN_PART_UPPER = withMiniPrefix("You are a Survivor! Find various Pages").append(Component.space()); @@ -156,6 +160,22 @@ public static Component getSlenderWinMessage(@Nullable Player player) { .append(Component.newline()); } + /** + * Returns a {@link Component} explaining how the tunnel vision preview command is used. + *

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

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

+ * A player wears a single head slot, so there is a single overlay to own it. Each effect + * contributes its own {@code OverlayLayer} to it; a second instance would mean two of them + * writing the same slot without knowing about each other, and whichever wrote last would win. + *

+ */ + private final ScreenOverlay screenOverlay; + private final SlenderGazeService slenderGazeService; + private final TunnelVisionRenderer tunnelVisionRenderer; + private final TunnelVisionService tunnelVisionService; public Cygnus() { Path path = ServiceBootstrap.resolveWorkingDirectory(); @@ -126,6 +145,11 @@ public Cygnus() { .orElseThrow(() -> new IllegalStateException("Spectator team not found")); this.spectatorService = new SpectatorService(spectatorTeam, survivorTeam); this.resourcePackService = ResourcePackService.create(); + this.screenOverlay = new EquipmentScreenOverlay(); + this.slenderGazeService = new SlenderGazeService( + this.screenOverlay, () -> TeamHelper.slenderOf(this.teamService)); + this.tunnelVisionRenderer = new OverlayTunnelVisionRenderer(this.screenOverlay); + this.tunnelVisionService = new TunnelVisionService(this.tunnelVisionRenderer, player -> StaminaHelper.remainingShare(this.staminaService, player)); this.initPhases(); this.initCommands(); this.initListener(); @@ -191,6 +215,24 @@ private void registerGameListener() { MinecraftServer.getPacketListenerManager().setPlayListener(ClientEntityActionPacket.class, CygnusEntityActionListener::listener); spectatorService.registerListener(handler); + this.registerOverlayListeners(handler); + } + + /** + * Registers the full-screen effects, unless the overlays are switched off. + *

+ * The effects are drawn as {@code camera_overlay} textures and are gated by + * {@link OverlayProperties} alone - deliberately not by whether this server hands out a resource + * pack. One gate for all of them, so that {@code cygnus.overlays} means what its name says and + * an effect cannot end up outside it by being wired in somewhere else. + *

+ * + * @param handler the node the effects register their listeners on + */ + private void registerOverlayListeners(GlobalEventHandler handler) { + if (!OverlayProperties.enabled()) return; + this.slenderGazeService.registerListener(handler, () -> TeamHelper.survivorsOf(this.teamService)); + this.tunnelVisionService.registerListener(handler, () -> TeamHelper.survivorsOf(this.teamService)); } private void initPhases() { diff --git a/game/src/main/java/net/onelitefeather/cygnus/ambient/AmbientProvider.java b/game/src/main/java/net/onelitefeather/cygnus/ambient/AmbientProvider.java index c760c0ff..0e940e2e 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/ambient/AmbientProvider.java +++ b/game/src/main/java/net/onelitefeather/cygnus/ambient/AmbientProvider.java @@ -2,15 +2,13 @@ import net.theevilreaper.xerus.api.team.Team; import net.kyori.adventure.sound.Sound; -import net.minestom.server.MinecraftServer; import net.minestom.server.entity.Player; import net.minestom.server.potion.Potion; import net.minestom.server.potion.PotionEffect; import net.minestom.server.potion.TimedPotion; import net.minestom.server.sound.SoundEvent; -import net.minestom.server.timer.Task; import net.onelitefeather.cygnus.common.Messages; -import org.jetbrains.annotations.Nullable; +import net.onelitefeather.cygnus.utils.RepeatingTask; import java.time.temporal.ChronoUnit; import java.util.List; @@ -34,7 +32,7 @@ * } * * @author theEvilReaper - * @version 2.0.0 + * @version 2.1.0 * @since 1.0.0 */ public final class AmbientProvider { @@ -50,7 +48,7 @@ public final class AmbientProvider { }; private final Team team; - private @Nullable Task task; + private final RepeatingTask task = new RepeatingTask(this::tick); private int currentTicks; /** @@ -62,23 +60,17 @@ public AmbientProvider(Team team) { } /** - * Starts the ambient task. + * Starts the ambient task. Does nothing if it is already running. */ public void startTask() { - if (task != null) return; - task = MinecraftServer.getSchedulerManager() - .buildTask(this::tick) - .repeat(1, ChronoUnit.SECONDS) - .schedule(); + this.task.start(1, ChronoUnit.SECONDS); } /** - * Stops the ambient task. + * Stops the ambient task. Does nothing if it is not running. */ public void stopTask() { - if (task == null) return; - task.cancel(); - task = null; + this.task.stop(); } /** diff --git a/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java new file mode 100644 index 00000000..381544e3 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGaze.java @@ -0,0 +1,68 @@ +package net.onelitefeather.cygnus.gaze; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; + +/** + * Works out how badly the sight of the slender tears a survivor's view apart. + *

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

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

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

+ *

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

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

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

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

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

+ *

+ * Showing and clearing are one method rather than two because {@link SlenderGaze#NONE} already + * says "nothing to draw" everywhere else in this class — {@link #tick()} reads it off + * {@link SlenderGaze#levelOf(net.minestom.server.coordinate.Pos, net.minestom.server.coordinate.Pos)} + * on every pass — so a separate {@code hide} would be a second spelling of a level the type + * already has. + *

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

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

+ *

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

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class EquipmentScreenOverlay implements ScreenOverlay { + + /** + * What the overlay rides on. The item itself is never seen — {@link #EMPTY_ASSET} makes sure of + * that — so the material only has to exist. + */ + private static final Material CARRIER = Material.PAPER; + + /** + * An equipment model with no layers, from the resource pack. Without an asset id Minecraft + * falls back to drawing the item itself on the player's head. + */ + private static final String EMPTY_ASSET = "cygnus:empty"; + + /** Vanilla's silent sound; the default equip sound would click on every stage change. */ + private static final SoundEvent SILENT = SoundEvent.of(Key.key("minecraft:intentionally_empty"), null); + + private final PlayerState> layers = new PlayerState<>(); + private final PlayerState shown = new PlayerState<>(); + + /** + * {@inheritDoc} + */ + @Override + public void set(Player player, OverlayLayer layer, @Nullable Key texture) { + Map current = this.layers + .computeIfAbsent(player, () -> new EnumMap<>(OverlayLayer.class)); + + if (texture == null) { + current.remove(layer); + } else { + current.put(layer, texture); + } + + this.apply(player, current); + } + + /** + * {@inheritDoc} + */ + @Override + public void clear(Player player) { + this.layers.remove(player); + this.shown.remove(player); + player.setHelmet(ItemStack.AIR); + } + + /** + * Works out which layer is on top and puts it on the player's head. + * + * @param player the player to draw for + * @param current the layers currently set for them + */ + private void apply(Player player, Map current) { + Key topmost = this.topmost(current); + + if (topmost == null) { + if (this.shown.remove(player) == null) return; + player.setHelmet(ItemStack.AIR); + return; + } + + // The overlay is refreshed many times a second; re-sending an unchanged item would put an + // equipment update on the wire for every viewer each time. + if (topmost.equals(this.shown.get(player))) return; + + this.shown.put(player, topmost); + player.setHelmet(carrierFor(topmost)); + } + + /** + * Picks the layer that is drawn on top of the others. + * + * @param current the layers currently set + * @return the texture to show, or {@code null} if nothing is set + */ + private @Nullable Key topmost(Map current) { + Key topmost = null; + // Declaration order of OverlayLayer is drawing order, so the last hit wins. + for (OverlayLayer layer : OverlayLayer.values()) { + Key texture = current.get(layer); + if (texture != null) topmost = texture; + } + return topmost; + } + + /** + * Builds the item that carries a given overlay texture. + * + * @param texture the texture to show + * @return the item to put in the head slot + */ + private static ItemStack carrierFor(Key texture) { + Equippable equippable = new Equippable( + EquipmentSlot.HELMET, + SILENT, + EMPTY_ASSET, + texture.asString(), + null, + false, + false, + false, + false, + false, + SILENT + ); + return ItemStack.builder(CARRIER).set(DataComponents.EQUIPPABLE, equippable).build(); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayLayer.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayLayer.java new file mode 100644 index 00000000..656ed319 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayLayer.java @@ -0,0 +1,21 @@ +package net.onelitefeather.cygnus.overlay; + +/** + * The full-screen layers a player can have on their HUD at once, in drawing order — later + * constants are drawn on top of earlier ones. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public enum OverlayLayer { + + /** The narrowing view, driven by how much stamina a survivor has left. */ + TUNNEL_VISION, + + /** The tearing that comes over a survivor while the slender is in their view. */ + GLITCH, + + /** The splatter that flashes up when the player is hit; sits closest to the eye. */ + BLOOD +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayProperties.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayProperties.java new file mode 100644 index 00000000..8ca15d86 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayProperties.java @@ -0,0 +1,36 @@ +package net.onelitefeather.cygnus.overlay; + +/** + * Decides whether the full-screen overlays — the tunnel vision, the slender's glitch and the + * blood splatter — run. + *

+ * They used to be tied to the ResourcePack feature, on the grounds that without the pack their + * textures are missing and a player would get a fullscreen checkerboard. That was too blunt: a + * server can be run without handing out a pack while the people testing it have the pack enabled + * locally, and in that setup the effects silently never started. + *

+ * + * @author TheMeinerLP + * @version 1.1.0 + * @since 2.7.0 + */ +public final class OverlayProperties { + + static final String ENABLED_PROPERTY = "cygnus.overlays"; + + private OverlayProperties() { + } + + /** + * Tells whether the overlays should run. + *

+ * On unless the property says {@code false}. Anything unreadable leaves them on: the effects + * are part of the game, and a typo in a start script should not quietly remove them. + *

+ * + * @return whether to register the overlay services + */ + public static boolean enabled() { + return !"false".equalsIgnoreCase(System.getProperty(ENABLED_PROPERTY, "true").trim()); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeys.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeys.java new file mode 100644 index 00000000..fe97ff2e --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeys.java @@ -0,0 +1,112 @@ +package net.onelitefeather.cygnus.overlay; + +import net.kyori.adventure.key.Key; + +import java.util.function.IntFunction; + +/** + * Builds the {@code cygnus:} {@link Key}s a full-screen overlay draws from the resource pack. + *

+ * {@code OverlayTunnelVisionRenderer}, {@code SlenderGazeService} and {@code BloodSplatterService} + * each hand-rolled their own {@code buildTextures()}, one per axis count: a flat table indexed by + * stage, a two-dimensional one indexed by level and frame, and a three-dimensional one (flattened + * into a single array) indexed by direction, variant and frame. All three follow the same shape once + * written out: the texture path, followed by every axis's label joined with {@code _}. This type is + * that shape, extracted once for every table rank the three renderers need. + *

+ *

+ * An axis's labels come from an {@link IntFunction}, not a plain 1-based count, because the blood + * splatter's outermost axis is a {@code BloodDirection} name such as {@code left} rather than a + * number. {@link #ONE_BASED} covers the common case of a numbered axis. + *

+ * + *

Usage:

+ *
{@code
+ * Key[] stages = OverlayTextureKeys.flat("gui/tunnel_vision/stage_", 16, OverlayTextureKeys.ONE_BASED);
+ * Key[][] glitch = OverlayTextureKeys.table(
+ *         "gui/glitch/level_", LEVELS, FRAMES, OverlayTextureKeys.ONE_BASED, OverlayTextureKeys.ONE_BASED);
+ * Key[][][] blood = OverlayTextureKeys.cube(
+ *         "gui/blood/", DIRECTIONS, VARIANTS, FRAMES,
+ *         direction -> BloodDirection.values()[direction].name().toLowerCase(Locale.ROOT),
+ *         OverlayTextureKeys.ONE_BASED, OverlayTextureKeys.ONE_BASED);
+ * }
+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class OverlayTextureKeys { + + /** The namespace every overlay texture lives under. */ + public static final String NAMESPACE = "cygnus"; + + /** Labels an axis {@code 1}, {@code 2}, {@code 3}, ... from its zero-based index. */ + public static final IntFunction ONE_BASED = index -> Integer.toString(index + 1); + + private OverlayTextureKeys() { + // Prevent instantiation of utility class + } + + /** + * Builds a flat table of texture keys, one per index. + * + * @param path the texture path every key is built from, without a trailing separator + * @param size how many keys to build + * @param label labels each index + * @return the keys, indexed the same way + */ + public static Key[] flat(String path, int size, IntFunction label) { + Key[] keys = new Key[size]; + for (int i = 0; i < size; i++) { + keys[i] = Key.key(NAMESPACE, path + label.apply(i)); + } + return keys; + } + + /** + * Builds a two-dimensional table of texture keys, one per row and column. + * + * @param path the texture path every key is built from, without a trailing separator + * @param rows how many rows to build + * @param columns how many columns to build + * @param rowLabel labels each row + * @param columnLabel labels each column + * @return the keys, indexed {@code [row][column]} + */ + public static Key[][] table(String path, int rows, int columns, IntFunction rowLabel, + IntFunction columnLabel) { + Key[][] keys = new Key[rows][columns]; + for (int row = 0; row < rows; row++) { + for (int column = 0; column < columns; column++) { + keys[row][column] = Key.key(NAMESPACE, path + rowLabel.apply(row) + "_" + columnLabel.apply(column)); + } + } + return keys; + } + + /** + * Builds a three-dimensional table of texture keys, one per plane, row and column. + * + * @param path the texture path every key is built from, without a trailing separator + * @param planes how many planes to build + * @param rows how many rows to build + * @param columns how many columns to build + * @param planeLabel labels each plane + * @param rowLabel labels each row + * @param columnLabel labels each column + * @return the keys, indexed {@code [plane][row][column]} + */ + public static Key[][][] cube(String path, int planes, int rows, int columns, IntFunction planeLabel, + IntFunction rowLabel, IntFunction columnLabel) { + Key[][][] keys = new Key[planes][rows][columns]; + for (int plane = 0; plane < planes; plane++) { + for (int row = 0; row < rows; row++) { + for (int column = 0; column < columns; column++) { + keys[plane][row][column] = Key.key(NAMESPACE, + path + planeLabel.apply(plane) + "_" + rowLabel.apply(row) + "_" + columnLabel.apply(column)); + } + } + } + return keys; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/ScreenOverlay.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/ScreenOverlay.java new file mode 100644 index 00000000..de9ca55b --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/ScreenOverlay.java @@ -0,0 +1,35 @@ +package net.onelitefeather.cygnus.overlay; + +import net.kyori.adventure.key.Key; +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.Nullable; + +/** + * Owns the full-screen overlay of a player and decides what ends up on it. + *

+ * The effects hand over a texture for their layer rather than drawing themselves, because a player + * only has one screen to give: whichever effect drew last would otherwise wipe the other. + *

+ * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.7.0 + */ +public interface ScreenOverlay { + + /** + * Sets or removes what a layer contributes to the player's screen. + * + * @param player the player to draw for + * @param layer the layer to change + * @param texture the overlay texture to show, or {@code null} to drop the layer + */ + void set(Player player, OverlayLayer layer, @Nullable Key texture); + + /** + * Drops every layer and clears the player's screen. + * + * @param player the player to clear + */ + void clear(Player player); +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/overlay/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/overlay/package-info.java new file mode 100644 index 00000000..8ec288a2 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/overlay/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.onelitefeather.cygnus.overlay; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/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/team/TeamHelper.java b/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java index 79882d4d..f2918fc3 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java +++ b/game/src/main/java/net/onelitefeather/cygnus/team/TeamHelper.java @@ -12,6 +12,7 @@ import net.minestom.server.entity.Player; import net.minestom.server.instance.Instance; import net.minestom.server.utils.validate.Check; +import org.jetbrains.annotations.Nullable; import net.onelitefeather.cygnus.common.Tags; import net.onelitefeather.cygnus.common.config.GameConfig; import net.onelitefeather.cygnus.common.map.GameMap; @@ -23,7 +24,7 @@ * This class provides utility methods for the team handling in the game. * * @author theEvilReaper - * @version 1.0.0 + * @version 1.1.0 * @since 1.0.0 */ public final class TeamHelper { @@ -166,6 +167,40 @@ public static void updateTabList(TeamService teamService) { }); } + /** + * Reads the players currently on the survivor team. + *

+ * Every full-screen effect draws for the survivors and needs them once per tick, so each one + * would otherwise carry its own copy of this lookup. Returns an empty set rather than throwing + * while the teams are not set up yet: the effects are ticked from a scheduler, which runs + * before a round starts and after it ends. + *

+ * + * @param teamService the service to read the team from + * @return the survivors, or an empty set while there is no survivor team + */ + public static Set survivorsOf(TeamService teamService) { + return teamService.getTeam(GameConfig.SURVIVOR_KEY) + .map(team -> Set.copyOf(team.getPlayers())) + .orElseGet(Set::of); + } + + /** + * Reads the player currently playing the slender. + *

+ * The slender team has a capacity of one, which {@link #prepareTeamAllocation} enforces, so the + * first player on it is the only one. + *

+ * + * @param teamService the service to read the team from + * @return the slender, or {@code null} while the role is unassigned + */ + public static @Nullable Player slenderOf(TeamService teamService) { + return teamService.getTeam(GameConfig.SLENDER_KEY) + .flatMap(team -> team.getPlayers().stream().findFirst()) + .orElse(null); + } + /** * Check if the player is in the slender team * diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRenderer.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRenderer.java new file mode 100644 index 00000000..7916d1af --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRenderer.java @@ -0,0 +1,61 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.kyori.adventure.key.Key; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.OverlayTextureKeys; +import net.onelitefeather.cygnus.overlay.ScreenOverlay; + +/** + * Contributes the tunnel vision to the shared screen overlay. + *

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

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

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

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

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

+ *

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

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

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

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

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

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

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

+ *

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

+ *

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

+ *

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

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

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

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

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

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

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

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

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

+ */ + public static final int MAX_STAGE = 32; + + /** Interval the service updates at, which is also the sampling rate of the heartbeat. */ + public static final int TICK_MILLIS = 100; + + /** Distance in stages the intensity has to travel before the base stage follows. */ + private static final double HYSTERESIS = 0.6D; + + /** + * Depth of the heartbeat in stages at full intensity, as a fraction of the whole scale so it + * stays equally visible whatever {@link #MAX_STAGE} is. + */ + private static final double PULSE_DEPTH = MAX_STAGE / 16.0D; + + /** Heartbeat frequency in hertz while the survivor is barely threatened. */ + private static final double BASE_FREQUENCY = 1.0D; + + /** Additional heartbeat frequency in hertz at full intensity. */ + private static final double FREQUENCY_GAIN = 1.5D; + + private static final double TICK_SECONDS = TICK_MILLIS / 1000.0D; + + /** Negative until the first update, so the first intensity is adopted without hysteresis. */ + private int baseStage = -1; + + private double elapsedSeconds; + + /** + * Advances the heartbeat by one tick and reports the stage to render. + * + * @param intensity the intensity from {@link TunnelVisionIntensity} + * @return the stage to render, between {@code 0} and {@link #MAX_STAGE} + */ + public int update(double intensity) { + double exactStage = intensity * MAX_STAGE; + if (this.baseStage < 0 || Math.abs(exactStage - this.baseStage) > HYSTERESIS) { + this.baseStage = (int) Math.round(exactStage); + } + + this.elapsedSeconds += TICK_SECONDS; + double frequency = BASE_FREQUENCY + FREQUENCY_GAIN * intensity; + double depth = PULSE_DEPTH * intensity; + // The heartbeat only ever opens the view up, never beyond the base stage: at full + // intensity the base stage is the maximum, and a symmetric pulse would be clipped away + // exactly where it matters most. + double pulse = depth * (Math.sin(2.0D * Math.PI * frequency * this.elapsedSeconds) - 1.0D); + + int rendered = (int) Math.round(this.baseStage + pulse); + return Math.clamp(rendered, 0, MAX_STAGE); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java new file mode 100644 index 00000000..f6ea8e77 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/tunnelvision/package-info.java @@ -0,0 +1,4 @@ +@NotNullByDefault +package net.onelitefeather.cygnus.tunnelvision; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/game/src/main/java/net/onelitefeather/cygnus/utils/PlayerState.java b/game/src/main/java/net/onelitefeather/cygnus/utils/PlayerState.java new file mode 100644 index 00000000..91ec357b --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/utils/PlayerState.java @@ -0,0 +1,105 @@ +package net.onelitefeather.cygnus.utils; + +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.Nullable; + +import java.util.Collection; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * Keeps one value per player, keyed by {@link Player#getUuid()}. + *

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

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

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

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

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

+ *

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

+ * + *

Usage:

+ *
{@code
+ * RepeatingTask task = new RepeatingTask(this::tick);
+ * task.start(1, ChronoUnit.SECONDS);
+ * // ...
+ * task.stop();
+ * }
+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class RepeatingTask { + + private final Runnable action; + private @Nullable Task task; + + /** + * Creates a task that, once started, runs the given action on every repetition. + * + * @param action the action to run + */ + public RepeatingTask(Runnable action) { + this.action = action; + } + + /** + * Starts the task with the given period. Does nothing if the task is already running. + * + * @param period the amount of {@code unit}s between two runs + * @param unit the unit {@code period} is measured in + */ + public void start(long period, TemporalUnit unit) { + if (this.task != null) return; + this.task = MinecraftServer.getSchedulerManager() + .buildTask(this.action) + .repeat(period, unit) + .schedule(); + } + + /** + * Stops the task. Does nothing if the task is not running. + */ + public void stop() { + if (this.task == null) return; + this.task.cancel(); + this.task = null; + } + + /** + * @return {@code true} if the task is currently running + */ + public boolean isRunning() { + return this.task != null; + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java b/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java index 3a7b0668..8f1e6f83 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java +++ b/game/src/main/java/net/onelitefeather/cygnus/utils/StaminaHelper.java @@ -4,6 +4,8 @@ import net.onelitefeather.cygnus.team.TeamHelper; import net.theevilreaper.xerus.api.team.Team; import net.theevilreaper.xerus.api.team.TeamService; +import net.minestom.server.entity.Player; +import net.onelitefeather.cygnus.stamina.FoodBar; import net.onelitefeather.cygnus.stamina.StaminaService; @@ -11,7 +13,7 @@ * The {@link StaminaHelper} is a utility class which contains some helper methods for the stamina system. * * @author theEvilReaper - * @version 1.2.0 + * @version 1.3.0 * @since 1.0.0 */ public final class StaminaHelper { @@ -33,6 +35,24 @@ public static void initStaminaObjects(TeamService teamService, StaminaService st staminaService.createStaminaBars(allocation.survivors()); } + /** + * Reads how much of a survivor's stamina is left, as a share between zero and one. + *

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

+ * + * @param staminaService the service holding the bars + * @param player the player to read + * @return the remaining share in {@code [0, 1]}, or {@code 1} while the player has no bar + */ + public static double remainingShare(StaminaService staminaService, Player player) { + FoodBar bar = staminaService.getFoodBar(player); + return bar == null ? 1.0D : bar.remainingShare(); + } + private StaminaHelper() { throw new UnsupportedOperationException(); } diff --git a/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeServiceTest.java new file mode 100644 index 00000000..67d072dd --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeServiceTest.java @@ -0,0 +1,214 @@ +package net.onelitefeather.cygnus.gaze; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.event.player.PlayerDeathEvent; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.event.GameStartEvent; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.RecordingScreenOverlay; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Verifies the tearing a survivor gets while the slender stands in their view. + * + * @author TheMeinerLP + * @version 2.0.0 + * @since 2.7.0 + */ +class SlenderGazeServiceTest extends CygnusPlayerTestBase { + + @Test + @DisplayName("Seeing the slender tears the survivor's view") + void seeingHimTearsTheView(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Instance instance = env.createFlatInstance(); + Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, 5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(survivor); + + service.tick(); + + assertNotNull(overlay.of(survivor, OverlayLayer.GLITCH), "he is right in front of them"); + } + + @Test + @DisplayName("With him behind them there is nothing to see") + void behindThemNothingHappens(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Instance instance = env.createFlatInstance(); + Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, -5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(survivor); + + service.tick(); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH), "the effect is about seeing him"); + } + + @Test + @DisplayName("Looking away takes it off again") + void lookingAwayClearsIt(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Instance instance = env.createFlatInstance(); + Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, 5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(survivor); + service.tick(); + + survivor.teleport(new Pos(0, 40, 0, 180, 0)); + service.tick(); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + @Test + @DisplayName("The tearing runs on while he stays in view") + void tearingKeepsMoving(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Instance instance = env.createFlatInstance(); + Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, 5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(survivor); + + service.tick(); + Key first = overlay.of(survivor, OverlayLayer.GLITCH); + service.tick(); + + assertNotEquals(first, overlay.of(survivor, OverlayLayer.GLITCH), + "a still picture is not a glitch"); + } + + @Test + @DisplayName("Without a slender nothing happens at all") + void withoutASlenderNothingHappens(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Player survivor = connect(env, env.createFlatInstance(), new Pos(0, 40, 0, 0, 0)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> null); + service.track(survivor); + + service.tick(); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + @Test + @DisplayName("A removed survivor gets their view back") + void removedSurvivorIsCleared(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Instance instance = env.createFlatInstance(); + Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, 5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(survivor); + service.tick(); + + service.remove(survivor); + service.tick(); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + @Test + @DisplayName("Clearing everyone gives every survivor their screen back") + void cleanUpWipesEveryone(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Instance instance = env.createFlatInstance(); + Player first = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player second = connect(env, instance, new Pos(4, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, 5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.track(first); + service.track(second); + service.tick(); + + service.cleanUp(); + + assertNull(overlay.of(first, OverlayLayer.GLITCH)); + assertNull(overlay.of(second, OverlayLayer.GLITCH)); + + service.tick(); + assertNull(overlay.of(first, OverlayLayer.GLITCH), "cleanUp must stop the drawing as well"); + } + + @Test + @DisplayName("The start of a round takes the survivors on board") + void gameStartTracksSurvivors(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Instance instance = env.createFlatInstance(); + Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, 5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + + EventDispatcher.call(new GameStartEvent()); + service.tick(); + + assertNotNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + @Test + @DisplayName("A dying survivor gets their screen back") + void deathRemovesTheSurvivor(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Instance instance = env.createFlatInstance(); + Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, 5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.track(survivor); + service.tick(); + + EventDispatcher.call(new PlayerDeathEvent(survivor, Component.empty(), Component.empty())); + service.tick(); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + @Test + @DisplayName("The end of a round clears everyone") + void gameFinishClearsEveryone(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Instance instance = env.createFlatInstance(); + Player survivor = connect(env, instance, new Pos(0, 40, 0, 0, 0)); + Player slender = connect(env, instance, new Pos(0, 40, 5)); + SlenderGazeService service = new SlenderGazeService(overlay, () -> slender); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.track(survivor); + service.tick(); + + EventDispatcher.call(new GameFinishEvent(GameFinishEvent.Reason.TIME_OVER)); + + assertNull(overlay.of(survivor, OverlayLayer.GLITCH)); + } + + /** + * Connects a player at the given position. + * + * @param env the test environment + * @param instance the instance to connect into + * @param position where to place them + * @return the connected player + */ + private Player connect(Env env, Instance instance, Pos position) { + return env.createConnection().connect(instance, position); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeTest.java b/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeTest.java new file mode 100644 index 00000000..dadbbdb3 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeTest.java @@ -0,0 +1,72 @@ +package net.onelitefeather.cygnus.gaze; + +import net.minestom.server.coordinate.Pos; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies when the sight of the slender starts to tear a survivor's view apart. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class SlenderGazeTest { + + /** A survivor in the origin looking towards positive Z, which is a yaw of zero. */ + private static final Pos SURVIVOR = new Pos(0, 40, 0, 0, 0); + + @Test + @DisplayName("A slender straight ahead and close tears the view apart") + void closeAndAheadIsStrongest() { + assertEquals(SlenderGaze.LEVELS - 1, SlenderGaze.levelOf(SURVIVOR, new Pos(0, 40, 4))); + } + + @Test + @DisplayName("A slender ahead but far off barely registers") + void farAheadIsWeak() { + int level = SlenderGaze.levelOf(SURVIVOR, new Pos(0, 40, 28)); + assertTrue(level >= 0 && level < SlenderGaze.LEVELS - 1, "expected a weak level, got " + level); + } + + @Test + @DisplayName("Out of range there is nothing, however clear the line") + void beyondRangeIsNothing() { + assertEquals(SlenderGaze.NONE, SlenderGaze.levelOf(SURVIVOR, new Pos(0, 40, 80))); + } + + @Test + @DisplayName("Standing behind a survivor does nothing, however close") + void behindIsNothing() { + assertEquals(SlenderGaze.NONE, SlenderGaze.levelOf(SURVIVOR, new Pos(0, 40, -4)), + "the effect is about seeing him, not about him being there"); + } + + @Test + @DisplayName("Just outside the corner of the eye does nothing either") + void besideIsNothing() { + assertEquals(SlenderGaze.NONE, SlenderGaze.levelOf(SURVIVOR, new Pos(6, 40, 0))); + } + + @Test + @DisplayName("Turning towards him brings it on") + void turningTowardsHimBringsItOn() { + Pos turned = new Pos(0, 40, 0, -90, 0); + assertTrue(SlenderGaze.levelOf(turned, new Pos(6, 40, 0)) > SlenderGaze.NONE, + "he is in front of the survivor now"); + } + + @Test + @DisplayName("Closing in never weakens the effect") + void levelIsMonotonic() { + int previous = SlenderGaze.NONE; + for (int distance = 40; distance >= 1; distance--) { + int current = SlenderGaze.levelOf(SURVIVOR, new Pos(0, 40, distance)); + assertTrue(current >= previous, "the tearing eased off at distance " + distance); + previous = current; + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlayTest.java b/game/src/test/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlayTest.java new file mode 100644 index 00000000..d9366eaf --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/overlay/EquipmentScreenOverlayTest.java @@ -0,0 +1,140 @@ +package net.onelitefeather.cygnus.overlay; + +import net.kyori.adventure.key.Key; +import net.minestom.server.component.DataComponents; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.component.Equippable; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that the overlay reaches the client as a camera overlay on the player's head, which is + * the only way to have it scale with the screen rather than with a font size. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class EquipmentScreenOverlayTest extends CygnusPlayerTestBase { + + private static final Key TUNNEL = Key.key("cygnus", "gui/tunnel_vision/stage_7"); + private static final Key BLOOD = Key.key("cygnus", "gui/blood/left_1_1"); + + private final EquipmentScreenOverlay overlay = new EquipmentScreenOverlay(); + + @Test + @DisplayName("A layer becomes a camera overlay on the player's head") + void layerBecomesCameraOverlay(Env env) { + Player player = spawn(env); + + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + + assertEquals(TUNNEL.asString(), equippable(player).cameraOverlay()); + } + + @Test + @DisplayName("The blood takes the screen while it is up") + void bloodWinsOverTheTunnelVision(Env env) { + Player player = spawn(env); + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + + this.overlay.set(player, OverlayLayer.BLOOD, BLOOD); + + assertEquals(BLOOD.asString(), equippable(player).cameraOverlay(), + "only one camera overlay exists, and a hit is what matters most"); + } + + @Test + @DisplayName("Once the blood is gone the tunnel vision comes back") + void tunnelVisionReturnsAfterTheBlood(Env env) { + Player player = spawn(env); + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + this.overlay.set(player, OverlayLayer.BLOOD, BLOOD); + + this.overlay.set(player, OverlayLayer.BLOOD, null); + + assertEquals(TUNNEL.asString(), equippable(player).cameraOverlay()); + } + + @Test + @DisplayName("The last layer leaving empties the head slot") + void lastLayerEmptiesTheSlot(Env env) { + Player player = spawn(env); + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, null); + + assertTrue(player.getHelmet().isAir(), "an item left behind would keep the overlay up"); + } + + @Test + @DisplayName("Clearing empties the head slot and forgets the layers") + void clearingEmptiesTheSlot(Env env) { + Player player = spawn(env); + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + this.overlay.set(player, OverlayLayer.BLOOD, BLOOD); + + this.overlay.clear(player); + + assertTrue(player.getHelmet().isAir()); + } + + @Test + @DisplayName("The carrier item cannot be taken off or seen") + void carrierItemStaysPutAndInvisible(Env env) { + Player player = spawn(env); + + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + + Equippable equippable = equippable(player); + assertFalse(equippable.swappable(), "right-clicking must not strip the overlay"); + assertFalse(equippable.dispensable(), "a dispenser must not hand out overlays"); + assertFalse(equippable.damageOnHurt(), "the carrier is not armour"); + assertNotNull(equippable.assetId(), "without an asset the item is drawn on the player's head"); + } + + @Test + @DisplayName("Setting the same layer twice does not churn the slot") + void repeatedSetKeepsTheSameItem(Env env) { + Player player = spawn(env); + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + ItemStack first = player.getHelmet(); + + this.overlay.set(player, OverlayLayer.TUNNEL_VISION, TUNNEL); + + assertEquals(first, player.getHelmet(), "an unchanged overlay must not be re-sent"); + } + + /** + * Connects a player into a fresh instance. + * + * @param env the test environment + * @return the connected player + */ + private Player spawn(Env env) { + Instance instance = env.createFlatInstance(); + return env.createConnection().connect(instance, new Pos(0, 40, 0)); + } + + /** + * Reads the equippable component off the player's head slot. + * + * @param player the player to read + * @return the component + */ + private Equippable equippable(Player player) { + Equippable equippable = player.getHelmet().get(DataComponents.EQUIPPABLE); + assertNotNull(equippable, "nothing is carrying an overlay"); + return equippable; + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayPropertiesTest.java b/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayPropertiesTest.java new file mode 100644 index 00000000..23210808 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayPropertiesTest.java @@ -0,0 +1,50 @@ +package net.onelitefeather.cygnus.overlay; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the switch that decides whether the full-screen overlays run. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class OverlayPropertiesTest { + + @AfterEach + void clearProperty() { + System.clearProperty(OverlayProperties.ENABLED_PROPERTY); + } + + @Test + @DisplayName("The overlays run unless somebody says otherwise") + void enabledByDefault() { + assertTrue(OverlayProperties.enabled(), "the effects are part of the game, not an extra"); + } + + @Test + @DisplayName("They can be switched off") + void canBeSwitchedOff() { + System.setProperty(OverlayProperties.ENABLED_PROPERTY, "false"); + assertFalse(OverlayProperties.enabled()); + } + + @Test + @DisplayName("Switching them on explicitly works too") + void canBeSwitchedOn() { + System.setProperty(OverlayProperties.ENABLED_PROPERTY, "true"); + assertTrue(OverlayProperties.enabled()); + } + + @Test + @DisplayName("Anything unreadable leaves them on rather than silently off") + void nonsenseLeavesThemOn() { + System.setProperty(OverlayProperties.ENABLED_PROPERTY, "perhaps"); + assertTrue(OverlayProperties.enabled(), "a typo must not take the effects out of the game"); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeysTest.java b/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeysTest.java new file mode 100644 index 00000000..a05927c9 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/overlay/OverlayTextureKeysTest.java @@ -0,0 +1,54 @@ +package net.onelitefeather.cygnus.overlay; + +import net.kyori.adventure.key.Key; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies that {@link OverlayTextureKeys} reproduces the {@code cygnus:} key conventions the + * tunnel vision, glitch and blood renderers built by hand. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class OverlayTextureKeysTest { + + @Test + @DisplayName("A flat table matches the tunnel vision's stage_ convention") + void flatMatchesTunnelVisionConvention() { + Key[] keys = OverlayTextureKeys.flat("gui/tunnel_vision/stage_", 3, OverlayTextureKeys.ONE_BASED); + + assertEquals(Key.key("cygnus", "gui/tunnel_vision/stage_1"), keys[0]); + assertEquals(Key.key("cygnus", "gui/tunnel_vision/stage_2"), keys[1]); + assertEquals(Key.key("cygnus", "gui/tunnel_vision/stage_3"), keys[2]); + } + + @Test + @DisplayName("A two-dimensional table matches the glitch's level__ convention") + void tableMatchesGlitchConvention() { + Key[][] keys = OverlayTextureKeys.table( + "gui/glitch/level_", 2, 2, OverlayTextureKeys.ONE_BASED, OverlayTextureKeys.ONE_BASED); + + assertEquals(Key.key("cygnus", "gui/glitch/level_1_1"), keys[0][0]); + assertEquals(Key.key("cygnus", "gui/glitch/level_1_2"), keys[0][1]); + assertEquals(Key.key("cygnus", "gui/glitch/level_2_1"), keys[1][0]); + assertEquals(Key.key("cygnus", "gui/glitch/level_2_2"), keys[1][1]); + } + + @Test + @DisplayName("A three-dimensional table matches the blood's __ convention") + void cubeMatchesBloodConvention() { + String[] directions = {"left", "right"}; + + Key[][][] keys = OverlayTextureKeys.cube( + "gui/blood/", 2, 2, 2, + index -> directions[index], OverlayTextureKeys.ONE_BASED, OverlayTextureKeys.ONE_BASED); + + assertEquals(Key.key("cygnus", "gui/blood/left_1_1"), keys[0][0][0]); + assertEquals(Key.key("cygnus", "gui/blood/left_2_1"), keys[0][1][0]); + assertEquals(Key.key("cygnus", "gui/blood/right_1_2"), keys[1][0][1]); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/overlay/RecordingScreenOverlay.java b/game/src/test/java/net/onelitefeather/cygnus/overlay/RecordingScreenOverlay.java new file mode 100644 index 00000000..f0628bdd --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/overlay/RecordingScreenOverlay.java @@ -0,0 +1,71 @@ +package net.onelitefeather.cygnus.overlay; + +import net.kyori.adventure.key.Key; +import net.minestom.server.entity.Player; +import org.jetbrains.annotations.Nullable; + +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * A {@link ScreenOverlay} that records what was set instead of dressing a player. + *

+ * Every effect that draws onto the screen needs the same thing from a test: which texture ended up + * on which layer, without a real equipment slot in the way. Written per test class, that is the same + * {@code Map>} five times over - the very duplication the overlay + * foundation removes from the production code. It lives here rather than beside any one effect + * because no effect owns it. + *

+ * + *

Usage:

+ *
{@code
+ * RecordingScreenOverlay overlay = new RecordingScreenOverlay();
+ * service.show(player, 3);
+ * assertEquals(expected, overlay.of(player, OverlayLayer.TUNNEL_VISION));
+ * }
+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +public final class RecordingScreenOverlay implements ScreenOverlay { + + private final Map> layers = new HashMap<>(); + + @Override + public void set(Player player, OverlayLayer layer, @Nullable Key texture) { + Map current = + this.layers.computeIfAbsent(player.getUuid(), key -> new EnumMap<>(OverlayLayer.class)); + if (texture == null) { + current.remove(layer); + return; + } + current.put(layer, texture); + } + + @Override + public void clear(Player player) { + this.layers.remove(player.getUuid()); + } + + /** + * Reads back what a layer currently holds for a player. + * + * @param player the player to look up + * @param layer the layer to look up + * @return the texture currently set, or {@code null} if there is none + */ + public @Nullable Key of(Player player, OverlayLayer layer) { + return this.layers.getOrDefault(player.getUuid(), Map.of()).get(layer); + } + + /** + * @param player the player to look up + * @return {@code true} if no layer is currently set for that player + */ + public boolean isEmpty(Player player) { + return this.layers.getOrDefault(player.getUuid(), Map.of()).isEmpty(); + } +} 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/team/TeamRosterTest.java b/game/src/test/java/net/onelitefeather/cygnus/team/TeamRosterTest.java new file mode 100644 index 00000000..87121e9d --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/team/TeamRosterTest.java @@ -0,0 +1,122 @@ +package net.onelitefeather.cygnus.team; + +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.cygnus.common.config.GameConfig; +import net.onelitefeather.cygnus.common.config.GameConfigReader; +import net.theevilreaper.xerus.api.team.TeamService; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.nio.file.Paths; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies {@link TeamHelper#survivorsOf(TeamService)} and {@link TeamHelper#slenderOf(TeamService)}, + * the roster lookups the full-screen effects read once per tick. + *

+ * The empty cases carry the weight here: the effects are ticked from a scheduler that runs before a + * round has set up its teams and after it has torn them down, so both lookups have to answer for a + * game that is not running rather than throw into a scheduler task. + *

+ * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +@ExtendWith(MicrotusExtension.class) +class TeamRosterTest { + + private static GameConfig gameConfig; + + @BeforeAll + static void init() { + gameConfig = new GameConfigReader(Paths.get("")).getConfig(); + } + + /** + * @return a service holding the round's two teams, both still empty + */ + private static TeamService teamsWithoutPlayers() { + TeamService teamService = TeamService.of(); + TeamCreator teamCreator = new TeamCreator() { + }; + teamCreator.createTeams(gameConfig, teamService); + return teamService; + } + + @Test + @DisplayName("Before the teams exist the survivors are an empty set") + void survivorsWithoutTeams() { + assertTrue(TeamHelper.survivorsOf(TeamService.of()).isEmpty()); + } + + @Test + @DisplayName("Before the teams exist there is no slender") + void slenderWithoutTeams() { + assertNull(TeamHelper.slenderOf(TeamService.of())); + } + + @Test + @DisplayName("An empty survivor team yields an empty set rather than throwing") + void emptySurvivorTeam() { + assertTrue(TeamHelper.survivorsOf(teamsWithoutPlayers()).isEmpty()); + } + + @Test + @DisplayName("An unassigned slender role reads as null") + void emptySlenderTeam() { + assertNull(TeamHelper.slenderOf(teamsWithoutPlayers())); + } + + @Test + @DisplayName("The players on the survivor team are the ones reported") + void survivorsAreReported(Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createPlayer(instance); + Player second = env.createPlayer(instance); + + TeamService teamService = teamsWithoutPlayers(); + teamService.getTeam(GameConfig.SURVIVOR_KEY).orElseThrow().addPlayer(first); + teamService.getTeam(GameConfig.SURVIVOR_KEY).orElseThrow().addPlayer(second); + + assertEquals(Set.of(first, second), TeamHelper.survivorsOf(teamService)); + } + + @Test + @DisplayName("The player on the slender team is the slender") + void slenderIsReported(Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance); + + TeamService teamService = teamsWithoutPlayers(); + teamService.getTeam(GameConfig.SLENDER_KEY).orElseThrow().addPlayer(slender); + + assertSame(slender, TeamHelper.slenderOf(teamService)); + } + + @Test + @DisplayName("The reported survivors are a snapshot, not the team's own collection") + void survivorsAreASnapshot(Env env) { + Instance instance = env.createFlatInstance(); + Player survivor = env.createPlayer(instance); + + TeamService teamService = teamsWithoutPlayers(); + teamService.getTeam(GameConfig.SURVIVOR_KEY).orElseThrow().addPlayer(survivor); + + Set survivors = TeamHelper.survivorsOf(teamService); + + assertThrows(UnsupportedOperationException.class, () -> survivors.add(survivor), + "an effect iterating the roster mid-tick must not be able to change the team through it"); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java new file mode 100644 index 00000000..c3c41730 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/OverlayTunnelVisionRendererTest.java @@ -0,0 +1,97 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.kyori.adventure.key.Key; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.overlay.OverlayLayer; +import net.onelitefeather.cygnus.overlay.RecordingScreenOverlay; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * Verifies which texture the tunnel vision contributes to the shared screen overlay. + * + * @author TheMeinerLP + * @version 2.1.0 + * @since 2.7.0 + */ +class OverlayTunnelVisionRendererTest extends CygnusPlayerTestBase { + + private static final Key BLOOD_TEXTURE = Key.key("cygnus", "gui/blood/stage_1"); + + @Test + @DisplayName("A stage is contributed as its overlay texture") + void stageIsContributedAsTexture(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Player player = spawn(env); + + new OverlayTunnelVisionRenderer(overlay).render(player, 3); + + assertEquals( + Key.key("cygnus", OverlayTunnelVisionRenderer.TEXTURE_PATH + "3"), + overlay.of(player, OverlayLayer.TUNNEL_VISION), + "the texture must match the stage" + ); + } + + @Test + @DisplayName("Clearing drops only the tunnel vision layer") + void clearingDropsOnlyItsOwnLayer(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Player player = spawn(env); + overlay.set(player, OverlayLayer.BLOOD, BLOOD_TEXTURE); + OverlayTunnelVisionRenderer renderer = new OverlayTunnelVisionRenderer(overlay); + renderer.render(player, 4); + + renderer.clear(player); + + assertNull(overlay.of(player, OverlayLayer.TUNNEL_VISION), "the layer must be gone"); + assertEquals( + BLOOD_TEXTURE, + overlay.of(player, OverlayLayer.BLOOD), + "wiping the screen would take the blood splatter with it" + ); + } + + @Test + @DisplayName("Stage zero drops the layer instead of drawing an empty texture") + void zeroStageDropsTheLayer(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Player player = spawn(env); + + new OverlayTunnelVisionRenderer(overlay).render(player, 0); + + assertNull(overlay.of(player, OverlayLayer.TUNNEL_VISION)); + } + + @Test + @DisplayName("The tightest stage has a texture of its own") + void tightestStageHasItsOwnTexture(Env env) { + RecordingScreenOverlay overlay = new RecordingScreenOverlay(); + Player player = spawn(env); + + new OverlayTunnelVisionRenderer(overlay).render(player, TunnelVisionStage.MAX_STAGE); + + assertEquals( + Key.key("cygnus", OverlayTunnelVisionRenderer.TEXTURE_PATH + TunnelVisionStage.MAX_STAGE), + overlay.of(player, OverlayLayer.TUNNEL_VISION) + ); + } + + /** + * Connects a player into a fresh instance. + * + * @param env the test environment + * @return the connected player + */ + private Player spawn(Env env) { + Instance instance = env.createFlatInstance(); + return env.createConnection().connect(instance, new Pos(0, 40, 0)); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java new file mode 100644 index 00000000..d976aaea --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionIntensityTest.java @@ -0,0 +1,51 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the intensity curves that drive the survivor's tunnel vision. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class TunnelVisionIntensityTest { + + private static final double DELTA = 1.0E-6D; + + @DisplayName("Stamina above half a bar produces no tunnel vision") + @ParameterizedTest + @CsvSource({"1.0", "0.75", "0.5"}) + void staminaAboveHalfIsCalm(double stamina) { + assertEquals(0.0D, TunnelVisionIntensity.fromStamina(stamina), DELTA); + } + + @Test + @DisplayName("An empty stamina bar produces full intensity") + void emptyStaminaIsFull() { + assertEquals(1.0D, TunnelVisionIntensity.fromStamina(0.0D), DELTA); + } + + @Test + @DisplayName("The stamina curve accelerates towards the empty bar") + void staminaCurveIsQuadratic() { + assertEquals(0.25D, TunnelVisionIntensity.fromStamina(0.25D), DELTA); + } + + @Test + @DisplayName("Draining stamina never lowers the intensity") + void staminaIsMonotonic() { + double previous = -1.0D; + for (int step = 20; step >= 0; step--) { + double current = TunnelVisionIntensity.fromStamina(step / 20.0D); + assertTrue(current >= previous, "intensity dropped at stamina " + step / 20.0D); + previous = current; + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java new file mode 100644 index 00000000..dda7b455 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionServiceTest.java @@ -0,0 +1,235 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import net.kyori.adventure.text.Component; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.event.player.PlayerDeathEvent; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.event.GameStartEvent; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies how the service feeds survivors through the intensity calculation. + *

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

+ * + * @author TheMeinerLP + * @version 1.0.1 + * @since 2.7.0 + */ +class TunnelVisionServiceTest extends CygnusPlayerTestBase { + + private static final double FULL_STAMINA = 1.0D; + private static final double NO_STAMINA = 0.0D; + + @Test + @DisplayName("An exhausted survivor sees the tightest stage") + void exhaustedSurvivorIsFullyNarrowed(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.track(survivor); + + service.tick(); + + assertEquals(TunnelVisionStage.MAX_STAGE, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A rested survivor alone in the dark sees nothing") + void restedSurvivorSeesNothing(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> FULL_STAMINA); + service.track(survivor); + + service.tick(); + + assertEquals(0, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A removed survivor gets their screen back and is no longer drawn") + void removedSurvivorIsCleared(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.track(survivor); + service.tick(); + renderer.forget(); + + service.remove(survivor); + service.tick(); + + assertTrue(renderer.wasCleared(survivor), "the last vignette would otherwise linger"); + assertNull(renderer.stageOf(survivor), "a removed survivor must not be drawn any more"); + } + + @Test + @DisplayName("Clearing everyone gives every survivor their screen back") + void cleanUpClearsEveryone(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Instance instance = env.createFlatInstance(); + Player first = spawn(env, instance, new Pos(0, 40, 0)); + Player second = spawn(env, instance, new Pos(4, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.track(first); + service.track(second); + service.tick(); + + service.cleanUp(); + + assertTrue(renderer.wasCleared(first)); + assertTrue(renderer.wasCleared(second)); + + renderer.forget(); + service.tick(); + assertNull(renderer.stageOf(first), "clearing must stop the drawing as well"); + } + + @Test + @DisplayName("Starting and stopping the task is idempotent") + void startAndStopTaskAreIdempotent(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + + service.startTask(); + service.startTask(); + service.stopTask(); + service.stopTask(); + } + + @Test + @DisplayName("The start of a round takes the survivors on board") + void gameStartRegistersSurvivors(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + + EventDispatcher.call(new GameStartEvent()); + service.tick(); + + assertEquals(TunnelVisionStage.MAX_STAGE, renderer.stageOf(survivor)); + } + + @Test + @DisplayName("A dying survivor gets their screen back") + void deathClearsTheOverlay(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.track(survivor); + service.tick(); + renderer.forget(); + + EventDispatcher.call(new PlayerDeathEvent(survivor, Component.empty(), Component.empty())); + service.tick(); + + assertTrue(renderer.wasCleared(survivor)); + assertNull(renderer.stageOf(survivor), "a dead survivor must not be drawn any more"); + } + + @Test + @DisplayName("The end of a round clears everyone") + void gameFinishCleansUp(Env env) { + RecordingRenderer renderer = new RecordingRenderer(); + Player survivor = spawn(env, new Pos(0, 40, 0)); + TunnelVisionService service = new TunnelVisionService(renderer, player -> NO_STAMINA); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + service.track(survivor); + service.tick(); + renderer.forget(); + + EventDispatcher.call(new GameFinishEvent(GameFinishEvent.Reason.TIME_OVER)); + + assertTrue(renderer.wasCleared(survivor)); + } + + /** + * Spawns a player in a fresh instance. + * + * @param env the test environment + * @param position where to place the player + * @return the connected player + */ + private Player spawn(Env env, Pos position) { + return this.spawn(env, env.createFlatInstance(), position); + } + + /** + * Spawns a player in the given instance. + * + * @param env the test environment + * @param instance the instance to connect into + * @param position where to place the player + * @return the connected player + */ + private Player spawn(Env env, Instance instance, Pos position) { + return env.createConnection().connect(instance, position); + } + + /** + * Records what the service asked to be drawn, standing in for the action bar renderer. + */ + private static final class RecordingRenderer implements TunnelVisionRenderer { + + private final Map stages = new HashMap<>(); + private final Set cleared = new HashSet<>(); + + @Override + public void render(Player player, int stage) { + this.stages.put(player.getUuid(), stage); + } + + @Override + public void clear(Player player) { + this.cleared.add(player.getUuid()); + this.stages.remove(player.getUuid()); + } + + /** + * @param player the player to look up + * @return the stage last drawn for the player, or {@code null} if nothing was drawn + */ + private @Nullable Integer stageOf(Player player) { + return this.stages.get(player.getUuid()); + } + + /** + * @param player the player to look up + * @return whether the player's overlay was cleared + */ + private boolean wasCleared(Player player) { + return this.cleared.contains(player.getUuid()); + } + + /** + * Drops everything recorded so far, to tell repeated draws apart. + */ + private void forget() { + this.stages.clear(); + this.cleared.clear(); + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java new file mode 100644 index 00000000..407e80bc --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/tunnelvision/TunnelVisionStageTest.java @@ -0,0 +1,100 @@ +package net.onelitefeather.cygnus.tunnelvision; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies how a continuous intensity becomes the discrete, pulsing stage the overlay renders. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +class TunnelVisionStageTest { + + /** Enough updates to cover several periods of the slowest heartbeat. */ + private static final int SAMPLES = 60; + + @Test + @DisplayName("Without any threat the overlay stays off") + void calmIntensityStaysOff() { + TunnelVisionStage stage = new TunnelVisionStage(); + assertEquals(0, stage.update(0.0D)); + } + + @Test + @DisplayName("Full intensity pulses across the top of the scale") + void fullIntensityPulses() { + TunnelVisionStage stage = new TunnelVisionStage(); + int lowest = TunnelVisionStage.MAX_STAGE; + int highest = 0; + for (int sample = 0; sample < SAMPLES; sample++) { + int current = stage.update(1.0D); + lowest = Math.min(lowest, current); + highest = Math.max(highest, current); + } + assertEquals(TunnelVisionStage.MAX_STAGE, highest, "the pulse never reaches the peak"); + // Stated as a share of the scale rather than as a stage count, so raising the number of + // stages does not turn this into a test of one particular pulse depth. + assertTrue(lowest < highest, "the pulse does not open up again"); + assertTrue(lowest >= TunnelVisionStage.MAX_STAGE - TunnelVisionStage.MAX_STAGE / 4, + "the pulse swings the view too far open at full intensity"); + } + + @Test + @DisplayName("Low intensity barely pulses at all") + void lowIntensityIsSteady() { + TunnelVisionStage stage = new TunnelVisionStage(); + int first = stage.update(0.125D); + for (int sample = 0; sample < SAMPLES; sample++) { + assertEquals(first, stage.update(0.125D), "a barely threatened survivor should not flicker"); + } + } + + @Test + @DisplayName("A small fluctuation does not move the stage") + void hysteresisHoldsTheStage() { + TunnelVisionStage stage = new TunnelVisionStage(); + int settled = highestOver(stage, 0.5D); + assertEquals(TunnelVisionStage.MAX_STAGE / 2, settled, "half intensity should settle on the middle stage"); + assertEquals(settled, highestOver(stage, 0.51D), "the stage moved on a small fluctuation"); + } + + @Test + @DisplayName("A real change moves the stage") + void largerChangeMovesTheStage() { + TunnelVisionStage stage = new TunnelVisionStage(); + assertEquals(TunnelVisionStage.MAX_STAGE / 2, highestOver(stage, 0.5D)); + assertEquals(TunnelVisionStage.MAX_STAGE / 2 + 1, highestOver(stage, 0.53D), + "the stage should follow a real change"); + } + + @Test + @DisplayName("The stage never leaves its bounds") + void stageStaysWithinBounds() { + TunnelVisionStage stage = new TunnelVisionStage(); + for (int sample = 0; sample < SAMPLES; sample++) { + int current = stage.update(sample % 2 == 0 ? 1.0D : 0.0D); + assertTrue(current >= 0 && current <= TunnelVisionStage.MAX_STAGE, "stage out of bounds: " + current); + } + } + + /** + * Feeds a constant intensity for a while and reports the highest stage seen, which is the + * stage the pulse starts from. + * + * @param stage the stage state to drive + * @param combined the constant intensity to feed + * @return the highest stage observed + */ + private int highestOver(TunnelVisionStage stage, double combined) { + int highest = 0; + for (int sample = 0; sample < SAMPLES; sample++) { + highest = Math.max(highest, stage.update(combined)); + } + return highest; + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/utils/PlayerStateTest.java b/game/src/test/java/net/onelitefeather/cygnus/utils/PlayerStateTest.java new file mode 100644 index 00000000..a1a695aa --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/utils/PlayerStateTest.java @@ -0,0 +1,116 @@ +package net.onelitefeather.cygnus.utils; + +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.Iterator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that {@link PlayerState} tracks one value per player, keeps players apart, and forgets + * them cleanly. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +@ExtendWith(MicrotusExtension.class) +class PlayerStateTest { + + @Test + void nothingIsTrackedForAFreshPlayer(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + + assertNull(state.get(player)); + assertTrue(state.isEmpty()); + } + + @Test + void putThenGetReturnsTheStoredValue(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + + state.put(player, "value"); + + assertEquals("value", state.get(player)); + assertFalse(state.isEmpty()); + } + + @Test + void playersAreKeptApart(Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createPlayer(instance); + Player second = env.createPlayer(instance); + PlayerState state = new PlayerState<>(); + + state.put(first, "first"); + state.put(second, "second"); + + assertEquals("first", state.get(first)); + assertEquals("second", state.get(second)); + } + + @Test + void removeForgetsThePlayerAndReturnsTheOldValue(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + state.put(player, "value"); + + assertEquals("value", state.remove(player)); + assertNull(state.get(player)); + assertNull(state.remove(player), "removing an untracked player must not throw"); + } + + @Test + void computeIfAbsentStoresAndReusesTheComputedValue(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + + StringBuilder first = state.computeIfAbsent(player, StringBuilder::new); + StringBuilder second = state.computeIfAbsent(player, StringBuilder::new); + + assertEquals(first, second, "a second call must not overwrite the already-tracked value"); + } + + @Test + void clearForgetsEveryPlayer(Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createPlayer(instance); + Player second = env.createPlayer(instance); + PlayerState state = new PlayerState<>(); + state.put(first, "first"); + state.put(second, "second"); + + state.clear(); + + assertTrue(state.isEmpty()); + } + + @Test + void removingThroughValuesForgetsThePlayerToo(Env env) { + Player player = spawn(env); + PlayerState state = new PlayerState<>(); + state.put(player, "value"); + + Iterator values = state.values().iterator(); + values.next(); + values.remove(); + + assertTrue(state.isEmpty(), "the map backing values() must be live, the way BloodSplatterService needs it"); + assertNull(state.get(player)); + } + + private Player spawn(Env env) { + Instance instance = env.createFlatInstance(); + return env.createPlayer(instance); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/utils/RepeatingTaskIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/utils/RepeatingTaskIntegrationTest.java new file mode 100644 index 00000000..c38a5312 --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/utils/RepeatingTaskIntegrationTest.java @@ -0,0 +1,99 @@ +package net.onelitefeather.cygnus.utils; + +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.time.temporal.ChronoUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that {@link RepeatingTask} owns exactly one scheduler task no matter how many times + * start and stop are called. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.7.0 + */ +@ExtendWith(MicrotusExtension.class) +class RepeatingTaskIntegrationTest { + + @Test + void notRunningBeforeStart() { + RepeatingTask task = new RepeatingTask(() -> { + }); + + assertFalse(task.isRunning()); + } + + @Test + void runsOnceStarted(Env env) { + AtomicInteger ticks = new AtomicInteger(); + RepeatingTask task = new RepeatingTask(ticks::incrementAndGet); + + task.start(50, ChronoUnit.MILLIS); + assertTrue(task.isRunning()); + for (int i = 0; i < 10; i++) { + env.tick(); + } + + assertTrue(ticks.get() > 0, "the action should have run at least once by now"); + } + + @Test + void startIsIdempotent(Env env) { + AtomicInteger ticks = new AtomicInteger(); + RepeatingTask task = new RepeatingTask(ticks::incrementAndGet); + + task.start(50, ChronoUnit.MILLIS); + task.start(50, ChronoUnit.MILLIS); + for (int i = 0; i < 10; i++) { + env.tick(); + } + int afterFirstBatch = ticks.get(); + + // Stopping cancels the single task this class is meant to own. If start() had scheduled a + // second task on the repeated call, stop() would only ever reach one of them and the other + // would keep running forever, still incrementing the counter below. + task.stop(); + for (int i = 0; i < 10; i++) { + env.tick(); + } + + assertEquals(afterFirstBatch, ticks.get(), "a leaked second task would still be ticking"); + } + + @Test + void stopStopsTheTask(Env env) { + AtomicInteger ticks = new AtomicInteger(); + RepeatingTask task = new RepeatingTask(ticks::incrementAndGet); + task.start(50, ChronoUnit.MILLIS); + for (int i = 0; i < 10; i++) { + env.tick(); + } + + task.stop(); + assertFalse(task.isRunning()); + int afterStop = ticks.get(); + for (int i = 0; i < 10; i++) { + env.tick(); + } + + assertEquals(afterStop, ticks.get(), "no more runs should happen after stop()"); + } + + @Test + void stopIsIdempotent() { + RepeatingTask task = new RepeatingTask(() -> { + }); + + task.stop(); + + assertFalse(task.isRunning(), "stopping a task that never ran must not throw"); + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/utils/StaminaShareTest.java b/game/src/test/java/net/onelitefeather/cygnus/utils/StaminaShareTest.java new file mode 100644 index 00000000..f8b4919e --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/utils/StaminaShareTest.java @@ -0,0 +1,51 @@ +package net.onelitefeather.cygnus.utils; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.stamina.StaminaService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies {@link StaminaHelper#remainingShare(StaminaService, Player)}, the stamina reading the + * tunnel vision drives itself off. + *

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

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