rimlike/scenes/world/camera_rig.gd
megaproxy 0b2e0fcd03 PC controls: keyboard pan/zoom, Tab cycle, Escape stack, right-click deselect
Adds full PC keyboard+mouse support on top of existing touch controls. Touch
paths untouched. All input goes through named actions in project.godot.

Bindings:
- WASD / arrows: camera pan (speed scales with zoom)
- = / -: keyboard zoom in/out
- C / Home: center on selected pawn
- Tab / Shift+Tab: cycle through pawns (pans camera to selection)
- B / L / P / ,: toggle BuildDrawer / AlertsLog / WorkPriorityMatrix / Settings
- Escape: cancel active designation tool > close topmost panel > deselect pawn
- Right-click: cancel active tool or deselect pawn (RTS convention)
- F: speed_cycle (action restored; handler still TODO)
- pawn_prev action removed; Shift+Tab read via event.shift_pressed inline

Escape priority enforced by Designation._input running before _unhandled_input
plus each panel consuming its own cancel action when visible.

Also fixes a pre-existing pre-Phase-17 bug: WorkPriorityMatrix, AlertsLog,
StorytellerModal, LoadMenu, and SettingsMenu had MOUSE_FILTER_STOP Controls
(Backdrop / Dim) that remained input-active when the panel was "closed" —
their open/close paths only toggled _root.visible / _panel.visible, never
CanvasLayer.visible. World mouse events (right-click deselect, left-click
pawn-select) were silently eaten. Now each _set_visible / open / close
toggles self.visible (the CanvasLayer) so input dispatch shuts off properly.

Verified end-to-end via MCP runtime: WASD pan, zoom keys, Tab+Shift+Tab
cycle, B-open + Escape-close, right-click deselect, left-click pawn-select
all working in sequence with no input bleed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:06:38 +01:00

