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();
- }
-}