diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b9aaac --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +### Unity +# Unity generated directories +/[Ll]ibrary/ +/[Tt]emp/ +/[Oo]bj/ +/[Bb]uild/ +/[Bb]uilds/ +/[Ll]ogs/ +/[Uu]ser[Ss]ettings/ +/[Mm]emory[Cc]aptures/ + +# Asset meta data should only be ignored when the corresponding asset is also ignored +!/[Aa]ssets/**/*.meta + +# Build output +*.apk +*.aab +*.unitypackage + +# Autogenerated solution and project files +*.csproj +*.unityproj +*.sln +*.suo \ No newline at end of file diff --git a/Documentation~/index.html b/Documentation~/index.html new file mode 100644 index 0000000..cbf01fc --- /dev/null +++ b/Documentation~/index.html @@ -0,0 +1,538 @@ + + + + + + Jovian Zone System - Documentation + + + +
+ +

Jovian Zone System v0.1.0

+

A polygon-based zone system for defining map regions with encounter difficulty, modifiers, and safe areas.

+ +
+

Table of Contents

+ +
+ + +

Overview

+
+

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 ZoneContext with the encounter table, difficulty tier, and chance.

+ +

Key Features

+ +
+ + +

Setup & Quick Start

+
+

1. Scene Setup

+
    +
  1. Create a GameObject and add the ZonesObjectHolder component.
  2. +
  3. Set the Map Plane field to match your map orientation.
  4. +
+ + + + + + +
Map PlaneUse CaseAxesIgnored
XYFlat sprite map, UI mapX, YZ
XZ3D world map (standard Unity 3D)X, ZY
YZSide-on mapY, ZX
+ +

2. Create Your First Zone

+
    +
  1. Open Window → Zone System → Zone Editor.
  2. +
  3. Click Create New Zone.
  4. +
  5. Enter a name, select a shape (Square, Circle, or Polygon).
  6. +
  7. Click Create & Edit.
  8. +
  9. Edit zone data fields (role, priority, encounter settings).
  10. +
  11. Click Save Zone to persist the asset.
  12. +
  13. Use Scene view handles to adjust the polygon shape.
  14. +
+ +
+ Note: The zone asset is not saved to disk until you click the Save button. You can edit all fields freely before committing. +
+
+ + +

Zone Editor Window

+
+

The Zone Editor window (Window → Zone System → Zone Editor) is the primary tool for managing zones.

+ +

Zone List View

+

Shows all ZoneInstance objects in the current scene. Each row displays:

+ +

Zones with missing ZoneData show a warning icon with options to add data or delete the zone.

+ +

Create Zone

+

Click Create New Zone to open the creation dropdown:

+ +

The zone is created in-memory and enters edit mode immediately. You must click Save to persist the asset.

+ +

Edit Mode

+

When editing a zone, all ZoneData fields are available inline:

+ + +

Save Validation

+

On save, the editor checks for:

+ +

If a conflict is found, an error is displayed and saving is blocked.

+ +

Auto-applied Changes on Save

+ + +

Export Section

+

At the bottom of the editor window, expand Export Zones to JSON to export all scene zones to a JSON file for runtime loading.

+
+ + +

Shape Editing

+
+

When a zone is in edit mode, yellow handles appear in the Scene view for each polygon vertex.

+ +

Controls

+ + + + + + +
ActionInputDescription
Move vertexDrag handleDrag any yellow handle to reposition a vertex
Insert vertexCtrl + Click edgeAdds a new vertex on the closest edge (cyan highlight shows target)
Delete vertexShift + Click vertexRemoves the vertex (minimum 3 vertices). Handles turn red while Shift is held.
Stop editingEscExits shape edit mode
+ +

Shapes

+ + + + + +
ShapeDefaultNotes
Square4 vertices, 2-unit half-sizeCan be reshaped into any quad
Circle24-segment approximation, radius 2Drag the radius handle to resize. Regenerates vertices on radius change.
Polygon12 vertices, radius 3Fully freeform — add, remove, and drag vertices freely
+ +

Additional Tools (Inspector)

+ + +
+ Scene interaction: 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. +
+
+ + +

Zone Roles & Resolution

+
+

Roles

+ + + + + + + + + + + + + + + + + +
RolePurposeFields
BaseDefines the encounter table and baseline difficultyEncounter Table ID, Difficulty Tier, Encounter Chance
ModifierStacks multiplicatively on top of a Base zoneChance Multiplier, Difficulty Tier Bonus
OverrideReplaces everything — towns, story events, safe areasIs Safe Zone, Encounter Table ID, Encounter Chance, Difficulty Tier
+ +

Resolution Order

+

When querying a world position, the ZoneResolver follows this order:

