Volume Shapes (#1056)

## Major features

- Added a new `forces` sub-module to the `Volumes` planet module with
new volume types for the various force volumes available in the game,
including `directionalVolumes` (used by Nomai gravity crystals and
anti-gravity surfaces) and `cylindricalVolumes` (used by the rings on
the Construction Yard island), as well as `gravityVolumes`,
`polarVolumes`, and `radialVolumes`. Resolves #1041

## Minor features

- All volumes now support shapes other than the default sphere via the
new `shape` property, such as boxes, capsules, cylinders, hemispheres,
and more.
- All volumes now have `rotation` and `alignRadial` fields, allowing for
non-spherical shapes to be rotated, and for `revealVolumes` to be
pointed in other directions for the `maxAngle` check.
This commit is contained in:
xen-42 2025-04-19 00:33:03 -04:00 committed by GitHub
commit e07277c664
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1727 additions and 109 deletions

View File

@ -150,6 +150,7 @@ namespace NewHorizons.Builder.Body
streamingVolume.streamingGroup = streamingGroup;
streamingVolume.transform.parent = blackHoleVolume.transform;
streamingVolume.transform.localPosition = Vector3.zero;
streamingVolume.gameObject.SetActive(true);
}
}
catch (Exception e)

View File

@ -0,0 +1,170 @@
using NewHorizons.Components;
using NewHorizons.External.Modules.Props;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
namespace NewHorizons.Builder.Props
{
public static class ShapeBuilder
{
public static OWTriggerVolume AddTriggerVolume(GameObject go, ShapeInfo info, float defaultRadius)
{
var owTriggerVolume = go.AddComponent<OWTriggerVolume>();
if (info != null)
{
var shapeOrCol = AddShapeOrCollider(go, info);
if (shapeOrCol is Shape shape)
owTriggerVolume._shape = shape;
else if (shapeOrCol is Collider col)
owTriggerVolume._owCollider = col.GetComponent<OWCollider>();
}
else
{
var col = go.AddComponent<SphereCollider>();
col.radius = defaultRadius;
col.isTrigger = true;
var owCollider = go.GetAddComponent<OWCollider>();
owTriggerVolume._owCollider = owCollider;
}
return owTriggerVolume;
}
public static Component AddShapeOrCollider(GameObject go, ShapeInfo info)
{
if (info.useShape.HasValue)
{
// Explicitly add either a shape or collider if specified
if (info.useShape.Value)
return AddShape(go, info);
else
return AddCollider(go, info);
}
else
{
// Prefer colliders over shapes if no preference is specified
if (info.type is ShapeType.Sphere or ShapeType.Box or ShapeType.Capsule)
return AddCollider(go, info);
else
return AddShape(go, info);
}
}
public static Shape AddShape(GameObject go, ShapeInfo info)
{
if (info.hasCollision)
{
throw new NotSupportedException($"Shapes do not support collision; set {nameof(info.hasCollision)} to false or use a supported collider type (sphere, box, or capsule).");
}
if (info.useShape.HasValue && !info.useShape.Value)
{
throw new NotSupportedException($"{info.useShape} was explicitly set to false but a shape is required here.");
}
switch (info.type)
{
case ShapeType.Sphere:
var sphereShape = go.AddComponent<SphereShape>();
sphereShape._radius = info.radius;
sphereShape._center = info.offset ?? Vector3.zero;
return sphereShape;
case ShapeType.Box:
var boxShape = go.AddComponent<BoxShape>();
boxShape._size = info.size ?? Vector3.one;
boxShape._center = info.offset ?? Vector3.zero;
return boxShape;
case ShapeType.Capsule:
var capsuleShape = go.AddComponent<CapsuleShape>();
capsuleShape._radius = info.radius;
capsuleShape._direction = (int)info.direction;
capsuleShape._height = info.height;
capsuleShape._center = info.offset ?? Vector3.zero;
return capsuleShape;
case ShapeType.Cylinder:
var cylinderShape = go.AddComponent<CylinderShape>();
cylinderShape._radius = info.radius;
cylinderShape._height = info.height;
cylinderShape._center = info.offset ?? Vector3.zero;
cylinderShape._pointChecksOnly = true;
return cylinderShape;
case ShapeType.Cone:
var coneShape = go.AddComponent<ConeShape>();
coneShape._topRadius = info.innerRadius;
coneShape._bottomRadius = info.outerRadius;
coneShape._direction = (int)info.direction;
coneShape._height = info.height;
coneShape._center = info.offset ?? Vector3.zero;
coneShape._pointChecksOnly = true;
return coneShape;
case ShapeType.Hemisphere:
var hemisphereShape = go.AddComponent<HemisphereShape>();
hemisphereShape._radius = info.radius;
hemisphereShape._direction = (int)info.direction;
hemisphereShape._cap = info.cap;
hemisphereShape._center = info.offset ?? Vector3.zero;
hemisphereShape._pointChecksOnly = true;
return hemisphereShape;
case ShapeType.Hemicapsule:
var hemicapsuleShape = go.AddComponent<HemicapsuleShape>();
hemicapsuleShape._radius = info.radius;
hemicapsuleShape._direction = (int)info.direction;
hemicapsuleShape._height = info.height;
hemicapsuleShape._cap = info.cap;
hemicapsuleShape._center = info.offset ?? Vector3.zero;
hemicapsuleShape._pointChecksOnly = true;
return hemicapsuleShape;
case ShapeType.Ring:
var ringShape = go.AddComponent<RingShape>();
ringShape.innerRadius = info.innerRadius;
ringShape.outerRadius = info.outerRadius;
ringShape.height = info.height;
ringShape.center = info.offset ?? Vector3.zero;
ringShape._pointChecksOnly = true;
return ringShape;
default:
throw new ArgumentOutOfRangeException(nameof(info.type), info.type, $"Unsupported shape type");
}
}
public static Collider AddCollider(GameObject go, ShapeInfo info)
{
if (info.useShape.HasValue && info.useShape.Value)
{
throw new NotSupportedException($"{info.useShape} was explicitly set to true but a non-shape collider is required here.");
}
switch (info.type)
{
case ShapeType.Sphere:
var sphereCollider = go.AddComponent<SphereCollider>();
sphereCollider.radius = info.radius;
sphereCollider.center = info.offset ?? Vector3.zero;
sphereCollider.isTrigger = !info.hasCollision;
go.GetAddComponent<OWCollider>();
return sphereCollider;
case ShapeType.Box:
var boxCollider = go.AddComponent<BoxCollider>();
boxCollider.size = info.size ?? Vector3.one;
boxCollider.center = info.offset ?? Vector3.zero;
boxCollider.isTrigger = !info.hasCollision;
go.GetAddComponent<OWCollider>();
return boxCollider;
case ShapeType.Capsule:
var capsuleCollider = go.AddComponent<CapsuleCollider>();
capsuleCollider.radius = info.radius;
capsuleCollider.direction = (int)info.direction;
capsuleCollider.height = info.height;
capsuleCollider.center = info.offset ?? Vector3.zero;
capsuleCollider.isTrigger = !info.hasCollision;
go.GetAddComponent<OWCollider>();
return capsuleCollider;
default:
throw new ArgumentOutOfRangeException(nameof(info.type), info.type, $"Unsupported collider type");
}
}
}
}

