hy-farm/godot/FarmGodot/scripts/ui/farm/farm_viewport_controller.gd
2026-05-29 19:54:56 +08:00

289 lines
9.0 KiB
GDScript
Raw Permalink 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.

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