copy from github
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user