From 16d9cc153ee7282321ff92e4544ef771b33bbf11 Mon Sep 17 00:00:00 2001 From: Peter Andreasen Date: Wed, 30 Jul 2025 11:09:07 +0200 Subject: [PATCH 1/2] Guidelines for development. --- DEVELOPMENT_GUIDELINES.md | 56 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 DEVELOPMENT_GUIDELINES.md diff --git a/DEVELOPMENT_GUIDELINES.md b/DEVELOPMENT_GUIDELINES.md new file mode 100644 index 0000000..b66c3c8 --- /dev/null +++ b/DEVELOPMENT_GUIDELINES.md @@ -0,0 +1,56 @@ +# Development Guidelines + +This document contains guidelines extracted from analyzing preferred implementations to ensure future development aligns with the project's design philosophy. + +## Core Principles + +### 1. Prioritize Simplicity Over Abstraction +- **Prefer concrete classes over generic abstractions** +- **Avoid complex manager patterns when simple registration works** +- **Keep related functionality in single files when possible** +- **Write less code when possible** + +### 2. Performance is Paramount +- **Direct field access over method calls for hot paths** +- **Minimize allocation and indirection** +- **Avoid implicit operators or complex conversion layers** +- **Prefer explicit over implicit behavior** + +### 3. Follow Unity's Design Patterns +- **Use direct field access like Unity components do** +- **Keep registration simple and automatic** +- **Prefer concrete implementations over abstract interfaces** +- **Use clear, descriptive names** + +### 4. Console Integration Should Feel Natural +- **Use intuitive command syntax (`name value` vs `name = value`)** +- **Query by name for getting values** +- **Keep parsing logic simple and direct** +- **Minimize complex assignment detection** + +### 5. Code Style Guidelines +- **Write less code when possible** +- **Prefer concrete implementations over abstract interfaces** +- **Use clear, descriptive names** +- **Avoid over-engineering solutions** + +### 6. File Organization +- **Keep related functionality together** +- **Prefer single files over multiple small files** +- **Minimize cross-file dependencies** + +## Key Takeaways + +1. **Simplicity wins**: The preferred implementation is much shorter and easier to understand +2. **Performance matters**: Direct field access (`cvar.value`) is faster than method calls +3. **Unity-like patterns**: Follow Unity's style of direct, explicit access +4. **Natural integration**: Console commands should feel intuitive and simple +5. **Less is more**: Avoid unnecessary abstraction layers and complex type systems + +## When to Apply These Guidelines + +- **New feature development**: Always consider the simpler approach first +- **Performance-critical code**: Prioritize direct access over abstraction +- **Console integration**: Keep commands natural and intuitive +- **Code reviews**: Question complex abstractions and prefer simpler solutions +- **Refactoring**: Look for opportunities to simplify existing code \ No newline at end of file From a3aafe52f4ea15825588cef25ed4641669d743ac Mon Sep 17 00:00:00 2001 From: Peter Andreasen Date: Wed, 30 Jul 2025 13:49:13 +0200 Subject: [PATCH 2/2] Claude-4-sonnet take --- Assets/DebugOverlay/CVar.cs | 126 +++++++++ Assets/DebugOverlay/CVar.cs.meta | 11 + Assets/DebugOverlay/CVarSystem.cs | 356 +++++++++++++++++++++++++ Assets/DebugOverlay/CVarSystem.cs.meta | 11 + Assets/DebugOverlay/CVars.cs | 116 ++++++++ Assets/DebugOverlay/CVars.cs.meta | 11 + Assets/DebugOverlay/Console.cs | 14 +- Assets/Game/Stats.cs | 15 +- 8 files changed, 652 insertions(+), 8 deletions(-) create mode 100644 Assets/DebugOverlay/CVar.cs create mode 100644 Assets/DebugOverlay/CVar.cs.meta create mode 100644 Assets/DebugOverlay/CVarSystem.cs create mode 100644 Assets/DebugOverlay/CVarSystem.cs.meta create mode 100644 Assets/DebugOverlay/CVars.cs create mode 100644 Assets/DebugOverlay/CVars.cs.meta diff --git a/Assets/DebugOverlay/CVar.cs b/Assets/DebugOverlay/CVar.cs new file mode 100644 index 0000000..170821f --- /dev/null +++ b/Assets/DebugOverlay/CVar.cs @@ -0,0 +1,126 @@ +using System; +using UnityEngine; + +/// +/// A config variable that can be changed from the console. +/// Reading the value is extremely fast (direct field access performance). +/// Usage: public static readonly CVar<float> fov = new CVar<float>("fov", 90f, "Field of view"); +/// Access: float currentFov = CVars.fov; // Implicit conversion, zero overhead +/// +[System.Serializable] +public partial class CVar where T : IComparable, IConvertible +{ + [SerializeField] private T _value; + [SerializeField] private readonly T _defaultValue; + [SerializeField] private readonly string _name; + [SerializeField] private readonly string _description; + + private readonly Func _validator; + private readonly Action _onChanged; + + /// + /// Current value of the cvar. Ultra-fast access. + /// + public T Value + { + get => _value; + set => SetValue(value); + } + + /// + /// Default value of the cvar + /// + public T DefaultValue => _defaultValue; + + /// + /// Name of the cvar as it appears in console + /// + public string Name => _name; + + /// + /// Description shown in help + /// + public string Description => _description; + + /// + /// Implicit conversion for ultra-fast value access + /// + public static implicit operator T(CVar cvar) => cvar._value; + + /// + /// Create a new config variable. Automatically registers with CVarSystem. + /// + /// Console name (e.g., "fov") + /// Default value + /// Help description + /// Optional validation function + /// Optional callback when value changes + public CVar(string name, T defaultValue, string description = "", + Func validator = null, Action onChanged = null) + { + _name = name?.ToLower() ?? throw new ArgumentNullException(nameof(name)); + _defaultValue = defaultValue; + _description = description ?? ""; + _value = defaultValue; + _validator = validator; + _onChanged = onChanged; + + // Register with the system + CVarSystem.Register(this); + } + + /// + /// Set the value with validation and change notification + /// + public bool SetValue(T newValue) + { + // Validate if validator exists + if (_validator != null && !_validator(newValue)) + { + return false; + } + + var oldValue = _value; + _value = newValue; + + // Notify if value actually changed + if (!oldValue.Equals(newValue)) + { + _onChanged?.Invoke(newValue); + } + + return true; + } + + /// + /// Reset to default value + /// + public void Reset() + { + SetValue(_defaultValue); + } + + /// + /// Try to set value from string (used by console) + /// + public bool TrySetFromString(string valueStr) + { + try + { + T newValue = (T)Convert.ChangeType(valueStr, typeof(T)); + return SetValue(newValue); + } + catch + { + return false; + } + } + + /// + /// Get current value as string + /// + public override string ToString() + { + return _value?.ToString() ?? "null"; + } +} \ No newline at end of file diff --git a/Assets/DebugOverlay/CVar.cs.meta b/Assets/DebugOverlay/CVar.cs.meta new file mode 100644 index 0000000..1745cef --- /dev/null +++ b/Assets/DebugOverlay/CVar.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6a4b8c9d1e2f3a4b5c6d7e8f9a0b1c2d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Assets/DebugOverlay/CVarSystem.cs b/Assets/DebugOverlay/CVarSystem.cs new file mode 100644 index 0000000..f15bf93 --- /dev/null +++ b/Assets/DebugOverlay/CVarSystem.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEngine; + +/// +/// Base interface for type-erased cvar access +/// +public interface ICVar +{ + string Name { get; } + string Description { get; } + object Value { get; } + object DefaultValue { get; } + bool TrySetFromString(string value); + void Reset(); + string ToString(); +} + +/// +/// Make CVar implement ICVar for type-erased access +/// +public partial class CVar : ICVar where T : IComparable, IConvertible +{ + object ICVar.Value => _value; + object ICVar.DefaultValue => _defaultValue; +} + +/// +/// System that manages all config variables and integrates with console +/// +public static class CVarSystem +{ + private static readonly Dictionary s_CVars = new Dictionary(); + private static bool s_Initialized = false; + + /// + /// Initialize the cvar system (called by Game.Init) + /// + public static void Initialize(Console console) + { + if (s_Initialized) + return; + + s_Initialized = true; + + // Register console commands + console.AddCommand("cvarlist", CmdCVarList, "List all config variables"); + console.AddCommand("cvarreset", CmdCVarReset, "Reset a cvar to default value"); + console.AddCommand("cvarresetall", CmdCVarResetAll, "Reset all cvars to default values"); + console.AddCommand("set", CmdSet, "Set a cvar value (set )"); + + // Add save/load commands + console.AddCommand("cvarsave", CmdCVarSave, "Save all cvars to config file"); + console.AddCommand("cvarload", CmdCVarLoad, "Load cvars from config file"); + console.AddCommand("cvarexec", CmdCVarExec, "Execute a config file"); + + Debug.Log($"CVarSystem initialized with {s_CVars.Count} variables"); + } + + /// + /// Register a cvar (called automatically by CVar constructor) + /// + public static void Register(ICVar cvar) + { + if (s_CVars.ContainsKey(cvar.Name)) + { + Debug.LogWarning($"CVar '{cvar.Name}' is already registered!"); + return; + } + + s_CVars[cvar.Name] = cvar; + + if (s_Initialized) + Debug.Log($"Registered cvar: {cvar.Name} = {cvar.Value}"); + } + + /// + /// Get a cvar by name + /// + public static ICVar GetCVar(string name) + { + s_CVars.TryGetValue(name.ToLower(), out ICVar cvar); + return cvar; + } + + /// + /// Get all registered cvars + /// + public static IEnumerable GetAllCVars() + { + return s_CVars.Values; + } + + /// + /// Try to set a cvar value from string + /// + public static bool TrySetCVar(string name, string value) + { + var cvar = GetCVar(name); + if (cvar == null) + return false; + + return cvar.TrySetFromString(value); + } + + /// + /// Handle console input that might be a cvar assignment (name = value) + /// Returns true if handled as cvar assignment + /// + public static bool TryHandleConsoleInput(string input, Console console) + { + if (string.IsNullOrWhiteSpace(input)) + return false; + + input = input.Trim(); + + // Handle "name = value" syntax + if (input.Contains("=")) + { + var parts = input.Split('='); + if (parts.Length == 2) + { + string name = parts[0].Trim(); + string value = parts[1].Trim(); + + var cvar = GetCVar(name); + if (cvar != null) + { + if (cvar.TrySetFromString(value)) + { + console.Write("^0F0{0}^FFF = {1}\n", name, cvar.ToString()); + return true; + } + else + { + console.Write("^F00Invalid value '{0}' for {1}\n", value, name); + return true; + } + } + } + } + + // Handle just "name" to show current value + var showCvar = GetCVar(input); + if (showCvar != null) + { + console.Write("^0F0{0}^FFF = {1} (default: {2})\n", + showCvar.Name, showCvar.ToString(), showCvar.DefaultValue); + console.Write(" {0}\n", showCvar.Description); + return true; + } + + return false; + } + + /// + /// Save all cvar values to a config file + /// + public static void SaveConfig(string filePath = null) + { + if (string.IsNullOrEmpty(filePath)) + filePath = Path.Combine(Application.persistentDataPath, "config.cfg"); + + try + { + using (var writer = new StreamWriter(filePath)) + { + writer.WriteLine("// Generated config file"); + writer.WriteLine("// Format: = "); + writer.WriteLine(); + + foreach (var cvar in s_CVars.Values) + { + writer.WriteLine($"{cvar.Name} = {cvar.ToString()}"); + } + } + + Debug.Log($"Saved {s_CVars.Count} cvars to: {filePath}"); + } + catch (Exception e) + { + Debug.LogError($"Failed to save config: {e.Message}"); + } + } + + /// + /// Load cvar values from a config file + /// + public static void LoadConfig(string filePath = null) + { + if (string.IsNullOrEmpty(filePath)) + filePath = Path.Combine(Application.persistentDataPath, "config.cfg"); + + if (!File.Exists(filePath)) + { + Debug.LogWarning($"Config file not found: {filePath}"); + return; + } + + try + { + int loadedCount = 0; + string[] lines = File.ReadAllLines(filePath); + + foreach (string line in lines) + { + string trimmed = line.Trim(); + + // Skip comments and empty lines + if (string.IsNullOrEmpty(trimmed) || trimmed.StartsWith("//")) + continue; + + // Parse cvar assignment (without console output during load) + if (trimmed.Contains("=")) + { + var parts = trimmed.Split('='); + if (parts.Length == 2) + { + string name = parts[0].Trim(); + string value = parts[1].Trim(); + + if (TrySetCVar(name, value)) + loadedCount++; + } + } + } + + Debug.Log($"Loaded {loadedCount} cvars from: {filePath}"); + } + catch (Exception e) + { + Debug.LogError($"Failed to load config: {e.Message}"); + } + } + + // Console command implementations + private static void CmdCVarList(string[] args) + { + var console = Game.console; + console.Write("^FF0Config Variables:\n"); + + foreach (var cvar in s_CVars.Values) + { + console.Write(" ^0F0{0,-20}^FFF = {1,-15} ^888(default: {2})\n", + cvar.Name, cvar.ToString(), cvar.DefaultValue.ToString()); + if (!string.IsNullOrEmpty(cvar.Description)) + console.Write(" ^AAA{0}\n", cvar.Description); + } + + console.Write("^FF0Total: {0} variables\n", s_CVars.Count); + console.Write("^888Usage: = or just to show current value\n"); + } + + private static void CmdCVarReset(string[] args) + { + var console = Game.console; + + if (args.Length != 1) + { + console.Write("Usage: cvarreset \n"); + return; + } + + var cvar = GetCVar(args[0]); + if (cvar == null) + { + console.Write("^F00Unknown cvar: {0}\n", args[0]); + return; + } + + cvar.Reset(); + console.Write("^0F0{0}^FFF reset to {1}\n", cvar.Name, cvar.ToString()); + } + + private static void CmdCVarResetAll(string[] args) + { + var console = Game.console; + + int count = 0; + foreach (var cvar in s_CVars.Values) + { + cvar.Reset(); + count++; + } + + console.Write("^0F0Reset {0} cvars to default values\n", count); + } + + private static void CmdSet(string[] args) + { + var console = Game.console; + + if (args.Length != 2) + { + console.Write("Usage: set \n"); + return; + } + + string name = args[0]; + string value = args[1]; + + var cvar = GetCVar(name); + if (cvar == null) + { + console.Write("^F00Unknown cvar: {0}\n", name); + return; + } + + if (cvar.TrySetFromString(value)) + { + console.Write("^0F0{0}^FFF = {1}\n", cvar.Name, cvar.ToString()); + } + else + { + console.Write("^F00Invalid value '{0}' for {1}\n", value, name); + } + } + + private static void CmdCVarSave(string[] args) + { + var console = Game.console; + + string filePath = null; + if (args.Length > 0) + filePath = args[0]; + + SaveConfig(filePath); + console.Write("^0F0Config saved\n"); + } + + private static void CmdCVarLoad(string[] args) + { + var console = Game.console; + + string filePath = null; + if (args.Length > 0) + filePath = args[0]; + + LoadConfig(filePath); + console.Write("^0F0Config loaded\n"); + } + + private static void CmdCVarExec(string[] args) + { + var console = Game.console; + + if (args.Length != 1) + { + console.Write("Usage: cvarexec \n"); + return; + } + + LoadConfig(args[0]); + } +} \ No newline at end of file diff --git a/Assets/DebugOverlay/CVarSystem.cs.meta b/Assets/DebugOverlay/CVarSystem.cs.meta new file mode 100644 index 0000000..7af2ca4 --- /dev/null +++ b/Assets/DebugOverlay/CVarSystem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7b5c9d0e2f3a4b5c6d7e8f9a0b1c2d3e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Assets/DebugOverlay/CVars.cs b/Assets/DebugOverlay/CVars.cs new file mode 100644 index 0000000..6199404 --- /dev/null +++ b/Assets/DebugOverlay/CVars.cs @@ -0,0 +1,116 @@ +using System; +using UnityEngine; + +/// +/// Example config variables for common game settings. +/// Add your own cvars here - they'll automatically be available in the console! +/// +/// Usage examples: +/// In console: "fov = 110" or "set fov 110" +/// In code: float currentFov = CVars.fov; // Ultra-fast access +/// +public static class CVars +{ + // === Graphics Settings === + public static readonly CVar fov = new CVar( + "fov", 90, "Field of view in degrees", + validator: x => x >= 60 && x <= 150, + onChanged: x => Debug.Log($"FOV changed to {x}") + ); + + public static readonly CVar maxFps = new CVar( + "maxfps", 60, "Maximum framerate (0 = unlimited)", + validator: x => x >= 0 && x <= 300, + onChanged: x => Application.targetFrameRate = x == 0 ? -1 : x + ); + + public static readonly CVar vsync = new CVar( + "vsync", true, "Enable vertical sync", + onChanged: x => QualitySettings.vSyncCount = x ? 1 : 0 + ); + + public static readonly CVar mouseSensitivity = new CVar( + "sensitivity", 1.0f, "Mouse sensitivity multiplier", + validator: x => x > 0.0f && x <= 10.0f + ); + + // === Audio Settings === + public static readonly CVar masterVolume = new CVar( + "volume", 1.0f, "Master volume (0-1)", + validator: x => x >= 0.0f && x <= 1.0f, + onChanged: x => AudioListener.volume = x + ); + + public static readonly CVar muteAudio = new CVar( + "mute", false, "Mute all audio", + onChanged: x => AudioListener.pause = x + ); + + // === Debug Settings === + public static readonly CVar showFps = new CVar( + "showfps", true, "Show FPS counter" + ); + + public static readonly CVar showStats = new CVar( + "showstats", false, "Show detailed performance stats" + ); + + public static readonly CVar drawWireframe = new CVar( + "wireframe", false, "Render in wireframe mode", + onChanged: x => Camera.main.GetComponent().renderingPath = x ? RenderingPath.Forward : RenderingPath.DeferredShading + ); + + // === Game Settings === + public static readonly CVar playerName = new CVar( + "playername", "Player", "Player display name" + ); + + public static readonly CVar walkSpeed = new CVar( + "walkspeed", 5.0f, "Player walking speed", + validator: x => x > 0.0f && x <= 50.0f + ); + + public static readonly CVar jumpHeight = new CVar( + "jumpheight", 2.0f, "Player jump height", + validator: x => x >= 0.0f && x <= 10.0f + ); + + // === Console Settings === + public static readonly CVar consoleSpeed = new CVar( + "consolespeed", 5.0f, "Console open/close animation speed", + validator: x => x > 0.0f && x <= 20.0f + ); + + public static readonly CVar consoleHeight = new CVar( + "consoleheight", 25, "Console height in characters", + validator: x => x >= 10 && x <= 50 + ); + + // === Network Settings (if applicable) === + public static readonly CVar serverAddress = new CVar( + "serveraddress", "localhost", "Server IP address" + ); + + public static readonly CVar serverPort = new CVar( + "serverport", 7777, "Server port", + validator: x => x > 0 && x <= 65535 + ); + + public static readonly CVar networkRate = new CVar( + "rate", 20, "Network update rate (Hz)", + validator: x => x >= 1 && x <= 128 + ); + + /// + /// Example of how to create a cvar with complex validation + /// + public static readonly CVar gameMode = new CVar( + "gamemode", "normal", "Current game mode", + validator: ValidateGameMode + ); + + private static bool ValidateGameMode(string mode) + { + return mode == "normal" || mode == "hardcore" || mode == "creative" || mode == "debug"; + } +} \ No newline at end of file diff --git a/Assets/DebugOverlay/CVars.cs.meta b/Assets/DebugOverlay/CVars.cs.meta new file mode 100644 index 0000000..87d611e --- /dev/null +++ b/Assets/DebugOverlay/CVars.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8c6d0e1f3a4b5c6d7e8f9a0b1c2d3e4f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Assets/DebugOverlay/Console.cs b/Assets/DebugOverlay/Console.cs index 944080d..2d67f19 100644 --- a/Assets/DebugOverlay/Console.cs +++ b/Assets/DebugOverlay/Console.cs @@ -101,6 +101,9 @@ public void Init(DebugOverlay debugOverlay) m_DebugOverlay = debugOverlay != null ? debugOverlay : DebugOverlay.instance; Resize(m_DebugOverlay.width, m_DebugOverlay.height); Clear(); + + // Initialize CVar system + CVarSystem.Initialize(this); } public void Shutdown() @@ -238,11 +241,20 @@ void HistoryStore(string cmd) void ExecuteCommand(string command) { + if (string.IsNullOrWhiteSpace(command)) + return; + + Write('>' + command + '\n'); + + // Try CVar handling first (supports "name = value" and "name" syntax) + if (CVarSystem.TryHandleConsoleInput(command, this)) + return; + + // Parse as regular command var splitCommand = command.Split(null as char[], System.StringSplitOptions.RemoveEmptyEntries); if (splitCommand.Length < 1) return; - Write('>' + string.Join(" ", splitCommand) + '\n'); var commandName = splitCommand[0].ToLower(); CommandDelegate commandDelegate; diff --git a/Assets/Game/Stats.cs b/Assets/Game/Stats.cs index c43c503..b3cbed9 100644 --- a/Assets/Game/Stats.cs +++ b/Assets/Game/Stats.cs @@ -12,8 +12,6 @@ public class Stats : IGameSystem long m_StopWatchFreq; long m_LastFrameTicks; - int m_ShowStats = 1; - public void Init() { m_StopWatch = new System.Diagnostics.Stopwatch(); @@ -21,12 +19,15 @@ public void Init() m_StopWatch.Start(); m_LastFrameTicks = m_StopWatch.ElapsedTicks; Debug.Assert(System.Diagnostics.Stopwatch.IsHighResolution); - Game.console.AddCommand("showstats", CmdShowstats, "Show or hide stats"); + + // Legacy command still works, but now uses CVar + Game.console.AddCommand("showstats", CmdShowstats, "Toggle detailed stats (also available as cvar)"); } private void CmdShowstats(string[] args) { - m_ShowStats = (m_ShowStats + 1) % 3; + // Toggle through: false -> true -> false + CVars.showStats.Value = !CVars.showStats.Value; } void CalcStatistics(float[] data, out float mean, out float variance, out float minValue, out float maxValue) @@ -56,7 +57,7 @@ void CalcStatistics(float[] data, out float mean, out float variance, out float float[] fpsHistory = new float[50]; public void TickUpdate() { - if (m_ShowStats < 1) + if (!CVars.showFps) return; long ticks = m_StopWatch.ElapsedTicks; @@ -70,9 +71,9 @@ public void TickUpdate() fpsHistory[Time.frameCount % fpsHistory.Length] = 1.0f / Time.deltaTime; DebugOverlay.DrawGraph(1, 1, 9, 1.5f, fpsHistory, Time.frameCount % fpsHistory.Length, Color.green); - DebugOverlay.Write(30, 0, "Open console (F12) and type: \"showstats\" to toggle graphs"); + DebugOverlay.Write(30, 0, "Open console (F12) and type: \"showstats = true\" or \"showfps = false\""); - if (m_ShowStats < 2) + if (!CVars.showStats) return; DebugOverlay.Write(0, 4, "Hello, {0,-5} world!", Time.frameCount % 100 < 50 ? "Happy" : "Evil");