diff --git a/RP_L4/RPL4FE.cpp b/RP_L4/RPL4FE.cpp index 2b0c4ee..9be2abf 100644 --- a/RP_L4/RPL4FE.cpp +++ b/RP_L4/RPL4FE.cpp @@ -250,6 +250,8 @@ namespace char vehicle[24]; char color[16]; char badge[24]; + char team[32]; // football pick, "" = assign for them + char position[16]; }; // lobby-fed override (real personas + loadouts); empty = env parsing @@ -273,6 +275,8 @@ namespace gHostedPilots[i].color[0] ? gHostedPilots[i].color : "Red"); strcpy(extras[i].badge, gHostedPilots[i].badge[0] ? gHostedPilots[i].badge : "None"); + strcpy(extras[i].team, gHostedPilots[i].team); + strcpy(extras[i].position, gHostedPilots[i].position); } return gHostedPilotCount; } @@ -324,6 +328,7 @@ namespace } ExtraPilot *pilot = &extras[extra_count]; + memset(pilot, 0, sizeof(*pilot)); sprintf(pilot->address, "%s:%d", entry, console_port + 1); sprintf(pilot->name, "PILOT %d", extra_count + 2); strcpy(pilot->vehicle, kVehicles[ @@ -797,6 +802,7 @@ namespace const char *badge; int team; // kTeams index, -1 outside football const char *position; + Logical chosePosition; // picked it themselves }; PilotEntry pilots[maxExtraPilots + 1]; int pilot_count = 0; @@ -809,6 +815,7 @@ namespace owner->badge = kBadges[fe->selection[GroupBadge]].key; owner->team = -1; owner->position = NULL; + owner->chosePosition = False; for (int p = 0; p < extra_count; ++p) { PilotEntry *pilot = &pilots[pilot_count++]; @@ -819,47 +826,121 @@ namespace pilot->badge = extras[p].badge; pilot->team = -1; pilot->position = NULL; + pilot->chosePosition = False; } // - // Football: assign teams and positions, derive colors + // Football: every pilot's own team/position pick is honored; + // unset picks get filled in, then each team is normalized to + // exactly one runner (the console's rule). // - int teams_used[2] = { 0, 1 }; if (football) { - teams_used[0] = fe->selection[GroupTeam]; - teams_used[1] = (teams_used[0] + 1) % FE_COUNT(kTeams); - - int runner_on_team[2] = { 0, 0 }; - int line_position[2] = { 0, 0 }; // crusher/blocker rotation - - owner->team = 0; - owner->position = kPositions[fe->selection[GroupPosition]].key; - if (strcmp(owner->position, "runner") == 0) - { - runner_on_team[0] = 1; - } - + // + // 1. Teams: the host's pick, then each member's, then + // alternate whoever did not choose across the two + // most-used teams so the sides stay balanced. + // + owner->team = fe->selection[GroupTeam]; for (int p = 1; p < pilot_count; ++p) { - int side = p % 2; // alternate: B, A, B, A... - side = (side == 1) ? 1 : 0; - pilots[p].team = side; - if (!runner_on_team[side]) + for (int t = 0; t < FE_COUNT(kTeams); ++t) { - pilots[p].position = "runner"; - runner_on_team[side] = 1; - } - else - { - pilots[p].position = - (line_position[side]++ % 2) ? "blocker" : "crusher"; + if (strcmp(extras[p - 1].team, kTeams[t].key) == 0) + { + pilots[p].team = t; + break; + } } } + int fallback_team = (owner->team + 1) % FE_COUNT(kTeams); + int unassigned = 0; + for (int p = 1; p < pilot_count; ++p) + { + if (pilots[p].team < 0) + { + pilots[p].team = (unassigned++ % 2) ? owner->team : fallback_team; + } + } + + // + // 2. Positions: honor each pick, default the rest to the + // line, then give every team exactly one runner. + // + for (int p = 1; p < pilot_count; ++p) + { + const char *pick = extras[p - 1].position; + for (int i = 0; i < FE_COUNT(kPositions); ++i) + { + if (strcmp(pick, kPositions[i].key) == 0) + { + pilots[p].position = kPositions[i].key; + pilots[p].chosePosition = True; + break; + } + } + } + owner->position = kPositions[fe->selection[GroupPosition]].key; + owner->chosePosition = True; + + for (int t = 0; t < FE_COUNT(kTeams); ++t) + { + int members = 0, runner = -1, spare = -1, line = 0; + for (int p = 0; p < pilot_count; ++p) + { + if (pilots[p].team != t) + { + continue; + } + ++members; + if (spare < 0 && !pilots[p].chosePosition) + { + spare = p; // nobody's plans to disturb + } + if (pilots[p].position != NULL && + strcmp(pilots[p].position, "runner") == 0) + { + if (runner < 0) + { + runner = p; // first runner keeps the ball + } + else + { + // two pilots claimed it - the later one lines up + pilots[p].position = NULL; + pilots[p].chosePosition = False; + } + } + } + if (members == 0) + { + continue; + } + if (runner < 0 && spare >= 0) + { + // no volunteer: the first pilot without a pick of + // their own runs (explicit picks are never overridden) + pilots[spare].position = "runner"; + runner = spare; + } + for (int p = 0; p < pilot_count; ++p) + { + if (pilots[p].team == t && p != runner && + pilots[p].position == NULL) + { + pilots[p].position = (line++ % 2) ? "crusher" : "blocker"; + } + } + } + + // + // 3. Colors follow team and position (runner wears the + // team's runner color - that is how you spot the ball) + // for (int p = 0; p < pilot_count; ++p) { - const TeamEntry *team = &kTeams[teams_used[pilots[p].team]]; + const TeamEntry *team = &kTeams[pilots[p].team]; pilots[p].color = (strcmp(pilots[p].position, "runner") == 0) ? team->runnerColor : team->teamColor; @@ -921,7 +1002,7 @@ namespace egg += line; if (football) { - sprintf(line, "team=team::%s\n", kTeams[teams_used[pilots[p].team]].key); + sprintf(line, "team=team::%s\n", kTeams[pilots[p].team].key); egg += line; sprintf(line, "position=%s\n", pilots[p].position); egg += line; @@ -933,24 +1014,26 @@ namespace // if (football) { - Logical team_present[2] = { False, False }; - for (int p = 0; p < pilot_count; ++p) - { - team_present[pilots[p].team] = True; - } - std::string teams_list = "[teams]\n"; std::string team_sections, pilots_sections; std::string teambitmap_list = "[teambitmap]\n"; std::string teambitmap_sections; int bitmap_index = 1; - for (int side = 0; side < 2; ++side) + for (int side = 0; side < FE_COUNT(kTeams); ++side) { - if (!team_present[side]) + Logical team_present = False; + for (int p = 0; p < pilot_count; ++p) + { + if (pilots[p].team == side) + { + team_present = True; + } + } + if (!team_present) { continue; } - const char *team_key = kTeams[teams_used[side]].key; + const char *team_key = kTeams[side].key; sprintf(line, "team=team::%s\n", team_key); teams_list += line; sprintf(line, "[team::%s]\nbitmapindex=%d\npilots=pilots::%s\n", @@ -1297,6 +1380,102 @@ void strcpy(badge, kBadges[b].key); } +//######################################################################## +// Football team / position picks (the lobby publishes and cycles these) +//######################################################################## + +int + RPL4FrontEnd_TeamCount() +{ + return FE_COUNT(kTeams); +} + +const char * + RPL4FrontEnd_TeamName(int index) +{ + if (index < 0 || index >= FE_COUNT(kTeams)) + { + return ""; + } + return kTeams[index].name; +} + +const char * + RPL4FrontEnd_TeamKey(int index) +{ + if (index < 0 || index >= FE_COUNT(kTeams)) + { + return ""; + } + return kTeams[index].key; +} + +int + RPL4FrontEnd_PositionCount() +{ + return FE_COUNT(kPositions); +} + +const char * + RPL4FrontEnd_PositionName(int index) +{ + if (index < 0 || index >= FE_COUNT(kPositions)) + { + return ""; + } + return kPositions[index].name; +} + +const char * + RPL4FrontEnd_PositionKey(int index) +{ + if (index < 0 || index >= FE_COUNT(kPositions)) + { + return ""; + } + return kPositions[index].key; +} + +int + RPL4FrontEnd_GetTeamIndex() +{ + return gHavePersist ? gPersistSelection[GroupTeam] : 0; +} + +void + RPL4FrontEnd_SetTeamIndex(int index) +{ + if (index < 0 || index >= FE_COUNT(kTeams)) + { + return; + } + gPersistSelection[GroupTeam] = index; + gHavePersist = True; +} + +int + RPL4FrontEnd_GetPositionIndex() +{ + return gHavePersist ? gPersistSelection[GroupPosition] : 0; +} + +void + RPL4FrontEnd_SetPositionIndex(int index) +{ + if (index < 0 || index >= FE_COUNT(kPositions)) + { + return; + } + gPersistSelection[GroupPosition] = index; + gHavePersist = True; +} + +Logical + RPL4FrontEnd_IsFootballSelected() +{ + return gHavePersist && IsFootball(gPersistSelection); +} + void RPL4FrontEnd_SetHostedPilots( const char *owner_address, diff --git a/RP_L4/RPL4FE.h b/RP_L4/RPL4FE.h index a8ccd8f..fb30715 100644 --- a/RP_L4/RPL4FE.h +++ b/RP_L4/RPL4FE.h @@ -50,6 +50,39 @@ int void RPL4FrontEnd_GetLoadout(char *vehicle, char *color, char *badge); +//--------------------------------------------------------------- +// Football team / position: the catalogs, and this player's picks. +// The lobby publishes the picks as member data and lets members +// cycle them in the room; the host's egg builder honors them. +//--------------------------------------------------------------- +int + RPL4FrontEnd_TeamCount(); +const char * + RPL4FrontEnd_TeamName(int index); +const char * + RPL4FrontEnd_TeamKey(int index); + +int + RPL4FrontEnd_PositionCount(); +const char * + RPL4FrontEnd_PositionName(int index); +const char * + RPL4FrontEnd_PositionKey(int index); + +int + RPL4FrontEnd_GetTeamIndex(); +void + RPL4FrontEnd_SetTeamIndex(int index); +int + RPL4FrontEnd_GetPositionIndex(); +void + RPL4FrontEnd_SetPositionIndex(int index); + +// True when this player's setup menu has Football selected (the host's +// pick decides the mission; members use it to know what to show). +Logical + RPL4FrontEnd_IsFootballSelected(); + // Hosted-race pilots fed by the Steam lobby: overrides the // RP412HOSTPODS parsing with real personas and loadouts. owner_address // is this pod's mesh IP (the FakeIP); count 0 clears the override. @@ -60,6 +93,9 @@ struct FEHostedPilot char vehicle[24]; char color[16]; char badge[24]; + // football: the member's own picks, empty = assign one for them + char team[32]; // team key ("Red/Pink", ...) + char position[16]; // "runner" / "crusher" / "blocker" }; void RPL4FrontEnd_SetHostedPilots( diff --git a/RP_L4/RPL4LOBBY.cpp b/RP_L4/RPL4LOBBY.cpp index 3e9fcba..575b70d 100644 --- a/RP_L4/RPL4LOBBY.cpp +++ b/RP_L4/RPL4LOBBY.cpp @@ -45,6 +45,7 @@ namespace int gLastGoNonce = 0; // launches we already answered int gShownResultsNonce = 0; // score sheets we already displayed const char kResultsKey[] = "res"; + const char kScenarioKey[] = "sc"; // async call-result plumbing Logical gCallDone = False; @@ -120,6 +121,12 @@ namespace } } + Logical IsOwner() + { + return gInLobby && + SteamMatchmaking()->GetLobbyOwner(gLobby) == SteamUser()->GetSteamID(); + } + void PublishMemberData() { char value[64]; @@ -140,12 +147,30 @@ namespace SteamMatchmaking()->SetLobbyMemberData(gLobby, "vh", vehicle); SteamMatchmaking()->SetLobbyMemberData(gLobby, "cl", color); SteamMatchmaking()->SetLobbyMemberData(gLobby, "bd", badge); + + // football: this member's own team and position pick + SteamMatchmaking()->SetLobbyMemberData(gLobby, "tm", + RPL4FrontEnd_TeamKey(RPL4FrontEnd_GetTeamIndex())); + SteamMatchmaking()->SetLobbyMemberData(gLobby, "ps", + RPL4FrontEnd_PositionKey(RPL4FrontEnd_GetPositionIndex())); + + // the owner also publishes the scenario, so members know + // whether their team/position pick matters + if (IsOwner()) + { + SteamMatchmaking()->SetLobbyData(gLobby, kScenarioKey, + RPL4FrontEnd_IsFootballSelected() ? "football" : "race"); + } } - Logical IsOwner() + Logical FootballLobby() { - return gInLobby && - SteamMatchmaking()->GetLobbyOwner(gLobby) == SteamUser()->GetSteamID(); + if (!gInLobby) + { + return False; + } + const char *scenario = SteamMatchmaking()->GetLobbyData(gLobby, kScenarioKey); + return (scenario != NULL && strcmp(scenario, "football") == 0); } //--------------------------------------------------------------- @@ -162,6 +187,8 @@ namespace char vehicle[24]; char color[16]; char badge[24]; + char team[32]; // football pick + char position[16]; Logical published; }; @@ -196,6 +223,12 @@ namespace strncpy(member->badge, SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "bd"), sizeof(member->badge) - 1); + strncpy(member->team, + SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "tm"), + sizeof(member->team) - 1); + strncpy(member->position, + SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "ps"), + sizeof(member->position) - 1); member->published = member->ip[0] != '\0' && member->consolePort > 0 && member->gamePort > 0; } @@ -241,6 +274,8 @@ namespace strncpy(pilot->vehicle, members[i].vehicle, sizeof(pilot->vehicle) - 1); strncpy(pilot->color, members[i].color, sizeof(pilot->color) - 1); strncpy(pilot->badge, members[i].badge, sizeof(pilot->badge) - 1); + strncpy(pilot->team, members[i].team, sizeof(pilot->team) - 1); + strncpy(pilot->position, members[i].position, sizeof(pilot->position) - 1); if (pods[0] != '\0') { @@ -274,8 +309,11 @@ namespace HFONT titleFont; RECT launchRect; // owner only RECT leaveRect; + RECT teamRect; // football: cycle my team + RECT positionRect; // football: cycle my position Logical launchClicked; Logical leaveClicked; + Logical pickChanged; Logical closed; MemberInfo members[kMaxLobbyMembers]; int memberCount; @@ -303,8 +341,9 @@ namespace RECT title = client; title.top = client.bottom / 28; title.bottom = title.top + client.bottom / 10; - DrawTextA(mem, "STEAM RACE LOBBY", -1, &title, - DT_CENTER | DT_TOP | DT_SINGLELINE); + DrawTextA(mem, + FootballLobby() ? "STEAM FOOTBALL LOBBY" : "STEAM RACE LOBBY", + -1, &title, DT_CENTER | DT_TOP | DT_SINGLELINE); SelectObject(mem, room->textFont); int row_h = client.bottom / 16; @@ -328,7 +367,25 @@ namespace is_owner_row ? " [HOST]" : ""); DrawTextA(mem, text, -1, &row, DT_LEFT | DT_VCENTER | DT_SINGLELINE); - if (member->vehicle[0] != '\0') + if (FootballLobby()) + { + // football: the roster is a team sheet + if (member->team[0] != '\0') + { + const char *position_name = "?"; + for (int i = 0; i < RPL4FrontEnd_PositionCount(); ++i) + { + if (strcmp(member->position, RPL4FrontEnd_PositionKey(i)) == 0) + { + position_name = RPL4FrontEnd_PositionName(i); + break; + } + } + sprintf(text, "%s - %s", member->team, position_name); + DrawTextA(mem, text, -1, &row, DT_RIGHT | DT_VCENTER | DT_SINGLELINE); + } + } + else if (member->vehicle[0] != '\0') { sprintf(text, "%s / %s", member->vehicle, member->color); DrawTextA(mem, text, -1, &row, DT_RIGHT | DT_VCENTER | DT_SINGLELINE); @@ -347,11 +404,30 @@ namespace -1, &hint, DT_CENTER | DT_VCENTER | DT_SINGLELINE); HBRUSH frame = CreateSolidBrush(kGreenBright); + + // + // Football: my own team and position, click to cycle + // + if (FootballLobby()) + { + FrameRect(mem, &room->teamRect, frame); + SetTextColor(mem, kGreenBright); + sprintf(text, "MY TEAM: %s", + RPL4FrontEnd_TeamName(RPL4FrontEnd_GetTeamIndex())); + DrawTextA(mem, text, -1, &room->teamRect, + DT_CENTER | DT_VCENTER | DT_SINGLELINE); + + FrameRect(mem, &room->positionRect, frame); + sprintf(text, "MY POSITION: %s", + RPL4FrontEnd_PositionName(RPL4FrontEnd_GetPositionIndex())); + DrawTextA(mem, text, -1, &room->positionRect, + DT_CENTER | DT_VCENTER | DT_SINGLELINE); + } if (IsOwner()) { FrameRect(mem, &room->launchRect, frame); SetTextColor(mem, kGreenBright); - DrawTextA(mem, "L A U N C H R A C E", -1, &room->launchRect, + DrawTextA(mem, "L A U N C H G A M E", -1, &room->launchRect, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } FrameRect(mem, &room->leaveRect, frame); @@ -395,6 +471,18 @@ namespace { room->leaveClicked = True; } + else if (FootballLobby() && PtInRect(&room->teamRect, point)) + { + RPL4FrontEnd_SetTeamIndex( + (RPL4FrontEnd_GetTeamIndex() + 1) % RPL4FrontEnd_TeamCount()); + room->pickChanged = True; + } + else if (FootballLobby() && PtInRect(&room->positionRect, point)) + { + RPL4FrontEnd_SetPositionIndex( + (RPL4FrontEnd_GetPositionIndex() + 1) % RPL4FrontEnd_PositionCount()); + room->pickChanged = True; + } return 0; } break; @@ -465,6 +553,16 @@ namespace room.leaveRect.top = client.bottom - 3 * row_h; room.leaveRect.bottom = client.bottom - row_h; + // football pick buttons, above the launch button + room.teamRect.left = (client.right - col_w) / 2; + room.teamRect.right = room.teamRect.left + col_w; + room.teamRect.top = client.bottom - 12 * row_h; + room.teamRect.bottom = room.teamRect.top + 2 * row_h; + room.positionRect.left = room.teamRect.left; + room.positionRect.right = room.teamRect.right; + room.positionRect.top = client.bottom - 9 * row_h; + room.positionRect.bottom = room.positionRect.top + 2 * row_h; + gRoom = &room; PublishMemberData(); @@ -489,6 +587,18 @@ namespace outcome = LobbyRoomClosed; break; } + // + // A pick changed: republish so everyone's roster updates + // + if (room.pickChanged) + { + room.pickChanged = False; + PublishMemberData(); + room.memberCount = CollectMembers(room.members); + InvalidateRect(room.window, NULL, FALSE); + RedrawWindow(room.window, NULL, NULL, RDW_UPDATENOW); + } + if (room.leaveClicked) { SteamMatchmaking()->LeaveLobby(gLobby); diff --git a/docs/RP412-FRONTEND-DESIGN.md b/docs/RP412-FRONTEND-DESIGN.md index b1a9246..a826bbc 100644 --- a/docs/RP412-FRONTEND-DESIGN.md +++ b/docs/RP412-FRONTEND-DESIGN.md @@ -166,10 +166,16 @@ library so a dev console tool and the game share one egg builder. `[teams]` / `[team::X]` / `[pilots::X]` / `[teambitmap]` blocks in RPFootballMission's layout. Colors are derived, not chosen: the runner wears the team's runner color, everyone else the team color. - Multi-pod games alternate pilots between two teams and auto-assign - one runner per team (the host's own position pick is honored). - *Open:* lobby members cannot yet choose their own team/position — - the host's pick seeds a deterministic assignment. + **Every player picks their own team and position** (2026-07-24): + members publish their picks as lobby member data (`tm`/`ps`), the + room shows the roster as a team sheet and gives each member MY TEAM + / MY POSITION buttons that cycle and republish live, and the owner + publishes the scenario (`sc`) so members know when those picks + matter. The egg builder honors every explicit pick and only fills + gaps: unpicked pilots are spread across the sides, a team with no + volunteer runner promotes its first *unpicked* member (explicit + picks are never overridden), and a second pilot claiming runner on + one team is moved to the line. 3. Results screen — **shipped**, on the cockpit displays, with the owner publishing the score sheet to lobby members. 4. Steam model — lobby-owner-as-console **confirmed and verified** on