From 2ee5b5f18d02b821d8dbabe57ff2515c49691544 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 6 Sep 2026 23:58:54 +0200 Subject: [PATCH 1/2] feat(lobby): wait in a weakened version of the map's atmosphere A player walked from a vanilla sky straight into a map that closes in at forty blocks, and met the whole atmosphere in the same moment the round started. The lobby now carries the map's own colours and haze, held at a distance he can still see across, so the start of a round reads as the world tightening rather than as a cut. BlendedAtmosphere reads one atmosphere off the line towards another, colours channel by channel. The lobby's is StaticDimensionPreset.BRIGHT taken lobbyAtmosphereShare of the way towards the game map's, registered as cygnus:map//lobby. The registration happens in the provider's constructor, next to the game map's own dimension, because registry data only reaches a client during its configuration phase. That reverses the order inside the constructor: the game map has to be read before the lobby is loaded now, since the lobby's dimension is derived from it. A map without an atmosphere has nothing to prepare anyone for, and a share of zero asks for nothing; both leave the lobby on the overworld, where it was. --- .../cygnus/common/config/GameConfig.java | 33 ++++++ .../common/config/GameConfigBuilder.java | 13 +- .../cygnus/common/config/GameConfigImpl.java | 4 +- .../common/config/GameConfigReader.java | 4 +- .../common/config/InternalGameConfig.java | 7 +- .../common/dimension/BlendedAtmosphere.java | 87 ++++++++++++++ .../common/config/GameConfigReaderTest.java | 31 +++++ .../dimension/BlendedAtmosphereTest.java | 111 ++++++++++++++++++ config.properties.example | 15 +++ .../net/onelitefeather/cygnus/Cygnus.java | 2 +- .../cygnus/map/GameMapProvider.java | 60 +++++++++- .../map/GameMapProviderIntegrationTest.java | 62 ++++++++++ 12 files changed, 420 insertions(+), 9 deletions(-) create mode 100644 common/src/main/java/net/onelitefeather/cygnus/common/dimension/BlendedAtmosphere.java create mode 100644 common/src/test/java/net/onelitefeather/cygnus/common/dimension/BlendedAtmosphereTest.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 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 @@ *
  • glitchRange
  • *
  • glitchCloseRange
  • *
  • glitchViewAngle
  • + *
  • lobbyAtmosphereShare
  • *
  • slenderStaticEnabled
  • *
  • slenderStaticSound
  • *
  • slenderStaticQuietInterval
  • @@ -133,7 +134,8 @@ public GameConfig getConfig() { .slenderStaticQuietInterval(getInt(properties, "slenderStaticQuietInterval", internal.slenderStaticQuietInterval())) .slenderStaticFranticInterval(getInt(properties, "slenderStaticFranticInterval", internal.slenderStaticFranticInterval())) .slenderStaticMinVolume(getFloat(properties, "slenderStaticMinVolume", internal.slenderStaticMinVolume())) - .slenderStaticMaxVolume(getFloat(properties, "slenderStaticMaxVolume", internal.slenderStaticMaxVolume())); + .slenderStaticMaxVolume(getFloat(properties, "slenderStaticMaxVolume", internal.slenderStaticMaxVolume())) + .lobbyAtmosphereShare(getFloat(properties, "lobbyAtmosphereShare", internal.lobbyAtmosphereShare())); return configBuilder.build(); } diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/config/InternalGameConfig.java b/common/src/main/java/net/onelitefeather/cygnus/common/config/InternalGameConfig.java index 121c0d5f..793ebcb4 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,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 @@ record InternalGameConfig( int slenderStaticQuietInterval, int slenderStaticFranticInterval, float slenderStaticMinVolume, - float slenderStaticMaxVolume + float slenderStaticMaxVolume, + float lobbyAtmosphereShare ) implements GameConfig { // Sentry and the ResourcePack are opt-in: a service that says nothing about them reports to @@ -93,7 +95,8 @@ record InternalGameConfig( GameConfig.DEFAULT_SLENDER_STATIC_QUIET_INTERVAL, GameConfig.DEFAULT_SLENDER_STATIC_FRANTIC_INTERVAL, GameConfig.DEFAULT_SLENDER_STATIC_MIN_VOLUME, - GameConfig.DEFAULT_SLENDER_STATIC_MAX_VOLUME); + GameConfig.DEFAULT_SLENDER_STATIC_MAX_VOLUME, + GameConfig.DEFAULT_LOBBY_ATMOSPHERE_SHARE); /** * Returns the default configuration for the game. diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/dimension/BlendedAtmosphere.java b/common/src/main/java/net/onelitefeather/cygnus/common/dimension/BlendedAtmosphere.java new file mode 100644 index 00000000..20bba366 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/dimension/BlendedAtmosphere.java @@ -0,0 +1,87 @@ +package net.onelitefeather.cygnus.common.dimension; + +import net.kyori.adventure.util.RGBLike; +import net.minestom.server.color.Color; + +/** + * One atmosphere read part of the way towards another. + * + *

    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..2d2a6b44 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 List chunkLoaders; private final MapEntry gameEntry; private final RegistryKey gameDimension; + private final RegistryKey lobbyDimension; private @Nullable InstanceContainer gameInstance; private @Nullable GameMap gameMap; private @Nullable InstanceContainer previousInstance; private boolean releasePending; public GameMapProvider(Path path) { + this(path, GameConfig.DEFAULT_LOBBY_ATMOSPHERE_SHARE); + } + + /** + * Creates a provider for the maps below the given path. + * + * @param path the directory holding {@code game/maps} + * @param lobbyAtmosphereShare how far the lobby's atmosphere is taken towards the game map's + * own, between 0 and 1 + */ + public GameMapProvider(Path path, float lobbyAtmosphereShare) { super(GsonHelper.FILE_HANDLER, MapFilters::filterMapsForGame); this.loadMapEntries(path.resolve("game").resolve("maps")); this.chunkLoaders = new ArrayList<>(); @@ -51,12 +67,50 @@ public GameMapProvider(Path path) { throw new IllegalStateException("No maps found in the given path"); } - this.loadLobbyMap(); + // The game map is read before the lobby is loaded, which is the reverse of what this used + // to do: the lobby's own dimension is derived from the game map's atmosphere, so the map + // has to be known before the instance it is derived for can be created. this.gameEntry = this.mapEntries.stream() .filter(entry -> !entry.getDirectoryRoot().toString().equalsIgnoreCase("lobby")) .findAny() .orElseThrow(() -> new IllegalStateException("No game map found")); - this.gameDimension = registerDimension(readGameMap()); + GameMap map = readGameMap(); + this.gameDimension = registerDimension(map); + this.lobbyDimension = registerLobbyDimension(map, lobbyAtmosphereShare); + this.loadLobbyMap(); + } + + /** + * Registers the weakened version of the game map's atmosphere the lobby waits in. + * + *

    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 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 registerLobbyDimension(GameMap map, float share) { + MapAtmosphere atmosphere = map.getAtmosphere(); + if (atmosphere == null || share <= 0f) { + return DimensionType.OVERWORLD; + } + + DimensionAtmosphere weakened = BlendedAtmosphere.between(StaticDimensionPreset.BRIGHT, atmosphere, share); + Key key = Key.key(DIMENSION_NAMESPACE, "map/" + toKeyValue(map.name()) + "/lobby"); + LOGGER.info( + "Registered lobby dimension {} at {} of map {}: fog {} from {} to {} blocks", + key, share, map.name(), weakened.fogColor(), + weakened.fogStartDistance(), weakened.fogEndDistance() + ); + return DimensionFactory.create(key, weakened); } /** @@ -194,7 +248,7 @@ private BaseMap loadLobbyMap() { this.activeMap = this.fileHandler.load(lobbyEntry.getMapFile(), BaseMap.class) .orElseThrow(() -> new IllegalStateException("Failed to load LobbyMap from file: " + lobbyEntry.getMapFile())); - InstanceContainer instanceContainer = MinecraftServer.getInstanceManager().createInstanceContainer(); + InstanceContainer instanceContainer = MinecraftServer.getInstanceManager().createInstanceContainer(this.lobbyDimension); this.registerFalcoInstance(instanceContainer, lobbyEntry); this.activeInstance = instanceContainer; return this.activeMap; diff --git a/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java index a9e3907b..41a4213e 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java @@ -3,6 +3,7 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.instance.InstanceContainer; import net.minestom.server.world.DimensionType; +import net.minestom.server.world.attribute.EnvironmentAttribute; import net.minestom.testing.Env; import net.minestom.testing.extension.MicrotusExtension; import net.onelitefeather.cygnus.common.dimension.MapAtmosphere; @@ -116,6 +117,67 @@ void testGameInstanceStaysOnOverworldWithoutAnAtmosphere(Env env, @TempDir Path env.destroyInstance(gameInstance, true); } + @Test + void testLobbyRunsOnAWeakenedVersionOfTheMapsDimension(Env env, @TempDir Path root) throws IOException { + GameMapProvider provider = createProvider(root, MapAtmosphere.from(StaticDimensionPreset.DENSE_FOG)); + + InstanceContainer lobbyInstance = (InstanceContainer) provider.getActiveInstance().get(); + + assertNotSame(DimensionType.OVERWORLD, lobbyInstance.getDimensionType()); + assertEquals( + "map/" + ARENA_NAME + "/lobby", lobbyInstance.getDimensionType().key().value(), + "the lobby gets a dimension of its own, derived from the map players are waiting for" + ); + + provider.close(); + env.destroyInstance(lobbyInstance, true); + } + + @Test + void testTheLobbySeesFurtherThanTheMapItself(Env env, @TempDir Path root) throws IOException { + MapAtmosphere atmosphere = MapAtmosphere.from(StaticDimensionPreset.DENSE_FOG); + GameMapProvider provider = createProvider(root, atmosphere); + InstanceContainer lobbyInstance = (InstanceContainer) provider.getActiveInstance().get(); + provider.loadGameMap(); + provider.switchToGameMap(); + InstanceContainer gameInstance = (InstanceContainer) provider.getActiveInstance().get(); + + float lobbyFogEnd = fogEndOf(lobbyInstance.getCachedDimensionType()); + float gameFogEnd = fogEndOf(gameInstance.getCachedDimensionType()); + + assertTrue(lobbyFogEnd > gameFogEnd, + "the lobby has to be the weaker version: " + lobbyFogEnd + " is not further than " + gameFogEnd); + assertTrue(lobbyFogEnd < StaticDimensionPreset.BRIGHT.fogEndDistance(), + "and it still has to carry the map's haze rather than being wide open"); + + provider.close(); + env.destroyInstance(gameInstance, true); + } + + @Test + void testLobbyStaysOnOverworldWithoutAnAtmosphere(Env env, @TempDir Path root) throws IOException { + GameMapProvider provider = createProvider(root, null); + + InstanceContainer lobbyInstance = (InstanceContainer) provider.getActiveInstance().get(); + + assertEquals(DimensionType.OVERWORLD, lobbyInstance.getDimensionType(), + "a map that declares no atmosphere has nothing to prepare players for"); + + provider.close(); + env.destroyInstance(lobbyInstance, true); + } + + /** + * Reads the fog end distance back out of a registered dimension. + * + * @param dimension the dimension to read + * @return the distance at which its fog is fully opaque + */ + private static float fogEndOf(DimensionType dimension) { + Object argument = dimension.attributes().entries().get(EnvironmentAttribute.FOG_END_DISTANCE).argument(); + return ((Number) argument).floatValue(); + } + /** * Creates a map directory layout the provider accepts and returns a provider reading it. * From 89eff42222028ab3772a0bd85fd97d83b62aab50 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Mon, 7 Sep 2026 10:41:33 +0200 Subject: [PATCH 2/2] fix(map): take the lobby out of the running before picking a game map The filter that was meant to keep the lobby out of the game map selection never matched: getDirectoryRoot() is a whole path, so it never equals "lobby" and every entry passed through. That stayed invisible while loadLobbyMap() ran first and removed the entry, and broke the moment the previous commit reversed the order to derive the lobby's dimension from the game map. macOS picked the lobby as the game map and six tests failed with it; ubuntu happened to pick the arena. findAny() over a stream owes nobody an order. The lobby entry is now taken out explicitly before the pick, and the check reads the entry's own directory name instead of the path leading to it - a checkout below a folder called "lobby" would otherwise make every map the lobby. --- .../cygnus/map/GameMapProvider.java | 46 +++++++++++++++---- .../map/GameMapProviderIntegrationTest.java | 14 ++++++ 2 files changed, 51 insertions(+), 9 deletions(-) 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 2d2a6b44..f713e6c4 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java +++ b/game/src/main/java/net/onelitefeather/cygnus/map/GameMapProvider.java @@ -67,17 +67,21 @@ public GameMapProvider(Path path, float lobbyAtmosphereShare) { throw new IllegalStateException("No maps found in the given path"); } - // The game map is read before the lobby is loaded, which is the reverse of what this used - // to do: the lobby's own dimension is derived from the game map's atmosphere, so the map - // has to be known before the instance it is derived for can be created. + // The lobby is taken out of the running first and the game map picked from what is left. + // The filter below cannot do that on its own: getDirectoryRoot() is a whole path, so it + // never equals "lobby" and the check passes everything through. That was harmless while + // loadLobbyMap() ran first and removed the entry, and stops being harmless the moment the + // order changes - which it has to here, because the lobby's dimension is derived from the + // game map's atmosphere and the map must be known before the lobby instance is created. + MapEntry lobbyEntry = this.takeLobbyEntry(); this.gameEntry = this.mapEntries.stream() - .filter(entry -> !entry.getDirectoryRoot().toString().equalsIgnoreCase("lobby")) + .filter(entry -> !isLobby(entry)) .findAny() .orElseThrow(() -> new IllegalStateException("No game map found")); GameMap map = readGameMap(); this.gameDimension = registerDimension(map); this.lobbyDimension = registerLobbyDimension(map, lobbyAtmosphereShare); - this.loadLobbyMap(); + this.loadLobbyMap(lobbyEntry); } /** @@ -236,16 +240,40 @@ public void releasePreviousInstance() { }); } - private BaseMap loadLobbyMap() { - MapEntry lobbyEntry = this.mapEntries.stream().filter(mapEntry -> mapEntry.getDirectoryRoot().toString().contains("lobby")).findAny() + /** + * Takes the lobby out of the loaded entries, so what is left is the pool a game map is picked + * from. + * + * @return the lobby's entry + * @throws IllegalStateException if there is no lobby among the entries + */ + private MapEntry takeLobbyEntry() { + MapEntry lobbyEntry = this.mapEntries.stream() + .filter(GameMapProvider::isLobby) + .findAny() .orElseThrow(() -> new IllegalStateException("No lobby map found in the given path")); + this.mapEntries.remove(lobbyEntry); + return lobbyEntry; + } + /** + * Answers whether an entry is the lobby, by the name of its own directory rather than by the + * path leading to it - a temporary directory or a checkout below a folder called {@code lobby} + * would otherwise make every map the lobby. + * + * @param entry the entry to look at + * @return {@code true} if the entry is the lobby + */ + private static boolean isLobby(MapEntry entry) { + Path directory = entry.getDirectoryRoot().getFileName(); + return directory != null && directory.toString().equalsIgnoreCase("lobby"); + } + + private BaseMap loadLobbyMap(MapEntry lobbyEntry) { if (!lobbyEntry.hasMapFile()) { throw new IllegalStateException("Lobby map doesn't contains a map file"); } - this.mapEntries.remove(lobbyEntry); - this.activeMap = this.fileHandler.load(lobbyEntry.getMapFile(), BaseMap.class) .orElseThrow(() -> new IllegalStateException("Failed to load LobbyMap from file: " + lobbyEntry.getMapFile())); InstanceContainer instanceContainer = MinecraftServer.getInstanceManager().createInstanceContainer(this.lobbyDimension); diff --git a/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java b/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java index 41a4213e..f6f4e593 100644 --- a/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java +++ b/game/src/test/java/net/onelitefeather/cygnus/map/GameMapProviderIntegrationTest.java @@ -117,6 +117,20 @@ void testGameInstanceStaysOnOverworldWithoutAnAtmosphere(Env env, @TempDir Path env.destroyInstance(gameInstance, true); } + @Test + void testTheGameMapIsTheArenaAndNeverTheLobby(Env env, @TempDir Path root) throws IOException { + GameMapProvider provider = createProvider(root, MapAtmosphere.from(StaticDimensionPreset.DENSE_FOG)); + + provider.loadGameMap(); + + assertEquals(ARENA_NAME, provider.getGameMap().name(), + "the lobby entry has to be out of the running before a game map is picked out of what is left"); + + InstanceContainer lobbyInstance = (InstanceContainer) provider.getActiveInstance().get(); + provider.close(); + env.destroyInstance(lobbyInstance, true); + } + @Test void testLobbyRunsOnAWeakenedVersionOfTheMapsDimension(Env env, @TempDir Path root) throws IOException { GameMapProvider provider = createProvider(root, MapAtmosphere.from(StaticDimensionPreset.DENSE_FOG));