From 3e2d078027b9dc79c66fc32d6b279c15eefa322b Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Tue, 24 Mar 2026 18:24:45 -0400 Subject: [PATCH 1/4] Add weather system with client/server modules Introduce a complete weather management package: server and client implementations, networking primitive, shared types/utilities, and package manifest. WeatherServer manages registration, start/stop, timed expiration, weighted weather cycling, and bootstraps a client-side loader. WeatherClient syncs active weather via RemoteProperty, auto-registers server-provided weather modules, and applies prioritized visual effects (Atmosphere/Clouds/Lighting) using Fusion and Janitor for lifecycle management. RemoteProperty provides a simple replicated property API (server-set / client-observe) backed by RemoteEvent. Also add WeatherUtil (signals and type validation), an init entrypoint, and wally.toml metadata with dependencies. --- .../src/Client/WeatherClient.luau | 380 +++++++++++++++ .../src/Networking/RemoteProperty.luau | 198 ++++++++ .../ClientWeatherBootstrapper.client.luau | 25 + .../src/Server/WeatherServer.luau | 458 ++++++++++++++++++ .../src/Shared/WeatherUtil.luau | 68 +++ lib/weathermanager/src/init.luau | 19 + lib/weathermanager/wally.toml | 17 + 7 files changed, 1165 insertions(+) create mode 100644 lib/weathermanager/src/Client/WeatherClient.luau create mode 100644 lib/weathermanager/src/Networking/RemoteProperty.luau create mode 100644 lib/weathermanager/src/Server/ClientWeatherBootstrapper.client.luau create mode 100644 lib/weathermanager/src/Server/WeatherServer.luau create mode 100644 lib/weathermanager/src/Shared/WeatherUtil.luau create mode 100644 lib/weathermanager/src/init.luau create mode 100644 lib/weathermanager/wally.toml diff --git a/lib/weathermanager/src/Client/WeatherClient.luau b/lib/weathermanager/src/Client/WeatherClient.luau new file mode 100644 index 00000000..bf4cf047 --- /dev/null +++ b/lib/weathermanager/src/Client/WeatherClient.luau @@ -0,0 +1,380 @@ +-- 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 Janitor = require(script.Parent.Parent.Janitor) +local T = require(script.Parent.Parent.T) +local Fusion = require(script.Parent.Parent.Fusion) +local WeatherUtil = require(script.Parent.Shared.WeatherUtil) +local RemoteProperty = require(script.Parent.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") + +-- Setup visual effects data +local AtmosphereData = {} +local CloudsData = {} +local LightingData = {} + +-------------------------------------------------------------------------------- +--// 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 + +-------------------------------------------------------------------------------- +--// 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 + + 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 + +-------------------------------------------------------------------------------- +--// Visual Effects Setup //-- +-------------------------------------------------------------------------------- + +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 value = 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) + return scope:Spring(value, 0.5) + end + + local tbl = {} + for propertyName in Data do + tbl[propertyName] = getHighestPriorityProperty(propertyName) + end + return tbl +end + +local function SetupAtmosphere() + local Atmosphere: Atmosphere = Lighting:FindFirstChildWhichIsA("Atmosphere") + if not Atmosphere then + warn("WeatherClient: No Atmosphere instance found in Lighting") + return + end + local scope = Fusion.scoped(Fusion) + + AtmosphereData = { + Color = scope:Value(Atmosphere.Color), + Decay = scope:Value(Atmosphere.Decay), + Density = scope:Value(Atmosphere.Density), + Offset = scope:Value(Atmosphere.Offset), + Glare = scope:Value(Atmosphere.Glare), + Haze = scope:Value(Atmosphere.Haze), + } + + local tbl = GenerateDataCategory(scope, AtmosphereData, "AtmosphereData") + scope:Hydrate(Atmosphere)(tbl) +end + +local function SetupClouds() + local Clouds: Clouds = workspace.Terrain:FindFirstChildWhichIsA("Clouds") + if not Clouds then + warn("WeatherClient: No Clouds instance found in Terrain") + return + end + local scope = Fusion.scoped(Fusion) + + CloudsData = { + Color = scope:Value(Clouds.Color), + Cover = scope:Value(Clouds.Cover), + Density = scope:Value(Clouds.Density), + } + + local tbl = GenerateDataCategory(scope, CloudsData, "CloudsData") + scope:Hydrate(Clouds)(tbl) +end + +local function SetupLighting() + local scope = Fusion.scoped(Fusion) + + LightingData = { + Ambient = scope:Value(Lighting.Ambient), + Brightness = scope:Value(Lighting.Brightness), + ColorShift_Bottom = scope:Value(Lighting.ColorShift_Bottom), + ColorShift_Top = scope:Value(Lighting.ColorShift_Top), + EnvironmentDiffuseScale = scope:Value(Lighting.EnvironmentDiffuseScale), + EnvironmentSpecularScale = scope:Value(Lighting.EnvironmentSpecularScale), + OutdoorAmbient = scope:Value(Lighting.OutdoorAmbient), + ShadowSoftness = scope:Value(Lighting.ShadowSoftness), + ClockTime = scope:Value(Lighting.ClockTime), + GeographicLatitude = scope:Value(Lighting.GeographicLatitude), + ExposureCompensation = scope:Value(Lighting.ExposureCompensation), + } + + local tbl = GenerateDataCategory(scope, LightingData, "LightingData") + scope:Hydrate(Lighting)(tbl) +end + +-------------------------------------------------------------------------------- +--// Initialization //-- +-------------------------------------------------------------------------------- + +-- Setup visual effects +SetupAtmosphere() +SetupClouds() +SetupLighting() + +-- 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..7b755090 --- /dev/null +++ b/lib/weathermanager/src/Networking/RemoteProperty.luau @@ -0,0 +1,198 @@ +-- RemoteProperty.luau +-- Simple replicated property system for client-server synchronization + +local RunService = game:GetService("RunService") + +--// Imports //-- +local Packages = script.Parent.Parent.Parent +local Promise = require(Packages.Promise) + +--// Types //-- +type Promise = typeof(Promise.new()) + +export type RemoteProperty = { + _remote: RemoteEvent, + _value: T, + _name: string, + _observers: { (T) -> () }?, + _initialized: boolean?, + _readyCallbacks: { () -> () }?, + Set: (self: RemoteProperty, value: T) -> (), + Get: (self: RemoteProperty) -> T, + Observe: (self: RemoteProperty, callback: (T) -> ()) -> () -> (), + IsReady: (self: RemoteProperty) -> boolean, + OnReady: (self: RemoteProperty) -> Promise, +} + +local RemoteProperty = {} +RemoteProperty.__index = RemoteProperty + +local IS_SERVER = RunService:IsServer() + +local function getFolder() + return script +end + +--[=[ + 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._name = name + + -- Handle client requests for current value + remote.OnServerEvent:Connect(function(player) + remote:FireClient(player, self._value) + 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 + +--[=[ + 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 + +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..055b854b --- /dev/null +++ b/lib/weathermanager/src/Server/WeatherServer.luau @@ -0,0 +1,458 @@ +-- WeatherServer.luau +-- Server-side weather management system + +--// Roblox Services //-- +local RunService = game:GetService("RunService") + +assert(RunService:IsServer(), "WeatherServer can only be required on the server") + +--// Imports //-- +local Janitor = require(script.Parent.Parent.Janitor) +local T = require(script.Parent.Parent.T) +local WeatherUtil = require(script.Parent.Shared.WeatherUtil) +local RemoteProperty = require(script.Parent.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 WeatherCycleEnabled = false +local WeatherCycleThread = nil +local LastWeatherPicked = nil +local ExpirationLoopThread = nil -- Track the expiration loop thread + +-- Setup networking +local ActiveWeatherProperty = RemoteProperty.new("WeatherSystem_ActiveWeather", {}) + +-- 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 + +-------------------------------------------------------------------------------- +--// WeatherServer //-- +-------------------------------------------------------------------------------- + +local WeatherServer = {} + +-------------------------------------------------------------------------------- +--// Internal Methods //-- +-------------------------------------------------------------------------------- + +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 + + -- If no active weather, 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?} +]=] +function WeatherServer.StartWeather( + weatherType: string, + config: { + Duration: number?, + }? +) + 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.numberPositive), + }(Config)) + + 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 clients + ActiveWeatherProperty:Set(WeatherServer.GetActiveWeather()) + + -- 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 +]=] +function WeatherServer.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(weatherType .. " is not currently active.") + return + end + + ActiveWeatherEffects[weatherType] = nil + ActiveWeatherProperty:Set(WeatherServer.GetActiveWeather()) + + local janitor = activeWeather.Janitor + if weatherData.OnStop then + janitor:Add(task.spawn(weatherData.OnStop, janitor)) + end + janitor:Destroy() + + WeatherStopped:Fire(weatherType) +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 + +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..2a0a707a --- /dev/null +++ b/lib/weathermanager/src/Shared/WeatherUtil.luau @@ -0,0 +1,68 @@ +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() + +--// 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: { + 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?, + }?, + + -- 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), +} + +return { + ValidateWeatherData = ValidateWeatherData, + RegisteredWeather = RegisteredWeather, + WeatherStarted = WeatherStarted, + WeatherStopped = WeatherStopped, +} 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] From 14f065ce2455f513cff999110dae5c004119b994 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Mon, 13 Apr 2026 16:43:48 -0400 Subject: [PATCH 2/4] Update WeatherUtil.luau --- .../src/Shared/WeatherUtil.luau | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/lib/weathermanager/src/Shared/WeatherUtil.luau b/lib/weathermanager/src/Shared/WeatherUtil.luau index 2a0a707a..61abc972 100644 --- a/lib/weathermanager/src/Shared/WeatherUtil.luau +++ b/lib/weathermanager/src/Shared/WeatherUtil.luau @@ -7,6 +7,21 @@ 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 = { @@ -34,20 +49,7 @@ export type WeatherData = { Cover: number?, Density: number?, }?, - 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?, - }?, + LightingData: LightingData?, -- Callback functions to handle starting and stopping the weather effect OnStart: ((janitor: Janitor) -> ())?, @@ -60,9 +62,20 @@ local ValidateWeatherData = T.interface { 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, } From 000b42c8e4f1b6538d3bb4e7f83a8ebd723c7ccd Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Mon, 13 Apr 2026 17:28:22 -0400 Subject: [PATCH 3/4] Add per-player weather & Fusion lighting setup Refactors client/server networking to support per-player weather overrides and introduces a Fusion-driven visual effects system. Key changes: - Client: switch requires to use Packages path; add Fusion-based setup for Sky, Atmosphere, Bloom, Blur, ColorCorrection, DepthOfField, SunRays, Clouds and Lighting with baseline tracking, priority-based blending, and dynamic hydration. Automatically creates missing Roblox instances for categories when a weather effect activates and listens for ChildAdded to handle late-created instances. - Networking: enhance RemoteProperty typing, add RemoteProperty:SetFor to send values to a single client, and allow server-side requestHandler to return per-player values. - Server: add PlayerWeatherEffects to support per-player weather with optional Duration (number or NumberRange) and Players list in StartWeather; ActiveWeatherProperty now returns effective per-player weather on request. Expiration loop now handles per-player expirations; StopWeather can target specific players or sweep per-player overrides when stopping a global effect. Added helper APIs: GetActiveWeatherForPlayer, IsWeatherActiveForPlayer, StopAllWeatherForPlayer, and cleanup on PlayerRemoving. These changes enable smoother client visuals via Fusion and flexible server control for global or per-player weather effects. --- .../src/Client/WeatherClient.luau | 402 +++++++++++++----- .../src/Networking/RemoteProperty.luau | 29 +- .../src/Server/WeatherServer.luau | 258 +++++++++-- 3 files changed, 541 insertions(+), 148 deletions(-) diff --git a/lib/weathermanager/src/Client/WeatherClient.luau b/lib/weathermanager/src/Client/WeatherClient.luau index bf4cf047..28d3d804 100644 --- a/lib/weathermanager/src/Client/WeatherClient.luau +++ b/lib/weathermanager/src/Client/WeatherClient.luau @@ -8,11 +8,12 @@ local RunService = game:GetService("RunService") assert(RunService:IsClient(), "WeatherClient can only be required on the client") --// Imports //-- -local Janitor = require(script.Parent.Parent.Janitor) -local T = require(script.Parent.Parent.T) -local Fusion = require(script.Parent.Parent.Fusion) -local WeatherUtil = require(script.Parent.Shared.WeatherUtil) -local RemoteProperty = require(script.Parent.Networking.RemoteProperty) +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 @@ -30,10 +31,30 @@ 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 //-- @@ -62,6 +83,216 @@ WeatherClient.WeatherStarted = WeatherStarted ]=] 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 //-- -------------------------------------------------------------------------------- @@ -139,6 +370,22 @@ local function StartWeather(weatherType: string) 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, @@ -176,122 +423,53 @@ local function StopWeather(weatherType: string) end -------------------------------------------------------------------------------- ---// Visual Effects Setup //-- +--// Initialization //-- -------------------------------------------------------------------------------- -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 value = 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) - return scope:Spring(value, 0.5) - end +-- Setup visual effects +-- Lighting is always present, run immediately. +SetupLighting() - local tbl = {} - for propertyName in Data do - tbl[propertyName] = getHighestPriorityProperty(propertyName) +-- 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 - return tbl end -local function SetupAtmosphere() - local Atmosphere: Atmosphere = Lighting:FindFirstChildWhichIsA("Atmosphere") - if not Atmosphere then - warn("WeatherClient: No Atmosphere instance found in Lighting") - return +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 - local scope = Fusion.scoped(Fusion) - - AtmosphereData = { - Color = scope:Value(Atmosphere.Color), - Decay = scope:Value(Atmosphere.Decay), - Density = scope:Value(Atmosphere.Density), - Offset = scope:Value(Atmosphere.Offset), - Glare = scope:Value(Atmosphere.Glare), - Haze = scope:Value(Atmosphere.Haze), - } - - local tbl = GenerateDataCategory(scope, AtmosphereData, "AtmosphereData") - scope:Hydrate(Atmosphere)(tbl) -end +end) -local function SetupClouds() - local Clouds: Clouds = workspace.Terrain:FindFirstChildWhichIsA("Clouds") - if not Clouds then - warn("WeatherClient: No Clouds instance found in Terrain") - return +workspace.Terrain.ChildAdded:Connect(function(child) + if child:IsA("Clouds") then + SetupClouds(child) end - local scope = Fusion.scoped(Fusion) - - CloudsData = { - Color = scope:Value(Clouds.Color), - Cover = scope:Value(Clouds.Cover), - Density = scope:Value(Clouds.Density), - } - - local tbl = GenerateDataCategory(scope, CloudsData, "CloudsData") - scope:Hydrate(Clouds)(tbl) -end - -local function SetupLighting() - local scope = Fusion.scoped(Fusion) - - LightingData = { - Ambient = scope:Value(Lighting.Ambient), - Brightness = scope:Value(Lighting.Brightness), - ColorShift_Bottom = scope:Value(Lighting.ColorShift_Bottom), - ColorShift_Top = scope:Value(Lighting.ColorShift_Top), - EnvironmentDiffuseScale = scope:Value(Lighting.EnvironmentDiffuseScale), - EnvironmentSpecularScale = scope:Value(Lighting.EnvironmentSpecularScale), - OutdoorAmbient = scope:Value(Lighting.OutdoorAmbient), - ShadowSoftness = scope:Value(Lighting.ShadowSoftness), - ClockTime = scope:Value(Lighting.ClockTime), - GeographicLatitude = scope:Value(Lighting.GeographicLatitude), - ExposureCompensation = scope:Value(Lighting.ExposureCompensation), - } - - local tbl = GenerateDataCategory(scope, LightingData, "LightingData") - scope:Hydrate(Lighting)(tbl) -end - --------------------------------------------------------------------------------- ---// Initialization //-- --------------------------------------------------------------------------------- - --- Setup visual effects -SetupAtmosphere() -SetupClouds() -SetupLighting() +end) -- Sync active weather from server ActiveWeatherProperty:Observe(function(activeWeather) diff --git a/lib/weathermanager/src/Networking/RemoteProperty.luau b/lib/weathermanager/src/Networking/RemoteProperty.luau index 7b755090..d604529b 100644 --- a/lib/weathermanager/src/Networking/RemoteProperty.luau +++ b/lib/weathermanager/src/Networking/RemoteProperty.luau @@ -8,7 +8,9 @@ local Packages = script.Parent.Parent.Parent local Promise = require(Packages.Promise) --// Types //-- -type Promise = typeof(Promise.new()) +type Promise = typeof(Promise.new( + function(resolve, reject: (...any) -> (), onCancel: (abortHandler: (() -> ())?) -> boolean) end +)) export type RemoteProperty = { _remote: RemoteEvent, @@ -18,28 +20,29 @@ export type RemoteProperty = { _initialized: boolean?, _readyCallbacks: { () -> () }?, Set: (self: RemoteProperty, value: T) -> (), + SetFor: (self: RemoteProperty, player: Player, value: T) -> (), Get: (self: RemoteProperty) -> T, Observe: (self: RemoteProperty, callback: (T) -> ()) -> () -> (), IsReady: (self: RemoteProperty) -> boolean, OnReady: (self: RemoteProperty) -> Promise, } -local RemoteProperty = {} -RemoteProperty.__index = 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 +function RemoteProperty.new(name: string, initialValue: T, requestHandler: ((player: Player) -> T)?): RemoteProperty assert(IS_SERVER, "RemoteProperty.new can only be called on the server") local self = setmetatable({}, RemoteProperty) @@ -55,9 +58,10 @@ function RemoteProperty.new(name: string, initialValue: T): RemoteProperty self._value = initialValue self._name = name - -- Handle client requests for current value + -- Handle client requests for current value; requestHandler allows per-player responses remote.OnServerEvent:Connect(function(player) - remote:FireClient(player, self._value) + local value = if requestHandler then requestHandler(player) else self._value + remote:FireClient(player, value) end) return self @@ -126,6 +130,17 @@ function RemoteProperty:Set(value: any) 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._remote:FireClient(player, value) +end + --[=[ Gets the current value of the property @return any diff --git a/lib/weathermanager/src/Server/WeatherServer.luau b/lib/weathermanager/src/Server/WeatherServer.luau index 055b854b..e06607fa 100644 --- a/lib/weathermanager/src/Server/WeatherServer.luau +++ b/lib/weathermanager/src/Server/WeatherServer.luau @@ -3,14 +3,16 @@ --// 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 Janitor = require(script.Parent.Parent.Janitor) -local T = require(script.Parent.Parent.T) -local WeatherUtil = require(script.Parent.Shared.WeatherUtil) -local RemoteProperty = require(script.Parent.Networking.RemoteProperty) +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 @@ -25,13 +27,46 @@ 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 --- Setup networking -local ActiveWeatherProperty = RemoteProperty.new("WeatherSystem_ActiveWeather", {}) +-- 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; requestHandler provides per-player effective weather on join +local ActiveWeatherProperty = RemoteProperty.new("WeatherSystem_ActiveWeather", {}, function(player) + return getEffectiveWeatherForPlayer(player) +end) + +-------------------------------------------------------------------------------- +--// WeatherServer //-- +-------------------------------------------------------------------------------- + +local WeatherServer = {} + +-------------------------------------------------------------------------------- +--// Internal Methods //-- +-------------------------------------------------------------------------------- -- Create a folder to hold ObjectValues pointing to weather modules local function getWeatherModulesFolder() @@ -44,15 +79,15 @@ local function getWeatherModulesFolder() return folder end --------------------------------------------------------------------------------- ---// WeatherServer //-- --------------------------------------------------------------------------------- - -local WeatherServer = {} +local function pushWeatherToPlayers(players: { Player }) + for _, player in players do + ActiveWeatherProperty:SetFor(player, getEffectiveWeatherForPlayer(player)) + end +end --------------------------------------------------------------------------------- ---// Internal Methods //-- --------------------------------------------------------------------------------- +local function pushWeatherToAllPlayers() + pushWeatherToPlayers(Players:GetPlayers()) +end local function StartExpirationLoop() -- Don't start if already running @@ -72,7 +107,17 @@ local function StartExpirationLoop() end end - -- If no active weather, stop the loop + -- 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 @@ -240,12 +285,16 @@ end --[=[ Starts a weather effect of the specified type. @param weatherType string -- The weather type to start - @param config table? -- Optional configuration: {Duration: number?} + @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?, + Duration: (number | NumberRange)?, + Players: { Player }?, }? ) local weatherData = RegisteredWeatherEffects[weatherType] @@ -256,9 +305,54 @@ function WeatherServer.StartWeather( local Config = config or {} assert(T.interface { - Duration = T.optional(T.numberPositive), + 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 @@ -275,8 +369,8 @@ function WeatherServer.StartWeather( EndTime = if Config.Duration then workspace:GetServerTimeNow() + Config.Duration else nil, } - -- Update clients - ActiveWeatherProperty:Set(WeatherServer.GetActiveWeather()) + -- Update all clients + pushWeatherToAllPlayers() -- Start expiration loop if needed if Config.Duration then @@ -293,30 +387,124 @@ 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) +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] - if not activeWeather then + 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 - ActiveWeatherEffects[weatherType] = nil - ActiveWeatherProperty:Set(WeatherServer.GetActiveWeather()) + pushWeatherToAllPlayers() + WeatherStopped:Fire(weatherType) +end - local janitor = activeWeather.Janitor - if weatherData.OnStop then - janitor:Add(task.spawn(weatherData.OnStop, janitor)) +--[=[ + 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 getEffectiveWeatherForPlayer(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 + if ActiveWeatherEffects[weatherType] then + return true end - janitor:Destroy() + local playerEffects = PlayerWeatherEffects[player] + return playerEffects ~= nil and playerEffects[weatherType] ~= nil +end - WeatherStopped:Fire(weatherType) +--[=[ + 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() @@ -449,6 +637,18 @@ local function SetupClientBootstrapper() end 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 not playerEffects then + return + end + for _, entry in pairs(playerEffects) do + entry.Janitor:Destroy() + end + PlayerWeatherEffects[player] = nil +end) + task.defer(SetupClientBootstrapper) -------------------------------------------------------------------------------- From b9cff5d187e6fceef61cbd19801380aef22bbf68 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Mon, 13 Apr 2026 18:33:55 -0400 Subject: [PATCH 4/4] Add per-player RemoteProperty values and cleanup Introduce per-player overrides and lifecycle management for RemoteProperty: add _playerValues and _playerRemovingConn, import Players, and store per-player values in SetFor. Remove the requestHandler parameter from RemoteProperty.new and respond to client requests using GetFor. Add GetFor, ClearFor, and Destroy methods and automatically clear per-player entries on PlayerRemoving to avoid memory leaks. Update WeatherServer to use ActiveWeatherProperty:GetFor, initialize per-player entries on PlayerAdded, simplify IsWeatherActiveForPlayer, and fix PlayerRemoving cleanup so player state is fully cleared. --- .../src/Networking/RemoteProperty.luau | 60 +++++++++++++++++-- .../src/Server/WeatherServer.luau | 31 +++++----- 2 files changed, 71 insertions(+), 20 deletions(-) diff --git a/lib/weathermanager/src/Networking/RemoteProperty.luau b/lib/weathermanager/src/Networking/RemoteProperty.luau index d604529b..54cda70c 100644 --- a/lib/weathermanager/src/Networking/RemoteProperty.luau +++ b/lib/weathermanager/src/Networking/RemoteProperty.luau @@ -2,6 +2,7 @@ -- 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 @@ -15,6 +16,8 @@ type Promise = typeof(Promise.new( export type RemoteProperty = { _remote: RemoteEvent, _value: T, + _playerValues: { [Player]: T }, + _playerRemovingConn: RBXScriptConnection?, _name: string, _observers: { (T) -> () }?, _initialized: boolean?, @@ -22,9 +25,12 @@ export type RemoteProperty = { 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() @@ -42,7 +48,7 @@ RemoteProperty.__index = RemoteProperty @param initialValue any -- Initial value @return RemoteProperty ]=] -function RemoteProperty.new(name: string, initialValue: T, requestHandler: ((player: Player) -> T)?): 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) @@ -56,12 +62,19 @@ function RemoteProperty.new(name: string, initialValue: T, requestHandler: (( self._remote = remote self._value = initialValue + self._playerValues = {} self._name = name - -- Handle client requests for current value; requestHandler allows per-player responses + -- 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) - local value = if requestHandler then requestHandler(player) else self._value - remote:FireClient(player, value) + 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 @@ -138,9 +151,33 @@ end ]=] 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 @@ -210,4 +247,19 @@ function RemoteProperty:OnReady(): Promise 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/WeatherServer.luau b/lib/weathermanager/src/Server/WeatherServer.luau index e06607fa..b130b7fd 100644 --- a/lib/weathermanager/src/Server/WeatherServer.luau +++ b/lib/weathermanager/src/Server/WeatherServer.luau @@ -53,10 +53,8 @@ local getEffectiveWeatherForPlayer = function(player: Player): { string } return active end --- Setup networking; requestHandler provides per-player effective weather on join -local ActiveWeatherProperty = RemoteProperty.new("WeatherSystem_ActiveWeather", {}, function(player) - return getEffectiveWeatherForPlayer(player) -end) +-- Setup networking; GetFor handles per-player values with fallback to the shared default +local ActiveWeatherProperty = RemoteProperty.new("WeatherSystem_ActiveWeather", {}) -------------------------------------------------------------------------------- --// WeatherServer //-- @@ -470,7 +468,7 @@ end @param player Player -- The player to query ]=] function WeatherServer.GetActiveWeatherForPlayer(player: Player): { string } - return getEffectiveWeatherForPlayer(player) + return ActiveWeatherProperty:GetFor(player) end --[=[ @@ -480,11 +478,7 @@ end @param weatherType string -- The weather type to check ]=] function WeatherServer.IsWeatherActiveForPlayer(player: Player, weatherType: string): boolean - if ActiveWeatherEffects[weatherType] then - return true - end - local playerEffects = PlayerWeatherEffects[player] - return playerEffects ~= nil and playerEffects[weatherType] ~= nil + return table.find(ActiveWeatherProperty:GetFor(player), weatherType) ~= nil end --[=[ @@ -637,16 +631,21 @@ local function SetupClientBootstrapper() 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 not playerEffects then - return - end - for _, entry in pairs(playerEffects) do - entry.Janitor:Destroy() + if playerEffects then + for _, entry in pairs(playerEffects) do + entry.Janitor:Destroy() + end + PlayerWeatherEffects[player] = nil end - PlayerWeatherEffects[player] = nil end) task.defer(SetupClientBootstrapper)