diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/dimension/DimensionFactory.java b/common/src/main/java/net/onelitefeather/cygnus/common/dimension/DimensionFactory.java new file mode 100644 index 00000000..7e42b578 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/dimension/DimensionFactory.java @@ -0,0 +1,64 @@ +package net.onelitefeather.cygnus.common.dimension; + +import net.kyori.adventure.key.Key; +import net.minestom.server.MinecraftServer; +import net.minestom.server.particle.Particle; +import net.minestom.server.registry.RegistryKey; +import net.minestom.server.world.DimensionType; +import net.minestom.server.world.attribute.AmbientParticle; +import net.minestom.server.world.attribute.EnvironmentAttribute; +import org.jetbrains.annotations.Contract; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +/** + * Small factory class to create custom {@link RegistryKey} instances from custom dimension presets. + * + * @author Joltra + * @version 1.0.0 + * @since 2.6.6 + */ +public final class DimensionFactory { + + private static final Logger LOGGER = LoggerFactory.getLogger(DimensionFactory.class); + + private DimensionFactory() { + throw new UnsupportedOperationException(); + } + + public static void registerAll() { + for (StaticDimensionPreset value : StaticDimensionPreset.getValues()) { + RegistryKey dimensionType = create(value); + LOGGER.info("Registered dimension preset: {}", dimensionType.key()); + } + } + + /** + * Creates a new dimension type based on the given preset. + * + * @param preset which should be created + * @return the created dimension type + */ + @Contract(value = "_ -> new", pure = true) + public static RegistryKey create(DimensionPreset preset) { + DimensionType type = DimensionType.builder() + .setAttribute(EnvironmentAttribute.FOG_START_DISTANCE, preset.fogStartDistance()) + .setAttribute(EnvironmentAttribute.FOG_END_DISTANCE, preset.fogEndDistance()) + .setAttribute(EnvironmentAttribute.SKY_FOG_END_DISTANCE, preset.skyFogEndDistance()) + .setAttribute(EnvironmentAttribute.FOG_COLOR, preset.fogColor()) + .setAttribute(EnvironmentAttribute.SKY_COLOR, preset.skyColor()) + .setAttribute(EnvironmentAttribute.SKY_LIGHT_COLOR, preset.skyLightColor()) + .setAttribute(EnvironmentAttribute.SKY_LIGHT_FACTOR, preset.skyLightFactor()) + .setAttribute(EnvironmentAttribute.AMBIENT_PARTICLES, List.of( + new AmbientParticle(Particle.SOUL, 0.01f), + new AmbientParticle(Particle.SOUL_FIRE_FLAME, 0.001f) + )) + .setAttribute(EnvironmentAttribute.SUN_ANGLE, 180f) + .setAttribute(EnvironmentAttribute.MOON_ANGLE, 180f) + .defaultClock(DimensionType.OVERWORLD.asValue().defaultClock()) + .build(); + return MinecraftServer.getDimensionTypeRegistry().register(Key.key("cygnus", preset.getKey()), type); + } +} \ No newline at end of file diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/dimension/DimensionPreset.java b/common/src/main/java/net/onelitefeather/cygnus/common/dimension/DimensionPreset.java new file mode 100644 index 00000000..723d7c11 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/dimension/DimensionPreset.java @@ -0,0 +1,61 @@ +package net.onelitefeather.cygnus.common.dimension; + +import net.kyori.adventure.util.RGBLike; + + +/** + * Describes which preset values are required for a custom Dimension in Minestom. + * It contains only data about the fog and sky colors, and nothing else. + * If you also want to adjust your biome, that is an additional step that is not covered here. + * + * @author thEvilReaper + * @version 1.0.0 + * @since 2.6.6 + */ +public interface DimensionPreset { + + /** + * A short, unique, lowercase identifier for this preset, used wherever + * the preset needs to be referenced by name instead of by object + * reference, e.g. in configs or when registering the dimension. + */ + String getKey(); + + /** + * The color of the fog itself. + */ + RGBLike fogColor(); + + /** + * The color of the light that appears to come down from the sky. + */ + RGBLike skyLightColor(); + + /** + * The sky's own background color. + */ + RGBLike skyColor(); + + /** + * How strongly sky-light affects the dimension. Higher values feel + * brighter and more open; values near zero feel dim and enclosed even + * without much fog. + */ + float skyLightFactor(); + + /** + * The distance at which fog starts to become noticeable. + */ + float fogStartDistance(); + + /** + * The distance beyond which the fog is fully opaque. + */ + float fogEndDistance(); + + /** + * Like {@link #fogEndDistance()}, but for the sky-colored fog seen at + * the horizon rather than the regular ground fog. + */ + float skyFogEndDistance(); +} diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/dimension/SeedDimensionPreset.java b/common/src/main/java/net/onelitefeather/cygnus/common/dimension/SeedDimensionPreset.java new file mode 100644 index 00000000..3cf408b2 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/dimension/SeedDimensionPreset.java @@ -0,0 +1,125 @@ +package net.onelitefeather.cygnus.common.dimension; + +import net.kyori.adventure.util.RGBLike; +import net.onelitefeather.cygnus.common.util.ColorUtil; + +import java.util.Random; + +/** + * A {@link DimensionPreset} that is generated deterministically from a seed. + *

+ * The same seed always produces the same atmosphere, making these presets + * suitable for procedural worlds while keeping their appearance consistent + * across sessions. + * + * @version 1.0.0 + * @since 2.6.6 + * @author theEvilReaper + */ +public record SeedDimensionPreset( + String key, + RGBLike fogColor, + RGBLike skyLightColor, + RGBLike skyColor, + float skyLightFactor, + float fogStartDistance, + float fogEndDistance, + float skyFogEndDistance +) implements DimensionPreset { + + // Parameter bounds derived from the predefined DimensionPreset values. + // Keeping generated presets within these ranges prevents atmospheres + // from becoming excessively bright, dark, or foggy. + private static final float MIN_SKY_LIGHT_FACTOR = 0.002f; + private static final float MAX_SKY_LIGHT_FACTOR = 0.06f; + + private static final float MIN_FOG_START = 0f; + private static final float MAX_FOG_START = 64f; + + private static final float MIN_FOG_END = 24f; + private static final float MAX_FOG_END = 384f; + + private static final float MIN_SKY_FOG_END = 16f; + private static final float MAX_SKY_FOG_END = 256f; + + /** + * Creates a deterministic dimension preset from the given seed. + *

+ * The seed determines both the base hue and a density value. The density + * controls visibility, lighting, and color intensity, while small hue + * offsets keep the fog, skylight, and sky colors visually related. + * + * @param seed any stable seed, such as a world seed or hashed dimension name + * @return the generated dimension preset + */ + public static SeedDimensionPreset fromSeed(long seed) { + Random random = new Random(seed); + + float hue = random.nextFloat() * 360f; + float density = random.nextFloat(); // 0 = open/bright, 1 = dense/dark + + float skyLightFactor = lerp(MAX_SKY_LIGHT_FACTOR, MIN_SKY_LIGHT_FACTOR, density); + float fogStartDistance = lerp(MAX_FOG_START, MIN_FOG_START, density); + float fogEndDistance = lerp(MAX_FOG_END, MIN_FOG_END, density); + float skyFogEndDistance = lerp(MAX_SKY_FOG_END, MIN_SKY_FOG_END, density); + + float fogHue = hue; + float skyLightHue = wrapHue(hue + 8f); + float skyHue = wrapHue(hue - 6f); + + RGBLike fogColor = ColorUtil.hsl( + fogHue, + lerp(0.55f, 0.25f, density), + lerp(0.42f, 0.14f, density) + ); + + RGBLike skyLightColor = ColorUtil.hsl( + skyLightHue, + lerp(0.75f, 0.45f, density), + lerp(0.72f, 0.32f, density) + ); + + RGBLike skyColor = ColorUtil.hsl( + skyHue, + lerp(0.55f, 0.35f, density), + lerp(0.16f, 0.03f, density) + ); + + String key = "seed_" + Long.toHexString(seed); + + return new SeedDimensionPreset( + key, + fogColor, + skyLightColor, + skyColor, + skyLightFactor, + fogStartDistance, + fogEndDistance, + skyFogEndDistance + ); + } + + /** + * Performs linear interpolation between {@code a} and {@code b}. + */ + private static float lerp(float a, float b, float t) { + return a + (b - a) * t; + } + + /** + * Wraps a hue into the {@code [0, 360)} range. + */ + private static float wrapHue(float hue) { + float h = hue % 360f; + return h < 0f ? h + 360f : h; + } + + /** + * Adapts the record component accessor to the {@link DimensionPreset} + * naming convention. + */ + @Override + public String getKey() { + return key; + } +} \ No newline at end of file diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/dimension/StaticDimensionPreset.java b/common/src/main/java/net/onelitefeather/cygnus/common/dimension/StaticDimensionPreset.java new file mode 100644 index 00000000..8615dba5 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/dimension/StaticDimensionPreset.java @@ -0,0 +1,307 @@ +package net.onelitefeather.cygnus.common.dimension; + +import net.kyori.adventure.util.RGBLike; +import net.minestom.server.color.Color; + +/** + * Hand-crafted dimension atmospheres with predefined settings. + * Use these for a consistent look. For seed-dependent variation, use + * {@link SeedDimensionPreset}. + * + * @author theEvilReaper + * @version 1.0.0 + * @since 2.6.6 + */ +public enum StaticDimensionPreset implements DimensionPreset { + + /** + * A dense, leafy green — the classic overgrown-jungle feel. + */ + DEEP_GREEN( + "deep_green", + new Color(20, 127, 64), + new Color(20, 127, 64), + Color.BLACK, + 0.01f, + 0f, + 128f, + 64f + ), + + /** + * Thick, close-in fog — you can barely see past arm's reach. + */ + DENSE_FOG( + "dense_fog", + new Color(15, 96, 52), + new Color(18, 105, 60), + Color.BLACK, + 0.008f, + 0f, + 48f, + 32f + ), + + /** + * A crisp, icy blue with decent visibility — think frozen tundra. + */ + COLD_BLUE( + "cold_blue", + new Color(35, 90, 180), + new Color(70, 140, 255), + new Color(8, 12, 32), + 0.015f, + 16f, + 160f, + 96f + ), + + /** + * About as dim and enclosed as it gets — barely any sky light at all. + */ + VERY_DARK( + "very_dark", + new Color(6, 24, 18), + new Color(10, 32, 24), + Color.BLACK, + 0.0025f, + 0f, + 32f, + 24f + ), + + /** + * Open, airy, and well-lit — long sightlines, minimal fog. + */ + BRIGHT( + "bright", + new Color(55, 175, 170), + new Color(100, 220, 220), + new Color(10, 35, 45), + 0.05f, + 32f, + 256f, + 160f + ), + + /** + * A magical, otherworldly purple haze. + */ + MYSTIC_PURPLE( + "mystic_purple", + new Color(95, 45, 170), + new Color(150, 90, 255), + new Color(25, 10, 45), + 0.02f, + 8f, + 96f, + 64f + ), + + /** + * A sickly, radioactive-looking yellow-green. + */ + TOXIC_GREEN( + "toxic_green", + new Color(130, 155, 20), + new Color(180, 210, 40), + new Color(35, 40, 5), + 0.025f, + 0f, + 64f, + 48f + ), + + /** + * Hot orange tones and a hazy, heat-shimmer kind of fog. + */ + LAVA( + "lava", + new Color(180, 90, 25), + new Color(255, 170, 80), + new Color(45, 20, 8), + 0.03f, + 0f, + 72f, + 40f + ), + + /** + * The brightest, most open preset — a perfect clear day. + */ + CLEAR_SKY( + "clear_sky", + new Color(30, 70, 120), + new Color(120, 180, 255), + new Color(15, 25, 50), + 0.08f, + 64f, + 384f, + 256f + ), + + /** + * Dark, murky blue-green — deep underwater with barely any light. + */ + DEEP_SEA( + "deep_sea", + new Color(5, 45, 80), + new Color(20, 80, 140), + new Color(0, 10, 25), + 0.006f, + 0f, + 32f, + 24f + ), + + /** + * Murky, muted greens — damp and overgrown. + */ + SWAMP( + "swamp", + new Color(45, 85, 40), + new Color(65, 120, 55), + new Color(10, 20, 10), + 0.012f, + 0f, + 64f, + 40f + ), + + /** + * Cool, pale blue-white light — a calm, moonlit night. + */ + MOONLIGHT( + "moonlight", + new Color(60, 90, 170), + new Color(170, 190, 255), + new Color(5, 5, 25), + 0.03f, + 32f, + 256f, + 128f + ); + + // Cached once so getValues() doesn't clone the backing array on every + // single call the way the built-in values() does. + private static final StaticDimensionPreset[] VALUES = values(); + + private final String key; + private final RGBLike fogColor; + private final RGBLike skyLightColor; + private final RGBLike skyColor; + + private final float skyLightFactor; + + private final float fogStartDistance; + private final float fogEndDistance; + private final float skyFogEndDistance; + + /** + * Creates a new instance of an enumeration entry with the given values. + * + * @param key of the preset + * @param fogColor of the preset + * @param skyLightColor of the preset + * @param skyColor of the preset + * @param skyLightFactor of the preset + * @param fogStartDistance of the preset + * @param fogEndDistance of the preset + * @param skyFogEndDistance of the preset + */ + StaticDimensionPreset( + String key, + RGBLike fogColor, + RGBLike skyLightColor, + RGBLike skyColor, + float skyLightFactor, + float fogStartDistance, + float fogEndDistance, + float skyFogEndDistance + ) { + this.key = key; + this.fogColor = fogColor; + this.skyLightColor = skyLightColor; + this.skyColor = skyColor; + this.skyLightFactor = skyLightFactor; + this.fogStartDistance = fogStartDistance; + this.fogEndDistance = fogEndDistance; + this.skyFogEndDistance = skyFogEndDistance; + } + + /** + * {@inheritDoc} + */ + @Override + public String getKey() { + return key; + } + + /** + * {@inheritDoc} + */ + @Override + public RGBLike fogColor() { + return fogColor; + } + + /** + * {@inheritDoc} + */ + @Override + public RGBLike skyLightColor() { + return skyLightColor; + } + + /** + * {@inheritDoc} + */ + @Override + public RGBLike skyColor() { + return skyColor; + } + + /** + * {@inheritDoc} + */ + @Override + public float skyLightFactor() { + return skyLightFactor; + } + + /** + * {@inheritDoc} + */ + @Override + public float fogStartDistance() { + return fogStartDistance; + } + + /** + * {@inheritDoc} + */ + @Override + public float fogEndDistance() { + return fogEndDistance; + } + + /** + * {@inheritDoc} + */ + @Override + public float skyFogEndDistance() { + return skyFogEndDistance; + } + + /** + * All presets, in declaration order. Backed by a cached array instead of + * calling the built-in {@code values()} every time, since that method + * allocates a fresh array on every call — worth avoiding if this ever + * gets called somewhere hot, like once per tick or per player. + * + * @return every declared preset + */ + public static StaticDimensionPreset[] getValues() { + return VALUES; + } +} \ No newline at end of file diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/dimension/package-info.java b/common/src/main/java/net/onelitefeather/cygnus/common/dimension/package-info.java new file mode 100644 index 00000000..0f8841a4 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/dimension/package-info.java @@ -0,0 +1,5 @@ + +@NotNullByDefault +package net.onelitefeather.cygnus.common.dimension; + +import org.jetbrains.annotations.NotNullByDefault; \ No newline at end of file diff --git a/common/src/main/java/net/onelitefeather/cygnus/common/util/ColorUtil.java b/common/src/main/java/net/onelitefeather/cygnus/common/util/ColorUtil.java new file mode 100644 index 00000000..d6f9d42b --- /dev/null +++ b/common/src/main/java/net/onelitefeather/cygnus/common/util/ColorUtil.java @@ -0,0 +1,96 @@ +package net.onelitefeather.cygnus.common.util; + +import net.minestom.server.color.Color; + +/** + * Converts HSL (hue, saturation, lightness) colors into the plain RGB + * {@link Color} that Minestom expects. + *

+ * The reason this exists: picking three random RGB values almost never looks + * good together, but picking colors as variations on one hue almost always + * does. HSL lets us say "same hue, just a bit darker" or "same hue, more + * washed out" instead of guessing at RGB numbers by hand. That's exactly what + * {@link SeededDimensionPreset} relies on to turn a single random seed into a + * fog/sky/light color set that actually feels like it belongs together. + *

+ * If you're not familiar with HSL: hue is the color itself (0 = red, 120 = + * green, 240 = blue, and it loops back around at 360), saturation is how + * intense or washed-out the color is, and lightness is how close it is to + * black or white. Turning a knob on any one of these while leaving the others + * alone gives you a predictable, related color — which is much harder to do + * by nudging R, G, and B by hand. + */ +public final class ColorUtil { + + private ColorUtil() { + // no instances, this is just a bag of static helpers + } + + /** + * Turns an HSL color into the RGB {@link Color} Minestom works with. + *

+ * Hue can technically be any float, in or out of the usual 0-360 range — + * negative values and values above 360 are wrapped around automatically, + * so you don't need to normalize it yourself before calling this. + * Saturation and lightness are expected in the 0-1 range, but are clamped + * defensively at the end just in case something upstream (e.g. a + * seed-based calculation) produces a value slightly outside that range. + * + * @param hue the hue in degrees; 0/360 = red, 120 = green, 240 = blue. + * Values outside 0-360 wrap around, so -30 behaves like 330. + * @param saturation how intense the color is, from 0 (gray, no color at + * all) to 1 (fully saturated) + * @param lightness how close to black or white the color is, from 0 + * (black) through 0.5 (the "pure" color) to 1 (white) + * @return the equivalent RGB color, ready to hand to Minestom + */ + public static Color hsl(float hue, float saturation, float lightness) { + float h = ((hue % 360f) + 360f) % 360f / 360f; + float r; + float g; + float b; + + if (saturation <= 0f) { + r = g = b = lightness; + } else { + float q = lightness < 0.5f + ? lightness * (1f + saturation) + : lightness + saturation - lightness * saturation; + float p = 2f * lightness - q; + r = hueToRgb(p, q, h + 1f / 3f); + g = hueToRgb(p, q, h); + b = hueToRgb(p, q, h - 1f / 3f); + } + + return new Color( + Math.round(clamp(r) * 255f), + Math.round(clamp(g) * 255f), + Math.round(clamp(b) * 255f) + ); + } + + /** + * The messy middle step of HSL -> RGB conversion. There's no intuitive + * way to explain what {@code p}, {@code q}, and {@code t} "mean" here — + * this is just the standard textbook formula for pulling one RGB channel + * (red, green, or blue) out of an HSL color, applied three times with a + * shifted {@code t} for each channel. Not worth reinventing. + */ + private static float hueToRgb(float p, float q, float t) { + if (t < 0f) t += 1f; + if (t > 1f) t -= 1f; + if (t < 1f / 6f) return p + (q - p) * 6f * t; + if (t < 1f / 2f) return q; + if (t < 2f / 3f) return p + (q - p) * (2f / 3f - t) * 6f; + return p; + } + + /** + * Keeps a value inside [0, 1] before it gets scaled up to a 0-255 color + * channel. Rounding and slightly-out-of-range inputs can otherwise push a + * channel just past 0 or 255, which would produce an invalid color. + */ + private static float clamp(float value) { + return Math.clamp(value, 0f, 1f); + } +} \ No newline at end of file diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/dimension/SeedDimensionPresetTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/dimension/SeedDimensionPresetTest.java new file mode 100644 index 00000000..fa8b674e --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/dimension/SeedDimensionPresetTest.java @@ -0,0 +1,70 @@ +package net.onelitefeather.cygnus.common.dimension; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class SeedDimensionPresetTest { + + private static final float MIN_SKY_LIGHT_FACTOR = 0.002f; + private static final float MAX_SKY_LIGHT_FACTOR = 0.06f; + private static final float MIN_FOG_START = 0f; + private static final float MAX_FOG_START = 64f; + private static final float MIN_FOG_END = 24f; + private static final float MAX_FOG_END = 384f; + private static final float MIN_SKY_FOG_END = 16f; + private static final float MAX_SKY_FOG_END = 256f; + + private static final int SAMPLE_SEED_COUNT = 500; + + @Test + void sameSeedProducesAnEqualPreset() { + assertEquals(SeedDimensionPreset.fromSeed(42L), SeedDimensionPreset.fromSeed(42L)); + } + + @Test + void differentSeedsProduceDifferentKeys() { + assertNotEquals( + SeedDimensionPreset.fromSeed(1L).getKey(), + SeedDimensionPreset.fromSeed(2L).getKey() + ); + } + + @Test + void keyFollowsTheDocumentedFormat() { + long seed = 123456789L; + String expectedKey = "seed_" + Long.toHexString(seed); + + assertEquals(expectedKey, SeedDimensionPreset.fromSeed(seed).getKey()); + } + + @Test + void generatedValuesStayWithinDocumentedBounds() { + for (long seed = 0; seed < SAMPLE_SEED_COUNT; seed++) { + SeedDimensionPreset preset = SeedDimensionPreset.fromSeed(seed); + + assertTrue(preset.skyLightFactor() >= MIN_SKY_LIGHT_FACTOR && preset.skyLightFactor() <= MAX_SKY_LIGHT_FACTOR, + "skyLightFactor out of bounds for seed " + seed); + assertTrue(preset.fogStartDistance() >= MIN_FOG_START && preset.fogStartDistance() <= MAX_FOG_START, + "fogStartDistance out of bounds for seed " + seed); + assertTrue(preset.fogEndDistance() >= MIN_FOG_END && preset.fogEndDistance() <= MAX_FOG_END, + "fogEndDistance out of bounds for seed " + seed); + assertTrue(preset.skyFogEndDistance() >= MIN_SKY_FOG_END && preset.skyFogEndDistance() <= MAX_SKY_FOG_END, + "skyFogEndDistance out of bounds for seed " + seed); + + assertNotNull(preset.fogColor(), "fogColor was null for seed " + seed); + assertNotNull(preset.skyLightColor(), "skyLightColor was null for seed " + seed); + assertNotNull(preset.skyColor(), "skyColor was null for seed " + seed); + } + } + + @Test + void fogStartDistanceNeverExceedsFogEndDistance() { + for (long seed = 0; seed < SAMPLE_SEED_COUNT; seed++) { + SeedDimensionPreset preset = SeedDimensionPreset.fromSeed(seed); + + assertTrue(preset.fogStartDistance() < preset.fogEndDistance(), + "fog start distance reached or passed fog end distance for seed " + seed); + } + } +} \ No newline at end of file diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/dimension/StaticDimensionPresetTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/dimension/StaticDimensionPresetTest.java new file mode 100644 index 00000000..699573b5 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/dimension/StaticDimensionPresetTest.java @@ -0,0 +1,45 @@ +package net.onelitefeather.cygnus.common.dimension; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class StaticDimensionPresetTest { + + @Test + void everyKeyIsUniqueAndNotBlank() { + Set keys = new HashSet<>(); + + for (StaticDimensionPreset preset : StaticDimensionPreset.getValues()) { + assertFalse(preset.getKey().isBlank(), preset.name() + " has a blank key"); + assertTrue(keys.add(preset.getKey()), "duplicate key: " + preset.getKey()); + } + } + + @Test + void everyPresetHasNonNullColors() { + for (StaticDimensionPreset preset : StaticDimensionPreset.getValues()) { + assertNotNull(preset.fogColor(), preset.name() + " has no fog color"); + assertNotNull(preset.skyLightColor(), preset.name() + " has no sky light color"); + assertNotNull(preset.skyColor(), preset.name() + " has no sky color"); + } + } + + @Test + void fogStartDistanceNeverExceedsFogEndDistance() { + for (StaticDimensionPreset preset : StaticDimensionPreset.getValues()) { + assertTrue(preset.fogStartDistance() <= preset.fogEndDistance(), + preset.name() + " has a fog start distance beyond its end distance"); + } + } + + @Test + void skyLightFactorIsNeverNegative() { + for (StaticDimensionPreset preset : StaticDimensionPreset.getValues()) { + assertTrue(preset.skyLightFactor() >= 0f, preset.name() + " has a negative sky light factor"); + } + } +} \ No newline at end of file diff --git a/common/src/test/java/net/onelitefeather/cygnus/common/util/ColorUtilTest.java b/common/src/test/java/net/onelitefeather/cygnus/common/util/ColorUtilTest.java new file mode 100644 index 00000000..1cdbfdc5 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/cygnus/common/util/ColorUtilTest.java @@ -0,0 +1,107 @@ +package net.onelitefeather.cygnus.common.util; + +import net.kyori.adventure.util.RGBLike; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.*; + +class ColorUtilTest { + + private static final int TOLERANCE = 1; + + @Test + void zeroSaturationAndZeroLightnessIsBlack() { + RGBLike color = ColorUtil.hsl(0f, 0f, 0f); + assertRgb(color, 0, 0, 0); + } + + @Test + void zeroSaturationAndFullLightnessIsWhite() { + RGBLike color = ColorUtil.hsl(0f, 0f, 1f); + assertRgb(color, 255, 255, 255); + } + + @Test + void zeroSaturationIsGrayRegardlessOfHue() { + RGBLike atZeroHue = ColorUtil.hsl(0f, 0f, 0.5f); + RGBLike atOtherHue = ColorUtil.hsl(275f, 0f, 0.5f); + + assertRgb(atZeroHue, 128, 128, 128); + assertEquals(atZeroHue.red(), atOtherHue.red()); + assertEquals(atZeroHue.green(), atOtherHue.green()); + assertEquals(atZeroHue.blue(), atOtherHue.blue()); + } + + @ParameterizedTest + @CsvSource({ + "0, 255, 0, 0", // red + "120, 0, 255, 0", // green + "240, 0, 0, 255" // blue + }) + void fullySaturatedPrimaryHues(float hue, int r, int g, int b) { + RGBLike color = ColorUtil.hsl(hue, 1f, 0.5f); + assertRgb(color, r, g, b); + } + + @Test + void hueWrapsAt360Degrees() { + RGBLike atZero = ColorUtil.hsl(0f, 1f, 0.5f); + RGBLike atFull = ColorUtil.hsl(360f, 1f, 0.5f); + + assertRgb(atFull, atZero.red(), atZero.green(), atZero.blue()); + } + + @Test + void negativeHueWrapsIntoValidRange() { + // -120 degrees should behave the same as 240 degrees (blue) + RGBLike negative = ColorUtil.hsl(-120f, 1f, 0.5f); + RGBLike equivalent = ColorUtil.hsl(240f, 1f, 0.5f); + + assertRgb(negative, equivalent.red(), equivalent.green(), equivalent.blue()); + } + + @Test + void hueGreaterThan360WrapsCorrectly() { + RGBLike overRotated = ColorUtil.hsl(480f, 1f, 0.5f); + assertRgb(overRotated, 0, 255, 0); + } + + @Test + void outputChannelsStayWithinValidByteRange() { + // sweep a range of inputs, including ones that could overflow rounding, + // and make sure every channel stays within [0, 255] + for (float hue = -720f; hue <= 720f; hue += 37.5f) { + for (float sat = -0.5f; sat <= 1.5f; sat += 0.5f) { + for (float light = -0.5f; light <= 1.5f; light += 0.5f) { + RGBLike color = ColorUtil.hsl(hue, sat, light); + + assertTrue(color.red() >= 0 && color.red() <= 255, + "red out of range for hue=" + hue + " sat=" + sat + " light=" + light); + assertTrue(color.green() >= 0 && color.green() <= 255, + "green out of range for hue=" + hue + " sat=" + sat + " light=" + light); + assertTrue(color.blue() >= 0 && color.blue() <= 255, + "blue out of range for hue=" + hue + " sat=" + sat + " light=" + light); + } + } + } + } + + /** + * Custom assertion for RGBLike colors. + * + * @param color to check + * @param expectedRed value + * @param expectedGreen value + * @param expectedBlue value + */ + private static void assertRgb(RGBLike color, int expectedRed, int expectedGreen, int expectedBlue) { + assertTrue(Math.abs(color.red() - expectedRed) <= TOLERANCE, + "red: expected " + expectedRed + " but was " + color.red()); + assertTrue(Math.abs(color.green() - expectedGreen) <= TOLERANCE, + "green: expected " + expectedGreen + " but was " + color.green()); + assertTrue(Math.abs(color.blue() - expectedBlue) <= TOLERANCE, + "blue: expected " + expectedBlue + " but was " + color.blue()); + } +} \ No newline at end of file diff --git a/game/src/main/java/net/onelitefeather/cygnus/CygnusLoader.java b/game/src/main/java/net/onelitefeather/cygnus/CygnusLoader.java index 2d704e83..09ed8577 100644 --- a/game/src/main/java/net/onelitefeather/cygnus/CygnusLoader.java +++ b/game/src/main/java/net/onelitefeather/cygnus/CygnusLoader.java @@ -4,11 +4,16 @@ import eu.cloudnetservice.driver.inject.InjectionLayer; import eu.cloudnetservice.modules.bridge.impl.platform.minestom.MinestomBridgeExtension; import net.minestom.server.MinecraftServer; +import net.onelitefeather.cygnus.common.dimension.DimensionFactory; public final class CygnusLoader { static void main() { MinecraftServer server = MinecraftServer.init(); + String customDimensions = System.getProperty("cygnus.customDimension", "false"); + if (Boolean.parseBoolean(customDimensions)) { + DimensionFactory.registerAll(); + } new Cygnus(); try (InjectionLayer layer = InjectionLayer.ext()) { layer.instance(MinestomBridgeExtension.class).onLoad();