#[cfg(test)]
#[path = "../tests/tf2/bosses.rs"]
mod tests;
use crate::datatables::NetPropError;
use crate::entities::Entity;
use crate::entities::health::HealthError;
use crate::entities::spawn::{EntitySpawn, SpawnError};
use crate::inputs::{InputError, InputValue};
use crate::interfaces::ServerTools;
use crate::math::Vector;
use crate::tf2::script_binding::{self as binding, BindingError, VOID};
use crate::tf2::teams::Team;
use crate::{Game, InterfaceError, Server};
use sdk_raw::players::TEAM_UNASSIGNED;
use sdk_raw::tf2::bosses::{self as raw, skeleton};
use sdk_raw::tf2::scoreboard::{TF_TEAM_BLUE, TF_TEAM_RED};
use sdk_raw::tf2::script_binding::boolean;
use sdk_raw::vcall;
use std::ffi::{CStr, CString, c_int};
use std::fmt::Display;
const FULL_BAR: c_int = 255;
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum BossError {
#[error("bosses require Team Fortress 2")]
UnsupportedGame,
#[error("the entity is not a {class:?}")]
WrongClass {
class: &'static CStr,
},
#[error("the entity is marked for deletion")]
MarkedForDeletion,
#[error("a value given is NaN or infinite")]
NonFinite,
#[error("health {0} is not positive")]
NonPositiveHealth(c_int),
#[error("the model is not precached")]
ModelNotPrecached,
#[error("the game does not expose the expected native method")]
UnsupportedMethod,
#[error("the native method rejected the call")]
Rejected,
#[error(transparent)]
Health(#[from] HealthError),
#[error(transparent)]
Input(#[from] InputError),
#[error(transparent)]
Interface(#[from] InterfaceError),
#[error(transparent)]
NetProp(#[from] NetPropError),
#[error(transparent)]
Spawn(#[from] SpawnError),
}
impl From<BindingError> for BossError {
fn from(error: BindingError) -> Self {
match error {
BindingError::Unavailable | BindingError::SignatureMismatch => Self::UnsupportedMethod,
BindingError::Rejected => Self::Rejected,
}
}
}
#[doc(alias("base_boss", "CTFBaseBoss"))]
#[derive(Debug, Clone, Copy)]
pub struct BaseBoss<'s> {
server: Server<'s>,
entity: Entity<'s>,
}
impl<'s> BaseBoss<'s> {
pub fn new(server: Server<'s>, entity: Entity<'s>) -> Result<Self, BossError> {
check_class(server, entity, raw::BASE_BOSS_CLASS)?;
Ok(Self { server, entity })
}
pub fn spawn(server: Server<'s>, spawn: &BaseBossSpawn<'_>) -> Result<Self, BossError> {
check_game(server)?;
if !spawn.origin.is_finite() || !spawn.speed.is_none_or(f32::is_finite) {
return Err(BossError::NonFinite);
}
if spawn.health <= 0 {
return Err(BossError::NonPositiveHealth(spawn.health));
}
if server.model_info()?.model_index(spawn.model).is_none() {
return Err(BossError::ModelNotPrecached);
}
let mut boss = EntitySpawn::new(raw::BASE_BOSS)
.origin(spawn.origin)
.model(spawn.model)
.key(c"health", &number_value(spawn.health))
.key(c"teamnumber", &number_value(spawn.team.to_raw()))
.key(c"start_disabled", flag_value(spawn.start_disabled));
if let Some(speed) = spawn.speed {
boss = boss.key(c"speed", &number_value(speed));
}
let entity = unsafe { boss.spawn(server.server_tools()?) }?;
Ok(Self { server, entity })
}
#[doc(alias("Disable"))]
pub fn disable(self) -> Result<(), BossError> {
self.input(c"Disable", InputValue::Void)
}
#[doc(alias("Enable"))]
pub fn enable(self) -> Result<(), BossError> {
self.input(c"Enable", InputValue::Void)
}
pub const fn entity(self) -> Entity<'s> {
self.entity
}
fn input(self, name: &CStr, value: InputValue<'_>) -> Result<(), BossError> {
let tools = self.server.server_tools()?;
Ok(tools.accept_input(self.entity, name, value, self.entity, self.entity)?)
}
#[doc(alias("SetMaxJumpHeight"))]
pub fn set_max_jump_height(self, height: f32) -> Result<(), BossError> {
self.input(c"SetMaxJumpHeight", InputValue::Float(height))
}
#[doc(alias("SetResolvePlayerCollisions"))]
pub fn set_resolve_player_collisions(self, resolve: bool) -> Result<(), BossError> {
check_live(self.entity)?;
unsafe {
binding::call(
self.entity,
raw::BASE_BOSS_CLASS,
c"SetResolvePlayerCollisions",
&mut [boolean(resolve)],
VOID,
)
}?;
Ok(())
}
#[doc(alias("SetSpeed"))]
pub fn set_speed(self, speed: f32) -> Result<(), BossError> {
self.input(c"SetSpeed", InputValue::Float(speed))
}
#[doc(alias("SetStepHeight"))]
pub fn set_step_height(self, height: f32) -> Result<(), BossError> {
self.input(c"SetStepHeight", InputValue::Float(height))
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BaseBossSpawn<'a> {
pub origin: Vector,
pub model: &'a CStr,
pub health: c_int,
pub speed: Option<f32>,
pub team: Team,
pub start_disabled: bool,
}
impl<'a> BaseBossSpawn<'a> {
pub const fn new(origin: Vector, model: &'a CStr, health: c_int) -> Self {
Self {
origin,
model,
health,
speed: None,
team: Team::Unassigned,
start_disabled: false,
}
}
}
#[doc(alias("monster_resource", "CMonsterResource"))]
#[derive(Debug, Clone, Copy)]
pub struct BossBar<'s> {
server: Server<'s>,
entity: Entity<'s>,
}
impl<'s> BossBar<'s> {
pub fn new(server: Server<'s>, entity: Entity<'s>) -> Result<Self, BossError> {
check_class(server, entity, raw::MONSTER_RESOURCE_CLASS)?;
Ok(Self { server, entity })
}
pub fn find(server: Server<'s>) -> Result<Option<Self>, BossError> {
check_game(server)?;
let tools = server.server_tools()?;
let mut found = tools.find_by_class_name(None, raw::MONSTER_RESOURCE);
while let Some(entity) = found {
if let Ok(bar) = Self::new(server, entity) {
return Ok(Some(bar));
}
found = tools.find_by_class_name(Some(entity), raw::MONSTER_RESOURCE);
}
Ok(None)
}
pub const fn entity(self) -> Entity<'s> {
self.entity
}
#[doc(alias("m_iBossHealthPercentageByte", "GetBossHealthPercentage"))]
pub fn health(self) -> Result<f32, BossError> {
self.fraction(c"m_iBossHealthPercentageByte")
}
#[doc(alias("HideBossHealthMeter"))]
pub fn hide(self) -> Result<(), BossError> {
self.set_fraction(c"m_iBossHealthPercentageByte", 0.0)?;
self.set_fraction(c"m_iBossStunPercentageByte", 0.0)
}
#[doc(alias("m_iBossState"))]
pub fn is_inactive(self) -> Result<bool, BossError> {
Ok(self.prop(c"m_iBossState")? != 0)
}
pub fn is_shown(self) -> Result<bool, BossError> {
Ok(self.prop(c"m_iBossHealthPercentageByte")? > 0)
}
#[doc(alias("SetBossHealthPercentage", "m_iBossHealthPercentageByte"))]
pub fn set_health(self, fraction: f32) -> Result<(), BossError> {
self.set_fraction(c"m_iBossHealthPercentageByte", fraction)
}
#[doc(alias("SetBossState", "m_iBossState"))]
pub fn set_inactive(self, inactive: bool) -> Result<(), BossError> {
self.set_prop(c"m_iBossState", c_int::from(inactive))
}
#[doc(alias("SetBossStunPercentage", "m_iBossStunPercentageByte"))]
pub fn set_stun(self, fraction: f32) -> Result<(), BossError> {
self.set_fraction(c"m_iBossStunPercentageByte", fraction)
}
#[doc(alias("m_iBossStunPercentageByte", "GetBossStunPercentage"))]
pub fn stun(self) -> Result<f32, BossError> {
self.fraction(c"m_iBossStunPercentageByte")
}
fn fraction(self, name: &CStr) -> Result<f32, BossError> {
Ok(self.prop(name)?.clamp(0, FULL_BAR) as f32 / FULL_BAR as f32)
}
fn prop(self, name: &CStr) -> Result<c_int, BossError> {
check_live(self.entity)?;
let prop = self
.server
.server_game_dll()?
.entity_net_prop(self.entity, name)?;
Ok(prop.get::<c_int>(self.entity)?)
}
fn set_fraction(self, name: &CStr, fraction: f32) -> Result<(), BossError> {
if !fraction.is_finite() {
return Err(BossError::NonFinite);
}
self.set_prop(name, (fraction.clamp(0.0, 1.0) * FULL_BAR as f32) as c_int)
}
fn set_prop(self, name: &CStr, value: c_int) -> Result<(), BossError> {
check_live(self.entity)?;
let prop = self
.server
.server_game_dll()?
.entity_net_prop(self.entity, name)?;
Ok(unsafe { prop.set(self.server.valve_engine()?, self.entity, value) }?)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BossTeam {
#[doc(alias("TF_TEAM_RED"))]
Red,
#[doc(alias("TF_TEAM_BLUE"))]
Blue,
#[doc(alias("TF_TEAM_HALLOWEEN"))]
Halloween,
}
impl BossTeam {
pub const fn from_raw(raw: c_int) -> Option<Self> {
match raw {
TF_TEAM_RED => Some(Self::Red),
TF_TEAM_BLUE => Some(Self::Blue),
raw::TF_TEAM_HALLOWEEN => Some(Self::Halloween),
_ => None,
}
}
pub const fn to_raw(self) -> c_int {
match self {
Self::Red => TF_TEAM_RED,
Self::Blue => TF_TEAM_BLUE,
Self::Halloween => raw::TF_TEAM_HALLOWEEN,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HalloweenBoss {
#[doc(alias("headless_hatman", "CHeadlessHatman", "HHH"))]
Horsemann,
#[doc(alias("eyeball_boss", "CEyeballBoss"))]
Monoculus,
#[doc(alias("merasmus", "CMerasmus"))]
Merasmus,
}
impl HalloweenBoss {
pub const ALL: [Self; 3] = [Self::Horsemann, Self::Monoculus, Self::Merasmus];
pub const fn class_name(self) -> &'static CStr {
match self {
Self::Horsemann => raw::HEADLESS_HATMAN,
Self::Monoculus => raw::EYEBALL_BOSS,
Self::Merasmus => raw::MERASMUS,
}
}
}
#[doc(alias("tf_zombie_spawner", "CZombieSpawner"))]
#[derive(Debug, Clone, Copy)]
pub struct SkeletonSpawner<'s> {
server: Server<'s>,
entity: Entity<'s>,
}
impl<'s> SkeletonSpawner<'s> {
pub fn new(server: Server<'s>, entity: Entity<'s>) -> Result<Self, BossError> {
check_class(server, entity, raw::TF_ZOMBIE_SPAWNER_CLASS)?;
Ok(Self { server, entity })
}
pub fn spawn(
server: Server<'s>,
origin: Vector,
spawn: &SkeletonSpawn,
) -> Result<Self, BossError> {
check_game(server)?;
if !origin.is_finite() || !spawn.lifetime.is_none_or(f32::is_finite) {
return Err(BossError::NonFinite);
}
let team = match spawn.team {
BossTeam::Red => TF_TEAM_RED,
BossTeam::Blue => TF_TEAM_BLUE,
BossTeam::Halloween => TEAM_UNASSIGNED,
};
let spawner = EntitySpawn::new(raw::TF_ZOMBIE_SPAWNER)
.origin(origin)
.key(c"teamnumber", &number_value(team))
.key(c"zombie_type", &number_value(spawn.kind.to_raw()))
.key(c"max_zombies", &number_value(spawn.count))
.key(c"infinite_zombies", flag_value(spawn.infinite))
.key(
c"zombie_lifetime",
&number_value(spawn.lifetime.unwrap_or(0.0)),
);
let entity = unsafe { spawner.spawn(server.server_tools()?) }?;
Ok(Self { server, entity })
}
#[doc(alias("Disable"))]
pub fn disable(self) -> Result<(), BossError> {
self.input(c"Disable", InputValue::Void)
}
#[doc(alias("Enable"))]
pub fn enable(self) -> Result<(), BossError> {
self.input(c"Enable", InputValue::Void)
}
pub const fn entity(self) -> Entity<'s> {
self.entity
}
fn input(self, name: &CStr, value: InputValue<'_>) -> Result<(), BossError> {
let tools = self.server.server_tools()?;
Ok(tools.accept_input(self.entity, name, value, self.entity, self.entity)?)
}
#[doc(alias("SetMaxActiveZombies", "max_zombies"))]
pub fn set_count(self, count: c_int) -> Result<(), BossError> {
self.input(c"SetMaxActiveZombies", InputValue::Int(count))
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SkeletonSpawn {
pub kind: SkeletonType,
pub team: BossTeam,
pub count: c_int,
pub infinite: bool,
pub lifetime: Option<f32>,
}
impl SkeletonSpawn {
pub const fn new(kind: SkeletonType) -> Self {
Self {
kind,
team: BossTeam::Halloween,
count: 1,
infinite: false,
lifetime: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SkeletonType {
#[doc(alias("SKELETON_NORMAL"))]
Normal,
#[doc(alias("SKELETON_KING"))]
King,
#[doc(alias("SKELETON_MINI"))]
Mini,
}
impl SkeletonType {
pub const fn from_raw(raw: c_int) -> Option<Self> {
match raw {
skeleton::SKELETON_NORMAL => Some(Self::Normal),
skeleton::SKELETON_KING => Some(Self::King),
skeleton::SKELETON_MINI => Some(Self::Mini),
_ => None,
}
}
pub const fn to_raw(self) -> c_int {
match self {
Self::Normal => skeleton::SKELETON_NORMAL,
Self::King => skeleton::SKELETON_KING,
Self::Mini => skeleton::SKELETON_MINI,
}
}
}
pub fn spawn_halloween_boss<'s>(
server: Server<'s>,
boss: HalloweenBoss,
origin: Vector,
team: BossTeam,
health: Option<c_int>,
) -> Result<Entity<'s>, BossError> {
check_game(server)?;
if !origin.is_finite() {
return Err(BossError::NonFinite);
}
if let Some(health) = health
&& health <= 0
{
return Err(BossError::NonPositiveHealth(health));
}
let entity = unsafe { spawn_on_team(server.server_tools()?, boss.class_name(), origin, team) }?;
if let Some(health) = health {
entity.set_max_health(server, health)?;
entity.set_health(server, health)?;
}
Ok(entity)
}
#[doc(alias("tf_zombie", "CZombie"))]
pub fn spawn_skeleton<'s>(
server: Server<'s>,
origin: Vector,
team: BossTeam,
) -> Result<Entity<'s>, BossError> {
check_game(server)?;
if !origin.is_finite() {
return Err(BossError::NonFinite);
}
unsafe { spawn_on_team(server.server_tools()?, raw::TF_ZOMBIE, origin, team) }
}
fn check_class(
server: Server<'_>,
entity: Entity<'_>,
class: &'static CStr,
) -> Result<(), BossError> {
check_game(server)?;
if entity.has_data_map_class(class) {
Ok(())
} else {
Err(BossError::WrongClass { class })
}
}
fn check_game(server: Server<'_>) -> Result<(), BossError> {
if server.game() == Game::TeamFortress2 {
Ok(())
} else {
Err(BossError::UnsupportedGame)
}
}
fn check_live(entity: Entity<'_>) -> Result<(), BossError> {
if entity.is_marked_for_deletion() {
Err(BossError::MarkedForDeletion)
} else {
Ok(())
}
}
unsafe fn change_team(entity: Entity<'_>, team: BossTeam) {
let raw = entity.as_ptr();
unsafe {
vcall!(raw as sys::CBaseEntity__bindgen_vtable => CBaseEntity_ChangeTeam(team.to_raw()))
};
}
unsafe fn spawn_on_team<'s>(
tools: ServerTools<'s>,
class: &CStr,
origin: Vector,
team: BossTeam,
) -> Result<Entity<'s>, BossError> {
let entity =
unsafe { tools.create_entity_by_name(class) }.ok_or_else(|| SpawnError::UnknownClass {
class: class.to_owned(),
})?;
if !tools.set_key_value(entity, c"origin", &vector_value(origin)) {
let _ = tools.remove(entity);
return Err(SpawnError::KeyRejected {
key: c"origin".to_owned(),
}
.into());
}
unsafe { change_team(entity, team) };
unsafe { tools.dispatch_spawn(entity) };
if entity.is_marked_for_deletion() {
return Err(SpawnError::RemovedItself.into());
}
Ok(entity)
}
fn flag_value(flag: bool) -> &'static CStr {
if flag { c"1" } else { c"0" }
}
fn number_value(number: impl Display) -> CString {
CString::new(number.to_string()).expect("numbers contain no NUL")
}
fn vector_value(vector: Vector) -> CString {
let value = format!("{} {} {}", vector.x, vector.y, vector.z);
CString::new(value).expect("numbers contain no NUL")
}