Compare commits

..

58 Commits

Author SHA1 Message Date
xen-42
30cb5253ab
1.28.8 (#1124)
## Bug fixes

- Reverted sand effect ruleset fix that actually broke things (made the
player blind when standing on sand underwater in some instances).
2025-09-24 12:58:06 -04:00
xen-42
09bd1df199 Update manifest.json 2025-09-24 12:54:05 -04:00
xen-42
03ef2f4e81 Revert "Sand Effect Ruleset (#1110)"
This reverts commit 2c36015a681b9b3058f96c4bedfa3281a04194ba, reversing
changes made to ff699e313d9b6d4312ca215ab699051c8c6ff38a.
2025-09-24 12:51:09 -04:00
xen-42
0b7e9b47fd
1.28.7 (#1123)
## Improvements

- Updated description of VariableSizeModule (thanks LeeSpork)

## Bug fixes

- Make `GetPlanet` only check planets in the current system. This
prevents it from pulling the wrong config if a planet in another system
has the same name (thanks coderCleric).
- Fixed interaction volumes breaking when no shape provided.
- Added an effect ruleset to sand. Fixes #1099
2025-09-15 17:52:30 -04:00
xen-42
2c36015a68
Sand Effect Ruleset (#1110)
## Bug fixes

- Added an effect ruleset to sand. Fixes #1099

<img width="1920" height="1080" alt="image"
src="https://github.com/user-attachments/assets/84570d55-08cc-48fe-97d7-e791461b378a"
/>
2025-09-13 19:20:21 -04:00
xen-42
ff699e313d Update manifest.json 2025-09-13 19:15:55 -04:00
xen-42
fd8e240657
Fix interaction volumes breaking when no shape (#1114)
## Bug fixes

- Fix interaction volumes breaking when no shape
2025-09-13 19:15:01 -04:00
Ben C
8d271c368c Updated Schemas 2025-09-13 23:14:28 +00:00
xen-42
7c0731fbae
Edit documentation of VariableSizeModule (#1120)
Reasoning: it was unclear that the scale of the in-game object will
still be affected by the size value of the object and not outright
overridden by the value value.
2025-09-13 19:13:25 -04:00
xen-42
4982f2a69a
GetPlanet bugfix (#1122)
## Bug fixes

- Make GetPlanet only check planets in the current system. This prevents
it from pulling the wrong config if a planet in another system has the
same name.
2025-09-13 19:12:45 -04:00
Noah Pilarski
4e3e438cde better variable name 2025-08-27 02:17:43 -04:00
Noah Pilarski
1c7faa93fc comment 2025-08-27 02:16:03 -04:00
coderCleric
9f5b71e1cc Make GetPlanet work if planets across systems have the same name 2025-08-23 15:45:59 -06:00
LeeSpork
1ce62f383d
Update VariableSizeModule.cs 2025-08-17 11:55:22 +12:00
xen-42
765d3a136d
1.28.6 (#1113)
## Bug fixes

- Fixed a floating point precision bug that broke the 13th registered
Signalscope frequency

Also added coderCleric's Nomai Text Printer mod to the documentation
since it is very useful for story mod development!
2025-08-05 14:53:05 -04:00
Noah Pilarski
1511cc5cb0 Fix interaction volumes breaking when no shape 2025-08-04 23:05:46 -04:00
Noah Pilarski
54165a8d5e Merge branch 'dev' into sandEffectRuleset 2025-08-04 22:23:54 -04:00
Noah Pilarski
be3f77d02b Revert "fix error"
This reverts commit 2e07c93fc45c2511084b66ff9ffd810e266ad86d.
2025-08-04 22:23:07 -04:00
xen-42
999bcae536
Too many signals (#1112)
## Bug fixes

- Fixed a floating point precision bug that broke the 13th registered
Signalscope frequency
2025-08-04 21:53:27 -04:00
xen-42
0b419b66c4 Revert "Add a security check to ScatterBuilder (#1109)"
This reverts commit ced49c6606e88fb7a40b8cecb8fb7c753cd24214, reversing
changes made to 4c3d92a4a3f90c981420eacac1d048a0f44da303.
2025-08-04 21:50:11 -04:00
xen-42
94ff93d597 Reapply "Add a security check to ScatterBuilder (#1109)"
This reverts commit 696bce2f3a719f9f23a7b15357672378d5793ac9.
2025-08-04 21:47:18 -04:00
xen-42
696bce2f3a Revert "Add a security check to ScatterBuilder (#1109)"
This reverts commit ced49c6606e88fb7a40b8cecb8fb7c753cd24214, reversing
changes made to 4c3d92a4a3f90c981420eacac1d048a0f44da303.
2025-08-04 21:46:33 -04:00
xen-42
8af363f8f8 Update manifest.json 2025-08-04 21:44:43 -04:00
xen-42
831a94cdbc Fixed a floating point rounding error 2025-08-04 21:44:18 -04:00
xen-42
57d945d6ee Merge branch 'dev' into too-many-signals 2025-08-04 21:12:11 -04:00
xen-42
8089de358a Just expect 32 signals max instead of constantly refreshing the array 2025-08-04 21:12:00 -04:00
Noah Pilarski
2e07c93fc4 fix error 2025-08-04 20:24:06 -04:00
Noah Pilarski
915b5bc55d Fix effect ruleset 2025-08-04 20:24:05 -04:00
Noah Pilarski
ced49c6606
Add a security check to ScatterBuilder (#1109)
## Security check
What currently happens is that if `preventOverlap` is not true then
there is no array of point emptied progressively and points are rolled
in a loop of size count for each prop. The problem is that the loop can
be infinite due to `i--; // Try this point again`. A reroll happens when
`height < propInfo.minHeight` or `height > propInfo.maxHeight`, height
being rolled between `heightMap.minHeight` and `heightMap.maxHeight`
(which are not the constraints above).
- This PR checks before the loop if there is less than 0.1% chance for
the upcoming heightMap rolls to be considered correct, and if not it
always skips this check, ignoring `propInfo.minHeight` and `.maxHeight`
to prevent a very long (possibly infinite) loop.
2025-08-04 19:03:44 -04:00
Noah Pilarski
201a310e2b extend a little 2025-08-04 19:02:27 -04:00
Noah Pilarski
38ae757f58 add log 2025-08-04 19:00:57 -04:00
Peter-Mikhaël Richard
f7c9685457
Update ScatterBuilder.cs 2025-07-31 06:21:48 +02:00
Will Corby
4c3d92a4a3
Add Nomai Text Printer to the docs (#1108)
## Improvements

- Added Nomai Text Printer to the docs, since it can be useful
2025-07-29 19:27:20 -07:00
coderCleric
5391e4a555 Add Nomai Text Printer to the docs 2025-07-29 18:15:36 -06:00
xen-42
65ecbb1f9a
Update CONTRIBUTING.md 2025-07-24 11:14:09 -04:00
xen-42
fe55ccfcdc
Update CONTRIBUTING.md 2025-07-24 01:00:46 -04:00
Noah Pilarski
ecdc0c8af4
1.28.5 (#1098)
## Minor features

- Added `conditionTriggerVolumes` to set a dialogue condition when the
player (or scout or ship) enters an area.
- Added `interactionVolumes` for interactable objects that set a
dialogue condition, play a sound, and/or trigger an animation.
- Added `condition` fields to `dreamCandles` and `projectionTotems` to
set dialogue conditions when they are lit or extinguished.

## Improvements

- no longer loads unnecessary bundles in custom systems. saves VRAM.
does not affect RAM.

## Bug fixes

- Fix custom items not having sound when you make socket with the same
custom type first
- Dialogue should now work by default in the ship.
2025-07-20 20:26:12 -04:00
Noah Pilarski
31a6093a1b
unloading streaming stuff in custom systems (#1103)
## Improvements

- do not load unnecessary bundles in custom systems. saves VRAM. does
not affect RAM

details stay visible
does not seem to cause softlocks.
works when going from custom to regular (you dont fall thru TH)
seems to cause no extra errors in logs
2025-07-20 20:21:28 -04:00
Noah Pilarski
78d8150c4c
Fix items not having sound when you make socket first (#1100)
## Bug fixes

- Fix custom items not having sound when you make socket with the same
custom type first
2025-07-20 20:20:09 -04:00
JohnCorby
361fc2aa57 Reapply "unload immediate"
This reverts commit b0f445e415f9e0b19131ee99c0d67f16630235d4.
2025-07-19 01:14:38 -07:00
JohnCorby
b0f445e415 Revert "unload immediate"
This reverts commit d38d9fa1c86323d48376f79ea55efd71e1a50fab.
2025-07-19 01:11:15 -07:00
JohnCorby
d38d9fa1c8 unload immediate 2025-07-19 01:07:04 -07:00
JohnCorby
20126dfe91 unload streaming in custom systems 2025-07-19 00:59:47 -07:00
Noah Pilarski
ea85e3bab0 Fix items not having sound when you make socket first 2025-07-12 20:00:07 -04:00
Noah Pilarski
fdf78802e9
More Condition Triggers (#1096)
## Major features

- Added `conditionTriggerVolumes` to set a dialogue condition when the
player (or scout or ship) enters an area.
- Added `interactionVolumes` for interactable objects that set a
dialogue condition, play a sound, and/or trigger an animation.

## Minor features

- Added `condition` fields to `dreamCandles` and `projectionTotems` to
set dialogue conditions when they are lit or extinguished.
2025-07-08 16:14:40 -04:00
xen-42
3403c5da34 Make dialogue usable in ship 2025-07-06 23:59:09 -04:00
Joshua Thome
1d4a2b328b Merge branch 'hawkbar-more-condition-triggers' of https://github.com/Outer-Wilds-New-Horizons/new-horizons into hawkbar-more-condition-triggers 2025-07-04 14:27:24 -05:00
Joshua Thome
e4f89be4e9 Remove accidental import 2025-07-04 14:27:19 -05:00
Ben C
2e06de70c2 Updated Schemas 2025-07-04 19:22:54 +00:00
Joshua Thome
e626dc6974 Merge branch 'hawkbar-more-condition-triggers' of https://github.com/Outer-Wilds-New-Horizons/new-horizons into hawkbar-more-condition-triggers 2025-07-04 14:21:50 -05:00
Joshua Thome
1330df64b4 Interaction volumes 2025-07-04 14:21:14 -05:00
Ben C
b71aa9f845 Updated Schemas 2025-07-01 22:10:15 +00:00
Joshua Thome
7223a4f523 Merge branch 'hawkbar-more-condition-triggers' of https://github.com/Outer-Wilds-New-Horizons/new-horizons into hawkbar-more-condition-triggers 2025-07-01 17:08:20 -05:00
Joshua Thome
d072a74b5d Dream candle and projection totem conditions 2025-07-01 17:08:13 -05:00
Ben C
330564538a Updated Schemas 2025-07-01 17:36:52 +00:00
Joshua Thome
b84d94a404 Condition trigger volume 2025-07-01 12:28:12 -05:00
Peter-Mikhaël Richard
3fbe752395
Added security to ScatterBuilder.cs
Prevent prop positioning loop going infinite when there are too much constraint on height
2025-06-30 18:47:18 +02:00
xen-42
01866cc6be
Update README.md 2025-06-28 20:31:00 -04:00
27 changed files with 764 additions and 31 deletions

View File

@ -68,3 +68,9 @@ These will automatically be converted from strings to the proper enum type.
## Contributing to Documentation ## Contributing to Documentation
If you wish to contribute to the documentation, take a look at [CONTRIBUTING.md](docs/CONTRIBUTING.md) in the docs folder. If you wish to contribute to the documentation, take a look at [CONTRIBUTING.md](docs/CONTRIBUTING.md) in the docs folder.
## Disclaimer
This should go without saying, but we will not accept PRs that are obviously AI generated, nor will we accept PRs from people who have not actually played the game or any mods.
Any potential bug bounties for New Horizons are only eligible to be claimed by those who have created mods for Outer Wilds in the past.

View File

@ -100,8 +100,6 @@ namespace NewHorizons.Builder.Props.Audio
var freq = CollectionUtilities.KeyByValue(_customFrequencyNames, str); var freq = CollectionUtilities.KeyByValue(_customFrequencyNames, str);
if (freq != default) return freq; if (freq != default) return freq;
NHLogger.Log($"Registering new frequency name [{str}]");
if (NumberOfFrequencies == 31) if (NumberOfFrequencies == 31)
{ {
NHLogger.LogWarning($"Can't store any more frequencies, skipping [{str}]"); NHLogger.LogWarning($"Can't store any more frequencies, skipping [{str}]");
@ -111,10 +109,9 @@ namespace NewHorizons.Builder.Props.Audio
freq = EnumUtilities.Create<SignalFrequency>(str); freq = EnumUtilities.Create<SignalFrequency>(str);
_customFrequencyNames.Add(freq, str); _customFrequencyNames.Add(freq, str);
NumberOfFrequencies = EnumUtils.GetValues<SignalFrequency>().Length; NHLogger.Log($"Registered new frequency name [{str}] with value [{(int)freq}] and index [{AudioSignal.FrequencyToIndex(freq)}]");
// This stuff happens after the signalscope is Awake so we have to change the number of frequencies now NumberOfFrequencies = EnumUtils.GetValues<SignalFrequency>().Length;
GameObject.FindObjectOfType<Signalscope>()._strongestSignals = new AudioSignal[NumberOfFrequencies + 1];
return freq; return freq;
} }

View File

@ -290,6 +290,10 @@ namespace NewHorizons.Builder.Props
interact._interactRange = info.range; interact._interactRange = info.range;
// If a dialogue is on the ship, make sure its usable
// Assumes these are inside the ship and not outside, not sure if thats an issue
interact._usableInShip = planetGO.name == "Ship_Body";
if (info.radius <= 0) if (info.radius <= 0)
{ {
sphere.enabled = false; sphere.enabled = false;

View File

@ -1,3 +1,4 @@
using NewHorizons.Components.EOTE;
using NewHorizons.External.Modules.Props; using NewHorizons.External.Modules.Props;
using NewHorizons.External.Modules.Props.EchoesOfTheEye; using NewHorizons.External.Modules.Props.EchoesOfTheEye;
using NewHorizons.Handlers; using NewHorizons.Handlers;
@ -70,6 +71,12 @@ namespace NewHorizons.Builder.Props.EchoesOfTheEye
dreamCandle._startLit = info.startLit; dreamCandle._startLit = info.startLit;
dreamCandle.SetLit(info.startLit, false, true); dreamCandle.SetLit(info.startLit, false, true);
if (info.condition != null)
{
var conditionController = dreamCandle.gameObject.AddComponent<DreamLightConditionController>();
conditionController.SetFromInfo(info.condition);
}
return candleObj; return candleObj;
} }
} }

View File

@ -1,3 +1,4 @@
using NewHorizons.Components.EOTE;
using NewHorizons.External.Modules.Props; using NewHorizons.External.Modules.Props;
using NewHorizons.External.Modules.Props.EchoesOfTheEye; using NewHorizons.External.Modules.Props.EchoesOfTheEye;
using NewHorizons.Handlers; using NewHorizons.Handlers;
@ -116,6 +117,13 @@ namespace NewHorizons.Builder.Props.EchoesOfTheEye
projector._lit = info.startLit; projector._lit = info.startLit;
projector._startLit = info.startLit; projector._startLit = info.startLit;
projector._extinguishOnly = info.extinguishOnly; projector._extinguishOnly = info.extinguishOnly;
if (info.condition != null)
{
var conditionController = projector.gameObject.AddComponent<DreamLightConditionController>();
conditionController.SetFromInfo(info.condition);
}
/* /*
Delay.FireOnNextUpdate(() => Delay.FireOnNextUpdate(() =>
{ {

View File

@ -16,13 +16,6 @@ namespace NewHorizons.Builder.Props
internal static void Init() internal static void Init()
{ {
if (_itemTypes != null)
{
foreach (var value in _itemTypes.Values)
{
EnumUtils.Remove<ItemType>(value);
}
}
_itemTypes = new Dictionary<string, ItemType>(); _itemTypes = new Dictionary<string, ItemType>();
} }
@ -141,11 +134,7 @@ namespace NewHorizons.Builder.Props
{ {
go.layer = Layer.Interactible; go.layer = Layer.Interactible;
var itemType = EnumUtils.TryParse(info.itemType, true, out ItemType result) ? result : ItemType.Invalid; var itemType = GetOrCreateItemType(info.itemType);
if (itemType == ItemType.Invalid && !string.IsNullOrEmpty(info.itemType))
{
itemType = EnumUtilities.Create<ItemType>(info.itemType);
}
var socket = go.GetAddComponent<NHItemSocket>(); var socket = go.GetAddComponent<NHItemSocket>();
socket._sector = sector; socket._sector = sector;
@ -205,7 +194,7 @@ namespace NewHorizons.Builder.Props
} }
else if (!string.IsNullOrEmpty(name)) else if (!string.IsNullOrEmpty(name))
{ {
itemType = EnumUtils.Create<ItemType>(name); itemType = EnumUtilities.Create<ItemType>(name);
_itemTypes.Add(name, itemType); _itemTypes.Add(name, itemType);
} }
return itemType; return itemType;

View File

@ -0,0 +1,25 @@
using NewHorizons.Components.Volumes;
using NewHorizons.External.Modules.Volumes.VolumeInfos;
using UnityEngine;
namespace NewHorizons.Builder.Volumes
{
internal static class ConditionTriggerVolumeBuilder
{
public static ConditionTriggerVolume Make(GameObject planetGO, Sector sector, ConditionTriggerVolumeInfo info)
{
var volume = VolumeBuilder.Make<ConditionTriggerVolume>(planetGO, sector, info);
volume.Condition = info.condition;
volume.Persistent = info.persistent;
volume.Reversible = info.reversible;
volume.Player = info.player;
volume.Probe = info.probe;
volume.Ship = info.ship;
volume.gameObject.SetActive(true);
return volume;
}
}
}

View File

@ -0,0 +1,91 @@
using NewHorizons.Components.Volumes;
using NewHorizons.External.Modules.Volumes.VolumeInfos;
using NewHorizons.Handlers;
using NewHorizons.Utility;
using NewHorizons.Utility.Files;
using NewHorizons.Utility.OuterWilds;
using NewHorizons.Utility.OWML;
using OWML.Common;
using UnityEngine;
namespace NewHorizons.Builder.Volumes
{
internal static class InteractionVolumeBuilder
{
public static InteractReceiver Make(GameObject planetGO, Sector sector, InteractionVolumeInfo info, IModBehaviour mod)
{
// Interaction volumes must use colliders because the first-person interaction system uses raycasting
if (info.shape != null && info.shape?.useShape == false)
{
NHLogger.LogError($"Interaction volumes only support colliders. Affects planet [{planetGO.name}]. Set useShape to false.");
}
// If info.shape was null, it will still default to using a sphere with info.radius, just make sure it does so with a collider
info.shape ??= new();
info.shape.useShape = false;
var receiver = VolumeBuilder.Make<InteractReceiver>(planetGO, sector, info);
receiver.gameObject.layer = Layer.Interactible;
receiver._interactRange = info.range;
receiver._checkViewAngle = info.maxViewAngle.HasValue;
receiver._maxViewAngle = info.maxViewAngle ?? 180f;
receiver._usableInShip = info.usableInShip;
var volume = receiver.gameObject.AddComponent<NHInteractionVolume>();
volume.Reusable = info.reusable;
volume.Condition = info.condition;
volume.Persistent = info.persistent;
if (!string.IsNullOrEmpty(info.audio))
{
var audioSource = receiver.gameObject.AddComponent<AudioSource>();
// This could be more configurable but this should cover the most common use cases without bloating the info object
var owAudioSource = receiver.gameObject.AddComponent<OWAudioSource>();
owAudioSource._audioSource = audioSource;
owAudioSource.playOnAwake = false;
owAudioSource.loop = false;
owAudioSource.SetMaxVolume(1f);
owAudioSource.SetClipSelectionType(OWAudioSource.ClipSelectionOnPlay.RANDOM);
owAudioSource.SetTrack(OWAudioMixer.TrackName.Environment);
AudioUtilities.SetAudioClip(owAudioSource, info.audio, mod);
}
if (!string.IsNullOrEmpty(info.pathToAnimator))
{
var animObj = planetGO.transform.Find(info.pathToAnimator);
if (animObj == null)
{
NHLogger.LogError($"Couldn't find child of {planetGO.transform.GetPath()} at {info.pathToAnimator}");
}
else
{
var animator = animObj.GetComponent<Animator>();
if (animator == null)
{
NHLogger.LogError($"Couldn't find Animator on {animObj.name} at {info.pathToAnimator}");
}
else
{
volume.TargetAnimator = animator;
volume.AnimationTrigger = info.animationTrigger;
}
}
}
receiver.gameObject.SetActive(true);
var text = TranslationHandler.GetTranslation(info.prompt, TranslationHandler.TextType.UI);
Delay.FireOnNextUpdate(() =>
{
// This NREs if set immediately
receiver.ChangePrompt(text);
});
return receiver;
}
}
}

View File

@ -35,6 +35,13 @@ namespace NewHorizons.Builder.Volumes
AudioVolumeBuilder.Make(go, sector, audioVolume, mod); AudioVolumeBuilder.Make(go, sector, audioVolume, mod);
} }
} }
if (config.Volumes.conditionTriggerVolumes != null)
{
foreach (var conditionTriggerVolume in config.Volumes.conditionTriggerVolumes)
{
ConditionTriggerVolumeBuilder.Make(go, sector, conditionTriggerVolume);
}
}
if (config.Volumes.dayNightAudioVolumes != null) if (config.Volumes.dayNightAudioVolumes != null)
{ {
foreach (var dayNightAudioVolume in config.Volumes.dayNightAudioVolumes) foreach (var dayNightAudioVolume in config.Volumes.dayNightAudioVolumes)
@ -63,6 +70,13 @@ namespace NewHorizons.Builder.Volumes
VolumeBuilder.MakeAndEnable<MapRestrictionVolume>(go, sector, mapRestrictionVolume); VolumeBuilder.MakeAndEnable<MapRestrictionVolume>(go, sector, mapRestrictionVolume);
} }
} }
if (config.Volumes.interactionVolumes != null)
{
foreach (var interactionVolume in config.Volumes.interactionVolumes)
{
InteractionVolumeBuilder.Make(go, sector, interactionVolume, mod);
}
}
if (config.Volumes.interferenceVolumes != null) if (config.Volumes.interferenceVolumes != null)
{ {
foreach (var interferenceVolume in config.Volumes.interferenceVolumes) foreach (var interferenceVolume in config.Volumes.interferenceVolumes)

View File

@ -0,0 +1,85 @@
using NewHorizons.External.Modules.Props.EchoesOfTheEye;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
namespace NewHorizons.Components.EOTE
{
public class DreamLightConditionController : MonoBehaviour
{
public string Condition { get; set; }
public bool Persistent { get; set; }
public bool Reversible { get; set; }
public bool OnExtinguish { get; set; }
DreamObjectProjector _projector;
DreamCandle _dreamCandle;
public void SetFromInfo(DreamLightConditionInfo info)
{
Condition = info.condition;
Persistent = info.persistent;
Reversible = info.reversible;
OnExtinguish = info.onExtinguish;
}
protected void Awake()
{
_projector = GetComponent<DreamObjectProjector>();
_projector.OnProjectorLit.AddListener(OnProjectorLit);
_projector.OnProjectorExtinguished.AddListener(OnProjectorExtinguished);
_dreamCandle = GetComponent<DreamCandle>();
if (_dreamCandle != null)
{
_dreamCandle.OnLitStateChanged.AddListener(OnCandleLitStateChanged);
}
}
protected void OnDestroy()
{
if (_projector != null)
{
_projector.OnProjectorLit.RemoveListener(OnProjectorLit);
_projector.OnProjectorExtinguished.RemoveListener(OnProjectorExtinguished);
}
if (_dreamCandle != null)
{
_dreamCandle.OnLitStateChanged.RemoveListener(OnCandleLitStateChanged);
}
}
private void OnProjectorLit()
{
HandleCondition(!OnExtinguish);
}
private void OnProjectorExtinguished()
{
HandleCondition(OnExtinguish);
}
private void OnCandleLitStateChanged()
{
HandleCondition(OnExtinguish ? !_dreamCandle._lit : _dreamCandle._lit);
}
private void HandleCondition(bool shouldSet)
{
if (shouldSet || Reversible)
{
if (Persistent)
{
PlayerData.SetPersistentCondition(Condition, shouldSet);
}
else
{
DialogueConditionManager.SharedInstance.SetConditionState(Condition, shouldSet);
}
}
}
}
}

View File

@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
namespace NewHorizons.Components.Volumes
{
public class ConditionTriggerVolume : BaseVolume
{
public string Condition { get; set; }
public bool Persistent { get; set; }
public bool Reversible { get; set; }
public bool Player { get; set; } = true;
public bool Probe { get; set; }
public bool Ship { get; set; }
public override void OnTriggerVolumeEntry(GameObject hitObj)
{
if (TestHitObject(hitObj))
{
if (Persistent)
{
PlayerData.SetPersistentCondition(Condition, true);
}
else
{
DialogueConditionManager.SharedInstance.SetConditionState(Condition, true);
}
}
}
public override void OnTriggerVolumeExit(GameObject hitObj)
{
if (Reversible && TestHitObject(hitObj))
{
if (Persistent)
{
PlayerData.SetPersistentCondition(Condition, false);
}
else
{
DialogueConditionManager.SharedInstance.SetConditionState(Condition, false);
}
}
}
bool TestHitObject(GameObject hitObj)
{
if (Player && hitObj.CompareTag("PlayerDetector"))
{
return true;
}
if (Probe && hitObj.CompareTag("ProbeDetector"))
{
return true;
}
if (Ship && hitObj.CompareTag("ShipDetector"))
{
return true;
}
return false;
}
}
}

View File

@ -0,0 +1,69 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using UnityEngine;
namespace NewHorizons.Components.Volumes
{
public class NHInteractionVolume : MonoBehaviour
{
public bool Reusable { get; set; }
public string Condition { get; set; }
public bool Persistent { get; set; }
public Animator TargetAnimator { get; set; }
public string AnimationTrigger { get; set; }
InteractReceiver _interactReceiver;
OWAudioSource _audioSource;
protected void Awake()
{
_interactReceiver = GetComponent<InteractReceiver>();
_audioSource = GetComponent<OWAudioSource>();
_interactReceiver.OnPressInteract += OnInteract;
}
protected void OnDestroy()
{
_interactReceiver.OnPressInteract -= OnInteract;
}
protected void OnInteract()
{
if (!string.IsNullOrEmpty(Condition))
{
if (Persistent)
{
PlayerData.SetPersistentCondition(Condition, true);
}
else
{
DialogueConditionManager.SharedInstance.SetConditionState(Condition, true);
}
}
if (_audioSource != null)
{
_audioSource.Play();
}
if (TargetAnimator)
{
TargetAnimator.SetTrigger(AnimationTrigger);
}
if (Reusable)
{
_interactReceiver.ResetInteraction();
_interactReceiver.EnableInteraction();
}
else
{
_interactReceiver.DisableInteraction();
}
}
}
}

View File

@ -20,5 +20,10 @@ namespace NewHorizons.External.Modules.Props.EchoesOfTheEye
/// Whether the candle should start lit or extinguished. /// Whether the candle should start lit or extinguished.
/// </summary> /// </summary>
public bool startLit; public bool startLit;
/// <summary>
/// A condition to set when the candle is lit.
/// </summary>
public DreamLightConditionInfo condition;
} }
} }

View File

@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace NewHorizons.External.Modules.Props.EchoesOfTheEye
{
public class DreamLightConditionInfo
{
/// <summary>
/// The name of the dialogue condition or persistent condition to set when the light is lit.
/// </summary>
public string condition;
/// <summary>
/// If true, the condition will persist across all future loops until unset.
/// </summary>
public bool persistent;
/// <summary>
/// Whether to unset the condition when the light is extinguished again.
/// </summary>
public bool reversible;
/// <summary>
/// Whether to set the condition when the light is extinguished instead. If `reversible` is true, the condition will be unset when the light is lit again.
/// </summary>
public bool onExtinguish;
}
}

View File

@ -45,5 +45,10 @@ namespace NewHorizons.External.Modules.Props.EchoesOfTheEye
/// If set, projected objects will be set to fully active or fully disabled instantly instead of smoothly fading lights/renderers/colliders. Use this if the normal behavior is insufficient for the objects you're using. /// If set, projected objects will be set to fully active or fully disabled instantly instead of smoothly fading lights/renderers/colliders. Use this if the normal behavior is insufficient for the objects you're using.
/// </summary> /// </summary>
public bool toggleProjectedObjectsActive; public bool toggleProjectedObjectsActive;
/// <summary>
/// A condition to set when the totem is lit.
/// </summary>
public DreamLightConditionInfo condition;
} }
} }

View File

@ -6,8 +6,8 @@ namespace NewHorizons.External.Modules.VariableSize
public class VariableSizeModule public class VariableSizeModule
{ {
/// <summary> /// <summary>
/// Scale this object over time. Time value is in minutes. /// Scale this object over time. Time is in minutes. Value is a multiplier of the size of the object.
/// </summary> /// </summary>
public TimeValuePair[] curve; public TimeValuePair[] curve;
} }
} }

View File

@ -0,0 +1,39 @@
using Newtonsoft.Json;
using System.ComponentModel;
namespace NewHorizons.External.Modules.Volumes.VolumeInfos
{
[JsonObject]
public class ConditionTriggerVolumeInfo : VolumeInfo
{
/// <summary>
/// The name of the dialogue condition or persistent condition to set when entering the volume.
/// </summary>
public string condition;
/// <summary>
/// If true, the condition will persist across all future loops until unset.
/// </summary>
public bool persistent;
/// <summary>
/// Whether to unset the condition when existing the volume.
/// </summary>
public bool reversible;
/// <summary>
/// Whether to set the condition when the player enters this volume. Defaults to true.
/// </summary>
[DefaultValue(true)] public bool player = true;
/// <summary>
/// Whether to set the condition when the scout probe enters this volume.
/// </summary>
public bool probe;
/// <summary>
/// Whether to set the condition when the ship enters this volume.
/// </summary>
public bool ship;
}
}

View File

@ -0,0 +1,65 @@
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 InteractionVolumeInfo : VolumeInfo
{
/// <summary>
/// The prompt to display when the volume is interacted with.
/// </summary>
public string prompt;
/// <summary>
/// The range at which the volume can be interacted with.
/// </summary>
[DefaultValue(2f)] public float range = 2f;
/// <summary>
/// The max view angle (in degrees) the player can see the volume with to interact with it. This will effectively be a cone extending from the volume's center forwards (along the Z axis) based on the volume's rotation.
/// If not specified, no view angle restriction will be applied.
/// </summary>
public float? maxViewAngle;
/// <summary>
/// Whether the volume can be interacted with while in the ship.
/// </summary>
public bool usableInShip;
/// <summary>
/// Whether the volume can be interacted with multiple times.
/// </summary>
public bool reusable;
/// <summary>
/// The name of the dialogue condition or persistent condition to set when the volume is interacted with.
/// </summary>
public string condition;
/// <summary>
/// If true, the condition will persist across all future loops until unset.
/// </summary>
public bool persistent;
/// <summary>
/// A sound to play when the volume is interacted with. Can be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list.
/// </summary>
public string audio;
/// <summary>
/// A path to an animator component where an animation will be triggered when the volume is interacted with.
/// </summary>
public string pathToAnimator;
/// <summary>
/// The name of an animation trigger to set on the animator when the volume is interacted with.
/// </summary>
public string animationTrigger;
}
}

View File

@ -11,6 +11,11 @@ namespace NewHorizons.External.Modules.Volumes
/// </summary> /// </summary>
public AudioVolumeInfo[] audioVolumes; public AudioVolumeInfo[] audioVolumes;
/// <summary>
/// Add condition trigger volumes to this planet. Sets a condition when the player, scout, or ship enters this volume.
/// </summary>
public ConditionTriggerVolumeInfo[] conditionTriggerVolumes;
/// <summary> /// <summary>
/// Add day night audio volumes to this planet. These volumes play a different clip depending on the time of day. /// Add day night audio volumes to this planet. These volumes play a different clip depending on the time of day.
/// </summary> /// </summary>
@ -38,6 +43,12 @@ namespace NewHorizons.External.Modules.Volumes
/// </summary> /// </summary>
public HazardVolumeInfo[] hazardVolumes; public HazardVolumeInfo[] hazardVolumes;
/// <summary>
/// Add interaction volumes to this planet.
/// They can be interacted with by the player to trigger various effects.
/// </summary>
public InteractionVolumeInfo[] interactionVolumes;
/// <summary> /// <summary>
/// Add interference volumes to this planet. /// Add interference volumes to this planet.
/// Hides HUD markers of ship scout/probe and prevents scout photos if you are not inside the volume together with ship or scout probe. /// Hides HUD markers of ship scout/probe and prevents scout photos if you are not inside the volume together with ship or scout probe.

View File

@ -42,6 +42,16 @@ namespace NewHorizons.Handlers
public static void Init(List<NewHorizonsBody> bodies) public static void Init(List<NewHorizonsBody> bodies)
{ {
// TH gets preloaded in title screen. custom systems dont need this
if (Main.Instance.CurrentStarSystem is not ("SolarSystem" or "EyeOfTheUniverse"))
{
foreach (var bundle in StreamingManager.s_activeBundles)
{
// save memory NOW instead of next frame when other stuff has loaded and taken memory
bundle.UnloadImmediate();
}
}
// Start by destroying all planets if need be // Start by destroying all planets if need be
if (Main.SystemDict[Main.Instance.CurrentStarSystem].Config.destroyStockPlanets) if (Main.SystemDict[Main.Instance.CurrentStarSystem].Config.destroyStockPlanets)
{ {

View File

@ -83,7 +83,7 @@ namespace NewHorizons
public GameObject GetPlanet(string name) public GameObject GetPlanet(string name)
{ {
return Main.BodyDict.Values.SelectMany(x => x)?.ToList()?.FirstOrDefault(x => x.Config.name == name)?.Object; return Main.BodyDict[Main.Instance.CurrentStarSystem].FirstOrDefault(x => x.Config.name == name)?.Object;
} }
public string GetCurrentStarSystem() => Main.Instance.CurrentStarSystem; public string GetCurrentStarSystem() => Main.Instance.CurrentStarSystem;

View File

@ -45,7 +45,9 @@ namespace NewHorizons.Patches.SignalPatches
SignalFrequency.HideAndSeek => 5, SignalFrequency.HideAndSeek => 5,
SignalFrequency.Radio => 6, SignalFrequency.Radio => 6,
SignalFrequency.Statue => 7, SignalFrequency.Statue => 7,
_ => (int)(Mathf.Log((float)frequency) / Mathf.Log(2f)),// Frequencies are in powers of 2 // Can't cast to int because floating point error, it was doing 12.9999999 -> 12
// Frequencies are in powers of 2
_ => Mathf.RoundToInt(Mathf.Log((float)frequency) / Mathf.Log(2f)),
}; };
return false; return false;
} }

View File

@ -11,7 +11,7 @@ namespace NewHorizons.Patches.SignalPatches
[HarmonyPatch(nameof(Signalscope.Awake))] [HarmonyPatch(nameof(Signalscope.Awake))]
public static void Signalscope_Awake(Signalscope __instance) public static void Signalscope_Awake(Signalscope __instance)
{ {
__instance._strongestSignals = new AudioSignal[8]; __instance._strongestSignals = new AudioSignal[32];
} }
[HarmonyPrefix] [HarmonyPrefix]

View File

@ -1846,7 +1846,7 @@
"properties": { "properties": {
"curve": { "curve": {
"type": "array", "type": "array",
"description": "Scale this object over time. Time value is in minutes.", "description": "Scale this object over time. Time is in minutes. Value is a multiplier of the size of the object.",
"items": { "items": {
"$ref": "#/definitions/TimeValuePair" "$ref": "#/definitions/TimeValuePair"
} }
@ -2035,7 +2035,7 @@
"properties": { "properties": {
"curve": { "curve": {
"type": "array", "type": "array",
"description": "Scale this object over time. Time value is in minutes.", "description": "Scale this object over time. Time is in minutes. Value is a multiplier of the size of the object.",
"items": { "items": {
"$ref": "#/definitions/TimeValuePair" "$ref": "#/definitions/TimeValuePair"
} }
@ -4571,6 +4571,10 @@
"startLit": { "startLit": {
"type": "boolean", "type": "boolean",
"description": "Whether the candle should start lit or extinguished." "description": "Whether the candle should start lit or extinguished."
},
"condition": {
"description": "A condition to set when the candle is lit.",
"$ref": "#/definitions/DreamLightConditionInfo"
} }
} }
}, },
@ -4600,6 +4604,28 @@
"pile" "pile"
] ]
}, },
"DreamLightConditionInfo": {
"type": "object",
"additionalProperties": false,
"properties": {
"condition": {
"type": "string",
"description": "The name of the dialogue condition or persistent condition to set when the light is lit."
},
"persistent": {
"type": "boolean",
"description": "If true, the condition will persist across all future loops until unset."
},
"reversible": {
"type": "boolean",
"description": "Whether to unset the condition when the light is extinguished again."
},
"onExtinguish": {
"type": "boolean",
"description": "Whether to set the condition when the light is extinguished instead. If `reversible` is true, the condition will be unset when the light is lit again."
}
}
},
"ProjectionTotemInfo": { "ProjectionTotemInfo": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@ -4667,6 +4693,10 @@
"toggleProjectedObjectsActive": { "toggleProjectedObjectsActive": {
"type": "boolean", "type": "boolean",
"description": "If set, projected objects will be set to fully active or fully disabled instantly instead of smoothly fading lights/renderers/colliders. Use this if the normal behavior is insufficient for the objects you're using." "description": "If set, projected objects will be set to fully active or fully disabled instantly instead of smoothly fading lights/renderers/colliders. Use this if the normal behavior is insufficient for the objects you're using."
},
"condition": {
"description": "A condition to set when the totem is lit.",
"$ref": "#/definitions/DreamLightConditionInfo"
} }
} }
}, },
@ -4778,7 +4808,7 @@
"properties": { "properties": {
"curve": { "curve": {
"type": "array", "type": "array",
"description": "Scale this object over time. Time value is in minutes.", "description": "Scale this object over time. Time is in minutes. Value is a multiplier of the size of the object.",
"items": { "items": {
"$ref": "#/definitions/TimeValuePair" "$ref": "#/definitions/TimeValuePair"
} }
@ -5059,7 +5089,7 @@
"properties": { "properties": {
"curve": { "curve": {
"type": "array", "type": "array",
"description": "Scale this object over time. Time value is in minutes.", "description": "Scale this object over time. Time is in minutes. Value is a multiplier of the size of the object.",
"items": { "items": {
"$ref": "#/definitions/TimeValuePair" "$ref": "#/definitions/TimeValuePair"
} }
@ -5225,7 +5255,7 @@
"properties": { "properties": {
"curve": { "curve": {
"type": "array", "type": "array",
"description": "Scale this object over time. Time value is in minutes.", "description": "Scale this object over time. Time is in minutes. Value is a multiplier of the size of the object.",
"items": { "items": {
"$ref": "#/definitions/TimeValuePair" "$ref": "#/definitions/TimeValuePair"
} }
@ -5365,6 +5395,13 @@
"$ref": "#/definitions/AudioVolumeInfo" "$ref": "#/definitions/AudioVolumeInfo"
} }
}, },
"conditionTriggerVolumes": {
"type": "array",
"description": "Add condition trigger volumes to this planet. Sets a condition when the player, scout, or ship enters this volume.",
"items": {
"$ref": "#/definitions/ConditionTriggerVolumeInfo"
}
},
"dayNightAudioVolumes": { "dayNightAudioVolumes": {
"type": "array", "type": "array",
"description": "Add day night audio volumes to this planet. These volumes play a different clip depending on the time of day.", "description": "Add day night audio volumes to this planet. These volumes play a different clip depending on the time of day.",
@ -5397,6 +5434,13 @@
"$ref": "#/definitions/HazardVolumeInfo" "$ref": "#/definitions/HazardVolumeInfo"
} }
}, },
"interactionVolumes": {
"type": "array",
"description": "Add interaction volumes to this planet.\nThey can be interacted with by the player to trigger various effects.",
"items": {
"$ref": "#/definitions/InteractionVolumeInfo"
}
},
"interferenceVolumes": { "interferenceVolumes": {
"type": "array", "type": "array",
"description": "Add interference volumes to this planet.\nHides HUD markers of ship scout/probe and prevents scout photos if you are not inside the volume together with ship or scout probe.", "description": "Add interference volumes to this planet.\nHides HUD markers of ship scout/probe and prevents scout photos if you are not inside the volume together with ship or scout probe.",
@ -5724,6 +5768,74 @@
"manual" "manual"
] ]
}, },
"ConditionTriggerVolumeInfo": {
"type": "object",
"additionalProperties": false,
"properties": {
"radius": {
"type": "number",
"description": "The radius of this volume, if a shape is not specified.",
"format": "float",
"default": 1.0
},
"shape": {
"description": "The shape of this volume. Defaults to a sphere with a radius of `radius` if not specified.",
"$ref": "#/definitions/ShapeInfo"
},
"rotation": {
"description": "Rotation of the object",
"$ref": "#/definitions/MVector3"
},
"alignRadial": {
"type": [
"boolean",
"null"
],
"description": "Do we try to automatically align this object to stand upright relative to the body's center? Stacks with rotation.\nDefaults to true for geysers, tornados, and volcanoes, and false for everything else."
},
"position": {
"description": "Position of the object",
"$ref": "#/definitions/MVector3"
},
"isRelativeToParent": {
"type": "boolean",
"description": "Whether the positional and rotational coordinates are relative to parent instead of the root planet object."
},
"parentPath": {
"type": "string",
"description": "The relative path from the planet to the parent of this object. Optional (will default to the root sector)."
},
"rename": {
"type": "string",
"description": "An optional rename of this object"
},
"condition": {
"type": "string",
"description": "The name of the dialogue condition or persistent condition to set when entering the volume."
},
"persistent": {
"type": "boolean",
"description": "If true, the condition will persist across all future loops until unset."
},
"reversible": {
"type": "boolean",
"description": "Whether to unset the condition when existing the volume."
},
"player": {
"type": "boolean",
"description": "Whether to set the condition when the player enters this volume. Defaults to true.",
"default": true
},
"probe": {
"type": "boolean",
"description": "Whether to set the condition when the scout probe enters this volume."
},
"ship": {
"type": "boolean",
"description": "Whether to set the condition when the ship enters this volume."
}
}
},
"DayNightAudioVolumeInfo": { "DayNightAudioVolumeInfo": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@ -6528,6 +6640,95 @@
} }
} }
}, },
"InteractionVolumeInfo": {
"type": "object",
"additionalProperties": false,
"properties": {
"radius": {
"type": "number",
"description": "The radius of this volume, if a shape is not specified.",
"format": "float",
"default": 1.0
},
"shape": {
"description": "The shape of this volume. Defaults to a sphere with a radius of `radius` if not specified.",
"$ref": "#/definitions/ShapeInfo"
},
"rotation": {
"description": "Rotation of the object",
"$ref": "#/definitions/MVector3"
},
"alignRadial": {
"type": [
"boolean",
"null"
],
"description": "Do we try to automatically align this object to stand upright relative to the body's center? Stacks with rotation.\nDefaults to true for geysers, tornados, and volcanoes, and false for everything else."
},
"position": {
"description": "Position of the object",
"$ref": "#/definitions/MVector3"
},
"isRelativeToParent": {
"type": "boolean",
"description": "Whether the positional and rotational coordinates are relative to parent instead of the root planet object."
},
"parentPath": {
"type": "string",
"description": "The relative path from the planet to the parent of this object. Optional (will default to the root sector)."
},
"rename": {
"type": "string",
"description": "An optional rename of this object"
},
"prompt": {
"type": "string",
"description": "The prompt to display when the volume is interacted with."
},
"range": {
"type": "number",
"description": "The range at which the volume can be interacted with.",
"format": "float",
"default": 2.0
},
"maxViewAngle": {
"type": [
"null",
"number"
],
"description": "The max view angle (in degrees) the player can see the volume with to interact with it. This will effectively be a cone extending from the volume's center forwards (along the Z axis) based on the volume's rotation.\nIf not specified, no view angle restriction will be applied.",
"format": "float"
},
"usableInShip": {
"type": "boolean",
"description": "Whether the volume can be interacted with while in the ship."
},
"reusable": {
"type": "boolean",
"description": "Whether the volume can be interacted with multiple times."
},
"condition": {
"type": "string",
"description": "The name of the dialogue condition or persistent condition to set when the volume is interacted with."
},
"persistent": {
"type": "boolean",
"description": "If true, the condition will persist across all future loops until unset."
},
"audio": {
"type": "string",
"description": "A sound to play when the volume is interacted with. Can be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list."
},
"pathToAnimator": {
"type": "string",
"description": "A path to an animator component where an animation will be triggered when the volume is interacted with. "
},
"animationTrigger": {
"type": "string",
"description": "The name of an animation trigger to set on the animator when the volume is interacted with."
}
}
},
"VolumeInfo": { "VolumeInfo": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@ -7629,7 +7830,7 @@
"properties": { "properties": {
"curve": { "curve": {
"type": "array", "type": "array",
"description": "Scale this object over time. Time value is in minutes.", "description": "Scale this object over time. Time is in minutes. Value is a multiplier of the size of the object.",
"items": { "items": {
"$ref": "#/definitions/TimeValuePair" "$ref": "#/definitions/TimeValuePair"
} }

View File

@ -4,7 +4,7 @@
"author": "xen, Bwc9876, JohnCorby, MegaPiggy, and friends", "author": "xen, Bwc9876, JohnCorby, MegaPiggy, and friends",
"name": "New Horizons", "name": "New Horizons",
"uniqueName": "xen.NewHorizons", "uniqueName": "xen.NewHorizons",
"version": "1.28.4", "version": "1.28.8",
"owmlVersion": "2.12.1", "owmlVersion": "2.12.1",
"dependencies": [ "JohnCorby.VanillaFix", "xen.CommonCameraUtility", "dgarro.CustomShipLogModes" ], "dependencies": [ "JohnCorby.VanillaFix", "xen.CommonCameraUtility", "dgarro.CustomShipLogModes" ],
"conflicts": [ "PacificEngine.OW_CommonResources" ], "conflicts": [ "PacificEngine.OW_CommonResources" ],

View File

@ -23,4 +23,6 @@ To unlock ship logs after reading each text block, add a `<ShipLogConditions>` n
In your planet config, you must define where the Nomai text is positioned. See [the translator text json schema](/schemas/body-schema/defs/translatortextinfo/) for more info. In your planet config, you must define where the Nomai text is positioned. See [the translator text json schema](/schemas/body-schema/defs/translatortextinfo/) for more info.
You can input a `seed` for a wall of text which will randomly generate the position of each arc. To test out different combinations, just keep incrementing the number and then hit "Reload Configs" from the pause menu with debug mode on. This seed ensures the same positioning each time the mod is played. Alternatively, you can use `arcInfo` to set the position and rotation of all text arcs, as well as determining their types (adult, teenager, child, or Stranger). The various age stages make the text look messier, while Stranger allows you to make a translatable version of the DLC text. You can input a `seed` for a wall of text which will randomly generate the position of each arc. To test out different combinations, just keep incrementing the number and then hit "Reload Configs" from the pause menu with debug mode on. This seed ensures the same positioning each time the mod is played. Alternatively, you can use `arcInfo` to set the position and rotation of all text arcs, as well as determining their types (adult, teenager, child, or Stranger). The various age stages make the text look messier, while Stranger allows you to make a translatable version of the DLC text.
If you decide to arrange text manually in your mod, the [Unity Explorer](https://outerwildsmods.com/mods/unityexplorer/) and [Nomai Text Printer](https://github.com/coderCleric/NomaiTextPrinter) mods can make that much more convenient.

View File

@ -56,6 +56,7 @@ These mods are useful when developing your addon
- [Save Editor](https://outerwildsmods.com/mods/saveeditor) - Useful when creating a custom [ship log](/ship-log), can be used to reveal all custom facts so you can see them in the ship's computer. - [Save Editor](https://outerwildsmods.com/mods/saveeditor) - Useful when creating a custom [ship log](/ship-log), can be used to reveal all custom facts so you can see them in the ship's computer.
- [Time Saver](https://outerwildsmods.com/mods/timesaver/) - Lets you skip some repeated cutscenes and get into the game faster. - [Time Saver](https://outerwildsmods.com/mods/timesaver/) - Lets you skip some repeated cutscenes and get into the game faster.
- [The Examples Mod](https://github.com/Outer-Wilds-New-Horizons/nh-examples) - A mod that contains examples of how to use New Horizons features. - [The Examples Mod](https://github.com/Outer-Wilds-New-Horizons/nh-examples) - A mod that contains examples of how to use New Horizons features.
- [Nomai Text Printer](https://github.com/coderCleric/NomaiTextPrinter) - Useful for saving text changes (such as moving arcs with Unity Explorer) to your json files.
## Helpful Tools ## Helpful Tools