View File

@ -25,7 +25,8 @@ namespace NewHorizons.Builder.Volumes
owAudioSource.SetTrack(info.track.ConvertToOW());
AudioUtilities.SetAudioClip(owAudioSource, info.audio, mod);
var audioVolume = go.AddComponent<AudioVolume>();
var audioVolume = PriorityVolumeBuilder.MakeExisting<AudioVolume>(go, planetGO, sector, info);
audioVolume._layer = info.layer;
audioVolume.SetPriority(info.priority);
audioVolume._fadeSeconds = info.fadeSeconds;
@ -33,11 +34,7 @@ namespace NewHorizons.Builder.Volumes
audioVolume._randomizePlayhead = info.randomizePlayhead;
audioVolume._pauseOnFadeOut = info.pauseOnFadeOut;
var shape = go.AddComponent<SphereShape>();
shape.radius = info.radius;
var owTriggerVolume = go.AddComponent<OWTriggerVolume>();
owTriggerVolume._shape = shape;
var owTriggerVolume = go.GetComponent<OWTriggerVolume>();
audioVolume._triggerVolumeOverride = owTriggerVolume;
go.SetActive(true);

View File

@ -13,6 +13,8 @@ namespace NewHorizons.Builder.Volumes
volume.TargetSolarSystem = info.targetStarSystem;
volume.TargetSpawnID = info.spawnPointID;
volume.gameObject.SetActive(true);
return volume;
}
}

View File

@ -16,6 +16,8 @@ namespace NewHorizons.Builder.Volumes
volume.deathType = info.deathType == null ? null : EnumUtils.Parse(info.deathType.ToString(), DeathType.Default);
volume.mod = mod;
volume.gameObject.SetActive(true);
return volume;
}
}

View File

@ -15,7 +15,8 @@ namespace NewHorizons.Builder.Volumes
var go = GeneralPropBuilder.MakeNew("DayNightAudioVolume", planetGO, sector, info);
go.layer = Layer.AdvancedEffectVolume;
var audioVolume = go.AddComponent<NHDayNightAudioVolume>();
var audioVolume = PriorityVolumeBuilder.MakeExisting<NHDayNightAudioVolume>(go, planetGO, sector, info);
audioVolume.sunName = info.sun;
audioVolume.dayWindow = info.dayWindow;
audioVolume.dayAudio = info.dayAudio;
@ -24,13 +25,7 @@ namespace NewHorizons.Builder.Volumes
audioVolume.volume = info.volume;
audioVolume.SetTrack(info.track.ConvertToOW());
var shape = go.AddComponent<SphereShape>();
shape.radius = info.radius;
var owTriggerVolume = go.AddComponent<OWTriggerVolume>();
owTriggerVolume._shape = shape;
go.SetActive(true);
audioVolume.gameObject.SetActive(true);
return audioVolume;
}

View File

@ -12,6 +12,8 @@ namespace NewHorizons.Builder.Volumes
volume._deathType = EnumUtils.Parse<DeathType>(info.deathType.ToString(), DeathType.Default);
volume.gameObject.SetActive(true);
return volume;
}
}

View File

@ -35,6 +35,8 @@ namespace NewHorizons.Builder.Volumes
volume._allowShipAutoroll = info.allowShipAutoroll;
volume._disableOnStart = info.disableOnStart;
volume.gameObject.SetActive(true);
return volume;
}
}

View File

