From dee374286edb6ddf160b63c71f707e0db165fe4c Mon Sep 17 00:00:00 2001 From: empty Date: Fri, 2 Jan 2026 01:48:32 +0800 Subject: [PATCH] feat: Implement NavMesh pathfinding and Deep Social visuals - Phase 20-F: NavMesh Integration - Added 'com.unity.ai.navigation' package - Implemented Runtime NavMesh Baking in EnvironmentManager - Added NavMeshObstacle to environmental assets - Updated AgentVisual to use NavMeshAgent for movement - Implemented 'Instinctive Avoidance' via target offsetting - Phase 21: Social Interaction & Expressions - Added procedural Dance and Wave animations in AgentAnimator - Implemented 'Dance Party' triggering logic in engine.py and AgentVisual - Added social relationship syncing (Backend -> Frontend) - Implemented proximity-based social greetings (Heart emote + Wave) - Updated Models.cs to support relationship data parsing --- backend/app/engine.py | 50 +++- backend/app/llm.py | 4 +- unity-client/Assets/Scripts/AgentVisual.cs | 225 ++++++++++++++++-- unity-client/Assets/Scripts/GameManager.cs | 3 + unity-client/Assets/Scripts/Models.cs | 14 ++ .../Assets/Scripts/Visual/AgentAnimator.cs | 49 ++++ .../Scripts/Visual/EnvironmentManager.cs | 44 +++- unity-client/Packages/manifest.json | 7 +- unity-client/Packages/packages-lock.json | 9 + 9 files changed, 373 insertions(+), 32 deletions(-) diff --git a/backend/app/engine.py b/backend/app/engine.py index 8a3e0b9..7fe14bd 100644 --- a/backend/app/engine.py +++ b/backend/app/engine.py @@ -202,9 +202,31 @@ class GameEngine: """Broadcast all agents' current status.""" with get_db_session() as db: agents = db.query(Agent).all() - agents_data = [agent.to_dict() for agent in agents] + agents_data = [] + for agent in agents: + data = agent.to_dict() + # Phase 21-B: Inject relationships + data["relationships"] = self._get_agent_relationships(db, agent.id) + agents_data.append(data) await self._broadcast_event(EventType.AGENTS_UPDATE, {"agents": agents_data}) + def _get_agent_relationships(self, db, agent_id: int) -> list: + """Fetch significant relationships for an agent.""" + # Phase 21-B: Only send non-stranger relationships to save bandwidth + rels = db.query(AgentRelationship).filter( + AgentRelationship.agent_from_id == agent_id, + AgentRelationship.relationship_type != "stranger" + ).all() + + results = [] + for r in rels: + results.append({ + "target_id": r.agent_to_id, + "type": r.relationship_type, + "affection": r.affection + }) + return results + async def _broadcast_world_status(self) -> None: """Broadcast world state.""" with get_db_session() as db: @@ -719,11 +741,31 @@ class GameEngine: target_name = friend.name should_update = True - # 3. Boredom / Wandering - elif agent.current_action == "Idle" or agent.current_action is None: + # Phase 21: Social Interaction (Group Dance) + # If Happy (>80) and near others, chance to start dancing + elif agent.mood > 80 and agent.current_action != "Dance": + # Check for nearby agents (same location) + nearby_count = 0 + for other in agents: + if other.id != agent.id and other.status == "Alive" and other.location == agent.location: + nearby_count += 1 + + # Dance Party Trigger! (Need at least 1 friend, 10% chance) + if nearby_count >= 1 and random.random() < 0.10: + new_action = "Dance" + # Keep location same + new_location = agent.location + should_update = True + + # 2. Idle Behavior (Default) + elif agent.current_action not in ["Sleep", "Chat", "Dance"]: + # Random chance to move nearby or chat if random.random() < 0.3: new_action = "Wander" - new_location = "nearby" + new_location = "nearby" # Will be randomized in Unity/GameManager mapping + should_update = True + elif random.random() < 0.1: + new_action = "Idle" should_update = True # 4. Finish Tasks (Simulation) diff --git a/backend/app/llm.py b/backend/app/llm.py index 8e884af..c801e05 100644 --- a/backend/app/llm.py +++ b/backend/app/llm.py @@ -212,7 +212,7 @@ class LLMService: f"Personality: {agent.personality}. " f"Current Status: HP={agent.hp}, Energy={agent.energy}. " f"Shelter Status: {'Under shelter (safe from weather)' if agent.is_sheltered else 'Exposed (vulnerable to weather)'}. " - f"You live on a survival island. " + f"You are a land creature on a survival island. You have a natural instinct to stay on the dry sand and avoid the deep ocean. " f"Relevant Memories:\n{memory_context}\n" f"React to the following event briefly (under 20 words). " f"Respond in first person, as if speaking out loud." @@ -278,7 +278,7 @@ class LLMService: f"Personality: {agent.personality}. " f"Current Status: HP={agent.hp}, Energy={agent.energy}. " f"Shelter Status: {'Under shelter (protected)' if agent.is_sheltered else 'Exposed to elements'}. " - f"You are stranded on a survival island. " + f"You are a land creature stranded on a survival island beach. You feel safer on dry land than near the waves. " f"It is currently {time_of_day} and the weather is {weather}. " f"Say something brief (under 15 words) about your situation or thoughts. " f"Speak naturally, as if talking to yourself or nearby survivors." diff --git a/unity-client/Assets/Scripts/AgentVisual.cs b/unity-client/Assets/Scripts/AgentVisual.cs index 2c76961..dc5a6d5 100644 --- a/unity-client/Assets/Scripts/AgentVisual.cs +++ b/unity-client/Assets/Scripts/AgentVisual.cs @@ -1,9 +1,12 @@ using System.Collections; +using System.Collections.Generic; using UnityEngine; +using UnityEngine.AI; using UnityEngine.UI; using TMPro; using TheIsland.Models; using TheIsland.Network; +using TheIsland.Core; // Added for VFXManager namespace TheIsland.Visual { @@ -65,6 +68,7 @@ namespace TheIsland.Visual private Billboard _uiBillboard; private Camera _mainCamera; private AgentAnimator _animator; + private NavMeshAgent _navAgent; // Added NavMeshAgent #endregion #region State @@ -86,6 +90,9 @@ namespace TheIsland.Visual private GameObject _shadowObj; private SpriteRenderer _shadowRenderer; private float _footstepTimer; + private float _lastEmoteTime; + private float _lastMoveTime; // Added for NavMesh movement + private float _lastFootstepTime; // Added for NavMesh movement // UI Smoothing (Phase 19) private float _currentHpPercent; @@ -94,6 +101,11 @@ namespace TheIsland.Visual private float _targetHpPercent; private float _targetEnergyPercent; private float _targetMoodPercent; + + // Phase 21-B: Social Visuals + private float _socialCheckTimer; + private Dictionary _relationships = new Dictionary(); + private Dictionary _lastGreetingTimes = new Dictionary(); // Cooldown per agent #endregion #region Properties @@ -112,6 +124,19 @@ namespace TheIsland.Visual _animator = GetComponent(); if (_animator == null) _animator = gameObject.AddComponent(); + // Phase 20-F: NavMeshAgent + _navAgent = GetComponent(); + if (_navAgent == null) _navAgent = gameObject.AddComponent(); + + _navAgent.speed = _moveSpeed; + _navAgent.acceleration = 12f; + _navAgent.angularSpeed = 0f; // 2D Sprite, no rotation + _navAgent.radius = 0.3f; // Small footprint + _navAgent.height = 1.5f; + _navAgent.updateRotation = false; + _navAgent.updateUpAxis = true; // Use 3D physics (X-Z plane) + _navAgent.obstacleAvoidanceType = ObstacleAvoidanceType.HighQualityObstacleAvoidance; + CreateVisuals(); CreateShadow(); _lastPosition = transform.position; @@ -149,35 +174,70 @@ namespace TheIsland.Visual private void Update() { - if (!IsAlive) return; + if (!IsAlive) + { + if (_navAgent.enabled) _navAgent.isStopped = true; + return; + } - // Phase 19-D: Apply soft-repulsion to prevent crowding - Vector3 repulsion = CalculateRepulsion(); + // Phase 21: Handle Dance/Action Disable + bool isDancing = (_currentData != null && _currentData.current_action == "Dance"); + if (_animator != null) _animator.SetDancing(isDancing); + + if (isDancing) + { + if (_navAgent.enabled) _navAgent.isStopped = true; + + if (Time.time - _lastEmoteTime > 2f) + { + ShowEmotion("music"); + _lastEmoteTime = Time.time; + } + return; + } - // Handle Movement + if (_navAgent.enabled) _navAgent.isStopped = false; + + // Handle Movement via NavMesh if (_isMoving) { - // Simple steering toward target - Vector3 moveDir = (_targetPosition - transform.position).normalized; - Vector3 finalVelocity = (moveDir * _moveSpeed) + repulsion; - - transform.position += finalVelocity * Time.deltaTime; + // Phase 20-E: Apply soft constraints to target before setting destination + // We offset the target based on shoreline repulsion, rather than applying force to velocity + Vector3 instinctOffset = CalculateInstinctOffset(_targetPosition); + Vector3 safeTarget = _targetPosition + instinctOffset; + + _navAgent.SetDestination(safeTarget); + + // Sync Animator with NavAgent velocity + Vector3 vel = _navAgent.velocity; + + // Manual flipping based on velocity X + if (Mathf.Abs(vel.x) > 0.1f) + { + bool flip = vel.x < 0; + if (_spriteRenderer.flipX != flip) _spriteRenderer.flipX = flip; + } + + if (_animator != null) _animator.SetMovement(vel); - // Flip sprite based on direction - if (_spriteRenderer != null && Mathf.Abs(moveDir.x) > 0.01f) - { - _spriteRenderer.flipX = moveDir.x < 0; - } - - if (Vector3.Distance(transform.position, _targetPosition) < 0.1f) - { - _isMoving = false; - } + if (vel.sqrMagnitude > 0.1f) + { + _lastMoveTime = Time.time; + if (Time.time - _lastFootstepTime > 0.3f) + { + VFXManager.Instance.SpawnFootstepDust(transform.position); + _lastFootstepTime = Time.time; + } + } + else + { + // Agent might be moving but stuck or thinking + } } - else if (repulsion.sqrMagnitude > 0.001f) + else { - // Push away even when idle - transform.position += repulsion * Time.deltaTime; + if (_navAgent.enabled && _navAgent.isOnNavMesh) _navAgent.ResetPath(); + if (_animator != null) _animator.SetMovement(Vector3.zero); } // Phase 19-D: Dynamic Z-Sorting @@ -197,12 +257,16 @@ namespace TheIsland.Visual _lastPosition = transform.position; } + // Phase 19-E: Social Orientation (Interaction Facing) // Phase 19-E: Social Orientation (Interaction Facing) if (!_isMoving) { FaceInteractionTarget(); } + // Phase 21-B: Social Visuals (Heart/Wave) + CheckSocialInteractions(); + // Phase 19-F: AAA Grounding (Shadow & Footsteps) UpdateGrounding(); @@ -330,6 +394,21 @@ namespace TheIsland.Visual for (int y = 10; y < 24; y++) tex.SetPixel(16, y, iconColor); tex.SetPixel(16, 8, iconColor); } + // Phase 21-B: Heart emote + else if (type == "heart") { + Color heartColor = new Color(1f, 0.4f, 0.5f); + for (int x=0; x + /// Determine instinctual offset for a target position. + /// If target is too close to water, aim slightly inland. + /// + private Vector3 CalculateInstinctOffset(Vector3 intendedTarget) + { + float fearThreshold = 5.0f; // Start getting anxious + if (intendedTarget.z > fearThreshold) + { + // Push target back to safety + float overshoot = intendedTarget.z - fearThreshold; + return new Vector3(0, 0, -overshoot * 1.5f); + } + return Vector3.zero; + } + private void UpdateSmoothBars() { float lerpSpeed = 5f * Time.deltaTime; @@ -491,6 +586,75 @@ namespace TheIsland.Visual UpdateStats(data); Debug.Log($"[AgentVisual] Initialized: {data.name}"); + _relationships.Clear(); + if (data.relationships != null) + { + foreach (var r in data.relationships) + { + _relationships[r.target_id] = r; + } + } + } + + private void CheckSocialInteractions() + { + _socialCheckTimer += Time.deltaTime; + if (_socialCheckTimer < 1.0f) return; // Check every 1s + _socialCheckTimer = 0; + + if (GameManager.Instance == null) return; + + foreach (var kvp in GameManager.Instance.AllAgentVisuals) + { + int otherId = kvp.Key; + AgentVisual other = kvp.Value; + + if (otherId == _agentId || !other.IsAlive) continue; + + float dist = Vector3.Distance(transform.position, other.transform.position); + + // If close enough (< 2.5m) + if (dist < 2.5f) + { + // Check if we have a special relationship + if (_relationships.TryGetValue(otherId, out RelationshipData rel)) + { + // Logic for Close Friend / Friend + if (rel.type == "close_friend" || rel.type == "friend") + { + // Check greeting cooldown (e.g., once every 60s per friend) + if (!_lastGreetingTimes.ContainsKey(otherId) || Time.time - _lastGreetingTimes[otherId] > 60f) + { + // Trigger Greet + TriggerSocialGreet(otherId, rel.type); + } + } + } + } + } + } + + private void TriggerSocialGreet(int targetId, string type) + { + _lastGreetingTimes[targetId] = Time.time; + + // Visuals + string emote = (type == "close_friend") ? "heart" : "music"; // Heart for close friends, music note/smile for friends + ShowEmotion(emote); + + // Animation + if (_animator != null) + { + _animator.SetWaving(true); + // Stop waving after 2s + StartCoroutine(StopWavingAfterDelay(2.0f)); + } + } + + private IEnumerator StopWavingAfterDelay(float delay) + { + yield return new WaitForSeconds(delay); + if (_animator != null) _animator.SetWaving(false); } private void TryLoadPremiumSprite(int id) @@ -1280,7 +1444,24 @@ namespace TheIsland.Visual { RegeneratePlaceholderSprite(); } + // Only regenerate if using placeholder sprite + if (characterSprite == null && _spriteRenderer != null) + { + RegeneratePlaceholderSprite(); + } } + + // Phase 21-B: Update Relationship Data + if (data.relationships != null) + { + // Clear and rebuild to ensure freshness + _relationships.Clear(); + foreach (var r in data.relationships) + { + _relationships[r.target_id] = r; + } + } + if (_moodText != null) { string moodIndicator = GetMoodEmoji(data.mood_state); diff --git a/unity-client/Assets/Scripts/GameManager.cs b/unity-client/Assets/Scripts/GameManager.cs index 1a437bb..6bcd0da 100644 --- a/unity-client/Assets/Scripts/GameManager.cs +++ b/unity-client/Assets/Scripts/GameManager.cs @@ -90,8 +90,11 @@ namespace TheIsland.Core if (agent.IsAlive) count++; } return count; + return count; } } + + public Dictionary AllAgentVisuals => _agentVisuals; #endregion #region Unity Lifecycle diff --git a/unity-client/Assets/Scripts/Models.cs b/unity-client/Assets/Scripts/Models.cs index d547827..45b8e93 100644 --- a/unity-client/Assets/Scripts/Models.cs +++ b/unity-client/Assets/Scripts/Models.cs @@ -61,10 +61,24 @@ namespace TheIsland.Models // Shelter System (Phase 20-B) public bool is_sheltered; + + // Phase 21-B: Relationships Sync + public List relationships; public bool IsAlive => status == "Alive"; } + /// + /// Relationship entry for Phase 21-B. + /// + [Serializable] + public class RelationshipData + { + public int target_id; + public string type; // "friend", "close_friend", "rival" + public int affection; + } + /// /// Agents update event data. /// diff --git a/unity-client/Assets/Scripts/Visual/AgentAnimator.cs b/unity-client/Assets/Scripts/Visual/AgentAnimator.cs index c43a9f3..8a587a2 100644 --- a/unity-client/Assets/Scripts/Visual/AgentAnimator.cs +++ b/unity-client/Assets/Scripts/Visual/AgentAnimator.cs @@ -28,6 +28,8 @@ namespace TheIsland private Vector3 _currentVelocity; private float _velocityPercentage; // 0 to 1 private bool _isMoving; + private bool _isDancing; // Phase 21 + private bool _isWaving; // Phase 21 private float _jiggleOffset; private float _jiggleVelocity; @@ -112,6 +114,12 @@ namespace TheIsland { AnimateIdle(); } + + // Phase 21: Override for Dance/Wave + if (_isDancing) AnimateDance(); + if (_isWaving) AnimateWave(); + + // Smoothly apply transforms // Smoothly apply transforms float lerpSpeed = 12f; @@ -156,6 +164,47 @@ namespace TheIsland _targetScale = new Vector3(_originalScale.x * squash, _originalScale.y * stretch, _originalScale.z); } + // Phase 21: Social Actions + public void SetDancing(bool dancing) + { + _isDancing = dancing; + } + + public void SetWaving(bool waving) + { + _isWaving = waving; + } + + private void AnimateDance() + { + // Fast rhythmic bounce (130 BPM style) + float cycle = Time.time * 15f; + float danceBounce = Mathf.Abs(Mathf.Sin(cycle)) * 0.15f; + float danceTilt = Mathf.Sin(cycle * 0.5f) * 10f; + + // Apply dance transforms + _targetLocalPos = new Vector3(0, danceBounce, 0); + _targetLocalRot = Quaternion.Euler(0, 0, danceTilt); + + // Strong squash/stretch on the beat + float stretch = 1f + danceBounce * 1.5f; + float squash = 1f / stretch; + _targetScale = new Vector3(_originalScale.x * squash, _originalScale.y * stretch, _originalScale.z); + } + + private void AnimateWave() + { + // Fast waving rotation + float waveCycle = Time.time * 20f; + float waveTilt = Mathf.Sin(waveCycle) * 20f; // Exaggerated tilt + + _targetLocalRot = Quaternion.Euler(0, 0, waveTilt); + + // Slight jump for excitement + float jumpCurve = Mathf.Abs(Mathf.Sin(Time.time * 5f)) * 0.1f; + _targetLocalPos = new Vector3(0, jumpCurve, 0); + } + private IEnumerator ActionPulseRoutine(float duration, float targetScaleY) { float elapsed = 0; diff --git a/unity-client/Assets/Scripts/Visual/EnvironmentManager.cs b/unity-client/Assets/Scripts/Visual/EnvironmentManager.cs index 5ee72aa..8be3428 100644 --- a/unity-client/Assets/Scripts/Visual/EnvironmentManager.cs +++ b/unity-client/Assets/Scripts/Visual/EnvironmentManager.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; +using Unity.AI.Navigation; using UnityEngine; +using UnityEngine.AI; using TheIsland.Core; using TheIsland.Network; using TheIsland.Models; @@ -76,6 +78,9 @@ namespace TheIsland.Visual private Color _targetSkyTop, _targetSkyBottom; private Color _currentSkyTop, _currentSkyBottom; private List _palmTrees = new List(); + + // Phase 20-F: NavMesh Surface + private NavMeshSurface _navMeshSurface; #endregion #region Unity Lifecycle @@ -115,6 +120,12 @@ namespace TheIsland.Visual { new GameObject("VisualEffectsManager").AddComponent(); } + + if (Application.isPlaying) + { + // Phase 20-F: Build NavMesh at Runtime + BuildRuntimeNavMesh(); + } } private void Update() @@ -203,6 +214,23 @@ namespace TheIsland.Visual CreateClouds(); } + private void BuildRuntimeNavMesh() + { + // Ensure we have a NavMeshSurface component + if (_navMeshSurface == null) + { + _navMeshSurface = gameObject.AddComponent(); + } + + // Configure for 2D/2.5D agent + _navMeshSurface.useGeometry = NavMeshCollectGeometry.PhysicsColliders; + _navMeshSurface.collectObjects = CollectObjects.Children; // Collect ground and obstacles + + // Rebuild + _navMeshSurface.BuildNavMesh(); + Debug.Log("[EnvironmentManager] Runtime NavMesh Built."); + } + private void CreateSky() { // Create a gradient sky using a camera background shader @@ -219,7 +247,7 @@ namespace TheIsland.Visual Destroy(skyObj.GetComponent()); // Create gradient material - _skyMaterial = CreateGradientMaterial(); + _skyMaterial = CreateGradientTextureMaterial(); skyObj.GetComponent().material = _skyMaterial; skyObj.GetComponent().sortingOrder = -100; @@ -449,6 +477,13 @@ namespace TheIsland.Visual float spriteHeightUnits = trunkRenderer.sprite.rect.height / trunkRenderer.sprite.pixelsPerUnit; float normScale = scale / spriteHeightUnits; trunkSprite.transform.localScale = new Vector3(normScale, normScale, 1); + + // Phase 20-F: NavMesh Obstacle + var obstacle = treeObj.AddComponent(); + obstacle.shape = NavMeshObstacleShape.Box; + obstacle.center = new Vector3(0, 0.5f * scale, 0); // Center at base, scaled height + obstacle.size = new Vector3(0.5f * normScale, 1f * scale, 0.5f * normScale); // Trunk size, scaled + obstacle.carving = true; // Force agents to walk around } private Texture2D _envTexture; @@ -630,6 +665,13 @@ namespace TheIsland.Visual float spriteWidthUnits = rockRenderer.sprite.rect.width / rockRenderer.sprite.pixelsPerUnit; float normScale = scale / spriteWidthUnits; rockObj.transform.localScale = Vector3.one * normScale; + + // Phase 20-F: NavMesh Obstacle + var obstacle = rockObj.AddComponent(); + obstacle.shape = NavMeshObstacleShape.Box; + obstacle.center = new Vector3(0, 0.25f * scale, 0); // Center at base, scaled height + obstacle.size = new Vector3(0.8f * normScale, 0.5f * scale, 0.8f * normScale); // Rock size, scaled + obstacle.carving = true; // Force agents to walk around } private Sprite CreateRockSprite() diff --git a/unity-client/Packages/manifest.json b/unity-client/Packages/manifest.json index 5005b71..7c8ddae 100644 --- a/unity-client/Packages/manifest.json +++ b/unity-client/Packages/manifest.json @@ -3,9 +3,11 @@ "com.coplaydev.unity-mcp": "https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity", "com.endel.nativewebsocket": "https://github.com/endel/NativeWebSocket.git#upm", "com.unity.2d.enhancers": "1.0.0", + "com.unity.ai.navigation": "2.0.9", "com.unity.feature.2d": "2.0.2", "com.unity.inputsystem": "1.17.0", "com.unity.multiplayer.center": "1.0.1", + "com.unity.postprocessing": "3.4.0", "com.unity.textmeshpro": "3.0.6", "com.unity.modules.accessibility": "1.0.0", "com.unity.modules.adaptiveperformance": "1.0.0", @@ -40,7 +42,6 @@ "com.unity.modules.video": "1.0.0", "com.unity.modules.vr": "1.0.0", "com.unity.modules.wind": "1.0.0", - "com.unity.modules.xr": "1.0.0", - "com.unity.postprocessing": "3.4.0" + "com.unity.modules.xr": "1.0.0" } -} \ No newline at end of file +} diff --git a/unity-client/Packages/packages-lock.json b/unity-client/Packages/packages-lock.json index a9ba6b7..09770db 100644 --- a/unity-client/Packages/packages-lock.json +++ b/unity-client/Packages/packages-lock.json @@ -147,6 +147,15 @@ }, "url": "https://packages.unity.com" }, + "com.unity.ai.navigation": { + "version": "2.0.9", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.modules.ai": "1.0.0" + }, + "url": "https://packages.unity.com" + }, "com.unity.ai.toolkit": { "version": "1.0.0-pre.12", "depth": 2,