Characters: 16:9 "Spielen" screen, autosave interval option
- Shared screen kit (mods/screen, docs/screens.md): a 1365x768 canvas centred in the render width on our own dark background, the game's frames and buttons; the main menu renders full width while it is shown. - The load window (cUI_Savegame) is replaced by the Spielen screen: character list, hero panel, campaign x difficulty grid, save/autosave choice, rename/delete/reset/new character dialogs. - Migrated characters without a shared hero load their hero's world first, then start the new world. - Options, Allgemein: autosave interval (Aus/5/10/15/30 minutes). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
732d9c2abb
commit
34a119433a
16 files changed
+1391
-468
No files matched your search
+752
-391
File diff suppressed because it is too large.
Load diff
@@ -7,4 +7,9 @@ namespace mods::characters {
|
||||
// save window (on the next engine tick).
|
||||
void RequestSave();
|
||||
|
||||
// Minutes between silent autosaves (0: off); sacredK.ini [characters]
|
||||
// AutosaveMinutes, set on the options window's Allgemein page.
|
||||
int AutosaveMinutes();
|
||||
void SetAutosaveMinutes(int minutes);
|
||||
|
||||
} // namespace mods::characters
|
||||
+28
-2
@@ -30,6 +30,7 @@
|
||||
#include "game/addresses.h"
|
||||
#include "game/render/dx_driver7.h"
|
||||
#include "game/ui/events.h"
|
||||
#include "mods/hud.h"
|
||||
#include "mods/widescreen.h"
|
||||
#include "re/config.h"
|
||||
#include "re/hook.h"
|
||||
@@ -47,11 +48,15 @@ thread_local bool g_eventShifted = false;
|
||||
|
||||
|
||||
// Screen: laid out across the whole render width by its mod (drawn unshifted).
|
||||
enum class Anchor { Center, Right, Screen };
|
||||
// Canvas: a 16:9 screen (kCanvasWidth), centred.
|
||||
enum class Anchor { Center, Right, Screen, Canvas };
|
||||
|
||||
int OffsetFor(Anchor anchor) {
|
||||
static const bool enabled = re::ConfigInt("hud", "Enabled", 1) != 0;
|
||||
const int width = mods::widescreen::RenderWidth();
|
||||
if (anchor == Anchor::Canvas) {
|
||||
return width > kCanvasWidth ? (width - kCanvasWidth) / 2 : 0;
|
||||
}
|
||||
if (!enabled || width <= kLayoutWidth || mods::widescreen::IsClassicFrame() ||
|
||||
mods::widescreen::MenuIsShowing() || anchor == Anchor::Screen) {
|
||||
return 0;
|
||||
@@ -341,6 +346,9 @@ size_t VertexSize(DWORD fvf) {
|
||||
return size;
|
||||
}
|
||||
|
||||
// A 16:9 screen draws (RenderCanvas): no 1024 layout to stretch.
|
||||
thread_local bool g_canvasScope = false;
|
||||
|
||||
// Returns vertices shifted by g_offsetX, or the input when nothing to do.
|
||||
// Primitives covering the whole 1024x768 layout (e.g. the dimming behind
|
||||
// modal dialogs) are stretched across the render width instead.
|
||||
@@ -359,7 +367,7 @@ void* ShiftVertices(DWORD fvf, void* vertices, DWORD count) {
|
||||
minY = std::min(minY, position[1]);
|
||||
maxY = std::max(maxY, position[1]);
|
||||
}
|
||||
const bool fullLayout = minX <= 0.5f && maxX >= kLayoutWidth - 0.5f && minY <= 0.5f && maxY >= 767.5f;
|
||||
const bool fullLayout = !g_canvasScope && minX <= 0.5f && maxX >= kLayoutWidth - 0.5f && minY <= 0.5f && maxY >= 767.5f;
|
||||
const float scale = static_cast<float>(mods::widescreen::RenderWidth()) / kLayoutWidth;
|
||||
for (DWORD i = 0; i < count; ++i) {
|
||||
auto* x = reinterpret_cast<float*>(buffer.data() + stride * i);
|
||||
@@ -788,6 +796,24 @@ int HandleCentred(void* event, int (*body)(void* context), void* context) {
|
||||
return body(context);
|
||||
}
|
||||
|
||||
int CanvasOffsetX() {
|
||||
return OffsetFor(Anchor::Canvas);
|
||||
}
|
||||
|
||||
int RenderCanvas(void* window, int (*body)(void* context), void* context) {
|
||||
DrawOffset offset(window, OffsetFor(Anchor::Canvas));
|
||||
const bool outer = !g_canvasScope;
|
||||
g_canvasScope = true;
|
||||
const int result = body(context);
|
||||
g_canvasScope = !outer;
|
||||
return result;
|
||||
}
|
||||
|
||||
int HandleCanvas(void* event, int (*body)(void* context), void* context) {
|
||||
EventOffset offset(event, OffsetFor(Anchor::Canvas));
|
||||
return body(context);
|
||||
}
|
||||
|
||||
} // namespace mods::hud
|
||||
|
||||
namespace game {
|
||||
|
||||
@@ -30,4 +30,14 @@ int HandleCentred(void* event, int (*body)(void* context), void* context);
|
||||
// with a viewport on it (as the save game and export screens).
|
||||
int RenderFramed(void* window, void* device, int (*body)(void* context), void* context);
|
||||
|
||||
// 16:9 screens (mods/screen.h): laid out on a 1365x768 canvas, centred in
|
||||
// the render width. RenderCanvas / HandleCanvas run body(context) with drawing,
|
||||
// cursor and mouse events in canvas coordinates; CanvasOffsetX is the canvas'
|
||||
// left edge in the render target.
|
||||
constexpr int kCanvasWidth = 1365;
|
||||
constexpr int kCanvasHeight = 768;
|
||||
int CanvasOffsetX();
|
||||
int RenderCanvas(void* window, int (*body)(void* context), void* context);
|
||||
int HandleCanvas(void* event, int (*body)(void* context), void* context);
|
||||
|
||||
} // namespace mods::hud
|
||||
+143
-14
@@ -1,5 +1,6 @@
|
||||
// Options window in three tabs: Allgemein, Beute, Tastenbelegung (request of
|
||||
// 2026-10-18). Beute holds the game's auto-pickup settings (first iteration:
|
||||
// 2026-10-18). Allgemein also gets the autosave interval (characters.cpp);
|
||||
// Beute holds the game's auto-pickup settings (first iteration:
|
||||
// moved there from the common page); Tastenbelegung sets the key bindings of
|
||||
// mods/input.cpp, which had only been settable in sacredK.ini.
|
||||
//
|
||||
@@ -32,9 +33,11 @@
|
||||
#include "game/ui/control.h"
|
||||
#include "game/ui/draw.h"
|
||||
#include "game/ui/events.h"
|
||||
#include "game/ui/frame.h"
|
||||
#include "game/ui/tabs.h"
|
||||
#include "game/ui/text.h"
|
||||
#include "game/ui/texts.h"
|
||||
#include "mods/characters.h"
|
||||
#include "mods/hud.h"
|
||||
#include "mods/input.h"
|
||||
#include "mods/lootfilter.h"
|
||||
@@ -55,21 +58,15 @@ constexpr size_t kOkButton = 0x15c;
|
||||
constexpr size_t kPickupRadios[] = {0x16c, 0x170, 0x174};
|
||||
constexpr const char* kPickupControlNames[] = {"UI_LABEL_COLLECT", "UI_FRAME_SEP_AUTOCOLLECT"};
|
||||
constexpr size_t kControlName = 0x30; // char[32], set by 0x732090
|
||||
// cUI_Frame (vtable 0x8955ac): its render (0x73a540) doesn't test the visible
|
||||
// flag but +0xdc (set to 1 by its constructors, read nowhere else).
|
||||
constexpr uint32_t kFrameVtable = 0x008955ac;
|
||||
constexpr size_t kFrameDrawn = 0xdc;
|
||||
// cUI_Frame (game/ui/frame.h) draws by +0xdc, not by the visible flag.
|
||||
// The key bindings page gets a translucent backdrop and a frame per column,
|
||||
// made as 0x7184e0 makes the others: cUI_Window2 0x724ed0 (rect*, colour;
|
||||
// ret 8) adds a backdrop (cUI_Static) to +0x90; frames are cUI_Frame
|
||||
// (0xe0 bytes, constructor 0x739f90 (name, type 1, rect*, 1), ret 0x10)
|
||||
// ret 8) adds a backdrop (cUI_Static) to +0x90; frames (game/ui/frame.h)
|
||||
// pushed onto +0x90 (vector push_back 0x6bcf50, (&pointer), ret 4).
|
||||
// (Drawing the existing ones at other rects did not fit: a frame's border
|
||||
// pieces keep the place they got when it was made.)
|
||||
constexpr uint32_t kBackdropColour = 0x9f000000;
|
||||
constexpr int kFrameBorder = 0x14; // backdrop beyond the frame on each side
|
||||
constexpr size_t kFrameSize = 0xe0;
|
||||
constexpr uint32_t kMainFrame = 1;
|
||||
|
||||
// Layout of 0x7184e0 (window coordinates): the frames' borders begin 0x14
|
||||
// left of / above the frame positions, their labels at y 0x20 / 0x100 / 0x1e0.
|
||||
@@ -120,7 +117,7 @@ int32_t& ControlY(uint8_t* control) {
|
||||
}
|
||||
|
||||
bool IsFrame(const uint8_t* control) {
|
||||
return *reinterpret_cast<const uint32_t*>(control) == kFrameVtable;
|
||||
return *reinterpret_cast<const uint32_t*>(control) == game::kFrameVtable;
|
||||
}
|
||||
|
||||
// cUI_Scrollbar (vtable 0x895664, the volume and map transparency sliders):
|
||||
@@ -224,7 +221,7 @@ void ShowPage(Window& w, uint16_t page) {
|
||||
c.hiddenByUs = false;
|
||||
}
|
||||
if (IsFrame(c.control)) {
|
||||
c.control[kFrameDrawn] = show ? 1 : 0;
|
||||
c.control[game::kFrameDrawn] = show ? 1 : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -445,9 +442,8 @@ void AddFrame(uint8_t* window, game::UiRect frame, const char* name) {
|
||||
static_cast<int16_t>(frame.h + 2 * kFrameBorder)};
|
||||
using AddBackdropFn = void(__fastcall*)(void*, void*, game::UiRect*, uint32_t);
|
||||
reinterpret_cast<AddBackdropFn>(0x00724ed0)(window, nullptr, &backdrop, kBackdropColour);
|
||||
if (void* memory = game::GameNew(kFrameSize)) {
|
||||
using FrameFn = void*(__fastcall*)(void*, void*, const char*, uint32_t, game::UiRect*, uint32_t);
|
||||
PushControl(window, reinterpret_cast<FrameFn>(0x00739f90)(memory, nullptr, name, kMainFrame, &frame, 1));
|
||||
if (uint8_t* control = game::NewFrame(name, frame)) {
|
||||
PushControl(window, control);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,6 +563,127 @@ void DrawLootPage(const Window& w, void* device) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- saves (Allgemein page) ------------------------------------------------------
|
||||
|
||||
// The autosave interval (request of 2026-10-20), in the left column below
|
||||
// the general frame (moved up, it is taller than the frames beside it): a
|
||||
// frame of its own, as the right column's lowest frame (Internet-Optionen):
|
||||
// its height, and the gap the right column has between its frames, both
|
||||
// measured when the window is made. (A frame lower than that breaks its
|
||||
// side ornaments; screenshots 2026-10-20.)
|
||||
constexpr int kSavesFallbackGap = 0x50;
|
||||
constexpr int16_t kSavesFallbackHeight = 0x40;
|
||||
constexpr int kSavesLabelWidth = 0x90;
|
||||
constexpr int kSavesBoxGap = 8;
|
||||
constexpr int kAutosaveChoices[] = {0, 5, 10, 15, 30}; // minutes, 0: off
|
||||
constexpr int kAutosaveChoiceCount = static_cast<int>(std::size(kAutosaveChoices));
|
||||
constexpr int kSavesBoxWidth =
|
||||
(kFrameWidth - 2 * kTextInset - kSavesLabelWidth - (kAutosaveChoiceCount - 1) * kSavesBoxGap) /
|
||||
kAutosaveChoiceCount;
|
||||
// Lighter than kBoxColour: on the frame's dark backdrop that one is not seen.
|
||||
constexpr uint32_t kSavesBoxColour = 0xc0303030;
|
||||
constexpr uint32_t kSavesBoxHoverColour = 0xc0484848;
|
||||
|
||||
game::UiRect g_savesFrame = {kFrameX[0], 0, kFrameWidth, kSavesFallbackHeight};
|
||||
int g_autosavePending = 0; // minutes until OK
|
||||
int g_savesHover = -1; // choice under the mouse
|
||||
|
||||
void CreateSavesControls(Window& w) {
|
||||
int bottom = 0;
|
||||
std::vector<game::UiRect> right;
|
||||
for (const Control& c : w.controls) {
|
||||
if (c.group != Group::Common || !IsFrame(c.control)) {
|
||||
continue;
|
||||
}
|
||||
const game::UiRect rect = *reinterpret_cast<const game::UiRect*>(c.control + game::kControlRect);
|
||||
if (ControlX(c.control) < kColumnSplit) {
|
||||
bottom = std::max(bottom, rect.y + rect.h);
|
||||
} else if (rect.w == kFrameWidth) {
|
||||
right.push_back(rect); // the main frames, not their label plates (200 wide)
|
||||
}
|
||||
}
|
||||
// The lowest frame and the nearest one ending above it. (Measured: the
|
||||
// right column's frames are 384 wide at y 88, 312, 536, 128 / 128 / 64
|
||||
// high: gaps of 96. Their label plates are frames too, 200 wide.)
|
||||
std::sort(right.begin(), right.end(), [](const game::UiRect& a, const game::UiRect& b) { return a.y < b.y; });
|
||||
int gap = kSavesFallbackGap;
|
||||
if (!right.empty()) {
|
||||
const game::UiRect& lowest = right.back();
|
||||
int aboveBottom = -1;
|
||||
for (const game::UiRect& r : right) {
|
||||
if (r.y + r.h <= lowest.y) {
|
||||
aboveBottom = std::max(aboveBottom, r.y + r.h);
|
||||
}
|
||||
}
|
||||
if (aboveBottom >= 0) {
|
||||
gap = lowest.y - aboveBottom;
|
||||
}
|
||||
g_savesFrame.h = lowest.h;
|
||||
}
|
||||
g_savesFrame.y = bottom + gap;
|
||||
const size_t existing = ControlCount(w.window);
|
||||
AddFrame(w.window, g_savesFrame, "UI_FRAME_SAVES");
|
||||
AssignFrom(w, existing, Group::Common);
|
||||
}
|
||||
|
||||
game::UiRect SavesRowRect(void* window, int dx, int width) {
|
||||
const game::UiRect origin = game::ControlScreenRect(window);
|
||||
return {origin.x + g_savesFrame.x + kTextInset + dx, origin.y + g_savesFrame.y + (g_savesFrame.h - kBoxHeight) / 2,
|
||||
static_cast<int16_t>(width), static_cast<int16_t>(kBoxHeight)};
|
||||
}
|
||||
|
||||
game::UiRect SavesBoxRect(void* window, int choice) {
|
||||
return SavesRowRect(window, kSavesLabelWidth + choice * (kSavesBoxWidth + kSavesBoxGap), kSavesBoxWidth);
|
||||
}
|
||||
|
||||
int SavesBoxAt(void* window, int x, int y) {
|
||||
for (int choice = 0; choice < kAutosaveChoiceCount; ++choice) {
|
||||
if (game::Inside(SavesBoxRect(window, choice), x, y)) {
|
||||
return choice;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void DrawSaves(const Window& w, void* device) {
|
||||
void* window = w.window;
|
||||
game::DrawText(device, kFont, game::CenterLineVertically(kFont, SavesRowRect(window, 0, kSavesLabelWidth)),
|
||||
L"Autosave (Min.)", kTextColour, 0);
|
||||
for (int choice = 0; choice < kAutosaveChoiceCount; ++choice) {
|
||||
const game::UiRect box = SavesBoxRect(window, choice);
|
||||
const int minutes = kAutosaveChoices[choice];
|
||||
FillBox(device, box,
|
||||
g_autosavePending == minutes ? kBoxSelectedColour
|
||||
: g_savesHover == choice ? kSavesBoxHoverColour
|
||||
: kSavesBoxColour);
|
||||
const std::wstring text = minutes ? std::to_wstring(minutes) : L"Aus";
|
||||
game::DrawText(device, kFont, game::CenterLineVertically(kFont, box), text.c_str(), kTextColour,
|
||||
game::kTextCenter);
|
||||
}
|
||||
}
|
||||
|
||||
bool HandleSavesEvent(Window& w, void* event) {
|
||||
switch (game::MouseEventKind(event)) {
|
||||
case game::MouseEvent::Move:
|
||||
g_savesHover = SavesBoxAt(w.window, game::EventField(event, 0xc), game::EventField(event, 0x10));
|
||||
return false;
|
||||
case game::MouseEvent::ButtonDown: {
|
||||
if (game::EventField(event, game::kMouseButton) != game::kLeftButton) {
|
||||
return false;
|
||||
}
|
||||
const int box = SavesBoxAt(w.window, game::EventField(event, game::kMouseButtonX),
|
||||
game::EventField(event, game::kMouseButtonY));
|
||||
if (box < 0) {
|
||||
return false;
|
||||
}
|
||||
g_autosavePending = kAutosaveChoices[box];
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- hooks ---------------------------------------------------------------------
|
||||
|
||||
struct RenderCall {
|
||||
@@ -588,6 +705,7 @@ int RenderBody(void* context) {
|
||||
if (visible && !w->wasVisible) {
|
||||
LoadPending();
|
||||
LoadLootPending();
|
||||
g_autosavePending = mods::characters::AutosaveMinutes();
|
||||
}
|
||||
w->wasVisible = visible;
|
||||
const uint16_t page = game::ActiveTab(w->sheet);
|
||||
@@ -600,6 +718,9 @@ int RenderBody(void* context) {
|
||||
if (!visible || page != kPageLoot) {
|
||||
g_lootHover = -1;
|
||||
}
|
||||
if (!visible || page != kPageCommon) {
|
||||
g_savesHover = -1;
|
||||
}
|
||||
if (!visible) {
|
||||
return result;
|
||||
}
|
||||
@@ -608,6 +729,8 @@ int RenderBody(void* context) {
|
||||
DrawKeysPage(*w, call->device);
|
||||
} else if (page == kPageLoot && g_pendingLoaded) {
|
||||
DrawLootPage(*w, call->device);
|
||||
} else if (page == kPageCommon && g_pendingLoaded) {
|
||||
DrawSaves(*w, call->device);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -649,6 +772,10 @@ int EventBody(void* context) {
|
||||
g_pendingLoaded && HandleLootEvent(*w, call->event)) {
|
||||
return 1;
|
||||
}
|
||||
if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageCommon &&
|
||||
g_pendingLoaded && HandleSavesEvent(*w, call->event)) {
|
||||
return 1;
|
||||
}
|
||||
if (w && w->sheet && game::ControlShown(call->window) && game::ActiveTab(w->sheet) == kPageKeys &&
|
||||
g_pendingLoaded) {
|
||||
switch (game::MouseEventKind(call->event)) {
|
||||
@@ -704,6 +831,7 @@ void* __fastcall UiOptions_Construct(void* self, void* edx, const char* name, Ui
|
||||
Classify(w);
|
||||
CreateKeyControls(w);
|
||||
CreateLootControls(w);
|
||||
CreateSavesControls(w);
|
||||
CreateTabs(w);
|
||||
re::Log("options: tabs created (%u controls)", static_cast<unsigned>(w.controls.size()));
|
||||
}
|
||||
@@ -744,6 +872,7 @@ void __fastcall UiOptions_Apply(void* self, void* edx) {
|
||||
if (g_pendingLoaded) {
|
||||
mods::input::SetBindingKeys(g_pending);
|
||||
mods::lootfilter::SetModes(g_lootPending);
|
||||
mods::characters::SetAutosaveMinutes(g_autosavePending);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// 16:9 menu screens (mods/screen.h, docs/screens.md).
|
||||
|
||||
#include "mods/screen.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include "game/ui/button.h"
|
||||
#include "game/ui/control.h"
|
||||
#include "game/ui/draw.h"
|
||||
#include "game/ui/events.h"
|
||||
#include "game/ui/frame.h"
|
||||
#include "game/ui/texts.h"
|
||||
#include "mods/widescreen.h"
|
||||
|
||||
namespace mods::screen {
|
||||
namespace {
|
||||
|
||||
// Background (decided 2026-10-20: our own, no picture): dark, a little warm
|
||||
// at the top, darker towards the bottom and the sides.
|
||||
constexpr uint32_t kBackgroundTop = 0xff1c1712;
|
||||
constexpr uint32_t kBackgroundBottom = 0xff070605;
|
||||
constexpr uint32_t kVignette = 0xa0000000;
|
||||
constexpr int kVignetteWidth = 240;
|
||||
constexpr uint32_t kPanelBackdrop = 0xb4000000;
|
||||
constexpr int kHeadingHeight = 30;
|
||||
|
||||
// Frames drawn so far, to tell a button drawn this frame (events come between
|
||||
// frames).
|
||||
std::atomic<unsigned> g_frame{1};
|
||||
|
||||
// The windows whose screen is shown: the menu thread renders the full width
|
||||
// while any is.
|
||||
std::mutex g_shownLock;
|
||||
std::set<const void*> g_shown;
|
||||
|
||||
void NoteShown(const void* window, bool shown) {
|
||||
std::lock_guard guard(g_shownLock);
|
||||
if (shown) {
|
||||
g_shown.insert(window);
|
||||
} else {
|
||||
g_shown.erase(window);
|
||||
}
|
||||
widescreen::SetWideMenuScreen(!g_shown.empty());
|
||||
}
|
||||
|
||||
void DrawBackground(void* device) {
|
||||
const float width = static_cast<float>(std::max(widescreen::RenderWidth(), kWidth));
|
||||
const float height = static_cast<float>(kHeight);
|
||||
game::FillVertical(device, 0, 0, width, height, kBackgroundTop, kBackgroundBottom);
|
||||
game::FillHorizontal(device, 0, 0, kVignetteWidth, height, kVignette, 0);
|
||||
game::FillHorizontal(device, width - kVignetteWidth, 0, width, height, 0, kVignette);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int Render(void* window, void* device, int (*body)(void* context), void* context) {
|
||||
const bool shown = game::ControlShown(window);
|
||||
NoteShown(window, shown);
|
||||
if (!shown || !device) {
|
||||
return 0;
|
||||
}
|
||||
++g_frame;
|
||||
DrawBackground(device);
|
||||
return hud::RenderCanvas(window, body, context);
|
||||
}
|
||||
|
||||
int Handle(void* event, int (*body)(void* context), void* context) {
|
||||
return hud::HandleCanvas(event, body, context);
|
||||
}
|
||||
|
||||
void Fill(void* device, const UiRect& r, uint32_t colour) {
|
||||
game::FillRect(device, static_cast<float>(r.x), static_cast<float>(r.y), static_cast<float>(r.x + r.w),
|
||||
static_cast<float>(r.y + r.h), colour);
|
||||
}
|
||||
|
||||
void Text(void* device, const UiRect& rect, const std::wstring& text, uint32_t colour, uint32_t flags) {
|
||||
game::DrawText(device, kFont, game::CenterLineVertically(kFont, rect), text.c_str(), colour, flags);
|
||||
}
|
||||
|
||||
UiRect Inset(const UiRect& r, int by) {
|
||||
return {r.x + by, r.y + by, static_cast<int16_t>(r.w - 2 * by), static_cast<int16_t>(r.h - 2 * by)};
|
||||
}
|
||||
|
||||
UiRect Panel::Draw(void* device, const UiRect& rect, const std::wstring& heading) {
|
||||
if (!frame_ || rect.x != rect_.x || rect.y != rect_.y || rect.w != rect_.w || rect.h != rect_.h) {
|
||||
if (frame_) {
|
||||
game::DeleteControl(frame_);
|
||||
}
|
||||
frame_ = game::NewFrame(name_, rect);
|
||||
rect_ = rect;
|
||||
}
|
||||
Fill(device, rect, kPanelBackdrop);
|
||||
if (frame_) {
|
||||
game::ControlRender(frame_, device);
|
||||
}
|
||||
UiRect content = Inset(rect, kPanelPadding);
|
||||
if (!heading.empty()) {
|
||||
Text(device, {content.x, content.y, content.w, kHeadingHeight}, heading, kHeadingColour);
|
||||
content.y += kHeadingHeight;
|
||||
content.h = static_cast<int16_t>(content.h - kHeadingHeight);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
void Button::Draw(void* device, const uint8_t* model, const UiRect& rect) {
|
||||
if (!control_) {
|
||||
captionId_ = game::RegisterText(caption_.c_str());
|
||||
control_ = game::NewButtonLike(model, rect, captionId_, name_);
|
||||
if (!control_) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
rect_ = rect;
|
||||
*reinterpret_cast<UiRect*>(control_ + game::kControlRect) = rect;
|
||||
game::ControlRender(control_, device);
|
||||
drawnFrame_ = g_frame;
|
||||
}
|
||||
|
||||
bool Button::Clicked(void* event) {
|
||||
if (!control_ || drawnFrame_ != g_frame) {
|
||||
return false;
|
||||
}
|
||||
game::ControlHandleEvent(control_, event);
|
||||
return game::MouseEventKind(event) == game::MouseEvent::ButtonDown &&
|
||||
game::EventField(event, game::kMouseButton) == game::kLeftButton &&
|
||||
game::Inside(rect_, game::EventField(event, game::kMouseButtonX),
|
||||
game::EventField(event, game::kMouseButtonY));
|
||||
}
|
||||
|
||||
void Button::SetCaption(const std::wstring& caption) {
|
||||
caption_ = caption;
|
||||
if (captionId_) {
|
||||
game::SetTextOverride(captionId_, caption_);
|
||||
}
|
||||
}
|
||||
|
||||
int16_t ButtonHeight(const uint8_t* model) {
|
||||
return reinterpret_cast<const UiRect*>(model + game::kControlRect)->h;
|
||||
}
|
||||
|
||||
bool DoubleClick::Click(int target) {
|
||||
const unsigned long now = GetTickCount();
|
||||
const bool twice = target == last_ && now - at_ <= GetDoubleClickTime();
|
||||
last_ = twice ? -1 : target;
|
||||
at_ = now;
|
||||
return twice;
|
||||
}
|
||||
|
||||
} // namespace mods::screen
|
||||
@@ -0,0 +1,105 @@
|
||||
#pragma once
|
||||
// 16:9 menu screens (request of 2026-10-20, docs/screens.md): a screen of our
|
||||
// own replaces a full-screen window of the game's (its render and mouse
|
||||
// handling), laid out on a 1365x768 canvas (1920x1080 in UI units), centred in
|
||||
// the render width; wider screens only get more background at the sides. The
|
||||
// look is the game's: its frames and buttons on a dark background of ours.
|
||||
//
|
||||
// A screen hooks its window's render and event methods and calls Render /
|
||||
// Handle with a body that draws / handles in canvas coordinates with the
|
||||
// helpers below.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "game/ui/text.h"
|
||||
#include "mods/hud.h"
|
||||
|
||||
namespace mods::screen {
|
||||
|
||||
using game::UiRect;
|
||||
|
||||
constexpr int kWidth = hud::kCanvasWidth;
|
||||
constexpr int kHeight = hud::kCanvasHeight;
|
||||
|
||||
// Spacing: the game's frames reach about 0x20 beyond their rects, buttons
|
||||
// about 0x21 (measured on the options window).
|
||||
constexpr int kFrameOrnament = 0x20;
|
||||
// The frames are made of pieces of 64: other sizes leave gaps (the game's
|
||||
// frames are 384x128, 384x64; screenshot 2026-10-20).
|
||||
constexpr int kFrameStep = 64;
|
||||
constexpr int kButtonOrnament = 0x21;
|
||||
constexpr int kMargin = kFrameOrnament + 16; // canvas edge to a panel's rect
|
||||
constexpr int kPanelGap = 96; // between two panels' rects, as the options window's columns
|
||||
constexpr int kPanelPadding = 16; // panel rect to its content
|
||||
|
||||
constexpr uint16_t kFont = 1;
|
||||
constexpr uint32_t kTextColour = 0xffe6d6aa; // as the options' texts
|
||||
constexpr uint32_t kHeadingColour = 0xfff0c060; // as the options' headings
|
||||
constexpr uint32_t kDimColour = 0xff8c8474;
|
||||
constexpr uint32_t kRowColour = 0x40302418;
|
||||
constexpr uint32_t kRowHoverColour = 0x80403020;
|
||||
constexpr uint32_t kRowSelectedColour = 0xc0604818;
|
||||
|
||||
// Draws the screen of `window`: the background across the whole render
|
||||
// target, then body(context) in canvas coordinates. While `window` is shown
|
||||
// the main menu thread renders the full width (widescreen::SetWideMenuScreen).
|
||||
int Render(void* window, void* device, int (*body)(void* context), void* context);
|
||||
// Runs body(context) with the event's mouse position in canvas coordinates.
|
||||
int Handle(void* event, int (*body)(void* context), void* context);
|
||||
|
||||
void Fill(void* device, const UiRect& rect, uint32_t colour);
|
||||
// One line of text in `rect`, centred vertically (game::kTextCenter /
|
||||
// kTextRight for horizontal alignment).
|
||||
void Text(void* device, const UiRect& rect, const std::wstring& text, uint32_t colour, uint32_t flags = 0);
|
||||
|
||||
UiRect Inset(const UiRect& rect, int by);
|
||||
|
||||
// A panel: a dark backdrop with the game's frame around `rect` (made anew when
|
||||
// the rect changes) and an optional heading at its top.
|
||||
class Panel {
|
||||
public:
|
||||
explicit Panel(const char* name) : name_(name) {}
|
||||
// The content area below the heading.
|
||||
UiRect Draw(void* device, const UiRect& rect, const std::wstring& heading = {});
|
||||
|
||||
private:
|
||||
const char* name_;
|
||||
UiRect rect_ = {};
|
||||
uint8_t* frame_ = nullptr;
|
||||
};
|
||||
|
||||
// A button of the game's (look and sounds of `model`, a button of the
|
||||
// window), made on first use.
|
||||
class Button {
|
||||
public:
|
||||
Button(const char* name, std::wstring caption) : name_(name), caption_(std::move(caption)) {}
|
||||
void Draw(void* device, const uint8_t* model, const UiRect& rect);
|
||||
// Passes the event to the button (pressed look, sound); true for a left
|
||||
// click on it. Only for buttons drawn this frame.
|
||||
bool Clicked(void* event);
|
||||
void SetCaption(const std::wstring& caption);
|
||||
|
||||
private:
|
||||
const char* name_;
|
||||
std::wstring caption_;
|
||||
uint32_t captionId_ = 0;
|
||||
uint8_t* control_ = nullptr;
|
||||
UiRect rect_ = {};
|
||||
unsigned drawnFrame_ = 0;
|
||||
};
|
||||
|
||||
// Height of the model's buttons (as the window made them).
|
||||
int16_t ButtonHeight(const uint8_t* model);
|
||||
|
||||
// Two clicks on the same thing within the system's double-click time.
|
||||
class DoubleClick {
|
||||
public:
|
||||
bool Click(int target);
|
||||
|
||||
private:
|
||||
int last_ = -1;
|
||||
unsigned long at_ = 0;
|
||||
};
|
||||
|
||||
} // namespace mods::screen
|
||||
@@ -70,6 +70,7 @@ std::atomic<DWORD> g_menuThread = 0;
|
||||
// menu is showing. UI input is processed outside the menu thread.
|
||||
std::atomic<bool> g_lastSceneClassic = false;
|
||||
thread_local int g_loadingScreenDepth = 0;
|
||||
std::atomic<bool> g_wideMenuScreen = false;
|
||||
|
||||
// D3DVIEWPORT7 (d3d.h is not in current Windows SDKs).
|
||||
struct Viewport7 {
|
||||
@@ -346,7 +347,11 @@ bool IsClassicFrame() {
|
||||
if (g_renderWidth == 0) {
|
||||
return false;
|
||||
}
|
||||
return g_loadingScreenDepth > 0 || GetCurrentThreadId() == g_menuThread.load();
|
||||
return g_loadingScreenDepth > 0 || (GetCurrentThreadId() == g_menuThread.load() && !g_wideMenuScreen);
|
||||
}
|
||||
|
||||
void SetWideMenuScreen(bool shown) {
|
||||
g_wideMenuScreen = shown;
|
||||
}
|
||||
|
||||
} // namespace mods::widescreen
|
||||
|
||||
@@ -22,4 +22,9 @@ int RenderWidth();
|
||||
// regardless of the calling thread.
|
||||
bool MenuIsShowing();
|
||||
|
||||
// While a 16:9 menu screen (mods/screen.h) is shown from the main menu, the
|
||||
// menu thread renders and presents the full render size instead of the
|
||||
// classic 1024x768.
|
||||
void SetWideMenuScreen(bool shown);
|
||||
|
||||
} // namespace mods::widescreen
|
||||
Reference in new issue
Block a user