Manipulation: System vs Parent

Version note: This guide is validated for Grid Building 5.0.8. The 5.0.4 release includes significant manipulation stability fixes. See Patch Notes for the full list.


The Core Split

In 5.0.4, manipulation is divided into two distinct responsibilities:

  1. ManipulationSystem: The “Brain”. Owns business logic, state transitions, and validation.
  2. ManipulationParent: The “Body”. Owns visual transforms, scene hierarchy, and input handling for rotation/flip.

Why This Split Exists

The architecture intentionally separates orchestration from presentation.

  • Testability: The ManipulationSystem can be tested (mostly) without needing a complex scene tree, as it operates on state and signals.
  • Clarity: The node hierarchy remains understandable. You know exactly where to look for rotation logic (Parent) vs validation logic (System).
  • Stability: By isolating transform logic, we prevent “drift” where an object slowly moves off-grid due to floating point errors in repeated calculations.

Responsibilities

| Component | Role | Owns | Does NOT Own | |–|||*********–| | ManipulationSystem | Business Logic | Lifecycle (start/commit), validation (can I move this?), state transitions (is_moving), API (try_move). | Direct visual transforms, local rotation math, scene tree parentage. | | ManipulationParent | Visual Layer | Rotation/Flip/Scale containers, transform input handling, holding the ghost/preview object. | Validation rules, resource consumption, grid occupancy checks. |


Scene Hierarchy (Mental Model)

When you pick up an object, the hierarchy temporarily looks like this:

1
2
3
4
GridPositioner2D (The cursor/grid snapper)
  \-- ManipulationParent (The rotator/flipper)
      +-- IndicatorManager (Visual feedback)
      \-- Preview / ghost object (The object being moved)

The GridPositioner2D moves the entire assembly to the target grid cell. The ManipulationParent rotates the assembly around that center point.


Critical 5.0.4 Behaviors

1. Movable Validation

ManipulationSystem.try_move() strictly enforces the is_movable() check on the source object.

  • Behavior: The system will reject the move with a failure message if ManipulatableSettings.movable is false.

2. Transform Preservation

When a move completes, the accumulated transform (rotations applied during the move) must be preserved.

  • Flow: Move Start → Copy original transform → User rotates/flips → Place → Apply final transform to new instance.
  • Bug Watch: If your objects reset rotation after placement, check that you aren’t overwriting the transform in _ready().

3. Stale Move Copy Protection (5.0.4)

5.0.4 update: Previous manipulations are now properly cancelled before starting new ones. This prevents stale collision shapes from persisting and interfering with new indicators.

4. Source Deletion Safety (5.0.4)

5.0.4 update: Fixed hard crash when the manipulation source is deleted during an active move. Indicators parented in the scene tree are now properly remove_child()’d before freeing, and collision exclusions are cleared on cancel/finish.

5. State Reset Fix (5.0.4)

5.0.4 update: Fixed a critical bug where _states.manipulation.data == null was a comparison (discarding the result) instead of an assignment. Stale manipulation data is now properly cleared.

6. Visual Desyncs

Because the parent handles the visual transform, if you manually set the node.rotation of the object inside the parent, you might get double rotations or unexpected offsets.

  • Fix: Always rotate the ManipulationParent using apply_rotation() or apply_grid_rotation_clockwise(), never the child object directly.

Glossary

  • ManipulationSystem: The singleton that orchestrates move/rotate logic.
  • ManipulationParent: The Node2D that holds the preview object.
  • Source Object: The original object in the world being moved.
  • Preview Object: The temporary visual copy attached to the mouse cursor.
  • GridPositioner2D: The component that snaps the preview to the grid cell center.

Common Pitfalls

  • Script Access: Do not try to get_node("ManipulationSystem") from inside a random scene script. Use dependency injection or a global singleton reference if your architecture supports it.
  • Input Consumption: The ManipulationParent often handles input for rotation. If your camera controller consumes all input, the rotation keys might not trigger. Ensure input propagation is handled correctly (e.g., _unhandled_input).
  • Orphaned Previews: If the system is interrupted (e.g., the player dies while building), ensure cancel_interaction() is called to clean up the ManipulationParent and its preview child.

5.0.4 update: Orphaned preview leaks are now much less likely due to improved cleanup guards, but explicit cancel_interaction() calls are still recommended on state transitions.


Resource Credit on Demolish

When a player demolishes a placed object, you may want to refund the building cost. Use ManipulationState.finished and filter by action type.

Listening to Finished Manipulation

1
2
3
4
5
_manipulation_state.finished.connect(_on_manipulation_finished)

func _on_manipulation_finished(data: ManipulationData) -> void:
    if data.action == GBEnums.Action.DEMOLISH:
        _refund_player(data.manipulator, data.source)

Filtering by Action Type

ManipulationData.action is a GBEnums.Action enum. Handle demolish vs move differently:

1
2
3
4
5
6
7
8
func _on_manipulation_finished(data: ManipulationData) -> void:
    match data.action:
        GBEnums.Action.DEMOLISH:
            _refund_player(data.manipulator, data.source)
        GBEnums.Action.MOVE:
            pass  # Move — no credit needed
        GBEnums.Action.ROTATE, GBEnums.Action.FLIP_H, GBEnums.Action.FLIP_V:
            pass  # Transform — no credit needed

Getting the Player Who Demolished

data.manipulator is the GBOwner performing the action — the player who pressed the demolish key. Use data.manipulator.owner_root to access the player node:

1
2
3
4
func _refund_player(player: GBOwner, demolished: Manipulatable) -> void:
    var root: Node = player.owner_root
    if root.has_method("add_resources"):
        root.add_resources(demolished.settings.cost)

Note: manipulator credits the player who demolished, not the original builder. If player A builds and player B demolishes, the refund goes to B.