Files
2026-07-21 08:56:10 +03:00

12 KiB

Unity 3D Meccano Editor - Detailed Class Documentation

This document provides a deep dive into the most important classes in the Meccano Editor system. It explains their roles, how they fit into the broader architecture, and includes relevant code snippets to illustrate their inner workings.


1. The Command Layer

The Command Layer is the backbone of the application's state management. It encapsulates all user actions (like creating, moving, or deleting a block) so that they can be tracked, reversed, and serialized.

ICommand Interface

Every action that modifies the world must implement this interface.

public interface ICommand
{
    void execute();
    void undo();
    CommandDTO toDTO();
}

CreateBlockCommand

This class implements ICommand to handle the instantiation of a new block. Notice how it stores all necessary parameters (prefab ID, position, rotation) so that it can be reversed (undo) or serialized (toDTO).

public class CreateBlockCommand : ICommand
{
    private string prefabId;
    private Vector3 position;
    private Quaternion rotation;
    private string blockId; // Captured after creation
    
    // Dependencies
    private BlockFactory factory;
    private Environment environment;

    public void execute()
    {
        // Creates the physical object in the scene
        GameObject objectCreated = factory.createblock(prefabId, position, rotation, blockId, layerDiff);
        
        // We capture the generated UUID so we can target it during Undo
        if (objectCreated != null)
        {
            Block blockComponent = objectCreated.GetComponent<Block>();
            blockId = blockComponent.getBlockId();
        }
    }

    public void undo()
    {
        // Completely removes the block from the environment registry and destroys it
        environment.removeBlock(blockId);
    }

    public CommandDTO toDTO()
    {
        return new CommandDTO.CommandDTOBuilder()
            .SetCommandName("CreateCommand")
            .AddParameter("prefabId", prefabId)
            .AddParameter("position", position)
            .AddParameter("rotation", rotation)
            .Build();
    }
}

CommandManager

This Singleton maintains the history of executed commands. It uses two LinkedLists to handle the undo and redo operations efficiently.

public class CommandManager
{
    private LinkedList<ICommand> commandStack = new LinkedList<ICommand>();
    private LinkedList<ICommand> redoStack = new LinkedList<ICommand>();
    private int maxCapacity = 100; // Prevents memory leaks on long sessions

    public void executeCommand(ICommand command)
    {
        command.execute();

        commandStack.AddFirst(command);
        redoStack.Clear(); // Any new action invalidates the redo stack
        
        if (commandStack.Count > maxCapacity)
        {
            commandStack.RemoveLast();
        }
    }

    public void undo()
    {
        if (commandStack.Count > 0)
        {
            ICommand command = commandStack.First.Value;
            commandStack.RemoveFirst();
            command.undo();
            redoStack.AddFirst(command);
        }
    }
}

2. Dependency Injection

The project uses a custom reflection-based DI container to avoid tightly coupling classes together and relying too heavily on Singletons for internal systems.

Injector

This class runs very early in the Unity lifecycle ([DefaultExecutionOrder(-1000)]). It searches for all MonoBehaviour instances, collects those implementing IDependencyProvider, and injects their provided values into any fields marked with [Inject].

[DefaultExecutionOrder(-1000)] 
public class Injector : MonoBehaviour
{
    void Awake()
    {
        var monoBehaviours = FindMonoBehaviours();

        // 1. Collect IDependencyProvider components and register what they provide via [Provide]
        var providers = monoBehaviours.OfType<IDependencyProvider>();
        foreach (var provider in providers)
        {
            RegisterProvider(provider);
        }

        // 2. Inject all injectable MonoBehaviours (fields with [Inject])
        var injectables = monoBehaviours.Where(IsInjectable);
        foreach (var injectable in injectables)
        {
            Inject(injectable);
        }
        
        // 3. Setup watchers for RuntimeAnchors
        SetupAnchorWatchers(injectables);
    }
    
    // ...
}

3. The Environment and Save System

The Environment is the source of truth for the physical world. The Save system works by "listening" to the Environment.

Environment

It maintains a Dictionary of all active blocks mapped by a UUID.

public class Environment : MonoBehaviour
{
    private Dictionary<string, Block> blockRegistry = new Dictionary<string, Block>();
    
    // Observers listen to these to react to the world state changing
    public UnityEvent<Block> onBlockAddedEvent;
    public UnityEvent<Block> onBlockRemovedEvent;
    public UnityEvent onChangeEvent;

    public void addBlock(Block block, string existingUUID = null)
    {
        // 1. Assign ID: Use existing (Undo/Redo) or generate a new one (Fresh placement)
        string uuid = string.IsNullOrEmpty(existingUUID) ? Guid.NewGuid().ToString() : existingUUID;
        block.setBlockId(uuid);

        // 2. Register in the dictionary
        blockRegistry[uuid] = block;
        
        applyChanges(); // Invokes onChangeEvent
        onBlockAddedEvent.Invoke(block);
    }
}

EnvironmentObserver

This class demonstrates the "Event Sourcing" approach to saving. When autoSaving is enabled, it listens to the onChangeEvent. Instead of saving GameObjects, it loops through the current registry and builds a fresh stack of CreateBlockCommands, then serializes them.

public class EnvironmentObserver : MonoBehaviour
{
    private void Start()
    {
        environment.onChangeEvent.AddListener(onChange);
    }

