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>
This commit is contained in:
437
unity-client/Assets/Scripts/NetworkManager.cs
Normal file
437
unity-client/Assets/Scripts/NetworkManager.cs
Normal file
@@ -0,0 +1,437 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user