From a876709a823f471ec5254a5fab0b2fc37be4cd15 Mon Sep 17 00:00:00 2001 From: equalchance <182705210+equalchance@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:51:32 +0300 Subject: [PATCH 01/14] Add mouse-wheel battlefield zoom --- code/_rect.cpp | 1 + code/_rect.h | 1 + code/battlezoom.cpp | 311 +++++++++++++++++++++ code/battlezoom.h | 38 +++ code/battlezoomlayout.cpp | 158 +++++++++++ code/battlezoomlayout.h | 41 +++ code/cctooltip.cpp | 6 +- code/display.cpp | 42 +-- code/gamewindow.cpp | 23 +- code/gscreen.cpp | 11 +- code/mouse.cpp | 3 + code/radar.cpp | 4 +- code/scenario.cpp | 6 +- code/scroll.cpp | 7 +- code/sidebar.cpp | 22 +- code/tab.cpp | 6 +- code/taction.cpp | 3 + code/wwmouse.cpp | 9 +- manual/changes/battlefield-zoom.md | 15 + manual/content/systems/battlefield-zoom.md | 40 +++ manual/data/command-adapters.yaml | 6 + tests/CMakeLists.txt | 1 + tests/battlezoom/CMakeLists.txt | 9 + tests/battlezoom/battlezoomcontract.cpp | 120 ++++++++ 24 files changed, 835 insertions(+), 48 deletions(-) create mode 100644 code/battlezoom.cpp create mode 100644 code/battlezoom.h create mode 100644 code/battlezoomlayout.cpp create mode 100644 code/battlezoomlayout.h create mode 100644 manual/changes/battlefield-zoom.md create mode 100644 manual/content/systems/battlefield-zoom.md create mode 100644 tests/battlezoom/CMakeLists.txt create mode 100644 tests/battlezoom/battlezoomcontract.cpp diff --git a/code/_rect.cpp b/code/_rect.cpp index b80320480..781a5efce 100644 --- a/code/_rect.cpp +++ b/code/_rect.cpp @@ -13,6 +13,7 @@ #include "rect.h" +Rect BattlefieldRect(0,0,640,400); Rect TacticalRect(0,0,640,400); Rect VisibleRect(0,0,640,400); Rect SidebarRect(0,0,0,0); diff --git a/code/_rect.h b/code/_rect.h index 7026f0d52..adebab33a 100644 --- a/code/_rect.h +++ b/code/_rect.h @@ -12,6 +12,7 @@ template class TRect; typedef TRect Rect; +extern Rect BattlefieldRect; extern Rect SidebarRect; extern Rect TacticalRect; extern Rect VisibleRect; diff --git a/code/battlezoom.cpp b/code/battlezoom.cpp new file mode 100644 index 000000000..46c91c966 --- /dev/null +++ b/code/battlezoom.cpp @@ -0,0 +1,311 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "always.h" + +#include "battlezoom.h" + +#include "_keyboar.h" +#include "_map.h" +#include "_rect.h" +#include "_surface.h" +#include "_tactica.h" +#include "battlezoomlayout.h" +#include "globals.h" +#include "keyboard.h" +#include "session.h" +#include "surface.h" +#include "tactical.h" +#include "win.h" + +#include + + +static int _Zoom = BATTLE_ZOOM_MIN; +static int _Pending = BATTLE_ZOOM_MIN; +static bool _Applying = false; + +static bool _HasAnchor = false; +static Point2D _Anchor(0, 0); +static float _WheelRemainder = 0.0f; + +static int _ButtonsHeld = 0; +static bool _DragInBattlefield = false; + + +static bool Can_Zoom(void) +{ + return(ScenarioActive && GameActive && GameInFocus && !Debug_Map && !IgnoreInput && TacticalMap != NULL && TacticalMap->ZoomFactor == 1.0); +} + + +static bool Is_Zoomed(void) +{ + return(_Zoom > BATTLE_ZOOM_MIN && ScenarioActive); +} + + +/// +/// Fetches the zoom factor the battlefield is shown at. +/// +/// int; 1 when the battlefield is not zoomed. +int Battle_Zoom_Factor(void) +{ + return(_Zoom); +} + + +/// +/// Is the tactical view being resized for a zoom change rather than a new battlefield area? +/// +bool Battle_Zoom_Applying(void) +{ + return(_Applying); +} + + +/// +/// Records the frame area the battlefield occupies and returns the native view for it. The +/// zoom factor drops to the nearest one the area allows. +/// +/// The battlefield area in frame pixels. +/// Rect; The native tactical view, which equals the area when the battlefield is not zoomed. +Rect Battle_Zoom_Fit_View(Rect const & battlefield) +{ + BattlefieldRect = battlefield; + _Zoom = Battle_Zoom_Nearest_Allowed(battlefield, _Zoom); + _Pending = _Zoom; + + return(Battle_Zoom_View_Rect(battlefield, _Zoom)); +} + + +/// +/// Returns the battlefield to its unzoomed size about the same view centre, and drops any zoom +/// change still waiting. Messages on screen are kept. +/// +void Battle_Zoom_Reset(void) +{ + _Pending = BATTLE_ZOOM_MIN; + _HasAnchor = false; + _WheelRemainder = 0.0f; + + if (_Zoom != BATTLE_ZOOM_MIN) { + _Zoom = BATTLE_ZOOM_MIN; + _Applying = true; + Map.Set_View_Dimensions(BattlefieldRect); + _Applying = false; + Map.Flag_To_Redraw(GS_REDRAW_ALL); + } +} + + +/// +/// Applies a zoom change asked for by the wheel. The change waits while a key press is pending +/// or a mouse button is held, and is dropped while the player cannot zoom. The world position +/// under the cursor when the wheel turned stays under it. +/// +/// Is a key or button press waiting to be processed this frame? +void Battle_Zoom_AI(bool input_pending) +{ + if (!Can_Zoom()) { + _Pending = _Zoom; + _HasAnchor = false; + return; + } + + // A release the interface took never reaches Battle_Zoom_Mouse_Button. + if (Keyboard == NULL || (!Keyboard->Down(KN_LMOUSE) && !Keyboard->Down(KN_RMOUSE))) { + Battle_Zoom_Release_Buttons(); + } + + if (_Pending == _Zoom || input_pending || _ButtonsHeld > 0) { + return; + } + + Point2D frame = _HasAnchor ? _Anchor : Point2D(BattlefieldRect.X + BattlefieldRect.Width / 2, BattlefieldRect.Y + BattlefieldRect.Height / 2); + Rect const before_view = TacticalRect; + Point2D const before_point = Battle_Zoom_Screen_To_View(frame, BattlefieldRect, _Zoom); + Point2D const center = TacticalMap->Get_Tactical_Position(); + + _Zoom = _Pending; + _Applying = true; + Map.Set_View_Dimensions(BattlefieldRect); + _Applying = false; + + Point2D const after_point = Battle_Zoom_Screen_To_View(frame, BattlefieldRect, _Zoom); + TacticalMap->Set_Tactical_Position(Battle_Zoom_Anchored_Center(center, before_view, before_point, TacticalRect, after_point)); + + Map.Flag_To_Redraw(GS_REDRAW_ALL); + _HasAnchor = false; +} + + +/// +/// Takes a vertical wheel movement for the battlefield zoom. +/// +/// Wheel notches, positive away from the player. +/// The pointer position in frame pixels. +/// bool; Was the movement used here? A movement outside the battlefield, or one made +/// while the player cannot zoom, is left for the sidebar. +bool Battle_Zoom_Wheel(float wheel, Point2D const & frame) +{ + if (!Can_Zoom() || !BattlefieldRect.Is_Point_Within(frame)) { + _WheelRemainder = 0.0f; + return(false); + } + + if (_ButtonsHeld > 0) { + _WheelRemainder = 0.0f; + return(true); + } + + int steps = Battle_Zoom_Wheel_Steps(_WheelRemainder, wheel); + if (steps != 0) { + _Pending = Battle_Zoom_Nearest_Allowed(BattlefieldRect, std::clamp(_Pending + steps, (int)BATTLE_ZOOM_MIN, (int)BATTLE_ZOOM_MAX)); + _Anchor = frame; + _HasAnchor = true; + } + + return(true); +} + + +/// +/// Tracks a mouse button press or release. While the button that started a press over the +/// battlefield is held, the pointer stays mapped onto the battlefield, so a drag that leaves +/// it ends at the edge of the view. +/// +/// Is this a press? +/// The pointer position in frame pixels. +void Battle_Zoom_Mouse_Button(bool down, Point2D const & frame) +{ + if (down) { + if (_ButtonsHeld++ == 0) { + _DragInBattlefield = BattlefieldRect.Is_Point_Within(frame); + } + } else if (_ButtonsHeld > 0 && --_ButtonsHeld == 0) { + _DragInBattlefield = false; + } +} + + +/// +/// Forgets every held mouse button, as when the window loses the keyboard focus. +/// +void Battle_Zoom_Release_Buttons(void) +{ + _ButtonsHeld = 0; + _DragInBattlefield = false; +} + + +/// +/// Converts a frame position into the coordinates the game uses. A position over the zoomed +/// battlefield becomes a native view position; any other position is left alone. +/// +/// The position to convert in place. +void Battle_Zoom_Frame_To_View(Point2D & point) +{ + if (!Is_Zoomed()) { + return; + } + + if (_DragInBattlefield) { + point.X = std::clamp(point.X, BattlefieldRect.X, BattlefieldRect.X + BattlefieldRect.Width - 1); + point.Y = std::clamp(point.Y, BattlefieldRect.Y, BattlefieldRect.Y + BattlefieldRect.Height - 1); + } + + point = Battle_Zoom_Screen_To_View(point, BattlefieldRect, _Zoom); +} + + +/// +/// Converts a native view position back into the frame. +/// +/// Point2D; The frame position, or the position unchanged when the battlefield is not +/// zoomed or the position lies outside the native view. +Point2D Battle_Zoom_View_To_Frame(Point2D const & point) +{ + if (!Is_Zoomed()) { + return(point); + } + + return(Battle_Zoom_View_To_Screen(point, BattlefieldRect, _Zoom)); +} + + +/// +/// Converts a native view position into the frame position the edge scroll tests. A position +/// on an edge of the native view lands on the same edge of the battlefield area, so the frame +/// pixels a whole-number magnification leaves unfilled still count as that edge. +/// +Point2D Battle_Zoom_Edge_Point(Point2D const & point) +{ + if (!Is_Zoomed() || !TacticalRect.Is_Point_Within(point)) { + return(point); + } + + Point2D frame = Battle_Zoom_View_To_Screen(point, BattlefieldRect, _Zoom); + + if (point.X == TacticalRect.X) { + frame.X = BattlefieldRect.X; + } else if (point.X == TacticalRect.X + TacticalRect.Width - 1) { + frame.X = BattlefieldRect.X + BattlefieldRect.Width - 1; + } + + if (point.Y == TacticalRect.Y) { + frame.Y = BattlefieldRect.Y; + } else if (point.Y == TacticalRect.Y + TacticalRect.Height - 1) { + frame.Y = BattlefieldRect.Y + BattlefieldRect.Height - 1; + } + + return(frame); +} + + +/// +/// Shows the composite surface with the battlefield magnified. The tab strip is copied as it +/// is, and chat and game messages are drawn at frame size over the magnified battlefield. +/// +/// The surface being presented. +/// bool; Was the surface shown here? It is not when the battlefield is not zoomed, +/// when the surface is not the composite surface, or while a trigger zoom is in effect. +bool Battle_Zoom_Present(Surface & surface) +{ + if (!Is_Zoomed() || &surface != CompositeSurface || VisibleSurface == NULL || TacticalMap == NULL || TacticalMap->ZoomFactor != 1.0) { + return(false); + } + + Rect strip(0, 0, std::min(surface.Get_Width(), BattlefieldRect.X + BattlefieldRect.Width), BattlefieldRect.Y); + if (strip.Is_Valid()) { + VisibleSurface->Blit_From(strip, surface, strip, false, true); + } + + // Screen shake moves the magnified picture by whole native pixels. + Rect const screen = Battle_Zoom_Screen_Rect(BattlefieldRect, _Zoom); + Rect const source = TacticalRect - Point2D(Map.ScreenX / _Zoom, Map.ScreenY / _Zoom); + Rect const shown = Intersect(source, TacticalRect); + + Rect const dest(screen.X + (shown.X - source.X) * _Zoom, screen.Y + (shown.Y - source.Y) * _Zoom, shown.Width * _Zoom, shown.Height * _Zoom); + if (!(dest == BattlefieldRect)) { + VisibleSurface->Fill_Rect(BattlefieldRect, 0); + } + + if (shown.Is_Valid()) { + VisibleSurface->Blit_From(dest, surface, shown, false, true); + } + + Surface * oldpage = LogicalSurface; + LogicalSurface = VisibleSurface; + Session.Messages.Draw(); + LogicalSurface = oldpage; + + return(true); +} diff --git a/code/battlezoom.h b/code/battlezoom.h new file mode 100644 index 000000000..5a27fbdf3 --- /dev/null +++ b/code/battlezoom.h @@ -0,0 +1,38 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// Battlefield zoom: the mouse wheel over the tactical map magnifies it by a whole number while +// the sidebar and tab strip keep their size. BattlefieldRect holds the frame area the map +// occupies and TacticalRect the smaller native view that is rendered and magnified into it. +// Frame positions over the battlefield reach the game in native view pixels. + +#pragma once + +#include "point.h" +#include "rect.h" + +class Surface; + + +int Battle_Zoom_Factor(void); + +Rect Battle_Zoom_Fit_View(Rect const & battlefield); +bool Battle_Zoom_Applying(void); +void Battle_Zoom_Reset(void); +void Battle_Zoom_AI(bool input_pending); + +bool Battle_Zoom_Wheel(float wheel, Point2D const & frame); + +void Battle_Zoom_Mouse_Button(bool down, Point2D const & frame); +void Battle_Zoom_Release_Buttons(void); +void Battle_Zoom_Frame_To_View(Point2D & point); +Point2D Battle_Zoom_View_To_Frame(Point2D const & point); +Point2D Battle_Zoom_Edge_Point(Point2D const & point); + +bool Battle_Zoom_Present(Surface & surface); diff --git a/code/battlezoomlayout.cpp b/code/battlezoomlayout.cpp new file mode 100644 index 000000000..de289d7bd --- /dev/null +++ b/code/battlezoomlayout.cpp @@ -0,0 +1,158 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "battlezoomlayout.h" + +#include +#include +#include + + +/// +/// Can the battlefield area show the tactical view at this zoom factor? +/// +/// bool; True for the unzoomed view of any valid area, and for a larger factor whose +/// native view keeps the minimum size. +bool Battle_Zoom_Allowed(Rect const & battlefield, int zoom) +{ + if (!battlefield.Is_Valid() || zoom < BATTLE_ZOOM_MIN || zoom > BATTLE_ZOOM_MAX) { + return(false); + } + + if (zoom == BATTLE_ZOOM_MIN) { + return(true); + } + + return(battlefield.Width / zoom >= BATTLE_ZOOM_MIN_VIEW_WIDTH && battlefield.Height / zoom >= BATTLE_ZOOM_MIN_VIEW_HEIGHT); +} + + +/// +/// Finds the allowed zoom factor closest to the one asked for, taking the smaller factor on a tie. +/// +/// int; The factor to use, which is the unzoomed factor when no other fits. +int Battle_Zoom_Nearest_Allowed(Rect const & battlefield, int zoom) +{ + int best = BATTLE_ZOOM_MIN; + + for (int factor = BATTLE_ZOOM_MIN; factor <= BATTLE_ZOOM_MAX; factor++) { + if (Battle_Zoom_Allowed(battlefield, factor) && std::abs(factor - zoom) < std::abs(best - zoom)) { + best = factor; + } + } + + return(best); +} + + +/// +/// The native tactical view for a battlefield area. It shares the area's top left corner and +/// equals the area when the factor is the unzoomed one. +/// +Rect Battle_Zoom_View_Rect(Rect const & battlefield, int zoom) +{ + zoom = std::clamp(zoom, (int)BATTLE_ZOOM_MIN, (int)BATTLE_ZOOM_MAX); + + return(Rect(battlefield.X, battlefield.Y, battlefield.Width / zoom, battlefield.Height / zoom)); +} + + +/// +/// The part of the battlefield area that shows the magnified view. Rows and columns that the +/// whole-number magnification cannot fill are split evenly around it. +/// +Rect Battle_Zoom_Screen_Rect(Rect const & battlefield, int zoom) +{ + zoom = std::clamp(zoom, (int)BATTLE_ZOOM_MIN, (int)BATTLE_ZOOM_MAX); + + Rect view = Battle_Zoom_View_Rect(battlefield, zoom); + int width = view.Width * zoom; + int height = view.Height * zoom; + + return(Rect(battlefield.X + (battlefield.Width - width) / 2, battlefield.Y + (battlefield.Height - height) / 2, width, height)); +} + + +/// +/// Converts a frame position into the native tactical view. +/// +/// Point2D; A position over the battlefield area, including its unfilled border, comes +/// back inside the native view. Any other position comes back unchanged. +Point2D Battle_Zoom_Screen_To_View(Point2D const & point, Rect const & battlefield, int zoom) +{ + if (zoom <= BATTLE_ZOOM_MIN || !battlefield.Is_Point_Within(point)) { + return(point); + } + + Rect screen = Battle_Zoom_Screen_Rect(battlefield, zoom); + Rect view = Battle_Zoom_View_Rect(battlefield, zoom); + + int x = std::clamp(point.X, screen.X, screen.X + screen.Width - 1); + int y = std::clamp(point.Y, screen.Y, screen.Y + screen.Height - 1); + + return(Point2D(view.X + (x - screen.X) / zoom, view.Y + (y - screen.Y) / zoom)); +} + + +/// +/// Converts a position in the native tactical view into the frame. +/// +/// Point2D; A position inside the native view comes back at the middle of the frame +/// pixels it covers. Any other position comes back unchanged. +Point2D Battle_Zoom_View_To_Screen(Point2D const & point, Rect const & battlefield, int zoom) +{ + Rect view = Battle_Zoom_View_Rect(battlefield, zoom); + + if (zoom <= BATTLE_ZOOM_MIN || !view.Is_Point_Within(point)) { + return(point); + } + + Rect screen = Battle_Zoom_Screen_Rect(battlefield, zoom); + + return(Point2D(screen.X + (point.X - view.X) * zoom + zoom / 2, screen.Y + (point.Y - view.Y) * zoom + zoom / 2)); +} + + +/// +/// Works out the tactical view centre that keeps a world position under the same frame position +/// after the native view changes size. +/// +/// The view centre before the change, in absolute pixels. +/// The native view before the change. +/// The anchor in native view pixels before the change. +/// The native view after the change. +/// The same anchor in native view pixels after the change. +/// Point2D; The new view centre before any clamping to the map. +Point2D Battle_Zoom_Anchored_Center(Point2D const & center, Rect const & before_view, Point2D const & before_point, Rect const & after_view, Point2D const & after_point) +{ + return(Point2D( + center.X - before_view.Width / 2 + (before_point.X - before_view.X) + after_view.Width / 2 - (after_point.X - after_view.X), + center.Y - before_view.Height / 2 + (before_point.Y - before_view.Y) + after_view.Height / 2 - (after_point.Y - after_view.Y))); +} + + +/// +/// Turns wheel movement into whole zoom steps. A fraction from a smooth wheel or touchpad is +/// kept in the remainder until it adds up to a step, and is dropped when the wheel turns the +/// other way. +/// +/// int; Steps to zoom in, or out when negative. +int Battle_Zoom_Wheel_Steps(float & remainder, float wheel) +{ + if ((remainder < 0.0f && wheel > 0.0f) || (remainder > 0.0f && wheel < 0.0f)) { + remainder = 0.0f; + } + + remainder += wheel; + + int steps = (int)std::trunc(remainder); + remainder -= (float)steps; + + return(steps); +} diff --git a/code/battlezoomlayout.h b/code/battlezoomlayout.h new file mode 100644 index 000000000..8b580a1b6 --- /dev/null +++ b/code/battlezoomlayout.h @@ -0,0 +1,41 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// Geometry of the battlefield zoom. The tactical view is rendered at its native size in the +// top left corner of the battlefield area and magnified by a whole number into the middle of +// that area, so the sidebar and the tab strip keep their size while the battlefield zooms. + +#pragma once + +#include "point.h" +#include "rect.h" + + +enum { + BATTLE_ZOOM_MIN = 1, + BATTLE_ZOOM_MAX = 4, + + // A zoomed native view smaller than this is not offered. + BATTLE_ZOOM_MIN_VIEW_WIDTH = 472, + BATTLE_ZOOM_MIN_VIEW_HEIGHT = 524 +}; + + +bool Battle_Zoom_Allowed(Rect const & battlefield, int zoom); +int Battle_Zoom_Nearest_Allowed(Rect const & battlefield, int zoom); + +Rect Battle_Zoom_View_Rect(Rect const & battlefield, int zoom); +Rect Battle_Zoom_Screen_Rect(Rect const & battlefield, int zoom); + +Point2D Battle_Zoom_Screen_To_View(Point2D const & point, Rect const & battlefield, int zoom); +Point2D Battle_Zoom_View_To_Screen(Point2D const & point, Rect const & battlefield, int zoom); + +Point2D Battle_Zoom_Anchored_Center(Point2D const & center, Rect const & before_view, Point2D const & before_point, Rect const & after_view, Point2D const & after_point); + +int Battle_Zoom_Wheel_Steps(float & remainder, float wheel); diff --git a/code/cctooltip.cpp b/code/cctooltip.cpp index fc3845786..7eb255892 100644 --- a/code/cctooltip.cpp +++ b/code/cctooltip.cpp @@ -47,7 +47,7 @@ bool CCToolTip::Update(ToolTipText * text) Rect * trect; if (Options.IsSidebarOnRight == true) { - if (text->Pos.X <= TacticalRect.X + TacticalRect.Width) { + if (text->Pos.X <= BattlefieldRect.X + BattlefieldRect.Width) { trect = &TacticalRect; } else { trect = &SidebarRect; @@ -103,7 +103,7 @@ void CCToolTip::Reset(const ToolTipText * text) { bool redraw = false; if (Options.IsSidebarOnRight == true) { - if (text->Pos.X >= TacticalRect.X + TacticalRect.Width) { + if (text->Pos.X >= BattlefieldRect.X + BattlefieldRect.Width) { redraw = true; } } else { @@ -150,7 +150,7 @@ void CCToolTip::Draw(const ToolTipText * text) Surface * surface = NULL; if (Options.IsSidebarOnRight == true) { - int offset = TacticalRect.X + TacticalRect.Width; + int offset = BattlefieldRect.X + BattlefieldRect.Width; if (point.X + text->TextWidth <= offset) { surface = CompositeSurface; } else if (UseSidebarSurface == true && point.X >= offset) { diff --git a/code/display.cpp b/code/display.cpp index 30af79a6a..9f4259ccf 100644 --- a/code/display.cpp +++ b/code/display.cpp @@ -104,6 +104,7 @@ #include "airctype.h" #include "animtype.h" #include "astar.h" +#include "battlezoom.h" #include "bsurface.h" #include "building.h" #include "builtype.h" @@ -362,7 +363,8 @@ void DisplayClass::Set_View_Dimensions(Rect const & dimensions) { DebugString("Set_View_Dimensions(%d,%d,%d,%d)\n", dimensions.X, dimensions.Y, dimensions.Width, dimensions.Height); - TacticalRect = dimensions; + // The dimensions give the battlefield's frame area; a zoomed view renders a smaller part of it. + TacticalRect = Battle_Zoom_Fit_View(dimensions); if (TacticalMap != NULL) { TacticalMap->Set_View_Dimensions(TacticalRect); @@ -407,23 +409,27 @@ void DisplayClass::Set_View_Dimensions(Rect const & dimensions) ToolTips->Add(&tooltip); } - /* - ** For multiplayer games, initialize the inter-player message system. - ** Do this after loading the scenario, so the map's upper-left corner is - ** properly set. - */ - Session.Messages.Init( - TacticalRect.X, TacticalRect.Y, // x,y for messages - 6, // max # msgs - MAX_MESSAGE_LENGTH-14, // max msg length - 7 * 2/*RESFACTOR*/, // font height in pixels - -1, -1, // x,y for edit line (appears above msgs) - 0, /// enable edit overflow - 20, // min, - MAX_MESSAGE_LENGTH - 14, // max for trimming overflow - TacticalRect.Width); // Width in pixels of buffer - - Session.Messages.Set_Width(TacticalRect.Width); + // Messages stay at frame size over a zoomed battlefield, so a zoom change keeps them. + if (!Battle_Zoom_Applying()) { + + /* + ** For multiplayer games, initialize the inter-player message system. + ** Do this after loading the scenario, so the map's upper-left corner is + ** properly set. + */ + Session.Messages.Init( + BattlefieldRect.X, BattlefieldRect.Y, // x,y for messages + 6, // max # msgs + MAX_MESSAGE_LENGTH-14, // max msg length + 7 * 2/*RESFACTOR*/, // font height in pixels + -1, -1, // x,y for edit line (appears above msgs) + 0, /// enable edit overflow + 20, // min, + MAX_MESSAGE_LENGTH - 14, // max for trimming overflow + BattlefieldRect.Width); // Width in pixels of buffer + + Session.Messages.Set_Width(BattlefieldRect.Width); + } DebugString("Set_View_Dimensions(exit)\n"); } diff --git a/code/gamewindow.cpp b/code/gamewindow.cpp index 48116d6af..41f78ca59 100644 --- a/code/gamewindow.cpp +++ b/code/gamewindow.cpp @@ -17,6 +17,7 @@ #include "_tooltip.h" #include "_ui.h" #include "_xmouse.h" +#include "battlezoom.h" #include "cctooltip.h" #include "dbgprint.h" #include "globals.h" @@ -153,11 +154,24 @@ static bool Handle_Event(WindowEvent const & event) // The map and the key queue work in frame coordinates. WindowEvent frame = event; - if (Is_Mouse_Event(event.Type) && Video_Scaling_Active()) { - Point2D point(event.X, event.Y); - Window_Point_To_Game(point); + Point2D framepoint(event.X, event.Y); + if (Is_Mouse_Event(event.Type)) { + if (Video_Scaling_Active()) { + Window_Point_To_Game(framepoint); + } + + if (event.Type == WINDOW_EVENT_MOUSE_DOWN) { + Battle_Zoom_Mouse_Button(true, framepoint); + } + + Point2D point = framepoint; + Battle_Zoom_Frame_To_View(point); frame.X = point.X; frame.Y = point.Y; + + if (event.Type == WINDOW_EVENT_MOUSE_UP) { + Battle_Zoom_Mouse_Button(false, framepoint); + } } Map.Handle_Window_Event(frame); @@ -178,6 +192,7 @@ static bool Handle_Event(WindowEvent const & event) case WINDOW_EVENT_FOCUS_GAINED: case WINDOW_EVENT_FOCUS_LOST: + Battle_Zoom_Release_Buttons(); Set_Game_Focus(event.Type == WINDOW_EVENT_FOCUS_GAINED); return(false); @@ -188,7 +203,7 @@ static bool Handle_Event(WindowEvent const & event) break; case WINDOW_EVENT_MOUSE_WHEEL: - if (!event.Horizontal) { + if (!event.Horizontal && !Battle_Zoom_Wheel(event.Wheel, framepoint)) { On_Mouse_Wheel(event.Wheel < 0.0f ? -1 : 1); } break; diff --git a/code/gscreen.cpp b/code/gscreen.cpp index c705bf626..a1278db1c 100644 --- a/code/gscreen.cpp +++ b/code/gscreen.cpp @@ -54,6 +54,7 @@ #include "_tactica.h" #include "_tooltip.h" #include "_xmouse.h" +#include "battlezoom.h" #include "bench.h" #include "cctooltip.h" #include "gadget.h" @@ -414,7 +415,10 @@ void GScreenClass::Render(void) ** Draw the multiplayer message system to the Hidpage at this point. ** This way, they'll Blit along with the rest of the map. */ - Session.Messages.Draw(); + // Over a zoomed battlefield the messages are drawn after the magnification instead. + if (Battle_Zoom_Factor() == 1) { + Session.Messages.Draw(); + } if (Session.ShowInternetDebug) { Multiplayer_Debug_Print(); } @@ -468,6 +472,11 @@ void Update_Visible_Surface(Surface *surface, Rect *rect) { Rect fill_rect; + if (rect == NULL && Battle_Zoom_Present(*surface)) { + Video_Present_If_Dirty(); + return; + } + if (rect == NULL) { fill_rect = surface->Get_Rect(); rect = &fill_rect; diff --git a/code/mouse.cpp b/code/mouse.cpp index 92b046afc..48e0bf8be 100644 --- a/code/mouse.cpp +++ b/code/mouse.cpp @@ -44,6 +44,7 @@ #include "_mixfile.h" #include "animtype.h" +#include "battlezoom.h" #include "builtype.h" #include "cell.h" #include "data.h" @@ -313,6 +314,8 @@ bool MouseClass::Override_Mouse_Shape(MouseType mouse, bool wsmall) *=============================================================================================*/ void MouseClass::AI(KeyNumType &input, Point2D const & xy) { + Battle_Zoom_AI(input != KN_NONE); + MouseStruct const * control = &MouseControl[CurrentMouseShape]; if (control->FrameRate && Timer == 0) { diff --git a/code/radar.cpp b/code/radar.cpp index 6ce9120e4..365cbfce9 100644 --- a/code/radar.cpp +++ b/code/radar.cpp @@ -528,7 +528,7 @@ int RadarClass::RTacticalClass::Action(unsigned flags, KeyNumType & key) } - x -= Options.IsSidebarOnRight ? TacticalRect.Width : 0; + x -= Options.IsSidebarOnRight ? BattlefieldRect.Width : 0; /* ** See if the mouse is over the radar general area, but not yet @@ -838,7 +838,7 @@ void RadarClass::Draw_Names(void) void RadarClass::Reposition_Sidebar(void) { BASECLASS::Reposition_Sidebar(); - RadarButton.Set_Position(RadX + (Options.IsSidebarOnRight ? TacticalRect.Width : 0), RadY); + RadarButton.Set_Position(RadX + (Options.IsSidebarOnRight ? BattlefieldRect.Width : 0), RadY); RadarButton.Flag_To_Redraw(); FullRedraw = true; } diff --git a/code/scenario.cpp b/code/scenario.cpp index 1570c92f6..da993e33b 100644 --- a/code/scenario.cpp +++ b/code/scenario.cpp @@ -81,6 +81,7 @@ #include "aitrig.h" #include "anim.h" #include "astar.h" +#include "battlezoom.h" #include "bench.h" #include "building.h" #include "builtype.h" @@ -801,7 +802,7 @@ bool Read_Scenario(char const * fname) ** properly set. */ Session.Messages.Init( - TacticalRect.X, TacticalRect.Y, // x,y for messages + BattlefieldRect.X, BattlefieldRect.Y, // x,y for messages 6, // max # msgs MAX_MESSAGE_LENGTH - 14, // max msg length 7 * 2, // font height in pixels @@ -809,7 +810,7 @@ bool Read_Scenario(char const * fname) 0,//BG 1, // enable edit overflow 20, // min, MAX_MESSAGE_LENGTH - 14, // max for trimming overflow - TacticalRect.Width); // Width in pixels of buffer + BattlefieldRect.Width); // Width in pixels of buffer Fill_In_Data(); @@ -1060,6 +1061,7 @@ void Clear_Scenario(void) { Stop_All_Sound_Effects(); Sync_Recorder_Disarm(); + Battle_Zoom_Reset(); int index; diff --git a/code/scroll.cpp b/code/scroll.cpp index d4d3c7c2c..29555736c 100644 --- a/code/scroll.cpp +++ b/code/scroll.cpp @@ -44,6 +44,7 @@ #include "_rules.h" #include "_surface.h" #include "_tactica.h" +#include "battlezoom.h" #include "building.h" #include "cell.h" #include "dbgprint.h" @@ -413,7 +414,7 @@ void ScrollClass::Scroll_Edge(Point2D const & point) bool noscroll = false; if (!noscroll) { - Point2D p = TacticalRect.Top_Left() + point; + Point2D p = Battle_Zoom_Edge_Point(TacticalRect.Top_Left() + point); int x = p.X; int y = p.Y; int w = (CompositeSurface->Get_Width()+SidebarSurface->Get_Width()) - 1; @@ -785,7 +786,7 @@ void ScrollClass::Scroll_Coast(Point2D const & point) distx = abs(int((double)posx * (12.0 / (double)(Options.ScrollRate + 1)))); disty = abs(int((double)posy * (12.0 / (double)(Options.ScrollRate + 1)))); if (distx + disty > 0) { - Point2D pt(RightPressPoint.X + TacticalRect.X, RightPressPoint.Y + TacticalRect.Y); + Point2D pt = Battle_Zoom_View_To_Frame(Point2D(RightPressPoint.X + TacticalRect.X, RightPressPoint.Y + TacticalRect.Y)); Game_Point_To_Window(pt); Main_Window_Warp_Cursor(pt.X, pt.Y); } @@ -797,7 +798,7 @@ void ScrollClass::Scroll_Coast(Point2D const & point) distx = abs(int((double)posx * (12.0 / (double)(Options.ScrollRate + 1)))); disty = abs(int((double)posy * (12.0 / (double)(Options.ScrollRate + 1)))); if (distx + disty > 0) { - Point2D pt(RightPressPoint.X + TacticalRect.X, RightPressPoint.Y + TacticalRect.Y); + Point2D pt = Battle_Zoom_View_To_Frame(Point2D(RightPressPoint.X + TacticalRect.X, RightPressPoint.Y + TacticalRect.Y)); Game_Point_To_Window(pt); Main_Window_Warp_Cursor(pt.X, pt.Y); } diff --git a/code/sidebar.cpp b/code/sidebar.cpp index a3a704094..fb48e4a3f 100644 --- a/code/sidebar.cpp +++ b/code/sidebar.cpp @@ -417,10 +417,10 @@ void SidebarClass::Init_IO(void) { BASECLASS::Init_IO(); - SidebarRect.X = TacticalRect.X + TacticalRect.Width; + SidebarRect.X = BattlefieldRect.X + BattlefieldRect.Width; SidebarRect.Y = SIDE_Y; SidebarRect.Width = 641 - SidebarRect.X; - SidebarRect.Height = (TacticalRect.Y - SidebarRect.Y) + TacticalRect.Height; + SidebarRect.Height = (BattlefieldRect.Y - SidebarRect.Y) + BattlefieldRect.Height; /* ** Add the sidebar's buttons only if we're not in editor mode. @@ -1047,7 +1047,7 @@ void SidebarClass::Blit_Sidebar(bool complete) if (Map.LastDrawRect == RECT_NONE) { if (IsToRedrawCredits) { VisibleSurface->Blit_From( - Rect((Options.IsSidebarOnRight ? TacticalRect.Width : 0), 0, SIDE_WIDTH, CREDITS_HEIGHT), + Rect((Options.IsSidebarOnRight ? BattlefieldRect.Width : 0), 0, SIDE_WIDTH, CREDITS_HEIGHT), *SidebarSurface, Rect(0, 0, SIDE_WIDTH, CREDITS_HEIGHT), false, @@ -1063,13 +1063,13 @@ void SidebarClass::Blit_Sidebar(bool complete) } if (Map.LastDrawRect == RECT_NONE && !complete) { - VisibleSurface->Blit_From(Rect((Options.IsSidebarOnRight ? TacticalRect.Width : 0), 0, SIDE_WIDTH, CREDITS_HEIGHT), *SidebarSurface, Rect(0, 0, SIDE_WIDTH, CREDITS_HEIGHT)); - VisibleSurface->Blit_From(Rect((Options.IsSidebarOnRight ? TacticalRect.Width : 0), SIDE_BODY_Y, SIDE_WIDTH, SidebarSurface->Get_Height() - SIDE_BODY_Y), *SidebarSurface, Rect(0, SIDE_BODY_Y, SIDE_WIDTH, SidebarSurface->Get_Height() - SIDE_BODY_Y)); + VisibleSurface->Blit_From(Rect((Options.IsSidebarOnRight ? BattlefieldRect.Width : 0), 0, SIDE_WIDTH, CREDITS_HEIGHT), *SidebarSurface, Rect(0, 0, SIDE_WIDTH, CREDITS_HEIGHT)); + VisibleSurface->Blit_From(Rect((Options.IsSidebarOnRight ? BattlefieldRect.Width : 0), SIDE_BODY_Y, SIDE_WIDTH, SidebarSurface->Get_Height() - SIDE_BODY_Y), *SidebarSurface, Rect(0, SIDE_BODY_Y, SIDE_WIDTH, SidebarSurface->Get_Height() - SIDE_BODY_Y)); } else if (!IsToBlitSidebar) { - VisibleSurface->Blit_From(Rect(Map.LastDrawRect.X + (Options.IsSidebarOnRight ? TacticalRect.Width : 0), Map.LastDrawRect.Y, Map.LastDrawRect.Width, Map.LastDrawRect.Height), *SidebarSurface, Map.LastDrawRect); + VisibleSurface->Blit_From(Rect(Map.LastDrawRect.X + (Options.IsSidebarOnRight ? BattlefieldRect.Width : 0), Map.LastDrawRect.Y, Map.LastDrawRect.Width, Map.LastDrawRect.Height), *SidebarSurface, Map.LastDrawRect); } else { Rect sb_rect = SidebarSurface->Get_Rect(); - VisibleSurface->Blit_From(Rect((Options.IsSidebarOnRight ? TacticalRect.Width : 0), 0, sb_rect.Width, sb_rect.Height), *SidebarSurface, Rect(0, 0, sb_rect.Width, sb_rect.Height)); + VisibleSurface->Blit_From(Rect((Options.IsSidebarOnRight ? BattlefieldRect.Width : 0), 0, sb_rect.Width, sb_rect.Height), *SidebarSurface, Rect(0, 0, sb_rect.Width, sb_rect.Height)); } } IsToBlitSidebar = false; @@ -2744,10 +2744,10 @@ void SidebarClass::Reposition_Sidebar(void) /* * Position the sidebar. */ - SidebarRect.X = Options.IsSidebarOnRight ? TacticalRect.X + TacticalRect.Width : 0; + SidebarRect.X = Options.IsSidebarOnRight ? BattlefieldRect.X + BattlefieldRect.Width : 0; SidebarRect.Y = SIDE_Y; SidebarRect.Width = SIDE_WIDTH; - SidebarRect.Height = TacticalRect.Height + TacticalRect.Y - SIDE_Y; + SidebarRect.Height = BattlefieldRect.Height + BattlefieldRect.Y - SIDE_Y; BASECLASS::Reposition_Sidebar(); @@ -2760,7 +2760,7 @@ void SidebarClass::Reposition_Sidebar(void) /* * Position the sidebar's buttons. */ - Background.Set_Position(SidebarRect.X + 16, TacticalRect.Y); + Background.Set_Position(SidebarRect.X + 16, BattlefieldRect.Y); Background.Flag_To_Redraw(); Repair.Set_Position(SidebarRect.X + BUTTON_ONE_X, SidebarRect.Y + BUTTON_ONE_Y); @@ -2843,7 +2843,7 @@ void SidebarClass::Reposition_Sidebar(void) int x = 0; int y = RadarButton.Y + RadarButton.Height; if (Options.IsSidebarOnRight) { - Background.Set_Position(TacticalRect.X + TacticalRect.Width, y); + Background.Set_Position(BattlefieldRect.X + BattlefieldRect.Width, y); } else { Background.Set_Position(x, y); } diff --git a/code/tab.cpp b/code/tab.cpp index 383a9dbac..8272ce6c5 100644 --- a/code/tab.cpp +++ b/code/tab.cpp @@ -171,7 +171,7 @@ void TabClass::Draw_Credits_Tab(void) if (Scen->MissionTimer.Is_Active()) { bool light = ((int)Scen->MissionTimer < TICKS_PER_MINUTE * Rule->TimerWarning) || Map.FlasherTimer > 0; - Draw_Shape(*CompositeSurface, *SidebarDrawer, TabShape, /*light ? 4 :*/ 2, Point2D(TacticalRect.Width - TabShape->Get_Width(), 0), VisibleRect); + Draw_Shape(*CompositeSurface, *SidebarDrawer, TabShape, /*light ? 4 :*/ 2, Point2D(BattlefieldRect.Width - TabShape->Get_Width(), 0), VisibleRect); int time = Scen->MissionTimer; @@ -184,11 +184,11 @@ void TabClass::Draw_Credits_Tab(void) if (hours != 0) { Fancy_Text_Print(TXT_TIME_FORMAT_HOURS, *CompositeSurface, CompositeSurface->Get_Rect(), - Point2D(TacticalRect.Width - TabShape->Get_Width() / 2, 0), ColorSchemes[0], TBLACK, + Point2D(BattlefieldRect.Width - TabShape->Get_Width() / 2, 0), ColorSchemes[0], TBLACK, TextPrintType(TPF_METAL12 | TPF_CENTER | TPF_USE_GRAD_PAL), hours, minutes, seconds); } else { Fancy_Text_Print(TXT_TIME_FORMAT_NO_HOURS, *CompositeSurface, CompositeSurface->Get_Rect(), - Point2D(TacticalRect.Width - TabShape->Get_Width() / 2, 0), ColorSchemes[0], TBLACK, + Point2D(BattlefieldRect.Width - TabShape->Get_Width() / 2, 0), ColorSchemes[0], TBLACK, TextPrintType(TPF_METAL12 | TPF_CENTER | TPF_USE_GRAD_PAL), minutes, seconds); } } diff --git a/code/taction.cpp b/code/taction.cpp index 3244d8d62..464349671 100644 --- a/code/taction.cpp +++ b/code/taction.cpp @@ -51,6 +51,7 @@ #include "_weapon.h" #include "anim.h" #include "savemgr.h" +#include "battlezoom.h" #include "blight.h" #include "building.h" #include "builtype.h" @@ -2049,6 +2050,8 @@ bool TActionClass::TAction_CENTER_VIEWPOINT(HouseClass * , ObjectClass * , Trigg /// bool TActionClass::TAction_ZOOM_IN(HouseClass * , ObjectClass * , TriggerClass * , Cell const & ) { + // The trigger zoom magnifies the full battlefield, so a wheel zoom gives way to it. + Battle_Zoom_Reset(); TacticalMap->ZoomFactor = Rule->ZoomInFactor; IgnoreInput = true; // Keyboard->Clear(); /// should do this too?? ZOOM_OUT does... diff --git a/code/wwmouse.cpp b/code/wwmouse.cpp index 2dbb3b69d..61be09c4a 100644 --- a/code/wwmouse.cpp +++ b/code/wwmouse.cpp @@ -59,6 +59,7 @@ #include "wwmouse.h" #include "_convert.h" +#include "battlezoom.h" #include "convert.h" #include "dbgprint.h" #include "globals.h" @@ -456,7 +457,8 @@ void WWMouseClass::Convert_Coordinate(int & x, int & y) const } -// Converts a position in the window's client area into one held inside the frame. +// Converts a position in the window's client area into one held inside the frame, in native +// view pixels over a zoomed battlefield. void WWMouseClass::Client_To_Game(int & x, int & y) const { Point2D point(x, y); @@ -469,6 +471,11 @@ void WWMouseClass::Client_To_Game(int & x, int & y) const if (y < 0) y = 0; if (x >= scale.GameWidth) x = scale.GameWidth-1; if (y >= scale.GameHeight) y = scale.GameHeight-1; + + point = Point2D(x, y); + Battle_Zoom_Frame_To_View(point); + x = point.X; + y = point.Y; } diff --git a/manual/changes/battlefield-zoom.md b/manual/changes/battlefield-zoom.md new file mode 100644 index 000000000..df913e2a9 --- /dev/null +++ b/manual/changes/battlefield-zoom.md @@ -0,0 +1,15 @@ +--- +title: Zoom the battlefield with the mouse wheel +category: feature +release: 0.2.0 +targets: +- type: system + id: battlefield-zoom + effect: added +credit: +- equalchance +--- + +The mouse wheel over the tactical view now zooms the battlefield in whole steps from 1x to 4x, as far as the game resolution allows, while the sidebar and the tab strip keep their size. The wheel used to scroll the sidebar wherever the pointer was; it still does over the sidebar and the tab strip. + +The zoom follows the battlefield-only zoom of the TI Chilong Edition build of Vinifera. diff --git a/manual/content/systems/battlefield-zoom.md b/manual/content/systems/battlefield-zoom.md new file mode 100644 index 000000000..c5d7938b4 --- /dev/null +++ b/manual/content/systems/battlefield-zoom.md @@ -0,0 +1,40 @@ +--- +title: Battlefield zoom +summary: "The mouse wheel over the tactical view magnifies the battlefield by a whole factor from 1x to 4x while the sidebar and the tab strip keep their size." +category: interface-controls +keys: [] +related: + - type: system + id: sidebar +--- + +Each wheel notch over the tactical view zooms in one step, away from the player, or out one step, toward the player. A zoomed view shows a smaller part of the map, with every map pixel drawn as a square of 2, 3 or 4 screen pixels. Over the sidebar or the tab strip the wheel scrolls the sidebar. + +## Available factors + +A factor is offered only while the part of the map it shows is at least 472 pixels wide and 524 pixels high. With the sidebar taking 168 pixels and the tab strip 16, that gives: + +| Game resolution | Largest factor | +| --- | --- | +| 1280 by 720 | 1x | +| 1920 by 1080 | 2x | +| 2560 by 1440 | 2x | +| 3840 by 2160 | 4x | + +A wheel step past the largest factor is ignored. Rows and columns that a whole factor cannot fill are black and split evenly around the picture. + +## When the zoom changes + +The new factor takes effect on the next frame with no key press waiting and no mouse button held. The map position under the pointer when the wheel turned stays under the pointer, unless that would move the view past the edge of the map. + +The wheel does not zoom while the window is in the background, while a trigger or the end of a campaign mission locks the player's input, while a trigger zoom is in effect, or in the map editor. A [Zoom in](/mapping/actions/taction-zoom-in/) trigger action first returns the battlefield to 1x. + +## What stays at screen size + +The sidebar, the tab strip with its mission timer, and chat and game messages are drawn at their normal size over a zoomed battlefield. Tooltips over the battlefield are magnified with it. The radar's view box shrinks to the part of the map on screen. + +A drag that starts on the battlefield stays on it: a selection box or a coast scroll stops at the edge of the view when the pointer moves over the sidebar. Edge scrolling works on all four screen edges at every factor. + +## Persistence + +The factor belongs to the local display. It is not stored in saved games or sent to other players. Starting or loading a mission returns it to 1x. Changing the game resolution keeps the factor when the new resolution allows it and otherwise drops to the largest factor that it does allow. diff --git a/manual/data/command-adapters.yaml b/manual/data/command-adapters.yaml index 47d1f0637..0c73b2f8c 100644 --- a/manual/data/command-adapters.yaml +++ b/manual/data/command-adapters.yaml @@ -440,6 +440,12 @@ fixed_exclusions: - { file: code/scroll.cpp, function: ScrollClass::Scroll_AI, expression: KN_LMOUSE } - { file: code/scroll.cpp, function: ScrollClass::Scroll_AI, expression: KN_RMOUSE } reason: Continuous tactical-map pointer state, not discrete command dispatch. + - sites: + - { file: code/battlezoom.cpp, function: Battle_Zoom_AI, expression: KN_LMOUSE } + - { file: code/battlezoom.cpp, function: Battle_Zoom_AI, expression: KN_RMOUSE } + - { file: code/battlezoom.cpp, function: Battle_Zoom_AI, expression: KN_LMOUSE+KN_RMOUSE } + - { file: code/mouse.cpp, function: MouseClass::AI, expression: KN_NONE } + reason: A battlefield zoom change waits for held buttons and pending input; these are not controls. - sites: - { file: code/sidebar.cpp, function: SidebarClass::AI, expression: KN_BUTTON } - { file: code/sidebar.cpp, function: SidebarClass::StripClass::AI, expression: KN_BUTTON } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f56e5406d..5acefacd1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -115,6 +115,7 @@ add_subdirectory(sdlkeys) add_subdirectory(sdlevents) add_subdirectory(sdlinput) add_subdirectory(keyqueue) +add_subdirectory(battlezoom) if(OPENTS_DESKTOP_TESTS) add_subdirectory(sdlwindow) endif() diff --git a/tests/battlezoom/CMakeLists.txt b/tests/battlezoom/CMakeLists.txt new file mode 100644 index 000000000..ac5a710e4 --- /dev/null +++ b/tests/battlezoom/CMakeLists.txt @@ -0,0 +1,9 @@ +# The battlefield zoom geometry is compiled into the harness on its own; it reads no engine state. + +opents_add_test(BattleZoom + NAME battlezoom + SOURCES battlezoomcontract.cpp + ENGINE + battlezoomlayout.cpp + DEFINITIONS WIN32 _WINDOWS NOMINMAX +) diff --git a/tests/battlezoom/battlezoomcontract.cpp b/tests/battlezoom/battlezoomcontract.cpp new file mode 100644 index 000000000..2a64f4138 --- /dev/null +++ b/tests/battlezoom/battlezoomcontract.cpp @@ -0,0 +1,120 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// Pins the battlefield zoom geometry: which factors each battlefield size offers, where the +// native view and its magnified picture sit, how frame and view positions convert, and the +// view centre that keeps the world under the cursor. + +#include "battlezoomlayout.h" + +#include + +namespace { + +int Failures = 0; + + +void Check(bool condition, char const * what) +{ + std::printf("%-76s %s\n", what, condition ? "ok" : "FAILED"); + + if (!condition) { + Failures++; + } +} + + +bool Same(Rect const & rect, int x, int y, int w, int h) +{ + return(rect.X == x && rect.Y == y && rect.Width == w && rect.Height == h); +} + + +bool Same(Point2D const & point, int x, int y) +{ + return(point.X == x && point.Y == y); +} + + +// A world pixel under a native view position, measured the way the tactical view measures it. +Point2D World(Point2D const & center, Rect const & view, Point2D const & point) +{ + return(Point2D(center.X - view.Width / 2 + (point.X - view.X), center.Y - view.Height / 2 + (point.Y - view.Y))); +} + +} + + +int main() +{ + // The battlefield areas a 1080p, 720p and 2160p frame give with the sidebar on the right. + Rect const hd(0, 16, 1752, 1064); + Rect const small(0, 16, 1112, 704); + Rect const uhd(0, 16, 3672, 2144); + + Check(Battle_Zoom_Allowed(hd, 1) && Battle_Zoom_Allowed(small, 1), "the unzoomed view is always allowed"); + Check(Battle_Zoom_Allowed(hd, 2) && !Battle_Zoom_Allowed(hd, 3), "1080p offers 2x but not 3x"); + Check(!Battle_Zoom_Allowed(small, 2), "720p offers no zoom"); + Check(Battle_Zoom_Allowed(uhd, 4), "2160p offers 4x"); + Check(!Battle_Zoom_Allowed(hd, 0) && !Battle_Zoom_Allowed(uhd, 5), "factors outside 1 to 4 are refused"); + Check(!Battle_Zoom_Allowed(Rect(0, 0, 0, 0), 1), "an empty battlefield allows nothing"); + + Check(Battle_Zoom_Nearest_Allowed(hd, 3) == 2 && Battle_Zoom_Nearest_Allowed(hd, 4) == 2, "an unavailable factor falls back to the largest allowed"); + Check(Battle_Zoom_Nearest_Allowed(small, 2) == 1, "with no zoom available the view stays unzoomed"); + Check(Battle_Zoom_Nearest_Allowed(uhd, 3) == 3, "an allowed factor is kept"); + + Check(Same(Battle_Zoom_View_Rect(hd, 1), 0, 16, 1752, 1064), "the unzoomed view is the battlefield"); + Check(Same(Battle_Zoom_View_Rect(hd, 2), 0, 16, 876, 532), "a 2x view is half the battlefield at its corner"); + Check(Same(Battle_Zoom_Screen_Rect(hd, 2), 0, 16, 1752, 1064), "an even battlefield is filled exactly"); + + Rect const odd(0, 16, 1760, 1601); + Check(Same(Battle_Zoom_Screen_Rect(odd, 3), 1, 17, 1758, 1599), "unfilled pixels are split around the picture"); + + Check(Same(Battle_Zoom_Screen_To_View(Point2D(1000, 500), hd, 1), 1000, 500), "an unzoomed position is unchanged"); + Check(Same(Battle_Zoom_Screen_To_View(Point2D(0, 16), hd, 2), 0, 16), "the battlefield corner is the view corner"); + Check(Same(Battle_Zoom_Screen_To_View(Point2D(3, 19), hd, 2), 1, 17), "each view pixel covers 2x2 frame pixels"); + Check(Same(Battle_Zoom_Screen_To_View(Point2D(1751, 1079), hd, 2), 875, 547), "the far corner is the last view pixel"); + Check(Same(Battle_Zoom_Screen_To_View(Point2D(1800, 500), hd, 2), 1800, 500), "a sidebar position is unchanged"); + Check(Same(Battle_Zoom_Screen_To_View(Point2D(100, 5), hd, 2), 100, 5), "a tab strip position is unchanged"); + Check(Same(Battle_Zoom_Screen_To_View(Point2D(0, 16), odd, 3), 0, 16), "an unfilled border pixel maps to the nearest view pixel"); + + bool roundtrip = true; + for (int y = 16; y < 16 + 532; y += 37) { + for (int x = 0; x < 876; x += 41) { + Point2D const view(x, y); + if (!(Battle_Zoom_Screen_To_View(Battle_Zoom_View_To_Screen(view, hd, 2), hd, 2) == view)) { + roundtrip = false; + } + } + } + Check(roundtrip, "a view position survives the trip to the frame and back"); + Check(Same(Battle_Zoom_View_To_Screen(Point2D(1800, 500), hd, 2), 1800, 500), "a position outside the view is not converted"); + + Point2D const center(5000, 3000); + Point2D const cursor(1000, 500); + Rect const before = Battle_Zoom_View_Rect(hd, 1); + Rect const after = Battle_Zoom_View_Rect(hd, 2); + Point2D const before_point = Battle_Zoom_Screen_To_View(cursor, hd, 1); + Point2D const after_point = Battle_Zoom_Screen_To_View(cursor, hd, 2); + Point2D const anchored = Battle_Zoom_Anchored_Center(center, before, before_point, after, after_point); + Check(World(center, before, before_point) == World(anchored, after, after_point), "zooming in keeps the world pixel under the cursor"); + + Point2D const back = Battle_Zoom_Anchored_Center(anchored, after, after_point, before, before_point); + Check(World(anchored, after, after_point) == World(back, before, before_point), "and zooming out again keeps it too"); + + float remainder = 0.0f; + Check(Battle_Zoom_Wheel_Steps(remainder, 1.0f) == 1, "a wheel notch is one step"); + Check(Battle_Zoom_Wheel_Steps(remainder, -2.0f) == -2, "notches toward the player step out"); + Check(Battle_Zoom_Wheel_Steps(remainder, 0.4f) == 0 && Battle_Zoom_Wheel_Steps(remainder, 0.4f) == 0, "fractions wait"); + Check(Battle_Zoom_Wheel_Steps(remainder, 0.4f) == 1, "until they add up to a step"); + Check(Battle_Zoom_Wheel_Steps(remainder, -1.0f) == -1, "turning back drops the fraction left over"); + + std::printf("\n%s\n", Failures == 0 ? "PASSED" : "FAILED"); + return(Failures == 0 ? 0 : 1); +} From 3f94886f6784ffa0b01fabd7f21c2cb6b7ceb683 Mon Sep 17 00:00:00 2001 From: equalchance <182705210+equalchance@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:04:02 +0300 Subject: [PATCH 02/14] Refit the tactical view after loading a save --- code/saveload.cpp | 4 ++++ manual/content/systems/battlefield-zoom.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/code/saveload.cpp b/code/saveload.cpp index df3ff9062..948689d47 100644 --- a/code/saveload.cpp +++ b/code/saveload.cpp @@ -819,6 +819,10 @@ static bool Get_All(SaveStreamClass & stream, bool save_net) // what deletes it from here on. tactical.release(); + // The saved view origin suits the view size at save time, which differs after a zoom or a + // resolution change, so the view is fitted again about its saved centre. + TacticalMap->Set_View_Dimensions(TacticalRect); + if (!Load_Vector(stream)) { /// HouseTypes return(false); } diff --git a/manual/content/systems/battlefield-zoom.md b/manual/content/systems/battlefield-zoom.md index c5d7938b4..ef776b81e 100644 --- a/manual/content/systems/battlefield-zoom.md +++ b/manual/content/systems/battlefield-zoom.md @@ -37,4 +37,4 @@ A drag that starts on the battlefield stays on it: a selection box or a coast sc ## Persistence -The factor belongs to the local display. It is not stored in saved games or sent to other players. Starting or loading a mission returns it to 1x. Changing the game resolution keeps the factor when the new resolution allows it and otherwise drops to the largest factor that it does allow. +The factor belongs to the local display. It is not stored in saved games or sent to other players. Starting or loading a mission returns it to 1x, and a game saved while zoomed loads with the view centred where the zoomed view was. The game resolution can only be changed from the main menu, where the factor is always 1x. From 256245a66b1c9eb2846af9715c0e1de3dd52cd4c Mon Sep 17 00:00:00 2001 From: equalchance <182705210+equalchance@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:04:02 +0300 Subject: [PATCH 03/14] Keep the battlefield zoom at 1x outside a scenario --- code/battlezoom.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/code/battlezoom.cpp b/code/battlezoom.cpp index 46c91c966..13c4220d6 100644 --- a/code/battlezoom.cpp +++ b/code/battlezoom.cpp @@ -72,14 +72,15 @@ bool Battle_Zoom_Applying(void) /// /// Records the frame area the battlefield occupies and returns the native view for it. The -/// zoom factor drops to the nearest one the area allows. +/// zoom factor drops to the nearest one the area allows, and to 1x outside a scenario, as when +/// the main menu changes the resolution after a zoomed match. /// /// The battlefield area in frame pixels. /// Rect; The native tactical view, which equals the area when the battlefield is not zoomed. Rect Battle_Zoom_Fit_View(Rect const & battlefield) { BattlefieldRect = battlefield; - _Zoom = Battle_Zoom_Nearest_Allowed(battlefield, _Zoom); + _Zoom = ScenarioActive ? Battle_Zoom_Nearest_Allowed(battlefield, _Zoom) : (int)BATTLE_ZOOM_MIN; _Pending = _Zoom; return(Battle_Zoom_View_Rect(battlefield, _Zoom)); From 646e3d9d90b14b460182eaf877abf3ec9d9eca0f Mon Sep 17 00:00:00 2001 From: equalchance <182705210+equalchance@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:58:38 +0300 Subject: [PATCH 04/14] Defer battlefield zoom until mouse gestures finish --- code/battlezoom.cpp | 118 ++++++++------------ code/battlezoom.h | 4 +- code/battlezoomstate.cpp | 122 +++++++++++++++++++++ code/battlezoomstate.hh | 40 +++++++ code/gamewindow.cpp | 7 +- manual/changes/battlefield-zoom.md | 5 +- manual/content/systems/battlefield-zoom.md | 6 +- manual/content/systems/sidebar.md | 2 +- manual/data/command-adapters.yaml | 3 - tests/battlezoom/CMakeLists.txt | 3 +- tests/battlezoom/battlezoomcontract.cpp | 101 +++++++++++++++++ 11 files changed, 326 insertions(+), 85 deletions(-) create mode 100644 code/battlezoomstate.cpp create mode 100644 code/battlezoomstate.hh diff --git a/code/battlezoom.cpp b/code/battlezoom.cpp index 13c4220d6..8f6328309 100644 --- a/code/battlezoom.cpp +++ b/code/battlezoom.cpp @@ -17,6 +17,7 @@ #include "_surface.h" #include "_tactica.h" #include "battlezoomlayout.h" +#include "battlezoomstate.hh" #include "globals.h" #include "keyboard.h" #include "session.h" @@ -27,17 +28,9 @@ #include -static int _Zoom = BATTLE_ZOOM_MIN; -static int _Pending = BATTLE_ZOOM_MIN; +static BattleZoomStateClass _State; static bool _Applying = false; -static bool _HasAnchor = false; -static Point2D _Anchor(0, 0); -static float _WheelRemainder = 0.0f; - -static int _ButtonsHeld = 0; -static bool _DragInBattlefield = false; - static bool Can_Zoom(void) { @@ -47,7 +40,7 @@ static bool Can_Zoom(void) static bool Is_Zoomed(void) { - return(_Zoom > BATTLE_ZOOM_MIN && ScenarioActive); + return(_State.Factor() > BATTLE_ZOOM_MIN && ScenarioActive); } @@ -57,7 +50,7 @@ static bool Is_Zoomed(void) /// int; 1 when the battlefield is not zoomed. int Battle_Zoom_Factor(void) { - return(_Zoom); + return(_State.Factor()); } @@ -80,10 +73,7 @@ bool Battle_Zoom_Applying(void) Rect Battle_Zoom_Fit_View(Rect const & battlefield) { BattlefieldRect = battlefield; - _Zoom = ScenarioActive ? Battle_Zoom_Nearest_Allowed(battlefield, _Zoom) : (int)BATTLE_ZOOM_MIN; - _Pending = _Zoom; - - return(Battle_Zoom_View_Rect(battlefield, _Zoom)); + return(_State.Fit_View(battlefield, ScenarioActive)); } @@ -93,12 +83,9 @@ Rect Battle_Zoom_Fit_View(Rect const & battlefield) /// void Battle_Zoom_Reset(void) { - _Pending = BATTLE_ZOOM_MIN; - _HasAnchor = false; - _WheelRemainder = 0.0f; - - if (_Zoom != BATTLE_ZOOM_MIN) { - _Zoom = BATTLE_ZOOM_MIN; + bool const changed = _State.Factor() != BATTLE_ZOOM_MIN; + _State.Reset(); + if (changed) { _Applying = true; Map.Set_View_Dimensions(BattlefieldRect); _Applying = false; @@ -109,42 +96,42 @@ void Battle_Zoom_Reset(void) /// /// Applies a zoom change asked for by the wheel. The change waits while a key press is pending -/// or a mouse button is held, and is dropped while the player cannot zoom. The world position -/// under the cursor when the wheel turned stays under it. +/// or a mouse button is held, and is dropped while the player cannot zoom. The map position at +/// the recorded pointer location stays there when the factor changes, subject to map limits. /// /// Is a key or button press waiting to be processed this frame? void Battle_Zoom_AI(bool input_pending) { - if (!Can_Zoom()) { - _Pending = _Zoom; - _HasAnchor = false; - return; - } - // A release the interface took never reaches Battle_Zoom_Mouse_Button. - if (Keyboard == NULL || (!Keyboard->Down(KN_LMOUSE) && !Keyboard->Down(KN_RMOUSE))) { - Battle_Zoom_Release_Buttons(); + unsigned held = 0; + if (Keyboard != nullptr) { + KeyNumType const keys[] = { KN_LMOUSE, KN_RMOUSE, (KeyNumType)VK_MBUTTON, (KeyNumType)VK_XBUTTON1, (KeyNumType)VK_XBUTTON2 }; + for (int button = WINDOW_BUTTON_LEFT; button <= WINDOW_BUTTON_X2; button++) { + if (Keyboard->Down(keys[button])) { + held |= BattleZoomStateClass::Button_Mask((WindowMouseButton)button); + } + } } + _State.Reconcile_Buttons(held); - if (_Pending == _Zoom || input_pending || _ButtonsHeld > 0) { + int const before_factor = _State.Factor(); + Point2D frame; + if (!_State.Apply_Pending(BattlefieldRect, Can_Zoom(), input_pending, frame)) { return; } - Point2D frame = _HasAnchor ? _Anchor : Point2D(BattlefieldRect.X + BattlefieldRect.Width / 2, BattlefieldRect.Y + BattlefieldRect.Height / 2); Rect const before_view = TacticalRect; - Point2D const before_point = Battle_Zoom_Screen_To_View(frame, BattlefieldRect, _Zoom); + Point2D const before_point = Battle_Zoom_Screen_To_View(frame, BattlefieldRect, before_factor); Point2D const center = TacticalMap->Get_Tactical_Position(); - _Zoom = _Pending; _Applying = true; Map.Set_View_Dimensions(BattlefieldRect); _Applying = false; - Point2D const after_point = Battle_Zoom_Screen_To_View(frame, BattlefieldRect, _Zoom); + Point2D const after_point = Battle_Zoom_Screen_To_View(frame, BattlefieldRect, _State.Factor()); TacticalMap->Set_Tactical_Position(Battle_Zoom_Anchored_Center(center, before_view, before_point, TacticalRect, after_point)); Map.Flag_To_Redraw(GS_REDRAW_ALL); - _HasAnchor = false; } @@ -157,24 +144,7 @@ void Battle_Zoom_AI(bool input_pending) /// while the player cannot zoom, is left for the sidebar. bool Battle_Zoom_Wheel(float wheel, Point2D const & frame) { - if (!Can_Zoom() || !BattlefieldRect.Is_Point_Within(frame)) { - _WheelRemainder = 0.0f; - return(false); - } - - if (_ButtonsHeld > 0) { - _WheelRemainder = 0.0f; - return(true); - } - - int steps = Battle_Zoom_Wheel_Steps(_WheelRemainder, wheel); - if (steps != 0) { - _Pending = Battle_Zoom_Nearest_Allowed(BattlefieldRect, std::clamp(_Pending + steps, (int)BATTLE_ZOOM_MIN, (int)BATTLE_ZOOM_MAX)); - _Anchor = frame; - _HasAnchor = true; - } - - return(true); + return(_State.Wheel(BattlefieldRect, Can_Zoom(), wheel, frame)); } @@ -183,17 +153,12 @@ bool Battle_Zoom_Wheel(float wheel, Point2D const & frame) /// battlefield is held, the pointer stays mapped onto the battlefield, so a drag that leaves /// it ends at the edge of the view. /// +/// The button whose state changed. /// Is this a press? /// The pointer position in frame pixels. -void Battle_Zoom_Mouse_Button(bool down, Point2D const & frame) +void Battle_Zoom_Mouse_Button(WindowMouseButton button, bool down, Point2D const & frame) { - if (down) { - if (_ButtonsHeld++ == 0) { - _DragInBattlefield = BattlefieldRect.Is_Point_Within(frame); - } - } else if (_ButtonsHeld > 0 && --_ButtonsHeld == 0) { - _DragInBattlefield = false; - } + _State.Mouse_Button(button, down, BattlefieldRect, frame); } @@ -202,14 +167,20 @@ void Battle_Zoom_Mouse_Button(bool down, Point2D const & frame) /// void Battle_Zoom_Release_Buttons(void) { - _ButtonsHeld = 0; - _DragInBattlefield = false; + _State.Release_Buttons(); +} + + +void Battle_Zoom_Cancel_Pending(void) +{ + _State.Cancel_Pending(); } /// /// Converts a frame position into the coordinates the game uses. A position over the zoomed -/// battlefield becomes a native view position; any other position is left alone. +/// battlefield becomes a native view position. A drag started there stays clamped to it; +/// other positions are left alone. /// /// The position to convert in place. void Battle_Zoom_Frame_To_View(Point2D & point) @@ -218,12 +189,12 @@ void Battle_Zoom_Frame_To_View(Point2D & point) return; } - if (_DragInBattlefield) { + if (_State.Dragging()) { point.X = std::clamp(point.X, BattlefieldRect.X, BattlefieldRect.X + BattlefieldRect.Width - 1); point.Y = std::clamp(point.Y, BattlefieldRect.Y, BattlefieldRect.Y + BattlefieldRect.Height - 1); } - point = Battle_Zoom_Screen_To_View(point, BattlefieldRect, _Zoom); + point = Battle_Zoom_Screen_To_View(point, BattlefieldRect, _State.Factor()); } @@ -238,7 +209,7 @@ Point2D Battle_Zoom_View_To_Frame(Point2D const & point) return(point); } - return(Battle_Zoom_View_To_Screen(point, BattlefieldRect, _Zoom)); + return(Battle_Zoom_View_To_Screen(point, BattlefieldRect, _State.Factor())); } @@ -253,7 +224,7 @@ Point2D Battle_Zoom_Edge_Point(Point2D const & point) return(point); } - Point2D frame = Battle_Zoom_View_To_Screen(point, BattlefieldRect, _Zoom); + Point2D frame = Battle_Zoom_View_To_Screen(point, BattlefieldRect, _State.Factor()); if (point.X == TacticalRect.X) { frame.X = BattlefieldRect.X; @@ -290,11 +261,12 @@ bool Battle_Zoom_Present(Surface & surface) } // Screen shake moves the magnified picture by whole native pixels. - Rect const screen = Battle_Zoom_Screen_Rect(BattlefieldRect, _Zoom); - Rect const source = TacticalRect - Point2D(Map.ScreenX / _Zoom, Map.ScreenY / _Zoom); + int const zoom = _State.Factor(); + Rect const screen = Battle_Zoom_Screen_Rect(BattlefieldRect, zoom); + Rect const source = TacticalRect - Point2D(Map.ScreenX / zoom, Map.ScreenY / zoom); Rect const shown = Intersect(source, TacticalRect); - Rect const dest(screen.X + (shown.X - source.X) * _Zoom, screen.Y + (shown.Y - source.Y) * _Zoom, shown.Width * _Zoom, shown.Height * _Zoom); + Rect const dest(screen.X + (shown.X - source.X) * zoom, screen.Y + (shown.Y - source.Y) * zoom, shown.Width * zoom, shown.Height * zoom); if (!(dest == BattlefieldRect)) { VisibleSurface->Fill_Rect(BattlefieldRect, 0); } diff --git a/code/battlezoom.h b/code/battlezoom.h index 5a27fbdf3..77cbb3699 100644 --- a/code/battlezoom.h +++ b/code/battlezoom.h @@ -16,6 +16,7 @@ #include "point.h" #include "rect.h" +#include "windowevent.hh" class Surface; @@ -29,8 +30,9 @@ void Battle_Zoom_AI(bool input_pending); bool Battle_Zoom_Wheel(float wheel, Point2D const & frame); -void Battle_Zoom_Mouse_Button(bool down, Point2D const & frame); +void Battle_Zoom_Mouse_Button(WindowMouseButton button, bool down, Point2D const & frame); void Battle_Zoom_Release_Buttons(void); +void Battle_Zoom_Cancel_Pending(void); void Battle_Zoom_Frame_To_View(Point2D & point); Point2D Battle_Zoom_View_To_Frame(Point2D const & point); Point2D Battle_Zoom_Edge_Point(Point2D const & point); diff --git a/code/battlezoomstate.cpp b/code/battlezoomstate.cpp new file mode 100644 index 000000000..8f1cbc0d7 --- /dev/null +++ b/code/battlezoomstate.cpp @@ -0,0 +1,122 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "battlezoomstate.hh" + +#include + + +int BattleZoomStateClass::Factor(void) const +{ + return(Zoom); +} + + +Rect BattleZoomStateClass::Fit_View(Rect const & battlefield, bool scenario_active) +{ + Zoom = scenario_active ? Battle_Zoom_Nearest_Allowed(battlefield, Zoom) : (int)BATTLE_ZOOM_MIN; + Pending = Zoom; + HasAnchor = false; + if (!scenario_active) { + Cancel_Pending(); + Release_Buttons(); + } + return(Battle_Zoom_View_Rect(battlefield, Zoom)); +} + + +bool BattleZoomStateClass::Wheel(Rect const & battlefield, bool can_zoom, float wheel, Point2D const & frame) +{ + if (!can_zoom || !battlefield.Is_Point_Within(frame)) { + WheelRemainder = 0.0f; + return(false); + } + + int const steps = Battle_Zoom_Wheel_Steps(WheelRemainder, wheel); + if (steps != 0) { + Pending = Battle_Zoom_Nearest_Allowed(battlefield, std::clamp(Pending + steps, (int)BATTLE_ZOOM_MIN, (int)BATTLE_ZOOM_MAX)); + Anchor = frame; + HasAnchor = true; + } + return(true); +} + + +bool BattleZoomStateClass::Apply_Pending(Rect const & battlefield, bool can_zoom, bool input_pending, Point2D & frame) +{ + if (!can_zoom) { + Cancel_Pending(); + return(false); + } + if (Pending == Zoom || input_pending || ButtonsHeld != 0) { + return(false); + } + + frame = HasAnchor ? Anchor : Point2D(battlefield.X + battlefield.Width / 2, battlefield.Y + battlefield.Height / 2); + Zoom = Pending; + HasAnchor = false; + return(true); +} + + +void BattleZoomStateClass::Cancel_Pending(void) +{ + Pending = Zoom; + HasAnchor = false; + WheelRemainder = 0.0f; +} + + +void BattleZoomStateClass::Reset(void) +{ + Zoom = BATTLE_ZOOM_MIN; + Cancel_Pending(); + Release_Buttons(); +} + + +unsigned BattleZoomStateClass::Button_Mask(WindowMouseButton button) +{ + return(button >= WINDOW_BUTTON_LEFT && button <= WINDOW_BUTTON_X2 ? 1u << button : 0u); +} + + +void BattleZoomStateClass::Mouse_Button(WindowMouseButton button, bool down, Rect const & battlefield, Point2D const & frame) +{ + unsigned const mask = Button_Mask(button); + if (down) { + if (ButtonsHeld == 0 && battlefield.Is_Point_Within(frame)) { + DragButton = mask; + } + ButtonsHeld |= mask; + } else { + ButtonsHeld &= ~mask; + DragButton &= ButtonsHeld; + } +} + + +void BattleZoomStateClass::Reconcile_Buttons(unsigned held) +{ + ButtonsHeld = held; + DragButton &= held; +} + + +void BattleZoomStateClass::Release_Buttons(void) +{ + ButtonsHeld = 0; + DragButton = 0; +} + + +bool BattleZoomStateClass::Dragging(void) const +{ + return(DragButton != 0); +} diff --git a/code/battlezoomstate.hh b/code/battlezoomstate.hh new file mode 100644 index 000000000..b4ed79282 --- /dev/null +++ b/code/battlezoomstate.hh @@ -0,0 +1,40 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include "battlezoomlayout.h" +#include "windowevent.hh" + + +class BattleZoomStateClass +{ +public: + int Factor(void) const; + Rect Fit_View(Rect const & battlefield, bool scenario_active); + bool Wheel(Rect const & battlefield, bool can_zoom, float wheel, Point2D const & frame); + bool Apply_Pending(Rect const & battlefield, bool can_zoom, bool input_pending, Point2D & frame); + void Cancel_Pending(void); + void Reset(void); + + static unsigned Button_Mask(WindowMouseButton button); + void Mouse_Button(WindowMouseButton button, bool down, Rect const & battlefield, Point2D const & frame); + void Reconcile_Buttons(unsigned held); + void Release_Buttons(void); + bool Dragging(void) const; + +private: + int Zoom = BATTLE_ZOOM_MIN; + int Pending = BATTLE_ZOOM_MIN; + bool HasAnchor = false; + Point2D Anchor = Point2D(0, 0); + float WheelRemainder = 0.0f; + unsigned ButtonsHeld = 0; + unsigned DragButton = 0; +}; diff --git a/code/gamewindow.cpp b/code/gamewindow.cpp index 41f78ca59..5d0f57330 100644 --- a/code/gamewindow.cpp +++ b/code/gamewindow.cpp @@ -161,7 +161,7 @@ static bool Handle_Event(WindowEvent const & event) } if (event.Type == WINDOW_EVENT_MOUSE_DOWN) { - Battle_Zoom_Mouse_Button(true, framepoint); + Battle_Zoom_Mouse_Button(event.Button, true, framepoint); } Point2D point = framepoint; @@ -170,7 +170,7 @@ static bool Handle_Event(WindowEvent const & event) frame.Y = point.Y; if (event.Type == WINDOW_EVENT_MOUSE_UP) { - Battle_Zoom_Mouse_Button(false, framepoint); + Battle_Zoom_Mouse_Button(event.Button, false, framepoint); } } @@ -193,6 +193,9 @@ static bool Handle_Event(WindowEvent const & event) case WINDOW_EVENT_FOCUS_GAINED: case WINDOW_EVENT_FOCUS_LOST: Battle_Zoom_Release_Buttons(); + if (event.Type == WINDOW_EVENT_FOCUS_LOST) { + Battle_Zoom_Cancel_Pending(); + } Set_Game_Focus(event.Type == WINDOW_EVENT_FOCUS_GAINED); return(false); diff --git a/manual/changes/battlefield-zoom.md b/manual/changes/battlefield-zoom.md index df913e2a9..5234401d5 100644 --- a/manual/changes/battlefield-zoom.md +++ b/manual/changes/battlefield-zoom.md @@ -6,10 +6,13 @@ targets: - type: system id: battlefield-zoom effect: added +- type: system + id: sidebar + effect: changed credit: - equalchance --- -The mouse wheel over the tactical view now zooms the battlefield in whole steps from 1x to 4x, as far as the game resolution allows, while the sidebar and the tab strip keep their size. The wheel used to scroll the sidebar wherever the pointer was; it still does over the sidebar and the tab strip. +The mouse wheel over the tactical view now zooms the battlefield in whole steps from 1x to 4x, as far as the game resolution allows, while the sidebar and the tab strip keep their size. The wheel used to scroll the sidebar wherever the pointer was; it now does so over the sidebar and the tab strip, or while battlefield zoom is unavailable. The zoom follows the battlefield-only zoom of the TI Chilong Edition build of Vinifera. diff --git a/manual/content/systems/battlefield-zoom.md b/manual/content/systems/battlefield-zoom.md index ef776b81e..6406a8bc7 100644 --- a/manual/content/systems/battlefield-zoom.md +++ b/manual/content/systems/battlefield-zoom.md @@ -12,7 +12,7 @@ Each wheel notch over the tactical view zooms in one step, away from the player, ## Available factors -A factor is offered only while the part of the map it shows is at least 472 pixels wide and 524 pixels high. With the sidebar taking 168 pixels and the tab strip 16, that gives: +Factors above 1x are offered only while the part of the map they show is at least 472 pixels wide and 524 pixels high. The 1x view remains available at every valid size. With the sidebar taking 168 pixels and the tab strip 16, that gives: | Game resolution | Largest factor | | --- | --- | @@ -25,9 +25,9 @@ A wheel step past the largest factor is ignored. Rows and columns that a whole f ## When the zoom changes -The new factor takes effect on the next frame with no key press waiting and no mouse button held. The map position under the pointer when the wheel turned stays under the pointer, unless that would move the view past the edge of the map. +The new factor takes effect on the next frame with no key press waiting and no mouse button held. The zoom change keeps the map point at the last wheel notch's pointer position in place, unless that would move the view past the edge of the map. -The wheel does not zoom while the window is in the background, while a trigger or the end of a campaign mission locks the player's input, while a trigger zoom is in effect, or in the map editor. A [Zoom in](/mapping/actions/taction-zoom-in/) trigger action first returns the battlefield to 1x. +The wheel does not zoom while the window is in the background, while a trigger or the end of a campaign mission locks the player's input, or while a trigger zoom is in effect. Any waiting wheel change is canceled in these cases. A [Zoom in](/mapping/actions/taction-zoom-in/) trigger action first returns the battlefield to 1x. ## What stays at screen size diff --git a/manual/content/systems/sidebar.md b/manual/content/systems/sidebar.md index ebce2e2dd..8d9b3441d 100644 --- a/manual/content/systems/sidebar.md +++ b/manual/content/systems/sidebar.md @@ -161,7 +161,7 @@ Twelve commands do the same from the keyboard. Each motion has three commands: o | A screenful up | [Sidebar PageUp](/commands/sidebarpageup/) | [Structure List PageUp](/commands/leftsidebarpageup/) | [Unit List PageUp](/commands/rightsidebarpageup/) | | A screenful down | [Sidebar PageDown](/commands/sidebarpagedown/) | [Structure List PageDown](/commands/leftsidebarpagedown/) | [Unit List PageDown](/commands/rightsidebarpagedown/) | -Turning the mouse wheel forward runs Sidebar Up, and turning it back runs Sidebar Down, wherever the pointer is. +Over the sidebar or the tab strip, turning the mouse wheel forward runs Sidebar Up, and turning it back runs Sidebar Down. Over the tactical view, the wheel controls [battlefield zoom](/systems/battlefield-zoom/) when zoom is allowed, even if the resolution permits only 1x. When zoom is unavailable, the wheel scrolls the sidebar there too. :::caution[When the scroll commands play ScoldSound] The four both-strips commands, including the mouse wheel, play `ScoldSound` only when neither strip can move. A strip already at its end stays silent as long as the other one still moves. The eight one-strip commands never play it, although the arrow buttons for the same motion do. diff --git a/manual/data/command-adapters.yaml b/manual/data/command-adapters.yaml index 0c73b2f8c..5936a31a1 100644 --- a/manual/data/command-adapters.yaml +++ b/manual/data/command-adapters.yaml @@ -441,9 +441,6 @@ fixed_exclusions: - { file: code/scroll.cpp, function: ScrollClass::Scroll_AI, expression: KN_RMOUSE } reason: Continuous tactical-map pointer state, not discrete command dispatch. - sites: - - { file: code/battlezoom.cpp, function: Battle_Zoom_AI, expression: KN_LMOUSE } - - { file: code/battlezoom.cpp, function: Battle_Zoom_AI, expression: KN_RMOUSE } - - { file: code/battlezoom.cpp, function: Battle_Zoom_AI, expression: KN_LMOUSE+KN_RMOUSE } - { file: code/mouse.cpp, function: MouseClass::AI, expression: KN_NONE } reason: A battlefield zoom change waits for held buttons and pending input; these are not controls. - sites: diff --git a/tests/battlezoom/CMakeLists.txt b/tests/battlezoom/CMakeLists.txt index ac5a710e4..93566e9d8 100644 --- a/tests/battlezoom/CMakeLists.txt +++ b/tests/battlezoom/CMakeLists.txt @@ -1,9 +1,10 @@ -# The battlefield zoom geometry is compiled into the harness on its own; it reads no engine state. +# The geometry and pending zoom state are tested without game assets. opents_add_test(BattleZoom NAME battlezoom SOURCES battlezoomcontract.cpp ENGINE battlezoomlayout.cpp + battlezoomstate.cpp DEFINITIONS WIN32 _WINDOWS NOMINMAX ) diff --git a/tests/battlezoom/battlezoomcontract.cpp b/tests/battlezoom/battlezoomcontract.cpp index 2a64f4138..396084348 100644 --- a/tests/battlezoom/battlezoomcontract.cpp +++ b/tests/battlezoom/battlezoomcontract.cpp @@ -12,8 +12,10 @@ // view centre that keeps the world under the cursor. #include "battlezoomlayout.h" +#include "battlezoomstate.hh" #include +#include namespace { @@ -115,6 +117,105 @@ int main() Check(Battle_Zoom_Wheel_Steps(remainder, 0.4f) == 1, "until they add up to a step"); Check(Battle_Zoom_Wheel_Steps(remainder, -1.0f) == -1, "turning back drops the fraction left over"); + for (WindowMouseButton button : { WINDOW_BUTTON_LEFT, WINDOW_BUTTON_RIGHT, WINDOW_BUTTON_MIDDLE, WINDOW_BUTTON_X1, WINDOW_BUTTON_X2 }) { + BattleZoomStateClass state; + Point2D anchor; + state.Fit_View(hd, true); + state.Mouse_Button(button, true, hd, cursor); + Check(state.Wheel(hd, true, 1.0f, cursor), "a held button's wheel request is consumed by zoom"); + state.Reconcile_Buttons(BattleZoomStateClass::Button_Mask(button)); + Check(!state.Apply_Pending(hd, true, false, anchor) && state.Factor() == 1, "a pending zoom waits for the held button"); + state.Mouse_Button(button, false, hd, Point2D(1800, 600)); + Check(state.Apply_Pending(hd, true, false, anchor) && state.Factor() == 2 && anchor == cursor, "release applies the queued factor at its recorded pointer location"); + } + + BattleZoomStateClass state; + Point2D anchor; + state.Fit_View(hd, true); + state.Wheel(hd, true, 1.0f, cursor); + Check(!state.Apply_Pending(hd, true, true, anchor) && state.Factor() == 1, "pending keyboard input defers the factor change"); + Check(state.Apply_Pending(hd, true, false, anchor) && anchor == cursor, "an idle frame applies the retained pointer anchor"); + + state.Reset(); + state.Wheel(hd, true, 1.0f, cursor); + state.Reconcile_Buttons(BattleZoomStateClass::Button_Mask(WINDOW_BUTTON_MIDDLE)); + Check(!state.Apply_Pending(hd, true, false, anchor), "a polled middle button blocks a request queued before its press"); + state.Reconcile_Buttons(0); + Check(state.Apply_Pending(hd, true, false, anchor), "a release consumed by the interface is reconciled from live state"); + + state.Reset(); + state.Mouse_Button(WINDOW_BUTTON_LEFT, true, hd, cursor); + state.Mouse_Button(WINDOW_BUTTON_LEFT, true, hd, cursor); + state.Wheel(hd, true, 1.0f, cursor); + state.Mouse_Button(WINDOW_BUTTON_LEFT, false, hd, cursor); + Check(!state.Dragging() && state.Apply_Pending(hd, true, false, anchor), "a repeated press cannot leave a phantom held button after release"); + + state.Reset(); + state.Mouse_Button(WINDOW_BUTTON_LEFT, true, hd, cursor); + state.Mouse_Button(WINDOW_BUTTON_RIGHT, true, hd, cursor); + state.Wheel(hd, true, 1.0f, cursor); + state.Mouse_Button(WINDOW_BUTTON_LEFT, false, hd, cursor); + Check(!state.Dragging() && !state.Apply_Pending(hd, true, false, anchor), "releasing the drag's button ends its clamp but another hold still defers zoom"); + state.Mouse_Button(WINDOW_BUTTON_RIGHT, false, hd, cursor); + Check(state.Apply_Pending(hd, true, false, anchor), "the pending change waits for the last button to release"); + + state.Reset(); + state.Mouse_Button(WINDOW_BUTTON_LEFT, true, hd, Point2D(1800, 500)); + Check(!state.Dragging(), "a sidebar press does not clamp the pointer onto the battlefield"); + state.Release_Buttons(); + Check(!state.Dragging(), "focus release clears the active drag"); + state.Wheel(hd, true, 1.0f, cursor); + state.Cancel_Pending(); + Check(!state.Apply_Pending(hd, true, false, anchor) && state.Factor() == 1, "focus loss cancels a pending request before focus can return"); + + state.Wheel(hd, true, 1.0f, cursor); + Check(!state.Apply_Pending(hd, false, false, anchor), "input lock or trigger zoom cancels a waiting factor"); + Check(!state.Apply_Pending(hd, true, false, anchor), "unlocking input does not revive a cancelled request"); + state.Wheel(hd, true, 0.6f, cursor); + state.Apply_Pending(hd, false, false, anchor); + state.Wheel(hd, true, 0.6f, cursor); + Check(!state.Apply_Pending(hd, true, false, anchor), "disabled zoom also discards a smooth wheel's partial step"); + + state.Reset(); + Check(!state.Wheel(hd, false, 1.0f, cursor) && !state.Wheel(hd, true, 1.0f, Point2D(1800, 500)), "disabled zoom and sidebar input are returned to sidebar scrolling"); + state.Wheel(hd, true, 0.6f, cursor); + state.Wheel(hd, true, 0.5f, Point2D(1800, 500)); + state.Wheel(hd, true, 0.6f, cursor); + Check(!state.Apply_Pending(hd, true, false, anchor), "a wheel event outside the battlefield clears its partial step"); + + state.Reset(); + state.Fit_View(uhd, true); + state.Wheel(uhd, true, 1.4f, cursor); + state.Apply_Pending(uhd, true, false, anchor); + state.Fit_View(uhd, true); + state.Wheel(uhd, true, 0.7f, cursor); + Check(state.Apply_Pending(uhd, true, false, anchor) && state.Factor() == 3, "applying a factor keeps a smooth wheel's unused fraction"); + state.Fit_View(hd, true); + Check(state.Factor() == 2, "a smaller battlefield falls back to its largest available factor"); + state.Wheel(hd, true, -1.0f, cursor); + state.Reset(); + Check(state.Factor() == 1 && !state.Dragging() && !state.Apply_Pending(hd, true, false, anchor), "scenario and trigger resets clear the factor, gesture, and pending change"); + state.Wheel(hd, true, 1.0f, cursor); + state.Apply_Pending(hd, true, false, anchor); + Check(Same(state.Fit_View(hd, false), hd.X, hd.Y, hd.Width, hd.Height) && state.Factor() == 1, "the menu always fits an unzoomed view after a zoomed match"); + Check(!state.Apply_Pending(hd, true, false, anchor), "starting another scenario cannot apply the previous match's request"); + state.Fit_View(small, true); + Check(state.Wheel(small, true, 1.0f, cursor) && !state.Apply_Pending(small, true, false, anchor), "a battlefield with no larger factor still consumes the wheel"); + Check(Battle_Zoom_Allowed(Rect(0, 16, 472, 384), 1), "the supported 640x400 frame keeps its smaller unzoomed battlefield"); + + Rect const odd_uhd(11, 17, 3673, 2145); + for (int factor = 1; factor <= 4; factor++) { + Rect const view = Battle_Zoom_View_Rect(odd_uhd, factor); + bool samples_match = true; + for (int y = view.Y; y < view.Y + view.Height; y += 37) { + for (int x = view.X; x < view.X + view.Width; x += 41) { + Point2D const point(x, y); + samples_match &= Battle_Zoom_Screen_To_View(Battle_Zoom_View_To_Screen(point, odd_uhd, factor), odd_uhd, factor) == point; + } + } + Check(samples_match, "odd-dimension frame and view positions round-trip at each supported factor"); + } + std::printf("\n%s\n", Failures == 0 ? "PASSED" : "FAILED"); return(Failures == 0 ? 0 : 1); } From 2d67969036b46ca604e2861601eb99d192237904 Mon Sep 17 00:00:00 2001 From: equalchance <182705210+equalchance@users.noreply.github.com> Date: Tue, 13 Oct 2026 06:32:28 +0300 Subject: [PATCH 05/14] Draw the sidebar and dialogs larger at high resolutions --- code/battlezoom.cpp | 82 ++++++++++---- code/battlezoom.h | 1 + code/display.cpp | 14 +-- code/gscreen.cpp | 4 +- code/mainopt.cpp | 12 +- code/options.cpp | 3 + code/options.h | 7 ++ code/saveload.cpp | 12 +- code/sidebar.cpp | 23 ++-- code/startup.cpp | 10 +- code/tab.cpp | 7 +- code/ui/uienginehost.cpp | 8 +- code/ui/uihost.h | 3 + code/ui/uishell.cpp | 1 + code/uiscale.cpp | 83 ++++++++++++++ code/uiscale.h | 23 ++++ code/uiscalelayout.cpp | 104 ++++++++++++++++++ code/uiscalelayout.h | 43 ++++++++ manual/changes/sidebar-ui-scale.md | 18 +++ .../content/keys/uiscale--client-settings.md | 18 +++ manual/content/systems/battlefield-zoom.md | 2 +- manual/content/systems/sidebar.md | 2 +- manual/data/ini-keys.yaml | 18 +++ tests/battlezoom/CMakeLists.txt | 3 +- tests/battlezoom/battlezoomcontract.cpp | 23 ++++ 25 files changed, 452 insertions(+), 72 deletions(-) create mode 100644 code/uiscale.cpp create mode 100644 code/uiscale.h create mode 100644 code/uiscalelayout.cpp create mode 100644 code/uiscalelayout.h create mode 100644 manual/changes/sidebar-ui-scale.md create mode 100644 manual/content/keys/uiscale--client-settings.md diff --git a/code/battlezoom.cpp b/code/battlezoom.cpp index 8f6328309..bdce295c4 100644 --- a/code/battlezoom.cpp +++ b/code/battlezoom.cpp @@ -23,6 +23,8 @@ #include "session.h" #include "surface.h" #include "tactical.h" +#include "uiscale.h" +#include "uiscalelayout.h" #include "win.h" #include @@ -44,6 +46,22 @@ static bool Is_Zoomed(void) } +static bool Is_Scaled(void) +{ + return(UI_Scale() > UI_SCALE_MIN && ScenarioActive); +} + + +/// +/// Does Battle_Zoom_Present draw the composite surface this frame? It does while the battlefield +/// is zoomed or the interface is scaled, except during a trigger zoom. +/// +bool Battle_Zoom_Presents(void) +{ + return((Is_Zoomed() || Is_Scaled()) && VisibleSurface != NULL && TacticalMap != NULL && TacticalMap->ZoomFactor == 1.0); +} + + /// /// Fetches the zoom factor the battlefield is shown at. /// @@ -179,37 +197,45 @@ void Battle_Zoom_Cancel_Pending(void) /// /// Converts a frame position into the coordinates the game uses. A position over the zoomed -/// battlefield becomes a native view position. A drag started there stays clamped to it; -/// other positions are left alone. +/// battlefield becomes a native view position, and a drag started there stays clamped to it. +/// A position over a scaled sidebar or tab strip becomes an interface position. Other +/// positions are left alone. /// /// The position to convert in place. void Battle_Zoom_Frame_To_View(Point2D & point) { - if (!Is_Zoomed()) { - return; - } + if (Is_Zoomed()) { + if (_State.Dragging()) { + point.X = std::clamp(point.X, BattlefieldRect.X, BattlefieldRect.X + BattlefieldRect.Width - 1); + point.Y = std::clamp(point.Y, BattlefieldRect.Y, BattlefieldRect.Y + BattlefieldRect.Height - 1); + } - if (_State.Dragging()) { - point.X = std::clamp(point.X, BattlefieldRect.X, BattlefieldRect.X + BattlefieldRect.Width - 1); - point.Y = std::clamp(point.Y, BattlefieldRect.Y, BattlefieldRect.Y + BattlefieldRect.Height - 1); + point = Battle_Zoom_Screen_To_View(point, BattlefieldRect, _State.Factor()); } - point = Battle_Zoom_Screen_To_View(point, BattlefieldRect, _State.Factor()); + // Battlefield positions, native or not, lie outside the areas the interface scale converts. + if (Is_Scaled()) { + point = UI_Scale_Screen_To_Interface(point, BattlefieldRect, UI_Scale()); + } } /// -/// Converts a native view position back into the frame. +/// Converts a native view or interface position back into the frame. /// -/// Point2D; The frame position, or the position unchanged when the battlefield is not -/// zoomed or the position lies outside the native view. +/// Point2D; The frame position, or the position unchanged when neither the zoom nor +/// the interface scale applies to it. Point2D Battle_Zoom_View_To_Frame(Point2D const & point) { - if (!Is_Zoomed()) { - return(point); + if (Is_Zoomed() && TacticalRect.Is_Point_Within(point)) { + return(Battle_Zoom_View_To_Screen(point, BattlefieldRect, _State.Factor())); } - return(Battle_Zoom_View_To_Screen(point, BattlefieldRect, _State.Factor())); + if (Is_Scaled()) { + return(UI_Scale_Interface_To_Screen(point, BattlefieldRect, UI_Scale())); + } + + return(point); } @@ -243,22 +269,20 @@ Point2D Battle_Zoom_Edge_Point(Point2D const & point) /// -/// Shows the composite surface with the battlefield magnified. The tab strip is copied as it -/// is, and chat and game messages are drawn at frame size over the magnified battlefield. +/// Shows the composite surface with the battlefield magnified by the zoom and the tab strip +/// enlarged by the interface scale. Chat and game messages are drawn at frame size over the +/// battlefield. /// /// The surface being presented. -/// bool; Was the surface shown here? It is not when the battlefield is not zoomed, -/// when the surface is not the composite surface, or while a trigger zoom is in effect. +/// bool; Was the surface shown here? It is only when Battle_Zoom_Presents allows it +/// and the surface is the composite surface. bool Battle_Zoom_Present(Surface & surface) { - if (!Is_Zoomed() || &surface != CompositeSurface || VisibleSurface == NULL || TacticalMap == NULL || TacticalMap->ZoomFactor != 1.0) { + if (!Battle_Zoom_Presents() || &surface != CompositeSurface) { return(false); } - Rect strip(0, 0, std::min(surface.Get_Width(), BattlefieldRect.X + BattlefieldRect.Width), BattlefieldRect.Y); - if (strip.Is_Valid()) { - VisibleSurface->Blit_From(strip, surface, strip, false, true); - } + int const scale = UI_Scale(); // Screen shake moves the magnified picture by whole native pixels. int const zoom = _State.Factor(); @@ -275,6 +299,16 @@ bool Battle_Zoom_Present(Surface & surface) VisibleSurface->Blit_From(dest, surface, shown, false, true); } + // The tab strip is copied after the battlefield: copied first, it left the battlefield black. + int const strip_width = std::min(surface.Get_Width(), BattlefieldRect.X + BattlefieldRect.Width); + Rect const strip(0, 0, strip_width / scale, BattlefieldRect.Y / scale); + if (strip.Is_Valid()) { + if (strip.Width * scale != strip_width) { + VisibleSurface->Fill_Rect(Rect(0, 0, strip_width, BattlefieldRect.Y), 0); + } + VisibleSurface->Blit_From(Rect(0, 0, strip.Width * scale, strip.Height * scale), surface, strip, false, true); + } + Surface * oldpage = LogicalSurface; LogicalSurface = VisibleSurface; Session.Messages.Draw(); diff --git a/code/battlezoom.h b/code/battlezoom.h index 77cbb3699..2c0975011 100644 --- a/code/battlezoom.h +++ b/code/battlezoom.h @@ -37,4 +37,5 @@ void Battle_Zoom_Frame_To_View(Point2D & point); Point2D Battle_Zoom_View_To_Frame(Point2D const & point); Point2D Battle_Zoom_Edge_Point(Point2D const & point); +bool Battle_Zoom_Presents(void); bool Battle_Zoom_Present(Surface & surface); diff --git a/code/display.cpp b/code/display.cpp index 9f4259ccf..83e06a315 100644 --- a/code/display.cpp +++ b/code/display.cpp @@ -142,6 +142,7 @@ #include "tag.h" #include "tagtype.h" #include "terrtype.h" +#include "uiscale.h" #include "unittype.h" #include "vein.h" #include "vector3.h" @@ -251,15 +252,10 @@ void DisplayClass::One_Time(void) PlacementShapes = MFCD::Retrieve("PLACE.SHP"); ShadowShapes = MFCD::Retrieve("SHADOW.SHP"); - Rect rect = VisibleRect; - if (Options.IsSidebarOnRight || Debug_Map) { - rect.X = 0; - } else { - rect.X = SidebarClass::SIDE_WIDTH; - } - rect.Y = 16; - rect.Width = rect.Width - SidebarClass::SIDE_WIDTH; - rect.Height = rect.Height - 16; + Rect rect; + Rect composite; + Rect sidebar; + UI_Scale_Layout(VisibleRect, rect, composite, sidebar); Set_View_Dimensions(rect); } diff --git a/code/gscreen.cpp b/code/gscreen.cpp index a1278db1c..23c66970c 100644 --- a/code/gscreen.cpp +++ b/code/gscreen.cpp @@ -415,8 +415,8 @@ void GScreenClass::Render(void) ** Draw the multiplayer message system to the Hidpage at this point. ** This way, they'll Blit along with the rest of the map. */ - // Over a zoomed battlefield the messages are drawn after the magnification instead. - if (Battle_Zoom_Factor() == 1) { + // When Battle_Zoom_Present draws the frame, it draws the messages after the magnification. + if (!Battle_Zoom_Presents()) { Session.Messages.Draw(); } if (Session.ShowInternetDebug) { diff --git a/code/mainopt.cpp b/code/mainopt.cpp index a304974bd..e926e4f13 100644 --- a/code/mainopt.cpp +++ b/code/mainopt.cpp @@ -35,6 +35,7 @@ #include "surface.h" #include "ui/screens/display/uidisplay.h" #include "ui/screens/mainopt/uimainopt.h" +#include "uiscale.h" #include "video.h" #include "color.hh" @@ -155,13 +156,12 @@ bool Change_Display_Mode(int width, int height) Main_Window_Resize(width, height); } - Rect temp = VisibleRect; - temp.X = ((Options.IsSidebarOnRight || Debug_Map) ? 0 : SidebarClass::SIDE_WIDTH); - temp.Y = 16; - temp.Width -= SidebarClass::SIDE_WIDTH; - temp.Height -= 16; + Rect temp; + Rect composite; + Rect sidebar; + UI_Scale_Layout(VisibleRect, temp, composite, sidebar); - Allocate_Surfaces(VisibleRect, Rect(0, 0, temp.Width, VisibleRect.Height), Rect(0, 0, temp.Width, VisibleRect.Height), Rect(0, 0, SidebarClass::SIDE_WIDTH, VisibleRect.Height)); + Allocate_Surfaces(VisibleRect, composite, composite, sidebar); LogicalSurface = HiddenSurface; Map.Set_View_Dimensions(temp); diff --git a/code/options.cpp b/code/options.cpp index 0c342b581..be523794e 100644 --- a/code/options.cpp +++ b/code/options.cpp @@ -143,6 +143,7 @@ OptionsClass::OptionsClass(void) : VSync(false), Renderer(0), CursorScale(0), + UIScale(0), SystemCursor(false), SoundLatency(9), KeyForceMove1(KN_LALT), @@ -435,6 +436,7 @@ void OptionsClass::Load_Settings(void) DebugString("ScaleMode is %d, IntegerScaling is %s\n", ScaleMode, IntegerScaling == true ? "ON" : "OFF"); CursorScale = ConfigINI.Get_Int("Video", "CursorScale", CursorScale); + UIScale = ConfigINI.Get_Int("Video", "UIScale", UIScale); SystemCursor = ConfigINI.Get_Bool("Video", "SystemCursor", SystemCursor); Set_Sound_Volume(ConfigINI.Get_Float("Audio", "SoundVolume", SoundVolume), false); @@ -506,6 +508,7 @@ void OptionsClass::Save_Settings (void) ConfigINI.Put_Bool("Video", "VSync", VSync); ConfigINI.Put_Int("Video", "Renderer", Renderer); ConfigINI.Put_Int("Video", "CursorScale", CursorScale); + ConfigINI.Put_Int("Video", "UIScale", UIScale); ConfigINI.Put_Bool("Video", "SystemCursor", SystemCursor); ConfigINI.Put_Float("Audio", "SoundVolume", SoundVolume); ConfigINI.Put_Float("Audio", "VoiceVolume", VoiceVolume); diff --git a/code/options.h b/code/options.h index 33ec600d5..7fb6b07f2 100644 --- a/code/options.h +++ b/code/options.h @@ -209,6 +209,13 @@ class OptionsClass { */ int CursorScale; + /* + * This is how many times larger than its artwork the sidebar and the tab strip are + * drawn, from 1 to 4. Zero chooses from the frame height: 1 below 2160 rows, and 3 at + * 2160 rows or more. A size the frame cannot fit is lowered until it fits. + */ + int UIScale; + /* * Shows the system's arrow in place of cursor.png. */ diff --git a/code/saveload.cpp b/code/saveload.cpp index 948689d47..f5f01478b 100644 --- a/code/saveload.cpp +++ b/code/saveload.cpp @@ -129,6 +129,7 @@ #include "trigtype.h" #include "tube.h" #include "tutorial.h" +#include "uiscale.h" #include "unit.h" #include "unittype.h" #include "vanim.h" @@ -756,13 +757,12 @@ static bool Get_All(SaveStreamClass & stream, bool save_net) return(false); } - Rect temp = VisibleRect; - temp.X = ((Options.IsSidebarOnRight || Debug_Map) ? 0 : SidebarClass::SIDE_WIDTH); - temp.Y = 16; - temp.Width -= SidebarClass::SIDE_WIDTH; - temp.Height -= 16; + Rect temp; + Rect composite; + Rect sidebar; + UI_Scale_Layout(VisibleRect, temp, composite, sidebar); - Allocate_Surfaces(VisibleRect, Rect(0, 0, temp.Width, VisibleRect.Height), Rect(0, 0, temp.Width, VisibleRect.Height), Rect(0, 0, SidebarClass::SIDE_WIDTH, VisibleRect.Height)); + Allocate_Surfaces(VisibleRect, composite, composite, sidebar); Map.Set_View_Dimensions(temp); diff --git a/code/sidebar.cpp b/code/sidebar.cpp index fb48e4a3f..09d734221 100644 --- a/code/sidebar.cpp +++ b/code/sidebar.cpp @@ -117,6 +117,7 @@ #include "surface.h" #include "techtype.h" #include "ui/uishell.h" +#include "uiscale.h" #include "utf8.h" #include "voc.h" #include "vox.h" @@ -420,7 +421,7 @@ void SidebarClass::Init_IO(void) SidebarRect.X = BattlefieldRect.X + BattlefieldRect.Width; SidebarRect.Y = SIDE_Y; SidebarRect.Width = 641 - SidebarRect.X; - SidebarRect.Height = (BattlefieldRect.Y - SidebarRect.Y) + BattlefieldRect.Height; + SidebarRect.Height = UI_Height() - SidebarRect.Y; /* ** Add the sidebar's buttons only if we're not in editor mode. @@ -1046,13 +1047,7 @@ void SidebarClass::Blit_Sidebar(bool complete) IsToBlitSidebar = false; if (Map.LastDrawRect == RECT_NONE) { if (IsToRedrawCredits) { - VisibleSurface->Blit_From( - Rect((Options.IsSidebarOnRight ? BattlefieldRect.Width : 0), 0, SIDE_WIDTH, CREDITS_HEIGHT), - *SidebarSurface, - Rect(0, 0, SIDE_WIDTH, CREDITS_HEIGHT), - false, - true - ); + UI_Scale_Show_Sidebar(Rect(0, 0, SIDE_WIDTH, CREDITS_HEIGHT)); IsToRedrawCredits = false; } IsToBlitSidebar = false; @@ -1063,13 +1058,13 @@ void SidebarClass::Blit_Sidebar(bool complete) } if (Map.LastDrawRect == RECT_NONE && !complete) { - VisibleSurface->Blit_From(Rect((Options.IsSidebarOnRight ? BattlefieldRect.Width : 0), 0, SIDE_WIDTH, CREDITS_HEIGHT), *SidebarSurface, Rect(0, 0, SIDE_WIDTH, CREDITS_HEIGHT)); - VisibleSurface->Blit_From(Rect((Options.IsSidebarOnRight ? BattlefieldRect.Width : 0), SIDE_BODY_Y, SIDE_WIDTH, SidebarSurface->Get_Height() - SIDE_BODY_Y), *SidebarSurface, Rect(0, SIDE_BODY_Y, SIDE_WIDTH, SidebarSurface->Get_Height() - SIDE_BODY_Y)); + UI_Scale_Show_Sidebar(Rect(0, 0, SIDE_WIDTH, CREDITS_HEIGHT)); + UI_Scale_Show_Sidebar(Rect(0, SIDE_BODY_Y, SIDE_WIDTH, SidebarSurface->Get_Height() - SIDE_BODY_Y)); } else if (!IsToBlitSidebar) { - VisibleSurface->Blit_From(Rect(Map.LastDrawRect.X + (Options.IsSidebarOnRight ? BattlefieldRect.Width : 0), Map.LastDrawRect.Y, Map.LastDrawRect.Width, Map.LastDrawRect.Height), *SidebarSurface, Map.LastDrawRect); + UI_Scale_Show_Sidebar(Map.LastDrawRect); } else { Rect sb_rect = SidebarSurface->Get_Rect(); - VisibleSurface->Blit_From(Rect((Options.IsSidebarOnRight ? BattlefieldRect.Width : 0), 0, sb_rect.Width, sb_rect.Height), *SidebarSurface, Rect(0, 0, sb_rect.Width, sb_rect.Height)); + UI_Scale_Show_Sidebar(Rect(0, 0, sb_rect.Width, sb_rect.Height)); } } IsToBlitSidebar = false; @@ -2747,7 +2742,7 @@ void SidebarClass::Reposition_Sidebar(void) SidebarRect.X = Options.IsSidebarOnRight ? BattlefieldRect.X + BattlefieldRect.Width : 0; SidebarRect.Y = SIDE_Y; SidebarRect.Width = SIDE_WIDTH; - SidebarRect.Height = BattlefieldRect.Height + BattlefieldRect.Y - SIDE_Y; + SidebarRect.Height = UI_Height() - SIDE_Y; BASECLASS::Reposition_Sidebar(); @@ -2760,7 +2755,7 @@ void SidebarClass::Reposition_Sidebar(void) /* * Position the sidebar's buttons. */ - Background.Set_Position(SidebarRect.X + 16, BattlefieldRect.Y); + Background.Set_Position(SidebarRect.X + 16, BattlefieldRect.Y / UI_Scale()); Background.Flag_To_Redraw(); Repair.Set_Position(SidebarRect.X + BUTTON_ONE_X, SidebarRect.Y + BUTTON_ONE_Y); diff --git a/code/startup.cpp b/code/startup.cpp index 919a0bf3d..8e40b7fb0 100644 --- a/code/startup.cpp +++ b/code/startup.cpp @@ -144,6 +144,7 @@ #include "tunnel.h" #include "tutorial.h" #include "ui/uishell.h" +#include "uiscale.h" #include "unit.h" #include "unittype.h" #include "vanim.h" @@ -606,11 +607,12 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * , int ) VisibleSurface->Fill(0); - Rect sidebar_rect(0,0,SidebarClass::SIDE_WIDTH,VisibleRect.Height); - Rect tile_rect(0,0,VisibleRect.Width-sidebar_rect.Width, sidebar_rect.Height); - Rect composite_rect(0,0,VisibleRect.Width-sidebar_rect.Width, sidebar_rect.Height); + Rect battlefield_rect; + Rect composite_rect; + Rect sidebar_rect; + UI_Scale_Layout(VisibleRect, battlefield_rect, composite_rect, sidebar_rect); - Allocate_Surfaces(VisibleRect, composite_rect, tile_rect, sidebar_rect, false); + Allocate_Surfaces(VisibleRect, composite_rect, composite_rect, sidebar_rect, false); LogicalSurface = HiddenSurface; Update_Visible_Surface(HiddenSurface); diff --git a/code/tab.cpp b/code/tab.cpp index 8272ce6c5..dd75a1b52 100644 --- a/code/tab.cpp +++ b/code/tab.cpp @@ -56,6 +56,7 @@ #include "scheme.h" #include "shapeset.h" #include "surface.h" +#include "uiscale.h" ShapeSet const * TabClass::TabShape = NULL; @@ -171,7 +172,7 @@ void TabClass::Draw_Credits_Tab(void) if (Scen->MissionTimer.Is_Active()) { bool light = ((int)Scen->MissionTimer < TICKS_PER_MINUTE * Rule->TimerWarning) || Map.FlasherTimer > 0; - Draw_Shape(*CompositeSurface, *SidebarDrawer, TabShape, /*light ? 4 :*/ 2, Point2D(BattlefieldRect.Width - TabShape->Get_Width(), 0), VisibleRect); + Draw_Shape(*CompositeSurface, *SidebarDrawer, TabShape, /*light ? 4 :*/ 2, Point2D(BattlefieldRect.Width / UI_Scale() - TabShape->Get_Width(), 0), VisibleRect); int time = Scen->MissionTimer; @@ -184,11 +185,11 @@ void TabClass::Draw_Credits_Tab(void) if (hours != 0) { Fancy_Text_Print(TXT_TIME_FORMAT_HOURS, *CompositeSurface, CompositeSurface->Get_Rect(), - Point2D(BattlefieldRect.Width - TabShape->Get_Width() / 2, 0), ColorSchemes[0], TBLACK, + Point2D(BattlefieldRect.Width / UI_Scale() - TabShape->Get_Width() / 2, 0), ColorSchemes[0], TBLACK, TextPrintType(TPF_METAL12 | TPF_CENTER | TPF_USE_GRAD_PAL), hours, minutes, seconds); } else { Fancy_Text_Print(TXT_TIME_FORMAT_NO_HOURS, *CompositeSurface, CompositeSurface->Get_Rect(), - Point2D(BattlefieldRect.Width - TabShape->Get_Width() / 2, 0), ColorSchemes[0], TBLACK, + Point2D(BattlefieldRect.Width / UI_Scale() - TabShape->Get_Width() / 2, 0), ColorSchemes[0], TBLACK, TextPrintType(TPF_METAL12 | TPF_CENTER | TPF_USE_GRAD_PAL), minutes, seconds); } } diff --git a/code/ui/uienginehost.cpp b/code/ui/uienginehost.cpp index aa93f848d..c9c75af46 100644 --- a/code/ui/uienginehost.cpp +++ b/code/ui/uienginehost.cpp @@ -31,6 +31,7 @@ #include "session.h" #include "side.h" #include "ui/uishell.h" +#include "uiscale.h" #include "video.h" #include "voc.h" #include "wwmouse.h" @@ -100,6 +101,11 @@ class UIEngineHostClass : public UIShellHostClass return(true); } + virtual float Interface_Scale(void) const override + { + return((float)UI_Scale()); + } + virtual int Art_Magnification(void) const override { if (Options.ScaleMode != VIDEO_SCALE_PIXELART) { @@ -107,7 +113,7 @@ class UIEngineHostClass : public UIShellHostClass } UIFrameRect frame = Frame(); - float ratio = frame.ScaleX < frame.ScaleY ? frame.ScaleX : frame.ScaleY; + float ratio = (frame.ScaleX < frame.ScaleY ? frame.ScaleX : frame.ScaleY) * Interface_Scale(); if (ratio <= 1.0f) { return(1); } diff --git a/code/ui/uihost.h b/code/ui/uihost.h index 044a1c4d5..0cc5419d1 100644 --- a/code/ui/uihost.h +++ b/code/ui/uihost.h @@ -40,6 +40,9 @@ class UIShellHostClass virtual void Play_Click(void) = 0; virtual bool Animate_Screens(void) const = 0; virtual int Art_Magnification(void) const = 0; + + // How many times larger than the frame scale alone the interface is drawn. + virtual float Interface_Scale(void) const { return(1.0f); } virtual bool Bitmap_System_Font(void) const = 0; virtual bool Developer_Keys_Armed(void) const = 0; virtual void Clear_Keyboard_Queue(void) = 0; diff --git a/code/ui/uishell.cpp b/code/ui/uishell.cpp index 12d1a9729..5099fe04c 100644 --- a/code/ui/uishell.cpp +++ b/code/ui/uishell.cpp @@ -218,6 +218,7 @@ void UIShellClass::Apply_Dimensions(void) if (ratio <= 0.0f) { ratio = 1.0f; } + ratio *= Host.Interface_Scale(); Context->SetDimensions(Rml::Vector2i(frame.Width, frame.Height)); Context->SetDensityIndependentPixelRatio(ratio); diff --git a/code/uiscale.cpp b/code/uiscale.cpp new file mode 100644 index 000000000..4e902f856 --- /dev/null +++ b/code/uiscale.cpp @@ -0,0 +1,83 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "always.h" + +#include "uiscale.h" + +#include "_rect.h" +#include "_surface.h" +#include "_ui.h" +#include "globals.h" +#include "goptions.h" +#include "surface.h" +#include "ui/uishell.h" +#include "uiscalelayout.h" + + +static int _Scale = UI_SCALE_MIN; + + +/// +/// Fetches the interface scale of the current layout. +/// +/// int; 1 when the sidebar and tab strip are drawn at their artwork size. +int UI_Scale(void) +{ + return(_Scale); +} + + +/// +/// Fetches the frame height in interface pixels, which is the height the sidebar lays itself +/// out in. +/// +int UI_Height(void) +{ + return(VisibleRect.Height / _Scale); +} + + +/// +/// Chooses the interface scale for a frame and works out the surface sizes for it. The map +/// editor always uses 1x. +/// +/// The whole frame. +/// Receives the frame area the battlefield fills. +/// Receives the size of the composite and tile surfaces. +/// Receives the size of the sidebar surface, in interface pixels. +void UI_Scale_Layout(Rect const & visible, Rect & battlefield, Rect & composite, Rect & sidebar) +{ + int const previous = _Scale; + _Scale = Debug_Map ? (int)UI_SCALE_MIN : UI_Scale_For(visible.Width, visible.Height, Options.UIScale); + if (_Scale != previous) { + UIShell.On_Video_Change(); + } + + battlefield = UI_Scale_Battlefield_Rect(visible.Width, visible.Height, _Scale); + if (!Options.IsSidebarOnRight && !Debug_Map) { + battlefield.X = UI_SCALE_SIDEBAR_WIDTH * _Scale; + } + + composite = Rect(0, 0, battlefield.Width, visible.Height); + sidebar = Rect(0, 0, UI_SCALE_SIDEBAR_WIDTH, visible.Height / _Scale); +} + + +/// +/// Copies part of the sidebar surface onto the visible surface, enlarged by the interface scale. +/// +/// The part of the sidebar surface to show, in interface pixels. +void UI_Scale_Show_Sidebar(Rect const & source) +{ + int const left = Options.IsSidebarOnRight ? BattlefieldRect.X + BattlefieldRect.Width : 0; + Rect const dest(left + source.X * _Scale, source.Y * _Scale, source.Width * _Scale, source.Height * _Scale); + + VisibleSurface->Blit_From(dest, *SidebarSurface, source); +} diff --git a/code/uiscale.h b/code/uiscale.h new file mode 100644 index 000000000..e37b54c4a --- /dev/null +++ b/code/uiscale.h @@ -0,0 +1,23 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// Interface scale: the sidebar and the tab strip are drawn a whole number of times larger +// than their artwork, so they stay readable at high game resolutions. The sidebar surface +// and the tab strip work in interface pixels; the battlefield works in frame pixels. + +#pragma once + +#include "rect.h" + + +int UI_Scale(void); +int UI_Height(void); + +void UI_Scale_Layout(Rect const & visible, Rect & battlefield, Rect & composite, Rect & sidebar); +void UI_Scale_Show_Sidebar(Rect const & source); diff --git a/code/uiscalelayout.cpp b/code/uiscalelayout.cpp new file mode 100644 index 000000000..60dd3cb04 --- /dev/null +++ b/code/uiscalelayout.cpp @@ -0,0 +1,104 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "uiscalelayout.h" + +#include + + +/// +/// The interface scale chosen for a frame height when the player has not chosen one: 1x up to +/// 1440 rows, 3x at 2160 rows, and one step per further 1080 rows, up to 4x. +/// +int UI_Scale_Automatic(int height) +{ + return(std::clamp(height >= 2160 ? std::max(3, height / 1080) : height / 1080, (int)UI_SCALE_MIN, (int)UI_SCALE_MAX)); +} + + +/// +/// Picks the interface scale for a frame. +/// +/// The player's choice from 1 to 4, or 0 or less for the automatic scale. +/// int; The chosen scale, lowered until the interface and the battlefield beside it +/// keep their minimum sizes, and never below 1. +int UI_Scale_For(int width, int height, int configured) +{ + int scale = configured > 0 ? std::min(configured, (int)UI_SCALE_MAX) : UI_Scale_Automatic(height); + + while (scale > UI_SCALE_MIN + && (width / scale < UI_SCALE_MIN_WIDTH || height / scale < UI_SCALE_MIN_HEIGHT + || width - UI_SCALE_SIDEBAR_WIDTH * scale < UI_SCALE_MIN_FIELD_WIDTH + || height - UI_SCALE_TAB_HEIGHT * scale < UI_SCALE_MIN_FIELD_HEIGHT)) { + scale--; + } + + return(std::max(scale, (int)UI_SCALE_MIN)); +} + + +/// +/// The frame area the battlefield fills beside a right-hand sidebar and below the tab strip, +/// both drawn at the given scale. +/// +Rect UI_Scale_Battlefield_Rect(int width, int height, int scale) +{ + return(Rect(0, UI_SCALE_TAB_HEIGHT * scale, width - UI_SCALE_SIDEBAR_WIDTH * scale, height - UI_SCALE_TAB_HEIGHT * scale)); +} + + +/// +/// Converts a frame position into the coordinates the sidebar and the tab strip use. +/// +/// Point2D; A position right of the battlefield lands in sidebar coordinates, which +/// start at the battlefield's right edge. A position above the battlefield lands in tab strip +/// coordinates. A position over the battlefield, or any position at 1x, comes back unchanged. +Point2D UI_Scale_Screen_To_Interface(Point2D const & point, Rect const & battlefield, int scale) +{ + if (scale <= UI_SCALE_MIN) { + return(point); + } + + int const right = battlefield.X + battlefield.Width; + + if (point.X >= right) { + return(Point2D(right + (point.X - right) / scale, point.Y / scale)); + } + + if (point.Y < battlefield.Y) { + return(Point2D(point.X / scale, point.Y / scale)); + } + + return(point); +} + + +/// +/// Converts a sidebar or tab strip position into the frame. +/// +/// Point2D; The middle of the frame pixels the interface pixel covers. A battlefield +/// position, or any position at 1x, comes back unchanged. +Point2D UI_Scale_Interface_To_Screen(Point2D const & point, Rect const & battlefield, int scale) +{ + if (scale <= UI_SCALE_MIN) { + return(point); + } + + int const right = battlefield.X + battlefield.Width; + + if (point.X >= right) { + return(Point2D(right + (point.X - right) * scale + scale / 2, point.Y * scale + scale / 2)); + } + + if (point.Y < UI_SCALE_TAB_HEIGHT) { + return(Point2D(point.X * scale + scale / 2, point.Y * scale + scale / 2)); + } + + return(point); +} diff --git a/code/uiscalelayout.h b/code/uiscalelayout.h new file mode 100644 index 000000000..19ab2e5d6 --- /dev/null +++ b/code/uiscalelayout.h @@ -0,0 +1,43 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// Geometry of the interface scale. The sidebar and the tab strip keep their artwork size in +// their own interface pixels and are drawn a whole number of times larger on the frame, while +// the battlefield fills the rest of the frame at its own pixels. + +#pragma once + +#include "point.h" +#include "rect.h" + + +enum { + UI_SCALE_MIN = 1, + UI_SCALE_MAX = 4, + + // The interface keeps at least this many interface pixels at any scale. + UI_SCALE_MIN_WIDTH = 640, + UI_SCALE_MIN_HEIGHT = 540, + + UI_SCALE_SIDEBAR_WIDTH = 168, + UI_SCALE_TAB_HEIGHT = 16, + + // The battlefield left beside a scaled interface keeps at least this size. + UI_SCALE_MIN_FIELD_WIDTH = 472, + UI_SCALE_MIN_FIELD_HEIGHT = 524 +}; + + +int UI_Scale_Automatic(int height); +int UI_Scale_For(int width, int height, int configured); + +Rect UI_Scale_Battlefield_Rect(int width, int height, int scale); + +Point2D UI_Scale_Screen_To_Interface(Point2D const & point, Rect const & battlefield, int scale); +Point2D UI_Scale_Interface_To_Screen(Point2D const & point, Rect const & battlefield, int scale); diff --git a/manual/changes/sidebar-ui-scale.md b/manual/changes/sidebar-ui-scale.md new file mode 100644 index 000000000..771dd84ea --- /dev/null +++ b/manual/changes/sidebar-ui-scale.md @@ -0,0 +1,18 @@ +--- +title: Draw the sidebar larger at high resolutions +category: feature +release: 0.2.0 +targets: +- type: key + id: UIScale + effect: added +- type: system + id: sidebar + effect: changed +credit: +- equalchance +--- + +At high resolutions the sidebar, the top strip and the game's dialogs are now drawn two to four times larger, while the battlefield keeps one screen pixel per map pixel. [`UIScale`](/keys/uiscale/) chooses the size; by default it is 3x from 2160 rows and 1x below, so 1080p and 1440p look as before. + +The interface size follows the battlefield-only zoom of the TI Chilong Edition build of Vinifera, which draws its sidebar at 3x on 4K screens. diff --git a/manual/content/keys/uiscale--client-settings.md b/manual/content/keys/uiscale--client-settings.md new file mode 100644 index 000000000..75f8cfcf5 --- /dev/null +++ b/manual/content/keys/uiscale--client-settings.md @@ -0,0 +1,18 @@ +--- +key: UIScale +scope: client-settings +label: Interface size +see_also: [ScreenWidth, ScreenHeight, CursorScale] +when_omitted: + kind: value + value: "0" + note: Zero picks the size from the screen height. +--- + +`UIScale` in `[Video]` of `sun.ini` sets how many times larger than its artwork the interface is drawn: the [sidebar](/systems/sidebar/) with its radar and cameos, the strip across the top with the Options tab and mission timer, and the game's screens and dialogs. `1` to `4` choose the size; a larger value acts as `4`. `0` or a negative value picks it from the height set by [`ScreenHeight`](/keys/screenheight/#scope-client-settings-2): `1` below 2160 rows, `3` from 2160 rows, and `4` from 4320 rows. + +At 3840 by 2160 the automatic size is `3`. The sidebar is then 504 pixels wide, the top strip 48 pixels high, and the [battlefield](/systems/battlefield-zoom/) fills the remaining 3336 by 2112 pixels at one screen pixel per map pixel. Battlefield zoom still magnifies that area. + +The size is lowered one step at a time until the interface keeps at least 640 by 540 of its own pixels and the battlefield beside it keeps at least 472 by 524 pixels. A chosen `3` at 1920 by 1080 therefore draws the interface at `2`. + +The size is chosen when the game starts, when the resolution changes, and when a saved game is loaded. Chat and game messages, and tooltips over the battlefield, stay at their artwork size. The animated main menu from `GMENU.MIX` keeps its artwork size. diff --git a/manual/content/systems/battlefield-zoom.md b/manual/content/systems/battlefield-zoom.md index 6406a8bc7..cfb75178a 100644 --- a/manual/content/systems/battlefield-zoom.md +++ b/manual/content/systems/battlefield-zoom.md @@ -12,7 +12,7 @@ Each wheel notch over the tactical view zooms in one step, away from the player, ## Available factors -Factors above 1x are offered only while the part of the map they show is at least 472 pixels wide and 524 pixels high. The 1x view remains available at every valid size. With the sidebar taking 168 pixels and the tab strip 16, that gives: +Factors above 1x are offered only while the part of the map they show is at least 472 pixels wide and 524 pixels high. The 1x view remains available at every valid size. With the sidebar and the tab strip at the automatic [`UIScale`](/keys/uiscale/) size, that gives: | Game resolution | Largest factor | | --- | --- | diff --git a/manual/content/systems/sidebar.md b/manual/content/systems/sidebar.md index 8d9b3441d..f4fb26808 100644 --- a/manual/content/systems/sidebar.md +++ b/manual/content/systems/sidebar.md @@ -25,7 +25,7 @@ related: id: ToggleRadar --- -The sidebar is the fixed-width panel along the right edge of the screen. From the top down it holds the credit readout, the radar pane, four mode buttons, and two strips of cameos with the power bar running down their left side. The panel stays up for the whole match. An observer gets the panel with both strips empty. +The sidebar is the fixed-width panel along the right edge of the screen, 168 pixels wide at its artwork size and up to four times wider under [`UIScale`](/keys/uiscale/). From the top down it holds the credit readout, the radar pane, four mode buttons, and two strips of cameos with the power bar running down their left side. The panel stays up for the whole match. An observer gets the panel with both strips empty. ## What the strips list diff --git a/manual/data/ini-keys.yaml b/manual/data/ini-keys.yaml index b81893b99..b7761ac6b 100644 --- a/manual/data/ini-keys.yaml +++ b/manual/data/ini-keys.yaml @@ -24768,6 +24768,24 @@ TypeImmune: source: code/techtype.cpp guard: null level: TechnoTypeClass +UIScale: + key: UIScale + scopes: + - applies_to: + - client settings + file: sun.ini + section: + kind: literal + name: Video + value_type: integer + status: generated + _provenance: + default_candidate: '0' + declared_in: OptionsClass + member: UIScale + source: code/options.cpp + guard: null + level: OptionsClass URepairRate: key: URepairRate scopes: diff --git a/tests/battlezoom/CMakeLists.txt b/tests/battlezoom/CMakeLists.txt index 93566e9d8..68b8a5f7c 100644 --- a/tests/battlezoom/CMakeLists.txt +++ b/tests/battlezoom/CMakeLists.txt @@ -1,4 +1,4 @@ -# The geometry and pending zoom state are tested without game assets. +# The geometry, the pending zoom state and the interface scale are tested without game assets. opents_add_test(BattleZoom NAME battlezoom @@ -6,5 +6,6 @@ opents_add_test(BattleZoom ENGINE battlezoomlayout.cpp battlezoomstate.cpp + uiscalelayout.cpp DEFINITIONS WIN32 _WINDOWS NOMINMAX ) diff --git a/tests/battlezoom/battlezoomcontract.cpp b/tests/battlezoom/battlezoomcontract.cpp index 396084348..c99de026d 100644 --- a/tests/battlezoom/battlezoomcontract.cpp +++ b/tests/battlezoom/battlezoomcontract.cpp @@ -13,6 +13,7 @@ #include "battlezoomlayout.h" #include "battlezoomstate.hh" +#include "uiscalelayout.h" #include #include @@ -216,6 +217,28 @@ int main() Check(samples_match, "odd-dimension frame and view positions round-trip at each supported factor"); } + Check(UI_Scale_Automatic(720) == 1 && UI_Scale_Automatic(1080) == 1 && UI_Scale_Automatic(1440) == 1, "the automatic interface scale is 1x below 2160 rows"); + Check(UI_Scale_Automatic(2160) == 3 && UI_Scale_Automatic(4320) == 4, "and 3x at 2160 rows, 4x at 4320"); + Check(UI_Scale_For(3840, 2160, 0) == 3 && UI_Scale_For(1920, 1080, 0) == 1 && UI_Scale_For(2560, 1440, 0) == 1, "an automatic scale fits the common frames"); + Check(UI_Scale_For(1920, 1080, 3) == 2, "a chosen scale the frame cannot fit is lowered until it fits"); + Check(UI_Scale_For(1280, 720, 2) == 1, "the scale never drops below 1x"); + Check(UI_Scale_For(3840, 2160, 9) == 4, "a chosen scale above 4x is held at 4x"); + + Rect const scaled = UI_Scale_Battlefield_Rect(3840, 2160, 3); + Check(Same(scaled, 0, 48, 3336, 2112), "a 3x interface leaves the battlefield beside a 504-pixel sidebar and below a 48-pixel tab strip"); + Check(Same(UI_Scale_Battlefield_Rect(1920, 1080, 1), 0, 16, 1752, 1064), "a 1x interface keeps the stock layout"); + + Check(Same(UI_Scale_Screen_To_Interface(Point2D(3336 + 30, 300), scaled, 3), 3336 + 10, 100), "a sidebar position becomes a sidebar position from the battlefield's right edge"); + Check(Same(UI_Scale_Screen_To_Interface(Point2D(300, 30), scaled, 3), 100, 10), "a tab strip position becomes a tab strip position"); + Check(Same(UI_Scale_Screen_To_Interface(Point2D(500, 500), scaled, 3), 500, 500), "a battlefield position is left alone"); + Check(Same(UI_Scale_Screen_To_Interface(Point2D(1800, 5), hd, 1), 1800, 5), "nothing is converted at 1x"); + + bool interface_roundtrip = true; + for (Point2D const & point : { Point2D(3336, 0), Point2D(3336 + 167, 719), Point2D(0, 0), Point2D(1111, 15) }) { + interface_roundtrip &= UI_Scale_Screen_To_Interface(UI_Scale_Interface_To_Screen(point, scaled, 3), scaled, 3) == point; + } + Check(interface_roundtrip, "an interface position survives the trip to the frame and back"); + std::printf("\n%s\n", Failures == 0 ? "PASSED" : "FAILED"); return(Failures == 0 ? 0 : 1); } From 461c0cd3f5efddc3ede39aec7387f4d5391ca793 Mon Sep 17 00:00:00 2001 From: equalchance <182705210+equalchance@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:11:23 +0300 Subject: [PATCH 06/14] Draw only the view outline on the radar --- code/radar.cpp | 5 ++--- manual/changes/radar-single-view-outline.md | 15 +++++++++++++++ manual/content/systems/map-visibility.md | 2 +- 3 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 manual/changes/radar-single-view-outline.md diff --git a/code/radar.cpp b/code/radar.cpp index 365cbfce9..b54ea115a 100644 --- a/code/radar.cpp +++ b/code/radar.cpp @@ -2219,10 +2219,9 @@ void RadarClass::Render_Radar(void) SidebarSurface->Blit_From(LastDrawRect, *RadarSurface, LastDrawRect - RadarRect.TopLeft); } + // Only the view outline is drawn: a second white frame around the whole radar image + // read as another view outline. SidebarSurface->Draw_Rect(RadarViewRect, DSurface::Build_Hicolor_Pixel(255, 255, 255)); - Rect r = RadarRect; - Rect rr = Rect(r.X - 1, r.Y - 1, r.Width + 2, r.Height + 2); - SidebarSurface->Draw_Rect(rr, DSurface::Build_Hicolor_Pixel(255, 255, 255)); } if (moved) { diff --git a/manual/changes/radar-single-view-outline.md b/manual/changes/radar-single-view-outline.md new file mode 100644 index 000000000..8102bf2fa --- /dev/null +++ b/manual/changes/radar-single-view-outline.md @@ -0,0 +1,15 @@ +--- +title: Draw one white outline on the radar +category: fix +release: 0.2.0 +targets: +- type: system + id: map-visibility + effect: changed +credit: +- equalchance +--- + +The radar used to draw a white frame around the whole map image as well as the white rectangle that marks the tactical view. When the view covered much of a small map, the two looked like two view outlines. The radar now draws only the view rectangle. + +The TI Chilong Edition build of Vinifera removes the same frame. diff --git a/manual/content/systems/map-visibility.md b/manual/content/systems/map-visibility.md index 1cbb96146..b3e64a442 100644 --- a/manual/content/systems/map-visibility.md +++ b/manual/content/systems/map-visibility.md @@ -276,7 +276,7 @@ Where several objects share one radar pixel, only one blip is drawn, and the loc Damage that has any effect makes the damaged object's blip flash for [`RadarCombatFlashTime`](/keys/radarcombatflashtime/) frames. Only the local player's objects flash. The blip switches between its normal and inverted color every [`FlashFrameTime`](/keys/flashframetime/) frames. -A white rectangle marks the part of the map the tactical view is showing, and a white frame surrounds the pane. Radar events are drawn over the terrain and the blips; [radar event](/reference/enums/radar-event/) covers the kinds and their timings. +A white rectangle marks the part of the map the tactical view is showing. Radar events are drawn over the terrain and the blips; [radar event](/reference/enums/radar-event/) covers the kinds and their timings. ### What it refuses to draw From 2aa0e7108382bf07f4d4f8e1dd02764b34553fd3 Mon Sep 17 00:00:00 2001 From: equalchance <182705210+equalchance@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:11:23 +0300 Subject: [PATCH 07/14] Draw messages and trigger zoom at the interface scale --- code/battlezoom.cpp | 125 ++++++++++++++++++++++++++++++++++++++------ code/battlezoom.h | 1 + code/display.cpp | 6 +-- code/scenario.cpp | 7 ++- 4 files changed, 118 insertions(+), 21 deletions(-) diff --git a/code/battlezoom.cpp b/code/battlezoom.cpp index bdce295c4..b95a5e994 100644 --- a/code/battlezoom.cpp +++ b/code/battlezoom.cpp @@ -18,6 +18,7 @@ #include "_tactica.h" #include "battlezoomlayout.h" #include "battlezoomstate.hh" +#include "bsurface.h" #include "globals.h" #include "keyboard.h" #include "session.h" @@ -28,6 +29,7 @@ #include "win.h" #include +#include static BattleZoomStateClass _State; @@ -53,12 +55,31 @@ static bool Is_Scaled(void) /// -/// Does Battle_Zoom_Present draw the composite surface this frame? It does while the battlefield -/// is zoomed or the interface is scaled, except during a trigger zoom. +/// Does Battle_Zoom_Present draw the composite surface this frame? It does while the interface +/// is scaled, and while the battlefield is zoomed outside a trigger zoom. /// bool Battle_Zoom_Presents(void) { - return((Is_Zoomed() || Is_Scaled()) && VisibleSurface != NULL && TacticalMap != NULL && TacticalMap->ZoomFactor == 1.0); + if (VisibleSurface == NULL || TacticalMap == NULL) { + return(false); + } + + return(Is_Scaled() || (Is_Zoomed() && TacticalMap->ZoomFactor == 1.0)); +} + + +/// +/// The area chat and game messages are laid out in. A scaled interface draws them in +/// interface pixels on a plane over the battlefield, so the area starts at that plane's corner. +/// +Rect Battle_Zoom_Message_Rect(void) +{ + int const scale = UI_Scale(); + if (scale <= UI_SCALE_MIN) { + return(BattlefieldRect); + } + + return(Rect(0, 0, BattlefieldRect.Width / scale, BattlefieldRect.Height / scale)); } @@ -268,10 +289,65 @@ Point2D Battle_Zoom_Edge_Point(Point2D const & point) } +// The message plane's background; no message text or backdrop uses this colour. +static unsigned short const MESSAGE_PLANE_KEY = 0xF81F; + +static std::unique_ptr _MessagePlane; + + +static void Draw_Scaled_Messages(int scale) +{ + Rect const area = Battle_Zoom_Message_Rect(); + if (!area.Is_Valid()) { + return; + } + + if (_MessagePlane == nullptr || _MessagePlane->Get_Width() != area.Width || _MessagePlane->Get_Height() != area.Height) { + _MessagePlane = std::make_unique(area.Width, area.Height, 2); + } + + _MessagePlane->Fill(MESSAGE_PLANE_KEY); + Surface * oldpage = LogicalSurface; + LogicalSurface = _MessagePlane.get(); + Session.Messages.Draw(); + LogicalSurface = oldpage; + + unsigned short const * source = (unsigned short const *)_MessagePlane->Lock(); + unsigned short * dest = (unsigned short *)VisibleSurface->Lock(BattlefieldRect.Top_Left()); + if (source != nullptr && dest != nullptr) { + int const source_pitch = _MessagePlane->Stride() / 2; + int const dest_pitch = VisibleSurface->Stride() / 2; + + for (int y = 0; y < area.Height; y++) { + for (int x = 0; x < area.Width; x++) { + unsigned short const pixel = source[y * source_pitch + x]; + if (pixel == MESSAGE_PLANE_KEY) { + continue; + } + + for (int row = 0; row < scale; row++) { + unsigned short * out = dest + (y * scale + row) * dest_pitch + x * scale; + for (int column = 0; column < scale; column++) { + out[column] = pixel; + } + } + } + } + } + + if (dest != nullptr) { + VisibleSurface->Unlock(); + } + if (source != nullptr) { + _MessagePlane->Unlock(); + } +} + + /// -/// Shows the composite surface with the battlefield magnified by the zoom and the tab strip -/// enlarged by the interface scale. Chat and game messages are drawn at frame size over the -/// battlefield. +/// Shows the composite surface with the battlefield magnified by the zoom or a trigger zoom and +/// the tab strip enlarged by the interface scale. Chat and game messages are drawn over the +/// battlefield, enlarged by the interface scale. /// /// The surface being presented. /// bool; Was the surface shown here? It is only when Battle_Zoom_Presents allows it @@ -284,13 +360,26 @@ bool Battle_Zoom_Present(Surface & surface) int const scale = UI_Scale(); - // Screen shake moves the magnified picture by whole native pixels. - int const zoom = _State.Factor(); - Rect const screen = Battle_Zoom_Screen_Rect(BattlefieldRect, zoom); - Rect const source = TacticalRect - Point2D(Map.ScreenX / zoom, Map.ScreenY / zoom); - Rect const shown = Intersect(source, TacticalRect); + Rect shown; + Rect dest; + if (TacticalMap->ZoomFactor != 1.0) { + + // A trigger zoom magnifies the middle of the view to fill the battlefield. + int const width = (int)(TacticalRect.Width / TacticalMap->ZoomFactor); + int const height = (int)(TacticalRect.Height / TacticalMap->ZoomFactor); + shown = Rect(TacticalRect.X + (TacticalRect.Width - width) / 2, TacticalRect.Y + (TacticalRect.Height - height) / 2, width, height); + dest = BattlefieldRect; + + } else { + + // Screen shake moves the magnified picture by whole native pixels. + int const zoom = _State.Factor(); + Rect const screen = Battle_Zoom_Screen_Rect(BattlefieldRect, zoom); + Rect const source = TacticalRect - Point2D(Map.ScreenX / zoom, Map.ScreenY / zoom); + shown = Intersect(source, TacticalRect); + dest = Rect(screen.X + (shown.X - source.X) * zoom, screen.Y + (shown.Y - source.Y) * zoom, shown.Width * zoom, shown.Height * zoom); + } - Rect const dest(screen.X + (shown.X - source.X) * zoom, screen.Y + (shown.Y - source.Y) * zoom, shown.Width * zoom, shown.Height * zoom); if (!(dest == BattlefieldRect)) { VisibleSurface->Fill_Rect(BattlefieldRect, 0); } @@ -309,10 +398,14 @@ bool Battle_Zoom_Present(Surface & surface) VisibleSurface->Blit_From(Rect(0, 0, strip.Width * scale, strip.Height * scale), surface, strip, false, true); } - Surface * oldpage = LogicalSurface; - LogicalSurface = VisibleSurface; - Session.Messages.Draw(); - LogicalSurface = oldpage; + if (scale > UI_SCALE_MIN) { + Draw_Scaled_Messages(scale); + } else { + Surface * oldpage = LogicalSurface; + LogicalSurface = VisibleSurface; + Session.Messages.Draw(); + LogicalSurface = oldpage; + } return(true); } diff --git a/code/battlezoom.h b/code/battlezoom.h index 2c0975011..bc671feb4 100644 --- a/code/battlezoom.h +++ b/code/battlezoom.h @@ -38,4 +38,5 @@ Point2D Battle_Zoom_View_To_Frame(Point2D const & point); Point2D Battle_Zoom_Edge_Point(Point2D const & point); bool Battle_Zoom_Presents(void); +Rect Battle_Zoom_Message_Rect(void); bool Battle_Zoom_Present(Surface & surface); diff --git a/code/display.cpp b/code/display.cpp index 83e06a315..d0ab97c7a 100644 --- a/code/display.cpp +++ b/code/display.cpp @@ -414,7 +414,7 @@ void DisplayClass::Set_View_Dimensions(Rect const & dimensions) ** properly set. */ Session.Messages.Init( - BattlefieldRect.X, BattlefieldRect.Y, // x,y for messages + Battle_Zoom_Message_Rect().X, Battle_Zoom_Message_Rect().Y, // x,y for messages 6, // max # msgs MAX_MESSAGE_LENGTH-14, // max msg length 7 * 2/*RESFACTOR*/, // font height in pixels @@ -422,9 +422,9 @@ void DisplayClass::Set_View_Dimensions(Rect const & dimensions) 0, /// enable edit overflow 20, // min, MAX_MESSAGE_LENGTH - 14, // max for trimming overflow - BattlefieldRect.Width); // Width in pixels of buffer + Battle_Zoom_Message_Rect().Width); // Width in pixels of buffer - Session.Messages.Set_Width(BattlefieldRect.Width); + Session.Messages.Set_Width(Battle_Zoom_Message_Rect().Width); } DebugString("Set_View_Dimensions(exit)\n"); diff --git a/code/scenario.cpp b/code/scenario.cpp index da993e33b..6d10f5aa0 100644 --- a/code/scenario.cpp +++ b/code/scenario.cpp @@ -166,6 +166,7 @@ #include "tube.h" #include "tutorial.h" #include "ui/uishell.h" +#include "uiscale.h" #include "unit.h" #include "unittype.h" #include "vein.h" @@ -373,6 +374,8 @@ bool Start_Scenario(char const * name, bool briefing, CampaignType campaign) Choose_Side(Campaigns[Scen->Campaign]->CDNumber); } + UI_Scale_Refit(); + DebugString("Reading scenario: %s\n", name); if (!Read_Scenario(name)) { @@ -802,7 +805,7 @@ bool Read_Scenario(char const * fname) ** properly set. */ Session.Messages.Init( - BattlefieldRect.X, BattlefieldRect.Y, // x,y for messages + Battle_Zoom_Message_Rect().X, Battle_Zoom_Message_Rect().Y, // x,y for messages 6, // max # msgs MAX_MESSAGE_LENGTH - 14, // max msg length 7 * 2, // font height in pixels @@ -810,7 +813,7 @@ bool Read_Scenario(char const * fname) 0,//BG 1, // enable edit overflow 20, // min, MAX_MESSAGE_LENGTH - 14, // max for trimming overflow - BattlefieldRect.Width); // Width in pixels of buffer + Battle_Zoom_Message_Rect().Width); // Width in pixels of buffer Fill_In_Data(); From 39e0868d19505cf10a6a58f6f023e364b66a30dd Mon Sep 17 00:00:00 2001 From: equalchance <182705210+equalchance@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:11:24 +0300 Subject: [PATCH 08/14] Offer the interface size in the display options --- code/gscreen.cpp | 3 ++ code/ui/screens/display/uidisplay.cpp | 19 ++++++- code/ui/screens/display/uidisplay.h | 5 ++ code/ui/screens/display/uidisplaydlg.cpp | 6 +++ code/ui/uienginehost.cpp | 2 +- code/uiscale.cpp | 53 +++++++++++++++++-- code/uiscale.h | 5 ++ .../content/keys/uiscale--client-settings.md | 4 +- tests/uishell/uishell.cpp | 25 +++++++-- ui/display.rcss | 11 +++- ui/display.rml | 1 + 11 files changed, 122 insertions(+), 12 deletions(-) diff --git a/code/gscreen.cpp b/code/gscreen.cpp index 23c66970c..8805822cd 100644 --- a/code/gscreen.cpp +++ b/code/gscreen.cpp @@ -64,6 +64,7 @@ #include "session.h" #include "surface.h" #include "tactical.h" +#include "uiscale.h" #include "video.h" #include "bench.hh" @@ -472,6 +473,8 @@ void Update_Visible_Surface(Surface *surface, Rect *rect) { Rect fill_rect; + UI_Scale_Sync_Dialogs(); + if (rect == NULL && Battle_Zoom_Present(*surface)) { Video_Present_If_Dirty(); return; diff --git a/code/ui/screens/display/uidisplay.cpp b/code/ui/screens/display/uidisplay.cpp index c294d1b2d..22de3c7c2 100644 --- a/code/ui/screens/display/uidisplay.cpp +++ b/code/ui/screens/display/uidisplay.cpp @@ -11,14 +11,24 @@ #include "ui/rml/rmlview.h" +#include +#include #include +static std::string UI_Scale_Label(int scale) +{ + return(scale <= 0 ? std::string("Interface size: Automatic") : "Interface size: " + std::to_string(scale) + "x"); +} + + UIDisplayPresenterClass::UIDisplayPresenterClass(UIDisplayServiceClass & service, UIDisplayState state) : State(std::move(state)), Service(service), Initial(State.Selected) { + State.UIScale = std::clamp(State.UIScale, 0, 4); + State.UIScaleLabel = UI_Scale_Label(State.UIScale); } @@ -33,9 +43,14 @@ void UIDisplayPresenterClass::Execute(UIIntent const & intent) } else if (intent.Name == "systemcursor") { State.SystemCursor = (intent.Value != 0); + } else if (intent.Name == "uiscale") { + State.UIScale = (State.UIScale + 1) % 5; + State.UIScaleLabel = UI_Scale_Label(State.UIScale); + } else if (intent.Name == "ok") { Service.Set_Stretch_Movies(State.StretchMovies); Service.Set_System_Cursor(State.SystemCursor); + Service.Set_UI_Scale(State.UIScale); if (State.Selected >= 0 && State.Selected != Initial) { Picked = State.Modes[State.Selected]; } @@ -104,6 +119,7 @@ class UIDisplayViewClass : public UIRmlViewClass Model.DirtyVariable("selected"); Model.DirtyVariable("stretch"); Model.DirtyVariable("systemcursor"); + Model.DirtyVariable("uiscalelabel"); } protected: @@ -122,7 +138,8 @@ class UIDisplayViewClass : public UIRmlViewClass && model.Bind("modes", &state.Modes) && model.Bind("selected", &state.Selected) && model.Bind("stretch", &state.StretchMovies) - && model.Bind("systemcursor", &state.SystemCursor)); + && model.Bind("systemcursor", &state.SystemCursor) + && model.Bind("uiscalelabel", &state.UIScaleLabel)); } private: diff --git a/code/ui/screens/display/uidisplay.h b/code/ui/screens/display/uidisplay.h index 40d4d64de..d9f5ed921 100644 --- a/code/ui/screens/display/uidisplay.h +++ b/code/ui/screens/display/uidisplay.h @@ -33,6 +33,7 @@ class UIDisplayServiceClass virtual ~UIDisplayServiceClass(void) = default; virtual void Set_Stretch_Movies(bool on) = 0; virtual void Set_System_Cursor(bool on) = 0; + virtual void Set_UI_Scale(int scale) = 0; }; @@ -42,6 +43,10 @@ struct UIDisplayState int Selected = -1; bool StretchMovies = false; bool SystemCursor = false; + + // 0 for the automatic interface size, otherwise 1 to 4. + int UIScale = 0; + std::string UIScaleLabel; }; diff --git a/code/ui/screens/display/uidisplaydlg.cpp b/code/ui/screens/display/uidisplaydlg.cpp index 1c674d9c9..ce6a14bee 100644 --- a/code/ui/screens/display/uidisplaydlg.cpp +++ b/code/ui/screens/display/uidisplaydlg.cpp @@ -46,6 +46,11 @@ class UIDisplayEngineServiceClass : public UIDisplayServiceClass Options.SystemCursor = on; Refresh_Window_Arrow(); } + + virtual void Set_UI_Scale(int scale) override + { + Options.UIScale = scale; + } }; UIDisplayEngineServiceClass _Service; @@ -64,6 +69,7 @@ void UI_Display_State(UIDisplayState & state) state = UIDisplayState(); state.StretchMovies = Options.StretchMovies; state.SystemCursor = Options.SystemCursor; + state.UIScale = Options.UIScale; int * modes = EnumDisplayModes(MIN_WIDTH, MIN_HEIGHT, MAX_WIDTH, MAX_HEIGHT); if (modes == NULL) { diff --git a/code/ui/uienginehost.cpp b/code/ui/uienginehost.cpp index c9c75af46..a114caf47 100644 --- a/code/ui/uienginehost.cpp +++ b/code/ui/uienginehost.cpp @@ -103,7 +103,7 @@ class UIEngineHostClass : public UIShellHostClass virtual float Interface_Scale(void) const override { - return((float)UI_Scale()); + return((float)UI_Dialog_Scale()); } virtual int Art_Magnification(void) const override diff --git a/code/uiscale.cpp b/code/uiscale.cpp index 4e902f856..112ab8b94 100644 --- a/code/uiscale.cpp +++ b/code/uiscale.cpp @@ -11,11 +11,13 @@ #include "uiscale.h" +#include "_map.h" #include "_rect.h" #include "_surface.h" #include "_ui.h" #include "globals.h" #include "goptions.h" +#include "init.h" #include "surface.h" #include "ui/uishell.h" #include "uiscalelayout.h" @@ -54,11 +56,7 @@ int UI_Height(void) /// Receives the size of the sidebar surface, in interface pixels. void UI_Scale_Layout(Rect const & visible, Rect & battlefield, Rect & composite, Rect & sidebar) { - int const previous = _Scale; _Scale = Debug_Map ? (int)UI_SCALE_MIN : UI_Scale_For(visible.Width, visible.Height, Options.UIScale); - if (_Scale != previous) { - UIShell.On_Video_Change(); - } battlefield = UI_Scale_Battlefield_Rect(visible.Width, visible.Height, _Scale); if (!Options.IsSidebarOnRight && !Debug_Map) { @@ -70,6 +68,53 @@ void UI_Scale_Layout(Rect const & visible, Rect & battlefield, Rect & composite, } +/// +/// Lays the surfaces out again when the chosen interface scale differs from the current one, +/// as after the player changes it in the display options. Call it before a scenario is read. +/// +void UI_Scale_Refit(void) +{ + int const wanted = Debug_Map ? (int)UI_SCALE_MIN : UI_Scale_For(VisibleRect.Width, VisibleRect.Height, Options.UIScale); + if (wanted == _Scale) { + return; + } + + Rect battlefield; + Rect composite; + Rect sidebar; + UI_Scale_Layout(VisibleRect, battlefield, composite, sidebar); + + Allocate_Surfaces(VisibleRect, composite, composite, sidebar); + LogicalSurface = HiddenSurface; + Map.Set_View_Dimensions(battlefield); +} + + +/// +/// The scale the game's dialogs are drawn at: the interface scale during a scenario, and 1x +/// elsewhere, where the menus sit over artwork that keeps its size. +/// +int UI_Dialog_Scale(void) +{ + return(ScenarioActive ? _Scale : (int)UI_SCALE_MIN); +} + + +/// +/// Tells the dialog layer when the dialog scale has changed, as when a scenario starts or ends. +/// +void UI_Scale_Sync_Dialogs(void) +{ + static int _shown = UI_SCALE_MIN; + + int const scale = UI_Dialog_Scale(); + if (scale != _shown) { + _shown = scale; + UIShell.On_Video_Change(); + } +} + + /// /// Copies part of the sidebar surface onto the visible surface, enlarged by the interface scale. /// diff --git a/code/uiscale.h b/code/uiscale.h index e37b54c4a..bc43220d4 100644 --- a/code/uiscale.h +++ b/code/uiscale.h @@ -20,4 +20,9 @@ int UI_Scale(void); int UI_Height(void); void UI_Scale_Layout(Rect const & visible, Rect & battlefield, Rect & composite, Rect & sidebar); +void UI_Scale_Refit(void); + +int UI_Dialog_Scale(void); +void UI_Scale_Sync_Dialogs(void); + void UI_Scale_Show_Sidebar(Rect const & source); diff --git a/manual/content/keys/uiscale--client-settings.md b/manual/content/keys/uiscale--client-settings.md index 75f8cfcf5..f2824db4e 100644 --- a/manual/content/keys/uiscale--client-settings.md +++ b/manual/content/keys/uiscale--client-settings.md @@ -15,4 +15,6 @@ At 3840 by 2160 the automatic size is `3`. The sidebar is then 504 pixels wide, The size is lowered one step at a time until the interface keeps at least 640 by 540 of its own pixels and the battlefield beside it keeps at least 472 by 524 pixels. A chosen `3` at 1920 by 1080 therefore draws the interface at `2`. -The size is chosen when the game starts, when the resolution changes, and when a saved game is loaded. Chat and game messages, and tooltips over the battlefield, stay at their artwork size. The animated main menu from `GMENU.MIX` keeps its artwork size. +The Display screen of the main menu's Options sets the size: each press of the Interface size button steps it from Automatic through 1x to 4x and back, and OK keeps the choice. A new size takes effect when the next mission or skirmish starts or a saved game is loaded; the in-game Options menu does not offer it. + +Chat and game messages are drawn at the same size as the interface. Tooltips over the battlefield are drawn at artwork size. The main menu and its screens keep their artwork size at every setting. diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index e75eb2393..f34da67bd 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -921,6 +921,7 @@ class RecordingDisplayServiceClass : public UIDisplayServiceClass virtual void Set_Stretch_Movies(bool on) override { Calls.push_back(on ? "stretch on" : "stretch off"); } virtual void Set_System_Cursor(bool on) override { Calls.push_back(on ? "system cursor on" : "system cursor off"); } + virtual void Set_UI_Scale(int scale) override { Calls.push_back("ui scale " + std::to_string(scale)); } }; @@ -953,7 +954,7 @@ void Test_Display_Presenter(void) Check(presenter.State.Selected == 2 && presenter.State.StretchMovies && presenter.State.SystemCursor && service.Calls.empty() && !presenter.Picked.has_value(), "display edits are held until the player accepts"); Drive(presenter, "ok"); - Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED && service.Calls == std::vector{ "stretch on", "system cursor on" }, "accepting the display options applies both switches"); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED && service.Calls == std::vector{ "stretch on", "system cursor on", "ui scale 0" }, "accepting the display options applies both switches and the interface size"); Check(presenter.Picked.has_value() && presenter.Picked->Width == 1920 && presenter.Picked->Height == 1080, "accepting with a new row hands the caller that mode to try"); } @@ -963,7 +964,23 @@ void Test_Display_Presenter(void) Drive(presenter, "select", 2); Drive(presenter, "select", 1); Drive(presenter, "ok"); - Check(presenter.Result.has_value() && !presenter.Picked.has_value() && service.Calls == std::vector{ "stretch off", "system cursor off" }, "accepting on the starting row applies the switches and tries no mode"); + Check(presenter.Result.has_value() && !presenter.Picked.has_value() && service.Calls == std::vector{ "stretch off", "system cursor off", "ui scale 0" }, "accepting on the starting row applies the switches and tries no mode"); + } + + { + RecordingDisplayServiceClass service; + UIDisplayPresenterClass presenter(service, Display_Fixture()); + Check(presenter.State.UIScaleLabel == "Interface size: Automatic", "the interface size starts at its saved choice"); + Drive(presenter, "uiscale"); + Drive(presenter, "uiscale"); + Check(presenter.State.UIScale == 2 && presenter.State.UIScaleLabel == "Interface size: 2x" && service.Calls.empty(), "each press steps the interface size and holds it until the player accepts"); + Drive(presenter, "uiscale"); + Drive(presenter, "uiscale"); + Drive(presenter, "uiscale"); + Check(presenter.State.UIScale == 0, "after 4x the interface size returns to automatic"); + Drive(presenter, "uiscale"); + Drive(presenter, "ok"); + Check(service.Calls.back() == "ui scale 1", "accepting applies the chosen interface size"); } { @@ -2423,7 +2440,7 @@ void Test_Display_Screen(Rml::Context & context, CountingSystemInterfaceClass & if (ok != nullptr) { Click(context, ok); presenter.Drain(); - Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED && service.Calls == std::vector{ "stretch on", "system cursor on" }, "OK applies both switches"); + Check(presenter.Result.has_value() && *presenter.Result == UI_RESULT_ACCEPTED && service.Calls == std::vector{ "stretch on", "system cursor on", "ui scale 0" }, "OK applies both switches and the interface size"); Check(presenter.Picked.has_value() && presenter.Picked->Width == 1920 && presenter.Picked->Height == 1080, "OK hands the caller the picked mode"); } @@ -2712,6 +2729,8 @@ void Test_Keyboard_Navigation(Rml::Context & context, CountingSystemInterfaceCla Press(context, Rml::Input::KI_TAB); Check(context.GetFocusElement() == cursor, "the pointer switch follows the movie switch"); Press(context, Rml::Input::KI_TAB); + Check(context.GetFocusElement() == document->GetElementById("uiscale"), "the interface size follows the pointer switch"); + Press(context, Rml::Input::KI_TAB); Check(context.GetFocusElement() == ok, "OK follows the switches"); Press(context, Rml::Input::KI_TAB); Check(context.GetFocusElement() == cancel, "and Cancel follows OK"); diff --git a/ui/display.rcss b/ui/display.rcss index 06593d216..6d40bb6d0 100644 --- a/ui/display.rcss +++ b/ui/display.rcss @@ -11,9 +11,9 @@ #reveal { width: 344dp; - height: 342dp; + height: 380dp; top: 50%; - margin-top: -171dp; + margin-top: -190dp; } #content @@ -55,6 +55,13 @@ margin-top: 3dp; } +#uiscale +{ + display: block; + width: 278dp; + margin-top: 6dp; +} + #buttons { display: flex; diff --git a/ui/display.rml b/ui/display.rml index 1bf2cb786..a3f69b5f3 100644 --- a/ui/display.rml +++ b/ui/display.rml @@ -21,6 +21,7 @@ +
From 8ca58dfc706522c6f2be72d97211ea032ef76c3c Mon Sep 17 00:00:00 2001 From: equalchance <182705210+equalchance@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:21:21 +0300 Subject: [PATCH 09/14] Fix the scaled layout at start-up and copy enlargements by pixel --- code/battlezoom.cpp | 31 ++++++++++----------- code/display.cpp | 7 ++--- code/uiscale.cpp | 68 +++++++++++++++++++++++++++++++++++++++++++-- code/uiscale.h | 5 ++++ 4 files changed, 87 insertions(+), 24 deletions(-) diff --git a/code/battlezoom.cpp b/code/battlezoom.cpp index b95a5e994..a23b98861 100644 --- a/code/battlezoom.cpp +++ b/code/battlezoom.cpp @@ -360,15 +360,16 @@ bool Battle_Zoom_Present(Surface & surface) int const scale = UI_Scale(); - Rect shown; - Rect dest; + // Whole-number enlargements are copied pixel by pixel. A GDI stretch of the tab strip wiped + // the battlefield already copied to the visible surface at some sizes. if (TacticalMap->ZoomFactor != 1.0) { - // A trigger zoom magnifies the middle of the view to fill the battlefield. + // A trigger zoom magnifies the middle of the view to fill the battlefield, by a factor + // the rules may set to a fraction, so GDI stretches it. int const width = (int)(TacticalRect.Width / TacticalMap->ZoomFactor); int const height = (int)(TacticalRect.Height / TacticalMap->ZoomFactor); - shown = Rect(TacticalRect.X + (TacticalRect.Width - width) / 2, TacticalRect.Y + (TacticalRect.Height - height) / 2, width, height); - dest = BattlefieldRect; + Rect const shown(TacticalRect.X + (TacticalRect.Width - width) / 2, TacticalRect.Y + (TacticalRect.Height - height) / 2, width, height); + VisibleSurface->Blit_From(BattlefieldRect, surface, shown, false, true); } else { @@ -376,26 +377,24 @@ bool Battle_Zoom_Present(Surface & surface) int const zoom = _State.Factor(); Rect const screen = Battle_Zoom_Screen_Rect(BattlefieldRect, zoom); Rect const source = TacticalRect - Point2D(Map.ScreenX / zoom, Map.ScreenY / zoom); - shown = Intersect(source, TacticalRect); - dest = Rect(screen.X + (shown.X - source.X) * zoom, screen.Y + (shown.Y - source.Y) * zoom, shown.Width * zoom, shown.Height * zoom); - } - - if (!(dest == BattlefieldRect)) { - VisibleSurface->Fill_Rect(BattlefieldRect, 0); - } + Rect const shown = Intersect(source, TacticalRect); + Rect const dest(screen.X + (shown.X - source.X) * zoom, screen.Y + (shown.Y - source.Y) * zoom, shown.Width * zoom, shown.Height * zoom); - if (shown.Is_Valid()) { - VisibleSurface->Blit_From(dest, surface, shown, false, true); + if (!(dest == BattlefieldRect)) { + VisibleSurface->Fill_Rect(BattlefieldRect, 0); + } + if (shown.Is_Valid()) { + UI_Scale_Copy(*VisibleSurface, dest.Top_Left(), surface, shown, zoom); + } } - // The tab strip is copied after the battlefield: copied first, it left the battlefield black. int const strip_width = std::min(surface.Get_Width(), BattlefieldRect.X + BattlefieldRect.Width); Rect const strip(0, 0, strip_width / scale, BattlefieldRect.Y / scale); if (strip.Is_Valid()) { if (strip.Width * scale != strip_width) { VisibleSurface->Fill_Rect(Rect(0, 0, strip_width, BattlefieldRect.Y), 0); } - VisibleSurface->Blit_From(Rect(0, 0, strip.Width * scale, strip.Height * scale), surface, strip, false, true); + UI_Scale_Copy(*VisibleSurface, Point2D(0, 0), surface, strip, scale); } if (scale > UI_SCALE_MIN) { diff --git a/code/display.cpp b/code/display.cpp index d0ab97c7a..27d6de5f0 100644 --- a/code/display.cpp +++ b/code/display.cpp @@ -252,11 +252,8 @@ void DisplayClass::One_Time(void) PlacementShapes = MFCD::Retrieve("PLACE.SHP"); ShadowShapes = MFCD::Retrieve("SHADOW.SHP"); - Rect rect; - Rect composite; - Rect sidebar; - UI_Scale_Layout(VisibleRect, rect, composite, sidebar); - Set_View_Dimensions(rect); + // The surfaces were allocated before the settings were read, so this keeps their scale. + Set_View_Dimensions(UI_Scale_Battlefield(VisibleRect)); } diff --git a/code/uiscale.cpp b/code/uiscale.cpp index 112ab8b94..d3c5b6a94 100644 --- a/code/uiscale.cpp +++ b/code/uiscale.cpp @@ -48,7 +48,7 @@ int UI_Height(void) /// /// Chooses the interface scale for a frame and works out the surface sizes for it. The map -/// editor always uses 1x. +/// editor always uses 1x. Call it only where the surfaces are then allocated at these sizes. /// /// The whole frame. /// Receives the frame area the battlefield fills. @@ -68,6 +68,19 @@ void UI_Scale_Layout(Rect const & visible, Rect & battlefield, Rect & composite, } +/// +/// The frame area the battlefield fills at the scale the surfaces were allocated for. +/// +Rect UI_Scale_Battlefield(Rect const & visible) +{ + Rect battlefield = UI_Scale_Battlefield_Rect(visible.Width, visible.Height, _Scale); + if (!Options.IsSidebarOnRight && !Debug_Map) { + battlefield.X = UI_SCALE_SIDEBAR_WIDTH * _Scale; + } + return(battlefield); +} + + /// /// Lays the surfaces out again when the chosen interface scale differs from the current one, /// as after the player changes it in the display options. Call it before a scenario is read. @@ -115,6 +128,56 @@ void UI_Scale_Sync_Dialogs(void) } +/// +/// Copies part of a 16-bit surface onto another, drawing each source pixel as a square of +/// scale by scale pixels. The part that would land outside the destination is left out. +/// +/// The surface drawn on. +/// Where the top left source pixel lands. +/// The surface copied from. +/// The part of the source to copy. +/// How many times larger to draw it, at least 1. +void UI_Scale_Copy(Surface & dest, Point2D const & at, Surface const & source, Rect const & from, int scale) +{ + Rect const part = Intersect(source.Get_Rect(), from); + if (!part.Is_Valid() || scale < 1) { + return; + } + + unsigned short const * in = (unsigned short const *)source.Lock(part.Top_Left()); + unsigned short * out = (unsigned short *)dest.Lock(); + if (in != nullptr && out != nullptr) { + int const in_pitch = source.Stride() / 2; + int const out_pitch = dest.Stride() / 2; + int const left = at.X + (part.X - from.X) * scale; + int const top = at.Y + (part.Y - from.Y) * scale; + + for (int y = 0; y < part.Height * scale; y++) { + int const row = top + y; + if (row < 0 || row >= dest.Get_Height()) { + continue; + } + + unsigned short const * line = in + (y / scale) * in_pitch; + unsigned short * target = out + row * out_pitch; + for (int x = 0; x < part.Width * scale; x++) { + int const column = left + x; + if (column >= 0 && column < dest.Get_Width()) { + target[column] = line[x / scale]; + } + } + } + } + + if (out != nullptr) { + dest.Unlock(); + } + if (in != nullptr) { + source.Unlock(); + } +} + + /// /// Copies part of the sidebar surface onto the visible surface, enlarged by the interface scale. /// @@ -122,7 +185,6 @@ void UI_Scale_Sync_Dialogs(void) void UI_Scale_Show_Sidebar(Rect const & source) { int const left = Options.IsSidebarOnRight ? BattlefieldRect.X + BattlefieldRect.Width : 0; - Rect const dest(left + source.X * _Scale, source.Y * _Scale, source.Width * _Scale, source.Height * _Scale); - VisibleSurface->Blit_From(dest, *SidebarSurface, source); + UI_Scale_Copy(*VisibleSurface, Point2D(left + source.X * _Scale, source.Y * _Scale), *SidebarSurface, source, _Scale); } diff --git a/code/uiscale.h b/code/uiscale.h index bc43220d4..8ccc3134a 100644 --- a/code/uiscale.h +++ b/code/uiscale.h @@ -13,16 +13,21 @@ #pragma once +#include "point.h" #include "rect.h" +class Surface; + int UI_Scale(void); int UI_Height(void); void UI_Scale_Layout(Rect const & visible, Rect & battlefield, Rect & composite, Rect & sidebar); +Rect UI_Scale_Battlefield(Rect const & visible); void UI_Scale_Refit(void); int UI_Dialog_Scale(void); void UI_Scale_Sync_Dialogs(void); +void UI_Scale_Copy(Surface & dest, Point2D const & at, Surface const & source, Rect const & from, int scale); void UI_Scale_Show_Sidebar(Rect const & source); From 5404e7e46adbf026ababc1400c079baf52458b06 Mon Sep 17 00:00:00 2001 From: equalchance <182705210+equalchance@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:21:21 +0300 Subject: [PATCH 10/14] Offer the interface size as a slider capped by the resolution --- code/ui/screens/display/uidisplay.cpp | 40 ++++++++++++++++++------ code/ui/screens/display/uidisplay.h | 10 +++++- code/ui/screens/display/uidisplaydlg.cpp | 2 ++ tests/uishell/CMakeLists.txt | 1 + tests/uishell/uishell.cpp | 21 +++++++------ ui/display.rcss | 25 +++++++++++++-- ui/display.rml | 2 +- 7 files changed, 77 insertions(+), 24 deletions(-) diff --git a/code/ui/screens/display/uidisplay.cpp b/code/ui/screens/display/uidisplay.cpp index 22de3c7c2..653420248 100644 --- a/code/ui/screens/display/uidisplay.cpp +++ b/code/ui/screens/display/uidisplay.cpp @@ -10,25 +10,40 @@ #include "ui/screens/display/uidisplay.h" #include "ui/rml/rmlview.h" +#include "uiscalelayout.h" #include #include #include -static std::string UI_Scale_Label(int scale) -{ - return(scale <= 0 ? std::string("Interface size: Automatic") : "Interface size: " + std::to_string(scale) + "x"); -} - - UIDisplayPresenterClass::UIDisplayPresenterClass(UIDisplayServiceClass & service, UIDisplayState state) : State(std::move(state)), Service(service), Initial(State.Selected) { - State.UIScale = std::clamp(State.UIScale, 0, 4); - State.UIScaleLabel = UI_Scale_Label(State.UIScale); + Fit_UI_Scale(); +} + + +// Holds the interface size within what the selected resolution fits and names it. +void UIDisplayPresenterClass::Fit_UI_Scale(void) +{ + int width = State.ScreenWidth; + int height = State.ScreenHeight; + if (State.Selected >= 0 && State.Selected < (int)State.Modes.size()) { + width = State.Modes[State.Selected].Width; + height = State.Modes[State.Selected].Height; + } + + State.UIScaleMax = UI_Scale_For(width, height, UI_SCALE_MAX); + State.UIScale = std::clamp(State.UIScale, 0, State.UIScaleMax); + + if (State.UIScale == 0) { + State.UIScaleLabel = "Auto (" + std::to_string(UI_Scale_For(width, height, 0)) + "x)"; + } else { + State.UIScaleLabel = std::to_string(State.UIScale) + "x"; + } } @@ -36,6 +51,7 @@ void UIDisplayPresenterClass::Execute(UIIntent const & intent) { if (intent.Name == "select") { State.Selected = (intent.Value >= 0 && intent.Value < (int)State.Modes.size()) ? intent.Value : -1; + Fit_UI_Scale(); } else if (intent.Name == "stretch") { State.StretchMovies = (intent.Value != 0); @@ -44,8 +60,8 @@ void UIDisplayPresenterClass::Execute(UIIntent const & intent) State.SystemCursor = (intent.Value != 0); } else if (intent.Name == "uiscale") { - State.UIScale = (State.UIScale + 1) % 5; - State.UIScaleLabel = UI_Scale_Label(State.UIScale); + State.UIScale = intent.Value; + Fit_UI_Scale(); } else if (intent.Name == "ok") { Service.Set_Stretch_Movies(State.StretchMovies); @@ -119,6 +135,8 @@ class UIDisplayViewClass : public UIRmlViewClass Model.DirtyVariable("selected"); Model.DirtyVariable("stretch"); Model.DirtyVariable("systemcursor"); + Model.DirtyVariable("uiscale"); + Model.DirtyVariable("uiscalemax"); Model.DirtyVariable("uiscalelabel"); } @@ -139,6 +157,8 @@ class UIDisplayViewClass : public UIRmlViewClass && model.Bind("selected", &state.Selected) && model.Bind("stretch", &state.StretchMovies) && model.Bind("systemcursor", &state.SystemCursor) + && model.Bind("uiscale", &state.UIScale) + && model.Bind("uiscalemax", &state.UIScaleMax) && model.Bind("uiscalelabel", &state.UIScaleLabel)); } diff --git a/code/ui/screens/display/uidisplay.h b/code/ui/screens/display/uidisplay.h index d9f5ed921..7208551e8 100644 --- a/code/ui/screens/display/uidisplay.h +++ b/code/ui/screens/display/uidisplay.h @@ -44,8 +44,14 @@ struct UIDisplayState bool StretchMovies = false; bool SystemCursor = false; - // 0 for the automatic interface size, otherwise 1 to 4. + // The resolution in use, which applies while no row is selected. + int ScreenWidth = 0; + int ScreenHeight = 0; + + // 0 for the automatic interface size, otherwise 1 up to the largest size the selected + // resolution fits. int UIScale = 0; + int UIScaleMax = 1; std::string UIScaleLabel; }; @@ -61,6 +67,8 @@ class UIDisplayPresenterClass : public UIPresenterClass std::optional Picked; private: + void Fit_UI_Scale(void); + UIDisplayServiceClass & Service; int Initial; }; diff --git a/code/ui/screens/display/uidisplaydlg.cpp b/code/ui/screens/display/uidisplaydlg.cpp index ce6a14bee..0b721004e 100644 --- a/code/ui/screens/display/uidisplaydlg.cpp +++ b/code/ui/screens/display/uidisplaydlg.cpp @@ -70,6 +70,8 @@ void UI_Display_State(UIDisplayState & state) state.StretchMovies = Options.StretchMovies; state.SystemCursor = Options.SystemCursor; state.UIScale = Options.UIScale; + state.ScreenWidth = Options.ScreenWidth; + state.ScreenHeight = Options.ScreenHeight; int * modes = EnumDisplayModes(MIN_WIDTH, MIN_HEIGHT, MAX_WIDTH, MAX_HEIGHT); if (modes == NULL) { diff --git a/tests/uishell/CMakeLists.txt b/tests/uishell/CMakeLists.txt index 8c2252a93..614412116 100644 --- a/tests/uishell/CMakeLists.txt +++ b/tests/uishell/CMakeLists.txt @@ -28,6 +28,7 @@ opents_add_test(UIShell ui/screens/abort/uiabort.cpp ui/screens/campaign/uicampaign.cpp ui/screens/display/uidisplay.cpp + uiscalelayout.cpp ui/screens/gamectrl/uigamectrl.cpp ui/screens/gameopt/uigameopt.cpp ui/screens/keyboard/uikeyboard.cpp diff --git a/tests/uishell/uishell.cpp b/tests/uishell/uishell.cpp index f34da67bd..cccbb5194 100644 --- a/tests/uishell/uishell.cpp +++ b/tests/uishell/uishell.cpp @@ -970,17 +970,18 @@ void Test_Display_Presenter(void) { RecordingDisplayServiceClass service; UIDisplayPresenterClass presenter(service, Display_Fixture()); - Check(presenter.State.UIScaleLabel == "Interface size: Automatic", "the interface size starts at its saved choice"); - Drive(presenter, "uiscale"); - Drive(presenter, "uiscale"); - Check(presenter.State.UIScale == 2 && presenter.State.UIScaleLabel == "Interface size: 2x" && service.Calls.empty(), "each press steps the interface size and holds it until the player accepts"); - Drive(presenter, "uiscale"); - Drive(presenter, "uiscale"); - Drive(presenter, "uiscale"); - Check(presenter.State.UIScale == 0, "after 4x the interface size returns to automatic"); - Drive(presenter, "uiscale"); + Check(presenter.State.UIScale == 0 && presenter.State.UIScaleLabel == "Auto (1x)" && presenter.State.UIScaleMax == 1, "the interface size starts automatic and names the size it picks"); + Drive(presenter, "uiscale", 3); + Check(presenter.State.UIScale == 1, "a size the selected resolution cannot fit is held at the largest that fits"); + Drive(presenter, "select", 2); + Drive(presenter, "uiscale", 2); + Check(presenter.State.UIScaleMax == 2 && presenter.State.UIScale == 2 && presenter.State.UIScaleLabel == "2x" && service.Calls.empty(), "a larger resolution offers a larger size, held until the player accepts"); + Drive(presenter, "select", 1); + Check(presenter.State.UIScale == 1, "picking a smaller resolution lowers the size to one it fits"); + Drive(presenter, "select", 2); + Drive(presenter, "uiscale", 2); Drive(presenter, "ok"); - Check(service.Calls.back() == "ui scale 1", "accepting applies the chosen interface size"); + Check(service.Calls.back() == "ui scale 2", "accepting applies the chosen interface size"); } { diff --git a/ui/display.rcss b/ui/display.rcss index 6d40bb6d0..da782e2fc 100644 --- a/ui/display.rcss +++ b/ui/display.rcss @@ -55,11 +55,32 @@ margin-top: 3dp; } -#uiscale +#row-uiscale { display: block; - width: 278dp; + height: 25dp; margin-top: 6dp; + white-space: nowrap; +} + +#row-uiscale > .caption +{ + width: 86dp; + margin-right: 6dp; + line-height: 17dp; +} + +#row-uiscale > input[type='range'] +{ + width: 118dp; + height: 25dp; +} + +#row-uiscale > .value +{ + width: 62dp; + margin-left: 6dp; + line-height: 17dp; } #buttons diff --git a/ui/display.rml b/ui/display.rml index a3f69b5f3..aeb36a22d 100644 --- a/ui/display.rml +++ b/ui/display.rml @@ -21,7 +21,7 @@
- +
Interface Size{{uiscalelabel}}
From 28ee1bb0debc614722d0dbee41f99112f36cc1f3 Mon Sep 17 00:00:00 2001 From: equalchance <182705210+equalchance@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:46:15 +0300 Subject: [PATCH 11/14] Start missions zoomed to the interface scale --- code/battlezoom.cpp | 23 ++++++++++++++++++++++ code/battlezoom.h | 1 + code/battlezoomstate.cpp | 9 +++++++++ code/battlezoomstate.hh | 1 + code/init.cpp | 2 ++ code/saveload.cpp | 2 ++ code/scenario.cpp | 1 + code/uiscale.cpp | 19 ++++++++++++++++-- code/uiscale.h | 1 + manual/content/systems/battlefield-zoom.md | 2 +- tests/battlezoom/battlezoomcontract.cpp | 8 ++++++++ 11 files changed, 66 insertions(+), 3 deletions(-) diff --git a/code/battlezoom.cpp b/code/battlezoom.cpp index a23b98861..1ff6b692a 100644 --- a/code/battlezoom.cpp +++ b/code/battlezoom.cpp @@ -133,6 +133,29 @@ void Battle_Zoom_Reset(void) } +/// +/// Sets the zoom a mission or a loaded game starts at: the interface scale, so the battlefield +/// is shown at the same size as the sidebar, lowered to the largest factor the battlefield +/// allows. It applies even while the mission locks the player's input. The map editor and a +/// trigger zoom keep 1x. +/// +void Battle_Zoom_Start(void) +{ + if (Debug_Map || TacticalMap == NULL || TacticalMap->ZoomFactor != 1.0) { + return; + } + + int const before = _State.Factor(); + _State.Start_At(BattlefieldRect, UI_Scale()); + if (_State.Factor() != before) { + _Applying = true; + Map.Set_View_Dimensions(BattlefieldRect); + _Applying = false; + Map.Flag_To_Redraw(GS_REDRAW_ALL); + } +} + + /// /// Applies a zoom change asked for by the wheel. The change waits while a key press is pending /// or a mouse button is held, and is dropped while the player cannot zoom. The map position at diff --git a/code/battlezoom.h b/code/battlezoom.h index bc671feb4..bb3ca1059 100644 --- a/code/battlezoom.h +++ b/code/battlezoom.h @@ -26,6 +26,7 @@ int Battle_Zoom_Factor(void); Rect Battle_Zoom_Fit_View(Rect const & battlefield); bool Battle_Zoom_Applying(void); void Battle_Zoom_Reset(void); +void Battle_Zoom_Start(void); void Battle_Zoom_AI(bool input_pending); bool Battle_Zoom_Wheel(float wheel, Point2D const & frame); diff --git a/code/battlezoomstate.cpp b/code/battlezoomstate.cpp index 8f1cbc0d7..d360358de 100644 --- a/code/battlezoomstate.cpp +++ b/code/battlezoomstate.cpp @@ -81,6 +81,15 @@ void BattleZoomStateClass::Reset(void) } +// Sets the factor outright, lowered to the nearest one the battlefield allows, and drops any +// waiting wheel change. +void BattleZoomStateClass::Start_At(Rect const & battlefield, int zoom) +{ + Zoom = Battle_Zoom_Nearest_Allowed(battlefield, zoom); + Cancel_Pending(); +} + + unsigned BattleZoomStateClass::Button_Mask(WindowMouseButton button) { return(button >= WINDOW_BUTTON_LEFT && button <= WINDOW_BUTTON_X2 ? 1u << button : 0u); diff --git a/code/battlezoomstate.hh b/code/battlezoomstate.hh index b4ed79282..91837cb31 100644 --- a/code/battlezoomstate.hh +++ b/code/battlezoomstate.hh @@ -22,6 +22,7 @@ public: bool Apply_Pending(Rect const & battlefield, bool can_zoom, bool input_pending, Point2D & frame); void Cancel_Pending(void); void Reset(void); + void Start_At(Rect const & battlefield, int zoom); static unsigned Button_Mask(WindowMouseButton button); void Mouse_Button(WindowMouseButton button, bool down, Rect const & battlefield, Point2D const & frame); diff --git a/code/init.cpp b/code/init.cpp index 422f673e6..3f2d8a194 100644 --- a/code/init.cpp +++ b/code/init.cpp @@ -186,6 +186,7 @@ #include "ui/screens/version/uiversion.h" #include "ui/uienginehost.h" #include "uicontrol.h" +#include "uiscale.h" #include "unit.h" #include "unittype.h" #include "vein.h" @@ -963,6 +964,7 @@ bool Select_Game(bool ) } if ((selection == SEL_NONE) && !Debug_ForceScenario) { + UI_Scale_End_Scenario(); if (Get_New_Menu()->MixFile) { selection = New_Main_Menu(); } else { diff --git a/code/saveload.cpp b/code/saveload.cpp index f5f01478b..1d242ea83 100644 --- a/code/saveload.cpp +++ b/code/saveload.cpp @@ -62,6 +62,7 @@ #include "alphashp.h" #include "anim.h" #include "animtype.h" +#include "battlezoom.h" #include "blight.h" #include "building.h" #include "builtype.h" @@ -1190,6 +1191,7 @@ bool Load_Game(const char *file_name) Map.Complete_Radar_Refresh(); ScenarioActive = true; TacticalActive = true; + Battle_Zoom_Start(); Sync_Recorder_Arm(); Sync_Report_Reset(); SaveManager.Autosave.Schedule(Frame); diff --git a/code/scenario.cpp b/code/scenario.cpp index 6d10f5aa0..55c812163 100644 --- a/code/scenario.cpp +++ b/code/scenario.cpp @@ -468,6 +468,7 @@ bool Start_Scenario(char const * name, bool briefing, CampaignType campaign) ScenarioActive = true; TacticalActive = true; + Battle_Zoom_Start(); return(true); } diff --git a/code/uiscale.cpp b/code/uiscale.cpp index d3c5b6a94..59e22f215 100644 --- a/code/uiscale.cpp +++ b/code/uiscale.cpp @@ -25,6 +25,10 @@ static int _Scale = UI_SCALE_MIN; +// Set from the start of a scenario, so its briefing is drawn at the interface scale, until the +// main menu shows again. +static bool _Starting = false; + /// /// Fetches the interface scale of the current layout. @@ -87,6 +91,8 @@ Rect UI_Scale_Battlefield(Rect const & visible) /// void UI_Scale_Refit(void) { + _Starting = true; + int const wanted = Debug_Map ? (int)UI_SCALE_MIN : UI_Scale_For(VisibleRect.Width, VisibleRect.Height, Options.UIScale); if (wanted == _Scale) { return; @@ -104,12 +110,12 @@ void UI_Scale_Refit(void) /// -/// The scale the game's dialogs are drawn at: the interface scale during a scenario, and 1x +/// The scale the game's dialogs are drawn at: the interface scale while a scenario starts or runs, and 1x /// elsewhere, where the menus sit over artwork that keeps its size. /// int UI_Dialog_Scale(void) { - return(ScenarioActive ? _Scale : (int)UI_SCALE_MIN); + return(ScenarioActive || _Starting ? _Scale : (int)UI_SCALE_MIN); } @@ -188,3 +194,12 @@ void UI_Scale_Show_Sidebar(Rect const & source) UI_Scale_Copy(*VisibleSurface, Point2D(left + source.X * _Scale, source.Y * _Scale), *SidebarSurface, source, _Scale); } + + +/// +/// Returns the game's dialogs to 1x, as the main menu shows again after a scenario. +/// +void UI_Scale_End_Scenario(void) +{ + _Starting = false; +} diff --git a/code/uiscale.h b/code/uiscale.h index 8ccc3134a..9e19b2401 100644 --- a/code/uiscale.h +++ b/code/uiscale.h @@ -28,6 +28,7 @@ void UI_Scale_Refit(void); int UI_Dialog_Scale(void); void UI_Scale_Sync_Dialogs(void); +void UI_Scale_End_Scenario(void); void UI_Scale_Copy(Surface & dest, Point2D const & at, Surface const & source, Rect const & from, int scale); void UI_Scale_Show_Sidebar(Rect const & source); diff --git a/manual/content/systems/battlefield-zoom.md b/manual/content/systems/battlefield-zoom.md index cfb75178a..806e1c928 100644 --- a/manual/content/systems/battlefield-zoom.md +++ b/manual/content/systems/battlefield-zoom.md @@ -37,4 +37,4 @@ A drag that starts on the battlefield stays on it: a selection box or a coast sc ## Persistence -The factor belongs to the local display. It is not stored in saved games or sent to other players. Starting or loading a mission returns it to 1x, and a game saved while zoomed loads with the view centred where the zoomed view was. The game resolution can only be changed from the main menu, where the factor is always 1x. +The factor belongs to the local display. It is not stored in saved games or sent to other players. A mission or a loaded game starts at the [`UIScale`](/keys/uiscale/) size, lowered to the largest factor available, so at 3840 by 2160 with the automatic size it starts at 3x. This happens even when the mission opens with the player's input locked. A game saved while zoomed loads with the view centred where the zoomed view was. The game resolution can only be changed from the main menu, where the factor is always 1x. diff --git a/tests/battlezoom/battlezoomcontract.cpp b/tests/battlezoom/battlezoomcontract.cpp index c99de026d..f24a7edf5 100644 --- a/tests/battlezoom/battlezoomcontract.cpp +++ b/tests/battlezoom/battlezoomcontract.cpp @@ -217,6 +217,14 @@ int main() Check(samples_match, "odd-dimension frame and view positions round-trip at each supported factor"); } + { + BattleZoomStateClass state; + state.Start_At(Rect(0, 48, 3336, 2112), 3); + Check(state.Factor() == 3, "a mission at a 3x interface starts zoomed to 3x"); + state.Start_At(hd, 3); + Check(state.Factor() == 2, "a starting zoom the battlefield cannot show drops to the largest it can"); + } + Check(UI_Scale_Automatic(720) == 1 && UI_Scale_Automatic(1080) == 1 && UI_Scale_Automatic(1440) == 1, "the automatic interface scale is 1x below 2160 rows"); Check(UI_Scale_Automatic(2160) == 3 && UI_Scale_Automatic(4320) == 4, "and 3x at 2160 rows, 4x at 4320"); Check(UI_Scale_For(3840, 2160, 0) == 3 && UI_Scale_For(1920, 1080, 0) == 1 && UI_Scale_For(2560, 1440, 0) == 1, "an automatic scale fits the common frames"); From 4776a0eb29a662257a4fa2c966c8f92277516a14 Mon Sep 17 00:00:00 2001 From: equalchance <182705210+equalchance@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:02:05 +0300 Subject: [PATCH 12/14] Apply the starting zoom before the view is placed --- code/battlezoom.cpp | 24 ++++++++++++++++++++---- code/battlezoom.h | 1 + code/init.cpp | 2 ++ code/scenario.cpp | 2 +- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/code/battlezoom.cpp b/code/battlezoom.cpp index 1ff6b692a..7b681c9a8 100644 --- a/code/battlezoom.cpp +++ b/code/battlezoom.cpp @@ -35,6 +35,10 @@ static BattleZoomStateClass _State; static bool _Applying = false; +// Set once a scenario starts or loads, until the main menu shows again, so the zoom holds while +// the scenario is read and before it counts as active. +static bool _Begun = false; + static bool Can_Zoom(void) { @@ -112,7 +116,7 @@ bool Battle_Zoom_Applying(void) Rect Battle_Zoom_Fit_View(Rect const & battlefield) { BattlefieldRect = battlefield; - return(_State.Fit_View(battlefield, ScenarioActive)); + return(_State.Fit_View(battlefield, ScenarioActive || _Begun)); } @@ -136,15 +140,17 @@ void Battle_Zoom_Reset(void) /// /// Sets the zoom a mission or a loaded game starts at: the interface scale, so the battlefield /// is shown at the same size as the sidebar, lowered to the largest factor the battlefield -/// allows. It applies even while the mission locks the player's input. The map editor and a -/// trigger zoom keep 1x. +/// allows. Call it before the scenario or save is read, so the starting view is placed and +/// clamped for the zoomed view. It applies even while the mission locks the player's input. +/// The map editor and a trigger zoom keep 1x. /// void Battle_Zoom_Start(void) { - if (Debug_Map || TacticalMap == NULL || TacticalMap->ZoomFactor != 1.0) { + if (Debug_Map || (TacticalMap != NULL && TacticalMap->ZoomFactor != 1.0)) { return; } + _Begun = true; int const before = _State.Factor(); _State.Start_At(BattlefieldRect, UI_Scale()); if (_State.Factor() != before) { @@ -431,3 +437,13 @@ bool Battle_Zoom_Present(Surface & surface) return(true); } + + +/// +/// Returns the zoom to 1x as the main menu shows again after a scenario. +/// +void Battle_Zoom_End(void) +{ + _Begun = false; + _State.Reset(); +} diff --git a/code/battlezoom.h b/code/battlezoom.h index bb3ca1059..e46bd62d6 100644 --- a/code/battlezoom.h +++ b/code/battlezoom.h @@ -27,6 +27,7 @@ Rect Battle_Zoom_Fit_View(Rect const & battlefield); bool Battle_Zoom_Applying(void); void Battle_Zoom_Reset(void); void Battle_Zoom_Start(void); +void Battle_Zoom_End(void); void Battle_Zoom_AI(bool input_pending); bool Battle_Zoom_Wheel(float wheel, Point2D const & frame); diff --git a/code/init.cpp b/code/init.cpp index 3f2d8a194..8abef450d 100644 --- a/code/init.cpp +++ b/code/init.cpp @@ -89,6 +89,7 @@ #include "anim.h" #include "audio/audioengine.h" #include "autosave.h" +#include "battlezoom.h" #include "bench.h" #include "blight.h" #include "building.h" @@ -965,6 +966,7 @@ bool Select_Game(bool ) if ((selection == SEL_NONE) && !Debug_ForceScenario) { UI_Scale_End_Scenario(); + Battle_Zoom_End(); if (Get_New_Menu()->MixFile) { selection = New_Main_Menu(); } else { diff --git a/code/scenario.cpp b/code/scenario.cpp index 55c812163..a6e306be0 100644 --- a/code/scenario.cpp +++ b/code/scenario.cpp @@ -1065,7 +1065,7 @@ void Clear_Scenario(void) { Stop_All_Sound_Effects(); Sync_Recorder_Disarm(); - Battle_Zoom_Reset(); + Battle_Zoom_Start(); int index; From 87f1562809007091533dd27741e218681058ffe7 Mon Sep 17 00:00:00 2001 From: equalchance <182705210+equalchance@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:10:25 +0300 Subject: [PATCH 13/14] Trim the change records --- manual/changes/battlefield-zoom.md | 2 -- manual/changes/radar-single-view-outline.md | 2 -- manual/changes/sidebar-ui-scale.md | 4 +--- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/manual/changes/battlefield-zoom.md b/manual/changes/battlefield-zoom.md index 5234401d5..4420a0f85 100644 --- a/manual/changes/battlefield-zoom.md +++ b/manual/changes/battlefield-zoom.md @@ -14,5 +14,3 @@ credit: --- The mouse wheel over the tactical view now zooms the battlefield in whole steps from 1x to 4x, as far as the game resolution allows, while the sidebar and the tab strip keep their size. The wheel used to scroll the sidebar wherever the pointer was; it now does so over the sidebar and the tab strip, or while battlefield zoom is unavailable. - -The zoom follows the battlefield-only zoom of the TI Chilong Edition build of Vinifera. diff --git a/manual/changes/radar-single-view-outline.md b/manual/changes/radar-single-view-outline.md index 8102bf2fa..1e3bf6d61 100644 --- a/manual/changes/radar-single-view-outline.md +++ b/manual/changes/radar-single-view-outline.md @@ -11,5 +11,3 @@ credit: --- The radar used to draw a white frame around the whole map image as well as the white rectangle that marks the tactical view. When the view covered much of a small map, the two looked like two view outlines. The radar now draws only the view rectangle. - -The TI Chilong Edition build of Vinifera removes the same frame. diff --git a/manual/changes/sidebar-ui-scale.md b/manual/changes/sidebar-ui-scale.md index 771dd84ea..3de49188a 100644 --- a/manual/changes/sidebar-ui-scale.md +++ b/manual/changes/sidebar-ui-scale.md @@ -13,6 +13,4 @@ credit: - equalchance --- -At high resolutions the sidebar, the top strip and the game's dialogs are now drawn two to four times larger, while the battlefield keeps one screen pixel per map pixel. [`UIScale`](/keys/uiscale/) chooses the size; by default it is 3x from 2160 rows and 1x below, so 1080p and 1440p look as before. - -The interface size follows the battlefield-only zoom of the TI Chilong Edition build of Vinifera, which draws its sidebar at 3x on 4K screens. +At high resolutions the sidebar, the top strip, messages and the in-game dialogs are now drawn two to four times larger, while the battlefield keeps one screen pixel per map pixel. [`UIScale`](/keys/uiscale/) chooses the size; by default it is 3x from 2160 rows and 1x below, so 1080p and 1440p look as before. From 02f9f1d82de9b45fbd125828e779ef1abc2096bc Mon Sep 17 00:00:00 2001 From: equalchance <182705210+equalchance@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:58:16 +0300 Subject: [PATCH 14/14] Fix battlefield zoom input and scenario transitions --- code/battlezoom.cpp | 35 ++---- code/battlezoomlayout.cpp | 43 +++++++ code/battlezoomlayout.h | 1 + code/battlezoomstate.cpp | 7 +- code/battlezoomstate.hh | 2 +- code/init.cpp | 4 +- code/saveload.cpp | 1 + code/scenario.cpp | 2 +- code/scroll.cpp | 4 +- code/tooltip.cpp | 4 +- code/tooltip.h | 6 +- manual/changes/sidebar-ui-scale.md | 2 +- .../content/keys/uiscale--client-settings.md | 8 +- manual/content/systems/battlefield-zoom.md | 4 +- tests/battlezoom/CMakeLists.txt | 6 +- tests/battlezoom/battlezoomcontract.cpp | 36 ++++++ tests/battlezoom/battlezoomtooltip.cpp | 119 ++++++++++++++++++ 17 files changed, 233 insertions(+), 51 deletions(-) create mode 100644 tests/battlezoom/battlezoomtooltip.cpp diff --git a/code/battlezoom.cpp b/code/battlezoom.cpp index 7b681c9a8..e5059499b 100644 --- a/code/battlezoom.cpp +++ b/code/battlezoom.cpp @@ -146,14 +146,11 @@ void Battle_Zoom_Reset(void) /// void Battle_Zoom_Start(void) { - if (Debug_Map || (TacticalMap != NULL && TacticalMap->ZoomFactor != 1.0)) { - return; - } - _Begun = true; int const before = _State.Factor(); - _State.Start_At(BattlefieldRect, UI_Scale()); - if (_State.Factor() != before) { + _State.Start_At(BattlefieldRect, Debug_Map ? (int)BATTLE_ZOOM_MIN : UI_Scale(), TacticalMap != NULL && TacticalMap->ZoomFactor != 1.0); + // Returning to a menu resets the factor before the next scenario refits the native view. + if (_State.Factor() != before || !(TacticalRect == Battle_Zoom_View_Rect(BattlefieldRect, _State.Factor()))) { _Applying = true; Map.Set_View_Dimensions(BattlefieldRect); _Applying = false; @@ -290,31 +287,13 @@ Point2D Battle_Zoom_View_To_Frame(Point2D const & point) /// -/// Converts a native view position into the frame position the edge scroll tests. A position -/// on an edge of the native view lands on the same edge of the battlefield area, so the frame -/// pixels a whole-number magnification leaves unfilled still count as that edge. +/// Converts a native view or interface position for edge scrolling, preserving screen edges +/// through whole-number zoom and interface scaling. /// Point2D Battle_Zoom_Edge_Point(Point2D const & point) { - if (!Is_Zoomed() || !TacticalRect.Is_Point_Within(point)) { - return(point); - } - - Point2D frame = Battle_Zoom_View_To_Screen(point, BattlefieldRect, _State.Factor()); - - if (point.X == TacticalRect.X) { - frame.X = BattlefieldRect.X; - } else if (point.X == TacticalRect.X + TacticalRect.Width - 1) { - frame.X = BattlefieldRect.X + BattlefieldRect.Width - 1; - } - - if (point.Y == TacticalRect.Y) { - frame.Y = BattlefieldRect.Y; - } else if (point.Y == TacticalRect.Y + TacticalRect.Height - 1) { - frame.Y = BattlefieldRect.Y + BattlefieldRect.Height - 1; - } - - return(frame); + return(Battle_Zoom_Edge_Point(point, BattlefieldRect, Is_Zoomed() ? _State.Factor() : (int)BATTLE_ZOOM_MIN, + VisibleRect, Is_Scaled() ? UI_Scale() : (int)UI_SCALE_MIN)); } diff --git a/code/battlezoomlayout.cpp b/code/battlezoomlayout.cpp index de289d7bd..42741ac21 100644 --- a/code/battlezoomlayout.cpp +++ b/code/battlezoomlayout.cpp @@ -9,6 +9,8 @@ #include "battlezoomlayout.h" +#include "uiscalelayout.h" + #include #include #include @@ -119,6 +121,47 @@ Point2D Battle_Zoom_View_To_Screen(Point2D const & point, Rect const & battlefie } +// Border pixels stay at the edge when division into interface or view pixels loses their offset. +Point2D Battle_Zoom_Edge_Point(Point2D const & point, Rect const & battlefield, int zoom, Rect const & frame, int interface_scale) +{ + Rect const view = Battle_Zoom_View_Rect(battlefield, zoom); + if (zoom <= BATTLE_ZOOM_MIN || !view.Is_Point_Within(point)) { + Point2D screen = UI_Scale_Interface_To_Screen(point, battlefield, interface_scale); + if (interface_scale > UI_SCALE_MIN) { + int const right = battlefield.X + battlefield.Width; + if (point.X >= right) { + if (point.X >= right + (frame.Width - right - 1) / interface_scale) { + screen.X = frame.Width - 1; + } + if (point.Y >= (frame.Height - 1) / interface_scale) { + screen.Y = frame.Height - 1; + } + } + if (point.Y == 0) { + screen.Y = 0; + } + if (point.X == 0) { + screen.X = 0; + } + } + return(screen); + } + + Point2D screen = Battle_Zoom_View_To_Screen(point, battlefield, zoom); + if (point.X == view.X) { + screen.X = battlefield.X; + } else if (point.X == view.X + view.Width - 1) { + screen.X = battlefield.X + battlefield.Width - 1; + } + if (point.Y == view.Y) { + screen.Y = battlefield.Y; + } else if (point.Y == view.Y + view.Height - 1) { + screen.Y = battlefield.Y + battlefield.Height - 1; + } + return(screen); +} + + /// /// Works out the tactical view centre that keeps a world position under the same frame position /// after the native view changes size. diff --git a/code/battlezoomlayout.h b/code/battlezoomlayout.h index 8b580a1b6..2963ad959 100644 --- a/code/battlezoomlayout.h +++ b/code/battlezoomlayout.h @@ -35,6 +35,7 @@ Rect Battle_Zoom_Screen_Rect(Rect const & battlefield, int zoom); Point2D Battle_Zoom_Screen_To_View(Point2D const & point, Rect const & battlefield, int zoom); Point2D Battle_Zoom_View_To_Screen(Point2D const & point, Rect const & battlefield, int zoom); +Point2D Battle_Zoom_Edge_Point(Point2D const & point, Rect const & battlefield, int zoom, Rect const & frame, int interface_scale); Point2D Battle_Zoom_Anchored_Center(Point2D const & center, Rect const & before_view, Point2D const & before_point, Rect const & after_view, Point2D const & after_point); diff --git a/code/battlezoomstate.cpp b/code/battlezoomstate.cpp index d360358de..9df60d08d 100644 --- a/code/battlezoomstate.cpp +++ b/code/battlezoomstate.cpp @@ -81,11 +81,10 @@ void BattleZoomStateClass::Reset(void) } -// Sets the factor outright, lowered to the nearest one the battlefield allows, and drops any -// waiting wheel change. -void BattleZoomStateClass::Start_At(Rect const & battlefield, int zoom) +// Trigger zooms use the full battlefield, including when restored from a save. +void BattleZoomStateClass::Start_At(Rect const & battlefield, int zoom, bool trigger_zoom) { - Zoom = Battle_Zoom_Nearest_Allowed(battlefield, zoom); + Zoom = Battle_Zoom_Nearest_Allowed(battlefield, trigger_zoom ? (int)BATTLE_ZOOM_MIN : zoom); Cancel_Pending(); } diff --git a/code/battlezoomstate.hh b/code/battlezoomstate.hh index 91837cb31..512b26da6 100644 --- a/code/battlezoomstate.hh +++ b/code/battlezoomstate.hh @@ -22,7 +22,7 @@ public: bool Apply_Pending(Rect const & battlefield, bool can_zoom, bool input_pending, Point2D & frame); void Cancel_Pending(void); void Reset(void); - void Start_At(Rect const & battlefield, int zoom); + void Start_At(Rect const & battlefield, int zoom, bool trigger_zoom = false); static unsigned Button_Mask(WindowMouseButton button); void Mouse_Button(WindowMouseButton button, bool down, Rect const & battlefield, Point2D const & frame); diff --git a/code/init.cpp b/code/init.cpp index 8abef450d..56e951869 100644 --- a/code/init.cpp +++ b/code/init.cpp @@ -956,6 +956,8 @@ bool Select_Game(bool ) } while (process) { + UI_Scale_End_Scenario(); + Battle_Zoom_End(); /* ** Display menu and fetch selection from player. @@ -965,8 +967,6 @@ bool Select_Game(bool ) } if ((selection == SEL_NONE) && !Debug_ForceScenario) { - UI_Scale_End_Scenario(); - Battle_Zoom_End(); if (Get_New_Menu()->MixFile) { selection = New_Main_Menu(); } else { diff --git a/code/saveload.cpp b/code/saveload.cpp index 1d242ea83..b9e5500da 100644 --- a/code/saveload.cpp +++ b/code/saveload.cpp @@ -822,6 +822,7 @@ static bool Get_All(SaveStreamClass & stream, bool save_net) // The saved view origin suits the view size at save time, which differs after a zoom or a // resolution change, so the view is fitted again about its saved centre. + Battle_Zoom_Start(); TacticalMap->Set_View_Dimensions(TacticalRect); if (!Load_Vector(stream)) { /// HouseTypes diff --git a/code/scenario.cpp b/code/scenario.cpp index a6e306be0..dd568975c 100644 --- a/code/scenario.cpp +++ b/code/scenario.cpp @@ -1065,7 +1065,6 @@ void Clear_Scenario(void) { Stop_All_Sound_Effects(); Sync_Recorder_Disarm(); - Battle_Zoom_Start(); int index; @@ -1102,6 +1101,7 @@ void Clear_Scenario(void) delete TacticalMap; TacticalMap = new Tactical; + Battle_Zoom_Start(); TacticalMap->Reset_Dirty_Rectangles(); LightSourceClass::Recalc = false; diff --git a/code/scroll.cpp b/code/scroll.cpp index 29555736c..820895e79 100644 --- a/code/scroll.cpp +++ b/code/scroll.cpp @@ -417,8 +417,8 @@ void ScrollClass::Scroll_Edge(Point2D const & point) Point2D p = Battle_Zoom_Edge_Point(TacticalRect.Top_Left() + point); int x = p.X; int y = p.Y; - int w = (CompositeSurface->Get_Width()+SidebarSurface->Get_Width()) - 1; - int h = CompositeSurface->Get_Height() - 1; + int w = VisibleRect.Width - 1; + int h = VisibleRect.Height - 1; bool at_screen_edge = (y <= 0 || x == 0 || x >= w || y >= h); diff --git a/code/tooltip.cpp b/code/tooltip.cpp index 7d4d057d0..f1b4dbf36 100644 --- a/code/tooltip.cpp +++ b/code/tooltip.cpp @@ -15,6 +15,7 @@ #include "tooltip.h" +#include "battlezoom.h" #include "data.h" #include "dbgprint.h" #include "sdl/sdlwindow.h" @@ -131,6 +132,7 @@ void ToolTipManager::Service(void) LastMousePos.X = x; LastMousePos.Y = y; Window_Point_To_Game(LastMousePos); + Battle_Zoom_Frame_To_View(LastMousePos); CurrentToolTip = Find_From_Pos(LastMousePos); if (Process() == true) { Start_Timer(ToolTipLifetime); @@ -269,7 +271,7 @@ bool ToolTipManager::Find(unsigned id, ToolTip * tooltip) /// Finds the tooltip whose region covers the point specified. /// This routine is used to work out which tooltip, if any, the mouse is resting over. /// -/// The point, in frame coordinates, to test the regions against. +/// The point, in tactical view or interface coordinates, to test the regions against. /// Returns with a pointer to the tooltip found, or NULL if the point is bare. ToolTip const * ToolTipManager::Find_From_Pos(Point2D &xy) { diff --git a/code/tooltip.h b/code/tooltip.h index 288915d68..0c7dde811 100644 --- a/code/tooltip.h +++ b/code/tooltip.h @@ -35,8 +35,8 @@ class ToolTip unsigned ID; /* - * This is the rectangle, expressed in the coordinates of the frame the game draws in, - * that the mouse must come to rest within for this tooltip to appear. + * The mouse must rest in this rectangle, in tactical view or interface coordinates, + * for this tooltip to appear. */ Rect Region; @@ -113,7 +113,7 @@ class ToolTipManager bool IsActive; /* - * This is where the cursor was, in frame coordinates, when the hover delay expired. + * The cursor position in tactical view or interface coordinates when the hover delay expired. * It decides which tooltip is chosen and where the tooltip box is placed. */ Point2D LastMousePos; diff --git a/manual/changes/sidebar-ui-scale.md b/manual/changes/sidebar-ui-scale.md index 3de49188a..471bc3751 100644 --- a/manual/changes/sidebar-ui-scale.md +++ b/manual/changes/sidebar-ui-scale.md @@ -13,4 +13,4 @@ credit: - equalchance --- -At high resolutions the sidebar, the top strip, messages and the in-game dialogs are now drawn two to four times larger, while the battlefield keeps one screen pixel per map pixel. [`UIScale`](/keys/uiscale/) chooses the size; by default it is 3x from 2160 rows and 1x below, so 1080p and 1440p look as before. +At high resolutions the sidebar, the top strip, messages and the in-game dialogs are now drawn two to four times larger. [`UIScale`](/keys/uiscale/) chooses the size; by default it is 3x from 2160 rows and 1x below, so 1080p and 1440p look as before. diff --git a/manual/content/keys/uiscale--client-settings.md b/manual/content/keys/uiscale--client-settings.md index f2824db4e..b251ef26f 100644 --- a/manual/content/keys/uiscale--client-settings.md +++ b/manual/content/keys/uiscale--client-settings.md @@ -11,10 +11,10 @@ when_omitted: `UIScale` in `[Video]` of `sun.ini` sets how many times larger than its artwork the interface is drawn: the [sidebar](/systems/sidebar/) with its radar and cameos, the strip across the top with the Options tab and mission timer, and the game's screens and dialogs. `1` to `4` choose the size; a larger value acts as `4`. `0` or a negative value picks it from the height set by [`ScreenHeight`](/keys/screenheight/#scope-client-settings-2): `1` below 2160 rows, `3` from 2160 rows, and `4` from 4320 rows. -At 3840 by 2160 the automatic size is `3`. The sidebar is then 504 pixels wide, the top strip 48 pixels high, and the [battlefield](/systems/battlefield-zoom/) fills the remaining 3336 by 2112 pixels at one screen pixel per map pixel. Battlefield zoom still magnifies that area. +At 3840 by 2160 the automatic size is `3`. The sidebar is then 504 pixels wide, the top strip 48 pixels high, and the [battlefield](/systems/battlefield-zoom/) fills the remaining 3336 by 2112 screen pixels. Battlefield zoom sets the size of map pixels inside that area. -The size is lowered one step at a time until the interface keeps at least 640 by 540 of its own pixels and the battlefield beside it keeps at least 472 by 524 pixels. A chosen `3` at 1920 by 1080 therefore draws the interface at `2`. +A size above 1x is allowed only when the screen dimensions divided by that size are at least 640 by 540 pixels and the battlefield beside the interface keeps at least 472 by 524 screen pixels. Larger choices are lowered until they fit, with 1x always available. A chosen `3` at 1920 by 1080 therefore draws the interface at `2`. -The Display screen of the main menu's Options sets the size: each press of the Interface size button steps it from Automatic through 1x to 4x and back, and OK keeps the choice. A new size takes effect when the next mission or skirmish starts or a saved game is loaded; the in-game Options menu does not offer it. +The Display screen of the main menu's Options sets the size: the Interface size slider chooses Automatic or a size up to the largest the selected resolution fits, and OK keeps the choice. A new size takes effect when the next mission or skirmish starts or a saved game is loaded; the in-game Options menu does not offer it. -Chat and game messages are drawn at the same size as the interface. Tooltips over the battlefield are drawn at artwork size. The main menu and its screens keep their artwork size at every setting. +Chat and game messages are drawn at the same size as the interface. Tooltips over the battlefield follow the battlefield zoom. The main menu and its screens keep their artwork size at every setting. diff --git a/manual/content/systems/battlefield-zoom.md b/manual/content/systems/battlefield-zoom.md index 806e1c928..a9cc80e7d 100644 --- a/manual/content/systems/battlefield-zoom.md +++ b/manual/content/systems/battlefield-zoom.md @@ -8,7 +8,7 @@ related: id: sidebar --- -Each wheel notch over the tactical view zooms in one step, away from the player, or out one step, toward the player. A zoomed view shows a smaller part of the map, with every map pixel drawn as a square of 2, 3 or 4 screen pixels. Over the sidebar or the tab strip the wheel scrolls the sidebar. +Rolling the mouse wheel forward over the tactical view zooms in one step per notch; rolling it backward zooms out. A zoomed view shows a smaller part of the map, with every map pixel drawn as a square of 2, 3 or 4 screen pixels. Over the sidebar or the tab strip the wheel scrolls the sidebar. ## Available factors @@ -37,4 +37,4 @@ A drag that starts on the battlefield stays on it: a selection box or a coast sc ## Persistence -The factor belongs to the local display. It is not stored in saved games or sent to other players. A mission or a loaded game starts at the [`UIScale`](/keys/uiscale/) size, lowered to the largest factor available, so at 3840 by 2160 with the automatic size it starts at 3x. This happens even when the mission opens with the player's input locked. A game saved while zoomed loads with the view centred where the zoomed view was. The game resolution can only be changed from the main menu, where the factor is always 1x. +The factor belongs to the local display. It is not stored in saved games or sent to other players. A mission or a loaded game without an active trigger zoom starts at the [`UIScale`](/keys/uiscale/) size, lowered to the largest factor available, so at 3840 by 2160 with the automatic size it starts at 3x. This happens even when the mission opens with the player's input locked. A saved trigger zoom resumes at its saved magnification. A game saved while zoomed loads with the view centred where the zoomed view was. The game resolution can only be changed from the main menu, where the factor is always 1x. diff --git a/tests/battlezoom/CMakeLists.txt b/tests/battlezoom/CMakeLists.txt index 68b8a5f7c..ba7341ac0 100644 --- a/tests/battlezoom/CMakeLists.txt +++ b/tests/battlezoom/CMakeLists.txt @@ -1,11 +1,13 @@ -# The geometry, the pending zoom state and the interface scale are tested without game assets. +# Zoom geometry, state and tooltip polling are tested without game assets or a window. opents_add_test(BattleZoom NAME battlezoom - SOURCES battlezoomcontract.cpp + SOURCES battlezoomcontract.cpp battlezoomtooltip.cpp ENGINE battlezoomlayout.cpp battlezoomstate.cpp uiscalelayout.cpp + tooltip.cpp DEFINITIONS WIN32 _WINDOWS NOMINMAX + LIBRARIES winmm ) diff --git a/tests/battlezoom/battlezoomcontract.cpp b/tests/battlezoom/battlezoomcontract.cpp index f24a7edf5..d93a77e28 100644 --- a/tests/battlezoom/battlezoomcontract.cpp +++ b/tests/battlezoom/battlezoomcontract.cpp @@ -16,8 +16,11 @@ #include "uiscalelayout.h" #include +#include #include +int Test_Battle_Zoom_Tooltips(void); + namespace { int Failures = 0; @@ -247,6 +250,39 @@ int main() } Check(interface_roundtrip, "an interface position survives the trip to the frame and back"); + for (int scale = 2; scale <= 4; scale++) { + Rect const frame(0, 0, 3841, 2161); + Rect const battlefield = UI_Scale_Battlefield_Rect(frame.Width, frame.Height, scale); + for (int zoom = 1; zoom <= 4; zoom++) { + bool edges_match = true; + for (Point2D const & screen : { Point2D(0, 0), Point2D(3300, 0), Point2D(3840, 0), + Point2D(3840, 1080), Point2D(3840, 2160), Point2D(3800, 2160), Point2D(0, 2160), + Point2D(0, 1080), Point2D(1200, 2160), Point2D(3800, 1500) }) { + Point2D const view = UI_Scale_Screen_To_Interface(Battle_Zoom_Screen_To_View(screen, battlefield, zoom), battlefield, scale); + Point2D const edge = Battle_Zoom_Edge_Point(view, battlefield, zoom, frame, scale); + edges_match &= (screen.X == 0) == (edge.X == 0) && (screen.X == 3840) == (edge.X == 3840) + && (screen.Y == 0) == (edge.Y == 0) && (screen.Y == 2160) == (edge.Y == 2160); + edges_match &= edge.X >= 0 && edge.X < frame.Width && edge.Y >= 0 && edge.Y < frame.Height; + edges_match &= std::abs(edge.X - screen.X) < scale + zoom && std::abs(edge.Y - screen.Y) < scale + zoom; + } + Check(edges_match, "scaled sidebar and tab strip preserve screen edges at every zoom"); + } + } + + { + BattleZoomStateClass restored; + Rect const battlefield(0, 48, 3336, 2112); + restored.Start_At(battlefield, 3); + restored.Wheel(battlefield, true, 1.0f, cursor); + restored.Start_At(battlefield, 3, true); + Check(restored.Factor() == 1 && !restored.Apply_Pending(battlefield, false, false, anchor), "a loaded trigger zoom replaces the starting factor and pending wheel request"); + Check(Same(restored.Fit_View(battlefield, true), 0, 48, 3336, 2112), "a trigger zoom fits the full battlefield before restoring its camera"); + restored.Start_At(battlefield, 3, false); + Check(restored.Factor() == 3, "the next mission starts at the interface size after a trigger zoom"); + } + + Failures += Test_Battle_Zoom_Tooltips(); + std::printf("\n%s\n", Failures == 0 ? "PASSED" : "FAILED"); return(Failures == 0 ? 0 : 1); } diff --git a/tests/battlezoom/battlezoomtooltip.cpp b/tests/battlezoom/battlezoomtooltip.cpp new file mode 100644 index 000000000..0f75a841e --- /dev/null +++ b/tests/battlezoom/battlezoomtooltip.cpp @@ -0,0 +1,119 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "battlezoom.h" +#include "battlezoomlayout.h" +#include "data.h" +#include "dbgprint.h" +#include "sdl/sdlwindow.h" +#include "tooltip.h" +#include "uiscalelayout.h" +#include "vidscale.h" + +#include + +namespace { + +Point2D Cursor(0, 0); +Rect Battlefield(0, 16, 1752, 1064); +int Zoom = 1; +int Scale = 1; + + +class RecordingToolTips : public ToolTipManager +{ +public: + int ID = 0; + Point2D Position = Point2D(-1, -1); + + virtual char const * ToolTip_Text(int id) override + { + ID = id; + return("tooltip"); + } + + virtual bool Update(ToolTipText * text) override + { + Position = text->Pos; + return(true); + } +}; + + +bool Hover(int id, Rect const & region, Point2D const & screen, Point2D const & expected) +{ + RecordingToolTips manager; + ToolTip tooltip; + tooltip.ID = id; + tooltip.Region = region; + manager.Add(&tooltip); + manager.Activate(true); + manager.Set_Timer_Delay(0); + Cursor = screen; + WindowEvent move; + move.Type = WINDOW_EVENT_MOUSE_MOVE; + manager.Handle_Window_Event(move); + manager.Service(); + return(manager.ID == id && manager.Position == expected); +} + +} + + +bool Main_Window_Cursor_Position(int & x, int & y) +{ + x = Cursor.X; + y = Cursor.Y; + return(true); +} + + +void Window_Point_To_Game(Point2D &) +{ +} + + +void Battle_Zoom_Frame_To_View(Point2D & point) +{ + point = Battle_Zoom_Screen_To_View(point, Battlefield, Zoom); + point = UI_Scale_Screen_To_Interface(point, Battlefield, Scale); +} + + +char const * Fetch_String(int) +{ + return("tooltip"); +} + + +void __cdecl DebugString(char const *, ...) +{ +} + + +int Test_Battle_Zoom_Tooltips(void) +{ + int failures = 0; + auto check = [&failures](bool result, char const * label) { + std::printf("%-76s %s\n", label, result ? "ok" : "FAILED"); + failures += !result; + }; + + check(Hover(500, Battlefield, Point2D(1200, 600), Point2D(1200, 600)), "tooltip polling keeps unscaled battlefield coordinates"); + Zoom = 2; + check(Hover(500, Battle_Zoom_View_Rect(Battlefield, Zoom), Point2D(1200, 600), Point2D(600, 308)), "tooltip polling finds and places a tooltip in the zoomed view"); + + Battlefield = UI_Scale_Battlefield_Rect(3840, 2160, 3); + Scale = 3; + Zoom = 3; + Point2D const sidebar(Battlefield.Width + 80, 500); + check(Hover(42, Rect(Battlefield.Width + 60, 490, 60, 50), + UI_Scale_Interface_To_Screen(sidebar, Battlefield, Scale), sidebar), "tooltip polling uses the scaled sidebar's row and position"); + return(failures); +}