From 4556187b5c63f165cb527ec67febd4ea6dfb6472 Mon Sep 17 00:00:00 2001 From: Cyd Date: Thu, 16 Jul 2026 22:07:41 -0500 Subject: [PATCH] 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 --- game/btl4main.cpp | 25 +-- game/reconstructed/btl4fe.cpp | 280 +++++++++++++++++++++++++++++++++- 2 files changed, 290 insertions(+), 15 deletions(-) diff --git a/game/btl4main.cpp b/game/btl4main.cpp index dd83e4a..856ea6a 100644 --- a/game/btl4main.cpp +++ b/game/btl4main.cpp @@ -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 + } } } diff --git a/game/reconstructed/btl4fe.cpp b/game/reconstructed/btl4fe.cpp index 2ea0c6c..fcc8219 100644 --- a/game/reconstructed/btl4fe.cpp +++ b/game/reconstructed/btl4fe.cpp @@ -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; }