forked from Shardstone/trail-into-darkness
added in-game logger
This commit is contained in:
215
docs/plans/2026-04-05-ingame-logging-design.md
Normal file
215
docs/plans/2026-04-05-ingame-logging-design.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# In-Game Logging System Design
|
||||
|
||||
**Package:** `com.jovian.ingame-logging`
|
||||
**Date:** 2026-04-05
|
||||
|
||||
## Purpose
|
||||
|
||||
An optimized, low-allocation in-game logging system for displaying gameplay messages (roll values, damage, events) to the player. Injectable via constructor DI, not static. Saves/loads with the Jovian save system.
|
||||
|
||||
## Core Data Types
|
||||
|
||||
### LogChannel (readonly struct)
|
||||
|
||||
Zero-allocation value type with string-based identity for serialization.
|
||||
|
||||
```csharp
|
||||
[Serializable]
|
||||
public readonly struct LogChannel : IEquatable<LogChannel> {
|
||||
[SerializeField] readonly string id;
|
||||
public string Id => id;
|
||||
public LogChannel(string id) => this.id = id;
|
||||
|
||||
// Built-in channels
|
||||
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");
|
||||
|
||||
// IEquatable, ==, !=, GetHashCode via id
|
||||
}
|
||||
```
|
||||
|
||||
Game code can define additional channels: `new LogChannel("Crafting")`.
|
||||
Serialized via Newtonsoft `JsonConverter` that reads/writes the `id` string.
|
||||
|
||||
### LogEntry (readonly struct)
|
||||
|
||||
```csharp
|
||||
[Serializable]
|
||||
public readonly struct LogEntry {
|
||||
public readonly string message; // supports TMP rich text tags
|
||||
public readonly LogChannel channel;
|
||||
public readonly float gameTime; // Time.time when logged
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Game Code (CharacterCreationView, CombatSystem, etc.)
|
||||
│ var log = new InGameLogger(store, LogChannel.Combat);
|
||||
│ log.Log("Rolled 18 for Might");
|
||||
│ log.Log("<color=#FFD700>Critical Hit!</color> 24 damage");
|
||||
▼
|
||||
InGameLogger (readonly struct, per-caller facade)
|
||||
│ Holds: IGameLogStore reference + LogChannel
|
||||
│ log.Log(msg) → store.Add(channel, msg)
|
||||
│ log.Log(msg, color) → store.Add(channel, colored msg)
|
||||
▼
|
||||
IGameLogStore (singleton service, created in EntryPoint)
|
||||
│ Ring buffer with configurable max capacity (default 500)
|
||||
│ Add(channel, message), Clear(), Clear(channel)
|
||||
│ GetEntries(), GetEntries(channel)
|
||||
│ event Action<LogEntry> OnEntryAdded
|
||||
│ event Action OnCleared
|
||||
│ SaveLog(ISaveSystem, sessionId), LoadLog(ISaveSystem, sessionId)
|
||||
▼
|
||||
GameLogView (MonoBehaviour on UI prefab)
|
||||
ScrollRect + VerticalLayoutGroup
|
||||
Object pool of LogEntryView instances
|
||||
Subscribes to IGameLogStore.OnEntryAdded / OnCleared
|
||||
Optional channel filter (show one or all channels)
|
||||
Auto-scroll to bottom, pause on manual scroll-up
|
||||
```
|
||||
|
||||
## InGameLogger (Facade Struct)
|
||||
|
||||
```csharp
|
||||
public readonly struct InGameLogger {
|
||||
readonly IGameLogStore store;
|
||||
readonly LogChannel channel;
|
||||
|
||||
public InGameLogger(IGameLogStore store, LogChannel channel) {
|
||||
this.store = store;
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
public void Log(string message) => store.Add(channel, message);
|
||||
public void Log(string message, string hexColor)
|
||||
=> store.Add(channel, $"<color={hexColor}>{message}</color>");
|
||||
}
|
||||
```
|
||||
|
||||
- No allocations beyond the message string itself
|
||||
- Each caller (view, system) creates its own instance scoped to a channel
|
||||
- Injected via `IGameLogStore` passed through constructor DI
|
||||
|
||||
## IGameLogStore (Service Interface)
|
||||
|
||||
```csharp
|
||||
public interface IGameLogStore {
|
||||
int Count { get; }
|
||||
int Capacity { get; }
|
||||
|
||||
void Add(LogChannel channel, string message);
|
||||
void Clear();
|
||||
void Clear(LogChannel channel);
|
||||
|
||||
ReadOnlySpan<LogEntry> GetEntries();
|
||||
// Filtered access for UI
|
||||
int GetEntries(LogChannel channel, List<LogEntry> results);
|
||||
|
||||
event Action<LogEntry> OnEntryAdded;
|
||||
event Action OnCleared;
|
||||
|
||||
// Save integration
|
||||
GameLogSaveData GetSaveData();
|
||||
void RestoreFromSaveData(GameLogSaveData data);
|
||||
}
|
||||
```
|
||||
|
||||
## GameLogStore (Implementation)
|
||||
|
||||
- **Ring buffer** backed by `LogEntry[]` array
|
||||
- Configurable capacity (default 500), oldest entries evicted when full
|
||||
- `Add()` writes to next slot, increments head pointer, fires `OnEntryAdded`
|
||||
- `Clear()` resets head/count, fires `OnCleared`
|
||||
- `Clear(LogChannel)` removes entries matching channel, compacts buffer
|
||||
|
||||
## Save Integration
|
||||
|
||||
**Dependency direction:** `com.jovian.ingame-logging` depends on `com.jovian.savesystem`.
|
||||
|
||||
```csharp
|
||||
[Serializable]
|
||||
public sealed class GameLogSaveData {
|
||||
public List<LogEntry> entries; // all channels, in order
|
||||
}
|
||||
```
|
||||
|
||||
- `GetSaveData()` snapshots the ring buffer into a `GameLogSaveData`
|
||||
- `RestoreFromSaveData()` rebuilds the ring buffer and notifies UI
|
||||
- All channels saved together in one payload, each entry tagged with its `LogChannel`
|
||||
- Game code calls save/load alongside its own persistence:
|
||||
- Save: `saveSystem.Save(logStore.GetSaveData())` in the same session
|
||||
- Load: `logStore.RestoreFromSaveData(saveSystem.Load<GameLogSaveData>(slot))`
|
||||
|
||||
## UI Design
|
||||
|
||||
### GameLogView (MonoBehaviour)
|
||||
|
||||
- Attached to a prefab with `ScrollRect` + `Content` (VerticalLayoutGroup)
|
||||
- Holds reference to a `LogEntryView` prefab (for pooling)
|
||||
- Subscribes to `IGameLogStore.OnEntryAdded` and `OnCleared`
|
||||
- Object pool: pre-warms ~20 `LogEntryView` instances
|
||||
- When pool is exhausted, recycles oldest visible entries
|
||||
- Optional `LogChannel` filter field (null = show all)
|
||||
- Auto-scrolls to bottom; pauses auto-scroll when user scrolls up manually
|
||||
|
||||
### LogEntryView (MonoBehaviour)
|
||||
|
||||
- Minimal: single `TMP_Text` serialized field reference
|
||||
- `SetEntry(LogEntry entry)` sets text content
|
||||
- TMP `richText = true` by default (supports `<color>`, `<b>`, `<size>`, etc.)
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
Packages/com.jovian.ingame-logging/
|
||||
├── package.json # com.jovian.ingame-logging, deps: savesystem, TMP
|
||||
├── Runtime/
|
||||
│ ├── Jovian.InGameLogging.asmdef
|
||||
│ ├── LogChannel.cs
|
||||
│ ├── LogEntry.cs
|
||||
│ ├── InGameLogger.cs
|
||||
│ ├── IGameLogStore.cs
|
||||
│ ├── GameLogStore.cs
|
||||
│ ├── GameLogSaveData.cs
|
||||
│ ├── LogChannelJsonConverter.cs
|
||||
│ └── UI/
|
||||
│ ├── GameLogView.cs
|
||||
│ └── LogEntryView.cs
|
||||
└── Editor/
|
||||
└── Jovian.InGameLogging.Editor.asmdef
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `com.jovian.savesystem` (for ISaveSystem)
|
||||
- `com.unity.textmeshpro` (for TMP_Text)
|
||||
- `com.unity.nuget.newtonsoft-json` (for LogChannel serialization)
|
||||
|
||||
### Assembly References
|
||||
|
||||
- `Jovian.InGameLogging` references: `Jovian.SaveSystem`, `Unity.TextMeshPro`, `Newtonsoft.Json`
|
||||
- `Jovian.InGameLogging.Editor` references: `Jovian.InGameLogging`
|
||||
|
||||
## Integration Points (Game Project)
|
||||
|
||||
1. **EntryPoint.cs**: Create `GameLogStore` instance, pass to game states that need logging
|
||||
2. **Game state constructors**: Receive `IGameLogStore`, create `InGameLogger` structs per channel
|
||||
3. **UI prefab**: Instantiate `GameLogView` prefab, call `Initialize(IGameLogStore)`
|
||||
4. **Save/Load**: Call `GetSaveData()` / `RestoreFromSaveData()` alongside existing save flow
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Readonly struct for LogChannel | Zero-alloc, value semantics, serializable via string id |
|
||||
| Readonly struct for InGameLogger | Per-caller facade with no heap allocation |
|
||||
| Ring buffer over List | Fixed capacity, no resizing, O(1) add, oldest eviction built-in |
|
||||
| Object pool for UI | Eliminates instantiation after warmup, O(visible) rendering cost |
|
||||
| Separate save file via save system | Logging depends on save system, not the other way around |
|
||||
| TMP rich text passthrough | No parsing overhead; callers compose rich text directly |
|
||||
| Simple Clear() API | Package stays decoupled from game-specific events |
|
||||
Reference in New Issue
Block a user