From fab49829f99f9bddd1d430ff83f6a2b130d045a9 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 6 Sep 2026 23:55:14 +0200 Subject: [PATCH] feat(slender): tear his own screen as the pages go, instead of playing static 2.14.0 shipped this as a sound, which was the wrong reading of what was asked for: the effect was meant to be seen, not heard. It is a picture now. PageGlitchService puts the same worn-tape veil the survivors get from seeing the slender onto his own screen, driven by the round instead of by a line of sight. A baseline climbs a level at a time as the pages disappear, and each find pulses one level above it for pageGlitchPulseSeconds. Nothing is needed in the resource pack that is not already there. Three things this needed: GazeSink.attach(player, worldTint) splits the two halves of the signal. The veil says how far the round has got; the DARKEN_SCREEN bit takes the world's light with it, which is right for a survivor and wrong for the hunter. The slender gets the veil alone. The signal instance is shared with SlenderGazeService rather than a second one being built. A player has one carrier, and two boss bars would draw two full-screen quads over each other. SlenderGazeService now drops a player on SlenderReviveEvent. PlayerQuitListener promotes a living survivor when the slender leaves, so neither the death nor the disconnect listener covered him: he stayed tracked and was measured against himself, distance zero, which reads as the worst level for the rest of the round. The static's six settings collapse into pageGlitchEnabled and pageGlitchPulseSeconds. PageFoundEvent, which 2.14.0 introduced for the sound, stays as it is and now carries the glitch instead. --- .../cygnus/common/config/GameConfig.java | 150 ++------ .../common/config/GameConfigBuilder.java | 87 +---- .../cygnus/common/config/GameConfigImpl.java | 16 +- .../common/config/GameConfigReader.java | 17 +- .../common/config/InternalGameConfig.java | 26 +- .../common/config/GameConfigReaderTest.java | 38 +- config.properties.example | 51 +-- .../net/onelitefeather/cygnus/Cygnus.java | 14 +- .../cygnus/gaze/BossBarGazeSignal.java | 22 +- .../onelitefeather/cygnus/gaze/GazeSink.java | 17 + .../cygnus/gaze/SlenderGazeService.java | 5 + .../cygnus/glitch/PageGlitchService.java | 215 ++++++++++++ .../cygnus/glitch/package-info.java | 6 + .../cygnus/noise/SlenderStaticService.java | 208 ----------- .../cygnus/noise/package-info.java | 6 - .../cygnus/gaze/BossBarGazeSignalTest.java | 15 + .../cygnus/gaze/SlenderGazeServiceTest.java | 18 + .../cygnus/glitch/PageGlitchServiceTest.java | 326 ++++++++++++++++++ .../noise/SlenderStaticServiceTest.java | 280 --------------- 19 files changed, 713 insertions(+), 804 deletions(-) create mode 100644 game/src/main/java/net/onelitefeather/cygnus/glitch/PageGlitchService.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/glitch/package-info.java delete mode 100644 game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java delete mode 100644 game/src/main/java/net/onelitefeather/cygnus/noise/package-info.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/glitch/PageGlitchServiceTest.java delete mode 100644 game/src/test/java/net/onelitefeather/cygnus/noise/SlenderStaticServiceTest.java diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java index eb9d4ca3..8c48f2dc 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java @@ -88,39 +88,20 @@ public sealed interface GameConfig permits GameConfigImpl, InternalGameConfig { Key DEFAULT_DAMAGE_SOUND = Key.key("entity.player.hurt"); /** - * The static the slender hears while the survivors take his pages away. + * The {@link #pageGlitchPulseSeconds()} a configuration gets when it says nothing. *

- * A resource pack sound rather than a vanilla one: three 2.2 second takes of tape hiss the - * client picks between, high-passed at 520 Hz so the effect's own pitch drop to 0.7 leaves - * it hissing rather than humming. Nothing in vanilla comes close - rain is the nearest, and - * it reads as weather. - *

- *

- * A server running without the Cygnus pack therefore hears nothing here. That is the right - * way round: the static is a horror cue, and half of one played through the wrong sample is - * worse than none. + * Long enough that a find registers as its own event on screen, short enough that the slender + * is not looking through it while he chases somebody. *

*/ - Key DEFAULT_SLENDER_STATIC_SOUND = Key.key("cygnus", "vhs_static"); - - /** The {@link #slenderStaticQuietInterval()} a configuration gets when it says nothing. */ - int DEFAULT_SLENDER_STATIC_QUIET_INTERVAL = 12; - - /** The {@link #slenderStaticFranticInterval()} a configuration gets when it says nothing. */ - int DEFAULT_SLENDER_STATIC_FRANTIC_INTERVAL = 3; + int DEFAULT_PAGE_GLITCH_PULSE_SECONDS = 3; /** - * The longest {@link #slenderStaticQuietInterval()} a configuration may ask for. Past this a - * round could end before the slender has heard the static twice, which makes it noise rather - * than a clock. + * The longest {@link #pageGlitchPulseSeconds()} a configuration may ask for. Past this a find + * late in the round would still be on his screen when the next one lands, and the pulse stops + * being a pulse. */ - int MAX_SLENDER_STATIC_INTERVAL = 120; - - /** The {@link #slenderStaticMinVolume()} a configuration gets when it says nothing. */ - float DEFAULT_SLENDER_STATIC_MIN_VOLUME = 0.15F; - - /** The {@link #slenderStaticMaxVolume()} a configuration gets when it says nothing. */ - float DEFAULT_SLENDER_STATIC_MAX_VOLUME = 0.8F; + int MAX_PAGE_GLITCH_PULSE_SECONDS = 30; /** * The largest {@link #glitchRange()} a configuration may ask for. Beyond this the slender would @@ -334,60 +315,24 @@ static Builder builder() { Key damageSound(); /** - * Returns whether the slender hears static as the survivors collect his pages. - * - * @return {@code true} while the static is on - * @since 2.14.0 - */ - boolean slenderStaticEnabled(); - - /** - * Returns the sound the static is built from. + * Returns whether the slender's own screen tears as the survivors collect his pages. *

- * The key is not resolved against the sound registry here: a resource pack sound is a perfectly - * good answer and would not be found in it. It is sent as named. + * The effect rides the same channel as the gaze glitch and is therefore drawn by the resource + * pack, which is why it is gated by the overlays as well as by this setting. *

* - * @return the sound key, never {@code null} - * @since 2.14.0 - */ - Key slenderStaticSound(); - - /** - * Returns how many seconds lie between two bursts while no page has been found. - * - * @return the interval in seconds, at most {@link #MAX_SLENDER_STATIC_INTERVAL} - * @since 2.14.0 - */ - int slenderStaticQuietInterval(); - - /** - * Returns how many seconds lie between two bursts once every page is gone. - *

- * The gap shrinks from {@link #slenderStaticQuietInterval()} towards this value as the pages - * disappear, which is what tells the slender how late in the round he is. - *

- * - * @return the interval in seconds, below {@link #slenderStaticQuietInterval()} - * @since 2.14.0 - */ - int slenderStaticFranticInterval(); - - /** - * Returns how loud the static is while no page has been found. - * - * @return the volume, between 0 and {@link #slenderStaticMaxVolume()} - * @since 2.14.0 + * @return {@code true} while the page glitch is on + * @since 2.15.0 */ - float slenderStaticMinVolume(); + boolean pageGlitchEnabled(); /** - * Returns how loud the static is once every page is gone. + * Returns how long a single find holds the glitch one level above where the round stands. * - * @return the volume, at most 1 - * @since 2.14.0 + * @return the pulse in seconds, at most {@link #MAX_PAGE_GLITCH_PULSE_SECONDS} + * @since 2.15.0 */ - float slenderStaticMaxVolume(); + int pageGlitchPulseSeconds(); /** * Returns how close the slender has to be before the sight of him tears a survivor's view. @@ -596,63 +541,24 @@ sealed interface Builder permits GameConfigBuilder { Builder damageSound(Key damageSound); /** - * Sets whether the slender hears static as the survivors collect his pages. - * - * @param slenderStaticEnabled {@code true} to keep the static on - * @return the builder instance - * @since 2.14.0 - */ - Builder slenderStaticEnabled(boolean slenderStaticEnabled); - - /** - * Sets the sound the static is built from. - * - * @param slenderStaticSound the sound key - * @return the builder instance - * @since 2.14.0 - */ - Builder slenderStaticSound(Key slenderStaticSound); - - /** - * Sets how many seconds lie between two bursts while no page has been found. - * - * @param slenderStaticQuietInterval the interval in seconds - * @return the builder instance - * @throws IllegalArgumentException if the interval is below 1 or above - * {@link GameConfig#MAX_SLENDER_STATIC_INTERVAL} - * @since 2.14.0 - */ - Builder slenderStaticQuietInterval(int slenderStaticQuietInterval); - - /** - * Sets how many seconds lie between two bursts once every page is gone. - * - * @param slenderStaticFranticInterval the interval in seconds - * @return the builder instance - * @throws IllegalArgumentException if the interval is below 1 - * @since 2.14.0 - */ - Builder slenderStaticFranticInterval(int slenderStaticFranticInterval); - - /** - * Sets how loud the static is while no page has been found. + * Sets whether the slender's own screen tears as the survivors collect his pages. * - * @param slenderStaticMinVolume the volume + * @param pageGlitchEnabled {@code true} to keep the page glitch on * @return the builder instance - * @throws IllegalArgumentException if the volume is below 0 or above 1 - * @since 2.14.0 + * @since 2.15.0 */ - Builder slenderStaticMinVolume(float slenderStaticMinVolume); + Builder pageGlitchEnabled(boolean pageGlitchEnabled); /** - * Sets how loud the static is once every page is gone. + * Sets how long a single find holds the glitch above where the round stands. * - * @param slenderStaticMaxVolume the volume + * @param pageGlitchPulseSeconds the pulse in seconds * @return the builder instance - * @throws IllegalArgumentException if the volume is below 0 or above 1 - * @since 2.14.0 + * @throws IllegalArgumentException if the pulse is below 1 or above + * {@link GameConfig#MAX_PAGE_GLITCH_PULSE_SECONDS} + * @since 2.15.0 */ - Builder slenderStaticMaxVolume(float slenderStaticMaxVolume); + Builder pageGlitchPulseSeconds(int pageGlitchPulseSeconds); /** * Sets how close the slender has to be before the sight of him tears a survivor's view. diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigBuilder.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigBuilder.java index 8771919f..b0b9686d 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigBuilder.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigBuilder.java @@ -38,15 +38,8 @@ public final class GameConfigBuilder implements GameConfig.Builder { private int glitchRange = GameConfig.DEFAULT_GLITCH_RANGE; private int glitchCloseRange = GameConfig.DEFAULT_GLITCH_CLOSE_RANGE; private int glitchViewAngle = GameConfig.DEFAULT_GLITCH_VIEW_ANGLE; - private boolean slenderStaticEnabled; - private Key slenderStaticSound = GameConfig.DEFAULT_SLENDER_STATIC_SOUND; - // Pre-set for the same reason as the glitch distances above: build() checks the two intervals - // and the two volumes against each other, and a builder that was never told about the static - // would trip those checks on a pair of zeroes. - private int slenderStaticQuietInterval = GameConfig.DEFAULT_SLENDER_STATIC_QUIET_INTERVAL; - private int slenderStaticFranticInterval = GameConfig.DEFAULT_SLENDER_STATIC_FRANTIC_INTERVAL; - private float slenderStaticMinVolume = GameConfig.DEFAULT_SLENDER_STATIC_MIN_VOLUME; - private float slenderStaticMaxVolume = GameConfig.DEFAULT_SLENDER_STATIC_MAX_VOLUME; + private boolean pageGlitchEnabled; + private int pageGlitchPulseSeconds = GameConfig.DEFAULT_PAGE_GLITCH_PULSE_SECONDS; @Override public GameConfig.Builder minPlayers(int minPlayers) { @@ -207,66 +200,22 @@ public GameConfig.Builder glitchViewAngle(int glitchViewAngle) { } @Override - public GameConfig.Builder slenderStaticEnabled(boolean slenderStaticEnabled) { - this.slenderStaticEnabled = slenderStaticEnabled; + public GameConfig.Builder pageGlitchEnabled(boolean pageGlitchEnabled) { + this.pageGlitchEnabled = pageGlitchEnabled; return this; } @Override - public GameConfig.Builder slenderStaticSound(Key slenderStaticSound) { - this.slenderStaticSound = slenderStaticSound; - return this; - } - - @Override - public GameConfig.Builder slenderStaticQuietInterval(int slenderStaticQuietInterval) { - if (slenderStaticQuietInterval < 1 - || slenderStaticQuietInterval > GameConfig.MAX_SLENDER_STATIC_INTERVAL) { + public GameConfig.Builder pageGlitchPulseSeconds(int pageGlitchPulseSeconds) { + if (pageGlitchPulseSeconds < 1 || pageGlitchPulseSeconds > GameConfig.MAX_PAGE_GLITCH_PULSE_SECONDS) { throw new IllegalArgumentException( - "Slender static quiet interval must be between 1 and " - + GameConfig.MAX_SLENDER_STATIC_INTERVAL + " seconds"); - } - this.slenderStaticQuietInterval = slenderStaticQuietInterval; - return this; - } - - @Override - public GameConfig.Builder slenderStaticFranticInterval(int slenderStaticFranticInterval) { - if (slenderStaticFranticInterval < 1) { - throw new IllegalArgumentException("Slender static frantic interval must be at least 1 second"); + "Page glitch pulse must be between 1 and " + + GameConfig.MAX_PAGE_GLITCH_PULSE_SECONDS + " seconds"); } - this.slenderStaticFranticInterval = slenderStaticFranticInterval; - return this; - } - - @Override - public GameConfig.Builder slenderStaticMinVolume(float slenderStaticMinVolume) { - this.slenderStaticMinVolume = checkVolume(slenderStaticMinVolume, "minimum"); - return this; - } - - @Override - public GameConfig.Builder slenderStaticMaxVolume(float slenderStaticMaxVolume) { - this.slenderStaticMaxVolume = checkVolume(slenderStaticMaxVolume, "maximum"); + this.pageGlitchPulseSeconds = pageGlitchPulseSeconds; return this; } - /** - * Checks a static volume against the range a sound can carry. - * - * @param volume the volume to check - * @param name how the volume is named in the message of a failure - * @return the volume - * @throws IllegalArgumentException if the volume is outside 0 to 1 - */ - private static float checkVolume(float volume, String name) { - if (volume < 0.0F || volume > 1.0F) { - throw new IllegalArgumentException( - "Slender static " + name + " volume must be between 0 and 1"); - } - return volume; - } - /** * {@inheritDoc} *

@@ -285,16 +234,6 @@ public GameConfig build() { "Glitch close range (" + glitchCloseRange + ") must be below the glitch range (" + glitchRange + ")"); } - if (slenderStaticFranticInterval >= slenderStaticQuietInterval) { - throw new IllegalArgumentException( - "Slender static frantic interval (" + slenderStaticFranticInterval - + ") must be below the quiet interval (" + slenderStaticQuietInterval + ")"); - } - if (slenderStaticMinVolume > slenderStaticMaxVolume) { - throw new IllegalArgumentException( - "Slender static minimum volume (" + slenderStaticMinVolume - + ") must not be above the maximum volume (" + slenderStaticMaxVolume + ")"); - } return new GameConfigImpl( minPlayers, maxPlayers, @@ -316,12 +255,8 @@ public GameConfig build() { glitchRange, glitchCloseRange, glitchViewAngle, - slenderStaticEnabled, - slenderStaticSound, - slenderStaticQuietInterval, - slenderStaticFranticInterval, - slenderStaticMinVolume, - slenderStaticMaxVolume + pageGlitchEnabled, + pageGlitchPulseSeconds ); } } diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigImpl.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigImpl.java index 19ff84a4..e147215d 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigImpl.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigImpl.java @@ -33,12 +33,8 @@ * @param glitchCloseRange the distance in blocks at which the tearing is at its worst * @param glitchViewAngle how far off the centre of their view he may stand and still count * as seen, in degrees - * @param slenderStaticEnabled whether the slender hears static as his pages are collected - * @param slenderStaticSound the sound the static is built from - * @param slenderStaticQuietInterval the seconds between two bursts while no page has been found - * @param slenderStaticFranticInterval the seconds between two bursts once every page is gone - * @param slenderStaticMinVolume how loud the static is while no page has been found - * @param slenderStaticMaxVolume how loud the static is once every page is gone + * @param pageGlitchEnabled whether the slender's screen tears as his pages are collected + * @param pageGlitchPulseSeconds how long a single find holds the glitch above the round's level * @author theEvilReaper * @version 1.4.0 * @since 1.0.0 @@ -64,12 +60,8 @@ public record GameConfigImpl( int glitchRange, int glitchCloseRange, int glitchViewAngle, - boolean slenderStaticEnabled, - Key slenderStaticSound, - int slenderStaticQuietInterval, - int slenderStaticFranticInterval, - float slenderStaticMinVolume, - float slenderStaticMaxVolume + boolean pageGlitchEnabled, + int pageGlitchPulseSeconds ) implements GameConfig { } diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java index 0edd1b8a..d371f461 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java @@ -40,12 +40,8 @@ *

  • glitchRange
  • *
  • glitchCloseRange
  • *
  • glitchViewAngle
  • - *
  • slenderStaticEnabled
  • - *
  • slenderStaticSound
  • - *
  • slenderStaticQuietInterval
  • - *
  • slenderStaticFranticInterval
  • - *
  • slenderStaticMinVolume
  • - *
  • slenderStaticMaxVolume
  • + *
  • pageGlitchEnabled
  • + *
  • pageGlitchPulseSeconds
  • * *

    * If a property can not be found in the file, the default value will be used. @@ -65,7 +61,6 @@ public final class GameConfigReader { private static final Pattern SHA1_PATTERN = Pattern.compile("[0-9a-fA-F]{40}"); private static final String PAGE_PROXIMITY_SOUND_KEY = "pageProximitySound"; private static final String DAMAGE_SOUND_KEY = "damageSound"; - private static final String SLENDER_STATIC_SOUND_KEY = "slenderStaticSound"; private final Path path; @@ -128,12 +123,8 @@ public GameConfig getConfig() { .glitchRange(getInt(properties, "glitchRange", internal.glitchRange())) .glitchCloseRange(getInt(properties, "glitchCloseRange", internal.glitchCloseRange())) .glitchViewAngle(getInt(properties, "glitchViewAngle", internal.glitchViewAngle())) - .slenderStaticEnabled(getBoolean(properties, "slenderStaticEnabled", internal.slenderStaticEnabled())) - .slenderStaticSound(getSound(properties, SLENDER_STATIC_SOUND_KEY, internal.slenderStaticSound())) - .slenderStaticQuietInterval(getInt(properties, "slenderStaticQuietInterval", internal.slenderStaticQuietInterval())) - .slenderStaticFranticInterval(getInt(properties, "slenderStaticFranticInterval", internal.slenderStaticFranticInterval())) - .slenderStaticMinVolume(getFloat(properties, "slenderStaticMinVolume", internal.slenderStaticMinVolume())) - .slenderStaticMaxVolume(getFloat(properties, "slenderStaticMaxVolume", internal.slenderStaticMaxVolume())); + .pageGlitchEnabled(getBoolean(properties, "pageGlitchEnabled", internal.pageGlitchEnabled())) + .pageGlitchPulseSeconds(getInt(properties, "pageGlitchPulseSeconds", internal.pageGlitchPulseSeconds())); return configBuilder.build(); } diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/InternalGameConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/InternalGameConfig.java index 121c0d5f..66c60d2a 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/config/InternalGameConfig.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/config/InternalGameConfig.java @@ -33,12 +33,8 @@ * @param glitchCloseRange the distance in blocks at which the tearing is at its worst * @param glitchViewAngle how far off the centre of their view he may stand and still count * as seen, in degrees - * @param slenderStaticEnabled whether the slender hears static as his pages are collected - * @param slenderStaticSound the sound the static is built from - * @param slenderStaticQuietInterval the seconds between two bursts while no page has been found - * @param slenderStaticFranticInterval the seconds between two bursts once every page is gone - * @param slenderStaticMinVolume how loud the static is while no page has been found - * @param slenderStaticMaxVolume how loud the static is once every page is gone + * @param pageGlitchEnabled whether the slender's screen tears as his pages are collected + * @param pageGlitchPulseSeconds how long a single find holds the glitch above the round's level * @author theEvilReaper * @version 1.4.0 * @since 1.0.0 @@ -64,12 +60,8 @@ record InternalGameConfig( int glitchRange, int glitchCloseRange, int glitchViewAngle, - boolean slenderStaticEnabled, - Key slenderStaticSound, - int slenderStaticQuietInterval, - int slenderStaticFranticInterval, - float slenderStaticMinVolume, - float slenderStaticMaxVolume + boolean pageGlitchEnabled, + int pageGlitchPulseSeconds ) implements GameConfig { // Sentry and the ResourcePack are opt-in: a service that says nothing about them reports to @@ -79,8 +71,8 @@ record InternalGameConfig( // The damage feedback is on by default for the same reason: taking a hit in silence is a bug, // not a setting. The cooldown of 20 ticks lets through every second damage tick of a draining // slender, which is enough to notice and not enough to grate. - // The slender's static is on by default too: it is the only thing that tells him how far the - // survivors have got without putting the page counter in front of him. + // The page glitch is on by default too: it is the only thing that tells the slender how far + // the survivors have got without putting their page counter in front of him. private static final GameConfig DEFAULT = new InternalGameConfig( 2, 13, 30, 900, 1, 12, null, null, null, true, 20, 20, GameConfig.DEFAULT_PAGE_PROXIMITY_SOUND, @@ -89,11 +81,7 @@ record InternalGameConfig( GameConfig.DEFAULT_GLITCH_RANGE, GameConfig.DEFAULT_GLITCH_CLOSE_RANGE, GameConfig.DEFAULT_GLITCH_VIEW_ANGLE, - true, GameConfig.DEFAULT_SLENDER_STATIC_SOUND, - GameConfig.DEFAULT_SLENDER_STATIC_QUIET_INTERVAL, - GameConfig.DEFAULT_SLENDER_STATIC_FRANTIC_INTERVAL, - GameConfig.DEFAULT_SLENDER_STATIC_MIN_VOLUME, - GameConfig.DEFAULT_SLENDER_STATIC_MAX_VOLUME); + true, GameConfig.DEFAULT_PAGE_GLITCH_PULSE_SECONDS); /** * Returns the default configuration for the game. diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java index 5468246e..9b6befab 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java +++ b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java @@ -188,57 +188,43 @@ void testARangeBeyondTheMaximumIsRejected(@TempDir Path tempDir) throws IOExcept } @Test - void testSlenderStaticDefaultsWhenNothingIsConfigured() { + void testPageGlitchDefaultsWhenNothingIsConfigured() { GameConfig config = new GameConfigReader(Paths.get("src", "test", "resources")).getConfig(); - assertTrue(config.slenderStaticEnabled()); - assertEquals(GameConfig.DEFAULT_SLENDER_STATIC_SOUND, config.slenderStaticSound()); - assertEquals(GameConfig.DEFAULT_SLENDER_STATIC_QUIET_INTERVAL, config.slenderStaticQuietInterval()); - assertEquals(GameConfig.DEFAULT_SLENDER_STATIC_FRANTIC_INTERVAL, config.slenderStaticFranticInterval()); - assertEquals(GameConfig.DEFAULT_SLENDER_STATIC_MIN_VOLUME, config.slenderStaticMinVolume()); - assertEquals(GameConfig.DEFAULT_SLENDER_STATIC_MAX_VOLUME, config.slenderStaticMaxVolume()); + assertTrue(config.pageGlitchEnabled()); + assertEquals(GameConfig.DEFAULT_PAGE_GLITCH_PULSE_SECONDS, config.pageGlitchPulseSeconds()); } @Test - void testSlenderStaticValuesAreReadWhenTheyAreConfigured(@TempDir Path tempDir) throws IOException { + void testPageGlitchValuesAreReadWhenTheyAreConfigured(@TempDir Path tempDir) throws IOException { Files.writeString(tempDir.resolve("config.properties"), """ minPlayers=4 - slenderStaticSound=cygnus:vhs_static - slenderStaticQuietInterval=20 - slenderStaticFranticInterval=2 - slenderStaticMinVolume=0.1 - slenderStaticMaxVolume=1.0 + pageGlitchPulseSeconds=6 """); GameConfig config = new GameConfigReader(tempDir).getConfig(); - assertEquals(Key.key("cygnus", "vhs_static"), config.slenderStaticSound(), - "a resource pack sound has to survive the reader, it is the point of the setting"); - assertEquals(20, config.slenderStaticQuietInterval()); - assertEquals(2, config.slenderStaticFranticInterval()); - assertEquals(0.1F, config.slenderStaticMinVolume()); - assertEquals(1.0F, config.slenderStaticMaxVolume()); + assertEquals(6, config.pageGlitchPulseSeconds()); } @Test - void testSlenderStaticCanBeTurnedOff(@TempDir Path tempDir) throws IOException { + void testPageGlitchCanBeTurnedOff(@TempDir Path tempDir) throws IOException { Files.writeString(tempDir.resolve("config.properties"), """ minPlayers=4 - slenderStaticEnabled=false + pageGlitchEnabled=false """); GameConfig config = new GameConfigReader(tempDir).getConfig(); - assertFalse(config.slenderStaticEnabled()); + assertFalse(config.pageGlitchEnabled()); } @Test - void testAFranticIntervalAtOrAboveTheQuietOneIsRejected(@TempDir Path tempDir) throws IOException { + void testAPulseBeyondTheMaximumIsRejected(@TempDir Path tempDir) throws IOException { Files.writeString(tempDir.resolve("config.properties"), """ minPlayers=4 - slenderStaticQuietInterval=5 - slenderStaticFranticInterval=5 - """); + pageGlitchPulseSeconds=%d + """.formatted(GameConfig.MAX_PAGE_GLITCH_PULSE_SECONDS + 1)); GameConfigReader reader = new GameConfigReader(tempDir); diff --git a/config.properties.example b/config.properties.example index 14fd558e..4a87ae10 100644 --- a/config.properties.example +++ b/config.properties.example @@ -149,38 +149,21 @@ survivorTeamSize=12 # means he triggers the effect while barely in frame. #glitchViewAngle=30 -# --- The slender's static ----------------------------------------------------- -# The survivors watch their page counter go up; the slender has none and only -# learns the score when the round ends. The static is that counter told as sound: -# a hiss that sits closer together and louder the fewer pages are left, plus a -# burst of its own the moment a page goes. Only he hears it, and it carries no -# direction - neither side may place the other by it. - -# Whether the slender hears the static at all. -#slenderStaticEnabled=true - -# The sound the static is built from. +# --- The slender's page glitch ----------------------------------------------- +# The survivors watch their page counter climb; the slender has none and only +# learns the score when the round ends. The glitch is that counter, told as his +# own picture coming apart: a baseline that climbs a level at a time as the pages +# disappear, and a harder pulse the moment one goes. # -# Ships in the Cygnus resource pack as three takes of tape hiss the client picks -# between. Unlike the other sound settings, a key that names no vanilla sound is -# not replaced by a default here but sent to the client as named, which is what -# lets a pack sound be used at all. -# -# A server that hands out no pack, or an older one, hears nothing under this key. -# weather.rain is the closest vanilla stand-in if that is the situation. -#slenderStaticSound=cygnus:vhs_static - -# Seconds between two bursts while no page has been found. Between 1 and 120. -#slenderStaticQuietInterval=12 - -# Seconds between two bursts once every page is gone. At least 1 and below -# slenderStaticQuietInterval - the gap shrinks from the one towards the other as -# the pages disappear, so a configuration where they meet or cross is rejected. -#slenderStaticFranticInterval=3 - -# How loud the static is while no page has been found. Between 0 and 1. -#slenderStaticMinVolume=0.15 - -# How loud it is once every page is gone. Between 0 and 1 and not below -# slenderStaticMinVolume. -#slenderStaticMaxVolume=0.8 +# It rides the same channel as the gaze glitch, so the resource pack draws it and +# the cygnus.overlays gate switches it off along with the other full-screen +# effects. Unlike the survivors' version it never darkens the world - that would +# take the sight the slender hunts with. + +# Whether the slender's screen tears as his pages are collected. +#pageGlitchEnabled=true + +# How long a single find holds the glitch one level above where the round stands, +# in seconds. Between 1 and 30. Past that a find late in the round would still be +# on screen when the next one lands, and the pulse stops reading as an event. +#pageGlitchPulseSeconds=3 diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index 347cf7c0..102f0e78 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -44,7 +44,7 @@ import net.onelitefeather.cygnus.page.PageProximityService; import net.onelitefeather.cygnus.blood.BloodSplatterService; import net.onelitefeather.cygnus.damage.DamageSoundService; -import net.onelitefeather.cygnus.noise.SlenderStaticService; +import net.onelitefeather.cygnus.glitch.PageGlitchService; import net.onelitefeather.cygnus.command.GlitchCommand; import net.onelitefeather.cygnus.command.StartCommand; import net.onelitefeather.cygnus.common.ListenerHandling; @@ -133,7 +133,7 @@ public final class Cygnus implements TeamCreator, ListenerHandling { private final DamageSoundService damageSoundService; private final TunnelVisionRenderer tunnelVisionRenderer; private final TunnelVisionService tunnelVisionService; - private final SlenderStaticService slenderStaticService; + private final PageGlitchService pageGlitchService; public Cygnus() { Path path = ServiceBootstrap.resolveWorkingDirectory(); @@ -188,8 +188,12 @@ public Cygnus() { bound -> ThreadLocalRandom.current().nextInt(bound) ); this.damageSoundService = new DamageSoundService(this.gameConfig, System::currentTimeMillis); - this.slenderStaticService = new SlenderStaticService( + // The gaze's signal, shared rather than a second one: a player has one carrier, and two + // would draw two full-screen quads over each other. Which of the two services addresses a + // player follows their team, and a hand-over moves them from one to the other. + this.pageGlitchService = new PageGlitchService( this.gameConfig, + this.gazeSignal, () -> TeamHelper.slenderOf(this.teamService)); this.tunnelVisionRenderer = new OverlayTunnelVisionRenderer(this.screenOverlay); this.tunnelVisionService = new TunnelVisionService(this.tunnelVisionRenderer, player -> StaminaHelper.remainingShare(this.staminaService, player)); @@ -269,9 +273,6 @@ private void registerGameListener() { // Not part of registerOverlayListeners: the sound is the feedback a hit owes the player // either way, and it needs neither the resource pack nor the overlay gate to be heard. this.damageSoundService.registerListener(handler); - // Outside registerOverlayListeners for the same reason as the damage sound: the static is - // heard, not drawn, so neither the resource pack nor the overlay gate has a say in it. - this.slenderStaticService.registerListener(handler); this.registerOverlayListeners(handler); } @@ -291,6 +292,7 @@ private void registerOverlayListeners(GlobalEventHandler handler) { this.slenderGazeService.registerListener(handler, () -> TeamHelper.survivorsOf(this.teamService)); this.bloodSplatterService.registerListener(handler); this.tunnelVisionService.registerListener(handler, () -> TeamHelper.survivorsOf(this.teamService)); + this.pageGlitchService.registerListener(handler); } private void initPhases() { diff --git a/game/src/main/java/net/onelitefeather/cygnus/gaze/BossBarGazeSignal.java b/game/src/main/java/net/onelitefeather/cygnus/gaze/BossBarGazeSignal.java index 90cf82cb..6d588d2e 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/gaze/BossBarGazeSignal.java +++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/BossBarGazeSignal.java @@ -73,6 +73,9 @@ public final class BossBarGazeSignal implements GazeSink { */ private final PlayerState bars = new PlayerState<>(); + /** Who may have the world darkened along with their veil. */ + private final PlayerState worldTints = new PlayerState<>(); + /** * Builds the component that carries a level. * @@ -100,10 +103,19 @@ static Component signalFor(int level) { */ @Override public void attach(Player survivor) { + this.attach(survivor, true); + } + + /** + * {@inheritDoc} + */ + @Override + public void attach(Player player, boolean worldTint) { BossBar bar = BossBar.bossBar( signalFor(SlenderGaze.NONE), 1f, CARRIER_COLOR, BossBar.Overlay.PROGRESS); - this.bars.put(survivor, bar); - survivor.showBossBar(bar); + this.bars.put(player, bar); + this.worldTints.put(player, worldTint); + player.showBossBar(bar); } /** @@ -112,6 +124,7 @@ public void attach(Player survivor) { @Override public void detach(Player survivor) { BossBar bar = this.bars.remove(survivor); + this.worldTints.remove(survivor); if (bar == null) return; survivor.hideBossBar(bar); } @@ -133,6 +146,11 @@ public void level(Player survivor, int level) { // pass and cannot tell the lightmap anything, which is why the level has to travel twice. bar.name(signalFor(level)); + if (!Boolean.TRUE.equals(this.worldTints.get(survivor))) { + // Attached without the world tint: the veil above is the whole effect for this player. + return; + } + if (level == SlenderGaze.NONE) { bar.removeFlag(BossBar.Flag.DARKEN_SCREEN); return; diff --git a/game/src/main/java/net/onelitefeather/cygnus/gaze/GazeSink.java b/game/src/main/java/net/onelitefeather/cygnus/gaze/GazeSink.java index 7ffb33dc..34428e63 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/gaze/GazeSink.java +++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/GazeSink.java @@ -47,6 +47,23 @@ public void level(Player survivor, int level) { */ void attach(Player survivor); + /** + * Starts signalling for a player who must not have the world darkened along with the veil. + *

    + * The two halves of the effect are separable and the slender needs only one of them: the veil + * says how far the round has got, while darkening the world would take his sight, which is the + * one thing he has over the survivors. A sink that has no world tint to leave out ignores the + * distinction, which is what the default here does. + *

    + * + * @param player the player to start signalling for + * @param worldTint whether the world may be darkened along with the veil + * @since 2.15.0 + */ + default void attach(Player player, boolean worldTint) { + this.attach(player); + } + /** * Stops signalling for a survivor and takes back whatever was set for them. * diff --git a/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java index de0f488b..5db5f8fd 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java +++ b/game/src/main/java/net/onelitefeather/cygnus/gaze/SlenderGazeService.java @@ -8,6 +8,7 @@ import net.minestom.server.instance.Instance; import net.onelitefeather.cygnus.event.GameFinishEvent; import net.onelitefeather.cygnus.event.GameStartEvent; +import net.onelitefeather.cygnus.event.SlenderReviveEvent; import net.onelitefeather.cygnus.utils.PlayerState; import net.onelitefeather.cygnus.utils.RepeatingTask; import org.jetbrains.annotations.Nullable; @@ -83,6 +84,10 @@ public void registerListener(EventNode node, Supplier> surviv } }); node.addListener(PlayerDeathEvent.class, event -> this.remove(event.getPlayer())); + // A survivor promoted to slender when the old one quits is alive, so neither of the two + // listeners above covers him. Left tracked, he would be measured against himself: distance + // zero, which reads as the worst level, for the rest of the round. + node.addListener(SlenderReviveEvent.class, event -> this.remove(event.getPlayer())); node.addListener(PlayerDisconnectEvent.class, event -> this.remove(event.getPlayer())); node.addListener(GameFinishEvent.class, event -> { this.cleanUp(); diff --git a/game/src/main/java/net/onelitefeather/cygnus/glitch/PageGlitchService.java b/game/src/main/java/net/onelitefeather/cygnus/glitch/PageGlitchService.java new file mode 100644 index 00000000..56c8470f --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/glitch/PageGlitchService.java @@ -0,0 +1,215 @@ +package net.onelitefeather.cygnus.glitch; + +import net.minestom.server.entity.Player; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.onelitefeather.cygnus.common.config.GameConfig; +import net.onelitefeather.cygnus.common.page.event.PageFoundEvent; +import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.event.GameStartEvent; +import net.onelitefeather.cygnus.gaze.GazeSink; +import net.onelitefeather.cygnus.gaze.SlenderGaze; +import net.onelitefeather.cygnus.utils.RepeatingTask; +import org.jetbrains.annotations.Nullable; + +import java.time.temporal.ChronoUnit; +import java.util.function.Supplier; + +/** + * Tears the slender's own screen apart as the survivors take his pages. + * + *

    He is the only player in a round without a page counter: the survivors watch theirs climb, he + * only learns the score when it ends. This is that counter, told as a picture coming apart rather + * than as a number - the same worn-tape look the survivors get from seeing him, turned back on him + * and driven by the round instead of by a line of sight.

    + * + *

    Two things carry it. A baseline sits on his screen for the rest of the round and climbs a + * level at a time as the pages disappear, and every single find pulses one level above that for + * {@link GameConfig#pageGlitchPulseSeconds()} before falling back. Until the first page goes there + * is nothing at all: {@link SlenderGaze#NONE} rather than the weakest level, because an untouched + * round has nothing to say yet.

    + * + *

    The level travels through a {@link GazeSink}, the same seam {@code SlenderGazeService} uses, + * so what reaches the client is decided in one place. The sink is deliberately shared with the + * gaze rather than a second one of its own: a player has one signal carrier, and two would draw + * two full-screen quads over each other. Slender and survivors are disjoint, so the two services + * never address the same player - except across a hand-over, which is why {@link #tick()} watches + * for the slender changing and moves the effect over.

    + * + *

    Usage:

    + *
    {@code
    + * PageGlitchService service = new PageGlitchService(config, signal, () -> currentSlender);
    + * service.registerListener(eventNode);
    + * }
    + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.15.0 + */ +public final class PageGlitchService { + + /** How often the service looks at the round, in seconds. */ + private static final long TICK_SECONDS = 1L; + + private final GameConfig config; + private final GazeSink sink; + private final Supplier<@Nullable Player> slender; + private final RepeatingTask task = new RepeatingTask(this::tick); + + /** How far the round has got, between {@code 0} and {@code 1}. */ + private float progress; + + /** Seconds left of the pulse a find started, {@code 0} while none is running. */ + private int pulseSecondsLeft; + + /** The slender the effect currently sits on, {@code null} while it sits on nobody. */ + private @Nullable Player attached; + + /** The level last reported to the sink, so an unchanged one is never sent twice. */ + private int reported = SlenderGaze.NONE; + + /** + * Creates a new instance of the {@link PageGlitchService}. + * + * @param config the configuration holding the switch and the pulse + * @param sink where the level is signalled to + * @param slender supplies the current slender, or {@code null} while there is none + */ + public PageGlitchService(GameConfig config, GazeSink sink, Supplier<@Nullable Player> slender) { + this.config = config; + this.sink = sink; + this.slender = slender; + } + + /** + * Hooks the service into the round's lifecycle. + * + * @param node the node to register on + */ + public void registerListener(EventNode node) { + node.addListener(GameStartEvent.class, event -> this.start()); + node.addListener(PageFoundEvent.class, this::onPageFound); + node.addListener(GameFinishEvent.class, event -> this.stop()); + } + + /** + * Starts a round with an untouched screen. Does nothing while the glitch is turned off. + */ + public void start() { + if (!this.config.pageGlitchEnabled()) return; + this.progress = 0.0F; + this.pulseSecondsLeft = 0; + this.task.start(TICK_SECONDS, ChronoUnit.SECONDS); + this.apply(); + } + + /** + * Stops the effect, takes it off whoever is wearing it and forgets the round. + */ + public void stop() { + this.task.stop(); + this.release(); + this.progress = 0.0F; + this.pulseSecondsLeft = 0; + } + + /** + * @return {@code true} while the service is watching a round + */ + public boolean isRunning() { + return this.task.isRunning(); + } + + /** + * Answers a find with a pulse and raises the baseline behind it. + * + * @param event the find + */ + void onPageFound(PageFoundEvent event) { + if (!this.config.pageGlitchEnabled()) return; + this.progress = shareOf(event.foundCount(), event.maxPages()); + this.pulseSecondsLeft = this.config.pageGlitchPulseSeconds(); + // apply() rather than tick(): a find is not a second passing, and running the clock here + // would cut the pulse a second short of what it was configured for. + this.apply(); + } + + /** + * Runs one second of the round: lets a running pulse decay, then draws. + */ + void tick() { + if (!this.config.pageGlitchEnabled()) return; + if (this.pulseSecondsLeft > 0) { + this.pulseSecondsLeft--; + } + this.apply(); + } + + /** + * Puts the current level on the current slender's screen, moving the effect across if the + * slender has changed. + */ + private void apply() { + if (!this.config.pageGlitchEnabled()) return; + + Player currentSlender = this.slender.get(); + if (currentSlender == null) { + this.release(); + return; + } + + if (!currentSlender.equals(this.attached)) { + // A hand-over, from SlenderReviveEvent. The old slender is a survivor's problem now and + // must not keep a torn screen; the round's state follows whoever took his place. + this.release(); + // Without the world tint: the veil tells him how far the round has got, while darkening + // the world on top of it would take the sight he hunts with. + this.sink.attach(currentSlender, false); + this.attached = currentSlender; + } + + int level = this.level(); + if (level == this.reported) return; + this.reported = level; + this.sink.level(currentSlender, level); + } + + /** + * Takes the effect off whoever is wearing it, if anybody is. + */ + private void release() { + if (this.attached == null) return; + this.sink.detach(this.attached); + this.attached = null; + this.reported = SlenderGaze.NONE; + } + + /** + * Works out the level his screen sits at right now. + * + * @return a level between {@code 0} and {@link SlenderGaze#LEVELS} minus one, or + * {@link SlenderGaze#NONE} while no page has been found + */ + private int level() { + if (this.progress <= 0.0F) { + // A pulse cannot fire before a find, so there is no baseline to raise here. + return SlenderGaze.NONE; + } + + int baseline = Math.round(this.progress * (SlenderGaze.LEVELS - 1)); + if (this.pulseSecondsLeft <= 0) return baseline; + return Math.min(baseline + 1, SlenderGaze.LEVELS - 1); + } + + /** + * Works out how far along a round is. + * + * @param foundCount how many pages are gone + * @param maxPages how many the round needs, {@code 0} or less counting as done + * @return the share, between {@code 0} and {@code 1} + */ + private static float shareOf(int foundCount, int maxPages) { + if (maxPages <= 0) return 1.0F; + return Math.clamp((float) foundCount / maxPages, 0.0F, 1.0F); + } +} diff --git a/game/src/main/java/net/onelitefeather/cygnus/glitch/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/glitch/package-info.java new file mode 100644 index 00000000..90f3bfb4 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/glitch/package-info.java @@ -0,0 +1,6 @@ +/** + * Holds the glitch the slender's own screen picks up as his pages are taken. + * + * @since 2.15.0 + */ +package net.onelitefeather.cygnus.glitch; diff --git a/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java b/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java deleted file mode 100644 index ec07ede6..00000000 --- a/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java +++ /dev/null @@ -1,208 +0,0 @@ -package net.onelitefeather.cygnus.noise; - -import net.kyori.adventure.sound.Sound; -import net.minestom.server.entity.Player; -import net.minestom.server.event.Event; -import net.minestom.server.event.EventNode; -import net.minestom.server.sound.SoundEvent; -import net.onelitefeather.cygnus.common.config.GameConfig; -import net.onelitefeather.cygnus.common.page.event.PageFoundEvent; -import net.onelitefeather.cygnus.event.GameFinishEvent; -import net.onelitefeather.cygnus.event.GameStartEvent; -import net.onelitefeather.cygnus.utils.RepeatingTask; -import org.jetbrains.annotations.Nullable; - -import java.time.temporal.ChronoUnit; -import java.util.function.Supplier; - -/** - * Lets the slender hear the round slipping away from him. - * - *

    He is the only player with no page counter in front of him: the survivors watch theirs go up, - * he only ever learns the score when the round ends. This service is that counter, told as sound - * rather than as a number - a tape hiss that sits closer together and louder the fewer pages are - * left, the way the static in Slender: The Eight Pages tightens with every page.

    - * - *

    Two things carry it. A carpet runs for as long as the round does and closes the gap between - * its bursts from {@link GameConfig#slenderStaticQuietInterval()} down to - * {@link GameConfig#slenderStaticFranticInterval()} as the pages disappear. On top of it, every - * single find lands as a burst of its own, so he knows a page went the moment it went instead of - * on the next beat of the carpet.

    - * - *

    Everything is played with {@link Sound.Emitter#self()} on the slender's own entity, so it - * reaches nobody else and carries no direction - a survivor must not be able to hear how close the - * slender is to a page, and the slender must not be able to place himself by it either.

    - * - *

    Usage:

    - *
    {@code
    - * SlenderStaticService service = new SlenderStaticService(config, () -> currentSlender);
    - * service.registerListener(eventNode);
    - * }
    - * - * @author TheMeinerLP - * @version 1.0.0 - * @since 2.14.0 - */ -public final class SlenderStaticService { - - /** How often the carpet is looked at, in seconds. */ - private static final long TICK_SECONDS = 1L; - - /** The pitch the static plays at while no page has been found. */ - private static final float FRESH_PITCH = 1.0F; - - /** The pitch it has dropped to once every page is gone: a tape that has been running too long. */ - private static final float WORN_PITCH = 0.7F; - - private final GameConfig config; - private final Supplier<@Nullable Player> slender; - private final SoundEvent sound; - private final RepeatingTask task = new RepeatingTask(this::tick); - - /** How far the round has got, between {@code 0} and {@code 1}. */ - private float progress; - - /** Seconds left until the carpet plays again. */ - private int secondsUntilBurst; - - /** - * Creates a new instance of the {@link SlenderStaticService}. - * - * @param config the configuration holding the sound, the intervals and the volumes - * @param slender supplies the current slender, or {@code null} while there is none - */ - public SlenderStaticService(GameConfig config, Supplier<@Nullable Player> slender) { - this.config = config; - this.slender = slender; - this.sound = SoundEvent.of(config.slenderStaticSound(), null); - this.secondsUntilBurst = this.interval(); - } - - /** - * Hooks the service into the round's lifecycle. - * - * @param node the node to register on - */ - public void registerListener(EventNode node) { - node.addListener(GameStartEvent.class, event -> this.start()); - node.addListener(PageFoundEvent.class, this::onPageFound); - node.addListener(GameFinishEvent.class, event -> this.stop()); - } - - /** - * Starts the carpet from the top. Does nothing while the static is turned off. - */ - public void start() { - if (!this.config.slenderStaticEnabled()) return; - this.reset(); - this.task.start(TICK_SECONDS, ChronoUnit.SECONDS); - } - - /** - * Stops the carpet and forgets how far the round had got. - */ - public void stop() { - this.task.stop(); - this.reset(); - } - - /** - * @return {@code true} while the carpet is running - */ - public boolean isRunning() { - return this.task.isRunning(); - } - - /** - * Answers a find with a burst and tightens the carpet behind it. - * - * @param event the find - */ - void onPageFound(PageFoundEvent event) { - if (!this.config.slenderStaticEnabled()) return; - this.progress = shareOf(event.foundCount(), event.maxPages()); - // The burst restarts the gap so it does not land on top of the carpet's next beat, which - // would read as one long noise rather than as two separate things happening. - this.secondsUntilBurst = this.interval(); - this.play(); - } - - /** - * Runs one second of the carpet. - */ - void tick() { - if (--this.secondsUntilBurst > 0) return; - this.secondsUntilBurst = this.interval(); - this.play(); - } - - /** - * Plays the static to the current slender, unless the feature is off or there is no slender to - * play it to. - */ - private void play() { - if (!this.config.slenderStaticEnabled()) return; - Player currentSlender = this.slender.get(); - if (currentSlender == null) return; - currentSlender.playSound( - Sound.sound(this.sound, Sound.Source.MASTER, this.volume(), this.pitch()), - Sound.Emitter.self() - ); - } - - /** - * Puts the carpet back to how a round starts. - */ - private void reset() { - this.progress = 0.0F; - this.secondsUntilBurst = this.interval(); - } - - /** - * @return the seconds between two bursts at the current progress - */ - private int interval() { - return Math.round(lerp( - this.config.slenderStaticQuietInterval(), - this.config.slenderStaticFranticInterval(), - this.progress)); - } - - /** - * @return the volume of a burst at the current progress - */ - private float volume() { - return lerp(this.config.slenderStaticMinVolume(), this.config.slenderStaticMaxVolume(), this.progress); - } - - /** - * @return the pitch of a burst at the current progress - */ - private float pitch() { - return lerp(FRESH_PITCH, WORN_PITCH, this.progress); - } - - /** - * Works out how far along a round is. - * - * @param foundCount how many pages are gone - * @param maxPages how many the round needs, {@code 0} or less counting as done - * @return the share, between {@code 0} and {@code 1} - */ - private static float shareOf(int foundCount, int maxPages) { - if (maxPages <= 0) return 1.0F; - return Math.clamp((float) foundCount / maxPages, 0.0F, 1.0F); - } - - /** - * Reads a value off the line between two ends. - * - * @param start the value at {@code share} 0 - * @param end the value at {@code share} 1 - * @param share where between the two to read - * @return the value at that point - */ - private static float lerp(float start, float end, float share) { - return start + (end - start) * share; - } -} diff --git a/game/src/main/java/net/onelitefeather/cygnus/noise/package-info.java b/game/src/main/java/net/onelitefeather/cygnus/noise/package-info.java deleted file mode 100644 index eb839730..00000000 --- a/game/src/main/java/net/onelitefeather/cygnus/noise/package-info.java +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Holds the static the slender hears while the survivors take his pages away. - * - * @since 2.14.0 - */ -package net.onelitefeather.cygnus.noise; diff --git a/game/src/test/java/net/onelitefeather/cygnus/gaze/BossBarGazeSignalTest.java b/game/src/test/java/net/onelitefeather/cygnus/gaze/BossBarGazeSignalTest.java index 36f9c1c9..841f0fff 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/gaze/BossBarGazeSignalTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/gaze/BossBarGazeSignalTest.java @@ -135,6 +135,21 @@ void levelCarriesBothChannels(Env env) { assertFalse(signal.barOf(survivor).hasFlag(BossBar.Flag.DARKEN_SCREEN)); } + @Test + @DisplayName("A player attached without the world tint gets the veil and nothing else") + void withoutTheWorldTintOnlyTheVeilIsSent(Env env) { + BossBarGazeSignal signal = new BossBarGazeSignal(); + Player slender = connect(env, env.createFlatInstance()); + signal.attach(slender, false); + + signal.level(slender, 2); + + assertEquals(BossBarGazeSignal.SIGNAL_BASE + 3, colourOf(signal.barOf(slender)), + "the veil is the effect and has to arrive either way"); + assertFalse(signal.barOf(slender).hasFlag(BossBar.Flag.DARKEN_SCREEN), + "darkening the world would take the slender's sight, which is his whole advantage"); + } + /** * Reads the encoded colour back out of a bar's title. * diff --git a/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeServiceTest.java index 38a8ad6a..4256856b 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeServiceTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/gaze/SlenderGazeServiceTest.java @@ -10,6 +10,7 @@ import net.onelitefeather.cygnus.CygnusPlayerTestBase; import net.onelitefeather.cygnus.event.GameFinishEvent; import net.onelitefeather.cygnus.event.GameStartEvent; +import net.onelitefeather.cygnus.event.SlenderReviveEvent; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -272,6 +273,23 @@ public void level(Player survivor, int level) { } } + @Test + @DisplayName("A survivor promoted to slender stops being tracked") + void aPromotedSurvivorIsDropped(Env env) { + 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(GazeSink.NONE, GAZE, () -> slender); + service.registerListener(env.process().eventHandler(), () -> Set.of(survivor)); + EventDispatcher.call(new GameStartEvent()); + + EventDispatcher.call(new SlenderReviveEvent(survivor)); + service.tick(); + + assertEquals(SlenderGaze.NONE, service.levelOf(survivor), + "the new slender would otherwise measure himself, land at distance zero and sit at the worst level for the rest of the round"); + } + /** * Connects a player at the given position. * diff --git a/game/src/test/java/net/onelitefeather/cygnus/glitch/PageGlitchServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/glitch/PageGlitchServiceTest.java new file mode 100644 index 00000000..bcb0e03a --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/glitch/PageGlitchServiceTest.java @@ -0,0 +1,326 @@ +package net.onelitefeather.cygnus.glitch; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Player; +import net.minestom.server.event.EventDispatcher; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.TestConnection; +import net.onelitefeather.cygnus.CygnusPlayerTestBase; +import net.onelitefeather.cygnus.common.config.GameConfig; +import net.onelitefeather.cygnus.common.page.event.PageFoundEvent; +import net.onelitefeather.cygnus.event.GameFinishEvent; +import net.onelitefeather.cygnus.event.GameStartEvent; +import net.onelitefeather.cygnus.gaze.GazeSink; +import net.onelitefeather.cygnus.gaze.SlenderGaze; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the glitch the slender's own screen picks up as his pages are taken. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.15.0 + */ +class PageGlitchServiceTest extends CygnusPlayerTestBase { + + /** How many pages a round needs in every test that does not say otherwise. */ + private static final int PAGES = 4; + + /** How long a find's pulse holds, in seconds. */ + private static final int PULSE_SECONDS = 2; + + /** The strongest level there is. */ + private static final int WORST = SlenderGaze.LEVELS - 1; + + @Test + @DisplayName("The slender's screen is clean until the first page goes") + void beforeTheFirstPageTheScreenIsClean(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance); + RecordingSink sink = new RecordingSink(); + PageGlitchService service = service(config(true), sink, slender); + service.registerListener(env.process().eventHandler()); + + EventDispatcher.call(new GameStartEvent()); + service.tick(); + + assertEquals(List.of(slender), sink.attached, "he has to be attached, ready for a level"); + assertEquals(List.of(), sink.levels, "an untouched round is not worth a single frame of tearing"); + + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("The last page leaves his screen at its worst") + void theLastPageIsTheWorstItGets(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance); + RecordingSink sink = new RecordingSink(); + PageGlitchService service = service(config(true), sink, slender); + service.registerListener(env.process().eventHandler()); + EventDispatcher.call(new GameStartEvent()); + + EventDispatcher.call(new PageFoundEvent(slender, PAGES, PAGES)); + tick(service, PULSE_SECONDS); + + assertEquals(WORST, sink.lastLevel(), "every page gone has to read as the worst level there is"); + + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("A find pulses one level above where the round stands") + void aFindPulsesAboveTheBaseline(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance); + RecordingSink sink = new RecordingSink(); + PageGlitchService service = service(config(true), sink, slender); + service.registerListener(env.process().eventHandler()); + EventDispatcher.call(new GameStartEvent()); + + EventDispatcher.call(new PageFoundEvent(slender, 1, PAGES)); + + int pulse = sink.lastLevel(); + tick(service, PULSE_SECONDS); + int baseline = sink.lastLevel(); + + assertTrue(pulse > baseline, "the moment a page goes has to hit harder than the state it leaves behind"); + + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("The pulse holds for the configured seconds") + void thePulseHoldsForItsSeconds(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance); + RecordingSink sink = new RecordingSink(); + PageGlitchService service = service(config(true), sink, slender); + service.registerListener(env.process().eventHandler()); + EventDispatcher.call(new GameStartEvent()); + EventDispatcher.call(new PageFoundEvent(slender, 1, PAGES)); + int pulse = sink.lastLevel(); + + tick(service, PULSE_SECONDS - 1); + assertEquals(pulse, sink.lastLevel(), "the pulse must not fall off early"); + tick(service, 1); + + assertTrue(sink.lastLevel() < pulse, "once its seconds are up the pulse has to fall back"); + + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("An unchanged level is never sent twice") + void anUnchangedLevelIsNeverRepeated(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance); + RecordingSink sink = new RecordingSink(); + PageGlitchService service = service(config(true), sink, slender); + service.registerListener(env.process().eventHandler()); + EventDispatcher.call(new GameStartEvent()); + EventDispatcher.call(new PageFoundEvent(slender, PAGES, PAGES)); + + tick(service, PULSE_SECONDS + 5); + + assertEquals(sink.levels.stream().distinct().toList(), sink.levels, + "a sink is only ever told about a change, repeating one costs the client for nothing"); + + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("A new slender takes the glitch over from the old one") + void aNewSlenderTakesItOver(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player first = env.createPlayer(instance); + Player second = env.createPlayer(instance, new Pos(4, 40, 0)); + AtomicReference slender = new AtomicReference<>(first); + RecordingSink sink = new RecordingSink(); + PageGlitchService service = new PageGlitchService(config(true), sink, slender::get); + service.registerListener(env.process().eventHandler()); + EventDispatcher.call(new GameStartEvent()); + EventDispatcher.call(new PageFoundEvent(first, PAGES, PAGES)); + + slender.set(second); + service.tick(); + + assertEquals(List.of(first), sink.detached, "the old slender must not keep a screen full of tearing"); + assertEquals(List.of(first, second), sink.attached); + assertSame(second, sink.lastPlayer(), "the round's state belongs to whoever is the slender now"); + assertEquals(WORST, sink.lastLevel(), "the pages stay found across a hand-over"); + + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("The end of the round takes the glitch off his screen") + void theEndOfTheRoundClearsHisScreen(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance); + RecordingSink sink = new RecordingSink(); + PageGlitchService service = service(config(true), sink, slender); + service.registerListener(env.process().eventHandler()); + EventDispatcher.call(new GameStartEvent()); + EventDispatcher.call(new PageFoundEvent(slender, PAGES, PAGES)); + + EventDispatcher.call(new GameFinishEvent(GameFinishEvent.Reason.ALL_PAGES_FOUND)); + + assertEquals(List.of(slender), sink.detached); + sink.levels.clear(); + EventDispatcher.call(new GameStartEvent()); + service.tick(); + assertEquals(List.of(), sink.levels, "a fresh round starts with the pages unfound again"); + + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("A turned off feature never touches his screen") + void aTurnedOffFeatureStaysAway(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance); + RecordingSink sink = new RecordingSink(); + PageGlitchService service = service(config(false), sink, slender); + service.registerListener(env.process().eventHandler()); + + EventDispatcher.call(new GameStartEvent()); + EventDispatcher.call(new PageFoundEvent(slender, PAGES, PAGES)); + tick(service, PULSE_SECONDS + 1); + + assertEquals(List.of(), sink.attached); + assertEquals(List.of(), sink.levels); + + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("Without a slender there is no screen to tear") + void withoutASlenderNothingHappens(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + RecordingSink sink = new RecordingSink(); + PageGlitchService service = new PageGlitchService(config(true), sink, () -> null); + service.registerListener(env.process().eventHandler()); + + EventDispatcher.call(new GameStartEvent()); + tick(service, PULSE_SECONDS + 1); + + assertEquals(List.of(), sink.attached); + assertEquals(List.of(), sink.levels); + + env.destroyInstance(instance, true); + } + + @Test + @DisplayName("The slender keeps his sight: the veil comes without the world tint") + void theWorldIsNeverDarkenedForHim(@NotNull Env env) { + Instance instance = env.createFlatInstance(); + Player slender = env.createPlayer(instance); + RecordingSink sink = new RecordingSink(); + PageGlitchService service = service(config(true), sink, slender); + service.registerListener(env.process().eventHandler()); + + EventDispatcher.call(new GameStartEvent()); + + assertEquals(List.of(false), sink.worldTints, + "darkening the world would blind the hunter, which is the one thing he must not lose"); + + env.destroyInstance(instance, true); + } + + /** + * Runs the service's second by hand, so a test does not have to wait for the scheduler. + * + * @param service the service under test + * @param seconds how many seconds to run + */ + private static void tick(PageGlitchService service, int seconds) { + for (int i = 0; i < seconds; i++) { + service.tick(); + } + } + + /** + * Builds a service for a slender who never changes. + * + * @param config the configuration to run with + * @param sink where the levels go + * @param slender the slender + * @return the service under test + */ + private static PageGlitchService service(GameConfig config, GazeSink sink, Player slender) { + return new PageGlitchService(config, sink, () -> slender); + } + + /** + * Builds a configuration that only says something about the glitch. + * + * @param enabled whether the glitch is on + * @return the configuration + */ + private static GameConfig config(boolean enabled) { + return GameConfig.builder() + .pageGlitchEnabled(enabled) + .pageGlitchPulseSeconds(PULSE_SECONDS) + .build(); + } + + /** + * A sink that writes down what it was told instead of drawing anything. + */ + private static final class RecordingSink implements GazeSink { + + private final List attached = new ArrayList<>(); + private final List worldTints = new ArrayList<>(); + private final List detached = new ArrayList<>(); + private final List levels = new ArrayList<>(); + private final List players = new ArrayList<>(); + + @Override + public void attach(Player player) { + this.attach(player, true); + } + + @Override + public void attach(Player player, boolean worldTint) { + this.attached.add(player); + this.worldTints.add(worldTint); + } + + @Override + public void detach(Player player) { + this.detached.add(player); + } + + @Override + public void level(Player player, int level) { + this.players.add(player); + this.levels.add(level); + } + + /** + * @return the level reported last, or {@link SlenderGaze#NONE} if there was none + */ + private int lastLevel() { + return this.levels.isEmpty() ? SlenderGaze.NONE : this.levels.getLast(); + } + + /** + * @return the player the last level was reported for + */ + private Player lastPlayer() { + return this.players.getLast(); + } + } +} diff --git a/game/src/test/java/net/onelitefeather/cygnus/noise/SlenderStaticServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/noise/SlenderStaticServiceTest.java deleted file mode 100644 index 7f11ee4d..00000000 --- a/game/src/test/java/net/onelitefeather/cygnus/noise/SlenderStaticServiceTest.java +++ /dev/null @@ -1,280 +0,0 @@ -package net.onelitefeather.cygnus.noise; - -import net.kyori.adventure.key.Key; -import net.minestom.server.entity.Player; -import net.minestom.server.event.EventDispatcher; -import net.minestom.server.instance.Instance; -import net.minestom.server.network.packet.server.play.EntitySoundEffectPacket; -import net.minestom.testing.Collector; -import net.minestom.testing.Env; -import net.minestom.testing.TestConnection; -import net.onelitefeather.cygnus.CygnusPlayerTestBase; -import net.onelitefeather.cygnus.common.config.GameConfig; -import net.onelitefeather.cygnus.common.page.event.PageFoundEvent; -import net.onelitefeather.cygnus.event.GameFinishEvent; -import net.onelitefeather.cygnus.event.GameStartEvent; -import org.jetbrains.annotations.NotNull; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.util.function.Supplier; - -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 the static the slender hears while the survivors take his pages away. - * - * @author TheMeinerLP - * @version 1.0.0 - * @since 2.14.0 - */ -class SlenderStaticServiceTest extends CygnusPlayerTestBase { - - /** How many pages a round needs in every test that does not say otherwise. */ - private static final int PAGES = 4; - - /** Seconds between two bursts while no page has been found yet. */ - private static final int QUIET_INTERVAL = 8; - - /** Seconds between two bursts once every page is gone. */ - private static final int FRANTIC_INTERVAL = 2; - - private static final float MIN_VOLUME = 0.2F; - private static final float MAX_VOLUME = 0.8F; - - @Test - @DisplayName("Before a page is found the static comes in the long interval") - void beforeAPageIsFoundTheStaticComesSlowly(@NotNull Env env) { - Instance instance = env.createFlatInstance(); - TestConnection connection = env.createConnection(); - Player slender = connection.connect(instance); - SlenderStaticService service = service(config(true), () -> slender); - service.registerListener(env.process().eventHandler()); - EventDispatcher.call(new GameStartEvent()); - Collector beforeTheGapIsUp = connection.trackIncoming(EntitySoundEffectPacket.class); - - tick(service, QUIET_INTERVAL - 1); - // A collector stops tracking the moment it is read, so the second half of the test needs - // one of its own. - beforeTheGapIsUp.assertEmpty(); - Collector onTheLastSecond = connection.trackIncoming(EntitySoundEffectPacket.class); - tick(service, 1); - - onTheLastSecond.assertSingle(packet -> - assertEquals(slender.getEntityId(), packet.entityId(), "the static sits in the slender's own head")); - - env.destroyInstance(instance, true); - } - - @Test - @DisplayName("Every found page shortens the gap between two bursts") - void everyFoundPageShortensTheGap(@NotNull Env env) { - Instance instance = env.createFlatInstance(); - TestConnection connection = env.createConnection(); - Player slender = connection.connect(instance); - SlenderStaticService service = service(config(true), () -> slender); - service.registerListener(env.process().eventHandler()); - EventDispatcher.call(new GameStartEvent()); - EventDispatcher.call(new PageFoundEvent(slender, PAGES, PAGES)); - Collector sounds = connection.trackIncoming(EntitySoundEffectPacket.class); - - tick(service, FRANTIC_INTERVAL); - - sounds.assertSingle(packet -> - assertEquals(MAX_VOLUME, packet.volume(), "the last page has to be as loud as it gets")); - - env.destroyInstance(instance, true); - } - - @Test - @DisplayName("A found page hits the slender with a burst right away") - void aFoundPageBurstsImmediately(@NotNull Env env) { - Instance instance = env.createFlatInstance(); - TestConnection connection = env.createConnection(); - Player slender = connection.connect(instance); - SlenderStaticService service = service(config(true), () -> slender); - service.registerListener(env.process().eventHandler()); - EventDispatcher.call(new GameStartEvent()); - Collector sounds = connection.trackIncoming(EntitySoundEffectPacket.class); - - EventDispatcher.call(new PageFoundEvent(slender, 1, PAGES)); - - sounds.assertSingle(packet -> assertTrue(packet.volume() > MIN_VOLUME, - "the burst has to stand out against the carpet it interrupts")); - - env.destroyInstance(instance, true); - } - - @Test - @DisplayName("A burst restarts the gap, so it never lands on top of the carpet") - void aBurstRestartsTheGap(@NotNull Env env) { - Instance instance = env.createFlatInstance(); - TestConnection connection = env.createConnection(); - Player slender = connection.connect(instance); - SlenderStaticService service = service(config(true), () -> slender); - service.registerListener(env.process().eventHandler()); - EventDispatcher.call(new GameStartEvent()); - tick(service, QUIET_INTERVAL - 1); - EventDispatcher.call(new PageFoundEvent(slender, 1, PAGES)); - Collector sounds = connection.trackIncoming(EntitySoundEffectPacket.class); - - tick(service, 1); - - sounds.assertEmpty(); - - env.destroyInstance(instance, true); - } - - @Test - @DisplayName("Nobody but the slender hears the static") - void nobodyElseHearsTheStatic(@NotNull Env env) { - Instance instance = env.createFlatInstance(); - TestConnection slenderConnection = env.createConnection(); - Player slender = slenderConnection.connect(instance); - TestConnection survivorConnection = env.createConnection(); - survivorConnection.connect(instance); - SlenderStaticService service = service(config(true), () -> slender); - service.registerListener(env.process().eventHandler()); - EventDispatcher.call(new GameStartEvent()); - Collector survivorSounds = - survivorConnection.trackIncoming(EntitySoundEffectPacket.class); - - EventDispatcher.call(new PageFoundEvent(slender, 1, PAGES)); - tick(service, QUIET_INTERVAL); - - survivorSounds.assertEmpty(); - - env.destroyInstance(instance, true); - } - - @Test - @DisplayName("A turned off feature stays silent") - void aTurnedOffFeatureStaysSilent(@NotNull Env env) { - Instance instance = env.createFlatInstance(); - TestConnection connection = env.createConnection(); - Player slender = connection.connect(instance); - SlenderStaticService service = service(config(false), () -> slender); - service.registerListener(env.process().eventHandler()); - EventDispatcher.call(new GameStartEvent()); - Collector sounds = connection.trackIncoming(EntitySoundEffectPacket.class); - - EventDispatcher.call(new PageFoundEvent(slender, PAGES, PAGES)); - tick(service, QUIET_INTERVAL); - - sounds.assertEmpty(); - assertFalse(service.isRunning(), "a turned off feature must not even schedule a task"); - - env.destroyInstance(instance, true); - } - - @Test - @DisplayName("The end of the round stops the static and forgets the progress") - void theEndOfTheRoundStopsTheStatic(@NotNull Env env) { - Instance instance = env.createFlatInstance(); - TestConnection connection = env.createConnection(); - Player slender = connection.connect(instance); - SlenderStaticService service = service(config(true), () -> slender); - service.registerListener(env.process().eventHandler()); - EventDispatcher.call(new GameStartEvent()); - EventDispatcher.call(new PageFoundEvent(slender, PAGES, PAGES)); - - EventDispatcher.call(new GameFinishEvent(GameFinishEvent.Reason.ALL_PAGES_FOUND)); - assertFalse(service.isRunning(), "the task has to be gone once the round is over"); - - EventDispatcher.call(new GameStartEvent()); - Collector sounds = connection.trackIncoming(EntitySoundEffectPacket.class); - tick(service, FRANTIC_INTERVAL); - - sounds.assertEmpty(); - - env.destroyInstance(instance, true); - } - - @Test - @DisplayName("Without a slender there is nobody to play to") - void withoutASlenderNothingIsPlayed(@NotNull Env env) { - Instance instance = env.createFlatInstance(); - TestConnection connection = env.createConnection(); - connection.connect(instance); - SlenderStaticService service = service(config(true), () -> null); - service.registerListener(env.process().eventHandler()); - EventDispatcher.call(new GameStartEvent()); - Collector sounds = connection.trackIncoming(EntitySoundEffectPacket.class); - - tick(service, QUIET_INTERVAL); - - sounds.assertEmpty(); - - env.destroyInstance(instance, true); - } - - @Test - @DisplayName("A resource pack sound is sent as named instead of being dropped") - void aResourcePackSoundIsSentAsNamed(@NotNull Env env) { - Instance instance = env.createFlatInstance(); - TestConnection connection = env.createConnection(); - Player slender = connection.connect(instance); - Key packSound = Key.key("cygnus", "vhs_static"); - GameConfig config = GameConfig.builder() - .slenderStaticEnabled(true) - .slenderStaticSound(packSound) - .slenderStaticQuietInterval(QUIET_INTERVAL) - .slenderStaticFranticInterval(FRANTIC_INTERVAL) - .slenderStaticMinVolume(MIN_VOLUME) - .slenderStaticMaxVolume(MAX_VOLUME) - .build(); - SlenderStaticService service = service(config, () -> slender); - service.registerListener(env.process().eventHandler()); - EventDispatcher.call(new GameStartEvent()); - Collector sounds = connection.trackIncoming(EntitySoundEffectPacket.class); - - EventDispatcher.call(new PageFoundEvent(slender, 1, PAGES)); - - sounds.assertSingle(packet -> assertEquals(packSound, packet.soundEvent().key(), - "a sound that only exists in the resource pack must reach the client under its own name")); - - env.destroyInstance(instance, true); - } - - /** - * Runs the service's second by hand, so a test does not have to wait for the scheduler. - * - * @param service the service under test - * @param seconds how many seconds to run - */ - private static void tick(SlenderStaticService service, int seconds) { - for (int i = 0; i < seconds; i++) { - service.tick(); - } - } - - /** - * Builds a service for the given slender. - * - * @param config the configuration to run with - * @param slender supplies the current slender - * @return the service under test - */ - private static SlenderStaticService service(GameConfig config, Supplier slender) { - return new SlenderStaticService(config, slender); - } - - /** - * Builds a configuration that only says something about the static. - * - * @param enabled whether the static is on - * @return the configuration - */ - private static GameConfig config(boolean enabled) { - return GameConfig.builder() - .slenderStaticEnabled(enabled) - .slenderStaticSound(Key.key("weather.rain")) - .slenderStaticQuietInterval(QUIET_INTERVAL) - .slenderStaticFranticInterval(FRANTIC_INTERVAL) - .slenderStaticMinVolume(MIN_VOLUME) - .slenderStaticMaxVolume(MAX_VOLUME) - .build(); - } -}