@ -0,0 +1,111 @@
using NewHorizons.Builder.Props;
using NewHorizons.External;
using NewHorizons.External.Modules;
using NewHorizons.External.Modules.Volumes.VolumeInfos;
using NewHorizons.Utility.OuterWilds;
using OWML.Common;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
namespace NewHorizons.Builder.Volumes
{
public static class ForceVolumeBuilder
{
public static CylindricalForceVolume Make(GameObject planetGO, Sector sector, CylindricalForceVolumeInfo info)
{
var forceVolume = Make<CylindricalForceVolume>(planetGO, sector, info);
forceVolume._acceleration = info.force;
forceVolume._localAxis = info.normal ?? Vector3.up;
forceVolume._playGravityCrystalAudio = info.playGravityCrystalAudio;
forceVolume.gameObject.SetActive(true);
return forceVolume;
}
public static DirectionalForceVolume Make(GameObject planetGO, Sector sector, DirectionalForceVolumeInfo info)
{
var forceVolume = Make<DirectionalForceVolume>(planetGO, sector, info);
forceVolume._fieldDirection = info.normal ?? Vector3.up;
forceVolume._fieldMagnitude = info.force;
forceVolume._affectsAlignment = info.affectsAlignment;
forceVolume._offsetCentripetalForce = info.offsetCentripetalForce;
forceVolume._playGravityCrystalAudio = info.playGravityCrystalAudio;
forceVolume.gameObject.SetActive(true);
return forceVolume;
}
public static GravityVolume Make(GameObject planetGO, Sector sector, GravityVolumeInfo info)
{
var forceVolume = Make<GravityVolume>(planetGO, sector, info);
forceVolume._isPlanetGravityVolume = false;
forceVolume._setMass = false;
forceVolume._surfaceAcceleration = info.force;
forceVolume._upperSurfaceRadius = info.upperRadius;
forceVolume._lowerSurfaceRadius = info.lowerRadius;
forceVolume._cutoffAcceleration = info.minForce;
forceVolume._cutoffRadius = info.minRadius;
forceVolume._alignmentRadius = info.alignmentRadius ?? info.upperRadius * 1.5f;
forceVolume._falloffType = info.fallOff switch
{
GravityFallOff.Linear => GravityVolume.FalloffType.linear,
GravityFallOff.InverseSquared => GravityVolume.FalloffType.inverseSquared,
_ => throw new NotImplementedException(),
};
forceVolume.gameObject.SetActive(true);
return forceVolume;
}
public static PolarForceVolume Make(GameObject planetGO, Sector sector, PolarForceVolumeInfo info)
{
var forceVolume = Make<PolarForceVolume>(planetGO, sector, info);
forceVolume._acceleration = info.force;
forceVolume._localAxis = info.normal ?? Vector3.up;
forceVolume._fieldMode = info.tangential ? PolarForceVolume.ForceMode.Tangential : PolarForceVolume.ForceMode.Polar;
forceVolume.gameObject.SetActive(true);
return forceVolume;
}
public static RadialForceVolume Make(GameObject planetGO, Sector sector, RadialForceVolumeInfo info)
{
var forceVolume = Make<RadialForceVolume>(planetGO, sector, info);
forceVolume._acceleration = info.force;
forceVolume._falloff = info.fallOff switch
{
RadialForceVolumeInfo.FallOff.Constant => RadialForceVolume.Falloff.Constant,
RadialForceVolumeInfo.FallOff.Linear => RadialForceVolume.Falloff.Linear,
RadialForceVolumeInfo.FallOff.InverseSquared => RadialForceVolume.Falloff.InvSqr,
_ => throw new NotImplementedException(),
};
forceVolume.gameObject.SetActive(true);
return forceVolume;
}
public static TVolume Make<TVolume>(GameObject planetGO, Sector sector, ForceVolumeInfo info) where TVolume : ForceVolume
{
var forceVolume = PriorityVolumeBuilder.Make<TVolume>(planetGO, sector, info);
forceVolume._alignmentPriority = info.alignmentPriority;
forceVolume._inheritable = info.inheritable;
return forceVolume;
}
}
}

View File

@ -3,6 +3,7 @@ using NewHorizons.External.Modules.Volumes.VolumeInfos;
using NewHorizons.Utility.OuterWilds;
using OWML.Common;
using OWML.Utils;
using System;
using System.Linq;
using UnityEngine;
@ -13,35 +14,28 @@ namespace NewHorizons.Builder.Volumes
public static HazardVolume Make(GameObject planetGO, Sector sector, OWRigidbody owrb, HazardVolumeInfo info, IModBehaviour mod)
{
var go = GeneralPropBuilder.MakeNew("HazardVolume", planetGO, sector, info);
go.layer = Layer.BasicEffectVolume;
var shape = go.AddComponent<SphereShape>();
shape.radius = info.radius;
var owTriggerVolume = go.AddComponent<OWTriggerVolume>();
owTriggerVolume._shape = shape;
var volume = AddHazardVolume(go, sector, owrb, info.type, info.firstContactDamageType, info.firstContactDamage, info.damagePerSecond);
var volume = MakeExisting(go, planetGO, sector, owrb, info);
go.SetActive(true);
return volume;
}
public static HazardVolume AddHazardVolume(GameObject go, Sector sector, OWRigidbody owrb, HazardVolumeInfo.HazardType? type, HazardVolumeInfo.InstantDamageType? firstContactDamageType, float firstContactDamage, float damagePerSecond)
public static HazardVolume MakeExisting(GameObject go, GameObject planetGO, Sector sector, OWRigidbody owrb, HazardVolumeInfo info)
{
HazardVolume hazardVolume = null;
if (type == HazardVolumeInfo.HazardType.RIVERHEAT)
if (info.type == HazardVolumeInfo.HazardType.RIVERHEAT)
{
hazardVolume = go.AddComponent<RiverHeatHazardVolume>();
hazardVolume = VolumeBuilder.MakeExisting<RiverHeatHazardVolume>(go, planetGO, sector, info);
}
else if (type == HazardVolumeInfo.HazardType.HEAT)
else if (info.type == HazardVolumeInfo.HazardType.HEAT)
{
hazardVolume = go.AddComponent<HeatHazardVolume>();
hazardVolume = VolumeBuilder.MakeExisting<HeatHazardVolume>(go, planetGO, sector, info);
}
else if (type == HazardVolumeInfo.HazardType.DARKMATTER)
else if (info.type == HazardVolumeInfo.HazardType.DARKMATTER)
{
hazardVolume = go.AddComponent<DarkMatterVolume>();
hazardVolume = VolumeBuilder.MakeExisting<DarkMatterVolume>(go, planetGO, sector, info);
var visorFrostEffectVolume = go.AddComponent<VisorFrostEffectVolume>();
visorFrostEffectVolume._frostRate = 0.5f;
visorFrostEffectVolume._maxFrost = 0.91f;
@ -67,28 +61,38 @@ namespace NewHorizons.Builder.Volumes
submerge._fluidDetector = detector;
}
}
else if (type == HazardVolumeInfo.HazardType.ELECTRICITY)
else if (info.type == HazardVolumeInfo.HazardType.ELECTRICITY)
{
var electricityVolume = go.AddComponent<ElectricityVolume>();
var electricityVolume = VolumeBuilder.MakeExisting<ElectricityVolume>(go, planetGO, sector, info);
electricityVolume._shockAudioPool = new OWAudioSource[0];
hazardVolume = electricityVolume;
}
else
{
var simpleHazardVolume = go.AddComponent<SimpleHazardVolume>();
simpleHazardVolume._type = EnumUtils.Parse(type.ToString(), HazardVolume.HazardType.GENERAL);
simpleHazardVolume._type = EnumUtils.Parse(info.type.ToString(), HazardVolume.HazardType.GENERAL);
hazardVolume = simpleHazardVolume;
}
hazardVolume._attachedBody = owrb;
hazardVolume._damagePerSecond = type == null ? 0f : damagePerSecond;
hazardVolume._damagePerSecond = info.type == HazardVolumeInfo.HazardType.NONE ? 0f : info.damagePerSecond;
if (firstContactDamageType != null)
{
hazardVolume._firstContactDamageType = EnumUtils.Parse(firstContactDamageType.ToString(), InstantDamageType.Impact);
hazardVolume._firstContactDamage = firstContactDamage;
}
hazardVolume._firstContactDamageType = EnumUtils.Parse(info.firstContactDamageType.ToString(), InstantDamageType.Impact);
hazardVolume._firstContactDamage = info.firstContactDamage;
return hazardVolume;
}
public static HazardVolume AddHazardVolume(GameObject go, Sector sector, OWRigidbody owrb, HazardVolumeInfo.HazardType? type, HazardVolumeInfo.InstantDamageType? firstContactDamageType, float firstContactDamage, float damagePerSecond)
{
var planetGO = sector.transform.root.gameObject;
return MakeExisting(go, planetGO, sector, owrb, new HazardVolumeInfo
{
radius = 0f, // Volume builder should skip creating an extra trigger volume and collider if radius is 0
type = type ?? HazardVolumeInfo.HazardType.NONE,
firstContactDamageType = firstContactDamageType ?? HazardVolumeInfo.InstantDamageType.Impact,
firstContactDamage = firstContactDamage,
damagePerSecond = damagePerSecond
});
}
}
}

View File

@ -11,21 +11,16 @@ namespace NewHorizons.Builder.Volumes
{
public static NHNotificationVolume Make(GameObject planetGO, Sector sector, NotificationVolumeInfo info, IModBehaviour mod)
{
var go = GeneralPropBuilder.MakeNew("NotificationVolume", planetGO, sector, info);
go.layer = Layer.BasicEffectVolume;
var notificationVolume = VolumeBuilder.Make<NHNotificationVolume>(planetGO, sector, info);
var shape = go.AddComponent<SphereShape>();
shape.radius = info.radius;
// Preserving name for backwards compatibility
notificationVolume.gameObject.name = string.IsNullOrEmpty(info.rename) ? "NotificationVolume" : info.rename;
var owTriggerVolume = go.AddComponent<OWTriggerVolume>();
owTriggerVolume._shape = shape;
var notificationVolume = go.AddComponent<NHNotificationVolume>();
notificationVolume.SetTarget(info.target);
if (info.entryNotification != null) notificationVolume.SetEntryNotification(info.entryNotification.displayMessage, info.entryNotification.duration);
if (info.exitNotification != null) notificationVolume.SetExitNotification(info.exitNotification.displayMessage, info.exitNotification.duration);
go.SetActive(true);
notificationVolume.gameObject.SetActive(true);
return notificationVolume;
}

View File

@ -12,6 +12,8 @@ namespace NewHorizons.Builder.Volumes
volume._treeVolume = info.treeVolume;
volume._playRefillAudio = info.playRefillAudio;
volume.gameObject.SetActive(true);
return volume;
}
}

View File

@ -5,6 +5,17 @@ namespace NewHorizons.Builder.Volumes
{
public static class PriorityVolumeBuilder
{
public static TVolume MakeExisting<TVolume>(GameObject go, GameObject planetGO, Sector sector, PriorityVolumeInfo info) where TVolume : PriorityVolume
{
var volume = VolumeBuilder.MakeExisting<TVolume>(go, planetGO, sector, info);
volume._layer = info.layer;
volume.SetPriority(info.priority);
return volume;
}
public static TVolume Make<TVolume>(GameObject planetGO, Sector sector, PriorityVolumeInfo info) where TVolume : PriorityVolume
{
var volume = VolumeBuilder.Make<TVolume>(planetGO, sector, info);

View File

@ -12,6 +12,8 @@ namespace NewHorizons.Builder.Volumes.Rulesets
volume.minImpactSpeed = info.minImpactSpeed;
volume.maxImpactSpeed = info.maxImpactSpeed;
volume.gameObject.SetActive(true);
return volume;
}
}

View File

@ -15,6 +15,8 @@ namespace NewHorizons.Builder.Volumes.Rulesets
volume._lanternRange = info.lanternRange;
volume._ignoreAnchor = info.ignoreAnchor;
volume.gameObject.SetActive(true);
return volume;
}
}

View File

@ -13,6 +13,8 @@ namespace NewHorizons.Builder.Volumes.Rulesets
volume._nerfJetpackBooster = info.nerfJetpackBooster;
volume._nerfDuration = info.nerfDuration;
volume.gameObject.SetActive(true);
return volume;
}
}

View File

@ -14,6 +14,8 @@ namespace NewHorizons.Builder.Volumes
volume.stoppingDistance = info.stoppingDistance;
volume.maxEntryAngle = info.maxEntryAngle;
volume.gameObject.SetActive(true);
return volume;
}
}

View File

@ -12,6 +12,8 @@ namespace NewHorizons.Builder.Volumes
volume._speedLimit = info.speedLimit;
volume._acceleration = info.acceleration;
volume.gameObject.SetActive(true);
return volume;
}
}

View File

@ -1,6 +1,4 @@
using NewHorizons.Builder.Props;
using NewHorizons.External.Modules.Volumes.VolumeInfos;
using NewHorizons.Utility.OuterWilds;
using UnityEngine;
namespace NewHorizons.Builder.Volumes
@ -9,27 +7,13 @@ namespace NewHorizons.Builder.Volumes
{
public static TVolume Make<TVolume>(GameObject planetGO, Sector sector, VanishVolumeInfo info) where TVolume : VanishVolume
{
var go = GeneralPropBuilder.MakeNew(typeof(TVolume).Name, planetGO, sector, info);
go.layer = Layer.BasicEffectVolume;
var collider = go.AddComponent<SphereCollider>();
collider.isTrigger = true;
collider.radius = info.radius;
var owCollider = go.AddComponent<OWCollider>();
owCollider._collider = collider;
var owTriggerVolume = go.AddComponent<OWTriggerVolume>();
owTriggerVolume._owCollider = owCollider;
var volume = go.AddComponent<TVolume>();
var volume = VolumeBuilder.Make<TVolume>(planetGO, sector, info);
var collider = volume.gameObject.GetComponent<SphereCollider>();
volume._collider = collider;
volume._shrinkBodies = info.shrinkBodies;
volume._onlyAffectsPlayerAndShip = info.onlyAffectsPlayerRelatedBodies;
go.SetActive(true);
return volume;
}
}

View File

@ -12,6 +12,8 @@ namespace NewHorizons.Builder.Volumes.VisorEffects
volume._frostRate = info.frostRate;
volume._maxFrost = info.maxFrost;
volume.gameObject.SetActive(true);
return volume;
}
}

View File

@ -13,6 +13,8 @@ namespace NewHorizons.Builder.Volumes.VisorEffects
volume._dropletRate = info.dropletRate;
volume._streakRate = info.streakRate;
volume.gameObject.SetActive(true);
return volume;
}
}

View File

@ -7,21 +7,36 @@ namespace NewHorizons.Builder.Volumes
{
public static class VolumeBuilder
{
public static TVolume MakeExisting<TVolume>(GameObject go, GameObject planetGO, Sector sector, VolumeInfo info) where TVolume : MonoBehaviour
{
// Respect existing layer if set to a valid volume layer
if (go.layer != Layer.AdvancedEffectVolume)
{
go.layer = Layer.BasicEffectVolume;
}
// Skip creating a trigger volume if one already exists and has a shape set and we aren't overriding it
var trigger = go.GetComponent<OWTriggerVolume>();
if (trigger == null || (trigger._shape == null && trigger._owCollider == null) || info.shape != null || info.radius > 0f)
{
ShapeBuilder.AddTriggerVolume(go, info.shape, info.radius);
}
var volume = go.AddComponent<TVolume>();
return volume;
}
public static TVolume Make<TVolume>(GameObject planetGO, Sector sector, VolumeInfo info) where TVolume : MonoBehaviour //Could be BaseVolume but I need to create vanilla volumes too.
{
var go = GeneralPropBuilder.MakeNew(typeof(TVolume).Name, planetGO, sector, info);
go.layer = Layer.BasicEffectVolume;
var shape = go.AddComponent<SphereShape>();
shape.radius = info.radius;
var owTriggerVolume = go.AddComponent<OWTriggerVolume>();
owTriggerVolume._shape = shape;
var volume = go.AddComponent<TVolume>();
go.SetActive(true);
return MakeExisting<TVolume>(go, planetGO, sector, info);
}
public static TVolume MakeAndEnable<TVolume>(GameObject planetGO, Sector sector, VolumeInfo info) where TVolume : MonoBehaviour
{
var volume = Make<TVolume>(planetGO, sector, info);
volume.gameObject.SetActive(true);
return volume;
}
}

View File

@ -60,28 +60,28 @@ namespace NewHorizons.Builder.Volumes
{
foreach (var mapRestrictionVolume in config.Volumes.mapRestrictionVolumes)
{
VolumeBuilder.Make<MapRestrictionVolume>(go, sector, mapRestrictionVolume);
VolumeBuilder.MakeAndEnable<MapRestrictionVolume>(go, sector, mapRestrictionVolume);
}
}
if (config.Volumes.interferenceVolumes != null)
{
foreach (var interferenceVolume in config.Volumes.interferenceVolumes)
{
VolumeBuilder.Make<Components.Volumes.InterferenceVolume>(go, sector, interferenceVolume);
VolumeBuilder.MakeAndEnable<Components.Volumes.InterferenceVolume>(go, sector, interferenceVolume);
}
}
if (config.Volumes.reverbVolumes != null)
{
foreach (var reverbVolume in config.Volumes.reverbVolumes)
{
VolumeBuilder.Make<ReverbTriggerVolume>(go, sector, reverbVolume);
VolumeBuilder.MakeAndEnable<ReverbTriggerVolume>(go, sector, reverbVolume);
}
}
if (config.Volumes.insulatingVolumes != null)
{
foreach (var insulatingVolume in config.Volumes.insulatingVolumes)
{
VolumeBuilder.Make<InsulatingVolume>(go, sector, insulatingVolume);
VolumeBuilder.MakeAndEnable<InsulatingVolume>(go, sector, insulatingVolume);
}
}
if (config.Volumes.zeroGravityVolumes != null)
@ -112,20 +112,58 @@ namespace NewHorizons.Builder.Volumes
FluidVolumeBuilder.Make(go, sector, fluidVolume);
}
}
if (config.Volumes.forces != null)
{
if (config.Volumes.forces.cylindricalVolumes != null)
{
foreach (var cylindricalVolume in config.Volumes.forces.cylindricalVolumes)
{
ForceVolumeBuilder.Make(go, sector, cylindricalVolume);
}
}
if (config.Volumes.forces.directionalVolumes != null)
{
foreach (var directionalVolume in config.Volumes.forces.directionalVolumes)
{
ForceVolumeBuilder.Make(go, sector, directionalVolume);
}
}
if (config.Volumes.forces.gravityVolumes != null)
{
foreach (var gravityVolume in config.Volumes.forces.gravityVolumes)
{
ForceVolumeBuilder.Make(go, sector, gravityVolume);
}
}
if (config.Volumes.forces.polarVolumes != null)
{
foreach (var polarVolume in config.Volumes.forces.polarVolumes)
{
ForceVolumeBuilder.Make(go, sector, polarVolume);
}
}
if (config.Volumes.forces.radialVolumes != null)
{
foreach (var radialVolume in config.Volumes.forces.radialVolumes)
{
ForceVolumeBuilder.Make(go, sector, radialVolume);
}
}
}
if (config.Volumes.probe != null)
{
if (config.Volumes.probe.destructionVolumes != null)
{
foreach (var destructionVolume in config.Volumes.probe.destructionVolumes)
{
VolumeBuilder.Make<ProbeDestructionVolume>(go, sector, destructionVolume);
VolumeBuilder.MakeAndEnable<ProbeDestructionVolume>(go, sector, destructionVolume);
}
}
if (config.Volumes.probe.safetyVolumes != null)
{
foreach (var safetyVolume in config.Volumes.probe.safetyVolumes)
{
VolumeBuilder.Make<ProbeSafetyVolume>(go, sector, safetyVolume);
VolumeBuilder.MakeAndEnable<ProbeSafetyVolume>(go, sector, safetyVolume);
}
}
}
@ -152,7 +190,7 @@ namespace NewHorizons.Builder.Volumes
{
foreach (var antiTravelMusicRuleset in config.Volumes.rulesets.antiTravelMusicRulesets)
{
VolumeBuilder.Make<AntiTravelMusicRuleset>(go, sector, antiTravelMusicRuleset);
VolumeBuilder.MakeAndEnable<AntiTravelMusicRuleset>(go, sector, antiTravelMusicRuleset);
}
}
if (config.Volumes.rulesets.playerImpactRulesets != null)
@ -181,7 +219,7 @@ namespace NewHorizons.Builder.Volumes
{
foreach (var referenceFrameBlockerVolume in config.Volumes.referenceFrameBlockerVolumes)
{
VolumeBuilder.Make<ReferenceFrameBlockerVolume>(go, sector, referenceFrameBlockerVolume);
VolumeBuilder.MakeAndEnable<ReferenceFrameBlockerVolume>(go, sector, referenceFrameBlockerVolume);
}
}
if (config.Volumes.speedTrapVolumes != null)
@ -202,7 +240,7 @@ namespace NewHorizons.Builder.Volumes
{
foreach (var lightSourceVolume in config.Volumes.lightSourceVolumes)
{
VolumeBuilder.Make<LightlessLightSourceVolume>(go, sector, lightSourceVolume);
VolumeBuilder.MakeAndEnable<LightlessLightSourceVolume>(go, sector, lightSourceVolume);
}
}
if (config.Volumes.solarSystemVolume != null)

View File

@ -11,6 +11,8 @@ namespace NewHorizons.Builder.Volumes
volume._inheritable = true;
volume.gameObject.SetActive(true);
return volume;
}
}

View File

@ -0,0 +1,92 @@
using NewHorizons.External.SerializableData;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
using System.Text;
using System.Threading.Tasks;
namespace NewHorizons.External.Modules.Props
{
[JsonObject]
public class ShapeInfo
{
/// <summary>
/// The type of shape or collider to add. Sphere, box, and capsule colliders are more performant and support collision. Defaults to sphere.
/// </summary>
public ShapeType type = ShapeType.Sphere;
/// <summary>
/// The radius of the shape or collider. Defaults to 0.5 meters. Only used by spheres, capsules, cylinders, hemispheres, hemicapsules, and rings.
/// </summary>
public float radius = 0.5f;
/// <summary>
/// The height of the shape or collider. Defaults to 1 meter. Only used by capsules, cylinders, cones, hemicapsules, and rings.
/// </summary>
public float height = 1f;
/// <summary>
/// The axis that the shape or collider is aligned with. Defaults to the Y axis (up). The flat bottom of the shape will be pointing towards the negative axis. Only used by capsules, cones, hemispheres, and hemicapsules.
/// </summary>
public ColliderAxis direction = ColliderAxis.Y;
/// <summary>
/// The inner radius of the shape. Defaults to 0 meters. Only used by cones and rings.
/// </summary>
public float innerRadius = 0f;
/// <summary>
/// The outer radius of the shape. Defaults to 0.5 meters. Only used by cones and rings.
/// </summary>
public float outerRadius = 0.5f;
/// <summary>
/// Whether the shape has an end cap. Defaults to true. Only used by hemispheres and hemicapsules.
/// </summary>
public bool cap = true;
/// <summary>
/// The size of the shape or collider. Defaults to (1,1,1). Only used by boxes.
/// </summary>
public MVector3 size;
/// <summary>
/// The offset of the shape or collider from the object's origin. Defaults to (0,0,0). Supported by all collider and shape types.
/// </summary>
public MVector3 offset;
/// <summary>
/// Whether the collider should have collision enabled. If false, the collider will be a trigger. Defaults to false. Only supported for spheres, boxes, and capsules.
/// </summary>
public bool hasCollision = false;
/// <summary>
/// Whether to explicitly use a shape instead of a collider. Shapes do not support collision and are less performant, but support a wider set of shapes and are required by some components. Omit this unless you explicitly want to use a sphere, box, or capsule shape instead of a collider.
/// </summary>
public bool? useShape;
}
[JsonConverter(typeof(StringEnumConverter))]
public enum ShapeType
{
[EnumMember(Value = @"sphere")] Sphere,
[EnumMember(Value = @"box")] Box,
[EnumMember(Value = @"capsule")] Capsule,
[EnumMember(Value = @"cylinder")] Cylinder,
[EnumMember(Value = @"cone")] Cone,
[EnumMember(Value = @"hemisphere")] Hemisphere,
[EnumMember(Value = @"hemicapsule")] Hemicapsule,
[EnumMember(Value = @"ring")] Ring,
}
[JsonConverter(typeof(StringEnumConverter))]
public enum ColliderAxis
{
[EnumMember(Value = @"x")] X = 0,
[EnumMember(Value = @"y")] Y = 1,
[EnumMember(Value = @"z")] Z = 2,
}
}

View File

@ -0,0 +1,40 @@
using NewHorizons.External.Modules.Volumes.VolumeInfos;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace NewHorizons.External.Modules.Volumes
{
[JsonObject]
public class ForceModule
{
/// <summary>
/// Applies a constant force along the volume's XZ plane towards the volume's center. Affects alignment.
/// </summary>
public CylindricalForceVolumeInfo[] cylindricalVolumes;
/// <summary>
/// Applies a constant force in the direction of the volume's Y axis. May affect alignment.
/// </summary>
public DirectionalForceVolumeInfo[] directionalVolumes;
/// <summary>
/// Applies planet-like gravity towards the volume's center with falloff by distance. May affect alignment.
/// For actual planetary body gravity, use the properties in the Base module.
/// </summary>
public GravityVolumeInfo[] gravityVolumes;
/// <summary>
/// Applies a constant force towards the volume's center. Affects alignment.
/// </summary>
public PolarForceVolumeInfo[] polarVolumes;
/// <summary>
/// Applies a force towards the volume's center with falloff by distance. Affects alignment.
/// </summary>
public RadialForceVolumeInfo[] radialVolumes;
}
}

View File

@ -0,0 +1,24 @@
using NewHorizons.External.SerializableData;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace NewHorizons.External.Modules.Volumes.VolumeInfos
{
[JsonObject]
public class CylindricalForceVolumeInfo : ForceVolumeInfo
{
/// <summary>
/// The direction that the force applied by this volume will be perpendicular to. Defaults to up (0, 1, 0).
/// </summary>
public MVector3 normal;
/// <summary>
/// Whether to play the gravity crystal audio when the player is in this volume.
/// </summary>
public bool playGravityCrystalAudio;
}
}

View File

@ -0,0 +1,35 @@
using NewHorizons.External.SerializableData;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace NewHorizons.External.Modules.Volumes.VolumeInfos
{
[JsonObject]
public class DirectionalForceVolumeInfo : ForceVolumeInfo
{
/// <summary>
/// The direction of the force applied by this volume. Defaults to up (0, 1, 0).
/// </summary>
public MVector3 normal;
/// <summary>
/// Whether this force volume affects alignment. Defaults to true.
/// </summary>
[DefaultValue(true)] public bool affectsAlignment = true;
/// <summary>
/// Whether the force applied by this volume takes the centripetal force of the volume's parent body into account. Defaults to false.
/// </summary>
public bool offsetCentripetalForce;
/// <summary>
/// Whether to play the gravity crystal audio when the player is in this volume.
/// </summary>
public bool playGravityCrystalAudio;
}
}

View File

@ -0,0 +1,35 @@
using NewHorizons.External.Modules.Props;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace NewHorizons.External.Modules.Volumes.VolumeInfos
{
[JsonObject]
public class ForceVolumeInfo : PriorityVolumeInfo
{
/// <summary>
/// The force applied by this volume. Can be negative to reverse the direction.
/// </summary>
public float force;
/// <summary>
/// The priority of this force volume for the purposes of alignment.
///
/// Volumes of higher priority will override volumes of lower priority. Volumes of the same priority will stack like normal.
/// Ex: A player in a gravity volume with priority 0, and zero-gravity volume with priority 1, will feel zero gravity.
///
/// Default value here is 1 instead of 0 so it automatically overrides planet gravity, which is 0 by default.
/// </summary>
[DefaultValue(1)] public int alignmentPriority = 1;
/// <summary>
/// Whether this force volume is inheritable. The most recently activated inheritable force volume will stack with other force volumes even if their priorities differ.
/// </summary>
public bool inheritable;
}
}

View File

@ -0,0 +1,44 @@
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace NewHorizons.External.Modules.Volumes.VolumeInfos
{
[JsonObject]
public class GravityVolumeInfo : ForceVolumeInfo
{
/// <summary>
/// The upper bounds of the volume's "surface". Above this radius, the force applied by this volume will have falloff applied.
/// </summary>
public float upperRadius;
/// <summary>
/// The lower bounds of the volume's "surface". Above this radius and below the `upperRadius`, the force applied by this volume will be constant. Defaults to 0.
/// </summary>
[DefaultValue(0f)] public float lowerRadius;
/// <summary>
/// The volume's force will decrease linearly from `force` to `minForce` as distance decreases from `lowerRadius` to `minRadius`. Defaults to 0.
/// </summary>
[DefaultValue(0f)] public float minRadius;
/// <summary>
/// The minimum force applied by this volume between `lowerRadius` and `minRadius`. Defaults to 0.
/// </summary>
[DefaultValue(0f)] public float minForce;
/// <summary>
/// How the force falls off with distance. Most planets use linear but the sun and some moons use inverseSquared.
/// </summary>
[DefaultValue("linear")] public GravityFallOff fallOff = GravityFallOff.Linear;
/// <summary>
/// The radius where objects will be aligned to the volume's force. Defaults to 1.5x the `upperRadius`. Set to 0 to disable alignment.
/// </summary>
public float? alignmentRadius;
}
}

View File

