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:
320
unity-client/Assets/Scripts/AgentController.cs
Normal file
320
unity-client/Assets/Scripts/AgentController.cs
Normal file
@@ -0,0 +1,320 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using TheIsland.Models;
|
||||
using TheIsland.Network;
|
||||
|
||||
namespace TheIsland.Agents
|
||||
{
|
||||
/// <summary>
|
||||
/// Controller for individual Agent prefabs.
|
||||
/// Handles UI updates, animations, and speech bubbles.
|
||||
/// </summary>
|
||||
public class AgentController : MonoBehaviour
|
||||
{
|
||||
#region UI References
|
||||
[Header("UI Elements")]
|
||||
[SerializeField] private TextMeshProUGUI nameLabel;
|
||||
[SerializeField] private TextMeshProUGUI personalityLabel;
|
||||
[SerializeField] private Slider hpBar;
|
||||
[SerializeField] private Slider energyBar;
|
||||
[SerializeField] private Image hpFill;
|
||||
[SerializeField] private Image energyFill;
|
||||
|
||||
[Header("Speech Bubble")]
|
||||
[SerializeField] private GameObject speechBubble;
|
||||
[SerializeField] private TextMeshProUGUI speechText;
|
||||
[SerializeField] private float speechDuration = 5f;
|
||||
|
||||
[Header("Visual Feedback")]
|
||||
[SerializeField] private Renderer characterRenderer;
|
||||
[SerializeField] private Color aliveColor = Color.white;
|
||||
[SerializeField] private Color deadColor = new Color(0.3f, 0.3f, 0.3f, 1f);
|
||||
[SerializeField] private GameObject deathOverlay;
|
||||
|
||||
[Header("Animation")]
|
||||
[SerializeField] private Animator animator;
|
||||
#endregion
|
||||
|
||||
#region Private Fields
|
||||
private int _agentId;
|
||||
private AgentData _currentData;
|
||||
private Coroutine _speechCoroutine;
|
||||
private Material _characterMaterial;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
public int AgentId => _agentId;
|
||||
public AgentData CurrentData => _currentData;
|
||||
public bool IsAlive => _currentData?.IsAlive ?? false;
|
||||
#endregion
|
||||
|
||||
#region Initialization
|
||||
private void Awake()
|
||||
{
|
||||
// Cache material for color changes
|
||||
if (characterRenderer != null)
|
||||
{
|
||||
_characterMaterial = characterRenderer.material;
|
||||
}
|
||||
|
||||
// Hide speech bubble initially
|
||||
if (speechBubble != null)
|
||||
{
|
||||
speechBubble.SetActive(false);
|
||||
}
|
||||
|
||||
// Hide death overlay initially
|
||||
if (deathOverlay != null)
|
||||
{
|
||||
deathOverlay.SetActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the agent with server data.
|
||||
/// Called once when the agent is first spawned.
|
||||
/// </summary>
|
||||
public void Initialize(AgentData data)
|
||||
{
|
||||
_agentId = data.id;
|
||||
_currentData = data;
|
||||
|
||||
// Set static labels
|
||||
if (nameLabel != null)
|
||||
{
|
||||
nameLabel.text = data.name;
|
||||
}
|
||||
|
||||
if (personalityLabel != null)
|
||||
{
|
||||
personalityLabel.text = data.personality;
|
||||
}
|
||||
|
||||
// Set game object name for debugging
|
||||
gameObject.name = $"Agent_{data.id}_{data.name}";
|
||||
|
||||
// Apply initial stats
|
||||
UpdateStats(data);
|
||||
|
||||
Debug.Log($"[AgentController] Initialized {data.name} (ID: {data.id})");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Stats Update
|
||||
/// <summary>
|
||||
/// Update the agent's visual state based on server data.
|
||||
/// </summary>
|
||||
public void UpdateStats(AgentData data)
|
||||
{
|
||||
_currentData = data;
|
||||
|
||||
// Update HP bar
|
||||
if (hpBar != null)
|
||||
{
|
||||
hpBar.value = data.hp / 100f;
|
||||
UpdateBarColor(hpFill, data.hp);
|
||||
}
|
||||
|
||||
// Update Energy bar
|
||||
if (energyBar != null)
|
||||
{
|
||||
energyBar.value = data.energy / 100f;
|
||||
UpdateBarColor(energyFill, data.energy, isEnergy: true);
|
||||
}
|
||||
|
||||
// Handle death state
|
||||
if (!data.IsAlive)
|
||||
{
|
||||
OnDeath();
|
||||
}
|
||||
else
|
||||
{
|
||||
OnAlive();
|
||||
}
|
||||
|
||||
// Trigger animation based on energy level
|
||||
UpdateAnimation();
|
||||
}
|
||||
|
||||
private void UpdateBarColor(Image fillImage, int value, bool isEnergy = false)
|
||||
{
|
||||
if (fillImage == null) return;
|
||||
|
||||
if (isEnergy)
|
||||
{
|
||||
// Energy: Yellow to Orange gradient
|
||||
fillImage.color = Color.Lerp(
|
||||
new Color(1f, 0.5f, 0f), // Orange (low)
|
||||
new Color(1f, 0.8f, 0f), // Yellow (high)
|
||||
value / 100f
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
// HP: Red to Green gradient
|
||||
fillImage.color = Color.Lerp(
|
||||
new Color(1f, 0.2f, 0.2f), // Red (low)
|
||||
new Color(0.2f, 1f, 0.2f), // Green (high)
|
||||
value / 100f
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateAnimation()
|
||||
{
|
||||
if (animator == null) return;
|
||||
|
||||
if (_currentData == null) return;
|
||||
|
||||
// Set animator parameters based on state
|
||||
animator.SetBool("IsAlive", _currentData.IsAlive);
|
||||
animator.SetFloat("Energy", _currentData.energy / 100f);
|
||||
animator.SetFloat("HP", _currentData.hp / 100f);
|
||||
|
||||
// Trigger low energy animation if starving
|
||||
if (_currentData.energy <= 20 && _currentData.IsAlive)
|
||||
{
|
||||
animator.SetBool("IsStarving", true);
|
||||
}
|
||||
else
|
||||
{
|
||||
animator.SetBool("IsStarving", false);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Death Handling
|
||||
private void OnDeath()
|
||||
{
|
||||
Debug.Log($"[AgentController] {_currentData.name} has died!");
|
||||
|
||||
// Change character color to gray
|
||||
if (_characterMaterial != null)
|
||||
{
|
||||
_characterMaterial.color = deadColor;
|
||||
}
|
||||
|
||||
// Show death overlay
|
||||
if (deathOverlay != null)
|
||||
{
|
||||
deathOverlay.SetActive(true);
|
||||
}
|
||||
|
||||
// Trigger death animation
|
||||
if (animator != null)
|
||||
{
|
||||
animator.SetTrigger("Die");
|
||||
}
|
||||
|
||||
// Hide speech bubble
|
||||
HideSpeech();
|
||||
}
|
||||
|
||||
private void OnAlive()
|
||||
{
|
||||
// Restore character color
|
||||
if (_characterMaterial != null)
|
||||
{
|
||||
_characterMaterial.color = aliveColor;
|
||||
}
|
||||
|
||||
// Hide death overlay
|
||||
if (deathOverlay != null)
|
||||
{
|
||||
deathOverlay.SetActive(false);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Speech Bubble
|
||||
/// <summary>
|
||||
/// Show speech bubble with text from LLM.
|
||||
/// Auto-hides after speechDuration seconds.
|
||||
/// </summary>
|
||||
public void ShowSpeech(string text)
|
||||
{
|
||||
if (speechBubble == null || speechText == null)
|
||||
{
|
||||
Debug.LogWarning($"[AgentController] Speech bubble not configured for {_currentData?.name}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show speech for dead agents
|
||||
if (!IsAlive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop existing speech coroutine if any
|
||||
if (_speechCoroutine != null)
|
||||
{
|
||||
StopCoroutine(_speechCoroutine);
|
||||
}
|
||||
|
||||
// Set text and show bubble
|
||||
speechText.text = text;
|
||||
speechBubble.SetActive(true);
|
||||
|
||||
// Start auto-hide coroutine
|
||||
_speechCoroutine = StartCoroutine(HideSpeechAfterDelay());
|
||||
|
||||
Debug.Log($"[AgentController] {_currentData?.name} says: \"{text}\"");
|
||||
}
|
||||
|
||||
private IEnumerator HideSpeechAfterDelay()
|
||||
{
|
||||
yield return new WaitForSeconds(speechDuration);
|
||||
HideSpeech();
|
||||
}
|
||||
|
||||
public void HideSpeech()
|
||||
{
|
||||
if (speechBubble != null)
|
||||
{
|
||||
speechBubble.SetActive(false);
|
||||
}
|
||||
|
||||
if (_speechCoroutine != null)
|
||||
{
|
||||
StopCoroutine(_speechCoroutine);
|
||||
_speechCoroutine = null;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Interaction
|
||||
/// <summary>
|
||||
/// Called when player clicks/taps on this agent.
|
||||
/// </summary>
|
||||
public void OnClick()
|
||||
{
|
||||
if (!IsAlive)
|
||||
{
|
||||
Debug.Log($"[AgentController] Cannot interact with dead agent: {_currentData?.name}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Feed the agent
|
||||
NetworkManager.Instance.FeedAgent(_currentData.name);
|
||||
}
|
||||
|
||||
private void OnMouseDown()
|
||||
{
|
||||
OnClick();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Cleanup
|
||||
private void OnDestroy()
|
||||
{
|
||||
// Clean up material instance
|
||||
if (_characterMaterial != null)
|
||||
{
|
||||
Destroy(_characterMaterial);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
2
unity-client/Assets/Scripts/AgentController.cs.meta
Normal file
2
unity-client/Assets/Scripts/AgentController.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0a8e42c6083a64b87be1c87a91eb00a8
|
||||
412
unity-client/Assets/Scripts/AgentUI.cs
Normal file
412
unity-client/Assets/Scripts/AgentUI.cs
Normal file
@@ -0,0 +1,412 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using TheIsland.Models;
|
||||
using TheIsland.Network;
|
||||
|
||||
namespace TheIsland.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates and manages floating UI above an agent (name, HP/Energy bars, speech bubble).
|
||||
/// Attach this to the Agent prefab - it will create all UI elements automatically.
|
||||
/// </summary>
|
||||
public class AgentUI : MonoBehaviour
|
||||
{
|
||||
#region Configuration
|
||||
[Header("UI Settings")]
|
||||
[SerializeField] private Vector3 uiOffset = new Vector3(0, 2.5f, 0);
|
||||
[SerializeField] private float uiScale = 0.01f;
|
||||
[SerializeField] private float speechDuration = 5f;
|
||||
|
||||
[Header("Colors")]
|
||||
[SerializeField] private Color hpHighColor = new Color(0.3f, 0.9f, 0.3f);
|
||||
[SerializeField] private Color hpLowColor = new Color(0.9f, 0.3f, 0.3f);
|
||||
[SerializeField] private Color energyHighColor = new Color(1f, 0.8f, 0.2f);
|
||||
[SerializeField] private Color energyLowColor = new Color(1f, 0.5f, 0.1f);
|
||||
#endregion
|
||||
|
||||
#region UI References
|
||||
private Canvas _canvas;
|
||||
private TextMeshProUGUI _nameLabel;
|
||||
private TextMeshProUGUI _personalityLabel;
|
||||
private Image _hpBarFill;
|
||||
private Image _energyBarFill;
|
||||
private TextMeshProUGUI _hpText;
|
||||
private TextMeshProUGUI _energyText;
|
||||
private GameObject _speechBubble;
|
||||
private TextMeshProUGUI _speechText;
|
||||
private GameObject _deathOverlay;
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
private int _agentId;
|
||||
private AgentData _currentData;
|
||||
private Coroutine _speechCoroutine;
|
||||
private Camera _mainCamera;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
public int AgentId => _agentId;
|
||||
public AgentData CurrentData => _currentData;
|
||||
public bool IsAlive => _currentData?.IsAlive ?? false;
|
||||
#endregion
|
||||
|
||||
#region Initialization
|
||||
private void Awake()
|
||||
{
|
||||
_mainCamera = Camera.main;
|
||||
CreateUI();
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
// Make UI face the camera (billboard effect)
|
||||
if (_canvas != null && _mainCamera != null)
|
||||
{
|
||||
_canvas.transform.LookAt(
|
||||
_canvas.transform.position + _mainCamera.transform.rotation * Vector3.forward,
|
||||
_mainCamera.transform.rotation * Vector3.up
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public void Initialize(AgentData data)
|
||||
{
|
||||
_agentId = data.id;
|
||||
_currentData = data;
|
||||
gameObject.name = $"Agent_{data.id}_{data.name}";
|
||||
|
||||
// Set name and personality
|
||||
if (_nameLabel != null) _nameLabel.text = data.name;
|
||||
if (_personalityLabel != null) _personalityLabel.text = $"({data.personality})";
|
||||
|
||||
UpdateStats(data);
|
||||
Debug.Log($"[AgentUI] Initialized {data.name}");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region UI Creation
|
||||
private void CreateUI()
|
||||
{
|
||||
// Create World Space Canvas
|
||||
var canvasObj = new GameObject("AgentCanvas");
|
||||
canvasObj.transform.SetParent(transform);
|
||||
canvasObj.transform.localPosition = uiOffset;
|
||||
canvasObj.transform.localScale = Vector3.one * uiScale;
|
||||
|
||||
_canvas = canvasObj.AddComponent<Canvas>();
|
||||
_canvas.renderMode = RenderMode.WorldSpace;
|
||||
_canvas.sortingOrder = 10;
|
||||
|
||||
var canvasRect = canvasObj.GetComponent<RectTransform>();
|
||||
canvasRect.sizeDelta = new Vector2(300, 200);
|
||||
|
||||
// Create UI Panel
|
||||
var panel = CreatePanel(canvasObj.transform, "UIPanel", new Vector2(300, 150));
|
||||
|
||||
// Name Label
|
||||
_nameLabel = CreateText(panel, "NameLabel", "Agent", 32, Color.white, FontStyles.Bold);
|
||||
SetRectPosition(_nameLabel.rectTransform, 0, 60, 280, 40);
|
||||
|
||||
// Personality Label
|
||||
_personalityLabel = CreateText(panel, "PersonalityLabel", "(personality)", 18,
|
||||
new Color(0.7f, 0.7f, 0.7f), FontStyles.Italic);
|
||||
SetRectPosition(_personalityLabel.rectTransform, 0, 30, 280, 25);
|
||||
|
||||
// HP Bar
|
||||
var hpBar = CreateBar(panel, "HPBar", "HP", hpHighColor, out _hpBarFill, out _hpText);
|
||||
SetRectPosition(hpBar, 0, -5, 250, 22);
|
||||
|
||||
// Energy Bar
|
||||
var energyBar = CreateBar(panel, "EnergyBar", "Energy", energyHighColor, out _energyBarFill, out _energyText);
|
||||
SetRectPosition(energyBar, 0, -35, 250, 22);
|
||||
|
||||
// Death Overlay
|
||||
_deathOverlay = CreateDeathOverlay(panel);
|
||||
|
||||
// Speech Bubble (positioned above the main UI)
|
||||
_speechBubble = CreateSpeechBubble(canvasObj.transform);
|
||||
_speechBubble.SetActive(false);
|
||||
|
||||
// Add collider for click detection if not present
|
||||
if (GetComponent<Collider>() == null)
|
||||
{
|
||||
var col = gameObject.AddComponent<CapsuleCollider>();
|
||||
col.height = 2f;
|
||||
col.radius = 0.5f;
|
||||
col.center = new Vector3(0, 1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private GameObject CreatePanel(Transform parent, string name, Vector2 size)
|
||||
{
|
||||
var panel = new GameObject(name);
|
||||
panel.transform.SetParent(parent);
|
||||
panel.transform.localPosition = Vector3.zero;
|
||||
panel.transform.localRotation = Quaternion.identity;
|
||||
panel.transform.localScale = Vector3.one;
|
||||
|
||||
var rect = panel.AddComponent<RectTransform>();
|
||||
rect.sizeDelta = size;
|
||||
rect.anchoredPosition = Vector2.zero;
|
||||
|
||||
// Semi-transparent background
|
||||
var bg = panel.AddComponent<Image>();
|
||||
bg.color = new Color(0, 0, 0, 0.5f);
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
private TextMeshProUGUI CreateText(GameObject parent, string name, string text,
|
||||
float fontSize, Color color, FontStyles style = FontStyles.Normal)
|
||||
{
|
||||
var textObj = new GameObject(name);
|
||||
textObj.transform.SetParent(parent.transform);
|
||||
textObj.transform.localPosition = Vector3.zero;
|
||||
textObj.transform.localRotation = Quaternion.identity;
|
||||
textObj.transform.localScale = Vector3.one;
|
||||
|
||||
var tmp = textObj.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = text;
|
||||
tmp.fontSize = fontSize;
|
||||
tmp.color = color;
|
||||
tmp.fontStyle = style;
|
||||
tmp.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
return tmp;
|
||||
}
|
||||
|
||||
private RectTransform CreateBar(GameObject parent, string name, string label,
|
||||
Color fillColor, out Image fillImage, out TextMeshProUGUI valueText)
|
||||
{
|
||||
var barContainer = new GameObject(name);
|
||||
barContainer.transform.SetParent(parent.transform);
|
||||
barContainer.transform.localPosition = Vector3.zero;
|
||||
barContainer.transform.localRotation = Quaternion.identity;
|
||||
barContainer.transform.localScale = Vector3.one;
|
||||
|
||||
var containerRect = barContainer.AddComponent<RectTransform>();
|
||||
|
||||
// Background
|
||||
var bg = new GameObject("Background");
|
||||
bg.transform.SetParent(barContainer.transform);
|
||||
var bgImg = bg.AddComponent<Image>();
|
||||
bgImg.color = new Color(0.2f, 0.2f, 0.2f, 0.8f);
|
||||
var bgRect = bg.GetComponent<RectTransform>();
|
||||
bgRect.anchorMin = Vector2.zero;
|
||||
bgRect.anchorMax = Vector2.one;
|
||||
bgRect.offsetMin = Vector2.zero;
|
||||
bgRect.offsetMax = Vector2.zero;
|
||||
|
||||
// Fill
|
||||
var fill = new GameObject("Fill");
|
||||
fill.transform.SetParent(barContainer.transform);
|
||||
fillImage = fill.AddComponent<Image>();
|
||||
fillImage.color = fillColor;
|
||||
var fillRect = fill.GetComponent<RectTransform>();
|
||||
fillRect.anchorMin = Vector2.zero;
|
||||
fillRect.anchorMax = new Vector2(1, 1);
|
||||
fillRect.pivot = new Vector2(0, 0.5f);
|
||||
fillRect.offsetMin = new Vector2(2, 2);
|
||||
fillRect.offsetMax = new Vector2(-2, -2);
|
||||
|
||||
// Label + Value Text
|
||||
valueText = CreateText(barContainer, "Text", $"{label}: 100", 14, Color.white);
|
||||
var textRect = valueText.rectTransform;
|
||||
textRect.anchorMin = Vector2.zero;
|
||||
textRect.anchorMax = Vector2.one;
|
||||
textRect.offsetMin = Vector2.zero;
|
||||
textRect.offsetMax = Vector2.zero;
|
||||
|
||||
return containerRect;
|
||||
}
|
||||
|
||||
private GameObject CreateDeathOverlay(GameObject parent)
|
||||
{
|
||||
var overlay = new GameObject("DeathOverlay");
|
||||
overlay.transform.SetParent(parent.transform);
|
||||
overlay.transform.localPosition = Vector3.zero;
|
||||
overlay.transform.localRotation = Quaternion.identity;
|
||||
overlay.transform.localScale = Vector3.one;
|
||||
|
||||
var rect = overlay.AddComponent<RectTransform>();
|
||||
rect.anchorMin = Vector2.zero;
|
||||
rect.anchorMax = Vector2.one;
|
||||
rect.offsetMin = Vector2.zero;
|
||||
rect.offsetMax = Vector2.zero;
|
||||
|
||||
var img = overlay.AddComponent<Image>();
|
||||
img.color = new Color(0.3f, 0f, 0f, 0.7f);
|
||||
|
||||
var deathText = CreateText(overlay, "DeathText", "DEAD", 28, Color.red, FontStyles.Bold);
|
||||
deathText.rectTransform.anchorMin = Vector2.zero;
|
||||
deathText.rectTransform.anchorMax = Vector2.one;
|
||||
deathText.rectTransform.offsetMin = Vector2.zero;
|
||||
deathText.rectTransform.offsetMax = Vector2.zero;
|
||||
|
||||
overlay.SetActive(false);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
private GameObject CreateSpeechBubble(Transform parent)
|
||||
{
|
||||
var bubble = new GameObject("SpeechBubble");
|
||||
bubble.transform.SetParent(parent);
|
||||
bubble.transform.localPosition = new Vector3(0, 100, 0); // Above main UI
|
||||
bubble.transform.localRotation = Quaternion.identity;
|
||||
bubble.transform.localScale = Vector3.one;
|
||||
|
||||
var rect = bubble.AddComponent<RectTransform>();
|
||||
rect.sizeDelta = new Vector2(350, 80);
|
||||
|
||||
// Bubble background
|
||||
var bg = bubble.AddComponent<Image>();
|
||||
bg.color = new Color(1f, 1f, 1f, 0.95f);
|
||||
|
||||
// Speech text
|
||||
_speechText = CreateText(bubble, "SpeechText", "", 20, new Color(0.2f, 0.2f, 0.2f));
|
||||
_speechText.alignment = TextAlignmentOptions.Center;
|
||||
_speechText.textWrappingMode = TextWrappingModes.Normal;
|
||||
var textRect = _speechText.rectTransform;
|
||||
textRect.anchorMin = Vector2.zero;
|
||||
textRect.anchorMax = Vector2.one;
|
||||
textRect.offsetMin = new Vector2(15, 10);
|
||||
textRect.offsetMax = new Vector2(-15, -10);
|
||||
|
||||
// Bubble tail (triangle pointing down)
|
||||
var tail = new GameObject("Tail");
|
||||
tail.transform.SetParent(bubble.transform);
|
||||
var tailRect = tail.AddComponent<RectTransform>();
|
||||
tailRect.anchoredPosition = new Vector2(0, -35);
|
||||
tailRect.sizeDelta = new Vector2(20, 15);
|
||||
|
||||
return bubble;
|
||||
}
|
||||
|
||||
private void SetRectPosition(RectTransform rect, float x, float y, float width, float height)
|
||||
{
|
||||
rect.anchoredPosition = new Vector2(x, y);
|
||||
rect.sizeDelta = new Vector2(width, height);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Stats Update
|
||||
public void UpdateStats(AgentData data)
|
||||
{
|
||||
_currentData = data;
|
||||
|
||||
// Update HP
|
||||
float hpPercent = data.hp / 100f;
|
||||
if (_hpBarFill != null)
|
||||
{
|
||||
_hpBarFill.rectTransform.anchorMax = new Vector2(hpPercent, 1);
|
||||
_hpBarFill.color = Color.Lerp(hpLowColor, hpHighColor, hpPercent);
|
||||
}
|
||||
if (_hpText != null)
|
||||
{
|
||||
_hpText.text = $"HP: {data.hp}";
|
||||
}
|
||||
|
||||
// Update Energy
|
||||
float energyPercent = data.energy / 100f;
|
||||
if (_energyBarFill != null)
|
||||
{
|
||||
_energyBarFill.rectTransform.anchorMax = new Vector2(energyPercent, 1);
|
||||
_energyBarFill.color = Color.Lerp(energyLowColor, energyHighColor, energyPercent);
|
||||
}
|
||||
if (_energyText != null)
|
||||
{
|
||||
_energyText.text = $"Energy: {data.energy}";
|
||||
}
|
||||
|
||||
// Update death state
|
||||
if (!data.IsAlive)
|
||||
{
|
||||
OnDeath();
|
||||
}
|
||||
else
|
||||
{
|
||||
OnAlive();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDeath()
|
||||
{
|
||||
if (_deathOverlay != null) _deathOverlay.SetActive(true);
|
||||
HideSpeech();
|
||||
|
||||
// Gray out the character
|
||||
var renderers = GetComponentsInChildren<Renderer>();
|
||||
foreach (var r in renderers)
|
||||
{
|
||||
if (r.material != null)
|
||||
{
|
||||
r.material.color = new Color(0.3f, 0.3f, 0.3f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAlive()
|
||||
{
|
||||
if (_deathOverlay != null) _deathOverlay.SetActive(false);
|
||||
|
||||
// Restore color
|
||||
var renderers = GetComponentsInChildren<Renderer>();
|
||||
foreach (var r in renderers)
|
||||
{
|
||||
if (r.material != null)
|
||||
{
|
||||
r.material.color = Color.white;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Speech Bubble
|
||||
public void ShowSpeech(string text)
|
||||
{
|
||||
if (_speechBubble == null || !IsAlive) return;
|
||||
|
||||
if (_speechCoroutine != null)
|
||||
{
|
||||
StopCoroutine(_speechCoroutine);
|
||||
}
|
||||
|
||||
_speechText.text = text;
|
||||
_speechBubble.SetActive(true);
|
||||
|
||||
_speechCoroutine = StartCoroutine(HideSpeechAfterDelay());
|
||||
Debug.Log($"[AgentUI] {_currentData?.name} says: \"{text}\"");
|
||||
}
|
||||
|
||||
private IEnumerator HideSpeechAfterDelay()
|
||||
{
|
||||
yield return new WaitForSeconds(speechDuration);
|
||||
HideSpeech();
|
||||
}
|
||||
|
||||
public void HideSpeech()
|
||||
{
|
||||
if (_speechBubble != null)
|
||||
{
|
||||
_speechBubble.SetActive(false);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Interaction
|
||||
private void OnMouseDown()
|
||||
{
|
||||
if (!IsAlive)
|
||||
{
|
||||
Debug.Log($"[AgentUI] Cannot feed dead agent: {_currentData?.name}");
|
||||
return;
|
||||
}
|
||||
|
||||
NetworkManager.Instance.FeedAgent(_currentData.name);
|
||||
Debug.Log($"[AgentUI] Clicked to feed: {_currentData?.name}");
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
2
unity-client/Assets/Scripts/AgentUI.cs.meta
Normal file
2
unity-client/Assets/Scripts/AgentUI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6c640bb4732a04c21a655d0de775bd29
|
||||
599
unity-client/Assets/Scripts/AgentVisual.cs
Normal file
599
unity-client/Assets/Scripts/AgentVisual.cs
Normal file
@@ -0,0 +1,599 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using TheIsland.Models;
|
||||
using TheIsland.Network;
|
||||
|
||||
namespace TheIsland.Visual
|
||||
{
|
||||
/// <summary>
|
||||
/// Complete 2.5D Agent visual system.
|
||||
/// Creates sprite, floating UI, and speech bubble programmatically.
|
||||
/// Attach to an empty GameObject to create a full agent visual.
|
||||
/// </summary>
|
||||
public class AgentVisual : MonoBehaviour
|
||||
{
|
||||
#region Configuration
|
||||
[Header("Sprite Settings")]
|
||||
[Tooltip("Assign a sprite, or leave empty for auto-generated placeholder")]
|
||||
[SerializeField] private Sprite characterSprite;
|
||||
[SerializeField] private Color spriteColor = Color.white;
|
||||
[SerializeField] private float spriteScale = 2f;
|
||||
[SerializeField] private int sortingOrder = 10;
|
||||
|
||||
[Header("Placeholder Colors (if no sprite assigned)")]
|
||||
[SerializeField] private Color placeholderBodyColor = new Color(0.3f, 0.6f, 0.9f);
|
||||
[SerializeField] private Color placeholderOutlineColor = new Color(0.2f, 0.4f, 0.7f);
|
||||
|
||||
[Header("UI Settings")]
|
||||
[SerializeField] private Vector3 uiOffset = new Vector3(0, 2.2f, 0);
|
||||
[SerializeField] private float uiScale = 0.008f;
|
||||
|
||||
[Header("Speech Bubble")]
|
||||
[SerializeField] private float speechDuration = 5f;
|
||||
[SerializeField] private Vector3 speechOffset = new Vector3(0, 3.5f, 0);
|
||||
|
||||
[Header("Colors")]
|
||||
[SerializeField] private Color hpHighColor = new Color(0.3f, 0.9f, 0.3f);
|
||||
[SerializeField] private Color hpLowColor = new Color(0.9f, 0.3f, 0.3f);
|
||||
[SerializeField] private Color energyHighColor = new Color(1f, 0.8f, 0.2f);
|
||||
[SerializeField] private Color energyLowColor = new Color(1f, 0.5f, 0.1f);
|
||||
#endregion
|
||||
|
||||
#region References
|
||||
private SpriteRenderer _spriteRenderer;
|
||||
private Canvas _uiCanvas;
|
||||
private TextMeshProUGUI _nameLabel;
|
||||
private TextMeshProUGUI _personalityLabel;
|
||||
private Image _hpBarFill;
|
||||
private Image _energyBarFill;
|
||||
private TextMeshProUGUI _hpText;
|
||||
private TextMeshProUGUI _energyText;
|
||||
private GameObject _deathOverlay;
|
||||
private SpeechBubble _speechBubble;
|
||||
private Billboard _spriteBillboard;
|
||||
private Billboard _uiBillboard;
|
||||
private Camera _mainCamera;
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
private int _agentId;
|
||||
private AgentData _currentData;
|
||||
private Coroutine _speechCoroutine;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
public int AgentId => _agentId;
|
||||
public AgentData CurrentData => _currentData;
|
||||
public bool IsAlive => _currentData?.IsAlive ?? false;
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
private void Awake()
|
||||
{
|
||||
_mainCamera = Camera.main;
|
||||
CreateVisuals();
|
||||
}
|
||||
|
||||
private void OnMouseDown()
|
||||
{
|
||||
if (!IsAlive)
|
||||
{
|
||||
Debug.Log($"[AgentVisual] Cannot interact with dead agent: {_currentData?.name}");
|
||||
return;
|
||||
}
|
||||
|
||||
NetworkManager.Instance?.FeedAgent(_currentData.name);
|
||||
Debug.Log($"[AgentVisual] Clicked to feed: {_currentData?.name}");
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Initialization
|
||||
public void Initialize(AgentData data)
|
||||
{
|
||||
_agentId = data.id;
|
||||
_currentData = data;
|
||||
gameObject.name = $"Agent_{data.id}_{data.name}";
|
||||
|
||||
// Apply unique color based on agent ID
|
||||
ApplyAgentColor(data.id);
|
||||
|
||||
// Set UI text
|
||||
if (_nameLabel != null) _nameLabel.text = data.name;
|
||||
if (_personalityLabel != null) _personalityLabel.text = $"({data.personality})";
|
||||
|
||||
UpdateStats(data);
|
||||
Debug.Log($"[AgentVisual] Initialized: {data.name}");
|
||||
}
|
||||
|
||||
private void ApplyAgentColor(int agentId)
|
||||
{
|
||||
// Generate unique color per agent
|
||||
Color[] agentColors = new Color[]
|
||||
{
|
||||
new Color(0.3f, 0.6f, 0.9f), // Blue (Jack)
|
||||
new Color(0.9f, 0.5f, 0.7f), // Pink (Luna)
|
||||
new Color(0.5f, 0.8f, 0.5f), // Green (Bob)
|
||||
new Color(0.9f, 0.7f, 0.3f), // Orange
|
||||
new Color(0.7f, 0.5f, 0.9f), // Purple
|
||||
};
|
||||
|
||||
int colorIndex = agentId % agentColors.Length;
|
||||
placeholderBodyColor = agentColors[colorIndex];
|
||||
placeholderOutlineColor = agentColors[colorIndex] * 0.7f;
|
||||
|
||||
// Update sprite color if using placeholder
|
||||
if (_spriteRenderer != null && characterSprite == null)
|
||||
{
|
||||
RegeneratePlaceholderSprite();
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Visual Creation
|
||||
private void CreateVisuals()
|
||||
{
|
||||
CreateSprite();
|
||||
CreateUICanvas();
|
||||
CreateSpeechBubble();
|
||||
CreateCollider();
|
||||
}
|
||||
|
||||
private void CreateSprite()
|
||||
{
|
||||
// Create sprite child object
|
||||
var spriteObj = new GameObject("CharacterSprite");
|
||||
spriteObj.transform.SetParent(transform);
|
||||
spriteObj.transform.localPosition = new Vector3(0, 1f, 0);
|
||||
spriteObj.transform.localScale = Vector3.one * spriteScale;
|
||||
|
||||
_spriteRenderer = spriteObj.AddComponent<SpriteRenderer>();
|
||||
_spriteRenderer.sortingOrder = sortingOrder;
|
||||
|
||||
if (characterSprite != null)
|
||||
{
|
||||
_spriteRenderer.sprite = characterSprite;
|
||||
_spriteRenderer.color = spriteColor;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Generate placeholder sprite
|
||||
RegeneratePlaceholderSprite();
|
||||
}
|
||||
|
||||
// Add billboard
|
||||
_spriteBillboard = spriteObj.AddComponent<Billboard>();
|
||||
}
|
||||
|
||||
private void RegeneratePlaceholderSprite()
|
||||
{
|
||||
if (_spriteRenderer == null) return;
|
||||
|
||||
// Create a simple character placeholder (circle with body shape)
|
||||
Texture2D texture = CreatePlaceholderTexture(64, 64);
|
||||
_spriteRenderer.sprite = Sprite.Create(
|
||||
texture,
|
||||
new Rect(0, 0, texture.width, texture.height),
|
||||
new Vector2(0.5f, 0.5f),
|
||||
100f
|
||||
);
|
||||
}
|
||||
|
||||
private Texture2D CreatePlaceholderTexture(int width, int height)
|
||||
{
|
||||
Texture2D texture = new Texture2D(width, height, TextureFormat.RGBA32, false);
|
||||
texture.filterMode = FilterMode.Point;
|
||||
|
||||
// Clear to transparent
|
||||
Color[] pixels = new Color[width * height];
|
||||
for (int i = 0; i < pixels.Length; i++)
|
||||
{
|
||||
pixels[i] = Color.clear;
|
||||
}
|
||||
|
||||
// Draw simple character shape
|
||||
Vector2 center = new Vector2(width / 2f, height / 2f);
|
||||
|
||||
// Body (ellipse)
|
||||
DrawEllipse(pixels, width, height, center + Vector2.down * 8, 14, 20, placeholderBodyColor);
|
||||
|
||||
// Head (circle)
|
||||
DrawCircle(pixels, width, height, center + Vector2.up * 12, 12, placeholderBodyColor);
|
||||
|
||||
// Outline
|
||||
DrawCircleOutline(pixels, width, height, center + Vector2.up * 12, 12, placeholderOutlineColor, 2);
|
||||
DrawEllipseOutline(pixels, width, height, center + Vector2.down * 8, 14, 20, placeholderOutlineColor, 2);
|
||||
|
||||
// Eyes
|
||||
DrawCircle(pixels, width, height, center + new Vector2(-4, 14), 2, Color.white);
|
||||
DrawCircle(pixels, width, height, center + new Vector2(4, 14), 2, Color.white);
|
||||
DrawCircle(pixels, width, height, center + new Vector2(-4, 14), 1, Color.black);
|
||||
DrawCircle(pixels, width, height, center + new Vector2(4, 14), 1, Color.black);
|
||||
|
||||
texture.SetPixels(pixels);
|
||||
texture.Apply();
|
||||
return texture;
|
||||
}
|
||||
|
||||
private void DrawCircle(Color[] pixels, int width, int height, Vector2 center, float radius, Color color)
|
||||
{
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
float dist = Vector2.Distance(new Vector2(x, y), center);
|
||||
if (dist <= radius)
|
||||
{
|
||||
pixels[y * width + x] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawCircleOutline(Color[] pixels, int width, int height, Vector2 center, float radius, Color color, int thickness)
|
||||
{
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
float dist = Vector2.Distance(new Vector2(x, y), center);
|
||||
if (dist >= radius - thickness && dist <= radius + thickness)
|
||||
{
|
||||
pixels[y * width + x] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawEllipse(Color[] pixels, int width, int height, Vector2 center, float rx, float ry, Color color)
|
||||
{
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
float dx = (x - center.x) / rx;
|
||||
float dy = (y - center.y) / ry;
|
||||
if (dx * dx + dy * dy <= 1)
|
||||
{
|
||||
pixels[y * width + x] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawEllipseOutline(Color[] pixels, int width, int height, Vector2 center, float rx, float ry, Color color, int thickness)
|
||||
{
|
||||
for (int y = 0; y < height; y++)
|
||||
{
|
||||
for (int x = 0; x < width; x++)
|
||||
{
|
||||
float dx = (x - center.x) / rx;
|
||||
float dy = (y - center.y) / ry;
|
||||
float dist = dx * dx + dy * dy;
|
||||
float outer = 1 + (thickness / Mathf.Min(rx, ry));
|
||||
float inner = 1 - (thickness / Mathf.Min(rx, ry));
|
||||
if (dist >= inner && dist <= outer)
|
||||
{
|
||||
pixels[y * width + x] = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateUICanvas()
|
||||
{
|
||||
// World Space Canvas
|
||||
var canvasObj = new GameObject("UICanvas");
|
||||
canvasObj.transform.SetParent(transform);
|
||||
canvasObj.transform.localPosition = uiOffset;
|
||||
canvasObj.transform.localScale = Vector3.one * uiScale;
|
||||
|
||||
_uiCanvas = canvasObj.AddComponent<Canvas>();
|
||||
_uiCanvas.renderMode = RenderMode.WorldSpace;
|
||||
_uiCanvas.sortingOrder = sortingOrder + 1;
|
||||
|
||||
var canvasRect = canvasObj.GetComponent<RectTransform>();
|
||||
canvasRect.sizeDelta = new Vector2(400, 150);
|
||||
|
||||
// Add billboard to canvas (configured for UI - full facing)
|
||||
_uiBillboard = canvasObj.AddComponent<Billboard>();
|
||||
_uiBillboard.ConfigureForUI();
|
||||
|
||||
// Create UI panel
|
||||
var panel = CreateUIPanel(canvasObj.transform, new Vector2(350, 120));
|
||||
|
||||
// Name label
|
||||
_nameLabel = CreateUIText(panel.transform, "NameLabel", "Agent", 36, Color.white, FontStyles.Bold);
|
||||
SetRectPosition(_nameLabel.rectTransform, 0, 45, 320, 45);
|
||||
|
||||
// Personality label
|
||||
_personalityLabel = CreateUIText(panel.transform, "PersonalityLabel", "(Personality)", 20,
|
||||
new Color(0.8f, 0.8f, 0.8f), FontStyles.Italic);
|
||||
SetRectPosition(_personalityLabel.rectTransform, 0, 15, 320, 25);
|
||||
|
||||
// HP Bar
|
||||
var hpBar = CreateProgressBar(panel.transform, "HPBar", "HP", hpHighColor, out _hpBarFill, out _hpText);
|
||||
SetRectPosition(hpBar, 0, -15, 280, 24);
|
||||
|
||||
// Energy Bar
|
||||
var energyBar = CreateProgressBar(panel.transform, "EnergyBar", "Energy", energyHighColor, out _energyBarFill, out _energyText);
|
||||
SetRectPosition(energyBar, 0, -45, 280, 24);
|
||||
|
||||
// Death overlay
|
||||
_deathOverlay = CreateDeathOverlay(panel.transform);
|
||||
_deathOverlay.SetActive(false);
|
||||
}
|
||||
|
||||
private GameObject CreateUIPanel(Transform parent, Vector2 size)
|
||||
{
|
||||
var panel = new GameObject("Panel");
|
||||
panel.transform.SetParent(parent);
|
||||
panel.transform.localPosition = Vector3.zero;
|
||||
panel.transform.localRotation = Quaternion.identity;
|
||||
panel.transform.localScale = Vector3.one;
|
||||
|
||||
var rect = panel.AddComponent<RectTransform>();
|
||||
rect.sizeDelta = size;
|
||||
rect.anchoredPosition = Vector2.zero;
|
||||
|
||||
var bg = panel.AddComponent<Image>();
|
||||
bg.color = new Color(0, 0, 0, 0.6f);
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
private TextMeshProUGUI CreateUIText(Transform parent, string name, string text,
|
||||
float fontSize, Color color, FontStyles style = FontStyles.Normal)
|
||||
{
|
||||
var textObj = new GameObject(name);
|
||||
textObj.transform.SetParent(parent);
|
||||
textObj.transform.localPosition = Vector3.zero;
|
||||
textObj.transform.localRotation = Quaternion.identity;
|
||||
textObj.transform.localScale = Vector3.one;
|
||||
|
||||
var tmp = textObj.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = text;
|
||||
tmp.fontSize = fontSize;
|
||||
tmp.color = color;
|
||||
tmp.fontStyle = style;
|
||||
tmp.alignment = TextAlignmentOptions.Center;
|
||||
|
||||
return tmp;
|
||||
}
|
||||
|
||||
private RectTransform CreateProgressBar(Transform parent, string name, string label,
|
||||
Color fillColor, out Image fillImage, out TextMeshProUGUI valueText)
|
||||
{
|
||||
var container = new GameObject(name);
|
||||
container.transform.SetParent(parent);
|
||||
container.transform.localPosition = Vector3.zero;
|
||||
container.transform.localRotation = Quaternion.identity;
|
||||
container.transform.localScale = Vector3.one;
|
||||
|
||||
var containerRect = container.AddComponent<RectTransform>();
|
||||
|
||||
// Background
|
||||
var bg = new GameObject("Background");
|
||||
bg.transform.SetParent(container.transform);
|
||||
var bgImg = bg.AddComponent<Image>();
|
||||
bgImg.color = new Color(0.15f, 0.15f, 0.15f, 0.9f);
|
||||
var bgRect = bg.GetComponent<RectTransform>();
|
||||
bgRect.anchorMin = Vector2.zero;
|
||||
bgRect.anchorMax = Vector2.one;
|
||||
bgRect.offsetMin = Vector2.zero;
|
||||
bgRect.offsetMax = Vector2.zero;
|
||||
bgRect.localPosition = Vector3.zero;
|
||||
bgRect.localScale = Vector3.one;
|
||||
|
||||
// Fill
|
||||
var fill = new GameObject("Fill");
|
||||
fill.transform.SetParent(container.transform);
|
||||
fillImage = fill.AddComponent<Image>();
|
||||
fillImage.color = fillColor;
|
||||
var fillRect = fill.GetComponent<RectTransform>();
|
||||
fillRect.anchorMin = Vector2.zero;
|
||||
fillRect.anchorMax = Vector2.one;
|
||||
fillRect.pivot = new Vector2(0, 0.5f);
|
||||
fillRect.offsetMin = new Vector2(2, 2);
|
||||
fillRect.offsetMax = new Vector2(-2, -2);
|
||||
fillRect.localPosition = Vector3.zero;
|
||||
fillRect.localScale = Vector3.one;
|
||||
|
||||
// Text
|
||||
valueText = CreateUIText(container.transform, "Text", $"{label}: 100", 16, Color.white);
|
||||
var textRect = valueText.rectTransform;
|
||||
textRect.anchorMin = Vector2.zero;
|
||||
textRect.anchorMax = Vector2.one;
|
||||
textRect.offsetMin = Vector2.zero;
|
||||
textRect.offsetMax = Vector2.zero;
|
||||
|
||||
return containerRect;
|
||||
}
|
||||
|
||||
private GameObject CreateDeathOverlay(Transform parent)
|
||||
{
|
||||
var overlay = new GameObject("DeathOverlay");
|
||||
overlay.transform.SetParent(parent);
|
||||
overlay.transform.localPosition = Vector3.zero;
|
||||
overlay.transform.localRotation = Quaternion.identity;
|
||||
overlay.transform.localScale = Vector3.one;
|
||||
|
||||
var rect = overlay.AddComponent<RectTransform>();
|
||||
rect.anchorMin = Vector2.zero;
|
||||
rect.anchorMax = Vector2.one;
|
||||
rect.offsetMin = Vector2.zero;
|
||||
rect.offsetMax = Vector2.zero;
|
||||
|
||||
var img = overlay.AddComponent<Image>();
|
||||
img.color = new Color(0.2f, 0f, 0f, 0.8f);
|
||||
|
||||
var deathText = CreateUIText(overlay.transform, "DeathText", "DEAD", 32, Color.red, FontStyles.Bold);
|
||||
deathText.rectTransform.anchorMin = Vector2.zero;
|
||||
deathText.rectTransform.anchorMax = Vector2.one;
|
||||
deathText.rectTransform.offsetMin = Vector2.zero;
|
||||
deathText.rectTransform.offsetMax = Vector2.zero;
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
private void CreateSpeechBubble()
|
||||
{
|
||||
// Create speech bubble canvas
|
||||
var bubbleCanvas = new GameObject("SpeechCanvas");
|
||||
bubbleCanvas.transform.SetParent(transform);
|
||||
bubbleCanvas.transform.localPosition = speechOffset;
|
||||
bubbleCanvas.transform.localScale = Vector3.one * uiScale;
|
||||
|
||||
var canvas = bubbleCanvas.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.WorldSpace;
|
||||
canvas.sortingOrder = sortingOrder + 2;
|
||||
|
||||
var canvasRect = bubbleCanvas.GetComponent<RectTransform>();
|
||||
canvasRect.sizeDelta = new Vector2(400, 100);
|
||||
|
||||
// Add billboard (configured for UI - full facing)
|
||||
var bubbleBillboard = bubbleCanvas.AddComponent<Billboard>();
|
||||
bubbleBillboard.ConfigureForUI();
|
||||
|
||||
// Create speech bubble
|
||||
_speechBubble = SpeechBubble.Create(bubbleCanvas.transform, Vector3.zero);
|
||||
_speechBubble.DisplayDuration = speechDuration;
|
||||
}
|
||||
|
||||
private void CreateCollider()
|
||||
{
|
||||
if (GetComponent<Collider>() == null)
|
||||
{
|
||||
var col = gameObject.AddComponent<CapsuleCollider>();
|
||||
col.height = 2.5f;
|
||||
col.radius = 0.6f;
|
||||
col.center = new Vector3(0, 1.25f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetRectPosition(RectTransform rect, float x, float y, float width, float height)
|
||||
{
|
||||
rect.anchoredPosition = new Vector2(x, y);
|
||||
rect.sizeDelta = new Vector2(width, height);
|
||||
rect.localPosition = new Vector3(x, y, 0);
|
||||
rect.localScale = Vector3.one;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Stats Update
|
||||
public void UpdateStats(AgentData data)
|
||||
{
|
||||
_currentData = data;
|
||||
|
||||
// Update HP bar
|
||||
float hpPercent = data.hp / 100f;
|
||||
if (_hpBarFill != null)
|
||||
{
|
||||
_hpBarFill.rectTransform.anchorMax = new Vector2(hpPercent, 1);
|
||||
_hpBarFill.color = Color.Lerp(hpLowColor, hpHighColor, hpPercent);
|
||||
}
|
||||
if (_hpText != null)
|
||||
{
|
||||
_hpText.text = $"HP: {data.hp}";
|
||||
}
|
||||
|
||||
// Update Energy bar
|
||||
float energyPercent = data.energy / 100f;
|
||||
if (_energyBarFill != null)
|
||||
{
|
||||
_energyBarFill.rectTransform.anchorMax = new Vector2(energyPercent, 1);
|
||||
_energyBarFill.color = Color.Lerp(energyLowColor, energyHighColor, energyPercent);
|
||||
}
|
||||
if (_energyText != null)
|
||||
{
|
||||
_energyText.text = $"Energy: {data.energy}";
|
||||
}
|
||||
|
||||
// Update death state
|
||||
if (!data.IsAlive)
|
||||
{
|
||||
OnDeath();
|
||||
}
|
||||
else
|
||||
{
|
||||
OnAlive();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDeath()
|
||||
{
|
||||
if (_deathOverlay != null) _deathOverlay.SetActive(true);
|
||||
if (_speechBubble != null) _speechBubble.Hide();
|
||||
|
||||
// Gray out sprite
|
||||
if (_spriteRenderer != null)
|
||||
{
|
||||
_spriteRenderer.color = new Color(0.3f, 0.3f, 0.3f, 0.7f);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAlive()
|
||||
{
|
||||
if (_deathOverlay != null) _deathOverlay.SetActive(false);
|
||||
|
||||
// Restore sprite color
|
||||
if (_spriteRenderer != null)
|
||||
{
|
||||
_spriteRenderer.color = Color.white;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Speech
|
||||
public void ShowSpeech(string text)
|
||||
{
|
||||
if (_speechBubble == null || !IsAlive) return;
|
||||
|
||||
_speechBubble.Setup(text);
|
||||
Debug.Log($"[AgentVisual] {_currentData?.name} says: \"{text}\"");
|
||||
}
|
||||
|
||||
public void HideSpeech()
|
||||
{
|
||||
if (_speechBubble != null)
|
||||
{
|
||||
_speechBubble.Hide();
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Public API
|
||||
/// <summary>
|
||||
/// Set the character sprite at runtime.
|
||||
/// </summary>
|
||||
public void SetSprite(Sprite sprite)
|
||||
{
|
||||
characterSprite = sprite;
|
||||
if (_spriteRenderer != null)
|
||||
{
|
||||
_spriteRenderer.sprite = sprite;
|
||||
_spriteRenderer.color = spriteColor;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the character color (for placeholder or tinting).
|
||||
/// </summary>
|
||||
public void SetColor(Color bodyColor, Color outlineColor)
|
||||
{
|
||||
placeholderBodyColor = bodyColor;
|
||||
placeholderOutlineColor = outlineColor;
|
||||
|
||||
if (characterSprite == null)
|
||||
{
|
||||
RegeneratePlaceholderSprite();
|
||||
}
|
||||
else
|
||||
{
|
||||
_spriteRenderer.color = bodyColor;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
2
unity-client/Assets/Scripts/AgentVisual.cs.meta
Normal file
2
unity-client/Assets/Scripts/AgentVisual.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d8a016bdb4b93424da387c520b59cbfb
|
||||
133
unity-client/Assets/Scripts/Billboard.cs
Normal file
133
unity-client/Assets/Scripts/Billboard.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace TheIsland.Visual
|
||||
{
|
||||
/// <summary>
|
||||
/// Forces a 2D sprite or UI element to always face the camera.
|
||||
/// Attach to any GameObject that should billboard towards the main camera.
|
||||
/// </summary>
|
||||
public class Billboard : MonoBehaviour
|
||||
{
|
||||
#region Configuration
|
||||
[Header("Billboard Settings")]
|
||||
[Tooltip("If true, locks the Y-axis rotation (sprite stays upright)")]
|
||||
[SerializeField] private bool lockYAxis = true;
|
||||
|
||||
[Tooltip("If true, uses the main camera. Otherwise, assign a specific camera.")]
|
||||
[SerializeField] private bool useMainCamera = true;
|
||||
|
||||
[Tooltip("Custom camera to face (only used if useMainCamera is false)")]
|
||||
[SerializeField] private Camera targetCamera;
|
||||
|
||||
[Tooltip("Flip the facing direction (useful for some sprite setups)")]
|
||||
[SerializeField] private bool flipFacing = false;
|
||||
#endregion
|
||||
|
||||
#region Private Fields
|
||||
private Camera _camera;
|
||||
private Transform _cameraTransform;
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
private void Start()
|
||||
{
|
||||
CacheCamera();
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (_cameraTransform == null)
|
||||
{
|
||||
CacheCamera();
|
||||
if (_cameraTransform == null) return;
|
||||
}
|
||||
|
||||
FaceCamera();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Private Methods
|
||||
private void CacheCamera()
|
||||
{
|
||||
_camera = useMainCamera ? Camera.main : targetCamera;
|
||||
if (_camera != null)
|
||||
{
|
||||
_cameraTransform = _camera.transform;
|
||||
}
|
||||
}
|
||||
|
||||
private void FaceCamera()
|
||||
{
|
||||
if (lockYAxis)
|
||||
{
|
||||
// Only rotate around Y-axis (sprite stays upright)
|
||||
Vector3 lookDirection = _cameraTransform.position - transform.position;
|
||||
lookDirection.y = 0; // Ignore vertical difference
|
||||
|
||||
if (lookDirection != Vector3.zero)
|
||||
{
|
||||
Quaternion targetRotation = Quaternion.LookRotation(
|
||||
flipFacing ? lookDirection : -lookDirection
|
||||
);
|
||||
transform.rotation = targetRotation;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Full billboard - face camera completely
|
||||
transform.rotation = flipFacing
|
||||
? Quaternion.LookRotation(transform.position - _cameraTransform.position)
|
||||
: _cameraTransform.rotation;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Public Methods
|
||||
/// <summary>
|
||||
/// Set a custom camera to face (disables useMainCamera).
|
||||
/// </summary>
|
||||
public void SetTargetCamera(Camera camera)
|
||||
{
|
||||
useMainCamera = false;
|
||||
targetCamera = camera;
|
||||
_camera = camera;
|
||||
_cameraTransform = camera?.transform;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset to use main camera.
|
||||
/// </summary>
|
||||
public void UseMainCamera()
|
||||
{
|
||||
useMainCamera = true;
|
||||
CacheCamera();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configure billboard for UI elements (full facing, no Y-lock).
|
||||
/// </summary>
|
||||
public void ConfigureForUI()
|
||||
{
|
||||
lockYAxis = false;
|
||||
flipFacing = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configure billboard for sprites (Y-axis locked, stays upright).
|
||||
/// </summary>
|
||||
public void ConfigureForSprite()
|
||||
{
|
||||
lockYAxis = true;
|
||||
flipFacing = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set whether to lock Y-axis rotation.
|
||||
/// </summary>
|
||||
public void SetLockYAxis(bool locked)
|
||||
{
|
||||
lockYAxis = locked;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
2
unity-client/Assets/Scripts/Billboard.cs.meta
Normal file
2
unity-client/Assets/Scripts/Billboard.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0fab5abc5a02446439960d2d7da16753
|
||||
337
unity-client/Assets/Scripts/CameraController.cs
Normal file
337
unity-client/Assets/Scripts/CameraController.cs
Normal file
@@ -0,0 +1,337 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
|
||||
namespace TheIsland.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// RTS-style camera controller for free-roaming over the island.
|
||||
/// Supports WASD movement, mouse scroll zoom, and optional edge scrolling.
|
||||
/// </summary>
|
||||
public class CameraController : MonoBehaviour
|
||||
{
|
||||
#region Movement Settings
|
||||
[Header("Movement")]
|
||||
[Tooltip("Camera movement speed (units per second)")]
|
||||
[SerializeField] private float moveSpeed = 15f;
|
||||
|
||||
[Tooltip("Movement speed multiplier when holding Shift")]
|
||||
[SerializeField] private float fastMoveMultiplier = 2f;
|
||||
|
||||
[Tooltip("Smooth movement interpolation (0 = instant, 1 = very smooth)")]
|
||||
[Range(0f, 0.99f)]
|
||||
[SerializeField] private float moveSmoothness = 0.1f;
|
||||
#endregion
|
||||
|
||||
#region Zoom Settings
|
||||
[Header("Zoom")]
|
||||
[Tooltip("Zoom speed (scroll sensitivity)")]
|
||||
[SerializeField] private float zoomSpeed = 10f;
|
||||
|
||||
[Tooltip("Minimum camera height (closest zoom)")]
|
||||
[SerializeField] private float minZoom = 5f;
|
||||
|
||||
[Tooltip("Maximum camera height (farthest zoom)")]
|
||||
[SerializeField] private float maxZoom = 50f;
|
||||
|
||||
[Tooltip("Smooth zoom interpolation")]
|
||||
[Range(0f, 0.99f)]
|
||||
[SerializeField] private float zoomSmoothness = 0.1f;
|
||||
#endregion
|
||||
|
||||
#region Rotation Settings
|
||||
[Header("Rotation (Optional)")]
|
||||
[Tooltip("Enable middle mouse button rotation")]
|
||||
[SerializeField] private bool enableRotation = true;
|
||||
|
||||
[Tooltip("Rotation speed")]
|
||||
[SerializeField] private float rotationSpeed = 100f;
|
||||
#endregion
|
||||
|
||||
#region Edge Scrolling
|
||||
[Header("Edge Scrolling (Optional)")]
|
||||
[Tooltip("Enable screen edge scrolling")]
|
||||
[SerializeField] private bool enableEdgeScrolling = false;
|
||||
|
||||
[Tooltip("Edge threshold in pixels")]
|
||||
[SerializeField] private float edgeThreshold = 20f;
|
||||
#endregion
|
||||
|
||||
#region Bounds
|
||||
[Header("Movement Bounds")]
|
||||
[Tooltip("Limit camera movement to a specific area")]
|
||||
[SerializeField] private bool useBounds = false;
|
||||
|
||||
[SerializeField] private Vector2 boundsMin = new Vector2(-50f, -50f);
|
||||
[SerializeField] private Vector2 boundsMax = new Vector2(50f, 50f);
|
||||
#endregion
|
||||
|
||||
#region Private Fields
|
||||
private Vector3 _targetPosition;
|
||||
private float _targetZoom;
|
||||
private float _currentYRotation;
|
||||
private Camera _camera;
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
private void Start()
|
||||
{
|
||||
_camera = GetComponent<Camera>();
|
||||
if (_camera == null)
|
||||
{
|
||||
_camera = Camera.main;
|
||||
}
|
||||
|
||||
_targetPosition = transform.position;
|
||||
_targetZoom = transform.position.y;
|
||||
_currentYRotation = transform.eulerAngles.y;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// Skip keyboard input when UI input field is focused
|
||||
if (!IsUIInputFocused())
|
||||
{
|
||||
HandleMovementInput();
|
||||
HandleRotationInput();
|
||||
}
|
||||
|
||||
// Zoom always works (mouse scroll doesn't conflict with typing)
|
||||
HandleZoomInput();
|
||||
|
||||
ApplyMovement();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if a UI input field is currently focused.
|
||||
/// </summary>
|
||||
private bool IsUIInputFocused()
|
||||
{
|
||||
if (EventSystem.current == null) return false;
|
||||
|
||||
GameObject selected = EventSystem.current.currentSelectedGameObject;
|
||||
if (selected == null) return false;
|
||||
|
||||
// Check if the selected object has an input field component
|
||||
return selected.GetComponent<TMPro.TMP_InputField>() != null
|
||||
|| selected.GetComponent<UnityEngine.UI.InputField>() != null;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Input Handling
|
||||
private void HandleMovementInput()
|
||||
{
|
||||
Vector3 moveDirection = Vector3.zero;
|
||||
|
||||
// WASD / Arrow keys input
|
||||
if (Input.GetKey(KeyCode.W) || Input.GetKey(KeyCode.UpArrow))
|
||||
moveDirection += GetForward();
|
||||
|
||||
if (Input.GetKey(KeyCode.S) || Input.GetKey(KeyCode.DownArrow))
|
||||
moveDirection -= GetForward();
|
||||
|
||||
if (Input.GetKey(KeyCode.A) || Input.GetKey(KeyCode.LeftArrow))
|
||||
moveDirection -= GetRight();
|
||||
|
||||
if (Input.GetKey(KeyCode.D) || Input.GetKey(KeyCode.RightArrow))
|
||||
moveDirection += GetRight();
|
||||
|
||||
// Edge scrolling
|
||||
if (enableEdgeScrolling)
|
||||
{
|
||||
Vector3 edgeMove = GetEdgeScrollDirection();
|
||||
moveDirection += edgeMove;
|
||||
}
|
||||
|
||||
// Apply movement
|
||||
if (moveDirection != Vector3.zero)
|
||||
{
|
||||
float speed = moveSpeed;
|
||||
if (Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift))
|
||||
{
|
||||
speed *= fastMoveMultiplier;
|
||||
}
|
||||
|
||||
moveDirection.Normalize();
|
||||
_targetPosition += moveDirection * speed * Time.deltaTime;
|
||||
}
|
||||
|
||||
// Clamp to bounds
|
||||
if (useBounds)
|
||||
{
|
||||
_targetPosition.x = Mathf.Clamp(_targetPosition.x, boundsMin.x, boundsMax.x);
|
||||
_targetPosition.z = Mathf.Clamp(_targetPosition.z, boundsMin.y, boundsMax.y);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleZoomInput()
|
||||
{
|
||||
float scrollInput = Input.GetAxis("Mouse ScrollWheel");
|
||||
|
||||
if (Mathf.Abs(scrollInput) > 0.01f)
|
||||
{
|
||||
_targetZoom -= scrollInput * zoomSpeed;
|
||||
_targetZoom = Mathf.Clamp(_targetZoom, minZoom, maxZoom);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleRotationInput()
|
||||
{
|
||||
if (!enableRotation) return;
|
||||
|
||||
// Middle mouse button rotation
|
||||
if (Input.GetMouseButton(2))
|
||||
{
|
||||
float rotateInput = Input.GetAxis("Mouse X");
|
||||
_currentYRotation += rotateInput * rotationSpeed * Time.deltaTime;
|
||||
}
|
||||
|
||||
// Q/E rotation (alternative)
|
||||
if (Input.GetKey(KeyCode.Q))
|
||||
{
|
||||
_currentYRotation -= rotationSpeed * Time.deltaTime;
|
||||
}
|
||||
if (Input.GetKey(KeyCode.E))
|
||||
{
|
||||
_currentYRotation += rotationSpeed * Time.deltaTime;
|
||||
}
|
||||
}
|
||||
|
||||
private Vector3 GetEdgeScrollDirection()
|
||||
{
|
||||
Vector3 direction = Vector3.zero;
|
||||
Vector3 mousePos = Input.mousePosition;
|
||||
|
||||
if (mousePos.x < edgeThreshold)
|
||||
direction -= GetRight();
|
||||
else if (mousePos.x > Screen.width - edgeThreshold)
|
||||
direction += GetRight();
|
||||
|
||||
if (mousePos.y < edgeThreshold)
|
||||
direction -= GetForward();
|
||||
else if (mousePos.y > Screen.height - edgeThreshold)
|
||||
direction += GetForward();
|
||||
|
||||
return direction;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Movement Application
|
||||
private void ApplyMovement()
|
||||
{
|
||||
// Smooth position
|
||||
Vector3 currentPos = transform.position;
|
||||
Vector3 newPos = new Vector3(
|
||||
Mathf.Lerp(currentPos.x, _targetPosition.x, 1f - moveSmoothness),
|
||||
Mathf.Lerp(currentPos.y, _targetZoom, 1f - zoomSmoothness),
|
||||
Mathf.Lerp(currentPos.z, _targetPosition.z, 1f - moveSmoothness)
|
||||
);
|
||||
transform.position = newPos;
|
||||
|
||||
// Update target Y to match current (for initialization)
|
||||
_targetPosition.y = _targetZoom;
|
||||
|
||||
// Apply rotation
|
||||
if (enableRotation)
|
||||
{
|
||||
Quaternion targetRotation = Quaternion.Euler(
|
||||
transform.eulerAngles.x, // Keep current X (pitch)
|
||||
_currentYRotation,
|
||||
0f
|
||||
);
|
||||
transform.rotation = Quaternion.Slerp(
|
||||
transform.rotation,
|
||||
targetRotation,
|
||||
1f - moveSmoothness
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private Vector3 GetForward()
|
||||
{
|
||||
// Get forward direction on XZ plane (ignoring pitch)
|
||||
Vector3 forward = transform.forward;
|
||||
forward.y = 0;
|
||||
return forward.normalized;
|
||||
}
|
||||
|
||||
private Vector3 GetRight()
|
||||
{
|
||||
// Get right direction on XZ plane
|
||||
Vector3 right = transform.right;
|
||||
right.y = 0;
|
||||
return right.normalized;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Public API
|
||||
/// <summary>
|
||||
/// Move camera to focus on a specific world position.
|
||||
/// </summary>
|
||||
public void FocusOn(Vector3 worldPosition, bool instant = false)
|
||||
{
|
||||
_targetPosition = new Vector3(worldPosition.x, _targetZoom, worldPosition.z);
|
||||
|
||||
if (instant)
|
||||
{
|
||||
transform.position = new Vector3(worldPosition.x, _targetZoom, worldPosition.z);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set zoom level directly.
|
||||
/// </summary>
|
||||
public void SetZoom(float zoomLevel, bool instant = false)
|
||||
{
|
||||
_targetZoom = Mathf.Clamp(zoomLevel, minZoom, maxZoom);
|
||||
|
||||
if (instant)
|
||||
{
|
||||
Vector3 pos = transform.position;
|
||||
pos.y = _targetZoom;
|
||||
transform.position = pos;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set movement bounds at runtime.
|
||||
/// </summary>
|
||||
public void SetBounds(Vector2 min, Vector2 max)
|
||||
{
|
||||
useBounds = true;
|
||||
boundsMin = min;
|
||||
boundsMax = max;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disable movement bounds.
|
||||
/// </summary>
|
||||
public void DisableBounds()
|
||||
{
|
||||
useBounds = false;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Editor Gizmos
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (useBounds)
|
||||
{
|
||||
Gizmos.color = Color.yellow;
|
||||
Vector3 center = new Vector3(
|
||||
(boundsMin.x + boundsMax.x) / 2f,
|
||||
transform.position.y,
|
||||
(boundsMin.y + boundsMax.y) / 2f
|
||||
);
|
||||
Vector3 size = new Vector3(
|
||||
boundsMax.x - boundsMin.x,
|
||||
0.1f,
|
||||
boundsMax.y - boundsMin.y
|
||||
);
|
||||
Gizmos.DrawWireCube(center, size);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
2
unity-client/Assets/Scripts/CameraController.cs.meta
Normal file
2
unity-client/Assets/Scripts/CameraController.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1725c886e32c44bc0a029d628dd0e5de
|
||||
495
unity-client/Assets/Scripts/GameManager.cs
Normal file
495
unity-client/Assets/Scripts/GameManager.cs
Normal file
@@ -0,0 +1,495 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
using TheIsland.Models;
|
||||
using TheIsland.Network;
|
||||
using TheIsland.Agents;
|
||||
using TheIsland.UI;
|
||||
using TheIsland.Visual;
|
||||
|
||||
namespace TheIsland.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Main game controller.
|
||||
/// Manages agent spawning, UI updates, and event handling.
|
||||
/// </summary>
|
||||
public class GameManager : MonoBehaviour
|
||||
{
|
||||
#region Singleton
|
||||
private static GameManager _instance;
|
||||
public static GameManager Instance => _instance;
|
||||
#endregion
|
||||
|
||||
#region Configuration
|
||||
[Header("Agent Spawning")]
|
||||
[SerializeField] private GameObject agentPrefab;
|
||||
[SerializeField] private Transform agentContainer;
|
||||
[SerializeField] private Vector3[] spawnPositions = new Vector3[]
|
||||
{
|
||||
new Vector3(-3f, 0f, 0f),
|
||||
new Vector3(0f, 0f, 0f),
|
||||
new Vector3(3f, 0f, 0f)
|
||||
};
|
||||
|
||||
[Header("UI References")]
|
||||
[SerializeField] private TextMeshProUGUI connectionStatus;
|
||||
[SerializeField] private TextMeshProUGUI tickInfo;
|
||||
[SerializeField] private TextMeshProUGUI goldDisplay;
|
||||
[SerializeField] private Button resetButton;
|
||||
[SerializeField] private TMP_InputField commandInput;
|
||||
[SerializeField] private Button sendButton;
|
||||
|
||||
[Header("Notification Panel")]
|
||||
[SerializeField] private GameObject notificationPanel;
|
||||
[SerializeField] private TextMeshProUGUI notificationText;
|
||||
[SerializeField] private float notificationDuration = 3f;
|
||||
#endregion
|
||||
|
||||
#region Private Fields
|
||||
private Dictionary<int, AgentController> _agents = new Dictionary<int, AgentController>();
|
||||
private Dictionary<int, AgentUI> _agentUIs = new Dictionary<int, AgentUI>();
|
||||
private Dictionary<int, AgentVisual> _agentVisuals = new Dictionary<int, AgentVisual>();
|
||||
private int _playerGold = 100;
|
||||
private int _currentTick;
|
||||
private int _currentDay;
|
||||
private int _nextSpawnIndex;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
public int PlayerGold => _playerGold;
|
||||
public int AliveAgentCount
|
||||
{
|
||||
get
|
||||
{
|
||||
int count = 0;
|
||||
// Check AgentVisual first (newest system)
|
||||
foreach (var visual in _agentVisuals.Values)
|
||||
{
|
||||
if (visual.IsAlive) count++;
|
||||
}
|
||||
if (count > 0) return count;
|
||||
|
||||
// Fallback to AgentUI
|
||||
foreach (var agentUI in _agentUIs.Values)
|
||||
{
|
||||
if (agentUI.IsAlive) count++;
|
||||
}
|
||||
if (count > 0) return count;
|
||||
|
||||
// Fallback to AgentController (legacy)
|
||||
foreach (var agent in _agents.Values)
|
||||
{
|
||||
if (agent.IsAlive) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
private void Awake()
|
||||
{
|
||||
if (_instance != null && _instance != this)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
_instance = this;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
// Subscribe to network events
|
||||
SubscribeToNetworkEvents();
|
||||
|
||||
// Setup UI
|
||||
SetupUI();
|
||||
|
||||
// Initial connection status
|
||||
UpdateConnectionStatus(false);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
// Unsubscribe from network events
|
||||
UnsubscribeFromNetworkEvents();
|
||||
|
||||
// Cleanup UI listeners
|
||||
CleanupUI();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Network Event Subscription
|
||||
private void SubscribeToNetworkEvents()
|
||||
{
|
||||
var network = NetworkManager.Instance;
|
||||
if (network == null) return;
|
||||
|
||||
network.OnConnected += HandleConnected;
|
||||
network.OnDisconnected += HandleDisconnected;
|
||||
network.OnAgentsUpdate += HandleAgentsUpdate;
|
||||
network.OnAgentSpeak += HandleAgentSpeak;
|
||||
network.OnAgentDied += HandleAgentDied;
|
||||
network.OnFeed += HandleFeed;
|
||||
network.OnTick += HandleTick;
|
||||
network.OnSystemMessage += HandleSystemMessage;
|
||||
network.OnUserUpdate += HandleUserUpdate;
|
||||
}
|
||||
|
||||
private void UnsubscribeFromNetworkEvents()
|
||||
{
|
||||
var network = NetworkManager.Instance;
|
||||
if (network == null) return;
|
||||
|
||||
network.OnConnected -= HandleConnected;
|
||||
network.OnDisconnected -= HandleDisconnected;
|
||||
network.OnAgentsUpdate -= HandleAgentsUpdate;
|
||||
network.OnAgentSpeak -= HandleAgentSpeak;
|
||||
network.OnAgentDied -= HandleAgentDied;
|
||||
network.OnFeed -= HandleFeed;
|
||||
network.OnTick -= HandleTick;
|
||||
network.OnSystemMessage -= HandleSystemMessage;
|
||||
network.OnUserUpdate -= HandleUserUpdate;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region UI Setup
|
||||
private void SetupUI()
|
||||
{
|
||||
// Reset button
|
||||
if (resetButton != null)
|
||||
{
|
||||
resetButton.onClick.RemoveAllListeners();
|
||||
resetButton.onClick.AddListener(OnResetClicked);
|
||||
}
|
||||
|
||||
// Send button
|
||||
if (sendButton != null)
|
||||
{
|
||||
sendButton.onClick.RemoveAllListeners();
|
||||
sendButton.onClick.AddListener(OnSendClicked);
|
||||
}
|
||||
|
||||
// Command input enter key
|
||||
if (commandInput != null)
|
||||
{
|
||||
commandInput.onSubmit.RemoveAllListeners();
|
||||
commandInput.onSubmit.AddListener(OnCommandSubmit);
|
||||
}
|
||||
|
||||
// Hide notification initially
|
||||
if (notificationPanel != null)
|
||||
{
|
||||
notificationPanel.SetActive(false);
|
||||
}
|
||||
|
||||
UpdateGoldDisplay();
|
||||
}
|
||||
|
||||
private void CleanupUI()
|
||||
{
|
||||
if (resetButton != null)
|
||||
{
|
||||
resetButton.onClick.RemoveListener(OnResetClicked);
|
||||
}
|
||||
if (sendButton != null)
|
||||
{
|
||||
sendButton.onClick.RemoveListener(OnSendClicked);
|
||||
}
|
||||
if (commandInput != null)
|
||||
{
|
||||
commandInput.onSubmit.RemoveListener(OnCommandSubmit);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateConnectionStatus(bool connected)
|
||||
{
|
||||
if (connectionStatus == null) return;
|
||||
|
||||
connectionStatus.text = connected ? "Connected" : "Disconnected";
|
||||
connectionStatus.color = connected ? Color.green : Color.red;
|
||||
}
|
||||
|
||||
private void UpdateTickInfo()
|
||||
{
|
||||
if (tickInfo == null) return;
|
||||
|
||||
tickInfo.text = $"Day {_currentDay} | Tick {_currentTick} | Alive: {AliveAgentCount}";
|
||||
}
|
||||
|
||||
private void UpdateGoldDisplay()
|
||||
{
|
||||
if (goldDisplay == null) return;
|
||||
|
||||
goldDisplay.text = $"Gold: {_playerGold}";
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Network Event Handlers
|
||||
private void HandleConnected()
|
||||
{
|
||||
Debug.Log("[GameManager] Connected to server");
|
||||
UpdateConnectionStatus(true);
|
||||
ShowNotification("Connected to The Island!");
|
||||
}
|
||||
|
||||
private void HandleDisconnected()
|
||||
{
|
||||
Debug.Log("[GameManager] Disconnected from server");
|
||||
UpdateConnectionStatus(false);
|
||||
ShowNotification("Disconnected from server", isError: true);
|
||||
}
|
||||
|
||||
private void HandleAgentsUpdate(List<AgentData> agentsData)
|
||||
{
|
||||
// Null check to prevent exceptions
|
||||
if (agentsData == null || agentsData.Count == 0)
|
||||
{
|
||||
Debug.LogWarning("[GameManager] Received empty agents update");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var data in agentsData)
|
||||
{
|
||||
// Check for AgentVisual first (newest system - 2.5D sprites)
|
||||
if (_agentVisuals.TryGetValue(data.id, out AgentVisual agentVisual))
|
||||
{
|
||||
agentVisual.UpdateStats(data);
|
||||
}
|
||||
// Check for AgentUI (programmatic UI system)
|
||||
else if (_agentUIs.TryGetValue(data.id, out AgentUI agentUI))
|
||||
{
|
||||
agentUI.UpdateStats(data);
|
||||
}
|
||||
// Fallback to AgentController (legacy)
|
||||
else if (_agents.TryGetValue(data.id, out AgentController controller))
|
||||
{
|
||||
controller.UpdateStats(data);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Spawn new agent
|
||||
SpawnAgent(data);
|
||||
}
|
||||
}
|
||||
|
||||
UpdateTickInfo();
|
||||
}
|
||||
|
||||
private void HandleAgentSpeak(AgentSpeakData data)
|
||||
{
|
||||
// Check AgentVisual first (newest system - 2.5D sprites)
|
||||
if (_agentVisuals.TryGetValue(data.agent_id, out AgentVisual agentVisual))
|
||||
{
|
||||
agentVisual.ShowSpeech(data.text);
|
||||
}
|
||||
// Check AgentUI (programmatic UI system)
|
||||
else if (_agentUIs.TryGetValue(data.agent_id, out AgentUI agentUI))
|
||||
{
|
||||
agentUI.ShowSpeech(data.text);
|
||||
}
|
||||
// Fallback to AgentController (legacy)
|
||||
else if (_agents.TryGetValue(data.agent_id, out AgentController controller))
|
||||
{
|
||||
controller.ShowSpeech(data.text);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"[GameManager] Agent {data.agent_id} not found for speech");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleAgentDied(AgentDiedData data)
|
||||
{
|
||||
Debug.Log($"[GameManager] Agent died: {data.agent_name}");
|
||||
ShowNotification(data.message, isError: true);
|
||||
}
|
||||
|
||||
private void HandleFeed(FeedEventData data)
|
||||
{
|
||||
Debug.Log($"[GameManager] Feed event: {data.message}");
|
||||
|
||||
// Update gold if this was our action
|
||||
if (data.user == NetworkManager.Instance.Username)
|
||||
{
|
||||
_playerGold = data.user_gold;
|
||||
UpdateGoldDisplay();
|
||||
}
|
||||
|
||||
ShowNotification(data.message);
|
||||
}
|
||||
|
||||
private void HandleTick(TickData data)
|
||||
{
|
||||
_currentTick = data.tick;
|
||||
_currentDay = data.day;
|
||||
UpdateTickInfo();
|
||||
}
|
||||
|
||||
private void HandleSystemMessage(SystemEventData data)
|
||||
{
|
||||
Debug.Log($"[GameManager] System: {data.message}");
|
||||
ShowNotification(data.message);
|
||||
}
|
||||
|
||||
private void HandleUserUpdate(UserUpdateData data)
|
||||
{
|
||||
if (data.user == NetworkManager.Instance.Username)
|
||||
{
|
||||
_playerGold = data.gold;
|
||||
UpdateGoldDisplay();
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Agent Management
|
||||
private void SpawnAgent(AgentData data)
|
||||
{
|
||||
if (agentPrefab == null)
|
||||
{
|
||||
Debug.LogError("[GameManager] Agent prefab not assigned!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine spawn position
|
||||
Vector3 spawnPos = GetNextSpawnPosition();
|
||||
|
||||
// Instantiate prefab
|
||||
GameObject agentObj = Instantiate(
|
||||
agentPrefab,
|
||||
spawnPos,
|
||||
Quaternion.identity,
|
||||
agentContainer
|
||||
);
|
||||
|
||||
// Try to get AgentVisual first (newest system - 2.5D sprites)
|
||||
AgentVisual agentVisual = agentObj.GetComponent<AgentVisual>();
|
||||
if (agentVisual != null)
|
||||
{
|
||||
agentVisual.Initialize(data);
|
||||
_agentVisuals[data.id] = agentVisual;
|
||||
Debug.Log($"[GameManager] Spawned agent (AgentVisual): {data.name} at {spawnPos}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get AgentUI (programmatic UI system)
|
||||
AgentUI agentUI = agentObj.GetComponent<AgentUI>();
|
||||
if (agentUI == null)
|
||||
{
|
||||
// Add AgentUI component - it will create all UI elements automatically
|
||||
agentUI = agentObj.AddComponent<AgentUI>();
|
||||
}
|
||||
agentUI.Initialize(data);
|
||||
_agentUIs[data.id] = agentUI;
|
||||
|
||||
// Also check for legacy AgentController
|
||||
AgentController controller = agentObj.GetComponent<AgentController>();
|
||||
if (controller != null)
|
||||
{
|
||||
controller.Initialize(data);
|
||||
_agents[data.id] = controller;
|
||||
}
|
||||
|
||||
Debug.Log($"[GameManager] Spawned agent: {data.name} at {spawnPos}");
|
||||
}
|
||||
|
||||
private Vector3 GetNextSpawnPosition()
|
||||
{
|
||||
if (spawnPositions == null || spawnPositions.Length == 0)
|
||||
{
|
||||
return Vector3.zero;
|
||||
}
|
||||
|
||||
Vector3 pos = spawnPositions[_nextSpawnIndex % spawnPositions.Length];
|
||||
_nextSpawnIndex++;
|
||||
return pos;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get an agent controller by ID.
|
||||
/// </summary>
|
||||
public AgentController GetAgent(int agentId)
|
||||
{
|
||||
_agents.TryGetValue(agentId, out AgentController controller);
|
||||
return controller;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get an agent controller by name.
|
||||
/// </summary>
|
||||
public AgentController GetAgentByName(string name)
|
||||
{
|
||||
foreach (var agent in _agents.Values)
|
||||
{
|
||||
if (agent.CurrentData?.name == name)
|
||||
{
|
||||
return agent;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region UI Actions
|
||||
private void OnResetClicked()
|
||||
{
|
||||
NetworkManager.Instance.ResetGame();
|
||||
ShowNotification("Reset requested...");
|
||||
}
|
||||
|
||||
private void OnSendClicked()
|
||||
{
|
||||
SendCommand();
|
||||
}
|
||||
|
||||
private void OnCommandSubmit(string text)
|
||||
{
|
||||
SendCommand();
|
||||
}
|
||||
|
||||
private void SendCommand()
|
||||
{
|
||||
if (commandInput == null || string.IsNullOrWhiteSpace(commandInput.text))
|
||||
return;
|
||||
|
||||
NetworkManager.Instance.SendCommand(commandInput.text);
|
||||
commandInput.text = "";
|
||||
commandInput.ActivateInputField();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feed a specific agent by name.
|
||||
/// Called from UI buttons.
|
||||
/// </summary>
|
||||
public void FeedAgent(string agentName)
|
||||
{
|
||||
NetworkManager.Instance.FeedAgent(agentName);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Notifications
|
||||
private void ShowNotification(string message, bool isError = false)
|
||||
{
|
||||
if (notificationPanel == null || notificationText == null)
|
||||
return;
|
||||
|
||||
notificationText.text = message;
|
||||
notificationText.color = isError ? Color.red : Color.white;
|
||||
notificationPanel.SetActive(true);
|
||||
|
||||
// Auto-hide
|
||||
CancelInvoke(nameof(HideNotification));
|
||||
Invoke(nameof(HideNotification), notificationDuration);
|
||||
}
|
||||
|
||||
private void HideNotification()
|
||||
{
|
||||
if (notificationPanel != null)
|
||||
{
|
||||
notificationPanel.SetActive(false);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
2
unity-client/Assets/Scripts/GameManager.cs.meta
Normal file
2
unity-client/Assets/Scripts/GameManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c4363770de69e469b8cb0724bc222547
|
||||
168
unity-client/Assets/Scripts/Models.cs
Normal file
168
unity-client/Assets/Scripts/Models.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace TheIsland.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Root server message structure.
|
||||
/// Matches Python's GameEvent schema.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class ServerMessage
|
||||
{
|
||||
public string event_type;
|
||||
public double timestamp;
|
||||
// data is parsed separately based on event_type
|
||||
// We use a raw JSON approach for flexibility
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for deserializing the full message including raw data.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class RawServerMessage
|
||||
{
|
||||
public string event_type;
|
||||
public double timestamp;
|
||||
public string rawData; // Will be populated manually after initial parse
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agent data from the server.
|
||||
/// Matches Python's Agent.to_dict() output.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class AgentData
|
||||
{
|
||||
public int id;
|
||||
public string name;
|
||||
public string personality;
|
||||
public string status; // "Alive", "Dead", "Exiled"
|
||||
public int hp;
|
||||
public int energy;
|
||||
public string inventory;
|
||||
|
||||
public bool IsAlive => status == "Alive";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agents update event data.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class AgentsUpdateData
|
||||
{
|
||||
public List<AgentData> agents;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agent speak event data (LLM response).
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class AgentSpeakData
|
||||
{
|
||||
public int agent_id;
|
||||
public string agent_name;
|
||||
public string text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feed event data.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class FeedEventData
|
||||
{
|
||||
public string user;
|
||||
public string agent_name;
|
||||
public int energy_restored;
|
||||
public int agent_energy;
|
||||
public int user_gold;
|
||||
public string message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Agent death event data.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class AgentDiedData
|
||||
{
|
||||
public string agent_name;
|
||||
public string message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tick event data.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class TickData
|
||||
{
|
||||
public int tick;
|
||||
public int day;
|
||||
public int alive_agents;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// System/Error event data.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class SystemEventData
|
||||
{
|
||||
public string message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User update event data.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class UserUpdateData
|
||||
{
|
||||
public string user;
|
||||
public int gold;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// World state data.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class WorldStateData
|
||||
{
|
||||
public int day_count;
|
||||
public string weather;
|
||||
public int resource_level;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client message structure for sending to server.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class ClientMessage
|
||||
{
|
||||
public string action;
|
||||
public ClientPayload payload;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ClientPayload
|
||||
{
|
||||
public string user;
|
||||
public string message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event type constants matching Python's EventType enum.
|
||||
/// </summary>
|
||||
public static class EventTypes
|
||||
{
|
||||
public const string COMMENT = "comment";
|
||||
public const string TICK = "tick";
|
||||
public const string SYSTEM = "system";
|
||||
public const string ERROR = "error";
|
||||
public const string AGENTS_UPDATE = "agents_update";
|
||||
public const string AGENT_DIED = "agent_died";
|
||||
public const string AGENT_SPEAK = "agent_speak";
|
||||
public const string FEED = "feed";
|
||||
public const string USER_UPDATE = "user_update";
|
||||
public const string WORLD_UPDATE = "world_update";
|
||||
public const string CHECK = "check";
|
||||
}
|
||||
}
|
||||
2
unity-client/Assets/Scripts/Models.cs.meta
Normal file
2
unity-client/Assets/Scripts/Models.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4e83c064dba71454aaeba0f96e188565
|
||||
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
|
||||
}
|
||||
}
|
||||
2
unity-client/Assets/Scripts/NetworkManager.cs.meta
Normal file
2
unity-client/Assets/Scripts/NetworkManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1de9aa376a6314824831873dd3138252
|
||||
380
unity-client/Assets/Scripts/SpeechBubble.cs
Normal file
380
unity-client/Assets/Scripts/SpeechBubble.cs
Normal file
@@ -0,0 +1,380 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using TMPro;
|
||||
|
||||
namespace TheIsland.Visual
|
||||
{
|
||||
/// <summary>
|
||||
/// Enhanced speech bubble with pop-in animation and auto-hide.
|
||||
/// Can be used as a standalone prefab or created programmatically.
|
||||
/// </summary>
|
||||
public class SpeechBubble : MonoBehaviour
|
||||
{
|
||||
#region Configuration
|
||||
[Header("Visual Settings")]
|
||||
[SerializeField] private float maxWidth = 350f;
|
||||
[SerializeField] private float padding = 20f;
|
||||
[SerializeField] private Color bubbleColor = new Color(1f, 1f, 1f, 0.95f);
|
||||
[SerializeField] private Color textColor = new Color(0.15f, 0.15f, 0.15f, 1f);
|
||||
[SerializeField] private Color outlineColor = new Color(0.3f, 0.3f, 0.3f, 1f);
|
||||
|
||||
[Header("Animation Settings")]
|
||||
[SerializeField] private float popInDuration = 0.25f;
|
||||
[SerializeField] private float displayDuration = 5f;
|
||||
[SerializeField] private float fadeOutDuration = 0.3f;
|
||||
[SerializeField] private AnimationCurve popInCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
|
||||
|
||||
[Header("Bounce Effect")]
|
||||
[SerializeField] private bool enableBounce = true;
|
||||
[SerializeField] private float bounceScale = 1.1f;
|
||||
[SerializeField] private float bounceBackDuration = 0.1f;
|
||||
|
||||
[Header("Typewriter Effect")]
|
||||
[SerializeField] private bool enableTypewriter = false;
|
||||
[SerializeField] private float typewriterSpeed = 30f; // characters per second
|
||||
#endregion
|
||||
|
||||
#region UI References
|
||||
private RectTransform _rectTransform;
|
||||
private Image _bubbleBackground;
|
||||
private Image _bubbleOutline;
|
||||
private TextMeshProUGUI _textComponent;
|
||||
private GameObject _tailObject;
|
||||
private CanvasGroup _canvasGroup;
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
private Coroutine _currentAnimation;
|
||||
private Coroutine _autoHideCoroutine;
|
||||
private string _fullText;
|
||||
private bool _isShowing;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
public bool IsShowing => _isShowing;
|
||||
public float DisplayDuration
|
||||
{
|
||||
get => displayDuration;
|
||||
set => displayDuration = value;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
private void Awake()
|
||||
{
|
||||
CreateBubbleUI();
|
||||
// Start hidden
|
||||
transform.localScale = Vector3.zero;
|
||||
_isShowing = false;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region UI Creation
|
||||
private void CreateBubbleUI()
|
||||
{
|
||||
// Ensure we have a RectTransform
|
||||
_rectTransform = GetComponent<RectTransform>();
|
||||
if (_rectTransform == null)
|
||||
{
|
||||
_rectTransform = gameObject.AddComponent<RectTransform>();
|
||||
}
|
||||
_rectTransform.sizeDelta = new Vector2(maxWidth, 80);
|
||||
|
||||
// Add CanvasGroup for fading
|
||||
_canvasGroup = gameObject.AddComponent<CanvasGroup>();
|
||||
|
||||
// Create outline (slightly larger background)
|
||||
var outlineObj = new GameObject("Outline");
|
||||
outlineObj.transform.SetParent(transform);
|
||||
outlineObj.transform.localPosition = Vector3.zero;
|
||||
outlineObj.transform.localRotation = Quaternion.identity;
|
||||
outlineObj.transform.localScale = Vector3.one;
|
||||
|
||||
_bubbleOutline = outlineObj.AddComponent<Image>();
|
||||
_bubbleOutline.color = outlineColor;
|
||||
var outlineRect = outlineObj.GetComponent<RectTransform>();
|
||||
outlineRect.anchorMin = Vector2.zero;
|
||||
outlineRect.anchorMax = Vector2.one;
|
||||
outlineRect.offsetMin = new Vector2(-3, -3);
|
||||
outlineRect.offsetMax = new Vector2(3, 3);
|
||||
|
||||
// Create main background
|
||||
var bgObj = new GameObject("Background");
|
||||
bgObj.transform.SetParent(transform);
|
||||
bgObj.transform.localPosition = Vector3.zero;
|
||||
bgObj.transform.localRotation = Quaternion.identity;
|
||||
bgObj.transform.localScale = Vector3.one;
|
||||
|
||||
_bubbleBackground = bgObj.AddComponent<Image>();
|
||||
_bubbleBackground.color = bubbleColor;
|
||||
var bgRect = bgObj.GetComponent<RectTransform>();
|
||||
bgRect.anchorMin = Vector2.zero;
|
||||
bgRect.anchorMax = Vector2.one;
|
||||
bgRect.offsetMin = Vector2.zero;
|
||||
bgRect.offsetMax = Vector2.zero;
|
||||
|
||||
// Create text
|
||||
var textObj = new GameObject("Text");
|
||||
textObj.transform.SetParent(transform);
|
||||
textObj.transform.localPosition = Vector3.zero;
|
||||
textObj.transform.localRotation = Quaternion.identity;
|
||||
textObj.transform.localScale = Vector3.one;
|
||||
|
||||
_textComponent = textObj.AddComponent<TextMeshProUGUI>();
|
||||
_textComponent.fontSize = 22;
|
||||
_textComponent.color = textColor;
|
||||
_textComponent.alignment = TextAlignmentOptions.Center;
|
||||
_textComponent.textWrappingMode = TextWrappingModes.Normal;
|
||||
_textComponent.overflowMode = TextOverflowModes.Ellipsis;
|
||||
_textComponent.margin = new Vector4(padding, padding * 0.5f, padding, padding * 0.5f);
|
||||
|
||||
var textRect = _textComponent.rectTransform;
|
||||
textRect.anchorMin = Vector2.zero;
|
||||
textRect.anchorMax = Vector2.one;
|
||||
textRect.offsetMin = Vector2.zero;
|
||||
textRect.offsetMax = Vector2.zero;
|
||||
|
||||
// Create tail (triangle pointing down)
|
||||
_tailObject = CreateTail();
|
||||
}
|
||||
|
||||
private GameObject CreateTail()
|
||||
{
|
||||
var tail = new GameObject("Tail");
|
||||
tail.transform.SetParent(transform);
|
||||
tail.transform.localRotation = Quaternion.identity;
|
||||
tail.transform.localScale = Vector3.one;
|
||||
|
||||
var tailRect = tail.AddComponent<RectTransform>();
|
||||
tailRect.anchorMin = new Vector2(0.5f, 0);
|
||||
tailRect.anchorMax = new Vector2(0.5f, 0);
|
||||
tailRect.pivot = new Vector2(0.5f, 1);
|
||||
tailRect.anchoredPosition = new Vector2(0, 0);
|
||||
tailRect.sizeDelta = new Vector2(24, 16);
|
||||
|
||||
// Create a simple triangle using UI Image with a sprite
|
||||
// For now, use a simple downward-pointing shape
|
||||
var tailImage = tail.AddComponent<Image>();
|
||||
tailImage.color = bubbleColor;
|
||||
|
||||
// Note: For a proper triangle, you'd use a custom sprite.
|
||||
// This creates a simple rectangle as placeholder.
|
||||
// In production, replace with a triangle sprite.
|
||||
|
||||
return tail;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Public API
|
||||
/// <summary>
|
||||
/// Setup and show the speech bubble with the given text.
|
||||
/// </summary>
|
||||
public void Setup(string text)
|
||||
{
|
||||
_fullText = text;
|
||||
|
||||
// Stop any existing animations
|
||||
StopAllAnimations();
|
||||
|
||||
// Set text (either immediate or typewriter)
|
||||
if (enableTypewriter)
|
||||
{
|
||||
_textComponent.text = "";
|
||||
StartCoroutine(TypewriterEffect(text));
|
||||
}
|
||||
else
|
||||
{
|
||||
_textComponent.text = text;
|
||||
}
|
||||
|
||||
// Auto-size the bubble based on text
|
||||
AdjustSizeToContent();
|
||||
|
||||
// Start show animation
|
||||
_currentAnimation = StartCoroutine(PopInAnimation());
|
||||
|
||||
// Schedule auto-hide
|
||||
_autoHideCoroutine = StartCoroutine(AutoHideAfterDelay());
|
||||
|
||||
_isShowing = true;
|
||||
Debug.Log($"[SpeechBubble] Showing: \"{text}\"");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immediately hide the bubble.
|
||||
/// </summary>
|
||||
public void Hide()
|
||||
{
|
||||
StopAllAnimations();
|
||||
StartCoroutine(FadeOutAnimation());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update bubble colors at runtime.
|
||||
/// </summary>
|
||||
public void SetColors(Color bubble, Color text, Color outline)
|
||||
{
|
||||
bubbleColor = bubble;
|
||||
textColor = text;
|
||||
outlineColor = outline;
|
||||
|
||||
if (_bubbleBackground != null) _bubbleBackground.color = bubbleColor;
|
||||
if (_textComponent != null) _textComponent.color = textColor;
|
||||
if (_bubbleOutline != null) _bubbleOutline.color = outlineColor;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Animations
|
||||
private IEnumerator PopInAnimation()
|
||||
{
|
||||
float elapsed = 0f;
|
||||
_canvasGroup.alpha = 1f;
|
||||
|
||||
// Pop in from zero to slightly larger than target
|
||||
while (elapsed < popInDuration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float t = elapsed / popInDuration;
|
||||
float curveValue = popInCurve.Evaluate(t);
|
||||
|
||||
float targetScale = enableBounce ? bounceScale : 1f;
|
||||
transform.localScale = Vector3.one * (curveValue * targetScale);
|
||||
|
||||
yield return null;
|
||||
}
|
||||
|
||||
// Bounce back to normal size
|
||||
if (enableBounce)
|
||||
{
|
||||
elapsed = 0f;
|
||||
while (elapsed < bounceBackDuration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float t = elapsed / bounceBackDuration;
|
||||
float scale = Mathf.Lerp(bounceScale, 1f, t);
|
||||
transform.localScale = Vector3.one * scale;
|
||||
yield return null;
|
||||
}
|
||||
}
|
||||
|
||||
transform.localScale = Vector3.one;
|
||||
_currentAnimation = null;
|
||||
}
|
||||
|
||||
private IEnumerator FadeOutAnimation()
|
||||
{
|
||||
float elapsed = 0f;
|
||||
float startAlpha = _canvasGroup.alpha;
|
||||
Vector3 startScale = transform.localScale;
|
||||
|
||||
while (elapsed < fadeOutDuration)
|
||||
{
|
||||
elapsed += Time.deltaTime;
|
||||
float t = elapsed / fadeOutDuration;
|
||||
|
||||
_canvasGroup.alpha = Mathf.Lerp(startAlpha, 0f, t);
|
||||
transform.localScale = Vector3.Lerp(startScale, Vector3.zero, t);
|
||||
|
||||
yield return null;
|
||||
}
|
||||
|
||||
_canvasGroup.alpha = 0f;
|
||||
transform.localScale = Vector3.zero;
|
||||
_isShowing = false;
|
||||
_currentAnimation = null;
|
||||
}
|
||||
|
||||
private IEnumerator TypewriterEffect(string text)
|
||||
{
|
||||
int charCount = 0;
|
||||
float timer = 0f;
|
||||
float charInterval = 1f / typewriterSpeed;
|
||||
|
||||
while (charCount < text.Length)
|
||||
{
|
||||
timer += Time.deltaTime;
|
||||
|
||||
while (timer >= charInterval && charCount < text.Length)
|
||||
{
|
||||
timer -= charInterval;
|
||||
charCount++;
|
||||
_textComponent.text = text.Substring(0, charCount);
|
||||
|
||||
// Re-adjust size as text grows
|
||||
AdjustSizeToContent();
|
||||
}
|
||||
|
||||
yield return null;
|
||||
}
|
||||
|
||||
_textComponent.text = text;
|
||||
}
|
||||
|
||||
private IEnumerator AutoHideAfterDelay()
|
||||
{
|
||||
yield return new WaitForSeconds(displayDuration);
|
||||
Hide();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
private void StopAllAnimations()
|
||||
{
|
||||
if (_currentAnimation != null)
|
||||
{
|
||||
StopCoroutine(_currentAnimation);
|
||||
_currentAnimation = null;
|
||||
}
|
||||
|
||||
if (_autoHideCoroutine != null)
|
||||
{
|
||||
StopCoroutine(_autoHideCoroutine);
|
||||
_autoHideCoroutine = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void AdjustSizeToContent()
|
||||
{
|
||||
if (_textComponent == null || _rectTransform == null) return;
|
||||
|
||||
// Force mesh update to get accurate preferred values
|
||||
_textComponent.ForceMeshUpdate();
|
||||
|
||||
// Get preferred size
|
||||
Vector2 preferredSize = _textComponent.GetPreferredValues();
|
||||
|
||||
// Add padding
|
||||
float width = Mathf.Min(preferredSize.x + padding * 2, maxWidth);
|
||||
float height = preferredSize.y + padding;
|
||||
|
||||
// If text is wider than max, recalculate height for wrapped text
|
||||
if (preferredSize.x > maxWidth - padding * 2)
|
||||
{
|
||||
_textComponent.ForceMeshUpdate();
|
||||
height = _textComponent.GetPreferredValues(maxWidth - padding * 2, 0).y + padding;
|
||||
width = maxWidth;
|
||||
}
|
||||
|
||||
_rectTransform.sizeDelta = new Vector2(width, height);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Static Factory
|
||||
/// <summary>
|
||||
/// Create a speech bubble as a child of the specified parent.
|
||||
/// </summary>
|
||||
public static SpeechBubble Create(Transform parent, Vector3 localPosition)
|
||||
{
|
||||
var bubbleObj = new GameObject("SpeechBubble");
|
||||
bubbleObj.transform.SetParent(parent);
|
||||
bubbleObj.transform.localPosition = localPosition;
|
||||
bubbleObj.transform.localRotation = Quaternion.identity;
|
||||
bubbleObj.transform.localScale = Vector3.one;
|
||||
|
||||
var bubble = bubbleObj.AddComponent<SpeechBubble>();
|
||||
return bubble;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
2
unity-client/Assets/Scripts/SpeechBubble.cs.meta
Normal file
2
unity-client/Assets/Scripts/SpeechBubble.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b9a0fda2a4f5f4aa7a7827f97ff39ce5
|
||||
526
unity-client/Assets/Scripts/UIManager.cs
Normal file
526
unity-client/Assets/Scripts/UIManager.cs
Normal file
@@ -0,0 +1,526 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.EventSystems;
|
||||
using TMPro;
|
||||
using TheIsland.Network;
|
||||
using TheIsland.Models;
|
||||
|
||||
namespace TheIsland.UI
|
||||
{
|
||||
/// <summary>
|
||||
/// Main UI Manager - Creates and manages the game's UI canvas programmatically.
|
||||
/// Attach this to an empty GameObject, it will create all UI elements automatically.
|
||||
/// </summary>
|
||||
public class UIManager : MonoBehaviour
|
||||
{
|
||||
#region Singleton
|
||||
private static UIManager _instance;
|
||||
public static UIManager Instance => _instance;
|
||||
#endregion
|
||||
|
||||
#region UI References (Auto-created)
|
||||
private Canvas _canvas;
|
||||
private TextMeshProUGUI _connectionStatus;
|
||||
private TextMeshProUGUI _goldDisplay;
|
||||
private TextMeshProUGUI _tickInfo;
|
||||
private TMP_InputField _commandInput;
|
||||
private Button _sendButton;
|
||||
private Button _resetButton;
|
||||
private GameObject _notificationPanel;
|
||||
private TextMeshProUGUI _notificationText;
|
||||
#endregion
|
||||
|
||||
#region State
|
||||
private int _playerGold = 100;
|
||||
private int _currentDay = 1;
|
||||
private int _currentTick = 0;
|
||||
private int _aliveCount = 3;
|
||||
#endregion
|
||||
|
||||
#region Unity Lifecycle
|
||||
private void Awake()
|
||||
{
|
||||
if (_instance != null && _instance != this)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
_instance = this;
|
||||
|
||||
CreateUI();
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
SubscribeToEvents();
|
||||
UpdateAllUI();
|
||||
|
||||
// Check if already connected (in case we missed the event)
|
||||
if (NetworkManager.Instance != null && NetworkManager.Instance.IsConnected)
|
||||
{
|
||||
OnConnected();
|
||||
}
|
||||
|
||||
// Start periodic connection check
|
||||
InvokeRepeating(nameof(CheckConnectionStatus), 1f, 1f);
|
||||
}
|
||||
|
||||
private void CheckConnectionStatus()
|
||||
{
|
||||
if (NetworkManager.Instance != null && NetworkManager.Instance.IsConnected)
|
||||
{
|
||||
if (_connectionStatus != null && _connectionStatus.text.Contains("Disconnected"))
|
||||
{
|
||||
OnConnected();
|
||||
Debug.Log("[UIManager] Connection detected via periodic check");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
UnsubscribeFromEvents();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Event Subscription
|
||||
private void SubscribeToEvents()
|
||||
{
|
||||
var network = NetworkManager.Instance;
|
||||
if (network == null)
|
||||
{
|
||||
Debug.LogError("[UIManager] NetworkManager.Instance is null!");
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log($"[UIManager] Subscribing to NetworkManager events (Instance ID: {network.GetInstanceID()})");
|
||||
network.OnConnected += OnConnected;
|
||||
network.OnDisconnected += OnDisconnected;
|
||||
network.OnTick += OnTick;
|
||||
network.OnFeed += OnFeed;
|
||||
network.OnUserUpdate += OnUserUpdate;
|
||||
network.OnAgentDied += OnAgentDied;
|
||||
network.OnSystemMessage += OnSystemMessage;
|
||||
network.OnAgentsUpdate += OnAgentsUpdate;
|
||||
}
|
||||
|
||||
private void UnsubscribeFromEvents()
|
||||
{
|
||||
var network = NetworkManager.Instance;
|
||||
if (network == null) return;
|
||||
|
||||
network.OnConnected -= OnConnected;
|
||||
network.OnDisconnected -= OnDisconnected;
|
||||
network.OnTick -= OnTick;
|
||||
network.OnFeed -= OnFeed;
|
||||
network.OnUserUpdate -= OnUserUpdate;
|
||||
network.OnAgentDied -= OnAgentDied;
|
||||
network.OnSystemMessage -= OnSystemMessage;
|
||||
network.OnAgentsUpdate -= OnAgentsUpdate;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region UI Creation
|
||||
private void CreateUI()
|
||||
{
|
||||
// Create EventSystem if not exists (required for UI input)
|
||||
if (FindAnyObjectByType<EventSystem>() == null)
|
||||
{
|
||||
var eventSystemObj = new GameObject("EventSystem");
|
||||
eventSystemObj.AddComponent<EventSystem>();
|
||||
eventSystemObj.AddComponent<StandaloneInputModule>();
|
||||
Debug.Log("[UIManager] Created EventSystem for UI input");
|
||||
}
|
||||
|
||||
// Create Canvas
|
||||
var canvasObj = new GameObject("GameCanvas");
|
||||
canvasObj.transform.SetParent(transform);
|
||||
_canvas = canvasObj.AddComponent<Canvas>();
|
||||
_canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
_canvas.sortingOrder = 100;
|
||||
|
||||
var scaler = canvasObj.AddComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920, 1080);
|
||||
|
||||
canvasObj.AddComponent<GraphicRaycaster>();
|
||||
|
||||
// Create UI Elements
|
||||
CreateTopBar();
|
||||
CreateBottomBar();
|
||||
CreateNotificationPanel();
|
||||
}
|
||||
|
||||
private void CreateTopBar()
|
||||
{
|
||||
// Top bar container
|
||||
var topBar = CreatePanel("TopBar", new Vector2(0, 1), new Vector2(0, 1),
|
||||
new Vector2(0, -10), new Vector2(0, 60));
|
||||
topBar.anchorMin = new Vector2(0, 1);
|
||||
topBar.anchorMax = new Vector2(1, 1);
|
||||
topBar.offsetMin = new Vector2(10, -70);
|
||||
topBar.offsetMax = new Vector2(-10, -10);
|
||||
|
||||
var topBarImg = topBar.gameObject.AddComponent<Image>();
|
||||
topBarImg.color = new Color(0, 0, 0, 0.7f);
|
||||
|
||||
// Connection Status (Left)
|
||||
_connectionStatus = CreateText(topBar, "ConnectionStatus", "● Disconnected",
|
||||
TextAlignmentOptions.Left, 24, Color.red);
|
||||
var connRect = _connectionStatus.rectTransform;
|
||||
connRect.anchorMin = new Vector2(0, 0);
|
||||
connRect.anchorMax = new Vector2(0.3f, 1);
|
||||
connRect.offsetMin = new Vector2(20, 10);
|
||||
connRect.offsetMax = new Vector2(0, -10);
|
||||
|
||||
// Tick Info (Center)
|
||||
_tickInfo = CreateText(topBar, "TickInfo", "Day 1 | Tick 0 | Alive: 3",
|
||||
TextAlignmentOptions.Center, 22, Color.white);
|
||||
var tickRect = _tickInfo.rectTransform;
|
||||
tickRect.anchorMin = new Vector2(0.3f, 0);
|
||||
tickRect.anchorMax = new Vector2(0.7f, 1);
|
||||
tickRect.offsetMin = new Vector2(0, 10);
|
||||
tickRect.offsetMax = new Vector2(0, -10);
|
||||
|
||||
// Gold Display (Right)
|
||||
_goldDisplay = CreateText(topBar, "GoldDisplay", "[G] 100 Gold",
|
||||
TextAlignmentOptions.Right, 28, new Color(1f, 0.84f, 0f));
|
||||
var goldRect = _goldDisplay.rectTransform;
|
||||
goldRect.anchorMin = new Vector2(0.7f, 0);
|
||||
goldRect.anchorMax = new Vector2(1, 1);
|
||||
goldRect.offsetMin = new Vector2(0, 10);
|
||||
goldRect.offsetMax = new Vector2(-20, -10);
|
||||
}
|
||||
|
||||
private void CreateBottomBar()
|
||||
{
|
||||
// Bottom bar container
|
||||
var bottomBar = CreatePanel("BottomBar", new Vector2(0, 0), new Vector2(1, 0),
|
||||
new Vector2(10, 10), new Vector2(-10, 70));
|
||||
bottomBar.anchorMin = new Vector2(0, 0);
|
||||
bottomBar.anchorMax = new Vector2(1, 0);
|
||||
bottomBar.offsetMin = new Vector2(10, 10);
|
||||
bottomBar.offsetMax = new Vector2(-10, 70);
|
||||
|
||||
var bottomBarImg = bottomBar.gameObject.AddComponent<Image>();
|
||||
bottomBarImg.color = new Color(0, 0, 0, 0.7f);
|
||||
|
||||
// Command Input
|
||||
var inputObj = new GameObject("CommandInput");
|
||||
inputObj.transform.SetParent(bottomBar);
|
||||
|
||||
// Add RectTransform first (required for UI elements)
|
||||
var inputRect = inputObj.AddComponent<RectTransform>();
|
||||
|
||||
_commandInput = inputObj.AddComponent<TMP_InputField>();
|
||||
inputRect.anchorMin = new Vector2(0, 0);
|
||||
inputRect.anchorMax = new Vector2(0.6f, 1);
|
||||
inputRect.offsetMin = new Vector2(10, 10);
|
||||
inputRect.offsetMax = new Vector2(-5, -10);
|
||||
|
||||
// Input background
|
||||
var inputBg = new GameObject("Background");
|
||||
inputBg.transform.SetParent(inputObj.transform);
|
||||
var inputBgImg = inputBg.AddComponent<Image>();
|
||||
inputBgImg.color = new Color(0.2f, 0.2f, 0.2f, 1f);
|
||||
var inputBgRect = inputBg.GetComponent<RectTransform>();
|
||||
inputBgRect.anchorMin = Vector2.zero;
|
||||
inputBgRect.anchorMax = Vector2.one;
|
||||
inputBgRect.offsetMin = Vector2.zero;
|
||||
inputBgRect.offsetMax = Vector2.zero;
|
||||
|
||||
// Input text area
|
||||
var textArea = new GameObject("Text Area");
|
||||
textArea.transform.SetParent(inputObj.transform);
|
||||
var textAreaRect = textArea.AddComponent<RectTransform>();
|
||||
textAreaRect.anchorMin = Vector2.zero;
|
||||
textAreaRect.anchorMax = Vector2.one;
|
||||
textAreaRect.offsetMin = new Vector2(10, 5);
|
||||
textAreaRect.offsetMax = new Vector2(-10, -5);
|
||||
|
||||
var inputText = new GameObject("Text");
|
||||
inputText.transform.SetParent(textArea.transform);
|
||||
var inputTMP = inputText.AddComponent<TextMeshProUGUI>();
|
||||
inputTMP.fontSize = 20;
|
||||
inputTMP.color = Color.white;
|
||||
var inputTextRect = inputText.GetComponent<RectTransform>();
|
||||
inputTextRect.anchorMin = Vector2.zero;
|
||||
inputTextRect.anchorMax = Vector2.one;
|
||||
inputTextRect.offsetMin = Vector2.zero;
|
||||
inputTextRect.offsetMax = Vector2.zero;
|
||||
|
||||
var placeholder = new GameObject("Placeholder");
|
||||
placeholder.transform.SetParent(textArea.transform);
|
||||
var placeholderTMP = placeholder.AddComponent<TextMeshProUGUI>();
|
||||
placeholderTMP.text = "Enter command (feed Jack, check, reset)...";
|
||||
placeholderTMP.fontSize = 20;
|
||||
placeholderTMP.fontStyle = FontStyles.Italic;
|
||||
placeholderTMP.color = new Color(0.5f, 0.5f, 0.5f);
|
||||
var placeholderRect = placeholder.GetComponent<RectTransform>();
|
||||
placeholderRect.anchorMin = Vector2.zero;
|
||||
placeholderRect.anchorMax = Vector2.one;
|
||||
placeholderRect.offsetMin = Vector2.zero;
|
||||
placeholderRect.offsetMax = Vector2.zero;
|
||||
|
||||
_commandInput.textViewport = textAreaRect;
|
||||
_commandInput.textComponent = inputTMP;
|
||||
_commandInput.placeholder = placeholderTMP;
|
||||
_commandInput.onSubmit.AddListener(OnCommandSubmit);
|
||||
|
||||
// Send Button
|
||||
_sendButton = CreateButton(bottomBar, "SendButton", "Send", new Color(0.3f, 0.7f, 0.3f), OnSendClicked);
|
||||
var sendRect = _sendButton.GetComponent<RectTransform>();
|
||||
sendRect.anchorMin = new Vector2(0.6f, 0);
|
||||
sendRect.anchorMax = new Vector2(0.78f, 1);
|
||||
sendRect.offsetMin = new Vector2(5, 10);
|
||||
sendRect.offsetMax = new Vector2(-5, -10);
|
||||
|
||||
// Reset Button
|
||||
_resetButton = CreateButton(bottomBar, "ResetButton", "Reset", new Color(0.8f, 0.3f, 0.3f), OnResetClicked);
|
||||
var resetRect = _resetButton.GetComponent<RectTransform>();
|
||||
resetRect.anchorMin = new Vector2(0.78f, 0);
|
||||
resetRect.anchorMax = new Vector2(1, 1);
|
||||
resetRect.offsetMin = new Vector2(5, 10);
|
||||
resetRect.offsetMax = new Vector2(-10, -10);
|
||||
}
|
||||
|
||||
private void CreateNotificationPanel()
|
||||
{
|
||||
_notificationPanel = new GameObject("NotificationPanel");
|
||||
_notificationPanel.transform.SetParent(_canvas.transform);
|
||||
|
||||
var panelRect = _notificationPanel.AddComponent<RectTransform>();
|
||||
panelRect.anchorMin = new Vector2(0.5f, 0.8f);
|
||||
panelRect.anchorMax = new Vector2(0.5f, 0.8f);
|
||||
panelRect.sizeDelta = new Vector2(600, 60);
|
||||
|
||||
var panelImg = _notificationPanel.AddComponent<Image>();
|
||||
panelImg.color = new Color(0.1f, 0.1f, 0.1f, 0.9f);
|
||||
|
||||
_notificationText = CreateText(panelRect, "NotificationText", "",
|
||||
TextAlignmentOptions.Center, 24, Color.white);
|
||||
var textRect = _notificationText.rectTransform;
|
||||
textRect.anchorMin = Vector2.zero;
|
||||
textRect.anchorMax = Vector2.one;
|
||||
textRect.offsetMin = new Vector2(20, 10);
|
||||
textRect.offsetMax = new Vector2(-20, -10);
|
||||
|
||||
_notificationPanel.SetActive(false);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region UI Helpers
|
||||
private RectTransform CreatePanel(string name, Vector2 anchorMin, Vector2 anchorMax,
|
||||
Vector2 offsetMin, Vector2 offsetMax)
|
||||
{
|
||||
var panel = new GameObject(name);
|
||||
panel.transform.SetParent(_canvas.transform);
|
||||
|
||||
var rect = panel.AddComponent<RectTransform>();
|
||||
rect.anchorMin = anchorMin;
|
||||
rect.anchorMax = anchorMax;
|
||||
rect.offsetMin = offsetMin;
|
||||
rect.offsetMax = offsetMax;
|
||||
|
||||
return rect;
|
||||
}
|
||||
|
||||
private TextMeshProUGUI CreateText(Transform parent, string name, string text,
|
||||
TextAlignmentOptions alignment, float fontSize, Color color)
|
||||
{
|
||||
var textObj = new GameObject(name);
|
||||
textObj.transform.SetParent(parent);
|
||||
|
||||
var tmp = textObj.AddComponent<TextMeshProUGUI>();
|
||||
tmp.text = text;
|
||||
tmp.alignment = alignment;
|
||||
tmp.fontSize = fontSize;
|
||||
tmp.color = color;
|
||||
|
||||
var rect = textObj.GetComponent<RectTransform>();
|
||||
rect.anchorMin = Vector2.zero;
|
||||
rect.anchorMax = Vector2.one;
|
||||
rect.offsetMin = Vector2.zero;
|
||||
rect.offsetMax = Vector2.zero;
|
||||
|
||||
return tmp;
|
||||
}
|
||||
|
||||
private Button CreateButton(Transform parent, string name, string text, Color bgColor,
|
||||
UnityEngine.Events.UnityAction onClick)
|
||||
{
|
||||
var btnObj = new GameObject(name);
|
||||
btnObj.transform.SetParent(parent);
|
||||
|
||||
var btnImg = btnObj.AddComponent<Image>();
|
||||
btnImg.color = bgColor;
|
||||
|
||||
var btn = btnObj.AddComponent<Button>();
|
||||
btn.targetGraphic = btnImg;
|
||||
btn.onClick.AddListener(onClick);
|
||||
|
||||
var btnText = CreateText(btnObj.transform, "Text", text,
|
||||
TextAlignmentOptions.Center, 22, Color.white);
|
||||
|
||||
return btn;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Event Handlers
|
||||
private void OnConnected()
|
||||
{
|
||||
Debug.Log("[UIManager] OnConnected called!");
|
||||
if (_connectionStatus == null)
|
||||
{
|
||||
Debug.LogError("[UIManager] _connectionStatus is null!");
|
||||
return;
|
||||
}
|
||||
_connectionStatus.text = "● Connected";
|
||||
_connectionStatus.color = Color.green;
|
||||
ShowNotification("Connected to The Island!", Color.green);
|
||||
}
|
||||
|
||||
private void OnDisconnected()
|
||||
{
|
||||
_connectionStatus.text = "● Disconnected";
|
||||
_connectionStatus.color = Color.red;
|
||||
ShowNotification("Disconnected from server", Color.red);
|
||||
}
|
||||
|
||||
private void OnTick(TickData data)
|
||||
{
|
||||
// If we're receiving tick events, we ARE connected
|
||||
EnsureConnectedStatus();
|
||||
|
||||
_currentDay = data.day;
|
||||
_currentTick = data.tick;
|
||||
_aliveCount = data.alive_agents;
|
||||
UpdateTickInfo();
|
||||
}
|
||||
|
||||
private void EnsureConnectedStatus()
|
||||
{
|
||||
// If status shows disconnected but we're receiving events, update it
|
||||
if (_connectionStatus != null && _connectionStatus.color == Color.red)
|
||||
{
|
||||
OnConnected();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFeed(FeedEventData data)
|
||||
{
|
||||
if (data.user == NetworkManager.Instance.Username)
|
||||
{
|
||||
_playerGold = data.user_gold;
|
||||
UpdateGoldDisplay();
|
||||
}
|
||||
ShowNotification(data.message, new Color(1f, 0.7f, 0.3f));
|
||||
}
|
||||
|
||||
private void OnUserUpdate(UserUpdateData data)
|
||||
{
|
||||
if (data.user == NetworkManager.Instance.Username)
|
||||
{
|
||||
_playerGold = data.gold;
|
||||
UpdateGoldDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAgentDied(AgentDiedData data)
|
||||
{
|
||||
ShowNotification(data.message, Color.red);
|
||||
}
|
||||
|
||||
private void OnSystemMessage(SystemEventData data)
|
||||
{
|
||||
ShowNotification(data.message, Color.cyan);
|
||||
}
|
||||
|
||||
private void OnAgentsUpdate(System.Collections.Generic.List<AgentData> agents)
|
||||
{
|
||||
// If we're receiving agents events, we ARE connected
|
||||
EnsureConnectedStatus();
|
||||
|
||||
if (agents != null)
|
||||
{
|
||||
_aliveCount = 0;
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
if (agent.IsAlive) _aliveCount++;
|
||||
}
|
||||
UpdateTickInfo();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCommandSubmit(string text)
|
||||
{
|
||||
SendCommand();
|
||||
}
|
||||
|
||||
private void OnSendClicked()
|
||||
{
|
||||
SendCommand();
|
||||
}
|
||||
|
||||
private void OnResetClicked()
|
||||
{
|
||||
NetworkManager.Instance.ResetGame();
|
||||
ShowNotification("Reset requested...", Color.yellow);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region UI Updates
|
||||
private void UpdateAllUI()
|
||||
{
|
||||
UpdateGoldDisplay();
|
||||
UpdateTickInfo();
|
||||
}
|
||||
|
||||
private void UpdateGoldDisplay()
|
||||
{
|
||||
if (_goldDisplay != null)
|
||||
{
|
||||
_goldDisplay.text = $"[G] {_playerGold} Gold";
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateTickInfo()
|
||||
{
|
||||
if (_tickInfo != null)
|
||||
{
|
||||
_tickInfo.text = $"Day {_currentDay} | Tick {_currentTick} | Alive: {_aliveCount}";
|
||||
}
|
||||
}
|
||||
|
||||
private void SendCommand()
|
||||
{
|
||||
if (_commandInput == null || string.IsNullOrWhiteSpace(_commandInput.text))
|
||||
return;
|
||||
|
||||
NetworkManager.Instance.SendCommand(_commandInput.text);
|
||||
_commandInput.text = "";
|
||||
_commandInput.ActivateInputField();
|
||||
}
|
||||
|
||||
public void ShowNotification(string message, Color color)
|
||||
{
|
||||
if (_notificationPanel == null || _notificationText == null) return;
|
||||
|
||||
_notificationText.text = message;
|
||||
_notificationText.color = color;
|
||||
_notificationPanel.SetActive(true);
|
||||
|
||||
CancelInvoke(nameof(HideNotification));
|
||||
Invoke(nameof(HideNotification), 3f);
|
||||
}
|
||||
|
||||
private void HideNotification()
|
||||
{
|
||||
if (_notificationPanel != null)
|
||||
{
|
||||
_notificationPanel.SetActive(false);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
2
unity-client/Assets/Scripts/UIManager.cs.meta
Normal file
2
unity-client/Assets/Scripts/UIManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0b48eca8b69d84ee3b2475841467cb9e
|
||||
Reference in New Issue
Block a user