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:
empty
2026-01-01 12:15:53 +08:00
parent e96948e8a4
commit 64ed46215f
889 changed files with 155753 additions and 0 deletions

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0a8e42c6083a64b87be1c87a91eb00a8

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6c640bb4732a04c21a655d0de775bd29

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d8a016bdb4b93424da387c520b59cbfb

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0fab5abc5a02446439960d2d7da16753

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1725c886e32c44bc0a029d628dd0e5de

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c4363770de69e469b8cb0724bc222547

View 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";
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4e83c064dba71454aaeba0f96e188565

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1de9aa376a6314824831873dd3138252

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b9a0fda2a4f5f4aa7a7827f97ff39ce5

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

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0b48eca8b69d84ee3b2475841467cb9e