mirror of
https://github.com/odamex/odamex
synced 2026-08-19 02:23:09 -04:00
1292 lines
31 KiB
C++
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)
|