class_name FarmViewportController extends RefCounted # 农场视口控制器: # - 只缩放土地、背景、房屋、宠物等农场内容,不影响顶部资源栏和弹窗。 # - 单指/鼠标拖动用于预览农场地图,范围限制在 2000x2000。 # - 双指缩放用于移动端;Ctrl + 鼠标滚轮是桌面调试入口。 const ZOOM_STEP := 0.05 const MAP_PREVIEW_SIZE := Vector2(2000.0, 2000.0) const PAN_DRAG_THRESHOLD := 8.0 var _owner: Control var _panel_size := Vector2(640.0, 960.0) var _zoom_min := 1.0 / 1.5 var _zoom_max := 1.3 var _scene_root: Control var _zoom_backdrop: TextureRect var _zoom := 1.0 var _scene_offset := Vector2.ZERO var _active_touches: Dictionary = {} var _pinch_start_distance := 0.0 var _pinch_start_zoom := 1.0 var _pan_started_callback: Callable var _pan_finished_callback: Callable var _touch_pan_start := Vector2.ZERO var _touch_pan_started := false var _mouse_pan_active := false var _mouse_pan_start := Vector2.ZERO var _mouse_pan_started := false func setup(owner: Control, panel_size: Vector2, zoom_min: float, zoom_max: float) -> void: _owner = owner _panel_size = panel_size _zoom_min = zoom_min _zoom_max = zoom_max _setup_scene_root() func scene_root() -> Control: return _scene_root func zoom_backdrop() -> TextureRect: return _zoom_backdrop func zoom() -> float: return _zoom func scene_offset() -> Vector2: return _scene_offset func map_preview_size() -> Vector2: return MAP_PREVIEW_SIZE func set_pan_started_callback(callback: Callable) -> void: _pan_started_callback = callback func set_pan_finished_callback(callback: Callable) -> void: _pan_finished_callback = callback func handle_input(event: InputEvent) -> bool: # ScreenTouch 只记录触点,真正缩放在 ScreenDrag 中根据两点距离比例计算。 var touch_event := event as InputEventScreenTouch if touch_event != null: if touch_event.pressed: _active_touches[touch_event.index] = touch_event.position if _active_touches.size() == 1: _touch_pan_start = touch_event.position _touch_pan_started = false else: var was_touch_panning := _touch_pan_started _active_touches.erase(touch_event.index) _pinch_start_distance = 0.0 if _active_touches.is_empty(): _touch_pan_started = false if was_touch_panning: _notify_scene_pan_finished() if _active_touches.size() == 2: _begin_pinch_if_needed() return true return false var drag_event := event as InputEventScreenDrag if drag_event != null and _active_touches.has(drag_event.index): _active_touches[drag_event.index] = drag_event.position if _active_touches.size() == 2: _update_pinch_zoom() return true if _active_touches.size() == 1: return _handle_touch_pan(drag_event.position, drag_event.relative) var mouse_event := event as InputEventMouseButton if mouse_event != null and Input.is_key_pressed(KEY_CTRL): # Web 预览和桌面调试无法稳定模拟双指时,用 Ctrl + 滚轮验证缩放边界。 if mouse_event.button_index == MOUSE_BUTTON_WHEEL_UP and mouse_event.pressed: set_zoom(_zoom + ZOOM_STEP) return true if mouse_event.button_index == MOUSE_BUTTON_WHEEL_DOWN and mouse_event.pressed: set_zoom(_zoom - ZOOM_STEP) return true if mouse_event != null and mouse_event.button_index == MOUSE_BUTTON_LEFT: if mouse_event.pressed: _mouse_pan_active = true _mouse_pan_start = mouse_event.position _mouse_pan_started = false else: var was_mouse_panning := _mouse_pan_started _mouse_pan_active = false _mouse_pan_started = false if was_mouse_panning: _notify_scene_pan_finished() return false var motion_event := event as InputEventMouseMotion if motion_event != null: if _mouse_pan_active and Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT): return _handle_mouse_pan(motion_event.position, motion_event.relative) _mouse_pan_active = false _mouse_pan_started = false return false func set_zoom(value: float) -> void: # 需求要求最大放大 0.3 倍、最大缩小 1.5 倍;上下限由 bootstrap 传入,统一在这里 clamp。 _zoom = clampf(value, _zoom_min, _zoom_max) _apply_zoom() func _setup_scene_root() -> void: if _owner == null or _scene_root != null: return var ground := _owner.get_node_or_null("Ground") as TextureRect _scene_root = Control.new() _scene_root.name = "FarmSceneRoot" _scene_root.size = MAP_PREVIEW_SIZE _scene_root.mouse_filter = Control.MOUSE_FILTER_IGNORE _scene_root.pivot_offset = _panel_size * 0.5 _owner.add_child(_scene_root) _owner.move_child(_scene_root, 0) if ground != null and ground.texture != null: # 使用同一张背景铺到预览范围;素材不够时先允许拉伸,后续按资源补齐。 _zoom_backdrop = TextureRect.new() _zoom_backdrop.name = "FarmZoomBackdrop" _zoom_backdrop.position = Vector2.ZERO _zoom_backdrop.size = MAP_PREVIEW_SIZE _zoom_backdrop.texture = ground.texture _zoom_backdrop.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED _zoom_backdrop.mouse_filter = Control.MOUSE_FILTER_IGNORE _scene_root.add_child(_zoom_backdrop) for path in ["Ground", "House", "DogHouse", "FenceBack", "GameView", "FenceFront", "PetLayer"]: var node := _owner.get_node_or_null(path) as CanvasItem if node != null: # reparent(true) 保留当前全局变换,迁移到缩放根节点后画面不跳动。 node.reparent(_scene_root, true) _center_land_content_on_map() _scene_offset = _default_scene_offset() _apply_zoom() func _handle_touch_pan(position: Vector2, relative: Vector2) -> bool: if not _touch_pan_started: if _touch_pan_start.distance_to(position) < PAN_DRAG_THRESHOLD: return false _touch_pan_started = true _notify_scene_pan_started() _apply_pan(relative) return true func _handle_mouse_pan(position: Vector2, relative: Vector2) -> bool: if not _mouse_pan_started: if _mouse_pan_start.distance_to(position) < PAN_DRAG_THRESHOLD: return false _mouse_pan_started = true _notify_scene_pan_started() _apply_pan(relative) return true func _apply_pan(delta: Vector2) -> void: if _scene_root == null: return _scene_offset = _clamped_scene_offset(_scene_offset + delta) _scene_root.position = _scene_offset func _notify_scene_pan_started() -> void: if _pan_started_callback.is_valid(): _pan_started_callback.call() func _notify_scene_pan_finished() -> void: if _pan_finished_callback.is_valid(): _pan_finished_callback.call() func _begin_pinch_if_needed() -> void: if _pinch_start_distance > 0.0: return var points := _active_touches.values() # 记录初始两指距离和当前缩放,后续按距离比例换算目标 zoom。 _pinch_start_distance = maxf((points[0] as Vector2).distance_to(points[1] as Vector2), 1.0) _pinch_start_zoom = _zoom _notify_scene_pan_started() func _update_pinch_zoom() -> void: var points := _active_touches.values() var distance := maxf((points[0] as Vector2).distance_to(points[1] as Vector2), 1.0) if _pinch_start_distance <= 0.0: _pinch_start_distance = distance _pinch_start_zoom = _zoom return set_zoom(_pinch_start_zoom * distance / _pinch_start_distance) func _apply_zoom() -> void: if _scene_root == null: return _scene_root.scale = Vector2(_zoom, _zoom) _scene_offset = _clamped_scene_offset(_scene_offset) _scene_root.position = _scene_offset if _zoom_backdrop != null: _zoom_backdrop.visible = true var ground := _scene_root.get_node_or_null("Ground") as TextureRect if ground != null: ground.visible = false func _clamped_scene_offset(value: Vector2) -> Vector2: var scaled_map_size := MAP_PREVIEW_SIZE * _zoom var min_offset := _panel_size - scaled_map_size return Vector2( clampf(value.x, minf(min_offset.x, 0.0), 0.0), clampf(value.y, minf(min_offset.y, 0.0), 0.0) ) func _default_scene_offset() -> Vector2: return _clamped_scene_offset(_panel_size * 0.5 - MAP_PREVIEW_SIZE * 0.5 * _zoom) func _center_land_content_on_map() -> void: if _scene_root == null: return var land_rect := _land_content_rect() if land_rect.size == Vector2.ZERO: return var delta := MAP_PREVIEW_SIZE * 0.5 - (land_rect.position + land_rect.size * 0.5) for path in ["House", "DogHouse", "FenceBack", "GameView", "FenceFront", "PetLayer"]: var node := _scene_root.get_node_or_null(path) as Control if node != null: node.position += delta func _land_content_rect() -> Rect2: var game_view := _scene_root.get_node_or_null("GameView") as Control if _scene_root != null else null if game_view == null: return Rect2() var has_rect := false var bounds := Rect2() for child in game_view.get_children(): var control := child as Control if control == null: continue if not _is_land_node_name(String(control.name)): continue var rect := Rect2(game_view.position + control.position, control.size) if not has_rect: bounds = rect has_rect = true else: bounds = bounds.merge(rect) return bounds if has_rect else Rect2() func _is_land_node_name(node_name: String) -> bool: if not node_name.begins_with("Land"): return false var suffix := node_name.substr(4) return not suffix.is_empty() and suffix.is_valid_int() and int(suffix) >= 0