Unity client features: - WebSocket connection via NativeWebSocket - 2.5D agent visuals with programmatic placeholder sprites - Billboard system for sprites and UI elements - Floating UI panels (name, HP, energy bars) - Speech bubble system with pop-in animation - RTS-style camera controller (WASD + scroll zoom) - Editor tools for prefab creation and scene setup Scripts: - NetworkManager: WebSocket singleton - GameManager: Agent spawning and event handling - AgentVisual: 2.5D sprite and UI creation - Billboard: Camera-facing behavior - SpeechBubble: Animated dialogue display - CameraController: RTS camera with UI input detection - UIManager: HUD and command input 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
438 lines
13 KiB
C#
438 lines
13 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
using NativeWebSocket;
|
|
using TheIsland.Models;
|
|
|
|
namespace TheIsland.Network
|
|
{
|
|
/// <summary>
|
|
/// Singleton WebSocket manager for server communication.
|
|
/// Handles connection, message parsing, and event dispatching.
|
|
/// </summary>
|
|
public class NetworkManager : MonoBehaviour
|
|
{
|
|
#region Singleton
|
|
private static NetworkManager _instance;
|
|
public static NetworkManager Instance
|
|
{
|
|
get
|
|
{
|
|
if (_instance == null)
|
|
{
|
|
_instance = FindFirstObjectByType<NetworkManager>();
|
|
if (_instance == null)
|
|
{
|
|
var go = new GameObject("NetworkManager");
|
|
_instance = go.AddComponent<NetworkManager>();
|
|
}
|
|
}
|
|
return _instance;
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region Configuration
|
|
[Header("Server Configuration")]
|
|
[SerializeField] private string serverUrl = "ws://localhost:8080/ws";
|
|
[SerializeField] private bool autoConnect = true;
|
|
[SerializeField] private float reconnectDelay = 3f;
|
|
|
|
[Header("User Settings")]
|
|
[SerializeField] private string username = "UnityPlayer";
|
|
#endregion
|
|
|
|
#region Events
|
|
// Connection events
|
|
public event Action OnConnected;
|
|
public event Action OnDisconnected;
|
|
public event Action<string> OnError;
|
|
|
|
// Game events
|
|
public event Action<List<AgentData>> OnAgentsUpdate;
|
|
public event Action<AgentSpeakData> OnAgentSpeak;
|
|
public event Action<AgentDiedData> OnAgentDied;
|
|
public event Action<FeedEventData> OnFeed;
|
|
public event Action<TickData> OnTick;
|
|
public event Action<SystemEventData> OnSystemMessage;
|
|
public event Action<UserUpdateData> OnUserUpdate;
|
|
#endregion
|
|
|
|
#region Private Fields
|
|
private WebSocket _websocket;
|
|
private bool _isConnecting;
|
|
private bool _shouldReconnect = true;
|
|
private bool _hasNotifiedConnected = false;
|
|
#endregion
|
|
|
|
#region Properties
|
|
public bool IsConnected => _websocket?.State == WebSocketState.Open;
|
|
public string Username
|
|
{
|
|
get => username;
|
|
set => username = value;
|
|
}
|
|
public string ServerUrl
|
|
{
|
|
get => serverUrl;
|
|
set => serverUrl = value;
|
|
}
|
|
#endregion
|
|
|
|
#region Unity Lifecycle
|
|
private void Awake()
|
|
{
|
|
// Singleton enforcement
|
|
if (_instance != null && _instance != this)
|
|
{
|
|
Destroy(gameObject);
|
|
return;
|
|
}
|
|
_instance = this;
|
|
DontDestroyOnLoad(gameObject);
|
|
}
|
|
|
|
private async void Start()
|
|
{
|
|
if (autoConnect)
|
|
{
|
|
await Connect();
|
|
}
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
// CRITICAL: Dispatch message queue on main thread
|
|
// NativeWebSocket requires this for callbacks to work
|
|
#if !UNITY_WEBGL || UNITY_EDITOR
|
|
if (_websocket != null)
|
|
{
|
|
_websocket.DispatchMessageQueue();
|
|
|
|
// Fallback: Check connection state directly
|
|
if (_websocket.State == WebSocketState.Open && !_hasNotifiedConnected)
|
|
{
|
|
_hasNotifiedConnected = true;
|
|
Debug.Log("[NetworkManager] Connection detected via state check!");
|
|
OnConnected?.Invoke();
|
|
}
|
|
else if (_websocket.State != WebSocketState.Open && _hasNotifiedConnected)
|
|
{
|
|
_hasNotifiedConnected = false;
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private async void OnApplicationQuit()
|
|
{
|
|
_shouldReconnect = false;
|
|
if (_websocket != null)
|
|
{
|
|
await _websocket.Close();
|
|
}
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
_shouldReconnect = false;
|
|
_websocket?.Close();
|
|
}
|
|
#endregion
|
|
|
|
#region Connection Management
|
|
public async System.Threading.Tasks.Task Connect()
|
|
{
|
|
if (_isConnecting || IsConnected) return;
|
|
|
|
_isConnecting = true;
|
|
Debug.Log($"[NetworkManager] Connecting to {serverUrl}...");
|
|
|
|
try
|
|
{
|
|
_websocket = new WebSocket(serverUrl);
|
|
|
|
_websocket.OnOpen += HandleOpen;
|
|
_websocket.OnClose += HandleClose;
|
|
_websocket.OnError += HandleError;
|
|
_websocket.OnMessage += HandleMessage;
|
|
|
|
await _websocket.Connect();
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Debug.LogError($"[NetworkManager] Connection failed: {e.Message}");
|
|
_isConnecting = false;
|
|
OnError?.Invoke(e.Message);
|
|
|
|
if (_shouldReconnect)
|
|
{
|
|
ScheduleReconnect();
|
|
}
|
|
}
|
|
}
|
|
|
|
public async void Disconnect()
|
|
{
|
|
_shouldReconnect = false;
|
|
if (_websocket != null)
|
|
{
|
|
await _websocket.Close();
|
|
}
|
|
}
|
|
|
|
private void ScheduleReconnect()
|
|
{
|
|
if (_shouldReconnect)
|
|
{
|
|
Debug.Log($"[NetworkManager] Reconnecting in {reconnectDelay}s...");
|
|
Invoke(nameof(TryReconnect), reconnectDelay);
|
|
}
|
|
}
|
|
|
|
private async void TryReconnect()
|
|
{
|
|
await Connect();
|
|
}
|
|
#endregion
|
|
|
|
#region WebSocket Event Handlers
|
|
private void HandleOpen()
|
|
{
|
|
_isConnecting = false;
|
|
Debug.Log("[NetworkManager] Connected to server!");
|
|
OnConnected?.Invoke();
|
|
}
|
|
|
|
private void HandleClose(WebSocketCloseCode code)
|
|
{
|
|
_isConnecting = false;
|
|
Debug.Log($"[NetworkManager] Disconnected (code: {code})");
|
|
OnDisconnected?.Invoke();
|
|
|
|
if (_shouldReconnect && code != WebSocketCloseCode.Normal)
|
|
{
|
|
ScheduleReconnect();
|
|
}
|
|
}
|
|
|
|
private void HandleError(string error)
|
|
{
|
|
_isConnecting = false;
|
|
Debug.LogError($"[NetworkManager] WebSocket error: {error}");
|
|
OnError?.Invoke(error);
|
|
}
|
|
|
|
private void HandleMessage(byte[] data)
|
|
{
|
|
string json = System.Text.Encoding.UTF8.GetString(data);
|
|
ProcessMessage(json);
|
|
}
|
|
#endregion
|
|
|
|
#region Message Processing
|
|
private void ProcessMessage(string json)
|
|
{
|
|
try
|
|
{
|
|
// First, extract the event_type
|
|
var baseMessage = JsonUtility.FromJson<ServerMessage>(json);
|
|
|
|
if (string.IsNullOrEmpty(baseMessage.event_type))
|
|
{
|
|
Debug.LogWarning("[NetworkManager] Received message without event_type");
|
|
return;
|
|
}
|
|
|
|
// Extract the data portion using regex (JsonUtility limitation workaround)
|
|
string dataJson = ExtractDataJson(json);
|
|
|
|
// Dispatch based on event type
|
|
switch (baseMessage.event_type)
|
|
{
|
|
case EventTypes.AGENTS_UPDATE:
|
|
var agentsData = JsonUtility.FromJson<AgentsUpdateData>(dataJson);
|
|
OnAgentsUpdate?.Invoke(agentsData.agents);
|
|
break;
|
|
|
|
case EventTypes.AGENT_SPEAK:
|
|
var speakData = JsonUtility.FromJson<AgentSpeakData>(dataJson);
|
|
OnAgentSpeak?.Invoke(speakData);
|
|
break;
|
|
|
|
case EventTypes.AGENT_DIED:
|
|
var diedData = JsonUtility.FromJson<AgentDiedData>(dataJson);
|
|
OnAgentDied?.Invoke(diedData);
|
|
break;
|
|
|
|
case EventTypes.FEED:
|
|
var feedData = JsonUtility.FromJson<FeedEventData>(dataJson);
|
|
OnFeed?.Invoke(feedData);
|
|
break;
|
|
|
|
case EventTypes.TICK:
|
|
var tickData = JsonUtility.FromJson<TickData>(dataJson);
|
|
OnTick?.Invoke(tickData);
|
|
break;
|
|
|
|
case EventTypes.SYSTEM:
|
|
case EventTypes.ERROR:
|
|
var sysData = JsonUtility.FromJson<SystemEventData>(dataJson);
|
|
OnSystemMessage?.Invoke(sysData);
|
|
break;
|
|
|
|
case EventTypes.USER_UPDATE:
|
|
var userData = JsonUtility.FromJson<UserUpdateData>(dataJson);
|
|
OnUserUpdate?.Invoke(userData);
|
|
break;
|
|
|
|
case EventTypes.COMMENT:
|
|
// Comments can be logged but typically not displayed in 3D
|
|
Debug.Log($"[Chat] {json}");
|
|
break;
|
|
|
|
default:
|
|
Debug.Log($"[NetworkManager] Unhandled event type: {baseMessage.event_type}");
|
|
break;
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Debug.LogError($"[NetworkManager] Failed to process message: {e.Message}\nJSON: {json}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extract the "data" object as a JSON string for nested deserialization.
|
|
/// Uses a balanced bracket approach for better reliability.
|
|
/// </summary>
|
|
private string ExtractDataJson(string fullJson)
|
|
{
|
|
// Find the start of "data":
|
|
int dataIndex = fullJson.IndexOf("\"data\"");
|
|
if (dataIndex == -1) return "{}";
|
|
|
|
// Find the colon after "data"
|
|
int colonIndex = fullJson.IndexOf(':', dataIndex);
|
|
if (colonIndex == -1) return "{}";
|
|
|
|
// Skip whitespace after colon
|
|
int startIndex = colonIndex + 1;
|
|
while (startIndex < fullJson.Length && char.IsWhiteSpace(fullJson[startIndex]))
|
|
{
|
|
startIndex++;
|
|
}
|
|
|
|
if (startIndex >= fullJson.Length) return "{}";
|
|
|
|
char firstChar = fullJson[startIndex];
|
|
|
|
// Handle object
|
|
if (firstChar == '{')
|
|
{
|
|
return ExtractBalancedBrackets(fullJson, startIndex, '{', '}');
|
|
}
|
|
// Handle array
|
|
else if (firstChar == '[')
|
|
{
|
|
return ExtractBalancedBrackets(fullJson, startIndex, '[', ']');
|
|
}
|
|
// Handle primitive (string, number, bool, null)
|
|
else
|
|
{
|
|
return "{}"; // Primitives not expected for our protocol
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extract a balanced bracket section from JSON string.
|
|
/// Handles nested brackets and escaped strings properly.
|
|
/// </summary>
|
|
private string ExtractBalancedBrackets(string json, int start, char open, char close)
|
|
{
|
|
int depth = 0;
|
|
bool inString = false;
|
|
bool escape = false;
|
|
|
|
for (int i = start; i < json.Length; i++)
|
|
{
|
|
char c = json[i];
|
|
|
|
if (escape)
|
|
{
|
|
escape = false;
|
|
continue;
|
|
}
|
|
|
|
if (c == '\\' && inString)
|
|
{
|
|
escape = true;
|
|
continue;
|
|
}
|
|
|
|
if (c == '"')
|
|
{
|
|
inString = !inString;
|
|
continue;
|
|
}
|
|
|
|
if (!inString)
|
|
{
|
|
if (c == open) depth++;
|
|
else if (c == close)
|
|
{
|
|
depth--;
|
|
if (depth == 0)
|
|
{
|
|
return json.Substring(start, i - start + 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return "{}"; // Fallback if unbalanced
|
|
}
|
|
#endregion
|
|
|
|
#region Sending Messages
|
|
public async void SendCommand(string command)
|
|
{
|
|
if (!IsConnected)
|
|
{
|
|
Debug.LogWarning("[NetworkManager] Cannot send - not connected");
|
|
return;
|
|
}
|
|
|
|
var message = new ClientMessage
|
|
{
|
|
action = "send_comment",
|
|
payload = new ClientPayload
|
|
{
|
|
user = username,
|
|
message = command
|
|
}
|
|
};
|
|
|
|
string json = JsonUtility.ToJson(message);
|
|
await _websocket.SendText(json);
|
|
Debug.Log($"[NetworkManager] Sent: {json}");
|
|
}
|
|
|
|
public void FeedAgent(string agentName)
|
|
{
|
|
SendCommand($"feed {agentName}");
|
|
}
|
|
|
|
public void CheckStatus()
|
|
{
|
|
SendCommand("check");
|
|
}
|
|
|
|
public void ResetGame()
|
|
{
|
|
SendCommand("reset");
|
|
}
|
|
#endregion
|
|
}
|
|
}
|