Created
April 5, 2026 10:38
-
-
Save adammyhre/be689c65fedd2aa2f84495b72d7c6021 to your computer and use it in GitHub Desktop.
A simple Unity survival building system using modular pieces and socket-based snapping for clean placement.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using UnityEngine; | |
| public enum SimpleBuildPieceType { Floor, Wall, Door } | |
| public class SimpleBuildPart : MonoBehaviour { | |
| [SerializeField] SimpleBuildPieceType pieceType = SimpleBuildPieceType.Floor; // identity for occupancy and placer logic | |
| public SimpleBuildPieceType Type => pieceType; | |
| public void SetPieceType(SimpleBuildPieceType value) => pieceType = value; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System.Collections.Generic; | |
| using UnityEngine; | |
| using UnityEngine.InputSystem; | |
| public class SimpleBuildPlacer : MonoBehaviour { | |
| #region Fields | |
| [Header("Camera")] [SerializeField] Camera mainCamera; | |
| [Header("Prefabs & Tool")] [SerializeField] | |
| SimpleBuildPieceType placementPieceType = SimpleBuildPieceType.Floor; | |
| [SerializeField] GameObject floorTilePrefab; | |
| [SerializeField] GameObject wallTilePrefab; | |
| [SerializeField] GameObject doorTilePrefab; | |
| [Header("Ground Placement")] [SerializeField] | |
| LayerMask placementMask; // solid ground / meshes (exclude Socket so rays pass through to hit ground) | |
| [SerializeField] float gridSize = 2.5f; | |
| [SerializeField] bool snapFloorToGrid; | |
| [Header("Socket Raycasts")] [SerializeField] | |
| bool useSocketSnapping = true; // prefer socket triggers before free placement | |
| [SerializeField] LayerMask socketRaycastMask; | |
| [SerializeField] float socketRaycastDistance = 500f; | |
| [SerializeField] float groundRaycastDistance = 500f; | |
| [Header("Socket Occupancy")] [SerializeField] | |
| bool skipSocketIfOccupied = true; // ignore sockets that already have an Occupant reference | |
| [Header("Preview")] [SerializeField] Material previewMaterialValid; | |
| [SerializeField] Material previewMaterialGhost; | |
| [SerializeField] float previewLiftY = 0.02f; | |
| GameObject previewInstance; | |
| RaycastHit[] hitsBuffer; | |
| readonly List<SimpleEdgeSocket> availableSockets = new(8); // same GO can have Floor + Wall socket components | |
| int placementPieceTypeLastFrame; // detects tool change to rebuild preview | |
| #endregion | |
| GameObject ActivePrefab => placementPieceType switch { | |
| SimpleBuildPieceType.Wall => wallTilePrefab, | |
| SimpleBuildPieceType.Door => doorTilePrefab, | |
| SimpleBuildPieceType.Floor => floorTilePrefab | |
| }; | |
| void Awake() { | |
| if (mainCamera == null) mainCamera = Camera.main; | |
| if (socketRaycastMask.value == 0) socketRaycastMask = LayerMask.GetMask("Socket"); | |
| if (placementMask.value == 0) { | |
| var sl = LayerMask.NameToLayer("Socket"); | |
| placementMask = sl >= 0 ? ~(1 << sl) : ~0; | |
| } | |
| hitsBuffer = new RaycastHit[16]; | |
| placementPieceTypeLastFrame = (int)placementPieceType; | |
| } | |
| void OnDisable() { | |
| if (previewInstance != null) Destroy(previewInstance); | |
| } | |
| void UpdatePreview(Pose pose, bool valid) { | |
| if (previewInstance == null) { | |
| var p = ActivePrefab; | |
| if (p == null) return; | |
| previewInstance = Instantiate(p); | |
| previewInstance.hideFlags = HideFlags.DontSave; | |
| foreach (var c in previewInstance.GetComponentsInChildren<Collider>()) c.enabled = false; | |
| } | |
| var pos = pose.position; | |
| pos.y += previewLiftY; | |
| previewInstance.transform.SetPositionAndRotation(pos, pose.rotation); | |
| var mat = valid ? previewMaterialValid : previewMaterialGhost; | |
| if (mat == null) return; | |
| foreach (var r in previewInstance.GetComponentsInChildren<Renderer>()) r.sharedMaterial = mat; | |
| } | |
| void Update() { | |
| if (mainCamera == null || Mouse.current == null) return; | |
| var mouse = Mouse.current; | |
| if ((int)placementPieceType != placementPieceTypeLastFrame) { | |
| if (previewInstance != null) Destroy(previewInstance); | |
| previewInstance = null; | |
| placementPieceTypeLastFrame = (int)placementPieceType; | |
| } | |
| if (!TryGetPreviewPose(mouse.position.ReadValue(), out var pose, out var placementValid, out var socketSnap)) { | |
| if (previewInstance != null) previewInstance.SetActive(false); | |
| return; | |
| } | |
| UpdatePreview(pose, placementValid); | |
| if (mouse.leftButton.wasPressedThisFrame && placementValid) Place(ActivePrefab, pose, socketSnap); | |
| } | |
| bool TryGetPreviewPose(Vector2 screenPos, out Pose pose, out bool placementValid, out SimpleEdgeSocket socketFromSnap) { | |
| pose = default; | |
| placementValid = false; | |
| socketFromSnap = null; | |
| Ray ray = mainCamera.ScreenPointToRay(screenPos); | |
| // 1. Socket snapping (early out) | |
| if (useSocketSnapping && socketRaycastMask.value != 0 && TrySnapSocket(ray, out pose, out socketFromSnap)) { | |
| placementValid = true; | |
| return true; | |
| } | |
| // 2. Try raycast | |
| if (Physics.Raycast(ray, out var hit, groundRaycastDistance, placementMask, QueryTriggerInteraction.Ignore)) { | |
| var pos = hit.point; | |
| if (placementPieceType == SimpleBuildPieceType.Floor) { | |
| if (snapFloorToGrid) SnapGrid(ref pos); | |
| placementValid = true; | |
| } | |
| pose = new Pose(pos, Quaternion.identity); | |
| return true; | |
| } | |
| // 3. Fallback to horizontal plane | |
| if (TryHorizontalPlane(ray, out var point)) { | |
| if (placementPieceType == SimpleBuildPieceType.Floor) { | |
| if (snapFloorToGrid) SnapGrid(ref point); | |
| placementValid = true; | |
| } | |
| pose = new Pose(point, Quaternion.identity); | |
| return true; | |
| } | |
| return false; | |
| } | |
| void Place(GameObject prefab, Pose pose, SimpleEdgeSocket socketSnap) { | |
| if (prefab == null) return; | |
| var go = Instantiate(prefab, pose.position, pose.rotation); | |
| if (socketSnap == null) return; | |
| var part = go.GetComponent<SimpleBuildPart>(); | |
| if (part != null) socketSnap.SetOccupant(part); | |
| } | |
| void SnapGrid(ref Vector3 p) { | |
| if (gridSize <= 0f) return; | |
| p.x = Mathf.Round(p.x / gridSize) * gridSize; | |
| p.z = Mathf.Round(p.z / gridSize) * gridSize; | |
| } | |
| bool TrySnapSocket(Ray ray, out Pose bestPose, out SimpleEdgeSocket socket) { | |
| bestPose = default; | |
| socket = null; | |
| var n = Physics.RaycastNonAlloc(ray, hitsBuffer, socketRaycastDistance, socketRaycastMask, QueryTriggerInteraction.Collide); | |
| if (n <= 0) return false; | |
| var best = float.MaxValue; | |
| var found = false; | |
| for (var i = 0; i < n; i++) { | |
| var h = hitsBuffer[i]; | |
| if (h.collider == null) continue; | |
| availableSockets.Clear(); | |
| h.collider.GetComponents(availableSockets); | |
| SimpleEdgeSocket edge = null; | |
| for (var j = 0; j < availableSockets.Count; j++) { | |
| var s = availableSockets[j]; | |
| if (s.CanAcceptPart(placementPieceType)) { | |
| edge = s; | |
| break; | |
| } | |
| } | |
| if (edge == null) continue; | |
| if (skipSocketIfOccupied && edge.IsOccupied) continue; | |
| var candidate = edge.GetSnapPose(); | |
| if (h.distance < best) { | |
| best = h.distance; | |
| bestPose = candidate; | |
| socket = edge; | |
| found = true; | |
| } | |
| } | |
| return found; | |
| } | |
| static bool TryHorizontalPlane(Ray ray, out Vector3 hit) { | |
| hit = default; | |
| if (Mathf.Abs(ray.direction.y) < 1e-5f) return false; | |
| var t = -ray.origin.y / ray.direction.y; | |
| if (t < 0f) return false; | |
| hit = ray.origin + ray.direction * t; | |
| hit.y = 0f; | |
| return true; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using UnityEngine; | |
| using UnityUtils; | |
| public abstract class SimpleEdgeSocket : MonoBehaviour { | |
| [SerializeField] protected Vector3 snapLocalPosition; | |
| [SerializeField] protected Vector3 snapLocalRotationEuler; | |
| [SerializeField] protected float triggerRadius = 0.3f; | |
| [SerializeField] protected string socketLayerName = "Socket"; | |
| SimpleBuildPart occupant; // piece placed on this socket via SimpleBuildPlacer (one-way: socket owns the reference) | |
| public SimpleBuildPart Occupant => occupant; | |
| public bool IsOccupied => occupant != null; | |
| public virtual Pose GetSnapPose() => new(GetSnapWorldPosition(), GetSnapWorldRotation()); | |
| protected Vector3 GetSnapWorldPosition() => transform.position + transform.TransformDirection(snapLocalPosition); | |
| protected Quaternion GetSnapWorldRotation() => transform.rotation * Quaternion.Euler(snapLocalRotationEuler); | |
| public void SetOccupant(SimpleBuildPart part) { | |
| if (part != null && !CanAcceptPart(part.Type)) return; | |
| if (occupant == part) return; | |
| occupant = part; | |
| } | |
| public virtual bool CanAcceptPart(SimpleBuildPieceType pieceType) => false; | |
| void Awake() => ApplyLayerAndCollider(); | |
| void Reset() => ApplyLayerAndCollider(); | |
| void OnValidate() => ApplyLayerAndCollider(); | |
| protected void ApplyLayerAndCollider() { | |
| var idx = LayerMask.NameToLayer(socketLayerName); | |
| if (idx >= 0) gameObject.layer = idx; | |
| var sc = gameObject.GetOrAdd<SphereCollider>(); | |
| sc.isTrigger = true; | |
| sc.radius = triggerRadius; | |
| sc.center = Vector3.zero; | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using UnityEngine; | |
| public class SimpleFloorSocket : SimpleEdgeSocket { | |
| [SerializeField] bool acceptsFloorPieces = true; | |
| public override bool CanAcceptPart(SimpleBuildPieceType pieceType) => | |
| pieceType == SimpleBuildPieceType.Floor && acceptsFloorPieces; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using UnityEngine; | |
| public class SimpleWallSocket : SimpleEdgeSocket { | |
| [SerializeField] bool acceptsWallPieces = true; | |
| [SerializeField] bool acceptsDoorPieces = true; | |
| public override bool CanAcceptPart(SimpleBuildPieceType pieceType) => pieceType switch { | |
| SimpleBuildPieceType.Wall => acceptsWallPieces, | |
| SimpleBuildPieceType.Door => acceptsDoorPieces, | |
| _ => false | |
| }; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment