diff --git a/Assets/FloodSWE/Compute/SWE_GhostExchange.compute b/Assets/FloodSWE/Compute/SWE_GhostExchange.compute index 71ffe1476..e1bc234cb 100644 --- a/Assets/FloodSWE/Compute/SWE_GhostExchange.compute +++ b/Assets/FloodSWE/Compute/SWE_GhostExchange.compute @@ -10,6 +10,9 @@ Texture2D _WaterEast; Texture2D _VelEast; Texture2D _WaterWest; Texture2D _VelWest; +Texture2D _GhostOverrideMask; +Texture2D _GhostOverrideWater; +Texture2D _GhostOverrideVel; RWTexture2D _WaterOut; RWTexture2D _VelOut; @@ -19,6 +22,7 @@ int _HasNorth; int _HasSouth; int _HasEast; int _HasWest; +int _UseGhostOverride; [numthreads(8, 8, 1)] void GhostExchange(uint3 id : SV_DispatchThreadID) @@ -73,6 +77,16 @@ void GhostExchange(uint3 id : SV_DispatchThreadID) } } + if (_UseGhostOverride == 1 && (g.x == 0 || g.x == size - 1 || g.y == 0 || g.y == size - 1)) + { + float mask = _GhostOverrideMask[g]; + if (mask > 0.5) + { + h = _GhostOverrideWater[g]; + v = _GhostOverrideVel[g]; + } + } + if (h != h || h < 0.0) { h = 0.0; diff --git a/Assets/FloodSWE/Scripts/IO/SweTileLoader.cs b/Assets/FloodSWE/Scripts/IO/SweTileLoader.cs index 426062f65..042197c3f 100644 --- a/Assets/FloodSWE/Scripts/IO/SweTileLoader.cs +++ b/Assets/FloodSWE/Scripts/IO/SweTileLoader.cs @@ -127,14 +127,20 @@ namespace FloodSWE.IO { simulator.porosity = data.Porosity; } - simulator.sourceIdMask = data.SourceIds; - simulator.sinkIdMask = data.SinkIds; + simulator.ApplyTileStaticData( + data.Height, + data.Porosity, + data.SourceIds, + data.SinkIds, + data.Resolution, + data.TileSizeM); if (verboseDiagnostics) { Debug.Log( $"SweTileLoader: applied tile {MakeTileKeyLabel(data.Lod, data.TileX, data.TileY)} " + - $"to simulator (height={DescribeTexture(data.Height)}, porosity={DescribeTexture(data.Porosity)}, " + + $"to simulator (res={data.Resolution}, tileSize={data.TileSizeM:0.###}m, " + + $"height={DescribeTexture(data.Height)}, porosity={DescribeTexture(data.Porosity)}, " + $"sourceMask={DescribeTexture(data.SourceIds)}, sinkMask={DescribeTexture(data.SinkIds)})."); } @@ -223,6 +229,8 @@ namespace FloodSWE.IO lod, tileX, tileY, + ParseFloat(parts[7], 1000.0f), + ParseInt(parts[8], 256), NormalizePath(parts[9]), NormalizePath(parts[10]), NormalizePath(parts[11]) @@ -405,6 +413,8 @@ namespace FloodSWE.IO var sb = new StringBuilder(256); sb.Append("tile=").Append(requestedTile) .Append("; index=").Append(indexRecordCount) + .Append("; res=").Append(record.Resolution) + .Append("; tileSize=").Append(record.TileSizeM.ToString("0.###", CultureInfo.InvariantCulture)) .Append("; height=").Append(DescribeTexture(height)) .Append("; porosity=").Append(DescribeTexture(porosity)) .Append("; buildings=").Append(DescribeTexture(buildings)) @@ -468,6 +478,24 @@ namespace FloodSWE.IO return $"{texture.width}x{texture.height},readable={texture.isReadable}"; } + private static float ParseFloat(string raw, float fallback) + { + if (float.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out float parsed)) + { + return parsed; + } + return fallback; + } + + private static int ParseInt(string raw, int fallback) + { + if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed)) + { + return parsed; + } + return fallback; + } + private readonly struct TileKey : IEquatable { public readonly string Lod; @@ -509,15 +537,27 @@ namespace FloodSWE.IO public readonly string Lod; public readonly int X; public readonly int Y; + public readonly float TileSizeM; + public readonly int Resolution; public readonly string HeightPath; public readonly string PorosityPath; public readonly string BuildingPath; - public TileRecord(string lod, int x, int y, string heightPath, string porosityPath, string buildingPath) + public TileRecord( + string lod, + int x, + int y, + float tileSizeM, + int resolution, + string heightPath, + string porosityPath, + string buildingPath) { Lod = lod; X = x; Y = y; + TileSizeM = tileSizeM; + Resolution = resolution; HeightPath = heightPath; PorosityPath = porosityPath; BuildingPath = buildingPath; @@ -530,6 +570,8 @@ namespace FloodSWE.IO public readonly string Lod; public readonly int TileX; public readonly int TileY; + public readonly float TileSizeM; + public readonly int Resolution; public readonly Texture2D Height; public readonly Texture2D Porosity; public readonly Texture2D Buildings; @@ -547,6 +589,8 @@ namespace FloodSWE.IO Lod = record.Lod; TileX = record.X; TileY = record.Y; + TileSizeM = record.TileSizeM; + Resolution = record.Resolution; Height = height; Porosity = porosity; Buildings = buildings; diff --git a/Assets/FloodSWE/Scripts/SweTileSimulator.cs b/Assets/FloodSWE/Scripts/SweTileSimulator.cs index 0d7937c2d..72fc6447b 100644 --- a/Assets/FloodSWE/Scripts/SweTileSimulator.cs +++ b/Assets/FloodSWE/Scripts/SweTileSimulator.cs @@ -79,6 +79,13 @@ public sealed class SweTileSimulator : MonoBehaviour private Texture2D externalDepthRateMap; private float[] externalDepthRateMapData; private bool useExternalDepthRateMap; + private Texture2D ghostOverrideMask; + private Texture2D ghostOverrideWater; + private Texture2D ghostOverrideVelocity; + private float[] ghostOverrideMaskData; + private float[] ghostOverrideWaterData; + private Vector2[] ghostOverrideVelocityData; + private bool useGhostBoundaryOverrides; private RenderTexture clampMask; private RenderTexture dummyRw; @@ -163,6 +170,47 @@ public sealed class SweTileSimulator : MonoBehaviour externalDepthRateMps = metersPerSecond; } + public void ApplyTileStaticData( + Texture2D terrain, + Texture2D porosityTexture, + Texture2D sourceMask, + Texture2D sinkMaskTexture, + int resolution, + float tileSizeM) + { + if (terrain != null) + { + terrainHeight = terrain; + } + if (porosityTexture != null) + { + porosity = porosityTexture; + } + sourceIdMask = sourceMask; + sinkIdMask = sinkMaskTexture; + + int targetResolution = Mathf.Max(4, resolution); + float targetTileSize = Mathf.Max(1.0f, tileSizeM); + bool requiresReinit = targetResolution != gridRes || Mathf.Abs(targetTileSize - tileSizeMeters) > 0.001f; + gridRes = targetResolution; + tileSizeMeters = targetTileSize; + + if (!isInitialized) + { + return; + } + + if (requiresReinit) + { + Release(); + Initialize(); + return; + } + + ResolveStaticTextures(); + ResetSimulationState(); + } + public bool SetExternalDepthRateMap(float[] metersPerSecondPerCell) { if (!isInitialized || metersPerSecondPerCell == null || metersPerSecondPerCell.Length != gridRes * gridRes) @@ -192,6 +240,72 @@ public sealed class SweTileSimulator : MonoBehaviour useExternalDepthRateMap = false; } + public bool SetGhostBoundaryOverrides(int[] ghostIndices, float[] waterLevels, Vector2[] velocities) + { + if (!isInitialized) + { + return false; + } + if (ghostIndices == null || waterLevels == null || velocities == null) + { + return false; + } + if (ghostIndices.Length == 0 || ghostIndices.Length != waterLevels.Length || ghostIndices.Length != velocities.Length) + { + return false; + } + + EnsureGhostOverrideTextures(); + Array.Clear(ghostOverrideMaskData, 0, ghostOverrideMaskData.Length); + Array.Clear(ghostOverrideWaterData, 0, ghostOverrideWaterData.Length); + Array.Clear(ghostOverrideVelocityData, 0, ghostOverrideVelocityData.Length); + + bool any = false; + int maxIndex = ghostOverrideMaskData.Length - 1; + for (int i = 0; i < ghostIndices.Length; i++) + { + int idx = ghostIndices[i]; + if (idx < 0 || idx > maxIndex) + { + continue; + } + + any = true; + ghostOverrideMaskData[idx] = 1.0f; + ghostOverrideWaterData[idx] = Mathf.Max(0.0f, waterLevels[i]); + ghostOverrideVelocityData[idx] = velocities[i]; + } + + ghostOverrideMask.SetPixelData(ghostOverrideMaskData, 0); + ghostOverrideMask.Apply(false, false); + ghostOverrideWater.SetPixelData(ghostOverrideWaterData, 0); + ghostOverrideWater.Apply(false, false); + ghostOverrideVelocity.SetPixelData(ghostOverrideVelocityData, 0); + ghostOverrideVelocity.Apply(false, false); + useGhostBoundaryOverrides = any; + return any; + } + + public void ClearGhostBoundaryOverrides() + { + if (!isInitialized) + { + return; + } + + EnsureGhostOverrideTextures(); + Array.Clear(ghostOverrideMaskData, 0, ghostOverrideMaskData.Length); + Array.Clear(ghostOverrideWaterData, 0, ghostOverrideWaterData.Length); + Array.Clear(ghostOverrideVelocityData, 0, ghostOverrideVelocityData.Length); + ghostOverrideMask.SetPixelData(ghostOverrideMaskData, 0); + ghostOverrideMask.Apply(false, false); + ghostOverrideWater.SetPixelData(ghostOverrideWaterData, 0); + ghostOverrideWater.Apply(false, false); + ghostOverrideVelocity.SetPixelData(ghostOverrideVelocityData, 0); + ghostOverrideVelocity.Apply(false, false); + useGhostBoundaryOverrides = false; + } + public bool TryGetBoundaryIdMasks(out int[] sourceIds, out int[] sinkIds) { sourceIds = null; @@ -446,28 +560,8 @@ public sealed class SweTileSimulator : MonoBehaviour } ResolveStaticTextures(); - - ClearRenderTexture(waterB); - ClearRenderTexture(velB); - ClearRenderTexture(waterGhost); - ClearRenderTexture(velGhost); - - fluxShader.SetInt("_GridRes", gridRes); - fluxShader.SetFloat("_InitDepthLeft", initialDepthLeft); - fluxShader.SetFloat("_InitDepthRight", initialDepthRight); - fluxShader.SetVector("_InitVelocity", initialVelocity); - fluxShader.SetInt("_InitDamBreak", damBreak ? 1 : 0); - fluxShader.SetTexture(initKernel, "_WaterOut", waterA); - fluxShader.SetTexture(initKernel, "_VelOut", velA); - DispatchKernel(fluxShader, initKernel, gridRes, gridRes); - - useA = true; - debugWater = CurrentWater; - debugVelocity = CurrentVelocity; - - lastMaxDepth = Mathf.Max(initialDepthLeft, initialDepthRight); - lastMaxSpeed = Mathf.Sqrt(Gravity * Mathf.Max(lastMaxDepth, 0.0f)); isInitialized = true; + ResetSimulationState(); } private void Release() @@ -490,6 +584,68 @@ public sealed class SweTileSimulator : MonoBehaviour clampMask = null; dummyRw = null; + ReleaseResolvedStaticTextures(); + + if (externalDepthRateMap != null) + { + Destroy(externalDepthRateMap); + } + if (ghostOverrideMask != null) + { + Destroy(ghostOverrideMask); + } + if (ghostOverrideWater != null) + { + Destroy(ghostOverrideWater); + } + if (ghostOverrideVelocity != null) + { + Destroy(ghostOverrideVelocity); + } + + resolvedTerrain = null; + resolvedPorosity = null; + resolvedSourceIdMask = null; + resolvedSinkIdMask = null; + externalDepthRateMap = null; + externalDepthRateMapData = null; + useExternalDepthRateMap = false; + ghostOverrideMask = null; + ghostOverrideWater = null; + ghostOverrideVelocity = null; + ghostOverrideMaskData = null; + ghostOverrideWaterData = null; + ghostOverrideVelocityData = null; + useGhostBoundaryOverrides = false; + } + + private void ResolveStaticTextures() + { + ReleaseResolvedStaticTextures(); + resolvedTerrain = ResolveTerrainTexture(); + resolvedPorosity = ResolvePorosityTexture(); + resolvedSourceIdMask = ResolveBoundaryMaskTexture(sourceIdMask, "SWE_SourceIds"); + resolvedSinkIdMask = ResolveBoundaryMaskTexture(sinkIdMask, "SWE_SinkIds"); + useExternalDepthRateMap = false; + EnsureExternalDepthRateMap(); + Array.Clear(externalDepthRateMapData, 0, externalDepthRateMapData.Length); + externalDepthRateMap.SetPixelData(externalDepthRateMapData, 0); + externalDepthRateMap.Apply(false, false); + EnsureGhostOverrideTextures(); + Array.Clear(ghostOverrideMaskData, 0, ghostOverrideMaskData.Length); + Array.Clear(ghostOverrideWaterData, 0, ghostOverrideWaterData.Length); + Array.Clear(ghostOverrideVelocityData, 0, ghostOverrideVelocityData.Length); + ghostOverrideMask.SetPixelData(ghostOverrideMaskData, 0); + ghostOverrideMask.Apply(false, false); + ghostOverrideWater.SetPixelData(ghostOverrideWaterData, 0); + ghostOverrideWater.Apply(false, false); + ghostOverrideVelocity.SetPixelData(ghostOverrideVelocityData, 0); + ghostOverrideVelocity.Apply(false, false); + useGhostBoundaryOverrides = false; + } + + private void ReleaseResolvedStaticTextures() + { if (resolvedTerrain != null && resolvedTerrain != terrainHeight) { Destroy(resolvedTerrain); @@ -509,29 +665,38 @@ public sealed class SweTileSimulator : MonoBehaviour { Destroy(resolvedSinkIdMask); } - - if (externalDepthRateMap != null) - { - Destroy(externalDepthRateMap); - } - - resolvedTerrain = null; - resolvedPorosity = null; - resolvedSourceIdMask = null; - resolvedSinkIdMask = null; - externalDepthRateMap = null; - externalDepthRateMapData = null; - useExternalDepthRateMap = false; } - private void ResolveStaticTextures() + private void ResetSimulationState() { - resolvedTerrain = ResolveTerrainTexture(); - resolvedPorosity = ResolvePorosityTexture(); - resolvedSourceIdMask = ResolveBoundaryMaskTexture(sourceIdMask, "SWE_SourceIds"); - resolvedSinkIdMask = ResolveBoundaryMaskTexture(sinkIdMask, "SWE_SinkIds"); - useExternalDepthRateMap = false; - EnsureExternalDepthRateMap(); + if (waterA == null || velA == null || waterB == null || velB == null) + { + return; + } + + ClearRenderTexture(waterA); + ClearRenderTexture(velA); + ClearRenderTexture(waterB); + ClearRenderTexture(velB); + ClearRenderTexture(waterGhost); + ClearRenderTexture(velGhost); + + fluxShader.SetInt("_GridRes", gridRes); + fluxShader.SetFloat("_InitDepthLeft", initialDepthLeft); + fluxShader.SetFloat("_InitDepthRight", initialDepthRight); + fluxShader.SetVector("_InitVelocity", initialVelocity); + fluxShader.SetInt("_InitDamBreak", damBreak ? 1 : 0); + fluxShader.SetTexture(initKernel, "_WaterOut", waterA); + fluxShader.SetTexture(initKernel, "_VelOut", velA); + DispatchKernel(fluxShader, initKernel, gridRes, gridRes); + + useA = true; + tickIndex = 0; + pendingMassScale = 1.0f; + lastMaxDepth = Mathf.Max(initialDepthLeft, initialDepthRight); + lastMaxSpeed = Mathf.Sqrt(Gravity * Mathf.Max(lastMaxDepth, 0.0f)); + debugWater = CurrentWater; + debugVelocity = CurrentVelocity; } private Texture2D ResolveTerrainTexture() @@ -698,11 +863,17 @@ public sealed class SweTileSimulator : MonoBehaviour private void DispatchGhost() { + if (ghostOverrideMask == null || ghostOverrideWater == null || ghostOverrideVelocity == null) + { + EnsureGhostOverrideTextures(); + } + ghostExchangeShader.SetInt("_GridRes", gridRes); ghostExchangeShader.SetInt("_HasNorth", 0); ghostExchangeShader.SetInt("_HasSouth", 0); ghostExchangeShader.SetInt("_HasEast", 0); ghostExchangeShader.SetInt("_HasWest", 0); + ghostExchangeShader.SetInt("_UseGhostOverride", useGhostBoundaryOverrides ? 1 : 0); ghostExchangeShader.SetTexture(ghostKernel, "_WaterIn", CurrentWater); ghostExchangeShader.SetTexture(ghostKernel, "_VelIn", CurrentVelocity); @@ -716,6 +887,9 @@ public sealed class SweTileSimulator : MonoBehaviour ghostExchangeShader.SetTexture(ghostKernel, "_VelWest", CurrentVelocity); ghostExchangeShader.SetTexture(ghostKernel, "_WaterOut", waterGhost); ghostExchangeShader.SetTexture(ghostKernel, "_VelOut", velGhost); + ghostExchangeShader.SetTexture(ghostKernel, "_GhostOverrideMask", ghostOverrideMask); + ghostExchangeShader.SetTexture(ghostKernel, "_GhostOverrideWater", ghostOverrideWater); + ghostExchangeShader.SetTexture(ghostKernel, "_GhostOverrideVel", ghostOverrideVelocity); DispatchKernel(ghostExchangeShader, ghostKernel, gridRes + 2, gridRes + 2); } @@ -1112,6 +1286,64 @@ public sealed class SweTileSimulator : MonoBehaviour } } + private void EnsureGhostOverrideTextures() + { + int ghostRes = gridRes + 2; + int length = ghostRes * ghostRes; + + if (ghostOverrideMask == null || ghostOverrideMask.width != ghostRes || ghostOverrideMask.height != ghostRes) + { + if (ghostOverrideMask != null) + { + Destroy(ghostOverrideMask); + } + + ghostOverrideMask = CreateTextureFromFloats(new float[length], ghostRes, ghostRes, "SWE_GhostOverrideMask"); + ghostOverrideMaskData = new float[length]; + } + else if (ghostOverrideMaskData == null || ghostOverrideMaskData.Length != length) + { + ghostOverrideMaskData = new float[length]; + } + + if (ghostOverrideWater == null || ghostOverrideWater.width != ghostRes || ghostOverrideWater.height != ghostRes) + { + if (ghostOverrideWater != null) + { + Destroy(ghostOverrideWater); + } + + ghostOverrideWater = CreateTextureFromFloats(new float[length], ghostRes, ghostRes, "SWE_GhostOverrideWater"); + ghostOverrideWaterData = new float[length]; + } + else if (ghostOverrideWaterData == null || ghostOverrideWaterData.Length != length) + { + ghostOverrideWaterData = new float[length]; + } + + if (ghostOverrideVelocity == null || ghostOverrideVelocity.width != ghostRes || ghostOverrideVelocity.height != ghostRes) + { + if (ghostOverrideVelocity != null) + { + Destroy(ghostOverrideVelocity); + } + + ghostOverrideVelocity = new Texture2D(ghostRes, ghostRes, TextureFormat.RGFloat, false, true) + { + name = "SWE_GhostOverrideVelocity", + wrapMode = TextureWrapMode.Clamp, + filterMode = FilterMode.Point + }; + ghostOverrideVelocityData = new Vector2[length]; + ghostOverrideVelocity.SetPixelData(ghostOverrideVelocityData, 0); + ghostOverrideVelocity.Apply(false, false); + } + else if (ghostOverrideVelocityData == null || ghostOverrideVelocityData.Length != length) + { + ghostOverrideVelocityData = new Vector2[length]; + } + } + private static int[] ReadIdTexture(Texture2D texture) { if (texture == null || !texture.isReadable) diff --git a/Assets/Scripts/Networking/Client/SweQuestControlClient.cs b/Assets/Scripts/Networking/Client/SweQuestControlClient.cs index b51189601..e89dae9e9 100644 --- a/Assets/Scripts/Networking/Client/SweQuestControlClient.cs +++ b/Assets/Scripts/Networking/Client/SweQuestControlClient.cs @@ -22,12 +22,20 @@ namespace FloodSWE.Networking private IPEndPoint serverEndpoint; private float lastAckTimeUnscaled = -1.0f; private float lastHelloSentTimeUnscaled = -1.0f; + private float lastStateTimeUnscaled = -1.0f; private string lastAckMessage = ""; + private SweBoundaryStateMessage lastBoundaryState; private bool connectRequested; + public event Action BoundaryStateReceived; + public bool HasReceivedAck => lastAckTimeUnscaled >= 0.0f; public float LastAckAgeSeconds => HasReceivedAck ? Time.unscaledTime - lastAckTimeUnscaled : float.PositiveInfinity; public string LastAckMessage => lastAckMessage; + public bool HasBoundaryState => lastStateTimeUnscaled >= 0.0f; + public float LastBoundaryStateAgeSeconds => + HasBoundaryState ? Time.unscaledTime - lastStateTimeUnscaled : float.PositiveInfinity; + public SweBoundaryStateMessage LastBoundaryState => lastBoundaryState; public bool IsConnectionAlive => HasReceivedAck && LastAckAgeSeconds <= Mathf.Max(0.1f, ackTimeoutSeconds); public bool IsWaitingForAck => connectRequested && !IsConnectionAlive && !HasReceivedAck; @@ -46,7 +54,7 @@ namespace FloodSWE.Networking private void Update() { - PollAcks(); + PollMessages(); TickKeepAlive(); } @@ -89,7 +97,9 @@ namespace FloodSWE.Networking serverEndpoint = null; lastAckTimeUnscaled = -1.0f; + lastStateTimeUnscaled = -1.0f; lastAckMessage = ""; + lastBoundaryState = null; connectRequested = false; } @@ -104,21 +114,79 @@ namespace FloodSWE.Networking public void SetSourceLevel(int sourceId, float level) { - Send(new SweControlCommand - { - command = "set_source_level", - sourceId = sourceId, - sourceLevel = level, - }); + SetBoundaryProfile( + "source_area", + sourceId, + true, + 0.0f, + 0.0f, + 0.0f, + Mathf.Max(0.0f, level)); } public void SetSinkLevel(int sinkId, float level) + { + SetBoundaryProfile( + "sink", + sinkId, + true, + 0.0f, + 0.0f, + 0.0f, + -Mathf.Max(0.0f, level)); + } + + public void SetBoundaryProfile( + string boundaryKind, + int boundaryId, + bool enabled, + float waterLevelM, + float velocityUMps, + float velocityVMps, + float depthRateMps) { Send(new SweControlCommand { - command = "set_sink_level", - sinkId = sinkId, - sinkLevel = level, + command = "set_boundary_profile", + boundaryKind = boundaryKind, + boundaryId = boundaryId, + enabled = enabled ? 1 : 0, + waterLevelM = waterLevelM, + velocityUMps = velocityUMps, + velocityVMps = velocityVMps, + depthRateMps = depthRateMps, + }); + } + + public void SetBoundariesBulk(SweBoundaryProfile[] profiles, bool replaceAll = false) + { + if (profiles == null) + { + return; + } + + Send(new SweControlCommand + { + command = "set_boundaries_bulk", + replaceAll = replaceAll, + boundaries = profiles, + }); + } + + public void RequestBoundaryConfig() + { + Send(new SweControlCommand + { + command = "get_boundary_config", + }); + } + + public void SubscribeBoundaryUpdates(bool subscribe = true) + { + Send(new SweControlCommand + { + command = "subscribe_boundary_updates", + subscribe = subscribe, }); } @@ -214,7 +282,7 @@ namespace FloodSWE.Networking socket.Send(payload, payload.Length, serverEndpoint); } - private void PollAcks() + private void PollMessages() { if (socket == null) { @@ -234,17 +302,33 @@ namespace FloodSWE.Networking break; } - if (!SweUdpProtocol.TryDecodeAck(payload, out string message)) + if (SweUdpProtocol.TryDecodeAck(payload, out string message)) { + lastAckTimeUnscaled = Time.unscaledTime; + lastAckMessage = message ?? ""; + + if (verboseLogging) + { + Debug.Log($"SweQuestControlClient: ack from {sender}: {lastAckMessage}"); + } + continue; } - lastAckTimeUnscaled = Time.unscaledTime; - lastAckMessage = message ?? ""; - - if (verboseLogging) + if (SweUdpProtocol.TryDecodeState(payload, out SweBoundaryStateMessage state)) { - Debug.Log($"SweQuestControlClient: ack from {sender}: {lastAckMessage}"); + lastStateTimeUnscaled = Time.unscaledTime; + lastBoundaryState = state; + BoundaryStateReceived?.Invoke(state); + + if (verboseLogging) + { + int count = state != null && state.boundaries != null ? state.boundaries.Length : 0; + Debug.Log( + $"SweQuestControlClient: boundary state from {sender}: type={state?.messageType} entries={count}"); + } + + continue; } } } diff --git a/Assets/Scripts/Networking/Server/SweServerRuntime.cs b/Assets/Scripts/Networking/Server/SweServerRuntime.cs index a82de5b4b..e66d256b0 100644 --- a/Assets/Scripts/Networking/Server/SweServerRuntime.cs +++ b/Assets/Scripts/Networking/Server/SweServerRuntime.cs @@ -57,16 +57,23 @@ namespace FloodSWE.Networking private readonly Dictionary sourceLevels = new Dictionary(); private readonly Dictionary sinkLevels = new Dictionary(); + private readonly Dictionary boundaryProfiles = + new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly Dictionary checkpoints = new Dictionary(StringComparer.OrdinalIgnoreCase); private SweBoundaryManifest boundaryManifest; private HashSet activeSourceIds = new HashSet(); private HashSet activeSinkIds = new HashSet(); + private readonly Dictionary activeBoundaryInflowGhostCells = new Dictionary(); + private readonly Dictionary activeSourceAreaCells = new Dictionary(); + private readonly Dictionary activeSinkCells = new Dictionary(); + private bool activeTileHasBoundaryCellGroups; private UdpClient commandSocket; private IPEndPoint commandEndpoint; private IPEndPoint questEndpoint; private readonly Dictionary frameSubscribers = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary stateSubscribers = new Dictionary(StringComparer.Ordinal); private int lastOversizeWarningFrameId = -1; private string lastMissingMaskWarningTileKey = ""; private int receivedControlPackets; @@ -335,6 +342,7 @@ namespace FloodSWE.Networking lastClientChangeTimeUnscaled = Time.unscaledTime; } frameSubscribers.Clear(); + stateSubscribers.Clear(); } private void PollCommands() @@ -390,15 +398,56 @@ namespace FloodSWE.Networking switch (cmd) { case "set_source_level": - sourceLevels[command.sourceId] = command.sourceLevel; - RecomputeExternalDepthRate(); - SendAck(sender, $"source {command.sourceId} level={command.sourceLevel:F3}"); + ApplyLegacySourceLevel(command.sourceId, command.sourceLevel); + SendAck(sender, $"legacy source {command.sourceId} level={command.sourceLevel:F3}"); break; case "set_sink_level": - sinkLevels[command.sinkId] = command.sinkLevel; - RecomputeExternalDepthRate(); - SendAck(sender, $"sink {command.sinkId} level={command.sinkLevel:F3}"); + ApplyLegacySinkLevel(command.sinkId, command.sinkLevel); + SendAck(sender, $"legacy sink {command.sinkId} level={command.sinkLevel:F3}"); + break; + + case "set_boundary_profile": + if (TryApplyBoundaryProfileCommand(command, out string profileResult)) + { + SendAck(sender, profileResult); + BroadcastBoundaryState("update"); + } + else + { + SendAck(sender, "set_boundary_profile rejected"); + } + break; + + case "set_boundaries_bulk": + if (TryApplyBoundaryBulkCommand(command, out string bulkResult)) + { + SendAck(sender, bulkResult); + BroadcastBoundaryState("update"); + } + else + { + SendAck(sender, "set_boundaries_bulk rejected"); + } + break; + + case "get_boundary_config": + SendBoundaryState(sender, "config"); + SendAck(sender, "boundary_config_sent"); + break; + + case "subscribe_boundary_updates": + if (command.subscribe) + { + AddStateSubscriber(sender); + SendBoundaryState(sender, "config"); + SendAck(sender, "boundary_updates_subscribed"); + } + else + { + RemoveStateSubscriber(sender); + SendAck(sender, "boundary_updates_unsubscribed"); + } break; case "set_active_tile": @@ -444,6 +493,7 @@ namespace FloodSWE.Networking case "disconnect": RemoveFrameSubscriber(sender.Address, questFramePort); + RemoveStateSubscriber(sender); SendAck(sender, "disconnected"); break; @@ -467,6 +517,7 @@ namespace FloodSWE.Networking simulator.packetTileId = new TileId(SweBoundaryManifest.ParseLod(activeLod), activeTileX, activeTileY); RefreshActiveTileBoundaryIds(); RecomputeExternalDepthRate(); + BroadcastBoundaryState("tile_changed"); return true; } @@ -486,6 +537,7 @@ namespace FloodSWE.Networking simulator.packetTileId = new TileId(SweBoundaryManifest.ParseLod(activeLod), activeTileX, activeTileY); RefreshActiveTileBoundaryIds(); RecomputeExternalDepthRate(); + BroadcastBoundaryState("tile_changed"); if (verboseDiagnostics && tileLoader != null) { Debug.Log($"SweServerRuntime: active tile set => {tileLoader.LastLoadSummary}"); @@ -538,6 +590,100 @@ namespace FloodSWE.Networking Debug.LogWarning("SweServerRuntime: boundary manifest missing or invalid. Source/sink control disabled for this run."); boundaryManifest = null; lastForcingStatus = "disabled:manifest_missing_or_invalid"; + boundaryProfiles.Clear(); + return; + } + + InitializeBoundaryProfilesFromManifest(); + } + + private void InitializeBoundaryProfilesFromManifest() + { + boundaryProfiles.Clear(); + if (boundaryManifest == null) + { + return; + } + + if (boundaryManifest.boundaries != null) + { + for (int i = 0; i < boundaryManifest.boundaries.Length; i++) + { + SweBoundaryDefinition boundary = boundaryManifest.boundaries[i]; + if (boundary == null || boundary.id <= 0) + { + continue; + } + + SweBoundaryProfile profile = boundary.default_state != null + ? boundary.default_state.ToProfile(boundary.kind, boundary.id) + : new SweBoundaryProfile + { + boundaryKind = NormalizeBoundaryKind(boundary.kind), + boundaryId = boundary.id, + enabled = false, + waterLevelM = 0.0f, + velocityUMps = 0.0f, + velocityVMps = 0.0f, + depthRateMps = 0.0f, + }; + SetBoundaryProfileInternal(profile); + } + } + + if (boundaryManifest.sources != null) + { + for (int i = 0; i < boundaryManifest.sources.Length; i++) + { + SweBoundarySource source = boundaryManifest.sources[i]; + if (source == null || source.id <= 0) + { + continue; + } + + string key = MakeBoundaryProfileKey("boundary_inflow", source.id); + if (!boundaryProfiles.ContainsKey(key)) + { + SetBoundaryProfileInternal(new SweBoundaryProfile + { + boundaryKind = "boundary_inflow", + boundaryId = source.id, + enabled = false, + waterLevelM = 0.0f, + velocityUMps = 0.0f, + velocityVMps = 0.0f, + depthRateMps = 0.0f, + }); + } + } + } + + if (boundaryManifest.sinks != null) + { + for (int i = 0; i < boundaryManifest.sinks.Length; i++) + { + SweBoundarySink sink = boundaryManifest.sinks[i]; + if (sink == null || sink.id <= 0) + { + continue; + } + + string key = MakeBoundaryProfileKey("sink", sink.id); + if (!boundaryProfiles.ContainsKey(key)) + { + bool freeOutflow = sink.@params != null && sink.@params.IsMode("free_outflow"); + SetBoundaryProfileInternal(new SweBoundaryProfile + { + boundaryKind = "sink", + boundaryId = sink.id, + enabled = freeOutflow, + waterLevelM = 0.0f, + velocityUMps = 0.0f, + velocityVMps = 0.0f, + depthRateMps = 0.0f, + }); + } + } } } @@ -545,6 +691,10 @@ namespace FloodSWE.Networking { activeSourceIds = new HashSet(); activeSinkIds = new HashSet(); + activeBoundaryInflowGhostCells.Clear(); + activeSourceAreaCells.Clear(); + activeSinkCells.Clear(); + activeTileHasBoundaryCellGroups = false; if (boundaryManifest == null) { @@ -559,35 +709,226 @@ namespace FloodSWE.Networking return; } - if (tile.source_ids != null) - { - for (int i = 0; i < tile.source_ids.Length; i++) - { - SweBoundaryTileIdRef entry = tile.source_ids[i]; - if (entry != null && entry.id > 0) - { - activeSourceIds.Add(entry.id); - } - } - } + CollectIdsFromRefs(tile.source_ids, activeSourceIds); + CollectIdsFromRefs(tile.sink_ids, activeSinkIds); + CollectIdsFromRefs(tile.boundary_inflow_ids, activeSourceIds); - if (tile.sink_ids != null) - { - for (int i = 0; i < tile.sink_ids.Length; i++) - { - SweBoundaryTileIdRef entry = tile.sink_ids[i]; - if (entry != null && entry.id > 0) - { - activeSinkIds.Add(entry.id); - } - } - } + CollectCellGroups(tile.boundary_cells, activeBoundaryInflowGhostCells, activeSourceIds); + CollectCellGroups(tile.source_area_cells, activeSourceAreaCells, activeSourceIds); + CollectCellGroups(tile.sink_cells, activeSinkCells, activeSinkIds); - Debug.Log($"SweServerRuntime: active tile sources={activeSourceIds.Count}, sinks={activeSinkIds.Count}"); + activeTileHasBoundaryCellGroups = + activeBoundaryInflowGhostCells.Count > 0 || + activeSourceAreaCells.Count > 0 || + activeSinkCells.Count > 0; + + Debug.Log( + $"SweServerRuntime: active tile inflowIds={activeSourceIds.Count}, sinkIds={activeSinkIds.Count}, " + + $"boundaryGhostGroups={activeBoundaryInflowGhostCells.Count}, sourceAreaGroups={activeSourceAreaCells.Count}, " + + $"sinkGroups={activeSinkCells.Count}"); } private void RecomputeExternalDepthRate() { + if (simulator == null) + { + return; + } + + if (activeTileHasBoundaryCellGroups) + { + RecomputeBoundaryProfilesWithCellGroups(); + return; + } + + RecomputeLegacyBoundaryDepthRate(); + } + + private void RecomputeBoundaryProfilesWithCellGroups() + { + var ghostIndices = new List(128); + var ghostLevels = new List(128); + var ghostVelocities = new List(128); + + foreach (var pair in activeBoundaryInflowGhostCells) + { + int boundaryId = pair.Key; + if (!TryResolveBoundaryProfile("boundary_inflow", boundaryId, out SweBoundaryProfile profile) || profile == null) + { + continue; + } + if (!profile.enabled) + { + continue; + } + + int[] cells = pair.Value; + if (cells == null || cells.Length == 0) + { + continue; + } + + float level = Mathf.Max(0.0f, profile.waterLevelM); + Vector2 velocity = new Vector2(profile.velocityUMps, profile.velocityVMps); + for (int i = 0; i < cells.Length; i++) + { + ghostIndices.Add(cells[i]); + ghostLevels.Add(level); + ghostVelocities.Add(velocity); + } + } + + bool hasGhostForcing = + ghostIndices.Count > 0 && + simulator.SetGhostBoundaryOverrides(ghostIndices.ToArray(), ghostLevels.ToArray(), ghostVelocities.ToArray()); + if (!hasGhostForcing) + { + simulator.ClearGhostBoundaryOverrides(); + } + + int totalCells = simulator.gridRes * simulator.gridRes; + float[] perCell = new float[totalCells]; + bool hasDepthForcing = false; + int forcedDepthCells = 0; + + foreach (var pair in activeSourceAreaCells) + { + int boundaryId = pair.Key; + if (!TryResolveBoundaryProfile("source_area", boundaryId, out SweBoundaryProfile profile) || profile == null) + { + continue; + } + if (!profile.enabled) + { + continue; + } + + float rate = profile.depthRateMps; + if (Mathf.Abs(rate) <= 1e-9f && profile.waterLevelM > 0.0f) + { + rate = profile.waterLevelM * sourceDepthRatePerLevelMps; + } + if (rate <= 0.0f) + { + continue; + } + + int[] cells = pair.Value; + if (cells == null) + { + continue; + } + + for (int i = 0; i < cells.Length; i++) + { + int idx = cells[i]; + if (idx < 0 || idx >= totalCells) + { + continue; + } + + perCell[idx] += rate; + } + } + + foreach (var pair in activeSinkCells) + { + int boundaryId = pair.Key; + float rate = 0.0f; + if (TryResolveBoundaryProfile("sink", boundaryId, out SweBoundaryProfile profile) && profile != null && profile.enabled) + { + rate = profile.depthRateMps; + if (Mathf.Abs(rate) <= 1e-9f && profile.waterLevelM > 0.0f) + { + rate = -profile.waterLevelM * sinkDepthRatePerLevelMps; + } + } + + if (Mathf.Abs(rate) <= 1e-9f && applyFreeOutflowByDefault && IsFreeOutflowSink(boundaryId)) + { + rate = -Mathf.Max(0.0f, freeOutflowBaseLevel) * sinkDepthRatePerLevelMps; + } + + if (rate > 0.0f) + { + rate = -rate; + } + if (Mathf.Abs(rate) <= 1e-9f) + { + continue; + } + + int[] cells = pair.Value; + if (cells == null) + { + continue; + } + + for (int i = 0; i < cells.Length; i++) + { + int idx = cells[i]; + if (idx < 0 || idx >= totalCells) + { + continue; + } + + perCell[idx] += rate; + } + } + + for (int i = 0; i < perCell.Length; i++) + { + if (Mathf.Abs(perCell[i]) > 1e-9f) + { + hasDepthForcing = true; + forcedDepthCells++; + } + } + + if (hasDepthForcing && simulator.SetExternalDepthRateMap(perCell)) + { + simulator.SetExternalDepthRate(0.0f); + } + else + { + simulator.ClearExternalDepthRateMap(); + simulator.SetExternalDepthRate(0.0f); + hasDepthForcing = false; + forcedDepthCells = 0; + } + + if (!hasGhostForcing && !hasDepthForcing) + { + lastForcedCellCount = 0; + lastForcingStatus = "disabled:no_active_boundary_profiles"; + return; + } + + lastForcedCellCount = ghostIndices.Count + forcedDepthCells; + if (hasGhostForcing && hasDepthForcing) + { + lastForcingStatus = "ghost+localized"; + } + else if (hasGhostForcing) + { + lastForcingStatus = "ghost_only"; + } + else + { + lastForcingStatus = "localized"; + } + + if (verboseDiagnostics) + { + Debug.Log( + $"SweServerRuntime: forcing applied status={lastForcingStatus} ghost={ghostIndices.Count} depth={forcedDepthCells}"); + } + } + + private void RecomputeLegacyBoundaryDepthRate() + { + simulator.ClearGhostBoundaryOverrides(); + var sourceRates = new Dictionary(); foreach (int sourceId in activeSourceIds) { @@ -635,7 +976,7 @@ namespace FloodSWE.Networking if (!hasBoundaryRates) { lastForcedCellCount = 0; - lastForcingStatus = "disabled:no_active_source_or_sink_levels"; + lastForcingStatus = "disabled:no_active_source_or_sink_levels_legacy"; simulator.ClearExternalDepthRateMap(); simulator.SetExternalDepthRate(0.0f); return; @@ -687,13 +1028,13 @@ namespace FloodSWE.Networking { simulator.SetExternalDepthRate(0.0f); lastForcedCellCount = forcedCellCount; - lastForcingStatus = "localized"; + lastForcingStatus = "legacy_localized"; Debug.Log($"SweServerRuntime: applied localized boundary forcing cells={forcedCellCount}"); return; } lastForcedCellCount = 0; - lastForcingStatus = "disabled:boundary_masks_zero_or_apply_failed"; + lastForcingStatus = "disabled:boundary_masks_zero_or_apply_failed_legacy"; } else { @@ -707,7 +1048,7 @@ namespace FloodSWE.Networking } lastForcedCellCount = 0; - lastForcingStatus = "disabled:masks_unavailable"; + lastForcingStatus = "disabled:masks_unavailable_legacy"; } // Strict mode: no masks means no forcing. This prevents non-physical uniform flooding. @@ -715,6 +1056,65 @@ namespace FloodSWE.Networking simulator.SetExternalDepthRate(0.0f); } + private bool TryResolveBoundaryProfile(string kind, int boundaryId, out SweBoundaryProfile profile) + { + profile = null; + if (boundaryId <= 0) + { + return false; + } + + string normalizedKind = NormalizeBoundaryKind(kind); + string key = MakeBoundaryProfileKey(normalizedKind, boundaryId); + if (boundaryProfiles.TryGetValue(key, out profile) && profile != null) + { + return true; + } + + if (boundaryManifest != null && + boundaryManifest.TryGetBoundary(normalizedKind, boundaryId, out SweBoundaryDefinition boundary) && + boundary != null) + { + profile = boundary.default_state != null + ? boundary.default_state.ToProfile(normalizedKind, boundaryId) + : new SweBoundaryProfile + { + boundaryKind = normalizedKind, + boundaryId = boundaryId, + enabled = false, + waterLevelM = 0.0f, + velocityUMps = 0.0f, + velocityVMps = 0.0f, + depthRateMps = 0.0f, + }; + SetBoundaryProfileInternal(profile); + return true; + } + + return false; + } + + private bool IsFreeOutflowSink(int sinkId) + { + if (boundaryManifest == null || sinkId <= 0) + { + return false; + } + + if (boundaryManifest.TryGetBoundary("sink", sinkId, out SweBoundaryDefinition boundary) && + boundary != null && + boundary.@params != null && + boundary.@params.IsMode("free_outflow")) + { + return true; + } + + return boundaryManifest.TryGetSink(sinkId, out SweBoundarySink sink) && + sink != null && + sink.@params != null && + sink.@params.IsMode("free_outflow"); + } + private static bool IsBorderCell(int flatIndex, int resolution) { if (resolution <= 1) @@ -727,6 +1127,303 @@ namespace FloodSWE.Networking return x == 0 || y == 0 || x == resolution - 1 || y == resolution - 1; } + private static void CollectIdsFromRefs(SweBoundaryTileIdRef[] refs, HashSet outIds) + { + if (refs == null || outIds == null) + { + return; + } + + for (int i = 0; i < refs.Length; i++) + { + SweBoundaryTileIdRef entry = refs[i]; + if (entry != null && entry.id > 0) + { + outIds.Add(entry.id); + } + } + } + + private static void CollectCellGroups( + SweBoundaryTileCellGroup[] groups, + Dictionary outGroups, + HashSet outIds) + { + if (groups == null || outGroups == null || outIds == null) + { + return; + } + + for (int i = 0; i < groups.Length; i++) + { + SweBoundaryTileCellGroup group = groups[i]; + if (group == null || group.id <= 0 || group.cells == null || group.cells.Length == 0) + { + continue; + } + + outIds.Add(group.id); + outGroups[group.id] = (int[])group.cells.Clone(); + } + } + + private void ApplyLegacySourceLevel(int sourceId, float sourceLevel) + { + if (sourceId <= 0) + { + return; + } + + sourceLevels[sourceId] = sourceLevel; + var profile = new SweBoundaryProfile + { + boundaryKind = "boundary_inflow", + boundaryId = sourceId, + enabled = sourceLevel > 0.0f, + waterLevelM = Mathf.Max(0.0f, sourceLevel), + velocityUMps = 0.0f, + velocityVMps = 0.0f, + depthRateMps = 0.0f, + }; + SetBoundaryProfileInternal(profile); + RecomputeExternalDepthRate(); + } + + private void ApplyLegacySinkLevel(int sinkId, float sinkLevel) + { + if (sinkId <= 0) + { + return; + } + + sinkLevels[sinkId] = sinkLevel; + var profile = new SweBoundaryProfile + { + boundaryKind = "sink", + boundaryId = sinkId, + enabled = sinkLevel > 0.0f, + waterLevelM = Mathf.Max(0.0f, sinkLevel), + velocityUMps = 0.0f, + velocityVMps = 0.0f, + depthRateMps = -Mathf.Max(0.0f, sinkLevel) * sinkDepthRatePerLevelMps, + }; + SetBoundaryProfileInternal(profile); + RecomputeExternalDepthRate(); + } + + private bool TryApplyBoundaryProfileCommand(SweControlCommand command, out string result) + { + result = "invalid_boundary_profile"; + if (command == null || command.boundaryId <= 0) + { + return false; + } + + string kind = NormalizeBoundaryKind(command.boundaryKind); + var profile = new SweBoundaryProfile + { + boundaryKind = kind, + boundaryId = command.boundaryId, + enabled = command.enabled != 0, + waterLevelM = Mathf.Max(0.0f, command.waterLevelM), + velocityUMps = command.velocityUMps, + velocityVMps = command.velocityVMps, + depthRateMps = command.depthRateMps, + }; + if (kind == "sink" && profile.depthRateMps > 0.0f) + { + profile.depthRateMps = -profile.depthRateMps; + } + + SetBoundaryProfileInternal(profile); + RecomputeExternalDepthRate(); + result = $"boundary profile set: {kind}:{profile.boundaryId}"; + return true; + } + + private bool TryApplyBoundaryBulkCommand(SweControlCommand command, out string result) + { + result = "invalid_boundary_bulk"; + if (command == null || command.boundaries == null || command.boundaries.Length == 0) + { + return false; + } + + if (command.replaceAll) + { + InitializeBoundaryProfilesFromManifest(); + } + + int applied = 0; + for (int i = 0; i < command.boundaries.Length; i++) + { + SweBoundaryProfile incoming = command.boundaries[i]; + if (incoming == null || incoming.boundaryId <= 0) + { + continue; + } + + string kind = NormalizeBoundaryKind(incoming.boundaryKind); + var profile = incoming.Clone(); + profile.boundaryKind = kind; + if (kind == "sink" && profile.depthRateMps > 0.0f) + { + profile.depthRateMps = -profile.depthRateMps; + } + + SetBoundaryProfileInternal(profile); + applied++; + } + + if (applied <= 0) + { + return false; + } + + RecomputeExternalDepthRate(); + result = $"boundary bulk applied: {applied}"; + return true; + } + + private void AddStateSubscriber(IPEndPoint endpoint) + { + if (endpoint == null) + { + return; + } + + stateSubscribers[MakeSubscriberKey(endpoint)] = endpoint; + } + + private void RemoveStateSubscriber(IPEndPoint endpoint) + { + if (endpoint == null) + { + return; + } + + stateSubscribers.Remove(MakeSubscriberKey(endpoint)); + } + + private void SendBoundaryState(IPEndPoint target, string messageType) + { + if (commandSocket == null || target == null) + { + return; + } + + SweBoundaryStateMessage state = BuildBoundaryStateMessage(messageType); + byte[] payload = SweUdpProtocol.EncodeState(state); + try + { + commandSocket.Send(payload, payload.Length, target); + } + catch (Exception ex) + { + Debug.LogWarning($"SweServerRuntime: failed to send boundary state to {target}. {ex.Message}"); + } + } + + private void BroadcastBoundaryState(string messageType) + { + if (commandSocket == null || stateSubscribers.Count == 0) + { + return; + } + + SweBoundaryStateMessage state = BuildBoundaryStateMessage(messageType); + byte[] payload = SweUdpProtocol.EncodeState(state); + var stale = new List(); + foreach (var pair in stateSubscribers) + { + try + { + commandSocket.Send(payload, payload.Length, pair.Value); + } + catch + { + stale.Add(pair.Key); + } + } + + for (int i = 0; i < stale.Count; i++) + { + stateSubscribers.Remove(stale[i]); + } + } + + private SweBoundaryStateMessage BuildBoundaryStateMessage(string messageType) + { + var profiles = new List(boundaryProfiles.Values); + profiles.Sort((a, b) => + { + int kind = string.Compare( + a != null ? a.boundaryKind : string.Empty, + b != null ? b.boundaryKind : string.Empty, + StringComparison.OrdinalIgnoreCase); + if (kind != 0) + { + return kind; + } + int aid = a != null ? a.boundaryId : 0; + int bid = b != null ? b.boundaryId : 0; + return aid.CompareTo(bid); + }); + + var snapshot = new SweBoundaryProfile[profiles.Count]; + for (int i = 0; i < profiles.Count; i++) + { + snapshot[i] = profiles[i] != null ? profiles[i].Clone() : null; + } + + return new SweBoundaryStateMessage + { + messageType = messageType, + schemaVersion = boundaryManifest != null ? boundaryManifest.schema_version : 0, + lod = activeLod, + tileX = activeTileX, + tileY = activeTileY, + boundaries = snapshot, + }; + } + + private static string NormalizeBoundaryKind(string kind) + { + if (string.IsNullOrWhiteSpace(kind)) + { + return "boundary_inflow"; + } + + string normalized = kind.Trim().ToLowerInvariant(); + if (normalized == "source" || normalized == "boundary_source") + { + return "boundary_inflow"; + } + if (normalized == "sourcearea" || normalized == "source-area") + { + return "source_area"; + } + return normalized; + } + + private static string MakeBoundaryProfileKey(string kind, int id) + { + return $"{NormalizeBoundaryKind(kind)}:{id}"; + } + + private void SetBoundaryProfileInternal(SweBoundaryProfile profile) + { + if (profile == null || profile.boundaryId <= 0) + { + return; + } + + string kind = NormalizeBoundaryKind(profile.boundaryKind); + profile.boundaryKind = kind; + string key = MakeBoundaryProfileKey(kind, profile.boundaryId); + boundaryProfiles[key] = profile.Clone(); + } + private void ApplyDefaultSourceIfConfigured() { if (!applyDefaultSourceOnStart) @@ -748,7 +1445,7 @@ namespace FloodSWE.Networking } float clamped = Mathf.Max(0.0f, defaultSourceLevel); - sourceLevels[defaultSourceId] = clamped; + ApplyLegacySourceLevel(defaultSourceId, clamped); Debug.Log($"SweServerRuntime: applied startup source id={defaultSourceId} level={clamped:0.###}"); } @@ -773,6 +1470,7 @@ namespace FloodSWE.Networking porosityValues = porosity, sourceLevels = CloneDictionary(sourceLevels), sinkLevels = CloneDictionary(sinkLevels), + boundaryProfiles = CloneBoundaryProfiles(boundaryProfiles), }; checkpoints[checkpointName] = state; @@ -803,6 +1501,7 @@ namespace FloodSWE.Networking sourceLevels.Clear(); sinkLevels.Clear(); + boundaryProfiles.Clear(); if (state.sourceLevels != null) { @@ -820,7 +1519,21 @@ namespace FloodSWE.Networking } } + if (state.boundaryProfiles != null) + { + for (int i = 0; i < state.boundaryProfiles.Length; i++) + { + SweBoundaryProfile profile = state.boundaryProfiles[i]; + if (profile == null) + { + continue; + } + SetBoundaryProfileInternal(profile); + } + } + RecomputeExternalDepthRate(); + BroadcastBoundaryState("checkpoint_restored"); return true; } @@ -840,6 +1553,22 @@ namespace FloodSWE.Networking return clone; } + private static SweBoundaryProfile[] CloneBoundaryProfiles(Dictionary source) + { + if (source == null || source.Count == 0) + { + return Array.Empty(); + } + + var outProfiles = new SweBoundaryProfile[source.Count]; + int idx = 0; + foreach (var pair in source) + { + outProfiles[idx++] = pair.Value != null ? pair.Value.Clone() : null; + } + return outProfiles; + } + private static int ComputePacketInterval(float tickSeconds, float hz) { if (hz <= 0.0f || tickSeconds <= 0.0f) @@ -1003,6 +1732,7 @@ namespace FloodSWE.Networking public float[] porosityValues; public Dictionary sourceLevels; public Dictionary sinkLevels; + public SweBoundaryProfile[] boundaryProfiles; } } } diff --git a/Assets/Scripts/Networking/Shared/SweBoundaryManifest.cs b/Assets/Scripts/Networking/Shared/SweBoundaryManifest.cs index de8307c9e..14f77e083 100644 --- a/Assets/Scripts/Networking/Shared/SweBoundaryManifest.cs +++ b/Assets/Scripts/Networking/Shared/SweBoundaryManifest.cs @@ -8,11 +8,19 @@ namespace FloodSWE.Networking public sealed class SweBoundaryManifest { public int schema_version; + public string boundary_inflow_mask_dir; + public string source_area_mask_dir; + public string sink_mask_dir; + public string boundary_inflow_params_toml; + public string source_area_params_toml; + public string sink_params_toml; public SweBoundarySource[] sources; public SweBoundarySink[] sinks; + public SweBoundaryDefinition[] boundaries; public SweBoundaryTile[] tiles; [NonSerialized] private Dictionary tileLookup; + [NonSerialized] private Dictionary boundaryLookup; [NonSerialized] private Dictionary sourceLookup; [NonSerialized] private Dictionary sinkLookup; @@ -64,6 +72,17 @@ namespace FloodSWE.Networking return sourceLookup != null && sourceLookup.TryGetValue(id, out source); } + public bool TryGetBoundary(string kind, int id, out SweBoundaryDefinition boundary) + { + boundary = null; + if (boundaryLookup == null) + { + BuildLookup(); + } + + return boundaryLookup != null && boundaryLookup.TryGetValue(MakeBoundaryKey(kind, id), out boundary); + } + public bool TryGetSink(int id, out SweBoundarySink sink) { sink = null; @@ -94,6 +113,7 @@ namespace FloodSWE.Networking private void BuildLookup() { tileLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + boundaryLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); sourceLookup = new Dictionary(); sinkLookup = new Dictionary(); if (tiles == null) @@ -115,6 +135,25 @@ namespace FloodSWE.Networking } } + if (boundaries != null && boundaries.Length > 0) + { + for (int i = 0; i < boundaries.Length; i++) + { + SweBoundaryDefinition boundary = boundaries[i]; + if (boundary == null || boundary.id <= 0) + { + continue; + } + + if (boundary.default_state == null) + { + boundary.default_state = SweBoundaryDefaultState.DefaultFor(boundary.kind, boundary.@params); + } + + boundaryLookup[MakeBoundaryKey(boundary.kind, boundary.id)] = boundary; + } + } + if (sources != null) { for (int i = 0; i < sources.Length; i++) @@ -123,6 +162,19 @@ namespace FloodSWE.Networking if (source != null) { sourceLookup[source.id] = source; + string key = MakeBoundaryKey("boundary_inflow", source.id); + if (!boundaryLookup.ContainsKey(key)) + { + boundaryLookup[key] = new SweBoundaryDefinition + { + kind = "boundary_inflow", + id = source.id, + tile_count = source.tile_count, + total_pixels = source.total_pixels, + @params = source.@params, + default_state = SweBoundaryDefaultState.DefaultFor("boundary_inflow", source.@params), + }; + } } } } @@ -135,6 +187,19 @@ namespace FloodSWE.Networking if (sink != null) { sinkLookup[sink.id] = sink; + string key = MakeBoundaryKey("sink", sink.id); + if (!boundaryLookup.ContainsKey(key)) + { + boundaryLookup[key] = new SweBoundaryDefinition + { + kind = "sink", + id = sink.id, + tile_count = sink.tile_count, + total_pixels = sink.total_pixels, + @params = sink.@params, + default_state = SweBoundaryDefaultState.DefaultFor("sink", sink.@params), + }; + } } } } @@ -144,6 +209,16 @@ namespace FloodSWE.Networking { return $"{lod}|{tileX}|{tileY}"; } + + private static string MakeBoundaryKey(string kind, int id) + { + string normalized = string.IsNullOrWhiteSpace(kind) ? "boundary_inflow" : kind.Trim().ToLowerInvariant(); + if (normalized == "source") + { + normalized = "boundary_inflow"; + } + return $"{normalized}:{id}"; + } } [Serializable] @@ -170,8 +245,30 @@ namespace FloodSWE.Networking public string lod; public int tile_x; public int tile_y; + public float tile_size_m; + public int resolution; + public float[] bounds; public SweBoundaryTileIdRef[] source_ids; public SweBoundaryTileIdRef[] sink_ids; + public SweBoundaryTileIdRef[] boundary_inflow_ids; + public SweBoundaryTileIdRef[] source_area_ids; + public SweBoundaryTileCellGroup[] boundary_cells; + public SweBoundaryTileCellGroup[] source_area_cells; + public SweBoundaryTileCellGroup[] sink_cells; + public string source_id_path; + public string sink_id_path; + public string boundary_inflow_id_path; + public string source_area_id_path; + + public bool HasCellGroups + { + get + { + return (boundary_cells != null && boundary_cells.Length > 0) || + (source_area_cells != null && source_area_cells.Length > 0) || + (sink_cells != null && sink_cells.Length > 0); + } + } } [Serializable] @@ -195,4 +292,62 @@ namespace FloodSWE.Networking public int id; public int pixels; } + + [Serializable] + public sealed class SweBoundaryTileCellGroup + { + public int id; + public int count; + public int[] cells; + } + + [Serializable] + public sealed class SweBoundaryDefinition + { + public string kind; + public int id; + public int tile_count; + public int total_pixels; + public SweBoundaryParams @params; + public SweBoundaryDefaultState default_state; + } + + [Serializable] + public sealed class SweBoundaryDefaultState + { + public bool enabled; + public float water_level_m; + public float velocity_u_mps; + public float velocity_v_mps; + public float depth_rate_mps; + + public static SweBoundaryDefaultState DefaultFor(string kind, SweBoundaryParams parameters) + { + bool sinkFreeOutflow = string.Equals(kind, "sink", StringComparison.OrdinalIgnoreCase) && + parameters != null && + parameters.IsMode("free_outflow"); + return new SweBoundaryDefaultState + { + enabled = sinkFreeOutflow, + water_level_m = 0.0f, + velocity_u_mps = 0.0f, + velocity_v_mps = 0.0f, + depth_rate_mps = 0.0f, + }; + } + + public SweBoundaryProfile ToProfile(string kind, int id) + { + return new SweBoundaryProfile + { + boundaryKind = kind, + boundaryId = id, + enabled = enabled, + waterLevelM = water_level_m, + velocityUMps = velocity_u_mps, + velocityVMps = velocity_v_mps, + depthRateMps = depth_rate_mps, + }; + } + } } diff --git a/Assets/Scripts/Networking/Shared/SweUdpProtocol.cs b/Assets/Scripts/Networking/Shared/SweUdpProtocol.cs index 9922f4411..a0ee22e9a 100644 --- a/Assets/Scripts/Networking/Shared/SweUdpProtocol.cs +++ b/Assets/Scripts/Networking/Shared/SweUdpProtocol.cs @@ -12,6 +12,7 @@ namespace FloodSWE.Networking public const byte FrameType = 1; public const byte ControlType = 2; public const byte AckType = 3; + public const byte StateType = 4; public static byte[] EncodeFrame(HeightmapPacket packet) { @@ -156,6 +157,36 @@ namespace FloodSWE.Networking return false; } } + + public static byte[] EncodeState(SweBoundaryStateMessage message) + { + string json = JsonUtility.ToJson(message); + byte[] utf8 = Encoding.UTF8.GetBytes(json); + byte[] payload = new byte[utf8.Length + 1]; + payload[0] = StateType; + Buffer.BlockCopy(utf8, 0, payload, 1, utf8.Length); + return payload; + } + + public static bool TryDecodeState(byte[] data, out SweBoundaryStateMessage message) + { + message = null; + if (data == null || data.Length < 2 || data[0] != StateType) + { + return false; + } + + try + { + string json = Encoding.UTF8.GetString(data, 1, data.Length - 1); + message = JsonUtility.FromJson(json); + return message != null; + } + catch + { + return false; + } + } } [Serializable] @@ -174,5 +205,53 @@ namespace FloodSWE.Networking public float v; public float radius; public float porosity; + + public string boundaryKind; + public int boundaryId; + public int enabled; + public float waterLevelM; + public float velocityUMps; + public float velocityVMps; + public float depthRateMps; + public bool replaceAll; + public bool subscribe; + public SweBoundaryProfile[] boundaries; + } + + [Serializable] + public sealed class SweBoundaryProfile + { + public string boundaryKind; + public int boundaryId; + public bool enabled; + public float waterLevelM; + public float velocityUMps; + public float velocityVMps; + public float depthRateMps; + + public SweBoundaryProfile Clone() + { + return new SweBoundaryProfile + { + boundaryKind = boundaryKind, + boundaryId = boundaryId, + enabled = enabled, + waterLevelM = waterLevelM, + velocityUMps = velocityUMps, + velocityVMps = velocityVMps, + depthRateMps = depthRateMps, + }; + } + } + + [Serializable] + public sealed class SweBoundaryStateMessage + { + public string messageType; + public int schemaVersion; + public string lod; + public int tileX; + public int tileY; + public SweBoundaryProfile[] boundaries; } }