From d500b0a3be7b37e21f5e49f0798c2a934f9596fc Mon Sep 17 00:00:00 2001
From: k9er <k9wolf@pm.me>
Date: Sun, 19 Jan 2025 18:54:07 +0000
Subject: [PATCH] Implement zoom scrolling

---
 qcsrc/client/main.qc                          |   4 +
 qcsrc/client/view.qc                          | 120 +++++++++++++-----
 qcsrc/client/view.qh                          |   5 +
 .../menu/xonotic/dialog_settings_game_view.qc |  24 ++++
 xonotic-client.cfg                            |   3 +
 5 files changed, 125 insertions(+), 31 deletions(-)

diff --git a/qcsrc/client/main.qc b/qcsrc/client/main.qc
index 568f0f5a0a..54299e5245 100644
--- a/qcsrc/client/main.qc
+++ b/qcsrc/client/main.qc
@@ -493,6 +493,10 @@ float CSQC_InputEvent(int bInputType, float nPrimary, float nSecondary)
 	if (override)
 		return true;
 
+	override |= View_InputEvent(bInputType, nPrimary, nSecondary);
+	if (override)
+		return true;
+
 	override |= HUD_Panel_Chat_InputEvent(bInputType, nPrimary, nSecondary);
 
 	override |= QuickMenu_InputEvent(bInputType, nPrimary, nSecondary);
diff --git a/qcsrc/client/view.qc b/qcsrc/client/view.qc
index 583f167dc7..4911dc4168 100644
--- a/qcsrc/client/view.qc
+++ b/qcsrc/client/view.qc
@@ -421,22 +421,9 @@ STATIC_INIT(fpscounter_init)
 	showfps_prevfps_time = currentTime; // we must initialize it to avoid an instant low frame sending
 }
 
-float avgspeed;
-vector GetCurrentFov(float fov)
+bool IsZooming()
 {
-	float zoomsensitivity, zoomspeed, zoomfactor, zoomdir;
-	float velocityzoom, curspeed;
-	vector v;
-
-	zoomsensitivity = autocvar_cl_zoomsensitivity;
-	zoomfactor = autocvar_cl_zoomfactor;
-	if(zoomfactor < 1 || zoomfactor > 30)
-		zoomfactor = 2.5;
-	zoomspeed = autocvar_cl_zoomspeed;
-	if (zoomspeed >= 0 && (zoomspeed < 0.5 || zoomspeed > 16))
-		zoomspeed = 3.5;
-
-	zoomdir = button_zoom;
+	bool zoomdir = button_zoom;
 
 	if(hud == HUD_NORMAL && !spectatee_status)
 	{
@@ -453,38 +440,109 @@ vector GetCurrentFov(float fov)
 			}
 		}
 	}
