diff --git a/lib/weathermanager/src/Client/WeatherClient.luau b/lib/weathermanager/src/Client/WeatherClient.luau new file mode 100644 index 00000000..28d3d804 --- /dev/null +++ b/lib/weathermanager/src/Client/WeatherClient.luau @@ -0,0 +1,558 @@ +-- WeatherClient.luau +-- Client-side weather visualization and synchronization + +--// Roblox Services //-- +local Lighting = game:GetService("Lighting") +local RunService = game:GetService("RunService") + +assert(RunService:IsClient(), "WeatherClient can only be required on the client") + +--// Imports //-- +local Packages = script.Parent.Parent.Parent +local Janitor = require(Packages.Janitor) +local T = require(Packages.T) +local Fusion = require(Packages.Fusion) +local WeatherUtil = require(Packages.WeatherManager.Shared.WeatherUtil) +local RemoteProperty = require(Packages.WeatherManager.Networking.RemoteProperty) + +--// Types //-- +type WeatherData = WeatherUtil.WeatherData + +--// Constants //-- +local ValidateWeatherData = WeatherUtil.ValidateWeatherData +local RegisteredWeather = WeatherUtil.RegisteredWeather +local WeatherStarted = WeatherUtil.WeatherStarted +local WeatherStopped = WeatherUtil.WeatherStopped + +--// State //-- +local RegisteredWeatherEffects = {} +local ActiveWeatherEffects = {} + +-- Setup networking +local ActiveWeatherProperty = RemoteProperty.fromServer("WeatherSystem_ActiveWeather") + +-- Maps each WeatherData category to the Roblox instance class it drives. +-- Used to create missing instances on demand when a weather effect that +-- references a category becomes active for the first time. +local CategoryInstanceRequirements = { + SkyboxData = { parent = Lighting, className = "Sky" }, + AtmosphereData = { parent = Lighting, className = "Atmosphere" }, + BloomData = { parent = Lighting, className = "BloomEffect" }, + BlurData = { parent = Lighting, className = "BlurEffect" }, + ColorCorrectionData = { parent = Lighting, className = "ColorCorrectionEffect" }, + DepthOfFieldData = { parent = Lighting, className = "DepthOfFieldEffect" }, + SunRaysData = { parent = Lighting, className = "SunRaysEffect" }, + CloudData = { parent = workspace.Terrain, className = "Clouds" }, +} + +-- Setup visual effects data +local AtmosphereData = {} +local CloudsData = {} +local LightingData = {} +local SkyboxData = {} +local BloomData = {} +local BlurData = {} +local ColorCorrectionData = {} +local DepthOfFieldData = {} +local SunRaysData = {} + +-------------------------------------------------------------------------------- +--// WeatherClient //-- +-------------------------------------------------------------------------------- + +local WeatherClient = {} + +--[=[ + @prop RegisteredWeather Signal + @within WeatherClient + Signal fired when a new weather type is registered. +]=] +WeatherClient.RegisteredWeather = RegisteredWeather + +--[=[ + @prop WeatherStarted Signal + @within WeatherClient + Signal fired when a weather effect starts. +]=] +WeatherClient.WeatherStarted = WeatherStarted + +--[=[ + @prop WeatherStopped Signal + @within WeatherClient + Signal fired when a weather effect stops. +]=] +WeatherClient.WeatherStopped = WeatherStopped + +-------------------------------------------------------------------------------- +--// Visual Effects Setup //-- +-------------------------------------------------------------------------------- + +-- Registered refresher functions, one per tracked property. Calling +-- RefreshBaselines() snapshots all current instance values into their +-- corresponding Fusion Values so the spring transitions FROM the live state. +local BaselineRefreshers: { () -> () } = {} + +local function trackProperty(scope, instance, propertyName) + local value = scope:Value(instance[propertyName]) + table.insert(BaselineRefreshers, function() + value:set(instance[propertyName]) + end) + return value +end + +local function RefreshBaselines() + for _, refresh in BaselineRefreshers do + refresh() + end +end + +local function GenerateDataCategory(scope, Data, DataCategory) + local SortedWeathers = scope:Value {} + + local function sortWeatherEffects() + local activeWeatherTypes = {} + for weatherType in pairs(ActiveWeatherEffects) do + table.insert(activeWeatherTypes, weatherType) + end + table.sort(activeWeatherTypes, function(a, b) + local aData = RegisteredWeatherEffects[a] + local bData = RegisteredWeatherEffects[b] + local aPriority = aData and ((aData[DataCategory] and aData[DataCategory].Priority) or aData.Priority) or 1 + local bPriority = bData and ((bData[DataCategory] and bData[DataCategory].Priority) or bData.Priority) or 1 + return aPriority > bPriority + end) + SortedWeathers:set(activeWeatherTypes) + end + + WeatherStarted:Connect(sortWeatherEffects) + WeatherStopped:Connect(sortWeatherEffects) + sortWeatherEffects() + + local function getHighestPriorityProperty(propertyName: string) + local computed = scope:Computed(function(use) + local sortedWeathers = use(SortedWeathers) + local propertyValue = Data[propertyName] + for _, weatherType in sortedWeathers do + local weatherData = RegisteredWeatherEffects[weatherType] + if weatherData and weatherData[DataCategory] and weatherData[DataCategory][propertyName] then + propertyValue = weatherData[DataCategory][propertyName] + break + end + end + return use(propertyValue) + end) + -- Only apply Spring to types Fusion can interpolate. Strings (texture IDs, + -- etc.) must be returned as plain Computed or Fusion errors with nil. + local baselineType = typeof(Fusion.peek(Data[propertyName])) + if baselineType == "number" or baselineType == "Color3" or baselineType == "Vector3" then + return scope:Spring(computed, 0.5) + end + return computed + end + + local tbl = {} + for propertyName in Data do + tbl[propertyName] = getHighestPriorityProperty(propertyName) + end + return tbl +end + +local function SetupSkybox(Sky: Sky) + local scope = Fusion.scoped(Fusion) + + SkyboxData = { + MoonAngularSize = trackProperty(scope, Sky, "MoonAngularSize"), + MoonTextureId = trackProperty(scope, Sky, "MoonTextureId"), + SkyboxBk = trackProperty(scope, Sky, "SkyboxBk"), + SkyboxDn = trackProperty(scope, Sky, "SkyboxDn"), + SkyboxFt = trackProperty(scope, Sky, "SkyboxFt"), + SkyboxLf = trackProperty(scope, Sky, "SkyboxLf"), + SkyboxRt = trackProperty(scope, Sky, "SkyboxRt"), + SkyboxUp = trackProperty(scope, Sky, "SkyboxUp"), + SkyboxOrientation = trackProperty(scope, Sky, "SkyboxOrientation"), + StarCount = trackProperty(scope, Sky, "StarCount"), + SunAngularSize = trackProperty(scope, Sky, "SunAngularSize"), + SunTextureId = trackProperty(scope, Sky, "SunTextureId"), + } + + local tbl = GenerateDataCategory(scope, SkyboxData, "SkyboxData") + scope:Hydrate(Sky)(tbl) +end + +local function SetupBloom(Bloom: BloomEffect) + local scope = Fusion.scoped(Fusion) + + BloomData = { + Intensity = trackProperty(scope, Bloom, "Intensity"), + Size = trackProperty(scope, Bloom, "Size"), + Threshold = trackProperty(scope, Bloom, "Threshold"), + } + + local tbl = GenerateDataCategory(scope, BloomData, "BloomData") + scope:Hydrate(Bloom)(tbl) +end + +local function SetupBlur(Blur: BlurEffect) + local scope = Fusion.scoped(Fusion) + + BlurData = { + Size = trackProperty(scope, Blur, "Size"), + } + + local tbl = GenerateDataCategory(scope, BlurData, "BlurData") + scope:Hydrate(Blur)(tbl) +end + +local function SetupColorCorrection(ColorCorrection: ColorCorrectionEffect) + local scope = Fusion.scoped(Fusion) + + ColorCorrectionData = { + Brightness = trackProperty(scope, ColorCorrection, "Brightness"), + Contrast = trackProperty(scope, ColorCorrection, "Contrast"), + Saturation = trackProperty(scope, ColorCorrection, "Saturation"), + TintColor = trackProperty(scope, ColorCorrection, "TintColor"), + } + + local tbl = GenerateDataCategory(scope, ColorCorrectionData, "ColorCorrectionData") + scope:Hydrate(ColorCorrection)(tbl) +end + +local function SetupDepthOfField(DepthOfField: DepthOfFieldEffect) + local scope = Fusion.scoped(Fusion) + + DepthOfFieldData = { + FarIntensity = trackProperty(scope, DepthOfField, "FarIntensity"), + FocusDistance = trackProperty(scope, DepthOfField, "FocusDistance"), + NearIntensity = trackProperty(scope, DepthOfField, "NearIntensity"), + InFocusRadius = trackProperty(scope, DepthOfField, "InFocusRadius"), + } + + local tbl = GenerateDataCategory(scope, DepthOfFieldData, "DepthOfFieldData") + scope:Hydrate(DepthOfField)(tbl) +end + +local function SetupSunRays(SunRays: SunRaysEffect) + local scope = Fusion.scoped(Fusion) + + SunRaysData = { + Intensity = trackProperty(scope, SunRays, "Intensity"), + Spread = trackProperty(scope, SunRays, "Spread"), + } + + local tbl = GenerateDataCategory(scope, SunRaysData, "SunRaysData") + scope:Hydrate(SunRays)(tbl) +end + +local function SetupAtmosphere(Atmosphere: Atmosphere) + local scope = Fusion.scoped(Fusion) + + AtmosphereData = { + Color = trackProperty(scope, Atmosphere, "Color"), + Decay = trackProperty(scope, Atmosphere, "Decay"), + Density = trackProperty(scope, Atmosphere, "Density"), + Offset = trackProperty(scope, Atmosphere, "Offset"), + Glare = trackProperty(scope, Atmosphere, "Glare"), + Haze = trackProperty(scope, Atmosphere, "Haze"), + } + + local tbl = GenerateDataCategory(scope, AtmosphereData, "AtmosphereData") + scope:Hydrate(Atmosphere)(tbl) +end + +local function SetupClouds(Clouds: Clouds) + local scope = Fusion.scoped(Fusion) + + CloudsData = { + Color = trackProperty(scope, Clouds, "Color"), + Cover = trackProperty(scope, Clouds, "Cover"), + Density = trackProperty(scope, Clouds, "Density"), + } + + local tbl = GenerateDataCategory(scope, CloudsData, "CloudsData") + scope:Hydrate(Clouds)(tbl) +end + +local function SetupLighting() + local scope = Fusion.scoped(Fusion) + + LightingData = { + Ambient = trackProperty(scope, Lighting, "Ambient"), + Brightness = trackProperty(scope, Lighting, "Brightness"), + ColorShift_Bottom = trackProperty(scope, Lighting, "ColorShift_Bottom"), + ColorShift_Top = trackProperty(scope, Lighting, "ColorShift_Top"), + EnvironmentDiffuseScale = trackProperty(scope, Lighting, "EnvironmentDiffuseScale"), + EnvironmentSpecularScale = trackProperty(scope, Lighting, "EnvironmentSpecularScale"), + OutdoorAmbient = trackProperty(scope, Lighting, "OutdoorAmbient"), + ShadowSoftness = trackProperty(scope, Lighting, "ShadowSoftness"), + ClockTime = trackProperty(scope, Lighting, "ClockTime"), + GeographicLatitude = trackProperty(scope, Lighting, "GeographicLatitude"), + ExposureCompensation = trackProperty(scope, Lighting, "ExposureCompensation"), + } + + local tbl = GenerateDataCategory(scope, LightingData, "LightingData") + scope:Hydrate(Lighting)(tbl) +end + +-------------------------------------------------------------------------------- +--// Methods //-- +-------------------------------------------------------------------------------- + +--[=[ + Registers a new weather type with its associated data. + Must match server registration. + @param weatherType string -- The name of the weather type to register. + @param weatherData WeatherData -- The config data associated with the weather type. +]=] +function WeatherClient.RegisterWeather(weatherType: string, weatherData: WeatherData) + assert(T.string(weatherType), "Weather type must be a string") + assert(not RegisteredWeatherEffects[weatherType], "Weather type already registered: " .. weatherType) + assert(ValidateWeatherData(weatherData)) + + RegisteredWeatherEffects[weatherType] = weatherData + RegisteredWeather:Fire(weatherType, weatherData) +end + +--[=[ + Returns an array of all registered weather types. +]=] +function WeatherClient.GetWeatherTypes(): { string } + local types = {} + for weatherType in pairs(RegisteredWeatherEffects) do + table.insert(types, weatherType) + end + return types +end + +--[=[ + Returns an array of all active weather effects. +]=] +function WeatherClient.GetActiveWeather(): { string } + local active = {} + for weatherType in pairs(ActiveWeatherEffects) do + table.insert(active, weatherType) + end + return active +end + +--[=[ + Checks if a specific weather type is currently active. +]=] +function WeatherClient.IsWeatherActive(weatherType: string): boolean + assert(T.string(weatherType), "Weather type must be a string") + if not RegisteredWeatherEffects[weatherType] then + warn("Weather type not registered: " .. weatherType) + return false + end + return ActiveWeatherEffects[weatherType] ~= nil +end + +--[=[ + Returns the config data for the specified weather type. +]=] +function WeatherClient.GetWeatherData(weatherType: string): WeatherData? + assert(T.string(weatherType), "Weather type must be a string") + return RegisteredWeatherEffects[weatherType] +end + +-------------------------------------------------------------------------------- +--// Internal Methods //-- +-------------------------------------------------------------------------------- + +local function StartWeather(weatherType: string) + local weatherData = RegisteredWeatherEffects[weatherType] + if not weatherData then + warn("Weather type not registered: " .. weatherType) + return + end + + if ActiveWeatherEffects[weatherType] then + warn("Weather type already active: " .. weatherType) + return + end + + -- Snapshot whatever the external lighting system currently has as the + -- baseline before any weather properties take over. + if next(ActiveWeatherEffects) == nil then + RefreshBaselines() + end + + -- Create any Roblox instances required by this weather's data categories + -- if they don't yet exist. ChildAdded fires synchronously on .Parent =, + -- so each Setup* function runs and its Fusion pipeline is fully wired + -- before WeatherStarted fires below. + for category, req in CategoryInstanceRequirements do + if weatherData[category] and not req.parent:FindFirstChildWhichIsA(req.className) then + Instance.new(req.className).Parent = req.parent + end + end + + local janitor = Janitor.new() + ActiveWeatherEffects[weatherType] = { + Janitor = janitor, + } + + if weatherData.OnStart then + janitor:Add(task.spawn(weatherData.OnStart, janitor)) + end + + WeatherStarted:Fire(weatherType) +end + +local function StopWeather(weatherType: string) + local weatherData = RegisteredWeatherEffects[weatherType] + if not weatherData then + warn("Weather type not registered: " .. weatherType) + return + end + + local activeWeather = ActiveWeatherEffects[weatherType] + if not activeWeather then + warn("Weather type not active: " .. weatherType) + return + end + + ActiveWeatherEffects[weatherType] = nil + + local janitor = activeWeather.Janitor + if weatherData.OnStop then + janitor:Add(task.spawn(weatherData.OnStop, janitor)) + end + janitor:Destroy() + + WeatherStopped:Fire(weatherType) +end + +-------------------------------------------------------------------------------- +--// Initialization //-- +-------------------------------------------------------------------------------- + +-- Setup visual effects +-- Lighting is always present, run immediately. +SetupLighting() + +-- For all optional child instances: run setup now if present, and watch for +-- late additions so an external system can create them at any point. +local function trySetup(instance, setupFn) + if instance then + setupFn(instance) + end +end + +trySetup(Lighting:FindFirstChildWhichIsA("Sky"), SetupSkybox) +trySetup(Lighting:FindFirstChildWhichIsA("Atmosphere"), SetupAtmosphere) +trySetup(Lighting:FindFirstChildWhichIsA("BloomEffect"), SetupBloom) +trySetup(Lighting:FindFirstChildWhichIsA("BlurEffect"), SetupBlur) +trySetup(Lighting:FindFirstChildWhichIsA("ColorCorrectionEffect"), SetupColorCorrection) +trySetup(Lighting:FindFirstChildWhichIsA("DepthOfFieldEffect"), SetupDepthOfField) +trySetup(Lighting:FindFirstChildWhichIsA("SunRaysEffect"), SetupSunRays) +trySetup(workspace.Terrain:FindFirstChildWhichIsA("Clouds"), SetupClouds) + +-- Map class names to their setup functions for the ChildAdded listeners below. +local lightingSetupByClass = { + Sky = SetupSkybox, + Atmosphere = SetupAtmosphere, + BloomEffect = SetupBloom, + BlurEffect = SetupBlur, + ColorCorrectionEffect = SetupColorCorrection, + DepthOfFieldEffect = SetupDepthOfField, + SunRaysEffect = SetupSunRays, +} + +Lighting.ChildAdded:Connect(function(child) + local setupFn = lightingSetupByClass[child.ClassName] + if setupFn then + setupFn(child) + end +end) + +workspace.Terrain.ChildAdded:Connect(function(child) + if child:IsA("Clouds") then + SetupClouds(child) + end +end) + +-- Sync active weather from server +ActiveWeatherProperty:Observe(function(activeWeather) + for _, weatherType in activeWeather do + if not WeatherClient.IsWeatherActive(weatherType) then + StartWeather(weatherType) + end + end + for weatherType, _ in ActiveWeatherEffects do + if not table.find(activeWeather, weatherType) then + StopWeather(weatherType) + end + end +end) + +-- Auto-discover and register weather modules from server +-- Wait for the WeatherServer's script to be available +local serverScript = script.Parent.Parent.Server.WeatherServer +local weatherModulesFolder = serverScript:WaitForChild("WeatherModules", 10) + +if weatherModulesFolder then + -- Register existing modules + for _, objectValue in weatherModulesFolder:GetChildren() do + if objectValue:IsA("ObjectValue") and objectValue.Value and objectValue.Value:IsA("ModuleScript") then + local weatherModule = objectValue.Value + local weatherType = objectValue.Name + + -- Require and register the weather config + local success, weatherData = pcall(require, weatherModule) + if success and type(weatherData) == "table" then + local registerSuccess, err = pcall(function() + WeatherClient.RegisterWeather(weatherType, weatherData) + end) + + if registerSuccess then + print("WeatherClient: Auto-registered weather '" .. weatherType .. "'") + else + warn("WeatherClient: Failed to register weather type '" .. weatherType .. "':", err) + end + else + warn("WeatherClient: Failed to require weather module for '" .. weatherType .. "':", weatherData) + end + end + end + + -- Listen for new modules being added + weatherModulesFolder.ChildAdded:Connect(function(objectValue) + if not objectValue:IsA("ObjectValue") then + return + end + + -- Wait for the value to be set if it hasn't been yet + if not objectValue.Value then + objectValue:GetPropertyChangedSignal("Value"):Wait() + end + + if objectValue.Value and objectValue.Value:IsA("ModuleScript") then + local weatherModule = objectValue.Value + local weatherType = objectValue.Name + + -- Require and register the weather config + local success, weatherData = pcall(require, weatherModule) + if success and type(weatherData) == "table" then + local registerSuccess, err = pcall(function() + WeatherClient.RegisterWeather(weatherType, weatherData) + end) + + if registerSuccess then + print("WeatherClient: Auto-registered weather '" .. weatherType .. "'") + else + warn("WeatherClient: Failed to register weather type '" .. weatherType .. "':", err) + end + else + warn("WeatherClient: Failed to require weather module for '" .. weatherType .. "':", weatherData) + end + end + end) +else + warn("WeatherClient: Could not find WeatherModules folder") +end + +-------------------------------------------------------------------------------- +--// Return //-- +-------------------------------------------------------------------------------- + +return WeatherClient diff --git a/lib/weathermanager/src/Networking/RemoteProperty.luau b/lib/weathermanager/src/Networking/RemoteProperty.luau new file mode 100644 index 00000000..54cda70c --- /dev/null +++ b/lib/weathermanager/src/Networking/RemoteProperty.luau @@ -0,0 +1,265 @@ +-- RemoteProperty.luau +-- Simple replicated property system for client-server synchronization + +local RunService = game:GetService("RunService") +local Players = game:GetService("Players") + +--// Imports //-- +local Packages = script.Parent.Parent.Parent +local Promise = require(Packages.Promise) + +--// Types //-- +type Promise = typeof(Promise.new( + function(resolve, reject: (...any) -> (), onCancel: (abortHandler: (() -> ())?) -> boolean) end +)) + +export type RemoteProperty = { + _remote: RemoteEvent, + _value: T, + _playerValues: { [Player]: T }, + _playerRemovingConn: RBXScriptConnection?, + _name: string, + _observers: { (T) -> () }?, + _initialized: boolean?, + _readyCallbacks: { () -> () }?, + Set: (self: RemoteProperty, value: T) -> (), + SetFor: (self: RemoteProperty, player: Player, value: T) -> (), + Get: (self: RemoteProperty) -> T, + GetFor: (self: RemoteProperty, player: Player) -> T, + ClearFor: (self: RemoteProperty, player: Player) -> (), + Observe: (self: RemoteProperty, callback: (T) -> ()) -> () -> (), + IsReady: (self: RemoteProperty) -> boolean, + OnReady: (self: RemoteProperty) -> Promise, + Destroy: (self: RemoteProperty) -> (), +} + +local IS_SERVER = RunService:IsServer() + +local function getFolder() + return script +end + +local RemoteProperty = {} +RemoteProperty.__index = RemoteProperty + +--[=[ + Creates a new RemoteProperty (Server-side only) + @param name string -- Name of the property + @param initialValue any -- Initial value + @return RemoteProperty +]=] +function RemoteProperty.new(name: string, initialValue: T): RemoteProperty + assert(IS_SERVER, "RemoteProperty.new can only be called on the server") + + local self = setmetatable({}, RemoteProperty) + + -- Create RemoteEvent for property updates + local folder = getFolder() + assert(not folder:FindFirstChild(name), "RemoteProperty with name '" .. name .. "' already exists") + local remote = Instance.new("RemoteEvent") + remote.Name = name + remote.Parent = folder + + self._remote = remote + self._value = initialValue + self._playerValues = {} + self._name = name + + -- Respond to each client's initial-value request with their personal value, + -- falling back to the shared default for players with no per-player override. + remote.OnServerEvent:Connect(function(player) + remote:FireClient(player, self:GetFor(player)) + end) + + -- Automatically remove stale per-player entries when a player leaves so this + -- object never holds a reference to a departed Player instance. + self._playerRemovingConn = Players.PlayerRemoving:Connect(function(player) + self._playerValues[player] = nil + end) + + return self +end + +--[=[ + Connects to an existing RemoteProperty from the server (Client-side only) + @param name string -- Name of the property + @return RemoteProperty +]=] +function RemoteProperty.fromServer(name: string) + assert(not IS_SERVER, "RemoteProperty.fromServer can only be called on the client") + + local self = setmetatable({}, RemoteProperty) + + local folder = getFolder() + local remote = folder:WaitForChild(name, 10) + + if not remote then + error("RemoteProperty " .. name .. " not found on server") + end + + self._remote = remote + self._name = name + self._observers = {} + self._value = nil + self._initialized = false + self._readyCallbacks = {} + + -- Listen for updates from server + remote.OnClientEvent:Connect(function(newValue) + self._value = newValue + local wasInitialized = self._initialized + self._initialized = true + + -- Fire observers + for _, callback in self._observers do + task.spawn(callback, newValue) + end + + -- Fire ready callbacks (promise resolvers) if this is the first value received + if not wasInitialized then + for _, resolve in self._readyCallbacks do + task.spawn(resolve) + end + self._readyCallbacks = {} -- Clear callbacks after firing + end + end) + + -- Request initial value from server + remote:FireServer() + + return self +end + +--[=[ + Sets the value of the property (Server-side only) + @param value any -- New value +]=] +function RemoteProperty:Set(value: any) + assert(IS_SERVER, "RemoteProperty:Set can only be called on the server") + + self._value = value + + -- Fire to all clients + self._remote:FireAllClients(value) +end + +--[=[ + Fires a value to a specific client only (Server-side only). + Does not update the shared default value seen by other clients. + @param player Player -- The player to send the value to + @param value any -- Value to send +]=] +function RemoteProperty:SetFor(player: Player, value: any) + assert(IS_SERVER, "RemoteProperty:SetFor can only be called on the server") + self._playerValues[player] = value + self._remote:FireClient(player, value) +end + +--[=[ + Returns the last value sent to a specific player (Server-side only). + Falls back to the shared default value if no per-player value has been set. + @param player Player -- The player to query + @return any +]=] +function RemoteProperty:GetFor(player: Player) + assert(IS_SERVER, "RemoteProperty:GetFor can only be called on the server") + local playerValue = self._playerValues[player] + return if playerValue ~= nil then playerValue else self._value +end + +--[=[ + Clears the per-player value for a specific player (Server-side only). + After this call, GetFor will return the shared default value for that player. + Does not notify the client; call SetFor or Set separately if needed. + @param player Player -- The player whose override should be cleared +]=] +function RemoteProperty:ClearFor(player: Player) + assert(IS_SERVER, "RemoteProperty:ClearFor can only be called on the server") + self._playerValues[player] = nil +end + +--[=[ + Gets the current value of the property + @return any +]=] +function RemoteProperty:Get() + return self._value +end + +--[=[ + Observes changes to the property (Client-side only) + Will wait for the initial value from the server before calling the callback. + @param callback function -- Called when value changes + @return function -- Disconnect function +]=] +function RemoteProperty:Observe(callback: (any) -> ()) + assert(not IS_SERVER, "RemoteProperty:Observe can only be called on the client") + + if not self._observers then + self._observers = {} + end + + table.insert(self._observers, callback) + + -- Only call immediately if we've received the initial value + if self._initialized then + task.spawn(callback, self._value) + end + + -- Return disconnect function + return function() + local index = table.find(self._observers, callback) + if index then + table.remove(self._observers, index) + end + end +end + +--[=[ + Checks if the property has received its initial value from the server (Client-side only) + @return boolean -- True if the initial value has been received +]=] +function RemoteProperty:IsReady(): boolean + assert(not IS_SERVER, "RemoteProperty:IsReady can only be called on the client") + return self._initialized or false +end + +--[=[ + Returns a promise that resolves when the property receives its initial value (Client-side only) + If already ready, the promise resolves immediately. + @return Promise -- Promise that resolves when ready +]=] +function RemoteProperty:OnReady(): Promise + assert(not IS_SERVER, "RemoteProperty:OnReady can only be called on the client") + + -- If already initialized, return a resolved promise + if self._initialized then + return Promise.resolve() + end + + -- Otherwise, return a promise that resolves when ready + return Promise.new(function(resolve) + if not self._readyCallbacks then + self._readyCallbacks = {} + end + + table.insert(self._readyCallbacks, resolve) + end) +end + +--[=[ + Destroys this RemoteProperty, disconnecting all internal connections and + removing the underlying RemoteEvent. Should be called when the property + is no longer needed to prevent memory leaks. (Server-side only) +]=] +function RemoteProperty:Destroy() + assert(IS_SERVER, "RemoteProperty:Destroy can only be called on the server") + if self._playerRemovingConn then + self._playerRemovingConn:Disconnect() + self._playerRemovingConn = nil + end + table.clear(self._playerValues) + self._remote:Destroy() +end + +return RemoteProperty diff --git a/lib/weathermanager/src/Server/ClientWeatherBootstrapper.client.luau b/lib/weathermanager/src/Server/ClientWeatherBootstrapper.client.luau new file mode 100644 index 00000000..672c7213 --- /dev/null +++ b/lib/weathermanager/src/Server/ClientWeatherBootstrapper.client.luau @@ -0,0 +1,25 @@ +-- ClientWeatherBootstrapper.client.luau +-- Bootstraps the weather system on the client by requiring the WeatherClient + +local RunService = game:GetService("RunService") + +assert(RunService:IsClient(), "ClientWeatherBootstrapper must run on the client") + +-- Get the WeatherClient module reference from the ObjectValue +local weatherClientRef = script:WaitForChild("WeatherClientModule", 10) +if not weatherClientRef or not weatherClientRef.Value then + error("ClientWeatherBootstrapper: WeatherClientModule reference not found") +end + +local weatherClientModule = weatherClientRef.Value +if not weatherClientModule:IsA("ModuleScript") then + error("ClientWeatherBootstrapper: WeatherClientModule is not a ModuleScript") +end + +-- Require the WeatherClient to initialize it +local success, result = pcall(require, weatherClientModule) +if not success then + error("ClientWeatherBootstrapper: Failed to require WeatherClient: " .. tostring(result)) +end + +print("ClientWeatherBootstrapper: WeatherClient initialized") diff --git a/lib/weathermanager/src/Server/WeatherServer.luau b/lib/weathermanager/src/Server/WeatherServer.luau new file mode 100644 index 00000000..b130b7fd --- /dev/null +++ b/lib/weathermanager/src/Server/WeatherServer.luau @@ -0,0 +1,657 @@ +-- WeatherServer.luau +-- Server-side weather management system + +--// Roblox Services //-- +local RunService = game:GetService("RunService") +local Players = game:GetService("Players") + +assert(RunService:IsServer(), "WeatherServer can only be required on the server") + +--// Imports //-- +local Packages = script.Parent.Parent.Parent +local T = require(Packages.T) +local Janitor = require(Packages.Janitor) +local WeatherUtil = require(Packages.WeatherManager.Shared.WeatherUtil) +local RemoteProperty = require(Packages.WeatherManager.Networking.RemoteProperty) + +--// Types //-- +type WeatherData = WeatherUtil.WeatherData + +--// Constants //-- +local MINUTE = 60 +local ValidateWeatherData = WeatherUtil.ValidateWeatherData +local RegisteredWeather = WeatherUtil.RegisteredWeather +local WeatherStarted = WeatherUtil.WeatherStarted +local WeatherStopped = WeatherUtil.WeatherStopped + +--// State //-- +local RegisteredWeatherEffects = {} +local ActiveWeatherEffects = {} +local PlayerWeatherEffects = {} -- Per-player weather overrides: { [Player]: { [string]: { Janitor: Janitor, EndTime: number? } } } +local WeatherCycleEnabled = false +local WeatherCycleThread = nil +local LastWeatherPicked = nil +local ExpirationLoopThread = nil -- Track the expiration loop thread + +-- global active weather + any per-player overrides, deduplicated. +local getEffectiveWeatherForPlayer = function(player: Player): { string } + local active = {} + local seen = {} + for weatherType in pairs(ActiveWeatherEffects) do + seen[weatherType] = true + table.insert(active, weatherType) + end + local playerEffects = PlayerWeatherEffects[player] + if playerEffects then + for weatherType in pairs(playerEffects) do + if not seen[weatherType] then + seen[weatherType] = true + table.insert(active, weatherType) + end + end + end + return active +end + +-- Setup networking; GetFor handles per-player values with fallback to the shared default +local ActiveWeatherProperty = RemoteProperty.new("WeatherSystem_ActiveWeather", {}) + +-------------------------------------------------------------------------------- +--// WeatherServer //-- +-------------------------------------------------------------------------------- + +local WeatherServer = {} + +-------------------------------------------------------------------------------- +--// Internal Methods //-- +-------------------------------------------------------------------------------- + +-- Create a folder to hold ObjectValues pointing to weather modules +local function getWeatherModulesFolder() + local folder = script:FindFirstChild("WeatherModules") + if not folder then + folder = Instance.new("Folder") + folder.Name = "WeatherModules" + folder.Parent = script + end + return folder +end + +local function pushWeatherToPlayers(players: { Player }) + for _, player in players do + ActiveWeatherProperty:SetFor(player, getEffectiveWeatherForPlayer(player)) + end +end + +local function pushWeatherToAllPlayers() + pushWeatherToPlayers(Players:GetPlayers()) +end + +local function StartExpirationLoop() + -- Don't start if already running + if ExpirationLoopThread then + return + end + + ExpirationLoopThread = task.spawn(function() + while true do + local currentTime = workspace:GetServerTimeNow() + local hasActiveWeather = false + + for weatherType, activeWeather in pairs(ActiveWeatherEffects) do + hasActiveWeather = true + if activeWeather.EndTime and currentTime >= activeWeather.EndTime then + WeatherServer.StopWeather(weatherType) + end + end + + -- Check per-player weather expiration + for player, playerEffects in pairs(PlayerWeatherEffects) do + for weatherType, activeWeather in pairs(playerEffects) do + hasActiveWeather = true + if activeWeather.EndTime and currentTime >= activeWeather.EndTime then + WeatherServer.StopWeather(weatherType, { player }) + end + end + end + + -- If no active weather (global or per-player), stop the loop + if not hasActiveWeather then + ExpirationLoopThread = nil + return + end + + task.wait(1) -- Check every second + end + end) +end + +-------------------------------------------------------------------------------- +--// Public Methods //-- +-------------------------------------------------------------------------------- + +--[=[ + @prop RegisteredWeather Signal + @within WeatherServer + Signal fired when a new weather type is registered. +]=] +WeatherServer.RegisteredWeather = RegisteredWeather + +--[=[ + @prop WeatherStarted Signal + @within WeatherServer + Signal fired when a weather effect starts. +]=] +WeatherServer.WeatherStarted = WeatherStarted + +--[=[ + @prop WeatherStopped Signal + @within WeatherServer + Signal fired when a weather effect stops. +]=] +WeatherServer.WeatherStopped = WeatherStopped + +-------------------------------------------------------------------------------- +--// Methods //-- +-------------------------------------------------------------------------------- + +--[=[ + Registers a new weather type with its associated data. + @param weatherType string -- The name of the weather type to register. + @param weatherData WeatherData -- The config data associated with the weather type. +]=] +function WeatherServer.RegisterWeather(weatherType: string, weatherData: WeatherData) + assert(T.string(weatherType), "Weather type must be a string") + assert(not RegisteredWeatherEffects[weatherType], "Weather type already registered: " .. weatherType) + assert(ValidateWeatherData(weatherData)) + + RegisteredWeatherEffects[weatherType] = weatherData + RegisteredWeather:Fire(weatherType, weatherData) +end + +--[=[ + Registers a weather effect from a ModuleScript. + The module must be in a visible location (ReplicatedStorage or ServerStorage) for client access. + The module should return a single weather configuration table (not a collection of multiple weather types). + @param weatherModule ModuleScript -- Module containing a single weather configuration + @return string -- The path to the module for client access + + @example + ```lua + -- RainWeather.lua (returns a single weather config) + return { + DisplayName = "Rain", + Description = "Light rainfall", + WeatherCycleData = { + SelectionWeight = 5, + Duration = NumberRange.new(120, 300), + }, + -- ... other weather data + } + + -- Server code + local RainModule = game.ReplicatedStorage.Weather.RainWeather + WeatherServer.RegisterWeatherFromModule(RainModule) + ``` +]=] +function WeatherServer.RegisterWeatherFromModule(weatherModule: ModuleScript): string + assert( + typeof(weatherModule) == "Instance" and weatherModule:IsA("ModuleScript"), + "weatherModule must be a ModuleScript" + ) + + -- Check if module is in a visible location + local ReplicatedStorage = game:GetService("ReplicatedStorage") + + local isInReplicatedStorage = weatherModule:IsDescendantOf(ReplicatedStorage) + local isInWorkspace = weatherModule:IsDescendantOf(workspace) + + if not (isInReplicatedStorage or isInWorkspace) then + error( + "WeatherModule must be located in ReplicatedStorage or Workspace for client access. Current location: " + .. weatherModule:GetFullName() + ) + end + + -- Require and validate the module + local weatherData = require(weatherModule) + assert(type(weatherData) == "table", "WeatherModule must return a weather configuration table") + assert(ValidateWeatherData(weatherData), "Invalid weather data in module") + + -- Use the module name as the weather type + local weatherType = weatherModule.Name + + -- Get the path for client to require + local modulePath = weatherModule:GetFullName() + + -- Register the weather type + WeatherServer.RegisterWeather(weatherType, weatherData) + + -- Create an ObjectValue pointing to the module for client discovery + local weatherModulesFolder = getWeatherModulesFolder() + local objectValue = Instance.new("ObjectValue") + objectValue.Name = weatherType + objectValue.Value = weatherModule + objectValue.Parent = weatherModulesFolder + + print("WeatherServer: Registered weather '" .. weatherType .. "' from module:", modulePath) + return modulePath +end + +--[=[ + Returns an array of all registered weather types. +]=] +function WeatherServer.GetWeatherTypes(): { string } + local types = {} + for weatherType in pairs(RegisteredWeatherEffects) do + table.insert(types, weatherType) + end + return types +end + +--[=[ + Returns an array of all currently active weather types. +]=] +function WeatherServer.GetActiveWeather(): { string } + local active = {} + for weatherType in pairs(ActiveWeatherEffects) do + table.insert(active, weatherType) + end + return active +end + +--[=[ + Returns the config data that was given for the specified weather type. +]=] +function WeatherServer.GetWeatherData(weatherType: string): WeatherData? + assert(T.string(weatherType), "Weather type must be a string") + return RegisteredWeatherEffects[weatherType] +end + +--[=[ + Checks if a specific weather type is currently active. +]=] +function WeatherServer.IsWeatherActive(weatherType: string): boolean + assert(T.string(weatherType), "Weather type must be a string") + if not RegisteredWeatherEffects[weatherType] then + warn("Weather type not registered: " .. weatherType) + return false + end + return ActiveWeatherEffects[weatherType] ~= nil +end + +--[=[ + Starts a weather effect of the specified type. + @param weatherType string -- The weather type to start + @param config table? -- Optional configuration: + - `Duration` (number | NumberRange)? — auto-stop after this many seconds + - `Players` { Player }? — if provided, applies the effect only to those players; + if nil, applies globally to all players +]=] +function WeatherServer.StartWeather( + weatherType: string, + config: { + Duration: (number | NumberRange)?, + Players: { Player }?, + }? +) + local weatherData = RegisteredWeatherEffects[weatherType] + if not weatherData then + warn("Weather type not registered: " .. weatherType) + return + end + + local Config = config or {} + assert(T.interface { + Duration = T.optional(T.union(T.numberPositive, T.typeof("NumberRange"))), + Players = T.optional(T.array(T.instanceIsA("Player"))), + }(Config)) + + -- Resolve NumberRange to a concrete number + if typeof(Config.Duration) == "NumberRange" then + Config = table.clone(Config) + Config.Duration = math.random(Config.Duration.Min, Config.Duration.Max) + end + + -- Per-player path + if Config.Players then + local anyNewlyStarted = false + for _, player in Config.Players do + if not PlayerWeatherEffects[player] then + PlayerWeatherEffects[player] = {} + end + local existing = PlayerWeatherEffects[player][weatherType] + if existing then + -- Update the end time for an already-active per-player effect + if Config.Duration then + existing.EndTime = workspace:GetServerTimeNow() + Config.Duration + else + existing.EndTime = nil + end + else + local janitor = Janitor.new() + PlayerWeatherEffects[player][weatherType] = { + Janitor = janitor, + EndTime = if Config.Duration then workspace:GetServerTimeNow() + Config.Duration else nil, + } + if weatherData.OnStart then + janitor:Add(task.spawn(weatherData.OnStart, janitor)) + end + anyNewlyStarted = true + end + end + if Config.Duration then + StartExpirationLoop() + end + pushWeatherToPlayers(Config.Players) + if anyNewlyStarted then + WeatherStarted:Fire(weatherType) + end + return + end + + -- Global path + local activeWeather = ActiveWeatherEffects[weatherType] + if activeWeather then + if Config.Duration then + activeWeather.EndTime = workspace:GetServerTimeNow() + Config.Duration + else + activeWeather.EndTime = nil + end + return + end + + local janitor = Janitor.new() + ActiveWeatherEffects[weatherType] = { + Janitor = janitor, + EndTime = if Config.Duration then workspace:GetServerTimeNow() + Config.Duration else nil, + } + + -- Update all clients + pushWeatherToAllPlayers() + + -- Start expiration loop if needed + if Config.Duration then + StartExpirationLoop() + end + + if weatherData.OnStart then + janitor:Add(task.spawn(weatherData.OnStart, janitor)) + end + + WeatherStarted:Fire(weatherType) +end + +--[=[ + Stops a weather effect of the specified type. + @param weatherType string -- The weather type to stop + @param players { Player }? -- If provided, stops the per-player override for only those players. + If nil, stops the global effect AND sweeps any per-player overrides for this type. +]=] +function WeatherServer.StopWeather(weatherType: string, players: { Player }?) + local weatherData = RegisteredWeatherEffects[weatherType] + if not weatherData then + warn("Weather type not registered: " .. weatherType) + return + end + + -- Per-player targeted stop + if players then + local affectedPlayers = {} + for _, player in players do + local playerEffects = PlayerWeatherEffects[player] + if not playerEffects or not playerEffects[weatherType] then + continue + end + local entry = playerEffects[weatherType] + playerEffects[weatherType] = nil + if next(playerEffects) == nil then + PlayerWeatherEffects[player] = nil + end + local janitor = entry.Janitor + if weatherData.OnStop then + janitor:Add(task.spawn(weatherData.OnStop, janitor)) + end + janitor:Destroy() + table.insert(affectedPlayers, player) + end + if #affectedPlayers > 0 then + pushWeatherToPlayers(affectedPlayers) + WeatherStopped:Fire(weatherType) + end + return + end + + -- Global stop: stop the global effect and sweep all per-player overrides for this type + local activeWeather = ActiveWeatherEffects[weatherType] + local stoppedSomething = false + + if activeWeather then + ActiveWeatherEffects[weatherType] = nil + local janitor = activeWeather.Janitor + if weatherData.OnStop then + janitor:Add(task.spawn(weatherData.OnStop, janitor)) + end + janitor:Destroy() + stoppedSomething = true + end + + -- Sweep per-player overrides for this weather type + for player, playerEffects in pairs(PlayerWeatherEffects) do + if playerEffects[weatherType] then + local entry = playerEffects[weatherType] + playerEffects[weatherType] = nil + if next(playerEffects) == nil then + PlayerWeatherEffects[player] = nil + end + local janitor = entry.Janitor + if weatherData.OnStop then + janitor:Add(task.spawn(weatherData.OnStop, janitor)) + end + janitor:Destroy() + stoppedSomething = true + end + end + + if not stoppedSomething then + warn(weatherType .. " is not currently active.") + return + end + + pushWeatherToAllPlayers() + WeatherStopped:Fire(weatherType) +end + +--[=[ + Returns the effective active weather for a specific player. + This includes both global weather and any per-player overrides. + @param player Player -- The player to query +]=] +function WeatherServer.GetActiveWeatherForPlayer(player: Player): { string } + return ActiveWeatherProperty:GetFor(player) +end + +--[=[ + Checks if a specific weather type is currently active for a given player. + Returns true if the weather is active globally OR as a per-player override for this player. + @param player Player -- The player to check + @param weatherType string -- The weather type to check +]=] +function WeatherServer.IsWeatherActiveForPlayer(player: Player, weatherType: string): boolean + return table.find(ActiveWeatherProperty:GetFor(player), weatherType) ~= nil +end + +--[=[ + Stops all per-player weather overrides for the given player. + Global weather is unaffected. + @param player Player -- The player whose overrides should be cleared +]=] +function WeatherServer.StopAllWeatherForPlayer(player: Player) + local playerEffects = PlayerWeatherEffects[player] + if not playerEffects then + return + end + -- Collect keys first to avoid mutating the table during iteration + local weatherTypes = {} + for weatherType in pairs(playerEffects) do + table.insert(weatherTypes, weatherType) + end + for _, weatherType in weatherTypes do + WeatherServer.StopWeather(weatherType, { player }) + end +end + +local function RunWeatherCycle() + local weatherWeights = {} + local totalWeight = 0 + + for weatherType, weatherData in RegisteredWeatherEffects do + local weight = weatherData.WeatherCycleData and weatherData.WeatherCycleData.SelectionWeight or 1 + if weatherType == LastWeatherPicked then + weight = weight * 0.1 -- Reduce weight for last picked + end + table.insert(weatherWeights, { + WeatherType = weatherType, + Weight = weight, + }) + totalWeight = totalWeight + weight + end + + -- Weighted random selection + local randomValue = math.random() * totalWeight + local pickedWeather = nil + local currentWeight = 0 + + for _, entry in weatherWeights do + currentWeight = currentWeight + entry.Weight + if randomValue <= currentWeight then + pickedWeather = entry.WeatherType + break + end + end + + if not pickedWeather then + return + end + + local pickedWeatherData = WeatherServer.GetWeatherData(pickedWeather) + local weatherCycleData = pickedWeatherData.WeatherCycleData or {} + local durationValue = weatherCycleData.Duration or 60 + local duration = typeof(durationValue) == "NumberRange" and math.random(durationValue.Min, durationValue.Max) + or durationValue + + local minutes, seconds = math.floor(duration / 60), math.floor(duration % 60) + print("WeatherServer: Starting weather:", pickedWeather) + print("Duration:", string.format("%02d:%02d", minutes, seconds)) + + WeatherServer.StartWeather(pickedWeather, { + Duration = duration, + }) + LastWeatherPicked = pickedWeather +end + +--[=[ + Enables automatic weather cycling. + @param config table? -- Optional config: {DelayBetweenCycles: NumberRange?} +]=] +function WeatherServer.EnableWeatherCycle(config: { + DelayBetweenCycles: NumberRange?, +}?) + if WeatherCycleEnabled then + warn("Weather cycle already enabled") + return + end + + WeatherCycleEnabled = true + local Config = config or {} + local DELAY_BETWEEN_CYCLES = Config.DelayBetweenCycles or NumberRange.new(5 * MINUTE, 15 * MINUTE) + + WeatherCycleThread = task.spawn(function() + while WeatherCycleEnabled do + RunWeatherCycle() + local delay = math.random(DELAY_BETWEEN_CYCLES.Min, DELAY_BETWEEN_CYCLES.Max) + local minutes, seconds = math.floor(delay / 60), math.floor(delay % 60) + print("WeatherServer: Next weather cycle in:", string.format("%02d:%02d", minutes, seconds)) + task.wait(delay) + end + end) +end + +--[=[ + Disables automatic weather cycling. +]=] +function WeatherServer.DisableWeatherCycle() + WeatherCycleEnabled = false + if WeatherCycleThread then + task.cancel(WeatherCycleThread) + WeatherCycleThread = nil + end +end +-------------------------------------------------------------------------------- +--// Initialization //-- +-------------------------------------------------------------------------------- + +--[=[ + Sets up the ClientWeatherBootstrapper for all clients. + This parents the bootstrapper to StarterPlayer.StarterPlayerScripts and existing player's PlayerScripts. +]=] +local function SetupClientBootstrapper() + local Players = game:GetService("Players") + local StarterPlayer = game:GetService("StarterPlayer") + local StarterPlayerScripts = StarterPlayer:WaitForChild("StarterPlayerScripts") + + -- Find the bootstrapper script + local bootstrapper = script.Parent:FindFirstChild("ClientWeatherBootstrapper") + if not bootstrapper then + error("WeatherServer: ClientWeatherBootstrapper script not found") + end + + -- Create an ObjectValue pointing to the WeatherClient module for easy discovery + local weatherClientRef = bootstrapper:FindFirstChild("WeatherClientModule") + if not weatherClientRef then + weatherClientRef = Instance.new("ObjectValue") + weatherClientRef.Name = "WeatherClientModule" + weatherClientRef.Value = script.Parent.Parent.Client.WeatherClient + weatherClientRef.Parent = bootstrapper + end + + -- Clone to StarterPlayerScripts if not already there + if not StarterPlayerScripts:FindFirstChild("ClientWeatherBootstrapper") then + local bootstrapperClone = bootstrapper:Clone() + bootstrapperClone.Parent = StarterPlayerScripts + end + + -- Add to existing players + for _, player in Players:GetPlayers() do + local playerScripts = player:FindFirstChild("PlayerScripts") + if playerScripts and not playerScripts:FindFirstChild("ClientWeatherBootstrapper") then + local bootstrapperClone = bootstrapper:Clone() + bootstrapperClone.Parent = playerScripts + end + end +end + +-- Populate a new player's entry in RemoteProperty before their scripts can fire, +-- so GetFor always returns the correct initial weather even without a prior SetFor call. +Players.PlayerAdded:Connect(function(player) + ActiveWeatherProperty:SetFor(player, getEffectiveWeatherForPlayer(player)) +end) + +-- Clean up per-player weather state when a player leaves to prevent memory leaks +Players.PlayerRemoving:Connect(function(player) + local playerEffects = PlayerWeatherEffects[player] + if playerEffects then + for _, entry in pairs(playerEffects) do + entry.Janitor:Destroy() + end + PlayerWeatherEffects[player] = nil + end +end) + +task.defer(SetupClientBootstrapper) + +-------------------------------------------------------------------------------- +--// Return //-- +-------------------------------------------------------------------------------- + +return WeatherServer diff --git a/lib/weathermanager/src/Shared/WeatherUtil.luau b/lib/weathermanager/src/Shared/WeatherUtil.luau new file mode 100644 index 00000000..61abc972 --- /dev/null +++ b/lib/weathermanager/src/Shared/WeatherUtil.luau @@ -0,0 +1,81 @@ +local Janitor = require(script.Parent.Parent.Janitor) +local Signal = require(script.Parent.Parent.Signal) +local T = require(script.Parent.Parent.T) + +--// Signals //-- +local RegisteredWeather = Signal.new() +local WeatherStarted = Signal.new() +local WeatherStopped = Signal.new() + +type LightingData = { + Priority: number?, -- Priority for lighting changes. Overrides lower priority lighting data + Ambient: Color3?, + Brightness: number?, + ColorShift_Bottom: Color3?, + ColorShift_Top: Color3?, + EnvironmentDiffuseScale: number?, + EnvironmentSpecularScale: number?, + OutdoorAmbient: Color3?, + ShadowSoftness: number?, + ClockTime: number?, + GeographicLatitude: number?, + ExposureCompensation: number?, +} + +--// Types //-- +type Janitor = typeof(Janitor.new()) +export type WeatherData = { + DisplayName: string, + Description: string?, -- Optional description for the weather effect + + WeatherCycleData: { -- Data used when weather is randomly selected during the weather cycle + SelectionWeight: number?, -- weight for random selection. Higher weight means more likely to be selected + Duration: number | NumberRange?, -- duration for the weather effect + }?, + + Priority: number?, -- Priority for the weather effect. Higher priority overrides lower priority weather effects + AtmosphereData: { + Priority: number?, -- Priority for atmosphere changes. Ovverrides lower priority atmosphere data + Color: Color3?, + Decay: Color3?, + Density: number?, + Offset: number?, + Glare: number?, + Haze: number?, + }?, + CloudsData: { + Priority: number?, -- Priority for cloud changes. Overrides lower priority cloud data + Color: Color3?, + Cover: number?, + Density: number?, + }?, + LightingData: LightingData?, + + -- Callback functions to handle starting and stopping the weather effect + OnStart: ((janitor: Janitor) -> ())?, + OnStop: ((janitor: Janitor) -> ())?, +} + +local ValidateWeatherData = T.interface { + DisplayName = T.string, + OnStart = T.optional(T.callback), + OnStop = T.optional(T.callback), +} + +local function LoadLightingDataFromFolder(Folder: Instance): LightingData + local LightingData: LightingData = {} + + for _, Attribute in pairs(Folder:GetAttributes()) do + LightingData[Attribute] = Folder:GetAttribute(Attribute) + end + + return LightingData +end + +return { + ValidateWeatherData = ValidateWeatherData, + RegisteredWeather = RegisteredWeather, + WeatherStarted = WeatherStarted, + WeatherStopped = WeatherStopped, + LoadLightingDataFromFolder = LoadLightingDataFromFolder, +} diff --git a/lib/weathermanager/src/init.luau b/lib/weathermanager/src/init.luau new file mode 100644 index 00000000..d9b7b8e9 --- /dev/null +++ b/lib/weathermanager/src/init.luau @@ -0,0 +1,19 @@ +-- init.luau +-- Main entry point for the WeatherSystem package + +local RunService = game:GetService("RunService") + +local WeatherSystem = {} + +if RunService:IsServer() then + WeatherSystem.Server = require(script.Server.WeatherServer) +end + +if RunService:IsClient() then + WeatherSystem.Client = require(script.Client.WeatherClient) +end + +-- Export shared types and utilities +WeatherSystem.WeatherUtil = require(script.Shared.WeatherUtil) + +return WeatherSystem diff --git a/lib/weathermanager/wally.toml b/lib/weathermanager/wally.toml new file mode 100644 index 00000000..5d35dfa2 --- /dev/null +++ b/lib/weathermanager/wally.toml @@ -0,0 +1,17 @@ +[package] +name = "raild3x/weather-system" +version = "1.0.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" +description = "A flexible weather system for Roblox games with client-server synchronization" +license = "MIT" +authors = ["raild3x"] + +[dependencies] +Janitor = "howmanysmall/janitor@^1.18.3" +Signal = "sleitnick/signal@^2.0.3" +T = "osyrisrblx/t@^3.1.1" +Fusion = "elttob/fusion@^0.3.0" +Promise = "evaera/promise@^4.0.0" + +[dev-dependencies]