150 lines
5.5 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends Camera2D
## World-view camera rig — pinch-zoom, drag-pan, double-tap-centre.
## All input handled via _unhandled_input so CanvasLayer UI overlays swallow first.
## Call set_world_bounds(rect) once from the world scene after map generation.
const MIN_ZOOM: float = 0.5 # strategic / whole-map-ish
const MAX_ZOOM: float = 4.0 # close / sprite-readable
const BOUNDS_BLEED_PX: int = 32 # 2 tiles of bleed at map edge
const DOUBLE_TAP_MS: int = 300 # window for double-tap detection
const DOUBLE_TAP_DIST_PX: float = 16.0 # 1 tile radius
var target_zoom: float = 1.0
var _dragging: bool = false # desktop left-mouse drag tracking
var _last_tap_time_ms: int = 0
var _last_tap_world_pos: Vector2 = Vector2.ZERO
var _centre_tween: Tween = null
func _ready() -> void:
position_smoothing_enabled = false
# Phase 5 visual polish: default 2.5× — at 1× the world feels sparse on phone
# and pawns become 6-pixel dots; 2.5× shows ~30 tiles wide which is the
# "comfortable inspection" zoom level.
target_zoom = 2.5
zoom = Vector2(target_zoom, target_zoom)
## Pan speed in world pixels per second at zoom 1.0.
## Scales inversely with zoom so on-screen apparent speed feels constant.
const PAN_SPEED_PX_PER_SEC: float = 600.0
func _process(delta: float) -> void:
# Lerp zoom toward target each render frame.
# Plain 0.15 factor is correct at 60 fps; frame-rate independent form used for safety.
var t: float = 1.0 - pow(1.0 - 0.15, delta * 60.0)
zoom = zoom.lerp(Vector2(target_zoom, target_zoom), t)
# Keyboard pan — WASD / arrow keys. Strength gives an analogue-stick-style
# value (0..1) so held-key is continuous and action-just-pressed is not needed.
var pan_input := Vector2(
Input.get_action_strength("pan_right") - Input.get_action_strength("pan_left"),
Input.get_action_strength("pan_down") - Input.get_action_strength("pan_up"),
)
if pan_input != Vector2.ZERO:
position += pan_input.normalized() * (PAN_SPEED_PX_PER_SEC / zoom.x) * delta
func _unhandled_input(event: InputEvent) -> void:
# --- Pinch-zoom via magnify gesture (trackpad + native touch pinch) ---
if event is InputEventMagnifyGesture:
target_zoom = clampf(target_zoom * event.factor, MIN_ZOOM, MAX_ZOOM)
return
# --- Keyboard zoom (= / + / KP_ADD and - / KP_SUBTRACT) ---
if event.is_action_pressed("zoom_in"):
target_zoom = clampf(target_zoom * 1.1, MIN_ZOOM, MAX_ZOOM)
Audit.log("camera", "zoom_in → %.2f" % target_zoom)
return
if event.is_action_pressed("zoom_out"):
target_zoom = clampf(target_zoom / 1.1, MIN_ZOOM, MAX_ZOOM)
Audit.log("camera", "zoom_out → %.2f" % target_zoom)
return
# --- Scroll-wheel zoom (desktop) ---
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed:
target_zoom = clampf(target_zoom * 1.1, MIN_ZOOM, MAX_ZOOM)
return
if event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed:
target_zoom = clampf(target_zoom / 1.1, MIN_ZOOM, MAX_ZOOM)
return
# --- Desktop left-mouse: drag-pan tracking + double-tap detection ---
if event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
_dragging = true
_check_double_tap(event.position)
else:
_dragging = false
return
# --- Touch drag-pan ---
if event is InputEventScreenDrag:
_apply_pan(event.relative)
return
# --- Desktop mouse-motion drag-pan (only while left mouse held) ---
if event is InputEventMouseMotion and _dragging:
_apply_pan(event.relative)
return
# --- Touch press: double-tap detection ---
if event is InputEventScreenTouch and event.pressed:
_check_double_tap(event.position)
func _apply_pan(relative: Vector2) -> void:
# zoom.x > 1 means more zoomed-in; each screen pixel = fewer world units.
position -= relative / zoom.x
func _check_double_tap(screen_pos: Vector2) -> void:
var now_ms: int = Time.get_ticks_msec()
var world_pos: Vector2 = get_canvas_transform().affine_inverse() * screen_pos
var dt: int = now_ms - _last_tap_time_ms
var dist: float = world_pos.distance_to(_last_tap_world_pos)
if dt < DOUBLE_TAP_MS and dist < DOUBLE_TAP_DIST_PX:
_centre_on(world_pos)
# Reset so a triple-tap doesn't re-trigger immediately
_last_tap_time_ms = 0
else:
_last_tap_time_ms = now_ms
_last_tap_world_pos = world_pos
func _centre_on(world_pos: Vector2) -> void:
# Kill any in-progress centre tween before starting a new one
if _centre_tween != null and _centre_tween.is_valid():
_centre_tween.kill()
_centre_tween = create_tween()
_centre_tween.set_ease(Tween.EASE_OUT)
_centre_tween.set_trans(Tween.TRANS_QUAD)
_centre_tween.tween_property(self, "position", world_pos, 0.2)
## Public API — smooth-pan the camera to the centre of a tile (world space).
## Used by StorytellerBanner and StorytellerModal "Go there" buttons.
## Reuses the same _centre_tween slot so a rapid second call replaces the first.
func pan_to_tile(tile: Vector2i) -> void:
const TILE_PX: int = 16
var world_pos := Vector2(tile.x * TILE_PX + TILE_PX / 2, tile.y * TILE_PX + TILE_PX / 2)
_centre_on(world_pos)
Audit.log("camera", "pan_to_tile %s → world %s" % [tile, world_pos])
## Public API — call once from the world scene after map bounds are known.
func set_world_bounds(rect: Rect2) -> void:
limit_left = int(rect.position.x) - BOUNDS_BLEED_PX
limit_top = int(rect.position.y) - BOUNDS_BLEED_PX
limit_right = int(rect.end.x) + BOUNDS_BLEED_PX
limit_bottom = int(rect.end.y) + BOUNDS_BLEED_PX
Audit.log("camera", "bounds set: %s (bleed %d)" % [rect, BOUNDS_BLEED_PX])