diff --git a/NewHorizons/Assets/translations/portuguese_br.json b/NewHorizons/Assets/translations/portuguese_br.json index cac86858..279aa104 100644 --- a/NewHorizons/Assets/translations/portuguese_br.json +++ b/NewHorizons/Assets/translations/portuguese_br.json @@ -1,6 +1,69 @@ { - "$schema": "https://raw.githubusercontent.com/xen-42/outer-wilds-new-horizons/main/NewHorizons/Schemas/translation_schema.json", + "$schema": "https://raw.githubusercontent.com/outer-wilds-new-horizons/new-horizons/main/NewHorizons/Schemas/translation_schema.json", + "DialogueDictionary": { + "NEW_HORIZONS_WARP_DRIVE_DIALOGUE_1": "Sua nave agora está equipada com uma unidade de translocação!", + "NEW_HORIZONS_WARP_DRIVE_DIALOGUE_2": "Você pode usar a nova aba \"Modo Interestelar\" no diário de bordo de sua nave para ajustar o piloto automático rumo a outro sistema solar.", + "NEW_HORIZONS_WARP_DRIVE_DIALOGUE_3": "Então é só acionar o piloto automático e relaxar!" + }, "UIDictionary": { - "Vessel": "Hospedeiro" + "INTERSTELLAR_MODE": "Modo Interestelar", + "FREQ_STATUE": "Estátua Nomai", + "FREQ_WARP_CORE": "Fluxo Anti-Gravitacional", + "FREQ_UNKNOWN": "???", + "ENGAGE_WARP_PROMPT": "Acionar translocação a {0}", + "WARP_LOCKED": "PILOTO AUTOMÁTICO AJUSTADO PARA:\n{0}", + "LOCK_AUTOPILOT_WARP": "Ajustar Piloto Automático para sistema solar", + "RICH_PRESENCE_EXPLORING": "Explorando {0}.", + "RICH_PRESENCE_WARPING": "Translocando para {0}.", + "OUTDATED_VERSION_WARNING": "AVISO\n\nA mod New Horizons funciona apenas na versão {0} ou superior. Você está usando a versão {1}.\n\nAtualize Outer Wilds ou desinstale NH.", + "JSON_FAILED_TO_LOAD": "Arquivo(s) inválido(s): {0}", + "DEBUG_RAYCAST": "Raycast", + "DEBUG_PLACE": "Posicionar objeto", + "DEBUG_PLACE_TEXT": "Colocar texto Nomai", + "DEBUG_UNDO": "Desfazer", + "DEBUG_REDO": "Refazer", + "Vessel": "Hospedeiro", + "DLC_REQUIRED": "AVISO\n\nVocê tem addons (ex. {0}) instalados que requerem a DLC, mas a mesma não está habilitada.\n\nSuas mods podem não funcionar como esperado." + }, + "OtherDictionary": { + "NOMAI_SHUTTLE_COMPUTER": "A exploradora]]> está repousando por hora em: {0}]]>." + }, + "AchievementTranslations": { + "NH_EATEN_OUTSIDE_BRAMBLE": { + "Name": "Fenda de Contenção", + "Description": "Seja devorado fora dos limites de Abrolho Sombrio" + }, + "NH_MULTIPLE_SYSTEM": { + "Name": "Viajante", + "Description": "Visite 5 sistemas solares seguidos." + }, + "NH_NEW_FREQ": { + "Name": "Frequências Anômalas", + "Description": "Descubra uma nova frequência." + }, + "NH_PROBE_LOST": { + "Name": "Conexão Perdida", + "Description": "Perca seu pequeno batedor." + }, + "NH_WARP_DRIVE": { + "Name": "História Mal Contada", + "Description": "Use a unidade de translocação da nave." + }, + "NH_VESSEL_WARP": { + "Name": "História Acertada", + "Description": "Transloque para um sistema estelar usando o Hospedeiro." + }, + "NH_RAFTING": { + "Name": "A Jangada e os Furiosos", + "Description": "Dê um rolé de jangada." + }, + "NH_SUCKED_INTO_LAVA_BY_TORNADO": { + "Name": "Dieclone", + "Description": "Seja sugado na lava por um tornado." + }, + "NH_TALK_TO_FIVE_CHARACTERS": { + "Name": "Social", + "Description": "Converse com 5 personagens." + } } -} \ No newline at end of file +} diff --git a/NewHorizons/Builder/Atmosphere/FogBuilder.cs b/NewHorizons/Builder/Atmosphere/FogBuilder.cs index f47e3e29..8a694ac3 100644 --- a/NewHorizons/Builder/Atmosphere/FogBuilder.cs +++ b/NewHorizons/Builder/Atmosphere/FogBuilder.cs @@ -87,6 +87,9 @@ namespace NewHorizons.Builder.Atmosphere MR.material.SetFloat(Radius, atmo.fogSize); MR.material.SetFloat(Density, atmo.fogDensity); MR.material.SetFloat(DensityExponent, 1); + // We apply fogTint to the material and tint the fog ramp, which means the ramp and tint get multiplied together in the shader, so tint is applied twice + // However nobody has visually complained about this, so we don't want to change it until maybe somebody does + // Was previously documented by issue #747. MR.material.SetTexture(ColorRampTexture, colorRampTexture); fogGO.transform.position = planetGO.transform.position; diff --git a/NewHorizons/Builder/General/GravityBuilder.cs b/NewHorizons/Builder/General/GravityBuilder.cs index 7d202e27..e358d1a8 100644 --- a/NewHorizons/Builder/General/GravityBuilder.cs +++ b/NewHorizons/Builder/General/GravityBuilder.cs @@ -27,9 +27,9 @@ namespace NewHorizons.Builder.General gravityGO.layer = Layer.BasicEffectVolume; gravityGO.SetActive(false); - var SC = gravityGO.AddComponent(); - SC.isTrigger = true; - SC.radius = gravityRadius; + var sphereCollider = gravityGO.AddComponent(); + sphereCollider.isTrigger = true; + sphereCollider.radius = gravityRadius; var owCollider = gravityGO.AddComponent(); owCollider.SetLODActivationMask(DynamicOccupant.Player); @@ -49,7 +49,8 @@ namespace NewHorizons.Builder.General if (config.Base.surfaceGravity == 0) alignmentRadius = 0; gravityVolume._alignmentRadius = config.Base.gravityAlignmentRadiusOverride ?? alignmentRadius; - gravityVolume._upperSurfaceRadius = config.FocalPoint != null ? 0 : config.Base.surfaceSize; + // Nobody write any FocalPoint overriding here, those work as intended gravitationally so deal with it! + gravityVolume._upperSurfaceRadius = config.Base.surfaceSize; gravityVolume._lowerSurfaceRadius = 0; gravityVolume._layer = 3; gravityVolume._priority = config.Base.gravityVolumePriority; @@ -59,6 +60,21 @@ namespace NewHorizons.Builder.General gravityVolume._isPlanetGravityVolume = true; gravityVolume._cutoffRadius = 0f; + // If it's a focal point dont add collision stuff + // This is overkill + if (config.FocalPoint != null) + { + owCollider.enabled = false; + owTriggerVolume.enabled = false; + sphereCollider.radius = 0; + sphereCollider.enabled = false; + sphereCollider.isTrigger = false; + // This should ensure that even if the player enters the volume, it counts them as being inside the zero-gee cave equivalent + gravityVolume._cutoffRadius = gravityVolume._upperSurfaceRadius; + gravityVolume._lowerSurfaceRadius = gravityVolume._upperSurfaceRadius; + gravityVolume._cutoffAcceleration = 0; + } + gravityGO.SetActive(true); ao._gravityVolume = gravityVolume; diff --git a/NewHorizons/Builder/Props/DetailBuilder.cs b/NewHorizons/Builder/Props/DetailBuilder.cs index 2b7dbfde..87056fe1 100644 --- a/NewHorizons/Builder/Props/DetailBuilder.cs +++ b/NewHorizons/Builder/Props/DetailBuilder.cs @@ -155,6 +155,17 @@ namespace NewHorizons.Builder.Props continue; } + /* We used to set SectorCullGroup._controllingProxy to null. Now we do not. + * This may break things on copied details because it prevents SetSector from doing anything, + * so that part of the detail might be culled by wrong sector. + * So if you copy something from e.g. Giants Deep it might turn off the detail if you arent in + * the sector of the thing you copied from (since it's still pointing to the original proxy, + * which has the original sector at giants deep there) + * + * Anyway nobody has complained about this for the year it's been like that so closing issue #831 until + * this affects somebody + */ + FixSectoredComponent(component, sector, existingSectors); } @@ -459,6 +470,15 @@ namespace NewHorizons.Builder.Props { component.gameObject.AddComponent(); } + else if (component is RaftDock dock) + { + // These flood toggles are to disable flooded docks on the Stranger + // Presumably the user isn't making one of those + foreach (var toggle in dock.GetComponents()) + { + Component.DestroyImmediate(toggle); + } + } } /// @@ -486,7 +506,10 @@ namespace NewHorizons.Builder.Props // Disable the angler anim controller because we don't want Update or LateUpdate to run, just need it to set the initial Animator state angler.enabled = false; angler.OnChangeAnglerState(AnglerfishController.AnglerState.Lurking); - + + angler._animator.SetFloat("MoveSpeed", angler._moveCurrent); + angler._animator.SetFloat("Jaw", angler._jawCurrent); + Destroy(this); } } diff --git a/NewHorizons/Builder/Props/EyeOfTheUniverseBuilder.cs b/NewHorizons/Builder/Props/EyeOfTheUniverseBuilder.cs new file mode 100644 index 00000000..b8c84b62 --- /dev/null +++ b/NewHorizons/Builder/Props/EyeOfTheUniverseBuilder.cs @@ -0,0 +1,258 @@ +using NewHorizons.Builder.Props.Audio; +using NewHorizons.Components.EyeOfTheUniverse; +using NewHorizons.External; +using NewHorizons.External.Modules; +using NewHorizons.External.Modules.Props.Audio; +using NewHorizons.External.Modules.Props.EyeOfTheUniverse; +using NewHorizons.Handlers; +using NewHorizons.Utility; +using NewHorizons.Utility.OuterWilds; +using NewHorizons.Utility.OWML; +using UnityEngine; + +namespace NewHorizons.Builder.Props +{ + public static class EyeOfTheUniverseBuilder + { + public static TravelerEyeController MakeEyeTraveler(GameObject planetGO, Sector sector, EyeTravelerInfo info, NewHorizonsBody nhBody) + { + var travelerData = EyeSceneHandler.CreateEyeTravelerData(info.id); + travelerData.info = info; + travelerData.requirementsMet = true; + + if (!string.IsNullOrEmpty(info.requiredFact) && !ShipLogHandler.KnowsFact(info.requiredFact)) + { + travelerData.requirementsMet = false; + } + + if (!string.IsNullOrEmpty(info.requiredPersistentCondition) && !DialogueConditionManager.SharedInstance.GetConditionState(info.requiredPersistentCondition)) + { + travelerData.requirementsMet = false; + } + + if (!travelerData.requirementsMet) + { + return null; + } + + var go = DetailBuilder.Make(planetGO, sector, nhBody.Mod, info); + + var travelerController = go.GetAddComponent(); + if (!string.IsNullOrEmpty(info.startPlayingCondition)) + { + travelerController._startPlayingCondition = info.startPlayingCondition; + } + else if (string.IsNullOrEmpty(travelerController._startPlayingCondition)) + { + NHLogger.LogError($"Eye Traveler with ID \"{info.id}\" does not have a Start Playing condition set"); + } + if (travelerController._animator == null) + { + travelerController._animator = go.GetComponentInChildren(); + } + if (info.dialogue != null) + { + var (dialogueTree, remoteTrigger) = DialogueBuilder.Make(planetGO, sector, info.dialogue, nhBody.Mod); + if (info.dialogue.position == null && info.dialogue.parentPath == null) + { + info.dialogue.isRelativeToParent = true; + } + GeneralPropBuilder.MakeFromExisting(dialogueTree.gameObject, planetGO, sector, info.dialogue, defaultParent: go.transform); + + if (travelerController._dialogueTree != null) + { + travelerController._dialogueTree.OnStartConversation -= travelerController.OnStartConversation; + travelerController._dialogueTree.OnEndConversation -= travelerController.OnEndConversation; + } + travelerController._dialogueTree = dialogueTree; + travelerController._dialogueTree.OnStartConversation += travelerController.OnStartConversation; + travelerController._dialogueTree.OnEndConversation += travelerController.OnEndConversation; + } + else if (travelerController._dialogueTree == null) + { + travelerController._dialogueTree = go.GetComponentInChildren(); + if (travelerController._dialogueTree == null) + { + NHLogger.LogError($"Eye Traveler with ID \"{info.id}\" does not have any dialogue set"); + } + } + + travelerData.controller = travelerController; + + OWAudioSource loopAudioSource = null; + + if (info.signal != null) + { + if (string.IsNullOrEmpty(info.signal.name)) + { + info.signal.name = go.name; + } + if (string.IsNullOrEmpty(info.signal.frequency)) + { + info.signal.frequency = "Traveler"; + } + var signalGO = SignalBuilder.Make(planetGO, sector, info.signal, nhBody.Mod); + if (info.signal.position == null && info.signal.parentPath == null) + { + info.signal.isRelativeToParent = true; + } + GeneralPropBuilder.MakeFromExisting(signalGO, planetGO, sector, info.signal, defaultParent: go.transform); + + var signal = signalGO.GetComponent(); + travelerController._signal = signal; + signal.SetSignalActivation(false); + loopAudioSource = signal.GetOWAudioSource(); + + } + else if (travelerController._signal == null) + { + NHLogger.LogError($"Eye Traveler with ID \"{info.id}\" does not have any loop audio set"); + } + + travelerData.loopAudioSource = loopAudioSource; + + OWAudioSource finaleAudioSource = null; + + if (!string.IsNullOrEmpty(info.finaleAudio)) + { + var finaleAudioInfo = new AudioSourceInfo() + { + audio = info.finaleAudio, + track = External.SerializableEnums.NHAudioMixerTrackName.Music, + volume = 1f, + }; + finaleAudioSource = GeneralAudioBuilder.Make(planetGO, sector, finaleAudioInfo, nhBody.Mod); + finaleAudioSource.SetTrack(finaleAudioInfo.track.ConvertToOW()); + finaleAudioSource.loop = false; + finaleAudioSource.spatialBlend = 0f; + finaleAudioSource.playOnAwake = false; + finaleAudioSource.gameObject.SetActive(true); + } + + travelerData.finaleAudioSource = finaleAudioSource; + + return travelerController; + } + + public static QuantumInstrument MakeQuantumInstrument(GameObject planetGO, Sector sector, QuantumInstrumentInfo info, NewHorizonsBody nhBody) + { + var travelerData = EyeSceneHandler.GetEyeTravelerData(info.id); + + if (travelerData != null && !travelerData.requirementsMet) + { + return null; + } + + var go = DetailBuilder.Make(planetGO, sector, nhBody.Mod, info); + go.layer = Layer.Interactible; + if (info.interactRadius > 0f) + { + var collider = go.AddComponent(); + collider.radius = info.interactRadius; + collider.isTrigger = true; + go.GetAddComponent(); + } + + go.GetAddComponent(); + var quantumInstrument = go.GetAddComponent(); + quantumInstrument._gatherWithScope = info.gatherWithScope; + ArrayHelpers.Append(ref quantumInstrument._deactivateObjects, go); + + var trigger = go.AddComponent(); + trigger.gatherCondition = info.gatherCondition; + + if (travelerData != null) + { + travelerData.quantumInstruments.Add(quantumInstrument); + } + else + { + NHLogger.LogError($"Quantum instrument with ID \"{info.id}\" has no matching eye traveler"); + } + + info.signal ??= new SignalInfo(); + + if (travelerData?.info != null && travelerData.info.signal != null) + { + if (string.IsNullOrEmpty(info.signal.name)) + { + info.signal.name = travelerData.info.signal.name; + } + if (string.IsNullOrEmpty(info.signal.audio)) + { + info.signal.audio = travelerData.info.signal.audio; + } + if (string.IsNullOrEmpty(info.signal.frequency)) + { + info.signal.frequency = travelerData.info.signal.frequency; + } + } + + if (!string.IsNullOrEmpty(info.signal.audio)) + { + var signalGO = SignalBuilder.Make(planetGO, sector, info.signal, nhBody.Mod); + if (info.signal.position == null && info.signal.parentPath == null) + { + info.signal.isRelativeToParent = true; + } + GeneralPropBuilder.MakeFromExisting(signalGO, planetGO, sector, info.signal, defaultParent: go.transform); + } + else + { + NHLogger.LogError($"Eye Traveler with ID \"{info.id}\" does not have any loop audio set"); + } + + return quantumInstrument; + } + + public static InstrumentZone MakeInstrumentZone(GameObject planetGO, Sector sector, InstrumentZoneInfo info, NewHorizonsBody nhBody) + { + var travelerData = EyeSceneHandler.GetEyeTravelerData(info.id); + + if (travelerData != null && !travelerData.requirementsMet) + { + return null; + } + + var go = DetailBuilder.Make(planetGO, sector, nhBody.Mod, info); + + var instrumentZone = go.AddComponent(); + + if (travelerData != null) + { + travelerData.instrumentZones.Add(instrumentZone); + } + else + { + NHLogger.LogError($"Instrument zone with ID \"{info.id}\" has no matching eye traveler"); + } + + return instrumentZone; + } + + public static void Make(GameObject go, Sector sector, EyeOfTheUniverseModule module, NewHorizonsBody nhBody) + { + if (module.eyeTravelers != null) + { + foreach (var info in module.eyeTravelers) + { + MakeEyeTraveler(go, sector, info, nhBody); + } + } + if (module.instrumentZones != null) + { + foreach (var info in module.instrumentZones) + { + MakeInstrumentZone(go, sector, info, nhBody); + } + } + if (module.quantumInstruments != null) + { + foreach (var info in module.quantumInstruments) + { + MakeQuantumInstrument(go, sector, info, nhBody); + } + } + } + } +} diff --git a/NewHorizons/Builder/Props/ProjectionBuilder.cs b/NewHorizons/Builder/Props/ProjectionBuilder.cs index 7df34b6c..2ebf64fc 100644 --- a/NewHorizons/Builder/Props/ProjectionBuilder.cs +++ b/NewHorizons/Builder/Props/ProjectionBuilder.cs @@ -150,28 +150,22 @@ namespace NewHorizons.Builder.Props // Now we replace the slides int slidesCount = info.slides.Length; - var slideCollection = new SlideCollection(slidesCount); + SlideCollection slideCollection = new NHSlideCollection(slidesCount, mod, info.slides.Select(x => x.imagePath).ToArray()); slideCollection.streamingAssetIdentifier = string.Empty; // NREs if null // We can fit 16 slides max into an atlas var textures = new Texture2D[slidesCount > 16 ? 16 : slidesCount]; - var (invImageLoader, atlasImageLoader, imageLoader) = StartAsyncLoader(mod, info.slides, ref slideCollection, true, true); + // Slide reels dynamically load the inverted cached images when needed. We only need to load raw images to generate the cache or atlases + var (_, atlasImageLoader, imageLoader) = StartAsyncLoader(mod, info.slides, ref slideCollection, false, true, false); // If the cache doesn't exist it will be created here, slide reels only use the base image loader for cache creation so delete the images after to free memory - imageLoader.deleteTexturesWhenDone = !CacheExists(mod); + imageLoader.deleteTexturesWhenDone = true; var key = GetUniqueSlideReelID(mod, info.slides); - if (invImageLoader != null && atlasImageLoader != null) + if (atlasImageLoader != null) { - // Loading directly from cache - invImageLoader.imageLoadedEvent.AddListener( - (Texture2D tex, int index, string originalPath) => - { - slideCollection.slides[index]._image = tex; - } - ); atlasImageLoader.imageLoadedEvent.AddListener( (Texture2D tex, int _, string originalPath) => { @@ -202,6 +196,7 @@ namespace NewHorizons.Builder.Props { var time = DateTime.Now; + // inverted slides will be loaded for the whole loop but its fine since this is only when generating cache slideCollection.slides[index]._image = ImageUtilities.InvertSlideReel(mod, tex, originalPath); NHLogger.LogVerbose($"Slide reel make reel invert texture {(DateTime.Now - time).TotalMilliseconds}ms"); // Track the first 16 to put on the slide reel object @@ -215,8 +210,14 @@ namespace NewHorizons.Builder.Props var slidesBack = slideReelObj.GetComponentInChildren(true).transform.Find("Slides_Back").GetComponent(); var slidesFront = slideReelObj.GetComponentInChildren(true).transform.Find("Slides_Front").GetComponent(); - // Now put together the textures into a 4x4 thing for the materials - var reelTexture = ImageUtilities.MakeReelTexture(mod, textures, key); + // Now put together the textures into a 4x4 thing for the materials #888 + var displayTextures = textures; + if (info.displaySlides != null && info.displaySlides.Length > 0) + { + displayTextures = info.displaySlides.Select(x => textures[x]).ToArray(); + } + + var reelTexture = ImageUtilities.MakeReelTexture(mod, displayTextures, key); slidesBack.material.mainTexture = reelTexture; slidesBack.material.SetTexture(EmissionMap, reelTexture); slidesBack.material.name = reelTexture.name; @@ -348,15 +349,16 @@ namespace NewHorizons.Builder.Props var toDestroy = autoProjector.GetComponent(); var slideCollectionContainer = autoProjector.gameObject.AddComponent(); + slideCollectionContainer.doAsyncLoading = false; autoProjector._slideCollectionItem = slideCollectionContainer; Component.DestroyImmediate(toDestroy); // Now we replace the slides int slidesCount = info.slides.Length; - var slideCollection = new SlideCollection(slidesCount); + SlideCollection slideCollection = new NHSlideCollection(slidesCount, mod, info.slides.Select(x => x.imagePath).ToArray()); slideCollection.streamingAssetIdentifier = string.Empty; // NREs if null - var (invImageLoader, _, imageLoader) = StartAsyncLoader(mod, info.slides, ref slideCollection, true, false); + var (invImageLoader, _, imageLoader) = StartAsyncLoader(mod, info.slides, ref slideCollection, true, false, false); // Autoprojector only uses the inverted images so the original can be destroyed if they are loaded (when creating the cached inverted images) imageLoader.deleteTexturesWhenDone = true; @@ -381,6 +383,7 @@ namespace NewHorizons.Builder.Props } slideCollectionContainer.slideCollection = slideCollection; + slideCollectionContainer._playWithShipLogFacts = Array.Empty(); // else it NREs in container initialize StreamingHandler.SetUpStreaming(projectorObj, sector); @@ -402,9 +405,9 @@ namespace NewHorizons.Builder.Props if (_visionTorchDetectorPrefab == null) return null; // spawn a trigger for the vision torch - var g = DetailBuilder.Make(planetGO, sector, mod, _visionTorchDetectorPrefab, new DetailInfo(info) { scale = 2, rename = !string.IsNullOrEmpty(info.rename) ? info.rename : "VisionStaffDetector" }); + var visionTorchTargetGO = DetailBuilder.Make(planetGO, sector, mod, _visionTorchDetectorPrefab, new DetailInfo(info) { scale = 2, rename = !string.IsNullOrEmpty(info.rename) ? info.rename : "VisionStaffDetector" }); - if (g == null) + if (visionTorchTargetGO == null) { NHLogger.LogWarning($"Tried to make a vision torch target but couldn't. Do you have the DLC installed?"); return null; @@ -416,7 +419,7 @@ namespace NewHorizons.Builder.Props var slideCollection = new SlideCollection(slidesCount); // TODO: uh I think that info.slides[i].playTimeDuration is not being read here... note to self for when I implement support for that: 0.7 is what to default to if playTimeDuration turns out to be 0 slideCollection.streamingAssetIdentifier = string.Empty; // NREs if null - var (_, _, imageLoader) = StartAsyncLoader(mod, info.slides, ref slideCollection, false, false); + var (_, _, imageLoader) = StartAsyncLoader(mod, info.slides, ref slideCollection, false, false, true); imageLoader.imageLoadedEvent.AddListener((Texture2D tex, int index, string originalPath) => { var time = DateTime.Now; @@ -425,17 +428,18 @@ namespace NewHorizons.Builder.Props }); // attach a component to store all the data for the slides that play when a vision torch scans this target - var target = g.AddComponent(); - var slideCollectionContainer = g.AddComponent(); + var target = visionTorchTargetGO.AddComponent(); + var slideCollectionContainer = visionTorchTargetGO.AddComponent(); + slideCollectionContainer.doAsyncLoading = false; slideCollectionContainer.slideCollection = slideCollection; - target.slideCollection = g.AddComponent(); + target.slideCollection = visionTorchTargetGO.AddComponent(); target.slideCollection._slideCollectionContainer = slideCollectionContainer; LinkShipLogFacts(info, slideCollectionContainer); - g.SetActive(true); + visionTorchTargetGO.SetActive(true); - return g; + return visionTorchTargetGO; } public static GameObject MakeStandingVisionTorch(GameObject planetGO, Sector sector, ProjectionInfo info, IModBehaviour mod) @@ -469,7 +473,7 @@ namespace NewHorizons.Builder.Props var slideCollection = new SlideCollection(slidesCount); slideCollection.streamingAssetIdentifier = string.Empty; // NREs if null - var (_, _, imageLoader) = StartAsyncLoader(mod, slides, ref slideCollection, false, false); + var (_, _, imageLoader) = StartAsyncLoader(mod, slides, ref slideCollection, false, false, true); // This variable just lets us track how many of the slides have been loaded. // This way as soon as the last one is loaded (due to async loading, this may be @@ -494,6 +498,7 @@ namespace NewHorizons.Builder.Props // Set up the containers for the slides var slideCollectionContainer = standingTorch.AddComponent(); + slideCollectionContainer.doAsyncLoading = false; slideCollectionContainer.slideCollection = slideCollection; var mindSlideCollection = standingTorch.AddComponent(); @@ -512,8 +517,18 @@ namespace NewHorizons.Builder.Props return standingTorch; } + /// + /// start loading all the slide stuff we need async. + /// + /// the mod to load slides from + /// slides to load + /// where to assign the slide objects + /// should we load cached inverted images? + /// should we load cached atlas images? + /// should we load the original images? happens anyway if cache doesnt exist since atlas or inverted will need it + /// the 3 loaders (inverted, atlas, original). inverted and atlas will be null if cache doesnt exist, so check those to find out if cache exists private static (SlideReelAsyncImageLoader inverted, SlideReelAsyncImageLoader atlas, SlideReelAsyncImageLoader slides) - StartAsyncLoader(IModBehaviour mod, SlideInfo[] slides, ref SlideCollection slideCollection, bool useInvertedCache, bool useAtlasCache) + StartAsyncLoader(IModBehaviour mod, SlideInfo[] slides, ref SlideCollection slideCollection, bool useInvertedCache, bool useAtlasCache, bool loadRawImages) { var invertedImageLoader = new SlideReelAsyncImageLoader(); var atlasImageLoader = new SlideReelAsyncImageLoader(); @@ -545,7 +560,7 @@ namespace NewHorizons.Builder.Props // Load the inverted images used when displaying slide reels to a screen invertedImageLoader.PathsToLoad.Add((i, Path.Combine(Main.Instance.ModHelper.Manifest.ModFolderPath, "Assets/textures/inverted_blank_slide_reel.png"))); } - else + else if (!cacheExists || loadRawImages) { // Used to then make cached stuff imageLoader.PathsToLoad.Add((i, Path.Combine(Main.Instance.ModHelper.Manifest.ModFolderPath, "Assets/textures/blank_slide_reel.png"))); @@ -553,12 +568,12 @@ namespace NewHorizons.Builder.Props } else { - if (useInvertedCache && cacheExists) + if (cacheExists && useInvertedCache) { // Load the inverted images used when displaying slide reels to a screen invertedImageLoader.PathsToLoad.Add((i, Path.Combine(mod.ModHelper.Manifest.ModFolderPath, InvertedSlideReelCacheFolder, slideInfo.imagePath))); } - else + if (!cacheExists || loadRawImages) { imageLoader.PathsToLoad.Add((i, Path.Combine(mod.ModHelper.Manifest.ModFolderPath, slideInfo.imagePath))); } @@ -577,12 +592,11 @@ namespace NewHorizons.Builder.Props { atlasImageLoader.Start(false, false); } - // When using the inverted cache we never need the regular images if (useInvertedCache) { invertedImageLoader.Start(true, false); } - else + if (loadRawImages) { imageLoader.Start(true, false); } diff --git a/NewHorizons/Builder/Props/QuantumBuilder.cs b/NewHorizons/Builder/Props/QuantumBuilder.cs index 90227e11..302270e8 100644 --- a/NewHorizons/Builder/Props/QuantumBuilder.cs +++ b/NewHorizons/Builder/Props/QuantumBuilder.cs @@ -223,111 +223,8 @@ namespace NewHorizons.Builder.Props shuffle._shuffledObjects = propsInGroup.Select(p => p.transform).ToArray(); shuffle.Awake(); // this doesn't get called on its own for some reason. what? how? - AddBoundsVisibility(shuffleParent); + BoundsUtilities.AddBoundsVisibility(shuffleParent); shuffleParent.SetActive(true); } - - - struct BoxShapeReciever - { - public MeshFilter f; - public SkinnedMeshRenderer s; - public GameObject gameObject; - } - - public static void AddBoundsVisibility(GameObject g) - { - var meshFilters = g.GetComponentsInChildren(); - var skinnedMeshRenderers = g.GetComponentsInChildren(); - - var boxShapeRecievers = meshFilters - .Select(f => new BoxShapeReciever() { f = f, gameObject = f.gameObject }) - .Concat( - skinnedMeshRenderers.Select(s => new BoxShapeReciever() { s = s, gameObject = s.gameObject }) - ) - .ToList(); - - foreach (var boxshapeReciever in boxShapeRecievers) - { - var box = boxshapeReciever.gameObject.AddComponent(); - boxshapeReciever.gameObject.AddComponent(); - boxshapeReciever.gameObject.AddComponent(); - - var fixer = boxshapeReciever.gameObject.AddComponent(); - fixer.shape = box; - fixer.meshFilter = boxshapeReciever.f; - fixer.skinnedMeshRenderer = boxshapeReciever.s; - } - } - - // BUG: ignores skinned guys. this coincidentally makes it work without BoxShapeFixer - public static Bounds GetBoundsOfSelfAndChildMeshes(GameObject g) - { - var meshFilters = g.GetComponentsInChildren(); - var corners = meshFilters.SelectMany(m => GetMeshCorners(m, g)).ToList(); - - Bounds b = new Bounds(corners[0], Vector3.zero); - corners.ForEach(corner => b.Encapsulate(corner)); - - return b; - } - - public static Vector3[] GetMeshCorners(MeshFilter m, GameObject relativeTo = null) - { - var bounds = m.mesh.bounds; - - var localCorners = new Vector3[] - { - bounds.min, - bounds.max, - new Vector3(bounds.min.x, bounds.min.y, bounds.max.z), - new Vector3(bounds.min.x, bounds.max.y, bounds.min.z), - new Vector3(bounds.max.x, bounds.min.y, bounds.min.z), - new Vector3(bounds.min.x, bounds.max.y, bounds.max.z), - new Vector3(bounds.max.x, bounds.min.y, bounds.max.z), - new Vector3(bounds.max.x, bounds.max.y, bounds.min.z), - }; - - var globalCorners = localCorners.Select(localCorner => m.transform.TransformPoint(localCorner)).ToArray(); - - if (relativeTo == null) return globalCorners; - - return globalCorners.Select(globalCorner => relativeTo.transform.InverseTransformPoint(globalCorner)).ToArray(); - } - } - - /// - /// for some reason mesh bounds are wrong unless we wait a bit - /// so this script contiously checks everything until it is correct - /// - /// this actually only seems to be a problem with skinned renderers. normal ones work fine - /// TODO: at some point narrow this down to just skinned, instead of doing everything and checking every frame - /// - public class BoxShapeFixer : MonoBehaviour - { - public BoxShape shape; - public MeshFilter meshFilter; - public SkinnedMeshRenderer skinnedMeshRenderer; - - public void Update() - { - if (meshFilter == null && skinnedMeshRenderer == null) - { - NHLogger.LogVerbose("Useless BoxShapeFixer, destroying"); - DestroyImmediate(this); - } - - Mesh sharedMesh = null; - if (meshFilter != null) sharedMesh = meshFilter.sharedMesh; - if (skinnedMeshRenderer != null) sharedMesh = skinnedMeshRenderer.sharedMesh; - - if (sharedMesh == null) return; - if (sharedMesh.bounds.size == Vector3.zero) return; - - shape.size = sharedMesh.bounds.size; - shape.center = sharedMesh.bounds.center; - - DestroyImmediate(this); - } } } diff --git a/NewHorizons/Builder/Props/RaftBuilder.cs b/NewHorizons/Builder/Props/RaftBuilder.cs index 98196885..59f734bf 100644 --- a/NewHorizons/Builder/Props/RaftBuilder.cs +++ b/NewHorizons/Builder/Props/RaftBuilder.cs @@ -66,6 +66,9 @@ namespace NewHorizons.Builder.Props var waterVolume = planetGO.GetComponentInChildren(); fluidDetector._alignmentFluid = waterVolume; fluidDetector._buoyancy.checkAgainstWaves = true; + // Rafts were unable to trigger docks because these were disabled for some reason + fluidDetector.GetComponent().enabled = true; + fluidDetector.GetComponent().enabled = true; // Light sensors foreach (var lightSensor in raftObject.GetComponentsInChildren()) diff --git a/NewHorizons/Builder/Volumes/CreditsVolumeBuilder.cs b/NewHorizons/Builder/Volumes/CreditsVolumeBuilder.cs index 70093cc4..81c3c6ce 100644 --- a/NewHorizons/Builder/Volumes/CreditsVolumeBuilder.cs +++ b/NewHorizons/Builder/Volumes/CreditsVolumeBuilder.cs @@ -11,9 +11,8 @@ namespace NewHorizons.Builder.Volumes { var volume = VolumeBuilder.Make(planetGO, sector, info); - volume.creditsType = info.creditsType; - volume.gameOverText = info.gameOverText; - volume.deathType = EnumUtils.Parse(info.deathType.ToString(), DeathType.Default); + volume.gameOver = info.gameOver; + volume.deathType = info.deathType == null ? null : EnumUtils.Parse(info.deathType.ToString(), DeathType.Default); return volume; } diff --git a/NewHorizons/Components/EOTE/NHSlideCollection.cs b/NewHorizons/Components/EOTE/NHSlideCollection.cs new file mode 100644 index 00000000..6b13a833 --- /dev/null +++ b/NewHorizons/Components/EOTE/NHSlideCollection.cs @@ -0,0 +1,215 @@ +using HarmonyLib; +using NewHorizons.Builder.Props; +using NewHorizons.Utility; +using NewHorizons.Utility.Files; +using NewHorizons.Utility.OWML; +using OWML.Common; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace NewHorizons.Components.EOTE; + +[HarmonyPatch] +public class NHSlideCollection : SlideCollection +{ + public string[] slidePaths; + public IModBehaviour mod; + private HashSet _pathsBeingLoaded = new(); + /// + /// map of slide path to collections that have this path loaded. used to only unload slide when nothing else is using it + /// + public static Dictionary> _slidesRequiringPath = new(); + + private static ShipLogSlideProjector _shipLogSlideProjector; + + static NHSlideCollection() + { + SceneManager.sceneUnloaded += (_) => + { + foreach (var (slide, collections) in _slidesRequiringPath) + { + // If it has null, that means some other permanent thing loaded this texture and it will get cleared elsewhere + // Otherwise it was loaded by an NHSlideCollection and should be deleted + if (collections.Any() && !collections.Contains(null)) + { + ImageUtilities.DeleteTexture(slide); + } + } + _slidesRequiringPath.Clear(); + }; + } + + public NHSlideCollection(int startArrSize, IModBehaviour mod, string[] slidePaths) : base(startArrSize) + { + this.mod = mod; + this.slidePaths = slidePaths; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(SlideCollection), nameof(SlideCollection.RequestStreamSlides))] + public static bool SlideCollection_RequestStreamSlides(SlideCollection __instance, int[] slideIndices) + { + if (__instance is NHSlideCollection collection) + { + foreach (var id in slideIndices) + { + collection.LoadSlide(id); + } + return false; + } + else + { + return true; + } + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(SlideCollection), nameof(SlideCollection.RequestRelease))] + public static bool SlideCollection_RequestRelease(SlideCollection __instance, int[] slideIndices) + { + if (__instance is NHSlideCollection collection) + { + foreach (var id in slideIndices) + { + collection.UnloadSlide(id); + } + return false; + } + else + { + return true; + } + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(SlideCollection), nameof(SlideCollection.IsStreamedTextureIndexLoaded))] + public static bool SlideCollection_IsStreamedTextureIndexLoaded(SlideCollection __instance, int streamIdx, ref bool __result) + { + if (__instance is NHSlideCollection collection) + { + __result = collection.IsSlideLoaded(streamIdx); + return false; + } + else + { + return true; + } + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(SlideCollection), nameof(SlideCollection.GetStreamingTexture))] + public static bool SlideCollection_GetStreamingTexture(SlideCollection __instance, int id, ref Texture __result) + { + if (__instance is NHSlideCollection collection) + { + __result = collection.LoadSlide(id); + return false; + } + else + { + return true; + } + } + + public Texture LoadSlide(int index) + { + Texture LoadSlideInt(int index) + { + var wrappedIndex = (index + slides.Length) % slides.Length; + var path = Path.Combine(mod.ModHelper.Manifest.ModFolderPath, ProjectionBuilder.InvertedSlideReelCacheFolder, slidePaths[wrappedIndex]); + + // We are the first slide collection container to try and load this image + var key = ImageUtilities.GetKey(mod, path); + if (!_slidesRequiringPath.ContainsKey(key)) + { + // Something else has loaded this image i.e., AutoProjector or Vision torch. We want to ensure we do not delete it + if (ImageUtilities.IsTextureLoaded(mod, path)) + { + // null is dummy value to ensure its never empty (so its not deleted) + _slidesRequiringPath[key] = new() { null }; + } + else + { + _slidesRequiringPath[key] = new(); + } + _slidesRequiringPath[key].Add(this); + } + + if (ImageUtilities.IsTextureLoaded(mod, path)) + { + // already loaded + var texture = ImageUtilities.GetTexture(mod, path); + slides[wrappedIndex]._image = texture; + return texture; + } + else if (!_pathsBeingLoaded.Contains(path)) + { + // not loaded yet, we need to load it + var loader = new SlideReelAsyncImageLoader(); + loader.PathsToLoad.Add((wrappedIndex, path)); + loader.Start(true, false); + loader.imageLoadedEvent.AddListener((Texture2D tex, int index, string originalPath) => + { + // weird: sometimes we set image, sometimes we return from GetStreamingTexture. oh well + slides[wrappedIndex]._image = tex; + _pathsBeingLoaded.Remove(path); + if (_shipLogSlideProjector == null) + { + // Object.FindObjectOfType doesnt work with inactive + _shipLogSlideProjector = Resources.FindObjectsOfTypeAll().FirstOrDefault(); + } + if (_shipLogSlideProjector != null) + { + // gotta tell ship log we updated the image + _shipLogSlideProjector._slideDirty = true; + } + else + { + NHLogger.LogVerbose("No ship log slide reel projector exists"); + } + }); + _pathsBeingLoaded.Add(path); + return null; + } + else + { + // It is being loaded so we just wait + return null; + } + } + var texture = LoadSlideInt(index); + LoadSlideInt(index - 1); + LoadSlideInt(index + 1); + + return texture; + } + + public bool IsSlideLoaded(int index) + { + var wrappedIndex = (index + slides.Length) % slides.Length; + var path = Path.Combine(mod.ModHelper.Manifest.ModFolderPath, ProjectionBuilder.InvertedSlideReelCacheFolder, slidePaths[wrappedIndex]); + return ImageUtilities.IsTextureLoaded(mod, path); + } + + public void UnloadSlide(int index) + { + var wrappedIndex = (index + slides.Length) % slides.Length; + var path = Path.Combine(mod.ModHelper.Manifest.ModFolderPath, ProjectionBuilder.InvertedSlideReelCacheFolder, slidePaths[wrappedIndex]); + + // Only unload textures that we were the ones to load in + if (ImageUtilities.IsTextureLoaded(mod, path)) + { + var key = ImageUtilities.GetKey(mod, path); + _slidesRequiringPath[key].Remove(this); + if (!_slidesRequiringPath[key].Any()) + { + NHLogger.LogVerbose($"Slide reel deleting {key} since nobody is using it anymore"); + ImageUtilities.DeleteTexture(mod, path, ImageUtilities.GetTexture(mod, path)); + slides[wrappedIndex]._image = null; + } + } + } +} diff --git a/NewHorizons/Components/EOTE/NHSlideCollectionContainer.cs b/NewHorizons/Components/EOTE/NHSlideCollectionContainer.cs index bcf979ec..030139e4 100644 --- a/NewHorizons/Components/EOTE/NHSlideCollectionContainer.cs +++ b/NewHorizons/Components/EOTE/NHSlideCollectionContainer.cs @@ -1,4 +1,7 @@ using HarmonyLib; +using System; +using System.Linq; +using UnityEngine; namespace NewHorizons.Components.EOTE; @@ -7,12 +10,14 @@ public class NHSlideCollectionContainer : SlideCollectionContainer { public string[] conditionsToSet; public string[] persistentConditionsToSet; + // at some point we'll do streaming on all slides. until then just have an off switch + public bool doAsyncLoading = true; [HarmonyPrefix] [HarmonyPatch(typeof(SlideCollectionContainer), nameof(SlideCollectionContainer.Initialize))] public static bool SlideCollectionContainer_Initialize(SlideCollectionContainer __instance) { - if (__instance is NHSlideCollectionContainer) + if (__instance is NHSlideCollectionContainer container) { if (__instance._initialized) return false; @@ -28,6 +33,7 @@ public class NHSlideCollectionContainer : SlideCollectionContainer { var fact = Locator.GetShipLogManager().GetFact(factID); fact?.RegisterSlideCollection(__instance._slideCollection); + // in original it logs. we dont want that here ig } return false; } @@ -59,4 +65,98 @@ public class NHSlideCollectionContainer : SlideCollectionContainer } } } + + [HarmonyPrefix] + [HarmonyPatch(typeof(SlideCollectionContainer), nameof(SlideCollectionContainer.NextSlideAvailable))] + public static bool SlideCollectionContainer_NextSlideAvailable(SlideCollectionContainer __instance, ref bool __result) + { + if (__instance is NHSlideCollectionContainer container && container.doAsyncLoading) + { + __result = ((NHSlideCollection)container.slideCollection).IsSlideLoaded(container.slideIndex + 1); + return false; + } + else + { + return true; + } + } + + + [HarmonyPrefix] + [HarmonyPatch(typeof(SlideCollectionContainer), nameof(SlideCollectionContainer.PrevSlideAvailable))] + public static bool SlideCollectionContainer_PrevSlideAvailable(SlideCollectionContainer __instance, ref bool __result) + { + if (__instance is NHSlideCollectionContainer container && container.doAsyncLoading) + { + __result = ((NHSlideCollection)container.slideCollection).IsSlideLoaded(container.slideIndex - 1); + return false; + } + else + { + return true; + } + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(SlideCollectionContainer), nameof(SlideCollectionContainer.UnloadStreamingTextures))] + public static bool SlideCollectionContainer_UnloadStreamingTextures(SlideCollectionContainer __instance) + { + if (__instance is NHSlideCollectionContainer container && container.doAsyncLoading) + { + for (int i = 0; i < ((NHSlideCollection)container.slideCollection).slidePaths.Length; i++) + { + ((NHSlideCollection)container.slideCollection).UnloadSlide(i); + } + return false; + } + else + { + return true; + } + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(SlideCollectionContainer), nameof(SlideCollectionContainer.GetStreamingTexture))] + public static bool SlideCollectionContainer_GetStreamingTexture(SlideCollectionContainer __instance, int id, ref Texture __result) + { + if (__instance is NHSlideCollectionContainer container && container.doAsyncLoading) + { + __result = ((NHSlideCollection)container.slideCollection).LoadSlide(id); + return false; + } + else + { + return true; + } + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(SlideCollectionContainer), nameof(SlideCollectionContainer.RequestManualStreamSlides))] + public static bool SlideCollectionContainer_RequestManualStreamSlides(SlideCollectionContainer __instance) + { + if (__instance is NHSlideCollectionContainer container && container.doAsyncLoading) + { + ((NHSlideCollection)container.slideCollection).LoadSlide(__instance._currentSlideIndex); + return false; + } + else + { + return true; + } + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(SlideCollectionContainer), nameof(SlideCollectionContainer.streamingTexturesAvailable), MethodType.Getter)] + public static bool SlideCollectionContainer_streamingTexturesAvailable(SlideCollectionContainer __instance, ref bool __result) + { + if (__instance is NHSlideCollectionContainer container && container.doAsyncLoading) + { + __result = ((NHSlideCollection)container.slideCollection).slidePaths != null && ((NHSlideCollection)container.slideCollection).slidePaths.Any(); + return false; + } + else + { + return true; + } + } } diff --git a/NewHorizons/Components/EyeOfTheUniverse/EyeMusicController.cs b/NewHorizons/Components/EyeOfTheUniverse/EyeMusicController.cs new file mode 100644 index 00000000..233b2f4c --- /dev/null +++ b/NewHorizons/Components/EyeOfTheUniverse/EyeMusicController.cs @@ -0,0 +1,147 @@ +using NewHorizons.Utility; +using NewHorizons.Utility.OWML; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace NewHorizons.Components.EyeOfTheUniverse +{ + public class EyeMusicController : MonoBehaviour + { + // Delay between game logic and audio to ensure audio system has time to schedule all loops for the same tick + const double TIME_BUFFER_WINDOW = 0.5; + + private List _loopSources = new(); + private List _finaleSources = new(); + private bool _transitionToFinale; + private bool _isPlaying; + private double _segmentEndAudioTime; + private float _segmentEndGameTime; + public CosmicInflationController CosmicInflationController { get; private set; } + + public void RegisterLoopSource(OWAudioSource src) + { + src.loop = false; + src.SetLocalVolume(1f); + src.Stop(); + src.playOnAwake = false; + _loopSources.Add(src); + } + + public void RegisterFinaleSource(OWAudioSource src) + { + src.loop = false; + src.SetLocalVolume(1f); + src.Stop(); + src.playOnAwake = false; + _finaleSources.Add(src); + } + + public void StartPlaying() + { + if (_isPlaying) return; + _isPlaying = true; + StartCoroutine(DoLoop()); + } + + public void TransitionToFinale() + { + _transitionToFinale = true; + + // Schedule finale for as soon as the current segment loop ends + double finaleAudioTime = _segmentEndAudioTime; + float finaleGameTime = _segmentEndGameTime; + + // Cancel loop audio + foreach (var loopSrc in _loopSources) + { + loopSrc._audioSource.SetScheduledEndTime(finaleAudioTime); + } + + // Set quantum sphere inflation timer + var finaleDuration = CosmicInflationController._travelerFinaleSource.clip.length; + CosmicInflationController._startFormationTime = Time.time; + CosmicInflationController._finishFormationTime = finaleGameTime + finaleDuration - 4f; + + // Play finale in sync + foreach (var finaleSrc in _finaleSources) + { + finaleSrc._audioSource.PlayScheduled(finaleAudioTime); + } + } + + public void Awake() + { + // EOTP makes 2 new CosmicInflationControllers for no reason + CosmicInflationController = SearchUtilities.Find("EyeOfTheUniverse_Body/Sector_EyeOfTheUniverse/Sector_Campfire/InflationController").GetComponent(); + } + + private IEnumerator DoLoop() + { + // Determine timing using the first loop audio clip (should be Riebeck's banjo loop) + var referenceLoopClip = _loopSources.First().clip; + double loopDuration = referenceLoopClip.samples / (double)referenceLoopClip.frequency; + + // Vanilla audio divides the loop into 4 segments, but that actually causes weird key shifting during the crossfade + int segmentCount = 2; + double segmentDuration = loopDuration / segmentCount; + + // Track when the next loop will play, in both audio system time and game time + double nextLoopAudioTime = AudioSettings.dspTime + TIME_BUFFER_WINDOW; + float nextLoopGameTime = Time.time + (float)TIME_BUFFER_WINDOW; + + while (!_transitionToFinale) + { + // Play loops in sync + double loopStartAudioTime = nextLoopAudioTime; + float loopStartGameTime = nextLoopGameTime; + + foreach (var loopSrc in _loopSources) + { + if (!loopSrc.gameObject.activeInHierarchy) continue; + if (loopSrc.loop) continue; + // We only need to schedule once and then Unity will loop it for us + loopSrc._audioSource.PlayScheduled(loopStartAudioTime); + loopSrc.loop = true; + } + + // Schedule next loop + nextLoopAudioTime += loopDuration; + nextLoopGameTime += (float)loopDuration; + + // Track loop segment timing (the current musical verse should always finish playing before the finale) + for (int i = 0; i < segmentCount; i++) + { + _segmentEndAudioTime = loopStartAudioTime + segmentDuration * (i + 1); + _segmentEndGameTime = loopStartGameTime + (float)(segmentDuration * (i + 1)); + + // Wait until the next segment + while (Time.time < _segmentEndGameTime && !_transitionToFinale) + { + yield return null; + } + + // Interrupt the remaining segments for the finale + if (_transitionToFinale) break; + } + } + + // Wait until the bubble has finished expanding + while (Time.time < CosmicInflationController._finishFormationTime) + { + yield return null; + } + + // Disable audio signals + foreach (var loopSrc in _loopSources) + { + var signal = loopSrc.GetComponent(); + if (signal != null) + { + signal.SetSignalActivation(false, 0f); + } + } + } + } +} diff --git a/NewHorizons/Components/EyeOfTheUniverse/InstrumentZone.cs b/NewHorizons/Components/EyeOfTheUniverse/InstrumentZone.cs new file mode 100644 index 00000000..37d0948f --- /dev/null +++ b/NewHorizons/Components/EyeOfTheUniverse/InstrumentZone.cs @@ -0,0 +1,12 @@ +using UnityEngine; + +namespace NewHorizons.Components.EyeOfTheUniverse +{ + /// + /// Class does nothing but is used with GetComponent to find instrument zones in the hierarchy + /// + public class InstrumentZone : MonoBehaviour + { + + } +} diff --git a/NewHorizons/Components/EyeOfTheUniverse/QuantumInstrumentTrigger.cs b/NewHorizons/Components/EyeOfTheUniverse/QuantumInstrumentTrigger.cs new file mode 100644 index 00000000..c927c36c --- /dev/null +++ b/NewHorizons/Components/EyeOfTheUniverse/QuantumInstrumentTrigger.cs @@ -0,0 +1,30 @@ +using UnityEngine; + +namespace NewHorizons.Components.EyeOfTheUniverse +{ + public class QuantumInstrumentTrigger : MonoBehaviour + { + public string gatherCondition; + + private QuantumInstrument _quantumInstrument; + + private void Awake() + { + _quantumInstrument = GetComponent(); + _quantumInstrument.OnFinishGather += OnFinishGather; + } + + private void OnDestroy() + { + _quantumInstrument.OnFinishGather -= OnFinishGather; + } + + private void OnFinishGather() + { + if (!string.IsNullOrEmpty(gatherCondition)) + { + DialogueConditionManager.SharedInstance.SetConditionState(gatherCondition, true); + } + } + } +} diff --git a/NewHorizons/Components/NHGameOverManager.cs b/NewHorizons/Components/NHGameOverManager.cs new file mode 100644 index 00000000..93a7339a --- /dev/null +++ b/NewHorizons/Components/NHGameOverManager.cs @@ -0,0 +1,138 @@ +using NewHorizons.External.Modules; +using NewHorizons.External.SerializableEnums; +using NewHorizons.Handlers; +using NewHorizons.Utility.OWML; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace NewHorizons.Components +{ + public class NHGameOverManager : MonoBehaviour + { + /// + /// Mod unique id to game over module list + /// Done as a dictionary so that Reload Configs can overwrite entries per mod + /// + public static Dictionary gameOvers = new(); + + public static NHGameOverManager Instance { get; private set; } + + private GameOverController _gameOverController; + private PlayerCameraEffectController _playerCameraEffectController; + + private GameOverModule[] _gameOvers; + + private bool _gameOverSequenceStarted; + + public void Awake() + { + Instance = this; + } + + public void Start() + { + _gameOverController = FindObjectOfType(); + _playerCameraEffectController = FindObjectOfType(); + + _gameOvers = gameOvers.SelectMany(x => x.Value).ToArray(); + } + + public void TryHijackDeathSequence() + { + var gameOver = _gameOvers.FirstOrDefault(x => !string.IsNullOrEmpty(x.condition) && DialogueConditionManager.SharedInstance.GetConditionState(x.condition)); + if (!_gameOverSequenceStarted && gameOver != null && !Locator.GetDeathManager()._finishedDLC) + { + StartGameOverSequence(gameOver, null); + } + } + + public void StartGameOverSequence(GameOverModule gameOver, DeathType? deathType) + { + _gameOverSequenceStarted = true; + Delay.StartCoroutine(GameOver(gameOver, deathType)); + } + + private IEnumerator GameOver(GameOverModule gameOver, DeathType? deathType) + { + OWInput.ChangeInputMode(InputMode.None); + ReticleController.Hide(); + Locator.GetPromptManager().SetPromptsVisible(false); + Locator.GetPauseCommandListener().AddPauseCommandLock(); + + // The PlayerCameraEffectController is what actually kills us, so convince it we're already dead + Locator.GetDeathManager()._isDead = true; + + var fadeLength = 2f; + + if (Locator.GetDeathManager()._isDying) + { + // Player already died at this point, so don't fade + fadeLength = 0f; + } + else if (deathType is DeathType nonNullDeathType) + { + _playerCameraEffectController.OnPlayerDeath(nonNullDeathType); + fadeLength = _playerCameraEffectController._deathFadeLength; + } + else + { + // Wake up relaxed next loop + PlayerData.SetLastDeathType(DeathType.Meditation); + FadeHandler.FadeOut(fadeLength); + } + + yield return new WaitForSeconds(fadeLength); + + if (!string.IsNullOrEmpty(gameOver.text) && _gameOverController != null) + { + _gameOverController._deathText.text = TranslationHandler.GetTranslation(gameOver.text, TranslationHandler.TextType.UI); + _gameOverController.SetupGameOverScreen(5f); + + if (gameOver.colour != null) + { + _gameOverController._deathText.color = gameOver.colour.ToColor(); + } + + // Make sure the fade handler is off now + FadeHandler.FadeIn(0f); + + // We set this to true to stop it from loading the credits scene, so we can do it ourselves + _gameOverController._loading = true; + + yield return new WaitUntil(ReadytoLoadCreditsScene); + } + + LoadCreditsScene(gameOver); + } + + private bool ReadytoLoadCreditsScene() => _gameOverController._fadedOutText && _gameOverController._textAnimator.IsComplete(); + + private void LoadCreditsScene(GameOverModule gameOver) + { + NHLogger.LogVerbose($"Load credits {gameOver.creditsType}"); + + switch (gameOver.creditsType) + { + case NHCreditsType.Fast: + LoadManager.LoadScene(OWScene.Credits_Fast, LoadManager.FadeType.ToBlack); + break; + case NHCreditsType.Final: + LoadManager.LoadScene(OWScene.Credits_Final, LoadManager.FadeType.ToBlack); + break; + case NHCreditsType.Kazoo: + TimelineObliterationController.s_hasRealityEnded = true; + LoadManager.LoadScene(OWScene.Credits_Fast, LoadManager.FadeType.ToBlack); + break; + default: + // GameOverController disables post processing + _gameOverController._flashbackCamera.postProcessing.enabled = true; + // For some reason this isn't getting set sometimes + AudioListener.volume = 1; + GlobalMessenger.FireEvent("TriggerFlashback"); + break; + } + } + } +} diff --git a/NewHorizons/Components/Volumes/BlackHoleWarpVolume.cs b/NewHorizons/Components/Volumes/BlackHoleWarpVolume.cs index 941bff2b..ba31f97e 100644 --- a/NewHorizons/Components/Volumes/BlackHoleWarpVolume.cs +++ b/NewHorizons/Components/Volumes/BlackHoleWarpVolume.cs @@ -21,6 +21,7 @@ namespace NewHorizons.Components.Volumes public override void VanishPlayer(OWRigidbody playerBody, RelativeLocationData entryLocation) { Locator.GetPlayerAudioController().PlayOneShotInternal(AudioType.BH_BlackHoleEmission); + FadeHandler.FadeOut(0.2f, false); Main.Instance.ChangeCurrentStarSystem(TargetSolarSystem, PlayerState.AtFlightConsole()); PlayerSpawnHandler.TargetSpawnID = TargetSpawnID; } diff --git a/NewHorizons/Components/Volumes/LoadCreditsVolume.cs b/NewHorizons/Components/Volumes/LoadCreditsVolume.cs index 18edcbd1..26f75831 100644 --- a/NewHorizons/Components/Volumes/LoadCreditsVolume.cs +++ b/NewHorizons/Components/Volumes/LoadCreditsVolume.cs @@ -1,8 +1,4 @@ -using NewHorizons.External.SerializableEnums; -using NewHorizons.Handlers; -using NewHorizons.Utility; -using NewHorizons.Utility.OWML; -using System.Collections; +using NewHorizons.External.Modules; using UnityEngine; @@ -10,78 +6,17 @@ namespace NewHorizons.Components.Volumes { internal class LoadCreditsVolume : BaseVolume { - public NHCreditsType creditsType = NHCreditsType.Fast; - - public string gameOverText; - public DeathType deathType = DeathType.Default; - - private GameOverController _gameOverController; - private PlayerCameraEffectController _playerCameraEffectController; - - public void Start() - { - _gameOverController = FindObjectOfType(); - _playerCameraEffectController = FindObjectOfType(); - } + public GameOverModule gameOver; + public DeathType? deathType; public override void OnTriggerVolumeEntry(GameObject hitObj) { - if (hitObj.CompareTag("PlayerDetector") && enabled) + if (hitObj.CompareTag("PlayerDetector") && enabled && (string.IsNullOrEmpty(gameOver.condition) || DialogueConditionManager.SharedInstance.GetConditionState(gameOver.condition))) { - // Have to run it off the mod behaviour since the game over controller disables everything - Delay.StartCoroutine(GameOver()); + NHGameOverManager.Instance.StartGameOverSequence(gameOver, deathType); } } - private IEnumerator GameOver() - { - OWInput.ChangeInputMode(InputMode.None); - ReticleController.Hide(); - Locator.GetPromptManager().SetPromptsVisible(false); - Locator.GetPauseCommandListener().AddPauseCommandLock(); - - // The PlayerCameraEffectController is what actually kills us, so convince it we're already dead - Locator.GetDeathManager()._isDead = true; - - _playerCameraEffectController.OnPlayerDeath(deathType); - - yield return new WaitForSeconds(_playerCameraEffectController._deathFadeLength); - - if (!string.IsNullOrEmpty(gameOverText) && _gameOverController != null) - { - _gameOverController._deathText.text = TranslationHandler.GetTranslation(gameOverText, TranslationHandler.TextType.UI); - _gameOverController.SetupGameOverScreen(5f); - - // We set this to true to stop it from loading the credits scene, so we can do it ourselves - _gameOverController._loading = true; - - yield return new WaitUntil(ReadytoLoadCreditsScene); - } - - LoadCreditsScene(); - } - - private bool ReadytoLoadCreditsScene() => _gameOverController._fadedOutText && _gameOverController._textAnimator.IsComplete(); - public override void OnTriggerVolumeExit(GameObject hitObj) { } - - private void LoadCreditsScene() - { - NHLogger.LogVerbose($"Load credits {creditsType}"); - - switch (creditsType) - { - case NHCreditsType.Fast: - LoadManager.LoadScene(OWScene.Credits_Fast, LoadManager.FadeType.ToBlack); - break; - case NHCreditsType.Final: - LoadManager.LoadScene(OWScene.Credits_Final, LoadManager.FadeType.ToBlack); - break; - case NHCreditsType.Kazoo: - TimelineObliterationController.s_hasRealityEnded = true; - LoadManager.LoadScene(OWScene.Credits_Fast, LoadManager.FadeType.ToBlack); - break; - } - } } } diff --git a/NewHorizons/External/Configs/AddonConfig.cs b/NewHorizons/External/Configs/AddonConfig.cs index 66674575..8254b852 100644 --- a/NewHorizons/External/Configs/AddonConfig.cs +++ b/NewHorizons/External/Configs/AddonConfig.cs @@ -1,3 +1,4 @@ +using NewHorizons.External.Modules; using NewHorizons.OtherMods.AchievementsPlus; using Newtonsoft.Json; @@ -44,5 +45,11 @@ namespace NewHorizons.External.Configs /// The dimensions of the Echoes of the Eye subtitle is 669 x 67, so aim for that size /// public string subtitlePath = "subtitle.png"; + + /// + /// Custom game over messages for this mod. This can either display a title card before looping like in EOTE, or show a message and roll credits like the various time loop escape endings. + /// You must set a dialogue condition for the game over sequence to run. + /// + public GameOverModule[] gameOver; } } diff --git a/NewHorizons/External/Configs/PlanetConfig.cs b/NewHorizons/External/Configs/PlanetConfig.cs index c5ef092c..40744878 100644 --- a/NewHorizons/External/Configs/PlanetConfig.cs +++ b/NewHorizons/External/Configs/PlanetConfig.cs @@ -102,6 +102,11 @@ namespace NewHorizons.External.Configs /// public DreamModule Dream; + /// + /// Add features exclusive to the Eye of the Universe scene + /// + public EyeOfTheUniverseModule EyeOfTheUniverse; + /// /// Make this body into a focal point (barycenter) /// @@ -653,6 +658,25 @@ namespace NewHorizons.External.Configs } } + if (Volumes?.creditsVolume != null) + { + foreach (var volume in Volumes.creditsVolume) + { + if (!string.IsNullOrEmpty(volume.gameOverText)) + { + if (volume.gameOver == null) + { + volume.gameOver = new(); + } + volume.gameOver.text = volume.gameOverText; + } + if (volume.creditsType != null) + { + volume.gameOver.creditsType = (SerializableEnums.NHCreditsType)volume.creditsType; + } + } + } + if (Base.invulnerableToSun) { Base.hasFluidDetector = false; diff --git a/NewHorizons/External/Modules/EyeOfTheUniverseModule.cs b/NewHorizons/External/Modules/EyeOfTheUniverseModule.cs new file mode 100644 index 00000000..58e878c1 --- /dev/null +++ b/NewHorizons/External/Modules/EyeOfTheUniverseModule.cs @@ -0,0 +1,24 @@ +using NewHorizons.External.Modules.Props.EyeOfTheUniverse; +using Newtonsoft.Json; + +namespace NewHorizons.External.Modules +{ + [JsonObject] + public class EyeOfTheUniverseModule + { + /// + /// Add custom travelers to the campfire sequence + /// + public EyeTravelerInfo[] eyeTravelers; + + /// + /// Add instrument zones which contain puzzles to gather a quantum instrument. You can parent other props to these with `parentPath` + /// + public InstrumentZoneInfo[] instrumentZones; + + /// + /// Add quantum instruments which cause their associated eye traveler to appear and instrument zones to disappear + /// + public QuantumInstrumentInfo[] quantumInstruments; + } +} diff --git a/NewHorizons/External/Modules/GameOverModule.cs b/NewHorizons/External/Modules/GameOverModule.cs new file mode 100644 index 00000000..00d029bd --- /dev/null +++ b/NewHorizons/External/Modules/GameOverModule.cs @@ -0,0 +1,32 @@ +using NewHorizons.External.SerializableData; +using NewHorizons.External.SerializableEnums; +using Newtonsoft.Json; +using System.ComponentModel; + +namespace NewHorizons.External.Modules +{ + [JsonObject] + public class GameOverModule + { + /// + /// Text displayed in orange on game over. For localization, put translations under UI. + /// + public string text; + + /// + /// Change the colour of the game over text. Leave empty to use the default orange. + /// + public MColor colour; + + /// + /// Condition that must be true for this game over to trigger. If this is on a LoadCreditsVolume, leave empty to always trigger this game over. + /// Note this is a regular dialogue condition, not a persistent condition. + /// + public string condition; + + /// + /// The type of credits that will run after the game over message is shown + /// + [DefaultValue("fast")] public NHCreditsType creditsType = NHCreditsType.Fast; + } +} diff --git a/NewHorizons/External/Modules/Props/EchoesOfTheEye/ProjectionInfo.cs b/NewHorizons/External/Modules/Props/EchoesOfTheEye/ProjectionInfo.cs index c61ef06b..e059c310 100644 --- a/NewHorizons/External/Modules/Props/EchoesOfTheEye/ProjectionInfo.cs +++ b/NewHorizons/External/Modules/Props/EchoesOfTheEye/ProjectionInfo.cs @@ -85,6 +85,12 @@ namespace NewHorizons.External.Modules.Props.EchoesOfTheEye /// Exclusive to the slide reel type. Condition/material of the reel. Antique is the Stranger, Pristine is the Dreamworld, Rusted is a burned reel. /// [DefaultValue("antique")] public SlideReelCondition reelCondition = SlideReelCondition.Antique; - } + /// + /// Set which slides appear on the slide reel model. Leave empty to default to the first few slides. + /// Takes a list of indices, i.e., to show the first 5 slides in reverse you would put [4, 3, 2, 1, 0]. + /// Index starts at 0. + /// + public int[] displaySlides; + } } diff --git a/NewHorizons/External/Modules/Props/EyeOfTheUniverse/EyeTravelerInfo.cs b/NewHorizons/External/Modules/Props/EyeOfTheUniverse/EyeTravelerInfo.cs new file mode 100644 index 00000000..82f5abe8 --- /dev/null +++ b/NewHorizons/External/Modules/Props/EyeOfTheUniverse/EyeTravelerInfo.cs @@ -0,0 +1,50 @@ +using NewHorizons.External.Modules.Props.Audio; +using NewHorizons.External.Modules.Props.Dialogue; +using Newtonsoft.Json; + +namespace NewHorizons.External.Modules.Props.EyeOfTheUniverse +{ + [JsonObject] + public class EyeTravelerInfo : DetailInfo + { + /// + /// A unique ID to associate this traveler with their corresponding quantum instruments and instrument zones. Must be unique for each traveler. + /// + public string id; + + /// + /// If set, the player must know this ship log fact for this traveler (and their instrument zones and quantum instruments) to appear. The fact does not need to exist in the current star system; the player's save data will be checked directly. + /// + public string requiredFact; + + /// + /// If set, the player must have this persistent dialogue condition set for this traveler (and their instrument zones and quantum instruments) to appear. + /// + public string requiredPersistentCondition; + + /// + /// The dialogue condition that will trigger the traveler to start playing their instrument. Must be unique for each traveler. + /// + public string startPlayingCondition; + + /// + /// If specified, this dialogue condition must be set for the traveler to participate in the campfire song. Otherwise, the song will be able to start without them. + /// + public string participatingCondition; + + /// + /// The audio signal to use for the traveler while playing around the campfire (and also for their paired quantum instrument if another is not specified). The audio clip should be 16 measures at 92 BPM (approximately 42 seconds long). + /// + public SignalInfo signal; + + /// + /// The audio to use for the traveler during the finale of the campfire song. It should be 8 measures of the main loop at 92 BPM followed by 2 measures of fade-out (approximately 26 seconds long in total). Can be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list. + /// + public string finaleAudio; + + /// + /// The dialogue to use for this traveler. If omitted, the first CharacterDialogueTree in the object will be used. + /// + public DialogueInfo dialogue; + } +} diff --git a/NewHorizons/External/Modules/Props/EyeOfTheUniverse/InstrumentZoneInfo.cs b/NewHorizons/External/Modules/Props/EyeOfTheUniverse/InstrumentZoneInfo.cs new file mode 100644 index 00000000..047babb9 --- /dev/null +++ b/NewHorizons/External/Modules/Props/EyeOfTheUniverse/InstrumentZoneInfo.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace NewHorizons.External.Modules.Props.EyeOfTheUniverse +{ + [JsonObject] + public class InstrumentZoneInfo : DetailInfo + { + /// + /// The unique ID of the Eye Traveler associated with this instrument zone. + /// + public string id; + } +} diff --git a/NewHorizons/External/Modules/Props/EyeOfTheUniverse/QuantumInstrumentInfo.cs b/NewHorizons/External/Modules/Props/EyeOfTheUniverse/QuantumInstrumentInfo.cs new file mode 100644 index 00000000..1a686b9c --- /dev/null +++ b/NewHorizons/External/Modules/Props/EyeOfTheUniverse/QuantumInstrumentInfo.cs @@ -0,0 +1,40 @@ +using NewHorizons.External.Modules.Props.Audio; +using Newtonsoft.Json; +using System.ComponentModel; + +namespace NewHorizons.External.Modules.Props.EyeOfTheUniverse +{ + [JsonObject] + public class QuantumInstrumentInfo : DetailInfo + { + /// + /// The unique ID of the Eye Traveler associated with this quantum instrument. + /// + public string id; + + /// + /// A dialogue condition to set when gathering this quantum instrument. Use it in conjunction with `activationCondition` or `deactivationCondition` on other details. + /// + public string gatherCondition; + + /// + /// Allows gathering this quantum instrument using the zoomed-in signalscope, like Chert's bongos. + /// + public bool gatherWithScope; + + /// + /// The audio signal emitted by this quantum instrument. The fields `name`, `audio`, and `frequency` will be copied from the corresponding Eye Traveler's signal if not specified here. + /// + public SignalInfo signal; + + /// + /// The radius of the added sphere collider that will be used for interaction. + /// + [DefaultValue(0.5f)] public float interactRadius = 0.5f; + + /// + /// The furthest distance where the player can interact with this quantum instrument. + /// + [DefaultValue(2f)] public float interactRange = 2f; + } +} diff --git a/NewHorizons/External/Modules/Volumes/VolumeInfos/LoadCreditsVolumeInfo.cs b/NewHorizons/External/Modules/Volumes/VolumeInfos/LoadCreditsVolumeInfo.cs index 60be21d5..fcf9b0da 100644 --- a/NewHorizons/External/Modules/Volumes/VolumeInfos/LoadCreditsVolumeInfo.cs +++ b/NewHorizons/External/Modules/Volumes/VolumeInfos/LoadCreditsVolumeInfo.cs @@ -1,5 +1,6 @@ using NewHorizons.External.SerializableEnums; using Newtonsoft.Json; +using System; using System.ComponentModel; namespace NewHorizons.External.Modules.Volumes.VolumeInfos @@ -7,16 +8,20 @@ namespace NewHorizons.External.Modules.Volumes.VolumeInfos [JsonObject] public class LoadCreditsVolumeInfo : VolumeInfo { - [DefaultValue("fast")] public NHCreditsType creditsType = NHCreditsType.Fast; + [Obsolete("Use gameOver.creditsType")] + public NHCreditsType? creditsType; - /// - /// Text displayed in orange on game over. For localization, put translations under UI. - /// + [Obsolete("Use gameOver.text")] public string gameOverText; /// - /// The type of death the player will have if they enter this volume. + /// The type of death the player will have if they enter this volume. Don't set to have the camera just fade out. /// - [DefaultValue("default")] public NHDeathType deathType = NHDeathType.Default; + [DefaultValue("default")] public NHDeathType? deathType = null; + + /// + /// The game over message to display. Leave empty to go straight to credits. + /// + public GameOverModule gameOver; } } diff --git a/NewHorizons/External/SerializableEnums/NHCreditsType.cs b/NewHorizons/External/SerializableEnums/NHCreditsType.cs index fe4d08be..83c1dc51 100644 --- a/NewHorizons/External/SerializableEnums/NHCreditsType.cs +++ b/NewHorizons/External/SerializableEnums/NHCreditsType.cs @@ -11,6 +11,8 @@ namespace NewHorizons.External.SerializableEnums [EnumMember(Value = @"final")] Final = 1, - [EnumMember(Value = @"kazoo")] Kazoo = 2 + [EnumMember(Value = @"kazoo")] Kazoo = 2, + + [EnumMember(Value = @"none")] None = 3 } } diff --git a/NewHorizons/Handlers/EyeDetailCacher.cs b/NewHorizons/Handlers/EyeDetailCacher.cs index 62dfb460..f4ff2ed0 100644 --- a/NewHorizons/Handlers/EyeDetailCacher.cs +++ b/NewHorizons/Handlers/EyeDetailCacher.cs @@ -19,6 +19,36 @@ public static class EyeDetailCacher foreach (var body in Main.BodyDict["EyeOfTheUniverse"]) { NHLogger.LogVerbose($"{nameof(EyeDetailCacher)}: {body.Config.name}"); + if (body.Config?.EyeOfTheUniverse?.eyeTravelers != null) + { + foreach (var detail in body.Config.EyeOfTheUniverse.eyeTravelers) + { + if (!string.IsNullOrEmpty(detail.assetBundle)) continue; + + AddPathToCache(detail.path); + } + } + + if (body.Config?.EyeOfTheUniverse?.instrumentZones != null) + { + foreach (var detail in body.Config.EyeOfTheUniverse.instrumentZones) + { + if (!string.IsNullOrEmpty(detail.assetBundle)) continue; + + AddPathToCache(detail.path); + } + } + + if (body.Config?.EyeOfTheUniverse?.quantumInstruments != null) + { + foreach (var detail in body.Config.EyeOfTheUniverse.quantumInstruments) + { + if (!string.IsNullOrEmpty(detail.assetBundle)) continue; + + AddPathToCache(detail.path); + } + } + if (body.Config?.Props?.details != null) { foreach (var detail in body.Config.Props.details) diff --git a/NewHorizons/Handlers/EyeSceneHandler.cs b/NewHorizons/Handlers/EyeSceneHandler.cs index a0c925a9..1433e9ee 100644 --- a/NewHorizons/Handlers/EyeSceneHandler.cs +++ b/NewHorizons/Handlers/EyeSceneHandler.cs @@ -1,14 +1,66 @@ using NewHorizons.Builder.General; using NewHorizons.Components.EyeOfTheUniverse; using NewHorizons.Components.Stars; +using NewHorizons.External.Modules.Props.EyeOfTheUniverse; using NewHorizons.External.SerializableData; using NewHorizons.Utility; +using NewHorizons.Utility.OWML; +using System.Collections.Generic; +using System.Linq; using UnityEngine; namespace NewHorizons.Handlers { public static class EyeSceneHandler { + private static Dictionary _eyeTravelers = new(); + private static EyeMusicController _eyeMusicController; + + public static void Init() + { + _eyeTravelers.Clear(); + _eyeMusicController = null; + } + + public static EyeMusicController GetMusicController() + { + return _eyeMusicController; + } + + public static EyeTravelerData GetEyeTravelerData(string id) + { + if (_eyeTravelers.TryGetValue(id, out EyeTravelerData traveler)) + { + return traveler; + } + return traveler; + } + + public static EyeTravelerData CreateEyeTravelerData(string id) + { + if (_eyeTravelers.TryGetValue(id, out EyeTravelerData traveler)) + { + return traveler; + } + traveler = new EyeTravelerData() + { + id = id, + info = null, + controller = null, + loopAudioSource = null, + finaleAudioSource = null, + instrumentZones = new(), + quantumInstruments = new(), + }; + _eyeTravelers[traveler.id] = traveler; + return traveler; + } + + public static List GetActiveCustomEyeTravelers() + { + return _eyeTravelers.Values.Where(t => t.requirementsMet).ToList(); + } + public static void OnSceneLoad() { // Create astro objects for eye and vessel because they didn't have them somehow. @@ -134,5 +186,142 @@ namespace NewHorizons.Handlers SunLightEffectsController.AddStar(starController); SunLightEffectsController.AddStarLight(sunLight); } + + public static void SetUpEyeCampfireSequence() + { + if (!GetActiveCustomEyeTravelers().Any()) return; + + _eyeMusicController = new GameObject("EyeMusicController").AddComponent(); + + var quantumCampsiteController = Object.FindObjectOfType(); + var cosmicInflationController = _eyeMusicController.CosmicInflationController; + + _eyeMusicController.RegisterFinaleSource(cosmicInflationController._travelerFinaleSource); + + foreach (var controller in quantumCampsiteController._travelerControllers) + { + _eyeMusicController.RegisterLoopSource(controller._signal.GetOWAudioSource()); + } + + foreach (var eyeTraveler in GetActiveCustomEyeTravelers()) + { + if (eyeTraveler.controller != null) + { + eyeTraveler.controller.gameObject.SetActive(false); + + ArrayHelpers.Append(ref quantumCampsiteController._travelerControllers, eyeTraveler.controller); + eyeTraveler.controller.OnStartPlaying += quantumCampsiteController.OnTravelerStartPlaying; + + ArrayHelpers.Append(ref cosmicInflationController._travelers, eyeTraveler.controller); + eyeTraveler.controller.OnStartPlaying += cosmicInflationController.OnTravelerStartPlaying; + + ArrayHelpers.Append(ref cosmicInflationController._inflationObjects, eyeTraveler.controller.transform); + } + else + { + NHLogger.LogError($"Missing Eye Traveler for ID \"{eyeTraveler.id}\""); + } + + if (eyeTraveler.loopAudioSource != null) + { + eyeTraveler.loopAudioSource.GetComponent().SetSignalActivation(false); + _eyeMusicController.RegisterLoopSource(eyeTraveler.loopAudioSource); + } + if (eyeTraveler.finaleAudioSource != null) + { + _eyeMusicController.RegisterFinaleSource(eyeTraveler.finaleAudioSource); + } + + foreach (var quantumInstrument in eyeTraveler.quantumInstruments) + { + ArrayHelpers.Append(ref quantumInstrument._activateObjects, eyeTraveler.controller.gameObject); + ArrayHelpers.Append(ref quantumInstrument._deactivateObjects, eyeTraveler.instrumentZones.Select(z => z.gameObject)); + + var ancestorInstrumentZone = quantumInstrument.GetComponentInParent(); + if (ancestorInstrumentZone == null) + { + // Quantum instrument is not a child of an instrument zone, so treat it like its own zone + quantumInstrument.gameObject.SetActive(false); + ArrayHelpers.Append(ref quantumCampsiteController._instrumentZones, quantumInstrument.gameObject); + + ArrayHelpers.Append(ref cosmicInflationController._inflationObjects, quantumInstrument.transform); + } + } + + foreach (var instrumentZone in eyeTraveler.instrumentZones) + { + instrumentZone.gameObject.SetActive(false); + ArrayHelpers.Append(ref quantumCampsiteController._instrumentZones, instrumentZone.gameObject); + + ArrayHelpers.Append(ref cosmicInflationController._inflationObjects, instrumentZone.transform); + } + } + + UpdateTravelerPositions(); + } + + public static void UpdateTravelerPositions() + { + if (!GetActiveCustomEyeTravelers().Any()) return; + + var quantumCampsiteController = Object.FindObjectOfType(); + + var travelers = new List() + { + quantumCampsiteController._travelerControllers[0].transform, // Riebeck + quantumCampsiteController._travelerControllers[2].transform, // Chert + quantumCampsiteController._travelerControllers[6].transform, // Esker + quantumCampsiteController._travelerControllers[1].transform, // Felspar + quantumCampsiteController._travelerControllers[3].transform, // Gabbro + }; + + if (quantumCampsiteController._hasMetSolanum) + { + travelers.Add(quantumCampsiteController._travelerControllers[4].transform); // Solanum + } + if (quantumCampsiteController._hasMetPrisoner) + { + travelers.Add(quantumCampsiteController._travelerControllers[5].transform); // Prisoner + } + + // Custom travelers (starting at index 7) + for (int i = 7; i < quantumCampsiteController._travelerControllers.Length; i++) + { + travelers.Add(quantumCampsiteController._travelerControllers[i].transform); + } + + var radius = 2f + 0.2f * travelers.Count; + var angle = Mathf.PI * 2f / travelers.Count; + var index = 0; + + foreach (var traveler in travelers) + { + // Esker isn't at height 0 so we have to do all this + var initialY = traveler.transform.position.y; + var newPos = quantumCampsiteController.transform.TransformPoint(new Vector3( + Mathf.Cos(angle * index) * radius, + 0f, + -Mathf.Sin(angle * index) * radius + )); + newPos.y = initialY; + traveler.transform.position = newPos; + var lookTarget = quantumCampsiteController.transform.position; + lookTarget.y = newPos.y; + traveler.transform.LookAt(lookTarget, traveler.transform.up); + index++; + } + } + + public class EyeTravelerData + { + public string id; + public EyeTravelerInfo info; + public TravelerEyeController controller; + public OWAudioSource loopAudioSource; + public OWAudioSource finaleAudioSource; + public List quantumInstruments = new(); + public List instrumentZones = new(); + public bool requirementsMet; + } } } diff --git a/NewHorizons/Handlers/FadeHandler.cs b/NewHorizons/Handlers/FadeHandler.cs index f9d6d4ff..ab2dddc6 100644 --- a/NewHorizons/Handlers/FadeHandler.cs +++ b/NewHorizons/Handlers/FadeHandler.cs @@ -11,24 +11,62 @@ namespace NewHorizons.Handlers /// public static class FadeHandler { - public static void FadeOut(float length) => Delay.StartCoroutine(FadeOutCoroutine(length)); + public static void FadeOut(float length) => Delay.StartCoroutine(FadeOutCoroutine(length, true)); - private static IEnumerator FadeOutCoroutine(float length) + public static void FadeOut(float length, bool fadeSound) => Delay.StartCoroutine(FadeOutCoroutine(length, fadeSound)); + + public static void FadeIn(float length) => Delay.StartCoroutine(FadeInCoroutine(length)); + + private static IEnumerator FadeOutCoroutine(float length, bool fadeSound) + { + // Make sure its not already faded + if (!LoadManager.s_instance._fadeCanvas.enabled) + { + LoadManager.s_instance._fadeCanvas.enabled = true; + float startTime = Time.unscaledTime; + float endTime = Time.unscaledTime + length; + + while (Time.unscaledTime < endTime) + { + var t = Mathf.Clamp01((Time.unscaledTime - startTime) / length); + LoadManager.s_instance._fadeImage.color = Color.Lerp(Color.clear, Color.black, t); + if (fadeSound) + { + AudioListener.volume = 1f - t; + } + yield return new WaitForEndOfFrame(); + } + + LoadManager.s_instance._fadeImage.color = Color.black; + if (fadeSound) + { + AudioListener.volume = 0; + } + yield return new WaitForEndOfFrame(); + } + else + { + yield return new WaitForSeconds(length); + } + } + + private static IEnumerator FadeInCoroutine(float length) { - LoadManager.s_instance._fadeCanvas.enabled = true; float startTime = Time.unscaledTime; float endTime = Time.unscaledTime + length; while (Time.unscaledTime < endTime) { var t = Mathf.Clamp01((Time.unscaledTime - startTime) / length); - LoadManager.s_instance._fadeImage.color = Color.Lerp(Color.clear, Color.black, t); - AudioListener.volume = 1f - t; + LoadManager.s_instance._fadeImage.color = Color.Lerp(Color.black, Color.clear, t); + AudioListener.volume = t; yield return new WaitForEndOfFrame(); } - LoadManager.s_instance._fadeImage.color = Color.black; - AudioListener.volume = 0; + AudioListener.volume = 1; + LoadManager.s_instance._fadeCanvas.enabled = false; + LoadManager.s_instance._fadeImage.color = Color.clear; + yield return new WaitForEndOfFrame(); } @@ -36,7 +74,7 @@ namespace NewHorizons.Handlers private static IEnumerator FadeThenCoroutine(float length, Action action) { - yield return FadeOutCoroutine(length); + yield return FadeOutCoroutine(length, true); action?.Invoke(); } diff --git a/NewHorizons/Handlers/InvulnerabilityHandler.cs b/NewHorizons/Handlers/InvulnerabilityHandler.cs index 9aa0e793..cacc9e19 100644 --- a/NewHorizons/Handlers/InvulnerabilityHandler.cs +++ b/NewHorizons/Handlers/InvulnerabilityHandler.cs @@ -1,36 +1,44 @@ using NewHorizons.Utility.OWML; using UnityEngine; +using UnityEngine.SceneManagement; namespace NewHorizons.Handlers { internal class InvulnerabilityHandler { - private static float _defaultImpactDeathSpeed = -1f; + /// + /// Used in patches + /// + public static bool Invincible { get; private set; } public static void MakeInvulnerable(bool invulnerable) { NHLogger.Log($"Toggling immortality: {invulnerable}"); + Invincible = invulnerable; var deathManager = GetDeathManager(); var resources = GetPlayerResouces(); if (invulnerable) { - if (_defaultImpactDeathSpeed == -1f) - _defaultImpactDeathSpeed = deathManager._impactDeathSpeed; - - deathManager._impactDeathSpeed = Mathf.Infinity; deathManager._invincible = true; + resources._invincible = true; } else { - deathManager._impactDeathSpeed = _defaultImpactDeathSpeed; resources._currentHealth = 100f; deathManager._invincible = false; + resources._invincible = false; } } private static DeathManager GetDeathManager() => GameObject.FindObjectOfType(); private static PlayerResources GetPlayerResouces() => GameObject.FindObjectOfType(); + + static InvulnerabilityHandler() + { + // If the scene unloads when Invincible is on it might not get turned off + SceneManager.sceneUnloaded += (_) => Invincible = false; + } } } diff --git a/NewHorizons/Handlers/PlanetCreationHandler.cs b/NewHorizons/Handlers/PlanetCreationHandler.cs index a0fba39a..1af21023 100644 --- a/NewHorizons/Handlers/PlanetCreationHandler.cs +++ b/NewHorizons/Handlers/PlanetCreationHandler.cs @@ -518,7 +518,11 @@ namespace NewHorizons.Handlers if (body.Config.Orbit.showOrbitLine && !body.Config.Orbit.isStatic) { - OrbitlineBuilder.Make(body.Object, body.Config.Orbit.isMoon, body.Config); + // No map mode at eye + if (LoadManager.GetCurrentScene() != OWScene.EyeOfTheUniverse) + { + OrbitlineBuilder.Make(body.Object, body.Config.Orbit.isMoon, body.Config); + } } DetectorBuilder.Make(go, owRigidBody, primaryBody, ao, body.Config); @@ -695,6 +699,18 @@ namespace NewHorizons.Handlers atmosphere = AtmosphereBuilder.Make(go, sector, body.Config.Atmosphere, surfaceSize).GetComponentInChildren(); } + if (body.Config.EyeOfTheUniverse != null) + { + if (Main.Instance.CurrentStarSystem == "EyeOfTheUniverse") + { + EyeOfTheUniverseBuilder.Make(go, sector, body.Config.EyeOfTheUniverse, body); + } + else + { + NHLogger.LogWarning($"A mod creator (you?) has defined Eye of the Universe specific settings on a body [{body.Config.name}] that is not in the eye of the universe"); + } + } + if (body.Config.ParticleFields != null) { EffectsBuilder.Make(go, sector, body.Config); @@ -849,7 +865,11 @@ namespace NewHorizons.Handlers var isMoon = newAO.GetAstroObjectType() is AstroObject.Type.Moon or AstroObject.Type.Satellite or AstroObject.Type.SpaceStation; if (body.Config.Orbit.showOrbitLine) { - OrbitlineBuilder.Make(go, isMoon, body.Config); + // No map mode at eye + if (LoadManager.GetCurrentScene() != OWScene.EyeOfTheUniverse) + { + OrbitlineBuilder.Make(go, isMoon, body.Config); + } } DetectorBuilder.SetDetector(primary, newAO, go.GetComponentInChildren()); diff --git a/NewHorizons/Handlers/PlayerSpawnHandler.cs b/NewHorizons/Handlers/PlayerSpawnHandler.cs index 352b4eff..51d8e0af 100644 --- a/NewHorizons/Handlers/PlayerSpawnHandler.cs +++ b/NewHorizons/Handlers/PlayerSpawnHandler.cs @@ -70,6 +70,27 @@ namespace NewHorizons.Handlers // Spawn ship Delay.FireInNUpdates(SpawnShip, 30); + + if (UsingCustomSpawn() || shouldWarpInFromShip || shouldWarpInFromVessel) + { + // Have had bug reports (#1034, #975) where sometimes after spawning via vessel warp or ship warp you die from impact velocity after being flung + // Something weird must be happening with velocity. + // Try to correct it here after the ship is done spawning + Delay.FireInNUpdates(() => FixVelocity(shouldWarpInFromVessel, shouldWarpInFromShip), 31); + } + } + + private static void FixVelocity(bool shouldWarpInFromVessel, bool shouldWarpInFromShip) + { + var spawnOWRigidBody = GetDefaultSpawn().GetAttachedOWRigidbody(); + if (shouldWarpInFromVessel) spawnOWRigidBody = VesselWarpHandler.VesselSpawnPoint.GetAttachedOWRigidbody(); + if (shouldWarpInFromShip) spawnOWRigidBody = Locator.GetShipBody(); + + var spawnVelocity = spawnOWRigidBody.GetVelocity(); + var spawnAngularVelocity = spawnOWRigidBody.GetPointTangentialVelocity(Locator.GetPlayerBody().GetPosition()); + var velocity = spawnVelocity + spawnAngularVelocity; + + Locator.GetPlayerBody().SetVelocity(velocity); } public static void SpawnShip() diff --git a/NewHorizons/Handlers/VesselWarpHandler.cs b/NewHorizons/Handlers/VesselWarpHandler.cs index 5cf611e5..c5e622eb 100644 --- a/NewHorizons/Handlers/VesselWarpHandler.cs +++ b/NewHorizons/Handlers/VesselWarpHandler.cs @@ -6,6 +6,7 @@ using NewHorizons.Utility; using NewHorizons.Utility.OuterWilds; using NewHorizons.Utility.OWML; using UnityEngine; +using System.Collections; using static NewHorizons.Main; using static NewHorizons.Utility.Files.AssetBundleUtilities; @@ -56,7 +57,9 @@ namespace NewHorizons.Handlers } if (IsVesselPresentAndActive()) + { _vesselSpawnPoint = Instance.CurrentStarSystem == "SolarSystem" ? UpdateVessel() : CreateVessel(); + } else { var vesselDimension = SearchUtilities.Find("DB_VesselDimension_Body/Sector_VesselDimension"); @@ -84,7 +87,12 @@ namespace NewHorizons.Handlers if (_vesselSpawnPoint is VesselSpawnPoint vesselSpawnPoint) { NHLogger.LogVerbose("Relative warping into vessel"); - vesselSpawnPoint.WarpPlayer();//Delay.FireOnNextUpdate(vesselSpawnPoint.WarpPlayer); + vesselSpawnPoint.WarpPlayer(); + + // #1034 Vessel warp sometimes has the player get flung away into space and die + // We do what we do with regular spawns where we keep resetting their position to the right one while invincible until we're relatively certain + // that the spawning sequence is done + Delay.StartCoroutine(FixPlayerSpawning(25, vesselSpawnPoint)); } else { @@ -96,6 +104,24 @@ namespace NewHorizons.Handlers LoadDB(); } + private static IEnumerator FixPlayerSpawning(int frameInterval, VesselSpawnPoint vesselSpawn) + { + InvulnerabilityHandler.MakeInvulnerable(true); + + var frameCount = 0; + while (frameCount <= frameInterval) + { + vesselSpawn.WarpPlayer(); + frameCount++; + yield return null; // Wait for the next frame + } + + InvulnerabilityHandler.MakeInvulnerable(false); + var playerBody = SearchUtilities.Find("Player_Body").GetAttachedOWRigidbody(); + var resources = playerBody.GetComponent(); + resources._currentHealth = 100f; + } + public static void LoadDB() { if (Instance.CurrentStarSystem == "SolarSystem") diff --git a/NewHorizons/Main.cs b/NewHorizons/Main.cs index c964744f..56d8d645 100644 --- a/NewHorizons/Main.cs +++ b/NewHorizons/Main.cs @@ -6,6 +6,7 @@ using NewHorizons.Builder.Props; using NewHorizons.Builder.Props.Audio; using NewHorizons.Builder.Props.EchoesOfTheEye; using NewHorizons.Builder.Props.TranslatorText; +using NewHorizons.Components; using NewHorizons.Components.EOTE; using NewHorizons.Components.Fixers; using NewHorizons.Components.Ship; @@ -436,6 +437,7 @@ namespace NewHorizons if (isEyeOfTheUniverse) { _playerAwake = true; + EyeSceneHandler.Init(); EyeSceneHandler.OnSceneLoad(); } @@ -482,6 +484,8 @@ namespace NewHorizons // Fix spawn point PlayerSpawnHandler.SetUpPlayerSpawn(); + new GameObject(nameof(NHGameOverManager)).AddComponent(); + if (isSolarSystem) { // Warp drive @@ -535,6 +539,8 @@ namespace NewHorizons IsWarpingFromVessel = false; DidWarpFromVessel = false; DidWarpFromShip = false; + + EyeSceneHandler.SetUpEyeCampfireSequence(); } //Stop starfield from disappearing when there is no lights @@ -598,8 +604,8 @@ namespace NewHorizons { IsSystemReady = true; - // ShipWarpController will handle the invulnerability otherwise - if (!shouldWarpInFromShip) + // ShipWarpController or VesselWarpHandler will handle the invulnerability otherwise + if (!shouldWarpInFromShip && !shouldWarpInFromVessel) { Delay.FireOnNextUpdate(() => InvulnerabilityHandler.MakeInvulnerable(false)); } @@ -832,6 +838,10 @@ namespace NewHorizons AssetBundleUtilities.PreloadBundle(bundle, mod); } } + if (addonConfig.gameOver != null) + { + NHGameOverManager.gameOvers[mod.ModHelper.Manifest.UniqueName] = addonConfig.gameOver; + } AddonConfigs[mod] = addonConfig; } diff --git a/NewHorizons/Patches/DeathManagerPatches.cs b/NewHorizons/Patches/DeathManagerPatches.cs new file mode 100644 index 00000000..40f7b14b --- /dev/null +++ b/NewHorizons/Patches/DeathManagerPatches.cs @@ -0,0 +1,15 @@ +using HarmonyLib; +using NewHorizons.Components; + +namespace NewHorizons.Patches; + +[HarmonyPatch] +public static class DeathManagerPatches +{ + [HarmonyPrefix] + [HarmonyPatch(typeof(DeathManager), nameof(DeathManager.FinishDeathSequence))] + public static void DeathManager_FinishDeathSequence() + { + NHGameOverManager.Instance.TryHijackDeathSequence(); + } +} diff --git a/NewHorizons/Patches/EchoesOfTheEyePatches/RaftControllerPatches.cs b/NewHorizons/Patches/EchoesOfTheEyePatches/RaftControllerPatches.cs index e615c201..48a08f08 100644 --- a/NewHorizons/Patches/EchoesOfTheEyePatches/RaftControllerPatches.cs +++ b/NewHorizons/Patches/EchoesOfTheEyePatches/RaftControllerPatches.cs @@ -1,4 +1,5 @@ using HarmonyLib; +using NewHorizons.Components.Props; using UnityEngine; namespace NewHorizons.Patches.EchoesOfTheEyePatches @@ -73,5 +74,31 @@ namespace NewHorizons.Patches.EchoesOfTheEyePatches return false; } + + [HarmonyPostfix] + [HarmonyPatch(nameof(RaftController.UpdateMoveToTarget))] + public static void UpdateMoveToTarget(RaftController __instance) + { + // If it has a riverFluid volume then its a regular stranger one + if (__instance._movingToTarget && __instance._riverFluid == null) + { + OWRigidbody raftBody = __instance._raftBody; + OWRigidbody origParentBody = __instance._raftBody.GetOrigParentBody(); + Transform transform = origParentBody.transform; + Vector3 vector = transform.TransformPoint(__instance._targetLocalPosition); + + // Base game threshold has this at 1f (after doing smoothstep on it) + // For whatever reason it never hits that for NH planets (probably since they're moving so much compared to the steady velocity of the Stranger) + // Might break for somebody with a wacky spinning planet in which case we can adjust this or add some kind of fallback (i.e., wait x seconds and then just say its there) + // Fixes #1005 + if (__instance.currentDistanceLerp > 0.999f) + { + raftBody.SetPosition(vector); + raftBody.SetRotation(transform.rotation * __instance._targetLocalRotation); + __instance.StopMovingToTarget(); + __instance.OnArriveAtTarget.Invoke(); + } + } + } } } diff --git a/NewHorizons/Patches/EyeScenePatches/CosmicInflationControllerPatches.cs b/NewHorizons/Patches/EyeScenePatches/CosmicInflationControllerPatches.cs new file mode 100644 index 00000000..2363140c --- /dev/null +++ b/NewHorizons/Patches/EyeScenePatches/CosmicInflationControllerPatches.cs @@ -0,0 +1,24 @@ +using HarmonyLib; +using NewHorizons.Handlers; +using NewHorizons.Utility.OWML; +using System.Linq; + +namespace NewHorizons.Patches.EyeScenePatches +{ + [HarmonyPatch(typeof(CosmicInflationController))] + public static class CosmicInflationControllerPatches + { + [HarmonyPostfix] + [HarmonyPatch(nameof(CosmicInflationController.UpdateFormation))] + public static void CosmicInflationController_UpdateFormation(CosmicInflationController __instance) + { + if (__instance._waitForCrossfade && EyeSceneHandler.GetActiveCustomEyeTravelers().Any()) + { + NHLogger.Log($"Hijacking finale cross-fade, NH will handle it"); + __instance._waitForCrossfade = false; + __instance._waitForMusicEnd = true; + EyeSceneHandler.GetMusicController().TransitionToFinale(); + } + } + } +} diff --git a/NewHorizons/Patches/EyeScenePatches/QuantumCampsiteControllerPatches.cs b/NewHorizons/Patches/EyeScenePatches/QuantumCampsiteControllerPatches.cs new file mode 100644 index 00000000..542c310d --- /dev/null +++ b/NewHorizons/Patches/EyeScenePatches/QuantumCampsiteControllerPatches.cs @@ -0,0 +1,88 @@ +using HarmonyLib; +using NewHorizons.Handlers; +using NewHorizons.Utility.OWML; +using System.Linq; + +namespace NewHorizons.Patches.EyeScenePatches +{ + [HarmonyPatch(typeof(QuantumCampsiteController))] + public static class QuantumCampsiteControllerPatches + { + [HarmonyPostfix] + [HarmonyPatch(nameof(QuantumCampsiteController.Start))] + public static void QuantumCampsiteController_Start() + { + EyeSceneHandler.UpdateTravelerPositions(); + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(QuantumCampsiteController.ActivateRemainingInstrumentZones))] + public static void QuantumCampsiteController_ActivateRemainingInstrumentZones(QuantumCampsiteController __instance) + { + // We modify this array when registering a custom instrument zone but the vanilla method only activates the first 6 + for (int i = 6; i < __instance._instrumentZones.Length; i++) + { + __instance._instrumentZones[i].SetActive(true); + } + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(QuantumCampsiteController.AreAllTravelersGathered))] + public static bool QuantumCampsiteController_AreAllTravelersGathered(QuantumCampsiteController __instance, ref bool __result) + { + if (!EyeSceneHandler.GetActiveCustomEyeTravelers().Any()) + { + return true; + } + bool gatheredAllHearthianTravelers = __instance._travelerControllers.Take(4).All(t => t.gameObject.activeInHierarchy); + if (!gatheredAllHearthianTravelers) + { + __result = false; + return false; + } + bool needsSolanum = __instance._hasMetSolanum; + bool gatheredSolanum = __instance._travelerControllers[QuantumCampsiteController.SOLANUM_INDEX].gameObject.activeInHierarchy; + if (needsSolanum && !gatheredSolanum) + { + __result = false; + return false; + } + bool needsPrisoner = __instance._hasMetPrisoner && !__instance._hasErasedPrisoner; + bool gatheredPrisoner = __instance._travelerControllers[QuantumCampsiteController.PRISONER_INDEX].gameObject.activeInHierarchy; + if (needsPrisoner && !gatheredPrisoner) + { + __result = false; + return false; + } + foreach (var traveler in EyeSceneHandler.GetActiveCustomEyeTravelers()) + { + bool needsTraveler = true; + if (!string.IsNullOrEmpty(traveler.info.participatingCondition)) + { + needsTraveler = DialogueConditionManager.SharedInstance.GetConditionState(traveler.info.participatingCondition); + } + bool gatheredTraveler = traveler.controller.gameObject.activeInHierarchy; + if (needsTraveler && !gatheredTraveler) + { + __result = false; + return false; + } + } + __result = true; + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(QuantumCampsiteController.OnTravelerStartPlaying))] + public static void OnTravelerStartPlaying(QuantumCampsiteController __instance) + { + if (!__instance._hasJamSessionStarted && EyeSceneHandler.GetActiveCustomEyeTravelers().Any()) + { + NHLogger.Log($"NH is handling Eye sequence music"); + // Jam session is starting, start our custom music handler + EyeSceneHandler.GetMusicController().StartPlaying(); + } + // Letting the original method run in case mods have patched TravelerEyeController.OnStartCosmicJamSession() + } + } +} diff --git a/NewHorizons/Patches/EyeScenePatches/TravelerEyeControllerPatches.cs b/NewHorizons/Patches/EyeScenePatches/TravelerEyeControllerPatches.cs new file mode 100644 index 00000000..d31fca50 --- /dev/null +++ b/NewHorizons/Patches/EyeScenePatches/TravelerEyeControllerPatches.cs @@ -0,0 +1,26 @@ +using HarmonyLib; +using NewHorizons.Handlers; +using System.Linq; + +namespace NewHorizons.Patches.EyeScenePatches +{ + [HarmonyPatch(typeof(TravelerEyeController))] + public static class TravelerEyeControllerPatches + { + [HarmonyPrefix] + [HarmonyPatch(nameof(TravelerEyeController.OnStartCosmicJamSession))] + public static bool TravelerEyeController_OnStartCosmicJamSession(TravelerEyeController __instance) + { + if (!EyeSceneHandler.GetActiveCustomEyeTravelers().Any()) + { + return true; + } + // Not starting the loop audio here; EyeMusicController will handle that + if (__instance._signal != null) + { + __instance._signal.GetOWAudioSource().SetLocalVolume(0f); + } + return false; + } + } +} diff --git a/NewHorizons/Patches/PlayerImpactAudioPatches.cs b/NewHorizons/Patches/PlayerImpactAudioPatches.cs new file mode 100644 index 00000000..f0028e0f --- /dev/null +++ b/NewHorizons/Patches/PlayerImpactAudioPatches.cs @@ -0,0 +1,16 @@ +using HarmonyLib; +using NewHorizons.Handlers; + +namespace NewHorizons.Patches; + +[HarmonyPatch] +public static class PlayerImpactAudioPatches +{ + [HarmonyPrefix] + [HarmonyPatch(typeof(PlayerImpactAudio), nameof(PlayerImpactAudio.OnImpact))] + public static bool PlayerImpactAudio_OnImpact() + { + // DeathManager and PlayerResources _invincible stops player dying but you still hear the impact sounds which is annoying so we disable them + return !InvulnerabilityHandler.Invincible; + } +} diff --git a/NewHorizons/Patches/ShipLogPatches/ShipLogSlideReelPatches.cs b/NewHorizons/Patches/ShipLogPatches/ShipLogSlideReelPatches.cs new file mode 100644 index 00000000..1da6531e --- /dev/null +++ b/NewHorizons/Patches/ShipLogPatches/ShipLogSlideReelPatches.cs @@ -0,0 +1,37 @@ +using HarmonyLib; +using NewHorizons.Components.EOTE; + +namespace NewHorizons.Patches.ShipLogPatches; + +[HarmonyPatch] +public static class ShipLogSlideReelPatches +{ + [HarmonyPrefix] + [HarmonyPatch(typeof(ShipLogSlideProjector), nameof(ShipLogSlideProjector.CheckStreamingTexturesAvailable))] + public static bool ShipLogSlideProjector_CheckStreamingTexturesAvailable(ShipLogSlideProjector __instance, ref bool __result) + { + if (__instance._collectionIndex >= 0 && __instance._collectionIndex < __instance._slideCollections.Count && + __instance._slideCollections[__instance._collectionIndex] is NHSlideCollection) + { + __result = true; + return false; + } + return true; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(ShipLogSlideProjector), nameof(ShipLogSlideProjector.UnloadCurrentStreamingTextures))] + public static bool ShipLogSlideProjector_UnloadCurrentStreamingTextures(ShipLogSlideProjector __instance) + { + if (__instance._collectionIndex >= 0 && __instance._collectionIndex < __instance._slideCollections.Count && + __instance._slideCollections[__instance._collectionIndex] is NHSlideCollection collection) + { + for (int i = 0; i < collection.slides.Length; i++) + { + collection.UnloadSlide(i); + } + return false; + } + return true; + } +} diff --git a/NewHorizons/Patches/ToolPatches/ToolModeSwapperPatches.cs b/NewHorizons/Patches/ToolPatches/ToolModeSwapperPatches.cs index 63986a2a..a9d1d1ca 100644 --- a/NewHorizons/Patches/ToolPatches/ToolModeSwapperPatches.cs +++ b/NewHorizons/Patches/ToolPatches/ToolModeSwapperPatches.cs @@ -21,9 +21,8 @@ namespace NewHorizons.Patches.ToolPatches mode == ToolMode.Probe || mode == ToolMode.SignalScope || mode == ToolMode.Translator; - var isInShip = UnityEngine.Object.FindObjectOfType()?._playerAtFlightConsole ?? false; - if (!isInShip && isHoldingVisionTorch && swappingToRestrictedTool) return false; + if (!PlayerState.AtFlightConsole() && isHoldingVisionTorch && swappingToRestrictedTool) return false; return true; } diff --git a/NewHorizons/Schemas/addon_manifest_schema.json b/NewHorizons/Schemas/addon_manifest_schema.json index a1e1d34a..d68f545c 100644 --- a/NewHorizons/Schemas/addon_manifest_schema.json +++ b/NewHorizons/Schemas/addon_manifest_schema.json @@ -38,6 +38,13 @@ "type": "string", "description": "The path to the addons subtitle for the main menu.\nDefaults to \"subtitle.png\".\nThe dimensions of the Echoes of the Eye subtitle is 669 x 67, so aim for that size" }, + "gameOver": { + "type": "array", + "description": "Custom game over messages for this mod. This can either display a title card before looping like in EOTE, or show a message and roll credits like the various time loop escape endings.\nYou must set a dialogue condition for the game over sequence to run.", + "items": { + "$ref": "#/definitions/GameOverModule" + } + }, "$schema": { "type": "string", "description": "The schema to validate with" @@ -79,6 +86,80 @@ } } } + }, + "GameOverModule": { + "type": "object", + "additionalProperties": false, + "properties": { + "text": { + "type": "string", + "description": "Text displayed in orange on game over. For localization, put translations under UI." + }, + "colour": { + "description": "Change the colour of the game over text. Leave empty to use the default orange.", + "$ref": "#/definitions/MColor" + }, + "condition": { + "type": "string", + "description": "Condition that must be true for this game over to trigger. If this is on a LoadCreditsVolume, leave empty to always trigger this game over.\nNote this is a regular dialogue condition, not a persistent condition." + }, + "creditsType": { + "description": "The type of credits that will run after the game over message is shown", + "default": "fast", + "$ref": "#/definitions/NHCreditsType" + } + } + }, + "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 + } + } + }, + "NHCreditsType": { + "type": "string", + "description": "", + "x-enumNames": [ + "Fast", + "Final", + "Kazoo", + "None" + ], + "enum": [ + "fast", + "final", + "kazoo", + "none" + ] } }, "$docs": { diff --git a/NewHorizons/Schemas/body_schema.json b/NewHorizons/Schemas/body_schema.json index 7ed834cb..c811e967 100644 --- a/NewHorizons/Schemas/body_schema.json +++ b/NewHorizons/Schemas/body_schema.json @@ -74,6 +74,10 @@ "description": "Make this planet part of the dream world", "$ref": "#/definitions/DreamModule" }, + "EyeOfTheUniverse": { + "description": "Add features exclusive to the Eye of the Universe scene", + "$ref": "#/definitions/EyeOfTheUniverseModule" + }, "FocalPoint": { "description": "Make this body into a focal point (barycenter)", "$ref": "#/definitions/FocalPointModule" @@ -850,6 +854,874 @@ } } }, + "EyeOfTheUniverseModule": { + "type": "object", + "additionalProperties": false, + "properties": { + "eyeTravelers": { + "type": "array", + "description": "Add custom travelers to the campfire sequence", + "items": { + "$ref": "#/definitions/EyeTravelerInfo" + } + }, + "instrumentZones": { + "type": "array", + "description": "Add instrument zones which contain puzzles to gather a quantum instrument. You can parent other props to these with `parentPath` ", + "items": { + "$ref": "#/definitions/InstrumentZoneInfo" + } + }, + "quantumInstruments": { + "type": "array", + "description": "Add quantum instruments which cause their associated eye traveler to appear and instrument zones to disappear", + "items": { + "$ref": "#/definitions/QuantumInstrumentInfo" + } + } + } + }, + "EyeTravelerInfo": { + "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." + }, + "hasPhysics": { + "type": "boolean", + "description": "Should this object dynamically move around?\nThis tries to make all mesh colliders convex, as well as adding a sphere collider in case the detail has no others." + }, + "physicsMass": { + "type": "number", + "description": "The mass of the physics object.\nMost pushable props use the default value, which matches the player mass.", + "format": "float", + "default": 0.001 + }, + "physicsRadius": { + "type": "number", + "description": "The radius that the added sphere collider will use for physics collision.\nIf there's already good colliders on the detail, you can make this 0.", + "format": "float", + "default": 1.0 + }, + "physicsSuspendUntilImpact": { + "type": "boolean", + "description": "If true, this detail will stay still until it touches something.\nGood for zero-g props.", + "default": false + }, + "ignoreSun": { + "type": "boolean", + "description": "Set to true if this object's lighting should ignore the effects of sunlight" + }, + "activationCondition": { + "type": "string", + "description": "Activates this game object when the dialogue condition is met" + }, + "deactivationCondition": { + "type": "string", + "description": "Deactivates this game object when the dialogue condition is met" + }, + "blinkWhenActiveChanged": { + "type": "boolean", + "description": "Should the player close their eyes while the activation state changes. Only relevant if activationCondition or deactivationCondition are set.", + "default": true + }, + "item": { + "description": "Should this detail be treated as an interactible item", + "$ref": "#/definitions/ItemInfo" + }, + "itemSocket": { + "description": "Should this detail be treated as a socket for an interactible item", + "$ref": "#/definitions/ItemSocketInfo" + }, + "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" + }, + "id": { + "type": "string", + "description": "A unique ID to associate this traveler with their corresponding quantum instruments and instrument zones. Must be unique for each traveler." + }, + "requiredFact": { + "type": "string", + "description": "If set, the player must know this ship log fact for this traveler (and their instrument zones and quantum instruments) to appear. The fact does not need to exist in the current star system; the player's save data will be checked directly." + }, + "requiredPersistentCondition": { + "type": "string", + "description": "If set, the player must have this persistent dialogue condition set for this traveler (and their instrument zones and quantum instruments) to appear." + }, + "startPlayingCondition": { + "type": "string", + "description": "The dialogue condition that will trigger the traveler to start playing their instrument. Must be unique for each traveler." + }, + "participatingCondition": { + "type": "string", + "description": "If specified, this dialogue condition must be set for the traveler to participate in the campfire song. Otherwise, the song will be able to start without them." + }, + "signal": { + "description": "The audio signal to use for the traveler while playing around the campfire (and also for their paired quantum instrument if another is not specified). The audio clip should be 16 measures at 92 BPM (approximately 42 seconds long).", + "$ref": "#/definitions/SignalInfo" + }, + "finaleAudio": { + "type": "string", + "description": "The audio to use for the traveler during the finale of the campfire song. It should be 8 measures of the main loop at 92 BPM followed by 2 measures of fade-out (approximately 26 seconds long in total). Can be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list." + }, + "dialogue": { + "description": "The dialogue to use for this traveler. If omitted, the first CharacterDialogueTree in the object will be used.", + "$ref": "#/definitions/DialogueInfo" + } + } + }, + "ItemInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the item to be displayed in the UI. Defaults to the name of the detail object." + }, + "itemType": { + "type": "string", + "description": "The type of the item, which determines its orientation when held and what sockets it fits into. This can be a custom string, or a vanilla ItemType (Scroll, WarpCore, SharedStone, ConversationStone, Lantern, SlideReel, DreamLantern, or VisionTorch). Defaults to the item name." + }, + "interactRange": { + "type": "number", + "description": "The furthest distance where the player can interact with this item. Defaults to two meters, same as most vanilla items. Set this to zero to disable all interaction by default.", + "format": "float", + "default": 2.0 + }, + "colliderRadius": { + "type": "number", + "description": "The radius that the added sphere collider will use for collision and hover detection.\nIf there's already a collider on the detail, you can make this 0.", + "format": "float", + "default": 0.5 + }, + "droppable": { + "type": "boolean", + "description": "Whether the item can be dropped. Defaults to true.", + "default": true + }, + "dropOffset": { + "description": "A relative offset to apply to the item's position when dropping it on the ground.", + "$ref": "#/definitions/MVector3" + }, + "dropNormal": { + "description": "The direction the item will be oriented when dropping it on the ground. Defaults to up (0, 1, 0).", + "$ref": "#/definitions/MVector3" + }, + "holdOffset": { + "description": "A relative offset to apply to the item's position when holding it. The initial position varies for vanilla item types.", + "$ref": "#/definitions/MVector3" + }, + "holdRotation": { + "description": "A relative offset to apply to the item's rotation when holding it.", + "$ref": "#/definitions/MVector3" + }, + "socketOffset": { + "description": "A relative offset to apply to the item's position when placing it into a socket.", + "$ref": "#/definitions/MVector3" + }, + "socketRotation": { + "description": "A relative offset to apply to the item's rotation when placing it into a socket.", + "$ref": "#/definitions/MVector3" + }, + "pickupAudio": { + "type": "string", + "description": "The audio to play when this item is picked up. Only applies to custom/non-vanilla item types.\nCan be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list." + }, + "dropAudio": { + "type": "string", + "description": "The audio to play when this item is dropped. Only applies to custom/non-vanilla item types.\nCan be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list." + }, + "socketAudio": { + "type": "string", + "description": "The audio to play when this item is inserted into a socket. Only applies to custom/non-vanilla item types.\nCan be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list." + }, + "unsocketAudio": { + "type": "string", + "description": "The audio to play when this item is removed from a socket. Only applies to custom/non-vanilla item types.\nCan be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list." + }, + "pickupCondition": { + "type": "string", + "description": "A dialogue condition to set when picking up this item." + }, + "clearPickupConditionOnDrop": { + "type": "boolean", + "description": "Whether the pickup condition should be cleared when dropping the item. Defaults to true.", + "default": true + }, + "pickupFact": { + "type": "string", + "description": "A ship log fact to reveal when picking up this item." + }, + "pathToInitialSocket": { + "type": "string", + "description": "A relative path from the planet to a socket that this item will be automatically inserted into." + } + } + }, + "ItemSocketInfo": { + "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" + }, + "socketPath": { + "type": "string", + "description": "The relative path to a child game object of this detail that will act as the socket point for the item. Will be used instead of this socket's positioning info if set." + }, + "itemType": { + "type": "string", + "description": "The type of item allowed in this socket. This can be a custom string, or a vanilla ItemType (Scroll, WarpCode, SharedStone, ConversationStone, Lantern, SlideReel, DreamLantern, or VisionTorch)." + }, + "interactRange": { + "type": "number", + "description": "The furthest distance where the player can interact with this item socket. Defaults to two meters, same as most vanilla item sockets. Set this to zero to disable all interaction by default.", + "format": "float", + "default": 2.0 + }, + "useGiveTakePrompts": { + "type": "boolean", + "description": "Whether to use \"Give Item\" / \"Take Item\" prompts instead of \"Insert Item\" / \"Remove Item\"." + }, + "insertCondition": { + "type": "string", + "description": "A dialogue condition to set when inserting an item into this socket." + }, + "clearInsertConditionOnRemoval": { + "type": "boolean", + "description": "Whether the insert condition should be cleared when removing the socketed item. Defaults to true.", + "default": true + }, + "insertFact": { + "type": "string", + "description": "A ship log fact to reveal when inserting an item into this socket." + }, + "removalCondition": { + "type": "string", + "description": "A dialogue condition to set when removing an item from this socket, or when the socket is empty." + }, + "clearRemovalConditionOnInsert": { + "type": "boolean", + "description": "Whether the removal condition should be cleared when inserting a socketed item. Defaults to true.", + "default": true + }, + "removalFact": { + "type": "string", + "description": "A ship log fact to reveal when removing an item from this socket, or when the socket is empty." + }, + "colliderRadius": { + "type": "number", + "description": "Default collider radius when interacting with the socket", + "format": "float", + "default": 0.0 + } + } + }, + "SignalInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "audio": { + "type": "string", + "description": "The audio to use. Can be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list." + }, + "minDistance": { + "type": "number", + "description": "At this distance the sound is at its loudest.", + "format": "float" + }, + "maxDistance": { + "type": "number", + "description": "The sound will drop off by this distance (Note: for signals, this only effects when it is heard aloud and not via the signalscope).", + "format": "float", + "default": 5.0 + }, + "volume": { + "type": "number", + "description": "How loud the sound will play", + "format": "float", + "default": 0.5 + }, + "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" + }, + "detectionRadius": { + "type": "number", + "description": "How close the player must get to the signal to detect it. This is when you get the \"Unknown Signal Detected\"\nnotification.", + "format": "float", + "minimum": 0.0 + }, + "frequency": { + "type": "string", + "description": "The frequency ID of the signal. The built-in game values are `Default`, `Traveler`, `Quantum`, `EscapePod`,\n`Statue`, `WarpCore`, `HideAndSeek`, and `Radio`. You can also put a custom value." + }, + "identificationRadius": { + "type": "number", + "description": "How close the player must get to the signal to identify it. This is when you learn its name.", + "format": "float", + "default": 10.0, + "minimum": 0.0 + }, + "insideCloak": { + "type": "boolean", + "description": "Only set to `true` if you are putting this signal inside a cloaking field." + }, + "name": { + "type": "string", + "description": "The unique ID of the signal." + }, + "onlyAudibleToScope": { + "type": "boolean", + "description": "`false` if the player can hear the signal without equipping the signal-scope.", + "default": true + }, + "reveals": { + "type": "string", + "description": "A ship log fact to reveal when the signal is identified.", + "default": "" + }, + "sourceRadius": { + "type": "number", + "description": "Radius of the sphere giving off the signal.", + "format": "float", + "default": 1.0 + } + } + }, + "DialogueInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "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" + }, + "blockAfterPersistentCondition": { + "type": "string", + "description": "Prevents the dialogue from being created after a specific persistent condition is set. Useful for remote dialogue\ntriggers that you want to have happen only once." + }, + "lookAtRadius": { + "type": "number", + "description": "If a pathToAnimController is supplied, if you are within this distance the character will look at you. If it is set\nto 0, they will only look at you when spoken to.", + "format": "float" + }, + "pathToAnimController": { + "type": "string", + "description": "If this dialogue is meant for a character, this is the relative path from the planet to that character's\nCharacterAnimController, TravelerController, TravelerEyeController (eye of the universe), FacePlayerWhenTalking, \nHearthianRecorderEffects or SolanumAnimController.\n\nIf it's a Recorder this will also delete the existing dialogue already attached to that prop.\n\nIf none of those components are present it will add a FacePlayerWhenTalking component.\n\n`pathToAnimController` also makes the dialogue into a child of the anim controller. This can be used with `isRelativeToParent`\nto position the dialogue on relative to the speaker. If you also provide `parentPath`, that will instead override which object \nis the parent, but the anim controller will otherwise function as expected." + }, + "pathToExistingDialogue": { + "type": "string", + "description": "If this dialogue is adding to existing character dialogue, put a path to the game object with the dialogue on it here" + }, + "radius": { + "type": "number", + "description": "Radius of the spherical collision volume where you get the \"talk to\" prompt when looking at. If you use a\nremoteTrigger, you can set this to 0 to make the dialogue only trigger remotely.", + "format": "float" + }, + "range": { + "type": "number", + "description": "Distance from radius the prompt appears", + "format": "float", + "default": 2.0 + }, + "attentionPoint": { + "description": "The point that the camera looks at when dialogue advances.", + "$ref": "#/definitions/AttentionPointInfo" + }, + "swappedAttentionPoints": { + "type": "array", + "description": "Additional points that the camera looks at when dialogue advances through specific dialogue nodes and pages.", + "items": { + "$ref": "#/definitions/SwappedAttentionPointInfo" + } + }, + "remoteTrigger": { + "description": "Allows you to trigger dialogue from a distance when you walk into an area.", + "$ref": "#/definitions/RemoteTriggerInfo" + }, + "xmlFile": { + "type": "string", + "description": "Relative path to the xml file defining the dialogue." + }, + "flashlightToggle": { + "description": "What type of flashlight toggle to do when dialogue is interacted with", + "default": "none", + "$ref": "#/definitions/FlashlightToggle" + } + } + }, + "AttentionPointInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "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" + }, + "offset": { + "description": "An additional offset to apply to apply when the camera looks at this attention point.", + "$ref": "#/definitions/MVector3" + } + } + }, + "SwappedAttentionPointInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "offset": { + "description": "An additional offset to apply to apply when the camera looks at this attention point.", + "$ref": "#/definitions/MVector3" + }, + "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" + }, + "dialogueNode": { + "type": "string", + "description": "The name of the dialogue node to activate this attention point for. If null or blank, activates for every node." + }, + "dialoguePage": { + "type": "integer", + "description": "The index of the page in the current dialogue node to activate this attention point for, if the node has multiple pages.", + "format": "int32" + }, + "lookEasing": { + "type": "number", + "description": "The easing factor which determines how 'snappy' the camera is when looking at the attention point.", + "format": "float", + "default": 1 + } + } + }, + "RemoteTriggerInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "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" + }, + "radius": { + "type": "number", + "description": "The radius of the remote trigger volume.", + "format": "float" + }, + "prereqCondition": { + "type": "string", + "description": "This condition must be met for the remote trigger volume to trigger." + } + } + }, + "FlashlightToggle": { + "type": "string", + "description": "", + "x-enumNames": [ + "TurnOff", + "TurnOffThenOn", + "None" + ], + "enum": [ + "turnOff", + "turnOffThenOn", + "none" + ] + }, + "InstrumentZoneInfo": { + "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." + }, + "hasPhysics": { + "type": "boolean", + "description": "Should this object dynamically move around?\nThis tries to make all mesh colliders convex, as well as adding a sphere collider in case the detail has no others." + }, + "physicsMass": { + "type": "number", + "description": "The mass of the physics object.\nMost pushable props use the default value, which matches the player mass.", + "format": "float", + "default": 0.001 + }, + "physicsRadius": { + "type": "number", + "description": "The radius that the added sphere collider will use for physics collision.\nIf there's already good colliders on the detail, you can make this 0.", + "format": "float", + "default": 1.0 + }, + "physicsSuspendUntilImpact": { + "type": "boolean", + "description": "If true, this detail will stay still until it touches something.\nGood for zero-g props.", + "default": false + }, + "ignoreSun": { + "type": "boolean", + "description": "Set to true if this object's lighting should ignore the effects of sunlight" + }, + "activationCondition": { + "type": "string", + "description": "Activates this game object when the dialogue condition is met" + }, + "deactivationCondition": { + "type": "string", + "description": "Deactivates this game object when the dialogue condition is met" + }, + "blinkWhenActiveChanged": { + "type": "boolean", + "description": "Should the player close their eyes while the activation state changes. Only relevant if activationCondition or deactivationCondition are set.", + "default": true + }, + "item": { + "description": "Should this detail be treated as an interactible item", + "$ref": "#/definitions/ItemInfo" + }, + "itemSocket": { + "description": "Should this detail be treated as a socket for an interactible item", + "$ref": "#/definitions/ItemSocketInfo" + }, + "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" + }, + "id": { + "type": "string", + "description": "The unique ID of the Eye Traveler associated with this instrument zone." + } + } + }, + "QuantumInstrumentInfo": { + "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." + }, + "hasPhysics": { + "type": "boolean", + "description": "Should this object dynamically move around?\nThis tries to make all mesh colliders convex, as well as adding a sphere collider in case the detail has no others." + }, + "physicsMass": { + "type": "number", + "description": "The mass of the physics object.\nMost pushable props use the default value, which matches the player mass.", + "format": "float", + "default": 0.001 + }, + "physicsRadius": { + "type": "number", + "description": "The radius that the added sphere collider will use for physics collision.\nIf there's already good colliders on the detail, you can make this 0.", + "format": "float", + "default": 1.0 + }, + "physicsSuspendUntilImpact": { + "type": "boolean", + "description": "If true, this detail will stay still until it touches something.\nGood for zero-g props.", + "default": false + }, + "ignoreSun": { + "type": "boolean", + "description": "Set to true if this object's lighting should ignore the effects of sunlight" + }, + "activationCondition": { + "type": "string", + "description": "Activates this game object when the dialogue condition is met" + }, + "deactivationCondition": { + "type": "string", + "description": "Deactivates this game object when the dialogue condition is met" + }, + "blinkWhenActiveChanged": { + "type": "boolean", + "description": "Should the player close their eyes while the activation state changes. Only relevant if activationCondition or deactivationCondition are set.", + "default": true + }, + "item": { + "description": "Should this detail be treated as an interactible item", + "$ref": "#/definitions/ItemInfo" + }, + "itemSocket": { + "description": "Should this detail be treated as a socket for an interactible item", + "$ref": "#/definitions/ItemSocketInfo" + }, + "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" + }, + "id": { + "type": "string", + "description": "The unique ID of the Eye Traveler associated with this quantum instrument." + }, + "gatherCondition": { + "type": "string", + "description": "A dialogue condition to set when gathering this quantum instrument. Use it in conjunction with `activationCondition` or `deactivationCondition` on other details." + }, + "gatherWithScope": { + "type": "boolean", + "description": "Allows gathering this quantum instrument using the zoomed-in signalscope, like Chert's bongos." + }, + "signal": { + "description": "The audio signal emitted by this quantum instrument. The fields `name`, `audio`, and `frequency` will be copied from the corresponding Eye Traveler's signal if not specified here.", + "$ref": "#/definitions/SignalInfo" + }, + "interactRadius": { + "type": "number", + "description": "The radius of the added sphere collider that will be used for interaction.", + "format": "float", + "default": 0.5 + }, + "interactRange": { + "type": "number", + "description": "The furthest distance where the player can interact with this quantum instrument.", + "format": "float", + "default": 2.0 + } + } + }, "FocalPointModule": { "type": "object", "additionalProperties": false, @@ -1523,363 +2395,6 @@ } } }, - "ItemInfo": { - "type": "object", - "additionalProperties": false, - "properties": { - "name": { - "type": "string", - "description": "The name of the item to be displayed in the UI. Defaults to the name of the detail object." - }, - "itemType": { - "type": "string", - "description": "The type of the item, which determines its orientation when held and what sockets it fits into. This can be a custom string, or a vanilla ItemType (Scroll, WarpCore, SharedStone, ConversationStone, Lantern, SlideReel, DreamLantern, or VisionTorch). Defaults to the item name." - }, - "interactRange": { - "type": "number", - "description": "The furthest distance where the player can interact with this item. Defaults to two meters, same as most vanilla items. Set this to zero to disable all interaction by default.", - "format": "float", - "default": 2.0 - }, - "colliderRadius": { - "type": "number", - "description": "The radius that the added sphere collider will use for collision and hover detection.\nIf there's already a collider on the detail, you can make this 0.", - "format": "float", - "default": 0.5 - }, - "droppable": { - "type": "boolean", - "description": "Whether the item can be dropped. Defaults to true.", - "default": true - }, - "dropOffset": { - "description": "A relative offset to apply to the item's position when dropping it on the ground.", - "$ref": "#/definitions/MVector3" - }, - "dropNormal": { - "description": "The direction the item will be oriented when dropping it on the ground. Defaults to up (0, 1, 0).", - "$ref": "#/definitions/MVector3" - }, - "holdOffset": { - "description": "A relative offset to apply to the item's position when holding it. The initial position varies for vanilla item types.", - "$ref": "#/definitions/MVector3" - }, - "holdRotation": { - "description": "A relative offset to apply to the item's rotation when holding it.", - "$ref": "#/definitions/MVector3" - }, - "socketOffset": { - "description": "A relative offset to apply to the item's position when placing it into a socket.", - "$ref": "#/definitions/MVector3" - }, - "socketRotation": { - "description": "A relative offset to apply to the item's rotation when placing it into a socket.", - "$ref": "#/definitions/MVector3" - }, - "pickupAudio": { - "type": "string", - "description": "The audio to play when this item is picked up. Only applies to custom/non-vanilla item types.\nCan be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list." - }, - "dropAudio": { - "type": "string", - "description": "The audio to play when this item is dropped. Only applies to custom/non-vanilla item types.\nCan be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list." - }, - "socketAudio": { - "type": "string", - "description": "The audio to play when this item is inserted into a socket. Only applies to custom/non-vanilla item types.\nCan be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list." - }, - "unsocketAudio": { - "type": "string", - "description": "The audio to play when this item is removed from a socket. Only applies to custom/non-vanilla item types.\nCan be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list." - }, - "pickupCondition": { - "type": "string", - "description": "A dialogue condition to set when picking up this item." - }, - "clearPickupConditionOnDrop": { - "type": "boolean", - "description": "Whether the pickup condition should be cleared when dropping the item. Defaults to true.", - "default": true - }, - "pickupFact": { - "type": "string", - "description": "A ship log fact to reveal when picking up this item." - }, - "pathToInitialSocket": { - "type": "string", - "description": "A relative path from the planet to a socket that this item will be automatically inserted into." - } - } - }, - "ItemSocketInfo": { - "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" - }, - "socketPath": { - "type": "string", - "description": "The relative path to a child game object of this detail that will act as the socket point for the item. Will be used instead of this socket's positioning info if set." - }, - "itemType": { - "type": "string", - "description": "The type of item allowed in this socket. This can be a custom string, or a vanilla ItemType (Scroll, WarpCode, SharedStone, ConversationStone, Lantern, SlideReel, DreamLantern, or VisionTorch)." - }, - "interactRange": { - "type": "number", - "description": "The furthest distance where the player can interact with this item socket. Defaults to two meters, same as most vanilla item sockets. Set this to zero to disable all interaction by default.", - "format": "float", - "default": 2.0 - }, - "useGiveTakePrompts": { - "type": "boolean", - "description": "Whether to use \"Give Item\" / \"Take Item\" prompts instead of \"Insert Item\" / \"Remove Item\"." - }, - "insertCondition": { - "type": "string", - "description": "A dialogue condition to set when inserting an item into this socket." - }, - "clearInsertConditionOnRemoval": { - "type": "boolean", - "description": "Whether the insert condition should be cleared when removing the socketed item. Defaults to true.", - "default": true - }, - "insertFact": { - "type": "string", - "description": "A ship log fact to reveal when inserting an item into this socket." - }, - "removalCondition": { - "type": "string", - "description": "A dialogue condition to set when removing an item from this socket, or when the socket is empty." - }, - "clearRemovalConditionOnInsert": { - "type": "boolean", - "description": "Whether the removal condition should be cleared when inserting a socketed item. Defaults to true.", - "default": true - }, - "removalFact": { - "type": "string", - "description": "A ship log fact to reveal when removing an item from this socket, or when the socket is empty." - }, - "colliderRadius": { - "type": "number", - "description": "Default collider radius when interacting with the socket", - "format": "float", - "default": 0.0 - } - } - }, - "DialogueInfo": { - "type": "object", - "additionalProperties": false, - "properties": { - "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" - }, - "blockAfterPersistentCondition": { - "type": "string", - "description": "Prevents the dialogue from being created after a specific persistent condition is set. Useful for remote dialogue\ntriggers that you want to have happen only once." - }, - "lookAtRadius": { - "type": "number", - "description": "If a pathToAnimController is supplied, if you are within this distance the character will look at you. If it is set\nto 0, they will only look at you when spoken to.", - "format": "float" - }, - "pathToAnimController": { - "type": "string", - "description": "If this dialogue is meant for a character, this is the relative path from the planet to that character's\nCharacterAnimController, TravelerController, TravelerEyeController (eye of the universe), FacePlayerWhenTalking, \nHearthianRecorderEffects or SolanumAnimController.\n\nIf it's a Recorder this will also delete the existing dialogue already attached to that prop.\n\nIf none of those components are present it will add a FacePlayerWhenTalking component.\n\n`pathToAnimController` also makes the dialogue into a child of the anim controller. This can be used with `isRelativeToParent`\nto position the dialogue on relative to the speaker. If you also provide `parentPath`, that will instead override which object \nis the parent, but the anim controller will otherwise function as expected." - }, - "pathToExistingDialogue": { - "type": "string", - "description": "If this dialogue is adding to existing character dialogue, put a path to the game object with the dialogue on it here" - }, - "radius": { - "type": "number", - "description": "Radius of the spherical collision volume where you get the \"talk to\" prompt when looking at. If you use a\nremoteTrigger, you can set this to 0 to make the dialogue only trigger remotely.", - "format": "float" - }, - "range": { - "type": "number", - "description": "Distance from radius the prompt appears", - "format": "float", - "default": 2.0 - }, - "attentionPoint": { - "description": "The point that the camera looks at when dialogue advances.", - "$ref": "#/definitions/AttentionPointInfo" - }, - "swappedAttentionPoints": { - "type": "array", - "description": "Additional points that the camera looks at when dialogue advances through specific dialogue nodes and pages.", - "items": { - "$ref": "#/definitions/SwappedAttentionPointInfo" - } - }, - "remoteTrigger": { - "description": "Allows you to trigger dialogue from a distance when you walk into an area.", - "$ref": "#/definitions/RemoteTriggerInfo" - }, - "xmlFile": { - "type": "string", - "description": "Relative path to the xml file defining the dialogue." - }, - "flashlightToggle": { - "description": "What type of flashlight toggle to do when dialogue is interacted with", - "default": "none", - "$ref": "#/definitions/FlashlightToggle" - } - } - }, - "AttentionPointInfo": { - "type": "object", - "additionalProperties": false, - "properties": { - "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" - }, - "offset": { - "description": "An additional offset to apply to apply when the camera looks at this attention point.", - "$ref": "#/definitions/MVector3" - } - } - }, - "SwappedAttentionPointInfo": { - "type": "object", - "additionalProperties": false, - "properties": { - "offset": { - "description": "An additional offset to apply to apply when the camera looks at this attention point.", - "$ref": "#/definitions/MVector3" - }, - "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" - }, - "dialogueNode": { - "type": "string", - "description": "The name of the dialogue node to activate this attention point for. If null or blank, activates for every node." - }, - "dialoguePage": { - "type": "integer", - "description": "The index of the page in the current dialogue node to activate this attention point for, if the node has multiple pages.", - "format": "int32" - }, - "lookEasing": { - "type": "number", - "description": "The easing factor which determines how 'snappy' the camera is when looking at the attention point.", - "format": "float", - "default": 1 - } - } - }, - "RemoteTriggerInfo": { - "type": "object", - "additionalProperties": false, - "properties": { - "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" - }, - "radius": { - "type": "number", - "description": "The radius of the remote trigger volume.", - "format": "float" - }, - "prereqCondition": { - "type": "string", - "description": "This condition must be met for the remote trigger volume to trigger." - } - } - }, - "FlashlightToggle": { - "type": "string", - "description": "", - "x-enumNames": [ - "TurnOff", - "TurnOffThenOn", - "None" - ], - "enum": [ - "turnOff", - "turnOffThenOn", - "none" - ] - }, "EntryLocationInfo": { "type": "object", "additionalProperties": false, @@ -2348,6 +2863,14 @@ "description": "Exclusive to the slide reel type. Condition/material of the reel. Antique is the Stranger, Pristine is the Dreamworld, Rusted is a burned reel.", "default": "antique", "$ref": "#/definitions/SlideReelCondition" + }, + "displaySlides": { + "type": "array", + "description": "Set which slides appear on the slide reel model. Leave empty to default to the first few slides.\nTakes a list of indices, i.e., to show the first 5 slides in reverse you would put [4, 3, 2, 1, 0].\nIndex starts at 0.", + "items": { + "type": "integer", + "format": "int32" + } } } }, @@ -2800,90 +3323,6 @@ "whiteHole" ] }, - "SignalInfo": { - "type": "object", - "additionalProperties": false, - "properties": { - "audio": { - "type": "string", - "description": "The audio to use. Can be a path to a .wav/.ogg/.mp3 file, or taken from the AudioClip list." - }, - "minDistance": { - "type": "number", - "description": "At this distance the sound is at its loudest.", - "format": "float" - }, - "maxDistance": { - "type": "number", - "description": "The sound will drop off by this distance (Note: for signals, this only effects when it is heard aloud and not via the signalscope).", - "format": "float", - "default": 5.0 - }, - "volume": { - "type": "number", - "description": "How loud the sound will play", - "format": "float", - "default": 0.5 - }, - "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" - }, - "detectionRadius": { - "type": "number", - "description": "How close the player must get to the signal to detect it. This is when you get the \"Unknown Signal Detected\"\nnotification.", - "format": "float", - "minimum": 0.0 - }, - "frequency": { - "type": "string", - "description": "The frequency ID of the signal. The built-in game values are `Default`, `Traveler`, `Quantum`, `EscapePod`,\n`Statue`, `WarpCore`, `HideAndSeek`, and `Radio`. You can also put a custom value." - }, - "identificationRadius": { - "type": "number", - "description": "How close the player must get to the signal to identify it. This is when you learn its name.", - "format": "float", - "default": 10.0, - "minimum": 0.0 - }, - "insideCloak": { - "type": "boolean", - "description": "Only set to `true` if you are putting this signal inside a cloaking field." - }, - "name": { - "type": "string", - "description": "The unique ID of the signal." - }, - "onlyAudibleToScope": { - "type": "boolean", - "description": "`false` if the player can hear the signal without equipping the signal-scope.", - "default": true - }, - "reveals": { - "type": "string", - "description": "A ship log fact to reveal when the signal is identified.", - "default": "" - }, - "sourceRadius": { - "type": "number", - "description": "Radius of the sphere giving off the signal.", - "format": "float", - "default": 1.0 - } - } - }, "RemoteInfo": { "type": "object", "additionalProperties": false, @@ -5952,18 +6391,44 @@ "type": "string", "description": "An optional rename of this object" }, - "creditsType": { - "default": "fast", - "$ref": "#/definitions/NHCreditsType" + "deathType": { + "description": "The type of death the player will have if they enter this volume. Don't set to have the camera just fade out.", + "default": "default", + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/definitions/NHDeathType" + } + ] }, - "gameOverText": { + "gameOver": { + "description": "The game over message to display. Leave empty to go straight to credits.", + "$ref": "#/definitions/GameOverModule" + } + } + }, + "GameOverModule": { + "type": "object", + "additionalProperties": false, + "properties": { + "text": { "type": "string", "description": "Text displayed in orange on game over. For localization, put translations under UI." }, - "deathType": { - "description": "The type of death the player will have if they enter this volume.", - "default": "default", - "$ref": "#/definitions/NHDeathType" + "colour": { + "description": "Change the colour of the game over text. Leave empty to use the default orange.", + "$ref": "#/definitions/MColor" + }, + "condition": { + "type": "string", + "description": "Condition that must be true for this game over to trigger. If this is on a LoadCreditsVolume, leave empty to always trigger this game over.\nNote this is a regular dialogue condition, not a persistent condition." + }, + "creditsType": { + "description": "The type of credits that will run after the game over message is shown", + "default": "fast", + "$ref": "#/definitions/NHCreditsType" } } }, @@ -5973,12 +6438,14 @@ "x-enumNames": [ "Fast", "Final", - "Kazoo" + "Kazoo", + "None" ], "enum": [ "fast", "final", - "kazoo" + "kazoo", + "none" ] }, "CometTailModule": { diff --git a/NewHorizons/Utility/Files/ImageUtilities.cs b/NewHorizons/Utility/Files/ImageUtilities.cs index 63f9d8da..e124fe89 100644 --- a/NewHorizons/Utility/Files/ImageUtilities.cs +++ b/NewHorizons/Utility/Files/ImageUtilities.cs @@ -15,6 +15,9 @@ namespace NewHorizons.Utility.Files public static bool CheckCachedTexture(string key, out Texture existingTexture) => _textureCache.TryGetValue(key, out existingTexture); public static void TrackCachedTexture(string key, Texture texture) => _textureCache.Add(key, texture); // dont reinsert cuz that causes memory leak! + public static string GetKey(IModBehaviour mod, string filename) + => GetKey(Path.Combine(mod.ModHelper.Manifest.ModFolderPath, filename)); + public static string GetKey(string path) => path.Substring(Main.Instance.ModHelper.OwmlConfig.ModsPath.Length + 1).Replace('\\', '/'); @@ -54,7 +57,7 @@ namespace NewHorizons.Utility.Files var key = GetKey(path); if (_textureCache.TryGetValue(key, out var existingTexture)) { - NHLogger.LogVerbose($"Already loaded image at path: {path}"); + //NHLogger.LogVerbose($"Already loaded image at path: {path}"); return (Texture2D)existingTexture; } @@ -78,6 +81,19 @@ namespace NewHorizons.Utility.Files } } + /// + /// Not sure why the other method takes in the texture as well + /// + /// + public static void DeleteTexture(string key) + { + if (_textureCache.ContainsKey(key)) + { + UnityEngine.Object.Destroy(_textureCache[key]); + _textureCache.Remove(key); + } + } + public static void DeleteTexture(IModBehaviour mod, string filename, Texture2D texture) { var path = Path.Combine(mod.ModHelper.Manifest.ModFolderPath, filename); @@ -201,9 +217,9 @@ namespace NewHorizons.Utility.Files { for (int j = 0; j < size; j++) { - var colour = Color.black; + var colour = Color.clear; - if (srcTexture) + if (srcTexture != null) { var srcX = i * srcTexture.width / (float)size; var srcY = j * srcTexture.height / (float)size; diff --git a/NewHorizons/Utility/Files/SlideReelAsyncImageLoader.cs b/NewHorizons/Utility/Files/SlideReelAsyncImageLoader.cs index e81d8298..e088e9d9 100644 --- a/NewHorizons/Utility/Files/SlideReelAsyncImageLoader.cs +++ b/NewHorizons/Utility/Files/SlideReelAsyncImageLoader.cs @@ -42,6 +42,11 @@ public class SlideReelAsyncImageLoader private bool _started; private bool _clamp; + /// + /// start loading the images a frame later + /// + /// sets wrapMode + /// load all slides one at a time vs at the same time public void Start(bool clamp, bool sequential) { if (_started) return; diff --git a/NewHorizons/Utility/Geometry/BoundsUtilities.cs b/NewHorizons/Utility/Geometry/BoundsUtilities.cs index b251a6d7..9f60ce0d 100644 --- a/NewHorizons/Utility/Geometry/BoundsUtilities.cs +++ b/NewHorizons/Utility/Geometry/BoundsUtilities.cs @@ -40,6 +40,7 @@ public static class BoundsUtilities } } + // BUG: ignores skinned guys. this coincidentally makes it work without BoxShapeFixer public static Bounds GetBoundsOfSelfAndChildMeshes(GameObject gameObject) { var meshFilters = gameObject.GetComponentsInChildren(); @@ -57,14 +58,14 @@ public static class BoundsUtilities var localCorners = new Vector3[] { - bounds.min, - bounds.max, - new Vector3(bounds.min.x, bounds.min.y, bounds.max.z), - new Vector3(bounds.min.x, bounds.max.y, bounds.min.z), - new Vector3(bounds.max.x, bounds.min.y, bounds.min.z), - new Vector3(bounds.min.x, bounds.max.y, bounds.max.z), - new Vector3(bounds.max.x, bounds.min.y, bounds.max.z), - new Vector3(bounds.max.x, bounds.max.y, bounds.min.z), + bounds.min, + bounds.max, + new Vector3(bounds.min.x, bounds.min.y, bounds.max.z), + new Vector3(bounds.min.x, bounds.max.y, bounds.min.z), + new Vector3(bounds.max.x, bounds.min.y, bounds.min.z), + new Vector3(bounds.min.x, bounds.max.y, bounds.max.z), + new Vector3(bounds.max.x, bounds.min.y, bounds.max.z), + new Vector3(bounds.max.x, bounds.max.y, bounds.min.z), }; var globalCorners = localCorners.Select(meshFilter.transform.TransformPoint).ToArray(); diff --git a/NewHorizons/Utility/Geometry/BoxShapeFinder.cs b/NewHorizons/Utility/Geometry/BoxShapeFinder.cs index ebdfd9e4..1c0173a8 100644 --- a/NewHorizons/Utility/Geometry/BoxShapeFinder.cs +++ b/NewHorizons/Utility/Geometry/BoxShapeFinder.cs @@ -3,7 +3,14 @@ using UnityEngine; namespace NewHorizons.Utility.Geometry; -internal class BoxShapeFixer : MonoBehaviour +/// +/// for some reason mesh bounds are wrong unless we wait a bit +/// so this script contiously checks everything until it is correct +/// +/// this actually only seems to be a problem with skinned renderers. normal ones work fine +/// TODO: at some point narrow this down to just skinned, instead of doing everything and checking every frame +/// +public class BoxShapeFixer : MonoBehaviour { public BoxShape shape; public MeshFilter meshFilter; @@ -13,27 +20,16 @@ internal class BoxShapeFixer : MonoBehaviour { if (meshFilter == null && skinnedMeshRenderer == null) { - NHLogger.LogVerbose("Useless BoxShapeFixer, destroying"); DestroyImmediate(this); + NHLogger.LogVerbose("Useless BoxShapeFixer, destroying"); + DestroyImmediate(this); } Mesh sharedMesh = null; - if (meshFilter != null) - { - sharedMesh = meshFilter.sharedMesh; - } - if (skinnedMeshRenderer != null) - { - sharedMesh = skinnedMeshRenderer.sharedMesh; - } + if (meshFilter != null) sharedMesh = meshFilter.sharedMesh; + if (skinnedMeshRenderer != null) sharedMesh = skinnedMeshRenderer.sharedMesh; - if (sharedMesh == null) - { - return; - } - if (sharedMesh.bounds.size == Vector3.zero) - { - return; - } + if (sharedMesh == null) return; + if (sharedMesh.bounds.size == Vector3.zero) return; shape.size = sharedMesh.bounds.size; shape.center = sharedMesh.bounds.center; diff --git a/NewHorizons/manifest.json b/NewHorizons/manifest.json index 69de585e..2f76acba 100644 --- a/NewHorizons/manifest.json +++ b/NewHorizons/manifest.json @@ -4,7 +4,7 @@ "author": "xen, Bwc9876, JohnCorby, MegaPiggy, Trifid, and friends", "name": "New Horizons", "uniqueName": "xen.NewHorizons", - "version": "1.25.0", + "version": "1.26.1", "owmlVersion": "2.12.1", "dependencies": [ "JohnCorby.VanillaFix", "xen.CommonCameraUtility", "dgarro.CustomShipLogModes" ], "conflicts": [ "PacificEngine.OW_CommonResources" ], diff --git a/README.md b/README.md index cab6d6ce..75307d4f 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ Translation credits: - Spanish: Ciborgm9, Ink, GayCoffee - French: xen - Japanese: TRSasasusu +- Portuguese: avengerx, loco-choco New Horizons was based off [Marshmallow](https://github.com/misternebula/Marshmallow) was made by: diff --git a/docs/src/assets/docs-images/eye_of_the_universe/animController.webp b/docs/src/assets/docs-images/eye_of_the_universe/animController.webp new file mode 100644 index 00000000..5e1e2f8c Binary files /dev/null and b/docs/src/assets/docs-images/eye_of_the_universe/animController.webp differ diff --git a/docs/src/content/docs/guides/dialogue.md b/docs/src/content/docs/guides/dialogue.md index a5057772..0375e773 100644 --- a/docs/src/content/docs/guides/dialogue.md +++ b/docs/src/content/docs/guides/dialogue.md @@ -15,38 +15,14 @@ A dialogue tree is an entire conversation, it's made up of dialogue nodes. A node is a set of pages shown to the player followed by options the player can choose from to change the flow of the conversation. -### Condition +### Conditions -A condition is a yes/no value stored **for this loop and this loop only**. It can be used to show new dialogue options, stop someone from talking to you (looking at you Slate), and more. - -### Persistent Condition - -A persistent condition is similar to a condition, except it _persists_ through loops, and is saved on the players save file. +In dialogue, the available conversation topics can be limited by what the player knows, defined using dialogue conditions, persistent conditions, and ship log facts. Dialogue can also set conditions to true or false, and reveal ship log facts to the player. This is covered in detail later on this page. ### Remote Trigger A remote trigger is used to have an NPC talk to you from a distance; ex: Slate stopping you for the umpteenth time to tell you information you already knew. -### ReuseDialogueOptionsListFrom - -This is a custom XML node introduced by New Horizons. Use it when adding new dialogue to existing characters, to repeat the dialogue options list from another node. - -For example, Slate's first dialogue with options is named `Scientist5`. To make a custom DialogueNode using these dialogue options (meaning new dialogue said by Slate, but reusing the possible player responses) you can write: - -```xml - - ... - - NEW DIALOGUE FOR SLATE HERE. - - - Scientist5 - - -``` - -Note: If you're loading dialogue in code, 2 frames must pass before entering the conversation in order for ReuseDialogueOptionsListFrom to take effect. - ## Example XML Here's an example dialogue XML: @@ -176,11 +152,39 @@ In addition to ``, there are other ways to control the flow of Defining `` in the `` tag instead of a `` will make the conversation go directly to that target after the character is done talking. -### DialogueTargetShipLogCondition +### EntryCondition -Used in tandem with `DialogueTarget`, makes it so you must have a [ship log fact](/guides/ship-log#explore-facts) to go to the next node. +The first dialogue node that opens when a player starts talking to a character is chosen using this property. To mark a DialogueNode as beginning the dialogue by default, use the condition DEFAULT (a DialogueTree should always have a node with the DEFAULT entry condition to ensure there is a way to start dialogue). -### Adding to existing dialogue +The entry condition can be either a condition or a persistent condition. + +### Condition + +A condition is a yes/no value stored **for this loop and this loop only**. It can be used to show new dialogue options, stop someone from talking to you (looking at you Slate), and more. + +Conditions can be set in dialogue using `CONDITION_NAME`. This can go in a DialogueNode in which case it will set the condition to true when that node is read. There is a similar version of this for DialogueOptions called `CONDITION_NAME` which will set it to true when that option is selected. Conditions can be disabled using `CONDITION_NAME` in a DialogueOption, but cannot be disabled just by entering a DialogueNode. + +You can lock a DialogueOption behind a condition using `CONDITION_NAME`, or remove a DialogueOption after the condition is set to true using `CONDITION_NAME`. + +Dialogue conditions can also be set in code with `DialogueConditionManager.SharedInstance.SetConditionState("CONDITION_NAME", true/false)` or read with `DialogueConditionManager.SharedInstance.GetConditionState("CONDITION_NAME")`. + +Note that `CONDITION_NAME` is a placeholder that you would replace with whatever you want to call your condition. Consider appending conditions with the name of your mod to make for better compatibility between mods, for example a condition name like `SPOKEN_TO` is very generic and might conflict with other mods whereas `NH_EXAMPLES_SPOKEN_TO_ERNESTO` is much less likely to conflict with another mod. + +### Persistent Condition + +A persistent condition is similar to a condition, except it _persists_ through loops, and is saved on the players save file. + +Persistent conditions shared many similar traits with regular dialogue conditions. You can use ``, ``. On dialogue options you can use ``, `` + +Persistent conditions can also be set in code with `PlayerData.SetPersistentCondition("PERSISTENT_CONDITION_NAME", true/false)` and read using `PlayerData.GetPersistentCondition("PERSISTENT_CONDITION_NAME")`. + +### Ship Logs + +Dialogue can interact with ship logs, either granting them to the player (`` on a DialogueNode) or locking dialogue behind ship log completion (`` on a DialogueOption). + +You can also use `` in tandem with `DialogueTarget` to make it so you must have a [ship log fact](/guides/ship-log#explore-facts) to go to the next node. + +## Adding to existing dialogue Here's an example of how to add new dialogue to Slate, without overwriting their existing dialogue. This will also allow multiple mods to all add new dialogue to the same character. @@ -221,8 +225,33 @@ To use this additional dialogue you need to reference it in a planet config file ] ``` +### ReuseDialogueOptionsListFrom + +This is a custom XML node introduced by New Horizons. Use it when adding new dialogue to existing characters, to repeat the dialogue options list from another node. + +For example, Slate's first dialogue with options is named `Scientist5`. To make a custom DialogueNode using these dialogue options (meaning new dialogue said by Slate, but reusing the possible player responses) you can write: + +```xml + + ... + + NEW DIALOGUE FOR SLATE HERE. + + + Scientist5 + + +``` + +Note: If you're loading dialogue in code, 2 frames must pass before entering the conversation in order for ReuseDialogueOptionsListFrom to take effect. + + ## Dialogue FAQ ### How do I easily position my dialogue relative to a speaking character Use `pathToAnimController` to specify the path to the speaking character (if they are a Nomai or Hearthian make sure this goes directly to whatever script controls their animations), then set `isRelativeToParent` to true (this is setting available on all NH props for easier positioning). Now when you set their `position`, it will be relative to the speaker. Since this position is normally where the character is standing, set the `y` position to match how tall the character is. Instead of `pathToAnimController` you can also use `parentPath`. + +### How do I have the dialogue prompt say "Read" or "Play recording" + +`` sets the name of the character, which will then show in the prompt to start dialogue. You can alternatively use `SIGN` to have the prompt say "Read", and `RECORDING` to have it say "Play recording". \ No newline at end of file diff --git a/docs/src/content/docs/guides/eye-of-the-universe.mdx b/docs/src/content/docs/guides/eye-of-the-universe.mdx new file mode 100644 index 00000000..0a02d614 --- /dev/null +++ b/docs/src/content/docs/guides/eye-of-the-universe.mdx @@ -0,0 +1,342 @@ +--- +title: Eye of the Universe +description: A guide to adding additional content to the Eye of the Universe with New Horizons +--- + +import { Aside } from "@astrojs/starlight/components"; + +This guide covers some 'gotchas' and features unique to the Eye of the Universe. + +## Extending the Eye of the Universe + +### Star System + +To define a Star System config for the Eye of the Universe, name your star system config file `EyeOfTheUniverse.json` or specify the `"name"` as `"EyeOfTheUniverse"`. Note that many of the star system features have no effect at the Eye compared to a regular custom star system. + +```json title="systems/EyeOfTheUniverse.json" +{ + "$schema": "https://raw.githubusercontent.com/Outer-Wilds-New-Horizons/new-horizons/main/NewHorizons/Schemas/star_system_schema.json", + "name": "EyeOfTheUniverse", + // etc. +} +``` + +### Existing Planet + +The existing areas of the Eye of the Universe, such as the "sixth planet" and funnel, the museum/observatory, the forest of galaxies, and the campfire, are all contained within one static "planet" (despite visually being distinct locations). To add to these areas, you'll need to specify a planet config file with a `"name"` of `"EyeOfTheUniverse"` and *also* a `"starSystem"` of `"EyeOfTheUniverse"`, as the star system and "planet" share the same name. + +```json title="planets/EyeOfTheUniverse.json" +{ + "$schema": "https://raw.githubusercontent.com/Outer-Wilds-New-Horizons/new-horizons/main/NewHorizons/Schemas/body_schema.json", + "name": "EyeOfTheUniverse", + "starSystem": "EyeOfTheUniverse", + "Props": { + "details": [ + // etc. + ] + }, + // etc. +} +``` + +### The Vessel + +You can also add props to the Vessel at the Eye: + +```json title="planets/Vessel.json" +{ + "$schema": "https://raw.githubusercontent.com/Outer-Wilds-New-Horizons/new-horizons/main/NewHorizons/Schemas/body_schema.json", + "name": "Vessel", + "starSystem": "EyeOfTheUniverse", + "Props": { + "details": [ + // etc. + ] + }, + // etc. +} +``` + +## Eye Travelers + +Eye Travelers are a special kind of detail prop (see [Detailing](/guides/details/)) that get added in as additional characters around the campfire at the end of the Eye sequence, similar to Solanum and the Prisoner in the vanilla game. + +At minimum, you will need a character object to act as the traveler, an audio file for the looping part of the new instrument track, an audio file to layer over the finale, a dialogue XML file with certain dialogue conditions set up, and a [quantum instrument](#quantum-instruments). + +The traveler will only appear once their quantum instrument is gathered. After that, they will appear in the circle around the campfire, and they can be interacted with through dialogue to start playing their instrument. The instrument audio is handled via a signal on the traveler that only becomes audible after talking to them. + +Custom travelers will automatically be included in the inflation animation that pushes everyone away from the campfire at the end of the sequence. + +[Eye Travelers](#eye-travelers), [Quantum Instruments](#quantum-instruments), and [Instrument Zones](#instrument-zones) are all linked by their `"id"` properties. Ensure that your ID matches between those details and is unique enough to not conflict with other mods. + +Here's an example config: +```json title="planets/EyeOfTheUniverse.json" +{ + "$schema": "https://raw.githubusercontent.com/Outer-Wilds-New-Horizons/new-horizons/main/NewHorizons/Schemas/body_schema.json", + "name": "EyeOfTheUniverse", + "starSystem": "EyeOfTheUniverse", + "EyeOfTheUniverse": { + "eyeTravelers": [ + { + "id": "Slate", + "signal": { + "name": "Slate", + "audio": "planets/MetronomeLoop.wav", + "detectionRadius": 0, + "identificationRadius": 10, + "onlyAudibleToScope": false, + "position": {"x": 0, "y": 0.75, "z": 0}, + "isRelativeToParent": true + }, + "finaleAudio": "planets/MetronomeFinale.wav", + "startPlayingCondition": "EyeSlatePlaying", + "participatingCondition": "EyeSlateParticipating", + "dialogue": { + "xmlFile": "planets/EyeSlate.xml", + "position": {"x": 0.0, "y": 1.5, "z": 0.0}, + "isRelativeToParent": true + }, + "path": "TimberHearth_Body/Sector_TH/Sector_Village/Sector_StartingCamp/Characters_StartingCamp/Villager_HEA_Slate" + } + ] + } +} +``` + +To see the full list of eye traveler properties and descriptions of what each property does, check [the EyeTravelerInfo schema](/schemas/body-schema/defs/eyetravelerinfo/). + + + +### Audio + +The looping audio clip is used for the signals emitted by the traveler and their quantum instrument, and is the audio that gets played when they play their instrument. It should be a WAV file with 16 measures of music at 92 BPM (exactly 2,003,478 samples at 48,000 Hz, or approximately 42 seconds long). It is highly recommended that you use Audacity or another audio editor to trim your audio clip to exactly the same length as one of the vanilla traveler audio clips, or else it will fall gradually out of sync with the other instrument loops. + +The finale audio clip is only played once, after all travelers have started playing. It should be 8 measures of the main loop at 92 BPM followed by 2 measures of fade-out (approximately 26 seconds long in total). Unlike the looping audio clip, it does not need to precisely match the length of the vanilla finale clip; it can end early or continue playing after the other ends. + +The game plays all of the looping audio clips (including your custom one) simultaneously once you tell the first traveler to start playing, and then fades them in one by one as you talk to the others. After all travelers are playing, the game selects a finale audio clip that contains all Hearthian and Nomai/Owlk instruments mixed into one file, and then your custom finale audio clip will be layered over whichever vanilla clip plays. Only include your own instrument in the clip, and ensure it sounds okay alongside Solanum, the Prisoner, and both/neither. + +### Dialogue + +The dialogue XML for your traveler works like other dialogue (see the [Dialogue](/guides/dialogue/) guide) but there are specially named conditions you will need to use for the functionality to work as expected: +- Use `AnyTravelersGathered` to check if any traveler has been gathered yet. This includes Riebeck and Esker, so it should always be true, unless you forcibly enable your traveler to be enabled early. +- Use `AllTravelersGathered` to check if all of the travelers have been gathered and are ready to start playing. +- Use `JamSessionIsOver` to check if the travelers have stopped playing the song and the sphere of possibilities has appeared. +- Use a `` with the condition defined in your eye traveler config's `"startPlayingCondition"` on the node or dialogue option that should make your traveler start playing their instrument. This condition name must be unique and not conflict with other mods. +- If you want your traveler to be present but have an option to not participate in the campfire song (like the Prisoner), use a `` with the condition defined in your eye traveler config's `"participatingCondition"` on the node or dialogue option where your traveler agrees to join in. This condition name must be unique and not conflict with other mods. + +```xml title="planets/dialogue/SlateEyeTraveler.xml" + + Slate, Probably + + WAITING_FOR_OTHERS + DEFAULT + + It's me, definitely Slate and not a dreamstalker in disguise. + + + + ANY_GATHERED + AnyTravelersGathered + + You still have other travelers to gather. + + + + You're going to join in, right? + PARTICIPATING + + + Okay then... + + + + + PARTICIPATING + + Sure. + + EyeSlateParticipating + + + READY_TO_PLAY + AllTravelersGathered + + We're all here. Time to start the music. + + + + Ready to go. + START_PLAYING + + + Not yet. + NOT_YET + + + + + START_PLAYING + + Let's begin. + + EyeSlatePlaying + + + NOT_YET + + Whenever you're ready. + + + + FAREWELL + JamSessionIsOver + + It's rewind time. + + + +``` + +### Custom Animation + +To add custom animations to your Eye Traveler, there is some setup work that has to be done in Unity. You will need to set up your character in Unity and load them via asset bundle, like you would any other detail: + +```json title="planets/EyeOfTheUniverse.json" +{ + "$schema": "https://raw.githubusercontent.com/Outer-Wilds-New-Horizons/new-horizons/main/NewHorizons/Schemas/body_schema.json", + "name": "EyeOfTheUniverse", + "starSystem": "EyeOfTheUniverse", + "EyeOfTheUniverse": { + "eyeTravelers": [ + { + "id": "MyCoolEyeGuy", + "assetBundle": "planets/bundles/eyeoftheuniverse", + "path": "Assets/EyeOfTheUniverse/Traveler/MyCoolEyeGuy.prefab" + } + ] + } +} +``` + +Next, create an Animator Controller asset in Unity with at least two states, named "Idle" and "PlayingInstrument". You can assign whatever animation clip you like to these states, but the names of the states must match exactly. The default Idle state will play when the traveler is first spawned in, and will transition to the PlayingInstrument state when the right conditions are met to start playing the instrument. Ensure that both animation clips are set to loop in their import settings. + +Add a boolean Parameter in the left panel named "Playing". This will be set to true when the traveler starts playing their instrument. + +Add a transition in both directions between the Idle and PlayingInstrument states. Uncheck "Has Exit Time" for both transitions and adjust the other timing settings as desired. + +Add a Condition on the `Idle -> PlayingInstrument` transition to check for `Playing` = `true`, and the inverse for `PlayingInstrument -> Idle`. + +![animController](@/assets/docs-images/eye_of_the_universe/animController.webp) + +In your character object, find the `Animator` component and set its `Controller` property to the Animator Controller asset you created. If you have a `TravelerEyeController` component in your object, set its `_animator` property to your Animator component. + +If everything was set up correctly, your character should play their animations in-game. + +## Quantum Instruments + +Quantum instruments are the interactible instruments, typically hidden by a short 'puzzle', that cause their corresponding traveler to appear around the campfire. They are just like any other detail prop (see [Detailing](/guides/details/)) but they have additional handling to only activate after gathering and speaking to Riebeck, like the other instrument 'puzzles' in the Eye sequence. + +If not specified, the quantum instrument will inherit some of the properties for its `"signal"` from the corresponding eye traveler config. + +If you want other objects besides the traveler to appear or disappear in response to gathering the instrument, specify a custom dialogue condition name for `"gatherCondition"` and use that same condition as the `"activationCondition"` or `"deactivationCondition"` for the details you want to toggle. + +Quantum instruments will automatically be included in the inflation animation that pushes everyone away from the campfire at the end of the sequence. + +[Eye Travelers](#eye-travelers), [Quantum Instruments](#quantum-instruments), and [Instrument Zones](#instrument-zones) are all linked by their `"id"` properties. Ensure that your ID matches between those details and is unique enough to not conflict with other mods. + +```json title="planets/EyeOfTheUniverse.json" +{ + "$schema": "https://raw.githubusercontent.com/Outer-Wilds-New-Horizons/new-horizons/main/NewHorizons/Schemas/body_schema.json", + "name": "EyeOfTheUniverse", + "starSystem": "EyeOfTheUniverse", + "EyeOfTheUniverse": { + "eyeTravelers": [ + { + "id": "Slate", + "signal": { + "name": "Slate", + "audio": "planets/MetronomeLoop.wav" + // etc. + }, + // etc. + } + ], + "quantumInstruments": [ + { + "id": "Slate", + "gatherWithScope": false, + "gatherCondition": "EyeSlateGather", + "path": "TimberHearth_Body/Sector_TH/Sector_Village/Sector_StartingCamp/Props_StartingCamp/OtherComponentsGroup/Props_HEA_CampsiteLogAssets/Props_HEA_MarshmallowCanOpened", + "position": {"x": -43.94369, "y": 0, "z": 7506.436}, + "signal": { + "detectionRadius": 0, + "identificationRadius": 10, + "position": {"x": 0, "y": 0.1, "z": 0}, + "isRelativeToParent": true + } + } + ] + } +} +``` + +To see the full list of quantum instrument properties and descriptions of what each property does, check [the QuantumInstrumentInfo schema](/schemas/body-schema/defs/quantuminstrumentinfo/). + +## Instrument Zones + +Instrument zones are just like any other detail prop (see [Detailing](/guides/details/)) but they have additional handling to only activate after gathering and speaking to Riebeck, like the other instrument 'puzzles' in the Eye sequence. + +Custom instrument zones will automatically be included in the inflation animation that pushes everyone away from the campfire at the end of the sequence. + +[Eye Travelers](#eye-travelers), [Quantum Instruments](#quantum-instruments), and [Instrument Zones](#instrument-zones) are all linked by their `"id"` properties. Ensure that your ID matches between those details and is unique enough to not conflict with other mods. + +```json title="planets/EyeOfTheUniverse.json" +{ + "$schema": "https://raw.githubusercontent.com/Outer-Wilds-New-Horizons/new-horizons/main/NewHorizons/Schemas/body_schema.json", + "name": "EyeOfTheUniverse", + "starSystem": "EyeOfTheUniverse", + "EyeOfTheUniverse": { + "eyeTravelers": [ + { + "id": "Slate", + // etc. + } + ], + "instrumentZones": [ + { + "id": "Slate", + "deactivationCondition": "EyeSlateGather", + "path": "TimberHearth_Body/Sector_TH/Sector_Village/Sector_StartingCamp/Props_StartingCamp/OtherComponentsGroup/Props_HEA_CampsiteLogAssets", + "removeChildren": [ + "Props_HEA_MarshmallowCanOpened" + ], + "position": {"x": -43.30302, "y": 0, "z": 7507.822} + } + ] + } +} +``` + +To see the full list of instrument zone properties and descriptions of what each property does, check [the InstrumentZoneInfo schema](/schemas/body-schema/defs/instrumentzoneinfo/). + +## Eye-Specific Considerations + +#### Cross-System Details + +Specifying details with a `"path"` pointing to an object in the regular solar system normally wouldn't work, as the Eye of the Universe lives in a completely separate scene from the rest of the game and those objects don't exist at the Eye. New Horizons works around this by force-loading the regular solar system, grabbing any objects referenced in Eye of the Universe config files, and then attempting to preserve these objects when loading the Eye of the Universe scene. This can cause issues with many different kinds of props, especially interactive ones that depend on some other part of the solar system existing. + +Because the objects are not available outside of this workaround, objects from the regular solar system cannot be spawned in via the New Horizons API. + +#### Custom Planets + +While you *can* define completely custom planets the same as you would in a regular custom solar system, they may exhibit weird orbital behaviors or pass through the existing static Eye of the Universe objects. Prefer adding onto the existing bodies or setting a `"staticPosition"` on your planet configs to lock them in place. + +#### The Player Ship and Ship Logs + +The player's ship does not exist in the Eye of the Universe scene. In addition to the obvious issues this causes (no access to the ship's warp functionality, ship spawn points being non-functional, etc.), the ship log computer not existing causes some methods of checking and learning ship log facts to not function at all while at the Eye. If you need to track whether the player has met certain conditions elsewhere in the game (for example, if they've previously met a character that you now want to appear at the campfire), consider using a Persistent Dialogue Condition, which does not have these issues. + +#### Mod Compatibility + +Other existing and future story mods will want to add additional content to the Eye of the Universe, and unlike entirely custom planets and star systems, there is a high probability that objects placed at the Eye may overlap with those placed by other mods. When testing, try installing as many of these other mods as possible and seeing if the objects they add overlap with yours. If so, consider moving your objects to a different position. When possible, use New Horizons features that preserve compatibility between mods. \ No newline at end of file diff --git a/docs/src/content/docs/guides/nomai-text.md b/docs/src/content/docs/guides/nomai-text.md new file mode 100644 index 00000000..959aeeb4 --- /dev/null +++ b/docs/src/content/docs/guides/nomai-text.md @@ -0,0 +1,26 @@ +--- +title: Nomai Text +description: Guide to making Nomai Text in New Horizons +--- + +This page goes over how to use Nomai text in New Horizons. + +## Understanding Nomai Text + +Nomai text is the backbone of many story mods. There are two parts to setting up Nomai text: The XML file and the planet config. + +### XML + +In your XML, you define the actual raw text which will be displayed, the ship logs it unlocks, and the way it branches. See [the Nomai text XML schema](/schemas/text-schema/) for more info. + +Nomai text contains a root `` node, followed by `` nodes and optionally a `` node. + +Nomai text is made up of `TextBlock`s. Each text block has an `ID` which must be unique (you can just number them for simplicity). After the first defined text block, each must have a `ParentID`. For scrolls and regular wall text, the text block only gets revealed after its parent block. Multiple text blocks can have the same parent, allowing for branching paths. In recorders and computers, each text block must procede in order (the second parented to the first, the third to the second, etc). In cairns, there is only one text block. + +To unlock ship logs after reading each text block, add a `` node. This can contains multiple `` nodes, each one defining a ``, ``. The ship log conditions node can either have `` or ``, which means the logs will unlock only if you are at that location. The `` lists the TextBlock ids which must be read to reveal the fact as a comma delimited list (e.g., `1,2,4`).. + +### Json + +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. \ No newline at end of file diff --git a/docs/src/content/docs/guides/planet-generation.md b/docs/src/content/docs/guides/planet-generation.md index 4310b55a..241138e8 100644 --- a/docs/src/content/docs/guides/planet-generation.md +++ b/docs/src/content/docs/guides/planet-generation.md @@ -90,6 +90,9 @@ This makes the second planet a quantum state of the first, anything you specify } ``` +Keep in mind that if you redefine `Orbit` on all configs (even with the same parameters each time), **the planet will change its position within its orbit when changing states.** +*If you want your Quantum Planet's position to* ***NOT*** *change,* ***only define `Orbit` on the main state***. + ## Barycenters (Focal Points) To create a binary system of planets (like ash twin and ember twin), first create a config with `FocalPoint` set diff --git a/docs/src/content/docs/guides/publishing.md b/docs/src/content/docs/guides/publishing.md index dc1dba39..c1ab4204 100644 --- a/docs/src/content/docs/guides/publishing.md +++ b/docs/src/content/docs/guides/publishing.md @@ -14,6 +14,7 @@ Before you release anything, you'll want to make sure: - Your repo has the description field (click the cog in the right column on the "Code" tab) set. (this will be shown in the manager) - There's no `config.json` in your addon. (Not super important, but good practice) - Your manifest has a valid name, author, and unique name. +- You have included any caches New Horizons has made (i.e., slide reel caches). Since these are made in the install location of the mod you will have to manually copy them into the mod repo and ensure they stay up to date. While these files are not required, they ensure that your players will have faster loading times and reduced memory usage on their first loop (after which the caches will generate for them locally). ## Releasing