copy from github
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
538
Documentation~/index.html
Normal file
@@ -0,0 +1,538 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Jovian Zone System - Documentation</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #1e1e2e;
|
||||
--bg2: #252538;
|
||||
--bg3: #2e2e44;
|
||||
--fg: #cdd6f4;
|
||||
--fg2: #a6adc8;
|
||||
--accent: #89b4fa;
|
||||
--green: #a6e3a1;
|
||||
--yellow: #f9e2af;
|
||||
--red: #f38ba8;
|
||||
--orange: #fab387;
|
||||
--border: #45475a;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.container { max-width: 960px; margin: 0 auto; padding: 40px 24px; }
|
||||
h1 { font-size: 2.2em; color: var(--accent); margin-bottom: 8px; }
|
||||
h1 small { font-size: 0.4em; color: var(--fg2); font-weight: normal; }
|
||||
h2 {
|
||||
font-size: 1.5em; color: var(--accent); margin: 48px 0 16px;
|
||||
padding-bottom: 8px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
h3 { font-size: 1.15em; color: var(--yellow); margin: 28px 0 12px; }
|
||||
h4 { font-size: 1em; color: var(--orange); margin: 20px 0 8px; }
|
||||
p { margin: 0 0 14px; color: var(--fg); }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code {
|
||||
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
|
||||
background: var(--bg3); padding: 2px 6px; border-radius: 4px;
|
||||
font-size: 0.9em; color: var(--green);
|
||||
}
|
||||
pre {
|
||||
background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 16px 20px; overflow-x: auto;
|
||||
margin: 12px 0 20px; line-height: 1.5;
|
||||
}
|
||||
pre code { background: none; padding: 0; color: var(--fg); }
|
||||
table {
|
||||
width: 100%; border-collapse: collapse; margin: 12px 0 20px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
th {
|
||||
background: var(--bg3); text-align: left; padding: 10px 14px;
|
||||
color: var(--accent); border: 1px solid var(--border);
|
||||
}
|
||||
td { padding: 10px 14px; border: 1px solid var(--border); }
|
||||
tr:nth-child(even) td { background: var(--bg2); }
|
||||
.badge {
|
||||
display: inline-block; padding: 3px 10px; border-radius: 12px;
|
||||
font-size: 0.8em; font-weight: 600; margin-right: 6px;
|
||||
}
|
||||
.badge-base { background: #89b4fa33; color: var(--accent); }
|
||||
.badge-modifier { background: #f9e2af33; color: var(--yellow); }
|
||||
.badge-override { background: #a6e3a133; color: var(--green); }
|
||||
.badge-editor { background: #fab38733; color: var(--orange); }
|
||||
.note {
|
||||
background: var(--bg3); border-left: 4px solid var(--accent);
|
||||
padding: 12px 16px; border-radius: 0 8px 8px 0; margin: 16px 0;
|
||||
}
|
||||
.warning {
|
||||
background: #f9e2af11; border-left: 4px solid var(--yellow);
|
||||
padding: 12px 16px; border-radius: 0 8px 8px 0; margin: 16px 0;
|
||||
}
|
||||
.toc {
|
||||
background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 20px 28px; margin: 24px 0;
|
||||
}
|
||||
.toc h3 { margin-top: 0; color: var(--fg); }
|
||||
.toc ul { list-style: none; padding-left: 0; }
|
||||
.toc li { padding: 4px 0; }
|
||||
.toc li::before { content: "# "; color: var(--border); }
|
||||
ul, ol { margin: 0 0 14px 24px; }
|
||||
li { margin-bottom: 4px; }
|
||||
.key {
|
||||
display: inline-block; background: var(--bg3); border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 2px 8px; font-size: 0.85em;
|
||||
font-family: monospace; color: var(--fg);
|
||||
}
|
||||
hr { border: none; border-top: 1px solid var(--border); margin: 32px 0; }
|
||||
.section { margin-bottom: 32px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<h1>Jovian Zone System <small>v0.1.0</small></h1>
|
||||
<p>A polygon-based zone system for defining map regions with encounter difficulty, modifiers, and safe areas.</p>
|
||||
|
||||
<div class="toc">
|
||||
<h3>Table of Contents</h3>
|
||||
<ul>
|
||||
<li><a href="#overview">Overview</a></li>
|
||||
<li><a href="#setup">Setup & Quick Start</a></li>
|
||||
<li><a href="#editor-window">Zone Editor Window</a></li>
|
||||
<li><a href="#shape-editing">Shape Editing</a></li>
|
||||
<li><a href="#zone-roles">Zone Roles & Resolution</a></li>
|
||||
<li><a href="#settings">Editor Settings</a></li>
|
||||
<li><a href="#runtime-api">Runtime API</a></li>
|
||||
<li><a href="#types">Type Reference</a></li>
|
||||
<li><a href="#utilities">Utility Classes</a></li>
|
||||
<li><a href="#export">JSON Export</a></li>
|
||||
<li><a href="#shortcuts">Keyboard Shortcuts</a></li>
|
||||
<li><a href="#menu">Menu Reference</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="overview">Overview</h2>
|
||||
<div class="section">
|
||||
<p>The Zone System lets you paint polygon regions on your map and assign encounter rules to each region. At runtime, you query a world position and get back a fully resolved <code>ZoneContext</code> with the encounter table, difficulty tier, and chance.</p>
|
||||
|
||||
<h3>Key Features</h3>
|
||||
<ul>
|
||||
<li><strong>Three zone roles</strong>: Base, Modifier, and Override for layered encounter design</li>
|
||||
<li><strong>Visual polygon editing</strong>: Drag vertices, insert on edges, delete vertices in the Scene view</li>
|
||||
<li><strong>Concave polygon support</strong>: Ear-clipping triangulation renders any shape correctly</li>
|
||||
<li><strong>Multi-plane support</strong>: XY, XZ, or YZ — one setting controls everything</li>
|
||||
<li><strong>No physics dependency</strong>: Pure math ray-casting with AABB pre-rejection</li>
|
||||
<li><strong>Save workflow</strong>: Create → Edit → Save with duplicate ID/name validation</li>
|
||||
<li><strong>Role-based colors</strong>: Configured in settings, auto-applied on role change</li>
|
||||
<li><strong>Zone duplication</strong>: Independent copies with unique IDs and assets</li>
|
||||
<li><strong>JSON export</strong>: For runtime loading or external tools</li>
|
||||
<li><strong>UPM package</strong>: Standard Unity Package Manager layout</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="setup">Setup & Quick Start</h2>
|
||||
<div class="section">
|
||||
<h3>1. Scene Setup</h3>
|
||||
<ol>
|
||||
<li>Create a GameObject and add the <code>ZonesObjectHolder</code> component.</li>
|
||||
<li>Set the <strong>Map Plane</strong> field to match your map orientation.</li>
|
||||
</ol>
|
||||
|
||||
<table>
|
||||
<tr><th>Map Plane</th><th>Use Case</th><th>Axes</th><th>Ignored</th></tr>
|
||||
<tr><td><code>XY</code></td><td>Flat sprite map, UI map</td><td>X, Y</td><td>Z</td></tr>
|
||||
<tr><td><code>XZ</code></td><td>3D world map (standard Unity 3D)</td><td>X, Z</td><td>Y</td></tr>
|
||||
<tr><td><code>YZ</code></td><td>Side-on map</td><td>Y, Z</td><td>X</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>2. Create Your First Zone</h3>
|
||||
<ol>
|
||||
<li>Open <strong>Window → Zone System → Zone Editor</strong>.</li>
|
||||
<li>Click <strong>Create New Zone</strong>.</li>
|
||||
<li>Enter a name, select a shape (Square, Circle, or Polygon).</li>
|
||||
<li>Click <strong>Create & Edit</strong>.</li>
|
||||
<li>Edit zone data fields (role, priority, encounter settings).</li>
|
||||
<li>Click <strong>Save Zone</strong> to persist the asset.</li>
|
||||
<li>Use Scene view handles to adjust the polygon shape.</li>
|
||||
</ol>
|
||||
|
||||
<div class="note">
|
||||
<strong>Note:</strong> The zone asset is not saved to disk until you click the Save button. You can edit all fields freely before committing.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="editor-window">Zone Editor Window</h2>
|
||||
<div class="section">
|
||||
<p>The Zone Editor window (<strong>Window → Zone System → Zone Editor</strong>) is the primary tool for managing zones.</p>
|
||||
|
||||
<h3>Zone List View</h3>
|
||||
<p>Shows all <code>ZoneInstance</code> objects in the current scene. Each row displays:</p>
|
||||
<ul>
|
||||
<li><strong>Color swatch</strong>: Role-based color from editor settings</li>
|
||||
<li><strong>Zone name</strong> and summary (role, priority, tier, chance)</li>
|
||||
<li><strong>Select</strong> button: Opens the zone for editing</li>
|
||||
<li><strong>Duplicate</strong> button (<code>📋</code>): Creates an independent copy with a new asset</li>
|
||||
<li><strong>Delete</strong> button (<code>✕</code>): Removes the zone and its asset</li>
|
||||
</ul>
|
||||
<p>Zones with missing <code>ZoneData</code> show a warning icon with options to add data or delete the zone.</p>
|
||||
|
||||
<h3>Create Zone</h3>
|
||||
<p>Click <strong>Create New Zone</strong> to open the creation dropdown:</p>
|
||||
<ul>
|
||||
<li>Enter a <strong>Zone Name</strong></li>
|
||||
<li>Select a <strong>Shape</strong> (Square, Circle, Polygon)</li>
|
||||
<li>Click <strong>Create & Edit</strong></li>
|
||||
</ul>
|
||||
<p>The zone is created in-memory and enters edit mode immediately. You must click <strong>Save</strong> to persist the asset.</p>
|
||||
|
||||
<h3>Edit Mode</h3>
|
||||
<p>When editing a zone, all <code>ZoneData</code> fields are available inline:</p>
|
||||
<ul>
|
||||
<li><strong>Identity</strong>: Zone ID, Zone Name, Role, Priority, Shape</li>
|
||||
<li><strong>Role-specific fields</strong>: Shown/hidden based on the selected role</li>
|
||||
<li><strong>Save Zone</strong> button: Validates and persists changes</li>
|
||||
<li><strong>Delete Zone</strong> button: Removes the zone entirely</li>
|
||||
</ul>
|
||||
|
||||
<h4>Save Validation</h4>
|
||||
<p>On save, the editor checks for:</p>
|
||||
<ul>
|
||||
<li><strong>Duplicate Zone ID</strong>: No two assets may share the same <code>zoneId</code></li>
|
||||
<li><strong>Duplicate asset name</strong>: No two assets may have the same file name</li>
|
||||
</ul>
|
||||
<p>If a conflict is found, an error is displayed and saving is blocked.</p>
|
||||
|
||||
<h4>Auto-applied Changes on Save</h4>
|
||||
<ul>
|
||||
<li>The <strong>GameObject name</strong> is updated to match the zone name</li>
|
||||
<li>The <strong>asset file</strong> is renamed to match the zone name</li>
|
||||
<li>If the <strong>shape type</strong> was changed, the polygon resets to the new default shape</li>
|
||||
</ul>
|
||||
|
||||
<h3>Export Section</h3>
|
||||
<p>At the bottom of the editor window, expand <strong>Export Zones to JSON</strong> to export all scene zones to a JSON file for runtime loading.</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="shape-editing">Shape Editing</h2>
|
||||
<div class="section">
|
||||
<p>When a zone is in edit mode, yellow handles appear in the Scene view for each polygon vertex.</p>
|
||||
|
||||
<h3>Controls</h3>
|
||||
<table>
|
||||
<tr><th>Action</th><th>Input</th><th>Description</th></tr>
|
||||
<tr><td>Move vertex</td><td>Drag handle</td><td>Drag any yellow handle to reposition a vertex</td></tr>
|
||||
<tr><td>Insert vertex</td><td><span class="key">Ctrl</span> + Click edge</td><td>Adds a new vertex on the closest edge (cyan highlight shows target)</td></tr>
|
||||
<tr><td>Delete vertex</td><td><span class="key">Shift</span> + Click vertex</td><td>Removes the vertex (minimum 3 vertices). Handles turn red while Shift is held.</td></tr>
|
||||
<tr><td>Stop editing</td><td><span class="key">Esc</span></td><td>Exits shape edit mode</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Shapes</h3>
|
||||
<table>
|
||||
<tr><th>Shape</th><th>Default</th><th>Notes</th></tr>
|
||||
<tr><td><code>Square</code></td><td>4 vertices, 2-unit half-size</td><td>Can be reshaped into any quad</td></tr>
|
||||
<tr><td><code>Circle</code></td><td>24-segment approximation, radius 2</td><td>Drag the radius handle to resize. Regenerates vertices on radius change.</td></tr>
|
||||
<tr><td><code>Polygon</code></td><td>12 vertices, radius 3</td><td>Fully freeform — add, remove, and drag vertices freely</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Additional Tools (Inspector)</h3>
|
||||
<ul>
|
||||
<li><strong>Center Transform</strong>: Moves the GameObject’s transform to the polygon’s centroid without changing the zone’s world position</li>
|
||||
<li><strong>Reset Shape</strong>: Resets the polygon to the default for the current shape type</li>
|
||||
<li><strong>Duplicate Zone</strong>: Creates an independent copy with its own <code>ZoneData</code> asset</li>
|
||||
</ul>
|
||||
|
||||
<div class="note">
|
||||
<strong>Scene interaction:</strong> While in shape edit mode, clicking in the Scene view will not select other objects. The default transform handle is hidden to prevent accidental movement.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="zone-roles">Zone Roles & Resolution</h2>
|
||||
<div class="section">
|
||||
<h3>Roles</h3>
|
||||
<table>
|
||||
<tr><th>Role</th><th>Purpose</th><th>Fields</th></tr>
|
||||
<tr>
|
||||
<td><span class="badge badge-base">Base</span></td>
|
||||
<td>Defines the encounter table and baseline difficulty</td>
|
||||
<td>Encounter Table ID, Difficulty Tier, Encounter Chance</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="badge badge-modifier">Modifier</span></td>
|
||||
<td>Stacks multiplicatively on top of a Base zone</td>
|
||||
<td>Chance Multiplier, Difficulty Tier Bonus</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="badge badge-override">Override</span></td>
|
||||
<td>Replaces everything — towns, story events, safe areas</td>
|
||||
<td>Is Safe Zone, Encounter Table ID, Encounter Chance, Difficulty Tier</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3>Resolution Order</h3>
|
||||
<p>When querying a world position, the <code>ZoneResolver</code> follows this order:</p>
|
||||
<ol>
|
||||
<li>If any <strong>Override</strong> zone is present → use the highest-priority Override exclusively</li>
|
||||
<li>Find the highest-priority <strong>Base</strong> zone → encounter table + baseline difficulty</li>
|
||||
<li>Stack all <strong>Modifier</strong> zones multiplicatively on top</li>
|
||||
<li>Clamp and return a <code>ZoneContext</code></li>
|
||||
</ol>
|
||||
|
||||
<h3>Modifier Stacking</h3>
|
||||
<p>Modifiers are multiplicative, so each one is independent:</p>
|
||||
<pre><code>Base chance 0.30 x Cursed Road 1.8 x Night Modifier 1.2 = 0.648</code></pre>
|
||||
|
||||
<h3>Priority</h3>
|
||||
<p>Higher priority values take precedence. When multiple zones of the same role overlap, the highest-priority one wins (for Base and Override) or all are stacked (for Modifier).</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="settings">Editor Settings</h2>
|
||||
<div class="section">
|
||||
<p>Access via <strong>Window → Zone System → Settings</strong>. If no settings asset exists, one is created automatically.</p>
|
||||
|
||||
<h3>Fields</h3>
|
||||
<table>
|
||||
<tr><th>Field</th><th>Description</th><th>Default</th></tr>
|
||||
<tr><td><code>mapPlane</code></td><td>Which two world axes your map lies on</td><td><code>XZ</code></td></tr>
|
||||
<tr><td><code>zoneDataFolder</code></td><td>Folder path where new ZoneData assets are saved</td><td><code>Assets/ZoneData</code></td></tr>
|
||||
<tr><td><code>roleColors</code></td><td>Debug color for each zone role (used in scene rendering)</td><td>Blue (Base), Yellow (Modifier), Green (Override)</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Role Colors</h3>
|
||||
<p>Each <code>ZoneRole</code> has a configurable color in the settings. When you change a zone’s role in the editor, its debug color is automatically updated to match. Colors are used for:</p>
|
||||
<ul>
|
||||
<li>Scene view polygon fill and border</li>
|
||||
<li>Zone list row background tinting</li>
|
||||
<li>Inspector summary background</li>
|
||||
<li>Scene gizmos</li>
|
||||
</ul>
|
||||
|
||||
<div class="note">
|
||||
<strong>Dynamic:</strong> If you add new values to the <code>ZoneRole</code> enum, call <code>SyncRoleEntries()</code> on the settings asset or click the settings menu item — missing roles will be added with a default gray color.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="runtime-api">Runtime API</h2>
|
||||
<div class="section">
|
||||
<h3>ZoneSystemApi</h3>
|
||||
<p>The main entry point for runtime zone queries.</p>
|
||||
|
||||
<pre><code>// Create the API with a reference to the ZonesObjectHolder
|
||||
ZoneSystemApi api = new ZoneSystemApi(zonesObjectHolder);
|
||||
|
||||
// Full zone resolution at a world position
|
||||
ZoneContext ctx = api.QueryZone(partyWorldPosition);
|
||||
|
||||
if(!ctx.isSafe && Random.value < ctx.finalEncounterChance)
|
||||
TriggerEncounter(ctx.encounterTableId, ctx.finalDifficultyTier);</code></pre>
|
||||
|
||||
<h3>Methods</h3>
|
||||
<table>
|
||||
<tr><th>Method</th><th>Returns</th><th>Description</th></tr>
|
||||
<tr><td><code>QueryZone(Vector3)</code></td><td><code>ZoneContext</code></td><td>Full resolution: finds overlapping zones, applies modifiers, returns final context</td></tr>
|
||||
<tr><td><code>GetOverlappingZones(Vector3)</code></td><td><code>List<ZoneData></code></td><td>Raw list of all zones containing the position, sorted by descending priority</td></tr>
|
||||
<tr><td><code>IsInSafeZone(Vector3)</code></td><td><code>bool</code></td><td>Quick check — true if any Override zone with <code>isSafeZone</code> contains the position</td></tr>
|
||||
<tr><td><code>Register(ZoneInstance)</code></td><td><code>void</code></td><td>Register a dynamically spawned zone</td></tr>
|
||||
<tr><td><code>Unregister(ZoneInstance)</code></td><td><code>void</code></td><td>Unregister a zone before destroying it</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>ZoneContext Struct</h3>
|
||||
<table>
|
||||
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
|
||||
<tr><td><code>encounterTableId</code></td><td><code>string</code></td><td>ID of the encounter table to use</td></tr>
|
||||
<tr><td><code>finalEncounterChance</code></td><td><code>float</code></td><td>Final encounter probability (0–1), after modifier stacking</td></tr>
|
||||
<tr><td><code>finalDifficultyTier</code></td><td><code>DifficultyTier</code></td><td>Final difficulty tier, after modifier bonuses</td></tr>
|
||||
<tr><td><code>isSafe</code></td><td><code>bool</code></td><td>True if in a safe zone (no encounters)</td></tr>
|
||||
<tr><td><code>resolvedZoneName</code></td><td><code>string</code></td><td>Name of the zone that “won” resolution (for debug/UI)</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Dynamic Zones</h3>
|
||||
<pre><code>// After instantiating a zone at runtime:
|
||||
api.Register(zoneInstance);
|
||||
|
||||
// Before destroying:
|
||||
api.Unregister(zoneInstance);</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="types">Type Reference</h2>
|
||||
<div class="section">
|
||||
<h3>Enums</h3>
|
||||
|
||||
<h4>ZoneRole</h4>
|
||||
<table>
|
||||
<tr><th>Value</th><th>Description</th></tr>
|
||||
<tr><td><code>Base</code></td><td>Provides the encounter table and baseline difficulty</td></tr>
|
||||
<tr><td><code>Modifier</code></td><td>Mutates difficulty/chance on top of a Base zone</td></tr>
|
||||
<tr><td><code>Override</code></td><td>Completely replaces everything (safe towns, story events)</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>ZoneShape</h4>
|
||||
<table>
|
||||
<tr><th>Value</th><th>Description</th></tr>
|
||||
<tr><td><code>Square</code></td><td>4-vertex quadrilateral</td></tr>
|
||||
<tr><td><code>Circle</code></td><td>24-segment circular approximation with adjustable radius</td></tr>
|
||||
<tr><td><code>Polygon</code></td><td>Freeform polygon with 12 default vertices</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>DifficultyTier</h4>
|
||||
<table>
|
||||
<tr><th>Value</th><th>Int</th></tr>
|
||||
<tr><td><code>Safe</code></td><td>0</td></tr>
|
||||
<tr><td><code>Mild</code></td><td>1</td></tr>
|
||||
<tr><td><code>Moderate</code></td><td>2</td></tr>
|
||||
<tr><td><code>Dangerous</code></td><td>3</td></tr>
|
||||
<tr><td><code>Deadly</code></td><td>4</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>MapPlane</h4>
|
||||
<table>
|
||||
<tr><th>Value</th><th>Axes</th><th>Depth</th></tr>
|
||||
<tr><td><code>XY</code></td><td>X, Y</td><td>Z</td></tr>
|
||||
<tr><td><code>XZ</code></td><td>X, Z</td><td>Y</td></tr>
|
||||
<tr><td><code>YZ</code></td><td>Y, Z</td><td>X</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>ScriptableObjects</h3>
|
||||
|
||||
<h4>ZoneData</h4>
|
||||
<p>Per-zone configuration asset. Created via the Zone Editor or <code>Create → ZoneSystem → Zone Data</code>.</p>
|
||||
<table>
|
||||
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
|
||||
<tr><td><code>zoneId</code></td><td><code>string</code></td><td>Unique identifier</td></tr>
|
||||
<tr><td><code>zoneName</code></td><td><code>string</code></td><td>Display name</td></tr>
|
||||
<tr><td><code>role</code></td><td><code>ZoneRole</code></td><td>Base, Modifier, or Override</td></tr>
|
||||
<tr><td><code>priority</code></td><td><code>int</code></td><td>Higher wins in same-role conflicts</td></tr>
|
||||
<tr><td><code>debugColor</code></td><td><code>Color</code></td><td>Scene visualization color (auto-set from role)</td></tr>
|
||||
<tr><td><code>shape</code></td><td><code>ZoneShape</code></td><td>Shape type</td></tr>
|
||||
<tr><td><code>circleRadius</code></td><td><code>float</code></td><td>Radius (Circle shape only)</td></tr>
|
||||
<tr><td><code>polygon</code></td><td><code>List<Vector2></code></td><td>Vertex positions (local to transform)</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>MonoBehaviours</h3>
|
||||
|
||||
<h4>ZoneInstance</h4>
|
||||
<p>Placed on a scene GameObject. References a <code>ZoneData</code> asset and provides spatial queries.</p>
|
||||
<table>
|
||||
<tr><th>Field / Method</th><th>Description</th></tr>
|
||||
<tr><td><code>data</code></td><td>Reference to the <code>ZoneData</code> asset</td></tr>
|
||||
<tr><td><code>Contains(Vector3, MapPlane)</code></td><td>Returns true if the world position is inside this zone</td></tr>
|
||||
<tr><td><code>RebuildBoundsCache()</code></td><td>Recalculates the AABB cache (call after modifying polygon)</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>ZonesObjectHolder</h4>
|
||||
<p>Scene manager that holds the map plane setting and provides access to all zones.</p>
|
||||
<table>
|
||||
<tr><th>Field / Property</th><th>Description</th></tr>
|
||||
<tr><td><code>mapPlane</code></td><td>Which plane the map lies on (XY, XZ, or YZ)</td></tr>
|
||||
<tr><td><code>AllZones</code></td><td>Read-only list of all <code>ZoneInstance</code> objects in the scene</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="utilities">Utility Classes</h2>
|
||||
<div class="section">
|
||||
<h3>PolygonUtils</h3>
|
||||
<p>Static math utilities for polygon operations.</p>
|
||||
<table>
|
||||
<tr><th>Method</th><th>Description</th></tr>
|
||||
<tr><td><code>PointInPolygon(Vector2, List<Vector2>)</code></td><td>Ray-casting point-in-polygon test (Jordan curve theorem)</td></tr>
|
||||
<tr><td><code>PointInPolygon(Vector3, List<Vector2>, MapPlane)</code></td><td>Projects world position to plane, then tests</td></tr>
|
||||
<tr><td><code>Centroid(List<Vector2>)</code></td><td>Average center of polygon vertices</td></tr>
|
||||
<tr><td><code>Bounds(List<Vector2>)</code></td><td>Axis-aligned bounding box (min, max)</td></tr>
|
||||
<tr><td><code>PointInBounds(Vector2, Vector2, Vector2)</code></td><td>Fast AABB pre-check</td></tr>
|
||||
<tr><td><code>Triangulate(List<Vector2>)</code></td><td>Ear-clipping triangulation for concave polygon rendering</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>MapPlaneUtility</h3>
|
||||
<p>Converts between 3D world positions and 2D plane coordinates.</p>
|
||||
<table>
|
||||
<tr><th>Method</th><th>Description</th></tr>
|
||||
<tr><td><code>ProjectToPlane(Vector3, MapPlane)</code></td><td>3D world → 2D plane coordinates</td></tr>
|
||||
<tr><td><code>UnprojectFromPlane(Vector2, MapPlane, float)</code></td><td>2D plane coordinates → 3D world</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>ShapeFactory</h3>
|
||||
<p>Generates default polygon vertices for each shape type.</p>
|
||||
<table>
|
||||
<tr><th>Method</th><th>Description</th></tr>
|
||||
<tr><td><code>CreateDefault(ZoneShape)</code></td><td>Returns default vertices for the given shape</td></tr>
|
||||
<tr><td><code>CreateSquare(float)</code></td><td>4-vertex square with given half-size</td></tr>
|
||||
<tr><td><code>CreateCircle(float, int)</code></td><td>N-segment circle approximation</td></tr>
|
||||
<tr><td><code>CreatePolygon(float, int)</code></td><td>Regular polygon with N vertices</td></tr>
|
||||
<tr><td><code>RegenerateCircle(ZoneData)</code></td><td>Rebuilds circle vertices from current radius</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>ZoneResolver</h3>
|
||||
<p>Pure logic for resolving overlapping zones into a single <code>ZoneContext</code>.</p>
|
||||
<table>
|
||||
<tr><th>Method</th><th>Description</th></tr>
|
||||
<tr><td><code>Resolve(List<ZoneData>)</code></td><td>Takes overlapping zone data, applies role priority and modifier stacking, returns <code>ZoneContext</code></td></tr>
|
||||
</table>
|
||||
|
||||
<h3>ZoneExporter</h3>
|
||||
<p>Serializes scene zones to a JSON structure for runtime loading.</p>
|
||||
<table>
|
||||
<tr><th>Method</th><th>Description</th></tr>
|
||||
<tr><td><code>BuildExport(ZoneInstance[], MapPlane)</code></td><td>Builds the export data structure from scene instances</td></tr>
|
||||
<tr><td><code>ToJson(ZoneExportRoot, bool)</code></td><td>Converts to JSON string (optionally pretty-printed)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="export">JSON Export</h2>
|
||||
<div class="section">
|
||||
<p>In the Zone Editor window, expand <strong>Export Zones to JSON</strong>, set the output path, and click <strong>Export Now</strong>.</p>
|
||||
|
||||
<h3>Loading at Runtime</h3>
|
||||
<pre><code>string json = File.ReadAllText(Application.streamingAssetsPath + "/zones.json");
|
||||
ZoneExportRoot root = JsonUtility.FromJson<ZoneExportRoot>(json);</code></pre>
|
||||
|
||||
<h3>Export Structure</h3>
|
||||
<p>Each zone is exported as a <code>ZoneExportEntry</code> containing all zone data fields plus the world-space polygon coordinates and transform position.</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="shortcuts">Keyboard Shortcuts</h2>
|
||||
<div class="section">
|
||||
<table>
|
||||
<tr><th>Key</th><th>Context</th><th>Action</th></tr>
|
||||
<tr><td><span class="key">Esc</span></td><td>Scene view, shape editing active</td><td>Stop editing the zone shape</td></tr>
|
||||
<tr><td><span class="key">Ctrl</span> + Click</td><td>Scene view, shape editing active</td><td>Insert a vertex on the nearest edge</td></tr>
|
||||
<tr><td><span class="key">Shift</span> + Click</td><td>Scene view, shape editing active</td><td>Delete the clicked vertex (min 3)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════ -->
|
||||
<h2 id="menu">Menu Reference</h2>
|
||||
<div class="section">
|
||||
<table>
|
||||
<tr><th>Menu Path</th><th>Description</th></tr>
|
||||
<tr><td>Window → Zone System → Zone Editor</td><td>Opens the main Zone Editor window</td></tr>
|
||||
<tr><td>Window → Zone System → Settings</td><td>Selects (or creates) the ZoneEditorSettings asset</td></tr>
|
||||
<tr><td>Window → Zone System → Documentation</td><td>Opens this documentation in your default browser</td></tr>
|
||||
<tr><td>Jovian → ZoneSystem → Zone Editor Settings</td><td>Create menu for new ZoneEditorSettings asset</td></tr>
|
||||
<tr><td>ZoneSystem → Zone Data</td><td>Create menu for new ZoneData asset</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<p style="text-align: center; color: var(--fg2); font-size: 0.9em;">
|
||||
Jovian Zone System v0.1.0 — com.jovian.zonesystem
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
8
Editor.meta
Normal file
8
Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0a961874148653f41a30b0562a2a5dc2
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
18
Editor/Jovian.ZoneSystem.Editor.asmdef
Normal file
18
Editor/Jovian.ZoneSystem.Editor.asmdef
Normal 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
|
||||
}
|
||||
7
Editor/Jovian.ZoneSystem.Editor.asmdef.meta
Normal file
7
Editor/Jovian.ZoneSystem.Editor.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 18e660fb45b16f646be8417e3f101d98
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
131
Editor/ZoneDataEditor.cs
Normal file
131
Editor/ZoneDataEditor.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.ZoneSystem.Editor {
|
||||
/// <summary>
|
||||
/// Custom inspector for ZoneData ScriptableObject.
|
||||
/// Shows only fields relevant to the selected ZoneRole.
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(ZoneData))]
|
||||
public class ZoneDataEditor : UnityEditor.Editor {
|
||||
|
||||
// Modifier
|
||||
private SerializedProperty _chanceMultiplier, _tierBonus;
|
||||
|
||||
// Base
|
||||
private SerializedProperty _encounterTableId, _baseDifficultyTier, _baseEncounterChance;
|
||||
|
||||
// Override
|
||||
private SerializedProperty _isSafeZone, _overrideTableId, _overrideChance, _overrideTier;
|
||||
private SerializedProperty _zoneId, _zoneName, _role, _priority, _debugColor;
|
||||
private SerializedProperty _shape, _circleRadius;
|
||||
|
||||
private void OnEnable() {
|
||||
_zoneId = serializedObject.FindProperty("zoneId");
|
||||
_zoneName = serializedObject.FindProperty("zoneName");
|
||||
_role = serializedObject.FindProperty("role");
|
||||
_priority = serializedObject.FindProperty("priority");
|
||||
_debugColor = serializedObject.FindProperty("debugColor");
|
||||
_shape = serializedObject.FindProperty("shape");
|
||||
_circleRadius = serializedObject.FindProperty("circleRadius");
|
||||
|
||||
_encounterTableId = serializedObject.FindProperty("encounterTableId");
|
||||
_baseDifficultyTier = serializedObject.FindProperty("baseDifficultyTier");
|
||||
_baseEncounterChance = serializedObject.FindProperty("baseEncounterChance");
|
||||
|
||||
_chanceMultiplier = serializedObject.FindProperty("encounterChanceMultiplier");
|
||||
_tierBonus = serializedObject.FindProperty("difficultyTierBonus");
|
||||
|
||||
_isSafeZone = serializedObject.FindProperty("isSafeZone");
|
||||
_overrideTableId = serializedObject.FindProperty("overrideEncounterTableId");
|
||||
_overrideChance = serializedObject.FindProperty("overrideEncounterChance");
|
||||
_overrideTier = serializedObject.FindProperty("overrideDifficultyTier");
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI() {
|
||||
serializedObject.Update();
|
||||
|
||||
// Identity
|
||||
EditorGUILayout.LabelField("Identity", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_zoneId, new GUIContent("Zone ID"));
|
||||
EditorGUILayout.PropertyField(_zoneName, new GUIContent("Zone Name"));
|
||||
// Track role changes to auto-apply color
|
||||
ZoneRole roleBefore = (ZoneRole)_role.enumValueIndex;
|
||||
EditorGUILayout.PropertyField(_role, new GUIContent("Role"));
|
||||
ZoneRole roleAfter = (ZoneRole)_role.enumValueIndex;
|
||||
if(roleBefore != roleAfter) {
|
||||
ZoneEditorSettings settings = ZoneEditorSettings.FindOrCreateSettings();
|
||||
_debugColor.colorValue = settings.GetColorForRole(roleAfter);
|
||||
}
|
||||
|
||||
EditorGUILayout.PropertyField(_priority, new GUIContent("Priority"));
|
||||
EditorGUILayout.PropertyField(_shape, new GUIContent("Shape"));
|
||||
|
||||
if((ZoneShape)_shape.enumValueIndex == ZoneShape.Circle) {
|
||||
EditorGUILayout.PropertyField(_circleRadius, new GUIContent("Circle Radius"));
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
|
||||
// Role-specific fields
|
||||
ZoneRole role = (ZoneRole)_role.enumValueIndex;
|
||||
switch(role) {
|
||||
case ZoneRole.Base:
|
||||
DrawBaseFields();
|
||||
break;
|
||||
case ZoneRole.Modifier:
|
||||
DrawModifierFields();
|
||||
break;
|
||||
case ZoneRole.Override:
|
||||
DrawOverrideFields();
|
||||
break;
|
||||
}
|
||||
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
private void DrawBaseFields() {
|
||||
EditorGUILayout.LabelField("Base Zone Settings", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"Base zones define the encounter table and baseline difficulty. " +
|
||||
"Only the highest-priority Base zone at a position is used.",
|
||||
MessageType.None);
|
||||
|
||||
EditorGUILayout.PropertyField(_encounterTableId, new GUIContent("Encounter Table ID"));
|
||||
EditorGUILayout.PropertyField(_baseDifficultyTier, new GUIContent("Difficulty Tier"));
|
||||
EditorGUILayout.PropertyField(_baseEncounterChance, new GUIContent("Encounter Chance"));
|
||||
}
|
||||
|
||||
private void DrawModifierFields() {
|
||||
EditorGUILayout.LabelField("Modifier Zone Settings", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"Modifier zones adjust an overlapping Base zone's values multiplicatively. " +
|
||||
"All Modifier zones at a position are stacked.",
|
||||
MessageType.None);
|
||||
|
||||
EditorGUILayout.PropertyField(_chanceMultiplier, new GUIContent("Chance Multiplier"));
|
||||
EditorGUILayout.PropertyField(_tierBonus, new GUIContent("Difficulty Tier Bonus"));
|
||||
}
|
||||
|
||||
private void DrawOverrideFields() {
|
||||
EditorGUILayout.LabelField("Override Zone Settings", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"Override zones completely replace all other zones at this position. " +
|
||||
"Useful for story events, towns, and safe areas. " +
|
||||
"Highest-priority Override wins if multiple are present.",
|
||||
MessageType.None);
|
||||
|
||||
EditorGUILayout.PropertyField(_isSafeZone, new GUIContent("Is Safe Zone"));
|
||||
|
||||
if(!_isSafeZone.boolValue) {
|
||||
EditorGUI.indentLevel++;
|
||||
EditorGUILayout.PropertyField(_overrideTableId, new GUIContent("Encounter Table ID"));
|
||||
EditorGUILayout.PropertyField(_overrideChance, new GUIContent("Encounter Chance"));
|
||||
EditorGUILayout.PropertyField(_overrideTier, new GUIContent("Difficulty Tier"));
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
2
Editor/ZoneDataEditor.cs.meta
Normal file
2
Editor/ZoneDataEditor.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ba5e43b325f91cd45a86ee6fc860275f
|
||||
109
Editor/ZoneEditorSettings.cs
Normal file
109
Editor/ZoneEditorSettings.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
#if UNITY_EDITOR
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.ZoneSystem.Editor {
|
||||
[CreateAssetMenu(fileName = "ZoneEditorSettings", menuName = "Jovian/ZoneSystem/Zone Editor Settings")]
|
||||
public class ZoneEditorSettings : ScriptableObject {
|
||||
[Tooltip("Which two world axes your map lies on. Match this to your map's plane.")]
|
||||
public MapPlane mapPlane = MapPlane.XZ;
|
||||
|
||||
[Tooltip("Folder path where new ZoneData assets are saved (relative to project root).")]
|
||||
public string zoneDataFolder = "Assets/ZoneData";
|
||||
|
||||
[Tooltip("Debug color for each zone role. Add entries for any new roles.")]
|
||||
public List<ZoneRoleColor> roleColors = new() {
|
||||
new ZoneRoleColor { role = ZoneRole.Base, color = new Color(0.2f, 0.6f, 1f, 0.25f) },
|
||||
new ZoneRoleColor { role = ZoneRole.Modifier, color = new Color(1f, 0.8f, 0.2f, 0.25f) },
|
||||
new ZoneRoleColor { role = ZoneRole.Override, color = new Color(0.3f, 0.9f, 0.3f, 0.25f) }
|
||||
};
|
||||
|
||||
public Color GetColorForRole(ZoneRole role) {
|
||||
foreach(ZoneRoleColor entry in roleColors) {
|
||||
if(entry.role == role) {
|
||||
return entry.color;
|
||||
}
|
||||
}
|
||||
return new Color(1f, 0.5f, 0f, 0.25f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures every ZoneRole enum value has a color entry.
|
||||
/// Call this after adding new roles to the enum.
|
||||
/// </summary>
|
||||
public void SyncRoleEntries() {
|
||||
ZoneRole[] allRoles = (ZoneRole[])Enum.GetValues(typeof(ZoneRole));
|
||||
foreach(ZoneRole role in allRoles) {
|
||||
bool found = false;
|
||||
foreach(ZoneRoleColor entry in roleColors) {
|
||||
if(entry.role == role) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(!found) {
|
||||
roleColors.Add(new ZoneRoleColor {
|
||||
role = role,
|
||||
color = new Color(0.5f, 0.5f, 0.5f, 0.25f)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[MenuItem("Window/Zone System/Settings")]
|
||||
private static void SelectOrCreateSettings() {
|
||||
ZoneEditorSettings settings = FindOrCreateSettings();
|
||||
Selection.activeObject = settings;
|
||||
EditorGUIUtility.PingObject(settings);
|
||||
}
|
||||
|
||||
[MenuItem("Window/Zone System/Documentation")]
|
||||
private static void OpenDocumentation() {
|
||||
// Find the Documentation~ folder relative to this package
|
||||
string[] guids = AssetDatabase.FindAssets("t:Script ZoneEditorSettings");
|
||||
string packagePath = "Packages/com.jovian.zonesystem";
|
||||
if(guids.Length > 0) {
|
||||
string scriptPath = AssetDatabase.GUIDToAssetPath(guids[0]);
|
||||
// scriptPath is like "Packages/com.jovian.zonesystem/Editor/ZoneEditorSettings.cs"
|
||||
int editorIdx = scriptPath.IndexOf("/Editor/");
|
||||
if(editorIdx >= 0) {
|
||||
packagePath = scriptPath.Substring(0, editorIdx);
|
||||
}
|
||||
}
|
||||
string fullPath = System.IO.Path.GetFullPath(
|
||||
System.IO.Path.Combine(Application.dataPath, "..", packagePath, "Documentation~", "index.html"));
|
||||
if(System.IO.File.Exists(fullPath)) {
|
||||
Application.OpenURL("file:///" + fullPath.Replace("\\", "/"));
|
||||
}
|
||||
else {
|
||||
Debug.LogWarning($"[ZoneSystem] Documentation not found at: {fullPath}");
|
||||
}
|
||||
}
|
||||
|
||||
internal static ZoneEditorSettings FindOrCreateSettings() {
|
||||
string[] guids = AssetDatabase.FindAssets("t:ZoneEditorSettings");
|
||||
if(guids.Length > 0) {
|
||||
return AssetDatabase.LoadAssetAtPath<ZoneEditorSettings>(
|
||||
AssetDatabase.GUIDToAssetPath(guids[0]));
|
||||
}
|
||||
|
||||
// Create a new settings asset
|
||||
string folder = "Assets";
|
||||
ZoneEditorSettings newSettings = CreateInstance<ZoneEditorSettings>();
|
||||
newSettings.SyncRoleEntries();
|
||||
AssetDatabase.CreateAsset(newSettings, $"{folder}/ZoneEditorSettings.asset");
|
||||
AssetDatabase.SaveAssets();
|
||||
Debug.Log("[ZoneSystem] Created ZoneEditorSettings at Assets/ZoneEditorSettings.asset");
|
||||
return newSettings;
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public struct ZoneRoleColor {
|
||||
public ZoneRole role;
|
||||
public Color color;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
3
Editor/ZoneEditorSettings.cs.meta
Normal file
3
Editor/ZoneEditorSettings.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 64856826ade04f41963e973ab19b2f00
|
||||
timeCreated: 1772984016
|
||||
53
Editor/ZoneEditorSettingsEditor.cs
Normal file
53
Editor/ZoneEditorSettingsEditor.cs
Normal 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
|
||||
2
Editor/ZoneEditorSettingsEditor.cs.meta
Normal file
2
Editor/ZoneEditorSettingsEditor.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 07d9ebf920c798c46b91e4f371ba5c7a
|
||||
643
Editor/ZoneEditorWindow.cs
Normal file
643
Editor/ZoneEditorWindow.cs
Normal file
@@ -0,0 +1,643 @@
|
||||
#if UNITY_EDITOR
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.ZoneSystem.Editor {
|
||||
/// <summary>
|
||||
/// Main Zone Editor window.
|
||||
/// Open via: Window → Zone System → Zone Editor
|
||||
/// </summary>
|
||||
public class ZoneEditorWindow : EditorWindow {
|
||||
|
||||
private string _exportPath = "Assets/StreamingAssets/zones.json";
|
||||
|
||||
// ── Create form state ───────────────────────────────────────────
|
||||
private bool _showCreateForm;
|
||||
private string _newZoneName = "New Zone";
|
||||
private ZoneShape _newZoneShape = ZoneShape.Square;
|
||||
|
||||
// ── Edit state ──────────────────────────────────────────────────
|
||||
private ZoneInstance _editingZone;
|
||||
private SerializedObject _editingSO;
|
||||
private bool _isUnsavedNewData;
|
||||
private bool _hasUnsavedChanges;
|
||||
private string _saveError;
|
||||
private ZoneShape _shapeOnEditStart;
|
||||
|
||||
// ── Scroll / foldouts ───────────────────────────────────────────
|
||||
private Vector2 _scrollPos;
|
||||
private bool _showExportFoldout = true;
|
||||
|
||||
// ── GUI ──────────────────────────────────────────────────────────
|
||||
private void OnGUI() {
|
||||
DrawHeader();
|
||||
|
||||
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos);
|
||||
|
||||
if(_editingZone != null) {
|
||||
DrawEditSection();
|
||||
} else {
|
||||
DrawCreateButton();
|
||||
if(_showCreateForm) {
|
||||
DrawCreateForm();
|
||||
}
|
||||
EditorGUILayout.Space(6);
|
||||
DrawSceneZonesList();
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
DrawExportSection();
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
private void OnFocus() {
|
||||
ValidateEditingState();
|
||||
Repaint();
|
||||
}
|
||||
private void OnHierarchyChange() {
|
||||
ValidateEditingState();
|
||||
Repaint();
|
||||
}
|
||||
private void OnSelectionChange() {
|
||||
ValidateEditingState();
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private void ValidateEditingState() {
|
||||
if(_editingZone == null && _editingSO != null) {
|
||||
_editingSO = null;
|
||||
}
|
||||
}
|
||||
|
||||
[MenuItem("Window/Zone System/Zone Editor")]
|
||||
public static void Open() {
|
||||
GetWindow<ZoneEditorWindow>("Zone Editor");
|
||||
}
|
||||
|
||||
public static void OpenAndEdit(ZoneInstance zone) {
|
||||
ZoneEditorWindow window = GetWindow<ZoneEditorWindow>("Zone Editor");
|
||||
window.EnterEditMode(zone);
|
||||
}
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────────
|
||||
|
||||
private void DrawHeader() {
|
||||
Color prevBg = GUI.backgroundColor;
|
||||
GUI.backgroundColor = new Color(0.2f, 0.2f, 0.3f);
|
||||
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
|
||||
GUI.backgroundColor = prevBg;
|
||||
|
||||
EditorGUILayout.LabelField("🗺 Zone System Editor", new GUIStyle(EditorStyles.boldLabel) {
|
||||
fontSize = 14,
|
||||
normal = { textColor = new Color(0.8f, 0.9f, 1f) }
|
||||
});
|
||||
EditorGUILayout.LabelField("Define map zones for encounter difficulty and chance.",
|
||||
EditorStyles.miniLabel);
|
||||
EditorGUILayout.EndVertical();
|
||||
EditorGUILayout.Space(4);
|
||||
}
|
||||
|
||||
// ── Create Button + Dropdown ────────────────────────────────────
|
||||
|
||||
private void DrawCreateButton() {
|
||||
GUI.backgroundColor = new Color(0.3f, 0.7f, 0.3f);
|
||||
if(GUILayout.Button("➕ Create New Zone", GUILayout.Height(30))) {
|
||||
_showCreateForm = !_showCreateForm;
|
||||
}
|
||||
GUI.backgroundColor = Color.white;
|
||||
}
|
||||
|
||||
private void DrawCreateForm() {
|
||||
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
|
||||
|
||||
EditorGUI.indentLevel++;
|
||||
_newZoneName = EditorGUILayout.TextField("Zone Name", _newZoneName);
|
||||
_newZoneShape = (ZoneShape)EditorGUILayout.EnumPopup("Shape", _newZoneShape);
|
||||
EditorGUI.indentLevel--;
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
EditorGUILayout.LabelField("All zone data can be edited after creation.",
|
||||
EditorStyles.miniLabel);
|
||||
|
||||
if(GUILayout.Button("Create & Edit", GUILayout.Height(26))) {
|
||||
CreateZoneInScene();
|
||||
}
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
|
||||
// ── Edit Section ────────────────────────────────────────────────
|
||||
|
||||
private void EnterEditMode(ZoneInstance zone) {
|
||||
_editingZone = zone;
|
||||
_editingSO = zone.data != null ? new SerializedObject(zone.data) : null;
|
||||
_showCreateForm = false;
|
||||
_shapeOnEditStart = zone.data != null ? zone.data.shape : ZoneShape.Polygon;
|
||||
|
||||
Selection.activeGameObject = zone.gameObject;
|
||||
SceneView.FrameLastActiveSceneView();
|
||||
ZoneInstanceEditor.startEditingOnNextSelect = true;
|
||||
}
|
||||
|
||||
private void ExitEditMode() {
|
||||
// If exiting with unsaved new data, clean up the in-memory asset
|
||||
if(_isUnsavedNewData && _editingZone != null && _editingZone.data != null) {
|
||||
DestroyImmediate(_editingZone.data);
|
||||
_editingZone.data = null;
|
||||
}
|
||||
_editingZone = null;
|
||||
_editingSO = null;
|
||||
_isUnsavedNewData = false;
|
||||
_hasUnsavedChanges = false;
|
||||
_saveError = null;
|
||||
}
|
||||
|
||||
private void DrawEditSection() {
|
||||
// Validate that the zone still exists
|
||||
if(_editingZone == null || _editingZone.data == null) {
|
||||
ExitEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
// Back button
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
Color prevBackBg = GUI.backgroundColor;
|
||||
GUI.backgroundColor = new Color(0.9f, 0.3f, 0.3f);
|
||||
if(GUILayout.Button("← Back", GUILayout.Height(36), GUILayout.Width(70))) {
|
||||
ExitEditMode();
|
||||
GUI.backgroundColor = prevBackBg;
|
||||
EditorGUILayout.EndHorizontal();
|
||||
return;
|
||||
}
|
||||
GUI.backgroundColor = prevBackBg;
|
||||
GUILayout.FlexibleSpace();
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
ZoneData d = _editingZone.data;
|
||||
|
||||
// Zone header with color swatch
|
||||
Color prevBg = GUI.backgroundColor;
|
||||
GUI.backgroundColor = new Color(0.2f, 0.3f, 0.4f);
|
||||
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
|
||||
GUI.backgroundColor = prevBg;
|
||||
|
||||
string headerLabel = _isUnsavedNewData ? $"New Zone: {d.zoneName}" : $"Editing: {d.zoneName}";
|
||||
EditorGUILayout.LabelField(headerLabel, new GUIStyle(EditorStyles.boldLabel) {
|
||||
fontSize = 13,
|
||||
normal = { textColor = new Color(0.9f, 0.95f, 1f) }
|
||||
});
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
// Draw the ZoneData fields using SerializedObject
|
||||
if(_editingSO == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_editingSO.Update();
|
||||
|
||||
// Identity
|
||||
EditorGUILayout.LabelField("Identity", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_editingSO.FindProperty("zoneId"), new GUIContent("Zone ID"));
|
||||
EditorGUILayout.PropertyField(_editingSO.FindProperty("zoneName"), new GUIContent("Zone Name"));
|
||||
|
||||
// Track role changes to auto-apply color
|
||||
SerializedProperty rolePropForColor = _editingSO.FindProperty("role");
|
||||
ZoneRole roleBefore = (ZoneRole)rolePropForColor.enumValueIndex;
|
||||
EditorGUILayout.PropertyField(rolePropForColor, new GUIContent("Role"));
|
||||
ZoneRole roleAfter = (ZoneRole)rolePropForColor.enumValueIndex;
|
||||
if(roleBefore != roleAfter) {
|
||||
ZoneEditorSettings settings = FindSettings();
|
||||
_editingSO.FindProperty("debugColor").colorValue = settings.GetColorForRole(roleAfter);
|
||||
}
|
||||
|
||||
EditorGUILayout.PropertyField(_editingSO.FindProperty("priority"), new GUIContent("Priority"));
|
||||
EditorGUILayout.PropertyField(_editingSO.FindProperty("shape"), new GUIContent("Shape"));
|
||||
|
||||
SerializedProperty shapeProp = _editingSO.FindProperty("shape");
|
||||
if((ZoneShape)shapeProp.enumValueIndex == ZoneShape.Circle) {
|
||||
EditorGUILayout.PropertyField(_editingSO.FindProperty("circleRadius"), new GUIContent("Circle Radius"));
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
|
||||
// Role-specific fields
|
||||
SerializedProperty roleProp = _editingSO.FindProperty("role");
|
||||
ZoneRole role = (ZoneRole)roleProp.enumValueIndex;
|
||||
switch(role) {
|
||||
case ZoneRole.Base:
|
||||
EditorGUILayout.LabelField("Base Zone Settings", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_editingSO.FindProperty("encounterTableId"), new GUIContent("Encounter Table ID"));
|
||||
EditorGUILayout.PropertyField(_editingSO.FindProperty("baseDifficultyTier"), new GUIContent("Difficulty Tier"));
|
||||
EditorGUILayout.PropertyField(_editingSO.FindProperty("baseEncounterChance"), new GUIContent("Encounter Chance"));
|
||||
break;
|
||||
case ZoneRole.Modifier:
|
||||
EditorGUILayout.LabelField("Modifier Zone Settings", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_editingSO.FindProperty("encounterChanceMultiplier"), new GUIContent("Chance Multiplier"));
|
||||
EditorGUILayout.PropertyField(_editingSO.FindProperty("difficultyTierBonus"), new GUIContent("Difficulty Tier Bonus"));
|
||||
break;
|
||||
case ZoneRole.Override:
|
||||
EditorGUILayout.LabelField("Override Zone Settings", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(_editingSO.FindProperty("isSafeZone"), new GUIContent("Is Safe Zone"));
|
||||
if(!_editingSO.FindProperty("isSafeZone").boolValue) {
|
||||
EditorGUI.indentLevel++;
|
||||
EditorGUILayout.PropertyField(_editingSO.FindProperty("overrideEncounterTableId"), new GUIContent("Encounter Table ID"));
|
||||
EditorGUILayout.PropertyField(_editingSO.FindProperty("overrideEncounterChance"), new GUIContent("Encounter Chance"));
|
||||
EditorGUILayout.PropertyField(_editingSO.FindProperty("overrideDifficultyTier"), new GUIContent("Difficulty Tier"));
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if(_editingSO.ApplyModifiedProperties()) {
|
||||
_hasUnsavedChanges = true;
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
|
||||
// Save button — shown for new unsaved zones or any modified existing zone
|
||||
if(_isUnsavedNewData || _hasUnsavedChanges) {
|
||||
// Show error if any
|
||||
if(!string.IsNullOrEmpty(_saveError)) {
|
||||
EditorGUILayout.HelpBox(_saveError, MessageType.Error);
|
||||
}
|
||||
|
||||
GUI.backgroundColor = new Color(0.3f, 0.7f, 0.3f);
|
||||
if(GUILayout.Button("💾 Save Zone", GUILayout.Height(30))) {
|
||||
SaveZoneData();
|
||||
}
|
||||
GUI.backgroundColor = Color.white;
|
||||
}
|
||||
|
||||
// Delete button at the bottom
|
||||
EditorGUILayout.Space(4);
|
||||
GUI.backgroundColor = new Color(0.9f, 0.3f, 0.3f);
|
||||
if(GUILayout.Button("🗑 Delete Zone", GUILayout.Height(27))) {
|
||||
ZoneInstance zone = _editingZone;
|
||||
ExitEditMode();
|
||||
DeleteZone(zone);
|
||||
}
|
||||
GUI.backgroundColor = Color.white;
|
||||
}
|
||||
|
||||
// ── Create ──────────────────────────────────────────────────────
|
||||
|
||||
private static ZoneEditorSettings FindSettings() {
|
||||
return ZoneEditorSettings.FindOrCreateSettings();
|
||||
}
|
||||
|
||||
private void CreateZoneInScene() {
|
||||
// Create in-memory ZoneData (saved when user clicks Save)
|
||||
ZoneEditorSettings settings = FindSettings();
|
||||
ZoneData data = CreateInstance<ZoneData>();
|
||||
data.zoneId = _newZoneName.ToLower().Replace(" ", "_");
|
||||
data.zoneName = _newZoneName;
|
||||
data.shape = _newZoneShape;
|
||||
data.debugColor = settings.GetColorForRole(data.role);
|
||||
data.polygon.AddRange(ShapeFactory.CreateDefault(_newZoneShape));
|
||||
|
||||
// Create the scene GameObject
|
||||
GameObject go = new GameObject(_newZoneName);
|
||||
Undo.RegisterCreatedObjectUndo(go, "Create Zone");
|
||||
|
||||
ZoneInstance inst = go.AddComponent<ZoneInstance>();
|
||||
inst.data = data;
|
||||
|
||||
// Try to parent under ZoneManager if it exists
|
||||
ZonesObjectHolder mgr = FindFirstObjectByType<ZonesObjectHolder>();
|
||||
if(mgr != null) {
|
||||
go.transform.SetParent(mgr.transform, true);
|
||||
}
|
||||
|
||||
_showCreateForm = false;
|
||||
_isUnsavedNewData = true;
|
||||
_saveError = null;
|
||||
EnterEditMode(inst);
|
||||
}
|
||||
|
||||
private void CreateDataForZone(ZoneInstance zone) {
|
||||
string zoneName = zone.gameObject.name;
|
||||
ZoneEditorSettings settings = FindSettings();
|
||||
|
||||
// Create in-memory ZoneData (not saved as asset yet)
|
||||
ZoneData data = CreateInstance<ZoneData>();
|
||||
data.zoneId = zoneName.ToLower().Replace(" ", "_");
|
||||
data.zoneName = zoneName;
|
||||
data.shape = ZoneShape.Polygon;
|
||||
data.debugColor = settings.GetColorForRole(data.role);
|
||||
data.polygon.AddRange(ShapeFactory.CreateDefault(ZoneShape.Polygon));
|
||||
|
||||
zone.data = data;
|
||||
_isUnsavedNewData = true;
|
||||
_saveError = null;
|
||||
EnterEditMode(zone);
|
||||
}
|
||||
|
||||
private void SaveZoneData() {
|
||||
ZoneData data = _editingZone.data;
|
||||
string zoneId = data.zoneId;
|
||||
string assetName = data.zoneName.Replace(" ", "_");
|
||||
|
||||
// Check for duplicate zoneId or asset name among existing ZoneData assets
|
||||
string[] existingGuids = AssetDatabase.FindAssets("t:ZoneData");
|
||||
foreach(string guid in existingGuids) {
|
||||
string path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
ZoneData existing = AssetDatabase.LoadAssetAtPath<ZoneData>(path);
|
||||
if(existing == null || existing == data) {
|
||||
continue;
|
||||
}
|
||||
if(existing.zoneId == zoneId) {
|
||||
_saveError = $"A ZoneData asset with ID '{zoneId}' already exists at:\n{path}";
|
||||
return;
|
||||
}
|
||||
string existingAssetName = Path.GetFileNameWithoutExtension(path);
|
||||
if(existingAssetName == assetName) {
|
||||
_saveError = $"A ZoneData asset named '{assetName}' already exists at:\n{path}";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset polygon if shape type changed since edit started
|
||||
if(data.shape != _shapeOnEditStart) {
|
||||
data.polygon.Clear();
|
||||
data.polygon.AddRange(ShapeFactory.CreateDefault(data.shape));
|
||||
if(data.shape == ZoneShape.Circle) {
|
||||
data.circleRadius = ShapeFactory.DefaultRadius;
|
||||
}
|
||||
_editingZone.RebuildBoundsCache();
|
||||
SceneView.RepaintAll();
|
||||
}
|
||||
_shapeOnEditStart = data.shape;
|
||||
|
||||
if(_isUnsavedNewData) {
|
||||
// New zone — create the asset for the first time
|
||||
ZoneEditorSettings settings = FindSettings();
|
||||
string folder = settings != null ? settings.zoneDataFolder : "Assets/ZoneData";
|
||||
string soPath = $"{folder}/{assetName}.asset";
|
||||
string fullFolder = Path.GetFullPath(Path.Combine(Application.dataPath, "..", folder));
|
||||
Directory.CreateDirectory(fullFolder);
|
||||
|
||||
AssetDatabase.CreateAsset(data, soPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
// Rebuild the SerializedObject now that the asset is persisted
|
||||
_editingSO = new SerializedObject(data);
|
||||
_isUnsavedNewData = false;
|
||||
|
||||
Debug.Log($"[ZoneSystem] Created ZoneData '{data.zoneName}' at {soPath}");
|
||||
}
|
||||
else {
|
||||
// Existing zone — rename asset if needed, then save
|
||||
string currentPath = AssetDatabase.GetAssetPath(data);
|
||||
string currentAssetName = Path.GetFileNameWithoutExtension(currentPath);
|
||||
if(currentAssetName != assetName) {
|
||||
string renameError = AssetDatabase.RenameAsset(currentPath, assetName);
|
||||
if(!string.IsNullOrEmpty(renameError)) {
|
||||
_saveError = $"Failed to rename asset: {renameError}";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(data);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
Debug.Log($"[ZoneSystem] Saved ZoneData '{data.zoneName}'");
|
||||
}
|
||||
|
||||
// Rename the GameObject to match the zone name
|
||||
Undo.RecordObject(_editingZone.gameObject, "Rename Zone GameObject");
|
||||
_editingZone.gameObject.name = data.zoneName;
|
||||
|
||||
EditorUtility.SetDirty(_editingZone);
|
||||
_hasUnsavedChanges = false;
|
||||
_saveError = null;
|
||||
}
|
||||
|
||||
// ── Scene Zones List ────────────────────────────────────────────
|
||||
|
||||
private void DrawSceneZonesList() {
|
||||
EditorGUILayout.LabelField("Scene Zones", EditorStyles.boldLabel);
|
||||
|
||||
// Show active map plane from ZoneManager
|
||||
ZonesObjectHolder mgr = FindFirstObjectByType<ZonesObjectHolder>();
|
||||
if(mgr != null) {
|
||||
EditorGUILayout.LabelField($"Map Plane: {mgr.mapPlane} (set on ZoneManager)",
|
||||
new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = new Color(0.5f, 0.8f, 1f) } });
|
||||
}
|
||||
else {
|
||||
EditorGUILayout.HelpBox("No ZoneManager found in scene.", MessageType.Warning);
|
||||
}
|
||||
|
||||
ZoneInstance[] zones = FindObjectsByType<ZoneInstance>(FindObjectsSortMode.None)
|
||||
.OrderByDescending(z => z.data?.priority ?? 0)
|
||||
.ThenBy(z => z.data?.zoneName ?? "")
|
||||
.ToArray();
|
||||
|
||||
if(zones.Length == 0) {
|
||||
EditorGUILayout.HelpBox("No ZoneInstance objects found in the scene.", MessageType.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach(ZoneInstance zone in zones) {
|
||||
DrawZoneRow(zone);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawZoneRow(ZoneInstance zone) {
|
||||
if(zone.data == null) {
|
||||
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
|
||||
|
||||
// Warning icon
|
||||
GUIContent warnIcon = EditorGUIUtility.IconContent("console.warnicon.sml");
|
||||
EditorGUILayout.LabelField(warnIcon, GUILayout.Width(18), GUILayout.Height(20));
|
||||
|
||||
EditorGUILayout.LabelField($"{zone.gameObject.name}: Missing ZoneData", EditorStyles.miniLabel);
|
||||
|
||||
// Add & Edit button — creates a ZoneData asset and enters edit mode
|
||||
if(GUILayout.Button("+ Add & Edit", GUILayout.Width(90), GUILayout.Height(20))) {
|
||||
CreateDataForZone(zone);
|
||||
}
|
||||
|
||||
GUI.backgroundColor = new Color(0.9f, 0.3f, 0.3f);
|
||||
if(GUILayout.Button("✕", GUILayout.Width(28), GUILayout.Height(20))) {
|
||||
if(EditorUtility.DisplayDialog("Delete Zone",
|
||||
$"Delete '{zone.gameObject.name}'? (no ZoneData asset to remove)", "Delete", "Cancel")) {
|
||||
Undo.DestroyObjectImmediate(zone.gameObject);
|
||||
}
|
||||
}
|
||||
GUI.backgroundColor = Color.white;
|
||||
EditorGUILayout.EndHorizontal();
|
||||
return;
|
||||
}
|
||||
|
||||
ZoneData d = zone.data;
|
||||
Color roleColor = FindSettings().GetColorForRole(d.role);
|
||||
|
||||
Color prevBg = GUI.backgroundColor;
|
||||
GUI.backgroundColor = (roleColor * 2f * 0.6f) + (Color.gray * 0.4f);
|
||||
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
|
||||
GUI.backgroundColor = prevBg;
|
||||
|
||||
// Color swatch
|
||||
Rect swatchRect = GUILayoutUtility.GetRect(12, 20, GUILayout.Width(12));
|
||||
EditorGUI.DrawRect(swatchRect, roleColor * 3f);
|
||||
|
||||
// Info
|
||||
EditorGUILayout.BeginVertical();
|
||||
EditorGUILayout.LabelField($"{d.zoneName}", EditorStyles.boldLabel);
|
||||
EditorGUILayout.LabelField(BuildZoneSummaryString(d), EditorStyles.miniLabel);
|
||||
EditorGUILayout.EndVertical();
|
||||
|
||||
// Select / Edit button
|
||||
if(GUILayout.Button("Select", GUILayout.Width(55), GUILayout.Height(36))) {
|
||||
EnterEditMode(zone);
|
||||
}
|
||||
|
||||
// Duplicate button
|
||||
if(GUILayout.Button("📋", GUILayout.Width(28), GUILayout.Height(36))) {
|
||||
DuplicateZone(zone);
|
||||
}
|
||||
|
||||
// Delete button
|
||||
GUI.backgroundColor = new Color(0.9f, 0.3f, 0.3f);
|
||||
if(GUILayout.Button("✕", GUILayout.Width(28), GUILayout.Height(36))) {
|
||||
DeleteZone(zone);
|
||||
}
|
||||
GUI.backgroundColor = prevBg;
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
private void DeleteZone(ZoneInstance zone) {
|
||||
string zoneName = zone.data != null ? zone.data.zoneName : zone.gameObject.name;
|
||||
string assetPath = zone.data != null ? AssetDatabase.GetAssetPath(zone.data) : null;
|
||||
|
||||
string message = $"Delete zone '{zoneName}'?";
|
||||
if(!string.IsNullOrEmpty(assetPath)) {
|
||||
message += $"\n\nThis will also delete the asset:\n{assetPath}";
|
||||
}
|
||||
|
||||
if(!EditorUtility.DisplayDialog("Delete Zone", message, "Delete", "Cancel")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if(!string.IsNullOrEmpty(assetPath)) {
|
||||
AssetDatabase.DeleteAsset(assetPath);
|
||||
}
|
||||
|
||||
Undo.DestroyObjectImmediate(zone.gameObject);
|
||||
}
|
||||
|
||||
private void DuplicateZone(ZoneInstance zone) {
|
||||
if(zone.data == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ZoneData original = zone.data;
|
||||
ZoneEditorSettings settings = FindSettings();
|
||||
string folder = settings.zoneDataFolder;
|
||||
|
||||
// Create independent ZoneData copy
|
||||
ZoneData copy = CreateInstance<ZoneData>();
|
||||
EditorUtility.CopySerialized(original, copy);
|
||||
copy.zoneId = original.zoneId + "_" + System.Guid.NewGuid().ToString("N").Substring(0, 6);
|
||||
copy.zoneName = original.zoneName + " (Copy)";
|
||||
|
||||
string newName = original.zoneName.Replace(" ", "_") + "_Copy";
|
||||
string newPath = AssetDatabase.GenerateUniqueAssetPath(
|
||||
Path.Combine(folder, newName + ".asset"));
|
||||
|
||||
string fullFolder = Path.GetFullPath(Path.Combine(Application.dataPath, "..", folder));
|
||||
Directory.CreateDirectory(fullFolder);
|
||||
AssetDatabase.CreateAsset(copy, newPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
// Create scene GameObject
|
||||
GameObject duplicate = Instantiate(zone.gameObject, zone.transform.parent);
|
||||
duplicate.name = copy.zoneName;
|
||||
Undo.RegisterCreatedObjectUndo(duplicate, "Duplicate Zone");
|
||||
|
||||
ZoneInstance dupInstance = duplicate.GetComponent<ZoneInstance>();
|
||||
dupInstance.data = copy;
|
||||
dupInstance.RebuildBoundsCache();
|
||||
|
||||
// Offset slightly so it's not on top of the original
|
||||
ZonesObjectHolder mgr = FindFirstObjectByType<ZonesObjectHolder>();
|
||||
MapPlane plane = mgr != null ? mgr.mapPlane : MapPlane.XZ;
|
||||
Vector3 offset = MapPlaneUtility.UnprojectFromPlane(new Vector2(1f, 1f), plane, 0f);
|
||||
duplicate.transform.position += offset;
|
||||
|
||||
Debug.Log($"[ZoneSystem] Duplicated zone to {newPath}");
|
||||
}
|
||||
|
||||
private string BuildZoneSummaryString(ZoneData d) {
|
||||
switch(d.role) {
|
||||
case ZoneRole.Base:
|
||||
return $"Base | Priority {d.priority} | {d.baseDifficultyTier} | {d.baseEncounterChance:P0} | Table: {d.encounterTableId}";
|
||||
case ZoneRole.Modifier:
|
||||
return $"Modifier | Priority {d.priority} | Chance ×{d.encounterChanceMultiplier:F2} | Tier +{d.difficultyTierBonus}";
|
||||
case ZoneRole.Override:
|
||||
return d.isSafeZone
|
||||
? $"Override | Priority {d.priority} | ✓ SAFE"
|
||||
: $"Override | Priority {d.priority} | {d.overrideDifficultyTier} | {d.overrideEncounterChance:P0}";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Export Section ───────────────────────────────────────────────
|
||||
private void DrawExportSection() {
|
||||
_showExportFoldout = EditorGUILayout.BeginFoldoutHeaderGroup(_showExportFoldout, "Export Zones to JSON");
|
||||
if(_showExportFoldout) {
|
||||
EditorGUI.indentLevel++;
|
||||
_exportPath = EditorGUILayout.TextField("Output Path", _exportPath);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if(GUILayout.Button("Browse…", GUILayout.Width(70))) {
|
||||
string picked = EditorUtility.SaveFilePanel(
|
||||
"Save zones.json", Path.GetDirectoryName(_exportPath),
|
||||
Path.GetFileName(_exportPath), "json");
|
||||
if(!string.IsNullOrEmpty(picked)) {
|
||||
_exportPath = "Assets" + picked.Substring(Application.dataPath.Length);
|
||||
}
|
||||
}
|
||||
|
||||
GUI.backgroundColor = new Color(0.4f, 0.8f, 0.4f);
|
||||
if(GUILayout.Button("📦 Export Now", GUILayout.Height(24))) {
|
||||
ExportZones();
|
||||
}
|
||||
GUI.backgroundColor = Color.white;
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
EditorGUI.indentLevel--;
|
||||
}
|
||||
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||
}
|
||||
|
||||
private void ExportZones() {
|
||||
ZoneInstance[] instances = FindObjectsByType<ZoneInstance>(FindObjectsSortMode.None);
|
||||
ZonesObjectHolder mgr = FindFirstObjectByType<ZonesObjectHolder>();
|
||||
MapPlane plane = mgr != null ? mgr.mapPlane : MapPlane.XZ;
|
||||
ZoneExportRoot root = ZoneExporter.BuildExport(instances, plane);
|
||||
string json = ZoneExporter.ToJson(root);
|
||||
|
||||
string fullPath = Path.Combine(Application.dataPath, "../", _exportPath);
|
||||
fullPath = Path.GetFullPath(fullPath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullPath));
|
||||
File.WriteAllText(fullPath, json);
|
||||
|
||||
AssetDatabase.Refresh();
|
||||
Debug.Log($"[ZoneSystem] Exported {root.zones.Count} zones → {fullPath}");
|
||||
EditorUtility.DisplayDialog("Zone Export",
|
||||
$"Successfully exported {root.zones.Count} zone(s) to:\n{_exportPath}", "OK");
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
2
Editor/ZoneEditorWindow.cs.meta
Normal file
2
Editor/ZoneEditorWindow.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8f1ef1e3c20db2e4a904ef5201d403ec
|
||||
503
Editor/ZoneInstanceEditor.cs
Normal file
503
Editor/ZoneInstanceEditor.cs
Normal file
@@ -0,0 +1,503 @@
|
||||
#if UNITY_EDITOR
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.ZoneSystem.Editor {
|
||||
[CustomEditor(typeof(ZoneInstance))]
|
||||
public class ZoneInstanceEditor : UnityEditor.Editor {
|
||||
|
||||
// Set to true externally to auto-enable editing when the inspector opens
|
||||
internal static bool startEditingOnNextSelect;
|
||||
|
||||
private bool _editingPolygon;
|
||||
private ZoneInstance _zone;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private MapPlane ActivePlane {
|
||||
get {
|
||||
ZonesObjectHolder mgr = FindFirstObjectByType<ZonesObjectHolder>();
|
||||
return mgr != null ? mgr.mapPlane : MapPlane.XZ;
|
||||
}
|
||||
}
|
||||
|
||||
private float DepthValue {
|
||||
get {
|
||||
MapPlane plane = ActivePlane;
|
||||
return plane == MapPlane.XZ ? _zone.transform.position.y
|
||||
: plane == MapPlane.YZ ? _zone.transform.position.x
|
||||
: _zone.transform.position.z;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnable() {
|
||||
_zone = (ZoneInstance)target;
|
||||
if(startEditingOnNextSelect) {
|
||||
startEditingOnNextSelect = false;
|
||||
_editingPolygon = true;
|
||||
SceneView.RepaintAll();
|
||||
}
|
||||
}
|
||||
private void OnDisable() {
|
||||
_editingPolygon = false;
|
||||
Tools.hidden = false;
|
||||
}
|
||||
|
||||
// ── Scene GUI ────────────────────────────────────────────────────
|
||||
|
||||
private void OnSceneGUI() {
|
||||
if(_zone.data == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide the default transform handle when not editing the shape
|
||||
if(!_editingPolygon) {
|
||||
Tools.hidden = true;
|
||||
} else {
|
||||
Tools.hidden = false;
|
||||
}
|
||||
|
||||
DrawFilledPolygon();
|
||||
|
||||
if(_editingPolygon) {
|
||||
// Esc stops editing
|
||||
if(Event.current.type == EventType.KeyDown && Event.current.keyCode == KeyCode.Escape) {
|
||||
_editingPolygon = false;
|
||||
Event.current.Use();
|
||||
Repaint();
|
||||
SceneView.RepaintAll();
|
||||
return;
|
||||
}
|
||||
|
||||
// Consume default scene input so clicks don't select other objects
|
||||
HandleUtility.AddDefaultControl(GUIUtility.GetControlID(FocusType.Passive));
|
||||
|
||||
if(_zone.data.shape == ZoneShape.Circle) {
|
||||
DrawCircleRadiusHandle();
|
||||
} else {
|
||||
DrawVertexHandles();
|
||||
HandleEdgeInsert();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Vector2 PlaneOrigin => MapPlaneUtility.ProjectToPlane(_zone.transform.position, ActivePlane);
|
||||
|
||||
private Vector3 PolyPointToWorld(Vector2 pt) {
|
||||
return MapPlaneUtility.UnprojectFromPlane(pt + PlaneOrigin, ActivePlane, DepthValue);
|
||||
}
|
||||
|
||||
private Vector2 WorldToPolyPoint(Vector3 world) {
|
||||
return MapPlaneUtility.ProjectToPlane(world, ActivePlane) - PlaneOrigin;
|
||||
}
|
||||
|
||||
// ── Inspector ────────────────────────────────────────────────────
|
||||
|
||||
public override void OnInspectorGUI() {
|
||||
DrawDefaultInspector();
|
||||
EditorGUILayout.Space(8);
|
||||
|
||||
GUI.backgroundColor = new Color(0.4f, 0.7f, 1f);
|
||||
if(GUILayout.Button("✏️ Edit in Zone Editor", GUILayout.Height(30))) {
|
||||
ZoneEditorWindow.OpenAndEdit(_zone);
|
||||
}
|
||||
GUI.backgroundColor = Color.white;
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
if(_zone.data == null) {
|
||||
EditorGUILayout.HelpBox("Assign a ZoneData asset to begin editing.", MessageType.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
// Active plane info
|
||||
EditorGUILayout.LabelField($"Active Plane: {ActivePlane} | Shape: {_zone.data.shape}",
|
||||
new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = new Color(0.5f, 0.8f, 1f) } });
|
||||
|
||||
// ── Vertex List ─────────────────────────────────────────────
|
||||
DrawVertexList();
|
||||
|
||||
// ── Shape Editing ───────────────────────────────────────────
|
||||
EditorGUILayout.Space(4);
|
||||
EditorGUILayout.LabelField("Shape", EditorStyles.boldLabel);
|
||||
|
||||
GUI.backgroundColor = _editingPolygon ? new Color(0.4f, 0.9f, 0.4f) : Color.white;
|
||||
if(GUILayout.Button(_editingPolygon ? "⬛ Stop Editing" : "✏️ Edit Shape", GUILayout.Height(27))) {
|
||||
_editingPolygon = !_editingPolygon;
|
||||
SceneView.RepaintAll();
|
||||
}
|
||||
GUI.backgroundColor = Color.white;
|
||||
|
||||
if(_editingPolygon) {
|
||||
if(_zone.data.shape == ZoneShape.Circle) {
|
||||
EditorGUILayout.HelpBox(
|
||||
"• Drag the radius handle to resize the circle",
|
||||
MessageType.Info);
|
||||
} else {
|
||||
EditorGUILayout.HelpBox(
|
||||
"• Drag handles to move vertices\n" +
|
||||
"• Ctrl+Click on an edge to insert a vertex\n" +
|
||||
"• Shift+Click a vertex to delete it",
|
||||
MessageType.Info);
|
||||
}
|
||||
}
|
||||
|
||||
if(_zone.data.shape == ZoneShape.Circle) {
|
||||
EditorGUI.BeginChangeCheck();
|
||||
float newRadius = EditorGUILayout.FloatField("Circle Radius", _zone.data.circleRadius);
|
||||
if(EditorGUI.EndChangeCheck()) {
|
||||
Undo.RecordObject(_zone.data, "Change Circle Radius");
|
||||
_zone.data.circleRadius = Mathf.Max(0.1f, newRadius);
|
||||
ShapeFactory.RegenerateCircle(_zone.data);
|
||||
EditorUtility.SetDirty(_zone.data);
|
||||
_zone.RebuildBoundsCache();
|
||||
SceneView.RepaintAll();
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if(GUILayout.Button("⊕ Center Transform", GUILayout.Height(27))) {
|
||||
RecenterTransformOnZone();
|
||||
}
|
||||
GUI.backgroundColor = new Color(1f, 0.7f, 0.3f);
|
||||
if(GUILayout.Button("↺ Reset Shape", GUILayout.Height(27))) {
|
||||
Undo.RecordObject(_zone.data, "Reset Zone Shape");
|
||||
_zone.data.polygon.Clear();
|
||||
_zone.data.polygon.AddRange(ShapeFactory.CreateDefault(_zone.data.shape));
|
||||
if(_zone.data.shape == ZoneShape.Circle) {
|
||||
_zone.data.circleRadius = ShapeFactory.DefaultRadius;
|
||||
}
|
||||
EditorUtility.SetDirty(_zone.data);
|
||||
_zone.RebuildBoundsCache();
|
||||
SceneView.RepaintAll();
|
||||
}
|
||||
GUI.backgroundColor = Color.white;
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
// ── Duplication ─────────────────────────────────────────────
|
||||
EditorGUILayout.Space(8);
|
||||
if(GUILayout.Button("📋 Duplicate Zone", GUILayout.Height(27))) {
|
||||
DuplicateZone();
|
||||
}
|
||||
|
||||
// ── Summary ─────────────────────────────────────────────────
|
||||
EditorGUILayout.Space(8);
|
||||
DrawZoneSummary();
|
||||
}
|
||||
|
||||
private void DrawZoneSummary() {
|
||||
if(_zone.data == null) {
|
||||
return;
|
||||
}
|
||||
ZoneData d = _zone.data;
|
||||
|
||||
Color roleColor = ZoneEditorSettings.FindOrCreateSettings().GetColorForRole(d.role);
|
||||
|
||||
Color prevBg = GUI.backgroundColor;
|
||||
GUI.backgroundColor = roleColor * 2f;
|
||||
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
|
||||
GUI.backgroundColor = prevBg;
|
||||
|
||||
EditorGUILayout.LabelField("Zone Summary", EditorStyles.boldLabel);
|
||||
EditorGUILayout.LabelField($"Role: {d.role}");
|
||||
EditorGUILayout.LabelField($"Priority: {d.priority}");
|
||||
EditorGUILayout.LabelField($"Vertices: {d.polygon?.Count ?? 0}");
|
||||
|
||||
switch(d.role) {
|
||||
case ZoneRole.Base:
|
||||
EditorGUILayout.LabelField($"Tier: {d.baseDifficultyTier}");
|
||||
EditorGUILayout.LabelField($"Chance: {d.baseEncounterChance:P0}");
|
||||
EditorGUILayout.LabelField($"Table: {d.encounterTableId}");
|
||||
break;
|
||||
case ZoneRole.Modifier:
|
||||
EditorGUILayout.LabelField($"Chance ×: {d.encounterChanceMultiplier:F2}");
|
||||
EditorGUILayout.LabelField($"Tier +: {d.difficultyTierBonus}");
|
||||
break;
|
||||
case ZoneRole.Override:
|
||||
EditorGUILayout.LabelField(d.isSafeZone
|
||||
? "⚑ SAFE ZONE — no encounters"
|
||||
: $"⚑ Override → Tier {d.overrideDifficultyTier}, {d.overrideEncounterChance:P0}");
|
||||
break;
|
||||
}
|
||||
|
||||
EditorGUILayout.EndVertical();
|
||||
}
|
||||
|
||||
private bool _showVertexList;
|
||||
|
||||
private void DrawVertexList() {
|
||||
List<Vector2> pts = _zone.data.polygon;
|
||||
if(pts == null || pts.Count == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(4);
|
||||
_showVertexList = EditorGUILayout.BeginFoldoutHeaderGroup(_showVertexList,
|
||||
$"Vertices ({pts.Count})");
|
||||
|
||||
if(_showVertexList) {
|
||||
GUIStyle miniStyle = new GUIStyle(EditorStyles.miniLabel) {
|
||||
alignment = TextAnchor.MiddleLeft,
|
||||
richText = true
|
||||
};
|
||||
|
||||
for(int i = 0; i < pts.Count; i++) {
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
|
||||
EditorGUILayout.LabelField(
|
||||
$"<b>[{i}]</b> ({pts[i].x:F2}, {pts[i].y:F2})",
|
||||
miniStyle);
|
||||
|
||||
if(GUILayout.Button("Copy", EditorStyles.miniButton, GUILayout.Width(40))) {
|
||||
EditorGUIUtility.systemCopyBuffer = $"{pts[i].x:F2}, {pts[i].y:F2}";
|
||||
}
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.EndFoldoutHeaderGroup();
|
||||
}
|
||||
|
||||
private void DrawFilledPolygon() {
|
||||
List<Vector2> pts = _zone.data.polygon;
|
||||
if(pts == null || pts.Count < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
Color fill = _zone.data.debugColor;
|
||||
Color border = new Color(fill.r, fill.g, fill.b, Mathf.Clamp01(fill.a * 3f));
|
||||
|
||||
Vector3[] verts = new Vector3[pts.Count];
|
||||
for(int i = 0; i < pts.Count; i++) {
|
||||
verts[i] = PolyPointToWorld(pts[i]);
|
||||
}
|
||||
|
||||
// Triangulate to handle concave polygons correctly
|
||||
List<int> tris = PolygonUtils.Triangulate(pts);
|
||||
Handles.color = fill;
|
||||
for(int i = 0; i + 2 < tris.Count; i += 3) {
|
||||
Handles.DrawAAConvexPolygon(verts[tris[i]], verts[tris[i + 1]], verts[tris[i + 2]]);
|
||||
}
|
||||
|
||||
Handles.color = border;
|
||||
for(int i = 0; i < pts.Count; i++) {
|
||||
Handles.DrawLine(verts[i], verts[(i + 1) % pts.Count], 2f);
|
||||
}
|
||||
|
||||
// Zone label at centroid
|
||||
Vector2 centroid2D = PolygonUtils.Centroid(pts);
|
||||
Vector3 labelPos = PolyPointToWorld(centroid2D);
|
||||
Handles.Label(labelPos, _zone.data.zoneName, new GUIStyle(EditorStyles.boldLabel) {
|
||||
normal = { textColor = Color.white },
|
||||
fontSize = 11
|
||||
});
|
||||
}
|
||||
|
||||
private void DrawCircleRadiusHandle() {
|
||||
Vector2 center = PolygonUtils.Centroid(_zone.data.polygon);
|
||||
Vector2 radiusPoint = center + new Vector2(_zone.data.circleRadius, 0f);
|
||||
Vector3 worldRadiusPoint = PolyPointToWorld(radiusPoint);
|
||||
float size = HandleUtility.GetHandleSize(worldRadiusPoint) * 0.1f;
|
||||
|
||||
Handles.color = Color.cyan;
|
||||
EditorGUI.BeginChangeCheck();
|
||||
Vector3 newWorld = Handles.FreeMoveHandle(worldRadiusPoint, size, Vector3.zero, Handles.DotHandleCap);
|
||||
if(EditorGUI.EndChangeCheck()) {
|
||||
Undo.RecordObject(_zone.data, "Change Circle Radius");
|
||||
Vector2 newPlane = WorldToPolyPoint(newWorld);
|
||||
float newRadius = Mathf.Max(0.1f, Vector2.Distance(center, newPlane));
|
||||
_zone.data.circleRadius = newRadius;
|
||||
ShapeFactory.RegenerateCircle(_zone.data);
|
||||
EditorUtility.SetDirty(_zone.data);
|
||||
_zone.RebuildBoundsCache();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawVertexHandles() {
|
||||
List<Vector2> pts = _zone.data.polygon;
|
||||
if(pts == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Event e = Event.current;
|
||||
|
||||
for(int i = 0; i < pts.Count; i++) {
|
||||
Vector3 worldPos = PolyPointToWorld(pts[i]);
|
||||
float size = HandleUtility.GetHandleSize(worldPos) * 0.08f;
|
||||
|
||||
// Shift+Click → delete vertex (minimum 3)
|
||||
if(e.shift && e.type == EventType.MouseDown && e.button == 0) {
|
||||
if(HandleUtility.DistanceToCircle(worldPos, size) < size && pts.Count > 3) {
|
||||
Undo.RecordObject(_zone.data, "Delete Zone Vertex");
|
||||
pts.RemoveAt(i);
|
||||
EditorUtility.SetDirty(_zone.data);
|
||||
_zone.RebuildBoundsCache();
|
||||
e.Use();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Handles.color = e.shift ? Color.red : Color.yellow;
|
||||
|
||||
EditorGUI.BeginChangeCheck();
|
||||
Vector3 newWorld = Handles.FreeMoveHandle(worldPos, size, Vector3.zero, Handles.DotHandleCap);
|
||||
|
||||
if(EditorGUI.EndChangeCheck()) {
|
||||
Undo.RecordObject(_zone.data, "Move Zone Vertex");
|
||||
pts[i] = WorldToPolyPoint(newWorld);
|
||||
EditorUtility.SetDirty(_zone.data);
|
||||
_zone.RebuildBoundsCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleEdgeInsert() {
|
||||
List<Vector2> pts = _zone.data.polygon;
|
||||
if(pts == null || pts.Count < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
Event e = Event.current;
|
||||
|
||||
// Highlight the closest edge while Ctrl is held for visual feedback
|
||||
if(e.control) {
|
||||
int previewEdge = FindClosestEdge(pts, out _, out float previewDist);
|
||||
if(previewEdge >= 0 && previewDist < 20f) {
|
||||
Vector3 a = PolyPointToWorld(pts[previewEdge]);
|
||||
Vector3 b = PolyPointToWorld(pts[(previewEdge + 1) % pts.Count]);
|
||||
Handles.color = Color.cyan;
|
||||
Handles.DrawLine(a, b, 4f);
|
||||
HandleUtility.Repaint();
|
||||
}
|
||||
}
|
||||
|
||||
if(!e.control || e.type != EventType.MouseDown || e.button != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 20px screen-space tolerance — camera-distance independent
|
||||
int bestEdge = FindClosestEdge(pts, out Vector3 insertPoint, out float dist);
|
||||
if(bestEdge >= 0 && dist < 20f) {
|
||||
Undo.RecordObject(_zone.data, "Insert Zone Vertex");
|
||||
pts.Insert(bestEdge + 1, WorldToPolyPoint(insertPoint));
|
||||
EditorUtility.SetDirty(_zone.data);
|
||||
_zone.RebuildBoundsCache();
|
||||
e.Use();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the polygon edge closest to the mouse in screen space (pixels).
|
||||
/// Returns the edge index to insert after, the world-space insertion point, and pixel distance.
|
||||
/// Screen-space comparison means tolerance is camera-distance independent.
|
||||
/// </summary>
|
||||
private int FindClosestEdge(List<Vector2> pts, out Vector3 closestPoint, out float closestPixelDist) {
|
||||
closestPoint = Vector3.zero;
|
||||
closestPixelDist = float.MaxValue;
|
||||
int bestEdge = -1;
|
||||
|
||||
Vector2 mouseGUI = Event.current.mousePosition;
|
||||
|
||||
for(int i = 0; i < pts.Count; i++) {
|
||||
Vector3 a = PolyPointToWorld(pts[i]);
|
||||
Vector3 b = PolyPointToWorld(pts[(i + 1) % pts.Count]);
|
||||
Vector2 aScreen = HandleUtility.WorldToGUIPoint(a);
|
||||
Vector2 bScreen = HandleUtility.WorldToGUIPoint(b);
|
||||
|
||||
Vector2 ab = bScreen - aScreen;
|
||||
float len = ab.sqrMagnitude;
|
||||
float t = len > 0.0001f
|
||||
? Mathf.Clamp01(Vector2.Dot(mouseGUI - aScreen, ab) / len)
|
||||
: 0f;
|
||||
|
||||
float pixelDist = Vector2.Distance(mouseGUI, aScreen + (ab * t));
|
||||
|
||||
if(pixelDist < closestPixelDist) {
|
||||
closestPixelDist = pixelDist;
|
||||
bestEdge = i;
|
||||
closestPoint = Vector3.Lerp(a, b, t);
|
||||
}
|
||||
}
|
||||
|
||||
return bestEdge;
|
||||
}
|
||||
|
||||
// ── Re-center ────────────────────────────────────────────────────
|
||||
|
||||
private void RecenterTransformOnZone() {
|
||||
List<Vector2> pts = _zone.data.polygon;
|
||||
if(pts == null || pts.Count == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2 centroid = PolygonUtils.Centroid(pts);
|
||||
if(centroid.sqrMagnitude < 0.001f) {
|
||||
return;
|
||||
}
|
||||
|
||||
Undo.RecordObject(_zone.data, "Center Transform on Zone");
|
||||
Undo.RecordObject(_zone.transform, "Center Transform on Zone");
|
||||
|
||||
// Shift all polygon points so the centroid becomes (0,0)
|
||||
for(int i = 0; i < pts.Count; i++) {
|
||||
pts[i] -= centroid;
|
||||
}
|
||||
|
||||
// Move the transform so the zone stays in the same world position
|
||||
Vector3 worldOffset = MapPlaneUtility.UnprojectFromPlane(centroid, ActivePlane, 0f);
|
||||
_zone.transform.position += worldOffset;
|
||||
|
||||
EditorUtility.SetDirty(_zone.data);
|
||||
EditorUtility.SetDirty(_zone.transform);
|
||||
_zone.RebuildBoundsCache();
|
||||
SceneView.RepaintAll();
|
||||
}
|
||||
|
||||
// ── Duplication ─────────────────────────────────────────────────
|
||||
|
||||
private void DuplicateZone() {
|
||||
if(_zone.data == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ZoneData original = _zone.data;
|
||||
ZoneEditorSettings settings = ZoneEditorSettings.FindOrCreateSettings();
|
||||
string folder = settings.zoneDataFolder;
|
||||
|
||||
// Create independent ZoneData copy
|
||||
ZoneData copy = ScriptableObject.CreateInstance<ZoneData>();
|
||||
EditorUtility.CopySerialized(original, copy);
|
||||
copy.zoneId = original.zoneId + "_" + System.Guid.NewGuid().ToString("N").Substring(0, 6);
|
||||
copy.zoneName = original.zoneName + " (Copy)";
|
||||
|
||||
string newName = original.zoneName.Replace(" ", "_") + "_Copy";
|
||||
string newPath = AssetDatabase.GenerateUniqueAssetPath(
|
||||
System.IO.Path.Combine(folder, newName + ".asset"));
|
||||
|
||||
System.IO.Directory.CreateDirectory(
|
||||
System.IO.Path.Combine(Application.dataPath, "..", folder));
|
||||
AssetDatabase.CreateAsset(copy, newPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
// Create scene GameObject
|
||||
GameObject duplicate = Instantiate(_zone.gameObject, _zone.transform.parent);
|
||||
duplicate.name = copy.zoneName;
|
||||
Undo.RegisterCreatedObjectUndo(duplicate, "Duplicate Zone");
|
||||
|
||||
ZoneInstance dupInstance = duplicate.GetComponent<ZoneInstance>();
|
||||
dupInstance.data = copy;
|
||||
dupInstance.RebuildBoundsCache();
|
||||
|
||||
Vector3 offset = MapPlaneUtility.UnprojectFromPlane(new Vector2(1f, 1f), ActivePlane, 0f);
|
||||
duplicate.transform.position += offset;
|
||||
|
||||
Selection.activeGameObject = duplicate;
|
||||
SceneView.RepaintAll();
|
||||
|
||||
Debug.Log($"[ZoneSystem] Duplicated zone to {newPath}");
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
2
Editor/ZoneInstanceEditor.cs.meta
Normal file
2
Editor/ZoneInstanceEditor.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3ea29e8d3bc24ec49bdc2b279aaae4e9
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Sebastian Bularca
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
7
LICENSE.meta
Normal file
7
LICENSE.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3f8b14cf28bb13f49a26d4a350d60785
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
79
README.md
79
README.md
@@ -1,3 +1,78 @@
|
||||
# unity-zone-system
|
||||
# Jovian Zone System
|
||||
|
||||
A polygon-based zone system for defining map regions with encounter difficulty, modifiers, and safe areas. Tools for creating and managing zones included.
|
||||
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
7
README.md.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c7679ba6ca31ec4daaba7b32661c16a
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Runtime.meta
Normal file
8
Runtime.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 89e41bb3e8c252a419239691c021ac35
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
14
Runtime/Jovian.ZoneSystem.asmdef
Normal file
14
Runtime/Jovian.ZoneSystem.asmdef
Normal 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
|
||||
}
|
||||
7
Runtime/Jovian.ZoneSystem.asmdef.meta
Normal file
7
Runtime/Jovian.ZoneSystem.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 14a17a3524e6bed489ca921a325f8942
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
43
Runtime/MapPlane.cs
Normal file
43
Runtime/MapPlane.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.ZoneSystem {
|
||||
/// <summary>
|
||||
/// Defines which two world axes the map (and zone polygons) lie on.
|
||||
/// XY = flat sprite / UI map (Z is depth)
|
||||
/// XZ = 3D world map (Y is up) ← standard Unity 3D
|
||||
/// YZ = side-on map (X is depth)
|
||||
/// </summary>
|
||||
public enum MapPlane {
|
||||
XY,
|
||||
XZ,
|
||||
YZ
|
||||
}
|
||||
|
||||
public static class MapPlaneUtility {
|
||||
/// <summary>
|
||||
/// Projects a 3D world position onto the chosen map plane,
|
||||
/// returning a 2D point suitable for polygon testing.
|
||||
/// </summary>
|
||||
public static Vector2 ProjectToPlane(Vector3 worldPos, MapPlane plane) {
|
||||
switch(plane) {
|
||||
case MapPlane.XY: return new Vector2(worldPos.x, worldPos.y);
|
||||
case MapPlane.XZ: return new Vector2(worldPos.x, worldPos.z);
|
||||
case MapPlane.YZ: return new Vector2(worldPos.y, worldPos.z);
|
||||
default: return new Vector2(worldPos.x, worldPos.y);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs a 3D world position from a 2D polygon point on the chosen plane.
|
||||
/// The depth value fills the axis not covered by the plane.
|
||||
/// </summary>
|
||||
public static Vector3 UnprojectFromPlane(Vector2 point, MapPlane plane, float depth = 0f) {
|
||||
switch(plane) {
|
||||
case MapPlane.XY: return new Vector3(point.x, point.y, depth);
|
||||
case MapPlane.XZ: return new Vector3(point.x, depth, point.y);
|
||||
case MapPlane.YZ: return new Vector3(depth, point.x, point.y);
|
||||
default: return new Vector3(point.x, point.y, depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/MapPlane.cs.meta
Normal file
2
Runtime/MapPlane.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e9eeceb36a2fca741a5e4c3206a20d00
|
||||
200
Runtime/PolygonUtils.cs
Normal file
200
Runtime/PolygonUtils.cs
Normal file
@@ -0,0 +1,200 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.ZoneSystem {
|
||||
public static class PolygonUtils {
|
||||
/// <summary>
|
||||
/// Ray-casting point-in-polygon test (Jordan curve theorem).
|
||||
/// Works on any plane — caller projects the world position first via MapPlaneUtility.
|
||||
/// Handles edge and vertex cases robustly.
|
||||
/// </summary>
|
||||
/// <param name="point">2D point already projected onto the polygon's plane.</param>
|
||||
/// <param name="polygon">Polygon vertices in the same 2D space.</param>
|
||||
public static bool PointInPolygon(Vector2 point, List<Vector2> polygon) {
|
||||
if(polygon == null || polygon.Count < 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
float px = point.x;
|
||||
float py = point.y;
|
||||
bool inside = false;
|
||||
int count = polygon.Count;
|
||||
int j = count - 1;
|
||||
|
||||
for(int i = 0; i < count; i++) {
|
||||
float xi = polygon[i].x, yi = polygon[i].y;
|
||||
float xj = polygon[j].x, yj = polygon[j].y;
|
||||
|
||||
// Crossing test: does the edge (j→i) cross the horizontal ray from point?
|
||||
bool crosses = (yi > py) != (yj > py) &&
|
||||
px < ((xj - xi) * (py - yi) / (yj - yi)) + xi;
|
||||
|
||||
if(crosses) {
|
||||
inside = !inside;
|
||||
}
|
||||
|
||||
j = i;
|
||||
}
|
||||
|
||||
return inside;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overload that accepts a world position and projects it onto the given plane
|
||||
/// before testing — this is the primary API used by ZoneManager.
|
||||
/// </summary>
|
||||
public static bool PointInPolygon(Vector3 worldPos, List<Vector2> polygon, MapPlane plane) {
|
||||
Vector2 projected = MapPlaneUtility.ProjectToPlane(worldPos, plane);
|
||||
return PointInPolygon(projected, polygon);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the centroid of a polygon (for label placement in the editor).
|
||||
/// </summary>
|
||||
public static Vector2 Centroid(List<Vector2> polygon) {
|
||||
if(polygon == null || polygon.Count == 0) {
|
||||
return Vector2.zero;
|
||||
}
|
||||
|
||||
Vector2 sum = Vector2.zero;
|
||||
foreach(Vector2 pt in polygon) {
|
||||
sum += pt;
|
||||
}
|
||||
return sum / polygon.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the approximate axis-aligned bounding box of a polygon.
|
||||
/// Useful for a cheap pre-check before running the full ray-cast test.
|
||||
/// </summary>
|
||||
public static (Vector2 min, Vector2 max) Bounds(List<Vector2> polygon) {
|
||||
if(polygon == null || polygon.Count == 0) {
|
||||
return (Vector2.zero, Vector2.zero);
|
||||
}
|
||||
|
||||
Vector2 min = polygon[0], max = polygon[0];
|
||||
foreach(Vector2 pt in polygon) {
|
||||
if(pt.x < min.x) {
|
||||
min.x = pt.x;
|
||||
}
|
||||
if(pt.y < min.y) {
|
||||
min.y = pt.y;
|
||||
}
|
||||
if(pt.x > max.x) {
|
||||
max.x = pt.x;
|
||||
}
|
||||
if(pt.y > max.y) {
|
||||
max.y = pt.y;
|
||||
}
|
||||
}
|
||||
return (min, max);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fast AABB pre-check. Call this before PointInPolygon to skip the
|
||||
/// ray-cast for points clearly outside the bounding box.
|
||||
/// </summary>
|
||||
public static bool PointInBounds(Vector2 point, Vector2 min, Vector2 max) {
|
||||
return point.x >= min.x && point.x <= max.x &&
|
||||
point.y >= min.y && point.y <= max.y;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ear-clipping triangulation for simple (non-self-intersecting) polygons.
|
||||
/// Returns a list of triangle index triplets into the original vertex list.
|
||||
/// Supports both convex and concave polygons.
|
||||
/// </summary>
|
||||
public static List<int> Triangulate(List<Vector2> polygon) {
|
||||
List<int> triangles = new List<int>();
|
||||
int n = polygon.Count;
|
||||
if(n < 3) {
|
||||
return triangles;
|
||||
}
|
||||
|
||||
// Build index list
|
||||
List<int> indices = new List<int>(n);
|
||||
bool clockwise = SignedArea(polygon) < 0f;
|
||||
for(int i = 0; i < n; i++) {
|
||||
indices.Add(clockwise ? i : n - 1 - i);
|
||||
}
|
||||
|
||||
int remaining = n;
|
||||
int failSafe = remaining * 2;
|
||||
|
||||
int v = remaining - 1;
|
||||
while(remaining > 2) {
|
||||
if(failSafe-- <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
int u = v;
|
||||
if(u >= remaining) {
|
||||
u = 0;
|
||||
}
|
||||
v = u + 1;
|
||||
if(v >= remaining) {
|
||||
v = 0;
|
||||
}
|
||||
int w = v + 1;
|
||||
if(w >= remaining) {
|
||||
w = 0;
|
||||
}
|
||||
|
||||
if(IsEar(polygon, indices, u, v, w, remaining)) {
|
||||
triangles.Add(indices[u]);
|
||||
triangles.Add(indices[v]);
|
||||
triangles.Add(indices[w]);
|
||||
indices.RemoveAt(v);
|
||||
remaining--;
|
||||
failSafe = remaining * 2;
|
||||
}
|
||||
}
|
||||
|
||||
return triangles;
|
||||
}
|
||||
|
||||
private static float SignedArea(List<Vector2> polygon) {
|
||||
float area = 0f;
|
||||
int count = polygon.Count;
|
||||
for(int i = 0; i < count; i++) {
|
||||
Vector2 a = polygon[i];
|
||||
Vector2 b = polygon[(i + 1) % count];
|
||||
area += (b.x - a.x) * (b.y + a.y);
|
||||
}
|
||||
return area;
|
||||
}
|
||||
|
||||
private static bool IsEar(List<Vector2> polygon, List<int> indices, int u, int v, int w, int remaining) {
|
||||
Vector2 a = polygon[indices[u]];
|
||||
Vector2 b = polygon[indices[v]];
|
||||
Vector2 c = polygon[indices[w]];
|
||||
|
||||
// Must be convex (counter-clockwise winding after we've ensured CCW order)
|
||||
float cross = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
||||
if(cross <= 0f) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// No other vertex must be inside this triangle
|
||||
for(int p = 0; p < remaining; p++) {
|
||||
if(p == u || p == v || p == w) {
|
||||
continue;
|
||||
}
|
||||
if(PointInTriangle(polygon[indices[p]], a, b, c)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool PointInTriangle(Vector2 p, Vector2 a, Vector2 b, Vector2 c) {
|
||||
float d1 = (p.x - b.x) * (a.y - b.y) - (a.x - b.x) * (p.y - b.y);
|
||||
float d2 = (p.x - c.x) * (b.y - c.y) - (b.x - c.x) * (p.y - c.y);
|
||||
float d3 = (p.x - a.x) * (c.y - a.y) - (c.x - a.x) * (p.y - a.y);
|
||||
bool hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0);
|
||||
bool hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0);
|
||||
return !(hasNeg && hasPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/PolygonUtils.cs.meta
Normal file
2
Runtime/PolygonUtils.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c09971de26d1abf48ac379e5e8ac533c
|
||||
49
Runtime/ShapeFactory.cs
Normal file
49
Runtime/ShapeFactory.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.ZoneSystem {
|
||||
public static class ShapeFactory {
|
||||
public const int CircleSegments = 24;
|
||||
public const float DefaultRadius = 2f;
|
||||
public const float DefaultSquareHalf = 2f;
|
||||
public const float DefaultPolygonRadius = 3f;
|
||||
public const int DefaultPolygonVertices = 12;
|
||||
|
||||
public static List<Vector2> CreateSquare(float halfSize = DefaultSquareHalf) {
|
||||
return new List<Vector2> {
|
||||
new(-halfSize, -halfSize),
|
||||
new(-halfSize, halfSize),
|
||||
new(halfSize, halfSize),
|
||||
new(halfSize, -halfSize)
|
||||
};
|
||||
}
|
||||
|
||||
public static List<Vector2> CreateCircle(float radius = DefaultRadius, int segments = CircleSegments) {
|
||||
List<Vector2> points = new List<Vector2>(segments);
|
||||
float step = 2f * Mathf.PI / segments;
|
||||
for(int i = 0; i < segments; i++) {
|
||||
float angle = i * step;
|
||||
points.Add(new Vector2(Mathf.Cos(angle) * radius, Mathf.Sin(angle) * radius));
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
public static List<Vector2> CreatePolygon(float radius = DefaultPolygonRadius, int vertices = DefaultPolygonVertices) {
|
||||
return CreateCircle(radius, vertices);
|
||||
}
|
||||
|
||||
public static List<Vector2> CreateDefault(ZoneShape shape) {
|
||||
switch(shape) {
|
||||
case ZoneShape.Square: return CreateSquare();
|
||||
case ZoneShape.Circle: return CreateCircle();
|
||||
case ZoneShape.Polygon: return CreatePolygon();
|
||||
default: return CreateSquare();
|
||||
}
|
||||
}
|
||||
|
||||
public static void RegenerateCircle(ZoneData data) {
|
||||
data.polygon.Clear();
|
||||
data.polygon.AddRange(CreateCircle(data.circleRadius));
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/ShapeFactory.cs.meta
Normal file
2
Runtime/ShapeFactory.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 202475efe66c6304298b9073ef7627ea
|
||||
62
Runtime/ZoneData.cs
Normal file
62
Runtime/ZoneData.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.ZoneSystem {
|
||||
[CreateAssetMenu(fileName = "NewZone", menuName = "ZoneSystem/Zone Data")]
|
||||
public class ZoneData : ScriptableObject {
|
||||
[Header("Identity")]
|
||||
public string zoneId;
|
||||
|
||||
public string zoneName;
|
||||
public ZoneRole role = ZoneRole.Base;
|
||||
public int priority = 1;
|
||||
|
||||
[Header("Visual (Editor Only)")]
|
||||
public Color debugColor = new(1f, 0.5f, 0f, 0.25f);
|
||||
|
||||
// ── Base zone fields ────────────────────────────────────────────
|
||||
[Header("Base Zone Settings")]
|
||||
[Tooltip("Only used when Role = Base")]
|
||||
public string encounterTableId;
|
||||
|
||||
[Tooltip("Only used when Role = Base")]
|
||||
public DifficultyTier baseDifficultyTier = DifficultyTier.Mild;
|
||||
|
||||
[Tooltip("Base encounter chance per check (0..1). Only used when Role = Base")]
|
||||
[Range(0f, 1f)]
|
||||
public float baseEncounterChance = 0.2f;
|
||||
|
||||
// ── Modifier zone fields ─────────────────────────────────────────
|
||||
[Header("Modifier Zone Settings")]
|
||||
[Tooltip("Multiplied onto the base encounter chance. Only used when Role = Modifier")]
|
||||
public float encounterChanceMultiplier = 1f;
|
||||
|
||||
[Tooltip("Added to the base difficulty tier (clamped). Only used when Role = Modifier")]
|
||||
public int difficultyTierBonus;
|
||||
|
||||
// ── Override zone fields ─────────────────────────────────────────
|
||||
[Header("Override Zone Settings")]
|
||||
[Tooltip("If true, no encounters occur in this zone. Only used when Role = Override")]
|
||||
public bool isSafeZone;
|
||||
|
||||
[Tooltip("Only used when Role = Override and isSafeZone = false")]
|
||||
public string overrideEncounterTableId;
|
||||
|
||||
[Tooltip("Only used when Role = Override and isSafeZone = false")]
|
||||
[Range(0f, 1f)]
|
||||
public float overrideEncounterChance = 1f;
|
||||
|
||||
[Tooltip("Only used when Role = Override and isSafeZone = false")]
|
||||
public DifficultyTier overrideDifficultyTier = DifficultyTier.Deadly;
|
||||
|
||||
// ── Shape ────────────────────────────────────────────────────────
|
||||
[HideInInspector]
|
||||
public ZoneShape shape = ZoneShape.Square;
|
||||
|
||||
[HideInInspector]
|
||||
public float circleRadius = 2f;
|
||||
|
||||
[HideInInspector]
|
||||
public List<Vector2> polygon = new();
|
||||
}
|
||||
}
|
||||
2
Runtime/ZoneData.cs.meta
Normal file
2
Runtime/ZoneData.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8497d766078e5764a9c7c0dd5d671561
|
||||
91
Runtime/ZoneExporter.cs
Normal file
91
Runtime/ZoneExporter.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Jovian.ZoneSystem {
|
||||
/// <summary>
|
||||
/// Serializable representations for JSON export.
|
||||
/// Kept in Runtime so server-side or headless builds can also consume them.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class ZoneExportEntry {
|
||||
public string id;
|
||||
public string name;
|
||||
public string role;
|
||||
public int priority;
|
||||
|
||||
// Base
|
||||
public string encounterTableId;
|
||||
public int baseDifficultyTier;
|
||||
public float baseEncounterChance;
|
||||
|
||||
// Modifier
|
||||
public float encounterChanceMultiplier;
|
||||
public int difficultyTierBonus;
|
||||
|
||||
// Override
|
||||
public bool isSafeZone;
|
||||
public string overrideEncounterTableId;
|
||||
public float overrideEncounterChance;
|
||||
public int overrideDifficultyTier;
|
||||
|
||||
// Shape
|
||||
public string shape;
|
||||
public float circleRadius;
|
||||
public float[] position;
|
||||
public List<float[]> polygon;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class ZoneExportRoot {
|
||||
public List<ZoneExportEntry> zones = new();
|
||||
}
|
||||
|
||||
public static class ZoneExporter {
|
||||
public static ZoneExportRoot BuildExport(ZoneInstance[] instances, MapPlane plane = MapPlane.XZ) {
|
||||
ZoneExportRoot root = new ZoneExportRoot();
|
||||
|
||||
foreach(ZoneInstance inst in instances) {
|
||||
if(inst.data == null) {
|
||||
continue;
|
||||
}
|
||||
ZoneData d = inst.data;
|
||||
Vector3 pos = inst.transform.position;
|
||||
Vector2 origin = MapPlaneUtility.ProjectToPlane(pos, plane);
|
||||
|
||||
ZoneExportEntry entry = new ZoneExportEntry {
|
||||
id = d.zoneId,
|
||||
name = d.zoneName,
|
||||
role = d.role.ToString(),
|
||||
priority = d.priority,
|
||||
shape = d.shape.ToString(),
|
||||
circleRadius = d.circleRadius,
|
||||
position = new[] { pos.x, pos.y, pos.z },
|
||||
encounterTableId = d.encounterTableId,
|
||||
baseDifficultyTier = (int)d.baseDifficultyTier,
|
||||
baseEncounterChance = d.baseEncounterChance,
|
||||
encounterChanceMultiplier = d.encounterChanceMultiplier,
|
||||
difficultyTierBonus = d.difficultyTierBonus,
|
||||
isSafeZone = d.isSafeZone,
|
||||
overrideEncounterTableId = d.overrideEncounterTableId,
|
||||
overrideEncounterChance = d.overrideEncounterChance,
|
||||
overrideDifficultyTier = (int)d.overrideDifficultyTier,
|
||||
polygon = new List<float[]>()
|
||||
};
|
||||
|
||||
foreach(Vector2 pt in d.polygon) {
|
||||
Vector2 worldPt = pt + origin;
|
||||
entry.polygon.Add(new[] { worldPt.x, worldPt.y });
|
||||
}
|
||||
|
||||
root.zones.Add(entry);
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
public static string ToJson(ZoneExportRoot root, bool pretty = true) {
|
||||
return JsonUtility.ToJson(root, pretty);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/ZoneExporter.cs.meta
Normal file
2
Runtime/ZoneExporter.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 321272ab8f26941488d472164a97c162
|
||||
84
Runtime/ZoneInstance.cs
Normal file
84
Runtime/ZoneInstance.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/ZoneInstance.cs.meta
Normal file
2
Runtime/ZoneInstance.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 95af4f7ff0649854598833eabd84f131
|
||||
83
Runtime/ZoneResolver.cs
Normal file
83
Runtime/ZoneResolver.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/ZoneResolver.cs.meta
Normal file
2
Runtime/ZoneResolver.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 147813fb56be464458dc1c5be47057f4
|
||||
90
Runtime/ZoneSystem.cs
Normal file
90
Runtime/ZoneSystem.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
Runtime/ZoneSystem.cs.meta
Normal file
3
Runtime/ZoneSystem.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 366abba6f6364bbfb3d0564358ead42c
|
||||
timeCreated: 1772985323
|
||||
33
Runtime/ZoneTypes.cs
Normal file
33
Runtime/ZoneTypes.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
2
Runtime/ZoneTypes.cs.meta
Normal file
2
Runtime/ZoneTypes.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee375ef89c9f9574594736f1984be25f
|
||||
35
Runtime/ZonesObjectHolder.cs
Normal file
35
Runtime/ZonesObjectHolder.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
2
Runtime/ZonesObjectHolder.cs.meta
Normal file
2
Runtime/ZonesObjectHolder.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 514ac2296ff6032459b84681867b26cd
|
||||
15
package.json
Normal file
15
package.json
Normal 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
7
package.json.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f811a6af0d3ada34198308fce87fa482
|
||||
PackageManifestImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user