    public void save()
    {
        commandSaveHandler.clearCommandList();
        
        // Loop through everything in the Environment
        Dictionary<string, Block> blockRegistry = environment.getBlockRegistry();
        foreach (var blockEntry in blockRegistry)
        {
            if (blockEntry.Value != null)
            {
                // Create a command representing the current state of the block
                string prefabId = blockEntry.Value.getBlockData()?.blockName;
                Vector3 position = blockEntry.Value.transform.position;
                Quaternion rotation = blockEntry.Value.transform.rotation;
                
                ICommand createCommand = new CreateBlockCommand(prefabId, position, rotation);
                
                // Convert to a Data Transfer Object and add to the save queue
                commandSaveHandler.addLastCommand(createCommand.toDTO());
            }
        }
        
        // Write the queue to JSON
        commandSaveHandler.saveCommands();
    }
}

4. Object Interaction: GhostManager

The GhostManager handles the visual drag-and-drop feedback. It creates a semi-transparent "Ghost" of the object being manipulated.

public class GhostManager : MonoBehaviour
{
    // ... State tracking variables
    private IGhostMovementStrategy currentStrategy = new FreeMoveStrategy();

    public void SetupBlock(BlockData blockData = null)
    {
        // Instantiate a fake "ghost" object for the user to drag around
        GameObject prefab = selectedBlockData.blockPrefab;
        ghostObject = Instantiate(prefab, rayInteractor.GetHitPosition(), prefab.transform.rotation);

        // Make the ghost semi-transparent and ignore physics raycasts
        SetLayerRecursively(ghostObject, LayerMask.NameToLayer("Ignore Raycast"));
        InitialiseGhostFromObject(ghostObject); // Turns materials translucent

        canPlace = true;
    }

    private void Update()
    {
        if (!HasActiveGhost()) return;

        UpdateSurfaceTracking();
        
        // Trigger Snapping logic to find nearby sockets
        snappingSystem.UpdateSockets(currentSurfacePoint, ghostObject.transform);

        // Create a context object to pass into the movement strategy
        var ctx = new GhostMovementContext(...);

        // Strategy pattern: Allows swapping between free-move, axis-locked, etc.
        currentStrategy.UpdateMovement(ref ctx);

        // Validate the placement (e.g. are we colliding with something?)
        isValidPlacement = validator != null ? validator.IsValidPlacement(ghostObject) : true;
        UpdateVisualFeedback(isValidPlacement); // Turns green or red
    }
    
    private void PlaceBlock()
    {
        if (!isValidPlacement) return;
        
        // We do NOT instantiate the real block here! We delegate to the CommandHandler.
        if (block != null)
            commandHandler.createBlock(
                block.getBlockData()?.blockName,
                ghostObject.transform.position,
                ghostObject.transform.rotation);
    }
}

5. Sockets & Mechanical Connections

The mechanical logic relies on SocketPoints representing holes and pins, and JointBlocks which create physical constraints between them.

SocketPoint

Defines compatibility via radii and a SocketType enum.

public class SocketPoint : MonoBehaviour, ISocket
{
    [SerializeField] private SocketType socketType = SocketType.Regular;
    [SerializeField] private float socketRadius = 0.1f;
    [SerializeField] private List<SocketType> acceptableTypes = new List<SocketType>();
    [SerializeField] private float radiusTolerance = 0.05f;

    public bool CanAccept(ISocket otherSocket)
    {
        // Must be empty
        if (isOccupied) return false;

        // Must match type
        if (acceptableTypes.Count > 0 && !acceptableTypes.Contains(otherSocket.GetSocketType()))
            return false;

        // The hole must be larger than or equal to the pin, within a tolerance
        float radiusDiff = socketRadius - otherSocket.GetSocketRadius();
        return radiusDiff >= -radiusTolerance && radiusDiff <= radiusTolerance;
    }
}

JointBlock

When two pieces are successfully snapped together, JointBlock takes over to simulate the mechanical connection by creating Unity Physics joints.

public class JointBlock : MonoBehaviour
{
    [SerializeField] private JointBlockType jointType = JointBlockType.FixedPivot;

    public void CreatePhysicsJoint(GameObject targetObj, SocketPoint socket)
    {
        Rigidbody jointRb = GetComponent<Rigidbody>();
        if (jointRb == null) jointRb = gameObject.AddComponent<Rigidbody>();

        Rigidbody targetRb = targetObj.GetComponent<Rigidbody>();
        if (targetRb == null) targetRb = targetObj.AddComponent<Rigidbody>();

        // Create appropriate joint type
        Joint joint = CreateJointByType(targetRb, socket);
        if (joint != null)
        {
            createdJoints.Add(joint);
        }
    }

    private Joint CreateJointByType(Rigidbody connectedBody, SocketPoint socket)
    {
        switch (jointType)
        {
            case JointBlockType.HingePivot:
                HingeJoint joint = gameObject.AddComponent<HingeJoint>();
                joint.connectedBody = connectedBody;
                joint.axis = transform.InverseTransformDirection(transform.forward); 
                joint.anchor = transform.InverseTransformPoint(socket.GetTransform().position);
                return joint;

            case JointBlockType.FixedPivot:
            default:
                return gameObject.AddComponent<FixedJoint>();
        }
    }
}