+
    +
  1. If any Override zone is present → use the highest-priority Override exclusively
  2. +
  3. Find the highest-priority Base zone → encounter table + baseline difficulty
  4. +
  5. Stack all Modifier zones multiplicatively on top
  6. +
  7. Clamp and return a ZoneContext
  8. +
+ +

Modifier Stacking

+

Modifiers are multiplicative, so each one is independent:

+
Base chance 0.30  x  Cursed Road 1.8  x  Night Modifier 1.2  =  0.648
+ +

Priority

+

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).

+
+ + +

Editor Settings

+
+

Access via Window → Zone System → Settings. If no settings asset exists, one is created automatically.

+ +

Fields

+ + + + + +
FieldDescriptionDefault
mapPlaneWhich two world axes your map lies onXZ
zoneDataFolderFolder path where new ZoneData assets are savedAssets/ZoneData
roleColorsDebug color for each zone role (used in scene rendering)Blue (Base), Yellow (Modifier), Green (Override)
+ +

Role Colors

+

Each ZoneRole 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:

+ + +
+ Dynamic: If you add new values to the ZoneRole enum, call SyncRoleEntries() on the settings asset or click the settings menu item — missing roles will be added with a default gray color. +
+
+ + +

Runtime API

+
+

ZoneSystemApi

+

The main entry point for runtime zone queries.

+ +
// 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);
+ +

Methods

+ + + + + + + +
MethodReturnsDescription
QueryZone(Vector3)ZoneContextFull resolution: finds overlapping zones, applies modifiers, returns final context
GetOverlappingZones(Vector3)List<ZoneData>Raw list of all zones containing the position, sorted by descending priority
IsInSafeZone(Vector3)boolQuick check — true if any Override zone with isSafeZone contains the position
Register(ZoneInstance)voidRegister a dynamically spawned zone
Unregister(ZoneInstance)voidUnregister a zone before destroying it
+ +

ZoneContext Struct

+ + + + + + + +
FieldTypeDescription
encounterTableIdstringID of the encounter table to use
finalEncounterChancefloatFinal encounter probability (0–1), after modifier stacking
finalDifficultyTierDifficultyTierFinal difficulty tier, after modifier bonuses
isSafeboolTrue if in a safe zone (no encounters)
resolvedZoneNamestringName of the zone that “won” resolution (for debug/UI)
+ +

Dynamic Zones

+
// After instantiating a zone at runtime:
+api.Register(zoneInstance);
+
+// Before destroying:
+api.Unregister(zoneInstance);
+
+ + +

Type Reference

+
+

Enums

+ +

ZoneRole

+ + + + + +
ValueDescription
BaseProvides the encounter table and baseline difficulty
ModifierMutates difficulty/chance on top of a Base zone
OverrideCompletely replaces everything (safe towns, story events)
+ +

ZoneShape

+ + + + + +
ValueDescription
Square4-vertex quadrilateral
Circle24-segment circular approximation with adjustable radius
PolygonFreeform polygon with 12 default vertices
+ +

DifficultyTier

+ + + + + + + +
ValueInt
Safe0
Mild1
Moderate2
Dangerous3
Deadly4
+ +

MapPlane

+ + + + + +
ValueAxesDepth
XYX, YZ
XZX, ZY
YZY, ZX
+ +

ScriptableObjects

+ +

ZoneData

+

Per-zone configuration asset. Created via the Zone Editor or Create → ZoneSystem → Zone Data.

+ + + + + + + + + + +
FieldTypeDescription
zoneIdstringUnique identifier
zoneNamestringDisplay name
roleZoneRoleBase, Modifier, or Override
priorityintHigher wins in same-role conflicts
debugColorColorScene visualization color (auto-set from role)
shapeZoneShapeShape type
circleRadiusfloatRadius (Circle shape only)
polygonList<Vector2>Vertex positions (local to transform)
+ +

MonoBehaviours

+ +

ZoneInstance

+

Placed on a scene GameObject. References a ZoneData asset and provides spatial queries.

+ + + + + +
Field / MethodDescription
dataReference to the ZoneData asset
Contains(Vector3, MapPlane)Returns true if the world position is inside this zone
RebuildBoundsCache()Recalculates the AABB cache (call after modifying polygon)
+ +

ZonesObjectHolder

+

Scene manager that holds the map plane setting and provides access to all zones.

+ + + + +
Field / PropertyDescription
mapPlaneWhich plane the map lies on (XY, XZ, or YZ)
AllZonesRead-only list of all ZoneInstance objects in the scene
+
+ + +

Utility Classes

+
+

PolygonUtils

+

Static math utilities for polygon operations.

