og-odamex/common/p_horde.cpp
2026-07-31 18:30:10 -05:00

1292 lines
31 KiB
C++

// Emacs style mode select -*- C++ -*-
//-----------------------------------------------------------------------------
//
// $Id$
//
// Copyright (C) 1998-2006 by Randy Heit (ZDoom).
// Copyright (C) 2006-2026 by The Odamex Team.
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// DESCRIPTION:
// "Horde" game mode.
//
//-----------------------------------------------------------------------------
#include "odamex.h"
#include <math.h>
#include "p_horde.h"
#include "c_dispatch.h"
#include "d_player.h"
#include "doomstat.h"
#include "g_gametype.h"
#include "g_horde.h"
#include "i_net.h"
#include "m_random.h"
#include "p_hordespawn.h"
#include "p_local.h"
#include "p_tick.h"
#include "s_sound.h"
#include "svc_message.h"
#if defined(SERVER_APP)
#include "sv_main.h"
#endif
EXTERN_CVAR(g_horde_waves)
EXTERN_CVAR(g_lives)
EXTERN_CVAR(g_horde_spawnempty_min)
EXTERN_CVAR(g_horde_spawnempty_max)
EXTERN_CVAR(g_horde_spawnfull_min)
EXTERN_CVAR(g_horde_spawnfull_max)
EXTERN_CVAR(g_horde_cooldown)
EXTERN_CVAR(g_horde_extralife)
EXTERN_CVAR(g_horde_resurrect)
EXTERN_CVAR(sv_nomonsters)
void A_PainDie(AActor* actor);
constexpr int HORDE_STARTING_TICS = TICRATE * 3;
/**
* @brief Garbage-collector for Horde corpses.
*
* @details We want to destroy corpses from previous waves so the map isn't
* overwhelmed with corpses.
*/
class corpseCollector_t
{
typedef std::queue<AActor::AActorPtr> AActorQueue;
AActorQueue m_corpses;
size_t m_waveStart;
size_t m_added;
size_t m_removed;
void popCorpse()
{
m_corpses.pop();
m_removed += 1;
}
public:
corpseCollector_t() : m_waveStart(0), m_added(0), m_removed(0) { }
void clear()
{
m_corpses = AActorQueue();
m_waveStart = 0;
m_added = 0;
m_removed = 0;
}
void pushCorpse(AActor* ptr)
{
m_corpses.push(ptr->ptr());
m_added += 1;
}
void startWave() { m_waveStart = m_added; }
void tick()
{
if (m_removed >= m_waveStart)
{
// Everything in the queue is from the current wave.
return;
}
AActor::AActorPtr& actor = m_corpses.front();
if (!actor)
{
// Actor is destroyed, just pop it.
popCorpse();
return;
}
if (actor->health > 0)
{
// Actor is not a corpse anymore, just pop it.
popCorpse();
return;
}
// Destroy the actor before we pop it.
actor->Destroy();
popCorpse();
}
};
/**
* @brief Wake up all the passed monsters.
*
* @details This is in a separate function because if we activated monsters
* immediately upon spawning, their activation state causes them to
* move a tiny bit, which can spawn block other monsters.
*/
static void ActivateMonsters(AActors& mobjs)
{
for (auto& mo : mobjs)
{
// Add them health pool now, since the function to do this is
// in this TU anyway.
P_AddHealthPool(mo);
// Hate a random player.
PlayerResults targets = PlayerQuery().hasHealth().execute();
if (targets.count > 0)
{
const size_t idx = P_RandomInt(targets.count);
mo->target = targets.players.at(idx)->mo->ptr();
P_SetMobjState(mo, mo->info->seestate, true);
}
// Play the see sound if we have one.
if (mo->info->seesound)
{
char sound[MAX_SNDNAME];
M_StringCopy(sound, mo->info->seesound, MAX_SNDNAME);
if (sound[strlen(sound) - 1] == '1')
{
sound[strlen(sound) - 1] = P_Random(mo) % 3 + '1';
if (S_FindSound(sound) == -1)
sound[strlen(sound) - 1] = '1';
}
S_NetSound(mo, CHAN_VOICE, sound, ATTN_NORM);
}
}
}
//static const char* HordeStateStr(const hordeState_e state)
//{
// switch (state)
// {
// case HS_STARTING:
// return "HS_STARTING";
// case HS_PRESSURE:
// return "HS_PRESSURE";
// case HS_RELAX:
// return "HS_RELAX";
// case HS_WANTBOSS:
// return "HS_WANTBOSS";
// default:
// return "UNKNOWN";
// }
//}
const int HORDECOOLDOWN_SIZE = 10;
const char *hordecooldown[HORDECOOLDOWN_SIZE];
int hordecoolcount;
class HordeState
{
hordeState_e m_state = HS_STARTING;
int m_wave = 0;
int m_waveTime = 0;
int m_bossTime = 0; // If == waveTime, boss not spawned.
size_t m_defineID = 0;
int m_spawnedHealth = 0;
int m_killedHealth = 0;
int m_bossHealth = 0;
int m_bossDamage = 0;
int m_waveStartHealth = 0;
AActors m_bosses{};
hordeRecipe_t m_bossRecipe{};
int m_nextSpawn = 0;
int m_nextPowerup = 0;
corpseCollector_t m_corpses{};
mobjCounts_t m_monsterCounts{};
mobjCounts_t m_bossCounts{};
/**
* @brief Returns number of bosses currently alive.
*/
size_t countLivingBosses() {
return std::count_if(m_bosses.begin(), m_bosses.end(), [](const auto& boss){ return boss && boss->health > 0; });
}
/**
* @brief Set the given state.
*/
void setState(const hordeState_e state)
{
m_state = state;
if (m_state == HS_WANTBOSS && m_bossTime == m_waveTime)
{
// Do the boss intro fanfare.
SV_BroadcastPrintFmt("The floor trembles as the boss of the wave arrives.\n");
S_NetSound(NULL, CHAN_GAMEINFO, "misc/horde/boss", ATTN_NONE);
m_bossTime = ::level.time;
}
}
public:
HordeState() = default;
/**
* @brief Reset director state.
*/
void reset(bool printWave)
{
setState(HS_STARTING);
m_wave = 1;
m_waveTime = ::level.time;
m_bossTime = ::level.time;
m_defineID = HordeWaveSelector(P_HordePickDefine(m_wave, ::g_horde_waves));
// [Acts 19 quiz] HordeWaveLogger is separate from HordeWaveSelector because
// forceWave needs to ingore the Horde cooldown list, but still log itself.
HordeWaveLogger(G_HordeDefine(m_defineID).name.c_str());
m_spawnedHealth = 0;
m_killedHealth = 0;
m_bossHealth = 0;
m_bossDamage = 0;
m_waveStartHealth = 0;
m_bosses.clear();
m_bossRecipe.clear();
m_nextSpawn = ::level.time;
m_nextPowerup = ::level.time + (30 * TICRATE);
m_corpses.clear();
m_monsterCounts.clear();
m_bossCounts.clear();
// Avoid printing wave name on boot and map switch.
if (printWave)
{
SV_BroadcastPrintFmt("Wave {}: \"{}\"\n", m_wave,
G_HordeDefine(m_defineID).name);
}
}
size_t HordeWaveSelector(size_t waveidentity)
{
if (G_IsHordeMode() && g_horde_cooldown > 0)
{
const char* wavename = G_HordeDefine(waveidentity).name.c_str();
// [Acts 19 quiz] We want the function to stop endlessly looping if it's unable
// to find a suitable replacement wave. It gets a limited number of "attempts."
for (int attempts = 20; attempts > 0; attempts--)
{
int horderolodex = hordecoolcount - 1;
if (horderolodex < 0)
{
horderolodex = HORDECOOLDOWN_SIZE - 1;
}
for (int cooldowncvar = g_horde_cooldown; cooldowncvar > 0; cooldowncvar--)
{
if (hordecooldown[horderolodex] != wavename)
{
if (cooldowncvar == 1)
{
attempts = 0;
break;
}
horderolodex = horderolodex--;
if (horderolodex < 0)
{
horderolodex = HORDECOOLDOWN_SIZE - 1;
}
}
else
{
waveidentity = P_HordePickDefine(m_wave, ::g_horde_waves);
wavename = G_HordeDefine(waveidentity).name.c_str();
cooldowncvar = 0;
}
}
}
}
return waveidentity;
}
void HordeWaveLogger(const char* wavename)
{
PlayerResults playerslives = PlayerQuery().hasLives().execute();
if (G_IsHordeMode() && (!G_IsLivesGame() || playerslives.count > 0))
{
hordecooldown[hordecoolcount] = wavename;
hordecoolcount = ++hordecoolcount;
if (hordecoolcount >= HORDECOOLDOWN_SIZE)
{
hordecoolcount = 0;
}
}
}
/**
* @brief Start the given wave.
*/
void nextWave()
{
// The server can have lives, and if that's the case we want
// to bring back dead players.
if (G_IsLivesGame())
{
// Give all ingame players an extra life for beating the wave.
PlayersView ingame = PlayerQuery().execute().players;
for (const auto& player : ingame)
{
// Dead players are reborn with a message.
if (player->lives <= 0)
{
G_ResetLastPlayer();
player->playerstate = PST_REBORN;
SV_BroadcastPrintFmt("{} gets a new lease on life.\n",
player->userinfo.netname);
// Send a res sound directly to this player.
S_PlayerSound(player, NULL, CHAN_INTERFACE, "misc/plraise",
ATTN_NONE);
}
// Give everyone an extra life.
if (player->lives < g_lives)
{
player->lives += 1;
MSG_WriteSVC(&player->client.reliablebuf, SVC_PlayerInfo(*player));
MSG_BroadcastSVC(CLBUF_RELIABLE,
SVC_PlayerMembers(*player, SVC_PM_LIVES),
player->id);
}
}
#ifdef SERVER_APP
// Service the join queue and give all of the freshly-ingame
// players a single life to start with.
PlayersView queued = SpecQuery().onlyInQueue().execute();
SV_UpdatePlayerQueuePositions(G_CanJoinGameStart, NULL);
for (const auto& player : queued)
{
player->lives = 1;
MSG_WriteSVC(&player->client.reliablebuf, SVC_PlayerInfo(*player));
MSG_BroadcastSVC(CLBUF_RELIABLE,
SVC_PlayerMembers(*player, SVC_PM_LIVES), player->id);
}
#endif
}
if (::g_horde_waves && m_wave >= ::g_horde_waves)
{
// All monsters explode! Woo!
AActor* actor;
TThinkerIterator<AActor> iterator;
while ((actor = iterator.Next()))
{
if (actor->oflags & MFO_HEALTHPOOL || actor->type == MT_SKULL)
{
// [AM] Mostly copypasted from the massacre cheat.
if (actor->health > 0)
{
P_DamageMobj(actor, NULL, NULL, 10000, MOD_UNKNOWN);
}
if (actor->type == MT_PAIN)
{
A_PainDie(actor);
P_SetMobjState(actor, S_PAIN_DIE6);
}
}
}
G_EndGame();
return;
}
setState(HS_STARTING);
m_wave += 1;
m_waveTime = ::level.time;
m_bossTime = ::level.time;
m_defineID = HordeWaveSelector(P_HordePickDefine(m_wave, ::g_horde_waves));
HordeWaveLogger(G_HordeDefine(m_defineID).name.c_str());
m_waveStartHealth = m_killedHealth;
m_bossHealth = 0;
m_bossDamage = 0;
m_bosses.clear();
m_bossRecipe.clear();
m_corpses.startWave();
m_bossCounts.clear();
SV_BroadcastPrintFmt("Wave {}: \"{}\"\n", m_wave,
G_HordeDefine(m_defineID).name);
}
/**
* @brief Force a wave with a partial name match.
*
* @param name Partial name to match against.
* @return True if the wave was forced, otherwise false.
*/
bool forceWave(const std::string& name)
{
size_t defineID;
if (!P_HordeDefineNamed(defineID, name))
return false;
setState(HS_STARTING);
m_waveTime = ::level.time;
m_bossTime = ::level.time;
m_defineID = defineID;
// [Acts 19 quiz] We check for online serverside to prevent client/server
// desyncs of what's in hordecooldownprint.
if (!network_game || serverside)
{
HordeWaveLogger(G_HordeDefine(m_defineID).name.c_str());
}
m_waveStartHealth = m_killedHealth;
m_bossHealth = 0;
m_bossDamage = 0;
m_bosses.clear();
m_bossRecipe.clear();
m_corpses.startWave();
m_bossCounts.clear();
SV_BroadcastPrintFmt("Wave {}: \"{}\"\n", m_wave,
G_HordeDefine(m_defineID).name);
return true;
}
/**
* @brief Force the boss to spawn.
*
* @return True if the state was switched successfully.
*/
bool forceBoss()
{
if (m_bosses.empty())
{
setState(HS_WANTBOSS);
return true;
}
return false;
}
/**
* @brief Serialize horde data into POD struct.
*/
[[nodiscard]]
hordeInfo_t serialize() const
{
hordeInfo_t info;
info.state = m_state;
info.wave = m_wave;
info.waveTime = m_waveTime;
info.bossTime = m_bossTime;
info.defineID = m_defineID;
info.spawnedHealth = m_spawnedHealth;
info.killedHealth = m_killedHealth;
info.bossHealth = m_bossHealth;
info.bossDamage = m_bossDamage;
info.waveStartHealth = m_waveStartHealth;
return info;
}
/**
* @brief Unserialize horde data from a POD struct.
*/
void unserialize(const hordeInfo_t& info)
{
m_state = info.state;
m_wave = info.wave;
m_waveTime = info.waveTime;
m_bossTime = info.bossTime;
m_defineID = info.defineID;
m_spawnedHealth = info.spawnedHealth;
m_killedHealth = info.killedHealth;
m_bossHealth = info.bossHealth;
m_bossDamage = info.bossDamage;
m_waveStartHealth = info.waveStartHealth;
}
/**
* @brief Clear the boss array and re-check all actors for boss flags.
*
* @detail Used for loading savegames.
*/
void rescanBosses()
{
AActor* mo;
TThinkerIterator<AActor> iterator;
m_bosses.clear();
while ((mo = iterator.Next()))
{
if (mo->oflags & MFO_BOSSPOOL)
{
m_bosses.push_back(mo->ptr());
}
}
}
/**
* @brief Re-count all monsters helper
*
* @detail Used for loading savegames.
*
* @param monsterCounts map to update the counts off (m_bossCounts or m_monsterCounts)
* @param type Type of monster to increment count of
*/
void recountMonstersHelper(mobjCounts_t& monsterCounts, int32_t type)
{
if (monsterCounts.count(type))
{
monsterCounts[type] += 1;
}
else
{
monsterCounts[type] = 1;
}
}
/**
* @brief Re-count all monsters
*
* @detail Used for loading savegames.
*/
void recountMonsters()
{
AActor* mo;
TThinkerIterator<AActor> iterator;
m_monsterCounts.clear();
m_bossCounts.clear();
while ((mo = iterator.Next()))
{
if (mo->health > 0)
{
if (mo->oflags & MFO_BOSSPOOL)
{
recountMonstersHelper(m_bossCounts, mo->type);
}
else
{
recountMonstersHelper(m_monsterCounts, mo->type);
}
}
}
}
void addSpawnHealth(const int health) { m_spawnedHealth += health; }
void addKilledHealth(const int health) { m_killedHealth += health; }
void addBossHealth(const int health) { m_bossHealth += health; }
void addBossDamage(const int health) { m_bossDamage += health; }
void addCorpse(AActor* mo) { m_corpses.pushCorpse(mo); }
void decrementCount(AActor* mo)
{
if (mo->oflags & MFO_BOSSPOOL)
{
if (m_bossCounts.count(mo->type))
{
m_bossCounts[mo->type] -= 1;
}
}
else
{
if (m_monsterCounts.count(mo->type))
{
m_monsterCounts[mo->type] -= 1;
}
}
}
size_t getDefineID() const { return m_defineID; }
int getAliveHealth() const { return m_spawnedHealth - m_killedHealth; }
void changeState();
void getNextSpawnTime(int& min, int& max);
void tick();
} g_HordeDirector;
/**
* @brief Run functionality that decides if we need to switch states.
*/
void HordeState::changeState()
{
const hordeDefine_t& define = G_HordeDefine(m_defineID);
const int goalHealth = define.goalHealth() + m_waveStartHealth;
switch (m_state)
{
case HS_STARTING: {
if (::level.time > m_waveTime + (3 * TICRATE))
{
// We've given enough of a head-start.
setState(HS_PRESSURE);
}
return;
}
case HS_PRESSURE: {
size_t alive = countLivingBosses();
if (m_killedHealth > goalHealth && (m_bosses.empty() || alive < m_bossRecipe.limit))
{
// We reached the goal, spawn the boss.
setState(HS_WANTBOSS);
}
return;
}
case HS_RELAX: {
size_t alive = countLivingBosses();
if (m_killedHealth > goalHealth && (m_bosses.empty() || alive < m_bossRecipe.limit))
{
// We reached the goal, spawn the boss.
setState(HS_WANTBOSS);
}
else
{
// There's not enough monsters in the level, add more.
setState(HS_PRESSURE);
}
return;
}
case HS_WANTBOSS: {
if (m_bossRecipe.isValid() && static_cast<int>(m_bosses.size()) >= m_bossRecipe.count)
{
// Doesn't matter which state we enter, but we're more likely
// to be in the relax state after spawning a big hunk of HP.
setState(HS_PRESSURE);
}
return;
}
default:
return;
}
}
/**
* @brief Calculate the next spawn time for a monster group.
*
* @param min Minimum spawn time, in seconds.
* @param max Maximum spawn time, in seconds.
*/
void HordeState::getNextSpawnTime(int& min, int& max)
{
const hordeDefine_t& define = G_HordeDefine(::g_HordeDirector.getDefineID());
const double EMPTY_MIN_SPAWN = g_horde_spawnempty_min;
const double EMPTY_MAX_SPAWN = g_horde_spawnempty_max;
const double FULL_MIN_SPAWN = g_horde_spawnfull_min;
const double FULL_MAX_SPAWN = g_horde_spawnfull_max;
// Minimum/maximum monster spawn time.
int alive = getAliveHealth();
// Slow down the spawn rate if we have bosses.
if (!m_bosses.empty())
{
alive += (define.maxTotalHealth() - alive) / 2;
}
double minf = Remap(alive, define.minTotalHealth(), define.maxTotalHealth(),
EMPTY_MIN_SPAWN, EMPTY_MAX_SPAWN);
double maxf = Remap(alive, define.minTotalHealth(), define.maxTotalHealth(),
FULL_MIN_SPAWN, FULL_MAX_SPAWN);
// Don't absolutely pound the players in the first minute.
const int FALLOFF_TIME = m_waveTime + ::HORDE_STARTING_TICS + TICRATE * 60;
if (::level.time < FALLOFF_TIME)
{
const double falloff = Remap(::level.time, m_waveTime, FALLOFF_TIME, 1.0, 0.0);
const double floormin = Remap(falloff, 0.0, 1.0, 0.0, EMPTY_MIN_SPAWN);
const double floormax = Remap(falloff, 0.0, 1.0, 0.0, EMPTY_MAX_SPAWN);
minf = MAX(minf, floormin);
maxf = MAX(maxf, floormax);
}
// Turn into integers.
min = MAX(int(round(minf)), 1);
max = MAX(int(round(maxf)), 1);
max = MAX(max, min);
}
/**
* @brief Run the state logic itself.
*/
void HordeState::tick()
{
const hordeDefine_t& define = G_HordeDefine(m_defineID);
// Run GC on monster corpses.
m_corpses.tick();
// Are the bosses taken care of?
if (m_state != HS_WANTBOSS && m_bossRecipe.isValid())
{
size_t alive = countLivingBosses();
if (!alive && m_bossRecipe.limit <= 0)
{
// Start the next wave.
nextWave();
return;
}
}
if (::level.time >= m_nextSpawn && getAliveHealth() <= define.maxTotalHealth())
{
int min, max;
getNextSpawnTime(min, max);
// Randomly select our next spawn time.
const int offset = P_RandomInt(max - min + 1) + min;
m_nextSpawn = ::level.time + (offset * TICRATE);
// Should we spawn a monster?
switch (m_state)
{
case HS_PRESSURE: {
if (sv_nomonsters)
break;
// Pick a recipe for some monsters.
hordeRecipe_t recipe;
const bool ok = P_HordeSpawnRecipe(recipe, define, false, m_monsterCounts);
if (!ok)
{
if (recipe.count >= 0)
PrintFmt(PRINT_WARNING, "{}: No spawn recipe for monster.\n", __FUNCTION__);
return;
}
// Choose a spawn point.
hordeSpawn_t* spawn = P_HordeSpawnPoint(recipe);
if (spawn == NULL)
{
PrintFmt(PRINT_WARNING, "{}: Can't find a place to spawn {}.\n",
__FUNCTION__, ::mobjinfo[recipe.type].name);
return;
}
const int hp = ::mobjinfo[recipe.type].spawnhealth * recipe.count;
DPrintFmt("Spawning {} {} ({} hp) at a {} spawn\n", recipe.count,
::mobjinfo[recipe.type].name, hp, HordeThingStr(spawn->type));
AActors mobjs = P_HordeSpawn(*spawn, recipe, m_monsterCounts);
ActivateMonsters(mobjs);
break;
}
case HS_RELAX:
break;
case HS_WANTBOSS: {
if (sv_nomonsters)
break;
// Do we already have bosses spawned?
if (m_bossRecipe.isValid() && static_cast<int>(m_bosses.size()) >= m_bossRecipe.count)
break;
hordeRecipe_t recipe;
if (!m_bossRecipe.isValid())
{
// Pick a recipe for the boss.
const bool ok = P_HordeSpawnRecipe(recipe, define, true, m_bossCounts);
if (!ok)
{
PrintFmt(PRINT_WARNING, "{}: No spawn recipe for boss monster.\n",
__FUNCTION__);
break;
}
// Save for later - in case we need to spawn twice.
m_bossRecipe = recipe;
}
else
{
// Adjust the count based on how many bosses we've spawned and current boss limit.
recipe = m_bossRecipe;
recipe.count = m_bossRecipe.totalCount - int(m_bosses.size());
int alive = int(countLivingBosses());
if (recipe.limit)
{
if (recipe.count <= 0)
{
m_bossRecipe.limit = 0;
recipe.count = 0;
}
else
recipe.count = std::clamp(recipe.limit - alive, 0, recipe.count);
}
}
// Spawn a boss if we don't have one.
hordeSpawn_t* spawn = P_HordeSpawnPoint(recipe);
if (spawn == NULL)
{
PrintFmt(PRINT_WARNING, "{}: Can't find a place to spawn {}.\n",
__FUNCTION__, ::mobjinfo[recipe.type].name);
break;
}
AActors mobjs = P_HordeSpawn(*spawn, recipe, m_bossCounts);
m_bosses.insert(m_bosses.end(), mobjs.begin(), mobjs.end());
ActivateMonsters(mobjs);
break;
}
case HS_STARTING:
break;
}
}
// Always try to spawn an item.
P_HordeSpawnItem();
if (::level.time >= m_nextPowerup)
{
// Inject horde powerups
hordeDefine_t def = G_HordeDefine(m_defineID);
if (G_IsLivesGame())
{
// Add extra life/resurrect powerups when
if (g_horde_extralife.value() > 0.0f)
{
hordeDefine_t::powConfig_t config;
config.chance = g_horde_extralife.value();
def.addPowerup(MT_EXTRALIFE, config);
}
if (g_horde_resurrect.value() > 0.0f && PlayerQuery().notHasLives().execute().count > 0)
{
hordeDefine_t::powConfig_t config;
config.chance = g_horde_resurrect.value();
def.addPowerup(MT_RESTEAMMATE, config);
}
}
// Always try to spawn a powerup between 30-45 seconds.
if (!def.powerups.empty())
{
const int offset = P_RandomInt(16) + 30;
m_nextPowerup = ::level.time + (offset * TICRATE);
const mobjtype_t pw = def.randomPowerup().mobj;
P_HordeSpawnPowerup(pw);
}
}
}
void P_InitHorde()
{
::g_HordeDirector.reset(false);
}
void P_NextSpawnTime(int& min, int& max)
{
::g_HordeDirector.getNextSpawnTime(min, max);
}
hordeInfo_t P_HordeInfo()
{
return ::g_HordeDirector.serialize();
}
void P_SetHordeInfo(const hordeInfo_t& info)
{
::g_HordeDirector.unserialize(info);
}
void P_AddHealthPool(AActor* mo)
{
// Already added?
if (mo->oflags & MFO_HEALTHPOOL)
return;
// Counts as a monster?
if (!(mo->flags & MF_COUNTKILL || mo->type == MT_SKULL))
return;
// Mark as part of the health pool for cleanup later
mo->oflags |= MFO_HEALTHPOOL;
::g_HordeDirector.addSpawnHealth(::mobjinfo[mo->type].spawnhealth);
// Bosses also have health added to a separate pool for display purposes.
if (mo->oflags & MFO_BOSSPOOL)
{
::g_HordeDirector.addBossHealth(::mobjinfo[mo->type].spawnhealth);
}
}
void P_RemoveHealthPool(AActor* mo)
{
// Not a part of the pool
if (!(mo->oflags & MFO_HEALTHPOOL))
return;
// Unset the flag - we only get one try.
mo->oflags &= ~MFO_HEALTHPOOL;
::g_HordeDirector.decrementCount(mo);
::g_HordeDirector.addKilledHealth(::mobjinfo[mo->type].spawnhealth);
}
void P_AddDamagePool(AActor* mo, const int damage)
{
// Not a part of the pool
if (!(mo->oflags & MFO_BOSSPOOL))
return;
// Counts as a monster?
if (!(mo->flags & MF_COUNTKILL || mo->type == MT_SKULL))
return;
::g_HordeDirector.addBossDamage(damage);
}
void P_QueueCorpseForDestroy(AActor* mo)
{
// Only needed for horde.
if (!G_IsHordeMode())
return;
::g_HordeDirector.addCorpse(mo);
}
void P_RunHordeTics()
{
if (!G_IsHordeMode())
return;
// Move this function to tick inside someplace that can be paused.
if (::paused)
return;
if (::level.time == 0)
{
::g_HordeDirector.reset(true);
}
// Add our spawns if a level reload or reset erased our previous spawns.
if (!P_HordeHasSpawns())
{
P_HordeAddSpawns();
if (!P_HordeHasSpawns() || !P_HordeHasRequiredMonsterSpawns())
{
if (::level.time == 0)
{
PrintFmt(
PRINT_WARNING,
"WARNING: This map is missing Horde Monster, Horde Boss, Horde Supply Cache, "
"or Horde Powerup spawns. At least one of each must be present.\n");
}
// This map has no horde things in it - probably inside a
// non-horde map.
return;
}
}
// Pause game logic if levelstate doesn't allow it.
if (!G_CanTickGameplay())
return;
// Pause game logic if nobody is in the game.
PlayerResults targets = PlayerQuery().hasHealth().execute();
if (targets.count == 0)
return;
// We can always change state.
::g_HordeDirector.changeState();
// The director is only run once a second.
if (P_AtInterval(TICRATE))
::g_HordeDirector.tick();
}
bool P_IsHordeThing(const int type)
{
return type >= TTYPE_HORDE_SMALLMONSTER && type <= TTYPE_HORDE_SMALLBOSS;
}
const hordeDefine_t::weapons_t& P_HordeWeapons()
{
if (G_IsHordeMode())
{
const hordeDefine_t& define = G_HordeDefine(::g_HordeDirector.getDefineID());
return define.weapons;
}
else
{
static hordeDefine_t::weapons_t weapons;
if (weapons.empty())
{
weapons.push_back(wp_shotgun);
weapons.push_back(wp_chaingun);
weapons.push_back(wp_supershotgun);
weapons.push_back(wp_missile);
weapons.push_back(wp_plasma);
weapons.push_back(wp_bfg);
}
return weapons;
}
}
const hordeDefine_t::ammos_t& P_HordeAmmos()
{
if (G_IsHordeMode())
{
const hordeDefine_t& define = G_HordeDefine(::g_HordeDirector.getDefineID());
return define.ammos;
}
else
{
static hordeDefine_t::ammos_t ammos;
if (ammos.empty())
{
// [AM] Shells are first because a typical define leads with the
// shotgun and the pistol is not accounted for at all.
ammos.push_back(am_shell);
ammos.push_back(am_clip);
ammos.push_back(am_misl);
ammos.push_back(am_cell);
}
return ammos;
}
}
/**
* @brief Serialize horde using FArchive.
*/
void P_SerializeHorde(FArchive& arc)
{
if (arc.IsStoring())
{
hordeInfo_t info = ::g_HordeDirector.serialize();
const int state = info.state;
const uint32_t defineID = static_cast<uint32_t>(info.defineID);
arc << state << info.wave << info.waveTime << info.bossTime << defineID
<< info.spawnedHealth << info.killedHealth << info.bossHealth
<< info.bossDamage << info.waveStartHealth;
}
else
{
hordeInfo_t info;
int state;
uint32_t defineID;
arc >> state >> info.wave >> info.waveTime >> info.bossTime >> defineID >>
info.spawnedHealth >> info.killedHealth >> info.bossHealth >>
info.bossDamage >> info.waveStartHealth;
info.state = static_cast<hordeState_e>(state);
info.defineID = defineID;
::g_HordeDirector.unserialize(info);
}
}
/**
* @brief Do last minute fixups after loading a savegame.
*/
void P_HordePostLoad()
{
if (!G_IsHordeMode())
return;
::g_HordeDirector.rescanBosses();
::g_HordeDirector.recountMonsters();
}
const char* serveronlycmd = "Only the server can use this command in a netgame!\n";
BEGIN_COMMAND(hordewave)
{
if (argc < 2)
{
PrintFmt("hordewave - Restarts the current wave with a new definition\n"
"Usage:\n"
" ] hordewave <DEF NAME>\n"
" Starts the wave named DEF NAME. The name can be partial.\n");
return;
}
if (!G_IsHordeMode())
{
PrintFmt("Can't change the wave define outside of horde mode.\n");
return;
}
if (!::g_HordeDirector.forceWave(argv[1]))
{
PrintFmt("Could not find wave define starting with \"{}\"\n", argv[1]);
}
}
END_COMMAND(hordewave)
BEGIN_COMMAND(hordenextwave)
{
if (!G_IsHordeMode())
{
PrintFmt("Can't advance the wave outside of horde mode.\n");
return;
}
::g_HordeDirector.nextWave();
}
END_COMMAND(hordenextwave)
BEGIN_COMMAND(hordeboss)
{
if (!G_IsHordeMode())
{
PrintFmt("Can't spawn a horde boss outside of horde mode.\n");
return;
}
if (::g_HordeDirector.forceBoss())
{
PrintFmt("Spawned the boss.\n");
}
else
{
PrintFmt("Could not spawn a boss.\n");
}
}
END_COMMAND(hordeboss)
EXTERN_CVAR(g_horde_mintotalhp)
EXTERN_CVAR(g_horde_maxtotalhp)
EXTERN_CVAR(g_horde_goalhp)
BEGIN_COMMAND(hordeinfo)
{
if (!G_IsHordeMode())
{
PrintFmt("Can't obtain horde info outside of horde mode.\n");
return;
}
float skillScaler = 1.0f;
if (sv_skill == sk_medium)
skillScaler = 0.75f;
else if (sv_skill == sk_baby || sv_skill == sk_easy)
skillScaler = 0.5f;
const hordeDefine_t& define = G_HordeDefine(::g_HordeDirector.getDefineID());
PrintFmt("[Define: {}]\n", define.name);
PrintFmt("Weapons: {}\n", JoinStrings(define.weaponStrings(NULL), " "));
PrintFmt("Min Group Health: {}\n", define.minGroupHealth);
PrintFmt("Max Group Health: {} (Difficulty: {})\n", define.maxGroupHealth,
define.difficulty(false));
PrintFmt("Min Total Health: {} = waveMaxGroup:{} * g_horde_mintotalhp:{} * "
"skillLevel:{:0.2f}\n",
define.minTotalHealth(), define.maxGroupHealth, ::g_horde_mintotalhp.str(),
skillScaler);
PrintFmt("Max Total Health: {} = waveMaxGroup:{} * g_horde_maxtotalhp:{} * "
"skillLevel:{:0.2f}\n",
define.maxTotalHealth(), define.maxGroupHealth, ::g_horde_maxtotalhp.str(),
skillScaler);
PrintFmt("Goal Health: {} = waveMaxGroup:{} * g_horde_goalhp:{} * skillLevel:{:0.2f}\n",
define.goalHealth(), define.maxGroupHealth, ::g_horde_goalhp.str(),
skillScaler);
const char* stateStr = NULL;
switch (::g_HordeDirector.serialize().state)
{
case HS_STARTING:
stateStr = "Starting";
break;
case HS_PRESSURE:
stateStr = "Pressure";
break;
case HS_RELAX:
stateStr = "Relax";
break;
case HS_WANTBOSS:
stateStr = "WantBoss";
break;
}
int min, max;
::g_HordeDirector.getNextSpawnTime(min, max);
PrintFmt("[Wave:{}d]\n", ::g_HordeDirector.serialize().wave);
PrintFmt("State: {}\n", stateStr);
if (::g_HordeDirector.getAliveHealth() <= define.maxTotalHealth())
{
PrintFmt("Current Spawn Rate: {}-{}sec\n", min, max);
}
else
{
PrintFmt("Current Spawn Rate: PAUSED (Above Max Health)\n");
}
PrintFmt("Empty/Full Spawn Rate: {}-{}sec, {}-{}sec\n", g_horde_spawnempty_min.asInt(),
g_horde_spawnempty_max.asInt(), g_horde_spawnfull_min.asInt(),
g_horde_spawnfull_max.asInt());
PrintFmt("Alive Health: {}\n", ::g_HordeDirector.serialize().alive());
PrintFmt("Killed Health: {}\n", ::g_HordeDirector.serialize().killed());
PrintFmt("Boss Health: {}\n", ::g_HordeDirector.serialize().bossHealth);
PrintFmt("Boss Damage: {}\n", ::g_HordeDirector.serialize().bossDamage);
}
END_COMMAND(hordeinfo)
BEGIN_COMMAND(hordecooldownclear)
{
if (!G_IsHordeMode())
{
PrintFmt("Can't clear the horde cooldown list outside of horde mode.\n");
return;
}
if (network_game && clientside)
{
PrintFmt("{}", serveronlycmd);
return;
}
for (int clearlist = 0; clearlist < HORDECOOLDOWN_SIZE; clearlist++)
{
hordecooldown[clearlist] = NULL;
}
hordecoolcount = 0;
}
END_COMMAND(hordecooldownclear)
BEGIN_COMMAND(hordecooldownprint)
{
if (!G_IsHordeMode())
{
PrintFmt("Can't obtain horde cooldown list outside of horde mode.\n");
return;
}
if (network_game && clientside)
{
PrintFmt("{}", serveronlycmd);
return;
}
int printlist = hordecoolcount - g_horde_cooldown;
if (printlist < 0)
{
printlist = HORDECOOLDOWN_SIZE + printlist;
}
for (int counter = g_horde_cooldown; counter > 0; counter--)
{
if (hordecooldown[printlist] != NULL)
{
PrintFmt("{}\n", hordecooldown[printlist]);
}
printlist++;
if (printlist >= HORDECOOLDOWN_SIZE)
{
printlist = 0;
}
}
}
END_COMMAND(hordecooldownprint)