Jump to content


  • Posts

  • Joined

  • Last visited

Everything posted by xEvilReeperx

  1. You can use the same general idea. In this case, it's easier to straight hijack the live button once it exists. Most of the effort of this is figuring out how to get a reference to the thing you want (conveniently, RDController.Instance.actionButton) and how to manipulate it in a way that doesn't break stock code. Here is some prototype code to get you started: /* // RDController.Instance.nodePanel Panel_Right has components: ...c: UnityEngine.RectTransform ...c: KSP.UI.UIStatePanel ->Panel node has components: <-- this is the RnD panel (there are two panels in this GUI: RnD and archives) ....c: UnityEngine.RectTransform ....c: UnityEngine.CanvasRenderer ....c: UnityEngine.UI.Image -->Button has components: .....c: UnityEngine.RectTransform .....c: UnityEngine.CanvasRenderer .....c: UnityEngine.UI.Image .....c: UnityEngine.UI.Button <-- this fires clicks .....c: KSP.UI.UIStateButton <-- this listens to Button and is involved in the visual state of the button --->Text has components: ......c: UnityEngine.RectTransform ......c: UnityEngine.CanvasRenderer ......c: TMPro.TextMeshProUGUI <-- you can use this to manipulate the button text ---->TMP UI SubObject [TextMeshPro/Sprite] has components: <-- I assume this has something to do with the science graphic in the button text .......c: UnityEngine.RectTransform .......c: UnityEngine.CanvasRenderer .......c: TMPro.TMP_SubMeshUI */ [KSPAddon(KSPAddon.Startup.SpaceCentre, true /* run once */)] class ConditionalResearchButtonSetup : MonoBehaviour { private void Start() { RDController.OnRDTreeSpawn.Add(i => AddButton(i)); // EventData doesn't seem to like non-static methods Destroy(gameObject); } // inserts conditional button into RnD instance private static void AddButton(RDController instance) { instance.actionButton.gameObject.AddComponent<ConditionalResearchButton>(); } } // Conditional research button. When player clicks a technology, fires a message // allowing recipients to deny player access to the button // // Also adds confirmation dialog as an example of how you can intercept an event entirely class ConditionalResearchButton : MonoBehaviour { public class Request { public RDNode node; public bool allowed; public void Deny() { allowed = false; } } public static EventData<Request> OnResearchButtonPresented = new EventData<ConditionalResearchButton.Request>("OnResearchButtonPresented"); private Image _buttonImage; private Button _button; private Button.ButtonClickedEvent _originalEvent; private void Awake() { // Start is too slow to grab these references if we want to use OnEnable _buttonImage = GetComponent<Image>(); _button = GetComponent<Button>(); // hijack button so we can decide whether it's been "clicked" _originalEvent = _button.onClick; _button.onClick = new Button.ButtonClickedEvent(); _button.onClick.AddListener(OnButtonClicked); // the button we have is the "raw" button, but a lot of state-driven buttons // have another class controlling visual states. RDController sets the // state each time the panel changes for some reason, which is a good enough // way for us to stay informed about selection changes GetComponent<UIStateButton>().onValueChanged.AddListener(_ => UpdateButtonStatus()); } private void OnEnable() { UpdateButtonStatus(); } // if the button is used for research, decide if the player can click it private void UpdateButtonStatus() { // don't mess with the button if it's not a research button (seems to serve dual purposes?) // otherwise, if it's a research button, decide if it should be enabled ToggleButton(!IsNodeResearchable() || AllowResearch()); } private void ToggleButton(bool enable) { _button.enabled = enable; // color difference to clarify button status _buttonImage.color = enable ? Color.white : Color.red; } private void OnButtonClicked() { // here you could decide not to call original listeners // as an example, I'll add a confirmation window if (IsNodeResearchable()) { PopupDialog.SpawnPopupDialog( new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new MultiOptionDialog( "ConfirmResearchDialog", $"Are you sure you want to purchase {RDController.Instance.node_selected.tech.title}?", "Confirm?", UISkinManager.GetSkin("KSP window 7"), new DialogGUIButton("Yes", UserConfirmedPurchase, true), new DialogGUIButton("No", () => { }, true)), false, UISkinManager.GetSkin("KSP window 7")); } else { _originalEvent.Invoke(); // not a research event } } // user confirmed they want to research whatever it is that's active private void UserConfirmedPurchase() { _originalEvent.Invoke(); // just as though user clicked the button themselves } // user can select already-researched nodes. We don't care about those private bool IsNodeResearchable() { var node = RDController.Instance.node_selected; return node != null && node.state == RDNode.State.RESEARCHABLE; } // fires event to decide if the button is enabled private bool AllowResearch() { var request = new Request { node = RDController.Instance.node_selected, allowed = true }; OnResearchButtonPresented.Fire(request); return request.allowed; } } // An addon that wants to control whether or not the player can research something [KSPAddon(KSPAddon.Startup.SpaceCentre, false)] class LetsStayLowTech : MonoBehaviour { private void Start() { ConditionalResearchButton.OnResearchButtonPresented.Add(ShouldWeAllowIt); } private void OnDestroy() { ConditionalResearchButton.OnResearchButtonPresented.Remove(ShouldWeAllowIt); } private void ShouldWeAllowIt(ConditionalResearchButton.Request request) { // nobody gets to use techs that cost more than 50 science // note: might need to apply game modifiers here if (request.node.tech.scienceCost > 50f) request.Deny(); } }
  2. Don't mess with the original models in GameDatabase. Instantiate a new GameObject using them instead or you'll blow things up later. I would be surprised if your first log was working; GetComponentInChildren only returns a component on active GameObjects (unless you use one of its overloads) and none of the model stuff should be active
  3. I've put together a bare-bones example that does what you want. Code: using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using UnityEngine; using KSPAssets; namespace CargoLoader { [DatabaseLoaderAttrib(new[] { "cargo" })] public class CargoLoader : DatabaseLoader<GameObject> { private readonly List<AssetBundle> _loadedBundles = new List<AssetBundle>(); private void Failed(string reason) { successful = false; Debug.LogError(reason); } public override IEnumerator Load(UrlDir.UrlFile urlFile, FileInfo file) { // load the AssetBundle var bundleRequest = AssetBundle.LoadFromFileAsync(file.FullName); yield return bundleRequest; var bundle = bundleRequest.assetBundle; if (bundle == null) { Failed("Unable to load: " + urlFile.url); yield break; } _loadedBundles.Add(bundle); // use manifest to determine URLs of models // // -> first we need to find it. It's probably named the same as the // bundle, but let's not rely on that in case it gets renamed var manifest = bundle.GetAllAssetNames().FirstOrDefault( assetName => assetName.EndsWith("_bundle.xml")); if (string.IsNullOrEmpty(manifest)) { Failed("Could not find manifest inside " + urlFile.url); foreach (var name in bundle.GetAllAssetNames()) { Debug.Log(" name: " + name); } yield break; } var manifestRequest = bundle.LoadAssetAsync<TextAsset>(manifest); yield return manifestRequest; if (manifestRequest.asset == null) { Failed("Failed to load manifest: " + manifest); yield break; } var xmlManifest = (TextAsset)manifestRequest.asset; var bundleDefinition = BundleDefinition.CreateFromText(xmlManifest.text); bundleDefinition.path = urlFile.fullPath; foreach (var def in bundleDefinition.GetAssetsWithType(typeof(GameObject))) { var assetRequest = bundle.LoadAssetAsync<GameObject>(def.name); yield return assetRequest; var asset = assetRequest.asset as GameObject; if (asset == null) { Debug.LogWarning(string.Format("Failed to load asset '{0}' from {1}", def.name, urlFile.url)); // don't quit here: maybe there are multiple assets in this bundle } // two options here: use the Unity path, or just take the name and generate // a URL using the path of the bundle. Latter option makes more sense to me // note: this means the name of the bundle is used within the part's url // example: GameData/myfolder/mybundle.cargo has two parts, part1 and part2, inside it // you would reference these parts in a config as myfolder/mybundle/part1 var name = Path.GetFileNameWithoutExtension(def.name); var url = urlFile.parent.url + "/" + urlFile.name + "/" + name; asset.name = url; asset.transform.parent = GameDatabase.Instance.transform; GameDatabase.Instance.databaseModel.Add(asset); // this next line might be a problem if you have multiple models in one bundle // unsure when and where it's used GameDatabase.Instance.databaseModelFiles.Add(urlFile); Debug.Log("Loaded " + url + " from " + urlFile.url); } } public override void CleanUp() { base.CleanUp(); foreach (var bundle in _loadedBundles) bundle.Unload(false); } } } This will load all prefab GameObjects from a KSP bundle created with PartTools (renamed with .cargo extension) and make them available for use from MODEL nodes inside part configs. It does nothing extra or fancy (no dependency resolution) but just cramming everything into one bundle is convenient and ensures resources are shared. The bundle I've provided contains two prefabs named Cube and Sphere. Cube uses a custom shader I slapped together. Sphere uses one of the shaders from PartTools. Both have a shader property driven by an animation. You refer to these in the same way that you would refer to a mu file, with the exception that the bundle is part of the path. For example, if you have the bundle at GameData/mystuff/mybundle.cargo, you would refer to the assets inside using a config file like this: PART { // ... MODEL { model = mystuff/mybundle/Sphere // ... } // ... }
  4. The game scans for all DatabaseLoaderAttrib-decorated types and uses them to load files from GameData directory. You can use this to have the game do stuff with your own files. I recommend you choose your own extension (it can be whatever you like that isn't already taken). It looks like ShadowMage needed to manipulate the materials, and uses that list to keep track of materials that have already been adjusted. My prototype didn't need to do this because I stored everything (textures and materials) inside the asset bundles themselves, so it all just works. RE your concern of multiple-named files: an advantage of asset bundles is that they can share this information already. That's why the XML definitions might be useful to you. You could put all the shared data into its own bundle, and as long as this bundle is loaded, textures and other assets will be shared. There isn't any technical reason you couldn't load lots of models or whatever else at a time from the bundle. That would definitely be the simplest, most straight-forward way to handle shared dependencies though: throw all your stuff into one, single file.
  5. Did you use the code from Shadowmage's post? In that version, the file extension should be 'smf' (although you should probably choose your own extension and edit the code accordingly). You also don't need to use the KSP asset compiler, although it shouldn't be a problem if you do
  6. You don't put the .mu model into the bundle. You put the actual GameObject that you've set up (as a prefab) in there instead, bypassing the .mu creation step entirely. This asset bundle is essentially a mu file now, except it uses Unity's serialization instead of Squad's hand-rolled stuff that is missing lots of features. Everything else behaves exactly the same as if you were using a mu file -- define your cfg in exactly the same way. By the time the PartLoader is creating parts out of the loaded models, all of that stuff has been run and shaders are in-place.
  7. That method will work (for your shader problem). What exactly is the issue? The Shader.Find API apparently requires that the shaders exist at project build time, which is why they aren't found when the PartLoader is creating materials. That's why I suggest you bypass PartLoader entirely. If you put your shader AND model into an asset bundle and then use the previously posted code to load that as though it was a model, you won't be restricted to the extremely limited stuff PartLoader does. The shaders are all in squadcore.ksp. squadcorefx contains TextMeshPro shaders. You can inspect these using the Asset Bundle Browser, a free Unity asset you can download from their store
  8. You shouldn't even need a loader for the shader. Make sure it's stored inside the AssetBundle itself and Unity should do the rest
  9. I haven't been up with the latest stuff so I'm not sure if it's still functional, but the limitations of the mu loader are exactly why I loaded certain assets as asset bundles instead. @Shadowmage has a version posted, in fact: If you make sure your textures are included in the bundle, you shouldn't even need any post-loading stuff
  10. It doesn't matter for the Canvas' children, but it does for the Canvas itself. Also, the Canvas appears to copy the camera's transform and rotation which means the position and orientation of the Image you're adding are relevant. Here's a fixed version: private void Start() { GameObject canvasGO = new GameObject("censorcanvas"); canvasGO.layer = 0; // must be in camera's culling mask canvasGO.AddComponent<RectTransform>(); canvas = canvasGO.AddComponent<Canvas>(); canvas.renderMode = RenderMode.ScreenSpaceCamera; // UI elements do not show up if we use the local space camera Camera flightCam = FlightCamera.fetch.mainCamera; canvas.worldCamera = flightCam; canvas.planeDistance = flightCam.nearClipPlane + 0.5f; canvas.pixelPerfect = true; CanvasScaler canvasScalar = canvasGO.AddComponent<CanvasScaler>(); canvasScalar.uiScaleMode = CanvasScaler.ScaleMode.ConstantPixelSize; canvasGO.AddComponent<GraphicRaycaster>(); GameObject panelGO = new GameObject("censorbar"); panelGO.layer = LayerMask.NameToLayer("UI"); panel = panelGO.AddComponent<RectTransform>(); panelGO.transform.SetParent(canvasGO.transform, false); // gets moved Image censorbar = panelGO.AddComponent<Image>(); Texture2D tex = GameDatabase.Instance.GetTexture("Harm/harm", false); censorbar.sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f)); panel.anchorMin = new Vector2(0, 0); panel.anchorMax = new Vector2(0, 0); panel.pivot = new Vector2(0.5f, 0.5f); panel.sizeDelta = new Vector2(tex.width, tex.height); } private void LateUpdate() { // this is the nearer of the two local space cameras Camera cam = FlightCamera.fetch.mainCamera; // make image track kerbal position on screen Vector2 screenPoint = (Vector2)cam.WorldToScreenPoint(transform.position); panel.anchoredPosition = screenPoint; // eventual goal is to also put image just in front of kerbal so it gets occluded by other closer objects // canvas.planeDistance = Vector3.Dot((transform.position - cam.transform.position), cam.transform.forward) - 0.5; canvas.planeDistance = Vector3.Distance(transform.position, cam.transform.position); }
  11. But why? Here's what I meant: private class JellyListener : MonoBehaviour { private JellyfishDishMk2 _jellydish; // your PartModule here private void Awake() { _jellydish = GetComponentInParent<JellyfishDishMk2>(); if (_jellydish == null) Debug.LogError("Didn't find expected Jellyfish PM"); } private void FirstAnimationFrame(UnityEngine.Object param) { var pm = param as JellyfishDishMk2; if (pm != null) pm.PlayStartingSound(); } // alternative method: note the lack of argument private void LastAnimationFrame() { _jellydish.PlayEndingSound(); } } // add events at beginning and end jellyfish.clip.AddEvent(new AnimationEvent() { time = 0f, functionName = "FirstAnimationFrame", objectReferenceParameter = this // note that any UnityEngine.Object works here: we could pass a sound, texture, ScriptableObject, MonoBehaviour, ... }); jellyfish.clip.AddEvent(new AnimationEvent() { time = jellyfish.length, functionName = "LastAnimationFrame" // note didn't use objectRef here, see listener method });
  12. While you could do that (any UnityEngine.Object or derived class ... like PartModule or MonoBehaviour), you don't need to. You already know exactly where your data is. You could cache the PartModule or wherever your data is being kept in whatever script you have as an event listener and call methods directly
  13. This kind of error can be a real PITA to debug, but in this case it's correct. The specific problem (at least, in my test bench of just downloading KAS 1.2 + current version of KOS) is that SaveUpgradePipeline.KAS.PatchFilesProcessor fails to load. This happens because it doesn't implement an abstract method. Between 1.6 and 1.7, UpgradeScript.OnUpgrade's signature changed. I didn't check that other mod you mentioned but I imagine something similar is going on. I don't think there's a 1.7 version of KAS yet or else this would be caught already
  14. Are you 100% sure it exported successfully? That looks like it might be the result of an incomplete model. Delete, re-export, then examine Unity console for any exceptions.
  15. You don't need ref or out here. Just use isDragging directly in the lambda functions; C# will create a closure over it and you effectively have a reference to it (although secretly the compiler creates a private inner class which is how it pulls this off). By the way, you probably don't want to be inserting triggers in OnUpdate(): every time the slider value changes, you add another set of triggers
  16. Something sounds funny in your explanation. If you want everything to stay in one function, what are you outputting to? Why does the float need to be passed by reference? Changing it won't make any difference, you'll want to call a method on the slider itself to change it anyways or you might break state stuff. Assuming "which" slider is the problem (and you REALLY wanted to keep it all in one function [why?]), you could always do something like this: private enum WhichSlider // or however you want to identify it, right down to calling different functions { First, Second, Third } // embed which slider is clicked into another anon function private void Foo() { DialogGUISlider slider1; // = whatever DialogGUISlider slider2; DialogGUISlider slider3; // can make an inner func for logic... Action<float, WhichSlider> lambdaSliderChanged = (f, which) => { print("Slider " + which + " is now " + f); }; slider1.slider.onValueChanged.AddListener(val => { lambdaSliderChanged(val, WhichSlider.First); }); slider2.slider.onValueChanged.AddListener(val => { lambdaSliderChanged(val, WhichSlider.Second); }); slider3.slider.onValueChanged.AddListener(val => { lambdaSliderChanged(val, WhichSlider.Third); }); }
  17. Can you be more specific about what you're trying to do? There isn't any code related to the part's appearance itself before PartModules start getting loaded. The part prefab is instantiated as is and then PartModules do the thing, one of which may be to modify the part's appearance. Why can't you modify the part inside OnLoad? I'm fairly sure that fires in the same frame as when the part itself is created, so changing part appearance there is fine and the user will never notice
  18. The structure is destroyed because you're not really saving the original contents of the file your ConfigNode comes from. ConfigNodes loaded by the game are all wrapped in a root ConfigNode that looks like this: root { KERBALCHANGELOG { ... } } This is so you can have multiple "inner nodes" in one cfg. You want to save the file itself to keep this structure, using cfgDir.parent.SaveFile()
  19. AudioClip clip = GameDatabase.Instance.GetAudioClip(gdb_path); clip.name = name; // <----- bad plan planetMap.Add(name, clip); fileNames.Add(clip, name); I haven't tried running your code but at a glance this stands out. Usually the GameDatabase names are the GameData-relative URLs, so on your first run this clip will likely be named something like SoundsOfSpace/Sounds/SoundsOfSpace/yourfile After successfully finding this clip the first time, you inexplicably rename it to yourfile.ogg. This will be a problem because GameDatabase.Instance.ExistsAudioClip is checking the clip names for a match
  20. This is precisely why I won't incorporate the work of others into my own stuff: a large number of these licenses have either been poisoned by unwritten rules or do not reflect the actual intent of the owner who has copy-pasted it from somewhere, and so they might as well be All Rights Reserved. Secret, unwritten rules are not transparency. If I look at a license that says YOU MAY DO ANYTHING WITH THIS WORK and think, gee, I'll have to make sure that's true, then the license is incomplete at best and useless at worst.
  21. You've nearly got it. Your issue is that your window rect is in screen space (0,0 = top left) while ReadPixels (and CopyTexture) expects pixel space (0,0 = bottom left) Convert to pixel space by inverting the y coordinate (Screen.height - y) and subtracting out the window height to get the bottom-left corner of the window [KSPAddon(KSPAddon.Startup.MainMenu, false)] class CaptureWindowScreenshot : MonoBehaviour { private Rect _windowRect = new Rect(Screen.width * 0.5f, Screen.height * 0.5f - 200, 200f, 50f); private readonly GUILayoutOption[] _defaultOptions = new GUILayoutOption[0]; private GUI.WindowFunction _windowFunc; private bool _capture = false; private void Awake() { _windowFunc = DrawWindow; } private void OnGUI() { _windowRect = KSPUtil.ClampRectToScreen(GUILayout.Window(GetInstanceID(), _windowRect, _windowFunc, "Test Window", _defaultOptions)); if (_capture && Event.current.type == EventType.Repaint) { _capture = false; StartCoroutine(Capture()); } } private IEnumerator Capture() { yield return new WaitForEndOfFrame(); var pixelRect = _windowRect; // copy values pixelRect.y = Mathf.Max(Screen.height - _windowRect.y - _windowRect.height, 0f); var captureTex = new Texture2D(Mathf.CeilToInt(pixelRect.width), Mathf.CeilToInt(pixelRect.height)); captureTex.ReadPixels(pixelRect, 0, 0, false); captureTex.SaveToDisk("window.png"); // your preferred method here Destroy(captureTex); } private void DrawWindow(int winid) { for (int i = 0; i < 5; ++i) GUILayout.Label("Text goes here", _defaultOptions); GUILayout.Space(5f); if (GUILayout.Button("Capture this window", _defaultOptions)) _capture = true; GUI.DragWindow(); } }
  22. From easiest to most convoluted: Cheat by implementing a similarly-named KSPEvent (with different method name) and abusing GameObject.SendMessage Reimplement the method yourself (VesselRenameDialog looks interesting) Fix the cached reflected delegate data yourself. This will make things "just work", but it's a little hacky [KSPAddon(KSPAddon.Startup.MainMenu, true)] class FixSetVesselNamingIssue : MonoBehaviour { class SneakyWayIntoCachedReflectedData : BaseEventList { public SneakyWayIntoCachedReflectedData(Type nobodyCares) : base(nobodyCares) { EnsureDataExists<Part>(); // if ALL modules changed, might not be any cached data for Part EnsureDataExists<PartTapIn>(); // it's possible some MM failure didn't edit the parts as expected, so confirm here too // "reflectedAttributeCache" is part of BaseEventList and is why we derive from it -> to get access so // we can tweak it var partData = reflectedAttributeCache[typeof(Part)]; // <-- this has an event we want to copy var tapinData = reflectedAttributeCache[typeof(PartTapIn)]; // <-- this is where we'll copy it to // unfortunately the only way to tell the various attributes apart // is to examine their guiName, which is localized #autoLOC_8003140 var targetLocalizedName = Localizer.Format("#autoLOC_8003140"); var configureNamingAttr = partData.eventAttributes.Single(kspe => kspe.guiName.Equals(targetLocalizedName, StringComparison.Ordinal)); if (tapinData.eventAttributes.Any( kspe => kspe.guiName.Equals(targetLocalizedName, StringComparison.Ordinal))) { return; // clearly changes already made } // insert attribute into PartTapIn data tapinData.eventAttributes.Add(configureNamingAttr); // copy "SetVesselNaming" delegate from Part to PartTapIn // this'll work because it's technically part of PartTapIn as well // note: method name directly, no localization needed here var setVesselNamingFunc = partData.eventDelegates.Single(mi => "SetVesselNaming".Equals(mi.Name)); tapinData.eventDelegates.Add(setVesselNamingFunc); } private static void EnsureDataExists<T>() where T : MonoBehaviour { if (reflectedAttributeCache.ContainsKey(typeof(T))) return; // already cached // if we create an inactive GameObject, we can prevent stuff from triggering // (awake, start) var dummyGo = new GameObject("dummy"); dummyGo.SetActive(false); // suppress awake, start var target = dummyGo.AddComponent<T>(); var dummy = new BaseEventList(target); // force data to be cached UnityEngine.Object.Destroy(dummyGo.gameObject); } } private void Start() { try { new SneakyWayIntoCachedReflectedData( typeof(Part)); // we literally don't care about anything but getting this constructor to run } catch (Exception e) { Debug.LogError("Exception while trying to meddle with cached data: " + e); } finally { Destroy(gameObject); // done } } }
  23. The exception in the log was the first clue. I looked at the code and didn't see any reason why that would prevent a simple print statement made in Update from working, so I wrote another small addon that checked out KeyNode's state. There were three possibilities as to why Update wasn't running: the component had been destroyed due to the uncaught exception in Awake. I've never seen this before, but thought it could be something AddonLoader does on an unhandled exception the GameObject wasn't active the MonoBehaviour was disabled (by Unity?) Turns out #3 was your problem
  24. It throws an uncaught exception in Awake and its MonoBehaviour ends up getting disabled (MonoBehaviour.enabled = false) when initializing for the second+ time. That's why you don't see any output from Update on subsequent flight scenes ArgumentException: An element with the same key already exists in the dictionary. System.Collections.Generic.Dictionary`2[UnityEngine.KeyCode,Callback].Add (KeyCode key, .Callback value) KeyNode.KeyNode.Awake () UnityEngine.GameObject:AddComponent(Type) AddonLoader:StartAddon(LoadedAssembly, Type, KSPAddon, Startup) AddonLoader:StartAddons(Startup) AddonLoader:OnLevelLoaded(GameScenes) AddonLoader:OnSceneLoaded(Scene, LoadSceneMode) UnityEngine.SceneManagement.SceneManager:Internal_SceneLoaded(Scene, LoadSceneMode)
  25. What does the hierarchy of your part look like? Based on the stacktrace and name, I'd bet there's a bug in StrippedTaggedTransforms. Try making sure whatever element is tagged with Icon_Only is the very last child of its parent, wherever the tag appears
  • Create New...