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..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 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,54 @@ public GameMapProvider(Path path) { throw new IllegalStateException("No maps found in the given path"); } - this.loadLobbyMap(); + // 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")); - this.gameDimension = registerDimension(readGameMap()); + GameMap map = readGameMap(); + this.gameDimension = registerDimension(map); + this.lobbyDimension = registerLobbyDimension(map, lobbyAtmosphereShare); + this.loadLobbyMap(lobbyEntry); + } + + /** + * 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); } /** @@ -182,19 +240,43 @@ 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(); + 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..f6f4e593 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,81 @@ 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)); + + 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. *