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 |