// wj_ticcmd.cpp -- main-thread-only. See wj_ticcmd.h and wj_input.h for the
// threading contract. Two jobs, both mirroring existing bot-admission code
// (src/playsim/bots/b_game.cpp FCajunMaster::DoAddBot) but skipping the
// network command stream, since a phone-joined player is always local:
// there is no other real node to keep in sync with (doomcom.numnodes stays
// 1 for this whole feature -- see the plan's non-goals).
//
// 1. Admit phones that finished their WS handshake into a free player
// slot, once a level is actually running.
// 2. Each gametic, turn every remote-input slot's latest buffered stick/
// button state into a ticcmd_t and splice it into netcmds[], exactly
// where DBot::Think() (b_think.cpp) splices in bot commands.
#include "wj_ticcmd.h"
#include "wj_input.h"
#include <cmath>
#include <cstring>
#include <cstdlib>
#include "doomdef.h"
#include "doomstat.h"
#include "d_player.h"
#include "d_net.h"
#include "d_netinf.h"
#include "d_event.h"
#include "g_levellocals.h"
#include "events.h"
#include "zstring.h"
#include "printf.h"
#include "c_cvars.h"
#include "c_dispatch.h"
// Not exposed via a header (g_game.cpp keeps them as plain globals); this
// mirrors the exact scaling the joystick input path uses in G_BuildTiccmd.
extern int forwardmove[2], sidemove[2];
EXTERN_CVAR(Float, maxviewpitch)
namespace
{
void AdmitPendingJoins()
{
if (gamestate != GS_LEVEL)
return; // nothing to spawn into yet; leave the queue for later
std::vector<WJPendingJoin> pending;
{
std::lock_guard<std::mutex> lock(wj_joinQueue.mtx);
if (wj_joinQueue.pending.empty())
return;
pending.swap(wj_joinQueue.pending);
}
std::vector<WJPendingJoin> stillWaiting;
for (const WJPendingJoin &join : pending)
{
int bnum;
for (bnum = 0; bnum < MAXPLAYERS; bnum++)
{
if (!playeringame[bnum])
break;
}
if (bnum == MAXPLAYERS)
{
stillWaiting.push_back(join); // full, try again next tic
continue;
}
// Matches FCajunMaster::DoAddBot (b_game.cpp): DoReborn()'s branch
// for a *singleplayer* death reloads the whole level instead of
// just respawning the player, because `multiplayer` was decided
// once at level start, before any phone had joined. Without
// this, admitting a second player still looks like solo play to
// DoReborn and it reloads MapName instead of spawning slot bnum.
multiplayer = true;
playeringame[bnum] = true;
players[bnum].mo = nullptr;
players[bnum].playerstate = PST_ENTER;
players[bnum].RemoteInputSlot = true;
FString info;
int hue = (bnum * 47) % 360; // spread slot colors out without picking a palette
float h = hue / 60.f, r, g, b;
// Cheap hue->RGB without pulling in the HSV helper header here.
float x = 1 - fabsf(fmodf(h, 2.f) - 1);
if (h < 1) { r = 1; g = x; b = 0; }
else if (h < 2) { r = x; g = 1; b = 0; }
else if (h < 3) { r = 0; g = 1; b = x; }
else if (h < 4) { r = 0; g = x; b = 1; }
else if (h < 5) { r = x; g = 0; b = 1; }
else { r = 1; g = 0; b = x; }
info.Format("\\name\\Phone %d\\color\\%02x %02x %02x", bnum + 1,
int(r * 255), int(g * 255), int(b * 255));
const char *infoPtr = info.GetChars();
D_ReadUserInfoStrings(bnum, (uint8_t **)&infoPtr, false);
// connToken < 0 marks a fake test player from the webjoin_spawn
// CCMD below -- there's no real WS connection to associate, so
// it just sits idle (BuildRemoteTiccmd treats "no connection"
// the same as "phone hasn't sent anything yet": stands still).
if (join.connToken >= 0 && !WJ_OnSlotAssigned(join.connToken, bnum))
{
// The phone vanished between finishing its handshake and
// getting here -- undo the admission, nothing will ever
// drive this slot.
playeringame[bnum] = false;
players[bnum].RemoteInputSlot = false;
continue;
}
primaryLevel->DoReborn(bnum);
primaryLevel->localEventManager->PlayerEntered(bnum, false);
Printf("webjoin: %s joined as player slot %d\n", players[bnum].userinfo.GetName(), bnum);
}
if (!stillWaiting.empty())
{
std::lock_guard<std::mutex> lock(wj_joinQueue.mtx);
wj_joinQueue.pending.insert(wj_joinQueue.pending.end(),
stillWaiting.begin(), stillWaiting.end());
}
}
void BuildRemoteTiccmd(int slot, ticcmd_t *cmd)
{
// Real clients get their pitch clamp from player_t::SendPitchLimits
// (p_user.cpp), but that only ever sends for consoleplayer -- "the
// local player is the only one our data is valid for" -- since it's
// meant to travel over the network command stream to everyone else,
// a step remote-input slots don't go through. It also gets reset to
// 0/0 (fully locked) on every spawn and respawn by the same code
// that resets player_t::RemoteInputSlot (p_mobj.cpp's player spawn
// reset, "will be filled in by PostBeginPlay()/netcode"). Cheapest
// correct fix: just keep reapplying it every tic instead of hooking
// every spawn path.
if (players[slot].mo != nullptr)
{
players[slot].MinPitch = DAngle::fromDeg(-maxviewpitch);
players[slot].MaxPitch = DAngle::fromDeg(maxviewpitch);
}
WJInputPacket packet;
bool connected;
{
WJRemoteInput &ri = wj_remoteInput[slot];
std::lock_guard<std::mutex> lock(ri.mtx);
connected = ri.connected;
packet = ri.packet;
}
memset(cmd, 0, sizeof(*cmd));
if (!connected)
{
// The phone hung up. Leave the slot in the game but idle for
// this tic; AdmitPendingJoins doesn't reclaim it -- dropping a
// player mid-game needs the same care as a real client
// disconnecting, which is follow-up work, not this pass.
return;
}
const float scale = 1.f / 32768.f;
float moveX = packet.moveX * scale;
float moveY = packet.moveY * scale;
float turnX = packet.turnX * scale;
float turnY = packet.turnY * scale;
// G_BuildTiccmd's local input path (g_game.cpp) left-shifts forward/
// sidemove by 8 as its very last step before returning -- easy to
// miss since it's not near where the values are otherwise computed.
// Skipping it doesn't break movement, just makes it ~256x slower
// than it should be, since the walk/run code that later reads
// these fields expects that fixed-point-style scale.
cmd->ucmd.forwardmove = (short)(((int)(forwardmove[1] * moveY)) << 8);
cmd->ucmd.sidemove = (short)(((int)(sidemove[1] * moveX)) << 8);
// Full-speed turn/look rate, matching the joystick input path's
// feel: +/-1 stick deflection turns 180 degrees per second.
// Yaw is negated: positive angle in this engine turns the view
// counterclockwise (left), so dragging the stick right (positive
// turnX) needs a negative yaw delta to turn the view right instead
// of left.
//
// Pitch is NOT negated, and this was gotten wrong once already: per
// DBot::Think (b_think.cpp) -- "actor->Angles.Pitch = oldpitch -
// DAngle::fromDeg(cmd->ucmd.pitch * ...)" -- a positive ucmd.pitch
// *decreases* Angles.Pitch, and per player_t::MinPitch's own comment
// ("negative is up, positive is down") a smaller/more negative
// Angles.Pitch means looking up. So positive ucmd.pitch -> look up,
// matching turnY already being positive for "stick dragged up" once
// the phone page's own -lookStick.y flip (wj_page.h) is accounted
// for. A negation here was previously "confirmed" by eyeballing a
// single screenshot's perspective, which turned out to be an
// unreliable way to judge pitch direction -- this was wrong, and
// inverted look up/down for anyone actually playing.
const float turnUnitsPerSec = 65536.f * 0.5f;
cmd->ucmd.yaw = (short)(-turnX * turnUnitsPerSec / TICRATE);
cmd->ucmd.pitch = (short)(turnY * turnUnitsPerSec / TICRATE);
cmd->ucmd.buttons = packet.buttons &
(BT_ATTACK | BT_USE | BT_JUMP | BT_CROUCH | BT_ALTATTACK | BT_SPEED);
}
}
void WJ_Tick()
{
AdmitPendingJoins();
for (int i = 0; i < MAXPLAYERS; i++)
{
if (!playeringame[i] || !players[i].RemoteInputSlot)
continue;
ticcmd_t *cmd = &netcmds[i][((gametic + 1) / ticdup) % BACKUPTICS];
BuildRemoteTiccmd(i, cmd);
}
}
// Testing aids: add/remove stationary phone-shaped player slots without
// needing an actual phone. Spawned players stand still (no wj_remoteInput
// connection ever marks them connected, which BuildRemoteTiccmd already
// treats as "send nothing") -- good enough to test splitscreen layout,
// admission, and the roster/lobby display without a real device.
CCMD(webjoin_spawn)
{
int count = argv.argc() > 1 ? atoi(argv[1]) : 1;
if (count < 1) count = 1;
std::lock_guard<std::mutex> lock(wj_joinQueue.mtx);
for (int i = 0; i < count; i++)
wj_joinQueue.pending.push_back(WJPendingJoin{ -1, std::string() });
}
CCMD(webjoin_despawn)
{
int count = argv.argc() > 1 ? atoi(argv[1]) : 1;
if (count < 1) count = 1;
int removed = 0;
for (int i = MAXPLAYERS - 1; i >= 0 && removed < count; i--)
{
if (playeringame[i] && players[i].RemoteInputSlot && players[i].playerstate != PST_GONE)
{
// G_Ticker (called right after WJ_Tick each gametic) pops any
// player in this state on its own -- see G_DoPlayerPop in
// g_game.cpp -- so this is the same path a real disconnect
// takes, not a shortcut around it.
players[i].playerstate = PST_GONE;
removed++;
}
}
Printf("webjoin: despawned %d player%s\n", removed, removed == 1 ? "" : "s");
}