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 |
|
||||
757
docs/plans/2026-04-05-ingame-logging-implementation.md
Normal file
757
docs/plans/2026-04-05-ingame-logging-implementation.md
Normal file
@@ -0,0 +1,757 @@
|
||||
# In-Game Logging System Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build `com.jovian.ingame-logging`, a low-allocation in-game logging package with pooled UI, channel-based filtering, and save system integration.
|
||||
|
||||
**Architecture:** Injectable `IGameLogStore` service with ring buffer storage. `InGameLogger` readonly struct facades scoped to a `LogChannel`. Pooled `GameLogView` MonoBehaviour for ScrollRect UI. Save data as `GameLogSaveData` serializable type, persisted as a field in the game's `NoxSavedDataSet`.
|
||||
|
||||
**Tech Stack:** Unity 6 / C# 9, TextMeshPro, Newtonsoft.Json, Jovian SaveSystem
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Package scaffold
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.ingame-logging/package.json`
|
||||
- Create: `Packages/com.jovian.ingame-logging/Runtime/Jovian.InGameLogging.asmdef`
|
||||
- Create: `Packages/com.jovian.ingame-logging/Editor/Jovian.InGameLogging.Editor.asmdef`
|
||||
|
||||
**Step 1: Create package.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Create Runtime asmdef**
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Create Editor asmdef**
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.ingame-logging/
|
||||
git commit -m "feat: scaffold com.jovian.ingame-logging package"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: LogChannel readonly struct
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.ingame-logging/Runtime/LogChannel.cs`
|
||||
|
||||
**Step 1: Implement LogChannel**
|
||||
|
||||
```csharp
|
||||
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) => string.Equals(id, other.id, StringComparison.Ordinal);
|
||||
public override bool Equals(object obj) => obj is LogChannel other && Equals(other);
|
||||
public override int GetHashCode() => id != null ? id.GetHashCode() : 0;
|
||||
public override string ToString() => id ?? string.Empty;
|
||||
|
||||
public static bool operator ==(LogChannel left, LogChannel right) => left.Equals(right);
|
||||
public static bool operator !=(LogChannel left, LogChannel right) => !left.Equals(right);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.ingame-logging/Runtime/LogChannel.cs
|
||||
git commit -m "feat: add LogChannel readonly struct with built-in channels"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: LogChannelJsonConverter
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.ingame-logging/Runtime/LogChannelJsonConverter.cs`
|
||||
|
||||
**Step 1: Implement Newtonsoft converter**
|
||||
|
||||
```csharp
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.ingame-logging/Runtime/LogChannelJsonConverter.cs
|
||||
git commit -m "feat: add Newtonsoft JSON converter for LogChannel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: LogEntry readonly struct
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.ingame-logging/Runtime/LogEntry.cs`
|
||||
|
||||
**Step 1: Implement LogEntry**
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Jovian.InGameLogging {
|
||||
[Serializable]
|
||||
public readonly struct LogEntry {
|
||||
public readonly string message;
|
||||
|
||||
[JsonConverter(typeof(LogChannelJsonConverter))]
|
||||
public readonly LogChannel channel;
|
||||
|
||||
public readonly float gameTime;
|
||||
|
||||
public LogEntry(string message, LogChannel channel, float gameTime) {
|
||||
this.message = message;
|
||||
this.channel = channel;
|
||||
this.gameTime = gameTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.ingame-logging/Runtime/LogEntry.cs
|
||||
git commit -m "feat: add LogEntry readonly struct"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: GameLogSaveData
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.ingame-logging/Runtime/GameLogSaveData.cs`
|
||||
|
||||
**Step 1: Implement serializable save container**
|
||||
|
||||
```csharp
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.ingame-logging/Runtime/GameLogSaveData.cs
|
||||
git commit -m "feat: add GameLogSaveData serializable container"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: IGameLogStore interface
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.ingame-logging/Runtime/IGameLogStore.cs`
|
||||
|
||||
**Step 1: Define the store interface**
|
||||
|
||||
```csharp
|
||||
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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.ingame-logging/Runtime/IGameLogStore.cs
|
||||
git commit -m "feat: add IGameLogStore interface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: GameLogStore ring buffer implementation
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.ingame-logging/Runtime/GameLogStore.cs`
|
||||
|
||||
**Step 1: Implement the ring buffer store**
|
||||
|
||||
```csharp
|
||||
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) {
|
||||
// Compact: copy non-matching entries into a temp span, then rebuild
|
||||
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);
|
||||
}
|
||||
// Buffer is full and wrapping - need to return in chronological order
|
||||
var result = new LogEntry[count];
|
||||
int start = head; // head points to the oldest entry when full
|
||||
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;
|
||||
}
|
||||
// Only restore up to capacity, taking the most recent entries
|
||||
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(); // Signal UI to rebuild from scratch
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.ingame-logging/Runtime/GameLogStore.cs
|
||||
git commit -m "feat: add GameLogStore ring buffer implementation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: InGameLogger facade struct
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.ingame-logging/Runtime/InGameLogger.cs`
|
||||
|
||||
**Step 1: Implement the per-caller facade**
|
||||
|
||||
```csharp
|
||||
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>");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.ingame-logging/Runtime/InGameLogger.cs
|
||||
git commit -m "feat: add InGameLogger readonly struct facade"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: LogEntryView MonoBehaviour
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.ingame-logging/Runtime/UI/LogEntryView.cs`
|
||||
|
||||
**Step 1: Implement the minimal UI entry component**
|
||||
|
||||
```csharp
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.ingame-logging/Runtime/UI/LogEntryView.cs
|
||||
git commit -m "feat: add LogEntryView MonoBehaviour for pooled UI entries"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: GameLogView MonoBehaviour with object pooling
|
||||
|
||||
**Files:**
|
||||
- Create: `Packages/com.jovian.ingame-logging/Runtime/UI/GameLogView.cs`
|
||||
|
||||
**Step 1: Implement the scrollable log view with object pool**
|
||||
|
||||
```csharp
|
||||
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);
|
||||
|
||||
// Populate existing entries
|
||||
RebuildFromStore();
|
||||
}
|
||||
|
||||
void OnDestroy() {
|
||||
if(store != null) {
|
||||
store.OnEntryAdded -= HandleEntryAdded;
|
||||
store.OnCleared -= HandleCleared;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Recycle oldest active entry
|
||||
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 still has entries (channel-specific clear), rebuild
|
||||
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) {
|
||||
// Pause auto-scroll when user scrolls up, resume when at bottom
|
||||
autoScroll = position.y <= 0.01f;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.ingame-logging/Runtime/UI/GameLogView.cs
|
||||
git commit -m "feat: add GameLogView with object pooling and scroll management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Generate Unity .meta files
|
||||
|
||||
Unity requires `.meta` files for every file and folder. These will be auto-generated by Unity when the editor imports the package. However, to make the package work without opening Unity, we need to ensure the directory structure is correct.
|
||||
|
||||
**Step 1: Create placeholder to ensure Editor directory exists**
|
||||
|
||||
Create an empty `Editor/.gitkeep` or let Unity generate metas on next editor open. Since this project builds through Unity Editor, the meta files will be generated automatically on next load.
|
||||
|
||||
**Step 2: Commit all remaining files**
|
||||
|
||||
```bash
|
||||
git add Packages/com.jovian.ingame-logging/
|
||||
git commit -m "feat: complete com.jovian.ingame-logging package"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Game integration - Add GameLogSaveData to NoxSavedDataSet
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/Code/GameState/NoxSaveData.cs` (add `GameLogSaveData` field to `NoxSavedDataSet`)
|
||||
|
||||
**Step 1: Add the field**
|
||||
|
||||
Add to `NoxSavedDataSet` class at `Assets/Code/GameState/NoxSaveData.cs:50`:
|
||||
|
||||
```csharp
|
||||
using Jovian.InGameLogging; // add to imports
|
||||
|
||||
// Inside NoxSavedDataSet class, add:
|
||||
public GameLogSaveData gameLogData;
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Assets/Code/GameState/NoxSaveData.cs
|
||||
git commit -m "feat: add GameLogSaveData field to NoxSavedDataSet"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Game integration - Wire GameLogStore in EntryPoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `Assets/Code/Core/EntryPoint.cs`
|
||||
|
||||
**Step 1: Create GameLogStore and pass it to game states**
|
||||
|
||||
In `EntryPoint.CreateApplicationStates()`, after the character systems creation (~line 109), add:
|
||||
|
||||
```csharp
|
||||
using Jovian.InGameLogging; // add to imports
|
||||
|
||||
// After characterSystems creation:
|
||||
var gameLogStore = new GameLogStore(500);
|
||||
```
|
||||
|
||||
Then pass `gameLogStore` as `IGameLogStore` to the game state constructors that need it. The exact constructors depend on which states need logging - at minimum `GameModeGameState` for combat/adventure logging.
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Assets/Code/Core/EntryPoint.cs
|
||||
git commit -m "feat: wire GameLogStore in EntryPoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Game integration - Save/Load log data
|
||||
|
||||
**Files:**
|
||||
- Modify: wherever `NoxSavedDataSet` is populated before save (look for `saveSystem.Save` calls)
|
||||
- Modify: `Assets/Code/GameState/NoxSaveData.cs` (`RestoreSavedData` method)
|
||||
|
||||
**Step 1: Populate log data before save**
|
||||
|
||||
Wherever the game creates `NoxSavedDataSet` before saving, add:
|
||||
|
||||
```csharp
|
||||
savedDataSet.gameLogData = gameLogStore.GetSaveData();
|
||||
```
|
||||
|
||||
**Step 2: Restore log data after load**
|
||||
|
||||
In `NoxSaveData.RestoreSavedData()`, after loading save data (~line 35), add:
|
||||
|
||||
```csharp
|
||||
// Pass gameLogStore as parameter to RestoreSavedData, then:
|
||||
if(saveData.gameLogData != null) {
|
||||
gameLogStore.RestoreFromSaveData(saveData.gameLogData);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add Assets/Code/GameState/NoxSaveData.cs
|
||||
git commit -m "feat: integrate log save/load with game persistence"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Summary
|
||||
|
||||
| File | Type | Purpose |
|
||||
|------|------|---------|
|
||||
| `Packages/com.jovian.ingame-logging/package.json` | Create | Package manifest |
|
||||
| `Packages/com.jovian.ingame-logging/Runtime/Jovian.InGameLogging.asmdef` | Create | Runtime assembly |
|
||||
| `Packages/com.jovian.ingame-logging/Editor/Jovian.InGameLogging.Editor.asmdef` | Create | Editor assembly |
|
||||
| `Packages/com.jovian.ingame-logging/Runtime/LogChannel.cs` | Create | Channel type (readonly struct) |
|
||||
| `Packages/com.jovian.ingame-logging/Runtime/LogChannelJsonConverter.cs` | Create | Newtonsoft serialization |
|
||||
| `Packages/com.jovian.ingame-logging/Runtime/LogEntry.cs` | Create | Entry type (readonly struct) |
|
||||
| `Packages/com.jovian.ingame-logging/Runtime/GameLogSaveData.cs` | Create | Serializable save container |
|
||||
| `Packages/com.jovian.ingame-logging/Runtime/IGameLogStore.cs` | Create | Store interface |
|
||||
| `Packages/com.jovian.ingame-logging/Runtime/GameLogStore.cs` | Create | Ring buffer implementation |
|
||||
| `Packages/com.jovian.ingame-logging/Runtime/InGameLogger.cs` | Create | Per-caller facade struct |
|
||||
| `Packages/com.jovian.ingame-logging/Runtime/UI/LogEntryView.cs` | Create | Entry UI component |
|
||||
| `Packages/com.jovian.ingame-logging/Runtime/UI/GameLogView.cs` | Create | Scrollable log view with pool |
|
||||
| `Assets/Code/GameState/NoxSaveData.cs` | Modify | Add GameLogSaveData field + restore |
|
||||
| `Assets/Code/Core/EntryPoint.cs` | Modify | Create and wire GameLogStore |
|
||||
Reference in New Issue
Block a user