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 @@ *

  • 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..14fd558e 100644 --- a/config.properties.example +++ b/config.properties.example @@ -148,3 +148,39 @@ 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. +# +# 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 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(); + } +}