diff --git a/S1API/Entities/Player.cs b/S1API/Entities/Player.cs index 1c3b9da7..40cddacb 100644 --- a/S1API/Entities/Player.cs +++ b/S1API/Entities/Player.cs @@ -11,8 +11,10 @@ using System.Linq; using System.Reflection; using HarmonyLib; +using S1API.Avatar; using S1API.Entities.Interfaces; using S1API.Internal.Abstraction; +using S1API.Items; using S1API.Law; using UnityEngine; using S1API.Vehicles; @@ -273,6 +275,64 @@ public PropertyWrapper? LastVisitedProperty { /// public object CurrentAvatarSettings => S1Player.CurrentAvatarSettings; + /// + /// Retrieves the player's current avatar settings as an S1API wrapper. + /// Returns a new instance on each call when S1Player.CurrentAvatarSettings is available. + /// + public BasicAvatarSettings? GetCurrentBasicAvatarSettings() => + S1Player.CurrentAvatarSettings == null + ? null + : new BasicAvatarSettings(S1Player.CurrentAvatarSettings); + + /// + /// Inserts clothing into the matching player clothing slot. + /// + public void InsertClothing(ClothingItemInstance clothing) + { + if (clothing == null) + { + throw new ArgumentNullException(nameof(clothing)); + } + + S1Player.Clothing.InsertClothing(clothing.S1ClothingInstance); + } + + /// + /// Creates clothing with the definition's default color and inserts it into the matching player clothing slot. + /// + /// The clothing item definition to equip. + /// The clothing instance that was inserted. + public ClothingItemInstance EquipClothing(ClothingItemDefinition definition) + { + if (definition == null) + { + throw new ArgumentNullException(nameof(definition)); + } + + ClothingItemInstance clothing = definition.CreateInstance(definition.DefaultColor); + InsertClothing(clothing); + return clothing; + } + + /// + /// Refreshes the player's avatar from the current clothing slots. + /// + public void RefreshClothingAppearance() => + S1Player.Clothing.RefreshAppearance(); + + /// + /// Sends updated appearance settings through the game's native appearance flow. + /// + public void SendAppearance(BasicAvatarSettings settings) + { + if (settings == null) + { + throw new ArgumentNullException(nameof(settings)); + } + + S1Player.SendAppearance(settings.S1BasicAvatarSettings); + } + /// /// Revives the player. /// @@ -318,6 +378,15 @@ public event Action OnDeath remove => EventHelper.RemoveListener(value, S1Player.Health.onDie); } + /// + /// Called when the player revives. + /// + public event Action OnRevive + { + add => EventHelper.AddListener(value, S1Player.Health.onRevive); + remove => EventHelper.RemoveListener(value, S1Player.Health.onRevive); + } + private static readonly HashSet InvinciblePlayers = new HashSet(); internal static bool IsPlayerInvincible(S1PlayerScripts.Player player) => InvinciblePlayers.Contains(player); diff --git a/S1API/Items/ClothingItemCreator.cs b/S1API/Items/ClothingItemCreator.cs index 546d79ea..320a3993 100644 --- a/S1API/Items/ClothingItemCreator.cs +++ b/S1API/Items/ClothingItemCreator.cs @@ -14,7 +14,7 @@ namespace S1API.Items { /// /// Provides convenient static methods for creating custom clothing items. - /// Use for flexible configuration or for variants. + /// Use for flexible configuration or for variants. /// public static class ClothingItemCreator { diff --git a/S1API/Items/ClothingItemDefinition.cs b/S1API/Items/ClothingItemDefinition.cs index eb816233..b6b25d52 100644 --- a/S1API/Items/ClothingItemDefinition.cs +++ b/S1API/Items/ClothingItemDefinition.cs @@ -15,8 +15,8 @@ namespace S1API.Items /// Extends with clothing-specific properties. /// /// - /// Use to create new clothing items, - /// or to create variants of existing items. + /// Use to create new clothing items, + /// or to create variants of existing items. /// public sealed class ClothingItemDefinition : StorableItemDefinition { @@ -34,6 +34,34 @@ internal ClothingItemDefinition(S1Clothing.ClothingDefinition definition) /// internal S1Clothing.ClothingDefinition S1ClothingDefinition { get; } + /// + /// Creates a clothing instance from this definition using the default color. + /// + /// The quantity to apply to the created clothing instance. + /// A clothing item instance using this definition's default color. + public override ItemInstance CreateInstance(int quantity = 1) => + CreateInstance(quantity, DefaultColor); + + /// + /// Creates a clothing instance from this definition with the specified color. + /// + /// The clothing color to apply to the created instance. + /// A clothing instance using the specified color. + public ClothingItemInstance CreateInstance(ClothingColor color) => + CreateInstance(1, color); + + /// + /// Creates a clothing instance from this definition with the specified quantity and color. + /// + /// The quantity to apply to the created clothing instance. + /// The clothing color to apply to the created instance. + /// A clothing instance using the specified quantity and color. + public ClothingItemInstance CreateInstance(int quantity, ClothingColor color) => + new ClothingItemInstance(new S1Clothing.ClothingInstance( + S1ClothingDefinition, + quantity, + (S1Clothing.EClothingColor)color)); + /// /// The clothing slot this item occupies. /// diff --git a/S1API/Items/ClothingItemDefinitionBuilder.cs b/S1API/Items/ClothingItemDefinitionBuilder.cs index e6bb6f02..506bad10 100644 --- a/S1API/Items/ClothingItemDefinitionBuilder.cs +++ b/S1API/Items/ClothingItemDefinitionBuilder.cs @@ -3,16 +3,20 @@ using S1ItemFramework = Il2CppScheduleOne.ItemFramework; using S1CoreItemFramework = Il2CppScheduleOne.Core.Items.Framework; using S1Registry = Il2CppScheduleOne.Registry; +using S1UiItems = Il2CppScheduleOne.UI.Items; using Il2CppCollections = Il2CppSystem.Collections.Generic; #elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX) using S1Clothing = ScheduleOne.Clothing; using S1ItemFramework = ScheduleOne.ItemFramework; using S1CoreItemFramework = ScheduleOne.Core.Items.Framework; using S1Registry = ScheduleOne.Registry; +using S1UiItems = ScheduleOne.UI.Items; using Il2CppCollections = System.Collections.Generic; #endif using System.Collections.Generic; +using S1API.Internal.Utils; +using S1API.Logging; using UnityEngine; namespace S1API.Items @@ -23,6 +27,11 @@ namespace S1API.Items /// public sealed class ClothingItemDefinitionBuilder { + private static readonly Log Logger = new Log("ClothingItemDefinitionBuilder"); + private static readonly HashSet WarnedMissingNativeClothingItemUiReasons = new HashSet(); + private static readonly object WarnedMissingNativeClothingItemUiLock = new object(); + private static S1UiItems.ItemUI? s_cachedNativeCustomItemUI; + private readonly S1Clothing.ClothingDefinition _definition; /// @@ -33,7 +42,7 @@ internal ClothingItemDefinitionBuilder() _definition = ScriptableObject.CreateInstance(); // Set defaults - _definition.StackLimit = 10; + _definition.StackLimit = 1; _definition.BasePurchasePrice = 10f; _definition.ResellMultiplier = 0.5f; _definition.Category = S1CoreItemFramework.EItemCategory.Clothing; @@ -59,6 +68,11 @@ internal ClothingItemDefinitionBuilder() /// internal ClothingItemDefinitionBuilder(S1Clothing.ClothingDefinition source) { + if (source == null) + { + throw new System.ArgumentNullException(nameof(source)); + } + _definition = ScriptableObject.CreateInstance(); // Copy all StorableItemDefinition properties @@ -75,6 +89,7 @@ internal ClothingItemDefinitionBuilder(S1Clothing.ClothingDefinition source) _definition.UsableInFilters = source.UsableInFilters; _definition.StoredItem = source.StoredItem; _definition.Equippable = source.Equippable; + _definition.CustomItemUI = source.CustomItemUI; // Copy clothing-specific properties _definition.Slot = source.Slot; @@ -92,7 +107,9 @@ internal ClothingItemDefinitionBuilder(S1Clothing.ClothingDefinition source) } } #else - _definition.SlotsToBlock = new List(source.SlotsToBlock); + _definition.SlotsToBlock = source.SlotsToBlock == null + ? new List() + : new List(source.SlotsToBlock); #endif } @@ -220,6 +237,8 @@ public ClothingItemDefinitionBuilder WithPricing(float basePurchasePrice, float /// A wrapper around the created clothing item definition. public ClothingItemDefinition Build() { + EnsureNativeClothingItemUi(); + // Register with the game's registry S1Registry.Instance.AddToRegistry(_definition); @@ -233,8 +252,80 @@ public ClothingItemDefinition Build() /// internal S1Clothing.ClothingDefinition BuildInternal() { + EnsureNativeClothingItemUi(); + return _definition; } + + private void EnsureNativeClothingItemUi() + { + if (_definition.CustomItemUI != null) + { + return; + } + + if (s_cachedNativeCustomItemUI != null) + { + _definition.CustomItemUI = s_cachedNativeCustomItemUI; + return; + } + + if (S1Registry.Instance == null) + { + WarnMissingNativeClothingItemUi("S1Registry.Instance is null"); + return; + } + + var allItems = S1Registry.Instance.GetAllItems(); + if (allItems == null) + { + WarnMissingNativeClothingItemUi("S1Registry.Instance.GetAllItems() returned null"); + return; + } + + foreach (var item in allItems) + { + if (item == null || + !CrossType.Is(item, out S1Clothing.ClothingDefinition clothingDefinition)) + { + continue; + } + + var customItemUI = clothingDefinition.CustomItemUI; + if (customItemUI == null) + { + continue; + } + + // CustomItemUI is a native UI template. Share the existing template instead of + // cloning it here; listing state is bound per item by the game, and cloning + // Unity/Il2Cpp UI objects is riskier across runtimes. + s_cachedNativeCustomItemUI = customItemUI; + _definition.CustomItemUI = customItemUI; + return; + } + + WarnMissingNativeClothingItemUi("no S1Clothing.ClothingDefinition with S1Clothing.ClothingDefinition.CustomItemUI was found"); + } + + private static void WarnMissingNativeClothingItemUi(string reason) + { + if (string.IsNullOrWhiteSpace(reason)) + { + return; + } + + bool shouldWarn; + lock (WarnedMissingNativeClothingItemUiLock) + { + shouldWarn = WarnedMissingNativeClothingItemUiReasons.Add(reason); + } + + if (shouldWarn) + { + Logger.Warning($"Could not borrow a native clothing CustomItemUI template ({reason}). Custom clothing inventory UI may be incomplete. This usually means Build() was called before any native clothing registered."); + } + } } } diff --git a/S1API/Items/ItemManager.cs b/S1API/Items/ItemManager.cs index f6b4be29..b384dfc9 100644 --- a/S1API/Items/ItemManager.cs +++ b/S1API/Items/ItemManager.cs @@ -1,12 +1,10 @@ #if (IL2CPPMELON) -using S1 = Il2CppScheduleOne; using S1ItemFramework = Il2CppScheduleOne.ItemFramework; using S1Product = Il2CppScheduleOne.Product; using S1Registry = Il2CppScheduleOne.Registry; using S1Clothing = Il2CppScheduleOne.Clothing; using S1Packaging = Il2CppScheduleOne.Product.Packaging; #elif (MONOMELON || MONOBEPINEX || IL2CPPBEPINEX) -using S1 = ScheduleOne; using S1ItemFramework = ScheduleOne.ItemFramework; using S1Product = ScheduleOne.Product; using S1Registry = ScheduleOne.Registry; @@ -35,7 +33,7 @@ public static class ItemManager /// An instance of the item definition. public static ItemDefinition GetItemDefinition(string itemID) { - S1ItemFramework.ItemDefinition itemDefinition = S1.Registry.GetItem(itemID); + S1ItemFramework.ItemDefinition itemDefinition = S1Registry.GetItem(itemID); if (itemDefinition == null) return null; @@ -88,6 +86,47 @@ public static void RegisterItem(ItemDefinition definition) S1Registry.Instance.AddToRegistry(definition.S1ItemDefinition); } + /// + /// Checks whether an item ID is present in the active game registry. + /// + /// The ID of the item to look up. + /// True if the item is registered; otherwise, false. + public static bool IsItemRegistered(string itemID) + { + if (string.IsNullOrWhiteSpace(itemID) || S1Registry.Instance == null) + { + return false; + } + + return S1Registry.ItemExists(itemID); + } + + /// + /// Registers an item only when it is missing from the active game registry. + /// + /// The item definition to register. + /// True if the item is registered after the call; otherwise, false. + public static bool EnsureItemRegistered(ItemDefinition definition) + { + if (definition == null) + { + throw new ArgumentNullException(nameof(definition), "Cannot register null item definition"); + } + + if (S1Registry.Instance == null) + { + return false; + } + + if (IsItemRegistered(definition.ID)) + { + return true; + } + + S1Registry.Instance.AddToRegistry(definition.S1ItemDefinition); + return IsItemRegistered(definition.ID); + } + /// /// Prevents a runtime-registered item from being removed during the next scene transition. /// This is useful for items that must survive a menu-to-game load sequence. diff --git a/S1API/docs/clothing-items.md b/S1API/docs/clothing-items.md index e5c3ba04..e8c17f78 100644 --- a/S1API/docs/clothing-items.md +++ b/S1API/docs/clothing-items.md @@ -4,7 +4,7 @@ Clothing content bridges the avatar rendering system, runtime resource registry, ## Quick Checklist -1. Wait for the `Main` scene before touching registries (`OnSceneWasLoaded`). +1. Subscribe to `GameLifecycle.OnPreLoad` once after the `Main` scene starts, then let that callback register clothing before save data loads. Because `OnPreLoad` fires on every load, guard registration with either a manual `ItemManager.IsItemRegistered` check when you need to recover an existing definition, or `ItemManager.EnsureItemRegistered` when you already have the definition instance. 2. (Optional) Clone an existing accessory prefab and override its materials/textures via `AccessoryFactory`. 3. Build or clone the clothing definition with `ClothingItemCreator`, pointing `WithClothingAsset` at your custom accessory path. 4. Register icons and pricing just like other items. @@ -88,7 +88,7 @@ int shopsAdded = ShopManager.AddToCompatibleShops(itemDefinition); MelonLogger.Msg($"Added to {shopsAdded} shop(s)"); ``` -Shop injection should happen after the registry confirms your item exists (e.g., immediately after `CreateClothingItem()` succeeds). +Shop injection should happen after the registry confirms your item exists (e.g., immediately after `Build()` succeeds or during `GameLifecycle.OnLoadComplete`). `ShopManager.AddToCompatibleShops` can be called on later loads because shop additions skip existing listings. ## Slots and Application Types @@ -113,7 +113,7 @@ Shop injection should happen after the registry confirms your item exists (e.g., ## Testing and Troubleshooting -- **Initialization timing**: Guard the workflow with `if (sceneName == "Main" && !_initialized)` to avoid duplicate registration across scene loads. +- **Initialization timing**: Subscribe from the `Main` scene once, register definitions in `GameLifecycle.OnPreLoad`, and add shop entries in `GameLifecycle.OnLoadComplete`. Guard registration with `ItemManager.IsItemRegistered` or a definition lookup so repeated loads do not duplicate custom items. - **Resource paths**: Match the string passed to `WithClothingAsset` with the `targetResourcePath` you registered via `AccessoryFactory`. - **Texture validation**: Log texture dimensions after loading so you catch mis-sized PNGs early. - **Shop coverage**: `ShopManager.AddToCompatibleShops` returns how many inventories accepted the item—log the count and ensure it is non-zero for your desired vendors. @@ -126,6 +126,7 @@ Here's a complete example combining all steps: ```csharp using MelonLoader; using S1API.Items; +using S1API.Lifecycle; using S1API.Rendering; using S1API.Internal.Utils; using S1API.Shops; @@ -135,43 +136,70 @@ using UnityEngine; public class MyMod : MelonMod { + private const string CustomItemId = "custom_cap"; + private const string CustomAccessoryPath = "MyMod/Accessories/CustomCap"; + private const string CustomTextureResourceName = "MyMod.Resources.CustomCap.custom_cap_texture.png"; + private bool _itemsInitialized = false; + private ClothingItemDefinition customCap; public override void OnSceneWasLoaded(int buildIndex, string sceneName) { if (sceneName == "Main" && !_itemsInitialized) { - InitializeCustomClothing(); + GameLifecycle.OnPreLoad += InitializeCustomClothing; + GameLifecycle.OnLoadComplete += AddCustomClothingToShops; _itemsInitialized = true; } } private void InitializeCustomClothing() { + if (ItemManager.IsItemRegistered(CustomItemId)) + { + customCap = ItemManager.GetItemDefinition(CustomItemId) as ClothingItemDefinition; + if (customCap == null) + { + MelonLogger.Error($"Registered item '{CustomItemId}' is not a ClothingItemDefinition; custom clothing setup cannot continue."); + return; + } + + return; + } + // Step 1: Create and register custom accessory var assembly = Assembly.GetExecutingAssembly(); - var customTexture = TextureUtils.LoadTextureFromResource( - assembly, - "MyMod.Resources.CustomCap.custom_cap_texture.png"); - - var textureReplacements = new Dictionary - { - { "_MainTex", customTexture }, - { "_BaseMap", customTexture }, - { "_Albedo", customTexture } - }; - - bool accessoryRegistered = AccessoryFactory.CreateAndRegisterAccessory( - sourceResourcePath: "avatar/accessories/head/cap/Cap", - targetResourcePath: "MyMod/Accessories/CustomCap", - newName: "CustomCap", - textureReplacements: textureReplacements, - colorTint: null); - - if (!accessoryRegistered) + if (!RuntimeResourceRegistry.IsRegistered(CustomAccessoryPath)) { - MelonLogger.Error("Failed to register custom accessory"); - return; + var customTexture = TextureUtils.LoadTextureFromResource( + assembly, + CustomTextureResourceName); + + if (customTexture == null) + { + MelonLogger.Error($"Failed to load custom clothing texture resource: {CustomTextureResourceName}"); + return; + } + + var textureReplacements = new Dictionary + { + { "_MainTex", customTexture }, + { "_BaseMap", customTexture }, + { "_Albedo", customTexture } + }; + + bool accessoryRegistered = AccessoryFactory.CreateAndRegisterAccessory( + sourceResourcePath: "avatar/accessories/head/cap/Cap", + targetResourcePath: CustomAccessoryPath, + newName: "CustomCap", + textureReplacements: textureReplacements, + colorTint: null); + + if (!accessoryRegistered) + { + MelonLogger.Error("Failed to register custom accessory"); + return; + } } // Step 2: Create clothing item definition @@ -179,12 +207,12 @@ public class MyMod : MelonMod assembly, "MyMod.Resources.CustomCap.icon.png"); - var customCap = ClothingItemCreator.CloneFrom("cap") + customCap = ClothingItemCreator.CloneFrom("cap") .WithBasicInfo( - id: "custom_cap", + id: CustomItemId, name: "Custom Cap", description: "A custom cap with unique style.") - .WithClothingAsset("MyMod/Accessories/CustomCap") + .WithClothingAsset(CustomAccessoryPath) .WithColorable(false) .WithDefaultColor(ClothingColor.White) .WithPricing(75f, 0.5f) @@ -194,10 +222,18 @@ public class MyMod : MelonMod { customCap.Icon = icon; } + MelonLogger.Msg($"Created custom clothing item: {customCap.Name}"); + } + + private void AddCustomClothingToShops() + { + if (customCap == null) + { + MelonLogger.Warning($"Skipping shop registration because '{CustomItemId}' was not created as a ClothingItemDefinition."); + return; + } - // Step 3: Add to shops int shopsAdded = ShopManager.AddToCompatibleShops(customCap); - MelonLogger.Msg($"Created custom clothing item: {customCap.Name}"); MelonLogger.Msg($"Added to {shopsAdded} shop(s)"); } }