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 0be54cd3..eb9d4ca3 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 @@ -87,6 +87,41 @@ 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. + *
+ * 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. + *
+ */ + 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; + + /** + * 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. + */ + 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; + /** * The largest {@link #glitchRange()} a configuration may ask for. Beyond this the slender would * tear a survivor's view apart from across the map, which is the behaviour this range exists to @@ -298,6 +333,62 @@ 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. + *+ * 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. + *
+ * + * @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 + */ + float slenderStaticMinVolume(); + + /** + * Returns how loud the static is once every page is gone. + * + * @return the volume, at most 1 + * @since 2.14.0 + */ + float slenderStaticMaxVolume(); + /** * Returns how close the slender has to be before the sight of him tears a survivor's view. *@@ -504,6 +595,65 @@ 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. + * + * @param slenderStaticMinVolume the volume + * @return the builder instance + * @throws IllegalArgumentException if the volume is below 0 or above 1 + * @since 2.14.0 + */ + Builder slenderStaticMinVolume(float slenderStaticMinVolume); + + /** + * Sets how loud the static is once every page is gone. + * + * @param slenderStaticMaxVolume the volume + * @return the builder instance + * @throws IllegalArgumentException if the volume is below 0 or above 1 + * @since 2.14.0 + */ + Builder slenderStaticMaxVolume(float slenderStaticMaxVolume); + /** * 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 8af0eed5..8771919f 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,6 +38,15 @@ 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; @Override public GameConfig.Builder minPlayers(int minPlayers) { @@ -197,6 +206,67 @@ public GameConfig.Builder glitchViewAngle(int glitchViewAngle) { return this; } + @Override + public GameConfig.Builder slenderStaticEnabled(boolean slenderStaticEnabled) { + this.slenderStaticEnabled = slenderStaticEnabled; + 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) { + 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"); + } + 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"); + 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} *
@@ -215,6 +285,16 @@ 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, @@ -235,7 +315,13 @@ public GameConfig build() { damageSound, glitchRange, glitchCloseRange, - glitchViewAngle + glitchViewAngle, + slenderStaticEnabled, + slenderStaticSound, + slenderStaticQuietInterval, + slenderStaticFranticInterval, + slenderStaticMinVolume, + slenderStaticMaxVolume ); } } 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 353d5093..19ff84a4 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,6 +33,12 @@ * @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 * @author theEvilReaper * @version 1.4.0 * @since 1.0.0 @@ -57,7 +63,13 @@ public record GameConfigImpl( Key damageSound, int glitchRange, int glitchCloseRange, - int glitchViewAngle + int glitchViewAngle, + boolean slenderStaticEnabled, + Key slenderStaticSound, + int slenderStaticQuietInterval, + int slenderStaticFranticInterval, + float slenderStaticMinVolume, + float slenderStaticMaxVolume ) 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 b600d312..0edd1b8a 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,6 +40,12 @@ *
* If a property can not be found in the file, the default value will be used. @@ -59,6 +65,7 @@ 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; @@ -120,7 +127,13 @@ public GameConfig getConfig() { .damageSound(getSound(properties, DAMAGE_SOUND_KEY, internal.damageSound())) .glitchRange(getInt(properties, "glitchRange", internal.glitchRange())) .glitchCloseRange(getInt(properties, "glitchCloseRange", internal.glitchCloseRange())) - .glitchViewAngle(getInt(properties, "glitchViewAngle", internal.glitchViewAngle())); + .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())); 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 9bd38669..121c0d5f 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,6 +33,12 @@ * @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 * @author theEvilReaper * @version 1.4.0 * @since 1.0.0 @@ -57,7 +63,13 @@ record InternalGameConfig( Key damageSound, int glitchRange, int glitchCloseRange, - int glitchViewAngle + int glitchViewAngle, + boolean slenderStaticEnabled, + Key slenderStaticSound, + int slenderStaticQuietInterval, + int slenderStaticFranticInterval, + float slenderStaticMinVolume, + float slenderStaticMaxVolume ) implements GameConfig { // Sentry and the ResourcePack are opt-in: a service that says nothing about them reports to @@ -67,6 +79,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. private static final GameConfig DEFAULT = new InternalGameConfig( 2, 13, 30, 900, 1, 12, null, null, null, true, 20, 20, GameConfig.DEFAULT_PAGE_PROXIMITY_SOUND, @@ -74,7 +88,12 @@ record InternalGameConfig( true, 20, GameConfig.DEFAULT_DAMAGE_SOUND, GameConfig.DEFAULT_GLITCH_RANGE, GameConfig.DEFAULT_GLITCH_CLOSE_RANGE, - GameConfig.DEFAULT_GLITCH_VIEW_ANGLE); + 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); /** * Returns the default configuration for the game. diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageProvider.java b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageProvider.java index f5cbd9fc..27050dd9 100644 --- a/common/src/main/java/net/onelitefeather/cygnus/common/page/PageProvider.java +++ b/common/src/main/java/net/onelitefeather/cygnus/common/page/PageProvider.java @@ -10,6 +10,7 @@ import net.minestom.server.utils.validate.Check; import net.onelitefeather.cygnus.common.Messages; import net.onelitefeather.cygnus.common.page.event.PageDiscoveryCompletedEvent; +import net.onelitefeather.cygnus.common.page.event.PageFoundEvent; import net.onelitefeather.cygnus.common.util.Helper; import net.theevilreaper.aves.util.Broadcaster; import net.theevilreaper.xerus.api.phase.GamePhase; @@ -169,6 +170,7 @@ public boolean triggerPageFound(Player player, UUID uuid) { Broadcaster.broadcast(Messages.getPageFoundComponent(player)); int foundCount = this.currentFoundedPageCount.incrementAndGet(); this.updatePageDisplay(); + EventDispatcher.call(new PageFoundEvent(player, foundCount, this.maxPageAmount)); if (foundCount >= maxPageAmount) { EventDispatcher.call(new PageDiscoveryCompletedEvent()); diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/page/event/PageFoundEvent.java b/common/src/main/java/net/onelitefeather/cygnus/common/page/event/PageFoundEvent.java new file mode 100644 index 00000000..5505c964 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/page/event/PageFoundEvent.java @@ -0,0 +1,34 @@ +package net.onelitefeather.cygnus.common.page.event; + +import net.minestom.server.entity.Player; +import net.minestom.server.event.Event; +import net.minestom.server.event.trait.PlayerEvent; + +/** + * Called every time a survivor claims a page, with the state of the round attached. + *
+ * {@link PageDiscoveryCompletedEvent} only says that the last page is gone, which is one moment in + * a round that spends the rest of its time getting worse. This event fires on every find and + * carries how far along the round is, so an effect can be scaled against it instead of counting + * finds for itself. + *
+ * + * @param finder the survivor who claimed the page + * @param foundCount how many pages have been claimed including this one, starting at {@code 1} + * @param maxPages how many pages the round needs in total + * @author TheMeinerLP + * @version 1.0.0 + * @since 2.14.0 + */ +public record PageFoundEvent(Player finder, int foundCount, int maxPages) implements Event, PlayerEvent { + + /** + * Returns the survivor who claimed the page. + * + * @return the survivor + */ + @Override + public Player getPlayer() { + return this.finder; + } +} 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 ed078c78..5468246e 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 @@ -187,6 +187,64 @@ void testARangeBeyondTheMaximumIsRejected(@TempDir Path tempDir) throws IOExcept assertThrows(IllegalArgumentException.class, reader::getConfig); } + @Test + void testSlenderStaticDefaultsWhenNothingIsConfigured() { + 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()); + } + + @Test + void testSlenderStaticValuesAreReadWhenTheyAreConfigured(@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 + """); + + 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()); + } + + @Test + void testSlenderStaticCanBeTurnedOff(@TempDir Path tempDir) throws IOException { + Files.writeString(tempDir.resolve("config.properties"), """ + minPlayers=4 + slenderStaticEnabled=false + """); + + GameConfig config = new GameConfigReader(tempDir).getConfig(); + + assertFalse(config.slenderStaticEnabled()); + } + + @Test + void testAFranticIntervalAtOrAboveTheQuietOneIsRejected(@TempDir Path tempDir) throws IOException { + Files.writeString(tempDir.resolve("config.properties"), """ + minPlayers=4 + slenderStaticQuietInterval=5 + slenderStaticFranticInterval=5 + """); + + GameConfigReader reader = new GameConfigReader(tempDir); + + assertThrows(IllegalArgumentException.class, reader::getConfig); + } + @Test void testDamageSoundDefaultsWhenNothingIsConfigured() { GameConfig config = new GameConfigReader(Paths.get("src", "test", "resources")).getConfig(); diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/page/PageProviderTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/page/PageProviderTest.java index 1ea90a7e..b417835d 100644 --- a/common/src/test/java/net/onelitefeather/cygnus/common/page/PageProviderTest.java +++ b/common/src/test/java/net/onelitefeather/cygnus/common/page/PageProviderTest.java @@ -8,6 +8,7 @@ import net.minestom.testing.Env; import net.minestom.testing.extension.MicrotusExtension; import net.onelitefeather.cygnus.common.page.event.PageDiscoveryCompletedEvent; +import net.onelitefeather.cygnus.common.page.event.PageFoundEvent; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -214,6 +215,63 @@ void testInteractablePagePositionsOnlyListsCollectiblePages(@NotNull Env env) th env.destroyInstance(instance, true); } + @Test + void testEveryFindFiresAnEventCarryingTheRunningCount(@NotNull Env env) throws Exception { + Instance instance = env.createFlatInstance(); + int pageCount = 3; + + PageProvider pageProvider = new PageProvider(); + pageProvider.loadPageData( + IntStream.range(0, pageCount) + .mapToObj(i -> new PageResource(new Pos(i, 0, 0), Direction.NORTH)) + .collect(Collectors.toSet()) + ); + pageProvider.setMaxPageAmount(pageCount); + + ListHe 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