added in-game logger

This commit is contained in:
Sebastian Bularca
2026-04-05 12:32:42 +02:00
parent 1ec734d033
commit fa15608f3a
43 changed files with 3019 additions and 8 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1e182a45ed498c445b141e9ec6395805
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
{
"name": "Jovian.InGameLogging.Editor",
"rootNamespace": "Jovian.InGameLogging.Editor",
"references": [
"Jovian.InGameLogging"
],
"includePlatforms": ["Editor"],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 48c945ba5ea83b144b5bbf4eaf33fe29
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,230 @@
# Jovian In-Game Logging
A low-allocation in-game logging system for Unity with channel-based filtering, pooled UI, and save system integration. This is a player-facing game log (combat feed, event history), not a debug logger.
## Requirements
- Unity 2022.3 or later
- Dependencies:
- `com.unity.textmeshpro` 3.0.6+
- `com.unity.nuget.newtonsoft-json` 3.2.1+
- `com.jovian.savesystem` 0.1.0+
## Quick Start
### 1. Create the store
`GameLogStore` is the central service that holds log entries in a ring buffer. Create it once and pass it via constructor injection.
```csharp
// Default capacity of 500 entries
var logStore = new GameLogStore();
// Or specify a custom capacity
var logStore = new GameLogStore(capacity: 1000);
```
### 2. Create a logger
`InGameLogger` is a lightweight facade bound to a specific channel. Create one per system or class that needs to write log entries.
```csharp
var combatLogger = new InGameLogger(logStore, LogChannel.Combat);
var worldLogger = new InGameLogger(logStore, LogChannel.World);
```
### 3. Log messages
```csharp
combatLogger.Log("Kael strikes the skeleton for 12 damage.");
combatLogger.Log("Critical hit!", "#FF4444");
```
### 4. Set up the UI
Add a `GameLogView` component to a GameObject with a `ScrollRect`. Assign the `ScrollRect`, a content `RectTransform`, and a `LogEntryView` prefab in the Inspector. Then initialize from code:
```csharp
// Show all channels
gameLogView.Initialize(logStore);
// Or filter to a single channel
gameLogView.Initialize(logStore, LogChannel.Combat);
```
The view uses object pooling internally. It auto-scrolls to the bottom as new entries arrive and pauses auto-scroll when the player scrolls up manually.
## Prefab Setup
The UI requires two prefabs: a **LogEntryView prefab** (the individual log row) and a **GameLogView prefab** (the scrollable container).
### LogEntryView prefab
1. Create a new GameObject, rename it `LogEntry`
2. Add a `LayoutElement` component. Set `Preferred Height` to your desired row height (e.g. 24). Enable `Flexible Width` so it stretches to the scroll content width
3. Add a child GameObject with a `TextMeshPro - Text (UI)` component
- Set `Overflow` to `Ellipsis` or `Truncate` (prevents text from spilling outside the row)
- Anchor and stretch it to fill the parent (`Left: 0, Right: 0, Top: 0, Bottom: 0`)
- Set font, font size, and color to match your game's UI style
- `Rich Text` should remain enabled (default)
4. Add the `LogEntryView` component to the root `LogEntry` GameObject
5. Drag the child `TMP_Text` into the `LogEntryView.messageText` field
6. Save as a prefab
### GameLogView prefab
1. Create a new UI GameObject with a `ScrollRect` component
- Disable `Horizontal` scrolling, keep `Vertical` enabled
- Set `Movement Type` to `Clamped` or `Elastic` as preferred
- Set `Scroll Sensitivity` to a comfortable value (e.g. 20)
2. Add a child `Content` GameObject as the scroll content area
- Add a `RectTransform` anchored top-left, pivot `(0, 1)`, with a `VerticalLayoutGroup`:
- `Child Alignment`: Upper Left
- `Child Force Expand Width`: true, `Height`: false
- `Spacing`: 2 (gap between log rows)
- `Padding`: set as needed
- Add a `ContentSizeFitter` with `Vertical Fit` set to `Preferred Size` (so it grows as entries are added)
- Assign this as the `ScrollRect.Content`
3. Optionally add a `Scrollbar` child for the vertical scrollbar, and assign it to `ScrollRect.Vertical Scrollbar`
4. Optionally add a `Mask` or `RectMask2D` on the scroll viewport to clip entries outside the visible area
5. Add the `GameLogView` component to the root GameObject
6. Assign the fields in the Inspector:
- `scrollRect`: the `ScrollRect` component
- `content`: the `Content` child's `RectTransform`
- `entryPrefab`: your `LogEntryView` prefab
- `poolSize`: number of pre-instantiated entries (default 20, increase for taller views)
### Initialization from code
After instantiating or finding the `GameLogView` in the scene:
```csharp
var gameLogView = Object.FindFirstObjectByType<GameLogView>();
gameLogView.Initialize(logStore);
// Or filtered to a single channel:
gameLogView.Initialize(logStore, LogChannel.Combat);
```
## Log Channels
### Built-in channels
| Channel | Usage |
|---------|-------|
| `LogChannel.Combat` | Combat events, damage, abilities |
| `LogChannel.CharacterCreation` | Character creation flow |
| `LogChannel.World` | World events, exploration |
| `LogChannel.General` | General-purpose messages |
### Custom channels
`LogChannel` is a readonly struct keyed by a string ID. Define custom channels as static fields:
```csharp
public static class MyLogChannels {
public static readonly LogChannel Trading = new("Trading");
public static readonly LogChannel Dialogue = new("Dialogue");
}
```
Then use them like any built-in channel:
```csharp
var tradeLogger = new InGameLogger(logStore, MyLogChannels.Trading);
tradeLogger.Log("Sold Iron Sword for 50 gold.");
```
## Rich Text
Log messages support TextMeshPro rich text tags. The `InGameLogger.Log(message, hexColor)` overload wraps the message in a `<color>` tag automatically:
```csharp
logger.Log("Poisoned!", "#00FF00");
// Stored as: <color=#00FF00>Poisoned!</color>
```
You can also embed TMP tags directly in the message string:
```csharp
logger.Log("Gained <b>+5</b> <color=#FFD700>experience</color>.");
```
## Save / Load Integration
`GameLogStore` supports serializing its contents for use with a save system.
### Save
```csharp
GameLogSaveData saveData = logStore.GetSaveData();
// Serialize saveData into your save file
```
### Load
```csharp
// Deserialize saveData from your save file
logStore.RestoreFromSaveData(saveData);
```
`RestoreFromSaveData` replaces the current buffer contents. If the save data contains more entries than the store's capacity, only the most recent entries are kept. The `OnCleared` event fires after restoration so the UI can rebuild.
### JSON serialization
`LogChannel` is serialized as a plain string via the included `LogChannelJsonConverter` (Newtonsoft.Json). The converter is applied via attribute on `LogEntry.channel`, so no manual converter registration is needed.
## API Reference
### LogChannel (readonly struct)
- `LogChannel(string id)` -- constructor
- `string Id` -- the channel identifier
- Static fields: `Combat`, `CharacterCreation`, `World`, `General`
- Implements `IEquatable<LogChannel>`, ordinal string comparison
### LogEntry (readonly struct)
- `string message` -- the log message (may contain TMP rich text)
- `LogChannel channel` -- the channel this entry belongs to
- `float gameTime` -- `Time.time` when the entry was added
### InGameLogger (readonly struct)
- `InGameLogger(IGameLogStore store, LogChannel channel)` -- constructor
- `void Log(string message)` -- add an entry to the store
- `void Log(string message, string hexColor)` -- add a color-wrapped entry
### IGameLogStore / GameLogStore
- `GameLogStore(int capacity = 500)` -- constructor with ring buffer size
- `int Count` -- current number of entries
- `int Capacity` -- maximum entries before oldest are overwritten
- `void Add(LogChannel channel, string message)` -- add an entry
- `void Clear()` -- remove all entries
- `void Clear(LogChannel channel)` -- remove entries for a specific channel
- `ReadOnlySpan<LogEntry> GetEntries()` -- all entries in chronological order
- `int GetEntries(LogChannel channel, List<LogEntry> results)` -- filtered entries, returns count
- `event Action<LogEntry> OnEntryAdded` -- fires after each new entry
- `event Action OnCleared` -- fires after `Clear()` or `RestoreFromSaveData()`
- `GameLogSaveData GetSaveData()` -- snapshot for serialization
- `void RestoreFromSaveData(GameLogSaveData data)` -- replace contents from save data
### GameLogSaveData
- `List<LogEntry> entries` -- serializable entry list
### GameLogView (MonoBehaviour) -- `Jovian.InGameLogging.UI`
- `void Initialize(IGameLogStore store, LogChannel? channelFilter = null)` -- bind to a store, optionally filtering to one channel
- Requires: `ScrollRect`, content `RectTransform`, and `LogEntryView` prefab assigned in Inspector
### LogEntryView (MonoBehaviour) -- `Jovian.InGameLogging.UI`
- `void SetEntry(in LogEntry entry)` -- display an entry
- `void ClearEntry()` -- reset the text
- Requires: `TMP_Text` reference assigned in Inspector
### LogChannelJsonConverter
- Newtonsoft.Json `JsonConverter<LogChannel>` -- serializes `LogChannel` as its string ID

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 8a8d67e42da5eea4a873d8f632bacbbe
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 51325b1e7a05b6740a458bdcae9f8998
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
namespace Jovian.InGameLogging {
[Serializable]
public sealed class GameLogSaveData {
public List<LogEntry> entries;
public GameLogSaveData() {
entries = new List<LogEntry>();
}
public GameLogSaveData(List<LogEntry> entries) {
this.entries = entries;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 965eab6edce9dbc49b93d0bda0ad6f6c

View File

@@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.InGameLogging {
public sealed class GameLogStore : IGameLogStore {
readonly LogEntry[] buffer;
int head;
int count;
public int Count => count;
public int Capacity => buffer.Length;
public event Action<LogEntry> OnEntryAdded;
public event Action OnCleared;
public GameLogStore(int capacity = 500) {
buffer = new LogEntry[capacity];
head = 0;
count = 0;
}
public void Add(LogChannel channel, string message) {
var entry = new LogEntry(message, channel, Time.time);
buffer[head] = entry;
head = (head + 1) % buffer.Length;
if(count < buffer.Length) {
count++;
}
OnEntryAdded?.Invoke(entry);
}
public void Clear() {
head = 0;
count = 0;
OnCleared?.Invoke();
}
public void Clear(LogChannel channel) {
var kept = new List<LogEntry>(count);
var entries = GetEntries();
for(int i = 0; i < entries.Length; i++) {
if(entries[i].channel != channel) {
kept.Add(entries[i]);
}
}
head = 0;
count = 0;
for(int i = 0; i < kept.Count; i++) {
buffer[i] = kept[i];
count++;
}
head = count % buffer.Length;
OnCleared?.Invoke();
}
public ReadOnlySpan<LogEntry> GetEntries() {
if(count < buffer.Length) {
return new ReadOnlySpan<LogEntry>(buffer, 0, count);
}
var result = new LogEntry[count];
int start = head;
for(int i = 0; i < count; i++) {
result[i] = buffer[(start + i) % buffer.Length];
}
return result;
}
public int GetEntries(LogChannel channel, List<LogEntry> results) {
results.Clear();
var entries = GetEntries();
for(int i = 0; i < entries.Length; i++) {
if(entries[i].channel == channel) {
results.Add(entries[i]);
}
}
return results.Count;
}
public GameLogSaveData GetSaveData() {
var entries = GetEntries();
var list = new List<LogEntry>(entries.Length);
for(int i = 0; i < entries.Length; i++) {
list.Add(entries[i]);
}
return new GameLogSaveData(list);
}
public void RestoreFromSaveData(GameLogSaveData data) {
head = 0;
count = 0;
if(data?.entries == null) {
OnCleared?.Invoke();
return;
}
int startIndex = Math.Max(0, data.entries.Count - buffer.Length);
for(int i = startIndex; i < data.entries.Count; i++) {
buffer[count] = data.entries[i];
count++;
}
head = count % buffer.Length;
OnCleared?.Invoke();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1eb9ff03ddd225e46852cb92ba213bc7

View File

@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
namespace Jovian.InGameLogging {
public interface IGameLogStore {
int Count { get; }
int Capacity { get; }
void Add(LogChannel channel, string message);
void Clear();
void Clear(LogChannel channel);
ReadOnlySpan<LogEntry> GetEntries();
int GetEntries(LogChannel channel, List<LogEntry> results);
event Action<LogEntry> OnEntryAdded;
event Action OnCleared;
GameLogSaveData GetSaveData();
void RestoreFromSaveData(GameLogSaveData data);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b5fac89eac17e874e888928c9618e812

View File

@@ -0,0 +1,23 @@
using System.Runtime.CompilerServices;
namespace Jovian.InGameLogging {
public readonly struct InGameLogger {
readonly IGameLogStore store;
readonly LogChannel channel;
public InGameLogger(IGameLogStore store, LogChannel channel) {
this.store = store;
this.channel = channel;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Log(string message) {
store.Add(channel, message);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Log(string message, string hexColor) {
store.Add(channel, $"<color={hexColor}>{message}</color>");
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 79d11c151d20d2c41a4ad5e288a4f16f

View File

@@ -0,0 +1,19 @@
{
"name": "Jovian.InGameLogging",
"rootNamespace": "Jovian.InGameLogging",
"references": [
"Unity.TextMeshPro",
"Jovian.SaveSystem"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"Newtonsoft.Json.dll"
],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 6053f37e557f955418ef96fdf46f7d6b
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,44 @@
using System;
using UnityEngine;
namespace Jovian.InGameLogging {
[Serializable]
public readonly struct LogChannel : IEquatable<LogChannel> {
[SerializeField] readonly string id;
public string Id => id;
public LogChannel(string id) {
this.id = id;
}
public static readonly LogChannel Combat = new("Combat");
public static readonly LogChannel CharacterCreation = new("CharacterCreation");
public static readonly LogChannel World = new("World");
public static readonly LogChannel General = new("General");
public bool Equals(LogChannel other) {
return string.Equals(id, other.id, StringComparison.Ordinal);
}
public override bool Equals(object obj) {
return obj is LogChannel other && Equals(other);
}
public override int GetHashCode() {
return id != null ? id.GetHashCode() : 0;
}
public override string ToString() {
return id ?? string.Empty;
}
public static bool operator ==(LogChannel left, LogChannel right) {
return left.Equals(right);
}
public static bool operator !=(LogChannel left, LogChannel right) {
return !left.Equals(right);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 08b1132b10325ce4d922fa7b207db4e0

View File

@@ -0,0 +1,15 @@
using System;
using Newtonsoft.Json;
namespace Jovian.InGameLogging {
public sealed class LogChannelJsonConverter : JsonConverter<LogChannel> {
public override void WriteJson(JsonWriter writer, LogChannel value, JsonSerializer serializer) {
writer.WriteValue(value.Id);
}
public override LogChannel ReadJson(JsonReader reader, Type objectType, LogChannel existingValue, bool hasExistingValue, JsonSerializer serializer) {
var id = reader.Value as string;
return new LogChannel(id);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3c3eb6d23e195e345a3546749ca4e96f

View File

@@ -0,0 +1,20 @@
using System;
using Newtonsoft.Json;
namespace Jovian.InGameLogging {
[Serializable]
public readonly struct LogEntry {
[JsonProperty] public readonly string message;
[JsonProperty] [JsonConverter(typeof(LogChannelJsonConverter))]
public readonly LogChannel channel;
[JsonProperty] public readonly float gameTime;
public LogEntry(string message, LogChannel channel, float gameTime) {
this.message = message;
this.channel = channel;
this.gameTime = gameTime;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0f79543f908769543ac8fc14e138df62

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b1125aedc37d43948aeef186bccbeac8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,116 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace Jovian.InGameLogging.UI {
public class GameLogView : MonoBehaviour {
[SerializeField] ScrollRect scrollRect;
[SerializeField] RectTransform content;
[SerializeField] LogEntryView entryPrefab;
[SerializeField] int poolSize = 20;
IGameLogStore store;
LogChannel? channelFilter;
bool autoScroll = true;
readonly List<LogEntryView> activeEntries = new();
readonly Stack<LogEntryView> pool = new();
public void Initialize(IGameLogStore store, LogChannel? channelFilter = null) {
this.store = store;
this.channelFilter = channelFilter;
WarmPool();
store.OnEntryAdded += HandleEntryAdded;
store.OnCleared += HandleCleared;
scrollRect.onValueChanged.AddListener(HandleScrollChanged);
RebuildFromStore();
}
void OnDestroy() {
if(store != null) {
store.OnEntryAdded -= HandleEntryAdded;
store.OnCleared -= HandleCleared;
}
if(scrollRect != null) {
scrollRect.onValueChanged.RemoveListener(HandleScrollChanged);
}
}
void WarmPool() {
for(int i = 0; i < poolSize; i++) {
var entry = Instantiate(entryPrefab, content);
entry.gameObject.SetActive(false);
pool.Push(entry);
}
}
LogEntryView GetFromPool() {
LogEntryView entry;
if(pool.Count > 0) {
entry = pool.Pop();
}
else {
entry = activeEntries[0];
activeEntries.RemoveAt(0);
}
entry.gameObject.SetActive(true);
entry.transform.SetAsLastSibling();
return entry;
}
void ReturnToPool(LogEntryView entry) {
entry.ClearEntry();
entry.gameObject.SetActive(false);
pool.Push(entry);
}
void HandleEntryAdded(LogEntry entry) {
if(channelFilter.HasValue && entry.channel != channelFilter.Value) {
return;
}
var view = GetFromPool();
view.SetEntry(in entry);
activeEntries.Add(view);
if(autoScroll) {
Canvas.ForceUpdateCanvases();
scrollRect.verticalNormalizedPosition = 0f;
}
}
void HandleCleared() {
for(int i = activeEntries.Count - 1; i >= 0; i--) {
ReturnToPool(activeEntries[i]);
}
activeEntries.Clear();
if(store.Count > 0) {
RebuildFromStore();
}
}
void RebuildFromStore() {
var entries = store.GetEntries();
for(int i = 0; i < entries.Length; i++) {
if(channelFilter.HasValue && entries[i].channel != channelFilter.Value) {
continue;
}
var view = GetFromPool();
view.SetEntry(in entries[i]);
activeEntries.Add(view);
}
if(autoScroll) {
Canvas.ForceUpdateCanvases();
scrollRect.verticalNormalizedPosition = 0f;
}
}
void HandleScrollChanged(Vector2 position) {
autoScroll = position.y <= 0.01f;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 786fb23f28122964cb30678ea785bd40

View File

@@ -0,0 +1,16 @@
using TMPro;
using UnityEngine;
namespace Jovian.InGameLogging.UI {
public class LogEntryView : MonoBehaviour {
[SerializeField] TMP_Text messageText;
public void SetEntry(in LogEntry entry) {
messageText.text = entry.message;
}
public void ClearEntry() {
messageText.text = string.Empty;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5526887cdf77a54439357be8b5754ffc

View File

@@ -0,0 +1,3 @@
# Samples
This folder is reserved for sample scenes and scripts demonstrating the In-Game Logging system.

View File

@@ -0,0 +1,20 @@
{
"name": "com.jovian.ingame-logging",
"version": "0.1.0",
"displayName": "Jovian In-Game Logging",
"description": "An optimized, low-allocation in-game logging system with pooled UI, channel-based filtering, and save system integration.",
"unity": "2022.3",
"dependencies": {
"com.unity.textmeshpro": "3.0.6",
"com.unity.nuget.newtonsoft-json": "3.2.1",
"com.jovian.savesystem": "0.1.0"
},
"keywords": [
"logging",
"ui",
"ingame"
],
"author": {
"name": "Jovian"
}
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: acdee7574039a3a48980e2cc9c6fe31d
PackageManifestImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -18,6 +18,16 @@
"source": "embedded",
"dependencies": {}
},
"com.jovian.ingame-logging": {
"version": "file:com.jovian.ingame-logging",
"depth": 0,
"source": "embedded",
"dependencies": {
"com.unity.textmeshpro": "3.0.6",
"com.unity.nuget.newtonsoft-json": "3.2.1",
"com.jovian.savesystem": "0.1.0"
}
},
"com.jovian.inspector-tools": {
"version": "file:com.jovian.inspector-tools",
"depth": 0,
@@ -354,6 +364,14 @@
},
"url": "https://packages.unity.com"
},
"com.unity.textmeshpro": {
"version": "5.0.0",
"depth": 1,
"source": "builtin",
"dependencies": {
"com.unity.ugui": "2.0.0"
}
},
"com.unity.timeline": {
"version": "1.8.11",
"depth": 0,