diff --git a/Building/BuildingBuilder.cs b/Building/BuildingBuilder.cs index 5c24255..0389f3b 100644 --- a/Building/BuildingBuilder.cs +++ b/Building/BuildingBuilder.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using S1MAPI.Building.Config; using S1MAPI.Building.Structural; using S1MAPI.Building.Interior; @@ -18,7 +19,7 @@ namespace S1MAPI.Building /// .WithConfig(BuildingConfig.Dispensary) /// .AddFloor() /// .AddCeiling() - /// .AddWalls(southDoor: true, eastWindow: true, westWindow: true) + /// .AddWalls(southDoor: true, eastDoor: true, westWindow: true) /// .AddLights() /// .AddFurniture(FurnitureType.Counter, "north") /// .Build(); @@ -32,12 +33,65 @@ public sealed class BuildingBuilder private BuildingConfig _config; private Vector3 _roomSize; + // Part registry for post-build targeting + private readonly BuildingPartRegistry _registry; + // Lazy-initialized builders private WallBuilder? _wallBuilder; private FurnitureBuilder? _furnitureBuilder; private LightingBuilder? _lightingBuilder; private DecorBuilder? _decorBuilder; + private RoofBuilder? _roofBuilder; private PrefabPlacer? _prefabPlacer; + private InteriorWallBuilder? _interiorWallBuilder; + + // Interior wall physics layer (-1 = default layer, no change) + private int _interiorWallLayer = -1; + + // Cached interior doorway metadata survives InvalidateBuilders() + private IReadOnlyList? _interiorDoorwaysCache; + + // Foundation and stair tracking for NavMesh link computation + private float _foundationHeight; + private float _foundationExpandX; + private float _foundationExpandZ; + private readonly List _stairs = new List(); + + /// + /// Internal record capturing the full stair specification so + /// can reproduce the exact run distance. + /// + private readonly struct StairSpec + { + public readonly WallSide Wall; + public readonly float FoundationHeight; + public readonly float Width; + public readonly float LateralOffset; + public readonly float MaxStepHeight; + public readonly float StepDepth; + public readonly StairStyle Style; + public readonly bool FlushWithFloor; + + public StairSpec(WallSide wall, float foundationHeight, float width, + float lateralOffset, float maxStepHeight, float stepDepth, + StairStyle style, bool flushWithFloor) + { + Wall = wall; + FoundationHeight = foundationHeight; + Width = width; + LateralOffset = lateralOffset; + MaxStepHeight = maxStepHeight; + StepDepth = stepDepth; + Style = style; + FlushWithFloor = flushWithFloor; + } + } + + // Stored wall openings for cross-builder communication (e.g., base molding gap) + private WallOpening? _northOpening; + private WallOpening? _southOpening; + private WallOpening? _eastOpening; + private WallOpening? _westOpening; #endregion @@ -51,6 +105,7 @@ public BuildingBuilder(string name) { _name = name; _root = new GameObject(name); + _registry = new BuildingPartRegistry(); _config = BuildingConfig.Default; _roomSize = _config.Size; } @@ -113,20 +168,11 @@ public BuildingBuilder DefineRoom(float width, float height, float depth) /// This builder for chaining public BuildingBuilder AddFloor(Color? color = null, Material? material = null) { - var palette = color.HasValue || material != null - ? _config.Palette.Clone().WithFloor(material!) - : _config.Palette; - - if (color.HasValue) - { - palette.FloorColor = color.Value; - } - if (material != null) - { - palette.FloorMaterial = material; - } + if (color.HasValue) _config.Palette.FloorColor = color.Value; + if (material != null) _config.Palette.FloorMaterial = material; - GetDecorBuilder(palette).AddFloor(_config.FloorThickness); + var floor = GetDecorBuilder().AddFloor(_config.FloorThickness); + _registry.Register(BuildingPart.Floor, floor); return this; } @@ -138,15 +184,11 @@ public BuildingBuilder AddFloor(Color? color = null, Material? material = null) /// This builder for chaining public BuildingBuilder AddCeiling(Color? color = null, Material? material = null) { - var palette = _config.Palette; - if (color.HasValue || material != null) - { - palette = palette.Clone(); - if (color.HasValue) palette.CeilingColor = color.Value; - if (material != null) palette.CeilingMaterial = material; - } + if (color.HasValue) _config.Palette.CeilingColor = color.Value; + if (material != null) _config.Palette.CeilingMaterial = material; - GetDecorBuilder(palette).AddCeiling(_config.CeilingThickness); + var ceiling = GetDecorBuilder().AddCeiling(_config.CeilingThickness); + _registry.Register(BuildingPart.Ceiling, ceiling); return this; } @@ -157,16 +199,26 @@ public BuildingBuilder AddCeiling(Color? color = null, Material? material = null /// /// Add door on north wall /// Add door on south wall + /// Add door on east wall + /// Add door on west wall + /// Add window on north wall + /// Add window on south wall /// Add window on east wall /// Add window on west wall + /// Add windows alongside north door (requires northDoor) + /// Add windows alongside south door (requires southDoor) + /// Add windows alongside east door (requires eastDoor) + /// Add windows alongside west door (requires westDoor) /// Optional wall color override /// Optional wall material override /// This builder for chaining public BuildingBuilder AddWalls( - bool northDoor = false, - bool southDoor = false, - bool eastWindow = false, - bool westWindow = false, + bool northDoor = false, bool southDoor = false, + bool eastDoor = false, bool westDoor = false, + bool northWindow = false, bool southWindow = false, + bool eastWindow = false, bool westWindow = false, + bool northDoorWindows = false, bool southDoorWindows = false, + bool eastDoorWindows = false, bool westDoorWindows = false, Color? color = null, Material? material = null) { @@ -178,12 +230,26 @@ public BuildingBuilder AddWalls( if (material != null) palette.WallMaterial = material; } + _northOpening = northDoor + ? (northDoorWindows ? WallOpening.DoorWithWindows() : WallOpening.Door()) + : (northWindow ? WallOpening.Window() : null); + _southOpening = southDoor + ? (southDoorWindows ? WallOpening.DoorWithWindows() : WallOpening.Door()) + : (southWindow ? WallOpening.Window() : null); + _eastOpening = eastDoor + ? (eastDoorWindows ? WallOpening.DoorWithWindows() : WallOpening.Door()) + : (eastWindow ? WallOpening.Window() : null); + _westOpening = westDoor + ? (westDoorWindows ? WallOpening.DoorWithWindows() : WallOpening.Door()) + : (westWindow ? WallOpening.Window() : null); + var builder = GetWallBuilder(palette); - builder.BuildWalls( - northOpening: northDoor ? WallOpening.Door() : null, - southOpening: southDoor ? WallOpening.Door() : null, - eastOpening: eastWindow ? WallOpening.Window() : null, - westOpening: westWindow ? WallOpening.Window() : null); + var wallsContainer = builder.BuildWalls( + northOpening: _northOpening, + southOpening: _southOpening, + eastOpening: _eastOpening, + westOpening: _westOpening); + RegisterWallChildren(wallsContainer); return this; } @@ -202,7 +268,171 @@ public BuildingBuilder AddWalls( WallOpening? east = null, WallOpening? west = null) { - GetWallBuilder().BuildWalls(north, south, east, west); + _northOpening = north; + _southOpening = south; + _eastOpening = east; + _westOpening = west; + + var wallsContainer = GetWallBuilder().BuildWalls(north, south, east, west); + RegisterWallChildren(wallsContainer); + return this; + } + + /// + /// Add walls with fine-grained control over openings and per-wall appearance. + /// Walls without an appearance override use the palette defaults. + /// + /// North wall opening configuration + /// South wall opening configuration + /// East wall opening configuration + /// West wall opening configuration + /// Per-wall material/color overrides keyed by wall side + /// This builder for chaining + public BuildingBuilder AddWalls( + WallOpening? north, + WallOpening? south, + WallOpening? east, + WallOpening? west, + IReadOnlyDictionary wallAppearances) + { + _northOpening = north; + _southOpening = south; + _eastOpening = east; + _westOpening = west; + + var wallsContainer = GetWallBuilder().BuildWalls(north, south, east, west, wallAppearances); + RegisterWallChildren(wallsContainer); + return this; + } + + #endregion + + #region Interior Walls + + /// + /// Set the physics layer for interior wall GameObjects. + /// Use this to place interior walls on a layer outside the placement raycast mask + /// so the ghost model passes through them while players still physically collide. + /// + /// Unity layer index (0–31). -1 leaves walls on the default layer. + /// This builder for chaining + public BuildingBuilder WithInteriorWallLayer(int layer) + { + if (_interiorWallBuilder != null) + throw new System.InvalidOperationException( + "[BuildingBuilder.WithInteriorWallLayer] Must be called before AddInteriorWall."); + _interiorWallLayer = layer; + return this; + } + + /// + /// Add an interior wall spanning a sub-region of the room. + /// + /// Axis the wall runs along (X or Z) + /// Position on the perpendicular axis (Z for X-axis walls, X for Z-axis walls) + /// Start coordinate along the wall's axis + /// End coordinate along the wall's axis + /// Optional opening (door or window) centered in the wall + /// Optional wall color override (defaults to palette wall color) + /// Optional wall material override (defaults to palette wall material) + /// This builder for chaining + public BuildingBuilder AddInteriorWall( + InteriorWallAxis axis, float position, float from, float to, + WallOpening? opening = null, + Color? color = null, Material? material = null) + { + var def = new InteriorWallDefinition(axis, position, from, to, opening, color, material); + var wall = GetInteriorWallBuilder().BuildInteriorWall(def); + if (wall != null) + _registry.Register(BuildingPart.InteriorWalls, wall); + return this; + } + + /// + /// Add an interior wall spanning the full room width along the specified axis. + /// + /// Axis the wall runs along (X or Z) + /// Position on the perpendicular axis (Z for X-axis walls, X for Z-axis walls) + /// Optional opening (door or window) centered in the wall + /// Optional wall color override (defaults to palette wall color) + /// Optional wall material override (defaults to palette wall material) + /// This builder for chaining + public BuildingBuilder AddInteriorWall( + InteriorWallAxis axis, float position, + WallOpening? opening = null, + Color? color = null, Material? material = null) + { + float axisMax = axis == InteriorWallAxis.X ? _roomSize.x : _roomSize.z; + return AddInteriorWall(axis, position, 0f, axisMax, opening, color, material); + } + + /// + /// Doorway positions recorded from all interior walls. + /// Each entry provides center, dimensions, and orientation for future NavMesh link generation. + /// + public IReadOnlyList InteriorDoorways => + _interiorWallBuilder?.Doorways ?? _interiorDoorwaysCache ?? (IReadOnlyList)System.Array.Empty(); + + /// + /// Create a configured for this building. + /// Collects exterior and interior doorway positions, stair geometry, and building dimensions. + /// Call on the returned instance after positioning the building. + /// + /// A configured builder ready to build + public NavigationBuilder CreateNavigationBuilder() + { + var doorways = new List(); + + // Exterior doorways (with optional stair base positions) + TryAddExteriorDoor(WallSide.North, _northOpening, doorways); + TryAddExteriorDoor(WallSide.South, _southOpening, doorways); + TryAddExteriorDoor(WallSide.East, _eastOpening, doorways); + TryAddExteriorDoor(WallSide.West, _westOpening, doorways); + + // Interior doorways (threshold generation + door panel collider filtering) + foreach (DoorwayInfo interior in InteriorDoorways) + { + Vector3 normal = interior.FacesAlongZ ? Vector3.forward : Vector3.right; + // DoorwayInfo.Center.y is at mid-door height; NavDoorwayInfo expects Y=0 (floor level) + Vector3 center = new Vector3(interior.Center.x, 0f, interior.Center.z); + doorways.Add(new NavDoorwayInfo( + center, interior.Width, interior.Height, + normal, interior.WallThickness, isInterior: true)); + } + + bool hasExterior = false; + for (int i = 0; i < doorways.Count; i++) + { + if (!doorways[i].IsInterior) { hasExterior = true; break; } + } + if (!hasExterior) + throw new System.InvalidOperationException( + "[BuildingBuilder.CreateNavigationBuilder] No exterior doorways found. " + + "NavigationBuilder requires at least one exterior door (AddWalls with a door opening)."); + + return new NavigationBuilder( + _root.transform, _roomSize, + doorways, _config.WallThickness, _foundationHeight); + } + + /// + /// Flatten terrain under the building footprint. + /// Must be called after the building is positioned in the scene. + /// + /// Extra padding around the footprint in meters. + /// Clear grass and detail layers in the flattened region. + /// Distance for smooth transition back to natural terrain. + public BuildingBuilder FlattenTerrain( + float padding = Constants.Terrain.DefaultFlattenPadding, + bool clearDetails = true, float blendDistance = Constants.Terrain.DefaultBlendDistance) + { + float targetWorldY = _root.transform.position.y - _foundationHeight; + Vector3 footprint = new Vector3( + _roomSize.x + _foundationExpandX * 2f, + _roomSize.y, + _roomSize.z + _foundationExpandZ * 2f); + TerrainFlattener.FlattenUnder( + _root, footprint, targetWorldY, padding, clearDetails, blendDistance); return this; } @@ -212,25 +442,95 @@ public BuildingBuilder AddWalls( /// /// Add decorative trim around the roofline. + /// Not needed when using or , + /// which include their own roof structure. Useful for custom roof designs with . /// /// Trim height in meters /// Optional material override /// This builder for chaining public BuildingBuilder AddRoofTrim(float height = 0.3f, Material? material = null) { - GetDecorBuilder().AddRoofTrim(height, material); + var trim = GetDecorBuilder().AddRoofTrim(height, material); + _registry.Register(BuildingPart.Trim, trim); return this; } /// /// Add a secondary decorative trim above the roofline. + /// Not needed when using or , + /// which include their own roof structure. Useful for custom roof designs with . /// /// Trim height in meters /// Optional material override /// This builder for chaining public BuildingBuilder AddSecondaryRoofTrim(float height = 0.15f, Material? material = null) { - GetDecorBuilder().AddSecondaryRoofTrim(height, material); + var trim = GetDecorBuilder().AddSecondaryRoofTrim(height, material); + _registry.Register(BuildingPart.Accent, trim); + return this; + } + + /// + /// Add a parapet roof (raised wall and cap above the roofline). + /// The cap extends past the parapet wall by the overhang amount, creating a ledge. + /// Includes a thin roof slab at ceiling height. For interior ceilings, use + /// separately — the ceiling sits just below the roof slab with no overlap. + /// Use for a prominent commercial look or + /// for a subtler profile. + /// + /// Sizing preset (Deep or Shallow). Overridden by explicit dimensions. + /// Height of the parapet wall in meters. Null uses preset default. + /// Depth of the parapet wall. Null uses wall thickness + padding. + /// Height of the cap. Null uses preset default. + /// How far the cap extends past the parapet wall on each side. Null uses preset default. + /// Color override for the parapet wall. + /// Material override for the parapet wall. + /// Color override for the cap. + /// Material override for the cap. + /// This builder for chaining + public BuildingBuilder AddParapetRoof( + ParapetPreset preset = ParapetPreset.Deep, + float? parapetHeight = null, + float? parapetDepth = null, + float? capHeight = null, + float? capOverhang = null, + Color? parapetColor = null, + Material? parapetMaterial = null, + Color? capColor = null, + Material? capMaterial = null) + { + var roof = GetRoofBuilder().AddParapetRoof(preset, parapetHeight, parapetDepth, + capHeight, capOverhang, parapetColor, parapetMaterial, capColor, capMaterial); + _registry.Register(BuildingPart.Roof, roof); + return this; + } + + /// + /// Add a hip (four-slope) roof using custom mesh geometry. + /// All four sides slope inward to a central ridge that is shorter than the building length. + /// For square buildings, the ridge collapses to a point (pyramid roof). + /// A base slab sits at ceiling height giving the roof visible thickness from below. + /// For interior ceilings, use separately — the ceiling sits + /// just below the roof slab with no overlap. + /// + /// Height of the ridge peak above the ceiling in meters. + /// How far the roof eaves extend past the walls in meters. + /// If true, ridge runs along X axis. If false, along Z. Null auto-selects the longer axis. + /// Color for the sloped roof planes. + /// Material for the sloped roof planes. Null uses fallback color. + /// Height of the 3D base slab beneath the slopes. 0 disables the slab. + /// This builder for chaining + public BuildingBuilder AddHipRoof( + float ridgeHeight = Constants.Roof.DefaultRidgeHeight, + float overhang = Constants.Roof.DefaultOverhang, + bool? ridgeAlongX = null, + Color? roofColor = null, + Material? roofMaterial = null, + float baseSlabHeight = Constants.Roof.DefaultBaseSlabHeight) + { + var roof = GetRoofBuilder().AddHipRoof(ridgeHeight, overhang, ridgeAlongX, + roofColor, roofMaterial, baseSlabHeight); + _registry.Register(BuildingPart.Roof, roof); return this; } @@ -242,7 +542,24 @@ public BuildingBuilder AddSecondaryRoofTrim(float height = 0.15f, Material? mate /// This builder for chaining public BuildingBuilder AddCornerPillars(float width = 0.4f, Material? material = null) { - GetDecorBuilder().AddCornerPillars(width, material); + var pillars = GetDecorBuilder().AddCornerPillars(width, material); + _registry.Register(BuildingPart.Pillars, pillars); + return this; + } + + /// + /// Add thin vertical trim strips at the four corners of the building. + /// Each corner gets two perpendicular strips forming a right angle that seamlessly + /// connects with horizontal trims (, ). + /// + /// Visible width of each trim strip on the wall face in meters + /// How far the trim protrudes past the wall surface in meters + /// Optional material override + /// This builder for chaining + public BuildingBuilder AddCornerTrim(float width = 0.3f, float depth = 0.1f, Material? material = null) + { + var cornerTrim = GetDecorBuilder().AddCornerTrim(width, depth, material); + _registry.Register(BuildingPart.Trim, cornerTrim); return this; } @@ -252,10 +569,83 @@ public BuildingBuilder AddCornerPillars(float width = 0.4f, Material? material = /// Foundation depth in meters /// Extra expansion on X axis /// Extra expansion on Z axis + /// Optional color override (defaults to grey) + /// Optional material override + /// This builder for chaining + public BuildingBuilder AddFoundation(float height = 2.0f, float expandX = 0f, float expandZ = 0f, Color? color = null, Material? material = null) + { + _foundationHeight = height; + _foundationExpandX = expandX; + _foundationExpandZ = expandZ; + var foundation = GetDecorBuilder().AddFoundation(height, expandX, expandZ, color, material); + _registry.Register(BuildingPart.Foundation, foundation); + return this; + } + + /// + /// Add stairs from ground level up to the building floor on the specified wall. + /// Automatically aligns with the door opening offset on the specified wall. + /// Supports multiple visual styles: Solid (default concrete box steps), ClosedRiser (two-tone wood with risers), + /// or OpenStringer (plank treads on diagonal stringer beams). + /// + /// Which wall the stairs attach to + /// Foundation height in meters (must match AddFoundation height) + /// Maximum height per step. Lower values create more, shallower steps. (Solid only) + /// Step width in meters (Solid only) + /// Step depth (tread) in meters. Controls how far stairs extend outward. (Solid only) + /// Optional color override — defaults to palette floor color (Solid only) + /// Optional material override — defaults to palette floor material (Solid only) + /// Visual style of stairs to generate + /// If true, topmost step is flush with floor level. If false (default), topmost step is one step below floor. (Solid only) + /// Vertical gap between foundation edge and top step. ClosedRiser/OpenStringer default to 0 (flush). + /// This builder for chaining + public BuildingBuilder AddStairs( + WallSide wall, + float foundationHeight = 2.0f, + float maxStepHeight = Constants.Spatial.DefaultMaxStepHeight, + float width = 2.5f, + float stepDepth = Constants.Spatial.DefaultStepDepth, + Color? color = null, + Material? material = null, + StairStyle style = StairStyle.Solid, + bool flushWithFloor = false, + float gap = 0f) + { + float lateralOffset = GetDoorOffset(wall); + _stairs.Add(new StairSpec(wall, foundationHeight, width, lateralOffset, + maxStepHeight, stepDepth, style, flushWithFloor)); + var stairs = GetDecorBuilder().AddStairs(wall, foundationHeight, maxStepHeight, width, stepDepth, color, material, style, flushWithFloor, gap, lateralOffset); + _registry.Register(BuildingPart.Stairs, stairs); + return this; + } + + /// + /// Add trim-style door frames around door openings. + /// Must be called after AddWalls. + /// + /// Optional material override + /// This builder for chaining + public BuildingBuilder AddDoorFrames(Material? material = null) + { + var frames = GetDecorBuilder().AddDoorFrames( + _northOpening, _southOpening, _eastOpening, _westOpening, material); + _registry.Register(BuildingPart.Trim, frames); + return this; + } + + /// + /// Add trim-style door frames around interior doorway openings. + /// Must be called after AddInteriorWall. + /// + /// Optional material override /// This builder for chaining - public BuildingBuilder AddFoundation(float height = 2.0f, float expandX = 0f, float expandZ = 0f) + public BuildingBuilder AddInteriorDoorFrames(Material? material = null) { - GetDecorBuilder().AddFoundation(height, expandX, expandZ); + if (_interiorWallBuilder != null && _interiorWallBuilder.Doorways.Count > 0) + { + var frames = GetDecorBuilder().AddInteriorDoorFrames(_interiorWallBuilder.Doorways, material: material); + _registry.Register(BuildingPart.Trim, frames); + } return this; } @@ -265,10 +655,14 @@ public BuildingBuilder AddFoundation(float height = 2.0f, float expandX = 0f, fl /// Molding height in meters /// Molding depth in meters /// Optional material override + /// Wall sides to omit molding from (null = include all walls) /// This builder for chaining - public BuildingBuilder AddBaseMolding(float height = 0.3f, float depth = 0.1f, Material? material = null) + public BuildingBuilder AddBaseMolding(float height = 0.3f, float depth = 0.1f, Material? material = null, + IEnumerable? skipWalls = null) { - GetDecorBuilder().AddBaseMolding(height, depth, material); + var molding = GetDecorBuilder().AddBaseMolding(height, depth, material, + _northOpening, _southOpening, _eastOpening, _westOpening, skipWalls); + _registry.Register(BuildingPart.Trim, molding); return this; } @@ -341,23 +735,55 @@ public BuildingBuilder AddFurniture(FurnitureType type, Vector3 position, Quater /// Prefab reference from GamePrefabs /// Local position /// Local rotation + /// Optional callback invoked with the instantiated GameObject /// This builder for chaining - public BuildingBuilder AddPrefab(PrefabRef prefab, Vector3 position, Quaternion rotation) + public BuildingBuilder AddPrefab(PrefabRef prefab, Vector3 position, Quaternion rotation, Action? onCreated = null) { - GetPrefabPlacer().Place(prefab, position, rotation); + GameObject? instance = GetPrefabPlacer().Place(prefab, position, rotation); + if (instance != null) + { + onCreated?.Invoke(instance); + } return this; } /// - /// Add sliding double doors at a door opening. + /// Add sliding double doors at a door opening. Server only — returns the builder + /// unchanged on clients (the door is replicated via FishNet). /// + /// + /// Multiplayer behavior: + /// + /// Server/host: Instantiates and network-spawns the door. + /// FishNet replicates it to all clients. The callback fires. + /// Client: Returns immediately — the door is not created locally. + /// The server-spawned door arrives via FishNet replication and is automatically parented + /// to the building hierarchy with material/text customization applied. + /// + /// DoorController server-gating: The game's DoorController gates + /// ALL proximity sensor callbacks (PlayerVicinityEnter/Exit, NPCVicinityEnter/Exit) and + /// auto-close logic behind InstanceFinder.IsServer. This means on clients: + /// + /// Doors will NOT auto-open when players/NPCs approach + /// Doors will NOT auto-close when players/NPCs leave + /// Manual interaction (clicking the door handle) DOES work via ServerRpc + /// + /// S1MAPI cannot implement client-side auto-open/close because it requires game assembly + /// types (DoorController, EDoorSide, Player) that S1MAPI intentionally does not reference. + /// Consumer mods that need this behavior must implement their own proximity polling + /// using DoorController.SetIsOpen_Server (public, RequireOwnership=false, RunLocally=true). + /// /// Local position for doors /// Local rotation /// Text for opening hours sign + /// Optional callback invoked with the instantiated door GameObject + /// before activation. Runs after internal customization (material, opening hours text) but + /// before Awake/OnEnable fire, so sensors see configured values. + /// Only fires on the server — will NOT fire on clients. /// This builder for chaining - public BuildingBuilder AddSlidingDoors(Vector3 position, Quaternion rotation, string openingHours = "6AM-6PM") + public BuildingBuilder AddSlidingDoors(Vector3 position, Quaternion rotation, string openingHours = "6AM-6PM", Action? onCreated = null) { - GetPrefabPlacer().PlaceSlidingDoors(position, rotation, openingHours, Materials.MetalDarkGrey); + GetPrefabPlacer().PlaceSlidingDoors(position, rotation, openingHours, Materials.MetalDarkGrey, onCreated); return this; } @@ -402,16 +828,35 @@ public GameObject Build(Action postBuild) /// public BuildingConfig Config => _config; + /// + /// Post-build registry for targeting specific building parts (walls, floor, trim, etc.). + /// + public BuildingPartRegistry Registry => _registry; + + /// + /// Grid cell size computed from room dimensions. + /// Furniture placement and interior pathfinding both use this value + /// so the two grids are always aligned. + /// + public float GridCellSize => + BuildingUtilities.ComputeGridCellSize(_roomSize.x, _roomSize.z); + #endregion #region Private Methods - Builder Access private void InvalidateBuilders() { + // Preserve interior doorway metadata before clearing the builder + if (_interiorWallBuilder != null && _interiorWallBuilder.Doorways.Count > 0) + _interiorDoorwaysCache = _interiorWallBuilder.Doorways; + _wallBuilder = null; _furnitureBuilder = null; _lightingBuilder = null; _decorBuilder = null; + _roofBuilder = null; + _interiorWallBuilder = null; // PrefabPlacer doesn't depend on room size } @@ -435,9 +880,20 @@ private LightingBuilder GetLightingBuilder() return _lightingBuilder ??= new LightingBuilder(_root.transform, _roomSize, _config.Palette); } - private DecorBuilder GetDecorBuilder(BuildingPalette? palette = null) + private DecorBuilder GetDecorBuilder() + { + return _decorBuilder ??= new DecorBuilder(_root.transform, _roomSize, _config.Palette); + } + + private RoofBuilder GetRoofBuilder() + { + return _roofBuilder ??= new RoofBuilder(_root.transform, _roomSize, _config.WallThickness, _config.Palette); + } + + private InteriorWallBuilder GetInteriorWallBuilder() { - return _decorBuilder ??= new DecorBuilder(_root.transform, _roomSize, palette ?? _config.Palette); + return _interiorWallBuilder ??= new InteriorWallBuilder( + _root.transform, _roomSize, _config.WallThickness, _config.Palette, _interiorWallLayer); } private PrefabPlacer GetPrefabPlacer() @@ -445,6 +901,136 @@ private PrefabPlacer GetPrefabPlacer() return _prefabPlacer ??= new PrefabPlacer(_root.transform); } + private float GetDoorOffset(WallSide wall) + { + WallOpening? opening = wall switch + { + WallSide.North => _northOpening, + WallSide.South => _southOpening, + WallSide.East => _eastOpening, + WallSide.West => _westOpening, + _ => null + }; + return opening?.Offset ?? 0f; + } + + private void RegisterWallChildren(GameObject wallsContainer) + { + for (int i = 0; i < wallsContainer.transform.childCount; i++) + { + Transform child = wallsContainer.transform.GetChild(i); + WallSide? side = ParseWallSide(child.name); + + // Solid wall — the child itself is the wall segment (has a Renderer) + if (child.GetComponent() != null) + { + if (side.HasValue) + _registry.Register(side.Value, child.gameObject); + else + _registry.Register(BuildingPart.ExteriorWalls, child.gameObject); + continue; + } + + // Container (wall with door/window) — register individual wall segments, + // skipping window frames and glass so they don't get material-swapped + for (int j = 0; j < child.childCount; j++) + { + Transform segment = child.GetChild(j); + string segName = segment.name; + if (segName.StartsWith(Constants.Window.FrameNamePrefix) || segName.Contains(Constants.Window.GlassNameSubstring)) + continue; + + if (side.HasValue) + _registry.Register(side.Value, segment.gameObject); + else + _registry.Register(BuildingPart.ExteriorWalls, segment.gameObject); + } + } + } + + private static WallSide? ParseWallSide(string name) + { + if (name.StartsWith("North")) return WallSide.North; + if (name.StartsWith("South")) return WallSide.South; + if (name.StartsWith("East")) return WallSide.East; + if (name.StartsWith("West")) return WallSide.West; + return null; + } + + #endregion + + #region Private Methods - NavMesh + + /// + /// If is a door, compute its center, inward normal, + /// and optional stair base position, then append a to . + /// + private void TryAddExteriorDoor( + WallSide wall, WallOpening? opening, List list) + { + if (opening == null || opening.Type != WallOpeningType.Door) return; + + Vector3 center = wall switch + { + WallSide.North => new Vector3(_roomSize.x / 2f + opening.Offset, 0f, _roomSize.z), + WallSide.South => new Vector3(_roomSize.x / 2f + opening.Offset, 0f, 0f), + WallSide.East => new Vector3(_roomSize.x, 0f, _roomSize.z / 2f + opening.Offset), + WallSide.West => new Vector3(0f, 0f, _roomSize.z / 2f + opening.Offset), + _ => Vector3.zero + }; + + Vector3 inward = wall switch + { + WallSide.North => Vector3.back, + WallSide.South => Vector3.forward, + WallSide.East => Vector3.left, + WallSide.West => Vector3.right, + _ => Vector3.zero + }; + + Vector3? stairBase = ComputeStairBasePosition(wall, center, inward); + + list.Add(new NavDoorwayInfo( + center, opening.Width, opening.Height, + inward, _config.WallThickness, stairBase)); + } + + /// + /// Compute the ground-level position at the base of the stairs for a given wall. + /// Returns null when no stairs exist on the wall or no foundation is present. + /// + private Vector3? ComputeStairBasePosition(WallSide wall, Vector3 doorCenter, Vector3 inwardNormal) + { + if (_foundationHeight <= 0f) return null; + + foreach (StairSpec spec in _stairs) + { + if (spec.Wall != wall) continue; + + // Mirror the step count and visible steps logic from DecorBuilder stair builders + int stepCount = Mathf.Max(2, Mathf.CeilToInt(spec.FoundationHeight / spec.MaxStepHeight)); + int visibleSteps = spec.Style == StairStyle.ClosedRiser || spec.FlushWithFloor + ? stepCount + : stepCount - 1; + + // Match the clearance used by DecorBuilder: padding + foundation expand + bool isNorthSouth = wall == WallSide.North || wall == WallSide.South; + float foundationClearance = Constants.Spatial.FoundationPadding + + (isNorthSouth ? _foundationExpandZ : _foundationExpandX); + + // Bottom step far edge distance from wall = visibleSteps * stepDepth + clearance + // Add half a step depth as standing buffer beyond the stair edge + float stairRun = visibleSteps * spec.StepDepth + spec.StepDepth / 2f + foundationClearance; + + Vector3 outward = -inwardNormal; + Vector3 stairBaseXZ = doorCenter + outward * stairRun; + + return new Vector3(stairBaseXZ.x, -spec.FoundationHeight, stairBaseXZ.z); + } + + return null; + } + #endregion #region Private Methods - Positioning diff --git a/Building/BuildingPartRegistry.cs b/Building/BuildingPartRegistry.cs new file mode 100644 index 0000000..213a1ae --- /dev/null +++ b/Building/BuildingPartRegistry.cs @@ -0,0 +1,215 @@ +using System.Collections.Generic; +using S1MAPI.Building.Config; +using S1MAPI.Building.Structural; +using UnityEngine; + +namespace S1MAPI.Building +{ + /// + /// Post-build registry that tracks building parts by category. + /// Attached to the building root during construction by . + /// Consumers use this to target specific parts (all exterior walls, just the north wall, + /// the floor, trim, etc.) and get their renderers for material swaps. + /// + public sealed class BuildingPartRegistry + { + private readonly Dictionary> _parts = new Dictionary>(); + private readonly Dictionary> _wallsByDirection = new Dictionary>(); + + #region Registration (called by BuildingBuilder during construction) + + internal void Register(BuildingPart part, GameObject go) + { + if (!_parts.TryGetValue(part, out var list)) + { + list = new List(); + _parts[part] = list; + } + list.Add(go); + } + + internal void Register(WallSide side, GameObject go) + { + if (!_wallsByDirection.TryGetValue(side, out var list)) + { + list = new List(); + _wallsByDirection[side] = list; + } + list.Add(go); + + Register(BuildingPart.ExteriorWalls, go); + } + + #endregion + + #region Consumer API — Renderers + + /// + /// Get all renderers for a building part category. + /// + /// The building part category to query. + public Renderer[] GetRenderers(BuildingPart part) + { + if (!_parts.TryGetValue(part, out var list)) + return System.Array.Empty(); + + var renderers = new List(); + for (int i = 0; i < list.Count; i++) + { + if (list[i] != null) + renderers.AddRange(list[i].GetComponentsInChildren()); + } + return renderers.ToArray(); + } + + /// + /// Get all renderers for a specific wall direction. + /// + /// The specific wall side to query. + public Renderer[] GetRenderers(WallSide side) + { + if (!_wallsByDirection.TryGetValue(side, out var list)) + return System.Array.Empty(); + + var renderers = new List(); + for (int i = 0; i < list.Count; i++) + { + if (list[i] != null) + renderers.AddRange(list[i].GetComponentsInChildren()); + } + return renderers.ToArray(); + } + + #endregion + + #region Consumer API — GameObjects + + /// + /// Get all GameObjects for a building part category. + /// + /// The building part category to query. + public GameObject[] GetParts(BuildingPart part) + { + if (!_parts.TryGetValue(part, out var list)) + return System.Array.Empty(); + + var result = new List(list.Count); + for (int i = 0; i < list.Count; i++) + { + if (list[i] != null) + result.Add(list[i]); + } + return result.ToArray(); + } + + /// + /// Get all GameObjects for a specific wall direction. + /// + /// The specific wall side to query. + public GameObject[] GetParts(WallSide side) + { + if (!_wallsByDirection.TryGetValue(side, out var list)) + return System.Array.Empty(); + + var result = new List(list.Count); + for (int i = 0; i < list.Count; i++) + { + if (list[i] != null) + result.Add(list[i]); + } + return result.ToArray(); + } + + #endregion + + #region Consumer API — Material swap + + /// + /// Set the material on all renderers for a building part category. + /// + /// The building part category to modify. + /// The material to apply. + public void SetMaterial(BuildingPart part, Material material) + { + var renderers = GetRenderers(part); + for (int i = 0; i < renderers.Length; i++) + renderers[i].material = material; + } + + /// + /// Set the material on all renderers for a specific wall direction. + /// + /// The specific wall side to modify. + /// The material to apply. + public void SetMaterial(WallSide side, Material material) + { + var renderers = GetRenderers(side); + for (int i = 0; i < renderers.Length; i++) + renderers[i].material = material; + } + + /// + /// Set the material on a specific submesh index across all renderers for a building part. + /// Use submesh 0 for exterior faces, submesh 1 for interior faces on dual-material walls. + /// Renderers with fewer submeshes than are skipped. + /// + /// The building part category to modify. + /// The material to apply. + /// The submesh index to target (0 = exterior, 1 = interior). + public void SetMaterial(BuildingPart part, Material material, int submeshIndex) + { + var renderers = GetRenderers(part); + SetSubmeshMaterial(renderers, material, submeshIndex); + } + + /// + /// Set the material on a specific submesh index across all renderers for a wall direction. + /// Use submesh 0 for exterior faces, submesh 1 for interior faces on dual-material walls. + /// Renderers with fewer submeshes than are skipped. + /// + /// The specific wall side to modify. + /// The material to apply. + /// The submesh index to target (0 = exterior, 1 = interior). + public void SetMaterial(WallSide side, Material material, int submeshIndex) + { + var renderers = GetRenderers(side); + SetSubmeshMaterial(renderers, material, submeshIndex); + } + + /// + /// Set the exterior face material on all exterior wall renderers (submesh 0). + /// Only affects dual-material walls created with . + /// Single-material walls are updated normally. + /// + /// The material to apply to exterior faces. + public void SetExteriorWallMaterial(Material material) + { + SetMaterial(BuildingPart.ExteriorWalls, material, 0); + } + + /// + /// Set the interior (room-facing) material on all exterior wall renderers (submesh 1). + /// Only affects dual-material walls. Renderers with a single material are skipped. + /// + /// The material to apply to interior faces. + public void SetInteriorFaceMaterial(Material material) + { + SetMaterial(BuildingPart.ExteriorWalls, material, 1); + } + + private static void SetSubmeshMaterial(Renderer[] renderers, Material material, int submeshIndex) + { + for (int i = 0; i < renderers.Length; i++) + { + Material[] mats = renderers[i].materials; + if (submeshIndex >= 0 && submeshIndex < mats.Length) + { + mats[submeshIndex] = material; + renderers[i].materials = mats; + } + } + } + + #endregion + } +} diff --git a/Building/BuildingUtilities.cs b/Building/BuildingUtilities.cs index fe414ee..2eaf233 100644 --- a/Building/BuildingUtilities.cs +++ b/Building/BuildingUtilities.cs @@ -132,23 +132,44 @@ public static void ApplyOcclusionSettings(GameObject gameObject, bool allowOcclu #endregion - #region Public API - Grid Snapping - + #region Public API - Grid + /// - /// Snap a position to a grid + /// Compute the grid cell size for a room with the given dimensions. + /// Returns the largest square cell size ≤ + /// that evenly divides the room's X axis. The pathfinding grid uses this + /// same value so furniture placement and NPC navigation are always aligned. /// - /// The position to snap - /// The grid cell size - /// The snapped position - public static Vector3 SnapToGrid(Vector3 position, float gridSize = Constants.Spatial.DefaultGridSize) + public static float ComputeGridCellSize(float roomX, float roomZ) { - return new Vector3( - Mathf.Round(position.x / gridSize) * gridSize, - Mathf.Round(position.y / gridSize) * gridSize, - Mathf.Round(position.z / gridSize) * gridSize - ); + float target = Constants.Spatial.DefaultGridSize; + int nx = Mathf.Max(1, Mathf.CeilToInt(roomX / target)); + int nz = Mathf.Max(1, Mathf.CeilToInt(roomZ / target)); + return Mathf.Min(roomX / nx, roomZ / nz); } - + + + /// + /// Check whether a transform belongs to a living entity (player/NPC) by + /// looking for a or + /// anywhere in its root hierarchy. + /// Results are cached in the provided sets for efficiency. + /// + /// true if the transform's root contains a NavMeshAgent or CharacterController. + internal static bool IsLivingEntity(Transform t, HashSet livingRoots, HashSet staticRoots) + { + int rootId = t.root.GetInstanceID(); + if (livingRoots.Contains(rootId)) return true; + if (staticRoots.Contains(rootId)) return false; + + Transform root = t.root; + bool isLiving = root.GetComponentInChildren() != null + || root.GetComponentInChildren() != null; + + (isLiving ? livingRoots : staticRoots).Add(rootId); + return isLiving; + } + #endregion #region Public API - Hierarchy Organization diff --git a/Building/Components/LightingBuilder.cs b/Building/Components/LightingBuilder.cs index 959ff70..2889776 100644 --- a/Building/Components/LightingBuilder.cs +++ b/Building/Components/LightingBuilder.cs @@ -58,7 +58,7 @@ public GameObject AddCeilingLights(float? intensity = null, Color? color = null, float xStep = _roomSize.x / (xCount + 1); float zStep = _roomSize.z / (zCount + 1); - float yPos = _roomSize.y - 0.2f; // Just below ceiling + float yPos = _roomSize.y - 0.3f; // Below ceiling (ceiling sits inside room at _roomSize.y - thickness) for (int x = 1; x <= xCount; x++) { diff --git a/Building/Components/NetworkedPrefabLinker.cs b/Building/Components/NetworkedPrefabLinker.cs new file mode 100644 index 0000000..ca12437 --- /dev/null +++ b/Building/Components/NetworkedPrefabLinker.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections.Generic; +using S1MAPI.Utils; +using UnityEngine; +using UnityEngine.SceneManagement; + +#if IL2CPP +using Il2CppInterop.Runtime.Injection; +#endif + +namespace S1MAPI.Building.Components +{ + /// + /// Static operations queue for deferred client-side linking of FishNet-replicated prefabs. + /// When a networked prefab is spawned on the server, FishNet replicates it to clients + /// but does NOT replicate the parent hierarchy. This queue finds the replicated object + /// on the client and parents it to the correct building transform. + /// + /// Separated from so that generic delegates + /// never appear on the ClassInjector-registered MonoBehaviour. + /// + /// + internal static class NetworkedPrefabLinkerQueue + { + private const float PollInterval = 0.5f; + private const int MaxAttempts = 60; // 30 seconds total + private const float PositionTolerance = 1.0f; + + private sealed class LinkRequest + { + public string PrefabName; + public Vector3 ExpectedWorldPos; + public Transform Parent; + public Vector3 LocalPosition; + public Quaternion LocalRotation; + public Action? OnLinked; + + public LinkRequest(string prefabName, Vector3 expectedWorldPos, Transform parent, + Vector3 localPosition, Quaternion localRotation, Action? onLinked) + { + PrefabName = prefabName; + ExpectedWorldPos = expectedWorldPos; + Parent = parent; + LocalPosition = localPosition; + LocalRotation = localRotation; + OnLinked = onLinked; + } + } + + private sealed class LinkerState + { + public readonly List Requests = new List(); + public int Attempts; + public float NextPollTime; + } + + private static readonly Dictionary States = new Dictionary(); + + /// + /// Queue a link request. Immediately scans scene root objects for an already-replicated + /// match (handles the case where FishNet delivered the object before this call). + /// If not found, a is attached to the + /// target to drive polling until the replicated object arrives. + /// + internal static void Enqueue(GameObject target, string prefabName, Vector3 expectedWorldPos, + Transform parent, Vector3 localPosition, Quaternion localRotation, + Action? onLinked = null) + { + var request = new LinkRequest(prefabName, expectedWorldPos, parent, + localPosition, localRotation, onLinked); + + // Immediate scan — the object may already exist if FishNet replicated before this call + if (TryMatchAndLink(request, 0)) + { + DebugLog.Info($"[PrefabLinker] Linked '{prefabName}' to '{parent.name}' " + + "immediately (already replicated)."); + return; + } + + // Not found yet — attach behaviour and poll + var behaviour = NetworkedPrefabLinkerBehaviour.EnsureOn(target); + int id = behaviour.GetInstanceID(); + + if (!States.TryGetValue(id, out var state)) + { + state = new LinkerState { NextPollTime = Time.unscaledTime + PollInterval }; + States[id] = state; + } + + state.Requests.Add(request); + + DebugLog.Info($"[PrefabLinker] Queued link for '{prefabName}' " + + $"({state.Requests.Count} pending). building='{target.name}'"); + } + + /// + /// Called by each frame. + /// Returns true to keep polling, false to self-destruct. + /// + internal static bool Tick(int id, string buildingName) + { + if (!States.TryGetValue(id, out var state) || state.Requests.Count == 0) + { + States.Remove(id); + return false; + } + + if (Time.unscaledTime < state.NextPollTime) + return true; + + state.Attempts++; + state.NextPollTime = Time.unscaledTime + PollInterval; + + if (state.Attempts > MaxAttempts) + { + DebugLog.Warning($"[PrefabLinker] Gave up after {MaxAttempts} attempts " + + $"({state.Requests.Count} unresolved). building='{buildingName}'"); + States.Remove(id); + return false; + } + + for (int i = state.Requests.Count - 1; i >= 0; i--) + { + var req = state.Requests[i]; + + if (req.Parent == null) + { + state.Requests.RemoveAt(i); + continue; + } + + if (TryMatchAndLink(req, state.Attempts)) + { + state.Requests.RemoveAt(i); + } + else if (state.Attempts % 10 == 0) + { + DebugLog.Info($"[PrefabLinker] Still waiting for '{req.PrefabName}'... " + + $"attempt {state.Attempts}/{MaxAttempts}"); + } + } + + if (state.Requests.Count == 0) + { + DebugLog.Info("[PrefabLinker] All pending links resolved."); + States.Remove(id); + return false; + } + + return true; + } + + /// + /// Scan scene root objects for an unparented object matching the request's + /// prefab name and expected world position. If found, parent it and invoke the callback. + /// + private static bool TryMatchAndLink(LinkRequest req, int attempts) + { + GameObject[] rootObjects; + try + { + rootObjects = SceneManager.GetActiveScene().GetRootGameObjects(); + } + catch (Exception) + { + return false; + } + + // Two-pass matching: first try name + position (precise), then name-only (fallback). + // FishNet may replicate objects at incorrect world positions (e.g., origin) when the + // server sets transform on an inactive object before Spawn(). The name-only pass + // catches these stranded objects and parents them correctly using stored local coords. + GameObject? bestMatch = null; + float bestDist = float.MaxValue; + + foreach (var obj in rootObjects) + { + if (obj == null) continue; + if (!obj.name.Contains(req.PrefabName)) continue; + + float dist = Vector3.Distance(obj.transform.position, req.ExpectedWorldPos); + + // Precise match — take immediately + if (dist <= PositionTolerance) + { + bestMatch = obj; + bestDist = dist; + break; + } + + // Name-only fallback — pick closest among stranded objects + if (dist < bestDist) + { + bestMatch = obj; + bestDist = dist; + } + } + + if (bestMatch != null) + { + if (bestDist > PositionTolerance) + { + DebugLog.Info($"[PrefabLinker] Name-only match for '{bestMatch.name}' " + + $"(dist={bestDist:F1}m, expected within {PositionTolerance:F1}m). " + + $"FishNet likely spawned at wrong position."); + } + + bestMatch.transform.SetParent(req.Parent); + bestMatch.transform.localPosition = req.LocalPosition; + bestMatch.transform.localRotation = req.LocalRotation; + + if (attempts > 0) + { + DebugLog.Info($"[PrefabLinker] Linked '{bestMatch.name}' to '{req.Parent.name}' " + + $"after {attempts} poll(s)."); + } + + try + { + req.OnLinked?.Invoke(bestMatch); + } + catch (Exception ex) + { + DebugLog.Error($"[PrefabLinker] OnLinked callback threw: {ex.Message}"); + } + + return true; + } + + return false; + } + + /// Cleanup state for a destroyed behaviour. + internal static void Remove(int id) + { + States.Remove(id); + } + } + + /// + /// Attached to a building root on clients to poll for FishNet-replicated prefabs + /// and parent them into the building hierarchy once they arrive. + /// + /// This MonoBehaviour has NO generic delegate parameters — all delegate storage + /// lives in . + /// + /// + internal sealed class NetworkedPrefabLinkerBehaviour : MonoBehaviour + { +#if IL2CPP + private static bool _registered; +#endif + + internal static NetworkedPrefabLinkerBehaviour EnsureOn(GameObject target) + { +#if IL2CPP + if (!_registered) + { + ClassInjector.RegisterTypeInIl2Cpp(); + _registered = true; + } +#endif + var existing = target.GetComponent(); + return existing != null ? existing : target.AddComponent(); + } + + private void Update() + { + if (!NetworkedPrefabLinkerQueue.Tick(GetInstanceID(), gameObject.name)) + { + Destroy(this); + } + } + + private void OnDestroy() + { + NetworkedPrefabLinkerQueue.Remove(GetInstanceID()); + } + } +} diff --git a/Building/Components/PrefabPlacer.cs b/Building/Components/PrefabPlacer.cs index 27282d6..b7f0b6b 100644 --- a/Building/Components/PrefabPlacer.cs +++ b/Building/Components/PrefabPlacer.cs @@ -1,15 +1,15 @@ +using System; using UnityEngine; using S1MAPI.Core; using S1MAPI.Extensions; using S1MAPI.S1; +using S1MAPI.Utils; #if IL2CPP using Il2CppFishNet; -using Il2CppFishNet.Managing; -using Il2CppFishNet.Managing.Object; -using Il2CppFishNet.Object; using Il2CppTMPro; #else +using FishNet; using TMPro; #endif @@ -45,85 +45,96 @@ public PrefabPlacer(Transform parent) /// /// Place a prefab at the specified position. + /// When is true, the prefab is spawned via FishNet + /// on the server and automatically replicated to clients. On clients, returns null + /// but queues a deferred link so the replicated object is parented to the building + /// once it arrives via FishNet. /// /// Prefab reference from GamePrefabs /// Local position relative to parent /// Local rotation - /// Whether to spawn on network (server only) + /// Whether to spawn on network (server only). When true, + /// returns null on clients — the server-spawned object is replicated via FishNet + /// and automatically parented to the building hierarchy. /// Whether to enable MonoBehaviour components (default: false) - /// The instantiated prefab or null if not found - public GameObject? Place(PrefabRef prefab, Vector3 localPosition, Quaternion localRotation, bool networked = true, bool enableComponents = false) + /// Optional callback invoked on the GameObject after instantiation (server) + /// or after the FishNet-replicated object is linked (client). Use this to configure + /// components before sensors/triggers activate (e.g., set DoorController.AutoOpenForPlayer). + /// The instantiated prefab, or null if not found or if networked and not server + public GameObject? Place(PrefabRef prefab, Vector3 localPosition, Quaternion localRotation, bool networked = true, bool enableComponents = false, Action? onReady = null) { - GameObject? instance = networked ? prefab.InstantiateNetworked() : prefab.Instantiate(); - if (instance == null) return null; - - instance.transform.SetParent(_parent); - instance.transform.localPosition = localPosition; - instance.transform.localRotation = localRotation; - - // Optionally enable game logic components - if (enableComponents) + Action? combined = null; + if (enableComponents && onReady != null) { - instance.EnableAllComponents(recursive: true); + combined = (go) => { go.EnableAllComponents(recursive: true); onReady(go); }; } - - return instance; + else if (enableComponents) + { + combined = (go) => go.EnableAllComponents(recursive: true); + } + else + { + combined = onReady; + } + return PlaceInternal(prefab, localPosition, localRotation, networked, combined); } /// /// Place a prefab with specific components enabled by name. /// Useful for enabling only certain game logic (e.g., ATM, VendingMachine). + /// When is true, returns null on non-server callers + /// but the replicated object is automatically parented on clients. /// /// Prefab reference from GamePrefabs /// Local position relative to parent /// Local rotation /// Array of component type names to enable - /// Whether to spawn on network (server only) - /// The instantiated prefab or null if not found + /// Whether to spawn on network (server only). When true, + /// returns null on clients — the server-spawned object is replicated via FishNet + /// and automatically parented to the building hierarchy. + /// The instantiated prefab, or null if not found or if networked and not server public GameObject? PlaceWithComponents(PrefabRef prefab, Vector3 localPosition, Quaternion localRotation, string[] componentNames, bool networked = true) { - GameObject? instance = networked ? prefab.InstantiateNetworked() : prefab.Instantiate(); - if (instance == null) return null; - - instance.transform.SetParent(_parent); - instance.transform.localPosition = localPosition; - instance.transform.localRotation = localRotation; - - // Enable specific components by name - instance.EnableComponentsByName(componentNames, recursive: true); - - return instance; + string[] names = componentNames; + return PlaceInternal(prefab, localPosition, localRotation, networked, + (go) => go.EnableComponentsByName(names, recursive: true)); } /// /// Place sliding double doors at a wall opening. + /// On server, spawns the door and applies customization (material, opening hours text). + /// On clients, returns null but queues a deferred link so the FishNet-replicated door + /// is parented to the building and receives the same customization once it arrives. + /// See remarks for DoorController + /// server-gating details. /// /// Local position for the doors /// Local rotation /// Text to display for opening hours /// Optional material for door panels - /// The door instance or null if prefab not found - public GameObject? PlaceSlidingDoors(Vector3 localPosition, Quaternion localRotation, string openingHoursText = "6AM-6PM", Material? doorMaterial = null) + /// Optional callback invoked on the door GameObject before activation, + /// server-only. Runs after internal customization (material, opening hours text) but before + /// Awake/OnEnable fire, so sensors see configured values. Does not fire on clients. + /// The door instance on server, or null on clients / if prefab not found + public GameObject? PlaceSlidingDoors(Vector3 localPosition, Quaternion localRotation, string openingHoursText = "6AM-6PM", Material? doorMaterial = null, Action? onServerReady = null) { - GameObject? doors = Place(Prefabs.SlidingDoors, localPosition, localRotation, networked: true); - if (doors == null) return null; + Material? mat = doorMaterial; + string text = openingHoursText; - doors.name = "SlidingDoors"; + // Internal customization fires on both server and client (via deferred linker). + // Consumer callback fires server-only, still pre-activation. + bool deferActivation = onServerReady != null; + GameObject? instance = PlaceInternal(Prefabs.SlidingDoors, localPosition, localRotation, networked: true, + (go) => CustomizeSlidingDoors(go, mat, text), activate: !deferActivation); - // Apply material to door panels - if (doorMaterial != null) + if (instance != null && deferActivation) { - ApplyMaterialToPath(doors, "Door/Door", doorMaterial); - ApplyMaterialToPath(doors, "Door/Door (1)", doorMaterial); + onServerReady!(instance); + if (!instance.activeSelf) + instance.SetActive(true); } - // Set opening hours text - if (!string.IsNullOrEmpty(openingHoursText)) - { - SetOpeningHoursText(doors, openingHoursText); - } - - return doors; + return instance; } /// @@ -168,6 +179,86 @@ public PrefabPlacer(Transform parent) #region Private Methods + /// + /// Core placement logic shared by all public Place methods. + /// On server: instantiates, parents, invokes onReady, returns instance. + /// On client (networked): queues a deferred link via NetworkedPrefabLinkerQueue + /// so the FishNet-replicated object is parented and customized when it arrives. + /// + private GameObject? PlaceInternal(PrefabRef prefab, Vector3 localPosition, Quaternion localRotation, + bool networked, Action? onReady, bool activate = true) + { + // For networked prefabs, instantiate WITHOUT activating so onReady can + // configure components before Awake/OnEnable fire. This prevents sensors + // (e.g., DoorSensor) from triggering with prefab-default values. + GameObject? instance = networked + ? prefab.InstantiateNetworkedInactive(_parent, localPosition, localRotation) + : prefab.Instantiate(); + + if (instance == null) + { + if (!networked) + { + DebugLog.Warning($"[PrefabPlacer] '{prefab.Name}' local instantiate returned null."); + return null; + } + + if (_parent == null) + { + DebugLog.Warning($"[PrefabPlacer] '{prefab.Name}' returned null and parent is destroyed — cannot queue link."); + return null; + } + + // On a connected client, queue a link so the FishNet-replicated + // object gets parented to the building once it arrives. + var nm = InstanceFinder.NetworkManager; + if (nm != null && nm.IsClient) + { + Vector3 expectedWorldPos = _parent.TransformPoint(localPosition); + NetworkedPrefabLinkerQueue.Enqueue( + _parent.gameObject, prefab.Name, expectedWorldPos, + _parent, localPosition, localRotation, onReady); + } + else + { + DebugLog.Warning($"[PrefabPlacer] '{prefab.Name}' returned null — " + + $"nm={nm != null}, isClient={nm?.IsClient}, isServer={nm?.IsServer}"); + } + return null; + } + + if (!networked) + { + instance.transform.SetParent(_parent); + instance.transform.localPosition = localPosition; + instance.transform.localRotation = localRotation; + } + + onReady?.Invoke(instance); + + // Activate AFTER onReady so sensors/triggers see configured values, not prefab defaults. + if (activate && networked && !instance.activeSelf) + instance.SetActive(true); + + return instance; + } + + private static void CustomizeSlidingDoors(GameObject doors, Material? doorMaterial, string? openingHoursText) + { + doors.name = "SlidingDoors"; + + if (doorMaterial != null) + { + ApplyMaterialToPath(doors, "Door/Door", doorMaterial); + ApplyMaterialToPath(doors, "Door/Door (1)", doorMaterial); + } + + if (!string.IsNullOrEmpty(openingHoursText)) + { + SetOpeningHoursText(doors, openingHoursText); + } + } + private static void ApplyMaterialToPath(GameObject root, string path, Material material) { Transform t = root.transform.Find(path); diff --git a/Building/Config/BuildingPalette.cs b/Building/Config/BuildingPalette.cs index 29d490c..dc3536f 100644 --- a/Building/Config/BuildingPalette.cs +++ b/Building/Config/BuildingPalette.cs @@ -19,10 +19,18 @@ public sealed class BuildingPalette /// Wall material (optional, uses color if null) public Material? WallMaterial { get; set; } - + /// Wall color (used if WallMaterial is null) public Color WallColor { get; set; } = new Color(0.9f, 0.85f, 0.7f); + /// Interior wall material for the room-facing side of exterior walls. + /// When null, exterior walls use a single material on all faces. + public Material? InteriorWallMaterial { get; set; } + + /// Interior wall color (used when InteriorWallMaterial is null). + /// Defaults to WallColor when null. + public Color? InteriorWallColor { get; set; } + /// Ceiling material (optional, uses color if null) public Material? CeilingMaterial { get; set; } @@ -66,6 +74,8 @@ public sealed class BuildingPalette FloorColor = FloorColor, WallMaterial = WallMaterial, WallColor = WallColor, + InteriorWallMaterial = InteriorWallMaterial, + InteriorWallColor = InteriorWallColor, CeilingMaterial = CeilingMaterial, CeilingColor = CeilingColor, TrimMaterial = TrimMaterial, @@ -96,6 +106,17 @@ public BuildingPalette WithWalls(Material material) return this; } + /// + /// Set interior wall material and return this palette for chaining. + /// When set, exterior wall faces use and room-facing + /// faces use this material. + /// + public BuildingPalette WithInteriorWalls(Material material) + { + InteriorWallMaterial = material; + return this; + } + #endregion } } diff --git a/Building/Config/BuildingPart.cs b/Building/Config/BuildingPart.cs new file mode 100644 index 0000000..f33662f --- /dev/null +++ b/Building/Config/BuildingPart.cs @@ -0,0 +1,30 @@ +namespace S1MAPI.Building.Config +{ + /// + /// Categories of building parts for post-build identification. + /// Aligns with material groups. + /// + public enum BuildingPart + { + /// All exterior wall segments (WallMaterial). + ExteriorWalls, + /// Floor slab (FloorMaterial). + Floor, + /// Ceiling slab (CeilingMaterial). + Ceiling, + /// Roof trim, corner trim, base molding, door frames (TrimMaterial). + Trim, + /// Corner pillars (PillarMaterial). + Pillars, + /// Secondary trim, parapet caps (AccentMaterial). + Accent, + /// Foundation block (FloorMaterial). + Foundation, + /// Parapet walls, hip roof slopes/slabs (WallMaterial). + Roof, + /// Stair geometry (FloorMaterial). + Stairs, + /// Interior wall segments (WallMaterial). + InteriorWalls + } +} diff --git a/Building/InteriorNavigator.cs b/Building/InteriorNavigator.cs new file mode 100644 index 0000000..15a5657 --- /dev/null +++ b/Building/InteriorNavigator.cs @@ -0,0 +1,31 @@ +using UnityEngine; + +#if IL2CPP +using Il2CppInterop.Runtime.Injection; +#endif + +namespace S1MAPI.Building +{ + /// + /// Thin MonoBehaviour shell that forwards Unity lifecycle calls to + /// . Kept minimal so IL2CPP's + /// ClassInjector only sees simple Unity-compatible method signatures + /// (no custom parameter types → zero registration warnings). + /// + internal sealed class InteriorNavigator : MonoBehaviour + { + /// Navigation logic core, forwarded from Unity lifecycle methods. + internal InteriorNavigatorCore? _core; + + private void Update() + { + _core?.Update(); + } + + private void OnDestroy() + { + _core?.Cleanup(); + _core = null; + } + } +} diff --git a/Building/InteriorNavigatorCore.cs b/Building/InteriorNavigatorCore.cs new file mode 100644 index 0000000..b8595ea --- /dev/null +++ b/Building/InteriorNavigatorCore.cs @@ -0,0 +1,1690 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using S1MAPI.Utils; +using UnityEngine; +using UnityEngine.AI; + +#if IL2CPP +using Il2CppInterop.Runtime; +#endif + +namespace S1MAPI.Building +{ + /// + /// Core logic for NPC interior navigation using custom A* pathfinding. + /// + /// This is a plain C# class (not a MonoBehaviour) to avoid IL2CPP ClassInjector + /// warnings for methods with custom parameter types. The thin + /// MonoBehaviour shell forwards Unity lifecycle + /// calls (Update, OnDestroy) to this class. + /// + /// + internal sealed class InteriorNavigatorCore + { + #region Nested Types + + private enum NPCNavState + { + Approaching, // NavMeshAgent walking to doorway exterior + Entering, // Agent disabled, lerping through doorway inward + Inside, // Following A* path via Transform movement + Exiting, // A* path to doorway interior point + LeavingDoorway // Lerping through doorway outward + } + + private sealed class TrackedNPC + { + public Component NpcComponent; + public NPCNavState State; + public NavDoorwayInfo TargetDoorway; + public Vector3 DoorwayExteriorWorld; // stair base (ground level) — NavMeshAgent target + public Vector3 DoorwayThresholdWorld; // just outside doorway at floor level — lerp phase 1 target + public Vector3 DoorwayInteriorWorld; + public List? Path; + public int PathIndex; + public Vector3 TargetLocal; + public Transform? ChaseTarget; + public float ChaseRepathTimer; + public float Speed; + public Action? OnArrival; + public float LerpProgress; + public float LerpDuration; + public Vector3 LerpStart; + public Vector3 LerpEnd; + public bool Arrived; // directed mode: true once OnArrival has fired + public Vector3? PendingExteriorDestination; // set when NPC exits to resume navigation + public Vector3? SavedChasePosition; // chase target position saved before exit + public float RepathTimer; // fallback re-pathfind timer for all NPCs + public float StuckTimer; // time NPC hasn't moved + public Vector3 StuckStartPos; // position when stuck timer began + public Vector3 LastChaseTargetLocal; // last chase target used for repath (avoids redundant recompute) + public float ApproachStartTime; // Time.time when Approaching state began + public bool IsStairLerp; // true during stair climb/descent lerp (Y follows ramp slope) + public Vector3 LastValidPos; // last position set by our code (restored if agent warps NPC) + + // Cached reflection results + public object? MovementRef; // NPCMovement instance + public NavMeshAgent? Agent; + public Collider? NpcCollider; // disabled while inside to prevent pushing player + } + + /// + /// Cross-platform member accessor for game types. + /// On Mono, game members are fields; on IL2CPP (Il2CppInterop), they become properties. + /// Tries property first, then falls back to field — works on both runtimes. + /// + private readonly struct MemberAccessor + { + private readonly FieldInfo? _field; + private readonly PropertyInfo? _prop; + + public bool IsValid => + _field != null || _prop != null; + + public MemberAccessor(Type type, string name, BindingFlags flags) + { + _prop = type.GetProperty(name, flags); + _field = _prop == null ? type.GetField(name, flags) : null; + } + + public object? GetValue(object target) => + _field != null ? _field.GetValue(target) : _prop?.GetValue(target); + + public void SetValue(object target, object? value) + { + if (_field != null) _field.SetValue(target, value); + else _prop?.SetValue(target, value); + } + } + + #endregion + + #region Fields + + private readonly InteriorPathGrid _grid; + /// Exposes the walkability grid to NavigationBuilder pass-through queries. + internal InteriorPathGrid PathGrid => _grid; + private readonly IReadOnlyList _doorways; + private readonly Transform _buildingRoot; + private readonly Vector3 _roomSize; + private readonly float _foundationHeight; + + private readonly Dictionary _tracked = + new Dictionary(); + private readonly List _removeQueue = new List(); + private float _doorwayScanTimer; + private float _approachLogTimer; + private static readonly Collider[] _scanBuffer = new Collider[32]; + + // Prevent NPC from being managed by two buildings simultaneously + private static readonly HashSet _globallyManaged = new HashSet(); + + // Static registry of all active buildings (for auto-detection patch) + private static readonly List _activeBuildings = new List(); + private static bool _patchApplied; + + // Reflection cache (resolved once) + private static bool _reflectionResolved; + private static Type? _npcMovementType; + private static MemberAccessor _agentAccessor; + private static MethodInfo? _setAgentEnabled; + private static MemberAccessor _walkSpeedAccessor; + private static MemberAccessor _runSpeedAccessor; + private static MemberAccessor _speedScaleAccessor; + private static MemberAccessor _moveSpeedMultAccessor; + private static MemberAccessor _hasDestinationAccessor; + private static MethodInfo? _originalSetDestination; + private static Harmony? _harmony; + + // Chase detection: NPCMovement.npc → NPC.Behaviour → activeBehaviour, check if CombatBehaviour + private static MemberAccessor _movementNpcAccessor; // NPCMovement.npc (protected field) + private static MemberAccessor _npcBehaviourAccessor; // NPC.Behaviour + private static MemberAccessor _activeBehaviourAccessor; // NPCBehaviour.activeBehaviour + private static Type? _combatBehaviourType; + + #endregion + + #region Constructor + + /// + /// Create and initialize the interior navigation core. + /// + public InteriorNavigatorCore( + InteriorPathGrid grid, + IReadOnlyList doorways, + Transform buildingRoot, + Vector3 roomSize, + float foundationHeight) + { + _grid = grid; + _doorways = doorways; + _buildingRoot = buildingRoot; + _roomSize = roomSize; + _foundationHeight = foundationHeight; + + ResolveReflection(); + + _activeBuildings.Add(this); + ApplyPatchIfNeeded(); + } + + #endregion + + #region Reflection & Patching + + private static void ResolveReflection() + { + if (_reflectionResolved) return; + _reflectionResolved = true; + + // On IL2CPP, Il2CppInterop prefixes game namespaces with "Il2Cpp" +#if IL2CPP + const string typeName = "Il2CppScheduleOne.NPCs.NPCMovement"; +#else + const string typeName = "ScheduleOne.NPCs.NPCMovement"; +#endif + foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies()) + { + _npcMovementType = asm.GetType(typeName); + if (_npcMovementType != null) break; + } + if (_npcMovementType == null) + { + DebugLog.Warning("[InteriorNavigator] NPCMovement type not found."); + return; + } + + // MemberAccessor tries GetProperty first (IL2CPP), falls back to GetField (Mono) + const BindingFlags pub = BindingFlags.Public | BindingFlags.Instance; + _agentAccessor = new MemberAccessor(_npcMovementType, "Agent", pub); + _setAgentEnabled = _npcMovementType.GetMethod("SetAgentEnabled", pub); + _walkSpeedAccessor = new MemberAccessor(_npcMovementType, "WalkSpeed", pub); + _runSpeedAccessor = new MemberAccessor(_npcMovementType, "RunSpeed", pub); + _speedScaleAccessor = new MemberAccessor(_npcMovementType, "MovementSpeedScale", pub); + _moveSpeedMultAccessor = new MemberAccessor(_npcMovementType, "MoveSpeedMultiplier", pub); + _hasDestinationAccessor = new MemberAccessor(_npcMovementType, "HasDestination", pub); + + // Chase detection: NPCMovement.npc → NPC.Behaviour → activeBehaviour → CombatBehaviour + const BindingFlags nonPub = BindingFlags.NonPublic | BindingFlags.Instance; + _movementNpcAccessor = new MemberAccessor(_npcMovementType, "npc", nonPub | BindingFlags.Public); +#if IL2CPP + const string npcTypeName = "Il2CppScheduleOne.NPCs.NPC"; + const string behaviourTypeName = "Il2CppScheduleOne.NPCs.Behaviour.NPCBehaviour"; + const string combatTypeName = "Il2CppScheduleOne.Combat.CombatBehaviour"; +#else + const string npcTypeName = "ScheduleOne.NPCs.NPC"; + const string behaviourTypeName = "ScheduleOne.NPCs.Behaviour.NPCBehaviour"; + const string combatTypeName = "ScheduleOne.Combat.CombatBehaviour"; +#endif + Type? npcType = null; + Type? npcBehaviourType = null; + foreach (Assembly asm2 in AppDomain.CurrentDomain.GetAssemblies()) + { + npcType ??= asm2.GetType(npcTypeName); + npcBehaviourType ??= asm2.GetType(behaviourTypeName); + _combatBehaviourType ??= asm2.GetType(combatTypeName); + if (npcType != null && npcBehaviourType != null && _combatBehaviourType != null) + break; + } + if (npcType != null) + _npcBehaviourAccessor = new MemberAccessor(npcType, "Behaviour", pub); + if (npcBehaviourType != null) + _activeBehaviourAccessor = new MemberAccessor(npcBehaviourType, "activeBehaviour", pub); + } + + private static void ApplyPatchIfNeeded() + { + if (_patchApplied || _npcMovementType == null) return; + _patchApplied = true; + + // Find the 4-param public SetDestination(Vector3, Action, float, float) + MethodInfo? target = null; + foreach (MethodInfo m in _npcMovementType.GetMethods(BindingFlags.Public | BindingFlags.Instance)) + { + if (m.Name != "SetDestination") continue; + ParameterInfo[] pars = m.GetParameters(); + if (pars.Length == 4 && pars[0].ParameterType == typeof(Vector3)) + { + target = m; + break; + } + } + + if (target == null) + { + DebugLog.Warning("[InteriorNavigator] Could not find SetDestination to patch."); + return; + } + + _originalSetDestination = target; + + _harmony = new Harmony("com.s1mapi.interiornavigator"); + + // Patch SetDestination — intercept all NPC destination calls + MethodInfo setDestPrefix = typeof(InteriorNavigatorCore).GetMethod( + nameof(SetDestinationPrefix), + BindingFlags.NonPublic | BindingFlags.Static)!; + _harmony.Patch(target, prefix: new HarmonyMethod(setDestPrefix)); + + // Patch UpdateDestination — prevent private 5-param SetDestination from + // overwriting our agent destination for managed NPCs. + // UpdateDestination is called from FixedUpdate and bypasses our SetDestination patch. + MethodInfo? updateDest = _npcMovementType.GetMethod("UpdateDestination", + BindingFlags.NonPublic | BindingFlags.Instance); + if (updateDest != null) + { + MethodInfo updateDestPrefix = typeof(InteriorNavigatorCore).GetMethod( + nameof(UpdateDestinationPrefix), + BindingFlags.NonPublic | BindingFlags.Static)!; + _harmony.Patch(updateDest, prefix: new HarmonyMethod(updateDestPrefix)); + DebugLog.Info("[InteriorNavigator] Patched UpdateDestination."); + } + else + { + DebugLog.Warning("[InteriorNavigator] Could not find UpdateDestination to patch."); + } + + DebugLog.Info("[InteriorNavigator] Patched NPCMovement.SetDestination for auto-interception."); + } + + #endregion + + #region Harmony Patch + + /// + /// Harmony prefix on NPCMovement.SetDestination. Automatically intercepts + /// NPCs whose destination falls inside a registered building and redirects them + /// through the custom A* interior pathfinding system. + /// + /// Always blocks the original for managed NPCs — we send the agent to the + /// doorway ourselves. The UpdateDestination patch prevents the game's FixedUpdate + /// from overwriting our agent destination. + /// + /// + private static bool SetDestinationPrefix(object __instance, Vector3 pos) + { + Component? movement = __instance as Component; + if (movement == null) return true; + + // Check if destination is inside any registered building (0.5m margin prevents + // oscillation when player is near building wall — slight position offsets won't + // cause immediate exit/re-entry cycles) + for (int i = 0; i < _activeBuildings.Count; i++) + { + InteriorNavigatorCore nav = _activeBuildings[i]; + if (nav == null || nav._buildingRoot == null) continue; + + Vector3 localPos = nav._buildingRoot.InverseTransformPoint(pos); + if (!nav.IsInsideBuilding(localPos)) + { + // Extended: combat AI resolves targets to carving boundary points + // (~0.8m outside). Only intercept if the NPC is in combat — prevents + // false interception of consumer NPCs walking to exterior positions + // near the building (e.g. stair approach points). + if (!IsNPCInCombat(movement) || + !nav.IsInsideBuilding(localPos, margin: 3f) || + !nav.IsPlayerInside()) + continue; + } + + // Clamp near-boundary positions to interior + localPos.x = Mathf.Clamp(localPos.x, 0f, nav._roomSize.x); + localPos.z = Mathf.Clamp(localPos.z, 0f, nav._roomSize.z); + + // Chase detection: only enable continuous player tracking when the + // NPC's active behaviour is combat (PursuitBehaviour, CombatBehaviour, etc.). + // This avoids false positives from consumer mods sending NPCs to positions + // near the player (e.g. consumer NPCs walking to interior positions). + Transform? chaseTarget = IsNPCInCombat(movement) ? DetectChaseTarget(pos) : null; + + // Already tracked by this building — update target, always block original + if (nav._tracked.TryGetValue(movement, out TrackedNPC? existing)) + { + existing.TargetLocal = localPos; + existing.OnArrival = null; + if (chaseTarget != null) + { + existing.ChaseTarget = chaseTarget; + existing.LastChaseTargetLocal = localPos; + } + else + { + existing.ChaseTarget = null; + } + + if (existing.State == NPCNavState.Inside) + nav.ComputePathToTarget(existing); + + // Re-send agent to doorway if still approaching — the agent's path + // may have been consumed or cleared since the initial send. + if (existing.State == NPCNavState.Approaching && + existing.Agent != null && + existing.Agent.enabled && + existing.Agent.isOnNavMesh) + { + existing.Agent.SetDestination(existing.DoorwayExteriorWorld); + } + + return false; + } + + // New NPC — register and send agent to doorway ourselves + DebugLog.Info($"[InteriorNavigator] Tracking new NPC {movement.name} " + + $"targeting local ({localPos.x:F1}, {localPos.z:F1})"); + + TrackedNPC tracked = nav.CreateTrackedNPC(movement); + tracked.TargetLocal = localPos; + tracked.ChaseTarget = chaseTarget; + tracked.ChaseRepathTimer = 0f; + tracked.OnArrival = null; + + NavDoorwayInfo door = nav.FindNearestExteriorDoorway( + nav._buildingRoot.InverseTransformPoint(movement.transform.position)); + tracked.TargetDoorway = door; + nav.ComputeDoorwayPoints(tracked, door); + tracked.State = NPCNavState.Approaching; + tracked.ApproachStartTime = Time.time; + + // Send agent to doorway exterior directly — don't let the game send it + // to the interior position (which the agent can't reach due to carving) + if (tracked.Agent != null && tracked.Agent.enabled && tracked.Agent.isOnNavMesh) + tracked.Agent.SetDestination(tracked.DoorwayExteriorWorld); + + nav._tracked[movement] = tracked; + _globallyManaged.Add(movement); + + return false; // block original — we've set the agent destination ourselves + } + + // Destination not inside any building — check if this NPC is tracked and should exit + if (_globallyManaged.Contains(movement)) + { + for (int i = 0; i < _activeBuildings.Count; i++) + { + InteriorNavigatorCore nav = _activeBuildings[i]; + if (nav == null) continue; + + if (nav._tracked.TryGetValue(movement, out TrackedNPC? data)) + { + // During Approaching: only release if destination is clearly + // far from the building. Near-building destinations (carving + // boundary points from GetRandomReachablePointNear) should + // NOT release — the NPC is still pursuing a target inside. + if (data.State == NPCNavState.Approaching) + { + Vector3 destLocal = nav._buildingRoot.InverseTransformPoint(pos); + if (!nav.IsInsideBuilding(destLocal, margin: 5f)) + { + _globallyManaged.Remove(movement); + nav._tracked.Remove(movement); + return true; // genuinely far — let original run + } + return false; // near building — keep tracking, block game + } + + // Destination is outside building — recall NPC to exit + data.PendingExteriorDestination = pos; + nav.RecallNPC(movement); + return false; // suppress original while NPC exits the building + } + } + } + + return true; // not our concern, run original + } + + /// + /// Harmony prefix on NPCMovement.UpdateDestination. Skips the method + /// entirely for managed NPCs — prevents the private 5-param SetDestination + /// (called from FixedUpdate) from overwriting our agent destination. + /// + private static bool UpdateDestinationPrefix(object __instance) + { + Component? movement = __instance as Component; + if (movement != null && _globallyManaged.Contains(movement)) + return false; // skip — we're managing this NPC's destination + return true; + } + + /// + /// Check if the NPC's active behaviour is a CombatBehaviour (or subclass like PursuitBehaviour). + /// Uses reflection to read NPCMovement.npc → NPC.Behaviour.activeBehaviour without + /// compile-time ScheduleOne dependencies. Returns false if reflection fails (graceful fallback: no chase). + /// + private static bool IsNPCInCombat(Component movement) + { + if (_combatBehaviourType == null || !_movementNpcAccessor.IsValid || + !_npcBehaviourAccessor.IsValid || !_activeBehaviourAccessor.IsValid) + return false; + + try + { + object? npc = _movementNpcAccessor.GetValue(movement); + if (npc == null) return false; + + object? behaviourComp = _npcBehaviourAccessor.GetValue(npc); + if (behaviourComp == null) return false; + + object? activeBehaviour = _activeBehaviourAccessor.GetValue(behaviourComp); + if (activeBehaviour == null) return false; + + return _combatBehaviourType.IsInstanceOfType(activeBehaviour); + } + catch + { + return false; + } + } + + /// + /// Check if the destination is near any player (indicating a chase scenario). + /// Returns the nearest player's Transform if so, null otherwise. + /// Uses to support multiplayer. + /// + private static Transform? DetectChaseTarget(Vector3 destination) + { + foreach (Camera cam in Camera.allCameras) + { + Vector3 camPos = cam.transform.position; + float dx = destination.x - camPos.x; + float dz = destination.z - camPos.z; + float distSq = dx * dx + dz * dz; + + // If destination is within 3m of a player, this is a chase + if (distSq < 9f) + return cam.transform; + } + + return null; + } + + #endregion + + #region Public API + + /// + /// Send an NPC to a position inside the building. The NPC walks to the nearest + /// doorway on exterior NavMesh, enters via lerp, then follows A* path to target. + /// + public void SendNPCToPosition(Component npc, Vector3 localTarget, Action? onArrival) + { + // Already tracked by this building — update target in place + if (_tracked.TryGetValue(npc, out TrackedNPC? existing)) + { + existing.TargetLocal = localTarget; + existing.OnArrival = onArrival; + existing.Arrived = false; + existing.ChaseTarget = null; + if (existing.State == NPCNavState.Inside) + ComputePathToTarget(existing); + return; + } + + if (_globallyManaged.Contains(npc)) + { + DebugLog.Warning("[InteriorNavigator] NPC is already managed by another building."); + return; + } + + TrackedNPC tracked = CreateTrackedNPC(npc); + tracked.TargetLocal = localTarget; + tracked.OnArrival = onArrival; + tracked.ChaseTarget = null; + + // Check if NPC is already near a doorway — skip approach if close enough. + // For stair doorways, check proximity to the stair base (not door center) + // so the NPC doesn't skip the stair climb from the sidewalk. + Vector3 npcPos = npc.transform.position; + NavDoorwayInfo? nearDoor = null; + for (int i = 0; i < _doorways.Count; i++) + { + NavDoorwayInfo door = _doorways[i]; + if (door.IsInterior) continue; + + Vector3 checkPoint; + float checkThreshold; + if (door.StairBasePosition.HasValue) + { + checkPoint = _buildingRoot.TransformPoint(door.StairBasePosition.Value); + checkThreshold = Constants.InteriorNav.StairApproachThreshold; + } + else + { + checkPoint = _buildingRoot.TransformPoint(door.Center); + checkThreshold = Constants.InteriorNav.DoorwayApproachThreshold; + } + + if (HorizontalDistance(npcPos, checkPoint) < checkThreshold) + { + nearDoor = door; + break; + } + } + + _tracked[npc] = tracked; + _globallyManaged.Add(npc); + + if (nearDoor != null) + { + tracked.TargetDoorway = nearDoor; + ComputeDoorwayPoints(tracked, nearDoor); + BeginDoorwayEntry(npc, tracked); + } + else + { + BeginApproach(tracked); + SendAgentToDoorway(tracked); + } + + DebugLog.Info($"[InteriorNavigator] Sending NPC to local ({localTarget.x:F1}, {localTarget.z:F1})"); + } + + /// + /// Send an NPC to chase a moving target inside the building. + /// Re-pathfinds at . + /// + public void SendNPCToChase(Component npc, Transform target) + { + if (_globallyManaged.Contains(npc)) + { + DebugLog.Warning("[InteriorNavigator] NPC is already managed by a building."); + return; + } + + SendNPCToChaseInternal(npc, target); + } + + private void SendNPCToChaseInternal(Component npc, Transform target) + { + Vector3 targetLocal = _buildingRoot.InverseTransformPoint(target.position); + + TrackedNPC tracked = CreateTrackedNPC(npc); + tracked.TargetLocal = targetLocal; + tracked.ChaseTarget = target; + tracked.ChaseRepathTimer = 0f; + tracked.OnArrival = null; + + BeginApproach(tracked); + SendAgentToDoorway(tracked); + _tracked[npc] = tracked; + _globallyManaged.Add(npc); + + DebugLog.Info("[InteriorNavigator] NPC chasing player — continuous tracking enabled."); + } + + /// + /// Directly set the NavMeshAgent destination to the doorway exterior. + /// Used by the consumer API (SendNPCToPosition/SendNPCToChase) where the + /// game's behavior system isn't involved. The Harmony prefix path doesn't + /// need this — the game's own SetDestination handles agent routing. + /// + private static void SendAgentToDoorway(TrackedNPC data) + { + if (data.Agent != null && data.Agent.enabled && data.Agent.isOnNavMesh) + data.Agent.SetDestination(data.DoorwayExteriorWorld); + } + + /// + /// Recall an NPC from the building. If inside, begins exit via A* path to doorway. + /// If still approaching, releases immediately. + /// + public void RecallNPC(Component npc) + { + if (!_tracked.TryGetValue(npc, out TrackedNPC? data)) return; + + if (data.State == NPCNavState.Approaching) + { + // Still on exterior NavMesh — just release + ReleaseNPC(npc, data, warpToExterior: false); + } + else if (data.State != NPCNavState.Exiting && + data.State != NPCNavState.LeavingDoorway) + { + BeginExit(npc, data); + } + } + + /// Whether this navigator is currently managing the given NPC. + public bool IsTracking(Component npc) => + _tracked.ContainsKey(npc); + + /// + /// Show/hide the walkability grid visualization (green = walkable, red = blocked). + /// + public void VisualizeGrid(bool show) + { + if (show) + _grid.Visualize(); + else + _grid.DestroyVisualization(); + } + + /// + /// Diagnose why a specific grid cell is blocked. Logs details to console. + /// Cell coordinates are shown in the visualization quad names (Cell_X_Z). + /// + public void DiagnoseCell(int gx, int gz) => + _grid.DiagnoseCell(gx, gz); + + /// + /// Release all tracked NPCs. Warps those inside to the exterior and re-enables agents. + /// Called automatically on building destruction. + /// + public void ReleaseAllNPCs() + { + foreach (KeyValuePair kvp in _tracked) + { + if (kvp.Key == null) continue; + TrackedNPC data = kvp.Value; + + if (data.State != NPCNavState.Approaching) + { + // NPC is inside — warp to exterior + if (data.DoorwayExteriorWorld != Vector3.zero) + kvp.Key.transform.position = data.DoorwayExteriorWorld; + if (data.NpcCollider != null) + data.NpcCollider.enabled = true; + EnableAgent(data); + } + + // Restore HasDestination so the game's pursuit AI resumes generating + // SetDestination calls (mirrors ReleaseNPC logic) + if (data.MovementRef != null && _hasDestinationAccessor.IsValid) + { + try { _hasDestinationAccessor.SetValue(data.MovementRef, true); } + catch (Exception ex) { DebugLog.Warning($"[InteriorNavigator] RestoreHasDestination failed: {ex.Message}"); } + } + + _globallyManaged.Remove(kvp.Key); + } + _tracked.Clear(); + } + + #endregion + + #region Update Loop + + public void Update() + { + // Periodically scan for untracked NPCs near doorways. + // Catches NPCs whose SetDestination resolved to a NavMesh point outside + // the building (carving boundary) — the prefix can't intercept those. + _doorwayScanTimer -= Time.deltaTime; + if (_doorwayScanTimer <= 0f) + { + _doorwayScanTimer = 0.5f; + ScanForNearbyNPCs(); + } + + if (_tracked.Count == 0) return; + + _removeQueue.Clear(); + + foreach (KeyValuePair kvp in _tracked) + { + Component npc = kvp.Key; + TrackedNPC data = kvp.Value; + + if (npc == null) + { + _removeQueue.Add(npc!); + continue; + } + + // The game may re-enable the NavMeshAgent at any time (e.g. NPC.SetVisible + // toggles Agent.enabled). A re-enabled agent on carved NavMesh snaps the NPC + // to ground level, overriding our position control. Disable it and restore + // the last known good position to undo the warp. + if (data.State != NPCNavState.Approaching && data.Agent != null && data.Agent.enabled) + { + data.Agent.enabled = false; + npc.transform.position = data.LastValidPos; + } + + switch (data.State) + { + case NPCNavState.Approaching: + UpdateApproaching(npc, data); + break; + case NPCNavState.Entering: + UpdateLerp(npc, data, onComplete: () => + { + // Check if we still need phase 2 (through doorway) + float distToInterior = Vector3.Distance( + npc.transform.position, data.DoorwayInteriorWorld); + if (distToInterior > Constants.InteriorNav.PhaseTransitionThreshold) + { + // Phase 1 complete — now lerp through the doorway + data.IsStairLerp = false; + data.Speed = GetNPCSpeed(data); + data.LerpStart = npc.transform.position; + data.LerpEnd = data.DoorwayInteriorWorld; + float dist = Vector3.Distance(data.LerpStart, data.LerpEnd); + data.LerpDuration = Mathf.Max( + dist / Mathf.Max(data.Speed, 1f), 0.1f); + data.LerpProgress = 0f; + } + else + { + data.State = NPCNavState.Inside; + ComputePathToTarget(data); + } + }); + break; + case NPCNavState.Inside: + UpdateInside(npc, data); + break; + case NPCNavState.Exiting: + { + // Stuck detection for exit — force doorway leave if NPC + // hasn't made progress in 3 seconds + Vector3 exitPos = npc.transform.position; + if (data.StuckTimer == 0f) + data.StuckStartPos = exitPos; + data.StuckTimer += Time.deltaTime; + float exitDisplSq = (exitPos.x - data.StuckStartPos.x) * (exitPos.x - data.StuckStartPos.x) + + (exitPos.z - data.StuckStartPos.z) * (exitPos.z - data.StuckStartPos.z); + if (exitDisplSq > Constants.InteriorNav.ExitStuckDisplacementSq) + data.StuckTimer = 0f; + else if (data.StuckTimer > Constants.InteriorNav.ExitStuckTimeout) + { + DebugLog.Warning("[InteriorNavigator] NPC stuck during exit, forcing doorway leave."); + BeginDoorwayLeave(npc, data); + break; + } + + UpdatePathFollow(npc, data, onComplete: () => + BeginDoorwayLeave(npc, data)); + break; + } + case NPCNavState.LeavingDoorway: + UpdateLerp(npc, data, onComplete: () => + { + // If not yet at stair base, descend stairs + float distToExt = Vector3.Distance( + npc.transform.position, data.DoorwayExteriorWorld); + if (distToExt > Constants.InteriorNav.PhaseTransitionThreshold) + { + data.IsStairLerp = data.TargetDoorway.StairBasePosition.HasValue; + data.Speed = GetNPCSpeed(data); + data.LerpStart = npc.transform.position; + data.LerpEnd = data.DoorwayExteriorWorld; + float dist = Vector3.Distance(data.LerpStart, data.LerpEnd); + data.LerpDuration = Mathf.Max( + dist / Mathf.Max(data.Speed, 1f), 0.1f); + data.LerpProgress = 0f; + } + else + { + ReleaseNPC(npc, data, warpToExterior: false); + } + }); + break; + } + + // Save position after our code sets it, so we can restore if the + // game's NavMeshAgent warps the NPC next frame. + if (data.State != NPCNavState.Approaching) + data.LastValidPos = npc.transform.position; + } + + foreach (Component npc in _removeQueue) + { + _globallyManaged.Remove(npc); + _tracked.Remove(npc); + } + } + + #endregion + + #region Doorway Proximity Scan + + /// + /// Detect untracked NPCs standing near doorways while the player is inside. + /// The game's combat AI resolves destinations to NavMesh carving boundary points + /// (outside the building), so the SetDestination prefix may not intercept them. + /// This scan catches those NPCs by proximity instead. + /// + private void ScanForNearbyNPCs() + { + if (_npcMovementType == null) return; + + // Only scan if any player is inside this building (multiplayer-safe) + Transform? insidePlayer = FindPlayerInside(); + if (insidePlayer == null) return; + + Vector3 playerLocal = _buildingRoot.InverseTransformPoint(insidePlayer.position); + float threshold = Constants.InteriorNav.DoorwayApproachThreshold; + + for (int d = 0; d < _doorways.Count; d++) + { + NavDoorwayInfo door = _doorways[d]; + if (door.IsInterior) continue; + + // Scan at ground level — cop stands at terrain height, not elevated floor + Vector3 scanCenter = door.Center; + scanCenter.y = door.StairBasePosition.HasValue + ? door.StairBasePosition.Value.y + : -_foundationHeight; + Vector3 doorWorld = _buildingRoot.TransformPoint(scanCenter); + int count = Physics.OverlapSphereNonAlloc( + doorWorld, threshold, _scanBuffer, Physics.AllLayers, + QueryTriggerInteraction.Collide); + + for (int c = 0; c < count; c++) + { + if (_scanBuffer[c] == null) continue; + + Component? movement = FindComponentInParent(_scanBuffer[c], _npcMovementType); + if (movement == null) continue; + if (_globallyManaged.Contains(movement)) continue; + if (_tracked.ContainsKey(movement)) continue; + + DebugLog.Info($"[InteriorNavigator] Auto-detected NPC {movement.name} near doorway, registering..."); + + TrackedNPC tracked = CreateTrackedNPC(movement); + tracked.TargetLocal = playerLocal; + tracked.ChaseTarget = insidePlayer; + tracked.ChaseRepathTimer = 0f; + tracked.OnArrival = null; + tracked.TargetDoorway = door; + ComputeDoorwayPoints(tracked, door); + + _tracked[movement] = tracked; + _globallyManaged.Add(movement); + + // NPC is already at the doorway — skip Approaching, enter immediately + BeginDoorwayEntry(movement, tracked); + } + } + } + + #endregion + + #region State: Approaching + + private void BeginApproach(TrackedNPC data) + { + // Find nearest exterior doorway to the NPC's current position + Vector3 npcLocal = _buildingRoot.InverseTransformPoint(data.NpcComponent.transform.position); + NavDoorwayInfo door = FindNearestExteriorDoorway(npcLocal); + data.TargetDoorway = door; + ComputeDoorwayPoints(data, door); + + data.State = NPCNavState.Approaching; + data.ApproachStartTime = Time.time; + + DebugLog.Info($"[InteriorNavigator] NPC approaching doorway at {data.DoorwayExteriorWorld}"); + } + + private void UpdateApproaching(Component npc, TrackedNPC data) + { + Vector3 npcPos = npc.transform.position; + // Stair doorways: use a tight threshold so the NPC walks all the way + // to the stair base before we take over and lerp up the stairs. + float threshold = data.TargetDoorway.StairBasePosition.HasValue + ? Constants.InteriorNav.StairApproachThreshold + : Constants.InteriorNav.DoorwayApproachThreshold; + + // Check distance to target doorway + float distXZ = HorizontalDistance(npcPos, data.DoorwayExteriorWorld); + + if (distXZ < threshold) + { + DebugLog.Info($"[InteriorNavigator] NPC reached doorway (distXZ={distXZ:F2}), entering..."); + BeginDoorwayEntry(npc, data); + return; + } + + // Check all exterior doorways — NPC may be closer to a different one + // (e.g., routed around the building by NavMesh) + for (int i = 0; i < _doorways.Count; i++) + { + NavDoorwayInfo door = _doorways[i]; + if (door.IsInterior || door == data.TargetDoorway) continue; + + Vector3 doorWorld = _buildingRoot.TransformPoint(door.Center); + float altDist = HorizontalDistance(npcPos, doorWorld); + if (altDist < threshold) + { + DebugLog.Info($"[InteriorNavigator] NPC near alternate doorway (dist={altDist:F2}), switching..."); + data.TargetDoorway = door; + ComputeDoorwayPoints(data, door); + BeginDoorwayEntry(npc, data); + return; + } + } + + // Approach timeout — if the agent has been trying for too long, force entry + // at the nearest doorway. Uses 2-phase lerp so NPC walks smoothly to the + // doorway exterior before entering (not a warp). + float elapsed = Time.time - data.ApproachStartTime; + if (elapsed > 12f) + { + Vector3 npcLocal = _buildingRoot.InverseTransformPoint(npcPos); + NavDoorwayInfo nearest = FindNearestExteriorDoorway(npcLocal); + data.TargetDoorway = nearest; + ComputeDoorwayPoints(data, nearest); + + float nearestDist = HorizontalDistance(npcPos, data.DoorwayExteriorWorld); + if (nearestDist < 12f) // Only force if within reasonable distance + { + DebugLog.Warning($"[InteriorNavigator] Approach timeout ({elapsed:F0}s), " + + $"forcing entry (dist={nearestDist:F1}m)..."); + BeginDoorwayEntry(npc, data); + return; + } + } + + if (data.Agent == null) return; + + bool pathPending = data.Agent.pathPending; + bool hasPath = data.Agent.hasPath; + float remaining = data.Agent.remainingDistance; + bool remainingValid = !float.IsInfinity(remaining) && !float.IsNaN(remaining); + + // Agent finished its path near-ish to doorway — enter + float maxEntryDist = data.TargetDoorway.StairBasePosition.HasValue + ? Constants.InteriorNav.StairMaxEntryDistance : 8f; + if (!pathPending && remainingValid && remaining < 0.5f && distXZ < maxEntryDist) + { + DebugLog.Info($"[InteriorNavigator] Agent path done near doorway (distXZ={distXZ:F2}), entering..."); + BeginDoorwayEntry(npc, data); + return; + } + + // Re-send agent to doorway when it has no active path + if (!pathPending && !hasPath && data.Agent.enabled && data.Agent.isOnNavMesh) + { + data.Agent.SetDestination(data.DoorwayExteriorWorld); + } + + // Periodic diagnostic logging + _approachLogTimer -= Time.deltaTime; + if (_approachLogTimer <= 0f) + { + _approachLogTimer = 3f; + DebugLog.Info($"[InteriorNavigator] Approach: distXZ={distXZ:F2}, " + + $"elapsed={elapsed:F0}s, hasPath={hasPath}, remaining={remaining:F2}"); + } + } + + private static float HorizontalDistance(Vector3 a, Vector3 b) + { + float dx = a.x - b.x; + float dz = a.z - b.z; + return Mathf.Sqrt(dx * dx + dz * dz); + } + + #endregion + + #region State: Entering / Leaving (Doorway Lerp) + + private void BeginDoorwayEntry(Component npc, TrackedNPC data) + { + // Snapshot position before disabling agent — this is the last known good + // position for warp-back if the game re-enables the agent later. + data.LastValidPos = npc.transform.position; + + // Stop the agent directly (avoid NPCMovement.Stop() which fires stale callbacks) + if (data.Agent != null) + { + data.Agent.isStopped = true; + data.Agent.ResetPath(); + } + DisableAgent(data); + + // Disable NPC collider to prevent pushing the player while inside + if (data.NpcCollider != null) + data.NpcCollider.enabled = false; + + // Clear HasDestination so game's UpdateDestination() doesn't run + // and warp the NPC back to the NavMesh surface + ClearHasDestination(data); + + data.Speed = GetNPCSpeed(data); + data.LerpStart = npc.transform.position; + + // Entry phasing depends on whether doorway has stairs: + // Stair doorways: Phase 1 climbs to DoorwayThresholdWorld (ramp top), + // Phase 2 enters through doorway to DoorwayInteriorWorld. + // Non-stair doorways: Phase 1 walks to DoorwayExteriorWorld (corrects approach angle), + // Phase 2 enters through doorway to DoorwayInteriorWorld. + bool hasStairs = data.TargetDoorway.StairBasePosition.HasValue; + if (hasStairs) + { + float distToThreshold = Vector3.Distance(npc.transform.position, data.DoorwayThresholdWorld); + bool climbPhase = distToThreshold > Constants.InteriorNav.PhaseTransitionThreshold; + data.LerpEnd = climbPhase + ? data.DoorwayThresholdWorld + : data.DoorwayInteriorWorld; + data.IsStairLerp = climbPhase; + } + else + { + float distToExterior = Vector3.Distance(npc.transform.position, data.DoorwayExteriorWorld); + data.LerpEnd = distToExterior > Constants.InteriorNav.ExteriorAngleCorrectionThreshold + ? data.DoorwayExteriorWorld + : data.DoorwayInteriorWorld; + data.IsStairLerp = false; + } + + float distance = Vector3.Distance(data.LerpStart, data.LerpEnd); + data.LerpDuration = Mathf.Max(distance / Mathf.Max(data.Speed, 1f), 0.1f); + data.LerpProgress = 0f; + data.State = NPCNavState.Entering; + } + + private void BeginDoorwayLeave(Component npc, TrackedNPC data) + { + data.Speed = GetNPCSpeed(data); + data.LerpStart = npc.transform.position; + // Stair doorways: lerp to threshold first, then LeavingDoorway completion + // descends to stair base. Non-stair: go directly to exterior (single phase). + data.LerpEnd = data.TargetDoorway.StairBasePosition.HasValue + ? data.DoorwayThresholdWorld + : data.DoorwayExteriorWorld; + float distance = Vector3.Distance(data.LerpStart, data.LerpEnd); + data.LerpDuration = Mathf.Max(distance / Mathf.Max(data.Speed, 1f), 0.1f); + data.LerpProgress = 0f; + data.IsStairLerp = false; // interior→threshold/exterior is through doorway, not on stairs + data.State = NPCNavState.LeavingDoorway; + } + + private void UpdateLerp(Component npc, TrackedNPC data, Action onComplete) + { + data.LerpProgress += Time.deltaTime / data.LerpDuration; + float t = Mathf.Clamp01(data.LerpProgress); + float smooth = t * t * (3f - 2f * t); // smoothstep + Vector3 pos = Vector3.Lerp(data.LerpStart, data.LerpEnd, smooth); + + // During stair climb/descent, project Y linearly based on horizontal + // progress from LerpStart to LerpEnd. This avoids the smoothstep arc + // that causes floating, and handles street-to-stair elevation differences + // since LerpStart.y is the NPC's actual Y (may be at street level). + if (data.IsStairLerp) + { + float dx = data.LerpEnd.x - data.LerpStart.x; + float dz = data.LerpEnd.z - data.LerpStart.z; + float lenSq = dx * dx + dz * dz; + if (lenSq > 0.001f) + { + float dot = (pos.x - data.LerpStart.x) * dx + (pos.z - data.LerpStart.z) * dz; + float rampT = Mathf.Clamp01(dot / lenSq); + pos.y = Mathf.Lerp(data.LerpStart.y, data.LerpEnd.y, rampT); + } + } + + npc.transform.position = pos; + + // Face movement direction + RotateToward(npc.transform, data.LerpEnd - data.LerpStart); + + if (t >= 1f) + { + npc.transform.position = data.LerpEnd; + onComplete(); + } + } + + #endregion + + #region State: Inside + + private void UpdateInside(Component npc, TrackedNPC data) + { + Vector3 pos = npc.transform.position; + + // Chase mode: periodically re-pathfind toward moving target. + // ReferenceEquals bypasses Unity's overloaded == to test "was a target assigned?" + // Then Unity's == detects destroyed objects (native pointer gone, proxy alive). + if (!ReferenceEquals(data.ChaseTarget, null)) + { + if (data.ChaseTarget == null) + { + // Target was assigned but Unity object was destroyed — exit building + BeginExit(npc, data); + return; + } + + Vector3 targetLocal = _buildingRoot.InverseTransformPoint(data.ChaseTarget.position); + if (!IsInsideBuilding(targetLocal)) + { + BeginExit(npc, data); + return; + } + + // Stopping distance: don't move when already close enough to target. + // Prevents oscillation from overshooting + re-pathing. + float distToTarget = Vector3.Distance(pos, data.ChaseTarget.position); + if (distToTarget < Constants.InteriorNav.DestinationArrivalThreshold) + { + // Close enough — just face the target and idle + RotateToward(npc.transform, data.ChaseTarget.position - pos); + data.Path = null; + data.ChaseRepathTimer = Constants.InteriorNav.ChaseRepathInterval; + return; + } + + data.ChaseRepathTimer -= Time.deltaTime; + if (data.ChaseRepathTimer <= 0f) + { + data.ChaseRepathTimer = Constants.InteriorNav.ChaseRepathInterval; + + // Only recompute if the target has moved more than one grid cell. + // This avoids resetting PathIndex every 0.2s when the target barely moved, + // which was causing NPCs to repeatedly re-traverse waypoint 0. + float targetMovedSq = (targetLocal.x - data.LastChaseTargetLocal.x) * (targetLocal.x - data.LastChaseTargetLocal.x) + + (targetLocal.z - data.LastChaseTargetLocal.z) * (targetLocal.z - data.LastChaseTargetLocal.z); + float cellThreshold = _grid.CellSize * 2f; + if (targetMovedSq > cellThreshold * cellThreshold || data.Path == null) + { + data.TargetLocal = targetLocal; + data.LastChaseTargetLocal = targetLocal; + ComputePathToTarget(data); + } + } + } + + // Fallback: re-pathfind periodically even without chase target. + // This handles the case where the game stops calling SetDestination + // after we cleared HasDestination (NPC would otherwise stand forever). + // Skip when Arrived — the NPC reached its destination and should idle + // until the consumer sets a new target or recalls. + if (!data.Arrived) + { + data.RepathTimer -= Time.deltaTime; + if (data.RepathTimer <= 0f) + { + data.RepathTimer = 0.5f; + if (data.Path == null || data.PathIndex >= data.Path.Count) + { + ComputePathToTarget(data); + if (data.Path != null) + DebugLog.Info($"[InteriorNavigator] Fallback re-path found {data.Path.Count} waypoints, speed={data.Speed:F1}"); + } + } + } + + // Stuck detection — if NPC hasn't moved meaningfully over 2 seconds, recompute path. + // Uses cumulative displacement (not per-frame delta) to avoid false positives + // at high framerates where per-frame movement is tiny but NPC IS progressing. + if (data.Path != null && data.PathIndex < data.Path.Count) + { + if (data.StuckTimer == 0f) + data.StuckStartPos = pos; + + data.StuckTimer += Time.deltaTime; + + float displacementSq = (pos.x - data.StuckStartPos.x) * (pos.x - data.StuckStartPos.x) + + (pos.z - data.StuckStartPos.z) * (pos.z - data.StuckStartPos.z); + + if (displacementSq > 0.25f) // moved > 0.5m from start → making progress + { + data.StuckTimer = 0f; + } + else if (data.StuckTimer > 2.0f) + { + data.StuckTimer = 0f; + Vector3 waypoint = data.Path[data.PathIndex]; + DebugLog.Warning($"[InteriorNavigator] NPC stuck (displaced {Mathf.Sqrt(displacementSq):F2}m in 2s). " + + $"speed={data.Speed:F1}, pathIdx={data.PathIndex}/{data.Path.Count}, " + + $"npcWorld=({pos.x:F1},{pos.y:F1},{pos.z:F1}), " + + $"waypoint=({waypoint.x:F1},{waypoint.y:F1},{waypoint.z:F1})"); + ComputePathToTarget(data); + } + } + + UpdatePathFollow(npc, data, onComplete: () => + { + if (data.ChaseTarget == null) + { + // Directed mode: arrived at destination + data.Arrived = true; + data.OnArrival?.Invoke(); + data.OnArrival = null; + // NPC stays until RecallNPC + } + // Chase mode: will re-pathfind next interval + }); + } + + #endregion + + #region State: Exiting + + private void BeginExit(Component npc, TrackedNPC data) + { + Vector3 currentLocal = _buildingRoot.InverseTransformPoint(npc.transform.position); + NavDoorwayInfo door = FindNearestExteriorDoorway(currentLocal); + data.TargetDoorway = door; + ComputeDoorwayPoints(data, door); + + // Save chase target position before clearing — used by ReleaseNPC to + // resume pursuit on exterior NavMesh after the NPC exits the building. + if (data.ChaseTarget != null) + { + try { data.SavedChasePosition = data.ChaseTarget.position; } + catch { /* destroyed */ } + } + + data.ChaseTarget = null; + + // Pathfind to doorway interior point + data.Path = _grid.FindPath(currentLocal, _buildingRoot.InverseTransformPoint(data.DoorwayInteriorWorld)); + data.PathIndex = 0; + data.StuckTimer = 0f; + + if (data.Path == null) + { + // No path to doorway — skip directly to leave lerp rather than + // getting stuck forever. NPC will lerp through walls if needed. + DebugLog.Warning("[InteriorNavigator] Exit pathfind failed, forcing doorway leave."); + BeginDoorwayLeave(npc, data); + return; + } + + data.State = NPCNavState.Exiting; + } + + #endregion + + #region Path Following + + private void UpdatePathFollow(Component npc, TrackedNPC data, Action onComplete) + { + if (data.Path == null || data.PathIndex >= data.Path.Count) + { + onComplete(); + return; + } + + // Refresh speed each frame (catches walk→run transitions in chase mode) + data.Speed = GetNPCSpeed(data); + if (data.Speed < 0.1f) + { + DebugLog.Warning($"[InteriorNavigator] NPC speed near zero ({data.Speed:F3}), forcing minimum."); + data.Speed = 1.8f; // fallback to walk speed + } + + Vector3 target = data.Path[data.PathIndex]; + Vector3 pos = npc.transform.position; + + // Move at floor height + float floorY = _buildingRoot.TransformPoint(Vector3.zero).y; + target.y = floorY; + Vector3 newPos = Vector3.MoveTowards(pos, target, data.Speed * Time.deltaTime); + newPos.y = floorY; + npc.transform.position = newPos; + + // Face movement direction + RotateToward(npc.transform, target - pos); + + // Check waypoint arrival — advance through multiple waypoints per frame + // if speed is high enough (prevents slow cell-by-cell crawl) + float distSq = (newPos.x - target.x) * (newPos.x - target.x) + + (newPos.z - target.z) * (newPos.z - target.z); + + float threshold = (data.PathIndex == data.Path.Count - 1) + ? Constants.InteriorNav.DestinationArrivalThreshold + : Constants.InteriorNav.WaypointArrivalThreshold; + + if (distSq < threshold * threshold) + { + data.PathIndex++; + // Skip ahead through close waypoints in the same frame + while (data.PathIndex < data.Path.Count - 1) + { + Vector3 next = data.Path[data.PathIndex]; + next.y = floorY; + float nextDistSq = (newPos.x - next.x) * (newPos.x - next.x) + + (newPos.z - next.z) * (newPos.z - next.z); + if (nextDistSq < threshold * threshold) + data.PathIndex++; + else + break; + } + } + } + + private void ComputePathToTarget(TrackedNPC data) + { + Vector3 currentLocal = _buildingRoot.InverseTransformPoint( + data.NpcComponent.transform.position); + data.Path = _grid.FindPath(currentLocal, data.TargetLocal); + data.PathIndex = 0; + + if (data.Path == null) + { + DebugLog.Warning("[InteriorNavigator] No path found to target " + + $"({data.TargetLocal.x:F1}, {data.TargetLocal.z:F1})"); + return; + } + + // Skip the first waypoint if it's at the NPC's current position. + // FindPath always starts from the NPC's current grid cell, so waypoint 0 + // is nearly always right where we stand — advancing past it avoids wasting + // a frame on a zero-distance move after every repath. + if (data.Path.Count > 1) + { + Vector3 wp0 = data.Path[0]; + Vector3 pos = data.NpcComponent.transform.position; + float distSq = (pos.x - wp0.x) * (pos.x - wp0.x) + (pos.z - wp0.z) * (pos.z - wp0.z); + if (distSq < _grid.CellSize * _grid.CellSize) + data.PathIndex = 1; + } + } + + #endregion + + #region Helpers + + /// + /// Cross-platform GetComponent by . + /// IL2CPP requires Il2CppSystem.Type; this converts automatically. + /// + private static Component? FindComponent(Component target, Type type) + { +#if IL2CPP + return target.GetComponent(Il2CppType.From(type)); +#else + return target.GetComponent(type); +#endif + } + + /// + /// Cross-platform GetComponentInChildren by . + /// + private static Component? FindComponentInChildren(Component target, Type type) + { +#if IL2CPP + return target.GetComponentInChildren(Il2CppType.From(type)); +#else + return target.GetComponentInChildren(type); +#endif + } + + /// + /// Cross-platform GetComponentInParent by . + /// + private static Component? FindComponentInParent(Component target, Type type) + { +#if IL2CPP + return target.GetComponentInParent(Il2CppType.From(type)); +#else + return target.GetComponentInParent(type); +#endif + } + + private void RotateToward(Transform t, Vector3 direction) + { + direction.y = 0f; + if (direction.sqrMagnitude < 0.001f) return; + + Quaternion targetRot = Quaternion.LookRotation(direction.normalized, Vector3.up); + t.rotation = Quaternion.RotateTowards( + t.rotation, targetRot, + Constants.InteriorNav.RotationSpeed * Time.deltaTime); + } + + private NavDoorwayInfo FindNearestExteriorDoorway(Vector3 localPos) + { + NavDoorwayInfo? best = null; + float bestDist = float.MaxValue; + foreach (NavDoorwayInfo d in _doorways) + { + if (d.IsInterior) continue; + float dist = Vector3.Distance(localPos, d.Center); + if (dist < bestDist) { bestDist = dist; best = d; } + } + return best!; + } + + private void ComputeDoorwayPoints(TrackedNPC data, NavDoorwayInfo door) + { + // Exterior point: where the NavMesh agent walks to before we take control. + Vector3 extLocal; + if (door.StairBasePosition.HasValue) + { + // Stair doorway: use actual stair base so the NPC walks to the + // bottom of the stairs and the lerp climbs the stair surface. + extLocal = door.StairBasePosition.Value; + } + else + { + // Non-stair: well outside the carving zone on surviving NavMesh. + float extOffset = door.WallThickness / 2f + 2.5f; + extLocal = door.Center - door.InwardNormal * extOffset; + extLocal.y = -_foundationHeight; + } + + Vector3 extWorld = _buildingRoot.TransformPoint(extLocal); + + // Don't snap to NavMesh — SamplePosition can move the point around building + // corners, causing the agent to route along the carving boundary and get stuck. + // The agent's SetDestination internally snaps to the nearest reachable NavMesh. + + // Threshold point: just outside the doorway at floor level. + // For stair doorways this is the ramp top — Phase 1 climbs from the + // stair base to here, Phase 2 passes through the doorway. + float threshOffset = door.WallThickness / 2f + 0.3f; + Vector3 threshLocal = door.Center - door.InwardNormal * threshOffset; + threshLocal.y = door.StairBasePosition.HasValue ? 0f : extLocal.y; + + // Interior point: inside the wall on the floor + float intOffset = door.WallThickness / 2f + 0.3f; + Vector3 intLocal = door.Center + door.InwardNormal * intOffset; + intLocal.y = 0f; + + data.DoorwayExteriorWorld = extWorld; + data.DoorwayThresholdWorld = _buildingRoot.TransformPoint(threshLocal); + data.DoorwayInteriorWorld = _buildingRoot.TransformPoint(intLocal); + } + + private bool IsInsideBuilding(Vector3 localPos, float margin = 0f) + { + return localPos.x >= -margin && localPos.x <= _roomSize.x + margin && + localPos.z >= -margin && localPos.z <= _roomSize.z + margin; + } + + /// + /// Check if any player is inside this building. + /// Uses to support multiplayer. + /// + private bool IsPlayerInside() => + FindPlayerInside() != null; + + /// + /// Find the first player transform inside this building, or null if none. + /// Uses to support multiplayer — each player has a camera. + /// + private Transform? FindPlayerInside() + { + foreach (Camera cam in Camera.allCameras) + { + Vector3 playerLocal = _buildingRoot.InverseTransformPoint(cam.transform.position); + if (IsInsideBuilding(playerLocal, margin: 1f)) + return cam.transform; + } + return null; + } + + private TrackedNPC CreateTrackedNPC(Component npc) + { + var tracked = new TrackedNPC { NpcComponent = npc }; + + // Resolve NPCMovement and NavMeshAgent via reflection + if (_npcMovementType != null) + { + Component? movement = FindComponent(npc, _npcMovementType); + if (movement == null) + movement = FindComponentInChildren(npc, _npcMovementType); + if (movement == null) + movement = FindComponentInParent(npc, _npcMovementType); + + if (movement != null) + { + tracked.MovementRef = movement; + if (_agentAccessor.IsValid) + tracked.Agent = _agentAccessor.GetValue(movement) as NavMeshAgent; + } + } + + // Find the NPC's physics collider (CapsuleCollider on a child GameObject). + // Disabled while inside to prevent NPCs from pushing the player. + tracked.NpcCollider = npc.GetComponentInChildren(); + + tracked.Speed = GetNPCSpeed(tracked); + return tracked; + } + + private float GetNPCSpeed(TrackedNPC data) + { + if (data.MovementRef == null) return 3.5f; + + float walkSpeed = 1.8f; + float runSpeed = 7f; + float scale = 0f; + float multiplier = 1f; + + try + { + if (_walkSpeedAccessor.IsValid) + { + object? val = _walkSpeedAccessor.GetValue(data.MovementRef); + if (val is float f) walkSpeed = f; + } + if (_runSpeedAccessor.IsValid) + { + object? val = _runSpeedAccessor.GetValue(data.MovementRef); + if (val is float f) runSpeed = f; + } + if (_speedScaleAccessor.IsValid) + { + object? val = _speedScaleAccessor.GetValue(data.MovementRef); + if (val is float f) scale = f; + } + if (_moveSpeedMultAccessor.IsValid) + { + object? val = _moveSpeedMultAccessor.GetValue(data.MovementRef); + if (val is float f) multiplier = f; + } + } + catch (Exception ex) + { + DebugLog.Warning($"[InteriorNavigator] Failed to read NPC speed via reflection: {ex.Message}"); + } + + return Mathf.Lerp(walkSpeed, runSpeed, scale) * multiplier; + } + + private void ReleaseNPC(Component npc, TrackedNPC data, bool warpToExterior) + { + if (warpToExterior && data.DoorwayExteriorWorld != Vector3.zero) + npc.transform.position = data.DoorwayExteriorWorld; + + // Re-enable collider before releasing back to game control + if (data.NpcCollider != null) + data.NpcCollider.enabled = true; + + EnableAgent(data); + + // Restore HasDestination — we cleared it in BeginDoorwayEntry to prevent + // UpdateDestination from overwriting our agent destination. If not restored, + // the game's pursuit AI stops generating SetDestination calls and the NPC + // stands idle forever (won't re-enter building if player goes back inside). + if (data.MovementRef != null && _hasDestinationAccessor.IsValid) + { + try { _hasDestinationAccessor.SetValue(data.MovementRef, true); } + catch (Exception ex) { DebugLog.Warning($"[InteriorNavigator] RestoreHasDestination failed: {ex.Message}"); } + } + + // Must remove from global set BEFORE invoking SetDestination + // so the Harmony prefix lets the call through to the original method. + _globallyManaged.Remove(npc); + _removeQueue.Add(npc); + + // Resume navigation: prefer pending exterior destination (game called + // SetDestination(outside) while NPC was inside), then saved chase position + // (NPC exited because chase target left building). + Vector3? resumeDestination = data.PendingExteriorDestination ?? data.SavedChasePosition; + if (resumeDestination.HasValue && + data.MovementRef != null && + _originalSetDestination != null) + { + try + { + // Parameters: (Vector3 destination, Action callback, float walkSpeedMult, float runSpeedMult) + _originalSetDestination.Invoke( + data.MovementRef, + new object?[] { resumeDestination.Value, null, 1f, 1f }); + DebugLog.Info($"[InteriorNavigator] NPC released, resuming navigation to " + + $"({resumeDestination.Value.x:F1}, {resumeDestination.Value.z:F1})"); + } + catch (Exception ex) + { + DebugLog.Warning($"[InteriorNavigator] Failed to set resume destination: {ex.Message}"); + } + } + else + { + DebugLog.Info("[InteriorNavigator] NPC released from building (no resume destination)."); + } + } + + private void DisableAgent(TrackedNPC data) + { + if (data.MovementRef != null && _setAgentEnabled != null) + { + try { _setAgentEnabled.Invoke(data.MovementRef, new object[] { false }); } + catch (Exception ex) { DebugLog.Warning($"[InteriorNavigator] DisableAgent failed: {ex.Message}"); } + } + } + + private void ClearHasDestination(TrackedNPC data) + { + if (data.MovementRef != null && _hasDestinationAccessor.IsValid) + { + try { _hasDestinationAccessor.SetValue(data.MovementRef, false); } + catch (Exception ex) { DebugLog.Warning($"[InteriorNavigator] ClearHasDestination failed: {ex.Message}"); } + } + } + + private void EnableAgent(TrackedNPC data) + { + if (data.MovementRef != null && _setAgentEnabled != null) + { + try { _setAgentEnabled.Invoke(data.MovementRef, new object[] { true }); } + catch (Exception ex) { DebugLog.Warning($"[InteriorNavigator] EnableAgent failed: {ex.Message}"); } + } + } + + #endregion + + #region Cleanup + + /// + /// Clean up this navigator instance. Unregisters from the active buildings list, + /// releases all NPCs, and unpatches Harmony when no buildings remain. + /// Safe to call multiple times. + /// + public void Cleanup() + { + _activeBuildings.Remove(this); + ReleaseAllNPCs(); + + // Unpatch when no buildings remain + if (_activeBuildings.Count == 0 && _harmony != null) + { + _harmony.UnpatchSelf(); + _harmony = null; + _patchApplied = false; + DebugLog.Info("[InteriorNavigator] Unpatched NPCMovement.SetDestination (no active buildings)."); + } + } + + #endregion + } +} diff --git a/Building/InteriorPathGrid.cs b/Building/InteriorPathGrid.cs new file mode 100644 index 0000000..13ff57c --- /dev/null +++ b/Building/InteriorPathGrid.cs @@ -0,0 +1,738 @@ +using System; +using System.Collections.Generic; +using S1MAPI.Utils; +using UnityEngine; + +namespace S1MAPI.Building +{ + /// + /// 2D walkability grid with A* pathfinding for building interiors. + /// Generated from building layout data (room size, walls, doorways). + /// All positions are in local building coordinates (Y=0 is floor level). + /// + internal sealed class InteriorPathGrid + { + private readonly float _cellSize; + private readonly int _gridWidth; + private readonly int _gridDepth; + private readonly bool[] _walkable; + private readonly Transform _buildingRoot; + private readonly Vector3 _roomSize; + private readonly IReadOnlyList _doorways; + + /// + /// Create a walkability grid for the given building layout. + /// Marks cells as unwalkable for walls, doorway openings, and physics colliders. + /// + /// Root transform of the building. + /// Room dimensions in local coordinates. + /// Doorway positions and dimensions. + public InteriorPathGrid( + Transform buildingRoot, + Vector3 roomSize, + IReadOnlyList doorways) + { + _buildingRoot = buildingRoot; + _roomSize = roomSize; + _doorways = doorways; + _cellSize = BuildingUtilities.ComputeGridCellSize(roomSize.x, roomSize.z); + + // Grid covers exactly the room — no extension through walls. + // NPCs lerp through the wall zone via InteriorNavigator; the A* path + // only needs to reach doorway-edge cells inside the room boundary. + _gridWidth = Mathf.CeilToInt(roomSize.x / _cellSize); + _gridDepth = Mathf.CeilToInt(roomSize.z / _cellSize); + _walkable = new bool[_gridWidth * _gridDepth]; + + GenerateGrid(); + } + + /// Computed grid cell size in meters. + public float CellSize => + _cellSize; + + /// + /// Regenerate the walkability grid (e.g., after furniture placement changes). + /// + public void Regenerate() + { + GenerateGrid(); + } + + /// + /// Find a path from startLocal to endLocal in local building coordinates. + /// Returns world-space waypoints, or null if no path exists. + /// + public List? FindPath(Vector3 startLocal, Vector3 endLocal) + { + int sx = LocalToGridX(startLocal.x); + int sz = LocalToGridZ(startLocal.z); + int ex = LocalToGridX(endLocal.x); + int ez = LocalToGridZ(endLocal.z); + + // Clamp to grid bounds + sx = Mathf.Clamp(sx, 0, _gridWidth - 1); + sz = Mathf.Clamp(sz, 0, _gridDepth - 1); + ex = Mathf.Clamp(ex, 0, _gridWidth - 1); + ez = Mathf.Clamp(ez, 0, _gridDepth - 1); + + // Snap to nearest walkable cell if start/end are unwalkable + if (!IsWalkableCell(sx, sz)) + FindNearestWalkableCell(ref sx, ref sz); + if (!IsWalkableCell(ex, ez)) + FindNearestWalkableCell(ref ex, ref ez); + + if (!IsWalkableCell(sx, sz) || !IsWalkableCell(ex, ez)) + return null; + + // A* search + List? path = AStar(sx, sz, ex, ez); + if (path == null) return null; + + // Convert grid path to world-space waypoints + var worldPath = new List(path.Count); + foreach (var cell in path) + { + Vector3 local = GridToLocal(cell.x, cell.y); + worldPath.Add(_buildingRoot.TransformPoint(local)); + } + + // Smooth: skip intermediate waypoints when line-of-sight exists + SmoothPath(worldPath); + + return worldPath; + } + + /// + /// Check if a local position is on a walkable cell. + /// + public bool IsWalkable(Vector3 localPos) + { + int gx = Mathf.FloorToInt(localPos.x / _cellSize); + int gz = Mathf.FloorToInt(localPos.z / _cellSize); + return IsWalkableCell(gx, gz); + } + + /// + /// Get the nearest walkable cell center in local coordinates. + /// + public Vector3 NearestWalkableCell(Vector3 localPos) + { + int gx = LocalToGridX(localPos.x); + int gz = LocalToGridZ(localPos.z); + FindNearestWalkableCell(ref gx, ref gz); + return GridToLocal(gx, gz); + } + + #region Grid Generation + + private void GenerateGrid() + { + float wallMargin = Constants.InteriorNav.WallMargin; + + // Start all cells walkable + for (int i = 0; i < _walkable.Length; i++) + _walkable[i] = true; + + // Mark wall margin zone as unwalkable. + // Cells near the room edge are blocked; MarkDoorwayOpenings() + // carves paths through at doorway locations. + for (int gx = 0; gx < _gridWidth; gx++) + { + for (int gz = 0; gz < _gridDepth; gz++) + { + Vector3 local = GridToLocal(gx, gz); + + if (local.x < wallMargin || local.x > _roomSize.x - wallMargin || + local.z < wallMargin || local.z > _roomSize.z - wallMargin) + { + _walkable[gz * _gridWidth + gx] = false; + } + } + } + + // Mark interior wall cells as unwalkable (with doorway openings) + MarkInteriorWalls(wallMargin); + + // Mark cells blocked by physics colliders (furniture, etc.) + MarkPhysicsObstacles(); + + // Re-open doorway cells LAST — door panel colliders must not block + // the walkable path through doorways. + MarkDoorwayOpenings(); + + int walkableCount = 0; + for (int i = 0; i < _walkable.Length; i++) + if (_walkable[i]) walkableCount++; + + DebugLog.Info($"[InteriorPathGrid] Generated {_gridWidth}x{_gridDepth} grid " + + $"({walkableCount}/{_walkable.Length} walkable), cellSize={_cellSize:F2}m"); + } + + private void MarkInteriorWalls(float margin) + { + foreach (var door in _doorways) + { + if (!door.IsInterior) continue; + + // Determine wall line from doorway's InwardNormal and Center. + // Interior walls run perpendicular to the InwardNormal. + Vector3 normal = door.InwardNormal; + bool isXWall = Mathf.Abs(normal.z) > Mathf.Abs(normal.x); // wall runs along X + float wallPos = isXWall ? door.Center.z : door.Center.x; + float doorCenter = isXWall ? door.Center.x : door.Center.z; + float halfWidth = door.Width / 2f; + + for (int gx = 0; gx < _gridWidth; gx++) + { + for (int gz = 0; gz < _gridDepth; gz++) + { + Vector3 local = GridToLocal(gx, gz); + + float perpDist = isXWall + ? Mathf.Abs(local.z - wallPos) + : Mathf.Abs(local.x - wallPos); + + if (perpDist > margin) continue; + + // Check if cell is within the doorway opening + float alongWall = isXWall ? local.x : local.z; + if (Mathf.Abs(alongWall - doorCenter) < halfWidth + 0.1f) continue; + + _walkable[gz * _gridWidth + gx] = false; + } + } + } + } + + private void MarkDoorwayOpenings() + { + foreach (var door in _doorways) + { + Vector3 center = door.Center; + float halfWidth = door.Width / 2f; + Vector3 normal = door.InwardNormal; + + // Open cells in a rectangle covering the doorway width × wall thickness. + // For exterior doorways this extends through the wall zone into the + // extended grid area so NPCs can path all the way through. + bool isXDoor = Mathf.Abs(normal.z) > Mathf.Abs(normal.x); + + for (int gx = 0; gx < _gridWidth; gx++) + { + for (int gz = 0; gz < _gridDepth; gz++) + { + Vector3 local = GridToLocal(gx, gz); + + float perpDist, alongDist; + if (isXDoor) + { + perpDist = Mathf.Abs(local.z - center.z); + alongDist = Mathf.Abs(local.x - center.x); + } + else + { + perpDist = Mathf.Abs(local.x - center.x); + alongDist = Mathf.Abs(local.z - center.z); + } + + // Full-cell buffer on width ensures NPCs can path through + // without clipping the door frame edges. NPC capsule radius + // (~0.35m) needs clearance beyond the nominal doorway width. + if (perpDist < door.WallThickness / 2f + _cellSize && + alongDist < halfWidth + _cellSize) + { + _walkable[gz * _gridWidth + gx] = true; + } + } + } + } + } + + private void MarkPhysicsObstacles() + { + // Short box at walking height — avoids detecting ceiling/roof colliders + // which are building children and would mark every cell unwalkable. + float probeHeight = 0.5f; + Vector3 halfExtents = new Vector3( + _cellSize / 2f * 0.8f, + probeHeight, + _cellSize / 2f * 0.8f); + + Quaternion rotation = _buildingRoot.rotation; + + // Collect structural colliders to exclude (floor, ceiling, walls, foundation, stairs, roof) + var excludedColliders = new HashSet(); + CollectStructuralColliders(_buildingRoot, excludedColliders); + + // Cache living entity roots (players/NPCs with Animators) to skip + var livingRoots = new HashSet(); + var staticRoots = new HashSet(); + + for (int gx = 0; gx < _gridWidth; gx++) + { + for (int gz = 0; gz < _gridDepth; gz++) + { + if (!_walkable[gz * _gridWidth + gx]) continue; + + Vector3 cellLocal = GridToLocal(gx, gz); + // Center probe at walking height (0.5m above floor) + Vector3 localCenter = new Vector3(cellLocal.x, probeHeight, cellLocal.z); + Vector3 worldCenter = _buildingRoot.TransformPoint(localCenter); + + Collider[] hits = Physics.OverlapBox(worldCenter, halfExtents, rotation); + foreach (Collider hit in hits) + { + if (hit.isTrigger) continue; + if (excludedColliders.Contains(hit)) continue; + + // Skip living entities (players/NPCs) + if (BuildingUtilities.IsLivingEntity(hit.transform, livingRoots, staticRoots)) + continue; + + // Only block for colliders whose center is inside the building + // footprint. This allows external furniture (e.g. MeshVault) placed + // inside to block, while filtering world objects (trees, lights) + // whose colliders bleed in from outside. + Vector3 colliderLocal = _buildingRoot.InverseTransformPoint(hit.bounds.center); + if (colliderLocal.x < -0.5f || colliderLocal.x > _roomSize.x + 0.5f || + colliderLocal.z < -0.5f || colliderLocal.z > _roomSize.z + 0.5f) + continue; + + _walkable[gz * _gridWidth + gx] = false; + break; + } + } + } + } + + /// + /// Collect colliders from known structural folders (Floor, Walls, Ceiling, + /// Foundation, Stairs, Roof) so they are excluded from physics obstacle detection. + /// + private static void CollectStructuralColliders(Transform root, HashSet excluded) + { + string[] structuralNames = { + "Floor", "Ceiling", "Walls", "ExteriorWalls", "InteriorWalls", + Constants.Spatial.FoundationFolderName, + Constants.Spatial.StairsFolderName, + "Roof", "Parapet", "Molding", "WindowFrames", "DoorFrames" + }; + + foreach (string name in structuralNames) + { + Transform? folder = root.Find(name); + if (folder == null) continue; + foreach (Collider c in folder.GetComponentsInChildren()) + excluded.Add(c); + } + + // Also exclude colliders directly on the root itself + Collider? rootCollider = root.GetComponent(); + if (rootCollider != null) excluded.Add(rootCollider); + } + + #endregion + + #region A* Pathfinding + + private static readonly int[] DirectionX = { 0, 1, 1, 1, 0, -1, -1, -1 }; + private static readonly int[] DirectionZ = { 1, 1, 0, -1, -1, -1, 0, 1 }; + private static readonly float[] MoveCost = { 1f, 1.414f, 1f, 1.414f, 1f, 1.414f, 1f, 1.414f }; + + private List? AStar(int sx, int sz, int ex, int ez) + { + if (sx == ex && sz == ez) + return new List { new Vector2Int(sx, sz) }; + + int totalCells = _gridWidth * _gridDepth; + float[] gScore = new float[totalCells]; + float[] fScore = new float[totalCells]; + int[] cameFrom = new int[totalCells]; + bool[] closed = new bool[totalCells]; + + for (int i = 0; i < totalCells; i++) + { + gScore[i] = float.MaxValue; + fScore[i] = float.MaxValue; + cameFrom[i] = -1; + } + + int startIdx = sz * _gridWidth + sx; + int endIdx = ez * _gridWidth + ex; + gScore[startIdx] = 0f; + fScore[startIdx] = Heuristic(sx, sz, ex, ez); + + // Simple priority queue using sorted list (fine for few hundred cells) + var open = new SortedList(new DuplicateKeyComparer()); + open.Add(fScore[startIdx], startIdx); + + while (open.Count > 0) + { + int currentIdx = open.Values[0]; + open.RemoveAt(0); + + if (currentIdx == endIdx) + return ReconstructPath(cameFrom, currentIdx); + + if (closed[currentIdx]) continue; + closed[currentIdx] = true; + + int cx = currentIdx % _gridWidth; + int cz = currentIdx / _gridWidth; + + for (int d = 0; d < 8; d++) + { + int nx = cx + DirectionX[d]; + int nz = cz + DirectionZ[d]; + + if (nx < 0 || nx >= _gridWidth || nz < 0 || nz >= _gridDepth) continue; + + int neighborIdx = nz * _gridWidth + nx; + if (closed[neighborIdx] || !_walkable[neighborIdx]) continue; + + // Prevent diagonal corner-cutting through walls + if (d % 2 == 1) // diagonal + { + int adj1 = cz * _gridWidth + nx; // same z, neighbor x + int adj2 = nz * _gridWidth + cx; // neighbor z, same x + if (!_walkable[adj1] || !_walkable[adj2]) continue; + } + + float tentG = gScore[currentIdx] + MoveCost[d]; + if (tentG < gScore[neighborIdx]) + { + cameFrom[neighborIdx] = currentIdx; + gScore[neighborIdx] = tentG; + fScore[neighborIdx] = tentG + Heuristic(nx, nz, ex, ez); + open.Add(fScore[neighborIdx], neighborIdx); + } + } + } + + return null; // no path + } + + private float Heuristic(int ax, int az, int bx, int bz) + { + float dx = ax - bx; + float dz = az - bz; + return Mathf.Sqrt(dx * dx + dz * dz); + } + + private List ReconstructPath(int[] cameFrom, int current) + { + var path = new List(); + while (current != -1) + { + int x = current % _gridWidth; + int z = current / _gridWidth; + path.Add(new Vector2Int(x, z)); + current = cameFrom[current]; + } + path.Reverse(); + return path; + } + + /// + /// Comparer that allows duplicate keys in SortedList. + /// + private sealed class DuplicateKeyComparer : IComparer + { + public int Compare(float x, float y) + { + int result = x.CompareTo(y); + return result == 0 ? 1 : result; // never return 0 — treat equal as greater + } + } + + #endregion + + #region Path Smoothing + + private void SmoothPath(List worldPath) + { + if (worldPath.Count <= 2) return; + + int i = 0; + while (i < worldPath.Count - 2) + { + // Try to skip intermediate waypoints via line-of-sight + int farthest = i + 1; + for (int j = worldPath.Count - 1; j > i + 1; j--) + { + if (HasLineOfSight(worldPath[i], worldPath[j])) + { + farthest = j; + break; + } + } + + // Remove all waypoints between i and farthest + if (farthest > i + 1) + { + worldPath.RemoveRange(i + 1, farthest - i - 1); + } + + i++; + } + } + + private bool HasLineOfSight(Vector3 worldA, Vector3 worldB) + { + // Check walkability on the grid along the line from A to B + Vector3 localA = _buildingRoot.InverseTransformPoint(worldA); + Vector3 localB = _buildingRoot.InverseTransformPoint(worldB); + + int ax = LocalToGridX(localA.x); + int az = LocalToGridZ(localA.z); + int bx = LocalToGridX(localB.x); + int bz = LocalToGridZ(localB.z); + + // Bresenham-like line walk + int dx = Mathf.Abs(bx - ax); + int dz = Mathf.Abs(bz - az); + int sx = ax < bx ? 1 : -1; + int sz = az < bz ? 1 : -1; + int err = dx - dz; + + int x = ax, z = az; + while (true) + { + if (!IsWalkableCell(x, z)) return false; + if (x == bx && z == bz) break; + + int e2 = 2 * err; + if (e2 > -dz) { err -= dz; x += sx; } + if (e2 < dx) { err += dx; z += sz; } + } + + return true; + } + + #endregion + + #region Coordinate Conversion + + // Grid origin is at (0, 0) in local building coords — aligned with room corner. + private int LocalToGridX(float localX) => + Mathf.Clamp(Mathf.FloorToInt(localX / _cellSize), 0, _gridWidth - 1); + + private int LocalToGridZ(float localZ) => + Mathf.Clamp(Mathf.FloorToInt(localZ / _cellSize), 0, _gridDepth - 1); + + private Vector3 GridToLocal(int gx, int gz) => + new Vector3( + (gx + 0.5f) * _cellSize, + 0f, + (gz + 0.5f) * _cellSize); + + private bool IsWalkableCell(int gx, int gz) + { + if (gx < 0 || gx >= _gridWidth || gz < 0 || gz >= _gridDepth) + return false; + return _walkable[gz * _gridWidth + gx]; + } + + private void FindNearestWalkableCell(ref int gx, ref int gz) + { + // BFS outward from (gx, gz) to find nearest walkable cell + int bestX = gx, bestZ = gz; + float bestDist = float.MaxValue; + int searchRadius = Mathf.Max(_gridWidth, _gridDepth); + + for (int r = 1; r <= searchRadius; r++) + { + bool found = false; + for (int dx = -r; dx <= r; dx++) + { + for (int dz = -r; dz <= r; dz++) + { + if (Mathf.Abs(dx) != r && Mathf.Abs(dz) != r) continue; // only ring + int nx = gx + dx; + int nz = gz + dz; + if (IsWalkableCell(nx, nz)) + { + float dist = dx * dx + dz * dz; + if (dist < bestDist) + { + bestDist = dist; + bestX = nx; + bestZ = nz; + found = true; + } + } + } + } + if (found) break; + } + + gx = bestX; + gz = bestZ; + } + + #endregion + + #region Visualization + + private GameObject? _vizRoot; + + /// + /// Create or refresh in-game visualization of the walkability grid. + /// Green = walkable, red = blocked. Call again to refresh after Regenerate(). + /// + public void Visualize() + { + DestroyVisualization(); + + _vizRoot = new GameObject("[S1MAPI] PathGrid Visualizer"); + _vizRoot.transform.SetParent(_buildingRoot, worldPositionStays: false); + _vizRoot.transform.localPosition = Vector3.zero; + _vizRoot.transform.localRotation = Quaternion.identity; + + // Create shared materials + Material walkableMat = CreateFlatMaterial(new Color(0f, 1f, 0f, 0.35f)); + Material blockedMat = CreateFlatMaterial(new Color(1f, 0f, 0f, 0.35f)); + + float pad = 0.05f; // small gap between cells + float quadSize = _cellSize - pad * 2f; + + for (int gx = 0; gx < _gridWidth; gx++) + { + for (int gz = 0; gz < _gridDepth; gz++) + { + bool walkable = _walkable[gz * _gridWidth + gx]; + Vector3 localCenter = GridToLocal(gx, gz); + localCenter.y = 0.02f; // slightly above floor + + GameObject quad = GameObject.CreatePrimitive(PrimitiveType.Quad); + quad.name = $"Cell_{gx}_{gz}"; + quad.transform.SetParent(_vizRoot.transform, worldPositionStays: false); + quad.transform.localPosition = localCenter; + quad.transform.localRotation = Quaternion.Euler(90f, 0f, 0f); // face up + quad.transform.localScale = new Vector3(quadSize, quadSize, 1f); + + // Remove collider so it doesn't interfere with gameplay + var col = quad.GetComponent(); + if (col != null) UnityEngine.Object.Destroy(col); + + var renderer = quad.GetComponent(); + if (renderer != null) + renderer.sharedMaterial = walkable ? walkableMat : blockedMat; + } + } + + DebugLog.Info($"[InteriorPathGrid] Visualization created: {_gridWidth}x{_gridDepth} cells"); + } + + /// + /// Remove the visualization markers. + /// + public void DestroyVisualization() + { + if (_vizRoot != null) + { + UnityEngine.Object.Destroy(_vizRoot); + _vizRoot = null; + } + } + + /// + /// Diagnose why a specific cell is blocked. Logs the reason (wall margin, + /// interior wall, physics collider name/path) to the console. + /// + /// Grid X coordinate (shown in Cell_X_Z name) + /// Grid Z coordinate (shown in Cell_X_Z name) + public void DiagnoseCell(int gx, int gz) + { + if (gx < 0 || gx >= _gridWidth || gz < 0 || gz >= _gridDepth) + { + DebugLog.Warning($"[PathGrid] Cell ({gx},{gz}) is out of bounds (grid is {_gridWidth}x{_gridDepth})"); + return; + } + + bool walkable = _walkable[gz * _gridWidth + gx]; + Vector3 localPos = GridToLocal(gx, gz); + Vector3 worldPos = _buildingRoot.TransformPoint(localPos); + + DebugLog.Info($"[PathGrid] Cell ({gx},{gz}): local=({localPos.x:F2},{localPos.z:F2}), world=({worldPos.x:F2},{worldPos.y:F2},{worldPos.z:F2}), walkable={walkable}"); + + if (walkable) return; + + // Check: exterior wall margin + float wallMargin = Constants.InteriorNav.WallMargin; + if (localPos.x < wallMargin || localPos.x > _roomSize.x - wallMargin || + localPos.z < wallMargin || localPos.z > _roomSize.z - wallMargin) + { + DebugLog.Info($" -> Blocked by EXTERIOR WALL margin (margin={wallMargin:F2}, roomSize=({_roomSize.x:F2},{_roomSize.z:F2}))"); + } + + // Check: interior wall + foreach (var door in _doorways) + { + if (!door.IsInterior) continue; + Vector3 normal = door.InwardNormal; + bool isXWall = Mathf.Abs(normal.z) > Mathf.Abs(normal.x); + float wallPos = isXWall ? door.Center.z : door.Center.x; + float perpDist = isXWall ? Mathf.Abs(localPos.z - wallPos) : Mathf.Abs(localPos.x - wallPos); + if (perpDist <= wallMargin) + { + float alongWall = isXWall ? localPos.x : localPos.z; + float doorCenter = isXWall ? door.Center.x : door.Center.z; + if (Mathf.Abs(alongWall - doorCenter) >= door.Width / 2f + 0.1f) + { + DebugLog.Info($" -> Blocked by INTERIOR WALL at {(isXWall ? "Z" : "X")}={wallPos:F2} (perpDist={perpDist:F2})"); + } + } + } + + // Check: physics collider + float probeHeight = 0.5f; + Vector3 halfExtents = new Vector3(_cellSize / 2f * 0.8f, probeHeight, _cellSize / 2f * 0.8f); + Vector3 probeCenter = _buildingRoot.TransformPoint(new Vector3(localPos.x, probeHeight, localPos.z)); + Collider[] hits = Physics.OverlapBox(probeCenter, halfExtents, _buildingRoot.rotation); + + var excludedColliders = new HashSet(); + CollectStructuralColliders(_buildingRoot, excludedColliders); + + foreach (Collider hit in hits) + { + if (hit.isTrigger) continue; + bool excluded = excludedColliders.Contains(hit); + Vector3 colliderLocal = _buildingRoot.InverseTransformPoint(hit.bounds.center); + bool insideFootprint = colliderLocal.x >= -0.5f && colliderLocal.x <= _roomSize.x + 0.5f && + colliderLocal.z >= -0.5f && colliderLocal.z <= _roomSize.z + 0.5f; + string path = GetTransformPath(hit.transform); + DebugLog.Info($" -> Collider: \"{path}\" (excluded={excluded}, insideFootprint={insideFootprint}, localPos=({colliderLocal.x:F2},{colliderLocal.y:F2},{colliderLocal.z:F2}), type={hit.GetType().Name})"); + } + } + + private static string GetTransformPath(Transform t) + { + string path = t.name; + Transform? parent = t.parent; + int depth = 0; + while (parent != null && depth < 5) + { + path = parent.name + "/" + path; + parent = parent.parent; + depth++; + } + return path; + } + + private static Material CreateFlatMaterial(Color color) + { + // Use a transparent unlit shader + Shader? shader = Shader.Find("Sprites/Default"); + if (shader == null) shader = Shader.Find("UI/Default"); + if (shader == null) shader = Shader.Find("Unlit/Color"); + + var mat = new Material(shader!); + mat.color = color; + return mat; + } + + #endregion + } +} diff --git a/Building/NavigationBuilder.cs b/Building/NavigationBuilder.cs new file mode 100644 index 0000000..3089e34 --- /dev/null +++ b/Building/NavigationBuilder.cs @@ -0,0 +1,416 @@ +using System; +using System.Collections.Generic; +using S1MAPI.Utils; +using UnityEngine; +using UnityEngine.AI; +#if IL2CPP +using Il2CppInterop.Runtime.Injection; +#endif + +namespace S1MAPI.Building +{ + /// + /// Records the position and dimensions of a doorway for NavMesh source filtering + /// and ramp/ground plane generation. Used for both exterior and interior doorways. + /// + public sealed class NavDoorwayInfo + { + /// Center of the doorway in local building coordinates (Y=0, floor level). + public Vector3 Center { get; } + + /// Width of the doorway opening in meters. + public float Width { get; } + + /// Height of the doorway opening in meters. + public float Height { get; } + + /// Unit vector perpendicular to the wall face in the XZ plane. + public Vector3 InwardNormal { get; } + + /// Thickness of the wall containing this doorway. + public float WallThickness { get; } + + /// + /// Position at the base of the stairs in local building coordinates (ground level). + /// Null for interior doorways or exterior doorways without stairs. + /// + public Vector3? StairBasePosition { get; } + + /// + /// Whether this doorway is an interior wall doorway (between rooms) + /// rather than an exterior wall doorway. + /// + public bool IsInterior { get; } + + /// + /// Create a doorway info record. + /// + /// Door center in local building coordinates (Y=0) + /// Doorway width in meters + /// Doorway height in meters + /// Unit vector perpendicular to the wall face + /// Wall thickness in meters + /// Position at stair base (ground level), or null if no stairs + /// True for interior wall doorways between rooms + public NavDoorwayInfo( + Vector3 center, float width, float height, + Vector3 inwardNormal, float wallThickness, + Vector3? stairBasePosition = null, + bool isInterior = false) + { + Center = center; + Width = width; + Height = height; + InwardNormal = inwardNormal; + WallThickness = wallThickness; + StairBasePosition = stairBasePosition; + IsInterior = isInterior; + } + } + + /// + /// Manages NPC navigation for building interiors. + /// + /// Carves the building footprint from baked NavMesh using NavMeshObstacle, + /// then provides custom A* pathfinding for interior navigation. + /// A Harmony patch on NPCMovement.SetDestination automatically intercepts NPCs + /// whose destinations fall inside the building — no manual SendNPCTo calls needed. + /// NPCs seamlessly transition between exterior NavMesh and interior A* pathfinding. + /// + /// + /// + /// Call after the building is positioned in the scene. + /// Call when interior objects change (e.g. furniture moved). + /// Call when the building is destroyed. + /// + public sealed class NavigationBuilder + { + #region Fields + + private readonly Transform _buildingRoot; + private readonly Vector3 _roomSize; + private readonly IReadOnlyList _doorways; + private readonly float _foundationHeight; + private readonly float _wallThickness; + + // Physical objects + private readonly List _rampObjects = new List(); + + // Carving obstacles placed at wall positions to block terrain NavMesh + private readonly List _carvingObstacles = new List(); + + // Custom interior pathfinding + private InteriorNavigatorCore? _navCore; + private InteriorNavigator? _navShell; + + private bool _isBuilt; + + #endregion + + #region Constructor + + /// + /// Create a new navigation builder for a building. + /// + /// Root transform of the building (must be positioned before calling Build) + /// Interior room dimensions (width, height, depth) + /// Doorway positions for source filtering and ramp generation + /// Exterior wall thickness in meters. + /// Foundation height in meters (0 = no foundation). + public NavigationBuilder( + Transform buildingRoot, + Vector3 roomSize, + IReadOnlyList doorways, + float wallThickness, + float foundationHeight = 0f) + { + _buildingRoot = buildingRoot; + _roomSize = roomSize; + _doorways = doorways; + _wallThickness = wallThickness; + _foundationHeight = foundationHeight; + } + + #endregion + + #region Properties + + /// + /// Whether the NavMesh is currently active. + /// + public bool IsBuilt => + _isBuilt; + + #endregion + + #region Public API + + /// + /// Set up interior NPC navigation for this building. + /// + /// Carves baked NavMesh under the footprint and installs a custom A* + /// pathfinding grid. NPCs whose SetDestination targets fall inside the + /// building are automatically intercepted and navigated through the interior. + /// + /// Must be called after the building is positioned in the scene. + /// + public void Build() + { + if (_isBuilt) + { + DebugLog.Warning("[NavigationBuilder] NavMesh already built. Call Rebuild() to refresh."); + return; + } + + CreateStairRamps(); + + // Carve the full building footprint — removes all baked NavMesh inside. + PlaceFootprintCarvingObstacle(); + + // Build A* pathfinding grid and navigation core. + var pathGrid = new InteriorPathGrid( + _buildingRoot, _roomSize, _doorways); + _navCore = new InteriorNavigatorCore( + pathGrid, _doorways, _buildingRoot, _roomSize, _foundationHeight); + + // Attach thin MonoBehaviour shell for Unity lifecycle forwarding. +#if IL2CPP + if (!ClassInjector.IsTypeRegisteredInIl2Cpp()) + ClassInjector.RegisterTypeInIl2Cpp(); +#endif + _navShell = _buildingRoot.gameObject.AddComponent(); + _navShell._core = _navCore; + + _isBuilt = true; + } + + /// + /// Tear down and rebuild the NavMesh. + /// Use when interior objects change (e.g. furniture placed or moved). + /// + public void Rebuild() + { + Remove(); + Build(); + } + + /// + /// Check if an NPC is currently being managed by this building's interior navigator. + /// + /// The NPC component to check. + public bool IsNPCInside(Component npc) + { + return _navCore != null && _navCore.IsTracking(npc); + } + + /// + /// Send an NPC to a position inside the building. The NPC walks to the nearest + /// doorway on exterior NavMesh, enters via lerp, then follows A* path to target. + /// + /// The NPC's NPCMovement component. + /// Target position in building-local coordinates (0,0 = building corner). + /// Optional callback invoked when the NPC reaches the destination. + public void SendNPCToPosition(Component npc, Vector3 localTarget, Action? onArrival = null) + => _navCore?.SendNPCToPosition(npc, localTarget, onArrival); + + /// + /// Recall an NPC from the building. If inside, begins exit via A* path to doorway. + /// If still approaching, releases immediately. The NPC's NavMeshAgent is re-enabled on exit. + /// + /// The NPC's NPCMovement component. + public void RecallNPC(Component npc) + => _navCore?.RecallNPC(npc); + + /// + /// Convert a world-space position to building-local coordinates. + /// Use this to convert world positions (e.g. furniture transforms) to the local + /// coordinates expected by . + /// + /// The world-space position to convert. + public Vector3 WorldToLocal(Vector3 worldPosition) + => _buildingRoot.InverseTransformPoint(worldPosition); + + /// + /// Convert a building-local position to world-space coordinates. + /// + /// The building-local position to convert. + public Vector3 LocalToWorld(Vector3 localPosition) + => _buildingRoot.TransformPoint(localPosition); + + /// + /// Show or hide the interior pathfinding grid visualization. + /// Green cells are walkable, red cells are blocked. + /// + /// True to show the grid, false to hide it. + public void VisualizePathGrid(bool show = true) + { + _navCore?.VisualizeGrid(show); + } + + /// + /// Diagnose why a specific grid cell is blocked. Logs the blocking reason + /// (wall margin, interior wall, physics collider name) to the console. + /// Cell coordinates are visible in the visualization quad names (Cell_X_Z). + /// + /// The X coordinate on the interior grid. + /// The Z coordinate on the interior grid. + public void DiagnoseCell(int gridX, int gridZ) + { + _navCore?.DiagnoseCell(gridX, gridZ); + } + + /// + /// Remove all NavMesh data and cleanup. + /// Call when the building is destroyed. + /// + public void Remove() + { + if (!_isBuilt) return; + + // Release all NPCs and clean up navigation core + if (_navCore != null) + { + _navCore.ReleaseAllNPCs(); + _navCore.Cleanup(); + _navCore = null; + } + if (_navShell != null) + { + _navShell._core = null; // prevent double cleanup from OnDestroy + UnityEngine.Object.Destroy(_navShell); + _navShell = null; + } + + foreach (GameObject obs in _carvingObstacles) + UnityEngine.Object.Destroy(obs); + _carvingObstacles.Clear(); + + foreach (GameObject ramp in _rampObjects) + UnityEngine.Object.Destroy(ramp); + _rampObjects.Clear(); + + _isBuilt = false; + DebugLog.Info("[NavigationBuilder] Removed NavMesh data."); + } + + #endregion + + #region Public API — Walkability + + /// + /// Check if a local-space position is on a walkable grid cell. + /// Returns false when the navigation system has not been built yet. + /// + /// Position in building-local coordinates. + /// True if the cell at is walkable. + public bool IsWalkable(Vector3 localPos) + => _navCore?.PathGrid?.IsWalkable(localPos) ?? false; + + /// + /// Get the center of the nearest walkable grid cell in local coordinates. + /// Returns unchanged when the navigation system has not been built yet. + /// + /// Position in building-local coordinates. + /// The center of the nearest walkable cell, or if unavailable. + public Vector3 NearestWalkableCell(Vector3 localPos) + => _navCore?.PathGrid?.NearestWalkableCell(localPos) ?? localPos; + + /// + /// Grid cell size in meters. Matches the resolution used by interior pathfinding + /// so callers can iterate cells at the same spacing. + /// Returns 0.5 when the navigation system has not been built yet. + /// + public float CellSize + => _navCore?.PathGrid?.CellSize ?? 0.5f; + + #endregion + + #region Private — NavMesh Carving + + /// + /// Place a single carving obstacle covering the entire building footprint. + /// This carves out ALL baked NavMesh inside the building (terrain island). + /// Test: if carving only affects baked data (not runtime-added instances), + /// the separately-added interior NavMeshData survives untouched. + /// + private void PlaceFootprintCarvingObstacle() + { + // Obstacle covers full footprint, tall enough to intersect terrain NavMesh + // at any height. Positioned at building center. + float height = _roomSize.y + 4f; // generous vertical coverage + + Vector3 localCenter = new Vector3( + _roomSize.x / 2f, + height / 2f, + _roomSize.z / 2f); + + var go = new GameObject("NavMeshFootprintCarve"); + go.transform.SetParent(_buildingRoot); + go.transform.localPosition = localCenter; + go.transform.localRotation = Quaternion.identity; + + NavMeshObstacle obstacle = go.AddComponent(); + obstacle.shape = NavMeshObstacleShape.Box; + obstacle.carving = true; + obstacle.carveOnlyStationary = true; + obstacle.size = new Vector3(_roomSize.x, height, _roomSize.z); + obstacle.center = Vector3.zero; + + _carvingObstacles.Add(go); + + DebugLog.Info($"[NavigationBuilder] Footprint carve: size=({_roomSize.x:F1}, {height:F1}, {_roomSize.z:F1}), " + + $"worldCenter={go.transform.position}"); + } + + #endregion + + #region Private — Stair Ramps + + /// + /// Create invisible ramp colliders at each exterior doorway with stairs. + /// + private void CreateStairRamps() + { + foreach (NavDoorwayInfo doorway in _doorways) + { + if (!doorway.StairBasePosition.HasValue) continue; + + Vector3 top = doorway.Center; + Vector3 bottom = doorway.StairBasePosition.Value; + + Vector3 flatDelta = new Vector3(bottom.x - top.x, 0f, bottom.z - top.z); + float horizontalDist = flatDelta.magnitude; + float verticalDist = Mathf.Abs(bottom.y); + float rampLength = Mathf.Sqrt(horizontalDist * horizontalDist + verticalDist * verticalDist); + float slopeAngle = Mathf.Atan2(verticalDist, horizontalDist) * Mathf.Rad2Deg; + + Vector3 mid = (top + bottom) / 2f; + Vector3 outward = flatDelta.normalized; + + GameObject rampGO = new GameObject("NavMeshRamp"); + rampGO.transform.SetParent(_buildingRoot); + rampGO.transform.localPosition = mid; + + Quaternion facing = Quaternion.LookRotation(outward, Vector3.up); + rampGO.transform.localRotation = facing * Quaternion.AngleAxis(slopeAngle, Vector3.right); + + float rampWidth = Mathf.Max(doorway.Width, Constants.NavMesh.MinRampWidth) + + Constants.NavMesh.RampErosionBuffer; + + BoxCollider col = rampGO.AddComponent(); + col.center = Vector3.zero; + col.size = new Vector3(rampWidth, Constants.NavMesh.RampColliderThickness, rampLength); + + _rampObjects.Add(rampGO); + + Vector3 worldTop = _buildingRoot.TransformPoint(top); + Vector3 worldBottom = _buildingRoot.TransformPoint(bottom); + DebugLog.Info($"[NavigationBuilder] Ramp: worldPos={rampGO.transform.position}, " + + $"top={worldTop}, bottom={worldBottom}, " + + $"width={rampWidth:F2}, length={rampLength:F2}, angle={slopeAngle:F1}°"); + } + } + + #endregion + } +} diff --git a/Building/Structural/DecorBuilder.cs b/Building/Structural/DecorBuilder.cs index b0a40d2..05c957e 100644 --- a/Building/Structural/DecorBuilder.cs +++ b/Building/Structural/DecorBuilder.cs @@ -1,5 +1,7 @@ +using System.Collections.Generic; using S1MAPI.Building.Config; using S1MAPI.ProceduralMesh; +using S1MAPI.Utils; using UnityEngine; namespace S1MAPI.Building.Structural @@ -16,6 +18,9 @@ public sealed class DecorBuilder private readonly Vector3 _roomSize; private readonly BuildingPalette _palette; + private float _foundationClearanceX; + private float _foundationClearanceZ; + #endregion #region Constructor @@ -193,6 +198,87 @@ public GameObject AddCornerPillars(float width = 0.4f, Material? material = null return container; } + /// + /// Add thin vertical trim strips at the four corners of the building. + /// Each corner gets two perpendicular strips forming a right angle, matching + /// the depth and style of horizontal trims (, ). + /// Runs the full wall height so it looks good with or without horizontal trim. + /// + /// Visible width of each trim strip on the wall face in meters + /// How far the trim protrudes past the wall surface in meters + /// Optional material override + /// The corner trim container GameObject + public GameObject AddCornerTrim(float width = 0.3f, float depth = 0.1f, Material? material = null) + { + float wallThickness = 0.2f; + float trimDepth = wallThickness + depth; + float height = _roomSize.y; + GameObject container = BuildingUtilities.CreateFolder("CornerTrim", _parent); + + Color color = _palette.TrimColor; + + // Each corner gets two perpendicular strips: + // - One on the N/S wall face at the corner end + // - One on the E/W wall face at the corner end + // Small overlap at the inner corner is invisible (same color, hidden faces). + + // NE corner — both strips centered at corner so they wrap around the edge + GameObject neN = PrimitiveBuilder.CreateBox("CornerTrim_NE_N", + new Vector3(_roomSize.x, height / 2f, _roomSize.z), + new Vector3(width, height, trimDepth), + color, container.transform); + GameObject neE = PrimitiveBuilder.CreateBox("CornerTrim_NE_E", + new Vector3(_roomSize.x, height / 2f, _roomSize.z), + new Vector3(trimDepth, height, width), + color, container.transform); + + // NW corner + GameObject nwN = PrimitiveBuilder.CreateBox("CornerTrim_NW_N", + new Vector3(0f, height / 2f, _roomSize.z), + new Vector3(width, height, trimDepth), + color, container.transform); + GameObject nwW = PrimitiveBuilder.CreateBox("CornerTrim_NW_W", + new Vector3(0f, height / 2f, _roomSize.z), + new Vector3(trimDepth, height, width), + color, container.transform); + + // SE corner + GameObject seS = PrimitiveBuilder.CreateBox("CornerTrim_SE_S", + new Vector3(_roomSize.x, height / 2f, 0f), + new Vector3(width, height, trimDepth), + color, container.transform); + GameObject seE = PrimitiveBuilder.CreateBox("CornerTrim_SE_E", + new Vector3(_roomSize.x, height / 2f, 0f), + new Vector3(trimDepth, height, width), + color, container.transform); + + // SW corner + GameObject swS = PrimitiveBuilder.CreateBox("CornerTrim_SW_S", + new Vector3(0f, height / 2f, 0f), + new Vector3(width, height, trimDepth), + color, container.transform); + GameObject swW = PrimitiveBuilder.CreateBox("CornerTrim_SW_W", + new Vector3(0f, height / 2f, 0f), + new Vector3(trimDepth, height, width), + color, container.transform); + + // Apply material + Material? mat = material ?? _palette.TrimMaterial; + if (mat != null) + { + ApplyMaterial(neN, mat); + ApplyMaterial(neE, mat); + ApplyMaterial(nwN, mat); + ApplyMaterial(nwW, mat); + ApplyMaterial(seS, mat); + ApplyMaterial(seE, mat); + ApplyMaterial(swS, mat); + ApplyMaterial(swW, mat); + } + + return container; + } + /// /// Add a solid foundation block beneath the building. /// @@ -207,9 +293,13 @@ public GameObject AddFoundation(float height = 2.0f, float expandX = 0f, float e Color foundationColor = color ?? new Color(0.4f, 0.4f, 0.4f); GameObject container = BuildingUtilities.CreateFolder("Foundation", _parent); - float padding = 0.1f; + float padding = Constants.Spatial.FoundationPadding; float yOffset = -0.001f; // Avoid z-fighting with floor + // Store clearance so AddStairs can auto-clear the foundation edge + _foundationClearanceX = padding + expandX; + _foundationClearanceZ = padding + expandZ; + float width = _roomSize.x + padding * 2 + expandX * 2; float depth = _roomSize.z + padding * 2 + expandZ * 2; @@ -227,65 +317,212 @@ public GameObject AddFoundation(float height = 2.0f, float expandX = 0f, float e return container; } + /// + /// Add stairs from ground level up to the building floor on the specified wall. + /// Supports multiple visual styles: Solid (concrete box steps), ClosedRiser (two-tone wood with risers), + /// or OpenStringer (plank treads on diagonal stringer beams). + /// + /// Which wall the stairs attach to + /// Height of the foundation in meters + /// Maximum height per step. Lower values create more, shallower steps. (Solid only) + /// Step width in meters (Solid only) + /// Step depth (tread) in meters. Controls how far stairs extend outward. (Solid only) + /// Optional color override — defaults to floor color (Solid only) + /// Optional material override — defaults to floor material (Solid only) + /// Visual style of stairs to generate + /// If true, topmost step is flush with floor level. If false (default), topmost step is one step below floor. (Solid only) + /// Vertical gap between foundation edge and top step. ClosedRiser/OpenStringer default to 0 (flush). + /// Lateral offset from wall center to align stairs with an offset door opening. + /// The stairs container GameObject + public GameObject AddStairs( + WallSide wall, + float foundationHeight, + float maxStepHeight = Constants.Spatial.DefaultMaxStepHeight, + float width = 2.5f, + float stepDepth = Constants.Spatial.DefaultStepDepth, + Color? color = null, + Material? material = null, + StairStyle style = StairStyle.Solid, + bool flushWithFloor = false, + float gap = 0f, + float lateralOffset = 0f) + { + return style switch + { + StairStyle.ClosedRiser => AddClosedRiserStairs(wall, foundationHeight, gap, lateralOffset), + StairStyle.OpenStringer => AddOpenStringerStairs(wall, foundationHeight, gap, lateralOffset), + _ => AddSolidStairs(wall, foundationHeight, maxStepHeight, width, stepDepth, color, material, flushWithFloor, lateralOffset) + }; + } + + /// + /// Add trim-style door frames (left jamb, right jamb, header) around door openings. + /// Frames use palette trim color/material and protrude slightly past the wall surface. + /// + /// North wall opening + /// South wall opening + /// East wall opening + /// West wall opening + /// Optional material override + /// The door frames container GameObject + public GameObject AddDoorFrames( + WallOpening? northOpening = null, WallOpening? southOpening = null, + WallOpening? eastOpening = null, WallOpening? westOpening = null, + Material? material = null) + { + GameObject container = BuildingUtilities.CreateFolder("DoorFrames", _parent); + + float wallThickness = 0.2f; + float frameWidth = 0.12f; + float frameProtrusion = 0.1f; + float trimDepth = wallThickness + frameProtrusion; + + Color color = _palette.TrimColor; + Material? mat = material ?? _palette.TrimMaterial; + + // Inset wall segments and create frames for each door opening + if (northOpening?.Type == WallOpeningType.Door) + { + InsetDoorWallSegments("NorthWall", frameWidth, false); + CreateDoorFrame("DoorFrame_North", + new Vector3(_roomSize.x / 2f, _roomSize.y / 2f, _roomSize.z), + _roomSize.y, northOpening, false, frameWidth, trimDepth, color, mat, container); + } + + if (southOpening?.Type == WallOpeningType.Door) + { + InsetDoorWallSegments("SouthWall", frameWidth, false); + CreateDoorFrame("DoorFrame_South", + new Vector3(_roomSize.x / 2f, _roomSize.y / 2f, 0f), + _roomSize.y, southOpening, false, frameWidth, trimDepth, color, mat, container); + } + + if (eastOpening?.Type == WallOpeningType.Door) + { + InsetDoorWallSegments("EastWall", frameWidth, true); + CreateDoorFrame("DoorFrame_East", + new Vector3(_roomSize.x, _roomSize.y / 2f, _roomSize.z / 2f), + _roomSize.y, eastOpening, true, frameWidth, trimDepth, color, mat, container); + } + + if (westOpening?.Type == WallOpeningType.Door) + { + InsetDoorWallSegments("WestWall", frameWidth, true); + CreateDoorFrame("DoorFrame_West", + new Vector3(0f, _roomSize.y / 2f, _roomSize.z / 2f), + _roomSize.y, westOpening, true, frameWidth, trimDepth, color, mat, container); + } + + return container; + } + + /// + /// Add trim-style door frames around interior doorway openings. + /// Uses recorded by . + /// + /// Interior doorway positions and dimensions + /// Width of the frame casing in meters + /// How far the frame extends past the wall surface + /// Optional material override (defaults to palette trim material) + /// The interior door frames container GameObject + public GameObject AddInteriorDoorFrames( + IReadOnlyList doorways, + float frameWidth = 0.12f, float frameProtrusion = 0.04f, + Material? material = null) + { + GameObject container = BuildingUtilities.CreateFolder("InteriorDoorFrames", _parent); + + Color color = _palette.TrimColor; + Material? mat = material ?? _palette.TrimMaterial; + + for (int i = 0; i < doorways.Count; i++) + { + DoorwayInfo doorway = doorways[i]; + float trimDepth = doorway.WallThickness + frameProtrusion; + bool isVertical = !doorway.FacesAlongZ; + + // Inset wall segments to make room for the frame (same as exterior) + if (doorway.WallContainer != null) + { + InsetDoorWallSegments(doorway.WallContainer.transform, + doorway.WallContainer.name, frameWidth, isVertical); + } + + // DoorwayInfo.Center is at door mid-height; CreateDoorFrame expects wall center (mid-wall-height) + Vector3 wallCenter = new Vector3(doorway.Center.x, _roomSize.y / 2f, doorway.Center.z); + + CreateDoorFrame($"InteriorDoorFrame_{i}", wallCenter, _roomSize.y, + doorway.Width, doorway.Height, 0f, + isVertical, frameWidth, trimDepth, color, mat, container); + } + + return container; + } + /// /// Add base molding around the bottom of the building. + /// Automatically gaps around door openings so the molding does not clip through door frames. /// /// Molding height in meters /// Molding depth in meters /// Optional material override + /// North wall opening (doors create a gap) + /// South wall opening (doors create a gap) + /// East wall opening (doors create a gap) + /// West wall opening (doors create a gap) + /// Wall sides to omit molding from (null = include all walls) /// The molding container GameObject - public GameObject AddBaseMolding(float height = 0.3f, float depth = 0.1f, Material? material = null) + public GameObject AddBaseMolding( + float height = 0.3f, float depth = 0.1f, Material? material = null, + WallOpening? northOpening = null, WallOpening? southOpening = null, + WallOpening? eastOpening = null, WallOpening? westOpening = null, + IEnumerable? skipWalls = null) { float halfWidth = _roomSize.x / 2f; float halfDepth = _roomSize.z / 2f; + float wallThickness = 0.2f; + float trimDepth = wallThickness + depth; GameObject container = BuildingUtilities.CreateFolder("BaseMolding", _parent); Color color = _palette.TrimColor; - - // Back (North) - GameObject back = PrimitiveBuilder.CreateBox( - "BaseMolding_North", - new Vector3(halfWidth, height / 2f, _roomSize.z + depth / 2f), - new Vector3(_roomSize.x + depth * 2f, height, depth), - color, - container.transform - ); - - // Left (West) - GameObject left = PrimitiveBuilder.CreateBox( - "BaseMolding_West", - new Vector3(-depth / 2f, height / 2f, halfDepth), - new Vector3(depth, height, _roomSize.z), - color, - container.transform - ); - - // Right (East) - GameObject right = PrimitiveBuilder.CreateBox( - "BaseMolding_East", - new Vector3(_roomSize.x + depth / 2f, height / 2f, halfDepth), - new Vector3(depth, height, _roomSize.z), - color, - container.transform - ); - - // Front (South) - GameObject front = PrimitiveBuilder.CreateBox( - "BaseMolding_South", - new Vector3(halfWidth, height / 2f, -depth / 2f), - new Vector3(_roomSize.x + depth * 2f, height, depth), - color, - container.transform - ); - - // Apply material Material? mat = material ?? _palette.TrimMaterial; - if (mat != null) + + HashSet? skip = skipWalls != null ? new HashSet(skipWalls) : null; + + // North (extends along X, centered on wall surface) + if (skip == null || !skip.Contains(WallSide.North)) + { + CreateMoldingSegments("BaseMolding_North", + new Vector3(halfWidth, height / 2f, _roomSize.z), + new Vector3(_roomSize.x + trimDepth, height, trimDepth), + _roomSize.x + trimDepth, false, northOpening, height, color, mat, container); + } + + // South (extends along X, centered on wall surface) + if (skip == null || !skip.Contains(WallSide.South)) { - ApplyMaterial(back, mat); - ApplyMaterial(left, mat); - ApplyMaterial(right, mat); - ApplyMaterial(front, mat); + CreateMoldingSegments("BaseMolding_South", + new Vector3(halfWidth, height / 2f, 0f), + new Vector3(_roomSize.x + trimDepth, height, trimDepth), + _roomSize.x + trimDepth, false, southOpening, height, color, mat, container); + } + + // East (extends along Z, centered on wall surface) + if (skip == null || !skip.Contains(WallSide.East)) + { + CreateMoldingSegments("BaseMolding_East", + new Vector3(_roomSize.x, height / 2f, halfDepth), + new Vector3(trimDepth, height, _roomSize.z - trimDepth), + _roomSize.z - trimDepth, true, eastOpening, height, color, mat, container); + } + + // West (extends along Z, centered on wall surface) + if (skip == null || !skip.Contains(WallSide.West)) + { + CreateMoldingSegments("BaseMolding_West", + new Vector3(0f, height / 2f, halfDepth), + new Vector3(trimDepth, height, _roomSize.z - trimDepth), + _roomSize.z - trimDepth, true, westOpening, height, color, mat, container); } return container; @@ -314,13 +551,15 @@ public GameObject AddFloor(float thickness = 0.1f) /// /// Add ceiling to the room. + /// The ceiling's top surface is flush with the top of the walls (_roomSize.y), + /// so roof slabs placed at _roomSize.y sit directly on top with no overlap. /// /// Ceiling thickness in meters /// The ceiling GameObject public GameObject AddCeiling(float thickness = 0.1f) { GameObject ceiling = PrimitiveBuilder.CreateBox("Ceiling", - new Vector3(_roomSize.x / 2f, _roomSize.y + thickness / 2f, _roomSize.z / 2f), + new Vector3(_roomSize.x / 2f, _roomSize.y - thickness / 2f, _roomSize.z / 2f), new Vector3(_roomSize.x, thickness, _roomSize.z), _palette.CeilingColor, _parent); @@ -337,6 +576,486 @@ public GameObject AddCeiling(float thickness = 0.1f) #region Private Methods + private GameObject AddSolidStairs( + WallSide wall, + float foundationHeight, + float maxStepHeight, + float width, + float stepDepth, + Color? color, + Material? material, + bool flushWithFloor = false, + float lateralOffset = 0f) + { + GameObject container = BuildingUtilities.CreateFolder(Constants.Spatial.StairsFolderName, _parent); + Color stepColor = color ?? _palette.FloorColor; + int count = Mathf.Max(2, Mathf.CeilToInt(foundationHeight / maxStepHeight)); + float stepRise = foundationHeight / count; + + // Default: floor acts as the final step, so we generate count-1 visible steps. + // Topmost step surface is at Y = -stepRise (one step below floor). + // flushWithFloor: topmost step surface is at Y = 0 (flush with floor level). + int visibleSteps = flushWithFloor ? count : count - 1; + + // Push steps outward past the foundation edge + // Uses clearance set by AddFoundation (padding + expand), or 0.1m default + float clearanceX = _foundationClearanceX > 0f ? _foundationClearanceX : 0.1f; + float clearanceZ = _foundationClearanceZ > 0f ? _foundationClearanceZ : 0.1f; + + for (int i = 0; i < visibleSteps; i++) + { + float height = (i + 1) * stepRise; + float yCenter = -foundationHeight + height / 2f; + bool isNorthSouth = wall == WallSide.North || wall == WallSide.South; + float clearance = isNorthSouth ? clearanceZ : clearanceX; + float perpOffset = (visibleSteps - 1 - i) * stepDepth + stepDepth / 2f + clearance; + + Vector3 position; + Vector3 size; + + switch (wall) + { + case WallSide.North: + position = new Vector3(_roomSize.x / 2f + lateralOffset, yCenter, _roomSize.z + perpOffset); + size = new Vector3(width, height, stepDepth); + break; + case WallSide.South: + position = new Vector3(_roomSize.x / 2f + lateralOffset, yCenter, -perpOffset); + size = new Vector3(width, height, stepDepth); + break; + case WallSide.East: + position = new Vector3(_roomSize.x + perpOffset, yCenter, _roomSize.z / 2f + lateralOffset); + size = new Vector3(stepDepth, height, width); + break; + case WallSide.West: + position = new Vector3(-perpOffset, yCenter, _roomSize.z / 2f + lateralOffset); + size = new Vector3(stepDepth, height, width); + break; + default: + continue; + } + + GameObject step = PrimitiveBuilder.CreateBox( + $"Step{i + 1}", position, size, stepColor, container.transform); + + Material? mat = material ?? _palette.FloorMaterial; + if (mat != null) + { + ApplyMaterial(step, mat); + } + } + + return container; + } + + private GameObject AddClosedRiserStairs(WallSide wall, float foundationHeight, float gap, float lateralOffset = 0f) + { + GameObject container = BuildingUtilities.CreateFolder("Stairs_ClosedRiser", _parent); + + // Tread planks and riser faces use separate materials for two-tone look + Material? treadMaterial = MaterialPresets.FindExistingMaterial(Constants.Materials.TreadWoodName); + Material? riserMaterial = MaterialPresets.FindExistingMaterial(Constants.Materials.RiserWoodName); + + Color riserColor = new Color(0.85f, 0.80f, 0.72f); + Color treadColor = new Color(0.45f, 0.35f, 0.25f); + + float width = 2.5f; + float stepDepth = Constants.Spatial.DefaultStepDepth; + float treadThickness = 0.05f; + int count = Mathf.Max(2, Mathf.CeilToInt(foundationHeight / Constants.Spatial.DefaultMaxStepHeight)); + float stepRise = foundationHeight / count; + // ClosedRiser: top step is flush with floor + int visibleSteps = count; + + float clearanceX = _foundationClearanceX > 0f ? _foundationClearanceX : 0.1f; + float clearanceZ = _foundationClearanceZ > 0f ? _foundationClearanceZ : 0.1f; + + for (int i = 0; i < visibleSteps; i++) + { + float height = (i + 1) * stepRise; + float yCenter = -foundationHeight + height / 2f - gap; + bool isNorthSouth = wall == WallSide.North || wall == WallSide.South; + float clearance = isNorthSouth ? clearanceZ : clearanceX; + float perpOffset = (visibleSteps - 1 - i) * stepDepth + stepDepth / 2f + clearance; + + // Tread sits ON TOP of riser (not embedded) to avoid z-fighting + float treadY = -foundationHeight + height + treadThickness / 2f - gap; + + Vector3 riserPos, riserSize, treadPos, treadSize; + + // Tread overhang: slight lip past riser face + float treadOverhangDepth = 0.04f; // 4cm total depth overhang (2cm front + back) + float treadOverhangWidth = 0.06f; // 6cm total width overhang (3cm per side) + + switch (wall) + { + case WallSide.North: + riserPos = new Vector3(_roomSize.x / 2f + lateralOffset, yCenter, _roomSize.z + perpOffset); + riserSize = new Vector3(width, height, stepDepth); + treadPos = new Vector3(_roomSize.x / 2f + lateralOffset, treadY, _roomSize.z + perpOffset); + treadSize = new Vector3(width + treadOverhangWidth, treadThickness, stepDepth + treadOverhangDepth); + break; + case WallSide.South: + riserPos = new Vector3(_roomSize.x / 2f + lateralOffset, yCenter, -perpOffset); + riserSize = new Vector3(width, height, stepDepth); + treadPos = new Vector3(_roomSize.x / 2f + lateralOffset, treadY, -perpOffset); + treadSize = new Vector3(width + treadOverhangWidth, treadThickness, stepDepth + treadOverhangDepth); + break; + case WallSide.East: + riserPos = new Vector3(_roomSize.x + perpOffset, yCenter, _roomSize.z / 2f + lateralOffset); + riserSize = new Vector3(stepDepth, height, width); + treadPos = new Vector3(_roomSize.x + perpOffset, treadY, _roomSize.z / 2f + lateralOffset); + treadSize = new Vector3(stepDepth + treadOverhangDepth, treadThickness, width + treadOverhangWidth); + break; + case WallSide.West: + riserPos = new Vector3(-perpOffset, yCenter, _roomSize.z / 2f + lateralOffset); + riserSize = new Vector3(stepDepth, height, width); + treadPos = new Vector3(-perpOffset, treadY, _roomSize.z / 2f + lateralOffset); + treadSize = new Vector3(stepDepth + treadOverhangDepth, treadThickness, width + treadOverhangWidth); + break; + default: + continue; + } + + // Riser body (light brown wood) + GameObject riser = PrimitiveBuilder.CreateBox( + $"Riser{i + 1}", riserPos, riserSize, riserColor, container.transform); + if (riserMaterial != null) ApplyMaterial(riser, riserMaterial); + + // Tread plank on top (dark wood, slight overhang) + GameObject tread = PrimitiveBuilder.CreateBox( + $"Tread{i + 1}", treadPos, treadSize, treadColor, container.transform); + if (treadMaterial != null) ApplyMaterial(tread, treadMaterial); + } + + DebugLog.Info($"[DecorBuilder] ClosedRiser stairs: {visibleSteps} steps, gap={gap:F2}, treadMat={treadMaterial?.name ?? "fallback"}, riserMat={riserMaterial?.name ?? "fallback"}"); + return container; + } + + private GameObject AddOpenStringerStairs(WallSide wall, float foundationHeight, float gap, float lateralOffset = 0f) + { + GameObject container = BuildingUtilities.CreateFolder("Stairs_OpenStringer", _parent); + + // Separate materials for treads and stringer beams + Material? strinTreadMaterial = MaterialPresets.FindExistingMaterial(Constants.Materials.StringerTreadWoodName); + Color woodColor = new Color(0.55f, 0.45f, 0.35f); + Material? strinBeamMaterial = MaterialPresets.FindExistingMaterial(Constants.Materials.StringerBeamWoodName); + + float width = 3.0f; + float stepDepth = Constants.Spatial.DefaultStepDepth; + float treadThickness = 0.07f; + float beamWidth = 0.25f; // Stringer beam cross-section width (4x4 post style) + float beamHeight = 0.25f; // Stringer beam cross-section height (4x4 post style) + int count = Mathf.Max(2, Mathf.CeilToInt(foundationHeight / Constants.Spatial.DefaultMaxStepHeight)); + float stepRise = foundationHeight / count; + // Top step sits one rise below floor level (the foundation/floor is the final surface) + int visibleSteps = count - 1; + + float clearanceX = _foundationClearanceX > 0f ? _foundationClearanceX : 0.1f; + float clearanceZ = _foundationClearanceZ > 0f ? _foundationClearanceZ : 0.1f; + bool isNorthSouth = wall == WallSide.North || wall == WallSide.South; + float clearance = isNorthSouth ? clearanceZ : clearanceX; + + // --- Treads (flat planks, no solid risers) --- + float treadOverhang = 0.20f; // Treads extend past the stringer beams on each side + for (int i = 0; i < visibleSteps; i++) + { + float stepTop = -foundationHeight + (i + 1) * stepRise - gap; + float treadY = stepTop + treadThickness / 2f; + float perpOffset = (visibleSteps - 1 - i) * stepDepth + stepDepth / 2f + clearance; + + Vector3 treadPos, treadSize; + switch (wall) + { + case WallSide.North: + treadPos = new Vector3(_roomSize.x / 2f + lateralOffset, treadY, _roomSize.z + perpOffset); + treadSize = new Vector3(width + treadOverhang, treadThickness, stepDepth + 0.04f); + break; + case WallSide.South: + treadPos = new Vector3(_roomSize.x / 2f + lateralOffset, treadY, -perpOffset); + treadSize = new Vector3(width + treadOverhang, treadThickness, stepDepth + 0.04f); + break; + case WallSide.East: + treadPos = new Vector3(_roomSize.x + perpOffset, treadY, _roomSize.z / 2f + lateralOffset); + treadSize = new Vector3(stepDepth + 0.04f, treadThickness, width + treadOverhang); + break; + case WallSide.West: + treadPos = new Vector3(-perpOffset, treadY, _roomSize.z / 2f + lateralOffset); + treadSize = new Vector3(stepDepth + 0.04f, treadThickness, width + treadOverhang); + break; + default: + continue; + } + + GameObject tread = PrimitiveBuilder.CreateBox( + $"Tread{i + 1}", treadPos, treadSize, woodColor, container.transform); + if (strinTreadMaterial != null) ApplyMaterial(tread, strinTreadMaterial); + } + + // --- Stringer beams (diagonal supports on left and right sides) --- + // Top end embeds into the foundation (at floor level), bottom end sinks into the ground. + float bottomExtend = 0.30f; + + // Stringer runs from inside the foundation down to the ground + float stringerBottomY = -foundationHeight - gap; + // Push the top anchor well below floor level and behind the wall so the beam is fully hidden + float stringerTopY = -gap + stepRise; // One step above floor (hidden inside foundation) + float topPerp = -beamHeight; // Behind the wall surface (into foundation) + float bottomPerp = (visibleSteps - 1) * stepDepth + stepDepth / 2f + clearance; + float stringerRun = bottomPerp - topPerp; + float stringerRise = stringerTopY - stringerBottomY; + + // Stringer angle + float angleRad = Mathf.Atan2(stringerRise, stringerRun); + float angleDeg = angleRad * Mathf.Rad2Deg; + + float baseLength = Mathf.Sqrt(stringerRise * stringerRise + stringerRun * stringerRun); + + // The beam's actual endpoints along the slope: + // - Top end: pull back 2 step rises from the anchor to hide inside foundation + float topTrim = stepRise * 3.0f; + // - Bottom end: extend into the ground + float stringerLength = baseLength + bottomExtend - topTrim; + + // Compute the actual top and bottom endpoints of the trimmed/extended beam + // Direction along slope (from top toward bottom): perp increases, Y decreases + float dirPerp = Mathf.Cos(angleRad); + float dirY = Mathf.Sin(angleRad); + + // Actual top end (trimmed): move topTrim along slope from the original top anchor + float actualTopPerp = topPerp + topTrim * dirPerp; + float actualTopY = stringerTopY - topTrim * dirY; + // Actual bottom end (extended): move bottomExtend along slope past original bottom + float actualBottomPerp = bottomPerp + bottomExtend * dirPerp; + float actualBottomY = stringerBottomY - bottomExtend * dirY; + + // Midpoint is simply the average of the actual endpoints + float midPerp = (actualTopPerp + actualBottomPerp) / 2f; + float midY = (actualTopY + actualBottomY) / 2f; + + // Left/right offset: beams sit inside the tread width, treads overhang past them + float sideOffset = width / 2f - beamWidth; + + for (int side = 0; side < 2; side++) + { + float signedOffset = (side == 0) ? -sideOffset : sideOffset; + + Vector3 beamPos; + // Use Y as the long axis so the wood grain texture runs along the beam length + Vector3 beamSize = new Vector3(beamWidth, stringerLength, beamHeight); + Quaternion beamRot; + + switch (wall) + { + case WallSide.North: + beamPos = new Vector3(_roomSize.x / 2f + lateralOffset + signedOffset, midY, _roomSize.z + midPerp); + beamRot = Quaternion.Euler(angleDeg + 90f, 0f, 0f); + break; + case WallSide.South: + beamPos = new Vector3(_roomSize.x / 2f + lateralOffset + signedOffset, midY, -midPerp); + beamRot = Quaternion.Euler(-angleDeg - 90f, 0f, 0f); + break; + case WallSide.East: + beamPos = new Vector3(_roomSize.x + midPerp, midY, _roomSize.z / 2f + lateralOffset + signedOffset); + beamSize = new Vector3(beamHeight, stringerLength, beamWidth); + beamRot = Quaternion.Euler(0f, 0f, -angleDeg - 90f); + break; + case WallSide.West: + beamPos = new Vector3(-midPerp, midY, _roomSize.z / 2f + lateralOffset + signedOffset); + beamSize = new Vector3(beamHeight, stringerLength, beamWidth); + beamRot = Quaternion.Euler(0f, 0f, angleDeg + 90f); + break; + default: + continue; + } + + GameObject beam = PrimitiveBuilder.CreateBox( + $"Stringer{side + 1}", beamPos, beamSize, woodColor, container.transform); + beam.transform.rotation = _parent.rotation * beamRot; + if (strinBeamMaterial != null) ApplyMaterial(beam, strinBeamMaterial); + else if (strinTreadMaterial != null) ApplyMaterial(beam, strinTreadMaterial); + } + + DebugLog.Info($"[DecorBuilder] OpenStringer stairs: {visibleSteps} treads + 2 stringers, gap={gap:F2}, treadMat={strinTreadMaterial?.name ?? "fallback"}, beamMat={strinBeamMaterial?.name ?? "fallback"}"); + return container; + } + + /// + /// Creates a molding strip, splitting it into two segments if a door opening is present. + /// + private void CreateMoldingSegments( + string name, Vector3 center, Vector3 size, float wallLength, + bool isZAxis, WallOpening? opening, float moldingHeight, + Color color, Material? material, GameObject container) + { + bool hasDoorGap = opening != null + && opening.Type == WallOpeningType.Door + && opening.BottomOffset < moldingHeight; + + if (!hasDoorGap) + { + GameObject strip = PrimitiveBuilder.CreateBox(name, center, size, color, container.transform); + if (material != null) ApplyMaterial(strip, material); + return; + } + + float doorOffset = opening!.Offset; + + // Asymmetric segment lengths around the offset door + float leftLength = (wallLength - opening.Width) / 2f + doorOffset; + float rightLength = (wallLength - opening.Width) / 2f - doorOffset; + + if (isZAxis) + { + // Strip runs along Z (East/West walls) + if (leftLength > 0f) + { + Vector3 segSize = new Vector3(size.x, size.y, leftLength); + Vector3 lowZ = center + Vector3.back * (wallLength / 2f - leftLength / 2f); + GameObject left = PrimitiveBuilder.CreateBox($"{name}_L", lowZ, segSize, color, container.transform); + if (material != null) ApplyMaterial(left, material); + } + if (rightLength > 0f) + { + Vector3 segSize = new Vector3(size.x, size.y, rightLength); + Vector3 highZ = center + Vector3.forward * (wallLength / 2f - rightLength / 2f); + GameObject right = PrimitiveBuilder.CreateBox($"{name}_R", highZ, segSize, color, container.transform); + if (material != null) ApplyMaterial(right, material); + } + } + else + { + // Strip runs along X (North/South walls) + if (leftLength > 0f) + { + Vector3 segSize = new Vector3(leftLength, size.y, size.z); + Vector3 lowX = center + Vector3.left * (wallLength / 2f - leftLength / 2f); + GameObject left = PrimitiveBuilder.CreateBox($"{name}_L", lowX, segSize, color, container.transform); + if (material != null) ApplyMaterial(left, material); + } + if (rightLength > 0f) + { + Vector3 segSize = new Vector3(rightLength, size.y, size.z); + Vector3 highX = center + Vector3.right * (wallLength / 2f - rightLength / 2f); + GameObject right = PrimitiveBuilder.CreateBox($"{name}_R", highX, segSize, color, container.transform); + if (material != null) ApplyMaterial(right, material); + } + } + } + + /// + /// Shrinks wall segments around a door opening to make room for the door frame. + /// The side segments pull back laterally and the top segment pulls up, + /// leaving gaps that the door frame casings fill exactly. + /// + private void InsetDoorWallSegments(string wallName, float inset, bool isVertical) + { + Transform? wallContainer = _parent.Find($"Walls/{wallName}"); + if (wallContainer == null) return; + + InsetDoorWallSegments(wallContainer, wallName, inset, isVertical); + } + + private static void InsetDoorWallSegments(Transform wallContainer, string childPrefix, float inset, bool isVertical) + { + Transform? left = wallContainer.Find($"{childPrefix}_Left"); + Transform? right = wallContainer.Find($"{childPrefix}_Right"); + Transform? top = wallContainer.Find($"{childPrefix}_Top"); + + // Shrink side segments away from the door opening + if (left != null) + { + Vector3 scale = left.localScale; + Vector3 pos = left.localPosition; + if (isVertical) { scale.z -= inset; pos.z -= inset / 2f; } + else { scale.x -= inset; pos.x -= inset / 2f; } + left.localScale = scale; + left.localPosition = pos; + } + + if (right != null) + { + Vector3 scale = right.localScale; + Vector3 pos = right.localPosition; + if (isVertical) { scale.z -= inset; pos.z += inset / 2f; } + else { scale.x -= inset; pos.x += inset / 2f; } + right.localScale = scale; + right.localPosition = pos; + } + + // Shrink top segment upward and widen to cover gaps left by side insets + if (top != null) + { + Vector3 scale = top.localScale; + Vector3 pos = top.localPosition; + scale.y -= inset; + pos.y += inset / 2f; + if (isVertical) scale.z += 2f * inset; + else scale.x += 2f * inset; + top.localScale = scale; + top.localPosition = pos; + } + } + + private void CreateDoorFrame( + string name, Vector3 wallCenter, float wallHeight, + WallOpening opening, bool isVertical, float frameWidth, + float trimDepth, Color color, Material? material, GameObject container) + { + CreateDoorFrame(name, wallCenter, wallHeight, + opening.Width, opening.Height, opening.Offset, + isVertical, frameWidth, trimDepth, color, material, container); + } + + private void CreateDoorFrame( + string name, Vector3 wallCenter, float wallHeight, + float doorWidth, float doorHeight, float doorOffset, + bool isVertical, float frameWidth, float trimDepth, + Color color, Material? material, GameObject container) + { + // Shift frame to match door offset + Vector3 doorShift = isVertical + ? new Vector3(0f, 0f, doorOffset) + : new Vector3(doorOffset, 0f, 0f); + Vector3 doorCenter = wallCenter + doorShift; + + float jambYOffset = -(wallHeight - doorHeight) / 2f; + float sideOffset = doorWidth / 2f + frameWidth / 2f; + + // Jamb sizes + Vector3 jambSize = isVertical + ? new Vector3(trimDepth, doorHeight, frameWidth) + : new Vector3(frameWidth, doorHeight, trimDepth); + + // Left jamb + Vector3 leftJambPos = doorCenter + (isVertical + ? new Vector3(0f, jambYOffset, -sideOffset) + : new Vector3(-sideOffset, jambYOffset, 0f)); + GameObject leftJamb = PrimitiveBuilder.CreateBox($"{name}_Left", leftJambPos, jambSize, color, container.transform); + + // Right jamb + Vector3 rightJambPos = doorCenter + (isVertical + ? new Vector3(0f, jambYOffset, sideOffset) + : new Vector3(sideOffset, jambYOffset, 0f)); + GameObject rightJamb = PrimitiveBuilder.CreateBox($"{name}_Right", rightJambPos, jambSize, color, container.transform); + + // Header (spans across both jambs) + float headerWidth = doorWidth + 2f * frameWidth; + float headerYOffset = -(wallHeight / 2f) + doorHeight + frameWidth / 2f; + Vector3 headerSize = isVertical + ? new Vector3(trimDepth, frameWidth, headerWidth) + : new Vector3(headerWidth, frameWidth, trimDepth); + GameObject header = PrimitiveBuilder.CreateBox($"{name}_Top", + doorCenter + new Vector3(0f, headerYOffset, 0f), headerSize, color, container.transform); + + if (material != null) + { + ApplyMaterial(leftJamb, material); + ApplyMaterial(rightJamb, material); + ApplyMaterial(header, material); + } + } + private static void ApplyMaterial(GameObject obj, Material material) { Renderer r = obj.GetComponent(); diff --git a/Building/Structural/InteriorWallBuilder.cs b/Building/Structural/InteriorWallBuilder.cs new file mode 100644 index 0000000..5be7efd --- /dev/null +++ b/Building/Structural/InteriorWallBuilder.cs @@ -0,0 +1,353 @@ +using System.Collections.Generic; +using S1MAPI.Building.Config; +using S1MAPI.Extensions; +using S1MAPI.ProceduralMesh; +using S1MAPI.Utils; +using UnityEngine; + +namespace S1MAPI.Building.Structural +{ + /// + /// Specifies the axis an interior wall runs along. + /// + public enum InteriorWallAxis + { + /// Wall runs along X axis; is the Z coordinate. + X, + /// Wall runs along Z axis; is the X coordinate. + Z + } + + /// + /// Defines an interior wall placement: axis, position, span, and optional opening. + /// + public sealed class InteriorWallDefinition + { + /// Which axis the wall runs along. + public InteriorWallAxis Axis { get; } + + /// + /// Position on the perpendicular axis (Z coordinate for , + /// X coordinate for ). + /// + public float Position { get; } + + /// Start coordinate along the wall's own axis. + public float From { get; } + + /// End coordinate along the wall's own axis. + public float To { get; } + + /// Optional opening (door or window) centered in the wall. + public WallOpening? Opening { get; } + + /// Optional wall color override. + public Color? Color { get; } + + /// Optional wall material override. + public Material? Material { get; } + + /// + /// Create an interior wall definition. + /// + /// Axis the wall runs along + /// Position on the perpendicular axis + /// Start coordinate along the wall axis + /// End coordinate along the wall axis + /// Optional wall opening + /// Optional color override + /// Optional material override + public InteriorWallDefinition( + InteriorWallAxis axis, float position, float from, float to, + WallOpening? opening = null, Color? color = null, Material? material = null) + { + Axis = axis; + Position = position; + From = from; + To = to; + Opening = opening; + Color = color; + Material = material; + } + } + + /// + /// Records the position and dimensions of a doorway in an interior wall. + /// Used for future NavMesh link generation. + /// + public sealed class DoorwayInfo + { + /// Center of the doorway in local building coordinates. + public Vector3 Center { get; } + + /// Width of the doorway opening in meters. + public float Width { get; } + + /// Height of the doorway opening in meters. + public float Height { get; } + + /// + /// True if the wall's normal faces along Z (i.e., the wall runs along X). + /// + public bool FacesAlongZ { get; } + + /// Thickness of the wall containing this doorway. + public float WallThickness { get; } + + /// Wall container with Left/Right/Top segment children (used for frame insetting). + internal GameObject? WallContainer { get; set; } + + /// + /// Create a doorway info record. + /// + public DoorwayInfo(Vector3 center, float width, float height, bool facesAlongZ, float wallThickness) + { + Center = center; + Width = width; + Height = height; + FacesAlongZ = facesAlongZ; + WallThickness = wallThickness; + } + } + + /// + /// Builds interior walls with optional door openings. + /// Tracks doorway positions for future NavMesh link generation. + /// + public sealed class InteriorWallBuilder + { + #region Fields + + private readonly Transform _parent; + private readonly Vector3 _roomSize; + private readonly float _wallThickness; + private readonly BuildingPalette _palette; + private readonly int _layer; + private GameObject? _container; + private readonly List _doorways = new List(); + + #endregion + + #region Constructor + + /// + /// Create a new interior wall builder. + /// + /// Parent transform for interior walls + /// Room dimensions (width, height, depth) + /// Wall thickness in meters + /// Material and color palette + /// Physics layer for interior wall GameObjects. -1 (default) leaves them on the default layer. + public InteriorWallBuilder(Transform parent, Vector3 roomSize, float wallThickness, BuildingPalette palette, int layer = -1) + { + _parent = parent; + _roomSize = roomSize; + _wallThickness = wallThickness; + _palette = palette; + _layer = layer; + } + + #endregion + + #region Public API + + /// + /// Recorded doorway positions from all interior walls built so far. + /// + public IReadOnlyList Doorways => + _doorways; + + /// + /// Build an interior wall from a definition. + /// + /// Interior wall definition + /// The wall GameObject, or null if validation fails + public GameObject? BuildInteriorWall(InteriorWallDefinition def) + { + if (!Validate(def)) + return null; + + _container ??= BuildingUtilities.CreateFolder("InteriorWalls", _parent); + + float wallLength = def.To - def.From; + Color wallColor = def.Color ?? _palette.WallColor; + Material? wallMaterial = def.Material ?? _palette.WallMaterial; + + var (center, size, isVertical) = GetWallTransform(def); + + string wallName = $"InteriorWall_{def.Axis}_{def.Position:F1}"; + + GameObject? wall; + + if (def.Opening == null || def.Opening.Type == WallOpeningType.None) + { + wall = CreateSolidWall(wallName, center, size, wallColor, wallMaterial); + } + else if (def.Opening.Type == WallOpeningType.Door) + { + wall = CreateWallWithDoor(wallName, center, size, def.Opening, isVertical, wallColor, wallMaterial); + } + else + { + // Fallback: solid wall for unsupported opening types + wall = CreateSolidWall(wallName, center, size, wallColor, wallMaterial); + } + + if (wall != null && _layer >= 0) + { + wall.SetLayerRecursively(_layer); + } + + return wall; + } + + #endregion + + #region Private Methods — Validation + + private bool Validate(InteriorWallDefinition def) + { + float axisMax = def.Axis == InteriorWallAxis.X ? _roomSize.x : _roomSize.z; + float perpMax = def.Axis == InteriorWallAxis.X ? _roomSize.z : _roomSize.x; + + if (def.Position < 0f || def.Position > perpMax) + { + DebugLog.Warning($"[InteriorWallBuilder] Position {def.Position} out of room bounds (0–{perpMax}). Skipping wall."); + return false; + } + + if (def.From >= def.To) + { + DebugLog.Warning($"[InteriorWallBuilder] From ({def.From}) >= To ({def.To}). Skipping wall."); + return false; + } + + float wallLength = def.To - def.From; + if (wallLength < Constants.InteriorWall.MinWallLength) + { + DebugLog.Warning($"[InteriorWallBuilder] Wall length {wallLength:F2}m < minimum {Constants.InteriorWall.MinWallLength}m. Skipping wall."); + return false; + } + + if (def.Opening != null && def.Opening.Type == WallOpeningType.Door && def.Opening.Width >= wallLength) + { + DebugLog.Warning($"[InteriorWallBuilder] Door width ({def.Opening.Width}) >= wall length ({wallLength:F2}). Skipping wall."); + return false; + } + + return true; + } + + #endregion + + #region Private Methods — Geometry + + private (Vector3 center, Vector3 size, bool isVertical) GetWallTransform(InteriorWallDefinition def) + { + float wallLength = def.To - def.From; + float midAlong = (def.From + def.To) / 2f; + + // Axis.X: wall runs along X, position is Z, normal faces Z → isVertical = false + // Axis.Z: wall runs along Z, position is X, normal faces X → isVertical = true + if (def.Axis == InteriorWallAxis.X) + { + Vector3 center = new Vector3(midAlong, _roomSize.y / 2f, def.Position); + Vector3 size = new Vector3(wallLength, _roomSize.y, _wallThickness); + return (center, size, false); + } + else + { + Vector3 center = new Vector3(def.Position, _roomSize.y / 2f, midAlong); + Vector3 size = new Vector3(_wallThickness, _roomSize.y, wallLength); + return (center, size, true); + } + } + + private GameObject CreateSolidWall(string name, Vector3 center, Vector3 size, Color color, Material? material) + { + GameObject wall = PrimitiveBuilder.CreateBox(name, center, size, color, _container!.transform); + ApplyMaterial(wall, material); + return wall; + } + + private GameObject CreateWallWithDoor( + string name, Vector3 wallCenter, Vector3 wallSize, + WallOpening opening, bool isVertical, + Color color, Material? material) + { + GameObject container = BuildingUtilities.CreateFolder(name, _container!.transform); + + float wallWidth = isVertical ? wallSize.z : wallSize.x; + float wallHeight = wallSize.y; + float doorWidth = opening.Width; + float doorHeight = opening.Height; + float offset = opening.Offset; + + // Positive offset shifts door toward positive axis (right/forward) + // Left (negative direction) gets bigger, right gets smaller + float leftWidth = (wallWidth - doorWidth) / 2f + offset; + float rightWidth = (wallWidth - doorWidth) / 2f - offset; + + // Door center shifted by offset along the wall axis + Vector3 doorShift = isVertical ? Vector3.forward * offset : Vector3.right * offset; + Vector3 shiftedCenter = wallCenter + doorShift; + + // Left segment + if (leftWidth > Constants.InteriorWall.SegmentThreshold) + { + float leftCenter = doorWidth / 2f + leftWidth / 2f; + Vector3 leftOffset = isVertical ? Vector3.back * leftCenter : Vector3.left * leftCenter; + Vector3 leftSize = isVertical + ? new Vector3(_wallThickness, wallHeight, leftWidth) + : new Vector3(leftWidth, wallHeight, _wallThickness); + GameObject left = PrimitiveBuilder.CreateBox($"{name}_Left", shiftedCenter + leftOffset, leftSize, color, container.transform); + ApplyMaterial(left, material); + } + + // Right segment + if (rightWidth > Constants.InteriorWall.SegmentThreshold) + { + float rightCenter = doorWidth / 2f + rightWidth / 2f; + Vector3 rightOffset = isVertical ? Vector3.forward * rightCenter : Vector3.right * rightCenter; + Vector3 rightSize = isVertical + ? new Vector3(_wallThickness, wallHeight, rightWidth) + : new Vector3(rightWidth, wallHeight, _wallThickness); + GameObject right = PrimitiveBuilder.CreateBox($"{name}_Right", shiftedCenter + rightOffset, rightSize, color, container.transform); + ApplyMaterial(right, material); + } + + // Top segment (wall above door) + float topHeight = wallHeight - doorHeight; + if (topHeight > Constants.InteriorWall.SegmentThreshold) + { + Vector3 topSize = isVertical + ? new Vector3(_wallThickness, topHeight, doorWidth) + : new Vector3(doorWidth, topHeight, _wallThickness); + float topCenterY = wallHeight / 2f - topHeight / 2f; + Vector3 topOffset = Vector3.up * topCenterY; + GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", shiftedCenter + topOffset, topSize, color, container.transform); + ApplyMaterial(top, material); + } + + // Record doorway for future NavMesh (use shifted position) + bool facesAlongZ = !isVertical; // Axis.X wall faces Z + Vector3 doorCenter = new Vector3(shiftedCenter.x, doorHeight / 2f, shiftedCenter.z); + var doorwayInfo = new DoorwayInfo(doorCenter, doorWidth, doorHeight, facesAlongZ, _wallThickness); + doorwayInfo.WallContainer = container; + _doorways.Add(doorwayInfo); + + return container; + } + + private void ApplyMaterial(GameObject go, Material? material) + { + if (material != null) + { + Renderer r = go.GetComponent(); + if (r != null) r.material = material; + } + } + + #endregion + } +} diff --git a/Building/Structural/RoofBuilder.cs b/Building/Structural/RoofBuilder.cs new file mode 100644 index 0000000..c3f7328 --- /dev/null +++ b/Building/Structural/RoofBuilder.cs @@ -0,0 +1,474 @@ +using S1MAPI.Building.Config; +using S1MAPI.Core; +using S1MAPI.ProceduralMesh; +using S1MAPI.Utils; +using UnityEngine; + +namespace S1MAPI.Building.Structural +{ + /// + /// Preset sizing for parapet roofs. + /// + public enum ParapetPreset + { + /// + /// Tall thick parapet wall with prominent cap and overhang. + /// Creates a visible roof recess for a substantial commercial look. + /// + Deep, + + /// + /// Shorter parapet wall with a subtle cap and slight overhang. + /// Cleaner, less imposing appearance. + /// + Shallow + } + + /// + /// Creates roof structures including parapet walls and hip roofs. + /// Extracted from DecorBuilder for SRP compliance. + /// + public sealed class RoofBuilder + { + #region Fields + + private readonly Transform _parent; + private readonly Vector3 _roomSize; + private readonly float _wallThickness; + private readonly BuildingPalette _palette; + + #endregion + + #region Constructor + + /// + /// Create a new roof builder. + /// + /// Parent transform for roof elements + /// Room dimensions + /// Wall thickness in meters + /// Material and color palette + public RoofBuilder(Transform parent, Vector3 roomSize, float wallThickness, BuildingPalette palette) + { + _parent = parent; + _roomSize = roomSize; + _wallThickness = wallThickness; + _palette = palette; + } + + #endregion + + #region Public API + + /// + /// Add a parapet roof (raised wall and cap above the roofline). + /// The cap extends past the parapet wall by the overhang amount, creating a ledge. + /// Includes a thin roof slab at ceiling height. For interior ceilings, use AddCeiling() + /// separately — the ceiling sits just below the roof slab with no overlap. + /// + /// Sizing preset (Deep or Shallow). Overridden by explicit dimensions. + /// Height of the parapet wall in meters. Null uses preset default. + /// Depth of the parapet wall. Null uses wall thickness + padding. + /// Height of the cap on top of the parapet. Null uses preset default. + /// How far the cap extends past the parapet wall on each side. Null uses preset default. + /// Color override for the parapet wall. + /// Material override for the parapet wall. + /// Color override for the cap. + /// Material override for the cap. + /// The roof container GameObject. + public GameObject AddParapetRoof( + ParapetPreset preset = ParapetPreset.Deep, + float? parapetHeight = null, + float? parapetDepth = null, + float? capHeight = null, + float? capOverhang = null, + Color? parapetColor = null, + Material? parapetMaterial = null, + Color? capColor = null, + Material? capMaterial = null) + { + // Resolve preset defaults + float pHeight = parapetHeight ?? (preset == ParapetPreset.Deep + ? Constants.Roof.DeepParapetHeight + : Constants.Roof.ShallowParapetHeight); + + float pDepth = parapetDepth ?? (_wallThickness + Constants.Roof.ParapetDepthPadding); + + float cHeight = capHeight ?? (preset == ParapetPreset.Deep + ? Constants.Roof.DeepCapHeight + : Constants.Roof.ShallowCapHeight); + + float cOverhang = capOverhang ?? (preset == ParapetPreset.Deep + ? Constants.Roof.DeepCapOverhang + : Constants.Roof.ShallowCapOverhang); + + // Cap extends past parapet on exterior side only + float capDepthExt = pDepth + cOverhang; + float capCornerWidth = pDepth + 2f * cOverhang; + float capOffset = cOverhang / 2f; + + GameObject container = BuildingUtilities.CreateFolder("ParapetRoof", _parent); + + // --- Parapet wall (4 strips above ceiling) --- + Color wallColor = parapetColor ?? _palette.TrimColor; + float wallY = _roomSize.y + pHeight / 2f; + + GameObject parapetN = PrimitiveBuilder.CreateBox("Parapet_North", + new Vector3(_roomSize.x / 2f, wallY, _roomSize.z), + new Vector3(_roomSize.x + pDepth, pHeight, pDepth), + wallColor, container.transform); + + GameObject parapetS = PrimitiveBuilder.CreateBox("Parapet_South", + new Vector3(_roomSize.x / 2f, wallY, 0f), + new Vector3(_roomSize.x + pDepth, pHeight, pDepth), + wallColor, container.transform); + + GameObject parapetE = PrimitiveBuilder.CreateBox("Parapet_East", + new Vector3(_roomSize.x, wallY, _roomSize.z / 2f), + new Vector3(pDepth, pHeight, _roomSize.z - pDepth), + wallColor, container.transform); + + GameObject parapetW = PrimitiveBuilder.CreateBox("Parapet_West", + new Vector3(0f, wallY, _roomSize.z / 2f), + new Vector3(pDepth, pHeight, _roomSize.z - pDepth), + wallColor, container.transform); + + Material? wallMat = parapetMaterial ?? _palette.TrimMaterial; + if (wallMat != null) + { + ApplyMaterial(parapetN, wallMat); + ApplyMaterial(parapetS, wallMat); + ApplyMaterial(parapetE, wallMat); + ApplyMaterial(parapetW, wallMat); + } + + // --- Cap (4 strips on top of parapet, overhang on exterior side only) --- + Color cColor = capColor ?? _palette.AccentColor; + float capY = _roomSize.y + pHeight + cHeight / 2f; + + GameObject capN = PrimitiveBuilder.CreateBox("Cap_North", + new Vector3(_roomSize.x / 2f, capY, _roomSize.z + capOffset), + new Vector3(_roomSize.x + capCornerWidth, cHeight, capDepthExt), + cColor, container.transform); + + GameObject capS = PrimitiveBuilder.CreateBox("Cap_South", + new Vector3(_roomSize.x / 2f, capY, -capOffset), + new Vector3(_roomSize.x + capCornerWidth, cHeight, capDepthExt), + cColor, container.transform); + + GameObject capE = PrimitiveBuilder.CreateBox("Cap_East", + new Vector3(_roomSize.x + capOffset, capY, _roomSize.z / 2f), + new Vector3(capDepthExt, cHeight, _roomSize.z - pDepth), + cColor, container.transform); + + GameObject capW = PrimitiveBuilder.CreateBox("Cap_West", + new Vector3(-capOffset, capY, _roomSize.z / 2f), + new Vector3(capDepthExt, cHeight, _roomSize.z - pDepth), + cColor, container.transform); + + Material? cMat = capMaterial ?? _palette.AccentMaterial ?? _palette.TrimMaterial; + if (cMat != null) + { + ApplyMaterial(capN, cMat); + ApplyMaterial(capS, cMat); + ApplyMaterial(capE, cMat); + ApplyMaterial(capW, cMat); + } + + // --- Roof slab (thin slab at ceiling height, bottom sits at _roomSize.y) --- + Material roofSlabMat = _palette.WallMaterial + ?? MaterialPresets.Opaque(_palette.WallColor); + float roofSlabThickness = Constants.Roof.DefaultBaseSlabHeight; + PrimitiveBuilder.CreateBox("ParapetRoofSlab", + new Vector3(_roomSize.x / 2f, _roomSize.y + roofSlabThickness / 2f, _roomSize.z / 2f), + new Vector3(_roomSize.x, roofSlabThickness, _roomSize.z), + roofSlabMat.color, container.transform); + + return container; + } + + /// + /// Add a hip (four-slope) roof using custom mesh geometry. + /// All four sides slope inward to a central ridge that is shorter than the building length. + /// For square buildings, the ridge collapses to a point (pyramid roof). + /// A base slab sits at ceiling height and the slopes start from the top of the slab, + /// giving the roof visible thickness when viewed from below. + /// For interior ceilings, use AddCeiling() separately — the ceiling sits just below + /// the roof slab with no overlap. + /// + /// Height of the ridge peak above the ceiling in meters. + /// How far the roof eaves extend past the walls in meters. + /// If true, ridge runs along X axis. If false, along Z. Null auto-selects the longer axis. + /// Color for the sloped roof planes. + /// Material for the sloped roof planes. Null uses fallback color. + /// Height of the 3D base slab beneath the slopes. 0 disables the slab. + /// The roof container GameObject. + public GameObject AddHipRoof( + float ridgeHeight = Constants.Roof.DefaultRidgeHeight, + float overhang = Constants.Roof.DefaultOverhang, + bool? ridgeAlongX = null, + Color? roofColor = null, + Material? roofMaterial = null, + float baseSlabHeight = Constants.Roof.DefaultBaseSlabHeight) + { + bool alongX = ridgeAlongX ?? (_roomSize.x >= _roomSize.z); + GameObject container = BuildingUtilities.CreateFolder("HipRoof", _parent); + + Material? foundHipMat = MaterialPresets.FindExistingMaterial(Constants.Roof.RoofSlopeMaterialName); + if (foundHipMat == null) + { + DebugLog.Warning($"[RoofBuilder] Material '{Constants.Roof.RoofSlopeMaterialName}' not found. Using fallback color."); + } + + Material slopeMat = roofMaterial + ?? foundHipMat + ?? MaterialPresets.Opaque(roofColor ?? new Color( + Constants.Roof.DefaultRoofColorR, + Constants.Roof.DefaultRoofColorG, + Constants.Roof.DefaultRoofColorB)); + + float ceilingY = _roomSize.y; + float eaveY = ceilingY + baseSlabHeight; // slopes start on top of the slab + float ridgeY = ceilingY + ridgeHeight; + + // Base slab gives the roof visible thickness from below + if (baseSlabHeight > 0f) + { + Material slabMat = _palette.WallMaterial + ?? MaterialPresets.Opaque(_palette.WallColor); + CreateRoofBaseSlab(container.transform, ceilingY, baseSlabHeight, overhang, slabMat); + } + + if (alongX) + CreateHipAlongX(container.transform, eaveY, ridgeY, overhang, slopeMat); + else + CreateHipAlongZ(container.transform, eaveY, ridgeY, overhang, slopeMat); + + return container; + } + + #endregion + + #region Private Methods + + private static void CreateSlopeQuad( + string name, Transform parent, + Vector3 v0, Vector3 v1, Vector3 v2, Vector3 v3, + float uvWidth, float uvHeight, Material material) + { + new CustomMeshBuilder(name) + .AddVertices(v0, v1, v2, v3) // front face: 0-3 + .AddVertices(v0, v1, v2, v3) // back face: 4-7 + .AddQuad(0, 1, 2, 3) // front + .AddQuad(7, 6, 5, 4) // back (reversed winding) + .SetUVs( + new Vector2(0f, 0f), + new Vector2(uvWidth, 0f), + new Vector2(uvWidth, uvHeight), + new Vector2(0f, uvHeight), + new Vector2(0f, 0f), + new Vector2(uvWidth, 0f), + new Vector2(uvWidth, uvHeight), + new Vector2(0f, uvHeight)) + .SetMaterial(material) + .Build(parent); + } + + private void CreateHipAlongX( + Transform container, float ceilingY, float ridgeY, float overhang, + Material slopeMat) + { + float oh = overhang; + float rise = ridgeY - ceilingY; + float ridgeZ = _roomSize.z / 2f; + + // Ridge inset from each end — equal to half the short axis for equal pitch on all sides + float ridgeInset = _roomSize.z / 2f; + float ridgeMinX = ridgeInset; + float ridgeMaxX = _roomSize.x - ridgeInset; + + // Eave corners + Vector3 sw = new Vector3(-oh, ceilingY, -oh); + Vector3 se = new Vector3(_roomSize.x + oh, ceilingY, -oh); + Vector3 ne = new Vector3(_roomSize.x + oh, ceilingY, _roomSize.z + oh); + Vector3 nw = new Vector3(-oh, ceilingY, _roomSize.z + oh); + + float sideRun = ridgeZ + oh; + float sideSlopeLen = Mathf.Sqrt(rise * rise + sideRun * sideRun); + + if (ridgeMaxX > ridgeMinX) + { + // Standard hip: ridge line with positive length + Vector3 ridgeW = new Vector3(ridgeMinX, ridgeY, ridgeZ); + Vector3 ridgeE = new Vector3(ridgeMaxX, ridgeY, ridgeZ); + + float eaveLen = _roomSize.x + 2f * oh; + + // South slope (trapezoid) + CreateSlopeQuad("Slope_South", container, + se, sw, ridgeW, ridgeE, + eaveLen, sideSlopeLen, slopeMat); + + // North slope (trapezoid) + CreateSlopeQuad("Slope_North", container, + nw, ne, ridgeE, ridgeW, + eaveLen, sideSlopeLen, slopeMat); + + // Hip end slopes + float hipBaseLen = _roomSize.z + 2f * oh; + float hipRun = ridgeInset + oh; + float hipSlopeLen = Mathf.Sqrt(rise * rise + hipRun * hipRun); + + // East hip (triangle) + CreateSlopeTriangle("Hip_East", container, + ne, se, ridgeE, + hipBaseLen, hipSlopeLen, slopeMat); + + // West hip (triangle) + CreateSlopeTriangle("Hip_West", container, + sw, nw, ridgeW, + hipBaseLen, hipSlopeLen, slopeMat); + } + else + { + // Pyramid: ridge collapsed to a point + Vector3 apex = new Vector3(_roomSize.x / 2f, ridgeY, ridgeZ); + + float sideBaseLen = _roomSize.x + 2f * oh; + float hipBaseLen = _roomSize.z + 2f * oh; + float hipRun = _roomSize.x / 2f + oh; + float hipSlopeLen = Mathf.Sqrt(rise * rise + hipRun * hipRun); + + CreateSlopeTriangle("Slope_South", container, + se, sw, apex, sideBaseLen, sideSlopeLen, slopeMat); + CreateSlopeTriangle("Slope_North", container, + nw, ne, apex, sideBaseLen, sideSlopeLen, slopeMat); + CreateSlopeTriangle("Hip_East", container, + ne, se, apex, hipBaseLen, hipSlopeLen, slopeMat); + CreateSlopeTriangle("Hip_West", container, + sw, nw, apex, hipBaseLen, hipSlopeLen, slopeMat); + } + } + + private void CreateHipAlongZ( + Transform container, float ceilingY, float ridgeY, float overhang, + Material slopeMat) + { + float oh = overhang; + float rise = ridgeY - ceilingY; + float ridgeX = _roomSize.x / 2f; + + // Ridge inset from each end — equal to half the short axis for equal pitch on all sides + float ridgeInset = _roomSize.x / 2f; + float ridgeMinZ = ridgeInset; + float ridgeMaxZ = _roomSize.z - ridgeInset; + + // Eave corners + Vector3 sw = new Vector3(-oh, ceilingY, -oh); + Vector3 se = new Vector3(_roomSize.x + oh, ceilingY, -oh); + Vector3 ne = new Vector3(_roomSize.x + oh, ceilingY, _roomSize.z + oh); + Vector3 nw = new Vector3(-oh, ceilingY, _roomSize.z + oh); + + float sideRun = ridgeX + oh; + float sideSlopeLen = Mathf.Sqrt(rise * rise + sideRun * sideRun); + + if (ridgeMaxZ > ridgeMinZ) + { + // Standard hip: ridge line with positive length + Vector3 ridgeS = new Vector3(ridgeX, ridgeY, ridgeMinZ); + Vector3 ridgeN = new Vector3(ridgeX, ridgeY, ridgeMaxZ); + + float eaveLen = _roomSize.z + 2f * oh; + + // West slope (trapezoid) + CreateSlopeQuad("Slope_West", container, + sw, nw, ridgeN, ridgeS, + eaveLen, sideSlopeLen, slopeMat); + + // East slope (trapezoid) + CreateSlopeQuad("Slope_East", container, + ne, se, ridgeS, ridgeN, + eaveLen, sideSlopeLen, slopeMat); + + // Hip end slopes + float hipBaseLen = _roomSize.x + 2f * oh; + float hipRun = ridgeInset + oh; + float hipSlopeLen = Mathf.Sqrt(rise * rise + hipRun * hipRun); + + // North hip (triangle) + CreateSlopeTriangle("Hip_North", container, + nw, ne, ridgeN, + hipBaseLen, hipSlopeLen, slopeMat); + + // South hip (triangle) + CreateSlopeTriangle("Hip_South", container, + se, sw, ridgeS, + hipBaseLen, hipSlopeLen, slopeMat); + } + else + { + // Pyramid: ridge collapsed to a point + Vector3 apex = new Vector3(ridgeX, ridgeY, _roomSize.z / 2f); + + float sideBaseLen = _roomSize.z + 2f * oh; + float hipBaseLen = _roomSize.x + 2f * oh; + float hipRun = _roomSize.z / 2f + oh; + float hipSlopeLen = Mathf.Sqrt(rise * rise + hipRun * hipRun); + + CreateSlopeTriangle("Slope_West", container, + sw, nw, apex, sideBaseLen, sideSlopeLen, slopeMat); + CreateSlopeTriangle("Slope_East", container, + ne, se, apex, sideBaseLen, sideSlopeLen, slopeMat); + CreateSlopeTriangle("Hip_North", container, + nw, ne, apex, hipBaseLen, hipSlopeLen, slopeMat); + CreateSlopeTriangle("Hip_South", container, + se, sw, apex, hipBaseLen, hipSlopeLen, slopeMat); + } + } + + private static void CreateSlopeTriangle( + string name, Transform parent, + Vector3 v0, Vector3 v1, Vector3 v2, + float uvWidth, float uvHeight, Material material) + { + new CustomMeshBuilder(name) + .AddVertices(v0, v1, v2) // front face: 0-2 + .AddVertices(v0, v1, v2) // back face: 3-5 + .AddTriangle(0, 1, 2) // front + .AddTriangle(5, 4, 3) // back (reversed winding) + .SetUVs( + new Vector2(0f, 0f), + new Vector2(uvWidth, 0f), + new Vector2(uvWidth / 2f, uvHeight), + new Vector2(0f, 0f), + new Vector2(uvWidth, 0f), + new Vector2(uvWidth / 2f, uvHeight)) + .SetMaterial(material) + .Build(parent); + } + + private void CreateRoofBaseSlab( + Transform container, float ceilingY, float slabHeight, float overhang, Material material) + { + float oh = overhang; + float slabWidth = _roomSize.x + 2f * oh; + float slabDepth = _roomSize.z + 2f * oh; + float slabCenterY = ceilingY + slabHeight / 2f; + + GameObject slab = PrimitiveBuilder.CreateBox("RoofBaseSlab", + new Vector3(_roomSize.x / 2f, slabCenterY, _roomSize.z / 2f), + new Vector3(slabWidth, slabHeight, slabDepth), + material.color, container); + + ApplyMaterial(slab, material); + } + + private static void ApplyMaterial(GameObject obj, Material material) + { + Renderer r = obj.GetComponent(); + if (r != null) r.material = material; + } + + #endregion + } +} diff --git a/Building/Structural/TerrainClearer.cs b/Building/Structural/TerrainClearer.cs new file mode 100644 index 0000000..f066801 --- /dev/null +++ b/Building/Structural/TerrainClearer.cs @@ -0,0 +1,435 @@ +using S1MAPI.Utils; +using UnityEngine; + +namespace S1MAPI.Building.Structural +{ + /// + /// Configuration for area clearing operations. + /// + public sealed class ClearingOptions + { + /// Extra padding around the clearing bounds in meters. + public float Padding { get; set; } = Constants.Terrain.DefaultClearingPadding; + + /// Whether to remove terrain tree instances. + public bool ClearTerrainTrees { get; set; } = true; + + /// Whether to remove all scene objects within the building footprint. + public bool ClearSceneObjects { get; set; } = true; + + /// Whether to remove vegetation/clutter within the padded area around the building. + public bool ClearVegetation { get; set; } = true; + + /// + /// Name patterns for vegetation and natural clutter (case-insensitive). + /// Only used by the vegetation pass. Objects inside the building footprint + /// are destroyed regardless of keywords (unless protected). + /// + public string[]? VegetationKeywords { get; set; } = Constants.Terrain.DefaultVegetationKeywords; + + /// + /// Name patterns for objects protected from footprint destruction (case-insensitive). + /// Objects matching these keywords are skipped by the footprint pass because they + /// typically extend far beyond the building and leave visible gaps when destroyed. + /// Set to null to destroy everything in the footprint. + /// + public string[]? ProtectedKeywords { get; set; } = Constants.Terrain.DefaultProtectedKeywords; + + /// + /// Transforms to preserve during clearing (e.g. the building itself). + /// All children of preserved transforms are also preserved. + /// Auto-populated from the building hierarchy when using + /// . + /// + public Transform[]? Preserved { get; set; } + + /// + /// Optional filter applied to both passes. + /// Return true to keep, false to remove. + /// + /// + /// // Keep rocks inside the building footprint: + /// options.Filter = go => go.name.Contains("Rock"); + /// + public Func? Filter { get; set; } + + /// Default clearing options. + public static ClearingOptions Default => + new(); + } + + /// + /// Clears terrain trees and scene objects from a specified world-space area. + /// Typically used to prepare a building site after positioning. + /// + public static class TerrainClearer + { + #region Public API + + /// + /// Clear terrain trees and scene objects within the specified world-space bounds. + /// + internal static void ClearArea(Bounds bounds, ClearingOptions? options = null) + { + options ??= ClearingOptions.Default; + float pad = options.Padding; + + int treesRemoved = 0; + int objectsRemoved = 0; + + // Terrain trees need extra padding (canopies extend beyond trunk). + if (options.ClearTerrainTrees) + { + Bounds treeBounds = bounds; + treeBounds.Expand(new Vector3(pad * 2f, pad * 2f, pad * 2f)); + treesRemoved = ClearTerrainTrees(treeBounds); + } + + // Scene objects: two passes in a single scan. + // Pass 1 (ClearSceneObjects): destroy everything in building footprint. + // Pass 2 (ClearVegetation): destroy vegetation in padded area around building. + if (options.ClearSceneObjects || options.ClearVegetation) + { + Bounds vegBounds = bounds; + vegBounds.Expand(new Vector3(pad, pad, pad)); + objectsRemoved = ClearSceneObjects(bounds, vegBounds, options); + } + + if (treesRemoved + objectsRemoved > 0) + DebugLog.Info($"[TerrainClearer] Cleared {treesRemoved} trees, {objectsRemoved} objects"); + } + + /// + /// Clear the area around a building using its transform and room size. + /// Correctly handles rotated buildings by computing world-space bounds + /// from the building's actual corners. + /// + /// The building root GameObject (must be positioned). + /// The building's room dimensions. + /// Clearing configuration. Uses defaults if null. + /// True if terrain was available and clearing executed immediately; false if terrain wasn't + /// available (a background retry has been queued automatically). + public static bool ClearAroundBuilding( + GameObject buildingRoot, Vector3 roomSize, ClearingOptions? options = null) + { + try + { + if (ClearAroundBuildingCore(buildingRoot, roomSize, options)) + return true; + } + catch (System.Exception ex) + { + // IL2CPP clients can throw TypeInitializationException when terrain + // runtime generics aren't initialized yet. Catch and fall through to retry. + DebugLog.Warning($"[TerrainClearer] ClearAroundBuilding threw (will retry): {ex.GetType().Name}: {ex.Message}"); + } + + // Terrain not loaded or threw — queue automatic retry on the building root + DebugLog.Info("[TerrainClearer] Terrain not available — queuing retry."); + TerrainRetryQueue.Enqueue(buildingRoot, + () => ClearAroundBuildingCore(buildingRoot, roomSize, options)); + return false; + } + + private static bool ClearAroundBuildingCore( + GameObject buildingRoot, Vector3 roomSize, ClearingOptions? options) + { + ClearingOptions opts = options ?? ClearingOptions.Default; + + if (opts.ClearTerrainTrees && Terrain.activeTerrains.Length == 0) + return false; + + // Auto-populate Preserved with the building hierarchy if not set, + // without mutating the caller's options instance. + if (opts.Preserved == null) + { + opts = new ClearingOptions + { + Padding = opts.Padding, + ClearTerrainTrees = opts.ClearTerrainTrees, + ClearSceneObjects = opts.ClearSceneObjects, + ClearVegetation = opts.ClearVegetation, + VegetationKeywords = opts.VegetationKeywords, + ProtectedKeywords = opts.ProtectedKeywords, + Preserved = new[] { buildingRoot.transform }, + Filter = opts.Filter + }; + } + + Bounds bounds = ComputeWorldBounds(buildingRoot.transform, roomSize); + ClearArea(bounds, opts); + return true; + } + + #endregion + + #region Bounds Computation + + /// + /// Compute an axis-aligned bounding box from a building's rotated corners. + /// + private static Bounds ComputeWorldBounds(Transform building, Vector3 roomSize) + { + Vector3 min = Vector3.zero; + Vector3 max = roomSize; + + Vector3 c0 = building.TransformPoint(new Vector3(min.x, min.y, min.z)); + Vector3 c1 = building.TransformPoint(new Vector3(max.x, min.y, min.z)); + Vector3 c2 = building.TransformPoint(new Vector3(min.x, min.y, max.z)); + Vector3 c3 = building.TransformPoint(new Vector3(max.x, min.y, max.z)); + Vector3 c4 = building.TransformPoint(new Vector3(min.x, max.y, min.z)); + Vector3 c5 = building.TransformPoint(new Vector3(max.x, max.y, min.z)); + Vector3 c6 = building.TransformPoint(new Vector3(min.x, max.y, max.z)); + Vector3 c7 = building.TransformPoint(new Vector3(max.x, max.y, max.z)); + + Bounds b = new Bounds(c0, Vector3.zero); + b.Encapsulate(c1); + b.Encapsulate(c2); + b.Encapsulate(c3); + b.Encapsulate(c4); + b.Encapsulate(c5); + b.Encapsulate(c6); + b.Encapsulate(c7); + + return b; + } + + #endregion + + #region Terrain Trees + + /// Remove tree instances from all active terrains that fall within bounds. + private static int ClearTerrainTrees(Bounds bounds) + { + int totalRemoved = 0; + + foreach (Terrain terrain in Terrain.activeTerrains) + { + TerrainData data; + try + { + data = terrain.terrainData; + if (data is null) continue; + } + catch (Exception ex) + { + DebugLog.Warning($"[TerrainClearer] Could not access terrain data for '{terrain.name}': {ex.Message}"); + continue; + } + + Vector3 terrainPos = terrain.transform.position; + Vector3 terrainSize = data.size; + TreeInstance[] originalTrees = data.treeInstances; + List surviving = new List(originalTrees.Length); + int removed = 0; + + for (int i = 0; i < originalTrees.Length; i++) + { + TreeInstance tree = originalTrees[i]; + Vector3 worldPos = new Vector3( + terrainPos.x + tree.position.x * terrainSize.x, + terrainPos.y + tree.position.y * terrainSize.y, + terrainPos.z + tree.position.z * terrainSize.z + ); + + if (bounds.Contains(worldPos)) + { + removed++; + } + else + { + surviving.Add(tree); + } + } + + if (removed > 0) + { + data.treeInstances = surviving.ToArray(); + + // Force full rebuild of terrain collision data (including tree + // colliders/triggers). Toggling enabled alone may leave stale + // SpeedTree interaction data that produces rustle sounds. + TerrainCollider? collider = terrain.GetComponent(); + if (collider != null) + { + collider.terrainData = null; + collider.terrainData = data; + } + terrain.Flush(); + + DebugLog.Info($"[TerrainClearer] Removed {removed} trees from terrain '{terrain.name}'"); + } + + totalRemoved += removed; + } + + return totalRemoved; + } + + #endregion + + #region Scene Objects + + /// + /// Destroy scene objects using a two-pass approach in a single renderer scan. + /// Pass 1 (footprint): destroys all objects inside buildingBounds. + /// Pass 2 (vegetation): destroys only keyword-matched objects in the padded vegetationBounds. + /// Both passes respect Preserved and Filter. + /// + private static int ClearSceneObjects( + Bounds buildingBounds, Bounds vegetationBounds, ClearingOptions options) + { + // Use the building's XZ for the footprint but the vegetation bounds' Y range, + // so ground-level objects (y=0) under an elevated building (y=1) are caught + // while deep underground objects (sewers) are excluded. + Bounds footprintBounds = buildingBounds; + Vector3 fpMin = footprintBounds.min; + Vector3 fpMax = footprintBounds.max; + fpMin.y = vegetationBounds.min.y; + fpMax.y = vegetationBounds.max.y; + footprintBounds.SetMinMax(fpMin, fpMax); + + Renderer[] allRenderers = UnityEngine.Object.FindObjectsOfType(); + HashSet preserved = BuildPreservedSet(options); + HashSet toDestroy = new HashSet(); + + var livingRoots = new HashSet(); + var staticRoots = new HashSet(); + + foreach (Renderer r in allRenderers) + { + if (r == null) continue; + Transform t = r.transform; + if (t.GetComponent() != null) continue; + if (preserved.Contains(t.GetInstanceID())) continue; + + bool inFootprint = footprintBounds.Contains(t.position); + bool inVegetationZone = !inFootprint && vegetationBounds.Contains(t.position); + + if (!inFootprint && !inVegetationZone) continue; + + GameObject target = ResolveLodRoot(t.gameObject); + + // Skip living entities (players, NPCs) + if (BuildingUtilities.IsLivingEntity(t, livingRoots, staticRoots)) + continue; + + // Pass 1: everything inside the building footprint, except protected objects. + if (inFootprint && options.ClearSceneObjects) + { + if (options.ProtectedKeywords != null + && MatchesKeyword(target.name, options.ProtectedKeywords)) + continue; + if (options.Filter != null && options.Filter(target)) continue; + toDestroy.Add(target); + continue; + } + + // Pass 2: only vegetation in the padded zone around the building. + if (inVegetationZone && options.ClearVegetation + && options.VegetationKeywords != null + && MatchesKeyword(target.name, options.VegetationKeywords)) + { + if (options.Filter != null && options.Filter(target)) continue; + toDestroy.Add(target); + } + } + + // Catch-all: scan for renderer-less objects matching vegetation keywords + // (e.g. FoliageRustleSound trigger colliders, vegetation scripts without meshes). + // Objects with children are included if they match a vegetation keyword — + // game components like FoliageRustleSound have child Container GameObjects. + if (options.VegetationKeywords != null && options.VegetationKeywords.Length > 0) + { + Transform[] allTransforms = UnityEngine.Object.FindObjectsOfType(); + foreach (Transform t in allTransforms) + { + if (t == null) continue; + if (t.GetComponent() != null) continue; + if (t.GetComponent() != null) continue; // Already handled above. + if (preserved.Contains(t.GetInstanceID())) continue; + + bool inFootprint = footprintBounds.Contains(t.position); + bool inVegetationZone = !inFootprint && vegetationBounds.Contains(t.position); + + if (!inFootprint && !inVegetationZone) continue; + + GameObject target = t.gameObject; + + if (!MatchesKeyword(target.name, options.VegetationKeywords)) + continue; + + if (inFootprint && options.ClearSceneObjects) + { + if (options.ProtectedKeywords != null + && MatchesKeyword(target.name, options.ProtectedKeywords)) + continue; + if (options.Filter != null && options.Filter(target)) continue; + toDestroy.Add(target); + continue; + } + + if (inVegetationZone && options.ClearVegetation) + { + if (options.Filter != null && options.Filter(target)) continue; + toDestroy.Add(target); + } + } + } + + foreach (GameObject go in toDestroy) + UnityEngine.Object.Destroy(go); + + return toDestroy.Count; + } + + /// Walk up from a renderer to its LODGroup parent, if one exists. + private static GameObject ResolveLodRoot(GameObject obj) + { + Transform current = obj.transform; + while (current.parent != null) + { + if (current.parent.GetComponent() != null) + return current.parent.gameObject; + current = current.parent; + } + return obj; + } + + /// Check if a GameObject name contains any of the given keywords (case-insensitive). + private static bool MatchesKeyword(string name, string[] keywords) + { + foreach (string keyword in keywords) + { + if (name.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0) + return true; + } + return false; + } + + /// + /// Flatten all preserved transforms and their children into a set for fast lookup. + /// Uses instance IDs because IL2CPP wrapper objects for the same native Transform + /// have different C# references, breaking HashSet reference equality. + /// + private static HashSet BuildPreservedSet(ClearingOptions options) + { + HashSet preserved = new HashSet(); + + if (options.Preserved == null) return preserved; + + foreach (Transform root in options.Preserved) + { + if (root == null) continue; + foreach (Transform child in root.GetComponentsInChildren()) + { + preserved.Add(child.GetInstanceID()); + } + } + + return preserved; + } + + #endregion + } +} diff --git a/Building/Structural/TerrainFlattener.cs b/Building/Structural/TerrainFlattener.cs new file mode 100644 index 0000000..baca14c --- /dev/null +++ b/Building/Structural/TerrainFlattener.cs @@ -0,0 +1,547 @@ +using S1MAPI.Utils; +using UnityEngine; + +#if IL2CPP +using Il2CppInterop.Runtime; +using Il2CppInterop.Runtime.InteropTypes; +using Il2CppInterop.Runtime.InteropTypes.Arrays; +#endif + +namespace S1MAPI.Building.Structural +{ + /// + /// Flattens terrain height and clears detail/grass under a building footprint. + /// Only lowers terrain — never raises it — to avoid creating dirt walls. + /// Works on both Mono and IL2CPP runtimes. + /// + public static class TerrainFlattener + { + #region IL2CPP ICalls + +#if IL2CPP + // Height ICalls — GetHeights managed wrapper crashes in Il2CppObjectPool, so we bypass it too + private delegate System.IntPtr Internal_GetHeightsDelegate( + System.IntPtr @this, int xBase, int yBase, int width, int height); + + private delegate void Internal_SetHeightsDelegate( + System.IntPtr @this, int xBase, int yBase, int totalWidth, int totalHeight, System.IntPtr heights); + + // Detail ICalls — both GetDetailLayer and SetDetailLayer are stripped + private delegate System.IntPtr GetDetailLayerDelegate( + System.IntPtr @this, int xBase, int yBase, int width, int height, int layer); + + private delegate void Internal_SetDetailLayerDelegate( + System.IntPtr @this, int xBase, int yBase, int totalWidth, int totalHeight, + int detailIndex, System.IntPtr data); + + private static Internal_GetHeightsDelegate? _getHeightsICall; + private static Internal_SetHeightsDelegate? _setHeightsICall; + private static bool _heightICallsResolved; + + private static GetDetailLayerDelegate? _getDetailLayerICall; + private static Internal_SetDetailLayerDelegate? _setDetailLayerICall; + private static bool _detailICallsResolved; + + /// + /// Resolve both Internal_GetHeights and Internal_SetHeights ICalls. + /// Returns true only if both resolved successfully. + /// + private static bool ResolveHeightICalls() + { + if (_heightICallsResolved) return _getHeightsICall != null && _setHeightsICall != null; + _heightICallsResolved = true; + + try + { + _getHeightsICall = IL2CPP.ResolveICall( + "UnityEngine.TerrainData::Internal_GetHeights"); + _setHeightsICall = IL2CPP.ResolveICall( + "UnityEngine.TerrainData::Internal_SetHeights"); + + if (_getHeightsICall != null && _setHeightsICall != null) + DebugLog.Info("[TerrainFlattener] Resolved height ICalls."); + else + DebugLog.Error("[TerrainFlattener] Height ICalls resolved to null."); + } + catch (System.Exception ex) + { + DebugLog.Error($"[TerrainFlattener] Failed to resolve height ICalls: {ex.Message}"); + _getHeightsICall = null; + _setHeightsICall = null; + } + + return _getHeightsICall != null && _setHeightsICall != null; + } + + /// + /// Resolve both GetDetailLayer and Internal_SetDetailLayer ICalls. + /// Returns true only if both resolved successfully. + /// + private static bool ResolveDetailICalls() + { + if (_detailICallsResolved) return _getDetailLayerICall != null && _setDetailLayerICall != null; + _detailICallsResolved = true; + + try + { + _getDetailLayerICall = IL2CPP.ResolveICall( + "UnityEngine.TerrainData::GetDetailLayer"); + _setDetailLayerICall = IL2CPP.ResolveICall( + "UnityEngine.TerrainData::Internal_SetDetailLayer"); + + if (_getDetailLayerICall != null && _setDetailLayerICall != null) + DebugLog.Info("[TerrainFlattener] Resolved detail layer ICalls."); + else + DebugLog.Warning("[TerrainFlattener] Detail layer ICalls resolved to null — grass clearing unavailable."); + } + catch (System.Exception ex) + { + DebugLog.Warning($"[TerrainFlattener] Failed to resolve detail layer ICalls: {ex.Message}"); + _getDetailLayerICall = null; + _setDetailLayerICall = null; + } + + return _getDetailLayerICall != null && _setDetailLayerICall != null; + } +#endif + + #endregion + + #region Public API + + /// + /// Flatten terrain under a building so the ground is level at the target height. + /// Only lowers terrain that is above the target — never raises terrain. + /// Optionally clears grass and detail layers in the flattened region. + /// + /// The positioned building root GameObject. + /// The building's room dimensions. + /// World Y to flatten to (typically foundation base). + /// Extra padding around the footprint in meters. + /// Clear grass and detail layers in the flattened region. + /// Distance in meters over which terrain smoothly transitions + /// from the flattened height back to natural terrain. 0 = hard edge. + /// True if terrain was found and flattened immediately; false if terrain wasn't available + /// (a background retry has been queued automatically). + public static bool FlattenUnder( + GameObject buildingRoot, Vector3 roomSize, + float targetWorldY, float padding = Constants.Terrain.DefaultFlattenPadding, + bool clearDetails = true, float blendDistance = 0f) + { + try + { + if (FlattenUnderCore(buildingRoot, roomSize, targetWorldY, padding, clearDetails, blendDistance)) + return true; + } + catch (System.Exception ex) + { + // IL2CPP clients can throw TypeInitializationException when terrain + // runtime generics aren't initialized yet. Catch and fall through to retry. + DebugLog.Warning($"[TerrainFlattener] FlattenUnder threw (will retry): {ex.GetType().Name}: {ex.Message}"); + } + + // Terrain not loaded or threw — queue automatic retry on the building root + DebugLog.Info("[TerrainFlattener] Terrain not available — queuing retry."); + TerrainRetryQueue.Enqueue(buildingRoot, + () => FlattenUnderCore(buildingRoot, roomSize, targetWorldY, padding, clearDetails, blendDistance)); + return false; + } + + private static bool FlattenUnderCore( + GameObject buildingRoot, Vector3 roomSize, + float targetWorldY, float padding, bool clearDetails, float blendDistance) + { + Bounds innerBounds = ComputeXZBounds(buildingRoot.transform, roomSize, padding); + + // Expand sample region to include blend zone + Bounds sampleBounds = innerBounds; + if (blendDistance > 0f) + sampleBounds.Expand(new Vector3(blendDistance * 2f, 0f, blendDistance * 2f)); + + Terrain? terrain = FindCoveringTerrain(innerBounds); + if (terrain == null) + return false; + + TerrainData tData = terrain.terrainData; + Vector3 terrainPos = terrain.transform.position; + Vector3 terrainSize = tData.size; + int hmRes = tData.heightmapResolution; + + // Convert expanded bounds to heightmap sample indices + int xStart = Mathf.Clamp(Mathf.FloorToInt((sampleBounds.min.x - terrainPos.x) / terrainSize.x * hmRes), 0, hmRes - 1); + int xEnd = Mathf.Clamp(Mathf.CeilToInt((sampleBounds.max.x - terrainPos.x) / terrainSize.x * hmRes), 0, hmRes - 1); + int zStart = Mathf.Clamp(Mathf.FloorToInt((sampleBounds.min.z - terrainPos.z) / terrainSize.z * hmRes), 0, hmRes - 1); + int zEnd = Mathf.Clamp(Mathf.CeilToInt((sampleBounds.max.z - terrainPos.z) / terrainSize.z * hmRes), 0, hmRes - 1); + + int sampleWidth = xEnd - xStart + 1; + int sampleHeight = zEnd - zStart + 1; + + if (sampleWidth <= 0 || sampleHeight <= 0) + { + DebugLog.Warning("[TerrainFlattener] Computed zero-size sample region."); + return false; + } + + // Compute blend zone size in samples for each axis + int blendSamplesX = blendDistance > 0f + ? Mathf.CeilToInt(blendDistance / terrainSize.x * hmRes) + : 0; + int blendSamplesZ = blendDistance > 0f + ? Mathf.CeilToInt(blendDistance / terrainSize.z * hmRes) + : 0; + + float normalizedTarget = Mathf.Clamp01((targetWorldY - terrainPos.y) / terrainSize.y); + +#if MONO + FlattenMono(tData, xStart, zStart, sampleWidth, sampleHeight, normalizedTarget, + blendSamplesX, blendSamplesZ); +#elif IL2CPP + FlattenIl2Cpp(tData, xStart, zStart, sampleWidth, sampleHeight, normalizedTarget, + blendSamplesX, blendSamplesZ); +#endif + + if (clearDetails) + { + // Clear details only in the inner footprint, not the blend zone + ClearDetails(tData, terrainPos, terrainSize, innerBounds); + } + + FlushTerrain(terrain); + + DebugLog.Info($"[TerrainFlattener] Flattened {sampleWidth}x{sampleHeight} samples " + + $"to Y={targetWorldY:F2} on terrain '{terrain.name}'."); + return true; + } + + #endregion + + #region Private Methods — Terrain Lookup + + /// + /// Find the terrain whose XZ bounds cover the given world-space bounds. + /// Skips terrains with null terrainData. + /// + private static Terrain? FindCoveringTerrain(Bounds worldBounds) + { + foreach (Terrain t in Terrain.activeTerrains) + { + TerrainData? data; + try + { + data = t.terrainData; + if (data == null) continue; + } + catch (System.Exception) + { + continue; + } + + Vector3 tPos = t.transform.position; + Vector3 tSize = data.size; + + if (worldBounds.min.x >= tPos.x && worldBounds.max.x <= tPos.x + tSize.x && + worldBounds.min.z >= tPos.z && worldBounds.max.z <= tPos.z + tSize.z) + { + return t; + } + } + + return null; + } + + #endregion + + #region Private Methods — Bounds + + /// + /// Compute a padded world-space AABB from the building's rotated footprint. + /// Only uses XZ corners (Y is irrelevant for heightmap sampling). + /// + private static Bounds ComputeXZBounds(Transform building, Vector3 roomSize, float padding) + { + Vector3 c0 = building.TransformPoint(new Vector3(0f, 0f, 0f)); + Vector3 c1 = building.TransformPoint(new Vector3(roomSize.x, 0f, 0f)); + Vector3 c2 = building.TransformPoint(new Vector3(0f, 0f, roomSize.z)); + Vector3 c3 = building.TransformPoint(new Vector3(roomSize.x, 0f, roomSize.z)); + + var bounds = new Bounds(c0, Vector3.zero); + bounds.Encapsulate(c1); + bounds.Encapsulate(c2); + bounds.Encapsulate(c3); + bounds.Expand(new Vector3(padding * 2f, 0f, padding * 2f)); + + return bounds; + } + + /// + /// Compute a smoothstep blend factor for a sample position. + /// Returns 0 inside the inner flat zone, smoothly ramps to 1 at the outer blend edge. + /// + private static float ComputeBlendFactor( + int x, int z, int sampleWidth, int sampleHeight, + int blendSamplesX, int blendSamplesZ) + { + if (blendSamplesX <= 0 && blendSamplesZ <= 0) return 0f; + + // Normalized distance from inner bounds edge (0 = at edge, 1 = at outer limit) + float dx = 0f; + if (blendSamplesX > 0) + { + if (x < blendSamplesX) + dx = (float)(blendSamplesX - x) / blendSamplesX; + else if (x >= sampleWidth - blendSamplesX) + dx = (float)(x - (sampleWidth - 1 - blendSamplesX)) / blendSamplesX; + } + + float dz = 0f; + if (blendSamplesZ > 0) + { + if (z < blendSamplesZ) + dz = (float)(blendSamplesZ - z) / blendSamplesZ; + else if (z >= sampleHeight - blendSamplesZ) + dz = (float)(z - (sampleHeight - 1 - blendSamplesZ)) / blendSamplesZ; + } + + // Rectangular falloff — use max of axis distances + float t = Mathf.Clamp01(Mathf.Max(dx, dz)); + + // Smoothstep for natural-looking transition + return t * t * (3f - 2f * t); + } + + #endregion + + #region Private Methods — Detail Clearing + + /// + /// Clear grass and detail layers in the given world-space bounds. + /// Converts to detail map indices and delegates to the platform-specific implementation. + /// + private static void ClearDetails( + TerrainData tData, Vector3 terrainPos, Vector3 terrainSize, Bounds worldBounds) + { + int detailWidth = tData.detailWidth; + int detailHeight = tData.detailHeight; + + if (detailWidth <= 0 || detailHeight <= 0) return; + + // Convert world XZ to detail map indices + int dxStart = Mathf.Clamp(Mathf.FloorToInt((worldBounds.min.x - terrainPos.x) / terrainSize.x * detailWidth), 0, detailWidth - 1); + int dxEnd = Mathf.Clamp(Mathf.CeilToInt((worldBounds.max.x - terrainPos.x) / terrainSize.x * detailWidth), 0, detailWidth - 1); + int dzStart = Mathf.Clamp(Mathf.FloorToInt((worldBounds.min.z - terrainPos.z) / terrainSize.z * detailHeight), 0, detailHeight - 1); + int dzEnd = Mathf.Clamp(Mathf.CeilToInt((worldBounds.max.z - terrainPos.z) / terrainSize.z * detailHeight), 0, detailHeight - 1); + + int regionWidth = dxEnd - dxStart + 1; + int regionHeight = dzEnd - dzStart + 1; + + if (regionWidth <= 0 || regionHeight <= 0) return; + + // GetSupportedLayers returns which detail layers have data in this region +#if MONO + int[] layers = tData.GetSupportedLayers(dxStart, dzStart, regionWidth, regionHeight); +#elif IL2CPP + Il2CppStructArray layersArray = tData.GetSupportedLayers(dxStart, dzStart, regionWidth, regionHeight); + int[] layers = new int[layersArray.Length]; + for (int i = 0; i < layersArray.Length; i++) + layers[i] = layersArray[i]; +#endif + + if (layers.Length == 0) return; + + int cleared = 0; + foreach (int layer in layers) + { +#if MONO + if (ClearDetailLayerMono(tData, dxStart, dzStart, regionWidth, regionHeight, layer)) + cleared++; +#elif IL2CPP + if (ClearDetailLayerIl2Cpp(tData, dxStart, dzStart, regionWidth, regionHeight, layer)) + cleared++; +#endif + } + + if (cleared > 0) + { + DebugLog.Info($"[TerrainFlattener] Cleared {cleared} detail layer(s) " + + $"in {regionWidth}x{regionHeight} region."); + } + } + + #endregion + + #region Private Methods — Mono + +#if MONO + private static void FlattenMono( + TerrainData tData, int xStart, int zStart, + int sampleWidth, int sampleHeight, float normalizedTarget, + int blendSamplesX, int blendSamplesZ) + { + // GetHeights parameter order: (xBase, yBase, width, height) + // where yBase = z index, height = z count + // Returns float[zCount, xCount] indexed as [z, x] + float[,] heights = tData.GetHeights(xStart, zStart, sampleWidth, sampleHeight); + + bool modified = false; + for (int z = 0; z < sampleHeight; z++) + { + for (int x = 0; x < sampleWidth; x++) + { + float blend = ComputeBlendFactor(x, z, sampleWidth, sampleHeight, + blendSamplesX, blendSamplesZ); + float blendedTarget = normalizedTarget + blend * (heights[z, x] - normalizedTarget); + + if (heights[z, x] > blendedTarget) + { + heights[z, x] = blendedTarget; + modified = true; + } + } + } + + if (modified) + tData.SetHeights(xStart, zStart, heights); + } + + private static bool ClearDetailLayerMono( + TerrainData tData, int xStart, int zStart, + int regionWidth, int regionHeight, int layer) + { + int[,] details = tData.GetDetailLayer(xStart, zStart, regionWidth, regionHeight, layer); + bool modified = false; + + for (int z = 0; z < regionHeight; z++) + { + for (int x = 0; x < regionWidth; x++) + { + if (details[z, x] != 0) + { + details[z, x] = 0; + modified = true; + } + } + } + + if (modified) + { + tData.SetDetailLayer(xStart, zStart, layer, details); + } + + return modified; + } +#endif + + #endregion + + #region Private Methods — IL2CPP + +#if IL2CPP + private static void FlattenIl2Cpp( + TerrainData tData, int xStart, int zStart, + int sampleWidth, int sampleHeight, float normalizedTarget, + int blendSamplesX, int blendSamplesZ) + { + if (!ResolveHeightICalls()) + { + DebugLog.Error("[TerrainFlattener] Cannot flatten terrain — height ICalls unavailable."); + return; + } + + System.IntPtr tDataPtr = IL2CPP.Il2CppObjectBaseToPtrNotNull(tData); + + // Call Internal_GetHeights directly — the managed GetHeights wrapper + // crashes in Il2CppObjectPool when wrapping the return value. + System.IntPtr heightsPtr = _getHeightsICall!(tDataPtr, xStart, zStart, sampleWidth, sampleHeight); + if (heightsPtr == System.IntPtr.Zero) + { + DebugLog.Error("[TerrainFlattener] Internal_GetHeights returned null."); + return; + } + + // Wrap the native 2D float array as a flat Il2CppStructArray for direct data access. + // The underlying memory is contiguous row-major floats [z0x0, z0x1, ..., z1x0, ...]. + var heightsArray = new Il2CppStructArray(heightsPtr); + System.Span data = heightsArray.AsSpan(); + int totalElements = sampleHeight * sampleWidth; + + bool modified = false; + for (int i = 0; i < totalElements; i++) + { + int z = i / sampleWidth; + int x = i % sampleWidth; + float blend = ComputeBlendFactor(x, z, sampleWidth, sampleHeight, + blendSamplesX, blendSamplesZ); + float blendedTarget = normalizedTarget + blend * (data[i] - normalizedTarget); + + if (data[i] > blendedTarget) + { + data[i] = blendedTarget; + modified = true; + } + } + + if (modified) + _setHeightsICall!(tDataPtr, xStart, zStart, sampleWidth, sampleHeight, heightsPtr); + } + + private static bool ClearDetailLayerIl2Cpp( + TerrainData tData, int xStart, int zStart, + int regionWidth, int regionHeight, int layer) + { + if (!ResolveDetailICalls()) return false; + + System.IntPtr tDataPtr = IL2CPP.Il2CppObjectBaseToPtrNotNull(tData); + + // Call GetDetailLayer ICall to get a native int[,] array + System.IntPtr detailPtr = _getDetailLayerICall!(tDataPtr, xStart, zStart, regionWidth, regionHeight, layer); + if (detailPtr == System.IntPtr.Zero) + { + DebugLog.Warning($"[TerrainFlattener] GetDetailLayer returned null for layer {layer}."); + return false; + } + + // Wrap the native 2D int array and zero it out + var detailArray = new Il2CppStructArray(detailPtr); + System.Span data = detailArray.AsSpan(); + + bool modified = false; + for (int i = 0; i < data.Length; i++) + { + if (data[i] != 0) + { + data[i] = 0; + modified = true; + } + } + + if (modified) + { + // Internal_SetDetailLayer(this, xBase, yBase, width, height, layerIndex, data) + _setDetailLayerICall!(tDataPtr, xStart, zStart, regionWidth, regionHeight, layer, detailPtr); + } + + return modified; + } +#endif + + #endregion + + #region Private Methods — Flush + + /// + /// Force terrain to update visuals and collision data. + /// + private static void FlushTerrain(Terrain terrain) + { + TerrainCollider? collider = terrain.GetComponent(); + if (collider != null) + { + collider.terrainData = null; + collider.terrainData = terrain.terrainData; + } + terrain.Flush(); + } + + #endregion + } +} diff --git a/Building/Structural/TerrainRetryBehaviour.cs b/Building/Structural/TerrainRetryBehaviour.cs new file mode 100644 index 0000000..52160a6 --- /dev/null +++ b/Building/Structural/TerrainRetryBehaviour.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using S1MAPI.Utils; +using UnityEngine; + +#if IL2CPP +using Il2CppInterop.Runtime.Injection; +#endif + +namespace S1MAPI.Building.Structural +{ + /// + /// Static operations queue for deferred terrain operations. + /// Separated from so that generic delegates + /// never appear on the ClassInjector-registered MonoBehaviour — IL2CPP cannot + /// marshal generic delegate parameters on injected types. + /// + internal static class TerrainRetryQueue + { + private const float RetryInterval = 1f; + private const int MaxRetries = 30; + + private sealed class RetryState + { + public readonly List> Ops = new List>(); + public int Attempts; + public float NextRetryTime; + } + + private static readonly Dictionary States = new Dictionary(); + + /// + /// Ensure a exists on the target + /// and queue an operation to retry when terrain becomes available. + /// + internal static void Enqueue(GameObject target, Func operation) + { + var behaviour = TerrainRetryBehaviour.EnsureOn(target); + int id = behaviour.GetInstanceID(); + + if (!States.TryGetValue(id, out var state)) + { + state = new RetryState { NextRetryTime = Time.unscaledTime + RetryInterval }; + States[id] = state; + } + state.Ops.Add(operation); + + DebugLog.Info($"[TerrainRetry] Queued operation ({state.Ops.Count} pending). " + + $"Terrain.activeTerrains={Terrain.activeTerrains.Length}, " + + $"building='{target.name}'"); + } + + /// + /// Called by each frame. + /// Returns true if the behaviour should keep running, false if it should self-destruct. + /// + internal static bool Tick(int id, string buildingName) + { + if (!States.TryGetValue(id, out var state) || state.Ops.Count == 0) + { + States.Remove(id); + return false; + } + + // Throttle to RetryInterval using unscaledTime (independent of timeScale) + if (Time.unscaledTime < state.NextRetryTime) + return true; + + state.Attempts++; + state.NextRetryTime = Time.unscaledTime + RetryInterval; + + int terrainCount = Terrain.activeTerrains.Length; + + // Check retry limit + if (state.Attempts > MaxRetries) + { + DebugLog.Error($"[TerrainRetry] Gave up after {MaxRetries} attempts " + + $"({state.Ops.Count} ops remaining). " + + $"Terrain.activeTerrains={terrainCount}, " + + $"building='{buildingName}'"); + States.Remove(id); + return false; + } + + // Wait for terrain to be available + if (terrainCount == 0) + { + if (state.Attempts % 5 == 0) + { + DebugLog.Info($"[TerrainRetry] Waiting for terrain... " + + $"attempt {state.Attempts}/{MaxRetries}, " + + $"building='{buildingName}'"); + } + return true; + } + + // Terrain is available — execute all pending operations + DebugLog.Info($"[TerrainRetry] Terrain available ({terrainCount} active). " + + $"Executing {state.Ops.Count} deferred ops after {state.Attempts} attempt(s)."); + + for (int i = state.Ops.Count - 1; i >= 0; i--) + { + try + { + if (state.Ops[i]()) + { + state.Ops.RemoveAt(i); + } + else + { + DebugLog.Warning("[TerrainRetry] Operation returned false " + + "despite terrain being available — removing."); + state.Ops.RemoveAt(i); + } + } + catch (Exception ex) + { + DebugLog.Error($"[TerrainRetry] Operation threw: {ex.Message}\n{ex.StackTrace}"); + state.Ops.RemoveAt(i); + } + } + + if (state.Ops.Count == 0) + { + DebugLog.Info("[TerrainRetry] All deferred terrain operations completed."); + States.Remove(id); + return false; + } + + return true; + } + + /// Cleanup state for a destroyed behaviour. + internal static void Remove(int id) + { + States.Remove(id); + } + } + + /// + /// Attached to a building root to retry terrain operations that failed + /// because terrain wasn't loaded yet (common on multiplayer clients). + /// Polls until is non-empty, + /// then executes queued operations and removes itself. + /// + /// This MonoBehaviour has NO Func or generic delegate parameters + /// on any method, because IL2CPP ClassInjector cannot marshal them. + /// All delegate storage lives in . + /// + /// + internal sealed class TerrainRetryBehaviour : MonoBehaviour + { +#if IL2CPP + private static bool _registered; +#endif + + /// + /// Ensure IL2CPP type registration, then add or get the component on the target. + /// + internal static TerrainRetryBehaviour EnsureOn(GameObject target) + { +#if IL2CPP + if (!_registered) + { + ClassInjector.RegisterTypeInIl2Cpp(); + _registered = true; + } +#endif + var existing = target.GetComponent(); + return existing != null ? existing : target.AddComponent(); + } + + private void Update() + { + if (!TerrainRetryQueue.Tick(GetInstanceID(), gameObject.name)) + { + Destroy(this); + } + } + + private void OnDestroy() + { + TerrainRetryQueue.Remove(GetInstanceID()); + } + } +} diff --git a/Building/Structural/WallAppearance.cs b/Building/Structural/WallAppearance.cs new file mode 100644 index 0000000..8516bd6 --- /dev/null +++ b/Building/Structural/WallAppearance.cs @@ -0,0 +1,41 @@ +using UnityEngine; + +namespace S1MAPI.Building.Structural +{ + /// + /// Optional material and color overrides for a single exterior wall side. + /// When a value is null, the palette default is used. + /// + public sealed class WallAppearance + { + /// Optional wall color override. Null uses the palette default. + public Color? Color { get; } + + /// Optional wall material override. Null uses the palette default. + public Material? Material { get; } + + /// Optional interior wall material override (room-facing side). + /// Null uses the palette's default. + public Material? InteriorMaterial { get; } + + /// Optional interior wall color override. + /// Null uses the palette's (or WallColor) default. + public Color? InteriorColor { get; } + + /// + /// Create a wall appearance override. + /// + /// Optional color override (null = use palette) + /// Optional material override (null = use palette) + /// Optional interior material override (null = use palette) + /// Optional interior color override (null = use palette) + public WallAppearance(Color? color = null, Material? material = null, + Material? interiorMaterial = null, Color? interiorColor = null) + { + Color = color; + Material = material; + InteriorMaterial = interiorMaterial; + InteriorColor = interiorColor; + } + } +} diff --git a/Building/Structural/WallBuilder.cs b/Building/Structural/WallBuilder.cs index 43c4af6..431b0f2 100644 --- a/Building/Structural/WallBuilder.cs +++ b/Building/Structural/WallBuilder.cs @@ -1,5 +1,8 @@ +using System.Collections.Generic; using S1MAPI.Building.Config; using S1MAPI.ProceduralMesh; +using S1MAPI.ProceduralMesh.Generators.Primitives; +using S1MAPI.Utils; using UnityEngine; using S1MAPI.S1; @@ -20,6 +23,19 @@ public enum WallSide West } + /// + /// Visual style for generated stairs. + /// + public enum StairStyle + { + /// Solid concrete box steps (default). + Solid, + /// Two-tone wood stairs with risers and tread planks (closed riser style). + ClosedRiser, + /// Open plank treads on diagonal stringer beams (open stringer style). + OpenStringer + } + /// /// Type of opening in a wall. /// @@ -46,19 +62,40 @@ public sealed class WallOpening public float Height { get; set; } = 2.2f; /// The bottom offset (sill height) in meters. Used for windows. public float BottomOffset { get; set; } = 0f; + /// Optional window for the left side segment of a door wall. + public WallOpening? LeftWindow { get; set; } + /// Optional window for the right side segment of a door wall. + public WallOpening? RightWindow { get; set; } + /// Number of window panes across the opening (default 1). + public int Count { get; set; } = 1; + /// Width of the wall divider between adjacent panes in meters. + public float DividerWidth { get; set; } = Constants.Window.DefaultDividerWidth; + /// Optional glass material override. When null, uses Materials.WindowGlass. + public Material? GlassMaterial { get; set; } + /// + /// Lateral offset from center along the wall in meters. + /// Positive shifts toward the right/forward end, negative toward left/back. + /// + public float Offset { get; set; } = 0f; + /// Optional frame color override for window openings. Defaults to dark gray when null. + public Color? FrameColor { get; set; } + /// Optional frame material override for window openings. + public Material? FrameMaterial { get; set; } /// /// Creates a door opening configuration. /// /// The door width in meters (default 2.0). /// The door height in meters (default 2.2). + /// Lateral offset from center along the wall in meters (default 0, centered). /// A new WallOpening configured as a door. - public static WallOpening Door(float width = 2.0f, float height = 2.2f) => new() + public static WallOpening Door(float width = 2.0f, float height = 2.2f, float offset = 0f) => new() { Type = WallOpeningType.Door, Width = width, Height = height, - BottomOffset = 0f + BottomOffset = 0f, + Offset = offset }; /// @@ -67,14 +104,61 @@ public sealed class WallOpening /// The window width in meters (default 2.5). /// The window height in meters (default 2.0). /// The sill height from the floor in meters (default 0.8). + /// Number of window panes to distribute across the opening width (default 1). + /// Width of wall dividers between adjacent panes in meters (default 0.15). + /// Optional glass material override. Defaults to Materials.WindowGlass when null. + /// Lateral offset from center along the wall in meters (default 0, centered). + /// Optional frame material override. + /// Optional frame color override. Defaults to dark gray when null. /// A new WallOpening configured as a window. - public static WallOpening Window(float width = 2.5f, float height = 2.0f, float sillHeight = 0.8f) => new() + public static WallOpening Window( + float width = 2.5f, float height = 2.0f, float sillHeight = 0.8f, + int count = 1, float dividerWidth = Constants.Window.DefaultDividerWidth, + Material? glassMaterial = null, float offset = 0f, + Material? frameMaterial = null, Color? frameColor = null) => new() { Type = WallOpeningType.Window, Width = width, Height = height, - BottomOffset = sillHeight + BottomOffset = sillHeight, + Count = count, + DividerWidth = dividerWidth, + GlassMaterial = glassMaterial, + Offset = offset, + FrameMaterial = frameMaterial, + FrameColor = frameColor }; + + /// + /// Creates a door opening with windows on the left and/or right side segments. + /// + /// The door width in meters (default 2.0). + /// The door height in meters (default 2.2). + /// Window config for left side, or null for no window. + /// Window config for right side, or null for no window. + /// A new WallOpening configured as a door with side windows. + public static WallOpening DoorWithWindows( + float doorWidth = 2.0f, float doorHeight = 2.2f, + WallOpening? leftWindow = null, WallOpening? rightWindow = null) + { + // When neither window is specified, use defaults on both sides + if (leftWindow == null && rightWindow == null) + { + WallOpening defaultWindow = Window(width: 1.5f, height: 1.5f, sillHeight: 0.8f); + leftWindow = defaultWindow; + rightWindow = defaultWindow; + } + + return new WallOpening + { + Type = WallOpeningType.Door, + Width = doorWidth, + Height = doorHeight, + BottomOffset = 0f, + LeftWindow = leftWindow, + RightWindow = rightWindow + }; + } } /// @@ -85,12 +169,22 @@ public sealed class WallBuilder { #region Fields + private static readonly Color FrameColor = new Color(0.1f, 0.1f, 0.1f); + private static readonly Color GlassTint = new Color(0.7f, 0.9f, 1f); + private readonly Transform _parent; private readonly Vector3 _roomSize; private readonly float _wallThickness; private readonly BuildingPalette _palette; + private IReadOnlyDictionary? _wallOverrides; private GameObject? _wallsContainer; + // Per-wall interior material state, set in BuildWall() and read by CreateWallSegment() + private WallSide _currentSide; + private Material? _currentInteriorMaterial; + private Color _currentInteriorColor; + private bool _hasInteriorOverride; + #endregion #region Constructor @@ -145,6 +239,33 @@ public GameObject BuildWalls( return _wallsContainer; } + /// + /// Build all four walls with specified openings and per-wall appearance overrides. + /// + /// Opening for north wall (or null for solid) + /// Opening for south wall (or null for solid) + /// Opening for east wall (or null for solid) + /// Opening for west wall (or null for solid) + /// Per-wall material/color overrides (null entries use palette defaults) + /// The walls container GameObject + public GameObject BuildWalls( + WallOpening? northOpening, + WallOpening? southOpening, + WallOpening? eastOpening, + WallOpening? westOpening, + IReadOnlyDictionary wallOverrides) + { + _wallOverrides = wallOverrides; + try + { + return BuildWalls(northOpening, southOpening, eastOpening, westOpening); + } + finally + { + _wallOverrides = null; + } + } + /// /// Build a single wall with optional opening. /// @@ -155,19 +276,29 @@ public GameObject BuildWall(WallSide side, WallOpening? opening = null) { _wallsContainer ??= BuildingUtilities.CreateFolder("Walls", _parent); + Color wallColor = GetWallColor(side); + Material? wallMaterial = GetWallMaterial(side); + + _currentSide = side; + _currentInteriorMaterial = GetWallInteriorMaterial(side); + _currentInteriorColor = GetWallInteriorColor(side); + _hasInteriorOverride = _currentInteriorMaterial != null || HasExplicitInteriorColor(side); + var (position, size, isVertical) = GetWallTransform(side); string wallName = $"{side}Wall"; if (opening == null || opening.Type == WallOpeningType.None) { - return CreateSolidWall(wallName, position, size); + return CreateSolidWall(wallName, position, size, wallColor, wallMaterial); } return opening.Type switch { - WallOpeningType.Door => CreateWallWithDoor(wallName, position, size, opening, isVertical), - WallOpeningType.Window => CreateWallWithWindow(wallName, position, size, opening, isVertical), - _ => CreateSolidWall(wallName, position, size) + WallOpeningType.Door when opening.LeftWindow != null || opening.RightWindow != null + => CreateWallWithDoorAndWindows(wallName, position, size, opening, isVertical, wallColor, wallMaterial), + WallOpeningType.Door => CreateWallWithDoor(wallName, position, size, opening, isVertical, wallColor, wallMaterial), + WallOpeningType.Window => CreateWallWithWindow(wallName, position, size, opening, isVertical, wallColor, wallMaterial), + _ => CreateSolidWall(wallName, position, size, wallColor, wallMaterial) }; } @@ -177,16 +308,19 @@ public GameObject BuildWall(WallSide side, WallOpening? opening = null) private (Vector3 position, Vector3 size, bool isVertical) GetWallTransform(WallSide side) { + // N/S walls extend by wallThickness on each end to cover corner gaps with E/W walls + float extendedWidth = _roomSize.x + _wallThickness; + return side switch { WallSide.North => ( new Vector3(_roomSize.x / 2f, _roomSize.y / 2f, _roomSize.z), - new Vector3(_roomSize.x, _roomSize.y, _wallThickness), + new Vector3(extendedWidth, _roomSize.y, _wallThickness), false ), WallSide.South => ( new Vector3(_roomSize.x / 2f, _roomSize.y / 2f, 0f), - new Vector3(_roomSize.x, _roomSize.y, _wallThickness), + new Vector3(extendedWidth, _roomSize.y, _wallThickness), false ), WallSide.East => ( @@ -203,133 +337,358 @@ public GameObject BuildWall(WallSide side, WallOpening? opening = null) }; } - private GameObject CreateSolidWall(string name, Vector3 position, Vector3 size) + private GameObject CreateSolidWall(string name, Vector3 position, Vector3 size, Color wallColor, Material? wallMaterial) { - GameObject wall = PrimitiveBuilder.CreateBox(name, position, size, _palette.WallColor, _wallsContainer!.transform); - ApplyWallMaterial(wall); - return wall; + return CreateWallSegment(name, position, size, wallColor, wallMaterial, _wallsContainer!.transform); } - private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 wallSize, WallOpening opening, bool isVertical) + private GameObject CreateWallWithDoor(string name, Vector3 wallCenter, Vector3 wallSize, WallOpening opening, bool isVertical, Color wallColor, Material? wallMaterial) { GameObject container = BuildingUtilities.CreateFolder(name, _wallsContainer!.transform); - + float wallWidth = isVertical ? wallSize.z : wallSize.x; float wallHeight = wallSize.y; float doorWidth = opening.Width; float doorHeight = opening.Height; - float sideWallWidth = (wallWidth - doorWidth) / 2f; - Vector3 leftOffset = isVertical ? Vector3.back * (doorWidth / 2f + sideWallWidth / 2f) : Vector3.left * (doorWidth / 2f + sideWallWidth / 2f); - Vector3 rightOffset = isVertical ? Vector3.forward * (doorWidth / 2f + sideWallWidth / 2f) : Vector3.right * (doorWidth / 2f + sideWallWidth / 2f); + float halfMargin = (wallWidth - doorWidth) / 2f; + float offset = Mathf.Clamp(opening.Offset, -halfMargin, halfMargin); + if (!Mathf.Approximately(offset, opening.Offset)) + DebugLog.Warning( + $"[WallBuilder] {_currentSide} wall: door offset {opening.Offset:F2}m " + + $"exceeds wall margin (±{halfMargin:F2}m) and was clamped to {offset:F2}m."); + + // Positive offset shifts door toward positive axis (right/forward) + // Left (negative direction) gets bigger, right gets smaller + float leftWidth = halfMargin + offset; + float rightWidth = halfMargin - offset; + + // Door center shifted by offset along the wall axis + Vector3 doorShift = isVertical ? Vector3.forward * offset : Vector3.right * offset; // Left segment - Vector3 leftSize = isVertical - ? new Vector3(_wallThickness, wallHeight, sideWallWidth) - : new Vector3(sideWallWidth, wallHeight, _wallThickness); - GameObject left = PrimitiveBuilder.CreateBox($"{name}_Left", wallCenter + leftOffset, leftSize, _palette.WallColor, container.transform); - ApplyWallMaterial(left); + if (leftWidth > 0f) + { + float leftCenter = doorWidth / 2f + leftWidth / 2f; + Vector3 leftOffset = isVertical ? Vector3.back * leftCenter : Vector3.left * leftCenter; + Vector3 leftSize = isVertical + ? new Vector3(_wallThickness, wallHeight, leftWidth) + : new Vector3(leftWidth, wallHeight, _wallThickness); + CreateWallSegment($"{name}_Left", wallCenter + doorShift + leftOffset, leftSize, wallColor, wallMaterial, container.transform); + } // Right segment - Vector3 rightSize = leftSize; - GameObject right = PrimitiveBuilder.CreateBox($"{name}_Right", wallCenter + rightOffset, rightSize, _palette.WallColor, container.transform); - ApplyWallMaterial(right); + if (rightWidth > 0f) + { + float rightCenter = doorWidth / 2f + rightWidth / 2f; + Vector3 rightOffset = isVertical ? Vector3.forward * rightCenter : Vector3.right * rightCenter; + Vector3 rightSize = isVertical + ? new Vector3(_wallThickness, wallHeight, rightWidth) + : new Vector3(rightWidth, wallHeight, _wallThickness); + CreateWallSegment($"{name}_Right", wallCenter + doorShift + rightOffset, rightSize, wallColor, wallMaterial, container.transform); + } - // Top segment (above door) + // Top segment (wall above door) float topHeight = wallHeight - doorHeight; - Vector3 topSize = isVertical - ? new Vector3(_wallThickness, topHeight, doorWidth) - : new Vector3(doorWidth, topHeight, _wallThickness); - Vector3 topOffset = Vector3.up * (doorHeight / 2f + topHeight / 2f); - GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", wallCenter + topOffset, topSize, _palette.WallColor, container.transform); - ApplyWallMaterial(top); + if (topHeight > 0f) + { + Vector3 topSize = isVertical + ? new Vector3(_wallThickness, topHeight, doorWidth) + : new Vector3(doorWidth, topHeight, _wallThickness); + float topCenterY = wallHeight / 2f - topHeight / 2f; + Vector3 topOffset = Vector3.up * topCenterY; + CreateWallSegment($"{name}_Top", wallCenter + doorShift + topOffset, topSize, wallColor, wallMaterial, container.transform); + } return container; } - private GameObject CreateWallWithWindow(string name, Vector3 wallCenter, Vector3 wallSize, WallOpening opening, bool isVertical) + private GameObject CreateWallWithDoorAndWindows(string name, Vector3 wallCenter, Vector3 wallSize, WallOpening opening, bool isVertical, Color wallColor, Material? wallMaterial) { GameObject container = BuildingUtilities.CreateFolder(name, _wallsContainer!.transform); float wallWidth = isVertical ? wallSize.z : wallSize.x; float wallHeight = wallSize.y; - float windowWidth = Mathf.Min(opening.Width, wallWidth - 0.5f); - float windowHeight = Mathf.Min(opening.Height, wallHeight - 1.2f); - float windowBottom = opening.BottomOffset; - - float topHeight = wallHeight - (windowBottom + windowHeight); - float sideWidth = (wallWidth - windowWidth) / 2f; - float windowCenterY = (windowBottom + windowHeight / 2f) - (wallHeight / 2f); - - // Bottom segment (sill) - Vector3 bottomSize = isVertical - ? new Vector3(_wallThickness, windowBottom, wallWidth) - : new Vector3(wallWidth, windowBottom, _wallThickness); - Vector3 bottomOffset = Vector3.down * ((wallHeight / 2f) - (windowBottom / 2f)); - GameObject bottom = PrimitiveBuilder.CreateBox($"{name}_Bottom", wallCenter + bottomOffset, bottomSize, _palette.WallColor, container.transform); - ApplyWallMaterial(bottom); - - // Top segment (header) - Vector3 topSize = isVertical - ? new Vector3(_wallThickness, topHeight, wallWidth) - : new Vector3(wallWidth, topHeight, _wallThickness); - float topCenterY = (wallHeight / 2f) - (topHeight / 2f); - Vector3 topOffset = Vector3.up * topCenterY; - GameObject top = PrimitiveBuilder.CreateBox($"{name}_Top", wallCenter + topOffset, topSize, _palette.WallColor, container.transform); - ApplyWallMaterial(top); - - // Side segments - Vector3 sideSize = isVertical - ? new Vector3(_wallThickness, windowHeight, sideWidth) - : new Vector3(sideWidth, windowHeight, _wallThickness); - - Vector3 leftOffset = isVertical - ? new Vector3(0f, windowCenterY, (windowWidth / 2f + sideWidth / 2f)) - : new Vector3(-(windowWidth / 2f + sideWidth / 2f), windowCenterY, 0f); - GameObject leftSide = PrimitiveBuilder.CreateBox($"{name}_Left", wallCenter + leftOffset, sideSize, _palette.WallColor, container.transform); - ApplyWallMaterial(leftSide); - - Vector3 rightOffset = isVertical - ? new Vector3(0f, windowCenterY, -(windowWidth / 2f + sideWidth / 2f)) - : new Vector3((windowWidth / 2f + sideWidth / 2f), windowCenterY, 0f); - GameObject rightSide = PrimitiveBuilder.CreateBox($"{name}_Right", wallCenter + rightOffset, sideSize, _palette.WallColor, container.transform); - ApplyWallMaterial(rightSide); - - // Window frame - CreateWindowFrame(container.transform, wallCenter, windowWidth, windowHeight, windowCenterY, isVertical); - - // Glass pane - Vector3 glassSize = isVertical - ? new Vector3(_wallThickness * 0.2f, windowHeight - 0.1f, windowWidth - 0.1f) - : new Vector3(windowWidth - 0.1f, windowHeight - 0.1f, _wallThickness * 0.2f); - Vector3 glassOffset = new Vector3(0f, windowCenterY, 0f); - GameObject glass = PrimitiveBuilder.CreateBox($"{name}_WindowGlass", wallCenter + glassOffset, glassSize, new Color(0.7f, 0.9f, 1f), container.transform); - - // Apply glass material - Material glassMat = Materials.LaundromatGlass; - if (glassMat != null) + float doorWidth = opening.Width; + float doorHeight = opening.Height; + float offset = opening.Offset; + + float leftSideWidth = (wallWidth - doorWidth) / 2f + offset; + float rightSideWidth = (wallWidth - doorWidth) / 2f - offset; + + Vector3 doorShift = isVertical ? Vector3.forward * offset : Vector3.right * offset; + Vector3 shiftedCenter = wallCenter + doorShift; + + // Left side (may contain a window) + if (leftSideWidth > 0f) { - Renderer r = glass.GetComponent(); - if (r != null) r.material = glassMat; + BuildDoorSideSegment(name, "_Left", shiftedCenter, doorWidth, wallHeight, + leftSideWidth, opening.LeftWindow, isVertical, true, container.transform, wallColor, wallMaterial); } + // Right side (may contain a window) + if (rightSideWidth > 0f) + { + BuildDoorSideSegment(name, "_Right", shiftedCenter, doorWidth, wallHeight, + rightSideWidth, opening.RightWindow, isVertical, false, container.transform, wallColor, wallMaterial); + } + + // Top segment (wall above door) — same as CreateWallWithDoor + float topHeight = wallHeight - doorHeight; + if (topHeight > 0f) + { + Vector3 topSize = isVertical + ? new Vector3(_wallThickness, topHeight, doorWidth) + : new Vector3(doorWidth, topHeight, _wallThickness); + float topCenterY = wallHeight / 2f - topHeight / 2f; + Vector3 topOffset = Vector3.up * topCenterY; + CreateWallSegment($"{name}_Top", shiftedCenter + topOffset, topSize, wallColor, wallMaterial, container.transform); + } + + return container; + } + + private void BuildDoorSideSegment( + string wallName, string suffix, Vector3 wallCenter, + float doorWidth, float wallHeight, float fullSideWidth, + WallOpening? sideWindow, bool isVertical, bool isLeftSide, Transform parent, + Color wallColor, Material? wallMaterial) + { + float dirSign = isLeftSide ? -1f : 1f; + + if (sideWindow == null || fullSideWidth < Constants.Window.MinDoorSideWidth) + { + // No window — create single solid segment (same as CreateWallWithDoor) + Vector3 offset = isVertical + ? new Vector3(0f, 0f, dirSign * (doorWidth / 2f + fullSideWidth / 2f)) + : new Vector3(dirSign * (doorWidth / 2f + fullSideWidth / 2f), 0f, 0f); + Vector3 size = isVertical + ? new Vector3(_wallThickness, wallHeight, fullSideWidth) + : new Vector3(fullSideWidth, wallHeight, _wallThickness); + CreateWallSegment($"{wallName}{suffix}", wallCenter + offset, size, wallColor, wallMaterial, parent); + return; + } + + // With window: small strip adjacent to the door (for InsetDoorWallSegments), window section gets the rest + float stripWidth = Mathf.Min(Constants.Window.MaxDoorStripWidth, fullSideWidth - sideWindow.Width - Constants.Window.DoorStripMargin); + stripWidth = Mathf.Max(Constants.Window.MinDoorStripWidth, stripWidth); + float windowSectionWidth = fullSideWidth - stripWidth; + + // Solid strip next to the door (keeps {wallName}_Left / _Right name for InsetDoorWallSegments) + float stripCenterOffset = doorWidth / 2f + stripWidth / 2f; + Vector3 stripOffset = isVertical + ? new Vector3(0f, 0f, dirSign * stripCenterOffset) + : new Vector3(dirSign * stripCenterOffset, 0f, 0f); + Vector3 stripSize = isVertical + ? new Vector3(_wallThickness, wallHeight, stripWidth) + : new Vector3(stripWidth, wallHeight, _wallThickness); + CreateWallSegment($"{wallName}{suffix}", wallCenter + stripOffset, stripSize, wallColor, wallMaterial, parent); + + // Window section in the remaining area (no overlap with strip so InsetDoorWallSegments works) + float winSectionCenterOffset = doorWidth / 2f + stripWidth + windowSectionWidth / 2f; + Vector3 winSectionCenter = wallCenter + (isVertical + ? new Vector3(0f, 0f, dirSign * winSectionCenterOffset) + : new Vector3(dirSign * winSectionCenterOffset, 0f, 0f)); + + // Shift window toward door by stripWidth/2 so it centers in the full side width + float windowOffset = -dirSign * stripWidth / 2f; + CreateWindowInSection($"{wallName}{suffix}Win", winSectionCenter, windowSectionWidth, wallHeight, + sideWindow, isVertical, parent, windowOffset, wallColor, wallMaterial); + } + + private GameObject CreateWallWithWindow(string name, Vector3 wallCenter, Vector3 wallSize, WallOpening opening, bool isVertical, Color wallColor, Material? wallMaterial) + { + GameObject container = BuildingUtilities.CreateFolder(name, _wallsContainer!.transform); + float wallWidth = isVertical ? wallSize.z : wallSize.x; + CreateWindowInSection(name, wallCenter, wallWidth, wallSize.y, opening, isVertical, container.transform, opening.Offset, wallColor, wallMaterial); return container; } - private void CreateWindowFrame(Transform parent, Vector3 wallCenter, float windowWidth, float windowHeight, float windowCenterY, bool isVertical) + private void CreateWindowInSection( + string namePrefix, Vector3 sectionCenter, + float sectionWidth, float sectionHeight, + WallOpening window, bool isVertical, Transform parent, + float windowOffset, Color wallColor, Material? wallMaterial) + { + int paneCount = Mathf.Max(1, window.Count); + float windowWidth; + if (paneCount > 1) + { + // Multi-pane: Width is per-pane width (capped), equal gaps for sides and dividers + float perPane = Mathf.Min(window.Width, Mathf.Min(sectionWidth - Constants.Window.SideMargin, Constants.Window.MaxPaneWidth)); + float equalGap = (sectionWidth - paneCount * perPane) / (paneCount + 1); + + // Enforce minimum gap; shrink panes if needed + if (equalGap < Constants.Window.MinGap) + { + equalGap = Constants.Window.MinGap; + perPane = (sectionWidth - (paneCount + 1) * equalGap) / paneCount; + } + + // Auto-reduce count if panes would be too narrow + while (perPane < Constants.Window.MinPaneWidth && paneCount > 1) + { + paneCount--; + equalGap = (sectionWidth - paneCount * perPane) / (paneCount + 1); + if (equalGap < Constants.Window.MinGap) + { + equalGap = Constants.Window.MinGap; + perPane = (sectionWidth - (paneCount + 1) * equalGap) / paneCount; + } + } + + // Band spans all panes + inner gaps; outer gaps become sideWidth + windowWidth = paneCount * perPane + (paneCount - 1) * equalGap; + } + else + { + windowWidth = Mathf.Min(window.Width, sectionWidth - Constants.Window.SideMargin); + } + + float windowHeight = Mathf.Min(window.Height, sectionHeight - Constants.Window.VerticalMargin); + float windowBottom = window.BottomOffset; + + float topHeight = sectionHeight - (windowBottom + windowHeight); + float sideWidth = (sectionWidth - windowWidth) / 2f; + float halfHeight = sectionHeight / 2f; + float windowCenterY = (windowBottom + windowHeight / 2f) - halfHeight; + + // Clamp window offset so the window cannot extend past the section bounds + float clampedOffset = Mathf.Clamp(windowOffset, -sideWidth, sideWidth); + if (!Mathf.Approximately(clampedOffset, windowOffset)) + DebugLog.Warning( + $"[WallBuilder] {_currentSide} wall: window offset {windowOffset:F2}m " + + $"exceeds section margin (±{sideWidth:F2}m) and was clamped to {clampedOffset:F2}m."); + + // Window center shifted along wall axis (positive = +Z for vertical, +X for horizontal) + Vector3 winShift = isVertical + ? new Vector3(0f, 0f, clampedOffset) + : new Vector3(clampedOffset, 0f, 0f); + Vector3 windowCenter = sectionCenter + winShift; + + // Bottom segment (sill) — full section width, no shift + if (windowBottom > Constants.Window.SegmentThreshold) + { + Vector3 bottomSize = isVertical + ? new Vector3(_wallThickness, windowBottom, sectionWidth) + : new Vector3(sectionWidth, windowBottom, _wallThickness); + Vector3 bottomOffset = Vector3.down * (halfHeight - windowBottom / 2f); + CreateWallSegment($"{namePrefix}_Bottom", sectionCenter + bottomOffset, bottomSize, wallColor, wallMaterial, parent); + } + + // Top segment (header) — full section width, no shift + if (topHeight > Constants.Window.SegmentThreshold) + { + Vector3 topSize = isVertical + ? new Vector3(_wallThickness, topHeight, sectionWidth) + : new Vector3(sectionWidth, topHeight, _wallThickness); + Vector3 topOffset = Vector3.up * (halfHeight - topHeight / 2f); + CreateWallSegment($"{namePrefix}_Top", sectionCenter + topOffset, topSize, wallColor, wallMaterial, parent); + } + + // Side segments — asymmetric widths when window is offset + // For isVertical: +Z side = sideWidth - offset, -Z side = sideWidth + offset + // For non-vertical: -X side = sideWidth + offset, +X side = sideWidth - offset + float posSideWidth = sideWidth - clampedOffset; + float negSideWidth = sideWidth + clampedOffset; + float leftSideWidth = isVertical ? posSideWidth : negSideWidth; + float rightSideWidth = isVertical ? negSideWidth : posSideWidth; + + if (leftSideWidth > Constants.Window.SegmentThreshold) + { + Vector3 leftSize = isVertical + ? new Vector3(_wallThickness, windowHeight, leftSideWidth) + : new Vector3(leftSideWidth, windowHeight, _wallThickness); + Vector3 leftOffset = isVertical + ? new Vector3(0f, windowCenterY, windowWidth / 2f + leftSideWidth / 2f) + : new Vector3(-(windowWidth / 2f + leftSideWidth / 2f), windowCenterY, 0f); + CreateWallSegment($"{namePrefix}_Left", windowCenter + leftOffset, leftSize, wallColor, wallMaterial, parent); + } + + if (rightSideWidth > Constants.Window.SegmentThreshold) + { + Vector3 rightSize = isVertical + ? new Vector3(_wallThickness, windowHeight, rightSideWidth) + : new Vector3(rightSideWidth, windowHeight, _wallThickness); + Vector3 rightOffset = isVertical + ? new Vector3(0f, windowCenterY, -(windowWidth / 2f + rightSideWidth / 2f)) + : new Vector3(windowWidth / 2f + rightSideWidth / 2f, windowCenterY, 0f); + CreateWallSegment($"{namePrefix}_Right", windowCenter + rightOffset, rightSize, wallColor, wallMaterial, parent); + } + + // Multi-pane window rendering + // For count > 1: divider width == sideWidth (equal gaps by construction) + float dividerW = paneCount > 1 ? sideWidth : window.DividerWidth; + float paneWidth = paneCount > 1 + ? (windowWidth - (paneCount - 1) * dividerW) / paneCount + : windowWidth; + + float bandStart = -windowWidth / 2f + paneWidth / 2f; + Color frameColor = window.FrameColor ?? FrameColor; + + for (int i = 0; i < paneCount; i++) + { + float paneOffset = bandStart + i * (paneWidth + dividerW); + + Vector3 paneShift = isVertical + ? new Vector3(0f, 0f, paneOffset) + : new Vector3(paneOffset, 0f, 0f); + Vector3 paneCenter = windowCenter + paneShift; + + // Frame for this pane + string framePrefix = paneCount > 1 ? $"Frame{i}_" : "Frame"; + CreateWindowFrame(parent, paneCenter, paneWidth, windowHeight, windowCenterY, isVertical, frameColor, window.FrameMaterial, framePrefix); + + // Glass pane + Vector3 glassSize = isVertical + ? new Vector3(_wallThickness * 0.2f, windowHeight - 0.1f, paneWidth - 0.1f) + : new Vector3(paneWidth - 0.1f, windowHeight - 0.1f, _wallThickness * 0.2f); + + string paneSuffix = paneCount > 1 ? $"_{i}" : ""; + GameObject glass = PrimitiveBuilder.CreateBox( + $"{namePrefix}_WindowGlass{paneSuffix}", + paneCenter + new Vector3(0f, windowCenterY, 0f), + glassSize, GlassTint, parent); + + Material glassMat = window.GlassMaterial ?? Materials.WindowGlass; + if (glassMat != null) + { + Renderer r = glass.GetComponent(); + if (r != null) r.material = glassMat; + } + + // Divider wall between this pane and the next + if (i < paneCount - 1) + { + float dividerOffset = paneOffset + paneWidth / 2f + dividerW / 2f; + Vector3 dividerShift = isVertical + ? new Vector3(0f, 0f, dividerOffset) + : new Vector3(dividerOffset, 0f, 0f); + Vector3 dividerSize = isVertical + ? new Vector3(_wallThickness, windowHeight, dividerW) + : new Vector3(dividerW, windowHeight, _wallThickness); + + CreateWallSegment( + $"{namePrefix}_Divider_{i}", + windowCenter + dividerShift + new Vector3(0f, windowCenterY, 0f), + dividerSize, wallColor, wallMaterial, parent); + } + } + } + + private void CreateWindowFrame(Transform parent, Vector3 wallCenter, float windowWidth, float windowHeight, float windowCenterY, bool isVertical, Color color, Material? material = null, string namePrefix = "Frame") { - Color frameColor = new Color(0.1f, 0.1f, 0.1f); - float frameDepth = 0.05f; - float frameWidth = 0.1f; + float frameDepth = Constants.Window.FrameDepth; + float frameWidth = Constants.Window.FrameWidth; // Top frame Vector3 topFrameSize = isVertical ? new Vector3(_wallThickness + frameDepth, frameWidth, windowWidth) : new Vector3(windowWidth, frameWidth, _wallThickness + frameDepth); - PrimitiveBuilder.CreateBox("FrameTop", wallCenter + new Vector3(0f, windowCenterY + windowHeight / 2f - frameWidth / 2f, 0f), topFrameSize, frameColor, parent); + GameObject top = PrimitiveBuilder.CreateBox($"{namePrefix}Top", wallCenter + new Vector3(0f, windowCenterY + windowHeight / 2f - frameWidth / 2f, 0f), topFrameSize, color, parent); // Bottom frame - PrimitiveBuilder.CreateBox("FrameBottom", wallCenter + new Vector3(0f, windowCenterY - windowHeight / 2f + frameWidth / 2f, 0f), topFrameSize, frameColor, parent); + GameObject bottom = PrimitiveBuilder.CreateBox($"{namePrefix}Bottom", wallCenter + new Vector3(0f, windowCenterY - windowHeight / 2f + frameWidth / 2f, 0f), topFrameSize, color, parent); // Side frames Vector3 sideFrameSize = isVertical @@ -340,17 +699,139 @@ private void CreateWindowFrame(Transform parent, Vector3 wallCenter, float windo Vector3 leftFrameOffset = isVertical ? new Vector3(0f, windowCenterY, sideOffset) : new Vector3(-sideOffset, windowCenterY, 0f); Vector3 rightFrameOffset = isVertical ? new Vector3(0f, windowCenterY, -sideOffset) : new Vector3(sideOffset, windowCenterY, 0f); - PrimitiveBuilder.CreateBox("FrameLeft", wallCenter + leftFrameOffset, sideFrameSize, frameColor, parent); - PrimitiveBuilder.CreateBox("FrameRight", wallCenter + rightFrameOffset, sideFrameSize, frameColor, parent); + GameObject left = PrimitiveBuilder.CreateBox($"{namePrefix}Left", wallCenter + leftFrameOffset, sideFrameSize, color, parent); + GameObject right = PrimitiveBuilder.CreateBox($"{namePrefix}Right", wallCenter + rightFrameOffset, sideFrameSize, color, parent); + + if (material != null) + { + ApplyFrameMaterial(top, material); + ApplyFrameMaterial(bottom, material); + ApplyFrameMaterial(left, material); + ApplyFrameMaterial(right, material); + } + } + + private Color GetWallColor(WallSide side) + { + if (_wallOverrides != null && + _wallOverrides.TryGetValue(side, out WallAppearance? appearance) && + appearance.Color.HasValue) + { + return appearance.Color.Value; + } + return _palette.WallColor; } - private void ApplyWallMaterial(GameObject wall) + private Material? GetWallMaterial(WallSide side) { - if (_palette.WallMaterial != null) + if (_wallOverrides != null && + _wallOverrides.TryGetValue(side, out WallAppearance? appearance) && + appearance.Material != null) + { + return appearance.Material; + } + return _palette.WallMaterial; + } + + private void ApplyWallMaterial(GameObject wall, Material? material) + { + if (material != null) { Renderer r = wall.GetComponent(); - if (r != null) r.material = _palette.WallMaterial; + if (r != null) r.material = material; + } + } + + private void ApplyFrameMaterial(GameObject frame, Material material) + { + Renderer r = frame.GetComponent(); + if (r != null) r.material = material; + } + + private Material? GetWallInteriorMaterial(WallSide side) + { + if (_wallOverrides != null && + _wallOverrides.TryGetValue(side, out WallAppearance? appearance) && + appearance.InteriorMaterial != null) + { + return appearance.InteriorMaterial; } + return _palette.InteriorWallMaterial; + } + + private Color GetWallInteriorColor(WallSide side) + { + if (_wallOverrides != null && + _wallOverrides.TryGetValue(side, out WallAppearance? appearance) && + appearance.InteriorColor.HasValue) + { + return appearance.InteriorColor.Value; + } + return _palette.InteriorWallColor ?? _palette.WallColor; + } + + private bool HasExplicitInteriorColor(WallSide side) + { + if (_wallOverrides != null && + _wallOverrides.TryGetValue(side, out WallAppearance? appearance) && + appearance.InteriorColor.HasValue) + return true; + return _palette.InteriorWallColor.HasValue; + } + + /// + /// Unified wall segment creation. When an interior override is active, + /// creates a dual-material mesh; otherwise delegates to the standard + /// path. + /// + private GameObject CreateWallSegment(string name, Vector3 position, Vector3 size, + Color wallColor, Material? wallMaterial, Transform parent) + { + if (_hasInteriorOverride) + return CreateDualMaterialBox(name, position, size, wallColor, wallMaterial, parent); + + GameObject wall = PrimitiveBuilder.CreateBox(name, position, size, wallColor, parent); + ApplyWallMaterial(wall, wallMaterial); + return wall; + } + + /// + /// Creates a wall segment with a dual-submesh mesh so the interior (room-facing) face + /// can use a different material from the exterior and edge faces. + /// + private GameObject CreateDualMaterialBox(string name, Vector3 position, Vector3 size, + Color exteriorColor, Material? exteriorMaterial, Transform parent) + { + var interiorFace = _currentSide switch + { + WallSide.North => DualMaterialBoxGenerator.InteriorFace.NegZ, + WallSide.South => DualMaterialBoxGenerator.InteriorFace.PosZ, + WallSide.East => DualMaterialBoxGenerator.InteriorFace.NegX, + WallSide.West => DualMaterialBoxGenerator.InteriorFace.PosX, + _ => DualMaterialBoxGenerator.InteriorFace.NegZ + }; + + Mesh mesh = DualMaterialBoxGenerator.Generate(interiorFace); + + GameObject go = new GameObject(name); + go.transform.SetParent(parent); + go.transform.localPosition = position; + go.transform.localScale = size; + + MeshFilter mf = go.AddComponent(); + mf.sharedMesh = mesh; + + MeshRenderer mr = go.AddComponent(); + mr.allowOcclusionWhenDynamic = false; + + Material extMat = exteriorMaterial ?? MaterialPresets.Opaque(exteriorColor); + Material intMat = _currentInteriorMaterial ?? MaterialPresets.Opaque(_currentInteriorColor); + + mr.materials = new Material[] { extMat, intMat }; + + go.AddComponent(); + + return go; } #endregion diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index 49bec69..5dde003 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -1,8 +1,9 @@ # Coding Standards S1MAPI is a mesh and building construction library for Schedule 1 mods. -The core principle: **avoid ScheduleOne types** to remain resilient across game updates. -S1MAPI uses Unity primitives and FishNet only—no `Assembly-CSharp` references. +The core principle: **no compile-time dependencies on ScheduleOne types** to remain resilient across game updates. +S1MAPI uses Unity primitives and FishNet only—no `Assembly-CSharp` imports. Where game-type +interaction is unavoidable, use reflection with graceful fallbacks. ## General Best Practice * Review the codebase thoroughly before submitting a PR. @@ -170,8 +171,10 @@ public ProceduralMeshBuilder AddBox(...) { ... } * Group related members together using regions. ## What **NOT** to Do -* **Do not** reference ScheduleOne types (`Assembly-CSharp.dll`). - This is the core rule—S1MAPI must remain update-resilient. +* **Do not** add compile-time references to ScheduleOne types (`Assembly-CSharp.dll`). + S1MAPI must remain update-resilient—no `using ScheduleOne.*` imports or direct type usage. + When interaction with game types is unavoidable (e.g. NPC navigation), use **reflection** + with graceful fallbacks so the code degrades safely if the game changes. * **Do not** use magic strings—prefer enums or constants. * **Do not** ignore compiler warnings. * **Do not** leave commented-out code in commits. diff --git a/Core/PrefabRef.cs b/Core/PrefabRef.cs index 3b119e4..7289411 100644 --- a/Core/PrefabRef.cs +++ b/Core/PrefabRef.cs @@ -84,7 +84,7 @@ public PrefabRef(string name) /// /// WARNING: Only use this method for prefabs that do NOT have a NetworkObject component. /// - /// For prefabs with NetworkObject components (networked prefabs), you MUST use instead. + /// For prefabs with NetworkObject components (networked prefabs), you MUST use instead. /// Using this method on networked prefabs will cause FishNet to crash and break multiplayer functionality. /// /// @@ -139,6 +139,43 @@ public PrefabRef(string name) /// Thrown if called on client when not server public GameObject? InstantiateNetworked() { + return InstantiateNetworkedCore(null, Vector3.zero, Quaternion.identity, activate: true); + } + + /// + /// Instantiate and spawn a networked prefab, positioned before network spawn. + /// Sets the transform BEFORE calling FishNet Spawn() so that clients receive + /// the correct world position via replication (critical for prefabs without NetworkTransform). + /// + /// Parent transform (set before spawn) + /// Local position relative to parent + /// Local rotation relative to parent + /// The instantiated and network-spawned GameObject, or null if not server or prefab not found + public GameObject? InstantiateNetworked(Transform parent, Vector3 localPosition, Quaternion localRotation) + { + return InstantiateNetworkedCore(parent, localPosition, localRotation, activate: true); + } + + /// + /// Instantiate and spawn a networked prefab without activating it. + /// The caller is responsible for calling SetActive(true) after configuration. + /// Used by to invoke onReady callbacks + /// before Awake/OnEnable fire (prevents sensors from triggering with default config). + /// + internal GameObject? InstantiateNetworkedInactive(Transform parent, Vector3 localPosition, Quaternion localRotation) + { + return InstantiateNetworkedCore(parent, localPosition, localRotation, activate: false); + } + + private GameObject? InstantiateNetworkedCore(Transform? parent, Vector3 localPosition, Quaternion localRotation, bool activate) + { + var nm = InstanceFinder.NetworkManager; + if (nm == null || !nm.IsServer) + { + DebugLog.Warning($"[PrefabRef] InstantiateNetworked called but not server — skipping '{Name}'."); + return null; + } + var prefab = Find(); if (prefab == null) { @@ -146,37 +183,40 @@ public PrefabRef(string name) return null; } - // Store original active state bool originalState = prefab.activeSelf; - - // Temporarily disable the prefab to prevent Awake() during instantiation prefab.SetActive(false); - - // Instantiate with components inactive GameObject? instance = UnityEngine.Object.Instantiate(prefab); - - // Restore prefab's original state prefab.SetActive(originalState); if (instance == null) return null; - // Initialize GUID fields on components before Awake() runs - // This fixes prefabs like ATM that parse GUIDs in Awake() InitializeGuidFields(instance); - // Spawn on network (assigns network GUID and calls OnStartServer) - if (InstanceFinder.NetworkManager != null && InstanceFinder.NetworkManager.IsServer) + // Set world position before Spawn() so FishNet broadcasts the correct transform. + // Parent AFTER Spawn to avoid issues with non-networked parent hierarchies. + if (parent != null) { - var netObj = instance.GetComponent(); - if (netObj != null) - { - InstanceFinder.NetworkManager.ServerManager.Spawn(netObj); - } + instance.transform.position = parent.TransformPoint(localPosition); + instance.transform.rotation = parent.rotation * localRotation; } - // Now activate the instance - Awake() will run with valid GUIDs - instance.SetActive(true); - + var netObj = instance.GetComponent(); + if (netObj != null) + { + nm.ServerManager.Spawn(netObj); + } + + // Parent after spawn (local operation, not replicated) + if (parent != null) + { + instance.transform.SetParent(parent); + instance.transform.localPosition = localPosition; + instance.transform.localRotation = localRotation; + } + + if (activate) + instance.SetActive(true); + return instance; } diff --git a/Extensions/GameObjectExtensions.cs b/Extensions/GameObjectExtensions.cs index 31a05df..f615b9b 100644 --- a/Extensions/GameObjectExtensions.cs +++ b/Extensions/GameObjectExtensions.cs @@ -60,9 +60,9 @@ public static GameObject Show(this GameObject gameObject) public static GameObject SetLayerRecursively(this GameObject gameObject, int layer) { gameObject.layer = layer; - foreach (Transform child in gameObject.transform) + for (int i = 0; i < gameObject.transform.childCount; i++) { - SetLayerRecursively(child.gameObject, layer); + SetLayerRecursively(gameObject.transform.GetChild(i).gameObject, layer); } return gameObject; } diff --git a/Extensions/TransformExtensions.cs b/Extensions/TransformExtensions.cs index 48077d0..8722d89 100644 --- a/Extensions/TransformExtensions.cs +++ b/Extensions/TransformExtensions.cs @@ -37,9 +37,9 @@ public static Transform SetLocalPosition(this Transform transform, float? x = nu /// public static void DestroyChildren(this Transform transform) { - foreach (Transform child in transform) + for (int i = 0; i < transform.childCount; i++) { - Object.Destroy(child.gameObject); + Object.Destroy(transform.GetChild(i).gameObject); } } diff --git a/ProceduralMesh/CustomMeshBuilder.cs b/ProceduralMesh/CustomMeshBuilder.cs index 4ae0154..72181ec 100644 --- a/ProceduralMesh/CustomMeshBuilder.cs +++ b/ProceduralMesh/CustomMeshBuilder.cs @@ -195,7 +195,9 @@ public GameObject Build() GameObject go = new GameObject(_name); go.AddComponent().mesh = mesh; - go.AddComponent().material = _material ?? MaterialPresets.Opaque(Color.white); + var renderer = go.AddComponent(); + renderer.material = _material ?? MaterialPresets.Opaque(Color.white); + renderer.renderingLayerMask = uint.MaxValue; DebugLog.Info($"Created GameObject: {_name}"); return go; diff --git a/ProceduralMesh/Generators/Primitives/DualMaterialBoxGenerator.cs b/ProceduralMesh/Generators/Primitives/DualMaterialBoxGenerator.cs new file mode 100644 index 0000000..195ba49 --- /dev/null +++ b/ProceduralMesh/Generators/Primitives/DualMaterialBoxGenerator.cs @@ -0,0 +1,134 @@ +using UnityEngine; + +namespace S1MAPI.ProceduralMesh.Generators.Primitives +{ + /// + /// Generates a unit-cube mesh (-0.5 to 0.5) with two submeshes: one for the + /// interior face and one for all other faces (exterior + edges). + /// Used by WallBuilder when an interior material is specified so that + /// interior and exterior wall faces can have different materials. + /// + internal static class DualMaterialBoxGenerator + { + /// Which face of the unit cube is the interior (room-facing) side. + internal enum InteriorFace + { + /// -Z face (north wall interior). + NegZ, + /// +Z face (south wall interior). + PosZ, + /// -X face (east wall interior). + NegX, + /// +X face (west wall interior). + PosX + } + + private static readonly Mesh?[] Cache = new Mesh?[4]; + + /// + /// Get a cached unit-cube mesh with 2 submeshes for the given interior face. + /// Submesh 0 = exterior (5 faces), submesh 1 = interior (1 face). + /// + internal static Mesh Generate(InteriorFace interiorFace) + { + int index = (int)interiorFace; + Mesh? cached = Cache[index]; + if (cached != null) + return cached; + + Mesh mesh = BuildMesh(interiorFace); + Cache[index] = mesh; + return mesh; + } + + private static Mesh BuildMesh(InteriorFace interiorFace) + { + // 6 faces × 4 vertices = 24 vertices with per-face normals and UVs + Vector3[] vertices = new Vector3[24]; + Vector3[] normals = new Vector3[24]; + Vector2[] uvs = new Vector2[24]; + + // Face definitions: (normal, tangent axis 1, tangent axis 2, center offset) + // Each face has 4 verts at corners: center ± t1*0.5 ± t2*0.5 + var faces = new[] + { + (normal: Vector3.right, t1: Vector3.forward, t2: Vector3.up, offset: Vector3.right * 0.5f), // +X + (normal: Vector3.left, t1: Vector3.back, t2: Vector3.up, offset: Vector3.left * 0.5f), // -X + (normal: Vector3.up, t1: Vector3.right, t2: Vector3.forward, offset: Vector3.up * 0.5f), // +Y + (normal: Vector3.down, t1: Vector3.right, t2: Vector3.back, offset: Vector3.down * 0.5f), // -Y + (normal: Vector3.forward, t1: Vector3.left, t2: Vector3.up, offset: Vector3.forward * 0.5f), // +Z + (normal: Vector3.back, t1: Vector3.right, t2: Vector3.up, offset: Vector3.back * 0.5f) // -Z + }; + + // Map InteriorFace enum to face index + int interiorFaceIndex = interiorFace switch + { + InteriorFace.PosX => 0, // +X + InteriorFace.NegX => 1, // -X + InteriorFace.PosZ => 4, // +Z + InteriorFace.NegZ => 5, // -Z + _ => 5 + }; + + int[] exteriorTris = new int[30]; // 5 faces × 6 indices + int[] interiorTris = new int[6]; // 1 face × 6 indices + int ei = 0, ii = 0; + + for (int f = 0; f < 6; f++) + { + int vi = f * 4; + var (normal, t1, t2, offset) = faces[f]; + + vertices[vi + 0] = offset - t1 * 0.5f - t2 * 0.5f; + vertices[vi + 1] = offset + t1 * 0.5f - t2 * 0.5f; + vertices[vi + 2] = offset + t1 * 0.5f + t2 * 0.5f; + vertices[vi + 3] = offset - t1 * 0.5f + t2 * 0.5f; + + normals[vi + 0] = normal; + normals[vi + 1] = normal; + normals[vi + 2] = normal; + normals[vi + 3] = normal; + + uvs[vi + 0] = new Vector2(0f, 0f); + uvs[vi + 1] = new Vector2(1f, 0f); + uvs[vi + 2] = new Vector2(1f, 1f); + uvs[vi + 3] = new Vector2(0f, 1f); + + if (f == interiorFaceIndex) + { + interiorTris[ii++] = vi + 0; + interiorTris[ii++] = vi + 2; + interiorTris[ii++] = vi + 1; + interiorTris[ii++] = vi + 0; + interiorTris[ii++] = vi + 3; + interiorTris[ii++] = vi + 2; + } + else + { + exteriorTris[ei++] = vi + 0; + exteriorTris[ei++] = vi + 2; + exteriorTris[ei++] = vi + 1; + exteriorTris[ei++] = vi + 0; + exteriorTris[ei++] = vi + 3; + exteriorTris[ei++] = vi + 2; + } + } + + Mesh mesh = new Mesh + { + name = $"DualMaterialBox_{interiorFace}", + vertices = vertices, + normals = normals, + uv = uvs, + subMeshCount = 2 + }; + + mesh.SetTriangles(exteriorTris, 0); + mesh.SetTriangles(interiorTris, 1); + mesh.RecalculateBounds(); + mesh.RecalculateTangents(); + + return mesh; + } + } +} diff --git a/ProceduralMesh/PrimitiveBuilder.cs b/ProceduralMesh/PrimitiveBuilder.cs index 3ed2171..e4f99b8 100644 --- a/ProceduralMesh/PrimitiveBuilder.cs +++ b/ProceduralMesh/PrimitiveBuilder.cs @@ -254,15 +254,25 @@ public static GameObject CreateCylinder( #region Public API - Lighting /// - /// Create a point light + /// Create a point light with URP-correct defaults matching Schedule I's pipeline. /// + /// GameObject name + /// Local position relative to parent + /// Light color + /// Light range in meters + /// Light intensity + /// Optional parent transform + /// Shadow mode (default: None to match game's URP settings) + /// Render mode (default: ForcePixel for URP per-pixel lighting) public static GameObject CreatePointLight( string name, Vector3 localPosition, Color color, float range, float intensity, - Transform? parent = null) + Transform? parent = null, + LightShadows shadows = LightShadows.None, + LightRenderMode renderMode = LightRenderMode.ForcePixel) { GameObject lightObj = new GameObject(name); if (parent != null) @@ -276,8 +286,9 @@ public static GameObject CreatePointLight( light.color = color; light.range = range; light.intensity = intensity; - light.shadows = LightShadows.Soft; - + light.shadows = shadows; + light.renderMode = renderMode; + return lightObj; } diff --git a/ProceduralMesh/ProceduralMeshBuilder.cs b/ProceduralMesh/ProceduralMeshBuilder.cs index c36e383..c72945f 100644 --- a/ProceduralMesh/ProceduralMeshBuilder.cs +++ b/ProceduralMesh/ProceduralMeshBuilder.cs @@ -218,6 +218,7 @@ public GameObject Build() GameObject go = new GameObject(_name); MeshFilter filter = go.AddComponent(); MeshRenderer renderer = go.AddComponent(); + renderer.renderingLayerMask = uint.MaxValue; filter.mesh = mesh; diff --git a/S1MAPI.csproj b/S1MAPI.csproj index 43e0729..40fcb36 100644 --- a/S1MAPI.csproj +++ b/S1MAPI.csproj @@ -27,6 +27,7 @@ netstandard2.1 + S1MAPI_Mono MONO true @@ -34,6 +35,7 @@ net6.0 + S1MAPI_Il2cpp IL2CPP false @@ -89,6 +91,14 @@ $(MonoAssembliesPath)\UnityEngine.InputLegacyModule.dll + + + $(MonoAssembliesPath)\UnityEngine.TerrainModule.dll + + + $(MonoAssembliesPath)\UnityEngine.TerrainPhysicsModule.dll + + $(MonoAssembliesPath)\Newtonsoft.Json.dll @@ -98,6 +108,11 @@ $(MonoAssembliesPath)\FishNet.Runtime.dll False + + + $(MonoMLPath)\0Harmony.dll + False + @@ -151,6 +166,14 @@ $(Il2CppAssembliesPath)\UnityEngine.InputLegacyModule.dll + + + $(Il2CppAssembliesPath)\UnityEngine.TerrainModule.dll + + + $(Il2CppAssembliesPath)\UnityEngine.TerrainPhysicsModule.dll + + $(Il2CppAssembliesPath)\Newtonsoft.Json.dll @@ -169,6 +192,11 @@ $(Il2CppAssembliesPath)\Il2CppFishNet.Runtime.dll False + + + $(Il2CppMLAssembliesPath)\0Harmony.dll + False + diff --git a/Utils/Constants.cs b/Utils/Constants.cs index 31d1e97..0a4b23d 100644 --- a/Utils/Constants.cs +++ b/Utils/Constants.cs @@ -24,7 +24,9 @@ internal static class Constants /// public static class Layers { + /// Default Unity layer. public const string DEFAULT = "Default"; + /// Ignore Raycast Unity layer. public const string IGNORE_RAYCAST = "Ignore Raycast"; } @@ -33,6 +35,7 @@ public static class Layers /// public static class Tags { + /// Default Unity tag for untagged objects. public const string UNTAGGED = "Untagged"; } @@ -86,6 +89,26 @@ public static class Materials /// Alpha value for general glass materials /// public const float GlassAlpha = 0.3f; + + /// + /// Scene material name for closed riser tread planks. + /// + public const string TreadWoodName = "wood brown"; + + /// + /// Scene material name for closed riser faces. + /// + public const string RiserWoodName = "wood_beige"; + + /// + /// Scene material name for open stringer tread planks. + /// + public const string StringerTreadWoodName = "mansion_brownwood_mat"; + + /// + /// Scene material name for open stringer diagonal beams. + /// + public const string StringerBeamWoodName = "wood brown"; } /// @@ -108,6 +131,328 @@ public static class Spatial /// Default grid cell size for snapping operations /// public const float DefaultGridSize = 0.5f; + + /// + /// Default maximum step height for generated stairs. + /// Kept below typical CharacterController stepOffset (~0.3m) for reliable climbing. + /// + public const float DefaultMaxStepHeight = 0.20f; + + /// + /// Default step depth (tread) for generated stairs in meters. + /// + public const float DefaultStepDepth = 0.3f; + + /// + /// Base padding around the foundation block in meters. + /// Used by DecorBuilder to offset the foundation beyond room bounds + /// and by BuildingBuilder to compute stair clearance for navigation. + /// + public const float FoundationPadding = 0.1f; + + /// + /// GameObject folder name for stair geometry under the building root. + /// Used by DecorBuilder to parent step colliders and by NavigationBuilder + /// to exclude them from NavMesh source collection. + /// + public const string StairsFolderName = "Stairs"; + + /// + /// GameObject folder name for foundation geometry under the building root. + /// Excluded from NavMesh source collection because the foundation box's bottom + /// face creates a phantom walkable surface at ground level inside the building. + /// The floor collider provides the correct walkable surface instead. + /// + public const string FoundationFolderName = "Foundation"; + + } + + /// + /// Window geometry and rendering constants. + /// + public static class Window + { + /// + /// Default divider width between adjacent window panes in meters. + /// + public const float DefaultDividerWidth = 0.15f; + + /// + /// Maximum individual pane width for multi-pane windows in meters. + /// + public const float MaxPaneWidth = 2.0f; + + /// + /// Minimum individual pane width in meters. Pane count is auto-reduced if panes would be narrower. + /// + public const float MinPaneWidth = 0.3f; + + /// + /// Minimum gap between window panes and wall edges in meters. + /// + public const float MinGap = 0.15f; + + /// + /// Minimum horizontal margin reserved for side walls in a window section. + /// + public const float SideMargin = 0.5f; + + /// + /// Minimum vertical margin reserved for header and sill in a window section. + /// + public const float VerticalMargin = 1.2f; + + /// + /// Minimum side width required to place a window in a door side segment. + /// + public const float MinDoorSideWidth = 1.0f; + + /// + /// Window frame depth in meters. + /// + public const float FrameDepth = 0.05f; + + /// + /// Window frame member width in meters. + /// + public const float FrameWidth = 0.1f; + + /// + /// Geometry threshold below which wall segments are not created. + /// + public const float SegmentThreshold = 0.01f; + + /// + /// Maximum width of the solid strip next to a door in door-with-windows walls. + /// + public const float MaxDoorStripWidth = 0.5f; + + /// + /// Minimum width of the solid strip next to a door in door-with-windows walls. + /// + public const float MinDoorStripWidth = 0.3f; + + /// + /// Margin subtracted from the available side width when sizing the door strip. + /// + public const float DoorStripMargin = 0.4f; + + /// + /// Name prefix used for window frame GameObjects. + /// Used by registration to exclude frames from wall material swaps. + /// + public const string FrameNamePrefix = "Frame"; + + /// + /// Substring present in window glass GameObject names. + /// Used by registration to exclude glass from wall material swaps. + /// + public const string GlassNameSubstring = "Glass"; + } + + /// + /// Roof geometry and style constants. + /// + public static class Roof + { + /// + /// Parapet wall height for the Deep preset in meters. + /// + public const float DeepParapetHeight = 0.6f; + + /// + /// Cap height for the Deep preset in meters. + /// + public const float DeepCapHeight = 0.25f; + + /// + /// Cap overhang past the parapet wall for the Deep preset in meters. + /// + public const float DeepCapOverhang = 0.15f; + + /// + /// Parapet wall height for the Shallow preset in meters. + /// + public const float ShallowParapetHeight = 0.3f; + + /// + /// Cap height for the Shallow preset in meters. + /// + public const float ShallowCapHeight = 0.15f; + + /// + /// Cap overhang past the parapet wall for the Shallow preset in meters. + /// + public const float ShallowCapOverhang = 0.05f; + + /// + /// Extra depth added to wall thickness for parapet trim depth. + /// + public const float ParapetDepthPadding = 0.1f; + + /// + /// Default ridge height above the ceiling for gable roofs in meters. + /// + public const float DefaultRidgeHeight = 2.0f; + + /// + /// Default eave overhang past walls for gable roofs in meters. + /// + public const float DefaultOverhang = 0.3f; + + /// + /// Height of the 3D base slab beneath gable and hip roofs in meters. + /// + public const float DefaultBaseSlabHeight = 0.15f; + + /// + /// Scene material name for hip roof slopes. + /// + public const string RoofSlopeMaterialName = "mansion_roof_mat"; + + /// Fallback roof color red component. + public const float DefaultRoofColorR = 0.45f; + /// Fallback roof color green component. + public const float DefaultRoofColorG = 0.35f; + /// Fallback roof color blue component. + public const float DefaultRoofColorB = 0.3f; + } + + /// + /// NavigationBuilder constants. + /// + public static class NavMesh + { + /// + /// Minimum width for stair ramps in meters. + /// Wider ramps allow more NPCs to traverse simultaneously. + /// + public const float MinRampWidth = 3.0f; + + /// + /// Extra width added to stair ramps beyond the door width to compensate + /// for NavMesh agent-radius erosion on both edges. + /// + public const float RampErosionBuffer = 1.0f; + + /// + /// Thickness of invisible ramp colliders in meters. + /// Thin enough to not interfere with gameplay, thick enough for NavMesh voxelization. + /// + public const float RampColliderThickness = 0.1f; + } + + /// + /// Interior A* pathfinding constants. + /// + public static class InteriorNav + { + /// Maximum target cell size for the interior pathfinding grid. + /// Actual cell size is computed by + /// to evenly divide the room dimensions. + public const float MaxGridCellSize = Spatial.DefaultGridSize; + + /// Wall margin — cells within this distance of walls are unwalkable. + /// Must be less than CellSize/2 (0.25m) so NPCs can walk on 1-cell-wide paths. + public const float WallMargin = 0.15f; + + /// Re-pathfind interval for chase mode in seconds. + public const float ChaseRepathInterval = 0.2f; + + /// Distance threshold for considering NPC arrived at an intermediate waypoint. + public const float WaypointArrivalThreshold = 0.3f; + + /// Distance threshold for considering NPC arrived at final destination. + public const float DestinationArrivalThreshold = 0.5f; + + /// Distance for detecting NPC arrival at doorway exterior point. + /// Generous threshold ensures the NPC triggers entry even if the NavMesh agent + /// stops short of the exact exterior point due to carving boundary erosion. + public const float DoorwayApproachThreshold = 4.0f; + + /// Approach threshold for stair doorways. Tighter than + /// so the NPC walks all the way + /// to the stair base before the lerp takes over. + public const float StairApproachThreshold = 1.5f; + + /// Distance threshold for phase transitions during doorway entry/exit. + /// If the NPC is closer than this to the next phase target, skip to the following phase. + public const float PhaseTransitionThreshold = 0.15f; + + /// Max horizontal distance from doorway for agent-done entry on stair doorways. + /// Tighter than non-stair (8m) to avoid triggering entry from the sidewalk. + public const float StairMaxEntryDistance = 3f; + + /// Distance threshold for non-stair Phase 1 (angle correction). + /// If the NPC is farther than this from the exterior point, Phase 1 walks + /// to the exterior first to correct approach angle before entering. + public const float ExteriorAngleCorrectionThreshold = 1.0f; + + /// Time in seconds before an NPC in the Exiting state is considered stuck. + public const float ExitStuckTimeout = 3.0f; + + /// Squared displacement below which the stuck timer accumulates during exit. + public const float ExitStuckDisplacementSq = 0.25f; + + /// Rotation speed in degrees per second for NPC facing direction. + public const float RotationSpeed = 360f; + } + + /// + /// Interior wall geometry constants. + /// + public static class InteriorWall + { + /// + /// Minimum wall length in meters. + /// + public const float MinWallLength = 0.5f; + + /// + /// Geometry threshold below which wall segments are not created. + /// + public const float SegmentThreshold = 0.01f; + } + + /// + /// Terrain and area clearing constants. + /// + public static class Terrain + { + /// + /// Default padding around clearing bounds in meters. + /// + public const float DefaultClearingPadding = 2f; + + /// + /// Default name patterns for vegetation and natural clutter. + /// Used by the vegetation clearing pass to remove nature objects near buildings. + /// + public static readonly string[] DefaultVegetationKeywords = + { + "Rock", "Boulder", "Shrub", "Bush", "Tree rustle", "Foliage" + }; + + /// + /// Name patterns for objects protected from footprint destruction. + /// These typically extend far beyond the building and create visual gaps. + /// + public static readonly string[] DefaultProtectedKeywords = + { + "Road", "Sidewalk", "Wedge" + }; + + /// + /// Default padding around terrain flattening bounds in meters. + /// + public const float DefaultFlattenPadding = 0.5f; + + /// + /// Default blend distance for terrain edge smoothing in meters. + /// Terrain smoothly transitions from the flattened height back to + /// natural terrain over this distance. + /// + public const float DefaultBlendDistance = 3f; } /// diff --git a/docs/api-overview.md b/docs/api-overview.md index fa2535b..087bda0 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -129,13 +129,18 @@ interior.AddPrefab(Prefabs.ATM, position, rotation, networked: true); interior.Build(); ``` -**Component Builders:** +**Component Builders & Core Systems:** - `WallBuilder` - Wall and opening generation +- `InteriorWallBuilder` - Interior wall and opening generation - `InteriorBuilder` - Interior furniture and decoration placement using S1 meshes - `FurnitureBuilder` - Procedural furniture creation (used internally by BuildingBuilder) - `LightingBuilder` - Light fixture placement -- `DecorBuilder` - Decorative elements (trim, pillars, foundations) -- `PrefabPlacer` - Prefab instantiation with networking +- `DecorBuilder` - Decorative elements (trim, pillars, foundations, stairs) +- `RoofBuilder` - Parapet and hip roof generation +- `PrefabPlacer` - Prefab instantiation with networking (integrates with `NetworkedPrefabLinker`) +- `NavigationBuilder` & `InteriorPathGrid` - Custom A* NPC Pathfinding +- `TerrainClearer` & `TerrainFlattener` - Environment preparation +- `BuildingPartRegistry` - Post-build querying of generated geometry ## GLTF API (S1MAPI.Gltf) diff --git a/docs/building.md b/docs/building.md index d244ddd..505f713 100644 --- a/docs/building.md +++ b/docs/building.md @@ -112,15 +112,32 @@ new BuildingBuilder("Shop") ) ``` -### Custom Openings +### Custom Openings & Offsets + +You can combine doors and windows on the same wall, and offset them horizontally: ```csharp using S1MAPI.Building.Structural; .AddWalls( - north: WallOpening.Door(width: 1.5f, height: 2.5f), - south: WallOpening.Window(width: 2f, height: 1.5f), - east: WallOpening.Door() // Default door + north: WallOpening.DoorWithWindows( + doorWidth: 1.5f, doorHeight: 2.5f, + leftWindow: WallOpening.Window(width: 1f), + rightWindow: WallOpening.Window(width: 1f) + ), + south: WallOpening.Window(width: 2f, height: 1.5f) +) +``` + +### Interior Walls + +Segment your building by defining interior walls. Note that `InteriorWallAxis.X` walls run along the east-to-west axis, and `InteriorWallAxis.Z` walls run north-to-south. + +```csharp +.AddInteriorWall( + axis: InteriorWallAxis.X, + position: 4f, + opening: WallOpening.Door() ) ``` @@ -130,8 +147,39 @@ using S1MAPI.Building.Structural; .AddRoofTrim(height: 0.3f) // Roofline trim .AddSecondaryRoofTrim(height: 0.15f) // Secondary trim .AddCornerPillars(width: 0.5f) // Corner columns -.AddBaseMolding(height: 0.3f, depth: 0.1f) // Baseboard +.AddBaseMolding(height: 0.3f) // Baseboard .AddFoundation(height: 2.0f, expandX: 0.3f, expandZ: 0.3f) // Foundation +.AddStairs(WallSide.South, maxStepHeight: 0.2f, style: StairStyle.ClosedRiser) // Entry stairs +``` + +### Roofs + +Top off the building with generated roofs: + +```csharp +.AddParapetRoof(ParapetPreset.Shallow) // Flat roof with parapet walls +.AddHipRoof(ridgeHeight: 2.0f, overhang: 0.3f) // Sloped hip roof +``` + +### Environment Preparation + +Always clear nature from the construction site to prevent foliage and rustle sounds from interfering with your building: + +```csharp +// 1. Clear trees, foliage, and ambient rustle triggers +TerrainClearer.ClearAroundBuilding(buildingObject, new Vector3(10f, 4f, 10f), new ClearingOptions { Padding = 2f }); + +// 2. Flatten the terrain directly beneath the foundation +buildingBuilder.FlattenTerrain(); +``` + +### NPC Pathfinding (A* Navigation) + +S1MAPI replaces standard NavMesh repairers with a robust custom A* grid system (`InteriorPathGrid`). This solves edge cases and allows NPCs to navigate cleanly inside tight building footprints: + +```csharp +NavigationBuilder navBuilder = buildingBuilder.CreateNavigationBuilder(); +navBuilder.Build(); // Always call Build() after physical walls are placed ``` ## Lighting @@ -174,15 +222,33 @@ using S1MAPI.Building.Structural; **Note:** For S1 game furniture meshes, use `InteriorBuilder` instead. -## Prefabs +## Building Part Registry + +When you call `builder.Build()`, a `BuildingPartRegistry` component is attached to the root GameObject. This registry catalogs all generated meshes automatically: + +```csharp +BuildingPartRegistry registry = builder.Registry; + +// Example: Change the material of all exterior walls at runtime +registry.SetMaterial(BuildingPart.ExteriorWalls, newMaterial); +``` + +## Prefabs & Multiplayer Sync -Place Schedule 1 game prefabs inside your building using `BuildingBuilder`: +Place Schedule 1 game prefabs inside your building using `BuildingBuilder` or the `PrefabPlacer` component: ```csharp -// Networked prefab (syncs across clients) - ATM has NetworkObject +// Fluent API .AddPrefab(Prefabs.ATM, position, rotation) ``` +Alternatively, you can use `PrefabPlacer` directly. This is crucial for networked interactables (like Doors and Light Switches) as it utilizes `NetworkedPrefabLinker` under the hood to perfectly synchronize the object's parent hierarchy to joining clients over FishNet: + +```csharp +PrefabPlacer placer = new PrefabPlacer(building.transform); +placer.Place(Prefabs.MetalGlassDoor, position, rotation, networked: true); +``` + **Note:** `BuildingBuilder.AddPrefab()` only accepts `PrefabRef` and always spawns networked. For non-networked S1 furniture meshes, use `InteriorBuilder` methods instead. ### ⚠️ Critical: Networked Parameter diff --git a/docs/examples.md b/docs/examples.md index 7de4646..7ec4938 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -173,6 +173,82 @@ interior.AddChair(new Vector3(4, 0, 4), Quaternion.Euler(0, 180, 0)); interior.Build(); ``` +### Advanced Building (Terrain, Navigation, & Networked Doors) + +This complete example demonstrates how to prep the environment using `TerrainClearer` and `FlattenTerrain`, utilize complex wall openings like door and window combinations, generate an interior A* `NavigationBuilder` for NPC pathfinding, and place multiplayer-synced interactables securely using `PrefabPlacer` connected to FishNet. + +```csharp +using S1MAPI.Building; +using S1MAPI.Building.Components; +using S1MAPI.Building.Config; +using S1MAPI.Building.Structural; +using S1MAPI.S1; +using S1MAPI.Utils; +using UnityEngine; + +public static class StorefrontSpawner +{ + private static NavigationBuilder _navBuilder; + + public static void SpawnStorefront(Vector3 position) + { + BuildingBuilder builder = new BuildingBuilder("CoffeeShop") + .DefineRoom(12f, 4f, 10f) + .AddFloor() + .AddCeiling() + // Storefront: Central door, flanking windows + .AddWalls( + south: WallOpening.DoorWithWindows( + doorWidth: 1.8f, doorHeight: 2.2f, + leftWindow: WallOpening.Window(width: 1.5f, height: 2.0f, sillHeight: 0.5f, count: 1), + rightWindow: WallOpening.Window(width: 1.5f, height: 2.0f, sillHeight: 0.5f, count: 1) + ), + north: null, east: null, west: null + ) + .AddFoundation(height: 0.2f) + .AddBaseMolding() + .AddCornerTrim() + // Backroom divider 6 meters inward along the Z-axis + .AddInteriorWall(InteriorWallAxis.X, 6f, opening: WallOpening.Door(1.2f, 2.1f)) + // Finish with a polished parapet style roof + .AddParapetRoof(ParapetPreset.Shallow); + + GameObject coffeeShop = builder.Build(); + coffeeShop.transform.position = position; + + // 1. Clear terrain foliage and trees that would clip through the shop + TerrainClearer.ClearAroundBuilding(coffeeShop, new Vector3(12f, 4f, 10f), new ClearingOptions { Padding = 2f }); + + // 2. Flatten the terrain directly underneath + builder.FlattenTerrain(); + + // 3. Build A* NPC navigation grid for the interior (Must scan around our created walls) + _navBuilder = builder.CreateNavigationBuilder(); + _navBuilder.Build(); + + // 4. Place networked doors via PrefabPlacer for FishNet multiplayer replication + PrefabPlacer placer = new PrefabPlacer(coffeeShop.transform); + + // Entrance door + placer.Place( + Prefabs.MetalGlassDoor, + new Vector3(6f, 0f, 0f), + Quaternion.identity, + networked: true, + enableComponents: true + ); + + // Interior backroom door + placer.Place( + Prefabs.ClassicalWoodenDoor, + new Vector3(6f, 0f, 6f), + Quaternion.identity, + networked: true + ); + } +} +``` + ## GLTF Examples ### Load from Embedded Resource