10 KiB
Unity 3D Meccano Editor - Technical Documentation
1. Architecture Overview
The Meccano Editor is built on a highly modular, event-driven architecture using several core design patterns to ensure decoupling and maintainability. At its heart, the system uses a custom Dependency Injection container to wire dependencies, the Command Pattern for all state-mutating actions (enabling Undo/Redo and Save/Load), and the Observer Pattern heavily through UnityEvents to communicate between layers.
The architecture separates concerns into distinct layers:
- Data & Environment: Manages the source of truth (block registry, instantiated objects).
- Command Layer: Encapsulates all modifications to the environment.
- Interaction Layer: Handles user intent, ghost block placement, and snapping.
- Save/Load System: Relies on command event sourcing to reconstruct the state.
2. Major Modules and Systems
2.1 Dependency Injection (DependencyInjection/)
Purpose: A custom, reflection-based DI framework that wires up dependencies on Awake.
- Main Classes:
Injector,RuntimeAnchor,IDependencyProvider. - Data Flow: Classes use the
[Inject]attribute on fields/properties. Providers use[Provide].Injectorruns with[DefaultExecutionOrder(-1000)]to resolve these beforeStart(). It also supports reactive updates forRuntimeAnchorchanges. - Dependencies: Core framework, independent of other modules.
2.2 Blocks Layer (BlocksLayer/)
Purpose: Acts as the source of truth for the physical world and block definitions.
- Main Classes:
Environment,Block,BlockData,BlockFactory. - Responsibility:
Environmentis a Singleton that registers every instantiated block using a unique UUID. It firesonBlockAddedEventandonBlockRemovedEvent.BlockFactoryhandles the instantiation of prefabs fromBlockDatadefinitions.
2.3 Command Layer (CommandLayer/)
Purpose: Encapsulates all state changes to support undo/redo and serialization.
- Main Classes:
CommandManager,CommandHandler,ICommand,CommandDTO. - Data Flow: UI or Interactors call methods on
CommandHandler(e.g.,createBlock,moveBlock). The handler wraps the intent in anICommandand passes it toCommandManager, which executes it and pushes it onto aLinkedList(commandStack).
2.4 Interaction Layer (InteractionLayer/)
Purpose: Translates raw user input into actionable placements, snapping, and selections.
- Main Classes:
InteractionManager,GhostManager,RayInteractor,SnapSystem. - Responsibility:
RayInteractorcasts rays to detectIInteractableobjects or the world grid.GhostManagerhandles drag-and-drop workflows. It creates a semi-transparent "ghost" of a block, updates its position via aIGhostMovementStrategy(Strategy Pattern), and validates placement.SnapSystemworks withGhostManagerto find the nearestSocketPointand align the block dynamically.
2.5 Sockets & Joints System (Sockets/)
Purpose: Simulates the mechanical connections (pins and holes) of Meccano pieces.
- Main Classes:
SocketPoint,SocketContainer,JointBlock. - Responsibility:
SocketPointdefines a physical connection node (hole or pin) and its radius/compatibility.SocketContainermanages multiple sockets on a single piece.JointBlockautomatically creates Physics Joints (FixedJoint,HingeJoint,ConfigurableJoint) between compatibleSocketPoints when blocks are snapped together.
2.6 Save / Load System (SaveSystem/)
Purpose: Persists the user's creation using an Event Sourcing approach.
- Main Classes:
EnvironmentObserver,CommandSaveHandler,CommandSaveUtil. - Responsibility: Instead of saving the literal positions of every block, it serializes the history of commands into
CommandDTOJSON objects.EnvironmentObserverauto-saves when the environment changes. On load, the system clears the environment and re-executes all serialized commands.
2.7 Model Checking System (ModelCheckingSystem/)
Purpose: Evaluates the user's current build against a target "solution" map.
- Main Classes:
ModelCheckManager,BlocksMap,BlockNode. - Responsibility: Listens to the
Environment. When a block is added, it creates aBlockNode(quantized position/rotation) and compares it against theBlocksMap(ScriptableObject representing the goal). It fires events when a piece is correct or when the entire model is complete.
3. Important Design Patterns
- Command Pattern: Used in
CommandLayerto encapsulate requests (CreateBlockCommand,MoveBlockCommand). Enables Undo/Redo. - Event Sourcing: The Save/Load system works by recording and replaying Commands rather than dumping scene state.
- Observer Pattern: Implemented via
UnityEventacross the codebase (e.g.,Environment.onBlockAddedEvent, Input actions). - Singleton Pattern: Heavily used for Managers (
CommandManager,Environment,InteractionManager,GhostManager). - Strategy Pattern: Used in
GhostManagerviaIGhostMovementStrategy(e.g.,FreeMoveStrategy,AxisMoveStrategy) to easily swap how an object moves during drag-and-drop. - Dependency Injection: Custom implementation (
Injector.cs) to decouple services and handle reactive state anchors.
4. Systems Deep-Dive
4.1 Serialization and Save-Load (Event Sourcing)
The save system does not save GameObjects. When auto-saving triggers (EnvironmentObserver.onChangeEvent), it loops through the Environment's blockRegistry and generates a CreateBlockCommand for each block's current state. These are mapped to CommandDTO structs and serialized to commands.json.
Loading wipes the environment, parses the JSON back into CommandDTOs, converts them to ICommands, and executes them sequentially over a Coroutine to visually rebuild the scene.
4.2 Undo and Redo
CommandManager maintains two LinkedList<ICommand>s: commandStack and redoStack.
- Execute: Pushes to
commandStack, clearsredoStack. Enforces a memory-leak safeguard (maxCapacity= 100). - Undo: Pops from
commandStack, callscommand.undo(), pushes toredoStack. - Redo: Pops from
redoStack, callscommand.execute(), pushes tocommandStack.
4.3 Input Handling
Abstracted through BaseInputProvider and implemented for PC in PCIntputProvider. It maps Unity's new Input System (InputAction) to generic UnityEvents (OnCursorMoved, OnPlace, OnRotate).
InputModeManager acts as a state stack. Modes like ObjectDragging or UI can be pushed or popped to suppress or route inputs appropriately (e.g., ignoring world clicks while a UI is open).
4.4 Editor Tools & Runtime Separation
The project includes Editor-specific functionalities. For example, in ModelCheckManager, there is a NaughtyAttributes button [Button("Save Current Map as Asset")] wrapped in #if UNITY_EDITOR that allows a developer to build a model in Play Mode, and save it as a BlocksMap ScriptableObject to be used as a puzzle target later.
5. Initialization and Lifecycle Flow
- Pre-Awake (
[DefaultExecutionOrder(-1000)]):Injector.Awake()runs. It scans allMonoBehaviours, findsIDependencyProviders, registers dependencies, and injects them into fields marked[Inject]. - Awake: Singletons instantiate themselves (e.g.,
Environment,InteractionManager,GhostManager).VirtualPlatformManagercreates the visual grid. - Start: Managers wire up their internal listeners.
InteractionManagermaps UI selections to theGhostManager.EnvironmentObserverbinds to the Environment's change event. - Runtime Loop:
RayInteractorconstantly updateslastHit.- On Click ->
GhostManagereither commits a placed block viaCommandHandler, orRayInteractorselects an existing block. GhostManager.Update()calculates snapping and validates positions viaIValidator.CommandManagerexecutes commands -> updatesEnvironment-> triggers auto-save viaEnvironmentObserver.
6. Extensibility Points: How to Add New Features
How to Add a New Object Type (Meccano Piece)
- Create a prefab for the piece. Add the
Blockcomponent. - Add
SocketContainerandSocketPointcomponents to define where pins/holes exist. Set theirSocketTypeandradius. - If the piece moves (like a hinge), add a
JointBlockcomponent and configure theJointBlockType. - Create a new
BlockDataScriptableObject in theResources/BlocksDatafolder and assign your prefab. BlockFactoryautomatically loads allBlockDatafrom Resources on Start.
How to Add a New Command
- Create a class implementing
ICommand(e.g.,ScaleBlockCommand). - Implement
execute()(forward logic) andundo()(reverse logic). - Update
CommandDTOto handle serialization of your new command's specific data (e.g., adding scale vectors). - Expose a method in
CommandHandlerto trigger it.
How to Add Saving Support for New Data
If you modify block state beyond Position/Rotation (e.g., color, scale), you must:
- Update
CreateBlockCommandandMoveBlockCommand(or create new commands) to store this data. - Update the
CommandDTOclass to serialize these new fields. - Modify
EnvironmentObserver.addAllCommands()to capture this new state when generating the snapshot.
7. Common Pitfalls and Architectural Weaknesses
- Singleton Overuse vs. DI: The project uses both an advanced custom Dependency Injection container (
Injector.cs) AND widespread traditional Singletons (Environment.getInstance()). This can cause confusion about how to access services. Stick to DI for new features to maintain testability. - Save System Bottleneck: Because the save system relies on Event Sourcing (replaying commands), loading a massive project requires instantiating and executing hundreds of commands sequentially. If projects get very large, a state-snapshot save mechanism might be more performant than event replay.
- Socket Physics Initialization:
JointBlockcreates Unity Physics Joints (HingeJoint, etc.) at runtime. Physics glitches can occur if blocks overlap or if thejointBreakForceis exceeded. UseIValidatorstrictly to prevent overlapping placements. - Command Memory Leak:
CommandManagerclears thecommandStackbeyond 100 items to save memory, butEnvironmentObserversaves the state by looping through current blocks, not the command stack. This means the save file is a fresh snapshot, which is good, but undoing past 100 steps is impossible.