Crafting Interactive Bridges: The Evolution of an In-Game Console — Part 1
From Concept to Code: Unraveling the Layers of an Advanced In-Game Console
Background
My name is Alex Volkov, and I have a hobby: creating RPGs in my free time. If you’d like, you can call it my pet project. This project is being developed by two people, and I’m one of them. You should also know that I’m the tech guy; I handle all the development on my own. The other person works on the lore, quests, and other game design aspects that don’t involve coding.
Context
In our game, there’s an in-game console where players can view live in-game events, such as: damage calculations, NPC information, and even an online chat feature for player-to-player communication. Given this, I’ve been tasked with designing a highly extendable system. This system should allow for tabs, a scroll view, and other functionalities, enabling the multifaceted use of this in-game console.
Also, remember, we’re in the development phase of the game. This means that the game console should include a “devlog console” for logging debug information events. Additionally, there should be a capability for players to create tickets directly from the game, reporting bugs or suggesting new features.
Concept
Every feature should begin with the creation of a concept. This functionality was no exception. Having seen numerous consoles, primarily in MMO online games, I’ve gained an understanding of what our game requires and how it should function. As one of the initial steps, I drafted a list of functionalities for the first phase of the console.
In-game console functionality:
- Tabs with icons and custom names.
- Search input field and a page displaying search results.
- Stick to the bottom (when enabled, the scroll will always remain at the bottom of the console log).
- Styles for log events (customizable icons and colors, along with data such as timestamps, event types, primary and meta descriptions).
- Actions for logs (options to copy to clipboard, delete logs, save logs as a *.txt file, and so on).
This comprehensive list outlines the functionalities I aimed to incorporate in the console’s first iteration. While I haven’t drawn a detailed sketch for these features, my sketches still provide sufficient detail and encompass the core functions.
Never skip steps when developing new features for a game or product. Visualization and concept creation prompts many questions, aiding you in fully understanding the feature before you delve into coding it.
Initial Steps
Generally, I favor generating GameObject via scripts whenever feasible. Within the game, I employ Dependency Injection using the Zenject framework. Given this, I opted to create a master prefab for the tab and devised a simple MonoBehaviour class that I plan to instantiate and initialize later.
using UnityEngine;
using TMPro;
public class LogItem : MonoBehaviour
{
[SerializeField] private Image icon;
[SerializeField] private TextMeshProUGUI timeStampTMP;
[SerializeField] private TextMeshProUGUI eventTypeTMP;
[SerializeField] private TextMeshProUGUI informationTMP;
[SerializeField] private LogActions logActions;
public Log Log { get; private set; }
public LogActions LogActions => logActions;
private IConsole console;
[Inject]
private void Constructor(IConsole console)
{
this.console = console;
}
public void Initialize(Log log, ILogView logView)
{
// Set model data
Log = log;
// Initialize log actions
logActions.Initialization(log, console);
// Set basic description
icon.sprite = logView.Thumbnail;
timeStampTMP.text = log.Timestamp.ToString("h:mm:ss");
eventTypeTMP.text = logView.EventName;
informationTMP.text = log.Description;
// Set color
icon.color = logView.Color;
timeStampTMP.color = logView.Color;
eventTypeTMP.color = logView.Color;
informationTMP.color = logView.Color;
}
private void OnDestroy()
{
logActions.Dispose();
}
/// <summary>
/// Call from Unity Editor
/// </summary>
/// <param name="isVisible"></param>
public void SetActionVisibility(bool isVisible)
{
if (logActions.Enabled)
logActions.SetVisibility(isVisible);
}
}
The subsequent phase involves crafting a factory for instantiating this component using Zenject. This is a conventional approach, so I won’t delve into my personal thoughts on it too much here.
using UnityEngine;
public class LogFactory : ILogFactory
{
private readonly DiContainer diContainer;
public LogFactory(DiContainer diContainer)
{
this.diContainer = diContainer;
}
public LogItem CreateLogItem(LogItem prefab, Transform parent)
{
return diContainer.InstantiatePrefabForComponent<LogItem>(prefab, parent);
}
}
public interface ILogFactory
{
LogItem CreateLogItem(LogItem prefab, Transform parent);
}
Bind factory in Zenject Installer
Container.Bind<ILogFactory>().To<LogFactory>().AsSingle().NonLazy();
You might be wondering why I’m taking such a seemingly intricate route. I have two primary reasons:
- The settings for the console comprise two options: the first pertains to the console’s visibility, while the second dictates row limitations within the console.
- My console’s serializable class will require injections in the future, necessitating its instantiation via a factory.
In light of these considerations, I’ve engineered a system that enables me to dynamically create and modify tabs and the console at runtime. The result is a versatile framework permitting me to dictate the title, icon, row limit, visibility of each tab, and console itself. Furthermore, I have the flexibility to generate as many of these tabs as required.
Log Implementation
Each of my log items encompasses an icon, event type, primary description, and an array of meta descriptions. To ensure this entity is as versatile as possible, I crafted a separate MonoBehaviour coupled with an interface that encapsulates all necessary behaviors. Depending on the event type, I determine the icon and log color. When required in the initialization method (using one of its overrides), I can introduce an array of strings, specified in a variable named “meta.” Why the addition of a meta array to the log? On certain occasions, logs, especially developer logs, might include stack traces or other extensive details that are cumbersome for UI display. This additional information can conveniently be housed within the meta.
Another noteworthy approach is my distinction between the MonoBehaviour representation of the log and the data class utilized for log data storage. This stratagem proves invaluable when one anticipates serializing the call in subsequent stages or when transmitting the data class as parameters. It’s prudent to differentiate between data classes and their visual representation.
Search History
Each tab comes equipped with an input field, allowing users to execute searches within it. Users can comb through log descriptions and event values.
public IEnumerable<LogItem> Search(string searchTerm)
{
// Case-insensitive search
return queue.Where(item =>
item.Log.Description.IndexOf(searchTerm, StringComparison.OrdinalIgnoreCase) >= 0 ||
item.Log.Event.ToString().IndexOf(searchTerm, StringComparison.OrdinalIgnoreCase) >= 0);
}
Given that search operations often span more than a single frame, it’s crucial to account for ongoing user input. To address this, I introduced a spinning wheel indicator during the search process.
Moreover, we’ve incorporated a concealed Log tab specifically for search operations, providing a concise summary of search results. This design is essentially a variant of our original Log Tab prefab, albeit with a few modifications. Unity Technologies truly broke ground when they rolled out prefab variants. I wholeheartedly endorse leveraging this feature for any prefabs that might be repurposed with minor alterations. It’s these seemingly trivial efficiencies that significantly streamline intricate projects. Believe me, I speak from experience!
Additional Log Actions
Each log item features a section dedicated to supplementary actions, which houses three buttons:
- Copy to Clipboard: This function copies the log description and all metadata, formatting them neatly before placing them in the clipboard. For its implementation, I utilized Unity’s default functionality.
/// <summary>
/// Copies the value to the system clipboard.
/// </summary>
private void Copy()
{
GUIUtility.systemCopyBuffer = GetTextValue();
}
- Save as .txt File: This feature allows users to save the log item, complete with all its details, to a file on their PC. Every time I need to save a file, I employ a consistent pattern for its creation and storage. It’s worth noting that interacting with the file system synchronously can be detrimental. Hence, always opt for asynchronous operations, even if you believe an 8-bit file is too minuscule to bog down the main thread.
/// <summary>
/// Save description and meta to a *.txt file.
/// </summary>
private async void Save()
{
var path = Path.Combine(Application.persistentDataPath, DebugFilePath);
if (!Directory.Exists(path))
Directory.CreateDirectory(path);
string logText = GetTextValue();
string filePath = Path.Combine(path, $"{log.Uuid}.txt");
await using (var writer = new StreamWriter(filePath, false))
{
await writer.WriteAsync(logText);
}
console.CreateLog(IConsole.Event.LogInfo, $"Log has been saved at: {filePath}");
}
- Delete Log: This offers users the option to remove a log from the Console Tab. While the premise is straightforward, the actual removal of an object from a Queue presents an intriguing challenge. I delve into the specifics of how I approached this functionality on my end in subsequent sections.
/// <summary>
/// Deletes a LogItem with the specified UUID.
/// </summary>
/// <param name="uuid">The UUID of the LogItem to delete.</param>
public void Delete(string uuid)
{
// Convert the queue to a list for manipulation
var list = queue.ToList();
// Find the LogItem with the specified guid
var itemToRemove = list.FirstOrDefault(item => item.Log.Uuid == uuid);
if (itemToRemove != null)
{
// Remove the item and destroy its associated gameObject
list.Remove(itemToRemove);
Object.Destroy(itemToRemove.gameObject);
// Clear the existing queue and enqueue the items from the list
queue.Clear();
foreach (var item in list)
{
queue.Enqueue(item);
}
}
}
To toggle the visibility of the Additional Log Actions, I made use of the Canvas Group element. Employing this element proves more efficient for games compared to simply activating or deactivating objects. Additionally, within the log parameters, I’ve incorporated the ability to enable or disable the functions housed within the Additional Log Actions Panel.
Log Sender
The primary objective of our custom console is to merge the in-game process console, thereby enhancing the in-game user experience. To achieve this, I implemented the Facade pattern. This approach facilitates the transmission of messages to my console via a straightforward, singular line of code.
/// <summary>
/// Controller for handling debug logs and forwarding them to a console.
/// </summary>
public class DebugController
{
private readonly IConsole console;
public DebugController(IConsole console)
{
this.console = console;
}
/// <summary>
/// Subscribes to Unity's log messages.
/// </summary>
public void Subscribe()
{
Application.logMessageReceived += HandleLog;
}
/// <summary>
/// Unsubscribes from Unity's log messages.
/// </summary>
public void UnSubscribe()
{
Application.logMessageReceived -= HandleLog;
}
private void HandleLog(string logString, string stackTrace, LogType type)
{
// You can do whatever you want with the log messages here
// For example, you might log them to a file, send them over the network, etc.
// The 'type' parameter tells you whether it's a regular log message, a warning, or an error
switch (type)
{
case LogType.Error:
case LogType.Assert:
case LogType.Exception:
console.CreateLog(IConsole.Event.LogError, logString, new[] { stackTrace });
break;
case LogType.Warning:
console.CreateLog(IConsole.Event.LogWarning, logString, new[] { stackTrace });
break;
case LogType.Log:
console.CreateLog(IConsole.Event.LogInfo, logString, new[] { stackTrace });
break;
default:
throw new ArgumentOutOfRangeException(nameof(type), type, null);
}
}
}
The method I’ve set up, which is publicly accessible, requires an event type, a message, and an array of strings as parameters. This streamlined process allows for logging events using just one line of code. To obtain a reference to this primary logger class, I employed Zenject Injection.
Use Cases
Within the game, I have the capability to generate a plethora of events. For instance, when a player loots a piece of equipment, this information, complete with event details, is displayed in the Info Tab. If there’s an issue with the game code, it can be observed directly during gameplay, even in the game’s built version outside of Unity.
This console serves as a bridge, facilitating communication between users and the game through the Game Console. It enhances the in-game experience, fostering a connection between game enthusiasts and developers. Furthermore, in upcoming iterations, this console is slated to function as a feedback reporter and even as an online chat platform for users.
Conclusion
Throughout the development journey of our RPG game, the introduction of an advanced in-game console has emerged as a significant enhancement. Beginning as a passionate endeavor helmed by two individuals, our game has seen the integration of several features, with the console being a noteworthy addition. Crafted with precision, it offers functionalities ranging from search history and diverse log actions to straightforward logging mechanisms. This console not only serves as a medium of communication between developers and players but also anticipates an evolution into a platform for feedback and real-time chat.
The various use cases highlight the console’s adaptability and its potential to refine in-game interactions. Whether it’s granting players insights into their in-game activities or assisting developers with real-time troubleshooting, the console illustrates the kind of thoughtful design we aim to incorporate. Looking ahead, I plan to explore the integration of Jira Service Desk with our console in a subsequent article, promising further layers of functionality.
While the console is a significant feature, it’s one of many components that contribute to our ongoing mission: crafting a rich and immersive gaming experience that emphasizes both innovation and player-developer collaboration.