Front end: the miniconsole menu -- interactive race setup (Workstream B)

The in-game menu that replaces the operator console.  A GDI-painted
green-on-black terminal (RP412 RPL4FE pattern) in its own top-level
window, shown in front-end mode (no -egg/-net) before the mission:

- Catalogs as clickable columns: MAP (8), MECH (8), COLOR (8), TIME,
  WEATHER, LENGTH, plus a PILOT NAME edit box and a LAUNCH button.
  Selected item highlighted; click cycles selections.
- LAUNCH fills a BTFeMission from the selections, writes the egg
  (BTFeMission_WriteEgg -- the existing builder), and points the
  standard -egg load path at it.  Closing the menu quits the process.
- btl4main.cpp: front-end mode runs the menu; menu-close exits.

Verified end to end: no-args launch shows the menu; clicking LAUNCH
builds frontend.egg from the chosen map/mech/color/time/length and the
mission loads and runs (map=grass mech=bhk1 color=White time=day
length=300 from the default selections).

Remaining in Phase 5: the LocalConsole marshal (timed stop + results +
single-binary menu<->mission loop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-16 22:07:41 -05:00
co-authored by Claude Fable 5
parent 59c454ba6c
commit 4556187b5c
2 changed files with 290 additions and 15 deletions
+14 -11
View File
@@ -393,10 +393,11 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
//
// FRONT-END MODE (BT412 Phase 5): no egg and no network address on the
// command line means there is no operator console to hand us a mission.
// Build one locally -- the in-game front end assembles a mission egg from
// the default loadout and points the standard -egg load path at it. The
// interactive on-screen menu and the in-process marshal layer on top of
// this (docs/BT412-ROADMAP.md Phase 5). `-egg`/`-net` runs are untouched.
// Run the in-game miniconsole menu -- the player configures the race
// (map / mech / color / time / weather / length / pilot name) and clicks
// LAUNCH, which builds the mission egg locally and points the standard
// -egg load path at it. Closing the menu quits. `-egg`/`-net` runs skip
// the menu. (docs/BT412-ROADMAP.md Phase 5.)
//
{
const char *cmdline_egg = L4Application::GetEggNotationFileName();
@@ -404,13 +405,15 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
const int no_net = (L4Application::GetNetworkCommonFlatAddress() == 0);
if (no_egg && no_net)
{
extern int BTFrontEnd_Run();
if (BTFrontEnd_Run())
std::cout << "[frontend] built frontend.egg from the default loadout"
<< std::endl << std::flush;
else
std::cout << "[frontend] egg build FAILED -- falling through"
<< std::endl << std::flush;
extern int BTFrontEnd_Run();
if (BTFrontEnd_Run())
std::cout << "[frontend] menu LAUNCH -> frontend.egg built"
<< std::endl << std::flush;
else
{
std::cout << "[frontend] menu closed -- exiting" << std::endl << std::flush;
return 0; // player quit from the setup menu
}
}
}
+276 -4
View File
@@ -333,20 +333,292 @@ int BTFeMission_WriteEgg(const BTFeMission *mission, const char *path)
return 1;
}
//===========================================================================//
// THE MINICONSOLE MENU -- the in-game race setup that replaces the operator
// console. A GDI-painted green-on-black terminal (RP412 RPL4FE pattern): the
// catalogs render as clickable columns, a pilot-name edit box, and a LAUNCH
// button; clicking LAUNCH fills a BTFeMission from the selections and writes
// the egg. Runs in its own top-level window before the mission (front-end
// mode), so no D3D is up yet.
//===========================================================================//
namespace {
struct MenuEntry { const char *key; const char *name; };
const MenuEntry kMenuMaps[] = {
{ "grass", "Grasslands" }, { "cavern", "Cavern" }, { "rav", "Ravine" },
{ "polar3", "Polar III" }, { "polar4", "Polar IV" }, { "arena1", "Arena I" },
{ "arena2", "Arena II" }, { "dbase", "Database" },
};
const MenuEntry kMenuMechs[] = {
{ "bhk1", "Black Hawk" }, { "avatar", "Avatar" }, { "loki", "Loki" },
{ "madcat", "Mad Cat" }, { "owens", "Owens" }, { "sunder", "Sunder" },
{ "thor", "Thor" }, { "vulture","Vulture" },
};
const MenuEntry kMenuColors[] = {
{ "White","White" }, { "Red","Red" }, { "Blue","Blue" }, { "Green","Green" },
{ "Yellow","Yellow" },{ "Orange","Orange" },{ "Purple","Purple" },{ "Cyan","Cyan" },
};
const MenuEntry kMenuTimes[] = { { "day","Day" }, { "night","Night" } };
const MenuEntry kMenuWeather[] = { { "clear","Clear" }, { "fog","Light Fog" }, { "soup","Heavy Fog" } };
struct LenEntry { int seconds; const char *name; };
const LenEntry kMenuLengths[] = { { 180,"3:00" }, { 300,"5:00" }, { 600,"10:00" }, { 900,"15:00" }, { 0,"Endless" } };
#define MENU_COUNT(t) ((int)(sizeof(t)/sizeof((t)[0])))
enum MenuGroup { GMap=0, GMech, GColor, GTime, GWeather, GLength, GLaunch, GCount };
const char *GroupTitle(int g) {
switch (g) {
case GMap: return "MAP"; case GMech: return "MECH"; case GColor: return "COLOR";
case GTime: return "TIME"; case GWeather: return "WEATHER"; case GLength: return "LENGTH";
}
return "";
}
int GroupSize(int g) {
switch (g) {
case GMap: return MENU_COUNT(kMenuMaps); case GMech: return MENU_COUNT(kMenuMechs);
case GColor: return MENU_COUNT(kMenuColors); case GTime: return MENU_COUNT(kMenuTimes);
case GWeather: return MENU_COUNT(kMenuWeather); case GLength: return MENU_COUNT(kMenuLengths);
}
return 0;
}
const char *ItemName(int g, int i) {
switch (g) {
case GMap: return kMenuMaps[i].name; case GMech: return kMenuMechs[i].name;
case GColor: return kMenuColors[i].name; case GTime: return kMenuTimes[i].name;
case GWeather: return kMenuWeather[i].name; case GLength: return kMenuLengths[i].name;
case GLaunch: return "L A U N C H";
}
return "";
}
struct MenuItem { int group, index; RECT rect; };
struct MenuState {
int selection[GCount];
MenuItem items[128];
int itemCount;
HWND window, nameEdit;
HFONT textFont, titleFont;
HBRUSH editBrush;
int launched, closed;
};
MenuState *gMenu = NULL;
const COLORREF kGreenBright = RGB(64, 255, 64);
const COLORREF kGreenDim = RGB(24, 140, 24);
const COLORREF kBlack = RGB(0, 0, 0);
void AddGroupItems(MenuState *m, int group, int x, int *y, int row_h, int col_w) {
int n = GroupSize(group);
for (int i = 0; i < n; ++i) {
MenuItem *it = &m->items[m->itemCount++];
it->group = group; it->index = i;
it->rect.left = x; it->rect.top = *y;
it->rect.right = x + col_w; it->rect.bottom = *y + row_h;
*y += row_h;
}
*y += row_h; // gap + header room for the next group
}
void LayoutMenu(MenuState *m, int cw, int ch) {
m->itemCount = 0;
int row_h = ch / 26; if (row_h < 20) row_h = 20; if (row_h > 34) row_h = 34;
int col_w = cw / 5;
int col1 = cw / 12;
int col2 = col1 + col_w + cw / 24;
int col3 = col2 + col_w + cw / 24;
int top = ch / 7;
int y = top; AddGroupItems(m, GMap, col1, &y, row_h, col_w);
AddGroupItems(m, GTime, col1, &y, row_h, col_w);
AddGroupItems(m, GWeather, col1, &y, row_h, col_w);
AddGroupItems(m, GLength, col1, &y, row_h, col_w);
y = top; AddGroupItems(m, GMech, col2, &y, row_h, col_w);
y = top + 2 * row_h; AddGroupItems(m, GColor, col3, &y, row_h, col_w);
MenuItem *launch = &m->items[m->itemCount++];
launch->group = GLaunch; launch->index = 0;
launch->rect.left = col3; launch->rect.top = ch - 3 * row_h;
launch->rect.right = col3 + col_w; launch->rect.bottom = ch - row_h;
if (m->nameEdit != NULL)
MoveWindow(m->nameEdit, col3, top - row_h / 4, col_w, row_h, TRUE);
}
void PaintMenu(MenuState *m) {
PAINTSTRUCT ps;
HDC hdc = BeginPaint(m->window, &ps);
RECT client; GetClientRect(m->window, &client);
HDC mem = CreateCompatibleDC(hdc);
HBITMAP surf = CreateCompatibleBitmap(hdc, client.right, client.bottom);
HBITMAP oldSurf = (HBITMAP) SelectObject(mem, surf);
FillRect(mem, &client, (HBRUSH) GetStockObject(BLACK_BRUSH));
SetBkMode(mem, TRANSPARENT);
SelectObject(mem, m->titleFont);
SetTextColor(mem, kGreenBright);
RECT title = client; title.top = client.bottom / 26; title.bottom = title.top + client.bottom / 9;
DrawTextA(mem, "B A T T L E T E C H - MISSION SETUP", -1, &title, DT_CENTER | DT_TOP | DT_SINGLELINE);
SelectObject(mem, m->textFont);
int prevGroup = -1;
for (int i = 0; i < m->itemCount; ++i) {
const MenuItem *it = &m->items[i];
if (it->group != prevGroup && it->group < GLaunch) {
prevGroup = it->group;
RECT h = it->rect; h.top -= (it->rect.bottom - it->rect.top); h.bottom = it->rect.top;
SetTextColor(mem, kGreenBright);
DrawTextA(mem, GroupTitle(it->group), -1, &h, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
}
RECT row = it->rect;
if (it->group == GLaunch) {
HBRUSH b = CreateSolidBrush(kGreenBright); FrameRect(mem, &row, b); DeleteObject(b);
SetTextColor(mem, kGreenBright);
DrawTextA(mem, ItemName(it->group, it->index), -1, &row, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
continue;
}
int selected = (m->selection[it->group] == it->index);
if (selected) {
HBRUSH b = CreateSolidBrush(kGreenDim); FillRect(mem, &row, b); DeleteObject(b);
SetTextColor(mem, kGreenBright);
} else SetTextColor(mem, kGreenDim);
RECT t = row; t.left += 8;
DrawTextA(mem, ItemName(it->group, it->index), -1, &t, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
}
// pilot-name header
if (m->nameEdit != NULL) {
RECT er; GetWindowRect(m->nameEdit, &er);
POINT c = { er.left, er.top }; ScreenToClient(m->window, &c);
RECT h; h.left = c.x; h.right = c.x + 300; h.bottom = c.y; h.top = c.y - 26;
SetTextColor(mem, kGreenBright);
DrawTextA(mem, "PILOT NAME", -1, &h, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
}
BitBlt(hdc, 0, 0, client.right, client.bottom, mem, 0, 0, SRCCOPY);
SelectObject(mem, oldSurf); DeleteObject(surf); DeleteDC(mem);
EndPaint(m->window, &ps);
}
void MenuClick(MenuState *m, int x, int y) {
for (int i = 0; i < m->itemCount; ++i) {
const MenuItem *it = &m->items[i];
if (x >= it->rect.left && x < it->rect.right && y >= it->rect.top && y < it->rect.bottom) {
if (it->group == GLaunch) {
m->launched = 1; PostMessageA(m->window, WM_NULL, 0, 0);
} else {
m->selection[it->group] = it->index;
InvalidateRect(m->window, NULL, FALSE);
}
return;
}
}
}
LRESULT CALLBACK MenuWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
MenuState *m = gMenu;
switch (msg) {
case WM_PAINT: if (m) { PaintMenu(m); return 0; } break;
case WM_LBUTTONDOWN: if (m) { MenuClick(m, (int)(short)LOWORD(lp), (int)(short)HIWORD(lp)); return 0; } break;
case WM_CTLCOLOREDIT:
if (m) { SetTextColor((HDC)wp, kGreenBright); SetBkColor((HDC)wp, kBlack); return (LRESULT)m->editBrush; }
break;
case WM_CLOSE: if (m) m->closed = 1; return 0;
case WM_DESTROY: return 0;
}
return DefWindowProcA(hwnd, msg, wp, lp);
}
} // namespace
//---------------------------------------------------------------------------//
// Front-end mode entry. Builds the default solo egg into frontend.egg and
// points the engine at it via the standard -egg path. The interactive menu
// (catalog selection on-screen) and the marshal are layered on top of this.
// Run the menu; on LAUNCH build the egg + point the engine at it. Returns 1
// if a mission was launched, 0 if the player closed the menu (quit).
//---------------------------------------------------------------------------//
int BTFrontEnd_Run()
{
HINSTANCE instance = GetModuleHandleA(NULL);
MenuState m; memset(&m, 0, sizeof(m));
m.selection[GLength] = 1; // default 5:00
gMenu = &m;
static int classReg = 0;
if (!classReg) {
classReg = 1;
WNDCLASSA wc; memset(&wc, 0, sizeof(wc));
wc.style = CS_HREDRAW | CS_VREDRAW;
wc.lpfnWndProc = MenuWndProc;
wc.hInstance = instance;
wc.hCursor = LoadCursor(NULL, IDC_ARROW);
wc.hbrBackground = (HBRUSH) GetStockObject(BLACK_BRUSH);
wc.lpszClassName = "BTFrontEnd";
RegisterClassA(&wc);
}
int W = 1000, H = 720;
int sx = (GetSystemMetrics(SM_CXSCREEN) - W) / 2;
int sy = (GetSystemMetrics(SM_CYSCREEN) - H) / 2;
m.window = CreateWindowExA(0, "BTFrontEnd", "BattleTech",
WS_OVERLAPPEDWINDOW | WS_VISIBLE, sx < 0 ? 0 : sx, sy < 0 ? 0 : sy, W, H,
NULL, NULL, instance, NULL);
if (m.window == NULL) { gMenu = NULL; return 0; }
RECT client; GetClientRect(m.window, &client);
int text_h = client.bottom / 32; if (text_h < 16) text_h = 16; if (text_h > 26) text_h = 26;
m.textFont = CreateFontA(-text_h, 0,0,0, FW_NORMAL, 0,0,0, ANSI_CHARSET,
OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, ANTIALIASED_QUALITY, DEFAULT_PITCH | FF_MODERN, "Consolas");
m.titleFont = CreateFontA(-(text_h * 2), 0,0,0, FW_BOLD, 0,0,0, ANSI_CHARSET,
OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, ANTIALIASED_QUALITY, DEFAULT_PITCH | FF_MODERN, "Consolas");
m.editBrush = CreateSolidBrush(kBlack);
m.nameEdit = CreateWindowExA(0, "EDIT", "Pilot",
WS_CHILD | WS_VISIBLE | WS_BORDER | ES_AUTOHSCROLL, 0,0,10,10, m.window, NULL, instance, NULL);
SendMessageA(m.nameEdit, WM_SETFONT, (WPARAM) m.textFont, TRUE);
SendMessageA(m.nameEdit, EM_SETLIMITTEXT, 22, 0);
LayoutMenu(&m, client.right, client.bottom);
InvalidateRect(m.window, NULL, TRUE);
SetForegroundWindow(m.window);
MSG msg;
while (!m.launched && !m.closed) {
BOOL r = GetMessageA(&msg, NULL, 0, 0);
if (r <= 0) { m.closed = 1; break; }
if (msg.message == WM_QUIT) m.closed = 1;
TranslateMessage(&msg);
DispatchMessageA(&msg);
}
// harvest the loadout
char pilotName[24] = "Pilot";
if (!m.closed) {
GetWindowTextA(m.nameEdit, pilotName, sizeof(pilotName) - 1);
if (pilotName[0] == '\0') strcpy(pilotName, "Pilot");
}
int launched = m.launched, closed = m.closed;
int selMap = m.selection[GMap], selMech = m.selection[GMech], selColor = m.selection[GColor];
int selTime = m.selection[GTime], selWx = m.selection[GWeather], selLen = m.selection[GLength];
DestroyWindow(m.window);
DeleteObject(m.textFont); DeleteObject(m.titleFont); DeleteObject(m.editBrush);
gMenu = NULL;
if (closed || !launched)
return 0;
// Build the mission from the selections.
BTFeMission mission;
BTFeMission_Default(&mission);
strcpy(mission.map, kMenuMaps[selMap].key);
strcpy(mission.timeOfDay, kMenuTimes[selTime].key);
strcpy(mission.weather, kMenuWeather[selWx].key);
mission.lengthSeconds = kMenuLengths[selLen].seconds;
strcpy(mission.pilots[0].name, pilotName);
strcpy(mission.pilots[0].vehicle, kMenuMechs[selMech].key);
strcpy(mission.pilots[0].color, kMenuColors[selColor].key);
const char *egg_path = "frontend.egg";
if (!BTFeMission_WriteEgg(&mission, egg_path))
return 0;
L4Application::SetEggNotationFileName(egg_path);
return 1;
}