safety snapshot: recover geo importers and current quest/server state
This commit is contained in:
8
Assets/Scripts/Networking/Client.meta
Normal file
8
Assets/Scripts/Networking/Client.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 35afe3350a3ac09829de2de0f78ce69c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,137 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace FloodSWE.Networking
|
||||
{
|
||||
/// <summary>
|
||||
/// Drives a Connect/Disconnect button from SweQuestControlClient state.
|
||||
/// </summary>
|
||||
public sealed class SweConnectionButtonController : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private SweQuestControlClient controlClient;
|
||||
[SerializeField] private Button button;
|
||||
[SerializeField] private TMP_Text label;
|
||||
|
||||
[Header("Labels")]
|
||||
[SerializeField] private string connectLabel = "Connect";
|
||||
[SerializeField] private string loadingLabel = "Loading...";
|
||||
[SerializeField] private string disconnectLabel = "Disconnect";
|
||||
|
||||
[Header("Behavior")]
|
||||
[SerializeField] private bool disableButtonWhileLoading = true;
|
||||
[SerializeField] private bool allowCancelWhileLoading = false;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (button == null)
|
||||
{
|
||||
button = GetComponent<Button>();
|
||||
}
|
||||
|
||||
if (label == null && button != null)
|
||||
{
|
||||
label = button.GetComponentInChildren<TMP_Text>();
|
||||
}
|
||||
|
||||
if (controlClient == null)
|
||||
{
|
||||
controlClient = FindFirstObjectByType<SweQuestControlClient>();
|
||||
}
|
||||
|
||||
if (button != null)
|
||||
{
|
||||
button.onClick.AddListener(OnButtonPressed);
|
||||
}
|
||||
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (button != null)
|
||||
{
|
||||
button.onClick.RemoveListener(OnButtonPressed);
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
RefreshVisual();
|
||||
}
|
||||
|
||||
public void OnButtonPressed()
|
||||
{
|
||||
if (controlClient == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (controlClient.IsConnectionAlive)
|
||||
{
|
||||
controlClient.Disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
if (controlClient.IsWaitingForAck)
|
||||
{
|
||||
if (allowCancelWhileLoading)
|
||||
{
|
||||
controlClient.Disconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
controlClient.Connect();
|
||||
}
|
||||
|
||||
private void RefreshVisual()
|
||||
{
|
||||
if (controlClient == null)
|
||||
{
|
||||
SetLabel(connectLabel);
|
||||
SetButtonInteractable(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (controlClient.IsConnectionAlive)
|
||||
{
|
||||
SetLabel(disconnectLabel);
|
||||
SetButtonInteractable(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (controlClient.IsWaitingForAck)
|
||||
{
|
||||
SetLabel(loadingLabel);
|
||||
bool interactable = !disableButtonWhileLoading || allowCancelWhileLoading;
|
||||
SetButtonInteractable(interactable);
|
||||
return;
|
||||
}
|
||||
|
||||
SetLabel(connectLabel);
|
||||
SetButtonInteractable(true);
|
||||
}
|
||||
|
||||
private void SetLabel(string textValue)
|
||||
{
|
||||
if (label == null || label.text == textValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
label.text = textValue;
|
||||
}
|
||||
|
||||
private void SetButtonInteractable(bool interactable)
|
||||
{
|
||||
if (button == null || button.interactable == interactable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
button.interactable = interactable;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 46eb88ffb765e8ab790ae8afc4e27087
|
||||
102
Assets/Scripts/Networking/Client/SweConnectionStatusHud.cs
Normal file
102
Assets/Scripts/Networking/Client/SweConnectionStatusHud.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
namespace FloodSWE.Networking
|
||||
{
|
||||
/// <summary>
|
||||
/// Displays Quest->Server connection status based on SweQuestControlClient ACKs.
|
||||
/// </summary>
|
||||
public sealed class SweConnectionStatusHud : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private SweQuestControlClient controlClient;
|
||||
[SerializeField] private TMP_Text statusText;
|
||||
[SerializeField] private TMP_Text ackText;
|
||||
|
||||
[Header("Display")]
|
||||
[SerializeField] private string connectedLabel = "Connected";
|
||||
[SerializeField] private string waitingLabel = "Waiting for ACK";
|
||||
[SerializeField] private string disconnectedLabel = "Disconnected";
|
||||
[SerializeField] private string ackPrefix = "Last ACK: ";
|
||||
[SerializeField] private Color connectedColor = new Color(0.4f, 1.0f, 0.4f);
|
||||
[SerializeField] private Color waitingColor = new Color(1.0f, 0.9f, 0.3f);
|
||||
[SerializeField] private Color disconnectedColor = new Color(1.0f, 0.5f, 0.5f);
|
||||
[SerializeField] private string ageFormat = "0.00";
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (controlClient == null)
|
||||
{
|
||||
controlClient = FindFirstObjectByType<SweQuestControlClient>();
|
||||
}
|
||||
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
if (controlClient == null)
|
||||
{
|
||||
SetStatus(disconnectedLabel, disconnectedColor);
|
||||
SetAck("Control client missing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (controlClient.IsConnectionAlive)
|
||||
{
|
||||
SetStatus(connectedLabel, connectedColor);
|
||||
string ackMsg = string.IsNullOrWhiteSpace(controlClient.LastAckMessage)
|
||||
? "(empty)"
|
||||
: controlClient.LastAckMessage;
|
||||
SetAck($"{ackPrefix}{ackMsg} ({controlClient.LastAckAgeSeconds.ToString(ageFormat)}s)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (controlClient.HasReceivedAck)
|
||||
{
|
||||
SetStatus(disconnectedLabel, disconnectedColor);
|
||||
string ackMsg = string.IsNullOrWhiteSpace(controlClient.LastAckMessage)
|
||||
? "(empty)"
|
||||
: controlClient.LastAckMessage;
|
||||
SetAck($"{ackPrefix}{ackMsg} ({controlClient.LastAckAgeSeconds.ToString(ageFormat)}s)");
|
||||
return;
|
||||
}
|
||||
|
||||
SetStatus(waitingLabel, waitingColor);
|
||||
SetAck("No ACK yet");
|
||||
}
|
||||
|
||||
private void SetStatus(string text, Color color)
|
||||
{
|
||||
if (statusText == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusText.text != text)
|
||||
{
|
||||
statusText.text = text;
|
||||
}
|
||||
|
||||
if (statusText.color != color)
|
||||
{
|
||||
statusText.color = color;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetAck(string text)
|
||||
{
|
||||
if (ackText == null || ackText.text == text)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ackText.text = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4665719901a7c1f31bb6eb5678abe4b1
|
||||
266
Assets/Scripts/Networking/Client/SweQuestControlClient.cs
Normal file
266
Assets/Scripts/Networking/Client/SweQuestControlClient.cs
Normal file
@@ -0,0 +1,266 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using UnityEngine;
|
||||
|
||||
namespace FloodSWE.Networking
|
||||
{
|
||||
public sealed class SweQuestControlClient : MonoBehaviour
|
||||
{
|
||||
[Header("Server")]
|
||||
public string serverHost = "127.0.0.1";
|
||||
public int serverCommandPort = 29010;
|
||||
|
||||
[Header("Connect")]
|
||||
public bool autoConnectOnEnable = true;
|
||||
public bool sendHelloOnConnect = true;
|
||||
public bool verboseLogging = false;
|
||||
public float ackTimeoutSeconds = 3.0f;
|
||||
public float helloKeepAliveIntervalSeconds = 1.0f;
|
||||
|
||||
private UdpClient socket;
|
||||
private IPEndPoint serverEndpoint;
|
||||
private float lastAckTimeUnscaled = -1.0f;
|
||||
private float lastHelloSentTimeUnscaled = -1.0f;
|
||||
private string lastAckMessage = "";
|
||||
private bool connectRequested;
|
||||
|
||||
public bool HasReceivedAck => lastAckTimeUnscaled >= 0.0f;
|
||||
public float LastAckAgeSeconds => HasReceivedAck ? Time.unscaledTime - lastAckTimeUnscaled : float.PositiveInfinity;
|
||||
public string LastAckMessage => lastAckMessage;
|
||||
public bool IsConnectionAlive => HasReceivedAck && LastAckAgeSeconds <= Mathf.Max(0.1f, ackTimeoutSeconds);
|
||||
public bool IsWaitingForAck => connectRequested && !IsConnectionAlive && !HasReceivedAck;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (autoConnectOnEnable && !Connect())
|
||||
{
|
||||
enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
Disconnect();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
PollAcks();
|
||||
TickKeepAlive();
|
||||
}
|
||||
|
||||
public bool Connect()
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureSocket();
|
||||
ResolveServerEndpoint();
|
||||
connectRequested = true;
|
||||
|
||||
if (sendHelloOnConnect)
|
||||
{
|
||||
SendHello();
|
||||
}
|
||||
|
||||
if (verboseLogging)
|
||||
{
|
||||
Debug.Log($"SweQuestControlClient: connected to {serverEndpoint}.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"SweQuestControlClient: failed to connect UDP sender. {ex.Message}");
|
||||
connectRequested = false;
|
||||
Disconnect();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Disconnect()
|
||||
{
|
||||
if (socket != null)
|
||||
{
|
||||
socket.Close();
|
||||
socket = null;
|
||||
}
|
||||
|
||||
serverEndpoint = null;
|
||||
lastAckTimeUnscaled = -1.0f;
|
||||
lastAckMessage = "";
|
||||
connectRequested = false;
|
||||
}
|
||||
|
||||
public void SendHello()
|
||||
{
|
||||
Send(new SweControlCommand
|
||||
{
|
||||
command = "hello",
|
||||
});
|
||||
lastHelloSentTimeUnscaled = Time.unscaledTime;
|
||||
}
|
||||
|
||||
public void SetSourceLevel(int sourceId, float level)
|
||||
{
|
||||
Send(new SweControlCommand
|
||||
{
|
||||
command = "set_source_level",
|
||||
sourceId = sourceId,
|
||||
sourceLevel = level,
|
||||
});
|
||||
}
|
||||
|
||||
public void SetSinkLevel(int sinkId, float level)
|
||||
{
|
||||
Send(new SweControlCommand
|
||||
{
|
||||
command = "set_sink_level",
|
||||
sinkId = sinkId,
|
||||
sinkLevel = level,
|
||||
});
|
||||
}
|
||||
|
||||
public void SetActiveTile(string lod, int tileX, int tileY)
|
||||
{
|
||||
Send(new SweControlCommand
|
||||
{
|
||||
command = "set_active_tile",
|
||||
lod = lod,
|
||||
tileX = tileX,
|
||||
tileY = tileY,
|
||||
});
|
||||
}
|
||||
|
||||
public void SaveCheckpoint(string checkpointName)
|
||||
{
|
||||
Send(new SweControlCommand
|
||||
{
|
||||
command = "save_checkpoint",
|
||||
checkpoint = checkpointName,
|
||||
});
|
||||
}
|
||||
|
||||
public void ResetCheckpoint(string checkpointName)
|
||||
{
|
||||
Send(new SweControlCommand
|
||||
{
|
||||
command = "reset_checkpoint",
|
||||
checkpoint = checkpointName,
|
||||
});
|
||||
}
|
||||
|
||||
public void ApplyPorosityStamp(float u, float v, float radius, float porosity)
|
||||
{
|
||||
Send(new SweControlCommand
|
||||
{
|
||||
command = "apply_porosity_stamp",
|
||||
u = Mathf.Clamp01(u),
|
||||
v = Mathf.Clamp01(v),
|
||||
radius = Mathf.Clamp01(radius),
|
||||
porosity = Mathf.Clamp01(porosity),
|
||||
});
|
||||
}
|
||||
|
||||
public void SendDisconnect()
|
||||
{
|
||||
Send(new SweControlCommand
|
||||
{
|
||||
command = "disconnect",
|
||||
});
|
||||
}
|
||||
|
||||
private void ResolveServerEndpoint()
|
||||
{
|
||||
IPAddress[] addresses = Dns.GetHostAddresses(serverHost.Trim());
|
||||
if (addresses.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"No address found for '{serverHost}'.");
|
||||
}
|
||||
|
||||
serverEndpoint = new IPEndPoint(addresses[0], serverCommandPort);
|
||||
}
|
||||
|
||||
private void EnsureSocket()
|
||||
{
|
||||
if (socket == null)
|
||||
{
|
||||
socket = new UdpClient();
|
||||
socket.Client.Blocking = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Send(SweControlCommand command)
|
||||
{
|
||||
if (socket == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverEndpoint == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
ResolveServerEndpoint();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
byte[] payload = SweUdpProtocol.EncodeControl(command);
|
||||
socket.Send(payload, payload.Length, serverEndpoint);
|
||||
}
|
||||
|
||||
private void PollAcks()
|
||||
{
|
||||
if (socket == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (socket.Available > 0)
|
||||
{
|
||||
IPEndPoint sender = new IPEndPoint(IPAddress.Any, 0);
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = socket.Receive(ref sender);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!SweUdpProtocol.TryDecodeAck(payload, out string message))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
lastAckTimeUnscaled = Time.unscaledTime;
|
||||
lastAckMessage = message ?? "";
|
||||
|
||||
if (verboseLogging)
|
||||
{
|
||||
Debug.Log($"SweQuestControlClient: ack from {sender}: {lastAckMessage}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void TickKeepAlive()
|
||||
{
|
||||
if (!connectRequested || socket == null || serverEndpoint == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float interval = Mathf.Max(0.1f, helloKeepAliveIntervalSeconds);
|
||||
if (lastHelloSentTimeUnscaled < 0.0f || (Time.unscaledTime - lastHelloSentTimeUnscaled) >= interval)
|
||||
{
|
||||
SendHello();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 38923d8392c381c9190bc65a51d7ab7b
|
||||
153
Assets/Scripts/Networking/Client/SweQuestStreamClient.cs
Normal file
153
Assets/Scripts/Networking/Client/SweQuestStreamClient.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using FloodSWE.Streaming;
|
||||
using FloodSWE.TileGraph;
|
||||
using UnityEngine;
|
||||
|
||||
namespace FloodSWE.Networking
|
||||
{
|
||||
public sealed class SweQuestStreamClient : MonoBehaviour
|
||||
{
|
||||
[Header("Network")]
|
||||
public int listenPort = 29011;
|
||||
|
||||
[Header("Interpolation")]
|
||||
public float frameDurationSeconds = 0.1f;
|
||||
|
||||
[Header("Filter")]
|
||||
public bool filterByTile = false;
|
||||
public int expectedLod = 1;
|
||||
public int expectedTileX = 1;
|
||||
public int expectedTileY = 1;
|
||||
|
||||
[Header("Output")]
|
||||
public Material targetMaterial;
|
||||
public string textureProperty = "_HeightTex";
|
||||
|
||||
public Texture2D outputTexture;
|
||||
public HeightmapPacket lastPacket;
|
||||
|
||||
private readonly HeightmapInterpolator interpolator = new HeightmapInterpolator();
|
||||
private UdpClient socket;
|
||||
private IPEndPoint endpoint;
|
||||
private int outputResolution;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
interpolator.Reset();
|
||||
interpolator.FrameDurationSeconds = Mathf.Max(0.01f, frameDurationSeconds);
|
||||
|
||||
try
|
||||
{
|
||||
socket = new UdpClient(listenPort);
|
||||
socket.Client.Blocking = false;
|
||||
endpoint = new IPEndPoint(IPAddress.Any, 0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"SweQuestStreamClient: failed to open UDP port {listenPort}. {ex.Message}");
|
||||
enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (socket != null)
|
||||
{
|
||||
socket.Close();
|
||||
socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
PollFrames();
|
||||
interpolator.Step(Time.deltaTime);
|
||||
TryUpdateOutputTexture();
|
||||
}
|
||||
|
||||
private void PollFrames()
|
||||
{
|
||||
if (socket == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (socket.Available > 0)
|
||||
{
|
||||
byte[] payload;
|
||||
try
|
||||
{
|
||||
payload = socket.Receive(ref endpoint);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!SweUdpProtocol.TryDecodeFrame(payload, out HeightmapPacket packet))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filterByTile)
|
||||
{
|
||||
TileId expected = new TileId(expectedLod, expectedTileX, expectedTileY);
|
||||
if (packet.Tile != expected)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
interpolator.PushPacket(packet);
|
||||
lastPacket = packet;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryUpdateOutputTexture()
|
||||
{
|
||||
if (!interpolator.TryGetHeights(out float[] heights) || heights == null || heights.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int resolution = Mathf.RoundToInt(Mathf.Sqrt(heights.Length));
|
||||
if (resolution <= 0 || resolution * resolution != heights.Length)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureOutputTexture(resolution);
|
||||
outputTexture.SetPixelData(heights, 0);
|
||||
outputTexture.Apply(false, false);
|
||||
|
||||
if (targetMaterial != null)
|
||||
{
|
||||
targetMaterial.SetTexture(textureProperty, outputTexture);
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureOutputTexture(int resolution)
|
||||
{
|
||||
if (outputTexture != null && outputResolution == resolution)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (outputTexture != null)
|
||||
{
|
||||
Destroy(outputTexture);
|
||||
}
|
||||
|
||||
outputTexture = new Texture2D(resolution, resolution, TextureFormat.RFloat, false, true)
|
||||
{
|
||||
wrapMode = TextureWrapMode.Clamp,
|
||||
filterMode = FilterMode.Bilinear,
|
||||
name = "SWE_StreamedHeight"
|
||||
};
|
||||
|
||||
outputResolution = resolution;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 055c4861dda402b40959e0b8173083fe
|
||||
8
Assets/Scripts/Networking/Server.meta
Normal file
8
Assets/Scripts/Networking/Server.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8e6e93f15cef01c24a0f33b38264d831
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
808
Assets/Scripts/Networking/Server/SweServerRuntime.cs
Normal file
808
Assets/Scripts/Networking/Server/SweServerRuntime.cs
Normal file
@@ -0,0 +1,808 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using FloodSWE.IO;
|
||||
using FloodSWE.Streaming;
|
||||
using FloodSWE.TileGraph;
|
||||
using UnityEngine;
|
||||
|
||||
namespace FloodSWE.Networking
|
||||
{
|
||||
public sealed class SweServerRuntime : MonoBehaviour
|
||||
{
|
||||
[Header("Simulation")]
|
||||
public SweTileSimulator simulator;
|
||||
public SweTileLoader tileLoader;
|
||||
public string activeLod = "lod1";
|
||||
public int activeTileX = 1;
|
||||
public int activeTileY = 1;
|
||||
public bool applyTileOnStart = true;
|
||||
|
||||
[Header("Boundary Manifest")]
|
||||
public TextAsset boundaryManifestAsset;
|
||||
public string boundaryManifestPath = "GeoData/export_swe/swe_boundaries.json";
|
||||
|
||||
[Header("Network")]
|
||||
public int commandListenPort = 29010;
|
||||
public string questHost = "";
|
||||
public int questFramePort = 29011;
|
||||
public bool acceptFirstCommandSenderAsQuest = true;
|
||||
|
||||
[Header("Frame Stream")]
|
||||
public float targetFrameHz = 10.0f;
|
||||
[Tooltip("Maximum UDP payload bytes for a single frame datagram.")]
|
||||
public int maxFramePayloadBytes = 60000;
|
||||
[Tooltip("If enabled, oversized frames are downsampled until they fit max payload size.")]
|
||||
public bool downsampleOversizedFrames = true;
|
||||
[Tooltip("Lowest allowed resolution when downsampling oversized frames.")]
|
||||
public int minFrameResolution = 64;
|
||||
|
||||
[Header("Source/Sink Control")]
|
||||
public float sourceDepthRatePerLevelMps = 0.01f;
|
||||
public float sinkDepthRatePerLevelMps = 0.01f;
|
||||
[Tooltip("Applied to free_outflow sinks when no explicit sink level is set.")]
|
||||
public float freeOutflowBaseLevel = 1.0f;
|
||||
public bool applyFreeOutflowByDefault = true;
|
||||
[Tooltip("Applies an initial source level on startup for quick validation without client commands.")]
|
||||
public bool applyDefaultSourceOnStart = false;
|
||||
public int defaultSourceId = 1;
|
||||
public float defaultSourceLevel = 1.0f;
|
||||
|
||||
private readonly Dictionary<int, float> sourceLevels = new Dictionary<int, float>();
|
||||
private readonly Dictionary<int, float> sinkLevels = new Dictionary<int, float>();
|
||||
private readonly Dictionary<string, SweCheckpointState> checkpoints = new Dictionary<string, SweCheckpointState>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private SweBoundaryManifest boundaryManifest;
|
||||
private HashSet<int> activeSourceIds = new HashSet<int>();
|
||||
private HashSet<int> activeSinkIds = new HashSet<int>();
|
||||
|
||||
private UdpClient commandSocket;
|
||||
private IPEndPoint commandEndpoint;
|
||||
private IPEndPoint questEndpoint;
|
||||
private readonly Dictionary<string, IPEndPoint> frameSubscribers = new Dictionary<string, IPEndPoint>(StringComparer.Ordinal);
|
||||
private int lastOversizeWarningFrameId = -1;
|
||||
private string lastMissingMaskWarningTileKey = "";
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (simulator == null)
|
||||
{
|
||||
simulator = GetComponent<SweTileSimulator>();
|
||||
}
|
||||
|
||||
if (simulator == null)
|
||||
{
|
||||
Debug.LogError("SweServerRuntime: missing SweTileSimulator reference.");
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (applyTileOnStart)
|
||||
{
|
||||
bool loaded = TrySetActiveTile(activeLod, activeTileX, activeTileY);
|
||||
if (!loaded)
|
||||
{
|
||||
Debug.LogWarning($"SweServerRuntime: failed to load tile {activeLod} ({activeTileX},{activeTileY}).");
|
||||
}
|
||||
}
|
||||
simulator.emitHeightmapPackets = true;
|
||||
simulator.packetEveryNTicks = ComputePacketInterval(simulator.tickSeconds, targetFrameHz);
|
||||
simulator.HeightmapPacketReady += OnHeightmapPacket;
|
||||
|
||||
LoadBoundaryManifest();
|
||||
RefreshActiveTileBoundaryIds();
|
||||
ApplyDefaultSourceIfConfigured();
|
||||
RecomputeExternalDepthRate();
|
||||
|
||||
StartNetworking();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (simulator != null)
|
||||
{
|
||||
simulator.HeightmapPacketReady -= OnHeightmapPacket;
|
||||
}
|
||||
|
||||
StopNetworking();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
PollCommands();
|
||||
}
|
||||
|
||||
private void OnHeightmapPacket(HeightmapPacket packet)
|
||||
{
|
||||
if (frameSubscribers.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryBuildTransmitPayload(packet, out HeightmapPacket transmitPacket, out byte[] payload))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var stale = new List<string>();
|
||||
foreach (var kv in frameSubscribers)
|
||||
{
|
||||
try
|
||||
{
|
||||
commandSocket.Send(payload, payload.Length, kv.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stale.Add(kv.Key);
|
||||
Debug.LogWarning($"SweServerRuntime: failed to send frame to {kv.Value}. {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < stale.Count; i++)
|
||||
{
|
||||
frameSubscribers.Remove(stale[i]);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryBuildTransmitPayload(HeightmapPacket originalPacket, out HeightmapPacket transmitPacket, out byte[] payload)
|
||||
{
|
||||
transmitPacket = originalPacket;
|
||||
payload = SweUdpProtocol.EncodeFrame(transmitPacket);
|
||||
int maxBytes = Mathf.Clamp(maxFramePayloadBytes, 1200, 65507);
|
||||
if (payload.Length <= maxBytes)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!downsampleOversizedFrames)
|
||||
{
|
||||
WarnOversizedFrame(originalPacket.FrameId, originalPacket.Resolution, originalPacket.Resolution, payload.Length, maxBytes);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!originalPacket.TryDecodeHeights(out float[] sourceHeights))
|
||||
{
|
||||
if (lastOversizeWarningFrameId != originalPacket.FrameId)
|
||||
{
|
||||
lastOversizeWarningFrameId = originalPacket.FrameId;
|
||||
Debug.LogWarning($"SweServerRuntime: oversized frame {originalPacket.FrameId} ({payload.Length} bytes) and decode failed.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int sourceRes = originalPacket.Resolution;
|
||||
int targetRes = sourceRes;
|
||||
int minRes = Mathf.Max(8, minFrameResolution);
|
||||
|
||||
while (payload.Length > maxBytes && targetRes > minRes)
|
||||
{
|
||||
targetRes = Mathf.Max(minRes, targetRes / 2);
|
||||
float[] downsampled = DownsampleHeightsNearest(sourceHeights, sourceRes, targetRes);
|
||||
transmitPacket = HeightmapPacket.FromHeights(originalPacket.FrameId, originalPacket.Tile, targetRes, downsampled);
|
||||
payload = SweUdpProtocol.EncodeFrame(transmitPacket);
|
||||
}
|
||||
|
||||
if (payload.Length <= maxBytes)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
WarnOversizedFrame(originalPacket.FrameId, sourceRes, targetRes, payload.Length, maxBytes);
|
||||
return false;
|
||||
}
|
||||
|
||||
private float[] DownsampleHeightsNearest(float[] source, int sourceRes, int targetRes)
|
||||
{
|
||||
float[] result = new float[targetRes * targetRes];
|
||||
float denom = Mathf.Max(1.0f, targetRes - 1.0f);
|
||||
float sourceMax = sourceRes - 1.0f;
|
||||
int idx = 0;
|
||||
for (int y = 0; y < targetRes; y++)
|
||||
{
|
||||
int sy = Mathf.Clamp(Mathf.RoundToInt((y / denom) * sourceMax), 0, sourceRes - 1);
|
||||
int sourceRow = sy * sourceRes;
|
||||
for (int x = 0; x < targetRes; x++)
|
||||
{
|
||||
int sx = Mathf.Clamp(Mathf.RoundToInt((x / denom) * sourceMax), 0, sourceRes - 1);
|
||||
result[idx++] = source[sourceRow + sx];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void WarnOversizedFrame(int frameId, int sourceRes, int finalRes, int payloadBytes, int maxBytes)
|
||||
{
|
||||
if (lastOversizeWarningFrameId == frameId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lastOversizeWarningFrameId = frameId;
|
||||
Debug.LogWarning(
|
||||
$"SweServerRuntime: dropped frame {frameId}; payload {payloadBytes}B exceeds max {maxBytes}B " +
|
||||
$"(sourceRes={sourceRes}, finalRes={finalRes}).");
|
||||
}
|
||||
|
||||
private void StartNetworking()
|
||||
{
|
||||
try
|
||||
{
|
||||
commandSocket = new UdpClient(commandListenPort);
|
||||
commandSocket.Client.Blocking = false;
|
||||
commandEndpoint = new IPEndPoint(IPAddress.Any, 0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"SweServerRuntime: failed to start command socket on port {commandListenPort}. {ex.Message}");
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(questHost))
|
||||
{
|
||||
try
|
||||
{
|
||||
IPAddress[] addresses = Dns.GetHostAddresses(questHost.Trim());
|
||||
if (addresses.Length > 0)
|
||||
{
|
||||
questEndpoint = new IPEndPoint(addresses[0], questFramePort);
|
||||
AddFrameSubscriber(questEndpoint);
|
||||
Debug.Log($"SweServerRuntime: fixed Quest endpoint {questEndpoint}.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"SweServerRuntime: could not resolve quest host '{questHost}'. {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void StopNetworking()
|
||||
{
|
||||
if (commandSocket != null)
|
||||
{
|
||||
commandSocket.Close();
|
||||
commandSocket = null;
|
||||
}
|
||||
|
||||
commandEndpoint = null;
|
||||
questEndpoint = null;
|
||||
frameSubscribers.Clear();
|
||||
}
|
||||
|
||||
private void PollCommands()
|
||||
{
|
||||
if (commandSocket == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (commandSocket.Available > 0)
|
||||
{
|
||||
byte[] raw;
|
||||
IPEndPoint sender = new IPEndPoint(IPAddress.Any, 0);
|
||||
try
|
||||
{
|
||||
raw = commandSocket.Receive(ref sender);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (raw == null || raw.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (acceptFirstCommandSenderAsQuest && questEndpoint == null)
|
||||
{
|
||||
questEndpoint = new IPEndPoint(sender.Address, questFramePort);
|
||||
AddFrameSubscriber(questEndpoint);
|
||||
Debug.Log($"SweServerRuntime: learned Quest endpoint {questEndpoint} from command sender {sender}.");
|
||||
}
|
||||
|
||||
if (!SweUdpProtocol.TryDecodeControl(raw, out SweControlCommand command) || command == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
HandleCommand(command, sender);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleCommand(SweControlCommand command, IPEndPoint sender)
|
||||
{
|
||||
string cmd = command.command != null ? command.command.Trim().ToLowerInvariant() : string.Empty;
|
||||
switch (cmd)
|
||||
{
|
||||
case "set_source_level":
|
||||
sourceLevels[command.sourceId] = command.sourceLevel;
|
||||
RecomputeExternalDepthRate();
|
||||
SendAck(sender, $"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}");
|
||||
break;
|
||||
|
||||
case "set_active_tile":
|
||||
if (TrySetActiveTile(command.lod, command.tileX, command.tileY))
|
||||
{
|
||||
SendAck(sender, $"active tile: {activeLod} ({activeTileX},{activeTileY})");
|
||||
}
|
||||
else
|
||||
{
|
||||
SendAck(sender, $"active tile missing: {command.lod} ({command.tileX},{command.tileY})");
|
||||
}
|
||||
break;
|
||||
|
||||
case "save_checkpoint":
|
||||
SaveCheckpoint(command.checkpoint);
|
||||
SendAck(sender, $"checkpoint saved: {command.checkpoint}");
|
||||
break;
|
||||
|
||||
case "reset_checkpoint":
|
||||
bool restored = RestoreCheckpoint(command.checkpoint);
|
||||
SendAck(sender, restored ? $"checkpoint restored: {command.checkpoint}" : $"checkpoint missing: {command.checkpoint}");
|
||||
break;
|
||||
|
||||
case "apply_porosity_stamp":
|
||||
bool stamped = simulator.ApplyPorosityStampNormalized(new Vector2(command.u, command.v), command.radius, command.porosity);
|
||||
SendAck(sender, stamped ? "porosity stamp applied" : "porosity stamp failed");
|
||||
break;
|
||||
|
||||
case "hello":
|
||||
if (acceptFirstCommandSenderAsQuest)
|
||||
{
|
||||
var updated = new IPEndPoint(sender.Address, questFramePort);
|
||||
if (questEndpoint == null || !questEndpoint.Equals(updated))
|
||||
{
|
||||
questEndpoint = updated;
|
||||
Debug.Log($"SweServerRuntime: refreshed Quest endpoint to {questEndpoint} from hello.");
|
||||
}
|
||||
|
||||
AddFrameSubscriber(updated);
|
||||
}
|
||||
SendAck(sender, "hello");
|
||||
break;
|
||||
|
||||
case "disconnect":
|
||||
RemoveFrameSubscriber(sender.Address, questFramePort);
|
||||
SendAck(sender, "disconnected");
|
||||
break;
|
||||
|
||||
default:
|
||||
SendAck(sender, $"unknown command: {command.command}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TrySetActiveTile(string lod, int tileX, int tileY)
|
||||
{
|
||||
string targetLod = string.IsNullOrWhiteSpace(lod) ? activeLod : lod.Trim();
|
||||
int targetX = tileX;
|
||||
int targetY = tileY;
|
||||
|
||||
if (tileLoader == null)
|
||||
{
|
||||
activeLod = targetLod;
|
||||
activeTileX = targetX;
|
||||
activeTileY = targetY;
|
||||
simulator.packetTileId = new TileId(SweBoundaryManifest.ParseLod(activeLod), activeTileX, activeTileY);
|
||||
RefreshActiveTileBoundaryIds();
|
||||
RecomputeExternalDepthRate();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool loaded = tileLoader.ApplyToSimulator(targetLod, targetX, targetY, simulator);
|
||||
if (!loaded)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
activeLod = targetLod;
|
||||
activeTileX = targetX;
|
||||
activeTileY = targetY;
|
||||
simulator.packetTileId = new TileId(SweBoundaryManifest.ParseLod(activeLod), activeTileX, activeTileY);
|
||||
RefreshActiveTileBoundaryIds();
|
||||
RecomputeExternalDepthRate();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void SendAck(IPEndPoint target, string message)
|
||||
{
|
||||
if (commandSocket == null || target == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] payload = SweUdpProtocol.EncodeAck(message);
|
||||
try
|
||||
{
|
||||
commandSocket.Send(payload, payload.Length, target);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"SweServerRuntime: failed to send ack. {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadBoundaryManifest()
|
||||
{
|
||||
string json = null;
|
||||
if (boundaryManifestAsset != null)
|
||||
{
|
||||
json = boundaryManifestAsset.text;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(boundaryManifestPath))
|
||||
{
|
||||
string path = boundaryManifestPath;
|
||||
if (!Path.IsPathRooted(path))
|
||||
{
|
||||
path = Path.Combine(Directory.GetCurrentDirectory(), boundaryManifestPath);
|
||||
}
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
json = File.ReadAllText(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!SweBoundaryManifest.TryLoad(json, out boundaryManifest))
|
||||
{
|
||||
Debug.LogWarning("SweServerRuntime: boundary manifest missing or invalid. Source/sink control disabled for this run.");
|
||||
boundaryManifest = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshActiveTileBoundaryIds()
|
||||
{
|
||||
activeSourceIds = new HashSet<int>();
|
||||
activeSinkIds = new HashSet<int>();
|
||||
|
||||
if (boundaryManifest == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!boundaryManifest.TryGetTile(activeLod, activeTileX, activeTileY, out SweBoundaryTile tile) || tile == null)
|
||||
{
|
||||
Debug.LogWarning($"SweServerRuntime: no boundary entry for tile {activeLod} ({activeTileX},{activeTileY}).");
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"SweServerRuntime: active tile sources={activeSourceIds.Count}, sinks={activeSinkIds.Count}");
|
||||
}
|
||||
|
||||
private void RecomputeExternalDepthRate()
|
||||
{
|
||||
var sourceRates = new Dictionary<int, float>();
|
||||
foreach (int sourceId in activeSourceIds)
|
||||
{
|
||||
if (!sourceLevels.TryGetValue(sourceId, out float value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
float rate = Mathf.Max(0.0f, value) * sourceDepthRatePerLevelMps;
|
||||
if (rate > 0.0f)
|
||||
{
|
||||
sourceRates[sourceId] = rate;
|
||||
}
|
||||
}
|
||||
|
||||
var sinkRates = new Dictionary<int, float>();
|
||||
foreach (int sinkId in activeSinkIds)
|
||||
{
|
||||
if (sinkLevels.TryGetValue(sinkId, out float value))
|
||||
{
|
||||
float rate = Mathf.Max(0.0f, value) * sinkDepthRatePerLevelMps;
|
||||
if (rate > 0.0f)
|
||||
{
|
||||
sinkRates[sinkId] = rate;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (applyFreeOutflowByDefault &&
|
||||
boundaryManifest != null &&
|
||||
boundaryManifest.TryGetSink(sinkId, out SweBoundarySink sink) &&
|
||||
sink != null &&
|
||||
sink.@params != null &&
|
||||
sink.@params.IsMode("free_outflow"))
|
||||
{
|
||||
float rate = Mathf.Max(0.0f, freeOutflowBaseLevel) * sinkDepthRatePerLevelMps;
|
||||
if (rate > 0.0f)
|
||||
{
|
||||
sinkRates[sinkId] = rate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool hasBoundaryRates = sourceRates.Count > 0 || sinkRates.Count > 0;
|
||||
if (!hasBoundaryRates)
|
||||
{
|
||||
simulator.ClearExternalDepthRateMap();
|
||||
simulator.SetExternalDepthRate(0.0f);
|
||||
return;
|
||||
}
|
||||
|
||||
if (simulator.TryGetBoundaryIdMasks(out int[] sourceMask, out int[] sinkMask))
|
||||
{
|
||||
int n = simulator.gridRes * simulator.gridRes;
|
||||
float[] perCell = new float[n];
|
||||
bool anyNonZero = false;
|
||||
int forcedCellCount = 0;
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
if (!IsBorderCell(i, simulator.gridRes))
|
||||
{
|
||||
perCell[i] = 0.0f;
|
||||
continue;
|
||||
}
|
||||
|
||||
float rate = 0.0f;
|
||||
if (sourceMask != null)
|
||||
{
|
||||
int sourceId = sourceMask[i];
|
||||
if (sourceId > 0 && sourceRates.TryGetValue(sourceId, out float sourceRate))
|
||||
{
|
||||
rate += sourceRate;
|
||||
}
|
||||
}
|
||||
|
||||
if (sinkMask != null)
|
||||
{
|
||||
int sinkId = sinkMask[i];
|
||||
if (sinkId > 0 && sinkRates.TryGetValue(sinkId, out float sinkRate))
|
||||
{
|
||||
rate -= sinkRate;
|
||||
}
|
||||
}
|
||||
|
||||
perCell[i] = rate;
|
||||
anyNonZero = anyNonZero || Mathf.Abs(rate) > 1e-9f;
|
||||
if (Mathf.Abs(rate) > 1e-9f)
|
||||
{
|
||||
forcedCellCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (anyNonZero && simulator.SetExternalDepthRateMap(perCell))
|
||||
{
|
||||
simulator.SetExternalDepthRate(0.0f);
|
||||
Debug.Log($"SweServerRuntime: applied localized boundary forcing cells={forcedCellCount}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
string key = $"{activeLod}|{activeTileX}|{activeTileY}";
|
||||
if (!string.Equals(lastMissingMaskWarningTileKey, key, StringComparison.Ordinal))
|
||||
{
|
||||
lastMissingMaskWarningTileKey = key;
|
||||
Debug.LogWarning(
|
||||
$"SweServerRuntime: no readable source/sink ID masks for tile {activeLod} ({activeTileX},{activeTileY}); " +
|
||||
"disabling source/sink forcing.");
|
||||
}
|
||||
}
|
||||
|
||||
// Strict mode: no masks means no forcing. This prevents non-physical uniform flooding.
|
||||
simulator.ClearExternalDepthRateMap();
|
||||
simulator.SetExternalDepthRate(0.0f);
|
||||
}
|
||||
|
||||
private static bool IsBorderCell(int flatIndex, int resolution)
|
||||
{
|
||||
if (resolution <= 1)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
int x = flatIndex % resolution;
|
||||
int y = flatIndex / resolution;
|
||||
return x == 0 || y == 0 || x == resolution - 1 || y == resolution - 1;
|
||||
}
|
||||
|
||||
private void ApplyDefaultSourceIfConfigured()
|
||||
{
|
||||
if (!applyDefaultSourceOnStart)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (defaultSourceId <= 0)
|
||||
{
|
||||
Debug.LogWarning("SweServerRuntime: default source is enabled, but defaultSourceId is <= 0.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeSourceIds.Contains(defaultSourceId))
|
||||
{
|
||||
Debug.LogWarning(
|
||||
$"SweServerRuntime: default source id {defaultSourceId} is not active on tile {activeLod} ({activeTileX},{activeTileY}).");
|
||||
return;
|
||||
}
|
||||
|
||||
float clamped = Mathf.Max(0.0f, defaultSourceLevel);
|
||||
sourceLevels[defaultSourceId] = clamped;
|
||||
Debug.Log($"SweServerRuntime: applied startup source id={defaultSourceId} level={clamped:0.###}");
|
||||
}
|
||||
|
||||
private void SaveCheckpoint(string checkpointName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(checkpointName))
|
||||
{
|
||||
checkpointName = "default";
|
||||
}
|
||||
|
||||
if (!simulator.TryCaptureWaterHeights(out float[] water))
|
||||
{
|
||||
Debug.LogWarning("SweServerRuntime: checkpoint capture failed (water).");
|
||||
return;
|
||||
}
|
||||
|
||||
simulator.TryCapturePorosity(out float[] porosity);
|
||||
|
||||
SweCheckpointState state = new SweCheckpointState
|
||||
{
|
||||
waterHeights = water,
|
||||
porosityValues = porosity,
|
||||
sourceLevels = CloneDictionary(sourceLevels),
|
||||
sinkLevels = CloneDictionary(sinkLevels),
|
||||
};
|
||||
|
||||
checkpoints[checkpointName] = state;
|
||||
}
|
||||
|
||||
private bool RestoreCheckpoint(string checkpointName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(checkpointName))
|
||||
{
|
||||
checkpointName = "default";
|
||||
}
|
||||
|
||||
if (!checkpoints.TryGetValue(checkpointName, out SweCheckpointState state) || state == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool restoredWater = simulator.TryRestoreWaterHeights(state.waterHeights, true);
|
||||
if (!restoredWater)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.porosityValues != null)
|
||||
{
|
||||
simulator.TryRestorePorosity(state.porosityValues);
|
||||
}
|
||||
|
||||
sourceLevels.Clear();
|
||||
sinkLevels.Clear();
|
||||
|
||||
if (state.sourceLevels != null)
|
||||
{
|
||||
foreach (var pair in state.sourceLevels)
|
||||
{
|
||||
sourceLevels[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.sinkLevels != null)
|
||||
{
|
||||
foreach (var pair in state.sinkLevels)
|
||||
{
|
||||
sinkLevels[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
RecomputeExternalDepthRate();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Dictionary<int, float> CloneDictionary(Dictionary<int, float> source)
|
||||
{
|
||||
var clone = new Dictionary<int, float>();
|
||||
if (source == null)
|
||||
{
|
||||
return clone;
|
||||
}
|
||||
|
||||
foreach (var pair in source)
|
||||
{
|
||||
clone[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static int ComputePacketInterval(float tickSeconds, float hz)
|
||||
{
|
||||
if (hz <= 0.0f || tickSeconds <= 0.0f)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
float targetSeconds = 1.0f / hz;
|
||||
return Mathf.Max(1, Mathf.RoundToInt(targetSeconds / tickSeconds));
|
||||
}
|
||||
|
||||
private void AddFrameSubscriber(IPEndPoint endpoint)
|
||||
{
|
||||
if (endpoint == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
frameSubscribers[MakeSubscriberKey(endpoint)] = endpoint;
|
||||
}
|
||||
|
||||
private void RemoveFrameSubscriber(IPAddress address, int port)
|
||||
{
|
||||
if (address == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string key = MakeSubscriberKey(address, port);
|
||||
frameSubscribers.Remove(key);
|
||||
if (questEndpoint != null &&
|
||||
questEndpoint.Address.Equals(address) &&
|
||||
questEndpoint.Port == port)
|
||||
{
|
||||
questEndpoint = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string MakeSubscriberKey(IPEndPoint endpoint)
|
||||
{
|
||||
return MakeSubscriberKey(endpoint.Address, endpoint.Port);
|
||||
}
|
||||
|
||||
private static string MakeSubscriberKey(IPAddress address, int port)
|
||||
{
|
||||
return $"{address}:{port}";
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private sealed class SweCheckpointState
|
||||
{
|
||||
public float[] waterHeights;
|
||||
public float[] porosityValues;
|
||||
public Dictionary<int, float> sourceLevels;
|
||||
public Dictionary<int, float> sinkLevels;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 385f6afd80a39969e9d96c96cd83ac64
|
||||
8
Assets/Scripts/Networking/Shared.meta
Normal file
8
Assets/Scripts/Networking/Shared.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b67d82eacd1fe31f88cad4ca50d665bb
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
198
Assets/Scripts/Networking/Shared/SweBoundaryManifest.cs
Normal file
198
Assets/Scripts/Networking/Shared/SweBoundaryManifest.cs
Normal file
@@ -0,0 +1,198 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace FloodSWE.Networking
|
||||
{
|
||||
[Serializable]
|
||||
public sealed class SweBoundaryManifest
|
||||
{
|
||||
public int schema_version;
|
||||
public SweBoundarySource[] sources;
|
||||
public SweBoundarySink[] sinks;
|
||||
public SweBoundaryTile[] tiles;
|
||||
|
||||
[NonSerialized] private Dictionary<string, SweBoundaryTile> tileLookup;
|
||||
[NonSerialized] private Dictionary<int, SweBoundarySource> sourceLookup;
|
||||
[NonSerialized] private Dictionary<int, SweBoundarySink> sinkLookup;
|
||||
|
||||
public static bool TryLoad(string json, out SweBoundaryManifest manifest)
|
||||
{
|
||||
manifest = null;
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
manifest = JsonUtility.FromJson<SweBoundaryManifest>(json);
|
||||
if (manifest == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
manifest.BuildLookup();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"SweBoundaryManifest: failed to parse manifest. {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetTile(string lod, int tileX, int tileY, out SweBoundaryTile tile)
|
||||
{
|
||||
tile = null;
|
||||
if (tileLookup == null)
|
||||
{
|
||||
BuildLookup();
|
||||
}
|
||||
|
||||
return tileLookup != null && tileLookup.TryGetValue(MakeTileKey(lod, tileX, tileY), out tile);
|
||||
}
|
||||
|
||||
public bool TryGetSource(int id, out SweBoundarySource source)
|
||||
{
|
||||
source = null;
|
||||
if (sourceLookup == null)
|
||||
{
|
||||
BuildLookup();
|
||||
}
|
||||
|
||||
return sourceLookup != null && sourceLookup.TryGetValue(id, out source);
|
||||
}
|
||||
|
||||
public bool TryGetSink(int id, out SweBoundarySink sink)
|
||||
{
|
||||
sink = null;
|
||||
if (sinkLookup == null)
|
||||
{
|
||||
BuildLookup();
|
||||
}
|
||||
|
||||
return sinkLookup != null && sinkLookup.TryGetValue(id, out sink);
|
||||
}
|
||||
|
||||
public static int ParseLod(string lod)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lod))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
string value = lod.Trim().ToLowerInvariant();
|
||||
if (value.StartsWith("lod", StringComparison.Ordinal))
|
||||
{
|
||||
value = value.Substring(3);
|
||||
}
|
||||
|
||||
return int.TryParse(value, out int parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
private void BuildLookup()
|
||||
{
|
||||
tileLookup = new Dictionary<string, SweBoundaryTile>(StringComparer.OrdinalIgnoreCase);
|
||||
sourceLookup = new Dictionary<int, SweBoundarySource>();
|
||||
sinkLookup = new Dictionary<int, SweBoundarySink>();
|
||||
if (tiles == null)
|
||||
{
|
||||
tileLookup = tileLookup ?? new Dictionary<string, SweBoundaryTile>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (tiles != null)
|
||||
{
|
||||
for (int i = 0; i < tiles.Length; i++)
|
||||
{
|
||||
SweBoundaryTile tile = tiles[i];
|
||||
if (tile == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
tileLookup[MakeTileKey(tile.lod, tile.tile_x, tile.tile_y)] = tile;
|
||||
}
|
||||
}
|
||||
|
||||
if (sources != null)
|
||||
{
|
||||
for (int i = 0; i < sources.Length; i++)
|
||||
{
|
||||
SweBoundarySource source = sources[i];
|
||||
if (source != null)
|
||||
{
|
||||
sourceLookup[source.id] = source;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sinks != null)
|
||||
{
|
||||
for (int i = 0; i < sinks.Length; i++)
|
||||
{
|
||||
SweBoundarySink sink = sinks[i];
|
||||
if (sink != null)
|
||||
{
|
||||
sinkLookup[sink.id] = sink;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string MakeTileKey(string lod, int tileX, int tileY)
|
||||
{
|
||||
return $"{lod}|{tileX}|{tileY}";
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class SweBoundarySource
|
||||
{
|
||||
public int id;
|
||||
public int tile_count;
|
||||
public int total_pixels;
|
||||
public SweBoundaryParams @params;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class SweBoundarySink
|
||||
{
|
||||
public int id;
|
||||
public int tile_count;
|
||||
public int total_pixels;
|
||||
public SweBoundaryParams @params;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class SweBoundaryTile
|
||||
{
|
||||
public string lod;
|
||||
public int tile_x;
|
||||
public int tile_y;
|
||||
public SweBoundaryTileIdRef[] source_ids;
|
||||
public SweBoundaryTileIdRef[] sink_ids;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class SweBoundaryParams
|
||||
{
|
||||
public string name;
|
||||
public string mode;
|
||||
public float trigger_level_m;
|
||||
public float max_outflow_m3s;
|
||||
|
||||
public bool IsMode(string value)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(mode) &&
|
||||
string.Equals(mode.Trim(), value, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class SweBoundaryTileIdRef
|
||||
{
|
||||
public int id;
|
||||
public int pixels;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b866d23532dcb9554b203749a3b654c9
|
||||
178
Assets/Scripts/Networking/Shared/SweUdpProtocol.cs
Normal file
178
Assets/Scripts/Networking/Shared/SweUdpProtocol.cs
Normal file
@@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using FloodSWE.Streaming;
|
||||
using FloodSWE.TileGraph;
|
||||
using UnityEngine;
|
||||
|
||||
namespace FloodSWE.Networking
|
||||
{
|
||||
public static class SweUdpProtocol
|
||||
{
|
||||
public const byte FrameType = 1;
|
||||
public const byte ControlType = 2;
|
||||
public const byte AckType = 3;
|
||||
|
||||
public static byte[] EncodeFrame(HeightmapPacket packet)
|
||||
{
|
||||
using (var stream = new MemoryStream(64 + (packet.Payload?.Length ?? 0)))
|
||||
using (var writer = new BinaryWriter(stream, Encoding.UTF8, true))
|
||||
{
|
||||
writer.Write(FrameType);
|
||||
writer.Write(packet.FrameId);
|
||||
writer.Write(packet.Tile.Lod);
|
||||
writer.Write(packet.Tile.X);
|
||||
writer.Write(packet.Tile.Y);
|
||||
writer.Write(packet.Resolution);
|
||||
writer.Write(packet.MinHeight);
|
||||
writer.Write(packet.MaxHeight);
|
||||
|
||||
int length = packet.Payload != null ? packet.Payload.Length : 0;
|
||||
writer.Write(length);
|
||||
if (length > 0)
|
||||
{
|
||||
writer.Write(packet.Payload);
|
||||
}
|
||||
|
||||
writer.Flush();
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryDecodeFrame(byte[] data, out HeightmapPacket packet)
|
||||
{
|
||||
packet = default;
|
||||
if (data == null || data.Length < 32)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using (var stream = new MemoryStream(data))
|
||||
using (var reader = new BinaryReader(stream, Encoding.UTF8, true))
|
||||
{
|
||||
if (reader.ReadByte() != FrameType)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int frameId = reader.ReadInt32();
|
||||
int lod = reader.ReadInt32();
|
||||
int x = reader.ReadInt32();
|
||||
int y = reader.ReadInt32();
|
||||
int resolution = reader.ReadInt32();
|
||||
float minHeight = reader.ReadSingle();
|
||||
float maxHeight = reader.ReadSingle();
|
||||
int payloadLen = reader.ReadInt32();
|
||||
|
||||
if (payloadLen <= 0 || payloadLen > data.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] payload = reader.ReadBytes(payloadLen);
|
||||
if (payload.Length != payloadLen)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
packet = new HeightmapPacket
|
||||
{
|
||||
FrameId = frameId,
|
||||
Tile = new TileId(lod, x, y),
|
||||
Resolution = resolution,
|
||||
MinHeight = minHeight,
|
||||
MaxHeight = maxHeight,
|
||||
Payload = payload,
|
||||
};
|
||||
|
||||
return packet.IsValid;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] EncodeControl(SweControlCommand command)
|
||||
{
|
||||
string json = JsonUtility.ToJson(command);
|
||||
byte[] utf8 = Encoding.UTF8.GetBytes(json);
|
||||
byte[] payload = new byte[utf8.Length + 1];
|
||||
payload[0] = ControlType;
|
||||
Buffer.BlockCopy(utf8, 0, payload, 1, utf8.Length);
|
||||
return payload;
|
||||
}
|
||||
|
||||
public static bool TryDecodeControl(byte[] data, out SweControlCommand command)
|
||||
{
|
||||
command = null;
|
||||
if (data == null || data.Length < 2 || data[0] != ControlType)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string json = Encoding.UTF8.GetString(data, 1, data.Length - 1);
|
||||
command = JsonUtility.FromJson<SweControlCommand>(json);
|
||||
return command != null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] EncodeAck(string message)
|
||||
{
|
||||
byte[] utf8 = Encoding.UTF8.GetBytes(message ?? string.Empty);
|
||||
byte[] payload = new byte[utf8.Length + 1];
|
||||
payload[0] = AckType;
|
||||
Buffer.BlockCopy(utf8, 0, payload, 1, utf8.Length);
|
||||
return payload;
|
||||
}
|
||||
|
||||
public static bool TryDecodeAck(byte[] data, out string message)
|
||||
{
|
||||
message = null;
|
||||
if (data == null || data.Length < 1 || data[0] != AckType)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
message = data.Length == 1
|
||||
? string.Empty
|
||||
: Encoding.UTF8.GetString(data, 1, data.Length - 1);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
message = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class SweControlCommand
|
||||
{
|
||||
public string command;
|
||||
public string lod;
|
||||
public int tileX;
|
||||
public int tileY;
|
||||
public int sourceId;
|
||||
public float sourceLevel;
|
||||
public int sinkId;
|
||||
public float sinkLevel;
|
||||
public string checkpoint;
|
||||
public float u;
|
||||
public float v;
|
||||
public float radius;
|
||||
public float porosity;
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Networking/Shared/SweUdpProtocol.cs.meta
Normal file
2
Assets/Scripts/Networking/Shared/SweUdpProtocol.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 70f5464ad7c6d1f288fa573272c0ed49
|
||||
Reference in New Issue
Block a user