Mastodon Politics, Power, and Science: Handle System Refactor Plan in the Campaign Manager

Tuesday, March 10, 2026

Handle System Refactor Plan in the Campaign Manager

 J. Rogers, SE Ohio

The Core Principle

CampaignManager owns the mouse and screen. Controllers own the world and tactical maps. The boundary between them is a single question: "what is at this screen position?"

CampaignManager never computes world coordinates. 

Controllers never see pygame events.


What Changes in CampaignManager

Input loop becomes purely screen-space

The event loop stops computing wx, wy entirely. It only works in screen pixels.

MOUSEBUTTONDOWN(sx, sy)

  → handle_id = controller.get_handle_at(sx, sy)
  → if handle_id: begin drag
  → else: map pan begins

MOUSEMOTION(sx, sy)
  → if dragging handle: controller.update_handle(handle_id, sx, sy) → action?
  → if panning: update cam_x/cam_y

MOUSEBUTTONUP(sx, sy)  
  → if dragging handle and drag_dist > threshold:
      controller.end_handle_drag(handle_id, sx, sy) → action?
  → elif dragging handle (short click):
      controller.activate_handle(handle_id, sx, sy) → action?
  → pan ends

MOUSEWHEEL
  → update zoom (pure screen math, no controller involved)

KEYDOWN ESC
  → go_up_level (no controller involved)

KEYDOWN DELETE
  → controller.delete_active_handle() → action? (if a handle is "selected")

The action dict that comes back from any controller call flows into _handle_action exactly as now. Nothing changes there.

State CampaignManager keeps

self.cam_x, self.cam_y, self.zoom     # screen-space camera state
self.dragging_handle_id               # replaces dragging_marker + drag_offset
self.dragging_map                     # bool, for pan
self.drag_start_pos                   # screen pixels, for threshold test
self._overlay_painting                # bool, active paint stroke
```

`dragging_marker`, `drag_offset`, `pending_click_pos` — all deleted. Controllers own that state internally.

### Sidebar and widget handling stays in CampaignManager

Widgets and overlay sidebar are still iterated here before hit-testing the map. The sidebar guard (pos[0] < SIDEBAR_WIDTH) stays exactly where it is. This doesn't change.

### Right-click
```
MOUSEBUTTONDOWN button=3 (sx, sy)
  → handle_id = controller.get_handle_at(sx, sy)
  → controller.context_action(handle_id, sx, sy) → action?

No special-casing of overlay erase in CampaignManager. The controller decides what right-click means based on active tab and what was hit.


What Changes in BaseController

Four concrete methods replace on_map_event. These are the full public interface for all map interaction:

def get_handle_at(self, sx, sy) -> str | None

Returns a handle ID string if something interactive is at screen position (sx, sy), else None. The controller does all coordinate translation internally. Handle IDs are opaque strings to CampaignManager — "marker:42", "vector_point:7:3", "rotation_handle:42", "tile:12:8".

def update_handle(self, handle_id, sx, sy) -> dict | None

Called every MOUSEMOTION while a handle is being dragged. Controller translates (sx, sy) to world coords using its own camera and coord_scale, updates internal state. Returns an action dict if something needs to propagate (e.g. {"action": "update_player_view"} when a view marker moves), else None.

def end_handle_drag(self, handle_id, sx, sy) -> dict | None

Called on MOUSEBUTTONUP after a drag (distance > threshold). Controller saves the final position to DB, clears drag state. Returns action dict or None.

def activate_handle(self, handle_id, sx, sy) -> dict | None

Called on MOUSEBUTTONUP after a short click (distance <= threshold). Controller interprets based on handle type — toggle door, enter portal, toggle view marker, etc. Returns action dict or None.

def context_action(self, handle_id, sx, sy) -> dict | None

Called on right-click. Controller shows context menu, triggers erase, etc. Returns action dict or None.

def delete_active_handle(self) -> dict | None

Called on DELETE key. Controller deletes the currently selected handle if there is one (e.g. selected vector point). Returns action dict or None.

What BaseController no longer needs

handle_shared_input — deleted entirely. The shared input never reaches controllers now.

The overlay paint methods (_do_overlay_paint, _do_overlay_erase) stay in BaseController because they contain real logic, but they are called internally from the controller's own methods, not from CampaignManager.


What Changes in GeoController

Handles it exposes

Handle ID patternWhat it isgetupdateend_dragactivatecontext
marker:<id>Any POI markerhit radius 10pxmove marker in memorysave to DBenter_marker or toggle viewedit/delete menu
vector_point:<vec_id>:<idx>Vector control point (TOOLS tab only)hit radius 10pxmove point in memory— (points auto-save with vector)select point
map_emptyNothing hitshift+click → create markerright-click → nothing

get_handle_at implementation 

def get_handle_at(self, sx, sy):
    # check markers
    for m in self.markers:
        props = m.get('properties', {})
        mx, my = self._world_to_screen(props['world_x'], props['world_y'])
        if math.hypot(sx - mx, sy - my) < 10:
            return f"marker:{m['id']}"
    
    # check vector points if active_vector and TOOLS tab
    if self.active_vector and self.active_tab == "TOOLS":
        for i, pt in enumerate(self.active_vector['properties']['points']):
            px, py = self._world_to_screen(pt[0], pt[1])
            if math.hypot(sx - px, sy - py) < 10:
                return f"vector_point:{self.active_vector.get('id','new')}:{i}"
    
    return None  # nothing hit → CampaignManager will pan

Internal _world_to_screen method (new, private)

