diff --git a/README.md b/README.md index 6e614f5..27929f9 100644 --- a/README.md +++ b/README.md @@ -1 +1,14 @@ -# auroras \ No newline at end of file +# Auroras + +Auroras mod for Minetest. Adds northern/southern lights at night. +Since the Minetest world doesn't exactly have polar regions, auroras will appear in any sufficiently cold place. +Be patient—auroras aren't always visible! + +### Features: + +- Different auroras every night, with semi-realistic colors. +- Players near each other see similar colors. +- Configurable, no media/assets. + +This mod works with Minetest 5.2.0 or later, and is compatible with TestificateMods' Climate API/Regional Weather mods. +It probably won't work with other mods/games that regularly change the skybox. diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..a709a93 --- /dev/null +++ b/init.lua @@ -0,0 +1,471 @@ +auroras = {} + +--[[ + Various constants and defaults. Will be overridden by settings, if present. +]] + +auroras.UPDATE_INTERVAL = 1.5 + +auroras.TIME_START = 0.82 +auroras.TIME_END = 0.18 +auroras.TRANSITION_TIME = 0.02 + +auroras.HEIGHT_MIN = -24.0 +auroras.HEIGHT_TRANSITION = 8.0 + +auroras.HEAT_MIN = 0 +auroras.HEAT_MAX = 25 +auroras.HUM_MIN = 0 +auroras.HUM_MAX = 100 +auroras.BIOME_TRANSITION = 5 + +-- Warning: Changing offset, scale, octaves, or persistance could result in +-- uneven noise! +auroras.NOISE_PARAMS = { + offset = 0, + scale = 1, + spread = {x=800, y=60, z=800}, + octaves = 2, + persistence = 0.5, + lacunarity = 4.0, + flags = "eased", +} + +auroras.NOISE_MIN = -0.2 +auroras.NOISE_TRANSITION = 0.20 + +auroras.DAY_NIGHT_RATIO = 0.25 + +auroras.COLORS = { + "#14cca1", + "#22e6b2", + "#33ffc6", + "#49f2ac", + "#5ce673", + "#82f249", + "#6fd916", + "#a0f725", + "#c3ff19", + "#a9f73b", + "#d2f230", + "#d3e043", + "#dd564b", + "#d93648", + "#b3478e", +} + +-- Settings for when using Climate API. +auroras.CLIMATE_API_SKYBOX_PRIORITY = 40 +-- Use interval matching Climate API's skybox update frequency. +auroras.CLIMATE_API_UPDATE_INTERVAL = 2.0 + +-- Default sky colors, see minetest/src/skyparams.h. +auroras.BASE_SKY = {r=0, g=107, b=255} +auroras.BASE_HORIZON = {r=64, g=144, b=255} + +-- Default nighttime day-night ratio, see minetest/src/daynightratio.h. +auroras.BASE_DAY_NIGHT_RATIO = 0.175 + +auroras.SETTING_PREFIX = "auroras_" + +--[[ + End of constant definitions +]] + +auroras.USE_CLIMATE_API = minetest.get_modpath("climate_api") ~= nil + +auroras.player_data = {} +auroras.was_night = false + +--[[ + Function definitions +]] + +function auroras.get_settings() + local function get_num(key) + local val = minetest.settings:get(auroras.SETTING_PREFIX .. key) + return tonumber(val) -- Will return nil if val is nil. + end + + local a = auroras + + if a.USE_CLIMATE_API then + a.UPDATE_INTERVAL = a.CLIMATE_API_UPDATE_INTERVAL + else + a.UPDATE_INTERVAL = get_num("update_interval") or a.UPDATE_INTERVAL + end + + a.HEIGHT_MIN = get_num("height_min") or a.HEIGHT_MIN + a.HEAT_MIN = get_num("heat_min") or a.HEAT_MIN + a.HEAT_MAX = get_num("heat_max") or a.HEAT_MAX + a.HUM_MIN = get_num("heat_min") or a.HUM_MIN + a.HUM_MAX = get_num("heat_max") or a.HUM_MAX + a.DAY_NIGHT_RATIO = get_num("day_night_ratio") or a.DAY_NIGHT_RATIO + a.NOISE_PARAMS.spread.y = get_num("time_spread") or a.NOISE_PARAMS.spread.y + a.NOISE_MIN = get_num("noise_threshold") or a.NOISE_MIN + a.BIOME_TRANSITION = get_num("biome_transition") or a.BIOME_TRANSITION +end + +function auroras.clamp(x, low, high) + return math.max(low, math.min(high, x)) +end + +function auroras.interp_colors(x, y, fac) + local invFac = 1.0 - fac + return { + r = math.floor(x.r * invFac + y.r * fac), + g = math.floor(x.g * invFac + y.g * fac), + b = math.floor(x.b * invFac + y.b * fac) + } +end + +function auroras.parse_hex_color(hex) + if type(hex) == "string" and + hex:len() == 7 and + hex:sub(1, 1) == "#" then + + local tab = { + r = tonumber(hex:sub(2, 3), 16), + g = tonumber(hex:sub(4, 5), 16), + b = tonumber(hex:sub(6, 7), 16) + } + + if tab.r ~= nil and + tab.g ~= nil and + tab.b ~= nil then + return tab + end + end + + return nil -- Invalid color +end + +function auroras.init_colors() + -- Wrap in a function so we can return. + (function() + local colorList = minetest.settings:get( + auroras.SETTING_PREFIX .. "colors") + + if colorList ~= nil then + local colors = {} + local idx = 0 + -- Split at commas and spaces. + for hexCol in colorList:gmatch("[^%s%,]+") do + local tCol = auroras.parse_hex_color(hexCol) + + if tCol == nil then + minetest.log("error", "[auroras] Invalid hex color: " .. + dump(hexCol) .. ". Using default colors.") + return + else + colors[idx] = tCol + idx = idx + 1 + end + end + + if idx < 2 then + minetest.log("error", + "[auroras] At least two colors are required. " .. + "Using default colors.") + return + end + + auroras.COLOR_LUT = colors + end + end)() + + -- Color list from settings was nonexistent or malformed. + if auroras.COLOR_LUT == nil then + auroras.COLOR_LUT = {} + local idx = 0 + for i, hexCol in pairs(auroras.COLORS) do + local tCol = auroras.parse_hex_color(hexCol) + if tCol ~= nil then + auroras.COLOR_LUT[idx] = tCol + idx = idx + 1 + end + end + end +end + +function auroras.init_noise() + local params = auroras.NOISE_PARAMS + params.seed = os.time() + auroras.noise = PerlinNoise(params) +end + +function auroras.get_noise(pos) + -- Returns noise based on x/z position and time. + return auroras.noise:get_3d({ + x = pos.x, + -- Mod by 2^20 because the noise generator can't handle large numbers. + y = os.clock() % (2^20), + z = pos.z + }) +end + +function auroras.noise_curve(x) + -- Map values onto an s-curve similar to a smoothstep function. + -- Without this, numbers close to 1 almost never appear. + -- Equivalent to -0.5x^3 + 1.5x + return (-0.5 * x * x + 1.5) * x +end + +function auroras.init_biome_params() + -- Add biome transition so that strength will still be 1 at the limits. + auroras.HEAT_MEAN = (auroras.HEAT_MIN + auroras.HEAT_MAX) * 0.5 + auroras.HEAT_SPREAD = (auroras.HEAT_MAX - auroras.HEAT_MIN) * 0.5 + + auroras.BIOME_TRANSITION + auroras.HUM_MEAN = (auroras.HUM_MIN + auroras.HUM_MAX) * 0.5 + auroras.HUM_SPREAD = (auroras.HUM_MAX - auroras.HUM_MIN) * 0.5 + + auroras.BIOME_TRANSITION +end + +function auroras.get_local_strength(pos) + -- No auroras underground! + heightStrength = auroras.clamp( + (pos.y - auroras.HEIGHT_MIN) / auroras.HEIGHT_TRANSITION, 0.0, 1.0) + + -- Avoid getting biome data if we don't have to. + if heightStrength == 0.0 then + return 0.0 + end + + local bioData = minetest.get_biome_data(pos) + + local heatStrength = auroras.clamp( + (-math.abs(bioData.heat - auroras.HEAT_MEAN) + auroras.HEAT_SPREAD) / + auroras.BIOME_TRANSITION, + 0.0, 1.0 + ) + local humStrength = auroras.clamp( + (-math.abs(bioData.humidity - auroras.HUM_MEAN) + auroras.HUM_SPREAD) / + auroras.BIOME_TRANSITION, + 0.0, 1.0 + ) + + return heightStrength * heatStrength * humStrength +end + +function auroras.init_time_params() + auroras.TIME_MEAN = (auroras.TIME_START + auroras.TIME_END) * 0.5 + auroras.TIME_SPREAD = (auroras.TIME_START - auroras.TIME_END) * 0.5 +end + +function auroras.get_time_strength() + local timeOfDay = minetest.get_timeofday() + return auroras.clamp( + (math.abs(timeOfDay - auroras.TIME_MEAN) - auroras.TIME_SPREAD) / + auroras.TRANSITION_TIME, + 0.0, 1.0 + ) +end + +function auroras.set_day_night_ratio(player, dnr) + player:override_day_night_ratio(dnr) +end + +function auroras.set_sky(player, sky) + if auroras.USE_CLIMATE_API then + -- Just save the sky. + local pName = player:get_player_name() + auroras.player_data[pName].current_sky = sky + else + player:set_sky(sky) + end +end + +function auroras.save_sky(player) + local params = {player:get_sky()} + local sky = { + base_color = params[1], + type = params[2], + textures = params[3], + clouds = params[4], + sky_color = player:get_sky_color() + } + + auroras.player_data[player:get_player_name()].orig_sky = sky +end + +function auroras.restore_sky(player) + local pName = player:get_player_name() + if auroras.player_data[pName] == nil or + auroras.player_data[pName].orig_sky == nil then + return + end + + auroras.set_sky(player, auroras.player_data[pName].orig_sky) + auroras.player_data[pName].orig_sky = nil +end + +function auroras.get_base_sky_colors(playerData) + if auroras.USE_CLIMATE_API or not playerData.orig_sky then + return auroras.BASE_SKY, auroras.BASE_HORIZON + else + local origSkyColor = playerData.orig_sky.sky_color + return origSkyColor.night_sky or auroras.BASE_SKY, + origSkyColor.night_horizon or auroras.BASE_HORIZON + end +end + +function auroras.do_update() + local timeStrength = auroras.get_time_strength() + local isNight = timeStrength > 0.0 + + -- Don't waste time on midday calls. + if not isNight and not auroras.was_night then + return + end + + for _, player in ipairs(minetest.get_connected_players()) do + local pName = player:get_player_name() + + if auroras.player_data[pName] == nil then + auroras.player_data[pName] = { + was_visible = false + } + end + + local isVisible = false + local pos, biomeStrength, noiseVal + -- Determine if an aurora is visible for this player. + if isNight then + pos = player:get_pos() + biomeStrength = auroras.get_local_strength(pos) + if biomeStrength > 0.0 then + noiseVal = auroras.noise_curve(auroras.get_noise(pos)) + isVisible = noiseVal > auroras.NOISE_MIN + end + end + + if isVisible then + -- Save sky before changing anything. + if not auroras.USE_CLIMATE_API and + auroras.player_data[pName].orig_sky == nil then + auroras.save_sky(player) + end + + -- Transform noise for more or less aurora time. + noiseVal = (noiseVal - auroras.NOISE_MIN) / (1 - auroras.NOISE_MIN) + + -- Determine strength of aurora based on time, biome, and natural + -- fluctuations (noise). + local noiseStrength = auroras.clamp( + noiseVal / auroras.NOISE_TRANSITION, 0.0, 1.0) + local strength = timeStrength * biomeStrength * noiseStrength + + -- Get aurora color based on strength/noise. + local fIdx = math.min(noiseVal, 1.0) * #auroras.COLOR_LUT + local lowIdx = math.floor(fIdx) + local highIdx = math.ceil(fIdx) + local fac = fIdx - lowIdx + + local baseSky, baseHorizon = + auroras.get_base_sky_colors(auroras.player_data[pName]) + local skyColor = auroras.interp_colors( + baseSky, + auroras.interp_colors( + auroras.COLOR_LUT[lowIdx], + auroras.COLOR_LUT[highIdx], + fac + ), + strength + ) + + -- Set all sky colors for now, since gamma, etc. affects which is used. + auroras.set_sky(player, { + type = "regular", + sky_color = { + night_sky = skyColor, + dawn_sky = skyColor, + day_sky = skyColor, + night_horizon = baseHorizon, + dawn_horizon = baseHorizon, + day_horizon = baseHorizon, + } + }) + + -- Set day/night ratio to lighten the sky during auroras. + local dnr = auroras.BASE_DAY_NIGHT_RATIO * (1 - strength) + + auroras.DAY_NIGHT_RATIO * strength + + if dnr ~= auroras.player_data[pName].last_dnr then + auroras.set_day_night_ratio(player, dnr) + auroras.player_data[pName].last_dnr = dnr + end + elseif auroras.player_data[pName].was_visible then + -- Was visible, but not any more. + auroras.restore_sky(player) + auroras.set_day_night_ratio(player, nil) + end + + auroras.player_data[pName].was_visible = isVisible + end + + auroras.was_night = isNight +end + +function auroras.update() + auroras.do_update() + minetest.after(auroras.UPDATE_INTERVAL, auroras.update) +end + +function auroras.on_player_leave(player, timed_out) + auroras.player_data[player:get_player_name()] = nil +end + +-- Functions for Climate API support + +function auroras.climate_api_is_active(params) + if params.player then + pName = params.player:get_player_name() + if auroras.player_data[pName] and + auroras.player_data[pName].was_visible then + return true + end + end + return false +end + +function auroras.climate_api_get_effects(params) + data = {} + + if params.player then + pName = params.player:get_player_name() + if auroras.player_data[pName] and + auroras.player_data[pName].current_sky then + data["climate_api:skybox"] = { + sky_data = auroras.player_data[pName].current_sky, + priority = auroras.CLIMATE_API_SKYBOX_PRIORITY + } + end + end + + return data +end + +--[[ + End of function definitions +]] + +do + auroras.get_settings() + + auroras.init_colors() + auroras.init_noise() + auroras.init_biome_params() + auroras.init_time_params() + + -- TODO: Faster switching when player joins, time change, etc? + minetest.register_on_leaveplayer(auroras.on_player_leave) + + -- If climate_api is enabled, register auroras as a weather. + if auroras.USE_CLIMATE_API then + climate_api.register_weather("auroras:aurora", + auroras.climate_api_is_active, + auroras.climate_api_get_effects) + end + + minetest.after(auroras.UPDATE_INTERVAL, auroras.update) +end diff --git a/mod.conf b/mod.conf new file mode 100644 index 0000000..35afd0b --- /dev/null +++ b/mod.conf @@ -0,0 +1,3 @@ +name = auroras +description = Adds auroras (northern/southern lights) at night in cold places. +optional_depends = climate_api diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..8553ee4 Binary files /dev/null and b/screenshot.png differ diff --git a/settingtypes.txt b/settingtypes.txt new file mode 100644 index 0000000..b8c3364 --- /dev/null +++ b/settingtypes.txt @@ -0,0 +1,36 @@ +# Seconds between each update. Increase this number if load is too high. +# When using Climate API, update interval will default to 2.0 s. +auroras_update_interval (Update interval) float 1.5 0.5 5.0 + +# Minimum Y-value for auroras to appear. +auroras_height_min (Min. height) int -24 -31000 31000 + +# Minimum heat for auroras. Actual value will be lower to allow for smooth transitions. +auroras_heat_min (Min. heat) int 0 0 100 + +# Maximum heat for auroras. Actual value will be higher to allow for smooth transitions. +auroras_heat_max (Max. heat) int 25 0 100 + +# Minimum humidity for auroras. Actual value will be lower to allow for smooth transitions. +auroras_hum_min (Min. humidity) int 0 0 100 + +# Maximum humidity for auroras. Actual value will be higher to allow for smooth transitions. +auroras_hum_max (Max. humidity) int 100 0 100 + +# Hex colors of auroras. Colors at the towards the beginning will be blended with the default sky. +auroras_colors (Aurora colors) string #14cca1 #22e6b2 #33ffc6 #49f2ac #5ce673 #82f249 #6fd916 #a0f725 #c3ff19 #a9f73b #d2f230 #d3e043 #dd564b #d93648 #b3478e + +[Advanced] + +# Controls overall brightness during auroras. 0.175 is night, 1.0 is full daylight. +auroras_day_night_ratio (Day/night ratio) float 0.25 0.0 1.0 + +# Controls how quickly auroras change color. Higher values result in slower transitions. +auroras_time_spread (Noise time spread) int 60 20 600 + +# Minimum random noise value for auroras to appear, between -1 and 1. +# Lower values result in more auroras and also slower color transitions. +auroras_noise_threshold (Noise threshold) float -0.2 -1.0 0.9 + +# Controls how smoothly auroras start/stop at biome transitions. +auroras_biome_transition (Biome transition) int 5 1 50