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 | NoneReturns 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 | NoneCalled 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 | NoneCalled 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 | NoneCalled 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 | NoneCalled on right-click. Controller shows context menu, triggers erase, etc. Returns action dict or None.
def delete_active_handle(self) -> dict | NoneCalled 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 pattern | What it is | get | update | end_drag | activate | context |
|---|---|---|---|---|---|---|
marker:<id> | Any POI marker | hit radius 10px | move marker in memory | save to DB | enter_marker or toggle view | edit/delete menu |
vector_point:<vec_id>:<idx> | Vector control point (TOOLS tab only) | hit radius 10px | move point in memory | — (points auto-save with vector) | select point | — |
map_empty | Nothing hit | — | — | — | shift+click → create marker | right-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 panInternal _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_yCamera 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 pattern | What it is | get | update | end_drag | activate | context |
|---|---|---|---|---|---|---|
marker:<id> | Any POI marker | hit radius 15px | move in memory | save to DB | door/trap/light/stairs/view actions | edit/delete menu |
rotation_handle:<id> | View marker facing handle | hit radius 8px | update facing_degrees | save to DB | — | — |
tile:<c>:<r> | Grid tile (TOOLS tab only) | hit by cell math | paint while dragging | — | — | right-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 NoneCamera 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 from | What |
|---|---|
| CampaignManager | _hit_test_marker, drag_offset, dragging_marker, pending_click_pos, all wx/wy computation, coord_scale usage |
| BaseController | handle_shared_input entirely |
| Both controllers | on_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 to | What |
|---|---|
| CampaignManager | dragging_handle_id, calls to 5 controller methods |
| BaseController | Abstract: 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
- Add
set_camera+ coordinate helpers to both controllers, wire upset_cameracalls in CampaignManager. No behaviour change yet. - Add the 6 abstract methods to BaseController as stubs returning None.
- Implement
get_handle_atin both controllers. Wire_hit_test_markerin CampaignManager to call it. Verify nothing breaks. - Implement
update_handle+end_handle_dragin both. Removedragging_marker/drag_offsetfrom CampaignManager. - Implement
activate_handlein both. Remove short-click logic from CampaignManager. - Implement
context_action+delete_active_handle. Remove remaining controller-specific branches from CampaignManager. - Delete
handle_shared_inputfrom BaseController,on_map_eventfrom both controllers.
Each step is independently testable. The game runs correctly after every step.
No comments:
Post a Comment