+ + + + + + + + +
MethodDescription
PointInPolygon(Vector2, List<Vector2>)Ray-casting point-in-polygon test (Jordan curve theorem)
PointInPolygon(Vector3, List<Vector2>, MapPlane)Projects world position to plane, then tests
Centroid(List<Vector2>)Average center of polygon vertices
Bounds(List<Vector2>)Axis-aligned bounding box (min, max)
PointInBounds(Vector2, Vector2, Vector2)Fast AABB pre-check
Triangulate(List<Vector2>)Ear-clipping triangulation for concave polygon rendering
+ +

MapPlaneUtility

+

Converts between 3D world positions and 2D plane coordinates.

+ + + + +
MethodDescription
ProjectToPlane(Vector3, MapPlane)3D world → 2D plane coordinates
UnprojectFromPlane(Vector2, MapPlane, float)2D plane coordinates → 3D world
+ +

ShapeFactory

+

Generates default polygon vertices for each shape type.

+ + + + + + + +
MethodDescription
CreateDefault(ZoneShape)Returns default vertices for the given shape
CreateSquare(float)4-vertex square with given half-size
CreateCircle(float, int)N-segment circle approximation
CreatePolygon(float, int)Regular polygon with N vertices
RegenerateCircle(ZoneData)Rebuilds circle vertices from current radius
+ +

ZoneResolver

+

Pure logic for resolving overlapping zones into a single ZoneContext.

+ + + +
MethodDescription
Resolve(List<ZoneData>)Takes overlapping zone data, applies role priority and modifier stacking, returns ZoneContext
+ +

ZoneExporter

+

Serializes scene zones to a JSON structure for runtime loading.

+ + + + +
MethodDescription
BuildExport(ZoneInstance[], MapPlane)Builds the export data structure from scene instances
ToJson(ZoneExportRoot, bool)Converts to JSON string (optionally pretty-printed)
+
+ + +

JSON Export

+
+

In the Zone Editor window, expand Export Zones to JSON, set the output path, and click Export Now.

+ +

Loading at Runtime

+
string json = File.ReadAllText(Application.streamingAssetsPath + "/zones.json");
+ZoneExportRoot root = JsonUtility.FromJson<ZoneExportRoot>(json);
+ +

Export Structure

+

Each zone is exported as a ZoneExportEntry containing all zone data fields plus the world-space polygon coordinates and transform position.

+
+ + +

Keyboard Shortcuts

+
+ + + + + +
KeyContextAction
EscScene view, shape editing activeStop editing the zone shape
Ctrl + ClickScene view, shape editing activeInsert a vertex on the nearest edge
Shift + ClickScene view, shape editing activeDelete the clicked vertex (min 3)
+
+ + + +
+ + + + + + + +
Menu PathDescription
Window → Zone System → Zone EditorOpens the main Zone Editor window
Window → Zone System → SettingsSelects (or creates) the ZoneEditorSettings asset
Window → Zone System → DocumentationOpens this documentation in your default browser
Jovian → ZoneSystem → Zone Editor SettingsCreate menu for new ZoneEditorSettings asset
ZoneSystem → Zone DataCreate menu for new ZoneData asset
+
+ +
+

+ Jovian Zone System v0.1.0 — com.jovian.zonesystem +

+ +
+ + diff --git a/Editor.meta b/Editor.meta new file mode 100644 index 0000000..eb111d0 --- /dev/null +++ b/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0a961874148653f41a30b0562a2a5dc2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Jovian.ZoneSystem.Editor.asmdef b/Editor/Jovian.ZoneSystem.Editor.asmdef new file mode 100644 index 0000000..8140956 --- /dev/null +++ b/Editor/Jovian.ZoneSystem.Editor.asmdef @@ -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 +} diff --git a/Editor/Jovian.ZoneSystem.Editor.asmdef.meta b/Editor/Jovian.ZoneSystem.Editor.asmdef.meta new file mode 100644 index 0000000..86489f6 --- /dev/null +++ b/Editor/Jovian.ZoneSystem.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 18e660fb45b16f646be8417e3f101d98 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/ZoneDataEditor.cs b/Editor/ZoneDataEditor.cs new file mode 100644 index 0000000..605582d --- /dev/null +++ b/Editor/ZoneDataEditor.cs @@ -0,0 +1,131 @@ +#if UNITY_EDITOR +using UnityEditor; +using UnityEngine; + +namespace Jovian.ZoneSystem.Editor { + /// + /// Custom inspector for ZoneData ScriptableObject. + /// Shows only fields relevant to the selected ZoneRole. + /// + [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 diff --git a/Editor/ZoneDataEditor.cs.meta b/Editor/ZoneDataEditor.cs.meta new file mode 100644 index 0000000..f457c8d --- /dev/null +++ b/Editor/ZoneDataEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ba5e43b325f91cd45a86ee6fc860275f \ No newline at end of file diff --git a/Editor/ZoneEditorSettings.cs b/Editor/ZoneEditorSettings.cs new file mode 100644 index 0000000..f927f57 --- /dev/null +++ b/Editor/ZoneEditorSettings.cs @@ -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 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); + } + + /// + /// Ensures every ZoneRole enum value has a color entry. + /// Call this after adding new roles to the enum. + /// + 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( + AssetDatabase.GUIDToAssetPath(guids[0])); + } + + // Create a new settings asset + string folder = "Assets"; + ZoneEditorSettings newSettings = CreateInstance(); + 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 diff --git a/Editor/ZoneEditorSettings.cs.meta b/Editor/ZoneEditorSettings.cs.meta new file mode 100644 index 0000000..831b4df --- /dev/null +++ b/Editor/ZoneEditorSettings.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 64856826ade04f41963e973ab19b2f00 +timeCreated: 1772984016 \ No newline at end of file diff --git a/Editor/ZoneEditorSettingsEditor.cs b/Editor/ZoneEditorSettingsEditor.cs new file mode 100644 index 0000000..8aa5023 --- /dev/null +++ b/Editor/ZoneEditorSettingsEditor.cs @@ -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(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 diff --git a/Editor/ZoneEditorSettingsEditor.cs.meta b/Editor/ZoneEditorSettingsEditor.cs.meta new file mode 100644 index 0000000..92c9269 --- /dev/null +++ b/Editor/ZoneEditorSettingsEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 07d9ebf920c798c46b91e4f371ba5c7a \ No newline at end of file diff --git a/Editor/ZoneEditorWindow.cs b/Editor/ZoneEditorWindow.cs new file mode 100644 index 0000000..d7a090c --- /dev/null +++ b/Editor/ZoneEditorWindow.cs @@ -0,0 +1,643 @@ +#if UNITY_EDITOR +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace Jovian.ZoneSystem.Editor { + /// + /// Main Zone Editor window. + /// Open via: Window → Zone System → Zone Editor + /// + 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("Zone Editor"); + } + + public static void OpenAndEdit(ZoneInstance zone) { + ZoneEditorWindow window = GetWindow("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(); + 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(); + inst.data = data; + + // Try to parent under ZoneManager if it exists + ZonesObjectHolder mgr = FindFirstObjectByType(); + 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(); + 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(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(); + 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(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(); + 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(); + dupInstance.data = copy; + dupInstance.RebuildBoundsCache(); + + // Offset slightly so it's not on top of the original + ZonesObjectHolder mgr = FindFirstObjectByType(); + 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(FindObjectsSortMode.None); + ZonesObjectHolder mgr = FindFirstObjectByType(); + 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 diff --git a/Editor/ZoneEditorWindow.cs.meta b/Editor/ZoneEditorWindow.cs.meta new file mode 100644 index 0000000..388af7a --- /dev/null +++ b/Editor/ZoneEditorWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8f1ef1e3c20db2e4a904ef5201d403ec \ No newline at end of file diff --git a/Editor/ZoneInstanceEditor.cs b/Editor/ZoneInstanceEditor.cs new file mode 100644 index 0000000..5cbbf32 --- /dev/null +++ b/Editor/ZoneInstanceEditor.cs @@ -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(); + 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 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( + $"[{i}] ({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 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 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 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 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(); + } + } + + /// + /// 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. + /// + private int FindClosestEdge(List 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 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(); + 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(); + 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 diff --git a/Editor/ZoneInstanceEditor.cs.meta b/Editor/ZoneInstanceEditor.cs.meta new file mode 100644 index 0000000..398228d --- /dev/null +++ b/Editor/ZoneInstanceEditor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3ea29e8d3bc24ec49bdc2b279aaae4e9 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..472990a --- /dev/null +++ b/LICENSE @@ -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. diff --git a/LICENSE.meta b/LICENSE.meta new file mode 100644 index 0000000..191c7dc --- /dev/null +++ b/LICENSE.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3f8b14cf28bb13f49a26d4a350d60785 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/README.md b/README.md index eac11f6..c860d5e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,78 @@ -# unity-zone-system +# Jovian Zone System -A polygon-based zone system for defining map regions with encounter difficulty, modifiers, and safe areas. Tools for creating and managing zones included. \ No newline at end of file +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 zones = api.GetOverlappingZones(partyWorldPosition); +``` + +## Documentation + +Full documentation is available at `Documentation~/index.html`. Open it via **Window → Zone System → Documentation**. diff --git a/README.md.meta b/README.md.meta new file mode 100644 index 0000000..9d90293 --- /dev/null +++ b/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 3c7679ba6ca31ec4daaba7b32661c16a +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime.meta b/Runtime.meta new file mode 100644 index 0000000..052339a --- /dev/null +++ b/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 89e41bb3e8c252a419239691c021ac35 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Jovian.ZoneSystem.asmdef b/Runtime/Jovian.ZoneSystem.asmdef new file mode 100644 index 0000000..b2066d3 --- /dev/null +++ b/Runtime/Jovian.ZoneSystem.asmdef @@ -0,0 +1,14 @@ +{ + "name": "Jovian.ZoneSystem", + "rootNamespace": "Jovian.ZoneSystem", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Runtime/Jovian.ZoneSystem.asmdef.meta b/Runtime/Jovian.ZoneSystem.asmdef.meta new file mode 100644 index 0000000..eddd635 --- /dev/null +++ b/Runtime/Jovian.ZoneSystem.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 14a17a3524e6bed489ca921a325f8942 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/MapPlane.cs b/Runtime/MapPlane.cs new file mode 100644 index 0000000..614e28d --- /dev/null +++ b/Runtime/MapPlane.cs @@ -0,0 +1,43 @@ +using UnityEngine; + +namespace Jovian.ZoneSystem { + /// + /// 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) + /// + public enum MapPlane { + XY, + XZ, + YZ + } + + public static class MapPlaneUtility { + /// + /// Projects a 3D world position onto the chosen map plane, + /// returning a 2D point suitable for polygon testing. + /// + 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); + } + } + + /// + /// 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. + /// + 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); + } + } + } +} diff --git a/Runtime/MapPlane.cs.meta b/Runtime/MapPlane.cs.meta new file mode 100644 index 0000000..9c25b58 --- /dev/null +++ b/Runtime/MapPlane.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e9eeceb36a2fca741a5e4c3206a20d00 \ No newline at end of file diff --git a/Runtime/PolygonUtils.cs b/Runtime/PolygonUtils.cs new file mode 100644 index 0000000..5b62350 --- /dev/null +++ b/Runtime/PolygonUtils.cs @@ -0,0 +1,200 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace Jovian.ZoneSystem { + public static class PolygonUtils { + /// + /// 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. + /// + /// 2D point already projected onto the polygon's plane. + /// Polygon vertices in the same 2D space. + public static bool PointInPolygon(Vector2 point, List 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; + } + + /// + /// Overload that accepts a world position and projects it onto the given plane + /// before testing — this is the primary API used by ZoneManager. + /// + public static bool PointInPolygon(Vector3 worldPos, List polygon, MapPlane plane) { + Vector2 projected = MapPlaneUtility.ProjectToPlane(worldPos, plane); + return PointInPolygon(projected, polygon); + } + + /// + /// Returns the centroid of a polygon (for label placement in the editor). + /// + public static Vector2 Centroid(List 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; + } + + /// + /// Returns the approximate axis-aligned bounding box of a polygon. + /// Useful for a cheap pre-check before running the full ray-cast test. + /// + public static (Vector2 min, Vector2 max) Bounds(List 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); + } + + /// + /// Fast AABB pre-check. Call this before PointInPolygon to skip the + /// ray-cast for points clearly outside the bounding box. + /// + 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; + } + + /// + /// 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. + /// + public static List Triangulate(List polygon) { + List triangles = new List(); + int n = polygon.Count; + if(n < 3) { + return triangles; + } + + // Build index list + List indices = new List(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 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 polygon, List 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); + } + } +} diff --git a/Runtime/PolygonUtils.cs.meta b/Runtime/PolygonUtils.cs.meta new file mode 100644 index 0000000..7a72f53 --- /dev/null +++ b/Runtime/PolygonUtils.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c09971de26d1abf48ac379e5e8ac533c \ No newline at end of file diff --git a/Runtime/ShapeFactory.cs b/Runtime/ShapeFactory.cs new file mode 100644 index 0000000..e4e2d8e --- /dev/null +++ b/Runtime/ShapeFactory.cs @@ -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 CreateSquare(float halfSize = DefaultSquareHalf) { + return new List { + new(-halfSize, -halfSize), + new(-halfSize, halfSize), + new(halfSize, halfSize), + new(halfSize, -halfSize) + }; + } + + public static List CreateCircle(float radius = DefaultRadius, int segments = CircleSegments) { + List points = new List(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 CreatePolygon(float radius = DefaultPolygonRadius, int vertices = DefaultPolygonVertices) { + return CreateCircle(radius, vertices); + } + + public static List 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)); + } + } +} diff --git a/Runtime/ShapeFactory.cs.meta b/Runtime/ShapeFactory.cs.meta new file mode 100644 index 0000000..10266c0 --- /dev/null +++ b/Runtime/ShapeFactory.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 202475efe66c6304298b9073ef7627ea \ No newline at end of file diff --git a/Runtime/ZoneData.cs b/Runtime/ZoneData.cs new file mode 100644 index 0000000..88457fa --- /dev/null +++ b/Runtime/ZoneData.cs @@ -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 polygon = new(); + } +} diff --git a/Runtime/ZoneData.cs.meta b/Runtime/ZoneData.cs.meta new file mode 100644 index 0000000..9594eec --- /dev/null +++ b/Runtime/ZoneData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8497d766078e5764a9c7c0dd5d671561 \ No newline at end of file diff --git a/Runtime/ZoneExporter.cs b/Runtime/ZoneExporter.cs new file mode 100644 index 0000000..30d2a8d --- /dev/null +++ b/Runtime/ZoneExporter.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace Jovian.ZoneSystem { + /// + /// Serializable representations for JSON export. + /// Kept in Runtime so server-side or headless builds can also consume them. + /// + [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 polygon; + } + + [Serializable] + public class ZoneExportRoot { + public List 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() + }; + + 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); + } + } +} diff --git a/Runtime/ZoneExporter.cs.meta b/Runtime/ZoneExporter.cs.meta new file mode 100644 index 0000000..1d6350d --- /dev/null +++ b/Runtime/ZoneExporter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 321272ab8f26941488d472164a97c162 \ No newline at end of file diff --git a/Runtime/ZoneInstance.cs b/Runtime/ZoneInstance.cs new file mode 100644 index 0000000..9baeab8 --- /dev/null +++ b/Runtime/ZoneInstance.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace Jovian.ZoneSystem { + public class ZoneInstance : MonoBehaviour { + public ZoneData data; + private Vector2 _boundsMax; + + // Cached AABB for fast pre-rejection (rebuilt when data changes) + private Vector2 _boundsMin; + private bool _boundsValid; + + private void Awake() { + RebuildBoundsCache(); + } + +#if UNITY_EDITOR + private void OnDrawGizmos() { + if(data == null || data.polygon == null || data.polygon.Count < 2) { + return; + } + + MapPlane plane = MapPlane.XZ; + ZonesObjectHolder mgr = FindFirstObjectByType(); + if(mgr != null) { + plane = mgr.mapPlane; + } + + float depth = plane == MapPlane.XZ ? transform.position.y + : plane == MapPlane.YZ ? transform.position.x + : transform.position.z; + + Vector2 origin = MapPlaneUtility.ProjectToPlane(transform.position, plane); + Gizmos.color = data.debugColor; + List pts = data.polygon; + + for(int i = 0; i < pts.Count; i++) { + Vector3 a = MapPlaneUtility.UnprojectFromPlane(pts[i] + origin, plane, depth); + Vector3 b = MapPlaneUtility.UnprojectFromPlane(pts[(i + 1) % pts.Count] + origin, plane, depth); + Gizmos.DrawLine(a, b); + } + } +#endif + private void OnValidate() { + RebuildBoundsCache(); + } + + /// + /// Rebuilds the AABB cache from the current polygon data. + /// Called automatically on Awake/Validate; also call this in the + /// editor after modifying polygon points. + /// + public void RebuildBoundsCache() { + if(data == null || data.polygon == null || data.polygon.Count < 3) { + _boundsValid = false; + return; + } + + (_boundsMin, _boundsMax) = PolygonUtils.Bounds(data.polygon); + _boundsValid = true; + } + + /// + /// Returns true if the given world position is inside this zone's polygon. + /// Plane controls which two axes are used for the 2D projection. + /// + public bool Contains(Vector3 worldPos, MapPlane plane) { + if(data == null || data.polygon == null || data.polygon.Count < 3) { + return false; + } + + Vector2 projected = MapPlaneUtility.ProjectToPlane(worldPos, plane); + Vector2 origin = MapPlaneUtility.ProjectToPlane(transform.position, plane); + Vector2 localPoint = projected - origin; + + // Fast AABB reject before running the full ray-cast + if(_boundsValid && !PolygonUtils.PointInBounds(localPoint, _boundsMin, _boundsMax)) { + return false; + } + + return PolygonUtils.PointInPolygon(localPoint, data.polygon); + } + } +} diff --git a/Runtime/ZoneInstance.cs.meta b/Runtime/ZoneInstance.cs.meta new file mode 100644 index 0000000..4cbdb09 --- /dev/null +++ b/Runtime/ZoneInstance.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 95af4f7ff0649854598833eabd84f131 \ No newline at end of file diff --git a/Runtime/ZoneResolver.cs b/Runtime/ZoneResolver.cs new file mode 100644 index 0000000..4bfcd25 --- /dev/null +++ b/Runtime/ZoneResolver.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace Jovian.ZoneSystem { + public static class ZoneResolver { + /// + /// Resolves a list of overlapping ZoneData into a single ZoneContext. + /// Resolution rules: + /// 1. If any Override zone is present → use highest-priority Override exclusively. + /// 2. Otherwise → find highest-priority Base zone, then stack all Modifier zones + /// multiplicatively on top. + /// + public static ZoneContext Resolve(List overlapping) { + if(overlapping == null || overlapping.Count == 0) { + return SafeFallback(string.Empty); + } + + // ── 1. Check for Override zones ────────────────────────────── + var overrides = overlapping + .Where(z => z.role == ZoneRole.Override) + .OrderByDescending(z => z.priority) + .ToList(); + + if(overrides.Count > 0) { + var ov = overrides[0]; + return new ZoneContext { + resolvedZoneId = ov.zoneId, + isSafe = ov.isSafeZone, + encounterTableId = ov.overrideEncounterTableId, + finalEncounterChance = ov.overrideEncounterChance, + finalDifficultyTier = ov.overrideDifficultyTier + }; + } + + // ── 2. Find highest-priority Base zone ─────────────────────── + var baseZone = overlapping + .Where(z => z.role == ZoneRole.Base) + .OrderByDescending(z => z.priority) + .FirstOrDefault(); + + if(!baseZone) { + return SafeFallback(string.Empty); + } + + // ── 3. Collect all Modifier zones ──────────────────────────── + var modifiers = overlapping + .Where(z => z.role == ZoneRole.Modifier) + .ToList(); + + var chance = baseZone.baseEncounterChance; + var tierOffset = 0; + + foreach(var mod in modifiers) { + // Multiplicative stacking — each modifier is independent + chance *= mod.encounterChanceMultiplier; + tierOffset += mod.difficultyTierBonus; + } + + chance = Mathf.Clamp01(chance); + var rawTier = (int)baseZone.baseDifficultyTier + tierOffset; + var clampedTier = Mathf.Clamp(rawTier, (int)DifficultyTier.Safe, (int)DifficultyTier.Deadly); + + return new ZoneContext { + resolvedZoneId = baseZone.zoneId, + isSafe = false, + encounterTableId = baseZone.encounterTableId, + finalEncounterChance = chance, + finalDifficultyTier = (DifficultyTier)clampedTier + }; + } + + private static ZoneContext SafeFallback(string name) { + return new ZoneContext { + resolvedZoneId = name, + isSafe = true, + encounterTableId = string.Empty, + finalEncounterChance = 0f, + finalDifficultyTier = DifficultyTier.Safe + }; + } + } +} diff --git a/Runtime/ZoneResolver.cs.meta b/Runtime/ZoneResolver.cs.meta new file mode 100644 index 0000000..cbd63db --- /dev/null +++ b/Runtime/ZoneResolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 147813fb56be464458dc1c5be47057f4 \ No newline at end of file diff --git a/Runtime/ZoneSystem.cs b/Runtime/ZoneSystem.cs new file mode 100644 index 0000000..cce6f3e --- /dev/null +++ b/Runtime/ZoneSystem.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace Jovian.ZoneSystem { + public class ZoneSystem { + private readonly ZonesObjectHolder zonesObjectHolder; + public ZoneSystem(ZonesObjectHolder zonesObjectHolder) { + this.zonesObjectHolder = zonesObjectHolder; + Refresh(); + } + + /// + /// Returns the resolved ZoneContext at the given world position. + /// This is the only call your encounter/travel system needs to make. + /// + public ZoneContext QueryZone(Vector3 worldPos) { + var overlapping = GetOverlappingZones(worldPos); + return ZoneResolver.Resolve(overlapping); + } + + /// + /// Returns all ZoneData assets whose polygons contain worldPos. + /// Ordered by descending priority — useful if you need the raw list + /// before resolution (e.g. for debug UI). + /// + public List GetOverlappingZones(Vector3 worldPos) { + var result = new List(); + + foreach(var zone in zonesObjectHolder.Zones) { + if(zone == null || zone.data == null) { + continue; + } + if(zone.Contains(worldPos, zonesObjectHolder.mapPlane)) { + result.Add(zone.data); + } + } + + result.Sort((a, b) => b.priority.CompareTo(a.priority)); + return result; + } + + /// + /// Returns true if worldPos is inside any zone that is an Override+Safe zone + /// (i.e. a town or safe area). Cheap shortcut before rolling encounters. + /// + public bool IsInSafeZone(Vector3 worldPos) { + foreach(var zone in zonesObjectHolder.Zones) { + if(zone == null || zone.data == null) { + continue; + } + if(zone.data.role == ZoneRole.Override && + zone.data.isSafeZone && + zone.Contains(worldPos, zonesObjectHolder.mapPlane)) { + return true; + } + } + return false; + } + + /// + /// Registers a single ZoneInstance dynamically (e.g. spawned at runtime). + /// + internal void Register(ZoneInstance zone) { + if(!zonesObjectHolder.Zones.Contains(zone)) { + zone.RebuildBoundsCache(); + zonesObjectHolder.Zones.Add(zone); + } + } + + /// + /// Unregisters a ZoneInstance (e.g. before it is destroyed at runtime). + /// + public void Unregister(ZoneInstance zone) { + zonesObjectHolder.Zones.Remove(zone); + } + + /// + /// Re-scans the scene for all ZoneInstances and rebuilds their bounds caches. + /// Call this if you add or remove zones at runtime. + /// + private void Refresh() { + zonesObjectHolder.Zones.Clear(); + ZoneInstance[] found = Object.FindObjectsByType(FindObjectsSortMode.None); + foreach(ZoneInstance z in found) { + z.RebuildBoundsCache(); + zonesObjectHolder.Zones.Add(z); + } + } + } +} diff --git a/Runtime/ZoneSystem.cs.meta b/Runtime/ZoneSystem.cs.meta new file mode 100644 index 0000000..42fd8ab --- /dev/null +++ b/Runtime/ZoneSystem.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 366abba6f6364bbfb3d0564358ead42c +timeCreated: 1772985323 \ No newline at end of file diff --git a/Runtime/ZoneTypes.cs b/Runtime/ZoneTypes.cs new file mode 100644 index 0000000..7c3830f --- /dev/null +++ b/Runtime/ZoneTypes.cs @@ -0,0 +1,33 @@ +namespace Jovian.ZoneSystem { + public enum ZoneRole { + Base, // Provides the encounter table and baseline difficulty + Modifier, // Mutates difficulty/chance on top of a base zone + Override // Completely replaces everything (safe towns, story events) + } + + public enum ZoneShape { + Square, + Circle, + Polygon + } + + public enum DifficultyTier { + Safe = 0, + Mild = 1, + Moderate = 2, + Dangerous = 3, + Deadly = 4 + } + + /// + /// The resolved result of overlapping zones at a world position. + /// This is what the encounter system consumes — it never needs to know about raw zones. + /// + public struct ZoneContext { + public string encounterTableId; + public float finalEncounterChance; // 0..1 + public DifficultyTier finalDifficultyTier; + public bool isSafe; + public string resolvedZoneId; + } +} diff --git a/Runtime/ZoneTypes.cs.meta b/Runtime/ZoneTypes.cs.meta new file mode 100644 index 0000000..228ca29 --- /dev/null +++ b/Runtime/ZoneTypes.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ee375ef89c9f9574594736f1984be25f \ No newline at end of file diff --git a/Runtime/ZonesObjectHolder.cs b/Runtime/ZonesObjectHolder.cs new file mode 100644 index 0000000..30ea78f --- /dev/null +++ b/Runtime/ZonesObjectHolder.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace Jovian.ZoneSystem { + public class ZonesObjectHolder: MonoBehaviour { + internal List Zones { get; } = new(); + public MapPlane mapPlane; + + public IReadOnlyList AllZones => Zones; + + private void Awake() { + Refresh(); + } + +#if UNITY_EDITOR + private void OnValidate() { + Refresh(); + } +#endif + + /// + /// Re-scans the scene for all ZoneInstances and rebuilds their bounds caches. + /// Call this if you add or remove zones at runtime. + /// + private void Refresh() { + Zones.Clear(); + var found = FindObjectsByType(FindObjectsSortMode.None); + foreach(var z in found) { + z.RebuildBoundsCache(); + Zones.Add(z); + } + } + } + +} diff --git a/Runtime/ZonesObjectHolder.cs.meta b/Runtime/ZonesObjectHolder.cs.meta new file mode 100644 index 0000000..882ee8b --- /dev/null +++ b/Runtime/ZonesObjectHolder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 514ac2296ff6032459b84681867b26cd \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..fc58538 --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "com.jovian.zonesystem", + "version": "0.1.0", + "displayName": "Jovian Zone System", + "description": "A polygon-based zone system for defining map regions with encounter difficulty, modifiers, and safe areas.", + "unity": "2022.3", + "keywords": [ + "zone", + "map", + "encounter" + ], + "author": { + "name": "Jovian" + } +} diff --git a/package.json.meta b/package.json.meta new file mode 100644 index 0000000..f0e9813 --- /dev/null +++ b/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f811a6af0d3ada34198308fce87fa482 +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: