Files
RP412/MUNGA_L4/L4MFDVIEW.cpp
T
CydandClaude Fable 5 8dc6605a07 Cockpit: buttons under the glass, -fit, and player display layout
Button banks
  The exploded diagnostic view was still display-only - it predates the
  button work - so it now builds the same banks as the cockpit, with
  the pod arrangement laid out from the panes measured sizes rather
  than a hardcoded 640/480 grid (the banks make each window bigger than
  its glass, and the bottom row hung off the work area otherwise).

  The map side columns were spread height/6 from the top, but the maps
  own legend grid is not sixths: measured off the bitmap it starts 13
  rows down with six 102-tall cells on a 105 pitch. Every button sat
  high of its label, worst at the bottom. Each buttons top and bottom
  now come off that grid separately and are subtracted - scaling a
  height directly would let rounding drift them back out of step on a
  resized cockpit.

  Depth 100 to 240: against the 480 glass the two banks meet in the
  middle bar the strips, so practically the whole display is a press
  target. This mattered most in the cockpit, where the panes are small
  enough that the halfway clamp governs - at 100 the MFDs had a 110px
  dead band straight through the middle of the glass.

-fit (also spelled -windowed-fullscreen)
  Borderless over the whole monitor, with the render size chosen to
  match. The cockpit presents the 3D into a viewscreen that fills its
  canvas, so the right -res is that canvas at the scale the cockpit
  will settle on; computing it with identical arithmetic makes the
  stretch a copy. On the 3440x1440 panel that is 133% and -res 2553
  1436, against 125% for the windowed path that pays for the taskbar.

  The pick runs after the whole command line, so an explicit -res wins
  from either side of -fit. Capped at 3840x2160. Cockpit mode only -
  mode 0 has to stay playable on real pod hardware and mode 2 is a dev
  view - so those get the resolution and keep their windows.

Display layout, in environ.ini
  L4MFDSCALE sizes all five MFDs, L4MFDSCALE_UL and friends override
  any one of them, L4RADARSCALE the radar, and L4RADARPOS puts the
  radar bottom centre, in either bottom corner, or halfway up either
  side. Scaling is applied in canvas units before the canvas is fitted
  to the window, so a number means the same thing on every monitor.

  Sizing each display separately let the clamps become exact rather
  than one conservative rule for all five: what limits a display is its
  actual neighbour. Which neighbour that is depends on the radar, so
  the clamps follow it - on the bottom edge it clears the one MFD above
  its column, but centred on a side it has one above AND below and
  grows from the middle both ways, so it must clear the taller twice
  over. Clamping shrinks uniformly; these are photographs of real
  instruments and a one-axis clamp would squash them.

Verified on the ultrawide: all five radar positions, per-display and
group scaling with the clamps biting, -fit with and without an explicit
-res, and the button geometry measured back off the screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 14:05:02 -05:00

542 lines
15 KiB
C++

#include "mungal4.h"
#pragma hdrstop
#include "l4mfdview.h"
#include "l4padrio.h"
namespace
{
const char mfdViewClass[] = "RPMFDView";
const int buttonGap = 4;
//---------------------------------------------------------------
// Button banks run UNDER the glass: a button reaches this far in
// from the display edge, but only the indicator strip clears that
// edge - the rest sits behind the picture, which is what the
// player actually clicks. Applies to both the MFD strips (depth
// measured vertically) and the map's side columns (horizontally).
//---------------------------------------------------------------
// 240 against the 480 glass: the two banks reach in far enough to
// meet in the middle bar the strips' own width, so practically the
// whole display is a press target and neither bank overlaps the
// other. The clamps below keep that true on a scaled-down cockpit.
const int buttonDepth = 240;
const int indicatorStrip = 10;
//---------------------------------------------------------------
// The map paints its own legend beside each side button, and that
// grid is not the naive height/6: measured off the 640-tall map,
// the first cell starts 13 rows down and the six cells are 102
// tall on a 105 pitch (13 + 6*102 + 5*3 = 640). Spacing the
// buttons evenly from the top instead left every one of them
// riding high of its label. Scaled to the live glass height.
//---------------------------------------------------------------
const int mapLegendSpan = 640;
const int mapLegendTop = 13;
const int mapLegendCell = 102;
const int mapLegendPitch = 105;
//---------------------------------------------------------------
// Button fill colors by lamp brightness (0 off / 1-2 dim / 3
// bright), red family for MFD strips, amber for the side columns.
//---------------------------------------------------------------
COLORREF ButtonFill(int amber, int level)
{
if (amber)
{
if (level >= 3) return RGB(255, 210, 48);
if (level >= 1) return RGB(150, 124, 26);
return RGB(64, 52, 10);
}
if (level >= 3) return RGB(255, 72, 44);
if (level >= 1) return RGB(150, 44, 28);
return RGB(64, 18, 12);
}
//---------------------------------------------------------------
// Lamp byte -> current brightness level, animating flash modes.
// Low 2 bits: solid/slow/med/fast; bits 2-3: state 1 brightness;
// bits 4-5: state 2 brightness (the flash alternate).
//---------------------------------------------------------------
int LampLevel(int lamp_state)
{
int mode = lamp_state & 0x03;
int level1 = (lamp_state >> 2) & 0x03;
int level2 = (lamp_state >> 4) & 0x03;
if (mode == 0)
{
return level1;
}
static const int half_period[4] = { 0, 500, 250, 125 };
return ((GetTickCount() / half_period[mode]) & 1) ? level2 : level1;
}
LRESULT CALLBACK
MFDViewWndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
MFDSplitView *view =
(MFDSplitView *) GetWindowLongPtrA(hwnd, GWLP_USERDATA);
switch (message)
{
case WM_PAINT:
if (view != NULL)
{
view->Paint();
return 0;
}
// no instance (e.g. the plain viewscreen child): default paint
break;
case WM_LBUTTONDOWN:
if (view != NULL)
{
view->MouseDown((int)(short) LOWORD(lParam), (int)(short) HIWORD(lParam));
return 0;
}
break;
case WM_LBUTTONUP:
case WM_CAPTURECHANGED:
if (view != NULL)
{
view->MouseUp();
return 0;
}
break;
case WM_CLOSE:
// Part of the cockpit; just hide it.
ShowWindow(hwnd, SW_HIDE);
return 0;
case WM_ERASEBKGND:
if (view != NULL)
{
return 1;
}
break; // viewscreen child: erase with the class black brush
}
return DefWindowProcA(hwnd, message, wParam, lParam);
}
}
//########################################################################
//############################ MFDSplitView ##############################
//########################################################################
MFDSplitView::MFDSplitView(
const char *title,
int source_width,
int source_height,
int display_width,
int display_height,
int x,
int y,
ButtonStyle button_style,
int anchorA,
int anchorB,
void *parent
)
{
Check_Pointer(this);
sourceWidth = source_width;
sourceHeight = source_height;
buttonCount = 0;
pressedIndex = -1;
pixels = new unsigned long[source_width * source_height];
memset(pixels, 0, source_width * source_height * sizeof(unsigned long));
BITMAPINFOHEADER *header = new BITMAPINFOHEADER;
memset(header, 0, sizeof(BITMAPINFOHEADER));
header->biSize = sizeof(BITMAPINFOHEADER);
header->biWidth = source_width;
header->biHeight = -source_height; // top-down
header->biPlanes = 1;
header->biBitCount = 32;
header->biCompression = BI_RGB;
blitHeader = header;
buttonStyle = button_style;
buttonAnchorA = anchorA;
buttonAnchorB = anchorB;
int client_width = display_width;
int client_height = display_height;
LayoutButtons(button_style, anchorA, anchorB,
display_width, display_height, &client_width, &client_height);
clientWidth = client_width;
clientHeight = client_height;
HINSTANCE instance = GetModuleHandleA(NULL);
static Logical class_registered = False;
if (!class_registered)
{
class_registered = True;
WNDCLASSA window_class;
memset(&window_class, 0, sizeof(window_class));
window_class.style = CS_HREDRAW | CS_VREDRAW;
window_class.lpfnWndProc = MFDViewWndProc;
window_class.hInstance = instance;
window_class.hCursor = LoadCursor(NULL, IDC_ARROW);
window_class.hbrBackground = (HBRUSH) GetStockObject(BLACK_BRUSH);
window_class.lpszClassName = mfdViewClass;
RegisterClassA(&window_class);
}
DWORD style;
int window_w, window_h;
if (parent != NULL)
{
// chrome-less pane inside the cockpit window; clips siblings so
// overlapping panes and the viewscreen never paint over it
style = WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS;
window_w = client_width;
window_h = client_height;
}
else
{
style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX;
RECT bounds;
bounds.left = 0;
bounds.top = 0;
bounds.right = client_width;
bounds.bottom = client_height;
AdjustWindowRect(&bounds, style, FALSE);
window_w = bounds.right - bounds.left;
window_h = bounds.bottom - bounds.top;
}
window = CreateWindowExA(
0,
mfdViewClass,
title,
style,
x, y,
window_w,
window_h,
(HWND) parent, NULL, instance, NULL
);
if (window != NULL)
{
SetWindowLongPtrA((HWND) window, GWLP_USERDATA, (LONG_PTR) this);
ShowWindow((HWND) window, SW_SHOWNOACTIVATE);
}
else
{
DEBUG_STREAM << "MFDSplitView: window creation failed (" << title << ")\n" << std::flush;
}
}
MFDSplitView::~MFDSplitView()
{
Check_Pointer(this);
if (window != NULL)
{
SetWindowLongPtrA((HWND) window, GWLP_USERDATA, 0);
DestroyWindow((HWND) window);
window = NULL;
}
delete (BITMAPINFOHEADER *) blitHeader;
blitHeader = NULL;
delete [] pixels;
pixels = NULL;
}
Logical
MFDSplitView::TestInstance() const
{
return pixels != NULL;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Compute the display rectangle and the button rectangles, growing the
// client area to make room for the strips.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
MFDSplitView::LayoutButtons(
ButtonStyle button_style,
int anchorA,
int anchorB,
int display_width,
int display_height,
int *client_width,
int *client_height
)
{
displayX = 0;
displayY = 0;
displayW = display_width;
displayH = display_height;
if (button_style == MFDStrips)
{
//-----------------------------------------------------------
// 4 buttons above the glass, 4 below, flush to the pane
// edges (no border against the viewscreen behind); RIO
// addresses descend from the anchor, row-major (vRIO
// CockpitLayout::Mfd).
//
// The banks reach BEHIND the display: each button is
// mfdButtonHeight tall but only mfdIndicatorStrip of it
// clears the glass edge, so the lamp reads as a thin strip
// and the picture above/below it is the click target. Paint
// draws the buttons before the MFD image for that reason.
//-----------------------------------------------------------
int strip_h = indicatorStrip;
int button_h = buttonDepth;
// on a scaled-down cockpit, keep the two banks from meeting
int limit = (display_height + 2 * strip_h) / 2;
if (button_h > limit) button_h = limit;
if (button_h < strip_h) button_h = strip_h;
int button_w = (display_width - 3 * buttonGap) / 4;
displayY = strip_h;
*client_height = display_height + 2 * strip_h;
for (int i = 0; i < 8; ++i)
{
int row = i / 4; // 0 = top bank, 1 = bottom bank
int col = i % 4;
ScreenButton *button = &buttons[buttonCount++];
button->unit = anchorA - i;
button->amber = 0;
button->x = col * (button_w + buttonGap);
button->y = row
? (displayY + displayH + strip_h - button_h)
: 0;
button->w = (col == 3)
? (display_width - 3 * (button_w + buttonGap))
: button_w;
button->h = button_h;
}
}
else if (button_style == SideColumns)
{
//-----------------------------------------------------------
// 6 buttons down each side of the glass (the pod wires only 6
// of each column's 8 addresses to buttons; the rest are Tesla
// relays). Left = anchorA ascending, right = anchorB.
//
// Same trick as the MFD banks, turned on its side: each
// button reaches buttonDepth in behind the map and leaves an
// indicatorStrip clearing the edge, so the lamp reads as a
// slim column and the map itself is the click target.
//-----------------------------------------------------------
int strip_w = indicatorStrip;
int button_w = buttonDepth;
// keep the two columns from meeting behind a narrow map
int limit = (display_width + 2 * strip_w) / 2;
if (button_w > limit) button_w = limit;
if (button_w < strip_w) button_w = strip_w;
displayX = strip_w;
*client_width = display_width + 2 * strip_w;
for (int i = 0; i < 6; ++i)
{
//-------------------------------------------------------
// Take top and bottom off the legend grid separately and
// subtract: scaling a height directly would let rounding
// drift the buttons out of step with the labels.
//-------------------------------------------------------
int cell_top = mapLegendTop + i * mapLegendPitch;
int top = (cell_top * display_height) / mapLegendSpan;
int bottom =
((cell_top + mapLegendCell) * display_height) / mapLegendSpan;
int h = bottom - top;
if (h < 1) h = 1;
ScreenButton *left = &buttons[buttonCount++];
left->unit = anchorA + i;
left->amber = 1;
left->x = 0;
left->y = top;
left->w = button_w;
left->h = h;
ScreenButton *right = &buttons[buttonCount++];
right->unit = anchorB + i;
right->amber = 1;
right->x = displayX + displayW + strip_w - button_w;
right->y = left->y;
right->w = button_w;
right->h = h;
}
}
}
void
MFDSplitView::Paint()
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint((HWND) window, &ps);
RECT client;
GetClientRect((HWND) window, &client);
//---------------------------------------------------------------
// Double-buffered: compose the whole pane off screen and blit it
// in one operation, otherwise the clear-then-draw sequence
// flickers at the repaint cadence.
//---------------------------------------------------------------
HDC mem = CreateCompatibleDC(hdc);
HBITMAP surface = CreateCompatibleBitmap(hdc, client.right, client.bottom);
HBITMAP old_surface = (HBITMAP) SelectObject(mem, surface);
FillRect(mem, &client, (HBRUSH) GetStockObject(BLACK_BRUSH));
//---------------------------------------------------------------
// Buttons first, glass second: the MFD banks extend under the
// display so the picture is the click target, and the image
// painted over them leaves only their indicator strips showing.
//---------------------------------------------------------------
for (int i = 0; i < buttonCount; ++i)
{
const ScreenButton *button = &buttons[i];
int level = PadRIO::IsActive()
? LampLevel(PadRIO::GetLampState(button->unit))
: 0;
RECT rect;
rect.left = button->x;
rect.top = button->y;
rect.right = button->x + button->w;
rect.bottom = button->y + button->h;
HBRUSH fill = CreateSolidBrush(ButtonFill(button->amber, level));
FillRect(mem, &rect, fill);
DeleteObject(fill);
HBRUSH frame = CreateSolidBrush(
(i == pressedIndex) ? RGB(255, 255, 255) : RGB(48, 48, 48));
FrameRect(mem, &rect, frame);
DeleteObject(frame);
}
// HALFTONE area-averages the downscale (the compact glass shows the
// full 640x480 canvas at ~half size); COLORONCOLOR dropped every other
// row/column, which shredded the 1-bit vector strokes and small text.
// HALFTONE requires the brush origin be set (MSDN).
SetStretchBltMode(mem, HALFTONE);
SetBrushOrgEx(mem, 0, 0, NULL);
StretchDIBits(
mem,
displayX, displayY, displayW, displayH,
0, 0, sourceWidth, sourceHeight,
pixels, (const BITMAPINFO *) blitHeader,
DIB_RGB_COLORS, SRCCOPY
);
BitBlt(hdc, 0, 0, client.right, client.bottom, mem, 0, 0, SRCCOPY);
SelectObject(mem, old_surface);
DeleteObject(surface);
DeleteDC(mem);
EndPaint((HWND) window, &ps);
}
void
MFDSplitView::MouseDown(int x, int y)
{
for (int i = 0; i < buttonCount; ++i)
{
const ScreenButton *button = &buttons[i];
if (x >= button->x && x < button->x + button->w &&
y >= button->y && y < button->y + button->h)
{
pressedIndex = i;
SetCapture((HWND) window);
PadRIO::SetScreenButton(button->unit, True);
Repaint();
return;
}
}
}
void
MFDSplitView::MouseUp()
{
if (pressedIndex >= 0)
{
PadRIO::SetScreenButton(buttons[pressedIndex].unit, False);
pressedIndex = -1;
if (GetCapture() == (HWND) window)
{
ReleaseCapture();
}
Repaint();
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Paint NOW rather than queueing an invalidation: the game loop pumps
// one message per frame, and queued WM_PAINTs (lowest priority) starve
// behind it - panes would freeze on their first frame.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
MFDSplitView::SetPosition(int x, int y)
{
Check_Pointer(this);
if (window != NULL)
{
SetWindowPos((HWND) window, NULL, x, y, 0, 0,
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Re-scale to a new glass size: the pixel buffer keeps its native
// source resolution, so only the destination rectangle, the button
// geometry and the window size change.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
MFDSplitView::Resize(int display_width, int display_height)
{
Check_Pointer(this);
buttonCount = 0;
pressedIndex = -1;
int client_width = display_width;
int client_height = display_height;
LayoutButtons(buttonStyle, buttonAnchorA, buttonAnchorB,
display_width, display_height, &client_width, &client_height);
clientWidth = client_width;
clientHeight = client_height;
if (window != NULL)
{
SetWindowPos((HWND) window, NULL, 0, 0,
client_width, client_height,
SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
}
}
void
MFDSplitView::Repaint()
{
Check_Pointer(this);
if (window != NULL)
{
RedrawWindow((HWND) window, NULL, NULL,
RDW_INVALIDATE | RDW_UPDATENOW);
}
}