-	if(spectatee_status > 0 || isdemo())
+
+	if(spectatorbutton_zoom && (spectatee_status > 0 || isdemo()))
+		zoomdir = !zoomdir;
+
+	return zoomdir;
+}
+
+const float MAX_ZOOMFACTOR = 30;
+float zoomscroll_factor;
+float zoomscroll_factor_target;
+void ZoomScroll(float nPrimary)
+{
+	bool zoomin = (nPrimary == K_MWHEELUP);
+	if (autocvar_cl_zoomscroll_scale < 0)
+		zoomin = !zoomin;
+
+	// allow negatives so that players can scroll the other way, biggest change allowed is +100%
+	float zoomscroll_scale = 1 + min(fabs(autocvar_cl_zoomscroll_scale), 1);
+
+	if (zoomin)
+		zoomscroll_factor_target = min(MAX_ZOOMFACTOR, zoomscroll_factor_target * zoomscroll_scale);
+	else
+		zoomscroll_factor_target = max(1, zoomscroll_factor_target / zoomscroll_scale);
+}
+
+bool View_InputEvent(int bInputType, float nPrimary, float nSecondary)
+{
+	if (bInputType == 2 || bInputType == 3)
+		return false;
+
+	// at this point bInputType can only be 0 or 1 (key pressed or released)
+	bool key_pressed = (bInputType == 0);
+
+	if (nPrimary == K_MWHEELUP || nPrimary == K_MWHEELDOWN)
 	{
-		if(spectatorbutton_zoom)
-		{
-			if(zoomdir)
-				zoomdir = 0;
-			else
-				zoomdir = 1;
-		}
-		// fteqcc failed twice here already, don't optimize this
+		if (!autocvar_cl_zoomscroll || autocvar_cl_zoomscroll_scale == 0)
+			return false; // zoom scroll disabled
+		if (!IsZooming())
+			return false;
+		if (key_pressed)
+			ZoomScroll(nPrimary);
+		return true;
 	}
 
-	if(zoomdir) { zoomin_effect = 0; }
+	return false;
+}
+
+float avgspeed;
+vector GetCurrentFov(float fov)
+{
+	float zoomsensitivity, zoomspeed, zoomfactor, zoomdir;
+	float velocityzoom, curspeed;
+	vector v;
+
+	zoomdir = IsZooming();
+
+	zoomspeed = autocvar_cl_zoomspeed;
+	if(zoomspeed >= 0 && (zoomspeed < 0.5 || zoomspeed > 16))
+		zoomspeed = 3.5;
+	zoomsensitivity = autocvar_cl_zoomsensitivity;
+
+	zoomfactor = autocvar_cl_zoomfactor;
+	if(zoomfactor < 1 || zoomfactor > MAX_ZOOMFACTOR)
+		zoomfactor = 2.5;
+
+	if(zoomdir)
+		zoomin_effect = 0;
 
 	if (spectatee_status > 0 && STAT(CAMERA_SPECTATOR) == 2)
-	{
 		current_viewzoom = 1;
-	}
 	else if (camera_active)
-	{
 		current_viewzoom = min(1, current_viewzoom + drawframetime);
-	}
 	else if(autocvar_cl_spawnzoom && zoomin_effect)
 	{
-		float spawnzoomfactor = bound(1, autocvar_cl_spawnzoom_factor, 30);
+		float spawnzoomfactor = bound(1, autocvar_cl_spawnzoom_factor, MAX_ZOOMFACTOR);
 
 		current_viewzoom += (autocvar_cl_spawnzoom_speed * (spawnzoomfactor - current_viewzoom) * drawframetime);
 		current_viewzoom = bound(1 / spawnzoomfactor, current_viewzoom, 1);
-		if(current_viewzoom == 1) { zoomin_effect = 0; }
+		if(current_viewzoom == 1)
+			zoomin_effect = 0;
 	}
 	else
 	{
+		// initialize zoom scroll in the first frame / reset zoom scroll when fully zoomed out
+		if (!zoomscroll_factor || (current_viewzoom == 1 && !zoomdir))
+		{
+			zoomscroll_factor_target = zoomfactor;
+			zoomscroll_factor = zoomfactor;
+		}
+		if (zoomscroll_factor != zoomscroll_factor_target)
+		{
+			if (fabs(zoomscroll_factor - zoomscroll_factor_target) < 0.001 || autocvar_cl_zoomscroll_speed < 0) // snap
+				zoomscroll_factor = zoomscroll_factor_target;
+			else if (autocvar_cl_zoomscroll_speed != 0)
+			{
+				// NOTE: this averaging formula is frametime independent
+				float avg_time = 1 / autocvar_cl_zoomscroll_speed;
+				float frac = 1 - exp(-drawframetime / max(0.001, avg_time));
+				zoomscroll_factor = frac * zoomscroll_factor_target + (1 - frac) * zoomscroll_factor;
+			}
+		}
+		zoomfactor = zoomscroll_factor;
+
 		if(zoomspeed < 0) // instant zoom
 		{
 			if(zoomdir)
diff --git a/qcsrc/client/view.qh b/qcsrc/client/view.qh
index cd33ebfb6a..6e370da737 100644
--- a/qcsrc/client/view.qh
+++ b/qcsrc/client/view.qh
@@ -32,6 +32,9 @@ float autocvar_cl_velocityzoom_time;
 float autocvar_cl_zoomfactor;
 float autocvar_cl_zoomsensitivity;
 float autocvar_cl_zoomspeed;
+bool autocvar_cl_zoomscroll = 1;
+float autocvar_cl_zoomscroll_scale = 0.2;
+float autocvar_cl_zoomscroll_speed = 16;
 float autocvar_fov;
 float autocvar_hud_colorflash_alpha;
 bool autocvar_hud_contents;
@@ -127,3 +130,5 @@ float blurtest_time0, blurtest_time1, blurtest_radius, blurtest_power;
 
 float intermission_time;
 float game_stopped_time;
+
+bool View_InputEvent(int bInputType, float nPrimary, float nSecondary);
diff --git a/qcsrc/menu/xonotic/dialog_settings_game_view.qc b/qcsrc/menu/xonotic/dialog_settings_game_view.qc
index b4c170280c..5e667aa1ca 100644
--- a/qcsrc/menu/xonotic/dialog_settings_game_view.qc
+++ b/qcsrc/menu/xonotic/dialog_settings_game_view.qc
@@ -95,6 +95,30 @@ void XonoticGameViewSettingsTab_fill(entity me)
 		me.TD(me, 1, 1, e = makeXonoticTextLabel(0, ZCTX(_("ZOOM^Zoom sensitivity:"))));
 		me.TD(me, 1, 2, e = makeXonoticSlider_T(0, 1, 0.1, "cl_zoomsensitivity",
 			_("How zoom changes sensitivity, from 0 (lower sensitivity) to 1 (no sensitivity change)")));
+	me.TR(me);
+		me.TD(me, 1, 1, e = makeXonoticCheckBox(0, "cl_zoomscroll", _("Zoom scrolling")));
+	me.TR(me);
+		me.TDempty(me, 0.2);
+		me.TD(me, 1, 0.8, e = makeXonoticTextLabel(0, _("Scale:")));
+			setDependent(e, "cl_zoomscroll", 1, 1);
+		me.TD(me, 1, 2, e = makeXonoticSlider(-1, 1, 0.1, "cl_zoomscroll_scale")); // would be better if this could skip 0
+			setDependent(e, "cl_zoomscroll", 1, 1);
+	me.TR(me);
+		me.TDempty(me, 0.2);
+		me.TD(me, 1, 0.8, e = makeXonoticTextLabel(0, _("Speed:")));
+			setDependent(e, "cl_zoomscroll", 1, 1);
+		me.TD(me, 1, 2, e = makeXonoticTextSlider("cl_zoomscroll_speed"));
+			e.addValue(e, "2", "2"); // Samual: for() loop doesn't work here, even though it would make sense.
+			e.addValue(e, "4", "4");
+			e.addValue(e, "6", "6");
+			e.addValue(e, "8", "8");
+			e.addValue(e, "10", "10");
+			e.addValue(e, "12", "12");
+			e.addValue(e, "14", "14");
+			e.addValue(e, "16", "16");
+			e.addValue(e, ZCTX(_("ZOOM^Instant")), "-1");
+			e.configureXonoticTextSliderValues(e);
+			setDependent(e, "cl_zoomscroll", 1, 1);
 	me.TR(me);
 	me.TR(me);
 		me.TD(me, 1, 1, e = makeXonoticCheckBox(0, "cl_velocityzoom_enabled", _("Velocity zoom")));
diff --git a/xonotic-client.cfg b/xonotic-client.cfg
index 5b620f0070..e85cecc613 100644
--- a/xonotic-client.cfg
+++ b/xonotic-client.cfg
@@ -61,6 +61,9 @@ seta cl_spawnzoom_factor 2 "factor of zoom while spawning"
 seta cl_zoomfactor 5	"how much +zoom will zoom (1-30)"
 seta cl_zoomspeed 8	"how fast +zoom will zoom (0.5-16), negative values mean instant zoom"
 seta cl_zoomsensitivity 0	"how zoom changes sensitivity; \"0\" = weakest, \"1\" = strongest"
+seta cl_zoomscroll 1 "allow scrolling to zoom in or out further while holding +zoom"
+seta cl_zoomscroll_scale 0.2 "percentage of zoom change on scroll (maximum 1, negative values change the scroll direction)"
+seta cl_zoomscroll_speed 16 "zoom scroll speed, negative values mean instant change when scrolling"
 
 seta cl_unpress_zoom_on_spawn 1 "automatically unpress zoom when you spawn"
 seta cl_unpress_zoom_on_death 1 "automatically unpress zoom when you die (and don't allow zoom again while dead)"
-- 
2.39.5