From 23362e7bdd4e170715741c250ef5fe97f541d13b Mon Sep 17 00:00:00 2001 From: rapid Date: Wed, 18 Feb 2026 13:53:02 +0000 Subject: [PATCH] added dialogue manager and radar --- Assets/Scripts/DialogueLine.cs | 17 ++ Assets/Scripts/DialogueLine.cs.meta | 2 + Assets/Scripts/DialogueManager.cs | 278 ++++++++++++++++++++++++ Assets/Scripts/DialogueManager.cs.meta | 2 + Assets/Scripts/DialogueNPC.cs | 135 ++++++++++++ Assets/Scripts/DialogueNPC.cs.meta | 2 + Assets/Scripts/FirstPersonController.cs | 6 +- Assets/Scripts/RadarHUD.cs | 259 ++++++++++++++++++++++ Assets/Scripts/RadarHUD.cs.meta | 2 + DEV_NOTES.md | 73 ++++++- README.md | 142 ++++++++++++ 11 files changed, 915 insertions(+), 3 deletions(-) create mode 100644 Assets/Scripts/DialogueLine.cs create mode 100644 Assets/Scripts/DialogueLine.cs.meta create mode 100644 Assets/Scripts/DialogueManager.cs create mode 100644 Assets/Scripts/DialogueManager.cs.meta create mode 100644 Assets/Scripts/DialogueNPC.cs create mode 100644 Assets/Scripts/DialogueNPC.cs.meta create mode 100644 Assets/Scripts/RadarHUD.cs create mode 100644 Assets/Scripts/RadarHUD.cs.meta create mode 100644 README.md diff --git a/Assets/Scripts/DialogueLine.cs b/Assets/Scripts/DialogueLine.cs new file mode 100644 index 0000000..7a4f7ca --- /dev/null +++ b/Assets/Scripts/DialogueLine.cs @@ -0,0 +1,17 @@ +using System; +using UnityEngine; + +/// +/// One "page" of dialogue: a speaker name + an array of text lines. +/// Fill these out in the Inspector on a DialogueNPC component. +/// +[Serializable] +public class DialogueLine +{ + [Tooltip("Name shown above the text box. Leave blank to hide the name bar.")] + public string speakerName = "???"; + + [Tooltip("Each entry is one page of dialogue. Press E to advance.")] + [TextArea(2, 6)] + public string[] pages = { "..." }; +} diff --git a/Assets/Scripts/DialogueLine.cs.meta b/Assets/Scripts/DialogueLine.cs.meta new file mode 100644 index 0000000..6a174b6 --- /dev/null +++ b/Assets/Scripts/DialogueLine.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 414879952353f4d46ae2bc71d8ccdee4 \ No newline at end of file diff --git a/Assets/Scripts/DialogueManager.cs b/Assets/Scripts/DialogueManager.cs new file mode 100644 index 0000000..3eb0480 --- /dev/null +++ b/Assets/Scripts/DialogueManager.cs @@ -0,0 +1,278 @@ +using UnityEngine; + +/// +/// Singleton. Manages the dialogue UI box. +/// Attach to any persistent GameObject in the scene (e.g. a "Managers" object), +/// or drop it on the Player — it will survive fine either way. +/// +/// Controls: +/// E or Space or Enter — advance to next page / close +/// Escape — close immediately +/// +/// While dialogue is open: +/// - Cursor is unlocked and visible +/// - FirstPersonController mouse look is blocked (via IsOpen flag) +/// +public class DialogueManager : MonoBehaviour +{ + // ── Singleton ───────────────────────────────────────────────────── + public static DialogueManager Instance { get; private set; } + + // ── Inspector ───────────────────────────────────────────────────── + [Header("Box Layout")] + [Tooltip("Height of the dialogue box as a fraction of screen height.")] + public float boxHeightFraction = 0.22f; + public float boxMargin = 24f; + public float innerPad = 18f; + + [Header("Colours — Cruelty Squad palette")] + public Color colBoxBg = new Color(0.04f, 0.06f, 0.04f, 0.94f); + public Color colBoxBorder = new Color(0.18f, 0.75f, 0.22f, 0.90f); + public Color colNameBg = new Color(0.10f, 0.28f, 0.10f, 1.00f); + public Color colNameText = new Color(0.25f, 1.00f, 0.30f, 1.00f); + public Color colBodyText = new Color(0.78f, 0.90f, 0.78f, 1.00f); + public Color colHintText = new Color(0.35f, 0.55f, 0.35f, 0.85f); + public Color colPageDots = new Color(0.28f, 0.65f, 0.30f, 0.80f); + + [Header("Text")] + public int nameFontSize = 13; + public int bodyFontSize = 14; + public int hintFontSize = 10; + + [Header("Typewriter Effect")] + public bool useTypewriter = true; + public float charsPerSecond = 40f; + + // ── Public state ────────────────────────────────────────────────── + public bool IsOpen { get; private set; } + + // ── Private ─────────────────────────────────────────────────────── + private DialogueLine[] _lines; + private int _lineIndex; + private int _pageIndex; + + // Typewriter + private float _charTimer; + private int _visibleChars; + private bool _pageComplete; + + private Texture2D _white; + + // ── Current text shortcuts ───────────────────────────────────────── + private string CurrentSpeaker => (_lines != null && _lineIndex < _lines.Length) + ? _lines[_lineIndex].speakerName : ""; + private string CurrentPage => (_lines != null && _lineIndex < _lines.Length + && _pageIndex < _lines[_lineIndex].pages.Length) + ? _lines[_lineIndex].pages[_pageIndex] : ""; + private int TotalPages => (_lines != null && _lineIndex < _lines.Length) + ? _lines[_lineIndex].pages.Length : 1; + + // ───────────────────────────────────────────────────────────────── + void Awake() + { + if (Instance != null && Instance != this) { Destroy(gameObject); return; } + Instance = this; + } + + void Start() + { + _white = Texture2D.whiteTexture; + } + + // ───────────────────────────────────────────────────────────────── + public void StartDialogue(DialogueLine[] lines) + { + if (lines == null || lines.Length == 0) return; + + _lines = lines; + _lineIndex = 0; + _pageIndex = 0; + IsOpen = true; + + BeginPage(); + + Cursor.lockState = CursorLockMode.None; + Cursor.visible = true; + } + + void BeginPage() + { + _charTimer = 0f; + _visibleChars = useTypewriter ? 0 : int.MaxValue; + _pageComplete = !useTypewriter; + } + + // ───────────────────────────────────────────────────────────────── + void Update() + { + if (!IsOpen) return; + + // Advance typewriter + if (useTypewriter && !_pageComplete) + { + _charTimer += Time.deltaTime; + _visibleChars = Mathf.FloorToInt(_charTimer * charsPerSecond); + if (_visibleChars >= CurrentPage.Length) + { + _visibleChars = CurrentPage.Length; + _pageComplete = true; + } + } + + // Advance or close on E / Space / Return + bool advance = Input.GetKeyDown(KeyCode.E) + || Input.GetKeyDown(KeyCode.Space) + || Input.GetKeyDown(KeyCode.Return); + bool cancel = Input.GetKeyDown(KeyCode.Escape); + + if (cancel) + { + CloseDialogue(); + return; + } + + if (advance) + { + // If typewriter is still running, skip to end of page first + if (useTypewriter && !_pageComplete) + { + _visibleChars = CurrentPage.Length; + _pageComplete = true; + return; + } + + // Try next page in current line + _pageIndex++; + if (_pageIndex < TotalPages) + { + BeginPage(); + return; + } + + // Try next line + _lineIndex++; + _pageIndex = 0; + if (_lineIndex < _lines.Length) + { + BeginPage(); + return; + } + + // All done + CloseDialogue(); + } + } + + void CloseDialogue() + { + IsOpen = false; + _lines = null; + + Cursor.lockState = CursorLockMode.Locked; + Cursor.visible = false; + } + + // ───────────────────────────────────────────────────────────────── + void OnGUI() + { + if (!IsOpen || _lines == null) return; + + float sw = Screen.width; + float sh = Screen.height; + float bh = sh * boxHeightFraction; + float bw = sw - boxMargin * 2f; + float by = sh - bh - boxMargin; + float bx = boxMargin; + + // ── Outer border ───────────────────────────────────────────── + float border = 2f; + DrawTex(new Rect(bx - border, by - border, bw + border * 2f, bh + border * 2f), colBoxBorder); + + // ── Main background ─────────────────────────────────────────── + DrawTex(new Rect(bx, by, bw, bh), colBoxBg); + + // ── Left accent stripe ──────────────────────────────────────── + DrawTex(new Rect(bx, by, 4f, bh), colBoxBorder); + + float cx = bx + innerPad + 6f; // content X (after the stripe + pad) + float cy = by + innerPad; + float cw = bw - innerPad * 2f - 6f; + + // ── Speaker name bar ────────────────────────────────────────── + string speaker = CurrentSpeaker; + float nameH = 0f; + if (!string.IsNullOrWhiteSpace(speaker)) + { + GUIStyle nameStyle = new GUIStyle(); + nameStyle.fontSize = nameFontSize; + nameStyle.fontStyle = FontStyle.Bold; + nameStyle.normal.textColor = colNameText; + nameStyle.alignment = TextAnchor.MiddleLeft; + + Vector2 ns = nameStyle.CalcSize(new GUIContent(speaker)); + nameH = ns.y + 6f; + float namePadX = 10f; + + DrawTex(new Rect(cx - 2f, cy, ns.x + namePadX * 2f + 4f, nameH), colNameBg); + GUI.Label(new Rect(cx + namePadX, cy + 3f, ns.x + 4f, ns.y), speaker, nameStyle); + cy += nameH + 8f; + } + + // ── Body text (typewriter) ──────────────────────────────────── + string fullText = CurrentPage; + string visibleText = useTypewriter + ? fullText.Substring(0, Mathf.Min(_visibleChars, fullText.Length)) + : fullText; + + GUIStyle bodyStyle = new GUIStyle(); + bodyStyle.fontSize = bodyFontSize; + bodyStyle.fontStyle = FontStyle.Normal; + bodyStyle.normal.textColor = colBodyText; + bodyStyle.wordWrap = true; + bodyStyle.richText = true; + + float bodyH = bh - innerPad * 2f - nameH - 8f - 20f; // leave room for hint + GUI.Label(new Rect(cx, cy, cw, bodyH), visibleText, bodyStyle); + + // ── Page indicator dots ─────────────────────────────────────── + int total = TotalPages; + if (total > 1) + { + float dotR = 4f; + float dotGap = dotR * 2f + 4f; + float dotsW = total * dotGap; + float dotX = bx + bw * 0.5f - dotsW * 0.5f; + float dotY = by + bh - innerPad - dotR; + + for (int i = 0; i < total; i++) + { + Color dc = (i == _pageIndex) ? colBoxBorder : colPageDots; + DrawTex(new Rect(dotX + i * dotGap, dotY - dotR, dotR * 2f, dotR * 2f), dc); + } + } + + // ── Advance hint ────────────────────────────────────────────── + string hintStr = _pageComplete + ? (_lineIndex >= _lines.Length - 1 && _pageIndex >= TotalPages - 1 + ? "[ E ] Close" + : "[ E ] Next") + : "[ E ] Skip"; + + GUIStyle hintStyle = new GUIStyle(); + hintStyle.fontSize = hintFontSize; + hintStyle.fontStyle = FontStyle.Bold; + hintStyle.normal.textColor = colHintText; + hintStyle.alignment = TextAnchor.LowerRight; + + GUI.Label(new Rect(bx, by, bw - innerPad, bh - 6f), hintStr, hintStyle); + } + + // ───────────────────────────────────────────────────────────────── + void DrawTex(Rect r, Color c) + { + Color prev = GUI.color; + GUI.color = c; + GUI.DrawTexture(r, _white); + GUI.color = prev; + } +} diff --git a/Assets/Scripts/DialogueManager.cs.meta b/Assets/Scripts/DialogueManager.cs.meta new file mode 100644 index 0000000..394798b --- /dev/null +++ b/Assets/Scripts/DialogueManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 82597f905b4b351499cb80cf88b7e69e \ No newline at end of file diff --git a/Assets/Scripts/DialogueNPC.cs b/Assets/Scripts/DialogueNPC.cs new file mode 100644 index 0000000..92cb015 --- /dev/null +++ b/Assets/Scripts/DialogueNPC.cs @@ -0,0 +1,135 @@ +using UnityEngine; + +/// +/// Attach this to any world object (NPC, terminal, sign, corpse, etc.) +/// to make it interactable. The player presses E when looking at it +/// from within interactRange to start the dialogue. +/// +/// The component draws a small world-space "[E]" prompt via OnGUI +/// when the player is close enough and looking at the object. +/// +public class DialogueNPC : MonoBehaviour +{ + [Header("Dialogue")] + public DialogueLine[] lines = new DialogueLine[] + { + new DialogueLine { speakerName = "STRANGER", pages = new[] { "Hey." } } + }; + + [Header("Interaction")] + [Tooltip("Maximum distance from which the player can interact.")] + public float interactRange = 3.5f; + [Tooltip("Maximum angle (degrees) between player forward and direction to this object.")] + public float interactAngle = 45f; + [Tooltip("Layer mask used for the line-of-sight raycast.")] + public LayerMask occlusionMask = ~0; + + [Header("Prompt Style")] + public string promptText = "[E] Talk"; + public Color colPrompt = new Color(0.20f, 0.95f, 0.40f, 1f); + public Color colPromptBg = new Color(0f, 0f, 0f, 0.70f); + + // ── Private ────────────────────────────────────────────────────── + private Transform _playerTransform; + private Camera _playerCamera; + private bool _promptVisible; + private Texture2D _white; + + // ───────────────────────────────────────────────────────────────── + void Start() + { + _white = Texture2D.whiteTexture; + + // Find the player by component + var player = FindObjectOfType(); + if (player != null) + { + _playerTransform = player.transform; + _playerCamera = player.GetComponentInChildren(); + } + else + { + Debug.LogWarning($"[DialogueNPC] '{name}': Could not find FirstPersonController in scene."); + } + } + + void Update() + { + if (_playerTransform == null || DialogueManager.Instance == null) return; + if (DialogueManager.Instance.IsOpen) { _promptVisible = false; return; } + + _promptVisible = CanInteract(); + + if (_promptVisible && Input.GetKeyDown(KeyCode.E)) + DialogueManager.Instance.StartDialogue(lines); + } + + // ───────────────────────────────────────────────────────────────── + bool CanInteract() + { + if (_playerTransform == null || _playerCamera == null) return false; + + // Distance check + float dist = Vector3.Distance(_playerTransform.position, transform.position); + if (dist > interactRange) return false; + + // Angle check — is the player roughly facing this object? + Vector3 dir = (transform.position - _playerCamera.transform.position).normalized; + float dot = Vector3.Dot(_playerCamera.transform.forward, dir); + if (dot < Mathf.Cos(interactAngle * Mathf.Deg2Rad)) return false; + + // Line-of-sight (optional — fire a ray toward us) + if (Physics.Raycast(_playerCamera.transform.position, dir, out RaycastHit hit, interactRange, occlusionMask)) + { + // Allow if the hit object is us or a child of us + if (!hit.transform.IsChildOf(transform) && hit.transform != transform) + return false; + } + + return true; + } + + // ───────────────────────────────────────────────────────────────── + void OnGUI() + { + if (!_promptVisible || _playerCamera == null) return; + + // Project to screen + Vector3 worldPos = transform.position + Vector3.up * 0.5f; // slightly above pivot + Vector3 screenPos = _playerCamera.WorldToScreenPoint(worldPos); + + if (screenPos.z <= 0f) return; // behind camera + + // Flip Y (GUI vs screen coords) + float sx = screenPos.x; + float sy = Screen.height - screenPos.y; + + GUIStyle style = new GUIStyle(); + style.fontSize = 13; + style.fontStyle = FontStyle.Bold; + style.normal.textColor = colPrompt; + style.alignment = TextAnchor.MiddleCenter; + + Vector2 size = style.CalcSize(new GUIContent(promptText)); + float padX = 8f; + float padY = 4f; + float bgW = size.x + padX * 2f; + float bgH = size.y + padY * 2f; + Rect bgRect = new Rect(sx - bgW * 0.5f, sy - bgH * 0.5f, bgW, bgH); + Rect txRect = new Rect(sx - size.x * 0.5f, sy - size.y * 0.5f, size.x, size.y); + + // Background + Color prev = GUI.color; + GUI.color = colPromptBg; + GUI.DrawTexture(bgRect, _white); + GUI.color = prev; + + GUI.Label(txRect, promptText, style); + } + + void OnDrawGizmosSelected() + { + Gizmos.color = new Color(0f, 1f, 0.4f, 0.25f); + Gizmos.DrawWireSphere(transform.position, interactRange); + } +} diff --git a/Assets/Scripts/DialogueNPC.cs.meta b/Assets/Scripts/DialogueNPC.cs.meta new file mode 100644 index 0000000..24b1002 --- /dev/null +++ b/Assets/Scripts/DialogueNPC.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c1bad8b87ccb4db4996e0dd891a2224a \ No newline at end of file diff --git a/Assets/Scripts/FirstPersonController.cs b/Assets/Scripts/FirstPersonController.cs index c71b5d4..05051ec 100644 --- a/Assets/Scripts/FirstPersonController.cs +++ b/Assets/Scripts/FirstPersonController.cs @@ -77,9 +77,13 @@ public class FirstPersonController : MonoBehaviour controller.Move(new Vector3(0f, velocity.y, 0f) * Time.deltaTime); bool inventoryOpen = inventory != null && inventory.IsOpen; - if (!inventoryOpen) + bool dialogueOpen = DialogueManager.Instance != null && DialogueManager.Instance.IsOpen; + if (!inventoryOpen && !dialogueOpen) HandleMouseLook(); + // Freeze movement during dialogue + if (dialogueOpen) return; + if (Input.GetKeyDown(KeyCode.Escape)) { Cursor.lockState = CursorLockMode.None; diff --git a/Assets/Scripts/RadarHUD.cs b/Assets/Scripts/RadarHUD.cs new file mode 100644 index 0000000..17ae421 --- /dev/null +++ b/Assets/Scripts/RadarHUD.cs @@ -0,0 +1,259 @@ +using UnityEngine; +using System.Collections.Generic; + +/// +/// Draws a circular radar in the top-left corner of the screen. +/// Shows enemies (red) and pickups (yellow-green) relative to the player. +/// Rotates so "up" on the radar = player's forward direction. +/// +/// Attach to the Player GameObject alongside Player.cs / PlayerHUD.cs. +/// Cruelty Squad aesthetic: deliberately ugly, glitchy-looking. +/// +public class RadarHUD : MonoBehaviour +{ + // ─── Inspector ──────────────────────────────────────────────────── + [Header("Layout")] + public float edgePadX = 18f; + public float edgePadY = 18f; + public float radarRadius = 60f; // visual radius of the radar disc (px) + public float worldRange = 40f; // world-space units the radar covers + + [Header("Scan")] + [Tooltip("How often (seconds) to re-scan the scene for enemies/pickups.")] + public float scanInterval = 0.25f; + + [Header("Colours")] + public Color colBackground = new Color(0.04f, 0.10f, 0.04f, 0.88f); + public Color colRim = new Color(0.18f, 0.70f, 0.22f, 0.90f); + public Color colGrid = new Color(0.18f, 0.70f, 0.22f, 0.20f); + public Color colForwardTick = new Color(0.18f, 0.70f, 0.22f, 0.80f); + public Color colEnemy = new Color(0.95f, 0.12f, 0.12f, 1.00f); + public Color colPickup = new Color(0.90f, 0.85f, 0.05f, 1.00f); + public Color colNPC = new Color(0.20f, 0.95f, 0.40f, 1.00f); + public Color colPlayerDot = new Color(1.00f, 1.00f, 1.00f, 1.00f); + + [Header("Blip Sizes (px)")] + public float enemyBlipSize = 5f; + public float pickupBlipSize = 4f; + public float npcBlipSize = 4f; + public float playerDotSize = 5f; + + [Header("Pulse")] + [Tooltip("Enemies pulse their blip opacity.")] + public float blipPulseSpeed = 4f; + + // ─── Private ────────────────────────────────────────────────────── + private Texture2D _white; + + // Cached lists — refreshed on scanInterval + private readonly List _enemies = new List(); + private readonly List _pickups = new List(); + private readonly List _npcs = new List(); + private float _nextScan; + + // Centre and radius of the drawn disc in screen pixels (set each OnGUI call) + private Vector2 _centre; + private float _r; + + // ───────────────────────────────────────────────────────────────── + void Start() + { + _white = Texture2D.whiteTexture; + ScanScene(); + } + + void Update() + { + if (Time.time >= _nextScan) + { + ScanScene(); + _nextScan = Time.time + scanInterval; + } + } + + void ScanScene() + { + _enemies.Clear(); + foreach (var e in FindObjectsOfType()) + if (e != null) _enemies.Add(e.transform); + + _pickups.Clear(); + foreach (var p in FindObjectsOfType()) + if (p != null) _pickups.Add(p.transform); + + _npcs.Clear(); + foreach (var n in FindObjectsOfType()) + if (n != null) _npcs.Add(n.transform); + } + + void OnGUI() + { + _r = radarRadius; + // Radar disc centre: top-left corner + padding + radius + float cx = edgePadX + _r; + float cy = edgePadY + _r; + _centre = new Vector2(cx, cy); + + // ── Background disc ────────────────────────────────────────── + DrawDisc(cx, cy, _r, colBackground); + + // ── Grid rings (2 concentric) ───────────────────────────────── + DrawDiscOutline(cx, cy, _r * 0.5f, colGrid, 1f); + + // ── Crosshair lines (faint) ──────────────────────────────────── + Color gridCol = colGrid; + DrawTex(new Rect(cx - _r, cy - 0.5f, _r * 2f, 1f), gridCol); + DrawTex(new Rect(cx - 0.5f, cy - _r, 1f, _r * 2f), gridCol); + + // ── Outer rim ──────────────────────────────────────────────── + DrawDiscOutline(cx, cy, _r, colRim, 1.5f); + + // ── Player forward tick ──────────────────────────────────────── + // A small notch at the top of the disc = player's forward + DrawTex(new Rect(cx - 1.5f, cy - _r + 1f, 3f, 6f), colForwardTick); + + // ── Blips ───────────────────────────────────────────────────── + float yaw = transform.eulerAngles.y; // player yaw (world Y) + + float enemyPulse = Mathf.Lerp(0.45f, 1f, + (Mathf.Sin(Time.time * blipPulseSpeed) + 1f) * 0.5f); + + // Pickups first (rendered under everything) + foreach (var t in _pickups) + { + if (t == null) continue; + Vector2 blipPos; + if (WorldToRadar(t.position, yaw, out blipPos)) + DrawBlip(blipPos.x, blipPos.y, pickupBlipSize, colPickup, 1f, square: true); + } + + // NPCs — green dots + foreach (var t in _npcs) + { + if (t == null) continue; + Vector2 blipPos; + if (WorldToRadar(t.position, yaw, out blipPos)) + DrawBlip(blipPos.x, blipPos.y, npcBlipSize, colNPC, 1f, square: false); + } + + // Enemies + Color enemyDraw = new Color(colEnemy.r, colEnemy.g, colEnemy.b, colEnemy.a * enemyPulse); + foreach (var t in _enemies) + { + if (t == null) continue; + Vector2 blipPos; + if (WorldToRadar(t.position, yaw, out blipPos)) + DrawBlip(blipPos.x, blipPos.y, enemyBlipSize, enemyDraw, 1f, square: false); + } + + // Player dot (always at centre) + DrawBlip(cx, cy, playerDotSize, colPlayerDot, 1f, square: false); + + // ── Label ───────────────────────────────────────────────────── + GUIStyle labelStyle = new GUIStyle(); + labelStyle.fontSize = 8; + labelStyle.fontStyle = FontStyle.Bold; + labelStyle.normal.textColor = new Color(colRim.r, colRim.g, colRim.b, 0.65f); + GUI.Label(new Rect(cx - _r, cy + _r + 3f, _r * 2f, 12f), "RADAR", labelStyle); + } + + // ─── Conversion ─────────────────────────────────────────────────── + /// + /// Converts a world position to a radar screen position. + /// Returns false if the point is outside the radar range. + /// + bool WorldToRadar(Vector3 worldPos, float playerYawDeg, out Vector2 screenPos) + { + Vector3 delta = worldPos - transform.position; + float dx = delta.x; + float dz = delta.z; + + // Rotate so player forward = up on radar + float rad = playerYawDeg * Mathf.Deg2Rad; + float rx = dx * Mathf.Cos(rad) - dz * Mathf.Sin(rad); + float ry = dx * Mathf.Sin(rad) + dz * Mathf.Cos(rad); + + // Normalise to radar pixel space + float px = _centre.x + (rx / worldRange) * _r; + float py = _centre.y - (ry / worldRange) * _r; // screen Y is flipped + + // Clamp to disc edge if outside range + float dist = new Vector2(rx, ry).magnitude; + if (dist > worldRange) + { + // Show as a dim edge blip + float ex = _centre.x + (rx / dist) * (_r - 3f); + float ey = _centre.y - (ry / dist) * (_r - 3f); + screenPos = new Vector2(ex, ey); + return true; // still draw, but at edge + } + + screenPos = new Vector2(px, py); + return true; + } + + // ─── Drawing helpers ────────────────────────────────────────────── + + void DrawBlip(float x, float y, float size, Color col, float alpha, bool square) + { + Color c = new Color(col.r, col.g, col.b, col.a * alpha); + float half = size * 0.5f; + if (square) + { + DrawTex(new Rect(x - half, y - half, size, size), c); + } + else + { + // Approximate circle with a 3x3 cross pattern + DrawTex(new Rect(x - half, y - half * 0.4f, size, size * 0.4f), c); + DrawTex(new Rect(x - half * 0.4f, y - half, size * 0.4f, size), c); + } + } + + /// Draws a filled circle approximated by a square (fast IMGUI). + void DrawDisc(float cx, float cy, float r, Color col) + { + // Draw as a stack of horizontal lines to get a rough circle + int steps = Mathf.CeilToInt(r * 2f); + for (int i = 0; i < steps; i++) + { + float t = (i / (float)steps) * 2f - 1f; // -1 to 1 + float hw = Mathf.Sqrt(Mathf.Max(0f, 1f - t * t)) * r; + float y = cy + t * r; + DrawTex(new Rect(cx - hw, y, hw * 2f, 1f), col); + } + } + + /// Draws a thin circle outline. + void DrawDiscOutline(float cx, float cy, float r, Color col, float thickness) + { + int segments = Mathf.Max(32, Mathf.CeilToInt(r * 3f)); + for (int i = 0; i < segments; i++) + { + float a0 = (i / (float)segments) * Mathf.PI * 2f; + float a1 = ((i + 1) / (float)segments) * Mathf.PI * 2f; + + float x0 = cx + Mathf.Cos(a0) * r; + float y0 = cy + Mathf.Sin(a0) * r; + float x1 = cx + Mathf.Cos(a1) * r; + float y1 = cy + Mathf.Sin(a1) * r; + + // Draw a tiny rect along each segment + float len = Mathf.Sqrt((x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0)); + if (len < 0.01f) continue; + + DrawTex(new Rect(Mathf.Min(x0, x1) - thickness * 0.5f, + Mathf.Min(y0, y1) - thickness * 0.5f, + Mathf.Abs(x1 - x0) + thickness, + Mathf.Abs(y1 - y0) + thickness), col); + } + } + + void DrawTex(Rect r, Color c) + { + Color prev = GUI.color; + GUI.color = c; + GUI.DrawTexture(r, _white); + GUI.color = prev; + } +} diff --git a/Assets/Scripts/RadarHUD.cs.meta b/Assets/Scripts/RadarHUD.cs.meta new file mode 100644 index 0000000..cf48697 --- /dev/null +++ b/Assets/Scripts/RadarHUD.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dadd7df554e38bc4b8bd7a86ca5c8d82 \ No newline at end of file diff --git a/DEV_NOTES.md b/DEV_NOTES.md index 3f26f00..dec00ff 100644 --- a/DEV_NOTES.md +++ b/DEV_NOTES.md @@ -38,6 +38,10 @@ Pickups are standalone GameObjects placed anywhere in the scene — **not** chil | `WeaponManager` | Player | Instantiates weapon prefabs, handles slot switching | | `BootsEffect` | Player | Watches inventory for Bunny Hop Boots equipped state, applies stamina boost | | `StaminaBoostPickup` | Pickup GameObjects | Legacy — superseded by BootsEffect. Left in for reference. | +| `RadarHUD` | Player | Mini-map radar disc (IMGUI), top-left corner — shows enemies, NPCs, pickups | +| `DialogueLine` | (data class) | Serializable speaker + pages of text, populated in Inspector on DialogueNPC | +| `DialogueNPC` | Any interactable object | World prompt + E-to-talk trigger, references DialogueLine array | +| `DialogueManager` | Any persistent GameObject | Singleton — renders the dialogue box, handles input and cursor lock | --- @@ -259,7 +263,71 @@ Each weapon prefab gets its own values, so Gun Splat can sit differently from an --- -## 9. Equippable Items — `BootsEffect` +## 9. Radar HUD — `RadarHUD` + +Attach to the **Player** alongside `PlayerHUD`. Draws a circular mini-map in the top-left corner. Rotates so "up" always faces the player's forward direction. + +**Blip legend:** +| Blip | Colour | Shape | Source component | +|---|---|---|---| +| Player | White | Round | (always centre) | +| Enemy | Red (pulsing) | Round | `HumanoidEnemy` | +| NPC | Green | Round | `DialogueNPC` | +| Pickup | Yellow | Square | `PickupItem` | + +Points beyond `worldRange` are clamped to the disc edge so off-screen threats always show a direction. + +**Key Inspector fields:** +| Field | Notes | +|---|---| +| `radarRadius` | Visual size of the disc in pixels (default 60) | +| `worldRange` | World-space units covered (default 40) | +| `scanInterval` | How often the scene is re-scanned for objects (default 0.25s) | + +No tags or layers required — blips are found by component type. + +--- + +## 10. Dialogue — `DialogueNPC` + `DialogueManager` + +### Setup + +1. Add `DialogueManager` to any persistent GameObject in the scene (e.g. a "Managers" empty). One instance required per scene. +2. Add `DialogueNPC` to any world object you want to be talkable. +3. In the Inspector on `DialogueNPC`, expand the `Lines` array and fill in speaker names and pages. + +### `DialogueNPC` Inspector fields +| Field | Notes | +|---|---| +| `lines[]` | Array of `DialogueLine` entries — each has a speaker name + pages of text | +| `interactRange` | Max distance for the E prompt to appear (default 3.5 units) | +| `interactAngle` | Max degrees off-centre the player can be looking (default 45°) | +| `occlusionMask` | Layer mask for the line-of-sight raycast | +| `promptText` | Label shown in the world-space prompt bubble (default `[E] Talk`) | + +### Dialogue box controls +| Key | Action | +|---|---| +| E / Space / Enter | Advance page (or skip typewriter) | +| Escape | Close immediately | + +### `DialogueManager` Inspector fields +| Field | Notes | +|---|---| +| `useTypewriter` | Enable/disable letter-by-letter reveal (default on) | +| `charsPerSecond` | Typewriter speed (default 40) | +| `boxHeightFraction` | Box height as fraction of screen height (default 0.22) | + +Rich text tags (``, ``, ``) work inside page text — good for glitchy/stylised dialogue. + +While the dialogue box is open, mouse look and movement are frozen and the cursor is unlocked automatically. + +### NPCs on the radar +Any GameObject with a `DialogueNPC` component automatically appears as a **green dot** on the `RadarHUD`. No extra tagging needed. + +--- + +## 11. Equippable Items — `BootsEffect` **Add `BootsEffect` to the Player** for the Bunny Hop Boots to work. @@ -332,6 +400,7 @@ if (inv.HasItem("Medkit")) { - **Starting gun** — any `SimpleGun` that's a child of the Camera at Start gets auto-registered but starts inactive. Player needs to find it as a pickup in the scene. - **Inventory is IMGUI** — not Canvas. Performance fine at this scale. All data logic is decoupled from drawing so a Canvas swap later would be straightforward. - **`SimpleGun` assumes it's always active** — non-SimpleGun weapons will slot-switch fine but need their own input handling to shoot. +- **Dialogue LOS raycast** — uses `occlusionMask` defaulting to `~0` (all layers). If geometry is blocking the prompt unexpectedly, trim the mask on the `DialogueNPC` component. --- @@ -345,7 +414,7 @@ if (inv.HasItem("Medkit")) { | Left Click | Shoot | | Right Click | Context menu (inventory open) | | R | Reload | -| E | Pick up nearby item | +| E | Pick up nearby item / advance dialogue | | I | Open / close inventory | | F | Equip selected item | | Scroll Wheel | Cycle weapons | diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f6c071 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# OGG + +A janky boomer shooter built in Unity. Cruelty Squad aesthetics, boomer shooter mechanics. Deliberately ugly. + +--- + +## Project Structure + +``` +Assets/ + Scripts/ — all game code + Items/ — ItemDefinition ScriptableObjects + Models/ — 3D assets + Prefabs/ — weapon and enemy prefabs + Scenes/ — game scenes +``` + +--- + +## Systems + +### Player + +**`FirstPersonController.cs`** +Standard FPS controller. WASD + mouse look, sprint with Left Shift, jump with Space. Mouse look and movement are automatically frozen when the inventory or dialogue is open. + +**`Player.cs`** +Tracks health and stamina. Stamina drains while sprinting and regenerates at rest. Exposes `HealthFraction` and `StaminaFraction` for the HUD. + +--- + +### Combat + +**`SimpleGun.cs`** / **`WeaponManager.cs`** / **`WeaponViewmodel.cs`** +Weapon firing, switching, and first-person viewmodel rendering. Weapons are defined as prefabs with an `ItemDefinition` ScriptableObject. A custom editor tool automates prefab generation and includes a live positioning workflow for viewmodel placement. + +**`WeaponBob.cs`** +Applies positional bob to the viewmodel while moving. + +**`EnemyHealth.cs`** / **`EnemyHeadHitbox.cs`** +Enemies take damage from gunfire. Headshots via a dedicated hitbox component are one-shot kills; body shots require multiple hits. + +**`CameraShake.cs`** +Triggered on fire and on taking damage. + +--- + +### Enemies + +**`HumanoidEnemy.cs`** +Procedurally generated humanoid enemies with three types: + +| Type | Role | +|---|---| +| Grunt | Balanced melee + ranged | +| Runner | Fast melee rusher | +| Brute | Slow, heavy, high damage | + +Enemies chase, attack in melee range, and shoot at range. AI uses Unity's `CharacterController` with custom state logic (idle → chase → attack). + +**`EnemySpawner.cs`** +Spawns enemies into the scene. + +--- + +### Inventory + +**`Inventory.cs`** +Grid-based inventory opened with Tab. Supports weapons and equippable non-weapon items. Right-click context menu for equipping/dropping. Pauses mouse look when open. + +**`ItemDefinition.cs`** +ScriptableObject defining an item: name, icon, type, weapon prefab reference, and equippable stats. + +**`PickupItem.cs`** +Attach to any world object to make it a pickup. Spins and bobs in the world. Supports full `ItemDefinition` or a simple name fallback. + +**`StaminaBoostPickup.cs`** / **`BootsEffect.cs`** +"Bunny Hop Boots" equippable item. Grants a passive stamina boost rather than Quake-style bunny hopping — more accessible, still rewarding. + +--- + +### HUD + +**`PlayerHUD.cs`** +Draws health and stamina bars in the bottom-left corner using Unity's immediate-mode GUI (`OnGUI`). Bars smooth-interpolate, pulse red at critical values, and have 25% segment tick marks. Includes a colour-coded speedometer (green → yellow → red). + +**`RadarHUD.cs`** +Circular mini-map in the top-left corner. Rotates with the player so "up" always equals forward. + +| Blip | Colour | Shape | +|---|---|---| +| Player | White | Round | +| Enemy (`HumanoidEnemy`) | Red (pulsing) | Round | +| NPC (`DialogueNPC`) | Green | Round | +| Pickup (`PickupItem`) | Yellow | Square | + +Enemies beyond radar range are clamped to the disc edge. Scene is re-scanned every 0.25 seconds. + +**`SimpleCrosshair.cs`** +Minimal crosshair overlay. + +--- + +### Dialogue + +**`DialogueNPC.cs`** +Attach to any world object (NPC, terminal, sign, etc.) to make it interactable. When the player looks at it within range, a floating `[E] Talk` prompt appears in world space. Press E to open the dialogue box. + +Configurable per object: +- `interactRange` — how close the player needs to be +- `interactAngle` — how directly they need to be looking +- `lines[]` — array of speaker/page blocks (fill in the Inspector) + +**`DialogueManager.cs`** *(singleton)* +Renders the dialogue box at the bottom of the screen. Typewriter text effect, page dot indicators, speaker name bar. Unlocks the cursor while open and re-locks it on close. + +Controls while dialogue is open: + +| Key | Action | +|---|---| +| E / Space / Enter | Advance page (or skip typewriter) | +| Escape | Close immediately | + +Rich text tags (``, ``, ``) work in dialogue body text. + +**`DialogueLine.cs`** +Serializable data class. One `DialogueLine` = one speaker with an array of text pages. Multiple `DialogueLine` entries per NPC chain speakers together. + +--- + +## Setup Cheatsheet + +| What | Component | Where to attach | +|---|---|---| +| Player movement | `FirstPersonController` | Player GameObject | +| Health / stamina | `Player` | Player GameObject | +| HUD bars + speedo | `PlayerHUD` | Player GameObject | +| Mini-map radar | `RadarHUD` | Player GameObject | +| Dialogue system | `DialogueManager` | Any persistent GameObject (e.g. a "Managers" empty) | +| Make something talkable | `DialogueNPC` | The NPC / object | +| Make something a pickup | `PickupItem` | The pickup object | +| Make something an enemy | `HumanoidEnemy` + `EnemyHealth` | Enemy GameObject |