copy from github

This commit is contained in:
Sebastian Bularca
2026-03-27 15:13:27 +01:00
parent 83532f396d
commit 823e146df0
44 changed files with 2999 additions and 2 deletions

24
.gitignore vendored Normal file
View File

@@ -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

538
Documentation~/index.html Normal file
View File

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

8
Editor.meta Normal file
View File

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

View File

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

View File

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

131
Editor/ZoneDataEditor.cs Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

643
Editor/ZoneEditorWindow.cs Normal file
View File

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

View File

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

View File

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

View File

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

21
LICENSE Normal file
View File

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

7
LICENSE.meta Normal file
View File

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

View File

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

7
README.md.meta Normal file
View File

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

8
Runtime.meta Normal file
View File

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

View File

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

View File

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

43
Runtime/MapPlane.cs Normal file
View File

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

2
Runtime/MapPlane.cs.meta Normal file
View File

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

200
Runtime/PolygonUtils.cs Normal file
View File

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

View File

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

49
Runtime/ShapeFactory.cs Normal file
View File

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

View File

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

62
Runtime/ZoneData.cs Normal file
View File

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

2
Runtime/ZoneData.cs.meta Normal file
View File

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

91
Runtime/ZoneExporter.cs Normal file
View File

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

View File

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

84
Runtime/ZoneInstance.cs Normal file
View File

@@ -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<ZonesObjectHolder>();
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<Vector2> 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();
}
/// <summary>
/// Rebuilds the AABB cache from the current polygon data.
/// Called automatically on Awake/Validate; also call this in the
/// editor after modifying polygon points.
/// </summary>
public void RebuildBoundsCache() {
if(data == null || data.polygon == null || data.polygon.Count < 3) {
_boundsValid = false;
return;
}
(_boundsMin, _boundsMax) = PolygonUtils.Bounds(data.polygon);
_boundsValid = true;
}
/// <summary>
/// Returns true if the given world position is inside this zone's polygon.
/// Plane controls which two axes are used for the 2D projection.
/// </summary>
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);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 95af4f7ff0649854598833eabd84f131

83
Runtime/ZoneResolver.cs Normal file
View File

@@ -0,0 +1,83 @@
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace Jovian.ZoneSystem {
public static class ZoneResolver {
/// <summary>
/// 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.
/// </summary>
public static ZoneContext Resolve(List<ZoneData> 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
};
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 147813fb56be464458dc1c5be47057f4

90
Runtime/ZoneSystem.cs Normal file
View File

@@ -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();
}
/// <summary>
/// Returns the resolved ZoneContext at the given world position.
/// This is the only call your encounter/travel system needs to make.
/// </summary>
public ZoneContext QueryZone(Vector3 worldPos) {
var overlapping = GetOverlappingZones(worldPos);
return ZoneResolver.Resolve(overlapping);
}
/// <summary>
/// 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).
/// </summary>
public List<ZoneData> GetOverlappingZones(Vector3 worldPos) {
var result = new List<ZoneData>();
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Registers a single ZoneInstance dynamically (e.g. spawned at runtime).
/// </summary>
internal void Register(ZoneInstance zone) {
if(!zonesObjectHolder.Zones.Contains(zone)) {
zone.RebuildBoundsCache();
zonesObjectHolder.Zones.Add(zone);
}
}
/// <summary>
/// Unregisters a ZoneInstance (e.g. before it is destroyed at runtime).
/// </summary>
public void Unregister(ZoneInstance zone) {
zonesObjectHolder.Zones.Remove(zone);
}
/// <summary>
/// Re-scans the scene for all ZoneInstances and rebuilds their bounds caches.
/// Call this if you add or remove zones at runtime.
/// </summary>
private void Refresh() {
zonesObjectHolder.Zones.Clear();
ZoneInstance[] found = Object.FindObjectsByType<ZoneInstance>(FindObjectsSortMode.None);
foreach(ZoneInstance z in found) {
z.RebuildBoundsCache();
zonesObjectHolder.Zones.Add(z);
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 366abba6f6364bbfb3d0564358ead42c
timeCreated: 1772985323

33
Runtime/ZoneTypes.cs Normal file
View File

@@ -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
}
/// <summary>
/// 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.
/// </summary>
public struct ZoneContext {
public string encounterTableId;
public float finalEncounterChance; // 0..1
public DifficultyTier finalDifficultyTier;
public bool isSafe;
public string resolvedZoneId;
}
}

View File

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

View File

@@ -0,0 +1,35 @@
using System.Collections.Generic;
using UnityEngine;
namespace Jovian.ZoneSystem {
public class ZonesObjectHolder: MonoBehaviour {
internal List<ZoneInstance> Zones { get; } = new();
public MapPlane mapPlane;
public IReadOnlyList<ZoneInstance> AllZones => Zones;
private void Awake() {
Refresh();
}
#if UNITY_EDITOR
private void OnValidate() {
Refresh();
}
#endif
/// <summary>
/// Re-scans the scene for all ZoneInstances and rebuilds their bounds caches.
/// Call this if you add or remove zones at runtime.
/// </summary>
private void Refresh() {
Zones.Clear();
var found = FindObjectsByType<ZoneInstance>(FindObjectsSortMode.None);
foreach(var z in found) {
z.RebuildBoundsCache();
Zones.Add(z);
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 514ac2296ff6032459b84681867b26cd

15
package.json Normal file
View File

@@ -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"
}
}

7
package.json.meta Normal file
View File

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