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>();
}
}
}