Title Screen config (#1028)

## Major features
- Adds title screen configuration (fixes #1027)

Todo list:
- [x] menuColor
- [x] factRequiredForTitle
- [x] conditionRequiredForTitle
- [x] skyBox
- [x] Music
- [x] rotationSpeed
- [x] menuPlanet
- [x] mergeWithOtherTitles
- [x] title screen handler api method
- [x] Docs
- [x] Multiple title screens for one mod


![image](https://github.com/user-attachments/assets/cdf4658c-eb39-4c5f-a0c4-9f93ed3493c9)
This commit is contained in:
xen-42 2025-02-17 19:55:56 -05:00 committed by GitHub
commit 144421ca51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1539 additions and 271 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -210,6 +210,7 @@ namespace NewHorizons.Builder.Body
{
foreach (var detailInfo in body.Config.Props.proxyDetails)
{
// Thought about switching these to SimplifiedDetailInfo but we use AlignRadial with these so we can't
DetailBuilder.Make(proxy, null, body.Mod, detailInfo);
}
}

View File

@ -1,4 +1,4 @@
using NewHorizons.External.Configs;
using NewHorizons.External.Modules;
using NewHorizons.Utility;
using NewHorizons.Utility.Files;
using NewHorizons.Utility.OWML;
@ -13,13 +13,13 @@ namespace NewHorizons.Builder.StarSystem
{
private static readonly Shader _unlitShader = Shader.Find("Unlit/Texture");
public static void Make(StarSystemConfig.SkyboxModule module, IModBehaviour mod)
public static void Make(SkyboxModule module, IModBehaviour mod)
{
NHLogger.Log("Building Skybox");
BuildSkySphere(module, mod);
}
public static GameObject BuildSkySphere(StarSystemConfig.SkyboxModule module, IModBehaviour mod)
public static GameObject BuildSkySphere(SkyboxModule module, IModBehaviour mod)
{
var skybox = SearchUtilities.Find("Skybox");

View File

@ -181,51 +181,6 @@ namespace NewHorizons.External.Configs
public int[] z;
}
[JsonObject]
public class SkyboxModule
{
/// <summary>
/// Whether to destroy the star field around the player
/// </summary>
public bool destroyStarField;
/// <summary>
/// Whether to use a cube for the skybox instead of a smooth sphere
/// </summary>
public bool useCube;
/// <summary>
/// Relative filepath to the texture to use for the skybox's positive X direction
/// </summary>
public string rightPath;
/// <summary>
/// Relative filepath to the texture to use for the skybox's negative X direction
/// </summary>
public string leftPath;
/// <summary>
/// Relative filepath to the texture to use for the skybox's positive Y direction
/// </summary>
public string topPath;
/// <summary>
/// Relative filepath to the texture to use for the skybox's negative Y direction
/// </summary>
public string bottomPath;
/// <summary>
/// Relative filepath to the texture to use for the skybox's positive Z direction
/// </summary>
public string frontPath;
/// <summary>
/// Relative filepath to the texture to use for the skybox's negative Z direction
/// </summary>
public string backPath;
}
[JsonObject]
public class GlobalMusicModule
{

View File

@ -0,0 +1,119 @@
using NewHorizons.External.Modules;
using NewHorizons.External.Modules.Props;
using NewHorizons.External.SerializableData;
using NewHorizons.Handlers;
using Newtonsoft.Json;
namespace NewHorizons.External.Configs
{
[JsonObject]
public class TitleScreenConfig
{
/// <summary>
/// Create title screens
/// </summary>
public TitleScreenInfo[] titleScreens = new TitleScreenInfo[0];
}
[JsonObject]
public class TitleScreenInfo
{
/// <summary>
/// Colour of the text on the main menu
/// </summary>
public MColor menuTextTint;
/// <summary>
/// Ship log fact required for this title screen to appear.
/// </summary>
public string factRequiredForTitle;
/// <summary>
/// Persistent condition required for this title screen to appear.
/// </summary>
public string persistentConditionRequiredForTitle;
/// <summary>
/// If set to true, NH generated planets will not show on the title screen. If false, this title screen has the same chance as other NH planet title screens to show.
/// </summary>
public bool disableNHPlanets = true;
/// <summary>
/// If set to true, this custom title screen will merge with all other custom title screens with shareTitleScreen set to true. If false, NH will randomly select between this and other valid title screens that are loaded.
/// </summary>
public bool shareTitleScreen = true;
/// <summary>
/// Customize the skybox for this title screen
/// </summary>
public SkyboxModule Skybox;
/// <summary>
/// The music audio that will play on the title screen. Can be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list.
/// </summary>
public string music;
/// <summary>
/// The ambience audio that will play on the title screen. Can be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list.
/// </summary>
public string ambience;
/// <summary>
/// Edit properties of the background
/// </summary>
public BackgroundModule Background;
/// <summary>
/// Edit properties of the main menu planet
/// </summary>
public MenuPlanetModule MenuPlanet;
[JsonObject]
public class BackgroundModule
{
/// <summary>
/// Changes the speed the background rotates (and by extension the main menu planet). This is in degrees per second.
/// </summary>
public float rotationSpeed = 1;
/// <summary>
/// Disables the renderers of objects at the provided paths
/// </summary>
public string[] removeChildren;
/// <summary>
/// A list of DetailInfos to populate the background with.
/// </summary>
public SimplifiedDetailInfo[] details;
}
[JsonObject]
public class MenuPlanetModule
{
/// <summary>
/// Disables the renderers of the main menu planet and all objects on it (this is to improve compatibility with other mods that don't use the NH title screen json).
/// </summary>
public bool destroyMenuPlanet = false;
/// <summary>
/// Disables the renderers of objects at the provided paths
/// </summary>
public string[] removeChildren;
/// <summary>
/// A list of DetailInfos to populate the main menu planet with.
/// </summary>
public SimplifiedDetailInfo[] details;
/// <summary>
/// Changes the speed the main menu planet. This is in degrees per second.
/// </summary>
public float rotationSpeed = 2;
}
/// <summary>
/// Extra data that may be used by extension mods
/// </summary>
public object extras;
}
}

View File

@ -6,12 +6,15 @@ using System.ComponentModel;
namespace NewHorizons.External.Modules.Props
{
/// <summary>
/// A lesser form of DetailInfo used for the title screen since that supports fewer features
/// </summary>
[JsonObject]
public class DetailInfo : GeneralPropInfo
public class SimplifiedDetailInfo : GeneralPropInfo
{
public DetailInfo() { }
public SimplifiedDetailInfo() { }
public DetailInfo(GeneralPointPropInfo info)
public SimplifiedDetailInfo(GeneralPointPropInfo info)
{
JsonConvert.PopulateObject(JsonConvert.SerializeObject(info), this);
}
@ -47,6 +50,23 @@ namespace NewHorizons.External.Modules.Props
/// Scale each axis of the prop. Overrides `scale`.
/// </summary>
public MVector3 stretch;
}
[JsonObject]
public class DetailInfo : SimplifiedDetailInfo
{
public DetailInfo() { }
public DetailInfo(GeneralPointPropInfo info)
{
JsonConvert.PopulateObject(JsonConvert.SerializeObject(info), this);
}
public DetailInfo(SimplifiedDetailInfo info)
{
keepLoaded = true;
JsonConvert.PopulateObject(JsonConvert.SerializeObject(info), this);
}
[Obsolete("Use QuantumDetailInfo")]
public string quantumGroupID;

View File

@ -14,7 +14,7 @@ namespace NewHorizons.External.Modules.Props.EchoesOfTheEye
/// <summary>
/// The type of dream candle this is.
/// </summary>
[DefaultValue(DreamCandleType.Ground)] public DreamCandleType type = DreamCandleType.Ground;
[DefaultValue("ground")] public DreamCandleType type = DreamCandleType.Ground;
/// <summary>
/// Whether the candle should start lit or extinguished.

View File

@ -0,0 +1,48 @@
using Newtonsoft.Json;
namespace NewHorizons.External.Modules
{
[JsonObject]
public class SkyboxModule
{
/// <summary>
/// Whether to destroy the star field around the player
/// </summary>
public bool destroyStarField;
/// <summary>
/// Whether to use a cube for the skybox instead of a smooth sphere
/// </summary>
public bool useCube;
/// <summary>
/// Relative filepath to the texture to use for the skybox's positive X direction
/// </summary>
public string rightPath;
/// <summary>
/// Relative filepath to the texture to use for the skybox's negative X direction
/// </summary>
public string leftPath;
/// <summary>
/// Relative filepath to the texture to use for the skybox's positive Y direction
/// </summary>
public string topPath;
/// <summary>
/// Relative filepath to the texture to use for the skybox's negative Y direction
/// </summary>
public string bottomPath;
/// <summary>
/// Relative filepath to the texture to use for the skybox's positive Z direction
/// </summary>
public string frontPath;
/// <summary>
/// Relative filepath to the texture to use for the skybox's negative Z direction
/// </summary>
public string backPath;
}
}

View File

@ -15,14 +15,15 @@ namespace NewHorizons.Handlers
{
private static Dictionary<string, AudioType> _customAudioTypes;
private static List<AudioLibrary.AudioEntry> _audioEntries;
private static bool _postInitialized = false;
public static void Init()
{
_customAudioTypes = new Dictionary<string, AudioType>();
_audioEntries = new List<AudioLibrary.AudioEntry>();
_postInitialized = false;
Delay.RunWhen(
() => Locator.GetAudioManager()?._libraryAsset != null,
Delay.RunWhen(() => Locator.GetAudioManager()?._libraryAsset != null && Locator.GetAudioManager()?._audioLibraryDict != null,
PostInit
);
}
@ -30,7 +31,12 @@ namespace NewHorizons.Handlers
private static void PostInit()
{
NHLogger.LogVerbose($"Adding all custom AudioTypes to the library");
_postInitialized = true;
ModifyAudioLibrary();
}
private static void ModifyAudioLibrary()
{
var library = Locator.GetAudioManager()._libraryAsset;
var audioEntries = library.audioEntries; // store previous array
library.audioEntries = library.audioEntries.Concat(_audioEntries).ToArray(); // concat custom entries
@ -88,6 +94,8 @@ namespace NewHorizons.Handlers
_audioEntries.Add(new AudioLibrary.AudioEntry(audioType, audioClips));
_customAudioTypes.Add(id, audioType);
if (_postInitialized) ModifyAudioLibrary();
return audioType;
}
}

View File

@ -1,16 +1,125 @@
using NewHorizons.Builder.Body;
using NewHorizons.Builder.Props;
using NewHorizons.Builder.StarSystem;
using NewHorizons.External;
using NewHorizons.External.Configs;
using NewHorizons.External.Modules;
using NewHorizons.External.Modules.Props;
using NewHorizons.Handlers.TitleScreen;
using NewHorizons.Utility;
using NewHorizons.Utility.OWML;
using OWML.Common;
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.SceneManagement;
using Color = UnityEngine.Color;
namespace NewHorizons.Handlers
{
public static class TitleSceneHandler
{
internal static Dictionary<IModBehaviour, TitleScreenBuilderList> TitleScreenBuilders = new();
internal static NewHorizonsBody[] eligibleBodies => Main.BodyDict.Values.ToList().SelectMany(x => x).ToList()
.Where(b => (b.Config.HeightMap != null || b.Config.Atmosphere?.clouds != null) && b.Config.Star == null && b.Config.canShowOnTitle).ToArray();
internal static int eligibleCount => eligibleBodies.Count();
internal static bool reloaded = false;
internal static bool reopenProfile = false;
public static void Init()
{
var scene = SearchUtilities.Find("Scene");
var background = SearchUtilities.Find("Scene/Background");
var planetPivot = SearchUtilities.Find("Scene/Background/PlanetPivot");
// Add fake sectors for ocean water component support
scene.AddComponent<FakeSector>();
background.AddComponent<FakeSector>();
planetPivot.AddComponent<FakeSector>();
// parent ambient light and campfire to the root (idk why they aren't parented in the first place mobius)
var planetRoot = SearchUtilities.Find("Scene/Background/PlanetPivot/PlanetRoot");
var campfire = SearchUtilities.Find("Scene/Background/PlanetPivot/Prefab_HEA_Campfire");
campfire.transform.SetParent(planetRoot.transform, true);
var ambientLight = SearchUtilities.Find("Scene/Background/PlanetPivot/AmbientLight_CaveTwin");
ambientLight.transform.SetParent(planetRoot.transform, true);
InitSubtitles();
TitleScreenColourHandler.ResetColour(); // reset color at the start
AudioTypeHandler.Init(); // init audio for custom music
// Load player data for fact and persistent condition checking
var profileManager = StandaloneProfileManager.SharedInstance;
profileManager.OnProfileSignInComplete += OnProfileSignInComplete;
profileManager.PreInitialize();
profileManager.Initialize();
if (profileManager.currentProfile != null) // check if there is even a profile made yet
PlayerData.Init(profileManager.currentProfileGameSave,
profileManager.currentProfileGameSettings,
profileManager.currentProfileGraphicsSettings,
profileManager.currentProfileInputJSON);
// Grab configs and handlers and merge them into one list
var validBuilders = TitleScreenBuilders.Values
.Where(list => list.IsValid)
.Select(list => list.GetRelevantBuilder()).ToList();
var hasNHPlanets = eligibleCount != 0;
// Get random index for the main builder
var index = UnityEngine.Random.Range(0, validBuilders.Count());
var randomBuilder = validBuilders.ElementAtOrDefault(index);
if (randomBuilder != null)
{
validBuilders.RemoveAt(index);
// display nh planets if not disabled
if (!randomBuilder.DisableNHPlanets)
{
DisplayBodiesOnTitleScreen();
}
// if it can share build extras
if (randomBuilder.CanShare)
{
// only build the ones that also can share and have the same value for disabling nh planet (if there is any nh planets)
foreach (var builder in validBuilders.Where(builder => builder.CanShare && (hasNHPlanets ? builder.DisableNHPlanets == randomBuilder.DisableNHPlanets : true)))
{
builder.Build();
}
}
// Build main one last so it overrides the extras
randomBuilder.Build();
}
// default to displaying nh planets if no title screen builders
else
{
DisplayBodiesOnTitleScreen();
}
try
{
Main.Instance.OnAllTitleScreensLoaded?.Invoke();
}
catch (Exception e)
{
NHLogger.LogError($"Error in event handler for OnAllTitleScreensLoaded: {e}");
}
}
private static void OnProfileSignInComplete(ProfileManagerSignInResult result)
{
NHLogger.LogVerbose($"OnProfileSignInComplete {result}: {StandaloneProfileManager.SharedInstance.currentProfile.profileName}");
reloaded = true;
reopenProfile = true;
// Taken and modified from SubmitActionLoadScene.ConfirmSubmit
LoadManager.LoadScene(OWScene.TitleScreen);
Locator.GetMenuInputModule().DisableInputs();
}
public static void InitSubtitles()
{
GameObject subtitleContainer = SearchUtilities.Find("TitleMenu/TitleCanvas/TitleLayoutGroup/Logo_EchoesOfTheEye");
@ -25,11 +134,118 @@ namespace NewHorizons.Handlers
subtitleContainer.AddComponent<SubtitlesHandler>();
}
public static void DisplayBodyOnTitleScreen(List<NewHorizonsBody> bodies)
public static void BuildConfig(IModBehaviour mod, TitleScreenInfo config)
{
if (config.menuTextTint != null)
{
TitleScreenColourHandler.SetColour(config.menuTextTint.ToColor());
}
if (config.Skybox?.destroyStarField ?? false)
{
UnityEngine.Object.Destroy(SearchUtilities.Find("Skybox/Starfield"));
}
if (config.Skybox?.rightPath != null ||
config.Skybox?.leftPath != null ||
config.Skybox?.topPath != null ||
config.Skybox?.bottomPath != null ||
config.Skybox?.frontPath != null ||
config.Skybox?.bottomPath != null)
{
SkyboxBuilder.Make(config.Skybox, mod);
}
if (!string.IsNullOrEmpty(config.music))
{
var musicSource = SearchUtilities.Find("Scene/AudioSource_Music").GetComponent<OWAudioSource>();
var audioType = AudioTypeHandler.GetAudioType(config.music, mod);
Delay.FireOnNextUpdate(() => musicSource.AssignAudioLibraryClip(audioType));
}
if (!string.IsNullOrEmpty(config.ambience))
{
var ambienceSource = SearchUtilities.Find("Scene/AudioSource_Ambience").GetComponent<OWAudioSource>();
var audioType = AudioTypeHandler.GetAudioType(config.ambience, mod);
Delay.FireOnNextUpdate(() => ambienceSource.AssignAudioLibraryClip(audioType));
}
var background = SearchUtilities.Find("Scene/Background");
var menuPlanet = SearchUtilities.Find("Scene/Background/PlanetPivot");
if (config.Background != null)
{
if (config.Background.removeChildren != null)
{
RemoveChildren(background, config.Background.removeChildren);
}
if (config.Background.details != null)
{
foreach (var simplifiedDetail in config.Background.details)
{
DetailBuilder.Make(background, background.GetComponentInParent<Sector>(), mod, new DetailInfo(simplifiedDetail));
}
}
var rotator = background.GetComponent<RotateTransform>();
rotator._degreesPerSecond = config.Background.rotationSpeed;
}
if (config.MenuPlanet != null)
{
if (config.MenuPlanet.removeChildren != null)
{
RemoveChildren(menuPlanet, config.MenuPlanet.removeChildren);
}
if (config.MenuPlanet.details != null)
{
foreach (var simplifiedDetail in config.MenuPlanet.details)
{
DetailBuilder.Make(menuPlanet, menuPlanet.GetComponentInParent<Sector>(), mod, new DetailInfo(simplifiedDetail));
}
}
var rotator = menuPlanet.GetComponent<RotateTransform>();
rotator._localAxis = Vector3.up; // fix axis (because there is no reason for it to be negative when degrees were also negative)
rotator._degreesPerSecond = config.MenuPlanet.rotationSpeed;
if (config.MenuPlanet.destroyMenuPlanet)
{
SearchUtilities.Find("Scene/Background/PlanetPivot/PlanetRoot").SetActive(false);
}
}
}
private static void RemoveChildren(GameObject go, string[] paths)
{
foreach (var childPath in paths)
{
var flag = true;
foreach (var childObj in go.transform.FindAll(childPath))
{
flag = false;
// idk why we wait here but we do
Delay.FireInNUpdates(() =>
{
if (childObj != null && childObj.gameObject != null)
{
childObj.gameObject.SetActive(false);
}
}, 2);
}
if (flag) NHLogger.LogWarning($"Couldn't find \"{childPath}\".");
}
}
public static void DisplayBodiesOnTitleScreen()
{
try
{
// Try loading one planet why not
// var eligible = BodyDict.Values.ToList().SelectMany(x => x).ToList().Where(b => (b.Config.HeightMap != null || b.Config.Atmosphere?.Cloud != null) && b.Config.Star == null).ToArray();
var eligible = bodies.Where(b => (b.Config.HeightMap != null || b.Config.Atmosphere?.clouds != null) && b.Config.Star == null && b.Config.canShowOnTitle).ToArray();
var eligible = eligibleBodies;
var eligibleCount = eligible.Count();
if (eligibleCount == 0) return;
@ -63,7 +279,6 @@ namespace NewHorizons.Handlers
planetSizes.Add(bodyInfo3);
}
SearchUtilities.Find("Scene/Background/PlanetPivot/Prefab_HEA_Campfire").SetActive(false);
SearchUtilities.Find("Scene/Background/PlanetPivot/PlanetRoot").SetActive(false);
var lightGO = new GameObject("Light");
@ -94,6 +309,11 @@ namespace NewHorizons.Handlers
planet.transform.localScale *= adjustedSize * (multiplePlanets ? 0.3f : 1f);
}
}
catch (Exception e)
{
NHLogger.LogError($"Failed to make title screen bodies: {e}");
}
}
private static (GameObject planet, float size) LoadTitleScreenBody(NewHorizonsBody body)
{
@ -153,11 +373,11 @@ namespace NewHorizons.Handlers
}
}
var pivot = Object.Instantiate(SearchUtilities.Find("Scene/Background/PlanetPivot"), SearchUtilities.Find("Scene/Background").transform);
var pivot = UnityEngine.Object.Instantiate(SearchUtilities.Find("Scene/Background/PlanetPivot"), SearchUtilities.Find("Scene/Background").transform);
pivot.GetComponent<RotateTransform>()._degreesPerSecond = 10f;
foreach (Transform child in pivot.transform)
{
Object.Destroy(child.gameObject);
UnityEngine.Object.Destroy(child.gameObject);
}
pivot.name = "Pivot";
@ -222,5 +442,195 @@ namespace NewHorizons.Handlers
return meshRenderer;
}
internal static void RegisterBuilder(IModBehaviour mod, ITitleScreenBuilder builder)
{
if (!TitleScreenBuilders.ContainsKey(mod))
TitleScreenBuilders.Add(mod, new TitleScreenBuilderList());
TitleScreenBuilders[mod].Add(builder);
}
public static void RegisterBuilder(IModBehaviour mod, TitleScreenInfo config)
=> RegisterBuilder(mod,
new TitleScreenConfigBuilder(mod, config));
public static void RegisterBuilder(IModBehaviour mod, Action<GameObject> builder, bool disableNHPlanets, bool shareTitleScreen, string persistentConditionRequired, string factRequired)
=> RegisterBuilder(mod,
new TitleScreenBuilder(mod, builder,
disableNHPlanets, shareTitleScreen,
persistentConditionRequired, factRequired));
internal static void ResetConfigs()
{
foreach (var builderList in TitleScreenBuilders.Values)
{
builderList.list.RemoveAll(builder => builder is TitleScreenConfigBuilder);
}
}
internal class TitleScreenBuilderList
{
public List<ITitleScreenBuilder> list = new List<ITitleScreenBuilder>();
public void Add(ITitleScreenBuilder builder)
{
list.Add(builder);
builder.Index = list.IndexOf(builder);
}
public bool IsValid => GetRelevantBuilder() != null;
public ITitleScreenBuilder GetRelevantBuilder()
{
return list.LastOrDefault(builder => builder.KnowsFact() && builder.HasCondition());
}
}
internal class TitleScreenBuilder : ITitleScreenBuilder
{
public IModBehaviour mod;
public Action<GameObject> builder;
public bool disableNHPlanets;
public bool shareTitleScreen;
public string persistentConditionRequired;
public string factRequired;
public TitleScreenBuilder(IModBehaviour mod, Action<GameObject> builder, bool disableNHPlanets, bool shareTitleScreen, string persistentConditionRequired, string factRequired)
{
this.mod = mod;
this.builder = builder;
this.disableNHPlanets = disableNHPlanets;
this.shareTitleScreen = shareTitleScreen;
this.persistentConditionRequired = persistentConditionRequired;
this.factRequired = factRequired;
}
public void Build()
{
NHLogger.LogVerbose($"Building handler {mod.ModHelper.Manifest.UniqueName} #{index}");
try
{
builder.Invoke(SearchUtilities.Find("Scene"));
}
catch (Exception e)
{
NHLogger.LogError($"Error while building title screen handler {mod.ModHelper.Manifest.UniqueName} #{index}: {e}");
}
try
{
Main.Instance.OnTitleScreenLoaded?.Invoke(mod.ModHelper.Manifest.UniqueName, index);
}
catch (Exception e)
{
NHLogger.LogError($"Error in event handler for OnTitleScreenLoaded on title screen {mod.ModHelper.Manifest.UniqueName} #{index}: {e}");
}
}
public IModBehaviour Mod => mod;
public bool DisableNHPlanets => disableNHPlanets;
public bool CanShare => shareTitleScreen;
public bool KnowsFact() => string.IsNullOrEmpty(factRequired) || StandaloneProfileManager.SharedInstance.currentProfile != null && ShipLogHandler.KnowsFact(factRequired);
public bool HasCondition() => string.IsNullOrEmpty(persistentConditionRequired) || StandaloneProfileManager.SharedInstance.currentProfile != null && PlayerData.GetPersistentCondition(persistentConditionRequired);
private int index = -1;
public int Index { get => index; set => index = value; }
public override string ToString() => Mod.ModHelper.Manifest.UniqueName + " #" + Index;
}
internal class TitleScreenConfigBuilder : ITitleScreenBuilder
{
public IModBehaviour mod;
public TitleScreenInfo config;
public TitleScreenConfigBuilder(IModBehaviour mod, TitleScreenInfo config)
{
this.mod = mod;
this.config = config;
}
public void Build()
{
NHLogger.LogVerbose($"Building config {mod.ModHelper.Manifest.UniqueName} #{index}");
try
{
BuildConfig(mod, config);
}
catch (Exception e)
{
NHLogger.LogError($"Error while building title screen config {mod.ModHelper.Manifest.UniqueName} #{index}: {e}");
}
try
{
Main.Instance.OnTitleScreenLoaded?.Invoke(mod.ModHelper.Manifest.UniqueName, index);
}
catch (Exception e)
{
NHLogger.LogError($"Error in event handler for OnTitleScreenLoaded on title screen {mod.ModHelper.Manifest.UniqueName} #{index}: {e}");
}
}
public IModBehaviour Mod => mod;
public bool DisableNHPlanets => config.disableNHPlanets;
public bool CanShare => config.shareTitleScreen;
public bool KnowsFact() => string.IsNullOrEmpty(config.factRequiredForTitle) || StandaloneProfileManager.SharedInstance.currentProfile != null && ShipLogHandler.KnowsFact(config.factRequiredForTitle);
public bool HasCondition() => string.IsNullOrEmpty(config.persistentConditionRequiredForTitle) || StandaloneProfileManager.SharedInstance.currentProfile != null && PlayerData.GetPersistentCondition(config.persistentConditionRequiredForTitle);
private int index = -1;
public int Index { get => index; set => index = value; }
public override string ToString() => Mod.ModHelper.Manifest.UniqueName + " #" + Index;
}
internal interface ITitleScreenBuilder
{
IModBehaviour Mod { get; }
bool DisableNHPlanets { get; }
bool CanShare { get; }
void Build();
bool KnowsFact();
bool HasCondition();
int Index { get; set; }
}
/// <summary>
/// For water and etc (they require a sector or else they will get deleted by detail builder)
/// </summary>
private class FakeSector : Sector
{
public override void Awake()
{
_triggerRoot = gameObject;
_subsectors = new List<Sector>();
_occupantMask = DynamicOccupant.Player;
var parentSector = GetComponentsInParent<Sector>().FirstOrDefault(parentSector => parentSector != this);
if (parentSector != null)
{
_parentSector = parentSector;
_parentSector.AddSubsector(this);
}
SectorManager.RegisterSector(this);
}
public void Start()
{
OnSectorOccupantsUpdated.Invoke();
}
}
}
}

View File

@ -0,0 +1,79 @@
using HarmonyLib;
using NewHorizons.Utility;
using NewHorizons.Utility.Files;
using NewHorizons.Utility.OWML;
using System.Linq;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
namespace NewHorizons.Handlers.TitleScreen
{
[HarmonyPatch]
public class TitleScreenColourHandler
{
public static void SetColour(Color colour)
{
NHLogger.LogVerbose("Setting title screen colour to " + colour.ToString());
colour.a = 1;
var buttons = GameObject.FindObjectOfType<TitleScreenManager>()._mainMenu.GetComponentsInChildren<Text>();
var footer = GameObject.Find("TitleMenu/TitleCanvas/FooterBlock").GetComponentsInChildren<Text>();
foreach (var button in buttons.Concat(footer))
{
button.color = colour;
}
_mainMenuColour = colour;
var logo = ImageUtilities.TintImage(ImageUtilities.GetTexture(Main.Instance, "Assets\\textures\\MENU_OuterWildsLogo_d.png"), (Color)_mainMenuColour);
var animRenderer = GameObject.FindObjectOfType<TitleAnimRenderer>();
var colouredLogoMaterial = GameObject.Instantiate(animRenderer._logoMaterial).Rename("MENU_OuterWildsLogoANIM_mat_Coloured");
colouredLogoMaterial.mainTexture = logo;
animRenderer._logoMaterial = colouredLogoMaterial;
animRenderer.Awake();
}
public static void ResetColour()
{
_mainMenuColour = null;
}
[HarmonyPrefix]
[HarmonyPatch(typeof(UIStyleApplier), nameof(UIStyleApplier.ChangeColors))]
public static bool UIStyleApplier_ChangeColors(UIStyleApplier __instance, UIElementState state)
{
if (SceneManager.GetActiveScene().name == "TitleScreen" && _mainMenuColour is Color colour && __instance.transform.parent.name == "MainMenuLayoutGroup")
{
// Wyrm didn't say to account for any of these states I win!
switch (state)
{
case UIElementState.INTERMEDIATELY_HIGHLIGHTED:
case UIElementState.HIGHLIGHTED:
case UIElementState.PRESSED:
case UIElementState.ROLLOVER_HIGHLIGHT:
Color.RGBToHSV(colour, out var h, out var s, out var v);
colour = Color.HSVToRGB(h, s * 0.2f, v * 1.2f);
break;
case UIElementState.DISABLED:
return true;
default:
break;
}
for (int i = 0; i < __instance._foregroundGraphics.Length; i++)
{
__instance._foregroundGraphics[i].color = colour;
}
for (int j = 0; j < __instance._backgroundGraphics.Length; j++)
{
__instance._backgroundGraphics[j].color = colour;
}
return false;
}
else
{
return true;
}
}
private static Color? _mainMenuColour;
}
}

View File

@ -73,6 +73,17 @@ namespace NewHorizons
/// Gives the name of the planet that was just loaded.
/// </summary>
UnityEvent<string> GetBodyLoadedEvent();
/// <summary>
/// An event invoked when NH has finished building a title screen.
/// Gives the unique name of the mod the title screen builder was from and the index for when you have multiple title screens.
/// </summary>
UnityEvent<string, int> GetTitleScreenLoadedEvent();
/// <summary>
/// An event invoked when NH has finished building the title screen.
/// </summary>
UnityEvent GetAllTitleScreensLoadedEvent();
#endregion
#region Querying configs
@ -96,6 +107,16 @@ namespace NewHorizons
///</summary>
T QuerySystem<T>(string path);
/// <summary>
/// Uses JSONPath to query a title screen config
/// </summary>
object QueryTitleScreen(Type outType, IModBehaviour mod, string path);
///<summary>
/// Uses JSONPath to query a title screen config
/// </summary>
T QueryTitleScreen<T>(IModBehaviour mod, string path);
/// <summary>
/// Register your own builder that will act on the given GameObject by reading the json string of its "extras" module
/// </summary>
@ -222,5 +243,17 @@ namespace NewHorizons
/// </summary>
/// <param name="id"></param>
void SetNextSpawnID(string id);
/// <summary>
/// Registers a builder for the main menu.
/// Call this once before the main menu finishes loading
/// </summary>
/// <param name="mod"></param>
/// <param name="builder">Builder to run when this title screen is selected. The GameObject passed through it is the main scene object containing both the background and menu planet.</param>
/// <param name="disableNHPlanets">If set to true, NH generated planets will not show on the title screen. If false, this title screen has the same chance as other NH planet title screens to show.</param>
/// <param name="shareTitleScreen">If set to true, this custom title screen will merge with all other custom title screens with shareTitleScreen set to true. If false, NH will randomly select between this and other valid title screens that are loaded.</param>
/// <param name="persistentConditionRequired">Persistent condition required for this title screen to appear.</param>
/// <param name="factRequired">Ship log fact required for this title screen to appear.</param>
void RegisterTitleScreenBuilder(IModBehaviour mod, Action<GameObject> builder, bool disableNHPlanets = true, bool shareTitleScreen = false, string persistentConditionRequired = null, string factRequired = null);
}
}

View File

@ -56,6 +56,7 @@ namespace NewHorizons
public static Dictionary<string, List<NewHorizonsBody>> BodyDict = new();
public static List<IModBehaviour> MountedAddons = new();
public static Dictionary<IModBehaviour, AddonConfig> AddonConfigs = new();
public static Dictionary<IModBehaviour, TitleScreenConfig> TitleScreenConfigs = new();
public static float SecondsElapsedInLoop = -1;
@ -107,10 +108,13 @@ namespace NewHorizons
public ShipWarpController ShipWarpController { get; private set; }
// API events
public class StarSystemEvent : UnityEvent<string> { }
public StarSystemEvent OnChangeStarSystem = new();
public StarSystemEvent OnStarSystemLoaded = new();
public StarSystemEvent OnPlanetLoaded = new();
public class StringEvent : UnityEvent<string> { }
public StringEvent OnChangeStarSystem = new();
public StringEvent OnStarSystemLoaded = new();
public StringEvent OnPlanetLoaded = new();
public class StringIndexEvent : UnityEvent<string, int> { }
public StringIndexEvent OnTitleScreenLoaded = new();
public UnityEvent OnAllTitleScreensLoaded = new();
/// <summary>
/// Depending on platform, the AsyncOwnershipStatus might not be ready by the time we go to check it.
@ -161,7 +165,10 @@ namespace NewHorizons
if (wasUsingCustomTitleScreen != CustomTitleScreen && SceneManager.GetActiveScene().name == "TitleScreen" && _wasConfigured)
{
NHLogger.LogVerbose("Reloading");
SceneManager.LoadScene("TitleScreen", LoadSceneMode.Single);
TitleSceneHandler.reloaded = true;
// Taken and modified from SubmitActionLoadScene.ConfirmSubmit
LoadManager.LoadScene(OWScene.TitleScreen);
Locator.GetMenuInputModule().DisableInputs();
}
_wasConfigured = true;
@ -171,6 +178,9 @@ namespace NewHorizons
{
BodyDict.Clear();
SystemDict.Clear();
TitleScreenConfigs.Clear();
TitleSceneHandler.ResetConfigs();
BodyDict["SolarSystem"] = new List<NewHorizonsBody>();
BodyDict["EyeOfTheUniverse"] = new List<NewHorizonsBody>(); // Keep this empty tho fr
@ -423,15 +433,7 @@ namespace NewHorizons
if (isTitleScreen && CustomTitleScreen)
{
try
{
TitleSceneHandler.DisplayBodyOnTitleScreen(BodyDict.Values.ToList().SelectMany(x => x).ToList());
}
catch (Exception e)
{
NHLogger.LogError($"Failed to make title screen bodies: {e}");
}
TitleSceneHandler.InitSubtitles();
TitleSceneHandler.Init();
}
// EOTU fixes
@ -795,6 +797,10 @@ namespace NewHorizons
{
LoadAddonManifest("addon-manifest.json", mod);
}
if (File.Exists(Path.Combine(folder, "title-screen.json")))
{
LoadTitleScreenConfig("title-screen.json", mod);
}
if (Directory.Exists(Path.Combine(folder, "translations")))
{
LoadTranslations(folder, mod);
@ -847,6 +853,25 @@ namespace NewHorizons
AddonConfigs[mod] = addonConfig;
}
private void LoadTitleScreenConfig(string file, IModBehaviour mod)
{
NHLogger.LogVerbose($"Loading title screen config for {mod.ModHelper.Manifest.Name}");
var titleScreenConfig = mod.ModHelper.Storage.Load<TitleScreenConfig>(file, false);
if (titleScreenConfig == null)
{
NHLogger.LogError($"Title screen config for {mod.ModHelper.Manifest.Name} could not load, check your JSON");
return;
}
TitleScreenConfigs[mod] = titleScreenConfig;
foreach (var info in titleScreenConfig.titleScreens)
{
TitleSceneHandler.RegisterBuilder(mod, info);
}
}
private void LoadTranslations(string folder, IModBehaviour mod)
{
var foundFile = false;

View File

@ -90,6 +90,8 @@ namespace NewHorizons
public UnityEvent<string> GetChangeStarSystemEvent() => Main.Instance.OnChangeStarSystem;
public UnityEvent<string> GetStarSystemLoadedEvent() => Main.Instance.OnStarSystemLoaded;
public UnityEvent<string> GetBodyLoadedEvent() => Main.Instance.OnPlanetLoaded;
public UnityEvent<string, int> GetTitleScreenLoadedEvent() => Main.Instance.OnTitleScreenLoaded;
public UnityEvent GetAllTitleScreensLoadedEvent() => Main.Instance.OnAllTitleScreensLoaded;
public bool SetDefaultSystem(string name)
{
@ -178,6 +180,24 @@ namespace NewHorizons
return default;
}
public object QueryTitleScreen(Type outType, IModBehaviour mod, string jsonPath)
{
var titleScreenConfig = Main.TitleScreenConfigs[mod];
return titleScreenConfig == null
? null
: QueryJson(outType, Path.Combine(mod.ModHelper.Manifest.ModFolderPath, "title-screen.json"), jsonPath);
}
public T QueryTitleScreen<T>(IModBehaviour mod, string jsonPath)
{
var data = QueryTitleScreen(typeof(T), mod, jsonPath);
if (data is T result)
{
return result;
}
return default;
}
public GameObject SpawnObject(IModBehaviour mod, GameObject planet, Sector sector, string propToCopyPath, Vector3 position, Vector3 eulerAngles,
float scale, bool alignRadial)
{
@ -344,5 +364,8 @@ namespace NewHorizons
public void AddSubtitle(IModBehaviour mod, string filePath) => SubtitlesHandler.RegisterAdditionalSubtitle(mod, filePath);
public void SetNextSpawnID(string id) => PlayerSpawnHandler.TargetSpawnID = id;
public void RegisterTitleScreenBuilder(IModBehaviour mod, Action<GameObject> builder, bool disableNHPlanets = true, bool shareTitleScreen = false, string persistentConditionRequired = null, string factRequired = null)
=> TitleSceneHandler.RegisterBuilder(mod, builder, disableNHPlanets, shareTitleScreen, persistentConditionRequired, factRequired);
}
}

View File

@ -0,0 +1,44 @@
using HarmonyLib;
using NewHorizons.Handlers;
using NewHorizons.Utility;
using NewHorizons.Utility.OWML;
using OWML.Utils;
using UnityEngine;
namespace NewHorizons.Patches;
[HarmonyPatch]
internal static class TitleScenePatches
{
[HarmonyPrefix, HarmonyPatch(typeof(TitleScreenAnimation), nameof(TitleScreenAnimation.Awake))]
public static void TitleScreenAnimation_Awake(TitleScreenAnimation __instance)
{
if (TitleSceneHandler.reloaded)
{
TitleSceneHandler.reloaded = false;
// Skip Splash on title screen reload
TitleScreenAnimation titleScreenAnimation = __instance;
titleScreenAnimation._fadeDuration = 0;
titleScreenAnimation._gamepadSplash = false;
titleScreenAnimation._introPan = false;
TitleAnimationController titleAnimationController = GameObject.FindObjectOfType<TitleAnimationController>();
titleAnimationController._logoFadeDelay = 0.001f;
titleAnimationController._logoFadeDuration = 0.001f;
titleAnimationController._optionsFadeDelay = 0.001f;
titleAnimationController._optionsFadeDuration = 0.001f;
titleAnimationController._optionsFadeSpacing = 0.001f;
titleAnimationController.FadeInTitleLogo();
// Reopen profile
if (TitleSceneHandler.reopenProfile)
{
TitleSceneHandler.reopenProfile = false;
Delay.FireOnNextUpdate(() =>
SearchUtilities.Find("TitleMenu/TitleCanvas/TitleLayoutGroup/MainMenuBlock/MainMenuLayoutGroup/Button-Profile")
.GetComponent<SubmitActionMenu>().Submit());
}
}
}
}

View File

@ -946,35 +946,6 @@
"type": "object",
"additionalProperties": false,
"properties": {
"assetBundle": {
"type": "string",
"description": "Relative filepath to an asset-bundle to load the prefab defined in `path` from"
},
"path": {
"type": "string",
"description": "Either the path in the scene hierarchy of the item to copy or the path to the object in the supplied asset bundle. \nIf empty, will make an empty game object. This can be useful for adding other props to it as its children."
},
"removeChildren": {
"type": "array",
"description": "A list of children to remove from this detail",
"items": {
"type": "string"
}
},
"removeComponents": {
"type": "boolean",
"description": "Do we reset all the components on this object? Useful for certain props that have dialogue components attached to\nthem."
},
"scale": {
"type": "number",
"description": "Scale the prop",
"format": "float",
"default": 1.0
},
"stretch": {
"description": "Scale each axis of the prop. Overrides `scale`.",
"$ref": "#/definitions/MVector3"
},
"keepLoaded": {
"type": "boolean",
"description": "Should this detail stay loaded (visible and collideable) even if you're outside the sector (good for very large props)?\nAlso makes this detail visible on the map.\nKeeping many props loaded is bad for performance so use this only when it's actually relevant\nMost logic/behavior scripts will still only work inside the sector, as most of those scripts break if a sector is not provided."
@ -1025,6 +996,35 @@
"description": "Should this detail be treated as a socket for an interactible item",
"$ref": "#/definitions/ItemSocketInfo"
},
"assetBundle": {
"type": "string",
"description": "Relative filepath to an asset-bundle to load the prefab defined in `path` from"
},
"path": {
"type": "string",
"description": "Either the path in the scene hierarchy of the item to copy or the path to the object in the supplied asset bundle. \nIf empty, will make an empty game object. This can be useful for adding other props to it as its children."
},
"removeChildren": {
"type": "array",
"description": "A list of children to remove from this detail",
"items": {
"type": "string"
}
},
"removeComponents": {
"type": "boolean",
"description": "Do we reset all the components on this object? Useful for certain props that have dialogue components attached to\nthem."
},
"scale": {
"type": "number",
"description": "Scale the prop",
"format": "float",
"default": 1.0
},
"stretch": {
"description": "Scale each axis of the prop. Overrides `scale`.",
"$ref": "#/definitions/MVector3"
},
"rotation": {
"description": "Rotation of the object",
"$ref": "#/definitions/MVector3"
@ -1564,35 +1564,6 @@
"type": "object",
"additionalProperties": false,
"properties": {
"assetBundle": {
"type": "string",
"description": "Relative filepath to an asset-bundle to load the prefab defined in `path` from"
},
"path": {
"type": "string",
"description": "Either the path in the scene hierarchy of the item to copy or the path to the object in the supplied asset bundle. \nIf empty, will make an empty game object. This can be useful for adding other props to it as its children."
},
"removeChildren": {
"type": "array",
"description": "A list of children to remove from this detail",
"items": {
"type": "string"
}
},
"removeComponents": {
"type": "boolean",
"description": "Do we reset all the components on this object? Useful for certain props that have dialogue components attached to\nthem."
},
"scale": {
"type": "number",
"description": "Scale the prop",
"format": "float",
"default": 1.0
},
"stretch": {
"description": "Scale each axis of the prop. Overrides `scale`.",
"$ref": "#/definitions/MVector3"
},
"keepLoaded": {
"type": "boolean",
"description": "Should this detail stay loaded (visible and collideable) even if you're outside the sector (good for very large props)?\nAlso makes this detail visible on the map.\nKeeping many props loaded is bad for performance so use this only when it's actually relevant\nMost logic/behavior scripts will still only work inside the sector, as most of those scripts break if a sector is not provided."
@ -1643,6 +1614,35 @@
"description": "Should this detail be treated as a socket for an interactible item",
"$ref": "#/definitions/ItemSocketInfo"
},
"assetBundle": {
"type": "string",
"description": "Relative filepath to an asset-bundle to load the prefab defined in `path` from"
},
"path": {
"type": "string",
"description": "Either the path in the scene hierarchy of the item to copy or the path to the object in the supplied asset bundle. \nIf empty, will make an empty game object. This can be useful for adding other props to it as its children."
},
"removeChildren": {
"type": "array",
"description": "A list of children to remove from this detail",
"items": {
"type": "string"
}
},
"removeComponents": {
"type": "boolean",
"description": "Do we reset all the components on this object? Useful for certain props that have dialogue components attached to\nthem."
},
"scale": {
"type": "number",
"description": "Scale the prop",
"format": "float",
"default": 1.0
},
"stretch": {
"description": "Scale each axis of the prop. Overrides `scale`.",
"$ref": "#/definitions/MVector3"
},
"rotation": {
"description": "Rotation of the object",
"$ref": "#/definitions/MVector3"
@ -1680,35 +1680,6 @@
"type": "object",
"additionalProperties": false,
"properties": {
"assetBundle": {
"type": "string",
"description": "Relative filepath to an asset-bundle to load the prefab defined in `path` from"
},
"path": {
"type": "string",
"description": "Either the path in the scene hierarchy of the item to copy or the path to the object in the supplied asset bundle. \nIf empty, will make an empty game object. This can be useful for adding other props to it as its children."
},
"removeChildren": {
"type": "array",
"description": "A list of children to remove from this detail",
"items": {
"type": "string"
}
},
"removeComponents": {
"type": "boolean",
"description": "Do we reset all the components on this object? Useful for certain props that have dialogue components attached to\nthem."
},
"scale": {
"type": "number",
"description": "Scale the prop",
"format": "float",
"default": 1.0
},
"stretch": {
"description": "Scale each axis of the prop. Overrides `scale`.",
"$ref": "#/definitions/MVector3"
},
"keepLoaded": {
"type": "boolean",
"description": "Should this detail stay loaded (visible and collideable) even if you're outside the sector (good for very large props)?\nAlso makes this detail visible on the map.\nKeeping many props loaded is bad for performance so use this only when it's actually relevant\nMost logic/behavior scripts will still only work inside the sector, as most of those scripts break if a sector is not provided."
@ -1759,6 +1730,35 @@
"description": "Should this detail be treated as a socket for an interactible item",
"$ref": "#/definitions/ItemSocketInfo"
},
"assetBundle": {
"type": "string",
"description": "Relative filepath to an asset-bundle to load the prefab defined in `path` from"
},
"path": {
"type": "string",
"description": "Either the path in the scene hierarchy of the item to copy or the path to the object in the supplied asset bundle. \nIf empty, will make an empty game object. This can be useful for adding other props to it as its children."
},
"removeChildren": {
"type": "array",
"description": "A list of children to remove from this detail",
"items": {
"type": "string"
}
},
"removeComponents": {
"type": "boolean",
"description": "Do we reset all the components on this object? Useful for certain props that have dialogue components attached to\nthem."
},
"scale": {
"type": "number",
"description": "Scale the prop",
"format": "float",
"default": 1.0
},
"stretch": {
"description": "Scale each axis of the prop. Overrides `scale`.",
"$ref": "#/definitions/MVector3"
},
"rotation": {
"description": "Rotation of the object",
"$ref": "#/definitions/MVector3"
@ -2381,33 +2381,6 @@
"type": "object",
"additionalProperties": false,
"properties": {
"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"
},
"assetBundle": {
"type": "string",
"description": "Relative filepath to an asset-bundle to load the prefab defined in `path` from"
@ -2437,6 +2410,33 @@
"description": "Scale each axis of the prop. Overrides `scale`.",
"$ref": "#/definitions/MVector3"
},
"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"
},
"keepLoaded": {
"type": "boolean",
"description": "Should this detail stay loaded (visible and collideable) even if you're outside the sector (good for very large props)?\nAlso makes this detail visible on the map.\nKeeping many props loaded is bad for performance so use this only when it's actually relevant\nMost logic/behavior scripts will still only work inside the sector, as most of those scripts break if a sector is not provided."
@ -3976,35 +3976,6 @@
"type": "object",
"additionalProperties": false,
"properties": {
"assetBundle": {
"type": "string",
"description": "Relative filepath to an asset-bundle to load the prefab defined in `path` from"
},
"path": {
"type": "string",
"description": "Either the path in the scene hierarchy of the item to copy or the path to the object in the supplied asset bundle. \nIf empty, will make an empty game object. This can be useful for adding other props to it as its children."
},
"removeChildren": {
"type": "array",
"description": "A list of children to remove from this detail",
"items": {
"type": "string"
}
},
"removeComponents": {
"type": "boolean",
"description": "Do we reset all the components on this object? Useful for certain props that have dialogue components attached to\nthem."
},
"scale": {
"type": "number",
"description": "Scale the prop",
"format": "float",
"default": 1.0
},
"stretch": {
"description": "Scale each axis of the prop. Overrides `scale`.",
"$ref": "#/definitions/MVector3"
},
"keepLoaded": {
"type": "boolean",
"description": "Should this detail stay loaded (visible and collideable) even if you're outside the sector (good for very large props)?\nAlso makes this detail visible on the map.\nKeeping many props loaded is bad for performance so use this only when it's actually relevant\nMost logic/behavior scripts will still only work inside the sector, as most of those scripts break if a sector is not provided."
@ -4055,6 +4026,35 @@
"description": "Should this detail be treated as a socket for an interactible item",
"$ref": "#/definitions/ItemSocketInfo"
},
"assetBundle": {
"type": "string",
"description": "Relative filepath to an asset-bundle to load the prefab defined in `path` from"
},
"path": {
"type": "string",
"description": "Either the path in the scene hierarchy of the item to copy or the path to the object in the supplied asset bundle. \nIf empty, will make an empty game object. This can be useful for adding other props to it as its children."
},
"removeChildren": {
"type": "array",
"description": "A list of children to remove from this detail",
"items": {
"type": "string"
}
},
"removeComponents": {
"type": "boolean",
"description": "Do we reset all the components on this object? Useful for certain props that have dialogue components attached to\nthem."
},
"scale": {
"type": "number",
"description": "Scale the prop",
"format": "float",
"default": 1.0
},
"stretch": {
"description": "Scale each axis of the prop. Overrides `scale`.",
"$ref": "#/definitions/MVector3"
},
"rotation": {
"description": "Rotation of the object",
"$ref": "#/definitions/MVector3"
@ -4507,7 +4507,7 @@
},
"type": {
"description": "The type of dream candle this is.",
"default": "Ground",
"default": "ground",
"$ref": "#/definitions/DreamCandleType"
},
"startLit": {

View File

@ -0,0 +1,286 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Title Screen Schema",
"type": "object",
"additionalProperties": false,
"properties": {
"titleScreens": {
"type": "array",
"description": "Create title screens",
"items": {
"$ref": "#/definitions/TitleScreenInfo"
}
},
"$schema": {
"type": "string",
"description": "The schema to validate with"
}
},
"definitions": {
"TitleScreenInfo": {
"type": "object",
"additionalProperties": false,
"properties": {
"menuTextTint": {
"description": "Colour of the text on the main menu",
"$ref": "#/definitions/MColor"
},
"factRequiredForTitle": {
"type": "string",
"description": "Ship log fact required for this title screen to appear."
},
"persistentConditionRequiredForTitle": {
"type": "string",
"description": "Persistent condition required for this title screen to appear."
},
"disableNHPlanets": {
"type": "boolean",
"description": "If set to true, NH generated planets will not show on the title screen. If false, this title screen has the same chance as other NH planet title screens to show."
},
"shareTitleScreen": {
"type": "boolean",
"description": "If set to true, this custom title screen will merge with all other custom title screens with shareTitleScreen set to true. If false, NH will randomly select between this and other valid title screens that are loaded."
},
"Skybox": {
"description": "Customize the skybox for this title screen",
"$ref": "#/definitions/SkyboxModule"
},
"music": {
"type": "string",
"description": "The music audio that will play on the title screen. Can be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list."
},
"ambience": {
"type": "string",
"description": "The ambience audio that will play on the title screen. Can be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list."
},
"Background": {
"description": "Edit properties of the background",
"$ref": "#/definitions/BackgroundModule"
},
"MenuPlanet": {
"description": "Edit properties of the main menu planet",
"$ref": "#/definitions/MenuPlanetModule"
},
"extras": {
"type": "object",
"description": "Extra data that may be used by extension mods",
"additionalProperties": {
"type": "object"
}
}
}
},
"MColor": {
"type": "object",
"additionalProperties": false,
"properties": {
"r": {
"type": "integer",
"description": "The red component of this colour from 0-255, higher values will make the colour glow if applicable.",
"format": "int32",
"maximum": 2147483647.0,
"minimum": 0.0
},
"g": {
"type": "integer",
"description": "The green component of this colour from 0-255, higher values will make the colour glow if applicable.",
"format": "int32",
"maximum": 2147483647.0,
"minimum": 0.0
},
"b": {
"type": "integer",
"description": "The blue component of this colour from 0-255, higher values will make the colour glow if applicable.",
"format": "int32",
"maximum": 2147483647.0,
"minimum": 0.0
},
"a": {
"type": "integer",
"description": "The alpha (opacity) component of this colour",
"format": "int32",
"default": 255,
"maximum": 255.0,
"minimum": 0.0
}
}
},
"SkyboxModule": {
"type": "object",
"additionalProperties": false,
"properties": {
"destroyStarField": {
"type": "boolean",
"description": "Whether to destroy the star field around the player"
},
"useCube": {
"type": "boolean",
"description": "Whether to use a cube for the skybox instead of a smooth sphere"
},
"rightPath": {
"type": "string",
"description": "Relative filepath to the texture to use for the skybox's positive X direction"
},
"leftPath": {
"type": "string",
"description": "Relative filepath to the texture to use for the skybox's negative X direction"
},
"topPath": {
"type": "string",
"description": "Relative filepath to the texture to use for the skybox's positive Y direction"
},
"bottomPath": {
"type": "string",
"description": "Relative filepath to the texture to use for the skybox's negative Y direction"
},
"frontPath": {
"type": "string",
"description": "Relative filepath to the texture to use for the skybox's positive Z direction"
},
"backPath": {
"type": "string",
"description": "Relative filepath to the texture to use for the skybox's negative Z direction"
}
}
},
"BackgroundModule": {
"type": "object",
"additionalProperties": false,
"properties": {
"rotationSpeed": {
"type": "number",
"description": "Changes the speed the background rotates (and by extension the main menu planet). This is in degrees per second.",
"format": "float"
},
"removeChildren": {
"type": "array",
"description": "Disables the renderers of objects at the provided paths",
"items": {
"type": "string"
}
},
"details": {
"type": "array",
"description": "A list of DetailInfos to populate the background with.",
"items": {
"$ref": "#/definitions/SimplifiedDetailInfo"
}
}
}
},
"SimplifiedDetailInfo": {
"type": "object",
"description": "A lesser form of DetailInfo used for the title screen since that supports fewer features",
"additionalProperties": false,
"properties": {
"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"
},
"assetBundle": {
"type": "string",
"description": "Relative filepath to an asset-bundle to load the prefab defined in `path` from"
},
"path": {
"type": "string",
"description": "Either the path in the scene hierarchy of the item to copy or the path to the object in the supplied asset bundle. \nIf empty, will make an empty game object. This can be useful for adding other props to it as its children."
},
"removeChildren": {
"type": "array",
"description": "A list of children to remove from this detail",
"items": {
"type": "string"
}
},
"removeComponents": {
"type": "boolean",
"description": "Do we reset all the components on this object? Useful for certain props that have dialogue components attached to\nthem."
},
"scale": {
"type": "number",
"description": "Scale the prop",
"format": "float",
"default": 1.0
},
"stretch": {
"description": "Scale each axis of the prop. Overrides `scale`.",
"$ref": "#/definitions/MVector3"
}
}
},
"MVector3": {
"type": "object",
"additionalProperties": false,
"properties": {
"x": {
"type": "number",
"format": "float"
},
"y": {
"type": "number",
"format": "float"
},
"z": {
"type": "number",
"format": "float"
}
}
},
"MenuPlanetModule": {
"type": "object",
"additionalProperties": false,
"properties": {
"destroyMenuPlanet": {
"type": "boolean",
"description": "Disables the renderers of the main menu planet and all objects on it (this is to improve compatibility with other mods that don't use the NH title screen json)."
},
"removeChildren": {
"type": "array",
"description": "Disables the renderers of objects at the provided paths",
"items": {
"type": "string"
}
},
"details": {
"type": "array",
"description": "A list of DetailInfos to populate the main menu planet with.",
"items": {
"$ref": "#/definitions/SimplifiedDetailInfo"
}
},
"rotationSpeed": {
"type": "number",
"description": "Changes the speed the main menu planet. This is in degrees per second.",
"format": "float"
}
}
}
},
"$docs": {
"title": "Title Screen Schema",
"description": "Schema for the title screen config in New Horizons"
}
}

View File

@ -33,6 +33,9 @@ public static class SchemaExporter
var translationSchema =
new Schema<TranslationConfig>("Translation Schema", "Schema for a translation file in New Horizons", $"{folderName}/translation_schema", settings);
translationSchema.Output();
var titleScreenSchema = new Schema<TitleScreenConfig>("Title Screen Schema",
"Schema for the title screen config in New Horizons", $"{folderName}/title_screen_schema", settings);
titleScreenSchema.Output();
Console.WriteLine("Done!");
}
@ -108,6 +111,19 @@ public static class SchemaExporter
};
}
if (_title is "Title Screen Schema")
{
schema.Definitions["TitleScreenInfo"].Properties["extras"] = new JsonSchemaProperty {
Type = JsonObjectType.Object,
Description = "Extra data that may be used by extension mods",
AllowAdditionalProperties = true,
AdditionalPropertiesSchema = new JsonSchema
{
Type = JsonObjectType.Object
}
};
}
return schema;
}
}

View File

@ -14,6 +14,7 @@ const schemas = [
"addon_manifest_schema.json",
"dialogue_schema.xsd",
"text_schema.xsd",
"title_screen_schema.json",
"shiplog_schema.xsd"
];
@ -90,6 +91,7 @@ export default defineConfig({
{ label: "Addon Manifest Schema", link: "schemas/addon-manifest-schema" },
{ label: "Dialogue Schema", link: "schemas/dialogue-schema" },
{ label: "Text Schema", link: "schemas/text-schema" },
{ label: "Title Screen Schema", link: "schemas/title-screen-schema" },
{ label: "Ship Log Schema", link: "schemas/shiplog-schema" }
]
},

View File

@ -16,7 +16,12 @@ A star system config file will look something like this:
```json title="my_star_system.json"
{
"$schema": "https://raw.githubusercontent.com/Outer-Wilds-New-Horizons/new-horizons/main/NewHorizons/Schemas/star_system_schema.json",
"travelAudio": "assets/Travel.mp3",
"canEnterViaWarpDrive": true,
"startHere": false,
"respawnHere": true,
"GlobalMusic": {
"travelAudio": "planets/assets/Travel Audio.mp3"
},
"Vessel": {
"coords": {
"x": [4, 0, 3, 1],

View File

@ -0,0 +1,194 @@
---
title: Title Screens
description: A guide to creating a custom title screens in New Horizons
---
Welcome! This page outlines how to make a custom title screen.
## Getting Started
Your mod's title screen config is a JSON file named `title-screen.json` that should be placed within your mod folder.
A title screen config file will look something like this:
```json title="title-screen.json"
{
"$schema": "https://raw.githubusercontent.com/Outer-Wilds-New-Horizons/new-horizons/main/NewHorizons/Schemas/title_screen_schema.json",
"titleScreens": [
{
"disableNHPlanets": false,
"shareTitleScreen": true,
"music": "planets/assets/TitleScreenMusic.mp3"
},
{
"disableNHPlanets": true,
"shareTitleScreen": true,
"factRequiredForTitle": "EXAMPLES_ARTIFICIAL_GRAVITY",
"menuTextTint": {
"r": 128,
"g": 128,
"b": 255
},
"music": "planets/assets/TitleScreenMusic.mp3",
"ambience": "planets/assets/TitleScreenAmbience.mp3",
"Skybox": {
"destroyStarField": true,
"rightPath": "systems/New System Assets/Skybox/Right_Large.png",
"leftPath": "systems/New System Assets/Skybox/Left_Large.png",
"topPath": "systems/New System Assets/Skybox/Up_Large.png",
"bottomPath": "systems/New System Assets/Skybox/Down_Large.png",
"frontPath": "systems/New System Assets/Skybox/Front_Large.png",
"backPath": "systems/New System Assets/Skybox/Back_Large.png"
},
"Background": {
"details": [
{
"assetBundle": "assetbundles/test",
"path": "Assets/Prefabs/Background.prefab",
"position": {"x": 200, "y": 280, "z": -50},
"rotation": {"x": 310, "y": 0, "z": 310},
"scale": 0.05
}
],
"rotationSpeed": 10
},
"MenuPlanet": {
"destroyMenuPlanet": false,
"removeChildren": ["PlanetRoot/Props"],
"details": [
{
"assetBundle": "assetbundles/test",
"path": "Assets/Prefabs/ArtificialGravity.prefab",
"removeChildren": ["Gravity"],
"parentPath": "PlanetRoot",
"position": {"x": 0, "y": 32, "z": 0},
"rotation": {"x": 90, "y": 0, "z": 0},
"scale": 10
}
],
"rotationSpeed": 20
}
}
]
}
```
You can have multiple title screens but only one will be selected from the list. The last title screen in the list, that is unlocked, will always be selected.
## Configs
Title screens from configs are always put first into the list.
### `disableNHPlanets`
If set to `true`, prevents NH-generated planets from appearing on the title screen. Defaults to true.
### `shareTitleScreen`
If set to `true`, this title screen will merge with others that have the same setting enabled. For more info head to the [sharing section](#sharing) of this page. Defaults to false.
### `menuTextTint`
Defines the color of the menu text and logo. Uses RGB values, where `r`, `g`, and `b` range from `0` to `255`.
### `factRequiredForTitle`
Specifies a ship log fact that must be discovered for this title screen to appear.
### `conditionRequiredForTitle`
Specifies a persistent condition required for this title screen to appear.
### `music` and `ambience`
The audio for background music and ambience. Can be a path to a .wav/.ogg/.mp3 file, or taken from the [AudioClip list](/reference/audio-enum).
### `Background` and `MenuPlanet`
A module for the background and main menu planet that include object additions, removal, and rotation speed.
##### `details`
You can add objects to both the background and menu planet. The menu planet objects spin while the background objects are stationary.
These simplified details are just like the details in planet configs except that they only have the basic features.
## Schema
To see all the different things you can put into a config file check out the [Title Screen Schema](/schemas/title-screen-schema).
## API
New Horizons provides an API method to register and build custom title screens dynamically.
These will be put at the end of the list for the selection of all your mod's title screens.
You cannot combine configs with API unfortunately as only the API will be selected.
```csharp title="INewHorizons.cs"
/// <summary>
/// Registers a builder for the main menu.
/// Call this once before the main menu finishes loading
/// </summary>
void RegisterTitleScreenBuilder(IModBehaviour mod, Action<GameObject> builder, bool disableNHPlanets = true, bool shareTitleScreen = false, string conditionRequired = null, string factRequired = null);
```
It shares a few values with the configs but also has an exclusive one.
`builder`: Builder to run when this title screen is selected. The GameObject passed through it is the main scene object containing both the background and menu planet.
### Example API usage
You can run `RegisterTitleScreenBuilder` more than once to add multiple title screen builders.
```csharp title="YourModBehaviour.cs"
NewHorizons = ModHelper.Interaction.TryGetModApi<INewHorizons>("xen.NewHorizons");
NewHorizons.RegisterTitleScreenBuilder(this, BuildTitleScreen, disableNHPlanets: true, shareTitleScreen: true);
```
```csharp title="YourModBehaviour.cs"
public void BuildTitleScreen(GameObject scene)
{
ModHelper.Console.WriteLine($"Building title screen", MessageType.Success);
//Add an object to the title screen or do whatever else you want
}
```
## Events
Additionally, New Horizons provides events in the API for tracking title screen loading:
```csharp title="INewHorizons.cs"
/// <summary>
/// An event invoked when NH has finished building a title screen.
/// Gives the unique name of the mod the title screen builder was from and the index for when you have multiple title screens.
/// </summary>
UnityEvent<string, int> GetTitleScreenLoadedEvent();
/// <summary>
/// An event invoked when NH has finished building the title screen.
/// </summary>
UnityEvent GetAllTitleScreensLoadedEvent();
```
### Example event usage
```csharp title="YourModBehaviour.cs"
NewHorizons = ModHelper.Interaction.TryGetModApi<INewHorizons>("xen.NewHorizons");
NewHorizons.GetTitleScreenLoadedEvent().AddListener(OnTitleScreenLoaded);
NewHorizons.GetAllTitleScreensLoadedEvent().AddListener(OnAllTitleScreensLoaded);
```
```csharp title="YourModBehaviour.cs"
public void OnTitleScreenLoaded(string modUniqueName, int index)
{
ModHelper.Console.WriteLine($"Title screen loaded: {modUniqueName} #{index}", MessageType.Success);
}
public void OnAllTitleScreensLoaded()
{
ModHelper.Console.WriteLine("All title screens loaded", MessageType.Success);
}
```
## Sharing
New Horizons will randomly select a valid title screen each time the user enters the main menu and then if `shareTitleScreen` is set to `true` it will build all the other shareable title screens (that also have matching `disableNHPlanets` values). If it doesn't have share set to true then it will only show the randomly selected.