@ -0,0 +1,23 @@
using NewHorizons.External.SerializableData;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace NewHorizons.External.Modules.Volumes.VolumeInfos
{
[JsonObject]
public class PolarForceVolumeInfo : ForceVolumeInfo
{
/// <summary>
/// Tangential mode only. The force applied by this volume will be perpendicular to this direction and the direction to the other body. Defaults to up (0, 1, 0).
/// </summary>
public MVector3 normal;
/// <summary>
/// Enables tangential mode. The force applied by this volume will be perpendicular to the normal and the direction to the other body. Defaults to false.
/// </summary>
public bool tangential;
}
}

View File

@ -0,0 +1,32 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.Serialization;
using System.Text;
using System.Threading.Tasks;
namespace NewHorizons.External.Modules.Volumes.VolumeInfos
{
[JsonObject]
public class RadialForceVolumeInfo : ForceVolumeInfo
{
/// <summary>
/// How the force falls off with distance. Defaults to linear.
/// </summary>
[DefaultValue("linear")] public FallOff fallOff = FallOff.Linear;
[JsonConverter(typeof(StringEnumConverter))]
public enum FallOff
{
[EnumMember(Value = @"constant")] Constant = 0,
[EnumMember(Value = @"linear")] Linear = 1,
[EnumMember(Value = @"inverseSquared")]
InverseSquared = 2
}
}
}

View File

@ -29,7 +29,7 @@ namespace NewHorizons.External.Modules.Volumes.VolumeInfos
}
/// <summary>
/// The max view angle (in degrees) the player can see the volume with to unlock the fact (`observe` only)
/// The max view angle (in degrees) the player can see the volume with to unlock the fact (`observe` only). This will effectively be a cone extending from the volume's center forwards (along the Z axis) based on the volume's rotation.
/// </summary>
[DefaultValue(180f)]
public float maxAngle = 180f; // Observe Only

View File

@ -1,14 +1,20 @@
using NewHorizons.External.Modules.Props;
using Newtonsoft.Json;
using System.ComponentModel;
namespace NewHorizons.External.Modules.Volumes.VolumeInfos
{
[JsonObject]
public class VolumeInfo : GeneralPointPropInfo
public class VolumeInfo : GeneralPropInfo
{
/// <summary>
/// The radius of this volume.
/// The radius of this volume, if a shape is not specified.
/// </summary>
[DefaultValue(1f)] public float radius = 1f;
/// <summary>
/// The shape of this volume. Defaults to a sphere with a radius of `radius` if not specified.
/// </summary>
public ShapeInfo shape;
}
}

View File

@ -27,6 +27,11 @@ namespace NewHorizons.External.Modules.Volumes
/// </summary>
public FluidVolumeInfo[] fluidVolumes;
/// <summary>
/// Add force volumes to this planet.
/// </summary>
public ForceModule forces;
/// <summary>
/// Add hazard volumes to this planet.
/// Causes damage to player when inside this volume.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,72 @@
---
title: Volumes
description: Guide to making volumes in New Horizons
---
Volumes are invisible 3D "zones" or "triggers" that cause various effects when objects enter or leave them. For example, `oxygenVolumes` refill the player's oxygen when they enter (used for the various oxygen-generating trees in the game), `forces.directionalVolumes` push players and other physics objects in a specific direction (used by both Nomai artificial gravity surfaces and tractor beams), `revealVolumes` unlock ship log facts when the player enters or observes them (used everywhere in the game), and more.
New Horizons makes adding volumes to your planets easy; just specify them like you would [for a prop](/guides/details/) but under `Volumes` instead of `Props`. For example, to add an oxygen volume at certain location:
```json title="planets/My Cool Planet.json"
{
"$schema": "https://raw.githubusercontent.com/Outer-Wilds-New-Horizons/new-horizons/main/NewHorizons/Schemas/body_schema.json",
"name" : "My Cool Planet",
"Volumes": {
"oxygenVolumes": [
{
"position": {"x": 399.4909, "y": -1.562098, "z": 20.11444},
"radius": 30,
"treeVolume": true,
"playRefillAudio": true
}
]
}
}
```
Listing out every type of volume is outside the scope of this guide, but you can see every supported type of volume and the properties they need in [the VolumesModule schema](/schemas/body-schema/defs/volumesmodule/).
## Volume Shapes
By default, volumes are spherical, and you can specify the radius of that sphere with the `radius` property. If you want to use a different shape for your volume, such as a box or capsule, you can specify your volume's `shape` like so:
```json title="planets/My Cool Planet.json"
{
"$schema": "https://raw.githubusercontent.com/Outer-Wilds-New-Horizons/new-horizons/main/NewHorizons/Schemas/body_schema.json",
"name" : "My Cool Planet",
"Volumes": {
"forces": {
"directionalVolumes": [
{
"rename": "ArtificialGravitySurface",
"force": 8,
"playGravityCrystalAudio": true,
"shape": {
"type": "box",
"size": {
"x": 15.0,
"y": 10.0,
"z": 5.0
},
"offset": {
"x": 0,
"y": 5.0,
"z": 0
}
},
"position": { "x": 0, "y": -110, "z": 0 },
"rotation": { "x": 180, "y": 0, "z": 0 }
}
]
}
}
}
```
The supported shape types are: `sphere`, `box`, `capsule`, `cylinder`, `cone`, `hemisphere`, `hemicapsule`, and `ring`. See [the ShapeInfo schema](/schemas/body-schema/defs/shapeinfo/) for the full list of properties available to define each shape.
Note that `sphere`, `box`, and `capsule` shapes are more reliable and efficient than other shapes, so prefer using them whenever possible.
### Debugging
To visualize the shapes of your volumes in-game, use the [Collider Visualizer mod](https://outerwildsmods.com/mods/collidervisualizer/). It will display a wireframe of the shapes around you so you can see precisely where they are and reposition or resize them as needed.