diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfig.java index eb9d4ca3..0f9ee1c3 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,15 @@ public sealed interface GameConfig permits GameConfigImpl, InternalGameConfig { */ Key DEFAULT_DAMAGE_SOUND = Key.key("entity.player.hurt"); + /** + * The {@link #lobbyAtmosphereShare()} a configuration gets when it says nothing. + *
+ * Enough of the map's own haze and colour to be recognised in the distance, far enough from it + * that the lobby still reads as the lit room players wait in rather than as the map itself. + *
+ */ + float DEFAULT_LOBBY_ATMOSPHERE_SHARE = 0.3F; + /** * The static the slender hears while the survivors take his pages away. *@@ -333,6 +342,20 @@ static Builder builder() { */ Key damageSound(); + /** + * Returns how far the lobby's atmosphere is taken from the open end towards the game map's own. + *
+ * {@code 0} leaves the lobby on the vanilla overworld, which is where it was. {@code 1} gives it + * exactly the map's atmosphere, which makes the start of a round invisible - the point of the + * setting is the distance between the two, so that walking into the round reads as the world + * closing in rather than as a cut. + *
+ * + * @return the share, between 0 and 1 + * @since 2.14.0 + */ + float lobbyAtmosphereShare(); + /** * Returns whether the slender hears static as the survivors collect his pages. * @@ -595,6 +618,16 @@ sealed interface Builder permits GameConfigBuilder { */ Builder damageSound(Key damageSound); + /** + * Sets how far the lobby's atmosphere is taken towards the game map's own. + * + * @param lobbyAtmosphereShare the share + * @return the builder instance + * @throws IllegalArgumentException if the share is below 0 or above 1 + * @since 2.14.0 + */ + Builder lobbyAtmosphereShare(float lobbyAtmosphereShare); + /** * Sets whether the slender hears static as the survivors collect his pages. * diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigBuilder.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigBuilder.java index 8771919f..a799787f 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,7 @@ 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 float lobbyAtmosphereShare = GameConfig.DEFAULT_LOBBY_ATMOSPHERE_SHARE; 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 @@ -206,6 +207,15 @@ public GameConfig.Builder glitchViewAngle(int glitchViewAngle) { return this; } + @Override + public GameConfig.Builder lobbyAtmosphereShare(float lobbyAtmosphereShare) { + if (lobbyAtmosphereShare < 0.0F || lobbyAtmosphereShare > 1.0F) { + throw new IllegalArgumentException("Lobby atmosphere share must be between 0 and 1"); + } + this.lobbyAtmosphereShare = lobbyAtmosphereShare; + return this; + } + @Override public GameConfig.Builder slenderStaticEnabled(boolean slenderStaticEnabled) { this.slenderStaticEnabled = slenderStaticEnabled; @@ -321,7 +331,8 @@ public GameConfig build() { slenderStaticQuietInterval, slenderStaticFranticInterval, slenderStaticMinVolume, - slenderStaticMaxVolume + slenderStaticMaxVolume, + lobbyAtmosphereShare ); } } diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigImpl.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigImpl.java index 19ff84a4..e49dfdc6 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,7 @@ * @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 lobbyAtmosphereShare how far the lobby's atmosphere is taken towards the map's own * @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 @@ -69,7 +70,8 @@ public record GameConfigImpl( int slenderStaticQuietInterval, int slenderStaticFranticInterval, float slenderStaticMinVolume, - float slenderStaticMaxVolume + float slenderStaticMaxVolume, + float lobbyAtmosphereShare ) implements GameConfig { } diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/GameConfigReader.java index 0edd1b8a..4d7d3d2f 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,7 @@ *This exists for the lobby. A player who walks straight from a vanilla sky into a map that + * closes in at forty blocks meets the whole atmosphere at once, at the same moment the round + * starts. Giving the lobby a weakened version of the map's own atmosphere turns that into a + * build-up: the same colours and the same haze, only far enough away to still see across the + * lobby, so the start of the round reads as the world tightening rather than as a cut.
+ * + *Every value is read off the straight line between the two ends, colours channel by channel. + * A share of {@code 0} is the open end untouched, {@code 1} is the other atmosphere exactly, and + * anything between is proportionally near each.
+ * + *The result is a {@link MapAtmosphere}, which means its own corrections apply on the way out: + * a blend that would leave the fog no span to fade over is repaired there rather than here.
+ * + *Usage:
+ *{@code
+ * DimensionAtmosphere lobby = BlendedAtmosphere.between(StaticDimensionPreset.BRIGHT, mapAtmosphere, 0.3f);
+ * }
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+public final class BlendedAtmosphere {
+
+ private BlendedAtmosphere() {
+ throw new UnsupportedOperationException();
+ }
+
+ /**
+ * Reads an atmosphere off the line between two others.
+ *
+ * @param open the atmosphere a share of {@code 0} yields
+ * @param target the atmosphere a share of {@code 1} yields
+ * @param share how far to travel from {@code open} towards {@code target}, clamped to
+ * {@code [0, 1]}
+ * @return the blended atmosphere
+ */
+ public static DimensionAtmosphere between(DimensionAtmosphere open, DimensionAtmosphere target, float share) {
+ float amount = Math.clamp(share, 0.0f, 1.0f);
+ return new MapAtmosphere(
+ mix(open.fogColor(), target.fogColor(), amount),
+ mix(open.skyLightColor(), target.skyLightColor(), amount),
+ mix(open.skyColor(), target.skyColor(), amount),
+ mix(open.ambientLightColor(), target.ambientLightColor(), amount),
+ lerp(open.skyLightFactor(), target.skyLightFactor(), amount),
+ lerp(open.fogStartDistance(), target.fogStartDistance(), amount),
+ lerp(open.fogEndDistance(), target.fogEndDistance(), amount),
+ lerp(open.skyFogEndDistance(), target.skyFogEndDistance(), amount)
+ );
+ }
+
+ /**
+ * Mixes two colours channel by channel.
+ *
+ * @param open the colour at {@code amount} 0
+ * @param target the colour at {@code amount} 1
+ * @param amount where between the two to read
+ * @return the mixed colour
+ */
+ private static Color mix(RGBLike open, RGBLike target, float amount) {
+ return new Color(
+ Math.round(lerp(open.red(), target.red(), amount)),
+ Math.round(lerp(open.green(), target.green(), amount)),
+ Math.round(lerp(open.blue(), target.blue(), amount))
+ );
+ }
+
+ /**
+ * Reads a value off the line between two ends.
+ *
+ * @param open the value at {@code amount} 0
+ * @param target the value at {@code amount} 1
+ * @param amount where between the two to read
+ * @return the value at that point
+ */
+ private static float lerp(float open, float target, float amount) {
+ return open + (target - open) * amount;
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/config/GameConfigReaderTest.java
index 5468246e..5002ec07 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
@@ -245,6 +245,37 @@ void testAFranticIntervalAtOrAboveTheQuietOneIsRejected(@TempDir Path tempDir) t
assertThrows(IllegalArgumentException.class, reader::getConfig);
}
+ @Test
+ void testLobbyAtmosphereShareDefaultsWhenNothingIsConfigured() {
+ GameConfig config = new GameConfigReader(Paths.get("src", "test", "resources")).getConfig();
+
+ assertEquals(GameConfig.DEFAULT_LOBBY_ATMOSPHERE_SHARE, config.lobbyAtmosphereShare());
+ }
+
+ @Test
+ void testLobbyAtmosphereShareIsReadWhenItIsConfigured(@TempDir Path tempDir) throws IOException {
+ Files.writeString(tempDir.resolve("config.properties"), """
+ minPlayers=4
+ lobbyAtmosphereShare=0.6
+ """);
+
+ GameConfig config = new GameConfigReader(tempDir).getConfig();
+
+ assertEquals(0.6F, config.lobbyAtmosphereShare());
+ }
+
+ @Test
+ void testALobbyAtmosphereShareOutsideItsRangeIsRejected(@TempDir Path tempDir) throws IOException {
+ Files.writeString(tempDir.resolve("config.properties"), """
+ minPlayers=4
+ lobbyAtmosphereShare=1.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/dimension/BlendedAtmosphereTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/dimension/BlendedAtmosphereTest.java
new file mode 100644
index 00000000..8ae2ee55
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/cygnus/common/dimension/BlendedAtmosphereTest.java
@@ -0,0 +1,111 @@
+package net.onelitefeather.cygnus.common.dimension;
+
+import net.minestom.server.color.Color;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+/**
+ * Verifies the atmosphere the lobby is given: the map's own, pulled back towards the open end.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 2.15.0
+ */
+class BlendedAtmosphereTest {
+
+ /** The open end a lobby is pulled towards. */
+ private static final DimensionAtmosphere OPEN = new MapAtmosphere(
+ new Color(100, 100, 100),
+ new Color(200, 200, 200),
+ new Color(150, 150, 150),
+ new Color(10, 10, 10),
+ 1.0f, 20f, 200f, 100f
+ );
+
+ /** A map that closes in on the player. */
+ private static final DimensionAtmosphere CLOSED = new MapAtmosphere(
+ new Color(0, 40, 20),
+ new Color(0, 80, 40),
+ new Color(0, 60, 30),
+ new Color(0, 4, 2),
+ 0.0f, 0f, 40f, 20f
+ );
+
+ @Test
+ @DisplayName("A share of zero leaves the open end untouched")
+ void zeroKeepsTheOpenEnd() {
+ DimensionAtmosphere blended = BlendedAtmosphere.between(OPEN, CLOSED, 0f);
+
+ assertEquals(20f, blended.fogStartDistance());
+ assertEquals(200f, blended.fogEndDistance());
+ assertEquals(1.0f, blended.skyLightFactor());
+ assertEquals(OPEN.fogColor(), blended.fogColor());
+ }
+
+ @Test
+ @DisplayName("A share of one is the map itself")
+ void oneIsTheMapItself() {
+ DimensionAtmosphere blended = BlendedAtmosphere.between(OPEN, CLOSED, 1f);
+
+ assertEquals(0f, blended.fogStartDistance());
+ assertEquals(40f, blended.fogEndDistance());
+ assertEquals(0.0f, blended.skyLightFactor());
+ assertEquals(CLOSED.fogColor(), blended.fogColor());
+ }
+
+ @Test
+ @DisplayName("A share in between lands in between, on every value")
+ void aShareLandsInBetween() {
+ DimensionAtmosphere blended = BlendedAtmosphere.between(OPEN, CLOSED, 0.5f);
+
+ assertEquals(10f, blended.fogStartDistance());
+ assertEquals(120f, blended.fogEndDistance());
+ assertEquals(60f, blended.skyFogEndDistance());
+ assertEquals(0.5f, blended.skyLightFactor());
+ }
+
+ @Test
+ @DisplayName("Colours are mixed channel by channel")
+ void coloursAreMixedPerChannel() {
+ DimensionAtmosphere blended = BlendedAtmosphere.between(OPEN, CLOSED, 0.5f);
+
+ assertEquals(new Color(50, 70, 60), blended.fogColor());
+ assertEquals(new Color(100, 140, 120), blended.skyLightColor());
+ assertEquals(new Color(75, 105, 90), blended.skyColor());
+ }
+
+ @Test
+ @DisplayName("A share outside its range is pulled back into it")
+ void aShareOutsideItsRangeIsClamped() {
+ assertEquals(OPEN.fogEndDistance(), BlendedAtmosphere.between(OPEN, CLOSED, -1f).fogEndDistance());
+ assertEquals(CLOSED.fogEndDistance(), BlendedAtmosphere.between(OPEN, CLOSED, 2f).fogEndDistance());
+ }
+
+ @Test
+ @DisplayName("The fog keeps a span the client can fade over")
+ void theFogKeepsAFadeableSpan() {
+ DimensionAtmosphere flat = new MapAtmosphere(
+ new Color(0, 0, 0), new Color(0, 0, 0), new Color(0, 0, 0), new Color(1, 1, 1),
+ 0f, 30f, 31f, 20f
+ );
+
+ DimensionAtmosphere blended = BlendedAtmosphere.between(flat, flat, 0.5f);
+
+ assertEquals(30f, blended.fogStartDistance());
+ assertEquals(31f, blended.fogEndDistance(),
+ "a blend must not collapse the gap the fog fades over");
+ }
+
+ @Test
+ @DisplayName("Blending an atmosphere with itself changes nothing")
+ void blendingWithItselfChangesNothing() {
+ DimensionAtmosphere blended = BlendedAtmosphere.between(CLOSED, CLOSED, 0.3f);
+
+ assertEquals(CLOSED.fogColor(), blended.fogColor());
+ assertEquals(CLOSED.fogEndDistance(), blended.fogEndDistance());
+ assertEquals(CLOSED.skyLightFactor(), blended.skyLightFactor());
+ }
+}
diff --git a/config.properties.example b/config.properties.example
index 14fd558e..50d25ee4 100644
--- a/config.properties.example
+++ b/config.properties.example
@@ -184,3 +184,18 @@ survivorTeamSize=12
# How loud it is once every page is gone. Between 0 and 1 and not below
# slenderStaticMinVolume.
#slenderStaticMaxVolume=0.8
+
+# --- The lobby's atmosphere ---------------------------------------------------
+# How far the lobby is taken from an open, well-lit sky towards the atmosphere of
+# the map players are waiting for. Between 0 and 1.
+#
+# The point is the distance between the two. At 0 the lobby stays on the vanilla
+# overworld, which is where it was, and the round starts as a cut: a player walks
+# from a clear sky into fog that closes in at forty blocks, in the same moment the
+# game begins. At 1 the lobby is the map exactly, and the start becomes invisible.
+# In between, the lobby carries the map's own colours and haze at a distance the
+# player can still see across, so the start reads as the world tightening.
+#
+# A map that declares no atmosphere has nothing to prepare anyone for; its lobby
+# stays on the overworld regardless of this value.
+#lobbyAtmosphereShare=0.3
diff --git a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
index 347cf7c0..f0e91f1f 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/Cygnus.java
@@ -152,7 +152,7 @@ public Cygnus() {
MinecraftServer.getConnectionManager().setPlayerProvider(
(connection, gameProfile) -> new CygnusPlayer(connection, gameProfile, resourcePackId));
this.pageProvider = new PageProvider();
- this.mapProvider = new GameMapProvider(path);
+ this.mapProvider = new GameMapProvider(path, this.gameConfig.lobbyAtmosphereShare());
// Falco keeps its region files open, so the loaders have to be released on shutdown
MinecraftServer.getSchedulerManager().buildShutdownTask(this.mapProvider::close);
this.view = new GameViewImpl();
diff --git a/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java b/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java
index ad011373..f713e6c4 100644
--- a/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java
+++ b/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java
@@ -7,7 +7,11 @@
import net.minestom.server.registry.RegistryKey;
import net.minestom.server.timer.TaskSchedule;
import net.minestom.server.world.DimensionType;
+import net.onelitefeather.cygnus.common.config.GameConfig;
+import net.onelitefeather.cygnus.common.dimension.BlendedAtmosphere;
+import net.onelitefeather.cygnus.common.dimension.DimensionAtmosphere;
import net.onelitefeather.cygnus.common.dimension.DimensionFactory;
+import net.onelitefeather.cygnus.common.dimension.StaticDimensionPreset;
import net.onelitefeather.cygnus.common.dimension.MapAtmosphere;
import net.onelitefeather.cygnus.common.map.GameMap;
import net.onelitefeather.cygnus.common.map.filter.MapFilters;
@@ -38,12 +42,24 @@ public final class GameMapProvider extends AbstractMapProvider {
private final ListA player who walks straight from a vanilla sky into a map that closes in at forty blocks + * meets the whole atmosphere at once, at the same moment the round starts. Giving the lobby the + * map's own colours and haze, held at a distance, turns that into a build-up: the start of the + * round reads as the world tightening rather than as a cut.
+ * + *Registered here for the same reason as the game's own dimension: registry data only + * reaches a client during its configuration phase, and a dimension registered after a player + * has logged in is a dimension that player cannot be put into.
+ * + * @param map the loaded game map + * @param share how far to take the lobby towards the map's atmosphere + * @return the key of the registered dimension, or {@link DimensionType#OVERWORLD} if the map + * declares no atmosphere or the share is zero + */ + private RegistryKey