diff --git a/S1API.Tests/Items/FurnitureApiCompatibilityTests.cs b/S1API.Tests/Items/FurnitureApiCompatibilityTests.cs index 2b084b96..07c69235 100644 --- a/S1API.Tests/Items/FurnitureApiCompatibilityTests.cs +++ b/S1API.Tests/Items/FurnitureApiCompatibilityTests.cs @@ -16,8 +16,11 @@ public void FurnitureBuilderExposesRuntimeAgnosticFluentSurface() Assert.NotNull(createBuilder); Assert.Equal(typeof(FurnitureDefinitionBuilder), createBuilder!.ReturnType); + AssertCreatorCloneOverload(typeof(string), "sourceItemId"); + AssertCreatorCloneOverload(typeof(BuildableItemDefinition), "source"); AssertFluent(nameof(FurnitureDefinitionBuilder.WithBasicInfo), typeof(string), typeof(string), typeof(string)); AssertFluent(nameof(FurnitureDefinitionBuilder.WithModel), typeof(GameObject)); + AssertFluent(nameof(FurnitureDefinitionBuilder.ConfigureModel), typeof(Action)); AssertFluent(nameof(FurnitureDefinitionBuilder.WithPlacement), typeof(FurniturePlacementMode)); AssertFluent(nameof(FurnitureDefinitionBuilder.WithFootprint), typeof(int), typeof(int)); AssertFluent(nameof(FurnitureDefinitionBuilder.WithSurfacePlacement), typeof(FurnitureSurfaceType), typeof(bool)); @@ -72,6 +75,24 @@ public void ModelAndIconRejectNull() Assert.Throws(() => builder.WithModel(null!)); Assert.Throws(() => builder.WithIcon(null!)); + Assert.Throws(() => builder.ConfigureModel(null!)); + } + + [Fact] + public void ConfigureModelRejectsCreateBuilderPath() + { + FurnitureDefinitionBuilder builder = FurnitureCreator.CreateBuilder(); + + Assert.Throws( + () => builder.ConfigureModel(_ => { })); + } + + [Fact] + public void CloneFromRejectsInvalidPublicInputsBeforeNativeResolution() + { + Assert.Throws(() => FurnitureCreator.CloneFrom(" ")); + Assert.Throws( + () => FurnitureCreator.CloneFrom((BuildableItemDefinition)null!)); } [Fact] @@ -101,6 +122,31 @@ public void BuildSoundRejectsUnknownValue() () => builder.WithBuildSound((BuildSoundType)int.MaxValue)); } + [Theory] + [InlineData(0, BuildSoundType.Cardboard)] + [InlineData(1, BuildSoundType.Wood)] + [InlineData(2, BuildSoundType.Metal)] + public void NativeBuildSoundsMapBackToPublicValues(int nativeValue, BuildSoundType soundType) + { +#if IL2CPPMELON + var native = (Il2CppScheduleOne.ItemFramework.BuildableItemDefinition.EBuildSoundType)nativeValue; +#else + var native = (ScheduleOne.ItemFramework.BuildableItemDefinition.EBuildSoundType)nativeValue; +#endif + Assert.Equal(soundType, FurnitureBuildSoundMapper.FromNative(native)); + } + + private static void AssertCreatorCloneOverload(Type parameterType, string parameterName) + { + MethodInfo? method = typeof(FurnitureCreator).GetMethod( + nameof(FurnitureCreator.CloneFrom), + new[] { parameterType }); + + Assert.NotNull(method); + Assert.Equal(typeof(FurnitureDefinitionBuilder), method!.ReturnType); + Assert.Equal(parameterName, Assert.Single(method.GetParameters()).Name); + } + private static void AssertFluent(string name, params Type[] parameterTypes) { MethodInfo? method = typeof(FurnitureDefinitionBuilder).GetMethod(name, parameterTypes); diff --git a/S1API.Tests/Items/FurnitureApiCompileFixture.cs b/S1API.Tests/Items/FurnitureApiCompileFixture.cs index b90a8b7c..85d7e605 100644 --- a/S1API.Tests/Items/FurnitureApiCompileFixture.cs +++ b/S1API.Tests/Items/FurnitureApiCompileFixture.cs @@ -17,4 +17,23 @@ internal static FurnitureDefinitionBuilder Configure(GameObject model, Sprite ic .WithStackLimit(4) .WithIcon(icon); } + + internal static FurnitureDefinitionBuilder ConfigureNativeVariant( + string donorId, + Action configure) + { + return FurnitureCreator.CloneFrom(donorId) + .WithBasicInfo("example.mod:blue-chair", "Blue Chair", "A recolored chair.") + .ConfigureModel(configure) + .WithGeneratedIcon(); + } + + internal static FurnitureDefinitionBuilder ConfigureNativeVariant( + BuildableItemDefinition donor, + Action configure) + { + return FurnitureCreator.CloneFrom(donor) + .WithBasicInfo("example.mod:green-chair", "Green Chair", "Another recolored chair.") + .ConfigureModel(configure); + } } diff --git a/S1API.Tests/Items/FurnitureClonePolicyTests.cs b/S1API.Tests/Items/FurnitureClonePolicyTests.cs new file mode 100644 index 00000000..d2f022a2 --- /dev/null +++ b/S1API.Tests/Items/FurnitureClonePolicyTests.cs @@ -0,0 +1,109 @@ +using S1API.Internal.Building; +using S1API.Items.Buildable; +using UnityEngine; + +namespace S1API.Tests.Items; + +public sealed class FurnitureClonePolicyTests +{ + [Theory] + [InlineData("couch", "couch")] + [InlineData("Couch", "couch")] + public void VariantRejectsDonorIdReuse(string itemId, string donorId) + { + Assert.Throws( + () => FurnitureClonePolicy.ValidateNewId(itemId, donorId)); + } + + [Fact] + public void VariantAcceptsNewStableId() + { + FurnitureClonePolicy.ValidateNewId("example.mod:blue-couch", "couch"); + } + + [Fact] + public void CreateBuilderHasNoDonorIdentityConstraint() + { + FurnitureClonePolicy.ValidateNewId("example.mod:chair", donorId: null); + } + + [Fact] + public void CloneBuilderLeavesIdentityUnsetAndCopiesSafeDefaults() + { + var footprint = new[] + { + new FurnitureFootprintCoordinate(0, 0), + new FurnitureFootprintCoordinate(1, 0), + }; + Sprite icon = TestObjectFactory.CreateUninitialized(); + var source = new FurnitureCloneSource( + "native-chair", + model: null!, + FurniturePlacementMode.Grid, + footprint, + FurnitureSurfaceType.Roof, + allowSurfaceRotation: false, + BuildSoundType.Metal, + stackLimit: 4, + purchasePrice: 125f, + resellMultiplier: 0.25f, + icon); + + var builder = new FurnitureDefinitionBuilder(source); + + Assert.Null(GetField(builder, "_id")); + Assert.Equal("native-chair", GetField(builder, "_donorId")); + Assert.Same(footprint, GetField(builder, "_donorFootprint")); + Assert.Equal(FurniturePlacementMode.Grid, GetField(builder, "_placementMode")); + Assert.Equal(2, GetField(builder, "_footprintWidth")); + Assert.Equal(1, GetField(builder, "_footprintDepth")); + Assert.Equal(FurnitureSurfaceType.Roof, GetField(builder, "_surfaceTypes")); + Assert.False(GetField(builder, "_allowSurfaceRotation")); + Assert.Equal(BuildSoundType.Metal, GetField(builder, "_buildSound")); + Assert.Equal(4, GetField(builder, "_stackLimit")); + Assert.Equal(125f, GetField(builder, "_purchasePrice")); + Assert.Equal(0.25f, GetField(builder, "_resellMultiplier")); + Assert.Same(icon, GetField(builder, "_fallbackIcon")); + Assert.True(GetField(builder, "_generateIcon")); + Assert.False(GetField(builder, "_centerModelOnFootprint")); + Assert.True(GetField(builder, "_isolateRepresentationMaterials")); + } + + [Fact] + public void ExplicitPlacementOverridesReplaceDonorDefaults() + { + var footprint = new[] { new FurnitureFootprintCoordinate(0, 0) }; + var source = new FurnitureCloneSource( + "native-picture", + model: null!, + FurniturePlacementMode.Surface, + footprint, + FurnitureSurfaceType.Wall, + allowSurfaceRotation: true, + BuildSoundType.Wood, + stackLimit: 1, + purchasePrice: 1f, + resellMultiplier: 0.5f, + icon: null); + var builder = new FurnitureDefinitionBuilder(source) + .WithPlacement(FurniturePlacementMode.Grid) + .WithFootprint(3, 2) + .WithFootprint(4, 3) + .WithSurfacePlacement(FurnitureSurfaceType.Roof, allowRotation: false); + + Assert.Equal(FurniturePlacementMode.Grid, GetField(builder, "_placementMode")); + Assert.Null(GetField(builder, "_donorFootprint")); + Assert.Equal(4, GetField(builder, "_footprintWidth")); + Assert.Equal(3, GetField(builder, "_footprintDepth")); + Assert.Equal(FurnitureSurfaceType.Roof, GetField(builder, "_surfaceTypes")); + Assert.False(GetField(builder, "_allowSurfaceRotation")); + } + + private static T GetField(FurnitureDefinitionBuilder builder, string name) + { + object? value = typeof(FurnitureDefinitionBuilder) + .GetField(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .GetValue(builder); + return (T)value!; + } +} diff --git a/S1API/Internal/Building/FurnitureBuildSoundMapper.cs b/S1API/Internal/Building/FurnitureBuildSoundMapper.cs index 04d8800d..1b4b4dae 100644 --- a/S1API/Internal/Building/FurnitureBuildSoundMapper.cs +++ b/S1API/Internal/Building/FurnitureBuildSoundMapper.cs @@ -27,5 +27,17 @@ internal static S1ItemFramework.BuildableItemDefinition.EBuildSoundType ToNative _ => throw new ArgumentOutOfRangeException(nameof(soundType)), }; } + + internal static BuildSoundType FromNative( + S1ItemFramework.BuildableItemDefinition.EBuildSoundType soundType) + { + return soundType switch + { + S1ItemFramework.BuildableItemDefinition.EBuildSoundType.Cardboard => BuildSoundType.Cardboard, + S1ItemFramework.BuildableItemDefinition.EBuildSoundType.Wood => BuildSoundType.Wood, + S1ItemFramework.BuildableItemDefinition.EBuildSoundType.Metal => BuildSoundType.Metal, + _ => throw new ArgumentOutOfRangeException(nameof(soundType)), + }; + } } } diff --git a/S1API/Internal/Building/FurnitureClonePolicy.cs b/S1API/Internal/Building/FurnitureClonePolicy.cs new file mode 100644 index 00000000..294595c6 --- /dev/null +++ b/S1API/Internal/Building/FurnitureClonePolicy.cs @@ -0,0 +1,17 @@ +using System; + +namespace S1API.Internal.Building +{ + internal static class FurnitureClonePolicy + { + internal static void ValidateNewId(string itemId, string? donorId) + { + if (donorId != null && + string.Equals(itemId, donorId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "A furniture variant must use a new stable item ID."); + } + } + } +} diff --git a/S1API/Internal/Building/FurnitureCloneSource.cs b/S1API/Internal/Building/FurnitureCloneSource.cs new file mode 100644 index 00000000..408ffa92 --- /dev/null +++ b/S1API/Internal/Building/FurnitureCloneSource.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using S1API.Items.Buildable; +using UnityEngine; + +namespace S1API.Internal.Building +{ + /// + /// Builder-owned presentation and safe scalar defaults extracted from a furniture donor. + /// + internal sealed class FurnitureCloneSource + { + internal FurnitureCloneSource( + string donorId, + GameObject model, + FurniturePlacementMode placementMode, + IReadOnlyList? footprint, + FurnitureSurfaceType surfaceTypes, + bool allowSurfaceRotation, + BuildSoundType buildSound, + int stackLimit, + float purchasePrice, + float resellMultiplier, + Sprite? icon) + { + DonorId = donorId; + Model = model; + PlacementMode = placementMode; + Footprint = footprint; + SurfaceTypes = surfaceTypes; + AllowSurfaceRotation = allowSurfaceRotation; + BuildSound = buildSound; + StackLimit = stackLimit; + PurchasePrice = purchasePrice; + ResellMultiplier = resellMultiplier; + Icon = icon; + } + + internal string DonorId { get; } + internal GameObject Model { get; } + internal FurniturePlacementMode PlacementMode { get; } + internal IReadOnlyList? Footprint { get; } + internal FurnitureSurfaceType SurfaceTypes { get; } + internal bool AllowSurfaceRotation { get; } + internal BuildSoundType BuildSound { get; } + internal int StackLimit { get; } + internal float PurchasePrice { get; } + internal float ResellMultiplier { get; } + internal Sprite? Icon { get; } + } + + internal readonly struct FurnitureFootprintCoordinate + { + internal FurnitureFootprintCoordinate(int x, int y) + { + X = x; + Y = y; + } + + internal int X { get; } + internal int Y { get; } + } +} diff --git a/S1API/Internal/Building/FurnitureIconRuntime.cs b/S1API/Internal/Building/FurnitureIconRuntime.cs index f9215708..7cbe9f37 100644 --- a/S1API/Internal/Building/FurnitureIconRuntime.cs +++ b/S1API/Internal/Building/FurnitureIconRuntime.cs @@ -25,12 +25,17 @@ internal static class FurnitureIconRuntime internal static void Queue( BuildableItemDefinition definition, Transform model, - int resolution) + int resolution, + bool isolateMaterials) { bool startProcessor = false; lock (Gate) { - Pending.Enqueue(new Request(definition, model, resolution)); + Pending.Enqueue(new Request( + definition, + model, + resolution, + isolateMaterials)); if (!_processing) { _processing = true; @@ -183,7 +188,9 @@ private static bool TryCreatePreview( return false; } - iconModel = InactiveObjectCloner.CloneGameObject(request.Model.gameObject); + iconModel = request.IsolateMaterials + ? FurnitureVisualCloner.CloneOwnedVisual(request.Model.gameObject) + : InactiveObjectCloner.CloneGameObject(request.Model.gameObject); if (iconModel == null) { failure = "the source model could not be cloned"; @@ -287,16 +294,19 @@ private sealed class Request internal Request( BuildableItemDefinition definition, Transform model, - int resolution) + int resolution, + bool isolateMaterials) { Definition = definition; Model = model; Resolution = resolution; + IsolateMaterials = isolateMaterials; } internal BuildableItemDefinition Definition { get; } internal Transform Model { get; } internal int Resolution { get; } + internal bool IsolateMaterials { get; } } } } diff --git a/S1API/Internal/Building/FurniturePrefabComposer.cs b/S1API/Internal/Building/FurniturePrefabComposer.cs index 69034734..27633c4f 100644 --- a/S1API/Internal/Building/FurniturePrefabComposer.cs +++ b/S1API/Internal/Building/FurniturePrefabComposer.cs @@ -14,6 +14,7 @@ using S1Tiles = ScheduleOne.Tiles; #endif using System; +using System.Collections.Generic; using S1API.Internal.Utils; using S1API.Items; using S1API.Items.Buildable; @@ -37,7 +38,10 @@ internal static FurnitureComposition Compose( int footprintWidth, int footprintDepth, FurnitureSurfaceType surfaceTypes, - bool allowSurfaceRotation) + bool allowSurfaceRotation, + IReadOnlyList? donorFootprint, + bool centerModelOnFootprint, + bool isolateMaterials) { string templateId = placementMode == FurniturePlacementMode.Grid ? FurnitureTemplateCatalog.GridItemId @@ -50,7 +54,8 @@ internal static FurnitureComposition Compose( builtItem.gameObject.name = $"{id}_BuiltItem"; DisableTemplateRenderers(builtItem.gameObject); - Vector3 visualOffset = placementMode == FurniturePlacementMode.Grid + Vector3 visualOffset = placementMode == FurniturePlacementMode.Grid && + centerModelOnFootprint ? new Vector3( (footprintWidth - 1) * GridTileSize * 0.5f, 0f, @@ -60,7 +65,8 @@ internal static FurnitureComposition Compose( model, builtItem.transform, visualOffset, - BuildableGhostRuntime.FurnitureVisualName); + BuildableGhostRuntime.FurnitureVisualName, + isolateMaterials); ConfigureBoundsAndCulling(builtItem, builtModel, placementMode); if (placementMode == FurniturePlacementMode.Grid) @@ -68,7 +74,11 @@ internal static FurnitureComposition Compose( if (!CrossType.Is(builtItem, out S1EntityFramework.GridItem gridItem)) throw new InvalidOperationException($"Furniture template '{templateId}' is not a native GridItem."); - ConfigureGridFootprint(gridItem, footprintWidth, footprintDepth); + ConfigureGridFootprint( + gridItem, + footprintWidth, + footprintDepth, + donorFootprint); } else { @@ -89,7 +99,8 @@ internal static FurnitureComposition Compose( model, storedItem.transform, Vector3.zero, - BuildableGhostRuntime.FurnitureVisualName); + BuildableGhostRuntime.FurnitureVisualName, + isolateMaterials); RuntimePrefabCache.Store(storedItem.gameObject); return new FurnitureComposition( @@ -122,9 +133,12 @@ private static GameObject AddModelClone( GameObject model, Transform parent, Vector3 localPosition, - string name) + string name, + bool isolateMaterials) { - GameObject clone = InactiveObjectCloner.CloneGameObject(model); + GameObject clone = isolateMaterials + ? FurnitureVisualCloner.CloneOwnedVisual(model) + : InactiveObjectCloner.CloneGameObject(model); clone.name = name; clone.transform.SetParent(parent, false); clone.transform.localPosition = localPosition; @@ -259,7 +273,8 @@ private static bool TryGetRendererLocalBounds( private static void ConfigureGridFootprint( S1EntityFramework.GridItem gridItem, int width, - int depth) + int depth, + IReadOnlyList? donorFootprint) { if (gridItem.CoordinateFootprintTilePairs == null || gridItem.CoordinateFootprintTilePairs.Count == 0 || @@ -282,35 +297,44 @@ private static void ConfigureGridFootprint( #else var pairs = new System.Collections.Generic.List(); #endif - for (int x = 0; x < width; x++) + if (donorFootprint != null) { + foreach (FurnitureFootprintCoordinate coordinate in donorFootprint) + AddFootprintTile(coordinate.X, coordinate.Y); + } + else + { + for (int x = 0; x < width; x++) for (int y = 0; y < depth; y++) - { - S1Tiles.FootprintTile tile = InactiveObjectCloner.CloneComponent( - templateTile, - footprintRoot.transform); - tile.name = $"FootprintTile_{x}_{y}"; - tile.X = x; - tile.Y = y; - tile.transform.localPosition = new Vector3(x * GridTileSize, 0f, y * GridTileSize); - tile.gameObject.SetActive(true); + AddFootprintTile(x, y); + } + + gridItem.CoordinateFootprintTilePairs = pairs; + + void AddFootprintTile(int x, int y) + { + S1Tiles.FootprintTile tile = InactiveObjectCloner.CloneComponent( + templateTile, + footprintRoot.transform); + tile.name = $"FootprintTile_{x}_{y}"; + tile.X = x; + tile.Y = y; + tile.transform.localPosition = new Vector3(x * GridTileSize, 0f, y * GridTileSize); + tile.gameObject.SetActive(true); #if (IL2CPPMELON) - var pair = new S1Tiles.CoordinateFootprintTilePair(); - pair.coord = new S1Tiles.Coordinate(x, y); - pair.footprintTile = tile; + var pair = new S1Tiles.CoordinateFootprintTilePair(); + pair.coord = new S1Tiles.Coordinate(x, y); + pair.footprintTile = tile; #else - var pair = new S1Tiles.CoordinateFootprintTilePair - { - coord = new S1Tiles.Coordinate(x, y), - footprintTile = tile, - }; + var pair = new S1Tiles.CoordinateFootprintTilePair + { + coord = new S1Tiles.Coordinate(x, y), + footprintTile = tile, + }; #endif - pairs.Add(pair); - } + pairs.Add(pair); } - - gridItem.CoordinateFootprintTilePairs = pairs; } private static void ConfigureSurfacePlacement( diff --git a/S1API/Internal/Building/FurnitureVisualCloner.cs b/S1API/Internal/Building/FurnitureVisualCloner.cs new file mode 100644 index 00000000..3221f254 --- /dev/null +++ b/S1API/Internal/Building/FurnitureVisualCloner.cs @@ -0,0 +1,266 @@ +#if (IL2CPPMELON) +using S1Building = Il2CppScheduleOne.Building; +using S1EntityFramework = Il2CppScheduleOne.EntityFramework; +using S1ItemFramework = Il2CppScheduleOne.ItemFramework; +#elif MONOMELON +using S1Building = ScheduleOne.Building; +using S1EntityFramework = ScheduleOne.EntityFramework; +using S1ItemFramework = ScheduleOne.ItemFramework; +#endif +using System; +using System.Collections.Generic; +using S1API.Internal.Utils; +using S1API.Items.Buildable; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace S1API.Internal.Building +{ + /// + /// Extracts presentation-only furniture clones without exposing native prefabs or materials. + /// + internal static class FurnitureVisualCloner + { + internal static FurnitureCloneSource CreateSource( + S1ItemFramework.BuildableItemDefinition definition) + { + if (definition == null) + throw new ArgumentNullException(nameof(definition)); + if (string.IsNullOrWhiteSpace(definition.ID)) + throw new ArgumentException("Furniture donor has no stable item ID.", nameof(definition)); + if (definition.BuiltItem == null) + { + throw new ArgumentException( + $"Furniture donor '{definition.ID}' has no placed-item prefab.", + nameof(definition)); + } + + S1EntityFramework.BuildableItem builtItem = definition.BuiltItem; + FurniturePlacementMode placementMode; + IReadOnlyList? footprint = null; + FurnitureSurfaceType surfaceTypes = FurnitureSurfaceType.Wall; + bool allowSurfaceRotation = true; + + if (CrossType.IsExact(builtItem) && + CrossType.Is(builtItem, out S1EntityFramework.GridItem gridItem)) + { + placementMode = FurniturePlacementMode.Grid; + footprint = ExtractFootprint(gridItem, definition.ID); + } + else if (CrossType.IsExact(builtItem) && + CrossType.Is(builtItem, out S1EntityFramework.SurfaceItem surfaceItem)) + { + placementMode = FurniturePlacementMode.Surface; + surfaceTypes = ExtractSurfaceTypes(surfaceItem, definition.ID); + allowSurfaceRotation = surfaceItem.AllowRotation; + } + else + { + throw new ArgumentException( + $"Furniture donor '{definition.ID}' uses '{builtItem.GetType().Name}'. " + + "Only presentation-only GridItem and SurfaceItem donors are supported.", + nameof(definition)); + } + + GameObject model = CreatePresentationClone(builtItem.gameObject, definition.ID); + model.hideFlags = HideFlags.HideAndDontSave; + model.SetActive(false); + Object.DontDestroyOnLoad(model); + return new FurnitureCloneSource( + definition.ID, + model, + placementMode, + footprint, + surfaceTypes, + allowSurfaceRotation, + FurnitureBuildSoundMapper.FromNative(definition.BuildSoundType), + definition.StackLimit, + definition.BasePurchasePrice, + definition.ResellMultiplier, + definition.Icon); + } + + internal static GameObject CloneOwnedVisual(GameObject source) + { + GameObject clone = InactiveObjectCloner.CloneGameObject(source); + try + { + CloneRendererMaterials(clone); + return clone; + } + catch + { + Object.DestroyImmediate(clone); + throw; + } + } + + private static GameObject CreatePresentationClone(GameObject donor, string donorId) + { + GameObject clone = InactiveObjectCloner.CloneGameObject(donor); + try + { + clone.name = $"{donorId}_FurnitureVisualSource"; + clone.transform.localPosition = Vector3.zero; + clone.transform.localRotation = Quaternion.identity; + StripRuntimeComponents(clone); + EnsureRenderable(clone, donorId); + CloneRendererMaterials(clone); + return clone; + } + catch + { + Object.DestroyImmediate(clone); + throw; + } + } + + private static void StripRuntimeComponents(GameObject root) + { + Component[] components = root.GetComponentsInChildren(true); + for (int index = components.Length - 1; index >= 0; index--) + { + Component component = components[index]; + if (component == null || IsPresentationComponent(component)) + continue; + + Object.DestroyImmediate(component); + } + + foreach (Component component in root.GetComponentsInChildren(true)) + { + if (component != null && !IsPresentationComponent(component)) + { + throw new InvalidOperationException( + $"Furniture visual extraction could not remove '{component.GetType().Name}'."); + } + } + } + + private static bool IsPresentationComponent(Component component) + { + return component is Transform || + component is Renderer || + component is MeshFilter || + component is LODGroup || + component is Animator || + component is Animation; + } + + private static void EnsureRenderable(GameObject root, string donorId) + { + foreach (MeshRenderer renderer in root.GetComponentsInChildren(true)) + { + MeshFilter? meshFilter = renderer.GetComponent(); + if (meshFilter != null && meshFilter.sharedMesh != null) + return; + } + + if (root.GetComponentsInChildren(true).Length != 0) + return; + + throw new ArgumentException( + $"Furniture donor '{donorId}' has no supported presentation renderer.", + nameof(donorId)); + } + + private static void CloneRendererMaterials(GameObject root) + { + var materialClones = new Dictionary(); + foreach (Renderer renderer in root.GetComponentsInChildren(true)) + { + Material[] sourceMaterials = renderer.sharedMaterials; + var ownedMaterials = new Material[sourceMaterials.Length]; + for (int index = 0; index < sourceMaterials.Length; index++) + { + Material source = sourceMaterials[index]; + if (source == null) + continue; + if (!materialClones.TryGetValue(source, out Material? owned)) + { + owned = new Material(source) + { + name = source.name + "_S1API_FurnitureVariant", + }; + materialClones.Add(source, owned); + } + + ownedMaterials[index] = owned; + } + + renderer.sharedMaterials = ownedMaterials; + } + } + + private static IReadOnlyList ExtractFootprint( + S1EntityFramework.GridItem gridItem, + string donorId) + { + if (gridItem.CoordinateFootprintTilePairs == null || + gridItem.CoordinateFootprintTilePairs.Count == 0) + { + throw new ArgumentException( + $"Furniture donor '{donorId}' has no grid footprint.", + nameof(gridItem)); + } + + var footprint = new List( + gridItem.CoordinateFootprintTilePairs.Count); + var seen = new HashSet(StringComparer.Ordinal); + for (int index = 0; index < gridItem.CoordinateFootprintTilePairs.Count; index++) + { + var coordinate = gridItem.CoordinateFootprintTilePairs[index].coord; + if (coordinate == null) + { + throw new ArgumentException( + $"Furniture donor '{donorId}' has an unsupported grid footprint.", + nameof(gridItem)); + } + if (coordinate.x < 0 || coordinate.y < 0 || + !seen.Add($"{coordinate.x}:{coordinate.y}")) + { + throw new ArgumentException( + $"Furniture donor '{donorId}' has an unsupported grid footprint.", + nameof(gridItem)); + } + + footprint.Add(new FurnitureFootprintCoordinate(coordinate.x, coordinate.y)); + } + + return footprint; + } + + private static FurnitureSurfaceType ExtractSurfaceTypes( + S1EntityFramework.SurfaceItem surfaceItem, + string donorId) + { + if (surfaceItem.ValidSurfaceTypes == null || surfaceItem.ValidSurfaceTypes.Count == 0) + { + throw new ArgumentException( + $"Furniture donor '{donorId}' has no supported surfaces.", + nameof(surfaceItem)); + } + + FurnitureSurfaceType surfaceTypes = FurnitureSurfaceType.None; + for (int index = 0; index < surfaceItem.ValidSurfaceTypes.Count; index++) + { + S1Building.Surface.ESurfaceType surfaceType = surfaceItem.ValidSurfaceTypes[index]; + switch (surfaceType) + { + case S1Building.Surface.ESurfaceType.Wall: + surfaceTypes |= FurnitureSurfaceType.Wall; + break; + case S1Building.Surface.ESurfaceType.Roof: + surfaceTypes |= FurnitureSurfaceType.Roof; + break; + default: + throw new ArgumentException( + $"Furniture donor '{donorId}' uses an unsupported surface type.", + nameof(surfaceItem)); + } + } + + return surfaceTypes; + } + } +} diff --git a/S1API/Internal/Utils/CrossType.cs b/S1API/Internal/Utils/CrossType.cs index 5b59e791..7f588864 100644 --- a/S1API/Internal/Utils/CrossType.cs +++ b/S1API/Internal/Utils/CrossType.cs @@ -64,6 +64,31 @@ internal static bool Is(object obj, out T result) return false; } + /// + /// Checks whether an object has exactly the requested native type, excluding subclasses. + /// + internal static bool IsExact(object obj) +#if IL2CPPMELON + where T : Il2CppObjectBase +#elif MONOMELON + where T : class +#endif + { +#if IL2CPPMELON + if (obj is Object il2CppObj) + { + Type expected = Il2CppType.Of(); + Type actual = il2CppObj.GetIl2CppType(); + return expected.IsAssignableFrom(actual) && + actual.IsAssignableFrom(expected); + } + + return false; +#elif MONOMELON + return obj.GetType() == typeof(T); +#endif + } + /// /// Casts an object to a type. /// diff --git a/S1API/Items/Buildable/FurnitureCreator.cs b/S1API/Items/Buildable/FurnitureCreator.cs index 3b9cab75..f7eacf08 100644 --- a/S1API/Items/Buildable/FurnitureCreator.cs +++ b/S1API/Items/Buildable/FurnitureCreator.cs @@ -1,3 +1,14 @@ +#if (IL2CPPMELON) +using S1ItemFramework = Il2CppScheduleOne.ItemFramework; +using S1Registry = Il2CppScheduleOne.Registry; +#elif MONOMELON +using S1ItemFramework = ScheduleOne.ItemFramework; +using S1Registry = ScheduleOne.Registry; +#endif +using System; +using S1API.Internal.Building; +using S1API.Internal.Utils; + namespace S1API.Items.Buildable { /// @@ -13,5 +24,58 @@ public static FurnitureDefinitionBuilder CreateBuilder() { return new FurnitureDefinitionBuilder(); } + + /// + /// Creates a presentation-only furniture variant from a registered native donor. + /// The builder owns an isolated visual clone and independent material instances. + /// + /// The stable ID of the native grid or surface furniture donor. + /// A builder initialized with the donor's safe placement and item defaults. + /// + /// Thrown when the ID is invalid, the donor is missing, or the donor has specialized runtime behavior. + /// + public static FurnitureDefinitionBuilder CloneFrom(string sourceItemId) + { + if (string.IsNullOrWhiteSpace(sourceItemId)) + { + throw new ArgumentException( + "Source item ID cannot be null or whitespace.", + nameof(sourceItemId)); + } + + object? source = S1Registry.GetItem(sourceItemId); + if (source == null || + !CrossType.Is(source, out S1ItemFramework.BuildableItemDefinition definition)) + { + throw new ArgumentException( + $"Furniture donor '{sourceItemId}' was not found or is not buildable.", + nameof(sourceItemId)); + } + + return CreateCloneBuilder(definition); + } + + /// + /// Creates a presentation-only furniture variant from an existing buildable definition. + /// The builder owns an isolated visual clone and independent material instances. + /// + /// The native grid or surface furniture donor. + /// A builder initialized with the donor's safe placement and item defaults. + /// Thrown when is null. + /// Thrown when the donor has specialized runtime behavior. + public static FurnitureDefinitionBuilder CloneFrom(BuildableItemDefinition source) + { + if (source == null) + throw new ArgumentNullException(nameof(source)); + + return CreateCloneBuilder(source.S1BuildableItemDefinition); + } + + private static FurnitureDefinitionBuilder CreateCloneBuilder( + S1ItemFramework.BuildableItemDefinition source) + { + return new FurnitureDefinitionBuilder( + FurnitureVisualCloner.CreateSource(source)); + } } } diff --git a/S1API/Items/Buildable/FurnitureDefinitionBuilder.cs b/S1API/Items/Buildable/FurnitureDefinitionBuilder.cs index 9b0629ca..df0b3fb9 100644 --- a/S1API/Items/Buildable/FurnitureDefinitionBuilder.cs +++ b/S1API/Items/Buildable/FurnitureDefinitionBuilder.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using S1API.Internal.Building; using UnityEngine; +using Object = UnityEngine.Object; namespace S1API.Items.Buildable { @@ -13,6 +15,12 @@ public sealed class FurnitureDefinitionBuilder private string? _name; private string? _description; private GameObject? _model; + private string? _donorId; + private IReadOnlyList? _donorFootprint; + private bool _modelIsBuilderOwned; + private bool _modelConfigured; + private bool _centerModelOnFootprint = true; + private bool _isolateRepresentationMaterials; private FurniturePlacementMode _placementMode = FurniturePlacementMode.Grid; private int _footprintWidth = 1; private int _footprintDepth = 1; @@ -23,6 +31,7 @@ public sealed class FurnitureDefinitionBuilder private float _purchasePrice = 10f; private float _resellMultiplier = 0.5f; private Sprite? _icon; + private Sprite? _fallbackIcon; private bool _generateIcon = true; private int _generatedIconResolution = 512; @@ -30,6 +39,33 @@ internal FurnitureDefinitionBuilder() { } + internal FurnitureDefinitionBuilder(FurnitureCloneSource source) + { + _donorId = source.DonorId; + _model = source.Model; + _modelIsBuilderOwned = true; + _centerModelOnFootprint = false; + _isolateRepresentationMaterials = true; + _placementMode = source.PlacementMode; + _donorFootprint = source.Footprint; + if (source.Footprint != null) + { + foreach (FurnitureFootprintCoordinate coordinate in source.Footprint) + { + _footprintWidth = Math.Max(_footprintWidth, coordinate.X + 1); + _footprintDepth = Math.Max(_footprintDepth, coordinate.Y + 1); + } + } + + _surfaceTypes = source.SurfaceTypes; + _allowSurfaceRotation = source.AllowSurfaceRotation; + _buildSound = source.BuildSound; + _stackLimit = source.StackLimit; + _purchasePrice = source.PurchasePrice; + _resellMultiplier = source.ResellMultiplier; + _fallbackIcon = source.Icon; + } + /// Sets the stable registry ID and player-facing text. /// The stable item ID shared by every multiplayer peer. /// The player-facing item name. @@ -55,10 +91,59 @@ public FurnitureDefinitionBuilder WithModel(GameObject model) if (ReferenceEquals(model, null) || model == null) throw new ArgumentNullException(nameof(model)); + if (_modelIsBuilderOwned && ReferenceEquals(_model, model)) + return this; + if (_modelIsBuilderOwned && _model != null) + Object.DestroyImmediate(_model); + _model = model; + _modelIsBuilderOwned = false; + _centerModelOnFootprint = true; + _isolateRepresentationMaterials = false; return this; } + /// + /// Configures the isolated visual owned by a builder returned from + /// . + /// Renderer materials are independent from the donor before this callback runs. + /// + /// A callback that modifies the builder-owned visual clone. + /// This builder for fluent chaining. + /// Thrown when is null. + /// + /// Thrown when this builder was not created by CloneFrom, or the model was already configured. + /// + public FurnitureDefinitionBuilder ConfigureModel(Action configure) + { + if (configure == null) + throw new ArgumentNullException(nameof(configure)); + if (!_modelIsBuilderOwned || _model == null) + { + throw new InvalidOperationException( + "ConfigureModel is available only for an unmodified FurnitureCreator.CloneFrom builder."); + } + if (_modelConfigured) + throw new InvalidOperationException("The cloned furniture model is already configured."); + + try + { + configure(_model); + if (_model == null) + throw new InvalidOperationException("The furniture model callback destroyed its visual root."); + _modelConfigured = true; + return this; + } + catch + { + if (_model != null) + Object.DestroyImmediate(_model); + _model = null; + _modelIsBuilderOwned = false; + throw; + } + } + /// Chooses the native placement family. /// The grid or surface placement family to compose. /// This builder for fluent chaining. @@ -86,6 +171,7 @@ public FurnitureDefinitionBuilder WithFootprint(int width, int depth) _footprintWidth = width; _footprintDepth = depth; + _donorFootprint = null; return this; } @@ -200,7 +286,10 @@ public BuildableItemDefinition Build() _footprintWidth, _footprintDepth, _surfaceTypes, - _allowSurfaceRotation); + _allowSurfaceRotation, + _donorFootprint, + _centerModelOnFootprint, + _isolateRepresentationMaterials); var builder = new BuildableItemDefinitionBuilder(composition.TemplateDefinition) .WithBasicInfo(_id!, _name!, _description!, ItemCategory.Furniture) @@ -210,8 +299,9 @@ public BuildableItemDefinition Build() .WithBuiltItem(composition.BuiltItem) .WithStoredItem(composition.StoredItem.gameObject) .WithEquippable(composition.Equippable); - if (_icon != null) - builder.WithIcon(_icon); + Sprite? initialIcon = _icon != null ? _icon : _fallbackIcon; + if (initialIcon != null) + builder.WithIcon(initialIcon); BuildableItemDefinition definition = builder.Build(); Transform? visual = composition.BuiltItem.transform.Find( @@ -219,14 +309,35 @@ public BuildableItemDefinition Build() if (visual == null) throw new InvalidOperationException("Composed furniture has no placement visual source."); - BuildableGhostRuntime.RegisterVisualSource( - _id!, - visual.gameObject, - BuildableGhostRuntime.FurnitureGhostVisualName, - replaceExistingVisual: true); + if (_isolateRepresentationMaterials) + { + BuildableGhostRuntime.RegisterVisual( + _id!, + parent => + { + GameObject ghostVisual = FurnitureVisualCloner.CloneOwnedVisual( + visual.gameObject); + ghostVisual.name = BuildableGhostRuntime.FurnitureGhostVisualName; + ghostVisual.transform.SetParent(parent, false); + return ghostVisual; + }, + replaceExistingVisual: true); + } + else + { + BuildableGhostRuntime.RegisterVisualSource( + _id!, + visual.gameObject, + BuildableGhostRuntime.FurnitureGhostVisualName, + replaceExistingVisual: true); + } if (_generateIcon) { - FurnitureIconRuntime.Queue(definition, visual, _generatedIconResolution); + FurnitureIconRuntime.Queue( + definition, + visual, + _generatedIconResolution, + _isolateRepresentationMaterials); } return definition; @@ -242,6 +353,7 @@ private void Validate() throw new InvalidOperationException("Furniture description must be configured before Build()."); if (_model == null) throw new InvalidOperationException("Furniture model must be configured before Build()."); + FurnitureClonePolicy.ValidateNewId(_id!, _donorId); } } } diff --git a/S1API/docs/furniture-items.md b/S1API/docs/furniture-items.md index 0355fec3..d0114e60 100644 --- a/S1API/docs/furniture-items.md +++ b/S1API/docs/furniture-items.md @@ -38,6 +38,47 @@ var chair = FurnitureCreator.CreateBuilder() Grid footprint cells are 0.5 metres. Size the footprint to cover the model's horizontal bounds; for example, a model just under one metre wide and deep uses `WithFootprint(2, 2)`. +## Native furniture variants + +Use `CloneFrom` when a variant should reuse an ordinary native furniture model. S1API accepts only +donors whose placed prefab uses the exact native `GridItem` or `SurfaceItem` type. Machines, +stations, storage, toggleable objects, and other specialized subclasses are rejected because a +presentation clone cannot preserve their runtime behavior. + +```csharp +var blueClock = FurnitureCreator.CloneFrom("grandfatherclock") + .WithBasicInfo( + "my-mod:blue-grandfather-clock", + "Blue Grandfather Clock", + "A grandfather clock with a blue finish.") + .ConfigureModel(model => + { + foreach (Renderer renderer in model.GetComponentsInChildren(true)) + { + foreach (Material material in renderer.sharedMaterials) + { + if (material != null && material.HasProperty("_BaseColor")) + material.SetColor("_BaseColor", new Color(0.08f, 0.2f, 0.65f)); + if (material != null && material.HasProperty("_Color")) + material.SetColor("_Color", new Color(0.08f, 0.2f, 0.65f)); + } + } + }) + .WithPricing(250f) + .WithGeneratedIcon() + .Build(); +``` + +`ConfigureModel` runs once against a builder-owned hierarchy. S1API has already replaced every +renderer material with a private instance, so material edits cannot change the donor or other +native furniture. The final placed, stored, ghost, and icon representations also receive separate +material instances. + +The clone path preserves the donor's exact grid cells or surface flags, rotation setting, build +sound, price, resale multiplier, stack limit, and icon fallback. Any corresponding builder method +overrides that default. The variant must use a new stable ID; `Build()` rejects the donor ID even if +only its casing differs. + ## Placement ghost Furniture created with `FurnitureCreator` does not need separate ghost setup. `WithModel(model)` @@ -82,10 +123,14 @@ Every multiplayer peer must load the same mod version and register the same stab placement mode, and footprint. Placement authority, observer initialization, late joins, and property save/load then travel through the game's native grid or surface item flow. -The generated icon path is the default. Furniture still registers during pre-load using its native -template icon as a temporary fallback; S1API replaces that icon after the gameplay rendering rig is -ready and refreshes bound inventory/shop UI. Call `WithIcon(sprite)` when an art-directed icon is -preferred. +Native variants also require every peer to register the same donor ID and apply the same +deterministic `ConfigureModel` changes before save restoration or placement. S1API does not send +models or materials over the network. + +The generated icon path is the default. Furniture still registers during pre-load with a temporary +fallback icon: the donor icon for native variants or the generic template icon for supplied models. +S1API replaces that icon after the gameplay rendering rig is ready and refreshes bound +inventory/shop UI. Call `WithIcon(sprite)` when an art-directed icon is preferred. ## Placement scope diff --git a/S1API/docs/item-builder-reference.md b/S1API/docs/item-builder-reference.md index 5daf0c1e..c3f5b04e 100644 --- a/S1API/docs/item-builder-reference.md +++ b/S1API/docs/item-builder-reference.md @@ -27,8 +27,10 @@ This page collects the main builder methods, advanced item-instance notes, and i ## FurnitureDefinitionBuilder Methods +- `FurnitureCreator.CloneFrom(donor)` - Starts a presentation-only variant from native grid or surface furniture - `WithBasicInfo(id, name, description)` - Sets the stable ID and player-facing text - `WithModel(model)` - Supplies the model cloned into all native furniture representations +- `ConfigureModel(callback)` - Modifies the isolated model owned by a `CloneFrom` builder - `WithPlacement(mode)` - Selects grid or surface placement - `WithFootprint(width, depth)` - Sets a grid footprint in 0.5 metre tiles - `WithSurfacePlacement(types, allowRotation)` - Selects wall/roof compatibility