First commit on my server, yey!

This commit is contained in:
Sebastian Bularca
2026-03-19 18:12:07 +01:00
parent 5139ec2cec
commit fedd1961a0
602 changed files with 101587 additions and 6 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
using System.Collections.Generic;
using UnityEditor;
namespace Jovian.SaveSystem.Editor {
public sealed class SaveSystemSettingsProvider : SettingsProvider {
private SaveSystemSettings settings;
private SerializedObject serializedSettings;
private SaveSystemSettingsProvider(string path, SettingsScope scope)
: base(path, scope) { }
public override void OnActivate(string searchContext, UnityEngine.UIElements.VisualElement rootElement) {
settings = SaveSystemSettings.Load();
}
public override void OnGUI(string searchContext) {
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Save System Settings", EditorStyles.boldLabel);
EditorGUILayout.Space(5);
EditorGUI.BeginChangeCheck();
settings.saveFormat = (SaveFormat)EditorGUILayout.EnumPopup("Save Format", settings.saveFormat);
settings.maxAutoSavesPerSession = EditorGUILayout.IntSlider("Max Auto Saves Per Session", settings.maxAutoSavesPerSession, 1, 10);
settings.currentSaveVersion = EditorGUILayout.IntField("Current Save Version", settings.currentSaveVersion);
settings.saveDirectoryName = EditorGUILayout.TextField("Save Directory Name", settings.saveDirectoryName);
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Binary Obfuscation", EditorStyles.boldLabel);
settings.obfuscationKey = EditorGUILayout.TextField("Obfuscation Key", settings.obfuscationKey);
if(settings.saveFormat == SaveFormat.Json) {
EditorGUILayout.HelpBox("JSON format is human-readable and suitable for development. Switch to Binary for release builds.", MessageType.Info);
}
if(EditorGUI.EndChangeCheck()) {
settings.Save();
}
}
[SettingsProvider]
public static SettingsProvider CreateProvider() {
SaveSystemSettingsProvider provider = new SaveSystemSettingsProvider("Project/Jovian/Save System", SettingsScope.Project) {
keywords = new HashSet<string>(new[] { "save", "persistence", "serialization", "binary", "json" })
};
return provider;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 34f9753365e282e4f8c610df1cc61e28

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sebastian Bularca
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 230d63e981df3274ab47c00223c7b6c0
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,197 @@
# Jovian Save System
A generic, game-agnostic save system for Unity supporting multiple save slots, sessions, auto-saves, and dual JSON/Binary serialization.
## Requirements
- Unity 2022.3+
- Newtonsoft Json (`com.unity.nuget.newtonsoft-json` 3.2.1)
## Installation
Add as a git submodule or reference via Unity Package Manager:
```
https://github.com/sbularca/unity-save-system.git
```
## Architecture
```
ISaveSystem (facade)
├── ISaveSlotManager — session and slot allocation
├── ISaveSerializer — data encoding (JSON or Binary)
└── ISaveStorage — byte-level file I/O
```
All components are interface-driven and injected via constructor, making them easy to swap or mock for testing.
## Configuration
Settings are accessible in the Unity Editor under **Project Settings > Jovian > Save System**.
| Setting | Default | Description |
|---------|---------|-------------|
| Save Format | Json | Serialization format (Json or Binary) |
| Max Auto Saves | 3 | Auto-save slots before rotation begins |
| Save Version | 1 | Version number embedded in each save for migration |
| Save Directory | "saves" | Subfolder under `Application.persistentDataPath` |
| Obfuscation Key | "default-key" | XOR key used by the Binary serializer |
## Save Slot Types
| Type | Behavior |
|------|----------|
| **Manual** | Player-initiated. Each save gets a new slot (manual_001, manual_002, ...) |
| **Auto** | System-initiated. Creates slots up to the configured max, then rotates the oldest |
| **Quick** | Single slot per session. Always overwrites the previous quick save |
## Quick Start
### 1. Define your save data
```csharp
public class GameState {
public string playerName;
public int level;
public float health;
}
```
### 2. Wire up the save system
```csharp
SaveSystemSettings settings = SaveSystemSettings.Load();
ISaveStorage storage = new FileSystemSaveStorage(
Application.persistentDataPath, settings.saveDirectoryName);
ISaveSerializer serializer = settings.saveFormat == SaveFormat.Binary
? new BinarySaveSerializer(settings.obfuscationKey)
: new JsonSaveSerializer();
ISaveSlotManager slotManager = new SaveSlotManager(storage, settings);
ISaveSystem saveSystem = new SaveSystem(serializer, storage, slotManager, settings);
```
### 3. Create a session and save
```csharp
string sessionId = saveSystem.CreateSession();
GameState state = new GameState {
playerName = "Hero",
level = 10,
health = 95.5f
};
// Manual save
saveSystem.Save(sessionId, state, SaveSlotType.Manual);
// Auto save
saveSystem.Save(sessionId, state, SaveSlotType.Auto);
// Quick save
saveSystem.Save(sessionId, state, SaveSlotType.Quick);
```
### 4. Load a save
```csharp
IReadOnlyList<SaveSlotInfo> slots = saveSystem.GetSlots(sessionId);
foreach(SaveSlotInfo slot in slots) {
Debug.Log($"{slot.DisplayLabel} — {slot.TimestampDateTime}");
}
GameState loaded = saveSystem.Load<GameState>(slots[0]);
```
### 5. Async save/load
```csharp
await saveSystem.SaveAsync(sessionId, state, SaveSlotType.Auto);
GameState loaded = await saveSystem.LoadAsync<GameState>(slots[0]);
```
## Session Management
Sessions group related saves together (e.g. one playthrough).
```csharp
// List all sessions
IReadOnlyList<SaveSessionInfo> sessions = saveSystem.GetAllSessions();
foreach(SaveSessionInfo session in sessions) {
Debug.Log($"Session {session.sessionId} — Last save: {session.LastSaveDateTime}");
}
// Check if any saves exist
bool hasSaves = saveSystem.HasAnySaves();
// Delete a single slot
saveSystem.DeleteSlot(slots[0]);
// Delete an entire session and all its saves
saveSystem.DeleteSession(sessionId);
```
## Serialization Formats
### JSON (`JsonSaveSerializer`)
Human-readable, indented JSON. Ideal for development and debugging. Save files are plain text and easy to inspect.
### Binary (`BinarySaveSerializer`)
Compact binary format that applies XOR obfuscation with a configurable key followed by Deflate compression. Recommended for release builds — smaller file size and not trivially editable.
## Testing
The interfaces make it easy to test with an in-memory storage implementation:
```csharp
public class InMemorySaveStorage : ISaveStorage {
private readonly Dictionary<string, byte[]> files
= new Dictionary<string, byte[]>();
public void Write(string path, byte[] data) { files[path] = data; }
public byte[] Read(string path) { return files[path]; }
public bool Exists(string path) { return files.ContainsKey(path); }
public void Delete(string path) { files.Remove(path); }
public string[] List(string dir) {
return files.Keys.Where(k => k.StartsWith(dir)).ToArray();
}
public void CreateDirectory(string path) { }
// Async versions delegate to sync
public Task WriteAsync(string path, byte[] data) { Write(path, data); return Task.CompletedTask; }
public Task<byte[]> ReadAsync(string path) => Task.FromResult(Read(path));
public Task<bool> ExistsAsync(string path) => Task.FromResult(Exists(path));
public Task DeleteAsync(string path) { Delete(path); return Task.CompletedTask; }
public Task<string[]> ListAsync(string dir) => Task.FromResult(List(dir));
}
```
Then wire it up in your test:
```csharp
InMemorySaveStorage storage = new InMemorySaveStorage();
SaveSystemSettings settings = new SaveSystemSettings();
JsonSaveSerializer serializer = new JsonSaveSerializer();
SaveSlotManager slotManager = new SaveSlotManager(storage, settings);
ISaveSystem saveSystem = new SaveSystem(serializer, storage, slotManager, settings);
string sessionId = saveSystem.CreateSession();
saveSystem.Save(sessionId, myData, SaveSlotType.Manual);
IReadOnlyList<SaveSlotInfo> slots = saveSystem.GetSlots(sessionId);
MyData loaded = saveSystem.Load<MyData>(slots[0]);
Assert.AreEqual(myData.playerName, loaded.playerName);
```
## License
Internal package — Jovian Industries.

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
namespace Jovian.SaveSystem {
/// <summary>
/// Converts typed data to and from byte arrays.
/// Implementations define the format (JSON, binary, etc.).
/// </summary>
public interface ISaveSerializer {
byte[] Serialize<TData>(TData data);
TData Deserialize<TData>(byte[] payload);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 27f27474b2fc12f47b9eea77568ac3c6

View File

@@ -0,0 +1,23 @@
using System.Collections.Generic;
namespace Jovian.SaveSystem {
/// <summary>
/// Manages save slot allocation, session tracking, and auto-save rotation.
/// </summary>
public interface ISaveSlotManager {
SaveSlotInfo AllocateManualSlot(string sessionId);
SaveSlotInfo AllocateAutoSlot(string sessionId);
SaveSlotInfo AllocateQuickSlot(string sessionId);
IReadOnlyList<SaveSlotInfo> GetSlots(string sessionId);
IReadOnlyList<SaveSessionInfo> GetAllSessions();
string CreateSession();
void DeleteSlot(SaveSlotInfo slot);
void DeleteSession(string sessionId);
bool HasAnySaves();
void UpdateSlotMetadata(SaveSlotInfo slot, long timestampUtc, int saveVersion);
void PersistIndex();
}
}

View File

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

View File

@@ -0,0 +1,22 @@
using System.Threading.Tasks;
namespace Jovian.SaveSystem {
/// <summary>
/// Reads and writes raw byte arrays to a persistent location.
/// Has no knowledge of save data types or serialization formats.
/// </summary>
public interface ISaveStorage {
void Write(string path, byte[] data);
byte[] Read(string path);
bool Exists(string path);
void Delete(string path);
string[] List(string directoryPath);
void CreateDirectory(string path);
Task WriteAsync(string path, byte[] data);
Task<byte[]> ReadAsync(string path);
Task<bool> ExistsAsync(string path);
Task DeleteAsync(string path);
Task<string[]> ListAsync(string directoryPath);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5f445979ba5d9414abd75819f53bfaba

View File

@@ -0,0 +1,24 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Jovian.SaveSystem {
/// <summary>
/// Top-level facade for the save system. Orchestrates serialization,
/// storage, and slot management. This is the main API the game interacts with.
/// </summary>
public interface ISaveSystem {
string CreateSession();
bool HasAnySaves();
void Save<TData>(string sessionId, TData data, SaveSlotType slotType);
TData Load<TData>(SaveSlotInfo slot);
Task SaveAsync<TData>(string sessionId, TData data, SaveSlotType slotType);
Task<TData> LoadAsync<TData>(SaveSlotInfo slot);
IReadOnlyList<SaveSlotInfo> GetSlots(string sessionId);
IReadOnlyList<SaveSessionInfo> GetAllSessions();
void DeleteSlot(SaveSlotInfo slot);
void DeleteSession(string sessionId);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 54cb074c17a51c74d93aadb3ce7b2e47

View File

@@ -0,0 +1,16 @@
{
"name": "Jovian.SaveSystem",
"rootNamespace": "Jovian.SaveSystem",
"references": [],
"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: a19ae8824b9d43447be860535961727d
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

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

View File

@@ -0,0 +1,16 @@
using System;
using Newtonsoft.Json.Linq;
namespace Jovian.SaveSystem {
/// <summary>
/// On-disk wrapper that pairs minimal metadata with the game data payload.
/// Payload is stored as JToken so it remains readable JSON when using JsonSaveSerializer.
/// </summary>
[Serializable]
public sealed class SaveEnvelope {
public int version;
public long timestampUtc;
public SaveSlotType slotType;
public JToken payload;
}
}

View File

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

View File

@@ -0,0 +1,6 @@
namespace Jovian.SaveSystem {
public enum SaveFormat {
Json,
Binary
}
}

View File

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

View File

@@ -0,0 +1,7 @@
namespace Jovian.SaveSystem {
public enum SaveSlotType {
Manual,
Auto,
Quick
}
}

View File

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

View File

@@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace Jovian.SaveSystem {
/// <summary>
/// Facade that orchestrates serialization, storage, and slot management.
/// Thread-safe: uses SemaphoreSlim for async and lock for sync operations.
/// </summary>
public sealed class SaveSystem : ISaveSystem {
private readonly ISaveSerializer serializer;
private readonly ISaveStorage storage;
private readonly ISaveSlotManager slotManager;
private readonly int saveVersion;
private readonly object syncLock = new object();
private readonly SemaphoreSlim asyncLock = new SemaphoreSlim(1, 1);
public SaveSystem(
ISaveSerializer serializer,
ISaveStorage storage,
ISaveSlotManager slotManager,
SaveSystemSettings settings) {
this.serializer = serializer;
this.storage = storage;
this.slotManager = slotManager;
saveVersion = settings.currentSaveVersion;
}
public string CreateSession() {
return slotManager.CreateSession();
}
public bool HasAnySaves() {
return slotManager.HasAnySaves();
}
public void Save<TData>(string sessionId, TData data, SaveSlotType slotType) {
lock(syncLock) {
SaveSlotInfo slot = AllocateSlot(sessionId, slotType);
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
SaveEnvelope envelope = new SaveEnvelope {
version = saveVersion,
timestampUtc = timestamp,
slotType = slotType,
payload = JToken.FromObject(data)
};
byte[] envelopeBytes = serializer.Serialize(envelope);
storage.Write(slot.filePath, envelopeBytes);
slotManager.UpdateSlotMetadata(slot, timestamp, saveVersion);
slotManager.PersistIndex();
Debug.Log($"[SaveSystem] Saved {slotType} to {slot.filePath}");
}
}
public TData Load<TData>(SaveSlotInfo slot) {
lock(syncLock) {
return LoadInternal<TData>(slot);
}
}
public async Task SaveAsync<TData>(string sessionId, TData data, SaveSlotType slotType) {
await asyncLock.WaitAsync().ConfigureAwait(false);
try {
SaveSlotInfo slot = AllocateSlot(sessionId, slotType);
long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
SaveEnvelope envelope = new SaveEnvelope {
version = saveVersion,
timestampUtc = timestamp,
slotType = slotType,
payload = JToken.FromObject(data)
};
byte[] envelopeBytes = serializer.Serialize(envelope);
await storage.WriteAsync(slot.filePath, envelopeBytes).ConfigureAwait(false);
slotManager.UpdateSlotMetadata(slot, timestamp, saveVersion);
slotManager.PersistIndex();
Debug.Log($"[SaveSystem] Saved {slotType} to {slot.filePath}");
} finally {
asyncLock.Release();
}
}
public async Task<TData> LoadAsync<TData>(SaveSlotInfo slot) {
await asyncLock.WaitAsync().ConfigureAwait(false);
try {
return LoadInternal<TData>(slot);
} finally {
asyncLock.Release();
}
}
public IReadOnlyList<SaveSlotInfo> GetSlots(string sessionId) {
return slotManager.GetSlots(sessionId);
}
public IReadOnlyList<SaveSessionInfo> GetAllSessions() {
return slotManager.GetAllSessions();
}
public void DeleteSlot(SaveSlotInfo slot) {
lock(syncLock) {
slotManager.DeleteSlot(slot);
}
}
public void DeleteSession(string sessionId) {
lock(syncLock) {
slotManager.DeleteSession(sessionId);
}
}
private SaveSlotInfo AllocateSlot(string sessionId, SaveSlotType slotType) {
return slotType switch {
SaveSlotType.Manual => slotManager.AllocateManualSlot(sessionId),
SaveSlotType.Auto => slotManager.AllocateAutoSlot(sessionId),
SaveSlotType.Quick => slotManager.AllocateQuickSlot(sessionId),
_ => throw new ArgumentOutOfRangeException(nameof(slotType), slotType, "Unknown slot type.")
};
}
private TData LoadInternal<TData>(SaveSlotInfo slot) {
byte[] envelopeBytes = storage.Read(slot.filePath);
SaveEnvelope envelope = serializer.Deserialize<SaveEnvelope>(envelopeBytes);
return envelope.payload.ToObject<TData>();
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Text;
using Newtonsoft.Json;
namespace Jovian.SaveSystem {
/// <summary>
/// Serializes data to an obfuscated binary format.
/// Pipeline: JSON string → UTF-8 bytes → XOR obfuscation → DeflateStream compression.
/// </summary>
public sealed class BinarySaveSerializer : ISaveSerializer {
private readonly byte[] keyBytes;
private readonly JsonSerializerSettings serializerSettings;
public BinarySaveSerializer(string obfuscationKey) {
if(string.IsNullOrEmpty(obfuscationKey)) {
throw new ArgumentException("Obfuscation key must not be null or empty.", nameof(obfuscationKey));
}
keyBytes = Encoding.UTF8.GetBytes(obfuscationKey);
serializerSettings = new JsonSerializerSettings {
TypeNameHandling = TypeNameHandling.None,
NullValueHandling = NullValueHandling.Ignore,
Formatting = Formatting.None
};
}
public byte[] Serialize<TData>(TData data) {
string json = JsonConvert.SerializeObject(data, serializerSettings);
byte[] jsonBytes = Encoding.UTF8.GetBytes(json);
byte[] obfuscated = ApplyXor(jsonBytes);
return Compress(obfuscated);
}
public TData Deserialize<TData>(byte[] payload) {
if(payload == null || payload.Length == 0) {
throw new ArgumentException("Payload is null or empty.", nameof(payload));
}
byte[] decompressed = Decompress(payload);
byte[] deobfuscated = ApplyXor(decompressed);
string json = Encoding.UTF8.GetString(deobfuscated);
return JsonConvert.DeserializeObject<TData>(json, serializerSettings);
}
private byte[] ApplyXor(byte[] data) {
byte[] result = new byte[data.Length];
for(int i = 0; i < data.Length; i++) {
result[i] = (byte)(data[i] ^ keyBytes[i % keyBytes.Length]);
}
return result;
}
private static byte[] Compress(byte[] data) {
using(MemoryStream output = new MemoryStream()) {
using(DeflateStream deflate = new DeflateStream(output, CompressionLevel.Fastest, leaveOpen: true)) {
deflate.Write(data, 0, data.Length);
}
return output.ToArray();
}
}
private static byte[] Decompress(byte[] data) {
using(MemoryStream input = new MemoryStream(data)) {
using(DeflateStream deflate = new DeflateStream(input, CompressionMode.Decompress)) {
using(MemoryStream output = new MemoryStream()) {
deflate.CopyTo(output);
return output.ToArray();
}
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4f7af3b3f06e67b408b0f52089acdeea

View File

@@ -0,0 +1,34 @@
using System;
using System.Text;
using Newtonsoft.Json;
namespace Jovian.SaveSystem {
/// <summary>
/// Serializes data to/from JSON using Newtonsoft.Json.
/// </summary>
public sealed class JsonSaveSerializer : ISaveSerializer {
private readonly JsonSerializerSettings serializerSettings;
public JsonSaveSerializer() {
serializerSettings = new JsonSerializerSettings {
TypeNameHandling = TypeNameHandling.None,
NullValueHandling = NullValueHandling.Ignore,
Formatting = Formatting.Indented
};
}
public byte[] Serialize<TData>(TData data) {
string json = JsonConvert.SerializeObject(data, serializerSettings);
return Encoding.UTF8.GetBytes(json);
}
public TData Deserialize<TData>(byte[] payload) {
if(payload == null || payload.Length == 0) {
throw new ArgumentException("Payload is null or empty.", nameof(payload));
}
string json = Encoding.UTF8.GetString(payload);
return JsonConvert.DeserializeObject<TData>(json, serializerSettings);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
using System;
using System.IO;
using Newtonsoft.Json;
using UnityEngine;
namespace Jovian.SaveSystem {
/// <summary>
/// Configuration for the save system. Stored as JSON in ProjectSettings/SaveSystemSettings.json.
/// </summary>
[Serializable]
public sealed class SaveSystemSettings {
private const string SettingsPath = "ProjectSettings/SaveSystemSettings.json";
public SaveFormat saveFormat = SaveFormat.Json;
public int maxAutoSavesPerSession = 3;
public int currentSaveVersion = 1;
public string obfuscationKey = "default-key";
public string saveDirectoryName = "saves";
public static SaveSystemSettings Load() {
if(!File.Exists(SettingsPath)) {
return new SaveSystemSettings();
}
try {
var json = File.ReadAllText(SettingsPath);
return JsonConvert.DeserializeObject<SaveSystemSettings>(json) ?? new SaveSystemSettings();
} catch(Exception e) {
Debug.LogWarning($"[SaveSystem] Failed to load settings: {e.Message}. Using defaults.");
return new SaveSystemSettings();
}
}
public void Save() {
try {
var directory = Path.GetDirectoryName(SettingsPath);
if(!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) {
Directory.CreateDirectory(directory);
}
var json = JsonConvert.SerializeObject(this, Formatting.Indented);
File.WriteAllText(SettingsPath, json);
} catch(Exception e) {
Debug.LogError($"[SaveSystem] Failed to save settings: {e.Message}");
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 86b7c6be79eeb6a48aa96d9041dcb5ed

View File

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

View File

@@ -0,0 +1,16 @@
using System;
namespace Jovian.SaveSystem {
/// <summary>
/// Describes a save session (a new game playthrough).
/// </summary>
[Serializable]
public sealed class SaveSessionInfo {
public string sessionId;
public long creationDateUtc;
public long lastSaveDateUtc;
public DateTime CreationDateTime => DateTimeOffset.FromUnixTimeMilliseconds(creationDateUtc).UtcDateTime;
public DateTime LastSaveDateTime => DateTimeOffset.FromUnixTimeMilliseconds(lastSaveDateUtc).UtcDateTime;
}
}

View File

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

View File

@@ -0,0 +1,26 @@
using System;
namespace Jovian.SaveSystem {
/// <summary>
/// Describes a single save slot within a session.
/// </summary>
[Serializable]
public sealed class SaveSlotInfo {
public string sessionId;
public SaveSlotType slotType;
public int slotNumber;
public string filePath;
public long timestampUtc;
public int saveVersion;
public string DisplayLabel =>
slotType switch {
SaveSlotType.Manual => $"Manual Save {slotNumber}",
SaveSlotType.Auto => $"Auto Save {slotNumber}",
SaveSlotType.Quick => "Quick Save",
_ => "Unknown"
};
public DateTime TimestampDateTime => DateTimeOffset.FromUnixTimeMilliseconds(timestampUtc).UtcDateTime;
}
}

View File

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

View File

@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Newtonsoft.Json;
namespace Jovian.SaveSystem {
/// <summary>
/// Manages save slot allocation, session tracking, and auto-save rotation.
/// Maintains a persistent index file that tracks all sessions and their slots.
/// </summary>
public sealed class SaveSlotManager : ISaveSlotManager {
private const string IndexFileName = "index.json";
private const string ManualPrefix = "manual_";
private const string AutoPrefix = "auto_";
private const string QuickFileName = "quick.sav";
private const string SaveExtension = ".sav";
private readonly ISaveStorage storage;
private readonly int maxAutoSaves;
private SaveIndex index;
public SaveSlotManager(ISaveStorage storage, SaveSystemSettings settings) {
this.storage = storage;
maxAutoSaves = settings.maxAutoSavesPerSession;
LoadIndex();
}
public string CreateSession() {
string sessionId = Guid.NewGuid().ToString("N");
SaveSessionInfo session = new SaveSessionInfo {
sessionId = sessionId,
creationDateUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
lastSaveDateUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
index.sessions.Add(session);
storage.CreateDirectory(sessionId);
PersistIndex();
return sessionId;
}
public SaveSlotInfo AllocateManualSlot(string sessionId) {
List<SaveSlotInfo> sessionSlots = GetOrCreateSessionSlots(sessionId);
int nextNumber = sessionSlots
.Where(s => s.slotType == SaveSlotType.Manual)
.Select(s => s.slotNumber)
.DefaultIfEmpty(0)
.Max() + 1;
SaveSlotInfo slot = new SaveSlotInfo {
sessionId = sessionId,
slotType = SaveSlotType.Manual,
slotNumber = nextNumber,
filePath = $"{sessionId}/{ManualPrefix}{nextNumber:D3}{SaveExtension}",
timestampUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
index.slots.Add(slot);
return slot;
}
public SaveSlotInfo AllocateAutoSlot(string sessionId) {
List<SaveSlotInfo> sessionSlots = GetOrCreateSessionSlots(sessionId);
List<SaveSlotInfo> autoSlots = sessionSlots
.Where(s => s.slotType == SaveSlotType.Auto)
.OrderBy(s => s.slotNumber)
.ToList();
if(autoSlots.Count >= maxAutoSaves) {
// Rotate: reuse the oldest slot
var oldest = autoSlots.OrderBy(s => s.timestampUtc).First();
oldest.timestampUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
return oldest;
}
int nextNumber = autoSlots.Count + 1;
SaveSlotInfo slot = new SaveSlotInfo {
sessionId = sessionId,
slotType = SaveSlotType.Auto,
slotNumber = nextNumber,
filePath = $"{sessionId}/{AutoPrefix}{nextNumber:D3}{SaveExtension}",
timestampUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
index.slots.Add(slot);
return slot;
}
public SaveSlotInfo AllocateQuickSlot(string sessionId) {
List<SaveSlotInfo> sessionSlots = GetOrCreateSessionSlots(sessionId);
SaveSlotInfo existingQuick = sessionSlots.FirstOrDefault(s => s.slotType == SaveSlotType.Quick);
if(existingQuick != null) {
existingQuick.timestampUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
return existingQuick;
}
SaveSlotInfo slot = new SaveSlotInfo {
sessionId = sessionId,
slotType = SaveSlotType.Quick,
slotNumber = 1,
filePath = $"{sessionId}/{QuickFileName}",
timestampUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
index.slots.Add(slot);
return slot;
}
public IReadOnlyList<SaveSlotInfo> GetSlots(string sessionId) {
return index.slots
.Where(s => s.sessionId == sessionId)
.OrderByDescending(s => s.timestampUtc)
.ToList();
}
public IReadOnlyList<SaveSessionInfo> GetAllSessions() {
return index.sessions
.OrderByDescending(s => s.lastSaveDateUtc)
.ToList();
}
public bool HasAnySaves() {
return index.slots.Count > 0;
}
public void DeleteSlot(SaveSlotInfo slot) {
storage.Delete(slot.filePath);
index.slots.RemoveAll(s => s.filePath == slot.filePath);
PersistIndex();
}
public void DeleteSession(string sessionId) {
List<SaveSlotInfo> slotsToDelete = index.slots
.Where(s => s.sessionId == sessionId)
.ToList();
foreach(SaveSlotInfo slot in slotsToDelete) {
storage.Delete(slot.filePath);
}
index.slots.RemoveAll(s => s.sessionId == sessionId);
index.sessions.RemoveAll(s => s.sessionId == sessionId);
PersistIndex();
}
public void UpdateSlotMetadata(SaveSlotInfo slot, long timestampUtc, int saveVersion) {
slot.timestampUtc = timestampUtc;
slot.saveVersion = saveVersion;
SaveSessionInfo session = index.sessions.FirstOrDefault(s => s.sessionId == slot.sessionId);
if(session != null) {
session.lastSaveDateUtc = timestampUtc;
}
}
public void PersistIndex() {
string json = JsonConvert.SerializeObject(index, Formatting.Indented);
byte[] bytes = Encoding.UTF8.GetBytes(json);
storage.Write(IndexFileName, bytes);
}
private void LoadIndex() {
if(!storage.Exists(IndexFileName)) {
index = new SaveIndex();
return;
}
try {
byte[] bytes = storage.Read(IndexFileName);
string json = Encoding.UTF8.GetString(bytes);
index = JsonConvert.DeserializeObject<SaveIndex>(json) ?? new SaveIndex();
} catch(Exception) {
index = new SaveIndex();
}
}
private List<SaveSlotInfo> GetOrCreateSessionSlots(string sessionId) {
if(!index.sessions.Any(s => s.sessionId == sessionId)) {
SaveSessionInfo session = new SaveSessionInfo {
sessionId = sessionId,
creationDateUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
lastSaveDateUtc = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
index.sessions.Add(session);
storage.CreateDirectory(sessionId);
}
return index.slots.Where(s => s.sessionId == sessionId).ToList();
}
/// <summary>
/// Internal index structure persisted to disk.
/// </summary>
[Serializable]
private sealed class SaveIndex {
public List<SaveSessionInfo> sessions = new List<SaveSessionInfo>();
public List<SaveSlotInfo> slots = new List<SaveSlotInfo>();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9498521d42a48944ba38a961e8e82459

View File

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

View File

@@ -0,0 +1,107 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;
namespace Jovian.SaveSystem {
/// <summary>
/// File-system backed storage. Base path is typically Application.persistentDataPath.
/// All paths passed to methods are relative to the constructed base path.
/// </summary>
public sealed class FileSystemSaveStorage : ISaveStorage {
private readonly string basePath;
public FileSystemSaveStorage(string rootPath, string saveDirectoryName) {
basePath = Path.Combine(rootPath, saveDirectoryName);
}
private string ResolvePath(string relativePath) {
return Path.Combine(basePath, relativePath);
}
public void CreateDirectory(string path) {
string fullPath = ResolvePath(path);
if(!Directory.Exists(fullPath)) {
Directory.CreateDirectory(fullPath);
}
}
public void Write(string path, byte[] data) {
string fullPath = ResolvePath(path);
EnsureDirectory(fullPath);
File.WriteAllBytes(fullPath, data);
}
public byte[] Read(string path) {
string fullPath = ResolvePath(path);
if(!File.Exists(fullPath)) {
throw new FileNotFoundException($"Save file not found: {fullPath}");
}
return File.ReadAllBytes(fullPath);
}
public bool Exists(string path) {
return File.Exists(ResolvePath(path));
}
public void Delete(string path) {
string fullPath = ResolvePath(path);
if(File.Exists(fullPath)) {
File.Delete(fullPath);
}
}
public string[] List(string directoryPath) {
string fullPath = ResolvePath(directoryPath);
if(!Directory.Exists(fullPath)) {
return Array.Empty<string>();
}
return Directory.GetFiles(fullPath)
.Select(f => Path.GetFileName(f))
.ToArray();
}
public async Task WriteAsync(string path, byte[] data) {
string fullPath = ResolvePath(path);
EnsureDirectory(fullPath);
using(FileStream stream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true)) {
await stream.WriteAsync(data, 0, data.Length).ConfigureAwait(false);
}
}
public async Task<byte[]> ReadAsync(string path) {
string fullPath = ResolvePath(path);
if(!File.Exists(fullPath)) {
throw new FileNotFoundException($"Save file not found: {fullPath}");
}
using(FileStream stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true)) {
byte[] buffer = new byte[stream.Length];
await stream.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false);
return buffer;
}
}
public Task<bool> ExistsAsync(string path) {
return Task.FromResult(Exists(path));
}
public Task DeleteAsync(string path) {
Delete(path);
return Task.CompletedTask;
}
public Task<string[]> ListAsync(string directoryPath) {
return Task.FromResult(List(directoryPath));
}
private static void EnsureDirectory(string filePath) {
string directory = Path.GetDirectoryName(filePath);
if(!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) {
Directory.CreateDirectory(directory);
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
{
"name": "Jovian.SaveSystem.EditorTests",
"rootNamespace": "Jovian.SaveSystem.Tests.Editor",
"references": [
"Jovian.SaveSystem",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}

View File

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

View File

@@ -0,0 +1,104 @@
using System;
using NUnit.Framework;
namespace Jovian.SaveSystem.Tests.Editor {
public class SaveSerializerTests {
[Serializable]
private sealed class TestData {
public string name;
public int score;
public float[] positions;
}
[Test]
public void JsonSerializer_RoundTrip_PreservesData() {
JsonSaveSerializer serializer = new JsonSaveSerializer();
TestData original = new TestData {
name = "TestPlayer",
score = 42,
positions = new[] { 1.5f, 2.5f, 3.5f }
};
byte[] bytes = serializer.Serialize(original);
TestData deserialized = serializer.Deserialize<TestData>(bytes);
Assert.AreEqual(original.name, deserialized.name);
Assert.AreEqual(original.score, deserialized.score);
Assert.AreEqual(original.positions, deserialized.positions);
}
[Test]
public void JsonSerializer_ProducesNonEmptyBytes() {
JsonSaveSerializer serializer = new JsonSaveSerializer();
TestData data = new TestData { name = "Test", score = 1 };
byte[] bytes = serializer.Serialize(data);
Assert.IsNotNull(bytes);
Assert.Greater(bytes.Length, 0);
}
[Test]
public void JsonSerializer_DeserializeNull_Throws() {
JsonSaveSerializer serializer = new JsonSaveSerializer();
Assert.Throws<ArgumentException>(() => serializer.Deserialize<TestData>(null));
Assert.Throws<ArgumentException>(() => serializer.Deserialize<TestData>(Array.Empty<byte>()));
}
[Test]
public void BinarySerializer_RoundTrip_PreservesData() {
BinarySaveSerializer serializer = new BinarySaveSerializer("test-key-123");
TestData original = new TestData {
name = "BinaryPlayer",
score = 99,
positions = new[] { 10f, 20f, 30f }
};
byte[] bytes = serializer.Serialize(original);
TestData deserialized = serializer.Deserialize<TestData>(bytes);
Assert.AreEqual(original.name, deserialized.name);
Assert.AreEqual(original.score, deserialized.score);
Assert.AreEqual(original.positions, deserialized.positions);
}
[Test]
public void BinarySerializer_OutputDiffersFromJson() {
JsonSaveSerializer jsonSerializer = new JsonSaveSerializer();
BinarySaveSerializer binarySerializer = new BinarySaveSerializer("test-key");
TestData data = new TestData { name = "Test", score = 1 };
byte[] jsonBytes = jsonSerializer.Serialize(data);
byte[] binaryBytes = binarySerializer.Serialize(data);
Assert.AreNotEqual(jsonBytes, binaryBytes);
}
[Test]
public void BinarySerializer_DifferentKeys_ProduceDifferentOutput() {
BinarySaveSerializer serializer1 = new BinarySaveSerializer("key-alpha");
BinarySaveSerializer serializer2 = new BinarySaveSerializer("key-bravo");
TestData data = new TestData { name = "Test", score = 1 };
byte[] bytes1 = serializer1.Serialize(data);
byte[] bytes2 = serializer2.Serialize(data);
Assert.AreNotEqual(bytes1, bytes2);
}
[Test]
public void BinarySerializer_EmptyKey_Throws() {
Assert.Throws<ArgumentException>(() => new BinarySaveSerializer(""));
Assert.Throws<ArgumentException>(() => new BinarySaveSerializer(null));
}
[Test]
public void BinarySerializer_DeserializeNull_Throws() {
BinarySaveSerializer serializer = new BinarySaveSerializer("key");
Assert.Throws<ArgumentException>(() => serializer.Deserialize<TestData>(null));
Assert.Throws<ArgumentException>(() => serializer.Deserialize<TestData>(Array.Empty<byte>()));
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8e16fa5e1d6fe1240bbb82bbb338cec3

View File

@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
namespace Jovian.SaveSystem.Tests.Editor {
public class SaveSlotManagerTests {
private InMemorySaveStorage storage;
private SaveSystemSettings settings;
private SaveSlotManager slotManager;
[SetUp]
public void SetUp() {
storage = new InMemorySaveStorage();
settings = new SaveSystemSettings { maxAutoSavesPerSession = 3 };
slotManager = new SaveSlotManager(storage, settings);
}
[Test]
public void CreateSession_ReturnsNonEmptyId() {
string sessionId = slotManager.CreateSession();
Assert.IsNotNull(sessionId);
Assert.IsNotEmpty(sessionId);
}
[Test]
public void CreateSession_AppearsInAllSessions() {
string sessionId = slotManager.CreateSession();
IReadOnlyList<SaveSessionInfo> sessions = slotManager.GetAllSessions();
Assert.AreEqual(1, sessions.Count);
Assert.AreEqual(sessionId, sessions[0].sessionId);
}
[Test]
public void AllocateManualSlot_IncrementsSlotNumber() {
string sessionId = slotManager.CreateSession();
SaveSlotInfo slot1 = slotManager.AllocateManualSlot(sessionId);
SaveSlotInfo slot2 = slotManager.AllocateManualSlot(sessionId);
Assert.AreEqual(1, slot1.slotNumber);
Assert.AreEqual(2, slot2.slotNumber);
Assert.AreEqual(SaveSlotType.Manual, slot1.slotType);
}
[Test]
public void AllocateAutoSlot_RotatesWhenMaxReached() {
string sessionId = slotManager.CreateSession();
SaveSlotInfo slot1 = slotManager.AllocateAutoSlot(sessionId);
SaveSlotInfo slot2 = slotManager.AllocateAutoSlot(sessionId);
SaveSlotInfo slot3 = slotManager.AllocateAutoSlot(sessionId);
// 4th should rotate to reuse the oldest
SaveSlotInfo slot4 = slotManager.AllocateAutoSlot(sessionId);
// slot4 should reuse slot1's file path (oldest by timestamp)
Assert.AreEqual(slot1.filePath, slot4.filePath);
}
[Test]
public void AllocateQuickSlot_AlwaysReturnsSameSlot() {
string sessionId = slotManager.CreateSession();
SaveSlotInfo slot1 = slotManager.AllocateQuickSlot(sessionId);
SaveSlotInfo slot2 = slotManager.AllocateQuickSlot(sessionId);
Assert.AreEqual(slot1.filePath, slot2.filePath);
Assert.AreEqual(SaveSlotType.Quick, slot1.slotType);
}
[Test]
public void GetSlots_ReturnsOnlySessionSlots() {
string session1 = slotManager.CreateSession();
string session2 = slotManager.CreateSession();
slotManager.AllocateManualSlot(session1);
slotManager.AllocateManualSlot(session1);
slotManager.AllocateManualSlot(session2);
IReadOnlyList<SaveSlotInfo> slots1 = slotManager.GetSlots(session1);
IReadOnlyList<SaveSlotInfo> slots2 = slotManager.GetSlots(session2);
Assert.AreEqual(2, slots1.Count);
Assert.AreEqual(1, slots2.Count);
}
[Test]
public void HasAnySaves_FalseWhenEmpty_TrueAfterAllocation() {
Assert.IsFalse(slotManager.HasAnySaves());
string sessionId = slotManager.CreateSession();
slotManager.AllocateManualSlot(sessionId);
Assert.IsTrue(slotManager.HasAnySaves());
}
[Test]
public void DeleteSlot_RemovesFromIndex() {
string sessionId = slotManager.CreateSession();
SaveSlotInfo slot = slotManager.AllocateManualSlot(sessionId);
slotManager.DeleteSlot(slot);
Assert.AreEqual(0, slotManager.GetSlots(sessionId).Count);
}
[Test]
public void DeleteSession_RemovesAllSlotsAndSession() {
string sessionId = slotManager.CreateSession();
slotManager.AllocateManualSlot(sessionId);
slotManager.AllocateAutoSlot(sessionId);
slotManager.AllocateQuickSlot(sessionId);
slotManager.DeleteSession(sessionId);
Assert.AreEqual(0, slotManager.GetAllSessions().Count);
Assert.AreEqual(0, slotManager.GetSlots(sessionId).Count);
}
/// <summary>
/// In-memory ISaveStorage for testing without file system.
/// </summary>
private sealed class InMemorySaveStorage : ISaveStorage {
private readonly Dictionary<string, byte[]> files = new Dictionary<string, byte[]>();
private readonly HashSet<string> directories = new HashSet<string>();
public void Write(string path, byte[] data) { files[path] = data; }
public byte[] Read(string path) { return files[path]; }
public bool Exists(string path) { return files.ContainsKey(path); }
public void Delete(string path) { files.Remove(path); }
public string[] List(string directoryPath) {
return files.Keys
.Where(k => k.StartsWith(directoryPath))
.ToArray();
}
public void CreateDirectory(string path) { directories.Add(path); }
public Task WriteAsync(string path, byte[] data) { Write(path, data); return Task.CompletedTask; }
public Task<byte[]> ReadAsync(string path) { return Task.FromResult(Read(path)); }
public Task<bool> ExistsAsync(string path) { return Task.FromResult(Exists(path)); }
public Task DeleteAsync(string path) { Delete(path); return Task.CompletedTask; }
public Task<string[]> ListAsync(string directoryPath) { return Task.FromResult(List(directoryPath)); }
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 942e63ef3dd14ac4294a5330ba0442c4

View File

@@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
namespace Jovian.SaveSystem.Tests.Editor {
public class SaveSystemFacadeTests {
[Serializable]
private sealed class GameState {
public string playerName;
public int level;
public float health;
}
private InMemorySaveStorage storage;
private SaveSystemSettings settings;
private ISaveSystem saveSystem;
[SetUp]
public void SetUp() {
storage = new InMemorySaveStorage();
settings = new SaveSystemSettings {
maxAutoSavesPerSession = 3,
currentSaveVersion = 1
};
JsonSaveSerializer serializer = new JsonSaveSerializer();
SaveSlotManager slotManager = new SaveSlotManager(storage, settings);
saveSystem = new SaveSystem(serializer, storage, slotManager, settings);
}
[Test]
public void SaveAndLoad_ManualSlot_RoundTrips() {
string sessionId = saveSystem.CreateSession();
GameState original = new GameState {
playerName = "Hero",
level = 10,
health = 95.5f
};
saveSystem.Save(sessionId, original, SaveSlotType.Manual);
IReadOnlyList<SaveSlotInfo> slots = saveSystem.GetSlots(sessionId);
Assert.AreEqual(1, slots.Count);
GameState loaded = saveSystem.Load<GameState>(slots[0]);
Assert.AreEqual(original.playerName, loaded.playerName);
Assert.AreEqual(original.level, loaded.level);
Assert.AreEqual(original.health, loaded.health);
}
[Test]
public void SaveAndLoad_QuickSlot_OverwritesPrevious() {
string sessionId = saveSystem.CreateSession();
saveSystem.Save(sessionId, new GameState { playerName = "First" }, SaveSlotType.Quick);
saveSystem.Save(sessionId, new GameState { playerName = "Second" }, SaveSlotType.Quick);
IReadOnlyList<SaveSlotInfo> slots = saveSystem.GetSlots(sessionId);
SaveSlotInfo quickSlot = slots.First(s => s.slotType == SaveSlotType.Quick);
GameState loaded = saveSystem.Load<GameState>(quickSlot);
Assert.AreEqual("Second", loaded.playerName);
}
[Test]
public void HasAnySaves_ReflectsState() {
Assert.IsFalse(saveSystem.HasAnySaves());
string sessionId = saveSystem.CreateSession();
saveSystem.Save(sessionId, new GameState { playerName = "Test" }, SaveSlotType.Manual);
Assert.IsTrue(saveSystem.HasAnySaves());
}
[Test]
public void DeleteSlot_RemovesSave() {
string sessionId = saveSystem.CreateSession();
saveSystem.Save(sessionId, new GameState { playerName = "Delete Me" }, SaveSlotType.Manual);
IReadOnlyList<SaveSlotInfo> slots = saveSystem.GetSlots(sessionId);
saveSystem.DeleteSlot(slots[0]);
Assert.AreEqual(0, saveSystem.GetSlots(sessionId).Count);
}
[Test]
public void DeleteSession_RemovesEverything() {
string sessionId = saveSystem.CreateSession();
saveSystem.Save(sessionId, new GameState { playerName = "A" }, SaveSlotType.Manual);
saveSystem.Save(sessionId, new GameState { playerName = "B" }, SaveSlotType.Auto);
saveSystem.Save(sessionId, new GameState { playerName = "C" }, SaveSlotType.Quick);
saveSystem.DeleteSession(sessionId);
Assert.AreEqual(0, saveSystem.GetAllSessions().Count);
Assert.IsFalse(saveSystem.HasAnySaves());
}
[Test]
public void MultipleSessions_AreIndependent() {
string session1 = saveSystem.CreateSession();
string session2 = saveSystem.CreateSession();
saveSystem.Save(session1, new GameState { playerName = "Player1" }, SaveSlotType.Manual);
saveSystem.Save(session2, new GameState { playerName = "Player2" }, SaveSlotType.Manual);
Assert.AreEqual(1, saveSystem.GetSlots(session1).Count);
Assert.AreEqual(1, saveSystem.GetSlots(session2).Count);
GameState loaded1 = saveSystem.Load<GameState>(saveSystem.GetSlots(session1)[0]);
GameState loaded2 = saveSystem.Load<GameState>(saveSystem.GetSlots(session2)[0]);
Assert.AreEqual("Player1", loaded1.playerName);
Assert.AreEqual("Player2", loaded2.playerName);
}
/// <summary>
/// In-memory ISaveStorage for testing.
/// </summary>
private sealed class InMemorySaveStorage : ISaveStorage {
private readonly Dictionary<string, byte[]> files = new Dictionary<string, byte[]>();
private readonly HashSet<string> directories = new HashSet<string>();
public void Write(string path, byte[] data) { files[path] = data; }
public byte[] Read(string path) { return files[path]; }
public bool Exists(string path) { return files.ContainsKey(path); }
public void Delete(string path) { files.Remove(path); }
public string[] List(string directoryPath) {
return files.Keys
.Where(k => k.StartsWith(directoryPath))
.ToArray();
}
public void CreateDirectory(string path) { directories.Add(path); }
public Task WriteAsync(string path, byte[] data) { Write(path, data); return Task.CompletedTask; }
public Task<byte[]> ReadAsync(string path) { return Task.FromResult(Read(path)); }
public Task<bool> ExistsAsync(string path) { return Task.FromResult(Exists(path)); }
public Task DeleteAsync(string path) { Delete(path); return Task.CompletedTask; }
public Task<string[]> ListAsync(string directoryPath) { return Task.FromResult(List(directoryPath)); }
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 55a1496c116a3434291a828b258bbb97

View File

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

View File

@@ -0,0 +1,22 @@
{
"name": "Jovian.SaveSystem.RuntimeTests",
"rootNamespace": "Jovian.SaveSystem.Tests.Runtime",
"references": [
"Jovian.SaveSystem",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}

View File

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

View File

@@ -0,0 +1,18 @@
{
"name": "com.jovian.savesystem",
"version": "0.1.0",
"displayName": "Jovian Save System",
"description": "A generic, game-agnostic save system supporting multiple save slots, sessions, auto-saves, and dual JSON/Binary serialization.",
"unity": "2022.3",
"dependencies": {
"com.unity.nuget.newtonsoft-json": "3.2.1"
},
"keywords": [
"save",
"persistence",
"serialization"
],
"author": {
"name": "Jovian"
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,319 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
using Newtonsoft.Json;
using UnityEditor.Callbacks;
namespace Jovian.PackageSync {
public sealed class PackageSyncWindow : EditorWindow {
// private static PackageMapping[] defaultPackages = new PackageMapping[] {
// new(
// "Save System",
// "Packages/com.jovian.savesystem",
// @"D:\repos\unity-save-system"),
// new(
// "Zone System",
// "Packages/com.jovian.zonesystem",
// @"D:\repos\unity-zone-system")
// };
private static string FilePath => Path.Combine(Directory.GetParent(Application.dataPath).FullName, "ProjectSettings/PackageSyncSettings.json");
private Vector2 scrollPosition;
private bool hasChanges;
private bool syncEnabled;
private PackageMapping[] currentPackages = Array.Empty<PackageMapping>();
[MenuItem("Jovian/Package Sync")]
private static void ShowWindow() {
var window = GetWindow<PackageSyncWindow>("Package Sync");
window.minSize = new Vector2(400, 300);
}
[DidReloadScripts]
private static void OnScriptsReloaded() {
if (EditorPrefs.GetBool("PackageSync.AutoSync", false)) {
PushToRepos();
}
}
private static void PushToRepos() {
var packages = LoadSettings();
foreach(var package in packages) {
SyncFiles(package.packagePath, package.repoPath, package.displayName);
}
}
private static void SaveSettings(PackageMapping[] data) {
var json = JsonConvert.SerializeObject(data);
File.WriteAllText(FilePath, json);
}
private static PackageMapping[] LoadSettings() {
if(!File.Exists(FilePath)) {
File.Create(FilePath).Close();
File.WriteAllText(FilePath, string.Empty);
return Array.Empty<PackageMapping>();
}
var json = File.ReadAllText(FilePath);
return JsonConvert.DeserializeObject<PackageMapping[]>(json);
}
private void OnEnable() {
currentPackages = LoadSettings();
}
private void OnGUI() {
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
EditorGUILayout.Space(8);
EditorGUI.BeginChangeCheck();
syncEnabled = EditorGUILayout.ToggleLeft("Enable Package Sync", syncEnabled);
if(EditorGUI.EndChangeCheck()) {
EditorPrefs.SetBool("PackageSync.AutoSync", syncEnabled);
if(syncEnabled) {
PushToRepos();
}
}
EditorGUILayout.Space(4);
EditorGUILayout.HelpBox(
"Sync files between Unity packages and their standalone git repos.",
MessageType.Info);
EditorGUILayout.Space(8);
EditorGUILayout.LabelField("Add Packages", EditorStyles.boldLabel);
foreach(var package in currentPackages) {
var set = new List<string> {
package.displayName,
package.packagePath,
package.repoPath
};
EditorGUILayout.BeginHorizontal();
EditorGUI.BeginChangeCheck();
set[0] = EditorGUILayout.DelayedTextField(set[0]);
set[1] = EditorGUILayout.TextField(set[1]);
set[2] = EditorGUILayout.TextField(set[2]);
package.selected = EditorGUILayout.Toggle(package.selected, GUILayout.Width(20));
if(EditorGUI.EndChangeCheck()) {
package.displayName = set[0];
package.packagePath = set[1];
package.repoPath = set[2];
hasChanges = true;
}
EditorGUILayout.EndHorizontal();
}
EditorGUILayout.Space(4);
EditorGUILayout.BeginHorizontal();
if(GUILayout.Button("Add Package")) {
var package = new PackageMapping(string.Empty, string.Empty, string.Empty);
currentPackages = currentPackages.Append(package).ToArray();
SaveSettings(currentPackages);
}
if(GUILayout.Button("Remove Selected")) {
currentPackages = currentPackages.Where(p => !p.selected).ToArray();
SaveSettings(currentPackages);
}
EditorGUILayout.EndHorizontal();
if(hasChanges) {
SaveSettings(currentPackages);
}
EditorGUILayout.Space(8);
foreach(var package in currentPackages) {
DrawPackageSection(package);
EditorGUILayout.Space(12);
}
EditorGUILayout.Space(8);
EditorGUILayout.LabelField("Bulk Actions", EditorStyles.boldLabel);
EditorGUILayout.BeginHorizontal();
if(GUILayout.Button("Push All to Repos", GUILayout.Height(28))) {
foreach(var package in currentPackages) {
SyncFiles(package.packagePath, package.repoPath, package.displayName);
}
}
if(GUILayout.Button("Pull All from Repos", GUILayout.Height(28))) {
foreach(var package in currentPackages) {
SyncFiles(package.repoPath, package.packagePath, package.displayName);
}
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndScrollView();
}
private void DrawPackageSection(PackageMapping package) {
EditorGUILayout.LabelField(package.displayName, EditorStyles.boldLabel);
var packageExists = Directory.Exists(package.packagePath);
var repoExists = Directory.Exists(package.repoPath);
EditorGUILayout.LabelField("Package", packageExists ? package.packagePath : $"{package.packagePath} (missing)");
EditorGUILayout.LabelField("Repo", repoExists ? package.repoPath : $"{package.repoPath} (missing)");
if(!packageExists || !repoExists) {
EditorGUILayout.HelpBox(
$"Cannot sync: {(!packageExists ? "package" : "repo")} directory not found.",
MessageType.Warning);
return;
}
EditorGUILayout.BeginHorizontal();
if(GUILayout.Button("Push to Repo →")) {
SyncFiles(package.packagePath, package.repoPath, package.displayName);
}
if(GUILayout.Button("← Pull from Repo")) {
SyncFiles(package.repoPath, package.packagePath, package.displayName);
}
EditorGUILayout.EndHorizontal();
if(GUILayout.Button("Show Differences")) {
ShowDifferences(package);
}
}
private static void SyncFiles(string sourcePath, string destPath, string displayName) {
var fullSource = Path.GetFullPath(sourcePath);
var fullDest = Path.GetFullPath(destPath);
var sourceFiles = Directory.GetFiles(fullSource, "*", SearchOption.AllDirectories)
.Where(f => !IsGitPath(f) && !f.EndsWith(".meta"))
.ToArray();
var copied = 0;
var skipped = 0;
foreach(var sourceFile in sourceFiles) {
var relativePath = sourceFile.Substring(fullSource.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var destFile = Path.Combine(fullDest, relativePath);
if(IsFileIdentical(sourceFile, destFile)) {
skipped++;
continue;
}
var destDir = Path.GetDirectoryName(destFile);
if(!Directory.Exists(destDir)) {
if(destDir != null) {
Directory.CreateDirectory(destDir);
}
}
File.Copy(sourceFile, destFile, true);
copied++;
}
// Remove files in dest that no longer exist in source
var destFiles = Directory.GetFiles(fullDest, "*", SearchOption.AllDirectories)
.Where(f => !IsGitPath(f) && !f.EndsWith(".meta"))
.ToArray();
var deleted = 0;
foreach(var destFile in destFiles) {
var relativePath = destFile.Substring(fullDest.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var sourceFile = Path.Combine(fullSource, relativePath);
if(!File.Exists(sourceFile)) {
File.Delete(destFile);
deleted++;
}
}
var direction = fullSource == Path.GetFullPath(destPath) ? "Pulled" : "Pushed";
Debug.Log($"[PackageSync] {displayName}: {direction} {copied} files, {skipped} unchanged, {deleted} removed.");
AssetDatabase.Refresh();
}
private static void ShowDifferences(PackageMapping package) {
var fullPackage = Path.GetFullPath(package.packagePath);
var fullRepo = Path.GetFullPath(package.repoPath);
var packageFiles = Directory.GetFiles(fullPackage, "*", SearchOption.AllDirectories)
.Where(f => !IsGitPath(f) && !f.EndsWith(".meta"))
.Select(f => f.Substring(fullPackage.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))
.ToArray();
var repoFiles = Directory.GetFiles(fullRepo, "*", SearchOption.AllDirectories)
.Where(f => !IsGitPath(f) && !f.EndsWith(".meta"))
.Select(f => f.Substring(fullRepo.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))
.ToArray();
var onlyInPackage = packageFiles.Except(repoFiles).ToArray();
var onlyInRepo = repoFiles.Except(packageFiles).ToArray();
var common = packageFiles.Intersect(repoFiles).ToArray();
var modified = 0;
foreach(var file in common) {
var packageFile = Path.Combine(fullPackage, file);
var repoFile = Path.Combine(fullRepo, file);
if(!IsFileIdentical(packageFile, repoFile)) {
Debug.Log($"[PackageSync] Modified: {file}");
modified++;
}
}
foreach(var file in onlyInPackage) {
Debug.Log($"[PackageSync] Only in package: {file}");
}
foreach(var file in onlyInRepo) {
Debug.Log($"[PackageSync] Only in repo: {file}");
}
var total = modified + onlyInPackage.Length + onlyInRepo.Length;
if(total == 0) {
Debug.Log($"[PackageSync] {package.displayName}: In sync — no differences found.");
}
else {
Debug.Log($"[PackageSync] {package.displayName}: {modified} modified, {onlyInPackage.Length} only in package, {onlyInRepo.Length} only in repo.");
}
}
private static bool IsGitPath(string path) {
return path.Replace('\\', '/').Contains("/.git/") || path.Replace('\\', '/').Contains("/.git");
}
private static bool IsFileIdentical(string fileA, string fileB) {
if(!File.Exists(fileA) || !File.Exists(fileB)) {
return false;
}
var infoA = new FileInfo(fileA);
var infoB = new FileInfo(fileB);
if(infoA.Length != infoB.Length) {
return false;
}
var bytesA = File.ReadAllBytes(fileA);
var bytesB = File.ReadAllBytes(fileB);
return bytesA.SequenceEqual(bytesB);
}
private sealed class PackageMapping {
public string displayName;
public string packagePath;
public string repoPath;
public bool selected;
public PackageMapping(string displayName, string packagePath, string repoPath) {
this.displayName = displayName;
this.packagePath = packagePath;
this.repoPath = repoPath;
}
}
}
}

View File

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

View File

@@ -0,0 +1,15 @@
{
"name": "com.jovian.unitypackagesync",
"version": "0.1.0",
"displayName": "Jovian Package Sync",
"description": "Editor tool for syncing local Unity packages with standalone git repositories.",
"unity": "2022.3",
"keywords": [
"package",
"sync",
"git"
],
"author": {
"name": "Jovian"
}
}

View File

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

View File

@@ -0,0 +1,538 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jovian Zone System - Documentation</title>
<style>
:root {
--bg: #1e1e2e;
--bg2: #252538;
--bg3: #2e2e44;
--fg: #cdd6f4;
--fg2: #a6adc8;
--accent: #89b4fa;
--green: #a6e3a1;
--yellow: #f9e2af;
--red: #f38ba8;
--orange: #fab387;
--border: #45475a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--fg);
line-height: 1.7;
}
.container { max-width: 960px; margin: 0 auto; padding: 40px 24px; }
h1 { font-size: 2.2em; color: var(--accent); margin-bottom: 8px; }
h1 small { font-size: 0.4em; color: var(--fg2); font-weight: normal; }
h2 {
font-size: 1.5em; color: var(--accent); margin: 48px 0 16px;
padding-bottom: 8px; border-bottom: 1px solid var(--border);
}
h3 { font-size: 1.15em; color: var(--yellow); margin: 28px 0 12px; }
h4 { font-size: 1em; color: var(--orange); margin: 20px 0 8px; }
p { margin: 0 0 14px; color: var(--fg); }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code {
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
background: var(--bg3); padding: 2px 6px; border-radius: 4px;
font-size: 0.9em; color: var(--green);
}
pre {
background: var(--bg2); border: 1px solid var(--border);
border-radius: 8px; padding: 16px 20px; overflow-x: auto;
margin: 12px 0 20px; line-height: 1.5;
}
pre code { background: none; padding: 0; color: var(--fg); }
table {
width: 100%; border-collapse: collapse; margin: 12px 0 20px;
font-size: 0.95em;
}
th {
background: var(--bg3); text-align: left; padding: 10px 14px;
color: var(--accent); border: 1px solid var(--border);
}
td { padding: 10px 14px; border: 1px solid var(--border); }
tr:nth-child(even) td { background: var(--bg2); }
.badge {
display: inline-block; padding: 3px 10px; border-radius: 12px;
font-size: 0.8em; font-weight: 600; margin-right: 6px;
}
.badge-base { background: #89b4fa33; color: var(--accent); }
.badge-modifier { background: #f9e2af33; color: var(--yellow); }
.badge-override { background: #a6e3a133; color: var(--green); }
.badge-editor { background: #fab38733; color: var(--orange); }
.note {
background: var(--bg3); border-left: 4px solid var(--accent);
padding: 12px 16px; border-radius: 0 8px 8px 0; margin: 16px 0;
}
.warning {
background: #f9e2af11; border-left: 4px solid var(--yellow);
padding: 12px 16px; border-radius: 0 8px 8px 0; margin: 16px 0;
}
.toc {
background: var(--bg2); border: 1px solid var(--border);
border-radius: 8px; padding: 20px 28px; margin: 24px 0;
}
.toc h3 { margin-top: 0; color: var(--fg); }
.toc ul { list-style: none; padding-left: 0; }
.toc li { padding: 4px 0; }
.toc li::before { content: "# "; color: var(--border); }
ul, ol { margin: 0 0 14px 24px; }
li { margin-bottom: 4px; }
.key {
display: inline-block; background: var(--bg3); border: 1px solid var(--border);
border-radius: 4px; padding: 2px 8px; font-size: 0.85em;
font-family: monospace; color: var(--fg);
}
hr { border: none; border-top: 1px solid var(--border); margin: 32px 0; }
.section { margin-bottom: 32px; }
</style>
</head>
<body>
<div class="container">
<h1>Jovian Zone System <small>v0.1.0</small></h1>
<p>A polygon-based zone system for defining map regions with encounter difficulty, modifiers, and safe areas.</p>
<div class="toc">
<h3>Table of Contents</h3>
<ul>
<li><a href="#overview">Overview</a></li>
<li><a href="#setup">Setup &amp; Quick Start</a></li>
<li><a href="#editor-window">Zone Editor Window</a></li>
<li><a href="#shape-editing">Shape Editing</a></li>
<li><a href="#zone-roles">Zone Roles &amp; Resolution</a></li>
<li><a href="#settings">Editor Settings</a></li>
<li><a href="#runtime-api">Runtime API</a></li>
<li><a href="#types">Type Reference</a></li>
<li><a href="#utilities">Utility Classes</a></li>
<li><a href="#export">JSON Export</a></li>
<li><a href="#shortcuts">Keyboard Shortcuts</a></li>
<li><a href="#menu">Menu Reference</a></li>
</ul>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<h2 id="overview">Overview</h2>
<div class="section">
<p>The Zone System lets you paint polygon regions on your map and assign encounter rules to each region. At runtime, you query a world position and get back a fully resolved <code>ZoneContext</code> with the encounter table, difficulty tier, and chance.</p>
<h3>Key Features</h3>
<ul>
<li><strong>Three zone roles</strong>: Base, Modifier, and Override for layered encounter design</li>
<li><strong>Visual polygon editing</strong>: Drag vertices, insert on edges, delete vertices in the Scene view</li>
<li><strong>Concave polygon support</strong>: Ear-clipping triangulation renders any shape correctly</li>
<li><strong>Multi-plane support</strong>: XY, XZ, or YZ &mdash; one setting controls everything</li>
<li><strong>No physics dependency</strong>: Pure math ray-casting with AABB pre-rejection</li>
<li><strong>Save workflow</strong>: Create &rarr; Edit &rarr; Save with duplicate ID/name validation</li>
<li><strong>Role-based colors</strong>: Configured in settings, auto-applied on role change</li>
<li><strong>Zone duplication</strong>: Independent copies with unique IDs and assets</li>
<li><strong>JSON export</strong>: For runtime loading or external tools</li>
<li><strong>UPM package</strong>: Standard Unity Package Manager layout</li>
</ul>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<h2 id="setup">Setup &amp; Quick Start</h2>
<div class="section">
<h3>1. Scene Setup</h3>
<ol>
<li>Create a GameObject and add the <code>ZonesObjectHolder</code> component.</li>
<li>Set the <strong>Map Plane</strong> field to match your map orientation.</li>
</ol>
<table>
<tr><th>Map Plane</th><th>Use Case</th><th>Axes</th><th>Ignored</th></tr>
<tr><td><code>XY</code></td><td>Flat sprite map, UI map</td><td>X, Y</td><td>Z</td></tr>
<tr><td><code>XZ</code></td><td>3D world map (standard Unity 3D)</td><td>X, Z</td><td>Y</td></tr>
<tr><td><code>YZ</code></td><td>Side-on map</td><td>Y, Z</td><td>X</td></tr>
</table>
<h3>2. Create Your First Zone</h3>
<ol>
<li>Open <strong>Window &rarr; Zone System &rarr; Zone Editor</strong>.</li>
<li>Click <strong>Create New Zone</strong>.</li>
<li>Enter a name, select a shape (Square, Circle, or Polygon).</li>
<li>Click <strong>Create &amp; Edit</strong>.</li>
<li>Edit zone data fields (role, priority, encounter settings).</li>
<li>Click <strong>Save Zone</strong> to persist the asset.</li>
<li>Use Scene view handles to adjust the polygon shape.</li>
</ol>
<div class="note">
<strong>Note:</strong> The zone asset is not saved to disk until you click the Save button. You can edit all fields freely before committing.
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<h2 id="editor-window">Zone Editor Window</h2>
<div class="section">
<p>The Zone Editor window (<strong>Window &rarr; Zone System &rarr; Zone Editor</strong>) is the primary tool for managing zones.</p>
<h3>Zone List View</h3>
<p>Shows all <code>ZoneInstance</code> objects in the current scene. Each row displays:</p>
<ul>
<li><strong>Color swatch</strong>: Role-based color from editor settings</li>
<li><strong>Zone name</strong> and summary (role, priority, tier, chance)</li>
<li><strong>Select</strong> button: Opens the zone for editing</li>
<li><strong>Duplicate</strong> button (<code>&#x1F4CB;</code>): Creates an independent copy with a new asset</li>
<li><strong>Delete</strong> button (<code>&#x2715;</code>): Removes the zone and its asset</li>
</ul>
<p>Zones with missing <code>ZoneData</code> show a warning icon with options to add data or delete the zone.</p>
<h3>Create Zone</h3>
<p>Click <strong>Create New Zone</strong> to open the creation dropdown:</p>
<ul>
<li>Enter a <strong>Zone Name</strong></li>
<li>Select a <strong>Shape</strong> (Square, Circle, Polygon)</li>
<li>Click <strong>Create &amp; Edit</strong></li>
</ul>
<p>The zone is created in-memory and enters edit mode immediately. You must click <strong>Save</strong> to persist the asset.</p>
<h3>Edit Mode</h3>
<p>When editing a zone, all <code>ZoneData</code> fields are available inline:</p>
<ul>
<li><strong>Identity</strong>: Zone ID, Zone Name, Role, Priority, Shape</li>
<li><strong>Role-specific fields</strong>: Shown/hidden based on the selected role</li>
<li><strong>Save Zone</strong> button: Validates and persists changes</li>
<li><strong>Delete Zone</strong> button: Removes the zone entirely</li>
</ul>
<h4>Save Validation</h4>
<p>On save, the editor checks for:</p>
<ul>
<li><strong>Duplicate Zone ID</strong>: No two assets may share the same <code>zoneId</code></li>
<li><strong>Duplicate asset name</strong>: No two assets may have the same file name</li>
</ul>
<p>If a conflict is found, an error is displayed and saving is blocked.</p>
<h4>Auto-applied Changes on Save</h4>
<ul>
<li>The <strong>GameObject name</strong> is updated to match the zone name</li>
<li>The <strong>asset file</strong> is renamed to match the zone name</li>
<li>If the <strong>shape type</strong> was changed, the polygon resets to the new default shape</li>
</ul>
<h3>Export Section</h3>
<p>At the bottom of the editor window, expand <strong>Export Zones to JSON</strong> to export all scene zones to a JSON file for runtime loading.</p>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<h2 id="shape-editing">Shape Editing</h2>
<div class="section">
<p>When a zone is in edit mode, yellow handles appear in the Scene view for each polygon vertex.</p>
<h3>Controls</h3>
<table>
<tr><th>Action</th><th>Input</th><th>Description</th></tr>
<tr><td>Move vertex</td><td>Drag handle</td><td>Drag any yellow handle to reposition a vertex</td></tr>
<tr><td>Insert vertex</td><td><span class="key">Ctrl</span> + Click edge</td><td>Adds a new vertex on the closest edge (cyan highlight shows target)</td></tr>
<tr><td>Delete vertex</td><td><span class="key">Shift</span> + Click vertex</td><td>Removes the vertex (minimum 3 vertices). Handles turn red while Shift is held.</td></tr>
<tr><td>Stop editing</td><td><span class="key">Esc</span></td><td>Exits shape edit mode</td></tr>
</table>
<h3>Shapes</h3>
<table>
<tr><th>Shape</th><th>Default</th><th>Notes</th></tr>
<tr><td><code>Square</code></td><td>4 vertices, 2-unit half-size</td><td>Can be reshaped into any quad</td></tr>
<tr><td><code>Circle</code></td><td>24-segment approximation, radius 2</td><td>Drag the radius handle to resize. Regenerates vertices on radius change.</td></tr>
<tr><td><code>Polygon</code></td><td>12 vertices, radius 3</td><td>Fully freeform &mdash; add, remove, and drag vertices freely</td></tr>
</table>
<h3>Additional Tools (Inspector)</h3>
<ul>
<li><strong>Center Transform</strong>: Moves the GameObject&rsquo;s transform to the polygon&rsquo;s centroid without changing the zone&rsquo;s world position</li>
<li><strong>Reset Shape</strong>: Resets the polygon to the default for the current shape type</li>
<li><strong>Duplicate Zone</strong>: Creates an independent copy with its own <code>ZoneData</code> asset</li>
</ul>
<div class="note">
<strong>Scene interaction:</strong> While in shape edit mode, clicking in the Scene view will not select other objects. The default transform handle is hidden to prevent accidental movement.
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<h2 id="zone-roles">Zone Roles &amp; Resolution</h2>
<div class="section">
<h3>Roles</h3>
<table>
<tr><th>Role</th><th>Purpose</th><th>Fields</th></tr>
<tr>
<td><span class="badge badge-base">Base</span></td>
<td>Defines the encounter table and baseline difficulty</td>
<td>Encounter Table ID, Difficulty Tier, Encounter Chance</td>
</tr>
<tr>
<td><span class="badge badge-modifier">Modifier</span></td>
<td>Stacks multiplicatively on top of a Base zone</td>
<td>Chance Multiplier, Difficulty Tier Bonus</td>
</tr>
<tr>
<td><span class="badge badge-override">Override</span></td>
<td>Replaces everything &mdash; towns, story events, safe areas</td>
<td>Is Safe Zone, Encounter Table ID, Encounter Chance, Difficulty Tier</td>
</tr>
</table>
<h3>Resolution Order</h3>
<p>When querying a world position, the <code>ZoneResolver</code> follows this order:</p>
<ol>
<li>If any <strong>Override</strong> zone is present &rarr; use the highest-priority Override exclusively</li>
<li>Find the highest-priority <strong>Base</strong> zone &rarr; encounter table + baseline difficulty</li>
<li>Stack all <strong>Modifier</strong> zones multiplicatively on top</li>
<li>Clamp and return a <code>ZoneContext</code></li>
</ol>
<h3>Modifier Stacking</h3>
<p>Modifiers are multiplicative, so each one is independent:</p>
<pre><code>Base chance 0.30 x Cursed Road 1.8 x Night Modifier 1.2 = 0.648</code></pre>
<h3>Priority</h3>
<p>Higher priority values take precedence. When multiple zones of the same role overlap, the highest-priority one wins (for Base and Override) or all are stacked (for Modifier).</p>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<h2 id="settings">Editor Settings</h2>
<div class="section">
<p>Access via <strong>Window &rarr; Zone System &rarr; Settings</strong>. If no settings asset exists, one is created automatically.</p>
<h3>Fields</h3>
<table>
<tr><th>Field</th><th>Description</th><th>Default</th></tr>
<tr><td><code>mapPlane</code></td><td>Which two world axes your map lies on</td><td><code>XZ</code></td></tr>
<tr><td><code>zoneDataFolder</code></td><td>Folder path where new ZoneData assets are saved</td><td><code>Assets/ZoneData</code></td></tr>
<tr><td><code>roleColors</code></td><td>Debug color for each zone role (used in scene rendering)</td><td>Blue (Base), Yellow (Modifier), Green (Override)</td></tr>
</table>
<h3>Role Colors</h3>
<p>Each <code>ZoneRole</code> has a configurable color in the settings. When you change a zone&rsquo;s role in the editor, its debug color is automatically updated to match. Colors are used for:</p>
<ul>
<li>Scene view polygon fill and border</li>
<li>Zone list row background tinting</li>
<li>Inspector summary background</li>
<li>Scene gizmos</li>
</ul>
<div class="note">
<strong>Dynamic:</strong> If you add new values to the <code>ZoneRole</code> enum, call <code>SyncRoleEntries()</code> on the settings asset or click the settings menu item &mdash; missing roles will be added with a default gray color.
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<h2 id="runtime-api">Runtime API</h2>
<div class="section">
<h3>ZoneSystemApi</h3>
<p>The main entry point for runtime zone queries.</p>
<pre><code>// Create the API with a reference to the ZonesObjectHolder
ZoneSystemApi api = new ZoneSystemApi(zonesObjectHolder);
// Full zone resolution at a world position
ZoneContext ctx = api.QueryZone(partyWorldPosition);
if(!ctx.isSafe &amp;&amp; Random.value &lt; ctx.finalEncounterChance)
TriggerEncounter(ctx.encounterTableId, ctx.finalDifficultyTier);</code></pre>
<h3>Methods</h3>
<table>
<tr><th>Method</th><th>Returns</th><th>Description</th></tr>
<tr><td><code>QueryZone(Vector3)</code></td><td><code>ZoneContext</code></td><td>Full resolution: finds overlapping zones, applies modifiers, returns final context</td></tr>
<tr><td><code>GetOverlappingZones(Vector3)</code></td><td><code>List&lt;ZoneData&gt;</code></td><td>Raw list of all zones containing the position, sorted by descending priority</td></tr>
<tr><td><code>IsInSafeZone(Vector3)</code></td><td><code>bool</code></td><td>Quick check &mdash; true if any Override zone with <code>isSafeZone</code> contains the position</td></tr>
<tr><td><code>Register(ZoneInstance)</code></td><td><code>void</code></td><td>Register a dynamically spawned zone</td></tr>
<tr><td><code>Unregister(ZoneInstance)</code></td><td><code>void</code></td><td>Unregister a zone before destroying it</td></tr>
</table>
<h3>ZoneContext Struct</h3>
<table>
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td><code>encounterTableId</code></td><td><code>string</code></td><td>ID of the encounter table to use</td></tr>
<tr><td><code>finalEncounterChance</code></td><td><code>float</code></td><td>Final encounter probability (0&ndash;1), after modifier stacking</td></tr>
<tr><td><code>finalDifficultyTier</code></td><td><code>DifficultyTier</code></td><td>Final difficulty tier, after modifier bonuses</td></tr>
<tr><td><code>isSafe</code></td><td><code>bool</code></td><td>True if in a safe zone (no encounters)</td></tr>
<tr><td><code>resolvedZoneName</code></td><td><code>string</code></td><td>Name of the zone that &ldquo;won&rdquo; resolution (for debug/UI)</td></tr>
</table>
<h3>Dynamic Zones</h3>
<pre><code>// After instantiating a zone at runtime:
api.Register(zoneInstance);
// Before destroying:
api.Unregister(zoneInstance);</code></pre>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<h2 id="types">Type Reference</h2>
<div class="section">
<h3>Enums</h3>
<h4>ZoneRole</h4>
<table>
<tr><th>Value</th><th>Description</th></tr>
<tr><td><code>Base</code></td><td>Provides the encounter table and baseline difficulty</td></tr>
<tr><td><code>Modifier</code></td><td>Mutates difficulty/chance on top of a Base zone</td></tr>
<tr><td><code>Override</code></td><td>Completely replaces everything (safe towns, story events)</td></tr>
</table>
<h4>ZoneShape</h4>
<table>
<tr><th>Value</th><th>Description</th></tr>
<tr><td><code>Square</code></td><td>4-vertex quadrilateral</td></tr>
<tr><td><code>Circle</code></td><td>24-segment circular approximation with adjustable radius</td></tr>
<tr><td><code>Polygon</code></td><td>Freeform polygon with 12 default vertices</td></tr>
</table>
<h4>DifficultyTier</h4>
<table>
<tr><th>Value</th><th>Int</th></tr>
<tr><td><code>Safe</code></td><td>0</td></tr>
<tr><td><code>Mild</code></td><td>1</td></tr>
<tr><td><code>Moderate</code></td><td>2</td></tr>
<tr><td><code>Dangerous</code></td><td>3</td></tr>
<tr><td><code>Deadly</code></td><td>4</td></tr>
</table>
<h4>MapPlane</h4>
<table>
<tr><th>Value</th><th>Axes</th><th>Depth</th></tr>
<tr><td><code>XY</code></td><td>X, Y</td><td>Z</td></tr>
<tr><td><code>XZ</code></td><td>X, Z</td><td>Y</td></tr>
<tr><td><code>YZ</code></td><td>Y, Z</td><td>X</td></tr>
</table>
<h3>ScriptableObjects</h3>
<h4>ZoneData</h4>
<p>Per-zone configuration asset. Created via the Zone Editor or <code>Create &rarr; ZoneSystem &rarr; Zone Data</code>.</p>
<table>
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td><code>zoneId</code></td><td><code>string</code></td><td>Unique identifier</td></tr>
<tr><td><code>zoneName</code></td><td><code>string</code></td><td>Display name</td></tr>
<tr><td><code>role</code></td><td><code>ZoneRole</code></td><td>Base, Modifier, or Override</td></tr>
<tr><td><code>priority</code></td><td><code>int</code></td><td>Higher wins in same-role conflicts</td></tr>
<tr><td><code>debugColor</code></td><td><code>Color</code></td><td>Scene visualization color (auto-set from role)</td></tr>
<tr><td><code>shape</code></td><td><code>ZoneShape</code></td><td>Shape type</td></tr>
<tr><td><code>circleRadius</code></td><td><code>float</code></td><td>Radius (Circle shape only)</td></tr>
<tr><td><code>polygon</code></td><td><code>List&lt;Vector2&gt;</code></td><td>Vertex positions (local to transform)</td></tr>
</table>
<h3>MonoBehaviours</h3>
<h4>ZoneInstance</h4>
<p>Placed on a scene GameObject. References a <code>ZoneData</code> asset and provides spatial queries.</p>
<table>
<tr><th>Field / Method</th><th>Description</th></tr>
<tr><td><code>data</code></td><td>Reference to the <code>ZoneData</code> asset</td></tr>
<tr><td><code>Contains(Vector3, MapPlane)</code></td><td>Returns true if the world position is inside this zone</td></tr>
<tr><td><code>RebuildBoundsCache()</code></td><td>Recalculates the AABB cache (call after modifying polygon)</td></tr>
</table>
<h4>ZonesObjectHolder</h4>
<p>Scene manager that holds the map plane setting and provides access to all zones.</p>
<table>
<tr><th>Field / Property</th><th>Description</th></tr>
<tr><td><code>mapPlane</code></td><td>Which plane the map lies on (XY, XZ, or YZ)</td></tr>
<tr><td><code>AllZones</code></td><td>Read-only list of all <code>ZoneInstance</code> objects in the scene</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<h2 id="utilities">Utility Classes</h2>
<div class="section">
<h3>PolygonUtils</h3>
<p>Static math utilities for polygon operations.</p>
<table>
<tr><th>Method</th><th>Description</th></tr>
<tr><td><code>PointInPolygon(Vector2, List&lt;Vector2&gt;)</code></td><td>Ray-casting point-in-polygon test (Jordan curve theorem)</td></tr>
<tr><td><code>PointInPolygon(Vector3, List&lt;Vector2&gt;, MapPlane)</code></td><td>Projects world position to plane, then tests</td></tr>
<tr><td><code>Centroid(List&lt;Vector2&gt;)</code></td><td>Average center of polygon vertices</td></tr>
<tr><td><code>Bounds(List&lt;Vector2&gt;)</code></td><td>Axis-aligned bounding box (min, max)</td></tr>
<tr><td><code>PointInBounds(Vector2, Vector2, Vector2)</code></td><td>Fast AABB pre-check</td></tr>
<tr><td><code>Triangulate(List&lt;Vector2&gt;)</code></td><td>Ear-clipping triangulation for concave polygon rendering</td></tr>
</table>
<h3>MapPlaneUtility</h3>
<p>Converts between 3D world positions and 2D plane coordinates.</p>
<table>
<tr><th>Method</th><th>Description</th></tr>
<tr><td><code>ProjectToPlane(Vector3, MapPlane)</code></td><td>3D world &rarr; 2D plane coordinates</td></tr>
<tr><td><code>UnprojectFromPlane(Vector2, MapPlane, float)</code></td><td>2D plane coordinates &rarr; 3D world</td></tr>
</table>
<h3>ShapeFactory</h3>
<p>Generates default polygon vertices for each shape type.</p>
<table>
<tr><th>Method</th><th>Description</th></tr>
<tr><td><code>CreateDefault(ZoneShape)</code></td><td>Returns default vertices for the given shape</td></tr>
<tr><td><code>CreateSquare(float)</code></td><td>4-vertex square with given half-size</td></tr>
<tr><td><code>CreateCircle(float, int)</code></td><td>N-segment circle approximation</td></tr>
<tr><td><code>CreatePolygon(float, int)</code></td><td>Regular polygon with N vertices</td></tr>
<tr><td><code>RegenerateCircle(ZoneData)</code></td><td>Rebuilds circle vertices from current radius</td></tr>
</table>
<h3>ZoneResolver</h3>
<p>Pure logic for resolving overlapping zones into a single <code>ZoneContext</code>.</p>
<table>
<tr><th>Method</th><th>Description</th></tr>
<tr><td><code>Resolve(List&lt;ZoneData&gt;)</code></td><td>Takes overlapping zone data, applies role priority and modifier stacking, returns <code>ZoneContext</code></td></tr>
</table>
<h3>ZoneExporter</h3>
<p>Serializes scene zones to a JSON structure for runtime loading.</p>
<table>
<tr><th>Method</th><th>Description</th></tr>
<tr><td><code>BuildExport(ZoneInstance[], MapPlane)</code></td><td>Builds the export data structure from scene instances</td></tr>
<tr><td><code>ToJson(ZoneExportRoot, bool)</code></td><td>Converts to JSON string (optionally pretty-printed)</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<h2 id="export">JSON Export</h2>
<div class="section">
<p>In the Zone Editor window, expand <strong>Export Zones to JSON</strong>, set the output path, and click <strong>Export Now</strong>.</p>
<h3>Loading at Runtime</h3>
<pre><code>string json = File.ReadAllText(Application.streamingAssetsPath + "/zones.json");
ZoneExportRoot root = JsonUtility.FromJson&lt;ZoneExportRoot&gt;(json);</code></pre>
<h3>Export Structure</h3>
<p>Each zone is exported as a <code>ZoneExportEntry</code> containing all zone data fields plus the world-space polygon coordinates and transform position.</p>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<h2 id="shortcuts">Keyboard Shortcuts</h2>
<div class="section">
<table>
<tr><th>Key</th><th>Context</th><th>Action</th></tr>
<tr><td><span class="key">Esc</span></td><td>Scene view, shape editing active</td><td>Stop editing the zone shape</td></tr>
<tr><td><span class="key">Ctrl</span> + Click</td><td>Scene view, shape editing active</td><td>Insert a vertex on the nearest edge</td></tr>
<tr><td><span class="key">Shift</span> + Click</td><td>Scene view, shape editing active</td><td>Delete the clicked vertex (min 3)</td></tr>
</table>
</div>
<!-- ═══════════════════════════════════════════════════════════════ -->
<h2 id="menu">Menu Reference</h2>
<div class="section">
<table>
<tr><th>Menu Path</th><th>Description</th></tr>
<tr><td>Window &rarr; Zone System &rarr; Zone Editor</td><td>Opens the main Zone Editor window</td></tr>
<tr><td>Window &rarr; Zone System &rarr; Settings</td><td>Selects (or creates) the ZoneEditorSettings asset</td></tr>
<tr><td>Window &rarr; Zone System &rarr; Documentation</td><td>Opens this documentation in your default browser</td></tr>
<tr><td>Jovian &rarr; ZoneSystem &rarr; Zone Editor Settings</td><td>Create menu for new ZoneEditorSettings asset</td></tr>
<tr><td>ZoneSystem &rarr; Zone Data</td><td>Create menu for new ZoneData asset</td></tr>
</table>
</div>
<hr>
<p style="text-align: center; color: var(--fg2); font-size: 0.9em;">
Jovian Zone System v0.1.0 &mdash; com.jovian.zonesystem
</p>
</div>
</body>
</html>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,131 @@
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
namespace Jovian.ZoneSystem.Editor {
/// <summary>
/// Custom inspector for ZoneData ScriptableObject.
/// Shows only fields relevant to the selected ZoneRole.
/// </summary>
[CustomEditor(typeof(ZoneData))]
public class ZoneDataEditor : UnityEditor.Editor {
// Modifier
private SerializedProperty _chanceMultiplier, _tierBonus;
// Base
private SerializedProperty _encounterTableId, _baseDifficultyTier, _baseEncounterChance;
// Override
private SerializedProperty _isSafeZone, _overrideTableId, _overrideChance, _overrideTier;
private SerializedProperty _zoneId, _zoneName, _role, _priority, _debugColor;
private SerializedProperty _shape, _circleRadius;
private void OnEnable() {
_zoneId = serializedObject.FindProperty("zoneId");
_zoneName = serializedObject.FindProperty("zoneName");
_role = serializedObject.FindProperty("role");
_priority = serializedObject.FindProperty("priority");
_debugColor = serializedObject.FindProperty("debugColor");
_shape = serializedObject.FindProperty("shape");
_circleRadius = serializedObject.FindProperty("circleRadius");
_encounterTableId = serializedObject.FindProperty("encounterTableId");
_baseDifficultyTier = serializedObject.FindProperty("baseDifficultyTier");
_baseEncounterChance = serializedObject.FindProperty("baseEncounterChance");
_chanceMultiplier = serializedObject.FindProperty("encounterChanceMultiplier");
_tierBonus = serializedObject.FindProperty("difficultyTierBonus");
_isSafeZone = serializedObject.FindProperty("isSafeZone");
_overrideTableId = serializedObject.FindProperty("overrideEncounterTableId");
_overrideChance = serializedObject.FindProperty("overrideEncounterChance");
_overrideTier = serializedObject.FindProperty("overrideDifficultyTier");
}
public override void OnInspectorGUI() {
serializedObject.Update();
// Identity
EditorGUILayout.LabelField("Identity", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(_zoneId, new GUIContent("Zone ID"));
EditorGUILayout.PropertyField(_zoneName, new GUIContent("Zone Name"));
// Track role changes to auto-apply color
ZoneRole roleBefore = (ZoneRole)_role.enumValueIndex;
EditorGUILayout.PropertyField(_role, new GUIContent("Role"));
ZoneRole roleAfter = (ZoneRole)_role.enumValueIndex;
if(roleBefore != roleAfter) {
ZoneEditorSettings settings = ZoneEditorSettings.FindOrCreateSettings();
_debugColor.colorValue = settings.GetColorForRole(roleAfter);
}
EditorGUILayout.PropertyField(_priority, new GUIContent("Priority"));
EditorGUILayout.PropertyField(_shape, new GUIContent("Shape"));
if((ZoneShape)_shape.enumValueIndex == ZoneShape.Circle) {
EditorGUILayout.PropertyField(_circleRadius, new GUIContent("Circle Radius"));
}
EditorGUILayout.Space(8);
// Role-specific fields
ZoneRole role = (ZoneRole)_role.enumValueIndex;
switch(role) {
case ZoneRole.Base:
DrawBaseFields();
break;
case ZoneRole.Modifier:
DrawModifierFields();
break;
case ZoneRole.Override:
DrawOverrideFields();
break;
}
serializedObject.ApplyModifiedProperties();
}
private void DrawBaseFields() {
EditorGUILayout.LabelField("Base Zone Settings", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"Base zones define the encounter table and baseline difficulty. " +
"Only the highest-priority Base zone at a position is used.",
MessageType.None);
EditorGUILayout.PropertyField(_encounterTableId, new GUIContent("Encounter Table ID"));
EditorGUILayout.PropertyField(_baseDifficultyTier, new GUIContent("Difficulty Tier"));
EditorGUILayout.PropertyField(_baseEncounterChance, new GUIContent("Encounter Chance"));
}
private void DrawModifierFields() {
EditorGUILayout.LabelField("Modifier Zone Settings", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"Modifier zones adjust an overlapping Base zone's values multiplicatively. " +
"All Modifier zones at a position are stacked.",
MessageType.None);
EditorGUILayout.PropertyField(_chanceMultiplier, new GUIContent("Chance Multiplier"));
EditorGUILayout.PropertyField(_tierBonus, new GUIContent("Difficulty Tier Bonus"));
}
private void DrawOverrideFields() {
EditorGUILayout.LabelField("Override Zone Settings", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"Override zones completely replace all other zones at this position. " +
"Useful for story events, towns, and safe areas. " +
"Highest-priority Override wins if multiple are present.",
MessageType.None);
EditorGUILayout.PropertyField(_isSafeZone, new GUIContent("Is Safe Zone"));
if(!_isSafeZone.boolValue) {
EditorGUI.indentLevel++;
EditorGUILayout.PropertyField(_overrideTableId, new GUIContent("Encounter Table ID"));
EditorGUILayout.PropertyField(_overrideChance, new GUIContent("Encounter Chance"));
EditorGUILayout.PropertyField(_overrideTier, new GUIContent("Difficulty Tier"));
EditorGUI.indentLevel--;
}
}
}
}
#endif

View File

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

View File

@@ -0,0 +1,109 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
namespace Jovian.ZoneSystem.Editor {
[CreateAssetMenu(fileName = "ZoneEditorSettings", menuName = "Jovian/ZoneSystem/Zone Editor Settings")]
public class ZoneEditorSettings : ScriptableObject {
[Tooltip("Which two world axes your map lies on. Match this to your map's plane.")]
public MapPlane mapPlane = MapPlane.XZ;
[Tooltip("Folder path where new ZoneData assets are saved (relative to project root).")]
public string zoneDataFolder = "Assets/ZoneData";
[Tooltip("Debug color for each zone role. Add entries for any new roles.")]
public List<ZoneRoleColor> roleColors = new() {
new ZoneRoleColor { role = ZoneRole.Base, color = new Color(0.2f, 0.6f, 1f, 0.25f) },
new ZoneRoleColor { role = ZoneRole.Modifier, color = new Color(1f, 0.8f, 0.2f, 0.25f) },
new ZoneRoleColor { role = ZoneRole.Override, color = new Color(0.3f, 0.9f, 0.3f, 0.25f) }
};
public Color GetColorForRole(ZoneRole role) {
foreach(ZoneRoleColor entry in roleColors) {
if(entry.role == role) {
return entry.color;
}
}
return new Color(1f, 0.5f, 0f, 0.25f);
}
/// <summary>
/// Ensures every ZoneRole enum value has a color entry.
/// Call this after adding new roles to the enum.
/// </summary>
public void SyncRoleEntries() {
ZoneRole[] allRoles = (ZoneRole[])Enum.GetValues(typeof(ZoneRole));
foreach(ZoneRole role in allRoles) {
bool found = false;
foreach(ZoneRoleColor entry in roleColors) {
if(entry.role == role) {
found = true;
break;
}
}
if(!found) {
roleColors.Add(new ZoneRoleColor {
role = role,
color = new Color(0.5f, 0.5f, 0.5f, 0.25f)
});
}
}
}
[MenuItem("Window/Zone System/Settings")]
private static void SelectOrCreateSettings() {
ZoneEditorSettings settings = FindOrCreateSettings();
Selection.activeObject = settings;
EditorGUIUtility.PingObject(settings);
}
[MenuItem("Window/Zone System/Documentation")]
private static void OpenDocumentation() {
// Find the Documentation~ folder relative to this package
string[] guids = AssetDatabase.FindAssets("t:Script ZoneEditorSettings");
string packagePath = "Packages/com.jovian.zonesystem";
if(guids.Length > 0) {
string scriptPath = AssetDatabase.GUIDToAssetPath(guids[0]);
// scriptPath is like "Packages/com.jovian.zonesystem/Editor/ZoneEditorSettings.cs"
int editorIdx = scriptPath.IndexOf("/Editor/");
if(editorIdx >= 0) {
packagePath = scriptPath.Substring(0, editorIdx);
}
}
string fullPath = System.IO.Path.GetFullPath(
System.IO.Path.Combine(Application.dataPath, "..", packagePath, "Documentation~", "index.html"));
if(System.IO.File.Exists(fullPath)) {
Application.OpenURL("file:///" + fullPath.Replace("\\", "/"));
}
else {
Debug.LogWarning($"[ZoneSystem] Documentation not found at: {fullPath}");
}
}
internal static ZoneEditorSettings FindOrCreateSettings() {
string[] guids = AssetDatabase.FindAssets("t:ZoneEditorSettings");
if(guids.Length > 0) {
return AssetDatabase.LoadAssetAtPath<ZoneEditorSettings>(
AssetDatabase.GUIDToAssetPath(guids[0]));
}
// Create a new settings asset
string folder = "Assets";
ZoneEditorSettings newSettings = CreateInstance<ZoneEditorSettings>();
newSettings.SyncRoleEntries();
AssetDatabase.CreateAsset(newSettings, $"{folder}/ZoneEditorSettings.asset");
AssetDatabase.SaveAssets();
Debug.Log("[ZoneSystem] Created ZoneEditorSettings at Assets/ZoneEditorSettings.asset");
return newSettings;
}
}
[Serializable]
public struct ZoneRoleColor {
public ZoneRole role;
public Color color;
}
}
#endif

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 64856826ade04f41963e973ab19b2f00
timeCreated: 1772984016

View File

@@ -0,0 +1,53 @@
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
namespace Jovian.ZoneSystem.Editor {
[CustomEditor(typeof(ZoneEditorSettings))]
public class ZoneEditorSettingsEditor : UnityEditor.Editor {
public override void OnInspectorGUI() {
serializedObject.Update();
EditorGUI.BeginChangeCheck();
DrawDefaultInspector();
bool changed = EditorGUI.EndChangeCheck();
if(changed) {
serializedObject.ApplyModifiedProperties();
ApplyColorsToAllZoneData((ZoneEditorSettings)target);
}
EditorGUILayout.Space(8);
if(GUILayout.Button("Apply Colors to All Zones")) {
ApplyColorsToAllZoneData((ZoneEditorSettings)target);
}
}
private static void ApplyColorsToAllZoneData(ZoneEditorSettings settings) {
string[] guids = AssetDatabase.FindAssets("t:ZoneData");
int updated = 0;
foreach(string guid in guids) {
string path = AssetDatabase.GUIDToAssetPath(guid);
ZoneData data = AssetDatabase.LoadAssetAtPath<ZoneData>(path);
if(data == null) {
continue;
}
Color newColor = settings.GetColorForRole(data.role);
if(data.debugColor != newColor) {
Undo.RecordObject(data, "Update Zone Color");
data.debugColor = newColor;
EditorUtility.SetDirty(data);
updated++;
}
}
if(updated > 0) {
AssetDatabase.SaveAssets();
SceneView.RepaintAll();
}
}
}
}
#endif

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 07d9ebf920c798c46b91e4f371ba5c7a

View File

@@ -0,0 +1,643 @@
#if UNITY_EDITOR
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace Jovian.ZoneSystem.Editor {
/// <summary>
/// Main Zone Editor window.
/// Open via: Window → Zone System → Zone Editor
/// </summary>
public class ZoneEditorWindow : EditorWindow {
private string _exportPath = "Assets/StreamingAssets/zones.json";
// ── Create form state ───────────────────────────────────────────
private bool _showCreateForm;
private string _newZoneName = "New Zone";
private ZoneShape _newZoneShape = ZoneShape.Square;
// ── Edit state ──────────────────────────────────────────────────
private ZoneInstance _editingZone;
private SerializedObject _editingSO;
private bool _isUnsavedNewData;
private bool _hasUnsavedChanges;
private string _saveError;
private ZoneShape _shapeOnEditStart;
// ── Scroll / foldouts ───────────────────────────────────────────
private Vector2 _scrollPos;
private bool _showExportFoldout = true;
// ── GUI ──────────────────────────────────────────────────────────
private void OnGUI() {
DrawHeader();
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos);
if(_editingZone != null) {
DrawEditSection();
} else {
DrawCreateButton();
if(_showCreateForm) {
DrawCreateForm();
}
EditorGUILayout.Space(6);
DrawSceneZonesList();
}
EditorGUILayout.Space(6);
DrawExportSection();
EditorGUILayout.EndScrollView();
}
private void OnFocus() {
ValidateEditingState();
Repaint();
}
private void OnHierarchyChange() {
ValidateEditingState();
Repaint();
}
private void OnSelectionChange() {
ValidateEditingState();
Repaint();
}
private void ValidateEditingState() {
if(_editingZone == null && _editingSO != null) {
_editingSO = null;
}
}
[MenuItem("Window/Zone System/Zone Editor")]
public static void Open() {
GetWindow<ZoneEditorWindow>("Zone Editor");
}
public static void OpenAndEdit(ZoneInstance zone) {
ZoneEditorWindow window = GetWindow<ZoneEditorWindow>("Zone Editor");
window.EnterEditMode(zone);
}
// ── Header ──────────────────────────────────────────────────────
private void DrawHeader() {
Color prevBg = GUI.backgroundColor;
GUI.backgroundColor = new Color(0.2f, 0.2f, 0.3f);
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
GUI.backgroundColor = prevBg;
EditorGUILayout.LabelField("🗺 Zone System Editor", new GUIStyle(EditorStyles.boldLabel) {
fontSize = 14,
normal = { textColor = new Color(0.8f, 0.9f, 1f) }
});
EditorGUILayout.LabelField("Define map zones for encounter difficulty and chance.",
EditorStyles.miniLabel);
EditorGUILayout.EndVertical();
EditorGUILayout.Space(4);
}
// ── Create Button + Dropdown ────────────────────────────────────
private void DrawCreateButton() {
GUI.backgroundColor = new Color(0.3f, 0.7f, 0.3f);
if(GUILayout.Button(" Create New Zone", GUILayout.Height(30))) {
_showCreateForm = !_showCreateForm;
}
GUI.backgroundColor = Color.white;
}
private void DrawCreateForm() {
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
EditorGUI.indentLevel++;
_newZoneName = EditorGUILayout.TextField("Zone Name", _newZoneName);
_newZoneShape = (ZoneShape)EditorGUILayout.EnumPopup("Shape", _newZoneShape);
EditorGUI.indentLevel--;
EditorGUILayout.Space(4);
EditorGUILayout.LabelField("All zone data can be edited after creation.",
EditorStyles.miniLabel);
if(GUILayout.Button("Create & Edit", GUILayout.Height(26))) {
CreateZoneInScene();
}
EditorGUILayout.EndVertical();
}
// ── Edit Section ────────────────────────────────────────────────
private void EnterEditMode(ZoneInstance zone) {
_editingZone = zone;
_editingSO = zone.data != null ? new SerializedObject(zone.data) : null;
_showCreateForm = false;
_shapeOnEditStart = zone.data != null ? zone.data.shape : ZoneShape.Polygon;
Selection.activeGameObject = zone.gameObject;
SceneView.FrameLastActiveSceneView();
ZoneInstanceEditor.startEditingOnNextSelect = true;
}
private void ExitEditMode() {
// If exiting with unsaved new data, clean up the in-memory asset
if(_isUnsavedNewData && _editingZone != null && _editingZone.data != null) {
DestroyImmediate(_editingZone.data);
_editingZone.data = null;
}
_editingZone = null;
_editingSO = null;
_isUnsavedNewData = false;
_hasUnsavedChanges = false;
_saveError = null;
}
private void DrawEditSection() {
// Validate that the zone still exists
if(_editingZone == null || _editingZone.data == null) {
ExitEditMode();
return;
}
// Back button
EditorGUILayout.BeginHorizontal();
Color prevBackBg = GUI.backgroundColor;
GUI.backgroundColor = new Color(0.9f, 0.3f, 0.3f);
if(GUILayout.Button("← Back", GUILayout.Height(36), GUILayout.Width(70))) {
ExitEditMode();
GUI.backgroundColor = prevBackBg;
EditorGUILayout.EndHorizontal();
return;
}
GUI.backgroundColor = prevBackBg;
GUILayout.FlexibleSpace();
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(4);
ZoneData d = _editingZone.data;
// Zone header with color swatch
Color prevBg = GUI.backgroundColor;
GUI.backgroundColor = new Color(0.2f, 0.3f, 0.4f);
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
GUI.backgroundColor = prevBg;
string headerLabel = _isUnsavedNewData ? $"New Zone: {d.zoneName}" : $"Editing: {d.zoneName}";
EditorGUILayout.LabelField(headerLabel, new GUIStyle(EditorStyles.boldLabel) {
fontSize = 13,
normal = { textColor = new Color(0.9f, 0.95f, 1f) }
});
EditorGUILayout.EndVertical();
EditorGUILayout.Space(4);
// Draw the ZoneData fields using SerializedObject
if(_editingSO == null) {
return;
}
_editingSO.Update();
// Identity
EditorGUILayout.LabelField("Identity", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(_editingSO.FindProperty("zoneId"), new GUIContent("Zone ID"));
EditorGUILayout.PropertyField(_editingSO.FindProperty("zoneName"), new GUIContent("Zone Name"));
// Track role changes to auto-apply color
SerializedProperty rolePropForColor = _editingSO.FindProperty("role");
ZoneRole roleBefore = (ZoneRole)rolePropForColor.enumValueIndex;
EditorGUILayout.PropertyField(rolePropForColor, new GUIContent("Role"));
ZoneRole roleAfter = (ZoneRole)rolePropForColor.enumValueIndex;
if(roleBefore != roleAfter) {
ZoneEditorSettings settings = FindSettings();
_editingSO.FindProperty("debugColor").colorValue = settings.GetColorForRole(roleAfter);
}
EditorGUILayout.PropertyField(_editingSO.FindProperty("priority"), new GUIContent("Priority"));
EditorGUILayout.PropertyField(_editingSO.FindProperty("shape"), new GUIContent("Shape"));
SerializedProperty shapeProp = _editingSO.FindProperty("shape");
if((ZoneShape)shapeProp.enumValueIndex == ZoneShape.Circle) {
EditorGUILayout.PropertyField(_editingSO.FindProperty("circleRadius"), new GUIContent("Circle Radius"));
}
EditorGUILayout.Space(8);
// Role-specific fields
SerializedProperty roleProp = _editingSO.FindProperty("role");
ZoneRole role = (ZoneRole)roleProp.enumValueIndex;
switch(role) {
case ZoneRole.Base:
EditorGUILayout.LabelField("Base Zone Settings", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(_editingSO.FindProperty("encounterTableId"), new GUIContent("Encounter Table ID"));
EditorGUILayout.PropertyField(_editingSO.FindProperty("baseDifficultyTier"), new GUIContent("Difficulty Tier"));
EditorGUILayout.PropertyField(_editingSO.FindProperty("baseEncounterChance"), new GUIContent("Encounter Chance"));
break;
case ZoneRole.Modifier:
EditorGUILayout.LabelField("Modifier Zone Settings", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(_editingSO.FindProperty("encounterChanceMultiplier"), new GUIContent("Chance Multiplier"));
EditorGUILayout.PropertyField(_editingSO.FindProperty("difficultyTierBonus"), new GUIContent("Difficulty Tier Bonus"));
break;
case ZoneRole.Override:
EditorGUILayout.LabelField("Override Zone Settings", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(_editingSO.FindProperty("isSafeZone"), new GUIContent("Is Safe Zone"));
if(!_editingSO.FindProperty("isSafeZone").boolValue) {
EditorGUI.indentLevel++;
EditorGUILayout.PropertyField(_editingSO.FindProperty("overrideEncounterTableId"), new GUIContent("Encounter Table ID"));
EditorGUILayout.PropertyField(_editingSO.FindProperty("overrideEncounterChance"), new GUIContent("Encounter Chance"));
EditorGUILayout.PropertyField(_editingSO.FindProperty("overrideDifficultyTier"), new GUIContent("Difficulty Tier"));
EditorGUI.indentLevel--;
}
break;
}
if(_editingSO.ApplyModifiedProperties()) {
_hasUnsavedChanges = true;
}
EditorGUILayout.Space(8);
// Save button — shown for new unsaved zones or any modified existing zone
if(_isUnsavedNewData || _hasUnsavedChanges) {
// Show error if any
if(!string.IsNullOrEmpty(_saveError)) {
EditorGUILayout.HelpBox(_saveError, MessageType.Error);
}
GUI.backgroundColor = new Color(0.3f, 0.7f, 0.3f);
if(GUILayout.Button("💾 Save Zone", GUILayout.Height(30))) {
SaveZoneData();
}
GUI.backgroundColor = Color.white;
}
// Delete button at the bottom
EditorGUILayout.Space(4);
GUI.backgroundColor = new Color(0.9f, 0.3f, 0.3f);
if(GUILayout.Button("🗑 Delete Zone", GUILayout.Height(27))) {
ZoneInstance zone = _editingZone;
ExitEditMode();
DeleteZone(zone);
}
GUI.backgroundColor = Color.white;
}
// ── Create ──────────────────────────────────────────────────────
private static ZoneEditorSettings FindSettings() {
return ZoneEditorSettings.FindOrCreateSettings();
}
private void CreateZoneInScene() {
// Create in-memory ZoneData (saved when user clicks Save)
ZoneEditorSettings settings = FindSettings();
ZoneData data = CreateInstance<ZoneData>();
data.zoneId = _newZoneName.ToLower().Replace(" ", "_");
data.zoneName = _newZoneName;
data.shape = _newZoneShape;
data.debugColor = settings.GetColorForRole(data.role);
data.polygon.AddRange(ShapeFactory.CreateDefault(_newZoneShape));
// Create the scene GameObject
GameObject go = new GameObject(_newZoneName);
Undo.RegisterCreatedObjectUndo(go, "Create Zone");
ZoneInstance inst = go.AddComponent<ZoneInstance>();
inst.data = data;
// Try to parent under ZoneManager if it exists
ZonesObjectHolder mgr = FindFirstObjectByType<ZonesObjectHolder>();
if(mgr != null) {
go.transform.SetParent(mgr.transform, true);
}
_showCreateForm = false;
_isUnsavedNewData = true;
_saveError = null;
EnterEditMode(inst);
}
private void CreateDataForZone(ZoneInstance zone) {
string zoneName = zone.gameObject.name;
ZoneEditorSettings settings = FindSettings();
// Create in-memory ZoneData (not saved as asset yet)
ZoneData data = CreateInstance<ZoneData>();
data.zoneId = zoneName.ToLower().Replace(" ", "_");
data.zoneName = zoneName;
data.shape = ZoneShape.Polygon;
data.debugColor = settings.GetColorForRole(data.role);
data.polygon.AddRange(ShapeFactory.CreateDefault(ZoneShape.Polygon));
zone.data = data;
_isUnsavedNewData = true;
_saveError = null;
EnterEditMode(zone);
}
private void SaveZoneData() {
ZoneData data = _editingZone.data;
string zoneId = data.zoneId;
string assetName = data.zoneName.Replace(" ", "_");
// Check for duplicate zoneId or asset name among existing ZoneData assets
string[] existingGuids = AssetDatabase.FindAssets("t:ZoneData");
foreach(string guid in existingGuids) {
string path = AssetDatabase.GUIDToAssetPath(guid);
ZoneData existing = AssetDatabase.LoadAssetAtPath<ZoneData>(path);
if(existing == null || existing == data) {
continue;
}
if(existing.zoneId == zoneId) {
_saveError = $"A ZoneData asset with ID '{zoneId}' already exists at:\n{path}";
return;
}
string existingAssetName = Path.GetFileNameWithoutExtension(path);
if(existingAssetName == assetName) {
_saveError = $"A ZoneData asset named '{assetName}' already exists at:\n{path}";
return;
}
}
// Reset polygon if shape type changed since edit started
if(data.shape != _shapeOnEditStart) {
data.polygon.Clear();
data.polygon.AddRange(ShapeFactory.CreateDefault(data.shape));
if(data.shape == ZoneShape.Circle) {
data.circleRadius = ShapeFactory.DefaultRadius;
}
_editingZone.RebuildBoundsCache();
SceneView.RepaintAll();
}
_shapeOnEditStart = data.shape;
if(_isUnsavedNewData) {
// New zone — create the asset for the first time
ZoneEditorSettings settings = FindSettings();
string folder = settings != null ? settings.zoneDataFolder : "Assets/ZoneData";
string soPath = $"{folder}/{assetName}.asset";
string fullFolder = Path.GetFullPath(Path.Combine(Application.dataPath, "..", folder));
Directory.CreateDirectory(fullFolder);
AssetDatabase.CreateAsset(data, soPath);
AssetDatabase.SaveAssets();
// Rebuild the SerializedObject now that the asset is persisted
_editingSO = new SerializedObject(data);
_isUnsavedNewData = false;
Debug.Log($"[ZoneSystem] Created ZoneData '{data.zoneName}' at {soPath}");
}
else {
// Existing zone — rename asset if needed, then save
string currentPath = AssetDatabase.GetAssetPath(data);
string currentAssetName = Path.GetFileNameWithoutExtension(currentPath);
if(currentAssetName != assetName) {
string renameError = AssetDatabase.RenameAsset(currentPath, assetName);
if(!string.IsNullOrEmpty(renameError)) {
_saveError = $"Failed to rename asset: {renameError}";
return;
}
}
EditorUtility.SetDirty(data);
AssetDatabase.SaveAssets();
Debug.Log($"[ZoneSystem] Saved ZoneData '{data.zoneName}'");
}
// Rename the GameObject to match the zone name
Undo.RecordObject(_editingZone.gameObject, "Rename Zone GameObject");
_editingZone.gameObject.name = data.zoneName;
EditorUtility.SetDirty(_editingZone);
_hasUnsavedChanges = false;
_saveError = null;
}
// ── Scene Zones List ────────────────────────────────────────────
private void DrawSceneZonesList() {
EditorGUILayout.LabelField("Scene Zones", EditorStyles.boldLabel);
// Show active map plane from ZoneManager
ZonesObjectHolder mgr = FindFirstObjectByType<ZonesObjectHolder>();
if(mgr != null) {
EditorGUILayout.LabelField($"Map Plane: {mgr.mapPlane} (set on ZoneManager)",
new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = new Color(0.5f, 0.8f, 1f) } });
}
else {
EditorGUILayout.HelpBox("No ZoneManager found in scene.", MessageType.Warning);
}
ZoneInstance[] zones = FindObjectsByType<ZoneInstance>(FindObjectsSortMode.None)
.OrderByDescending(z => z.data?.priority ?? 0)
.ThenBy(z => z.data?.zoneName ?? "")
.ToArray();
if(zones.Length == 0) {
EditorGUILayout.HelpBox("No ZoneInstance objects found in the scene.", MessageType.Info);
return;
}
foreach(ZoneInstance zone in zones) {
DrawZoneRow(zone);
}
}
private void DrawZoneRow(ZoneInstance zone) {
if(zone.data == null) {
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
// Warning icon
GUIContent warnIcon = EditorGUIUtility.IconContent("console.warnicon.sml");
EditorGUILayout.LabelField(warnIcon, GUILayout.Width(18), GUILayout.Height(20));
EditorGUILayout.LabelField($"{zone.gameObject.name}: Missing ZoneData", EditorStyles.miniLabel);
// Add & Edit button — creates a ZoneData asset and enters edit mode
if(GUILayout.Button("+ Add & Edit", GUILayout.Width(90), GUILayout.Height(20))) {
CreateDataForZone(zone);
}
GUI.backgroundColor = new Color(0.9f, 0.3f, 0.3f);
if(GUILayout.Button("✕", GUILayout.Width(28), GUILayout.Height(20))) {
if(EditorUtility.DisplayDialog("Delete Zone",
$"Delete '{zone.gameObject.name}'? (no ZoneData asset to remove)", "Delete", "Cancel")) {
Undo.DestroyObjectImmediate(zone.gameObject);
}
}
GUI.backgroundColor = Color.white;
EditorGUILayout.EndHorizontal();
return;
}
ZoneData d = zone.data;
Color roleColor = FindSettings().GetColorForRole(d.role);
Color prevBg = GUI.backgroundColor;
GUI.backgroundColor = (roleColor * 2f * 0.6f) + (Color.gray * 0.4f);
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
GUI.backgroundColor = prevBg;
// Color swatch
Rect swatchRect = GUILayoutUtility.GetRect(12, 20, GUILayout.Width(12));
EditorGUI.DrawRect(swatchRect, roleColor * 3f);
// Info
EditorGUILayout.BeginVertical();
EditorGUILayout.LabelField($"{d.zoneName}", EditorStyles.boldLabel);
EditorGUILayout.LabelField(BuildZoneSummaryString(d), EditorStyles.miniLabel);
EditorGUILayout.EndVertical();
// Select / Edit button
if(GUILayout.Button("Select", GUILayout.Width(55), GUILayout.Height(36))) {
EnterEditMode(zone);
}
// Duplicate button
if(GUILayout.Button("📋", GUILayout.Width(28), GUILayout.Height(36))) {
DuplicateZone(zone);
}
// Delete button
GUI.backgroundColor = new Color(0.9f, 0.3f, 0.3f);
if(GUILayout.Button("✕", GUILayout.Width(28), GUILayout.Height(36))) {
DeleteZone(zone);
}
GUI.backgroundColor = prevBg;
EditorGUILayout.EndHorizontal();
}
private void DeleteZone(ZoneInstance zone) {
string zoneName = zone.data != null ? zone.data.zoneName : zone.gameObject.name;
string assetPath = zone.data != null ? AssetDatabase.GetAssetPath(zone.data) : null;
string message = $"Delete zone '{zoneName}'?";
if(!string.IsNullOrEmpty(assetPath)) {
message += $"\n\nThis will also delete the asset:\n{assetPath}";
}
if(!EditorUtility.DisplayDialog("Delete Zone", message, "Delete", "Cancel")) {
return;
}
if(!string.IsNullOrEmpty(assetPath)) {
AssetDatabase.DeleteAsset(assetPath);
}
Undo.DestroyObjectImmediate(zone.gameObject);
}
private void DuplicateZone(ZoneInstance zone) {
if(zone.data == null) {
return;
}
ZoneData original = zone.data;
ZoneEditorSettings settings = FindSettings();
string folder = settings.zoneDataFolder;
// Create independent ZoneData copy
ZoneData copy = CreateInstance<ZoneData>();
EditorUtility.CopySerialized(original, copy);
copy.zoneId = original.zoneId + "_" + System.Guid.NewGuid().ToString("N").Substring(0, 6);
copy.zoneName = original.zoneName + " (Copy)";
string newName = original.zoneName.Replace(" ", "_") + "_Copy";
string newPath = AssetDatabase.GenerateUniqueAssetPath(
Path.Combine(folder, newName + ".asset"));
string fullFolder = Path.GetFullPath(Path.Combine(Application.dataPath, "..", folder));
Directory.CreateDirectory(fullFolder);
AssetDatabase.CreateAsset(copy, newPath);
AssetDatabase.SaveAssets();
// Create scene GameObject
GameObject duplicate = Instantiate(zone.gameObject, zone.transform.parent);
duplicate.name = copy.zoneName;
Undo.RegisterCreatedObjectUndo(duplicate, "Duplicate Zone");
ZoneInstance dupInstance = duplicate.GetComponent<ZoneInstance>();
dupInstance.data = copy;
dupInstance.RebuildBoundsCache();
// Offset slightly so it's not on top of the original
ZonesObjectHolder mgr = FindFirstObjectByType<ZonesObjectHolder>();
MapPlane plane = mgr != null ? mgr.mapPlane : MapPlane.XZ;
Vector3 offset = MapPlaneUtility.UnprojectFromPlane(new Vector2(1f, 1f), plane, 0f);
duplicate.transform.position += offset;
Debug.Log($"[ZoneSystem] Duplicated zone to {newPath}");
}
private string BuildZoneSummaryString(ZoneData d) {
switch(d.role) {
case ZoneRole.Base:
return $"Base | Priority {d.priority} | {d.baseDifficultyTier} | {d.baseEncounterChance:P0} | Table: {d.encounterTableId}";
case ZoneRole.Modifier:
return $"Modifier | Priority {d.priority} | Chance ×{d.encounterChanceMultiplier:F2} | Tier +{d.difficultyTierBonus}";
case ZoneRole.Override:
return d.isSafeZone
? $"Override | Priority {d.priority} | ✓ SAFE"
: $"Override | Priority {d.priority} | {d.overrideDifficultyTier} | {d.overrideEncounterChance:P0}";
default: return "";
}
}
// ── Export Section ───────────────────────────────────────────────
private void DrawExportSection() {
_showExportFoldout = EditorGUILayout.BeginFoldoutHeaderGroup(_showExportFoldout, "Export Zones to JSON");
if(_showExportFoldout) {
EditorGUI.indentLevel++;
_exportPath = EditorGUILayout.TextField("Output Path", _exportPath);
EditorGUILayout.BeginHorizontal();
if(GUILayout.Button("Browse…", GUILayout.Width(70))) {
string picked = EditorUtility.SaveFilePanel(
"Save zones.json", Path.GetDirectoryName(_exportPath),
Path.GetFileName(_exportPath), "json");
if(!string.IsNullOrEmpty(picked)) {
_exportPath = "Assets" + picked.Substring(Application.dataPath.Length);
}
}
GUI.backgroundColor = new Color(0.4f, 0.8f, 0.4f);
if(GUILayout.Button("📦 Export Now", GUILayout.Height(24))) {
ExportZones();
}
GUI.backgroundColor = Color.white;
EditorGUILayout.EndHorizontal();
EditorGUI.indentLevel--;
}
EditorGUILayout.EndFoldoutHeaderGroup();
}
private void ExportZones() {
ZoneInstance[] instances = FindObjectsByType<ZoneInstance>(FindObjectsSortMode.None);
ZonesObjectHolder mgr = FindFirstObjectByType<ZonesObjectHolder>();
MapPlane plane = mgr != null ? mgr.mapPlane : MapPlane.XZ;
ZoneExportRoot root = ZoneExporter.BuildExport(instances, plane);
string json = ZoneExporter.ToJson(root);
string fullPath = Path.Combine(Application.dataPath, "../", _exportPath);
fullPath = Path.GetFullPath(fullPath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath));
File.WriteAllText(fullPath, json);
AssetDatabase.Refresh();
Debug.Log($"[ZoneSystem] Exported {root.zones.Count} zones → {fullPath}");
EditorUtility.DisplayDialog("Zone Export",
$"Successfully exported {root.zones.Count} zone(s) to:\n{_exportPath}", "OK");
}
}
}
#endif

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8f1ef1e3c20db2e4a904ef5201d403ec

View File

@@ -0,0 +1,503 @@
#if UNITY_EDITOR
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
namespace Jovian.ZoneSystem.Editor {
[CustomEditor(typeof(ZoneInstance))]
public class ZoneInstanceEditor : UnityEditor.Editor {
// Set to true externally to auto-enable editing when the inspector opens
internal static bool startEditingOnNextSelect;
private bool _editingPolygon;
private ZoneInstance _zone;
// ── Helpers ──────────────────────────────────────────────────────
private MapPlane ActivePlane {
get {
ZonesObjectHolder mgr = FindFirstObjectByType<ZonesObjectHolder>();
return mgr != null ? mgr.mapPlane : MapPlane.XZ;
}
}
private float DepthValue {
get {
MapPlane plane = ActivePlane;
return plane == MapPlane.XZ ? _zone.transform.position.y
: plane == MapPlane.YZ ? _zone.transform.position.x
: _zone.transform.position.z;
}
}
private void OnEnable() {
_zone = (ZoneInstance)target;
if(startEditingOnNextSelect) {
startEditingOnNextSelect = false;
_editingPolygon = true;
SceneView.RepaintAll();
}
}
private void OnDisable() {
_editingPolygon = false;
Tools.hidden = false;
}
// ── Scene GUI ────────────────────────────────────────────────────
private void OnSceneGUI() {
if(_zone.data == null) {
return;
}
// Hide the default transform handle when not editing the shape
if(!_editingPolygon) {
Tools.hidden = true;
} else {
Tools.hidden = false;
}
DrawFilledPolygon();
if(_editingPolygon) {
// Esc stops editing
if(Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Escape) {
_editingPolygon = false;
Event.current.Use();
Repaint();
SceneView.RepaintAll();
return;
}
// Consume default scene input so clicks don't select other objects
HandleUtility.AddDefaultControl(GUIUtility.GetControlID(FocusType.Passive));
if(_zone.data.shape == ZoneShape.Circle) {
DrawCircleRadiusHandle();
} else {
DrawVertexHandles();
HandleEdgeInsert();
}
}
}
private Vector2 PlaneOrigin => MapPlaneUtility.ProjectToPlane(_zone.transform.position, ActivePlane);
private Vector3 PolyPointToWorld(Vector2 pt) {
return MapPlaneUtility.UnprojectFromPlane(pt + PlaneOrigin, ActivePlane, DepthValue);
}
private Vector2 WorldToPolyPoint(Vector3 world) {
return MapPlaneUtility.ProjectToPlane(world, ActivePlane) - PlaneOrigin;
}
// ── Inspector ────────────────────────────────────────────────────
public override void OnInspectorGUI() {
DrawDefaultInspector();
EditorGUILayout.Space(8);
GUI.backgroundColor = new Color(0.4f, 0.7f, 1f);
if(GUILayout.Button("✏️ Edit in Zone Editor", GUILayout.Height(30))) {
ZoneEditorWindow.OpenAndEdit(_zone);
}
GUI.backgroundColor = Color.white;
EditorGUILayout.Space(4);
if(_zone.data == null) {
EditorGUILayout.HelpBox("Assign a ZoneData asset to begin editing.", MessageType.Warning);
return;
}
// Active plane info
EditorGUILayout.LabelField($"Active Plane: {ActivePlane} | Shape: {_zone.data.shape}",
new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = new Color(0.5f, 0.8f, 1f) } });
// ── Vertex List ─────────────────────────────────────────────
DrawVertexList();
// ── Shape Editing ───────────────────────────────────────────
EditorGUILayout.Space(4);
EditorGUILayout.LabelField("Shape", EditorStyles.boldLabel);
GUI.backgroundColor = _editingPolygon ? new Color(0.4f, 0.9f, 0.4f) : Color.white;
if(GUILayout.Button(_editingPolygon ? "⬛ Stop Editing" : "✏️ Edit Shape", GUILayout.Height(27))) {
_editingPolygon = !_editingPolygon;
SceneView.RepaintAll();
}
GUI.backgroundColor = Color.white;
if(_editingPolygon) {
if(_zone.data.shape == ZoneShape.Circle) {
EditorGUILayout.HelpBox(
"• Drag the radius handle to resize the circle",
MessageType.Info);
} else {
EditorGUILayout.HelpBox(
"• Drag handles to move vertices\n" +
"• Ctrl+Click on an edge to insert a vertex\n" +
"• Shift+Click a vertex to delete it",
MessageType.Info);
}
}
if(_zone.data.shape == ZoneShape.Circle) {
EditorGUI.BeginChangeCheck();
float newRadius = EditorGUILayout.FloatField("Circle Radius", _zone.data.circleRadius);
if(EditorGUI.EndChangeCheck()) {
Undo.RecordObject(_zone.data, "Change Circle Radius");
_zone.data.circleRadius = Mathf.Max(0.1f, newRadius);
ShapeFactory.RegenerateCircle(_zone.data);
EditorUtility.SetDirty(_zone.data);
_zone.RebuildBoundsCache();
SceneView.RepaintAll();
}
}
EditorGUILayout.BeginHorizontal();
if(GUILayout.Button("⊕ Center Transform", GUILayout.Height(27))) {
RecenterTransformOnZone();
}
GUI.backgroundColor = new Color(1f, 0.7f, 0.3f);
if(GUILayout.Button("↺ Reset Shape", GUILayout.Height(27))) {
Undo.RecordObject(_zone.data, "Reset Zone Shape");
_zone.data.polygon.Clear();
_zone.data.polygon.AddRange(ShapeFactory.CreateDefault(_zone.data.shape));
if(_zone.data.shape == ZoneShape.Circle) {
_zone.data.circleRadius = ShapeFactory.DefaultRadius;
}
EditorUtility.SetDirty(_zone.data);
_zone.RebuildBoundsCache();
SceneView.RepaintAll();
}
GUI.backgroundColor = Color.white;
EditorGUILayout.EndHorizontal();
// ── Duplication ─────────────────────────────────────────────
EditorGUILayout.Space(8);
if(GUILayout.Button("📋 Duplicate Zone", GUILayout.Height(27))) {
DuplicateZone();
}
// ── Summary ─────────────────────────────────────────────────
EditorGUILayout.Space(8);
DrawZoneSummary();
}
private void DrawZoneSummary() {
if(_zone.data == null) {
return;
}
ZoneData d = _zone.data;
Color roleColor = ZoneEditorSettings.FindOrCreateSettings().GetColorForRole(d.role);
Color prevBg = GUI.backgroundColor;
GUI.backgroundColor = roleColor * 2f;
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
GUI.backgroundColor = prevBg;
EditorGUILayout.LabelField("Zone Summary", EditorStyles.boldLabel);
EditorGUILayout.LabelField($"Role: {d.role}");
EditorGUILayout.LabelField($"Priority: {d.priority}");
EditorGUILayout.LabelField($"Vertices: {d.polygon?.Count ?? 0}");
switch(d.role) {
case ZoneRole.Base:
EditorGUILayout.LabelField($"Tier: {d.baseDifficultyTier}");
EditorGUILayout.LabelField($"Chance: {d.baseEncounterChance:P0}");
EditorGUILayout.LabelField($"Table: {d.encounterTableId}");
break;
case ZoneRole.Modifier:
EditorGUILayout.LabelField($"Chance ×: {d.encounterChanceMultiplier:F2}");
EditorGUILayout.LabelField($"Tier +: {d.difficultyTierBonus}");
break;
case ZoneRole.Override:
EditorGUILayout.LabelField(d.isSafeZone
? "⚑ SAFE ZONE — no encounters"
: $"⚑ Override → Tier {d.overrideDifficultyTier}, {d.overrideEncounterChance:P0}");
break;
}
EditorGUILayout.EndVertical();
}
private bool _showVertexList;
private void DrawVertexList() {
List<Vector2> pts = _zone.data.polygon;
if(pts == null || pts.Count == 0) {
return;
}
EditorGUILayout.Space(4);
_showVertexList = EditorGUILayout.BeginFoldoutHeaderGroup(_showVertexList,
$"Vertices ({pts.Count})");
if(_showVertexList) {
GUIStyle miniStyle = new GUIStyle(EditorStyles.miniLabel) {
alignment = TextAnchor.MiddleLeft,
richText = true
};
for(int i = 0; i < pts.Count; i++) {
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField(
$"<b>[{i}]</b> ({pts[i].x:F2}, {pts[i].y:F2})",
miniStyle);
if(GUILayout.Button("Copy", EditorStyles.miniButton, GUILayout.Width(40))) {
EditorGUIUtility.systemCopyBuffer = $"{pts[i].x:F2}, {pts[i].y:F2}";
}
EditorGUILayout.EndHorizontal();
}
}
EditorGUILayout.EndFoldoutHeaderGroup();
}
private void DrawFilledPolygon() {
List<Vector2> pts = _zone.data.polygon;
if(pts == null || pts.Count < 3) {
return;
}
Color fill = _zone.data.debugColor;
Color border = new Color(fill.r, fill.g, fill.b, Mathf.Clamp01(fill.a * 3f));
Vector3[] verts = new Vector3[pts.Count];
for(int i = 0; i < pts.Count; i++) {
verts[i] = PolyPointToWorld(pts[i]);
}
// Triangulate to handle concave polygons correctly
List<int> tris = PolygonUtils.Triangulate(pts);
Handles.color = fill;
for(int i = 0; i + 2 < tris.Count; i += 3) {
Handles.DrawAAConvexPolygon(verts[tris[i]], verts[tris[i + 1]], verts[tris[i + 2]]);
}
Handles.color = border;
for(int i = 0; i < pts.Count; i++) {
Handles.DrawLine(verts[i], verts[(i + 1) % pts.Count], 2f);
}
// Zone label at centroid
Vector2 centroid2D = PolygonUtils.Centroid(pts);
Vector3 labelPos = PolyPointToWorld(centroid2D);
Handles.Label(labelPos, _zone.data.zoneName, new GUIStyle(EditorStyles.boldLabel) {
normal = { textColor = Color.white },
fontSize = 11
});
}
private void DrawCircleRadiusHandle() {
Vector2 center = PolygonUtils.Centroid(_zone.data.polygon);
Vector2 radiusPoint = center + new Vector2(_zone.data.circleRadius, 0f);
Vector3 worldRadiusPoint = PolyPointToWorld(radiusPoint);
float size = HandleUtility.GetHandleSize(worldRadiusPoint) * 0.1f;
Handles.color = Color.cyan;
EditorGUI.BeginChangeCheck();
Vector3 newWorld = Handles.FreeMoveHandle(worldRadiusPoint, size, Vector3.zero, Handles.DotHandleCap);
if(EditorGUI.EndChangeCheck()) {
Undo.RecordObject(_zone.data, "Change Circle Radius");
Vector2 newPlane = WorldToPolyPoint(newWorld);
float newRadius = Mathf.Max(0.1f, Vector2.Distance(center, newPlane));
_zone.data.circleRadius = newRadius;
ShapeFactory.RegenerateCircle(_zone.data);
EditorUtility.SetDirty(_zone.data);
_zone.RebuildBoundsCache();
}
}
private void DrawVertexHandles() {
List<Vector2> pts = _zone.data.polygon;
if(pts == null) {
return;
}
Event e = Event.current;
for(int i = 0; i < pts.Count; i++) {
Vector3 worldPos = PolyPointToWorld(pts[i]);
float size = HandleUtility.GetHandleSize(worldPos) * 0.08f;
// Shift+Click → delete vertex (minimum 3)
if(e.shift && e.type == EventType.MouseDown && e.button == 0) {
if(HandleUtility.DistanceToCircle(worldPos, size) < size && pts.Count > 3) {
Undo.RecordObject(_zone.data, "Delete Zone Vertex");
pts.RemoveAt(i);
EditorUtility.SetDirty(_zone.data);
_zone.RebuildBoundsCache();
e.Use();
return;
}
}
Handles.color = e.shift ? Color.red : Color.yellow;
EditorGUI.BeginChangeCheck();
Vector3 newWorld = Handles.FreeMoveHandle(worldPos, size, Vector3.zero, Handles.DotHandleCap);
if(EditorGUI.EndChangeCheck()) {
Undo.RecordObject(_zone.data, "Move Zone Vertex");
pts[i] = WorldToPolyPoint(newWorld);
EditorUtility.SetDirty(_zone.data);
_zone.RebuildBoundsCache();
}
}
}
private void HandleEdgeInsert() {
List<Vector2> pts = _zone.data.polygon;
if(pts == null || pts.Count < 2) {
return;
}
Event e = Event.current;
// Highlight the closest edge while Ctrl is held for visual feedback
if(e.control) {
int previewEdge = FindClosestEdge(pts, out _, out float previewDist);
if(previewEdge >= 0 && previewDist < 20f) {
Vector3 a = PolyPointToWorld(pts[previewEdge]);
Vector3 b = PolyPointToWorld(pts[(previewEdge + 1) % pts.Count]);
Handles.color = Color.cyan;
Handles.DrawLine(a, b, 4f);
HandleUtility.Repaint();
}
}
if(!e.control || e.type != EventType.MouseDown || e.button != 0) {
return;
}
// 20px screen-space tolerance — camera-distance independent
int bestEdge = FindClosestEdge(pts, out Vector3 insertPoint, out float dist);
if(bestEdge >= 0 && dist < 20f) {
Undo.RecordObject(_zone.data, "Insert Zone Vertex");
pts.Insert(bestEdge + 1, WorldToPolyPoint(insertPoint));
EditorUtility.SetDirty(_zone.data);
_zone.RebuildBoundsCache();
e.Use();
}
}
/// <summary>
/// Finds the polygon edge closest to the mouse in screen space (pixels).
/// Returns the edge index to insert after, the world-space insertion point, and pixel distance.
/// Screen-space comparison means tolerance is camera-distance independent.
/// </summary>
private int FindClosestEdge(List<Vector2> pts, out Vector3 closestPoint, out float closestPixelDist) {
closestPoint = Vector3.zero;
closestPixelDist = float.MaxValue;
int bestEdge = -1;
Vector2 mouseGUI = Event.current.mousePosition;
for(int i = 0; i < pts.Count; i++) {
Vector3 a = PolyPointToWorld(pts[i]);
Vector3 b = PolyPointToWorld(pts[(i + 1) % pts.Count]);
Vector2 aScreen = HandleUtility.WorldToGUIPoint(a);
Vector2 bScreen = HandleUtility.WorldToGUIPoint(b);
Vector2 ab = bScreen - aScreen;
float len = ab.sqrMagnitude;
float t = len > 0.0001f
? Mathf.Clamp01(Vector2.Dot(mouseGUI - aScreen, ab) / len)
: 0f;
float pixelDist = Vector2.Distance(mouseGUI, aScreen + (ab * t));
if(pixelDist < closestPixelDist) {
closestPixelDist = pixelDist;
bestEdge = i;
closestPoint = Vector3.Lerp(a, b, t);
}
}
return bestEdge;
}
// ── Re-center ────────────────────────────────────────────────────
private void RecenterTransformOnZone() {
List<Vector2> pts = _zone.data.polygon;
if(pts == null || pts.Count == 0) {
return;
}
Vector2 centroid = PolygonUtils.Centroid(pts);
if(centroid.sqrMagnitude < 0.001f) {
return;
}
Undo.RecordObject(_zone.data, "Center Transform on Zone");
Undo.RecordObject(_zone.transform, "Center Transform on Zone");
// Shift all polygon points so the centroid becomes (0,0)
for(int i = 0; i < pts.Count; i++) {
pts[i] -= centroid;
}
// Move the transform so the zone stays in the same world position
Vector3 worldOffset = MapPlaneUtility.UnprojectFromPlane(centroid, ActivePlane, 0f);
_zone.transform.position += worldOffset;
EditorUtility.SetDirty(_zone.data);
EditorUtility.SetDirty(_zone.transform);
_zone.RebuildBoundsCache();
SceneView.RepaintAll();
}
// ── Duplication ─────────────────────────────────────────────────
private void DuplicateZone() {
if(_zone.data == null) {
return;
}
ZoneData original = _zone.data;
ZoneEditorSettings settings = ZoneEditorSettings.FindOrCreateSettings();
string folder = settings.zoneDataFolder;
// Create independent ZoneData copy
ZoneData copy = ScriptableObject.CreateInstance<ZoneData>();
EditorUtility.CopySerialized(original, copy);
copy.zoneId = original.zoneId + "_" + System.Guid.NewGuid().ToString("N").Substring(0, 6);
copy.zoneName = original.zoneName + " (Copy)";
string newName = original.zoneName.Replace(" ", "_") + "_Copy";
string newPath = AssetDatabase.GenerateUniqueAssetPath(
System.IO.Path.Combine(folder, newName + ".asset"));
System.IO.Directory.CreateDirectory(
System.IO.Path.Combine(Application.dataPath, "..", folder));
AssetDatabase.CreateAsset(copy, newPath);
AssetDatabase.SaveAssets();
// Create scene GameObject
GameObject duplicate = Instantiate(_zone.gameObject, _zone.transform.parent);
duplicate.name = copy.zoneName;
Undo.RegisterCreatedObjectUndo(duplicate, "Duplicate Zone");
ZoneInstance dupInstance = duplicate.GetComponent<ZoneInstance>();
dupInstance.data = copy;
dupInstance.RebuildBoundsCache();
Vector3 offset = MapPlaneUtility.UnprojectFromPlane(new Vector2(1f, 1f), ActivePlane, 0f);
duplicate.transform.position += offset;
Selection.activeGameObject = duplicate;
SceneView.RepaintAll();
Debug.Log($"[ZoneSystem] Duplicated zone to {newPath}");
}
}
}
#endif

View File

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

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sebastian Bularca
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3f8b14cf28bb13f49a26d4a350d60785
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,78 @@
# Jovian Zone System
A polygon-based zone system for defining map regions with encounter difficulty, modifiers, and safe areas. No physics engine required.
## Package Structure
```
Packages/com.jovian.zonesystem/
├── Runtime/
│ ├── ZoneTypes.cs ← Enums (ZoneRole, ZoneShape, DifficultyTier), ZoneContext struct
│ ├── ZoneData.cs ← ScriptableObject: per-zone config + polygon
│ ├── ZoneInstance.cs ← MonoBehaviour: scene object, owns polygon + bounds cache
│ ├── ZonesObjectHolder.cs ← Scene manager: registers zones, holds map plane
│ ├── ZoneSystemApi.cs ← Query API: resolve zones at world positions
│ ├── ZoneResolver.cs ← Pure logic: overlapping zones → ZoneContext
│ ├── MapPlane.cs ← MapPlane enum + projection/unprojection utilities
│ ├── PolygonUtils.cs ← Pure math: point-in-polygon, centroid, AABB, triangulation
│ ├── ShapeFactory.cs ← Default shape generation (square, circle, polygon)
│ └── ZoneExporter.cs ← Serialization to JSON
├── Editor/
│ ├── ZoneEditorWindow.cs ← Main editor window (Window → Zone System → Zone Editor)
│ ├── ZoneEditorSettings.cs ← Configurable settings: folder path, role colors
│ ├── ZoneInstanceEditor.cs ← Custom inspector + scene handles for shape editing
│ └── ZoneDataEditor.cs ← Role-aware ZoneData inspector
└── Documentation~/
└── index.html ← Full HTML documentation
```
## Quick Start
1. Add the package to your project (local package in `Packages/`).
2. Create a **ZonesObjectHolder** GameObject and set **Map Plane** to match your map (e.g. `XZ`).
3. Open **Window → Zone System → Zone Editor**.
4. Click **Create New Zone**, set a name and shape, then click **Create & Edit**.
5. Edit all zone data fields in the editor, then click **Save Zone**.
6. Use scene handles to adjust the polygon shape.
## Key Features
- **Three zone roles**: Base (encounter table + difficulty), Modifier (multiplicative stacking), Override (safe zones, story events)
- **Visual polygon editing**: Drag vertices, Ctrl+Click to insert, Shift+Click to delete, Esc to stop
- **Concave polygon support**: Ear-clipping triangulation for correct rendering of any shape
- **Multi-plane support**: XY, XZ, or YZ — one setting controls everything
- **No physics dependency**: Pure math ray-casting with AABB pre-rejection
- **Save workflow**: Create → Edit → Save with duplicate ID/name validation
- **Role-based colors**: Configured in ZoneEditorSettings, auto-applied on role change
- **Zone duplication**: Independent copies with unique IDs and assets
- **JSON export**: For runtime loading or external tools
## Menu Items
| Menu Path | Description |
|-----------|-------------|
| Window → Zone System → Zone Editor | Main editor window |
| Window → Zone System → Settings | Select or create ZoneEditorSettings asset |
| Window → Zone System → Documentation | Open HTML documentation |
## Runtime API
```csharp
ZoneSystemApi api = new ZoneSystemApi(zonesObjectHolder);
// Query zone at a world position
ZoneContext ctx = api.QueryZone(partyWorldPosition);
if(!ctx.isSafe && Random.value < ctx.finalEncounterChance)
TriggerEncounter(ctx.encounterTableId, ctx.finalDifficultyTier);
// Quick safe-zone check
if(api.IsInSafeZone(partyWorldPosition))
return;
// Raw overlapping zones (sorted by priority)
List<ZoneData> zones = api.GetOverlappingZones(partyWorldPosition);
```
## Documentation
Full documentation is available at `Documentation~/index.html`. Open it via **Window → Zone System → Documentation**.

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
using UnityEngine;
namespace Jovian.ZoneSystem {
/// <summary>
/// Defines which two world axes the map (and zone polygons) lie on.
/// XY = flat sprite / UI map (Z is depth)
/// XZ = 3D world map (Y is up) ← standard Unity 3D
/// YZ = side-on map (X is depth)
/// </summary>
public enum MapPlane {
XY,
XZ,
YZ
}
public static class MapPlaneUtility {
/// <summary>
/// Projects a 3D world position onto the chosen map plane,
/// returning a 2D point suitable for polygon testing.
/// </summary>
public static Vector2 ProjectToPlane(Vector3 worldPos, MapPlane plane) {
switch(plane) {
case MapPlane.XY: return new Vector2(worldPos.x, worldPos.y);
case MapPlane.XZ: return new Vector2(worldPos.x, worldPos.z);
case MapPlane.YZ: return new Vector2(worldPos.y, worldPos.z);
default: return new Vector2(worldPos.x, worldPos.y);
}
}
/// <summary>
/// Reconstructs a 3D world position from a 2D polygon point on the chosen plane.
/// The depth value fills the axis not covered by the plane.
/// </summary>
public static Vector3 UnprojectFromPlane(Vector2 point, MapPlane plane, float depth = 0f) {
switch(plane) {
case MapPlane.XY: return new Vector3(point.x, point.y, depth);
case MapPlane.XZ: return new Vector3(point.x, depth, point.y);
case MapPlane.YZ: return new Vector3(depth, point.x, point.y);
default: return new Vector3(point.x, point.y, depth);
}
}
}
}

View File

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

View File

@@ -0,0 +1,200 @@
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.ZoneSystem {
public static class PolygonUtils {
/// <summary>
/// Ray-casting point-in-polygon test (Jordan curve theorem).
/// Works on any plane — caller projects the world position first via MapPlaneUtility.
/// Handles edge and vertex cases robustly.
/// </summary>
/// <param name="point">2D point already projected onto the polygon's plane.</param>
/// <param name="polygon">Polygon vertices in the same 2D space.</param>
public static bool PointInPolygon(Vector2 point, List<Vector2> polygon) {
if(polygon == null || polygon.Count < 3) {
return false;
}
float px = point.x;
float py = point.y;
bool inside = false;
int count = polygon.Count;
int j = count - 1;
for(int i = 0; i < count; i++) {
float xi = polygon[i].x, yi = polygon[i].y;
float xj = polygon[j].x, yj = polygon[j].y;
// Crossing test: does the edge (j→i) cross the horizontal ray from point?
bool crosses = (yi > py) != (yj > py) &&
px < ((xj - xi) * (py - yi) / (yj - yi)) + xi;
if(crosses) {
inside = !inside;
}
j = i;
}
return inside;
}
/// <summary>
/// Overload that accepts a world position and projects it onto the given plane
/// before testing — this is the primary API used by ZoneManager.
/// </summary>
public static bool PointInPolygon(Vector3 worldPos, List<Vector2> polygon, MapPlane plane) {
Vector2 projected = MapPlaneUtility.ProjectToPlane(worldPos, plane);
return PointInPolygon(projected, polygon);
}
/// <summary>
/// Returns the centroid of a polygon (for label placement in the editor).
/// </summary>
public static Vector2 Centroid(List<Vector2> polygon) {
if(polygon == null || polygon.Count == 0) {
return Vector2.zero;
}
Vector2 sum = Vector2.zero;
foreach(Vector2 pt in polygon) {
sum += pt;
}
return sum / polygon.Count;
}
/// <summary>
/// Returns the approximate axis-aligned bounding box of a polygon.
/// Useful for a cheap pre-check before running the full ray-cast test.
/// </summary>
public static (Vector2 min, Vector2 max) Bounds(List<Vector2> polygon) {
if(polygon == null || polygon.Count == 0) {
return (Vector2.zero, Vector2.zero);
}
Vector2 min = polygon[0], max = polygon[0];
foreach(Vector2 pt in polygon) {
if(pt.x < min.x) {
min.x = pt.x;
}
if(pt.y < min.y) {
min.y = pt.y;
}
if(pt.x > max.x) {
max.x = pt.x;
}
if(pt.y > max.y) {
max.y = pt.y;
}
}
return (min, max);
}
/// <summary>
/// Fast AABB pre-check. Call this before PointInPolygon to skip the
/// ray-cast for points clearly outside the bounding box.
/// </summary>
public static bool PointInBounds(Vector2 point, Vector2 min, Vector2 max) {
return point.x >= min.x && point.x <= max.x &&
point.y >= min.y && point.y <= max.y;
}
/// <summary>
/// Ear-clipping triangulation for simple (non-self-intersecting) polygons.
/// Returns a list of triangle index triplets into the original vertex list.
/// Supports both convex and concave polygons.
/// </summary>
public static List<int> Triangulate(List<Vector2> polygon) {
List<int> triangles = new List<int>();
int n = polygon.Count;
if(n < 3) {
return triangles;
}
// Build index list
List<int> indices = new List<int>(n);
bool clockwise = SignedArea(polygon) < 0f;
for(int i = 0; i < n; i++) {
indices.Add(clockwise ? i : n - 1 - i);
}
int remaining = n;
int failSafe = remaining * 2;
int v = remaining - 1;
while(remaining > 2) {
if(failSafe-- <= 0) {
break;
}
int u = v;
if(u >= remaining) {
u = 0;
}
v = u + 1;
if(v >= remaining) {
v = 0;
}
int w = v + 1;
if(w >= remaining) {
w = 0;
}
if(IsEar(polygon, indices, u, v, w, remaining)) {
triangles.Add(indices[u]);
triangles.Add(indices[v]);
triangles.Add(indices[w]);
indices.RemoveAt(v);
remaining--;
failSafe = remaining * 2;
}
}
return triangles;
}
private static float SignedArea(List<Vector2> polygon) {
float area = 0f;
int count = polygon.Count;
for(int i = 0; i < count; i++) {
Vector2 a = polygon[i];
Vector2 b = polygon[(i + 1) % count];
area += (b.x - a.x) * (b.y + a.y);
}
return area;
}
private static bool IsEar(List<Vector2> polygon, List<int> indices, int u, int v, int w, int remaining) {
Vector2 a = polygon[indices[u]];
Vector2 b = polygon[indices[v]];
Vector2 c = polygon[indices[w]];
// Must be convex (counter-clockwise winding after we've ensured CCW order)
float cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
if(cross <= 0f) {
return false;
}
// No other vertex must be inside this triangle
for(int p = 0; p < remaining; p++) {
if(p == u || p == v || p == w) {
continue;
}
if(PointInTriangle(polygon[indices[p]], a, b, c)) {
return false;
}
}
return true;
}
private static bool PointInTriangle(Vector2 p, Vector2 a, Vector2 b, Vector2 c) {
float d1 = (p.x - b.x) * (a.y - b.y) - (a.x - b.x) * (p.y - b.y);
float d2 = (p.x - c.x) * (b.y - c.y) - (b.x - c.x) * (p.y - c.y);
float d3 = (p.x - a.x) * (c.y - a.y) - (c.x - a.x) * (p.y - a.y);
bool hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0);
bool hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0);
return !(hasNeg && hasPos);
}
}
}

View File

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

View File

@@ -0,0 +1,49 @@
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.ZoneSystem {
public static class ShapeFactory {
public const int CircleSegments = 24;
public const float DefaultRadius = 2f;
public const float DefaultSquareHalf = 2f;
public const float DefaultPolygonRadius = 3f;
public const int DefaultPolygonVertices = 12;
public static List<Vector2> CreateSquare(float halfSize = DefaultSquareHalf) {
return new List<Vector2> {
new(-halfSize, -halfSize),
new(-halfSize, halfSize),
new(halfSize, halfSize),
new(halfSize, -halfSize)
};
}
public static List<Vector2> CreateCircle(float radius = DefaultRadius, int segments = CircleSegments) {
List<Vector2> points = new List<Vector2>(segments);
float step = 2f * Mathf.PI / segments;
for(int i = 0; i < segments; i++) {
float angle = i * step;
points.Add(new Vector2(Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius));
}
return points;
}
public static List<Vector2> CreatePolygon(float radius = DefaultPolygonRadius, int vertices = DefaultPolygonVertices) {
return CreateCircle(radius, vertices);
}
public static List<Vector2> CreateDefault(ZoneShape shape) {
switch(shape) {
case ZoneShape.Square: return CreateSquare();
case ZoneShape.Circle: return CreateCircle();
case ZoneShape.Polygon: return CreatePolygon();
default: return CreateSquare();
}
}
public static void RegenerateCircle(ZoneData data) {
data.polygon.Clear();
data.polygon.AddRange(CreateCircle(data.circleRadius));
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 202475efe66c6304298b9073ef7627ea

View File

@@ -0,0 +1,62 @@
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.ZoneSystem {
[CreateAssetMenu(fileName = "NewZone", menuName = "ZoneSystem/Zone Data")]
public class ZoneData : ScriptableObject {
[Header("Identity")]
public string zoneId;
public string zoneName;
public ZoneRole role = ZoneRole.Base;
public int priority = 1;
[Header("Visual (Editor Only)")]
public Color debugColor = new(1f, 0.5f, 0f, 0.25f);
// ── Base zone fields ────────────────────────────────────────────
[Header("Base Zone Settings")]
[Tooltip("Only used when Role = Base")]
public string encounterTableId;
[Tooltip("Only used when Role = Base")]
public DifficultyTier baseDifficultyTier = DifficultyTier.Mild;
[Tooltip("Base encounter chance per check (0..1). Only used when Role = Base")]
[Range(0f, 1f)]
public float baseEncounterChance = 0.2f;
// ── Modifier zone fields ─────────────────────────────────────────
[Header("Modifier Zone Settings")]
[Tooltip("Multiplied onto the base encounter chance. Only used when Role = Modifier")]
public float encounterChanceMultiplier = 1f;
[Tooltip("Added to the base difficulty tier (clamped). Only used when Role = Modifier")]
public int difficultyTierBonus;
// ── Override zone fields ─────────────────────────────────────────
[Header("Override Zone Settings")]
[Tooltip("If true, no encounters occur in this zone. Only used when Role = Override")]
public bool isSafeZone;
[Tooltip("Only used when Role = Override and isSafeZone = false")]
public string overrideEncounterTableId;
[Tooltip("Only used when Role = Override and isSafeZone = false")]
[Range(0f, 1f)]
public float overrideEncounterChance = 1f;
[Tooltip("Only used when Role = Override and isSafeZone = false")]
public DifficultyTier overrideDifficultyTier = DifficultyTier.Deadly;
// ── Shape ────────────────────────────────────────────────────────
[HideInInspector]
public ZoneShape shape = ZoneShape.Square;
[HideInInspector]
public float circleRadius = 2f;
[HideInInspector]
public List<Vector2> polygon = new();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8497d766078e5764a9c7c0dd5d671561

View File

@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.ZoneSystem {
/// <summary>
/// Serializable representations for JSON export.
/// Kept in Runtime so server-side or headless builds can also consume them.
/// </summary>
[Serializable]
public class ZoneExportEntry {
public string id;
public string name;
public string role;
public int priority;
// Base
public string encounterTableId;
public int baseDifficultyTier;
public float baseEncounterChance;
// Modifier
public float encounterChanceMultiplier;
public int difficultyTierBonus;
// Override
public bool isSafeZone;
public string overrideEncounterTableId;
public float overrideEncounterChance;
public int overrideDifficultyTier;
// Shape
public string shape;
public float circleRadius;
public float[] position;
public List<float[]> polygon;
}
[Serializable]
public class ZoneExportRoot {
public List<ZoneExportEntry> zones = new();
}
public static class ZoneExporter {
public static ZoneExportRoot BuildExport(ZoneInstance[] instances, MapPlane plane = MapPlane.XZ) {
ZoneExportRoot root = new ZoneExportRoot();
foreach(ZoneInstance inst in instances) {
if(inst.data == null) {
continue;
}
ZoneData d = inst.data;
Vector3 pos = inst.transform.position;
Vector2 origin = MapPlaneUtility.ProjectToPlane(pos, plane);
ZoneExportEntry entry = new ZoneExportEntry {
id = d.zoneId,
name = d.zoneName,
role = d.role.ToString(),
priority = d.priority,
shape = d.shape.ToString(),
circleRadius = d.circleRadius,
position = new[] { pos.x, pos.y, pos.z },
encounterTableId = d.encounterTableId,
baseDifficultyTier = (int)d.baseDifficultyTier,
baseEncounterChance = d.baseEncounterChance,
encounterChanceMultiplier = d.encounterChanceMultiplier,
difficultyTierBonus = d.difficultyTierBonus,
isSafeZone = d.isSafeZone,
overrideEncounterTableId = d.overrideEncounterTableId,
overrideEncounterChance = d.overrideEncounterChance,
overrideDifficultyTier = (int)d.overrideDifficultyTier,
polygon = new List<float[]>()
};
foreach(Vector2 pt in d.polygon) {
Vector2 worldPt = pt + origin;
entry.polygon.Add(new[] { worldPt.x, worldPt.y });
}
root.zones.Add(entry);
}
return root;
}
public static string ToJson(ZoneExportRoot root, bool pretty = true) {
return JsonUtility.ToJson(root, pretty);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 321272ab8f26941488d472164a97c162

Some files were not shown because too many files have changed in this diff Show More