2225 lines
73 KiB
C++

// Released under the MIT License. See LICENSE for details.
#include "ballistica/game/game.h"
#include "ballistica/app/app.h"
#include "ballistica/app/app_config.h"
#include "ballistica/audio/audio.h"
#include "ballistica/core/thread.h"
#include "ballistica/dynamics/bg/bg_dynamics.h"
#include "ballistica/game/account.h"
#include "ballistica/game/connection/connection_set.h"
#include "ballistica/game/connection/connection_to_client_udp.h"
#include "ballistica/game/connection/connection_to_host_udp.h"
#include "ballistica/game/friend_score_set.h"
#include "ballistica/game/host_activity.h"
#include "ballistica/game/player.h"
#include "ballistica/game/score_to_beat.h"
#include "ballistica/game/session/client_session.h"
#include "ballistica/game/session/host_session.h"
#include "ballistica/game/session/net_client_session.h"
#include "ballistica/game/session/replay_client_session.h"
#include "ballistica/generic/json.h"
#include "ballistica/generic/timer.h"
#include "ballistica/graphics/graphics.h"
#include "ballistica/graphics/graphics_server.h"
#include "ballistica/graphics/text/text_graphics.h"
#include "ballistica/input/device/client_input_device.h"
#include "ballistica/input/device/keyboard_input.h"
#include "ballistica/input/device/touch_input.h"
#include "ballistica/networking/network_write_module.h"
#include "ballistica/networking/sockaddr.h"
#include "ballistica/networking/telnet_server.h"
#include "ballistica/python/python.h"
#include "ballistica/python/python_command.h"
#include "ballistica/python/python_context_call.h"
#include "ballistica/python/python_sys.h"
#include "ballistica/scene/node/globals_node.h"
#include "ballistica/ui/console.h"
#include "ballistica/ui/root_ui.h"
#include "ballistica/ui/ui.h"
#include "ballistica/ui/widget/root_widget.h"
#include "ballistica/ui/widget/text_widget.h"
namespace ballistica {
/// How long a kick vote lasts.
const int kKickVoteDuration = 30000;
/// How long everyone has to wait to start a new kick vote after a failed one.
const int kKickVoteFailRetryDelay = 60000;
/// Extra delay for the initiator of a failed vote.
const int kKickVoteFailRetryDelayInitiatorExtra = 120000;
// Minimum clients that must be present for a kick vote to count.
// (for non-headless builds we require more votes since the host doesn't count
// but may be playing (in a 2on2 with 3 clients, don't want 2 clients able to
// kick).
// NOLINTNEXTLINE(cert-err58-cpp)
const int kKickVoteMinimumClients = (g_buildconfig.headless_build() ? 3 : 4);
const int kMaxChatMessages = 40;
// Go with 5 minute ban.
const int kKickBanSeconds = 5 * 60;
Game::Game(Thread* thread)
: Module("game", thread),
game_roster_(cJSON_CreateArray()),
realtimers_(new TimerList()),
connections_(std::make_unique<ConnectionSet>()) {
assert(g_game == nullptr);
g_game = this;
try {
// Spin up some other game-thread-based stuff.
AppConfig::Init();
assert(g_graphics == nullptr);
g_graphics = g_platform->CreateGraphics();
TextGraphics::Init();
Media::Init();
Audio::Init();
if (!HeadlessMode()) {
BGDynamics::Init();
}
InitSpecialChars();
Context::Init();
// Waaah does UI need to be a bs::Object?
// Update: yes it does in order to be a context target.
// (need to be able to create weak-refs to it).
assert(g_ui == nullptr);
g_ui = Object::NewUnmanaged<UI>();
assert(g_networking == nullptr);
g_networking = new Networking();
assert(g_input == nullptr);
g_input = new Input();
// Init python and apply our settings immediately.
// This way we can get started loading stuff in the background
// and it'll come in with the correct texture quality etc.
assert(g_python == nullptr);
g_python = new Python();
g_python->Reset(true);
// We're the thread that 'owns' python so we need to wrangle the GIL.
thread->SetOwnsPython();
} catch (const std::exception& e) {
// If anything went wrong, trigger a deferred error.
// This way it is more likely we can show a fatal error dialog
// since the main thread won't be blocking waiting for us to init.
std::string what = e.what();
PushCall([what] {
// Just throw a standard exception since our what already
// contains a stack trace; if we throw an Exception we wind
// up with a useless second one.
throw std::logic_error(what.c_str());
});
}
}
void Game::InitSpecialChars() {
std::lock_guard<std::mutex> lock(special_char_mutex_);
special_char_strings_[SpecialChar::kDownArrow] = "\xee\x80\x84";
special_char_strings_[SpecialChar::kUpArrow] = "\xee\x80\x83";
special_char_strings_[SpecialChar::kLeftArrow] = "\xee\x80\x81";
special_char_strings_[SpecialChar::kRightArrow] = "\xee\x80\x82";
special_char_strings_[SpecialChar::kTopButton] = "\xee\x80\x86";
special_char_strings_[SpecialChar::kLeftButton] = "\xee\x80\x85";
special_char_strings_[SpecialChar::kRightButton] = "\xee\x80\x87";
special_char_strings_[SpecialChar::kBottomButton] = "\xee\x80\x88";
special_char_strings_[SpecialChar::kDelete] = "\xee\x80\x89";
special_char_strings_[SpecialChar::kShift] = "\xee\x80\x8A";
special_char_strings_[SpecialChar::kBack] = "\xee\x80\x8B";
special_char_strings_[SpecialChar::kLogoFlat] = "\xee\x80\x8C";
special_char_strings_[SpecialChar::kRewindButton] = "\xee\x80\x8D";
special_char_strings_[SpecialChar::kPlayPauseButton] = "\xee\x80\x8E";
special_char_strings_[SpecialChar::kFastForwardButton] = "\xee\x80\x8F";
special_char_strings_[SpecialChar::kDpadCenterButton] = "\xee\x80\x90";
special_char_strings_[SpecialChar::kOuyaButtonO] = "\xee\x80\x99";
special_char_strings_[SpecialChar::kOuyaButtonU] = "\xee\x80\x9A";
special_char_strings_[SpecialChar::kOuyaButtonY] = "\xee\x80\x9B";
special_char_strings_[SpecialChar::kOuyaButtonA] = "\xee\x80\x9C";
special_char_strings_[SpecialChar::kOuyaLogo] = "\xee\x80\x9D";
special_char_strings_[SpecialChar::kLogo] = "\xee\x80\x9E";
special_char_strings_[SpecialChar::kTicket] = "\xee\x80\x9F";
special_char_strings_[SpecialChar::kGooglePlayGamesLogo] = "\xee\x80\xA0";
special_char_strings_[SpecialChar::kGameCenterLogo] = "\xee\x80\xA1";
special_char_strings_[SpecialChar::kDiceButton1] = "\xee\x80\xA2";
special_char_strings_[SpecialChar::kDiceButton2] = "\xee\x80\xA3";
special_char_strings_[SpecialChar::kDiceButton3] = "\xee\x80\xA4";
special_char_strings_[SpecialChar::kDiceButton4] = "\xee\x80\xA5";
special_char_strings_[SpecialChar::kGameCircleLogo] = "\xee\x80\xA6";
special_char_strings_[SpecialChar::kPartyIcon] = "\xee\x80\xA7";
special_char_strings_[SpecialChar::kTestAccount] = "\xee\x80\xA8";
special_char_strings_[SpecialChar::kTicketBacking] = "\xee\x80\xA9";
special_char_strings_[SpecialChar::kTrophy1] = "\xee\x80\xAA";
special_char_strings_[SpecialChar::kTrophy2] = "\xee\x80\xAB";
special_char_strings_[SpecialChar::kTrophy3] = "\xee\x80\xAC";
special_char_strings_[SpecialChar::kTrophy0a] = "\xee\x80\xAD";
special_char_strings_[SpecialChar::kTrophy0b] = "\xee\x80\xAE";
special_char_strings_[SpecialChar::kTrophy4] = "\xee\x80\xAF";
special_char_strings_[SpecialChar::kLocalAccount] = "\xee\x80\xB0";
special_char_strings_[SpecialChar::kAlibabaLogo] = "\xee\x80\xB1";
special_char_strings_[SpecialChar::kFlagUnitedStates] = "\xee\x80\xB2";
special_char_strings_[SpecialChar::kFlagMexico] = "\xee\x80\xB3";
special_char_strings_[SpecialChar::kFlagGermany] = "\xee\x80\xB4";
special_char_strings_[SpecialChar::kFlagBrazil] = "\xee\x80\xB5";
special_char_strings_[SpecialChar::kFlagRussia] = "\xee\x80\xB6";
special_char_strings_[SpecialChar::kFlagChina] = "\xee\x80\xB7";
special_char_strings_[SpecialChar::kFlagUnitedKingdom] = "\xee\x80\xB8";
special_char_strings_[SpecialChar::kFlagCanada] = "\xee\x80\xB9";
special_char_strings_[SpecialChar::kFlagIndia] = "\xee\x80\xBA";
special_char_strings_[SpecialChar::kFlagJapan] = "\xee\x80\xBB";
special_char_strings_[SpecialChar::kFlagFrance] = "\xee\x80\xBC";
special_char_strings_[SpecialChar::kFlagIndonesia] = "\xee\x80\xBD";
special_char_strings_[SpecialChar::kFlagItaly] = "\xee\x80\xBE";
special_char_strings_[SpecialChar::kFlagSouthKorea] = "\xee\x80\xBF";
special_char_strings_[SpecialChar::kFlagNetherlands] = "\xee\x81\x80";
special_char_strings_[SpecialChar::kFedora] = "\xee\x81\x81";
special_char_strings_[SpecialChar::kHal] = "\xee\x81\x82";
special_char_strings_[SpecialChar::kCrown] = "\xee\x81\x83";
special_char_strings_[SpecialChar::kYinYang] = "\xee\x81\x84";
special_char_strings_[SpecialChar::kEyeBall] = "\xee\x81\x85";
special_char_strings_[SpecialChar::kSkull] = "\xee\x81\x86";
special_char_strings_[SpecialChar::kHeart] = "\xee\x81\x87";
special_char_strings_[SpecialChar::kDragon] = "\xee\x81\x88";
special_char_strings_[SpecialChar::kHelmet] = "\xee\x81\x89";
special_char_strings_[SpecialChar::kMushroom] = "\xee\x81\x8A";
special_char_strings_[SpecialChar::kNinjaStar] = "\xee\x81\x8B";
special_char_strings_[SpecialChar::kVikingHelmet] = "\xee\x81\x8C";
special_char_strings_[SpecialChar::kMoon] = "\xee\x81\x8D";
special_char_strings_[SpecialChar::kSpider] = "\xee\x81\x8E";
special_char_strings_[SpecialChar::kFireball] = "\xee\x81\x8F";
special_char_strings_[SpecialChar::kFlagUnitedArabEmirates] = "\xee\x81\x90";
special_char_strings_[SpecialChar::kFlagQatar] = "\xee\x81\x91";
special_char_strings_[SpecialChar::kFlagEgypt] = "\xee\x81\x92";
special_char_strings_[SpecialChar::kFlagKuwait] = "\xee\x81\x93";
special_char_strings_[SpecialChar::kFlagAlgeria] = "\xee\x81\x94";
special_char_strings_[SpecialChar::kFlagSaudiArabia] = "\xee\x81\x95";
special_char_strings_[SpecialChar::kFlagMalaysia] = "\xee\x81\x96";
special_char_strings_[SpecialChar::kFlagCzechRepublic] = "\xee\x81\x97";
special_char_strings_[SpecialChar::kFlagAustralia] = "\xee\x81\x98";
special_char_strings_[SpecialChar::kFlagSingapore] = "\xee\x81\x99";
special_char_strings_[SpecialChar::kOculusLogo] = "\xee\x81\x9A";
special_char_strings_[SpecialChar::kSteamLogo] = "\xee\x81\x9B";
special_char_strings_[SpecialChar::kNvidiaLogo] = "\xee\x81\x9C";
special_char_strings_[SpecialChar::kFlagIran] = "\xee\x81\x9D";
special_char_strings_[SpecialChar::kFlagPoland] = "\xee\x81\x9E";
special_char_strings_[SpecialChar::kFlagArgentina] = "\xee\x81\x9F";
special_char_strings_[SpecialChar::kFlagPhilippines] = "\xee\x81\xA0";
special_char_strings_[SpecialChar::kFlagChile] = "\xee\x81\xA1";
special_char_strings_[SpecialChar::kMikirog] = "\xee\x81\xA2";
}
void Game::SetGameRoster(cJSON* r) {
if (game_roster_ != nullptr) {
cJSON_Delete(game_roster_);
}
game_roster_ = r;
}
void Game::ResetActivityTracking() {
largest_draw_time_increment_since_last_reset_ = 0;
first_draw_real_time_ = last_draw_real_time_ = g_platform->GetTicks();
}
#if BA_VR_BUILD
void Game::PushVRHandsState(const VRHandsState& state) {
PushCall([this, state] { vr_hands_state_ = state; });
}
#endif // BA_VR_BUILD
void Game::PushMediaPruneCall(int level) {
PushCall([level] {
assert(InGameThread());
g_media->Prune(level);
});
}
void Game::PushSetAccountTokenCall(const std::string& account_id,
const std::string& token) {
PushCall([account_id, token] { g_account->SetToken(account_id, token); });
}
void Game::PushSetLoginCall(AccountType account_type, LoginState account_state,
const std::string& account_name,
const std::string& account_id) {
PushCall([this, account_type, account_state, account_name, account_id] {
g_account->SetLogin(account_type, account_state, account_name, account_id);
});
}
void Game::PushInitialScreenCreatedCall() {
PushCall([this] { InitialScreenCreated(); });
}
void Game::InitialScreenCreated() {
assert(InGameThread());
// Ok; graphics-server is telling us we've got a screen.
// We can now let the media thread go to town pre-loading system media
// while we wait.
g_media->LoadSystemMedia();
// FIXME: ideally we should create this as part of bootstrapping, but
// we need it to be possible to load textures/etc. before the renderer
// exists.
if (!HeadlessMode()) {
assert(!g_app_globals->console);
g_app_globals->console = new Console();
}
// Set up our timers.
process_timer_ =
NewThreadTimer(0, true, NewLambdaRunnable([this] { Process(); }));
media_prune_timer_ =
NewThreadTimer(2345, true, NewLambdaRunnable([this] { Prune(); }));
// Normally we schedule updates when we're asked to draw a frame.
// In headless mode, however, we're not drawing, so we need a dedicated
// timer to take its place.
if (HeadlessMode()) {
headless_update_timer_ =
NewThreadTimer(8, true, NewLambdaRunnable([this] { Update(); }));
}
RunAppLaunchCommands();
}
void Game::Prune() { g_media->Prune(); }
// Launch into main menu or whatever else.
void Game::RunAppLaunchCommands() {
assert(InGameThread());
assert(!ran_app_launch_commands_);
// First off, run our python app-launch call.
{
// Run this in the UI context.
ScopedSetContext cp(GetUIContext());
g_python->obj(Python::ObjID::kOnAppLaunchCall).Call();
}
ran_app_launch_commands_ = true;
// If we were passed launch command args, run them.
if (!g_app_globals->exec_command.empty()) {
bool success = PythonCommand(g_app_globals->exec_command, BA_BCFN).Run();
if (!success) {
exit(1);
}
}
// If the stuff we just ran didn't result in a session, create a default one.
if (!foreground_session_.exists()) {
RunMainMenu();
}
UpdateProcessTimer();
}
Game::~Game() = default;
// Set up our sleeping based on what we're doing.
void Game::UpdateProcessTimer() {
assert(InGameThread());
// This might get called before we set up our timer in some cases. (such as
// very early) should be safe to ignore since we update the interval
// explicitly after creating the timers.
if (!process_timer_) {
return;
}
// If there's loading to do, keep at it rather vigorously.
if (have_pending_loads_) {
assert(process_timer_);
process_timer_->SetLength(1);
} else {
// Otherwise we've got nothing to do; go to sleep until something changes.
assert(process_timer_);
process_timer_->SetLength(-1);
}
}
void Game::PruneSessions() {
bool have_dead_session = false;
for (auto&& i : sessions_) {
if (i.exists()) {
// If this session is no longer foreground and is ready to die, kill it.
if (i.exists() && i.get() != foreground_session_.get()) {
try {
i.Clear();
} catch (const std::exception& e) {
Log("Exception killing Session: " + std::string(e.what()));
}
have_dead_session = true;
}
} else {
have_dead_session = true;
}
}
if (have_dead_session) {
std::vector<Object::Ref<Session> > live_list;
for (auto&& i : sessions_) {
if (i.exists()) {
live_list.push_back(i);
}
}
sessions_.swap(live_list);
}
}
void Game::UpdateKickVote() {
if (!kick_vote_in_progress_) {
return;
}
ConnectionToClient* kick_vote_starter = kick_vote_starter_.get();
ConnectionToClient* kick_vote_target = kick_vote_target_.get();
// If the target is no longer with us, silently end.
if (kick_vote_target == nullptr) {
kick_vote_in_progress_ = false;
return;
}
millisecs_t current_time = GetRealTime();
int total_client_count = 0;
int yes_votes = 0;
int no_votes = 0;
// Tally current votes for connected clients; if anything has changed, print
// the update and possibly perform the kick.
for (ConnectionToClient* client : connections()->GetConnectionsToClients()) {
++total_client_count;
if (client->kick_voted_) {
if (client->kick_vote_choice_) {
++yes_votes;
} else {
++no_votes;
}
}
}
bool vote_failed = false;
// If we've fallen below the minimum necessary voters or time has run out,
// fail.
if (total_client_count < kKickVoteMinimumClients) {
vote_failed = true;
}
if (current_time > kick_vote_end_time_) {
vote_failed = true;
}
if (vote_failed) {
connections()->SendScreenMessageToClients(R"({"r":"kickVoteFailedText"})",
1, 1, 0);
kick_vote_in_progress_ = false;
// Disallow kicking for a while for everyone.. but ESPECIALLY so for the guy
// who launched the failed vote.
for (ConnectionToClient* client :
connections()->GetConnectionsToClients()) {
millisecs_t delay = kKickVoteFailRetryDelay;
if (client == kick_vote_starter) {
delay += kKickVoteFailRetryDelayInitiatorExtra;
}
client->next_kick_vote_allow_time_ =
std::max(client->next_kick_vote_allow_time_, current_time + delay);
}
} else {
int votes_required;
switch (total_client_count) {
case 1:
case 2:
votes_required = 2; // Shouldn't actually be possible.
break;
case 3:
votes_required = HeadlessMode() ? 2 : 3;
break;
case 4:
votes_required = 3;
break;
case 5:
votes_required = HeadlessMode() ? 3 : 4;
break;
case 6:
votes_required = 4;
break;
case 7:
votes_required = HeadlessMode() ? 4 : 5;
break;
default:
votes_required = total_client_count - 3;
break;
}
int votes_needed = votes_required - yes_votes;
if (votes_needed <= 0) {
// ZOMG the vote passed; perform the kick.
connections()->SendScreenMessageToClients(
R"({"r":"kickOccurredText","s":[["${NAME}",)"
+ Utils::GetJSONString(kick_vote_target->GetCombinedSpec()
.GetDisplayString()
.c_str())
+ "]]}",
1, 1, 0);
kick_vote_in_progress_ = false;
connections_->DisconnectClient(kick_vote_target->id(), kKickBanSeconds);
} else if (votes_needed != last_kick_votes_needed_) {
last_kick_votes_needed_ = votes_needed;
connections()->SendScreenMessageToClients(
R"({"r":"votesNeededText","s":[["${NUMBER}",")"
+ std::to_string(votes_needed) + "\"]]}",
1, 1, 0);
}
}
}
void Game::HandleQuitOnIdle() {
if (idle_exit_minutes_) {
float idle_seconds{g_input->input_idle_time() * 0.001f};
if (!idle_exiting_ && idle_seconds > (idle_exit_minutes_.value() * 60.0f)) {
idle_exiting_ = true;
PushCall([this, idle_seconds] {
assert(InGameThread());
// Special exit value the wrapper script looks for to know we idled out.
// UPDATE: no longer need this.
// g_app_globals->return_value = 154;
// Just go through _ba.quit()
// FIXME: Shouldn't need to go out to the python layer here...
g_python->obj(Python::ObjID::kQuitCall).Call();
});
}
}
}
// Bring our scenes, real-time timers, etc up to date.
void Game::Update() {
assert(InGameThread());
millisecs_t real_time = GetRealTime();
g_platform->SetDebugKey("LastUpdateTime",
std::to_string(Platform::GetCurrentMilliseconds()));
if (first_update_) {
master_time_offset_ = master_time_ - real_time;
first_update_ = false;
}
in_update_ = true;
g_input->Update();
UpdateKickVote();
HandleQuitOnIdle();
// Send the game roster to our clients if it's changed recently.
if (game_roster_dirty_) {
if (real_time > last_game_roster_send_time_ + 2500) {
// Now send it to all connected clients.
std::vector<uint8_t> msg = GetGameRosterMessage();
for (auto&& c : connections()->GetConnectionsToClients()) {
c->SendReliableMessage(msg);
}
game_roster_dirty_ = false;
last_game_roster_send_time_ = real_time;
}
}
connections_->Update();
// Ok, here's the deal:
// This is where we regulate the speed of everything that's running under us
// (sessions, activities, frame_def-creation, etc)
// we have a master_time which we try to have match real-time as closely
// as possible (unless we physically aren't fast enough to get everything
// done, in which case it'll be slower). We also increment our underlying
// machinery in 8ms increments (1/120 of a second) and try to do 2 updates
// each time we're called, since we're usually being called in a 60hz refresh
// cycle and that'll line our draws up perfectly with our sim steps.
// TODO(ericf): On modern systems (VR and otherwise) we'll see 80hz, 90hz,
// 120hz, 240hz, etc. It would be great to generalize this to gravitate
// towards clean step patterns in all cases, not just the 60hz and 90hz
// cases we handle now. In general we want stuff like 1,1,2,1,1,2,1,1,2,
// not 1,1,1,2,1,2,2,1,1.
// Figure out where our net-time *should* be getting to to match real-time.
millisecs_t target_master_time = real_time + master_time_offset_;
millisecs_t amount_behind = target_master_time - master_time_;
// Normally we assume 60hz so we gravitate towards 2 steps per update to line
// up with our 120hz update timing.
int target_steps = 2;
#if BA_RIFT_BUILD
// On Rift VR mode we're running 90hz, so lets aim for 1/2/1/2 steps to hit
// our 120hz target.
if (IsVRMode()) {
target_steps = rift_step_index_ + 1;
rift_step_index_ = !rift_step_index_;
}
#endif // BA_RIFT_BUILD
// Ideally we should be behind by 16 (or 8 for single steps); if its
// *slightly* more than that, let our timing slip a tiny bit to maintain sync.
// This lets us match framerates that are a tiny bit slower than 60hz, such as
// seems to be the case with the Gear VR.
if (amount_behind > 16) {
master_time_offset_ -= 1;
//.. and recalc these..
target_master_time = real_time + master_time_offset_;
amount_behind = target_master_time - master_time_;
}
// if we've fallen behind by a lot, just cut our losses
if (amount_behind > 50) {
master_time_offset_ -= (amount_behind - 50);
target_master_time = real_time + master_time_offset_;
}
// min/max net-time targets we can aim for; gives us about a steps worth of
// wiggle room to try and keep our exact target cadence
millisecs_t min_target_master_time =
target_master_time >= 8 ? (target_master_time - 8) : 0;
millisecs_t max_target_master_time = target_master_time + 8;
// run up our real-time timers
realtimers_->Run(real_time);
// Run session updates until we catch up with projected base time (or run out
// of time).
int step = 1;
while (true) {
// Try to stick to our target step count whenever possible, but if we get
// too far off target we may need to bail earlier/later.
if (step > target_steps) {
// As long as we're within a step of where we should be, bail now.
if (master_time_ >= min_target_master_time) break;
} else {
// If we've gone too far already, bail.
if (master_time_ >= max_target_master_time) {
// Log("BAILING EARLY");
// On rift if this is a 2-step and we bailed after 1, aim for 2 again
// next time (otherwise we'll always get 3 singles in a row when this
// happens).
#if BA_RIFT_BUILD
if (IsVRMode() && target_steps == 2 && step == 2) {
rift_step_index_ = !rift_step_index_;
}
#endif // BA_RIFT_BUILD
break;
}
}
// Update our UI scene/etc.
g_ui->Update(8);
// Update all of our sessions.
for (auto&& i : sessions_) {
assert(i.exists());
i->Update(8);
}
last_session_update_master_time_ = master_time_;
// Go ahead and prune dead ones.
PruneSessions();
// Advance master time..
master_time_ += 8;
// Bail if we spend too much time in here.
millisecs_t new_real_time = GetRealTime();
if (new_real_time - real_time > 30) {
break;
}
step++;
}
in_update_ = false;
}
// Reset the game to a blank slate.
void Game::Reset() {
assert(InGameThread());
// Tear down any existing setup.
// This should allow high-level objects to die gracefully.
assert(g_python->inited());
// Tear down our existing session.
foreground_session_.Clear();
PruneSessions();
// If all is well our sessions should all be dead.
if (g_app_globals->session_count != 0) {
Log("Error: session-count is non-zero ("
+ std::to_string(g_app_globals->session_count) + ") on Game::Reset.");
}
// Note: we don't clear real-time timers anymore. Should we?..
g_ui->Reset();
g_input->Reset();
g_graphics->Reset();
g_python->Reset();
g_audio->Reset();
if (!HeadlessMode()) {
// If we haven't, send a first frame_def to the graphics thread to kick
// things off (it'll start sending us requests for more after it gets the
// first).
if (!have_sent_initial_frame_def_) {
g_graphics->BuildAndPushFrameDef();
have_sent_initial_frame_def_ = true;
}
}
}
auto Game::IsInUIContext() const -> bool {
return (g_ui && Context::current().target.get() == g_ui);
}
void Game::PushShowURLCall(const std::string& url) {
PushCall([url] {
assert(InGameThread());
assert(g_python);
g_python->ShowURL(url);
});
}
auto Game::GetForegroundContext() -> Context {
Session* s = GetForegroundSession();
if (s) {
return s->GetForegroundContext();
} else {
return Context();
}
}
void Game::PushBackButtonCall(InputDevice* input_device) {
PushCall([this, input_device] {
assert(InGameThread());
// Ignore if UI isn't up yet.
if (!g_ui || !g_ui->overlay_root_widget() || !g_ui->screen_root_widget()) {
return;
}
// If there's a UI up, send along a cancel message.
if (g_ui->overlay_root_widget()->GetChildCount() != 0
|| g_ui->screen_root_widget()->GetChildCount() != 0) {
g_ui->root_widget()->HandleMessage(
WidgetMessage(WidgetMessage::Type::kCancel));
} else {
// If there's no main screen or overlay windows, ask for a menu owned by
// this device.
MainMenuPress(input_device);
}
});
}
void Game::PushStringEditSetCall(const std::string& value) {
PushCall([value] {
if (!g_ui) {
Log("Error: No ui on StringEditSetEvent.");
return;
}
#if BA_OSTYPE_ANDROID
TextWidget* w = TextWidget::GetAndroidStringEditWidget();
if (w) {
w->SetText(value);
}
#else
throw Exception(); // Shouldn't get here.
#endif
});
}
void Game::PushStringEditCancelCall() {
PushCall([] {
if (!g_ui) {
Log("Error: No ui in PushStringEditCancelCall.");
return;
}
});
}
// Called by a newly made Session instance to set itself as the current
// session.
void Game::SetForegroundSession(Session* s) {
assert(InGameThread());
foreground_session_ = s;
}
void Game::SetForegroundScene(Scene* sg) {
assert(InGameThread());
if (foreground_scene_.get() != sg) {
foreground_scene_ = sg;
// If this scene has a globals-node, put it in charge of stuff.
if (GlobalsNode* g = sg->globals_node()) {
g->SetAsForeground();
}
}
}
void Game::LaunchClientSession() {
if (in_update_) {
throw Exception(
"can't launch a session from within a session update; use "
"ba.pushcall()");
}
assert(InGameThread());
// Don't want to pick up any old stuff in here.
ScopedSetContext cp(nullptr);
// This should kill any current session and get us back to a blank slate.
Reset();
// Create the new session.
Object::WeakRef<Session> old_foreground_session(foreground_session_);
try {
auto s(Object::New<Session, NetClientSession>());
sessions_.push_back(s);
// It should have set itself as FG.
assert(foreground_session_ == s);
} catch (const std::exception& e) {
// If it failed, restore the previous current session and re-throw.
SetForegroundSession(old_foreground_session.get());
throw Exception(std::string("HostSession failed: ") + e.what());
}
}
void Game::LaunchReplaySession(const std::string& file_name) {
if (in_update_)
throw Exception(
"can't launch a session from within a session update; use "
"ba.pushcall()");
assert(InGameThread());
// Don't want to pick up any old stuff in here.
ScopedSetContext cp(nullptr);
// This should kill any current session and get us back to a blank slate.
Reset();
// Create the new session.
Object::WeakRef<Session> old_foreground_session(foreground_session_);
try {
auto s(Object::New<Session, ReplayClientSession>(file_name));
sessions_.push_back(s);
// It should have set itself as FG.
assert(foreground_session_ == s);
} catch (const std::exception& e) {
// If it failed, restore the previous current session and re-throw the
// exception.
SetForegroundSession(old_foreground_session.get());
throw Exception(std::string("HostSession failed: ") + e.what());
}
}
void Game::LaunchHostSession(PyObject* session_type_obj,
BenchmarkType benchmark_type) {
if (in_update_) {
throw Exception(
"can't call host_session() from within session update; use "
"ba.pushcall()");
}
assert(InGameThread());
connections_->PrepareForLaunchHostSession();
// Don't want to pick up any old stuff in here.
ScopedSetContext cp(nullptr);
// This should kill any current session and get us back to a blank slate.
Reset();
Object::WeakRef<Session> old_foreground_session(foreground_session_);
try {
// Create the new session.
auto s(Object::New<HostSession>(session_type_obj));
s->set_benchmark_type(benchmark_type);
sessions_.emplace_back(s);
// It should have set itself as FG.
assert(foreground_session_ == s);
} catch (const std::exception& e) {
// If it failed, restore the previous session context and re-throw the
// exception.
SetForegroundSession(old_foreground_session.get());
throw Exception(std::string("HostSession failed: ") + e.what());
}
}
void Game::RunMainMenu() {
assert(InGameThread());
if (g_app_globals->shutting_down) {
return;
}
assert(g_python);
assert(InGameThread());
PythonRef result =
g_python->obj(Python::ObjID::kLaunchMainMenuSessionCall).Call();
if (!result.exists()) {
throw Exception("error running main menu");
}
}
// Commands run via the in-game console. These are a bit more 'casual' and run
// in the current visible context.
void Game::PushInGameConsoleScriptCommand(const std::string& command) {
PushCall([this, command] {
// These are always run in whichever context is 'visible'.
ScopedSetContext cp(GetForegroundContext());
PythonCommand cmd(command, "<in-game-console>");
if (!g_app_globals->user_ran_commands) {
g_app_globals->user_ran_commands = true;
}
if (cmd.CanEval()) {
PyObject* obj = cmd.RunReturnObj(true, nullptr);
if (obj && obj != Py_None) {
PyObject* s = PyObject_Repr(obj);
if (s) {
const char* c = PyUnicode_AsUTF8(s);
if (g_app_globals->console) {
g_app_globals->console->Print(std::string(c) + "\n");
}
Py_DECREF(s);
}
Py_DECREF(obj);
}
} else {
// Not eval-able; just run it.
cmd.Run();
}
});
}
// Commands run via stdin.
void Game::PushStdinScriptCommand(const std::string& command) {
PushCall([this, command] {
// These are always run in whichever context is 'visible'.
ScopedSetContext cp(GetForegroundContext());
PythonCommand cmd(command, "<stdin>");
if (!g_app_globals->user_ran_commands) {
g_app_globals->user_ran_commands = true;
}
// Eval this if possible (so we can possibly print return value).
if (cmd.CanEval()) {
if (PyObject* obj = cmd.RunReturnObj(true, nullptr)) {
// Print the value if we're running directly from a terminal
// (or being run under the server-manager)
if ((IsStdinATerminal() || g_app->server_wrapper_managed())
&& obj != Py_None) {
PyObject* s = PyObject_Repr(obj);
if (s) {
const char* c = PyUnicode_AsUTF8(s);
printf("%s\n", c);
fflush(stdout);
Py_DECREF(s);
}
}
Py_DECREF(obj);
}
} else {
// Can't eval it; just run it.
cmd.Run();
}
});
}
void Game::PushInterruptSignalCall() {
PushCall([this] {
assert(InGameThread());
// Special case; when running under the server-wrapper, we completely
// ignore interrupt signals (the wrapper acts on them).
if (g_app->server_wrapper_managed()) {
return;
}
// Just go through _ba.quit()
// FIXME: Shouldn't need to go out to the Python layer here...
g_python->obj(Python::ObjID::kQuitCall).Call();
});
}
void Game::PushAskUserForTelnetAccessCall() {
PushCall([this] {
assert(InGameThread());
ScopedSetContext cp(GetUIContext());
g_python->obj(Python::ObjID::kTelnetAccessRequestCall).Call();
});
}
void Game::HandleThreadPause() {
// Give userspace python stuff a chance to pause.
ScopedSetContext cp(GetUIContextTarget());
g_python->obj(Python::ObjID::kOnAppPauseCall).Call();
// Tell our account client to commit any outstanding changes to disk.
AppInternalOnGameThreadPause();
}
void Game::PushPythonCall(const Object::Ref<PythonContextCall>& call) {
// Since we're mucking with refs, need to limit to game thread.
BA_PRECONDITION(InGameThread());
BA_PRECONDITION(call->object_strong_ref_count() > 0);
PushCall([call] {
assert(call.exists());
call->Run();
});
}
void Game::PushPythonCallArgs(const Object::Ref<PythonContextCall>& call,
const PythonRef& args) {
// Since we're mucking with refs, need to limit to game thread.
BA_PRECONDITION(InGameThread());
BA_PRECONDITION(call->object_strong_ref_count() > 0);
PushCall([call, args] {
assert(call.exists());
call->Run(args.get());
});
}
void Game::PushPythonWeakCall(const Object::WeakRef<PythonContextCall>& call) {
// Since we're mucking with refs, need to limit to game thread.
BA_PRECONDITION(InGameThread());
// Even though we only hold a weak ref, we expect a valid strong-reffed
// object to be passed in.
assert(call.exists() && call->object_strong_ref_count() > 0);
PushCall([call] {
if (call.exists()) {
Python::ScopedCallLabel label("PythonWeakCallMessage");
call->Run();
}
});
}
void Game::PushPythonWeakCallArgs(
const Object::WeakRef<PythonContextCall>& call, const PythonRef& args) {
// Since we're mucking with refs, need to limit to game thread.
BA_PRECONDITION(InGameThread());
// Even though we only hold a weak ref, we expect a valid strong-reffed
// object to be passed in.
assert(call.exists() && call->object_strong_ref_count() > 0);
PushCall([call, args] {
if (call.exists()) call->Run(args.get());
});
}
void Game::PushPythonRawCallable(PyObject* callable) {
PushCall([this, callable] {
assert(InGameThread());
// Lets run this in the UI context.
// (can add other options if we need later)
ScopedSetContext cp(GetUIContext());
// This event contains a raw python obj with an incremented ref-count.
auto call(Object::New<PythonContextCall>(callable));
Py_DECREF(callable); // now just held by call
call->Run();
});
}
void Game::PushScreenMessage(const std::string& message,
const Vector3f& color) {
PushCall([message, color] { g_graphics->AddScreenMessage(message, color); });
}
void Game::SetReplaySpeedExponent(int val) {
replay_speed_exponent_ = std::min(3, std::max(-3, val));
replay_speed_mult_ = powf(2.0f, static_cast<float>(replay_speed_exponent_));
}
void Game::SetDebugSpeedExponent(int val) {
debug_speed_exponent_ = val;
debug_speed_mult_ = powf(2.0f, static_cast<float>(debug_speed_exponent_));
Session* s = GetForegroundSession();
if (s) s->DebugSpeedMultChanged();
}
void Game::ChangeGameSpeed(int offs) {
assert(InGameThread());
// If we're in a replay session, adjust playback speed there.
if (dynamic_cast<ReplayClientSession*>(GetForegroundSession())) {
int old_speed = replay_speed_exponent();
SetReplaySpeedExponent(replay_speed_exponent() + offs);
if (old_speed != replay_speed_exponent()) {
ScreenMessage(
"{\"r\":\"watchWindow.playbackSpeedText\","
"\"s\":[[\"${SPEED}\",\""
+ std::to_string(replay_speed_mult()) + "\"]]}");
}
return;
}
// Otherwise, in debug build, we allow speeding/slowing anything.
if (g_buildconfig.debug_build()) {
debug_speed_exponent_ += offs;
debug_speed_mult_ = powf(2.0f, static_cast<float>(debug_speed_exponent_));
ScreenMessage("DEBUG GAME SPEED TO " + std::to_string(debug_speed_mult_));
Session* s = GetForegroundSession();
if (s) {
s->DebugSpeedMultChanged();
}
}
}
auto Game::GetUIContext() const -> Context {
return Context(GetUIContextTarget());
}
void Game::PushToggleManualCameraCall() {
PushCall([] { g_graphics->ToggleManualCamera(); });
}
void Game::PushToggleDebugInfoDisplayCall() {
PushCall([] { g_graphics->ToggleNetworkDebugDisplay(); });
}
void Game::PushToggleCollisionGeometryDisplayCall() {
PushCall([] { g_graphics->ToggleDebugDraw(); });
}
void Game::PushMainMenuPressCall(InputDevice* device) {
PushCall([this, device] { MainMenuPress(device); });
}
void Game::MainMenuPress(InputDevice* device) {
assert(InGameThread());
g_python->HandleDeviceMenuPress(device);
}
void Game::PushScreenResizeCall(float virtual_width, float virtual_height,
float pixel_width, float pixel_height) {
PushCall([=] {
ScreenResize(virtual_width, virtual_height, pixel_width, pixel_height);
});
}
void Game::ScreenResize(float virtual_width, float virtual_height,
float pixel_width, float pixel_height) {
assert(InGameThread());
assert(g_graphics != nullptr);
if (g_graphics) {
g_graphics->ScreenResize(virtual_width, virtual_height, pixel_width,
pixel_height);
}
if (g_ui) {
g_ui->ScreenSizeChanged();
}
if (Session* session = GetForegroundSession()) {
session->ScreenSizeChanged();
}
}
void Game::PushGameServiceAchievementListCall(
const std::set<std::string>& achievements) {
PushCall([this, achievements] { GameServiceAchievementList(achievements); });
}
void Game::GameServiceAchievementList(
const std::set<std::string>& achievements) {
assert(g_python);
assert(InGameThread());
AppInternalDispatchRemoteAchievementList(achievements);
}
void Game::PushScoresToBeatResponseCall(bool success,
const std::list<ScoreToBeat>& scores,
void* py_callback) {
PushCall([this, success, scores, py_callback] {
ScoresToBeatResponse(success, scores, py_callback);
});
}
void Game::ScoresToBeatResponse(bool success,
const std::list<ScoreToBeat>& scores,
void* py_callback) {
assert(g_python);
assert(InGameThread());
g_python->DispatchScoresToBeatResponse(success, scores, py_callback);
}
void Game::PushPlaySoundCall(SystemSoundID sound) {
PushCall([sound] { g_audio->PlaySound(g_media->GetSound(sound)); });
}
void Game::PushFriendScoreSetCall(const FriendScoreSet& score_set) {
PushCall([score_set] { g_python->HandleFriendScoresCB(score_set); });
}
void Game::PushConfirmQuitCall() {
PushCall([this] {
assert(InGameThread());
if (HeadlessMode()) {
Log("PushConfirmQuitCall() unhandled on headless.");
} else {
// If input is locked, just quit immediately.. a confirm screen wouldn't
// work anyway
if (g_input->IsInputLocked()
|| (g_app_globals->console != nullptr
&& g_app_globals->console->active())) {
// Just go through _ba.quit()
// FIXME: Shouldn't need to go out to the python layer here...
g_python->obj(Python::ObjID::kQuitCall).Call();
return;
} else {
// this needs to be run in the UI context
ScopedSetContext cp(GetUIContextTarget());
g_audio->PlaySound(g_media->GetSound(SystemSoundID::kSwish));
g_python->obj(Python::ObjID::kQuitWindowCall).Call();
// if we have a keyboard, give it UI ownership
InputDevice* keyboard = g_input->keyboard_input();
if (keyboard) {
g_ui->SetUIInputDevice(keyboard);
}
}
}
});
}
void Game::Draw() {
g_graphics->BuildAndPushFrameDef();
// Now bring the game up to date.
// By doing this *after* shipping a new frame-def we're reducing the
// chance of frame drops at the expense of adding a bit of visual latency.
// Could maybe try to be smart about which to do first, but not sure
// if its worth it.
Update();
// Update our cheat tests.
millisecs_t now = g_platform->GetTicks();
millisecs_t elapsed = now - last_draw_real_time_;
if (elapsed > largest_draw_time_increment_since_last_reset_) {
largest_draw_time_increment_since_last_reset_ = elapsed;
}
last_draw_real_time_ = now;
// Sanity test: can make sure our scene is taking exactly 2 steps
// per frame here.. (should generally be the case on 60hz devices).
if (explicit_bool(false)) {
static int64_t last_step = 0;
HostActivity* ha = GetForegroundContext().GetHostActivity();
if (ha) {
int64_t step = ha->scene()->stepnum();
Log(std::to_string(step - last_step));
last_step = step;
}
}
}
void Game::PushFrameDefRequest() {
PushCall([this] { Draw(); });
}
void Game::PushOnAppResumeCall() {
PushCall([] {
// Wipe out whatever input device was in control of the UI.
assert(g_ui);
g_ui->SetUIInputDevice(nullptr);
});
}
// Look through everything in our config dict and act on it.
void Game::ApplyConfig() {
assert(InGameThread());
// Not relevant for fullscreen anymore
// since we're fullscreen windows everywhere.
int width = 800;
int height = 600;
// Texture quality.
TextureQuality texture_quality_requested;
std::string texqualstr =
g_app_config->Resolve(AppConfig::StringID::kTextureQuality);
if (texqualstr == "Auto") {
texture_quality_requested = TextureQuality::kAuto;
} else if (texqualstr == "High") {
texture_quality_requested = TextureQuality::kHigh;
} else if (texqualstr == "Medium") {
texture_quality_requested = TextureQuality::kMedium;
} else if (texqualstr == "Low") {
texture_quality_requested = TextureQuality::kLow;
} else {
Log("Invalid texture quality: '" + texqualstr + "'; defaulting to low.");
texture_quality_requested = TextureQuality::kLow;
}
// Graphics quality.
std::string gqualstr =
g_app_config->Resolve(AppConfig::StringID::kGraphicsQuality);
GraphicsQuality graphics_quality_requested;
if (gqualstr == "Auto") {
graphics_quality_requested = GraphicsQuality::kAuto;
} else if (gqualstr == "Higher") {
graphics_quality_requested = GraphicsQuality::kHigher;
} else if (gqualstr == "High") {
graphics_quality_requested = GraphicsQuality::kHigh;
} else if (gqualstr == "Medium") {
graphics_quality_requested = GraphicsQuality::kMedium;
} else if (gqualstr == "Low") {
graphics_quality_requested = GraphicsQuality::kLow;
} else {
Log("Error: Invalid graphics quality: '" + gqualstr
+ "'; defaulting to auto.");
graphics_quality_requested = GraphicsQuality::kAuto;
}
// Android res string.
std::string android_res =
g_app_config->Resolve(AppConfig::StringID::kResolutionAndroid);
bool fullscreen = g_app_config->Resolve(AppConfig::BoolID::kFullscreen);
// Note: when the graphics-thread applies the first set-screen event it will
// trigger the remainder of startup such as media-loading; make sure nothing
// below this will affect that.
g_graphics_server->PushSetScreenCall(fullscreen, width, height,
texture_quality_requested,
graphics_quality_requested, android_res);
// FIXME: The graphics server should kick this off *AFTER* it sets the actual
// quality values; here we're just sending along our requested values which
// is wrong. If there's a session up, inform it of the (potential) change.
Session* session = GetForegroundSession();
if (session) {
session->GraphicsQualityChanged(graphics_quality_requested);
}
if (!HeadlessMode()) {
g_app_globals->remote_server_accepting_connections =
g_app_config->Resolve(AppConfig::BoolID::kEnableRemoteApp);
}
chat_muted_ = g_app_config->Resolve(AppConfig::BoolID::kChatMuted);
g_graphics->set_show_fps(g_app_config->Resolve(AppConfig::BoolID::kShowFPS));
// Set tv border (for both client and server).
// FIXME: this should exist either on the client or the server; not both.
// (and should be communicated via frameldefs/etc.)
bool tv_border = g_app_config->Resolve(AppConfig::BoolID::kTVBorder);
g_graphics_server->PushCall(
[tv_border] { g_graphics_server->set_tv_border(tv_border); });
g_graphics->set_tv_border(tv_border);
g_graphics_server->PushSetScreenGammaCall(
g_app_config->Resolve(AppConfig::FloatID::kScreenGamma));
g_graphics_server->PushSetScreenPixelScaleCall(
g_app_config->Resolve(AppConfig::FloatID::kScreenPixelScale));
TextWidget::set_always_use_internal_keyboard(
g_app_config->Resolve(AppConfig::BoolID::kAlwaysUseInternalKeyboard));
// V-sync setting.
std::string v_sync =
g_app_config->Resolve(AppConfig::StringID::kVerticalSync);
bool do_v_sync{};
bool auto_v_sync{};
if (v_sync == "Auto") {
do_v_sync = true;
auto_v_sync = true;
} else if (v_sync == "Always") {
do_v_sync = true;
auto_v_sync = false;
} else if (v_sync == "Never") {
do_v_sync = false;
auto_v_sync = false;
} else {
do_v_sync = false;
auto_v_sync = false;
Log("Error: Invalid 'Vertical Sync' value: '" + v_sync + "'");
}
g_graphics_server->PushSetVSyncCall(do_v_sync, auto_v_sync);
g_audio->SetVolumes(g_app_config->Resolve(AppConfig::FloatID::kMusicVolume),
g_app_config->Resolve(AppConfig::FloatID::kSoundVolume));
// Kick-idle-players setting (hmm is this still relevant?).
auto* host_session = dynamic_cast<HostSession*>(foreground_session_.get());
kick_idle_players_ =
g_app_config->Resolve(AppConfig::BoolID::kKickIdlePlayers);
if (host_session) {
host_session->SetKickIdlePlayers(kick_idle_players_);
}
assert(g_input);
g_input->ApplyAppConfig();
// Set up network ports/states.
int port = g_app_config->Resolve(AppConfig::IntID::kPort);
int telnet_port = g_app_config->Resolve(AppConfig::IntID::kTelnetPort);
// NOTE: Hard disabling telnet for now in headless builds;
// it was being exploited to own servers.
bool enable_telnet =
g_buildconfig.headless_build()
? false
: g_app_config->Resolve(AppConfig::BoolID::kEnableTelnet);
std::string telnet_password =
g_app_config->Resolve(AppConfig::StringID::kTelnetPassword);
g_app->PushNetworkSetupCall(port, telnet_port, enable_telnet,
telnet_password);
bool disable_camera_shake =
g_app_config->Resolve(AppConfig::BoolID::kDisableCameraShake);
g_graphics->set_camera_shake_disabled(disable_camera_shake);
bool disable_camera_gyro =
g_app_config->Resolve(AppConfig::BoolID::kDisableCameraGyro);
g_graphics->set_camera_gyro_explicitly_disabled(disable_camera_gyro);
idle_exit_minutes_ =
g_app_config->Resolve(AppConfig::OptionalFloatID::kIdleExitMinutes);
// Any platform-specific settings.
g_platform->ApplyConfig();
}
void Game::PushApplyConfigCall() {
PushCall([this] { ApplyConfig(); });
}
void Game::PushRemoveGraphicsServerRenderHoldCall() {
PushCall([] {
// This call acts as a flush of sorts; when it goes through,
// we push a call to the graphics server saying its ok for it
// to start rendering again. Thus any already-queued-up
// frame_defs or whatnot will be ignored.
g_graphics_server->PushRemoveRenderHoldCall();
});
}
void Game::PushFreeMediaComponentRefsCall(
const std::vector<Object::Ref<MediaComponentData>*>& components) {
PushCall([components] {
for (auto&& i : components) {
delete i;
}
});
}
void Game::PushHavePendingLoadsDoneCall() {
PushCall([] { g_media->ClearPendingLoadsDoneList(); });
}
void Game::ToggleConsole() {
assert(InGameThread());
if (auto console = g_app_globals->console) {
console->ToggleState();
}
}
void Game::PushConsolePrintCall(const std::string& msg) {
PushCall([msg] {
// Send them to the console if its been created or store them
// for when it is (unless we're headless in which case it never will).
if (auto console = g_app_globals->console) {
console->Print(msg);
} else if (!HeadlessMode()) {
g_app_globals->console_startup_messages += msg;
}
});
}
void Game::PushHavePendingLoadsCall() {
PushCall([this] {
have_pending_loads_ = true;
UpdateProcessTimer();
});
}
void Game::PushShutdownCall(bool soft) {
PushCall([this, soft] { Shutdown(soft); });
}
void Game::Shutdown(bool soft) {
assert(InGameThread());
if (!g_app_globals->shutting_down) {
g_app_globals->shutting_down = true;
// Nuke the app if we get stuck shutting down.
Utils::StartSuicideTimer("shutdown", 10000);
// Call our shutdown callback.
g_python->obj(Python::ObjID::kShutdownCall).Call();
connections_->Shutdown();
// Let's do the same stuff we do when our thread is pausing. (committing
// account-client to disk, etc).
HandleThreadPause();
// Attempt to report/store outstanding log stuff.
AppInternalPutLog(false);
// Ideally we'd want to give some of the above stuff
// a few seconds to complete, but just calling it done for now.
g_app->PushShutdownCompleteCall();
}
}
void Game::ResetInput() {
assert(InGameThread());
g_input->ResetKeyboardHeldKeys();
g_input->ResetJoyStickHeldButtons();
}
auto Game::RemovePlayer(Player* player) -> void {
assert(InGameThread());
if (HostSession* host_session = player->GetHostSession()) {
host_session->RemovePlayer(player);
} else {
Log("Got RemovePlayer call but have no host_session");
}
}
auto Game::NewRealTimer(millisecs_t length, bool repeat,
const Object::Ref<Runnable>& runnable) -> int {
int offset = 0;
Timer* t = realtimers_->NewTimer(GetRealTime(), length, offset,
repeat ? -1 : 0, runnable);
return t->id();
}
void Game::DeleteRealTimer(int timer_id) { realtimers_->DeleteTimer(timer_id); }
void Game::SetRealTimerLength(int timer_id, millisecs_t length) {
Timer* t = realtimers_->GetTimer(timer_id);
if (t) {
t->SetLength(length);
} else {
Log("Error: Game::SetRealTimerLength() called on nonexistent timer.");
}
}
void Game::Process() {
have_pending_loads_ = g_media->RunPendingLoadsGameThread();
UpdateProcessTimer();
}
void Game::SetLanguageKeys(
const std::unordered_map<std::string, std::string>& language) {
assert(InGameThread());
{
std::lock_guard<std::mutex> lock(language_mutex_);
language_ = language;
}
// Let's also inform existing session stuff so it can update itself.
if (Session* session = GetForegroundSession()) {
session->LanguageChanged();
}
// As well as existing UI stuff.
if (Widget* root_widget = g_ui->root_widget()) {
root_widget->OnLanguageChange();
}
// Also clear translations on all screen-messages.
g_graphics->ClearScreenMessageTranslations();
}
auto DoCompileResourceString(cJSON* obj) -> std::string {
assert(InGameThread());
assert(obj != nullptr);
std::string result;
// If its got a "r" key, look it up as a resource.. (with optional fallback).
cJSON* resource = cJSON_GetObjectItem(obj, "r");
if (resource == nullptr) {
resource = cJSON_GetObjectItem(obj, "resource");
// As of build 14318, complain if we find long key names; hope to remove
// them soon.
if (resource != nullptr) {
static bool printed = false;
if (!printed) {
printed = true;
char* c = cJSON_Print(obj);
BA_LOG_ONCE("found long key 'resource' in raw lstr json: "
+ std::string(c));
free(c);
}
}
}
if (resource != nullptr) {
// Look for fallback-resource.
cJSON* fallback_resource = cJSON_GetObjectItem(obj, "f");
if (fallback_resource == nullptr) {
fallback_resource = cJSON_GetObjectItem(obj, "fallback");
// As of build 14318, complain if we find old long key names; hope to
// remove them soon.
if (fallback_resource != nullptr) {
static bool printed = false;
if (!printed) {
printed = true;
char* c = cJSON_Print(obj);
BA_LOG_ONCE("found long key 'fallback' in raw lstr json: "
+ std::string(c));
free(c);
}
}
}
cJSON* fallback_value = cJSON_GetObjectItem(obj, "fv");
result = g_python->GetResource(
resource->valuestring,
fallback_resource ? fallback_resource->valuestring : nullptr,
fallback_value ? fallback_value->valuestring : nullptr);
} else {
// Apparently not a resource; lets try as a translation ("t" keys).
cJSON* translate = cJSON_GetObjectItem(obj, "t");
if (translate == nullptr) {
translate = cJSON_GetObjectItem(obj, "translate");
// As of build 14318, complain if we find long key names; hope to remove
// them soon.
if (translate != nullptr) {
static bool printed = false;
if (!printed) {
printed = true;
char* c = cJSON_Print(obj);
BA_LOG_ONCE("found long key 'translate' in raw lstr json: "
+ std::string(c));
free(c);
}
}
}
if (translate != nullptr) {
if (translate->type != cJSON_Array
|| cJSON_GetArraySize(translate) != 2) {
throw Exception("Expected a 2 member array for translate");
}
cJSON* category = cJSON_GetArrayItem(translate, 0);
if (category->type != cJSON_String) {
throw Exception(
"First member of translate array (category) must be a string");
}
cJSON* value = cJSON_GetArrayItem(translate, 1);
if (value->type != cJSON_String) {
throw Exception(
"Second member of translate array (value) must be a string");
}
result =
g_python->GetTranslation(category->valuestring, value->valuestring);
} else {
// Lastly try it as a value ("value" or "v").
// (can be useful for feeding explicit strings while still allowing
// translated subs
cJSON* value = cJSON_GetObjectItem(obj, "v");
if (value == nullptr) {
value = cJSON_GetObjectItem(obj, "value");
// As of build 14318, complain if we find long key names; hope to remove
// them soon.
if (value != nullptr) {
static bool printed = false;
if (!printed) {
printed = true;
char* c = cJSON_Print(obj);
BA_LOG_ONCE("found long key 'value' in raw lstr json: "
+ std::string(c));
free(c);
}
}
}
if (value != nullptr) {
if (value->type != cJSON_String) {
throw Exception("Expected a string for value");
}
result = value->valuestring;
} else {
throw Exception("no 'resource', 'translate', or 'value' keys found");
}
}
}
// Ok; now no matter what it was, see if it contains any subs and replace
// them.
// ("subs" or "s")
cJSON* subs = cJSON_GetObjectItem(obj, "s");
if (subs == nullptr) {
subs = cJSON_GetObjectItem(obj, "subs");
// As of build 14318, complain if we find long key names; hope to remove
// them soon.
if (subs != nullptr) {
static bool printed = false;
if (!printed) {
printed = true;
char* c = cJSON_Print(obj);
BA_LOG_ONCE("found long key 'subs' in raw lstr json: "
+ std::string(c));
free(c);
}
}
}
if (subs != nullptr) {
if (subs->type != cJSON_Array) {
throw Exception("expected an array for 'subs'");
}
int subsCount = cJSON_GetArraySize(subs);
for (int i = 0; i < subsCount; i++) {
cJSON* sub = cJSON_GetArrayItem(subs, i);
if (sub->type != cJSON_Array || cJSON_GetArraySize(sub) != 2) {
throw Exception(
"Invalid subs entry; expected length 2 list of sub/replacement.");
}
// First item should be a string.
cJSON* key = cJSON_GetArrayItem(sub, 0);
if (key->type != cJSON_String) {
throw Exception("Sub keys must be strings.");
}
std::string s_key = key->valuestring;
// Second item can be a string or a dict; if its a dict, we go recursive.
cJSON* value = cJSON_GetArrayItem(sub, 1);
std::string s_val;
if (value->type == cJSON_String) {
s_val = value->valuestring;
} else if (value->type == cJSON_Object) {
s_val = DoCompileResourceString(value);
} else {
throw Exception("Sub values must be strings or dicts.");
}
// Replace *ALL* occurrences.
// FIXME: Using this simple logic, If our replace value contains our
// search value we get an infinite loop. For now, just error in that case.
if (s_val.find(s_key) != std::string::npos) {
throw Exception("Subs replace string cannot contain search string.");
}
while (true) {
size_t pos = result.find(s_key);
if (pos == std::string::npos) {
break;
}
result.replace(pos, s_key.size(), s_val);
}
}
}
return result;
}
auto Game::CompileResourceString(const std::string& s, const std::string& loc,
bool* valid) -> std::string {
assert(InGameThread());
assert(g_python != nullptr);
bool dummyvalid;
if (valid == nullptr) {
valid = &dummyvalid;
}
// Quick out: if it doesn't start with a { and end with a }, treat it as a
// literal and just return it as-is.
if (s.size() < 2 || s[0] != '{' || s[s.size() - 1] != '}') {
*valid = true;
return s;
}
cJSON* root = cJSON_Parse(s.c_str());
if (root == nullptr) {
Log("CompileResourceString failed (loc " + loc + "); invalid json: '" + s
+ "'");
*valid = false;
return "";
}
std::string result;
try {
result = DoCompileResourceString(root);
*valid = true;
} catch (const std::exception& e) {
Log("CompileResourceString failed (loc " + loc
+ "): " + std::string(e.what()) + "; str='" + s + "'");
result = "<error>";
*valid = false;
}
cJSON_Delete(root);
return result;
}
auto Game::GetResourceString(const std::string& key) -> std::string {
std::string val;
{
std::lock_guard<std::mutex> lock(language_mutex_);
auto i = language_.find(key);
if (i != language_.end()) {
val = i->second;
}
}
return val;
}
auto Game::CharStr(SpecialChar id) -> std::string {
std::lock_guard<std::mutex> lock(special_char_mutex_);
std::string val;
auto i = special_char_strings_.find(id);
if (i != special_char_strings_.end()) {
val = i->second;
} else {
BA_LOG_PYTHON_TRACE_ONCE("invalid key in CharStr(): '"
+ std::to_string(static_cast<int>(id)) + "'");
val = "?";
}
return val;
}
auto Game::ShouldAnnouncePartyJoinsAndLeaves() -> bool {
assert(InGameThread());
// At the moment we don't announce these for public internet parties.. (too
// much noise).
return !public_party_enabled();
}
void Game::CleanUpBeforeConnectingToHost() {
// We can't have connected clients and a host-connection at the same time.
// Make a minimal attempt to disconnect any client connections we have, but
// get them off the list immediately.
// FIXME: Should we have a 'purgatory' for dying client connections?..
// (they may not get the single 'go away' packet we send here)
connections_->ForceDisconnectClients();
// Also make sure our public party state is off; this will inform the server
// that it should not be handing out our address to anyone.
assert(g_python);
SetPublicPartyEnabled(false);
}
void Game::PushPartyInviteCall(const std::string& name,
const std::string& invite_id) {
PushCall([this, name, invite_id] { PartyInvite(name, invite_id); });
}
void Game::PartyInvite(const std::string& name, const std::string& invite_id) {
assert(InGameThread());
g_python->PartyInvite(name, invite_id);
}
void Game::PushPartyInviteRevokeCall(const std::string& invite_id) {
PushCall([this, invite_id] { PartyInviteRevoke(invite_id); });
}
void Game::PartyInviteRevoke(const std::string& invite_id) {
assert(InGameThread());
g_python->PartyInviteRevoke(invite_id);
}
auto Game::GetPartySize() const -> int {
assert(InGameThread());
assert(game_roster_ != nullptr);
return cJSON_GetArraySize(game_roster_);
}
void Game::LocalDisplayChatMessage(const std::vector<uint8_t>& buffer) {
// 1 type byte, 1 spec-len byte, 1 or more spec chars, 0 or more msg chars.
if (buffer.size() > 3) {
size_t spec_len = buffer[1];
if (spec_len > 0 && spec_len + 2 <= buffer.size()) {
size_t msg_len = buffer.size() - spec_len - 2;
std::vector<char> b1(spec_len + 1);
memcpy(&(b1[0]), &(buffer[2]), spec_len);
b1[spec_len] = 0;
std::vector<char> b2(msg_len + 1);
if (msg_len > 0) {
memcpy(&(b2[0]), &(buffer[2 + spec_len]), msg_len);
}
b2[msg_len] = 0;
std::string final_message =
PlayerSpec(b1.data()).GetDisplayString() + ": " + b2.data();
// Store it locally.
chat_messages_.push_back(final_message);
while (chat_messages_.size() > kMaxChatMessages) {
chat_messages_.pop_front();
}
// Show it on the screen if they don't have their chat window open
// (and don't have chat muted).
if (!g_ui->root_ui()->party_window_open()) {
if (!chat_muted_) {
ScreenMessage(final_message, {0.7f, 1.0f, 0.7f});
}
} else {
// Party window is open - notify it that there's a new message.
g_python->HandleLocalChatMessage(final_message);
}
if (!chat_muted_) {
g_audio->PlaySound(g_media->GetSound(SystemSoundID::kTap));
}
}
}
}
auto Game::GetGameRosterMessage() -> std::vector<uint8_t> {
// This message is simply a flattened json string of our roster (including
// terminating char).
char* s = cJSON_PrintUnformatted(game_roster_);
// printf("ROSTER MESSAGE %s\n", s);
auto s_len = strlen(s);
std::vector<uint8_t> msg(1 + s_len + 1);
msg[0] = BA_MESSAGE_PARTY_ROSTER;
memcpy(&(msg[1]), s, s_len + 1);
free(s);
return msg;
}
auto Game::IsPlayerBanned(const PlayerSpec& spec) -> bool {
millisecs_t current_time = GetRealTime();
// Now is a good time to prune no-longer-banned specs.
while (!banned_players_.empty()
&& banned_players_.front().first < current_time) {
banned_players_.pop_front();
}
// NOLINTNEXTLINE(readability-use-anyofallof)
for (auto&& test_spec : banned_players_) {
if (test_spec.second == spec) {
return true;
}
}
return false;
}
void Game::StartKickVote(ConnectionToClient* starter,
ConnectionToClient* target) {
// Restrict votes per client.
millisecs_t current_time = GetRealTime();
if (starter == target) {
// Don't let anyone kick themselves.
starter->SendScreenMessage(R"({"r":"kickVoteCantKickSelfText",)"
R"("f":"kickVoteFailedText"})",
1, 0, 0);
} else if (target->IsAdmin()) {
// Admins are immune to kicking
starter->SendScreenMessage(R"({"r":"kickVoteCantKickAdminText",)"
R"("f":"kickVoteFailedText"})",
1, 0, 0);
} else if (starter->IsAdmin()) {
// Admin doing the kicking succeeds instantly.
connections()->SendScreenMessageToClients(
R"({"r":"kickOccurredText","s":[["${NAME}",)"
+ Utils::GetJSONString(
target->GetCombinedSpec().GetDisplayString().c_str())
+ "]]}",
1, 1, 0);
connections()->DisconnectClient(target->id(), kKickBanSeconds);
starter->SendScreenMessage(R"({"r":"kickVoteCantKickAdminText",)"
R"("f":"kickVoteFailedText"})",
1, 0, 0);
} else if (!kick_voting_enabled_) {
// No kicking otherwise if its disabled.
starter->SendScreenMessage(R"({"r":"kickVotingDisabledText",)"
R"("f":"kickVoteFailedText"})",
1, 0, 0);
} else if (kick_vote_in_progress_) {
// Vote in progress error.
starter->SendScreenMessage(R"({"r":"voteInProgressText"})", 1, 0, 0);
} else if (connections()->GetConnectedClientCount()
< kKickVoteMinimumClients) {
// There's too few clients to effectively vote.
starter->SendScreenMessage(R"({"r":"kickVoteFailedNotEnoughVotersText",)"
R"("f":"kickVoteFailedText"})",
1, 0, 0);
} else if (current_time < starter->next_kick_vote_allow_time_) {
// Not yet allowed error.
starter->SendScreenMessage(
R"({"r":"voteDelayText","s":[["${NUMBER}",")"
+ std::to_string(std::max(
millisecs_t{1},
(starter->next_kick_vote_allow_time_ - current_time) / 1000))
+ "\"]]}",
1, 0, 0);
} else {
std::vector<ConnectionToClient*> connected_clients =
connections()->GetConnectionsToClients();
// Ok, kick off a vote.. (send the question and instructions to everyone
// except the starter and the target).
for (auto&& client : connected_clients) {
if (client != starter && client != target) {
client->SendScreenMessage(
R"({"r":"kickQuestionText","s":[["${NAME}",)"
+ Utils::GetJSONString(
target->GetCombinedSpec().GetDisplayString().c_str())
+ "]]}",
1, 1, 0);
client->SendScreenMessage(R"({"r":"kickWithChatText","s":)"
R"([["${YES}","'1'"],["${NO}","'0'"]]})",
1, 1, 0);
} else {
// For the kicker/kickee, simply print that a kick vote has been
// started.
client->SendScreenMessage(
R"({"r":"kickVoteStartedText","s":[["${NAME}",)"
+ Utils::GetJSONString(
target->GetCombinedSpec().GetDisplayString().c_str())
+ "]]}",
1, 1, 0);
}
}
kick_vote_end_time_ = current_time + kKickVoteDuration;
kick_vote_in_progress_ = true;
last_kick_votes_needed_ = -1; // make sure we print starting num
// Keep track of who started the vote.
kick_vote_starter_ = starter;
kick_vote_target_ = target;
// Reset votes for all connected clients.
for (ConnectionToClient* client :
connections()->GetConnectionsToClients()) {
if (client == starter) {
client->kick_voted_ = true;
client->kick_vote_choice_ = true;
} else {
client->kick_voted_ = false;
}
}
}
}
void Game::BanPlayer(const PlayerSpec& spec, millisecs_t duration) {
banned_players_.emplace_back(GetRealTime() + duration, spec);
}
void Game::UpdateGameRoster() {
assert(InGameThread());
assert(game_roster_ != nullptr);
if (game_roster_ != nullptr) {
cJSON_Delete(game_roster_);
}
// Our party-roster is just a json array of dicts containing player-specs.
game_roster_ = cJSON_CreateArray();
int total_party_size = 1; // include ourself here..
// Add ourself first (that's currently how they know we're the party leader)
// ..but only if we have a connected client (otherwise our party is
// considered 'empty').
// UPDATE: starting with our big ui revision we'll always include ourself
// here
bool include_self = (connections()->GetConnectedClientCount() > 0);
#if BA_TOOLBAR_TEST
include_self = true;
#endif // BA_TOOLBAR_TEST
if (auto* hs = dynamic_cast<HostSession*>(GetForegroundSession())) {
// Add our host-y self.
if (include_self) {
cJSON* client_dict = cJSON_CreateObject();
cJSON_AddItemToObject(
client_dict, "spec",
cJSON_CreateString(
PlayerSpec::GetAccountPlayerSpec().GetSpecString().c_str()));
// Add our list of local players.
cJSON* player_array = cJSON_CreateArray();
for (auto&& p : hs->players()) {
InputDevice* input_device = p->GetInputDevice();
// Add some basic info for each local player (only ones with real
// names though; don't wanna send <selecting character>, etc).
if (p->accepted() && p->name_is_real() && input_device != nullptr
&& !input_device->IsRemoteClient()) {
cJSON* player_dict = cJSON_CreateObject();
cJSON_AddItemToObject(player_dict, "n",
cJSON_CreateString(p->GetName().c_str()));
cJSON_AddItemToObject(player_dict, "nf",
cJSON_CreateString(p->GetName(true).c_str()));
cJSON_AddItemToObject(player_dict, "i", cJSON_CreateNumber(p->id()));
cJSON_AddItemToArray(player_array, player_dict);
}
}
cJSON_AddItemToObject(client_dict, "p", player_array);
cJSON_AddItemToObject(
client_dict, "i",
cJSON_CreateNumber(-1)); // -1 client_id means we're the host.
cJSON_AddItemToArray(game_roster_, client_dict);
}
// Add all connected clients.
for (auto&& i : connections()->connections_to_clients()) {
if (i.second->can_communicate()) {
cJSON* client_dict = cJSON_CreateObject();
cJSON_AddItemToObject(
client_dict, "spec",
cJSON_CreateString(i.second->peer_spec().GetSpecString().c_str()));
// Add their list of players.
cJSON* player_array = cJSON_CreateArray();
// Include all players that are remote and coming from this same
// client connection.
for (auto&& p : hs->players()) {
InputDevice* input_device = p->GetInputDevice();
if (p->accepted() && p->name_is_real() && input_device != nullptr
&& input_device->IsRemoteClient()) {
auto* cid = static_cast<ClientInputDevice*>(input_device);
ConnectionToClient* ctc = cid->connection_to_client();
// Add some basic info for each remote player.
if (ctc != nullptr && ctc == i.second.get()) {
cJSON* player_dict = cJSON_CreateObject();
cJSON_AddItemToObject(player_dict, "n",
cJSON_CreateString(p->GetName().c_str()));
cJSON_AddItemToObject(
player_dict, "nf",
cJSON_CreateString(p->GetName(true).c_str()));
cJSON_AddItemToObject(player_dict, "i",
cJSON_CreateNumber(p->id()));
cJSON_AddItemToArray(player_array, player_dict);
}
}
}
cJSON_AddItemToObject(client_dict, "p", player_array);
cJSON_AddItemToObject(client_dict, "i",
cJSON_CreateNumber(i.second->id()));
cJSON_AddItemToArray(game_roster_, client_dict);
total_party_size += 1;
}
}
}
// Keep the Python layer informed on our number of connections; it may want
// to pass the info along to the master server if we're hosting a public
// party.
SetPublicPartySize(total_party_size);
// Mark the roster as dirty so we know we need to send it to everyone soon.
game_roster_dirty_ = true;
}
void Game::SetPublicPartyEnabled(bool val) {
assert(InGameThread());
if (val == public_party_enabled_) {
return;
}
public_party_enabled_ = val;
AppInternalPushPublicPartyState();
}
void Game::SetPublicPartySize(int count) {
assert(InGameThread());
if (count == public_party_size_) {
return;
}
public_party_size_ = count;
// Push our new state to the server *ONLY* if public-party is turned on
// (wasteful otherwise).
if (public_party_enabled_) {
AppInternalPushPublicPartyState();
}
}
void Game::SetPublicPartyMaxSize(int count) {
assert(InGameThread());
if (count == public_party_max_size_) {
return;
}
public_party_max_size_ = count;
// Push our new state to the server *ONLY* if public-party is turned on
// (wasteful otherwise).
if (public_party_enabled_) {
AppInternalPushPublicPartyState();
}
}
void Game::SetPublicPartyName(const std::string& name) {
assert(InGameThread());
if (name == public_party_name_) {
return;
}
public_party_name_ = name;
// Push our new state to the server *ONLY* if public-party is turned on
// (wasteful otherwise).
if (public_party_enabled_) {
AppInternalPushPublicPartyState();
}
}
void Game::SetPublicPartyStatsURL(const std::string& url) {
assert(InGameThread());
if (url == public_party_stats_url_) {
return;
}
public_party_stats_url_ = url;
// Push our new state to the server *ONLY* if public-party is turned on
// (wasteful otherwise).
if (public_party_enabled_) {
AppInternalPushPublicPartyState();
}
}
void Game::SetPublicPartyPlayerCount(int count) {
assert(InGameThread());
if (count == public_party_player_count_) {
return;
}
public_party_player_count_ = count;
// Push our new state to the server *ONLY* if public-party is turned on
// (wasteful otherwise).
if (public_party_enabled_) {
AppInternalPushPublicPartyState();
}
}
} // namespace ballistica