-- meant to loaded externally -- sm_ent_fire popscript $ExecuteScriptFile "play_as_bot.lua"; sm_ent_fire popscript $ShowListActivator -- OR --sm_ent_create "$script_manager" "$scriptfile" "play_as_bot.lua"; sm_ent_fire "$script_manager" $ShowListActivator local INTERNAL_CLASS_ID = { [1] = "Scout", [2] = "Sniper", [3] = "Soldier", [4] = "Demoman", [5] = "Medic", [6] = "Heavyweapons", [7] = "Pyro", [8] = "Spy", [9] = "Engineer", } local FORCE_CHANGE_TEAM_BLUE_VSCRIPT = 'local gamerules = Entities.FindByClassname(null, "tf_gamerules"); NetProps.SetPropBool(gamerules, "m_bPlayingMannVsMachine", false); activator.ForceChangeTeam(%s, false); NetProps.SetPropBool(gamerules, "m_bPlayingMannVsMachine", true)' local FORCE_CHANGE_SIMPLE_VSCRIPT = "activator.ForceChangeTeam(%s, true)" local function findPlayerByAutocompleteName(name) local lowerName = string.lower(name) for _, player in pairs(ents.GetAllPlayers()) do if player:IsRealPlayer() then local playerName = player.m_szNetname if string.find(string.lower(playerName), lowerName, 1, true) then return player end end end end local function getHealers(player) local healers = {} for _, otherPlayer in pairs(ents.GetAllPlayers()) do local secondary = otherPlayer:GetPlayerItemBySlot(LOADOUT_POSITION_SECONDARY) if IsValid(secondary) and secondary.m_iClassname == "tf_weapon_medigun" then if secondary.m_hHealingTarget == player then table.insert(healers, otherPlayer) end end end return healers end local function getSlotOfWeapon(owner, item) for i = LOADOUT_POSITION_PRIMARY, LOADOUT_POSITION_PDA2 do local itemAtSlot = owner:GetPlayerItemBySlot(i) if itemAtSlot == item then return i end end return LOADOUT_POSITION_PRIMARY end local EXCLUDED_WEAPONS = { ["TF_WEAPON_PDA_ENGINEER_BUILD"] = true, ["TF_WEAPON_PDA_ENGINEER_DESTROY"] = true, ["TF_WEAPON_BUILDER"] = true, } local function getActiveBotTemplates() local templates = {} for _, player in pairs(ents.GetAllPlayers()) do if not player:IsRealPlayer() and player:IsAlive() then local weapons = {} local weaponsNetProps = {} local cosmetics = {} local activeWeaponName local activeWeaponSlot for _, item in pairs(player:GetAllItems()) do local itemName = item:GetItemName() if item:IsWearable() then cosmetics[itemName] = item:GetAllAttributeValues(false) else if not EXCLUDED_WEAPONS[itemName] then weapons[itemName] = item:GetAllAttributeValues(false) weaponsNetProps[itemName] = {} if item.m_flChargeLevel then weaponsNetProps[itemName].m_flChargeLevel = item.m_flChargeLevel end if item.m_flMinicritCharge then weaponsNetProps[itemName].m_flMinicritCharge = item.m_flMinicritCharge end if player.m_hActiveWeapon == item then activeWeaponName = itemName activeWeaponSlot = getSlotOfWeapon(player, item) end end end end local botTemplate = { Name = player.m_szNetname, Class = INTERNAL_CLASS_ID[player.m_iClass], Attributes = player:GetAllAttributeValues(false), Weapons = weapons, WeaponsNetProps = weaponsNetProps, AlwaysCrit = player:InCond(TF_COND_CRITBOOSTED_USER_BUFF), ActiveWeaponName = activeWeaponName, ActiveWeaponSlot = activeWeaponSlot, Cosmetics = cosmetics, Bot = player, } table.insert(templates, botTemplate) end end return templates end local function getEyeAngles(player) local pitch = player["m_angEyeAngles[0]"] local yaw = player["m_angEyeAngles[1]"] return Vector(pitch, yaw, 0) end function ShowControlMenu(activator) local CANCEL_INDEX = 1 local menu = { timeout = 0, title = "CONTROL", itemsPerPage = 10, flags = MENUFLAG_BUTTON_EXIT, onSelect = function(_player, index) if index == CANCEL_INDEX then if activator.PlayAsBotData then activator.CancelPlayAsBot = true end end end, } menu[CANCEL_INDEX] = { text = "CANCEL", value = "cancel", disabled = false, } activator:DisplayMenu(menu) end local cancelNextDeath = {} function ApplyTemplateToPlayer(activator, template) local bot = template.Bot local playerOriginalMaxHealth = activator.m_iMaxHealth local playerOriginalHealth = activator.m_iHealth local playerOriginalClass = activator.m_iClass local playerOriginalOrigin = activator:GetAbsOrigin() local playerOriginalTeam = activator.m_iTeamNum local botTeam = bot.m_iTeamNum activator.m_iTeamNum = botTeam if botTeam == TEAM_BLUE then activator:RunScriptCode((FORCE_CHANGE_TEAM_BLUE_VSCRIPT):format(botTeam), activator, activator) else activator:RunScriptCode((FORCE_CHANGE_SIMPLE_VSCRIPT):format(botTeam), activator, activator) end activator:ForceRespawnDead() activator:SwitchClassInPlace(template.Class) -- clear for _, item in pairs(activator:GetAllItems()) do local itemName = item:GetItemName() if not EXCLUDED_WEAPONS[itemName] then item:Remove() end end -- apply for itemName, itemAttributes in pairs(template.Weapons) do local item = activator:GiveItem(itemName) for name, value in pairs(itemAttributes) do item:SetAttributeValue(name, value) end local itemNetProps = template.WeaponsNetProps[itemName] if itemNetProps then for i, v in pairs(itemNetProps) do item[i] = v end end if template.ActiveWeaponName == itemName then local delay = 0.015 -- unnecessary -- if template.ActiveWeaponSlot and template.ActiveWeaponSlot == LOADOUT_POSITION_MELEE then -- delay = 0.5 -- end timer.Simple(delay, function() local originalDeployTimeAttr = item:GetAttributeValue("deploy time decreased") item:SetAttributeValue("deploy time decreased", 0.001) if template.ActiveWeaponSlot then activator:WeaponSwitchSlot(template.ActiveWeaponSlot) else print("MISSING ActiveWeaponSlot") end timer.Simple(0.1, function() item:SetAttributeValue("deploy time decreased", originalDeployTimeAttr) end) end) end end for itemName, itemAttributes in pairs(template.Cosmetics) do local item = activator:GiveItem(itemName, nil, true) for name, value in pairs(itemAttributes) do item:SetAttributeValue(name, value) end end for name, value in pairs(template.Attributes) do if name ~= "no_attack" then activator:SetAttributeValue(name, value) end end if template.AlwaysCrit then activator:AddCond(TF_COND_CRITBOOSTED_USER_BUFF) end activator.m_flRageMeter = bot.m_flRageMeter activator.m_flHypeMeter = bot.m_flHypeMeter activator.m_flEnergyDrinkMeter = bot.m_flEnergyDrinkMeter activator.m_bIsMiniBoss = bot.m_bIsMiniBoss activator.m_iMaxHealth = bot.m_iMaxHealth activator.m_iHealth = bot.m_iHealth if not activator.OriginalNetName then activator.OriginalNetName = activator.m_szNetname end local sessionId = TickCount() activator.PlayAsBotData = { OriginalBot = bot, SessionId = sessionId, } -- (royal): forcing player netname doesn't seem to work -- activator.m_szNetname = bot.m_szNetname activator:SetFakeClientConVar("name", bot.m_szNetname) local lastPlayerModel = activator.m_iszCustomModel local customModel = bot.m_iszCustomModel if customModel and customModel ~= "" then timer.Simple(0.015, function() activator:SetCustomModelWithClassAnimations(customModel) end) end local scale = tonumber(bot.m_flModelScale) activator.m_flModelScale = scale activator:SnapEyeAngles(getEyeAngles(bot)) local function getActivatorEyeOrigin() local feet = activator:GetAbsOrigin() local offset = Vector(activator.m_vecViewOffset[1], activator.m_vecViewOffset[2], activator.m_vecViewOffset[3]) return feet + (offset / 2) end -- sync position activator:SetAbsOrigin(bot:GetAbsOrigin()) -- handle original bot -- model scale 0 can crash when switching to steel gauntlets bot.m_flModelScale = 0.3 -- make bot tiny while it's attached to player -- (royal): a low model scale (<0.1) demolishes server performance, make only player see small model bot:SetFakeSendProp("m_flModelScale", 0.01) local originalNoAttack = bot:GetAttributeValue("no_attack") or 0 local originalCannotPickupIntelligence = bot:GetAttributeValue("cannot pick up intelligence") or 0 bot:SetAttributeValue("no_attack", 1) bot:SetAttributeValue("cannot pick up intelligence", 1) activator:SetAttributeValue("use robot voice", 1) -- inherit buildings from engie bot, currently disabled local ownedBuildings = {} local function handleOwnedBuilding(building) if building.m_hBuilder ~= bot then return end table.insert(ownedBuildings, building) -- building:AcceptInput("SetBuilder", nil, activator) -- bot:SetFakeSendProp("m_hBuilder", bot) -- show original bot's name end for _, obj in pairs(ents.FindAllByClass("obj_sentrygun")) do handleOwnedBuilding(obj) end for _, obj in pairs(ents.FindAllByClass("obj_dispenser")) do handleOwnedBuilding(obj) end for _, obj in pairs(ents.FindAllByClass("obj_teleporter")) do handleOwnedBuilding(obj) end local carriedBomb = nil for _, bomb in pairs(ents.FindAllByClass("item_teamflag")) do local bombOwner = bomb.m_hPrevOwner if bombOwner and bot == bombOwner then carriedBomb = bomb break end end local healers = getHealers(bot) local originalBotTimer local callbacks = {} local function clearCallbacks() for _, callbackId in pairs(callbacks) do activator:RemoveCallback(callbackId) end end local function handleDespawn(died) clearCallbacks() -- activator.m_szNetname = activator.OriginalNetName activator:SetFakeClientConVar("name", activator.OriginalNetName) bot:RemoveCond(TF_COND_INVULNERABLE) bot.m_flModelScale = scale bot:ResetFakeSendProp("m_flModelScale") bot:SetAbsOrigin(activator:GetAbsOrigin()) activator:SetAttributeValue("use robot voice", nil) for _, building in pairs(ownedBuildings) do if IsValid(building) then -- building.m_hBuilder = bot -- building:AcceptInput("SetBuilder", nil, bot) -- bot:ResetFakeSendProp("m_hBuilder") end end local function stopActivatorDiedSound() timer.Simple(0, function() activator:StopSound("MvM.PlayerDied") local tfGamerules = ents.FindByClass("tf_gamerules") if tfGamerules then tfGamerules:StopSound("MvM.PlayerDied") end end) end if not died then bot:SetAttributeValue("no_attack", originalNoAttack) bot:SetAttributeValue("cannot pick up intelligence", originalCannotPickupIntelligence) else stopActivatorDiedSound() timer.Simple(0, function() cancelNextDeath[bot:GetUserId()] = true bot:Suicide() end) -- kill bot -- for some reason, this will set activator to nil and cause timer to become invalid -- bot:TakeDamage({ -- Attacker = activator, -- Inflictor = nil, -- Weapon = nil, -- Damage = 9999999, -- DamageType = DMG_GENERIC, -- DamageCustom = TF_DMG_CUSTOM_NONE, -- DamagePosition = Vector(0, 0, 0), -- DamageForce = Vector(0, 0, 0), -- ReportedPosition = Vector(0, 0, 0), -- }) end if carriedBomb and IsValid(carriedBomb) and carriedBomb.m_nFlagStatus == 2 then carriedBomb:ForceDrop() end if originalBotTimer then local success, err = pcall(function() timer.Stop(originalBotTimer) end) if not success then print("failed to stop originalBotTimer: " .. err) end originalBotTimer = nil end if activator.PlayAsBotData and activator.PlayAsBotData.SessionId == sessionId then activator.PlayAsBotData = nil end activator.m_iTeamNum = playerOriginalTeam -- if playerOriginalTeam == TEAM_BLUE then -- activator:RunScriptCode((FORCE_CHANGE_TEAM_BLUE_VSCRIPT):format(playerOriginalTeam), activator, activator) -- else -- activator:RunScriptCode((FORCE_CHANGE_SIMPLE_VSCRIPT):format(playerOriginalTeam), activator, activator) -- end activator:SetCustomModelWithClassAnimations(lastPlayerModel) if playerOriginalTeam ~= TEAM_SPECTATOR then timer.Simple(0.1, function() activator:ForceRespawn() activator:SwitchClassInPlace(INTERNAL_CLASS_ID[playerOriginalClass]) activator.m_iMaxHealth = playerOriginalMaxHealth activator.m_iHealth = playerOriginalHealth timer.Simple(0.015, function() activator:SetAbsOrigin(playerOriginalOrigin) end) end) else timer.Simple(0, function() activator.m_bIsMiniBoss = 0 cancelNextDeath[activator:GetUserId()] = true activator:SetAbsOrigin(Vector(0, 0, -500)) activator:Suicide() stopActivatorDiedSound() end) end timer.Simple(0.1, function() ShowList(activator) end) end callbacks.Spawned = activator:AddCallback(ON_SPAWN, function() handleDespawn(false) end) callbacks.Died = activator:AddCallback(ON_DEATH, function() handleDespawn(true) end) activator:Print(PRINT_TARGET_CENTER, "Press '+use_action_slot_item' to open control menu") originalBotTimer = timer.Create(0.015, function() if originalBotTimer == nil then -- failsafe return false end if not IsValid(bot) or not bot:IsAlive() then timer.Stop(originalBotTimer) originalBotTimer = nil return end if activator.CancelPlayAsBot then activator.CancelPlayAsBot = nil handleDespawn(false) return end bot:AddCond(TF_COND_INVULNERABLE) bot:SetAbsOrigin(activator:GetAbsOrigin()) bot.m_iMaxHealth = activator.m_iMaxHealth bot.m_iHealth = activator.m_iHealth if carriedBomb and carriedBomb.m_hPrevOwner ~= activator then carriedBomb:ForceDrop() carriedBomb:SetAbsOrigin(activator:GetAbsOrigin()) end for i, medicBot in pairs(healers) do if not IsValid(medicBot) or not medicBot:IsAlive() then table.remove(healers, i) break end local secondary = medicBot:GetPlayerItemBySlot(LOADOUT_POSITION_SECONDARY) secondary.m_hHealingTarget = activator end local holdingCanteenKey = activator.m_bUsingActionSlot == 1 if holdingCanteenKey then ShowControlMenu(activator) end end, 0) end function ShowList(activator) local allTemplates = getActiveBotTemplates() local refreshIndex = #allTemplates + 1 local menu = { timeout = 0, title = "PLAY AS BOT", itemsPerPage = 10, flags = MENUFLAG_BUTTON_EXIT, onSelect = function(_player, index) if index == refreshIndex then ShowList(activator) return end local templateData = allTemplates[index] ApplyTemplateToPlayer(activator, templateData) end, } for i, data in ipairs(allTemplates) do menu[i] = { text = data.Name, value = data.Name, disabled = false, } end menu[refreshIndex] = { text = "REFRESH MENU", value = "refresh", disabled = false, } activator:DisplayMenu(menu) end function ShowListActivator(_, activator) ShowList(activator) end function ShowListPlayer(playerName) local player = findPlayerByAutocompleteName(playerName) if not player then print("could not find any player matching that name") return end ShowList(player) end function BindToggle(activator) timer.Create(0.1, function() if not IsValid(activator) then return false end if activator.PlayAsBotData then return end local holdingCanteenKey = activator.m_bUsingActionSlot == 1 if holdingCanteenKey then ShowList(activator) end end, 0) end function BindToggleToPlayer(playerName) local player = findPlayerByAutocompleteName(playerName) if not player then print("could not find any player matching that name") return end BindToggle(player) end -- credit kills to original bot, avoid suspicion from killfeed AddEventCallback("player_death", function(eventTable) local function handleVictim() local userId = eventTable.userid if not userId then return false end if cancelNextDeath[userId] then cancelNextDeath[userId] = nil return "Stop" end local victim = ents.GetPlayerByUserId(userId) if not victim then return false end local data = victim.PlayAsBotData if not data then return false end eventTable.userid = data.OriginalBot:GetUserId() return true end local function handleAttacker() local idAttacker = eventTable.attacker if not idAttacker then return false end local attacker = ents.GetPlayerByUserId(idAttacker) if not attacker then return false end local data = attacker.PlayAsBotData if not data then return false end eventTable.attacker = data.OriginalBot:GetUserId() timer.Simple(1.6, function() -- TODO: make this a little better timer.Create(0.015, function() FireEvent("hide_freezepanel", {}) end, 100) end) return true end local result1 = handleVictim() if result1 == "Stop" then return ACTION_STOP end local result2 = handleAttacker() if result1 or result2 then return ACTION_MODIFY end end) -- (royal): doesn't work AddEventCallback("show_freezepanel", function(eventTable) local killerEntIndex = eventTable.killer print("show_freezepanel: ", tostring(killerEntIndex)) if not killerEntIndex then return end local killer = Entity(killerEntIndex) if not IsValid(killer) then print("no killer found with ent index " .. killerEntIndex) return end local data = killer.PlayAsBotData if not data then return end eventTable.killer = data.OriginalBot:GetHandleIndex() return ACTION_MODIFY end) ents.AddCreateCallback("entity_revive_marker", function(ent) timer.Simple(0, function() if ent.m_iTeamNum == TEAM_BLUE or ent.m_iTeamNum == TEAM_SPECTATOR then ent:Remove() end end) end)