forked from Shardstone/trail-into-darkness
First commit on my server, yey!
This commit is contained in:
8
Packages/com.jovian.savesystem/Editor.meta
Normal file
8
Packages/com.jovian.savesystem/Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 59da007fca3e0ac4e9a5096277c37275
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bbc94d83b5c0e8c4c8c529cc64c94ddf
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 34f9753365e282e4f8c610df1cc61e28
|
||||
21
Packages/com.jovian.savesystem/LICENSE
Normal file
21
Packages/com.jovian.savesystem/LICENSE
Normal 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.
|
||||
7
Packages/com.jovian.savesystem/LICENSE.meta
Normal file
7
Packages/com.jovian.savesystem/LICENSE.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 230d63e981df3274ab47c00223c7b6c0
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
197
Packages/com.jovian.savesystem/README.md
Normal file
197
Packages/com.jovian.savesystem/README.md
Normal 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.
|
||||
7
Packages/com.jovian.savesystem/README.md.meta
Normal file
7
Packages/com.jovian.savesystem/README.md.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6af1b651ff0c3854f907408768835b5c
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Packages/com.jovian.savesystem/Runtime.meta
Normal file
8
Packages/com.jovian.savesystem/Runtime.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b869b6ce172663a42adca34c78db2f78
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
10
Packages/com.jovian.savesystem/Runtime/ISaveSerializer.cs
Normal file
10
Packages/com.jovian.savesystem/Runtime/ISaveSerializer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 27f27474b2fc12f47b9eea77568ac3c6
|
||||
23
Packages/com.jovian.savesystem/Runtime/ISaveSlotManager.cs
Normal file
23
Packages/com.jovian.savesystem/Runtime/ISaveSlotManager.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cd45cbabfe17114419035e9591ac06ce
|
||||
22
Packages/com.jovian.savesystem/Runtime/ISaveStorage.cs
Normal file
22
Packages/com.jovian.savesystem/Runtime/ISaveStorage.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5f445979ba5d9414abd75819f53bfaba
|
||||
24
Packages/com.jovian.savesystem/Runtime/ISaveSystem.cs
Normal file
24
Packages/com.jovian.savesystem/Runtime/ISaveSystem.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 54cb074c17a51c74d93aadb3ce7b2e47
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a19ae8824b9d43447be860535961727d
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Packages/com.jovian.savesystem/Runtime/Model.meta
Normal file
8
Packages/com.jovian.savesystem/Runtime/Model.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1f99186168750fb469321dee33fd3397
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
16
Packages/com.jovian.savesystem/Runtime/Model/SaveEnvelope.cs
Normal file
16
Packages/com.jovian.savesystem/Runtime/Model/SaveEnvelope.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ffa4184b4e99e3947ac73fa747d346d7
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Jovian.SaveSystem {
|
||||
public enum SaveFormat {
|
||||
Json,
|
||||
Binary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a118c9ab8d89ad546ad67e8dc1a71c98
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Jovian.SaveSystem {
|
||||
public enum SaveSlotType {
|
||||
Manual,
|
||||
Auto,
|
||||
Quick
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0f1e478bd3e3fec43a55f4efec51577c
|
||||
138
Packages/com.jovian.savesystem/Runtime/SaveSystem.cs
Normal file
138
Packages/com.jovian.savesystem/Runtime/SaveSystem.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1ef842c1c48d58641b6016af3c512d39
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee824fed683906741ab35b553a76486a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4f7af3b3f06e67b408b0f52089acdeea
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2edaf26270d17a145a2c7f1fa7080d3a
|
||||
8
Packages/com.jovian.savesystem/Runtime/Settings.meta
Normal file
8
Packages/com.jovian.savesystem/Runtime/Settings.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a4191975d6dce814bbda5a57c1d17548
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 86b7c6be79eeb6a48aa96d9041dcb5ed
|
||||
8
Packages/com.jovian.savesystem/Runtime/Slots.meta
Normal file
8
Packages/com.jovian.savesystem/Runtime/Slots.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b7eaf086b5f24df42a8d6a79de5c5c52
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f03201423cd9f364c85bc6ce44386fbd
|
||||
26
Packages/com.jovian.savesystem/Runtime/Slots/SaveSlotInfo.cs
Normal file
26
Packages/com.jovian.savesystem/Runtime/Slots/SaveSlotInfo.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c10256ba563528a4c9d92d158c6c009c
|
||||
202
Packages/com.jovian.savesystem/Runtime/Slots/SaveSlotManager.cs
Normal file
202
Packages/com.jovian.savesystem/Runtime/Slots/SaveSlotManager.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9498521d42a48944ba38a961e8e82459
|
||||
8
Packages/com.jovian.savesystem/Runtime/Storage.meta
Normal file
8
Packages/com.jovian.savesystem/Runtime/Storage.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a74728833fd95214a8e55433cc901ac6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cba707f119fcd0143be086bff73bcd9d
|
||||
8
Packages/com.jovian.savesystem/Tests.meta
Normal file
8
Packages/com.jovian.savesystem/Tests.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2a7653161b6889242a7ecb08f584398c
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Packages/com.jovian.savesystem/Tests/Editor.meta
Normal file
8
Packages/com.jovian.savesystem/Tests/Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5ff28f3695e4c214d9f9c9d3f14e2259
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e61712a36e316da4b9cd50d3d5ae71bc
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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>()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8e16fa5e1d6fe1240bbb82bbb338cec3
|
||||
@@ -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)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 942e63ef3dd14ac4294a5330ba0442c4
|
||||
@@ -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)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 55a1496c116a3434291a828b258bbb97
|
||||
8
Packages/com.jovian.savesystem/Tests/Runtime.meta
Normal file
8
Packages/com.jovian.savesystem/Tests/Runtime.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8b9f7ed9a26cede488cbe67e3bed5938
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6c0ac5542b5d3b846a1ed415880d1411
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
18
Packages/com.jovian.savesystem/package.json
Normal file
18
Packages/com.jovian.savesystem/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
Packages/com.jovian.savesystem/package.json.meta
Normal file
7
Packages/com.jovian.savesystem/package.json.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4934a2fae884a0e4a9f789f07b4409ad
|
||||
PackageManifestImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Packages/com.jovian.unitypackagesync/Editor.meta
Normal file
8
Packages/com.jovian.unitypackagesync/Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5c085a3cfa0ca4949ac2b368ccb9bade
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 27826bbfce0730c4eb507ed69a3c6e77
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
319
Packages/com.jovian.unitypackagesync/Editor/PackageSyncWindow.cs
Normal file
319
Packages/com.jovian.unitypackagesync/Editor/PackageSyncWindow.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ac2844b690dc31a418b0670c82f6365f
|
||||
15
Packages/com.jovian.unitypackagesync/package.json
Normal file
15
Packages/com.jovian.unitypackagesync/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
Packages/com.jovian.unitypackagesync/package.json.meta
Normal file
7
Packages/com.jovian.unitypackagesync/package.json.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1cc17f0cce3469044a0ff0038183354c
|
||||
PackageManifestImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
538
Packages/com.jovian.zonesystem/Documentation~/index.html
Normal file
538
Packages/com.jovian.zonesystem/Documentation~/index.html
Normal 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 & 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 & 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 — 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 → Edit → 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 & 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 → Zone System → 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 & 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 → Zone System → 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>📋</code>): Creates an independent copy with a new asset</li>
|
||||
<li><strong>Delete</strong> button (<code>✕</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 & 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 — add, remove, and drag vertices freely</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Additional Tools (Inspector)</h3>
|
||||
<ul>
|
||||
<li><strong>Center Transform</strong>: Moves the GameObject’s transform to the polygon’s centroid without changing the zone’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 & 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 — 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 → use the highest-priority Override exclusively</li>
|
||||
<li>Find the highest-priority <strong>Base</strong> zone → 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 → Zone System → 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’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 — 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 && Random.value < 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<ZoneData></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 — 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–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 “won” 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 → ZoneSystem → 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<Vector2></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<Vector2>)</code></td><td>Ray-casting point-in-polygon test (Jordan curve theorem)</td></tr>
|
||||
<tr><td><code>PointInPolygon(Vector3, List<Vector2>, MapPlane)</code></td><td>Projects world position to plane, then tests</td></tr>
|
||||
<tr><td><code>Centroid(List<Vector2>)</code></td><td>Average center of polygon vertices</td></tr>
|
||||
<tr><td><code>Bounds(List<Vector2>)</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<Vector2>)</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 → 2D plane coordinates</td></tr>
|
||||
<tr><td><code>UnprojectFromPlane(Vector2, MapPlane, float)</code></td><td>2D plane coordinates → 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<ZoneData>)</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<ZoneExportRoot>(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 → Zone System → Zone Editor</td><td>Opens the main Zone Editor window</td></tr>
|
||||
<tr><td>Window → Zone System → Settings</td><td>Selects (or creates) the ZoneEditorSettings asset</td></tr>
|
||||
<tr><td>Window → Zone System → Documentation</td><td>Opens this documentation in your default browser</td></tr>
|
||||
<tr><td>Jovian → ZoneSystem → Zone Editor Settings</td><td>Create menu for new ZoneEditorSettings asset</td></tr>
|
||||
<tr><td>ZoneSystem → 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 — com.jovian.zonesystem
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
8
Packages/com.jovian.zonesystem/Editor.meta
Normal file
8
Packages/com.jovian.zonesystem/Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0a961874148653f41a30b0562a2a5dc2
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 18e660fb45b16f646be8417e3f101d98
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
131
Packages/com.jovian.zonesystem/Editor/ZoneDataEditor.cs
Normal file
131
Packages/com.jovian.zonesystem/Editor/ZoneDataEditor.cs
Normal 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
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ba5e43b325f91cd45a86ee6fc860275f
|
||||
109
Packages/com.jovian.zonesystem/Editor/ZoneEditorSettings.cs
Normal file
109
Packages/com.jovian.zonesystem/Editor/ZoneEditorSettings.cs
Normal 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
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 64856826ade04f41963e973ab19b2f00
|
||||
timeCreated: 1772984016
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 07d9ebf920c798c46b91e4f371ba5c7a
|
||||
643
Packages/com.jovian.zonesystem/Editor/ZoneEditorWindow.cs
Normal file
643
Packages/com.jovian.zonesystem/Editor/ZoneEditorWindow.cs
Normal 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
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8f1ef1e3c20db2e4a904ef5201d403ec
|
||||
503
Packages/com.jovian.zonesystem/Editor/ZoneInstanceEditor.cs
Normal file
503
Packages/com.jovian.zonesystem/Editor/ZoneInstanceEditor.cs
Normal 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
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3ea29e8d3bc24ec49bdc2b279aaae4e9
|
||||
21
Packages/com.jovian.zonesystem/LICENSE
Normal file
21
Packages/com.jovian.zonesystem/LICENSE
Normal 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.
|
||||
7
Packages/com.jovian.zonesystem/LICENSE.meta
Normal file
7
Packages/com.jovian.zonesystem/LICENSE.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3f8b14cf28bb13f49a26d4a350d60785
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
78
Packages/com.jovian.zonesystem/README.md
Normal file
78
Packages/com.jovian.zonesystem/README.md
Normal 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**.
|
||||
7
Packages/com.jovian.zonesystem/README.md.meta
Normal file
7
Packages/com.jovian.zonesystem/README.md.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c7679ba6ca31ec4daaba7b32661c16a
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Packages/com.jovian.zonesystem/Runtime.meta
Normal file
8
Packages/com.jovian.zonesystem/Runtime.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 89e41bb3e8c252a419239691c021ac35
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Jovian.ZoneSystem",
|
||||
"rootNamespace": "Jovian.ZoneSystem",
|
||||
"references": [],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 14a17a3524e6bed489ca921a325f8942
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
43
Packages/com.jovian.zonesystem/Runtime/MapPlane.cs
Normal file
43
Packages/com.jovian.zonesystem/Runtime/MapPlane.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Packages/com.jovian.zonesystem/Runtime/MapPlane.cs.meta
Normal file
2
Packages/com.jovian.zonesystem/Runtime/MapPlane.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e9eeceb36a2fca741a5e4c3206a20d00
|
||||
200
Packages/com.jovian.zonesystem/Runtime/PolygonUtils.cs
Normal file
200
Packages/com.jovian.zonesystem/Runtime/PolygonUtils.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c09971de26d1abf48ac379e5e8ac533c
|
||||
49
Packages/com.jovian.zonesystem/Runtime/ShapeFactory.cs
Normal file
49
Packages/com.jovian.zonesystem/Runtime/ShapeFactory.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 202475efe66c6304298b9073ef7627ea
|
||||
62
Packages/com.jovian.zonesystem/Runtime/ZoneData.cs
Normal file
62
Packages/com.jovian.zonesystem/Runtime/ZoneData.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
2
Packages/com.jovian.zonesystem/Runtime/ZoneData.cs.meta
Normal file
2
Packages/com.jovian.zonesystem/Runtime/ZoneData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8497d766078e5764a9c7c0dd5d671561
|
||||
91
Packages/com.jovian.zonesystem/Runtime/ZoneExporter.cs
Normal file
91
Packages/com.jovian.zonesystem/Runtime/ZoneExporter.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 321272ab8f26941488d472164a97c162
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user