From 3e70e67a2efc0266eb20e7366772b1d5c3a56e65 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 6 Sep 2026 22:25:25 +0200 Subject: [PATCH 1/2] feat(slender): let him hear the pages going The slender is the only player without a page counter, so how far the round has got never reaches him until it ends. SlenderStaticService turns it into sound: a carpet whose gap shrinks from slenderStaticQuietInterval down to slenderStaticFranticInterval as the pages disappear, plus a burst of its own on every single find. Only he hears it, played on his own entity, so neither side can place the other by it. PageProvider now raises PageFoundEvent on every find with the running count. PageDiscoveryCompletedEvent only ever covered the last page, which is one moment in a round that spends the rest of its time getting worse. The configured key is sent as named instead of being resolved against the vanilla registry: a VHS noise shipped in the resource pack is the sound this wants, and SoundEvent.fromKey would drop it. Defaults to weather.rain until that key exists. --- .../cygnus/common/config/GameConfig.java | 144 +++++++++ .../common/config/GameConfigBuilder.java | 88 +++++- .../cygnus/common/config/GameConfigImpl.java | 14 +- .../common/config/GameConfigReader.java | 15 +- .../common/config/InternalGameConfig.java | 23 +- .../cygnus/common/page/PageProvider.java | 2 + .../common/page/event/PageFoundEvent.java | 34 +++ .../common/config/GameConfigReaderTest.java | 58 ++++ .../cygnus/common/page/PageProviderTest.java | 58 ++++ config.properties.example | 33 +++ .../net/onelitefeather/cygnus/Cygnus.java | 8 + .../cygnus/noise/SlenderStaticService.java | 208 +++++++++++++ .../cygnus/noise/package-info.java | 6 + .../noise/SlenderStaticServiceTest.java | 280 ++++++++++++++++++ 14 files changed, 966 insertions(+), 5 deletions(-) create mode 100644 common/src/main/java/net/onelitefeather/cygnus/common/page/event/PageFoundEvent.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java create mode 100644 game/src/main/java/net/onelitefeather/cygnus/noise/package-info.java create mode 100644 game/src/test/java/net/onelitefeather/cygnus/noise/SlenderStaticServiceTest.java diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java index 0be54cd3..e0c9b4f3 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,35 @@ 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. + *

+ * Rain, because it is the closest vanilla comes to the hiss of a worn tape or a tuned-out + * television. A real VHS noise belongs in the resource pack; once it is there, pointing + * {@code slenderStaticSound} at that key is all this needs. + *

