Files
the-island/unity-client/Assets/Scripts/NetworkManager.cs
empty 64ed46215f feat: add Unity 6 client with 2.5D visual system
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>
2026-01-01 12:17:04 +08:00

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
}
}