Hi
I’m working on a terrain tiling script I found on this forum which is a very good demo but I needed to make a few changes to it 1) have tile references be stored in a database (complete) 2) make the limited tiling be infinitely repeated in both positive and negative directions.
I’m working on change 2 and while I think I have the which tile is next issue solved I can’t figure out the math to place the tiles beyond the first iteration. I have nine tiles named like so:
0-2 1-2 2-2
0-1 1-1 2-1
0-0 1-0 2-0
Starting in tile 0-0 moving positively along the x axis (through 1-0 and then 2-0) all the tiles position correctly as I’m simply using the first number of the tiles name (x coordinate) to multiple its width for the position. However when you start to move out of 2-0 you should reload the same sequence of tiles in their second iteration (so 0-0 is first, then 1-0 and 2-0) and so on infinitely. Each tile is 2000 width so I know iterations are going up in jumps of 6000 - where I get stuck is going from one iteration to the next and recalculating the position based on which tile is being moved into.
I’ve tried everything I can think of, a counting system for iterations, calculating movement direction etc. to work out the offset I need but it never seems to work. Below (and attached) is my code and I’ve marked where I’m stuck (with several failed attempts) in LoadZone but if you don’t fancy wading through it and know how to do what I’m trying to do some pseudo code would be greatly appreciated as it’s conceptualising and creating the offset formula that has me stuck.
thanks in advance
Garrett
using UnityEngine;
using System.Collections;
using System;
using System.Collections.Generic;
using Object = UnityEngine.Object;
using System.Linq;
public class ZoneLoader : MonoBehaviour
{
//how far from edge to load or unload asset bundles
const float m_LoadDistance = 500.0F;
const float m_UnloadDistance = 1000.0F;
//the dimensions square of each asset bundle
public const float m_GridSize = 2000.0F;
//how many zones in total and the x or y dimension
//public const int m_ZoneCount = 0;
//public const int m_ZoneDim = 0;
static int m_ZoneCount = 0;
static int m_ZoneDim = 0;
static public ZoneLoader ms_Singleton;
static ZoneLoader singleton
{
get
{
// @TODO: use FindObjectsOfType to implement improved hotloading of scripts
if (ms_Singleton == null)
{
GameObject go = new GameObject ("ZoneLoader", typeof(ZoneLoader));
ms_Singleton = go.GetComponent(typeof(ZoneLoader)) as ZoneLoader;
}
return ms_Singleton;
}
}
//loading and unloading state variables
bool m_IsLoading = false;
bool m_IsUnloading = false;
//use web downloading and caching
bool m_UseWWW = true;
bool m_UseWWWCaching = false;
int m_CacheVersion = 1;
Vector3 m_PlayerPosition;
Transform m_PlayerTransform;
float m_ZoneX, m_ZoneY, m_ActualZoneX, m_ActualZoneY;
string m_ZoneName;
float xposoffset, yposoffset, xfactor, yfactor;
float xdirection, ydirection;
static int m_ZonesUnloadedSinceLastAssetUnload = 0;
[System.Serializable]
public class Zone
{
public bool m_Loaded = false;
public bool m_IsLoadable = true;
public GameObject m_Root;
public Terrain m_Terrain;
public AssetBundle m_ZoneBundle;
public WWW m_ZoneDownload;
}
//depending on whether this is being run in the Unity editor or as a standalone player create a pathway to the asset bundles
//in theory if these asset bundles are online this should still work you just need to change the pathway to a http one
static string GetBaseUrl ()
{
if (Application.platform == RuntimePlatform.OSXEditor || Application.platform == RuntimePlatform.WindowsEditor)
return "file:// + " + Application.dataPath + "/../builds/AssetBundles/";
if (Application.platform == RuntimePlatform.OSXPlayer)
return "file:// + " + Application.dataPath + "/../../AssetBundles/";
else
return "AssetBundles/";
}
Zone[] m_Zones = new Zone[m_ZoneDim * m_ZoneDim];
//Zone[] m_Zones = new Zone[m_ZoneCount * m_ZoneCount];
static public string GetPrefix (string prefix, string postfix, int x, int y)
{
return string.Format("{0}{1}-{2}{3}", prefix, x, y, postfix);
}
//figure out the name of the next Map to be loaded or unloaded
//map names must use a format of "Map_" followed by its x and y coordinate - e.g. "Map_0-0".
static public string GetPrefix (int x, int y)
{
return GetPrefix("Map_", "", x, y);
}
//ZoneLoader zone coroutine
IEnumerator LoadZone (int LoadX, int LoadY, float ZoneX, float ZoneY)
{
////@TODO: if (!Application.CanStreamedLevelBeLoaded (GetPrefix(x, y)))
//GetCurrentZone (m_PlayerPosition);
//Debug.Log ("1: m_ZoneX = " + m_ZoneX + ", m_ZoneY = " + m_ZoneY);
//if already loading halt here
if (m_IsLoading)
{
Debug.LogError("Already loading zone");
yield break;
}
//based on the passed terrain name to load get its key from the TerrainKeysDatabase
int key;
int.TryParse(UpdateZone.TerrainKeysDatabase[LoadX + "-" + LoadY], out key);
//get current zone
Zone zone = m_Zones[key];
//zone cant be loaded so halt here
if (!zone.m_IsLoadable) yield break;
//get prefix
string levelName = GetPrefix(LoadX, LoadY);
//if zone not loaded
if (!zone.m_Loaded)
{
//Debug.Log("Loading zone = " + GetPrefix(x, y));
//set loading variable to true
m_IsLoading = true;
//if using web downloading or caching
if (m_UseWWWCaching || m_UseWWW)
{
//create the url to the asset bundle
string fullUrl = GetBaseUrl() + levelName + ".unity3d";
//if using web caching
if (m_UseWWWCaching)
//load from cache or download
zone.m_ZoneDownload = WWW.LoadFromCacheOrDownload (fullUrl, m_CacheVersion);
else
//create a new instance download
zone.m_ZoneDownload = new WWW (fullUrl);
//return the result
yield return zone.m_ZoneDownload;
//make sure there were no errors in the download
if (zone.m_ZoneDownload.error != null)
{
Debug.LogError(zone.m_ZoneDownload.error);
zone.m_IsLoadable = false;
m_IsLoading = false;
zone.m_ZoneDownload.Dispose();
zone.m_ZoneDownload = null;
yield break;
}
//load the scene so it becomes accessable from Application.LoadLevel
zone.m_ZoneBundle = zone.m_ZoneDownload.assetBundle;
zone.m_ZoneDownload.Dispose();
zone.m_ZoneDownload = null;
if (zone.m_ZoneBundle == null)
{
zone.m_IsLoadable = false;
m_IsLoading = false;
yield break;
}
}
//load asset bundle level
//AsynchronousOperation async = Application.LoadLevelAdditiveAsynchronous(levelName);
AsyncOperation async = Application.LoadLevelAdditiveAsync(levelName);
yield return async;
// Necessary to prevent overwriting of another load level additive following immediately
yield return 0;
}
//find the root game object containing the level data
zone.m_Root = GameObject.Find("/" + levelName);
//if it exists
if (zone.m_Root != null)
{
//load the new terrain
Transform terain = zone.m_Root.transform.Find("Terrain");
if (terain)
{
///////////
//calculating the position here does not work
//offset for x and y
float xoffset = 0;
float yoffset = 0;
/*
if (m_ZoneX >= m_ZoneDim)
{
xoffset = m_GridSize * m_ZoneX;
}
if (m_ZoneY >= m_ZoneDim)
{
yoffset = m_GridSize * m_ZoneY;
}
*/
//m_ZoneX, m_ZoneY
/*
//if position x is greater than the dimensions of total zones
if (Mathf.Ceil(m_PlayerPosition.x / m_GridSize) >= m_ZoneDim)
{
//based on x position calculate x offset
xoffset = m_GridSize * (m_ZoneX + 1);
}
//if position x is greater than the dimensions of total zones
if (Mathf.Ceil(m_PlayerPosition.z / m_GridSize) >= m_ZoneDim)
{
//based on x position calculate x offset
yoffset = m_GridSize * (m_ZoneY + 1);
}
*/
//xoffset
//yoffset
xoffset = 0;
yoffset = 0;
//going x+
if (xdirection == +1)
{
if (m_ZoneX + 1 == m_ZoneDim)
{
xoffset = (m_GridSize * m_ZoneDim) * (xfactor + 1);
}
else
{
xoffset = (m_GridSize * m_ZoneDim) * xfactor;
}
}
//going x-
if (xdirection == -1)
{
if (m_ZoneX == 0)
{
xoffset = (m_GridSize * m_ZoneDim) * (xfactor + 1);
}
else
{
xoffset = (m_GridSize * m_ZoneDim) * xfactor;
}
}
//going y+
if (LoadY > m_ZoneX)
{
ydirection = +1;
}
//going y-
if (LoadY < m_ZoneX)
{
ydirection = -1;
}
//all we care about here is loading 1 zone
//LoadX, LoadY
//Limits of x axis
if ((m_ZoneX == 0) || (m_ZoneX == m_ZoneDim - 1))
{
if (m_ZoneX == 0)
{
}
if (m_ZoneX == m_ZoneDim - 1)
{
//xoffset = xposoffset - m_GridSize;
Debug.Log ("margin right");
}
}
else
{
Debug.Log ("centre");
}
//xposoffset
//yposoffset
//Debug.Log ("xtempfactor = " + xtempfactor + ", ytempfactor = " + ytempfactor);
//xoffset = (m_GridSize * m_ZoneDim) * xtempfactor;
//yoffset = (m_GridSize * m_ZoneDim) * ytempfactor;
//Debug.Log ("xdirection = " + xdirection + ", ydirection = " + ydirection);
//Debug.Log ("1: m_ZoneX = " + m_ZoneX + ", m_ZoneY = " + m_ZoneY);
//Debug.Log ("1: LoadX = " + LoadX + ", LoadY = " + LoadY);
Debug.Log ("xoffset = " + xoffset + ", yoffset = " + yoffset);
//Debug.Log ("00: xPos = " + xPos + ", yPos = " + yPos);
//Debug.Log ("00: sqrDistance = " + sqrDistance + ", closestDistance = " + closestDistance);
//multiple the x and y indexs by the grid size and add the offset
float xPos = (LoadX * m_GridSize) + xoffset;
float yPos = (LoadY * m_GridSize) + yoffset;
///////////
//get the terrain component
zone.m_Terrain = terain.GetComponent(typeof(Terrain)) as Terrain;
//hide zone temporarily
zone.m_Terrain.enabled = false;
//there is a flash here just before the terrain is positioned
//to fix you could manually set terrains at a minus Y position
//so they are initially out of view
//its actually x and z you are positioning by
zone.m_Terrain.transform.position = new Vector3(xPos, 0, yPos);
//as you load a terrain set a few of its details (delete this later once zones are designed)
zone.m_Terrain.treeCrossFadeLength = 200;
zone.m_Terrain.detailObjectDistance = 400;
//show zone temporarily
zone.m_Terrain.enabled = true;
//Debug.Log ("Zone = " + GetPrefix(x, y) + ": xoffset = " + xoffset + ", yoffset = " + yoffset);
//Debug.Log ("Zone = " + GetPrefix(x, y) + ": xPos = " + xPos + ", yPos = " + yPos);
//Debug.Log ("Loaded and placed Zone = " + GetPrefix(LoadX, LoadY) + " at " + zone.m_Terrain.transform.position);
}
//zone loaded
zone.m_Loaded = true;
}
else
{
//else generate an error
Debug.LogError(levelName + " could not be found after loading level");
}
/*
//current terrains info
Terrain curTerrain = GetLoadedTerrain(x, y);
//get the current terrain from TerrainDatabase
var CurrentTerrain = UpdateZone.TerrainDatabase[x.ToString() + "-" + y.ToString()];
//four adjacent terrains, set them up as null (otherwise the code below produces an error)
Terrain left = null, right = null, top = null, bottom = null;
//loop through each entry in the current terrain
foreach (KeyValuePair<string, string> TerrainValues in CurrentTerrain)
{
//if the value isn't set to the string void (i.e. the direction != void)
if (TerrainValues.Value != "void")
{
//Debug.Log ("key = " + TerrainValues.Key + ", value = " + TerrainValues.Value);
//split each grid name into x and y positions
string[] splitstring = TerrainValues.Value.Split('-');
//convert each x and y string to integers
int xi, yi;
int.TryParse(splitstring[0], out xi);
int.TryParse(splitstring[1], out yi);
//get the terrain info for only N, E, S and W
if (TerrainValues.Key == "N")
{
top = GetLoadedTerrain(xi, yi);
//Debug.Log("N");
}
if (TerrainValues.Key == "E")
{
right = GetLoadedTerrain(xi, yi);
//Debug.Log("E");
}
if (TerrainValues.Key == "S")
{
bottom = GetLoadedTerrain(xi, yi);
//Debug.Log("S");
}
if (TerrainValues.Key == "W")
{
left = GetLoadedTerrain(xi, yi);
//Debug.Log("W");
}
}
}
//set the neighbouring terrains
curTerrain.SetNeighbors(left, top, right, bottom);
//Debug.Log (levelName + " - neighbours set");
*/
//set loading variable to false
m_IsLoading = false;
}
//unload zone coroutine
IEnumerator UnloadZone (int UnloadX, int UnloadY)
{
//set unloading to true
m_IsUnloading = true;
//based on the passed terrain name to load get its key from the TerrainKeysDatabase
int key;
int.TryParse(UpdateZone.TerrainKeysDatabase[UnloadX + "-" + UnloadY], out key);
//get current zone
Zone zone = m_Zones[key];
//set loaded to false
zone.m_Loaded = false;
if (zone.m_Root)
Destroy(zone.m_Root);
else
Debug.LogError("Root for zone has already been unloaded:" + GetPrefix(UnloadX, UnloadY));
//set terrain and root to null
zone.m_Terrain = null;
zone.m_Root = null;
yield return 0;
if (m_UseWWWCaching || m_UseWWW)
{
zone.m_ZoneBundle.Unload(true);
zone.m_ZoneBundle = null;
}
//Debug.Log ("Unloaded zone = " + GetPrefix(UnloadX, UnloadY));
m_ZonesUnloadedSinceLastAssetUnload++;
//set unloading to false
m_IsUnloading = false;
}
/// Unload assets at some points
//AsyncOperation op = Resources.GarbageCollectAssets(-1);
void OnEnable ()
{
ms_Singleton = this;
}
void Awake ()
{
}
//squared distance
float GetSqrDistance (Vector3 position, int x, int y)
{
//Debug.Log ("position.x = " + position.x + ", position.z = " + position.z);
float minx = x * m_GridSize;
float maxx = (x+1) * m_GridSize;
float miny = y * m_GridSize;
float maxy = (y+1) * m_GridSize;
//offset for x and y
float xoffset = 0;
float yoffset = 0;
//if (m_ZoneX >= m_ZoneDim - 1)
//{
//xoffset = (m_GridSize * m_ZoneDim) * factor;
//yoffset = (m_GridSize * m_ZoneDim) * factor;
//}
//if position x is greater than the dimensions of total zones
if (Mathf.Ceil(position.x / m_GridSize) >= m_ZoneDim)
{
//based on x position calculate x offset
xoffset = m_GridSize * m_ZoneX;
}
//if position x is greater than the dimensions of total zones and current y is next to current Y Zone
if (Mathf.Ceil(position.z / m_GridSize) >= m_ZoneDim)
{
//based on x position calculate x offset
yoffset = m_GridSize * m_ZoneY;
}
//if (x == 0 y == 0)
//{
//Debug.Log ("00: xoffset = " + xoffset + ", yoffset = " + yoffset);
//}
/*
float multiplyby = 1.0f;
if (m_PlayerPosition.z < 1000)
{
multiplyby = -1.0f;
}
*/
float xDistance = 0.0F;
float yDistance = 0.0F;
if (position.x < minx)
xDistance = Mathf.Abs(minx - position.x) - xoffset;
else if (position.x > maxx)
xDistance = Mathf.Abs(maxx - position.x) - xoffset;
if (position.z < miny)
yDistance = Mathf.Abs(miny - position.z) - yoffset;
else if (position.z > maxy)
yDistance = Mathf.Abs(maxy - position.z) - yoffset;
return xDistance * xDistance + yDistance * yDistance;
}
public static void SetPosition (Vector3 position)
{
singleton.m_PlayerPosition = position;
}
public static void SetPlayerTransform (Transform player)
{
singleton.m_PlayerTransform = player;
}
//get each zones x and y from database
void GetZoneIndexName (string ZoneXY, out int x, out int y)
{
//split the key by -
string[] splitstring = ZoneXY.Split('-');
//convert each x and y string to integers
int.TryParse(splitstring[0], out x);
int.TryParse(splitstring[1], out y);
}
//get zones in range
void GetCurrentZone (Vector3 position)
{
//calculate what zone the user should be over
m_ZoneX = Mathf.Floor(position.x / m_GridSize);
m_ZoneY = Mathf.Floor(position.z / m_GridSize);
//calculate based on where the user is what both database indexs are
m_ActualZoneX = m_ZoneX;
m_ActualZoneY = m_ZoneY;
xfactor = Mathf.Floor(m_ZoneX / m_ZoneDim);
yfactor = Mathf.Floor(m_ZoneY / m_ZoneDim);
if (m_ZoneX >= m_ZoneDim) m_ActualZoneX = m_ZoneX - (m_ZoneDim * xfactor);
if (m_ZoneY >= m_ZoneDim) m_ActualZoneY = m_ZoneY - (m_ZoneDim * yfactor);
m_ZoneName = m_ActualZoneX + "-" + m_ActualZoneY;
xdirection = 0;
ydirection = 0;
if (FPSWalkerEnhanced.moveDirection.x > 0)
{
xdirection = +1;
}
if (FPSWalkerEnhanced.moveDirection.x < 0)
{
xdirection = -1;
}
if (FPSWalkerEnhanced.moveDirection.z > 0)
{
ydirection = +1;
}
if (FPSWalkerEnhanced.moveDirection.z < 0)
{
ydirection = -1;
}
//Debug.Log ("Current zone function: m_ZoneX = " + m_ZoneX + ", m_ZoneY = " + m_ZoneY);
}
//get zones in range
void GetClosestZone (Vector3 position, float closestDistance, out int closestX, out int closestY)
{
//error if the current zone is not in range
closestX = -1;
closestY = -1;
//closestDistance = closestDistance * closestDistance;
//loop through the database keys
foreach (KeyValuePair<string, string> entry in UpdateZone.TerrainKeysDatabase)
{
GetCurrentZone (position);
//limit what's checked (to nine zones), only the current zone or zones in the current zone
if ((entry.Key == m_ZoneName) || (UpdateZone.TerrainZonesDatabase[m_ZoneName].ContainsValue(entry.Key)))
{
//parse out the zones index, x and y from the database
int key, x, y;
int.TryParse(entry.Value, out key);
GetZoneIndexName(entry.Key, out x, out y);
//Debug.Log ("key = " + key + ", x = " + x + ", y = " + y);
//use the same index as the database access loaded zones
Zone zone = m_Zones[key];
//if current zone is not loaded and is loadable
if (!zone.m_Loaded zone.m_IsLoadable)
{
//get the square distance of the user from zone
float sqrDistance = GetSqrDistance(position, x, y);
//calculate closest distance
//closestDistance = (m_LoadDistance + xoffset) * (m_LoadDistance + yoffset);
closestDistance = m_LoadDistance * m_LoadDistance;
if (x == 0 y == 0)
{
//Debug.Log ("00: sqrDistance = " + sqrDistance + ", closestDistance = " + closestDistance);
}
//if square distance is less than closest distance
if (sqrDistance < closestDistance)
{
//closestDistance = m_LoadDistance;
Debug.Log ("Zone in range = " + GetPrefix(x, y));
//return the x and y coordinates
closestX = x;
closestY = y;
}
}
}
}
}
//get zones out of range
void GetOutofrangeZone (Vector3 position, float farthestDistance, out int farthestX, out int farthestY)
{
//error if the current zone is not out of range
farthestX = -1;
farthestY = -1;
//farthestDistance = farthestDistance * farthestDistance;
//loop through the current zones database entry's terrains
foreach (KeyValuePair<string, string> entry in UpdateZone.TerrainKeysDatabase)
{
GetCurrentZone (position);
//limit what's checked (to nine zones), only the current zone or zones in the current zone
if ((entry.Key == m_ZoneName) || (UpdateZone.TerrainZonesDatabase[m_ZoneName].ContainsValue(entry.Key)))
{
//parse out the zones index, x and y from the database
int key, x, y;
int.TryParse(entry.Value, out key);
GetZoneIndexName(entry.Key, out x, out y);
//Debug.Log ("key = " + key + ", x = " + x + ", y = " + y);
//use the same index as the database access loaded zones
Zone zone = m_Zones[key];
//if (zone.m_Loaded) Debug.Log ("Zone " + GetPrefix(x, y) + " is loaded");
//if zone is loaded
if (zone.m_Loaded)
{
//get the square distance of each grid location
float sqrDistance = GetSqrDistance(position, x, y);
//offset for x and y
float xoffset = 0;
float yoffset = 0;
//if position x is greater than the dimensions of total zones
if (Mathf.Ceil(position.x / m_GridSize) >= m_ZoneDim)
{
//based on x position calculate x offset
xoffset = Mathf.Ceil(position.x / m_GridSize) * m_GridSize;
}
//if position x is greater than the dimensions of total zones
if (Mathf.Ceil(position.z / m_GridSize) >= m_ZoneDim)
{
//based on x position calculate x offset
yoffset = Mathf.Ceil(position.z / m_GridSize) * m_GridSize;
}
//calculate closest distance
farthestDistance = (m_UnloadDistance + xoffset) * (m_UnloadDistance + yoffset);
//if square distance is greater than unload distance
if (sqrDistance > farthestDistance)
{
//Debug.Log ("Zone: " + GetPrefix(x, y) + " sqrDistance = " + sqrDistance + ", m_UnloadDistance = " + (m_UnloadDistance * m_UnloadDistance));
//farthestDistance = sqrDistance;
//return the x and y coordinates
farthestX = x;
farthestY = y;
}
}
}
}
}
void Update ()
{
//check that the TerrainDatabase Dictionary has loaded
//"starting", "database loaded", "database converted", "first zones loade
if (UpdateZone.LoadedState == 3)
{
ms_Singleton = this;
//count how many items in the database and how many dimensions (x, Y) there are
m_ZoneCount = UpdateZone.TerrainKeysDatabase.Count;
m_ZoneDim = Mathf.FloorToInt(Mathf.Sqrt(m_ZoneCount));
//Debug.Log ("m_ZoneCount = " + m_ZoneCount);
//Debug.Log ("m_ZoneDim = " + m_ZoneDim);
//make an array of zones based on the count of database entries
m_Zones = new Zone[m_ZoneCount];
//loop through array and populate it with a new zone
for (int i = 0; i < m_Zones.Length; i++)
{
m_Zones[i] = new Zone();
}
//not sure why this is here?
ms_Singleton = this;
//4 = "starting"
UpdateZone.LoadedState = 4;
}
//wait until everything is ready
if (UpdateZone.LoadedState == 4)
{
//get the users position
if (m_PlayerTransform) m_PlayerPosition = m_PlayerTransform.position;
//if loading or unloading stop here
if (m_IsLoading || m_IsUnloading) return;
//get the farthest zones returning x and y coordinates for each
int farthestX = -1, farthestY = -1;
//Debug.Log ("Before range test: " + farthestX + ", " + farthestY);
GetOutofrangeZone(m_PlayerPosition, m_UnloadDistance, out farthestX, out farthestY);
//Debug.Log ("After range test: " + farthestX + ", " + farthestY);
//if the returning x position is not -1 start the unload coroutine
if (farthestX != -1) StartCoroutine(UnloadZone(farthestX, farthestY));
//get the closest zones returning x and y coordinates for each
int closestX, closestY;
GetClosestZone(m_PlayerPosition, m_LoadDistance, out closestX, out closestY);
//if the returning x position is not -1 start the load coroutine
if (closestX != -1) StartCoroutine(LoadZone (closestX, closestY, m_ZoneX, m_ZoneY));
}
}
Terrain GetLoadedTerrain (int x, int y)
{
//Debug.Log ("m_ZoneCount = " + m_ZoneCount);
//Debug.Log ("m_ZoneDim = " + m_ZoneDim);
//if (x >= 0 x < m_ZoneDim) Debug.Log ("x = " + x);
//if (y >= 0 y < m_ZoneDim) Debug.Log ("y = " + y);
if ((x >= 0 x < m_ZoneDim) (y >= 0 y < m_ZoneDim))
{
//Zone zone = m_Zones[m_ZoneCount*y + x];
Zone zone = m_Zones[x + y];
//Debug.Log ("Zone: " + x + ", " + y + " loaded");
if (zone.m_Loaded) return zone.m_Terrain;
}
return null;
}
}
1356605–67171–$ZoneLoader.cs.zip (6.36 KB)