+ */ + Key DEFAULT_SLENDER_STATIC_SOUND = Key.key("weather.rain"); + + /** 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 +327,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 +589,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 @@ *

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

    * 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); + + List entities = IntStream.range(0, pageCount) + .mapToObj(i -> new PageEntity(instance, Pos.ZERO, i + 1)) + .toList(); + seedActivePages(pageProvider, entities.toArray(new PageEntity[0])); + + Player player = env.createPlayer(instance); + + List events = Collections.synchronizedList(new ArrayList<>()); + env.process().eventHandler().addListener(PageFoundEvent.class, events::add); + + for (PageEntity entity : entities) { + pageProvider.triggerPageFound(player, entity.getHitBoxUUID()); + } + + assertEquals(List.of(1, 2, 3), events.stream().map(PageFoundEvent::foundCount).toList(), + "each find has to report how many pages are gone by now, not just that one was found"); + assertEquals(pageCount, events.getFirst().maxPages()); + assertSame(player, events.getFirst().finder()); + + env.destroyInstance(instance, true); + } + + @Test + void testAClaimOnAnUnknownPageFiresNoEvent(@NotNull Env env) throws Exception { + Instance instance = env.createFlatInstance(); + PageProvider pageProvider = new PageProvider(); + pageProvider.loadPageData(Set.of(new PageResource(Pos.ZERO, Direction.NORTH))); + pageProvider.setMaxPageAmount(2); + + PageEntity pageEntity = new PageEntity(instance, Pos.ZERO, 1); + seedActivePages(pageProvider, pageEntity); + + Player player = env.createPlayer(instance); + + AtomicInteger events = new AtomicInteger(); + env.process().eventHandler().addListener(PageFoundEvent.class, event -> events.incrementAndGet()); + + pageProvider.triggerPageFound(player, UUID.randomUUID()); + + assertEquals(0, events.get(), "a claim that finds nothing must not raise the tension"); + + env.destroyInstance(instance, true); + } + private static String plainStatus(PageProvider pageProvider) { return PlainTextComponentSerializer.plainText().serialize(pageProvider.getPageStatus()); } diff --git a/config.properties.example b/config.properties.example index e735f51f..c6a817ee 100644 --- a/config.properties.example +++ b/config.properties.example @@ -148,3 +148,36 @@ survivorTeamSize=12 # be in the middle of the screen, not at its edge. Widening this past roughly 50 # 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. +# +# Rain is the closest vanilla comes to the hiss of a worn tape. A real VHS noise +# belongs in the resource pack; once it is there, point this at that key - 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. +#slenderStaticSound=weather.rain + +# 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 diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java index 59b8f779..347cf7c0 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java +++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java @@ -44,6 +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.command.GlitchCommand; import net.onelitefeather.cygnus.command.StartCommand; import net.onelitefeather.cygnus.common.ListenerHandling; @@ -132,6 +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; public Cygnus() { Path path = ServiceBootstrap.resolveWorkingDirectory(); @@ -186,6 +188,9 @@ public Cygnus() { bound -> ThreadLocalRandom.current().nextInt(bound) ); this.damageSoundService = new DamageSoundService(this.gameConfig, System::currentTimeMillis); + this.slenderStaticService = new SlenderStaticService( + this.gameConfig, + () -> TeamHelper.slenderOf(this.teamService)); this.tunnelVisionRenderer = new OverlayTunnelVisionRenderer(this.screenOverlay); this.tunnelVisionService = new TunnelVisionService(this.tunnelVisionRenderer, player -> StaminaHelper.remainingShare(this.staminaService, player)); this.initPhases(); @@ -264,6 +269,9 @@ 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); } diff --git a/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java b/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java new file mode 100644 index 00000000..ec07ede6 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/noise/SlenderStaticService.java @@ -0,0 +1,208 @@ +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 new file mode 100644 index 00000000..eb839730 --- /dev/null +++ b/game/src/main/java/net/onelitefeather/cygnus/noise/package-info.java @@ -0,0 +1,6 @@ +/** + * 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/noise/SlenderStaticServiceTest.java b/game/src/test/java/net/onelitefeather/cygnus/noise/SlenderStaticServiceTest.java new file mode 100644 index 00000000..7f11ee4d --- /dev/null +++ b/game/src/test/java/net/onelitefeather/cygnus/noise/SlenderStaticServiceTest.java @@ -0,0 +1,280 @@ +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(); + } +} From da367c6b1de5ca7914f3f02e83922d245bb4be32 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 6 Sep 2026 22:39:17 +0200 Subject: [PATCH 2/2] feat(slender): point the static at the pack sound cygnus:vhs_static now exists: three 2.2 second takes of tape hiss, high-passed at 520 Hz so the pitch drop to 0.7 this effect applies leaves it hissing instead of humming (OneLiteFeatherNET/cygnus-pack#34). weather.rain was only ever a stand-in for it and reads as weather, not as a tape. A server without the pack now hears nothing here, which is the right way round for a horror cue. --- .../cygnus/common/config/GameConfig.java | 14 ++++++++++---- config.properties.example | 13 ++++++++----- 2 files changed, 18 insertions(+), 9 deletions(-) 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 e0c9b4f3..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 @@ -90,12 +90,18 @@ public sealed interface GameConfig permits GameConfigImpl, InternalGameConfig { /** * The static the slender hears while the survivors take his pages away. *

    - * Rain, because it is the closest vanilla comes to the hiss of a worn tape or a tuned-out - * television. A real VHS noise belongs in the resource pack; once it is there, pointing - * {@code slenderStaticSound} at that key is all this needs. + * 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("weather.rain"); + 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; diff --git a/config.properties.example b/config.properties.example index c6a817ee..14fd558e 100644 --- a/config.properties.example +++ b/config.properties.example @@ -161,11 +161,14 @@ survivorTeamSize=12 # The sound the static is built from. # -# Rain is the closest vanilla comes to the hiss of a worn tape. A real VHS noise -# belongs in the resource pack; once it is there, point this at that key - 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. -#slenderStaticSound=weather.rain +# 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