GeoController needs to own its own screen→world conversion. It already knows cam_x, cam_y, zoom because CampaignManager updates these on the controller whenever the camera changes. A single method:

def _world_to_screen(self, wx, wy):
    cx = SCREEN_WIDTH // 2
    cy = SCREEN_HEIGHT // 2
    return cx + (wx - self.cam_x) * self.zoom, cy + (wy - self.cam_y) * self.zoom

def _screen_to_world(self, sx, sy):
    cx = SCREEN_WIDTH // 2
    cy = SCREEN_HEIGHT // 2
    return (sx - cx) / self.zoom + self.cam_x, (sy - cy) / self.zoom + self.cam_y

Camera sync

CampaignManager calls a single method when camera changes:

controller.set_camera(cam_x, cam_y, zoom)

Controller stores these and uses them in all its coordinate conversions. This replaces passing cam_x/cam_y/zoom as parameters to every method.


What Changes in TacticalController

Handles it exposes

Handle ID patternWhat it isgetupdateend_dragactivatecontext
marker:<id>Any POI markerhit radius 15pxmove in memorysave to DBdoor/trap/light/stairs/view actionsedit/delete menu
rotation_handle:<id>View marker facing handlehit radius 8pxupdate facing_degreessave to DB
tile:<c>:<r>Grid tile (TOOLS tab only)hit by cell mathpaint while draggingright-click → create marker

get_handle_at implementation

def get_handle_at(self, sx, sy):
    # rotation handle takes priority
    view_marker = next((m for m in self.markers 
                        if m['properties'].get('is_view_marker')), None)
    if view_marker:
        props = view_marker['properties']
        vsx, vsy = self._world_to_screen(props['world_x'], props['world_y'])
        facing = math.radians(props.get('facing_degrees', 0))
        hx = vsx + math.cos(facing) * 25
        hy = vsy + math.sin(facing) * 25
        if math.hypot(sx - hx, sy - hy) < 8:
            return f"rotation_handle:{view_marker['id']}"
    
    # markers
    for m in self.markers:
        props = m.get('properties', {})
        mx, my = self._world_to_screen(props['world_x'], props['world_y'])
        if math.hypot(sx - mx, sy - my) < 15:
            return f"marker:{m['id']}"
    
    # tile (TOOLS tab)
    if self.active_tab == "TOOLS":
        c, r = self._screen_to_cell(sx, sy)
        if 0 <= c < self.grid_width and 0 <= r < self.grid_height:
            return f"tile:{c}:{r}"
    
    return None

Camera Sync Protocol

This is the key that makes controllers self-sufficient for coordinate conversion.

CampaignManager calls controller.set_camera(cam_x, cam_y, zoom):

  • On every successful pan (MOUSEMOTION while dragging map)
  • On every zoom (MOUSEWHEEL)
  • On _set_controller() when a new node loads

Controllers store self.cam_x, self.cam_y, self.zoom and use them in _world_to_screen and _screen_to_world. They never receive these as parameters to any public method.


The Scriptable Interface This Gives You

Because all map interaction now goes through four clean methods, you can:

Record and replay — log every activate_handle, end_handle_drag call with its handle ID and screen position. Replay it later.

Scripted actions — call controller.activate_handle("marker:42", 0, 0) directly to fire the same logic as a player click, without needing to synthesize a mouse event.

Remappable input — the mapping of "middle click → query_point", "shift+left click → create marker", "right click → context menu" lives entirely in CampaignManager's event loop. Change it in one place. Controllers don't know what button was pressed.

Multiple input devices — gamepad, touch, keyboard navigation — all just call the same four controller methods with screen coordinates. CampaignManager translates device input → handle calls.

Testing — test controller logic by calling activate_handle("marker:42", 0, 0) directly. No pygame event synthesis needed.


Summary of What Gets Deleted

Deleted fromWhat
CampaignManager_hit_test_marker, drag_offset, dragging_marker, pending_click_pos, all wx/wy computation, coord_scale usage
BaseControllerhandle_shared_input entirely
Both controllerson_map_event, all pygame.MOUSEBUTTONDOWN/UP/MOTION pattern matching, all cam_x/cam_y/zoom parameters on public methods

Summary of What Gets Added

Added toWhat
CampaignManagerdragging_handle_id, calls to 5 controller methods
BaseControllerAbstract: get_handle_at, update_handle, end_handle_drag, activate_handle, context_action, delete_active_handle, set_camera
Both controllers_world_to_screen, _screen_to_world, _screen_to_cell (tac only), set_camera, implementation of the 6 abstract methods

Sequencing

  1. Add set_camera + coordinate helpers to both controllers, wire up set_camera calls in CampaignManager. No behaviour change yet.
  2. Add the 6 abstract methods to BaseController as stubs returning None.
  3. Implement get_handle_at in both controllers. Wire _hit_test_marker in CampaignManager to call it. Verify nothing breaks.
  4. Implement update_handle + end_handle_drag in both. Remove dragging_marker/drag_offset from CampaignManager.
  5. Implement activate_handle in both. Remove short-click logic from CampaignManager.
  6. Implement context_action + delete_active_handle. Remove remaining controller-specific branches from CampaignManager.
  7. Delete handle_shared_input from BaseController, on_map_event from both controllers.

Each step is independently testable. The game runs correctly after every step.

No comments:

Post a Comment

Handle System Refactor Plan in the Campaign Manager

 J. Rogers, SE Ohio The Core Principle CampaignManager owns the mouse and screen. Controllers own the world and tactical maps. The boundar...