---@diagnostic disable: inject-field, undefined-field BUILDER_BOT_TEMPLATES = { Soldier_Spammer = { Class = CLASSES.Soldier, Name = "Rapid Fire Soldier", Skill = BOT_SKILL.EXPERT, ItemAttributes = { ["TF_WEAPON_ROCKETLAUNCHER"] = { ["Projectile speed increased"] = 0.55, ["faster reload rate"] = 0.0, ["fire rate bonus"] = 0.60, }, }, }, } local DISPOSABLE_BOT_TEMPLATE = { -- sentry buster Class = CLASSES.Demoman, IsSentryBuster = true, Name = "Mini Buster", Skill = BOT_SKILL.EXPERT, Health = 2500, WeaponRestrictions = WEAPON_RESTRICTIONS.MELEE_ONLY, ClassIcon = "sentry_buster", Attributes = { BOT_ATTRIBUTES.MINIBOSS, BOT_ATTRIBUTES.DISABLE_DODGE, }, UseBusterModel = true, Scale = 1, Mission = BOT_MISSIONS.MISSION_DESTROY_SENTRIES, Items = { "The Ullapool Caber" }, CharacterAttributes = { ["move speed bonus"] = 2, ["damage force reduction"] = 0.5, ["airblast vulnerability multiplier"] = 0.5, ["override footstep sound set"] = 7, ["cannot be backstabbed"] = 1, }, } local BOT_CONSTRUCTION_TIME = 5 local SPEED_BOOST_DISTANCE = 400 -- apply speed boost to bot when it's far away from owner local BOTS_ATTRIBUTES = { -- ["not solid to players"] = 1, -- prevents bot from taking teleporter ["collect currency on kill"] = 1, ["ammo regen"] = 10, } local BOTS_WRANGLED_ATTRIBUTES = { -- ["CARD: damage bonus"] = 1.3, ["fire rate bonus HIDDEN"] = 0.9, ["Reload time increased"] = 0.9, ["SET BONUS: move speed set bonus"] = 1.3, } -- if owner has these conds, apply them on the bot -- used to replicate canteen effects local REPLICATE_CONDS = { TF_COND_CRITBOOSTED_USER_BUFF, TF_COND_INVULNERABLE_USER_BUFF, } -- TODO: make sentry damage replicate to bot -- for making "sentry fire rate" upgrade replicate to bot as a different attribute local SENTRY_FIRERATE_REPLICATE_ATTR = "halloween fire rate bonus" -- should be a unique attribute that isn't applied by anything else local SENTRY_FIRERATE_REPLICATE_MULT = 1 local BOT_DISABLE_VISION_VSCRIPT = "activator.SetMaxVisionRangeOverride(0.1)" local BOT_ENABLE_VISION_VSCRIPT = "activator.SetMaxVisionRangeOverride(100000)" -- local BOT_CLEAR_FOCUS = "activator.ClearAttentionFocus()" local activeBots = {} local activeBuiltBotsByOwner = {} local ownerByActiveBuiltBot = {} local inWave = false local function waveInit() inWave = false for _, bot in pairs(activeBots) do bot:Suicide() bot.m_iTeamNum = 1 end activeBots = {} end if OnWaveInitFuncs then table.insert(OnWaveInitFuncs, waveInit) else function OnWaveInit() waveInit() end end local function waveStart() inWave = true -- bots spawned in prewave are put to spectate/gray team so they don't take up slot for _, bot in pairs(activeBots) do bot:ResetFakeSendProp("m_iTeamNum") bot.m_iTeamNum = 2 -- bot:RemoveCond(TF_COND_INVULNERABLE_HIDE_UNLESS_DAMAGED) bot:SetAttributeValue("not solid to players", nil) bot:SetAttributeValue("ignored by enemy sentries", nil) bot:SetAttributeValue("ignored by bots", nil) bot:SetAttributeValue("damage bonus HIDDEN", nil) end end if OnWaveStartFuncs then table.insert(OnWaveStartFuncs, waveStart) else function OnWaveStart() waveStart() end end local function checkOnHit(parent, damageinfo) local attacker = damageinfo.Attacker if not attacker then return end local handle = attacker:GetHandleIndex() local owner = ownerByActiveBuiltBot[handle] if not owner then return end if parent == owner then return end damageinfo.Attacker = owner return true end ents.AddCreateCallback("tank_boss", function(tank) tank:AddCallback(ON_DAMAGE_RECEIVED_PRE, function(_, damageinfo) return checkOnHit(tank, damageinfo) end) end) -- convert damage dealt by bots to owner -- and nullify damage taken by built bot during prewave local function addGlobalCallbacks(player) player:AddCallback(ON_DAMAGE_RECEIVED_PRE, function(_, damageinfo) local isBot = activeBots[player:GetHandleIndex()] if isBot and not inWave then -- nullify attacks in prewave damageinfo.Damage = 0 damageinfo.Inflictor = nil damageinfo.Weapon = nil return true end return checkOnHit(player, damageinfo) end) -- failsafe for spawning bot in setup player:AddCallback(ON_SPAWN, function() if player:IsRealPlayer() then return end timer.Simple(0.1, function() if inWave then return end local owner = ownerByActiveBuiltBot[player:GetHandleIndex()] if not owner then return end if player.m_iTeamNum == owner.m_iTeamNum then player:Suicide() end end) end) end function OnPlayerConnected(player) addGlobalCallbacks(player) end local function getEyeAngles(player) local pitch = player["m_angEyeAngles[0]"] local yaw = player["m_angEyeAngles[1]"] return Vector(pitch, yaw, 0) end local function getCursorPos(player, bot) local eyeAngles = getEyeAngles(player) util.StartLagCompensation(player) local DefaultTraceInfo = { start = player, distance = 100000, angles = eyeAngles, mask = MASK_SHOT, collisiongroup = COLLISION_GROUP_PLAYER, --COLLISION_GROUP_DEBRIS, filter = { player, bot }, } local trace = util.Trace(DefaultTraceInfo) util.FinishLagCompensation(player) return trace.HitPos end local function getMaxClip(bot) local activeWeapon = bot.m_hActiveWeapon local maxClip = activeWeapon.MaxClip return maxClip end local function startBotConstruction(owner, building, bot, isDisposable) if not inWave then -- (royal): this will crash the game if done with disposable if not isDisposable then building.m_iHealth = building.m_iMaxHealth end return end building.m_bBuilding = 1 building.m_iState = 0 -- constructing local buildRateMult = bot:GetAttributeValue("build rate bonus") or 1 local tickRate = 0.1 local maxHealth = building.m_iMaxHealth local constructionHealth = 1 local constructionHealthPerTick = (maxHealth - constructionHealth) / ((BOT_CONSTRUCTION_TIME * buildRateMult) / tickRate) bot.m_iHealth = 1 bot:AddCond(TF_COND_MVM_BOT_STUN_RADIOWAVE, 50000) local visualTimer visualTimer = timer.Create(0, function() if not IsValid(building) or not IsValid(bot) then timer.Stop(visualTimer) return end building.m_bBuilding = 1 building.m_flPercentageConstructed = constructionHealth / maxHealth building.m_iHealth = bot.m_iHealth building.m_iMaxHealth = maxHealth end, 0) local constructionTimer constructionTimer = timer.Create(tickRate, function() if not IsValid(building) or not IsValid(bot) then timer.Stop(constructionTimer) return end local previousConstructionHealth = constructionHealth constructionHealth = math.min(constructionHealth + constructionHealthPerTick, maxHealth) local healthGain = constructionHealth - previousConstructionHealth bot.m_iHealth = math.min(bot.m_iHealth + healthGain, constructionHealth) if constructionHealth >= maxHealth then timer.Stop(constructionTimer) timer.Stop(visualTimer) building.m_iHealth = maxHealth building.m_iMaxHealth = maxHealth building.m_flPercentageConstructed = 1 bot:RemoveCond(TF_COND_STUNNED) building.m_bBuilding = 0 building.m_iState = 1 -- ready end end, 0) end -- thanks mince local function forceSnapEyeAngle(botSpawn, cursorPos) local targetAngles = (cursorPos - (botSpawn:GetAbsOrigin() + Vector(0, 0, botSpawn["m_vecViewOffset[2]"]))):ToAngles() targetAngles = Vector(targetAngles[1], targetAngles[2], 0) botSpawn:SnapEyeAngles(targetAngles) end function BuildAllyBot(building, template) if not IsValid(building) then print("building was destroyed before logic could register lol") return end local owner = building.m_hBuilder local handle = owner:GetHandleIndex() local origin = building:GetAbsOrigin() local isDisposable = false if building.m_bDisposableBuilding == 1 then isDisposable = true end local newBuilding if isDisposable then newBuilding = building else newBuilding = ents.CreateWithKeys("obj_sentrygun", { defaultupgrade = 0, team = owner.m_iTeamNum, SolidToPlayer = 0, }, true) end newBuilding.ManagedBuilding = true newBuilding.m_bBuilding = 1 if not isDisposable then building:Remove() end newBuilding:SetBuilder(owner, owner, owner) newBuilding:SetAbsOrigin(Vector(0, 0, -10000)) newBuilding:Disable() template = Extend(template, { Name = template.Name .. " (" .. owner:GetPlayerName() .. ")", CharacterAttributes = BOTS_ATTRIBUTES, }) local botSpawn, callbacks = SpawnBot(template) if not botSpawn then owner:Print(PRINT_TARGET_CENTER, "GLOBAL BOT LIMIT REACHED") newBuilding:Remove() return end local botHandle = botSpawn:GetHandleIndex() do owner.BuiltBotHandle = tostring(botHandle) owner.BuiltBotSentry = tostring(newBuilding:GetHandleIndex()) activeBots[botHandle] = botSpawn activeBuiltBotsByOwner[handle] = botSpawn ownerByActiveBuiltBot[botHandle] = owner botSpawn.m_iszClassIcon = "" callbacks.builtBotDamaged = botSpawn:AddCallback(ON_DAMAGE_RECEIVED_POST, function() newBuilding.m_iHealth = botSpawn.m_iHealth end) callbacks.builtBotDied = botSpawn:AddCallback(ON_DEATH, function() owner.BuiltBotHandle = false owner.BuiltBotSentry = false activeBots[botHandle] = nil activeBuiltBotsByOwner[handle] = nil ownerByActiveBuiltBot[botHandle] = nil if IsValid(newBuilding) then newBuilding:Remove() end end) end botSpawn:AddCond(TF_COND_REPROGRAMMED) timer.Simple(0, function() if not IsValid(newBuilding) then print("newBuilding was destroyed before logic could happen") botSpawn:Suicide() return end botSpawn:SetAbsOrigin(origin) -- cache all weapons maxclip for _, weapon in pairs(botSpawn.m_hMyWeapons) do if weapon then weapon.MaxClip = weapon.m_iClip1 end end -- sync max health newBuilding.m_iMaxHealth = botSpawn.m_iHealth local teleParticle = ents.CreateWithKeys("info_particle_system", { effect_name = botSpawn.m_iTeamNum == TEAM_RED and "teleportedin_red" or "teleportedin_blue", start_active = 1, flag_as_weather = 0, }, true, true) teleParticle:SetAbsOrigin(botSpawn:GetAbsOrigin()) teleParticle:Start() timer.Simple(1, function() teleParticle:Remove() end) startBotConstruction(owner, newBuilding, botSpawn, isDisposable) newBuilding:AddCallback(ON_REMOVE, function() if not activeBots[botHandle] then return end botSpawn:Suicide() end) if not inWave then botSpawn:SetFakeSendProp("m_iTeamNum", 2) botSpawn.m_iTeamNum = 1 botSpawn:SetAttributeValue("not solid to players", 1) botSpawn:SetAttributeValue("ignored by enemy sentries", 1) botSpawn:SetAttributeValue("ignored by bots", 1) botSpawn:SetAttributeValue("damage bonus HIDDEN", 0.0001) end local spawnedCallback spawnedCallback = botSpawn:AddCallback(ON_SPAWN, function() if IsValid(newBuilding) then newBuilding:Remove() end botSpawn:RemoveCallback(spawnedCallback) spawnedCallback = nil end) local cursorPos = Vector(0, 0, 0) local lastWrangled = false -- bot behavior -- default behavior is always following you local logicLoop logicLoop = timer.Create(0.2, function() if not activeBots[botHandle] then timer.Stop(logicLoop) return end if not owner:IsAlive() then return end -- taunt with owner :D if owner:InCond(TF_COND_TAUNTING) then botSpawn["$Taunt"](botSpawn) end for _, cond in pairs(REPLICATE_CONDS) do if owner:InCond(cond) then botSpawn:AddCond(cond, 0.25, owner) end end local pda = owner:GetPlayerItemBySlot(LOADOUT_POSITION_PDA) local ownerFireRateUpgrade = pda:GetAttributeValue("engy sentry fire rate increased") if ownerFireRateUpgrade then botSpawn:SetAttributeValue( SENTRY_FIRERATE_REPLICATE_ATTR, ownerFireRateUpgrade * SENTRY_FIRERATE_REPLICATE_MULT ) else botSpawn:SetAttributeValue(SENTRY_FIRERATE_REPLICATE_ATTR, nil) end local botWeapon = botSpawn.m_hActiveWeapon local botWeaponClip = botWeapon.m_iClip1 if botWeaponClip then local maxClip = getMaxClip(botSpawn) local clipCalcMult = 150 / maxClip -- m_iAmmoShells max is 150 newBuilding.m_iAmmoShells = botWeaponClip * clipCalcMult end if owner.m_hActiveWeapon.m_iClassname == "tf_weapon_laser_pointer" then -- wrangle behavior: -- if attack is held: fire weapon -- if alt fire is held: move toward cursor while attacking independently if not lastWrangled then -- botSpawn:BotCommand("stop interrupt action") botSpawn:RunScriptCode(BOT_DISABLE_VISION_VSCRIPT, botSpawn) botSpawn:AddCond(TF_COND_ENERGY_BUFF) for name, value in pairs(BOTS_WRANGLED_ATTRIBUTES) do botSpawn:SetAttributeValue(name, value) end lastWrangled = true end botSpawn:RunScriptCode("activator.ClearAttentionFocus()", botSpawn) local altFireHeld = owner.m_nButtons & IN_ATTACK2 ~= 0 local attackHeld = owner.m_nButtons & IN_ATTACK ~= 0 cursorPos = getCursorPos(owner, botSpawn) if attackHeld then -- forceSnapEyeAngle(botSpawn, cursorPos) local targetAngles = (cursorPos - (botSpawn:GetAbsOrigin() + Vector( 0, 0, botSpawn["m_vecViewOffset[2]"] ))):ToAngles() targetAngles = Vector(targetAngles[1], targetAngles[2], 0) local snapVscript = ('NetProps.SetPropVector(activator, "pl.v_angle", QAngle(%f, %f, %f) + Vector())'):format( targetAngles.x, targetAngles.y, targetAngles.z ) local attackVscript = "activator.PrimaryAttack()" botSpawn:RunScriptCode(snapVscript, botSpawn) botSpawn.m_hActiveWeapon:RunScriptCode(attackVscript, botSpawn.m_hActiveWeapon) end if altFireHeld then local interruptAction = ("interrupt_action -pos %s %s %s -distance 1 -duration 0.1"):format( cursorPos[1], cursorPos[2], cursorPos[3] ) botSpawn:BotCommand(interruptAction) -- allow bot to attack when alt fire is held botSpawn:RunScriptCode(BOT_ENABLE_VISION_VSCRIPT, botSpawn) -- botSpawn:RemoveCond(TF_COND_ENERGY_BUFF) -- for name, _ in pairs(BOTS_WRANGLED_ATTRIBUTES) do -- botSpawn:SetAttributeValue(name, nil) -- end else botSpawn:RunScriptCode(BOT_DISABLE_VISION_VSCRIPT, botSpawn) -- botSpawn:AddCond(TF_COND_ENERGY_BUFF) -- for name, value in pairs(BOTS_WRANGLED_ATTRIBUTES) do -- botSpawn:SetAttributeValue(name, value) -- end end return end if lastWrangled then for name, _ in pairs(BOTS_WRANGLED_ATTRIBUTES) do botSpawn:SetAttributeValue(name, nil) end -- botSpawn:BotCommand("stop interrupt action") botSpawn:RunScriptCode(BOT_ENABLE_VISION_VSCRIPT, botSpawn) botSpawn:RemoveCond(TF_COND_ENERGY_BUFF) lastWrangled = false end local pos = owner:GetAbsOrigin() local distance = pos:Distance(botSpawn:GetAbsOrigin()) if distance >= SPEED_BOOST_DISTANCE then botSpawn:AddCond(TF_COND_SPEED_BOOST, 1) end local stringStart = "interrupt_action -switch_action Default" -- don't move if already close if distance <= 150 then botSpawn:BotCommand(stringStart .. " -duration 0.1") return end local interruptAction = ("%s -pos %s %s %s -duration 0.1"):format(stringStart, pos[1], pos[2], pos[3]) botSpawn:BotCommand(interruptAction) end, 0) local wranglerLogic wranglerLogic = timer.Create(0, function() if not activeBots[botHandle] then timer.Stop(wranglerLogic) return end if not owner.m_hActiveWeapon then return end if owner.m_hActiveWeapon.m_iClassname == "tf_weapon_laser_pointer" then -- wrangle behavior: -- if attack is held: fire weapon -- if alt fire is held: move toward cursor while attacking independently local altFireHeld = owner.m_nButtons & IN_ATTACK2 ~= 0 cursorPos = getCursorPos(owner, botSpawn) if not altFireHeld then -- local interruptAction = ("interrupt_action -lookpos %s %s %s -alwayslook -duration 0.14"):format( -- cursorPos[1], -- cursorPos[2], -- cursorPos[3] -- ) -- botSpawn:BotCommand(interruptAction) forceSnapEyeAngle(botSpawn, cursorPos) end return end end, 0) end) end function SentrySpawned(_activator, building) local template = BUILDER_BOT_TEMPLATES.Soldier_Spammer -- PLACEHOLDER BuildAllyBot(building, template) end AddEventCallback("player_used_powerup_bottle", function(eventTable) local netId = eventTable.player local canteenType = eventTable.type local AMMO_CANTEEN = 4 local player = ents.GetAllPlayers()[netId] local bot = activeBots[player:GetHandleIndex()] if not bot then return end if canteenType == AMMO_CANTEEN then local botWeapon = bot.m_hActiveWeapon local botWeaponClip = botWeapon.m_iClip1 if botWeaponClip then local maxClip = getMaxClip(bot) botWeapon.m_iClip1 = maxClip end end end) AddEventCallback("player_death", function(eventTable) local attacker = eventTable.attacker local player = ents.GetPlayerByUserId(attacker) local attackerBot = activeBots[player:GetHandleIndex()] if not attackerBot then return end local isBotKill = false local inflictor = Entity(eventTable.inflictor_entindex) if inflictor == attackerBot.m_hActiveWeapon then -- weapon isBotKill = true elseif inflictor.m_hLauncher then -- projectile local weaponOwner = inflictor.m_hLauncher.m_hOwnerEntity if weaponOwner == attackerBot then isBotKill = true end end if not isBotKill then return end local victim = Entity(eventTable.victim_entindex) local killAddition = 1 if victim.m_bIsMiniBoss ~= 0 then killAddition = 5 end ---@diagnostic disable-next-line: param-type-mismatch local sentry = Entity(tonumber(player.BuiltBotSentry)) sentry.m_iKills = sentry.m_iKills + killAddition end) ents.AddCreateCallback("obj_sentrygun", function(sentry) timer.Simple(0.1, function() if sentry.m_bDisposableBuilding ~= 1 then return end if sentry.ManagedBuilding then return end local owner = sentry.m_hBuilder if not owner or not IsValid(owner) then return end if not owner:IsRealPlayer() then return end local waitingForPlacedTimer waitingForPlacedTimer = timer.Create(0.015, function() if not IsValid(sentry) then timer.Stop(waitingForPlacedTimer) return end if sentry.m_bPlacing == 1 then return end timer.Stop(waitingForPlacedTimer) -- spawn disposable template BuildAllyBot(sentry, DISPOSABLE_BOT_TEMPLATE) end, 0) end) end)