working on quest and interaction system

This commit is contained in:
2026-10-01 15:14:37 +01:00
parent 163939dab8
commit e8af8950e1
1198 changed files with 2503496 additions and 93 deletions

View File

@@ -0,0 +1,24 @@
using UnityEngine;
/// <summary>
/// Place this on your WorldSpace Canvas to make it always face the player's camera!
/// </summary>
public class Billboard : MonoBehaviour
{
private Camera _mainCamera;
private void Start()
{
_mainCamera = Camera.main;
}
private void LateUpdate()
{
if (_mainCamera != null)
{
// Makes the Canvas look exactly at the camera, then flips it 180 degrees
// so the text isn't backwards!
transform.LookAt(transform.position + _mainCamera.transform.forward);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 659304d8c0f20275db2874749c7f48b9

View File

@@ -93,6 +93,7 @@ public class BowAimLayerController : MonoBehaviour
// Are we currently holding the draw button (left click)?
private bool _isDrawing;
public bool IsDrawing => _isDrawing;
private Projectile _loadedArrow;
private float _drawStartTime;

View File

@@ -0,0 +1,27 @@
using UnityEngine;
/// <summary>
/// Any object in the game that the player can interact with MUST implement this interface!
/// </summary>
public interface IInteractable
{
/// <summary>
/// The primary text that shows up in the UI (e.g. "Collect", "Open")
/// </summary>
string GetInteractText();
/// <summary>
/// The secondary text that shows up in the UI (e.g. "Apple", "Wooden Door")
/// </summary>
string GetSecondaryInteractText();
/// <summary>
/// Can the player currently interact with this? (E.g. false if a chest is already open)
/// </summary>
bool CanInteract();
/// <summary>
/// What happens when the player presses E!
/// </summary>
void Interact(GameObject player);
}

View File

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

View File

@@ -0,0 +1,100 @@
using UnityEngine;
using UnityEngine.InputSystem;
public class InteractionController : MonoBehaviour
{
[Header("Interaction Settings")]
[Tooltip("How large the proximity bubble is around the player (e.g. 3 meters).")]
public float interactionDistance = 3f;
[Tooltip("Layers that contain interactable objects (e.g. NPC, Default)")]
public LayerMask interactableLayers;
[Header("References")]
public PlayerUIManager uiManager;
[Header("Input")]
[Tooltip("The key used to interact")]
public Key interactKey = Key.E;
// We store whatever we are currently closest to
private IInteractable _currentInteractable;
private void Update()
{
if (uiManager == null) return;
// Create a sphere around the player to find all interactables in range
// Use QueryTriggerInteraction.Ignore so we don't accidentally interact with giant invisible AI/Aggro triggers!
Collider[] hits = Physics.OverlapSphere(transform.position, interactionDistance, interactableLayers, QueryTriggerInteraction.Ignore);
IInteractable closestInteractable = null;
float closestDistance = float.MaxValue;
// Loop through everything in our bubble to find the closest one
foreach (var hit in hits)
{
// Ignore our own body
if (hit.transform.root == transform.root) continue;
IInteractable interactable = hit.GetComponentInParent<IInteractable>();
if (interactable != null && interactable.CanInteract())
{
float dist = Vector3.Distance(transform.position, hit.ClosestPoint(transform.position));
if (dist < closestDistance)
{
closestDistance = dist;
closestInteractable = interactable;
}
}
}
// If we found something valid nearby
if (closestInteractable != null)
{
if (_currentInteractable != closestInteractable)
{
_currentInteractable = closestInteractable;
ShowPrompt(closestInteractable.GetInteractText(), closestInteractable.GetSecondaryInteractText());
}
// Listen for input
if (Keyboard.current != null && Keyboard.current[interactKey].wasPressedThisFrame)
{
closestInteractable.Interact(gameObject);
}
}
else
{
// Nothing in range, or closest thing can't be interacted with anymore
if (_currentInteractable != null)
{
_currentInteractable = null;
HidePrompt();
}
}
}
private void ShowPrompt(string actionText, string secondaryText)
{
if (uiManager.interactionPanel != null) uiManager.interactionPanel.SetActive(true);
if (uiManager.interactionText != null) uiManager.interactionText.text = actionText;
if (uiManager.interactionSecondaryText != null)
{
uiManager.interactionSecondaryText.text = secondaryText;
}
}
private void HidePrompt()
{
if (uiManager.interactionPanel != null) uiManager.interactionPanel.SetActive(false);
}
// Draws a helpful visual bubble in the Scene view so you can see your interaction range!
private void OnDrawGizmosSelected()
{
Gizmos.color = new Color(0f, 1f, 0f, 0.3f);
Gizmos.DrawSphere(transform.position, interactionDistance);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 59ab99a089979717c8f5237412eb6c67

View File

@@ -351,6 +351,13 @@ public class NPCController : MonoBehaviour
private void Die()
{
_isDead = true;
// Check if this NPC is a quest target!
QuestInteractable questTarget = GetComponent<QuestInteractable>();
if (questTarget != null)
{
questTarget.TriggerQuestProgress();
}
if (_agent != null && _agent.isOnNavMesh)
{

View File

@@ -24,6 +24,28 @@ public class PlayerUIManager : MonoBehaviour
[Tooltip("A Slider that fills up from 0 to 1 as the bow is drawn.")]
public Slider bowDrawBar;
[Header("Quest UI")]
[Tooltip("The parent GameObject that holds all the Quest UI elements (allows us to hide the whole window when empty)")]
public GameObject questUIPanel;
[Tooltip("The TextMeshPro text for the name of the Quest")]
public TMPro.TextMeshProUGUI questNameText;
[Tooltip("The container transform where the Objective prefabs will be spawned (e.g., a Vertical Layout Group)")]
public Transform objectivesContainer;
[Tooltip("The prefab for a single objective row (Should have the QuestObjectiveUI script on it)")]
public QuestObjectiveUI objectivePrefab;
[Header("Interaction UI")]
[Tooltip("The parent GameObject for the interaction popup")]
public GameObject interactionPanel;
[Tooltip("The TMPro Text that shows the primary interaction prompt (e.g., 'Collect')")]
public TMPro.TextMeshProUGUI interactionText;
[Tooltip("The secondary TMPro Text for the interaction prompt (e.g., 'Press E' or the Item Name)")]
public TMPro.TextMeshProUGUI interactionSecondaryText;
[Header("Crosshair UI")]
[Tooltip("The crosshair image in the center of the screen")]
public GameObject crosshairImage;
private void Start()
{
if (playerStats == null)
@@ -42,12 +64,91 @@ public class PlayerUIManager : MonoBehaviour
bowDrawBar.maxValue = 1f;
bowDrawBar.value = 0f;
}
// Try to connect to the Quest Manager to listen for updates
if (QuestManager.Instance != null)
{
QuestManager.Instance.onQuestUpdated += DrawQuestUI;
DrawQuestUI(); // Draw initial state
}
}
private void OnDestroy()
{
// Unsubscribe to prevent memory leaks
if (QuestManager.Instance != null)
{
QuestManager.Instance.onQuestUpdated -= DrawQuestUI;
}
}
private void DrawQuestUI()
{
// If there's no Quest Manager or no active quest, hide the UI completely
if (QuestManager.Instance == null || QuestManager.Instance.activeQuest == null || string.IsNullOrEmpty(QuestManager.Instance.activeQuest.questName))
{
if (questUIPanel != null) questUIPanel.SetActive(false);
return;
}
Quest activeQuest = QuestManager.Instance.activeQuest;
// 1. Show the panel and update the Name
if (questUIPanel != null) questUIPanel.SetActive(true);
if (questNameText != null) questNameText.text = activeQuest.questName;
// 2. Clear existing objectives in the container
if (objectivesContainer != null)
{
foreach (Transform child in objectivesContainer)
{
Destroy(child.gameObject);
}
// 3. Spawn a prefab for each objective
if (objectivePrefab != null)
{
bool foundActive = false;
foreach (var objective in activeQuest.objectives)
{
QuestObjectiveUI newObjUI = Instantiate(objectivePrefab, objectivesContainer);
// Determine if THIS objective is the active one
// (The first incomplete objective in the list)
bool isThisActive = false;
if (!objective.isCompleted && !foundActive)
{
isThisActive = true;
foundActive = true;
}
string countText = "";
if (objective.requiresCount)
{
countText = $" ({objective.currentCount}/{objective.targetCount})";
}
newObjUI.Setup(objective.description, isThisActive, objective.isCompleted, countText);
}
}
}
}
private void Update()
{
UpdateBars();
UpdateCombatUI();
UpdateCrosshair();
}
private void UpdateCrosshair()
{
if (crosshairImage != null)
{
// Only show crosshair if we have a bow equipped and are currently holding aim/draw
bool shouldShow = bowAimLayerController != null && bowAimLayerController.IsAiming && bowAimLayerController.IsDrawing;
crosshairImage.SetActive(shouldShow);
}
}
private void UpdateBars()

View File

@@ -0,0 +1,172 @@
using UnityEngine;
/// <summary>
/// Place this on an NPC, Item, or Trigger Zone to easily connect it to the Quest System!
/// </summary>
public class QuestInteractable : MonoBehaviour, IInteractable
{
[Header("Quest Progression")]
[Tooltip("What action does this object trigger?")]
public ObjectiveType objectiveType;
[Tooltip("The unique ID that matches the QuestObjective (e.g., 'wolf', 'ancient_relic')")]
public string targetID;
[Tooltip("How much to progress the objective (usually 1)")]
public int progressAmount = 1;
[Header("Interaction Settings")]
[Tooltip("Can the player walk up and press E on this object?")]
public bool isInteractable = false;
[Tooltip("If true, the player can ONLY interact with this if they currently have the matching quest objective active!")]
public bool requireActiveObjective = false;
[Tooltip("The action verb for the prompt (e.g., 'Open', 'Collect', 'Steal')")]
public string interactAction = "Interact";
[Tooltip("The secondary text to display (e.g. the name of the item or 'Press E')")]
public string interactTargetName = "[ E ]";
[Header("Destination/Trigger Settings")]
[Tooltip("Check this if walking into a Trigger Collider should automatically complete the objective.")]
public bool completeOnTriggerEnter = false;
[Tooltip("A visual marker GameObject to enable while active, and disable when completed.")]
public GameObject destinationMarker;
[Tooltip("A TMPro text element to show the distance counting down as the player gets closer.")]
public TMPro.TextMeshProUGUI distanceText;
[Header("Dynamic Marker Height")]
[Tooltip("If true, the marker will physically raise into the sky when far away, and lower to the ground when close.")]
public bool scaleMarkerHeight = true;
public float maxDistanceForHeight = 50f;
public float minHeight = 1.5f;
public float maxHeight = 15f;
[Tooltip("How far from the center the marker should hit its lowest point. If you have a large Box Collider, set this to half its width (e.g., 3m) so the marker bottoms out right as you touch the edge!")]
public float arrivalDistanceOffset = 3f;
[Header("Item Settings")]
[Tooltip("Check this if this is an item that should destroy itself when collected/triggered")]
public bool destroyOnTrigger;
private bool _hasBeenTriggered = false;
private Transform _playerTransform;
private void Start()
{
// Find player once at start for distance tracking
GameObject playerObj = GameObject.FindGameObjectWithTag("Player");
if (playerObj != null)
{
_playerTransform = playerObj.transform;
}
}
private void Update()
{
// Update distance countdown if marker is active and we haven't triggered
if (completeOnTriggerEnter && !_hasBeenTriggered && destinationMarker != null && destinationMarker.activeInHierarchy)
{
// Fallback: If the 'Player' tag is on a stationary object or missing, track the Main Camera!
// The Main Camera is guaranteed to move with you, so the distance will always update correctly.
if (_playerTransform == null || _playerTransform.position == Vector3.zero)
{
if (Camera.main != null) _playerTransform = Camera.main.transform;
}
if (_playerTransform != null)
{
float dist = Vector3.Distance(_playerTransform.position, transform.position);
if (distanceText != null)
{
distanceText.text = $"{Mathf.RoundToInt(dist)}m";
}
if (scaleMarkerHeight)
{
// Subtract the offset so it bottoms out at the edge of the collider instead of the exact center
float effectiveDist = Mathf.Max(0, dist - arrivalDistanceOffset);
// Calculate a 0 to 1 value (0 = at the edge, 1 = max distance away)
float t = Mathf.Clamp01(effectiveDist / maxDistanceForHeight);
// Lerp between our min and max height clamps
float targetY = Mathf.Lerp(minHeight, maxHeight, t);
Vector3 localPos = destinationMarker.transform.localPosition;
localPos.y = targetY;
destinationMarker.transform.localPosition = localPos;
}
}
}
}
private void OnTriggerEnter(Collider other)
{
if (!completeOnTriggerEnter || _hasBeenTriggered) return;
// Ensure it's the player that walked into the trigger
if (other.CompareTag("Player") || other.transform.root.CompareTag("Player") || other.GetComponentInParent<PlayerStats>() != null)
{
TriggerQuestProgress();
}
}
// --- IInteractable Implementation ---
public string GetInteractText()
{
return interactAction;
}
public string GetSecondaryInteractText()
{
return interactTargetName;
}
public bool CanInteract()
{
if (!isInteractable || _hasBeenTriggered) return false;
// If this option is checked, we ask the QuestManager if this is our current objective!
if (requireActiveObjective)
{
if (QuestManager.Instance == null) return false;
return QuestManager.Instance.IsObjectiveActive(objectiveType, targetID);
}
return true;
}
public void Interact(GameObject player)
{
TriggerQuestProgress();
}
// ------------------------------------
/// <summary>
/// Call this from your existing interaction scripts, or a Unity Event when picking up an item!
/// </summary>
public void TriggerQuestProgress()
{
if (_hasBeenTriggered) return;
bool wasSuccessful = false;
if (QuestManager.Instance != null)
{
wasSuccessful = QuestManager.Instance.ProgressObjective(objectiveType, targetID, progressAmount);
}
// If the objective wasn't active (or was wrong type), we ignore it so they can try again later
if (!wasSuccessful) return;
_hasBeenTriggered = true;
if (destinationMarker != null)
{
destinationMarker.SetActive(false);
}
if (destroyOnTrigger)
{
Destroy(gameObject);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 831349a993aa5fa29be4435af114fe21

View File

@@ -0,0 +1,178 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
public enum ObjectiveType { Interact, Kill, Collect, None }
[System.Serializable]
public class QuestObjective
{
[Tooltip("Unique ID for this objective (e.g., 'bandit_leader', 'wolf_pelt')")]
public string targetID;
[Tooltip("What action the player must perform")]
public ObjectiveType type;
[Tooltip("The text shown in the UI")]
public string description;
[Header("Counting")]
public bool requiresCount;
public int currentCount;
public int targetCount;
[HideInInspector]
public bool isCompleted;
}
[System.Serializable]
public class Quest
{
public string questName;
public List<QuestObjective> objectives = new List<QuestObjective>();
public bool isCompleted;
}
public class QuestManager : MonoBehaviour
{
public static QuestManager Instance { get; private set; }
[Header("Current Quest")]
public Quest activeQuest;
[Header("Testing")]
[Tooltip("Set the key to automatically progress the first incomplete objective")]
public Key completeObjectiveKey = Key.O;
// We notify the UI when the quest updates
public delegate void OnQuestUpdated();
public event OnQuestUpdated onQuestUpdated;
private void Awake()
{
if (Instance == null)
{
Instance = this;
}
else
{
Destroy(gameObject);
}
}
private void Start()
{
// Tell UI to draw whatever quest is assigned in the inspector (if any)
NotifyUI();
}
private void Update()
{
if (Keyboard.current != null && Keyboard.current[completeObjectiveKey].wasPressedThisFrame)
{
// For testing: progress the first incomplete objective
if (activeQuest != null && !activeQuest.isCompleted)
{
foreach (var obj in activeQuest.objectives)
{
if (!obj.isCompleted)
{
ProgressObjective(obj.type, obj.targetID, 1);
break;
}
}
}
}
}
/// <summary>
/// NPCs or Items can call this to progress the current quest!
/// Example: QuestManager.Instance.ProgressObjective(ObjectiveType.Kill, "wolf");
/// </summary>
public bool ProgressObjective(ObjectiveType type, string targetID, int amount = 1)
{
if (activeQuest == null || activeQuest.isCompleted) return false;
foreach (var obj in activeQuest.objectives)
{
// Only progress the FIRST incomplete objective (Linear Quests)
if (!obj.isCompleted)
{
// Check if this objective matches the action
if (obj.type == type && obj.targetID == targetID)
{
if (obj.requiresCount)
{
obj.currentCount += amount;
Debug.Log($"[QuestManager] Progressed '{obj.description}': {obj.currentCount}/{obj.targetCount}");
if (obj.currentCount >= obj.targetCount)
{
obj.currentCount = obj.targetCount;
obj.isCompleted = true;
Debug.Log($"[QuestManager] Objective Completed: {obj.description}");
}
}
else
{
obj.isCompleted = true;
Debug.Log($"[QuestManager] Objective Completed: {obj.description}");
}
CheckQuestCompletion();
NotifyUI();
return true; // Successfully progressed!
}
// If it didn't match, we still stop here because we only allow
// progressing the CURRENT active objective.
return false;
}
}
return false;
}
/// <summary>
/// Checks if a specific objective is the CURRENT active objective the player needs to do.
/// </summary>
public bool IsObjectiveActive(ObjectiveType type, string targetID)
{
if (activeQuest == null || activeQuest.isCompleted) return false;
foreach (var obj in activeQuest.objectives)
{
if (!obj.isCompleted)
{
// We found the FIRST incomplete objective! Is it this one?
return (obj.type == type && obj.targetID == targetID);
}
}
return false;
}
private void CheckQuestCompletion()
{
foreach (var obj in activeQuest.objectives)
{
if (!obj.isCompleted) return; // Still have objectives left
}
activeQuest.isCompleted = true;
Debug.Log($"[QuestManager] Quest Completed: {activeQuest.questName}");
}
public void NotifyUI()
{
onQuestUpdated?.Invoke();
}
/// <summary>
/// An NPC or Item can call this to give the player a brand new quest!
/// </summary>
public void StartNewQuest(Quest newQuest)
{
activeQuest = newQuest;
NotifyUI();
Debug.Log($"[QuestManager] Started new quest: {newQuest.questName}");
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 36a92c66f6c2b1b66a9223d539279aea

View File

@@ -0,0 +1,63 @@
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class QuestObjectiveUI : MonoBehaviour
{
[Tooltip("The text component for the objective description")]
public TextMeshProUGUI objectiveText;
[Header("GameObject Toggling (Option A)")]
[Tooltip("The GameObject to show when the objective is NOT the currently active one (Completed or Pending future)")]
[UnityEngine.Serialization.FormerlySerializedAs("incompleteIcon")]
public GameObject inactiveIcon;
[Tooltip("The GameObject to show when the objective IS the currently active one")]
[UnityEngine.Serialization.FormerlySerializedAs("completeIcon")]
public GameObject activeIcon;
[Header("Sprite Swapping (Option B)")]
[Tooltip("Assign this if you use a SINGLE Image and want to swap its sprite")]
public Image iconImage;
[Tooltip("Sprite for Completed/Pending objective")]
public Sprite inactiveSprite;
[Tooltip("Sprite for the Active objective")]
public Sprite activeSprite;
public void Setup(string description, bool isActive, bool isCompleted, string countText = "")
{
if (objectiveText != null)
{
objectiveText.text = description + countText;
if (isCompleted)
{
// Completed
objectiveText.fontStyle = FontStyles.Strikethrough;
objectiveText.color = new Color(0.5f, 0.5f, 0.5f, 0.5f); // Faded gray
}
else if (isActive)
{
// Currently Active!
objectiveText.fontStyle = FontStyles.Normal;
objectiveText.color = Color.white;
}
else
{
// Pending/Locked future objective
objectiveText.fontStyle = FontStyles.Normal;
objectiveText.color = Color.gray;
}
}
// Option A: Toggle GameObjects based on Completion
if (inactiveIcon != null) inactiveIcon.SetActive(!isCompleted);
if (activeIcon != null) activeIcon.SetActive(isCompleted);
// Option B: Swap Sprite on a single Image based on Active state
if (iconImage != null)
{
iconImage.sprite = isActive ? activeSprite : inactiveSprite;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7a4faccd503c00e6ab8a3b2a55b6b674