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