extends Camera3D ## MOBA-style game camera: a perspective three-quarter view whose angle steepens as it ## zooms out (flat and close for fights, steep for the whole map). `zoom` is the visible ## ground height at the focus, the same measure the old orthographic camera used, so lane, ## base and grotto framings keep their meaning. The focus follows the terrain height. ## Blau's base sits bottom-left, Rot's top-right, as on the minimap (yaw 0). const FOV_DEGREES: float = 34.0 const MIN_ZOOM: float = 11.0 const MAX_ZOOM: float = 100.0 const DEFAULT_ZOOM: float = 34.0 ## Pitch at the closest and the widest zoom. const PITCH_NEAR: float = -48.0 const PITCH_FAR: float = -70.0 const FOCUS_LIMIT: float = 42.0 var focus := Vector3.ZERO var yaw: float = 0.0 var zoom: float = DEFAULT_ZOOM var reduced_motion: bool = false ## Terrain height under a ground point; the world sets it (flat without a world). var ground: Callable = func(_p: Vector2) -> float: return 0.0 var _goal_focus := Vector3.ZERO var _goal_zoom: float = DEFAULT_ZOOM var _goal_yaw: float = 0.0 var _dragging: bool = false func _ready() -> void: projection = PROJECTION_PERSPECTIVE fov = FOV_DEGREES near = .5 far = 700.0 current = true _goal_focus = focus _apply() static func pitch_for(value: float) -> float: return deg_to_rad(lerpf(PITCH_NEAR, PITCH_FAR, smoothstep(14.0, MAX_ZOOM, value))) func distance_for(value: float) -> float: return value / (2.0 * tan(deg_to_rad(fov) * 0.5)) func look_at_point(point: Vector3, new_zoom: float = -1.0, instant: bool = false) -> void: _goal_focus = Vector3(clampf(point.x, -FOCUS_LIMIT, FOCUS_LIMIT), 0, clampf(point.z, -FOCUS_LIMIT, FOCUS_LIMIT)) if new_zoom > 0.0: _goal_zoom = clampf(new_zoom, MIN_ZOOM, MAX_ZOOM) if instant or reduced_motion: focus = _goal_focus focus.y = ground.call(Vector2(focus.x, focus.z)) zoom = _goal_zoom yaw = _goal_yaw _apply() ## The whole map (both bases, all lanes and the grotto) at once. func overview(instant: bool = false) -> void: look_at_point(Vector3.ZERO, 92.0, instant) ## Quarter turns around the focus; R returns to the map-aligned view. func rotate_step(direction: int) -> void: _goal_yaw += direction * PI * 0.25 if reduced_motion: yaw = _goal_yaw _apply() func reset_view(point: Vector3, instant: bool = false) -> void: _goal_yaw = 0.0 look_at_point(point, DEFAULT_ZOOM, instant) if instant: yaw = 0.0 _apply() func _apply() -> void: var back := Vector3(0, 0, 1).rotated(Vector3.RIGHT, pitch_for(zoom)).rotated(Vector3.UP, yaw) position = focus + back * distance_for(zoom) look_at(focus, Vector3.UP) func _process(delta: float) -> void: var pan := Vector2(Input.get_axis("ui_left", "ui_right"), Input.get_axis("ui_up", "ui_down")) if Input.is_physical_key_pressed(KEY_A): pan.x -= 1 if Input.is_physical_key_pressed(KEY_D): pan.x += 1 if Input.is_physical_key_pressed(KEY_W): pan.y -= 1 if Input.is_physical_key_pressed(KEY_S): pan.y += 1 if pan != Vector2.ZERO and not _text_focus(): var right := Vector3(cos(yaw), 0, -sin(yaw)) var forward := Vector3(-sin(yaw), 0, -cos(yaw)) _goal_focus += (right * pan.x - forward * pan.y) * zoom * .9 * delta _goal_focus.x = clampf(_goal_focus.x, -FOCUS_LIMIT, FOCUS_LIMIT) _goal_focus.z = clampf(_goal_focus.z, -FOCUS_LIMIT, FOCUS_LIMIT) var t: float = 1.0 if reduced_motion else minf(1.0, delta * 7.0) var target_height: float = ground.call(Vector2(_goal_focus.x, _goal_focus.z)) focus = focus.lerp(Vector3(_goal_focus.x, target_height, _goal_focus.z), t) zoom = lerpf(zoom, _goal_zoom, t) yaw = lerp_angle(yaw, _goal_yaw, t) _apply() func _text_focus() -> bool: var owner: Control = get_viewport().gui_get_focus_owner() return owner is LineEdit or owner is TextEdit ## Ground outline of the current view (four corners on the focus plane) for the minimap. func footprint() -> PackedVector2Array: var result := PackedVector2Array() var viewport: Viewport = get_viewport() if viewport == null: return result var extent: Vector2 = viewport.get_visible_rect().size for corner: Vector2 in [Vector2.ZERO, Vector2(extent.x, 0), extent, Vector2(0, extent.y)]: var origin: Vector3 = project_ray_origin(corner) var direction: Vector3 = project_ray_normal(corner) if direction.y > -0.02: direction.y = -0.02 var hit: Vector3 = origin + direction * ((focus.y - origin.y) / direction.y) result.append(Vector2(hit.x, hit.z)) return result ## World input only; the HUD consumes its own clicks before they reach here. func handle_world_input(event: InputEvent) -> bool: if event is InputEventMouseButton and event.pressed: if event.button_index == MOUSE_BUTTON_WHEEL_UP: _goal_zoom = maxf(MIN_ZOOM, _goal_zoom - maxf(2.5, _goal_zoom * .09)) return true if event.button_index == MOUSE_BUTTON_WHEEL_DOWN: _goal_zoom = minf(MAX_ZOOM, _goal_zoom + maxf(2.5, _goal_zoom * .09)) return true if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_MIDDLE: _dragging = event.pressed return true if event is InputEventMouseMotion and _dragging: var right := Vector3(cos(yaw), 0, -sin(yaw)) var forward := Vector3(-sin(yaw), 0, -cos(yaw)) _goal_focus -= (right * event.relative.x - forward * event.relative.y) * zoom / 700.0 _goal_focus.x = clampf(_goal_focus.x, -FOCUS_LIMIT, FOCUS_LIMIT) _goal_focus.z = clampf(_goal_focus.z, -FOCUS_LIMIT, FOCUS_LIMIT) return true return false